yyb
10 小时以前 1fba2685678695cca45127adaada26c7b96eec12
: 重构费用报销模块界面和功能
已添加3个文件
已修改1个文件
1600 ■■■■■ 文件已修改
src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/components/DetailPanel.vue 71 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/costReimburseUtils.js 309 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/index.vue 558 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/useCostReimburse.js 662 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
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>
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 ?? "",
  };
}
src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/index.vue
@@ -1,12 +1,556 @@
<!--
  æ¨¡å—中文名:费用报销
  ç›®å½•标识:ReimburseManage/cost-reimburse(cost-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>
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: "采购打印机硒鼓、A4纸等办公耗材。",
      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?.("解析失败");
      }
    };
    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,
  };
}