From b44addf784a1638bac1102799ed415645a373a55 Mon Sep 17 00:00:00 2001
From: yyb <995253665@qq.com>
Date: 星期四, 21 五月 2026 14:29:30 +0800
Subject: [PATCH] 转正申请/调岗申请/工作交接/请假申请/加班申请页面画页面,接口联调和web端保持一致 日报

---
 src/pages/oa/_utils/approvalTemplateType.js                                       |  153 +
 src/pages/oa/AttendManage/overtime-apply/index.vue                                |   12 
 src/config/oaPaths.js                                                             |    2 
 src/pages.json                                                                    |   14 
 src/pages/oa/ApproveManage/approve-list/_components/ApproveInstanceDetailBody.vue |  415 ++++++
 src/pages/oa/ApproveManage/approve-list/index.vue                                 |  295 +--
 src/pages/oa/_utils/approvalModuleListSearch.js                                   |  319 +++++
 src/pages/oa/ApproveManage/approve-list/template-select.vue                       |  146 +
 src/pages/oa/_components/ApprovalInstanceListPage.vue                             |  347 +++++
 src/pages/oa/AttendManage/leave-apply/index.vue                                   |   12 
 src/pages/oa/_styles/oa-approval-list.scss                                        |  409 ++++++
 src/pages/oa/_utils/approvalModuleRegistry.js                                     |  137 ++
 src/pages/oa/_utils/approveListUtils.js                                           |  327 +++++
 src/pages/oa/_utils/approvalModuleApplyExtras.js                                  |  293 ++++
 src/pages/oa/HrManage/work-handover/index.vue                                     |   12 
 src/api/system/post.js                                                            |   10 
 src/pages/oa/HrManage/transfer-apply/index.vue                                    |   12 
 src/config/oaWorkbench.js                                                         |    2 
 src/pages/oa/ApproveManage/approve-list/approve.vue                               |  164 ++
 src/pages/oa/_components/ApprovalModuleSearchPopup.vue                            |  268 ++++
 src/api/oa/approvalInstance.js                                                    |   21 
 src/pages/oa/ApproveManage/approve-list/apply.vue                                 |  250 +++
 src/pages/oa/ApproveManage/approve-list/detail.vue                                |  116 +
 src/pages/oa/HrManage/regular-apply/index.vue                                     |   12 
 24 files changed, 3,441 insertions(+), 307 deletions(-)

diff --git a/src/api/oa/approvalInstance.js b/src/api/oa/approvalInstance.js
index 604f437..fa60c77 100644
--- a/src/api/oa/approvalInstance.js
+++ b/src/api/oa/approvalInstance.js
@@ -29,3 +29,24 @@
     data: { approvalInstanceDto },
   });
 }
+
+/** 瀹℃壒锛堥�氳繃/椹冲洖锛塒OST /approvalInstance/approve */
+export function approveApprovalInstance(approvalInstanceDto) {
+  return request({
+    url: "/approvalInstance/approve",
+    method: "post",
+    data: { approvalInstanceDto },
+  });
+}
+
+/** 鍒犻櫎瀹℃壒瀹炰緥 DELETE /approvalInstance/delete */
+export function deleteApprovalInstance(ids) {
+  const idList = (Array.isArray(ids) ? ids : [ids]).filter(
+    id => id != null && id !== ""
+  );
+  return request({
+    url: "/approvalInstance/delete",
+    method: "delete",
+    data: idList,
+  });
+}
diff --git a/src/api/system/post.js b/src/api/system/post.js
new file mode 100644
index 0000000..c3f70c2
--- /dev/null
+++ b/src/api/system/post.js
@@ -0,0 +1,10 @@
+import request from "@/utils/request";
+
+/** 宀椾綅涓嬫媺 GET /system/post/optionselect */
+export function findPostOptions(query) {
+  return request({
+    url: "/system/post/optionselect",
+    method: "get",
+    params: query,
+  });
+}
diff --git a/src/config/oaPaths.js b/src/config/oaPaths.js
index 73cca36..2124c3f 100644
--- a/src/config/oaPaths.js
+++ b/src/config/oaPaths.js
@@ -26,6 +26,8 @@
   approveList: `/${P}/ApproveManage/approve-list/index`,
   approveListTemplateSelect: `/${P}/ApproveManage/approve-list/template-select`,
   approveListApply: `/${P}/ApproveManage/approve-list/apply`,
+  approveListDetail: `/${P}/ApproveManage/approve-list/detail`,
+  approveListApprove: `/${P}/ApproveManage/approve-list/approve`,
   approveTemplate: `/${P}/ApproveManage/approve-template/index`,
   approveTemplateEdit: `/${P}/ApproveManage/approve-template/edit`,
   approveTemplateDetail: `/${P}/ApproveManage/approve-template/detail`,
diff --git a/src/config/oaWorkbench.js b/src/config/oaWorkbench.js
index c1263d2..e4f6d7a 100644
--- a/src/config/oaWorkbench.js
+++ b/src/config/oaWorkbench.js
@@ -12,7 +12,7 @@
       // { label: "鍛樺伐鍚堝悓", icon: "/static/images/icon/hetongguanli.svg", path: OA_NAV.staffContract },
       { label: "杞鐢宠", icon: "/static/images/icon/hetongguanli.svg", path: OA_NAV.regularApply },
       { label: "璋冨矖鐢宠", icon: "/static/images/icon/renyuanxinzi.svg", path: OA_NAV.transferApply },
-      { label: "绂昏亴鐢宠", icon: "/static/images/icon/qingjiaguanli.svg", path: OA_NAV.resignApply },
+      // { label: "绂昏亴鐢宠", icon: "/static/images/icon/qingjiaguanli.svg", path: OA_NAV.resignApply },
       { label: "宸ヤ綔浜ゆ帴", icon: "/static/images/icon/gongchuguanli.svg", path: OA_NAV.workHandover },
       // { label: "宀椾綅绠$悊", icon: "/static/images/icon/gongxuguanli.svg", path: OA_NAV.postManage },
     ],
diff --git a/src/pages.json b/src/pages.json
index e39fb89..607d08d 100644
--- a/src/pages.json
+++ b/src/pages.json
@@ -1418,6 +1418,20 @@
       }
     },
     {
+      "path": "pages/oa/ApproveManage/approve-list/detail",
+      "style": {
+        "navigationBarTitleText": "瀹℃壒璇︽儏",
+        "navigationStyle": "custom"
+      }
+    },
+    {
+      "path": "pages/oa/ApproveManage/approve-list/approve",
+      "style": {
+        "navigationBarTitleText": "瀹℃壒澶勭悊",
+        "navigationStyle": "custom"
+      }
+    },
+    {
       "path": "pages/oa/ApproveManage/approve-template/index",
       "style": {
         "navigationBarTitleText": "瀹℃壒妯℃澘",
diff --git a/src/pages/oa/ApproveManage/approve-list/_components/ApproveInstanceDetailBody.vue b/src/pages/oa/ApproveManage/approve-list/_components/ApproveInstanceDetailBody.vue
new file mode 100644
index 0000000..da62d09
--- /dev/null
+++ b/src/pages/oa/ApproveManage/approve-list/_components/ApproveInstanceDetailBody.vue
@@ -0,0 +1,415 @@
+<!--
+  瀹℃壒瀹炰緥璇︽儏灞曠ず锛氬熀鏈俊鎭� + 濉姤 + 娴佺▼ + 瀹℃壒璁板綍
+-->
+<template>
+  <view class="detail-body">
+    <view class="section-card">
+      <view class="section-head">
+        <text class="section-title">鍩烘湰淇℃伅</text>
+      </view>
+      <view class="info-rows">
+        <view class="info-row">
+          <text class="info-label">涓氬姟鍗曞彿</text>
+          <text class="info-value">{{ row.instanceNo || row.id || "鈥�" }}</text>
+        </view>
+        <view class="info-row">
+          <text class="info-label">瀹℃壒鐘舵��</text>
+          <u-tag :type="statusTagType(row.status)"
+                 :text="statusLabel(row.status)"
+                 size="mini" />
+        </view>
+        <view class="info-row">
+          <text class="info-label">妯℃澘鍚嶇О</text>
+          <text class="info-value">{{ row.templateName || "鈥�" }}</text>
+        </view>
+        <view class="info-row">
+          <text class="info-label">涓氬姟鍚嶇О</text>
+          <text class="info-value">{{ row.businessName || "鈥�" }}</text>
+        </view>
+        <view class="info-row">
+          <text class="info-label">鐢宠浜�</text>
+          <text class="info-value">{{ row.applicantName || "鈥�" }}</text>
+        </view>
+        <view class="info-row">
+          <text class="info-label">鐢宠鏍囬</text>
+          <text class="info-value">{{ row.title || "鈥�" }}</text>
+        </view>
+        <view v-if="rejectReason"
+              class="info-row">
+          <text class="info-label">椹冲洖鍘熷洜</text>
+          <text class="info-value reject-text">{{ rejectReason }}</text>
+        </view>
+        <view class="info-row">
+          <text class="info-label">鐢宠鏃堕棿</text>
+          <text class="info-value">{{ formatDateTime(row.applyTime || row.createTime) }}</text>
+        </view>
+        <view v-if="row.finishTime"
+              class="info-row">
+          <text class="info-label">瀹屾垚鏃堕棿</text>
+          <text class="info-value">{{ formatDateTime(row.finishTime) }}</text>
+        </view>
+      </view>
+    </view>
+
+    <view class="section-card">
+      <view class="section-head">
+        <text class="section-title">濉姤鍐呭</text>
+      </view>
+      <view v-if="displayFields.length"
+            class="info-rows">
+        <view v-for="field in displayFields"
+              :key="field.key"
+              class="info-row">
+          <text class="info-label">{{ field.label }}</text>
+          <text class="info-value">{{ displayFieldValue(field) }}</text>
+        </view>
+        <view v-for="(extra, idx) in moduleExtraRows"
+              :key="`extra-${idx}`"
+              class="info-row">
+          <text class="info-label">{{ extra.label }}</text>
+          <text class="info-value">{{ extra.value }}</text>
+        </view>
+      </view>
+      <view v-else
+            class="empty-hint">鏆傛棤濉姤鍐呭</view>
+    </view>
+
+    <view class="section-card">
+      <view class="section-head">
+        <text class="section-title">瀹℃壒娴佺▼锛坽{ flowNodes.length }} 椤癸級</text>
+      </view>
+      <view v-if="flowNodes.length"
+            class="flow-wrap">
+        <view v-for="(node, nodeIndex) in flowNodes"
+              :key="nodeIndex"
+              class="flow-node-block">
+          <view class="flow-node-card">
+            <view class="node-header">
+              <view class="node-level-badge">{{ node.levelNo }}</view>
+              <text class="node-level-text">绗瑊{ levelLabel(node.levelNo) }}绾�</text>
+              <u-tag size="mini"
+                     :type="node.approveType === 'OR' ? 'warning' : 'primary'"
+                     :text="node.approveType === 'OR' ? '鎴栫' : '浼氱'"
+                     plain />
+            </view>
+            <view class="approver-list">
+              <view v-for="(a, aIdx) in node.approvers"
+                    :key="aIdx"
+                    class="approver-row">
+                <text class="approver-name">{{ a.approverName }}</text>
+                <u-tag v-if="a.taskStatus"
+                       size="mini"
+                       :type="taskStatusTagType(a.taskStatus)"
+                       :text="taskStatusText(a.taskStatus)"
+                       plain />
+              </view>
+            </view>
+          </view>
+          <view v-if="nodeIndex < flowNodes.length - 1"
+                class="flow-connector-line" />
+        </view>
+      </view>
+      <view v-else
+            class="empty-hint">鏆傛棤娴佺▼鑺傜偣</view>
+    </view>
+
+    <view class="section-card">
+      <view class="section-head">
+        <text class="section-title">瀹℃壒璁板綍</text>
+      </view>
+      <view v-if="approvalRecords.length"
+            class="record-list">
+        <view v-for="(rec, index) in approvalRecords"
+              :key="rec.id ?? index"
+              class="record-item">
+          <view class="record-head">
+            <text class="record-operator">{{ rec.operatorName }}</text>
+            <u-tag size="mini"
+                   :type="rec.result === 'approved' ? 'success' : rec.result === 'rejected' ? 'error' : 'info'"
+                   :text="recordActionLabel(rec.result)"
+                   plain />
+          </view>
+          <text class="record-time">{{ rec.time }}</text>
+          <text class="record-opinion">{{ rec.opinion || "鏃犳剰瑙�" }}</text>
+        </view>
+      </view>
+      <view v-else
+            class="empty-hint">鏆傛棤瀹℃壒璁板綍</view>
+    </view>
+  </view>
+</template>
+
+<script setup>
+  import { computed } from "vue";
+  import { APPROVAL_MODULE_KEYS } from "../../../_utils/approvalModuleRegistry.js";
+  import {
+    computeLeaveDurationDisplay,
+    computeOvertimeHoursDisplay,
+  } from "../../../_utils/approvalModuleApplyExtras.js";
+  import {
+    businessStatusTagType,
+    businessStatusText,
+    displayFieldValue,
+    formatDateTime,
+    getRejectReasonFromRecords,
+    instanceStatusTagType,
+    instanceStatusText,
+    mapApprovalRecords,
+    mapTasksToFlowNodes,
+    recordActionLabel,
+    resolveInstanceDisplayFields,
+    taskStatusTagType,
+    taskStatusText,
+  } from "../../../_utils/approveListUtils.js";
+  import { parseApprovalFormConfig } from "../../../_utils/approvalFormField.js";
+
+  const props = defineProps({
+    row: { type: Object, default: () => ({}) },
+    moduleKey: { type: String, default: "" },
+  });
+
+  const LEVEL_TEXT = ["", "涓�", "浜�", "涓�", "鍥�", "浜�", "鍏�", "涓�", "鍏�", "涔�", "鍗�"];
+
+  const isBusinessModule = computed(() =>
+    [
+      APPROVAL_MODULE_KEYS.LEAVE,
+      APPROVAL_MODULE_KEYS.OVERTIME,
+      APPROVAL_MODULE_KEYS.TRANSFER,
+      APPROVAL_MODULE_KEYS.REGULAR,
+      APPROVAL_MODULE_KEYS.WORK_HANDOVER,
+    ].includes(props.moduleKey)
+  );
+
+  const statusLabel = status =>
+    isBusinessModule.value ? businessStatusText(status) : instanceStatusText(status);
+
+  const statusTagType = status =>
+    isBusinessModule.value ? businessStatusTagType(status) : instanceStatusTagType(status);
+
+  const displayFields = computed(() =>
+    resolveInstanceDisplayFields(props.row?.formConfig)
+  );
+
+  const moduleExtraRows = computed(() => {
+    const rows = [];
+    const cfg = parseApprovalFormConfig(props.row?.formConfig);
+    const payload = {};
+    (cfg.fields || []).forEach(f => {
+      if (f?.key) payload[f.key] = f.value ?? "";
+    });
+    if (props.moduleKey === APPROVAL_MODULE_KEYS.LEAVE) {
+      const balance = payload.leaveBalanceDays;
+      if (balance != null && balance !== "") {
+        rows.push({ label: "鍋囨湡浣欓", value: `${balance} 澶ー });
+      }
+      const days = computeLeaveDurationDisplay(cfg.fields, payload);
+      if (days) rows.push({ label: "璇峰亣鏃堕暱", value: `${days} 澶ー });
+    }
+    if (props.moduleKey === APPROVAL_MODULE_KEYS.OVERTIME) {
+      const hours = computeOvertimeHoursDisplay(cfg.fields, payload);
+      if (hours) rows.push({ label: "鍔犵彮鏃堕暱", value: `${hours} 灏忔椂` });
+    }
+    if (props.moduleKey === APPROVAL_MODULE_KEYS.TRANSFER) {
+      const post = payload.originalPostName || payload.originalPost;
+      if (post) rows.push({ label: "鍘熷矖浣�", value: post });
+    }
+    return rows;
+  });
+
+  const flowNodes = computed(() => mapTasksToFlowNodes(props.row?.tasks));
+
+  const approvalRecords = computed(() =>
+    mapApprovalRecords(props.row?.records)
+  );
+
+  const rejectReason = computed(() =>
+    getRejectReasonFromRecords(props.row?.records)
+  );
+
+  const levelLabel = n => LEVEL_TEXT[Number(n)] || String(n);
+</script>
+
+<style scoped lang="scss">
+  $primary: #2979ff;
+  $text: #1f2d3d;
+  $text-muted: #909399;
+
+  .detail-body {
+    display: flex;
+    flex-direction: column;
+    gap: 10px;
+  }
+
+  .section-card {
+    background: #fff;
+    border-radius: 12px;
+    overflow: hidden;
+    box-shadow: 0 2px 12px rgba(31, 45, 61, 0.05);
+  }
+
+  .section-head {
+    padding: 12px 16px;
+    border-bottom: 1px solid #f2f4f7;
+  }
+
+  .section-title {
+    font-size: 15px;
+    font-weight: 600;
+    color: $text;
+    padding-left: 10px;
+    border-left: 3px solid $primary;
+    line-height: 1.2;
+  }
+
+  .info-rows {
+    padding: 4px 16px 12px;
+  }
+
+  .info-row {
+    display: flex;
+    align-items: flex-start;
+    justify-content: space-between;
+    gap: 12px;
+    padding: 10px 0;
+    border-bottom: 1px solid #f5f6f8;
+
+    &:last-child {
+      border-bottom: none;
+    }
+  }
+
+  .info-label {
+    flex-shrink: 0;
+    font-size: 14px;
+    color: $text-muted;
+    min-width: 72px;
+  }
+
+  .info-value {
+    flex: 1;
+    font-size: 14px;
+    color: $text;
+    text-align: right;
+    word-break: break-all;
+  }
+
+  .reject-text {
+    color: #f56c6c;
+  }
+
+  .flow-wrap {
+    padding: 10px 16px 14px;
+  }
+
+  .flow-node-block {
+    display: flex;
+    flex-direction: column;
+    align-items: stretch;
+  }
+
+  .flow-node-card {
+    background: #fafbfd;
+    border: 1px solid #e8eef5;
+    border-radius: 10px;
+    padding: 12px;
+  }
+
+  .node-header {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    margin-bottom: 10px;
+  }
+
+  .node-level-badge {
+    width: 26px;
+    height: 26px;
+    border-radius: 8px;
+    background: $primary;
+    color: #fff;
+    font-size: 13px;
+    font-weight: 600;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+
+  .node-level-text {
+    flex: 1;
+    font-size: 14px;
+    font-weight: 600;
+    color: $text;
+  }
+
+  .approver-list {
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+  }
+
+  .approver-row {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 8px;
+  }
+
+  .approver-name {
+    font-size: 13px;
+    color: #606266;
+  }
+
+  .flow-connector-line {
+    width: 2px;
+    height: 12px;
+    background: #d0dff0;
+    margin: 4px auto;
+  }
+
+  .record-list {
+    padding: 8px 16px 14px;
+  }
+
+  .record-item {
+    padding: 10px 0;
+    border-bottom: 1px solid #f0f2f5;
+
+    &:last-child {
+      border-bottom: none;
+    }
+  }
+
+  .record-head {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 8px;
+  }
+
+  .record-operator {
+    font-size: 14px;
+    font-weight: 600;
+    color: $text;
+  }
+
+  .record-time {
+    display: block;
+    margin-top: 4px;
+    font-size: 12px;
+    color: $text-muted;
+  }
+
+  .record-opinion {
+    display: block;
+    margin-top: 6px;
+    font-size: 13px;
+    color: #606266;
+    line-height: 1.5;
+  }
+
+  .empty-hint {
+    padding: 12px 16px 16px;
+    font-size: 13px;
+    color: $text-muted;
+  }
+</style>
diff --git a/src/pages/oa/ApproveManage/approve-list/apply.vue b/src/pages/oa/ApproveManage/approve-list/apply.vue
index 3e873a8..7d6e107 100644
--- a/src/pages/oa/ApproveManage/approve-list/apply.vue
+++ b/src/pages/oa/ApproveManage/approve-list/apply.vue
@@ -56,7 +56,7 @@
                    label-width="88"
                    input-align="right"
                    class="dynamic-form">
-            <up-form-item v-for="field in formConfigData.fields"
+            <up-form-item v-for="field in displayTemplateFields"
                           :key="field.key"
                           :label="field.label"
                           :required="!!field.required"
@@ -120,6 +120,66 @@
           </up-form>
           <view v-else
                 class="empty-hint">璇ユā鏉挎殏鏃犲~鎶ラ」</view>
+
+          <!-- 璇峰亣锛氬亣鏈熶綑棰� + 鏃堕暱鑷姩璁$畻 -->
+          <view v-if="isLeaveModule"
+                class="module-extra-block">
+            <up-form :model="extraForm"
+                     label-width="88"
+                     input-align="right"
+                     class="dynamic-form">
+              <up-form-item label="鍋囨湡浣欓"
+                            required
+                            class="form-item-inline">
+                <up-input v-model="extraForm.leaveBalanceDays"
+                          type="digit"
+                          placeholder="璇疯緭鍏ュぉ鏁�"
+                          clearable />
+              </up-form-item>
+              <up-form-item label="璇峰亣鏃堕暱"
+                            class="form-item-inline">
+                <view class="readonly-with-unit">
+                  <up-input :model-value="leaveDurationText"
+                            readonly
+                            placeholder="鏍规嵁璇峰亣鏃堕棿鑷姩璁$畻" />
+                  <text class="unit-text">澶�</text>
+                </view>
+              </up-form-item>
+            </up-form>
+          </view>
+
+          <!-- 鍔犵彮锛氭椂闀胯嚜鍔ㄨ绠� -->
+          <view v-if="isOvertimeModule"
+                class="module-extra-block">
+            <up-form label-width="88"
+                     input-align="right"
+                     class="dynamic-form">
+              <up-form-item label="鍔犵彮鏃堕暱"
+                            class="form-item-inline">
+                <view class="readonly-with-unit">
+                  <up-input :model-value="overtimeHoursText"
+                            readonly
+                            placeholder="鏍规嵁鍔犵彮鏃堕棿鑷姩璁$畻" />
+                  <text class="unit-text">灏忔椂</text>
+                </view>
+              </up-form-item>
+            </up-form>
+          </view>
+
+          <!-- 璋冨矖锛氬師宀椾綅鑷姩甯﹀嚭 -->
+          <view v-if="isTransferModule"
+                class="module-extra-block">
+            <up-form label-width="88"
+                     input-align="right"
+                     class="dynamic-form">
+              <up-form-item label="鍘熷矖浣�"
+                            class="form-item-readonly">
+                <up-input :model-value="extraForm.originalPostName"
+                          readonly
+                          placeholder="閫夋嫨鐢宠浜哄悗鑷姩甯﹀嚭" />
+              </up-form-item>
+            </up-form>
+          </view>
         </view>
 
         <view class="section-card">
@@ -200,7 +260,7 @@
 </template>
 
 <script setup>
-  import { computed, reactive, ref } from "vue";
+  import { computed, reactive, ref, watch } from "vue";
   import { onLoad } from "@dcloudio/uni-app";
   import PageHeader from "@/components/PageHeader.vue";
   import FooterButtons from "@/components/FooterButtons.vue";
@@ -212,7 +272,25 @@
   import useUserStore from "@/store/modules/user";
   import { parseTime } from "@/utils/ruoyi";
   import { getDept } from "@/api/collaborativeApproval/approvalProcess.js";
+  import { findPostOptions } from "@/api/system/post.js";
   import { userListNoPageByTenantId } from "@/api/system/user";
+  import { APPROVAL_MODULE_KEYS } from "../../_utils/approvalModuleRegistry.js";
+  import {
+    computeLeaveDurationDisplay,
+    computeOvertimeHoursDisplay,
+    displayTemplateFieldsByModule,
+    findApplicantTemplateField,
+    findLeaveTimeTemplateField,
+    findOvertimeTimeTemplateField,
+    inferModuleKeyFromRow,
+    loadModuleExtrasFromRow,
+    resolveOriginalPostName,
+    syncModuleExtrasToFormValues,
+    unwrapUserArray,
+    userById,
+    validateModuleExtras,
+    buildPostIdToNameMap,
+  } from "../../_utils/approvalModuleApplyExtras.js";
   import {
     formatDatetimerangeDisplay,
     formatFieldDateValue,
@@ -233,10 +311,12 @@
     parseFieldDateToTs,
   } from "../../_utils/approvalFormField.js";
 
-  const EDIT_STORAGE_KEY = "oa_approve_instance_edit_row";
+  import { EDIT_STORAGE_KEY } from "../../_utils/approveListUtils.js";
+
   const LEVEL_TEXT = ["", "涓�", "浜�", "涓�", "鍥�", "浜�", "鍏�", "涓�", "鍏�", "涔�", "鍗�"];
 
   const userStore = useUserStore();
+  const moduleKey = ref("");
   const templateId = ref("");
   const instanceId = ref("");
   const instanceRow = ref(null);
@@ -245,6 +325,22 @@
   const submitting = ref(false);
   const formValues = reactive({});
   const form = reactive({ title: "" });
+  const extraForm = reactive({
+    leaveBalanceDays: undefined,
+    originalPostName: "",
+  });
+  const postIdToName = ref({});
+  const transferUserPool = ref([]);
+
+  const isLeaveModule = computed(
+    () => moduleKey.value === APPROVAL_MODULE_KEYS.LEAVE
+  );
+  const isOvertimeModule = computed(
+    () => moduleKey.value === APPROVAL_MODULE_KEYS.OVERTIME
+  );
+  const isTransferModule = computed(
+    () => moduleKey.value === APPROVAL_MODULE_KEYS.TRANSFER
+  );
 
   const showDatePicker = ref(false);
   const datePickerTs = ref(Date.now());
@@ -288,6 +384,31 @@
       );
     }
     return parseApprovalFormConfig(detail.value?.formConfig);
+  });
+
+  const displayTemplateFields = computed(() =>
+    displayTemplateFieldsByModule(moduleKey.value, formConfigData.value.fields)
+  );
+
+  const leaveDurationText = computed(() => {
+    if (!isLeaveModule.value) return "";
+    const fields = formConfigData.value.fields;
+    const timeField = findLeaveTimeTemplateField(fields);
+    if (timeField?.key) void formValues[timeField.key];
+    return computeLeaveDurationDisplay(fields, formValues);
+  });
+
+  const overtimeHoursText = computed(() => {
+    if (!isOvertimeModule.value) return "";
+    const fields = formConfigData.value.fields;
+    const timeField = findOvertimeTimeTemplateField(fields);
+    if (timeField?.key) void formValues[timeField.key];
+    return computeOvertimeHoursDisplay(fields, formValues);
+  });
+
+  const applicantPickerValue = computed(() => {
+    const f = findApplicantTemplateField(formConfigData.value.fields);
+    return f?.key ? formValues[f.key] : undefined;
   });
 
   const levelLabel = n => LEVEL_TEXT[Number(n)] || String(n);
@@ -414,7 +535,7 @@
       uni.showToast({ title: "璇疯緭鍏ュ鎵规爣棰�", icon: "none" });
       return false;
     }
-    for (const field of formConfigData.value.fields) {
+    for (const field of displayTemplateFields.value) {
       if (!field.required) continue;
       const val = formValues[field.key];
       if (val === undefined || val === null || String(val).trim() === "") {
@@ -453,17 +574,35 @@
       uni.showToast({ title: "妯℃澘鏈厤缃鎵规祦绋�", icon: "none" });
       return false;
     }
+    const moduleMsg = validateModuleExtras(
+      moduleKey.value,
+      formConfigData.value.fields,
+      formValues,
+      extraForm
+    );
+    if (moduleMsg) {
+      uni.showToast({ title: moduleMsg, icon: "none" });
+      return false;
+    }
     return true;
   };
 
-  const buildFormConfigPayload = () =>
-    JSON.stringify({
+  const buildFormConfigPayload = () => {
+    syncModuleExtrasToFormValues(
+      moduleKey.value,
+      formValues,
+      extraForm,
+      formConfigData.value.fields
+    );
+    const allFields = formConfigData.value.fields || [];
+    return JSON.stringify({
       prompt: formConfigData.value.prompt,
-      fields: formConfigData.value.fields.map(field => ({
+      fields: allFields.map(field => ({
         ...field,
         value: formValues[field.key] ?? "",
       })),
     });
+  };
 
   const buildSavePayload = () => ({
     templateId: detail.value.id,
@@ -562,7 +701,8 @@
     try {
       await loadTemplateDetail();
       if (!detail.value) return;
-      initFormValues(formConfigData.value.fields);
+      initFormValues(displayTemplateFields.value);
+      resetModuleExtras();
       if (!form.title && detail.value.templateName) {
         form.title = `${detail.value.templateName}鐢宠`;
       }
@@ -579,6 +719,9 @@
       return;
     }
     instanceRow.value = row;
+    if (!moduleKey.value) {
+      moduleKey.value = inferModuleKeyFromRow(row);
+    }
     templateId.value = row.templateId;
     form.title = row.title || "";
 
@@ -587,11 +730,65 @@
     try {
       await loadTemplateDetail();
       if (!detail.value) return;
-      initFormValues(formConfigData.value.fields);
+      initFormValues(displayTemplateFields.value);
+      applyModuleExtrasFromRow();
+      if (isTransferModule.value) {
+        await ensureTransferLookupData();
+        syncOriginalPostFromApplicant(applicantPickerValue.value);
+      }
     } finally {
       loading.value = false;
     }
   };
+
+  function resetModuleExtras() {
+    extraForm.leaveBalanceDays = undefined;
+    extraForm.originalPostName = "";
+  }
+
+  function applyModuleExtrasFromRow() {
+    const loaded = loadModuleExtrasFromRow(
+      moduleKey.value,
+      instanceRow.value,
+      formValues
+    );
+    if (loaded.leaveBalanceDays != null) {
+      extraForm.leaveBalanceDays = loaded.leaveBalanceDays;
+    }
+    if (loaded.originalPostName) {
+      extraForm.originalPostName = loaded.originalPostName;
+    }
+  }
+
+  async function ensureTransferLookupData() {
+    if (!transferUserPool.value.length) {
+      try {
+        const res = await userListNoPageByTenantId();
+        transferUserPool.value = unwrapUserArray(res);
+      } catch {
+        transferUserPool.value = [];
+      }
+    }
+    if (!Object.keys(postIdToName.value).length) {
+      try {
+        const res = await findPostOptions();
+        const rows = res?.data ?? res?.rows ?? [];
+        postIdToName.value = buildPostIdToNameMap(Array.isArray(rows) ? rows : []);
+      } catch {
+        postIdToName.value = {};
+      }
+    }
+  }
+
+  function syncOriginalPostFromApplicant(uid) {
+    if (!isTransferModule.value) return;
+    if (!uid) {
+      extraForm.originalPostName = "";
+      return;
+    }
+    const user = userById(transferUserPool.value, uid);
+    extraForm.originalPostName = resolveOriginalPostName(user, postIdToName.value);
+  }
 
   const goBack = () => {
     uni.navigateBack();
@@ -614,8 +811,18 @@
       });
   };
 
+  watch(applicantPickerValue, async uid => {
+    if (!isTransferModule.value) return;
+    await ensureTransferLookupData();
+    syncOriginalPostFromApplicant(uid);
+  });
+
   onLoad(options => {
+    moduleKey.value = options?.moduleKey || "";
     loadPickerSourceData();
+    if (isTransferModule.value) {
+      ensureTransferLookupData();
+    }
     if (options?.id) {
       instanceId.value = options.id;
       loadForEdit();
@@ -1015,4 +1222,29 @@
   .empty-wrap {
     padding: 48px 20px;
   }
+
+  .module-extra-block {
+    margin-top: 8px;
+    padding-top: 8px;
+    border-top: 1px dashed #e8ecf0;
+  }
+
+  .readonly-with-unit {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    width: 100%;
+    justify-content: flex-end;
+  }
+
+  .readonly-with-unit :deep(.u-input) {
+    flex: 1;
+    min-width: 0;
+  }
+
+  .unit-text {
+    flex-shrink: 0;
+    font-size: 14px;
+    color: $text-muted;
+  }
 </style>
diff --git a/src/pages/oa/ApproveManage/approve-list/approve.vue b/src/pages/oa/ApproveManage/approve-list/approve.vue
new file mode 100644
index 0000000..3ed6220
--- /dev/null
+++ b/src/pages/oa/ApproveManage/approve-list/approve.vue
@@ -0,0 +1,164 @@
+<!--
+  OA / 瀹℃壒绠$悊 / 瀹℃壒澶勭悊
+  璺敱锛�/pages/oa/ApproveManage/approve-list/approve
+-->
+<template>
+  <view class="oa-detail-page">
+    <PageHeader title="瀹℃壒澶勭悊"
+                @back="goBack" />
+
+    <scroll-view v-if="row"
+                 class="oa-detail-scroll"
+                 scroll-y
+                 :show-scrollbar="false">
+      <ApproveInstanceDetailBody :row="row"
+                                 :module-key="detailModuleKey" />
+
+      <view class="section-card opinion-card">
+        <view class="section-head">
+          <text class="section-title">瀹℃壒鎰忚</text>
+        </view>
+        <view class="opinion-wrap">
+          <up-textarea v-model="approveOpinion"
+                       placeholder="閫氳繃鍙暀绌猴紱椹冲洖璇峰~鍐欏叿浣撳師鍥�"
+                       maxlength="500"
+                       count
+                       height="100"
+                       border="surround" />
+        </view>
+      </view>
+    </scroll-view>
+
+    <view v-else
+          class="oa-empty">
+      <up-empty mode="data"
+                text="鏈幏鍙栧埌瀹℃壒鏁版嵁" />
+    </view>
+
+    <view v-if="row"
+          class="oa-page-footer">
+      <text class="oa-footer-btn btn-default"
+            :class="{ 'is-disabled': submitting }"
+            @click="goBack">鍙栨秷</text>
+      <text class="oa-footer-btn btn-danger"
+            :class="{ 'is-disabled': submitting }"
+            @click="submitApprove('rejected')">椹冲洖</text>
+      <text class="oa-footer-btn btn-success"
+            :class="{ 'is-disabled': submitting }"
+            @click="submitApprove('approved')">閫氳繃</text>
+    </view>
+  </view>
+</template>
+
+<script setup>
+  import { computed, ref } from "vue";
+  import { onLoad } from "@dcloudio/uni-app";
+  import PageHeader from "@/components/PageHeader.vue";
+  import ApproveInstanceDetailBody from "./_components/ApproveInstanceDetailBody.vue";
+  import { approveApprovalInstance } from "@/api/oa/approvalInstance.js";
+  import {
+    buildApproveInstanceDto,
+    canApproveInstance,
+    loadInstanceRow,
+  } from "../../_utils/approveListUtils.js";
+  import { inferModuleKeyFromRow } from "../../_utils/approvalModuleApplyExtras.js";
+
+  const instanceId = ref("");
+  const row = ref(null);
+  const detailModuleKey = computed(() => inferModuleKeyFromRow(row.value));
+  const approveOpinion = ref("");
+  const submitting = ref(false);
+
+  const goBack = () => {
+    uni.navigateBack();
+  };
+
+  const submitApprove = uiResult => {
+    if (!row.value?.id || submitting.value) return;
+
+    if (uiResult === "rejected" && !(approveOpinion.value || "").trim()) {
+      uni.showToast({ title: "椹冲洖鏃惰濉啓瀹℃壒鎰忚", icon: "none" });
+      return;
+    }
+
+    submitting.value = true;
+    const dto = buildApproveInstanceDto(
+      row.value.id,
+      uiResult,
+      approveOpinion.value
+    );
+
+    approveApprovalInstance(dto)
+      .then(() => {
+        uni.showToast({
+          title: uiResult === "approved" ? "宸查�氳繃" : "宸查┏鍥�",
+          icon: "success",
+        });
+        setTimeout(() => {
+          const pages = getCurrentPages();
+          const prevRoute = pages[pages.length - 2]?.route || "";
+          const delta = prevRoute.includes("approve-list/detail") ? 2 : 1;
+          uni.navigateBack({ delta });
+        }, 300);
+      })
+      .catch(() => {
+        uni.showToast({ title: "瀹℃壒鎿嶄綔澶辫触", icon: "none" });
+      })
+      .finally(() => {
+        submitting.value = false;
+      });
+  };
+
+  onLoad(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" });
+      setTimeout(goBack, 500);
+      return;
+    }
+    if (!canApproveInstance(cached)) {
+      uni.showToast({ title: "褰撳墠瀹℃壒涓嶅彲澶勭悊", icon: "none" });
+      setTimeout(goBack, 500);
+      return;
+    }
+    row.value = cached;
+  });
+</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
new file mode 100644
index 0000000..61ac9e4
--- /dev/null
+++ b/src/pages/oa/ApproveManage/approve-list/detail.vue
@@ -0,0 +1,116 @@
+<!--
+  OA / 瀹℃壒绠$悊 / 瀹℃壒璇︽儏
+  璺敱锛�/pages/oa/ApproveManage/approve-list/detail
+-->
+<template>
+  <view class="oa-detail-page">
+    <PageHeader title="瀹℃壒璇︽儏"
+                @back="goBack" />
+
+    <scroll-view v-if="row"
+                 class="oa-detail-scroll"
+                 scroll-y
+                 :show-scrollbar="false">
+      <ApproveInstanceDetailBody :row="row"
+                                 :module-key="detailModuleKey" />
+    </scroll-view>
+
+    <view v-else
+          class="oa-empty">
+      <up-empty mode="data"
+                text="鏈幏鍙栧埌瀹℃壒鏁版嵁" />
+    </view>
+
+    <view v-if="row"
+          class="oa-page-footer">
+      <text class="oa-footer-btn btn-default"
+            @click="goBack">杩斿洖</text>
+      <text v-if="showEdit"
+            class="oa-footer-btn btn-warn"
+            @click="goEdit">淇敼</text>
+      <text v-if="showApprove && !fromBusiness"
+            class="oa-footer-btn btn-primary"
+            @click="goApprove">鍘诲鎵�</text>
+    </view>
+  </view>
+</template>
+
+<script setup>
+  import { computed, ref } from "vue";
+  import { onLoad } from "@dcloudio/uni-app";
+  import PageHeader from "@/components/PageHeader.vue";
+  import ApproveInstanceDetailBody from "./_components/ApproveInstanceDetailBody.vue";
+  import { 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";
+
+  const userStore = useUserStore();
+  const instanceId = ref("");
+  const fromBusiness = ref(false);
+  const row = ref(null);
+
+  const detailModuleKey = computed(() => inferModuleKeyFromRow(row.value));
+
+  const showEdit = computed(() => {
+    if (fromBusiness.value) {
+      return canEditBusinessInstanceRow(row.value);
+    }
+    return canModifyInstance(row.value, userStore);
+  });
+  const showApprove = computed(() => canApproveInstance(row.value));
+
+  const goBack = () => {
+    uni.navigateBack();
+  };
+
+  const goEdit = () => {
+    if (!showEdit.value || !row.value?.id) return;
+    if (fromBusiness.value && !canEditBusinessInstanceRow(row.value)) {
+      uni.showToast({ title: "杩涜涓垨宸插畬鎴愮殑瀹℃壒涓嶅彲淇敼", icon: "none" });
+      return;
+    }
+    uni.setStorageSync(EDIT_STORAGE_KEY, row.value);
+    const mk = detailModuleKey.value;
+    const q = mk ? `&moduleKey=${mk}` : "";
+    uni.navigateTo({
+      url: `${OA_NAV.approveListApply}?id=${row.value.id}${q}`,
+    });
+  };
+
+  const goApprove = () => {
+    if (!row.value?.id) return;
+    stashInstanceRow(row.value);
+    uni.navigateTo({
+      url: `${OA_NAV.approveListApprove}?id=${row.value.id}`,
+    });
+  };
+
+  onLoad(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" });
+      setTimeout(goBack, 500);
+      return;
+    }
+    row.value = cached;
+  });
+</script>
+
+<style scoped lang="scss">
+  @import "../../_styles/oa-approval-list.scss";
+</style>
diff --git a/src/pages/oa/ApproveManage/approve-list/index.vue b/src/pages/oa/ApproveManage/approve-list/index.vue
index fd5e142..7c1603e 100644
--- a/src/pages/oa/ApproveManage/approve-list/index.vue
+++ b/src/pages/oa/ApproveManage/approve-list/index.vue
@@ -1,109 +1,94 @@
 <!--
   OA / 瀹℃壒绠$悊 / 瀹℃壒鍒楄〃
-  璺敱锛�/pages/oa/ApproveManage/approve-list/index
 -->
 <template>
-  <view class="approve-list-page sales-account">
+  <view class="oa-approval-page">
     <PageHeader title="瀹℃壒鍒楄〃"
                 @back="goBack" />
-    <view class="search-section">
-      <view class="search-bar">
-        <view class="search-input">
-          <up-input v-model="queryParams.keyword"
-                    class="search-text"
-                    placeholder="瀹℃壒鏍囬 / 瀹℃壒缂栧彿"
-                    clearable
-                    @confirm="handleSearch" />
-        </view>
-        <view class="filter-button"
-              @click="handleSearch">
-          <up-icon name="search"
-                   size="24"
-                   color="#999" />
-        </view>
+
+    <view class="oa-toolbar">
+      <view class="oa-filter-chip active-search">
+        <up-icon name="search"
+                 size="18"
+                 color="#666" />
+        <up-input v-model="queryParams.keyword"
+                  class="chip-input"
+                  placeholder="瀹℃壒鏍囬 / 瀹℃壒缂栧彿"
+                  clearable
+                  border="none"
+                  @confirm="handleSearch" />
+      </view>
+      <view class="oa-icon-btn"
+            @click="handleSearch">
+        <up-icon name="search"
+                 size="20"
+                 color="#2979ff" />
       </view>
     </view>
 
-    <scroll-view class="list-scroll"
+    <scroll-view class="oa-list-scroll"
                  scroll-y
                  :show-scrollbar="false"
+                 :style="{ height: listScrollHeight + 'px' }"
                  @scrolltolower="loadMore">
       <view v-if="list.length"
-            class="ledger-list">
+            class="oa-card-list">
         <view v-for="item in list"
               :key="item.id"
-              class="ledger-item">
-          <view class="item-header">
-            <view class="item-left">
-              <view class="document-icon">
-                <up-icon name="file-text"
-                         size="16"
-                         color="#ffffff" />
+              class="oa-card"
+              @click="openDetail(item)">
+          <view class="oa-card-head">
+            <view class="oa-card-title-wrap">
+              <text class="oa-card-title">{{ item.title || item.instanceNo || "-" }}</text>
+              <text v-if="item.templateName"
+                    class="oa-card-sub">{{ item.templateName }}</text>
+            </view>
+            <text :class="['oa-status', businessStatusClass(item.status)]">
+              {{ statusText(item.status) }}
+            </text>
+          </view>
+
+          <view class="oa-card-body">
+            <view class="oa-info-grid">
+              <view class="oa-info-row">
+                <text class="oa-info-label">瀹℃壒缂栧彿</text>
+                <text class="oa-info-value">{{ item.instanceNo || "-" }}</text>
               </view>
-              <text class="item-id">{{ item.title || item.instanceNo || "-" }}</text>
-            </view>
-            <u-tag :type="statusTagType(item.status)"
-                   :text="statusText(item.status)" />
-          </view>
-          <up-divider />
-          <view class="item-details">
-            <view class="detail-row">
-              <text class="detail-label">瀹℃壒缂栧彿</text>
-              <text class="detail-value">{{ item.instanceNo || "-" }}</text>
-            </view>
-            <view class="detail-row">
-              <text class="detail-label">妯℃澘鍚嶇О</text>
-              <text class="detail-value">{{ item.templateName || "-" }}</text>
-            </view>
-            <view class="detail-row">
-              <text class="detail-label">涓氬姟鍚嶇О</text>
-              <text class="detail-value">{{ item.businessName || "-" }}</text>
-            </view>
-            <view class="detail-row">
-              <text class="detail-label">鐢宠浜�</text>
-              <text class="detail-value">{{ item.applicantName || "-" }}</text>
-            </view>
-            <view class="detail-row">
-              <text class="detail-label">褰撳墠绾у埆</text>
-              <text class="detail-value">{{ formatLevel(item.currentLevel) }}</text>
-            </view>
-            <view class="detail-row">
-              <text class="detail-label">褰撳墠瀹℃壒浜�</text>
-              <text class="detail-value">{{ currentApproverName(item) }}</text>
-            </view>
-            <view class="detail-row">
-              <text class="detail-label">鐢宠鏃堕棿</text>
-              <text class="detail-value">{{ formatDateTime(item.applyTime) }}</text>
-            </view>
-            <view v-if="item.finishTime"
-                  class="detail-row">
-              <text class="detail-label">瀹屾垚鏃堕棿</text>
-              <text class="detail-value">{{ formatDateTime(item.finishTime) }}</text>
+              <view v-if="item.businessName"
+                    class="oa-info-row">
+                <text class="oa-info-label">涓氬姟鍚嶇О</text>
+                <text class="oa-info-value">{{ item.businessName }}</text>
+              </view>
+              <view class="oa-info-row">
+                <text class="oa-info-label">鐢宠浜�</text>
+                <text class="oa-info-value">{{ item.applicantName || "-" }}</text>
+              </view>
+              <view class="oa-info-row">
+                <text class="oa-info-label">褰撳墠瀹℃壒浜�</text>
+                <text class="oa-info-value">{{ currentApproverName(item) }}</text>
+              </view>
+              <view class="oa-info-row">
+                <text class="oa-info-label">鐢宠鏃堕棿</text>
+                <text class="oa-info-value">{{ formatDateTime(item.applyTime) }}</text>
+              </view>
             </view>
           </view>
+
           <view v-if="canModify(item) || item.isApprove"
-                class="action-buttons">
-            <up-button v-if="canModify(item)"
-                       class="action-btn"
-                       size="small"
-                       type="warning"
-                       plain
-                       @click.stop="goModify(item)">
-              缂栬緫
-            </up-button>
-            <up-button v-if="item.isApprove"
-                       class="action-btn"
-                       size="small"
-                       type="primary"
-                       @click.stop="handleApprove(item)">
-              瀹℃壒
-            </up-button>
+                class="oa-card-foot"
+                @click.stop>
+            <text v-if="canModify(item)"
+                  class="oa-foot-btn btn-edit"
+                  @click="goModify(item)">缂栬緫</text>
+            <text v-if="item.isApprove"
+                  class="oa-foot-btn btn-approve"
+                  @click="handleApprove(item)">瀹℃壒</text>
           </view>
         </view>
         <up-loadmore :status="pageStatus" />
       </view>
       <view v-else
-            class="empty-wrap">
+            class="oa-empty">
         <up-empty mode="list"
                   text="鏆傛棤瀹℃壒鏁版嵁" />
       </view>
@@ -119,71 +104,48 @@
 </template>
 
 <script setup>
-  import { reactive, ref } from "vue";
+  import { onMounted, reactive, ref } from "vue";
   import { onShow } from "@dcloudio/uni-app";
   import PageHeader from "@/components/PageHeader.vue";
   import { listApprovalInstancePage } from "@/api/oa/approvalInstance.js";
   import { OA_NAV } from "@/config/oaPaths.js";
   import useUserStore from "@/store/modules/user";
   import { parseTime } from "@/utils/ruoyi";
+  import {
+    businessStatusClass,
+    businessStatusText,
+    canModifyInstance,
+    EDIT_STORAGE_KEY,
+    stashInstanceRow,
+  } from "../../_utils/approveListUtils.js";
 
-  const EDIT_STORAGE_KEY = "oa_approve_instance_edit_row";
   const userStore = useUserStore();
-
-  const queryParams = reactive({
-    keyword: "",
-  });
-
+  const queryParams = reactive({ keyword: "" });
   const list = ref([]);
   const pageStatus = ref("loadmore");
+  const page = reactive({ current: 1, size: 10, total: 0 });
+  const listScrollHeight = ref(400);
 
-  const page = reactive({
-    current: 1,
-    size: 10,
-    total: 0,
-  });
+  function calcListScrollHeight() {
+    const sys = uni.getSystemInfoSync();
+    const statusBar = sys.statusBarHeight || 0;
+    const navBar = 44;
+    const toolbar = 56;
+    const fabGap = 16;
+    listScrollHeight.value = Math.max(
+      200,
+      sys.windowHeight - statusBar - navBar - toolbar - fabGap
+    );
+  }
 
-  const STATUS_TEXT = {
-    PENDING: "杩涜涓�",
-    APPROVED: "宸查�氳繃",
-    REJECTED: "宸查┏鍥�",
-  };
-
-  const STATUS_TAG = {
-    PENDING: "warning",
-    APPROVED: "success",
-    REJECTED: "error",
-  };
-
-  const statusText = status => STATUS_TEXT[status] || status || "-";
-
-  const statusTagType = status => STATUS_TAG[status] || "info";
-
-  const formatLevel = level => {
-    if (level == null || level === "") return "-";
-    return `绗� ${level} 绾;
-  };
+  const statusText = status => businessStatusText(status);
 
   const formatDateTime = val => {
     if (!val) return "-";
     return parseTime(val, "{y}-{m}-{d} {h}:{i}:{s}") || String(val);
   };
 
-  /** 鏄惁鏈汉鍙戣捣鐨勫鎵癸紙鍏煎鍒楄〃鏈繑鍥� applicantId锛� */
-  const isOwnApplication = item => {
-    const uid = userStore.id;
-    if (item?.applicantId != null && uid != null && uid !== "") {
-      return String(item.applicantId) === String(uid);
-    }
-    const loginName = userStore.nickName || userStore.name;
-    if (loginName && item?.applicantName) {
-      return String(item.applicantName).trim() === String(loginName).trim();
-    }
-    return false;
-  };
-
-  /** 浠呫�岃繘琛屼腑銆嶄笖鏈汉鍙戣捣鏃跺彲缂栬緫锛堝凡閫氳繃/宸查┏鍥炰笉鏄剧ず缂栬緫锛� */
-  const canModify = item => item?.status === "PENDING" && isOwnApplication(item);
+  const canModify = item => canModifyInstance(item, userStore);
 
   const currentApproverName = item => {
     const tasks = item?.tasks;
@@ -204,31 +166,22 @@
         dto.instanceNo = keyword;
       }
     }
-    return {
-      page: {
-        current: page.current,
-        size: page.size,
-      },
-      approvalInstanceDto: dto,
-    };
+    return { current: page.current, size: page.size, ...dto };
   };
 
   const getList = () => {
     if (pageStatus.value === "loading" || pageStatus.value === "nomore") return;
-
     pageStatus.value = "loading";
     listApprovalInstancePage(buildListParams())
       .then(res => {
         const pageData = res?.data || {};
         const records = pageData.records || [];
         const total = pageData.total ?? 0;
-
         if (page.current === 1) {
           list.value = records;
         } else {
           list.value = [...list.value, ...records];
         }
-
         page.total = total;
         if (list.value.length >= total || records.length < page.size) {
           pageStatus.value = "nomore";
@@ -238,9 +191,7 @@
         }
       })
       .catch(() => {
-        if (page.current === 1) {
-          list.value = [];
-        }
+        if (page.current === 1) list.value = [];
         pageStatus.value = "loadmore";
         uni.showToast({ title: "鏌ヨ澶辫触", icon: "none" });
       });
@@ -254,17 +205,16 @@
   };
 
   const loadMore = () => {
-    if (pageStatus.value === "loadmore") {
-      getList();
-    }
+    if (pageStatus.value === "loadmore") getList();
   };
 
-  const goBack = () => {
-    uni.navigateBack();
-  };
+  const goBack = () => uni.navigateBack();
+  const goAdd = () => uni.navigateTo({ url: OA_NAV.approveListTemplateSelect });
 
-  const goAdd = () => {
-    uni.navigateTo({ url: OA_NAV.approveListTemplateSelect });
+  const openDetail = item => {
+    if (!item?.id) return;
+    stashInstanceRow(item);
+    uni.navigateTo({ url: `${OA_NAV.approveListDetail}?id=${item.id}` });
   };
 
   const goModify = item => {
@@ -274,50 +224,43 @@
     }
     if (!item?.id) return;
     uni.setStorageSync(EDIT_STORAGE_KEY, item);
-    uni.navigateTo({
-      url: `${OA_NAV.approveListApply}?id=${item.id}`,
-    });
+    stashInstanceRow(item);
+    uni.navigateTo({ url: `${OA_NAV.approveListApply}?id=${item.id}` });
   };
 
   const handleApprove = item => {
     if (!item?.id) return;
-    uni.showToast({ title: "瀹℃壒璇︽儏椤靛緟瀵规帴", icon: "none" });
+    if (!item.isApprove) {
+      uni.showToast({ title: "褰撳墠瀹℃壒鏃犻渶鎮ㄥ鐞�", icon: "none" });
+      return;
+    }
+    stashInstanceRow(item);
+    uni.navigateTo({ url: `${OA_NAV.approveListApprove}?id=${item.id}` });
   };
 
+  onMounted(() => calcListScrollHeight());
+
   onShow(() => {
+    calcListScrollHeight();
     handleSearch();
   });
 </script>
 
 <style scoped lang="scss">
   @import "@/styles/sales-common.scss";
+  @import "../../_styles/oa-approval-list.scss";
 
-  .approve-list-page {
-    display: flex;
-    flex-direction: column;
-    min-height: 100vh;
+  .active-search {
+    padding-right: 4px;
   }
 
-  .list-scroll {
+  .chip-input {
     flex: 1;
-    height: 0;
-    padding-bottom: calc(80px + env(safe-area-inset-bottom));
+    font-size: 14px;
   }
 
-  .empty-wrap {
-    padding: 48px 20px;
-  }
-
-  .action-buttons {
-    display: flex;
-    justify-content: flex-end;
-    gap: 10px;
-    margin-top: 12px;
-    padding-top: 12px;
-    border-top: 1px solid #f0f0f0;
-  }
-
-  .action-btn {
-    min-width: 72px;
+  :deep(.chip-input .u-input__content) {
+    background: transparent !important;
+    padding: 0 !important;
   }
 </style>
diff --git a/src/pages/oa/ApproveManage/approve-list/template-select.vue b/src/pages/oa/ApproveManage/approve-list/template-select.vue
index 628483b..073b556 100644
--- a/src/pages/oa/ApproveManage/approve-list/template-select.vue
+++ b/src/pages/oa/ApproveManage/approve-list/template-select.vue
@@ -5,10 +5,10 @@
 -->
 <template>
   <view class="template-select-page sales-account">
-    <PageHeader title="閫夋嫨瀹℃壒妯℃澘"
+    <PageHeader :title="pageHeaderTitle"
                 @back="goBack" />
 
-    <view v-if="typeOptions.length"
+    <view v-if="typeOptions.length && !moduleKey"
           class="step-section">
       <view class="tabs-wrap">
         <up-tabs :list="tabList"
@@ -16,6 +16,11 @@
                  line-color="#2979ff"
                  @click="onTabClick" />
       </view>
+    </view>
+
+    <view v-if="useAllTemplatesFallback && allTemplates.length"
+          class="fallback-hint">
+      <text>褰撳墠绫诲瀷涓嬫棤鍖归厤妯℃澘锛屽凡鏄剧ず鍏ㄩ儴 {{ allTemplates.length }} 涓彲鐢ㄦā鏉�</text>
     </view>
 
     <view class="search-section">
@@ -41,11 +46,6 @@
             class="loading-wrap">
         <up-loading-icon mode="circle" />
         <text class="loading-text">鍔犺浇涓�...</text>
-      </view>
-      <view v-else-if="!typeOptions.length"
-            class="empty-wrap">
-        <up-empty mode="list"
-                  text="鏈幏鍙栧埌瀹℃壒绫诲瀷" />
       </view>
       <view v-else-if="displayList.length"
             class="ledger-list">
@@ -95,17 +95,25 @@
   import { computed, ref } from "vue";
   import { onLoad } from "@dcloudio/uni-app";
   import PageHeader from "@/components/PageHeader.vue";
-  import { listApprovalTemplateByType } from "@/api/oa/approvalTemplate.js";
   import { OA_NAV } from "@/config/oaPaths.js";
   import {
     buildTypeLabelMap,
-    CUSTOM_TEMPLATE_LIST_TYPE,
     fetchApprovalTemplateTypes,
+    buildTypeOptionsFromTemplates,
+    FALLBACK_BUSINESS_TYPE_OPTIONS,
+    fetchEnabledApprovalTemplates,
     filterTemplatesByBusinessType,
+    filterTemplatesByBusinessTypes,
     getBusinessTypeLabel,
-    getDefaultTypeTabIndex,
+    pickTabIndexWithTemplates,
   } from "../../_utils/approvalTemplateType.js";
+  import {
+    getApprovalModuleConfig,
+    getModuleMatchingBusinessTypes,
+    resolveModuleBusinessType,
+  } from "../../_utils/approvalModuleRegistry.js";
 
+  const moduleKey = ref("");
   const typeOptions = ref([]);
   const typeLabelMap = ref({});
   /** 鍏ㄩ儴鑷畾涔夊凡鍚敤妯℃澘锛坙ist/1 涓�娆℃媺鍙栵級 */
@@ -118,9 +126,45 @@
     typeOptions.value.map(opt => ({ name: opt.name }))
   );
 
+  const moduleConfig = computed(() =>
+    moduleKey.value ? getApprovalModuleConfig(moduleKey.value) : null
+  );
+
+  const pageHeaderTitle = computed(() => {
+    if (moduleConfig.value?.label) {
+      return `閫夋嫨${moduleConfig.value.label}妯℃澘`;
+    }
+    return "閫夋嫨瀹℃壒妯℃澘";
+  });
+
+  const moduleBusinessTypes = computed(() => {
+    if (!moduleKey.value) return [];
+    return getModuleMatchingBusinessTypes(moduleKey.value, typeOptions.value);
+  });
+
   const currentTypeOption = computed(() => typeOptions.value[activeTab.value]);
 
+  /** 鏃� moduleKey 涓斿綋鍓� Tab 绛涗笉鍒版椂锛屽睍绀哄叏閮ㄦā鏉块伩鍏嶃�屾湁鏁版嵁鍗寸┖鐧姐�� */
+  const useAllTemplatesFallback = computed(() => {
+    if (moduleKey.value) return false;
+    if (!allTemplates.value.length) return false;
+    const businessType = currentTypeOption.value?.value;
+    if (businessType == null || businessType === "") return true;
+    return filterTemplatesByBusinessType(allTemplates.value, businessType).length === 0;
+  });
+
   const currentSource = computed(() => {
+    if (moduleKey.value && moduleBusinessTypes.value.length) {
+      const filtered = filterTemplatesByBusinessTypes(
+        allTemplates.value,
+        moduleBusinessTypes.value
+      );
+      if (filtered.length) return filtered;
+      return allTemplates.value;
+    }
+    if (useAllTemplatesFallback.value) {
+      return allTemplates.value;
+    }
     const businessType = currentTypeOption.value?.value;
     return filterTemplatesByBusinessType(allTemplates.value, businessType);
   });
@@ -134,8 +178,17 @@
   });
 
   const emptyText = computed(() => {
+    if (allTemplates.value.length === 0) {
+      return "鏆傛棤宸插惎鐢ㄧ殑瀹℃壒妯℃澘锛岃鍏堝湪銆屽鎵规ā鏉裤�嶄腑鍒涘缓骞跺惎鐢�";
+    }
+    if (moduleConfig.value?.label) {
+      return `鏆傛棤${moduleConfig.value.label}鍙敤妯℃澘`;
+    }
+    if (useAllTemplatesFallback.value) {
+      return "褰撳墠绫诲瀷涓嬫棤鍖归厤妯℃澘";
+    }
     const typeName = currentTypeOption.value?.name || "璇ュ鎵圭被鍨�";
-    return `鏆傛棤${typeName}涓嬬殑妯℃澘`;
+    return `鏆傛棤${typeName}涓嬬殑妯℃澘锛堝彲鍒囨崲涓婃柟绫诲瀷锛塦;
   });
 
   const businessTypeText = type =>
@@ -160,41 +213,46 @@
     return count != null ? `${count} 涓猔 : "-";
   };
 
-  const normalizeList = data => {
-    const list = Array.isArray(data)
-      ? data
-      : Array.isArray(data?.records)
-        ? data.records
-        : [];
-    return list.filter(item => String(item?.enabled ?? "1") === "1");
-  };
-
-  const loadCustomTemplates = () =>
-    listApprovalTemplateByType(CUSTOM_TEMPLATE_LIST_TYPE)
-      .then(res => {
-        allTemplates.value = normalizeList(res?.data);
-      })
-      .catch(() => {
-        allTemplates.value = [];
-        uni.showToast({ title: "鍔犺浇妯℃澘鍒楄〃澶辫触", icon: "none" });
-      });
-
   const initPage = async () => {
     loading.value = true;
     keyword.value = "";
     allTemplates.value = [];
     try {
-      const [opts] = await Promise.all([
+      const [opts, templates] = await Promise.all([
         fetchApprovalTemplateTypes(),
-        loadCustomTemplates(),
+        fetchEnabledApprovalTemplates(),
       ]);
-      typeOptions.value = opts;
-      typeLabelMap.value = buildTypeLabelMap(opts);
-      activeTab.value = getDefaultTypeTabIndex(opts);
+      let resolvedOpts = opts?.length ? opts : buildTypeOptionsFromTemplates(templates);
+      if (!resolvedOpts.length) {
+        resolvedOpts = [...FALLBACK_BUSINESS_TYPE_OPTIONS];
+      }
+      typeOptions.value = resolvedOpts;
+      typeLabelMap.value = buildTypeLabelMap(resolvedOpts);
+      allTemplates.value = templates;
+
+      if (!templates.length) {
+        uni.showToast({
+          title: "鏈幏鍙栧埌宸插惎鐢ㄦā鏉�",
+          icon: "none",
+          duration: 2500,
+        });
+      }
+
+      if (moduleKey.value) {
+        const resolved = resolveModuleBusinessType(moduleKey.value, opts);
+        const idx = opts.findIndex(
+          opt => String(opt.value) === String(resolved)
+        );
+        activeTab.value =
+          idx >= 0 ? idx : pickTabIndexWithTemplates(resolvedOpts, templates);
+      } else {
+        activeTab.value = pickTabIndexWithTemplates(resolvedOpts, templates);
+      }
     } catch {
       typeOptions.value = [];
       typeLabelMap.value = {};
-      uni.showToast({ title: "鑾峰彇瀹℃壒绫诲瀷澶辫触", icon: "none" });
+      allTemplates.value = [];
+      uni.showToast({ title: "鍔犺浇妯℃澘澶辫触", icon: "none" });
     } finally {
       loading.value = false;
     }
@@ -215,12 +273,14 @@
       uni.showToast({ title: "璇ユā鏉垮凡鍋滅敤", icon: "none" });
       return;
     }
+    const base = `${OA_NAV.approveListApply}?templateId=${item.id}`;
     uni.navigateTo({
-      url: `${OA_NAV.approveListApply}?templateId=${item.id}`,
+      url: moduleKey.value ? `${base}&moduleKey=${moduleKey.value}` : base,
     });
   };
 
-  onLoad(() => {
+  onLoad(options => {
+    moduleKey.value = options?.moduleKey || "";
     initPage();
   });
 </script>
@@ -234,6 +294,16 @@
     min-height: 100vh;
   }
 
+  .fallback-hint {
+    margin: 8px 12px 0;
+    padding: 8px 12px;
+    font-size: 12px;
+    color: #e6a23c;
+    background: #fdf6ec;
+    border-radius: 6px;
+    line-height: 1.5;
+  }
+
   .step-section {
     background: #fff;
     border-bottom: 1px solid #f0f0f0;
diff --git a/src/pages/oa/AttendManage/leave-apply/index.vue b/src/pages/oa/AttendManage/leave-apply/index.vue
index 237b92b..feb5e4c 100644
--- a/src/pages/oa/AttendManage/leave-apply/index.vue
+++ b/src/pages/oa/AttendManage/leave-apply/index.vue
@@ -3,16 +3,10 @@
   璺敱锛�/pages/oa/AttendManage/leave-apply/index
 -->
 <template>
-  <OaListPage v-if="config"
-              :page-key="pageKey"
-              :page-config="config" />
+  <ApprovalInstanceListPage :module-key="APPROVAL_MODULE_KEYS.LEAVE" />
 </template>
 
 <script setup>
-  /** OA - 鍋囧嫟绠$悊 - 璇峰亣鐢宠 */
-  import OaListPage from "../../_components/OaListPage.vue";
-  import { useOaPage } from "../../_utils/useOaPage.js";
-
-  const pageKey = "AttendManage/leave-apply";
-  const { config } = useOaPage(pageKey);
+  import ApprovalInstanceListPage from "../../_components/ApprovalInstanceListPage.vue";
+  import { APPROVAL_MODULE_KEYS } from "../../_utils/approvalModuleRegistry.js";
 </script>
diff --git a/src/pages/oa/AttendManage/overtime-apply/index.vue b/src/pages/oa/AttendManage/overtime-apply/index.vue
index 04b071a..439ea26 100644
--- a/src/pages/oa/AttendManage/overtime-apply/index.vue
+++ b/src/pages/oa/AttendManage/overtime-apply/index.vue
@@ -3,16 +3,10 @@
   璺敱锛�/pages/oa/AttendManage/overtime-apply/index
 -->
 <template>
-  <OaListPage v-if="config"
-              :page-key="pageKey"
-              :page-config="config" />
+  <ApprovalInstanceListPage :module-key="APPROVAL_MODULE_KEYS.OVERTIME" />
 </template>
 
 <script setup>
-  /** OA - 鍋囧嫟绠$悊 - 鍔犵彮鐢宠 */
-  import OaListPage from "../../_components/OaListPage.vue";
-  import { useOaPage } from "../../_utils/useOaPage.js";
-
-  const pageKey = "AttendManage/overtime-apply";
-  const { config } = useOaPage(pageKey);
+  import ApprovalInstanceListPage from "../../_components/ApprovalInstanceListPage.vue";
+  import { APPROVAL_MODULE_KEYS } from "../../_utils/approvalModuleRegistry.js";
 </script>
diff --git a/src/pages/oa/HrManage/regular-apply/index.vue b/src/pages/oa/HrManage/regular-apply/index.vue
index ae962c6..e45364a 100644
--- a/src/pages/oa/HrManage/regular-apply/index.vue
+++ b/src/pages/oa/HrManage/regular-apply/index.vue
@@ -3,16 +3,10 @@
   璺敱锛�/pages/oa/HrManage/regular-apply/index
 -->
 <template>
-  <OaListPage v-if="config"
-              :page-key="pageKey"
-              :page-config="config" />
+  <ApprovalInstanceListPage :module-key="APPROVAL_MODULE_KEYS.REGULAR" />
 </template>
 
 <script setup>
-  /** OA - 浜轰簨绠$悊 - 杞鐢宠 */
-  import OaListPage from "../../_components/OaListPage.vue";
-  import { useOaPage } from "../../_utils/useOaPage.js";
-
-  const pageKey = "HrManage/regular-apply";
-  const { config } = useOaPage(pageKey);
+  import ApprovalInstanceListPage from "../../_components/ApprovalInstanceListPage.vue";
+  import { APPROVAL_MODULE_KEYS } from "../../_utils/approvalModuleRegistry.js";
 </script>
diff --git a/src/pages/oa/HrManage/transfer-apply/index.vue b/src/pages/oa/HrManage/transfer-apply/index.vue
index f3161bf..99ccacf 100644
--- a/src/pages/oa/HrManage/transfer-apply/index.vue
+++ b/src/pages/oa/HrManage/transfer-apply/index.vue
@@ -3,16 +3,10 @@
   璺敱锛�/pages/oa/HrManage/transfer-apply/index
 -->
 <template>
-  <OaListPage v-if="config"
-              :page-key="pageKey"
-              :page-config="config" />
+  <ApprovalInstanceListPage :module-key="APPROVAL_MODULE_KEYS.TRANSFER" />
 </template>
 
 <script setup>
-  /** OA - 浜轰簨绠$悊 - 璋冨矖鐢宠 */
-  import OaListPage from "../../_components/OaListPage.vue";
-  import { useOaPage } from "../../_utils/useOaPage.js";
-
-  const pageKey = "HrManage/transfer-apply";
-  const { config } = useOaPage(pageKey);
+  import ApprovalInstanceListPage from "../../_components/ApprovalInstanceListPage.vue";
+  import { APPROVAL_MODULE_KEYS } from "../../_utils/approvalModuleRegistry.js";
 </script>
diff --git a/src/pages/oa/HrManage/work-handover/index.vue b/src/pages/oa/HrManage/work-handover/index.vue
index c5d0e19..9fa24b6 100644
--- a/src/pages/oa/HrManage/work-handover/index.vue
+++ b/src/pages/oa/HrManage/work-handover/index.vue
@@ -3,16 +3,10 @@
   璺敱锛�/pages/oa/HrManage/work-handover/index
 -->
 <template>
-  <OaListPage v-if="config"
-              :page-key="pageKey"
-              :page-config="config" />
+  <ApprovalInstanceListPage :module-key="APPROVAL_MODULE_KEYS.WORK_HANDOVER" />
 </template>
 
 <script setup>
-  /** OA - 浜轰簨绠$悊 - 宸ヤ綔浜ゆ帴 */
-  import OaListPage from "../../_components/OaListPage.vue";
-  import { useOaPage } from "../../_utils/useOaPage.js";
-
-  const pageKey = "HrManage/work-handover";
-  const { config } = useOaPage(pageKey);
+  import ApprovalInstanceListPage from "../../_components/ApprovalInstanceListPage.vue";
+  import { APPROVAL_MODULE_KEYS } from "../../_utils/approvalModuleRegistry.js";
 </script>
diff --git a/src/pages/oa/_components/ApprovalInstanceListPage.vue b/src/pages/oa/_components/ApprovalInstanceListPage.vue
new file mode 100644
index 0000000..cdf0e39
--- /dev/null
+++ b/src/pages/oa/_components/ApprovalInstanceListPage.vue
@@ -0,0 +1,347 @@
+<!--
+  涓氬姟瀹℃壒鐢宠鍒楄〃锛堣浆姝�/璋冨矖/浜ゆ帴/璇峰亣/鍔犵彮锛�
+-->
+<template>
+  <view class="oa-approval-page">
+    <PageHeader :title="pageTitle"
+                @back="goBack" />
+
+    <view class="oa-toolbar">
+      <view class="oa-filter-chip"
+            :class="{ active: hasActiveFilter }"
+            @click="showFilter = true">
+        <up-icon name="list"
+                 size="18"
+                 :color="hasActiveFilter ? '#2979ff' : '#666'" />
+        <text class="chip-label">绛涢��</text>
+        <text v-if="filterSummary"
+              class="chip-value">{{ filterSummary }}</text>
+        <text v-else
+              class="chip-placeholder">鍏ㄩ儴鏉′欢</text>
+      </view>
+      <view class="oa-icon-btn"
+            @click="handleSearch">
+        <up-icon name="search"
+                 size="20"
+                 color="#666" />
+      </view>
+    </view>
+
+    <ApprovalModuleSearchPopup v-model:show="showFilter"
+                               :module-key="moduleKey"
+                               v-model="searchForm"
+                               @search="handleSearch"
+                               @reset="handleReset" />
+
+    <scroll-view class="oa-list-scroll"
+                 scroll-y
+                 :show-scrollbar="false"
+                 :style="{ height: listScrollHeight + 'px' }"
+                 @scrolltolower="loadMore">
+      <view v-if="displayList.length"
+            class="oa-card-list">
+        <view v-for="item in displayList"
+              :key="item.id"
+              class="oa-card"
+              @click="openDetail(item)">
+          <view class="oa-card-head">
+            <view class="oa-card-title-wrap">
+              <text class="oa-card-title">{{ cardTitle(item) }}</text>
+              <text v-if="item.instanceNo"
+                    class="oa-card-sub">{{ item.instanceNo }}</text>
+            </view>
+            <text :class="['oa-status', businessStatusClass(item.status)]">
+              {{ businessStatusText(item.status) }}
+            </text>
+          </view>
+
+          <view class="oa-card-body">
+            <view class="oa-info-grid">
+              <view v-for="(row, idx) in visibleDisplayRows(item)"
+                    :key="'f-' + idx"
+                    class="oa-info-row">
+                <text class="oa-info-label">{{ row.label }}</text>
+                <text class="oa-info-value">{{ row.value || "-" }}</text>
+              </view>
+              <view class="oa-info-row">
+                <text class="oa-info-label">鐢宠浜�</text>
+                <text class="oa-info-value">{{ item.applicantName || "-" }}</text>
+              </view>
+              <view class="oa-info-row">
+                <text class="oa-info-label">鐢宠鏃堕棿</text>
+                <text class="oa-info-value">{{ item.createTime || "-" }}</text>
+              </view>
+            </view>
+          </view>
+
+          <view v-if="canEditBusinessInstanceRow(item)"
+                class="oa-card-foot"
+                @click.stop>
+            <text class="oa-foot-btn btn-edit"
+                  @click="goEdit(item)">淇敼</text>
+            <text class="oa-foot-btn btn-delete"
+                  @click="confirmDelete(item)">鍒犻櫎</text>
+          </view>
+        </view>
+        <up-loadmore :status="pageStatus" />
+      </view>
+
+      <view v-else-if="!tableLoading"
+            class="oa-empty">
+        <up-empty mode="list"
+                  :text="`鏆傛棤${pageTitle}鏁版嵁`" />
+      </view>
+      <view v-if="tableLoading && !list.length"
+            class="oa-loading">
+        <up-loading-icon mode="circle" />
+      </view>
+    </scroll-view>
+
+    <view class="fab-button"
+          @click="handleAdd">
+      <up-icon name="plus"
+               size="28"
+               color="#ffffff" />
+    </view>
+  </view>
+</template>
+
+<script setup>
+  import { computed, onMounted, reactive, ref } from "vue";
+  import { onShow } from "@dcloudio/uni-app";
+  import PageHeader from "@/components/PageHeader.vue";
+  import ApprovalModuleSearchPopup from "./ApprovalModuleSearchPopup.vue";
+  import {
+    deleteApprovalInstance,
+    listApprovalInstancePage,
+  } from "@/api/oa/approvalInstance.js";
+  import { OA_NAV } from "@/config/oaPaths.js";
+  import { fetchApprovalTemplateTypes } from "../_utils/approvalTemplateType.js";
+  import {
+    getApprovalModuleConfig,
+    resolveModuleBusinessType,
+  } from "../_utils/approvalModuleRegistry.js";
+  import {
+    buildModuleListDto,
+    createModuleSearchForm,
+    filterRowsByModuleSearch,
+    filterRowsByModuleBusinessType,
+    formatDateRangeLabel,
+    getModuleSearchMeta,
+  } from "../_utils/approvalModuleListSearch.js";
+  import {
+    buildInstanceListParams,
+    businessStatusClass,
+    businessStatusText,
+    canEditBusinessInstanceRow,
+    EDIT_STORAGE_KEY,
+    mapInstanceListRow,
+    stashInstanceRow,
+    unwrapInstancePage,
+  } from "../_utils/approveListUtils.js";
+
+  const props = defineProps({
+    moduleKey: { type: String, required: true },
+  });
+
+  const moduleConfig = computed(() => getApprovalModuleConfig(props.moduleKey));
+  const pageTitle = computed(() => moduleConfig.value?.label || "鐢宠");
+
+  const showFilter = ref(false);
+  const searchForm = reactive(createModuleSearchForm(props.moduleKey));
+  const list = ref([]);
+  const tableLoading = ref(false);
+  const pageStatus = ref("loadmore");
+  const businessType = ref("");
+  const typeOptions = ref([]);
+
+  const page = reactive({ current: 1, size: 10, total: 0 });
+  const listScrollHeight = ref(400);
+
+  function calcListScrollHeight() {
+    const sys = uni.getSystemInfoSync();
+    const statusBar = sys.statusBarHeight || 0;
+    const navBar = 44;
+    const toolbar = 56;
+    const fabGap = 16;
+    listScrollHeight.value = Math.max(
+      200,
+      sys.windowHeight - statusBar - navBar - toolbar - fabGap
+    );
+  }
+
+  const displayList = computed(() => {
+    const byType = filterRowsByModuleBusinessType(
+      props.moduleKey,
+      list.value,
+      typeOptions.value
+    );
+    return filterRowsByModuleSearch(props.moduleKey, byType, searchForm);
+  });
+
+  const hasActiveFilter = computed(() => Boolean(filterSummary.value));
+
+  const filterSummary = computed(() => {
+    const parts = [];
+    const meta = getModuleSearchMeta(props.moduleKey);
+    for (const field of meta.fields || []) {
+      const val = searchForm[field.key];
+      if (field.type === "input" && (val || "").trim()) {
+        parts.push(`${field.label}:${String(val).trim()}`);
+      } else if (field.type === "daterange" && Array.isArray(val) && val[0]) {
+        parts.push(`${field.label}:${formatDateRangeLabel(val)}`);
+      } else if (field.type === "select" && val) {
+        const opt = (field.options || []).find(o => o.value === val);
+        parts.push(`${field.label}:${opt?.label || val}`);
+      } else if (field.type === "user" && val) {
+        parts.push(`${field.label}:宸查�塦);
+      }
+    }
+    return parts.join("锛�");
+  });
+
+  function cardTitle(item) {
+    return item.summary || item.title || pageTitle.value;
+  }
+
+  function visibleDisplayRows(item) {
+    const rows = item.displayRows || [];
+    return rows.slice(0, 2);
+  }
+
+  const buildListRequestParams = () => {
+    const extraDto = buildModuleListDto(props.moduleKey, searchForm);
+    return buildInstanceListParams({
+      page,
+      businessType: businessType.value,
+      extraDto,
+      searchForm,
+    });
+  };
+
+  const fetchList = async (reset = false) => {
+    if (reset) {
+      page.current = 1;
+      pageStatus.value = "loadmore";
+      list.value = [];
+    }
+    if (pageStatus.value === "loading" || pageStatus.value === "nomore") return;
+    if (!businessType.value && businessType.value !== 0) return;
+
+    pageStatus.value = "loading";
+    tableLoading.value = true;
+
+    try {
+      const res = await listApprovalInstancePage(buildListRequestParams());
+      const { records, total } = unwrapInstancePage(res);
+      const listFields = moduleConfig.value?.listFields || [];
+      const mapped = records.map(row => mapInstanceListRow(row, listFields));
+
+      if (page.current === 1) {
+        list.value = mapped;
+      } else {
+        list.value = [...list.value, ...mapped];
+      }
+      page.total = total;
+
+      if (list.value.length >= total || records.length < page.size) {
+        pageStatus.value = "nomore";
+      } else {
+        pageStatus.value = "loadmore";
+        page.current += 1;
+      }
+    } catch {
+      if (page.current === 1) list.value = [];
+      pageStatus.value = "loadmore";
+      uni.showToast({ title: `${pageTitle.value}鍔犺浇澶辫触`, icon: "none" });
+    } finally {
+      tableLoading.value = false;
+    }
+  };
+
+  const initBusinessType = async () => {
+    try {
+      typeOptions.value = await fetchApprovalTemplateTypes();
+      const resolved = resolveModuleBusinessType(props.moduleKey, typeOptions.value);
+      businessType.value =
+        resolved != null && resolved !== ""
+          ? resolved
+          : moduleConfig.value?.approvalType ?? "";
+    } catch {
+      businessType.value = moduleConfig.value?.approvalType ?? "";
+    }
+  };
+
+  const handleSearch = () => fetchList(true);
+  const handleReset = () => {
+    Object.assign(searchForm, createModuleSearchForm(props.moduleKey));
+    fetchList(true);
+  };
+  const loadMore = () => {
+    if (pageStatus.value === "loadmore") fetchList(false);
+  };
+  const goBack = () => uni.navigateBack();
+
+  const openDetail = item => {
+    if (!item?.id) return;
+    stashInstanceRow(item);
+    uni.navigateTo({
+      url: `${OA_NAV.approveListDetail}?id=${item.id}&from=business`,
+    });
+  };
+
+  const goEdit = item => {
+    if (!canEditBusinessInstanceRow(item)) {
+      uni.showToast({ title: "杩涜涓垨宸插畬鎴愮殑瀹℃壒涓嶅彲淇敼", icon: "none" });
+      return;
+    }
+    if (!item?.id) return;
+    uni.setStorageSync(EDIT_STORAGE_KEY, item);
+    stashInstanceRow(item);
+    uni.navigateTo({
+      url: `${OA_NAV.approveListApply}?id=${item.id}&moduleKey=${props.moduleKey}`,
+    });
+  };
+
+  const confirmDelete = item => {
+    if (!item?.id) return;
+    const title = item.title || item.templateName || item.instanceNo || "璇ュ鎵�";
+    uni.showModal({
+      title: "鍒犻櫎纭",
+      content: `纭畾瑕佸垹闄ゃ��${title}銆嶅悧锛熷垹闄ゅ悗涓嶅彲鎭㈠銆俙,
+      confirmText: "纭畾鍒犻櫎",
+      confirmColor: "#f56c6c",
+      success: async res => {
+        if (!res.confirm) return;
+        try {
+          await deleteApprovalInstance([item.id]);
+          uni.showToast({ title: "鍒犻櫎鎴愬姛", icon: "success" });
+          fetchList(true);
+        } catch {
+          uni.showToast({ title: "鍒犻櫎澶辫触", icon: "none" });
+        }
+      },
+    });
+  };
+
+  const handleAdd = () => {
+    uni.navigateTo({
+      url: `${OA_NAV.approveListTemplateSelect}?moduleKey=${props.moduleKey}`,
+    });
+  };
+
+  onMounted(() => {
+    calcListScrollHeight();
+  });
+
+  onShow(async () => {
+    calcListScrollHeight();
+    await initBusinessType();
+    fetchList(true);
+  });
+</script>
+
+<style scoped lang="scss">
+  @import "@/styles/sales-common.scss";
+  @import "../_styles/oa-approval-list.scss";
+</style>
diff --git a/src/pages/oa/_components/ApprovalModuleSearchPopup.vue b/src/pages/oa/_components/ApprovalModuleSearchPopup.vue
new file mode 100644
index 0000000..522bd02
--- /dev/null
+++ b/src/pages/oa/_components/ApprovalModuleSearchPopup.vue
@@ -0,0 +1,268 @@
+<!--
+  涓氬姟瀹℃壒鍒楄〃绛涢�夊脊绐�
+-->
+<template>
+  <up-popup :show="modelShow"
+            mode="bottom"
+            round
+            @close="closePopup">
+    <view class="oa-filter-popup">
+      <view class="oa-filter-head">绛涢�夋潯浠�</view>
+      <scroll-view scroll-y
+                   class="oa-filter-scroll">
+        <view v-for="field in fields"
+              :key="field.key"
+              class="oa-filter-field">
+          <text class="oa-field-label">{{ field.label }}</text>
+
+          <up-input v-if="field.type === 'input'"
+                    v-model="localForm[field.key]"
+                    :placeholder="field.placeholder || `璇疯緭鍏�${field.label}`"
+                    clearable
+                    border="surround" />
+
+          <view v-else-if="field.type === 'daterange'"
+                class="oa-picker-row"
+                @click="openDateRange(field.key)">
+            <text :class="{ placeholder: !dateLabel(field.key) }">
+              {{ dateLabel(field.key) || `璇烽�夋嫨${field.label}` }}
+            </text>
+            <up-icon name="calendar"
+                     size="18"
+                     color="#999" />
+          </view>
+
+          <view v-else-if="field.type === 'user'"
+                class="oa-picker-row"
+                @click="openUserPicker">
+            <text :class="{ placeholder: !userLabel }">
+              {{ userLabel || field.placeholder || '璇烽�夋嫨鐢宠浜�' }}
+            </text>
+            <up-icon name="arrow-down"
+                     size="14"
+                     color="#999" />
+          </view>
+
+          <view v-else-if="field.type === 'select'"
+                class="oa-picker-row"
+                @click="openSelect(field)">
+            <text :class="{ placeholder: !selectLabel(field) }">
+              {{ selectLabel(field) || `璇烽�夋嫨${field.label}` }}
+            </text>
+            <up-icon name="arrow-down"
+                     size="14"
+                     color="#999" />
+          </view>
+        </view>
+      </scroll-view>
+
+      <view class="oa-filter-actions">
+        <up-button text="閲嶇疆"
+                   shape="circle"
+                   @click="handleReset" />
+        <up-button type="primary"
+                   text="纭畾"
+                   shape="circle"
+                   @click="handleConfirm" />
+      </view>
+    </view>
+
+    <up-calendar :show="calendarShow"
+                 mode="range"
+                 :maxDate="maxDate"
+                 minDate="2020-01-01"
+                 :monthNum="24"
+                 @confirm="onDateConfirm"
+                 @close="calendarShow = false" />
+
+    <up-picker :show="pickerShow"
+               :columns="pickerColumns"
+               keyName="label"
+               @confirm="onPickerConfirm"
+               @cancel="pickerShow = false"
+               @close="pickerShow = false" />
+
+    <up-popup :show="userPickerShow"
+              mode="bottom"
+              round
+              @close="userPickerShow = false">
+      <view class="oa-user-popup">
+        <view class="oa-user-popup-title">閫夋嫨鐢宠浜�</view>
+        <up-input v-model="userKeyword"
+                  placeholder="鎼滅储濮撳悕鎴栫紪鍙�"
+                  clearable
+                  border="surround" />
+        <scroll-view scroll-y
+                     class="oa-user-list">
+          <view v-for="u in filteredUsers"
+                :key="u.userId ?? u.id"
+                class="oa-user-item"
+                @click="pickUser(u)">
+            <text>{{ userSelectLabel(u) }}</text>
+          </view>
+          <view v-if="!filteredUsers.length"
+                class="oa-user-empty">鏆傛棤鍖归厤鐢ㄦ埛</view>
+        </scroll-view>
+      </view>
+    </up-popup>
+  </up-popup>
+</template>
+
+<script setup>
+  import { computed, reactive, ref, watch } from "vue";
+  import dayjs from "dayjs";
+  import { userListNoPageByTenantId } from "@/api/system/user";
+  import {
+    formatDateRangeLabel,
+    getModuleSearchMeta,
+    resetModuleSearchForm,
+    userSelectLabel,
+  } from "../_utils/approvalModuleListSearch.js";
+
+  const props = defineProps({
+    show: { type: Boolean, default: false },
+    moduleKey: { type: String, required: true },
+    modelValue: { type: Object, default: () => ({}) },
+  });
+
+  const emit = defineEmits(["update:show", "update:modelValue", "search", "reset"]);
+
+  const modelShow = computed({
+    get: () => props.show,
+    set: v => emit("update:show", v),
+  });
+
+  function closePopup() {
+    emit("update:show", false);
+  }
+
+  const localForm = reactive({});
+  const calendarShow = ref(false);
+  const activeDateKey = ref("applyDateRange");
+  const maxDate = dayjs().format("YYYY-MM-DD");
+  const pickerShow = ref(false);
+  const pickerColumns = ref([[]]);
+  const activeSelectField = ref(null);
+  const userPickerShow = ref(false);
+  const userKeyword = ref("");
+  const allUsers = ref([]);
+
+  const fields = computed(() => getModuleSearchMeta(props.moduleKey).fields || []);
+
+  const userLabel = computed(() => {
+    const id = localForm.applicantId;
+    if (!id) return "";
+    const u = allUsers.value.find(x => String(x.userId ?? x.id) === String(id));
+    return u ? userSelectLabel(u) : String(id);
+  });
+
+  const filteredUsers = computed(() => {
+    const q = userKeyword.value.trim().toLowerCase();
+    const list = allUsers.value.filter(u => {
+      if (u.delFlag === "2" || u.delFlag === 2) return false;
+      if (u.status != null && String(u.status) !== "0") return false;
+      return true;
+    });
+    if (!q) return list.slice(0, 50);
+    return list
+      .filter(u => {
+        const nick = (u.nickName || "").toLowerCase();
+        const name = (u.userName || "").toLowerCase();
+        const id = String(u.userId ?? u.id ?? "");
+        return nick.includes(q) || name.includes(q) || id.includes(q);
+      })
+      .slice(0, 50);
+  });
+
+  function unwrapUsers(res) {
+    if (Array.isArray(res)) return res;
+    if (Array.isArray(res?.data)) return res.data;
+    if (Array.isArray(res?.rows)) return res.rows;
+    return [];
+  }
+
+  async function loadUsers() {
+    if (allUsers.value.length) return;
+    try {
+      const res = await userListNoPageByTenantId();
+      allUsers.value = unwrapUsers(res);
+    } catch {
+      allUsers.value = [];
+    }
+  }
+
+  watch(
+    () => props.show,
+    v => {
+      if (!v) return;
+      Object.assign(localForm, props.modelValue || {});
+    }
+  );
+
+  function dateLabel(key) {
+    return formatDateRangeLabel(localForm[key]);
+  }
+
+  function openDateRange(key) {
+    activeDateKey.value = key;
+    calendarShow.value = true;
+  }
+
+  function onDateConfirm(e) {
+    if (!e?.length) {
+      calendarShow.value = false;
+      return;
+    }
+    localForm[activeDateKey.value] = [e[0], e[e.length - 1]];
+    calendarShow.value = false;
+  }
+
+  function selectLabel(field) {
+    const val = localForm[field.key];
+    if (!val) return "";
+    const opt = (field.options || []).find(o => o.value === val);
+    return opt?.label || String(val);
+  }
+
+  function openSelect(field) {
+    activeSelectField.value = field;
+    pickerColumns.value = [field.options || []];
+    pickerShow.value = true;
+  }
+
+  function onPickerConfirm(e) {
+    const item = e?.value?.[0];
+    if (activeSelectField.value?.key && item) {
+      localForm[activeSelectField.value.key] = item.value;
+    }
+    pickerShow.value = false;
+  }
+
+  async function openUserPicker() {
+    await loadUsers();
+    userKeyword.value = "";
+    userPickerShow.value = true;
+  }
+
+  function pickUser(u) {
+    localForm.applicantId = u.userId ?? u.id ?? "";
+    userPickerShow.value = false;
+  }
+
+  function handleReset() {
+    resetModuleSearchForm(props.moduleKey, localForm);
+    emit("update:modelValue", { ...localForm });
+    emit("reset", { ...localForm });
+    emit("update:show", false);
+  }
+
+  function handleConfirm() {
+    emit("update:modelValue", { ...localForm });
+    emit("search", { ...localForm });
+    emit("update:show", false);
+  }
+</script>
+
+<style scoped lang="scss">
+  @import "../_styles/oa-approval-list.scss";
+</style>
diff --git a/src/pages/oa/_styles/oa-approval-list.scss b/src/pages/oa/_styles/oa-approval-list.scss
new file mode 100644
index 0000000..b130fbc
--- /dev/null
+++ b/src/pages/oa/_styles/oa-approval-list.scss
@@ -0,0 +1,409 @@
+// OA 瀹℃壒鍒楄〃锛堜笟鍔$敵璇� + 瀹℃壒鍒楄〃锛夌粺涓�鏍峰紡
+
+.oa-approval-page {
+  min-height: 100vh;
+  background: #f2f4f7;
+  display: flex;
+  flex-direction: column;
+  box-sizing: border-box;
+}
+
+.oa-list-scroll {
+  width: 100%;
+  box-sizing: border-box;
+}
+
+.oa-toolbar {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  padding: 10px 16px;
+  background: #fff;
+  border-bottom: 1px solid #eef0f3;
+}
+
+.oa-filter-chip {
+  flex: 1;
+  min-height: 40px;
+  padding: 0 14px;
+  background: #f5f7fa;
+  border-radius: 20px;
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  border: 1px solid transparent;
+
+  &.active {
+    background: #ecf3ff;
+    border-color: #b3d4ff;
+  }
+
+  .chip-label {
+    font-size: 14px;
+    color: #333;
+    font-weight: 500;
+    flex-shrink: 0;
+  }
+
+  .chip-value {
+    flex: 1;
+    font-size: 13px;
+    color: #666;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  .chip-placeholder {
+    flex: 1;
+    font-size: 13px;
+    color: #aaa;
+  }
+}
+
+.oa-icon-btn {
+  width: 40px;
+  height: 40px;
+  border-radius: 20px;
+  background: #f5f7fa;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+
+  &:active {
+    background: #e8ebf0;
+  }
+}
+
+.oa-card-list {
+  padding: 12px 16px 4px;
+}
+
+.oa-card {
+  background: #fff;
+  border-radius: 12px;
+  margin-bottom: 12px;
+  overflow: hidden;
+  box-shadow: 0 1px 4px rgba(15, 23, 42, 0.06);
+
+  &:active {
+    opacity: 0.92;
+  }
+}
+
+.oa-card-head {
+  padding: 14px 14px 10px;
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 10px;
+}
+
+.oa-card-title-wrap {
+  flex: 1;
+  min-width: 0;
+}
+
+.oa-card-title {
+  display: block;
+  font-size: 15px;
+  font-weight: 600;
+  color: #1a1a1a;
+  line-height: 1.4;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.oa-card-sub {
+  display: block;
+  margin-top: 4px;
+  font-size: 12px;
+  color: #8c8c8c;
+}
+
+.oa-status {
+  flex-shrink: 0;
+  font-size: 11px;
+  line-height: 1;
+  padding: 5px 8px;
+  border-radius: 4px;
+  font-weight: 500;
+
+  &.status-pending {
+    color: #d46b08;
+    background: #fff7e6;
+  }
+
+  &.status-approved {
+    color: #389e0d;
+    background: #f6ffed;
+  }
+
+  &.status-rejected {
+    color: #cf1322;
+    background: #fff1f0;
+  }
+
+  &.status-draft {
+    color: #595959;
+    background: #f5f5f5;
+  }
+
+  &.status-cancelled {
+    color: #8c8c8c;
+    background: #fafafa;
+  }
+}
+
+.oa-card-body {
+  padding: 0 14px 12px;
+}
+
+.oa-info-grid {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.oa-info-row {
+  display: flex;
+  align-items: baseline;
+  font-size: 13px;
+  line-height: 1.45;
+}
+
+.oa-info-label {
+  width: 72px;
+  flex-shrink: 0;
+  color: #8c8c8c;
+}
+
+.oa-info-value {
+  flex: 1;
+  color: #333;
+  word-break: break-all;
+}
+
+.oa-card-foot {
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  gap: 10px;
+  padding: 10px 14px;
+  background: #fafbfc;
+  border-top: 1px solid #f0f2f5;
+}
+
+.oa-foot-btn {
+  min-width: 64px;
+  height: 32px;
+  line-height: 32px;
+  padding: 0 14px;
+  font-size: 13px;
+  border-radius: 16px;
+  text-align: center;
+
+  &.btn-edit {
+    color: #2979ff;
+    background: #ecf3ff;
+  }
+
+  &.btn-delete {
+    color: #ff4d4f;
+    background: #fff1f0;
+  }
+
+  &.btn-approve {
+    color: #fff;
+    background: #2979ff;
+  }
+}
+
+.oa-empty,
+.oa-loading {
+  padding: 48px 20px;
+}
+
+.oa-loading {
+  display: flex;
+  justify-content: center;
+}
+
+// 绛涢�夊脊绐�
+.oa-filter-popup {
+  padding: 16px 16px calc(16px + env(safe-area-inset-bottom));
+  max-height: 72vh;
+  display: flex;
+  flex-direction: column;
+  background: #fff;
+}
+
+.oa-filter-head {
+  text-align: center;
+  font-size: 16px;
+  font-weight: 600;
+  color: #1a1a1a;
+  padding-bottom: 12px;
+  border-bottom: 1px solid #f0f2f5;
+  margin-bottom: 12px;
+}
+
+.oa-filter-scroll {
+  max-height: 50vh;
+}
+
+.oa-filter-field {
+  margin-bottom: 16px;
+}
+
+.oa-field-label {
+  display: block;
+  font-size: 13px;
+  color: #666;
+  margin-bottom: 8px;
+}
+
+.oa-picker-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  min-height: 44px;
+  padding: 0 14px;
+  background: #f5f7fa;
+  border-radius: 8px;
+  font-size: 14px;
+  color: #333;
+
+  .placeholder {
+    color: #bbb;
+  }
+}
+
+.oa-filter-actions {
+  display: flex;
+  gap: 12px;
+  margin-top: 16px;
+  padding-top: 12px;
+}
+
+.oa-filter-actions :deep(.u-button) {
+  flex: 1;
+  border-radius: 22px !important;
+}
+
+.oa-user-popup {
+  padding: 16px;
+  max-height: 60vh;
+  background: #fff;
+}
+
+.oa-user-popup-title {
+  text-align: center;
+  font-weight: 600;
+  font-size: 16px;
+  margin-bottom: 12px;
+}
+
+.oa-user-list {
+  max-height: 40vh;
+  margin-top: 10px;
+}
+
+.oa-user-item {
+  padding: 14px 4px;
+  font-size: 14px;
+  color: #333;
+  border-bottom: 1px solid #f0f2f5;
+}
+
+.oa-user-empty {
+  text-align: center;
+  color: #999;
+  padding: 24px;
+  font-size: 13px;
+}
+
+// 璇︽儏 / 瀹℃壒澶勭悊绛夐〉搴曢儴鎿嶄綔鏍�
+.oa-page-footer {
+  position: fixed;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  z-index: 100;
+  display: flex;
+  flex-direction: row;
+  flex-wrap: nowrap;
+  align-items: center;
+  gap: 10px;
+  padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
+  background: #fff;
+  border-top: 1px solid #eef0f3;
+  box-shadow: 0 -4px 16px rgba(15, 23, 42, 0.08);
+}
+
+.oa-footer-btn {
+  flex: 1;
+  min-width: 0;
+  height: 44px;
+  line-height: 44px;
+  text-align: center;
+  font-size: 15px;
+  font-weight: 500;
+  border-radius: 22px;
+  border: none;
+
+  &:active {
+    opacity: 0.85;
+  }
+
+  &.btn-default {
+    color: #666;
+    background: #f2f4f7;
+  }
+
+  &.btn-primary {
+    color: #fff;
+    background: linear-gradient(140deg, #00baff 0%, #006cfb 100%);
+    box-shadow: 0 4px 10px rgba(0, 108, 251, 0.25);
+  }
+
+  &.btn-warn {
+    color: #fff;
+    background: linear-gradient(140deg, #ffb347 0%, #ff9800 100%);
+    box-shadow: 0 4px 10px rgba(255, 152, 0, 0.25);
+  }
+
+  &.btn-success {
+    color: #fff;
+    background: linear-gradient(140deg, #52c41a 0%, #389e0d 100%);
+    box-shadow: 0 4px 10px rgba(56, 158, 13, 0.25);
+  }
+
+  &.btn-danger {
+    color: #fff;
+    background: linear-gradient(140deg, #ff7875 0%, #f5222d 100%);
+    box-shadow: 0 4px 10px rgba(245, 34, 45, 0.2);
+  }
+
+  &.is-disabled {
+    opacity: 0.5;
+    pointer-events: none;
+  }
+}
+
+.oa-detail-page {
+  min-height: 100vh;
+  background: #f2f4f7;
+  display: flex;
+  flex-direction: column;
+}
+
+.oa-detail-scroll {
+  flex: 1;
+  height: 0;
+  padding: 10px 12px;
+  padding-bottom: calc(76px + env(safe-area-inset-bottom));
+  box-sizing: border-box;
+}
diff --git a/src/pages/oa/_utils/approvalModuleApplyExtras.js b/src/pages/oa/_utils/approvalModuleApplyExtras.js
new file mode 100644
index 0000000..820d584
--- /dev/null
+++ b/src/pages/oa/_utils/approvalModuleApplyExtras.js
@@ -0,0 +1,293 @@
+import dayjs from "dayjs";
+import {
+  APPROVAL_MODULE_KEYS,
+  APPROVAL_MODULE_REGISTRY,
+  getModuleMatchingBusinessTypes,
+} from "./approvalModuleRegistry.js";
+import { matchBusinessTypeValue } from "./approvalTemplateType.js";
+import { parseDatetimerangeValue } from "./approvalFormField.js";
+
+/** 浜哄憳涓嬫媺瀛楁璇嗗埆锛堜笌 Web SELECT_OPTION_SOURCE.USER 绛変环锛� */
+export function isUserSelectField(field) {
+  const src = String(field?.optionSource ?? "").toLowerCase();
+  return (
+    src === "user" ||
+    src === "personnel" ||
+    src === "userlist" ||
+    (field?.type === "select" && String(field?.label || "").includes("鐢宠浜�"))
+  );
+}
+
+export function findApplicantTemplateField(fields = []) {
+  return (
+    fields.find(f => String(f?.label || "").includes("鐢宠浜�")) ||
+    fields.find(f => isUserSelectField(f)) ||
+    null
+  );
+}
+
+/* ---------- 璇峰亣 ---------- */
+
+export function isLeaveBalanceField(field) {
+  const label = String(field?.label || "");
+  return label.includes("鍋囨湡浣欓") || field?.key === "leaveBalanceDays";
+}
+
+export function isLeaveDurationField(field) {
+  const label = String(field?.label || "");
+  return label.includes("璇峰亣鏃堕暱") || field?.key === "leaveDurationDays";
+}
+
+export function displayLeaveTemplateFields(fields = []) {
+  return (fields || []).filter(
+    f => !isLeaveBalanceField(f) && !isLeaveDurationField(f)
+  );
+}
+
+export function findLeaveTimeTemplateField(fields = []) {
+  return (
+    fields.find(
+      f => f?.type === "datetimerange" && String(f?.label || "").includes("璇峰亣鏃堕棿")
+    ) ||
+    fields.find(f => f?.type === "datetimerange" && f?.key === "dateRange") ||
+    fields.find(f => f?.type === "datetimerange") ||
+    null
+  );
+}
+
+export function resolveTimeRangeFromPayload(payload, timeField) {
+  if (!timeField?.key) return { start: "", end: "" };
+  const val = payload?.[timeField.key];
+  if (Array.isArray(val) && val.length >= 2) {
+    return { start: val[0] || "", end: val[1] || "" };
+  }
+  return parseDatetimerangeValue(val);
+}
+
+export function computeLeaveDays(startStr, endStr) {
+  if (!startStr || !endStr) return null;
+  const t0 = dayjs(startStr);
+  const t1 = dayjs(endStr);
+  if (!t0.isValid() || !t1.isValid() || !t1.isAfter(t0)) return null;
+  const days = t1.diff(t0, "millisecond") / (24 * 60 * 60 * 1000);
+  return Math.round(days * 100) / 100;
+}
+
+export function computeLeaveDurationDisplay(fields, formPayload) {
+  const timeField = findLeaveTimeTemplateField(fields);
+  const { start, end } = resolveTimeRangeFromPayload(formPayload, timeField);
+  const d = computeLeaveDays(start, end);
+  return d == null ? "" : String(d);
+}
+
+export function validateLeaveBeforeSubmit(fields, formPayload) {
+  const timeField = findLeaveTimeTemplateField(fields);
+  const { start, end } = resolveTimeRangeFromPayload(formPayload, timeField);
+  if (computeLeaveDays(start, end) == null) {
+    return "璇锋鏌ユā鏉夸腑鐨勮鍋囨椂闂达紝缁撴潫鏃堕棿椤绘櫄浜庡紑濮嬫椂闂�";
+  }
+  return "";
+}
+
+/* ---------- 鍔犵彮 ---------- */
+
+export function isOvertimeDurationField(field) {
+  const label = String(field?.label || "");
+  return label.includes("鍔犵彮鏃堕暱") || field?.key === "overtimeHours";
+}
+
+export function displayOvertimeTemplateFields(fields = []) {
+  return (fields || []).filter(f => !isOvertimeDurationField(f));
+}
+
+export function findOvertimeTimeTemplateField(fields = []) {
+  return (
+    fields.find(
+      f => f?.type === "datetimerange" && String(f?.label || "").includes("鍔犵彮鏃堕棿")
+    ) ||
+    fields.find(f => f?.type === "datetimerange") ||
+    null
+  );
+}
+
+export function computeOvertimeHours(startStr, endStr) {
+  if (!startStr || !endStr) return null;
+  const t0 = dayjs(startStr);
+  const t1 = dayjs(endStr);
+  if (!t0.isValid() || !t1.isValid() || !t1.isAfter(t0)) return null;
+  return Math.round((t1.diff(t0, "millisecond") / 3600000) * 100) / 100;
+}
+
+export function computeOvertimeHoursDisplay(fields, formPayload) {
+  const field = findOvertimeTimeTemplateField(fields);
+  const { start, end } = resolveTimeRangeFromPayload(formPayload, field);
+  const h = computeOvertimeHours(start, end);
+  return h == null ? "" : String(h);
+}
+
+export function validateOvertimeBeforeSubmit(fields, formPayload) {
+  const field = findOvertimeTimeTemplateField(fields);
+  const { start, end } = resolveTimeRangeFromPayload(formPayload, field);
+  if (computeOvertimeHours(start, end) == null) {
+    return "璇锋鏌ユā鏉夸腑鐨勫姞鐝椂闂达紝缁撴潫鏃堕棿椤绘櫄浜庡紑濮嬫椂闂�";
+  }
+  return "";
+}
+
+/* ---------- 璋冨矖 ---------- */
+
+export function isOriginalPostField(field) {
+  const label = String(field?.label || "");
+  return (
+    label.includes("鍘熷矖浣�") ||
+    field?.key === "originalPost" ||
+    field?.key === "originalPostName" ||
+    field?.key === "originalPostId"
+  );
+}
+
+export function displayTransferTemplateFields(fields = []) {
+  return (fields || []).filter(f => !isOriginalPostField(f));
+}
+
+export function unwrapUserArray(payload) {
+  if (Array.isArray(payload)) return payload;
+  if (payload?.data && Array.isArray(payload.data)) return payload.data;
+  if (payload?.rows && Array.isArray(payload.rows)) return payload.rows;
+  return [];
+}
+
+export function isActiveUser(u) {
+  if (u?.delFlag === "2" || u?.delFlag === 2) return false;
+  if (u?.status == null) return true;
+  return String(u.status) === "0";
+}
+
+export function firstPostId(user) {
+  if (!user) return undefined;
+  if (Array.isArray(user.postIds) && user.postIds.length) return user.postIds[0];
+  if (user.postId != null && user.postId !== "") return user.postId;
+  return undefined;
+}
+
+export function buildPostIdToNameMap(postRows = []) {
+  const m = {};
+  for (const p of postRows) {
+    const id = p.postId ?? p.value ?? p.id;
+    if (id != null && id !== "") {
+      m[String(id)] = p.postName ?? p.label ?? p.name ?? "";
+    }
+  }
+  return m;
+}
+
+export function resolveOriginalPostName(user, postIdToName = {}) {
+  if (!user) return "";
+  const nameStr = (user.postName ?? user.postname ?? "").toString().trim();
+  if (nameStr) return nameStr;
+  if (Array.isArray(user.posts) && user.posts.length) {
+    return (user.posts[0].postName ?? "").toString() || "鏈懡鍚嶅矖浣�";
+  }
+  const pid = firstPostId(user);
+  if (pid != null && pid !== "") {
+    const n = postIdToName[String(pid)] || "";
+    return n || "褰撳墠宀椾綅锛堟湭鍦ㄥ矖浣嶅瓧鍏镐腑锛�";
+  }
+  return "鏈垎閰嶅矖浣�";
+}
+
+export function userById(users, id) {
+  if (id == null || id === "") return undefined;
+  return (users || []).find(u => String(u.userId ?? u.id) === String(id));
+}
+
+/** 鎸� moduleKey 杩囨护妯℃澘濉姤椤� */
+export function displayTemplateFieldsByModule(moduleKey, fields = []) {
+  if (moduleKey === APPROVAL_MODULE_KEYS.LEAVE) {
+    return displayLeaveTemplateFields(fields);
+  }
+  if (moduleKey === APPROVAL_MODULE_KEYS.OVERTIME) {
+    return displayOvertimeTemplateFields(fields);
+  }
+  if (moduleKey === APPROVAL_MODULE_KEYS.TRANSFER) {
+    return displayTransferTemplateFields(fields);
+  }
+  return fields || [];
+}
+
+/** 淇濆瓨鍓嶅皢涓氬姟鎵╁睍瀛楁鍐欏叆 formValues */
+export function syncModuleExtrasToFormValues(moduleKey, formValues, extras, fields) {
+  if (!moduleKey || !formValues) return;
+  if (moduleKey === APPROVAL_MODULE_KEYS.LEAVE) {
+    if (extras.leaveBalanceDays != null && extras.leaveBalanceDays !== "") {
+      formValues.leaveBalanceDays = extras.leaveBalanceDays;
+    }
+    const days = computeLeaveDurationDisplay(fields, formValues);
+    if (days) formValues.leaveDurationDays = days;
+  }
+  if (moduleKey === APPROVAL_MODULE_KEYS.OVERTIME) {
+    const hours = computeOvertimeHoursDisplay(fields, formValues);
+    if (hours) formValues.overtimeHours = hours;
+  }
+  if (moduleKey === APPROVAL_MODULE_KEYS.TRANSFER && extras.originalPostName) {
+    formValues.originalPostName = extras.originalPostName;
+    formValues.originalPost = extras.originalPostName;
+  }
+}
+
+/** 涓氬姟鎵╁睍鏍¢獙 */
+export function validateModuleExtras(moduleKey, fields, formPayload, extras) {
+  if (moduleKey === APPROVAL_MODULE_KEYS.LEAVE) {
+    if (
+      extras.leaveBalanceDays == null ||
+      extras.leaveBalanceDays === "" ||
+      Number.isNaN(Number(extras.leaveBalanceDays))
+    ) {
+      return "璇峰~鍐欏亣鏈熶綑棰�";
+    }
+    const msg = validateLeaveBeforeSubmit(fields, formPayload);
+    if (msg) return msg;
+  }
+  if (moduleKey === APPROVAL_MODULE_KEYS.OVERTIME) {
+    const msg = validateOvertimeBeforeSubmit(fields, formPayload);
+    if (msg) return msg;
+  }
+  return "";
+}
+
+/** 浠庡疄渚� businessType 鎺ㄦ柇 moduleKey锛堢紪杈戝叆鍙f湭甯� moduleKey 鏃讹級 */
+export function inferModuleKeyFromRow(row, typeOptions = []) {
+  const bt = row?.businessType;
+  if (bt == null || bt === "") return "";
+  for (const key of Object.values(APPROVAL_MODULE_KEYS)) {
+    const types = getModuleMatchingBusinessTypes(key, typeOptions);
+    if (types.some(t => matchBusinessTypeValue(t, bt))) return key;
+    const cfg = APPROVAL_MODULE_REGISTRY[key];
+    if (cfg && matchBusinessTypeValue(cfg.approvalType, bt)) return key;
+  }
+  return "";
+}
+
+/** 缂栬緫鍥炴樉锛氫粠瀹炰緥琛屾仮澶嶆墿灞曞瓧娈� */
+export function loadModuleExtrasFromRow(moduleKey, row, formPayload) {
+  const extras = {
+    leaveBalanceDays: undefined,
+    originalPostName: "",
+  };
+  if (!moduleKey || !row) return extras;
+
+  const payload = formPayload || {};
+  if (moduleKey === APPROVAL_MODULE_KEYS.LEAVE) {
+    const v = payload.leaveBalanceDays ?? row.leaveBalanceDays;
+    extras.leaveBalanceDays =
+      v != null && v !== "" ? Number(v) : undefined;
+  }
+  if (moduleKey === APPROVAL_MODULE_KEYS.TRANSFER) {
+    extras.originalPostName =
+      payload.originalPostName ||
+      payload.originalPost ||
+      row.originalPostName ||
+      "";
+  }
+  return extras;
+}
diff --git a/src/pages/oa/_utils/approvalModuleListSearch.js b/src/pages/oa/_utils/approvalModuleListSearch.js
new file mode 100644
index 0000000..45a7d43
--- /dev/null
+++ b/src/pages/oa/_utils/approvalModuleListSearch.js
@@ -0,0 +1,319 @@
+import {
+  APPROVAL_MODULE_KEYS,
+  APPROVAL_MODULE_REGISTRY,
+  getApprovalModuleConfig,
+  getModuleMatchingBusinessTypes,
+} from "./approvalModuleRegistry.js";
+import { parseApprovalFormConfig } from "./approvalFormField.js";
+import { matchBusinessTypeValue } from "./approvalTemplateType.js";
+
+/** 涓� Web leave-apply LEAVE_TYPE_OPTIONS 涓�鑷� */
+export const LEAVE_TYPE_OPTIONS = [
+  { label: "骞村亣", value: "annual" },
+  { label: "鐥呭亣", value: "sick" },
+  { label: "浜嬪亣", value: "personal" },
+  { label: "濠氬亣", value: "marriage" },
+  { label: "浜у亣", value: "maternity" },
+  { label: "鍝轰钩鍋�", value: "nursing" },
+  { label: "鎱板攣鍋�", value: "condolence" },
+  { label: "璋冧紤", value: "compensatory" },
+];
+
+/** 涓� Web overtime-apply OVERTIME_TYPE_OPTIONS 涓�鑷� */
+export const OVERTIME_TYPE_OPTIONS = [
+  { label: "宸ヤ綔鏃ュ姞鐝�", value: "weekday" },
+  { label: "浼戞伅鏃ュ姞鐝�", value: "weekend" },
+  { label: "娉曞畾鑺傚亣鏃ュ姞鐝�", value: "holiday" },
+];
+
+export const HANDOVER_STATUS_OPTIONS = [
+  { label: "杩涜涓�", value: "in_progress" },
+  { label: "宸插畬鎴�", value: "completed" },
+  { label: "宸查��鍥�", value: "returned" },
+];
+
+export const HANDOVER_TYPE_OPTIONS = [
+  { label: "绂昏亴浜ゆ帴", value: "resignation" },
+  { label: "璋冨矖浜ゆ帴", value: "transfer" },
+];
+
+function buildFormPayloadFromFields(fields = []) {
+  const payload = {};
+  for (const f of fields) {
+    if (!f?.key) continue;
+    const val = f.value ?? f.defaultValue;
+    if (val !== undefined && val !== null && val !== "") {
+      payload[f.key] = val;
+    }
+  }
+  return payload;
+}
+
+/** 瑙f瀽瀹炰緥 formConfig / formPayload锛堜笌 Web resolveInstanceFormFields 瀵归綈锛� */
+export function resolveInstanceFormPayload(row) {
+  const cfg = parseApprovalFormConfig(row?.formConfig);
+  const fields = (row?.formFieldDefs?.length ? row.formFieldDefs : cfg.fields) || [];
+  const formPayload = {
+    ...(fields.length ? buildFormPayloadFromFields(fields) : {}),
+    ...cfg.formPayload,
+    ...(row?.formPayload || {}),
+  };
+  return { fields, formPayload };
+}
+
+export function getRowPayloadValue(row, keys) {
+  const keyList = Array.isArray(keys) ? keys : [keys];
+  const { formPayload } = resolveInstanceFormPayload(row);
+  for (const k of keyList) {
+    if (row?.[k] != null && row[k] !== "") return row[k];
+    if (formPayload[k] != null && formPayload[k] !== "") return formPayload[k];
+  }
+  return "";
+}
+
+function pickDateRange(searchForm) {
+  const range =
+    searchForm?.createTimeRange ??
+    searchForm?.applyDateRange ??
+    searchForm?.transferDateRange;
+  if (!Array.isArray(range) || !range[0]) return {};
+  const out = { createTimeStart: range[0] };
+  if (range[1]) out.createTimeEnd = range[1];
+  return out;
+}
+
+/** 鍚勬ā鍧楅粯璁ゆ煡璇㈣〃鍗曪紙涓� Web searchForm 瀛楁涓�鑷达級 */
+export function createModuleSearchForm(moduleKey) {
+  switch (moduleKey) {
+    case APPROVAL_MODULE_KEYS.REGULAR:
+      return { applicantName: "", applyDateRange: null };
+    case APPROVAL_MODULE_KEYS.TRANSFER:
+      return { applicantId: "", transferDateRange: null };
+    case APPROVAL_MODULE_KEYS.WORK_HANDOVER:
+      return { applicantId: "", handoverStatus: "", handoverType: "" };
+    case APPROVAL_MODULE_KEYS.LEAVE:
+      return { applicantKeyword: "", leaveType: "" };
+    case APPROVAL_MODULE_KEYS.OVERTIME:
+      return { applicantKeyword: "", overtimeType: "" };
+    default:
+      return {};
+  }
+}
+
+/** 鏈嶅姟绔� listPage DTO 鐗囨锛堜笌 Web buildExtraListParams + buildApprovalInstanceListParams 涓�鑷达級 */
+export function buildModuleListDto(moduleKey, searchForm = {}) {
+  const sf = searchForm || {};
+  const dto = { ...pickDateRange(sf) };
+
+  switch (moduleKey) {
+    case APPROVAL_MODULE_KEYS.REGULAR: {
+      const name = (sf.applicantName || "").trim();
+      if (name) dto.applicantName = name;
+      break;
+    }
+    case APPROVAL_MODULE_KEYS.TRANSFER:
+      break;
+    case APPROVAL_MODULE_KEYS.WORK_HANDOVER:
+      break;
+    case APPROVAL_MODULE_KEYS.LEAVE:
+    case APPROVAL_MODULE_KEYS.OVERTIME:
+      break;
+    default:
+      break;
+  }
+  return dto;
+}
+
+function matchApplicantKeyword(row, keyword) {
+  const kw = (keyword || "").trim().toLowerCase();
+  if (!kw) return true;
+  const parts = [
+    row?.applicantName,
+    row?.applicantNo,
+    row?.applicantId,
+    getRowPayloadValue(row, ["applicant", "applicantName", "applicantId"]),
+  ]
+    .filter(v => v != null && v !== "")
+    .map(v => String(v).toLowerCase());
+  return parts.some(p => p.includes(kw));
+}
+
+function matchSelectValue(row, keys, expected) {
+  if (!expected) return true;
+  const raw = getRowPayloadValue(row, keys);
+  return String(raw) === String(expected);
+}
+
+function matchApplicantId(row, applicantId) {
+  if (!applicantId) return true;
+  const id = String(applicantId);
+  if (row?.applicantId != null && String(row.applicantId) === id) return true;
+  const payloadApplicant = getRowPayloadValue(row, [
+    "applicant",
+    "applicantId",
+    "applicantUserId",
+  ]);
+  return String(payloadApplicant) === id;
+}
+
+/** 鎸夋ā鍧� businessType / 鏍囬褰掑睘杩囨护锛堟湇鍔$鏈敓鏁堟椂鐨勫厹搴曪級 */
+export function filterRowsByModuleBusinessType(moduleKey, rows, typeOptions = []) {
+  const cfg = getApprovalModuleConfig(moduleKey);
+  if (!cfg) return rows;
+
+  const types = getModuleMatchingBusinessTypes(moduleKey, typeOptions);
+  const myLabels = [cfg.label, ...(cfg.typeLabels || [])].filter(Boolean);
+
+  return (rows || []).filter(row => {
+    if (types.length && row?.businessType != null && row.businessType !== "") {
+      if (types.some(t => matchBusinessTypeValue(row.businessType, t))) {
+        return true;
+      }
+    }
+
+    const title = String(row?.title || row?.templateName || "").trim();
+    if (title) {
+      if (myLabels.some(l => title === l || title.includes(l))) return true;
+      for (const [key, other] of Object.entries(APPROVAL_MODULE_REGISTRY)) {
+        if (key === moduleKey) continue;
+        const otherLabels = [other.label, ...(other.typeLabels || [])].filter(Boolean);
+        if (otherLabels.some(l => title === l || (l.length > 2 && title.includes(l)))) {
+          return false;
+        }
+      }
+    }
+
+    return types.length === 0;
+  });
+}
+
+/** 鍓嶇绛涢�夛紙Web 鏈笅鍙戞帴鍙g殑瀛楁涓� Web 琛屼负涓�鑷达級 */
+export function filterRowsByModuleSearch(moduleKey, rows, searchForm = {}) {
+  const sf = searchForm || {};
+  const list = Array.isArray(rows) ? rows : [];
+
+  switch (moduleKey) {
+    case APPROVAL_MODULE_KEYS.TRANSFER:
+      return list.filter(
+        row =>
+          matchApplicantId(row, sf.applicantId) &&
+          matchApplicantKeyword(row, sf.applicantKeyword)
+      );
+    case APPROVAL_MODULE_KEYS.WORK_HANDOVER:
+      return list.filter(
+        row =>
+          matchApplicantId(row, sf.applicantId) &&
+          matchSelectValue(row, ["handoverStatus", "浜ゆ帴鐘舵��"], sf.handoverStatus) &&
+          matchSelectValue(row, ["handoverType", "浜ゆ帴绫诲瀷"], sf.handoverType)
+      );
+    case APPROVAL_MODULE_KEYS.LEAVE:
+      return list.filter(
+        row =>
+          matchApplicantKeyword(row, sf.applicantKeyword) &&
+          matchSelectValue(row, ["leaveType", "璇峰亣绫诲瀷"], sf.leaveType)
+      );
+    case APPROVAL_MODULE_KEYS.OVERTIME:
+      return list.filter(
+        row =>
+          matchApplicantKeyword(row, sf.applicantKeyword) &&
+          matchSelectValue(row, ["overtimeType", "鍔犵彮绫诲瀷"], sf.overtimeType)
+      );
+    default:
+      return list;
+  }
+}
+
+/** 妯″潡绛涢�� UI 閰嶇疆 */
+export function getModuleSearchMeta(moduleKey) {
+  switch (moduleKey) {
+    case APPROVAL_MODULE_KEYS.REGULAR:
+      return {
+        fields: [
+          { key: "applicantName", type: "input", label: "鐢宠浜�", placeholder: "璇疯緭鍏ョ敵璇蜂汉" },
+          { key: "applyDateRange", type: "daterange", label: "鐢宠鏃ユ湡" },
+        ],
+      };
+    case APPROVAL_MODULE_KEYS.TRANSFER:
+      return {
+        fields: [
+          { key: "applicantId", type: "user", label: "鐢宠浜�", placeholder: "璇烽�夋嫨鐢宠浜�" },
+          { key: "transferDateRange", type: "daterange", label: "杞矖鏃堕棿" },
+        ],
+      };
+    case APPROVAL_MODULE_KEYS.WORK_HANDOVER:
+      return {
+        fields: [
+          { key: "applicantId", type: "user", label: "鐢宠浜�", placeholder: "璇烽�夋嫨鐢宠浜�" },
+          {
+            key: "handoverStatus",
+            type: "select",
+            label: "浜ゆ帴鐘舵��",
+            options: HANDOVER_STATUS_OPTIONS,
+          },
+          {
+            key: "handoverType",
+            type: "select",
+            label: "浜ゆ帴绫诲瀷",
+            options: HANDOVER_TYPE_OPTIONS,
+          },
+        ],
+      };
+    case APPROVAL_MODULE_KEYS.LEAVE:
+      return {
+        fields: [
+          {
+            key: "applicantKeyword",
+            type: "input",
+            label: "鐢宠浜�",
+            placeholder: "濮撳悕鎴栫紪鍙�",
+          },
+          {
+            key: "leaveType",
+            type: "select",
+            label: "璇峰亣绫诲瀷",
+            options: LEAVE_TYPE_OPTIONS,
+          },
+        ],
+      };
+    case APPROVAL_MODULE_KEYS.OVERTIME:
+      return {
+        fields: [
+          {
+            key: "applicantKeyword",
+            type: "input",
+            label: "鐢宠浜�",
+            placeholder: "濮撳悕鎴栫紪鍙�",
+          },
+          {
+            key: "overtimeType",
+            type: "select",
+            label: "鍔犵彮绫诲瀷",
+            options: OVERTIME_TYPE_OPTIONS,
+          },
+        ],
+      };
+    default:
+      return { fields: [] };
+  }
+}
+
+export function resetModuleSearchForm(moduleKey, target) {
+  const defaults = createModuleSearchForm(moduleKey);
+  Object.keys(target).forEach(k => {
+    if (!(k in defaults)) delete target[k];
+  });
+  Object.assign(target, defaults);
+}
+
+export function formatDateRangeLabel(range) {
+  if (!Array.isArray(range) || !range[0]) return "";
+  if (range[1]) return `${range[0]} 鑷� ${range[1]}`;
+  return range[0];
+}
+
+export function userSelectLabel(u) {
+  const nick = u?.nickName || "";
+  const name = u?.userName || "";
+  if (nick && name && nick !== name) return `${nick}锛�${name}锛塦;
+  return nick || name || `鐢ㄦ埛${u?.userId ?? u?.id ?? ""}`;
+}
diff --git a/src/pages/oa/_utils/approvalModuleRegistry.js b/src/pages/oa/_utils/approvalModuleRegistry.js
new file mode 100644
index 0000000..63d0c59
--- /dev/null
+++ b/src/pages/oa/_utils/approvalModuleRegistry.js
@@ -0,0 +1,137 @@
+
+/** 涓� Web approvalModuleRegistry 涓�鑷� */
+export const APPROVAL_MODULE_KEYS = {
+  REGULAR: "regular",
+  TRANSFER: "transfer",
+  WORK_HANDOVER: "work_handover",
+  LEAVE: "leave",
+  OVERTIME: "overtime",
+};
+
+export const APPROVAL_MODULE_REGISTRY = {
+  [APPROVAL_MODULE_KEYS.REGULAR]: {
+    label: "杞鐢宠",
+    approvalType: "regular",
+    typeLabels: ["杞", "杞鐢宠"],
+    listFields: [
+      { label: "鍏ヨ亴鏃ユ湡", prop: "entryDate" },
+      { label: "杞鏃ユ湡", prop: "regularDate" },
+    ],
+  },
+  [APPROVAL_MODULE_KEYS.TRANSFER]: {
+    label: "璋冨矖鐢宠",
+    approvalType: "transfer",
+    typeLabels: ["璋冨矖", "璋冨姩", "璋冨矖鐢宠", "璋冨姩鐢宠"],
+    listFields: [
+      { label: "鍘熷矖浣�", prop: "fromPost" },
+      { label: "鐩爣宀椾綅", prop: "toPost" },
+    ],
+  },
+  [APPROVAL_MODULE_KEYS.WORK_HANDOVER]: {
+    label: "宸ヤ綔浜ゆ帴",
+    approvalType: "work_handover",
+    typeLabels: ["宸ヤ綔浜ゆ帴", "浜ゆ帴", "宸ヤ綔浜ゆ帴瀹℃壒"],
+    listFields: [
+      { label: "浜ゆ帴浜�", prop: "handoverTo" },
+      { label: "浜ゆ帴浜嬮」", prop: "handoverItems" },
+    ],
+  },
+  [APPROVAL_MODULE_KEYS.LEAVE]: {
+    label: "璇峰亣鐢宠",
+    approvalType: "leave",
+    typeLabels: ["璇峰亣", "璇峰亣鐢宠", "璇峰亣瀹℃壒"],
+    listFields: [
+      { label: "璇峰亣绫诲瀷", prop: "leaveType" },
+      { label: "寮�濮嬫椂闂�", prop: "startTime" },
+      { label: "缁撴潫鏃堕棿", prop: "endTime" },
+    ],
+  },
+  [APPROVAL_MODULE_KEYS.OVERTIME]: {
+    label: "鍔犵彮鐢宠",
+    approvalType: "overtime",
+    typeLabels: ["鍔犵彮", "鍔犵彮鐢宠", "鍔犵彮瀹℃壒"],
+    listFields: [
+      { label: "鍔犵彮鏃ユ湡", prop: "overtimeDate" },
+      { label: "鏃堕暱(灏忔椂)", prop: "hours" },
+    ],
+  },
+};
+
+export function getApprovalModuleConfig(moduleKey) {
+  if (!moduleKey) return null;
+  return APPROVAL_MODULE_REGISTRY[moduleKey] || null;
+}
+
+export function getModuleListBusinessType(moduleKey) {
+  const cfg = getApprovalModuleConfig(moduleKey);
+  if (!cfg) return "";
+  if (cfg.businessType != null && cfg.businessType !== "") return cfg.businessType;
+  return cfg.approvalType || "";
+}
+
+function matchBiz(a, b) {
+  if (a == null || a === "" || b == null || b === "") return false;
+  return a === b || a === Number(b) || Number(a) === b || String(a) === String(b);
+}
+
+export function resolveModuleBusinessType(moduleKey, typeOptions = []) {
+  const cfg = getApprovalModuleConfig(moduleKey);
+  if (!cfg) return null;
+  if (cfg.businessType != null && cfg.businessType !== "") return cfg.businessType;
+
+  const labels = [cfg.label, ...(cfg.typeLabels || [])].filter(Boolean);
+  const hitByLabel = (typeOptions || []).find(opt => {
+    const optLabel = String(opt?.name || opt?.label || "").trim();
+    if (!optLabel) return false;
+    return labels.some(
+      l => optLabel === l || optLabel.includes(l) || l.includes(optLabel)
+    );
+  });
+  if (hitByLabel?.value != null && hitByLabel.value !== "") return hitByLabel.value;
+
+  if (cfg.approvalType) {
+    const hitByValue = (typeOptions || []).find(
+      opt =>
+        matchBiz(opt?.value, cfg.approvalType) || matchBiz(opt?.code, cfg.approvalType)
+    );
+    if (hitByValue?.value != null && hitByValue.value !== "") return hitByValue.value;
+  }
+
+  return cfg.approvalType || null;
+}
+
+export function getModuleMatchingBusinessTypes(moduleKey, typeOptions = []) {
+  const cfg = getApprovalModuleConfig(moduleKey);
+  if (!cfg) return [];
+
+  const values = new Set();
+  const primary = resolveModuleBusinessType(moduleKey, typeOptions);
+  if (primary != null && primary !== "") values.add(primary);
+  if (cfg.approvalType) values.add(cfg.approvalType);
+
+  const labels = [cfg.label, ...(cfg.typeLabels || [])].filter(Boolean);
+  for (const opt of typeOptions || []) {
+    const optLabel = String(opt?.name || opt?.label || "").trim();
+    if (!optLabel) continue;
+    const matched = labels.some(
+      l => optLabel === l || optLabel.includes(l) || l.includes(optLabel)
+    );
+    if (matched && opt.value != null && opt.value !== "") {
+      values.add(opt.value);
+    }
+  }
+  return [...values];
+}
+
+/** 鍒楄〃椤� moduleKey 涓庤矾鐢� pageKey 瀵圭収 */
+export const PAGE_KEY_TO_MODULE = {
+  "HrManage/regular-apply": APPROVAL_MODULE_KEYS.REGULAR,
+  "HrManage/transfer-apply": APPROVAL_MODULE_KEYS.TRANSFER,
+  "HrManage/work-handover": APPROVAL_MODULE_KEYS.WORK_HANDOVER,
+  "AttendManage/leave-apply": APPROVAL_MODULE_KEYS.LEAVE,
+  "AttendManage/overtime-apply": APPROVAL_MODULE_KEYS.OVERTIME,
+};
+
+export function getModuleKeyFromPageKey(pageKey) {
+  return PAGE_KEY_TO_MODULE[pageKey] || "";
+}
diff --git a/src/pages/oa/_utils/approvalTemplateType.js b/src/pages/oa/_utils/approvalTemplateType.js
index 4113ee8..e554068 100644
--- a/src/pages/oa/_utils/approvalTemplateType.js
+++ b/src/pages/oa/_utils/approvalTemplateType.js
@@ -1,4 +1,5 @@
 import { getTypeEnums } from "@/api/basic/enum.js";
+import { listApprovalTemplateByType } from "@/api/oa/approvalTemplate.js";
 
 /**
  * GET /approvalTemplate/list/{type} 璺緞鍙傛暟涓� templateType
@@ -19,58 +20,120 @@
   { name: "璇峰亣绠$悊", value: 2 },
 ];
 
+/** 瑙f瀽 TypeEnums 鎺ュ彛杩斿洖锛堝吋瀹� { TypeEnums: [] } 宓屽锛� */
+export function unwrapEnumList(data) {
+  if (Array.isArray(data)) return data;
+  if (!data || typeof data !== "object") return [];
+  if (Array.isArray(data.TypeEnums)) return data.TypeEnums;
+  if (Array.isArray(data.typeEnums)) return data.typeEnums;
+  const nested = Object.values(data).find(v => Array.isArray(v));
+  return nested || [];
+}
+
+/** 瑙f瀽妯℃澘鍒楄〃鎺ュ彛杩斿洖 */
+export function unwrapTemplateList(payload) {
+  const data = payload?.data ?? payload;
+  if (Array.isArray(data)) return data;
+  if (Array.isArray(data?.records)) return data.records;
+  if (Array.isArray(data?.list)) return data.list;
+  if (Array.isArray(data?.rows)) return data.rows;
+  return [];
+}
+
+/** enabled锛�1 / true 涓哄惎鐢紙涓� Web mapEnabledFromApi 涓�鑷达級 */
+export function mapEnabledFromApi(enabled) {
+  if (enabled === undefined || enabled === null) return true;
+  if (enabled === true || enabled === 1) return true;
+  const s = String(enabled).toLowerCase();
+  return s === "1" || s === "true" || s === "yes";
+}
+
+export function filterEnabledTemplates(list) {
+  return (list || []).filter(row => mapEnabledFromApi(row?.enabled));
+}
+
 /** 灏� /basic/enum/TypeEnums 鍝嶅簲瑙勮寖涓� { name, value }[] */
 export function normalizeEnumOptions(data) {
-  if (!data) return [];
-
-  if (Array.isArray(data)) {
-    return data
-      .map(item => {
-        const name =
-          item?.name ??
-          item?.label ??
-          item?.text ??
-          item?.dictLabel ??
-          item?.description;
-        const rawValue =
-          item?.value ?? item?.code ?? item?.dictValue ?? item?.key ?? item?.id;
-        if (name == null || rawValue === undefined || rawValue === null) {
-          return null;
-        }
-        const num = Number(rawValue);
-        return {
-          name: String(name),
-          value: Number.isNaN(num) ? rawValue : num,
-        };
-      })
-      .filter(Boolean);
-  }
-
-  if (typeof data === "object") {
-    return Object.entries(data).map(([value, name]) => {
-      const num = Number(value);
+  return unwrapEnumList(data)
+    .map(item => {
+      const name =
+        item?.name ??
+        item?.label ??
+        item?.text ??
+        item?.dictLabel ??
+        item?.description;
+      const rawValue =
+        item?.value ?? item?.code ?? item?.dictValue ?? item?.key ?? item?.id;
+      if (name == null || rawValue === undefined || rawValue === null) {
+        return null;
+      }
+      const num = Number(rawValue);
       return {
         name: String(name),
-        value: Number.isNaN(num) ? value : num,
+        value: Number.isNaN(num) ? rawValue : num,
       };
-    });
-  }
-
-  return [];
+    })
+    .filter(Boolean);
 }
 
 /** 鎷夊彇涓氬姟绫诲瀷鏋氫妇锛圱ypeEnums 鈫� businessType锛� */
 export async function fetchApprovalTemplateTypes() {
-  const res = await getTypeEnums();
-  const options = normalizeEnumOptions(res?.data);
-  return options.length ? options : [...FALLBACK_BUSINESS_TYPE_OPTIONS];
+  try {
+    const res = await getTypeEnums();
+    const options = normalizeEnumOptions(res?.data ?? res);
+    return options.length ? options : [...FALLBACK_BUSINESS_TYPE_OPTIONS];
+  } catch {
+    return [...FALLBACK_BUSINESS_TYPE_OPTIONS];
+  }
+}
+
+/** 閫夊彇绗竴涓�屾湁妯℃澘銆嶇殑 Tab 涓嬫爣 */
+export function pickTabIndexWithTemplates(typeOptions, templates) {
+  if (!typeOptions?.length) return 0;
+  for (let i = 0; i < typeOptions.length; i++) {
+    const bt = typeOptions[i]?.value;
+    if (filterTemplatesByBusinessType(templates, bt).length > 0) return i;
+  }
+  return 0;
+}
+
+/** 鎷夊彇宸插惎鐢ㄦā鏉匡紙鑷畾涔� + 绯荤粺鍐呯疆锛屼笌 Web 瀵煎叆閫昏緫涓�鑷达級 */
+export async function fetchEnabledApprovalTemplates() {
+  const [customRes, builtinRes] = await Promise.all([
+    listApprovalTemplateByType(CUSTOM_TEMPLATE_LIST_TYPE),
+    listApprovalTemplateByType(SYSTEM_TEMPLATE_TYPE),
+  ]);
+  const merged = [
+    ...unwrapTemplateList(customRes),
+    ...unwrapTemplateList(builtinRes),
+  ];
+  const byId = new Map();
+  merged.forEach(item => {
+    if (item?.id != null) byId.set(String(item.id), item);
+  });
+  return filterEnabledTemplates([...byId.values()]);
+}
+
+/** businessType 瀹芥澗鍖归厤锛堜笌 Web matchBusinessTypeValue 涓�鑷达級 */
+export function matchBusinessTypeValue(a, b) {
+  if (a == null || a === "" || b == null || b === "") return false;
+  return a === b || a === Number(b) || Number(a) === b || String(a) === String(b);
 }
 
 /** 鎸� businessType 绛涢�夋ā鏉� */
 export function filterTemplatesByBusinessType(templates, businessType) {
   if (businessType == null || businessType === "") return [];
-  return (templates || []).filter(
-    item => String(item.businessType) === String(businessType)
+  return (templates || []).filter(item =>
+    matchBusinessTypeValue(item.businessType, businessType)
+  );
+}
+
+/** 鎸夊涓� businessType 绛涢�夛紙涓氬姟妯″潡锛� */
+export function filterTemplatesByBusinessTypes(templates, businessTypes = []) {
+  const types = (businessTypes || []).filter(t => t != null && t !== "");
+  if (!types.length) return [];
+  return (templates || []).filter(item =>
+    types.some(t => matchBusinessTypeValue(item.businessType, t))
   );
 }
 
@@ -84,6 +147,20 @@
   return idx >= 0 ? idx : 0;
 }
 
+/** TypeEnums 涓虹┖鏃讹紝浠庢ā鏉垮垪琛ㄥ弽鎺� Tab */
+export function buildTypeOptionsFromTemplates(templates) {
+  const map = new Map();
+  (templates || []).forEach(item => {
+    const v = item?.businessType;
+    if (v == null || v === "") return;
+    const key = String(v);
+    if (!map.has(key)) {
+      map.set(key, { name: `瀹℃壒绫诲瀷 ${key}`, value: v });
+    }
+  });
+  return [...map.values()];
+}
+
 export function buildTypeLabelMap(options) {
   const map = {};
   (options || []).forEach(opt => {
diff --git a/src/pages/oa/_utils/approveListUtils.js b/src/pages/oa/_utils/approveListUtils.js
new file mode 100644
index 0000000..965b3dd
--- /dev/null
+++ b/src/pages/oa/_utils/approveListUtils.js
@@ -0,0 +1,327 @@
+import { parseTime } from "@/utils/ruoyi";
+import {
+  formatFieldDisplayValue,
+  getFieldOptionLabel,
+  isSelectField,
+  mergeFormConfigForEdit,
+} from "./approvalFormField.js";
+
+export const DETAIL_STORAGE_KEY = "oa_approve_instance_detail_row";
+
+export const INSTANCE_STATUS_TEXT = {
+  PENDING: "杩涜涓�",
+  APPROVED: "宸查�氳繃",
+  REJECTED: "宸查┏鍥�",
+  DRAFT: "鑽夌",
+};
+
+export const INSTANCE_STATUS_TAG = {
+  PENDING: "warning",
+  APPROVED: "success",
+  REJECTED: "error",
+  DRAFT: "info",
+};
+
+export const TASK_STATUS_TEXT = {
+  PENDING: "寰呭鐞�",
+  APPROVED: "宸查�氳繃",
+  REJECTED: "宸查┏鍥�",
+};
+
+export const TASK_STATUS_TAG = {
+  PENDING: "warning",
+  APPROVED: "success",
+  REJECTED: "error",
+};
+
+export function instanceStatusText(status) {
+  return INSTANCE_STATUS_TEXT[status] || status || "-";
+}
+
+export function instanceStatusTagType(status) {
+  return INSTANCE_STATUS_TAG[status] || "info";
+}
+
+export function taskStatusText(status) {
+  const key = String(status || "").toUpperCase();
+  return TASK_STATUS_TEXT[key] || status || "寰呭鐞�";
+}
+
+export function taskStatusTagType(status) {
+  const key = String(status || "").toUpperCase();
+  return TASK_STATUS_TAG[key] || "info";
+}
+
+export function formatDateTime(val) {
+  if (!val) return "-";
+  return parseTime(val, "{y}-{m}-{d} {h}:{i}:{s}") || String(val);
+}
+
+/** 瑙f瀽瀹炰緥 formConfig 涓哄彧璇诲睍绀哄瓧娈� */
+export function resolveInstanceDisplayFields(formConfig) {
+  const merged = mergeFormConfigForEdit("", formConfig);
+  return (merged.fields || []).filter(f => f?.key);
+}
+
+export function displayFieldValue(field) {
+  const val = field.value ?? field.defaultValue;
+  if (val === undefined || val === null || val === "") return "-";
+  if (isSelectField(field)) {
+    return getFieldOptionLabel(field, val) || String(val);
+  }
+  const shown = formatFieldDisplayValue(field, val);
+  return shown || String(val);
+}
+
+/** 瀹℃壒璁板綍 result锛歛pproved | rejected | pending */
+export function mapRecordResult(action) {
+  const s = String(action || "").toUpperCase();
+  if (s === "APPROVED" || s === "APPROVE" || s === "PASS") return "approved";
+  if (s === "REJECTED" || s === "REJECT" || s === "REFUSE") return "rejected";
+  return "pending";
+}
+
+export function recordActionLabel(result) {
+  if (result === "approved") return "閫氳繃";
+  if (result === "rejected") return "椹冲洖";
+  return "寰呭鐞�";
+}
+
+export function mapApprovalRecords(records) {
+  const list = Array.isArray(records) ? records : [];
+  return list.map((r, index) => ({
+    id: r.id ?? index,
+    operatorName: r.approverName || r.operatorName || r.createUserName || "鈥�",
+    result: mapRecordResult(r.approveAction ?? r.action ?? r.status),
+    opinion: r.approveComment || r.comment || r.opinion || "",
+    time: formatDateTime(r.approveTime || r.createTime || r.time),
+  }));
+}
+
+export function getRejectReasonFromRecords(records) {
+  const mapped = mapApprovalRecords(records);
+  const hit = mapped.find(r => r.result === "rejected");
+  return hit?.opinion || "";
+}
+
+/** 鍒楄〃 tasks 鈫� 娴佺▼鑺傜偣锛堜笌 apply 椤佃妭鐐圭粨鏋勬帴杩戯級 */
+export function mapTasksToFlowNodes(tasks) {
+  const list = Array.isArray(tasks) ? tasks : [];
+  if (!list.length) return [];
+
+  const byLevel = new Map();
+  list.forEach(t => {
+    const level = Number(t.levelNo ?? t.taskLevel ?? t.nodeOrder ?? 1);
+    if (!byLevel.has(level)) {
+      byLevel.set(level, {
+        levelNo: level,
+        approveType: t.approveType || "AND",
+        approvers: [],
+      });
+    }
+    const node = byLevel.get(level);
+    node.approvers.push({
+      approverName: t.approverName || "鈥�",
+      taskStatus: t.taskStatus ?? t.status,
+      approveComment: t.approveComment,
+      approveTime: t.approveTime,
+    });
+    if (t.approveType) node.approveType = t.approveType;
+  });
+
+  return [...byLevel.entries()]
+    .sort(([a], [b]) => a - b)
+    .map(([, node]) => node);
+}
+
+/** 缁勮瀹℃壒鎻愪氦 DTO锛堜笌 Web buildApproveInstanceDto 涓�鑷达級 */
+export function buildApproveInstanceDto(id, uiResult, comment) {
+  const opinion = (comment || "").trim();
+  return {
+    id,
+    approveAction: uiResult === "rejected" ? "REJECTED" : "APPROVED",
+    approveComment: opinion || (uiResult === "approved" ? "鍚屾剰" : ""),
+  };
+}
+
+/** 鏄惁鏈汉鍙戣捣鐨勫鎵� */
+export function isOwnApplication(item, userStore) {
+  const uid = userStore?.id;
+  if (item?.applicantId != null && uid != null && uid !== "") {
+    return String(item.applicantId) === String(uid);
+  }
+  const loginName = userStore?.nickName || userStore?.name;
+  if (loginName && item?.applicantName) {
+    return String(item.applicantName).trim() === String(loginName).trim();
+  }
+  return false;
+}
+
+/** 浠呰繘琛屼腑涓旀湰浜哄彂璧锋椂鍙紪杈� */
+export function canModifyInstance(item, userStore) {
+  return item?.status === "PENDING" && isOwnApplication(item, userStore);
+}
+
+/** 寰呭綋鍓嶇敤鎴峰鎵� */
+export function canApproveInstance(item) {
+  return Boolean(item?.isApprove) && item?.status === "PENDING";
+}
+
+export function stashInstanceRow(item) {
+  if (item) {
+    uni.setStorageSync(DETAIL_STORAGE_KEY, item);
+  }
+}
+
+export function loadInstanceRow(id) {
+  const row = uni.getStorageSync(DETAIL_STORAGE_KEY);
+  if (!row || String(row.id) !== String(id)) return null;
+  return row;
+}
+
+export const EDIT_STORAGE_KEY = "oa_approve_instance_edit_row";
+
+/** 涓氬姟鐢宠椤电姸鎬侊細杩涜涓�/宸插畬鎴愪笉鍙慨鏀癸紙涓� Web canEditBusinessInstanceRow 涓�鑷达級 */
+export function normalizeApprovalStatusKey(v) {
+  if (v == null || v === "") return "pending";
+  const upper = String(v).trim().toUpperCase();
+  if (upper === "DRAFT") return "draft";
+  if (upper === "APPROVED" || upper === "PASS") return "approved";
+  if (upper === "REJECTED" || upper === "REJECT" || upper === "REFUSE") {
+    return "rejected";
+  }
+  if (upper === "CANCELLED" || upper === "CANCEL") return "cancelled";
+  if (upper === "PENDING" || upper === "IN_PROGRESS") return "pending";
+  const lower = String(v).trim().toLowerCase();
+  if (["draft", "pending", "approved", "rejected", "cancelled"].includes(lower)) {
+    return lower;
+  }
+  return "pending";
+}
+
+export function canEditBusinessInstanceRow(row) {
+  const key = normalizeApprovalStatusKey(row?.status ?? row?.approvalStatus);
+  return key !== "pending" && key !== "approved";
+}
+
+export function businessStatusText(status) {
+  const key = normalizeApprovalStatusKey(status);
+  if (key === "draft") return "鑽夌";
+  if (key === "pending") return "杩涜涓�";
+  if (key === "approved") return "宸插畬鎴�";
+  if (key === "rejected") return "宸查┏鍥�";
+  if (key === "cancelled") return "宸叉挙閿�";
+  return instanceStatusText(status);
+}
+
+export function businessStatusTagType(status) {
+  const key = normalizeApprovalStatusKey(status);
+  if (key === "approved") return "success";
+  if (key === "rejected") return "error";
+  if (key === "draft" || key === "cancelled") return "info";
+  return "warning";
+}
+
+/** OA 鍒楄〃鑷畾涔夌姸鎬佽鏍� class */
+export function businessStatusClass(status) {
+  return `status-${normalizeApprovalStatusKey(status)}`;
+}
+
+/**
+ * 涓� Web buildApprovalInstanceListParams 涓�鑷达細鎵佸钩 query锛坈urrent/size/businessType/...锛�
+ * 瀹℃壒鍒楄〃涓嶄紶 businessType 鍗虫煡鍏ㄩ儴
+ */
+export function buildInstanceListParams({
+  page,
+  businessType,
+  extraDto = {},
+  searchForm,
+}) {
+  const extra = { ...(extraDto && typeof extraDto === "object" ? extraDto : {}) };
+  if (extra.createTime != null && extra.createTimeStart == null) {
+    extra.createTimeStart = extra.createTime;
+  }
+  delete extra.createTime;
+
+  const params = {
+    current: page.current,
+    size: page.size,
+    ...extra,
+  };
+
+  const bizType = businessType ?? searchForm?.businessType;
+  if (bizType != null && bizType !== "") {
+    params.businessType = bizType;
+  }
+  if (searchForm?.status) {
+    params.status = searchForm.status;
+  }
+
+  const range =
+    searchForm?.createTimeRange ??
+    searchForm?.applyDateRange ??
+    searchForm?.transferDateRange;
+  if (Array.isArray(range) && range[0] && params.createTimeStart == null) {
+    params.createTimeStart = range[0];
+  }
+  if (Array.isArray(range) && range[1] && params.createTimeEnd == null) {
+    params.createTimeEnd = range[1];
+  }
+
+  return params;
+}
+
+export function unwrapInstancePage(res) {
+  const data = res?.data ?? res;
+  return {
+    records: Array.isArray(data?.records) ? data.records : [],
+    total: Number(data?.total ?? 0),
+  };
+}
+
+/** 浠� formConfig 鎻愬彇鍒楄〃灞曠ず瀛楁锛坙abel + value锛� */
+export function buildFormDisplayRows(formConfig, listFields = []) {
+  const fields = resolveInstanceDisplayFields(formConfig);
+  const rows = [];
+  const propKeys = (listFields || []).map(f => f.prop).filter(Boolean);
+
+  if (propKeys.length) {
+    propKeys.forEach(prop => {
+      const hit = fields.find(f => f.key === prop);
+      if (hit) {
+        rows.push({ label: hit.label, value: displayFieldValue(hit) });
+      }
+    });
+  } else {
+    fields.slice(0, 3).forEach(f => {
+      rows.push({ label: f.label, value: displayFieldValue(f) });
+    });
+  }
+  return rows;
+}
+
+/** 鍒楄〃琛屽寮猴紙淇濈暀鍘熷瀛楁渚涜鎯�/缂栬緫锛� */
+export function mapInstanceListRow(row, listFields = []) {
+  if (!row) return {};
+  const displayRows = buildFormDisplayRows(row.formConfig, listFields);
+  const extra = {};
+  const formFields = resolveInstanceDisplayFields(row.formConfig);
+  (listFields || []).forEach(def => {
+    if (!def?.prop) return;
+    const hit = formFields.find(f => f.key === def.prop);
+    extra[def.prop] = hit ? displayFieldValue(hit) : "-";
+  });
+  const formPayload = {};
+  formFields.forEach(f => {
+    if (f?.key) formPayload[f.key] = f.value ?? f.defaultValue ?? "";
+  });
+  return {
+    ...row,
+    approvalStatus: normalizeApprovalStatusKey(row.status),
+    summary: row.title || row.templateName || "",
+    createTime: formatDateTime(row.applyTime || row.createTime),
+    displayRows,
+    formPayload,
+    ...extra,
+  };
+}

--
Gitblit v1.9.3