yyb
14 小时以前 6c324a234060820d031014ea657af5aa0b0d478e
审批模板
已添加3个文件
已修改6个文件
1672 ■■■■ 文件已修改
src/api/officeProcessAutomation/approvalTemplate.js 63 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-list/approveListConstants.js 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-list/useApproveList.js 16 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-template/approveTemplateConstants.js 310 ●●●● 补丁 | 查看 | 原始文档 | 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/approvalTemplate.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,63 @@
import request from "@/utils/request";
/** æ¨¡æ¿ç±»åž‹ï¼š1 ç³»ç»Ÿå†…置,2 è‡ªå®šä¹‰ */
export const TEMPLATE_TYPE_BUILTIN = 1;
export const TEMPLATE_TYPE_CUSTOM = 2;
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,5 @@
import dayjs from "dayjs";
import { buildFormPayloadFromFields } from "../approve-template/formConfigUtils.js";
/** å®¡æ‰¹ç±»åž‹ï¼ˆä¸ŽåŽç«¯å­—段 approvalType å¯¹é½ï¼ŒåŽæœŸå¯åŒæ­¥ï¼‰ */
export const APPROVAL_TYPE_OPTIONS = [
@@ -299,16 +300,12 @@
  }
}
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) {
  const tpl = templateOverride || SUBMIT_TEMPLATES[templateKey];
  const payload = tpl?.fields?.length ? buildFormPayloadFromFields(tpl.fields) : { summary: "" };
  return {
    templateKey: templateKey || "",
    templateSnapshot: null,
    approvalMode: tpl?.approvalMode || "parallel",
    formPayload: payload,
    approvalFlowNodes: buildDefaultFlowNodes(),
src/views/officeProcessAutomation/ApproveManage/approve-list/useApproveList.js
@@ -15,6 +15,7 @@
  saveStoredRows,
  buildDefaultFlowNodes,
} from "./approveListConstants.js";
import { buildSubmitTemplateFromRow } from "../approve-template/formConfigUtils.js";
function advanceFlow(row, result, opinion) {
  const nodes = row.approvalFlowNodes || [];
@@ -110,7 +111,9 @@
    return filteredList.value.slice(start, start + page.size);
  });
  const activeTemplate = computed(() => SUBMIT_TEMPLATES[submitForm.templateKey] || null);
  const activeTemplate = computed(
    () => submitForm.templateSnapshot || SUBMIT_TEMPLATES[submitForm.templateKey] || null
  );
  const submitFormRules = computed(() => {
    const rules = {
@@ -232,8 +235,15 @@
    submitDialog.visible = true;
  }
  function onTemplatePick(key) {
    Object.assign(submitForm, createEmptySubmitForm(key));
  /** @param {string} key å†…置模板 key æˆ–自定义 id */
  function onTemplatePick(key, templateRow) {
    const base = templateRow
      ? createEmptySubmitForm(key, buildSubmitTemplateFromRow(templateRow))
      : createEmptySubmitForm(key);
    Object.assign(submitForm, {
      ...base,
      templateSnapshot: templateRow ? buildSubmitTemplateFromRow(templateRow) : null,
    });
    submitDialog.step = 2;
  }
src/views/officeProcessAutomation/ApproveManage/approve-template/approveTemplateConstants.js
@@ -1,5 +1,21 @@
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) {
  return TEMPLATE_TYPE_OPTIONS.find((x) => x.value === type)?.label || "—";
}
/** èŠ‚ç‚¹å†…å®¡æ‰¹æ–¹å¼ï¼šä¼šç­¾ / æˆ–ç­¾ */
export const NODE_SIGN_MODE_OPTIONS = [
@@ -7,18 +23,206 @@
  { 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,
    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 }) {
  const params = {
    current: page.current,
    size: page.size,
  };
  if (searchForm?.templateType != null && searchForm.templateType !== "") {
    params.templateType = searchForm.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 +246,9 @@
    id: "",
    templateName: "",
    description: "",
    templateType: TEMPLATE_TYPE_CUSTOM,
    formConfig: "",
    formConfigData: createEmptyFormConfigData(),
    enabled: true,
    flowNodes: [createEmptyNode(1)],
  };
@@ -50,11 +257,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 +283,8 @@
      return { ok: false, message: `请为第 ${i + 1} ä¸ªèŠ‚ç‚¹é€‰æ‹©è‡³å°‘ä¸€åå®¡æ‰¹äºº` };
    }
  }
  const cfgCheck = validateFormConfigData(form.formConfigData);
  if (!cfgCheck.ok) return cfgCheck;
  return { ok: true, nodes, name };
}
@@ -83,78 +297,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,
  };
}