From 6c324a234060820d031014ea657af5aa0b0d478e Mon Sep 17 00:00:00 2001
From: yyb <995253665@qq.com>
Date: 星期二, 19 五月 2026 13:29:20 +0800
Subject: [PATCH] 审批模板

---
 src/views/officeProcessAutomation/ApproveManage/approve-template/components/FormConfigEditor.vue   |  624 ++++++++++++++++++++++
 src/api/officeProcessAutomation/approvalTemplate.js                                                |   63 ++
 src/views/officeProcessAutomation/ApproveManage/approve-list/useApproveList.js                     |   16 
 src/views/officeProcessAutomation/ApproveManage/approve-template/approveTemplateConstants.js       |  310 ++++++++---
 src/views/officeProcessAutomation/ApproveManage/approve-list/approveListConstants.js               |   13 
 src/views/officeProcessAutomation/ApproveManage/approve-template/formConfigUtils.js                |  278 +++++++++
 src/views/officeProcessAutomation/ApproveManage/approve-template/components/TemplateFlowEditor.vue |   16 
 src/views/officeProcessAutomation/ApproveManage/approve-template/useApproveTemplate.js             |  263 +++++---
 src/views/officeProcessAutomation/ApproveManage/approve-template/index.vue                         |   89 ++
 9 files changed, 1,464 insertions(+), 208 deletions(-)

diff --git a/src/api/officeProcessAutomation/approvalTemplate.js b/src/api/officeProcessAutomation/approvalTemplate.js
new file mode 100644
index 0000000..28c4234
--- /dev/null
+++ b/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",
+  });
+}
+
+/** 鏂板瀹℃壒妯℃澘锛坆ody 涓� ApprovalTemplateDto锛� */
+export function addApprovalTemplate(approvalTemplateDto) {
+  return request({
+    url: "/approvalTemplate/add",
+    method: "post",
+    data: approvalTemplateDto,
+  });
+}
+
+/** 淇敼瀹℃壒妯℃澘锛坆ody 涓� ApprovalTemplateDto锛� */
+export function updateApprovalTemplate(approvalTemplateDto) {
+  return request({
+    url: "/approvalTemplate/update",
+    method: "put",
+    data: approvalTemplateDto,
+  });
+}
+
+/** 鍒犻櫎瀹℃壒妯℃澘锛坆ody 涓烘ā鏉� 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,
+  });
+}
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-list/approveListConstants.js b/src/views/officeProcessAutomation/ApproveManage/approve-list/approveListConstants.js
index 447627a..e4bb66f 100644
--- a/src/views/officeProcessAutomation/ApproveManage/approve-list/approveListConstants.js
+++ b/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(),
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-list/useApproveList.js b/src/views/officeProcessAutomation/ApproveManage/approve-list/useApproveList.js
index 48103aa..c3c3241 100644
--- a/src/views/officeProcessAutomation/ApproveManage/approve-list/useApproveList.js
+++ b/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;
   }
 
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-template/approveTemplateConstants.js b/src/views/officeProcessAutomation/ApproveManage/approve-template/approveTemplateConstants.js
index 81884a1..1ffdbf5 100644
--- a/src/views/officeProcessAutomation/ApproveManage/approve-template/approveTemplateConstants.js
+++ b/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";
+}
+
+/** 灏嗘帴鍙h繑鍥炵殑妯℃澘杞负銆岀郴缁熷父鐢ㄥ鎵广�嶅崱鐗囨暟鎹� */
+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 瑙e寘 */
+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锛圓pprovalTemplateDto锛� */
+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: "鎶�鏈礋璐d汉" },
-            { 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 */
-  }
 }
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-template/components/FormConfigEditor.vue b/src/views/officeProcessAutomation/ApproveManage/approve-template/components/FormConfigEditor.vue
new file mode 100644
index 0000000..1881f60
--- /dev/null
+++ b/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>
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-template/components/TemplateFlowEditor.vue b/src/views/officeProcessAutomation/ApproveManage/approve-template/components/TemplateFlowEditor.vue
index 45b32c0..f3f9540 100644
--- a/src/views/officeProcessAutomation/ApproveManage/approve-template/components/TemplateFlowEditor.vue
+++ b/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();
 }
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-template/formConfigUtils.js b/src/views/officeProcessAutomation/ApproveManage/approve-template/formConfigUtils.js
new file mode 100644
index 0000000..0cb20ea
--- /dev/null
+++ b/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: "" }],
+  };
+}
+
+/** 瑙f瀽鍗曢」榛樿鍊硷紙渚涙彁浜ら〉 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("銆�");
+}
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-template/index.vue b/src/views/officeProcessAutomation/ApproveManage/approve-template/index.vue
index a79d546..9d7324c 100644
--- a/src/views/officeProcessAutomation/ApproveManage/approve-template/index.vue
+++ b/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锛圝SON锛夈��</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);
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-template/useApproveTemplate.js b/src/views/officeProcessAutomation/ApproveManage/approve-template/useApproveTemplate.js
index c56d055..8489e13 100644
--- a/src/views/officeProcessAutomation/ApproveManage/approve-template/useApproveTemplate.js
+++ b/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,
   };
 }

--
Gitblit v1.9.3