Преглед на файлове

feat(@six/smart-pharmacy): 智慧药事系统第二版-岗位人员资质管理静态页面新增

cmj преди 4 седмици
родител
ревизия
22575d0f12

+ 10 - 0
apps/smart-pharmacy/public/database/menu.json

@@ -58,6 +58,16 @@
           "title": "用户管理"
         },
         "component": "/system/user/list"
+      },
+      {
+        "id": "2415",
+        "path": "/system/personnel-qualification",
+        "name": "SystemPersonnelQualification",
+        "meta": {
+          "icon": "mdi:certificate-outline",
+          "title": "岗位人员资质管理"
+        },
+        "component": "/system/personnel-qualification/list"
       }
     ]
   },

+ 1 - 0
apps/smart-pharmacy/src/api/index.ts

@@ -17,6 +17,7 @@ export * from './method/business';
 export * from './method/common';
 export * from './method/dict';
 export * from './method/patient-evaluation';
+export * from './method/personnel-qualification';
 export * from './method/prescription';
 export * from './method/prescription-review';
 export * from './method/system';

+ 559 - 0
apps/smart-pharmacy/src/api/method/personnel-qualification.ts

@@ -0,0 +1,559 @@
+import type { TransformList, TransformRecord } from '#/api';
+
+/** 岗位人员资质(接口就绪后替换为真实请求) */
+export namespace PersonnelQualificationModel {
+  export type QualificationStatus = 'valid' | 'expiring' | 'expired';
+
+  export interface CertificateAttachment {
+    id: string;
+    name: string;
+    url: string;
+    type: 'image' | 'pdf';
+  }
+
+  export interface Certificate {
+    id: string;
+    name: string;
+    type?: string;
+    number: string;
+    expiryDate: string;
+    longTerm?: boolean;
+    status: QualificationStatus;
+    attachments: CertificateAttachment[];
+  }
+
+  export interface Personnel extends TransformRecord {
+    enterpriseId?: string;
+    enterpriseName: string;
+    decoctionCenterId?: string;
+    decoctionCenterName: string;
+    name: string;
+    positions: string[];
+    employeeNo: string;
+    idNumber: string;
+    certificateNames: string;
+    qualificationStatus: QualificationStatus;
+    certificates: Certificate[];
+  }
+
+  export interface PersonnelForm {
+    id?: string;
+    decoctionCenterId: string;
+    decoctionCenterName?: string;
+    name: string;
+    positions: string[];
+    employeeNo: string;
+    idNumber: string;
+    certificates: Certificate[];
+  }
+
+  export interface ListQuery {
+    enterpriseId?: string;
+    decoctionCenterId?: string;
+    position?: string;
+    qualificationStatus?: QualificationStatus;
+    keyword?: string;
+  }
+
+  export interface ExpirySummary {
+    expiredCount: number;
+    expiringCount: number;
+  }
+}
+
+export const POSITION_OPTIONS = [
+  { label: '接方、审方', value: '接方、审方' },
+  { label: '审方', value: '审方' },
+  { label: '配药', value: '配药' },
+  { label: '复核', value: '复核' },
+  { label: '煎煮', value: '煎煮' },
+  { label: '打包', value: '打包' },
+];
+
+export const CERTIFICATE_NAME_OPTIONS = [
+  { label: '健康证', value: '健康证' },
+  { label: '中药调剂员', value: '中药调剂员' },
+  { label: '中药保管员', value: '中药保管员' },
+  { label: '执业中药师', value: '执业中药师' },
+];
+
+export const QUALIFICATION_STATUS_OPTIONS = [
+  { label: '有效', value: 'valid' },
+  { label: '即将过期', value: 'expiring' },
+  { label: '过期', value: 'expired' },
+];
+
+export const QUALIFICATION_STATUS_LABELS: Record<
+  PersonnelQualificationModel.QualificationStatus,
+  string
+> = {
+  valid: '有效',
+  expiring: '即将过期',
+  expired: '过期',
+};
+
+/** 各证书类型独立的 mock 附件,避免多证共用同一套图片 */
+function getDefaultAttachments(
+  certName: string,
+  index: number,
+): PersonnelQualificationModel.CertificateAttachment[] {
+  const attachmentSets: Record<
+    string,
+    PersonnelQualificationModel.CertificateAttachment[]
+  > = {
+    健康证: [
+      {
+        id: `health-${index}-1`,
+        name: '健康证正面',
+        url: 'https://fastly.jsdelivr.net/npm/@vant/assets/apple-8.jpeg',
+        type: 'image',
+      },
+      {
+        id: `health-${index}-2`,
+        name: '健康证反面',
+        url: 'https://fastly.jsdelivr.net/npm/@vant/assets/apple-7.jpeg',
+        type: 'image',
+      },
+    ],
+    中药保管员: [
+      {
+        id: `custodian-${index}-1`,
+        name: '保管员资格证',
+        url: 'https://fastly.jsdelivr.net/npm/@vant/assets/apple-6.jpeg',
+        type: 'image',
+      },
+      {
+        id: `custodian-${index}-2`,
+        name: '保管员证书扫描件',
+        url: 'https://fastly.jsdelivr.net/npm/@vant/assets/apple-5.jpeg',
+        type: 'image',
+      },
+    ],
+    执业中药师: [
+      {
+        id: `pharmacist-${index}-1`,
+        name: '执业药师资格证',
+        url: 'https://fastly.jsdelivr.net/npm/@vant/assets/apple-4.jpeg',
+        type: 'image',
+      },
+      {
+        id: `pharmacist-${index}-2`,
+        name: '执业药师注册证',
+        url: 'https://fastly.jsdelivr.net/npm/@vant/assets/apple-3.jpeg',
+        type: 'pdf',
+      },
+    ],
+    中药调剂员: [
+      {
+        id: `dispenser-${index}-1`,
+        name: '调剂员资格证',
+        url: 'https://fastly.jsdelivr.net/npm/@vant/assets/apple-2.jpeg',
+        type: 'image',
+      },
+      {
+        id: `dispenser-${index}-2`,
+        name: '调剂员证书扫描件',
+        url: 'https://fastly.jsdelivr.net/npm/@vant/assets/apple-1.jpeg',
+        type: 'image',
+      },
+    ],
+  };
+
+  return (
+    attachmentSets[certName] ?? [
+      {
+        id: `cert-${index}-1`,
+        name: '证书附件',
+        url: 'https://fastly.jsdelivr.net/npm/@vant/assets/apple-8.jpeg',
+        type: 'image',
+      },
+    ]
+  );
+}
+
+function buildCertificates(
+  items: Array<
+    Omit<PersonnelQualificationModel.Certificate, 'id' | 'attachments'> & {
+      attachments?: PersonnelQualificationModel.CertificateAttachment[];
+    }
+  >,
+): PersonnelQualificationModel.Certificate[] {
+  return items.map((item, index) => ({
+    ...item,
+    id: `cert-${index + 1}`,
+    attachments: item.attachments ?? getDefaultAttachments(item.name, index),
+  }));
+}
+
+function resolveWorstStatus(
+  certificates: PersonnelQualificationModel.Certificate[],
+): PersonnelQualificationModel.QualificationStatus {
+  if (certificates.some((item) => item.status === 'expired')) return 'expired';
+  if (certificates.some((item) => item.status === 'expiring')) return 'expiring';
+  return 'valid';
+}
+
+function buildPersonnel(
+  data: Omit<
+    PersonnelQualificationModel.Personnel,
+    'certificateNames' | 'qualificationStatus'
+  >,
+): PersonnelQualificationModel.Personnel {
+  const certificateNames = data.certificates.map((item) => item.name).join('、');
+  return {
+    ...data,
+    certificateNames,
+    qualificationStatus: resolveWorstStatus(data.certificates),
+  };
+}
+
+const MOCK_PERSONNEL: PersonnelQualificationModel.Personnel[] = [
+  buildPersonnel({
+    id: '1',
+    enterpriseId: 'e1',
+    enterpriseName: '重药煎药中心',
+    decoctionCenterId: 'c1',
+    decoctionCenterName: '重药华东煎药中心2',
+    name: '孙明1',
+    positions: ['接方、审方'],
+    employeeNo: '28473',
+    idNumber: '330102199001012839',
+    certificates: buildCertificates([
+      {
+        name: '健康证',
+        number: '944895756806594342',
+        expiryDate: '2027-3-20',
+        status: 'valid',
+        type: '健康证',
+      },
+      {
+        name: '中药保管员',
+        number: '944895756806594343',
+        expiryDate: '2026-4-15',
+        status: 'expiring',
+        type: '保管员',
+      },
+      {
+        name: '执业中药师',
+        number: '944895756806594344',
+        expiryDate: '2028-6-30',
+        status: 'valid',
+        type: '执业药师',
+      },
+    ]),
+  }),
+  buildPersonnel({
+    id: '2',
+    enterpriseId: 'e1',
+    enterpriseName: '重药煎药中心',
+    decoctionCenterId: 'c1',
+    decoctionCenterName: '重药华东煎药中心2',
+    name: '孙明2',
+    positions: ['审方'],
+    employeeNo: '28474',
+    idNumber: '330103198802022840',
+    certificates: buildCertificates([
+      {
+        name: '健康证',
+        number: '944895756806594345',
+        expiryDate: '2025-1-10',
+        status: 'expired',
+      },
+      {
+        name: '执业中药师',
+        number: '944895756806594346',
+        expiryDate: '2027-8-20',
+        status: 'valid',
+      },
+    ]),
+  }),
+  buildPersonnel({
+    id: '3',
+    enterpriseId: 'e1',
+    enterpriseName: '重药煎药中心',
+    decoctionCenterId: 'c1',
+    decoctionCenterName: '重药华东煎药中心2',
+    name: '孙明3',
+    positions: ['配药'],
+    employeeNo: '28475',
+    idNumber: '330104199203033841',
+    certificates: buildCertificates([
+      {
+        name: '中药调剂员',
+        number: '944895756806594347',
+        expiryDate: '2026-5-1',
+        status: 'expiring',
+      },
+      {
+        name: '健康证',
+        number: '944895756806594348',
+        expiryDate: '2027-2-18',
+        status: 'valid',
+      },
+    ]),
+  }),
+  buildPersonnel({
+    id: '4',
+    enterpriseId: 'e1',
+    enterpriseName: '重药煎药中心',
+    decoctionCenterId: 'c1',
+    decoctionCenterName: '重药华东煎药中心2',
+    name: '孙明4',
+    positions: ['复核'],
+    employeeNo: '28476',
+    idNumber: '330105199504044842',
+    certificates: buildCertificates([
+      {
+        name: '健康证',
+        number: '944895756806594349',
+        expiryDate: '2027-12-31',
+        status: 'valid',
+      },
+    ]),
+  }),
+  buildPersonnel({
+    id: '5',
+    enterpriseId: 'e1',
+    enterpriseName: '重药煎药中心',
+    decoctionCenterId: 'c1',
+    decoctionCenterName: '重药华东煎药中心2',
+    name: '孙明5',
+    positions: ['煎煮'],
+    employeeNo: '28477',
+    idNumber: '330106199605055843',
+    certificates: buildCertificates([
+      {
+        name: '健康证',
+        number: '944895756806594350',
+        expiryDate: '2024-12-1',
+        status: 'expired',
+      },
+    ]),
+  }),
+  buildPersonnel({
+    id: '6',
+    enterpriseId: 'e1',
+    enterpriseName: '重药煎药中心',
+    decoctionCenterId: 'c1',
+    decoctionCenterName: '重药华东煎药中心2',
+    name: '孙明6',
+    positions: ['打包'],
+    employeeNo: '28478',
+    idNumber: '330107199706066844',
+    certificates: buildCertificates([
+      {
+        name: '健康证',
+        number: '944895756806594351',
+        expiryDate: '2027-9-10',
+        status: 'valid',
+      },
+    ]),
+  }),
+  buildPersonnel({
+    id: '7',
+    enterpriseId: 'e2',
+    enterpriseName: '杭州中药煎配中心',
+    decoctionCenterId: 'c2',
+    decoctionCenterName: '西湖煎药中心',
+    name: '李明1',
+    positions: ['接方、审方'],
+    employeeNo: '38473',
+    idNumber: '330108199807077845',
+    certificates: buildCertificates([
+      {
+        name: '执业中药师',
+        number: '944895756806594352',
+        expiryDate: '2026-3-20',
+        status: 'expiring',
+      },
+    ]),
+  }),
+  buildPersonnel({
+    id: '8',
+    enterpriseId: 'e2',
+    enterpriseName: '杭州中药煎配中心',
+    decoctionCenterId: 'c2',
+    decoctionCenterName: '西湖煎药中心',
+    name: '李明2',
+    positions: ['配药'],
+    employeeNo: '38474',
+    idNumber: '330109199908088846',
+    certificates: buildCertificates([
+      {
+        name: '中药调剂员',
+        number: '944895756806594353',
+        expiryDate: '2027-6-15',
+        status: 'valid',
+      },
+    ]),
+  }),
+];
+
+let mockStore = [...MOCK_PERSONNEL];
+
+function filterPersonnel(
+  list: PersonnelQualificationModel.Personnel[],
+  query?: PersonnelQualificationModel.ListQuery,
+) {
+  if (!query) return list;
+
+  return list.filter((item) => {
+    if (query.enterpriseId && item.enterpriseId !== query.enterpriseId) {
+      return false;
+    }
+    if (
+      query.decoctionCenterId &&
+      item.decoctionCenterId !== query.decoctionCenterId
+    ) {
+      return false;
+    }
+    if (query.position && !item.positions.includes(query.position)) {
+      return false;
+    }
+    if (
+      query.qualificationStatus &&
+      item.qualificationStatus !== query.qualificationStatus
+    ) {
+      return false;
+    }
+    if (query.keyword) {
+      const keyword = query.keyword.trim();
+      if (
+        !item.name.includes(keyword) &&
+        !item.employeeNo.includes(keyword)
+      ) {
+        return false;
+      }
+    }
+    return true;
+  });
+}
+
+function computeExpirySummary(): PersonnelQualificationModel.ExpirySummary {
+  let expiredCount = 0;
+  let expiringCount = 0;
+
+  for (const person of mockStore) {
+    if (person.qualificationStatus === 'expired') expiredCount += 1;
+    else if (person.qualificationStatus === 'expiring') expiringCount += 1;
+  }
+
+  return { expiredCount, expiringCount };
+}
+
+/** 岗位人员资质列表(当前为本地 mock,后期对接后端接口) */
+export function listPersonnelQualificationsMethod(
+  page = 1,
+  size = 10,
+  query?: PersonnelQualificationModel.ListQuery,
+): Promise<TransformList<PersonnelQualificationModel.Personnel>> {
+  const filtered = filterPersonnel(mockStore, query);
+  const start = (page - 1) * size;
+  const items = filtered.slice(start, start + size);
+  return Promise.resolve({
+    items,
+    total: filtered.length,
+    data: { page, size, total: filtered.length },
+  });
+}
+
+/** 资质到期统计(当前为本地 mock) */
+export function getPersonnelQualificationSummaryMethod(): Promise<PersonnelQualificationModel.ExpirySummary> {
+  return Promise.resolve(computeExpirySummary());
+}
+
+/** 岗位人员资质详情(当前为本地 mock) */
+export function getPersonnelQualificationMethod(id: string) {
+  const item = mockStore.find((row) => row.id === id);
+  if (!item) {
+    return Promise.reject(new Error('人员记录不存在'));
+  }
+  return Promise.resolve({ ...item });
+}
+
+/** 新增/修改岗位人员资质(当前为本地 mock) */
+export function editPersonnelQualificationMethod(
+  data: PersonnelQualificationModel.PersonnelForm,
+) {
+  const certificates = data.certificates.map((cert, index) => ({
+    ...cert,
+    id: cert.id || `cert-${Date.now()}-${index}`,
+    status: cert.status ?? 'valid',
+    attachments: cert.attachments ?? [],
+  }));
+
+  const enterpriseName =
+    mockStore.find((item) => item.decoctionCenterId === data.decoctionCenterId)
+      ?.enterpriseName ?? '重药煎药中心';
+  const decoctionCenterName =
+    data.decoctionCenterName ??
+    mockStore.find((item) => item.decoctionCenterId === data.decoctionCenterId)
+      ?.decoctionCenterName ??
+    '重药华东煎药中心2';
+
+  const payload = buildPersonnel({
+    id: data.id ?? String(Date.now()),
+    enterpriseId: mockStore.find(
+      (item) => item.decoctionCenterId === data.decoctionCenterId,
+    )?.enterpriseId,
+    enterpriseName,
+    decoctionCenterId: data.decoctionCenterId,
+    decoctionCenterName,
+    name: data.name,
+    positions: data.positions,
+    employeeNo: data.employeeNo,
+    idNumber: data.idNumber,
+    certificates,
+  });
+
+  if (data.id) {
+    const index = mockStore.findIndex((item) => item.id === data.id);
+    if (index === -1) {
+      return Promise.reject(new Error('人员记录不存在'));
+    }
+    mockStore[index] = payload;
+  } else {
+    mockStore = [payload, ...mockStore];
+  }
+
+  return Promise.resolve(payload);
+}
+
+/** 删除岗位人员资质(当前为本地 mock) */
+export function deletePersonnelQualificationMethod(id: string) {
+  const index = mockStore.findIndex((item) => item.id === id);
+  if (index === -1) {
+    return Promise.reject(new Error('人员记录不存在'));
+  }
+  mockStore.splice(index, 1);
+  return Promise.resolve(true);
+}
+
+/** 煎药企业选项(mock) */
+export function optionsPersonnelEnterpriseMethod() {
+  const map = new Map<string, string>();
+  for (const item of mockStore) {
+    if (item.enterpriseId) {
+      map.set(item.enterpriseId, item.enterpriseName);
+    }
+  }
+  return Promise.resolve(
+    [...map.entries()].map(([value, label]) => ({ label, value })),
+  );
+}
+
+/** 煎药中心选项(mock) */
+export function optionsPersonnelDecoctionCenterMethod(
+  enterpriseId?: string,
+) {
+  const map = new Map<string, string>();
+  for (const item of mockStore) {
+    if (enterpriseId && item.enterpriseId !== enterpriseId) continue;
+    if (item.decoctionCenterId) {
+      map.set(item.decoctionCenterId, item.decoctionCenterName);
+    }
+  }
+  return Promise.resolve(
+    [...map.entries()].map(([value, label]) => ({ label, value })),
+  );
+}

+ 65 - 0
apps/smart-pharmacy/src/api/model/menu.ts

@@ -36,6 +36,11 @@ const accessMenuRouteMap = buildAccessMenuRouteMap(
  * 后期后端配置相同 id 的菜单后,将优先使用接口返回的 label。
  */
 export const HARDCODED_MENU_TREE_SELECT: TreeSelectMenuNode[] = [
+  {
+    id: '1',
+    label: '系统管理',
+    children: [{ id: '2415', label: '岗位人员资质管理' }],
+  },
   {
     id: '2500',
     label: '处方点评',
@@ -55,6 +60,7 @@ export const HARDCODED_MENU_TREE_SELECT: TreeSelectMenuNode[] = [
 ];
 
 const HARDCODED_MENU_ROOT_IDS = ['2500', '2600'];
+const HARDCODED_MENU_LEAF_IDS = ['2415'];
 
 /** 将本地写死菜单合并进后端 treeselect 结果 */
 export function mergeHardcodedMenuTree(
@@ -82,6 +88,64 @@ export function mergeHardcodedMenuTree(
   return backend;
 }
 
+function findMenuInTree(
+  menus: SystemModel.Menu[],
+  id: string,
+): SystemModel.Menu | null {
+  for (const menu of menus) {
+    if (String(menu.id) === id) return menu;
+    if (menu.children?.length) {
+      const found = findMenuInTree(menu.children, id);
+      if (found) return found;
+    }
+  }
+  return null;
+}
+
+function findParentOfMenu(
+  menus: SystemModel.Menu[],
+  id: string,
+): SystemModel.Menu | null {
+  for (const menu of menus) {
+    if (menu.children?.some((child) => String(child.id) === id)) return menu;
+    if (menu.children?.length) {
+      const found = findParentOfMenu(menu.children, id);
+      if (found) return found;
+    }
+  }
+  return null;
+}
+
+/** 父级菜单可见时,补全本地写死的子菜单(便于前期联调) */
+function ensureHardcodedMenuLeavesVisible(
+  filtered: SystemModel.Menu[],
+  all: SystemModel.Menu[],
+): SystemModel.Menu[] {
+  const result = filtered.map((menu) => ({
+    ...menu,
+    children: menu.children ? [...menu.children] : undefined,
+  }));
+
+  for (const leafId of HARDCODED_MENU_LEAF_IDS) {
+    if (findMenuInTree(result, leafId)) continue;
+    const leaf = findMenuInTree(all, leafId);
+    const parentInAll = findParentOfMenu(all, leafId);
+    if (!leaf || !parentInAll) continue;
+
+    const parentInFiltered = findMenuInTree(result, String(parentInAll.id));
+    if (!parentInFiltered) continue;
+
+    parentInFiltered.children ??= [];
+    if (
+      !parentInFiltered.children.some((child) => String(child.id) === leafId)
+    ) {
+      parentInFiltered.children.push(leaf);
+    }
+  }
+
+  return result;
+}
+
 /** 角色权限未包含写死菜单 id 时,仍保留本地菜单(便于前期联调) */
 export function ensureHardcodedMenusVisible(
   filtered: SystemModel.Menu[],
@@ -94,6 +158,7 @@ export function ensureHardcodedMenusVisible(
     if (result.some((menu) => String(menu.id) === rootId)) continue;
     result = [...result, hardRoot];
   }
+  result = ensureHardcodedMenuLeavesVisible(result, all);
   return result.sort(
     (a, b) => (a.meta.order ?? 999_999) - (b.meta.order ?? 999_999),
   );

+ 9 - 0
apps/smart-pharmacy/src/utils/mask-id.ts

@@ -0,0 +1,9 @@
+/** 身份证号中间脱敏,如 3301********2839 */
+export function maskIdNumber(idNumber?: string): string {
+  if (!idNumber || idNumber === '-') return idNumber ?? '-';
+
+  const value = idNumber.trim();
+  if (value.length < 8) return value;
+
+  return `${value.slice(0, 4)}********${value.slice(-4)}`;
+}

+ 198 - 0
apps/smart-pharmacy/src/views/system/personnel-qualification/data.ts

@@ -0,0 +1,198 @@
+import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
+
+import type { VbenFormSchema } from '#/adapter/form';
+import type { OnActionClickFn } from '#/adapter/vxe-table';
+import type { PersonnelQualificationModel } from '#/api/method/personnel-qualification';
+
+import { h } from 'vue';
+
+import {
+  CERTIFICATE_NAME_OPTIONS,
+  optionsPersonnelDecoctionCenterMethod,
+  optionsPersonnelEnterpriseMethod,
+  POSITION_OPTIONS,
+  QUALIFICATION_STATUS_LABELS,
+  QUALIFICATION_STATUS_OPTIONS,
+} from '#/api/method/personnel-qualification';
+import { maskIdNumber } from '#/utils/mask-id';
+
+export function usePersonnelQualificationSearchFormSchema(): VbenFormSchema[] {
+  return [
+    {
+      component: 'ApiSelect',
+      componentProps: {
+        allowClear: true,
+        api: optionsPersonnelEnterpriseMethod,
+        class: 'w-full',
+        labelField: 'label',
+        showSearch: true,
+        valueField: 'value',
+      },
+      fieldName: 'enterpriseId',
+      label: '煎药企业',
+    },
+    {
+      component: 'ApiSelect',
+      componentProps: {
+        allowClear: true,
+        api: () => optionsPersonnelDecoctionCenterMethod(),
+        class: 'w-full',
+        labelField: 'label',
+        showSearch: true,
+        valueField: 'value',
+      },
+      dependencies: {
+        componentProps: (values) => ({
+          key: `decoction-center-${values.enterpriseId || 'all'}`,
+          allowClear: true,
+          api: () =>
+            optionsPersonnelDecoctionCenterMethod(values.enterpriseId),
+          class: 'w-full',
+          labelField: 'label',
+          showSearch: true,
+          valueField: 'value',
+        }),
+        trigger: (_values, formApi) => {
+          formApi.setFieldValue('decoctionCenterId', undefined);
+        },
+        triggerFields: ['enterpriseId'],
+      },
+      fieldName: 'decoctionCenterId',
+      label: '煎药中心',
+    },
+    {
+      component: 'Select',
+      componentProps: {
+        allowClear: true,
+        options: POSITION_OPTIONS,
+        placeholder: '请选择',
+      },
+      fieldName: 'position',
+      label: '岗位',
+    },
+    {
+      component: 'Select',
+      componentProps: {
+        allowClear: true,
+        options: QUALIFICATION_STATUS_OPTIONS,
+        placeholder: '请选择',
+      },
+      fieldName: 'qualificationStatus',
+      label: '资质状态',
+    },
+    {
+      component: 'Input',
+      componentProps: {
+        placeholder: '请输入',
+      },
+      fieldName: 'keyword',
+      label: '姓名/工号',
+    },
+  ];
+}
+
+export function usePersonnelQualificationTableColumns(
+  onActionClick?: OnActionClickFn<PersonnelQualificationModel.Personnel>,
+): VxeTableGridOptions<PersonnelQualificationModel.Personnel>['columns'] {
+  return [
+    {
+      field: 'enterpriseName',
+      minWidth: 160,
+      title: '煎药企业/医疗机构',
+    },
+    {
+      field: 'decoctionCenterName',
+      minWidth: 150,
+      title: '所属煎药中心',
+    },
+    {
+      field: 'name',
+      minWidth: 90,
+      title: '姓名',
+    },
+    {
+      field: 'positions',
+      minWidth: 110,
+      slots: {
+        default: ({ row }) => row.positions?.join('、') || '-',
+      },
+      title: '岗位',
+    },
+    {
+      field: 'employeeNo',
+      minWidth: 90,
+      title: '工号',
+    },
+    {
+      field: 'idNumber',
+      minWidth: 150,
+      slots: {
+        default: ({ row }) => maskIdNumber(row.idNumber),
+      },
+      title: '身份证号',
+    },
+    {
+      field: 'certificateNames',
+      minWidth: 160,
+      title: '证书名称',
+    },
+    {
+      field: 'qualificationStatus',
+      minWidth: 100,
+      slots: {
+        default: ({ row }) => {
+          const label =
+            QUALIFICATION_STATUS_LABELS[row.qualificationStatus] ?? '-';
+          const isWarning =
+            row.qualificationStatus === 'expired' ||
+            row.qualificationStatus === 'expiring';
+          return h(
+            'span',
+            { style: isWarning ? { color: '#ff4d4f' } : undefined },
+            label,
+          );
+        },
+      },
+      title: '资质状态',
+    },
+    {
+      align: 'center',
+      cellRender: {
+        attrs: {
+          nameField: 'name',
+          nameTitle: '岗位人员',
+          onClick: onActionClick,
+        },
+        name: 'CellOperation',
+        options: [
+          {
+            code: 'certificate',
+            style: { color: '#fa8c16' },
+            text: '证书',
+          },
+          'edit',
+          'delete',
+        ],
+      },
+      field: 'operation',
+      fixed: 'right',
+      title: '操作',
+      width: 160,
+    },
+  ];
+}
+
+export function createEmptyCertificate(): PersonnelQualificationModel.Certificate {
+  return {
+    attachments: [],
+    expiryDate: '',
+    id: '',
+    longTerm: false,
+    name: '',
+    number: '',
+    status: 'valid',
+    type: '',
+  };
+}
+
+export { CERTIFICATE_NAME_OPTIONS, POSITION_OPTIONS };

+ 182 - 0
apps/smart-pharmacy/src/views/system/personnel-qualification/list.vue

@@ -0,0 +1,182 @@
+<script lang="ts" setup>
+import type {
+  OnActionClickParams,
+  VxeTableGridOptions,
+} from '#/adapter/vxe-table';
+import type { PersonnelQualificationModel } from '#/api';
+
+import { onMounted, ref } from 'vue';
+
+import { Page, useVbenDrawer, useVbenModal } from '@vben/common-ui';
+import { Plus } from '@vben/icons';
+
+import { Button, message, Modal } from 'ant-design-vue';
+
+import { useVbenVxeGrid } from '#/adapter/vxe-table';
+import {
+  deletePersonnelQualificationMethod,
+  getPersonnelQualificationSummaryMethod,
+  listPersonnelQualificationsMethod,
+} from '#/api';
+
+import {
+  usePersonnelQualificationSearchFormSchema,
+  usePersonnelQualificationTableColumns,
+} from './data';
+import Certificate from './modules/certificate.vue';
+import Form from './modules/form.vue';
+
+const summary = ref<PersonnelQualificationModel.ExpirySummary>({
+  expiredCount: 0,
+  expiringCount: 0,
+});
+
+const [FormDrawer, formDrawerApi] = useVbenDrawer({
+  connectedComponent: Form,
+  destroyOnClose: true,
+});
+
+const [CertificateModal, certificateModalApi] = useVbenModal({
+  connectedComponent: Certificate,
+  destroyOnClose: true,
+});
+
+const [Grid, gridApi] = useVbenVxeGrid({
+  formOptions: {
+    schema: usePersonnelQualificationSearchFormSchema(),
+    submitOnChange: false,
+    wrapperClass:
+      'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 personnel-qualification-search-form',
+  },
+  gridOptions: {
+    columns: usePersonnelQualificationTableColumns(onActionClick),
+    height: 'auto',
+    keepSource: true,
+    pagerConfig: {
+      pageSize: 10,
+      layouts: ['PrevPage', 'Jump', 'NextPage'],
+    },
+    proxyConfig: {
+      ajax: {
+        query({ page }, formValues) {
+          return listPersonnelQualificationsMethod(
+            page.currentPage,
+            page.pageSize,
+            formValues,
+          );
+        },
+      },
+    },
+    rowConfig: {
+      keyField: 'id',
+    },
+    stripe: true,
+  } as VxeTableGridOptions<PersonnelQualificationModel.Personnel>,
+});
+
+async function loadSummary() {
+  summary.value = await getPersonnelQualificationSummaryMethod();
+}
+
+function onActionClick(
+  e: OnActionClickParams<PersonnelQualificationModel.Personnel>,
+) {
+  switch (e.code) {
+    case 'certificate': {
+      onCertificateHandle(e.row);
+      break;
+    }
+    case 'delete': {
+      onDeleteHandle(e.row);
+      break;
+    }
+    case 'edit': {
+      onEditHandle(e.row);
+      break;
+    }
+  }
+}
+
+function confirm(content: string, title: string) {
+  return new Promise((resolve, reject) => {
+    Modal.confirm({
+      content,
+      onCancel() {
+        reject(new Error('已取消'));
+      },
+      onOk() {
+        resolve(true);
+      },
+      title,
+    });
+  });
+}
+
+function onRefresh() {
+  gridApi.query();
+  loadSummary();
+}
+
+function onAddHandle() {
+  formDrawerApi.setData(undefined).open();
+}
+
+function onEditHandle(row: PersonnelQualificationModel.Personnel) {
+  formDrawerApi.setData(row).open();
+}
+
+function onCertificateHandle(row: PersonnelQualificationModel.Personnel) {
+  certificateModalApi.setData(row).open();
+}
+
+async function onDeleteHandle(row: PersonnelQualificationModel.Personnel) {
+  try {
+    await confirm(`确定删除人员【${row.name}】吗?`, '删除确认');
+    await deletePersonnelQualificationMethod(row.id);
+    message.success('删除成功');
+    onRefresh();
+  } catch (error: any) {
+    if (error.message !== '已取消') {
+      message.error(error.message || '删除失败');
+    }
+  }
+}
+
+onMounted(() => {
+  loadSummary();
+});
+</script>
+<template>
+  <Page auto-content-height class="personnel-qualification-page">
+    <FormDrawer @success="onRefresh" />
+    <CertificateModal />
+    <div
+      v-if="summary.expiredCount || summary.expiringCount"
+      class="qualification-alert"
+    >
+      有{{ summary.expiredCount }}位人员的资质证书已过期,有{{
+        summary.expiringCount
+      }}位人员的资质证书即将过期
+    </div>
+    <Grid>
+      <template #toolbar-tools>
+        <Button type="primary" @click="onAddHandle">
+          <Plus class="size-5" />
+          新增
+        </Button>
+      </template>
+    </Grid>
+  </Page>
+</template>
+<style scoped>
+.personnel-qualification-page :deep(.personnel-qualification-search-form) {
+  margin-bottom: 0;
+}
+
+.qualification-alert {
+  margin: 0 0 12px;
+  color: #ff4d4f;
+  font-size: 14px;
+  line-height: 1.6;
+}
+</style>

+ 287 - 0
apps/smart-pharmacy/src/views/system/personnel-qualification/modules/certificate.vue

@@ -0,0 +1,287 @@
+<script lang="ts" setup>
+import type { PersonnelQualificationModel } from '#/api';
+
+import { computed, ref } from 'vue';
+
+import { useVbenModal } from '@vben/common-ui';
+import { IconifyIcon } from '@vben/icons';
+
+import { Spin, Tabs } from 'ant-design-vue';
+
+import { getPersonnelQualificationMethod } from '#/api';
+import { QUALIFICATION_STATUS_LABELS } from '#/api/method/personnel-qualification';
+
+const loading = ref(false);
+const detail = ref<PersonnelQualificationModel.Personnel>(
+  {} as PersonnelQualificationModel.Personnel,
+);
+const activeCertId = ref('');
+const activeAttachmentIndex = ref(0);
+
+const activeCertificate = computed(() =>
+  detail.value.certificates?.find((item) => item.id === activeCertId.value),
+);
+
+const activeAttachments = computed(
+  () => activeCertificate.value?.attachments ?? [],
+);
+
+const currentAttachment = computed(
+  () => activeAttachments.value[activeAttachmentIndex.value],
+);
+
+function getTabLabel(cert: PersonnelQualificationModel.Certificate) {
+  const label = cert.name;
+  if (cert.status === 'expiring') {
+    return `${label}(即将过期)`;
+  }
+  if (cert.status === 'expired') {
+    return `${label}(过期)`;
+  }
+  return label;
+}
+
+function isTabWarning(cert: PersonnelQualificationModel.Certificate) {
+  return cert.status === 'expiring' || cert.status === 'expired';
+}
+
+function onTabChange(key: string | number) {
+  activeCertId.value = String(key);
+  activeAttachmentIndex.value = 0;
+}
+
+function showPrevAttachment() {
+  if (!activeAttachments.value.length) return;
+  activeAttachmentIndex.value =
+    (activeAttachmentIndex.value - 1 + activeAttachments.value.length) %
+    activeAttachments.value.length;
+}
+
+function showNextAttachment() {
+  if (!activeAttachments.value.length) return;
+  activeAttachmentIndex.value =
+    (activeAttachmentIndex.value + 1) % activeAttachments.value.length;
+}
+
+const [Modal, modalApi] = useVbenModal({
+  class: 'certificate-view-modal !w-[calc(100%-120px)] !max-w-[960px]',
+  showConfirmButton: false,
+  async onOpenChange(isOpen) {
+    if (!isOpen) {
+      detail.value = {} as PersonnelQualificationModel.Personnel;
+      activeCertId.value = '';
+      activeAttachmentIndex.value = 0;
+      return;
+    }
+
+    const data = modalApi.getData<PersonnelQualificationModel.Personnel>();
+    if (!data?.id) return;
+
+    loading.value = true;
+    try {
+      detail.value = await getPersonnelQualificationMethod(data.id);
+      activeCertId.value = detail.value.certificates?.[0]?.id ?? '';
+      activeAttachmentIndex.value = 0;
+    } finally {
+      loading.value = false;
+    }
+  },
+});
+</script>
+<template>
+  <Modal :title="`${detail.name || ''} - 资质证书`">
+    <Spin :spinning="loading">
+      <div v-if="detail.certificates?.length" class="certificate-view">
+        <Tabs
+          :active-key="activeCertId"
+          class="certificate-tabs"
+          @change="onTabChange"
+        >
+          <Tabs.TabPane v-for="cert in detail.certificates" :key="cert.id">
+            <template #tab>
+              <span :class="{ 'tab-warning': isTabWarning(cert) }">
+                {{ getTabLabel(cert) }}
+              </span>
+            </template>
+          </Tabs.TabPane>
+        </Tabs>
+
+        <div v-if="activeCertificate" class="certificate-meta">
+          <div class="meta-item">
+            <span class="meta-label">资质编号:</span>
+            <span>{{ activeCertificate.number || '-' }}</span>
+          </div>
+          <div class="meta-item">
+            <span class="meta-label">资质到期日期:</span>
+            <span>{{
+              activeCertificate.longTerm
+                ? '长期'
+                : activeCertificate.expiryDate || '-'
+            }}</span>
+          </div>
+          <div class="meta-item">
+            <span class="meta-label">证书类型:</span>
+            <span>{{ activeCertificate.type || '-' }}</span>
+          </div>
+          <div class="meta-item">
+            <span class="meta-label">资质附件:</span>
+            <span>共{{ activeAttachments.length }}份</span>
+          </div>
+          <div class="meta-item">
+            <span class="meta-label">资质状态:</span>
+            <span
+              :class="{
+                'status-warning':
+                  activeCertificate.status === 'expiring' ||
+                  activeCertificate.status === 'expired',
+              }"
+            >
+              {{ QUALIFICATION_STATUS_LABELS[activeCertificate.status] || '-' }}
+            </span>
+          </div>
+        </div>
+
+        <div class="attachment-viewer">
+          <button
+            class="nav-btn"
+            type="button"
+            @click="showPrevAttachment"
+          >
+            <IconifyIcon class="size-5" icon="mdi:chevron-left" />
+          </button>
+          <div class="attachment-content">
+            <template v-if="currentAttachment">
+              <div
+                v-if="currentAttachment.type === 'pdf'"
+                class="attachment-placeholder"
+              >
+                <span class="placeholder-icon">PDF</span>
+                <span>{{ currentAttachment.name }}</span>
+              </div>
+              <img
+                v-else-if="currentAttachment.url"
+                :alt="currentAttachment.name"
+                :src="currentAttachment.url"
+                class="attachment-image"
+              />
+              <div v-else class="attachment-placeholder">
+                <span class="placeholder-icon">X</span>
+                <span>{{ currentAttachment.name }}</span>
+              </div>
+            </template>
+            <div v-else class="attachment-placeholder">
+              <span>暂无附件</span>
+            </div>
+          </div>
+          <button
+            class="nav-btn"
+            type="button"
+            @click="showNextAttachment"
+          >
+            <IconifyIcon class="size-5" icon="mdi:chevron-right" />
+          </button>
+        </div>
+      </div>
+      <div v-else class="empty-text">暂无证书信息</div>
+    </Spin>
+  </Modal>
+</template>
+<style scoped>
+.certificate-view {
+  padding: 8px 16px 16px;
+}
+
+.certificate-tabs :deep(.tab-warning) {
+  color: #ff4d4f;
+}
+
+.certificate-meta {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 12px 24px;
+  margin: 16px 0 24px;
+}
+
+.meta-item {
+  line-height: 1.6;
+  word-break: break-all;
+}
+
+.meta-label {
+  color: rgb(0 0 0 / 65%);
+}
+
+.status-warning {
+  color: #ff4d4f;
+}
+
+.attachment-viewer {
+  display: flex;
+  gap: 16px;
+  align-items: center;
+  min-height: 360px;
+}
+
+.nav-btn {
+  display: flex;
+  flex-shrink: 0;
+  align-items: center;
+  justify-content: center;
+  width: 40px;
+  height: 40px;
+  color: rgb(0 0 0 / 45%);
+  font-size: 18px;
+  background: #fff;
+  border: 1px solid #d9d9d9;
+  border-radius: 50%;
+  cursor: pointer;
+}
+
+.nav-btn:hover {
+  color: #1677ff;
+  border-color: #1677ff;
+}
+
+.attachment-content {
+  display: flex;
+  flex: 1;
+  align-items: center;
+  justify-content: center;
+  min-height: 360px;
+  background: #fafafa;
+  border: 1px dashed #d9d9d9;
+  border-radius: 8px;
+}
+
+.attachment-image {
+  max-width: 100%;
+  max-height: 340px;
+  object-fit: contain;
+}
+
+.attachment-placeholder {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  align-items: center;
+  justify-content: center;
+  color: rgb(0 0 0 / 45%);
+}
+
+.placeholder-icon {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 120px;
+  height: 120px;
+  font-size: 48px;
+  background: #fff;
+  border: 1px solid #d9d9d9;
+}
+
+.empty-text {
+  padding: 48px 0;
+  color: rgb(0 0 0 / 45%);
+  text-align: center;
+}
+</style>

+ 428 - 0
apps/smart-pharmacy/src/views/system/personnel-qualification/modules/form.vue

@@ -0,0 +1,428 @@
+<script lang="ts" setup>
+import type { UploadFile, UploadProps } from 'ant-design-vue';
+
+import type { PersonnelQualificationModel } from '#/api';
+
+import { computed, ref } from 'vue';
+
+import { useVbenDrawer } from '@vben/common-ui';
+import { Plus } from '@vben/icons';
+
+import {
+  Button,
+  Checkbox,
+  DatePicker,
+  Input,
+  message,
+  Select,
+  Spin,
+  Upload,
+} from 'ant-design-vue';
+
+import {
+  editPersonnelQualificationMethod,
+  getPersonnelQualificationMethod,
+  optionsPersonnelDecoctionCenterMethod,
+} from '#/api';
+
+import {
+  CERTIFICATE_NAME_OPTIONS,
+  createEmptyCertificate,
+  POSITION_OPTIONS,
+} from '../data';
+
+const emit = defineEmits(['success']);
+
+const loading = ref(false);
+const formData = ref<PersonnelQualificationModel.Personnel>();
+const decoctionCenterOptions = ref<Array<{ label: string; value: string }>>(
+  [],
+);
+
+const formModel = ref({
+  certificates: [createEmptyCertificate()] as PersonnelQualificationModel.Certificate[],
+  decoctionCenterId: '',
+  employeeNo: '',
+  idNumber: '',
+  name: '',
+  positions: [] as string[],
+});
+
+const getTitle = computed(() =>
+  formData.value?.id ? '修改人员' : '新增人员',
+);
+
+async function loadDecoctionCenterOptions() {
+  decoctionCenterOptions.value = await optionsPersonnelDecoctionCenterMethod();
+}
+
+function resetForm() {
+  formModel.value = {
+    certificates: [createEmptyCertificate()],
+    decoctionCenterId: '',
+    employeeNo: '',
+    idNumber: '',
+    name: '',
+    positions: [],
+  };
+}
+
+function addCertificate() {
+  formModel.value.certificates.push(createEmptyCertificate());
+}
+
+function removeCertificate(index: number) {
+  if (formModel.value.certificates.length <= 1) {
+    message.warning('至少保留一份证书');
+    return;
+  }
+  formModel.value.certificates.splice(index, 1);
+}
+
+function getUploadFileList(
+  cert: PersonnelQualificationModel.Certificate,
+): UploadFile[] {
+  return (cert.attachments ?? []).map((item) => ({
+    name: item.name,
+    status: 'done',
+    uid: item.id,
+    url: item.url,
+  }));
+}
+
+function createUploadChangeHandler(
+  cert: PersonnelQualificationModel.Certificate,
+): UploadProps['onChange'] {
+  return ({ fileList }) => {
+    cert.attachments = fileList.slice(0, 9).map((file, index) => ({
+      id: file.uid || `upload-${index}`,
+      name: file.name,
+      type: file.name?.toLowerCase().endsWith('.pdf') ? 'pdf' : 'image',
+      url: file.url || file.thumbUrl || '',
+    }));
+  };
+}
+
+function validateForm() {
+  const { certificates, decoctionCenterId, employeeNo, idNumber, name, positions } =
+    formModel.value;
+
+  if (!decoctionCenterId) {
+    message.warning('请选择所属煎药中心');
+    return false;
+  }
+  if (!name.trim()) {
+    message.warning('请输入姓名');
+    return false;
+  }
+  if (!positions.length) {
+    message.warning('请选择岗位');
+    return false;
+  }
+  if (!employeeNo.trim()) {
+    message.warning('请输入工号');
+    return false;
+  }
+  if (!idNumber.trim()) {
+    message.warning('请输入身份证号');
+    return false;
+  }
+
+  for (const [index, cert] of certificates.entries()) {
+    if (!cert.name) {
+      message.warning(`请选择第${index + 1}份证书名称`);
+      return false;
+    }
+    if (!cert.attachments?.length) {
+      message.warning(`请上传第${index + 1}份资质附件`);
+      return false;
+    }
+    if (!cert.number.trim()) {
+      message.warning(`请输入第${index + 1}份资质编号`);
+      return false;
+    }
+    if (!cert.longTerm && !cert.expiryDate) {
+      message.warning(`请选择第${index + 1}份资质到期日期`);
+      return false;
+    }
+  }
+
+  return true;
+}
+
+const [Drawer, drawerApi] = useVbenDrawer({
+  async onConfirm() {
+    if (!validateForm()) return;
+    drawerApi.lock();
+    try {
+      const center = decoctionCenterOptions.value.find(
+        (item) => item.value === formModel.value.decoctionCenterId,
+      );
+      await editPersonnelQualificationMethod({
+        certificates: formModel.value.certificates,
+        decoctionCenterId: formModel.value.decoctionCenterId,
+        decoctionCenterName: center?.label,
+        employeeNo: formModel.value.employeeNo.trim(),
+        id: formData.value?.id,
+        idNumber: formModel.value.idNumber.trim(),
+        name: formModel.value.name.trim(),
+        positions: formModel.value.positions,
+      });
+      message.success(formData.value?.id ? '修改成功' : '新增成功');
+      emit('success');
+      await drawerApi.close();
+    } catch (error: any) {
+      message.error(error.message || '保存失败');
+    } finally {
+      drawerApi.unlock();
+    }
+  },
+  async onOpenChange(isOpen) {
+    if (!isOpen) {
+      formData.value = undefined;
+      resetForm();
+      return;
+    }
+
+    await loadDecoctionCenterOptions();
+    const data = drawerApi.getData<PersonnelQualificationModel.Personnel>();
+
+    if (data?.id) {
+      loading.value = true;
+      try {
+        const detail = await getPersonnelQualificationMethod(data.id);
+        formData.value = detail;
+        formModel.value = {
+          certificates: detail.certificates.map((item) => ({ ...item })),
+          decoctionCenterId: detail.decoctionCenterId ?? '',
+          employeeNo: detail.employeeNo,
+          idNumber: detail.idNumber,
+          name: detail.name,
+          positions: [...detail.positions],
+        };
+      } finally {
+        loading.value = false;
+      }
+      return;
+    }
+
+    formData.value = undefined;
+    resetForm();
+  },
+});
+</script>
+<template>
+  <Drawer :title="getTitle" class="personnel-form-drawer">
+    <Spin :spinning="loading">
+    <div class="form-section">
+      <div class="form-item required">
+        <label>所属煎药中心</label>
+        <Select
+          v-model:value="formModel.decoctionCenterId"
+          :options="decoctionCenterOptions"
+          allow-clear
+          class="w-full"
+          placeholder="请搜索选择"
+          show-search
+        />
+      </div>
+      <div class="form-item required">
+        <label>姓名</label>
+        <Input v-model:value="formModel.name" placeholder="请输入" />
+      </div>
+      <div class="form-item required">
+        <label>岗位</label>
+        <Select
+          v-model:value="formModel.positions"
+          :options="POSITION_OPTIONS"
+          allow-clear
+          class="w-full"
+          mode="multiple"
+          placeholder="请选择"
+        />
+      </div>
+      <div class="form-item required">
+        <label>工号</label>
+        <Input v-model:value="formModel.employeeNo" placeholder="请输入" />
+      </div>
+      <div class="form-item required">
+        <label>身份证号</label>
+        <Input v-model:value="formModel.idNumber" placeholder="请输入" />
+      </div>
+    </div>
+
+    <div class="certificate-section">
+      <div class="section-title">证书列表</div>
+      <div
+        v-for="(cert, index) in formModel.certificates"
+        :key="index"
+        class="certificate-card"
+      >
+        <div class="certificate-card-header">
+          <span class="certificate-index">{{ index + 1 }}</span>
+          <span class="certificate-card-title">证书名称</span>
+          <Button
+            v-if="formModel.certificates.length > 1"
+            danger
+            size="small"
+            type="link"
+            @click="removeCertificate(index)"
+          >
+            删除
+          </Button>
+        </div>
+
+        <div class="form-item required">
+          <Select
+            v-model:value="cert.name"
+            :options="CERTIFICATE_NAME_OPTIONS"
+            allow-clear
+            class="w-full"
+            placeholder="请选择"
+          />
+        </div>
+
+        <div class="form-item required">
+          <label>资质附件</label>
+          <Upload
+            :before-upload="() => false"
+            :file-list="getUploadFileList(cert)"
+            :max-count="9"
+            accept=".jpg,.jpeg,.png,.pdf"
+            list-type="picture-card"
+            @change="createUploadChangeHandler(cert)"
+          >
+            <div v-if="(cert.attachments?.length ?? 0) < 9" class="upload-trigger">
+              <Plus class="size-5" />
+            </div>
+          </Upload>
+          <div class="upload-hint">上传图片或pdf文件,最多上传9份</div>
+        </div>
+
+        <div class="form-item">
+          <label>资质类型</label>
+          <Input v-model:value="cert.type" placeholder="请输入" />
+        </div>
+
+        <div class="form-item required">
+          <label>资质编号</label>
+          <Input
+            v-model:value="cert.number"
+            placeholder="请输入最新的资质编号"
+          />
+        </div>
+
+        <div class="form-item required expiry-row">
+          <label>资质到期日期</label>
+          <DatePicker
+            v-model:value="cert.expiryDate"
+            :disabled="cert.longTerm"
+            class="expiry-picker"
+            placeholder="请输入最新的资质到期日期"
+            value-format="YYYY-M-D"
+          />
+          <Checkbox v-model:checked="cert.longTerm">长期</Checkbox>
+        </div>
+      </div>
+
+      <Button block type="dashed" @click="addCertificate">添加证书</Button>
+    </div>
+    </Spin>
+  </Drawer>
+</template>
+<style scoped>
+.personnel-form-drawer :deep(.ant-drawer-body) {
+  padding-top: 12px;
+}
+
+.form-section,
+.certificate-section {
+  padding: 0 8px;
+}
+
+.form-item {
+  margin-bottom: 16px;
+}
+
+.form-item label {
+  display: block;
+  margin-bottom: 8px;
+  color: rgb(0 0 0 / 85%);
+}
+
+.form-item.required label::before,
+.certificate-card-title::before {
+  margin-right: 4px;
+  color: #ff4d4f;
+  content: '*';
+}
+
+.section-title {
+  margin: 8px 0 16px;
+  font-size: 16px;
+  font-weight: 600;
+}
+
+.certificate-card {
+  margin-bottom: 16px;
+  padding: 16px;
+  background: #fafafa;
+  border: 1px solid #f0f0f0;
+  border-radius: 8px;
+}
+
+.certificate-card-header {
+  display: flex;
+  gap: 8px;
+  align-items: center;
+  margin-bottom: 12px;
+}
+
+.certificate-index {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 20px;
+  height: 20px;
+  color: #1677ff;
+  font-size: 12px;
+  border: 1px solid #1677ff;
+  border-radius: 50%;
+}
+
+.certificate-card-title {
+  flex: 1;
+  font-weight: 500;
+}
+
+.upload-trigger {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 100%;
+  height: 100%;
+  color: rgb(0 0 0 / 45%);
+}
+
+.upload-hint {
+  margin-top: 8px;
+  color: rgb(0 0 0 / 45%);
+  font-size: 12px;
+}
+
+.expiry-row {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 12px;
+  align-items: center;
+}
+
+.expiry-row label {
+  width: 100%;
+}
+
+.expiry-picker {
+  flex: 1;
+  min-width: 220px;
+}
+</style>