已修改18个文件
已添加30个文件
11614 ■■■■■ 文件已修改
src/api/oa/approvalInstance.js 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/oa/finReimbursement.js 71 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/system/post.js 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/config/oaPaths.js 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/config/oaWorkbench.js 52 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages.json 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ApproveManage/approve-list/_components/ApproveInstanceDetailBody.vue 413 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ApproveManage/approve-list/apply.vue 1074 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ApproveManage/approve-list/approve.vue 180 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ApproveManage/approve-list/detail.vue 174 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ApproveManage/approve-list/index.vue 315 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ApproveManage/approve-list/template-select.vue 146 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ApproveManage/approve-template/detail.vue 10 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ApproveManage/approve-template/edit.vue 1443 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ApproveManage/approve-template/index.vue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/AttendManage/leave-apply/index.vue 12 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/AttendManage/overtime-apply/index.vue 12 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/HrManage/regular-apply/index.vue 12 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/HrManage/transfer-apply/index.vue 12 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/HrManage/work-handover/index.vue 12 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ReimburseManage/_components/ReimburseApprovalFlowEditor.vue 245 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ReimburseManage/_components/ReimburseExpenseDetailSheet.vue 315 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ReimburseManage/_components/ReimburseInstanceDetailBody.vue 426 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ReimburseManage/_utils/costReimburseUtils.js 120 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ReimburseManage/_utils/expenseDetailDisplay.js 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ReimburseManage/_utils/finReimbursementDetailExtras.js 153 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ReimburseManage/_utils/travelReimburseUtils.js 82 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ReimburseManage/cost-reimburse/index.vue 15 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ReimburseManage/reimburse-detail/index.vue 120 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ReimburseManage/reimburse-detail/reimburse-detail.scss 344 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ReimburseManage/reimburse-form/index.vue 564 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ReimburseManage/reimburse-form/reimburse-form.scss 354 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ReimburseManage/reimburse-form/useFinReimburseForm.js 434 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ReimburseManage/travel-reimburse/index.vue 15 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/_components/ApprovalInstanceListPage.vue 344 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/_components/ApprovalModuleSearchPopup.vue 268 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/_components/FinReimbursementListPage.vue 346 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/_components/OaUserSearchPicker.vue 261 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/_styles/oa-approval-list.scss 414 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/_utils/approvalFormField.js 372 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/_utils/approvalModuleApplyExtras.js 293 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/_utils/approvalModuleListSearch.js 447 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/_utils/approvalModuleRegistry.js 184 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/_utils/approvalTemplateType.js 160 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/_utils/approveListUtils.js 374 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/_utils/finReimbursementMappers.js 763 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/_utils/reimburseApproveBridge.js 99 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/_utils/userPickerUtils.js 53 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/oa/approvalInstance.js
@@ -18,7 +18,10 @@
  });
}
/** å®¡æ ¸ä¸­ä¿®æ”¹å®¡æ‰¹å®žä¾‹ PUT /approvalInstance/update */
/**
 * ä¿®æ”¹å®¡æ‰¹å®žä¾‹ PUT /approvalInstance/update
 * @param {Object} approvalInstanceDto å®¡æ‰¹å®žä¾‹ï¼ˆéœ€å« id,其余字段按业务保留/更新)
 */
export function updateApprovalInstance(approvalInstanceDto) {
  return request({
    url: "/approvalInstance/update",
@@ -26,3 +29,24 @@
    data: { approvalInstanceDto },
  });
}
/** å®¡æ‰¹ï¼ˆé€šè¿‡/驳回)POST /approvalInstance/approve */
export function approveApprovalInstance(approvalInstanceDto) {
  return request({
    url: "/approvalInstance/approve",
    method: "post",
    data: { approvalInstanceDto },
  });
}
/** åˆ é™¤å®¡æ‰¹å®žä¾‹ DELETE /approvalInstance/delete */
export function deleteApprovalInstance(ids) {
  const idList = (Array.isArray(ids) ? ids : [ids]).filter(
    id => id != null && id !== ""
  );
  return request({
    url: "/approvalInstance/delete",
    method: "delete",
    data: idList,
  });
}
src/api/oa/finReimbursement.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,71 @@
import request from "@/utils/request";
/** åˆ†é¡µæŸ¥è¯¢è´¢åŠ¡æŠ¥é”€ GET /finReimbursement/listPage */
export function listFinReimbursementPage(params) {
  return request({
    url: "/finReimbursement/listPage",
    method: "get",
    params,
  });
}
/** è¯¦æƒ… query:Spring ç»‘定 finReimbursementDto.id,勿用 finReimbursementDto[id] */
function buildFinReimbursementDetailParams(idOrDto) {
  const raw =
    typeof idOrDto === "object" && idOrDto !== null
      ? idOrDto.id ?? idOrDto.reimbursementId
      : idOrDto;
  return {
    "finReimbursementDto.id": raw,
    id: raw,
  };
}
/** æŸ¥è¯¢è´¢åŠ¡æŠ¥é”€è¯¦æƒ… GET /finReimbursement/detail */
export function getFinReimbursementDetail(idOrDto) {
  return request({
    url: "/finReimbursement/detail",
    method: "get",
    params: buildFinReimbursementDetailParams(idOrDto),
  });
}
/** æ–°å¢žè´¢åŠ¡æŠ¥é”€ POST /finReimbursement/save */
export function saveFinReimbursement(finReimbursementDto) {
  return request({
    url: "/finReimbursement/save",
    method: "post",
    data: finReimbursementDto,
  });
}
/** ä¿®æ”¹è´¢åŠ¡æŠ¥é”€ POST /finReimbursement/update */
export function updateFinReimbursement(finReimbursementDto) {
  return request({
    url: "/finReimbursement/update",
    method: "post",
    data: finReimbursementDto,
  });
}
/** åˆ é™¤è´¢åŠ¡æŠ¥é”€ DELETE /finReimbursement/delete(body ä¸º ID æ•°ç»„) */
export function deleteFinReimbursement(ids) {
  const idList = (Array.isArray(ids) ? ids : [ids]).filter(
    (id) => id != null && id !== ""
  );
  return request({
    url: "/finReimbursement/delete",
    method: "delete",
    data: idList,
  });
}
/** æ–°å¢žèµ° save,修改走 update(与接口文档一致) */
export function persistFinReimbursement(finReimbursementDto, isEdit = false) {
  if (isEdit) {
    return updateFinReimbursement(finReimbursementDto);
  }
  const payload = { ...finReimbursementDto };
  delete payload.id;
  return saveFinReimbursement(payload);
}
src/api/system/post.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,10 @@
import request from "@/utils/request";
/** å²—位下拉 GET /system/post/optionselect */
export function findPostOptions(query) {
  return request({
    url: "/system/post/optionselect",
    method: "get",
    params: query,
  });
}
src/config/oaPaths.js
@@ -19,6 +19,8 @@
  /** æŠ¥é”€ç®¡ç† */
  travelReimburse: `/${P}/ReimburseManage/travel-reimburse/index`,
  costReimburse: `/${P}/ReimburseManage/cost-reimburse/index`,
  reimburseDetail: `/${P}/ReimburseManage/reimburse-detail/index`,
  reimburseForm: `/${P}/ReimburseManage/reimburse-form/index`,
  /** åˆåŒç®¡ç† */
  purchaseContract: `/${P}/ContractManage/purchase-contract/index`,
  saleContract: `/${P}/ContractManage/sale-contract/index`,
@@ -26,6 +28,8 @@
  approveList: `/${P}/ApproveManage/approve-list/index`,
  approveListTemplateSelect: `/${P}/ApproveManage/approve-list/template-select`,
  approveListApply: `/${P}/ApproveManage/approve-list/apply`,
  approveListDetail: `/${P}/ApproveManage/approve-list/detail`,
  approveListApprove: `/${P}/ApproveManage/approve-list/approve`,
  approveTemplate: `/${P}/ApproveManage/approve-template/index`,
  approveTemplateEdit: `/${P}/ApproveManage/approve-template/edit`,
  approveTemplateDetail: `/${P}/ApproveManage/approve-template/detail`,
src/config/oaWorkbench.js
@@ -8,13 +8,13 @@
    key: "HrManage",
    name: "人事管理",
    children: [
      { label: "员工档案", icon: "/static/images/icon/renyuanxinzi.svg", path: OA_NAV.staffArchive },
      { label: "员工合同", icon: "/static/images/icon/hetongguanli.svg", path: OA_NAV.staffContract },
      // { label: "员工档案", icon: "/static/images/icon/renyuanxinzi.svg", path: OA_NAV.staffArchive },
      // { label: "员工合同", icon: "/static/images/icon/hetongguanli.svg", path: OA_NAV.staffContract },
      { label: "转正申请", icon: "/static/images/icon/hetongguanli.svg", path: OA_NAV.regularApply },
      { label: "调岗申请", icon: "/static/images/icon/renyuanxinzi.svg", path: OA_NAV.transferApply },
      { label: "离职申请", icon: "/static/images/icon/qingjiaguanli.svg", path: OA_NAV.resignApply },
      // { label: "离职申请", icon: "/static/images/icon/qingjiaguanli.svg", path: OA_NAV.resignApply },
      { label: "工作交接", icon: "/static/images/icon/gongchuguanli.svg", path: OA_NAV.workHandover },
      { label: "岗位管理", icon: "/static/images/icon/gongxuguanli.svg", path: OA_NAV.postManage },
      // { label: "岗位管理", icon: "/static/images/icon/gongxuguanli.svg", path: OA_NAV.postManage },
    ],
  },
  {
@@ -33,14 +33,14 @@
      { label: "费用报销", icon: "/static/images/icon/baoxiaoguanli.svg", path: OA_NAV.costReimburse },
    ],
  },
  {
    key: "ContractManage",
    name: "合同管理",
    children: [
      { label: "采购合同", icon: "/static/images/icon/caigoutaizhang.svg", path: OA_NAV.purchaseContract },
      { label: "销售合同", icon: "/static/images/icon/xiaoshoutaizhang.svg", path: OA_NAV.saleContract },
    ],
  },
  // {
  //   key: "ContractManage",
  //   name: "合同管理",
  //   children: [
  //     { label: "采购合同", icon: "/static/images/icon/caigoutaizhang.svg", path: OA_NAV.purchaseContract },
  //     { label: "销售合同", icon: "/static/images/icon/xiaoshoutaizhang.svg", path: OA_NAV.saleContract },
  //   ],
  // },
  {
    key: "ApproveManage",
    name: "审批管理",
@@ -49,20 +49,20 @@
      { label: "审批模板", icon: "/static/images/icon/guizhangzhidu.svg", path: OA_NAV.approveTemplate },
    ],
  },
  {
    key: "EnterpriseNews",
    name: "企业新闻",
    children: [
      { label: "企业新闻", icon: "/static/images/icon/zhishiku.svg", path: OA_NAV.enterpriseNews },
    ],
  },
  {
    key: "NoticeAnnouncement",
    name: "公告通知",
    children: [
      { label: "公告通知", icon: "/static/images/icon/tongzhigonggao.svg", path: OA_NAV.noticeAnnouncement },
    ],
  },
  // {
  //   key: "EnterpriseNews",
  //   name: "企业新闻",
  //   children: [
  //     { label: "企业新闻", icon: "/static/images/icon/zhishiku.svg", path: OA_NAV.enterpriseNews },
  //   ],
  // },
  // {
  //   key: "NoticeAnnouncement",
  //   name: "公告通知",
  //   children: [
  //     { label: "公告通知", icon: "/static/images/icon/tongzhigonggao.svg", path: OA_NAV.noticeAnnouncement },
  //   ],
  // },
];
/** å·¥ä½œå°æ‰å¹³èœå•(纯前端配置) */
src/pages.json
@@ -1383,6 +1383,20 @@
      }
    },
    {
      "path": "pages/oa/ReimburseManage/reimburse-detail/index",
      "style": {
        "navigationBarTitleText": "报销详情",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/oa/ReimburseManage/reimburse-form/index",
      "style": {
        "navigationBarTitleText": "报销填报",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/oa/ContractManage/purchase-contract/index",
      "style": {
        "navigationBarTitleText": "采购合同",
@@ -1418,6 +1432,20 @@
      }
    },
    {
      "path": "pages/oa/ApproveManage/approve-list/detail",
      "style": {
        "navigationBarTitleText": "审批详情",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/oa/ApproveManage/approve-list/approve",
      "style": {
        "navigationBarTitleText": "审批处理",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/oa/ApproveManage/approve-template/index",
      "style": {
        "navigationBarTitleText": "审批模板",
src/pages/oa/ApproveManage/approve-list/_components/ApproveInstanceDetailBody.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,413 @@
<!--
  å®¡æ‰¹å®žä¾‹è¯¦æƒ…展示:基本信息 + å¡«æŠ¥ + æµç¨‹ + å®¡æ‰¹è®°å½•
-->
<template>
  <view class="detail-body">
    <view class="section-card">
      <view class="section-head">
        <text class="section-title">基本信息</text>
      </view>
      <view class="info-rows">
        <view class="info-row">
          <text class="info-label">业务单号</text>
          <text class="info-value">{{ row.instanceNo || row.id || "—" }}</text>
        </view>
        <view class="info-row">
          <text class="info-label">审批状态</text>
          <u-tag :type="statusTagType(row.status)"
                 :text="statusLabel(row.status)"
                 size="mini" />
        </view>
        <view class="info-row">
          <text class="info-label">模板名称</text>
          <text class="info-value">{{ row.templateName || "—" }}</text>
        </view>
        <view class="info-row">
          <text class="info-label">业务名称</text>
          <text class="info-value">{{ row.businessName || "—" }}</text>
        </view>
        <view class="info-row">
          <text class="info-label">申请人</text>
          <text class="info-value">{{ row.applicantName || "—" }}</text>
        </view>
        <view class="info-row">
          <text class="info-label">申请标题</text>
          <text class="info-value">{{ row.title || "—" }}</text>
        </view>
        <view v-if="rejectReason"
              class="info-row">
          <text class="info-label">驳回原因</text>
          <text class="info-value reject-text">{{ rejectReason }}</text>
        </view>
        <view class="info-row">
          <text class="info-label">申请时间</text>
          <text class="info-value">{{ formatDateTime(row.applyTime || row.createTime) }}</text>
        </view>
        <view v-if="row.finishTime"
              class="info-row">
          <text class="info-label">完成时间</text>
          <text class="info-value">{{ formatDateTime(row.finishTime) }}</text>
        </view>
      </view>
    </view>
    <view class="section-card">
      <view class="section-head">
        <text class="section-title">填报内容</text>
      </view>
      <view v-if="displayFields.length"
            class="info-rows">
        <view v-for="field in displayFields"
              :key="field.key"
              class="info-row">
          <text class="info-label">{{ field.label }}</text>
          <text class="info-value">{{ displayFieldValue(field) }}</text>
        </view>
        <view v-for="(extra, idx) in moduleExtraRows"
              :key="`extra-${idx}`"
              class="info-row">
          <text class="info-label">{{ extra.label }}</text>
          <text class="info-value">{{ extra.value }}</text>
        </view>
      </view>
      <view v-else
            class="empty-hint">暂无填报内容</view>
    </view>
    <view class="section-card">
      <view class="section-head">
        <text class="section-title">审批流程({{ flowNodes.length }} é¡¹ï¼‰</text>
      </view>
      <view v-if="flowNodes.length"
            class="flow-wrap">
        <view v-for="(node, nodeIndex) in flowNodes"
              :key="nodeIndex"
              class="flow-node-block">
          <view class="flow-node-card">
            <view class="node-header">
              <view class="node-level-badge">{{ node.levelNo }}</view>
              <text class="node-level-text">第{{ levelLabel(node.levelNo) }}级</text>
              <u-tag size="mini"
                     :type="node.approveType === 'OR' ? 'warning' : 'primary'"
                     :text="node.approveType === 'OR' ? '或签' : '会签'"
                     plain />
            </view>
            <view class="approver-list">
              <view v-for="(a, aIdx) in node.approvers"
                    :key="aIdx"
                    class="approver-row">
                <text class="approver-name">{{ a.approverName }}</text>
                <u-tag v-if="a.taskStatus"
                       size="mini"
                       :type="taskStatusTagType(a.taskStatus)"
                       :text="taskStatusText(a.taskStatus)"
                       plain />
              </view>
            </view>
          </view>
          <view v-if="nodeIndex < flowNodes.length - 1"
                class="flow-connector-line" />
        </view>
      </view>
      <view v-else
            class="empty-hint">暂无流程节点</view>
    </view>
    <view class="section-card">
      <view class="section-head">
        <text class="section-title">审批记录</text>
      </view>
      <view v-if="approvalRecords.length"
            class="record-list">
        <view v-for="(rec, index) in approvalRecords"
              :key="rec.id ?? index"
              class="record-item">
          <view class="record-head">
            <text class="record-operator">{{ rec.operatorName }}</text>
            <u-tag size="mini"
                   :type="rec.result === 'approved' ? 'success' : rec.result === 'rejected' ? 'error' : 'info'"
                   :text="recordActionLabel(rec.result)"
                   plain />
          </view>
          <text class="record-time">{{ rec.time }}</text>
          <text class="record-opinion">{{ rec.opinion || "无意见" }}</text>
        </view>
      </view>
      <view v-else
            class="empty-hint">暂无审批记录</view>
    </view>
  </view>
</template>
<script setup>
  import { computed } from "vue";
  import { APPROVAL_MODULE_KEYS } from "../../../_utils/approvalModuleRegistry.js";
  import {
    computeLeaveDurationDisplay,
    computeOvertimeHoursDisplay,
  } from "../../../_utils/approvalModuleApplyExtras.js";
  import { resolveInstanceFormPayload } from "../../../_utils/approvalModuleListSearch.js";
  import {
    businessStatusTagType,
    businessStatusText,
    displayFieldValue,
    formatDateTime,
    getRejectReasonFromRecords,
    instanceStatusTagType,
    instanceStatusText,
    mapApprovalRecords,
    mapTasksToFlowNodes,
    recordActionLabel,
    resolveInstanceDisplayFields,
    taskStatusTagType,
    taskStatusText,
  } from "../../../_utils/approveListUtils.js";
  const props = defineProps({
    row: { type: Object, default: () => ({}) },
    moduleKey: { type: String, default: "" },
  });
  const LEVEL_TEXT = ["", "一", "二", "三", "四", "五", "六", "七", "八", "九", "十"];
  const isBusinessModule = computed(() =>
    [
      APPROVAL_MODULE_KEYS.LEAVE,
      APPROVAL_MODULE_KEYS.OVERTIME,
      APPROVAL_MODULE_KEYS.TRANSFER,
      APPROVAL_MODULE_KEYS.REGULAR,
      APPROVAL_MODULE_KEYS.WORK_HANDOVER,
    ].includes(props.moduleKey)
  );
  const statusLabel = status =>
    isBusinessModule.value ? businessStatusText(status) : instanceStatusText(status);
  const statusTagType = status =>
    isBusinessModule.value ? businessStatusTagType(status) : instanceStatusTagType(status);
  const displayFields = computed(() => resolveInstanceDisplayFields(props.row));
  const moduleExtraRows = computed(() => {
    const rows = [];
    const { fields, formPayload } = resolveInstanceFormPayload(props.row);
    const payload = { ...formPayload };
    (fields || []).forEach(f => {
      if (f?.key && payload[f.key] == null) payload[f.key] = f.value ?? "";
    });
    if (props.moduleKey === APPROVAL_MODULE_KEYS.LEAVE) {
      const balance = payload.leaveBalanceDays;
      if (balance != null && balance !== "") {
        rows.push({ label: "假期余额", value: `${balance} å¤©` });
      }
      const days = computeLeaveDurationDisplay(fields, payload);
      if (days) rows.push({ label: "请假时长", value: `${days} å¤©` });
    }
    if (props.moduleKey === APPROVAL_MODULE_KEYS.OVERTIME) {
      const hours = computeOvertimeHoursDisplay(fields, payload);
      if (hours) rows.push({ label: "加班时长", value: `${hours} å°æ—¶` });
    }
    if (props.moduleKey === APPROVAL_MODULE_KEYS.TRANSFER) {
      const post = payload.originalPostName || payload.originalPost;
      if (post) rows.push({ label: "原岗位", value: post });
    }
    return rows;
  });
  const flowNodes = computed(() => mapTasksToFlowNodes(props.row?.tasks));
  const approvalRecords = computed(() =>
    mapApprovalRecords(props.row?.records)
  );
  const rejectReason = computed(() =>
    getRejectReasonFromRecords(props.row?.records)
  );
  const levelLabel = n => LEVEL_TEXT[Number(n)] || String(n);
</script>
<style scoped lang="scss">
  $primary: #2979ff;
  $text: #1f2d3d;
  $text-muted: #909399;
  .detail-body {
    display: flex;
    flex-direction: column;
    gap: 10px;
  }
  .section-card {
    background: #fff;
    border-radius: 12px;
    overflow: hidden;
    box-shadow: 0 2px 12px rgba(31, 45, 61, 0.05);
  }
  .section-head {
    padding: 12px 16px;
    border-bottom: 1px solid #f2f4f7;
  }
  .section-title {
    font-size: 15px;
    font-weight: 600;
    color: $text;
    padding-left: 10px;
    border-left: 3px solid $primary;
    line-height: 1.2;
  }
  .info-rows {
    padding: 4px 16px 12px;
  }
  .info-row {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    gap: 12px;
    padding: 10px 0;
    border-bottom: 1px solid #f5f6f8;
    &:last-child {
      border-bottom: none;
    }
  }
  .info-label {
    flex-shrink: 0;
    font-size: 14px;
    color: $text-muted;
    min-width: 72px;
  }
  .info-value {
    flex: 1;
    font-size: 14px;
    color: $text;
    text-align: right;
    word-break: break-all;
  }
  .reject-text {
    color: #f56c6c;
  }
  .flow-wrap {
    padding: 10px 16px 14px;
  }
  .flow-node-block {
    display: flex;
    flex-direction: column;
    align-items: stretch;
  }
  .flow-node-card {
    background: #fafbfd;
    border: 1px solid #e8eef5;
    border-radius: 10px;
    padding: 12px;
  }
  .node-header {
    display: flex;
    align-items: center;
    gap: 8px;
    margin-bottom: 10px;
  }
  .node-level-badge {
    width: 26px;
    height: 26px;
    border-radius: 8px;
    background: $primary;
    color: #fff;
    font-size: 13px;
    font-weight: 600;
    display: flex;
    align-items: center;
    justify-content: center;
  }
  .node-level-text {
    flex: 1;
    font-size: 14px;
    font-weight: 600;
    color: $text;
  }
  .approver-list {
    display: flex;
    flex-direction: column;
    gap: 8px;
  }
  .approver-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 8px;
  }
  .approver-name {
    font-size: 13px;
    color: #606266;
  }
  .flow-connector-line {
    width: 2px;
    height: 12px;
    background: #d0dff0;
    margin: 4px auto;
  }
  .record-list {
    padding: 8px 16px 14px;
  }
  .record-item {
    padding: 10px 0;
    border-bottom: 1px solid #f0f2f5;
    &:last-child {
      border-bottom: none;
    }
  }
  .record-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 8px;
  }
  .record-operator {
    font-size: 14px;
    font-weight: 600;
    color: $text;
  }
  .record-time {
    display: block;
    margin-top: 4px;
    font-size: 12px;
    color: $text-muted;
  }
  .record-opinion {
    display: block;
    margin-top: 6px;
    font-size: 13px;
    color: #606266;
    line-height: 1.5;
  }
  .empty-hint {
    padding: 12px 16px 16px;
    font-size: 13px;
    color: $text-muted;
  }
</style>
src/pages/oa/ApproveManage/approve-list/apply.vue
@@ -16,83 +16,211 @@
        <text class="loading-text">加载中...</text>
      </view>
      <template v-else-if="detail">
        <view class="section">
          <view class="section-title">基本信息</view>
          <view class="form-body">
            <view class="form-row">
              <text class="form-label required">审批标题</text>
        <up-form :model="form"
                 label-width="100"
                 input-align="right">
          <u-cell-group title="基本信息"
                        class="form-section">
            <up-form-item label="审批标题"
                          required
                          class="form-item-name">
              <up-input v-model="form.title"
                        class="name-input-inline"
                        placeholder="请输入审批标题"
                        maxlength="100"
                        clearable />
            </view>
            <view class="form-row">
              <text class="form-label">审批模板</text>
              <text class="form-readonly">{{ templateName }}</text>
            </view>
            <view class="form-row">
              <text class="form-label">申请人</text>
              <text class="form-readonly">{{ displayApplicantName }}</text>
            </view>
          </view>
        </view>
            </up-form-item>
            <up-form-item label="审批模板"
                          class="form-item-readonly">
              <up-input :model-value="templateName"
                        readonly />
            </up-form-item>
            <up-form-item label="申请人"
                          class="form-item-readonly">
              <up-input :model-value="displayApplicantName"
                        readonly />
            </up-form-item>
          </u-cell-group>
        </up-form>
        <view class="section">
          <view class="section-title">填报内容</view>
        <view class="section-card">
          <view class="section-head">
            <text class="section-title">填报内容</text>
          </view>
          <view v-if="formConfigData.prompt"
                class="form-prompt">
            {{ formConfigData.prompt }}
          </view>
          <view v-if="formConfigData.fields.length"
                class="form-body">
            <view v-for="field in formConfigData.fields"
                  :key="field.key"
                  class="form-row form-row--field">
              <text class="form-label"
                    :class="{ required: field.required }">{{ field.label }}</text>
              <up-textarea v-if="field.type === 'textarea'"
          <up-form v-if="formConfigData.fields.length"
                   :model="formValues"
                   label-width="100"
                   input-align="right"
                   class="dynamic-form">
            <up-form-item v-for="field in displayTemplateFields"
                          :key="field.key"
                          :label="field.label"
                          :required="!!field.required"
                          :label-position="formItemLabelPosition(field)"
                          :class="formItemClass(field)">
              <up-textarea v-if="isTextareaField(field)"
                           v-model="formValues[field.key]"
                           :placeholder="`请输入${field.label}`"
                           maxlength="500"
                           border="surround"
                           height="80" />
              <view v-else-if="field.type === 'date'"
                    class="date-trigger"
                    @click="openDatePicker(field.key)">
                <up-input :model-value="formValues[field.key]"
              <view v-else-if="isDatetimerangeField(field)"
                    class="daterange-fill">
                <view class="range-fill-row"
                      @click="openRangePicker(field, 'start')">
                  <text class="range-fill-label">开始</text>
                  <up-input :model-value="getRangePartDisplay(field, 'start')"
                            placeholder="开始时间"
                            readonly />
                  <up-icon name="calendar"
                           size="16"
                           color="#909399" />
                </view>
                <text class="range-fill-sep">至</text>
                <view class="range-fill-row"
                      @click="openRangePicker(field, 'end')">
                  <text class="range-fill-label">结束</text>
                  <up-input :model-value="getRangePartDisplay(field, 'end')"
                            placeholder="结束时间"
                            readonly />
                  <up-icon name="calendar"
                           size="16"
                           color="#909399" />
                </view>
              </view>
              <view v-else-if="isDateLikeField(field)"
                    class="field-trigger"
                    @click="openDatePicker(field)">
                <up-input :model-value="formatFieldDisplayValue(field, formValues[field.key])"
                          :placeholder="`请选择${field.label}`"
                          readonly />
                <up-icon :name="getDatePickerMode(field) === 'time' ? 'clock' : 'calendar'"
                         size="18"
                         color="#909399" />
              </view>
              <view v-else-if="isSelectField(field)"
                    class="field-trigger"
                    @click="openSelectPicker(field)">
                <up-input :model-value="getSelectDisplayText(field)"
                          :placeholder="`请选择${field.label}`"
                          readonly />
                <up-icon name="arrow-right"
                         size="16"
                         color="#c0c4cc" />
              </view>
              <up-input v-else
                        v-model="formValues[field.key]"
                        :type="field.type === 'number' ? 'digit' : 'text'"
                        :type="isNumberField(field) ? 'digit' : 'text'"
                        :placeholder="`请输入${field.label}`"
                        clearable />
            </view>
          </view>
            </up-form-item>
          </up-form>
          <view v-else
                class="empty-hint">该模板暂无填报项</view>
          <!-- è¯·å‡ï¼šå‡æœŸä½™é¢ + æ—¶é•¿è‡ªåŠ¨è®¡ç®— -->
          <view v-if="isLeaveModule"
                class="module-extra-block">
            <up-form :model="extraForm"
                     label-width="100"
                     input-align="right"
                     class="dynamic-form">
              <up-form-item label="假期余额"
                            required
                            class="form-item-inline">
                <up-input v-model="extraForm.leaveBalanceDays"
                          type="digit"
                          placeholder="请输入天数"
                          clearable />
              </up-form-item>
              <up-form-item label="请假时长"
                            class="form-item-inline">
                <view class="readonly-with-unit">
                  <up-input :model-value="leaveDurationText"
                            readonly
                            placeholder="根据请假时间自动计算" />
                  <text class="unit-text">天</text>
                </view>
              </up-form-item>
            </up-form>
          </view>
          <!-- åŠ ç­ï¼šæ—¶é•¿è‡ªåŠ¨è®¡ç®— -->
          <view v-if="isOvertimeModule"
                class="module-extra-block">
            <up-form label-width="100"
                     input-align="right"
                     class="dynamic-form">
              <up-form-item label="加班时长"
                            class="form-item-inline">
                <view class="readonly-with-unit">
                  <up-input :model-value="overtimeHoursText"
                            readonly
                            placeholder="根据加班时间自动计算" />
                  <text class="unit-text">小时</text>
                </view>
              </up-form-item>
            </up-form>
          </view>
          <!-- è°ƒå²—:原岗位自动带出 -->
          <view v-if="isTransferModule"
                class="module-extra-block">
            <up-form label-width="100"
                     input-align="right"
                     class="dynamic-form">
              <up-form-item label="原岗位"
                            class="form-item-readonly">
                <up-input :model-value="extraForm.originalPostName"
                          readonly
                          placeholder="选择申请人后自动带出" />
              </up-form-item>
            </up-form>
          </view>
        </view>
        <view class="section">
          <view class="section-title">审批流程</view>
        <view class="section-card">
          <view class="section-head">
            <text class="section-title">审批流程</text>
          </view>
          <view v-if="detail.nodes?.length"
                class="flow-list">
            <view v-for="(node, index) in detail.nodes"
                  :key="node.id || index"
                  class="flow-card">
              <view class="flow-card-head">
                <text class="flow-level">第{{ levelLabel(node.levelNo || index + 1) }}级</text>
                <text class="flow-type">{{ approveTypeText(node.approveType) }}</text>
                class="flow-wrap">
            <view v-for="(node, nodeIndex) in detail.nodes"
                  :key="node.id || nodeIndex"
                  class="flow-node-block">
              <view class="flow-node-card">
                <view class="node-header">
                  <view class="node-level-badge">{{ node.levelNo || nodeIndex + 1 }}</view>
                  <text class="node-level-text">第{{ levelLabel(node.levelNo || nodeIndex + 1) }}级</text>
                </view>
                <view class="approve-type-row approve-type-row--readonly">
                  <view class="type-btn"
                        :class="{ active: node.approveType !== 'OR' }">
                    ä¼šç­¾
                  </view>
                  <view class="type-btn"
                        :class="{ active: node.approveType === 'OR' }">
                    æˆ–ç­¾
                  </view>
                </view>
                <view class="approver-list">
                  <view v-for="(approver, aIdx) in node.approvers || []"
                        :key="approver.id || aIdx"
                        class="approver-chip">
                    <view class="approver-avatar">{{ (approver.approverName || "?").charAt(0) }}</view>
                    <text class="approver-name">{{ approver.approverName || "-" }}</text>
                  </view>
                  <text v-if="!(node.approvers || []).length"
                        class="empty-hint inline">暂无审批人</text>
                </view>
              </view>
              <view class="approver-tags">
                <text v-for="(approver, aIdx) in node.approvers || []"
                      :key="approver.id || aIdx"
                      class="approver-tag">
                  {{ approver.approverName || "-" }}
                </text>
                <text v-if="!(node.approvers || []).length"
                      class="empty-hint inline">暂无审批人</text>
              <view v-if="nodeIndex < detail.nodes.length - 1"
                    class="flow-connector">
                <view class="flow-connector-line" />
              </view>
            </view>
          </view>
@@ -119,15 +247,21 @@
              @close="showDatePicker = false">
      <up-datetime-picker :show="true"
                          v-model="datePickerTs"
                          mode="date"
                          :mode="datePickerMode"
                          @confirm="onDateConfirm"
                          @cancel="showDatePicker = false" />
                          @cancel="onDatePickerCancel" />
    </up-popup>
    <up-action-sheet :show="showSelectSheet"
                     :title="selectSheetTitle"
                     :actions="selectSheetActions"
                     @select="onSelectOption"
                     @close="showSelectSheet = false" />
  </view>
</template>
<script setup>
  import { computed, reactive, ref } from "vue";
  import { computed, reactive, ref, watch } from "vue";
  import { onLoad } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import FooterButtons from "@/components/FooterButtons.vue";
@@ -137,12 +271,53 @@
    updateApprovalInstance,
  } from "@/api/oa/approvalInstance.js";
  import useUserStore from "@/store/modules/user";
  import { formatDateToYMD, parseTime } from "@/utils/ruoyi";
  import { parseTime } from "@/utils/ruoyi";
  import { getDept } from "@/api/collaborativeApproval/approvalProcess.js";
  import { findPostOptions } from "@/api/system/post.js";
  import { userListNoPageByTenantId } from "@/api/system/user";
  import { APPROVAL_MODULE_KEYS } from "../../_utils/approvalModuleRegistry.js";
  import {
    computeLeaveDurationDisplay,
    computeOvertimeHoursDisplay,
    displayTemplateFieldsByModule,
    findApplicantTemplateField,
    findLeaveTimeTemplateField,
    findOvertimeTimeTemplateField,
    inferModuleKeyFromRow,
    loadModuleExtrasFromRow,
    resolveOriginalPostName,
    syncModuleExtrasToFormValues,
    unwrapUserArray,
    userById,
    validateModuleExtras,
    buildPostIdToNameMap,
  } from "../../_utils/approvalModuleApplyExtras.js";
  import {
    formatDatetimerangeDisplay,
    formatFieldDateValue,
    formatFieldDisplayValue,
    getDatePickerMode,
    getFieldInitialValue,
    getFieldOptionLabel,
    isDatetimerangeField,
    isDateLikeField,
    isNumberField,
    isSelectField,
    isTextareaField,
    joinDatetimerangeValue,
    mergeFormConfigForEdit,
    parseDatetimerangeValue,
    resolveFieldOptions,
    parseApprovalFormConfig,
    parseFieldDateToTs,
  } from "../../_utils/approvalFormField.js";
  const EDIT_STORAGE_KEY = "oa_approve_instance_edit_row";
  import { EDIT_STORAGE_KEY } from "../../_utils/approveListUtils.js";
  const LEVEL_TEXT = ["", "一", "二", "三", "四", "五", "六", "七", "八", "九", "十"];
  const userStore = useUserStore();
  const moduleKey = ref("");
  const templateId = ref("");
  const instanceId = ref("");
  const instanceRow = ref(null);
@@ -151,15 +326,44 @@
  const submitting = ref(false);
  const formValues = reactive({});
  const form = reactive({ title: "" });
  const extraForm = reactive({
    leaveBalanceDays: undefined,
    originalPostName: "",
  });
  const postIdToName = ref({});
  const transferUserPool = ref([]);
  const isLeaveModule = computed(
    () => moduleKey.value === APPROVAL_MODULE_KEYS.LEAVE
  );
  const isOvertimeModule = computed(
    () => moduleKey.value === APPROVAL_MODULE_KEYS.OVERTIME
  );
  const isTransferModule = computed(
    () => moduleKey.value === APPROVAL_MODULE_KEYS.TRANSFER
  );
  const showDatePicker = ref(false);
  const datePickerTs = ref(Date.now());
  const activeDateFieldKey = ref("");
  const activeDateField = ref(null);
  const activeRangePart = ref("start");
  const datePickerMode = computed(() => {
    const field = activeDateField.value;
    if (!field) return "date";
    if (isDatetimerangeField(field)) return "datetime";
    return getDatePickerMode(field);
  });
  const showSelectSheet = ref(false);
  const activeSelectField = ref(null);
  const pickerUserList = ref([]);
  const pickerDeptList = ref([]);
  const isEditMode = computed(() => !!instanceId.value);
  const pageTitle = computed(() => (isEditMode.value ? "编辑审批" : "发起审批"));
  const confirmText = computed(() => (isEditMode.value ? "保存" : "提交审批"));
  const pageTitle = computed(() => (isEditMode.value ? "修改审批" : "发起审批"));
  const confirmText = computed(() => (isEditMode.value ? "保存修改" : "提交审批"));
  const applicantName = computed(
    () => userStore.nickName || userStore.name || "-"
@@ -173,28 +377,103 @@
    () => detail.value?.templateName || instanceRow.value?.templateName || "-"
  );
  const parseFormConfig = raw => {
    if (!raw) return { prompt: "", fields: [] };
    try {
      const obj = typeof raw === "string" ? JSON.parse(raw) : raw;
      return {
        prompt: obj?.prompt || "",
        fields: Array.isArray(obj?.fields) ? obj.fields : [],
      };
    } catch {
      return { prompt: "", fields: [] };
    }
  };
  const formConfigData = computed(() => {
    const raw = isEditMode.value
      ? instanceRow.value?.formConfig
      : detail.value?.formConfig;
    return parseFormConfig(raw);
    if (isEditMode.value) {
      return mergeFormConfigForEdit(
        detail.value?.formConfig,
        instanceRow.value?.formConfig
      );
    }
    return parseApprovalFormConfig(detail.value?.formConfig);
  });
  const displayTemplateFields = computed(() =>
    displayTemplateFieldsByModule(moduleKey.value, formConfigData.value.fields)
  );
  const leaveDurationText = computed(() => {
    if (!isLeaveModule.value) return "";
    const fields = formConfigData.value.fields;
    const timeField = findLeaveTimeTemplateField(fields);
    if (timeField?.key) void formValues[timeField.key];
    return computeLeaveDurationDisplay(fields, formValues);
  });
  const overtimeHoursText = computed(() => {
    if (!isOvertimeModule.value) return "";
    const fields = formConfigData.value.fields;
    const timeField = findOvertimeTimeTemplateField(fields);
    if (timeField?.key) void formValues[timeField.key];
    return computeOvertimeHoursDisplay(fields, formValues);
  });
  const applicantPickerValue = computed(() => {
    const f = findApplicantTemplateField(formConfigData.value.fields);
    return f?.key ? formValues[f.key] : undefined;
  });
  const levelLabel = n => LEVEL_TEXT[Number(n)] || String(n);
  const approveTypeText = type => (type === "OR" ? "或签" : "会签");
  const selectSheetTitle = computed(
    () => (activeSelectField.value?.label ? `选择${activeSelectField.value.label}` : "请选择")
  );
  const selectSheetActions = computed(() => {
    const field = activeSelectField.value;
    if (!field) return [];
    return resolveFieldOptions(field, {
      users: pickerUserList.value,
      depts: pickerDeptList.value,
    }).map(opt => ({
      name: opt.label,
      value: opt.value,
    }));
  });
  const formItemClass = field => {
    if (isTextareaField(field)) return "form-item-textarea";
    if (isDatetimerangeField(field)) return "form-item-daterange";
    if (isSelectField(field) || isDateLikeField(field)) return "form-item-select";
    return "form-item-inline";
  };
  /** å¤šè¡Œæ–‡æœ¬ã€æ—¥æœŸèŒƒå›´ï¼šæ ‡ç­¾ç½®é¡¶ï¼Œé¿å…é•¿æ–‡æ¡ˆåœ¨çª„列内断行 */
  const formItemLabelPosition = field => {
    if (isTextareaField(field) || isDatetimerangeField(field)) return "top";
    return "left";
  };
  const getRangePartDisplay = (field, part) => {
    const parts = parseDatetimerangeValue(formValues[field.key]);
    const val = part === "start" ? parts.start : parts.end;
    return val ? formatFieldDisplayValue({ type: "datetime" }, val) : "";
  };
  const openRangePicker = (field, part) => {
    activeDateField.value = field;
    activeRangePart.value = part;
    const parts = parseDatetimerangeValue(formValues[field.key]);
    const val = part === "start" ? parts.start : parts.end;
    datePickerTs.value = parseFieldDateToTs(val) ?? Date.now();
    showDatePicker.value = true;
  };
  const getSelectDisplayText = field => {
    const stored = formValues[field.key];
    const options = resolveFieldOptions(field, {
      users: pickerUserList.value,
      depts: pickerDeptList.value,
    });
    const matched = options.find(
      opt =>
        String(opt.value) === String(stored) || String(opt.label) === String(stored)
    );
    return (
      matched?.label ||
      getFieldOptionLabel(field, stored) ||
      (stored !== undefined && stored !== null ? String(stored) : "")
    );
  };
  const initFormValues = fields => {
    Object.keys(formValues).forEach(key => {
@@ -202,23 +481,60 @@
    });
    fields.forEach(field => {
      if (!field?.key) return;
      formValues[field.key] = field.value ?? field.defaultValue ?? "";
      formValues[field.key] = getFieldInitialValue(field);
    });
  };
  const openDatePicker = fieldKey => {
    activeDateFieldKey.value = fieldKey;
    const current = formValues[fieldKey];
    datePickerTs.value = current ? new Date(current).getTime() : Date.now();
  const openSelectPicker = field => {
    const options = resolveFieldOptions(field, {
      users: pickerUserList.value,
      depts: pickerDeptList.value,
    });
    if (!options.length) {
      uni.showToast({ title: "该字段未配置下拉选项", icon: "none" });
      return;
    }
    activeSelectField.value = field;
    showSelectSheet.value = true;
  };
  const onSelectOption = action => {
    const key = activeSelectField.value?.key;
    if (key) {
      formValues[key] = action.value;
    }
    showSelectSheet.value = false;
    activeSelectField.value = null;
  };
  const openDatePicker = field => {
    activeDateField.value = field;
    const current = formValues[field.key];
    datePickerTs.value = parseFieldDateToTs(current) ?? Date.now();
    showDatePicker.value = true;
  };
  const onDatePickerCancel = () => {
    showDatePicker.value = false;
    activeDateField.value = null;
  };
  const onDateConfirm = e => {
    const ts = e?.value ?? datePickerTs.value;
    if (activeDateFieldKey.value) {
      formValues[activeDateFieldKey.value] = formatDateToYMD(ts);
    const field = activeDateField.value;
    if (field?.key) {
      if (isDatetimerangeField(field)) {
        const parts = parseDatetimerangeValue(formValues[field.key]);
        const formatted = formatFieldDateValue({ type: "datetime" }, ts);
        formValues[field.key] = joinDatetimerangeValue(
          activeRangePart.value === "start" ? formatted : parts.start,
          activeRangePart.value === "end" ? formatted : parts.end
        );
      } else {
        formValues[field.key] = formatFieldDateValue(field, ts);
      }
    }
    showDatePicker.value = false;
    onDatePickerCancel();
  };
  const validateForm = () => {
@@ -226,29 +542,74 @@
      uni.showToast({ title: "请输入审批标题", icon: "none" });
      return false;
    }
    for (const field of formConfigData.value.fields) {
    for (const field of displayTemplateFields.value) {
      if (!field.required) continue;
      const val = formValues[field.key];
      if (val === undefined || val === null || String(val).trim() === "") {
        uni.showToast({ title: `请填写${field.label}`, icon: "none" });
        const action =
          isSelectField(field) || isDateLikeField(field) || isDatetimerangeField(field)
            ? "请选择"
            : "请填写";
        uni.showToast({ title: `${action}${field.label}`, icon: "none" });
        return false;
      }
      if (isDatetimerangeField(field)) {
        const { start, end } = parseDatetimerangeValue(val);
        if (!start || !end) {
          uni.showToast({ title: `请完整选择${field.label}`, icon: "none" });
          return false;
        }
      }
      if (isSelectField(field)) {
        const options = resolveFieldOptions(field, {
          users: pickerUserList.value,
          depts: pickerDeptList.value,
        });
        if (
          options.length &&
          !options.some(
            opt =>
              String(opt.value) === String(val) || String(opt.label) === String(val)
          )
        ) {
          uni.showToast({ title: `${field.label}选项无效`, icon: "none" });
          return false;
        }
      }
    }
    if (!detail.value?.nodes?.length) {
      uni.showToast({ title: "模板未配置审批流程", icon: "none" });
      return false;
    }
    const moduleMsg = validateModuleExtras(
      moduleKey.value,
      formConfigData.value.fields,
      formValues,
      extraForm
    );
    if (moduleMsg) {
      uni.showToast({ title: moduleMsg, icon: "none" });
      return false;
    }
    return true;
  };
  const buildFormConfigPayload = () =>
    JSON.stringify({
  const buildFormConfigPayload = () => {
    syncModuleExtrasToFormValues(
      moduleKey.value,
      formValues,
      extraForm,
      formConfigData.value.fields
    );
    const allFields = formConfigData.value.fields || [];
    return JSON.stringify({
      prompt: formConfigData.value.prompt,
      fields: formConfigData.value.fields.map(field => ({
      fields: allFields.map(field => ({
        ...field,
        value: formValues[field.key] ?? "",
      })),
    });
  };
  const buildSavePayload = () => ({
    templateId: detail.value.id,
@@ -272,15 +633,23 @@
      templateId: row.templateId ?? detail.value?.id,
      templateName: row.templateName ?? detail.value?.templateName,
      businessId: row.businessId,
      businessType: row.businessType,
      businessType: row.businessType ?? detail.value?.businessType,
      title: form.title.trim(),
      status: row.status || "PENDING",
      currentLevel: row.currentLevel,
      applicantId: row.applicantId,
      applicantName: row.applicantName,
      applyTime: row.applyTime,
      finishTime: row.finishTime,
      createUser: row.createUser,
      createTime: row.createTime,
      updateUser: row.updateUser,
      updateTime: row.updateTime,
      deptId: row.deptId,
      deleted: row.deleted,
      formConfig: buildFormConfigPayload(),
      approveAction: row.approveAction,
      approveComment: row.approveComment,
    };
  };
@@ -296,7 +665,7 @@
    submitApi(payload)
      .then(() => {
        uni.showToast({
          title: isEditMode.value ? "保存成功" : "提交成功",
          title: isEditMode.value ? "修改成功" : "提交成功",
          icon: "success",
        });
        if (isEditMode.value) {
@@ -308,7 +677,7 @@
      })
      .catch(() => {
        uni.showToast({
          title: isEditMode.value ? "保存失败" : "提交失败",
          title: isEditMode.value ? "修改失败" : "提交失败",
          icon: "none",
        });
      })
@@ -339,7 +708,8 @@
    try {
      await loadTemplateDetail();
      if (!detail.value) return;
      initFormValues(formConfigData.value.fields);
      initFormValues(displayTemplateFields.value);
      resetModuleExtras();
      if (!form.title && detail.value.templateName) {
        form.title = `${detail.value.templateName}申请`;
      }
@@ -352,10 +722,13 @@
    const row = uni.getStorageSync(EDIT_STORAGE_KEY);
    if (!row || String(row.id) !== String(instanceId.value)) {
      uni.showToast({ title: "未获取到审批数据", icon: "none" });
      setTimeout(() => uni.navigateBack(), 500);
      return;
    }
    uni.removeStorageSync(EDIT_STORAGE_KEY);
    instanceRow.value = row;
    if (!moduleKey.value) {
      moduleKey.value = inferModuleKeyFromRow(row);
    }
    templateId.value = row.templateId;
    form.title = row.title || "";
@@ -363,17 +736,100 @@
    detail.value = null;
    try {
      await loadTemplateDetail();
      initFormValues(formConfigData.value.fields);
      if (!detail.value) return;
      initFormValues(displayTemplateFields.value);
      applyModuleExtrasFromRow();
      if (isTransferModule.value) {
        await ensureTransferLookupData();
        syncOriginalPostFromApplicant(applicantPickerValue.value);
      }
    } finally {
      loading.value = false;
    }
  };
  function resetModuleExtras() {
    extraForm.leaveBalanceDays = undefined;
    extraForm.originalPostName = "";
  }
  function applyModuleExtrasFromRow() {
    const loaded = loadModuleExtrasFromRow(
      moduleKey.value,
      instanceRow.value,
      formValues
    );
    if (loaded.leaveBalanceDays != null) {
      extraForm.leaveBalanceDays = loaded.leaveBalanceDays;
    }
    if (loaded.originalPostName) {
      extraForm.originalPostName = loaded.originalPostName;
    }
  }
  async function ensureTransferLookupData() {
    if (!transferUserPool.value.length) {
      try {
        const res = await userListNoPageByTenantId();
        transferUserPool.value = unwrapUserArray(res);
      } catch {
        transferUserPool.value = [];
      }
    }
    if (!Object.keys(postIdToName.value).length) {
      try {
        const res = await findPostOptions();
        const rows = res?.data ?? res?.rows ?? [];
        postIdToName.value = buildPostIdToNameMap(Array.isArray(rows) ? rows : []);
      } catch {
        postIdToName.value = {};
      }
    }
  }
  function syncOriginalPostFromApplicant(uid) {
    if (!isTransferModule.value) return;
    if (!uid) {
      extraForm.originalPostName = "";
      return;
    }
    const user = userById(transferUserPool.value, uid);
    extraForm.originalPostName = resolveOriginalPostName(user, postIdToName.value);
  }
  const goBack = () => {
    uni.navigateBack();
  };
  const loadPickerSourceData = () => {
    userListNoPageByTenantId()
      .then(res => {
        pickerUserList.value = res?.data || [];
      })
      .catch(() => {
        pickerUserList.value = [];
      });
    getDept()
      .then(res => {
        pickerDeptList.value = res?.data || [];
      })
      .catch(() => {
        pickerDeptList.value = [];
      });
  };
  watch(applicantPickerValue, async uid => {
    if (!isTransferModule.value) return;
    await ensureTransferLookupData();
    syncOriginalPostFromApplicant(uid);
  });
  onLoad(options => {
    moduleKey.value = options?.moduleKey || "";
    loadPickerSourceData();
    if (isTransferModule.value) {
      ensureTransferLookupData();
    }
    if (options?.id) {
      instanceId.value = options.id;
      loadForEdit();
@@ -389,11 +845,22 @@
</script>
<style scoped lang="scss">
  @import "@/static/scss/form-common.scss";
  $primary: #2979ff;
  $text: #1f2d3d;
  $text-secondary: #606266;
  $text-muted: #909399;
  $bg-page: #f0f3f8;
  $radius-lg: 12px;
  $radius-md: 10px;
  $shadow-card: 0 2px 12px rgba(31, 45, 61, 0.05);
  .approve-apply-page {
    display: flex;
    flex-direction: column;
    min-height: 100vh;
    background: #f0f3f8;
    background: $bg-page;
  }
  .form-scroll {
@@ -412,130 +879,364 @@
  .loading-text {
    font-size: 14px;
    color: $text-muted;
  }
  .form-section {
    margin-bottom: 10px;
    border-radius: $radius-lg;
    overflow: hidden;
    box-shadow: $shadow-card;
  }
  :deep(.form-section .u-cell-group__title) {
    padding: 12px 16px 8px !important;
    font-size: 15px !important;
    font-weight: 600 !important;
    color: $text !important;
    background: #fff !important;
  }
  :deep(.form-section .u-form-item) {
    padding: 0 16px !important;
  }
  :deep(.form-section .u-form-item__body) {
    padding: 10px 0 !important;
    min-height: auto !important;
  }
  :deep(.form-item-name .u-form-item__body) {
    flex-direction: row !important;
    align-items: center !important;
  }
  :deep(.form-item-name .u-form-item__content) {
    flex: 1 !important;
    min-width: 0 !important;
    justify-content: flex-end !important;
  }
  :deep(.name-input-inline),
  :deep(.name-input-inline .u-input__content) {
    width: 100% !important;
    flex: 1 !important;
  }
  :deep(.name-input-inline input),
  :deep(.name-input-inline .u-input__content__field-wrapper__field) {
    width: 100% !important;
    text-align: right !important;
    font-size: 15px !important;
  }
  :deep(.form-item-readonly .u-form-item__body) {
    align-items: center !important;
  }
  :deep(.form-item-readonly .u-form-item__content) {
    flex: 1 !important;
    min-width: 0 !important;
    justify-content: flex-end !important;
  }
  :deep(.form-item-readonly .u-input__content__field-wrapper__field) {
    text-align: right !important;
    color: #303133 !important;
  }
  .dynamic-form {
    padding: 0 0 4px;
  }
  :deep(.dynamic-form .u-form-item) {
    padding: 0 16px !important;
  }
  :deep(.dynamic-form .u-form-item__body) {
    padding: 10px 0 !important;
    min-height: auto !important;
  }
  :deep(.form-item-inline .u-form-item__body) {
    flex-direction: row !important;
    align-items: center !important;
  }
  :deep(.form-item-inline .u-form-item__content) {
    flex: 1 !important;
    min-width: 0 !important;
    justify-content: flex-end !important;
  }
  :deep(.form-item-inline input),
  :deep(.form-item-inline .u-input__content__field-wrapper__field) {
    text-align: right !important;
    font-size: 15px !important;
  }
  :deep(.form-item-select .u-form-item__body) {
    align-items: center !important;
  }
  :deep(.form-item-select .u-form-item__content) {
    flex: 1 !important;
    min-width: 0 !important;
    justify-content: flex-end !important;
  }
  :deep(.form-item-textarea .u-form-item__body),
  :deep(.form-item-daterange .u-form-item__body) {
    flex-direction: column !important;
    align-items: stretch !important;
    padding: 10px 0 12px !important;
  }
  :deep(.form-item-textarea .u-form-item__body__left),
  :deep(.form-item-daterange .u-form-item__body__left) {
    width: 100% !important;
    max-width: 100% !important;
    margin-bottom: 8px !important;
    padding-right: 0 !important;
  }
  :deep(.form-item-textarea .u-form-item__body__left__content__label),
  :deep(.form-item-daterange .u-form-item__body__left__content__label) {
    white-space: normal !important;
    line-height: 1.45 !important;
    font-size: 14px !important;
  }
  :deep(.form-item-textarea .u-form-item__body__right),
  :deep(.form-item-daterange .u-form-item__body__right) {
    width: 100% !important;
    flex: none !important;
  }
  :deep(.form-item-textarea .u-form-item__content),
  :deep(.form-item-daterange .u-form-item__content) {
    width: 100% !important;
    justify-content: stretch !important;
  }
  :deep(.dynamic-form .u-form-item__body__left__content__label) {
    white-space: nowrap;
  }
  .field-trigger {
    display: flex;
    align-items: center;
    justify-content: flex-end;
    gap: 6px;
    width: 100%;
    min-width: 0;
  }
  :deep(.field-trigger .u-input) {
    flex: 1 !important;
    min-width: 0 !important;
  }
  :deep(.field-trigger .u-input__content__field-wrapper__field) {
    text-align: right !important;
    font-size: 15px !important;
  }
  .daterange-fill {
    width: 100%;
    display: flex;
    flex-direction: column;
    gap: 8px;
  }
  .range-fill-row {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 8px 10px;
    background: #f7f9fc;
    border: 1px solid #eef1f6;
    border-radius: 8px;
  }
  .range-fill-label {
    flex-shrink: 0;
    width: 36px;
    font-size: 13px;
    color: #909399;
  }
  .section {
    background: #fff;
    border-radius: 12px;
  .range-fill-sep {
    font-size: 12px;
    color: #c0c4cc;
    text-align: center;
  }
  :deep(.range-fill-row .u-input) {
    flex: 1 !important;
    min-width: 0 !important;
  }
  .section-card {
    margin-bottom: 10px;
    background: #fff;
    border-radius: $radius-lg;
    overflow: hidden;
    box-shadow: 0 2px 12px rgba(31, 45, 61, 0.05);
    box-shadow: $shadow-card;
  }
  .section-head {
    padding: 12px 16px;
    border-bottom: 1px solid #f2f4f7;
  }
  .section-title {
    padding: 12px 16px;
    font-size: 15px;
    font-weight: 600;
    color: #1f2d3d;
    border-bottom: 1px solid #f2f4f7;
    border-left: 3px solid #2979ff;
    padding-left: 13px;
  }
  .form-body {
    padding: 8px 16px 16px;
  }
  .form-row {
    padding: 10px 0;
    border-bottom: 1px solid #f5f7fa;
    &:last-child {
      border-bottom: none;
    }
    &--field {
      flex-direction: column;
      align-items: stretch;
    }
  }
  .form-label {
    display: block;
    margin-bottom: 8px;
    font-size: 14px;
    color: #606266;
    &.required::before {
      content: "*";
      color: #f56c6c;
      margin-right: 4px;
    }
  }
  .form-readonly {
    font-size: 14px;
    color: #303133;
    color: $text;
    padding-left: 10px;
    border-left: 3px solid $primary;
    line-height: 1.2;
  }
  .form-prompt {
    margin: 12px 16px 0;
    padding: 10px 12px;
    font-size: 13px;
    color: #606266;
    color: $text-secondary;
    background: #f8fafc;
    border-radius: 8px;
    line-height: 1.5;
  }
  .date-trigger {
    width: 100%;
  .flow-wrap {
    padding: 10px 16px 14px;
  }
  .flow-list {
  .flow-node-block {
    display: flex;
    flex-direction: column;
    align-items: stretch;
  }
  .flow-node-card {
    background: #fafbfd;
    border: 1px solid #e8eef5;
    border-radius: $radius-md;
    padding: 12px;
  }
  .flow-card {
    padding: 12px;
    margin-bottom: 8px;
    background: #f8fafc;
  .node-header {
    display: flex;
    align-items: center;
    gap: 10px;
    margin-bottom: 10px;
  }
  .node-level-badge {
    width: 26px;
    height: 26px;
    border-radius: 8px;
    border: 1px solid #eef2f6;
    background: $primary;
    color: #fff;
    font-size: 14px;
    font-weight: 600;
    display: flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
  }
    &:last-child {
      margin-bottom: 0;
  .node-level-text {
    flex: 1;
    font-size: 15px;
    font-weight: 600;
    color: $text;
  }
  .approve-type-row {
    display: flex;
    background: #f0f3f8;
    border-radius: 8px;
    padding: 3px;
    margin-bottom: 10px;
    &--readonly {
      pointer-events: none;
    }
  }
  .flow-card-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin-bottom: 8px;
  }
  .flow-level {
  .type-btn {
    flex: 1;
    text-align: center;
    padding: 8px 0;
    font-size: 14px;
    font-weight: 600;
    color: #303133;
    color: $text-secondary;
    border-radius: 6px;
    &.active {
      background: #fff;
      color: $primary;
      font-weight: 500;
    }
  }
  .flow-type {
    font-size: 13px;
    color: #2979ff;
  }
  .approver-tags {
  .approver-list {
    display: flex;
    flex-wrap: wrap;
    gap: 8px;
    align-items: center;
  }
  .approver-tag {
    padding: 4px 10px;
    font-size: 13px;
    color: #303133;
  .approver-chip {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 6px 12px 6px 6px;
    background: #fff;
    border: 1px solid #dce8f8;
    border-radius: 16px;
    border-radius: 24px;
    box-shadow: 0 2px 6px rgba(41, 121, 255, 0.06);
  }
  .approver-avatar {
    width: 26px;
    height: 26px;
    border-radius: 50%;
    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
    color: #fff;
    font-size: 12px;
    font-weight: 600;
    display: flex;
    align-items: center;
    justify-content: center;
  }
  .approver-name {
    font-size: 13px;
    color: $text;
    max-width: 120px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .flow-connector {
    display: flex;
    justify-content: center;
    padding: 4px 0;
  }
  .flow-connector-line {
    width: 2px;
    height: 14px;
    background: #d0dff0;
  }
  .empty-hint {
    padding: 12px 16px 16px;
    font-size: 13px;
    color: #909399;
    color: $text-muted;
    &.inline {
      padding: 0;
@@ -545,4 +1246,29 @@
  .empty-wrap {
    padding: 48px 20px;
  }
  .module-extra-block {
    margin-top: 8px;
    padding-top: 8px;
    border-top: 1px dashed #e8ecf0;
  }
  .readonly-with-unit {
    display: flex;
    align-items: center;
    gap: 8px;
    width: 100%;
    justify-content: flex-end;
  }
  .readonly-with-unit :deep(.u-input) {
    flex: 1;
    min-width: 0;
  }
  .unit-text {
    flex-shrink: 0;
    font-size: 14px;
    color: $text-muted;
  }
</style>
src/pages/oa/ApproveManage/approve-list/approve.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,180 @@
<!--
  OA / å®¡æ‰¹ç®¡ç† / å®¡æ‰¹å¤„理
  å·®æ—…/费用报销使用报销详情 + å®¡æ‰¹åˆ—表 approve æŽ¥å£
-->
<template>
  <view class="oa-detail-page">
    <PageHeader :title="pageTitle"
                @back="goBack" />
    <scroll-view v-if="displayReady"
                 class="oa-detail-scroll"
                 scroll-y
                 :show-scrollbar="false">
      <ReimburseInstanceDetailBody v-if="isReimburse"
                                 :reimburse-row="reimburseRow"
                                 :module-key="detailModuleKey" />
      <ApproveInstanceDetailBody v-else
                                 :row="row"
                                 :module-key="detailModuleKey" />
      <view class="section-card opinion-card">
        <view class="section-head">
          <text class="section-title">审批意见</text>
        </view>
        <view class="opinion-wrap">
          <up-textarea v-model="approveOpinion"
                       placeholder="通过可留空;驳回请填写具体原因"
                       maxlength="500"
                       count
                       height="100"
                       border="surround" />
        </view>
      </view>
    </scroll-view>
    <view v-else
          class="oa-empty">
      <up-empty mode="data"
                :text="loading ? '加载中' : '未获取到审批数据'" />
    </view>
    <view v-if="displayReady"
          class="oa-page-footer">
      <text class="oa-footer-btn btn-default"
            :class="{ 'is-disabled': submitting }"
            @click="goBack">取消</text>
      <text class="oa-footer-btn btn-danger"
            :class="{ 'is-disabled': submitting }"
            @click="submitApprove('rejected')">驳回</text>
      <text class="oa-footer-btn btn-success"
            :class="{ 'is-disabled': submitting }"
            @click="submitApprove('approved')">通过</text>
    </view>
  </view>
</template>
<script setup>
  import { computed, ref } from "vue";
  import { onLoad } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import ApproveInstanceDetailBody from "./_components/ApproveInstanceDetailBody.vue";
  import ReimburseInstanceDetailBody from "../../ReimburseManage/_components/ReimburseInstanceDetailBody.vue";
  import { approveApprovalInstance } from "@/api/oa/approvalInstance.js";
  import {
    buildApproveInstanceDto,
    canApproveInstance,
    loadInstanceRow,
  } from "../../_utils/approveListUtils.js";
  import { inferModuleKeyFromRow } from "../../_utils/approvalModuleApplyExtras.js";
  import {
    inferReimburseModuleKeyFromInstance,
    isReimburseApprovalInstance,
    loadReimburseDetailForInstance,
  } from "../../_utils/reimburseApproveBridge.js";
  import { getApprovalModuleConfig } from "../../_utils/approvalModuleRegistry.js";
  const row = ref(null);
  const reimburseRow = ref(null);
  const loading = ref(false);
  const approveOpinion = ref("");
  const submitting = ref(false);
  const isReimburse = computed(() => isReimburseApprovalInstance(row.value));
  const detailModuleKey = computed(() => {
    if (isReimburse.value) {
      return (
        reimburseRow.value?.moduleKey ||
        inferReimburseModuleKeyFromInstance(row.value)
      );
    }
    return inferModuleKeyFromRow(row.value);
  });
  const pageTitle = computed(() => {
    if (isReimburse.value) {
      const label = getApprovalModuleConfig(detailModuleKey.value)?.label || "报销";
      return `${label}审批`;
    }
    return "审批处理";
  });
  const displayReady = computed(() =>
    isReimburse.value ? Boolean(reimburseRow.value) : Boolean(row.value)
  );
  const goBack = () => uni.navigateBack();
  const submitApprove = uiResult => {
    if (!row.value?.id || submitting.value) return;
    if (uiResult === "rejected" && !(approveOpinion.value || "").trim()) {
      uni.showToast({ title: "驳回时请填写审批意见", icon: "none" });
      return;
    }
    submitting.value = true;
    const dto = buildApproveInstanceDto(
      row.value.id,
      uiResult,
      approveOpinion.value
    );
    approveApprovalInstance(dto)
      .then(() => {
        uni.showToast({
          title: uiResult === "approved" ? "已通过" : "已驳回",
          icon: "success",
        });
        setTimeout(() => {
          const pages = getCurrentPages();
          const prevRoute = pages[pages.length - 2]?.route || "";
          const delta = prevRoute.includes("approve-list/detail") ? 2 : 1;
          uni.navigateBack({ delta });
        }, 400);
      })
      .catch(() => {
        uni.showToast({ title: "审批操作失败", icon: "none" });
      })
      .finally(() => {
        submitting.value = false;
      });
  };
  onLoad(async options => {
    if (!options?.id) {
      uni.showToast({ title: "缺少审批 ID", icon: "none" });
      setTimeout(goBack, 500);
      return;
    }
    const cached = loadInstanceRow(options.id);
    if (!cached) {
      uni.showToast({ title: "请从列表进入", icon: "none" });
      setTimeout(goBack, 500);
      return;
    }
    if (!canApproveInstance(cached)) {
      uni.showToast({ title: "当前审批无需您处理", icon: "none" });
      setTimeout(goBack, 500);
      return;
    }
    row.value = cached;
    if (isReimburseApprovalInstance(cached)) {
      loading.value = true;
      try {
        const { reimburseRow: mapped } = await loadReimburseDetailForInstance(cached);
        reimburseRow.value = mapped;
      } catch {
        uni.showToast({ title: "加载报销详情失败", icon: "none" });
      } finally {
        loading.value = false;
      }
    }
  });
</script>
<style scoped lang="scss">
  @import "../../_styles/oa-approval-list.scss";
</style>
src/pages/oa/ApproveManage/approve-list/detail.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,174 @@
<!--
  OA / å®¡æ‰¹ç®¡ç† / å®¡æ‰¹è¯¦æƒ…
  è·¯ç”±ï¼š/pages/oa/ApproveManage/approve-list/detail
-->
<template>
  <view class="oa-detail-page">
    <PageHeader :title="pageTitle"
                @back="goBack" />
    <scroll-view v-if="displayReady"
                 class="oa-detail-scroll"
                 scroll-y
                 :show-scrollbar="false">
      <ReimburseInstanceDetailBody v-if="isReimburse"
                                 :reimburse-row="reimburseRow"
                                 :module-key="detailModuleKey" />
      <ApproveInstanceDetailBody v-else
                                 :row="row"
                                 :module-key="detailModuleKey" />
    </scroll-view>
    <view v-else
          class="oa-empty">
      <up-empty mode="data"
                :text="loading ? '加载中' : '未获取到审批数据'" />
    </view>
    <view v-if="displayReady"
          class="oa-page-footer">
      <text class="oa-footer-btn btn-default"
            @click="goBack">返回</text>
      <text v-if="showEdit"
            class="oa-footer-btn btn-warn"
            @click="goEdit">修改</text>
      <text v-if="showApprove && !fromBusiness"
            class="oa-footer-btn btn-primary"
            @click="goApprove">去审批</text>
    </view>
  </view>
</template>
<script setup>
  import { computed, ref } from "vue";
  import { onLoad } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import ApproveInstanceDetailBody from "./_components/ApproveInstanceDetailBody.vue";
  import ReimburseInstanceDetailBody from "../../ReimburseManage/_components/ReimburseInstanceDetailBody.vue";
  import { OA_NAV } from "@/config/oaPaths.js";
  import useUserStore from "@/store/modules/user";
  import {
    canApproveInstance,
    canEditBusinessInstanceRow,
    canModifyInstance,
    loadInstanceRow,
    stashInstanceRow,
  } from "../../_utils/approveListUtils.js";
  import { inferModuleKeyFromRow } from "../../_utils/approvalModuleApplyExtras.js";
  import {
    inferReimburseModuleKeyFromInstance,
    isReimburseApprovalInstance,
    loadReimburseDetailForInstance,
    resolveFinReimbursementIdFromInstance,
    stashReimburseEditFromApprove,
  } from "../../_utils/reimburseApproveBridge.js";
  import { getApprovalModuleConfig } from "../../_utils/approvalModuleRegistry.js";
  import { canEditReimbursementRow } from "../../_utils/finReimbursementMappers.js";
  const userStore = useUserStore();
  const fromBusiness = ref(false);
  const row = ref(null);
  const reimburseRow = ref(null);
  const loading = ref(false);
  const detailModuleKey = computed(() => {
    if (isReimburse.value) {
      return (
        reimburseRow.value?.moduleKey ||
        inferReimburseModuleKeyFromInstance(row.value)
      );
    }
    return inferModuleKeyFromRow(row.value);
  });
  const isReimburse = computed(() => isReimburseApprovalInstance(row.value));
  const pageTitle = computed(() => {
    if (isReimburse.value) {
      return getApprovalModuleConfig(detailModuleKey.value)?.label
        ? `${getApprovalModuleConfig(detailModuleKey.value).label}详情`
        : "报销详情";
    }
    return "审批详情";
  });
  const displayReady = computed(() =>
    isReimburse.value ? Boolean(reimburseRow.value) : Boolean(row.value)
  );
  const showEdit = computed(() => {
    if (isReimburse.value) {
      return canEditReimbursementRow(reimburseRow.value);
    }
    if (fromBusiness.value) {
      return canEditBusinessInstanceRow(row.value);
    }
    return canModifyInstance(row.value, userStore);
  });
  const showApprove = computed(() => canApproveInstance(row.value));
  const goBack = () => uni.navigateBack();
  const goEdit = () => {
    if (!showEdit.value) return;
    if (isReimburse.value) {
      const mk = detailModuleKey.value;
      const rid = resolveFinReimbursementIdFromInstance(row.value);
      if (rid == null) {
        uni.showToast({ title: "无法修改:缺少报销单 ID", icon: "none" });
        return;
      }
      stashReimburseEditFromApprove(mk, rid);
      uni.navigateTo({
        url: `${OA_NAV.reimburseForm}?moduleKey=${mk}&mode=edit&reimbursementId=${rid}`,
      });
      return;
    }
    if (!row.value?.id) return;
    const mk = detailModuleKey.value;
    const q = mk ? `&moduleKey=${mk}` : "";
    uni.navigateTo({
      url: `${OA_NAV.approveListApply}?id=${row.value.id}${q}`,
    });
  };
  const goApprove = () => {
    if (!row.value?.id) return;
    stashInstanceRow(row.value);
    uni.navigateTo({
      url: `${OA_NAV.approveListApprove}?id=${row.value.id}`,
    });
  };
  onLoad(async options => {
    fromBusiness.value = options?.from === "business";
    if (!options?.id) {
      uni.showToast({ title: "缺少审批 ID", icon: "none" });
      setTimeout(goBack, 500);
      return;
    }
    const cached = loadInstanceRow(options.id);
    if (!cached) {
      uni.showToast({ title: "请从列表进入详情", icon: "none" });
      setTimeout(goBack, 500);
      return;
    }
    row.value = cached;
    if (isReimburseApprovalInstance(cached)) {
      loading.value = true;
      try {
        const { reimburseRow: mapped } = await loadReimburseDetailForInstance(cached);
        reimburseRow.value = mapped;
      } catch {
        uni.showToast({ title: "加载报销详情失败", icon: "none" });
      } finally {
        loading.value = false;
      }
    }
  });
</script>
<style scoped lang="scss">
  @import "../../_styles/oa-approval-list.scss";
</style>
src/pages/oa/ApproveManage/approve-list/index.vue
@@ -1,107 +1,94 @@
<!--
  OA / å®¡æ‰¹ç®¡ç† / å®¡æ‰¹åˆ—表
  è·¯ç”±ï¼š/pages/oa/ApproveManage/approve-list/index
-->
<template>
  <view class="approve-list-page sales-account">
  <view class="oa-approval-page">
    <PageHeader title="审批列表"
                @back="goBack" />
    <view class="search-section">
      <view class="search-bar">
        <view class="search-input">
          <up-input v-model="queryParams.keyword"
                    class="search-text"
                    placeholder="审批标题 / å®¡æ‰¹ç¼–号"
                    clearable
                    @confirm="handleSearch" />
        </view>
        <view class="filter-button"
              @click="handleSearch">
          <up-icon name="search"
                   size="24"
                   color="#999" />
        </view>
    <view class="oa-toolbar">
      <view class="oa-filter-chip active-search">
        <up-icon name="search"
                 size="18"
                 color="#666" />
        <up-input v-model="queryParams.keyword"
                  class="chip-input"
                  placeholder="审批标题 / å®¡æ‰¹ç¼–号"
                  clearable
                  border="none"
                  @confirm="handleSearch" />
      </view>
      <view class="oa-icon-btn"
            @click="handleSearch">
        <up-icon name="search"
                 size="20"
                 color="#2979ff" />
      </view>
    </view>
    <scroll-view class="list-scroll"
    <scroll-view class="oa-list-scroll"
                 scroll-y
                 :show-scrollbar="false"
                 :style="{ height: listScrollHeight + 'px' }"
                 @scrolltolower="loadMore">
      <view v-if="list.length"
            class="ledger-list">
            class="oa-card-list">
        <view v-for="item in list"
              :key="item.id"
              class="ledger-item">
          <view class="item-header">
            <view class="item-left">
              <view class="document-icon">
                <up-icon name="file-text"
                         size="16"
                         color="#ffffff" />
              class="oa-card"
              @click="openDetail(item)">
          <view class="oa-card-head">
            <view class="oa-card-title-wrap">
              <text class="oa-card-title">{{ item.title || item.instanceNo || "-" }}</text>
              <text v-if="item.templateName"
                    class="oa-card-sub">{{ item.templateName }}</text>
            </view>
            <text :class="['oa-status', businessStatusClass(item.status)]">
              {{ statusText(item.status) }}
            </text>
          </view>
          <view class="oa-card-body">
            <view class="oa-info-grid">
              <view class="oa-info-row">
                <text class="oa-info-label">审批编号</text>
                <text class="oa-info-value">{{ item.instanceNo || "-" }}</text>
              </view>
              <text class="item-id">{{ item.title || item.instanceNo || "-" }}</text>
            </view>
            <u-tag :type="statusTagType(item.status)"
                   :text="statusText(item.status)" />
          </view>
          <up-divider />
          <view class="item-details">
            <view class="detail-row">
              <text class="detail-label">审批编号</text>
              <text class="detail-value">{{ item.instanceNo || "-" }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">模板名称</text>
              <text class="detail-value">{{ item.templateName || "-" }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">业务名称</text>
              <text class="detail-value">{{ item.businessName || "-" }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">申请人</text>
              <text class="detail-value">{{ item.applicantName || "-" }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">当前级别</text>
              <text class="detail-value">{{ formatLevel(item.currentLevel) }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">当前审批人</text>
              <text class="detail-value">{{ currentApproverName(item) }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">申请时间</text>
              <text class="detail-value">{{ item.applyTime || "-" }}</text>
            </view>
            <view v-if="item.finishTime"
                  class="detail-row">
              <text class="detail-label">完成时间</text>
              <text class="detail-value">{{ item.finishTime }}</text>
              <view v-if="item.businessName"
                    class="oa-info-row">
                <text class="oa-info-label">业务名称</text>
                <text class="oa-info-value">{{ item.businessName }}</text>
              </view>
              <view class="oa-info-row">
                <text class="oa-info-label">申请人</text>
                <text class="oa-info-value">{{ item.applicantName || "-" }}</text>
              </view>
              <view class="oa-info-row">
                <text class="oa-info-label">当前审批人</text>
                <text class="oa-info-value">{{ currentApproverName(item) }}</text>
              </view>
              <view class="oa-info-row">
                <text class="oa-info-label">申请时间</text>
                <text class="oa-info-value">{{ formatDateTime(item.applyTime) }}</text>
              </view>
            </view>
          </view>
          <view v-if="canEdit(item) || item.isApprove"
                class="action-buttons">
            <up-button v-if="canEdit(item)"
                       class="action-btn"
                       size="small"
                       @click.stop="goEdit(item)">
              ç¼–辑
            </up-button>
            <up-button v-if="item.isApprove"
                       class="action-btn"
                       size="small"
                       type="primary"
                       @click.stop="handleApprove(item)">
              å®¡æ‰¹
            </up-button>
          <view v-if="canModify(item) || item.isApprove"
                class="oa-card-foot"
                @click.stop>
            <text v-if="canModify(item)"
                  class="oa-foot-btn btn-edit"
                  @click="goModify(item)">编辑</text>
            <text v-if="item.isApprove"
                  class="oa-foot-btn btn-approve"
                  @click="handleApprove(item)">审批</text>
          </view>
        </view>
        <up-loadmore :status="pageStatus" />
      </view>
      <view v-else
            class="empty-wrap">
            class="oa-empty">
        <up-empty mode="list"
                  text="暂无审批数据" />
      </view>
@@ -117,49 +104,52 @@
</template>
<script setup>
  import { reactive, ref } from "vue";
  import { onMounted, reactive, ref } from "vue";
  import { onShow } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import { listApprovalInstancePage } from "@/api/oa/approvalInstance.js";
  import { OA_NAV } from "@/config/oaPaths.js";
  import useUserStore from "@/store/modules/user";
  import { parseTime } from "@/utils/ruoyi";
  import {
    businessStatusClass,
    businessStatusText,
    canModifyInstance,
    stashInstanceRow,
  } from "../../_utils/approveListUtils.js";
  import {
    inferReimburseModuleKeyFromInstance,
    resolveFinReimbursementIdFromInstance,
    stashReimburseEditFromApprove,
  } from "../../_utils/reimburseApproveBridge.js";
  const EDIT_STORAGE_KEY = "oa_approve_instance_edit_row";
  const userStore = useUserStore();
  const queryParams = reactive({
    keyword: "",
  });
  const queryParams = reactive({ keyword: "" });
  const list = ref([]);
  const pageStatus = ref("loadmore");
  const page = reactive({ current: 1, size: 10, total: 0 });
  const listScrollHeight = ref(400);
  const page = reactive({
    current: 1,
    size: 10,
    total: 0,
  });
  function calcListScrollHeight() {
    const sys = uni.getSystemInfoSync();
    const statusBar = sys.statusBarHeight || 0;
    const navBar = 44;
    const toolbar = 56;
    const fabGap = 16;
    listScrollHeight.value = Math.max(
      200,
      sys.windowHeight - statusBar - navBar - toolbar - fabGap
    );
  }
  const STATUS_TEXT = {
    PENDING: "进行中",
    APPROVED: "已通过",
    REJECTED: "已驳回",
  const statusText = status => businessStatusText(status);
  const formatDateTime = val => {
    if (!val) return "-";
    return parseTime(val, "{y}-{m}-{d} {h}:{i}:{s}") || String(val);
  };
  const STATUS_TAG = {
    PENDING: "warning",
    APPROVED: "success",
    REJECTED: "error",
  };
  const statusText = status => STATUS_TEXT[status] || status || "-";
  const statusTagType = status => STATUS_TAG[status] || "info";
  const formatLevel = level => {
    if (level == null || level === "") return "-";
    return `第 ${level} çº§`;
  };
  const canModify = item => canModifyInstance(item, userStore);
  const currentApproverName = item => {
    const tasks = item?.tasks;
@@ -180,31 +170,22 @@
        dto.instanceNo = keyword;
      }
    }
    return {
      page: {
        current: page.current,
        size: page.size,
      },
      approvalInstanceDto: dto,
    };
    return { current: page.current, size: page.size, ...dto };
  };
  const getList = () => {
    if (pageStatus.value === "loading" || pageStatus.value === "nomore") return;
    pageStatus.value = "loading";
    listApprovalInstancePage(buildListParams())
      .then(res => {
        const pageData = res?.data || {};
        const records = pageData.records || [];
        const total = pageData.total ?? 0;
        if (page.current === 1) {
          list.value = records;
        } else {
          list.value = [...list.value, ...records];
        }
        page.total = total;
        if (list.value.length >= total || records.length < page.size) {
          pageStatus.value = "nomore";
@@ -214,9 +195,7 @@
        }
      })
      .catch(() => {
        if (page.current === 1) {
          list.value = [];
        }
        if (page.current === 1) list.value = [];
        pageStatus.value = "loadmore";
        uni.showToast({ title: "查询失败", icon: "none" });
      });
@@ -230,70 +209,74 @@
  };
  const loadMore = () => {
    if (pageStatus.value === "loadmore") {
      getList();
    }
    if (pageStatus.value === "loadmore") getList();
  };
  const goBack = () => {
    uni.navigateBack();
  };
  const goBack = () => uni.navigateBack();
  const goAdd = () => uni.navigateTo({ url: OA_NAV.approveListTemplateSelect });
  const goAdd = () => {
    uni.navigateTo({ url: OA_NAV.approveListTemplateSelect });
  };
  const canEdit = item =>
    item?.status === "PENDING" &&
    String(item.applicantId) === String(userStore.id);
  const goEdit = item => {
  const openDetail = item => {
    if (!item?.id) return;
    uni.setStorageSync(EDIT_STORAGE_KEY, item);
    uni.navigateTo({
      url: `${OA_NAV.approveListApply}?id=${item.id}`,
    });
    stashInstanceRow(item);
    uni.navigateTo({ url: `${OA_NAV.approveListDetail}?id=${item.id}` });
  };
  const goModify = item => {
    if (!canModify(item)) {
      uni.showToast({ title: "仅进行中的本人申请可编辑", icon: "none" });
      return;
    }
    const mk = inferReimburseModuleKeyFromInstance(item);
    if (mk) {
      const rid = resolveFinReimbursementIdFromInstance(item);
      if (rid == null) {
        uni.showToast({ title: "无法修改:缺少报销单 ID", icon: "none" });
        return;
      }
      stashReimburseEditFromApprove(mk, rid);
      uni.navigateTo({
        url: `${OA_NAV.reimburseForm}?moduleKey=${mk}&mode=edit&reimbursementId=${rid}`,
      });
      return;
    }
    if (!item?.id) return;
    stashInstanceRow(item);
    uni.navigateTo({ url: `${OA_NAV.approveListApply}?id=${item.id}` });
  };
  const handleApprove = item => {
    if (!item?.id) return;
    uni.showToast({ title: "审批详情页待对接", icon: "none" });
    if (!item.isApprove) {
      uni.showToast({ title: "当前审批无需您处理", icon: "none" });
      return;
    }
    stashInstanceRow(item);
    uni.navigateTo({ url: `${OA_NAV.approveListApprove}?id=${item.id}` });
  };
  onMounted(() => calcListScrollHeight());
  onShow(() => {
    calcListScrollHeight();
    handleSearch();
  });
</script>
<style scoped lang="scss">
  @import "@/styles/sales-common.scss";
  @import "../../_styles/oa-approval-list.scss";
  .approve-list-page {
    display: flex;
    flex-direction: column;
    min-height: 100vh;
  .active-search {
    padding-right: 4px;
  }
  .list-scroll {
  .chip-input {
    flex: 1;
    height: 0;
    padding-bottom: calc(80px + env(safe-area-inset-bottom));
    font-size: 14px;
  }
  .empty-wrap {
    padding: 48px 20px;
  }
  .action-buttons {
    display: flex;
    justify-content: flex-end;
    gap: 10px;
    margin-top: 12px;
    padding-top: 12px;
    border-top: 1px solid #f0f0f0;
  }
  .action-btn {
    min-width: 72px;
  :deep(.chip-input .u-input__content) {
    background: transparent !important;
    padding: 0 !important;
  }
</style>
src/pages/oa/ApproveManage/approve-list/template-select.vue
@@ -5,10 +5,10 @@
-->
<template>
  <view class="template-select-page sales-account">
    <PageHeader title="选择审批模板"
    <PageHeader :title="pageHeaderTitle"
                @back="goBack" />
    <view v-if="typeOptions.length"
    <view v-if="typeOptions.length && !moduleKey"
          class="step-section">
      <view class="tabs-wrap">
        <up-tabs :list="tabList"
@@ -16,6 +16,11 @@
                 line-color="#2979ff"
                 @click="onTabClick" />
      </view>
    </view>
    <view v-if="useAllTemplatesFallback && allTemplates.length"
          class="fallback-hint">
      <text>当前类型下无匹配模板,已显示全部 {{ allTemplates.length }} ä¸ªå¯ç”¨æ¨¡æ¿</text>
    </view>
    <view class="search-section">
@@ -41,11 +46,6 @@
            class="loading-wrap">
        <up-loading-icon mode="circle" />
        <text class="loading-text">加载中...</text>
      </view>
      <view v-else-if="!typeOptions.length"
            class="empty-wrap">
        <up-empty mode="list"
                  text="未获取到审批类型" />
      </view>
      <view v-else-if="displayList.length"
            class="ledger-list">
@@ -95,17 +95,25 @@
  import { computed, ref } from "vue";
  import { onLoad } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import { listApprovalTemplateByType } from "@/api/oa/approvalTemplate.js";
  import { OA_NAV } from "@/config/oaPaths.js";
  import {
    buildTypeLabelMap,
    CUSTOM_TEMPLATE_LIST_TYPE,
    fetchApprovalTemplateTypes,
    buildTypeOptionsFromTemplates,
    FALLBACK_BUSINESS_TYPE_OPTIONS,
    fetchEnabledApprovalTemplates,
    filterTemplatesByBusinessType,
    filterTemplatesByBusinessTypes,
    getBusinessTypeLabel,
    getDefaultTypeTabIndex,
    pickTabIndexWithTemplates,
  } from "../../_utils/approvalTemplateType.js";
  import {
    getApprovalModuleConfig,
    getModuleMatchingBusinessTypes,
    resolveModuleBusinessType,
  } from "../../_utils/approvalModuleRegistry.js";
  const moduleKey = ref("");
  const typeOptions = ref([]);
  const typeLabelMap = ref({});
  /** å…¨éƒ¨è‡ªå®šä¹‰å·²å¯ç”¨æ¨¡æ¿ï¼ˆlist/1 ä¸€æ¬¡æ‹‰å–) */
@@ -118,9 +126,45 @@
    typeOptions.value.map(opt => ({ name: opt.name }))
  );
  const moduleConfig = computed(() =>
    moduleKey.value ? getApprovalModuleConfig(moduleKey.value) : null
  );
  const pageHeaderTitle = computed(() => {
    if (moduleConfig.value?.label) {
      return `选择${moduleConfig.value.label}模板`;
    }
    return "选择审批模板";
  });
  const moduleBusinessTypes = computed(() => {
    if (!moduleKey.value) return [];
    return getModuleMatchingBusinessTypes(moduleKey.value, typeOptions.value);
  });
  const currentTypeOption = computed(() => typeOptions.value[activeTab.value]);
  /** æ—  moduleKey ä¸”当前 Tab ç­›ä¸åˆ°æ—¶ï¼Œå±•示全部模板避免「有数据却空白」 */
  const useAllTemplatesFallback = computed(() => {
    if (moduleKey.value) return false;
    if (!allTemplates.value.length) return false;
    const businessType = currentTypeOption.value?.value;
    if (businessType == null || businessType === "") return true;
    return filterTemplatesByBusinessType(allTemplates.value, businessType).length === 0;
  });
  const currentSource = computed(() => {
    if (moduleKey.value && moduleBusinessTypes.value.length) {
      const filtered = filterTemplatesByBusinessTypes(
        allTemplates.value,
        moduleBusinessTypes.value
      );
      if (filtered.length) return filtered;
      return allTemplates.value;
    }
    if (useAllTemplatesFallback.value) {
      return allTemplates.value;
    }
    const businessType = currentTypeOption.value?.value;
    return filterTemplatesByBusinessType(allTemplates.value, businessType);
  });
@@ -134,8 +178,17 @@
  });
  const emptyText = computed(() => {
    if (allTemplates.value.length === 0) {
      return "暂无已启用的审批模板,请先在「审批模板」中创建并启用";
    }
    if (moduleConfig.value?.label) {
      return `暂无${moduleConfig.value.label}可用模板`;
    }
    if (useAllTemplatesFallback.value) {
      return "当前类型下无匹配模板";
    }
    const typeName = currentTypeOption.value?.name || "该审批类型";
    return `暂无${typeName}下的模板`;
    return `暂无${typeName}下的模板(可切换上方类型)`;
  });
  const businessTypeText = type =>
@@ -160,41 +213,46 @@
    return count != null ? `${count} ä¸ª` : "-";
  };
  const normalizeList = data => {
    const list = Array.isArray(data)
      ? data
      : Array.isArray(data?.records)
        ? data.records
        : [];
    return list.filter(item => String(item?.enabled ?? "1") === "1");
  };
  const loadCustomTemplates = () =>
    listApprovalTemplateByType(CUSTOM_TEMPLATE_LIST_TYPE)
      .then(res => {
        allTemplates.value = normalizeList(res?.data);
      })
      .catch(() => {
        allTemplates.value = [];
        uni.showToast({ title: "加载模板列表失败", icon: "none" });
      });
  const initPage = async () => {
    loading.value = true;
    keyword.value = "";
    allTemplates.value = [];
    try {
      const [opts] = await Promise.all([
      const [opts, templates] = await Promise.all([
        fetchApprovalTemplateTypes(),
        loadCustomTemplates(),
        fetchEnabledApprovalTemplates(),
      ]);
      typeOptions.value = opts;
      typeLabelMap.value = buildTypeLabelMap(opts);
      activeTab.value = getDefaultTypeTabIndex(opts);
      let resolvedOpts = opts?.length ? opts : buildTypeOptionsFromTemplates(templates);
      if (!resolvedOpts.length) {
        resolvedOpts = [...FALLBACK_BUSINESS_TYPE_OPTIONS];
      }
      typeOptions.value = resolvedOpts;
      typeLabelMap.value = buildTypeLabelMap(resolvedOpts);
      allTemplates.value = templates;
      if (!templates.length) {
        uni.showToast({
          title: "未获取到已启用模板",
          icon: "none",
          duration: 2500,
        });
      }
      if (moduleKey.value) {
        const resolved = resolveModuleBusinessType(moduleKey.value, opts);
        const idx = opts.findIndex(
          opt => String(opt.value) === String(resolved)
        );
        activeTab.value =
          idx >= 0 ? idx : pickTabIndexWithTemplates(resolvedOpts, templates);
      } else {
        activeTab.value = pickTabIndexWithTemplates(resolvedOpts, templates);
      }
    } catch {
      typeOptions.value = [];
      typeLabelMap.value = {};
      uni.showToast({ title: "获取审批类型失败", icon: "none" });
      allTemplates.value = [];
      uni.showToast({ title: "加载模板失败", icon: "none" });
    } finally {
      loading.value = false;
    }
@@ -215,12 +273,14 @@
      uni.showToast({ title: "该模板已停用", icon: "none" });
      return;
    }
    const base = `${OA_NAV.approveListApply}?templateId=${item.id}`;
    uni.navigateTo({
      url: `${OA_NAV.approveListApply}?templateId=${item.id}`,
      url: moduleKey.value ? `${base}&moduleKey=${moduleKey.value}` : base,
    });
  };
  onLoad(() => {
  onLoad(options => {
    moduleKey.value = options?.moduleKey || "";
    initPage();
  });
</script>
@@ -234,6 +294,16 @@
    min-height: 100vh;
  }
  .fallback-hint {
    margin: 8px 12px 0;
    padding: 8px 12px;
    font-size: 12px;
    color: #e6a23c;
    background: #fdf6ec;
    border-radius: 6px;
    line-height: 1.5;
  }
  .step-section {
    background: #fff;
    border-bottom: 1px solid #f0f0f0;
src/pages/oa/ApproveManage/approve-template/detail.vue
@@ -125,6 +125,7 @@
  import PageHeader from "@/components/PageHeader.vue";
  import FooterButtons from "@/components/FooterButtons.vue";
  import { getApprovalTemplateDetail } from "@/api/oa/approvalTemplate.js";
  import { getFieldEditorTypeLabel } from "../../_utils/approvalFormField.js";
  import {
    buildTypeLabelMap,
    fetchApprovalTemplateTypes,
@@ -133,13 +134,6 @@
  const EDIT_STORAGE_KEY = "oa_approve_template_edit_row";
  const LEVEL_TEXT = ["", "一", "二", "三", "四", "五", "六", "七", "八", "九", "十"];
  const FIELD_TYPE_MAP = {
    text: "单行文本",
    textarea: "多行文本",
    number: "数字",
    date: "日期",
  };
  const templateId = ref("");
  const detail = ref(null);
@@ -179,7 +173,7 @@
    return "";
  };
  const fieldTypeLabel = type => FIELD_TYPE_MAP[type] || type || "-";
  const fieldTypeLabel = type => getFieldEditorTypeLabel(type);
  const approveTypeText = type => (type === "OR" ? "或签" : "会签");
src/pages/oa/ApproveManage/approve-template/edit.vue
@@ -26,17 +26,21 @@
                      class="name-input-inline"
                      placeholder="请输入模板名称"
                      maxlength="50"
                      clearable />
                      :disabled="isSystemTemplate"
                      :clearable="!isSystemTemplate" />
          </up-form-item>
          <up-form-item label="审批类型"
                        prop="businessType"
                        required
                        class="form-item-select"
                        :class="{ 'form-item-select--disabled': isSystemTemplate }"
                        @click="openBusinessTypeSheet">
            <up-input :model-value="businessTypeText"
                      placeholder="请选择审批类型"
                      readonly />
            <template #right>
                      readonly
                      :disabled="isSystemTemplate" />
            <template v-if="!isSystemTemplate"
                      #right>
              <up-icon name="arrow-right"
                       @click.stop="openBusinessTypeSheet" />
            </template>
@@ -62,12 +66,16 @@
        <view class="section-card">
          <view class="section-head section-head--between">
            <text class="section-title">填报配置</text>
            <view class="section-head-left">
              <text class="section-title">填报项配置</text>
              <text class="section-count">共 {{ formConfig.fields.length }} é¡¹</text>
            </view>
            <view class="head-actions">
              <text class="head-link"
                    @click="showPresetSheet = true">预设</text>
              <text class="head-link head-link--import"
                    :class="{ 'head-link--disabled': !canImportTemplate }"
                    @click="openTemplateImport">从已有模板导入</text>
              <text class="head-link head-link--primary"
                    @click="openFieldEditor()">添加</text>
                    @click="openFieldEditor()">+ æ·»åŠ å¡«æŠ¥é¡¹</text>
            </view>
          </view>
          <view class="section-body">
@@ -83,34 +91,46 @@
                  class="field-list">
              <view v-for="(field, index) in formConfig.fields"
                    :key="field.key"
                    class="field-item">
                    class="field-item"
                    :class="{ 'field-item--locked': isFieldLocked(field) }"
                    @click="onFieldItemClick(field, index)">
                <view class="field-order">{{ index + 1 }}</view>
                <view class="field-main">
                  <view class="field-title-row">
                    <text class="field-name">{{ field.label }}</text>
                    <text class="type-tag"
                          :class="fieldTypeTagClass(field.type)">
                      {{ fieldTypeLabel(field.type) }}
                    </text>
                    <text v-if="field.required"
                          class="req-tag">必填</text>
                    <view class="field-tags">
                      <text class="type-tag"
                            :class="fieldTypeTagClass(field.type)">
                        {{ fieldTypeLabel(field.type) }}
                      </text>
                      <text v-if="field.required"
                            class="req-tag">必填</text>
                    </view>
                  </view>
                  <text class="field-key">{{ field.key }}</text>
                  <text v-if="field.defaultValue"
                        class="field-default">默认:{{ field.defaultValue }}</text>
                        class="field-default">
                    é»˜è®¤ï¼š{{ formatFieldDefaultPreview(field) }}
                  </text>
                </view>
                <view class="field-actions">
                <view v-if="!isFieldLocked(field)"
                      class="field-actions"
                      @click.stop>
                  <view class="icon-btn icon-btn--edit"
                        @click="openFieldEditor(field, index)">
                        @click.stop="openFieldEditor(field, index)">
                    <up-icon name="edit-pen"
                             size="16"
                             color="#2979ff" />
                  </view>
                  <view class="icon-btn icon-btn--del"
                        @click="removeField(index)">
                        @click.stop="removeField(index)">
                    <up-icon name="trash"
                             size="16"
                             color="#f56c6c" />
                  </view>
                </view>
                <view v-else
                      class="field-lock-tag">内置</view>
              </view>
            </view>
            <view v-else
@@ -196,11 +216,11 @@
                   @cancel="goBack"
                   @confirm="handleSubmit" />
    <up-action-sheet :show="showPresetSheet"
                     title="从预设导入"
                     :actions="presetActions"
                     @select="onSelectPreset"
                     @close="showPresetSheet = false" />
    <up-action-sheet :show="showTemplateImportSheet"
                     title="从已有模板导入"
                     :actions="templateImportActions"
                     @select="onSelectImportTemplate"
                     @close="showTemplateImportSheet = false" />
    <up-popup :show="showFieldEditor"
              mode="bottom"
@@ -208,71 +228,255 @@
              @close="closeFieldEditor">
      <view class="field-editor">
        <view class="sheet-handle" />
        <text class="editor-title">{{ editingFieldIndex >= 0 ? "编辑填报项" : "添加填报项" }}</text>
        <view class="editor-form">
          <view class="editor-row">
            <text class="editor-label required">字段名称</text>
            <up-input v-model="fieldDraft.label"
                      placeholder="请输入"
                      clearable />
          </view>
          <view class="editor-row editor-row--block">
            <text class="editor-label required">字段类型</text>
            <view class="type-chip-grid">
              <view v-for="opt in FIELD_TYPE_OPTIONS"
                    :key="opt.value"
                    class="type-chip"
                    :class="{ active: fieldDraft.type === opt.value }"
                    @click="selectFieldType(opt.value)">
                {{ opt.name }}
        <view class="editor-header">
          <text class="editor-title">{{ editingFieldIndex >= 0 ? "编辑填报项" : "添加填报项" }}</text>
          <text class="editor-subtitle">配置字段属性、校验与默认值</text>
        </view>
        <scroll-view class="editor-scroll"
                     scroll-y
                     :show-scrollbar="false">
          <view class="editor-form">
            <view class="editor-section-card">
              <view class="editor-section-head">
                <text class="editor-section-title">基础信息</text>
              </view>
              <view class="editor-cell">
                <text class="editor-label required">显示名称</text>
                <view class="editor-input-box">
                  <up-input v-model="fieldDraft.label"
                            placeholder="如:报销说明"
                            border="none"
                            clearable />
                </view>
              </view>
              <view class="editor-cell">
                <text class="editor-label required">字段标识</text>
                <view class="editor-input-box">
                  <up-input v-model="fieldDraft.key"
                            placeholder="如:summary"
                            border="none"
                            clearable />
                </view>
              </view>
              <view class="editor-cell editor-cell--tap"
                    @click="openFieldTypePicker">
                <text class="editor-label required">控件类型</text>
                <view class="picker-value-row">
                  <text class="picker-value"
                        :class="{ 'picker-value--placeholder': !fieldDraft.type }">
                    {{ fieldDraftTypeText || "请选择" }}
                  </text>
                  <up-icon name="arrow-right"
                           size="14"
                           color="#b0b8c4" />
                </view>
              </view>
            </view>
            <view class="editor-section-card">
              <view class="editor-section-head">
                <text class="editor-section-title">校验与格式</text>
              </view>
              <view class="editor-cell editor-cell--switch">
                <view class="switch-label-wrap">
                  <text class="editor-label">是否必填</text>
                  <text class="switch-hint">提交审批时须填写该项</text>
                </view>
                <up-switch v-model="fieldDraft.required"
                           active-color="#2979ff" />
              </view>
            </view>
            <view v-if="isSelectDraft"
                  class="editor-section-card">
              <view class="editor-section-head">
                <text class="editor-section-title">下拉选项</text>
              </view>
              <view class="editor-cell editor-cell--tap"
                    @click="openOptionSourcePicker">
                <text class="editor-label">选项来源</text>
                <view class="picker-value-row">
                  <text class="picker-value">{{ fieldDraftOptionSourceText }}</text>
                  <up-icon name="arrow-right"
                           size="14"
                           color="#b0b8c4" />
                </view>
              </view>
              <view v-if="fieldDraft.optionSource === 'manual'"
                    class="manual-options">
                <text class="manual-options-title">手动选项</text>
                <view class="manual-options-table">
                  <view class="option-table-head">
                    <text class="option-col option-col--idx" />
                    <text class="option-col option-col--label">显示文本</text>
                    <text class="option-col option-col--value">选项值</text>
                    <text class="option-col option-col--action" />
                  </view>
                  <view v-for="(opt, optIndex) in fieldDraft.options"
                        :key="optIndex"
                        class="option-card">
                    <text class="option-idx">{{ optIndex + 1 }}</text>
                    <view class="option-input-wrap">
                      <up-input v-model="opt.label"
                                placeholder="如:工作日加班"
                                border="none"
                                clearable />
                    </view>
                    <view class="option-input-wrap option-input-wrap--value">
                      <up-input v-model="opt.value"
                                placeholder="如:0"
                                border="none"
                                clearable />
                    </view>
                    <view class="option-del"
                          hover-class="option-del--active"
                          @click.stop="removeDraftOption(optIndex)">
                      <up-icon name="trash"
                               size="16"
                               color="#f56c6c" />
                    </view>
                  </view>
                </view>
                <view class="add-option-btn"
                      hover-class="add-option-btn--active"
                      @click="addDraftOption">
                  <up-icon name="plus-circle"
                           size="16"
                           color="#2979ff" />
                  <text>添加选项</text>
                </view>
              </view>
              <view v-else
                    class="option-source-tip">
                <up-icon name="info-circle"
                         size="14"
                         color="#909399" />
                <text>发起审批时将自动加载{{ fieldDraftOptionSourceText }}</text>
              </view>
            </view>
            <view class="editor-section-card">
              <view class="editor-section-head">
                <text class="editor-section-title">默认值</text>
              </view>
              <text class="default-hint">
                é€‰æ‹©è¯¥æ¨¡æ¿æäº¤å®¡æ‰¹æ—¶è‡ªåŠ¨é¢„å¡«ï¼Œç”¨æˆ·ä»å¯ä¿®æ”¹
              </text>
              <view class="editor-cell editor-cell--value">
                <up-textarea v-if="fieldDraft.type === 'textarea'"
                             v-model="fieldDraft.defaultValue"
                             placeholder="选填"
                             maxlength="500"
                             border="surround"
                             height="72" />
                <view v-else-if="fieldDraft.type === 'date'"
                      class="picker-value-row picker-value-row--tap"
                      @click="openDefaultDatePicker">
                  <text class="picker-value"
                        :class="{ 'picker-value--placeholder': !fieldDraft.defaultValue }">
                    {{ fieldDraft.defaultValue || "选择日期" }}
                  </text>
                  <up-icon name="calendar"
                           size="18"
                           color="#909399" />
                </view>
                <view v-else-if="isDatetimerangeDraft"
                      class="daterange-default-wrap">
                  <view class="daterange-default-item"
                        @click="openDefaultRangePicker('start')">
                    <text class="daterange-default-label">开始时间</text>
                    <view class="picker-value-row picker-value-row--tap">
                      <text class="picker-value"
                            :class="{ 'picker-value--placeholder': !defaultRangeStart }">
                        {{ defaultRangeStart || "选择开始时间" }}
                      </text>
                      <up-icon name="calendar"
                               size="18"
                               color="#909399" />
                    </view>
                  </view>
                  <view class="daterange-default-item"
                        @click="openDefaultRangePicker('end')">
                    <text class="daterange-default-label">结束时间</text>
                    <view class="picker-value-row picker-value-row--tap">
                      <text class="picker-value"
                            :class="{ 'picker-value--placeholder': !defaultRangeEnd }">
                        {{ defaultRangeEnd || "选择结束时间" }}
                      </text>
                      <up-icon name="calendar"
                               size="18"
                               color="#909399" />
                    </view>
                  </view>
                </view>
                <view v-else-if="isSelectDraft"
                      class="picker-value-row picker-value-row--tap"
                      @click="openDefaultSelectSheet">
                  <text class="picker-value"
                        :class="{ 'picker-value--placeholder': !fieldDraft.defaultValue }">
                    {{ defaultSelectDisplayText || "选填" }}
                  </text>
                  <up-icon name="arrow-right"
                           size="14"
                           color="#b0b8c4" />
                </view>
                <view v-else
                      class="editor-input-box">
                  <up-input v-model="fieldDraft.defaultValue"
                            :type="fieldDraft.type === 'number' ? 'digit' : 'text'"
                            placeholder="选填"
                            border="none"
                            clearable />
                </view>
              </view>
            </view>
          </view>
          <view class="editor-row editor-row--block">
            <text class="editor-label">默认值</text>
            <up-textarea v-if="fieldDraft.type === 'textarea'"
                         v-model="fieldDraft.defaultValue"
                         placeholder="选填"
                         maxlength="500"
                         height="72" />
            <view v-else-if="fieldDraft.type === 'date'"
                  class="default-date-row"
                  @click="showDefaultDatePicker = true">
              <up-input :model-value="fieldDraft.defaultValue"
                        placeholder="选择日期"
                        readonly />
              <up-icon name="calendar"
                       size="18"
                       color="#909399" />
            </view>
            <up-input v-else
                      v-model="fieldDraft.defaultValue"
                      :type="fieldDraft.type === 'number' ? 'digit' : 'text'"
                      placeholder="选填"
                      clearable />
          </view>
          <view class="editor-row editor-row--switch">
            <text class="editor-label">是否必填</text>
            <up-switch v-model="fieldDraft.required" />
          </view>
        </view>
        </scroll-view>
        <view class="editor-footer">
          <view class="editor-btn editor-btn--cancel"
                @click="closeFieldEditor">取消</view>
          <view class="editor-btn editor-btn--confirm"
                @click="confirmFieldEditor">确定</view>
        </view>
        <view v-if="inlinePickerShow"
              class="editor-picker-layer">
          <view class="editor-picker-mask"
                @click="closeInlinePicker" />
          <view class="editor-picker-panel">
            <view class="editor-picker-head">
              <text class="editor-picker-cancel"
                    @click="closeInlinePicker">取消</text>
              <text class="editor-picker-title">{{ inlinePickerTitle }}</text>
              <text class="editor-picker-placeholder" />
            </view>
            <scroll-view class="editor-picker-scroll"
                         scroll-y>
              <view v-for="(item, pickerIndex) in inlinePickerOptions"
                    :key="`${inlinePickerMode}-${pickerIndex}-${item.value}`"
                    class="editor-picker-item"
                    :class="{ 'editor-picker-item--active': isInlinePickerItemActive(item) }"
                    @click="onInlinePickerSelect(item)">
                <text>{{ item.name }}</text>
                <up-icon v-if="isInlinePickerItemActive(item)"
                         name="checkmark"
                         size="18"
                         color="#2979ff" />
              </view>
            </scroll-view>
          </view>
        </view>
      </view>
    </up-popup>
    <up-popup :show="showDefaultDatePicker"
              mode="bottom"
              @close="showDefaultDatePicker = false">
              @close="closeDefaultDatePicker">
      <up-datetime-picker :show="true"
                          v-model="defaultDateTs"
                          mode="date"
                          @confirm="onDefaultDateConfirm"
                          @cancel="showDefaultDatePicker = false" />
                          :mode="defaultDatePickerMode"
                          @confirm="onDefaultDatePickerConfirm"
                          @cancel="closeDefaultDatePicker" />
    </up-popup>
    <up-popup :show="showUserPicker"
@@ -326,67 +530,65 @@
  import FooterButtons from "@/components/FooterButtons.vue";
  import {
    addApprovalTemplate,
    getApprovalTemplateDetail,
    listApprovalTemplatePage,
    updateApprovalTemplate,
  } from "@/api/oa/approvalTemplate.js";
  import { getDept } from "@/api/collaborativeApproval/approvalProcess.js";
  import { userListNoPageByTenantId } from "@/api/system/user";
  import { formatDateToYMD } from "@/utils/ruoyi";
  import { fetchApprovalTemplateTypes } from "../../_utils/approvalTemplateType.js";
  import {
    buildFieldConfigPayload,
    createEmptyFieldOption,
    parseApprovalFormConfig,
    FIELD_EDITOR_TYPE_OPTIONS,
    FIELD_OPTION_SOURCE_OPTIONS,
    getFieldEditorTypeLabel,
    getFieldOptionLabel,
    getFieldOptionSource,
    getFieldOptionSourceLabel,
    isDatetimerangeField,
    isSelectField,
    formatDatetimerangeDisplay,
    formatFieldDateValue,
    joinDatetimerangeValue,
    parseDatetimerangeValue,
    parseFieldDateToTs,
    resolveFieldOptions,
  } from "../../_utils/approvalFormField.js";
  import {
    fetchApprovalTemplateTypes,
    isSystemApprovalTemplate,
  } from "../../_utils/approvalTemplateType.js";
  const EDIT_STORAGE_KEY = "oa_approve_template_edit_row";
  const LEVEL_TEXT = ["", "一", "二", "三", "四", "五", "六", "七", "八", "九", "十"];
  const FORM_PRESETS = [
    {
      name: "通用报销",
      prompt: "请填写报销事由、金额等",
      fields: [
        { key: "reason", label: "报销事由", type: "textarea", required: true },
        { key: "amount", label: "报销金额(元)", type: "number", required: true },
        { key: "applyDate", label: "申请日期", type: "date", required: true },
      ],
    },
    {
      name: "请假申请",
      prompt: "请填写请假类型、起止时间等",
      fields: [
        { key: "leaveType", label: "请假类型", type: "text", required: true },
        { key: "startTime", label: "开始时间", type: "date", required: true },
        { key: "endTime", label: "结束时间", type: "date", required: true },
        { key: "reason", label: "请假事由", type: "textarea", required: true },
      ],
    },
    {
      name: "采购申请",
      prompt: "请填写采购事由、预估金额等",
      fields: [
        { key: "title", label: "采购事由", type: "textarea", required: true },
        { key: "amount", label: "预估金额(元)", type: "number", required: true },
      ],
    },
  ];
  const FIELD_TYPE_OPTIONS = [
    { name: "单行文本", value: "text" },
    { name: "多行文本", value: "textarea" },
    { name: "数字", value: "number" },
    { name: "日期", value: "date" },
  ];
  const formRef = ref();
  const submitting = ref(false);
  const userList = ref([]);
  const templateId = ref(null);
  const showPresetSheet = ref(false);
  const showTemplateImportSheet = ref(false);
  const importTemplateList = ref([]);
  const showFieldEditor = ref(false);
  const inlinePickerShow = ref(false);
  const inlinePickerTitle = ref("");
  const inlinePickerOptions = ref([]);
  const inlinePickerMode = ref("");
  const showUserPicker = ref(false);
  const showDefaultDatePicker = ref(false);
  const defaultDatePickerMode = ref("date");
  const defaultRangePickerPart = ref("start");
  const defaultDateTs = ref(Date.now());
  const deptList = ref([]);
  const editingFieldIndex = ref(-1);
  const editingNodeIndex = ref(-1);
  const pickerSelectedIds = ref([]);
  /** ç³»ç»Ÿæ¨¡æ¿åŠ è½½æ—¶é”å®šçš„å¡«æŠ¥é¡¹ key,不可编辑/删除 */
  const lockedFieldKeys = ref(new Set());
  const form = reactive({
    templateName: "",
@@ -403,9 +605,12 @@
  const fieldDraft = reactive({
    label: "",
    key: "",
    type: "text",
    defaultValue: "",
    required: true,
    optionSource: "manual",
    options: [createEmptyFieldOption()],
  });
  let nodeKeySeed = 1;
@@ -451,10 +656,57 @@
    return matched?.name || "";
  });
  const presetActions = FORM_PRESETS.map(item => ({
    name: item.name,
    value: item.name,
  }));
  const canImportTemplate = computed(() => !isSystemTemplate.value);
  const templateImportActions = computed(() =>
    importTemplateList.value.map(item => {
      const typeTag = isSystemApprovalTemplate(item) ? "系统" : "自定义";
      return {
        name: `【${typeTag}】${item.templateName || `模板${item.id}`}`,
        value: String(item.id),
      };
    })
  );
  const isSelectDraft = computed(() => isSelectField(fieldDraft));
  const isDatetimerangeDraft = computed(() => isDatetimerangeField(fieldDraft));
  const defaultRangeParts = computed(() =>
    parseDatetimerangeValue(fieldDraft.defaultValue)
  );
  const defaultRangeStart = computed(() => defaultRangeParts.value.start);
  const defaultRangeEnd = computed(() => defaultRangeParts.value.end);
  const fieldDraftTypeText = computed(() => getFieldEditorTypeLabel(fieldDraft.type));
  const fieldDraftOptionSourceText = computed(() =>
    getFieldOptionSourceLabel(fieldDraft.optionSource)
  );
  const defaultSelectActions = computed(() => {
    const options = resolveFieldOptions(fieldDraft, {
      users: userList.value,
      depts: deptList.value,
    });
    return [
      { name: "不设置", value: "" },
      ...options.map(opt => ({
        name: opt.label,
        value: opt.value,
      })),
    ];
  });
  const defaultSelectDisplayText = computed(() => {
    if (!fieldDraft.defaultValue) return "";
    return (
      getFieldOptionLabel(fieldDraft, fieldDraft.defaultValue) ||
      String(fieldDraft.defaultValue)
    );
  });
  const enabledBool = computed({
    get: () => form.enabled === "1",
@@ -465,22 +717,14 @@
  const isEditMode = computed(() => templateId.value != null && templateId.value !== "");
  const isSystemTemplate = computed(() => isSystemApprovalTemplate(form));
  const isFieldLocked = field =>
    isSystemTemplate.value && lockedFieldKeys.value.has(field?.key);
  const pageTitle = computed(() =>
    isEditMode.value ? "编辑审批模板" : "新建审批模板"
  );
  const parseFormConfig = raw => {
    if (!raw) return { prompt: "", fields: [] };
    try {
      const obj = typeof raw === "string" ? JSON.parse(raw) : raw;
      return {
        prompt: obj?.prompt || "",
        fields: Array.isArray(obj?.fields) ? obj.fields.map(f => ({ ...f })) : [],
      };
    } catch {
      return { prompt: "", fields: [] };
    }
  };
  const mapNodesFromRow = nodes => {
    if (!Array.isArray(nodes) || !nodes.length) {
@@ -515,9 +759,12 @@
    form.enabled = String(row.enabled ?? "1");
    form.description = row.description || "";
    const config = parseFormConfig(row.formConfig);
    const config = parseApprovalFormConfig(row.formConfig);
    formConfig.prompt = config.prompt;
    formConfig.fields = config.fields;
    lockedFieldKeys.value = isSystemApprovalTemplate(row)
      ? new Set(config.fields.map(f => f.key).filter(Boolean))
      : new Set();
    flowNodes.value = mapNodesFromRow(row.nodes);
  };
@@ -530,8 +777,14 @@
  const levelLabel = n => LEVEL_TEXT[n] || String(n);
  const fieldTypeLabel = type =>
    FIELD_TYPE_OPTIONS.find(item => item.value === type)?.name || type;
  const fieldTypeLabel = type => getFieldEditorTypeLabel(type);
  const formatFieldDefaultPreview = field => {
    if (isDatetimerangeField(field)) {
      return formatDatetimerangeDisplay(field.defaultValue) || field.defaultValue;
    }
    return field.defaultValue;
  };
  const fieldTypeTagClass = type => {
    const map = {
@@ -539,6 +792,8 @@
      textarea: "type-tag--area",
      number: "type-tag--num",
      date: "type-tag--date",
      datetimerange: "type-tag--date",
      select: "type-tag--select",
    };
    return map[type] || "type-tag--text";
  };
@@ -548,6 +803,7 @@
  };
  const openBusinessTypeSheet = () => {
    if (isSystemTemplate.value) return;
    if (!businessTypeOptions.value.length) {
      uni.showToast({ title: "审批类型加载中", icon: "none" });
      return;
@@ -561,51 +817,316 @@
    formRef.value?.validateField?.("businessType");
  };
  const onSelectPreset = action => {
    const preset = FORM_PRESETS.find(item => item.name === action.value);
    if (!preset) return;
    formConfig.prompt = preset.prompt;
    formConfig.fields = preset.fields.map(field => ({ ...field }));
    showPresetSheet.value = false;
    uni.showToast({ title: "已导入预设", icon: "success" });
  const applyImportedFormConfig = (config, sourceName = "") => {
    const parsed = {
      prompt: config?.prompt || "",
      fields: (config?.fields || []).map(field => ({ ...field })),
    };
    formConfig.prompt = parsed.prompt;
    formConfig.fields = parsed.fields;
    const tip = sourceName ? `已导入「${sourceName}」` : "已导入填报配置";
    uni.showToast({ title: tip, icon: "success" });
  };
  const selectFieldType = type => {
    if (fieldDraft.type === type) return;
    fieldDraft.type = type;
  const doImportFormConfig = (config, sourceName) => {
    const hasExisting =
      !!formConfig.prompt?.trim() || formConfig.fields.length > 0;
    if (!hasExisting) {
      applyImportedFormConfig(config, sourceName);
      return;
    }
    uni.showModal({
      title: "导入确认",
      content: `将使用「${sourceName}」的填报配置覆盖当前内容,是否继续?`,
      success: res => {
        if (res.confirm) {
          applyImportedFormConfig(config, sourceName);
        }
      },
    });
  };
  const applyTemplateImport = templateIdValue => {
    const row = importTemplateList.value.find(
      item => String(item.id) === String(templateIdValue)
    );
    const sourceName = row?.templateName || "所选模板";
    const applyFromDetail = detail => {
      const config = parseApprovalFormConfig(detail?.formConfig);
      if (!config.fields.length && !config.prompt) {
        uni.showToast({ title: "该模板无填报配置", icon: "none" });
        return;
      }
      doImportFormConfig(config, sourceName);
    };
    if (row?.formConfig) {
      applyFromDetail(row);
      return;
    }
    uni.showLoading({ title: "加载配置...", mask: true });
    getApprovalTemplateDetail(templateIdValue)
      .then(res => applyFromDetail(res?.data))
      .catch(() => {
        uni.showToast({ title: "获取模板配置失败", icon: "none" });
      })
      .finally(() => {
        uni.hideLoading();
      });
  };
  const openTemplateImport = () => {
    if (!canImportTemplate.value) {
      uni.showToast({ title: "系统内置模板不可导入", icon: "none" });
      return;
    }
    uni.showLoading({ title: "加载中...", mask: true });
    listApprovalTemplatePage({
      page: { current: 1, size: 200 },
      approvalTemplateDto: {},
    })
      .then(res => {
        const records = res?.data?.records || [];
        importTemplateList.value = records.filter(
          item =>
            item?.id != null && String(item.id) !== String(templateId.value)
        );
        if (!importTemplateList.value.length) {
          uni.showToast({ title: "暂无可导入的模板", icon: "none" });
          return;
        }
        showTemplateImportSheet.value = true;
      })
      .catch(() => {
        uni.showToast({ title: "加载模板列表失败", icon: "none" });
      })
      .finally(() => {
        uni.hideLoading();
      });
  };
  const onSelectImportTemplate = action => {
    showTemplateImportSheet.value = false;
    const value = String(action?.value ?? "");
    if (!value) return;
    applyTemplateImport(value);
  };
  const resetFieldDraft = () => {
    fieldDraft.label = "";
    fieldDraft.key = "";
    fieldDraft.type = "text";
    fieldDraft.defaultValue = "";
    fieldDraft.required = true;
    fieldDraft.optionSource = "manual";
    fieldDraft.options = [createEmptyFieldOption()];
  };
  const onDefaultDateConfirm = e => {
    fieldDraft.defaultValue = formatDateToYMD(e.value);
  const resolveActionValue = (action, options) => {
    if (action?.value !== undefined && action?.value !== null) {
      return action.value;
    }
    const name = action?.name;
    if (name == null) return undefined;
    return options.find(opt => opt.name === name)?.value;
  };
  const onSelectFieldType = action => {
    const nextType = resolveActionValue(action, FIELD_EDITOR_TYPE_OPTIONS);
    if (!nextType || fieldDraft.type === nextType) return;
    fieldDraft.type = nextType;
    fieldDraft.defaultValue = "";
    if (!isSelectField(fieldDraft)) {
      fieldDraft.optionSource = "manual";
      fieldDraft.options = [createEmptyFieldOption()];
    } else if (!fieldDraft.options?.length) {
      fieldDraft.options = [createEmptyFieldOption()];
    }
  };
  const openInlinePicker = (title, options, mode) => {
    inlinePickerTitle.value = title;
    inlinePickerOptions.value = options;
    inlinePickerMode.value = mode;
    inlinePickerShow.value = true;
  };
  const closeInlinePicker = () => {
    inlinePickerShow.value = false;
    inlinePickerMode.value = "";
    inlinePickerOptions.value = [];
  };
  const isInlinePickerItemActive = item => {
    if (inlinePickerMode.value === "fieldType") {
      return String(fieldDraft.type) === String(item.value);
    }
    if (inlinePickerMode.value === "optionSource") {
      return String(fieldDraft.optionSource) === String(item.value);
    }
    if (inlinePickerMode.value === "defaultValue") {
      const val = fieldDraft.defaultValue;
      if (val === "" || val === undefined || val === null) {
        return item.value === "" || item.value === undefined || item.value === null;
      }
      return String(val) === String(item.value);
    }
    return false;
  };
  const onInlinePickerSelect = item => {
    if (inlinePickerMode.value === "fieldType") {
      onSelectFieldType(item);
    } else if (inlinePickerMode.value === "optionSource") {
      onSelectOptionSource(item);
    } else if (inlinePickerMode.value === "defaultValue") {
      onSelectDefaultOption(item);
    }
    closeInlinePicker();
  };
  const openFieldTypePicker = () => {
    openInlinePicker(
      "控件类型",
      FIELD_EDITOR_TYPE_OPTIONS.map(item => ({
        name: item.name,
        value: item.value,
      })),
      "fieldType"
    );
  };
  const onSelectOptionSource = action => {
    const nextSource = resolveActionValue(action, FIELD_OPTION_SOURCE_OPTIONS);
    if (!nextSource) return;
    fieldDraft.optionSource = nextSource;
    fieldDraft.defaultValue = "";
    if (nextSource === "manual" && !fieldDraft.options?.length) {
      fieldDraft.options = [createEmptyFieldOption()];
    }
  };
  const openOptionSourcePicker = () => {
    openInlinePicker(
      "选项来源",
      FIELD_OPTION_SOURCE_OPTIONS.map(item => ({
        name: item.name,
        value: item.value,
      })),
      "optionSource"
    );
  };
  const addDraftOption = () => {
    fieldDraft.options.push(createEmptyFieldOption());
  };
  const removeDraftOption = index => {
    if (fieldDraft.options.length <= 1) {
      fieldDraft.options[0] = createEmptyFieldOption();
      return;
    }
    fieldDraft.options.splice(index, 1);
  };
  const openDefaultSelectSheet = () => {
    const options = resolveFieldOptions(fieldDraft, {
      users: userList.value,
      depts: deptList.value,
    });
    if (!options.length) {
      uni.showToast({ title: "请先配置下拉选项", icon: "none" });
      return;
    }
    openInlinePicker("默认值", defaultSelectActions.value, "defaultValue");
  };
  const onSelectDefaultOption = action => {
    fieldDraft.defaultValue =
      action.value === undefined || action.value === null
        ? ""
        : String(action.value);
  };
  const closeDefaultDatePicker = () => {
    showDefaultDatePicker.value = false;
    defaultDatePickerMode.value = "date";
    defaultRangePickerPart.value = "start";
  };
  const openDefaultDatePicker = () => {
    defaultDatePickerMode.value = "date";
    const parsed = Date.parse(fieldDraft.defaultValue);
    defaultDateTs.value = Number.isNaN(parsed) ? Date.now() : parsed;
    showDefaultDatePicker.value = true;
  };
  const openDefaultRangePicker = part => {
    defaultDatePickerMode.value = "datetime";
    defaultRangePickerPart.value = part;
    const parts = parseDatetimerangeValue(fieldDraft.defaultValue);
    const val = part === "start" ? parts.start : parts.end;
    defaultDateTs.value = parseFieldDateToTs(val) ?? Date.now();
    showDefaultDatePicker.value = true;
  };
  const onDefaultDatePickerConfirm = e => {
    const ts = e?.value ?? defaultDateTs.value;
    if (defaultDatePickerMode.value === "datetime") {
      const parts = parseDatetimerangeValue(fieldDraft.defaultValue);
      const formatted = formatFieldDateValue({ type: "datetime" }, ts);
      fieldDraft.defaultValue = joinDatetimerangeValue(
        defaultRangePickerPart.value === "start" ? formatted : parts.start,
        defaultRangePickerPart.value === "end" ? formatted : parts.end
      );
    } else {
      fieldDraft.defaultValue = formatDateToYMD(ts);
    }
    closeDefaultDatePicker();
  };
  const onFieldItemClick = (field, index) => {
    if (isFieldLocked(field)) return;
    openFieldEditor(field, index);
  };
  const openFieldEditor = (field, index = -1) => {
    if (field && isFieldLocked(field)) {
      uni.showToast({ title: "系统内置填报项不可修改", icon: "none" });
      return;
    }
    editingFieldIndex.value = index;
    if (field) {
      fieldDraft.label = field.label;
      fieldDraft.label = field.label || "";
      fieldDraft.key = field.key || "";
      fieldDraft.type = field.type || "text";
      fieldDraft.defaultValue = field.defaultValue ?? "";
      fieldDraft.required = !!field.required;
      fieldDraft.optionSource = getFieldOptionSource(field);
      fieldDraft.options = normalizeDraftOptions(field);
    } else {
      fieldDraft.label = "";
      fieldDraft.type = "text";
      fieldDraft.defaultValue = "";
      fieldDraft.required = true;
      resetFieldDraft();
    }
    if (fieldDraft.type === "date" && fieldDraft.defaultValue) {
      const parsed = Date.parse(fieldDraft.defaultValue);
      defaultDateTs.value = Number.isNaN(parsed) ? Date.now() : parsed;
    } else {
      defaultDateTs.value = Date.now();
    }
    defaultDateTs.value = Date.now();
    showFieldEditor.value = true;
  };
  const closeFieldEditor = () => {
    closeInlinePicker();
    showFieldEditor.value = false;
    editingFieldIndex.value = -1;
  };
  const normalizeDraftOptions = field => {
    const options = field?.options;
    if (!Array.isArray(options) || !options.length) {
      return [createEmptyFieldOption()];
    }
    return options.map(opt => ({
      label: opt?.label ?? "",
      value: opt?.value != null ? String(opt.value) : "",
    }));
  };
  const buildFieldKey = label => {
@@ -622,22 +1143,46 @@
  };
  const confirmFieldEditor = () => {
    if (!fieldDraft.label?.trim()) {
      uni.showToast({ title: "请输入字段名称", icon: "none" });
    if (
      editingFieldIndex.value >= 0 &&
      isFieldLocked(formConfig.fields[editingFieldIndex.value])
    ) {
      uni.showToast({ title: "系统内置填报项不可修改", icon: "none" });
      return;
    }
    const defaultValue = String(fieldDraft.defaultValue ?? "").trim();
    if (!fieldDraft.label?.trim()) {
      uni.showToast({ title: "请输入显示名称", icon: "none" });
      return;
    }
    const existingKey =
      editingFieldIndex.value >= 0
        ? formConfig.fields[editingFieldIndex.value]?.key
        : null;
    const payload = {
      key: existingKey || buildFieldKey(fieldDraft.label),
      label: fieldDraft.label.trim(),
      type: fieldDraft.type,
      required: !!fieldDraft.required,
      defaultValue,
    };
    const draftKey = fieldDraft.key?.trim() || existingKey || buildFieldKey(fieldDraft.label);
    if (!draftKey) {
      uni.showToast({ title: "请输入字段标识", icon: "none" });
      return;
    }
    const duplicateKey = formConfig.fields.some(
      (item, idx) => item.key === draftKey && idx !== editingFieldIndex.value
    );
    if (duplicateKey) {
      uni.showToast({ title: "字段标识已存在", icon: "none" });
      return;
    }
    if (isSelectField(fieldDraft) && fieldDraft.optionSource === "manual") {
      const validOptions = (fieldDraft.options || []).filter(
        opt => opt.label?.trim() && opt.value?.trim()
      );
      if (!validOptions.length) {
        uni.showToast({ title: "请至少配置一个下拉选项", icon: "none" });
        return;
      }
    }
    const payload = buildFieldConfigPayload(
      { ...fieldDraft, key: draftKey },
      existingKey
    );
    if (editingFieldIndex.value >= 0) {
      formConfig.fields.splice(editingFieldIndex.value, 1, payload);
    } else {
@@ -647,6 +1192,11 @@
  };
  const removeField = index => {
    const field = formConfig.fields[index];
    if (isFieldLocked(field)) {
      uni.showToast({ title: "系统内置填报项不可删除", icon: "none" });
      return;
    }
    formConfig.fields.splice(index, 1);
  };
@@ -850,6 +1400,13 @@
      .catch(() => {
        userList.value = [];
      });
    getDept()
      .then(res => {
        deptList.value = res?.data || [];
      })
      .catch(() => {
        deptList.value = [];
      });
  });
</script>
@@ -899,6 +1456,12 @@
    }
  }
  .section-head-left {
    display: flex;
    flex-direction: column;
    gap: 4px;
  }
  .section-title {
    font-size: 15px;
    font-weight: 600;
@@ -906,6 +1469,12 @@
    padding-left: 10px;
    border-left: 3px solid $primary;
    line-height: 1.2;
  }
  .section-count {
    font-size: 12px;
    color: $text-muted;
    padding-left: 13px;
  }
  .head-actions {
@@ -918,10 +1487,34 @@
    font-size: 14px;
    color: $text-secondary;
    &--primary {
      color: $primary;
      font-weight: 500;
    &--import {
      color: $text-secondary;
      padding: 6px 12px;
      border: 1px solid #dce3ed;
      border-radius: 8px;
      background: #fff;
      font-size: 13px;
    }
    &--disabled {
      color: #c0c4cc;
      border-color: #ebeef5;
      background: #f5f7fa;
    }
    &--primary {
      color: #fff;
      font-weight: 500;
      padding: 6px 14px;
      border: none;
      border-radius: 8px;
      background: linear-gradient(135deg, #4d8dff 0%, #2979ff 100%);
      box-shadow: 0 2px 8px rgba(41, 121, 255, 0.25);
    }
  }
  :deep(.form-item-select--disabled .u-form-item__body) {
    opacity: 0.65;
  }
  .section-body {
@@ -1141,11 +1734,36 @@
  .field-item {
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 10px 12px;
    background: #f8fafc;
    gap: 12px;
    padding: 14px;
    background: #fff;
    border-radius: $radius-md;
    border: 1px solid #eef2f6;
    border: 1px solid #e8eef5;
    box-shadow: 0 1px 4px rgba(31, 45, 61, 0.04);
    transition: border-color 0.2s, box-shadow 0.2s;
    &:active:not(.field-item--locked) {
      border-color: #c6daf5;
      box-shadow: 0 2px 8px rgba(41, 121, 255, 0.08);
    }
    &--locked {
      background: #fafbfd;
    }
  }
  .field-order {
    width: 28px;
    height: 28px;
    border-radius: 8px;
    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
    color: #fff;
    font-size: 13px;
    font-weight: 600;
    display: flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
  }
  .field-main {
@@ -1156,14 +1774,40 @@
  .field-title-row {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 6px;
    justify-content: space-between;
    gap: 8px;
    margin-bottom: 4px;
  }
  .field-name {
    font-size: 15px;
    font-weight: 600;
    color: $text;
    flex: 1;
    min-width: 0;
  }
  .field-tags {
    display: flex;
    align-items: center;
    gap: 6px;
    flex-shrink: 0;
  }
  .field-key {
    display: block;
    font-size: 12px;
    color: $text-muted;
    font-family: ui-monospace, monospace;
  }
  .field-lock-tag {
    flex-shrink: 0;
    font-size: 11px;
    color: #909399;
    padding: 4px 8px;
    background: #f0f2f5;
    border-radius: 4px;
  }
  .type-tag {
@@ -1189,6 +1833,11 @@
    &--date {
      color: #18a058;
      background: #e8faf0;
    }
    &--select {
      color: #9c27b0;
      background: #f6edfc;
    }
  }
@@ -1231,10 +1880,13 @@
  }
  .empty-mini {
    padding: 20px 0;
    padding: 32px 16px;
    text-align: center;
    font-size: 13px;
    color: $text-muted;
    background: #fafbfd;
    border: 1px dashed #dce8f5;
    border-radius: 10px;
  }
  .flow-wrap {
@@ -1410,65 +2062,433 @@
  }
  .sheet-handle {
    width: 40px;
    width: 36px;
    height: 4px;
    margin: 10px auto 6px;
    background: #e4e7ed;
    margin: 10px auto 4px;
    background: #d8dde6;
    border-radius: 2px;
  }
  .field-editor,
  .field-editor .sheet-handle {
    background: #c8ced8;
  }
  .field-editor {
    position: relative;
    display: flex;
    flex-direction: column;
    max-height: 88vh;
    background: #f5f7fb;
    border-radius: 16px 16px 0 0;
    overflow: hidden;
  }
  .user-picker {
    position: relative;
    padding: 0 18px calc(18px + env(safe-area-inset-bottom));
    background: #fff;
    max-height: 85vh;
  }
  .editor-title {
  .editor-header {
    padding: 4px 20px 12px;
    background: #fff;
    text-align: center;
    border-bottom: 1px solid #f0f2f5;
  }
  .editor-subtitle {
    display: block;
    margin-top: 4px;
    font-size: 12px;
    color: $text-muted;
  }
  .editor-picker-layer {
    position: absolute;
    left: 0;
    right: 0;
    top: 0;
    bottom: 0;
    z-index: 20;
    display: flex;
    flex-direction: column;
    justify-content: flex-end;
  }
  .editor-picker-mask {
    position: absolute;
    left: 0;
    right: 0;
    top: 0;
    bottom: 0;
    background: rgba(0, 0, 0, 0.45);
  }
  .editor-picker-panel {
    position: relative;
    z-index: 1;
    background: #fff;
    border-radius: 16px 16px 0 0;
    max-height: 55vh;
    padding-bottom: env(safe-area-inset-bottom);
  }
  .editor-picker-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 14px 18px;
    border-bottom: 1px solid #f0f0f0;
  }
  .editor-picker-cancel {
    font-size: 15px;
    color: #909399;
    min-width: 48px;
  }
  .editor-picker-title {
    font-size: 16px;
    font-weight: 600;
    color: $text;
    text-align: center;
    margin-bottom: 14px;
  }
  .editor-picker-placeholder {
    min-width: 48px;
  }
  .editor-picker-scroll {
    max-height: calc(55vh - 52px);
  }
  .editor-picker-item {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 16px 18px;
    font-size: 16px;
    color: $text;
    border-bottom: 1px solid #f5f7fa;
    &--active {
      color: $primary;
      background: #f5f9ff;
    }
    &:last-child {
      border-bottom: none;
    }
  }
  .editor-scroll {
    flex: 1;
    height: 0;
    max-height: 62vh;
  }
  .editor-form {
    display: flex;
    flex-direction: column;
    gap: 12px;
    gap: 10px;
    padding: 12px 16px 16px;
  }
  .editor-row {
    display: flex;
    flex-direction: column;
    gap: 10px;
  .editor-section-card {
    background: #fff;
    border-radius: 12px;
    padding: 14px 14px 4px;
    box-shadow: 0 1px 6px rgba(31, 45, 61, 0.05);
  }
  .editor-section-head {
    margin-bottom: 10px;
    padding-bottom: 10px;
    border-bottom: 1px solid #f2f4f7;
  }
  .editor-section-title {
    font-size: 14px;
    font-weight: 600;
    color: $text;
    padding-left: 8px;
    border-left: 3px solid $primary;
    line-height: 1.2;
  }
  .editor-cell {
    margin-bottom: 14px;
    &--tap:active .picker-value-row {
      background: #eef4ff;
      border-color: #c6daf5;
    }
    &--switch {
      flex-direction: row;
      display: flex;
      align-items: center;
      justify-content: space-between;
      padding: 4px 0;
      gap: 12px;
      padding: 4px 0 10px;
      margin-bottom: 4px;
    }
    &--value {
      margin-bottom: 10px;
    }
  }
  .switch-label-wrap {
    display: flex;
    flex-direction: column;
    gap: 2px;
  }
  .switch-hint {
    font-size: 12px;
    color: $text-muted;
  }
  .editor-input-box {
    background: #f7f9fc;
    border: 1px solid #e8ecf2;
    border-radius: 10px;
    overflow: hidden;
  }
  :deep(.editor-input-box .u-input) {
    background: transparent !important;
  }
  .default-hint {
    display: block;
    font-size: 12px;
    color: $text-muted;
    line-height: 1.5;
    margin: -4px 0 10px;
    padding: 0 2px;
  }
  .manual-options {
    margin: 4px 0 12px;
    padding-top: 4px;
  }
  .manual-options-title {
    display: block;
    font-size: 12px;
    color: $text-muted;
    margin-bottom: 10px;
  }
  .manual-options-table {
    display: flex;
    flex-direction: column;
    gap: 8px;
  }
  .option-table-head {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 0 4px 4px;
  }
  .option-col {
    font-size: 12px;
    color: $text-muted;
    font-weight: 500;
    &--idx {
      width: 22px;
      flex-shrink: 0;
    }
    &--label {
      flex: 1.4;
      min-width: 0;
    }
    &--value {
      flex: 0.9;
      min-width: 72px;
    }
    &--action {
      width: 32px;
      flex-shrink: 0;
    }
  }
  .option-card {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 8px 10px;
    background: #f8fafc;
    border: 1px solid #e8ecf2;
    border-radius: 10px;
  }
  .option-idx {
    width: 22px;
    height: 22px;
    flex-shrink: 0;
    border-radius: 6px;
    background: #eef2f8;
    color: $text-muted;
    font-size: 12px;
    font-weight: 600;
    line-height: 22px;
    text-align: center;
  }
  .option-input-wrap {
    flex: 1.4;
    min-width: 0;
    background: #fff;
    border: 1px solid #e4e8ef;
    border-radius: 8px;
    overflow: hidden;
    &--value {
      flex: 0.9;
      min-width: 72px;
    }
  }
  :deep(.option-input-wrap .u-input) {
    background: transparent !important;
  }
  :deep(.option-input-wrap input),
  :deep(.option-input-wrap .u-input__content__field-wrapper__field) {
    font-size: 14px !important;
    height: 36px !important;
    min-height: 36px !important;
    padding: 0 10px !important;
  }
  .option-del {
    flex-shrink: 0;
    width: 32px;
    height: 32px;
    border-radius: 8px;
    background: #fff;
    border: 1px solid #fde2e2;
    display: flex;
    align-items: center;
    justify-content: center;
  }
  .option-del--active {
    background: #fef0f0;
  }
  .add-option-btn {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 6px;
    margin-top: 10px;
    padding: 11px;
    border: 1.5px dashed #b8d4ff;
    border-radius: 10px;
    background: linear-gradient(180deg, #f8fbff 0%, #f0f6ff 100%);
    color: $primary;
    font-size: 14px;
    font-weight: 500;
  }
  .add-option-btn--active {
    background: #e8f2ff;
    border-color: $primary;
  }
  .option-source-tip {
    display: flex;
    align-items: flex-start;
    gap: 6px;
    padding: 10px 12px;
    margin-bottom: 10px;
    background: #f5f7fa;
    border-radius: 8px;
    font-size: 12px;
    color: $text-muted;
    line-height: 1.5;
  }
  .editor-title {
    display: block;
    font-size: 17px;
    font-weight: 600;
    color: $text;
  }
  .picker-value-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    min-height: 44px;
    padding: 0 14px;
    background: #f7f9fc;
    border: 1px solid #e8ecf2;
    border-radius: 10px;
    gap: 8px;
    transition: background 0.15s, border-color 0.15s;
    &--tap:active {
      background: #eef4ff;
      border-color: #c6daf5;
    }
  }
  .picker-value {
    flex: 1;
    min-width: 0;
    font-size: 15px;
    color: $text;
    text-align: left;
    line-height: 1.4;
    &--placeholder {
      color: #c0c4cc;
    }
  }
  .editor-label {
    font-size: 14px;
    display: block;
    font-size: 13px;
    font-weight: 500;
    color: $text-secondary;
    margin-bottom: 8px;
    &.required::before {
      content: "*";
      color: #f56c6c;
      margin-right: 4px;
      margin-right: 3px;
    }
  }
  .editor-row .input-box,
  .editor-row .textarea-box {
    background: #f7f9fc;
    border-radius: 10px;
    border: 1px solid #eef1f6;
  .editor-cell--switch .editor-label {
    margin-bottom: 0;
  }
  .daterange-default-wrap {
    display: flex;
    flex-direction: column;
    gap: 12px;
  }
  .daterange-default-item {
    display: flex;
    flex-direction: column;
    gap: 8px;
  }
  .daterange-default-label {
    font-size: 13px;
    color: $text-secondary;
  }
  .type-chip-grid {
@@ -1494,40 +2514,37 @@
    }
  }
  .default-date-row {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 0 12px;
    min-height: 44px;
    background: #f7f9fc;
    border-radius: 10px;
    border: 1px solid #eef1f6;
  }
  .editor-footer {
    display: flex;
    gap: 10px;
    margin-top: 16px;
    padding-top: 14px;
    border-top: 1px solid #f5f7fa;
    gap: 12px;
    padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
    background: #fff;
    border-top: 1px solid #eef0f4;
    box-shadow: 0 -4px 12px rgba(31, 45, 61, 0.06);
  }
  .editor-btn {
    flex: 1;
    text-align: center;
    padding: 11px 0;
    border-radius: 8px;
    padding: 12px 0;
    border-radius: 10px;
    font-size: 15px;
    font-weight: 500;
    &--cancel {
      color: $text-secondary;
      background: #f5f7fa;
      border: 1px solid #e4e7ed;
    }
    &--confirm {
      color: #fff;
      background: $primary;
      background: linear-gradient(135deg, #4d8dff 0%, #2979ff 100%);
      box-shadow: 0 4px 12px rgba(41, 121, 255, 0.35);
    }
    &--confirm:active {
      opacity: 0.9;
    }
  }
src/pages/oa/ApproveManage/approve-template/index.vue
@@ -80,7 +80,8 @@
                       @click.stop="goEdit(item)">
              ç¼–辑
            </up-button>
            <up-button class="action-btn"
            <up-button v-if="!isSystemTemplate(item)"
                       class="action-btn"
                       size="small"
                       type="error"
                       plain
@@ -119,6 +120,7 @@
    buildTypeLabelMap,
    fetchApprovalTemplateTypes,
    getTemplateTypeLabel,
    isSystemApprovalTemplate,
  } from "../../_utils/approvalTemplateType.js";
  const EDIT_STORAGE_KEY = "oa_approve_template_edit_row";
@@ -163,6 +165,8 @@
  const businessTypeText = type =>
    getTemplateTypeLabel(type, typeLabelMap.value);
  const isSystemTemplate = isSystemApprovalTemplate;
  const loadTemplateTypes = () =>
    fetchApprovalTemplateTypes()
@@ -250,6 +254,10 @@
  const handleDelete = item => {
    if (!item?.id) return;
    if (isSystemTemplate(item)) {
      uni.showToast({ title: "系统内置模板不可删除", icon: "none" });
      return;
    }
    const name = item.templateName || "该模板";
    uni.showModal({
      title: "删除确认",
src/pages/oa/AttendManage/leave-apply/index.vue
@@ -3,16 +3,10 @@
  è·¯ç”±ï¼š/pages/oa/AttendManage/leave-apply/index
-->
<template>
  <OaListPage v-if="config"
              :page-key="pageKey"
              :page-config="config" />
  <ApprovalInstanceListPage :module-key="APPROVAL_MODULE_KEYS.LEAVE" />
</template>
<script setup>
  /** OA - å‡å‹¤ç®¡ç† - è¯·å‡ç”³è¯· */
  import OaListPage from "../../_components/OaListPage.vue";
  import { useOaPage } from "../../_utils/useOaPage.js";
  const pageKey = "AttendManage/leave-apply";
  const { config } = useOaPage(pageKey);
  import ApprovalInstanceListPage from "../../_components/ApprovalInstanceListPage.vue";
  import { APPROVAL_MODULE_KEYS } from "../../_utils/approvalModuleRegistry.js";
</script>
src/pages/oa/AttendManage/overtime-apply/index.vue
@@ -3,16 +3,10 @@
  è·¯ç”±ï¼š/pages/oa/AttendManage/overtime-apply/index
-->
<template>
  <OaListPage v-if="config"
              :page-key="pageKey"
              :page-config="config" />
  <ApprovalInstanceListPage :module-key="APPROVAL_MODULE_KEYS.OVERTIME" />
</template>
<script setup>
  /** OA - å‡å‹¤ç®¡ç† - åŠ ç­ç”³è¯· */
  import OaListPage from "../../_components/OaListPage.vue";
  import { useOaPage } from "../../_utils/useOaPage.js";
  const pageKey = "AttendManage/overtime-apply";
  const { config } = useOaPage(pageKey);
  import ApprovalInstanceListPage from "../../_components/ApprovalInstanceListPage.vue";
  import { APPROVAL_MODULE_KEYS } from "../../_utils/approvalModuleRegistry.js";
</script>
src/pages/oa/HrManage/regular-apply/index.vue
@@ -3,16 +3,10 @@
  è·¯ç”±ï¼š/pages/oa/HrManage/regular-apply/index
-->
<template>
  <OaListPage v-if="config"
              :page-key="pageKey"
              :page-config="config" />
  <ApprovalInstanceListPage :module-key="APPROVAL_MODULE_KEYS.REGULAR" />
</template>
<script setup>
  /** OA - äººäº‹ç®¡ç† - è½¬æ­£ç”³è¯· */
  import OaListPage from "../../_components/OaListPage.vue";
  import { useOaPage } from "../../_utils/useOaPage.js";
  const pageKey = "HrManage/regular-apply";
  const { config } = useOaPage(pageKey);
  import ApprovalInstanceListPage from "../../_components/ApprovalInstanceListPage.vue";
  import { APPROVAL_MODULE_KEYS } from "../../_utils/approvalModuleRegistry.js";
</script>
src/pages/oa/HrManage/transfer-apply/index.vue
@@ -3,16 +3,10 @@
  è·¯ç”±ï¼š/pages/oa/HrManage/transfer-apply/index
-->
<template>
  <OaListPage v-if="config"
              :page-key="pageKey"
              :page-config="config" />
  <ApprovalInstanceListPage :module-key="APPROVAL_MODULE_KEYS.TRANSFER" />
</template>
<script setup>
  /** OA - äººäº‹ç®¡ç† - è°ƒå²—申请 */
  import OaListPage from "../../_components/OaListPage.vue";
  import { useOaPage } from "../../_utils/useOaPage.js";
  const pageKey = "HrManage/transfer-apply";
  const { config } = useOaPage(pageKey);
  import ApprovalInstanceListPage from "../../_components/ApprovalInstanceListPage.vue";
  import { APPROVAL_MODULE_KEYS } from "../../_utils/approvalModuleRegistry.js";
</script>
src/pages/oa/HrManage/work-handover/index.vue
@@ -3,16 +3,10 @@
  è·¯ç”±ï¼š/pages/oa/HrManage/work-handover/index
-->
<template>
  <OaListPage v-if="config"
              :page-key="pageKey"
              :page-config="config" />
  <ApprovalInstanceListPage :module-key="APPROVAL_MODULE_KEYS.WORK_HANDOVER" />
</template>
<script setup>
  /** OA - äººäº‹ç®¡ç† - å·¥ä½œäº¤æŽ¥ */
  import OaListPage from "../../_components/OaListPage.vue";
  import { useOaPage } from "../../_utils/useOaPage.js";
  const pageKey = "HrManage/work-handover";
  const { config } = useOaPage(pageKey);
  import ApprovalInstanceListPage from "../../_components/ApprovalInstanceListPage.vue";
  import { APPROVAL_MODULE_KEYS } from "../../_utils/approvalModuleRegistry.js";
</script>
src/pages/oa/ReimburseManage/_components/ReimburseApprovalFlowEditor.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,245 @@
<!--
  æŠ¥é”€å®¡æ‰¹æµç¨‹ï¼ˆå¯æœç´¢é€‰äººï¼Œç‚¹é€‰å³ç¡®è®¤ï¼‰
-->
<template>
  <view class="flow-wrap">
    <view v-for="(item, index) in innerList"
          :key="item._uid"
          class="flow-node-block">
      <view class="flow-node-card">
        <view class="node-header">
          <view class="node-level-badge">{{ index + 1 }}</view>
          <text class="node-level-text">第{{ levelLabel(index + 1) }}级审批</text>
          <view v-if="innerList.length > 1"
                class="node-delete"
                @click="remove(index)">
            <up-icon name="trash"
                     size="16"
                     color="#f56c6c" />
          </view>
        </view>
        <view class="approver-row"
              @click="openPicker(index)">
          <view class="approver-avatar"
                :style="{ backgroundColor: avatarColor(item.approverName) }">
            {{ (item.approverName || '+').charAt(0) }}
          </view>
          <view class="approver-meta">
            <text class="approver-name">{{ item.approverName || '点击选择审批人' }}</text>
            <text class="approver-hint">支持搜索姓名或工号</text>
          </view>
          <up-icon name="arrow-right"
                   size="14"
                   color="#c0c4cc" />
        </view>
      </view>
      <view v-if="index < innerList.length - 1"
            class="flow-connector">
        <view class="flow-connector-line" />
      </view>
    </view>
    <view class="add-node-bar"
          @click="addNode">
      <up-icon name="plus-circle"
               size="18"
               color="#2979ff" />
      <text>添加审批级次</text>
    </view>
    <OaUserSearchPicker v-model:show="pickerShow"
                        v-model="pickerUserId"
                        title="选择审批人"
                        :users="userOptions"
                        :show-self-quick="false"
                        @select="onUserSelected" />
  </view>
</template>
<script setup>
  import { ref, watch } from "vue";
  import OaUserSearchPicker from "../../_components/OaUserSearchPicker.vue";
  import { userAvatarColor } from "../../_utils/userPickerUtils.js";
  const props = defineProps({
    modelValue: { type: Array, default: () => [] },
    userOptions: { type: Array, default: () => [] },
  });
  const emit = defineEmits(["update:modelValue"]);
  const innerList = ref([]);
  const pickerShow = ref(false);
  const pickerUserId = ref("");
  const editingIndex = ref(-1);
  function newUid() {
    return `n_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
  }
  function levelLabel(n) {
    const t = ["一", "二", "三", "四", "五", "六", "七", "八"];
    return t[n - 1] || String(n);
  }
  function avatarColor(name) {
    return userAvatarColor(name);
  }
  function mapIn(rows) {
    return (rows || []).map((n, i) => ({
      _uid: n._uid || newUid(),
      nodeOrder: n.nodeOrder ?? i + 1,
      signMode: n.signMode || "countersign",
      approverId: n.approverId ?? "",
      approverName: n.approverName || "",
      id: n.id,
      templateId: n.templateId,
    }));
  }
  function mapOut() {
    return innerList.value.map((n, i) => ({
      nodeOrder: i + 1,
      signMode: n.signMode || "countersign",
      approverId: n.approverId,
      approverName: n.approverName,
      id: n.id,
      templateId: n.templateId,
    }));
  }
  function syncEmit() {
    emit("update:modelValue", mapOut());
  }
  watch(
    () => props.modelValue,
    v => {
      innerList.value = mapIn(v);
      if (!innerList.value.length) {
        innerList.value = [
          { _uid: newUid(), nodeOrder: 1, signMode: "countersign", approverId: "", approverName: "" },
        ];
      }
    },
    { immediate: true, deep: true }
  );
  function addNode() {
    innerList.value.push({
      _uid: newUid(),
      nodeOrder: innerList.value.length + 1,
      signMode: "countersign",
      approverId: "",
      approverName: "",
    });
    syncEmit();
  }
  function remove(index) {
    if (innerList.value.length <= 1) {
      uni.showToast({ title: "至少保留一个审批节点", icon: "none" });
      return;
    }
    innerList.value.splice(index, 1);
    syncEmit();
  }
  function openPicker(index) {
    editingIndex.value = index;
    pickerUserId.value = innerList.value[index]?.approverId || "";
    pickerShow.value = true;
  }
  function onUserSelected(u) {
    const node = innerList.value[editingIndex.value];
    if (!node) return;
    node.approverId = u.userId ?? u.id;
    node.approverName = u.nickName || u.userName || "";
    syncEmit();
  }
</script>
<style scoped lang="scss">
  .flow-node-card {
    background: #f8f9fb;
    border-radius: 10px;
    padding: 12px;
    border: 1px solid #eef0f3;
  }
  .node-header {
    display: flex;
    align-items: center;
    margin-bottom: 10px;
  }
  .node-level-badge {
    width: 22px;
    height: 22px;
    border-radius: 50%;
    background: #2979ff;
    color: #fff;
    font-size: 12px;
    text-align: center;
    line-height: 22px;
    margin-right: 8px;
  }
  .node-level-text {
    flex: 1;
    font-size: 14px;
    color: #303133;
    font-weight: 500;
  }
  .approver-row {
    display: flex;
    align-items: center;
    padding: 10px 12px;
    background: #fff;
    border-radius: 8px;
  }
  .approver-avatar {
    width: 36px;
    height: 36px;
    border-radius: 50%;
    color: #fff;
    font-size: 15px;
    font-weight: 600;
    display: flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
  }
  .approver-meta {
    flex: 1;
    margin-left: 10px;
    min-width: 0;
  }
  .approver-name {
    display: block;
    font-size: 15px;
    color: #303133;
  }
  .approver-hint {
    display: block;
    font-size: 12px;
    color: #c0c4cc;
    margin-top: 2px;
  }
  .flow-connector {
    display: flex;
    justify-content: center;
    padding: 6px 0;
  }
  .flow-connector-line {
    width: 2px;
    height: 14px;
    background: #dcdfe6;
  }
  .add-node-bar {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 6px;
    padding: 14px 0 4px;
    color: #2979ff;
    font-size: 14px;
  }
</style>
src/pages/oa/ReimburseManage/_components/ReimburseExpenseDetailSheet.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,315 @@
<!--
  æŠ¥é”€æ˜Žç»†å•条编辑(底部弹层)
-->
<template>
  <up-popup :show="show"
            mode="bottom"
            round="16"
            :safe-area-inset-bottom="true"
            @close="close">
    <view class="detail-sheet">
      <view class="sheet-handle" />
      <view class="sheet-head">
        <text class="sheet-cancel"
              @click="close">取消</text>
        <text class="sheet-title">{{ title }}</text>
        <text class="sheet-confirm"
              @click="confirm">保存</text>
      </view>
      <scroll-view scroll-y
                   class="sheet-body"
                   :show-scrollbar="false">
        <view class="sheet-group">
          <view class="sheet-cell sheet-cell--tap"
                @click="showDatePicker = true">
            <text class="sheet-label required">发票日期</text>
            <view class="sheet-value-wrap">
              <text class="sheet-value"
                    :class="{ placeholder: !draft.invoiceDate }">
                {{ draft.invoiceDate || '请选择' }}
              </text>
              <up-icon name="calendar"
                       size="18"
                       color="#c0c4cc" />
            </view>
          </view>
          <view class="sheet-cell sheet-cell--tap"
                @click="showSubjectSheet = true">
            <text class="sheet-label required">费用科目</text>
            <view class="sheet-value-wrap">
              <text class="sheet-value"
                    :class="{ placeholder: !draft.expenseSubject }">
                {{ subjectText }}
              </text>
              <up-icon name="arrow-right"
                       size="14"
                       color="#c0c4cc" />
            </view>
          </view>
          <view class="sheet-cell">
            <text class="sheet-label required">金额</text>
            <view class="sheet-input-wrap">
              <up-input v-model="draft.amount"
                        type="digit"
                        placeholder="0.00"
                        border="none"
                        input-align="right" />
              <text class="sheet-unit">元</text>
            </view>
          </view>
          <view class="sheet-cell sheet-cell--col">
            <text class="sheet-label">描述</text>
            <view class="sheet-textarea-wrap">
              <up-textarea v-model="draft.description"
                           placeholder="费用说明(选填)"
                           maxlength="200"
                           border="none"
                           height="64" />
            </view>
          </view>
        </view>
        <view v-if="showDelete"
              class="sheet-delete"
              @click="emit('delete')">
          åˆ é™¤æœ¬æ¡æ˜Žç»†
        </view>
      </scroll-view>
    </view>
    <up-action-sheet :show="showSubjectSheet"
                     title="费用科目"
                     :actions="subjectActions"
                     @select="onSubjectSelect"
                     @close="showSubjectSheet = false" />
    <up-popup :show="showDatePicker"
              mode="bottom"
              round="16"
              @close="showDatePicker = false">
      <up-datetime-picker :show="true"
                          v-model="datePickerTs"
                          mode="date"
                          @confirm="onDateConfirm"
                          @cancel="showDatePicker = false" />
    </up-popup>
  </up-popup>
</template>
<script setup>
  import { computed, reactive, ref, watch } from "vue";
  import { parseTime } from "@/utils/ruoyi";
  import { expenseSubjectLabel as costSubjectLabel } from "../_utils/costReimburseUtils.js";
  import { expenseSubjectLabel as travelSubjectLabel } from "../_utils/travelReimburseUtils.js";
  const props = defineProps({
    show: { type: Boolean, default: false },
    modelValue: { type: Object, default: () => ({}) },
    index: { type: Number, default: 0 },
    isTravel: { type: Boolean, default: true },
    subjectOptions: { type: Array, default: () => [] },
    showDelete: { type: Boolean, default: true },
  });
  const emit = defineEmits(["update:show", "update:modelValue", "confirm", "delete"]);
  const draft = reactive({
    invoiceDate: "",
    expenseSubject: "",
    amount: "",
    description: "",
  });
  const showDatePicker = ref(false);
  const showSubjectSheet = ref(false);
  const datePickerTs = ref(Date.now());
  const title = computed(() => `明细 ${props.index + 1}`);
  const subjectActions = computed(() =>
    (props.subjectOptions || []).map(x => ({ name: x.label, value: x.value }))
  );
  const subjectText = computed(() => resolveSubjectLabel(draft.expenseSubject));
  function resolveSubjectLabel(v) {
    if (!v) return "请选择";
    const labelFn = props.isTravel ? travelSubjectLabel : costSubjectLabel;
    const t = labelFn(v);
    if (t && t !== "—") return t;
    const hit = (props.subjectOptions || []).find(x => x.value === v || x.label === v);
    return hit?.label || v;
  }
  watch(
    () => props.show,
    v => {
      if (v && props.modelValue) {
        Object.assign(draft, {
          invoiceDate: "",
          expenseSubject: "",
          amount: "",
          description: "",
          ...JSON.parse(JSON.stringify(props.modelValue)),
        });
      }
    }
  );
  function close() {
    emit("update:show", false);
  }
  function confirm() {
    if (!draft.invoiceDate) {
      uni.showToast({ title: "请选择发票日期", icon: "none" });
      return;
    }
    if (!draft.expenseSubject) {
      uni.showToast({ title: "请选择费用科目", icon: "none" });
      return;
    }
    if (draft.amount === "" || draft.amount == null) {
      uni.showToast({ title: "请填写金额", icon: "none" });
      return;
    }
    emit("update:modelValue", { ...draft });
    emit("confirm", { ...draft });
    emit("update:show", false);
  }
  function onSubjectSelect(action) {
    draft.expenseSubject = action.value;
    showSubjectSheet.value = false;
  }
  function onDateConfirm(e) {
    const ts = e?.value ?? datePickerTs.value;
    draft.invoiceDate = parseTime(ts, "{y}-{m}-{d}");
    showDatePicker.value = false;
  }
</script>
<style scoped lang="scss">
  .detail-sheet {
    background: #fff;
    border-radius: 16px 16px 0 0;
    max-height: 85vh;
    display: flex;
    flex-direction: column;
  }
  .sheet-handle {
    width: 36px;
    height: 4px;
    background: #e4e7ed;
    border-radius: 2px;
    margin: 8px auto 4px;
  }
  .sheet-head {
    display: flex;
    align-items: center;
    padding: 8px 16px 12px;
    border-bottom: 1px solid #f0f2f5;
  }
  .sheet-cancel {
    font-size: 15px;
    color: #909399;
    min-width: 48px;
  }
  .sheet-title {
    flex: 1;
    text-align: center;
    font-size: 16px;
    font-weight: 600;
    color: #303133;
  }
  .sheet-confirm {
    font-size: 15px;
    color: #2979ff;
    font-weight: 600;
    min-width: 48px;
    text-align: right;
  }
  .sheet-body {
    max-height: 70vh;
    padding-bottom: env(safe-area-inset-bottom);
  }
  .sheet-group {
    margin: 12px 16px;
    background: #f8f9fb;
    border-radius: 12px;
    overflow: hidden;
  }
  .sheet-cell {
    display: flex;
    align-items: center;
    min-height: 52px;
    padding: 12px 14px;
    background: #fff;
    border-bottom: 1px solid #f5f6f8;
    &--col {
      flex-direction: column;
      align-items: stretch;
    }
    &--tap:active {
      background: #fafbfc;
    }
    &:last-child {
      border-bottom: none;
    }
  }
  .sheet-label {
    width: 80px;
    font-size: 15px;
    color: #303133;
    flex-shrink: 0;
    &.required::before {
      content: "*";
      color: #f56c6c;
      margin-right: 2px;
    }
  }
  .sheet-value-wrap {
    flex: 1;
    display: flex;
    align-items: center;
    justify-content: flex-end;
    gap: 4px;
  }
  .sheet-value {
    font-size: 15px;
    color: #303133;
    &.placeholder {
      color: #c0c4cc;
    }
  }
  .sheet-input-wrap {
    flex: 1;
    display: flex;
    align-items: center;
    justify-content: flex-end;
  }
  .sheet-unit {
    font-size: 14px;
    color: #909399;
    margin-left: 4px;
  }
  .sheet-textarea-wrap {
    width: 100%;
    margin-top: 8px;
    background: #f5f7fa;
    border-radius: 8px;
    padding: 4px 8px;
  }
  .sheet-delete {
    margin: 16px;
    text-align: center;
    font-size: 15px;
    color: #f56c6c;
    padding: 14px;
    background: #fff;
    border-radius: 12px;
    border: 1px solid #fde2e2;
  }
</style>
src/pages/oa/ReimburseManage/_components/ReimburseInstanceDetailBody.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,426 @@
<!--
  å·®æ—…/费用报销详情展示(列表详情 / å®¡æ‰¹è¯¦æƒ…共用)
-->
<template>
  <view class="rd-body">
    <!-- æ¦‚要 -->
    <view class="rd-hero">
      <view class="rd-hero-top">
        <text class="rd-bill-no">{{ billNo }}</text>
        <text :class="['rd-status', statusCssClass]">{{ statusText }}</text>
      </view>
      <text class="rd-reason">{{ reasonText }}</text>
      <view class="rd-amount-row">
        <text class="rd-amount-label">申请金额</text>
        <text class="rd-amount">{{ amountText }}</text>
      </view>
    </view>
    <!-- ç”³è¯·äºº -->
    <view class="rd-section">
      <view class="rd-section-hd">
        <text class="rd-section-title">申请人</text>
      </view>
      <view class="rd-group">
        <view class="rd-cell">
          <text class="rd-label">姓名</text>
          <text class="rd-value">{{ r.applicantName || "—" }}</text>
        </view>
        <view class="rd-cell">
          <text class="rd-label">员工编号</text>
          <text class="rd-value">{{ r.applicantCode || r.applicantNo || "—" }}</text>
        </view>
        <view v-if="r.applicantDeptName || r.deptName"
              class="rd-cell">
          <text class="rd-label">部门</text>
          <text class="rd-value">{{ r.applicantDeptName || r.deptName }}</text>
        </view>
      </view>
    </view>
    <!-- å‡ºå·® / è´¹ç”¨ -->
    <view class="rd-section">
      <view class="rd-section-hd">
        <text class="rd-section-title">{{ isTravel ? "出差信息" : "费用信息" }}</text>
      </view>
      <view class="rd-group">
        <template v-if="isTravel">
          <view class="rd-cell">
            <text class="rd-label">出差开始</text>
            <text class="rd-value">{{ formatTime(r.travelStartTime) }}</text>
          </view>
          <view class="rd-cell">
            <text class="rd-label">出差结束</text>
            <text class="rd-value">{{ formatTime(r.travelEndTime) }}</text>
          </view>
          <view class="rd-cell">
            <text class="rd-label">出差天数</text>
            <text class="rd-value">{{ travelDaysText }}</text>
          </view>
          <view class="rd-cell">
            <text class="rd-label">出差地</text>
            <text class="rd-value">{{ r.departurePlace || "—" }}</text>
          </view>
          <view class="rd-cell">
            <text class="rd-label">目的地</text>
            <text class="rd-value">{{ r.destination || "—" }}</text>
          </view>
        </template>
        <view v-else
              class="rd-cell">
          <text class="rd-label">费用类型</text>
          <text class="rd-value">{{ expenseTypeText }}</text>
        </view>
      </view>
    </view>
    <!-- å·®æ—…标准 -->
    <view v-if="isTravel && hasTravelStandard"
          class="rd-section">
      <view class="rd-section-hd">
        <text class="rd-section-title">差旅标准</text>
      </view>
      <view class="rd-group">
        <view v-if="r.hotelStandard != null"
              class="rd-cell">
          <text class="rd-label">酒店标准</text>
          <text class="rd-value">{{ r.hotelStandard }} å…ƒ/晚</text>
        </view>
        <view v-if="r.hotelDays != null"
              class="rd-cell">
          <text class="rd-label">住宿天数</text>
          <text class="rd-value">{{ r.hotelDays }} å¤©</text>
        </view>
        <view v-if="r.livingSubsidy != null"
              class="rd-cell">
          <text class="rd-label">生活补贴</text>
          <text class="rd-value">{{ r.livingSubsidy }} å…ƒ</text>
        </view>
        <view class="rd-cell">
          <text class="rd-label">标准标记</text>
          <text class="rd-value">{{ r.standardTag || (r.needSpecialApproval ? "超支需特批" : "在标准内") }}</text>
        </view>
      </view>
    </view>
    <!-- æ”¶æ¬¾ -->
    <view class="rd-section">
      <view class="rd-section-hd">
        <text class="rd-section-title">收款信息</text>
      </view>
      <view class="rd-group">
        <view class="rd-cell">
          <text class="rd-label">收款人</text>
          <text class="rd-value">{{ r.payeeName || r.payee || "—" }}</text>
        </view>
        <view class="rd-cell">
          <text class="rd-label">收款账号</text>
          <text class="rd-value">{{ r.payeeAccount || "—" }}</text>
        </view>
        <view class="rd-cell">
          <text class="rd-label">开户支行</text>
          <text class="rd-value">{{ r.payeeBank || r.bankBranch || "—" }}</text>
        </view>
      </view>
    </view>
    <!-- æŠ¥é”€æ˜Žç»† -->
    <view class="rd-section">
      <view class="rd-section-hd">
        <text class="rd-section-title">报销明细</text>
        <text class="rd-section-count">共 {{ detailRows.length }} æ¡</text>
      </view>
      <view v-if="detailRows.length"
            class="rd-group">
        <view v-for="(d, idx) in detailRows"
              :key="'d-' + idx"
              class="rd-detail-item">
          <view class="rd-detail-head">
            <text class="rd-detail-badge">{{ idx + 1 }}</text>
            <text class="rd-detail-title">{{ detailSubject(d) }}</text>
            <text class="rd-detail-amount">{{ detailAmount(d) }}</text>
          </view>
          <view class="rd-cell">
            <text class="rd-label">发票日期</text>
            <text class="rd-value">{{ d.invoiceDate || "—" }}</text>
          </view>
          <view v-if="d.description"
                class="rd-cell">
            <text class="rd-label">描述</text>
            <text class="rd-value">{{ d.description }}</text>
          </view>
          <view v-if="d.invoiceNo"
                class="rd-cell">
            <text class="rd-label">发票号</text>
            <text class="rd-value">{{ d.invoiceNo }}</text>
          </view>
        </view>
      </view>
      <view v-else
            class="rd-group">
        <view class="rd-empty">暂无报销明细</view>
      </view>
    </view>
    <!-- é™„ä»¶ -->
    <view v-if="attachmentList.length"
          class="rd-section">
      <view class="rd-section-hd">
        <text class="rd-section-title">发票附件</text>
      </view>
      <view class="rd-group">
        <view v-for="(f, i) in attachmentList"
              :key="i"
              class="rd-attach"
              @click="openAttachment(f)">
          {{ f.name || "附件" }}
        </view>
      </view>
    </view>
    <!-- å®¡æ‰¹æµç¨‹ï¼ˆtasks) -->
    <view class="rd-section">
      <view class="rd-section-hd">
        <text class="rd-section-title">审批流程</text>
        <text class="rd-section-count">{{ flowNodesList.length }} çº§</text>
      </view>
      <view v-if="flowNodesList.length"
            class="rd-group">
        <view v-for="(node, nodeIndex) in flowNodesList"
              :key="nodeIndex"
              class="rd-flow-node">
          <view class="rd-flow-line">
            <view class="rd-flow-dot" />
            <view v-if="nodeIndex < flowNodesList.length - 1"
                  class="rd-flow-bar" />
          </view>
          <view class="rd-flow-body">
            <text class="rd-flow-level">第{{ node.levelNo }}级 Â· {{ node.approveType === 'OR' ? '或签' : '会签' }}</text>
            <view v-for="(a, ai) in node.approvers"
                  :key="ai"
                  class="rd-flow-approver">
              <view class="rd-flow-avatar"
                    :style="{ backgroundColor: avatarColor(a.approverName) }">
                {{ (a.approverName || "?").charAt(0) }}
              </view>
              <view class="rd-flow-approver-meta">
                <text class="rd-flow-name">{{ a.approverName || "—" }}</text>
                <text v-if="a.taskStatus"
                      class="rd-flow-status">{{ taskStatusLabel(a.taskStatus) }}</text>
              </view>
            </view>
          </view>
        </view>
      </view>
      <view v-else
            class="rd-group">
        <view class="rd-empty">暂无审批节点</view>
      </view>
    </view>
    <!-- å®¡æ‰¹è®°å½•(tasks ç•™ç—•) -->
    <view class="rd-section">
      <view class="rd-section-hd">
        <text class="rd-section-title">审批记录</text>
        <text class="rd-section-count">{{ approvalRecords.length }} æ¡</text>
      </view>
      <view v-if="approvalRecords.length"
            class="rd-group">
        <view v-for="(rec, index) in approvalRecords"
              :key="rec.id ?? index"
              class="rd-record-item">
          <view class="rd-record-head">
            <text class="rd-record-operator">{{ rec.operatorName }}</text>
            <text class="rd-record-tag"
                  :class="'rd-record-tag--' + rec.result">{{ recordLabel(rec.result) }}</text>
          </view>
          <text v-if="rec.time"
                class="rd-record-time">{{ rec.time }}</text>
          <text class="rd-record-opinion">{{ rec.opinion || "无意见" }}</text>
        </view>
      </view>
      <view v-else
            class="rd-group">
        <view class="rd-empty">暂无审批记录</view>
      </view>
    </view>
    <view class="rd-section">
      <view class="rd-group">
        <view class="rd-cell">
          <text class="rd-label">创建时间</text>
          <text class="rd-value">{{ formatTime(r.createTime) }}</text>
        </view>
      </view>
    </view>
  </view>
</template>
<script setup>
  import { computed } from "vue";
  import { parseTime } from "@/utils/ruoyi";
  import { isTravelReimbursementType } from "../../_utils/finReimbursementMappers.js";
  import {
    billStatusCssClass,
    billStatusLabel,
  } from "../../_utils/finReimbursementMappers.js";
  import { expenseCategoryLabel, EXPENSE_SUBJECT_OPTIONS as COST_SUBJECTS } from "../_utils/costReimburseUtils.js";
  import { EXPENSE_SUBJECT_OPTIONS as TRAVEL_SUBJECTS } from "../_utils/travelReimburseUtils.js";
  import {
    resolveExpenseSubjectLabel,
    formatDetailAmount,
  } from "../_utils/expenseDetailDisplay.js";
  import { userAvatarColor } from "../../_utils/userPickerUtils.js";
  import {
    mapTasksToFlowNodes,
    recordActionLabel,
    taskStatusText,
  } from "../../_utils/approveListUtils.js";
  import config from "@/config.js";
  const props = defineProps({
    reimburseRow: { type: Object, default: () => ({}) },
    moduleKey: { type: String, default: "" },
  });
  const r = computed(() => props.reimburseRow || {});
  const isTravel = computed(() =>
    isTravelReimbursementType(r.value.reimbursementType ?? props.moduleKey)
  );
  const billNo = computed(() => r.value.billNo || r.value.reimburseNo || "—");
  const statusText = computed(() =>
    billStatusLabel(r.value.billStatus ?? r.value.status)
  );
  const statusCssClass = computed(() =>
    billStatusCssClass(r.value)
  );
  const reasonText = computed(
    () => r.value.reason || r.value.reimburseReason || "—"
  );
  const amountText = computed(() =>
    r.value.applyAmount != null ? String(r.value.applyAmount) : "—"
  );
  const expenseTypeText = computed(() =>
    expenseCategoryLabel(r.value.expenseCategory) || r.value.expenseType || "—"
  );
  const travelDaysText = computed(() => {
    const d = r.value.travelDays ?? r.value.travel?.travelDays;
    return d != null ? `${d} å¤©` : "—";
  });
  const hasTravelStandard = computed(() => {
    const row = r.value;
    return (
      row.hotelStandard != null ||
      row.hotelDays != null ||
      row.livingSubsidy != null ||
      row.standardTag ||
      row.needSpecialApproval
    );
  });
  const subjectOptions = computed(() =>
    isTravel.value ? TRAVEL_SUBJECTS : COST_SUBJECTS
  );
  const detailRows = computed(() => {
    const list = r.value.expenseDetails || r.value.details || [];
    return Array.isArray(list) ? list : [];
  });
  const attachmentList = computed(() => {
    const list =
      r.value.attachmentList ||
      r.value.storageBlobVOList ||
      r.value.invoiceAttachments ||
      [];
    return Array.isArray(list) ? list : [];
  });
  const approvalRecords = computed(() => {
    const list = r.value.approvalRecords || [];
    return Array.isArray(list) ? list : [];
  });
  /** æµç¨‹å±•示优先用 enrichment åŽçš„ flowNodes(来自 tasks) */
  const flowNodesList = computed(() => {
    const row = r.value;
    if (Array.isArray(row.flowNodes) && row.flowNodes.length) {
      return row.flowNodes;
    }
    if (Array.isArray(row.tasks) && row.tasks.length) {
      return mapTasksToFlowNodes(row.tasks);
    }
    return [];
  });
  function taskStatusLabel(status) {
    return taskStatusText(status);
  }
  function recordLabel(result) {
    return recordActionLabel(result);
  }
  function formatTime(t) {
    if (!t) return "—";
    const s = parseTime(t, "{y}-{m}-{d} {h}:{i}");
    return s || String(t).replace("T", " ").slice(0, 16);
  }
  function detailSubject(d) {
    return (
      resolveExpenseSubjectLabel(d.expenseSubject || d.expenseCategory, {
        isTravel: isTravel.value,
        subjectOptions: subjectOptions.value,
      }) || "未选科目"
    );
  }
  function detailAmount(d) {
    return formatDetailAmount(d.amount) || "—";
  }
  function avatarColor(name) {
    return userAvatarColor(name);
  }
  function resolveFileUrl(f) {
    let url = f?.url || f?.downloadURL || f?.previewURL || f?.fileUrl || "";
    if (!url) return "";
    if (/^https?:\/\//i.test(url)) return url;
    const base = (config.baseUrl || "").replace(/\/+$/, "");
    const path = url.startsWith("/") ? url : `/${url}`;
    return `${base}${path}`;
  }
  function openAttachment(f) {
    const url = resolveFileUrl(f);
    if (!url) {
      uni.showToast({ title: "无法打开附件", icon: "none" });
      return;
    }
    // #ifdef H5
    window.open(url, "_blank");
    // #endif
    // #ifndef H5
    uni.downloadFile({
      url,
      success: res => {
        if (res.statusCode === 200) {
          uni.openDocument({ filePath: res.tempFilePath, showMenu: true });
        }
      },
      fail: () => uni.showToast({ title: "附件打开失败", icon: "none" }),
    });
    // #endif
  }
</script>
<style scoped lang="scss">
  @import "../reimburse-detail/reimburse-detail.scss";
</style>
src/pages/oa/ReimburseManage/_utils/costReimburseUtils.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,120 @@
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 function expenseSubjectLabel(v) {
  return EXPENSE_SUBJECT_OPTIONS.find(x => x.value === v)?.label || "—";
}
export function expenseCategoryLabel(v) {
  return EXPENSE_CATEGORY_OPTIONS.find(x => x.value === v)?.label || v || "—";
}
export function expenseTypeToCategory(expenseType) {
  const t = (expenseType || "").trim();
  const hit = EXPENSE_CATEGORY_OPTIONS.find(x => x.label === t || x.value === t);
  return hit?.value || "other";
}
export function createEmptyExpenseDetail() {
  return {
    id: `ed_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
    invoiceDate: "",
    expenseSubject: "",
    amount: "",
    description: "",
  };
}
export function createEmptyCostForm() {
  return {
    reimbursementId: undefined,
    applicantId: "",
    employeeNo: "",
    employeeName: "",
    expenseCategory: "other",
    reimburseReason: "",
    applyAmount: "",
    payee: "",
    payeeAccount: "",
    bankBranch: "",
    expenseDetails: [],
    attachmentList: [],
    approvalFlowNodes: [{ approverId: "", approverName: "", nodeOrder: 1, signMode: "countersign" }],
    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"),
  }));
}
src/pages/oa/ReimburseManage/_utils/expenseDetailDisplay.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,33 @@
import { expenseSubjectLabel as costSubjectLabel } from "./costReimburseUtils.js";
import { expenseSubjectLabel as travelSubjectLabel } from "./travelReimburseUtils.js";
/** è´¹ç”¨ç§‘目展示(兼容 value / ä¸­æ–‡ label / API expenseCategory) */
export function resolveExpenseSubjectLabel(v, { isTravel = true, subjectOptions = [] } = {}) {
  if (!v) return "";
  const labelFn = isTravel ? travelSubjectLabel : costSubjectLabel;
  const t = labelFn(v);
  if (t && t !== "—") return t;
  const hit = subjectOptions.find(x => x.value === v || x.label === v);
  return hit?.label || String(v);
}
export function formatDetailAmount(amount) {
  if (amount === "" || amount == null) return null;
  const n = Number(amount);
  if (Number.isNaN(n)) return String(amount);
  return `${n} å…ƒ`;
}
/** åˆ—表行摘要 */
export function buildExpenseDetailSummary(row, opts = {}) {
  const subject = resolveExpenseSubjectLabel(row?.expenseSubject, opts) || "未选科目";
  const amount = formatDetailAmount(row?.amount);
  const date = row?.invoiceDate || "";
  const desc = (row?.description || "").trim();
  const parts = [];
  if (date) parts.push(date);
  if (desc) parts.push(desc);
  const sub = parts.length ? parts.join(" Â· ") : "点击详情完善信息";
  const incomplete = !row?.invoiceDate || !row?.expenseSubject || row?.amount === "" || row?.amount == null;
  return { subject, amount: amount || "金额未填", sub, incomplete };
}
src/pages/oa/ReimburseManage/_utils/finReimbursementDetailExtras.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,153 @@
import { parseTime } from "@/utils/ruoyi";
import {
  mapApprovalRecords,
  mapRecordResult,
  mapTasksToFlowNodes,
} from "../../_utils/approveListUtils.js";
function formatDisplayTime(val) {
  if (!val) return "";
  const s = parseTime(val, "{y}-{m}-{d} {h}:{i}");
  return s || String(val).replace("T", " ").slice(0, 16);
}
function taskStatusToNodeStatus(taskStatus) {
  const s = String(taskStatus ?? "").toUpperCase();
  if (["APPROVED", "COMPLETED", "FINISHED", "PASSED", "AGREE"].includes(s)) {
    return "finish";
  }
  if (["REJECTED", "REJECT", "REFUSE", "REFUSED"].includes(s)) {
    return "error";
  }
  if (["PENDING", "IN_APPROVAL", "PROCESS", "PROCESSING"].includes(s)) {
    return "process";
  }
  return "wait";
}
/** storageBlobVOList â†’ é¡µé¢é™„ä»¶ */
export function mapReimbursementAttachments(source = {}) {
  const list =
    source.storageBlobVOList ||
    source.storageBlobDTOs ||
    source.storageBlobDTOS ||
    source.attachmentList ||
    source.invoiceAttachments ||
    [];
  if (!Array.isArray(list)) return [];
  return list.map((b, i) => ({
    ...b,
    id: b.id ?? b.blobId ?? `att_${i}`,
    name:
      b.fileName ||
      b.originalFilename ||
      b.originalFileName ||
      b.blobName ||
      b.name ||
      "附件",
    url:
      b.url ||
      b.fileUrl ||
      b.downloadUrl ||
      b.downloadURL ||
      b.previewUrl ||
      b.previewURL ||
      b.link ||
      "",
  }));
}
/** å®¡æ‰¹è®°å½•在 tasks */
export function mapTasksToApprovalRecords(tasks) {
  const list = Array.isArray(tasks) ? tasks : [];
  return list
    .map((t, index) => ({
      id: t.id ?? index,
      operatorName: t.approverName || t.operatorName || t.createUserName || "—",
      result: mapRecordResult(t.approveAction ?? t.taskStatus ?? t.status),
      opinion: t.approveComment || t.comment || t.opinion || "",
      time: formatDisplayTime(
        t.approveTime || t.finishTime || t.updateTime || t.createTime || ""
      ),
      levelNo: t.levelNo ?? t.taskLevel,
    }))
    .sort((a, b) => {
      const la = Number(a.levelNo ?? 0);
      const lb = Number(b.levelNo ?? 0);
      if (la !== lb) return la - lb;
      return String(a.time).localeCompare(String(b.time));
    });
}
export function mapTasksToApprovalFlowNodes(tasks) {
  const grouped = mapTasksToFlowNodes(tasks);
  return grouped.map((node, i) => {
    const approvers = node.approvers || [];
    const statuses = approvers.map(a =>
      taskStatusToNodeStatus(a.taskStatus ?? a.status)
    );
    let nodeStatus = "wait";
    if (statuses.includes("error")) nodeStatus = "error";
    else if (statuses.length && statuses.every(s => s === "finish")) {
      nodeStatus = "finish";
    } else if (statuses.includes("process")) nodeStatus = "process";
    const names = approvers.map(a => a.approverName).filter(Boolean).join("、");
    const opinions = approvers
      .map(a => a.approveComment)
      .filter(Boolean)
      .join(";");
    return {
      nodeOrder: node.levelNo ?? i + 1,
      levelNo: node.levelNo ?? i + 1,
      approveType: node.approveType || "AND",
      approveTypeLabel: node.approveType === "OR" ? "或签" : "会签",
      approvers,
      approverName: names || "—",
      approveOpinion: opinions,
      nodeStatus,
    };
  });
}
export function computeApprovalFlowCurrentIndex(approvalFlowNodes = []) {
  const list = approvalFlowNodes || [];
  const processing = list.findIndex(n => n.nodeStatus === "process");
  if (processing >= 0) return processing;
  const errorIdx = list.findIndex(n => n.nodeStatus === "error");
  if (errorIdx >= 0) return errorIdx;
  return list.filter(n => n.nodeStatus === "finish").length;
}
export function applyFinReimbursementDetailEnrichment(mapped, raw = {}) {
  if (!mapped || typeof mapped !== "object") return mapped;
  const source = { ...raw, ...mapped };
  const tasks = Array.isArray(source.tasks) ? source.tasks : [];
  const attachments = mapReimbursementAttachments(source);
  const approvalRecords = tasks.length
    ? mapTasksToApprovalRecords(tasks)
    : mapApprovalRecords(source.records || source.approvalRecords);
  const approvalFlowNodes = tasks.length
    ? mapTasksToApprovalFlowNodes(tasks)
    : mapped.approvalFlowNodes || [];
  const flowNodes = tasks.length
    ? mapTasksToFlowNodes(tasks)
    : mapped.flowNodes || mapped.nodes || [];
  return {
    ...mapped,
    tasks,
    storageBlobVOList: attachments,
    attachmentList: attachments,
    invoiceAttachments: attachments,
    approvalRecords,
    approvalFlowNodes,
    currentNodeIndex: computeApprovalFlowCurrentIndex(approvalFlowNodes),
    rejectReason:
      approvalRecords.find(r => r.result === "rejected")?.opinion ||
      source.rejectReason ||
      "",
    flowNodes,
  };
}
src/pages/oa/ReimburseManage/_utils/travelReimburseUtils.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,82 @@
import dayjs from "dayjs";
export const EXPENSE_SUBJECT_OPTIONS = [
  { label: "交通费", value: "transport" },
  { label: "住宿费", value: "hotel" },
  { label: "餐饮费", value: "meal" },
  { label: "其他", value: "other" },
];
const TIER1_CITIES = ["北京", "上海", "广州", "深圳"];
export function expenseSubjectLabel(v) {
  return EXPENSE_SUBJECT_OPTIONS.find(x => x.value === v)?.label || "—";
}
export function detectTravelTier(destination) {
  const city = (destination || "").trim();
  if (!city) return "tier3";
  if (TIER1_CITIES.some(c => city.includes(c))) return "tier1";
  const tier2Keywords = ["杭州", "南京", "武汉", "成都", "重庆", "西安", "天津", "苏州", "长沙", "郑州"];
  if (tier2Keywords.some(c => city.includes(c))) return "tier2";
  return "tier3";
}
export function getTravelStandardByTier(tier) {
  const map = {
    tier1: { hotelPerNight: 600, transportPerDay: 80, mealPerDay: 100, label: "一线城市" },
    tier2: { hotelPerNight: 450, transportPerDay: 60, mealPerDay: 80, label: "二线城市" },
    tier3: { hotelPerNight: 350, transportPerDay: 40, mealPerDay: 60, label: "其他城市" },
  };
  return map[tier] || map.tier3;
}
export function computeTravelDays(startStr, endStr) {
  if (!startStr || !endStr) return null;
  const t0 = dayjs(startStr);
  const t1 = dayjs(endStr);
  if (!t0.isValid() || !t1.isValid() || !t1.isAfter(t0)) return null;
  return Math.max(1, Math.ceil(t1.diff(t0, "day", true)));
}
export function createEmptyExpenseDetail() {
  return {
    id: `ed_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
    invoiceDate: "",
    expenseSubject: "",
    amount: "",
    description: "",
  };
}
export function createEmptyTravelForm() {
  return {
    reimbursementId: undefined,
    applicantId: "",
    employeeNo: "",
    employeeName: "",
    reimburseReason: "",
    travelStartTime: "",
    travelEndTime: "",
    travelDays: undefined,
    departurePlace: "",
    destination: "",
    hotelStandard: undefined,
    hotelDays: undefined,
    livingSubsidy: undefined,
    transportSubsidy: undefined,
    lodgingLimit: undefined,
    applyAmount: "",
    payee: "",
    payeeAccount: "",
    payeeBank: "",
    expenseDetails: [],
    attachmentList: [],
    approvalFlowNodes: [{ approverId: "", approverName: "", nodeOrder: 1, signMode: "countersign" }],
    needSpecialApproval: false,
    deptId: "",
    deptName: "",
    travelTier: "tier3",
    standardTag: "",
  };
}
src/pages/oa/ReimburseManage/cost-reimburse/index.vue
@@ -1,18 +1,11 @@
<!--
  OA / æŠ¥é”€ç®¡ç† / è´¹ç”¨æŠ¥é”€
  è·¯ç”±ï¼š/pages/oa/ReimburseManage/cost-reimburse/index
  OA / æŠ¥é”€ç®¡ç† / è´¹ç”¨æŠ¥é”€ï¼ˆ/finReimbursement/listPage,reimbursementType=2)
-->
<template>
  <OaListPage v-if="config"
              :page-key="pageKey"
              :page-config="config" />
  <FinReimbursementListPage :module-key="APPROVAL_MODULE_KEYS.COST_REIMBURSE" />
</template>
<script setup>
  /** OA - æŠ¥é”€ç®¡ç† - è´¹ç”¨æŠ¥é”€ */
  import OaListPage from "../../_components/OaListPage.vue";
  import { useOaPage } from "../../_utils/useOaPage.js";
  const pageKey = "ReimburseManage/cost-reimburse";
  const { config } = useOaPage(pageKey);
  import FinReimbursementListPage from "../../_components/FinReimbursementListPage.vue";
  import { APPROVAL_MODULE_KEYS } from "../../_utils/approvalModuleRegistry.js";
</script>
src/pages/oa/ReimburseManage/reimburse-detail/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,120 @@
<!--
  å·®æ—…/费用报销详情页
-->
<template>
  <view class="oa-detail-page reimburse-detail-page">
    <PageHeader :title="pageTitle"
                @back="goBack" />
    <view v-if="loading"
          class="rd-loading-wrap">
      <up-loading-icon mode="circle" />
      <text class="rd-loading-text">加载中...</text>
    </view>
    <scroll-view v-else-if="reimburseRow"
                 class="oa-detail-scroll reimburse-detail-scroll"
                 scroll-y
                 :show-scrollbar="false">
      <ReimburseInstanceDetailBody :reimburse-row="reimburseRow"
                                 :module-key="moduleKey" />
    </scroll-view>
    <view v-else
          class="oa-empty">
      <up-empty mode="data"
                text="未获取到报销数据" />
    </view>
    <view v-if="reimburseRow && canEdit"
          class="oa-page-footer">
      <text class="oa-footer-btn btn-default"
            @click="goBack">返回</text>
      <text class="oa-footer-btn btn-primary"
            @click="goEdit">修改</text>
    </view>
  </view>
</template>
<script setup>
  import { computed, ref } from "vue";
  import { onLoad } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import ReimburseInstanceDetailBody from "../_components/ReimburseInstanceDetailBody.vue";
  import { OA_NAV } from "@/config/oaPaths.js";
  import { getApprovalModuleConfig } from "../../_utils/approvalModuleRegistry.js";
  import {
    canEditReimbursementRow,
    fetchFinReimbursementListItemDetail,
    resolveReimbursementDeleteId,
  } from "../../_utils/finReimbursementMappers.js";
  const moduleKey = ref("");
  const reimbursementId = ref("");
  const reimburseRow = ref(null);
  const loading = ref(false);
  const pageTitle = computed(
    () => `${getApprovalModuleConfig(moduleKey.value)?.label || "报销"}详情`
  );
  const canEdit = computed(() =>
    reimburseRow.value ? canEditReimbursementRow(reimburseRow.value) : false
  );
  const goBack = () => uni.navigateBack();
  const goEdit = () => {
    const rid = resolveReimbursementDeleteId(reimburseRow.value);
    if (rid == null) {
      uni.showToast({ title: "无法修改", icon: "none" });
      return;
    }
    uni.navigateTo({
      url: `${OA_NAV.reimburseForm}?moduleKey=${moduleKey.value}&mode=edit&reimbursementId=${rid}`,
    });
  };
  onLoad(async options => {
    moduleKey.value = options?.moduleKey || "";
    reimbursementId.value = options?.reimbursementId || "";
    if (!moduleKey.value || !reimbursementId.value) {
      uni.showToast({ title: "参数不完整", icon: "none" });
      setTimeout(goBack, 500);
      return;
    }
    loading.value = true;
    try {
      reimburseRow.value = await fetchFinReimbursementListItemDetail(
        { reimbursementId: reimbursementId.value },
        moduleKey.value
      );
      if (reimburseRow.value?.moduleKey) {
        moduleKey.value = reimburseRow.value.moduleKey;
      }
    } catch {
      uni.showToast({ title: "加载详情失败", icon: "none" });
    } finally {
      loading.value = false;
    }
  });
</script>
<style scoped lang="scss">
  @import "../../_styles/oa-approval-list.scss";
  @import "./reimburse-detail.scss";
  .rd-loading-wrap {
    flex: 1;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    padding: 80px 0;
  }
  .rd-loading-text {
    margin-top: 12px;
    font-size: 14px;
    color: #909399;
  }
</style>
src/pages/oa/ReimburseManage/reimburse-detail/reimburse-detail.scss
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,344 @@
.reimburse-detail-page {
  min-height: 100vh;
  background: #f2f4f7;
}
.reimburse-detail-scroll {
  padding-bottom: calc(72px + env(safe-area-inset-bottom));
}
.rd-hero {
  margin: 12px 16px 0;
  padding: 16px;
  background: linear-gradient(135deg, #f0f7ff 0%, #fff 55%);
  border-radius: 12px;
  box-shadow: 0 1px 4px rgba(15, 23, 42, 0.06);
  border: 1px solid #e8f0fe;
}
.rd-hero-top {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 10px;
}
.rd-bill-no {
  font-size: 13px;
  color: #8c8c8c;
  flex: 1;
  word-break: break-all;
}
.rd-status {
  flex-shrink: 0;
  font-size: 11px;
  padding: 5px 8px;
  border-radius: 4px;
  font-weight: 500;
  &.status-pending {
    color: #d46b08;
    background: #fff7e6;
  }
  &.status-approved {
    color: #389e0d;
    background: #f6ffed;
  }
  &.status-rejected {
    color: #cf1322;
    background: #fff1f0;
  }
  &.status-draft {
    color: #595959;
    background: #f5f5f5;
  }
  &.status-cancelled {
    color: #8c8c8c;
    background: #fafafa;
  }
}
.rd-reason {
  display: block;
  margin-top: 10px;
  font-size: 17px;
  font-weight: 600;
  color: #1a1a1a;
  line-height: 1.45;
}
.rd-amount-row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  margin-top: 14px;
  padding-top: 12px;
  border-top: 1px dashed #e8ecf0;
}
.rd-amount-label {
  font-size: 14px;
  color: #8c8c8c;
}
.rd-amount {
  font-size: 22px;
  font-weight: 700;
  color: #2979ff;
}
.rd-section {
  margin: 12px 16px 0;
}
.rd-section-hd {
  padding: 4px 4px 8px;
  display: flex;
  align-items: center;
  justify-content: space-between;
}
.rd-section-title {
  font-size: 13px;
  font-weight: 600;
  color: #909399;
}
.rd-section-count {
  font-size: 12px;
  color: #c0c4cc;
}
.rd-group {
  background: #fff;
  border-radius: 12px;
  overflow: hidden;
  box-shadow: 0 1px 4px rgba(15, 23, 42, 0.04);
}
.rd-cell {
  display: flex;
  align-items: flex-start;
  padding: 13px 16px;
  border-bottom: 1px solid #f5f6f8;
  font-size: 14px;
  line-height: 1.45;
  &:last-child {
    border-bottom: none;
  }
}
.rd-label {
  width: 88px;
  flex-shrink: 0;
  color: #8c8c8c;
}
.rd-value {
  flex: 1;
  color: #303133;
  text-align: right;
  word-break: break-all;
}
.rd-detail-item {
  padding: 14px 16px;
  border-bottom: 1px solid #f5f6f8;
  &:last-child {
    border-bottom: none;
  }
}
.rd-detail-head {
  display: flex;
  align-items: center;
  margin-bottom: 10px;
}
.rd-detail-badge {
  width: 24px;
  height: 24px;
  border-radius: 6px;
  background: #ecf5ff;
  color: #2979ff;
  font-size: 13px;
  font-weight: 600;
  text-align: center;
  line-height: 24px;
  margin-right: 8px;
}
.rd-detail-title {
  font-size: 15px;
  font-weight: 600;
  color: #303133;
}
.rd-detail-amount {
  margin-left: auto;
  font-size: 15px;
  font-weight: 600;
  color: #2979ff;
}
.rd-flow-node {
  display: flex;
  padding: 12px 16px;
  border-bottom: 1px solid #f5f6f8;
  &:last-child {
    border-bottom: none;
  }
}
.rd-flow-line {
  display: flex;
  flex-direction: column;
  align-items: center;
  margin-right: 12px;
  width: 20px;
}
.rd-flow-dot {
  width: 10px;
  height: 10px;
  border-radius: 50%;
  background: #2979ff;
  flex-shrink: 0;
}
.rd-flow-bar {
  flex: 1;
  width: 2px;
  min-height: 20px;
  background: #e4e7ed;
  margin-top: 4px;
}
.rd-flow-body {
  flex: 1;
  min-width: 0;
}
.rd-flow-level {
  font-size: 14px;
  font-weight: 500;
  color: #303133;
}
.rd-flow-type {
  font-size: 12px;
  color: #909399;
  margin-top: 2px;
}
.rd-flow-approver {
  display: flex;
  align-items: center;
  margin-top: 8px;
}
.rd-flow-avatar {
  width: 28px;
  height: 28px;
  border-radius: 50%;
  color: #fff;
  font-size: 12px;
  font-weight: 600;
  display: flex;
  align-items: center;
  justify-content: center;
  margin-right: 8px;
}
.rd-flow-approver-meta {
  flex: 1;
  min-width: 0;
}
.rd-flow-name {
  display: block;
  font-size: 14px;
  color: #303133;
}
.rd-flow-status {
  display: block;
  font-size: 12px;
  color: #909399;
  margin-top: 2px;
}
.rd-record-item {
  padding: 14px 16px;
  border-bottom: 1px solid #f5f6f8;
  &:last-child {
    border-bottom: none;
  }
}
.rd-record-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
}
.rd-record-operator {
  font-size: 15px;
  font-weight: 500;
  color: #303133;
}
.rd-record-tag {
  font-size: 11px;
  padding: 2px 8px;
  border-radius: 4px;
  flex-shrink: 0;
  &--approved {
    color: #389e0d;
    background: #f6ffed;
  }
  &--rejected {
    color: #cf1322;
    background: #fff1f0;
  }
  &--pending {
    color: #d46b08;
    background: #fff7e6;
  }
}
.rd-record-time {
  display: block;
  font-size: 12px;
  color: #c0c4cc;
  margin-top: 4px;
}
.rd-record-opinion {
  display: block;
  font-size: 13px;
  color: #606266;
  margin-top: 6px;
  line-height: 1.45;
}
.rd-empty {
  padding: 20px;
  text-align: center;
  font-size: 13px;
  color: #c0c4cc;
}
.rd-attach {
  padding: 12px 16px;
  font-size: 14px;
  color: #2979ff;
  border-bottom: 1px solid #f5f6f8;
}
src/pages/oa/ReimburseManage/reimburse-form/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,564 @@
<!--
  å·®æ—…/费用报销新增/编辑(与 Web å­—段一致,移动端优化选人/布局)
-->
<template>
  <view class="oa-detail-page reimburse-form-page">
    <PageHeader :title="pageTitle"
                @back="goBack" />
    <scroll-view class="oa-detail-scroll reimburse-scroll"
                 scroll-y
                 :show-scrollbar="false">
      <view v-if="loading"
            class="rf-loading">加载中...</view>
      <view v-else>
        <!-- ç”³è¯·äºº -->
        <view class="rf-section">
          <view class="rf-section-hd">
            <text class="rf-section-title">申请人</text>
          </view>
          <view class="rf-group">
            <view class="rf-applicant-card"
                  :class="{ 'is-empty': !form.applicantId }"
                  @click="showApplicantPicker = true">
              <view class="rf-applicant-avatar"
                    :style="{ backgroundColor: applicantAvatarColor }">
                {{ (form.employeeName || '选').charAt(0) }}
              </view>
              <view class="rf-applicant-meta">
                <text class="rf-applicant-name">{{ form.employeeName || '请选择员工' }}</text>
                <text class="rf-applicant-sub">{{ applicantDisplaySub }}</text>
              </view>
              <text class="rf-applicant-action">{{ form.applicantId ? '更换' : '选择' }}</text>
            </view>
          </view>
        </view>
        <!-- åŸºæœ¬ä¿¡æ¯ -->
        <view class="rf-section">
          <view class="rf-section-hd">
            <text class="rf-section-title">基本信息</text>
          </view>
          <view class="rf-group">
            <view class="rf-cell rf-cell--col">
              <text class="rf-label required">报销原因</text>
              <view class="rf-textarea-wrap">
                <up-textarea v-model="form.reimburseReason"
                             placeholder="请填写出差及报销原因"
                             maxlength="2000"
                             border="none"
                             height="80" />
              </view>
            </view>
            <template v-if="isTravel">
              <view class="rf-cell rf-cell--tap"
                    @click="openDatePicker('travelStartTime')">
                <text class="rf-label required">出差开始</text>
                <view class="rf-value-wrap">
                  <text class="rf-value"
                        :class="{ placeholder: !form.travelStartTime }">
                    {{ form.travelStartTime || '请选择' }}
                  </text>
                  <up-icon name="calendar"
                           size="18"
                           color="#c0c4cc" />
                </view>
              </view>
              <view class="rf-cell rf-cell--tap"
                    @click="openDatePicker('travelEndTime')">
                <text class="rf-label required">出差结束</text>
                <view class="rf-value-wrap">
                  <text class="rf-value"
                        :class="{ placeholder: !form.travelEndTime }">
                    {{ form.travelEndTime || '请选择' }}
                  </text>
                  <up-icon name="calendar"
                           size="18"
                           color="#c0c4cc" />
                </view>
              </view>
              <view class="rf-cell">
                <text class="rf-label">出差天数</text>
                <view class="rf-value-wrap">
                  <text class="rf-value">{{ travelDaysDisplay || '—' }}</text>
                  <text class="rf-value"
                        style="color:#909399;margin-left:4px">天</text>
                </view>
              </view>
              <view class="rf-cell">
                <text class="rf-label required">出差地</text>
                <view class="rf-input-body">
                  <up-input v-model="form.departurePlace"
                            placeholder="出发城市"
                            border="none"
                            input-align="right"
                            @blur="recalcTravelStandards" />
                </view>
              </view>
              <view class="rf-cell">
                <text class="rf-label required">目的地</text>
                <view class="rf-input-body">
                  <up-input v-model="form.destination"
                            placeholder="目的城市"
                            border="none"
                            input-align="right"
                            @blur="recalcTravelStandards" />
                </view>
              </view>
            </template>
            <template v-else>
              <view class="rf-cell rf-cell--tap"
                    @click="showCategorySheet = true">
                <text class="rf-label required">费用类型</text>
                <view class="rf-value-wrap">
                  <text class="rf-value"
                        :class="{ placeholder: !form.expenseCategory }">{{ categoryLabel }}</text>
                  <up-icon name="arrow-right"
                           size="14"
                           color="#c0c4cc" />
                </view>
              </view>
              <view class="rf-chips">
                <text v-for="cat in quickCategories"
                      :key="cat.value"
                      class="rf-chip"
                      :class="{ active: form.expenseCategory === cat.value }"
                      @click="applyTemplate(cat.value)">{{ cat.label }}</text>
              </view>
            </template>
          </view>
        </view>
        <!-- å·®æ—…标准 -->
        <view v-if="isTravel"
              class="rf-section">
          <view class="rf-section-hd">
            <text class="rf-section-title">差旅标准</text>
            <text class="rf-section-extra">{{ travelTierLabel }}</text>
          </view>
          <view v-if="overBudgetWarnings.length"
                class="rf-warn-box">
            <text v-for="(w, i) in overBudgetWarnings"
                  :key="i"
                  class="rf-warn-line">{{ w }}</text>
          </view>
          <view class="rf-group">
            <view class="rf-cell">
              <text class="rf-label">酒店标准</text>
              <view class="rf-input-body">
                <up-input v-model="form.hotelStandard"
                          type="digit"
                          placeholder="元/晚"
                          border="none"
                          input-align="right"
                          @blur="recalcTravelStandards" />
              </view>
            </view>
            <view class="rf-cell">
              <text class="rf-label">住宿天数</text>
              <view class="rf-input-body">
                <up-input v-model="form.hotelDays"
                          type="number"
                          border="none"
                          input-align="right"
                          @blur="recalcTravelStandards" />
              </view>
            </view>
            <view class="rf-cell">
              <text class="rf-label">生活补贴</text>
              <view class="rf-input-body">
                <up-input v-model="form.livingSubsidy"
                          type="digit"
                          border="none"
                          input-align="right" />
              </view>
            </view>
            <view class="rf-cell">
              <text class="rf-label">交通补贴</text>
              <view class="rf-value-wrap">
                <text class="rf-value">建议 {{ suggestedTransportSubsidy }} å…ƒ</text>
              </view>
            </view>
            <view class="rf-cell">
              <text class="rf-label">住宿限额</text>
              <view class="rf-value-wrap">
                <text class="rf-value">建议 {{ suggestedHotelLimit }} å…ƒ</text>
              </view>
            </view>
            <view class="rf-cell">
              <text class="rf-label">特批标记</text>
              <text class="rf-tag"
                    :class="form.needSpecialApproval ? 'rf-tag--danger' : 'rf-tag--ok'">
                {{ form.needSpecialApproval ? '超支需特批' : '在标准内' }}
              </text>
            </view>
          </view>
        </view>
        <!-- é‡‘额与收款 -->
        <view class="rf-section">
          <view class="rf-section-hd">
            <text class="rf-section-title">金额与收款</text>
            <text class="rf-section-extra"
                  @click="syncApplyAmountFromDetails">按明细 {{ detailTotalAmount }} å…ƒ</text>
          </view>
          <view class="rf-group">
            <view class="rf-cell">
              <text class="rf-label required">申请金额</text>
              <view class="rf-input-body">
                <up-input v-model="form.applyAmount"
                          type="digit"
                          placeholder="元"
                          border="none"
                          input-align="right" />
              </view>
            </view>
            <view class="rf-cell">
              <text class="rf-label required">收款人</text>
              <view class="rf-input-body">
                <up-input v-model="form.payee"
                          placeholder="收款人"
                          border="none"
                          input-align="right" />
              </view>
            </view>
            <view class="rf-cell">
              <text class="rf-label">收款账号</text>
              <view class="rf-input-body">
                <up-input v-model="form.payeeAccount"
                          placeholder="选填"
                          border="none"
                          input-align="right" />
              </view>
            </view>
            <view class="rf-cell">
              <text class="rf-label">开户支行</text>
              <view class="rf-input-body">
                <up-input v-if="isTravel"
                          v-model="form.payeeBank"
                          placeholder="选填"
                          border="none"
                          input-align="right" />
                <up-input v-else
                          v-model="form.bankBranch"
                          placeholder="选填"
                          border="none"
                          input-align="right" />
              </view>
            </view>
          </view>
        </view>
        <!-- æŠ¥é”€æ˜Žç»†ï¼šåˆ—表摘要 + è¯¦æƒ…按钮 -->
        <view class="rf-section">
          <view class="rf-section-hd">
            <text class="rf-section-title">报销明细</text>
            <text class="rf-section-extra"
                  @click="addAndOpenDetail">+ æ–°å¢ž</text>
          </view>
          <view class="rf-group"
                v-if="form.expenseDetails.length">
            <view v-for="(row, idx) in form.expenseDetails"
                  :key="row.id || idx"
                  class="rf-detail-row"
                  :class="{ 'rf-detail-row--warn': detailSummary(row).incomplete }"
                  @click="openDetailEditor(idx)">
              <view class="rf-detail-index">{{ idx + 1 }}</view>
              <view class="rf-detail-body">
                <view class="rf-detail-line1">
                  <text class="rf-detail-subject">{{ detailSummary(row).subject }}</text>
                  <text class="rf-detail-amount">{{ detailSummary(row).amount }}</text>
                </view>
                <text class="rf-detail-line2">{{ detailSummary(row).sub }}</text>
              </view>
              <text class="rf-detail-action"
                    @click.stop="openDetailEditor(idx)">详情</text>
            </view>
          </view>
          <view v-else
                class="rf-group">
            <view class="rf-empty"
                  @click="addAndOpenDetail">点击添加报销明细</view>
          </view>
        </view>
        <!-- é™„ä»¶ -->
        <view class="rf-section">
          <view class="rf-section-hd">
            <text class="rf-section-title">附件(发票)</text>
          </view>
          <view class="rf-group">
            <view v-for="(f, i) in form.attachmentList"
                  :key="i"
                  class="rf-attach-item">
              <text>{{ f.name || '附件' }}</text>
              <text class="rf-detail-del"
                    @click="removeAttachment(i)">删除</text>
            </view>
            <view class="rf-upload-zone"
                  @click="chooseAttachment">
              <up-icon name="plus-circle"
                       size="22"
                       color="#2979ff" />
              <text>上传发票/附件</text>
            </view>
          </view>
        </view>
        <!-- å®¡æ‰¹æµç¨‹ -->
        <view class="rf-section">
          <view class="rf-section-hd">
            <text class="rf-section-title">审批流程</text>
          </view>
          <view class="rf-group"
                style="padding:12px">
            <ReimburseApprovalFlowEditor v-model="form.approvalFlowNodes"
                                         :user-options="flowUserOptions" />
            <text class="rf-hint-row">每级须指定审批人,支持搜索姓名或工号</text>
          </view>
        </view>
      </view>
    </scroll-view>
    <view class="oa-page-footer">
      <text class="oa-footer-btn btn-default"
            @click="goBack">取消</text>
      <text class="oa-footer-btn btn-primary"
            :class="{ 'is-disabled': submitting }"
            @click="onSubmit">提交</text>
    </view>
    <OaUserSearchPicker v-model:show="showApplicantPicker"
                        v-model="form.applicantId"
                        title="选择申请人"
                        :users="flowUserOptions"
                        @select="onApplicantPicked" />
    <up-action-sheet :show="showCategorySheet"
                     title="费用类型"
                     :actions="categoryActions"
                     @select="onCategorySelect"
                     @close="showCategorySheet = false" />
    <ReimburseExpenseDetailSheet v-model:show="showDetailSheet"
                                 v-model="detailDraft"
                                 :index="editingDetailIndex"
                                 :is-travel="isTravel"
                                 :subject-options="expenseSubjectOptions"
                                 @confirm="onDetailSheetConfirm"
                                 @delete="onDetailSheetDelete" />
    <up-popup :show="showDatePicker"
              mode="bottom"
              round="16"
              @close="showDatePicker = false">
      <up-datetime-picker :show="true"
                          v-model="datePickerTs"
                          mode="datetime"
                          @confirm="onDateConfirm"
                          @cancel="showDatePicker = false" />
    </up-popup>
  </view>
</template>
<script setup>
  import { computed, reactive, ref } from "vue";
  import { onLoad } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import OaUserSearchPicker from "../../_components/OaUserSearchPicker.vue";
  import ReimburseExpenseDetailSheet from "../_components/ReimburseExpenseDetailSheet.vue";
  import config from "@/config.js";
  import { getToken } from "@/utils/auth";
  import { parseTime } from "@/utils/ruoyi";
  import { getApprovalModuleConfig } from "../../_utils/approvalModuleRegistry.js";
  import { consumeReimburseEditFromApprove } from "../../_utils/reimburseApproveBridge.js";
  import { EXPENSE_CATEGORY_OPTIONS } from "../_utils/costReimburseUtils.js";
  import { buildExpenseDetailSummary } from "../_utils/expenseDetailDisplay.js";
  import ReimburseApprovalFlowEditor from "../_components/ReimburseApprovalFlowEditor.vue";
  import { useFinReimburseForm } from "./useFinReimburseForm.js";
  const moduleKey = ref("");
  const mode = ref("add");
  const reimbursementId = ref("");
  const {
    form,
    isTravel,
    submitting,
    loading,
    flowUserOptions,
    travelDaysDisplay,
    travelTierLabel,
    suggestedTransportSubsidy,
    suggestedHotelLimit,
    detailTotalAmount,
    overBudgetWarnings,
    expenseSubjectOptions,
    categoryActions,
    categoryLabel,
    showApplicantPicker,
    applicantDisplaySub,
    applicantAvatarColor,
    showCategorySheet,
    loadUserPool,
    onApplicantPicked,
    recalcTravelStandards,
    syncApplyAmountFromDetails,
    addExpenseDetail,
    removeExpenseDetail,
    applyTemplate,
    initForm,
    loadEdit,
    submitForm,
  } = useFinReimburseForm(moduleKey, mode);
  const showDatePicker = ref(false);
  const datePickerField = ref("");
  const datePickerTs = ref(Date.now());
  const showDetailSheet = ref(false);
  const editingDetailIndex = ref(0);
  const detailDraft = reactive({
    invoiceDate: "",
    expenseSubject: "",
    amount: "",
    description: "",
  });
  const quickCategories = EXPENSE_CATEGORY_OPTIONS.slice(0, 4);
  const pageTitle = computed(() => {
    const label = getApprovalModuleConfig(moduleKey.value)?.label || "报销";
    return mode.value === "edit" ? `编辑${label}` : `新增${label}`;
  });
  const goBack = () => uni.navigateBack();
  function detailSummary(row) {
    return buildExpenseDetailSummary(row, {
      isTravel: isTravel.value,
      subjectOptions: expenseSubjectOptions.value,
    });
  }
  function openDetailEditor(idx) {
    editingDetailIndex.value = idx;
    const row = form.expenseDetails[idx];
    if (!row) return;
    Object.assign(detailDraft, JSON.parse(JSON.stringify(row)));
    showDetailSheet.value = true;
  }
  function addAndOpenDetail() {
    addExpenseDetail();
    openDetailEditor(form.expenseDetails.length - 1);
  }
  function onDetailSheetConfirm(data) {
    const idx = editingDetailIndex.value;
    if (form.expenseDetails[idx]) {
      Object.assign(form.expenseDetails[idx], data);
    }
    recalcTravelStandards();
  }
  function onDetailSheetDelete() {
    const idx = editingDetailIndex.value;
    removeExpenseDetail(idx);
    showDetailSheet.value = false;
  }
  function onCategorySelect(action) {
    form.expenseCategory = action.value;
    applyTemplate(action.value);
    showCategorySheet.value = false;
  }
  function openDatePicker(field) {
    datePickerField.value = field;
    detailDateIndex.value = -1;
    datePickerTs.value = Date.now();
    showDatePicker.value = true;
  }
  function onDateConfirm(e) {
    const ts = e?.value ?? datePickerTs.value;
    if (datePickerField.value) {
      form[datePickerField.value] = parseTime(ts, "{y}-{m}-{d} {h}:{i}:{s}");
      recalcTravelStandards();
    }
    showDatePicker.value = false;
  }
  function chooseAttachment() {
    uni.chooseImage({
      count: 9,
      success: res => {
        (res.tempFilePaths || []).forEach(path => uploadOne(path));
      },
    });
  }
  function uploadOne(filePath) {
    uni.uploadFile({
      url: `${config.baseUrl}/file/upload`,
      filePath,
      name: "file",
      header: { Authorization: "Bearer " + getToken() },
      success: res => {
        try {
          const data = JSON.parse(res.data || "{}");
          const url = data.url || data.data?.url || "";
          const name = data.originalFilename || data.fileName || "附件";
          if (!form.attachmentList) form.attachmentList = [];
          form.attachmentList.push({ name, url });
        } catch {
          uni.showToast({ title: "上传解析失败", icon: "none" });
        }
      },
      fail: () => uni.showToast({ title: "上传失败", icon: "none" }),
    });
  }
  function removeAttachment(i) {
    form.attachmentList.splice(i, 1);
  }
  async function onSubmit() {
    const ok = await submitForm();
    if (ok) setTimeout(goBack, 400);
  }
  onLoad(async options => {
    moduleKey.value = options?.moduleKey || "";
    mode.value = options?.mode === "edit" ? "edit" : "add";
    reimbursementId.value = options?.reimbursementId || "";
    const fromApprove = consumeReimburseEditFromApprove();
    if (fromApprove?.moduleKey) {
      moduleKey.value = fromApprove.moduleKey;
      mode.value = "edit";
      reimbursementId.value = String(fromApprove.reimbursementId ?? "");
    }
    if (!moduleKey.value) {
      uni.showToast({ title: "缺少模块类型", icon: "none" });
      setTimeout(goBack, 500);
      return;
    }
    await loadUserPool();
    await initForm();
    if (mode.value === "edit" && reimbursementId.value) {
      try {
        await loadEdit(reimbursementId.value);
      } catch {
        uni.showToast({ title: "加载失败", icon: "none" });
      }
    }
  });
</script>
<style scoped lang="scss">
  @import "../../_styles/oa-approval-list.scss";
  @import "./reimburse-form.scss";
</style>
src/pages/oa/ReimburseManage/reimburse-form/reimburse-form.scss
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,354 @@
.reimburse-form-page {
  min-height: 100vh;
  background: #f2f4f7;
}
.reimburse-scroll {
  padding-bottom: calc(80px + env(safe-area-inset-bottom));
}
.rf-section {
  margin: 12px 16px 0;
}
.rf-section-hd {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 4px 4px 8px;
}
.rf-section-title {
  font-size: 13px;
  font-weight: 600;
  color: #909399;
  letter-spacing: 0.5px;
}
.rf-section-extra {
  font-size: 13px;
  color: #2979ff;
}
.rf-group {
  background: #fff;
  border-radius: 12px;
  overflow: hidden;
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
}
.rf-applicant-card {
  display: flex;
  align-items: center;
  padding: 16px;
  background: linear-gradient(135deg, #f8fbff 0%, #fff 60%);
  border-bottom: 1px solid #f0f2f5;
  &.is-empty {
    background: #fff;
  }
}
.rf-applicant-avatar {
  width: 48px;
  height: 48px;
  border-radius: 50%;
  color: #fff;
  font-size: 18px;
  font-weight: 600;
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
}
.rf-applicant-meta {
  flex: 1;
  margin-left: 12px;
  min-width: 0;
}
.rf-applicant-name {
  font-size: 17px;
  font-weight: 600;
  color: #303133;
}
.rf-applicant-sub {
  font-size: 13px;
  color: #909399;
  margin-top: 4px;
}
.rf-applicant-action {
  font-size: 14px;
  color: #2979ff;
  padding: 6px 12px;
  background: #ecf5ff;
  border-radius: 16px;
  flex-shrink: 0;
}
.rf-cell {
  display: flex;
  align-items: center;
  min-height: 52px;
  padding: 12px 16px;
  border-bottom: 1px solid #f5f6f8;
  &:last-child {
    border-bottom: none;
  }
  &--tap:active {
    background: #f9fafb;
  }
  &--col {
    flex-direction: column;
    align-items: stretch;
    min-height: auto;
    padding-bottom: 14px;
  }
}
.rf-label {
  width: 88px;
  flex-shrink: 0;
  font-size: 15px;
  color: #303133;
  &.required::before {
    content: "*";
    color: #f56c6c;
    margin-right: 2px;
  }
}
.rf-value-wrap {
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: flex-end;
  min-width: 0;
  gap: 4px;
}
.rf-value {
  font-size: 15px;
  color: #303133;
  text-align: right;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  &.placeholder {
    color: #c0c4cc;
  }
}
.rf-input-body {
  flex: 1;
  min-width: 0;
}
.rf-textarea-wrap {
  width: 100%;
  margin-top: 8px;
  background: #f5f7fa;
  border-radius: 8px;
  padding: 4px 8px;
}
.rf-inline-input {
  text-align: right;
  font-size: 15px;
}
.rf-hint-row {
  padding: 8px 16px 12px;
  font-size: 12px;
  color: #909399;
}
.rf-warn-box {
  margin: 0 16px 8px;
  padding: 10px 12px;
  background: #fdf6ec;
  border-radius: 8px;
  border-left: 3px solid #e6a23c;
}
.rf-warn-line {
  display: block;
  font-size: 12px;
  color: #e6a23c;
  line-height: 1.5;
}
.rf-tag {
  font-size: 13px;
  padding: 4px 10px;
  border-radius: 4px;
  &--ok {
    color: #67c23a;
    background: #f0f9eb;
  }
  &--danger {
    color: #f56c6c;
    background: #fef0f0;
  }
}
.rf-chips {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  padding: 0 16px 14px;
}
.rf-chip {
  font-size: 13px;
  padding: 6px 14px;
  background: #f5f7fa;
  color: #606266;
  border-radius: 20px;
  border: 1px solid #ebeef5;
  &.active {
    background: #ecf5ff;
    color: #2979ff;
    border-color: #b3d8ff;
  }
}
.rf-detail-row {
  display: flex;
  align-items: center;
  padding: 14px 16px;
  border-bottom: 1px solid #f5f6f8;
  min-height: 64px;
  &:last-child {
    border-bottom: none;
  }
  &:active {
    background: #f9fafb;
  }
  &--warn .rf-detail-subject {
    color: #e6a23c;
  }
}
.rf-detail-index {
  width: 28px;
  height: 28px;
  border-radius: 8px;
  background: #ecf5ff;
  color: #2979ff;
  font-size: 14px;
  font-weight: 600;
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
}
.rf-detail-body {
  flex: 1;
  margin: 0 10px;
  min-width: 0;
}
.rf-detail-line1 {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
}
.rf-detail-subject {
  font-size: 15px;
  font-weight: 500;
  color: #303133;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  flex: 1;
}
.rf-detail-amount {
  font-size: 15px;
  font-weight: 600;
  color: #2979ff;
  flex-shrink: 0;
}
.rf-detail-line2 {
  display: block;
  font-size: 12px;
  color: #909399;
  margin-top: 4px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.rf-detail-action {
  flex-shrink: 0;
  font-size: 14px;
  color: #2979ff;
  padding: 6px 12px;
  background: #ecf5ff;
  border-radius: 16px;
  border: 1px solid #d9ecff;
}
.rf-detail-del {
  font-size: 13px;
  color: #f56c6c;
}
.rf-upload-zone {
  margin: 0 16px 14px;
  padding: 20px;
  border: 1px dashed #c0c4cc;
  border-radius: 10px;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
  color: #2979ff;
  font-size: 14px;
  background: #fafbfc;
}
.rf-attach-item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 10px 16px;
  border-bottom: 1px solid #f5f6f8;
  font-size: 14px;
}
.rf-link {
  font-size: 13px;
  color: #2979ff;
  padding: 4px 0;
}
.rf-empty {
  text-align: center;
  padding: 20px;
  color: #c0c4cc;
  font-size: 13px;
}
.rf-loading {
  padding: 60px;
  text-align: center;
  color: #909399;
}
src/pages/oa/ReimburseManage/reimburse-form/useFinReimburseForm.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,434 @@
import { computed, reactive, ref } from "vue";
import { userListNoPageByTenantId } from "@/api/system/user";
import useUserStore from "@/store/modules/user";
import { persistFinReimbursement } from "@/api/oa/finReimbursement.js";
import {
  isActiveUser,
  unwrapUserList,
  userAvatarColor,
  userSelectLabel,
  userSubLabel,
} from "../../_utils/userPickerUtils.js";
import { APPROVAL_MODULE_KEYS } from "../../_utils/approvalModuleRegistry.js";
import {
  buildCostReimbursementSaveDto,
  buildTravelReimbursementSaveDto,
  fetchFinReimbursementFormDetail,
  getReimbursementTypeByModuleKey,
  validateReimbursementPersistDto,
} from "../../_utils/finReimbursementMappers.js";
import {
  applyCategoryTemplate,
  createEmptyCostForm,
  EXPENSE_CATEGORY_OPTIONS,
  EXPENSE_SUBJECT_OPTIONS as COST_SUBJECT_OPTIONS,
  createEmptyExpenseDetail as createCostDetail,
} from "../_utils/costReimburseUtils.js";
import {
  computeTravelDays,
  createEmptyExpenseDetail,
  createEmptyTravelForm,
  detectTravelTier,
  EXPENSE_SUBJECT_OPTIONS,
  getTravelStandardByTier,
} from "../_utils/travelReimburseUtils.js";
const userStore = useUserStore();
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 (Number(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;
}
export function useFinReimburseForm(moduleKeyRef, modeRef) {
  const isTravel = computed(
    () => moduleKeyRef.value === APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE
  );
  const form = reactive(
    moduleKeyRef.value === APPROVAL_MODULE_KEYS.COST_REIMBURSE
      ? createEmptyCostForm()
      : createEmptyTravelForm()
  );
  const submitting = ref(false);
  const loading = ref(false);
  const allUsersCache = ref([]);
  const showApplicantPicker = ref(false);
  const applicantDisplaySub = computed(() => {
    if (!form.applicantId) return "点击选择申请人";
    const u = userById(form.applicantId);
    if (u) return userSubLabel(u) || form.employeeNo || "";
    return form.employeeNo ? `工号 ${form.employeeNo}` : "";
  });
  const applicantAvatarColor = computed(() =>
    userAvatarColor(form.employeeName || form.employeeNo || "")
  );
  const showCategorySheet = ref(false);
  const showSubjectSheet = ref(false);
  const editingDetailIndex = ref(-1);
  const pickApplicantId = ref("");
  const pickCategoryValue = ref("");
  const pickSubjectValue = ref("");
  const flowUserOptions = computed(() => allUsersCache.value.filter(isActiveUser));
  const travelDaysDisplay = computed(() => {
    if (!isTravel.value) return "";
    const d = computeTravelDays(form.travelStartTime, form.travelEndTime);
    return d == null ? "" : String(d);
  });
  const travelTierLabel = computed(() => {
    if (!isTravel.value) return "";
    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(() => {
    if (!isTravel.value) return [];
    return buildOverBudgetWarnings(
      form,
      detailTotalAmount.value,
      suggestedHotelLimit.value,
      suggestedTransportSubsidy.value,
      suggestedLivingSubsidy.value
    );
  });
  const expenseSubjectOptions = computed(() =>
    isTravel.value ? EXPENSE_SUBJECT_OPTIONS : COST_SUBJECT_OPTIONS
  );
  const categoryActions = computed(() =>
    EXPENSE_CATEGORY_OPTIONS.map(x => ({ name: x.label, value: x.value }))
  );
  const categoryLabel = computed(() => {
    const hit = EXPENSE_CATEGORY_OPTIONS.find(x => x.value === form.expenseCategory);
    return hit?.label || "请选择费用类型";
  });
  async function loadUserPool() {
    try {
      allUsersCache.value = unwrapUserList(await userListNoPageByTenantId());
    } catch {
      allUsersCache.value = [];
    }
  }
  function userLabel(u) {
    return userSelectLabel(u);
  }
  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 fillApplicantFromUser(u) {
    if (!u) return;
    form.applicantId = u.userId ?? u.id;
    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 ?? "";
  }
  function onApplicantPicked(uidOrUser) {
    const u =
      typeof uidOrUser === "object" && uidOrUser
        ? uidOrUser
        : userById(uidOrUser);
    fillApplicantFromUser(u);
  }
  /** æ–°å¢žæ—¶é»˜è®¤å¸¦å‡ºå½“前登录人,减少选人步骤 */
  function tryApplyCurrentUser() {
    if (modeRef.value === "edit" || form.applicantId) return;
    const id = userStore.id;
    if (!id) return;
    let u = userById(id);
    if (!u) {
      u = {
        userId: id,
        nickName: userStore.nickName,
        userName: userStore.name,
      };
    }
    fillApplicantFromUser(u);
  }
  function recalcTravelStandards() {
    if (!isTravel.value) return;
    form.travelTier = detectTravelTier(form.destination);
    const std = getTravelStandardByTier(form.travelTier);
    if (form.hotelStandard == null || form.hotelStandard === "" || 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 === "") {
        form.hotelDays = Math.max(0, days - 1);
      }
      if (form.livingSubsidy == null || form.livingSubsidy === "" || form.livingSubsidy === 0) {
        form.livingSubsidy = suggestedLivingSubsidy.value;
      }
    }
    form.needSpecialApproval = overBudgetWarnings.value.length > 0;
  }
  function syncApplyAmountFromDetails() {
    form.applyAmount = detailTotalAmount.value;
    recalcTravelStandards();
  }
  function addExpenseDetail() {
    const row = isTravel.value ? createEmptyExpenseDetail() : createCostDetail();
    form.expenseDetails.push(row);
  }
  function removeExpenseDetail(index) {
    form.expenseDetails.splice(index, 1);
    recalcTravelStandards();
  }
  function applyTemplate(category) {
    applyCategoryTemplate(form, category);
    syncApplyAmountFromDetails();
  }
  function resetFormForModule() {
    const empty = isTravel.value ? createEmptyTravelForm() : createEmptyCostForm();
    Object.keys(form).forEach(k => delete form[k]);
    Object.assign(form, empty);
    if (!form.approvalFlowNodes?.length) {
      form.approvalFlowNodes = [
        { approverId: "", approverName: "", nodeOrder: 1, signMode: "countersign" },
      ];
    }
  }
  async function loadEdit(reimbursementId) {
    loading.value = true;
    try {
      if (!allUsersCache.value.length) await loadUserPool();
      const row = await fetchFinReimbursementFormDetail(
        { reimbursementId },
        moduleKeyRef.value
      );
      if (row?.moduleKey && row.moduleKey !== moduleKeyRef.value) {
        moduleKeyRef.value = row.moduleKey;
      }
      Object.assign(form, JSON.parse(JSON.stringify(row)), {
        reimbursementId: row.reimbursementId ?? row.id,
        expenseDetails: JSON.parse(JSON.stringify(row.expenseDetails || [])),
        approvalFlowNodes: JSON.parse(
          JSON.stringify(
            row.approvalFlowNodes?.length
              ? row.approvalFlowNodes
              : [{ approverId: "", approverName: "", nodeOrder: 1, signMode: "countersign" }]
          )
        ),
        attachmentList: JSON.parse(JSON.stringify(row.attachmentList || [])),
      });
      if (!isTravel.value && form.expenseCategory) {
        /* å·²ç”± mapCost è½¬ä¸º value */
      }
      recalcTravelStandards();
    } finally {
      loading.value = false;
    }
  }
  async function initForm() {
    resetFormForModule();
    if (!allUsersCache.value.length) await loadUserPool();
    if (modeRef.value !== "edit") {
      form.approvalFlowNodes = [
        { approverId: "", approverName: "", nodeOrder: 1, signMode: "countersign" },
      ];
      tryApplyCurrentUser();
    }
  }
  function validateForm() {
    if (!form.applicantId) {
      uni.showToast({ title: "请选择员工", icon: "none" });
      return false;
    }
    if (!(form.reimburseReason || "").trim()) {
      uni.showToast({ title: "请填写报销原因", icon: "none" });
      return false;
    }
    if (isTravel.value) {
      if (!form.travelStartTime) {
        uni.showToast({ title: "请选择出差开始时间", icon: "none" });
        return false;
      }
      if (!form.travelEndTime) {
        uni.showToast({ title: "请选择出差结束时间", icon: "none" });
        return false;
      }
      if (computeTravelDays(form.travelStartTime, form.travelEndTime) == null) {
        uni.showToast({ title: "结束时间须晚于开始时间", icon: "none" });
        return false;
      }
      if (!(form.departurePlace || "").trim()) {
        uni.showToast({ title: "请填写出差地", icon: "none" });
        return false;
      }
      if (!(form.destination || "").trim()) {
        uni.showToast({ title: "请填写目的地", icon: "none" });
        return false;
      }
    } else if (!form.expenseCategory) {
      uni.showToast({ title: "请选择费用类型", icon: "none" });
      return false;
    }
    if (form.applyAmount === "" || form.applyAmount == null) {
      uni.showToast({ title: "请填写申请金额", icon: "none" });
      return false;
    }
    if (!(form.payee || "").trim()) {
      uni.showToast({ title: "请填写收款人", icon: "none" });
      return false;
    }
    if (!(form.expenseDetails || []).length) {
      uni.showToast({ title: "请至少添加一条报销明细", icon: "none" });
      return false;
    }
    const nodes = form.approvalFlowNodes || [];
    if (!nodes.length || nodes.some(n => n.approverId == null || n.approverId === "")) {
      uni.showToast({ title: "每个审批节点须选择审批人", icon: "none" });
      return false;
    }
    return true;
  }
  async function submitForm() {
    if (!validateForm()) return;
    recalcTravelStandards();
    if (isTravel.value && form.needSpecialApproval) {
      const ok = await new Promise(resolve => {
        uni.showModal({
          title: "超支提醒",
          content: "存在超支项,提交后将标记为需特批,是否继续?",
          success: r => resolve(!!r.confirm),
        });
      });
      if (!ok) return;
    }
    const isEdit = modeRef.value === "edit";
    const dto = isTravel.value
      ? buildTravelReimbursementSaveDto(form, { computeTravelDays })
      : buildCostReimbursementSaveDto(form);
    const check = validateReimbursementPersistDto(dto, isEdit);
    if (!check.ok) {
      uni.showToast({ title: check.message, icon: "none" });
      return;
    }
    submitting.value = true;
    try {
      await persistFinReimbursement(dto, isEdit);
      uni.showToast({ title: isEdit ? "保存成功" : "提交成功", icon: "success" });
      return true;
    } catch {
      uni.showToast({ title: isEdit ? "保存失败" : "提交失败", icon: "none" });
      return false;
    } finally {
      submitting.value = false;
    }
  }
  return {
    form,
    isTravel,
    submitting,
    loading,
    flowUserOptions,
    travelDaysDisplay,
    travelTierLabel,
    suggestedLivingSubsidy,
    suggestedTransportSubsidy,
    suggestedHotelLimit,
    detailTotalAmount,
    overBudgetWarnings,
    expenseSubjectOptions,
    categoryActions,
    categoryLabel,
    showApplicantPicker,
    applicantDisplaySub,
    applicantAvatarColor,
    showCategorySheet,
    showSubjectSheet,
    editingDetailIndex,
    pickCategoryValue,
    pickSubjectValue,
    loadUserPool,
    userLabel,
    onApplicantPicked,
    tryApplyCurrentUser,
    recalcTravelStandards,
    syncApplyAmountFromDetails,
    addExpenseDetail,
    removeExpenseDetail,
    applyTemplate,
    initForm,
    loadEdit,
    submitForm,
    getReimbursementTypeByModuleKey,
  };
}
src/pages/oa/ReimburseManage/travel-reimburse/index.vue
@@ -1,18 +1,11 @@
<!--
  OA / æŠ¥é”€ç®¡ç† / å·®æ—…报销
  è·¯ç”±ï¼š/pages/oa/ReimburseManage/travel-reimburse/index
  OA / æŠ¥é”€ç®¡ç† / å·®æ—…报销(/finReimbursement/listPage,reimbursementType=1)
-->
<template>
  <OaListPage v-if="config"
              :page-key="pageKey"
              :page-config="config" />
  <FinReimbursementListPage :module-key="APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE" />
</template>
<script setup>
  /** OA - æŠ¥é”€ç®¡ç† - å·®æ—…报销 */
  import OaListPage from "../../_components/OaListPage.vue";
  import { useOaPage } from "../../_utils/useOaPage.js";
  const pageKey = "ReimburseManage/travel-reimburse";
  const { config } = useOaPage(pageKey);
  import FinReimbursementListPage from "../../_components/FinReimbursementListPage.vue";
  import { APPROVAL_MODULE_KEYS } from "../../_utils/approvalModuleRegistry.js";
</script>
src/pages/oa/_components/ApprovalInstanceListPage.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,344 @@
<!--
  ä¸šåŠ¡å®¡æ‰¹ç”³è¯·åˆ—è¡¨ï¼ˆè½¬æ­£/调岗/交接/请假/加班)
-->
<template>
  <view class="oa-approval-page">
    <PageHeader :title="pageTitle"
                @back="goBack" />
    <view class="oa-toolbar">
      <view class="oa-filter-chip"
            :class="{ active: hasActiveFilter }"
            @click="showFilter = true">
        <up-icon name="list"
                 size="18"
                 :color="hasActiveFilter ? '#2979ff' : '#666'" />
        <text class="chip-label">筛选</text>
        <text v-if="filterSummary"
              class="chip-value">{{ filterSummary }}</text>
        <text v-else
              class="chip-placeholder">全部条件</text>
      </view>
      <view class="oa-icon-btn"
            @click="handleSearch">
        <up-icon name="search"
                 size="20"
                 color="#666" />
      </view>
    </view>
    <ApprovalModuleSearchPopup v-model:show="showFilter"
                               :module-key="moduleKey"
                               v-model="searchForm"
                               @search="handleSearch"
                               @reset="handleReset" />
    <scroll-view class="oa-list-scroll"
                 scroll-y
                 :show-scrollbar="false"
                 :style="{ height: listScrollHeight + 'px' }"
                 @scrolltolower="loadMore">
      <view v-if="displayList.length"
            class="oa-card-list">
        <view v-for="item in displayList"
              :key="item.id"
              class="oa-card"
              @click="openDetail(item)">
          <view class="oa-card-head">
            <view class="oa-card-title-wrap">
              <text class="oa-card-title">{{ cardTitle(item) }}</text>
              <text v-if="item.instanceNo"
                    class="oa-card-sub">{{ item.instanceNo }}</text>
            </view>
            <text :class="['oa-status', businessStatusClass(item.status)]">
              {{ businessStatusText(item.status) }}
            </text>
          </view>
          <view class="oa-card-body">
            <view class="oa-info-grid">
              <view v-for="(row, idx) in visibleDisplayRows(item)"
                    :key="'f-' + idx"
                    class="oa-info-row">
                <text class="oa-info-label">{{ row.label }}</text>
                <text class="oa-info-value">{{ row.value || "-" }}</text>
              </view>
              <view class="oa-info-row">
                <text class="oa-info-label">申请人</text>
                <text class="oa-info-value">{{ item.applicantName || "-" }}</text>
              </view>
              <view class="oa-info-row">
                <text class="oa-info-label">申请时间</text>
                <text class="oa-info-value">{{ item.createTime || "-" }}</text>
              </view>
            </view>
          </view>
          <view v-if="canEditBusinessInstanceRow(item)"
                class="oa-card-foot"
                @click.stop>
            <text class="oa-foot-btn btn-edit"
                  @click="goEdit(item)">修改</text>
            <text class="oa-foot-btn btn-delete"
                  @click="confirmDelete(item)">删除</text>
          </view>
        </view>
        <up-loadmore :status="pageStatus" />
      </view>
      <view v-else-if="!tableLoading"
            class="oa-empty">
        <up-empty mode="list"
                  :text="`暂无${pageTitle}数据`" />
      </view>
      <view v-if="tableLoading && !list.length"
            class="oa-loading">
        <up-loading-icon mode="circle" />
      </view>
    </scroll-view>
    <view class="fab-button"
          @click="handleAdd">
      <up-icon name="plus"
               size="28"
               color="#ffffff" />
    </view>
  </view>
</template>
<script setup>
  import { computed, onMounted, reactive, ref } from "vue";
  import { onShow } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import ApprovalModuleSearchPopup from "./ApprovalModuleSearchPopup.vue";
  import {
    deleteApprovalInstance,
    listApprovalInstancePage,
  } from "@/api/oa/approvalInstance.js";
  import { OA_NAV } from "@/config/oaPaths.js";
  import { fetchApprovalTemplateTypes } from "../_utils/approvalTemplateType.js";
  import {
    getApprovalModuleConfig,
    getModuleListBusinessType,
  } from "../_utils/approvalModuleRegistry.js";
  import {
    buildModuleListDto,
    createModuleSearchForm,
    filterRowsByModuleSearch,
    filterRowsByModuleBusinessType,
    formatDateRangeLabel,
    getModuleSearchMeta,
  } from "../_utils/approvalModuleListSearch.js";
  import {
    buildInstanceListParams,
    businessStatusClass,
    businessStatusText,
    canEditBusinessInstanceRow,
    EDIT_STORAGE_KEY,
    mapInstanceListRow,
    stashInstanceRow,
    unwrapInstancePage,
  } from "../_utils/approveListUtils.js";
  const props = defineProps({
    moduleKey: { type: String, required: true },
  });
  const moduleConfig = computed(() => getApprovalModuleConfig(props.moduleKey));
  const pageTitle = computed(() => moduleConfig.value?.label || "申请");
  const showFilter = ref(false);
  const searchForm = reactive(createModuleSearchForm(props.moduleKey));
  const list = ref([]);
  const tableLoading = ref(false);
  const pageStatus = ref("loadmore");
  const businessType = ref("");
  const typeOptions = ref([]);
  const page = reactive({ current: 1, size: 10, total: 0 });
  const listScrollHeight = ref(400);
  function calcListScrollHeight() {
    const sys = uni.getSystemInfoSync();
    const statusBar = sys.statusBarHeight || 0;
    const navBar = 44;
    const toolbar = 56;
    const fabGap = 16;
    listScrollHeight.value = Math.max(
      200,
      sys.windowHeight - statusBar - navBar - toolbar - fabGap
    );
  }
  const displayList = computed(() => {
    const byType = filterRowsByModuleBusinessType(
      props.moduleKey,
      list.value,
      typeOptions.value
    );
    return filterRowsByModuleSearch(props.moduleKey, byType, searchForm);
  });
  const hasActiveFilter = computed(() => Boolean(filterSummary.value));
  const filterSummary = computed(() => {
    const parts = [];
    const meta = getModuleSearchMeta(props.moduleKey);
    for (const field of meta.fields || []) {
      const val = searchForm[field.key];
      if (field.type === "input" && (val || "").trim()) {
        parts.push(`${field.label}:${String(val).trim()}`);
      } else if (field.type === "daterange" && Array.isArray(val) && val[0]) {
        parts.push(`${field.label}:${formatDateRangeLabel(val)}`);
      } else if (field.type === "select" && val) {
        const opt = (field.options || []).find(o => o.value === val);
        parts.push(`${field.label}:${opt?.label || val}`);
      } else if (field.type === "user" && val) {
        parts.push(`${field.label}:已选`);
      }
    }
    return parts.join(";");
  });
  function cardTitle(item) {
    return item.summary || item.title || pageTitle.value;
  }
  function visibleDisplayRows(item) {
    const rows = item.displayRows || [];
    return rows.slice(0, 2);
  }
  const buildListRequestParams = () => {
    const extraDto = buildModuleListDto(props.moduleKey, searchForm);
    return buildInstanceListParams({
      page,
      businessType: businessType.value,
      extraDto,
      searchForm,
    });
  };
  const fetchList = async (reset = false) => {
    if (reset) {
      page.current = 1;
      pageStatus.value = "loadmore";
      list.value = [];
    }
    if (pageStatus.value === "loading" || pageStatus.value === "nomore") return;
    if (!businessType.value && businessType.value !== 0) return;
    pageStatus.value = "loading";
    tableLoading.value = true;
    try {
      const res = await listApprovalInstancePage(buildListRequestParams());
      const { records, total } = unwrapInstancePage(res);
      const listFields = moduleConfig.value?.listFields || [];
      const mapped = records.map(row => mapInstanceListRow(row, listFields));
      if (page.current === 1) {
        list.value = mapped;
      } else {
        list.value = [...list.value, ...mapped];
      }
      page.total = total;
      if (list.value.length >= total || records.length < page.size) {
        pageStatus.value = "nomore";
      } else {
        pageStatus.value = "loadmore";
        page.current += 1;
      }
    } catch {
      if (page.current === 1) list.value = [];
      pageStatus.value = "loadmore";
      uni.showToast({ title: `${pageTitle.value}加载失败`, icon: "none" });
    } finally {
      tableLoading.value = false;
    }
  };
  const initBusinessType = async () => {
    const fixed = getModuleListBusinessType(props.moduleKey);
    businessType.value = fixed != null && fixed !== "" ? fixed : "";
    try {
      typeOptions.value = await fetchApprovalTemplateTypes();
    } catch {
      typeOptions.value = [];
    }
  };
  const handleSearch = () => fetchList(true);
  const handleReset = () => {
    Object.assign(searchForm, createModuleSearchForm(props.moduleKey));
    fetchList(true);
  };
  const loadMore = () => {
    if (pageStatus.value === "loadmore") fetchList(false);
  };
  const goBack = () => uni.navigateBack();
  const openDetail = item => {
    if (!item?.id) return;
    stashInstanceRow(item);
    uni.navigateTo({
      url: `${OA_NAV.approveListDetail}?id=${item.id}&from=business`,
    });
  };
  const goEdit = item => {
    if (!canEditBusinessInstanceRow(item)) {
      uni.showToast({ title: "进行中或已完成的审批不可修改", icon: "none" });
      return;
    }
    if (!item?.id) return;
    uni.setStorageSync(EDIT_STORAGE_KEY, item);
    stashInstanceRow(item);
    uni.navigateTo({
      url: `${OA_NAV.approveListApply}?id=${item.id}&moduleKey=${props.moduleKey}`,
    });
  };
  const confirmDelete = item => {
    if (!item?.id) return;
    const title = item.title || item.templateName || item.instanceNo || "该审批";
    uni.showModal({
      title: "删除确认",
      content: `确定要删除「${title}」吗?删除后不可恢复。`,
      confirmText: "确定删除",
      confirmColor: "#f56c6c",
      success: async res => {
        if (!res.confirm) return;
        try {
          await deleteApprovalInstance([item.id]);
          uni.showToast({ title: "删除成功", icon: "success" });
          fetchList(true);
        } catch {
          uni.showToast({ title: "删除失败", icon: "none" });
        }
      },
    });
  };
  const handleAdd = () => {
    uni.navigateTo({
      url: `${OA_NAV.approveListTemplateSelect}?moduleKey=${props.moduleKey}`,
    });
  };
  onMounted(() => {
    calcListScrollHeight();
  });
  onShow(async () => {
    calcListScrollHeight();
    await initBusinessType();
    fetchList(true);
  });
</script>
<style scoped lang="scss">
  @import "@/styles/sales-common.scss";
  @import "../_styles/oa-approval-list.scss";
</style>
src/pages/oa/_components/ApprovalModuleSearchPopup.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,268 @@
<!--
  ä¸šåŠ¡å®¡æ‰¹åˆ—è¡¨ç­›é€‰å¼¹çª—
-->
<template>
  <up-popup :show="modelShow"
            mode="bottom"
            round
            @close="closePopup">
    <view class="oa-filter-popup">
      <view class="oa-filter-head">筛选条件</view>
      <scroll-view scroll-y
                   class="oa-filter-scroll">
        <view v-for="field in fields"
              :key="field.key"
              class="oa-filter-field">
          <text class="oa-field-label">{{ field.label }}</text>
          <up-input v-if="field.type === 'input'"
                    v-model="localForm[field.key]"
                    :placeholder="field.placeholder || `请输入${field.label}`"
                    clearable
                    border="surround" />
          <view v-else-if="field.type === 'daterange'"
                class="oa-picker-row"
                @click="openDateRange(field.key)">
            <text :class="{ placeholder: !dateLabel(field.key) }">
              {{ dateLabel(field.key) || `请选择${field.label}` }}
            </text>
            <up-icon name="calendar"
                     size="18"
                     color="#999" />
          </view>
          <view v-else-if="field.type === 'user'"
                class="oa-picker-row"
                @click="openUserPicker">
            <text :class="{ placeholder: !userLabel }">
              {{ userLabel || field.placeholder || '请选择申请人' }}
            </text>
            <up-icon name="arrow-down"
                     size="14"
                     color="#999" />
          </view>
          <view v-else-if="field.type === 'select'"
                class="oa-picker-row"
                @click="openSelect(field)">
            <text :class="{ placeholder: !selectLabel(field) }">
              {{ selectLabel(field) || `请选择${field.label}` }}
            </text>
            <up-icon name="arrow-down"
                     size="14"
                     color="#999" />
          </view>
        </view>
      </scroll-view>
      <view class="oa-filter-actions">
        <up-button text="重置"
                   shape="circle"
                   @click="handleReset" />
        <up-button type="primary"
                   text="确定"
                   shape="circle"
                   @click="handleConfirm" />
      </view>
    </view>
    <up-calendar :show="calendarShow"
                 mode="range"
                 :maxDate="maxDate"
                 minDate="2020-01-01"
                 :monthNum="24"
                 @confirm="onDateConfirm"
                 @close="calendarShow = false" />
    <up-picker :show="pickerShow"
               :columns="pickerColumns"
               keyName="label"
               @confirm="onPickerConfirm"
               @cancel="pickerShow = false"
               @close="pickerShow = false" />
    <up-popup :show="userPickerShow"
              mode="bottom"
              round
              @close="userPickerShow = false">
      <view class="oa-user-popup">
        <view class="oa-user-popup-title">选择申请人</view>
        <up-input v-model="userKeyword"
                  placeholder="搜索姓名或编号"
                  clearable
                  border="surround" />
        <scroll-view scroll-y
                     class="oa-user-list">
          <view v-for="u in filteredUsers"
                :key="u.userId ?? u.id"
                class="oa-user-item"
                @click="pickUser(u)">
            <text>{{ userSelectLabel(u) }}</text>
          </view>
          <view v-if="!filteredUsers.length"
                class="oa-user-empty">暂无匹配用户</view>
        </scroll-view>
      </view>
    </up-popup>
  </up-popup>
</template>
<script setup>
  import { computed, reactive, ref, watch } from "vue";
  import dayjs from "dayjs";
  import { userListNoPageByTenantId } from "@/api/system/user";
  import {
    formatDateRangeLabel,
    getModuleSearchMeta,
    resetModuleSearchForm,
    userSelectLabel,
  } from "../_utils/approvalModuleListSearch.js";
  const props = defineProps({
    show: { type: Boolean, default: false },
    moduleKey: { type: String, required: true },
    modelValue: { type: Object, default: () => ({}) },
  });
  const emit = defineEmits(["update:show", "update:modelValue", "search", "reset"]);
  const modelShow = computed({
    get: () => props.show,
    set: v => emit("update:show", v),
  });
  function closePopup() {
    emit("update:show", false);
  }
  const localForm = reactive({});
  const calendarShow = ref(false);
  const activeDateKey = ref("applyDateRange");
  const maxDate = dayjs().format("YYYY-MM-DD");
  const pickerShow = ref(false);
  const pickerColumns = ref([[]]);
  const activeSelectField = ref(null);
  const userPickerShow = ref(false);
  const userKeyword = ref("");
  const allUsers = ref([]);
  const fields = computed(() => getModuleSearchMeta(props.moduleKey).fields || []);
  const userLabel = computed(() => {
    const id = localForm.applicantId;
    if (!id) return "";
    const u = allUsers.value.find(x => String(x.userId ?? x.id) === String(id));
    return u ? userSelectLabel(u) : String(id);
  });
  const filteredUsers = computed(() => {
    const q = userKeyword.value.trim().toLowerCase();
    const list = allUsers.value.filter(u => {
      if (u.delFlag === "2" || u.delFlag === 2) return false;
      if (u.status != null && String(u.status) !== "0") return false;
      return true;
    });
    if (!q) return list.slice(0, 50);
    return list
      .filter(u => {
        const nick = (u.nickName || "").toLowerCase();
        const name = (u.userName || "").toLowerCase();
        const id = String(u.userId ?? u.id ?? "");
        return nick.includes(q) || name.includes(q) || id.includes(q);
      })
      .slice(0, 50);
  });
  function unwrapUsers(res) {
    if (Array.isArray(res)) return res;
    if (Array.isArray(res?.data)) return res.data;
    if (Array.isArray(res?.rows)) return res.rows;
    return [];
  }
  async function loadUsers() {
    if (allUsers.value.length) return;
    try {
      const res = await userListNoPageByTenantId();
      allUsers.value = unwrapUsers(res);
    } catch {
      allUsers.value = [];
    }
  }
  watch(
    () => props.show,
    v => {
      if (!v) return;
      Object.assign(localForm, props.modelValue || {});
    }
  );
  function dateLabel(key) {
    return formatDateRangeLabel(localForm[key]);
  }
  function openDateRange(key) {
    activeDateKey.value = key;
    calendarShow.value = true;
  }
  function onDateConfirm(e) {
    if (!e?.length) {
      calendarShow.value = false;
      return;
    }
    localForm[activeDateKey.value] = [e[0], e[e.length - 1]];
    calendarShow.value = false;
  }
  function selectLabel(field) {
    const val = localForm[field.key];
    if (!val) return "";
    const opt = (field.options || []).find(o => o.value === val);
    return opt?.label || String(val);
  }
  function openSelect(field) {
    activeSelectField.value = field;
    pickerColumns.value = [field.options || []];
    pickerShow.value = true;
  }
  function onPickerConfirm(e) {
    const item = e?.value?.[0];
    if (activeSelectField.value?.key && item) {
      localForm[activeSelectField.value.key] = item.value;
    }
    pickerShow.value = false;
  }
  async function openUserPicker() {
    await loadUsers();
    userKeyword.value = "";
    userPickerShow.value = true;
  }
  function pickUser(u) {
    localForm.applicantId = u.userId ?? u.id ?? "";
    userPickerShow.value = false;
  }
  function handleReset() {
    resetModuleSearchForm(props.moduleKey, localForm);
    emit("update:modelValue", { ...localForm });
    emit("reset", { ...localForm });
    emit("update:show", false);
  }
  function handleConfirm() {
    emit("update:modelValue", { ...localForm });
    emit("search", { ...localForm });
    emit("update:show", false);
  }
</script>
<style scoped lang="scss">
  @import "../_styles/oa-approval-list.scss";
</style>
src/pages/oa/_components/FinReimbursementListPage.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,346 @@
<!--
  å·®æ—…/费用报销列表(/finReimbursement/listPage)
-->
<template>
  <view class="oa-approval-page">
    <PageHeader :title="pageTitle"
                @back="goBack" />
    <view class="oa-toolbar">
      <view class="oa-filter-chip"
            :class="{ active: hasActiveFilter }"
            @click="showFilter = true">
        <up-icon name="list"
                 size="18"
                 :color="hasActiveFilter ? '#2979ff' : '#666'" />
        <text class="chip-label">筛选</text>
        <text v-if="filterSummary"
              class="chip-value">{{ filterSummary }}</text>
        <text v-else
              class="chip-placeholder">全部条件</text>
      </view>
      <view class="oa-icon-btn"
            @click="handleSearch">
        <up-icon name="search"
                 size="20"
                 color="#666" />
      </view>
    </view>
    <ApprovalModuleSearchPopup v-model:show="showFilter"
                               :module-key="moduleKey"
                               v-model="searchForm"
                               @search="handleSearch"
                               @reset="handleReset" />
    <scroll-view class="oa-list-scroll"
                 scroll-y
                 :show-scrollbar="false"
                 :style="{ height: listScrollHeight + 'px' }"
                 @scrolltolower="loadMore">
      <view v-if="displayList.length"
            class="oa-card-list">
        <view v-for="item in displayList"
              :key="item.reimbursementId || item.id"
              class="oa-card">
          <view class="oa-card-head">
            <view class="oa-card-title-wrap">
              <text class="oa-card-title">{{ cardTitle(item) }}</text>
              <text v-if="item.billNo"
                    class="oa-card-sub">{{ item.billNo }}</text>
            </view>
            <text :class="['oa-status', billStatusCssClass(item)]">
              {{ billStatusLabel(item.billStatus ?? item.status) }}
            </text>
          </view>
          <view class="oa-card-body">
            <view class="oa-info-grid">
              <view v-for="(row, idx) in visibleDisplayRows(item)"
                    :key="'f-' + idx"
                    class="oa-info-row">
                <text class="oa-info-label">{{ row.label }}</text>
                <text class="oa-info-value">{{ row.value || "-" }}</text>
              </view>
              <view class="oa-info-row">
                <text class="oa-info-label">申请人</text>
                <text class="oa-info-value">{{ item.applicantName || "-" }}</text>
              </view>
              <view class="oa-info-row">
                <text class="oa-info-label">申请时间</text>
                <text class="oa-info-value">{{ formatListTime(item.createTime) }}</text>
              </view>
            </view>
          </view>
          <view class="oa-card-foot"
                @click.stop>
            <text class="oa-foot-btn btn-detail"
                  @click="openDetail(item)">详情</text>
            <text v-if="canEditReimbursementRow(item)"
                  class="oa-foot-btn btn-edit"
                  @click="goEdit(item)">修改</text>
            <text v-if="canDeleteReimbursementRow(item)"
                  class="oa-foot-btn btn-delete"
                  @click="confirmDelete(item)">删除</text>
          </view>
        </view>
        <up-loadmore :status="pageStatus" />
      </view>
      <view v-else-if="!tableLoading"
            class="oa-empty">
        <up-empty mode="list"
                  :text="`暂无${pageTitle}数据`" />
      </view>
      <view v-if="tableLoading && !list.length"
            class="oa-loading">
        <up-loading-icon mode="circle" />
      </view>
    </scroll-view>
    <view class="fab-button"
          @click="handleAdd">
      <up-icon name="plus"
               size="28"
               color="#ffffff" />
    </view>
  </view>
</template>
<script setup>
  import { computed, onMounted, reactive, ref } from "vue";
  import { onShow } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import ApprovalModuleSearchPopup from "./ApprovalModuleSearchPopup.vue";
  import { listFinReimbursementPage } from "@/api/oa/finReimbursement.js";
  import { OA_NAV } from "@/config/oaPaths.js";
  import { getApprovalModuleConfig } from "../_utils/approvalModuleRegistry.js";
  import {
    createModuleSearchForm,
    filterRowsByModuleSearch,
    formatDateRangeLabel,
    getModuleSearchMeta,
  } from "../_utils/approvalModuleListSearch.js";
  import { parseTime } from "@/utils/ruoyi";
  import {
    billStatusCssClass,
    billStatusLabel,
    buildFinReimbursementListParams,
    canDeleteReimbursementRow,
    canEditReimbursementRow,
    deleteFinReimbursement,
    getReimbursementTypeByModuleKey,
    filterRowsByReimbursementType,
    mapFinReimbursementFromApi,
    resolveReimbursementDeleteId,
    unwrapFinReimbursementPage,
  } from "../_utils/finReimbursementMappers.js";
  const props = defineProps({
    moduleKey: { type: String, required: true },
  });
  const moduleConfig = computed(() => getApprovalModuleConfig(props.moduleKey));
  const pageTitle = computed(() => moduleConfig.value?.label || "报销");
  const reimbursementType = computed(() =>
    getReimbursementTypeByModuleKey(props.moduleKey)
  );
  const showFilter = ref(false);
  const searchForm = reactive(createModuleSearchForm(props.moduleKey));
  const list = ref([]);
  const tableLoading = ref(false);
  const pageStatus = ref("loadmore");
  const page = reactive({ current: 1, size: 10, total: 0 });
  const listScrollHeight = ref(400);
  function calcListScrollHeight() {
    const sys = uni.getSystemInfoSync();
    const statusBar = sys.statusBarHeight || 0;
    const navBar = 44;
    const toolbar = 56;
    const fabGap = 16;
    listScrollHeight.value = Math.max(
      200,
      sys.windowHeight - statusBar - navBar - toolbar - fabGap
    );
  }
  const displayList = computed(() =>
    filterRowsByModuleSearch(props.moduleKey, list.value, searchForm)
  );
  const hasActiveFilter = computed(() => Boolean(filterSummary.value));
  const filterSummary = computed(() => {
    const parts = [];
    const meta = getModuleSearchMeta(props.moduleKey);
    for (const field of meta.fields || []) {
      const val = searchForm[field.key];
      if (field.type === "input" && (val || "").trim()) {
        parts.push(`${field.label}:${String(val).trim()}`);
      } else if (field.type === "daterange" && Array.isArray(val) && val[0]) {
        parts.push(`${field.label}:${formatDateRangeLabel(val)}`);
      } else if (field.type === "select" && val) {
        const opt = (field.options || []).find(o => o.value === val);
        parts.push(`${field.label}:${opt?.label || val}`);
      }
    }
    return parts.join(";");
  });
  function cardTitle(item) {
    return item.summary || item.title || item.reason || pageTitle.value;
  }
  function visibleDisplayRows(item) {
    return (item.displayRows || []).slice(0, 3);
  }
  function formatListTime(t) {
    if (!t) return "-";
    const formatted = parseTime(t, "{y}-{m}-{d} {h}:{i}");
    return formatted || String(t).replace("T", " ").slice(0, 16);
  }
  const fetchList = async (reset = false) => {
    if (!reimbursementType.value) return;
    if (reset) {
      page.current = 1;
      pageStatus.value = "loadmore";
      list.value = [];
    }
    if (pageStatus.value === "loading" || pageStatus.value === "nomore") return;
    pageStatus.value = "loading";
    tableLoading.value = true;
    try {
      const res = await listFinReimbursementPage(
        buildFinReimbursementListParams({
          page,
          searchForm,
          reimbursementType: reimbursementType.value,
        })
      );
      const { records, total } = unwrapFinReimbursementPage(res);
      const mapped = filterRowsByReimbursementType(
        records,
        reimbursementType.value
      ).map(row =>
        mapFinReimbursementFromApi(row, {
          reimbursementType: reimbursementType.value,
          moduleKey: props.moduleKey,
        })
      );
      if (page.current === 1) {
        list.value = mapped;
      } else {
        list.value = [...list.value, ...mapped];
      }
      page.total = total;
      if (list.value.length >= total || records.length < page.size) {
        pageStatus.value = "nomore";
      } else {
        pageStatus.value = "loadmore";
        page.current += 1;
      }
    } catch {
      if (page.current === 1) list.value = [];
      pageStatus.value = "loadmore";
      uni.showToast({ title: `${pageTitle.value}加载失败`, icon: "none" });
    } finally {
      tableLoading.value = false;
    }
  };
  const handleSearch = () => fetchList(true);
  const handleReset = () => {
    Object.assign(searchForm, createModuleSearchForm(props.moduleKey));
    fetchList(true);
  };
  const loadMore = () => {
    if (pageStatus.value === "loadmore") fetchList(false);
  };
  const goBack = () => uni.navigateBack();
  const openDetail = item => {
    const rid = resolveReimbursementDeleteId(item);
    if (rid == null) {
      uni.showToast({ title: "无法查看详情:缺少报销单 ID", icon: "none" });
      return;
    }
    uni.navigateTo({
      url: `${OA_NAV.reimburseDetail}?moduleKey=${props.moduleKey}&reimbursementId=${rid}`,
    });
  };
  const handleAdd = () => {
    uni.navigateTo({
      url: `${OA_NAV.reimburseForm}?moduleKey=${props.moduleKey}&mode=add`,
    });
  };
  const goEdit = item => {
    if (!canEditReimbursementRow(item)) {
      uni.showToast({ title: "审批中或已完成的报销不可修改", icon: "none" });
      return;
    }
    const rid = resolveReimbursementDeleteId(item);
    if (rid == null) {
      uni.showToast({ title: "无法修改:缺少报销单 ID", icon: "none" });
      return;
    }
    uni.navigateTo({
      url: `${OA_NAV.reimburseForm}?moduleKey=${props.moduleKey}&mode=edit&reimbursementId=${rid}`,
    });
  };
  const confirmDelete = item => {
    if (!canDeleteReimbursementRow(item)) {
      uni.showToast({ title: "该状态不可删除", icon: "none" });
      return;
    }
    const id = resolveReimbursementDeleteId(item);
    if (id == null) {
      uni.showToast({ title: "无法删除:缺少报销单 ID", icon: "none" });
      return;
    }
    const title = item.billNo || item.summary || item.title || "该报销单";
    uni.showModal({
      title: "删除确认",
      content: `确定要删除「${title}」吗?删除后不可恢复。`,
      confirmText: "确定删除",
      confirmColor: "#f56c6c",
      success: async res => {
        if (!res.confirm) return;
        try {
          await deleteFinReimbursement([id]);
          uni.showToast({ title: "删除成功", icon: "success" });
          fetchList(true);
        } catch {
          uni.showToast({ title: "删除失败", icon: "none" });
        }
      },
    });
  };
  onMounted(() => {
    calcListScrollHeight();
  });
  onShow(() => {
    calcListScrollHeight();
    fetchList(true);
  });
</script>
<style scoped lang="scss">
  @import "@/styles/sales-common.scss";
  @import "../_styles/oa-approval-list.scss";
</style>
src/pages/oa/_components/OaUserSearchPicker.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,261 @@
<!--
  OA é€šç”¨ï¼šå¯æœç´¢çš„用户单选弹层(点选即确认)
-->
<template>
  <up-popup :show="show"
            mode="bottom"
            round="16"
            :safe-area-inset-bottom="true"
            @close="emit('update:show', false)">
    <view class="oa-user-sheet">
      <view class="sheet-handle" />
      <view class="sheet-head">
        <text class="sheet-cancel"
              @click="emit('update:show', false)">取消</text>
        <text class="sheet-title">{{ title }}</text>
        <text class="sheet-spacer" />
      </view>
      <view class="sheet-search">
        <up-search v-model="keyword"
                   placeholder="搜索姓名或工号"
                   :show-action="false"
                   shape="round"
                   bg-color="#f5f7fa" />
      </view>
      <view v-if="selfUser && showSelfQuick"
            class="self-quick"
            @click="pickUser(selfUser)">
        <view class="user-avatar"
              :style="{ backgroundColor: avatarColor(selfUser.nickName || selfUser.userName) }">
          {{ (selfUser.nickName || selfUser.userName || "我").charAt(0) }}
        </view>
        <view class="user-meta">
          <text class="user-name">选本人 Â· {{ userSelectLabel(selfUser) }}</text>
          <text class="user-sub">{{ userSubLabel(selfUser) }}</text>
        </view>
        <up-icon name="arrow-right"
                 size="14"
                 color="#c0c4cc" />
      </view>
      <scroll-view scroll-y
                   class="user-scroll"
                   :show-scrollbar="false">
        <view v-for="u in filteredList"
              :key="String(u.userId ?? u.id)"
              class="user-item"
              :class="{ selected: isSelected(u) }"
              @click="pickUser(u)">
          <view class="user-avatar"
                :style="{ backgroundColor: avatarColor(u.nickName || u.userName) }">
            {{ (u.nickName || u.userName || "?").charAt(0) }}
          </view>
          <view class="user-meta">
            <text class="user-name">{{ userSelectLabel(u) }}</text>
            <text class="user-sub">{{ userSubLabel(u) }}</text>
          </view>
          <view class="user-check"
                :class="{ checked: isSelected(u) }">
            <up-icon v-if="isSelected(u)"
                     name="checkmark"
                     size="14"
                     color="#fff" />
          </view>
        </view>
        <view v-if="!filteredList.length"
              class="user-empty">
          <up-empty mode="search"
                    text="暂无匹配用户" />
        </view>
      </scroll-view>
    </view>
  </up-popup>
</template>
<script setup>
  import { computed, ref, watch } from "vue";
  import useUserStore from "@/store/modules/user";
  import {
    filterActiveUsers,
    userAvatarColor,
    userSelectLabel,
    userSubLabel,
  } from "../_utils/userPickerUtils.js";
  const props = defineProps({
    show: { type: Boolean, default: false },
    title: { type: String, default: "选择员工" },
    users: { type: Array, default: () => [] },
    modelValue: { type: [String, Number], default: "" },
    showSelfQuick: { type: Boolean, default: true },
  });
  const emit = defineEmits(["update:show", "update:modelValue", "select"]);
  const keyword = ref("");
  const userStore = useUserStore();
  const filteredList = computed(() =>
    filterActiveUsers(props.users, keyword.value, 100)
  );
  const selfUser = computed(() => {
    const id = userStore.id;
    if (!id) return null;
    const hit = props.users.find(u => String(u.userId ?? u.id) === String(id));
    if (hit) return hit;
    return {
      userId: id,
      nickName: userStore.nickName,
      userName: userStore.name,
    };
  });
  watch(
    () => props.show,
    v => {
      if (v) keyword.value = "";
    }
  );
  function avatarColor(name) {
    return userAvatarColor(name);
  }
  function isSelected(u) {
    const id = u.userId ?? u.id;
    return id != null && String(id) === String(props.modelValue ?? "");
  }
  function pickUser(u) {
    const id = u.userId ?? u.id;
    emit("update:modelValue", id);
    emit("select", u);
    emit("update:show", false);
  }
</script>
<style scoped lang="scss">
  .oa-user-sheet {
    background: #fff;
    border-radius: 16px 16px 0 0;
    max-height: 78vh;
    display: flex;
    flex-direction: column;
  }
  .sheet-handle {
    width: 36px;
    height: 4px;
    background: #e4e7ed;
    border-radius: 2px;
    margin: 8px auto 4px;
  }
  .sheet-head {
    display: flex;
    align-items: center;
    padding: 8px 16px 12px;
  }
  .sheet-cancel {
    font-size: 15px;
    color: #909399;
    min-width: 48px;
  }
  .sheet-title {
    flex: 1;
    text-align: center;
    font-size: 16px;
    font-weight: 600;
    color: #303133;
  }
  .sheet-spacer {
    min-width: 48px;
  }
  .sheet-search {
    padding: 0 16px 10px;
  }
  .self-quick {
    display: flex;
    align-items: center;
    margin: 0 16px 8px;
    padding: 12px;
    background: linear-gradient(135deg, #ecf5ff 0%, #f0f9ff 100%);
    border-radius: 12px;
    border: 1px solid #d9ecff;
  }
  .user-scroll {
    flex: 1;
    max-height: 52vh;
    padding: 0 8px 16px;
    box-sizing: border-box;
  }
  .user-item,
  .self-quick {
    &:active {
      opacity: 0.85;
    }
  }
  .user-item {
    display: flex;
    align-items: center;
    padding: 12px 10px;
    border-radius: 10px;
    margin-bottom: 4px;
    &.selected {
      background: #f0f7ff;
    }
  }
  .user-avatar {
    width: 40px;
    height: 40px;
    border-radius: 50%;
    color: #fff;
    font-size: 16px;
    font-weight: 600;
    display: flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
  }
  .user-meta {
    flex: 1;
    margin-left: 12px;
    min-width: 0;
  }
  .user-name {
    display: block;
    font-size: 15px;
    color: #303133;
    font-weight: 500;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .user-sub {
    display: block;
    font-size: 12px;
    color: #909399;
    margin-top: 2px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .user-check {
    width: 22px;
    height: 22px;
    border-radius: 50%;
    border: 2px solid #dcdfe6;
    display: flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
    &.checked {
      background: #2979ff;
      border-color: #2979ff;
    }
  }
  .user-empty {
    padding: 24px 0;
  }
</style>
src/pages/oa/_styles/oa-approval-list.scss
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,414 @@
// OA å®¡æ‰¹åˆ—表(业务申请 + å®¡æ‰¹åˆ—表)统一样式
.oa-approval-page {
  min-height: 100vh;
  background: #f2f4f7;
  display: flex;
  flex-direction: column;
  box-sizing: border-box;
}
.oa-list-scroll {
  width: 100%;
  box-sizing: border-box;
}
.oa-toolbar {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 10px 16px;
  background: #fff;
  border-bottom: 1px solid #eef0f3;
}
.oa-filter-chip {
  flex: 1;
  min-height: 40px;
  padding: 0 14px;
  background: #f5f7fa;
  border-radius: 20px;
  display: flex;
  align-items: center;
  gap: 6px;
  border: 1px solid transparent;
  &.active {
    background: #ecf3ff;
    border-color: #b3d4ff;
  }
  .chip-label {
    font-size: 14px;
    color: #333;
    font-weight: 500;
    flex-shrink: 0;
  }
  .chip-value {
    flex: 1;
    font-size: 13px;
    color: #666;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .chip-placeholder {
    flex: 1;
    font-size: 13px;
    color: #aaa;
  }
}
.oa-icon-btn {
  width: 40px;
  height: 40px;
  border-radius: 20px;
  background: #f5f7fa;
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  &:active {
    background: #e8ebf0;
  }
}
.oa-card-list {
  padding: 12px 16px 4px;
}
.oa-card {
  background: #fff;
  border-radius: 12px;
  margin-bottom: 12px;
  overflow: hidden;
  box-shadow: 0 1px 4px rgba(15, 23, 42, 0.06);
  &:active {
    opacity: 0.92;
  }
}
.oa-card-head {
  padding: 14px 14px 10px;
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 10px;
}
.oa-card-title-wrap {
  flex: 1;
  min-width: 0;
}
.oa-card-title {
  display: block;
  font-size: 15px;
  font-weight: 600;
  color: #1a1a1a;
  line-height: 1.4;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.oa-card-sub {
  display: block;
  margin-top: 4px;
  font-size: 12px;
  color: #8c8c8c;
}
.oa-status {
  flex-shrink: 0;
  font-size: 11px;
  line-height: 1;
  padding: 5px 8px;
  border-radius: 4px;
  font-weight: 500;
  &.status-pending {
    color: #d46b08;
    background: #fff7e6;
  }
  &.status-approved {
    color: #389e0d;
    background: #f6ffed;
  }
  &.status-rejected {
    color: #cf1322;
    background: #fff1f0;
  }
  &.status-draft {
    color: #595959;
    background: #f5f5f5;
  }
  &.status-cancelled {
    color: #8c8c8c;
    background: #fafafa;
  }
}
.oa-card-body {
  padding: 0 14px 12px;
}
.oa-info-grid {
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.oa-info-row {
  display: flex;
  align-items: baseline;
  font-size: 13px;
  line-height: 1.45;
}
.oa-info-label {
  width: 72px;
  flex-shrink: 0;
  color: #8c8c8c;
}
.oa-info-value {
  flex: 1;
  color: #333;
  word-break: break-all;
}
.oa-card-foot {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 10px;
  padding: 10px 14px;
  background: #fafbfc;
  border-top: 1px solid #f0f2f5;
}
.oa-foot-btn {
  min-width: 64px;
  height: 32px;
  line-height: 32px;
  padding: 0 14px;
  font-size: 13px;
  border-radius: 16px;
  text-align: center;
  &.btn-detail {
    color: #fff;
    background: #2979ff;
  }
  &.btn-edit {
    color: #2979ff;
    background: #ecf3ff;
  }
  &.btn-delete {
    color: #ff4d4f;
    background: #fff1f0;
  }
  &.btn-approve {
    color: #fff;
    background: #2979ff;
  }
}
.oa-empty,
.oa-loading {
  padding: 48px 20px;
}
.oa-loading {
  display: flex;
  justify-content: center;
}
// ç­›é€‰å¼¹çª—
.oa-filter-popup {
  padding: 16px 16px calc(16px + env(safe-area-inset-bottom));
  max-height: 72vh;
  display: flex;
  flex-direction: column;
  background: #fff;
}
.oa-filter-head {
  text-align: center;
  font-size: 16px;
  font-weight: 600;
  color: #1a1a1a;
  padding-bottom: 12px;
  border-bottom: 1px solid #f0f2f5;
  margin-bottom: 12px;
}
.oa-filter-scroll {
  max-height: 50vh;
}
.oa-filter-field {
  margin-bottom: 16px;
}
.oa-field-label {
  display: block;
  font-size: 13px;
  color: #666;
  margin-bottom: 8px;
}
.oa-picker-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  min-height: 44px;
  padding: 0 14px;
  background: #f5f7fa;
  border-radius: 8px;
  font-size: 14px;
  color: #333;
  .placeholder {
    color: #bbb;
  }
}
.oa-filter-actions {
  display: flex;
  gap: 12px;
  margin-top: 16px;
  padding-top: 12px;
}
.oa-filter-actions :deep(.u-button) {
  flex: 1;
  border-radius: 22px !important;
}
.oa-user-popup {
  padding: 16px;
  max-height: 60vh;
  background: #fff;
}
.oa-user-popup-title {
  text-align: center;
  font-weight: 600;
  font-size: 16px;
  margin-bottom: 12px;
}
.oa-user-list {
  max-height: 40vh;
  margin-top: 10px;
}
.oa-user-item {
  padding: 14px 4px;
  font-size: 14px;
  color: #333;
  border-bottom: 1px solid #f0f2f5;
}
.oa-user-empty {
  text-align: center;
  color: #999;
  padding: 24px;
  font-size: 13px;
}
// è¯¦æƒ… / å®¡æ‰¹å¤„理等页底部操作栏
.oa-page-footer {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 100;
  display: flex;
  flex-direction: row;
  flex-wrap: nowrap;
  align-items: center;
  gap: 10px;
  padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
  background: #fff;
  border-top: 1px solid #eef0f3;
  box-shadow: 0 -4px 16px rgba(15, 23, 42, 0.08);
}
.oa-footer-btn {
  flex: 1;
  min-width: 0;
  height: 44px;
  line-height: 44px;
  text-align: center;
  font-size: 15px;
  font-weight: 500;
  border-radius: 22px;
  border: none;
  &:active {
    opacity: 0.85;
  }
  &.btn-default {
    color: #666;
    background: #f2f4f7;
  }
  &.btn-primary {
    color: #fff;
    background: linear-gradient(140deg, #00baff 0%, #006cfb 100%);
    box-shadow: 0 4px 10px rgba(0, 108, 251, 0.25);
  }
  &.btn-warn {
    color: #fff;
    background: linear-gradient(140deg, #ffb347 0%, #ff9800 100%);
    box-shadow: 0 4px 10px rgba(255, 152, 0, 0.25);
  }
  &.btn-success {
    color: #fff;
    background: linear-gradient(140deg, #52c41a 0%, #389e0d 100%);
    box-shadow: 0 4px 10px rgba(56, 158, 13, 0.25);
  }
  &.btn-danger {
    color: #fff;
    background: linear-gradient(140deg, #ff7875 0%, #f5222d 100%);
    box-shadow: 0 4px 10px rgba(245, 34, 45, 0.2);
  }
  &.is-disabled {
    opacity: 0.5;
    pointer-events: none;
  }
}
.oa-detail-page {
  min-height: 100vh;
  background: #f2f4f7;
  display: flex;
  flex-direction: column;
}
.oa-detail-scroll {
  flex: 1;
  height: 0;
  padding: 10px 12px;
  padding-bottom: calc(76px + env(safe-area-inset-bottom));
  box-sizing: border-box;
}
src/pages/oa/_utils/approvalFormField.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,372 @@
import { parseTime } from "@/utils/ruoyi";
/** å¡«æŠ¥å­—段类型:下拉 */
export const SELECT_FIELD_TYPES = new Set(["select", "dropdown", "picker"]);
/** æ—¥æœŸæ—¶é—´ç±» type */
const DATE_KIND_BY_TYPE = {
  date: "date",
  time: "time",
  datetime: "datetime",
  datetimerange: "datetime",
};
const DEFAULT_FORMAT = {
  date: "YYYY-MM-DD",
  time: "HH:mm",
  datetime: "YYYY-MM-DD HH:mm:ss",
};
/** è§£æž formConfig JSON(含嵌套 formPayload,与 Web parseInstanceFormConfig ä¸€è‡´ï¼‰ */
export function parseApprovalFormConfig(raw) {
  if (!raw) return { prompt: "", fields: [], formPayload: {} };
  try {
    const obj = typeof raw === "string" ? JSON.parse(raw) : raw;
    const payload = obj?.formPayload;
    return {
      prompt: obj?.prompt || obj?.summaryPlaceholder || "",
      summaryPlaceholder: obj?.summaryPlaceholder || "",
      approvalType: obj?.approvalType || "",
      fields: Array.isArray(obj?.fields)
        ? obj.fields
        : Array.isArray(obj?.formFields)
          ? obj.formFields
          : [],
      formPayload: payload && typeof payload === "object" ? payload : {},
    };
  } catch {
    return { prompt: "", fields: [], formPayload: {} };
  }
}
/**
 * ä¿®æ”¹å®¡æ‰¹ï¼šæ¨¡æ¿å­—段定义 + å®žä¾‹å·²å¡«å€¼åˆå¹¶
 */
export function mergeFormConfigForEdit(templateRaw, instanceRaw) {
  const template = parseApprovalFormConfig(templateRaw);
  const instance = parseApprovalFormConfig(instanceRaw);
  const valueMap = {};
  instance.fields.forEach(field => {
    if (!field?.key) return;
    const val = field.value ?? field.defaultValue;
    if (val !== undefined && val !== null && val !== "") {
      valueMap[field.key] = val;
    }
  });
  Object.assign(valueMap, instance.formPayload || {});
  const baseFields = template.fields.length ? template.fields : instance.fields;
  return {
    prompt: instance.prompt || template.prompt,
    fields: baseFields.map(field => ({
      ...field,
      value: valueMap[field.key] ?? field.value ?? field.defaultValue ?? "",
    })),
  };
}
/** æ˜¯å¦ä¸ºä¸‹æ‹‰ç±»å­—段 */
export function isSelectField(field) {
  const type = String(field?.type ?? "").toLowerCase();
  return SELECT_FIELD_TYPES.has(type);
}
/** æ˜¯å¦ä¸ºå¤šè¡Œæ–‡æœ¬ */
export function isTextareaField(field) {
  return String(field?.type ?? "").toLowerCase() === "textarea";
}
/** è¯»å–字段配置的日期格式(兼容 format / dateFormat / timeFormat) */
export function getFieldFormatStr(field) {
  return (
    field?.format ?? field?.dateFormat ?? field?.timeFormat ?? ""
  ).trim();
}
/** æ—¥æœŸæ—¶é—´å­—段种类:date | time | datetime | null */
export function getDateFieldKind(field) {
  const type = String(field?.type ?? "").toLowerCase();
  if (DATE_KIND_BY_TYPE[type]) return DATE_KIND_BY_TYPE[type];
  const fmt = getFieldFormatStr(field);
  if (!fmt) return null;
  const hasDate = /Y{2,4}|D{1,2}/i.test(fmt);
  const hasTime = /H{1,2}|h{1,2}|m{1,2}|s{1,2}/i.test(fmt);
  if (hasDate && hasTime) return "datetime";
  if (hasTime && !hasDate) return "time";
  if (hasDate) return "date";
  return null;
}
/** æ˜¯å¦ä¸ºæ—¥æœŸ/时间类字段(不含日期时间范围) */
export function isDateLikeField(field) {
  if (isDatetimerangeField(field)) return false;
  return !!getDateFieldKind(field);
}
/** @deprecated ä½¿ç”¨ isDateLikeField */
export function isDateField(field) {
  return isDateLikeField(field);
}
/** uView datetime-picker çš„ mode */
export function getDatePickerMode(field) {
  const kind = getDateFieldKind(field);
  if (kind === "time") return "time";
  if (kind === "datetime") return "datetime";
  return "date";
}
/** moment é£Žæ ¼æ ¼å¼ â†’ parseTime æ¨¡æ¿ */
export function momentFormatToParsePattern(fmt) {
  if (!fmt) return null;
  return fmt
    .replace(/YYYY/g, "{y}")
    .replace(/YY/g, "{y}")
    .replace(/DD/g, "{d}")
    .replace(/dd/g, "{d}")
    .replace(/MM/g, "{m}")
    .replace(/HH/g, "{h}")
    .replace(/hh/g, "{h}")
    .replace(/mm/g, "{i}")
    .replace(/ss/g, "{s}");
}
/** å°†æ—¶é—´æˆ³/Date æ ¼å¼åŒ–为字段配置格式 */
export function formatFieldDateValue(field, dateSource) {
  const kind = getDateFieldKind(field);
  if (!kind) return "";
  const fmt = getFieldFormatStr(field) || DEFAULT_FORMAT[kind];
  const pattern = momentFormatToParsePattern(fmt);
  let date;
  if (typeof dateSource === "number") date = new Date(dateSource);
  else if (dateSource instanceof Date) date = dateSource;
  else return String(dateSource ?? "");
  return parseTime(date, pattern) || "";
}
/** å±•示用:将已存值按配置格式回显 */
export function formatFieldDisplayValue(field, storedValue) {
  if (storedValue === undefined || storedValue === null || storedValue === "") {
    return "";
  }
  if (!getDateFieldKind(field)) return String(storedValue);
  const ts = parseFieldDateToTs(storedValue);
  if (ts) return formatFieldDateValue(field, ts);
  return String(storedValue);
}
/** å°†å·²å­˜æ—¥æœŸå­—符串转为时间戳(供选择器初始值) */
export function parseFieldDateToTs(value) {
  if (value === undefined || value === null || value === "") return null;
  if (typeof value === "number") return value;
  const str = String(value).trim();
  const normalized = str.replace(/-/g, "/").replace("T", " ");
  const t = new Date(normalized).getTime();
  return Number.isNaN(t) ? null : t;
}
/** æ˜¯å¦ä¸ºæ•°å­— */
export function isNumberField(field) {
  return String(field?.type ?? "").toLowerCase() === "number";
}
/**
 * å°†å­—段配置中的选项规范为 { label, value }[]
 * æ”¯æŒï¼šoptions / optionList;项为字符串或 { label|name|text, value|key|code }
 */
export function normalizeFieldOptions(field) {
  const raw =
    field?.options ?? field?.optionList ?? field?.dictOptions ?? field?.items;
  if (!Array.isArray(raw) || !raw.length) return [];
  return raw
    .map((item, index) => {
      if (item == null) return null;
      if (typeof item === "string" || typeof item === "number") {
        const text = String(item);
        return { label: text, value: text };
      }
      if (typeof item !== "object") return null;
      const label =
        item.label ??
        item.name ??
        item.text ??
        item.dictLabel ??
        item.title;
      const rawValue =
        item.value ?? item.key ?? item.code ?? item.dictValue ?? item.id;
      if (label == null && rawValue == null) return null;
      const value =
        rawValue !== undefined && rawValue !== null ? rawValue : label ?? index;
      return {
        label: String(label ?? value),
        value,
      };
    })
    .filter(Boolean);
}
/** æŒ‰å­˜å‚¨å€¼åŒ¹é…é€‰é¡¹å±•示文案 */
export function getFieldOptionLabel(field, storedValue) {
  if (storedValue === undefined || storedValue === null || storedValue === "") {
    return "";
  }
  const options = normalizeFieldOptions(field);
  const strVal = String(storedValue);
  const matched = options.find(
    opt =>
      String(opt.value) === strVal ||
      String(opt.label) === strVal
  );
  return matched?.label ?? "";
}
/** åˆå§‹åŒ–填报值:优先已填 value,其次 defaultValue */
export function getFieldInitialValue(field) {
  if (field?.value !== undefined && field?.value !== null && field?.value !== "") {
    return field.value;
  }
  if (
    field?.defaultValue !== undefined &&
    field?.defaultValue !== null &&
    field?.defaultValue !== ""
  ) {
    return field.defaultValue;
  }
  return "";
}
/** æ¨¡æ¿ç¼–辑:控件类型选项(与 Web ç«¯ä¸€è‡´ï¼‰ */
export const FIELD_EDITOR_TYPE_OPTIONS = [
  { name: "单行文本", value: "text" },
  { name: "多行文本", value: "textarea" },
  { name: "数字", value: "number" },
  { name: "日期", value: "date" },
  { name: "日期时间范围", value: "datetimerange" },
  { name: "下拉选择", value: "select" },
];
/** ä¸‹æ‹‰é€‰é¡¹æ¥æº */
export const FIELD_OPTION_SOURCE_OPTIONS = [
  { name: "手动配置", value: "manual" },
  { name: "人员列表", value: "user" },
  { name: "部门列表", value: "dept" },
];
const OPTION_SOURCE_ALIASES = {
  manual: "manual",
  user: "user",
  personnel: "user",
  userlist: "user",
  dept: "dept",
  department: "dept",
  deptlist: "dept",
};
export function getFieldEditorTypeLabel(type) {
  const found = FIELD_EDITOR_TYPE_OPTIONS.find(
    item => String(item.value) === String(type)
  );
  return found?.name || type || "-";
}
export function getFieldOptionSourceLabel(source) {
  const key = getFieldOptionSource(source);
  const found = FIELD_OPTION_SOURCE_OPTIONS.find(item => item.value === key);
  return found?.name || "手动配置";
}
/** è§£æžé€‰é¡¹æ¥æºï¼šmanual | user | dept */
export function getFieldOptionSource(fieldOrSource) {
  const raw =
    typeof fieldOrSource === "object"
      ? fieldOrSource?.optionSource
      : fieldOrSource;
  const key = String(raw ?? "manual")
    .trim()
    .toLowerCase();
  return OPTION_SOURCE_ALIASES[key] || "manual";
}
export function isDatetimerangeField(field) {
  return String(field?.type ?? "").toLowerCase() === "datetimerange";
}
/** è§£æžæ—¥æœŸæ—¶é—´èŒƒå›´é»˜è®¤å€¼ï¼šstart,end */
export function parseDatetimerangeValue(stored) {
  if (stored === undefined || stored === null || stored === "") {
    return { start: "", end: "" };
  }
  const parts = String(stored)
    .split(",")
    .map(s => s.trim());
  return { start: parts[0] || "", end: parts[1] || "" };
}
export function joinDatetimerangeValue(start, end) {
  const s = String(start ?? "").trim();
  const e = String(end ?? "").trim();
  if (!s && !e) return "";
  return `${s},${e}`;
}
export function formatDatetimerangeDisplay(stored) {
  const { start, end } = parseDatetimerangeValue(stored);
  if (!start && !end) return "";
  if (start && end) return `${start} è‡³ ${end}`;
  return start || end;
}
/**
 * è§£æžä¸‹æ‹‰é€‰é¡¹ï¼ˆå«äººå‘˜/部门动态来源)
 * @param {object} field
 * @param {{ users?: array, depts?: array }} context
 */
export function resolveFieldOptions(field, context = {}) {
  const source = getFieldOptionSource(field);
  if (source === "user") {
    return (context.users || []).map(user => ({
      label: user.nickName || user.userName || String(user.userId ?? ""),
      value: user.userId,
    }));
  }
  if (source === "dept") {
    return (context.depts || []).map(dept => ({
      label: dept.deptName || dept.name || String(dept.deptId ?? dept.id ?? ""),
      value: dept.deptId ?? dept.id,
    }));
  }
  return normalizeFieldOptions(field);
}
export function createEmptyFieldOption() {
  return { label: "", value: "" };
}
/** å°†ç¼–辑草稿规范为可提交的字段对象 */
export function buildFieldConfigPayload(draft, existingKey) {
  const payload = {
    key: (draft.key || existingKey || "").trim(),
    label: (draft.label || "").trim(),
    type: draft.type || "text",
    required: !!draft.required,
    defaultValue: String(draft.defaultValue ?? "").trim(),
  };
  if (isSelectField(payload)) {
    payload.optionSource = getFieldOptionSource(draft.optionSource);
    if (payload.optionSource === "manual") {
      payload.options = (draft.options || [])
        .map(opt => ({
          label: String(opt?.label ?? "").trim(),
          value: String(opt?.value ?? "").trim(),
        }))
        .filter(opt => opt.label && opt.value);
    } else {
      delete payload.options;
    }
  }
  return payload;
}
src/pages/oa/_utils/approvalModuleApplyExtras.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,293 @@
import dayjs from "dayjs";
import {
  APPROVAL_MODULE_KEYS,
  APPROVAL_MODULE_REGISTRY,
  getModuleMatchingBusinessTypes,
} from "./approvalModuleRegistry.js";
import { matchBusinessTypeValue } from "./approvalTemplateType.js";
import { parseDatetimerangeValue } from "./approvalFormField.js";
/** äººå‘˜ä¸‹æ‹‰å­—段识别(与 Web SELECT_OPTION_SOURCE.USER ç­‰ä»·ï¼‰ */
export function isUserSelectField(field) {
  const src = String(field?.optionSource ?? "").toLowerCase();
  return (
    src === "user" ||
    src === "personnel" ||
    src === "userlist" ||
    (field?.type === "select" && String(field?.label || "").includes("申请人"))
  );
}
export function findApplicantTemplateField(fields = []) {
  return (
    fields.find(f => String(f?.label || "").includes("申请人")) ||
    fields.find(f => isUserSelectField(f)) ||
    null
  );
}
/* ---------- è¯·å‡ ---------- */
export function isLeaveBalanceField(field) {
  const label = String(field?.label || "");
  return label.includes("假期余额") || field?.key === "leaveBalanceDays";
}
export function isLeaveDurationField(field) {
  const label = String(field?.label || "");
  return label.includes("请假时长") || field?.key === "leaveDurationDays";
}
export function displayLeaveTemplateFields(fields = []) {
  return (fields || []).filter(
    f => !isLeaveBalanceField(f) && !isLeaveDurationField(f)
  );
}
export function findLeaveTimeTemplateField(fields = []) {
  return (
    fields.find(
      f => f?.type === "datetimerange" && String(f?.label || "").includes("请假时间")
    ) ||
    fields.find(f => f?.type === "datetimerange" && f?.key === "dateRange") ||
    fields.find(f => f?.type === "datetimerange") ||
    null
  );
}
export function resolveTimeRangeFromPayload(payload, timeField) {
  if (!timeField?.key) return { start: "", end: "" };
  const val = payload?.[timeField.key];
  if (Array.isArray(val) && val.length >= 2) {
    return { start: val[0] || "", end: val[1] || "" };
  }
  return parseDatetimerangeValue(val);
}
export function computeLeaveDays(startStr, endStr) {
  if (!startStr || !endStr) return null;
  const t0 = dayjs(startStr);
  const t1 = dayjs(endStr);
  if (!t0.isValid() || !t1.isValid() || !t1.isAfter(t0)) return null;
  const days = t1.diff(t0, "millisecond") / (24 * 60 * 60 * 1000);
  return Math.round(days * 100) / 100;
}
export function computeLeaveDurationDisplay(fields, formPayload) {
  const timeField = findLeaveTimeTemplateField(fields);
  const { start, end } = resolveTimeRangeFromPayload(formPayload, timeField);
  const d = computeLeaveDays(start, end);
  return d == null ? "" : String(d);
}
export function validateLeaveBeforeSubmit(fields, formPayload) {
  const timeField = findLeaveTimeTemplateField(fields);
  const { start, end } = resolveTimeRangeFromPayload(formPayload, timeField);
  if (computeLeaveDays(start, end) == null) {
    return "请检查模板中的请假时间,结束时间须晚于开始时间";
  }
  return "";
}
/* ---------- åŠ ç­ ---------- */
export function isOvertimeDurationField(field) {
  const label = String(field?.label || "");
  return label.includes("加班时长") || field?.key === "overtimeHours";
}
export function displayOvertimeTemplateFields(fields = []) {
  return (fields || []).filter(f => !isOvertimeDurationField(f));
}
export function findOvertimeTimeTemplateField(fields = []) {
  return (
    fields.find(
      f => f?.type === "datetimerange" && String(f?.label || "").includes("加班时间")
    ) ||
    fields.find(f => f?.type === "datetimerange") ||
    null
  );
}
export function computeOvertimeHours(startStr, endStr) {
  if (!startStr || !endStr) return null;
  const t0 = dayjs(startStr);
  const t1 = dayjs(endStr);
  if (!t0.isValid() || !t1.isValid() || !t1.isAfter(t0)) return null;
  return Math.round((t1.diff(t0, "millisecond") / 3600000) * 100) / 100;
}
export function computeOvertimeHoursDisplay(fields, formPayload) {
  const field = findOvertimeTimeTemplateField(fields);
  const { start, end } = resolveTimeRangeFromPayload(formPayload, field);
  const h = computeOvertimeHours(start, end);
  return h == null ? "" : String(h);
}
export function validateOvertimeBeforeSubmit(fields, formPayload) {
  const field = findOvertimeTimeTemplateField(fields);
  const { start, end } = resolveTimeRangeFromPayload(formPayload, field);
  if (computeOvertimeHours(start, end) == null) {
    return "请检查模板中的加班时间,结束时间须晚于开始时间";
  }
  return "";
}
/* ---------- è°ƒå²— ---------- */
export function isOriginalPostField(field) {
  const label = String(field?.label || "");
  return (
    label.includes("原岗位") ||
    field?.key === "originalPost" ||
    field?.key === "originalPostName" ||
    field?.key === "originalPostId"
  );
}
export function displayTransferTemplateFields(fields = []) {
  return (fields || []).filter(f => !isOriginalPostField(f));
}
export function unwrapUserArray(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 [];
}
export function isActiveUser(u) {
  if (u?.delFlag === "2" || u?.delFlag === 2) return false;
  if (u?.status == null) return true;
  return String(u.status) === "0";
}
export function firstPostId(user) {
  if (!user) return undefined;
  if (Array.isArray(user.postIds) && user.postIds.length) return user.postIds[0];
  if (user.postId != null && user.postId !== "") return user.postId;
  return undefined;
}
export function buildPostIdToNameMap(postRows = []) {
  const m = {};
  for (const p of postRows) {
    const id = p.postId ?? p.value ?? p.id;
    if (id != null && id !== "") {
      m[String(id)] = p.postName ?? p.label ?? p.name ?? "";
    }
  }
  return m;
}
export function resolveOriginalPostName(user, postIdToName = {}) {
  if (!user) return "";
  const nameStr = (user.postName ?? user.postname ?? "").toString().trim();
  if (nameStr) return nameStr;
  if (Array.isArray(user.posts) && user.posts.length) {
    return (user.posts[0].postName ?? "").toString() || "未命名岗位";
  }
  const pid = firstPostId(user);
  if (pid != null && pid !== "") {
    const n = postIdToName[String(pid)] || "";
    return n || "当前岗位(未在岗位字典中)";
  }
  return "未分配岗位";
}
export function userById(users, id) {
  if (id == null || id === "") return undefined;
  return (users || []).find(u => String(u.userId ?? u.id) === String(id));
}
/** æŒ‰ moduleKey è¿‡æ»¤æ¨¡æ¿å¡«æŠ¥é¡¹ */
export function displayTemplateFieldsByModule(moduleKey, fields = []) {
  if (moduleKey === APPROVAL_MODULE_KEYS.LEAVE) {
    return displayLeaveTemplateFields(fields);
  }
  if (moduleKey === APPROVAL_MODULE_KEYS.OVERTIME) {
    return displayOvertimeTemplateFields(fields);
  }
  if (moduleKey === APPROVAL_MODULE_KEYS.TRANSFER) {
    return displayTransferTemplateFields(fields);
  }
  return fields || [];
}
/** ä¿å­˜å‰å°†ä¸šåŠ¡æ‰©å±•å­—æ®µå†™å…¥ formValues */
export function syncModuleExtrasToFormValues(moduleKey, formValues, extras, fields) {
  if (!moduleKey || !formValues) return;
  if (moduleKey === APPROVAL_MODULE_KEYS.LEAVE) {
    if (extras.leaveBalanceDays != null && extras.leaveBalanceDays !== "") {
      formValues.leaveBalanceDays = extras.leaveBalanceDays;
    }
    const days = computeLeaveDurationDisplay(fields, formValues);
    if (days) formValues.leaveDurationDays = days;
  }
  if (moduleKey === APPROVAL_MODULE_KEYS.OVERTIME) {
    const hours = computeOvertimeHoursDisplay(fields, formValues);
    if (hours) formValues.overtimeHours = hours;
  }
  if (moduleKey === APPROVAL_MODULE_KEYS.TRANSFER && extras.originalPostName) {
    formValues.originalPostName = extras.originalPostName;
    formValues.originalPost = extras.originalPostName;
  }
}
/** ä¸šåŠ¡æ‰©å±•æ ¡éªŒ */
export function validateModuleExtras(moduleKey, fields, formPayload, extras) {
  if (moduleKey === APPROVAL_MODULE_KEYS.LEAVE) {
    if (
      extras.leaveBalanceDays == null ||
      extras.leaveBalanceDays === "" ||
      Number.isNaN(Number(extras.leaveBalanceDays))
    ) {
      return "请填写假期余额";
    }
    const msg = validateLeaveBeforeSubmit(fields, formPayload);
    if (msg) return msg;
  }
  if (moduleKey === APPROVAL_MODULE_KEYS.OVERTIME) {
    const msg = validateOvertimeBeforeSubmit(fields, formPayload);
    if (msg) return msg;
  }
  return "";
}
/** ä»Žå®žä¾‹ businessType æŽ¨æ–­ moduleKey(编辑入口未带 moduleKey æ—¶ï¼‰ */
export function inferModuleKeyFromRow(row, typeOptions = []) {
  const bt = row?.businessType;
  if (bt == null || bt === "") return "";
  for (const key of Object.values(APPROVAL_MODULE_KEYS)) {
    const types = getModuleMatchingBusinessTypes(key, typeOptions);
    if (types.some(t => matchBusinessTypeValue(t, bt))) return key;
    const cfg = APPROVAL_MODULE_REGISTRY[key];
    if (cfg && matchBusinessTypeValue(cfg.approvalType, bt)) return key;
  }
  return "";
}
/** ç¼–辑回显:从实例行恢复扩展字段 */
export function loadModuleExtrasFromRow(moduleKey, row, formPayload) {
  const extras = {
    leaveBalanceDays: undefined,
    originalPostName: "",
  };
  if (!moduleKey || !row) return extras;
  const payload = formPayload || {};
  if (moduleKey === APPROVAL_MODULE_KEYS.LEAVE) {
    const v = payload.leaveBalanceDays ?? row.leaveBalanceDays;
    extras.leaveBalanceDays =
      v != null && v !== "" ? Number(v) : undefined;
  }
  if (moduleKey === APPROVAL_MODULE_KEYS.TRANSFER) {
    extras.originalPostName =
      payload.originalPostName ||
      payload.originalPost ||
      row.originalPostName ||
      "";
  }
  return extras;
}
src/pages/oa/_utils/approvalModuleListSearch.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,447 @@
import {
  APPROVAL_MODULE_KEYS,
  APPROVAL_MODULE_REGISTRY,
  getApprovalModuleConfig,
  getModuleMatchingBusinessTypes,
} from "./approvalModuleRegistry.js";
import {
  parseApprovalFormConfig,
  parseDatetimerangeValue,
} from "./approvalFormField.js";
import { matchBusinessTypeValue } from "./approvalTemplateType.js";
/** ä¸Ž Web leave-apply LEAVE_TYPE_OPTIONS ä¸€è‡´ */
export const LEAVE_TYPE_OPTIONS = [
  { label: "年假", value: "annual" },
  { label: "病假", value: "sick" },
  { label: "事假", value: "personal" },
  { label: "婚假", value: "marriage" },
  { label: "产假", value: "maternity" },
  { label: "哺乳假", value: "nursing" },
  { label: "慰唁假", value: "condolence" },
  { label: "调休", value: "compensatory" },
];
/** ä¸Ž Web overtime-apply OVERTIME_TYPE_OPTIONS ä¸€è‡´ */
export const OVERTIME_TYPE_OPTIONS = [
  { label: "工作日加班", value: "weekday" },
  { label: "休息日加班", value: "weekend" },
  { label: "法定节假日加班", value: "holiday" },
];
export const HANDOVER_STATUS_OPTIONS = [
  { label: "进行中", value: "in_progress" },
  { label: "已完成", value: "completed" },
  { label: "已退回", value: "returned" },
];
export const HANDOVER_TYPE_OPTIONS = [
  { label: "离职交接", value: "resignation" },
  { label: "调岗交接", value: "transfer" },
];
function buildFormPayloadFromFields(fields = []) {
  const payload = {};
  for (const f of fields) {
    if (!f?.key) continue;
    const val = f.value ?? f.defaultValue;
    if (val !== undefined && val !== null && val !== "") {
      payload[f.key] = val;
    }
  }
  return payload;
}
function guessFieldTypeFromValue(val) {
  if (Array.isArray(val)) return "datetimerange";
  if (typeof val === "number") return "number";
  return "text";
}
/** è§£æžå®žä¾‹ formConfig / formPayload(与 Web resolveInstanceFormFields å¯¹é½ï¼‰ */
export function resolveInstanceFormPayload(row) {
  const cfg = parseApprovalFormConfig(row?.formConfig);
  let fields = (row?.formFieldDefs?.length ? row.formFieldDefs : cfg.fields) || [];
  const formPayload = {
    ...(fields.length ? buildFormPayloadFromFields(fields) : {}),
    ...cfg.formPayload,
    ...(row?.formPayload || {}),
  };
  if (!fields.length && Object.keys(formPayload).length) {
    fields = Object.keys(formPayload)
      .filter(k => k && k !== "summary")
      .map(k => ({
        key: k,
        label: k,
        type: guessFieldTypeFromValue(formPayload[k]),
        required: false,
        options: [],
      }));
  }
  fields = fields.map(field => ({
    ...field,
    value:
      formPayload[field.key] ?? field.value ?? field.defaultValue ?? "",
  }));
  return { fields, formPayload };
}
/** å·²çŸ¥ä¸‹æ‹‰å­—段 value â†’ å±•示文案(模板未带 options æ—¶å…œåº•) */
export function formatKnownSelectLabel(prop, val) {
  if (val === undefined || val === null || val === "") return "";
  const maps = {
    leaveType: LEAVE_TYPE_OPTIONS,
    overtimeType: OVERTIME_TYPE_OPTIONS,
    handoverStatus: HANDOVER_STATUS_OPTIONS,
    handoverType: HANDOVER_TYPE_OPTIONS,
  };
  const options = maps[prop];
  if (!options) return "";
  const hit = options.find(o => String(o.value) === String(val));
  return hit?.label || "";
}
export function getRowPayloadValue(row, keys) {
  const keyList = Array.isArray(keys) ? keys : [keys];
  const { formPayload } = resolveInstanceFormPayload(row);
  for (const k of keyList) {
    if (row?.[k] != null && row[k] !== "") return row[k];
    if (formPayload[k] != null && formPayload[k] !== "") return formPayload[k];
  }
  return "";
}
const DATETIME_RANGE_KEYS = [
  "dateRange",
  "leaveTime",
  "overtimeTime",
  "timeRange",
];
function pickDatetimerangeRaw(formPayload, fields = []) {
  for (const key of DATETIME_RANGE_KEYS) {
    const v = formPayload?.[key];
    if (v != null && v !== "") return v;
    const field = (fields || []).find(f => f?.key === key);
    const fv = field?.value ?? field?.defaultValue;
    if (fv != null && fv !== "") return fv;
  }
  const rangeField = (fields || []).find(
    f => String(f?.type || "").toLowerCase() === "datetimerange"
  );
  if (rangeField?.key) {
    const v =
      formPayload?.[rangeField.key] ?? rangeField.value ?? rangeField.defaultValue;
    if (v != null && v !== "") return v;
  }
  return null;
}
function splitRangeValue(val) {
  if (val === undefined || val === null || val === "") {
    return { start: "", end: "" };
  }
  if (Array.isArray(val)) {
    return { start: val[0] || "", end: val[1] || "" };
  }
  return parseDatetimerangeValue(val);
}
/**
 * åˆ—表列 prop ä¸Ž formPayload å¯¹é½ï¼ˆè¯·å‡ startTime/endTime æ¥è‡ª dateRange)
 */
export function resolveListFieldRawValue(prop, row, fields = [], formPayload = {}) {
  const payload = formPayload || {};
  const direct = payload[prop] ?? row?.[prop];
  if (prop === "startTime" || prop === "endTime") {
    if (direct != null && direct !== "") return direct;
    const altStart =
      payload.start ?? payload.startDate ?? payload.beginTime ?? row?.startTime;
    const altEnd =
      payload.end ?? payload.endDate ?? payload.finishTime ?? row?.endTime;
    if (prop === "startTime" && altStart) return altStart;
    if (prop === "endTime" && altEnd) return altEnd;
    const { start, end } = splitRangeValue(pickDatetimerangeRaw(payload, fields));
    return prop === "startTime" ? start : end;
  }
  if (prop === "overtimeDate") {
    const d = payload.overtimeDate ?? payload.date ?? direct;
    if (d != null && d !== "") return Array.isArray(d) ? d[0] || "" : d;
    const { start } = splitRangeValue(pickDatetimerangeRaw(payload, fields));
    return start || "";
  }
  if (direct != null && direct !== "") return direct;
  const hit = (fields || []).find(f => f?.key === prop);
  return hit?.value ?? hit?.defaultValue ?? "";
}
function pickDateRange(searchForm) {
  const range =
    searchForm?.createTimeRange ??
    searchForm?.applyDateRange ??
    searchForm?.transferDateRange;
  if (!Array.isArray(range) || !range[0]) return {};
  const out = { createTimeStart: range[0] };
  if (range[1]) out.createTimeEnd = range[1];
  return out;
}
/** å„模块默认查询表单(与 Web searchForm å­—段一致) */
export function createModuleSearchForm(moduleKey) {
  switch (moduleKey) {
    case APPROVAL_MODULE_KEYS.REGULAR:
      return { applicantName: "", applyDateRange: null };
    case APPROVAL_MODULE_KEYS.TRANSFER:
      return { applicantId: "", transferDateRange: null };
    case APPROVAL_MODULE_KEYS.WORK_HANDOVER:
      return { applicantId: "", handoverStatus: "", handoverType: "" };
    case APPROVAL_MODULE_KEYS.LEAVE:
      return { applicantKeyword: "", leaveType: "" };
    case APPROVAL_MODULE_KEYS.OVERTIME:
      return { applicantKeyword: "", overtimeType: "" };
    case APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE:
    case APPROVAL_MODULE_KEYS.COST_REIMBURSE:
    case APPROVAL_MODULE_KEYS.ENTERPRISE_NEWS:
      return { applicantKeyword: "" };
    default:
      return {};
  }
}
/** æœåŠ¡ç«¯ listPage DTO ç‰‡æ®µï¼ˆä¸Ž Web buildExtraListParams + buildApprovalInstanceListParams ä¸€è‡´ï¼‰ */
export function buildModuleListDto(moduleKey, searchForm = {}) {
  const sf = searchForm || {};
  const dto = { ...pickDateRange(sf) };
  switch (moduleKey) {
    case APPROVAL_MODULE_KEYS.REGULAR: {
      const name = (sf.applicantName || "").trim();
      if (name) dto.applicantName = name;
      break;
    }
    case APPROVAL_MODULE_KEYS.TRANSFER:
      break;
    case APPROVAL_MODULE_KEYS.WORK_HANDOVER:
      break;
    case APPROVAL_MODULE_KEYS.LEAVE:
    case APPROVAL_MODULE_KEYS.OVERTIME:
      break;
    default:
      break;
  }
  return dto;
}
function matchApplicantKeyword(row, keyword) {
  const kw = (keyword || "").trim().toLowerCase();
  if (!kw) return true;
  const parts = [
    row?.applicantName,
    row?.applicantNo,
    row?.applicantId,
    getRowPayloadValue(row, ["applicant", "applicantName", "applicantId"]),
  ]
    .filter(v => v != null && v !== "")
    .map(v => String(v).toLowerCase());
  return parts.some(p => p.includes(kw));
}
function matchSelectValue(row, keys, expected) {
  if (!expected) return true;
  const raw = getRowPayloadValue(row, keys);
  return String(raw) === String(expected);
}
function matchApplicantId(row, applicantId) {
  if (!applicantId) return true;
  const id = String(applicantId);
  if (row?.applicantId != null && String(row.applicantId) === id) return true;
  const payloadApplicant = getRowPayloadValue(row, [
    "applicant",
    "applicantId",
    "applicantUserId",
  ]);
  return String(payloadApplicant) === id;
}
/** æŒ‰æ¨¡å— businessType / æ ‡é¢˜å½’属过滤(服务端未生效时的兜底) */
export function filterRowsByModuleBusinessType(moduleKey, rows, typeOptions = []) {
  const cfg = getApprovalModuleConfig(moduleKey);
  if (!cfg) return rows;
  const types = getModuleMatchingBusinessTypes(moduleKey, typeOptions);
  const myLabels = [cfg.label, ...(cfg.typeLabels || [])].filter(Boolean);
  return (rows || []).filter(row => {
    if (types.length && row?.businessType != null && row.businessType !== "") {
      if (types.some(t => matchBusinessTypeValue(row.businessType, t))) {
        return true;
      }
    }
    const title = String(row?.title || row?.templateName || "").trim();
    if (title) {
      if (myLabels.some(l => title === l || title.includes(l))) return true;
      for (const [key, other] of Object.entries(APPROVAL_MODULE_REGISTRY)) {
        if (key === moduleKey) continue;
        const otherLabels = [other.label, ...(other.typeLabels || [])].filter(Boolean);
        if (otherLabels.some(l => title === l || (l.length > 2 && title.includes(l)))) {
          return false;
        }
      }
    }
    return types.length === 0;
  });
}
/** å‰ç«¯ç­›é€‰ï¼ˆWeb æœªä¸‹å‘接口的字段与 Web è¡Œä¸ºä¸€è‡´ï¼‰ */
export function filterRowsByModuleSearch(moduleKey, rows, searchForm = {}) {
  const sf = searchForm || {};
  const list = Array.isArray(rows) ? rows : [];
  switch (moduleKey) {
    case APPROVAL_MODULE_KEYS.TRANSFER:
      return list.filter(
        row =>
          matchApplicantId(row, sf.applicantId) &&
          matchApplicantKeyword(row, sf.applicantKeyword)
      );
    case APPROVAL_MODULE_KEYS.WORK_HANDOVER:
      return list.filter(
        row =>
          matchApplicantId(row, sf.applicantId) &&
          matchSelectValue(row, ["handoverStatus", "交接状态"], sf.handoverStatus) &&
          matchSelectValue(row, ["handoverType", "交接类型"], sf.handoverType)
      );
    case APPROVAL_MODULE_KEYS.LEAVE:
      return list.filter(
        row =>
          matchApplicantKeyword(row, sf.applicantKeyword) &&
          matchSelectValue(row, ["leaveType", "请假类型"], sf.leaveType)
      );
    case APPROVAL_MODULE_KEYS.OVERTIME:
      return list.filter(
        row =>
          matchApplicantKeyword(row, sf.applicantKeyword) &&
          matchSelectValue(row, ["overtimeType", "加班类型"], sf.overtimeType)
      );
    case APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE:
    case APPROVAL_MODULE_KEYS.COST_REIMBURSE:
    case APPROVAL_MODULE_KEYS.ENTERPRISE_NEWS:
      return list.filter(row => matchApplicantKeyword(row, sf.applicantKeyword));
    default:
      return list;
  }
}
/** æ¨¡å—筛选 UI é…ç½® */
export function getModuleSearchMeta(moduleKey) {
  switch (moduleKey) {
    case APPROVAL_MODULE_KEYS.REGULAR:
      return {
        fields: [
          { key: "applicantName", type: "input", label: "申请人", placeholder: "请输入申请人" },
          { key: "applyDateRange", type: "daterange", label: "申请日期" },
        ],
      };
    case APPROVAL_MODULE_KEYS.TRANSFER:
      return {
        fields: [
          { key: "applicantId", type: "user", label: "申请人", placeholder: "请选择申请人" },
          { key: "transferDateRange", type: "daterange", label: "转岗时间" },
        ],
      };
    case APPROVAL_MODULE_KEYS.WORK_HANDOVER:
      return {
        fields: [
          { key: "applicantId", type: "user", label: "申请人", placeholder: "请选择申请人" },
          {
            key: "handoverStatus",
            type: "select",
            label: "交接状态",
            options: HANDOVER_STATUS_OPTIONS,
          },
          {
            key: "handoverType",
            type: "select",
            label: "交接类型",
            options: HANDOVER_TYPE_OPTIONS,
          },
        ],
      };
    case APPROVAL_MODULE_KEYS.LEAVE:
      return {
        fields: [
          {
            key: "applicantKeyword",
            type: "input",
            label: "申请人",
            placeholder: "姓名或编号",
          },
          {
            key: "leaveType",
            type: "select",
            label: "请假类型",
            options: LEAVE_TYPE_OPTIONS,
          },
        ],
      };
    case APPROVAL_MODULE_KEYS.OVERTIME:
      return {
        fields: [
          {
            key: "applicantKeyword",
            type: "input",
            label: "申请人",
            placeholder: "姓名或编号",
          },
          {
            key: "overtimeType",
            type: "select",
            label: "加班类型",
            options: OVERTIME_TYPE_OPTIONS,
          },
        ],
      };
    case APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE:
    case APPROVAL_MODULE_KEYS.COST_REIMBURSE:
    case APPROVAL_MODULE_KEYS.ENTERPRISE_NEWS:
      return {
        fields: [
          {
            key: "applicantKeyword",
            type: "input",
            label: "申请人",
            placeholder: "姓名或编号",
          },
        ],
      };
    default:
      return { fields: [] };
  }
}
export function resetModuleSearchForm(moduleKey, target) {
  const defaults = createModuleSearchForm(moduleKey);
  Object.keys(target).forEach(k => {
    if (!(k in defaults)) delete target[k];
  });
  Object.assign(target, defaults);
}
export function formatDateRangeLabel(range) {
  if (!Array.isArray(range) || !range[0]) return "";
  if (range[1]) return `${range[0]} è‡³ ${range[1]}`;
  return range[0];
}
export 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 ?? ""}`;
}
src/pages/oa/_utils/approvalModuleRegistry.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,184 @@
/** ä¸Ž Web approvalModuleRegistry ä¸€è‡´ */
export const APPROVAL_MODULE_KEYS = {
  REGULAR: "regular",
  TRANSFER: "transfer",
  WORK_HANDOVER: "work_handover",
  LEAVE: "leave",
  OVERTIME: "overtime",
  TRAVEL_REIMBURSE: "travel_reimburse",
  COST_REIMBURSE: "cost_reimburse",
  ENTERPRISE_NEWS: "enterprise_news",
};
/** å®¡æ‰¹å®žä¾‹ listPage businessType(与后端约定一致) */
export const APPROVAL_BUSINESS_TYPE = {
  [APPROVAL_MODULE_KEYS.REGULAR]: 10,
  [APPROVAL_MODULE_KEYS.TRANSFER]: 11,
  [APPROVAL_MODULE_KEYS.WORK_HANDOVER]: 13,
  [APPROVAL_MODULE_KEYS.LEAVE]: 14,
  [APPROVAL_MODULE_KEYS.OVERTIME]: 15,
  [APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE]: 16,
  [APPROVAL_MODULE_KEYS.COST_REIMBURSE]: 17,
  [APPROVAL_MODULE_KEYS.ENTERPRISE_NEWS]: 18,
};
export const APPROVAL_MODULE_REGISTRY = {
  [APPROVAL_MODULE_KEYS.REGULAR]: {
    label: "转正申请",
    approvalType: "regular",
    businessType: APPROVAL_BUSINESS_TYPE[APPROVAL_MODULE_KEYS.REGULAR],
    typeLabels: ["转正", "转正申请"],
    listFields: [
      { label: "入职日期", prop: "entryDate" },
      { label: "转正日期", prop: "regularDate" },
    ],
  },
  [APPROVAL_MODULE_KEYS.TRANSFER]: {
    label: "调岗申请",
    approvalType: "transfer",
    businessType: APPROVAL_BUSINESS_TYPE[APPROVAL_MODULE_KEYS.TRANSFER],
    typeLabels: ["调岗", "调动", "调岗申请", "调动申请"],
    listFields: [
      { label: "原岗位", prop: "fromPost" },
      { label: "目标岗位", prop: "toPost" },
    ],
  },
  [APPROVAL_MODULE_KEYS.WORK_HANDOVER]: {
    label: "工作交接",
    approvalType: "work_handover",
    businessType: APPROVAL_BUSINESS_TYPE[APPROVAL_MODULE_KEYS.WORK_HANDOVER],
    typeLabels: ["工作交接", "交接", "工作交接审批"],
    listFields: [
      { label: "交接人", prop: "handoverTo" },
      { label: "交接事项", prop: "handoverItems" },
    ],
  },
  [APPROVAL_MODULE_KEYS.LEAVE]: {
    label: "请假申请",
    approvalType: "leave",
    businessType: APPROVAL_BUSINESS_TYPE[APPROVAL_MODULE_KEYS.LEAVE],
    typeLabels: ["请假", "请假申请", "请假审批"],
    listFields: [
      { label: "请假类型", prop: "leaveType" },
      { label: "开始时间", prop: "startTime" },
      { label: "结束时间", prop: "endTime" },
    ],
  },
  [APPROVAL_MODULE_KEYS.OVERTIME]: {
    label: "加班申请",
    approvalType: "overtime",
    businessType: APPROVAL_BUSINESS_TYPE[APPROVAL_MODULE_KEYS.OVERTIME],
    typeLabels: ["加班", "加班申请", "加班审批"],
    listFields: [
      { label: "加班日期", prop: "overtimeDate" },
      { label: "时长(小时)", prop: "hours" },
    ],
  },
  [APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE]: {
    label: "差旅报销",
    approvalType: "travel_reimburse",
    businessType: APPROVAL_BUSINESS_TYPE[APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE],
    typeLabels: ["差旅", "差旅报销", "出差报销"],
    listFields: [],
  },
  [APPROVAL_MODULE_KEYS.COST_REIMBURSE]: {
    label: "费用报销",
    approvalType: "cost_reimburse",
    businessType: APPROVAL_BUSINESS_TYPE[APPROVAL_MODULE_KEYS.COST_REIMBURSE],
    typeLabels: ["费用", "费用报销"],
    listFields: [],
  },
  [APPROVAL_MODULE_KEYS.ENTERPRISE_NEWS]: {
    label: "企业新闻",
    approvalType: "enterprise_news",
    businessType: APPROVAL_BUSINESS_TYPE[APPROVAL_MODULE_KEYS.ENTERPRISE_NEWS],
    typeLabels: ["企业新闻", "新闻", "新闻发布"],
    listFields: [],
  },
};
export function getApprovalModuleConfig(moduleKey) {
  if (!moduleKey) return null;
  return APPROVAL_MODULE_REGISTRY[moduleKey] || null;
}
export function getModuleListBusinessType(moduleKey) {
  const cfg = getApprovalModuleConfig(moduleKey);
  if (!cfg) return "";
  if (cfg.businessType != null && cfg.businessType !== "") return cfg.businessType;
  return APPROVAL_BUSINESS_TYPE[moduleKey] ?? "";
}
function matchBiz(a, b) {
  if (a == null || a === "" || b == null || b === "") return false;
  return a === b || a === Number(b) || Number(a) === b || String(a) === String(b);
}
export function resolveModuleBusinessType(moduleKey, typeOptions = []) {
  const cfg = getApprovalModuleConfig(moduleKey);
  if (!cfg) return null;
  const fixed = getModuleListBusinessType(moduleKey);
  if (fixed != null && fixed !== "") return fixed;
  const labels = [cfg.label, ...(cfg.typeLabels || [])].filter(Boolean);
  const hitByLabel = (typeOptions || []).find(opt => {
    const optLabel = String(opt?.name || opt?.label || "").trim();
    if (!optLabel) return false;
    return labels.some(
      l => optLabel === l || optLabel.includes(l) || l.includes(optLabel)
    );
  });
  if (hitByLabel?.value != null && hitByLabel.value !== "") return hitByLabel.value;
  if (cfg.approvalType) {
    const hitByValue = (typeOptions || []).find(
      opt =>
        matchBiz(opt?.value, cfg.approvalType) || matchBiz(opt?.code, cfg.approvalType)
    );
    if (hitByValue?.value != null && hitByValue.value !== "") return hitByValue.value;
  }
  return null;
}
export function getModuleMatchingBusinessTypes(moduleKey, typeOptions = []) {
  const cfg = getApprovalModuleConfig(moduleKey);
  if (!cfg) return [];
  const fixed = getModuleListBusinessType(moduleKey);
  if (fixed != null && fixed !== "") return [fixed];
  const values = new Set();
  const primary = resolveModuleBusinessType(moduleKey, typeOptions);
  if (primary != null && primary !== "") values.add(primary);
  const labels = [cfg.label, ...(cfg.typeLabels || [])].filter(Boolean);
  for (const opt of typeOptions || []) {
    const optLabel = String(opt?.name || opt?.label || "").trim();
    if (!optLabel) continue;
    const matched = labels.some(
      l => optLabel === l || optLabel.includes(l) || l.includes(optLabel)
    );
    if (matched && opt.value != null && opt.value !== "") {
      values.add(opt.value);
    }
  }
  return [...values];
}
export const PAGE_KEY_TO_MODULE = {
  "HrManage/regular-apply": APPROVAL_MODULE_KEYS.REGULAR,
  "HrManage/transfer-apply": APPROVAL_MODULE_KEYS.TRANSFER,
  "HrManage/work-handover": APPROVAL_MODULE_KEYS.WORK_HANDOVER,
  "AttendManage/leave-apply": APPROVAL_MODULE_KEYS.LEAVE,
  "AttendManage/overtime-apply": APPROVAL_MODULE_KEYS.OVERTIME,
  "ReimburseManage/travel-reimburse": APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE,
  "ReimburseManage/cost-reimburse": APPROVAL_MODULE_KEYS.COST_REIMBURSE,
  "EnterpriseNews/news-manage": APPROVAL_MODULE_KEYS.ENTERPRISE_NEWS,
};
export function getModuleKeyFromPageKey(pageKey) {
  return PAGE_KEY_TO_MODULE[pageKey] || "";
}
src/pages/oa/_utils/approvalTemplateType.js
@@ -1,4 +1,5 @@
import { getTypeEnums } from "@/api/basic/enum.js";
import { listApprovalTemplateByType } from "@/api/oa/approvalTemplate.js";
/**
 * GET /approvalTemplate/list/{type} è·¯å¾„参数为 templateType
@@ -6,64 +7,133 @@
 */
export const CUSTOM_TEMPLATE_LIST_TYPE = 1;
/** ç³»ç»Ÿå†…置模板(不可删除,填报项等受限) */
export const SYSTEM_TEMPLATE_TYPE = 0;
export function isSystemApprovalTemplate(item) {
  return Number(item?.templateType) === SYSTEM_TEMPLATE_TYPE;
}
/** ä¸šåŠ¡ç±»åž‹æžšä¸¾å…œåº•ï¼ˆapproveType:1公出 2请假 â€¦ï¼‰ */
export const FALLBACK_BUSINESS_TYPE_OPTIONS = [
  { name: "公出管理", value: 1 },
  { name: "请假管理", value: 2 },
];
/** è§£æž TypeEnums æŽ¥å£è¿”回(兼容 { TypeEnums: [] } åµŒå¥—) */
export function unwrapEnumList(data) {
  if (Array.isArray(data)) return data;
  if (!data || typeof data !== "object") return [];
  if (Array.isArray(data.TypeEnums)) return data.TypeEnums;
  if (Array.isArray(data.typeEnums)) return data.typeEnums;
  const nested = Object.values(data).find(v => Array.isArray(v));
  return nested || [];
}
/** è§£æžæ¨¡æ¿åˆ—表接口返回 */
export function unwrapTemplateList(payload) {
  const data = payload?.data ?? payload;
  if (Array.isArray(data)) return data;
  if (Array.isArray(data?.records)) return data.records;
  if (Array.isArray(data?.list)) return data.list;
  if (Array.isArray(data?.rows)) return data.rows;
  return [];
}
/** enabled:1 / true ä¸ºå¯ç”¨ï¼ˆä¸Ž Web mapEnabledFromApi ä¸€è‡´ï¼‰ */
export function mapEnabledFromApi(enabled) {
  if (enabled === undefined || enabled === null) return true;
  if (enabled === true || enabled === 1) return true;
  const s = String(enabled).toLowerCase();
  return s === "1" || s === "true" || s === "yes";
}
export function filterEnabledTemplates(list) {
  return (list || []).filter(row => mapEnabledFromApi(row?.enabled));
}
/** å°† /basic/enum/TypeEnums å“åº”规范为 { name, value }[] */
export function normalizeEnumOptions(data) {
  if (!data) return [];
  if (Array.isArray(data)) {
    return data
      .map(item => {
        const name =
          item?.name ??
          item?.label ??
          item?.text ??
          item?.dictLabel ??
          item?.description;
        const rawValue =
          item?.value ?? item?.code ?? item?.dictValue ?? item?.key ?? item?.id;
        if (name == null || rawValue === undefined || rawValue === null) {
          return null;
        }
        const num = Number(rawValue);
        return {
          name: String(name),
          value: Number.isNaN(num) ? rawValue : num,
        };
      })
      .filter(Boolean);
  }
  if (typeof data === "object") {
    return Object.entries(data).map(([value, name]) => {
      const num = Number(value);
  return unwrapEnumList(data)
    .map(item => {
      const name =
        item?.name ??
        item?.label ??
        item?.text ??
        item?.dictLabel ??
        item?.description;
      const rawValue =
        item?.value ?? item?.code ?? item?.dictValue ?? item?.key ?? item?.id;
      if (name == null || rawValue === undefined || rawValue === null) {
        return null;
      }
      const num = Number(rawValue);
      return {
        name: String(name),
        value: Number.isNaN(num) ? value : num,
        value: Number.isNaN(num) ? rawValue : num,
      };
    });
  }
  return [];
    })
    .filter(Boolean);
}
/** æ‹‰å–业务类型枚举(TypeEnums â†’ businessType) */
export async function fetchApprovalTemplateTypes() {
  const res = await getTypeEnums();
  const options = normalizeEnumOptions(res?.data);
  return options.length ? options : [...FALLBACK_BUSINESS_TYPE_OPTIONS];
  try {
    const res = await getTypeEnums();
    const options = normalizeEnumOptions(res?.data ?? res);
    return options.length ? options : [...FALLBACK_BUSINESS_TYPE_OPTIONS];
  } catch {
    return [...FALLBACK_BUSINESS_TYPE_OPTIONS];
  }
}
/** é€‰å–第一个「有模板」的 Tab ä¸‹æ ‡ */
export function pickTabIndexWithTemplates(typeOptions, templates) {
  if (!typeOptions?.length) return 0;
  for (let i = 0; i < typeOptions.length; i++) {
    const bt = typeOptions[i]?.value;
    if (filterTemplatesByBusinessType(templates, bt).length > 0) return i;
  }
  return 0;
}
/** æ‹‰å–已启用模板(自定义 + ç³»ç»Ÿå†…置,与 Web å¯¼å…¥é€»è¾‘一致) */
export async function fetchEnabledApprovalTemplates() {
  const [customRes, builtinRes] = await Promise.all([
    listApprovalTemplateByType(CUSTOM_TEMPLATE_LIST_TYPE),
    listApprovalTemplateByType(SYSTEM_TEMPLATE_TYPE),
  ]);
  const merged = [
    ...unwrapTemplateList(customRes),
    ...unwrapTemplateList(builtinRes),
  ];
  const byId = new Map();
  merged.forEach(item => {
    if (item?.id != null) byId.set(String(item.id), item);
  });
  return filterEnabledTemplates([...byId.values()]);
}
/** businessType å®½æ¾åŒ¹é…ï¼ˆä¸Ž Web matchBusinessTypeValue ä¸€è‡´ï¼‰ */
export function matchBusinessTypeValue(a, b) {
  if (a == null || a === "" || b == null || b === "") return false;
  return a === b || a === Number(b) || Number(a) === b || String(a) === String(b);
}
/** æŒ‰ businessType ç­›é€‰æ¨¡æ¿ */
export function filterTemplatesByBusinessType(templates, businessType) {
  if (businessType == null || businessType === "") return [];
  return (templates || []).filter(
    item => String(item.businessType) === String(businessType)
  return (templates || []).filter(item =>
    matchBusinessTypeValue(item.businessType, businessType)
  );
}
/** æŒ‰å¤šä¸ª businessType ç­›é€‰ï¼ˆä¸šåŠ¡æ¨¡å—ï¼‰ */
export function filterTemplatesByBusinessTypes(templates, businessTypes = []) {
  const types = (businessTypes || []).filter(t => t != null && t !== "");
  if (!types.length) return [];
  return (templates || []).filter(item =>
    types.some(t => matchBusinessTypeValue(item.businessType, t))
  );
}
@@ -77,6 +147,20 @@
  return idx >= 0 ? idx : 0;
}
/** TypeEnums ä¸ºç©ºæ—¶ï¼Œä»Žæ¨¡æ¿åˆ—表反推 Tab */
export function buildTypeOptionsFromTemplates(templates) {
  const map = new Map();
  (templates || []).forEach(item => {
    const v = item?.businessType;
    if (v == null || v === "") return;
    const key = String(v);
    if (!map.has(key)) {
      map.set(key, { name: `审批类型 ${key}`, value: v });
    }
  });
  return [...map.values()];
}
export function buildTypeLabelMap(options) {
  const map = {};
  (options || []).forEach(opt => {
src/pages/oa/_utils/approveListUtils.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,374 @@
import { parseTime } from "@/utils/ruoyi";
import {
  formatFieldDisplayValue,
  getFieldOptionLabel,
  isSelectField,
  mergeFormConfigForEdit,
} from "./approvalFormField.js";
import {
  formatKnownSelectLabel,
  resolveInstanceFormPayload,
  resolveListFieldRawValue,
} from "./approvalModuleListSearch.js";
export const DETAIL_STORAGE_KEY = "oa_approve_instance_detail_row";
export const INSTANCE_STATUS_TEXT = {
  PENDING: "进行中",
  APPROVED: "已通过",
  REJECTED: "已驳回",
  DRAFT: "草稿",
};
export const INSTANCE_STATUS_TAG = {
  PENDING: "warning",
  APPROVED: "success",
  REJECTED: "error",
  DRAFT: "info",
};
export const TASK_STATUS_TEXT = {
  PENDING: "待处理",
  APPROVED: "已通过",
  REJECTED: "已驳回",
};
export const TASK_STATUS_TAG = {
  PENDING: "warning",
  APPROVED: "success",
  REJECTED: "error",
};
export function instanceStatusText(status) {
  return INSTANCE_STATUS_TEXT[status] || status || "-";
}
export function instanceStatusTagType(status) {
  return INSTANCE_STATUS_TAG[status] || "info";
}
export function taskStatusText(status) {
  const key = String(status || "").toUpperCase();
  return TASK_STATUS_TEXT[key] || status || "待处理";
}
export function taskStatusTagType(status) {
  const key = String(status || "").toUpperCase();
  return TASK_STATUS_TAG[key] || "info";
}
export function formatDateTime(val) {
  if (!val) return "-";
  return parseTime(val, "{y}-{m}-{d} {h}:{i}:{s}") || String(val);
}
/** è§£æžå®žä¾‹ä¸ºåªè¯»å±•示字段(合并 formPayload,支持传整行或仅 formConfig) */
export function resolveInstanceDisplayFields(formConfigOrRow) {
  const row =
    formConfigOrRow &&
    typeof formConfigOrRow === "object" &&
    (formConfigOrRow.formConfig != null ||
      formConfigOrRow.formPayload != null ||
      formConfigOrRow.formFieldDefs != null)
      ? formConfigOrRow
      : { formConfig: formConfigOrRow };
  const { fields } = resolveInstanceFormPayload(row);
  if (fields.length) return fields.filter(f => f?.key);
  const merged = mergeFormConfigForEdit("", row.formConfig);
  return (merged.fields || []).filter(f => f?.key);
}
export function displayFieldValue(field) {
  const val = field.value ?? field.defaultValue;
  if (val === undefined || val === null || val === "") return "-";
  if (isSelectField(field)) {
    const fromOptions = getFieldOptionLabel(field, val);
    if (fromOptions && fromOptions !== "-") return fromOptions;
    const known = formatKnownSelectLabel(field.key, val);
    if (known) return known;
    return String(val);
  }
  const known = formatKnownSelectLabel(field?.key, val);
  if (known) return known;
  const shown = formatFieldDisplayValue(field, val);
  return shown || String(val);
}
const DATETIME_LIST_PROPS = new Set([
  "startTime",
  "endTime",
  "overtimeDate",
  "applyTime",
]);
function formatListFieldDisplay(prop, val, field) {
  if (val === undefined || val === null || val === "") return "-";
  if (DATETIME_LIST_PROPS.has(prop)) {
    const shown = formatDateTime(val);
    if (shown && shown !== "-") return shown;
  }
  if (field?.type === "datetimerange") {
    const shown = formatFieldDisplayValue(field, val);
    if (shown) return shown;
  }
  if (field) return displayFieldValue({ ...field, value: val });
  const known = formatKnownSelectLabel(prop, val);
  if (known) return known;
  return String(val);
}
/** å®¡æ‰¹è®°å½• result:approved | rejected | pending */
export function mapRecordResult(action) {
  const s = String(action || "").toUpperCase();
  if (s === "APPROVED" || s === "APPROVE" || s === "PASS") return "approved";
  if (s === "REJECTED" || s === "REJECT" || s === "REFUSE") return "rejected";
  return "pending";
}
export function recordActionLabel(result) {
  if (result === "approved") return "通过";
  if (result === "rejected") return "驳回";
  return "待处理";
}
export function mapApprovalRecords(records) {
  const list = Array.isArray(records) ? records : [];
  return list.map((r, index) => ({
    id: r.id ?? index,
    operatorName: r.approverName || r.operatorName || r.createUserName || "—",
    result: mapRecordResult(r.approveAction ?? r.action ?? r.status),
    opinion: r.approveComment || r.comment || r.opinion || "",
    time: formatDateTime(r.approveTime || r.createTime || r.time),
  }));
}
export function getRejectReasonFromRecords(records) {
  const mapped = mapApprovalRecords(records);
  const hit = mapped.find(r => r.result === "rejected");
  return hit?.opinion || "";
}
/** åˆ—表 tasks â†’ æµç¨‹èŠ‚ç‚¹ï¼ˆä¸Ž apply é¡µèŠ‚ç‚¹ç»“æž„æŽ¥è¿‘ï¼‰ */
export function mapTasksToFlowNodes(tasks) {
  const list = Array.isArray(tasks) ? tasks : [];
  if (!list.length) return [];
  const byLevel = new Map();
  list.forEach(t => {
    const level = Number(t.levelNo ?? t.taskLevel ?? t.nodeOrder ?? 1);
    if (!byLevel.has(level)) {
      byLevel.set(level, {
        levelNo: level,
        approveType: t.approveType || "AND",
        approvers: [],
      });
    }
    const node = byLevel.get(level);
    node.approvers.push({
      approverName: t.approverName || "—",
      taskStatus: t.taskStatus ?? t.status,
      approveComment: t.approveComment,
      approveTime: t.approveTime,
    });
    if (t.approveType) node.approveType = t.approveType;
  });
  return [...byLevel.entries()]
    .sort(([a], [b]) => a - b)
    .map(([, node]) => node);
}
/** ç»„装审批提交 DTO(与 Web buildApproveInstanceDto ä¸€è‡´ï¼‰ */
export function buildApproveInstanceDto(id, uiResult, comment) {
  const opinion = (comment || "").trim();
  return {
    id,
    approveAction: uiResult === "rejected" ? "REJECTED" : "APPROVED",
    approveComment: opinion || (uiResult === "approved" ? "同意" : ""),
  };
}
/** æ˜¯å¦æœ¬äººå‘起的审批 */
export function isOwnApplication(item, userStore) {
  const uid = userStore?.id;
  if (item?.applicantId != null && uid != null && uid !== "") {
    return String(item.applicantId) === String(uid);
  }
  const loginName = userStore?.nickName || userStore?.name;
  if (loginName && item?.applicantName) {
    return String(item.applicantName).trim() === String(loginName).trim();
  }
  return false;
}
/** ä»…进行中且本人发起时可编辑 */
export function canModifyInstance(item, userStore) {
  return item?.status === "PENDING" && isOwnApplication(item, userStore);
}
/** å¾…当前用户审批 */
export function canApproveInstance(item) {
  return Boolean(item?.isApprove) && item?.status === "PENDING";
}
export function stashInstanceRow(item) {
  if (item) {
    uni.setStorageSync(DETAIL_STORAGE_KEY, item);
  }
}
export function loadInstanceRow(id) {
  const row = uni.getStorageSync(DETAIL_STORAGE_KEY);
  if (!row || String(row.id) !== String(id)) return null;
  return row;
}
export const EDIT_STORAGE_KEY = "oa_approve_instance_edit_row";
/** ä¸šåŠ¡ç”³è¯·é¡µçŠ¶æ€ï¼šè¿›è¡Œä¸­/已完成不可修改(与 Web canEditBusinessInstanceRow ä¸€è‡´ï¼‰ */
export function normalizeApprovalStatusKey(v) {
  if (v == null || v === "") return "pending";
  const upper = String(v).trim().toUpperCase();
  if (upper === "DRAFT") return "draft";
  if (upper === "APPROVED" || upper === "PASS") return "approved";
  if (upper === "REJECTED" || upper === "REJECT" || upper === "REFUSE") {
    return "rejected";
  }
  if (upper === "CANCELLED" || upper === "CANCEL") return "cancelled";
  if (upper === "PENDING" || upper === "IN_PROGRESS") return "pending";
  const lower = String(v).trim().toLowerCase();
  if (["draft", "pending", "approved", "rejected", "cancelled"].includes(lower)) {
    return lower;
  }
  return "pending";
}
export function canEditBusinessInstanceRow(row) {
  const key = normalizeApprovalStatusKey(row?.status ?? row?.approvalStatus);
  return key !== "pending" && key !== "approved";
}
export function businessStatusText(status) {
  const key = normalizeApprovalStatusKey(status);
  if (key === "draft") return "草稿";
  if (key === "pending") return "进行中";
  if (key === "approved") return "已完成";
  if (key === "rejected") return "已驳回";
  if (key === "cancelled") return "已撤销";
  return instanceStatusText(status);
}
export function businessStatusTagType(status) {
  const key = normalizeApprovalStatusKey(status);
  if (key === "approved") return "success";
  if (key === "rejected") return "error";
  if (key === "draft" || key === "cancelled") return "info";
  return "warning";
}
/** OA åˆ—表自定义状态角标 class */
export function businessStatusClass(status) {
  return `status-${normalizeApprovalStatusKey(status)}`;
}
/**
 * ä¸Ž Web buildApprovalInstanceListParams ä¸€è‡´ï¼šæ‰å¹³ query(current/size/businessType/...)
 * å®¡æ‰¹åˆ—表不传 businessType å³æŸ¥å…¨éƒ¨
 */
export function buildInstanceListParams({
  page,
  businessType,
  extraDto = {},
  searchForm,
}) {
  const extra = { ...(extraDto && typeof extraDto === "object" ? extraDto : {}) };
  if (extra.createTime != null && extra.createTimeStart == null) {
    extra.createTimeStart = extra.createTime;
  }
  delete extra.createTime;
  const params = {
    current: page.current,
    size: page.size,
    ...extra,
  };
  const bizType = businessType ?? searchForm?.businessType;
  if (bizType != null && bizType !== "") {
    params.businessType = bizType;
  }
  if (searchForm?.status) {
    params.status = searchForm.status;
  }
  const range =
    searchForm?.createTimeRange ??
    searchForm?.applyDateRange ??
    searchForm?.transferDateRange;
  if (Array.isArray(range) && range[0] && params.createTimeStart == null) {
    params.createTimeStart = range[0];
  }
  if (Array.isArray(range) && range[1] && params.createTimeEnd == null) {
    params.createTimeEnd = range[1];
  }
  return params;
}
export function unwrapInstancePage(res) {
  const data = res?.data ?? res;
  return {
    records: Array.isArray(data?.records) ? data.records : [],
    total: Number(data?.total ?? 0),
  };
}
/** ä»Žå®žä¾‹è¡Œæå–列表展示字段(label + value,含 formPayload) */
export function buildFormDisplayRows(row, listFields = []) {
  const { fields, formPayload } = resolveInstanceFormPayload(row);
  const fieldByKey = new Map((fields || []).map(f => [f.key, f]));
  const rows = [];
  const defs = listFields || [];
  if (defs.length) {
    defs.forEach(def => {
      if (!def?.prop) return;
      const prop = def.prop;
      const hit = fieldByKey.get(prop);
      const raw = resolveListFieldRawValue(prop, row, fields, formPayload);
      rows.push({
        label: def.label || hit?.label || prop,
        value: formatListFieldDisplay(prop, raw, hit),
      });
    });
  } else {
    fields.slice(0, 3).forEach(f => {
      rows.push({ label: f.label, value: displayFieldValue(f) });
    });
  }
  return rows;
}
/** åˆ—表行增强(保留原始字段供详情/编辑) */
export function mapInstanceListRow(row, listFields = []) {
  if (!row) return {};
  const displayRows = buildFormDisplayRows(row, listFields);
  const extra = {};
  const { fields, formPayload } = resolveInstanceFormPayload(row);
  (listFields || []).forEach(def => {
    if (!def?.prop) return;
    const hit = fields.find(f => f.key === def.prop);
    const raw = resolveListFieldRawValue(def.prop, row, fields, formPayload);
    extra[def.prop] = formatListFieldDisplay(def.prop, raw, hit);
  });
  return {
    ...row,
    approvalStatus: normalizeApprovalStatusKey(row.status),
    summary: row.title || row.templateName || "",
    createTime: formatDateTime(row.applyTime || row.createTime),
    displayRows,
    formPayload,
    formFieldDefs: fields,
    ...extra,
  };
}
src/pages/oa/_utils/finReimbursementMappers.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,763 @@
import dayjs from "dayjs";
import {
  deleteFinReimbursement,
  getFinReimbursementDetail,
  persistFinReimbursement,
} from "@/api/oa/finReimbursement.js";
import { APPROVAL_MODULE_KEYS } from "./approvalModuleRegistry.js";
import { businessStatusClass, normalizeApprovalStatusKey } from "./approveListUtils.js";
import {
  EXPENSE_CATEGORY_OPTIONS,
  expenseTypeToCategory,
} from "../ReimburseManage/_utils/costReimburseUtils.js";
import { EXPENSE_SUBJECT_OPTIONS as TRAVEL_EXPENSE_SUBJECTS } from "../ReimburseManage/_utils/travelReimburseUtils.js";
import { EXPENSE_SUBJECT_OPTIONS as COST_EXPENSE_SUBJECTS } from "../ReimburseManage/_utils/costReimburseUtils.js";
import { resolveExpenseSubjectLabel } from "../ReimburseManage/_utils/expenseDetailDisplay.js";
import { applyFinReimbursementDetailEnrichment } from "../ReimburseManage/_utils/finReimbursementDetailExtras.js";
export const FIN_REIMBURSEMENT_TYPE = {
  TRAVEL: "1",
  COST: "2",
};
const REIMBURSEMENT_TYPE_LABEL = {
  [FIN_REIMBURSEMENT_TYPE.TRAVEL]: "差旅报销",
  [FIN_REIMBURSEMENT_TYPE.COST]: "费用报销",
};
/** å½’一化报销类型:1-差旅,2-费用 */
export function normalizeReimbursementType(val) {
  const s = String(val ?? "").trim();
  if (s === "1" || s === FIN_REIMBURSEMENT_TYPE.TRAVEL) {
    return FIN_REIMBURSEMENT_TYPE.TRAVEL;
  }
  if (s === "2" || s === FIN_REIMBURSEMENT_TYPE.COST) {
    return FIN_REIMBURSEMENT_TYPE.COST;
  }
  return "";
}
export function reimbursementTypeLabel(type) {
  return REIMBURSEMENT_TYPE_LABEL[normalizeReimbursementType(type)] || "—";
}
export function getModuleKeyByReimbursementType(type) {
  const t = normalizeReimbursementType(type);
  if (t === FIN_REIMBURSEMENT_TYPE.TRAVEL) {
    return APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE;
  }
  if (t === FIN_REIMBURSEMENT_TYPE.COST) {
    return APPROVAL_MODULE_KEYS.COST_REIMBURSE;
  }
  return "";
}
/** ä¼˜å…ˆæŽ¥å£ reimbursementType,其次页面 moduleKey / å…¥å‚ */
export function resolveReimbursementType(raw, fallback) {
  const fromApi = normalizeReimbursementType(raw?.reimbursementType);
  if (fromApi) return fromApi;
  return (
    normalizeReimbursementType(fallback) ||
    getReimbursementTypeByModuleKey(fallback) ||
    ""
  );
}
export function isTravelReimbursementType(type) {
  return resolveReimbursementType({ reimbursementType: type }, type) === FIN_REIMBURSEMENT_TYPE.TRAVEL;
}
export function filterRowsByReimbursementType(rows, expectedType) {
  const expected = normalizeReimbursementType(expectedType);
  if (!expected) return rows || [];
  return (rows || []).filter(row => {
    const t = resolveReimbursementType(row, expected);
    return t === expected;
  });
}
const BILL_STATUS_LABEL = {
  DRAFT: "草稿",
  IN_APPROVAL: "审批中",
  APPROVED: "审批通过",
  REJECTED: "审批驳回",
  WITHDRAWN: "已撤回",
  PAID: "已付款",
};
export function getReimbursementTypeByModuleKey(moduleKey) {
  if (moduleKey === APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE) {
    return FIN_REIMBURSEMENT_TYPE.TRAVEL;
  }
  if (moduleKey === APPROVAL_MODULE_KEYS.COST_REIMBURSE) {
    return FIN_REIMBURSEMENT_TYPE.COST;
  }
  return "";
}
export function unwrapFinReimbursementPage(res) {
  const data = res?.data ?? res;
  if (!data || typeof data !== "object") {
    return { records: [], total: 0 };
  }
  if (Array.isArray(data.records)) {
    return { records: data.records, total: Number(data.total ?? 0) };
  }
  const nested = data.data;
  if (nested && typeof nested === "object" && Array.isArray(nested.records)) {
    return { records: nested.records, total: Number(nested.total ?? 0) };
  }
  return { records: [], total: 0 };
}
/** è¯¦æƒ…接口 data è§£åŒ… */
export function unwrapFinReimbursementDetail(res) {
  const data = res?.data ?? res;
  if (!data || typeof data !== "object") return {};
  if (data.billNo != null || data.id != null || data.reimbursementType != null) {
    return data;
  }
  const nested = data.data;
  if (nested && typeof nested === "object" && !Array.isArray(nested)) {
    return nested;
  }
  if (data.finReimbursementDto && typeof data.finReimbursementDto === "object") {
    return data.finReimbursementDto;
  }
  return data;
}
export function mapBillStatusToApprovalKey(billStatus) {
  const upper = String(billStatus ?? "").trim().toUpperCase();
  if (upper === "DRAFT") return "draft";
  if (upper === "IN_APPROVAL") return "pending";
  if (upper === "APPROVED") return "approved";
  if (upper === "REJECTED") return "rejected";
  if (upper === "WITHDRAWN") return "cancelled";
  if (upper === "PAID") return "approved";
  return normalizeApprovalStatusKey(billStatus);
}
export function billStatusLabel(billStatus) {
  const upper = String(billStatus ?? "").trim().toUpperCase();
  if (BILL_STATUS_LABEL[upper]) return BILL_STATUS_LABEL[upper];
  const key = mapBillStatusToApprovalKey(billStatus);
  if (key === "draft") return "草稿";
  if (key === "approved") return "已完成";
  if (key === "rejected") return "已驳回";
  if (key === "cancelled") return "已撤回";
  return "进行中";
}
export function billStatusCssClass(item) {
  return businessStatusClass(
    mapBillStatusToApprovalKey(item?.billStatus ?? item?.status)
  );
}
function pickApplicantQuery(searchForm = {}) {
  const kw = (searchForm.applicantKeyword || "").trim();
  if (!kw) return {};
  if (/[\u4e00-\u9fa5]/.test(kw)) return { applicantName: kw };
  return { applicantCode: kw };
}
export function buildFinReimbursementListParams({
  page,
  searchForm,
  reimbursementType,
  extraDto = {},
}) {
  const dto = {
    reimbursementType,
    ...pickApplicantQuery(searchForm),
    ...(extraDto && typeof extraDto === "object" ? extraDto : {}),
  };
  const range = searchForm?.createTimeRange ?? searchForm?.applyDateRange;
  if (Array.isArray(range) && range[0]) {
    dto.createTimeStart = range[0];
  }
  if (Array.isArray(range) && range[1]) {
    dto.createTimeEnd = range[1];
  }
  if (reimbursementType === FIN_REIMBURSEMENT_TYPE.TRAVEL) {
    if (searchForm?.travelStartFrom) {
      dto.startTimeStart = searchForm.travelStartFrom;
    }
    if (searchForm?.travelEndTo) {
      dto.endTimeEnd = searchForm.travelEndTo;
    }
  }
  return {
    page: {
      current: page.current,
      size: page.size,
    },
    finReimbursementDto: dto,
  };
}
function pickTravelField(obj, keys) {
  if (!obj || typeof obj !== "object") return "";
  for (const key of keys) {
    const v = obj[key];
    if (v != null && v !== "") return v;
  }
  return "";
}
/** å…¼å®¹ list/detail å¤šç§å·®æ—…子对象结构 */
export function pickTravelFromRow(row) {
  if (!row || typeof row !== "object") return {};
  const nested =
    (row.travel && typeof row.travel === "object" ? row.travel : null) ||
    row.finReimbursementTravel ||
    row.finReimbursementTravelDto ||
    row.travelDto ||
    row.travelVO ||
    {};
  const src =
    nested && typeof nested === "object" && Object.keys(nested).length
      ? nested
      : row;
  return {
    startTime: pickTravelField(src, [
      "startTime",
      "travelStartTime",
      "startDate",
      "travelStartDate",
      "departureTime",
    ]),
    endTime: pickTravelField(src, [
      "endTime",
      "travelEndTime",
      "endDate",
      "travelEndDate",
      "returnTime",
    ]),
    travelDays: src.travelDays,
    departureCity: pickTravelField(src, [
      "departureCity",
      "departurePlace",
      "departure",
    ]),
    destinationCity: pickTravelField(src, [
      "destinationCity",
      "destination",
      "destinationPlace",
    ]),
    hotelStandard: src.hotelStandard,
    lodgingDays: src.lodgingDays ?? src.hotelDays,
    mealAllowance: src.mealAllowance ?? src.livingSubsidy,
    transportAllowance: src.transportAllowance ?? src.transportSubsidy,
    lodgingLimit: src.lodgingLimit,
    withinStandard: src.withinStandard,
    standardTag: src.standardTag || "",
    id: src.id,
    reimbursementId: src.reimbursementId,
  };
}
export function formatReimbursementDateTime(val) {
  if (val == null || val === "") return "";
  const d = dayjs(val);
  if (!d.isValid()) return String(val);
  const raw = String(val);
  const hasTime = raw.includes("T") || /:\d{2}/.test(raw);
  return hasTime ? d.format("YYYY-MM-DD HH:mm:ss") : d.format("YYYY-MM-DD");
}
export function mapFinReimbursementFromApi(row, { reimbursementType, moduleKey } = {}) {
  if (!row) return {};
  const type = resolveReimbursementType(
    row,
    reimbursementType || getReimbursementTypeByModuleKey(moduleKey)
  );
  const isTravel = type === FIN_REIMBURSEMENT_TYPE.TRAVEL;
  const travel = isTravel ? pickTravelFromRow(row) : {};
  const instanceId = row.approvalInstanceId ?? row.id;
  return {
    ...row,
    reimbursementId: row.id,
    id: instanceId,
    approvalInstanceId: row.approvalInstanceId,
    instanceNo: row.billNo || "",
    billNo: row.billNo || "",
    reimbursementType: type,
    reimbursementTypeLabel: reimbursementTypeLabel(type),
    moduleKey: getModuleKeyByReimbursementType(type),
    applicantNo: row.applicantCode || "",
    applicantCode: row.applicantCode || "",
    applicantName: row.applicantName || "",
    reason: row.reason || "",
    expenseType: row.expenseType || "",
    applyAmount: row.applyAmount,
    billStatus: row.billStatus,
    status: row.billStatus,
    approvalStatus: mapBillStatusToApprovalKey(row.billStatus),
    title: row.reason || row.billNo || "",
    summary: row.reason || row.billNo || "",
    createTime: formatReimbursementDateTime(row.createTime),
    departurePlace: travel.departureCity || "",
    destination: travel.destinationCity || "",
    travelStartTime: formatReimbursementDateTime(travel.startTime),
    travelEndTime: formatReimbursementDateTime(travel.endTime),
    travel,
    details: row.details || [],
    nodes: row.nodes || [],
    flowNodes: row.nodes || [],
    displayRows: buildFinReimbursementDisplayRows(
      {
        billNo: row.billNo,
        applyAmount: row.applyAmount,
        billStatus: row.billStatus,
        departurePlace: travel.departureCity,
        destination: travel.destinationCity,
        expenseType: row.expenseType,
        reason: row.reason,
      },
      type
    ),
  };
}
export function buildFinReimbursementDisplayRows(item, reimbursementType) {
  const type = normalizeReimbursementType(reimbursementType);
  const isTravel = type === FIN_REIMBURSEMENT_TYPE.TRAVEL;
  const rows = [
    { label: "报销单号", value: item.billNo },
    {
      label: "申请金额",
      value: item.applyAmount != null ? `${item.applyAmount} å…ƒ` : "",
    },
    { label: "单据状态", value: billStatusLabel(item.billStatus) },
  ];
  if (isTravel) {
    rows.splice(
      1,
      0,
      { label: "出差地", value: item.departurePlace },
      { label: "目的地", value: item.destination }
    );
  } else {
    rows.splice(1, 0, { label: "费用类型", value: item.expenseType });
  }
  if (item.reason) {
    rows.push({ label: "报销原因", value: item.reason });
  }
  return rows;
}
/** ä¿®æ”¹åœºæ™¯å¿…须带主键 ID(与 Web ä¸€è‡´ï¼‰ */
export function validateReimbursementPersistDto(dto, isEdit) {
  if (!isEdit) return { ok: true };
  if (dto?.id != null && dto.id !== "") return { ok: true };
  return { ok: false, message: "无法修改:缺少报销单 ID" };
}
export { deleteFinReimbursement, getFinReimbursementDetail, persistFinReimbursement };
/** åˆ—表行主键(删除/修改用 fin_reimbursement.id,勿用 item.id å®¡æ‰¹å®žä¾‹ ID) */
export function resolveReimbursementDeleteId(row) {
  const raw = row?.reimbursementId;
  if (raw == null || raw === "" || String(raw).startsWith("local_")) {
    return undefined;
  }
  const n = Number(raw);
  return Number.isNaN(n) ? raw : n;
}
/** æ˜¯å¦å…è®¸åˆ é™¤ï¼ˆå®¡æ‰¹ä¸­ã€å·²é€šè¿‡ã€å·²ä»˜æ¬¾ä¸å¯åˆ ï¼‰ */
export function canDeleteReimbursementRow(row) {
  const upper = String(row?.billStatus ?? row?.status ?? "").trim().toUpperCase();
  if (upper === "PAID") return false;
  const key = mapBillStatusToApprovalKey(
    row?.billStatus ?? row?.approvalStatus ?? row?.status
  );
  return key !== "pending" && key !== "approved";
}
export function canEditReimbursementRow(row) {
  return canDeleteReimbursementRow(row);
}
/** æ‹‰å–报销详情(含明细、审批节点,与 Web mapFinReimbursementDetailRow ä¸€è‡´ï¼‰ */
export async function fetchFinReimbursementListItemDetail(item, reimbursementTypeOrModuleKey) {
  const id = resolveReimbursementDeleteId(item);
  if (id == null) {
    throw new Error("missing reimbursement id");
  }
  const res = await getFinReimbursementDetail(id);
  const raw = unwrapFinReimbursementDetail(res);
  const type = resolveReimbursementType(raw, reimbursementTypeOrModuleKey);
  const row = mapFinReimbursementDetailRow(raw, type);
  return {
    ...row,
    reimbursementType: type,
    reimbursementTypeLabel: reimbursementTypeLabel(type),
    moduleKey: getModuleKeyByReimbursementType(type),
    displayRows: buildFinReimbursementDisplayRows(
      {
        billNo: row.billNo || row.reimburseNo,
        applyAmount: row.applyAmount,
        billStatus: row.billStatus,
        departurePlace: row.departurePlace,
        destination: row.destination,
        expenseType: row.expenseCategory || row.expenseType,
        reason: row.reimburseReason || row.reason,
      },
      type
    ),
  };
}
function toNumber(val) {
  if (val == null || val === "") return undefined;
  const n = Number(val);
  return Number.isNaN(n) ? undefined : n;
}
function mapSignModeToApi(signMode) {
  return signMode === "or_sign" ? "OR" : "AND";
}
function expenseSubjectToCategory(subject) {
  const hit =
    TRAVEL_EXPENSE_SUBJECTS.find(x => x.value === subject) ||
    COST_EXPENSE_SUBJECTS.find(x => x.value === subject);
  return hit?.label || subject || "";
}
function mapDetailRowFromApi(d, reimbursementType) {
  const type = normalizeReimbursementType(reimbursementType);
  const raw = d.expenseCategory ?? d.expenseSubject ?? "";
  const opts =
    type === FIN_REIMBURSEMENT_TYPE.TRAVEL
      ? TRAVEL_EXPENSE_SUBJECTS
      : COST_EXPENSE_SUBJECTS;
  const label = resolveExpenseSubjectLabel(raw, {
    isTravel: type === FIN_REIMBURSEMENT_TYPE.TRAVEL,
    subjectOptions: opts,
  });
  const hit = opts.find(x => x.value === raw || x.label === raw || x.label === label);
  return {
    ...d,
    expenseSubject: hit?.value || raw,
  };
}
function expenseCategoryToType(category) {
  const hit = EXPENSE_CATEGORY_OPTIONS.find(x => x.value === category);
  return hit?.label || category || "";
}
/** æŽ¥å£ nodes â†’ é¡µé¢å®¡æ‰¹æµ */
export function mapNodesToFormFlow(nodes = []) {
  return (Array.isArray(nodes) ? nodes : []).map((n, i) => {
    const first = Array.isArray(n.approvers) ? n.approvers[0] : null;
    return {
      ...n,
      nodeOrder: n.levelNo ?? n.nodeOrder ?? i + 1,
      signMode: String(n.approveType || "").toUpperCase() === "OR" ? "or_sign" : "countersign",
      approverId: first?.approverId ?? n.approverId ?? "",
      approverName: first?.approverName ?? n.approverName ?? "",
    };
  });
}
/** é¡µé¢å®¡æ‰¹èŠ‚ç‚¹ â†’ æŽ¥å£ nodes */
export function mapApprovalFlowNodesToApi(nodes = [], templateId) {
  const list = Array.isArray(nodes) ? nodes : [];
  return list
    .map((n, i) => {
      let approvers = [];
      if (Array.isArray(n.approvers) && n.approvers.length) {
        approvers = n.approvers
          .filter(a => a?.approverId != null && a.approverId !== "")
          .map((a, idx) => ({
            id: a.id,
            nodeId: a.nodeId,
            templateId: a.templateId ?? templateId,
            approverId: toNumber(a.approverId) ?? a.approverId,
            approverName: a.approverName || "",
            sortNo: a.sortNo ?? idx + 1,
          }));
      } else if (n.approverId != null && n.approverId !== "") {
        approvers = [
          {
            approverId: toNumber(n.approverId) ?? n.approverId,
            approverName: n.approverName || "",
            sortNo: 1,
          },
        ];
      }
      if (!approvers.length) return null;
      const node = {
        levelNo: n.levelNo ?? n.nodeOrder ?? i + 1,
        approveType: n.approveType || mapSignModeToApi(n.signMode),
        approvers,
      };
      if (n.id != null) node.id = n.id;
      if (n.templateId != null) node.templateId = n.templateId;
      else if (templateId != null) node.templateId = templateId;
      return node;
    })
    .filter(Boolean);
}
function mapDetailsToApi(details = []) {
  return (details || []).map((d, i) => {
    const item = {
      rowNo: d.rowNo ?? i + 1,
      invoiceDate: d.invoiceDate || undefined,
      expenseCategory: expenseSubjectToCategory(d.expenseSubject ?? d.expenseCategory),
      amount: toNumber(d.amount),
      description: d.description || "",
      invoiceNo: d.invoiceNo || undefined,
      invoiceType: d.invoiceType || undefined,
      invoiceAmount: toNumber(d.invoiceAmount),
      taxRate: toNumber(d.taxRate),
      taxAmount: toNumber(d.taxAmount),
      remark: d.remark || undefined,
    };
    if (d.id != null && !String(d.id).startsWith("ed_")) {
      item.id = toNumber(d.id) ?? d.id;
    }
    if (d.reimbursementId != null) item.reimbursementId = toNumber(d.reimbursementId);
    return item;
  });
}
function sumDetailAmount(details = []) {
  const sum = (details || []).reduce((s, d) => s + (Number(d.amount) || 0), 0);
  return Math.round(sum * 100) / 100;
}
function applyReimbursementRelations(dto) {
  const rid = dto?.id;
  if (rid == null) return dto;
  if (dto.travel && typeof dto.travel === "object") {
    dto.travel.reimbursementId = rid;
  }
  if (Array.isArray(dto.details)) {
    dto.details.forEach(d => {
      d.reimbursementId = rid;
    });
  }
  return dto;
}
function resolveReimbursementId(form) {
  const rawId = form?.reimbursementId ?? form?.id;
  if (rawId == null || rawId === "" || String(rawId).startsWith("local_")) {
    return undefined;
  }
  return toNumber(rawId) ?? rawId;
}
/** æŽ¥å£è¡Œ â†’ å·®æ—…报销表单行 */
export function mapTravelReimbursementRow(row) {
  if (!row) return {};
  const travel = pickTravelFromRow(row);
  const details = Array.isArray(row.details) ? row.details : [];
  return {
    ...row,
    id: row.id,
    reimbursementId: row.id,
    approvalInstanceId: row.approvalInstanceId,
    reimburseNo: row.billNo || "",
    applicantId: row.applicantId,
    applicantNo: row.applicantCode || "",
    applicantName: row.applicantName || "",
    employeeNo: row.applicantCode || "",
    employeeName: row.applicantName || "",
    applicantDeptName: row.applicantDeptName || "",
    reimburseReason: row.reason || "",
    travelStartTime: formatReimbursementDateTime(travel.startTime),
    travelEndTime: formatReimbursementDateTime(travel.endTime),
    travelDays: travel.travelDays,
    departurePlace: travel.departureCity || "",
    destination: travel.destinationCity || "",
    hotelStandard: travel.hotelStandard,
    hotelDays: travel.lodgingDays,
    livingSubsidy: travel.mealAllowance,
    transportSubsidy: travel.transportAllowance,
    lodgingLimit: travel.lodgingLimit,
    needSpecialApproval: travel.withinStandard === "0" || travel.withinStandard === 0,
    standardTag: travel.standardTag || "",
    applyAmount: row.applyAmount,
    payee: row.payeeName || "",
    payeeAccount: row.payeeAccount || "",
    payeeBank: row.payeeBank || "",
    billStatus: row.billStatus,
    expenseDetails: details.map(d =>
      mapDetailRowFromApi(d, FIN_REIMBURSEMENT_TYPE.TRAVEL)
    ),
    travel:
      row.travel && typeof row.travel === "object" && Object.keys(row.travel).length
        ? row.travel
        : travel,
    details,
    nodes: row.nodes || [],
    approvalFlowNodes: mapNodesToFormFlow(row.nodes),
    tasks: row.tasks || [],
    attachmentList: row.attachmentList || row.invoiceAttachments || [],
  };
}
/** æŽ¥å£è¡Œ â†’ è´¹ç”¨æŠ¥é”€è¡¨å•行 */
export function mapCostReimbursementRow(row) {
  if (!row) return {};
  const details = Array.isArray(row.details) ? row.details : [];
  return {
    ...row,
    id: row.id,
    reimbursementId: row.id,
    approvalInstanceId: row.approvalInstanceId,
    reimburseNo: row.billNo || "",
    applicantId: row.applicantId,
    applicantNo: row.applicantCode || "",
    applicantName: row.applicantName || "",
    employeeNo: row.applicantCode || "",
    employeeName: row.applicantName || "",
    applicantDeptName: row.applicantDeptName || "",
    reimburseReason: row.reason || "",
    expenseCategory: expenseTypeToCategory(row.expenseType),
    applyAmount: row.applyAmount,
    applyTime: formatReimbursementDateTime(row.createTime),
    createTime: formatReimbursementDateTime(row.createTime),
    payee: row.payeeName || "",
    payeeAccount: row.payeeAccount || "",
    bankBranch: row.payeeBank || "",
    payeeBank: row.payeeBank || "",
    billStatus: row.billStatus,
    expenseDetails: details.map(d =>
      mapDetailRowFromApi(d, FIN_REIMBURSEMENT_TYPE.COST)
    ),
    details,
    nodes: row.nodes || [],
    approvalFlowNodes: mapNodesToFormFlow(row.nodes),
    tasks: row.tasks || [],
    attachmentList: row.attachmentList || row.invoiceAttachments || [],
  };
}
export function mapFinReimbursementDetailRow(raw, reimbursementTypeOrModuleKey) {
  const type = resolveReimbursementType(raw, reimbursementTypeOrModuleKey);
  let mapped = {};
  if (type === FIN_REIMBURSEMENT_TYPE.TRAVEL) {
    mapped = mapTravelReimbursementRow(raw);
  } else if (type === FIN_REIMBURSEMENT_TYPE.COST) {
    mapped = mapCostReimbursementRow(raw);
  } else {
    mapped = raw || {};
  }
  return {
    ...applyFinReimbursementDetailEnrichment(mapped, raw),
    reimbursementType: type,
    reimbursementTypeLabel: reimbursementTypeLabel(type),
    moduleKey: getModuleKeyByReimbursementType(type),
  };
}
/** å·®æ—…表单 â†’ FinReimbursementDto */
export function buildTravelReimbursementSaveDto(form, { computeTravelDays } = {}) {
  const details = mapDetailsToApi(form.expenseDetails);
  const detailTotal = sumDetailAmount(form.expenseDetails);
  const applyAmount = toNumber(form.applyAmount) ?? detailTotal;
  const travelDays =
    form.travelDays != null
      ? toNumber(form.travelDays)
      : computeTravelDays?.(form.travelStartTime, form.travelEndTime);
  const dto = {
    reimbursementType: FIN_REIMBURSEMENT_TYPE.TRAVEL,
    expenseType: "差旅费",
    applicantId: toNumber(form.applicantId),
    applicantCode: form.employeeNo || form.applicantNo || "",
    applicantName: form.employeeName || form.applicantName || "",
    applicantDeptId: toNumber(form.applicantDeptId),
    applicantDeptName: form.applicantDeptName || form.deptName || "",
    reason: (form.reimburseReason || "").trim(),
    applyAmount,
    detailTotalAmount: detailTotal,
    payeeName: form.payee || "",
    payeeAccount: form.payeeAccount || undefined,
    payeeBank: form.payeeBank || undefined,
    billStatus: "IN_APPROVAL",
    deptId: toNumber(form.deptId),
    travel: {
      startTime: form.travelStartTime || undefined,
      endTime: form.travelEndTime || undefined,
      travelDays,
      departureCity: form.departurePlace || "",
      destinationCity: form.destination || "",
      hotelStandard: toNumber(form.hotelStandard),
      lodgingDays: toNumber(form.hotelDays),
      mealAllowance: toNumber(form.livingSubsidy),
      transportAllowance: toNumber(form.transportSubsidy),
      lodgingLimit: toNumber(form.lodgingLimit),
      standardTag: form.standardTag || (form.needSpecialApproval ? "超标特批" : "在标准范围内"),
      withinStandard: form.needSpecialApproval ? "0" : "1",
    },
    details,
    nodes: mapApprovalFlowNodesToApi(form.approvalFlowNodes, form.templateId),
  };
  const id = resolveReimbursementId(form);
  if (id != null) dto.id = id;
  if (form.billNo || form.reimburseNo) dto.billNo = form.billNo || form.reimburseNo;
  if (form.approvalInstanceId != null) dto.approvalInstanceId = toNumber(form.approvalInstanceId);
  if (form.approveProcessId != null) dto.approveProcessId = toNumber(form.approveProcessId);
  if (form.travel?.id != null) dto.travel.id = toNumber(form.travel.id);
  return applyReimbursementRelations(dto);
}
/** è´¹ç”¨è¡¨å• â†’ FinReimbursementDto */
export function buildCostReimbursementSaveDto(form) {
  const details = mapDetailsToApi(form.expenseDetails);
  const detailTotal = sumDetailAmount(form.expenseDetails);
  const applyAmount = toNumber(form.applyAmount) ?? detailTotal;
  const dto = {
    reimbursementType: FIN_REIMBURSEMENT_TYPE.COST,
    expenseType: expenseCategoryToType(form.expenseCategory),
    applicantId: toNumber(form.applicantId),
    applicantCode: form.employeeNo || form.applicantNo || "",
    applicantName: form.employeeName || form.applicantName || "",
    applicantDeptId: toNumber(form.applicantDeptId),
    applicantDeptName: form.applicantDeptName || form.deptName || "",
    reason: (form.reimburseReason || "").trim(),
    applyAmount,
    detailTotalAmount: detailTotal,
    payeeName: form.payee || "",
    payeeAccount: form.payeeAccount || "",
    payeeBank: form.bankBranch || form.payeeBank || "",
    billStatus: "IN_APPROVAL",
    deptId: toNumber(form.deptId),
    details,
    nodes: mapApprovalFlowNodesToApi(form.approvalFlowNodes, form.templateId),
  };
  const id = resolveReimbursementId(form);
  if (id != null) dto.id = id;
  if (form.billNo || form.reimburseNo) dto.billNo = form.billNo || form.reimburseNo;
  if (form.approvalInstanceId != null) dto.approvalInstanceId = toNumber(form.approvalInstanceId);
  if (form.approveProcessId != null) dto.approveProcessId = toNumber(form.approveProcessId);
  return applyReimbursementRelations(dto);
}
/** å¡«æŠ¥é¡µåŠ è½½è¯¦æƒ…ï¼ˆä¸Ž Web openFormDialog edit ä¸€è‡´ï¼‰ */
export async function fetchFinReimbursementFormDetail(item, moduleKey) {
  const id = resolveReimbursementDeleteId(item);
  if (id == null) throw new Error("missing reimbursement id");
  const res = await getFinReimbursementDetail(id);
  const raw = unwrapFinReimbursementDetail(res);
  return mapFinReimbursementDetailRow(raw, moduleKey);
}
src/pages/oa/_utils/reimburseApproveBridge.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,99 @@
import { matchBusinessTypeValue } from "./approvalTemplateType.js";
import {
  APPROVAL_MODULE_KEYS,
  getApprovalModuleConfig,
} from "./approvalModuleRegistry.js";
import { fetchFinReimbursementListItemDetail } from "./finReimbursementMappers.js";
export const REIMBURSE_EDIT_FROM_APPROVE_KEY = "oa_reimburse_edit_from_approve";
export const FIN_REIMBURSE_FORM_ACTION_KEY = "oa_fin_reimburse_form_action";
const REIMBURSE_MODULE_KEYS = [
  APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE,
  APPROVAL_MODULE_KEYS.COST_REIMBURSE,
];
export function inferReimburseModuleKeyFromInstance(row) {
  if (!row) return "";
  for (const moduleKey of REIMBURSE_MODULE_KEYS) {
    const cfg = getApprovalModuleConfig(moduleKey);
    if (!cfg) continue;
    if (
      cfg.businessType != null &&
      cfg.businessType !== "" &&
      matchBusinessTypeValue(row.businessType, cfg.businessType)
    ) {
      return moduleKey;
    }
    if (matchBusinessTypeValue(row.businessType, cfg.approvalType)) {
      return moduleKey;
    }
    const text = `${row.templateName || ""}${row.title || ""}${row.businessName || ""}`;
    if ((cfg.typeLabels || []).some(l => l && text.includes(l))) {
      return moduleKey;
    }
  }
  return "";
}
export function isReimburseApprovalInstance(row) {
  return Boolean(inferReimburseModuleKeyFromInstance(row));
}
export function resolveFinReimbursementIdFromInstance(row) {
  const raw = row?.businessId ?? row?.formPayload?.reimbursementId;
  if (raw == null || raw === "") return undefined;
  const n = Number(raw);
  return Number.isNaN(n) ? raw : n;
}
export async function loadReimburseDetailForInstance(instanceRow, moduleKey) {
  const mk = moduleKey || inferReimburseModuleKeyFromInstance(instanceRow);
  const id = resolveFinReimbursementIdFromInstance(instanceRow);
  if (id == null) {
    throw new Error("missing reimbursement id");
  }
  const reimburseRow = await fetchFinReimbursementListItemDetail(
    { reimbursementId: id },
    mk
  );
  return {
    reimburseRow,
    instanceRow,
    moduleKey: reimburseRow.moduleKey || mk,
    reimbursementType: reimburseRow.reimbursementType,
  };
}
export function stashReimburseEditFromApprove(moduleKey, reimbursementId) {
  uni.setStorageSync(
    REIMBURSE_EDIT_FROM_APPROVE_KEY,
    JSON.stringify({ moduleKey, reimbursementId })
  );
}
export function consumeReimburseEditFromApprove() {
  const raw = uni.getStorageSync(REIMBURSE_EDIT_FROM_APPROVE_KEY);
  if (!raw) return null;
  uni.removeStorageSync(REIMBURSE_EDIT_FROM_APPROVE_KEY);
  try {
    return typeof raw === "string" ? JSON.parse(raw) : raw;
  } catch {
    return null;
  }
}
export function stashFinReimburseFormAction(payload) {
  uni.setStorageSync(FIN_REIMBURSE_FORM_ACTION_KEY, JSON.stringify(payload));
}
export function consumeFinReimburseFormAction() {
  const raw = uni.getStorageSync(FIN_REIMBURSE_FORM_ACTION_KEY);
  if (!raw) return null;
  uni.removeStorageSync(FIN_REIMBURSE_FORM_ACTION_KEY);
  try {
    return typeof raw === "string" ? JSON.parse(raw) : raw;
  } catch {
    return null;
  }
}
src/pages/oa/_utils/userPickerUtils.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,53 @@
/** ç”¨æˆ·åˆ—表解包 */
export function unwrapUserList(res) {
  if (Array.isArray(res)) return res;
  if (Array.isArray(res?.data)) return res.data;
  if (Array.isArray(res?.rows)) return res.rows;
  return [];
}
export function isActiveUser(u) {
  if (u?.delFlag === "2" || u?.delFlag === 2) return false;
  if (u?.status == null) return true;
  return String(u.status) === "0";
}
export 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 ?? ""}`;
}
export function userSubLabel(u) {
  const parts = [];
  const code = u?.userName || u?.userCode || "";
  if (code) parts.push(`工号 ${code}`);
  const dept = u?.dept?.deptName ?? u?.deptName ?? "";
  if (dept) parts.push(dept);
  return parts.join(" Â· ") || "";
}
const AVATAR_COLORS = ["#409EFF", "#67C23A", "#E6A23C", "#9B59B6", "#1ABC9C", "#F56C6C"];
export function userAvatarColor(name) {
  if (!name) return "#c0c4cc";
  let h = 0;
  for (let i = 0; i < name.length; i++) h = name.charCodeAt(i) + ((h << 5) - h);
  return AVATAR_COLORS[Math.abs(h) % AVATAR_COLORS.length];
}
/** æŒ‰å§“名/工号/ID æœç´¢ï¼Œç©ºå…³é”®å­—时优先展示前 limit æ¡ */
export function filterActiveUsers(list, keyword, limit = 80) {
  const active = (list || []).filter(isActiveUser);
  const q = (keyword || "").trim().toLowerCase();
  if (!q) return active.slice(0, limit);
  return active
    .filter(u => {
      const nick = (u.nickName || "").toLowerCase();
      const name = (u.userName || "").toLowerCase();
      const id = String(u.userId ?? u.id ?? "");
      return nick.includes(q) || name.includes(q) || id.includes(q);
    })
    .slice(0, limit);
}