From 5b248a9716688d8132cfb02b4ba0abecd4060b06 Mon Sep 17 00:00:00 2001
From: yyb <995253665@qq.com>
Date: 星期三, 20 五月 2026 11:49:08 +0800
Subject: [PATCH] 审批模板流程化

---
 src/views/officeProcessAutomation/ApproveManage/approve-template/components/FormConfigEditor.vue          |  130 ++++-
 src/views/officeProcessAutomation/ApproveManage/approve-list/index.vue                                    |   83 --
 src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalTemplateBindingUtils.js            |   91 +++
 src/views/officeProcessAutomation/ApproveManage/approve-list/components/FormPayloadFields.vue             |   44 +
 src/views/officeProcessAutomation/ApproveManage/approve-template/selectOptionSource.js                    |  140 +++++
 src/views/officeProcessAutomation/ApproveManage/approve-template/formConfigUtils.js                       |   30 +
 src/views/officeProcessAutomation/ApproveManage/approve-shared/useFlowUserOptions.js                      |   35 +
 src/views/officeProcessAutomation/ApproveManage/approve-template/useApproveTemplate.js                    |    1 
 src/views/officeProcessAutomation/HrManage/regular-apply/index.vue                                        |  101 +++
 src/views/officeProcessAutomation/ApproveManage/approve-list/useApproveList.js                            |   61 -
 src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalModuleRegistry.js                  |  102 +++
 src/views/officeProcessAutomation/ApproveManage/approve-template/approveTemplateConstants.js              |   16 
 src/views/officeProcessAutomation/ApproveManage/approve-template/useSelectOptionSources.js                |   45 +
 src/views/officeProcessAutomation/ApproveManage/approve-list/approveListConstants.js                      |   23 
 src/views/officeProcessAutomation/ApproveManage/approve-shared/useApprovalTemplateBinding.js              |  229 ++++++++
 src/views/officeProcessAutomation/ApproveManage/approve-shared/components/ApprovalTemplateBindDialog.vue  |  158 ++++++
 src/views/officeProcessAutomation/ApproveManage/approve-shared/components/ApprovalTemplatePicker.vue      |   85 +++
 src/views/officeProcessAutomation/ApproveManage/approve-shared/components/ApprovalTemplateFormSection.vue |   95 +++
 src/views/officeProcessAutomation/ApproveManage/approve-template/index.vue                                |   79 +++
 19 files changed, 1,392 insertions(+), 156 deletions(-)

diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-list/approveListConstants.js b/src/views/officeProcessAutomation/ApproveManage/approve-list/approveListConstants.js
index 8974cf4..090dbb1 100644
--- a/src/views/officeProcessAutomation/ApproveManage/approve-list/approveListConstants.js
+++ b/src/views/officeProcessAutomation/ApproveManage/approve-list/approveListConstants.js
@@ -8,6 +8,7 @@
   nodeSignModeLabel,
 } from "../approve-template/approveTemplateConstants.js";
 import { buildFormPayloadFromFields, parseFormConfigToData } from "../approve-template/formConfigUtils.js";
+import { isDynamicOptionSource, selectOptionSourceLabel } from "../approve-template/selectOptionSource.js";
 
 /** 瀹℃壒绫诲瀷锛堜笌鍚庣瀛楁 approvalType 瀵归綈锛屽悗鏈熷彲鍚屾锛� */
 export const APPROVAL_TYPE_OPTIONS = [
@@ -176,6 +177,9 @@
 /** 鍗曞瓧娈靛睍绀哄�硷紙璇︽儏鍙锛� */
 export function formatFieldDisplayValue(field, val) {
   if (val == null || val === "" || (Array.isArray(val) && !val.length)) return "鈥�";
+  if (field?.type === "select" && isDynamicOptionSource(field.optionSource)) {
+    return `${selectOptionSourceLabel(field.optionSource)}锛�${String(val)}`;
+  }
   if (field?.type === "select" && field.options?.length) {
     const hit = field.options.find((o) => String(o.value) === String(val));
     return hit?.label || String(val);
@@ -286,6 +290,12 @@
     formConfig: buildInstanceFormConfigJson({ ...tpl, fields: tpl.fields || submitForm?.formFieldDefs }, payload),
     tasks: taskList,
   };
+
+  const attachments =
+    (Array.isArray(submitForm?.storageBlobDTOs) && submitForm.storageBlobDTOs.length
+      ? submitForm.storageBlobDTOs
+      : null) || tpl.storageBlobDTOs;
+  if (attachments?.length) dto.storageBlobDTOs = attachments;
 
   if (isUpdate) {
     dto.id = existingRow?.id ?? submitForm?.instanceId;
@@ -487,6 +497,10 @@
     formFieldDefs: fields,
     formPayload,
     flowNodes,
+    templateAttachments: initTemplateAttachmentsFromSnapshot(templateSnapshot),
+    storageBlobDTOs: row?.storageBlobDTOs?.length
+      ? JSON.parse(JSON.stringify(row.storageBlobDTOs))
+      : [],
   };
 }
 
@@ -511,5 +525,14 @@
     formFieldDefs: tpl?.fields || [],
     formPayload: payload,
     flowNodes,
+    templateAttachments: tpl?.storageBlobDTOs
+      ? JSON.parse(JSON.stringify(tpl.storageBlobDTOs))
+      : [],
+    storageBlobDTOs: [],
   };
 }
+
+export function initTemplateAttachmentsFromSnapshot(templateSnapshot) {
+  const list = templateSnapshot?.storageBlobDTOs;
+  return list?.length ? JSON.parse(JSON.stringify(list)) : [];
+}
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-list/components/FormPayloadFields.vue b/src/views/officeProcessAutomation/ApproveManage/approve-list/components/FormPayloadFields.vue
index 6cdc627..7933db5 100644
--- a/src/views/officeProcessAutomation/ApproveManage/approve-list/components/FormPayloadFields.vue
+++ b/src/views/officeProcessAutomation/ApproveManage/approve-list/components/FormPayloadFields.vue
@@ -17,12 +17,17 @@
       </el-descriptions-item>
     </el-descriptions>
 
-    <el-form v-else label-width="120px" class="form-payload-edit">
+    <div
+      v-else
+      class="form-payload-edit"
+      v-loading="optionSourceLoading"
+    >
       <el-form-item
         v-for="field in fields"
         :key="field.key"
         :label="field.label"
         :prop="`formPayload.${field.key}`"
+        :required="Boolean(field.required)"
       >
         <el-input
           v-if="field.type === 'text'"
@@ -73,17 +78,25 @@
           :placeholder="`璇烽�夋嫨${field.label}`"
           style="width: 100%"
           clearable
+          filterable
         >
-          <el-option v-for="o in field.options" :key="o.value" :label="o.label" :value="o.value" />
+          <el-option
+            v-for="o in getOptions(field)"
+            :key="String(o.value)"
+            :label="o.label"
+            :value="o.value"
+          />
         </el-select>
         <span v-else class="field-value">{{ displayValue(field) }}</span>
       </el-form-item>
-    </el-form>
+    </div>
   </template>
   <el-empty v-else description="鏆傛棤濉姤椤�" :image-size="48" />
 </template>
 
 <script setup>
+import { onMounted, watch } from "vue";
+import { useSelectOptionSources } from "../../approve-template/useSelectOptionSources.js";
 import { formatFieldDisplayValue } from "../approveListConstants.js";
 
 const props = defineProps({
@@ -92,8 +105,31 @@
   readonly: { type: Boolean, default: false },
 });
 
+const { loading: optionSourceLoading, ensureForFields, getOptions, getDisplayLabel } =
+  useSelectOptionSources();
+
+async function loadOptionCaches() {
+  await ensureForFields(props.fields);
+}
+
+onMounted(() => {
+  loadOptionCaches();
+});
+
+watch(
+  () => props.fields,
+  () => {
+    loadOptionCaches();
+  },
+  { deep: true }
+);
+
 function displayValue(field) {
-  return formatFieldDisplayValue(field, props.formPayload?.[field.key]);
+  const val = props.formPayload?.[field.key];
+  if (field.type === "select" && field.optionSource && field.optionSource !== "static") {
+    return getDisplayLabel(field, val);
+  }
+  return formatFieldDisplayValue(field, val);
 }
 </script>
 
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-list/index.vue b/src/views/officeProcessAutomation/ApproveManage/approve-list/index.vue
index 5fce871..b87a964 100644
--- a/src/views/officeProcessAutomation/ApproveManage/approve-list/index.vue
+++ b/src/views/officeProcessAutomation/ApproveManage/approve-list/index.vue
@@ -105,54 +105,33 @@
           褰撳墠绫诲瀷锛歿{ selectedBusinessTypeLabel || "鈥�" }}锛岃閫夋嫨鍏蜂綋瀹℃壒妯℃澘銆�
           <el-button type="primary" link class="ml8" @click="backToBusinessTypePick">鏇存崲绫诲瀷</el-button>
         </p>
-        <div v-loading="submitTemplatesLoading" class="template-grid">
-          <div
-            v-for="card in submitTemplateCards"
-            :key="card.key"
-            class="template-card"
-            @click="onTemplatePick(card)"
-          >
-            <span class="template-card-type" :style="approvalTypeStyle(card.approvalType)">
-              {{ card.label }}
-            </span>
-            <span class="template-card-desc">{{ card.summaryPlaceholder }}</span>
-          </div>
-          <el-empty
-            v-if="!submitTemplatesLoading && !submitTemplateCards.length"
-            description="璇ョ被鍨嬩笅鏆傛棤鍙敤瀹℃壒妯℃澘"
-            :image-size="80"
-            class="template-empty"
-          />
-        </div>
+        <ApprovalTemplatePicker
+          :cards="submitTemplateCards"
+          :loading="submitTemplatesLoading"
+          @pick="onTemplatePick"
+        />
       </template>
 
       <template v-else>
         <div v-loading="submitTemplatesLoading && !isSubmitEdit">
         <el-form ref="submitFormRef" :model="submitForm" :rules="submitFormRules" label-width="120px">
-          <el-form-item label="瀹℃壒绫诲瀷">
+          <el-form-item v-if="isSubmitEdit" label="瀹℃壒绫诲瀷">
             <span class="approve-type-cell" :style="approvalTypeStyle(activeTemplate.approvalType)">
               {{ activeTemplate.label }}
             </span>
-            <el-button
-              v-if="!isSubmitEdit"
-              type="primary"
-              link
-              class="ml12"
-              @click="backToTemplatePick"
-            >
-              鏇存崲妯℃澘
-            </el-button>
           </el-form-item>
-          <FormPayloadFields
+          <ApprovalTemplateFormSection
+            :active-template="activeTemplate"
             :fields="submitFormFields"
             :form-payload="submitForm.formPayload"
+            v-model:flow-nodes="submitForm.flowNodes"
+            v-model:attachments="submitForm.storageBlobDTOs"
+            :template-attachments="submitForm.templateAttachments"
+            :user-options="flowUserOptions"
+            :show-template-name="!isSubmitEdit"
+            :allow-change-template="!isSubmitEdit"
+            @change-template="backToTemplatePick"
           />
-          <el-form-item label="瀹℃壒娴佺▼" required>
-            <TemplateFlowEditor v-model="submitForm.flowNodes" :user-options="flowUserOptions" />
-            <p class="flow-tip">
-              鎸夐『搴忔祦杞細鍙负姣忎釜鑺傜偣娣诲姞澶氬悕瀹℃壒浜猴紱浼氱闇�鍏ㄩ儴閫氳繃锛屾垨绛句换涓�浜洪�氳繃鍗冲彲杩涘叆涓嬩竴鑺傜偣銆�
-            </p>
-          </el-form-item>
         </el-form>
         </div>
       </template>
@@ -294,9 +273,9 @@
 import { Plus, RefreshRight } from "@element-plus/icons-vue";
 import { ElMessage } from "element-plus";
 import { onMounted, ref } from "vue";
-import { userListNoPageByTenantId } from "@/api/system/user.js";
-import TemplateFlowEditor from "../approve-template/components/TemplateFlowEditor.vue";
-import FormPayloadFields from "./components/FormPayloadFields.vue";
+import ApprovalTemplateFormSection from "../approve-shared/components/ApprovalTemplateFormSection.vue";
+import ApprovalTemplatePicker from "../approve-shared/components/ApprovalTemplatePicker.vue";
+import { useFlowUserOptions } from "../approve-shared/useFlowUserOptions.js";
 import { formatDisplayTime } from "../approve-template/approveTemplateConstants.js";
 import { approvalTypeStyle } from "./approveListConstants.js";
 import ApproveDetailPanel from "./components/ApproveDetailPanel.vue";
@@ -349,29 +328,7 @@
   openApprove,
 } = al;
 
-const flowUserOptions = ref([]);
-
-function unwrapArray(payload) {
-  if (Array.isArray(payload)) return payload;
-  if (payload?.data && Array.isArray(payload.data)) return payload.data;
-  if (payload?.rows && Array.isArray(payload.rows)) return payload.rows;
-  return [];
-}
-
-function isActiveUser(u) {
-  if (u.delFlag === "2" || u.delFlag === 2) return false;
-  if (u.status == null) return true;
-  return String(u.status) === "0";
-}
-
-async function loadUsers() {
-  try {
-    const res = await userListNoPageByTenantId();
-    flowUserOptions.value = unwrapArray(res).filter(isActiveUser);
-  } catch {
-    flowUserOptions.value = [];
-  }
-}
+const { flowUserOptions, loadFlowUsers } = useFlowUserOptions();
 
 async function onSubmitInstance() {
   const ok = await submitInstanceForm();
@@ -406,7 +363,7 @@
 }
 
 onMounted(() => {
-  loadUsers();
+  loadFlowUsers();
   handleQuery();
 });
 </script>
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-list/useApproveList.js b/src/views/officeProcessAutomation/ApproveManage/approve-list/useApproveList.js
index 4b8510a..27706b9 100644
--- a/src/views/officeProcessAutomation/ApproveManage/approve-list/useApproveList.js
+++ b/src/views/officeProcessAutomation/ApproveManage/approve-list/useApproveList.js
@@ -18,11 +18,13 @@
   fetchBusinessTypeOptions,
   formatDisplayTime,
   mapEnabledFromApi,
-  mapTemplateFromApi,
-  unwrapTemplateDetail,
   unwrapTemplateList,
 } from "../approve-template/approveTemplateConstants.js";
-import { buildSubmitTemplateFromRow } from "../approve-template/formConfigUtils.js";
+import {
+  buildFormPayloadRules,
+  buildTemplateBindingFromDetail,
+  validateTemplateBinding,
+} from "../approve-shared/approvalTemplateBindingUtils.js";
 import {
   APPROVAL_TYPE_OPTIONS,
   approvalStatusLabel,
@@ -37,7 +39,6 @@
   mapInstanceFromApi,
   mapSubmitTemplateCard,
   matchBusinessTypeValue,
-  validateSubmitFlowNodes,
   unwrapInstancePage,
 } from "./approveListConstants.js";
 
@@ -111,22 +112,10 @@
     return submitForm.formFieldDefs || [];
   });
 
-  const submitFormRules = computed(() => {
-    const rules = {
-      templateKey: [{ required: true, message: "璇烽�夋嫨瀹℃壒绫诲瀷", trigger: "change" }],
-    };
-    submitFormFields.value.forEach((f) => {
-      if (!f.required) return;
-      if (f.type === "number") {
-        rules[`formPayload.${f.key}`] = [{ required: true, message: `璇峰~鍐�${f.label}`, trigger: "blur" }];
-      } else if (f.type === "datetimerange") {
-        rules[`formPayload.${f.key}`] = [{ required: true, message: `璇烽�夋嫨${f.label}`, trigger: "change" }];
-      } else {
-        rules[`formPayload.${f.key}`] = [{ required: true, message: `璇峰~鍐�${f.label}`, trigger: "blur" }];
-      }
-    });
-    return rules;
-  });
+  const submitFormRules = computed(() => ({
+    templateKey: [{ required: true, message: "璇烽�夋嫨瀹℃壒绫诲瀷", trigger: "change" }],
+    ...buildFormPayloadRules(submitFormFields.value),
+  }));
 
   const tableColumn = ref([
     { label: "鐢宠浜虹紪鍙�", prop: "applicantNo", width: 110 },
@@ -291,18 +280,12 @@
     submitTemplatesLoading.value = true;
     try {
       const res = await getApprovalTemplateDetail(card.id);
-      const mapped = mapTemplateFromApi(unwrapTemplateDetail(res));
-      const tpl = {
-        ...buildSubmitTemplateFromRow(mapped),
-        templateId: mapped.id,
-      };
-      const base = createEmptySubmitForm(String(card.id), tpl, mapped.flowNodes);
+      const applied = buildTemplateBindingFromDetail(res);
       Object.assign(submitForm, {
-        ...base,
-        templateName: mapped.templateName || tpl.label || "",
-        businessType: mapped.businessType ?? card.businessType ?? selectedBusinessType.value,
-        templateSnapshot: tpl,
-        formFieldDefs: tpl.fields || [],
+        templateKey: String(card.id),
+        ...applied,
+        businessType:
+          applied.businessType ?? card.businessType ?? selectedBusinessType.value,
       });
       submitDialog.step = 3;
     } catch {
@@ -343,9 +326,9 @@
       return false;
     }
     if (!activeTemplate.value) return false;
-    const flowCheck = validateSubmitFlowNodes(submitForm.flowNodes);
-    if (!flowCheck.ok) {
-      ElMessage.warning(flowCheck.message);
+    const bindingCheck = validateTemplateBinding({ flowNodes: submitForm.flowNodes });
+    if (!bindingCheck.ok) {
+      ElMessage.warning(bindingCheck.message);
       return false;
     }
     if (!submitForm.templateId) {
@@ -360,7 +343,7 @@
           submitForm,
           activeTemplate: activeTemplate.value,
           userStore,
-          flowNodes: flowCheck.nodes,
+          flowNodes: bindingCheck.nodes,
         })
       );
       submitDialog.visible = false;
@@ -382,9 +365,9 @@
       return false;
     }
     if (!activeTemplate.value) return false;
-    const flowCheck = validateSubmitFlowNodes(submitForm.flowNodes);
-    if (!flowCheck.ok) {
-      ElMessage.warning(flowCheck.message);
+    const bindingCheck = validateTemplateBinding({ flowNodes: submitForm.flowNodes });
+    if (!bindingCheck.ok) {
+      ElMessage.warning(bindingCheck.message);
       return false;
     }
     if (!submitForm.instanceId) {
@@ -398,7 +381,7 @@
         buildInstanceDto({
           submitForm,
           activeTemplate: activeTemplate.value,
-          flowNodes: flowCheck.nodes,
+          flowNodes: bindingCheck.nodes,
           existingRow: submitEditRow.value,
         })
       );
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalModuleRegistry.js b/src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalModuleRegistry.js
new file mode 100644
index 0000000..09d68fd
--- /dev/null
+++ b/src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalModuleRegistry.js
@@ -0,0 +1,102 @@
+/**
+ * 鍚勪笟鍔℃ā鍧椾笌瀹℃壒妯℃澘绫诲瀷鐨勬槧灏勶紙閰嶇疆鍖栧叆鍙o級
+ *
+ * 浣跨敤鏂瑰紡锛�
+ * 1. 鍦ㄤ笟鍔¢〉寮曞叆 ApprovalTemplateBindDialog锛屼紶鍏� moduleKey
+ * 2. 鎴栧湪琛ㄥ崟鍐呭祵 ApprovalTemplateFormSection + useApprovalTemplateBinding({ moduleKey })
+ *
+ * businessType锛氳嫢鍚庣 TypeEnums 宸插浐瀹� code锛屽彲鐩存帴鍐欐 value锛涘惁鍒欑敤 typeLabels 鎸夊悕绉板尮閰�
+ */
+export const APPROVAL_MODULE_KEYS = {
+  REGULAR: "regular",
+  TRANSFER: "transfer",
+  RESIGN: "resign",
+  WORK_HANDOVER: "work_handover",
+  LEAVE: "leave",
+  OVERTIME: "overtime",
+  TRAVEL_REIMBURSE: "travel_reimburse",
+  COST_REIMBURSE: "cost_reimburse",
+};
+
+/** @type {Record<string, import('./approvalModuleRegistry.js').ApprovalModuleConfig>} */
+export const APPROVAL_MODULE_REGISTRY = {
+  [APPROVAL_MODULE_KEYS.REGULAR]: {
+    label: "杞鐢宠",
+    approvalType: "regular",
+    typeLabels: ["杞", "杞鐢宠"],
+  },
+  [APPROVAL_MODULE_KEYS.TRANSFER]: {
+    label: "璋冨矖鐢宠",
+    approvalType: "transfer",
+    typeLabels: ["璋冨矖", "璋冨姩", "璋冨矖鐢宠", "璋冨姩鐢宠"],
+  },
+  [APPROVAL_MODULE_KEYS.RESIGN]: {
+    label: "绂昏亴鐢宠",
+    approvalType: "resign",
+    typeLabels: ["绂昏亴", "绂昏亴鐢宠"],
+  },
+  [APPROVAL_MODULE_KEYS.WORK_HANDOVER]: {
+    label: "宸ヤ綔浜ゆ帴",
+    approvalType: "work_handover",
+    typeLabels: ["宸ヤ綔浜ゆ帴", "浜ゆ帴"],
+  },
+  [APPROVAL_MODULE_KEYS.LEAVE]: {
+    label: "璇峰亣鐢宠",
+    approvalType: "leave",
+    typeLabels: ["璇峰亣", "璇峰亣鐢宠"],
+  },
+  [APPROVAL_MODULE_KEYS.OVERTIME]: {
+    label: "鍔犵彮鐢宠",
+    approvalType: "overtime",
+    typeLabels: ["鍔犵彮", "鍔犵彮鐢宠"],
+  },
+  [APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE]: {
+    label: "宸梾鎶ラ攢",
+    approvalType: "travel_reimburse",
+    typeLabels: ["宸梾", "宸梾鎶ラ攢"],
+  },
+  [APPROVAL_MODULE_KEYS.COST_REIMBURSE]: {
+    label: "璐圭敤鎶ラ攢",
+    approvalType: "cost_reimburse",
+    typeLabels: ["璐圭敤", "璐圭敤鎶ラ攢"],
+  },
+};
+
+/**
+ * @typedef {object} ApprovalModuleConfig
+ * @property {string} label
+ * @property {string} [approvalType] 鍒楄〃鏍峰紡鐢�
+ * @property {string|number} [businessType] 涓� TypeEnums value 涓�鑷存椂鍙啓姝�
+ * @property {string[]} [typeLabels] 涓� TypeEnums label 妯$硦鍖归厤
+ */
+
+export function getApprovalModuleConfig(moduleKey) {
+  if (!moduleKey) return null;
+  return APPROVAL_MODULE_REGISTRY[moduleKey] || null;
+}
+
+export function listApprovalModuleEntries() {
+  return Object.entries(APPROVAL_MODULE_REGISTRY).map(([moduleKey, cfg]) => ({
+    moduleKey,
+    ...cfg,
+  }));
+}
+
+/** 浠� TypeEnums 閫夐」涓В鏋愭湰妯″潡鐨� businessType */
+export function resolveModuleBusinessType(moduleKey, typeOptions = []) {
+  const cfg = getApprovalModuleConfig(moduleKey);
+  if (!cfg) return null;
+  if (cfg.businessType != null && cfg.businessType !== "") return cfg.businessType;
+
+  const labels = [cfg.label, ...(cfg.typeLabels || [])].filter(Boolean);
+  const hit = (typeOptions || []).find((opt) => {
+    const optLabel = String(opt?.label || "").trim();
+    if (!optLabel) return false;
+    return labels.some(
+      (l) => optLabel === l || optLabel.includes(l) || l.includes(optLabel)
+    );
+  });
+  if (hit?.value != null && hit.value !== "") return hit.value;
+
+  return cfg.approvalType || null;
+}
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalTemplateBindingUtils.js b/src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalTemplateBindingUtils.js
new file mode 100644
index 0000000..d68016f
--- /dev/null
+++ b/src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalTemplateBindingUtils.js
@@ -0,0 +1,91 @@
+import {
+  mapAttachmentsFromApi,
+  mapTemplateFromApi,
+  unwrapTemplateDetail,
+} from "../approve-template/approveTemplateConstants.js";
+import { buildSubmitTemplateFromRow } from "../approve-template/formConfigUtils.js";
+import {
+  createEmptySubmitForm,
+  validateSubmitFlowNodes,
+} from "../approve-list/approveListConstants.js";
+
+export function attachmentDisplayName(file) {
+  return (
+    file?.fileName ||
+    file?.originalFilename ||
+    file?.name ||
+    file?.blobName ||
+    "闄勪欢"
+  );
+}
+
+/** 鎺ュ彛璇︽儏 鈫� 鎻愪氦缁戝畾蹇収锛堝惈娴佺▼銆侀檮浠躲�佸~鎶ラ」锛� */
+export function buildTemplateBindingFromDetail(detailRow) {
+  const mapped = mapTemplateFromApi(unwrapTemplateDetail(detailRow));
+  const templateAttachments = mapAttachmentsFromApi(mapped);
+  const tpl = {
+    ...buildSubmitTemplateFromRow(mapped),
+    templateId: mapped.id,
+    businessType: mapped.businessType,
+    storageBlobDTOs: templateAttachments,
+  };
+  const base = createEmptySubmitForm(String(mapped.id ?? ""), tpl, mapped.flowNodes);
+  return {
+    templateId: mapped.id,
+    templateName: mapped.templateName || tpl.label || "",
+    businessType: mapped.businessType ?? "",
+    templateSnapshot: tpl,
+    formFieldDefs: tpl.fields || [],
+    formPayload: base.formPayload,
+    flowNodes: base.flowNodes,
+    templateAttachments: JSON.parse(JSON.stringify(templateAttachments)),
+    storageBlobDTOs: [],
+  };
+}
+
+/** 鏍规嵁妯℃澘 fields 鐢熸垚 el-form rules锛坧rop 涓� formPayload.xxx锛� */
+export function buildFormPayloadRules(fields = []) {
+  const rules = {};
+  (fields || []).forEach((f) => {
+    if (!f.required || !f.key) return;
+    const prop = `formPayload.${f.key}`;
+    if (f.type === "number") {
+      rules[prop] = [{ required: true, message: `璇峰~鍐�${f.label}`, trigger: "blur" }];
+    } else if (f.type === "datetimerange" || f.type === "date" || f.type === "select") {
+      rules[prop] = [{ required: true, message: `璇烽�夋嫨${f.label}`, trigger: "change" }];
+    } else {
+      rules[prop] = [{ required: true, message: `璇峰~鍐�${f.label}`, trigger: "blur" }];
+    }
+  });
+  return rules;
+}
+
+/** 鏍¢獙妯℃澘缁戝畾锛氬鎵规祦绋嬶紙闄勪欢閫夊~锛岀敱鐢ㄦ埛鑷涓婁紶锛� */
+export function validateTemplateBinding({ flowNodes }) {
+  const flowCheck = validateSubmitFlowNodes(flowNodes);
+  if (!flowCheck.ok) return flowCheck;
+  return { ok: true, nodes: flowCheck.nodes };
+}
+
+/** 鍚堝苟缁戝畾缁撴灉鍒颁笟鍔¤〃鍗曞璞★紙瀛楁鍚嶅彲鎸変笟鍔¤鐩栵級 */
+export function applyBindingToForm(target, binding, fieldMap = {}) {
+  if (!target || !binding) return target;
+  const map = {
+    templateId: "templateId",
+    templateName: "templateName",
+    businessType: "businessType",
+    templateSnapshot: "templateSnapshot",
+    formFieldDefs: "formFieldDefs",
+    formPayload: "formPayload",
+    flowNodes: "flowNodes",
+    templateAttachments: "templateAttachments",
+    storageBlobDTOs: "storageBlobDTOs",
+    ...fieldMap,
+  };
+  Object.entries(map).forEach(([srcKey, destKey]) => {
+    if (binding[srcKey] !== undefined) {
+      target[destKey] = binding[srcKey];
+    }
+  });
+  return target;
+}
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-shared/components/ApprovalTemplateBindDialog.vue b/src/views/officeProcessAutomation/ApproveManage/approve-shared/components/ApprovalTemplateBindDialog.vue
new file mode 100644
index 0000000..c05374d
--- /dev/null
+++ b/src/views/officeProcessAutomation/ApproveManage/approve-shared/components/ApprovalTemplateBindDialog.vue
@@ -0,0 +1,158 @@
+<!--
+  涓氬姟妯″潡銆屾柊澧炪�嶆椂瀵煎叆瀹℃壒妯℃澘锛堝浐瀹� moduleKey锛屼粎灞曠ず璇ョ被鍨嬩笅妯℃澘锛�
+
+  鐢ㄦ硶锛�
+  <ApprovalTemplateBindDialog
+    v-model:visible="visible"
+    module-key="regular"
+    @confirm="onTemplateBound"
+  />
+-->
+<template>
+  <el-dialog
+    v-model="dialogVisible"
+    :title="dialogTitle"
+    :width="step === formStep ? 720 : 640"
+    append-to-body
+    destroy-on-close
+    class="approval-template-bind-dialog"
+    @closed="onClosed"
+  >
+    <template v-if="step === 1">
+      <ApprovalTemplatePicker
+        :cards="templateCards"
+        :loading="templatesLoading"
+        :hint="pickerHint"
+        @pick="onPickTemplate"
+      />
+    </template>
+
+    <template v-else>
+      <div v-loading="templatesLoading">
+        <el-form
+          ref="formRef"
+          :model="bindingForm"
+          :rules="mergedRules"
+          label-width="120px"
+        >
+          <ApprovalTemplateFormSection
+            :active-template="activeTemplate"
+            :fields="formFields"
+            :form-payload="bindingForm.formPayload"
+            v-model:flow-nodes="bindingForm.flowNodes"
+            v-model:attachments="bindingForm.storageBlobDTOs"
+            :template-attachments="bindingForm.templateAttachments"
+            :user-options="flowUserOptions"
+            allow-change-template
+            @change-template="step = 1"
+          />
+        </el-form>
+      </div>
+    </template>
+
+    <template #footer>
+      <el-button v-if="step === formStep" type="primary" :loading="confirming" @click="onConfirm">
+        纭� 瀹�
+      </el-button>
+      <el-button v-if="step === formStep" @click="step = 1">閲嶉�夋ā鏉�</el-button>
+      <el-button @click="dialogVisible = false">{{ step === 1 ? "鍙� 娑�" : "鍏� 闂�" }}</el-button>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup>
+import { computed, ref, watch } from "vue";
+import { ElMessage } from "element-plus";
+import ApprovalTemplatePicker from "./ApprovalTemplatePicker.vue";
+import ApprovalTemplateFormSection from "./ApprovalTemplateFormSection.vue";
+import { useApprovalTemplateBinding } from "../useApprovalTemplateBinding.js";
+import { useFlowUserOptions } from "../useFlowUserOptions.js";
+import { getApprovalModuleConfig } from "../approvalModuleRegistry.js";
+
+const props = defineProps({
+  visible: { type: Boolean, default: false },
+  /** approvalModuleRegistry 涓殑 moduleKey */
+  moduleKey: { type: String, required: true },
+});
+
+const emit = defineEmits(["update:visible", "confirm"]);
+
+const dialogVisible = computed({
+  get: () => props.visible,
+  set: (v) => emit("update:visible", v),
+});
+
+const {
+  step,
+  bindingForm,
+  templateCards,
+  activeTemplate,
+  formFields,
+  formRules,
+  templatesLoading,
+  loadTemplates,
+  resetBinding,
+  pickTemplate,
+  validateBinding,
+  getBindingPayload,
+  moduleConfig,
+} = useApprovalTemplateBinding({ moduleKey: props.moduleKey, mode: "module" });
+
+const formStep = 2;
+const formRef = ref();
+const confirming = ref(false);
+const { flowUserOptions, loadFlowUsers } = useFlowUserOptions();
+
+const mergedRules = computed(() => ({ ...formRules.value }));
+
+const dialogTitle = computed(() => {
+  const label = moduleConfig.value?.label || "瀹℃壒";
+  return step.value === 1 ? `閫夋嫨${label}妯℃澘` : `纭${label}瀹℃壒淇℃伅`;
+});
+
+const pickerHint = computed(
+  () => `璇烽�夋嫨銆�${moduleConfig.value?.label || "鈥�"}銆嶇被鍨嬩笅宸插惎鐢ㄧ殑瀹℃壒妯℃澘锛屽鎵规祦绋嬪皢鑷姩甯﹀叆銆俙
+);
+
+watch(
+  () => props.visible,
+  async (v) => {
+    if (!v) return;
+    resetBinding();
+    step.value = 1;
+    await Promise.all([loadTemplates(), loadFlowUsers()]);
+    const cfg = getApprovalModuleConfig(props.moduleKey);
+    if (!cfg) ElMessage.warning(`鏈厤缃ā鍧椼��${props.moduleKey}銆嶏紝璇锋鏌� approvalModuleRegistry`);
+  }
+);
+
+async function onPickTemplate(card) {
+  const ok = await pickTemplate(card);
+  if (ok) step.value = formStep;
+}
+
+async function onConfirm() {
+  confirming.value = true;
+  try {
+    const check = await validateBinding(formRef.value);
+    if (!check.ok) {
+      if (check.message) ElMessage.warning(check.message);
+      return;
+    }
+    emit("confirm", { ...getBindingPayload(), flowNodes: check.nodes });
+    dialogVisible.value = false;
+  } finally {
+    confirming.value = false;
+  }
+}
+
+function onClosed() {
+  resetBinding();
+}
+</script>
+
+<style scoped>
+.approval-template-bind-dialog :deep(.el-dialog__body) {
+  padding-top: 12px;
+}
+</style>
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-shared/components/ApprovalTemplateFormSection.vue b/src/views/officeProcessAutomation/ApproveManage/approve-shared/components/ApprovalTemplateFormSection.vue
new file mode 100644
index 0000000..47ad787
--- /dev/null
+++ b/src/views/officeProcessAutomation/ApproveManage/approve-shared/components/ApprovalTemplateFormSection.vue
@@ -0,0 +1,95 @@
+<!-- 妯℃澘缁戝畾琛ㄥ崟鍖猴細濉姤椤� + 瀹℃壒娴佺▼ + 闄勪欢锛堥』鎸傚湪澶栧眰 el-form 涓嬶級 -->
+<template>
+  <template v-if="activeTemplate">
+    <el-form-item v-if="showTemplateName" label="瀹℃壒妯℃澘">
+      <span class="template-name">{{ activeTemplate.label }}</span>
+      <el-button v-if="allowChangeTemplate" type="primary" link class="ml12" @click="emit('change-template')">
+        鏇存崲妯℃澘
+      </el-button>
+    </el-form-item>
+
+    <FormPayloadFields :fields="fields" :form-payload="formPayload" />
+
+    <el-form-item label="瀹℃壒娴佺▼" required>
+      <TemplateFlowEditor v-model="flowNodesModel" :user-options="userOptions" />
+      <p class="section-tip">娴佺▼涓庡鎵逛汉鐢辨ā鏉块缃紝鍙寜闇�寰皟鑺傜偣瀹℃壒浜恒��</p>
+    </el-form-item>
+
+    <el-form-item v-if="templateAttachments.length" label="妯℃澘鍙傝��">
+      <el-tag
+        v-for="(f, i) in templateAttachments"
+        :key="`tpl-${i}`"
+        class="attachment-tag"
+        type="info"
+        effect="plain"
+      >
+        {{ attachmentDisplayName(f) }}
+      </el-tag>
+      <p class="section-tip">浠ヤ笂涓烘ā鏉块檮甯︽枃浠讹紝浠呬緵鍙傝�冿紱鎻愪氦闄勪欢璇峰湪涓嬫柟涓婁紶銆�</p>
+    </el-form-item>
+
+    <el-form-item label="闄勪欢">
+      <FileUpload
+        v-model:file-list="attachmentsModel"
+        :limit="uploadLimit"
+        button-text="鐐瑰嚮閫夋嫨鏂囦欢"
+      />
+      <p class="section-tip">閫夊~锛屽彲涓婁紶涓庣敵璇风浉鍏崇殑璇存槑鏉愭枡銆�</p>
+    </el-form-item>
+  </template>
+  <el-empty v-else description="璇峰厛閫夋嫨瀹℃壒妯℃澘" :image-size="64" />
+</template>
+
+<script setup>
+import { computed } from "vue";
+import FileUpload from "@/components/AttachmentUpload/file/index.vue";
+import TemplateFlowEditor from "../../approve-template/components/TemplateFlowEditor.vue";
+import FormPayloadFields from "../../approve-list/components/FormPayloadFields.vue";
+import { attachmentDisplayName } from "../approvalTemplateBindingUtils.js";
+
+const props = defineProps({
+  activeTemplate: { type: Object, default: null },
+  fields: { type: Array, default: () => [] },
+  formPayload: { type: Object, required: true },
+  flowNodes: { type: Array, default: () => [] },
+  /** 鐢ㄦ埛鑷涓婁紶鐨勯檮浠� */
+  attachments: { type: Array, default: () => [] },
+  /** 妯℃澘棰勭疆闄勪欢锛堝彧璇诲睍绀猴級 */
+  templateAttachments: { type: Array, default: () => [] },
+  userOptions: { type: Array, default: () => [] },
+  showTemplateName: { type: Boolean, default: true },
+  allowChangeTemplate: { type: Boolean, default: true },
+  uploadLimit: { type: Number, default: 10 },
+});
+
+const emit = defineEmits(["update:flowNodes", "update:attachments", "change-template"]);
+
+const flowNodesModel = computed({
+  get: () => props.flowNodes,
+  set: (v) => emit("update:flowNodes", v),
+});
+
+const attachmentsModel = computed({
+  get: () => props.attachments,
+  set: (v) => emit("update:attachments", v),
+});
+</script>
+
+<style scoped>
+.template-name {
+  font-weight: 600;
+  color: var(--el-text-color-primary);
+}
+.ml12 {
+  margin-left: 12px;
+}
+.section-tip {
+  font-size: 12px;
+  color: var(--el-text-color-secondary);
+  margin: 8px 0 0;
+  line-height: 1.5;
+}
+.attachment-tag {
+  margin: 0 8px 8px 0;
+}
+</style>
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-shared/components/ApprovalTemplatePicker.vue b/src/views/officeProcessAutomation/ApproveManage/approve-shared/components/ApprovalTemplatePicker.vue
new file mode 100644
index 0000000..8adfebc
--- /dev/null
+++ b/src/views/officeProcessAutomation/ApproveManage/approve-shared/components/ApprovalTemplatePicker.vue
@@ -0,0 +1,85 @@
+<!-- 瀹℃壒妯℃澘鍗$墖閫夋嫨锛堟寜 businessType 杩囨护锛� -->
+<template>
+  <div class="approval-template-picker">
+    <p v-if="hint" class="picker-hint">{{ hint }}</p>
+    <div v-loading="loading" class="template-grid">
+      <div
+        v-for="card in cards"
+        :key="card.key || card.id"
+        class="template-card"
+        @click="emit('pick', card)"
+      >
+        <span class="template-card-type" :style="typeStyle(card.approvalType)">
+          {{ card.label }}
+        </span>
+        <span class="template-card-desc">{{ card.summaryPlaceholder }}</span>
+      </div>
+      <el-empty
+        v-if="!loading && !cards.length"
+        :description="emptyText"
+        :image-size="80"
+        class="template-empty"
+      />
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { approvalTypeStyle } from "../../approve-list/approveListConstants.js";
+
+defineProps({
+  cards: { type: Array, default: () => [] },
+  loading: { type: Boolean, default: false },
+  hint: { type: String, default: "" },
+  emptyText: { type: String, default: "璇ョ被鍨嬩笅鏆傛棤鍙敤瀹℃壒妯℃澘" },
+});
+
+const emit = defineEmits(["pick"]);
+
+function typeStyle(approvalType) {
+  return approvalTypeStyle(approvalType);
+}
+</script>
+
+<style scoped>
+.picker-hint {
+  font-size: 13px;
+  color: var(--el-text-color-secondary);
+  margin: 0 0 16px;
+}
+.template-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+  gap: 12px;
+  min-height: 120px;
+}
+.template-empty {
+  grid-column: 1 / -1;
+}
+.template-card {
+  padding: 14px 16px;
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: var(--radius-md, 8px);
+  cursor: pointer;
+  transition: border-color 0.2s, box-shadow 0.2s;
+  background: var(--el-fill-color-blank);
+}
+.template-card:hover {
+  border-color: var(--el-color-primary);
+  box-shadow: var(--shadow-sm, 0 2px 8px rgba(0, 0, 0, 0.06));
+}
+.template-card-type {
+  display: inline-block;
+  padding: 2px 8px;
+  border-radius: 4px;
+  font-size: 13px;
+  font-weight: 600;
+  margin-bottom: 8px;
+}
+.template-card-desc {
+  display: block;
+  font-size: 12px;
+  color: var(--el-text-color-secondary);
+  line-height: 1.5;
+}
+</style>
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-shared/useApprovalTemplateBinding.js b/src/views/officeProcessAutomation/ApproveManage/approve-shared/useApprovalTemplateBinding.js
new file mode 100644
index 0000000..de056d2
--- /dev/null
+++ b/src/views/officeProcessAutomation/ApproveManage/approve-shared/useApprovalTemplateBinding.js
@@ -0,0 +1,229 @@
+import {
+  getApprovalTemplateDetail,
+  listApprovalTemplate,
+  TEMPLATE_TYPE_CUSTOM,
+} from "@/api/officeProcessAutomation/approvalTemplate.js";
+import { computed, reactive, ref } from "vue";
+import { ElMessage } from "element-plus";
+import {
+  fetchBusinessTypeOptions,
+  mapEnabledFromApi,
+  unwrapTemplateList,
+} from "../approve-template/approveTemplateConstants.js";
+import {
+  createEmptySubmitForm,
+  mapSubmitTemplateCard,
+  matchBusinessTypeValue,
+} from "../approve-list/approveListConstants.js";
+import {
+  getApprovalModuleConfig,
+  resolveModuleBusinessType,
+} from "./approvalModuleRegistry.js";
+import {
+  buildFormPayloadRules,
+  buildTemplateBindingFromDetail,
+  validateTemplateBinding,
+} from "./approvalTemplateBindingUtils.js";
+
+/**
+ * 瀹℃壒妯℃澘缁戝畾锛堜笟鍔℃ā鍧楀浐瀹氱被鍨� / 瀹℃壒鍒楄〃閫氱敤锛�
+ *
+ * @param {object} options
+ * @param {string} [options.moduleKey] 涓氬姟妯″潡 key锛岃 approvalModuleRegistry
+ * @param {string|number} [options.businessType] 鐩存帴鎸囧畾绫诲瀷锛堜紭鍏堢骇楂樹簬 moduleKey锛�
+ * @param {'module'|'universal'} [options.mode] module=浠呮湰绫诲瀷妯℃澘锛泆niversal=闇�鍏堥�夌被鍨�
+ */
+export function useApprovalTemplateBinding(options = {}) {
+  const { moduleKey = null, businessType: fixedBusinessType = null, mode = moduleKey ? "module" : "universal" } =
+    options;
+
+  const isUniversal = mode === "universal" && !moduleKey && fixedBusinessType == null;
+
+  const allTemplates = ref([]);
+  const businessTypeOptions = ref([]);
+  const selectedBusinessType = ref(fixedBusinessType ?? "");
+  const templatesLoading = ref(false);
+  const step = ref(isUniversal ? 1 : 1);
+
+  const bindingForm = reactive(createEmptySubmitForm(""));
+
+  const moduleConfig = computed(() => getApprovalModuleConfig(moduleKey));
+
+  const resolvedBusinessType = computed(() => {
+    if (fixedBusinessType != null && fixedBusinessType !== "") return fixedBusinessType;
+    if (selectedBusinessType.value != null && selectedBusinessType.value !== "") {
+      return selectedBusinessType.value;
+    }
+    if (moduleKey) {
+      return resolveModuleBusinessType(moduleKey, businessTypeOptions.value);
+    }
+    return "";
+  });
+
+  const templateCards = computed(() => {
+    const type = resolvedBusinessType.value;
+    if (type == null || type === "") return [];
+    return allTemplates.value.filter((card) => matchBusinessTypeValue(card.businessType, type));
+  });
+
+  const activeTemplate = computed(() => bindingForm.templateSnapshot || null);
+
+  const formFields = computed(() => {
+    const tplFields = activeTemplate.value?.fields;
+    if (tplFields?.length) return tplFields;
+    return bindingForm.formFieldDefs || [];
+  });
+
+  const formRules = computed(() => buildFormPayloadRules(formFields.value));
+
+  const hasTemplateBound = computed(() => Boolean(activeTemplate.value?.templateId || bindingForm.templateId));
+
+  function businessTypeLabel(type) {
+    if (type == null || type === "") return "";
+    const hit = businessTypeOptions.value.find((x) => matchBusinessTypeValue(x.value, type));
+    return hit?.label || moduleConfig.value?.label || "";
+  }
+
+  const selectedBusinessTypeLabel = computed(() => businessTypeLabel(resolvedBusinessType.value));
+
+  function countTemplatesByBusinessType(type) {
+    return allTemplates.value.filter((card) => matchBusinessTypeValue(card.businessType, type)).length;
+  }
+
+  async function loadTemplates() {
+    templatesLoading.value = true;
+    try {
+      const [typeOptions, customRes] = await Promise.all([
+        fetchBusinessTypeOptions(),
+        listApprovalTemplate(TEMPLATE_TYPE_CUSTOM),
+      ]);
+      businessTypeOptions.value = typeOptions;
+      allTemplates.value = unwrapTemplateList(customRes)
+        .filter((row) => mapEnabledFromApi(row.enabled))
+        .map(mapSubmitTemplateCard);
+
+      if (moduleKey && !fixedBusinessType) {
+        const resolved = resolveModuleBusinessType(moduleKey, typeOptions);
+        if (resolved != null && resolved !== "") selectedBusinessType.value = resolved;
+      }
+    } catch {
+      businessTypeOptions.value = [];
+      allTemplates.value = [];
+      ElMessage.error("鍔犺浇瀹℃壒妯℃澘澶辫触");
+    } finally {
+      templatesLoading.value = false;
+    }
+  }
+
+  function resetBinding() {
+    step.value = isUniversal ? 1 : 1;
+    if (!fixedBusinessType && !moduleKey) selectedBusinessType.value = "";
+    else if (moduleKey) {
+      selectedBusinessType.value =
+        fixedBusinessType ?? resolveModuleBusinessType(moduleKey, businessTypeOptions.value) ?? "";
+    }
+    Object.assign(bindingForm, createEmptySubmitForm(""));
+  }
+
+  function pickBusinessType(type) {
+    if (!countTemplatesByBusinessType(type)) {
+      ElMessage.warning("璇ョ被鍨嬩笅鏆傛棤鍙敤瀹℃壒妯℃澘");
+      return;
+    }
+    selectedBusinessType.value = type;
+    step.value = 2;
+  }
+
+  function backToBusinessTypePick() {
+    selectedBusinessType.value = "";
+    step.value = 1;
+  }
+
+  function backToTemplatePick() {
+    step.value = isUniversal ? 2 : 1;
+  }
+
+  async function pickTemplate(card) {
+    if (!card?.id) return false;
+    templatesLoading.value = true;
+    try {
+      const res = await getApprovalTemplateDetail(card.id);
+      const applied = buildTemplateBindingFromDetail(res);
+      Object.assign(bindingForm, {
+        templateKey: String(card.id),
+        ...applied,
+      });
+      step.value = isUniversal ? 3 : 2;
+      return true;
+    } catch {
+      ElMessage.error("鍔犺浇妯℃澘璇︽儏澶辫触");
+      return false;
+    } finally {
+      templatesLoading.value = false;
+    }
+  }
+
+  /** 鐩存帴浠ヨ鎯呰缁戝畾锛堢紪杈戝洖鏄撅級 */
+  function applyBindingState(state) {
+    if (!state) return;
+    Object.assign(bindingForm, createEmptySubmitForm(""), state);
+    step.value = isUniversal ? 3 : 2;
+  }
+
+  async function validateBinding(formRef) {
+    if (formRef?.validate) {
+      try {
+        await formRef.validate();
+      } catch {
+        return { ok: false };
+      }
+    }
+    if (!hasTemplateBound.value) {
+      return { ok: false, message: "璇烽�夋嫨瀹℃壒妯℃澘" };
+    }
+    return validateTemplateBinding({ flowNodes: bindingForm.flowNodes });
+  }
+
+  function getBindingPayload() {
+    return {
+      templateId: bindingForm.templateId,
+      templateName: bindingForm.templateName,
+      businessType: bindingForm.businessType ?? resolvedBusinessType.value,
+      templateSnapshot: bindingForm.templateSnapshot,
+      formFieldDefs: bindingForm.formFieldDefs,
+      formPayload: bindingForm.formPayload,
+      flowNodes: bindingForm.flowNodes,
+      templateAttachments: bindingForm.templateAttachments,
+      storageBlobDTOs: bindingForm.storageBlobDTOs,
+    };
+  }
+
+  return {
+    isUniversal,
+    moduleConfig,
+    step,
+    bindingForm,
+    allTemplates,
+    businessTypeOptions,
+    selectedBusinessType,
+    resolvedBusinessType,
+    selectedBusinessTypeLabel,
+    templateCards,
+    activeTemplate,
+    formFields,
+    formRules,
+    hasTemplateBound,
+    templatesLoading,
+    loadTemplates,
+    resetBinding,
+    pickBusinessType,
+    backToBusinessTypePick,
+    backToTemplatePick,
+    pickTemplate,
+    applyBindingState,
+    validateBinding,
+    getBindingPayload,
+    countTemplatesByBusinessType,
+    businessTypeLabel,
+  };
+}
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-shared/useFlowUserOptions.js b/src/views/officeProcessAutomation/ApproveManage/approve-shared/useFlowUserOptions.js
new file mode 100644
index 0000000..2788ac7
--- /dev/null
+++ b/src/views/officeProcessAutomation/ApproveManage/approve-shared/useFlowUserOptions.js
@@ -0,0 +1,35 @@
+import { ref } from "vue";
+import { userListNoPageByTenantId } from "@/api/system/user.js";
+
+function unwrapArray(payload) {
+  if (Array.isArray(payload)) return payload;
+  if (payload?.data && Array.isArray(payload.data)) return payload.data;
+  if (payload?.rows && Array.isArray(payload.rows)) return payload.rows;
+  return [];
+}
+
+function isActiveUser(u) {
+  if (u.delFlag === "2" || u.delFlag === 2) return false;
+  if (u.status == null) return true;
+  return String(u.status) === "0";
+}
+
+/** 瀹℃壒娴佺▼閫変汉涓嬫媺锛堟ā鏉�/瀹炰緥鍏辩敤锛� */
+export function useFlowUserOptions() {
+  const flowUserOptions = ref([]);
+  const loading = ref(false);
+
+  async function loadFlowUsers() {
+    loading.value = true;
+    try {
+      const res = await userListNoPageByTenantId();
+      flowUserOptions.value = unwrapArray(res).filter(isActiveUser);
+    } catch {
+      flowUserOptions.value = [];
+    } finally {
+      loading.value = false;
+    }
+  }
+
+  return { flowUserOptions, loading, loadFlowUsers };
+}
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-template/approveTemplateConstants.js b/src/views/officeProcessAutomation/ApproveManage/approve-template/approveTemplateConstants.js
index 517e01c..4d7d7a6 100644
--- a/src/views/officeProcessAutomation/ApproveManage/approve-template/approveTemplateConstants.js
+++ b/src/views/officeProcessAutomation/ApproveManage/approve-template/approveTemplateConstants.js
@@ -178,6 +178,18 @@
   return data;
 }
 
+/** 鍚庣闄勪欢瀛楁 鈫� 椤甸潰 storageBlobDTOs */
+export function mapAttachmentsFromApi(row) {
+  const list =
+    row?.storageBlobDTOs ||
+    row?.storageBlobDTOS ||
+    row?.storageBlobVOS ||
+    row?.storageBlobVOList ||
+    row?.attachmentList ||
+    [];
+  return Array.isArray(list) ? list : [];
+}
+
 /** 鍒嗛〉鍒楄〃椤� 鈫� 椤甸潰琛屾暟鎹紙涓昏〃 + 鑺傜偣锛� */
 export function mapTemplateFromApi(row) {
   if (!row) return {};
@@ -193,6 +205,7 @@
     businessType: row.businessType ?? "",
     formConfig: row.formConfig,
     formConfigData: parseFormConfigToData(row.formConfig),
+    storageBlobDTOs: mapAttachmentsFromApi(row),
     createdUser: row.createdUser,
     createdUserName: row.createdUserName,
     ...times,
@@ -236,6 +249,8 @@
     }),
   };
   if (templateId) dto.id = templateId;
+  const attachments = Array.isArray(form.storageBlobDTOs) ? form.storageBlobDTOs : [];
+  if (attachments.length) dto.storageBlobDTOs = attachments;
   return dto;
 }
 
@@ -280,6 +295,7 @@
     formConfigData: createEmptyFormConfigData(),
     enabled: true,
     flowNodes: [createEmptyNode(1)],
+    storageBlobDTOs: [],
   };
 }
 
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-template/components/FormConfigEditor.vue b/src/views/officeProcessAutomation/ApproveManage/approve-template/components/FormConfigEditor.vue
index 1881f60..b63383f 100644
--- a/src/views/officeProcessAutomation/ApproveManage/approve-template/components/FormConfigEditor.vue
+++ b/src/views/officeProcessAutomation/ApproveManage/approve-template/components/FormConfigEditor.vue
@@ -219,10 +219,12 @@
               placeholder="閫夊~"
               style="width: 100%"
               clearable
+              filterable
+              :loading="optionSourceLoading"
               @change="emitOut"
             >
               <el-option
-                v-for="o in field.options.filter((x) => x.value !== '' && x.value != null)"
+                v-for="o in resolvedSelectOptions(field)"
                 :key="String(o.value)"
                 :label="o.label || o.value"
                 :value="o.value"
@@ -231,28 +233,52 @@
           </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>
+            <span class="fce-section-title">涓嬫媺閫夐」</span>
+            <el-row :gutter="16" class="fce-source-row">
+              <el-col :span="12">
+                <el-form-item label="閫夐」鏉ユ簮" class="fce-field-item">
+                  <el-select
+                    v-model="field.optionSource"
+                    style="width: 100%"
+                    @change="onOptionSourceChange(field)"
+                  >
+                    <el-option
+                      v-for="s in SELECT_OPTION_SOURCE_OPTIONS"
+                      :key="s.value"
+                      :label="s.label"
+                      :value="s.value"
+                    />
+                  </el-select>
+                </el-form-item>
+              </el-col>
+            </el-row>
+            <p v-if="isDynamicOptionSource(field.optionSource)" class="fce-source-tip">
+              {{ optionSourceDesc(field.optionSource) }}銆傛彁浜ゅ鎵规椂灏嗚嚜鍔ㄥ姞杞芥渶鏂版暟鎹紝鏃犻渶鎵嬪姩缁存姢閫夐」銆�
+            </p>
+            <template v-if="!isDynamicOptionSource(field.optionSource)">
+              <div class="fce-options-head">
+                <span class="fce-section-subtitle">鎵嬪姩閫夐」</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>
+            </template>
           </div>
         </div>
       </div>
@@ -271,6 +297,12 @@
   createEmptyFormField,
   formFieldTypeLabel,
 } from "../formConfigUtils.js";
+import {
+  SELECT_OPTION_SOURCE,
+  SELECT_OPTION_SOURCE_OPTIONS,
+  isDynamicOptionSource,
+} from "../selectOptionSource.js";
+import { useSelectOptionSources } from "../useSelectOptionSources.js";
 
 const props = defineProps({
   modelValue: { type: Object, default: () => createEmptyFormConfigData() },
@@ -279,6 +311,8 @@
 const emit = defineEmits(["update:modelValue"]);
 
 const inner = reactive(createEmptyFormConfigData());
+
+const { loading: optionSourceLoading, ensureForFields, getOptions } = useSelectOptionSources();
 
 function typeLabel(type) {
   return formFieldTypeLabel(type);
@@ -289,6 +323,15 @@
   return `閫夊~锛岄�夋嫨妯℃澘鏃跺皢棰勫~${name}`;
 }
 
+function optionSourceDesc(source) {
+  return SELECT_OPTION_SOURCE_OPTIONS.find((x) => x.value === source)?.desc || "";
+}
+
+function resolvedSelectOptions(field) {
+  if (field.type !== "select") return [];
+  return getOptions(field);
+}
+
 function syncFromProps(v) {
   const src = v || createEmptyFormConfigData();
   inner.summaryPlaceholder = src.summaryPlaceholder || "";
@@ -296,8 +339,10 @@
     ...createEmptyFormField(),
     ...f,
     _uid: f._uid || createEmptyFormField()._uid,
+    optionSource: f.optionSource || SELECT_OPTION_SOURCE.STATIC,
     options: (f.options || [{ label: "", value: "" }]).map((o) => ({ ...o })),
   }));
+  ensureForFields(inner.fields);
 }
 
 function emitOut() {
@@ -313,6 +358,7 @@
       min: f.min,
       precision: f.precision,
       defaultValue: cloneDefaultValue(f),
+      optionSource: f.optionSource || SELECT_OPTION_SOURCE.STATIC,
       options: (f.options || []).map((o) => ({ label: o.label, value: o.value })),
     })),
   });
@@ -333,6 +379,7 @@
 
 function addField() {
   inner.fields.push(createEmptyFormField());
+  ensureForFields(inner.fields);
   emitOut();
 }
 
@@ -357,10 +404,23 @@
 }
 
 function onTypeChange(field) {
-  if (field.type === "select" && (!field.options || !field.options.length)) {
-    field.options = [{ label: "", value: "" }];
+  if (field.type === "select") {
+    if (!field.optionSource) field.optionSource = SELECT_OPTION_SOURCE.STATIC;
+    if (!field.options || !field.options.length) {
+      field.options = [{ label: "", value: "" }];
+    }
+    ensureForFields(inner.fields);
   }
   resetDefaultValueForType(field);
+  emitOut();
+}
+
+function onOptionSourceChange(field) {
+  field.defaultValue = "";
+  if (!isDynamicOptionSource(field.optionSource) && (!field.options || !field.options.length)) {
+    field.options = [{ label: "", value: "" }];
+  }
+  ensureForFields(inner.fields);
   emitOut();
 }
 
@@ -585,10 +645,28 @@
   margin-bottom: 10px;
 }
 
-.fce-options-head .fce-section-title {
+.fce-options-head .fce-section-title,
+.fce-options-head .fce-section-subtitle {
   margin-bottom: 0;
 }
 
+.fce-section-subtitle {
+  font-size: 12px;
+  font-weight: 600;
+  color: var(--el-text-color-secondary);
+}
+
+.fce-source-row {
+  margin-bottom: 4px;
+}
+
+.fce-source-tip {
+  margin: 0 0 10px;
+  font-size: 12px;
+  color: var(--el-text-color-secondary);
+  line-height: 1.5;
+}
+
 .fce-option-row {
   display: flex;
   align-items: center;
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-template/formConfigUtils.js b/src/views/officeProcessAutomation/ApproveManage/approve-template/formConfigUtils.js
index 733f463..c1f66bd 100644
--- a/src/views/officeProcessAutomation/ApproveManage/approve-template/formConfigUtils.js
+++ b/src/views/officeProcessAutomation/ApproveManage/approve-template/formConfigUtils.js
@@ -1,3 +1,12 @@
+import { mapAttachmentsFromApi } from "./approveTemplateConstants.js";
+import {
+  isDynamicOptionSource,
+  SELECT_OPTION_SOURCE,
+  selectOptionSourceLabel,
+} from "./selectOptionSource.js";
+
+export { selectOptionSourceLabel };
+
 /** 濉姤椤圭被鍨嬶紙涓庡鎵规彁浜ら〉 field.type 涓�鑷达級 */
 export const FORM_FIELD_TYPE_OPTIONS = [
   { value: "text", label: "鍗曡鏂囨湰" },
@@ -67,6 +76,7 @@
     min: 0,
     precision: 0,
     defaultValue: "",
+    optionSource: SELECT_OPTION_SOURCE.STATIC,
     options: [{ label: "", value: "" }],
   };
 }
@@ -154,6 +164,7 @@
     min: f.min ?? 0,
     precision: f.precision ?? 0,
     defaultValue: normalizeDefaultValueFromApi(f),
+    optionSource: f.optionSource || SELECT_OPTION_SOURCE.STATIC,
     options: (f.options || []).length
       ? f.options.map((o) => ({ label: o.label || "", value: o.value ?? "" }))
       : [{ label: "", value: "" }],
@@ -180,9 +191,13 @@
       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 }));
+      const source = f.optionSource || SELECT_OPTION_SOURCE.STATIC;
+      item.optionSource = source;
+      if (!isDynamicOptionSource(source)) {
+        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 =
@@ -223,6 +238,8 @@
     if (keys.has(key)) return { ok: false, message: `瀛楁鏍囪瘑銆�${key}銆嶉噸澶嶏紝璇蜂慨鏀筦 };
     keys.add(key);
     if (f.type === "select") {
+      const source = f.optionSource || SELECT_OPTION_SOURCE.STATIC;
+      if (isDynamicOptionSource(source)) continue;
       const opts = (f.options || []).filter((o) => (o.label || "").trim() && o.value !== "" && o.value != null);
       if (!opts.length) return { ok: false, message: `璇蜂负銆�${label}銆嶉厤缃嚦灏戜竴涓笅鎷夐�夐」` };
     }
@@ -241,13 +258,16 @@
     return dv.length === 2 ? `${dv[0]} ~ ${dv[1]}` : "鈥�";
   }
   if (field?.type === "select") {
+    if (isDynamicOptionSource(field.optionSource)) {
+      return `${selectOptionSourceLabel(field.optionSource)}锛�${String(dv)}`;
+    }
     const opt = (field.options || []).find((o) => String(o.value) === String(dv));
     return opt?.label || String(dv);
   }
   return String(dv);
 }
 
-/** 灏嗗悗绔ā鏉胯杞负鎻愪氦椤垫ā鏉跨粨鏋勶紙鍚� fields 榛樿鍊硷級 */
+/** 灏嗗悗绔ā鏉胯杞负鎻愪氦椤垫ā鏉跨粨鏋勶紙鍚� fields 榛樿鍊笺�侀檮浠讹級 */
 export function buildSubmitTemplateFromRow(row) {
   const cfg = parseFormConfigToData(row?.formConfig);
   const fields = (cfg.fields || []).map(({ _uid, ...rest }) => ({
@@ -260,6 +280,7 @@
     min: rest.min,
     precision: rest.precision,
     defaultValue: rest.defaultValue,
+    optionSource: rest.optionSource,
     options: rest.options,
   }));
   return {
@@ -269,6 +290,7 @@
     summaryPlaceholder: cfg.summaryPlaceholder || "",
     approvalMode: cfg.approvalMode || "parallel",
     fields,
+    storageBlobDTOs: mapAttachmentsFromApi(row),
   };
 }
 
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-template/index.vue b/src/views/officeProcessAutomation/ApproveManage/approve-template/index.vue
index 5bc65dd..59850e2 100644
--- a/src/views/officeProcessAutomation/ApproveManage/approve-template/index.vue
+++ b/src/views/officeProcessAutomation/ApproveManage/approve-template/index.vue
@@ -196,6 +196,18 @@
 
         </el-form-item>
 
+        <el-form-item label="闄勪欢">
+
+          <div class="upload-block">
+
+            <FileUpload v-model:file-list="form.storageBlobDTOs" :limit="10" button-text="鐐瑰嚮閫夋嫨鏂囦欢" />
+
+          </div>
+
+          <p class="flow-tip">鍙笂浼犳ā鏉胯鏄庢枃妗c�佸埗搴︽枃浠剁瓑锛堥�夊~锛夈��</p>
+
+        </el-form-item>
+
       </el-form>
 
       <template #footer>
@@ -274,6 +286,16 @@
 
         </el-table-column>
 
+        <el-table-column label="閫夐」鏉ユ簮" width="100">
+
+          <template #default="{ row }">
+
+            {{ row.type === 'select' ? selectOptionSourceLabel(row.optionSource) : '鈥�' }}
+
+          </template>
+
+        </el-table-column>
+
         <el-table-column label="蹇呭~" width="70" align="center">
 
           <template #default="{ row }">{{ row.required !== false ? "鏄�" : "鍚�" }}</template>
@@ -338,6 +360,32 @@
 
       <el-empty v-else description="鏆傛棤娴佺▼鑺傜偣" :image-size="60" />
 
+      <el-divider content-position="left">闄勪欢锛坽{ detailAttachments.length }} 涓級</el-divider>
+
+      <template v-if="detailAttachments.length">
+
+        <el-tag
+
+          v-for="(f, i) in detailAttachments"
+
+          :key="i"
+
+          class="detail-attachment-tag"
+
+          type="info"
+
+          effect="plain"
+
+        >
+
+          {{ attachmentDisplayName(f) }}
+
+        </el-tag>
+
+      </template>
+
+      <el-empty v-else description="鏆傛棤闄勪欢" :image-size="48" />
+
       </div>
 
       <template #footer>
@@ -366,13 +414,16 @@
 
 import { userListNoPageByTenantId } from "@/api/system/user.js";
 
+import FileUpload from "@/components/AttachmentUpload/file/index.vue";
+
 import FormConfigEditor from "./components/FormConfigEditor.vue";
 
 import TemplateFlowEditor from "./components/TemplateFlowEditor.vue";
 
-import { formatDisplayTime } from "./approveTemplateConstants.js";
+import { formatDisplayTime, mapAttachmentsFromApi } from "./approveTemplateConstants.js";
 
 import { formatDefaultValueDisplay, formFieldTypeLabel, parseFormConfigToData } from "./formConfigUtils.js";
+import { selectOptionSourceLabel } from "./selectOptionSource.js";
 
 import { useApproveTemplate } from "./useApproveTemplate.js";
 
@@ -441,6 +492,20 @@
   parseFormConfigToData(detailRow.value?.formConfigData ?? detailRow.value?.formConfig)
 
 );
+
+
+
+const detailAttachments = computed(() => mapAttachmentsFromApi(detailRow.value));
+
+
+
+function attachmentDisplayName(file) {
+
+  if (!file) return "鏈懡鍚�";
+
+  return file.name || file.originalFilename || file.fileName || "鏈懡鍚�";
+
+}
 
 
 
@@ -706,6 +771,18 @@
 
 }
 
+.upload-block {
+
+  width: 100%;
+
+}
+
+.detail-attachment-tag {
+
+  margin: 0 8px 8px 0;
+
+}
+
 .text-muted {
 
   font-size: 12px;
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-template/selectOptionSource.js b/src/views/officeProcessAutomation/ApproveManage/approve-template/selectOptionSource.js
new file mode 100644
index 0000000..99706b4
--- /dev/null
+++ b/src/views/officeProcessAutomation/ApproveManage/approve-template/selectOptionSource.js
@@ -0,0 +1,140 @@
+import { deptTreeSelect, userListNoPageByTenantId } from "@/api/system/user.js";
+
+/** 涓嬫媺閫夐」鏉ユ簮锛堝啓鍏� formConfig锛屾彁浜ら〉鎸夋潵婧愭媺鍙栨暟鎹級 */
+export const SELECT_OPTION_SOURCE = {
+  STATIC: "static",
+  USER: "user",
+  DEPT: "dept",
+};
+
+export const SELECT_OPTION_SOURCE_OPTIONS = [
+  { value: SELECT_OPTION_SOURCE.STATIC, label: "鎵嬪姩閰嶇疆", desc: "鍦ㄦā鏉夸腑鑷畾涔夐�夐」鏂囨湰涓庡��" },
+  { value: SELECT_OPTION_SOURCE.USER, label: "浜哄憳鍒楄〃", desc: "浠庣郴缁熺敤鎴蜂腑閫夋嫨锛屽�间负鐢ㄦ埛 ID" },
+  { value: SELECT_OPTION_SOURCE.DEPT, label: "閮ㄩ棬鍒楄〃", desc: "浠庣粍缁囨灦鏋勪腑閫夋嫨锛屽�间负閮ㄩ棬 ID" },
+];
+
+export function selectOptionSourceLabel(source) {
+  return SELECT_OPTION_SOURCE_OPTIONS.find((x) => x.value === source)?.label || "鈥�";
+}
+
+export function isDynamicOptionSource(source) {
+  return source === SELECT_OPTION_SOURCE.USER || source === SELECT_OPTION_SOURCE.DEPT;
+}
+
+function unwrapArray(payload) {
+  if (Array.isArray(payload)) return payload;
+  if (payload?.data && Array.isArray(payload.data)) return payload.data;
+  if (payload?.rows && Array.isArray(payload.rows)) return payload.rows;
+  return [];
+}
+
+function isActiveUser(u) {
+  if (u.delFlag === "2" || u.delFlag === 2) return false;
+  if (u.status == null) return true;
+  return String(u.status) === "0";
+}
+
+/** 鐢ㄦ埛 鈫� 涓嬫媺 option */
+export function mapUserToSelectOption(u) {
+  const value = u.userId ?? u.id;
+  return {
+    label: u.nickName || u.userName || `鐢ㄦ埛${value}`,
+    value,
+  };
+}
+
+/** 閮ㄩ棬鏍戞媿骞充负涓嬫媺 option */
+export function flattenDeptToSelectOptions(nodes, result = []) {
+  (nodes || []).forEach((node) => {
+    const value = node.id ?? node.deptId ?? node.value;
+    if (value != null && value !== "") {
+      result.push({
+        label: node.label ?? node.deptName ?? node.name ?? String(value),
+        value,
+      });
+    }
+    if (node.children?.length) flattenDeptToSelectOptions(node.children, result);
+  });
+  return result;
+}
+
+function filterDisabledDept(deptList) {
+  if (!Array.isArray(deptList)) return [];
+  return deptList.filter((dept) => {
+    if (dept.disabled) return false;
+    if (dept.children?.length) {
+      dept.children = filterDisabledDept(dept.children);
+    }
+    return true;
+  });
+}
+
+/** 鎸夊瓧娈甸厤缃В鏋愪笅鎷� options锛堥渶浼犲叆宸插姞杞界殑缂撳瓨锛� */
+export function resolveFieldSelectOptions(field, caches = {}) {
+  const source = field?.optionSource || SELECT_OPTION_SOURCE.STATIC;
+  if (source === SELECT_OPTION_SOURCE.USER) {
+    return (caches.users || []).map(mapUserToSelectOption);
+  }
+  if (source === SELECT_OPTION_SOURCE.DEPT) {
+    return caches.deptOptions || [];
+  }
+  return (field?.options || []).filter((o) => o.value !== "" && o.value != null);
+}
+
+/** 鏍规嵁宸茶В鏋愮殑 options 鍙嶆煡灞曠ず鏂囨湰 */
+export function resolveSelectDisplayLabel(field, val, caches = {}) {
+  if (val == null || val === "") return "鈥�";
+  const options = resolveFieldSelectOptions(field, caches);
+  const hit = options.find((o) => String(o.value) === String(val));
+  return hit?.label || String(val);
+}
+
+/** 鍔犺浇浜哄憳 / 閮ㄩ棬缂撳瓨锛堝澶勫鐢級 */
+export async function fetchSelectOptionCaches(sources = []) {
+  const needUser = sources.includes(SELECT_OPTION_SOURCE.USER);
+  const needDept = sources.includes(SELECT_OPTION_SOURCE.DEPT);
+  const caches = { users: [], deptOptions: [] };
+
+  if (!needUser && !needDept) return caches;
+
+  const tasks = [];
+  if (needUser) {
+    tasks.push(
+      userListNoPageByTenantId()
+        .then((res) => {
+          caches.users = unwrapArray(res).filter(isActiveUser);
+        })
+        .catch(() => {
+          caches.users = [];
+        })
+    );
+  }
+  if (needDept) {
+    tasks.push(
+      deptTreeSelect()
+        .then((res) => {
+          let tree = unwrapArray(res);
+          tree = tree.length ? filterDisabledDept(JSON.parse(JSON.stringify(tree))) : [];
+          if (!tree.length) tree = unwrapArray(res);
+          caches.deptOptions = flattenDeptToSelectOptions(tree);
+        })
+        .catch(() => {
+          caches.deptOptions = [];
+        })
+    );
+  }
+
+  await Promise.all(tasks);
+  return caches;
+}
+
+/** 浠庡瓧娈靛垪琛ㄦ敹闆嗛渶瑕侀鍔犺浇鐨勫姩鎬佹潵婧� */
+export function collectOptionSourcesFromFields(fields) {
+  const set = new Set();
+  (fields || []).forEach((f) => {
+    if (f?.type === "select" && isDynamicOptionSource(f.optionSource)) {
+      set.add(f.optionSource);
+    }
+  });
+  return [...set];
+}
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-template/useApproveTemplate.js b/src/views/officeProcessAutomation/ApproveManage/approve-template/useApproveTemplate.js
index 95eac7b..4b2abe4 100644
--- a/src/views/officeProcessAutomation/ApproveManage/approve-template/useApproveTemplate.js
+++ b/src/views/officeProcessAutomation/ApproveManage/approve-template/useApproveTemplate.js
@@ -210,6 +210,7 @@
       ),
       enabled: row.enabled !== false,
       flowNodes: JSON.parse(JSON.stringify(row.flowNodes || [base.flowNodes[0]])),
+      storageBlobDTOs: JSON.parse(JSON.stringify(row.storageBlobDTOs || [])),
     });
   }
 
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-template/useSelectOptionSources.js b/src/views/officeProcessAutomation/ApproveManage/approve-template/useSelectOptionSources.js
new file mode 100644
index 0000000..8397288
--- /dev/null
+++ b/src/views/officeProcessAutomation/ApproveManage/approve-template/useSelectOptionSources.js
@@ -0,0 +1,45 @@
+import { reactive, ref } from "vue";
+import {
+  collectOptionSourcesFromFields,
+  fetchSelectOptionCaches,
+  resolveFieldSelectOptions,
+  resolveSelectDisplayLabel,
+} from "./selectOptionSource.js";
+
+/** 涓嬫媺鍔ㄦ�侀�夐」锛氫汉鍛� / 閮ㄩ棬缂撳瓨涓庤В鏋� */
+export function useSelectOptionSources() {
+  const loading = ref(false);
+  const caches = reactive({
+    users: [],
+    deptOptions: [],
+  });
+
+  async function ensureForFields(fields) {
+    const sources = collectOptionSourcesFromFields(fields);
+    if (!sources.length) return;
+    loading.value = true;
+    try {
+      const next = await fetchSelectOptionCaches(sources);
+      caches.users = next.users;
+      caches.deptOptions = next.deptOptions;
+    } finally {
+      loading.value = false;
+    }
+  }
+
+  function getOptions(field) {
+    return resolveFieldSelectOptions(field, caches);
+  }
+
+  function getDisplayLabel(field, val) {
+    return resolveSelectDisplayLabel(field, val, caches);
+  }
+
+  return {
+    loading,
+    caches,
+    ensureForFields,
+    getOptions,
+    getDisplayLabel,
+  };
+}
diff --git a/src/views/officeProcessAutomation/HrManage/regular-apply/index.vue b/src/views/officeProcessAutomation/HrManage/regular-apply/index.vue
index b95b6e7..a807e6b 100644
--- a/src/views/officeProcessAutomation/HrManage/regular-apply/index.vue
+++ b/src/views/officeProcessAutomation/HrManage/regular-apply/index.vue
@@ -28,7 +28,7 @@
         <el-button @click="resetSearch">閲嶇疆</el-button>
       </div>
       <div>
-        <el-button type="primary" @click="openFormDialog('add')">鏂板杞鐢宠</el-button>
+        <el-button type="primary" @click="openAddWithTemplate">鏂板杞鐢宠</el-button>
       </div>
     </div>
     <div class="table_list">
@@ -87,7 +87,7 @@
               />
             </el-form-item>
           </el-col>
-          <el-col :span="12">
+          <el-col v-if="!form.hasTemplateBinding" :span="12">
             <el-form-item label="瀹℃壒鏂瑰紡" prop="approvalMode">
               <el-radio-group v-model="form.approvalMode">
                 <el-radio value="parallel">涓庣</el-radio>
@@ -96,7 +96,20 @@
             </el-form-item>
           </el-col>
         </el-row>
-        <el-row :gutter="24">
+        <template v-if="form.hasTemplateBinding">
+          <ApprovalTemplateFormSection
+            :active-template="form.templateSnapshot"
+            :fields="form.formFieldDefs"
+            :form-payload="form.formPayload"
+            v-model:flow-nodes="form.flowNodes"
+            v-model:attachments="form.storageBlobDTOs"
+            :template-attachments="form.templateAttachments"
+            :user-options="flowUserOptions"
+            :allow-change-template="formDialog.mode === 'add'"
+            @change-template="reopenTemplateBind"
+          />
+        </template>
+        <el-row v-else :gutter="24">
           <el-col :span="24">
             <el-form-item label="瀹℃壒浜�" prop="approverIds">
               <el-tree-select
@@ -129,7 +142,7 @@
             </el-form-item>
           </el-col>
         </el-row>
-        <el-row :gutter="24">
+        <el-row v-if="!form.hasTemplateBinding" :gutter="24">
           <el-col :span="24">
             <el-form-item label="闄勪欢">
               <div class="upload-block">
@@ -146,6 +159,12 @@
         </div>
       </template>
     </el-dialog>
+
+    <ApprovalTemplateBindDialog
+      v-model:visible="templateBindVisible"
+      :module-key="APPROVAL_MODULE_KEYS.REGULAR"
+      @confirm="onTemplateBound"
+    />
 
     <!-- 璇︽儏锛堝彧璇伙級 -->
     <el-dialog v-model="detailDialog.visible" title="杞鐢宠璇︽儏" width="640px" append-to-body>
@@ -204,6 +223,14 @@
 import FileUpload from "@/components/AttachmentUpload/file/index.vue";
 import { deptTreeSelect, userListNoPageByTenantId } from "@/api/system/user.js";
 import { computed, getCurrentInstance, nextTick, onMounted, reactive, ref, watch } from "vue";
+import ApprovalTemplateBindDialog from "../../ApproveManage/approve-shared/components/ApprovalTemplateBindDialog.vue";
+import ApprovalTemplateFormSection from "../../ApproveManage/approve-shared/components/ApprovalTemplateFormSection.vue";
+import { APPROVAL_MODULE_KEYS } from "../../ApproveManage/approve-shared/approvalModuleRegistry.js";
+import {
+  applyBindingToForm,
+  buildFormPayloadRules,
+} from "../../ApproveManage/approve-shared/approvalTemplateBindingUtils.js";
+import { useFlowUserOptions } from "../../ApproveManage/approve-shared/useFlowUserOptions.js";
 
 /** 涓庡悗绔害瀹氬瓧娈碉紙鍗犱綅锛� */
 const createEmptyForm = () => ({
@@ -216,6 +243,15 @@
   approverIds: [],
   approverNames: "",
   attachmentList: [],
+  hasTemplateBinding: false,
+  templateId: "",
+  templateName: "",
+  templateSnapshot: null,
+  formFieldDefs: [],
+  formPayload: {},
+  flowNodes: [],
+  templateAttachments: [],
+  storageBlobDTOs: [],
 });
 
 const { proxy } = getCurrentInstance();
@@ -511,22 +547,27 @@
 });
 const formRef = ref();
 const form = reactive(createEmptyForm());
+const templateBindVisible = ref(false);
+const { flowUserOptions, loadFlowUsers } = useFlowUserOptions();
 
-const formRules = {
-  applicantName: [{ required: true, message: "璇疯緭鍏ョ敵璇蜂汉", trigger: "blur" }],
-  applyDate: [{ required: true, message: "璇烽�夋嫨鐢宠鏃ユ湡", trigger: "change" }],
-  regularizationDate: [{ required: true, message: "璇烽�夋嫨杞鏃ユ湡", trigger: "change" }],
-  probationSummary: [{ required: true, message: "璇峰~鍐欒瘯鐢ㄦ湡宸ヤ綔鎬荤粨", trigger: "blur" }],
-  approvalMode: [{ required: true, message: "璇烽�夋嫨瀹℃壒鏂瑰紡", trigger: "change" }],
-  approverIds: [
-    {
-      type: "array",
-      required: true,
-      message: "璇烽�夋嫨瀹℃壒浜�",
-      trigger: "change",
-    },
-  ],
-};
+const formRules = computed(() => {
+  const base = {
+    applicantName: [{ required: true, message: "璇疯緭鍏ョ敵璇蜂汉", trigger: "blur" }],
+    applyDate: [{ required: true, message: "璇烽�夋嫨鐢宠鏃ユ湡", trigger: "change" }],
+    regularizationDate: [{ required: true, message: "璇烽�夋嫨杞鏃ユ湡", trigger: "change" }],
+    probationSummary: [{ required: true, message: "璇峰~鍐欒瘯鐢ㄦ湡宸ヤ綔鎬荤粨", trigger: "blur" }],
+  };
+  if (form.hasTemplateBinding) {
+    return { ...base, ...buildFormPayloadRules(form.formFieldDefs) };
+  }
+  return {
+    ...base,
+    approvalMode: [{ required: true, message: "璇烽�夋嫨瀹℃壒鏂瑰紡", trigger: "change" }],
+    approverIds: [
+      { type: "array", required: true, message: "璇烽�夋嫨瀹℃壒浜�", trigger: "change" },
+    ],
+  };
+});
 
 const detailDialog = reactive({ visible: false });
 const detailRow = ref({});
@@ -569,6 +610,27 @@
     return;
   }
   proxy?.$modal?.msgSuccess?.(`宸叉ā鎷熶笅杞斤細${row.name}`);
+}
+
+function openAddWithTemplate() {
+  templateBindVisible.value = true;
+}
+
+function onTemplateBound(binding) {
+  Object.assign(form, createEmptyForm());
+  applyBindingToForm(form, binding);
+  form.hasTemplateBinding = true;
+  formDialog.mode = "add";
+  formDialog.title = "鏂板杞鐢宠";
+  loadApproverTree();
+  loadFlowUsers();
+  formDialog.visible = true;
+  nextTick(() => formRef.value?.clearValidate?.());
+}
+
+function reopenTemplateBind() {
+  formDialog.visible = false;
+  templateBindVisible.value = true;
 }
 
 function openFormDialog(mode, row) {
@@ -637,6 +699,7 @@
 
 onMounted(() => {
   loadApproverTree();
+  loadFlowUsers();
 });
 </script>
 

--
Gitblit v1.9.3