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