From f9e6eb6fc2c2e0c2a2b17238b817fcbfcb6bab56 Mon Sep 17 00:00:00 2001
From: 云 <2163098428@qq.com>
Date: 星期一, 10 八月 2026 16:38:45 +0800
Subject: [PATCH] feat(erp): 添加采购来票管理和付款关联功能

---
 src/views/hrm/employee/modules/contract-list.vue                 |  107 +++
 src/views/hrm/employee/contract/modules/terminate.vue            |  103 +++
 src/views/erp/finance/payment/data.ts                            |   57 +
 src/views/erp/purchase/invoice/index.vue                         |  221 ++++++
 src/views/hrm/employee/contract/index.vue                        |  164 +++++
 src/views/hrm/employee/contract/data.ts                          |  297 +++++++++
 src/views/erp/purchase/invoice/modules/form.vue                  |  104 +++
 src/views/erp/finance/payment/modules/form.vue                   |    4 
 src/views/erp/purchase/invoice/data.ts                           |  241 +++++++
 src/api/hrm/employee/contract/index.ts                           |  130 ++++
 src/views/hrm/employee/contract/modules/form.vue                 |  126 +++
 src/views/erp/purchase/invoice/modules/purchase-order-select.vue |  185 +++++
 src/api/erp/purchase/invoice/index.ts                            |  113 +++
 src/views/erp/finance/payment/index.vue                          |    7 
 src/views/hrm/employee/modules/detail.vue                        |    4 
 src/packages/constants/src/dict-enum.ts                          |    4 
 src/api/erp/finance/payment/index.ts                             |    4 
 17 files changed, 1,869 insertions(+), 2 deletions(-)

diff --git a/src/api/erp/finance/payment/index.ts b/src/api/erp/finance/payment/index.ts
index 7f8586d..6efa6e3 100644
--- a/src/api/erp/finance/payment/index.ts
+++ b/src/api/erp/finance/payment/index.ts
@@ -1,5 +1,7 @@
 import type { PageParam, PageResult } from '@vben/request';
 
+import type { ErpPurchaseInvoiceApi } from '#/api/erp/purchase/invoice';
+
 import { requestClient } from '#/api/request';
 
 export namespace ErpFinancePaymentApi {
@@ -9,6 +11,8 @@
     no: string; // 浠樻鍗曞彿
     supplierId?: number; // 渚涘簲鍟嗙紪鍙�
     supplierName?: string; // 渚涘簲鍟嗗悕绉�
+    invoiceId?: number; // 鍏宠仈鏉ョエ缂栧彿
+    invoice?: ErpPurchaseInvoiceApi.PurchaseInvoice; // 鍏宠仈鏉ョエ淇℃伅
     paymentTime?: Date; // 浠樻鏃堕棿
     totalPrice: number; // 鍚堣閲戦锛屽崟浣嶏細鍏�
     discountPrice: number; // 浼樻儬閲戦
diff --git a/src/api/erp/purchase/invoice/index.ts b/src/api/erp/purchase/invoice/index.ts
new file mode 100644
index 0000000..cac70f5
--- /dev/null
+++ b/src/api/erp/purchase/invoice/index.ts
@@ -0,0 +1,113 @@
+import type { PageParam, PageResult } from '@vben/request';
+
+import { requestClient } from '#/api/request';
+
+export namespace ErpPurchaseInvoiceApi {
+  /** 閲囪喘璁㈠崟淇℃伅锛堟潵绁ㄥ叧鑱旓級 */
+  export interface PurchaseOrder {
+    id?: number;
+    no?: string;
+    supplierId?: number;
+    supplierName?: string;
+    orderTime?: Date | string;
+    totalPrice?: number;
+    status?: number;
+  }
+
+  /** 鏉ョエ淇℃伅 */
+  export interface PurchaseInvoice {
+    id?: number;
+    no?: string;
+    supplierId?: number;
+    supplierName?: string;
+    purchaseOrderId?: number;
+    order?: PurchaseOrder;
+    invoiceNo?: string;
+    invoiceTitle?: string;
+    price?: number;
+    invoiceTime?: string;
+    auditStatus?: number;
+    processInstanceId?: string;
+    remark?: string;
+    attachmentList?: { id: number; name?: string; url?: string }[];
+    blobIds?: number[];
+    hasAttachment?: boolean;
+    remainingInvoicePrice?: number;
+    creator?: string;
+    creatorName?: string;
+    createTime?: string;
+    updateTime?: string;
+  }
+
+  /** 鏉ョエ绠�瑕佷俊鎭紙鐢ㄤ簬浠樻鍏宠仈閫夋嫨锛� */
+  export interface PurchaseInvoiceSimple {
+    id: number;
+    no: string;
+    invoiceNo?: string;
+    invoiceTitle?: string;
+    price?: number;
+    supplierId?: number;
+    purchaseOrderId?: number;
+    auditStatus?: number;
+    hasAttachment?: boolean;
+    remainingInvoicePrice?: number;
+  }
+}
+
+/** 鏌ヨ鏉ョエ鍒嗛〉 */
+export function getPurchaseInvoicePage(params: PageParam) {
+  return requestClient.get<PageResult<ErpPurchaseInvoiceApi.PurchaseInvoice>>(
+    '/erp/purchase-invoice/page',
+    { params },
+  );
+}
+
+/** 鏌ヨ鏉ョエ璇︽儏 */
+export function getPurchaseInvoice(id: number) {
+  return requestClient.get<ErpPurchaseInvoiceApi.PurchaseInvoice>(
+    `/erp/purchase-invoice/get?id=${id}`,
+  );
+}
+
+/** 鏂板鏉ョエ */
+export function createPurchaseInvoice(data: ErpPurchaseInvoiceApi.PurchaseInvoice) {
+  return requestClient.post('/erp/purchase-invoice/create', data);
+}
+
+/** 淇敼鏉ョエ */
+export function updatePurchaseInvoice(data: ErpPurchaseInvoiceApi.PurchaseInvoice) {
+  return requestClient.put('/erp/purchase-invoice/update', data);
+}
+
+/** 鍒犻櫎鏉ョエ */
+export function deletePurchaseInvoice(id: number) {
+  return requestClient.delete(`/erp/purchase-invoice/delete?id=${id}`);
+}
+
+/** 瀵煎嚭鏉ョエ */
+export function exportPurchaseInvoice(params: Record<string, unknown>) {
+  return requestClient.download('/erp/purchase-invoice/export-excel', { params });
+}
+
+/** 鎻愪氦鏉ョエ瀹℃牳 */
+export function submitPurchaseInvoice(id: number) {
+  return requestClient.put(`/erp/purchase-invoice/submit?id=${id}`);
+}
+
+/** 鑾峰緱寰呭鏍告潵绁ㄦ暟閲� */
+export function getAuditPurchaseInvoiceCount() {
+  return requestClient.get<number>('/erp/purchase-invoice/audit-count');
+}
+
+/** 鏌ヨ鏉ョエ涓嬫媺鍒楄〃锛堟寜渚涘簲鍟嗗彲閫夋潵绁紝浠呭鏍搁�氳繃涓斿凡涓婁紶鍙戠エ闄勪欢锛� */
+export function getPurchaseInvoiceSimpleList(
+  supplierId: number,
+  purchaseOrderId?: number,
+) {
+  const params = `supplierId=${supplierId}${
+    purchaseOrderId ? `&purchaseOrderId=${purchaseOrderId}` : ''
+  }`;
+  return requestClient.get<ErpPurchaseInvoiceApi.PurchaseInvoiceSimple[]>(
+    `/erp/purchase-invoice/simple-list?${params}`,
+  );
+}
diff --git a/src/api/hrm/employee/contract/index.ts b/src/api/hrm/employee/contract/index.ts
new file mode 100644
index 0000000..38f1d73
--- /dev/null
+++ b/src/api/hrm/employee/contract/index.ts
@@ -0,0 +1,130 @@
+import type { PageParam, PageResult } from '#/packages/effects/request/src';
+
+import { requestClient } from '#/api/request';
+
+export namespace HrmEmployeeContractApi {
+  /** 鍛樺伐鍚堝悓 */
+  export interface EmployeeContract {
+    id?: number;
+    /** 缁涓婁竴浠藉悎鍚孖D */
+    parentId?: number;
+    /** 缁涓婁竴浠藉悎鍚岀紪鍙� */
+    parentNo?: string;
+    /** 鏄惁褰撳墠鐢熸晥鍚堝悓 */
+    isCurrent?: boolean;
+    /** 鍚堝悓缂栧彿锛堝悗绔嚜鍔ㄧ敓鎴愶級 */
+    contractNo?: string;
+    /** 鍛樺伐ID */
+    employeeId?: number;
+    /** 鍛樺伐濮撳悕 */
+    employeeName?: string;
+    /** 鍛樺伐宸ュ彿 */
+    employeeNo?: string;
+    /** 閮ㄩ棬鍚嶇О */
+    deptName?: string;
+    /** 鍚堝悓绫诲瀷锛�1-鍔冲姩鍚堝悓 2-鍔冲姟鍚堝悓 3-瀹炰範鍗忚 4-鍔冲姟娲鹃仯 5-鍏朵粬 */
+    contractType?: number;
+    /** 鍚堝悓鏈熼檺绫诲瀷锛�1-鍥哄畾鏈熼檺 2-鏃犲浐瀹氭湡闄� 3-浠ュ畬鎴愪竴瀹氬伐浣滀换鍔′负鏈熼檺 */
+    contractTermType?: number;
+    /** 绛剧害涓讳綋 */
+    signCompany?: string;
+    /** 绛捐鏃ユ湡 */
+    signDate?: string;
+    /** 鍚堝悓寮�濮嬫棩鏈� */
+    startDate?: string;
+    /** 鍚堝悓缁撴潫鏃ユ湡 */
+    endDate?: string;
+    /** 璇曠敤鏈熷紑濮嬫棩鏈� */
+    probationStartDate?: string;
+    /** 璇曠敤鏈熺粨鏉熸棩鏈� */
+    probationEndDate?: string;
+    /** 璇曠敤鏈熷伐璧� */
+    probationSalary?: number;
+    /** 杞宸ヨ祫 */
+    regularSalary?: number;
+    /** 瑙i櫎缁堟鐘舵�侊細0-姝e父 1-宸茶В闄� 2-宸茬粓姝� */
+    terminateStatus?: number;
+    /** 瑙i櫎/缁堟鏃ユ湡 */
+    terminateDate?: string;
+    /** 瑙i櫎/缁堟鍘熷洜 */
+    terminateReason?: string;
+    /** 鍚堝悓鐘舵�侊紙鍔ㄦ�佽绠楋級锛�1-寰呯敓鏁� 2-鐢熸晥涓� 3-鍗冲皢鍒版湡 4-宸插埌鏈� 5-宸茶В闄� 6-宸茬粓姝� */
+    status?: number;
+    /** 闄勪欢 */
+    attachmentList?: AttachmentItem[];
+    /** 闄勪欢 blobId 鍒楄〃 */
+    blobIds?: number[];
+    remark?: string;
+    createTime?: string;
+  }
+
+  /** 闄勪欢椤� */
+  export interface AttachmentItem {
+    id: number;
+    name?: string;
+    url?: string;
+    uid?: string;
+  }
+
+  /** 鍛樺伐鍚堝悓鏌ヨ鍙傛暟 */
+  export interface EmployeeContractPageParam extends PageParam {
+    contractNo?: string;
+    employeeId?: number;
+    contractType?: number;
+    contractTermType?: number;
+    terminateStatus?: number;
+    status?: number;
+    /** 鍒版湡绫诲瀷锛�1-鍗冲皢鍒版湡 2-宸插埌鏈� */
+    expiryType?: number;
+  }
+
+  /** 瑙i櫎/缁堟鍚堝悓璇锋眰 */
+  export interface TerminateReq {
+    id: number;
+    terminateStatus: number;
+    terminateDate: string;
+    terminateReason: string;
+  }
+}
+
+/** 鑾峰彇鍛樺伐鍚堝悓鍒嗛〉鍒楄〃 */
+export function getEmployeeContractPage(
+  params: HrmEmployeeContractApi.EmployeeContractPageParam,
+) {
+  return requestClient.get<PageResult<HrmEmployeeContractApi.EmployeeContract>>(
+    '/hrm/employee-contract/page',
+    { params },
+  );
+}
+
+/** 鑾峰彇鍛樺伐鍚堝悓璇︽儏 */
+export function getEmployeeContract(id: number) {
+  return requestClient.get<HrmEmployeeContractApi.EmployeeContract>(
+    `/hrm/employee-contract/get?id=${id}`,
+  );
+}
+
+/** 鍒涘缓鍛樺伐鍚堝悓 */
+export function createEmployeeContract(data: HrmEmployeeContractApi.EmployeeContract) {
+  return requestClient.post('/hrm/employee-contract/create', data);
+}
+
+/** 鏇存柊鍛樺伐鍚堝悓 */
+export function updateEmployeeContract(data: HrmEmployeeContractApi.EmployeeContract) {
+  return requestClient.put('/hrm/employee-contract/update', data);
+}
+
+/** 瑙i櫎/缁堟鍛樺伐鍚堝悓 */
+export function terminateEmployeeContract(data: HrmEmployeeContractApi.TerminateReq) {
+  return requestClient.put('/hrm/employee-contract/terminate', data);
+}
+
+/** 鍒犻櫎鍛樺伐鍚堝悓 */
+export function deleteEmployeeContract(id: number) {
+  return requestClient.delete(`/hrm/employee-contract/delete?id=${id}`);
+}
+
+/** 鑾峰彇鍗冲皢鍒版湡鍚堝悓鏁伴噺 */
+export function getExpiringContractCount() {
+  return requestClient.get<number>('/hrm/employee-contract/expiring-count');
+}
diff --git a/src/packages/constants/src/dict-enum.ts b/src/packages/constants/src/dict-enum.ts
index fa057e0..b7c45f8 100644
--- a/src/packages/constants/src/dict-enum.ts
+++ b/src/packages/constants/src/dict-enum.ts
@@ -321,6 +321,10 @@
   HRM_SALARY_STATUS: 'hrm_salary_status', // HRM 钖祫鐘舵��
   HRM_HANDOVER_STATUS: 'hrm_handover_status', // HRM 浜ゆ帴鐘舵��
   HRM_PAYMENT_METHOD: 'hrm_payment_method', // HRM 鍙戞斁鏂瑰紡
+  HRM_CONTRACT_TYPE: 'hrm_contract_type', // HRM 鍚堝悓绫诲瀷
+  HRM_CONTRACT_TERM_TYPE: 'hrm_contract_term_type', // HRM 鍚堝悓鏈熼檺绫诲瀷
+  HRM_CONTRACT_STATUS: 'hrm_contract_status', // HRM 鍚堝悓鐘舵��
+  HRM_CONTRACT_TERMINATE_STATUS: 'hrm_contract_terminate_status', // HRM 鍚堝悓瑙i櫎缁堟鐘舵��
 } as const;
 
 /** 瀛楀吀绫诲瀷鏋氫妇 - 缁熶竴瀵煎嚭 */
diff --git a/src/views/erp/finance/payment/data.ts b/src/views/erp/finance/payment/data.ts
index 22aa220..85cf9ad 100644
--- a/src/views/erp/finance/payment/data.ts
+++ b/src/views/erp/finance/payment/data.ts
@@ -6,6 +6,7 @@
 import { erpPriceInputFormatter } from '@vben/utils';
 
 import { getAccountSimpleList } from '#/api/erp/finance/account';
+import { getPurchaseInvoiceSimpleList } from '#/api/erp/purchase/invoice';
 import { getSupplierSimpleList } from '#/api/srm/supplier';
 import { getSimpleUserList } from '#/api/system/user';
 import { getRangePickerDefaultProps } from '#/utils';
@@ -50,7 +51,7 @@
       fieldName: 'supplierId',
       label: '渚涘簲鍟�',
       component: 'ApiSelect',
-      componentProps: {
+      componentProps: (_values, form) => ({
         disabled: formType === 'detail',
         placeholder: '璇烽�夋嫨渚涘簲鍟�',
         allowClear: true,
@@ -58,8 +59,54 @@
         api: getSupplierSimpleList,
         labelField: 'name',
         valueField: 'id',
-      },
+        onChange: () => {
+          // 渚涘簲鍟嗗彉鍖栧悗锛屾竻绌哄凡閫夌殑鍏宠仈鏉ョエ
+          form.setFieldValue('invoiceId', undefined);
+        },
+      }),
       rules: 'required',
+    },
+    {
+      fieldName: 'invoiceId',
+      label: '鍏宠仈鏉ョエ',
+      component: formType === 'detail' ? 'Input' : 'Select',
+      componentProps:
+        formType === 'detail'
+          ? {
+              disabled: true,
+              placeholder: '--',
+            }
+          : undefined,
+      rules: 'required',
+      dependencies: {
+        // 渚濊禆 invoiceId 鑷韩锛岀‘淇濈紪杈戝洖鏄炬椂閲嶆柊鍔犺浇閫夐」浠ュ尮閰嶅凡閫夋潵绁�
+        triggerFields: ['supplierId', 'invoiceId'],
+        disabled: (values) => formType === 'detail' || !values.supplierId,
+        async componentProps(values) {
+          if (formType === 'detail') {
+            return {};
+          }
+          if (!values.supplierId) {
+            return {
+              options: [],
+              placeholder: '璇峰厛閫夋嫨渚涘簲鍟�',
+            };
+          }
+          const invoices = await getPurchaseInvoiceSimpleList(
+            values.supplierId,
+          );
+          return {
+            options: invoices.map((item) => {
+              const remain = item.remainingInvoicePrice ?? item.price ?? 0;
+              return {
+                label: `${item.no} / ${item.invoiceNo ?? '-'} / 楼${item.price ?? 0} / 鍓╀綑鍙粯 楼${remain.toFixed(2)}`,
+                value: item.id,
+              };
+            }),
+            placeholder: '璇烽�夋嫨鍏宠仈鏉ョエ',
+          } as any;
+        },
+      },
     },
     {
       fieldName: 'financeUserId',
@@ -335,6 +382,12 @@
       minWidth: 120,
     },
     {
+      field: 'invoice',
+      title: '鍏宠仈鏉ョエ',
+      minWidth: 200,
+      slots: { default: 'invoice' },
+    },
+    {
       field: 'paymentTime',
       title: '浠樻鏃堕棿',
       width: 160,
diff --git a/src/views/erp/finance/payment/index.vue b/src/views/erp/finance/payment/index.vue
index 3c5637d..a2aa1bc 100644
--- a/src/views/erp/finance/payment/index.vue
+++ b/src/views/erp/finance/payment/index.vue
@@ -167,6 +167,13 @@
           ]"
         />
       </template>
+      <template #invoice="{ row }">
+        <span v-if="row.invoice">
+          {{ row.invoice.no
+          }}{{ row.invoice.invoiceNo ? ` / ${row.invoice.invoiceNo}` : '' }}
+        </span>
+        <span v-else>--</span>
+      </template>
       <template #actions="{ row }">
         <TableAction
           :actions="[
diff --git a/src/views/erp/finance/payment/modules/form.vue b/src/views/erp/finance/payment/modules/form.vue
index 9d92bb2..d00de4a 100644
--- a/src/views/erp/finance/payment/modules/form.vue
+++ b/src/views/erp/finance/payment/modules/form.vue
@@ -162,6 +162,10 @@
       formData.value = await getFinancePayment(data.id);
       // 璁剧疆鍒� values
       await formApi.setValues(formData.value, false);
+      // 璇︽儏妯″紡涓嬶紝鍏宠仈鏉ョエ瀛楁鐢� Input 灞曠ず鏉ョエ缂栧彿
+      if (formType.value === 'detail' && formData.value?.invoice) {
+        await formApi.setFieldValue('invoiceId', formData.value.invoice.no);
+      }
       if (formData.value?.attachmentList?.length) {
         const blobIds = formData.value.attachmentList.map((item) => ({
           uid: String(item.id),
diff --git a/src/views/erp/purchase/invoice/data.ts b/src/views/erp/purchase/invoice/data.ts
new file mode 100644
index 0000000..e222159
--- /dev/null
+++ b/src/views/erp/purchase/invoice/data.ts
@@ -0,0 +1,241 @@
+import type { VbenFormSchema } from '#/adapter/form';
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+
+import { getSupplierSimpleList } from '#/api/srm/supplier';
+
+import { markRaw } from 'vue';
+
+import PurchaseOrderSelect from './modules/purchase-order-select.vue';
+
+/** 琛ㄥ崟绫诲瀷 */
+export type FormType = 'create' | 'detail' | 'edit';
+
+/** 鏂板/淇敼鐨勮〃鍗� */
+export function useFormSchema(): VbenFormSchema[] {
+  return [
+    {
+      fieldName: 'id',
+      component: 'Input',
+      dependencies: {
+        triggerFields: [''],
+        show: () => false,
+      },
+    },
+    {
+      fieldName: 'no',
+      label: '鏉ョエ缂栧彿',
+      component: 'Input',
+      componentProps: {
+        placeholder: '淇濆瓨鏃惰嚜鍔ㄧ敓鎴�',
+        disabled: true,
+      },
+    },
+    {
+      fieldName: 'purchaseOrderId',
+      label: '閲囪喘璁㈠崟',
+      component: markRaw(PurchaseOrderSelect),
+      rules: 'required',
+      dependencies: {
+        triggerFields: ['id'],
+        disabled: (values) => !!values.id,
+      },
+      componentProps: (_values, form) => ({
+        placeholder: '璇烽�夋嫨閲囪喘璁㈠崟',
+        onChange: (order: { supplierName?: string } | undefined) => {
+          form.setFieldValue('supplierName', order?.supplierName);
+        },
+      }),
+    },
+    {
+      fieldName: 'supplierName',
+      label: '渚涘簲鍟�',
+      component: 'Input',
+      componentProps: {
+        placeholder: '閫夋嫨閲囪喘璁㈠崟鍚庤嚜鍔ㄥ甫鍑�',
+        disabled: true,
+      },
+    },
+    {
+      fieldName: 'invoiceNo',
+      label: '鍙戠エ鍙风爜',
+      component: 'Input',
+      componentProps: {
+        placeholder: '璇疯緭鍏ュ彂绁ㄥ彿鐮�',
+        allowClear: true,
+      },
+    },
+    {
+      fieldName: 'invoiceTitle',
+      label: '鍙戠エ鎶ご',
+      component: 'Input',
+      componentProps: {
+        placeholder: '璇疯緭鍏ュ彂绁ㄦ姮澶�',
+        allowClear: true,
+      },
+    },
+    {
+      fieldName: 'price',
+      label: '鏉ョエ閲戦锛堝厓锛�',
+      component: 'InputNumber',
+      rules: 'required',
+      componentProps: {
+        class: '!w-full',
+        placeholder: '璇疯緭鍏ユ潵绁ㄩ噾棰�',
+        min: 0,
+        precision: 2,
+      },
+    },
+    {
+      fieldName: 'invoiceTime',
+      label: '鏉ョエ鏃ユ湡',
+      component: 'DatePicker',
+      rules: 'required',
+      componentProps: {
+        placeholder: '璇烽�夋嫨鏉ョエ鏃ユ湡',
+        showTime: false,
+        valueFormat: 'YYYY-MM-DD',
+        format: 'YYYY-MM-DD',
+      },
+    },
+    {
+      fieldName: 'blobIds',
+      label: '鍙戠エ闄勪欢',
+      component: 'FileUpload',
+      componentProps: {
+        valueKey: 'id',
+        maxNumber: 10,
+        multiple: true,
+        accept: ['jpg', 'jpeg', 'png', 'pdf'],
+        helpText: '鏀寔jpg銆乯peg銆乸ng銆乸df鏍煎紡锛屽崟涓枃浠朵笉瓒呰繃2MB',
+      },
+      formItemClass: 'md:col-span-2',
+    },
+    {
+      fieldName: 'remark',
+      label: '澶囨敞',
+      component: 'Textarea',
+      componentProps: {
+        placeholder: '璇疯緭鍏ュ娉�',
+        rows: 4,
+      },
+      formItemClass: 'md:col-span-2',
+    },
+  ];
+}
+
+/** 鍒楄〃鐨勬悳绱㈣〃鍗� */
+export function useGridFormSchema(): VbenFormSchema[] {
+  return [
+    {
+      fieldName: 'no',
+      label: '鏉ョエ缂栧彿',
+      component: 'Input',
+      componentProps: {
+        placeholder: '璇疯緭鍏ユ潵绁ㄧ紪鍙�',
+        allowClear: true,
+      },
+    },
+    {
+      fieldName: 'supplierId',
+      label: '渚涘簲鍟�',
+      component: 'ApiSelect',
+      componentProps: {
+        placeholder: '璇烽�夋嫨渚涘簲鍟�',
+        allowClear: true,
+        showSearch: true,
+        api: getSupplierSimpleList,
+        labelField: 'name',
+        valueField: 'id',
+      },
+    },
+    {
+      fieldName: 'auditStatus',
+      label: '瀹℃壒鐘舵��',
+      component: 'Select',
+      componentProps: {
+        options: [
+          { label: '鏈彁浜�', value: 0 },
+          { label: '瀹℃壒涓�', value: 10 },
+          { label: '瀹℃牳閫氳繃', value: 20 },
+          { label: '瀹℃牳涓嶉�氳繃', value: 30 },
+          { label: '宸蹭綔搴�', value: 40 },
+        ],
+        placeholder: '璇烽�夋嫨瀹℃壒鐘舵��',
+        allowClear: true,
+      },
+    },
+  ];
+}
+
+/** 鍒楄〃鐨勫瓧娈� */
+export function useGridColumns(): VxeTableGridOptions['columns'] {
+  return [
+    {
+      title: '鏉ョエ缂栧彿',
+      field: 'no',
+      minWidth: 160,
+      fixed: 'left',
+      slots: { default: 'no' },
+    },
+    {
+      title: '渚涘簲鍟�',
+      field: 'supplierName',
+      minWidth: 150,
+    },
+    {
+      title: '閲囪喘璁㈠崟缂栧彿',
+      field: 'order',
+      minWidth: 160,
+      slots: { default: 'orderNo' },
+    },
+    {
+      title: '鍙戠エ鍙风爜',
+      field: 'invoiceNo',
+      minWidth: 150,
+    },
+    {
+      title: '鏉ョエ閲戦锛堝厓锛�',
+      field: 'price',
+      minWidth: 150,
+      formatter: 'formatAmount2',
+    },
+    {
+      title: '鍓╀綑鍙粯閲戦',
+      field: 'remainingInvoicePrice',
+      minWidth: 150,
+      formatter: 'formatAmount2',
+    },
+    {
+      title: '鏉ョエ鏃ユ湡',
+      field: 'invoiceTime',
+      minWidth: 150,
+      formatter: 'formatDateTime',
+    },
+    {
+      title: '澶囨敞',
+      field: 'remark',
+      minWidth: 150,
+    },
+    {
+      title: '闄勪欢',
+      field: 'attachmentList',
+      minWidth: 100,
+      fixed: 'right',
+      slots: { default: 'attachment' },
+    },
+    {
+      title: '瀹℃壒鐘舵��',
+      field: 'auditStatus',
+      minWidth: 100,
+      fixed: 'right',
+      slots: { default: 'auditStatus' },
+    },
+    {
+      title: '鎿嶄綔',
+      field: 'actions',
+      minWidth: 200,
+      fixed: 'right',
+      slots: { default: 'actions' },
+    },
+  ];
+}
diff --git a/src/views/erp/purchase/invoice/index.vue b/src/views/erp/purchase/invoice/index.vue
new file mode 100644
index 0000000..6c1dd21
--- /dev/null
+++ b/src/views/erp/purchase/invoice/index.vue
@@ -0,0 +1,221 @@
+<script lang="ts" setup>
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+import type { ErpPurchaseInvoiceApi } from '#/api/erp/purchase/invoice';
+
+import { useRouter } from 'vue-router';
+
+import { Page, useVbenModal } from '@vben/common-ui';
+import { downloadFileFromBlobPart } from '@vben/utils';
+
+import { Button, message, Tag } from 'ant-design-vue';
+
+import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
+import {
+  deletePurchaseInvoice,
+  exportPurchaseInvoice,
+  getPurchaseInvoicePage,
+  submitPurchaseInvoice,
+} from '#/api/erp/purchase/invoice';
+import { $t } from '#/locales';
+
+import { useGridColumns, useGridFormSchema } from './data';
+import Form from './modules/form.vue';
+
+defineOptions({ name: 'ErpPurchaseInvoice' });
+
+const { push } = useRouter();
+
+const [FormModal, formModalApi] = useVbenModal({
+  connectedComponent: Form,
+  destroyOnClose: true,
+});
+
+/** 鍒锋柊琛ㄦ牸 */
+function handleRefresh() {
+  gridApi.query();
+}
+
+/** 瀵煎嚭琛ㄦ牸 */
+async function handleExport() {
+  const formValues = await gridApi.formApi.getValues();
+  const data = await exportPurchaseInvoice(formValues);
+  downloadFileFromBlobPart({ fileName: '鏉ョエ.xls', source: data });
+}
+
+/** 鍒涘缓鏉ョエ */
+function handleCreate() {
+  formModalApi.setData({ formType: 'create' }).open();
+}
+
+/** 缂栬緫鏉ョエ */
+function handleEdit(row: ErpPurchaseInvoiceApi.PurchaseInvoice) {
+  formModalApi.setData({ formType: 'edit', id: row.id }).open();
+}
+
+/** 鏌ョ湅鏉ョエ璇︽儏 */
+function handleDetail(row: ErpPurchaseInvoiceApi.PurchaseInvoice) {
+  formModalApi.setData({ formType: 'detail', id: row.id }).open();
+}
+
+/** 鍒犻櫎鏉ョエ */
+async function handleDelete(row: ErpPurchaseInvoiceApi.PurchaseInvoice) {
+  const hideLoading = message.loading({
+    content: $t('ui.actionMessage.deleting', [row.no]),
+    duration: 0,
+  });
+  try {
+    await deletePurchaseInvoice(row.id!);
+    message.success($t('ui.actionMessage.deleteSuccess', [row.no]));
+    handleRefresh();
+  } finally {
+    hideLoading();
+  }
+}
+
+/** 鎻愪氦瀹℃牳 */
+async function handleSubmit(row: ErpPurchaseInvoiceApi.PurchaseInvoice) {
+  const hideLoading = message.loading({
+    content: '鎻愪氦瀹℃牳涓�...',
+    duration: 0,
+  });
+  try {
+    await submitPurchaseInvoice(row.id!);
+    message.success('鎻愪氦瀹℃牳鎴愬姛');
+    handleRefresh();
+  } finally {
+    hideLoading();
+  }
+}
+
+/** 鏌ョ湅瀹℃壒璇︽儏 */
+function handleProcessDetail(row: ErpPurchaseInvoiceApi.PurchaseInvoice) {
+  push({
+    name: 'BpmProcessInstanceDetail',
+    query: { id: row.processInstanceId },
+  });
+}
+
+const [Grid, gridApi] = useVbenVxeGrid({
+  formOptions: {
+    schema: useGridFormSchema(),
+  },
+  gridOptions: {
+    columns: useGridColumns(),
+    height: 'auto',
+    keepSource: true,
+    proxyConfig: {
+      ajax: {
+        query: async ({ page }, formValues) => {
+          return await getPurchaseInvoicePage({
+            pageNo: page.currentPage,
+            pageSize: page.pageSize,
+            ...formValues,
+          });
+        },
+      },
+    },
+    rowConfig: {
+      keyField: 'id',
+      isHover: true,
+    },
+    toolbarConfig: {
+      refresh: true,
+      search: true,
+    },
+  } as VxeTableGridOptions<ErpPurchaseInvoiceApi.PurchaseInvoice>,
+});
+</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: ['erp:purchase-invoice:create'],
+              onClick: handleCreate,
+            },
+            {
+              label: $t('ui.actionTitle.export'),
+              type: 'primary',
+              icon: ACTION_ICON.DOWNLOAD,
+              auth: ['erp:purchase-invoice:export'],
+              onClick: handleExport,
+            },
+          ]"
+        />
+      </template>
+      <template #no="{ row }">
+        <Button type="link" @click="handleDetail(row)">
+          {{ row.no }}
+        </Button>
+      </template>
+      <template #orderNo="{ row }">
+        <span>{{ row.order?.no ?? '--' }}</span>
+      </template>
+      <template #attachment="{ row }">
+        <Tag v-if="!row.hasAttachment" color="warning">鏈笂浼�</Tag>
+        <Tag v-else color="success">宸蹭笂浼�</Tag>
+      </template>
+      <template #auditStatus="{ row }">
+        <Tag v-if="row.auditStatus === 0" color="default">鏈彁浜�</Tag>
+        <Tag v-else-if="row.auditStatus === 10" color="warning">瀹℃壒涓�</Tag>
+        <Tag v-else-if="row.auditStatus === 20" color="success">瀹℃牳閫氳繃</Tag>
+        <Tag v-else-if="row.auditStatus === 30" color="error">瀹℃牳涓嶉�氳繃</Tag>
+        <Tag v-else-if="row.auditStatus === 40" color="default">宸蹭綔搴�</Tag>
+      </template>
+      <template #actions="{ row }">
+        <TableAction
+          :actions="[
+            {
+              label: $t('common.detail'),
+              type: 'link',
+              icon: ACTION_ICON.VIEW,
+              auth: ['erp:purchase-invoice:query'],
+              onClick: handleDetail.bind(null, row),
+            },
+            {
+              label: $t('common.edit'),
+              type: 'link',
+              icon: ACTION_ICON.EDIT,
+              auth: ['erp:purchase-invoice:update'],
+              ifShow: row.auditStatus === 0,
+              onClick: handleEdit.bind(null, row),
+            },
+            {
+              label: '鎻愪氦瀹℃牳',
+              type: 'link',
+              auth: ['erp:purchase-invoice:update'],
+              onClick: handleSubmit.bind(null, row),
+              ifShow: row.auditStatus === 0,
+            },
+            {
+              label: '鏌ョ湅瀹℃壒',
+              type: 'link',
+              auth: ['erp:purchase-invoice:query'],
+              onClick: handleProcessDetail.bind(null, row),
+              ifShow: row.auditStatus !== 0 && !!row.processInstanceId,
+            },
+            {
+              label: $t('common.delete'),
+              type: 'link',
+              danger: true,
+              icon: ACTION_ICON.DELETE,
+              auth: ['erp:purchase-invoice:delete'],
+              ifShow: row.auditStatus !== 20,
+              popConfirm: {
+                title: $t('ui.actionMessage.deleteConfirm', [row.no]),
+                confirm: handleDelete.bind(null, row),
+              },
+            },
+          ]"
+        />
+      </template>
+    </Grid>
+  </Page>
+</template>
diff --git a/src/views/erp/purchase/invoice/modules/form.vue b/src/views/erp/purchase/invoice/modules/form.vue
new file mode 100644
index 0000000..3450581
--- /dev/null
+++ b/src/views/erp/purchase/invoice/modules/form.vue
@@ -0,0 +1,104 @@
+<script lang="ts" setup>
+import type { FormType } from '../data';
+
+import type { ErpPurchaseInvoiceApi } from '#/api/erp/purchase/invoice';
+
+import { computed, ref } from 'vue';
+
+import { useVbenModal } from '@vben/common-ui';
+
+import { message } from 'ant-design-vue';
+
+import { useVbenForm } from '#/adapter/form';
+import {
+  createPurchaseInvoice,
+  getPurchaseInvoice,
+  updatePurchaseInvoice,
+} from '#/api/erp/purchase/invoice';
+import { $t } from '#/locales';
+
+import { useFormSchema } from '../data';
+
+const emit = defineEmits(['success']);
+const formData = ref<ErpPurchaseInvoiceApi.PurchaseInvoice>();
+const formType = ref<FormType>('create');
+
+const getTitle = computed(() => {
+  if (formType.value === 'create') {
+    return $t('ui.actionTitle.create', ['鏉ョエ']);
+  } else if (formType.value === 'edit') {
+    return $t('ui.actionTitle.edit', ['鏉ョエ']);
+  } else {
+    return '鏉ョエ璇︽儏';
+  }
+});
+
+const [Form, formApi] = useVbenForm({
+  commonConfig: {
+    componentProps: {
+      class: 'w-full',
+    },
+    labelWidth: 120,
+  },
+  wrapperClass: 'grid-cols-2',
+  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 ErpPurchaseInvoiceApi.PurchaseInvoice;
+    if (formData.value?.id) {
+      data.id = formData.value.id;
+    }
+    try {
+      await (formData.value?.id
+        ? updatePurchaseInvoice(data)
+        : createPurchaseInvoice(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<{ formType: FormType; id?: number }>();
+    formType.value = data.formType;
+    formApi.setDisabled(formType.value === 'detail');
+    formApi.updateSchema(useFormSchema());
+    await formApi.resetForm();
+    if (!data || !data.id) {
+      return;
+    }
+    modalApi.lock();
+    try {
+      formData.value = await getPurchaseInvoice(data.id);
+      if (formData.value?.attachmentList?.length) {
+        // 鐢� attachmentList 浠f浛 blobIds锛岃涓婁紶缁勪欢鑳藉睍绀烘枃浠跺悕鍜岄瑙堥摼鎺�
+        (formData.value as Record<string, unknown>).blobIds =
+          formData.value.attachmentList;
+      }
+      await formApi.setValues(formData.value as any);
+    } finally {
+      modalApi.unlock();
+    }
+  },
+});
+</script>
+
+<template>
+  <Modal :title="getTitle" class="w-3/5">
+    <Form class="mx-4" />
+  </Modal>
+</template>
diff --git a/src/views/erp/purchase/invoice/modules/purchase-order-select.vue b/src/views/erp/purchase/invoice/modules/purchase-order-select.vue
new file mode 100644
index 0000000..1c6ca0b
--- /dev/null
+++ b/src/views/erp/purchase/invoice/modules/purchase-order-select.vue
@@ -0,0 +1,185 @@
+<script lang="ts" setup>
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+import type { ErpPurchaseOrderApi } from '#/api/erp/purchase/order';
+
+import { computed, nextTick, ref, watch } from 'vue';
+
+import { DICT_TYPE } from '@vben/constants';
+import { IconifyIcon } from '@vben/icons';
+
+import { Input, Modal } from 'ant-design-vue';
+
+import { useVbenVxeGrid } from '#/adapter/vxe-table';
+import {
+  getPurchaseOrder,
+  getPurchaseOrderPage,
+} from '#/api/erp/purchase/order';
+
+defineOptions({ name: 'ErpPurchaseInvoiceOrderSelect' });
+
+const props = withDefaults(
+  defineProps<{
+    disabled?: boolean;
+    modelValue?: number;
+    placeholder?: string;
+  }>(),
+  {
+    disabled: false,
+    modelValue: undefined,
+    placeholder: '璇烽�夋嫨閲囪喘璁㈠崟',
+  },
+);
+const emit = defineEmits<{
+  change: [order: ErpPurchaseOrderApi.PurchaseOrder | undefined];
+  'update:modelValue': [value: number | undefined];
+}>();
+
+const open = ref(false); // 閫夋嫨寮圭獥鏄惁鎵撳紑
+const order = ref<ErpPurchaseOrderApi.PurchaseOrder>(); // 褰撳墠閫変腑閲囪喘璁㈠崟
+
+const displayNo = computed(() => order.value?.no ?? '');
+
+/** 鏍规嵁璁㈠崟缂栧彿鍥炴樉閫夋嫨鍣� */
+async function resolveOrderById(id: number | undefined) {
+  if (!id) {
+    order.value = undefined;
+    return;
+  }
+  if (order.value?.id === id) {
+    return;
+  }
+  order.value = await getPurchaseOrder(id);
+}
+
+watch(
+  () => props.modelValue,
+  (value) => {
+    resolveOrderById(value);
+  },
+  { immediate: true },
+);
+
+/** 琛ㄦ牸閰嶇疆 */
+const [Grid, gridApi] = useVbenVxeGrid({
+  formOptions: {
+    schema: [
+      {
+        fieldName: 'no',
+        label: '璁㈠崟鍗曞彿',
+        component: 'Input',
+        componentProps: {
+          placeholder: '璇疯緭鍏ヨ鍗曞崟鍙�',
+          allowClear: true,
+        },
+      },
+    ],
+  },
+  gridOptions: {
+    columns: [
+      { field: 'no', title: '璁㈠崟鍗曞彿', width: 200, fixed: 'left' },
+      { field: 'supplierName', title: '渚涘簲鍟�', minWidth: 120 },
+      {
+        field: 'orderTime',
+        title: '璁㈠崟鏃堕棿',
+        width: 160,
+        formatter: 'formatDate',
+      },
+      {
+        field: 'totalPrice',
+        title: '鍚◣閲戦',
+        formatter: 'formatAmount2',
+        minWidth: 120,
+      },
+      {
+        field: 'status',
+        title: '鐘舵��',
+        minWidth: 100,
+        cellRender: {
+          name: 'CellDict',
+          props: { type: DICT_TYPE.ERP_AUDIT_STATUS },
+        },
+      },
+    ],
+    height: 520,
+    keepSource: true,
+    proxyConfig: {
+      ajax: {
+        query: async ({ page }, formValues) => {
+          return await getPurchaseOrderPage({
+            pageNo: page.currentPage,
+            pageSize: page.pageSize,
+            status: 20, // 浠呭睍绀哄凡瀹℃牳閫氳繃鐨勯噰璐鍗�
+            ...formValues,
+          });
+        },
+      },
+    },
+    rowConfig: {
+      keyField: 'id',
+      isHover: true,
+    },
+    radioConfig: {
+      trigger: 'row',
+      highlight: true,
+    },
+    toolbarConfig: {
+      refresh: true,
+      search: true,
+    },
+  } as VxeTableGridOptions<ErpPurchaseOrderApi.PurchaseOrder>,
+  gridEvents: {
+    radioChange: ({ row }: { row: ErpPurchaseOrderApi.PurchaseOrder }) => {
+      order.value = row;
+    },
+  },
+});
+
+/** 鎵撳紑閫夋嫨寮圭獥 */
+async function handleOpen() {
+  if (props.disabled) {
+    return;
+  }
+  open.value = true;
+  await nextTick();
+  gridApi.query();
+}
+
+/** 纭閫夋嫨閲囪喘璁㈠崟 */
+function handleOk() {
+  if (!order.value?.id) {
+    return;
+  }
+  emit('update:modelValue', order.value.id);
+  emit('change', order.value);
+  open.value = false;
+}
+</script>
+
+<template>
+  <div>
+    <Input
+      readonly
+      :disabled="disabled"
+      :placeholder="placeholder"
+      :value="displayNo"
+      @click="handleOpen"
+    >
+      <template #suffix>
+        <IconifyIcon
+          class="size-4 cursor-pointer"
+          icon="lucide:search"
+          :style="{ cursor: disabled ? 'not-allowed' : 'pointer' }"
+        />
+      </template>
+    </Input>
+    <Modal
+      v-model:open="open"
+      title="閫夋嫨閲囪喘璁㈠崟锛堜粎宸插鏍搁�氳繃锛�"
+      width="80%"
+      @cancel.stop="open = false"
+      @ok.stop="handleOk"
+    >
+      <Grid table-title="閲囪喘璁㈠崟鍒楄〃" />
+    </Modal>
+  </div>
+</template>
diff --git a/src/views/hrm/employee/contract/data.ts b/src/views/hrm/employee/contract/data.ts
new file mode 100644
index 0000000..a978d4c
--- /dev/null
+++ b/src/views/hrm/employee/contract/data.ts
@@ -0,0 +1,297 @@
+import type { VbenFormSchema } from '#/adapter/form';
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+import type { HrmEmployeeContractApi } from '#/api/hrm/employee/contract';
+
+import { DICT_TYPE } from '#/packages/constants/src';
+import { getDictOptions } from '#/packages/effects/hooks/src';
+
+import { getEmployeeSimpleList } from '#/api/hrm/employee';
+
+/** 琛ㄥ崟绫诲瀷 */
+export type FormType = 'create' | 'update';
+
+/** 鏂板/淇敼鍛樺伐鍚堝悓鐨勮〃鍗� */
+export function useFormSchema(): VbenFormSchema[] {
+  return [
+    {
+      fieldName: 'id',
+      component: 'Input',
+      dependencies: {
+        triggerFields: [''],
+        show: () => false,
+      },
+    },
+    {
+      fieldName: 'employeeId',
+      label: '鍛樺伐',
+      component: 'ApiSelect',
+      componentProps: {
+        api: getEmployeeSimpleList,
+        labelField: 'name',
+        valueField: 'id',
+        showSearch: true,
+        filterOption: (input: string, option: any) =>
+          option.label?.toLowerCase().includes(input.toLowerCase()),
+        placeholder: '璇烽�夋嫨鍛樺伐',
+      },
+      rules: 'selectRequired',
+      formItemClass: 'col-span-2',
+    },
+    {
+      fieldName: 'contractType',
+      label: '鍚堝悓绫诲瀷',
+      component: 'Select',
+      componentProps: {
+        placeholder: '璇烽�夋嫨鍚堝悓绫诲瀷',
+        options: getDictOptions(DICT_TYPE.HRM_CONTRACT_TYPE, 'number'),
+      },
+      rules: 'selectRequired',
+    },
+    {
+      fieldName: 'contractTermType',
+      label: '鏈熼檺绫诲瀷',
+      component: 'Select',
+      componentProps: {
+        placeholder: '璇烽�夋嫨鏈熼檺绫诲瀷',
+        options: getDictOptions(DICT_TYPE.HRM_CONTRACT_TERM_TYPE, 'number'),
+      },
+      rules: 'selectRequired',
+    },
+    {
+      fieldName: 'signCompany',
+      label: '绛剧害涓讳綋',
+      component: 'Input',
+      componentProps: {
+        placeholder: '璇疯緭鍏ョ绾︿富浣擄紙鍏徃鍏ㄧО锛�',
+      },
+    },
+    {
+      fieldName: 'signDate',
+      label: '绛捐鏃ユ湡',
+      component: 'DatePicker',
+      componentProps: {
+        placeholder: '璇烽�夋嫨绛捐鏃ユ湡',
+        valueFormat: 'YYYY-MM-DD',
+        style: { width: '100%' },
+      },
+    },
+    {
+      fieldName: 'startDate',
+      label: '寮�濮嬫棩鏈�',
+      component: 'DatePicker',
+      componentProps: {
+        placeholder: '璇烽�夋嫨寮�濮嬫棩鏈�',
+        valueFormat: 'YYYY-MM-DD',
+        style: { width: '100%' },
+      },
+      rules: 'required',
+    },
+    {
+      fieldName: 'endDate',
+      label: '缁撴潫鏃ユ湡',
+      component: 'DatePicker',
+      componentProps: {
+        placeholder: '鏃犲浐瀹氭湡闄愬彲涓嶅~',
+        valueFormat: 'YYYY-MM-DD',
+        style: { width: '100%' },
+      },
+    },
+    {
+      fieldName: 'probationStartDate',
+      label: '璇曠敤鏈熷紑濮�',
+      component: 'DatePicker',
+      componentProps: {
+        placeholder: '璇烽�夋嫨璇曠敤鏈熷紑濮嬫棩鏈�',
+        valueFormat: 'YYYY-MM-DD',
+        style: { width: '100%' },
+      },
+    },
+    {
+      fieldName: 'probationEndDate',
+      label: '璇曠敤鏈熺粨鏉�',
+      component: 'DatePicker',
+      componentProps: {
+        placeholder: '璇烽�夋嫨璇曠敤鏈熺粨鏉熸棩鏈�',
+        valueFormat: 'YYYY-MM-DD',
+        style: { width: '100%' },
+      },
+    },
+    {
+      fieldName: 'probationSalary',
+      label: '璇曠敤鏈熷伐璧�',
+      component: 'InputNumber',
+      componentProps: {
+        placeholder: '璇疯緭鍏ヨ瘯鐢ㄦ湡宸ヨ祫',
+        min: 0,
+        precision: 2,
+        style: { width: '100%' },
+      },
+    },
+    {
+      fieldName: 'regularSalary',
+      label: '杞宸ヨ祫',
+      component: 'InputNumber',
+      componentProps: {
+        placeholder: '璇疯緭鍏ヨ浆姝e伐璧�',
+        min: 0,
+        precision: 2,
+        style: { width: '100%' },
+      },
+    },
+    {
+      fieldName: 'blobIds',
+      label: '闄勪欢',
+      component: 'FileUpload',
+      formItemClass: 'col-span-2',
+      componentProps: {
+        valueKey: 'id',
+        maxNumber: 10,
+        multiple: true,
+      },
+    },
+    {
+      fieldName: 'remark',
+      label: '澶囨敞',
+      component: 'Textarea',
+      formItemClass: 'col-span-2',
+      componentProps: {
+        placeholder: '璇疯緭鍏ュ娉�',
+        rows: 2,
+      },
+    },
+  ];
+}
+
+/** 鍒楄〃鐨勬悳绱㈣〃鍗� */
+export function useGridFormSchema(): VbenFormSchema[] {
+  return [
+    {
+      fieldName: 'contractNo',
+      label: '鍚堝悓缂栧彿',
+      component: 'Input',
+      componentProps: {
+        allowClear: true,
+        placeholder: '璇疯緭鍏ュ悎鍚岀紪鍙�',
+      },
+    },
+    {
+      fieldName: 'employeeId',
+      label: '鍛樺伐',
+      component: 'ApiSelect',
+      componentProps: {
+        api: getEmployeeSimpleList,
+        labelField: 'name',
+        valueField: 'id',
+        showSearch: true,
+        filterOption: (input: string, option: any) =>
+          option.label?.toLowerCase().includes(input.toLowerCase()),
+        allowClear: true,
+        placeholder: '璇烽�夋嫨鍛樺伐',
+      },
+    },
+    {
+      fieldName: 'contractType',
+      label: '鍚堝悓绫诲瀷',
+      component: 'Select',
+      componentProps: {
+        allowClear: true,
+        options: getDictOptions(DICT_TYPE.HRM_CONTRACT_TYPE, 'number'),
+        placeholder: '璇烽�夋嫨鍚堝悓绫诲瀷',
+      },
+    },
+    {
+      fieldName: 'contractTermType',
+      label: '鏈熼檺绫诲瀷',
+      component: 'Select',
+      componentProps: {
+        allowClear: true,
+        options: getDictOptions(DICT_TYPE.HRM_CONTRACT_TERM_TYPE, 'number'),
+        placeholder: '璇烽�夋嫨鏈熼檺绫诲瀷',
+      },
+    },
+    {
+      fieldName: 'terminateStatus',
+      label: '瑙i櫎缁堟鐘舵��',
+      component: 'Select',
+      componentProps: {
+        allowClear: true,
+        options: getDictOptions(DICT_TYPE.HRM_CONTRACT_TERMINATE_STATUS, 'number'),
+        placeholder: '璇烽�夋嫨瑙i櫎缁堟鐘舵��',
+      },
+    },
+    {
+      fieldName: 'status',
+      label: '鍚堝悓鐘舵��',
+      component: 'Select',
+      componentProps: {
+        allowClear: true,
+        options: getDictOptions(DICT_TYPE.HRM_CONTRACT_STATUS, 'number'),
+        placeholder: '璇烽�夋嫨鍚堝悓鐘舵��',
+      },
+    },
+  ];
+}
+
+/** 鍒楄〃鐨勫瓧娈� */
+export function useGridColumns(): VxeTableGridOptions<HrmEmployeeContractApi.EmployeeContract>['columns'] {
+  return [
+    { field: 'contractNo', title: '鍚堝悓缂栧彿', minWidth: 160 },
+    {
+      field: 'parentNo',
+      title: '缁鑷�',
+      minWidth: 140,
+      slots: { default: 'parentNo' },
+    },
+    { field: 'employeeName', title: '鍛樺伐濮撳悕', minWidth: 100 },
+    { field: 'employeeNo', title: '鍛樺伐宸ュ彿', minWidth: 130 },
+    { field: 'deptName', title: '閮ㄩ棬', minWidth: 120 },
+    {
+      field: 'contractType',
+      title: '鍚堝悓绫诲瀷',
+      width: 100,
+      cellRender: {
+        name: 'CellDict',
+        props: { type: DICT_TYPE.HRM_CONTRACT_TYPE },
+      },
+    },
+    {
+      field: 'contractTermType',
+      title: '鏈熼檺绫诲瀷',
+      width: 130,
+      cellRender: {
+        name: 'CellDict',
+        props: { type: DICT_TYPE.HRM_CONTRACT_TERM_TYPE },
+      },
+    },
+    { field: 'signDate', title: '绛捐鏃ユ湡', width: 110 },
+    { field: 'startDate', title: '寮�濮嬫棩鏈�', width: 110 },
+    { field: 'endDate', title: '缁撴潫鏃ユ湡', width: 110 },
+    {
+      field: 'status',
+      title: '鍚堝悓鐘舵��',
+      width: 100,
+      cellRender: {
+        name: 'CellDict',
+        props: { type: DICT_TYPE.HRM_CONTRACT_STATUS },
+      },
+    },
+    {
+      field: 'isCurrent',
+      title: '褰撳墠鍚堝悓',
+      width: 90,
+      slots: { default: 'isCurrent' },
+    },
+    {
+      field: 'createTime',
+      title: '鍒涘缓鏃堕棿',
+      width: 180,
+      formatter: 'formatDateTime',
+    },
+    {
+      title: '鎿嶄綔',
+      width: 260,
+      fixed: 'right',
+      slots: { default: 'actions' },
+    },
+  ];
+}
diff --git a/src/views/hrm/employee/contract/index.vue b/src/views/hrm/employee/contract/index.vue
new file mode 100644
index 0000000..8f69373
--- /dev/null
+++ b/src/views/hrm/employee/contract/index.vue
@@ -0,0 +1,164 @@
+<script lang="ts" setup>
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+import type { HrmEmployeeContractApi } from '#/api/hrm/employee/contract';
+
+import { Page, useVbenModal } from '#/packages/effects/common-ui/src';
+
+import { message, Tag } from 'ant-design-vue';
+
+import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
+import {
+  deleteEmployeeContract,
+  getEmployeeContractPage,
+} from '#/api/hrm/employee/contract';
+import { $t } from '#/locales';
+
+import { useGridColumns, useGridFormSchema } from './data';
+import Form from './modules/form.vue';
+import Terminate from './modules/terminate.vue';
+
+const [FormModal, formModalApi] = useVbenModal({
+  connectedComponent: Form,
+  destroyOnClose: true,
+});
+
+const [TerminateModal, terminateModalApi] = useVbenModal({
+  connectedComponent: Terminate,
+  destroyOnClose: true,
+});
+
+/** 鍒锋柊琛ㄦ牸 */
+function handleRefresh() {
+  gridApi.query();
+}
+
+/** 鏂板鍚堝悓 */
+function handleCreate() {
+  formModalApi.setData({ formType: 'create' }).open();
+}
+
+/** 缂栬緫鍚堝悓 */
+function handleEdit(row: HrmEmployeeContractApi.EmployeeContract) {
+  formModalApi.setData({ id: row.id, formType: 'update' }).open();
+}
+
+/** 缁鍚堝悓锛氬甫鍏ヤ笂浠藉悎鍚屽熀纭�淇℃伅锛屽垱寤烘柊鍚堝悓锛堝悗绔嚜鍔ㄥ叧鑱� parentId锛� */
+function handleRenew(row: HrmEmployeeContractApi.EmployeeContract) {
+  formModalApi.setData({ formType: 'create', renewFrom: row }).open();
+}
+
+/** 瑙i櫎/缁堟鍚堝悓 */
+function handleTerminate(row: HrmEmployeeContractApi.EmployeeContract) {
+  terminateModalApi.setData({ id: row.id }).open();
+}
+
+/** 鍒犻櫎鍚堝悓 */
+async function handleDelete(row: HrmEmployeeContractApi.EmployeeContract) {
+  await deleteEmployeeContract(row.id!);
+  message.success($t('ui.actionMessage.deleteSuccess', ['鍛樺伐鍚堝悓']));
+  handleRefresh();
+}
+
+/** 鏄惁鍙В闄�/缁堟锛氫粎鏈В闄�/鏈粓姝㈢殑鍚堝悓鍙搷浣� */
+function isTerminable(row: HrmEmployeeContractApi.EmployeeContract) {
+  return row.terminateStatus === 0;
+}
+
+const [Grid, gridApi] = useVbenVxeGrid({
+  formOptions: {
+    schema: useGridFormSchema(),
+  },
+  gridOptions: {
+    columns: useGridColumns(),
+    height: 'auto',
+    keepSource: true,
+    proxyConfig: {
+      ajax: {
+        query: async ({ page }, formValues) =>
+          await getEmployeeContractPage({
+            pageNo: page.currentPage,
+            pageSize: page.pageSize,
+            ...formValues,
+          }),
+      },
+    },
+    rowConfig: {
+      keyField: 'id',
+      isHover: true,
+    },
+    toolbarConfig: {
+      refresh: true,
+      search: true,
+    },
+  } as VxeTableGridOptions<HrmEmployeeContractApi.EmployeeContract>,
+});
+</script>
+
+<template>
+  <Page auto-content-height>
+    <FormModal @success="handleRefresh" />
+    <TerminateModal @success="handleRefresh" />
+    <Grid table-title="鍛樺伐鍚堝悓鍒楄〃">
+      <template #parentNo="{ row }">
+        <span>{{ row.parentNo || '-' }}</span>
+      </template>
+      <template #isCurrent="{ row }">
+        <Tag v-if="row.isCurrent" color="processing">褰撳墠</Tag>
+        <span v-else>-</span>
+      </template>
+      <template #toolbar-tools>
+        <TableAction
+          :actions="[
+            {
+              label: $t('ui.actionTitle.create', ['鍛樺伐鍚堝悓']),
+              type: 'primary',
+              icon: ACTION_ICON.ADD,
+              auth: ['hrm:employee-contract:create'],
+              onClick: handleCreate,
+            },
+          ]"
+        />
+      </template>
+      <template #actions="{ row }">
+        <TableAction
+          :actions="[
+            {
+              label: '缁',
+              type: 'link',
+              icon: ACTION_ICON.ADD,
+              auth: ['hrm:employee-contract:create'],
+              ifShow: isTerminable(row),
+              onClick: handleRenew.bind(null, row),
+            },
+            {
+              label: $t('common.edit'),
+              type: 'link',
+              icon: ACTION_ICON.EDIT,
+              auth: ['hrm:employee-contract:update'],
+              onClick: handleEdit.bind(null, row),
+            },
+            {
+              label: '瑙i櫎/缁堟',
+              type: 'link',
+              icon: ACTION_ICON.CLOSE,
+              auth: ['hrm:employee-contract:update'],
+              ifShow: isTerminable(row),
+              onClick: handleTerminate.bind(null, row),
+            },
+            {
+              label: $t('common.delete'),
+              type: 'link',
+              danger: true,
+              icon: ACTION_ICON.DELETE,
+              auth: ['hrm:employee-contract:delete'],
+              popConfirm: {
+                title: $t('ui.actionMessage.deleteConfirm', ['鍛樺伐鍚堝悓']),
+                confirm: handleDelete.bind(null, row),
+              },
+            },
+          ]"
+        />
+      </template>
+    </Grid>
+  </Page>
+</template>
diff --git a/src/views/hrm/employee/contract/modules/form.vue b/src/views/hrm/employee/contract/modules/form.vue
new file mode 100644
index 0000000..d48678e
--- /dev/null
+++ b/src/views/hrm/employee/contract/modules/form.vue
@@ -0,0 +1,126 @@
+<script lang="ts" setup>
+import type { FormType } from '../data';
+import type { HrmEmployeeContractApi } from '#/api/hrm/employee/contract';
+
+import { computed, ref } from 'vue';
+
+import { useVbenModal } from '#/packages/effects/common-ui/src';
+
+import { message } from 'ant-design-vue';
+
+import { useVbenForm } from '#/adapter/form';
+import {
+  createEmployeeContract,
+  getEmployeeContract,
+  updateEmployeeContract,
+} from '#/api/hrm/employee/contract';
+import { $t } from '#/locales';
+
+import { useFormSchema } from '../data';
+
+const emit = defineEmits(['success']);
+const formType = ref<FormType>('create');
+const isRenew = ref(false);
+
+const getTitle = computed(() => {
+  if (formType.value === 'update') {
+    return '淇敼鍛樺伐鍚堝悓';
+  }
+  return isRenew.value ? '缁鍛樺伐鍚堝悓' : '鏂板鍛樺伐鍚堝悓';
+});
+
+const [Form, formApi] = useVbenForm({
+  commonConfig: {
+    componentProps: {
+      class: 'w-full',
+    },
+    formItemClass: 'col-span-1',
+    labelWidth: 100,
+  },
+  wrapperClass: 'grid-cols-2',
+  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 HrmEmployeeContractApi.EmployeeContract;
+    try {
+      // FileUpload 缁勪欢鐨勫�奸渶杞崲涓� blobId 鏁扮粍鎻愪氦
+      data.blobIds = (data.blobIds ?? []).map((item: any) => item.id ?? item);
+      if (data.id) {
+        await updateEmployeeContract(data);
+      } else {
+        await createEmployeeContract(data);
+      }
+      await modalApi.close();
+      emit('success');
+      message.success($t('ui.actionMessage.operationSuccess'));
+    } finally {
+      modalApi.unlock();
+    }
+  },
+  async onOpenChange(isOpen: boolean) {
+    if (!isOpen) {
+      return;
+    }
+    const data = modalApi.getData<{
+      formType: FormType;
+      id?: number;
+      renewFrom?: HrmEmployeeContractApi.EmployeeContract;
+    }>();
+    formType.value = data.formType;
+    isRenew.value = !data?.id && !!data?.renewFrom;
+    // 缁锛氬甫鍏ヤ笂浠藉悎鍚岀殑鍩虹淇℃伅锛堝憳宸ャ�佺被鍨嬨�佺绾︿富浣撱�佸伐璧勩�佺画绛炬潵婧愶級锛屾棩鏈熼渶閲嶆柊濉啓
+    if (data?.renewFrom) {
+      await formApi.resetForm();
+      await formApi.setValues({
+        parentId: data.renewFrom.id,
+        employeeId: data.renewFrom.employeeId,
+        contractType: data.renewFrom.contractType,
+        contractTermType: data.renewFrom.contractTermType,
+        signCompany: data.renewFrom.signCompany,
+        probationSalary: data.renewFrom.probationSalary,
+        regularSalary: data.renewFrom.regularSalary,
+      });
+      return;
+    }
+    if (!data?.id) {
+      await formApi.resetForm();
+      return;
+    }
+    modalApi.lock();
+    try {
+      const detail = await getEmployeeContract(data.id);
+      await formApi.setValues(detail);
+      if (detail.attachmentList?.length) {
+        await formApi.setFieldValue(
+          'blobIds',
+          detail.attachmentList.map((item) => ({
+            uid: String(item.id),
+            name: item.name || '',
+            url: item.url || '',
+            status: 'done',
+            id: item.id,
+          })),
+        );
+      }
+    } finally {
+      modalApi.unlock();
+    }
+  },
+});
+</script>
+
+<template>
+  <Modal :title="getTitle" class="w-[720px]">
+    <Form class="mx-4" />
+  </Modal>
+</template>
diff --git a/src/views/hrm/employee/contract/modules/terminate.vue b/src/views/hrm/employee/contract/modules/terminate.vue
new file mode 100644
index 0000000..c554c0c
--- /dev/null
+++ b/src/views/hrm/employee/contract/modules/terminate.vue
@@ -0,0 +1,103 @@
+<script lang="ts" setup>
+import type { VbenFormSchema } from '#/adapter/form';
+
+import { ref } from 'vue';
+
+import { useVbenModal } from '#/packages/effects/common-ui/src';
+
+import { message } from 'ant-design-vue';
+
+import { useVbenForm } from '#/adapter/form';
+import { terminateEmployeeContract } from '#/api/hrm/employee/contract';
+import { $t } from '#/locales';
+
+const emit = defineEmits(['success']);
+const contractId = ref<number>();
+
+/** 瑙i櫎/缁堟琛ㄥ崟 */
+const schema: VbenFormSchema[] = [
+  {
+    fieldName: 'terminateStatus',
+    label: '鎿嶄綔绫诲瀷',
+    component: 'RadioGroup',
+    componentProps: {
+      options: [
+        { label: '瑙i櫎', value: 1 },
+        { label: '缁堟', value: 2 },
+      ],
+    },
+    rules: 'required',
+  },
+  {
+    fieldName: 'terminateDate',
+    label: '瑙i櫎/缁堟鏃ユ湡',
+    component: 'DatePicker',
+    componentProps: {
+      placeholder: '璇烽�夋嫨鏃ユ湡',
+      valueFormat: 'YYYY-MM-DD',
+      style: { width: '100%' },
+    },
+    rules: 'required',
+  },
+  {
+    fieldName: 'terminateReason',
+    label: '鍘熷洜',
+    component: 'Textarea',
+    formItemClass: 'col-span-2',
+    componentProps: {
+      placeholder: '璇疯緭鍏ヨВ闄�/缁堟鍘熷洜',
+      rows: 3,
+      maxlength: 255,
+      showCount: true,
+    },
+    rules: 'required',
+  },
+];
+
+const [Form, formApi] = useVbenForm({
+  commonConfig: {
+    componentProps: {
+      class: 'w-full',
+    },
+    formItemClass: 'col-span-1',
+    labelWidth: 110,
+  },
+  wrapperClass: 'grid-cols-2',
+  layout: 'horizontal',
+  schema,
+  showDefaultActions: false,
+});
+
+const [Modal, modalApi] = useVbenModal({
+  async onConfirm() {
+    const { valid } = await formApi.validate();
+    if (!valid) {
+      return;
+    }
+    modalApi.lock();
+    const data = await formApi.getValues();
+    try {
+      await terminateEmployeeContract({ id: contractId.value!, ...data });
+      await modalApi.close();
+      emit('success');
+      message.success($t('ui.actionMessage.operationSuccess'));
+    } finally {
+      modalApi.unlock();
+    }
+  },
+  async onOpenChange(isOpen: boolean) {
+    if (!isOpen) {
+      return;
+    }
+    const data = modalApi.getData<{ id: number }>();
+    contractId.value = data.id;
+    await formApi.resetForm();
+  },
+});
+</script>
+
+<template>
+  <Modal title="瑙i櫎/缁堟鍚堝悓" class="w-[520px]">
+    <Form class="mx-4" />
+  </Modal>
+</template>
diff --git a/src/views/hrm/employee/modules/contract-list.vue b/src/views/hrm/employee/modules/contract-list.vue
new file mode 100644
index 0000000..8f95f77
--- /dev/null
+++ b/src/views/hrm/employee/modules/contract-list.vue
@@ -0,0 +1,107 @@
+<script lang="ts" setup>
+import type { HrmEmployeeContractApi } from '#/api/hrm/employee/contract';
+
+import { ref, watch } from 'vue';
+
+import { Table, Tag } from 'ant-design-vue';
+
+import { getEmployeeContractPage } from '#/api/hrm/employee/contract';
+import { DICT_TYPE } from '#/packages/constants/src';
+import { getDictLabel } from '#/packages/effects/hooks/src';
+
+const props = defineProps<{
+  employeeId?: number;
+}>();
+
+const list = ref<HrmEmployeeContractApi.EmployeeContract[]>([]);
+const loading = ref(false);
+
+/** 鍚堝悓鐘舵�侀鑹诧細1-寰呯敓鏁� 2-鐢熸晥涓� 3-鍗冲皢鍒版湡 4-宸插埌鏈� 5-宸茶В闄� 6-宸茬粓姝� */
+const statusColorMap: Record<number, string> = {
+  1: 'processing',
+  2: 'success',
+  3: 'warning',
+  4: 'default',
+  5: 'error',
+  6: 'error',
+};
+
+/** 鍒锋柊鍒楄〃 */
+async function handleRefresh() {
+  if (!props.employeeId) return;
+  loading.value = true;
+  try {
+    const res = await getEmployeeContractPage({
+      pageNo: 1,
+      pageSize: 100,
+      employeeId: props.employeeId,
+    });
+    list.value = res.list;
+  } finally {
+    loading.value = false;
+  }
+}
+
+/** 鐩戝惉 employeeId 鍙樺寲 */
+watch(
+  () => props.employeeId,
+  (val) => {
+    if (val) {
+      handleRefresh();
+    }
+  },
+  { immediate: true },
+);
+
+const columns = [
+  { title: '鍚堝悓缂栧彿', dataIndex: 'contractNo', width: 150 },
+  { title: '缁鑷�', dataIndex: 'parentNo', width: 130, key: 'parentNo' },
+  { title: '鍚堝悓绫诲瀷', dataIndex: 'contractType', width: 100, key: 'contractType' },
+  { title: '鏈熼檺绫诲瀷', dataIndex: 'contractTermType', width: 110, key: 'contractTermType' },
+  { title: '绛捐鏃ユ湡', dataIndex: 'signDate', width: 110 },
+  { title: '寮�濮嬫棩鏈�', dataIndex: 'startDate', width: 110 },
+  { title: '缁撴潫鏃ユ湡', dataIndex: 'endDate', width: 110 },
+  { title: '鍚堝悓鐘舵��', dataIndex: 'status', width: 100, key: 'status' },
+  { title: '褰撳墠鍚堝悓', dataIndex: 'isCurrent', width: 90, key: 'isCurrent' },
+];
+</script>
+
+<template>
+  <div class="contract-list">
+    <Table
+      :columns="columns"
+      :data-source="list"
+      :loading="loading"
+      :pagination="false"
+      size="small"
+      row-key="id"
+    >
+      <template #bodyCell="{ column, record }">
+        <template v-if="column.key === 'parentNo'">
+          {{ record.parentNo || '-' }}
+        </template>
+        <template v-else-if="column.key === 'contractType'">
+          {{ getDictLabel(DICT_TYPE.HRM_CONTRACT_TYPE, record.contractType) }}
+        </template>
+        <template v-else-if="column.key === 'contractTermType'">
+          {{ getDictLabel(DICT_TYPE.HRM_CONTRACT_TERM_TYPE, record.contractTermType) }}
+        </template>
+        <template v-else-if="column.key === 'status'">
+          <Tag :color="statusColorMap[record.status!] || 'default'">
+            {{ getDictLabel(DICT_TYPE.HRM_CONTRACT_STATUS, record.status) }}
+          </Tag>
+        </template>
+        <template v-else-if="column.key === 'isCurrent'">
+          <Tag v-if="record.isCurrent" color="processing">褰撳墠</Tag>
+          <span v-else>-</span>
+        </template>
+      </template>
+    </Table>
+  </div>
+</template>
+
+<style scoped>
+.contract-list {
+  padding: 0;
+}
+</style>
diff --git a/src/views/hrm/employee/modules/detail.vue b/src/views/hrm/employee/modules/detail.vue
index 4f30a8b..010621b 100644
--- a/src/views/hrm/employee/modules/detail.vue
+++ b/src/views/hrm/employee/modules/detail.vue
@@ -12,6 +12,7 @@
 import WorkHistoryList from './work-history-list.vue';
 import EmergencyContactList from './emergency-contact-list.vue';
 import SocialSecurityList from './social-security-list.vue';
+import ContractList from './contract-list.vue';
 
 const props = defineProps<{
   employee: HrmEmployeeApi.Employee;
@@ -96,6 +97,9 @@
       <Tabs.TabPane key="socialSecurity" tab="绀句繚鍏Н閲戞。妗�">
         <SocialSecurityList form-type="detail" :employee-id="employee.id" :user-id="employee.userId" />
       </Tabs.TabPane>
+      <Tabs.TabPane key="contract" tab="鍛樺伐鍚堝悓">
+        <ContractList :employee-id="employee.id" />
+      </Tabs.TabPane>
     </Tabs>
   </div>
 </template>

--
Gitblit v1.9.3