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