From 8bba0a2d08c7abc07604a0654661efc884e5d751 Mon Sep 17 00:00:00 2001
From: yyb <995253665@qq.com>
Date: 星期六, 16 五月 2026 15:23:17 +0800
Subject: [PATCH] 审批列表和审批模板页面

---
 src/views/officeProcessAutomation/ApproveManage/approve-list/useApproveList.js                     |  343 ++++++++
 src/views/officeProcessAutomation/ApproveManage/approve-template/approveTemplateConstants.js       |  160 ++++
 src/views/officeProcessAutomation/ApproveManage/approve-list/index.vue                             |  438 +++++++++++
 src/views/officeProcessAutomation/ApproveManage/approve-list/approveListConstants.js               |  316 ++++++++
 src/views/officeProcessAutomation/ApproveManage/approve-list/components/ApproveDetailPanel.vue     |   94 ++
 src/views/officeProcessAutomation/ApproveManage/approve-template/components/TemplateFlowEditor.vue |  356 +++++++++
 src/views/officeProcessAutomation/ApproveManage/approve-template/useApproveTemplate.js             |  260 ++++++
 src/views/officeProcessAutomation/ApproveManage/approve-template/index.vue                         |  367 +++++++++
 8 files changed, 2,320 insertions(+), 14 deletions(-)

diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-list/approveListConstants.js b/src/views/officeProcessAutomation/ApproveManage/approve-list/approveListConstants.js
new file mode 100644
index 0000000..447627a
--- /dev/null
+++ b/src/views/officeProcessAutomation/ApproveManage/approve-list/approveListConstants.js
@@ -0,0 +1,316 @@
+import dayjs from "dayjs";
+
+/** 瀹℃壒绫诲瀷锛堜笌鍚庣瀛楁 approvalType 瀵归綈锛屽悗鏈熷彲鍚屾锛� */
+export const APPROVAL_TYPE_OPTIONS = [
+  { value: "cost_reimburse", label: "璐圭敤鎶ラ攢鐢宠", cellBg: "#e8f8ef", cellColor: "#1a7f4b" },
+  { value: "travel_reimburse", label: "宸梾鎶ラ攢鐢宠", cellBg: "#f0f2f5", cellColor: "#606266" },
+  { value: "overtime", label: "鍔犵彮鐢宠", cellBg: "#fdf3e8", cellColor: "#c45c26" },
+  { value: "leave", label: "璇峰亣鐢宠", cellBg: "#fce8f0", cellColor: "#b84d7a" },
+  { value: "work_handover", label: "宸ヤ綔浜ゆ帴鐢宠", cellBg: "#f0e8fc", cellColor: "#6b4d9e" },
+  { value: "regular", label: "杞鐢宠", cellBg: "#e8f4fc", cellColor: "#2b6cb0" },
+  { value: "resign", label: "绂昏亴鐢宠", cellBg: "#ffffff", cellColor: "#303133", border: "1px solid #e4e7ed" },
+  { value: "transfer", label: "璋冨矖鐢宠", cellBg: "#ffffff", cellColor: "#303133", border: "1px solid #e4e7ed" },
+  { value: "out_office", label: "鍏嚭鐢宠", cellBg: "#e8f4ff", cellColor: "#409eff" },
+  { value: "business_trip", label: "鍑哄樊鐢宠", cellBg: "#fdf6ec", cellColor: "#e6a23c" },
+  { value: "procurement", label: "閲囪喘瀹℃壒", cellBg: "#f4f4f5", cellColor: "#909399" },
+  { value: "quotation", label: "鎶ヤ环瀹℃壒", cellBg: "#f4ecfc", cellColor: "#9b59b6" },
+  { value: "shipment", label: "鍙戣揣瀹℃壒", cellBg: "#e8faf6", cellColor: "#1abc9c" },
+];
+
+/** 瀹℃壒鐘舵�� approvalStatus */
+export const APPROVAL_STATUS_OPTIONS = [
+  { value: "pending", label: "瀹℃牳涓�" },
+  { value: "approved", label: "宸查�氳繃" },
+  { value: "rejected", label: "宸查┏鍥�" },
+  { value: "cancelled", label: "宸叉挙閿�" },
+];
+
+/** 瀹℃壒鏂瑰紡 approvalMode */
+export const APPROVAL_MODE_OPTIONS = [
+  { value: "parallel", label: "涓庣" },
+  { value: "or_sign", label: "鎴栫" },
+];
+
+/**
+ * 鎻愪氦瀹℃壒妯℃澘锛堟寜绫诲瀷涓�閿~鎶ワ紝瀛楁鍚庢湡涓庡悗绔ā鏉垮悓姝ワ級
+ */
+export const SUBMIT_TEMPLATES = {
+  cost_reimburse: {
+    approvalType: "cost_reimburse",
+    label: "璐圭敤鎶ラ攢",
+    summaryPlaceholder: "璇峰~鍐欐姤閿�浜嬬敱銆侀噾棰濈瓑",
+    fields: [
+      { key: "summary", label: "鐢宠浜嬬敱", type: "textarea", required: true, rows: 3 },
+      { key: "amount", label: "鎶ラ攢閲戦(鍏�)", type: "number", required: true, min: 0, precision: 2 },
+    ],
+    approvalMode: "parallel",
+  },
+  travel_reimburse: {
+    approvalType: "travel_reimburse",
+    label: "宸梾鎶ラ攢",
+    summaryPlaceholder: "鍑哄樊琛岀▼涓庤垂鐢ㄨ鏄�",
+    fields: [
+      { key: "summary", label: "宸梾璇存槑", type: "textarea", required: true, rows: 3 },
+      { key: "amount", label: "鎶ラ攢閲戦(鍏�)", type: "number", required: true, min: 0, precision: 2 },
+      { key: "tripDays", label: "鍑哄樊澶╂暟", type: "number", required: false, min: 0, precision: 0 },
+    ],
+    approvalMode: "parallel",
+  },
+  overtime: {
+    approvalType: "overtime",
+    label: "鍔犵彮鐢宠",
+    fields: [
+      { key: "summary", label: "鍔犵彮浜嬬敱", type: "textarea", required: true, rows: 3 },
+      { key: "overtimeDate", label: "鍔犵彮鏃ユ湡", type: "date", required: true },
+      { key: "hours", label: "鍔犵彮鏃堕暱(灏忔椂)", type: "number", required: true, min: 0.5, precision: 1 },
+    ],
+    approvalMode: "parallel",
+  },
+  leave: {
+    approvalType: "leave",
+    label: "璇峰亣鐢宠",
+    fields: [
+      { key: "leaveType", label: "璇峰亣绫诲瀷", type: "select", required: true, options: [
+        { label: "骞村亣", value: "annual" },
+        { label: "鐥呭亣", value: "sick" },
+        { label: "浜嬪亣", value: "personal" },
+        { label: "璋冧紤", value: "compensatory" },
+      ] },
+      { key: "summary", label: "璇峰亣浜嬬敱", type: "textarea", required: true, rows: 2 },
+      { key: "dateRange", label: "璇峰亣鏃堕棿", type: "datetimerange", required: true },
+    ],
+    approvalMode: "parallel",
+  },
+  work_handover: {
+    approvalType: "work_handover",
+    label: "宸ヤ綔浜ゆ帴",
+    fields: [
+      { key: "summary", label: "浜ゆ帴璇存槑", type: "textarea", required: true, rows: 3 },
+      { key: "handoverTo", label: "浜ゆ帴瀵硅薄", type: "text", required: true },
+    ],
+    approvalMode: "parallel",
+  },
+  regular: {
+    approvalType: "regular",
+    label: "杞鐢宠",
+    fields: [
+      { key: "summary", label: "杞璇存槑", type: "textarea", required: true, rows: 3 },
+      { key: "regularDate", label: "鎷熻浆姝f棩鏈�", type: "date", required: true },
+    ],
+    approvalMode: "parallel",
+  },
+  resign: {
+    approvalType: "resign",
+    label: "绂昏亴鐢宠",
+    fields: [
+      { key: "summary", label: "绂昏亴鍘熷洜", type: "textarea", required: true, rows: 3 },
+      { key: "lastWorkDay", label: "鏈�鍚庡伐浣滄棩", type: "date", required: true },
+    ],
+    approvalMode: "or_sign",
+  },
+  transfer: {
+    approvalType: "transfer",
+    label: "璋冨矖鐢宠",
+    fields: [
+      { key: "summary", label: "璋冨矖璇存槑", type: "textarea", required: true, rows: 2 },
+      { key: "targetDept", label: "鐩爣閮ㄩ棬", type: "text", required: true },
+      { key: "targetPost", label: "鐩爣宀椾綅", type: "text", required: true },
+    ],
+    approvalMode: "parallel",
+  },
+};
+
+export const STORAGE_KEY = "oa_unified_approve_list_v1";
+
+export function approvalTypeLabel(v) {
+  return APPROVAL_TYPE_OPTIONS.find((x) => x.value === v)?.label || "鈥�";
+}
+
+export function approvalTypeStyle(v) {
+  const hit = APPROVAL_TYPE_OPTIONS.find((x) => x.value === v);
+  if (!hit) return {};
+  return {
+    backgroundColor: hit.cellBg,
+    color: hit.cellColor,
+    border: hit.border || "none",
+  };
+}
+
+export function approvalStatusLabel(v) {
+  return APPROVAL_STATUS_OPTIONS.find((x) => x.value === v)?.label || "鈥�";
+}
+
+export function approvalStatusTagType(v) {
+  if (v === "approved") return "success";
+  if (v === "rejected") return "danger";
+  if (v === "cancelled") return "info";
+  return "primary";
+}
+
+export function approvalModeLabel(v) {
+  if (v === "countersign") return "鎴栫";
+  return APPROVAL_MODE_OPTIONS.find((x) => x.value === v)?.label || "涓庣";
+}
+
+export function unreadLabel(v) {
+  return v ? "鏄�" : "鍚�";
+}
+
+export function buildDefaultFlowNodes() {
+  return [
+    {
+      approverId: "mock_supervisor",
+      approverName: "鐩村睘涓婄骇",
+      sortOrder: 1,
+      nodeOrder: 1,
+      nodeStatus: "process",
+      approveOpinion: "",
+      approveTime: "",
+    },
+    {
+      approverId: "mock_manager",
+      approverName: "閮ㄩ棬缁忕悊",
+      sortOrder: 2,
+      nodeOrder: 2,
+      nodeStatus: "wait",
+      approveOpinion: "",
+      approveTime: "",
+    },
+  ];
+}
+
+function demoRow(partial) {
+  const now = dayjs().format("YYYY-MM-DD HH:mm:ss");
+  return {
+    id: partial.id,
+    bizId: partial.bizId || partial.id,
+    applicantNo: partial.applicantNo,
+    applicantName: partial.applicantName,
+    approvalType: partial.approvalType,
+    approvalMode: partial.approvalMode || "parallel",
+    unread: partial.unread ?? false,
+    approvalStatus: partial.approvalStatus || "pending",
+    createTime: partial.createTime || now,
+    summary: partial.summary || "",
+    formPayload: partial.formPayload || {},
+    approvalFlowNodes: partial.approvalFlowNodes || buildDefaultFlowNodes(),
+    currentNodeIndex: partial.currentNodeIndex ?? 0,
+    approvalRecords: partial.approvalRecords || [],
+    rejectReason: partial.rejectReason || "",
+    sourceRoute: partial.sourceRoute || "",
+  };
+}
+
+/** 鍒濆婕旂ず鏁版嵁锛堝叡 22 鏉★紝涓庡師鍨嬫暟閲忎竴鑷达級 */
+export function createInitialMockRows() {
+  const types = [
+    "cost_reimburse",
+    "travel_reimburse",
+    "overtime",
+    "leave",
+    "work_handover",
+    "regular",
+    "resign",
+    "transfer",
+    "cost_reimburse",
+    "leave",
+    "overtime",
+    "travel_reimburse",
+    "work_handover",
+    "regular",
+    "cost_reimburse",
+    "leave",
+    "transfer",
+    "resign",
+    "overtime",
+    "travel_reimburse",
+    "cost_reimburse",
+    "leave",
+  ];
+  const applicants = [
+    { no: "007", name: "鑻规灉" },
+    { no: "Guest001", name: "澶栭儴鐢ㄦ埛" },
+    { no: "0056", name: "鐜嬩簲" },
+    { no: "0042", name: "鏉庡洓" },
+    { no: "0088", name: "鐚尗" },
+    { no: "0012", name: "寮犱笁" },
+    { no: "0033", name: "璧靛叚" },
+  ];
+  const summaries = [
+    "鍔炲叕鐢ㄥ搧閲囪喘鎶ラ攢",
+    "涓婃捣鍑哄樊宸梾璐�",
+    "鍛ㄦ湯椤圭洰鍔犵彮",
+    "骞村亣 3 澶�",
+    "绂昏亴宸ヤ綔浜ゆ帴",
+    "璇曠敤鏈熻浆姝g敵璇�",
+    "涓汉鍘熷洜绂昏亴",
+    "璋冭嚦閿�鍞儴",
+    "瀹㈡埛鎺ュ緟椁愯垂",
+    "鐥呭亣 1 澶�",
+    "鑺傚亣鏃ュ�肩彮鍔犵彮",
+    "鍖椾含鍩硅宸梾",
+    "椤圭洰鏂囨。浜ゆ帴",
+    "鐮斿彂宀楄浆姝�",
+    "閫氳璐规姤閿�",
+    "浜嬪亣鍗婂ぉ",
+    "璋冨矖鑷冲競鍦洪儴",
+    "鍗忓晢绂昏亴",
+    "宸ヤ綔鏃ュ欢鏃跺姞鐝�",
+    "鎴愰兘灞曚細宸梾",
+    "浜ら�氳垂鎶ラ攢",
+    "璋冧紤 1 澶�",
+  ];
+  const statuses = ["pending", "pending", "pending", "approved", "pending", "pending", "rejected", "pending"];
+  return types.map((approvalType, i) => {
+    const ap = applicants[i % applicants.length];
+    const daysAgo = i % 14;
+    return demoRow({
+      id: `mock_${i + 1}`,
+      bizId: `BIZ${String(2025031400 + i)}`,
+      applicantNo: ap.no,
+      applicantName: ap.name,
+      approvalType,
+      approvalMode: i % 5 === 0 ? "or_sign" : "parallel",
+      unread: i % 3 === 0,
+      approvalStatus: statuses[i % statuses.length],
+      createTime: dayjs().subtract(daysAgo, "day").hour(9 + (i % 8)).minute((i * 7) % 60).second(0).format("YYYY-MM-DD HH:mm:ss"),
+      summary: summaries[i],
+      formPayload: { summary: summaries[i] },
+    });
+  });
+}
+
+export function loadStoredRows() {
+  try {
+    const raw = localStorage.getItem(STORAGE_KEY);
+    if (!raw) return null;
+    const parsed = JSON.parse(raw);
+    return Array.isArray(parsed) ? parsed : null;
+  } catch {
+    return null;
+  }
+}
+
+export function saveStoredRows(rows) {
+  try {
+    localStorage.setItem(STORAGE_KEY, JSON.stringify(rows));
+  } catch {
+    /* ignore quota */
+  }
+}
+
+export function createEmptySubmitForm(templateKey) {
+  const tpl = SUBMIT_TEMPLATES[templateKey];
+  const payload = { summary: "" };
+  (tpl?.fields || []).forEach((f) => {
+    if (f.type === "number") payload[f.key] = undefined;
+    else if (f.type === "datetimerange") payload[f.key] = [];
+    else payload[f.key] = "";
+  });
+  return {
+    templateKey: templateKey || "",
+    approvalMode: tpl?.approvalMode || "parallel",
+    formPayload: payload,
+    approvalFlowNodes: buildDefaultFlowNodes(),
+  };
+}
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-list/components/ApproveDetailPanel.vue b/src/views/officeProcessAutomation/ApproveManage/approve-list/components/ApproveDetailPanel.vue
new file mode 100644
index 0000000..19328af
--- /dev/null
+++ b/src/views/officeProcessAutomation/ApproveManage/approve-list/components/ApproveDetailPanel.vue
@@ -0,0 +1,94 @@
+<!-- 缁熶竴瀹℃壒锛氫笟鍔℃憳瑕� -->
+<template>
+  <el-descriptions :column="2" border>
+    <el-descriptions-item label="涓氬姟鍗曞彿">{{ row.bizId || row.id || "鈥�" }}</el-descriptions-item>
+    <el-descriptions-item label="瀹℃壒鐘舵��">
+      <el-tag :type="approvalStatusTagType(row.approvalStatus)" size="small" effect="plain">
+        {{ approvalStatusLabel(row.approvalStatus) }}
+      </el-tag>
+    </el-descriptions-item>
+    <el-descriptions-item label="瀹℃壒绫诲瀷">
+      <span class="approve-type-cell" :style="approvalTypeStyle(row.approvalType)">
+        {{ approvalTypeLabel(row.approvalType) }}
+      </span>
+    </el-descriptions-item>
+    <el-descriptions-item label="瀹℃壒鏂瑰紡">
+      <span class="approval-method-text">{{ approvalModeLabel(row.approvalMode) }}</span>
+    </el-descriptions-item>
+    <el-descriptions-item label="鐢宠浜虹紪鍙�">{{ row.applicantNo || "鈥�" }}</el-descriptions-item>
+    <el-descriptions-item label="鐢宠浜哄悕绉�">{{ row.applicantName || "鈥�" }}</el-descriptions-item>
+    <el-descriptions-item label="鐢宠鎽樿" :span="2">{{ row.summary || "鈥�" }}</el-descriptions-item>
+    <el-descriptions-item v-if="row.rejectReason" label="椹冲洖鍘熷洜" :span="2">
+      <span class="reject-text">{{ row.rejectReason }}</span>
+    </el-descriptions-item>
+    <el-descriptions-item label="鍒涘缓鏃堕棿" :span="2">{{ row.createTime || "鈥�" }}</el-descriptions-item>
+  </el-descriptions>
+
+  <template v-if="extraFields.length">
+    <el-divider content-position="left">濉姤鍐呭</el-divider>
+    <el-descriptions :column="2" border size="small">
+      <el-descriptions-item v-for="item in extraFields" :key="item.key" :label="item.label">
+        {{ item.display }}
+      </el-descriptions-item>
+    </el-descriptions>
+  </template>
+</template>
+
+<script setup>
+import { computed } from "vue";
+import {
+  approvalTypeLabel,
+  approvalTypeStyle,
+  approvalModeLabel,
+  approvalStatusLabel,
+  approvalStatusTagType,
+  SUBMIT_TEMPLATES,
+} from "../approveListConstants.js";
+
+const props = defineProps({
+  row: { type: Object, default: () => ({}) },
+});
+
+const extraFields = computed(() => {
+  const payload = props.row?.formPayload || {};
+  const tpl = Object.values(SUBMIT_TEMPLATES).find((t) => t.approvalType === props.row?.approvalType);
+  if (!tpl?.fields?.length) {
+    return Object.keys(payload)
+      .filter((k) => k !== "summary" && payload[k] != null && payload[k] !== "")
+      .map((k) => ({ key: k, label: k, display: formatValue(payload[k]) }));
+  }
+  return tpl.fields
+    .map((f) => {
+      const val = payload[f.key];
+      if (val == null || val === "" || (Array.isArray(val) && !val.length)) return null;
+      let display = formatValue(val);
+      if (f.type === "select" && f.options) {
+        display = f.options.find((o) => o.value === val)?.label || display;
+      }
+      return { key: f.key, label: f.label, display };
+    })
+    .filter(Boolean);
+});
+
+function formatValue(val) {
+  if (Array.isArray(val)) return val.join(" 鑷� ");
+  return String(val);
+}
+</script>
+
+<style scoped>
+.approve-type-cell {
+  display: inline-block;
+  padding: 2px 10px;
+  border-radius: 4px;
+  font-size: 13px;
+  line-height: 1.5;
+}
+.approval-method-text {
+  color: var(--el-color-danger);
+  font-weight: 500;
+}
+.reject-text {
+  color: var(--el-color-danger);
+}
+</style>
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-list/index.vue b/src/views/officeProcessAutomation/ApproveManage/approve-list/index.vue
index d4ff149..774b322 100644
--- a/src/views/officeProcessAutomation/ApproveManage/approve-list/index.vue
+++ b/src/views/officeProcessAutomation/ApproveManage/approve-list/index.vue
@@ -1,12 +1,436 @@
-<!--
-  妯″潡涓枃鍚嶏細瀹℃壒鍒楄〃
-  鐩綍鏍囪瘑锛欰pproveManage/approve-list锛坅pprove-list 鈫� 涓枃锛氬鎵瑰垪琛級
-  澶嶇敤椤甸潰锛欯/views/procurementManagement/procurementLedger/index.vue锛堥噰璐彴璐︼紱鏂囦欢鍚� index.vue 鈫� 鍏ュ彛椤碉級
--->
+<!--OA妯″潡锛氬鎵瑰垪琛�-->
 <template>
-  <ProcurementLedger />
+  <div class="app-container">
+    <div class="search_form mb20">
+      <div class="search_fields">
+        <span class="search_title">瀹℃壒绫诲瀷锛�</span>
+        <el-select
+          v-model="searchForm.approvalType"
+          placeholder="璇烽�夋嫨瀹℃壒绫诲瀷"
+          clearable
+          filterable
+          style="width: 200px"
+        >
+          <el-option
+            v-for="opt in APPROVAL_TYPE_OPTIONS"
+            :key="opt.value"
+            :label="opt.label"
+            :value="opt.value"
+          />
+        </el-select>
+        <span class="search_title" style="margin-left: 12px">鐢宠浜哄悕绉帮細</span>
+        <el-input
+          v-model="searchForm.applicantKeyword"
+          style="width: 200px"
+          placeholder="璇疯緭鍏ョ敵璇蜂汉鍚嶇О"
+          clearable
+          :prefix-icon="Search"
+          @keyup.enter="handleQuery"
+        />
+        <span class="search_title" style="margin-left: 12px">鍒涘缓鏃堕棿锛�</span>
+        <el-date-picker
+          v-model="searchForm.createTimeRange"
+          type="daterange"
+          range-separator="-"
+          start-placeholder="寮�濮嬫棩鏈�"
+          end-placeholder="缁撴潫鏃ユ湡"
+          format="YYYY-MM-DD"
+          value-format="YYYY-MM-DD"
+          style="width: 260px"
+          clearable
+        />
+        <el-button type="primary" :icon="Search" style="margin-left: 10px" @click="handleQuery">鎼滅储</el-button>
+        <el-button :icon="RefreshRight" @click="resetSearch">閲嶇疆</el-button>
+      </div>
+      <div class="search_actions">
+        <el-button type="primary" :icon="Plus" @click="openSubmitDialog">鎻愪氦瀹℃壒</el-button>
+      </div>
+    </div>
+
+    <div class="table_list">
+      <PIMTable
+        rowKey="id"
+        :column="tableColumn"
+        :tableData="tableData"
+        :page="page"
+        :isSelection="false"
+        :tableLoading="tableLoading"
+        :total="page.total"
+        @pagination="pagination"
+      >
+        <template #approveType="{ row }">
+          <span class="approve-type-cell" :style="approvalTypeStyle(row.approvalType)">
+            {{ approvalTypeLabel(row.approvalType) }}
+          </span>
+        </template>
+        <template #approvalMethod="{ row }">
+          <span class="approval-method-text">{{ approvalModeLabel(row.approvalMode) }}</span>
+        </template>
+      </PIMTable>
+    </div>
+
+    <!-- 鎻愪氦瀹℃壒锛堟寜妯℃澘锛� -->
+    <el-dialog
+      v-model="submitDialog.visible"
+      :title="submitDialog.step === 1 ? '閫夋嫨瀹℃壒妯℃澘' : `鎻愪氦${activeTemplate?.label || '瀹℃壒'}`"
+      width="720px"
+      append-to-body
+      destroy-on-close
+      class="approve-submit-dialog"
+      @closed="submitDialog.step = 1"
+    >
+      <template v-if="submitDialog.step === 1">
+        <p class="template-hint">璇烽�夋嫨瑕佹彁浜ょ殑瀹℃壒绫诲瀷锛岀郴缁熷皢鎸夊搴旀ā鏉垮紩瀵煎~鎶ワ紙瀛楁鍚庢湡涓庡悗绔悓姝ワ級銆�</p>
+        <div class="template-grid">
+          <div
+            v-for="(tpl, key) in SUBMIT_TEMPLATES"
+            :key="key"
+            class="template-card"
+            @click="onTemplatePick(key)"
+          >
+            <span class="template-card-type" :style="approvalTypeStyle(tpl.approvalType)">
+              {{ tpl.label }}
+            </span>
+            <span class="template-card-desc">{{ tpl.summaryPlaceholder || "鐐瑰嚮濉啓骞舵彁浜�" }}</span>
+          </div>
+        </div>
+      </template>
+
+      <template v-else>
+        <el-form ref="submitFormRef" :model="submitForm" :rules="submitFormRules" label-width="120px">
+          <el-form-item label="瀹℃壒绫诲瀷">
+            <span class="approve-type-cell" :style="approvalTypeStyle(activeTemplate.approvalType)">
+              {{ activeTemplate.label }}
+            </span>
+            <el-button type="primary" link class="ml12" @click="backToTemplatePick">鏇存崲妯℃澘</el-button>
+          </el-form-item>
+          <el-form-item label="瀹℃壒鏂瑰紡" prop="approvalMode">
+            <el-radio-group v-model="submitForm.approvalMode">
+              <el-radio value="parallel">涓庣</el-radio>
+              <el-radio value="or_sign">鎴栫</el-radio>
+            </el-radio-group>
+          </el-form-item>
+          <template v-for="field in activeTemplate.fields" :key="field.key">
+            <el-form-item :label="field.label" :prop="`formPayload.${field.key}`">
+              <el-input
+                v-if="field.type === 'text'"
+                v-model="submitForm.formPayload[field.key]"
+                :placeholder="`璇疯緭鍏�${field.label}`"
+                maxlength="200"
+              />
+              <el-input
+                v-else-if="field.type === 'textarea'"
+                v-model="submitForm.formPayload[field.key]"
+                type="textarea"
+                :rows="field.rows || 3"
+                :placeholder="`璇峰~鍐�${field.label}`"
+                maxlength="2000"
+                show-word-limit
+              />
+              <el-input-number
+                v-else-if="field.type === 'number'"
+                v-model="submitForm.formPayload[field.key]"
+                :min="field.min ?? 0"
+                :precision="field.precision ?? 0"
+                controls-position="right"
+                style="width: 100%"
+              />
+              <el-date-picker
+                v-else-if="field.type === 'date'"
+                v-model="submitForm.formPayload[field.key]"
+                type="date"
+                :placeholder="`璇烽�夋嫨${field.label}`"
+                format="YYYY-MM-DD"
+                value-format="YYYY-MM-DD"
+                style="width: 100%"
+              />
+              <el-date-picker
+                v-else-if="field.type === 'datetimerange'"
+                v-model="submitForm.formPayload[field.key]"
+                type="datetimerange"
+                range-separator="鑷�"
+                start-placeholder="寮�濮嬫椂闂�"
+                end-placeholder="缁撴潫鏃堕棿"
+                format="YYYY-MM-DD HH:mm:ss"
+                value-format="YYYY-MM-DD HH:mm:ss"
+                style="width: 100%"
+              />
+              <el-select
+                v-else-if="field.type === 'select'"
+                v-model="submitForm.formPayload[field.key]"
+                :placeholder="`璇烽�夋嫨${field.label}`"
+                style="width: 100%"
+                clearable
+              >
+                <el-option v-for="o in field.options" :key="o.value" :label="o.label" :value="o.value" />
+              </el-select>
+            </el-form-item>
+          </template>
+          <el-form-item label="瀹℃壒娴佺▼">
+            <ApprovalFlowEditor v-model="submitForm.approvalFlowNodes" :user-options="flowUserOptions" />
+            <p class="flow-tip">鑷冲皯淇濈暀涓�涓鎵硅妭鐐癸紱鎻愪氦鍚庤繘鍏ャ�屽鏍镐腑銆嶇姸鎬併��</p>
+          </el-form-item>
+        </el-form>
+      </template>
+
+      <template #footer>
+        <el-button v-if="submitDialog.step === 2" type="primary" @click="onSubmitNew">鎻� 浜�</el-button>
+        <el-button @click="submitDialog.visible = false">{{ submitDialog.step === 1 ? "鍙� 娑�" : "鍏� 闂�" }}</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 璇︽儏 -->
+    <el-dialog
+      v-model="detailDialog.visible"
+      title="瀹℃壒璇︽儏"
+      width="920px"
+      append-to-body
+      destroy-on-close
+    >
+      <ApproveDetailPanel :row="detailRow" />
+      <el-divider content-position="left">瀹℃壒娴佺▼</el-divider>
+      <ApprovalFlowProgress
+        :nodes="detailRow.approvalFlowNodes"
+        :current-index="detailRow.currentNodeIndex ?? 0"
+      />
+      <el-divider content-position="left">瀹℃壒璁板綍</el-divider>
+      <el-timeline v-if="detailRow.approvalRecords?.length">
+        <el-timeline-item
+          v-for="(rec, i) in detailRow.approvalRecords"
+          :key="i"
+          :type="rec.result === 'approved' ? 'success' : rec.result === 'rejected' ? 'danger' : 'primary'"
+          :timestamp="rec.time"
+        >
+          {{ rec.operatorName }} 鈥� {{ approvalActionLabel(rec.result) }}锛歿{ rec.opinion || "鏃犳剰瑙�" }}
+        </el-timeline-item>
+      </el-timeline>
+      <el-empty v-else description="鏆傛棤瀹℃壒璁板綍" :image-size="60" />
+      <template #footer>
+        <el-button
+          v-if="detailRow.approvalStatus === 'pending'"
+          type="primary"
+          @click="openApproveFromDetail"
+        >
+          鍘诲鎵�
+        </el-button>
+        <el-button @click="detailDialog.visible = false">鍏� 闂�</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 瀹℃壒鎿嶄綔 -->
+    <el-dialog
+      v-model="approveDialog.visible"
+      title="瀹℃壒澶勭悊"
+      width="960px"
+      append-to-body
+      destroy-on-close
+      @closed="approveOpinion = ''"
+    >
+      <ApproveDetailPanel :row="approveDialog.row" />
+      <el-divider content-position="left">娴佺▼杩涘害</el-divider>
+      <ApprovalFlowProgress
+        :nodes="approveDialog.row?.approvalFlowNodes"
+        :current-index="approveDialog.row?.currentNodeIndex ?? 0"
+      />
+      <el-form label-width="100px" class="mt16">
+        <el-form-item label="瀹℃壒鎰忚" required>
+          <el-input
+            v-model="approveOpinion"
+            type="textarea"
+            :rows="3"
+            maxlength="500"
+            show-word-limit
+            placeholder="閫氳繃鍙暀绌猴紱椹冲洖璇峰~鍐欏叿浣撳師鍥�"
+          />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button type="success" @click="onApprove('approved')">閫� 杩�</el-button>
+        <el-button type="danger" @click="onApprove('rejected')">椹� 鍥�</el-button>
+        <el-button @click="approveDialog.visible = false">鍙� 娑�</el-button>
+      </template>
+    </el-dialog>
+  </div>
 </template>
 
 <script setup>
-import ProcurementLedger from '@/views/procurementManagement/procurementLedger/index.vue'
+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 ApprovalFlowEditor from "@/views/officeProcessAutomation/AttendManage/overtime-apply/components/ApprovalFlowEditor.vue";
+import ApprovalFlowProgress from "@/views/officeProcessAutomation/ReimburseManage/travel-reimburse/components/ApprovalFlowProgress.vue";
+import { approvalTypeStyle } from "./approveListConstants.js";
+import ApproveDetailPanel from "./components/ApproveDetailPanel.vue";
+import { useApproveList } from "./useApproveList.js";
+
+const al = useApproveList();
+const {
+  Search,
+  APPROVAL_TYPE_OPTIONS,
+  SUBMIT_TEMPLATES,
+  approvalTypeLabel,
+  approvalModeLabel,
+  approvalActionLabel,
+  searchForm,
+  tableLoading,
+  page,
+  tableData,
+  tableColumn,
+  detailDialog,
+  detailRow,
+  approveDialog,
+  approveOpinion,
+  submitDialog,
+  submitForm,
+  submitFormRef,
+  activeTemplate,
+  submitFormRules,
+  handleQuery,
+  resetSearch,
+  pagination,
+  openSubmitDialog,
+  onTemplatePick,
+  backToTemplatePick,
+  submitNewApproval,
+  submitApprove,
+  openDetail,
+  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 = [];
+  }
+}
+
+async function onSubmitNew() {
+  const ok = await submitNewApproval();
+  if (ok) ElMessage.success("瀹℃壒宸叉彁浜�");
+}
+
+function onApprove(result) {
+  const ret = submitApprove(result);
+  if (ret?.needOpinion) {
+    ElMessage.warning("椹冲洖鏃惰濉啓瀹℃壒鎰忚");
+    return;
+  }
+  if (ret?.ok) {
+    ElMessage.success(result === "approved" ? "宸查�氳繃" : "宸查┏鍥�");
+  }
+}
+
+function openApproveFromDetail() {
+  const row = detailRow.value;
+  detailDialog.visible = false;
+  openApprove(row);
+}
+
+onMounted(() => {
+  loadUsers();
+  handleQuery();
+});
 </script>
+
+<style scoped>
+.mb20 {
+  margin-bottom: 20px;
+}
+.ml12 {
+  margin-left: 12px;
+}
+.mt16 {
+  margin-top: 16px;
+}
+.search_form {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+}
+.search_fields {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 4px;
+}
+.search_actions {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+.approve-type-cell {
+  display: inline-block;
+  padding: 2px 10px;
+  border-radius: 4px;
+  font-size: 13px;
+  line-height: 1.5;
+}
+.approval-method-text {
+  color: var(--el-color-danger);
+  font-weight: 500;
+}
+.template-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;
+}
+.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;
+}
+.flow-tip {
+  font-size: 12px;
+  color: var(--el-text-color-secondary);
+  margin-top: 8px;
+}
+.approve-submit-dialog :deep(.el-dialog__body) {
+  padding-top: 12px;
+}
+</style>
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-list/useApproveList.js b/src/views/officeProcessAutomation/ApproveManage/approve-list/useApproveList.js
new file mode 100644
index 0000000..48103aa
--- /dev/null
+++ b/src/views/officeProcessAutomation/ApproveManage/approve-list/useApproveList.js
@@ -0,0 +1,343 @@
+import { Search } from "@element-plus/icons-vue";
+import dayjs from "dayjs";
+import useUserStore from "@/store/modules/user";
+import { computed, reactive, ref, watch } from "vue";
+import {
+  APPROVAL_TYPE_OPTIONS,
+  SUBMIT_TEMPLATES,
+  approvalModeLabel,
+  approvalStatusLabel,
+  approvalStatusTagType,
+  approvalTypeLabel,
+  createEmptySubmitForm,
+  createInitialMockRows,
+  loadStoredRows,
+  saveStoredRows,
+  buildDefaultFlowNodes,
+} from "./approveListConstants.js";
+
+function advanceFlow(row, result, opinion) {
+  const nodes = row.approvalFlowNodes || [];
+  const idx = row.currentNodeIndex ?? 0;
+  const node = nodes[idx];
+  if (!node) return;
+  node.nodeStatus = result === "approved" ? "finish" : "error";
+  node.approveOpinion = opinion || (result === "approved" ? "鍚屾剰" : "椹冲洖");
+  node.approveTime = dayjs().format("YYYY-MM-DD HH:mm:ss");
+  row.approvalRecords = row.approvalRecords || [];
+  row.approvalRecords.push({
+    operatorName: node.approverName || "瀹℃壒浜�",
+    result,
+    opinion: node.approveOpinion,
+    time: node.approveTime,
+  });
+  if (result === "rejected") {
+    row.approvalStatus = "rejected";
+    row.rejectReason = opinion || node.approveOpinion;
+    return;
+  }
+  const next = idx + 1;
+  if (next < nodes.length) {
+    row.currentNodeIndex = next;
+    nodes[next].nodeStatus = "process";
+    row.approvalStatus = "pending";
+  } else {
+    row.approvalStatus = "approved";
+    row.rejectReason = "";
+  }
+}
+
+export function useApproveList() {
+  const userStore = useUserStore();
+  const stored = loadStoredRows();
+  const allRows = ref(stored?.length ? stored : createInitialMockRows());
+
+  const searchForm = reactive({
+    approvalType: "",
+    applicantKeyword: "",
+    createTimeRange: [],
+  });
+
+  const tableLoading = ref(false);
+  const page = reactive({ current: 1, size: 10, total: 0 });
+
+  const detailDialog = reactive({ visible: false });
+  const detailRow = ref({});
+
+  const approveDialog = reactive({ visible: false, row: null });
+  const approveOpinion = ref("");
+
+  const submitDialog = reactive({ visible: false, step: 1 });
+  const submitForm = reactive(createEmptySubmitForm(""));
+  const submitFormRef = ref();
+
+  const filteredList = computed(() => {
+    let list = [...allRows.value];
+    if (searchForm.approvalType) {
+      list = list.filter((r) => r.approvalType === searchForm.approvalType);
+    }
+    const kw = (searchForm.applicantKeyword || "").trim().toLowerCase();
+    if (kw) {
+      list = list.filter((r) => {
+        const name = (r.applicantName || "").toLowerCase();
+        const no = (r.applicantNo || "").toLowerCase();
+        return name.includes(kw) || no.includes(kw);
+      });
+    }
+    const range = searchForm.createTimeRange;
+    if (range?.length === 2) {
+      const [from, to] = range;
+      list = list.filter((r) => {
+        const t = (r.createTime || "").slice(0, 10);
+        return t && t >= from && t <= to;
+      });
+    }
+    return list.sort((a, b) => (String(a.createTime) < String(b.createTime) ? 1 : -1));
+  });
+
+  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 activeTemplate = computed(() => SUBMIT_TEMPLATES[submitForm.templateKey] || null);
+
+  const submitFormRules = computed(() => {
+    const rules = {
+      templateKey: [{ required: true, message: "璇烽�夋嫨瀹℃壒绫诲瀷", trigger: "change" }],
+    };
+    (activeTemplate.value?.fields || []).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 tableColumn = ref([
+    { label: "鐢宠浜虹紪鍙�", prop: "applicantNo", width: 110 },
+    { label: "鐢宠浜哄悕绉�", prop: "applicantName", minWidth: 100 },
+    {
+      label: "瀹℃壒绫诲瀷",
+      prop: "approvalType",
+      minWidth: 140,
+      dataType: "slot",
+      slot: "approveType",
+    },
+    {
+      label: "瀹℃壒鏂瑰紡",
+      prop: "approvalMode",
+      width: 90,
+      dataType: "slot",
+      slot: "approvalMethod",
+    },
+    {
+      label: "鏄惁鏈",
+      prop: "unread",
+      width: 90,
+      align: "center",
+      formatData: (v) => (v ? "鏄�" : "鍚�"),
+    },
+    {
+      label: "瀹℃壒鐘舵��",
+      prop: "approvalStatus",
+      width: 100,
+      dataType: "tag",
+      formatData: (v) => approvalStatusLabel(v),
+      formatType: (v) => approvalStatusTagType(v),
+    },
+    { label: "鍒涘缓鏃堕棿", prop: "createTime", width: 170 },
+    {
+      dataType: "action",
+      label: "鎿嶄綔",
+      align: "center",
+      fixed: "right",
+      width: 160,
+      operation: [
+        { name: "璇︽儏", type: "text", clickFun: (row) => openDetail(row) },
+        {
+          name: "瀹℃壒",
+          type: "text",
+          disabled: (row) => row.approvalStatus !== "pending",
+          clickFun: (row) => openApprove(row),
+        },
+      ],
+    },
+  ]);
+
+  function persist() {
+    saveStoredRows(allRows.value);
+  }
+
+  function handleQuery() {
+    tableLoading.value = true;
+    page.current = 1;
+    setTimeout(() => {
+      tableLoading.value = false;
+    }, 200);
+  }
+
+  function resetSearch() {
+    searchForm.approvalType = "";
+    searchForm.applicantKeyword = "";
+    searchForm.createTimeRange = [];
+    handleQuery();
+  }
+
+  function pagination({ page: p, limit }) {
+    page.current = p;
+    page.size = limit;
+  }
+
+  function markRead(row) {
+    if (!row.unread) return;
+    const hit = allRows.value.find((r) => r.id === row.id);
+    if (hit) {
+      hit.unread = false;
+      persist();
+    }
+  }
+
+  function openDetail(row) {
+    markRead(row);
+    detailRow.value = { ...row };
+    detailDialog.visible = true;
+  }
+
+  function openApprove(row) {
+    markRead(row);
+    approveDialog.row = { ...row };
+    approveOpinion.value = "";
+    approveDialog.visible = true;
+  }
+
+  function openSubmitDialog() {
+    Object.assign(submitForm, createEmptySubmitForm(""));
+    submitDialog.step = 1;
+    submitDialog.visible = true;
+  }
+
+  function onTemplatePick(key) {
+    Object.assign(submitForm, createEmptySubmitForm(key));
+    submitDialog.step = 2;
+  }
+
+  function backToTemplatePick() {
+    submitDialog.step = 1;
+  }
+
+  async function submitNewApproval() {
+    if (!submitFormRef.value) return false;
+    try {
+      await submitFormRef.value.validate();
+    } catch {
+      return false;
+    }
+    const tpl = activeTemplate.value;
+    if (!tpl) return false;
+    const id = `user_${Date.now()}`;
+    const summary =
+      submitForm.formPayload.summary ||
+      submitForm.formPayload.handoverTo ||
+      `${tpl.label}鐢宠`;
+    const row = {
+      id,
+      bizId: `BIZ${dayjs().format("YYYYMMDDHHmmss")}`,
+      applicantNo: userStore.name || String(userStore.id || "褰撳墠鐢ㄦ埛"),
+      applicantName: userStore.nickName || userStore.name || "褰撳墠鐢ㄦ埛",
+      approvalType: tpl.approvalType,
+      approvalMode: submitForm.approvalMode,
+      unread: false,
+      approvalStatus: "pending",
+      createTime: dayjs().format("YYYY-MM-DD HH:mm:ss"),
+      summary,
+      formPayload: { ...submitForm.formPayload },
+      approvalFlowNodes: (submitForm.approvalFlowNodes?.length
+        ? submitForm.approvalFlowNodes
+        : buildDefaultFlowNodes()
+      ).map((n, i) => ({ ...n, nodeStatus: i === 0 ? "process" : n.nodeStatus || "wait" })),
+      currentNodeIndex: 0,
+      approvalRecords: [],
+      rejectReason: "",
+    };
+    allRows.value.unshift(row);
+    persist();
+    submitDialog.visible = false;
+    page.current = 1;
+    return true;
+  }
+
+  function submitApprove(result) {
+    const row = approveDialog.row;
+    if (!row) return;
+    const hit = allRows.value.find((r) => r.id === row.id);
+    if (!hit || hit.approvalStatus !== "pending") return;
+    if (result === "rejected" && !(approveOpinion.value || "").trim()) {
+      return { needOpinion: true };
+    }
+    advanceFlow(hit, result, (approveOpinion.value || "").trim());
+    hit.unread = false;
+    persist();
+    approveDialog.visible = false;
+    if (detailDialog.visible && detailRow.value?.id === hit.id) {
+      detailRow.value = { ...hit };
+    }
+    return { ok: true };
+  }
+
+  function approvalActionLabel(result) {
+    if (result === "approved") return "閫氳繃";
+    if (result === "rejected") return "椹冲洖";
+    return "寰呭鐞�";
+  }
+
+  return {
+    Search,
+    APPROVAL_TYPE_OPTIONS,
+    SUBMIT_TEMPLATES,
+    approvalTypeLabel,
+    approvalModeLabel,
+    approvalStatusLabel,
+    approvalStatusTagType,
+    approvalActionLabel,
+    searchForm,
+    tableLoading,
+    page,
+    tableData,
+    tableColumn,
+    detailDialog,
+    detailRow,
+    approveDialog,
+    approveOpinion,
+    submitDialog,
+    submitForm,
+    submitFormRef,
+    activeTemplate,
+    submitFormRules,
+    handleQuery,
+    resetSearch,
+    pagination,
+    openSubmitDialog,
+    onTemplatePick,
+    backToTemplatePick,
+    submitNewApproval,
+    submitApprove,
+    openDetail,
+    openApprove,
+  };
+}
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-template/approveTemplateConstants.js b/src/views/officeProcessAutomation/ApproveManage/approve-template/approveTemplateConstants.js
new file mode 100644
index 0000000..81884a1
--- /dev/null
+++ b/src/views/officeProcessAutomation/ApproveManage/approve-template/approveTemplateConstants.js
@@ -0,0 +1,160 @@
+import dayjs from "dayjs";
+import { APPROVAL_TYPE_OPTIONS, SUBMIT_TEMPLATES } from "../approve-list/approveListConstants.js";
+
+/** 鑺傜偣鍐呭鎵规柟寮忥細浼氱 / 鎴栫 */
+export const NODE_SIGN_MODE_OPTIONS = [
+  { value: "countersign", label: "浼氱", desc: "鏈妭鐐规墍鏈夊鎵逛汉鍧囬渶閫氳繃" },
+  { value: "or_sign", label: "鎴栫", desc: "鏈妭鐐逛换涓�瀹℃壒浜洪�氳繃鍗冲彲" },
+];
+
+export const STORAGE_KEY = "oa_approve_template_custom_v1";
+
+/** 绯荤粺鍐呯疆甯哥敤瀹℃壒锛堝彧璇诲睍绀猴紝鏉ユ簮浜庡鎵瑰垪琛ㄦ彁浜ゆā鏉匡級 */
+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,
+  }));
+}
+
+export function nodeSignModeLabel(mode) {
+  return NODE_SIGN_MODE_OPTIONS.find((x) => x.value === mode)?.label || "鈥�";
+}
+
+export function approvalTypeLabel(type) {
+  return APPROVAL_TYPE_OPTIONS.find((x) => x.value === type)?.label || type || "鈥�";
+}
+
+export function createEmptyNode(order = 1) {
+  return {
+    nodeOrder: order,
+    signMode: "countersign",
+    approvers: [],
+  };
+}
+
+export function createEmptyTemplateForm() {
+  return {
+    id: "",
+    templateName: "",
+    description: "",
+    enabled: true,
+    flowNodes: [createEmptyNode(1)],
+  };
+}
+
+export function normalizeFlowNodes(nodes) {
+  const list = Array.isArray(nodes) ? nodes : [];
+  return list.map((n, i) => ({
+    nodeOrder: i + 1,
+    signMode: n.signMode === "or_sign" ? "or_sign" : "countersign",
+    approvers: (n.approvers || [])
+      .filter((a) => a?.approverId != null && a.approverId !== "")
+      .map((a) => ({
+        approverId: a.approverId,
+        approverName: a.approverName || "",
+      })),
+  }));
+}
+
+export function validateTemplateForm(form) {
+  const name = (form.templateName || "").trim();
+  if (!name) return { ok: false, message: "璇峰~鍐欐ā鏉垮悕绉�" };
+  const nodes = normalizeFlowNodes(form.flowNodes);
+  if (!nodes.length) return { ok: false, message: "璇疯嚦灏戦厤缃竴涓鎵硅妭鐐�" };
+  for (let i = 0; i < nodes.length; i++) {
+    if (!nodes[i].approvers.length) {
+      return { ok: false, message: `璇蜂负绗� ${i + 1} 涓妭鐐归�夋嫨鑷冲皯涓�鍚嶅鎵逛汉` };
+    }
+  }
+  return { ok: true, nodes, name };
+}
+
+export function flowNodesSummary(nodes) {
+  const list = normalizeFlowNodes(nodes);
+  if (!list.length) return "鈥�";
+  return list
+    .map((n, i) => {
+      const names = n.approvers.map((a) => a.approverName || "鏈懡鍚�").join("銆�") || "鏈厤缃�";
+      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/TemplateFlowEditor.vue b/src/views/officeProcessAutomation/ApproveManage/approve-template/components/TemplateFlowEditor.vue
new file mode 100644
index 0000000..45b32c0
--- /dev/null
+++ b/src/views/officeProcessAutomation/ApproveManage/approve-template/components/TemplateFlowEditor.vue
@@ -0,0 +1,356 @@
+<!-- 瀹℃壒妯℃澘锛氬彲閰嶇疆鑺傜偣鏁帮紝姣忚妭鐐瑰浜� + 浼氱/鎴栫 -->
+<template>
+  <div class="tfe">
+    <div v-if="innerList.length" class="tfe-flow">
+      <div v-for="(item, index) in innerList" :key="item._uid" class="tfe-flow-item">
+        <div class="tfe-card" :class="{ 'tfe-card--empty': !item.approvers?.length }">
+          <div class="tfe-badge">{{ index + 1 }}</div>
+          <div class="tfe-head">
+            <span class="tfe-level">{{ levelText(index) }}</span>
+            <el-radio-group v-model="item.signMode" size="small" @change="emitOut">
+              <el-radio-button value="countersign">浼氱</el-radio-button>
+              <el-radio-button value="or_sign">鎴栫</el-radio-button>
+            </el-radio-group>
+          </div>
+          <p class="tfe-mode-tip">{{ signModeTip(item.signMode) }}</p>
+          <div class="tfe-select">
+            <el-select
+              v-model="item.approverIds"
+              multiple
+              collapse-tags
+              collapse-tags-tooltip
+              :max-collapse-tags="2"
+              filterable
+              placeholder="璇烽�夋嫨瀹℃壒浜猴紙鍙閫夛級"
+              style="width: 100%"
+              @change="(ids) => onApproversChange(ids, item)"
+            >
+              <el-option
+                v-for="u in userOptions"
+                :key="String(u.userId ?? u.id)"
+                :label="optionLabel(u)"
+                :value="u.userId ?? u.id"
+              />
+            </el-select>
+          </div>
+          <div v-if="item.approvers?.length" class="tfe-chips">
+            <el-tag
+              v-for="a in item.approvers"
+              :key="String(a.approverId)"
+              size="small"
+              type="info"
+              effect="plain"
+            >
+              {{ a.approverName || "鈥�" }}
+            </el-tag>
+          </div>
+          <div class="tfe-actions">
+            <el-button type="primary" circle size="small" :disabled="index === 0" title="鍓嶇Щ" @click="moveLeft(index)">
+              <el-icon><ArrowLeft /></el-icon>
+            </el-button>
+            <el-button
+              type="primary"
+              circle
+              size="small"
+              :disabled="index === innerList.length - 1"
+              title="鍚庣Щ"
+              @click="moveRight(index)"
+            >
+              <el-icon><ArrowRight /></el-icon>
+            </el-button>
+            <el-button type="danger" circle size="small" title="鍒犻櫎鑺傜偣" @click="remove(index)">
+              <el-icon><Delete /></el-icon>
+            </el-button>
+          </div>
+        </div>
+        <div v-if="index < innerList.length - 1" class="tfe-conn">
+          <div class="tfe-conn-line"></div>
+          <el-icon class="tfe-conn-icon"><ArrowRight /></el-icon>
+        </div>
+      </div>
+
+      <div class="tfe-add-wrap">
+        <div v-if="innerList.length" class="tfe-conn">
+          <div class="tfe-conn-line"></div>
+          <el-icon class="tfe-conn-icon"><ArrowRight /></el-icon>
+        </div>
+        <div class="tfe-add-card" @click="addNode">
+          <div class="tfe-add-icon"><el-icon :size="26"><Plus /></el-icon></div>
+          <span>鏂板鑺傜偣</span>
+        </div>
+      </div>
+    </div>
+
+    <div v-else class="tfe-empty">
+      <el-icon :size="44" color="#c0c4cc"><User /></el-icon>
+      <p>鏆傛棤瀹℃壒鑺傜偣</p>
+      <el-button type="primary" @click="addNode">娣诲姞绗竴涓妭鐐�</el-button>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ArrowLeft, ArrowRight, Delete, Plus, User } from "@element-plus/icons-vue";
+import { ref, watch } from "vue";
+import { NODE_SIGN_MODE_OPTIONS, normalizeFlowNodes } from "../approveTemplateConstants.js";
+
+const props = defineProps({
+  modelValue: { type: Array, default: () => [] },
+  userOptions: { type: Array, default: () => [] },
+});
+
+const emit = defineEmits(["update:modelValue"]);
+
+const innerList = ref([]);
+
+function signModeTip(mode) {
+  return NODE_SIGN_MODE_OPTIONS.find((x) => x.value === mode)?.desc || "";
+}
+
+function levelText(i) {
+  const t = ["绗竴绾�", "绗簩绾�", "绗笁绾�", "绗洓绾�", "绗簲绾�", "绗叚绾�", "绗竷绾�", "绗叓绾�"];
+  return t[i] || `绗�${i + 1}绾;
+}
+
+function optionLabel(u) {
+  const nick = u.nickName || "";
+  const un = u.userName || "";
+  if (nick && un && nick !== un) return `${nick}锛�${un}锛塦;
+  return nick || un || `鐢ㄦ埛${u.userId ?? u.id ?? ""}`;
+}
+
+function newUid() {
+  return `n_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
+}
+
+function mapIn(rows) {
+  const normalized = normalizeFlowNodes(rows);
+  return normalized.map((n) => ({
+    _uid: newUid(),
+    nodeOrder: n.nodeOrder,
+    signMode: n.signMode,
+    approverIds: n.approvers.map((a) => a.approverId),
+    approvers: [...n.approvers],
+  }));
+}
+
+function publicShape(rows) {
+  return normalizeFlowNodes(
+    (rows || []).map((r) => ({
+      nodeOrder: r.nodeOrder,
+      signMode: r.signMode,
+      approvers: r.approvers || [],
+    }))
+  );
+}
+
+function emitOut() {
+  emit("update:modelValue", publicShape(innerList.value));
+}
+
+watch(
+  () => props.modelValue,
+  (v) => {
+    const next = publicShape(v || []);
+    if (JSON.stringify(next) === JSON.stringify(publicShape(innerList.value))) return;
+    innerList.value = mapIn(v || []);
+  },
+  { deep: true, immediate: true }
+);
+
+function findUser(id) {
+  if (id == null || id === "") return null;
+  return props.userOptions.find((u) => String(u.userId ?? u.id) === String(id)) ?? null;
+}
+
+function onApproversChange(ids, row) {
+  const idList = Array.isArray(ids) ? ids : [];
+  row.approverIds = idList;
+  row.approvers = idList.map((id) => {
+    const u = findUser(id);
+    return {
+      approverId: id,
+      approverName: u ? u.nickName || u.userName || "" : "",
+    };
+  });
+  emitOut();
+}
+
+function addNode() {
+  innerList.value.push({
+    _uid: newUid(),
+    nodeOrder: innerList.value.length + 1,
+    signMode: "countersign",
+    approverIds: [],
+    approvers: [],
+  });
+  emitOut();
+}
+
+function remove(index) {
+  innerList.value.splice(index, 1);
+  emitOut();
+}
+
+function moveLeft(index) {
+  if (index < 1) return;
+  const t = innerList.value[index];
+  innerList.value[index] = innerList.value[index - 1];
+  innerList.value[index - 1] = t;
+  emitOut();
+}
+
+function moveRight(index) {
+  if (index >= innerList.value.length - 1) return;
+  const t = innerList.value[index];
+  innerList.value[index] = innerList.value[index + 1];
+  innerList.value[index + 1] = t;
+  emitOut();
+}
+</script>
+
+<style scoped>
+.tfe {
+  width: 100%;
+}
+.tfe-flow {
+  display: flex;
+  align-items: flex-start;
+  flex-wrap: nowrap;
+  overflow-x: auto;
+  padding: 6px 0 10px;
+}
+.tfe-flow-item {
+  display: flex;
+  align-items: center;
+}
+.tfe-card {
+  width: 248px;
+  flex-shrink: 0;
+  border: 2px solid var(--el-border-color);
+  border-radius: 12px;
+  padding: 14px 12px 12px;
+  position: relative;
+  background: var(--el-bg-color);
+}
+.tfe-card--empty {
+  border-style: dashed;
+  background: var(--el-fill-color-lighter);
+}
+.tfe-badge {
+  position: absolute;
+  top: -8px;
+  left: 12px;
+  width: 22px;
+  height: 22px;
+  border-radius: 50%;
+  background: var(--el-color-primary);
+  color: #fff;
+  font-size: 12px;
+  font-weight: 700;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.tfe-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+  margin: 8px 0 4px;
+}
+.tfe-level {
+  font-size: 13px;
+  font-weight: 600;
+  color: var(--el-text-color-primary);
+}
+.tfe-mode-tip {
+  font-size: 11px;
+  color: var(--el-text-color-secondary);
+  margin: 0 0 10px;
+  line-height: 1.4;
+  min-height: 30px;
+}
+.tfe-select {
+  margin-bottom: 8px;
+}
+.tfe-chips {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 4px;
+  margin-bottom: 8px;
+  min-height: 24px;
+}
+.tfe-actions {
+  display: flex;
+  justify-content: center;
+  gap: 8px;
+  padding-top: 10px;
+  border-top: 1px solid var(--el-border-color-lighter);
+}
+.tfe-conn {
+  display: flex;
+  align-items: center;
+  width: 40px;
+  flex-shrink: 0;
+  align-self: center;
+}
+.tfe-conn-line {
+  flex: 1;
+  height: 2px;
+  background: var(--el-border-color);
+}
+.tfe-conn-icon {
+  font-size: 14px;
+  color: var(--el-text-color-placeholder);
+  margin-left: -2px;
+}
+.tfe-add-wrap {
+  display: flex;
+  align-items: center;
+}
+.tfe-add-card {
+  width: 120px;
+  min-height: 200px;
+  flex-shrink: 0;
+  border: 2px dashed var(--el-border-color);
+  border-radius: 12px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 10px;
+  cursor: pointer;
+  color: var(--el-text-color-regular);
+  font-size: 13px;
+  background: var(--el-fill-color-lighter);
+  transition: border-color 0.2s, background 0.2s;
+}
+.tfe-add-card:hover {
+  border-color: var(--el-color-primary);
+  background: var(--el-color-primary-light-9);
+  color: var(--el-color-primary);
+}
+.tfe-add-icon {
+  width: 44px;
+  height: 44px;
+  border-radius: 50%;
+  background: var(--el-color-primary);
+  color: #fff;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.tfe-empty {
+  text-align: center;
+  padding: 28px 16px;
+  border: 1px dashed var(--el-border-color);
+  border-radius: 12px;
+  background: var(--el-fill-color-lighter);
+}
+.tfe-empty p {
+  margin: 10px 0 14px;
+  color: var(--el-text-color-secondary);
+  font-size: 14px;
+}
+</style>
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-template/index.vue b/src/views/officeProcessAutomation/ApproveManage/approve-template/index.vue
index f88c88f..a79d546 100644
--- a/src/views/officeProcessAutomation/ApproveManage/approve-template/index.vue
+++ b/src/views/officeProcessAutomation/ApproveManage/approve-template/index.vue
@@ -1,12 +1,365 @@
-<!--
-  妯″潡涓枃鍚嶏細瀹℃壒妯℃澘
-  鐩綍鏍囪瘑锛欰pproveManage/approve-template锛坅pprove-template 鈫� 涓枃锛氬鎵规ā鏉匡級
-  澶嶇敤椤甸潰锛欯/views/procurementManagement/procurementLedger/index.vue锛堥噰璐彴璐︼紱鏂囦欢鍚� index.vue 鈫� 鍏ュ彛椤碉級
--->
+<!--OA妯″潡锛氬鎵规ā鏉匡紙绯荤粺甯哥敤 + 鑷畾涔夊鑺傜偣娴佺▼锛�-->
 <template>
-  <ProcurementLedger />
+  <div class="app-container approve-template-page">
+    <el-tabs v-model="activeTab" class="template-tabs">
+      <el-tab-pane label="绯荤粺甯哥敤瀹℃壒" name="builtin">
+        <el-alert type="info" show-icon :closable="false" class="mb16">
+          <template #title>绯荤粺棰勭疆妯℃澘</template>
+          <template #default>
+            浠ヤ笅涓� OA 妯″潡鍐呯疆鐨勫父鐢ㄥ鎵圭被鍨嬶紝濉姤瀛楁涓庨粯璁ゅ鎵规柟寮忕敱绯荤粺缁存姢锛涙彁浜ゅ鎵规椂鍙洿鎺ラ�夌敤銆�
+          </template>
+        </el-alert>
+        <div class="builtin-grid">
+          <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">
+              <el-tag size="small" effect="plain">{{ item.fieldCount }} 涓~鎶ラ」</el-tag>
+              <el-tag size="small" type="warning" effect="plain">
+                榛樿{{ item.defaultMode === "or_sign" ? "鎴栫" : "涓庣" }}
+              </el-tag>
+              <el-tag size="small" type="info" effect="plain">鍙</el-tag>
+            </div>
+          </div>
+        </div>
+      </el-tab-pane>
+
+      <el-tab-pane label="鑷畾涔夊鎵规ā鏉�" name="custom">
+        <div class="search_form mb20">
+          <div class="search_fields">
+            <span class="search_title">妯℃澘鍚嶇О锛�</span>
+            <el-input
+              v-model="searchForm.keyword"
+              style="width: 220px"
+              placeholder="鎼滅储鍚嶇О鎴栬鏄�"
+              clearable
+              :prefix-icon="Search"
+              @keyup.enter="handleQuery"
+            />
+            <el-checkbox v-model="searchForm.enabledOnly" class="ml12" @change="handleQuery">
+              浠呮樉绀哄惎鐢�
+            </el-checkbox>
+            <el-button type="primary" :icon="Search" class="ml10" @click="handleQuery">鎼滅储</el-button>
+            <el-button :icon="RefreshRight" @click="resetSearch">閲嶇疆</el-button>
+          </div>
+          <div class="search_actions">
+            <el-button type="primary" :icon="Plus" @click="openFormDialog('add')">鏂板缓妯℃澘</el-button>
+          </div>
+        </div>
+
+        <div class="table_list">
+          <PIMTable
+            rowKey="id"
+            :column="tableColumn"
+            :tableData="tableData"
+            :page="page"
+            :isSelection="false"
+            :tableLoading="tableLoading"
+            :total="page.total"
+            @pagination="pagination"
+          />
+        </div>
+      </el-tab-pane>
+    </el-tabs>
+
+    <!-- 鏂板缓 / 缂栬緫 -->
+    <el-dialog
+      v-model="formDialog.visible"
+      :title="formDialog.title"
+      width="960px"
+      append-to-body
+      destroy-on-close
+      class="template-form-dialog"
+      @closed="formRef?.resetFields?.()"
+    >
+      <el-form ref="formRef" :model="form" :rules="formRules" label-width="100px">
+        <el-row :gutter="20">
+          <el-col :span="12">
+            <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-form-item label="鍚敤鐘舵��">
+              <el-switch v-model="form.enabled" active-text="鍚敤" inactive-text="鍋滅敤" />
+            </el-form-item>
+          </el-col>
+        </el-row>
+        <el-form-item label="妯℃澘璇存槑">
+          <el-input
+            v-model="form.description"
+            type="textarea"
+            :rows="2"
+            placeholder="绠�瑕佽鏄庤妯℃澘鐨勯�傜敤鍦烘櫙"
+            maxlength="200"
+            show-word-limit
+          />
+        </el-form-item>
+        <el-form-item label="瀹℃壒娴佺▼" required>
+          <TemplateFlowEditor v-model="form.flowNodes" :user-options="flowUserOptions" />
+          <p class="flow-tip">
+            鎸夐『搴忔祦杞細鍙负姣忎釜鑺傜偣娣诲姞澶氬悕瀹℃壒浜猴紱浼氱闇�鍏ㄩ儴閫氳繃锛屾垨绛句换涓�浜洪�氳繃鍗冲彲杩涘叆涓嬩竴鑺傜偣銆�
+          </p>
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button type="primary" @click="onSubmitForm">淇� 瀛�</el-button>
+        <el-button @click="formDialog.visible = false">鍙� 娑�</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 璇︽儏 -->
+    <el-dialog v-model="detailDialog.visible" title="妯℃澘璇︽儏" width="880px" append-to-body destroy-on-close>
+      <el-descriptions :column="2" border>
+        <el-descriptions-item label="妯℃澘鍚嶇О">{{ detailRow.templateName }}</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>
+      <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">
+          <div class="detail-node-head">
+            <span class="detail-node-order">鑺傜偣 {{ index + 1 }}</span>
+            <el-tag size="small" :type="node.signMode === 'or_sign' ? 'warning' : 'primary'">
+              {{ nodeSignModeLabel(node.signMode) }}
+            </el-tag>
+          </div>
+          <div class="detail-approvers">
+            <el-tag
+              v-for="a in node.approvers"
+              :key="String(a.approverId)"
+              class="detail-approver-tag"
+              effect="plain"
+            >
+              {{ a.approverName || "鈥�" }}
+            </el-tag>
+            <span v-if="!node.approvers?.length" class="text-muted">鏈厤缃鎵逛汉</span>
+          </div>
+          <el-icon v-if="index < detailRow.flowNodes.length - 1" class="detail-arrow"><ArrowRight /></el-icon>
+        </div>
+      </div>
+      <el-empty v-else description="鏆傛棤娴佺▼鑺傜偣" :image-size="60" />
+      <template #footer>
+        <el-button @click="detailDialog.visible = false">鍏� 闂�</el-button>
+        <el-button type="primary" @click="editFromDetail">缂� 杈�</el-button>
+      </template>
+    </el-dialog>
+  </div>
 </template>
 
 <script setup>
-import ProcurementLedger from '@/views/procurementManagement/procurementLedger/index.vue'
+import { ArrowRight, Document, 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 "./components/TemplateFlowEditor.vue";
+import { useApproveTemplate } from "./useApproveTemplate.js";
+
+const at = useApproveTemplate();
+const {
+  Search,
+  activeTab,
+  builtinTemplates,
+  nodeSignModeLabel,
+  searchForm,
+  tableLoading,
+  page,
+  tableData,
+  tableColumn,
+  formDialog,
+  form,
+  formRef,
+  formRules,
+  detailDialog,
+  detailRow,
+  handleQuery,
+  resetSearch,
+  pagination,
+  openFormDialog,
+  openDetail,
+  submitForm,
+} = at;
+
+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 = [];
+  }
+}
+
+async function onSubmitForm() {
+  const ret = await submitForm();
+  if (ret?.message) {
+    ElMessage.warning(ret.message);
+    return;
+  }
+  if (ret?.ok) ElMessage.success("淇濆瓨鎴愬姛");
+}
+
+function editFromDetail() {
+  const row = detailRow.value;
+  detailDialog.visible = false;
+  openFormDialog("edit", row);
+}
+
+onMounted(() => {
+  loadUsers();
+  handleQuery();
+});
 </script>
+
+<style scoped>
+.mb20 {
+  margin-bottom: 20px;
+}
+.mb16 {
+  margin-bottom: 16px;
+}
+.ml10 {
+  margin-left: 10px;
+}
+.ml12 {
+  margin-left: 12px;
+}
+.page-header .header-title {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  font-size: 18px;
+  font-weight: 600;
+  color: var(--el-text-color-primary);
+  margin-bottom: 8px;
+}
+.title-icon {
+  font-size: 22px;
+  color: var(--el-color-primary);
+}
+.header-desc {
+  margin: 0;
+  font-size: 13px;
+  color: var(--el-text-color-secondary);
+  line-height: 1.6;
+  max-width: 920px;
+}
+.search_form {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+}
+.search_fields {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 4px;
+}
+.search_actions {
+  display: flex;
+  gap: 8px;
+}
+.builtin-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
+  gap: 12px;
+}
+.builtin-card {
+  padding: 14px 16px;
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: var(--radius-md, 8px);
+  background: var(--el-fill-color-blank);
+}
+.builtin-label {
+  font-size: 15px;
+  font-weight: 600;
+  color: var(--el-text-color-primary);
+}
+.builtin-summary {
+  margin: 8px 0 10px;
+  font-size: 12px;
+  color: var(--el-text-color-secondary);
+  line-height: 1.5;
+  min-height: 36px;
+}
+.builtin-meta {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 6px;
+}
+.flow-tip {
+  font-size: 12px;
+  color: var(--el-text-color-secondary);
+  margin: 8px 0 0;
+  line-height: 1.5;
+}
+.detail-flow {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: flex-start;
+  gap: 8px;
+}
+.detail-node {
+  position: relative;
+  min-width: 180px;
+  max-width: 240px;
+  padding: 12px;
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 8px;
+  background: var(--el-fill-color-lighter);
+}
+.detail-node-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 8px;
+}
+.detail-node-order {
+  font-weight: 600;
+  font-size: 13px;
+}
+.detail-approvers {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 4px;
+}
+.detail-approver-tag {
+  margin: 0;
+}
+.detail-arrow {
+  position: absolute;
+  right: -20px;
+  top: 50%;
+  transform: translateY(-50%);
+  color: var(--el-text-color-placeholder);
+}
+.text-muted {
+  font-size: 12px;
+  color: var(--el-text-color-placeholder);
+}
+.template-form-dialog :deep(.el-dialog__body) {
+  padding-top: 8px;
+}
+</style>
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-template/useApproveTemplate.js b/src/views/officeProcessAutomation/ApproveManage/approve-template/useApproveTemplate.js
new file mode 100644
index 0000000..c56d055
--- /dev/null
+++ b/src/views/officeProcessAutomation/ApproveManage/approve-template/useApproveTemplate.js
@@ -0,0 +1,260 @@
+import { Search } from "@element-plus/icons-vue";
+import dayjs from "dayjs";
+import { ElMessageBox } from "element-plus";
+import { computed, reactive, ref, watch } from "vue";
+import {
+  createEmptyTemplateForm,
+  createInitialMockTemplates,
+  flowNodesSummary,
+  getBuiltinTemplates,
+  loadStoredTemplates,
+  nodeSignModeLabel,
+  saveStoredTemplates,
+  validateTemplateForm,
+} from "./approveTemplateConstants.js";
+
+export function useApproveTemplate() {
+  const stored = loadStoredTemplates();
+  const allTemplates = ref(stored?.length ? stored : createInitialMockTemplates());
+
+  const activeTab = ref("custom");
+  const builtinTemplates = getBuiltinTemplates();
+
+  const searchForm = reactive({
+    keyword: "",
+    enabledOnly: false,
+  });
+
+  const tableLoading = ref(false);
+  const page = reactive({ current: 1, size: 10, total: 0 });
+
+  const formDialog = reactive({ visible: false, title: "", mode: "add" });
+  const form = reactive(createEmptyTemplateForm());
+  const formRef = ref();
+
+  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 formRules = {
+    templateName: [{ required: true, message: "璇疯緭鍏ユā鏉垮悕绉�", trigger: "blur" }],
+  };
+
+  const tableColumn = ref([
+    { label: "妯℃澘鍚嶇О", prop: "templateName", minWidth: 140 },
+    { label: "璇存槑", prop: "description", minWidth: 160, showOverflowTooltip: true },
+    {
+      label: "鑺傜偣鏁�",
+      prop: "flowNodes",
+      width: 80,
+      align: "center",
+      formatData: (v) => (Array.isArray(v) ? v.length : 0),
+    },
+    {
+      label: "娴佺▼姒傝",
+      prop: "flowNodes",
+      minWidth: 220,
+      showOverflowTooltip: true,
+      formatData: (v) => flowNodesSummary(v),
+    },
+    {
+      label: "鐘舵��",
+      prop: "enabled",
+      width: 90,
+      align: "center",
+      dataType: "tag",
+      formatData: (v) => (v !== false ? "鍚敤" : "鍋滅敤"),
+      formatType: (v) => (v !== false ? "success" : "info"),
+    },
+    { label: "鏇存柊鏃堕棿", prop: "updateTime", width: 170 },
+    {
+      dataType: "action",
+      label: "鎿嶄綔",
+      align: "center",
+      fixed: "right",
+      width: 200,
+      operation: [
+        { name: "璇︽儏", type: "text", clickFun: (row) => openDetail(row) },
+        { name: "缂栬緫", type: "text", clickFun: (row) => openFormDialog("edit", row) },
+        { name: "鍒犻櫎", type: "text", clickFun: (row) => removeTemplate(row) },
+      ],
+    },
+  ]);
+
+  function persist() {
+    saveStoredTemplates(allTemplates.value);
+  }
+
+  function handleQuery() {
+    tableLoading.value = true;
+    page.current = 1;
+    setTimeout(() => {
+      tableLoading.value = false;
+    }, 150);
+  }
+
+  function resetSearch() {
+    searchForm.keyword = "";
+    searchForm.enabledOnly = false;
+    handleQuery();
+  }
+
+  function pagination({ page: p, limit }) {
+    page.current = p;
+    page.size = limit;
+  }
+
+  function resetForm(row) {
+    const base = createEmptyTemplateForm();
+    if (!row) {
+      Object.assign(form, base);
+      return;
+    }
+    Object.assign(form, {
+      ...base,
+      id: row.id,
+      templateName: row.templateName || "",
+      description: row.description || "",
+      enabled: row.enabled !== false,
+      flowNodes: JSON.parse(JSON.stringify(row.flowNodes || [base.flowNodes[0]])),
+    });
+  }
+
+  function openFormDialog(mode, row) {
+    formDialog.mode = mode;
+    formDialog.title = mode === "add" ? "鏂板缓鑷畾涔夊鎵规ā鏉�" : "缂栬緫鑷畾涔夊鎵规ā鏉�";
+    resetForm(mode === "edit" ? row : null);
+    formDialog.visible = true;
+  }
+
+  function openDetail(row) {
+    detailRow.value = { ...row };
+    detailDialog.visible = true;
+  }
+
+  function isNameDuplicate(name, excludeId) {
+    const n = (name || "").trim();
+    return allTemplates.value.some((t) => t.templateName?.trim() === n && t.id !== excludeId);
+  }
+
+  async function submitForm() {
+    if (!formRef.value) return false;
+    try {
+      await formRef.value.validate();
+    } catch {
+      return false;
+    }
+    const validated = validateTemplateForm(form);
+    if (!validated.ok) {
+      return { message: validated.message };
+    }
+    if (isNameDuplicate(validated.name, form.id)) {
+      return { message: "妯℃澘鍚嶇О宸插瓨鍦紝璇锋洿鎹㈠悕绉�" };
+    }
+    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;
+    }
+    persist();
+    formDialog.visible = false;
+    page.current = 1;
+    return { ok: true };
+  }
+
+  async function removeTemplate(row) {
+    try {
+      await ElMessageBox.confirm(`纭畾鍒犻櫎妯℃澘銆�${row.templateName}銆嶅悧锛焋, "鎻愮ず", {
+        type: "warning",
+        confirmButtonText: "鍒犻櫎",
+        cancelButtonText: "鍙栨秷",
+      });
+    } catch {
+      return;
+    }
+    const idx = allTemplates.value.findIndex((t) => t.id === row.id);
+    if (idx >= 0) {
+      allTemplates.value.splice(idx, 1);
+      persist();
+    }
+  }
+
+  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,
+    activeTab,
+    builtinTemplates,
+    nodeSignModeLabel,
+    flowNodesSummary,
+    searchForm,
+    tableLoading,
+    page,
+    tableData,
+    tableColumn,
+    formDialog,
+    form,
+    formRef,
+    formRules,
+    detailDialog,
+    detailRow,
+    handleQuery,
+    resetSearch,
+    pagination,
+    openFormDialog,
+    openDetail,
+    submitForm,
+    toggleEnabled,
+  };
+}

--
Gitblit v1.9.3