From eb322fd6b88273f1dada1f850f4473d5f054dd66 Mon Sep 17 00:00:00 2001
From: yyb <995253665@qq.com>
Date: 星期四, 21 五月 2026 17:48:19 +0800
Subject: [PATCH] 差旅报销费用报销
---
src/config/oaPaths.js | 2
src/pages.json | 14
src/pages/oa/_components/OaUserSearchPicker.vue | 261 +++
src/pages/oa/ApproveManage/approve-list/index.vue | 20
src/pages/oa/ReimburseManage/_components/ReimburseApprovalFlowEditor.vue | 245 ++
src/pages/oa/ReimburseManage/reimburse-form/useFinReimburseForm.js | 434 +++++
src/pages/oa/ReimburseManage/reimburse-detail/index.vue | 120 +
src/pages/oa/ReimburseManage/reimburse-form/reimburse-form.scss | 354 ++++
src/pages/oa/_utils/userPickerUtils.js | 53
src/pages/oa/_utils/finReimbursementMappers.js | 763 ++++++++
src/pages/oa/ReimburseManage/_components/ReimburseExpenseDetailSheet.vue | 315 +++
src/pages/oa/ApproveManage/approve-list/detail.vue | 92
src/pages/oa/ReimburseManage/travel-reimburse/index.vue | 15
src/pages/oa/ReimburseManage/_utils/finReimbursementDetailExtras.js | 153 +
src/pages/oa/ReimburseManage/reimburse-detail/reimburse-detail.scss | 344 ++++
src/pages/oa/_styles/oa-approval-list.scss | 5
src/pages/oa/ReimburseManage/_utils/travelReimburseUtils.js | 82
src/pages/oa/ReimburseManage/_utils/expenseDetailDisplay.js | 33
src/pages/oa/_components/FinReimbursementListPage.vue | 346 ++++
src/pages/oa/ApproveManage/approve-list/approve.vue | 104
src/pages/oa/ReimburseManage/_components/ReimburseInstanceDetailBody.vue | 426 +++++
src/pages/oa/ReimburseManage/cost-reimburse/index.vue | 15
src/pages/oa/ReimburseManage/reimburse-form/index.vue | 564 ++++++
src/pages/oa/_utils/reimburseApproveBridge.js | 99 +
src/pages/oa/ReimburseManage/_utils/costReimburseUtils.js | 120 +
src/api/oa/finReimbursement.js | 71
26 files changed, 4,965 insertions(+), 85 deletions(-)
diff --git a/src/api/oa/finReimbursement.js b/src/api/oa/finReimbursement.js
new file mode 100644
index 0000000..84c3560
--- /dev/null
+++ b/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锛歋pring 缁戝畾 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锛坆ody 涓� 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);
+}
diff --git a/src/config/oaPaths.js b/src/config/oaPaths.js
index 2124c3f..561db54 100644
--- a/src/config/oaPaths.js
+++ b/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`,
diff --git a/src/pages.json b/src/pages.json
index 607d08d..1aa49ad 100644
--- a/src/pages.json
+++ b/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": "閲囪喘鍚堝悓",
diff --git a/src/pages/oa/ApproveManage/approve-list/approve.vue b/src/pages/oa/ApproveManage/approve-list/approve.vue
index 3ed6220..9201818 100644
--- a/src/pages/oa/ApproveManage/approve-list/approve.vue
+++ b/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>
diff --git a/src/pages/oa/ApproveManage/approve-list/detail.vue b/src/pages/oa/ApproveManage/approve-list/detail.vue
index 61ac9e4..9c38634 100644
--- a/src/pages/oa/ApproveManage/approve-list/detail.vue
+++ b/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>
diff --git a/src/pages/oa/ApproveManage/approve-list/index.vue b/src/pages/oa/ApproveManage/approve-list/index.vue
index 7c1603e..40f9468 100644
--- a/src/pages/oa/ApproveManage/approve-list/index.vue
+++ b/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}` });
};
diff --git a/src/pages/oa/ReimburseManage/_components/ReimburseApprovalFlowEditor.vue b/src/pages/oa/ReimburseManage/_components/ReimburseApprovalFlowEditor.vue
new file mode 100644
index 0000000..77bd712
--- /dev/null
+++ b/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>
diff --git a/src/pages/oa/ReimburseManage/_components/ReimburseExpenseDetailSheet.vue b/src/pages/oa/ReimburseManage/_components/ReimburseExpenseDetailSheet.vue
new file mode 100644
index 0000000..a0b25f0
--- /dev/null
+++ b/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>
diff --git a/src/pages/oa/ReimburseManage/_components/ReimburseInstanceDetailBody.vue b/src/pages/oa/ReimburseManage/_components/ReimburseInstanceDetailBody.vue
new file mode 100644
index 0000000..0b270f1
--- /dev/null
+++ b/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>
+
+ <!-- 瀹℃壒娴佺▼锛坱asks锛� -->
+ <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>
+
+ <!-- 瀹℃壒璁板綍锛坱asks 鐣欑棔锛� -->
+ <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>
diff --git a/src/pages/oa/ReimburseManage/_utils/costReimburseUtils.js b/src/pages/oa/ReimburseManage/_utils/costReimburseUtils.js
new file mode 100644
index 0000000..118b353
--- /dev/null
+++ b/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"),
+ }));
+}
diff --git a/src/pages/oa/ReimburseManage/_utils/expenseDetailDisplay.js b/src/pages/oa/ReimburseManage/_utils/expenseDetailDisplay.js
new file mode 100644
index 0000000..559bcd1
--- /dev/null
+++ b/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 };
+}
diff --git a/src/pages/oa/ReimburseManage/_utils/finReimbursementDetailExtras.js b/src/pages/oa/ReimburseManage/_utils/finReimbursementDetailExtras.js
new file mode 100644
index 0000000..7e893c9
--- /dev/null
+++ b/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,
+ };
+}
diff --git a/src/pages/oa/ReimburseManage/_utils/travelReimburseUtils.js b/src/pages/oa/ReimburseManage/_utils/travelReimburseUtils.js
new file mode 100644
index 0000000..b620079
--- /dev/null
+++ b/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: "",
+ };
+}
diff --git a/src/pages/oa/ReimburseManage/cost-reimburse/index.vue b/src/pages/oa/ReimburseManage/cost-reimburse/index.vue
index 5343c75..dfa4365 100644
--- a/src/pages/oa/ReimburseManage/cost-reimburse/index.vue
+++ b/src/pages/oa/ReimburseManage/cost-reimburse/index.vue
@@ -1,18 +1,11 @@
<!--
- OA / 鎶ラ攢绠$悊 / 璐圭敤鎶ラ攢
- 璺敱锛�/pages/oa/ReimburseManage/cost-reimburse/index
+ OA / 鎶ラ攢绠$悊 / 璐圭敤鎶ラ攢锛�/finReimbursement/listPage锛宺eimbursementType=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>
diff --git a/src/pages/oa/ReimburseManage/reimburse-detail/index.vue b/src/pages/oa/ReimburseManage/reimburse-detail/index.vue
new file mode 100644
index 0000000..a7aabe9
--- /dev/null
+++ b/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>
diff --git a/src/pages/oa/ReimburseManage/reimburse-detail/reimburse-detail.scss b/src/pages/oa/ReimburseManage/reimburse-detail/reimburse-detail.scss
new file mode 100644
index 0000000..660a64b
--- /dev/null
+++ b/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;
+}
diff --git a/src/pages/oa/ReimburseManage/reimburse-form/index.vue b/src/pages/oa/ReimburseManage/reimburse-form/index.vue
new file mode 100644
index 0000000..35eb65d
--- /dev/null
+++ b/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: "涓婁紶瑙f瀽澶辫触", 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>
diff --git a/src/pages/oa/ReimburseManage/reimburse-form/reimburse-form.scss b/src/pages/oa/ReimburseManage/reimburse-form/reimburse-form.scss
new file mode 100644
index 0000000..e50634d
--- /dev/null
+++ b/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;
+}
diff --git a/src/pages/oa/ReimburseManage/reimburse-form/useFinReimburseForm.js b/src/pages/oa/ReimburseManage/reimburse-form/useFinReimburseForm.js
new file mode 100644
index 0000000..c74f7c6
--- /dev/null
+++ b/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,
+ };
+}
diff --git a/src/pages/oa/ReimburseManage/travel-reimburse/index.vue b/src/pages/oa/ReimburseManage/travel-reimburse/index.vue
index df4dac1..a892511 100644
--- a/src/pages/oa/ReimburseManage/travel-reimburse/index.vue
+++ b/src/pages/oa/ReimburseManage/travel-reimburse/index.vue
@@ -1,18 +1,11 @@
<!--
- OA / 鎶ラ攢绠$悊 / 宸梾鎶ラ攢
- 璺敱锛�/pages/oa/ReimburseManage/travel-reimburse/index
+ OA / 鎶ラ攢绠$悊 / 宸梾鎶ラ攢锛�/finReimbursement/listPage锛宺eimbursementType=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>
diff --git a/src/pages/oa/_components/FinReimbursementListPage.vue b/src/pages/oa/_components/FinReimbursementListPage.vue
new file mode 100644
index 0000000..d5f4fdc
--- /dev/null
+++ b/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>
diff --git a/src/pages/oa/_components/OaUserSearchPicker.vue b/src/pages/oa/_components/OaUserSearchPicker.vue
new file mode 100644
index 0000000..90a704b
--- /dev/null
+++ b/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>
diff --git a/src/pages/oa/_styles/oa-approval-list.scss b/src/pages/oa/_styles/oa-approval-list.scss
index b130fbc..81a9ad1 100644
--- a/src/pages/oa/_styles/oa-approval-list.scss
+++ b/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;
diff --git a/src/pages/oa/_utils/finReimbursementMappers.js b/src/pages/oa/_utils/finReimbursementMappers.js
new file mode 100644
index 0000000..5e4f9d3
--- /dev/null
+++ b/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 瑙e寘 */
+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);
+}
diff --git a/src/pages/oa/_utils/reimburseApproveBridge.js b/src/pages/oa/_utils/reimburseApproveBridge.js
new file mode 100644
index 0000000..8d5666d
--- /dev/null
+++ b/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;
+ }
+}
diff --git a/src/pages/oa/_utils/userPickerUtils.js b/src/pages/oa/_utils/userPickerUtils.js
new file mode 100644
index 0000000..d812ef1
--- /dev/null
+++ b/src/pages/oa/_utils/userPickerUtils.js
@@ -0,0 +1,53 @@
+/** 鐢ㄦ埛鍒楄〃瑙e寘 */
+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);
+}
--
Gitblit v1.9.3