spring
2 天以前 930d38ed2a3c2131be3305a585602c7a5a275fe3
Merge branch 'dev-new_pro_OA' of http://114.132.189.42:9002/r/product-inventory-management into dev-new_pro_OA
已添加6个文件
已修改8个文件
3532 ■■■■ 文件已修改
src/api/officeProcessAutomation/approvalInstance.js 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/officeProcessAutomation/approvalTemplate.js 63 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-list/approveListConstants.js 686 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-list/components/ApproveDetailPanel.vue 117 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-list/components/FormPayloadFields.vue 116 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-list/components/InstanceFlowDisplay.vue 147 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-list/index.vue 310 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-list/useApproveList.js 465 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-template/approveTemplateConstants.js 311 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-template/components/FormConfigEditor.vue 624 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-template/components/TemplateFlowEditor.vue 16 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-template/formConfigUtils.js 278 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-template/index.vue 89 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-template/useApproveTemplate.js 263 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/officeProcessAutomation/approvalInstance.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,47 @@
import request from "@/utils/request";
/** åˆ†é¡µæŸ¥è¯¢å®¡æ‰¹å®žä¾‹ */
export function listApprovalInstancePage(params) {
  return request({
    url: "/approvalInstance/listPage",
    method: "get",
    params,
  });
}
/** æäº¤/保存审批实例 */
export function saveApprovalInstance(approvalInstanceDto) {
  return request({
    url: "/approvalInstance/save",
    method: "post",
    data: approvalInstanceDto,
  });
}
/** æ›´æ–°å®¡æ‰¹å®žä¾‹ */
export function updateApprovalInstance(approvalInstanceDto) {
  return request({
    url: "/approvalInstance/update",
    method: "put",
    data: approvalInstanceDto,
  });
}
/** å®¡æ‰¹ï¼ˆé€šè¿‡/驳回) */
export function approveApprovalInstance(approvalInstanceDto) {
  return request({
    url: "/approvalInstance/approve",
    method: "post",
    data: approvalInstanceDto,
  });
}
/** åˆ é™¤å®¡æ‰¹å®žä¾‹ï¼ˆbody ä¸º ID æ•°ç»„) */
export function deleteApprovalInstance(ids) {
  const idList = (Array.isArray(ids) ? ids : [ids]).filter((id) => id != null && id !== "");
  return request({
    url: "/approvalInstance/delete",
    method: "delete",
    data: idList,
  });
}
src/api/officeProcessAutomation/approvalTemplate.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,63 @@
import request from "@/utils/request";
/** æ¨¡æ¿ç±»åž‹ï¼š0 ç³»ç»Ÿå†…置,1 è‡ªå®šä¹‰ï¼ˆä¸ŽåŽç«¯ templateType ä¸€è‡´ï¼‰ */
export const TEMPLATE_TYPE_BUILTIN = 0;
export const TEMPLATE_TYPE_CUSTOM = 1;
export const TEMPLATE_TYPE_OPTIONS = [
  { value: TEMPLATE_TYPE_BUILTIN, label: "系统内置" },
  { value: TEMPLATE_TYPE_CUSTOM, label: "自定义" },
];
/** æŸ¥è¯¢æ‰€æœ‰å®¡æ‰¹æ¨¡æ¿ */
export function listApprovalTemplate(type) {
  return request({
    url: `/approvalTemplate/list/${type}`,
    method: "get",
  });
}
/** åˆ†é¡µæŸ¥è¯¢å®¡æ‰¹æ¨¡æ¿ */
export function listApprovalTemplatePage(params) {
  return request({
    url: "/approvalTemplate/listPage",
    method: "get",
    params,
  });
}
/** æŸ¥è¯¢å®¡æ‰¹æ¨¡æ¿è¯¦æƒ… */
export function getApprovalTemplateDetail(id) {
  return request({
    url: `/approvalTemplate/detail/${id}`,
    method: "get",
  });
}
/** æ–°å¢žå®¡æ‰¹æ¨¡æ¿ï¼ˆbody ä¸º ApprovalTemplateDto) */
export function addApprovalTemplate(approvalTemplateDto) {
  return request({
    url: "/approvalTemplate/add",
    method: "post",
    data: approvalTemplateDto,
  });
}
/** ä¿®æ”¹å®¡æ‰¹æ¨¡æ¿ï¼ˆbody ä¸º ApprovalTemplateDto) */
export function updateApprovalTemplate(approvalTemplateDto) {
  return request({
    url: "/approvalTemplate/update",
    method: "put",
    data: approvalTemplateDto,
  });
}
/** åˆ é™¤å®¡æ‰¹æ¨¡æ¿ï¼ˆbody ä¸ºæ¨¡æ¿ ID æ•°ç»„) */
export function deleteApprovalTemplate(ids) {
  const idList = (Array.isArray(ids) ? ids : [ids]).filter((id) => id != null && id !== "");
  return request({
    url: "/approvalTemplate/delete",
    method: "post",
    data: idList,
  });
}
src/views/officeProcessAutomation/ApproveManage/approve-list/approveListConstants.js
@@ -1,4 +1,13 @@
import dayjs from "dayjs";
import {
  createEmptyNode,
  formatDisplayTime,
  mapNodesFromApi,
  mapSignModeFromApi,
  mapSignModeToApi,
  normalizeFlowNodes,
  nodeSignModeLabel,
} from "../approve-template/approveTemplateConstants.js";
import { buildFormPayloadFromFields, parseFormConfigToData } from "../approve-template/formConfigUtils.js";
/** å®¡æ‰¹ç±»åž‹ï¼ˆä¸ŽåŽç«¯å­—段 approvalType å¯¹é½ï¼ŒåŽæœŸå¯åŒæ­¥ï¼‰ */
export const APPROVAL_TYPE_OPTIONS = [
@@ -25,105 +34,401 @@
  { value: "cancelled", label: "已撤销" },
];
/** å®¡æ‰¹æ–¹å¼ approvalMode */
export const APPROVAL_MODE_OPTIONS = [
  { value: "parallel", label: "与签" },
  { value: "or_sign", label: "或签" },
];
export const LEGACY_APPROVE_LIST_STORAGE_KEY = "oa_unified_approve_list_v1";
export function clearLegacyApproveListStorage() {
  try {
    localStorage.removeItem(LEGACY_APPROVE_LIST_STORAGE_KEY);
  } catch {
    /* ignore */
  }
}
/** æäº¤å¼¹çª—:模板卡片(来自后端列表) */
export function mapSubmitTemplateCard(row) {
  const cfg = parseFormConfigToData(row?.formConfig);
  return {
    id: row?.id,
    key: String(row?.id ?? ""),
    approvalType: cfg.approvalType || row?.approvalType || "",
    label: row?.templateName || "—",
    summaryPlaceholder: (row?.description || "").trim() || cfg.summaryPlaceholder || "点击填写并提交",
  };
}
/** å®¡æ‰¹è®°å½• approveAction â†’ é¡µé¢ result */
export function mapRecordResultFromApi(action) {
  const s = String(action || "").toUpperCase();
  if (s === "APPROVED" || s === "APPROVE" || s === "PASS") return "approved";
  if (s === "REJECTED" || s === "REJECT" || s === "REFUSE") return "rejected";
  return "pending";
}
/** åŽç«¯ records â†’ æ—¶é—´çº¿å±•示结构 */
export function mapRecordsFromApi(records) {
  const list = Array.isArray(records) ? records : [];
  return list.map((r) => ({
    id: r.id,
    operatorName: r.approverName || r.operatorName || r.createUserName || "",
    result: mapRecordResultFromApi(r.approveAction ?? r.action ?? r.status),
    opinion: r.approveComment || r.comment || r.opinion || "",
    time: formatDisplayTime(r.approveTime || r.createTime || r.time || ""),
    raw: r,
  }));
}
export function mapTaskStatusLabel(status) {
  const s = String(status || "").toUpperCase();
  if (s === "APPROVED") return "已通过";
  if (s === "REJECTED") return "已驳回";
  if (s === "PENDING") return "待审批";
  if (s === "CANCELLED") return "已撤销";
  return status || "—";
}
export function mapTaskStatusTagType(status) {
  const s = String(status || "").toUpperCase();
  if (s === "APPROVED") return "success";
  if (s === "REJECTED") return "danger";
  if (s === "CANCELLED") return "info";
  return "warning";
}
/** åŽç«¯ tasks â†’ é¡µé¢ flowNodes(按 levelNo åˆ†ç»„,供流程编辑/展示) */
export function mapTasksToFlowNodes(tasks) {
  const list = Array.isArray(tasks) ? tasks : [];
  if (!list.length) return [];
  const byLevel = new Map();
  list.forEach((t) => {
    const level = Number(t.levelNo ?? t.taskLevel ?? t.nodeOrder ?? 1);
    if (!byLevel.has(level)) {
      byLevel.set(level, {
        id: t.nodeId,
        templateId: t.templateId,
        nodeOrder: level,
        signMode: mapSignModeFromApi(t.approveType),
        approvers: [],
        tasks: [],
      });
    }
    const node = byLevel.get(level);
    node.approvers.push({
      id: t.id,
      nodeId: t.nodeId,
      templateId: t.templateId,
      approverId: t.approverId,
      approverName: t.approverName || "",
      status: t.status,
      approveComment: t.approveComment,
      approveTime: t.approveTime,
    });
    node.tasks.push(t);
    if (t.approveType != null) {
      node.signMode = mapSignModeFromApi(t.approveType);
    }
  });
  return [...byLevel.entries()]
    .sort(([a], [b]) => a - b)
    .map(([, node]) => node);
}
/** é¡µé¢ flowNodes â†’ åŽç«¯ tasks */
export function mapFlowNodesToTasks(flowNodes, { instanceId, templateId } = {}) {
  const nodes = normalizeFlowNodes(flowNodes);
  const tasks = [];
  nodes.forEach((n) => {
    const levelNo = n.nodeOrder ?? 1;
    const approveType = mapSignModeToApi(n.signMode);
    n.approvers.forEach((a, idx) => {
      const task = {
        levelNo,
        approveType,
        approverId: a.approverId,
        approverName: a.approverName || "",
        sortNo: a.sortNo ?? idx + 1,
      };
      if (a.id != null) task.id = a.id;
      if (a.nodeId != null) task.nodeId = a.nodeId;
      if (a.templateId != null) task.templateId = a.templateId;
      else if (templateId) task.templateId = templateId;
      if (instanceId) task.instanceId = instanceId;
      if (a.status != null) task.status = a.status;
      tasks.push(task);
    });
  });
  return tasks;
}
function guessFieldTypeFromValue(val) {
  if (Array.isArray(val) && val.length === 2) return "datetimerange";
  if (typeof val === "number") return "number";
  if (typeof val === "string" && /^\d{4}-\d{2}-\d{2}$/.test(val)) return "date";
  if (typeof val === "string" && val.length > 100) return "textarea";
  return "text";
}
/** å•字段展示值(详情只读) */
export function formatFieldDisplayValue(field, val) {
  if (val == null || val === "" || (Array.isArray(val) && !val.length)) return "—";
  if (field?.type === "select" && field.options?.length) {
    const hit = field.options.find((o) => String(o.value) === String(val));
    return hit?.label || String(val);
  }
  if (Array.isArray(val)) return val.join(" è‡³ ");
  return String(val);
}
/**
 * æäº¤å®¡æ‰¹æ¨¡æ¿ï¼ˆæŒ‰ç±»åž‹ä¸€é”®å¡«æŠ¥ï¼Œå­—段后期与后端模板同步)
 * ä»Žè¡Œæ•°æ® / formConfig è§£æžå¡«æŠ¥å­—段定义与 formPayload(与新增提交结构一致)
 */
export const SUBMIT_TEMPLATES = {
  cost_reimburse: {
    approvalType: "cost_reimburse",
    label: "费用报销",
    summaryPlaceholder: "请填写报销事由、金额等",
    fields: [
      { key: "summary", label: "申请事由", type: "textarea", required: true, rows: 3 },
      { key: "amount", label: "报销金额(元)", type: "number", required: true, min: 0, precision: 2 },
    ],
    approvalMode: "parallel",
  },
  travel_reimburse: {
    approvalType: "travel_reimburse",
    label: "差旅报销",
    summaryPlaceholder: "出差行程与费用说明",
    fields: [
      { key: "summary", label: "差旅说明", type: "textarea", required: true, rows: 3 },
      { key: "amount", label: "报销金额(元)", type: "number", required: true, min: 0, precision: 2 },
      { key: "tripDays", label: "出差天数", type: "number", required: false, min: 0, precision: 0 },
    ],
    approvalMode: "parallel",
  },
  overtime: {
    approvalType: "overtime",
    label: "加班申请",
    fields: [
      { key: "summary", label: "加班事由", type: "textarea", required: true, rows: 3 },
      { key: "overtimeDate", label: "加班日期", type: "date", required: true },
      { key: "hours", label: "加班时长(小时)", type: "number", required: true, min: 0.5, precision: 1 },
    ],
    approvalMode: "parallel",
  },
  leave: {
    approvalType: "leave",
    label: "请假申请",
    fields: [
      { key: "leaveType", label: "请假类型", type: "select", required: true, options: [
        { label: "年假", value: "annual" },
        { label: "病假", value: "sick" },
        { label: "事假", value: "personal" },
        { label: "调休", value: "compensatory" },
      ] },
      { key: "summary", label: "请假事由", type: "textarea", required: true, rows: 2 },
      { key: "dateRange", label: "请假时间", type: "datetimerange", required: true },
    ],
    approvalMode: "parallel",
  },
  work_handover: {
    approvalType: "work_handover",
    label: "工作交接",
    fields: [
      { key: "summary", label: "交接说明", type: "textarea", required: true, rows: 3 },
      { key: "handoverTo", label: "交接对象", type: "text", required: true },
    ],
    approvalMode: "parallel",
  },
  regular: {
    approvalType: "regular",
    label: "转正申请",
    fields: [
      { key: "summary", label: "转正说明", type: "textarea", required: true, rows: 3 },
      { key: "regularDate", label: "拟转正日期", type: "date", required: true },
    ],
    approvalMode: "parallel",
  },
  resign: {
    approvalType: "resign",
    label: "离职申请",
    fields: [
      { key: "summary", label: "离职原因", type: "textarea", required: true, rows: 3 },
      { key: "lastWorkDay", label: "最后工作日", type: "date", required: true },
    ],
    approvalMode: "or_sign",
  },
  transfer: {
    approvalType: "transfer",
    label: "调岗申请",
    fields: [
      { key: "summary", label: "调岗说明", type: "textarea", required: true, rows: 2 },
      { key: "targetDept", label: "目标部门", type: "text", required: true },
      { key: "targetPost", label: "目标岗位", type: "text", required: true },
    ],
    approvalMode: "parallel",
  },
};
export function resolveInstanceFormFields(row) {
  const cfg = parseInstanceFormConfig(row?.formConfig);
  let fields = (row?.formFieldDefs?.length ? row.formFieldDefs : cfg.fields) || [];
  const formPayload = {
    ...(fields.length ? buildFormPayloadFromFields(fields) : {}),
    ...cfg.formPayload,
    ...(row?.formPayload || {}),
  };
  if (!fields.length && Object.keys(formPayload).length) {
    fields = Object.keys(formPayload)
      .filter((k) => k && k !== "summary")
      .map((k) => ({
        key: k,
        label: k,
        type: guessFieldTypeFromValue(formPayload[k]),
        required: false,
        rows: 3,
        min: 0,
        precision: 0,
        options: [],
      }));
  }
  const templateSnapshot = {
    label: row?.templateName || row?.title || "审批",
    approvalType: cfg.approvalType || row?.approvalType || "",
    summaryPlaceholder: cfg.summaryPlaceholder || "",
    templateId: row?.templateId,
    fields,
  };
  return { fields, formPayload, templateSnapshot, formConfigData: cfg };
}
export const STORAGE_KEY = "oa_unified_approve_list_v1";
/** è§£æžå®žä¾‹ formConfig */
export function parseInstanceFormConfig(formConfig) {
  let raw = {};
  if (formConfig) {
    if (typeof formConfig === "object") raw = formConfig;
    else {
      try {
        raw = JSON.parse(formConfig);
      } catch {
        raw = {};
      }
    }
  }
  const data = parseFormConfigToData(formConfig);
  const payload = raw.formPayload;
  return {
    summaryPlaceholder: raw.summaryPlaceholder || data.summaryPlaceholder || "",
    approvalType: raw.approvalType || "",
    fields: data.fields || [],
    formPayload: payload && typeof payload === "object" ? payload : {},
  };
}
export function unwrapInstanceDetail(res) {
  const data = res?.data ?? res;
  if (!data || typeof data !== "object") return {};
  if (data.id != null || data.instanceNo) return data;
  if (data.approvalInstanceVo) return data.approvalInstanceVo;
  return data;
}
/** å¡«æŠ¥å†…容 + æ¨¡æ¿å­—段定义 â†’ formConfig JSON */
export function buildInstanceFormConfigJson(templateSnapshot, formPayload) {
  const payload = formPayload || {};
  return JSON.stringify({
    summaryPlaceholder: templateSnapshot?.summaryPlaceholder || "",
    approvalType: templateSnapshot?.approvalType || "",
    fields: templateSnapshot?.fields || [],
    formPayload: payload,
  });
}
/** ç»„装保存/更新审批 DTO */
export function buildInstanceDto({ submitForm, activeTemplate, userStore, flowNodes, existingRow }) {
  const payload = submitForm?.formPayload || {};
  const tpl = activeTemplate || {};
  const title =
    String(payload.summary || payload.title || "").trim() ||
    tpl.label ||
    submitForm?.templateName ||
    "审批申请";
  const templateId = submitForm?.templateId || tpl.templateId;
  const instanceId = existingRow?.id ?? submitForm?.instanceId;
  const taskList = mapFlowNodesToTasks(flowNodes || submitForm?.flowNodes, {
    instanceId,
    templateId,
  });
  const isUpdate = Boolean(instanceId);
  const dto = {
    templateId,
    templateName: submitForm?.templateName || tpl.label || "",
    title,
    formConfig: buildInstanceFormConfigJson({ ...tpl, fields: tpl.fields || submitForm?.formFieldDefs }, payload),
    tasks: taskList,
  };
  if (isUpdate) {
    dto.id = existingRow?.id ?? submitForm?.instanceId;
    dto.instanceNo = existingRow?.instanceNo ?? submitForm?.instanceNo ?? "";
    dto.status =
      existingRow?.statusRaw || mapInstanceStatusToApi(existingRow?.approvalStatus) || "PENDING";
    dto.currentLevel = existingRow?.currentLevel ?? submitForm?.currentLevel ?? 1;
    dto.applicantId = existingRow?.applicantId ?? existingRow?.applicantNo;
    dto.applicantName = existingRow?.applicantName || "";
  } else {
    dto.status = "PENDING";
    dto.currentLevel = 1;
    dto.applicantId = userStore?.id;
    dto.applicantName = userStore?.nickName || userStore?.name || "";
  }
  return dto;
}
/** @deprecated ä½¿ç”¨ buildInstanceDto */
export function buildSaveInstanceDto(params) {
  return buildInstanceDto(params);
}
/** æ ¡éªŒæäº¤å®¡æ‰¹æµç¨‹ï¼ˆä¸Žæ¨¡æ¿é¡µè§„则一致) */
export function validateSubmitFlowNodes(flowNodes) {
  const nodes = normalizeFlowNodes(flowNodes);
  if (!nodes.length) return { ok: false, message: "请至少配置一个审批节点" };
  for (let i = 0; i < nodes.length; i++) {
    if (!nodes[i].approvers.length) {
      return { ok: false, message: `请为第 ${i + 1} ä¸ªèŠ‚ç‚¹é€‰æ‹©è‡³å°‘ä¸€åå®¡æ‰¹äºº` };
    }
  }
  return { ok: true, nodes };
}
/** åŽç«¯ status â†’ é¡µé¢ approvalStatus */
export function mapInstanceStatusFromApi(status) {
  const s = String(status || "").toUpperCase();
  if (s === "APPROVED") return "approved";
  if (s === "REJECTED") return "rejected";
  if (s === "CANCELLED") return "cancelled";
  return "pending";
}
/** é¡µé¢ approvalStatus â†’ åŽç«¯ status */
export function mapInstanceStatusToApi(approvalStatus) {
  const s = String(approvalStatus || "").toLowerCase();
  if (s === "approved") return "APPROVED";
  if (s === "rejected") return "REJECTED";
  if (s === "cancelled") return "CANCELLED";
  return "PENDING";
}
export function unwrapInstancePage(res) {
  const data = res?.data ?? res;
  return {
    records: Array.isArray(data?.records) ? data.records : [],
    total: Number(data?.total ?? 0),
  };
}
/** åˆ†é¡µåˆ—表项 â†’ è¡¨æ ¼è¡Œ */
export function mapInstanceFromApi(row) {
  if (!row) return {};
  const approvalStatus = mapInstanceStatusFromApi(row.status);
  const createTime = formatDisplayTime(row.createTime ?? row.applyTime ?? "");
  const applyTime = formatDisplayTime(row.applyTime ?? "");
  const finishTime = formatDisplayTime(row.finishTime ?? "");
  const resolved = resolveInstanceFormFields(row);
  const { fields, formPayload, templateSnapshot } = resolved;
  const tasks = Array.isArray(row.tasks) ? row.tasks : [];
  const flowNodes = tasks.length
    ? mapTasksToFlowNodes(tasks)
    : mapNodesFromApi(row.nodes || row.flowNodes);
  const approvalRecords = mapRecordsFromApi(row.records);
  return {
    id: row.id,
    bizId: row.instanceNo || String(row.id ?? ""),
    instanceNo: row.instanceNo || "",
    templateId: row.templateId,
    templateName: row.templateName || "",
    businessId: row.businessId,
    businessType: row.businessType,
    businessName: row.businessName || "",
    applicantId: row.applicantId,
    applicantNo: row.applicantId != null ? String(row.applicantId) : "",
    applicantName: row.applicantName || "",
    approvalType: row.templateName || "",
    unread: Boolean(row.isApprove) && approvalStatus === "pending",
    isApprove: Boolean(row.isApprove),
    approvalStatus,
    statusRaw: row.status,
    createTime,
    applyTime: applyTime === "—" ? "" : applyTime,
    finishTime: finishTime === "—" ? "" : finishTime,
    title: row.title || "",
    summary: row.title || row.templateName || "",
    currentLevel: row.currentLevel,
    formConfig: row.formConfig,
    formPayload,
    formFieldDefs: fields,
    templateSnapshot,
    tasks,
    records: Array.isArray(row.records) ? row.records : [],
    flowNodes,
    approvalFlowNodes: [],
    currentNodeIndex: 0,
    approvalRecords,
    rejectReason:
      approvalRecords.find((r) => r.result === "rejected")?.opinion || "",
  };
}
/** å®¡æ‰¹æ“ä½œï¼šä¸ŽåŽç«¯ status æžšä¸¾ä¸€è‡´ */
export const APPROVE_ACTION_APPROVED = "APPROVED";
export const APPROVE_ACTION_REJECTED = "REJECTED";
/** é¡µé¢æ“ä½œ â†’ approveAction */
export function mapApproveActionToApi(uiResult) {
  return uiResult === "rejected" ? APPROVE_ACTION_REJECTED : APPROVE_ACTION_APPROVED;
}
/** ç»„装审批提交 DTO */
export function buildApproveInstanceDto(row, uiResult, comment) {
  const opinion = (comment || "").trim();
  return {
    id: row?.id,
    approveAction: mapApproveActionToApi(uiResult),
    approveComment: opinion || (uiResult === "approved" ? "同意" : ""),
  };
}
export function buildApprovalInstanceListParams({ page, searchForm }) {
  const params = {
    current: page.current,
    size: page.size,
  };
  const dto = {};
  const kw = (searchForm?.applicantKeyword || "").trim();
  if (kw) dto.applicantName = kw;
  if (searchForm?.approvalType) {
    const opt = APPROVAL_TYPE_OPTIONS.find((x) => x.value === searchForm.approvalType);
    if (opt?.label) dto.templateName = opt.label;
  }
  if (Object.keys(dto).length) params.approvalInstanceDto = dto;
  return params;
}
export function approvalTypeLabel(v) {
  return APPROVAL_TYPE_OPTIONS.find((x) => x.value === v)?.label || "—";
  return APPROVAL_TYPE_OPTIONS.find((x) => x.value === v)?.label || v || "—";
}
export function approvalTypeStyle(v) {
@@ -147,170 +452,57 @@
  return "primary";
}
export function approvalModeLabel(v) {
  if (v === "countersign") return "或签";
  return APPROVAL_MODE_OPTIONS.find((x) => x.value === v)?.label || "与签";
}
export function unreadLabel(v) {
  return v ? "是" : "否";
}
export function buildDefaultFlowNodes() {
  return [
    {
      approverId: "mock_supervisor",
      approverName: "直属上级",
      sortOrder: 1,
      nodeOrder: 1,
      nodeStatus: "process",
      approveOpinion: "",
      approveTime: "",
    },
    {
      approverId: "mock_manager",
      approverName: "部门经理",
      sortOrder: 2,
      nodeOrder: 2,
      nodeStatus: "wait",
      approveOpinion: "",
      approveTime: "",
    },
  ];
}
/** åˆ—表行 â†’ ç¼–辑表单(仅用行数据回显) */
export function buildEditFormFromInstanceRow(row) {
  const { fields, formPayload, templateSnapshot } = resolveInstanceFormFields(row);
  const normalized = normalizeFlowNodes(
    row?.flowNodes?.length ? row.flowNodes : mapTasksToFlowNodes(row?.tasks)
  );
  const flowNodes = normalized.length
    ? JSON.parse(JSON.stringify(normalized))
    : [createEmptyNode(1)];
function demoRow(partial) {
  const now = dayjs().format("YYYY-MM-DD HH:mm:ss");
  return {
    id: partial.id,
    bizId: partial.bizId || partial.id,
    applicantNo: partial.applicantNo,
    applicantName: partial.applicantName,
    approvalType: partial.approvalType,
    approvalMode: partial.approvalMode || "parallel",
    unread: partial.unread ?? false,
    approvalStatus: partial.approvalStatus || "pending",
    createTime: partial.createTime || now,
    summary: partial.summary || "",
    formPayload: partial.formPayload || {},
    approvalFlowNodes: partial.approvalFlowNodes || buildDefaultFlowNodes(),
    currentNodeIndex: partial.currentNodeIndex ?? 0,
    approvalRecords: partial.approvalRecords || [],
    rejectReason: partial.rejectReason || "",
    sourceRoute: partial.sourceRoute || "",
    templateKey: String(row?.templateId || ""),
    templateId: row?.templateId,
    templateName: row?.templateName || templateSnapshot.label,
    instanceId: row?.id,
    instanceNo: row?.instanceNo || "",
    statusRaw: row?.statusRaw || row?.status || "PENDING",
    currentLevel: row?.currentLevel ?? 1,
    applicantId: row?.applicantId,
    applicantName: row?.applicantName || "",
    templateSnapshot,
    formFieldDefs: fields,
    formPayload,
    flowNodes,
  };
}
/** åˆå§‹æ¼”示数据(共 22 æ¡ï¼Œä¸ŽåŽŸåž‹æ•°é‡ä¸€è‡´ï¼‰ */
export function createInitialMockRows() {
  const types = [
    "cost_reimburse",
    "travel_reimburse",
    "overtime",
    "leave",
    "work_handover",
    "regular",
    "resign",
    "transfer",
    "cost_reimburse",
    "leave",
    "overtime",
    "travel_reimburse",
    "work_handover",
    "regular",
    "cost_reimburse",
    "leave",
    "transfer",
    "resign",
    "overtime",
    "travel_reimburse",
    "cost_reimburse",
    "leave",
  ];
  const applicants = [
    { no: "007", name: "苹果" },
    { no: "Guest001", name: "外部用户" },
    { no: "0056", name: "王五" },
    { no: "0042", name: "李四" },
    { no: "0088", name: "猫猫" },
    { no: "0012", name: "张三" },
    { no: "0033", name: "赵六" },
  ];
  const summaries = [
    "办公用品采购报销",
    "上海出差差旅费",
    "周末项目加班",
    "年假 3 å¤©",
    "离职工作交接",
    "试用期转正申请",
    "个人原因离职",
    "调至销售部",
    "客户接待餐费",
    "病假 1 å¤©",
    "节假日值班加班",
    "北京培训差旅",
    "项目文档交接",
    "研发岗转正",
    "通讯费报销",
    "事假半天",
    "调岗至市场部",
    "协商离职",
    "工作日延时加班",
    "成都展会差旅",
    "交通费报销",
    "调休 1 å¤©",
  ];
  const statuses = ["pending", "pending", "pending", "approved", "pending", "pending", "rejected", "pending"];
  return types.map((approvalType, i) => {
    const ap = applicants[i % applicants.length];
    const daysAgo = i % 14;
    return demoRow({
      id: `mock_${i + 1}`,
      bizId: `BIZ${String(2025031400 + i)}`,
      applicantNo: ap.no,
      applicantName: ap.name,
      approvalType,
      approvalMode: i % 5 === 0 ? "or_sign" : "parallel",
      unread: i % 3 === 0,
      approvalStatus: statuses[i % statuses.length],
      createTime: dayjs().subtract(daysAgo, "day").hour(9 + (i % 8)).minute((i * 7) % 60).second(0).format("YYYY-MM-DD HH:mm:ss"),
      summary: summaries[i],
      formPayload: { summary: summaries[i] },
    });
  });
}
export function loadStoredRows() {
  try {
    const raw = localStorage.getItem(STORAGE_KEY);
    if (!raw) return null;
    const parsed = JSON.parse(raw);
    return Array.isArray(parsed) ? parsed : null;
  } catch {
    return null;
  }
}
export function saveStoredRows(rows) {
  try {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(rows));
  } catch {
    /* ignore quota */
  }
}
export function createEmptySubmitForm(templateKey) {
  const tpl = SUBMIT_TEMPLATES[templateKey];
  const payload = { summary: "" };
  (tpl?.fields || []).forEach((f) => {
    if (f.type === "number") payload[f.key] = undefined;
    else if (f.type === "datetimerange") payload[f.key] = [];
    else payload[f.key] = "";
  });
export function createEmptySubmitForm(templateKey, templateOverride, flowNodesOverride) {
  const tpl = templateOverride || null;
  const payload = tpl?.fields?.length ? buildFormPayloadFromFields(tpl.fields) : { summary: "" };
  const normalized = normalizeFlowNodes(flowNodesOverride);
  const flowNodes = normalized.length
    ? JSON.parse(JSON.stringify(normalized))
    : [createEmptyNode(1)];
  return {
    templateKey: templateKey || "",
    approvalMode: tpl?.approvalMode || "parallel",
    templateId: tpl?.templateId || "",
    templateName: tpl?.label || "",
    instanceId: "",
    instanceNo: "",
    statusRaw: "",
    currentLevel: 1,
    applicantId: null,
    applicantName: "",
    templateSnapshot: templateOverride || null,
    formFieldDefs: tpl?.fields || [],
    formPayload: payload,
    approvalFlowNodes: buildDefaultFlowNodes(),
    flowNodes,
  };
}
src/views/officeProcessAutomation/ApproveManage/approve-list/components/ApproveDetailPanel.vue
@@ -1,92 +1,83 @@
<!-- ç»Ÿä¸€å®¡æ‰¹ï¼šä¸šåŠ¡æ‘˜è¦ -->
<!-- å®¡æ‰¹è¯¦æƒ…:基础信息 + å¡«æŠ¥å†…容 -->
<template>
  <el-descriptions :column="2" border>
    <el-descriptions-item label="业务单号">{{ row.bizId || row.id || "—" }}</el-descriptions-item>
    <el-descriptions-item label="审批状态">
      <el-tag :type="approvalStatusTagType(row.approvalStatus)" size="small" effect="plain">
        {{ approvalStatusLabel(row.approvalStatus) }}
      </el-tag>
    </el-descriptions-item>
    <el-descriptions-item label="审批类型">
      <span class="approve-type-cell" :style="approvalTypeStyle(row.approvalType)">
        {{ approvalTypeLabel(row.approvalType) }}
      </span>
    </el-descriptions-item>
    <el-descriptions-item label="审批方式">
      <span class="approval-method-text">{{ approvalModeLabel(row.approvalMode) }}</span>
    </el-descriptions-item>
    <el-descriptions-item label="申请人编号">{{ row.applicantNo || "—" }}</el-descriptions-item>
    <el-descriptions-item label="申请人名称">{{ row.applicantName || "—" }}</el-descriptions-item>
    <el-descriptions-item label="申请摘要" :span="2">{{ row.summary || "—" }}</el-descriptions-item>
    <el-descriptions-item v-if="row.rejectReason" label="驳回原因" :span="2">
      <span class="reject-text">{{ row.rejectReason }}</span>
    </el-descriptions-item>
    <el-descriptions-item label="创建时间" :span="2">{{ row.createTime || "—" }}</el-descriptions-item>
  </el-descriptions>
  <div class="approve-detail-panel">
    <div class="detail-block">
      <div class="detail-block-title">基本信息</div>
      <el-descriptions :column="2" border>
        <el-descriptions-item label="业务单号">{{ row.bizId || row.id || "—" }}</el-descriptions-item>
        <el-descriptions-item label="审批状态">
          <el-tag :type="approvalStatusTagType(row.approvalStatus)" size="small" effect="plain">
            {{ approvalStatusLabel(row.approvalStatus) }}
          </el-tag>
        </el-descriptions-item>
        <el-descriptions-item label="审批类型">
          <span class="approve-type-cell" :style="approvalTypeStyle(row.approvalType)">
            {{ approvalTypeLabel(row.approvalType) }}
          </span>
        </el-descriptions-item>
        <el-descriptions-item label="申请人编号">{{ row.applicantNo || "—" }}</el-descriptions-item>
        <el-descriptions-item label="申请人名称">{{ row.applicantName || "—" }}</el-descriptions-item>
        <el-descriptions-item label="申请摘要">{{ row.summary || "—" }}</el-descriptions-item>
        <el-descriptions-item v-if="row.rejectReason" label="驳回原因" :span="2">
          <span class="reject-text">{{ row.rejectReason }}</span>
        </el-descriptions-item>
        <el-descriptions-item label="创建时间" :span="2">
          {{ formatDisplayTime(row.createTime) }}
        </el-descriptions-item>
      </el-descriptions>
    </div>
  <template v-if="extraFields.length">
    <el-divider content-position="left">填报内容</el-divider>
    <el-descriptions :column="2" border size="small">
      <el-descriptions-item v-for="item in extraFields" :key="item.key" :label="item.label">
        {{ item.display }}
      </el-descriptions-item>
    </el-descriptions>
  </template>
    <div class="detail-block">
      <div class="detail-block-title">填报内容</div>
      <FormPayloadFields
        :fields="formResolved.fields"
        :form-payload="formResolved.formPayload"
        readonly
      />
    </div>
  </div>
</template>
<script setup>
import { computed } from "vue";
import { formatDisplayTime } from "../../approve-template/approveTemplateConstants.js";
import {
  approvalTypeLabel,
  approvalTypeStyle,
  approvalModeLabel,
  approvalStatusLabel,
  approvalStatusTagType,
  SUBMIT_TEMPLATES,
  resolveInstanceFormFields,
} from "../approveListConstants.js";
import FormPayloadFields from "./FormPayloadFields.vue";
const props = defineProps({
  row: { type: Object, default: () => ({}) },
});
const extraFields = computed(() => {
  const payload = props.row?.formPayload || {};
  const tpl = Object.values(SUBMIT_TEMPLATES).find((t) => t.approvalType === props.row?.approvalType);
  if (!tpl?.fields?.length) {
    return Object.keys(payload)
      .filter((k) => k !== "summary" && payload[k] != null && payload[k] !== "")
      .map((k) => ({ key: k, label: k, display: formatValue(payload[k]) }));
  }
  return tpl.fields
    .map((f) => {
      const val = payload[f.key];
      if (val == null || val === "" || (Array.isArray(val) && !val.length)) return null;
      let display = formatValue(val);
      if (f.type === "select" && f.options) {
        display = f.options.find((o) => o.value === val)?.label || display;
      }
      return { key: f.key, label: f.label, display };
    })
    .filter(Boolean);
});
function formatValue(val) {
  if (Array.isArray(val)) return val.join(" è‡³ ");
  return String(val);
}
const formResolved = computed(() => resolveInstanceFormFields(props.row));
</script>
<style scoped>
.approve-detail-panel {
  display: flex;
  flex-direction: column;
  gap: 20px;
}
.detail-block-title {
  font-size: 14px;
  font-weight: 600;
  color: var(--el-text-color-primary);
  margin: 0 0 12px;
  padding-left: 10px;
  border-left: 3px solid var(--el-color-primary);
  line-height: 1.4;
}
.approve-type-cell {
  display: inline-block;
  padding: 2px 10px;
  border-radius: 4px;
  font-size: 13px;
  line-height: 1.5;
}
.approval-method-text {
  color: var(--el-color-danger);
  font-weight: 500;
}
.reject-text {
  color: var(--el-color-danger);
src/views/officeProcessAutomation/ApproveManage/approve-list/components/FormPayloadFields.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,116 @@
<!-- å¡«æŠ¥é¡¹ï¼šç¼–辑为表单控件,详情为 descriptions è¡¨æ ¼ï¼ˆä¸Žä¸Šæ–¹åŸºç¡€ä¿¡æ¯ä¸€è‡´ï¼‰ -->
<template>
  <template v-if="fields?.length">
    <el-descriptions
      v-if="readonly"
      :column="2"
      border
      class="form-payload-desc"
    >
      <el-descriptions-item
        v-for="field in fields"
        :key="field.key"
        :label="field.label"
        :span="field.type === 'textarea' || field.type === 'datetimerange' ? 2 : 1"
      >
        <span class="field-value">{{ displayValue(field) }}</span>
      </el-descriptions-item>
    </el-descriptions>
    <el-form v-else label-width="120px" class="form-payload-edit">
      <el-form-item
        v-for="field in fields"
        :key="field.key"
        :label="field.label"
        :prop="`formPayload.${field.key}`"
      >
        <el-input
          v-if="field.type === 'text'"
          v-model="formPayload[field.key]"
          :placeholder="`请输入${field.label}`"
          maxlength="200"
        />
        <el-input
          v-else-if="field.type === 'textarea'"
          v-model="formPayload[field.key]"
          type="textarea"
          :rows="field.rows || 3"
          :placeholder="`请填写${field.label}`"
          maxlength="2000"
          show-word-limit
        />
        <el-input-number
          v-else-if="field.type === 'number'"
          v-model="formPayload[field.key]"
          :min="field.min ?? 0"
          :precision="field.precision ?? 0"
          controls-position="right"
          style="width: 100%"
        />
        <el-date-picker
          v-else-if="field.type === 'date'"
          v-model="formPayload[field.key]"
          type="date"
          :placeholder="`请选择${field.label}`"
          format="YYYY-MM-DD"
          value-format="YYYY-MM-DD"
          style="width: 100%"
        />
        <el-date-picker
          v-else-if="field.type === 'datetimerange'"
          v-model="formPayload[field.key]"
          type="datetimerange"
          range-separator="至"
          start-placeholder="开始时间"
          end-placeholder="结束时间"
          format="YYYY-MM-DD HH:mm:ss"
          value-format="YYYY-MM-DD HH:mm:ss"
          style="width: 100%"
        />
        <el-select
          v-else-if="field.type === 'select'"
          v-model="formPayload[field.key]"
          :placeholder="`请选择${field.label}`"
          style="width: 100%"
          clearable
        >
          <el-option v-for="o in field.options" :key="o.value" :label="o.label" :value="o.value" />
        </el-select>
        <span v-else class="field-value">{{ displayValue(field) }}</span>
      </el-form-item>
    </el-form>
  </template>
  <el-empty v-else description="暂无填报项" :image-size="48" />
</template>
<script setup>
import { formatFieldDisplayValue } from "../approveListConstants.js";
const props = defineProps({
  fields: { type: Array, default: () => [] },
  formPayload: { type: Object, default: () => ({}) },
  readonly: { type: Boolean, default: false },
});
function displayValue(field) {
  return formatFieldDisplayValue(field, props.formPayload?.[field.key]);
}
</script>
<style scoped>
.form-payload-desc {
  width: 100%;
}
.form-payload-desc :deep(.el-descriptions__label) {
  width: 120px;
  font-weight: 500;
}
.field-value {
  color: var(--el-text-color-primary);
  line-height: 1.6;
  word-break: break-word;
}
.form-payload-edit {
  width: 100%;
}
</style>
src/views/officeProcessAutomation/ApproveManage/approve-list/components/InstanceFlowDisplay.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,147 @@
<!-- å®¡æ‰¹å®žä¾‹ï¼štasks å®¡æ‰¹æµç¨‹å±•示(横向步骤条) -->
<template>
  <div v-if="displayNodes.length" class="flow-track">
    <div
      v-for="(node, index) in displayNodes"
      :key="index"
      class="flow-step"
      :class="{ 'is-last': index === displayNodes.length - 1 }"
    >
      <div class="flow-step-card">
        <div class="flow-step-badge">{{ index + 1 }}</div>
        <div class="flow-step-main">
          <div class="flow-step-head">
            <span class="flow-step-name">节点 {{ index + 1 }}</span>
            <el-tag size="small" :type="node.signMode === 'or_sign' ? 'warning' : 'primary'" effect="plain">
              {{ nodeSignModeLabel(node.signMode) }}
            </el-tag>
          </div>
          <div class="flow-approvers">
            <div
              v-for="a in node.approvers"
              :key="String(a.approverId ?? a.id)"
              class="flow-approver"
            >
              <span class="flow-approver-name">{{ a.approverName || "—" }}</span>
              <el-tag
                v-if="a.status"
                size="small"
                :type="mapTaskStatusTagType(a.status)"
                effect="plain"
              >
                {{ mapTaskStatusLabel(a.status) }}
              </el-tag>
            </div>
            <span v-if="!node.approvers?.length" class="flow-empty">未配置审批人</span>
          </div>
        </div>
      </div>
      <div v-if="index < displayNodes.length - 1" class="flow-connector" aria-hidden="true">
        <el-icon><ArrowRight /></el-icon>
      </div>
    </div>
  </div>
  <el-empty v-else description="暂无流程节点" :image-size="48" />
</template>
<script setup>
import { computed } from "vue";
import { ArrowRight } from "@element-plus/icons-vue";
import { nodeSignModeLabel } from "../../approve-template/approveTemplateConstants.js";
import {
  mapTaskStatusLabel,
  mapTaskStatusTagType,
  mapTasksToFlowNodes,
} from "../approveListConstants.js";
const props = defineProps({
  tasks: { type: Array, default: () => [] },
  nodes: { type: Array, default: () => [] },
});
const displayNodes = computed(() => {
  if (props.tasks?.length) return mapTasksToFlowNodes(props.tasks);
  return props.nodes || [];
});
</script>
<style scoped>
.flow-track {
  display: flex;
  align-items: stretch;
  gap: 0;
  overflow-x: auto;
  padding: 4px 2px 8px;
}
.flow-step {
  display: flex;
  align-items: center;
  flex: 0 0 auto;
}
.flow-step-card {
  display: flex;
  gap: 12px;
  min-width: 200px;
  max-width: 260px;
  padding: 14px;
  border: 1px solid var(--el-border-color-lighter);
  border-radius: 8px;
  background: var(--el-bg-color);
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.flow-step-badge {
  flex-shrink: 0;
  width: 28px;
  height: 28px;
  border-radius: 50%;
  background: var(--el-color-primary-light-9);
  color: var(--el-color-primary);
  font-size: 13px;
  font-weight: 600;
  display: flex;
  align-items: center;
  justify-content: center;
}
.flow-step-main {
  flex: 1;
  min-width: 0;
}
.flow-step-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  margin-bottom: 10px;
}
.flow-step-name {
  font-weight: 600;
  font-size: 13px;
  color: var(--el-text-color-primary);
}
.flow-approvers {
  display: flex;
  flex-direction: column;
  gap: 6px;
}
.flow-approver {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 6px;
}
.flow-approver-name {
  font-size: 13px;
  color: var(--el-text-color-regular);
}
.flow-empty {
  font-size: 12px;
  color: var(--el-text-color-placeholder);
}
.flow-connector {
  display: flex;
  align-items: center;
  padding: 0 6px;
  color: var(--el-text-color-placeholder);
  font-size: 16px;
}
</style>
src/views/officeProcessAutomation/ApproveManage/approve-list/index.vue
@@ -63,119 +63,85 @@
            {{ approvalTypeLabel(row.approvalType) }}
          </span>
        </template>
        <template #approvalMethod="{ row }">
          <span class="approval-method-text">{{ approvalModeLabel(row.approvalMode) }}</span>
        </template>
      </PIMTable>
    </div>
    <!-- æäº¤å®¡æ‰¹ï¼ˆæŒ‰æ¨¡æ¿ï¼‰ -->
    <el-dialog
      v-model="submitDialog.visible"
      :title="submitDialog.step === 1 ? '选择审批模板' : `提交${activeTemplate?.label || '审批'}`"
      :title="submitDialogTitle"
      width="720px"
      append-to-body
      destroy-on-close
      class="approve-submit-dialog"
      @closed="submitDialog.step = 1"
      @closed="resetSubmitDialogState"
    >
      <template v-if="submitDialog.step === 1">
        <p class="template-hint">请选择要提交的审批类型,系统将按对应模板引导填报(字段后期与后端同步)。</p>
        <div class="template-grid">
      <template v-if="submitDialog.step === 1 && !isSubmitEdit">
        <p class="template-hint">请选择已启用的审批模板,系统将按模板配置引导填报。</p>
        <div v-loading="submitTemplatesLoading" class="template-grid">
          <div
            v-for="(tpl, key) in SUBMIT_TEMPLATES"
            :key="key"
            v-for="card in submitTemplateCards"
            :key="card.key"
            class="template-card"
            @click="onTemplatePick(key)"
            @click="onTemplatePick(card)"
          >
            <span class="template-card-type" :style="approvalTypeStyle(tpl.approvalType)">
              {{ tpl.label }}
            <span class="template-card-type" :style="approvalTypeStyle(card.approvalType)">
              {{ card.label }}
            </span>
            <span class="template-card-desc">{{ tpl.summaryPlaceholder || "点击填写并提交" }}</span>
            <span class="template-card-desc">{{ card.summaryPlaceholder }}</span>
          </div>
          <el-empty
            v-if="!submitTemplatesLoading && !submitTemplateCards.length"
            description="暂无可用审批模板"
            :image-size="80"
            class="template-empty"
          />
        </div>
      </template>
      <template v-else>
        <div v-loading="submitTemplatesLoading && !isSubmitEdit">
        <el-form ref="submitFormRef" :model="submitForm" :rules="submitFormRules" label-width="120px">
          <el-form-item label="审批类型">
            <span class="approve-type-cell" :style="approvalTypeStyle(activeTemplate.approvalType)">
              {{ activeTemplate.label }}
            </span>
            <el-button type="primary" link class="ml12" @click="backToTemplatePick">更换模板</el-button>
            <el-button
              v-if="!isSubmitEdit"
              type="primary"
              link
              class="ml12"
              @click="backToTemplatePick"
            >
              æ›´æ¢æ¨¡æ¿
            </el-button>
          </el-form-item>
          <el-form-item label="审批方式" prop="approvalMode">
            <el-radio-group v-model="submitForm.approvalMode">
              <el-radio value="parallel">与签</el-radio>
              <el-radio value="or_sign">或签</el-radio>
            </el-radio-group>
          </el-form-item>
          <template v-for="field in activeTemplate.fields" :key="field.key">
            <el-form-item :label="field.label" :prop="`formPayload.${field.key}`">
              <el-input
                v-if="field.type === 'text'"
                v-model="submitForm.formPayload[field.key]"
                :placeholder="`请输入${field.label}`"
                maxlength="200"
              />
              <el-input
                v-else-if="field.type === 'textarea'"
                v-model="submitForm.formPayload[field.key]"
                type="textarea"
                :rows="field.rows || 3"
                :placeholder="`请填写${field.label}`"
                maxlength="2000"
                show-word-limit
              />
              <el-input-number
                v-else-if="field.type === 'number'"
                v-model="submitForm.formPayload[field.key]"
                :min="field.min ?? 0"
                :precision="field.precision ?? 0"
                controls-position="right"
                style="width: 100%"
              />
              <el-date-picker
                v-else-if="field.type === 'date'"
                v-model="submitForm.formPayload[field.key]"
                type="date"
                :placeholder="`请选择${field.label}`"
                format="YYYY-MM-DD"
                value-format="YYYY-MM-DD"
                style="width: 100%"
              />
              <el-date-picker
                v-else-if="field.type === 'datetimerange'"
                v-model="submitForm.formPayload[field.key]"
                type="datetimerange"
                range-separator="至"
                start-placeholder="开始时间"
                end-placeholder="结束时间"
                format="YYYY-MM-DD HH:mm:ss"
                value-format="YYYY-MM-DD HH:mm:ss"
                style="width: 100%"
              />
              <el-select
                v-else-if="field.type === 'select'"
                v-model="submitForm.formPayload[field.key]"
                :placeholder="`请选择${field.label}`"
                style="width: 100%"
                clearable
              >
                <el-option v-for="o in field.options" :key="o.value" :label="o.label" :value="o.value" />
              </el-select>
            </el-form-item>
          </template>
          <el-form-item label="审批流程">
            <ApprovalFlowEditor v-model="submitForm.approvalFlowNodes" :user-options="flowUserOptions" />
            <p class="flow-tip">至少保留一个审批节点;提交后进入「审核中」状态。</p>
          <FormPayloadFields
            :fields="submitFormFields"
            :form-payload="submitForm.formPayload"
          />
          <el-form-item label="审批流程" required>
            <TemplateFlowEditor v-model="submitForm.flowNodes" :user-options="flowUserOptions" />
            <p class="flow-tip">
              æŒ‰é¡ºåºæµè½¬ï¼šå¯ä¸ºæ¯ä¸ªèŠ‚ç‚¹æ·»åŠ å¤šåå®¡æ‰¹äººï¼›ä¼šç­¾éœ€å…¨éƒ¨é€šè¿‡ï¼Œæˆ–ç­¾ä»»ä¸€äººé€šè¿‡å³å¯è¿›å…¥ä¸‹ä¸€èŠ‚ç‚¹ã€‚
            </p>
          </el-form-item>
        </el-form>
        </div>
      </template>
      <template #footer>
        <el-button v-if="submitDialog.step === 2" type="primary" @click="onSubmitNew">提 äº¤</el-button>
        <el-button @click="submitDialog.visible = false">{{ submitDialog.step === 1 ? "取 æ¶ˆ" : "关 é—­" }}</el-button>
        <el-button
          v-if="submitDialog.step === 2 || isSubmitEdit"
          type="primary"
          :loading="submitSaving"
          @click="onSubmitInstance"
        >
          {{ isSubmitEdit ? "保 å­˜" : "提 äº¤" }}
        </el-button>
        <el-button @click="submitDialog.visible = false">
          {{ submitDialog.step === 1 && !isSubmitEdit ? "取 æ¶ˆ" : "关 é—­" }}
        </el-button>
      </template>
    </el-dialog>
@@ -186,28 +152,51 @@
      width="920px"
      append-to-body
      destroy-on-close
      class="approve-detail-dialog"
    >
      <ApproveDetailPanel :row="detailRow" />
      <el-divider content-position="left">审批流程</el-divider>
      <ApprovalFlowProgress
        :nodes="detailRow.approvalFlowNodes"
        :current-index="detailRow.currentNodeIndex ?? 0"
      />
      <el-divider content-position="left">审批记录</el-divider>
      <el-timeline v-if="detailRow.approvalRecords?.length">
        <el-timeline-item
          v-for="(rec, i) in detailRow.approvalRecords"
          :key="i"
          :type="rec.result === 'approved' ? 'success' : rec.result === 'rejected' ? 'danger' : 'primary'"
          :timestamp="rec.time"
        >
          {{ rec.operatorName }} â€” {{ approvalActionLabel(rec.result) }}:{{ rec.opinion || "无意见" }}
        </el-timeline-item>
      </el-timeline>
      <el-empty v-else description="暂无审批记录" :image-size="60" />
      <div class="approve-detail-body">
        <ApproveDetailPanel :row="detailRow" />
        <div class="detail-block">
          <div class="detail-block-title">
            å®¡æ‰¹æµç¨‹ï¼ˆ{{ detailRow.tasks?.length || detailRow.flowNodes?.length || 0 }} é¡¹ï¼‰
          </div>
          <InstanceFlowDisplay :tasks="detailRow.tasks" :nodes="detailRow.flowNodes" />
        </div>
        <div class="detail-block">
          <div class="detail-block-title">审批记录</div>
          <el-timeline v-if="detailRow.approvalRecords?.length" class="approve-record-timeline">
            <el-timeline-item
              v-for="(rec, i) in detailRow.approvalRecords"
              :key="rec.id ?? i"
              :type="rec.result === 'approved' ? 'success' : rec.result === 'rejected' ? 'danger' : 'primary'"
              :timestamp="formatRecordTime(rec.time)"
              placement="top"
            >
              <div class="record-item">
                <span class="record-operator">{{ rec.operatorName || "—" }}</span>
                <el-tag
                  size="small"
                  :type="rec.result === 'approved' ? 'success' : rec.result === 'rejected' ? 'danger' : 'info'"
                  effect="plain"
                >
                  {{ approvalActionLabel(rec.result) }}
                </el-tag>
                <p class="record-opinion">{{ rec.opinion || "无意见" }}</p>
              </div>
            </el-timeline-item>
          </el-timeline>
          <el-empty v-else description="暂无审批记录" :image-size="48" />
        </div>
      </div>
      <template #footer>
        <el-button
          v-if="detailRow.approvalStatus === 'pending'"
          @click="openEditFromDetail"
        >
          ä¿® æ”¹
        </el-button>
        <el-button
          v-if="detailRow.approvalStatus === 'pending' && detailRow.isApprove"
          type="primary"
          @click="openApproveFromDetail"
        >
@@ -227,11 +216,12 @@
      @closed="approveOpinion = ''"
    >
      <ApproveDetailPanel :row="approveDialog.row" />
      <el-divider content-position="left">流程进度</el-divider>
      <ApprovalFlowProgress
        :nodes="approveDialog.row?.approvalFlowNodes"
        :current-index="approveDialog.row?.currentNodeIndex ?? 0"
      />
      <div class="detail-block mt16">
        <div class="detail-block-title">
          å®¡æ‰¹æµç¨‹ï¼ˆ{{ approveDialog.row?.tasks?.length || approveDialog.row?.flowNodes?.length || 0 }} é¡¹ï¼‰
        </div>
        <InstanceFlowDisplay :tasks="approveDialog.row?.tasks" :nodes="approveDialog.row?.flowNodes" />
      </div>
      <el-form label-width="100px" class="mt16">
        <el-form-item label="审批意见" required>
          <el-input
@@ -245,9 +235,23 @@
        </el-form-item>
      </el-form>
      <template #footer>
        <el-button type="success" @click="onApprove('approved')">通 è¿‡</el-button>
        <el-button type="danger" @click="onApprove('rejected')">驳 å›ž</el-button>
        <el-button @click="approveDialog.visible = false">取 æ¶ˆ</el-button>
        <el-button
          type="success"
          :loading="approveSubmitting"
          @click="onApprove('approved')"
        >
          é€š è¿‡
        </el-button>
        <el-button
          type="danger"
          :loading="approveSubmitting"
          @click="onApprove('rejected')"
        >
          é©³ å›ž
        </el-button>
        <el-button :disabled="approveSubmitting" @click="approveDialog.visible = false">
          å– æ¶ˆ
        </el-button>
      </template>
    </el-dialog>
  </div>
@@ -258,19 +262,21 @@
import { ElMessage } from "element-plus";
import { onMounted, ref } from "vue";
import { userListNoPageByTenantId } from "@/api/system/user.js";
import ApprovalFlowEditor from "@/views/officeProcessAutomation/AttendManage/overtime-apply/components/ApprovalFlowEditor.vue";
import ApprovalFlowProgress from "@/views/officeProcessAutomation/ReimburseManage/travel-reimburse/components/ApprovalFlowProgress.vue";
import TemplateFlowEditor from "../approve-template/components/TemplateFlowEditor.vue";
import FormPayloadFields from "./components/FormPayloadFields.vue";
import { formatDisplayTime } from "../approve-template/approveTemplateConstants.js";
import { approvalTypeStyle } from "./approveListConstants.js";
import ApproveDetailPanel from "./components/ApproveDetailPanel.vue";
import InstanceFlowDisplay from "./components/InstanceFlowDisplay.vue";
import { useApproveList } from "./useApproveList.js";
const al = useApproveList();
const {
  Search,
  APPROVAL_TYPE_OPTIONS,
  SUBMIT_TEMPLATES,
  submitTemplateCards,
  submitTemplatesLoading,
  approvalTypeLabel,
  approvalModeLabel,
  approvalActionLabel,
  searchForm,
  tableLoading,
@@ -281,18 +287,25 @@
  detailRow,
  approveDialog,
  approveOpinion,
  approveSubmitting,
  submitDialog,
  isSubmitEdit,
  submitDialogTitle,
  submitForm,
  submitFormRef,
  submitSaving,
  activeTemplate,
  submitFormFields,
  submitFormRules,
  handleQuery,
  resetSearch,
  pagination,
  resetSubmitDialogState,
  openSubmitDialog,
  openEditDialog,
  onTemplatePick,
  backToTemplatePick,
  submitNewApproval,
  submitInstanceForm,
  submitApprove,
  openDetail,
  openApprove,
@@ -322,13 +335,13 @@
  }
}
async function onSubmitNew() {
  const ok = await submitNewApproval();
  if (ok) ElMessage.success("审批已提交");
async function onSubmitInstance() {
  const ok = await submitInstanceForm();
  if (ok) ElMessage.success(isSubmitEdit.value ? "修改成功" : "审批已提交");
}
function onApprove(result) {
  const ret = submitApprove(result);
async function onApprove(result) {
  const ret = await submitApprove(result);
  if (ret?.needOpinion) {
    ElMessage.warning("驳回时请填写审批意见");
    return;
@@ -338,10 +351,20 @@
  }
}
function formatRecordTime(time) {
  return formatDisplayTime(time) || "—";
}
function openApproveFromDetail() {
  const row = detailRow.value;
  detailDialog.visible = false;
  openApprove(row);
}
function openEditFromDetail() {
  const row = detailRow.value;
  detailDialog.visible = false;
  openEditDialog(row);
}
onMounted(() => {
@@ -385,10 +408,6 @@
  font-size: 13px;
  line-height: 1.5;
}
.approval-method-text {
  color: var(--el-color-danger);
  font-weight: 500;
}
.template-hint {
  font-size: 13px;
  color: var(--el-text-color-secondary);
@@ -398,6 +417,10 @@
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
  gap: 12px;
  min-height: 120px;
}
.template-empty {
  grid-column: 1 / -1;
}
.template-card {
  padding: 14px 16px;
@@ -433,4 +456,47 @@
.approve-submit-dialog :deep(.el-dialog__body) {
  padding-top: 12px;
}
.approve-detail-dialog :deep(.el-dialog__body) {
  padding-top: 16px;
  max-height: 70vh;
  overflow-y: auto;
}
.approve-detail-body .detail-block {
  margin-top: 20px;
}
.approve-detail-body .detail-block-title {
  font-size: 14px;
  font-weight: 600;
  color: var(--el-text-color-primary);
  margin: 0 0 12px;
  padding-left: 10px;
  border-left: 3px solid var(--el-color-primary);
  line-height: 1.4;
}
.approve-record-timeline {
  padding-left: 4px;
}
.record-item {
  padding: 4px 0 2px;
}
.record-operator {
  font-weight: 600;
  margin-right: 8px;
  color: var(--el-text-color-primary);
}
.record-opinion {
  margin: 8px 0 0;
  font-size: 13px;
  color: var(--el-text-color-regular);
  line-height: 1.5;
}
.detail-block-title {
  font-size: 14px;
  font-weight: 600;
  color: var(--el-text-color-primary);
  margin: 0 0 12px;
  padding-left: 10px;
  border-left: 3px solid var(--el-color-primary);
  line-height: 1.4;
}
</style>
src/views/officeProcessAutomation/ApproveManage/approve-list/useApproveList.js
@@ -1,56 +1,52 @@
import { Search } from "@element-plus/icons-vue";
import dayjs from "dayjs";
import {
  getApprovalTemplateDetail,
  listApprovalTemplate,
  TEMPLATE_TYPE_BUILTIN,
  TEMPLATE_TYPE_CUSTOM,
} from "@/api/officeProcessAutomation/approvalTemplate.js";
import {
  approveApprovalInstance,
  deleteApprovalInstance,
  listApprovalInstancePage,
  saveApprovalInstance,
  updateApprovalInstance,
} from "@/api/officeProcessAutomation/approvalInstance.js";
import useUserStore from "@/store/modules/user";
import { computed, reactive, ref, watch } from "vue";
import { Search } from "@element-plus/icons-vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { computed, reactive, ref } from "vue";
import {
  formatDisplayTime,
  mapEnabledFromApi,
  mapTemplateFromApi,
  unwrapTemplateDetail,
  unwrapTemplateList,
} from "../approve-template/approveTemplateConstants.js";
import { buildSubmitTemplateFromRow } from "../approve-template/formConfigUtils.js";
import {
  APPROVAL_TYPE_OPTIONS,
  SUBMIT_TEMPLATES,
  approvalModeLabel,
  approvalStatusLabel,
  approvalStatusTagType,
  approvalTypeLabel,
  buildApprovalInstanceListParams,
  buildApproveInstanceDto,
  buildEditFormFromInstanceRow,
  buildInstanceDto,
  clearLegacyApproveListStorage,
  createEmptySubmitForm,
  createInitialMockRows,
  loadStoredRows,
  saveStoredRows,
  buildDefaultFlowNodes,
  mapInstanceFromApi,
  mapSubmitTemplateCard,
  validateSubmitFlowNodes,
  unwrapInstancePage,
} from "./approveListConstants.js";
function advanceFlow(row, result, opinion) {
  const nodes = row.approvalFlowNodes || [];
  const idx = row.currentNodeIndex ?? 0;
  const node = nodes[idx];
  if (!node) return;
  node.nodeStatus = result === "approved" ? "finish" : "error";
  node.approveOpinion = opinion || (result === "approved" ? "同意" : "驳回");
  node.approveTime = dayjs().format("YYYY-MM-DD HH:mm:ss");
  row.approvalRecords = row.approvalRecords || [];
  row.approvalRecords.push({
    operatorName: node.approverName || "审批人",
    result,
    opinion: node.approveOpinion,
    time: node.approveTime,
  });
  if (result === "rejected") {
    row.approvalStatus = "rejected";
    row.rejectReason = opinion || node.approveOpinion;
    return;
  }
  const next = idx + 1;
  if (next < nodes.length) {
    row.currentNodeIndex = next;
    nodes[next].nodeStatus = "process";
    row.approvalStatus = "pending";
  } else {
    row.approvalStatus = "approved";
    row.rejectReason = "";
  }
}
export function useApproveList() {
  clearLegacyApproveListStorage();
  const userStore = useUserStore();
  const stored = loadStoredRows();
  const allRows = ref(stored?.length ? stored : createInitialMockRows());
  const tableData = ref([]);
  const submitTemplateCards = ref([]);
  const submitTemplatesLoading = ref(false);
  const searchForm = reactive({
    approvalType: "",
@@ -66,57 +62,37 @@
  const approveDialog = reactive({ visible: false, row: null });
  const approveOpinion = ref("");
  const approveSubmitting = ref(false);
  const submitDialog = reactive({ visible: false, step: 1 });
  const submitDialog = reactive({ visible: false, step: 1, mode: "add" });
  const submitEditRow = ref(null);
  const submitForm = reactive(createEmptySubmitForm(""));
  const submitFormRef = ref();
  const submitSaving = ref(false);
  const filteredList = computed(() => {
    let list = [...allRows.value];
    if (searchForm.approvalType) {
      list = list.filter((r) => r.approvalType === searchForm.approvalType);
  const isSubmitEdit = computed(() => submitDialog.mode === "edit");
  const submitDialogTitle = computed(() => {
    if (submitDialog.mode === "edit") {
      return `修改${activeTemplate.value?.label || submitForm.templateName || "审批"}`;
    }
    const kw = (searchForm.applicantKeyword || "").trim().toLowerCase();
    if (kw) {
      list = list.filter((r) => {
        const name = (r.applicantName || "").toLowerCase();
        const no = (r.applicantNo || "").toLowerCase();
        return name.includes(kw) || no.includes(kw);
      });
    }
    const range = searchForm.createTimeRange;
    if (range?.length === 2) {
      const [from, to] = range;
      list = list.filter((r) => {
        const t = (r.createTime || "").slice(0, 10);
        return t && t >= from && t <= to;
      });
    }
    return list.sort((a, b) => (String(a.createTime) < String(b.createTime) ? 1 : -1));
    if (submitDialog.step === 1) return "选择审批模板";
    return `提交${activeTemplate.value?.label || "审批"}`;
  });
  watch(
    filteredList,
    (list) => {
      page.total = list.length;
      const maxPage = Math.max(1, Math.ceil(list.length / page.size) || 1);
      if (page.current > maxPage) page.current = maxPage;
    },
    { immediate: true }
  );
  const activeTemplate = computed(() => submitForm.templateSnapshot || null);
  const tableData = computed(() => {
    const start = (page.current - 1) * page.size;
    return filteredList.value.slice(start, start + page.size);
  /** å¡«æŠ¥é¡¹å®šä¹‰ï¼ˆæ–°å¢ž/修改与 formConfig ä¸€è‡´ï¼‰ */
  const submitFormFields = computed(() => {
    const tplFields = activeTemplate.value?.fields;
    if (tplFields?.length) return tplFields;
    return submitForm.formFieldDefs || [];
  });
  const activeTemplate = computed(() => SUBMIT_TEMPLATES[submitForm.templateKey] || null);
  const submitFormRules = computed(() => {
    const rules = {
      templateKey: [{ required: true, message: "请选择审批类型", trigger: "change" }],
    };
    (activeTemplate.value?.fields || []).forEach((f) => {
    submitFormFields.value.forEach((f) => {
      if (!f.required) return;
      if (f.type === "number") {
        rules[`formPayload.${f.key}`] = [{ required: true, message: `请填写${f.label}`, trigger: "blur" }];
@@ -132,6 +108,7 @@
  const tableColumn = ref([
    { label: "申请人编号", prop: "applicantNo", width: 110 },
    { label: "申请人名称", prop: "applicantName", minWidth: 100 },
    { label: "业务类型", prop: "businessName", minWidth: 120 },
    {
      label: "审批类型",
      prop: "approvalType",
@@ -140,14 +117,7 @@
      slot: "approveType",
    },
    {
      label: "审批方式",
      prop: "approvalMode",
      width: 90,
      dataType: "slot",
      slot: "approvalMethod",
    },
    {
      label: "是否未读",
      label: "待我审批",
      prop: "unread",
      width: 90,
      align: "center",
@@ -161,35 +131,82 @@
      formatData: (v) => approvalStatusLabel(v),
      formatType: (v) => approvalStatusTagType(v),
    },
    { label: "创建时间", prop: "createTime", width: 170 },
    {
      label: "创建时间",
      prop: "createTime",
      width: 170,
      formatData: (v) => formatDisplayTime(v),
    },
    {
      dataType: "action",
      label: "操作",
      align: "center",
      fixed: "right",
      width: 160,
      width: 240,
      operation: [
        { name: "详情", type: "text", clickFun: (row) => openDetail(row) },
        {
          name: "审批",
          name: "修改",
          type: "text",
          disabled: (row) => row.approvalStatus !== "pending",
          clickFun: (row) => openEditDialog(row),
        },
        {
          name: "审批",
          type: "text",
          disabled: (row) => row.approvalStatus !== "pending" || !row.isApprove,
          clickFun: (row) => openApprove(row),
        },
        {
          name: "删除",
          type: "danger",
          clickFun: (row) => removeInstance(row),
        },
      ],
    },
  ]);
  function persist() {
    saveStoredRows(allRows.value);
  async function fetchApprovalList() {
    tableLoading.value = true;
    try {
      const res = await listApprovalInstancePage(
        buildApprovalInstanceListParams({ page, searchForm })
      );
      const { records, total } = unwrapInstancePage(res);
      tableData.value = records.map(mapInstanceFromApi);
      page.total = total;
    } catch {
      tableData.value = [];
      page.total = 0;
      ElMessage.error("审批列表加载失败");
    } finally {
      tableLoading.value = false;
    }
  }
  async function loadSubmitTemplates() {
    submitTemplatesLoading.value = true;
    try {
      const [builtinRes, customRes] = await Promise.all([
        listApprovalTemplate(TEMPLATE_TYPE_BUILTIN),
        listApprovalTemplate(TEMPLATE_TYPE_CUSTOM),
      ]);
      const merged = [
        ...unwrapTemplateList(builtinRes),
        ...unwrapTemplateList(customRes),
      ].filter((row) => mapEnabledFromApi(row.enabled));
      submitTemplateCards.value = merged.map(mapSubmitTemplateCard);
    } catch {
      submitTemplateCards.value = [];
      ElMessage.error("加载审批模板失败");
    } finally {
      submitTemplatesLoading.value = false;
    }
  }
  function handleQuery() {
    tableLoading.value = true;
    page.current = 1;
    setTimeout(() => {
      tableLoading.value = false;
    }, 200);
    fetchApprovalList();
  }
  function resetSearch() {
@@ -202,43 +219,81 @@
  function pagination({ page: p, limit }) {
    page.current = p;
    page.size = limit;
  }
  function markRead(row) {
    if (!row.unread) return;
    const hit = allRows.value.find((r) => r.id === row.id);
    if (hit) {
      hit.unread = false;
      persist();
    }
    fetchApprovalList();
  }
  function openDetail(row) {
    markRead(row);
    detailRow.value = { ...row };
    detailDialog.visible = true;
  }
  function openApprove(row) {
    markRead(row);
    approveDialog.row = { ...row };
    approveOpinion.value = "";
    approveDialog.visible = true;
  }
  function openSubmitDialog() {
    Object.assign(submitForm, createEmptySubmitForm(""));
  function resetSubmitDialogState() {
    submitDialog.mode = "add";
    submitDialog.step = 1;
    submitEditRow.value = null;
    Object.assign(submitForm, createEmptySubmitForm(""));
  }
  function openSubmitDialog() {
    resetSubmitDialogState();
    submitDialog.visible = true;
    loadSubmitTemplates();
  }
  function openEditDialog(row) {
    if (row?.approvalStatus !== "pending") {
      ElMessage.warning("仅审核中的审批可修改");
      return;
    }
    if (!row?.id) {
      ElMessage.warning("无法修改:缺少审批实例 ID");
      return;
    }
    submitDialog.mode = "edit";
    submitDialog.step = 2;
    submitEditRow.value = { ...row };
    Object.assign(submitForm, buildEditFormFromInstanceRow(row));
    submitDialog.visible = true;
  }
  function onTemplatePick(key) {
    Object.assign(submitForm, createEmptySubmitForm(key));
    submitDialog.step = 2;
  async function onTemplatePick(card) {
    if (!card?.id) return;
    submitTemplatesLoading.value = true;
    try {
      const res = await getApprovalTemplateDetail(card.id);
      const mapped = mapTemplateFromApi(unwrapTemplateDetail(res));
      const tpl = {
        ...buildSubmitTemplateFromRow(mapped),
        templateId: mapped.id,
      };
      const base = createEmptySubmitForm(String(card.id), tpl, mapped.flowNodes);
      Object.assign(submitForm, {
        ...base,
        templateName: mapped.templateName || tpl.label || "",
        templateSnapshot: tpl,
        formFieldDefs: tpl.fields || [],
      });
      submitDialog.step = 2;
    } catch {
      ElMessage.error("加载模板详情失败");
    } finally {
      submitTemplatesLoading.value = false;
    }
  }
  function backToTemplatePick() {
    submitDialog.step = 1;
  }
  async function submitInstanceForm() {
    if (submitDialog.mode === "edit") return submitEditApproval();
    return submitNewApproval();
  }
  async function submitNewApproval() {
@@ -248,56 +303,143 @@
    } catch {
      return false;
    }
    const tpl = activeTemplate.value;
    if (!tpl) return false;
    const id = `user_${Date.now()}`;
    const summary =
      submitForm.formPayload.summary ||
      submitForm.formPayload.handoverTo ||
      `${tpl.label}申请`;
    const row = {
      id,
      bizId: `BIZ${dayjs().format("YYYYMMDDHHmmss")}`,
      applicantNo: userStore.name || String(userStore.id || "当前用户"),
      applicantName: userStore.nickName || userStore.name || "当前用户",
      approvalType: tpl.approvalType,
      approvalMode: submitForm.approvalMode,
      unread: false,
      approvalStatus: "pending",
      createTime: dayjs().format("YYYY-MM-DD HH:mm:ss"),
      summary,
      formPayload: { ...submitForm.formPayload },
      approvalFlowNodes: (submitForm.approvalFlowNodes?.length
        ? submitForm.approvalFlowNodes
        : buildDefaultFlowNodes()
      ).map((n, i) => ({ ...n, nodeStatus: i === 0 ? "process" : n.nodeStatus || "wait" })),
      currentNodeIndex: 0,
      approvalRecords: [],
      rejectReason: "",
    };
    allRows.value.unshift(row);
    persist();
    submitDialog.visible = false;
    page.current = 1;
    return true;
    if (!activeTemplate.value) return false;
    const flowCheck = validateSubmitFlowNodes(submitForm.flowNodes);
    if (!flowCheck.ok) {
      ElMessage.warning(flowCheck.message);
      return false;
    }
    if (!submitForm.templateId) {
      ElMessage.warning("缺少模板 ID,无法提交");
      return false;
    }
    if (submitSaving.value) return false;
    submitSaving.value = true;
    try {
      await saveApprovalInstance(
        buildInstanceDto({
          submitForm,
          activeTemplate: activeTemplate.value,
          userStore,
          flowNodes: flowCheck.nodes,
        })
      );
      submitDialog.visible = false;
      page.current = 1;
      await fetchApprovalList();
      return true;
    } catch {
      return false;
    } finally {
      submitSaving.value = false;
    }
  }
  function submitApprove(result) {
  async function submitEditApproval() {
    if (!submitFormRef.value) return false;
    try {
      await submitFormRef.value.validate();
    } catch {
      return false;
    }
    if (!activeTemplate.value) return false;
    const flowCheck = validateSubmitFlowNodes(submitForm.flowNodes);
    if (!flowCheck.ok) {
      ElMessage.warning(flowCheck.message);
      return false;
    }
    if (!submitForm.instanceId) {
      ElMessage.warning("缺少审批实例 ID,无法保存");
      return false;
    }
    if (submitSaving.value) return false;
    submitSaving.value = true;
    try {
      await updateApprovalInstance(
        buildInstanceDto({
          submitForm,
          activeTemplate: activeTemplate.value,
          flowNodes: flowCheck.nodes,
          existingRow: submitEditRow.value,
        })
      );
      submitDialog.visible = false;
      await fetchApprovalList();
      if (detailDialog.visible && detailRow.value?.id === submitForm.instanceId) {
        const hit = tableData.value.find((r) => r.id === submitForm.instanceId);
        if (hit) detailRow.value = { ...hit };
        else detailDialog.visible = false;
      }
      return true;
    } catch {
      return false;
    } finally {
      submitSaving.value = false;
    }
  }
  async function removeInstance(row) {
    if (row?.id == null || row.id === "") {
      ElMessage.warning("无法删除:缺少审批实例 ID");
      return;
    }
    const title = row.title || row.templateName || row.instanceNo || "该审批";
    try {
      await ElMessageBox.confirm(
        `确定要删除审批「${title}」吗?删除后不可恢复。`,
        "删除确认",
        {
          type: "warning",
          confirmButtonText: "确定删除",
          cancelButtonText: "取消",
          distinguishCancelAndClose: true,
          autofocus: false,
        }
      );
    } catch {
      return;
    }
    try {
      await deleteApprovalInstance([row.id]);
      ElMessage.success("删除成功");
      if (detailDialog.visible && detailRow.value?.id === row.id) {
        detailDialog.visible = false;
      }
      if (approveDialog.visible && approveDialog.row?.id === row.id) {
        approveDialog.visible = false;
      }
      await fetchApprovalList();
    } catch {
      /* é”™è¯¯ç”±æ‹¦æˆªå™¨æç¤º */
    }
  }
  async function submitApprove(result) {
    const row = approveDialog.row;
    if (!row) return;
    const hit = allRows.value.find((r) => r.id === row.id);
    if (!hit || hit.approvalStatus !== "pending") return;
    if (!row?.id) return { ok: false };
    if (result === "rejected" && !(approveOpinion.value || "").trim()) {
      return { needOpinion: true };
    }
    advanceFlow(hit, result, (approveOpinion.value || "").trim());
    hit.unread = false;
    persist();
    approveDialog.visible = false;
    if (detailDialog.visible && detailRow.value?.id === hit.id) {
      detailRow.value = { ...hit };
    if (approveSubmitting.value) return { ok: false };
    approveSubmitting.value = true;
    try {
      await approveApprovalInstance(
        buildApproveInstanceDto(row, result, approveOpinion.value)
      );
      approveDialog.visible = false;
      await fetchApprovalList();
      if (detailDialog.visible && detailRow.value?.id === row.id) {
        const hit = tableData.value.find((r) => r.id === row.id);
        if (hit) detailRow.value = { ...hit };
        else detailDialog.visible = false;
      }
      return { ok: true, result };
    } catch {
      ElMessage.error("审批操作失败");
      return { ok: false };
    } finally {
      approveSubmitting.value = false;
    }
    return { ok: true };
  }
  function approvalActionLabel(result) {
@@ -309,9 +451,7 @@
  return {
    Search,
    APPROVAL_TYPE_OPTIONS,
    SUBMIT_TEMPLATES,
    approvalTypeLabel,
    approvalModeLabel,
    approvalStatusLabel,
    approvalStatusTagType,
    approvalActionLabel,
@@ -324,20 +464,31 @@
    detailRow,
    approveDialog,
    approveOpinion,
    approveSubmitting,
    submitDialog,
    isSubmitEdit,
    submitDialogTitle,
    submitForm,
    submitFormRef,
    submitSaving,
    activeTemplate,
    submitFormFields,
    submitFormRules,
    submitTemplateCards,
    submitTemplatesLoading,
    handleQuery,
    resetSearch,
    pagination,
    resetSubmitDialogState,
    openSubmitDialog,
    openEditDialog,
    onTemplatePick,
    backToTemplatePick,
    submitInstanceForm,
    submitNewApproval,
    submitApprove,
    openDetail,
    openApprove,
    fetchApprovalList,
  };
}
src/views/officeProcessAutomation/ApproveManage/approve-template/approveTemplateConstants.js
@@ -1,5 +1,23 @@
import dayjs from "dayjs";
import { APPROVAL_TYPE_OPTIONS, SUBMIT_TEMPLATES } from "../approve-list/approveListConstants.js";
import {
  TEMPLATE_TYPE_CUSTOM,
  TEMPLATE_TYPE_OPTIONS,
} from "@/api/officeProcessAutomation/approvalTemplate.js";
import { APPROVAL_TYPE_OPTIONS } from "../approve-list/approveListConstants.js";
import {
  buildFormConfigJson,
  createEmptyFormConfigData,
  parseFormConfigToData,
  validateFormConfigData,
} from "./formConfigUtils.js";
export { TEMPLATE_TYPE_OPTIONS };
export function templateTypeLabel(type) {
  if (type == null || type === "") return "—";
  const n = Number(type);
  return TEMPLATE_TYPE_OPTIONS.find((x) => x.value === n)?.label || "—";
}
/** èŠ‚ç‚¹å†…å®¡æ‰¹æ–¹å¼ï¼šä¼šç­¾ / æˆ–ç­¾ */
export const NODE_SIGN_MODE_OPTIONS = [
@@ -7,18 +25,205 @@
  { value: "or_sign", label: "或签", desc: "本节点任一审批人通过即可" },
];
export const STORAGE_KEY = "oa_approve_template_custom_v1";
function parseFormConfig(formConfig) {
  if (!formConfig) return {};
  if (typeof formConfig === "object") return formConfig;
  try {
    return JSON.parse(formConfig);
  } catch {
    return {};
  }
}
/** ç³»ç»Ÿå†…置常用审批(只读展示,来源于审批列表提交模板) */
export function getBuiltinTemplates() {
  return Object.entries(SUBMIT_TEMPLATES).map(([key, tpl]) => ({
    key,
    approvalType: tpl.approvalType,
    label: tpl.label,
    summary: tpl.summaryPlaceholder || "系统预置填报字段",
    fieldCount: (tpl.fields || []).length,
    defaultMode: tpl.approvalMode,
function resolveDefaultMode(row, cfg, nodes) {
  let mode = cfg.approvalMode || cfg.defaultMode;
  if (!mode && nodes.length) {
    const t = String(nodes[0]?.approveType || "").toUpperCase();
    mode = t === "OR" ? "or_sign" : "parallel";
  }
  const m = String(mode || "").toLowerCase();
  if (m === "or" || m === "or_sign") return "or_sign";
  return "parallel";
}
/** å°†æŽ¥å£è¿”回的模板转为「系统常用审批」卡片数据 */
export function mapBuiltinCardFromApi(row) {
  const cfg = parseFormConfig(row?.formConfig);
  const fields = cfg.fields || cfg.formFields || [];
  const nodes = row?.nodes || row?.flowNodes || [];
  return {
    key: String(row?.id ?? row?.templateName ?? ""),
    id: row?.id,
    approvalType: cfg.approvalType || row?.approvalType || "",
    label: row?.templateName || row?.name || "—",
    summary: (row?.description || "").trim() || cfg.summaryPlaceholder || "系统预置填报字段",
    fieldCount: fields.length,
    defaultMode: resolveDefaultMode(row, cfg, nodes),
  };
}
export function unwrapTemplateList(payload) {
  const data = payload?.data ?? payload;
  if (Array.isArray(data)) return data;
  if (Array.isArray(data?.records)) return data.records;
  if (Array.isArray(data?.list)) return data.list;
  return [];
}
/** åŽç«¯ approveType â†’ é¡µé¢ signMode */
export function mapSignModeFromApi(approveType) {
  const t = String(approveType || "").toUpperCase();
  return t === "OR" ? "or_sign" : "countersign";
}
/** é¡µé¢ signMode â†’ åŽç«¯ approveType */
export function mapSignModeToApi(signMode) {
  return signMode === "or_sign" ? "OR" : "AND";
}
/** é¡µé¢ enabled â†’ åŽç«¯ enabled(1 å¯ç”¨ï¼Œ0 åœç”¨ï¼‰ */
export function mapEnabledToApi(enabled) {
  return enabled !== false ? "1" : "0";
}
/** åŽç«¯ nodes â†’ é¡µé¢ flowNodes(保留 id ä¾›ä¿®æ”¹æäº¤ï¼‰ */
export function mapNodesFromApi(nodes) {
  const list = Array.isArray(nodes) ? nodes : [];
  return list.map((n, i) => ({
    id: n.id,
    templateId: n.templateId,
    nodeOrder: n.levelNo ?? i + 1,
    signMode: mapSignModeFromApi(n.approveType ?? n.signMode),
    approvers: (n.approvers || [])
      .filter((a) => a?.approverId != null && a.approverId !== "")
      .map((a) => ({
        id: a.id,
        nodeId: a.nodeId,
        templateId: a.templateId,
        approverId: a.approverId,
        approverName: a.approverName || "",
      })),
  }));
}
/** enabled:1 å¯ç”¨ï¼Œ0 åœç”¨ */
export function mapEnabledFromApi(enabled) {
  return enabled === "1" || enabled === 1 || enabled === true;
}
/** å…¼å®¹å¤šç§åŽç«¯æ—¶é—´å­—段名并格式化展示 */
export function pickTemplateTimes(row) {
  const rawCreated =
    row?.createdTime ?? row?.createTime ?? row?.gmtCreate ?? row?.created_at ?? "";
  const rawUpdated =
    row?.updatedTime ?? row?.updateTime ?? row?.gmtModified ?? row?.modifyTime ?? row?.updated_at ?? "";
  const createdTime = normalizeTimeValue(rawCreated);
  const updatedTime = normalizeTimeValue(rawUpdated);
  return { createdTime, updatedTime, createTime: createdTime, updateTime: updatedTime };
}
function normalizeTimeValue(val) {
  if (val == null || val === "") return "";
  if (Array.isArray(val) && val.length >= 3) {
    const [y, m, d, h = 0, min = 0, s = 0] = val;
    return dayjs(new Date(y, m - 1, d, h, min, s)).format("YYYY-MM-DD HH:mm:ss");
  }
  if (typeof val === "number") {
    const d = val > 1e12 ? dayjs(val) : dayjs.unix(val);
    return d.isValid() ? d.format("YYYY-MM-DD HH:mm:ss") : "";
  }
  const s = String(val).trim();
  if (!s) return "";
  const parsed = dayjs(s.includes("T") ? s : s.replace(/-/g, "/"));
  return parsed.isValid() ? parsed.format("YYYY-MM-DD HH:mm:ss") : s;
}
export function formatDisplayTime(val) {
  const t = normalizeTimeValue(val);
  return t || "—";
}
/** è¯¦æƒ…接口 data è§£åŒ… */
export function unwrapTemplateDetail(res) {
  const data = res?.data ?? res;
  if (!data || typeof data !== "object") return {};
  if (data.templateName != null || data.id != null) return data;
  if (data.approvalTemplateVo) return data.approvalTemplateVo;
  if (data.records && data.records[0]) return data.records[0];
  return data;
}
/** åˆ†é¡µåˆ—表项 â†’ é¡µé¢è¡Œæ•°æ®ï¼ˆä¸»è¡¨ + èŠ‚ç‚¹ï¼‰ */
export function mapTemplateFromApi(row) {
  if (!row) return {};
  const flowNodes = mapNodesFromApi(row.nodes || row.flowNodes);
  const times = pickTemplateTimes(row);
  return {
    id: row.id,
    templateName: row.templateName || "",
    description: row.description || "",
    enabled: mapEnabledFromApi(row.enabled),
    enabledRaw: row.enabled,
    templateType: row.templateType != null ? Number(row.templateType) : undefined,
    formConfig: row.formConfig,
    formConfigData: parseFormConfigToData(row.formConfig),
    createdUser: row.createdUser,
    createdUserName: row.createdUserName,
    ...times,
    flowNodes,
    nodes: row.nodes || row.flowNodes,
  };
}
/** è¡¨å•数据 â†’ æäº¤ DTO(ApprovalTemplateDto) */
export function mapTemplateToApi(form) {
  const nodes = normalizeFlowNodes(form.flowNodes);
  const templateId = form.id || null;
  const dto = {
    templateName: (form.templateName || "").trim(),
    description: (form.description || "").trim(),
    enabled: mapEnabledToApi(form.enabled),
    templateType: form.templateType ?? TEMPLATE_TYPE_CUSTOM,
    formConfig: buildFormConfigJson(form.formConfigData),
    nodes: nodes.map((n, i) => {
      const node = {
        levelNo: n.nodeOrder ?? i + 1,
        approveType: mapSignModeToApi(n.signMode),
        approvers: n.approvers.map((a, idx) => {
          const approver = {
            approverId: a.approverId,
            approverName: a.approverName || "",
            sortNo: idx + 1,
          };
          if (a.id != null) approver.id = a.id;
          if (a.nodeId != null) approver.nodeId = a.nodeId;
          if (a.templateId != null) approver.templateId = a.templateId;
          else if (templateId) approver.templateId = templateId;
          return approver;
        }),
      };
      if (n.id != null) node.id = n.id;
      if (n.templateId != null) node.templateId = n.templateId;
      else if (templateId) node.templateId = templateId;
      return node;
    }),
  };
  if (templateId) dto.id = templateId;
  return dto;
}
export function buildApprovalTemplateListParams({ page, searchForm, templateType = TEMPLATE_TYPE_CUSTOM }) {
  const params = {
    current: page.current,
    size: page.size,
    templateType: searchForm?.templateType != null && searchForm.templateType !== ""
      ? searchForm.templateType
      : templateType,
  };
  const kw = (searchForm?.keyword || "").trim();
  if (kw) params.templateName = kw;
  if (searchForm?.enabledOnly) params.enabled = "1";
  return params;
}
export function nodeSignModeLabel(mode) {
@@ -42,6 +247,9 @@
    id: "",
    templateName: "",
    description: "",
    templateType: TEMPLATE_TYPE_CUSTOM,
    formConfig: "",
    formConfigData: createEmptyFormConfigData(),
    enabled: true,
    flowNodes: [createEmptyNode(1)],
  };
@@ -50,11 +258,16 @@
export function normalizeFlowNodes(nodes) {
  const list = Array.isArray(nodes) ? nodes : [];
  return list.map((n, i) => ({
    id: n.id,
    templateId: n.templateId,
    nodeOrder: i + 1,
    signMode: n.signMode === "or_sign" ? "or_sign" : "countersign",
    approvers: (n.approvers || [])
      .filter((a) => a?.approverId != null && a.approverId !== "")
      .map((a) => ({
        id: a.id,
        nodeId: a.nodeId,
        templateId: a.templateId,
        approverId: a.approverId,
        approverName: a.approverName || "",
      })),
@@ -71,6 +284,8 @@
      return { ok: false, message: `请为第 ${i + 1} ä¸ªèŠ‚ç‚¹é€‰æ‹©è‡³å°‘ä¸€åå®¡æ‰¹äºº` };
    }
  }
  const cfgCheck = validateFormConfigData(form.formConfigData);
  if (!cfgCheck.ok) return cfgCheck;
  return { ok: true, nodes, name };
}
@@ -83,78 +298,4 @@
      return `节点${i + 1}(${nodeSignModeLabel(n.signMode)}:${names})`;
    })
    .join(" â†’ ");
}
export function createInitialMockTemplates() {
  const now = dayjs().format("YYYY-MM-DD HH:mm:ss");
  return [
    {
      id: "tpl_demo_1",
      templateName: "项目立项审批",
      description: "跨部门项目立项,需技术、财务依次会签",
      enabled: true,
      createTime: dayjs().subtract(5, "day").format("YYYY-MM-DD HH:mm:ss"),
      updateTime: now,
      flowNodes: [
        {
          nodeOrder: 1,
          signMode: "countersign",
          approvers: [
            { approverId: "mock_tech_lead", approverName: "技术负责人" },
            { approverId: "mock_pm", approverName: "项目经理" },
          ],
        },
        {
          nodeOrder: 2,
          signMode: "or_sign",
          approvers: [
            { approverId: "mock_finance", approverName: "财务主管" },
            { approverId: "mock_cfo", approverName: "财务总监" },
          ],
        },
      ],
    },
    {
      id: "tpl_demo_2",
      templateName: "合同用印申请",
      description: "法务与行政或签后,总经理终审",
      enabled: true,
      createTime: dayjs().subtract(12, "day").format("YYYY-MM-DD HH:mm:ss"),
      updateTime: dayjs().subtract(2, "day").format("YYYY-MM-DD HH:mm:ss"),
      flowNodes: [
        {
          nodeOrder: 1,
          signMode: "or_sign",
          approvers: [
            { approverId: "mock_legal", approverName: "法务专员" },
            { approverId: "mock_admin", approverName: "行政主管" },
          ],
        },
        {
          nodeOrder: 2,
          signMode: "countersign",
          approvers: [{ approverId: "mock_ceo", approverName: "总经理" }],
        },
      ],
    },
  ];
}
export function loadStoredTemplates() {
  try {
    const raw = localStorage.getItem(STORAGE_KEY);
    if (!raw) return null;
    const parsed = JSON.parse(raw);
    return Array.isArray(parsed) ? parsed : null;
  } catch {
    return null;
  }
}
export function saveStoredTemplates(rows) {
  try {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(rows));
  } catch {
    /* ignore */
  }
}
src/views/officeProcessAutomation/ApproveManage/approve-template/components/FormConfigEditor.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,624 @@
<!-- å®¡æ‰¹æ¨¡æ¿ï¼šå¯é…ç½®å¡«æŠ¥é¡¹ï¼Œåºåˆ—化到 formConfig -->
<template>
  <div class="fce">
    <div class="fce-hint">
      <span class="fce-hint-label">填报提示</span>
      <el-input
        v-model="inner.summaryPlaceholder"
        placeholder="如:请填写报销事由、金额等"
        maxlength="200"
        show-word-limit
        @input="emitOut"
      />
    </div>
    <div class="fce-panel">
      <div class="fce-toolbar">
        <div class="fce-toolbar-left">
          <span class="fce-title">填报项配置</span>
          <el-tag v-if="inner.fields.length" size="small" type="info" effect="plain">
            å…± {{ inner.fields.length }} é¡¹
          </el-tag>
        </div>
        <div class="fce-toolbar-actions">
          <el-dropdown trigger="click" @command="applyPreset">
            <el-button size="small">从预设导入</el-button>
            <template #dropdown>
              <el-dropdown-menu>
                <el-dropdown-item v-for="p in FORM_CONFIG_PRESETS" :key="p.key" :command="p.key">
                  {{ p.label }}
                </el-dropdown-item>
              </el-dropdown-menu>
            </template>
          </el-dropdown>
          <el-button type="primary" size="small" :icon="Plus" @click="addField">添加填报项</el-button>
        </div>
      </div>
      <el-empty
        v-if="!inner.fields.length"
        class="fce-empty"
        description="暂无填报项,可添加或从预设快速导入"
        :image-size="72"
      />
      <div v-else class="fce-list">
        <div
          v-for="(field, index) in inner.fields"
          :key="field._uid"
          class="fce-card"
          :class="{ 'fce-card--required': field.required }"
        >
          <div class="fce-card-badge">{{ index + 1 }}</div>
          <div class="fce-card-head">
            <div class="fce-card-title">
              <span class="fce-card-name">{{ field.label || `填报项 ${index + 1}` }}</span>
              <el-tag size="small" effect="light" type="primary">{{ typeLabel(field.type) }}</el-tag>
              <el-tag v-if="field.required" size="small" type="danger" effect="plain">必填</el-tag>
            </div>
            <div class="fce-card-btns">
              <el-tooltip content="上移" placement="top">
                <el-button circle size="small" :disabled="index === 0" @click="moveField(index, -1)">
                  <el-icon><Top /></el-icon>
                </el-button>
              </el-tooltip>
              <el-tooltip content="下移" placement="top">
                <el-button
                  circle
                  size="small"
                  :disabled="index >= inner.fields.length - 1"
                  @click="moveField(index, 1)"
                >
                  <el-icon><Bottom /></el-icon>
                </el-button>
              </el-tooltip>
              <el-tooltip content="删除" placement="top">
                <el-button circle size="small" type="danger" plain @click="removeField(index)">
                  <el-icon><Delete /></el-icon>
                </el-button>
              </el-tooltip>
            </div>
          </div>
          <div class="fce-section">
            <span class="fce-section-title">基础信息</span>
            <el-row :gutter="16">
              <el-col :span="8">
                <el-form-item label="显示名称" required class="fce-field-item">
                  <el-input
                    v-model="field.label"
                    placeholder="如:报销说明"
                    maxlength="50"
                    @input="emitOut"
                  />
                </el-form-item>
              </el-col>
              <el-col :span="8">
                <el-form-item label="字段标识" required class="fce-field-item">
                  <el-input v-model="field.key" placeholder="如:summary" maxlength="50" @input="emitOut" />
                </el-form-item>
              </el-col>
              <el-col :span="8">
                <el-form-item label="控件类型" class="fce-field-item">
                  <el-select v-model="field.type" style="width: 100%" @change="onTypeChange(field)">
                    <el-option
                      v-for="t in FORM_FIELD_TYPE_OPTIONS"
                      :key="t.value"
                      :label="t.label"
                      :value="t.value"
                    />
                  </el-select>
                </el-form-item>
              </el-col>
            </el-row>
          </div>
          <div class="fce-section">
            <span class="fce-section-title">校验与格式</span>
            <el-row :gutter="16" align="middle">
              <el-col :span="8">
                <el-form-item label="是否必填" class="fce-field-item fce-field-item--switch">
                  <el-switch
                    v-model="field.required"
                    inline-prompt
                    active-text="必填"
                    inactive-text="选填"
                    @change="emitOut"
                  />
                </el-form-item>
              </el-col>
              <el-col v-if="field.type === 'textarea'" :span="8">
                <el-form-item label="行数" class="fce-field-item">
                  <el-input-number
                    v-model="field.rows"
                    :min="1"
                    :max="10"
                    controls-position="right"
                    style="width: 100%"
                    @change="emitOut"
                  />
                </el-form-item>
              </el-col>
              <template v-if="field.type === 'number'">
                <el-col :span="8">
                  <el-form-item label="最小值" class="fce-field-item">
                    <el-input-number
                      v-model="field.min"
                      controls-position="right"
                      style="width: 100%"
                      @change="emitOut"
                    />
                  </el-form-item>
                </el-col>
                <el-col :span="8">
                  <el-form-item label="小数位" class="fce-field-item">
                    <el-input-number
                      v-model="field.precision"
                      :min="0"
                      :max="4"
                      controls-position="right"
                      style="width: 100%"
                      @change="emitOut"
                    />
                  </el-form-item>
                </el-col>
              </template>
            </el-row>
          </div>
          <div class="fce-section fce-section--default">
            <span class="fce-section-title">默认值</span>
            <p class="fce-section-desc">选择该模板提交审批时,将自动预填以下内容(用户仍可修改)</p>
            <el-input
              v-if="field.type === 'text' || field.type === 'textarea'"
              v-model="field.defaultValue"
              :type="field.type === 'textarea' ? 'textarea' : 'text'"
              :rows="field.type === 'textarea' ? 2 : undefined"
              :placeholder="defaultPlaceholder(field)"
              clearable
              @input="emitOut"
            />
            <el-input-number
              v-else-if="field.type === 'number'"
              v-model="field.defaultValue"
              :min="field.min"
              :precision="field.precision ?? 0"
              controls-position="right"
              placeholder="选填"
              style="width: 100%"
              @change="emitOut"
            />
            <el-date-picker
              v-else-if="field.type === 'date'"
              v-model="field.defaultValue"
              type="date"
              placeholder="选填"
              format="YYYY-MM-DD"
              value-format="YYYY-MM-DD"
              style="width: 100%"
              clearable
              @change="emitOut"
            />
            <el-date-picker
              v-else-if="field.type === 'datetimerange'"
              v-model="field.defaultValue"
              type="datetimerange"
              range-separator="至"
              start-placeholder="开始时间"
              end-placeholder="结束时间"
              format="YYYY-MM-DD HH:mm:ss"
              value-format="YYYY-MM-DD HH:mm:ss"
              style="width: 100%"
              clearable
              @change="emitOut"
            />
            <el-select
              v-else-if="field.type === 'select'"
              v-model="field.defaultValue"
              placeholder="选填"
              style="width: 100%"
              clearable
              @change="emitOut"
            >
              <el-option
                v-for="o in field.options.filter((x) => x.value !== '' && x.value != null)"
                :key="String(o.value)"
                :label="o.label || o.value"
                :value="o.value"
              />
            </el-select>
          </div>
          <div v-if="field.type === 'select'" class="fce-section fce-section--options">
            <div class="fce-options-head">
              <span class="fce-section-title">下拉选项</span>
              <el-button type="primary" link size="small" :icon="Plus" @click="addOption(field)">
                æ·»åР选项
              </el-button>
            </div>
            <div
              v-for="(opt, oi) in field.options"
              :key="oi"
              class="fce-option-row"
            >
              <span class="fce-option-index">{{ oi + 1 }}</span>
              <el-input v-model="opt.label" placeholder="显示文本" @input="emitOut" />
              <el-input v-model="opt.value" placeholder="选项值" class="fce-option-value" @input="emitOut" />
              <el-button
                type="danger"
                link
                :icon="Delete"
                :disabled="field.options.length <= 1"
                @click="removeOption(field, oi)"
              />
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>
<script setup>
import { Bottom, Delete, Plus, Top } from "@element-plus/icons-vue";
import { reactive, watch } from "vue";
import {
  FORM_CONFIG_PRESETS,
  FORM_FIELD_TYPE_OPTIONS,
  applyFormConfigPreset,
  createEmptyFormConfigData,
  createEmptyFormField,
  formFieldTypeLabel,
} from "../formConfigUtils.js";
const props = defineProps({
  modelValue: { type: Object, default: () => createEmptyFormConfigData() },
});
const emit = defineEmits(["update:modelValue"]);
const inner = reactive(createEmptyFormConfigData());
function typeLabel(type) {
  return formFieldTypeLabel(type);
}
function defaultPlaceholder(field) {
  const name = field.label || "该字段";
  return `选填,选择模板时将预填${name}`;
}
function syncFromProps(v) {
  const src = v || createEmptyFormConfigData();
  inner.summaryPlaceholder = src.summaryPlaceholder || "";
  inner.fields = (src.fields || []).map((f) => ({
    ...createEmptyFormField(),
    ...f,
    _uid: f._uid || createEmptyFormField()._uid,
    options: (f.options || [{ label: "", value: "" }]).map((o) => ({ ...o })),
  }));
}
function emitOut() {
  emit("update:modelValue", {
    summaryPlaceholder: inner.summaryPlaceholder,
    fields: inner.fields.map((f) => ({
      _uid: f._uid,
      key: f.key,
      label: f.label,
      type: f.type,
      required: f.required,
      rows: f.rows,
      min: f.min,
      precision: f.precision,
      defaultValue: cloneDefaultValue(f),
      options: (f.options || []).map((o) => ({ label: o.label, value: o.value })),
    })),
  });
}
function cloneDefaultValue(f) {
  if (f.type === "datetimerange" && Array.isArray(f.defaultValue)) {
    return [...f.defaultValue];
  }
  return f.defaultValue;
}
watch(
  () => props.modelValue,
  (v) => syncFromProps(v),
  { deep: true, immediate: true }
);
function addField() {
  inner.fields.push(createEmptyFormField());
  emitOut();
}
function removeField(index) {
  inner.fields.splice(index, 1);
  emitOut();
}
function moveField(index, delta) {
  const next = index + delta;
  if (next < 0 || next >= inner.fields.length) return;
  const t = inner.fields[index];
  inner.fields[index] = inner.fields[next];
  inner.fields[next] = t;
  emitOut();
}
function resetDefaultValueForType(field) {
  if (field.type === "number") field.defaultValue = undefined;
  else if (field.type === "datetimerange") field.defaultValue = [];
  else field.defaultValue = "";
}
function onTypeChange(field) {
  if (field.type === "select" && (!field.options || !field.options.length)) {
    field.options = [{ label: "", value: "" }];
  }
  resetDefaultValueForType(field);
  emitOut();
}
function addOption(field) {
  field.options.push({ label: "", value: "" });
  emitOut();
}
function removeOption(field, oi) {
  if (field.options.length <= 1) return;
  field.options.splice(oi, 1);
  emitOut();
}
function applyPreset(key) {
  const data = applyFormConfigPreset(key);
  syncFromProps(data);
  emitOut();
}
</script>
<style scoped>
.fce {
  width: 100%;
}
.fce-hint {
  padding: 14px 16px;
  margin-bottom: 14px;
  border-radius: 10px;
  background: linear-gradient(135deg, var(--el-color-primary-light-9) 0%, var(--el-fill-color-blank) 100%);
  border: 1px solid var(--el-color-primary-light-7);
}
.fce-hint-label {
  display: block;
  font-size: 13px;
  font-weight: 600;
  color: var(--el-text-color-primary);
  margin-bottom: 8px;
}
.fce-panel {
  padding: 16px;
  border-radius: 12px;
  background: var(--el-fill-color-lighter);
  border: 1px solid var(--el-border-color-lighter);
}
.fce-toolbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 12px;
  margin-bottom: 16px;
}
.fce-toolbar-left {
  display: flex;
  align-items: center;
  gap: 10px;
}
.fce-title {
  font-size: 15px;
  font-weight: 600;
  color: var(--el-text-color-primary);
}
.fce-toolbar-actions {
  display: flex;
  align-items: center;
  gap: 8px;
}
.fce-empty {
  padding: 24px 0;
}
.fce-list {
  display: flex;
  flex-direction: column;
  gap: 14px;
}
.fce-card {
  position: relative;
  padding: 16px 16px 12px;
  border-radius: 12px;
  background: var(--el-bg-color);
  border: 1px solid var(--el-border-color-lighter);
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
  transition: border-color 0.2s, box-shadow 0.2s;
}
.fce-card:hover {
  border-color: var(--el-color-primary-light-5);
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
}
.fce-card--required {
  border-left: 3px solid var(--el-color-danger-light-3);
}
.fce-card-badge {
  position: absolute;
  top: -10px;
  left: 16px;
  min-width: 22px;
  height: 22px;
  padding: 0 6px;
  border-radius: 11px;
  background: var(--el-color-primary);
  color: #fff;
  font-size: 12px;
  font-weight: 700;
  display: flex;
  align-items: center;
  justify-content: center;
  box-shadow: 0 2px 6px rgba(var(--el-color-primary-rgb), 0.35);
}
.fce-card-head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 12px;
  margin-bottom: 14px;
  padding-top: 4px;
}
.fce-card-title {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  min-width: 0;
}
.fce-card-name {
  font-size: 14px;
  font-weight: 600;
  color: var(--el-text-color-primary);
}
.fce-card-btns {
  display: flex;
  align-items: center;
  gap: 4px;
  flex-shrink: 0;
}
.fce-section {
  margin-bottom: 12px;
  padding-bottom: 12px;
  border-bottom: 1px dashed var(--el-border-color-extra-light);
}
.fce-section:last-child {
  margin-bottom: 0;
  padding-bottom: 0;
  border-bottom: none;
}
.fce-section-title {
  display: block;
  font-size: 12px;
  font-weight: 600;
  color: var(--el-text-color-secondary);
  text-transform: uppercase;
  letter-spacing: 0.5px;
  margin-bottom: 10px;
}
.fce-section-desc {
  margin: -6px 0 10px;
  font-size: 12px;
  color: var(--el-text-color-placeholder);
  line-height: 1.5;
}
.fce-section--default {
  padding: 12px 14px;
  border-radius: 8px;
  background: var(--el-fill-color-lighter);
  border-bottom: none;
  margin-bottom: 0;
}
.fce-section--default .fce-section-title {
  margin-bottom: 4px;
  color: var(--el-color-primary);
  text-transform: none;
  letter-spacing: 0;
  font-size: 13px;
}
.fce-section--options {
  padding-top: 4px;
  border-bottom: none;
  margin-bottom: 0;
}
.fce-field-item {
  margin-bottom: 0;
}
.fce-field-item :deep(.el-form-item__label) {
  font-size: 13px;
  color: var(--el-text-color-regular);
}
.fce-field-item--switch :deep(.el-form-item__content) {
  line-height: 32px;
}
.fce-options-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 10px;
}
.fce-options-head .fce-section-title {
  margin-bottom: 0;
}
.fce-option-row {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-bottom: 8px;
  padding: 8px 10px;
  border-radius: 8px;
  background: var(--el-fill-color-lighter);
}
.fce-option-row:last-child {
  margin-bottom: 0;
}
.fce-option-index {
  flex-shrink: 0;
  width: 20px;
  height: 20px;
  border-radius: 50%;
  background: var(--el-color-info-light-8);
  color: var(--el-text-color-secondary);
  font-size: 11px;
  font-weight: 600;
  display: flex;
  align-items: center;
  justify-content: center;
}
.fce-option-value {
  width: 140px;
  flex-shrink: 0;
}
</style>
src/views/officeProcessAutomation/ApproveManage/approve-template/components/TemplateFlowEditor.vue
@@ -127,6 +127,8 @@
  const normalized = normalizeFlowNodes(rows);
  return normalized.map((n) => ({
    _uid: newUid(),
    id: n.id,
    templateId: n.templateId,
    nodeOrder: n.nodeOrder,
    signMode: n.signMode,
    approverIds: n.approvers.map((a) => a.approverId),
@@ -137,6 +139,8 @@
function publicShape(rows) {
  return normalizeFlowNodes(
    (rows || []).map((r) => ({
      id: r.id,
      templateId: r.templateId,
      nodeOrder: r.nodeOrder,
      signMode: r.signMode,
      approvers: r.approvers || [],
@@ -165,13 +169,21 @@
function onApproversChange(ids, row) {
  const idList = Array.isArray(ids) ? ids : [];
  const prevById = new Map((row.approvers || []).map((a) => [String(a.approverId), a]));
  row.approverIds = idList;
  row.approvers = idList.map((id) => {
    const prev = prevById.get(String(id));
    const u = findUser(id);
    return {
    const item = {
      approverId: id,
      approverName: u ? u.nickName || u.userName || "" : "",
      approverName: u ? u.nickName || u.userName || "" : prev?.approverName || "",
    };
    if (prev?.id != null) item.id = prev.id;
    if (prev?.nodeId != null) item.nodeId = prev.nodeId;
    else if (row.id != null) item.nodeId = row.id;
    if (prev?.templateId != null) item.templateId = prev.templateId;
    else if (row.templateId != null) item.templateId = row.templateId;
    return item;
  });
  emitOut();
}
src/views/officeProcessAutomation/ApproveManage/approve-template/formConfigUtils.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,278 @@
/** å¡«æŠ¥é¡¹ç±»åž‹ï¼ˆä¸Žå®¡æ‰¹æäº¤é¡µ field.type ä¸€è‡´ï¼‰ */
export const FORM_FIELD_TYPE_OPTIONS = [
  { value: "text", label: "单行文本" },
  { value: "textarea", label: "多行文本" },
  { value: "number", label: "数字" },
  { value: "date", label: "日期" },
  { value: "datetimerange", label: "日期时间范围" },
  { value: "select", label: "下拉选择" },
];
/** å¸¸ç”¨é¢„设(如费用报销) */
export const FORM_CONFIG_PRESETS = [
  {
    key: "cost_reimburse",
    label: "费用报销",
    summaryPlaceholder: "请填写报销事由、金额等",
    fields: [
      { key: "summary", label: "报销说明", type: "textarea", required: true, rows: 3 },
      { key: "amount", label: "报销金额(元)", type: "number", required: true, min: 0, precision: 2 },
    ],
  },
  {
    key: "travel_reimburse",
    label: "差旅报销",
    summaryPlaceholder: "出差行程与费用说明",
    fields: [
      { key: "summary", label: "差旅说明", type: "textarea", required: true, rows: 3 },
      { key: "amount", label: "报销金额(元)", type: "number", required: true, min: 0, precision: 2 },
      { key: "tripDays", label: "出差天数", type: "number", required: false, min: 0, precision: 0 },
    ],
  },
  {
    key: "leave",
    label: "请假申请",
    summaryPlaceholder: "请填写请假类型与时间",
    fields: [
      {
        key: "leaveType",
        label: "请假类型",
        type: "select",
        required: true,
        options: [
          { label: "年假", value: "annual" },
          { label: "病假", value: "sick" },
          { label: "事假", value: "personal" },
          { label: "调休", value: "compensatory" },
        ],
      },
      { key: "summary", label: "请假事由", type: "textarea", required: true, rows: 2 },
      { key: "dateRange", label: "请假时间", type: "datetimerange", required: true },
    ],
  },
];
function newFieldUid() {
  return `f_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
export function createEmptyFormField() {
  return {
    _uid: newFieldUid(),
    key: "",
    label: "",
    type: "text",
    required: true,
    rows: 3,
    min: 0,
    precision: 0,
    defaultValue: "",
    options: [{ label: "", value: "" }],
  };
}
/** è§£æžå•项默认值(供提交页 formPayload åˆå§‹åŒ–) */
export function resolveFieldDefaultValue(field) {
  const type = field?.type || "text";
  const dv = field?.defaultValue;
  if (dv === undefined || dv === null || dv === "") {
    if (type === "number") return undefined;
    if (type === "datetimerange") return [];
    return "";
  }
  if (type === "number") {
    const n = Number(dv);
    return Number.isNaN(n) ? undefined : n;
  }
  if (type === "datetimerange") {
    return Array.isArray(dv) ? [...dv] : [];
  }
  return dv;
}
function hasDefaultValue(field) {
  const type = field?.type || "text";
  const dv = field?.defaultValue;
  if (dv === undefined || dv === null) return false;
  if (type === "number") return dv !== "" && !Number.isNaN(Number(dv));
  if (type === "datetimerange") return Array.isArray(dv) && dv.length === 2;
  if (type === "select") return dv !== "";
  return String(dv).trim() !== "";
}
/** æ ¹æ®å­—段定义生成 formPayload åˆå§‹å€¼ï¼ˆå«é»˜è®¤å€¼ï¼‰ */
export function buildFormPayloadFromFields(fields) {
  const payload = {};
  (fields || []).forEach((f) => {
    const key = (f.key || "").trim();
    if (!key) return;
    payload[key] = resolveFieldDefaultValue(f);
  });
  return payload;
}
export function createEmptyFormConfigData() {
  return {
    summaryPlaceholder: "",
    fields: [],
  };
}
function parseFormConfigRaw(formConfig) {
  if (!formConfig) return {};
  if (typeof formConfig === "object") return formConfig;
  try {
    return JSON.parse(formConfig);
  } catch {
    return {};
  }
}
function normalizeDefaultValueFromApi(f) {
  const type = f.type || "text";
  if (f.defaultValue === undefined || f.defaultValue === null) {
    if (type === "number") return undefined;
    if (type === "datetimerange") return [];
    return "";
  }
  if (type === "datetimerange" && Array.isArray(f.defaultValue)) {
    return [...f.defaultValue];
  }
  return f.defaultValue;
}
/** æŽ¥å£ formConfig â†’ ç¼–辑器数据 */
export function parseFormConfigToData(formConfig) {
  const raw = parseFormConfigRaw(formConfig);
  const fields = (raw.fields || raw.formFields || []).map((f) => ({
    _uid: newFieldUid(),
    key: f.key || "",
    label: f.label || "",
    type: f.type || "text",
    required: f.required !== false,
    rows: f.rows ?? 3,
    min: f.min ?? 0,
    precision: f.precision ?? 0,
    defaultValue: normalizeDefaultValueFromApi(f),
    options: (f.options || []).length
      ? f.options.map((o) => ({ label: o.label || "", value: o.value ?? "" }))
      : [{ label: "", value: "" }],
  }));
  return {
    summaryPlaceholder: raw.summaryPlaceholder || "",
    fields,
  };
}
/** ç¼–辑器数据 â†’ æäº¤ç”¨ JSON å­—符串 */
export function buildFormConfigJson(formConfigData) {
  const data = formConfigData || createEmptyFormConfigData();
  const fields = (data.fields || []).map((f) => {
    const item = {
      key: (f.key || "").trim(),
      label: (f.label || "").trim(),
      type: f.type || "text",
      required: f.required !== false,
    };
    if (item.type === "textarea") item.rows = Number(f.rows) || 3;
    if (item.type === "number") {
      item.min = f.min ?? 0;
      item.precision = f.precision ?? 0;
    }
    if (item.type === "select") {
      item.options = (f.options || [])
        .filter((o) => (o.label || "").trim() || o.value !== "" && o.value != null)
        .map((o) => ({ label: (o.label || "").trim(), value: o.value }));
    }
    if (hasDefaultValue(f)) {
      item.defaultValue =
        f.type === "datetimerange" && Array.isArray(f.defaultValue)
          ? f.defaultValue
          : f.defaultValue;
    }
    return item;
  });
  const payload = {
    summaryPlaceholder: (data.summaryPlaceholder || "").trim(),
    fields,
  };
  return JSON.stringify(payload);
}
export function applyFormConfigPreset(presetKey) {
  const preset = FORM_CONFIG_PRESETS.find((p) => p.key === presetKey);
  if (!preset) return createEmptyFormConfigData();
  return parseFormConfigToData({
    summaryPlaceholder: preset.summaryPlaceholder,
    fields: preset.fields,
  });
}
export function validateFormConfigData(formConfigData) {
  const fields = formConfigData?.fields || [];
  if (!fields.length) {
    return { ok: true };
  }
  const keys = new Set();
  for (let i = 0; i < fields.length; i++) {
    const f = fields[i];
    const key = (f.key || "").trim();
    const label = (f.label || "").trim();
    if (!key) return { ok: false, message: `请填写第 ${i + 1} ä¸ªå¡«æŠ¥é¡¹çš„字段标识` };
    if (!label) return { ok: false, message: `请填写第 ${i + 1} ä¸ªå¡«æŠ¥é¡¹çš„æ˜¾ç¤ºåç§°` };
    if (keys.has(key)) return { ok: false, message: `字段标识「${key}」重复,请修改` };
    keys.add(key);
    if (f.type === "select") {
      const opts = (f.options || []).filter((o) => (o.label || "").trim() && o.value !== "" && o.value != null);
      if (!opts.length) return { ok: false, message: `请为「${label}」配置至少一个下拉选项` };
    }
  }
  return { ok: true };
}
export function formFieldTypeLabel(type) {
  return FORM_FIELD_TYPE_OPTIONS.find((x) => x.value === type)?.label || type || "—";
}
export function formatDefaultValueDisplay(field) {
  const dv = field?.defaultValue;
  if (dv === undefined || dv === null || dv === "") return "—";
  if (field?.type === "datetimerange" && Array.isArray(dv)) {
    return dv.length === 2 ? `${dv[0]} ~ ${dv[1]}` : "—";
  }
  if (field?.type === "select") {
    const opt = (field.options || []).find((o) => String(o.value) === String(dv));
    return opt?.label || String(dv);
  }
  return String(dv);
}
/** å°†åŽç«¯æ¨¡æ¿è¡Œè½¬ä¸ºæäº¤é¡µæ¨¡æ¿ç»“构(含 fields é»˜è®¤å€¼ï¼‰ */
export function buildSubmitTemplateFromRow(row) {
  const cfg = parseFormConfigToData(row?.formConfig);
  const fields = (cfg.fields || []).map(({ _uid, ...rest }) => ({
    ...rest,
    key: rest.key,
    label: rest.label,
    type: rest.type,
    required: rest.required,
    rows: rest.rows,
    min: rest.min,
    precision: rest.precision,
    defaultValue: rest.defaultValue,
    options: rest.options,
  }));
  return {
    label: row?.templateName || "审批",
    approvalType: cfg.approvalType || "",
    summaryPlaceholder: cfg.summaryPlaceholder || "",
    approvalMode: cfg.approvalMode || "parallel",
    fields,
  };
}
export function formConfigFieldsSummary(formConfigData) {
  const fields = formConfigData?.fields || [];
  if (!fields.length) return "—";
  return fields.map((f) => f.label || f.key || "未命名").join("、");
}
src/views/officeProcessAutomation/ApproveManage/approve-template/index.vue
@@ -1,4 +1,4 @@
<!--OA模块:审批模板(系统常用 + è‡ªå®šä¹‰å¤šèŠ‚ç‚¹æµç¨‹ï¼‰-->
<!--OA模块:审批模板-->
<template>
  <div class="app-container approve-template-page">
    <el-tabs v-model="activeTab" class="template-tabs">
@@ -9,8 +9,9 @@
            ä»¥ä¸‹ä¸º OA æ¨¡å—内置的常用审批类型,填报字段与默认审批方式由系统维护;提交审批时可直接选用。
          </template>
        </el-alert>
        <div class="builtin-grid">
          <div v-for="item in builtinTemplates" :key="item.key" class="builtin-card">
        <div v-loading="builtinLoading" class="builtin-grid">
          <template v-if="builtinTemplates.length">
            <div v-for="item in builtinTemplates" :key="item.key" class="builtin-card">
            <span class="builtin-label">{{ item.label }}</span>
            <p class="builtin-summary">{{ item.summary }}</p>
            <div class="builtin-meta">
@@ -20,7 +21,9 @@
              </el-tag>
              <el-tag size="small" type="info" effect="plain">只读</el-tag>
            </div>
          </div>
            </div>
          </template>
          <el-empty v-else-if="!builtinLoading" description="暂无系统常用审批模板" :image-size="80" />
        </div>
      </el-tab-pane>
@@ -66,7 +69,7 @@
    <el-dialog
      v-model="formDialog.visible"
      :title="formDialog.title"
      width="960px"
      width="1020px"
      append-to-body
      destroy-on-close
      class="template-form-dialog"
@@ -74,12 +77,24 @@
    >
      <el-form ref="formRef" :model="form" :rules="formRules" label-width="100px">
        <el-row :gutter="20">
          <el-col :span="12">
          <el-col :span="8">
            <el-form-item label="模板名称" prop="templateName">
              <el-input v-model="form.templateName" placeholder="如:项目立项审批" maxlength="50" show-word-limit />
            </el-form-item>
          </el-col>
          <el-col :span="12">
          <el-col :span="8">
            <el-form-item label="模板类型" prop="templateType">
              <el-select v-model="form.templateType" placeholder="请选择" style="width: 100%">
                <el-option
                  v-for="opt in TEMPLATE_TYPE_OPTIONS"
                  :key="opt.value"
                  :label="opt.label"
                  :value="opt.value"
                />
              </el-select>
            </el-form-item>
          </el-col>
          <el-col :span="8">
            <el-form-item label="启用状态">
              <el-switch v-model="form.enabled" active-text="启用" inactive-text="停用" />
            </el-form-item>
@@ -94,6 +109,10 @@
            maxlength="200"
            show-word-limit
          />
        </el-form-item>
        <el-form-item label="填报配置">
          <FormConfigEditor v-model="form.formConfigData" />
          <p class="flow-tip">配置提交审批时需填写的表单项,保存后写入 formConfig(JSON)。</p>
        </el-form-item>
        <el-form-item label="审批流程" required>
          <TemplateFlowEditor v-model="form.flowNodes" :user-options="flowUserOptions" />
@@ -110,17 +129,44 @@
    <!-- è¯¦æƒ… -->
    <el-dialog v-model="detailDialog.visible" title="模板详情" width="880px" append-to-body destroy-on-close>
      <div v-loading="detailLoading" class="detail-dialog-body">
      <el-descriptions :column="2" border>
        <el-descriptions-item label="模板名称">{{ detailRow.templateName }}</el-descriptions-item>
        <el-descriptions-item label="模板类型">{{ templateTypeLabel(detailRow.templateType) }}</el-descriptions-item>
        <el-descriptions-item label="状态">
          <el-tag :type="detailRow.enabled !== false ? 'success' : 'info'" size="small">
            {{ detailRow.enabled !== false ? "启用" : "停用" }}
          </el-tag>
        </el-descriptions-item>
        <el-descriptions-item label="说明" :span="2">{{ detailRow.description || "—" }}</el-descriptions-item>
        <el-descriptions-item label="创建时间">{{ detailRow.createTime || "—" }}</el-descriptions-item>
        <el-descriptions-item label="更新时间">{{ detailRow.updateTime || "—" }}</el-descriptions-item>
        <el-descriptions-item label="填报提示" :span="2">
          {{ detailFormConfig.summaryPlaceholder || "—" }}
        </el-descriptions-item>
        <el-descriptions-item label="创建人">{{ detailRow.createdUserName || "—" }}</el-descriptions-item>
        <el-descriptions-item label="创建时间">{{ formatDisplayTime(detailRow.createdTime) }}</el-descriptions-item>
        <el-descriptions-item label="更新时间">{{ formatDisplayTime(detailRow.updatedTime) }}</el-descriptions-item>
      </el-descriptions>
      <el-divider content-position="left">填报项({{ detailFormConfig.fields?.length || 0 }} é¡¹ï¼‰</el-divider>
      <el-table
        v-if="detailFormConfig.fields?.length"
        :data="detailFormConfig.fields"
        border
        size="small"
        class="mb16"
      >
        <el-table-column prop="label" label="显示名称" min-width="120" />
        <el-table-column prop="key" label="字段标识" min-width="100" />
        <el-table-column label="类型" width="100">
          <template #default="{ row }">{{ formFieldTypeLabel(row.type) }}</template>
        </el-table-column>
        <el-table-column label="必填" width="70" align="center">
          <template #default="{ row }">{{ row.required !== false ? "是" : "否" }}</template>
        </el-table-column>
        <el-table-column label="默认值" min-width="120" show-overflow-tooltip>
          <template #default="{ row }">{{ formatDefaultValueDisplay(row) }}</template>
        </el-table-column>
      </el-table>
      <el-empty v-else description="未配置填报项" :image-size="48" class="mb16" />
      <el-divider content-position="left">审批流程({{ detailRow.flowNodes?.length || 0 }} ä¸ªèŠ‚ç‚¹ï¼‰</el-divider>
      <div v-if="detailRow.flowNodes?.length" class="detail-flow">
        <div v-for="(node, index) in detailRow.flowNodes" :key="index" class="detail-node">
@@ -145,6 +191,7 @@
        </div>
      </div>
      <el-empty v-else description="暂无流程节点" :image-size="60" />
      </div>
      <template #footer>
        <el-button @click="detailDialog.visible = false">关 é—­</el-button>
        <el-button type="primary" @click="editFromDetail">编 è¾‘</el-button>
@@ -156,16 +203,23 @@
<script setup>
import { ArrowRight, Document, Plus, RefreshRight } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { onMounted, ref } from "vue";
import { computed, onMounted, ref } from "vue";
import { userListNoPageByTenantId } from "@/api/system/user.js";
import FormConfigEditor from "./components/FormConfigEditor.vue";
import TemplateFlowEditor from "./components/TemplateFlowEditor.vue";
import { formatDisplayTime } from "./approveTemplateConstants.js";
import { formatDefaultValueDisplay, formFieldTypeLabel, parseFormConfigToData } from "./formConfigUtils.js";
import { useApproveTemplate } from "./useApproveTemplate.js";
const at = useApproveTemplate();
const {
  Search,
  TEMPLATE_TYPE_OPTIONS,
  templateTypeLabel,
  activeTab,
  builtinTemplates,
  builtinLoading,
  loadBuiltinTemplates,
  nodeSignModeLabel,
  searchForm,
  tableLoading,
@@ -178,6 +232,8 @@
  formRules,
  detailDialog,
  detailRow,
  detailLoading,
  fetchTemplateList,
  handleQuery,
  resetSearch,
  pagination,
@@ -187,6 +243,10 @@
} = at;
const flowUserOptions = ref([]);
const detailFormConfig = computed(() =>
  parseFormConfigToData(detailRow.value?.formConfigData ?? detailRow.value?.formConfig)
);
function unwrapArray(payload) {
  if (Array.isArray(payload)) return payload;
@@ -227,7 +287,8 @@
onMounted(() => {
  loadUsers();
  handleQuery();
  loadBuiltinTemplates();
  fetchTemplateList();
});
</script>
@@ -237,6 +298,9 @@
}
.mb16 {
  margin-bottom: 16px;
}
.mb16.el-empty {
  padding: 8px 0;
}
.ml10 {
  margin-left: 10px;
@@ -355,6 +419,9 @@
  transform: translateY(-50%);
  color: var(--el-text-color-placeholder);
}
.detail-dialog-body {
  min-height: 120px;
}
.text-muted {
  font-size: 12px;
  color: var(--el-text-color-placeholder);
src/views/officeProcessAutomation/ApproveManage/approve-template/useApproveTemplate.js
@@ -1,24 +1,49 @@
import { Search } from "@element-plus/icons-vue";
import dayjs from "dayjs";
import { ElMessageBox } from "element-plus";
import { computed, reactive, ref, watch } from "vue";
import {
  addApprovalTemplate,
  deleteApprovalTemplate,
  getApprovalTemplateDetail,
  listApprovalTemplate,
  listApprovalTemplatePage,
  TEMPLATE_TYPE_BUILTIN,
  TEMPLATE_TYPE_CUSTOM,
  TEMPLATE_TYPE_OPTIONS,
  updateApprovalTemplate,
} from "@/api/officeProcessAutomation/approvalTemplate.js";
import { Search } from "@element-plus/icons-vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { reactive, ref } from "vue";
import {
  buildApprovalTemplateListParams,
  createEmptyTemplateForm,
  createInitialMockTemplates,
  flowNodesSummary,
  getBuiltinTemplates,
  loadStoredTemplates,
  mapBuiltinCardFromApi,
  mapTemplateFromApi,
  mapTemplateToApi,
  nodeSignModeLabel,
  saveStoredTemplates,
  templateTypeLabel,
  unwrapTemplateList,
  formatDisplayTime,
  unwrapTemplateDetail,
  validateTemplateForm,
} from "./approveTemplateConstants.js";
import { parseFormConfigToData } from "./formConfigUtils.js";
const LEGACY_STORAGE_KEY = "oa_approve_template_custom_v1";
function clearLegacyStorage() {
  try {
    localStorage.removeItem(LEGACY_STORAGE_KEY);
  } catch {
    /* ignore */
  }
}
export function useApproveTemplate() {
  const stored = loadStoredTemplates();
  const allTemplates = ref(stored?.length ? stored : createInitialMockTemplates());
  clearLegacyStorage();
  const activeTab = ref("custom");
  const builtinTemplates = getBuiltinTemplates();
  const builtinTemplates = ref([]);
  const builtinLoading = ref(false);
  const searchForm = reactive({
    keyword: "",
@@ -27,6 +52,7 @@
  const tableLoading = ref(false);
  const page = reactive({ current: 1, size: 10, total: 0 });
  const tableData = ref([]);
  const formDialog = reactive({ visible: false, title: "", mode: "add" });
  const form = reactive(createEmptyTemplateForm());
@@ -34,44 +60,22 @@
  const detailDialog = reactive({ visible: false });
  const detailRow = ref({});
  const filteredList = computed(() => {
    let list = [...allTemplates.value];
    const kw = (searchForm.keyword || "").trim().toLowerCase();
    if (kw) {
      list = list.filter((r) => {
        const name = (r.templateName || "").toLowerCase();
        const desc = (r.description || "").toLowerCase();
        return name.includes(kw) || desc.includes(kw);
      });
    }
    if (searchForm.enabledOnly) {
      list = list.filter((r) => r.enabled !== false);
    }
    return list.sort((a, b) => (String(a.updateTime) < String(b.updateTime) ? 1 : -1));
  });
  watch(
    filteredList,
    (list) => {
      page.total = list.length;
      const maxPage = Math.max(1, Math.ceil(list.length / page.size) || 1);
      if (page.current > maxPage) page.current = maxPage;
    },
    { immediate: true }
  );
  const tableData = computed(() => {
    const start = (page.current - 1) * page.size;
    return filteredList.value.slice(start, start + page.size);
  });
  const detailLoading = ref(false);
  const formRules = {
    templateName: [{ required: true, message: "请输入模板名称", trigger: "blur" }],
    templateType: [{ required: true, message: "请选择模板类型", trigger: "change" }],
  };
  const tableColumn = ref([
    { label: "模板名称", prop: "templateName", minWidth: 140 },
    {
      label: "模板类型",
      prop: "templateType",
      width: 100,
      align: "center",
      formatData: (v) => templateTypeLabel(v),
    },
    { label: "说明", prop: "description", minWidth: 160, showOverflowTooltip: true },
    {
      label: "节点数",
@@ -96,31 +100,72 @@
      formatData: (v) => (v !== false ? "启用" : "停用"),
      formatType: (v) => (v !== false ? "success" : "info"),
    },
    { label: "更新时间", prop: "updateTime", width: 170 },
    {
      label: "创建时间",
      prop: "createdTime",
      width: 170,
      showOverflowTooltip: true,
      formatData: (v) => formatDisplayTime(v),
    },
    {
      label: "更新时间",
      prop: "updatedTime",
      width: 170,
      showOverflowTooltip: true,
      formatData: (v) => formatDisplayTime(v),
    },
    {
      dataType: "action",
      label: "操作",
      align: "center",
      fixed: "right",
      width: 200,
      width: 220,
      operation: [
        { name: "详情", type: "text", clickFun: (row) => openDetail(row) },
        { name: "编辑", type: "text", clickFun: (row) => openFormDialog("edit", row) },
        { name: "删除", type: "text", clickFun: (row) => removeTemplate(row) },
        {
          name: "删除",
          type: "danger",
          link: true,
          clickFun: (row) => removeTemplate(row),
        },
      ],
    },
  ]);
  function persist() {
    saveStoredTemplates(allTemplates.value);
  async function loadBuiltinTemplates() {
    builtinLoading.value = true;
    try {
      const res = await listApprovalTemplate(TEMPLATE_TYPE_BUILTIN);
      builtinTemplates.value = unwrapTemplateList(res).map(mapBuiltinCardFromApi);
    } catch {
      builtinTemplates.value = [];
      ElMessage.warning("系统常用审批加载失败");
    } finally {
      builtinLoading.value = false;
    }
  }
  async function fetchTemplateList() {
    tableLoading.value = true;
    try {
      const res = await listApprovalTemplatePage(
        buildApprovalTemplateListParams({ page, searchForm })
      );
      const data = res?.data || {};
      tableData.value = (data.records || []).map(mapTemplateFromApi);
      page.total = Number(data.total || 0);
    } catch {
      tableData.value = [];
      page.total = 0;
    } finally {
      tableLoading.value = false;
    }
  }
  function handleQuery() {
    tableLoading.value = true;
    page.current = 1;
    setTimeout(() => {
      tableLoading.value = false;
    }, 150);
    fetchTemplateList();
  }
  function resetSearch() {
@@ -132,6 +177,7 @@
  function pagination({ page: p, limit }) {
    page.current = p;
    page.size = limit;
    fetchTemplateList();
  }
  function resetForm(row) {
@@ -145,6 +191,11 @@
      id: row.id,
      templateName: row.templateName || "",
      description: row.description || "",
      templateType: row.templateType ?? TEMPLATE_TYPE_CUSTOM,
      formConfig: row.formConfig || "",
      formConfigData: JSON.parse(
        JSON.stringify(row.formConfigData || parseFormConfigToData(row.formConfig))
      ),
      enabled: row.enabled !== false,
      flowNodes: JSON.parse(JSON.stringify(row.flowNodes || [base.flowNodes[0]])),
    });
@@ -152,19 +203,27 @@
  function openFormDialog(mode, row) {
    formDialog.mode = mode;
    formDialog.title = mode === "add" ? "新建自定义审批模板" : "编辑自定义审批模板";
    formDialog.title = mode === "add" ? "新建审批模板" : "编辑审批模板";
    resetForm(mode === "edit" ? row : null);
    formDialog.visible = true;
  }
  function openDetail(row) {
    detailRow.value = { ...row };
  async function openDetail(row) {
    if (row?.id == null || row.id === "") {
      ElMessage.warning("无法查看详情:缺少模板 ID");
      return;
    }
    detailDialog.visible = true;
  }
  function isNameDuplicate(name, excludeId) {
    const n = (name || "").trim();
    return allTemplates.value.some((t) => t.templateName?.trim() === n && t.id !== excludeId);
    detailLoading.value = true;
    detailRow.value = {};
    try {
      const res = await getApprovalTemplateDetail(row.id);
      detailRow.value = mapTemplateFromApi(unwrapTemplateDetail(res));
    } catch {
      detailDialog.visible = false;
    } finally {
      detailLoading.value = false;
    }
  }
  async function submitForm() {
@@ -178,64 +237,70 @@
    if (!validated.ok) {
      return { message: validated.message };
    }
    if (isNameDuplicate(validated.name, form.id)) {
      return { message: "模板名称已存在,请更换名称" };
    if (formDialog.mode === "edit" && !form.id) {
      return { message: "缺少模板 ID,无法保存修改" };
    }
    const now = dayjs().format("YYYY-MM-DD HH:mm:ss");
    if (formDialog.mode === "add") {
      allTemplates.value.unshift({
        id: `tpl_${Date.now()}`,
        templateName: validated.name,
        description: (form.description || "").trim(),
        enabled: form.enabled !== false,
        createTime: now,
        updateTime: now,
        flowNodes: validated.nodes,
      });
    } else {
      const hit = allTemplates.value.find((t) => t.id === form.id);
      if (!hit) return { message: "模板不存在或已删除" };
      hit.templateName = validated.name;
      hit.description = (form.description || "").trim();
      hit.enabled = form.enabled !== false;
      hit.flowNodes = validated.nodes;
      hit.updateTime = now;
    const dto = mapTemplateToApi(form);
    try {
      if (formDialog.mode === "add") {
        await addApprovalTemplate(dto);
      } else {
        await updateApprovalTemplate(dto);
      }
    } catch {
      return false;
    }
    persist();
    formDialog.visible = false;
    page.current = 1;
    await fetchTemplateList();
    if (dto.templateType === TEMPLATE_TYPE_BUILTIN) {
      await loadBuiltinTemplates();
    }
    return { ok: true };
  }
  async function removeTemplate(row) {
    if (row?.id == null || row.id === "") {
      ElMessage.warning("无法删除:缺少模板 ID");
      return;
    }
    const name = row.templateName || "未命名模板";
    try {
      await ElMessageBox.confirm(`确定删除模板「${row.templateName}」吗?`, "提示", {
        type: "warning",
        confirmButtonText: "删除",
        cancelButtonText: "取消",
      });
      await ElMessageBox.confirm(
        `确定要删除审批模板「${name}」吗?删除后不可恢复。`,
        "删除确认",
        {
          type: "warning",
          confirmButtonText: "确定删除",
          cancelButtonText: "取消",
          distinguishCancelAndClose: true,
          autofocus: false,
        }
      );
    } catch {
      return;
    }
    const idx = allTemplates.value.findIndex((t) => t.id === row.id);
    if (idx >= 0) {
      allTemplates.value.splice(idx, 1);
      persist();
    try {
      await deleteApprovalTemplate([row.id]);
      ElMessage.success("删除成功");
      await fetchTemplateList();
      if (row.templateType === TEMPLATE_TYPE_BUILTIN) {
        await loadBuiltinTemplates();
      }
    } catch {
      /* é”™è¯¯ç”±æ‹¦æˆªå™¨æç¤º */
    }
  }
  function toggleEnabled(row) {
    const hit = allTemplates.value.find((t) => t.id === row.id);
    if (!hit) return;
    hit.enabled = !hit.enabled;
    hit.updateTime = dayjs().format("YYYY-MM-DD HH:mm:ss");
    persist();
  }
  return {
    Search,
    TEMPLATE_TYPE_OPTIONS,
    templateTypeLabel,
    activeTab,
    builtinTemplates,
    builtinLoading,
    loadBuiltinTemplates,
    fetchTemplateList,
    nodeSignModeLabel,
    flowNodesSummary,
    searchForm,
@@ -249,12 +314,12 @@
    formRules,
    detailDialog,
    detailRow,
    detailLoading,
    handleQuery,
    resetSearch,
    pagination,
    openFormDialog,
    openDetail,
    submitForm,
    toggleEnabled,
  };
}