From 1fba2685678695cca45127adaada26c7b96eec12 Mon Sep 17 00:00:00 2001
From: yyb <995253665@qq.com>
Date: 星期六, 16 五月 2026 14:46:00 +0800
Subject: [PATCH] : 重构费用报销模块界面和功能
---
src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/useCostReimburse.js | 662 ++++++++++++++++++++++++
src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/costReimburseUtils.js | 309 +++++++++++
src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/index.vue | 558 ++++++++++++++++++++
src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/components/DetailPanel.vue | 71 ++
4 files changed, 1,593 insertions(+), 7 deletions(-)
diff --git a/src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/components/DetailPanel.vue b/src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/components/DetailPanel.vue
new file mode 100644
index 0000000..bfe1b68
--- /dev/null
+++ b/src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/components/DetailPanel.vue
@@ -0,0 +1,71 @@
+<!-- 璐圭敤鎶ラ攢锛氳鎯呭彧璇婚潰鏉� -->
+<template>
+ <el-descriptions :column="2" border>
+ <el-descriptions-item label="鎶ラ攢鍗曞彿">{{ row.reimburseNo || "鈥�" }}</el-descriptions-item>
+ <el-descriptions-item label="鎶ラ攢鐘舵��">
+ <el-tag :type="statusTagType(row.approvalResult)" size="small">{{ statusLabel(row.approvalResult) }}</el-tag>
+ </el-descriptions-item>
+ <el-descriptions-item label="璐圭敤绫诲瀷">{{ expenseCategoryLabel(row.expenseCategory) }}</el-descriptions-item>
+ <el-descriptions-item label="鐢宠鏃堕棿">{{ row.applyTime || row.createTime || "鈥�" }}</el-descriptions-item>
+ <el-descriptions-item label="鍛樺伐缂栧彿">{{ row.employeeNo || row.applicantNo || "鈥�" }}</el-descriptions-item>
+ <el-descriptions-item label="鍛樺伐濮撳悕">{{ row.employeeName || row.applicantName || "鈥�" }}</el-descriptions-item>
+ <el-descriptions-item label="鎶ラ攢鍘熷洜" :span="2">{{ row.reimburseReason || "鈥�" }}</el-descriptions-item>
+ <el-descriptions-item label="鎶ラ攢閲戦">{{ row.applyAmount != null ? `${row.applyAmount} 鍏僠 : "鈥�" }}</el-descriptions-item>
+ <el-descriptions-item label="鏀舵浜�">{{ row.payee || "鈥�" }}</el-descriptions-item>
+ <el-descriptions-item label="鏀舵璐﹀彿">{{ row.payeeAccount || "鈥�" }}</el-descriptions-item>
+ <el-descriptions-item label="寮�鎴锋敮琛�">{{ row.bankBranch || "鈥�" }}</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>
+
+ <el-divider content-position="left">鎶ラ攢鏄庣粏</el-divider>
+ <el-table v-if="row.expenseDetails?.length" :data="row.expenseDetails" border size="small">
+ <el-table-column type="index" label="搴忓彿" width="55" align="center" />
+ <el-table-column prop="invoiceDate" label="鍙戠エ鏃ユ湡" width="120" />
+ <el-table-column label="璐圭敤绉戠洰" width="100">
+ <template #default="{ row: d }">{{ expenseSubjectLabel(d.expenseSubject) }}</template>
+ </el-table-column>
+ <el-table-column prop="amount" label="閲戦" width="100" />
+ <el-table-column prop="description" label="鎻忚堪" min-width="140" show-overflow-tooltip />
+ </el-table>
+ <el-empty v-else description="鏆傛棤鏄庣粏" :image-size="48" />
+
+ <el-divider content-position="left">鍙戠エ闄勪欢</el-divider>
+ <template v-if="attachmentFiles.length">
+ <el-tag v-for="(f, i) in attachmentFiles" :key="i" class="file-tag" type="info" @click="openFile(f)">
+ {{ f.name }}
+ </el-tag>
+ </template>
+ <el-empty v-else description="鏆傛棤闄勪欢" :image-size="48" />
+</template>
+
+<script setup>
+import { computed } from "vue";
+import { expenseCategoryLabel, expenseSubjectLabel, statusLabel, statusTagType } from "../costReimburseUtils.js";
+
+const props = defineProps({
+ row: { type: Object, default: () => ({}) },
+});
+
+const attachmentFiles = computed(() => {
+ const list = props.row?.attachmentList?.length ? props.row.attachmentList : props.row?.invoiceAttachments;
+ return Array.isArray(list) ? list : [];
+});
+
+function openFile(f) {
+ const url = f?.url || f?.downloadURL || f?.previewURL;
+ if (url) window.open(url, "_blank");
+}
+</script>
+
+<style scoped>
+.reject-text {
+ color: var(--el-color-danger);
+}
+.file-tag {
+ margin: 0 8px 8px 0;
+ cursor: pointer;
+}
+</style>
diff --git a/src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/costReimburseUtils.js b/src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/costReimburseUtils.js
new file mode 100644
index 0000000..8cb8fa0
--- /dev/null
+++ b/src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/costReimburseUtils.js
@@ -0,0 +1,309 @@
+import dayjs from "dayjs";
+
+/** 璐圭敤鎶ラ攢澶х被 */
+export const EXPENSE_CATEGORY_OPTIONS = [
+ { label: "宸梾", value: "travel" },
+ { label: "鍔炲叕閲囪喘", value: "office_procurement" },
+ { label: "涓氬姟鎷涘緟", value: "business_entertainment" },
+ { label: "浜ら�氳垂", value: "transport" },
+ { label: "閫氳璐�", value: "communication" },
+ { label: "鍏朵粬", value: "other" },
+];
+
+/** 鏄庣粏璐圭敤绉戠洰 */
+export const EXPENSE_SUBJECT_OPTIONS = [
+ { label: "浜ら�氳垂", value: "transport" },
+ { label: "浣忓璐�", value: "hotel" },
+ { label: "椁愰ギ璐�", value: "meal" },
+ { label: "鍔炲叕鐢ㄥ搧", value: "office_supply" },
+ { label: "鎷涘緟璐�", value: "entertainment" },
+ { label: "閫氳璐�", value: "phone" },
+ { label: "鍏朵粬", value: "other" },
+];
+
+/** 鍒嗙被濉姤妯℃澘锛堜竴閿皟鐢級 */
+export const CATEGORY_TEMPLATES = {
+ travel: {
+ label: "宸梾璐圭敤",
+ reason: "鍥犲叕鍑哄樊浜х敓鐨勪氦閫氥�佷綇瀹裤�侀楗瓑璐圭敤鎶ラ攢銆�",
+ details: [
+ { expenseSubject: "transport", description: "寰�杩斾氦閫氳垂" },
+ { expenseSubject: "hotel", description: "浣忓璐�" },
+ { expenseSubject: "meal", description: "鍑哄樊椁愰ギ" },
+ ],
+ },
+ office_procurement: {
+ label: "鍔炲叕閲囪喘",
+ reason: "閮ㄩ棬鏃ュ父鍔炲叕鐢ㄥ搧銆佽�楁潗閲囪喘鎶ラ攢銆�",
+ details: [
+ { expenseSubject: "office_supply", description: "鍔炲叕鐢ㄥ搧閲囪喘" },
+ { expenseSubject: "office_supply", description: "鎵撳嵃鑰楁潗" },
+ ],
+ },
+ business_entertainment: {
+ label: "涓氬姟鎷涘緟",
+ reason: "瀹㈡埛鎺ュ緟銆佸晢鍔″璇风瓑璐圭敤鎶ラ攢銆�",
+ details: [
+ { expenseSubject: "entertainment", description: "瀹㈡埛鎺ュ緟椁愯垂" },
+ { expenseSubject: "entertainment", description: "鍟嗗姟绀煎搧" },
+ ],
+ },
+ transport: {
+ label: "浜ら�氳垂",
+ reason: "甯傚唴閫氬嫟銆佹墦杞︺�佸仠杞︾瓑浜ら�氳垂鐢ㄦ姤閿�銆�",
+ details: [{ expenseSubject: "transport", description: "甯傚唴浜ら��" }],
+ },
+ communication: {
+ label: "閫氳璐�",
+ reason: "鍥犲叕閫氳銆佹祦閲忋�佽瘽璐硅ˉ璐存姤閿�銆�",
+ details: [{ expenseSubject: "phone", description: "璇濊垂/娴侀噺" }],
+ },
+ other: {
+ label: "鍏朵粬璐圭敤",
+ reason: "鍏朵粬鍥犲叕鏀嚭璐圭敤鎶ラ攢銆�",
+ details: [{ expenseSubject: "other", description: "鍏朵粬璐圭敤" }],
+ },
+};
+
+/** 瀹℃壒瑙掕壊涓庢ā鎷熷鎵逛汉 */
+export const MOCK_APPROVERS_BY_ROLE = {
+ direct_supervisor: { approverId: "mock_supervisor", approverName: "鐩村睘涓婄骇" },
+ dept_manager: { approverId: "mock_manager", approverName: "閮ㄩ棬缁忕悊" },
+ cfo: { approverId: "mock_cfo", approverName: "璐㈠姟鎬荤洃" },
+ compliance: { approverId: "mock_compliance", approverName: "鍚堣瀹℃牳" },
+};
+
+/** 鎸夐噾棰濋璁惧鎵归摼 */
+export const APPROVAL_AMOUNT_RULES = [
+ {
+ maxAmount: 500,
+ description: "500鍏冧互鍐咃細鐩村睘涓婄骇瀹℃壒",
+ roles: ["direct_supervisor"],
+ },
+ {
+ maxAmount: 5000,
+ description: "500锝�5000鍏冿細鐩村睘涓婄骇 + 閮ㄩ棬缁忕悊",
+ roles: ["direct_supervisor", "dept_manager"],
+ },
+ {
+ maxAmount: Infinity,
+ description: "瓒�5000鍏冿細鐩村睘涓婄骇 + 閮ㄩ棬缁忕悊 + 璐㈠姟鎬荤洃澶嶆牳",
+ roles: ["direct_supervisor", "dept_manager", "cfo"],
+ },
+];
+
+/** 閮ㄥ垎鍝佺被棰濆瀹℃壒鑺傜偣 */
+export const CATEGORY_EXTRA_APPROVAL = {
+ business_entertainment: ["compliance"],
+ office_procurement: [],
+};
+
+export function expenseCategoryLabel(v) {
+ return EXPENSE_CATEGORY_OPTIONS.find((x) => x.value === v)?.label || "鈥�";
+}
+
+export function expenseSubjectLabel(v) {
+ return EXPENSE_SUBJECT_OPTIONS.find((x) => x.value === v)?.label || "鈥�";
+}
+
+export function statusLabel(v) {
+ if (v === "approved") return "宸查�氳繃";
+ if (v === "rejected") return "宸查┏鍥�";
+ return "瀹℃牳涓�";
+}
+
+export function statusTagType(v) {
+ if (v === "approved") return "success";
+ if (v === "rejected") return "danger";
+ return "warning";
+}
+
+export function formatApprovalFlowSummary(row) {
+ const nodes = row?.approvalFlowNodes || [];
+ if (!nodes.length) return "鈥�";
+ return nodes
+ .map((n, i) => {
+ const name = (n.approverName || "").trim() || `鑺傜偣${i + 1}`;
+ if (n.nodeStatus === "finish") return `${name}鉁揱;
+ if (n.nodeStatus === "error") return `${name}鉁梎;
+ if (n.nodeStatus === "process") return `${name}鈥;
+ return name;
+ })
+ .join(" 鈫� ");
+}
+
+export function resolveApprovalRoles(amount, expenseCategory) {
+ const amt = Number(amount) || 0;
+ let roles = [];
+ for (const rule of APPROVAL_AMOUNT_RULES) {
+ if (amt <= rule.maxAmount) {
+ roles = [...rule.roles];
+ break;
+ }
+ }
+ if (!roles.length) roles = ["direct_supervisor"];
+ const extra = CATEGORY_EXTRA_APPROVAL[expenseCategory] || [];
+ extra.forEach((r) => {
+ if (!roles.includes(r)) roles.push(r);
+ });
+ return roles;
+}
+
+export function buildAutoApprovalFlow(amount, expenseCategory) {
+ const roles = resolveApprovalRoles(amount, expenseCategory);
+ return roles.map((role, i) => {
+ const mock = MOCK_APPROVERS_BY_ROLE[role] || { approverId: `mock_${role}`, approverName: role };
+ return {
+ approverId: mock.approverId,
+ approverName: mock.approverName,
+ roleKey: role,
+ sortOrder: i + 1,
+ nodeOrder: i + 1,
+ nodeStatus: i === 0 ? "process" : "wait",
+ approveOpinion: "",
+ approveTime: "",
+ };
+ });
+}
+
+export function getApprovalRuleHint(amount, expenseCategory) {
+ const amt = Number(amount) || 0;
+ const rule = APPROVAL_AMOUNT_RULES.find((r) => amt <= r.maxAmount) || APPROVAL_AMOUNT_RULES[APPROVAL_AMOUNT_RULES.length - 1];
+ const extra = CATEGORY_EXTRA_APPROVAL[expenseCategory] || [];
+ const extraText = extra.length
+ ? `锛�${expenseCategoryLabel(expenseCategory)}绫诲彟闇�锛�${extra.map((r) => MOCK_APPROVERS_BY_ROLE[r]?.approverName || r).join("銆�")}`
+ : "";
+ return `${rule.description}${extraText}`;
+}
+
+export function createEmptyExpenseDetail() {
+ return {
+ id: `ed_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
+ invoiceDate: "",
+ expenseSubject: "",
+ amount: undefined,
+ description: "",
+ };
+}
+
+export function createEmptyForm() {
+ return {
+ id: undefined,
+ reimburseNo: "",
+ applicantId: "",
+ employeeNo: "",
+ employeeName: "",
+ expenseCategory: "",
+ reimburseReason: "",
+ applyAmount: undefined,
+ payee: "",
+ payeeAccount: "",
+ bankBranch: "",
+ expenseDetails: [],
+ attachmentList: [],
+ approvalFlowNodes: [],
+ currentNodeIndex: 0,
+ approvalResult: "pending",
+ rejectReason: "",
+ deptId: "",
+ deptName: "",
+ };
+}
+
+export function applyCategoryTemplate(form, category) {
+ const tpl = CATEGORY_TEMPLATES[category];
+ if (!tpl) return;
+ form.expenseCategory = category;
+ if (!form.reimburseReason?.trim()) form.reimburseReason = tpl.reason;
+ form.expenseDetails = (tpl.details || []).map((d) => ({
+ ...createEmptyExpenseDetail(),
+ expenseSubject: d.expenseSubject,
+ description: d.description,
+ invoiceDate: dayjs().format("YYYY-MM-DD"),
+ }));
+}
+
+export function initApprovalFlowNodes(nodes) {
+ return (nodes || []).map((n, i) => ({
+ ...n,
+ sortOrder: i + 1,
+ nodeOrder: i + 1,
+ nodeStatus: i === 0 ? "process" : "wait",
+ approveOpinion: n.approveOpinion || "",
+ approveTime: n.approveTime || "",
+ }));
+}
+
+export function advanceApprovalFlow(row, opinion) {
+ const nodes = [...(row.approvalFlowNodes || [])];
+ const idx = row.currentNodeIndex ?? 0;
+ if (!nodes.length) return { nodes, currentNodeIndex: idx, approvalResult: row.approvalResult };
+ const now = dayjs().format("YYYY-MM-DD HH:mm:ss");
+ nodes[idx] = {
+ ...nodes[idx],
+ nodeStatus: "finish",
+ approveOpinion: opinion || "鍚屾剰",
+ approveTime: now,
+ };
+ const next = idx + 1;
+ if (next >= nodes.length) {
+ return { nodes, currentNodeIndex: idx, approvalResult: "approved", rejectReason: "" };
+ }
+ nodes[next] = { ...nodes[next], nodeStatus: "process" };
+ return { nodes, currentNodeIndex: next, approvalResult: "pending", rejectReason: "" };
+}
+
+export function rejectApprovalFlow(row, opinion) {
+ const nodes = [...(row.approvalFlowNodes || [])];
+ const idx = row.currentNodeIndex ?? 0;
+ const now = dayjs().format("YYYY-MM-DD HH:mm:ss");
+ const reason = (opinion || "").trim() || "椹冲洖";
+ if (nodes[idx]) {
+ nodes[idx] = {
+ ...nodes[idx],
+ nodeStatus: "error",
+ approveOpinion: reason,
+ approveTime: now,
+ };
+ }
+ return { nodes, currentNodeIndex: idx, approvalResult: "rejected", rejectReason: reason };
+}
+
+export function normalizeImportedRow(raw, idx) {
+ const id = raw.id != null && String(raw.id).length ? `imp_${String(raw.id)}_${idx}` : `imp_${Date.now()}_${idx}`;
+ const expenseDetails = Array.isArray(raw.expenseDetails) ? raw.expenseDetails : [];
+ const applyAmount = raw.applyAmount ?? expenseDetails.reduce((s, d) => s + (Number(d.amount) || 0), 0);
+ const expenseCategory = raw.expenseCategory || "other";
+ const approvalFlowNodes =
+ Array.isArray(raw.approvalFlowNodes) && raw.approvalFlowNodes.length
+ ? raw.approvalFlowNodes
+ : buildAutoApprovalFlow(applyAmount, expenseCategory);
+
+ return {
+ id,
+ reimburseNo: raw.reimburseNo || `CR${dayjs().format("YYYYMMDD")}${String(idx).padStart(4, "0")}`,
+ applicantId: raw.applicantId != null ? String(raw.applicantId) : `imp_user_${idx}`,
+ employeeNo: raw.employeeNo ?? raw.applicantNo ?? "",
+ employeeName: raw.employeeName ?? raw.applicantName ?? "鏈煡",
+ applicantNo: raw.employeeNo ?? raw.applicantNo ?? "",
+ applicantName: raw.employeeName ?? raw.applicantName ?? "鏈煡",
+ expenseCategory,
+ reimburseReason: raw.reimburseReason ?? "",
+ applyAmount,
+ payee: raw.payee ?? "",
+ payeeAccount: raw.payeeAccount ?? "",
+ bankBranch: raw.bankBranch ?? "",
+ expenseDetails,
+ attachmentList: Array.isArray(raw.attachmentList) ? raw.attachmentList : [],
+ invoiceAttachments: Array.isArray(raw.invoiceAttachments) ? raw.invoiceAttachments : [],
+ approvalFlowNodes,
+ currentNodeIndex: raw.currentNodeIndex ?? 0,
+ approvalResult: ["pending", "approved", "rejected"].includes(raw.approvalResult) ? raw.approvalResult : "pending",
+ rejectReason: raw.rejectReason ?? "",
+ approvalRecords: Array.isArray(raw.approvalRecords) ? raw.approvalRecords : [],
+ applyTime: raw.applyTime || raw.createTime || dayjs().format("YYYY-MM-DD HH:mm:ss"),
+ createTime: raw.createTime || dayjs().format("YYYY-MM-DD HH:mm:ss"),
+ deptId: raw.deptId ?? "",
+ deptName: raw.deptName ?? "",
+ };
+}
diff --git a/src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/index.vue b/src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/index.vue
index 2ee0a60..b384569 100644
--- a/src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/index.vue
+++ b/src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/index.vue
@@ -1,12 +1,556 @@
-<!--
- 妯″潡涓枃鍚嶏細璐圭敤鎶ラ攢
- 鐩綍鏍囪瘑锛歊eimburseManage/cost-reimburse锛坈ost-reimburse 鈫� 涓枃锛氳垂鐢ㄦ姤閿�锛�
- 澶嶇敤椤甸潰锛欯/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-input
+ v-model="searchForm.applicantKeyword"
+ style="width: 220px"
+ placeholder="濮撳悕鎴栫紪鍙�"
+ clearable
+ :prefix-icon="Search"
+ @keyup.enter="handleQuery"
+ />
+ <span class="search_title" style="margin-left: 12px">鐢宠鏃堕棿锛�</span>
+ <el-date-picker
+ v-model="searchForm.applyTimeFrom"
+ type="date"
+ placeholder="寮�濮嬫棩鏈�"
+ format="YYYY-MM-DD"
+ value-format="YYYY-MM-DD"
+ style="width: 150px"
+ clearable
+ />
+ <span class="search_title" style="margin-left: 8px">鑷�</span>
+ <el-date-picker
+ v-model="searchForm.applyTimeTo"
+ type="date"
+ placeholder="缁撴潫鏃ユ湡"
+ format="YYYY-MM-DD"
+ value-format="YYYY-MM-DD"
+ style="width: 150px; margin-left: 8px"
+ clearable
+ />
+ <el-button type="primary" style="margin-left: 10px" @click="handleQuery">鎼滅储</el-button>
+ <el-button @click="resetSearch">閲嶇疆</el-button>
+ </div>
+ <div class="search_actions">
+ <el-button type="success" plain @click="handleImportClick">瀵煎叆</el-button>
+ <el-button type="warning" plain @click="handleExport">瀵煎嚭</el-button>
+ <el-button type="primary" @click="openFormDialog('add')">鏂板璐圭敤鎶ラ攢</el-button>
+ </div>
+ </div>
+
+ <input ref="importInputRef" type="file" accept="application/json,.json" class="sr-only-input" @change="onImportFile" />
+
+ <div class="table_list">
+ <PIMTable
+ rowKey="id"
+ :column="tableColumn"
+ :tableData="tableData"
+ :page="page"
+ :isSelection="false"
+ :tableLoading="tableLoading"
+ :total="page.total"
+ @pagination="pagination"
+ />
+ </div>
+
+ <!-- 鏂板 / 缂栬緫 -->
+ <el-dialog
+ v-model="formDialog.visible"
+ :title="formDialog.title"
+ width="1120px"
+ append-to-body
+ destroy-on-close
+ class="cost-reimburse-form-dialog"
+ @closed="onFormClosed"
+ >
+ <el-alert type="info" show-icon :closable="false" class="mb16">
+ <template #title>鍏ㄥ搧绫昏垂鐢ㄦ姤閿� 路 鍒嗙被妯℃澘涓�閿~鎶�</template>
+ <template #default>
+ 鏀寔宸梾銆佸姙鍏噰璐�佷笟鍔℃嫑寰呫�佷氦閫氳垂銆侀�氳璐圭瓑锛涙寜閲戦鑷姩鍖归厤瀹℃壒閾撅紙500鍏冨唴鐩村睘涓婄骇锛岃秴5000鍏冭储鍔℃�荤洃澶嶆牳锛夈��
+ </template>
+ </el-alert>
+
+ <div v-if="!formDialog.readonly" class="template-bar mb16">
+ <span class="template-label">鍒嗙被妯℃澘锛�</span>
+ <el-button
+ v-for="(tpl, key) in CATEGORY_TEMPLATES"
+ :key="key"
+ size="small"
+ :type="form.expenseCategory === key ? 'primary' : 'default'"
+ plain
+ @click="applyTemplate(key)"
+ >
+ {{ tpl.label }}
+ </el-button>
+ </div>
+
+ <el-form
+ ref="formRef"
+ :model="form"
+ :rules="formRules"
+ label-width="120px"
+ class="cost-reimburse-form"
+ :disabled="formDialog.readonly"
+ >
+ <el-card class="form-section" shadow="never">
+ <template #header><span class="card-header-title">鍩烘湰淇℃伅</span></template>
+ <el-row :gutter="20">
+ <el-col :span="12">
+ <el-form-item label="鍛樺伐缂栧彿">
+ <el-input v-model="form.employeeNo" readonly placeholder="閫夋嫨鍛樺伐鍚庤嚜鍔ㄥ甫鍑�" />
+ </el-form-item>
+ </el-col>
+ <el-col :span="12">
+ <el-form-item label="鍛樺伐濮撳悕" prop="applicantId">
+ <el-select
+ v-model="form.applicantId"
+ filterable
+ remote
+ clearable
+ reserve-keyword
+ placeholder="璇烽�夋嫨鎴栨悳绱㈠憳宸�"
+ style="width: 100%"
+ :remote-method="remoteSearchApplicantForm"
+ :loading="applicantFormSearchLoading"
+ @change="onApplicantChange"
+ >
+ <el-option
+ v-for="u in applicantFormOptions"
+ :key="u.userId"
+ :label="userSelectLabel(u)"
+ :value="u.userId"
+ />
+ </el-select>
+ </el-form-item>
+ </el-col>
+ </el-row>
+ <el-row :gutter="20">
+ <el-col :span="12">
+ <el-form-item label="璐圭敤绫诲瀷" prop="expenseCategory">
+ <el-select
+ v-model="form.expenseCategory"
+ placeholder="璇烽�夋嫨璐圭敤绫诲瀷"
+ style="width: 100%"
+ @change="onExpenseCategoryChange"
+ >
+ <el-option
+ v-for="opt in EXPENSE_CATEGORY_OPTIONS"
+ :key="opt.value"
+ :label="opt.label"
+ :value="opt.value"
+ />
+ </el-select>
+ </el-form-item>
+ </el-col>
+ <el-col :span="12">
+ <el-form-item label="鎶ラ攢鐘舵��">
+ <el-tag
+ :type="form.approvalResult === 'approved' ? 'success' : form.approvalResult === 'rejected' ? 'danger' : 'warning'"
+ effect="plain"
+ >
+ {{
+ form.approvalResult === "approved"
+ ? "宸查�氳繃"
+ : form.approvalResult === "rejected"
+ ? "宸查┏鍥�"
+ : "瀹℃牳涓�"
+ }}
+ </el-tag>
+ </el-form-item>
+ </el-col>
+ </el-row>
+ <el-row :gutter="20">
+ <el-col :span="24">
+ <el-form-item label="鎶ラ攢鍘熷洜" prop="reimburseReason">
+ <el-input
+ v-model="form.reimburseReason"
+ type="textarea"
+ :rows="3"
+ placeholder="璇峰~鍐欐姤閿�鍘熷洜"
+ maxlength="2000"
+ show-word-limit
+ />
+ </el-form-item>
+ </el-col>
+ </el-row>
+ <el-row :gutter="20">
+ <el-col :span="12">
+ <el-form-item label="鎶ラ攢閲戦" prop="applyAmount">
+ <div class="amount-row">
+ <el-input-number
+ v-model="form.applyAmount"
+ :min="0"
+ :precision="2"
+ controls-position="right"
+ class="amount-input"
+ @change="autoAssignApprovalFlow"
+ />
+ <el-button v-if="!formDialog.readonly" type="primary" link @click="syncApplyAmountFromDetails">
+ 鎸夋槑缁嗘眹鎬� {{ detailTotalAmount }} 鍏�
+ </el-button>
+ </div>
+ </el-form-item>
+ </el-col>
+ </el-row>
+ </el-card>
+
+ <el-card class="form-section" shadow="never">
+ <template #header>
+ <div class="card-header-row">
+ <span class="card-header-title">鎶ラ攢鏄庣粏</span>
+ <el-button v-if="!formDialog.readonly" type="primary" plain size="small" @click="addExpenseDetail">
+ 鏂板鏄庣粏
+ </el-button>
+ </div>
+ </template>
+
+ <el-table :data="form.expenseDetails" border size="small" class="detail-table">
+ <el-table-column type="index" label="搴忓彿" width="55" align="center" />
+ <el-table-column label="鍙戠エ鏃ユ湡" width="150">
+ <template #default="{ row }">
+ <el-date-picker
+ v-if="!formDialog.readonly"
+ v-model="row.invoiceDate"
+ type="date"
+ value-format="YYYY-MM-DD"
+ size="small"
+ style="width: 100%"
+ />
+ <span v-else>{{ row.invoiceDate || "鈥�" }}</span>
+ </template>
+ </el-table-column>
+ <el-table-column label="璐圭敤绉戠洰" width="130">
+ <template #default="{ row }">
+ <el-select v-if="!formDialog.readonly" v-model="row.expenseSubject" size="small" style="width: 100%">
+ <el-option
+ v-for="opt in EXPENSE_SUBJECT_OPTIONS"
+ :key="opt.value"
+ :label="opt.label"
+ :value="opt.value"
+ />
+ </el-select>
+ <span v-else>{{ expenseSubjectLabel(row.expenseSubject) }}</span>
+ </template>
+ </el-table-column>
+ <el-table-column label="閲戦" width="120">
+ <template #default="{ row }">
+ <el-input-number
+ v-if="!formDialog.readonly"
+ v-model="row.amount"
+ :min="0"
+ :precision="2"
+ size="small"
+ controls-position="right"
+ style="width: 100%"
+ @change="onDetailAmountChange"
+ />
+ <span v-else>{{ row.amount ?? "鈥�" }}</span>
+ </template>
+ </el-table-column>
+ <el-table-column label="鎻忚堪" min-width="140">
+ <template #default="{ row }">
+ <el-input v-if="!formDialog.readonly" v-model="row.description" size="small" placeholder="璇存槑" />
+ <span v-else>{{ row.description || "鈥�" }}</span>
+ </template>
+ </el-table-column>
+ <el-table-column v-if="!formDialog.readonly" label="鎿嶄綔" width="70" align="center">
+ <template #default="{ $index }">
+ <el-button type="danger" link size="small" @click="removeExpenseDetail($index)">鍒犻櫎</el-button>
+ </template>
+ </el-table-column>
+ </el-table>
+ </el-card>
+
+ <el-card class="form-section" shadow="never">
+ <template #header><span class="card-header-title">鏀舵淇℃伅</span></template>
+ <el-row :gutter="20">
+ <el-col :span="8">
+ <el-form-item label="鏀舵浜�" prop="payee">
+ <el-input v-model="form.payee" placeholder="璇疯緭鍏ユ敹娆句汉" maxlength="50" />
+ </el-form-item>
+ </el-col>
+ <el-col :span="8">
+ <el-form-item label="鏀舵璐﹀彿" prop="payeeAccount">
+ <el-input v-model="form.payeeAccount" placeholder="閾惰鍗″彿" maxlength="30" />
+ </el-form-item>
+ </el-col>
+ <el-col :span="8">
+ <el-form-item label="寮�鎴锋敮琛�" prop="bankBranch">
+ <el-input v-model="form.bankBranch" placeholder="寮�鎴锋敮琛屽叏绉�" maxlength="100" />
+ </el-form-item>
+ </el-col>
+ </el-row>
+ </el-card>
+
+ <el-card class="form-section" shadow="never">
+ <template #header><span class="card-header-title">闄勪欢锛堝彂绁級</span></template>
+ <el-form-item label-width="0" class="attachment-form-item">
+ <div class="upload-block">
+ <FileUpload v-model:file-list="form.attachmentList" :limit="20" button-text="鐐瑰嚮閫夋嫨鏂囦欢" />
+ </div>
+ </el-form-item>
+ </el-card>
+
+ <el-card class="form-section" shadow="never">
+ <template #header>
+ <div class="card-header-row">
+ <span class="card-header-title">瀹℃壒娴佺▼</span>
+ <el-button v-if="!formDialog.readonly" type="primary" link size="small" @click="autoAssignApprovalFlow">
+ 鎸夎鍒欓噸鏂板垎閰�
+ </el-button>
+ </div>
+ </template>
+ <el-alert type="success" :title="approvalRuleHint" show-icon :closable="false" class="mb12" />
+ <el-form-item prop="approvalFlowNodes" label-width="0">
+ <ApprovalFlowEditor
+ v-if="!formDialog.readonly"
+ v-model="form.approvalFlowNodes"
+ :user-options="flowUserOptions"
+ @update:model-value="onApprovalFlowChange"
+ />
+ <ApprovalFlowProgress v-else :nodes="form.approvalFlowNodes" :current-index="form.currentNodeIndex" />
+ <p v-if="!formDialog.readonly" class="flow-tip">绯荤粺宸叉寜閲戦涓庤垂鐢ㄧ被鍨嬭嚜鍔ㄥ垎閰嶅鎵逛汉锛屽彲鎵嬪姩璋冩暣銆�</p>
+ </el-form-item>
+ </el-card>
+ </el-form>
+ <template #footer>
+ <el-button v-if="!formDialog.readonly" type="primary" @click="submitForm">鎻� 浜�</el-button>
+ <el-button @click="formDialog.visible = false">{{ formDialog.readonly ? "鍏� 闂�" : "鍙� 娑�" }}</el-button>
+ </template>
+ </el-dialog>
+
+ <!-- 璇︽儏 -->
+ <el-dialog v-model="detailDialog.visible" title="璐圭敤鎶ラ攢璇︽儏" width="900px" append-to-body destroy-on-close>
+ <DetailPanel :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 type="primary" @click="detailDialog.visible = false">鍏� 闂�</el-button>
+ </template>
+ </el-dialog>
+
+ <!-- 瀹℃壒 -->
+ <el-dialog
+ v-model="approveDialog.visible"
+ title="璐圭敤鎶ラ攢瀹℃壒"
+ width="1000px"
+ append-to-body
+ destroy-on-close
+ @closed="approveOpinion = ''"
+ >
+ <DetailPanel :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="submitApprove('approved')">閫� 杩�</el-button>
+ <el-button type="danger" @click="submitApprove('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 FileUpload from "@/components/AttachmentUpload/file/index.vue";
+import ApprovalFlowEditor from "@/views/officeProcessAutomation/AttendManage/overtime-apply/components/ApprovalFlowEditor.vue";
+import ApprovalFlowProgress from "../travel-reimburse/components/ApprovalFlowProgress.vue";
+import DetailPanel from "./components/DetailPanel.vue";
+import { useCostReimburse } from "./useCostReimburse.js";
+
+const cr = useCostReimburse();
+const {
+ Search,
+ EXPENSE_CATEGORY_OPTIONS,
+ CATEGORY_TEMPLATES,
+ EXPENSE_SUBJECT_OPTIONS,
+ expenseSubjectLabel,
+ searchForm,
+ tableLoading,
+ page,
+ tableData,
+ tableColumn,
+ importInputRef,
+ formRef,
+ form,
+ formDialog,
+ formRules,
+ detailDialog,
+ detailRow,
+ approveDialog,
+ approveOpinion,
+ applicantFormSearchLoading,
+ applicantFormOptions,
+ flowUserOptions,
+ detailTotalAmount,
+ approvalRuleHint,
+ handleQuery,
+ resetSearch,
+ pagination,
+ remoteSearchApplicantForm,
+ userSelectLabel,
+ onApplicantChange,
+ onExpenseCategoryChange,
+ applyTemplate,
+ onDetailAmountChange,
+ onApprovalFlowChange,
+ addExpenseDetail,
+ removeExpenseDetail,
+ syncApplyAmountFromDetails,
+ autoAssignApprovalFlow,
+ openFormDialog,
+ onFormClosed,
+ submitForm,
+ approvalActionLabel,
+ submitApprove,
+ handleExport,
+ handleImportClick,
+ onImportFile,
+} = cr;
</script>
+
+<style scoped>
+.mb20 {
+ margin-bottom: 20px;
+}
+.mb16 {
+ margin-bottom: 16px;
+}
+.mb12 {
+ margin-bottom: 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;
+}
+.search_title {
+ font-size: 14px;
+ color: var(--el-text-color-regular);
+}
+.sr-only-input {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+.template-bar {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+}
+.template-label {
+ font-size: 14px;
+ color: var(--el-text-color-secondary);
+ flex-shrink: 0;
+}
+.form-section {
+ margin-bottom: 16px;
+ border: 1px solid var(--el-border-color-lighter);
+}
+.form-section :deep(.el-card__header) {
+ padding: 12px 16px;
+ background: var(--el-fill-color-lighter);
+}
+.form-section :deep(.el-card__body) {
+ padding: 16px 16px 4px;
+}
+.card-header-title {
+ font-size: 15px;
+ font-weight: 600;
+}
+.card-header-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+.amount-row {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ width: 100%;
+}
+.amount-input {
+ flex: 1;
+ min-width: 160px;
+}
+.attachment-form-item {
+ margin-bottom: 0;
+}
+.detail-table {
+ margin-bottom: 0;
+}
+.upload-block {
+ width: 100%;
+}
+.flow-tip {
+ font-size: 12px;
+ color: var(--el-text-color-secondary);
+ margin-top: 8px;
+}
+.cost-reimburse-form-dialog :deep(.el-dialog__body) {
+ padding-top: 12px;
+}
+.cost-reimburse-form :deep(.el-form-item) {
+ margin-bottom: 18px;
+}
+.cost-reimburse-form :deep(.el-input-number) {
+ width: 100%;
+}
+.cost-reimburse-form :deep(.el-row) {
+ margin-bottom: 0;
+}
+</style>
diff --git a/src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/useCostReimburse.js b/src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/useCostReimburse.js
new file mode 100644
index 0000000..79ffe6b
--- /dev/null
+++ b/src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/useCostReimburse.js
@@ -0,0 +1,662 @@
+import { Search } from "@element-plus/icons-vue";
+import dayjs from "dayjs";
+import { userListNoPageByTenantId } from "@/api/system/user.js";
+import { computed, getCurrentInstance, nextTick, onMounted, reactive, ref, watch } from "vue";
+import {
+ EXPENSE_CATEGORY_OPTIONS,
+ CATEGORY_TEMPLATES,
+ EXPENSE_SUBJECT_OPTIONS,
+ expenseCategoryLabel,
+ expenseSubjectLabel,
+ statusLabel,
+ statusTagType,
+ formatApprovalFlowSummary,
+ buildAutoApprovalFlow,
+ getApprovalRuleHint,
+ createEmptyExpenseDetail,
+ createEmptyForm,
+ applyCategoryTemplate,
+ initApprovalFlowNodes,
+ advanceApprovalFlow,
+ rejectApprovalFlow,
+ normalizeImportedRow,
+} from "./costReimburseUtils.js";
+
+function unwrapArray(payload) {
+ if (Array.isArray(payload)) return payload;
+ if (payload?.data && Array.isArray(payload.data)) return payload.data;
+ if (payload?.rows && Array.isArray(payload.rows)) return payload.rows;
+ return [];
+}
+
+function isActiveUser(u) {
+ if (u.delFlag === "2" || u.delFlag === 2) return false;
+ if (u.status == null) return true;
+ return String(u.status) === "0";
+}
+
+function demoFlowNodes(amount = 1200, category = "transport") {
+ return buildAutoApprovalFlow(amount, category);
+}
+
+export function useCostReimburse() {
+ const { proxy } = getCurrentInstance();
+
+ const allRows = ref([
+ {
+ id: "1",
+ reimburseNo: "CR202605100001",
+ applicantId: "mock_1",
+ employeeNo: "zhangsan",
+ employeeName: "寮犱笁",
+ applicantNo: "zhangsan",
+ applicantName: "寮犱笁",
+ expenseCategory: "office_procurement",
+ reimburseReason: "閲囪喘鎵撳嵃鏈虹榧撱�丄4绾哥瓑鍔炲叕鑰楁潗銆�",
+ applyAmount: 680,
+ payee: "寮犱笁",
+ payeeAccount: "6222 **** **** 1234",
+ bankBranch: "涓浗宸ュ晢閾惰鏉窞瑗挎箹鏀",
+ expenseDetails: [
+ { id: "d1", invoiceDate: "2026-05-08", expenseSubject: "office_supply", amount: 380, description: "A4澶嶅嵃绾�" },
+ { id: "d2", invoiceDate: "2026-05-08", expenseSubject: "office_supply", amount: 300, description: "纭掗紦" },
+ ],
+ attachmentList: [{ name: "閲囪喘鍙戠エ.pdf", url: "/mock/invoice1.pdf" }],
+ approvalFlowNodes: demoFlowNodes(680, "office_procurement"),
+ currentNodeIndex: 0,
+ approvalResult: "pending",
+ rejectReason: "",
+ approvalRecords: [],
+ applyTime: "2026-05-10 09:15:00",
+ createTime: "2026-05-10 09:15:00",
+ deptId: "101",
+ deptName: "琛屾斂閮�",
+ },
+ {
+ id: "2",
+ reimburseNo: "CR202605080002",
+ applicantId: "mock_2",
+ employeeNo: "lisi",
+ employeeName: "鏉庡洓",
+ applicantNo: "lisi",
+ applicantName: "鏉庡洓",
+ expenseCategory: "business_entertainment",
+ reimburseReason: "鎺ュ緟閲嶇偣瀹㈡埛鍟嗗姟瀹磋銆�",
+ applyAmount: 3200,
+ payee: "鏉庡洓",
+ payeeAccount: "6217 **** **** 5678",
+ bankBranch: "鎷涘晢閾惰姝︽眽鍏夎胺鏀",
+ expenseDetails: [
+ { id: "d3", invoiceDate: "2026-05-06", expenseSubject: "entertainment", amount: 3200, description: "瀹㈡埛瀹磋" },
+ ],
+ attachmentList: [],
+ approvalFlowNodes: demoFlowNodes(3200, "business_entertainment").map((n, i) => ({
+ ...n,
+ nodeStatus: i === 0 ? "error" : "wait",
+ approveOpinion: i === 0 ? "鍙戠エ妯$硦闇�閲嶄紶" : "",
+ approveTime: i === 0 ? "2026-05-09 14:20:00" : "",
+ })),
+ currentNodeIndex: 0,
+ approvalResult: "rejected",
+ rejectReason: "鍙戠エ妯$硦闇�閲嶄紶",
+ approvalRecords: [
+ { operatorName: "鐩村睘涓婄骇", result: "rejected", opinion: "鍙戠エ妯$硦闇�閲嶄紶", time: "2026-05-09 14:20:00" },
+ ],
+ applyTime: "2026-05-07 16:30:00",
+ createTime: "2026-05-07 16:30:00",
+ deptId: "102",
+ deptName: "閿�鍞儴",
+ },
+ {
+ id: "3",
+ reimburseNo: "CR202605050003",
+ applicantId: "mock_3",
+ employeeNo: "wangwu",
+ employeeName: "鐜嬩簲",
+ applicantNo: "wangwu",
+ applicantName: "鐜嬩簲",
+ expenseCategory: "communication",
+ reimburseReason: "5鏈堝洜鍏瘽璐规姤閿�銆�",
+ applyAmount: 198,
+ payee: "鐜嬩簲",
+ payeeAccount: "6228 **** **** 9012",
+ bankBranch: "涓浗寤鸿閾惰鎴愰兘楂樻柊鏀",
+ expenseDetails: [
+ { id: "d4", invoiceDate: "2026-05-05", expenseSubject: "phone", amount: 198, description: "璇濊垂璐﹀崟" },
+ ],
+ attachmentList: [{ name: "璇濊垂璐﹀崟.jpg", url: "/mock/phone.jpg" }],
+ approvalFlowNodes: demoFlowNodes(198, "communication").map((n) => ({
+ ...n,
+ nodeStatus: "finish",
+ approveOpinion: "鍚屾剰",
+ approveTime: "2026-05-06 10:00:00",
+ })),
+ currentNodeIndex: 0,
+ approvalResult: "approved",
+ rejectReason: "",
+ approvalRecords: [{ operatorName: "鐩村睘涓婄骇", result: "approved", opinion: "鍚屾剰", time: "2026-05-06 10:00:00" }],
+ applyTime: "2026-05-05 11:00:00",
+ createTime: "2026-05-05 11:00:00",
+ deptId: "103",
+ deptName: "鎶�鏈儴",
+ },
+ ]);
+
+ const searchForm = reactive({
+ applicantKeyword: "",
+ applyTimeFrom: "",
+ applyTimeTo: "",
+ });
+ const tableLoading = ref(false);
+ const page = reactive({ current: 1, size: 10, total: 0 });
+ const importInputRef = ref(null);
+ const allUsersCache = ref([]);
+ const applicantFormSearchLoading = ref(false);
+ const applicantFormOptions = ref([]);
+ const formRef = ref();
+ const form = reactive(createEmptyForm());
+ const formDialog = reactive({ visible: false, title: "", mode: "add", readonly: false });
+ const detailDialog = reactive({ visible: false });
+ const detailRow = ref({});
+ const approveDialog = reactive({ visible: false, row: null });
+ const approveOpinion = ref("");
+
+ const filteredList = computed(() => {
+ let list = [...allRows.value];
+ const kw = (searchForm.applicantKeyword || "").trim().toLowerCase();
+ if (kw) {
+ list = list.filter((r) => {
+ const name = (r.applicantName || r.employeeName || "").toLowerCase();
+ const no = (r.applicantNo || r.employeeNo || "").toLowerCase();
+ return name.includes(kw) || no.includes(kw);
+ });
+ }
+ if (searchForm.applyTimeFrom) {
+ list = list.filter((r) => {
+ const t = (r.applyTime || r.createTime || "").slice(0, 10);
+ return !t || t >= searchForm.applyTimeFrom;
+ });
+ }
+ if (searchForm.applyTimeTo) {
+ list = list.filter((r) => {
+ const t = (r.applyTime || r.createTime || "").slice(0, 10);
+ return !t || t <= searchForm.applyTimeTo;
+ });
+ }
+ 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).map((r) => ({
+ ...r,
+ approvalFlowSummary: formatApprovalFlowSummary(r),
+ }));
+ });
+
+ const flowUserOptions = computed(() => allUsersCache.value.filter(isActiveUser));
+
+ const detailTotalAmount = computed(() => {
+ const sum = (form.expenseDetails || []).reduce((s, d) => s + (Number(d.amount) || 0), 0);
+ return Math.round(sum * 100) / 100;
+ });
+
+ const approvalRuleHint = computed(() =>
+ getApprovalRuleHint(form.applyAmount ?? detailTotalAmount.value, form.expenseCategory)
+ );
+
+ const tableColumn = ref([
+ { label: "鎶ラ攢鍗曞彿", prop: "reimburseNo", width: 150 },
+ { label: "鐢宠浜虹紪鍙�", prop: "applicantNo", width: 110 },
+ { label: "鐢宠浜�", prop: "applicantName", minWidth: 90 },
+ { label: "鎶ラ攢閲戦(鍏�)", prop: "applyAmount", width: 110 },
+ { label: "鎶ラ攢鍘熷洜", prop: "reimburseReason", minWidth: 160, showOverflowTooltip: true },
+ { label: "鐢宠鏃堕棿", prop: "applyTime", width: 165 },
+ { label: "鍒涘缓鏃堕棿", prop: "createTime", width: 165 },
+ {
+ label: "鎶ラ攢鐘舵��",
+ prop: "approvalResult",
+ width: 100,
+ dataType: "tag",
+ formatData: (v) => statusLabel(v),
+ formatType: (v) => statusTagType(v),
+ },
+ {
+ label: "瀹℃壒娴佺▼",
+ prop: "approvalFlowSummary",
+ minWidth: 200,
+ showOverflowTooltip: true,
+ },
+ {
+ dataType: "action",
+ label: "鎿嶄綔",
+ align: "center",
+ fixed: "right",
+ width: 220,
+ operation: [
+ {
+ name: "缂栬緫",
+ type: "text",
+ disabled: (row) => row.approvalResult === "pending" || row.approvalResult === "approved",
+ clickFun: (row) => openFormDialog("edit", row),
+ },
+ { name: "璇︽儏", type: "text", clickFun: (row) => openDetail(row) },
+ {
+ name: "瀹℃壒",
+ type: "text",
+ disabled: (row) => row.approvalResult !== "pending",
+ clickFun: (row) => openApprove(row),
+ },
+ ],
+ },
+ ]);
+
+ const formRules = {
+ applicantId: [{ required: true, message: "璇烽�夋嫨鍛樺伐", trigger: "change" }],
+ expenseCategory: [{ required: true, message: "璇烽�夋嫨璐圭敤绫诲瀷", trigger: "change" }],
+ reimburseReason: [{ required: true, message: "璇峰~鍐欐姤閿�鍘熷洜", trigger: "blur" }],
+ applyAmount: [{ required: true, message: "璇峰~鍐欐姤閿�閲戦", trigger: "blur" }],
+ payee: [{ required: true, message: "璇峰~鍐欐敹娆句汉", trigger: "blur" }],
+ payeeAccount: [{ required: true, message: "璇峰~鍐欐敹娆捐处鍙�", trigger: "blur" }],
+ bankBranch: [{ required: true, message: "璇峰~鍐欏紑鎴锋敮琛�", trigger: "blur" }],
+ approvalFlowNodes: [
+ {
+ validator: (_r, _v, cb) => {
+ const nodes = form.approvalFlowNodes || [];
+ if (!nodes.length) {
+ cb(new Error("璇疯嚦灏戦厤缃竴涓鎵硅妭鐐�"));
+ return;
+ }
+ if (nodes.some((n) => n.approverId == null || n.approverId === "")) {
+ cb(new Error("姣忎釜鑺傜偣椤婚�夋嫨瀹℃壒浜�"));
+ return;
+ }
+ cb();
+ },
+ trigger: "change",
+ },
+ ],
+ };
+
+ async function loadUserPool() {
+ try {
+ allUsersCache.value = unwrapArray(await userListNoPageByTenantId());
+ } catch {
+ allUsersCache.value = [];
+ }
+ }
+
+ function userSelectLabel(u) {
+ const nick = u.nickName || "";
+ const name = u.userName || "";
+ if (nick && name && nick !== name) return `${nick}锛�${name}锛塦;
+ return nick || name || `鐢ㄦ埛${u.userId ?? u.id ?? ""}`;
+ }
+
+ function userById(id) {
+ return allUsersCache.value.find((u) => String(u.userId ?? u.id) === String(id));
+ }
+
+ function employeeNoFromUser(u) {
+ if (!u) return "";
+ return u.userName ?? u.userCode ?? u.jobNumber ?? u.workNo ?? (u.userId != null ? String(u.userId) : "");
+ }
+
+ function filterUsersByQuery(query) {
+ const list = allUsersCache.value.filter(isActiveUser);
+ const q = (query || "").trim().toLowerCase();
+ if (!q) return [...list];
+ return list.filter((u) => {
+ const nick = (u.nickName || "").toLowerCase();
+ const uname = (u.userName || "").toLowerCase();
+ return nick.includes(q) || uname.includes(q);
+ });
+ }
+
+ async function remoteSearchApplicantForm(query) {
+ applicantFormSearchLoading.value = true;
+ try {
+ if (!allUsersCache.value.length) await loadUserPool();
+ applicantFormOptions.value = filterUsersByQuery(query);
+ } finally {
+ applicantFormSearchLoading.value = false;
+ }
+ }
+
+ function onApplicantChange(uid) {
+ const u = userById(uid);
+ if (u) {
+ form.employeeName = u.nickName || u.userName || "";
+ form.employeeNo = employeeNoFromUser(u);
+ form.payee = form.payee || form.employeeName;
+ form.deptId = String(u.deptId ?? u.sysDeptId ?? "");
+ form.deptName = u.dept?.deptName ?? u.deptName ?? "";
+ } else {
+ form.employeeName = "";
+ form.employeeNo = "";
+ }
+ }
+
+ function autoAssignApprovalFlow() {
+ const amount = Number(form.applyAmount) || detailTotalAmount.value || 0;
+ form.approvalFlowNodes = buildAutoApprovalFlow(amount, form.expenseCategory || "other");
+ nextTick(() => formRef.value?.validateField?.("approvalFlowNodes"));
+ }
+
+ function onExpenseCategoryChange(val) {
+ if (val && !(form.expenseDetails || []).length) {
+ applyCategoryTemplate(form, val);
+ syncApplyAmountFromDetails();
+ }
+ autoAssignApprovalFlow();
+ }
+
+ function applyTemplate(category) {
+ applyCategoryTemplate(form, category);
+ syncApplyAmountFromDetails();
+ autoAssignApprovalFlow();
+ proxy?.$modal?.msgSuccess?.(`宸插簲鐢ㄣ��${CATEGORY_TEMPLATES[category]?.label || category}銆嶅~鎶ユā鏉縛);
+ }
+
+ function onDetailAmountChange() {
+ syncApplyAmountFromDetails();
+ autoAssignApprovalFlow();
+ }
+
+ function onApprovalFlowChange() {
+ nextTick(() => formRef.value?.validateField?.("approvalFlowNodes"));
+ }
+
+ function addExpenseDetail() {
+ form.expenseDetails.push(createEmptyExpenseDetail());
+ }
+
+ function removeExpenseDetail(index) {
+ form.expenseDetails.splice(index, 1);
+ syncApplyAmountFromDetails();
+ autoAssignApprovalFlow();
+ }
+
+ function syncApplyAmountFromDetails() {
+ form.applyAmount = detailTotalAmount.value;
+ }
+
+ function handleQuery() {
+ page.current = 1;
+ tableLoading.value = true;
+ setTimeout(() => {
+ tableLoading.value = false;
+ }, 150);
+ }
+
+ function resetSearch() {
+ searchForm.applicantKeyword = "";
+ searchForm.applyTimeFrom = "";
+ searchForm.applyTimeTo = "";
+ handleQuery();
+ }
+
+ function pagination(obj) {
+ page.current = obj.page;
+ page.size = obj.limit;
+ }
+
+ function openDetail(row) {
+ detailRow.value = { ...row };
+ detailDialog.visible = true;
+ }
+
+ function openApprove(row) {
+ approveDialog.row = { ...row };
+ approveDialog.visible = true;
+ }
+
+ function approvalActionLabel(v) {
+ if (v === "approved") return "閫氳繃";
+ if (v === "rejected") return "椹冲洖";
+ return "鎻愪氦";
+ }
+
+ async function openFormDialog(mode, row) {
+ formDialog.mode = mode;
+ formDialog.readonly = false;
+ formDialog.title = mode === "add" ? "鏂板璐圭敤鎶ラ攢" : "缂栬緫璐圭敤鎶ラ攢";
+ if (!allUsersCache.value.length) await loadUserPool();
+ Object.assign(form, createEmptyForm());
+ if (mode === "edit" && row) {
+ Object.assign(form, {
+ ...JSON.parse(JSON.stringify(row)),
+ attachmentList: JSON.parse(JSON.stringify(row.attachmentList || row.invoiceAttachments || [])),
+ approvalFlowNodes: JSON.parse(JSON.stringify(row.approvalFlowNodes || [])),
+ expenseDetails: JSON.parse(JSON.stringify(row.expenseDetails || [])),
+ });
+ const u = userById(row.applicantId);
+ applicantFormOptions.value = u
+ ? [u]
+ : [{ userId: row.applicantId, nickName: row.employeeName, userName: row.employeeNo }];
+ } else {
+ form.approvalFlowNodes = buildAutoApprovalFlow(0, "other");
+ remoteSearchApplicantForm("");
+ }
+ formDialog.visible = true;
+ nextTick(() => {
+ formRef.value?.clearValidate?.();
+ });
+ }
+
+ function onFormClosed() {
+ formRef.value?.resetFields?.();
+ }
+
+ async function submitForm() {
+ try {
+ await formRef.value?.validate?.();
+ } catch {
+ return;
+ }
+ if (!(form.expenseDetails || []).length) {
+ proxy?.$modal?.msgWarning?.("璇疯嚦灏戞坊鍔犱竴鏉℃姤閿�鏄庣粏");
+ return;
+ }
+ syncApplyAmountFromDetails();
+ autoAssignApprovalFlow();
+
+ const payload = {
+ reimburseNo: form.reimburseNo || `CR${dayjs().format("YYYYMMDDHHmmss")}`,
+ applicantId: form.applicantId,
+ employeeNo: form.employeeNo,
+ employeeName: form.employeeName,
+ applicantNo: form.employeeNo,
+ applicantName: form.employeeName,
+ expenseCategory: form.expenseCategory,
+ reimburseReason: form.reimburseReason,
+ applyAmount: form.applyAmount,
+ payee: form.payee,
+ payeeAccount: form.payeeAccount,
+ bankBranch: form.bankBranch,
+ expenseDetails: JSON.parse(JSON.stringify(form.expenseDetails)),
+ attachmentList: JSON.parse(JSON.stringify(form.attachmentList || [])),
+ invoiceAttachments: (form.attachmentList || []).map((f, i) => ({
+ id: f.id ?? f.uid ?? `inv_${Date.now()}_${i}`,
+ name: f.name || f.fileName || "鏈懡鍚�",
+ url: f.url || f.downloadURL || "",
+ })),
+ approvalFlowNodes: initApprovalFlowNodes(form.approvalFlowNodes),
+ currentNodeIndex: 0,
+ deptId: form.deptId,
+ deptName: form.deptName,
+ };
+
+ if (formDialog.mode === "add") {
+ const now = dayjs().format("YYYY-MM-DD HH:mm:ss");
+ allRows.value.unshift({
+ id: `local_${Date.now()}`,
+ ...payload,
+ approvalResult: "pending",
+ rejectReason: "",
+ approvalRecords: [],
+ applyTime: now,
+ createTime: now,
+ });
+ proxy?.$modal?.msgSuccess?.("鎻愪氦鎴愬姛锛屽凡杩涘叆瀹℃壒锛堟湰鍦版ā鎷燂級");
+ } else {
+ const idx = allRows.value.findIndex((r) => r.id === form.id);
+ if (idx !== -1) {
+ const prev = allRows.value[idx];
+ allRows.value[idx] = {
+ ...prev,
+ ...payload,
+ id: form.id,
+ approvalResult: prev.approvalResult === "rejected" ? "pending" : prev.approvalResult,
+ approvalFlowNodes: initApprovalFlowNodes(form.approvalFlowNodes),
+ currentNodeIndex: 0,
+ rejectReason: prev.approvalResult === "rejected" ? "" : prev.rejectReason,
+ applyTime: prev.applyTime,
+ createTime: prev.createTime,
+ };
+ }
+ proxy?.$modal?.msgSuccess?.("淇濆瓨鎴愬姛锛堟湰鍦版ā鎷燂級");
+ }
+ formDialog.visible = false;
+ handleQuery();
+ }
+
+ async function submitApprove(result) {
+ const row = approveDialog.row;
+ if (!row) return;
+ if (result === "rejected" && !(approveOpinion.value || "").trim()) {
+ proxy?.$modal?.msgWarning?.("椹冲洖椤诲~鍐欏鎵规剰瑙侊紙濡傦細鍙戠エ妯$硦闇�閲嶄紶锛�");
+ return;
+ }
+ const idx = allRows.value.findIndex((r) => r.id === row.id);
+ if (idx === -1) return;
+ const cur = allRows.value[idx];
+ const operatorName = "褰撳墠瀹℃壒浜�";
+ const record = {
+ operatorName,
+ result,
+ opinion: approveOpinion.value || (result === "approved" ? "鍚屾剰" : "椹冲洖"),
+ time: dayjs().format("YYYY-MM-DD HH:mm:ss"),
+ };
+ const records = [...(cur.approvalRecords || []), record];
+ let flowUpdate;
+ if (result === "approved") {
+ flowUpdate = advanceApprovalFlow(cur, approveOpinion.value);
+ } else {
+ flowUpdate = rejectApprovalFlow(cur, approveOpinion.value);
+ }
+ allRows.value[idx] = {
+ ...cur,
+ approvalFlowNodes: flowUpdate.nodes,
+ currentNodeIndex: flowUpdate.currentNodeIndex,
+ approvalResult: flowUpdate.approvalResult,
+ rejectReason: flowUpdate.rejectReason ?? cur.rejectReason,
+ approvalRecords: records,
+ };
+ proxy?.$modal?.msgSuccess?.(result === "approved" ? "宸查�氳繃" : "宸查┏鍥�");
+ approveDialog.visible = false;
+ handleQuery();
+ }
+
+ function handleExport() {
+ const data = filteredList.value;
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json;charset=utf-8" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `璐圭敤鎶ラ攢瀵煎嚭_${dayjs().format("YYYYMMDDHHmmss")}.json`;
+ a.click();
+ URL.revokeObjectURL(url);
+ proxy?.$modal?.msgSuccess?.(`宸插鍑� ${data.length} 鏉);
+ }
+
+ function handleImportClick() {
+ importInputRef.value?.click?.();
+ }
+
+ function onImportFile(e) {
+ const file = e.target.files?.[0];
+ e.target.value = "";
+ if (!file) return;
+ const reader = new FileReader();
+ reader.onload = () => {
+ try {
+ const parsed = JSON.parse(String(reader.result || ""));
+ const arr = Array.isArray(parsed) ? parsed : parsed?.rows || parsed?.data;
+ if (!Array.isArray(arr) || !arr.length) {
+ proxy?.$modal?.msgWarning?.("瀵煎叆鏍煎紡椤讳负璐圭敤鎶ラ攢 JSON 鏁扮粍");
+ return;
+ }
+ arr.forEach((raw, i) => allRows.value.unshift(normalizeImportedRow(raw, i)));
+ proxy?.$modal?.msgSuccess?.(`鎴愬姛瀵煎叆 ${arr.length} 鏉);
+ handleQuery();
+ } catch {
+ proxy?.$modal?.msgError?.("瑙f瀽澶辫触");
+ }
+ };
+ reader.readAsText(file, "utf-8");
+ }
+
+ onMounted(() => loadUserPool());
+
+ return {
+ Search,
+ EXPENSE_CATEGORY_OPTIONS,
+ CATEGORY_TEMPLATES,
+ EXPENSE_SUBJECT_OPTIONS,
+ expenseCategoryLabel,
+ expenseSubjectLabel,
+ searchForm,
+ tableLoading,
+ page,
+ tableData,
+ tableColumn,
+ importInputRef,
+ formRef,
+ form,
+ formDialog,
+ formRules,
+ detailDialog,
+ detailRow,
+ approveDialog,
+ approveOpinion,
+ applicantFormSearchLoading,
+ applicantFormOptions,
+ flowUserOptions,
+ detailTotalAmount,
+ approvalRuleHint,
+ handleQuery,
+ resetSearch,
+ pagination,
+ remoteSearchApplicantForm,
+ userSelectLabel,
+ onApplicantChange,
+ onExpenseCategoryChange,
+ applyTemplate,
+ onDetailAmountChange,
+ onApprovalFlowChange,
+ addExpenseDetail,
+ removeExpenseDetail,
+ syncApplyAmountFromDetails,
+ autoAssignApprovalFlow,
+ openFormDialog,
+ onFormClosed,
+ submitForm,
+ openDetail,
+ approvalActionLabel,
+ submitApprove,
+ handleExport,
+ handleImportClick,
+ onImportFile,
+ };
+}
--
Gitblit v1.9.3