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