From 4dbf9836e8338765af978d09b18d3c59de9015a3 Mon Sep 17 00:00:00 2001
From: yyb <995253665@qq.com>
Date: 星期六, 16 五月 2026 14:24:25 +0800
Subject: [PATCH] feat(travel-reimburse): 优化差旅报销模块功能和界面

---
 src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/useTravelReimburse.js      |  689 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/components/DetailPanel.vue |   81 ++++++
 2 files changed, 770 insertions(+), 0 deletions(-)

diff --git a/src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/components/DetailPanel.vue b/src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/components/DetailPanel.vue
new file mode 100644
index 0000000..d09e580
--- /dev/null
+++ b/src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/components/DetailPanel.vue
@@ -0,0 +1,81 @@
+<!-- 宸梾鎶ラ攢锛氳鎯呭彧璇婚潰鏉� -->
+<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="鍛樺伐缂栧彿">{{ 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.travelStartTime || "鈥�" }}</el-descriptions-item>
+    <el-descriptions-item label="鍑哄樊缁撴潫">{{ row.travelEndTime || "鈥�" }}</el-descriptions-item>
+    <el-descriptions-item label="鍑哄樊鍦�">{{ row.departurePlace || "鈥�" }}</el-descriptions-item>
+    <el-descriptions-item label="鐩殑鍦�">{{ row.destination || "鈥�" }}</el-descriptions-item>
+    <el-descriptions-item label="閰掑簵鏍囧噯">{{ row.hotelStandard != null ? `${row.hotelStandard} 鍏�/鏅歚 : "鈥�" }}</el-descriptions-item>
+    <el-descriptions-item label="浣忓澶╂暟">{{ row.hotelDays ?? "鈥�" }}</el-descriptions-item>
+    <el-descriptions-item label="鐢熸椿琛ヨ创">{{ row.livingSubsidy != null ? `${row.livingSubsidy} 鍏僠 : "鈥�" }}</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="鐗规壒">
+      <el-tag :type="row.needSpecialApproval ? 'danger' : 'info'" size="small">
+        {{ row.needSpecialApproval ? "瓒呮敮闇�鐗规壒" : "鏍囧噯鍐�" }}
+      </el-tag>
+    </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 { expenseSubjectLabel, statusLabel, statusTagType } from "../travelReimburseUtils.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/travel-reimburse/useTravelReimburse.js b/src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/useTravelReimburse.js
new file mode 100644
index 0000000..9125d64
--- /dev/null
+++ b/src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/useTravelReimburse.js
@@ -0,0 +1,689 @@
+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_SUBJECT_OPTIONS,
+  expenseSubjectLabel,
+  statusLabel,
+  statusTagType,
+  detectTravelTier,
+  getTravelStandardByTier,
+  computeTravelDays,
+  createEmptyExpenseDetail,
+  createEmptyForm,
+  initApprovalFlowNodes,
+  advanceApprovalFlow,
+  rejectApprovalFlow,
+  mockDeptBudget,
+  normalizeImportedRow,
+} from "./travelReimburseUtils.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(names = ["閮ㄩ棬涓荤", "璐㈠姟瀹℃牳"]) {
+  return names.map((name, i) => ({
+    approverId: `mock_${i + 1}`,
+    approverName: name,
+    sortOrder: i + 1,
+    nodeOrder: i + 1,
+    nodeStatus: i === 0 ? "process" : "wait",
+    approveOpinion: "",
+    approveTime: "",
+  }));
+}
+
+export function useTravelReimburse() {
+  const { proxy } = getCurrentInstance();
+
+  const allRows = ref([
+    {
+      id: "1",
+      reimburseNo: "TR202605090001",
+      applicantId: "mock_1",
+      employeeNo: "zhangsan",
+      employeeName: "寮犱笁",
+      applicantNo: "zhangsan",
+      applicantName: "寮犱笁",
+      reimburseReason: "璧翠笂娴峰弬鍔犺涓氬睍浼氬強瀹㈡埛鎷滆銆�",
+      travelStartTime: "2026-05-10 08:00:00",
+      travelEndTime: "2026-05-13 18:00:00",
+      travelDays: 4,
+      departurePlace: "鏉窞",
+      destination: "涓婃捣",
+      hotelStandard: 600,
+      hotelDays: 3,
+      livingSubsidy: 400,
+      applyAmount: 4580,
+      payee: "寮犱笁",
+      expenseDetails: [
+        { id: "d1", invoiceDate: "2026-05-10", expenseSubject: "transport", amount: 553, description: "楂橀搧寰�杩�" },
+        { id: "d2", invoiceDate: "2026-05-11", expenseSubject: "hotel", amount: 1680, description: "閰掑簵浣忓" },
+      ],
+      attachmentList: [{ name: "楂橀搧绁�.pdf", url: "/mock/invoice1.pdf" }],
+      invoiceAttachments: [{ name: "楂橀搧绁�.pdf", url: "/mock/invoice1.pdf" }],
+      approvalFlowNodes: demoFlowNodes(),
+      currentNodeIndex: 0,
+      approvalResult: "pending",
+      rejectReason: "",
+      approvalRecords: [],
+      needSpecialApproval: false,
+      deptId: "101",
+      deptName: "閿�鍞儴",
+      travelTier: "tier1",
+      createTime: "2026-05-09 10:20:00",
+    },
+    {
+      id: "2",
+      reimburseNo: "TR202605080002",
+      applicantId: "mock_2",
+      employeeNo: "lisi",
+      employeeName: "鏉庡洓",
+      applicantNo: "lisi",
+      applicantName: "鏉庡洓",
+      reimburseReason: "鎴愰兘鍒嗗叕鍙告妧鏈敮鎸併��",
+      travelStartTime: "2026-05-05 09:00:00",
+      travelEndTime: "2026-05-07 17:00:00",
+      travelDays: 3,
+      departurePlace: "姝︽眽",
+      destination: "鎴愰兘",
+      hotelStandard: 450,
+      hotelDays: 2,
+      livingSubsidy: 240,
+      applyAmount: 2100,
+      payee: "鏉庡洓",
+      expenseDetails: [{ id: "d3", invoiceDate: "2026-05-06", expenseSubject: "meal", amount: 180, description: "宸ヤ綔椁�" }],
+      attachmentList: [],
+      invoiceAttachments: [],
+      approvalFlowNodes: demoFlowNodes().map((n, i) => ({ ...n, nodeStatus: "finish", approveOpinion: "鍚屾剰", approveTime: "2026-05-08 11:00:00" })),
+      currentNodeIndex: 1,
+      approvalResult: "approved",
+      rejectReason: "",
+      approvalRecords: [{ operatorName: "閮ㄩ棬涓荤", result: "approved", opinion: "鍚屾剰", time: "2026-05-08 10:00:00" }],
+      needSpecialApproval: false,
+      deptId: "102",
+      deptName: "鎶�鏈儴",
+      travelTier: "tier2",
+      createTime: "2026-05-07 16:00:00",
+    },
+  ]);
+
+  const searchForm = reactive({ applicantKeyword: "", travelStartFrom: "", travelEndTo: "" });
+  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.travelStartFrom) {
+      list = list.filter((r) => !r.travelStartTime || r.travelStartTime.slice(0, 10) >= searchForm.travelStartFrom);
+    }
+    if (searchForm.travelEndTo) {
+      list = list.filter((r) => !r.travelEndTime || r.travelEndTime.slice(0, 10) <= searchForm.travelEndTo);
+    }
+    return list.sort((a, b) => (String(a.createTime) < String(b.createTime) ? 1 : -1));
+  });
+
+  watch(
+    filteredList,
+    (list) => {
+      page.total = list.length;
+      const maxPage = Math.max(1, Math.ceil(list.length / page.size) || 1);
+      if (page.current > maxPage) page.current = maxPage;
+    },
+    { immediate: true }
+  );
+
+  const tableData = computed(() => {
+    const start = (page.current - 1) * page.size;
+    return filteredList.value.slice(start, start + page.size);
+  });
+
+  const flowUserOptions = computed(() => allUsersCache.value.filter(isActiveUser));
+
+  const travelDaysDisplay = computed(() => {
+    const d = computeTravelDays(form.travelStartTime, form.travelEndTime);
+    return d == null ? "" : String(d);
+  });
+
+  const travelTierLabel = computed(() => {
+    const std = getTravelStandardByTier(form.travelTier || detectTravelTier(form.destination));
+    return `鎸�${std.label}鏍囧噯`;
+  });
+
+  const suggestedLivingSubsidy = computed(() => {
+    const days = computeTravelDays(form.travelStartTime, form.travelEndTime) || form.hotelDays || 0;
+    const std = getTravelStandardByTier(form.travelTier);
+    return Math.round(std.mealPerDay * days * 100) / 100;
+  });
+
+  const suggestedTransportSubsidy = computed(() => {
+    const days = computeTravelDays(form.travelStartTime, form.travelEndTime) || 0;
+    const std = getTravelStandardByTier(form.travelTier);
+    return Math.round(std.transportPerDay * days * 100) / 100;
+  });
+
+  const suggestedHotelLimit = computed(() => {
+    const nights = form.hotelDays || 0;
+    const perNight = form.hotelStandard ?? getTravelStandardByTier(form.travelTier).hotelPerNight;
+    return Math.round(perNight * nights * 100) / 100;
+  });
+
+  const detailTotalAmount = computed(() => {
+    const sum = (form.expenseDetails || []).reduce((s, d) => s + (Number(d.amount) || 0), 0);
+    return Math.round(sum * 100) / 100;
+  });
+
+  const overBudgetWarnings = computed(() => buildOverBudgetWarnings(form, detailTotalAmount.value, suggestedHotelLimit.value, suggestedTransportSubsidy.value, suggestedLivingSubsidy.value));
+
+  const budgetHint = computed(() => {
+    if (!form.deptId) return { visible: false };
+    const b = mockDeptBudget(form.deptId);
+    const apply = Number(form.applyAmount) || detailTotalAmount.value || 0;
+    const after = b.remainingAmount - apply;
+    return {
+      visible: true,
+      type: after < 0 ? "error" : "info",
+      title: `閮ㄩ棬棰勭畻鑱斿姩锛�${form.deptName || b.deptId}锛塦,
+      description: `骞村害棰勭畻 ${b.totalBudget} 鍏冿紝宸茬敤 ${b.usedAmount} 鍏冿紝鍓╀綑 ${b.remainingAmount} 鍏冿紱鏈崟鐢宠鍚庨璁″墿浣� ${Math.round(after * 100) / 100} 鍏冦�俙,
+    };
+  });
+
+  const tableColumn = ref([
+    { label: "鎶ラ攢鍗曞彿", prop: "reimburseNo", width: 150 },
+    { label: "鐢宠浜虹紪鍙�", prop: "applicantNo", width: 110 },
+    { label: "鐢宠浜�", prop: "applicantName", minWidth: 90 },
+    { label: "鍑哄樊寮�濮�", prop: "travelStartTime", width: 165 },
+    { label: "鍑哄樊缁撴潫", prop: "travelEndTime", width: 165 },
+    { label: "鍒涘缓鏃堕棿", prop: "createTime", width: 165 },
+    {
+      label: "鐘舵��",
+      prop: "approvalResult",
+      width: 100,
+      dataType: "tag",
+      formatData: (v) => statusLabel(v),
+      formatType: (v) => statusTagType(v),
+    },
+    {
+      dataType: "action",
+      label: "鎿嶄綔",
+      align: "center",
+      fixed: "right",
+      width: 200,
+      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" }],
+    reimburseReason: [{ required: true, message: "璇峰~鍐欐姤閿�鍘熷洜", trigger: "blur" }],
+    travelStartTime: [{ required: true, message: "璇烽�夋嫨鍑哄樊寮�濮嬫椂闂�", trigger: "change" }],
+    travelEndTime: [
+      { required: true, message: "璇烽�夋嫨鍑哄樊缁撴潫鏃堕棿", trigger: "change" },
+      {
+        validator: (_r, val, cb) => {
+          if (!form.travelStartTime || !val) { cb(); return; }
+          if (computeTravelDays(form.travelStartTime, val) == null) cb(new Error("缁撴潫鏃堕棿椤绘櫄浜庡紑濮嬫椂闂�"));
+          else cb();
+        },
+        trigger: "change",
+      },
+    ],
+    departurePlace: [{ required: true, message: "璇峰~鍐欏嚭宸湴", trigger: "blur" }],
+    destination: [{ required: true, message: "璇峰~鍐欑洰鐨勫湴", trigger: "blur" }],
+    applyAmount: [{ required: true, message: "璇峰~鍐欑敵璇烽噾棰�", trigger: "blur" }],
+    payee: [{ 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",
+      },
+    ],
+  };
+
+  function buildOverBudgetWarnings(f, detailTotal, hotelLimit, transportLimit, mealLimit) {
+    const warnings = [];
+    const bySubject = { transport: 0, hotel: 0, meal: 0, other: 0 };
+    (f.expenseDetails || []).forEach((d) => {
+      const key = d.expenseSubject || "other";
+      bySubject[key] = (bySubject[key] || 0) + (Number(d.amount) || 0);
+    });
+    if (bySubject.transport > transportLimit && transportLimit > 0) {
+      warnings.push(`浜ら�氳垂 ${bySubject.transport} 鍏冭秴鍑烘爣鍑� ${transportLimit} 鍏僠);
+    }
+    if (bySubject.hotel > hotelLimit && hotelLimit > 0) {
+      warnings.push(`浣忓璐� ${bySubject.hotel} 鍏冭秴鍑洪檺棰� ${hotelLimit} 鍏僠);
+    }
+    if (bySubject.meal > mealLimit && mealLimit > 0) {
+      warnings.push(`椁愰ギ璐� ${bySubject.meal} 鍏冭秴鍑虹敓娲昏ˉ璐村缓璁� ${mealLimit} 鍏僠);
+    }
+    const std = getTravelStandardByTier(f.travelTier);
+    if (f.hotelStandard > std.hotelPerNight) {
+      warnings.push(`閰掑簵鏍囧噯 ${f.hotelStandard} 鍏�/鏅氶珮浜�${std.label}鏍囧噯 ${std.hotelPerNight} 鍏�/鏅歚);
+    }
+    const apply = Number(f.applyAmount) || detailTotal;
+    const standardTotal = transportLimit + hotelLimit + mealLimit;
+    if (apply > standardTotal && standardTotal > 0) {
+      warnings.push(`鐢宠鎬婚 ${apply} 鍏冮珮浜庡樊鏃呮爣鍑嗗悎璁$害 ${standardTotal} 鍏僠);
+    }
+    return warnings;
+  }
+
+  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 recalcTravelStandards() {
+    form.travelTier = detectTravelTier(form.destination);
+    const std = getTravelStandardByTier(form.travelTier);
+    if (form.hotelStandard == null || form.hotelStandard === 0) form.hotelStandard = std.hotelPerNight;
+    const days = computeTravelDays(form.travelStartTime, form.travelEndTime);
+    if (days != null) {
+      form.travelDays = days;
+      if (form.hotelDays == null) form.hotelDays = Math.max(0, days - 1);
+      if (form.livingSubsidy == null || form.livingSubsidy === 0) form.livingSubsidy = suggestedLivingSubsidy.value;
+    }
+    form.needSpecialApproval = buildOverBudgetWarnings(form, detailTotalAmount.value, suggestedHotelLimit.value, suggestedTransportSubsidy.value, suggestedLivingSubsidy.value).length > 0;
+  }
+
+  function onTravelRangeChange() {
+    recalcTravelStandards();
+    nextTick(() => formRef.value?.validateField?.("travelEndTime"));
+  }
+
+  function onDetailAmountChange() {
+    recalcTravelStandards();
+  }
+
+  function onApprovalFlowChange() {
+    nextTick(() => formRef.value?.validateField?.("approvalFlowNodes"));
+  }
+
+  function addExpenseDetail() {
+    form.expenseDetails.push(createEmptyExpenseDetail());
+  }
+
+  function removeExpenseDetail(index) {
+    form.expenseDetails.splice(index, 1);
+    recalcTravelStandards();
+  }
+
+  function mapAttachmentList(list) {
+    return (list || []).map((f, i) => ({
+      id: f.id ?? f.uid ?? `inv_${Date.now()}_${i}`,
+      name: f.name || f.fileName || f.originalFilename || "鏈懡鍚�",
+      url: f.url || f.downloadURL || f.previewURL || f.previewUrl || "",
+    }));
+  }
+
+  function syncApplyAmountFromDetails() {
+    form.applyAmount = detailTotalAmount.value;
+    recalcTravelStandards();
+  }
+
+  function handleQuery() {
+    page.current = 1;
+    tableLoading.value = true;
+    setTimeout(() => { tableLoading.value = false; }, 150);
+  }
+
+  function resetSearch() {
+    searchForm.applicantKeyword = "";
+    searchForm.travelStartFrom = "";
+    searchForm.travelEndTo = "";
+    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 = [{ approverId: null, approverName: "", sortOrder: 1, nodeOrder: 1 }];
+      remoteSearchApplicantForm("");
+    }
+    formDialog.visible = true;
+    nextTick(() => {
+      formRef.value?.clearValidate?.();
+      recalcTravelStandards();
+    });
+  }
+
+  function onFormClosed() {
+    formRef.value?.resetFields?.();
+  }
+
+  async function submitForm() {
+    try {
+      await formRef.value?.validate?.();
+    } catch {
+      return;
+    }
+    if (!(form.expenseDetails || []).length) {
+      proxy?.$modal?.msgWarning?.("璇疯嚦灏戞坊鍔犱竴鏉℃姤閿�鏄庣粏");
+      return;
+    }
+    recalcTravelStandards();
+    if (form.needSpecialApproval) {
+      try {
+        await proxy.$modal.confirm("瀛樺湪瓒呮敮椤癸紝鎻愪氦鍚庡皢鏍囪涓洪渶鐗规壒锛屾槸鍚︾户缁紵");
+      } catch {
+        return;
+      }
+    }
+    const days = computeTravelDays(form.travelStartTime, form.travelEndTime);
+    const payload = {
+      reimburseNo: form.reimburseNo || `TR${dayjs().format("YYYYMMDDHHmmss")}`,
+      applicantId: form.applicantId,
+      employeeNo: form.employeeNo,
+      employeeName: form.employeeName,
+      applicantNo: form.employeeNo,
+      applicantName: form.employeeName,
+      reimburseReason: form.reimburseReason,
+      travelStartTime: form.travelStartTime,
+      travelEndTime: form.travelEndTime,
+      travelDays: days,
+      departurePlace: form.departurePlace,
+      destination: form.destination,
+      hotelStandard: form.hotelStandard,
+      hotelDays: form.hotelDays,
+      livingSubsidy: form.livingSubsidy,
+      applyAmount: form.applyAmount,
+      payee: form.payee,
+      expenseDetails: JSON.parse(JSON.stringify(form.expenseDetails)),
+      attachmentList: JSON.parse(JSON.stringify(form.attachmentList || [])),
+      invoiceAttachments: mapAttachmentList(form.attachmentList),
+      approvalFlowNodes: initApprovalFlowNodes(form.approvalFlowNodes),
+      currentNodeIndex: 0,
+      needSpecialApproval: form.needSpecialApproval,
+      deptId: form.deptId,
+      deptName: form.deptName,
+      travelTier: form.travelTier,
+    };
+    if (formDialog.mode === "add") {
+      allRows.value.unshift({
+        id: `local_${Date.now()}`,
+        ...payload,
+        approvalResult: "pending",
+        rejectReason: "",
+        approvalRecords: [],
+        createTime: dayjs().format("YYYY-MM-DD HH:mm:ss"),
+      });
+      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,
+          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_SUBJECT_OPTIONS,
+    expenseSubjectLabel,
+    searchForm,
+    tableLoading,
+    page,
+    tableData,
+    tableColumn,
+    importInputRef,
+    formRef,
+    form,
+    formDialog,
+    formRules,
+    detailDialog,
+    detailRow,
+    approveDialog,
+    approveOpinion,
+    applicantFormSearchLoading,
+    applicantFormOptions,
+    flowUserOptions,
+    travelDaysDisplay,
+    travelTierLabel,
+    suggestedLivingSubsidy,
+    suggestedTransportSubsidy,
+    suggestedHotelLimit,
+    detailTotalAmount,
+    overBudgetWarnings,
+    budgetHint,
+    handleQuery,
+    resetSearch,
+    pagination,
+    remoteSearchApplicantForm,
+    userSelectLabel,
+    onApplicantChange,
+    recalcTravelStandards,
+    onTravelRangeChange,
+    onDetailAmountChange,
+    onApprovalFlowChange,
+    addExpenseDetail,
+    removeExpenseDetail,
+    syncApplyAmountFromDetails,
+    openFormDialog,
+    onFormClosed,
+    submitForm,
+    openDetail,
+    openApprove,
+    approvalActionLabel,
+    submitApprove,
+    handleExport,
+    handleImportClick,
+    onImportFile,
+  };
+}

--
Gitblit v1.9.3