yyb
10 天以前 eb322fd6b88273f1dada1f850f4473d5f054dd66
差旅报销费用报销
已添加18个文件
已修改8个文件
5050 ■■■■■ 文件已修改
src/api/oa/finReimbursement.js 71 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/config/oaPaths.js 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages.json 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ApproveManage/approve-list/approve.vue 104 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ApproveManage/approve-list/detail.vue 92 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ApproveManage/approve-list/index.vue 20 ●●●●● 补丁 | 查看 | 原始文档 | 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/FinReimbursementListPage.vue 346 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/_components/OaUserSearchPicker.vue 261 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/_styles/oa-approval-list.scss 5 ●●●●● 补丁 | 查看 | 原始文档 | 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/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/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`,
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": "采购合同",
src/pages/oa/ApproveManage/approve-list/approve.vue
@@ -1,17 +1,22 @@
<!--
  OA / å®¡æ‰¹ç®¡ç† / å®¡æ‰¹å¤„理
  è·¯ç”±ï¼š/pages/oa/ApproveManage/approve-list/approve
  å·®æ—…/费用报销使用报销详情 + å®¡æ‰¹åˆ—表 approve æŽ¥å£
-->
<template>
  <view class="oa-detail-page">
    <PageHeader title="审批处理"
    <PageHeader :title="pageTitle"
                @back="goBack" />
    <scroll-view v-if="row"
    <scroll-view v-if="displayReady"
                 class="oa-detail-scroll"
                 scroll-y
                 :show-scrollbar="false">
      <ApproveInstanceDetailBody :row="row"
      <ReimburseInstanceDetailBody v-if="isReimburse"
                                 :reimburse-row="reimburseRow"
                                 :module-key="detailModuleKey" />
      <ApproveInstanceDetailBody v-else
                                 :row="row"
                                 :module-key="detailModuleKey" />
      <view class="section-card opinion-card">
@@ -32,10 +37,10 @@
    <view v-else
          class="oa-empty">
      <up-empty mode="data"
                text="未获取到审批数据" />
                :text="loading ? '加载中' : '未获取到审批数据'" />
    </view>
    <view v-if="row"
    <view v-if="displayReady"
          class="oa-page-footer">
      <text class="oa-footer-btn btn-default"
            :class="{ 'is-disabled': submitting }"
@@ -55,6 +60,7 @@
  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,
@@ -62,16 +68,44 @@
    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 instanceId = ref("");
  const row = ref(null);
  const detailModuleKey = computed(() => inferModuleKeyFromRow(row.value));
  const reimburseRow = ref(null);
  const loading = ref(false);
  const approveOpinion = ref("");
  const submitting = ref(false);
  const goBack = () => {
    uni.navigateBack();
  };
  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;
@@ -99,7 +133,7 @@
          const prevRoute = pages[pages.length - 2]?.route || "";
          const delta = prevRoute.includes("approve-list/detail") ? 2 : 1;
          uni.navigateBack({ delta });
        }, 300);
        }, 400);
      })
      .catch(() => {
        uni.showToast({ title: "审批操作失败", icon: "none" });
@@ -109,56 +143,38 @@
      });
  };
  onLoad(options => {
  onLoad(async options => {
    if (!options?.id) {
      uni.showToast({ title: "缺少审批 ID", icon: "none" });
      setTimeout(goBack, 500);
      return;
    }
    instanceId.value = options.id;
    const cached = loadInstanceRow(options.id);
    if (!cached) {
      uni.showToast({ title: "请从列表进入审批", icon: "none" });
      uni.showToast({ title: "请从列表进入", icon: "none" });
      setTimeout(goBack, 500);
      return;
    }
    if (!canApproveInstance(cached)) {
      uni.showToast({ title: "当前审批不可处理", icon: "none" });
      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";
  $primary: #2979ff;
  .opinion-card {
    margin-top: 10px;
    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: #1f2d3d;
    padding-left: 10px;
    border-left: 3px solid $primary;
    line-height: 1.2;
  }
  .opinion-wrap {
    padding: 12px 16px 16px;
  }
</style>
src/pages/oa/ApproveManage/approve-list/detail.vue
@@ -4,24 +4,28 @@
-->
<template>
  <view class="oa-detail-page">
    <PageHeader title="审批详情"
    <PageHeader :title="pageTitle"
                @back="goBack" />
    <scroll-view v-if="row"
    <scroll-view v-if="displayReady"
                 class="oa-detail-scroll"
                 scroll-y
                 :show-scrollbar="false">
      <ApproveInstanceDetailBody :row="row"
      <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="未获取到审批数据" />
                :text="loading ? '加载中' : '未获取到审批数据'" />
    </view>
    <view v-if="row"
    <view v-if="displayReady"
          class="oa-page-footer">
      <text class="oa-footer-btn btn-default"
            @click="goBack">返回</text>
@@ -40,44 +44,88 @@
  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,
    EDIT_STORAGE_KEY,
    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 instanceId = ref("");
  const fromBusiness = ref(false);
  const row = ref(null);
  const reimburseRow = ref(null);
  const loading = ref(false);
  const detailModuleKey = computed(() => inferModuleKeyFromRow(row.value));
  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 goBack = () => uni.navigateBack();
  const goEdit = () => {
    if (!showEdit.value || !row.value?.id) return;
    if (fromBusiness.value && !canEditBusinessInstanceRow(row.value)) {
      uni.showToast({ title: "进行中或已完成的审批不可修改", icon: "none" });
    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;
    }
    uni.setStorageSync(EDIT_STORAGE_KEY, row.value);
    if (!row.value?.id) return;
    const mk = detailModuleKey.value;
    const q = mk ? `&moduleKey=${mk}` : "";
    uni.navigateTo({
@@ -93,14 +141,13 @@
    });
  };
  onLoad(options => {
  onLoad(async options => {
    fromBusiness.value = options?.from === "business";
    if (!options?.id) {
      uni.showToast({ title: "缺少审批 ID", icon: "none" });
      setTimeout(goBack, 500);
      return;
    }
    instanceId.value = options.id;
    const cached = loadInstanceRow(options.id);
    if (!cached) {
      uni.showToast({ title: "请从列表进入详情", icon: "none" });
@@ -108,6 +155,17 @@
      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>
src/pages/oa/ApproveManage/approve-list/index.vue
@@ -115,9 +115,13 @@
    businessStatusClass,
    businessStatusText,
    canModifyInstance,
    EDIT_STORAGE_KEY,
    stashInstanceRow,
  } from "../../_utils/approveListUtils.js";
  import {
    inferReimburseModuleKeyFromInstance,
    resolveFinReimbursementIdFromInstance,
    stashReimburseEditFromApprove,
  } from "../../_utils/reimburseApproveBridge.js";
  const userStore = useUserStore();
  const queryParams = reactive({ keyword: "" });
@@ -222,8 +226,20 @@
      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;
    uni.setStorageSync(EDIT_STORAGE_KEY, item);
    stashInstanceRow(item);
    uni.navigateTo({ url: `${OA_NAV.approveListApply}?id=${item.id}` });
  };
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/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
@@ -205,6 +205,11 @@
  border-radius: 16px;
  text-align: center;
  &.btn-detail {
    color: #fff;
    background: #2979ff;
  }
  &.btn-edit {
    color: #2979ff;
    background: #ecf3ff;
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);
}