From bdaa472dc215775e5d306b7046af0bc1111c718d Mon Sep 17 00:00:00 2001
From: 云 <2163098428@qq.com>
Date: 星期五, 14 八月 2026 15:58:06 +0800
Subject: [PATCH] feat(bpm): 添加业务附件配置功能

---
 src/views/hrm/leave/detail/approve/index.vue                |   93 +++
 src/views/erp/finance/payment/detail/approve/index.vue      |  172 +++++
 pnpm-workspace.yaml                                         |    2 
 src/views/erp/purchase/order/detail/index.vue               |  134 ++++
 src/views/crm/invoice/detail/approve/index.vue              |  105 +++
 pnpm-lock.yaml                                              |   66 ++
 src/views/crm/receivable/detail/approve/index.vue           |  112 +++
 src/views/hrm/attendance/exception/detail/approve/index.vue |   82 ++
 src/views/hrm/resignation/detail/approve/index.vue          |   95 +++
 src/views/erp/purchase/invoice/detail/approve/index.vue     |   90 ++
 src/views/crm/contract/detail/approve/index.vue             |  190 ++++++
 src/views/erp/purchase/request/detail/index.vue             |  181 +++++
 src/views/hrm/transfer/detail/approve/index.vue             |   90 ++
 src/api/bpm/businessAttach/index.ts                         |   52 +
 package.json                                                |    4 
 src/views/bpm/businessAttach/modules/form.vue               |   88 ++
 src/views/bpm/businessAttach/index.vue                      |  129 ++++
 src/views/bpm/businessAttach/data.ts                        |  163 +++++
 18 files changed, 1,842 insertions(+), 6 deletions(-)

diff --git a/package.json b/package.json
index b7c7fbb..f8cce41 100644
--- a/package.json
+++ b/package.json
@@ -81,11 +81,13 @@
     "@vben/tailwind-config": "workspace:*",
     "@vben/tsconfig": "workspace:*",
     "@vben/vite-config": "workspace:*",
+    "cross-env": "catalog:",
     "jiti": "^2.7.0",
     "tsdown": "^0.22.3",
     "tsx": "^4.22.4",
     "typescript": "catalog:",
-    "vite": "catalog:"
+    "vite": "catalog:",
+    "vue-tsc": "catalog:"
   },
   "packageManager": "pnpm@11.0.9+sha512.34ce82e6780233cf9cad8685029a8f81d2e06196c5a9bad98879f7424940c6817c4e4524fb7d38b8553ceed48b9758b8ebaf1abd3600c232c4c8cf7366086f38"
 }
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b02d913..81f3c62 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -231,6 +231,9 @@
     cropperjs:
       specifier: ^1.6.2
       version: 1.6.2
+    cross-env:
+      specifier: ^10.1.0
+      version: 10.1.0
     crypto-js:
       specifier: ^4.2.0
       version: 4.2.0
@@ -504,6 +507,9 @@
     vue-tippy:
       specifier: ^6.7.1
       version: 6.7.1
+    vue-tsc:
+      specifier: ^3.3.9
+      version: 3.3.9
     vue3-print-nb:
       specifier: ^0.1.4
       version: 0.1.4
@@ -693,12 +699,15 @@
       '@vben/vite-config':
         specifier: workspace:*
         version: link:internal/vite-config
+      cross-env:
+        specifier: 'catalog:'
+        version: 10.1.0
       jiti:
         specifier: ^2.7.0
         version: 2.7.0
       tsdown:
         specifier: ^0.22.3
-        version: 0.22.3(tsx@4.22.4)(typescript@6.0.3)(unrun@0.3.1(synckit@0.11.13))
+        version: 0.22.3(tsx@4.22.4)(typescript@6.0.3)(unrun@0.3.1(synckit@0.11.13))(vue-tsc@3.3.9(typescript@6.0.3))
       tsx:
         specifier: ^4.22.4
         version: 4.22.4
@@ -708,6 +717,9 @@
       vite:
         specifier: 'catalog:'
         version: 8.0.10(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)
+      vue-tsc:
+        specifier: 'catalog:'
+        version: 3.3.9(typescript@6.0.3)
 
   internal/lint-configs/commitlint-config:
     dependencies:
@@ -2552,6 +2564,9 @@
 
   '@emotion/unitless@0.8.1':
     resolution: {integrity: sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==}
+
+  '@epic-web/invariant@1.0.0':
+    resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==}
 
   '@es-joy/jsdoccomment@0.84.0':
     resolution: {integrity: sha512-0xew1CxOam0gV5OMjh2KjFQZsKL2bByX1+q4j3E73MpYIdyUxcZb/xQct9ccUb+ve5KGUYbCUxyPnYB7RbuP+w==}
@@ -4754,6 +4769,9 @@
   '@vue/devtools-shared@8.1.3':
     resolution: {integrity: sha512-CM3uIPL+v+lrJUk33+pxspYo0MhuMWlCvf7zC9fybifvCPyM2jUbYRPwoYEJgYbwRqPikm5HozbUhp60MF2QuA==}
 
+  '@vue/language-core@3.3.9':
+    resolution: {integrity: sha512-in/68oAa4BCtVY6n/nkuhLIkV8DHYd2UivedJ6cMZ6UYtlq9jaoaSNUBHYCVO44z3nKg7MdE5OBoHKt5SxeBKQ==}
+
   '@vue/reactivity@3.5.38':
     resolution: {integrity: sha512-pG6LV/NDNRbKizcUjFFLAfjaL8mcv4DmR9avNcUw2gDHBzZneuS2TWCmp633ynzxz9YYKNeEPK2I8Wraqy2HUQ==}
 
@@ -4914,6 +4932,9 @@
 
   ajv@8.20.0:
     resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
+
+  alien-signals@3.2.1:
+    resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==}
 
   ansi-regex@5.0.1:
     resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
@@ -5527,6 +5548,11 @@
 
   cropperjs@1.6.2:
     resolution: {integrity: sha512-nhymn9GdnV3CqiEHJVai54TULFAE3VshJTXSqSJKa8yXAKyBKDWdhHarnlIPrshJ0WMFTGuFvG02YjLXfPiuOA==}
+
+  cross-env@10.1.0:
+    resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==}
+    engines: {node: '>=20'}
+    hasBin: true
 
   cross-spawn@7.0.6:
     resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
@@ -9595,6 +9621,12 @@
     peerDependencies:
       vue: ^3.2.0
 
+  vue-tsc@3.3.9:
+    resolution: {integrity: sha512-TS3Y1ux/IRoE8OCP2PpACAeOseuIs0UvWrcr7u+w3PmfY+SlCfEf8zjrBgnQksHUgLpthi5vHlffcQTQTdPBZA==}
+    hasBin: true
+    peerDependencies:
+      typescript: '>=5.0.0'
+
   vue-types@3.0.0:
     resolution: {integrity: sha512-xzq2D7zLGbzn3bPQ0CWWYx4W78JWdGYZAFfRkzxz1aTm9DaNoH0qxYDiLXBofBEQ5X78l32uIq/G9INQ2E8X/Q==}
     engines: {node: '>=10.15.0'}
@@ -10976,6 +11008,8 @@
   '@emotion/hash@0.9.2': {}
 
   '@emotion/unitless@0.8.1': {}
+
+  '@epic-web/invariant@1.0.0': {}
 
   '@es-joy/jsdoccomment@0.84.0':
     dependencies:
@@ -12903,6 +12937,16 @@
 
   '@vue/devtools-shared@8.1.3': {}
 
+  '@vue/language-core@3.3.9':
+    dependencies:
+      '@volar/language-core': 2.4.28
+      '@vue/compiler-dom': 3.5.38
+      '@vue/shared': 3.5.38
+      alien-signals: 3.2.1
+      muggle-string: 0.4.1
+      path-browserify: 1.0.1
+      picomatch: 4.0.4
+
   '@vue/reactivity@3.5.38':
     dependencies:
       '@vue/shared': 3.5.38
@@ -13069,6 +13113,8 @@
       fast-uri: 3.1.2
       json-schema-traverse: 1.0.0
       require-from-string: 2.0.2
+
+  alien-signals@3.2.1: {}
 
   ansi-regex@5.0.1: {}
 
@@ -13722,6 +13768,11 @@
   croner@10.0.1: {}
 
   cropperjs@1.6.2: {}
+
+  cross-env@10.1.0:
+    dependencies:
+      '@epic-web/invariant': 1.0.0
+      cross-spawn: 7.0.6
 
   cross-spawn@7.0.6:
     dependencies:
@@ -16668,7 +16719,7 @@
 
   robust-predicates@3.0.3: {}
 
-  rolldown-plugin-dts@0.26.0(rolldown@1.1.2)(typescript@6.0.3):
+  rolldown-plugin-dts@0.26.0(rolldown@1.1.2)(typescript@6.0.3)(vue-tsc@3.3.9(typescript@6.0.3)):
     dependencies:
       '@babel/generator': 8.0.0
       '@babel/helper-validator-identifier': 8.0.2
@@ -16681,6 +16732,7 @@
       rolldown: 1.1.2
     optionalDependencies:
       typescript: 6.0.3
+      vue-tsc: 3.3.9(typescript@6.0.3)
     transitivePeerDependencies:
       - oxc-resolver
 
@@ -17573,7 +17625,7 @@
       minimist: 1.2.8
       strip-bom: 3.0.0
 
-  tsdown@0.22.3(tsx@4.22.4)(typescript@6.0.3)(unrun@0.3.1(synckit@0.11.13)):
+  tsdown@0.22.3(tsx@4.22.4)(typescript@6.0.3)(unrun@0.3.1(synckit@0.11.13))(vue-tsc@3.3.9(typescript@6.0.3)):
     dependencies:
       ansis: 4.3.1
       cac: 7.0.0
@@ -17584,7 +17636,7 @@
       obug: 2.1.3
       picomatch: 4.0.4
       rolldown: 1.1.2
-      rolldown-plugin-dts: 0.26.0(rolldown@1.1.2)(typescript@6.0.3)
+      rolldown-plugin-dts: 0.26.0(rolldown@1.1.2)(typescript@6.0.3)(vue-tsc@3.3.9(typescript@6.0.3))
       semver: 7.8.5
       tinyexec: 1.2.4
       tinyglobby: 0.2.17
@@ -18092,6 +18144,12 @@
       tippy.js: 6.3.7
       vue: 3.5.38(typescript@6.0.3)
 
+  vue-tsc@3.3.9(typescript@6.0.3):
+    dependencies:
+      '@volar/typescript': 2.4.28
+      '@vue/language-core': 3.3.9
+      typescript: 6.0.3
+
   vue-types@3.0.0(vue@3.5.38(typescript@6.0.3)):
     dependencies:
       is-plain-object: 3.0.1
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 5df4a75..6105520 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -193,7 +193,7 @@
   vue-json-pretty: ^2.6.0
   vue-router: ^5.1.0
   vue-tippy: ^6.7.1
-  vue-tsc: ^3.3.3
+  vue-tsc: ^3.3.9
   vue3-print-nb: ^0.1.4
   vue3-signature: ^0.4.4
   vuedraggable: ^4.1.0
diff --git a/src/api/bpm/businessAttach/index.ts b/src/api/bpm/businessAttach/index.ts
new file mode 100644
index 0000000..4dd1aa9
--- /dev/null
+++ b/src/api/bpm/businessAttach/index.ts
@@ -0,0 +1,52 @@
+import type { PageParam, PageResult } from '@vben/request';
+
+import { requestClient } from '#/api/request';
+
+export namespace BpmBusinessAttachApi {
+  /** 涓氬姟闄勪欢閰嶇疆 */
+  export interface BusinessAttach {
+    id: number;
+    processDefinitionKey: string;
+    businessType?: string;
+    componentPath: string;
+    status: number;
+    remark?: string;
+    createTime?: string;
+  }
+}
+
+/** 鏌ヨ涓氬姟闄勪欢閰嶇疆鍒嗛〉 */
+export async function getBusinessAttachPage(params: PageParam) {
+  return requestClient.get<PageResult<BpmBusinessAttachApi.BusinessAttach>>(
+    '/bpm/business-attach/page',
+    { params },
+  );
+}
+
+/** 鏌ヨ涓氬姟闄勪欢閰嶇疆璇︽儏 */
+export async function getBusinessAttach(id: number) {
+  return requestClient.get<BpmBusinessAttachApi.BusinessAttach>(
+    `/bpm/business-attach/get?id=${id}`,
+  );
+}
+
+/** 鏂板涓氬姟闄勪欢閰嶇疆 */
+export async function createBusinessAttach(
+  data: BpmBusinessAttachApi.BusinessAttach,
+) {
+  return requestClient.post<number>('/bpm/business-attach/create', data);
+}
+
+/** 淇敼涓氬姟闄勪欢閰嶇疆 */
+export async function updateBusinessAttach(
+  data: BpmBusinessAttachApi.BusinessAttach,
+) {
+  return requestClient.put<boolean>('/bpm/business-attach/update', data);
+}
+
+/** 鍒犻櫎涓氬姟闄勪欢閰嶇疆 */
+export async function deleteBusinessAttach(id: number) {
+  return requestClient.delete<boolean>(
+    `/bpm/business-attach/delete?id=${id}`,
+  );
+}
diff --git a/src/views/bpm/businessAttach/data.ts b/src/views/bpm/businessAttach/data.ts
new file mode 100644
index 0000000..f642965
--- /dev/null
+++ b/src/views/bpm/businessAttach/data.ts
@@ -0,0 +1,163 @@
+import type { VbenFormSchema } from '#/adapter/form';
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+
+import { CommonStatusEnum, DICT_TYPE } from '@vben/constants';
+import { getDictOptions } from '@vben/hooks';
+
+import { z } from '#/adapter/form';
+import { getRangePickerDefaultProps } from '#/utils';
+
+/** 鏂板/淇敼鐨勮〃鍗� */
+export function useFormSchema(): VbenFormSchema[] {
+  return [
+    {
+      fieldName: 'id',
+      component: 'Input',
+      dependencies: {
+        triggerFields: [''],
+        show: () => false,
+      },
+    },
+    {
+      fieldName: 'processDefinitionKey',
+      label: '娴佺▼瀹氫箟 Key',
+      component: 'Input',
+      componentProps: {
+        placeholder: '璇疯緭鍏ユ祦绋嬪畾涔� Key锛屽 purchase_approve',
+      },
+      rules: 'required',
+    },
+    {
+      fieldName: 'businessType',
+      label: '涓氬姟绫诲瀷',
+      component: 'Input',
+      componentProps: {
+        placeholder: '璇疯緭鍏ヤ笟鍔$被鍨嬶紝濡� erp_purchase_order',
+      },
+    },
+    {
+      fieldName: 'componentPath',
+      label: '涓氬姟璇︽儏缁勪欢璺緞',
+      component: 'Input',
+      componentProps: {
+        placeholder:
+          '璇疯緭鍏ヤ笟鍔¤鎯呯粍浠惰矾寰勶紝濡� /erp/purchase/order/detail/index.vue',
+      },
+      rules: 'required',
+    },
+    {
+      fieldName: 'status',
+      label: '鐘舵��',
+      component: 'RadioGroup',
+      componentProps: {
+        options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
+        buttonStyle: 'solid',
+        optionType: 'button',
+      },
+      rules: z.number().default(CommonStatusEnum.ENABLE),
+    },
+    {
+      fieldName: 'remark',
+      label: '澶囨敞',
+      component: 'Textarea',
+      componentProps: {
+        placeholder: '璇疯緭鍏ュ娉�',
+      },
+    },
+  ];
+}
+
+/** 鍒楄〃鐨勬悳绱㈣〃鍗� */
+export function useGridFormSchema(): VbenFormSchema[] {
+  return [
+    {
+      fieldName: 'processDefinitionKey',
+      label: '娴佺▼瀹氫箟 Key',
+      component: 'Input',
+      componentProps: {
+        placeholder: '璇疯緭鍏ユ祦绋嬪畾涔� Key',
+        allowClear: true,
+      },
+    },
+    {
+      fieldName: 'businessType',
+      label: '涓氬姟绫诲瀷',
+      component: 'Input',
+      componentProps: {
+        placeholder: '璇疯緭鍏ヤ笟鍔$被鍨�',
+        allowClear: true,
+      },
+    },
+    {
+      fieldName: 'status',
+      label: '鐘舵��',
+      component: 'Select',
+      componentProps: {
+        options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
+        placeholder: '璇烽�夋嫨鐘舵��',
+        allowClear: true,
+      },
+    },
+    {
+      fieldName: 'createTime',
+      label: '鍒涘缓鏃堕棿',
+      component: 'RangePicker',
+      componentProps: {
+        ...getRangePickerDefaultProps(),
+        allowClear: true,
+      },
+    },
+  ];
+}
+
+/** 鍒楄〃鐨勫瓧娈� */
+export function useGridColumns(): VxeTableGridOptions['columns'] {
+  return [
+    {
+      field: 'id',
+      title: '缂栧彿',
+      minWidth: 80,
+    },
+    {
+      field: 'processDefinitionKey',
+      title: '娴佺▼瀹氫箟 Key',
+      minWidth: 180,
+    },
+    {
+      field: 'businessType',
+      title: '涓氬姟绫诲瀷',
+      minWidth: 160,
+    },
+    {
+      field: 'componentPath',
+      title: '涓氬姟璇︽儏缁勪欢璺緞',
+      minWidth: 260,
+    },
+    {
+      field: 'status',
+      title: '鐘舵��',
+      minWidth: 80,
+      cellRender: {
+        name: 'CellDict',
+        props: { type: DICT_TYPE.COMMON_STATUS },
+      },
+    },
+    {
+      field: 'remark',
+      title: '澶囨敞',
+      minWidth: 160,
+    },
+    {
+      field: 'createTime',
+      title: '鍒涘缓鏃堕棿',
+      minWidth: 180,
+      formatter: 'formatDateTime',
+    },
+    {
+      title: '鎿嶄綔',
+      width: 160,
+      fixed: 'right',
+      slots: { default: 'actions' },
+    },
+  ];
+}
diff --git a/src/views/bpm/businessAttach/index.vue b/src/views/bpm/businessAttach/index.vue
new file mode 100644
index 0000000..10c506d
--- /dev/null
+++ b/src/views/bpm/businessAttach/index.vue
@@ -0,0 +1,129 @@
+<script lang="ts" setup>
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+import type { BpmBusinessAttachApi } from '#/api/bpm/businessAttach';
+
+import { Page, useVbenModal } from '@vben/common-ui';
+
+import { message } from 'ant-design-vue';
+
+import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
+import {
+  deleteBusinessAttach,
+  getBusinessAttachPage,
+} from '#/api/bpm/businessAttach';
+import { $t } from '#/locales';
+
+import { useGridColumns, useGridFormSchema } from './data';
+import Form from './modules/form.vue';
+
+const [FormModal, formModalApi] = useVbenModal({
+  connectedComponent: Form,
+  destroyOnClose: true,
+});
+
+/** 鍒锋柊琛ㄦ牸 */
+function handleRefresh() {
+  gridApi.query();
+}
+
+/** 鍒涘缓涓氬姟闄勪欢閰嶇疆 */
+function handleCreate() {
+  formModalApi.setData(null).open();
+}
+
+/** 缂栬緫涓氬姟闄勪欢閰嶇疆 */
+function handleEdit(row: BpmBusinessAttachApi.BusinessAttach) {
+  formModalApi.setData(row).open();
+}
+
+/** 鍒犻櫎涓氬姟闄勪欢閰嶇疆 */
+async function handleDelete(row: BpmBusinessAttachApi.BusinessAttach) {
+  const hideLoading = message.loading({
+    content: $t('ui.actionMessage.deleting', [row.processDefinitionKey]),
+    duration: 0,
+  });
+  try {
+    await deleteBusinessAttach(row.id as number);
+    message.success($t('ui.actionMessage.deleteSuccess'));
+    handleRefresh();
+  } finally {
+    hideLoading();
+  }
+}
+
+const [Grid, gridApi] = useVbenVxeGrid({
+  formOptions: {
+    schema: useGridFormSchema(),
+  },
+  gridOptions: {
+    columns: useGridColumns(),
+    height: 'auto',
+    keepSource: true,
+    proxyConfig: {
+      ajax: {
+        query: async ({ page }, formValues) => {
+          return await getBusinessAttachPage({
+            pageNo: page.currentPage,
+            pageSize: page.pageSize,
+            ...formValues,
+          });
+        },
+      },
+    },
+    rowConfig: {
+      keyField: 'id',
+      isHover: true,
+    },
+    toolbarConfig: {
+      refresh: true,
+      search: true,
+    },
+  } as VxeTableGridOptions<BpmBusinessAttachApi.BusinessAttach>,
+});
+</script>
+
+<template>
+  <Page auto-content-height><FormModal @success="handleRefresh" />
+    <Grid table-title="涓氬姟闄勪欢閰嶇疆">
+      <template #toolbar-tools>
+        <TableAction
+          :actions="[
+            {
+              label: $t('ui.actionTitle.create', ['涓氬姟闄勪欢閰嶇疆']),
+              type: 'primary',
+              icon: ACTION_ICON.ADD,
+              auth: ['bpm:business-attach:create'],
+              onClick: handleCreate,
+            },
+          ]"
+        />
+      </template>
+      <template #actions="{ row }">
+        <TableAction
+          :actions="[
+            {
+              label: $t('common.edit'),
+              type: 'link',
+              icon: ACTION_ICON.EDIT,
+              auth: ['bpm:business-attach:update'],
+              onClick: handleEdit.bind(null, row),
+            },
+            {
+              label: $t('common.delete'),
+              type: 'link',
+              danger: true,
+              icon: ACTION_ICON.DELETE,
+              auth: ['bpm:business-attach:delete'],
+              popConfirm: {
+                title: $t('ui.actionMessage.deleteConfirm', [
+                  row.processDefinitionKey,
+                ]),
+                confirm: handleDelete.bind(null, row),
+              },
+            },
+          ]"
+        />
+      </template>
+    </Grid>
+  </Page>
+</template>
diff --git a/src/views/bpm/businessAttach/modules/form.vue b/src/views/bpm/businessAttach/modules/form.vue
new file mode 100644
index 0000000..62e57dc
--- /dev/null
+++ b/src/views/bpm/businessAttach/modules/form.vue
@@ -0,0 +1,88 @@
+<script lang="ts" setup>
+import type { BpmBusinessAttachApi } from '#/api/bpm/businessAttach';
+
+import { computed, ref } from 'vue';
+
+import { useVbenModal } from '@vben/common-ui';
+
+import { message } from 'ant-design-vue';
+
+import { useVbenForm } from '#/adapter/form';
+import {
+  createBusinessAttach,
+  getBusinessAttach,
+  updateBusinessAttach,
+} from '#/api/bpm/businessAttach';
+import { $t } from '#/locales';
+
+import { useFormSchema } from '../data';
+
+const emit = defineEmits(['success']);
+const formData = ref<BpmBusinessAttachApi.BusinessAttach>();
+const getTitle = computed(() => {
+  return formData.value?.id
+    ? $t('ui.actionTitle.edit', ['涓氬姟闄勪欢閰嶇疆'])
+    : $t('ui.actionTitle.create', ['涓氬姟闄勪欢閰嶇疆']);
+});
+
+const [Form, formApi] = useVbenForm({
+  commonConfig: {
+    componentProps: {
+      class: 'w-full',
+    },
+    formItemClass: 'col-span-2',
+    labelWidth: 140,
+  },
+  layout: 'horizontal',
+  schema: useFormSchema(),
+  showDefaultActions: false,
+});
+
+const [Modal, modalApi] = useVbenModal({
+  async onConfirm() {
+    const { valid } = await formApi.validate();
+    if (!valid) {
+      return;
+    }
+    modalApi.lock();
+    // 鎻愪氦琛ㄥ崟
+    const data = (await formApi.getValues()) as BpmBusinessAttachApi.BusinessAttach;
+    try {
+      await (formData.value?.id
+        ? updateBusinessAttach(data)
+        : createBusinessAttach(data));
+      // 鍏抽棴骞舵彁绀�
+      await modalApi.close();
+      emit('success');
+      message.success($t('ui.actionMessage.operationSuccess'));
+    } finally {
+      modalApi.unlock();
+    }
+  },
+  async onOpenChange(isOpen: boolean) {
+    if (!isOpen) {
+      formData.value = undefined;
+      return;
+    }
+    // 鍔犺浇鏁版嵁
+    const data = modalApi.getData<BpmBusinessAttachApi.BusinessAttach>();
+    if (!data || !data.id) {
+      return;
+    }
+    modalApi.lock();
+    try {
+      formData.value = await getBusinessAttach(data.id);
+      // 璁剧疆鍒� values
+      await formApi.setValues(formData.value);
+    } finally {
+      modalApi.unlock();
+    }
+  },
+});
+</script>
+
+<template>
+  <Modal :title="getTitle" class="w-2/3">
+    <Form class="mx-4" />
+  </Modal>
+</template>
diff --git a/src/views/crm/contract/detail/approve/index.vue b/src/views/crm/contract/detail/approve/index.vue
new file mode 100644
index 0000000..adc3157
--- /dev/null
+++ b/src/views/crm/contract/detail/approve/index.vue
@@ -0,0 +1,190 @@
+<script lang="ts" setup>
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+import type { CrmContractApi } from '#/api/crm/contract';
+import type { DescriptionItemSchema } from '#/components/description';
+
+import { h, nextTick, onMounted, ref } from 'vue';
+
+import { ContentWrap } from '@vben/common-ui';
+import { DICT_TYPE } from '@vben/constants';
+import {
+  erpCountInputFormatter,
+  erpPriceInputFormatter,
+  formatDateTime,
+} from '@vben/utils';
+
+import { Spin } from 'ant-design-vue';
+
+import { useVbenVxeGrid } from '#/adapter/vxe-table';
+import { getContract } from '#/api/crm/contract';
+import { DictTag } from '#/components/dict-tag';
+import { useDescription } from '#/components/description';
+
+defineOptions({ name: 'CrmContractApproveDetail' });
+
+const props = defineProps<{ id: string }>();
+
+const loading = ref(false);
+const formData = ref<CrmContractApi.Contract>();
+
+/** 鏂囨湰瀛楁绌哄�煎崰浣� */
+function textRender(val: unknown) {
+  return val === 0 || val ? String(val) : '-';
+}
+
+/** 鍚堝悓浜у搧鏄庣粏锛堝鎵硅鎯呭彧璇诲垪锛� */
+function useProductColumns(): VxeTableGridOptions['columns'] {
+  return [
+    { type: 'seq', title: '搴忓彿', width: 60 },
+    { field: 'itemName', title: '浜у搧鍚嶇О', minWidth: 180 },
+    { field: 'itemCode', title: '鐗╂枡缂栫爜', minWidth: 120 },
+    { field: 'itemUnitName', title: '鍗曚綅', minWidth: 80, formatter: ({ cellValue }) => textRender(cellValue) },
+    {
+      field: 'contractPrice',
+      title: '鍚堝悓浠锋牸锛堝厓锛�',
+      minWidth: 120,
+      formatter: ({ cellValue }) => erpPriceInputFormatter(cellValue) || '-',
+    },
+    {
+      field: 'count',
+      title: '鏁伴噺',
+      minWidth: 100,
+      formatter: ({ cellValue }) => erpCountInputFormatter(cellValue) || '-',
+    },
+    {
+      field: 'totalPrice',
+      title: '鍚堣閲戦锛堝厓锛�',
+      minWidth: 120,
+      formatter: ({ cellValue }) => erpPriceInputFormatter(cellValue) || '-',
+    },
+  ];
+}
+
+const [Grid, gridApi] = useVbenVxeGrid({
+  gridOptions: {
+    columns: useProductColumns(),
+    data: [],
+    minHeight: 200,
+    autoResize: true,
+    border: true,
+    rowConfig: {
+      keyField: 'id',
+      isHover: true,
+    },
+    pagerConfig: {
+      enabled: false,
+    },
+    toolbarConfig: {
+      enabled: false,
+    },
+  },
+});
+
+const [Descriptions] = useDescription({
+  bordered: true,
+  column: 2,
+  schema: [
+    { label: '鍚堝悓缂栧彿', field: 'no', render: textRender },
+    { label: '鍚堝悓鍚嶇О', field: 'name', render: textRender },
+    { label: '瀹㈡埛鍚嶇О', field: 'customerName', render: textRender },
+    { label: '鍟嗘満鍚嶇О', field: 'businessName', render: textRender },
+    {
+      label: '鍚堝悓閲戦锛堝厓锛�',
+      field: 'totalPrice',
+      render: (val) => erpPriceInputFormatter(val) || '-',
+    },
+    {
+      label: '瀹氶噾閲戦锛堝厓锛�',
+      field: 'depositPrice',
+      render: (val) => erpPriceInputFormatter(val) || '-',
+    },
+    {
+      label: '鍥炴閲戦锛堝厓锛�',
+      field: 'totalReceivablePrice',
+      render: (val) => erpPriceInputFormatter(val) || '-',
+    },
+    {
+      label: '宸插紑绁ㄩ噾棰濓紙鍏冿級',
+      field: 'totalInvoicePrice',
+      render: (val) => erpPriceInputFormatter(val) || '-',
+    },
+    {
+      label: '鏈紑绁ㄩ噾棰濓紙鍏冿級',
+      field: 'unInvoicePrice',
+      render: (val) => erpPriceInputFormatter(val) || '-',
+    },
+    {
+      label: '涓嬪崟鏃堕棿',
+      field: 'orderDate',
+      render: (val) => formatDateTime(val) as string,
+    },
+    {
+      label: '鍚堝悓寮�濮嬫椂闂�',
+      field: 'startTime',
+      render: (val) => formatDateTime(val) as string,
+    },
+    {
+      label: '鍚堝悓缁撴潫鏃堕棿',
+      field: 'endTime',
+      render: (val) => formatDateTime(val) as string,
+    },
+    { label: '瀹㈡埛绛剧害浜�', field: 'signContactName', render: textRender },
+    { label: '鍏徃绛剧害浜�', field: 'signUserName', render: textRender },
+    { label: '閿�鍞鍗�', field: 'orderNo', render: textRender },
+    { label: '鍒涘缓浜�', field: 'creatorName', render: textRender },
+    {
+      label: '鍒涘缓鏃堕棿',
+      field: 'createTime',
+      render: (val) => formatDateTime(val) as string,
+    },
+    {
+      label: '鍚堝悓鐘舵��',
+      field: 'auditStatus',
+      render: (val) =>
+        h(DictTag, { type: DICT_TYPE.CRM_AUDIT_STATUS, value: val }),
+    },
+    { label: '澶囨敞', field: 'remark', render: textRender },
+  ] as DescriptionItemSchema[],
+});
+
+onMounted(async () => {
+  try {
+    loading.value = true;
+    formData.value = await getContract(Number(props.id));
+    if (formData.value?.products?.length) {
+      await nextTick();
+      await gridApi.grid.reloadData(formData.value.products);
+    }
+  } finally {
+    loading.value = false;
+  }
+});
+</script>
+
+<template>
+  <ContentWrap class="m-2">
+    <Spin :spinning="loading" tip="鍔犺浇涓�...">
+      <div class="space-y-4">
+        <!-- 鍚堝悓鍩烘湰淇℃伅 -->
+        <div class="rounded-lg border border-border bg-background p-4">
+          <div class="mb-4 flex items-center gap-2">
+            <span class="inline-block h-4 w-1 rounded bg-primary" />
+            <span class="text-base font-semibold">鍚堝悓鍩烘湰淇℃伅</span>
+          </div>
+          <Descriptions :data="formData" />
+        </div>
+        <!-- 浜у搧鏄庣粏 -->
+        <div
+          v-if="formData?.products?.length"
+          class="rounded-lg border border-border bg-background p-4"
+        >
+          <div class="mb-4 flex items-center gap-2">
+            <span class="inline-block h-4 w-1 rounded bg-primary" />
+            <span class="text-base font-semibold">浜у搧鏄庣粏</span>
+          </div>
+          <Grid />
+        </div>
+      </div>
+    </Spin>
+  </ContentWrap>
+</template>
diff --git a/src/views/crm/invoice/detail/approve/index.vue b/src/views/crm/invoice/detail/approve/index.vue
new file mode 100644
index 0000000..ec1530c
--- /dev/null
+++ b/src/views/crm/invoice/detail/approve/index.vue
@@ -0,0 +1,105 @@
+<script lang="ts" setup>
+import type { CrmInvoiceApi } from '#/api/crm/invoice';
+import type { DescriptionItemSchema } from '#/components/description';
+
+import { h, onMounted, ref } from 'vue';
+
+import { ContentWrap } from '@vben/common-ui';
+import { DICT_TYPE } from '@vben/constants';
+import { erpPriceInputFormatter, formatDateTime } from '@vben/utils';
+
+import { Spin } from 'ant-design-vue';
+
+import { getInvoice } from '#/api/crm/invoice';
+import { DictTag } from '#/components/dict-tag';
+import { useDescription } from '#/components/description';
+
+defineOptions({ name: 'CrmInvoiceApproveDetail' });
+
+const props = defineProps<{ id: string }>();
+
+const loading = ref(false);
+const formData = ref<CrmInvoiceApi.Invoice>();
+
+/** 鏂囨湰瀛楁绌哄�煎崰浣� */
+function textRender(val: unknown) {
+  return val === 0 || val ? String(val) : '-';
+}
+
+const [Descriptions] = useDescription({
+  bordered: true,
+  column: 2,
+  schema: [
+    { label: '寮�绁ㄧ紪鍙�', field: 'no', render: textRender },
+    { label: '瀹㈡埛鍚嶇О', field: 'customerName', render: textRender },
+    {
+      label: '鍚堝悓缂栧彿',
+      field: 'contract',
+      render: (_val, data) => (data?.contract?.no ? data.contract.no : '-'),
+    },
+    {
+      label: '鍙戠エ绫诲瀷',
+      field: 'invoiceType',
+      render: (val) =>
+        h(DictTag, { type: DICT_TYPE.CRM_INVOICE_TYPE, value: val }),
+    },
+    { label: '鍙戠エ鍙风爜', field: 'invoiceNo', render: textRender },
+    { label: '鍙戠エ鎶ご', field: 'invoiceTitle', render: textRender },
+    {
+      label: '寮�绁ㄩ噾棰濓紙鍏冿級',
+      field: 'price',
+      render: (val) => erpPriceInputFormatter(val) || '-',
+    },
+    {
+      label: '鍓╀綑鍙洖娆鹃噾棰濓紙鍏冿級',
+      field: 'remainingInvoicePrice',
+      render: (val) => erpPriceInputFormatter(val ?? 0) || '-',
+    },
+    {
+      label: '寮�绁ㄦ棩鏈�',
+      field: 'invoiceTime',
+      render: (val) => formatDateTime(val) as string,
+    },
+    {
+      label: '瀹℃壒鐘舵��',
+      field: 'auditStatus',
+      render: (val) =>
+        h(DictTag, { type: DICT_TYPE.CRM_AUDIT_STATUS, value: val }),
+    },
+    { label: '璐熻矗浜�', field: 'ownerUserName', render: textRender },
+    { label: '鍒涘缓浜�', field: 'creatorName', render: textRender },
+    {
+      label: '鍒涘缓鏃堕棿',
+      field: 'createTime',
+      render: (val) => formatDateTime(val) as string,
+    },
+    { label: '澶囨敞', field: 'remark', render: textRender },
+  ] as DescriptionItemSchema[],
+});
+
+onMounted(async () => {
+  try {
+    loading.value = true;
+    formData.value = await getInvoice(Number(props.id));
+  } finally {
+    loading.value = false;
+  }
+});
+</script>
+
+<template>
+  <ContentWrap class="m-2">
+    <Spin :spinning="loading" tip="鍔犺浇涓�...">
+      <div class="space-y-4">
+        <!-- 寮�绁ㄥ熀鏈俊鎭� -->
+        <div class="rounded-lg border border-border bg-background p-4">
+          <div class="mb-4 flex items-center gap-2">
+            <span class="inline-block h-4 w-1 rounded bg-primary" />
+            <span class="text-base font-semibold">寮�绁ㄥ熀鏈俊鎭�</span>
+          </div>
+          <Descriptions :data="formData" />
+        </div>
+      </div>
+    </Spin>
+  </ContentWrap>
+</template>
diff --git a/src/views/crm/receivable/detail/approve/index.vue b/src/views/crm/receivable/detail/approve/index.vue
new file mode 100644
index 0000000..cc3c795
--- /dev/null
+++ b/src/views/crm/receivable/detail/approve/index.vue
@@ -0,0 +1,112 @@
+<script lang="ts" setup>
+import type { CrmReceivableApi } from '#/api/crm/receivable';
+import type { DescriptionItemSchema } from '#/components/description';
+
+import { h, onMounted, ref } from 'vue';
+
+import { ContentWrap } from '@vben/common-ui';
+import { DICT_TYPE } from '@vben/constants';
+import { erpPriceInputFormatter, formatDateTime } from '@vben/utils';
+
+import { Spin } from 'ant-design-vue';
+
+import { getReceivable } from '#/api/crm/receivable';
+import { DictTag } from '#/components/dict-tag';
+import { useDescription } from '#/components/description';
+
+defineOptions({ name: 'CrmReceivableApproveDetail' });
+
+const props = defineProps<{ id: string }>();
+
+const loading = ref(false);
+const formData = ref<CrmReceivableApi.Receivable>();
+
+/** 鏂囨湰瀛楁绌哄�煎崰浣� */
+function textRender(val: unknown) {
+  return val === 0 || val ? String(val) : '-';
+}
+
+/** 鍏宠仈寮�绁ㄥ睍绀� */
+function invoiceRender(_val: unknown, data?: CrmReceivableApi.Receivable) {
+  if (!data?.invoice?.no) {
+    return '-';
+  }
+  return `${data.invoice.no} / ${data.invoice.invoiceNo ?? '-'} / 楼${
+    data.invoice.price ?? 0
+  }`;
+}
+
+const [Descriptions] = useDescription({
+  bordered: true,
+  column: 2,
+  schema: [
+    { label: '鍥炴缂栧彿', field: 'no', render: textRender },
+    { label: '瀹㈡埛鍚嶇О', field: 'customerName', render: textRender },
+    {
+      label: '鍚堝悓缂栧彿',
+      field: 'contract',
+      render: (_val, data) => (data?.contract?.no ? data.contract.no : '-'),
+    },
+    { label: '鍏宠仈寮�绁�', field: 'invoice', render: invoiceRender },
+    {
+      label: '鍥炴鏃ユ湡',
+      field: 'returnTime',
+      render: (val) => formatDateTime(val) as string,
+    },
+    {
+      label: '鍥炴閲戦锛堝厓锛�',
+      field: 'price',
+      render: (val) => erpPriceInputFormatter(val) || '-',
+    },
+    {
+      label: '鍥炴鏂瑰紡',
+      field: 'returnType',
+      render: (val) =>
+        h(DictTag, {
+          type: DICT_TYPE.CRM_RECEIVABLE_RETURN_TYPE,
+          value: val,
+        }),
+    },
+    {
+      label: '瀹℃壒鐘舵��',
+      field: 'auditStatus',
+      render: (val) =>
+        h(DictTag, { type: DICT_TYPE.CRM_AUDIT_STATUS, value: val }),
+    },
+    { label: '璐熻矗浜�', field: 'ownerUserName', render: textRender },
+    { label: '鍒涘缓浜�', field: 'creatorName', render: textRender },
+    {
+      label: '鍒涘缓鏃堕棿',
+      field: 'createTime',
+      render: (val) => formatDateTime(val) as string,
+    },
+    { label: '澶囨敞', field: 'remark', render: textRender },
+  ] as DescriptionItemSchema[],
+});
+
+onMounted(async () => {
+  try {
+    loading.value = true;
+    formData.value = await getReceivable(Number(props.id));
+  } finally {
+    loading.value = false;
+  }
+});
+</script>
+
+<template>
+  <ContentWrap class="m-2">
+    <Spin :spinning="loading" tip="鍔犺浇涓�...">
+      <div class="space-y-4">
+        <!-- 鍥炴鍩烘湰淇℃伅 -->
+        <div class="rounded-lg border border-border bg-background p-4">
+          <div class="mb-4 flex items-center gap-2">
+            <span class="inline-block h-4 w-1 rounded bg-primary" />
+            <span class="text-base font-semibold">鍥炴鍩烘湰淇℃伅</span>
+          </div>
+          <Descriptions :data="formData" />
+        </div>
+      </div>
+    </Spin>
+  </ContentWrap>
+</template>
diff --git a/src/views/erp/finance/payment/detail/approve/index.vue b/src/views/erp/finance/payment/detail/approve/index.vue
new file mode 100644
index 0000000..c098816
--- /dev/null
+++ b/src/views/erp/finance/payment/detail/approve/index.vue
@@ -0,0 +1,172 @@
+<script lang="ts" setup>
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+import type { ErpFinancePaymentApi } from '#/api/erp/finance/payment';
+import type { DescriptionItemSchema } from '#/components/description';
+
+import { h, nextTick, onMounted, ref } from 'vue';
+
+import { ContentWrap } from '@vben/common-ui';
+import { DICT_TYPE } from '@vben/constants';
+import { erpPriceInputFormatter, formatDateTime } from '@vben/utils';
+
+import { Spin } from 'ant-design-vue';
+
+import { useVbenVxeGrid } from '#/adapter/vxe-table';
+import { getFinancePayment } from '#/api/erp/finance/payment';
+import { DictTag } from '#/components/dict-tag';
+import { useDescription } from '#/components/description';
+
+defineOptions({ name: 'ErpFinancePaymentApproveDetail' });
+
+const props = defineProps<{ id: string }>();
+
+const loading = ref(false);
+const formData = ref<ErpFinancePaymentApi.FinancePayment>();
+
+/** 鏂囨湰瀛楁绌哄�煎崰浣� */
+function textRender(val: unknown) {
+  return val === 0 || val ? String(val) : '-';
+}
+
+/** 浠樻鏄庣粏锛堝鎵硅鎯呭彧璇诲垪锛� */
+function usePaymentItemColumns(): VxeTableGridOptions['columns'] {
+  return [
+    { type: 'seq', title: '搴忓彿', width: 60 },
+    { field: 'bizNo', title: '鏉ョエ缂栧彿', minWidth: 200 },
+    {
+      field: 'totalPrice',
+      title: '搴斾粯閲戦',
+      minWidth: 120,
+      formatter: ({ cellValue }) => erpPriceInputFormatter(cellValue) || '-',
+    },
+    {
+      field: 'paidPrice',
+      title: '宸蹭粯閲戦',
+      minWidth: 120,
+      formatter: ({ cellValue }) => erpPriceInputFormatter(cellValue) || '-',
+    },
+    {
+      field: 'paymentPrice',
+      title: '鏈浠樻',
+      minWidth: 120,
+      formatter: ({ cellValue }) => erpPriceInputFormatter(cellValue) || '-',
+    },
+    {
+      field: 'remark',
+      title: '澶囨敞',
+      minWidth: 150,
+      formatter: ({ cellValue }) => textRender(cellValue),
+    },
+  ];
+}
+
+const [Grid, gridApi] = useVbenVxeGrid({
+  gridOptions: {
+    columns: usePaymentItemColumns(),
+    data: [],
+    minHeight: 200,
+    autoResize: true,
+    border: true,
+    rowConfig: {
+      keyField: 'bizId',
+      isHover: true,
+    },
+    pagerConfig: {
+      enabled: false,
+    },
+    toolbarConfig: {
+      enabled: false,
+    },
+  },
+});
+
+const [Descriptions] = useDescription({
+  bordered: true,
+  column: 2,
+  schema: [
+    { label: '浠樻鍗曞彿', field: 'no', render: textRender },
+    { label: '渚涘簲鍟嗗悕绉�', field: 'supplierName', render: textRender },
+    {
+      label: '鍏宠仈鏉ョエ',
+      field: 'invoice',
+      render: (_val, data) => (data?.invoice?.no ? data.invoice.no : '-'),
+    },
+    {
+      label: '浠樻鏃堕棿',
+      field: 'paymentTime',
+      render: (val) => formatDateTime(val) as string,
+    },
+    {
+      label: '鍚堣閲戦锛堝厓锛�',
+      field: 'totalPrice',
+      render: (val) => erpPriceInputFormatter(val) || '-',
+    },
+    {
+      label: '浼樻儬閲戦锛堝厓锛�',
+      field: 'discountPrice',
+      render: (val) => erpPriceInputFormatter(val) || '-',
+    },
+    {
+      label: '瀹為檯浠樻閲戦锛堝厓锛�',
+      field: 'paymentPrice',
+      render: (val) => erpPriceInputFormatter(val) || '-',
+    },
+    { label: '浠樻璐︽埛', field: 'accountName', render: textRender },
+    { label: '璐㈠姟浜哄憳', field: 'financeUserName', render: textRender },
+    {
+      label: '瀹℃壒鐘舵��',
+      field: 'status',
+      render: (val) =>
+        h(DictTag, { type: DICT_TYPE.ERP_AUDIT_STATUS, value: val }),
+    },
+    { label: '鍒涘缓浜�', field: 'creatorName', render: textRender },
+    {
+      label: '鍒涘缓鏃堕棿',
+      field: 'createTime',
+      render: (val) => formatDateTime(val) as string,
+    },
+    { label: '澶囨敞', field: 'remark', render: textRender },
+  ] as DescriptionItemSchema[],
+});
+
+onMounted(async () => {
+  try {
+    loading.value = true;
+    formData.value = await getFinancePayment(Number(props.id));
+    if (formData.value?.items?.length) {
+      await nextTick();
+      await gridApi.grid.reloadData(formData.value.items);
+    }
+  } finally {
+    loading.value = false;
+  }
+});
+</script>
+
+<template>
+  <ContentWrap class="m-2">
+    <Spin :spinning="loading" tip="鍔犺浇涓�...">
+      <div class="space-y-4">
+        <!-- 浠樻鍗曞熀鏈俊鎭� -->
+        <div class="rounded-lg border border-border bg-background p-4">
+          <div class="mb-4 flex items-center gap-2">
+            <span class="inline-block h-4 w-1 rounded bg-primary" />
+            <span class="text-base font-semibold">浠樻鍗曞熀鏈俊鎭�</span>
+          </div>
+          <Descriptions :data="formData" />
+        </div>
+        <!-- 浠樻鏄庣粏 -->
+        <div
+          v-if="formData?.items?.length"
+          class="rounded-lg border border-border bg-background p-4"
+        >
+          <div class="mb-4 flex items-center gap-2">
+            <span class="inline-block h-4 w-1 rounded bg-primary" />
+            <span class="text-base font-semibold">浠樻鏄庣粏</span>
+          </div>
+          <Grid />
+        </div>
+      </div>
+    </Spin>
+  </ContentWrap>
+</template>
diff --git a/src/views/erp/purchase/invoice/detail/approve/index.vue b/src/views/erp/purchase/invoice/detail/approve/index.vue
new file mode 100644
index 0000000..993efbc
--- /dev/null
+++ b/src/views/erp/purchase/invoice/detail/approve/index.vue
@@ -0,0 +1,90 @@
+<script lang="ts" setup>
+import type { ErpPurchaseInvoiceApi } from '#/api/erp/purchase/invoice';
+import type { DescriptionItemSchema } from '#/components/description';
+
+import { onMounted, ref } from 'vue';
+
+import { ContentWrap } from '@vben/common-ui';
+import { erpPriceInputFormatter, formatDateTime } from '@vben/utils';
+
+import { Spin } from 'ant-design-vue';
+
+import { getPurchaseInvoice } from '#/api/erp/purchase/invoice';
+import { useDescription } from '#/components/description';
+
+defineOptions({ name: 'ErpPurchaseInvoiceApproveDetail' });
+
+const props = defineProps<{ id: string }>();
+
+const loading = ref(false);
+const formData = ref<ErpPurchaseInvoiceApi.PurchaseInvoice>();
+
+/** 鏂囨湰瀛楁绌哄�煎崰浣� */
+function textRender(val: unknown) {
+  return val === 0 || val ? String(val) : '-';
+}
+
+const [Descriptions] = useDescription({
+  bordered: true,
+  column: 2,
+  schema: [
+    { label: '鏉ョエ缂栧彿', field: 'no', render: textRender },
+    { label: '渚涘簲鍟嗗悕绉�', field: 'supplierName', render: textRender },
+    {
+      label: '閲囪喘璁㈠崟鍙�',
+      field: 'order',
+      render: (_val, data) => (data?.order?.no ? data.order.no : '-'),
+    },
+    { label: '鍙戠エ鍙风爜', field: 'invoiceNo', render: textRender },
+    { label: '鍙戠エ鎶ご', field: 'invoiceTitle', render: textRender },
+    {
+      label: '鏉ョエ閲戦锛堝厓锛�',
+      field: 'price',
+      render: (val) => erpPriceInputFormatter(val) || '-',
+    },
+    {
+      label: '鍓╀綑鍙粯閲戦锛堝厓锛�',
+      field: 'remainingInvoicePrice',
+      render: (val) => erpPriceInputFormatter(val ?? 0) || '-',
+    },
+    {
+      label: '寮�绁ㄦ棩鏈�',
+      field: 'invoiceTime',
+      render: (val) => formatDateTime(val) as string,
+    },
+    { label: '鍒涘缓浜�', field: 'creatorName', render: textRender },
+    {
+      label: '鍒涘缓鏃堕棿',
+      field: 'createTime',
+      render: (val) => formatDateTime(val) as string,
+    },
+    { label: '澶囨敞', field: 'remark', render: textRender },
+  ] as DescriptionItemSchema[],
+});
+
+onMounted(async () => {
+  try {
+    loading.value = true;
+    formData.value = await getPurchaseInvoice(Number(props.id));
+  } finally {
+    loading.value = false;
+  }
+});
+</script>
+
+<template>
+  <ContentWrap class="m-2">
+    <Spin :spinning="loading" tip="鍔犺浇涓�...">
+      <div class="space-y-4">
+        <!-- 鏉ョエ鍩烘湰淇℃伅 -->
+        <div class="rounded-lg border border-border bg-background p-4">
+          <div class="mb-4 flex items-center gap-2">
+            <span class="inline-block h-4 w-1 rounded bg-primary" />
+            <span class="text-base font-semibold">鏉ョエ鍩烘湰淇℃伅</span>
+          </div>
+          <Descriptions :data="formData" />
+        </div>
+      </div>
+    </Spin>
+  </ContentWrap>
+</template>
diff --git a/src/views/erp/purchase/order/detail/index.vue b/src/views/erp/purchase/order/detail/index.vue
new file mode 100644
index 0000000..fadc835
--- /dev/null
+++ b/src/views/erp/purchase/order/detail/index.vue
@@ -0,0 +1,134 @@
+<script lang="ts" setup>
+import type { ErpPurchaseOrderApi } from '#/api/erp/purchase/order';
+import type { DescriptionItemSchema } from '#/components/description';
+
+import { h, onMounted, ref } from 'vue';
+
+import { ContentWrap } from '@vben/common-ui';
+import { DICT_TYPE } from '@vben/constants';
+import {
+  erpCountInputFormatter,
+  erpPriceInputFormatter,
+  formatDateTime,
+} from '@vben/utils';
+
+import { Spin, Tag } from 'ant-design-vue';
+
+import { getPurchaseOrder } from '#/api/erp/purchase/order';
+import { DictTag } from '#/components/dict-tag';
+import { useDescription } from '#/components/description';
+
+import ItemForm from '../modules/item-form.vue';
+
+defineOptions({ name: 'ErpPurchaseOrderDetail' });
+
+const props = defineProps<{ id: string }>();
+
+const loading = ref(false);
+const formData = ref<ErpPurchaseOrderApi.PurchaseOrder>();
+
+/** 鏂囨湰瀛楁绌哄�煎崰浣� */
+function textRender(val: unknown) {
+  return val === 0 || val ? String(val) : '-';
+}
+
+/** 鍏ュ簱鐘舵�佹覆鏌� */
+function renderInStatus(val?: number) {
+  const map: Record<number, { text: string; color: string }> = {
+    0: { text: '鏈叆搴�', color: 'default' },
+    1: { text: '閮ㄥ垎鍏ュ簱', color: 'warning' },
+    2: { text: '鍏ㄩ儴鍏ュ簱', color: 'success' },
+  };
+  const item = map[val ?? 0]!;
+  return h(Tag, { color: item.color }, () => item.text);
+}
+
+const [Descriptions] = useDescription({
+  bordered: true,
+  column: 2,
+  schema: [
+    { label: '閲囪喘璁㈠崟鍙�', field: 'no' },
+    { label: '渚涘簲鍟嗗悕绉�', field: 'supplierName', render: textRender },
+    {
+      label: '璁㈠崟鏃堕棿',
+      field: 'orderTime',
+      render: (val) => formatDateTime(val) as string,
+    },
+    {
+      label: '鍚堣鏁伴噺',
+      field: 'totalCount',
+      render: (val) => erpCountInputFormatter(val) || '-',
+    },
+    {
+      label: '鍚堣閲戦',
+      field: 'totalPrice',
+      render: (val) => erpPriceInputFormatter(val) || '-',
+    },
+    {
+      label: '浼樻儬閲戦',
+      field: 'discountPrice',
+      render: (val) => erpPriceInputFormatter(val) || '-',
+    },
+    {
+      label: '瀹氶噾閲戦',
+      field: 'depositPrice',
+      render: (val) => erpPriceInputFormatter(val) || '-',
+    },
+    {
+      label: '瀹℃壒鐘舵��',
+      field: 'status',
+      render: (val) =>
+        h(DictTag, { type: DICT_TYPE.ERP_AUDIT_STATUS, value: val }),
+    },
+    {
+      label: '鍏ュ簱鐘舵��',
+      field: 'inStatus',
+      render: (val) => renderInStatus(val as number),
+    },
+    { label: '鍒涘缓浜�', field: 'creatorName', render: textRender },
+    {
+      label: '鍒涘缓鏃堕棿',
+      field: 'createTime',
+      render: (val) => formatDateTime(val) as string,
+    },
+    { label: '澶囨敞', field: 'remark', render: textRender },
+  ] as DescriptionItemSchema[],
+});
+
+onMounted(async () => {
+  try {
+    loading.value = true;
+    formData.value = await getPurchaseOrder(Number(props.id));
+  } finally {
+    loading.value = false;
+  }
+});
+</script>
+
+<template>
+  <ContentWrap class="m-2">
+    <Spin :spinning="loading" tip="鍔犺浇涓�...">
+      <div class="space-y-4">
+        <!-- 鍩烘湰淇℃伅 -->
+        <div class="rounded-lg border border-border bg-background p-4">
+          <div class="mb-4 flex items-center gap-2">
+            <span class="inline-block h-4 w-1 rounded bg-primary" />
+            <span class="text-base font-semibold">鍩烘湰淇℃伅</span>
+          </div>
+          <Descriptions :data="formData" />
+        </div>
+        <!-- 閲囪喘鏄庣粏 -->
+        <div
+          v-if="formData?.items?.length"
+          class="rounded-lg border border-border bg-background p-4"
+        >
+          <div class="mb-4 flex items-center gap-2">
+            <span class="inline-block h-4 w-1 rounded bg-primary" />
+            <span class="text-base font-semibold">閲囪喘鏄庣粏</span>
+          </div>
+          <ItemForm :items="formData.items" :disabled="true" />
+        </div>
+      </div>
+    </Spin>
+  </ContentWrap>
+</template>
diff --git a/src/views/erp/purchase/request/detail/index.vue b/src/views/erp/purchase/request/detail/index.vue
new file mode 100644
index 0000000..62c383e
--- /dev/null
+++ b/src/views/erp/purchase/request/detail/index.vue
@@ -0,0 +1,181 @@
+<script lang="ts" setup>
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+import type { ErpPurchaseRequestApi } from '#/api/erp/purchase/request';
+import type { DescriptionItemSchema } from '#/components/description';
+
+import { h, nextTick, onMounted, ref } from 'vue';
+
+import { ContentWrap } from '@vben/common-ui';
+import { DICT_TYPE } from '@vben/constants';
+import {
+  erpCountInputFormatter,
+  erpPriceInputFormatter,
+  formatDateTime,
+} from '@vben/utils';
+
+import { Spin } from 'ant-design-vue';
+
+import { useVbenVxeGrid } from '#/adapter/vxe-table';
+import { getPurchaseRequest } from '#/api/erp/purchase/request';
+import { DictTag } from '#/components/dict-tag';
+import { useDescription } from '#/components/description';
+
+defineOptions({ name: 'ErpPurchaseRequestDetail' });
+
+const props = defineProps<{ id: string }>();
+
+const loading = ref(false);
+const formData = ref<ErpPurchaseRequestApi.PurchaseRequest>();
+
+/** 鏂囨湰瀛楁绌哄�煎崰浣� */
+function textRender(val: unknown) {
+  return val === 0 || val ? String(val) : '-';
+}
+
+/** 閲囪喘鐢宠鏄庣粏锛堝鎵硅鎯呭彧璇诲垪锛� */
+function useDetailItemColumns(): VxeTableGridOptions['columns'] {
+  return [
+    { type: 'seq', title: '搴忓彿', width: 60 },
+    { field: 'productName', title: '浜у搧鍚嶇О', minWidth: 180 },
+    { field: 'productUnitName', title: '鍗曚綅', minWidth: 80 },
+    {
+      field: 'count',
+      title: '鏁伴噺',
+      minWidth: 100,
+      formatter: ({ cellValue }) => erpCountInputFormatter(cellValue) || '-',
+    },
+    {
+      field: 'productPrice',
+      title: '鍗曚环锛堝弬鑰冧环锛�',
+      minWidth: 120,
+      formatter: ({ cellValue }) => erpPriceInputFormatter(cellValue) || '-',
+    },
+    {
+      field: 'totalPrice',
+      title: '棰勪及閲戦',
+      minWidth: 120,
+      formatter: ({ cellValue }) => erpPriceInputFormatter(cellValue) || '-',
+    },
+    {
+      field: 'taxPercent',
+      title: '绋庣巼(%)',
+      minWidth: 90,
+      formatter: ({ cellValue }) => (cellValue ? `${cellValue}%` : '-'),
+    },
+    { field: 'demandTime', title: '闇�姹傛棩鏈�', minWidth: 110 },
+    {
+      field: 'remark',
+      title: '澶囨敞',
+      minWidth: 160,
+      showOverflow: 'tooltip',
+    },
+    {
+      field: 'qcCheckFlag',
+      title: '鏄惁璐ㄦ',
+      minWidth: 80,
+      formatter: ({ cellValue }) => (cellValue ? '鏄�' : '鍚�'),
+    },
+  ];
+}
+
+const [Grid, gridApi] = useVbenVxeGrid({
+  gridOptions: {
+    columns: useDetailItemColumns(),
+    data: [],
+    minHeight: 200,
+    autoResize: true,
+    border: true,
+    rowConfig: {
+      keyField: 'seq',
+      isHover: true,
+    },
+    pagerConfig: {
+      enabled: false,
+    },
+    toolbarConfig: {
+      enabled: false,
+    },
+  },
+});
+
+const [Descriptions] = useDescription({
+  bordered: true,
+  column: 2,
+  schema: [
+    { label: '鐢宠缂栧彿', field: 'no' },
+    {
+      label: '瀹℃壒鐘舵��',
+      field: 'status',
+      render: (val) =>
+        h(DictTag, { type: DICT_TYPE.ERP_AUDIT_STATUS, value: val }),
+    },
+    { label: '鐢宠浜�', field: 'requestUserName', render: textRender },
+    { label: '鐢宠閮ㄩ棬', field: 'requestDeptName', render: textRender },
+    {
+      label: '鐢宠鏃堕棿',
+      field: 'requestTime',
+      render: (val) => formatDateTime(val) as string,
+    },
+    { label: '渚涘簲鍟嗗悕绉�', field: 'supplierName', render: textRender },
+    {
+      label: '鍚堣鏁伴噺',
+      field: 'totalCount',
+      render: (val) => erpCountInputFormatter(val) || '-',
+    },
+    {
+      label: '鍚堣閲戦',
+      field: 'totalPrice',
+      render: (val) => erpPriceInputFormatter(val) || '-',
+    },
+    { label: '鐢宠鐞嗙敱', field: 'requestReason', render: textRender },
+    { label: '鐢熸垚閲囪喘璁㈠崟', field: 'orderNo', render: textRender },
+    {
+      label: '鍒涘缓鏃堕棿',
+      field: 'createTime',
+      render: (val) => formatDateTime(val) as string,
+    },
+    { label: '澶囨敞', field: 'remark', render: textRender },
+  ] as DescriptionItemSchema[],
+});
+
+onMounted(async () => {
+  try {
+    loading.value = true;
+    formData.value = await getPurchaseRequest(Number(props.id));
+    if (formData.value?.items?.length) {
+      await nextTick();
+      await gridApi.grid.reloadData(formData.value.items);
+    }
+  } finally {
+    loading.value = false;
+  }
+});
+</script>
+
+<template>
+  <ContentWrap class="m-2">
+    <Spin :spinning="loading" tip="鍔犺浇涓�...">
+      <div class="space-y-4">
+        <!-- 鍩烘湰淇℃伅 -->
+        <div class="rounded-lg border border-border bg-background p-4">
+          <div class="mb-4 flex items-center gap-2">
+            <span class="inline-block h-4 w-1 rounded bg-primary" />
+            <span class="text-base font-semibold">鍩烘湰淇℃伅</span>
+          </div>
+          <Descriptions :data="formData" />
+        </div>
+        <!-- 鐢宠鏄庣粏 -->
+        <div
+          v-if="formData?.items?.length"
+          class="rounded-lg border border-border bg-background p-4"
+        >
+          <div class="mb-4 flex items-center gap-2">
+            <span class="inline-block h-4 w-1 rounded bg-primary" />
+            <span class="text-base font-semibold">鐢宠鏄庣粏</span>
+          </div>
+          <Grid />
+        </div>
+      </div>
+    </Spin>
+  </ContentWrap>
+</template>
diff --git a/src/views/hrm/attendance/exception/detail/approve/index.vue b/src/views/hrm/attendance/exception/detail/approve/index.vue
new file mode 100644
index 0000000..e0fef3a
--- /dev/null
+++ b/src/views/hrm/attendance/exception/detail/approve/index.vue
@@ -0,0 +1,82 @@
+<script lang="ts" setup>
+import type { HrmAttendanceExceptionApi } from '#/api/hrm/attendance/exception';
+import type { DescriptionItemSchema } from '#/components/description';
+
+import { h, onMounted, ref } from 'vue';
+
+import { ContentWrap } from '@vben/common-ui';
+import { DICT_TYPE } from '@vben/constants';
+import { formatDateTime } from '@vben/utils';
+
+import { Spin } from 'ant-design-vue';
+
+import { getAttendanceException } from '#/api/hrm/attendance/exception';
+import { DictTag } from '#/components/dict-tag';
+import { useDescription } from '#/components/description';
+
+defineOptions({ name: 'HrmAttendanceExceptionApproveDetail' });
+
+const props = defineProps<{ id: string }>();
+
+const loading = ref(false);
+const formData = ref<HrmAttendanceExceptionApi.AttendanceException>();
+
+/** 鏂囨湰瀛楁绌哄�煎崰浣� */
+function textRender(val: unknown) {
+  return val === 0 || val ? String(val) : '-';
+}
+
+const [Descriptions] = useDescription({
+  bordered: true,
+  column: 2,
+  schema: [
+    { label: '鍛樺伐濮撳悕', field: 'userName', render: textRender },
+    { label: '鎵�灞為儴闂�', field: 'deptName', render: textRender },
+    {
+      label: '寮傚父鏃ユ湡',
+      field: 'date',
+      render: (val) => formatDateTime(val) as string,
+    },
+    { label: '寮傚父绫诲瀷', field: 'exceptionTypeName', render: textRender },
+    { label: '寮傚父璇存槑', field: 'reason', render: textRender },
+    {
+      label: '瀹℃壒鐘舵��',
+      field: 'status',
+      render: (val) =>
+        h(DictTag, { type: DICT_TYPE.HRM_AUDIT_STATUS, value: val }),
+    },
+    { label: '澶囨敞', field: 'remark', render: textRender },
+    {
+      label: '鍒涘缓鏃堕棿',
+      field: 'createTime',
+      render: (val) => formatDateTime(val) as string,
+    },
+  ] as DescriptionItemSchema[],
+});
+
+onMounted(async () => {
+  try {
+    loading.value = true;
+    formData.value = await getAttendanceException(Number(props.id));
+  } finally {
+    loading.value = false;
+  }
+});
+</script>
+
+<template>
+  <ContentWrap class="m-2">
+    <Spin :spinning="loading" tip="鍔犺浇涓�...">
+      <div class="space-y-4">
+        <!-- 鑰冨嫟寮傚父鍩烘湰淇℃伅 -->
+        <div class="rounded-lg border border-border bg-background p-4">
+          <div class="mb-4 flex items-center gap-2">
+            <span class="inline-block h-4 w-1 rounded bg-primary" />
+            <span class="text-base font-semibold">鑰冨嫟寮傚父鍩烘湰淇℃伅</span>
+          </div>
+          <Descriptions :data="formData" />
+        </div>
+      </div>
+    </Spin>
+  </ContentWrap>
+</template>
diff --git a/src/views/hrm/leave/detail/approve/index.vue b/src/views/hrm/leave/detail/approve/index.vue
new file mode 100644
index 0000000..59248e4
--- /dev/null
+++ b/src/views/hrm/leave/detail/approve/index.vue
@@ -0,0 +1,93 @@
+<script lang="ts" setup>
+import type { HrmLeaveApi } from '#/api/hrm/leave';
+import type { DescriptionItemSchema } from '#/components/description';
+
+import { h, onMounted, ref } from 'vue';
+
+import { ContentWrap } from '@vben/common-ui';
+import { DICT_TYPE } from '@vben/constants';
+import { formatDateTime } from '@vben/utils';
+
+import { Spin } from 'ant-design-vue';
+
+import { getLeave } from '#/api/hrm/leave';
+import { DictTag } from '#/components/dict-tag';
+import { useDescription } from '#/components/description';
+
+defineOptions({ name: 'HrmLeaveApproveDetail' });
+
+const props = defineProps<{ id: string }>();
+
+const loading = ref(false);
+const formData = ref<HrmLeaveApi.Leave>();
+
+/** 鏂囨湰瀛楁绌哄�煎崰浣� */
+function textRender(val: unknown) {
+  return val === 0 || val ? String(val) : '-';
+}
+
+const [Descriptions] = useDescription({
+  bordered: true,
+  column: 2,
+  schema: [
+    { label: '鍛樺伐濮撳悕', field: 'employeeName', render: textRender },
+    { label: '鎵�灞為儴闂�', field: 'deptName', render: textRender },
+    {
+      label: '璇峰亣绫诲瀷',
+      field: 'leaveType',
+      render: (val) =>
+        h(DictTag, { type: DICT_TYPE.HRM_LEAVE_TYPE, value: val }),
+    },
+    {
+      label: '寮�濮嬫椂闂�',
+      field: 'startTime',
+      render: (val) => formatDateTime(val) as string,
+    },
+    {
+      label: '缁撴潫鏃堕棿',
+      field: 'endTime',
+      render: (val) => formatDateTime(val) as string,
+    },
+    { label: '璇峰亣鏃堕暱', field: 'duration', render: textRender },
+    { label: '璇峰亣浜嬬敱', field: 'reason', render: textRender },
+    {
+      label: '瀹℃壒鐘舵��',
+      field: 'status',
+      render: (val) =>
+        h(DictTag, { type: DICT_TYPE.HRM_AUDIT_STATUS, value: val }),
+    },
+    { label: '澶囨敞', field: 'remark', render: textRender },
+    {
+      label: '鍒涘缓鏃堕棿',
+      field: 'createTime',
+      render: (val) => formatDateTime(val) as string,
+    },
+  ] as DescriptionItemSchema[],
+});
+
+onMounted(async () => {
+  try {
+    loading.value = true;
+    formData.value = await getLeave(Number(props.id));
+  } finally {
+    loading.value = false;
+  }
+});
+</script>
+
+<template>
+  <ContentWrap class="m-2">
+    <Spin :spinning="loading" tip="鍔犺浇涓�...">
+      <div class="space-y-4">
+        <!-- 璇峰亣鍩烘湰淇℃伅 -->
+        <div class="rounded-lg border border-border bg-background p-4">
+          <div class="mb-4 flex items-center gap-2">
+            <span class="inline-block h-4 w-1 rounded bg-primary" />
+            <span class="text-base font-semibold">璇峰亣鍩烘湰淇℃伅</span>
+          </div>
+          <Descriptions :data="formData" />
+        </div>
+      </div>
+    </Spin>
+  </ContentWrap>
+</template>
diff --git a/src/views/hrm/resignation/detail/approve/index.vue b/src/views/hrm/resignation/detail/approve/index.vue
new file mode 100644
index 0000000..4e3a587
--- /dev/null
+++ b/src/views/hrm/resignation/detail/approve/index.vue
@@ -0,0 +1,95 @@
+<script lang="ts" setup>
+import type { HrmResignationApi } from '#/api/hrm/resignation';
+import type { DescriptionItemSchema } from '#/components/description';
+
+import { h, onMounted, ref } from 'vue';
+
+import { ContentWrap } from '@vben/common-ui';
+import { DICT_TYPE } from '@vben/constants';
+import { formatDateTime } from '@vben/utils';
+
+import { Spin } from 'ant-design-vue';
+
+import { getResignation } from '#/api/hrm/resignation';
+import { DictTag } from '#/components/dict-tag';
+import { useDescription } from '#/components/description';
+
+defineOptions({ name: 'HrmResignationApproveDetail' });
+
+const props = defineProps<{ id: string }>();
+
+const loading = ref(false);
+const formData = ref<HrmResignationApi.Resignation>();
+
+/** 鏂囨湰瀛楁绌哄�煎崰浣� */
+function textRender(val: unknown) {
+  return val === 0 || val ? String(val) : '-';
+}
+
+const [Descriptions] = useDescription({
+  bordered: true,
+  column: 2,
+  schema: [
+    { label: '鍛樺伐濮撳悕', field: 'employeeName', render: textRender },
+    { label: '鎵�灞為儴闂�', field: 'deptName', render: textRender },
+    { label: '鑱屼綅', field: 'postName', render: textRender },
+    {
+      label: '绂昏亴绫诲瀷',
+      field: 'resignationType',
+      render: (val) =>
+        h(DictTag, { type: DICT_TYPE.HRM_RESIGNATION_TYPE, value: val }),
+    },
+    {
+      label: '棰勮绂昏亴鏃ユ湡',
+      field: 'expectedDate',
+      render: (val) => formatDateTime(val) as string,
+    },
+    { label: '绂昏亴鍘熷洜', field: 'reason', render: textRender },
+    { label: '浜ゆ帴浜�', field: 'handoverUserName', render: textRender },
+    {
+      label: '浜ゆ帴鐘舵��',
+      field: 'handoverStatus',
+      render: (val) =>
+        h(DictTag, { type: DICT_TYPE.HRM_HANDOVER_STATUS, value: val }),
+    },
+    {
+      label: '瀹℃壒鐘舵��',
+      field: 'status',
+      render: (val) =>
+        h(DictTag, { type: DICT_TYPE.HRM_AUDIT_STATUS, value: val }),
+    },
+    { label: '澶囨敞', field: 'remark', render: textRender },
+    {
+      label: '鍒涘缓鏃堕棿',
+      field: 'createTime',
+      render: (val) => formatDateTime(val) as string,
+    },
+  ] as DescriptionItemSchema[],
+});
+
+onMounted(async () => {
+  try {
+    loading.value = true;
+    formData.value = await getResignation(Number(props.id));
+  } finally {
+    loading.value = false;
+  }
+});
+</script>
+
+<template>
+  <ContentWrap class="m-2">
+    <Spin :spinning="loading" tip="鍔犺浇涓�...">
+      <div class="space-y-4">
+        <!-- 绂昏亴鍩烘湰淇℃伅 -->
+        <div class="rounded-lg border border-border bg-background p-4">
+          <div class="mb-4 flex items-center gap-2">
+            <span class="inline-block h-4 w-1 rounded bg-primary" />
+            <span class="text-base font-semibold">绂昏亴鍩烘湰淇℃伅</span>
+          </div>
+          <Descriptions :data="formData" />
+        </div>
+      </div>
+    </Spin>
+  </ContentWrap>
+</template>
diff --git a/src/views/hrm/transfer/detail/approve/index.vue b/src/views/hrm/transfer/detail/approve/index.vue
new file mode 100644
index 0000000..5d983ef
--- /dev/null
+++ b/src/views/hrm/transfer/detail/approve/index.vue
@@ -0,0 +1,90 @@
+<script lang="ts" setup>
+import type { HrmTransferApi } from '#/api/hrm/transfer';
+import type { DescriptionItemSchema } from '#/components/description';
+
+import { h, onMounted, ref } from 'vue';
+
+import { ContentWrap } from '@vben/common-ui';
+import { DICT_TYPE } from '@vben/constants';
+import { formatDateTime } from '@vben/utils';
+
+import { Spin } from 'ant-design-vue';
+
+import { getTransfer } from '#/api/hrm/transfer';
+import { DictTag } from '#/components/dict-tag';
+import { useDescription } from '#/components/description';
+
+defineOptions({ name: 'HrmTransferApproveDetail' });
+
+const props = defineProps<{ id: string }>();
+
+const loading = ref(false);
+const formData = ref<HrmTransferApi.Transfer>();
+
+/** 鏂囨湰瀛楁绌哄�煎崰浣� */
+function textRender(val: unknown) {
+  return val === 0 || val ? String(val) : '-';
+}
+
+const [Descriptions] = useDescription({
+  bordered: true,
+  column: 2,
+  schema: [
+    { label: '鍛樺伐濮撳悕', field: 'employeeName', render: textRender },
+    { label: '褰撳墠閮ㄩ棬', field: 'currentDeptName', render: textRender },
+    { label: '褰撳墠鑱屼綅', field: 'currentPostName', render: textRender },
+    { label: '鐩爣閮ㄩ棬', field: 'targetDeptName', render: textRender },
+    { label: '鐩爣鑱屼綅', field: 'targetPostName', render: textRender },
+    {
+      label: '璋冨矖绫诲瀷',
+      field: 'transferType',
+      render: (val) =>
+        h(DictTag, { type: DICT_TYPE.HRM_TRANSFER_TYPE, value: val }),
+    },
+    {
+      label: '鐢熸晥鏃ユ湡',
+      field: 'effectiveDate',
+      render: (val) => formatDateTime(val) as string,
+    },
+    { label: '璋冨矖鍘熷洜', field: 'reason', render: textRender },
+    {
+      label: '瀹℃壒鐘舵��',
+      field: 'status',
+      render: (val) =>
+        h(DictTag, { type: DICT_TYPE.HRM_AUDIT_STATUS, value: val }),
+    },
+    { label: '澶囨敞', field: 'remark', render: textRender },
+    {
+      label: '鍒涘缓鏃堕棿',
+      field: 'createTime',
+      render: (val) => formatDateTime(val) as string,
+    },
+  ] as DescriptionItemSchema[],
+});
+
+onMounted(async () => {
+  try {
+    loading.value = true;
+    formData.value = await getTransfer(Number(props.id));
+  } finally {
+    loading.value = false;
+  }
+});
+</script>
+
+<template>
+  <ContentWrap class="m-2">
+    <Spin :spinning="loading" tip="鍔犺浇涓�...">
+      <div class="space-y-4">
+        <!-- 璋冨矖鍩烘湰淇℃伅 -->
+        <div class="rounded-lg border border-border bg-background p-4">
+          <div class="mb-4 flex items-center gap-2">
+            <span class="inline-block h-4 w-1 rounded bg-primary" />
+            <span class="text-base font-semibold">璋冨矖鍩烘湰淇℃伅</span>
+          </div>
+          <Descriptions :data="formData" />
+        </div>
+      </div>
+    </Spin>
+  </ContentWrap>
+</template>

--
Gitblit v1.9.3