Преглед изворни кода

feat(@six/smart-pharmacy): 智慧药事系统第二版-岗位管理接口对接

cmj пре 2 недеља
родитељ
комит
da9002d569

+ 10 - 0
apps/smart-pharmacy/src/api/method/dict.ts

@@ -83,3 +83,13 @@ export function listExpressStateDictMethod() {
 export function listDecoctionTypeDictMethod() {
   return listDictByCodeMethod('decoction_type');
 }
+
+/** 岗位类型字典(post_type) */
+export function listPostTypeDictMethod() {
+  return listDictByCodeMethod('post_type');
+}
+
+/** 证书名称字典(certificate_name) */
+export function listCertificateNameDictMethod() {
+  return listDictByCodeMethod('certificate_name');
+}

+ 219 - 503
apps/smart-pharmacy/src/api/method/personnel-qualification.ts

@@ -1,6 +1,26 @@
-import type { TransformList, TransformRecord } from '#/api';
-
-/** 岗位人员资质(接口就绪后替换为真实请求) */
+import type {
+  TransformBody,
+  TransformData,
+  TransformList,
+  TransformRecord,
+} from '#/api';
+
+import { http } from '#/api';
+import {
+  fromEmployee,
+  parseEmployeeListResponse,
+  syncEmployeeCertDict,
+  syncEmployeePostDict,
+  toEmployeePayload,
+  toEmployeeQuery,
+} from '#/api/model/personnel-qualification';
+import {
+  listDictByCodeMethod,
+  listCertificateNameDictMethod,
+  listPostTypeDictMethod,
+} from '#/api/method/dict';
+
+/** 岗位人员资质 */
 export namespace PersonnelQualificationModel {
   export type QualificationStatus = 'valid' | 'expiring' | 'expired';
 
@@ -66,14 +86,7 @@ export namespace PersonnelQualificationModel {
   }
 }
 
-export const POSITION_OPTIONS = [
-  { label: '接方、审方', value: '接方、审方' },
-  { label: '审方', value: '审方' },
-  { label: '配药', value: '配药' },
-  { label: '复核', value: '复核' },
-  { label: '煎煮', value: '煎煮' },
-  { label: '打包', value: '打包' },
-];
+export let POSITION_OPTIONS: Array<{ label: string; value: string }> = [];
 
 export const CERTIFICATE_NAME_OPTIONS = [
   { label: '健康证', value: '健康证' },
@@ -105,543 +118,246 @@ export const QUALIFICATION_STATUS_LABELS: Record<
   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',
-    organizationType: 'enterprise',
-    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',
-    organizationType: 'enterprise',
-    enterpriseId: 'e2',
-    enterpriseName: '杭州中药煎配中心',
-    decoctionCenterId: 'c2',
-    decoctionCenterName: '西湖煎药中心',
-    name: '李明2',
-    positions: ['配药'],
-    employeeNo: '38474',
-    idNumber: '330109199908088846',
-    certificates: buildCertificates([
-      {
-        name: '中药调剂员',
-        number: '944895756806594353',
-        expiryDate: '2027-6-15',
-        status: 'valid',
-      },
-    ]),
-  }),
-  buildPersonnel({
-    id: '9',
-    organizationType: 'medicalInstitution',
-    enterpriseId: 'm1',
-    enterpriseName: '蒋村社区卫生服务中心',
-    decoctionCenterId: 'c3',
-    decoctionCenterName: '蒋村煎药中心',
-    name: '王芳',
-    positions: ['接方、审方'],
-    employeeNo: '48473',
-    idNumber: '330110199001011234',
-    certificates: buildCertificates([
-      {
-        name: '健康证',
-        number: '944895756806594360',
-        expiryDate: '2027-5-20',
-        status: 'valid',
-      },
-      {
-        name: '执业中药师',
-        number: '944895756806594361',
-        expiryDate: '2026-2-10',
-        status: 'expiring',
-      },
-    ]),
-  }),
-  buildPersonnel({
-    id: '10',
-    organizationType: 'medicalInstitution',
-    enterpriseId: 'm2',
-    enterpriseName: '西湖区中医院',
-    decoctionCenterId: 'c4',
-    decoctionCenterName: '西湖区中医院煎药室',
-    name: '赵强',
-    positions: ['配药'],
-    employeeNo: '48474',
-    idNumber: '330111198803022345',
-    certificates: buildCertificates([
-      {
-        name: '中药调剂员',
-        number: '944895756806594362',
-        expiryDate: '2027-8-1',
-        status: 'valid',
-      },
-    ]),
-  }),
+const EMPLOYEE_BASE = '/manager/tcmp-pc/employee';
+const EMPLOYEE_UPLOAD = '/manager/tcmp-pc/external/employeeInfoUpload';
+const EMPLOYEE_CERT_DICT_CODES = [
+  'certificate_name',
+  'employee_cert',
+  'cert_name',
+  'qualification_name',
 ];
 
-let mockStore = [...MOCK_PERSONNEL];
+let postDictLoaded = false;
+let latestListSummary: PersonnelQualificationModel.ExpirySummary = {
+  expiredCount: 0,
+  expiringCount: 0,
+};
 
-function filterPersonnel(
-  list: PersonnelQualificationModel.Personnel[],
-  query?: PersonnelQualificationModel.ListQuery,
+function syncPostTypeOptions(
+  items: Array<{ dictName: string; dictValue: number | string }>,
 ) {
-  if (!query) return list;
+  if (!items.length) return;
+  syncEmployeePostDict(items);
+  POSITION_OPTIONS.splice(
+    0,
+    POSITION_OPTIONS.length,
+    ...items.map((item) => ({
+      label: item.dictName,
+      value: item.dictName,
+    })),
+  );
+}
 
-  return list.filter((item) => {
-    const organizationId = query.organizationId ?? query.enterpriseId;
-    const organizationType = query.organizationType ?? 'enterprise';
+async function ensurePostDictLoaded() {
+  if (postDictLoaded) return;
 
-    if (organizationId) {
-      const itemType = item.organizationType ?? 'enterprise';
-      if (itemType !== organizationType) {
-        return false;
-      }
-      if (item.enterpriseId !== organizationId) {
-        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;
+  try {
+    const items = await listPostTypeDictMethod();
+    syncPostTypeOptions(items);
+  } catch {
+    // 字典未配置时岗位展示原始编码
+  }
+
+  try {
+    const items = await listCertificateNameDictMethod();
+    if (items.length) {
+      syncEmployeeCertDict(items);
+    } else {
+      for (const dictCode of EMPLOYEE_CERT_DICT_CODES.slice(1)) {
+        try {
+          const fallbackItems = await listDictByCodeMethod(dictCode);
+          if (fallbackItems.length) {
+            syncEmployeeCertDict(fallbackItems);
+            break;
+          }
+        } catch {
+          // 字典未配置时证书名称展示原始编码
+        }
       }
     }
-    return true;
-  });
-}
+  } catch {
+    // 字典未配置时证书名称展示原始编码
+  }
 
-function computeExpirySummary(): PersonnelQualificationModel.ExpirySummary {
-  let expiredCount = 0;
-  let expiringCount = 0;
+  postDictLoaded = true;
+}
 
-  for (const person of mockStore) {
-    if (person.qualificationStatus === 'expired') expiredCount += 1;
-    else if (person.qualificationStatus === 'expiring') expiringCount += 1;
-  }
+/** 岗位类型下拉选项(post_type) */
+export async function optionsPostTypeMethod() {
+  await ensurePostDictLoaded();
+  return [...POSITION_OPTIONS];
+}
 
-  return { expiredCount, expiringCount };
+function toSelectOptions(
+  items: Array<{ label: string; value: string }>,
+): Array<{ label: string; value: string }> {
+  return items.filter((item) => item.label && item.value);
 }
 
-/** 岗位人员资质列表(当前为本地 mock,后期对接后端接口) */
-export function listPersonnelQualificationsMethod(
+/** 岗位人员资质列表 */
+export async 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 },
+) {
+  await ensurePostDictLoaded();
+
+  return http.get<
+    TransformList<PersonnelQualificationModel.Personnel> & {
+      summary: PersonnelQualificationModel.ExpirySummary;
+    },
+    TransformList
+  >(`${EMPLOYEE_BASE}/list`, {
+    params: toEmployeeQuery(page, size, query),
+    cacheFor: 0,
+    transform(data) {
+      const parsed = parseEmployeeListResponse(data);
+      const summary = {
+        expiredCount: parsed.expiredCount,
+        expiringCount: parsed.expiringCount,
+      };
+      latestListSummary = summary;
+      return {
+        total: parsed.total,
+        items: parsed.items.map((item) => fromEmployee(item)),
+        summary,
+      };
+    },
   });
 }
 
-/** 资质到期统计(当前为本地 mock) */
+/** 资质到期统计(由列表接口同包返回,读取最近一次列表请求的统计) */
 export function getPersonnelQualificationSummaryMethod(): Promise<PersonnelQualificationModel.ExpirySummary> {
-  return Promise.resolve(computeExpirySummary());
+  return Promise.resolve(latestListSummary);
+}
+
+/** 岗位人员资质详情 */
+export async function getPersonnelQualificationMethod(id: string) {
+  await ensurePostDictLoaded();
+
+  return http.get<PersonnelQualificationModel.Personnel, TransformData>(
+    `${EMPLOYEE_BASE}/detailById`,
+    {
+      params: { id },
+      cacheFor: 0,
+      transform(data) {
+        return fromEmployee(data);
+      },
+    },
+  );
 }
 
-/** 岗位人员资质详情(当前为本地 mock) */
-export function getPersonnelQualificationMethod(id: string) {
-  const item = mockStore.find((row) => row.id === id);
-  if (!item) {
-    return Promise.reject(new Error('人员记录不存在'));
+function parseEmployeeUploadResult(data: unknown, message?: string): string {
+  if (typeof data === 'string' && data.trim()) {
+    const value = data.trim();
+    if (/^https?:\/\//i.test(value) || value.startsWith('/')) return value;
+  }
+
+  if (data && typeof data === 'object') {
+    const record = data as TransformData;
+    const url =
+      record.url ?? record.fileUrl ?? record.filePath ?? record.path ?? '';
+    if (url) return String(url);
   }
-  return Promise.resolve({ ...item });
+
+  if (message?.startsWith('http')) return message;
+
+  throw new Error(message || '上传失败');
 }
 
-/** 新增/修改岗位人员资质(当前为本地 mock) */
-export function editPersonnelQualificationMethod(
+/** 岗位人员资质附件上传 */
+export function uploadEmployeeQualificationAttachmentMethod(file: File) {
+  const data = new FormData();
+  data.append('file', file);
+  return http.post<string, TransformBody<unknown>>(EMPLOYEE_UPLOAD, data, {
+    meta: { notParseResponseBody: true },
+    transform(body: TransformBody<unknown>) {
+      if (body.code === 0) {
+        return parseEmployeeUploadResult(body.data, body.message);
+      }
+      // eslint-disable-next-line no-throw-literal
+      throw { message: body.message ?? '上传失败' };
+    },
+  });
+}
+
+/** 新增/修改岗位人员资质 */
+export async 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,
+  await ensurePostDictLoaded();
+
+  const payload = toEmployeePayload(data);
+  const url = data.id
+    ? `${EMPLOYEE_BASE}/updateInfo`
+    : `${EMPLOYEE_BASE}/saveInfo`;
+
+  const result = await http.post<TransformData, TransformData>(url, payload, {
+    cacheFor: 0,
   });
 
-  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];
+  const savedId =
+    data.id ??
+    (result?.id === undefined || result?.id === null
+      ? undefined
+      : String(result.id));
+
+  if (savedId) {
+    return getPersonnelQualificationMethod(savedId);
   }
 
-  return Promise.resolve(payload);
+  return fromEmployee(result ?? 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);
+/** 删除岗位人员资质(接口文档未提供,保留占位) */
+export function deletePersonnelQualificationMethod(_id: string) {
+  return Promise.reject(new Error('后端暂未提供删除接口'));
 }
 
-/** 煎药企业选项(mock) */
-export function optionsPersonnelEnterpriseMethod() {
-  const map = new Map<string, string>();
-  for (const item of mockStore) {
-    if ((item.organizationType ?? 'enterprise') !== 'enterprise') continue;
-    if (item.enterpriseId) {
-      map.set(item.enterpriseId, item.enterpriseName);
-    }
-  }
-  return Promise.resolve(
-    [...map.entries()].map(([value, label]) => ({ label, value })),
+/** 煎药企业选项 */
+export async function optionsPersonnelEnterpriseMethod() {
+  const { listDecoctionCentersAllMethod } = await import('#/api/method/system');
+  const items = await listDecoctionCentersAllMethod();
+  return toSelectOptions(
+    items.map((item) => ({
+      label: item.name,
+      value: item.code || item.id,
+    })),
   );
 }
 
-/** 医疗机构选项(mock) */
-export function optionsPersonnelMedicalInstitutionMethod() {
-  const map = new Map<string, string>();
-  for (const item of mockStore) {
-    if (item.organizationType !== 'medicalInstitution') continue;
-    if (item.enterpriseId) {
-      map.set(item.enterpriseId, item.enterpriseName);
-    }
-  }
-  return Promise.resolve(
-    [...map.entries()].map(([value, label]) => ({ label, value })),
+/** 医疗机构选项 */
+export async function optionsPersonnelMedicalInstitutionMethod() {
+  const { listMedicalInstitutionsMethod } = await import('#/api/method/system');
+  const items = await listMedicalInstitutionsMethod();
+  return toSelectOptions(
+    items.map((item) => ({
+      label: item.name,
+      value: item.code || item.id,
+    })),
   );
 }
 
-/** 煎药中心选项(mock) */
-export function optionsPersonnelDecoctionCenterMethod(
+/** 煎药中心选项 */
+export async function optionsPersonnelDecoctionCenterMethod(
   organizationId?: string,
   organizationType: PersonnelQualificationModel.OrganizationType = 'enterprise',
 ) {
-  const map = new Map<string, string>();
-  for (const item of mockStore) {
-    const itemType = item.organizationType ?? 'enterprise';
-    if (itemType !== organizationType) continue;
-    if (organizationId && item.enterpriseId !== organizationId) continue;
-    if (item.decoctionCenterId) {
-      map.set(item.decoctionCenterId, item.decoctionCenterName);
+  const { listMedicineCentersMethod } = await import('#/api/method/system');
+  const query: TransformData = {};
+  if (organizationId) {
+    if (organizationType === 'medicalInstitution') {
+      query.institutionId = organizationId;
+    } else {
+      query.enterpriseId = organizationId;
     }
   }
-  return Promise.resolve(
-    [...map.entries()].map(([value, label]) => ({ label, value })),
+
+  const result = await listMedicineCentersMethod(1, 500, query);
+  return toSelectOptions(
+    (result.items ?? []).map((item) => ({
+      label: item.name,
+      value: item.code || item.id,
+    })),
   );
 }

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

@@ -5,6 +5,7 @@ export * from './dict';
 export * from './doctor';
 export * from './enterprise';
 export * from './evaluation';
+export * from './personnel-qualification';
 export * from './organization';
 export * from './prescription';
 export * from './prescription-detail';

+ 433 - 0
apps/smart-pharmacy/src/api/model/personnel-qualification.ts

@@ -0,0 +1,433 @@
+import type { TransformData } from '#/api';
+
+import type { PersonnelQualificationModel } from '#/api/method/personnel-qualification';
+
+import { fromRow } from '#/api/model';
+
+const QUALIFICATION_STATUS_TO_FRONT: Record<
+  string,
+  PersonnelQualificationModel.QualificationStatus
+> = {
+  '1': 'valid',
+  '2': 'expired',
+  '3': 'expiring',
+  valid: 'valid',
+  expired: 'expired',
+  expiring: 'expiring',
+};
+
+const QUALIFICATION_STATUS_TO_BACK: Record<
+  PersonnelQualificationModel.QualificationStatus,
+  string
+> = {
+  valid: '1',
+  expired: '2',
+  expiring: '3',
+};
+
+const postLabelByCode = new Map<string, string>();
+const postCodeByLabel = new Map<string, string>();
+const certLabelByCode = new Map<string, string>();
+
+export function syncEmployeePostDict(
+  items: Array<{ dictName: string; dictValue: number | string }>,
+) {
+  postLabelByCode.clear();
+  postCodeByLabel.clear();
+  for (const item of items) {
+    const code = String(item.dictValue);
+    const label = item.dictName;
+    if (!code || !label) continue;
+    postLabelByCode.set(code, label);
+    postCodeByLabel.set(label, code);
+  }
+}
+
+export function syncEmployeeCertDict(
+  items: Array<{ dictName: string; dictValue: number | string }>,
+) {
+  certLabelByCode.clear();
+  for (const item of items) {
+    const code = String(item.dictValue);
+    const label = item.dictName;
+    if (!code || !label) continue;
+    certLabelByCode.set(code, label);
+  }
+}
+
+function resolveQualificationStatus(
+  value?: unknown,
+): PersonnelQualificationModel.QualificationStatus {
+  if (value === undefined || value === null || value === '') return 'valid';
+  return QUALIFICATION_STATUS_TO_FRONT[String(value).trim()] ?? 'valid';
+}
+
+function resolveOrganizationType(
+  data?: TransformData,
+): PersonnelQualificationModel.OrganizationType {
+  if (data?.hospitalCode || data?.hospitalName) {
+    return 'medicalInstitution';
+  }
+  return 'enterprise';
+}
+
+function resolvePostLabels(post?: unknown): string[] {
+  if (post === undefined || post === null || post === '') return [];
+  return String(post)
+    .split('|')
+    .map((item) => item.trim())
+    .filter(Boolean)
+    .map((code) => postLabelByCode.get(code) ?? code);
+}
+
+function resolveCertificateNameLabels(raw?: unknown): string {
+  if (raw === undefined || raw === null || raw === '') return '';
+  return String(raw)
+    .split(/[,|、]/)
+    .map((item) => item.trim())
+    .filter(Boolean)
+    .map((code) => resolveCertNameLabel(code))
+    .join('、');
+}
+
+function resolveCertNameLabel(raw?: unknown): string {
+  if (raw === undefined || raw === null || raw === '') return '';
+  const code = String(raw).trim();
+  return certLabelByCode.get(code) ?? code;
+}
+
+function resolveEnterpriseName(data?: TransformData): string {
+  const medCenterName = String(data?.medCenterName ?? '').trim();
+  const hospitalName = String(data?.hospitalName ?? '').trim();
+  if (medCenterName && hospitalName) {
+    return `${medCenterName} / ${hospitalName}`;
+  }
+  return medCenterName || hospitalName || '-';
+}
+
+function resolvePostCodes(positions?: string[]): string | undefined {
+  if (!positions?.length) return undefined;
+  const codes = positions
+    .map((label) => postCodeByLabel.get(label) ?? label)
+    .filter(Boolean);
+  return codes.length ? codes.join('|') : undefined;
+}
+
+function parseAttachmentList(
+  raw?: unknown,
+): PersonnelQualificationModel.CertificateAttachment[] {
+  if (!raw) return [];
+
+  if (typeof raw === 'string') {
+    return raw
+      .split(',')
+      .map((item) => item.trim())
+      .filter(Boolean)
+      .map((url, index) => ({
+        id: `file-${index}`,
+        name: url.split('/').pop() ?? `附件${index + 1}`,
+        url,
+        type: url.toLowerCase().endsWith('.pdf') ? 'pdf' : 'image',
+      }));
+  }
+
+  if (!Array.isArray(raw)) return [];
+
+  return raw
+    .map((item, index) => {
+      if (typeof item === 'string') {
+        return {
+          id: `file-${index}`,
+          name: item.split('/').pop() ?? `附件${index + 1}`,
+          url: item,
+          type: item.toLowerCase().endsWith('.pdf') ? 'pdf' : 'image',
+        } satisfies PersonnelQualificationModel.CertificateAttachment;
+      }
+      if (!item || typeof item !== 'object') return null;
+
+      const record = item as TransformData;
+      const url = String(
+        record.url ??
+          record.fileUrl ??
+          record.filePath ??
+          record.path ??
+          record.attachmentUrl ??
+          '',
+      );
+      if (!url) return null;
+
+      const name = String(
+        record.name ??
+          record.fileName ??
+          record.attachmentName ??
+          url.split('/').pop() ??
+          `附件${index + 1}`,
+      );
+      const typeValue = String(record.type ?? record.fileType ?? '').toLowerCase();
+
+      return {
+        id: String(record.id ?? `file-${index}`),
+        name,
+        url,
+        type:
+          typeValue === 'pdf' || name.toLowerCase().endsWith('.pdf')
+            ? 'pdf'
+            : 'image',
+      } satisfies PersonnelQualificationModel.CertificateAttachment;
+    })
+    .filter(Boolean) as PersonnelQualificationModel.CertificateAttachment[];
+}
+
+function fromEmployeeQualification(
+  data?: TransformData,
+  index = 0,
+): PersonnelQualificationModel.Certificate {
+  const id =
+    data?.id === undefined || data?.id === null
+      ? `cert-${index + 1}`
+      : String(data.id);
+  const name = resolveCertNameLabel(
+    data?.qualificationName ??
+      data?.certName ??
+      data?.certificateName ??
+      data?.name ??
+      '',
+  );
+  const longTerm =
+    data?.isLongTerm === 1 ||
+    data?.isLongTerm === '1' ||
+    data?.longTerm === true ||
+    data?.longTerm === 1 ||
+    data?.longTerm === '1';
+  const expiryDate = longTerm
+    ? ''
+    : String(
+        data?.endDate ??
+          data?.expiryDate ??
+          data?.validEndDate ??
+          data?.expireDate ??
+          '',
+      );
+
+  return {
+    id,
+    name,
+    type: String(
+      data?.qualificationType ?? data?.qualType ?? data?.type ?? '',
+    ),
+    number: String(
+      data?.certificateNumber ??
+        data?.qualificationNo ??
+        data?.certNo ??
+        data?.certificateNo ??
+        data?.number ??
+        '',
+    ),
+    expiryDate,
+    longTerm,
+    status: resolveQualificationStatus(
+      data?.qualificationStatus ?? data?.status,
+    ),
+    attachments: parseAttachmentList(
+      data?.attachments ??
+        data?.attachmentList ??
+        data?.files ??
+        data?.fileList ??
+        data?.fileUrl ??
+        data?.fileUrls,
+    ),
+  };
+}
+
+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';
+}
+
+export function fromEmployee(
+  data?: TransformData,
+): PersonnelQualificationModel.Personnel {
+  const id = data?.id === undefined || data?.id === null ? '' : String(data.id);
+  const organizationType = resolveOrganizationType(data);
+  const certificates = (
+    Array.isArray(data?.qualifications) ? data.qualifications : []
+  ).map((item, index) => fromEmployeeQualification(item as TransformData, index));
+  const certificateNames = certificates.some((item) => item.name)
+    ? certificates
+        .map((item) => item.name)
+        .filter(Boolean)
+        .join('、')
+    : resolveCertificateNameLabels(data?.certificateName);
+  const qualificationStatus =
+    certificates.length > 0
+      ? resolveWorstStatus(certificates)
+      : resolveQualificationStatus(data?.qualificationStatus);
+
+  return {
+    ...fromRow({ ...data, id }),
+    id,
+    organizationType,
+    enterpriseId:
+      organizationType === 'medicalInstitution'
+        ? String(data?.hospitalCode ?? '')
+        : String(data?.medCenterId ?? data?.enterpriseId ?? ''),
+    enterpriseName: resolveEnterpriseName(data),
+    decoctionCenterId: String(
+      data?.decoctionCenterCode ?? data?.decoctionCenterId ?? '',
+    ),
+    decoctionCenterName: String(data?.decoctionCenterName ?? ''),
+    name: String(data?.name ?? ''),
+    positions: resolvePostLabels(data?.post),
+    employeeNo: String(data?.operatorCode ?? data?.employeeNo ?? ''),
+    idNumber: String(data?.cardNo ?? data?.idNumber ?? ''),
+    certificateNames,
+    qualificationStatus,
+    certificates,
+  };
+}
+
+function toEmployeeQualification(
+  cert: PersonnelQualificationModel.Certificate,
+): TransformData {
+  const attachments = cert.attachments ?? [];
+  const fileUrl = attachments
+    .map((item) => item.url)
+    .filter(Boolean)
+    .join(',');
+
+  return {
+    id: cert.id || void 0,
+    qualificationName: cert.name,
+    qualificationType: cert.type || void 0,
+    qualificationNo: cert.number,
+    expiryDate: cert.longTerm ? void 0 : cert.expiryDate || void 0,
+    isLongTerm: cert.longTerm ? 1 : 0,
+    qualificationStatus: QUALIFICATION_STATUS_TO_BACK[cert.status ?? 'valid'],
+    fileUrl: fileUrl || void 0,
+    attachments: attachments.map((item) => ({
+      fileName: item.name,
+      fileUrl: item.url,
+      type: item.type,
+    })),
+  };
+}
+
+export function toEmployeeQuery(
+  page?: number,
+  size?: number,
+  query?: PersonnelQualificationModel.ListQuery,
+): TransformData {
+  const dto: TransformData = {};
+
+  if (page !== undefined) dto.pageNum = page;
+  if (size !== undefined) dto.pageSize = size;
+
+  const organizationType = query?.organizationType ?? 'enterprise';
+  const organizationId = query?.organizationId ?? query?.enterpriseId;
+  if (organizationId) {
+    if (organizationType === 'medicalInstitution') {
+      dto.hospitalCode = organizationId;
+    } else {
+      dto.medCenterId = organizationId;
+    }
+  }
+
+  if (query?.decoctionCenterId) {
+    dto.decoctionCenterCode = query.decoctionCenterId;
+  }
+
+  if (query?.position) {
+    dto.post = postCodeByLabel.get(query.position) ?? query.position;
+  }
+
+  if (query?.qualificationStatus) {
+    dto.qualificationStatus =
+      QUALIFICATION_STATUS_TO_BACK[query.qualificationStatus];
+  }
+
+  const keyword = query?.keyword?.trim();
+  if (keyword) {
+    if (/^\d+$/.test(keyword)) {
+      dto.operatorCode = keyword;
+    } else {
+      dto.name = keyword;
+    }
+  }
+
+  return dto;
+}
+
+export function toEmployeePayload(
+  data: PersonnelQualificationModel.PersonnelForm,
+): TransformData {
+  const payload: TransformData = {
+    id: data.id || void 0,
+    decoctionCenterCode: data.decoctionCenterId,
+    decoctionCenterName: data.decoctionCenterName,
+    name: data.name,
+    operatorCode: data.employeeNo,
+    cardNo: data.idNumber,
+    post: resolvePostCodes(data.positions),
+    qualifications: (data.certificates ?? []).map(toEmployeeQualification),
+  };
+
+  if (data.decoctionCenterId) {
+    payload.decoctionCenterCode = data.decoctionCenterId;
+  }
+
+  return payload;
+}
+
+/** 解析 /employee/list 响应:data.t.rows + expiredNum / isExpired */
+export function parseEmployeeListResponse(data: unknown): {
+  expiredCount: number;
+  expiringCount: number;
+  items: TransformData[];
+  total: number;
+} {
+  if (Array.isArray(data)) {
+    return {
+      expiredCount: 0,
+      expiringCount: 0,
+      items: data as TransformData[],
+      total: data.length,
+    };
+  }
+
+  if (!data || typeof data !== 'object') {
+    return { expiredCount: 0, expiringCount: 0, items: [], total: 0 };
+  }
+
+  const record = data as TransformData;
+  const pageData =
+    record.t && typeof record.t === 'object'
+      ? (record.t as TransformData)
+      : record;
+
+  const list =
+    pageData.rows ??
+    pageData.items ??
+    pageData.records ??
+    pageData.list ??
+    record.rows ??
+    record.items;
+
+  const items = Array.isArray(list) ? list : [];
+  const total = Number(
+    pageData.total ?? record.total ?? record.totalCount ?? items.length,
+  );
+  const expiredCount = Number(record.expiredNum ?? 0);
+  const expiringCount = Number(
+    record.isExpired ?? record.expiringNum ?? record.expiringCount ?? 0,
+  );
+
+  return {
+    expiredCount: Number.isFinite(expiredCount) ? expiredCount : 0,
+    expiringCount: Number.isFinite(expiringCount) ? expiringCount : 0,
+    items,
+    total: Number.isFinite(total) ? total : items.length,
+  };
+}

+ 6 - 14
apps/smart-pharmacy/src/views/system/personnel-qualification-query/list.vue

@@ -5,15 +5,12 @@ import type {
 } from '#/adapter/vxe-table';
 import type { PersonnelQualificationModel } from '#/api';
 
-import { onMounted, ref } from 'vue';
+import { ref } from 'vue';
 
 import { Page, useVbenModal } from '@vben/common-ui';
 
 import { useVbenVxeGrid } from '#/adapter/vxe-table';
-import {
-  getPersonnelQualificationSummaryMethod,
-  listPersonnelQualificationsMethod,
-} from '#/api';
+import { listPersonnelQualificationsMethod } from '#/api';
 
 import {
   usePersonnelQualificationSearchFormSchema,
@@ -48,12 +45,14 @@ const [Grid] = useVbenVxeGrid({
     },
     proxyConfig: {
       ajax: {
-        query({ page }, formValues) {
-          return listPersonnelQualificationsMethod(
+        async query({ page }, formValues) {
+          const result = await listPersonnelQualificationsMethod(
             page.currentPage,
             page.pageSize,
             formValues,
           );
+          summary.value = result.summary;
+          return result;
         },
       },
     },
@@ -64,10 +63,6 @@ const [Grid] = useVbenVxeGrid({
   } as VxeTableGridOptions<PersonnelQualificationModel.Personnel>,
 });
 
-async function loadSummary() {
-  summary.value = await getPersonnelQualificationSummaryMethod();
-}
-
 function onActionClick(
   e: OnActionClickParams<PersonnelQualificationModel.Personnel>,
 ) {
@@ -80,9 +75,6 @@ function onCertificateHandle(row: PersonnelQualificationModel.Personnel) {
   certificateModalApi.setData(row).open();
 }
 
-onMounted(() => {
-  loadSummary();
-});
 </script>
 <template>
   <Page auto-content-height class="personnel-qualification-page">

+ 8 - 4
apps/smart-pharmacy/src/views/system/personnel-qualification/data.ts

@@ -9,7 +9,7 @@ import { h } from 'vue';
 import {
   CERTIFICATE_NAME_OPTIONS,
   optionsPersonnelDecoctionCenterMethod,
-  POSITION_OPTIONS,
+  optionsPostTypeMethod,
   QUALIFICATION_STATUS_LABELS,
   QUALIFICATION_STATUS_OPTIONS,
 } from '#/api/method/personnel-qualification';
@@ -79,11 +79,15 @@ export function usePersonnelQualificationSearchFormSchema(): VbenFormSchema[] {
       label: '煎药中心',
     },
     {
-      component: 'Select',
+      component: 'ApiSelect',
       componentProps: {
         allowClear: true,
-        options: POSITION_OPTIONS,
+        api: optionsPostTypeMethod,
+        class: 'w-full',
+        labelField: 'label',
         placeholder: '请选择',
+        showSearch: true,
+        valueField: 'value',
       },
       fieldName: 'position',
       label: '岗位',
@@ -222,4 +226,4 @@ export function createEmptyCertificate(): PersonnelQualificationModel.Certificat
   };
 }
 
-export { CERTIFICATE_NAME_OPTIONS, POSITION_OPTIONS };
+export { CERTIFICATE_NAME_OPTIONS, optionsPostTypeMethod };

+ 5 - 13
apps/smart-pharmacy/src/views/system/personnel-qualification/list.vue

@@ -5,7 +5,7 @@ import type {
 } from '#/adapter/vxe-table';
 import type { PersonnelQualificationModel } from '#/api';
 
-import { onMounted, ref } from 'vue';
+import { ref } from 'vue';
 
 import { Page, useVbenDrawer, useVbenModal } from '@vben/common-ui';
 import { Plus } from '@vben/icons';
@@ -15,7 +15,6 @@ import { Button, message, Modal } from 'ant-design-vue';
 import { useVbenVxeGrid } from '#/adapter/vxe-table';
 import {
   deletePersonnelQualificationMethod,
-  getPersonnelQualificationSummaryMethod,
   listPersonnelQualificationsMethod,
 } from '#/api';
 
@@ -58,12 +57,14 @@ const [Grid, gridApi] = useVbenVxeGrid({
     },
     proxyConfig: {
       ajax: {
-        query({ page }, formValues) {
-          return listPersonnelQualificationsMethod(
+        async query({ page }, formValues) {
+          const result = await listPersonnelQualificationsMethod(
             page.currentPage,
             page.pageSize,
             formValues,
           );
+          summary.value = result.summary;
+          return result;
         },
       },
     },
@@ -74,10 +75,6 @@ const [Grid, gridApi] = useVbenVxeGrid({
   } as VxeTableGridOptions<PersonnelQualificationModel.Personnel>,
 });
 
-async function loadSummary() {
-  summary.value = await getPersonnelQualificationSummaryMethod();
-}
-
 function onActionClick(
   e: OnActionClickParams<PersonnelQualificationModel.Personnel>,
 ) {
@@ -114,7 +111,6 @@ function confirm(content: string, title: string) {
 
 function onRefresh() {
   gridApi.query();
-  loadSummary();
 }
 
 function onAddHandle() {
@@ -141,10 +137,6 @@ async function onDeleteHandle(row: PersonnelQualificationModel.Personnel) {
     }
   }
 }
-
-onMounted(() => {
-  loadSummary();
-});
 </script>
 <template>
   <Page auto-content-height class="personnel-qualification-page">

+ 85 - 14
apps/smart-pharmacy/src/views/system/personnel-qualification/modules/form.vue

@@ -1,5 +1,5 @@
 <script lang="ts" setup>
-import type { UploadFile, UploadProps } from 'ant-design-vue';
+import type { UploadChangeParam, UploadFile } from 'ant-design-vue';
 
 import type { PersonnelQualificationModel } from '#/api';
 
@@ -23,21 +23,26 @@ import {
   editPersonnelQualificationMethod,
   getPersonnelQualificationMethod,
   optionsPersonnelDecoctionCenterMethod,
+  uploadEmployeeQualificationAttachmentMethod,
 } from '#/api';
 
 import {
   CERTIFICATE_NAME_OPTIONS,
   createEmptyCertificate,
-  POSITION_OPTIONS,
+  optionsPostTypeMethod,
 } from '../data';
 
 const emit = defineEmits(['success']);
 
 const loading = ref(false);
+const uploadingCount = ref(0);
+const uploadingUids = new Set<string>();
+const certificateUploadFileLists = ref<UploadFile[][]>([[]]);
 const formData = ref<PersonnelQualificationModel.Personnel>();
 const decoctionCenterOptions = ref<Array<{ label: string; value: string }>>(
   [],
 );
+const positionOptions = ref<Array<{ label: string; value: string }>>([]);
 
 const formModel = ref({
   certificates: [createEmptyCertificate()] as PersonnelQualificationModel.Certificate[],
@@ -56,6 +61,10 @@ async function loadDecoctionCenterOptions() {
   decoctionCenterOptions.value = await optionsPersonnelDecoctionCenterMethod();
 }
 
+async function loadPositionOptions() {
+  positionOptions.value = await optionsPostTypeMethod();
+}
+
 function resetForm() {
   formModel.value = {
     certificates: [createEmptyCertificate()],
@@ -65,10 +74,13 @@ function resetForm() {
     name: '',
     positions: [],
   };
+  certificateUploadFileLists.value = [[]];
+  uploadingUids.clear();
 }
 
 function addCertificate() {
   formModel.value.certificates.push(createEmptyCertificate());
+  certificateUploadFileLists.value.push([]);
 }
 
 function removeCertificate(index: number) {
@@ -77,9 +89,10 @@ function removeCertificate(index: number) {
     return;
   }
   formModel.value.certificates.splice(index, 1);
+  certificateUploadFileLists.value.splice(index, 1);
 }
 
-function getUploadFileList(
+function toUploadFileList(
   cert: PersonnelQualificationModel.Certificate,
 ): UploadFile[] {
   return (cert.attachments ?? []).map((item) => ({
@@ -90,23 +103,71 @@ function getUploadFileList(
   }));
 }
 
-function createUploadChangeHandler(
+function resolveUploadRawFile(file: UploadFile): File | undefined {
+  if (file.originFileObj instanceof File) return file.originFileObj;
+  if (file instanceof File) return file;
+  return undefined;
+}
+
+function syncCertificateAttachments(
   cert: PersonnelQualificationModel.Certificate,
-): UploadProps['onChange'] {
-  return ({ fileList }) => {
-    cert.attachments = fileList.slice(0, 9).map((file, index) => ({
+  fileList: UploadFile[],
+) {
+  cert.attachments = fileList
+    .filter((file) => file.status === 'done' && file.url)
+    .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 || '',
+      url: file.url || '',
     }));
-  };
+}
+
+async function handleCertificateUploadChange(
+  index: number,
+  cert: PersonnelQualificationModel.Certificate,
+  { file, fileList }: UploadChangeParam,
+) {
+  certificateUploadFileLists.value[index] = [...fileList];
+
+  const rawFile = resolveUploadRawFile(file);
+  const shouldUpload =
+    rawFile &&
+    !file.url &&
+    file.status !== 'error' &&
+    !uploadingUids.has(file.uid);
+
+  if (shouldUpload) {
+    uploadingUids.add(file.uid);
+    uploadingCount.value += 1;
+    file.status = 'uploading';
+    try {
+      const url = await uploadEmployeeQualificationAttachmentMethod(rawFile);
+      file.status = 'done';
+      file.url = url;
+      certificateUploadFileLists.value[index] = [...fileList];
+    } catch (error: any) {
+      file.status = 'error';
+      message.error(error.message || `${file.name} 上传失败`);
+    } finally {
+      uploadingUids.delete(file.uid);
+      uploadingCount.value -= 1;
+    }
+  }
+
+  syncCertificateAttachments(cert, certificateUploadFileLists.value[index] ?? []);
 }
 
 function validateForm() {
   const { certificates, decoctionCenterId, employeeNo, idNumber, name, positions } =
     formModel.value;
 
+  if (uploadingCount.value > 0) {
+    message.warning('资质附件正在上传,请稍候');
+    return false;
+  }
+
   if (!decoctionCenterId) {
     message.warning('请选择所属煎药中心');
     return false;
@@ -137,6 +198,10 @@ function validateForm() {
       message.warning(`请上传第${index + 1}份资质附件`);
       return false;
     }
+    if (cert.attachments.some((item) => !item.url)) {
+      message.warning(`第${index + 1}份资质附件尚未上传完成`);
+      return false;
+    }
     if (!cert.number.trim()) {
       message.warning(`请输入第${index + 1}份资质编号`);
       return false;
@@ -184,7 +249,7 @@ const [Drawer, drawerApi] = useVbenDrawer({
       return;
     }
 
-    await loadDecoctionCenterOptions();
+    await Promise.all([loadDecoctionCenterOptions(), loadPositionOptions()]);
     const data = drawerApi.getData<PersonnelQualificationModel.Personnel>();
 
     if (data?.id) {
@@ -200,6 +265,9 @@ const [Drawer, drawerApi] = useVbenDrawer({
           name: detail.name,
           positions: [...detail.positions],
         };
+        certificateUploadFileLists.value = detail.certificates.map((item) =>
+          toUploadFileList(item),
+        );
       } finally {
         loading.value = false;
       }
@@ -234,7 +302,7 @@ const [Drawer, drawerApi] = useVbenDrawer({
         <label>岗位</label>
         <Select
           v-model:value="formModel.positions"
-          :options="POSITION_OPTIONS"
+          :options="positionOptions"
           allow-clear
           class="w-full"
           mode="multiple"
@@ -285,14 +353,17 @@ const [Drawer, drawerApi] = useVbenDrawer({
         <div class="form-item required">
           <label>资质附件</label>
           <Upload
+            v-model:file-list="certificateUploadFileLists[index]"
             :before-upload="() => false"
-            :file-list="getUploadFileList(cert)"
             :max-count="9"
             accept=".jpg,.jpeg,.png,.pdf"
             list-type="picture-card"
-            @change="createUploadChangeHandler(cert)"
+            @change="handleCertificateUploadChange(index, cert, $event)"
           >
-            <div v-if="(cert.attachments?.length ?? 0) < 9" class="upload-trigger">
+            <div
+              v-if="(certificateUploadFileLists[index]?.length ?? 0) < 9"
+              class="upload-trigger"
+            >
               <Plus class="size-5" />
             </div>
           </Upload>

+ 2 - 0
packages/request/package.json

@@ -19,9 +19,11 @@
     "@vben/stores": "workspace:*",
     "@vben/utils": "workspace:*",
     "alova": "catalog:",
+    "json-bigint": "catalog:",
     "qs": "catalog:"
   },
   "devDependencies": {
+    "@types/json-bigint": "catalog:",
     "@types/qs": "catalog:",
     "axios-mock-adapter": "catalog:"
   }

+ 6 - 1
packages/request/src/index.ts

@@ -4,9 +4,13 @@ import { useAppConfig } from '@vben/hooks';
 import { useAccessStore } from '@vben/stores';
 
 import { createServerTokenAuthentication } from 'alova/client';
+import JsonBigint from 'json-bigint';
 
 import { createAlovaClient } from './alova';
 
+/** 超出 JS 安全整数范围的 id 等字段在 JSON 解析时保留为字符串,避免精度丢失 */
+const parseJsonBody = JsonBigint({ storeAsString: true }).parse;
+
 const { requestBaseURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
 
 const BINARY_DOWNLOAD_CONTENT_TYPES = new Set([
@@ -68,7 +72,8 @@ export default function createRequestClient(options?: SimpleAlovaOptions) {
       /* prettier-ignore */
       const [contentType] = response.headers.get('content-type')?.split(';') ?? [];
       if (contentType === 'application/json') {
-        const body = transform(await response.json(), method);
+        const text = await response.text();
+        const body = transform(text ? parseJsonBody(text) : {}, method);
         if (method.meta?.notParseResponseBody) return body;
         if (body.code === 0) return body.data;
         throw body;

+ 15 - 0
pnpm-lock.yaml

@@ -75,6 +75,9 @@ catalogs:
     '@types/html-minifier-terser':
       specifier: ^7.0.2
       version: 7.0.2
+    '@types/json-bigint':
+      specifier: ^1.0.4
+      version: 1.0.4
     '@types/lodash.clonedeep':
       specifier: ^4.5.9
       version: 4.5.9
@@ -1699,10 +1702,16 @@ importers:
       alova:
         specifier: 'catalog:'
         version: 3.3.4
+      json-bigint:
+        specifier: 'catalog:'
+        version: 1.0.0
       qs:
         specifier: 'catalog:'
         version: 6.14.0
     devDependencies:
+      '@types/json-bigint':
+        specifier: 'catalog:'
+        version: 1.0.4
       '@types/qs':
         specifier: 'catalog:'
         version: 6.14.0
@@ -4334,6 +4343,9 @@ packages:
   '@types/html-minifier-terser@7.0.2':
     resolution: {integrity: sha512-mm2HqV22l8lFQh4r2oSsOEVea+m0qqxEmwpc9kC1p/XzmjLWrReR9D/GRs8Pex2NX/imyEH9c5IU/7tMBQCHOA==}
 
+  '@types/json-bigint@1.0.4':
+    resolution: {integrity: sha512-ydHooXLbOmxBbubnA7Eh+RpBzuaIiQjh8WGJYQB50JFGFrdxW7JzVlyEV7fAXw0T2sqJ1ysTneJbiyNLqZRAag==}
+
   '@types/json-schema@7.0.15':
     resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
 
@@ -10619,6 +10631,7 @@ packages:
   whatwg-encoding@3.1.1:
     resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
     engines: {node: '>=18'}
+    deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
 
   whatwg-mimetype@3.0.0:
     resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
@@ -13764,6 +13777,8 @@ snapshots:
 
   '@types/html-minifier-terser@7.0.2': {}
 
+  '@types/json-bigint@1.0.4': {}
+
   '@types/json-schema@7.0.15': {}
 
   '@types/lodash.clonedeep@4.5.9':