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

feat(@six/smart-pharmacy): 智慧药事系统第二版-处方点评接口对接

cmj преди 1 ден
родител
ревизия
3425ce7982

+ 170 - 96
apps/smart-pharmacy/src/api/method/prescription-review.ts

@@ -1,6 +1,19 @@
 import type { TransformData, TransformList, TransformRecord } from '#/api';
 
 import { http } from '#/api';
+import {
+  fromReviewDetail,
+  fromReviewPrescriptionDetail,
+  type ReviewDetailSaveParams,
+  toReviewDetailPageQuery,
+  toReviewDetailSavePayload,
+} from '#/api/model/review-detail';
+import { fromPrescriptionReviewAssistantDetail } from '#/api/model/review-assistant';
+import {
+  fromReviewRecord,
+  toReviewRecordExpertPayload,
+  toReviewRecordPageQuery,
+} from '#/api/model/review-record';
 import {
   listUsersMethod,
   updateUserStatusMethod,
@@ -93,11 +106,16 @@ export namespace PrescriptionReviewModel {
   /** 点评任务中的处方记录 */
   export interface ReviewPrescriptionRecord extends TransformRecord {
     taskId: string;
+    /** 抽样记录 id */
+    recordId?: string;
+    /** 处方 id */
+    prescriptionId?: string;
     prescriptionNo: string;
     institutionName: string;
     departmentName: string;
     doctorName: string;
-    status: 'pending' | 'qualified' | 'unqualified';
+    /** 点评状态:未点评 / 已点评 */
+    status: 'unreviewed' | 'reviewed';
     reviewExpert?: string;
     /** 点评详情 */
     reviewResult?: ReviewPrescriptionResult;
@@ -118,6 +136,15 @@ export namespace PrescriptionReviewModel {
     totalAmount?: number;
     unitDoseAmount?: number;
     herbs?: string[];
+    /** 超剂量药品列表(不合格指标关联中药下拉选项) */
+    medicineList?: ReviewExcessDosageMedicine[];
+  }
+
+  /** 超剂量药品 */
+  export interface ReviewExcessDosageMedicine {
+    id?: string;
+    medicineId: string;
+    medicineName: string;
   }
 
   export interface ReviewPrescriptionResult {
@@ -258,6 +285,19 @@ export namespace PrescriptionReviewModel {
   }
 }
 
+/** 点评专家下拉选项:value 为 userId,label 为 nickName */
+export function toReviewExpertSelectOptions(
+  experts: PrescriptionReviewModel.ReviewExpert[],
+) {
+  return experts.map((item) => {
+    const value = String(item.pid ?? item.id);
+    return {
+      label: item.name || item.access || value,
+      value,
+    };
+  });
+}
+
 /** 点评专家列表(userType=01) */
 export function listReviewExpertsMethod(
   page = 1,
@@ -322,6 +362,9 @@ export function updateReviewCostLimitMethod(dictValue: string) {
 
 const COMMENT_CATEGORY_BASE = '/manager/tcmp-pc/commentCategory';
 const COMMENT_ITEMS_BASE = '/manager/tcmp-pc/commentItems';
+const REVIEW_RECORD_BASE = '/manager/tcmp-pc/reviewRecord';
+const REVIEW_DETAIL_BASE = '/manager/tcmp-pc/reviewDetail';
+const REVIEW_ASSISTANT_BASE = '/manager/tcmp-pc/prescriptionReviewAssistant';
 
 function toCommentCategoryRecordId(id?: unknown): string {
   if (id === undefined || id === null) return '';
@@ -850,6 +893,41 @@ export function sortReviewIndicatorsMethod(
   return Promise.resolve(sorted);
 }
 
+/** 点评页 - 获取全部点评指标(接口返回什么展示什么) */
+export async function listReviewIndicatorsForReviewMethod() {
+  const result = await listReviewIndicatorsMethod(1, 9999);
+  return result.items ?? [];
+}
+
+/** 按分类名称分组点评指标(保持接口返回顺序) */
+export function groupReviewIndicatorsByCategory(
+  indicators: PrescriptionReviewModel.ReviewIndicator[],
+) {
+  const groups: Array<{
+    categoryId: string;
+    categoryName: string;
+    indicators: PrescriptionReviewModel.ReviewIndicator[];
+  }> = [];
+  const groupIndex = new Map<string, number>();
+
+  for (const indicator of indicators) {
+    const key = indicator.categoryId || indicator.categoryName;
+    let index = groupIndex.get(key);
+    if (index === undefined) {
+      index = groups.length;
+      groupIndex.set(key, index);
+      groups.push({
+        categoryId: indicator.categoryId,
+        categoryName: indicator.categoryName,
+        indicators: [],
+      });
+    }
+    groups[index]!.indicators.push(indicator);
+  }
+
+  return groups;
+}
+
 /** 点评指标分页列表 */
 export function listReviewIndicatorsMethod(
   page = 1,
@@ -874,10 +952,15 @@ export function listReviewIndicatorsMethod(
   >(`${COMMENT_ITEMS_BASE}/pageList`, {
     params,
     cacheFor: 0,
-    transform({ items, ...data }) {
-      const rows = items ?? [];
+    transform(data) {
+      const rows = normalizeCommentList(data);
+      const record = (data ?? {}) as TransformData;
+      const totalValue = record.total;
       return {
-        ...data,
+        total:
+          totalValue === undefined || totalValue === null
+            ? rows.length
+            : Number(totalValue),
         items: rows.map((item) => fromCommentIndicator(item)),
       };
     },
@@ -1211,7 +1294,7 @@ const MOCK_REVIEW_PRESCRIPTIONS: PrescriptionReviewModel.ReviewPrescriptionRecor
       institutionName: '蒋村社区卫生服务中心',
       departmentName: '中医科',
       doctorName: '张医生',
-      status: 'unqualified',
+      status: 'reviewed',
       reviewExpert: '专家1',
       patientName: '唐熙',
       patientGender: '女',
@@ -1270,7 +1353,7 @@ const MOCK_REVIEW_PRESCRIPTIONS: PrescriptionReviewModel.ReviewPrescriptionRecor
       institutionName: '同仁堂',
       departmentName: '内科',
       doctorName: '李医生',
-      status: 'pending',
+      status: 'unreviewed',
       patientName: '王明',
       patientGender: '男',
       patientAge: 55,
@@ -1287,7 +1370,7 @@ const MOCK_REVIEW_PRESCRIPTIONS: PrescriptionReviewModel.ReviewPrescriptionRecor
       institutionName: '浙江省中医院',
       departmentName: '针灸科',
       doctorName: '王医生',
-      status: 'qualified',
+      status: 'reviewed',
       reviewExpert: '专家2',
       patientName: '赵丽',
       patientGender: '女',
@@ -1335,19 +1418,29 @@ export function getReviewTaskStatusLabel(
   return REVIEW_TASK_STATUS_LABEL[status] ?? status;
 }
 
-/** 处方点评任务列表(当前为本地 mock) */
+/** 处方点评记录分页列表 */
 export function listReviewTasksMethod(
   page = 1,
   size = 10,
   query?: Parameters<typeof filterReviewTasks>[1],
 ): Promise<TransformList<PrescriptionReviewModel.ReviewTask>> {
-  const filtered = filterReviewTasks(MOCK_REVIEW_TASKS, 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 },
+  return http.get<
+    TransformList<PrescriptionReviewModel.ReviewTask>,
+    TransformList
+  >(`${REVIEW_RECORD_BASE}/pageList`, {
+    params: {
+      pageNum: page,
+      pageSize: size,
+      ...toReviewRecordPageQuery(query),
+    },
+    cacheFor: 0,
+    transform({ items, ...data }) {
+      const rows = items ?? [];
+      return {
+        ...data,
+        items: rows.map((item) => fromReviewRecord(item)),
+      };
+    },
   });
 }
 
@@ -1404,15 +1497,16 @@ export function deleteReviewTaskMethod(taskId: string) {
   return Promise.resolve(true);
 }
 
-/** 更新点评专家组(当前为本地 mock) */
+/** 更新点评专家组 */
 export function updateReviewTaskExpertsMethod(
   taskId: string,
   expertIds: string[],
 ) {
-  const task = MOCK_REVIEW_TASKS.find((item) => item.id === taskId);
-  if (!task) return Promise.reject(new Error('点评任务不存在'));
-  task.expertIds = expertIds;
-  return Promise.resolve(task);
+  return http.post<void, unknown>(
+    `${REVIEW_RECORD_BASE}/update`,
+    toReviewRecordExpertPayload(taskId, expertIds),
+    { cacheFor: 0 },
+  );
 }
 
 /** 启动智能辅助点评(当前为本地 mock) */
@@ -1426,7 +1520,7 @@ export function startIntelligentReviewMethod(taskId: string) {
   return Promise.resolve(true);
 }
 
-/** 点评任务处方列表(当前为本地 mock) */
+/** 处方点评明细分页列表(点评详情页左侧列表) */
 export function listReviewPrescriptionsMethod(
   taskId: string,
   page = 1,
@@ -1435,92 +1529,72 @@ export function listReviewPrescriptionsMethod(
     departmentName?: string;
     doctorName?: string;
     institutionName?: string;
-    status?: string;
+    status?: PrescriptionReviewModel.ReviewPrescriptionRecord['status'];
   },
-) {
-  let items = MOCK_REVIEW_PRESCRIPTIONS.filter(
-    (item) => item.taskId === taskId,
-  );
-  if (query?.status) {
-    items = items.filter((item) => item.status === query.status);
-  }
-  if (query?.institutionName) {
-    items = items.filter(
-      (item) => item.institutionName === query.institutionName,
-    );
-  }
-  if (query?.departmentName) {
-    items = items.filter(
-      (item) => item.departmentName === query.departmentName,
-    );
-  }
-  if (query?.doctorName) {
-    items = items.filter((item) => item.doctorName === query.doctorName);
-  }
-  const start = (page - 1) * size;
-  return Promise.resolve({
-    items: items.slice(start, start + size),
-    total: items.length,
-    data: { page, size, total: items.length },
+): Promise<TransformList<PrescriptionReviewModel.ReviewPrescriptionRecord>> {
+  return http.get<
+    TransformList<PrescriptionReviewModel.ReviewPrescriptionRecord>,
+    TransformList
+  >(`${REVIEW_DETAIL_BASE}/pageList`, {
+    params: {
+      pageNum: page,
+      pageSize: size,
+      id: taskId,
+      ...toReviewDetailPageQuery(query),
+    },
+    cacheFor: 0,
+    transform({ items, ...data }) {
+      const rows = items ?? [];
+      return {
+        ...data,
+        items: rows.map((item) => fromReviewDetail(item, taskId)),
+      };
+    },
   });
 }
 
-/** 获取处方点评详情(当前为本地 mock) */
-export function getReviewPrescriptionMethod(prescriptionId: string) {
-  const record = MOCK_REVIEW_PRESCRIPTIONS.find(
-    (item) => item.id === prescriptionId,
-  );
-  if (!record) return Promise.reject(new Error('处方不存在'));
-  return Promise.resolve(record);
+/** 获取处方点评详情(右侧面板上部:处方信息 + 明细) */
+export function getReviewPrescriptionMethod(
+  reviewDetailId: string,
+  taskId?: string,
+) {
+  return http.get<
+    PrescriptionReviewModel.ReviewPrescriptionRecord,
+    TransformData
+  >(`${REVIEW_DETAIL_BASE}/detail`, {
+    params: { id: reviewDetailId },
+    cacheFor: 0,
+    transform(data) {
+      return fromReviewPrescriptionDetail(data, { reviewDetailId, taskId });
+    },
+  });
 }
 
-/** 获取处方辅助点评结果(当前为本地 mock) */
+/** 获取处方辅助点评结果 */
 export function getAuxiliaryReviewResultMethod(prescriptionId: string) {
-  const record = MOCK_REVIEW_PRESCRIPTIONS.find(
-    (item) => item.id === prescriptionId,
-  );
-  if (!record) return Promise.reject(new Error('处方不存在'));
-  if (!record.auxiliaryReviewResult) {
-    return Promise.reject(new Error('暂无辅助点评结果'));
+  if (!prescriptionId) {
+    return Promise.reject(new Error('处方不存在'));
   }
-  return Promise.resolve(record.auxiliaryReviewResult);
+  return http.get<
+    PrescriptionReviewModel.ReviewPrescriptionResult,
+    TransformData
+  >(`${REVIEW_ASSISTANT_BASE}/detail`, {
+    params: { prescriptionId },
+    cacheFor: 0,
+    transform(data) {
+      return fromPrescriptionReviewAssistantDetail(data);
+    },
+  });
 }
 
-/** 保存处方点评结果(当前为本地 mock) */
-export function saveReviewPrescriptionResultMethod(
-  prescriptionId: string,
-  result: PrescriptionReviewModel.ReviewPrescriptionResult,
-) {
-  const record = MOCK_REVIEW_PRESCRIPTIONS.find(
-    (item) => item.id === prescriptionId,
+/** 保存处方点评结果 */
+export function saveReviewPrescriptionResultMethod(params: ReviewDetailSaveParams) {
+  const payload = toReviewDetailSavePayload(params);
+  return http.post<void, TransformData>(
+    `${REVIEW_DETAIL_BASE}/saveInfo`,
+    payload,
+    { cacheFor: 0 },
   );
-  if (!record) return Promise.reject(new Error('处方不存在'));
-  const task = MOCK_REVIEW_TASKS.find((item) => item.id === record.taskId);
-  if (task?.status === 'archived') {
-    return Promise.reject(new Error('已归档任务不可修改'));
-  }
-  const wasReviewed = record.status !== 'pending';
-  record.reviewResult = result;
-  record.status = result.qualified ? 'qualified' : 'unqualified';
-  if (task && !wasReviewed) {
-    task.reviewedCount += 1;
-    if (task.reviewedCount >= task.sampleCount) {
-      task.status = 'completed';
-    } else if (task.status === 'pending') {
-      task.status = 'in_progress';
-    }
-    const taskRecords = MOCK_REVIEW_PRESCRIPTIONS.filter(
-      (item) => item.taskId === task.id && item.status !== 'pending',
-    );
-    const qualified = taskRecords.filter(
-      (item) => item.status === 'qualified',
-    ).length;
-    task.passRate =
-      taskRecords.length > 0
-        ? Math.round((qualified / taskRecords.length) * 100)
-        : 100;
-  }
-  return Promise.resolve(record);
 }
 
 const MOCK_STATISTICS_PRESCRIPTION_DETAILS: PrescriptionReviewModel.ReviewStatisticsPrescriptionDetail[] =

+ 70 - 0
apps/smart-pharmacy/src/api/model/review-assistant.ts

@@ -0,0 +1,70 @@
+import type { PrescriptionReviewModel } from '#/api/method/prescription-review';
+
+import type { TransformData } from '#/api';
+
+function pickValue(data: TransformData | undefined, ...keys: string[]): unknown {
+  if (!data) return undefined;
+  for (const key of keys) {
+    const value = data[key];
+    if (value !== undefined && value !== null && value !== '') {
+      return value;
+    }
+  }
+  return undefined;
+}
+
+function pickList(data: TransformData | undefined, ...keys: string[]): TransformData[] {
+  if (!data) return [];
+  for (const key of keys) {
+    const value = data[key];
+    if (Array.isArray(value)) return value as TransformData[];
+  }
+  return [];
+}
+
+/** 辅助点评详情 → 点评表单结果 */
+export function fromPrescriptionReviewAssistantDetail(
+  data?: TransformData,
+): PrescriptionReviewModel.ReviewPrescriptionResult {
+  const root = (data ?? {}) as TransformData;
+  const assistant = (root.reviewAssistant ?? {}) as TransformData;
+  const assistantId = pickValue(assistant, 'id');
+
+  if (assistantId === undefined || assistantId === null || assistantId === '') {
+    throw new Error('暂无辅助点评结果');
+  }
+
+  const reviewResult = assistant.reviewResult;
+  const qualified = reviewResult !== '2' && reviewResult !== 2;
+
+  let indicatorIds = String(assistant.optionId ?? '')
+    .split(',')
+    .map((item) => item.trim())
+    .filter(Boolean);
+
+  if (indicatorIds.length === 0) {
+    indicatorIds = pickList(root, 'unqualifiedList')
+      .map((item) => String(pickValue(item, 'unqualifiedId') ?? ''))
+      .filter(Boolean);
+  }
+
+  const herbIndicatorMap: Record<string, string[]> = {};
+  const savedMedicineNames = pickList(root, 'medicineList')
+    .map((item) => String(pickValue(item, 'medicineName') ?? ''))
+    .filter(Boolean);
+
+  if (!qualified && indicatorIds.length > 0 && savedMedicineNames.length > 0) {
+    for (const indicatorId of indicatorIds) {
+      herbIndicatorMap[indicatorId] = [...savedMedicineNames];
+    }
+  }
+
+  const remark = pickValue(assistant, 'remark');
+  return {
+    qualified,
+    indicatorIds,
+    herbIndicatorMap: qualified ? {} : herbIndicatorMap,
+    comment:
+      remark === undefined || remark === null ? '' : String(remark),
+  };
+}

+ 448 - 0
apps/smart-pharmacy/src/api/model/review-detail.ts

@@ -0,0 +1,448 @@
+import type { PrescriptionReviewModel } from '#/api/method/prescription-review';
+
+import type { TransformData } from '#/api';
+
+import { fromRow } from '#/api/model';
+
+const ZY_ROUTE_LABEL: Record<string, string> = {
+  '1': '内服',
+  '2': '外用',
+};
+
+function pickValue(data: TransformData | undefined, ...keys: string[]): unknown {
+  if (!data) return undefined;
+  for (const key of keys) {
+    const value = data[key];
+    if (value !== undefined && value !== null && value !== '') {
+      return value;
+    }
+  }
+  return undefined;
+}
+
+function pickList(data: TransformData | undefined, ...keys: string[]): TransformData[] {
+  if (!data) return [];
+  for (const key of keys) {
+    const value = data[key];
+    if (Array.isArray(value)) return value as TransformData[];
+  }
+  return [];
+}
+
+function toNumber(value: unknown, fallback = 0): number {
+  const num = Number(value);
+  return Number.isFinite(num) ? num : fallback;
+}
+
+function resolveGender(value: unknown): string {
+  if (value === 1 || value === '1') return '男';
+  if (value === 2 || value === '2') return '女';
+  if (value === 0 || value === '0') return '未知';
+  if (value === undefined || value === null || value === '') return '';
+  return String(value);
+}
+
+function resolveYesNoFlag(value: unknown): boolean {
+  return value === 1 || value === '1';
+}
+
+function resolveZyRoute(value: unknown): string {
+  const key = String(value ?? '');
+  if (!key) return '';
+  return ZY_ROUTE_LABEL[key] ?? key;
+}
+
+function formatHerbDisplay(item: TransformData): string {
+  const name = pickValue(
+    item,
+    'standardName',
+    'yljgMedicineName',
+    'medicineName',
+    'name',
+  );
+  const dose = pickValue(item, 'matDose', 'dosage', 'dose');
+  const unit = pickValue(item, 'unit');
+  const nameText = name === undefined || name === null ? '' : String(name);
+  if (dose === undefined || dose === null || dose === '') {
+    return nameText;
+  }
+  const unitText = unit ? String(unit) : 'g';
+  return `${nameText} ${dose}${unitText}`.trim();
+}
+
+function fromReviewMedicineDetailItem(
+  item: TransformData,
+): PrescriptionReviewModel.ReviewExcessDosageMedicine | null {
+  const medicineName = pickValue(
+    item,
+    'standardName',
+    'yljgMedicineName',
+    'medicineName',
+    'name',
+  );
+  const medicineId = pickValue(
+    item,
+    'standardCode',
+    'yljgMedicineCode',
+    'medicineId',
+    'id',
+  );
+  if (
+    (medicineName === undefined ||
+      medicineName === null ||
+      medicineName === '') &&
+    (medicineId === undefined || medicineId === null || medicineId === '')
+  ) {
+    return null;
+  }
+  return {
+    id:
+      item.id === undefined || item.id === null
+        ? undefined
+        : String(item.id),
+    medicineId:
+      medicineId === undefined || medicineId === null
+        ? ''
+        : String(medicineId),
+    medicineName:
+      medicineName === undefined || medicineName === null
+        ? String(medicineId)
+        : String(medicineName),
+  };
+}
+
+function resolveUnitDoseAmount(info: TransformData): number {
+  const quantity = toNumber(pickValue(info, 'quantity'));
+  const totalAmount = toNumber(
+    pickValue(info, 'preMonry', 'prescriptionSum'),
+  );
+  if (quantity <= 0) return totalAmount;
+  return Number((totalAmount / quantity).toFixed(2));
+}
+
+const REVIEW_DETAIL_STATUS_FROM_API: Record<
+  string,
+  PrescriptionReviewModel.ReviewPrescriptionRecord['status']
+> = {
+  '0': 'unreviewed',
+  '1': 'reviewed',
+  未点评: 'unreviewed',
+  已点评: 'reviewed',
+};
+
+const REVIEW_DETAIL_STATUS_TO_API: Record<
+  PrescriptionReviewModel.ReviewPrescriptionRecord['status'],
+  string
+> = {
+  unreviewed: '0',
+  reviewed: '1',
+};
+
+function resolveReviewDetailStatus(
+  reviewStatus: unknown,
+): PrescriptionReviewModel.ReviewPrescriptionRecord['status'] {
+  if (reviewStatus === null || reviewStatus === undefined || reviewStatus === '') {
+    return 'unreviewed';
+  }
+  return REVIEW_DETAIL_STATUS_FROM_API[String(reviewStatus)] ?? 'unreviewed';
+}
+
+function toOptionalId(value: unknown): string | undefined {
+  if (value === undefined || value === null || value === '') return undefined;
+  return String(value);
+}
+
+function formatDateTime(date = new Date()): string {
+  const pad = (n: number) => String(n).padStart(2, '0');
+  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
+}
+
+function parseReviewResultForForm(
+  review: TransformData,
+  root: TransformData,
+): PrescriptionReviewModel.ReviewPrescriptionResult | undefined {
+  const reviewResult = review.reviewResult;
+  const reviewStatus = review.reviewStatus;
+  const isReviewed =
+    reviewStatus === '1' ||
+    reviewStatus === 1 ||
+    reviewResult === '1' ||
+    reviewResult === 1 ||
+    reviewResult === '2' ||
+    reviewResult === 2;
+
+  if (!isReviewed) {
+    return undefined;
+  }
+
+  const qualified = reviewResult !== '2' && reviewResult !== 2;
+  let indicatorIds = String(review.optionId ?? '')
+    .split(',')
+    .map((item) => item.trim())
+    .filter(Boolean);
+
+  if (indicatorIds.length === 0) {
+    indicatorIds = pickList(root, 'unqualifiedList')
+      .map((item) => String(pickValue(item, 'unqualifiedId') ?? ''))
+      .filter(Boolean);
+  }
+
+  const herbIndicatorMap: Record<string, string[]> = {};
+  const savedMedicineNames = pickList(root, 'medicineList')
+    .filter((item) => {
+      const detailId = pickValue(item, 'detailId');
+      return detailId !== undefined && detailId !== null && detailId !== '';
+    })
+    .map((item) => String(pickValue(item, 'medicineName') ?? ''))
+    .filter(Boolean);
+
+  if (!qualified && indicatorIds.length > 0 && savedMedicineNames.length > 0) {
+    for (const indicatorId of indicatorIds) {
+      herbIndicatorMap[indicatorId] = [...savedMedicineNames];
+    }
+  }
+
+  const remark = pickValue(review, 'remark');
+  return {
+    qualified,
+    indicatorIds: qualified ? [] : indicatorIds,
+    herbIndicatorMap: qualified ? {} : herbIndicatorMap,
+    comment:
+      remark === undefined || remark === null ? '' : String(remark),
+  };
+}
+
+/** 处方点评明细分页列表查询参数 */
+export function toReviewDetailPageQuery(query?: {
+  departmentName?: string;
+  doctorName?: string;
+  institutionName?: string;
+  status?: PrescriptionReviewModel.ReviewPrescriptionRecord['status'];
+}): TransformData {
+  const params: TransformData = {};
+  if (query?.institutionName) {
+    params.hospitalName = query.institutionName;
+  }
+  if (query?.departmentName) {
+    params.department = query.departmentName;
+  }
+  if (query?.doctorName) {
+    params.doctorName = query.doctorName;
+  }
+  if (query?.status) {
+    params.reviewStatus =
+      REVIEW_DETAIL_STATUS_TO_API[query.status] ?? String(query.status);
+  }
+  return params;
+}
+
+export interface ReviewDetailSaveParams {
+  reviewDetailId: string;
+  recordId: string;
+  prescriptionId?: string;
+  result: PrescriptionReviewModel.ReviewPrescriptionResult;
+  indicators?: PrescriptionReviewModel.ReviewIndicator[];
+  excessMedicineOptions?: PrescriptionReviewModel.ReviewExcessDosageMedicine[];
+  reviewUserId?: string | number;
+  reviewUserName?: string;
+}
+
+/** 处方点评保存请求体 */
+export function toReviewDetailSavePayload(
+  params: ReviewDetailSaveParams,
+): TransformData {
+  const {
+    reviewDetailId,
+    recordId,
+    prescriptionId,
+    result,
+    indicators = [],
+    excessMedicineOptions = [],
+    reviewUserId,
+    reviewUserName,
+  } = params;
+  const indicatorMap = new Map(indicators.map((item) => [item.id, item]));
+  const indicatorIds = result.qualified ? [] : (result.indicatorIds ?? []);
+  const indicatorNames = indicatorIds.map(
+    (id) => indicatorMap.get(id)?.name ?? '',
+  );
+  const medicineIdByName = new Map(
+    excessMedicineOptions.map((item) => [item.medicineName, item.medicineId]),
+  );
+  const medicineList: TransformData[] = [];
+  const herbIndicatorMap = result.qualified
+    ? {}
+    : (result.herbIndicatorMap ?? {});
+  const addedMedicineNames = new Set<string>();
+
+  for (const names of Object.values(herbIndicatorMap)) {
+    for (const medicineName of names) {
+      if (!medicineName || addedMedicineNames.has(medicineName)) continue;
+      addedMedicineNames.add(medicineName);
+      medicineList.push({
+        detailId: reviewDetailId,
+        prescriptionId,
+        medicineName,
+        medicineId: medicineIdByName.get(medicineName) ?? medicineName,
+      });
+    }
+  }
+
+  const unqualifiedList = indicatorIds.map((unqualifiedId) => ({
+    detailId: reviewDetailId,
+    prescriptionId,
+    unqualifiedId,
+    unqualifiedName: indicatorMap.get(unqualifiedId)?.name ?? '',
+  }));
+
+  return {
+    id: reviewDetailId,
+    recordId,
+    prescriptionId,
+    reviewUserId,
+    reviewUserName,
+    reviewResult: result.qualified ? '1' : '2',
+    reviewStatus: '1',
+    optionId: indicatorIds.join(','),
+    optionName: indicatorNames.filter(Boolean).join(','),
+    remark: result.comment ?? '',
+    reviewDate: formatDateTime(),
+    medicineList,
+    unqualifiedList,
+  };
+}
+
+export function fromReviewDetail(
+  data?: TransformData,
+  taskId = '',
+): PrescriptionReviewModel.ReviewPrescriptionRecord {
+  const id =
+    data?.id === undefined || data?.id === null ? '' : String(data.id);
+
+  return {
+    ...fromRow({
+      ...data,
+      id,
+    }),
+    id,
+    taskId,
+    recordId: toOptionalId(data?.recordId) ?? toOptionalId(taskId),
+    prescriptionId: toOptionalId(data?.prescriptionId),
+    prescriptionNo: String(data?.recipeCode ?? ''),
+    institutionName: String(data?.hospitalName ?? ''),
+    departmentName: String(data?.department ?? ''),
+    doctorName: String(data?.doctorName ?? ''),
+    status: resolveReviewDetailStatus(data?.reviewStatus),
+    reviewExpert: data?.reviewUserName
+      ? String(data.reviewUserName)
+      : undefined,
+  };
+}
+
+/** 处方点评详情(右侧面板上部:处方信息 + 明细) */
+export function fromReviewPrescriptionDetail(
+  data?: TransformData,
+  options?: { reviewDetailId?: string; taskId?: string },
+): PrescriptionReviewModel.ReviewPrescriptionRecord {
+  const root = (data ?? {}) as TransformData;
+  const info = (root.prescriptionInfo ?? {}) as TransformData;
+  const review = (root.reviewDetail ?? {}) as TransformData;
+  const id =
+    options?.reviewDetailId ??
+    (review.id === undefined || review.id === null
+      ? ''
+      : String(review.id));
+  const taskId = options?.taskId ?? '';
+  const recordId =
+    toOptionalId(review.recordId) ?? toOptionalId(taskId);
+  const prescriptionId =
+    toOptionalId(review.prescriptionId) ?? toOptionalId(info.id);
+  const ageValue = pickValue(info, 'age');
+  const patientAge =
+    ageValue === undefined || ageValue === null || ageValue === ''
+      ? undefined
+      : toNumber(ageValue, NaN);
+  const medicineDetailItems = pickList(root, 'medicineDetail', 'medicineDetails');
+  const herbs = medicineDetailItems.map(formatHerbDisplay).filter(Boolean);
+  const savedMedicineList = pickList(root, 'medicineList')
+    .map((item) => {
+      const medicineName = pickValue(item, 'medicineName');
+      const medicineId = pickValue(item, 'medicineId');
+      if (
+        (medicineName === undefined ||
+          medicineName === null ||
+          medicineName === '') &&
+        (medicineId === undefined || medicineId === null || medicineId === '')
+      ) {
+        return null;
+      }
+      return {
+        id:
+          item.id === undefined || item.id === null
+            ? undefined
+            : String(item.id),
+        medicineId:
+          medicineId === undefined || medicineId === null
+            ? ''
+            : String(medicineId),
+        medicineName:
+          medicineName === undefined || medicineName === null
+            ? String(medicineId)
+            : String(medicineName),
+      };
+    })
+    .filter(Boolean) as PrescriptionReviewModel.ReviewExcessDosageMedicine[];
+  const medicineList =
+    savedMedicineList.length > 0
+      ? savedMedicineList
+      : (medicineDetailItems
+          .map(fromReviewMedicineDetailItem)
+          .filter(Boolean) as PrescriptionReviewModel.ReviewExcessDosageMedicine[]);
+
+  return {
+    ...fromRow({
+      ...review,
+      id,
+    }),
+    id,
+    taskId,
+    recordId,
+    prescriptionId,
+    prescriptionNo: String(pickValue(info, 'recipeCode') ?? ''),
+    institutionName: String(pickValue(info, 'hospitalName') ?? ''),
+    departmentName: String(pickValue(info, 'department') ?? ''),
+    doctorName: String(pickValue(info, 'doctorName') ?? ''),
+    status: resolveReviewDetailStatus(review.reviewStatus),
+    reviewExpert: review.reviewUserName
+      ? String(review.reviewUserName)
+      : undefined,
+    patientName: pickValue(info, 'recipientName')
+      ? String(pickValue(info, 'recipientName'))
+      : undefined,
+    patientGender: resolveGender(pickValue(info, 'sex')),
+    patientAge: Number.isFinite(patientAge) ? patientAge : undefined,
+    pregnancy: resolveYesNoFlag(pickValue(info, 'pregnancy')),
+    lactation: resolveYesNoFlag(pickValue(info, 'lactation')),
+    tcmDisease: pickValue(info, 'disName')
+      ? String(pickValue(info, 'disName'))
+      : undefined,
+    tcmSyndrome: pickValue(info, 'symName')
+      ? String(pickValue(info, 'symName'))
+      : undefined,
+    treatmentPrinciple: pickValue(info, 'therapeuticName')
+      ? String(pickValue(info, 'therapeuticName'))
+      : undefined,
+    administrationMethod:
+      resolveZyRoute(pickValue(info, 'zyRoute')) ||
+      (pickValue(info, 'prescriptionUsageCode')
+        ? String(pickValue(info, 'prescriptionUsageCode'))
+        : undefined),
+    herbCount: toNumber(pickValue(info, 'medicinalNumber')),
+    doseCount: toNumber(pickValue(info, 'quantity')),
+    totalAmount: toNumber(pickValue(info, 'preMonry', 'prescriptionSum')),
+    unitDoseAmount: resolveUnitDoseAmount(info),
+    herbs: herbs.length > 0 ? herbs : undefined,
+    medicineList: medicineList.length > 0 ? medicineList : undefined,
+    reviewResult: parseReviewResultForForm(review, root),
+  };
+}

+ 146 - 0
apps/smart-pharmacy/src/api/model/review-record.ts

@@ -0,0 +1,146 @@
+import type { TransformData } from '#/api';
+import type { PrescriptionReviewModel } from '#/api/method/prescription-review';
+
+import { fromRow } from '#/api/model';
+
+const REVIEW_STATUS_FROM_API: Record<
+  string,
+  PrescriptionReviewModel.ReviewTaskStatus
+> = {
+  '1': 'pending',
+  '2': 'in_progress',
+  '3': 'completed',
+  '4': 'archived',
+};
+
+const REVIEW_STATUS_TO_API: Record<
+  PrescriptionReviewModel.ReviewTaskStatus,
+  string
+> = {
+  pending: '1',
+  in_progress: '2',
+  completed: '3',
+  archived: '4',
+};
+
+export function fromReviewRecord(
+  data?: TransformData,
+): PrescriptionReviewModel.ReviewTask {
+  const id =
+    data?.id === undefined || data?.id === null ? '' : String(data.id);
+  const reviewStatus = String(data?.reviewStatus ?? '1');
+  const expertId = data?.expertId;
+  const isSamplingFinish = String(data?.isSamplingFinish ?? '');
+
+  return {
+    ...fromRow({
+      ...data,
+      id,
+      createUser: data?.createBy,
+      updateUser: data?.updateBy,
+    }),
+    id,
+    name: String(data?.samplingName ?? ''),
+    sampleTime: String(data?.samplingTime ?? ''),
+    sampleCount: Number(data?.samplingNumber ?? 0),
+    prescriptionDateStart: String(data?.prescriptionStartDate ?? ''),
+    prescriptionDateEnd: String(data?.prescriptionEndDate ?? ''),
+    status: REVIEW_STATUS_FROM_API[reviewStatus] ?? 'pending',
+    reviewedCount: Number(data?.finishNumber ?? 0),
+    archiveTime: data?.fileTime ? String(data.fileTime) : undefined,
+    passRate: Number(data?.passRate ?? 0),
+    expertIds: expertId
+      ? String(expertId)
+          .split(',')
+          .map((item) => item.trim())
+          .filter(Boolean)
+      : [],
+    samplingRule: {
+      name: String(data?.samplingName ?? ''),
+      prescriptionDateStart: data?.prescriptionStartDate
+        ? String(data.prescriptionStartDate)
+        : undefined,
+      prescriptionDateEnd: data?.prescriptionEndDate
+        ? String(data.prescriptionEndDate)
+        : undefined,
+      herbCountMin:
+        data?.medicinalNumberMin === null ||
+        data?.medicinalNumberMin === undefined
+          ? undefined
+          : Number(data.medicinalNumberMin),
+      herbCountMax:
+        data?.medicinalNumberMax === null ||
+        data?.medicinalNumberMax === undefined
+          ? undefined
+          : Number(data.medicinalNumberMax),
+      amountMin:
+        data?.amountMin === null || data?.amountMin === undefined
+          ? undefined
+          : Number(data.amountMin),
+      amountMax:
+        data?.amountMax === null || data?.amountMax === undefined
+          ? undefined
+          : Number(data.amountMax),
+      sampleCount:
+        data?.samplingNumber === null || data?.samplingNumber === undefined
+          ? undefined
+          : Number(data.samplingNumber),
+      sampleRatio:
+        data?.samplingProportion === null ||
+        data?.samplingProportion === undefined
+          ? undefined
+          : Number(data.samplingProportion),
+      excludeReviewed: isSamplingFinish === '1',
+    },
+  };
+}
+
+/** 保存点评专家组:expertId 为 userId 逗号拼接 */
+export function toReviewRecordExpertPayload(
+  taskId: string,
+  expertIds: string[],
+): TransformData {
+  return {
+    id: taskId,
+    expertId: expertIds
+      .map((item) => String(item).trim())
+      .filter(Boolean)
+      .join(','),
+  };
+}
+
+/** 仅保留专家列表中存在的 userId,避免显示无效选中项 */
+export function normalizeReviewExpertIds(
+  expertIds: string[],
+  validExpertIds: Iterable<string>,
+): string[] {
+  const validSet = new Set(
+    [...validExpertIds].map((item) => String(item)),
+  );
+  return expertIds
+    .map((item) => String(item).trim())
+    .filter((item) => item && validSet.has(item));
+}
+
+export function toReviewRecordPageQuery(query?: {
+  name?: string;
+  sampleTimeEnd?: string;
+  sampleTimeStart?: string;
+  status?: PrescriptionReviewModel.ReviewTaskStatus;
+}): TransformData {
+  const params: TransformData = {};
+  if (query?.name?.trim()) {
+    params.samplingName = query.name.trim();
+  }
+  if (query?.status) {
+    params.reviewStatus =
+      REVIEW_STATUS_TO_API[query.status] ?? String(query.status);
+  }
+  if (query?.sampleTimeStart) {
+    params.startDate = query.sampleTimeStart;
+  }
+  if (query?.sampleTimeEnd) {
+    params.endDate = query.sampleTimeEnd;
+  }
+  return params;
+}

+ 3 - 5
apps/smart-pharmacy/src/views/prescription-review/archived/log.vue

@@ -22,6 +22,8 @@ import {
   listReviewStatisticsDetailIndicatorsByCategoryMethod,
 } from '#/api';
 
+import { needsHerbSelect } from '../task/data';
+
 interface CategoryGroup {
   categoryId: string;
   categoryName: string;
@@ -76,10 +78,6 @@ function isIndicatorChecked(indicatorId: string) {
   return selectedIndicatorIds.value.includes(indicatorId);
 }
 
-function needsHerbInput(indicator: PrescriptionReviewModel.ReviewIndicator) {
-  return indicator.categoryId === 'cat-2' || indicator.associatedChineseMedicine;
-}
-
 function getSelectedHerbs(indicatorId: string) {
   return herbIndicatorMap.value[indicatorId]?.join('、') ?? '';
 }
@@ -281,7 +279,7 @@ onMounted(loadDetail);
                 <Input
                   v-if="
                     isIndicatorChecked(indicator.id) &&
-                    needsHerbInput(indicator)
+                    needsHerbSelect(indicator)
                   "
                   :value="getSelectedHerbs(indicator.id)"
                   class="herb-input"

+ 7 - 0
apps/smart-pharmacy/src/views/prescription-review/task/data.ts

@@ -5,6 +5,13 @@ import type { PrescriptionReviewModel } from '#/api/method/prescription-review';
 import { getReviewTaskStatusLabel } from '#/api';
 import { listReviewSamplingInstitutionTreeMethod } from '#/api/method/prescription-review';
 
+/** 仅「剂量超标」指标需关联中药下拉选择 */
+export function needsHerbSelect(
+  indicator: PrescriptionReviewModel.ReviewIndicator,
+) {
+  return indicator.name === '剂量超标';
+}
+
 const REVIEW_STATUS_OPTIONS: {
   label: string;
   value: PrescriptionReviewModel.ReviewTaskStatus;

+ 4 - 1
apps/smart-pharmacy/src/views/prescription-review/task/list.vue

@@ -84,7 +84,10 @@ function onRefresh() {
 }
 
 function onReview(row: PrescriptionReviewModel.ReviewTask) {
-  router.push(`/prescription-review/task/review/${row.id}`);
+  router.push({
+    path: `/prescription-review/task/review/${row.id}`,
+    query: { name: row.name },
+  });
 }
 
 function onStatistics(row: PrescriptionReviewModel.ReviewTask) {

+ 57 - 57
apps/smart-pharmacy/src/views/prescription-review/task/modules/auxiliary-review-modal.vue

@@ -7,6 +7,8 @@ import { useVbenModal } from '@vben/common-ui';
 
 import { Checkbox, Select } from 'ant-design-vue';
 
+import { needsHerbSelect } from '../data';
+
 interface CategoryGroup {
   categoryId: string;
   categoryName: string;
@@ -20,8 +22,6 @@ export interface AuxiliaryReviewModalData {
   onApply?: (result: PrescriptionReviewModel.ReviewPrescriptionResult) => void;
 }
 
-const EDITABLE_CATEGORY_IDS = new Set(['cat-1', 'cat-2', 'cat-3']);
-
 const displayGroups = ref<CategoryGroup[]>([]);
 const localIndicatorIds = ref<string[]>([]);
 const localHerbMap = ref<Record<string, string[]>>({});
@@ -41,40 +41,28 @@ const [Modal, modalApi] = useVbenModal({
     if (!isOpen) return;
     const data = modalApi.getData<AuxiliaryReviewModalData>();
     if (!data) return;
-    const auxiliaryIds = new Set(data.result.indicatorIds ?? []);
-    displayGroups.value = data.indicatorGroups
-      .map((group) => ({
-        ...group,
-        indicators: group.indicators.filter((item) => auxiliaryIds.has(item.id)),
-      }))
-      .filter((group) => group.indicators.length > 0);
+    displayGroups.value = data.indicatorGroups;
     localIndicatorIds.value = [...(data.result.indicatorIds ?? [])];
     localHerbMap.value = { ...(data.result.herbIndicatorMap ?? {}) };
     herbOptions.value = data.herbOptions;
   },
   onConfirm() {
     const data = modalApi.getData<AuxiliaryReviewModalData>();
+    const hasIndicators = localIndicatorIds.value.length > 0;
     data?.onApply?.({
-      qualified: false,
+      qualified: hasIndicators ? false : (data.result.qualified ?? false),
       indicatorIds: [...localIndicatorIds.value],
       herbIndicatorMap: { ...localHerbMap.value },
+      comment: data.result.comment,
     });
     modalApi.close();
   },
 });
 
-function isCategoryEditable(categoryId: string) {
-  return EDITABLE_CATEGORY_IDS.has(categoryId);
-}
-
 function isIndicatorChecked(indicatorId: string) {
   return localIndicatorIds.value.includes(indicatorId);
 }
 
-function needsHerbSelect(indicator: PrescriptionReviewModel.ReviewIndicator) {
-  return indicator.categoryId === 'cat-2' || indicator.associatedChineseMedicine;
-}
-
 function onIndicatorChange(indicatorId: string, checked: boolean) {
   if (checked) {
     if (!localIndicatorIds.value.includes(indicatorId)) {
@@ -96,45 +84,46 @@ function onHerbChange(indicatorId: string, herbs: string[]) {
 <template>
   <Modal title="辅助点评结果">
     <div class="auxiliary-review-modal__body">
-      <div
-        v-for="group in displayGroups"
-        :key="group.categoryId"
-        class="indicator-group"
-      >
-        <div class="group-title">{{ group.categoryName }}</div>
-        <div class="group-items">
+      <div class="indicator-section">
+        <template v-if="displayGroups.length">
           <div
-            v-for="indicator in group.indicators"
-            :key="indicator.id"
-            class="indicator-item"
+            v-for="group in displayGroups"
+            :key="`${group.categoryId}-${group.categoryName}`"
+            class="indicator-group"
           >
-            <Checkbox
-              :checked="isIndicatorChecked(indicator.id)"
-              :disabled="!isCategoryEditable(group.categoryId)"
-              @change="
-                (e) =>
-                  onIndicatorChange(indicator.id, e.target.checked ?? false)
-              "
-            >
-              {{ indicator.name }}
-            </Checkbox>
-            <Select
-              v-if="
-                isIndicatorChecked(indicator.id) && needsHerbSelect(indicator)
-              "
-              :disabled="!isCategoryEditable(group.categoryId)"
-              :options="herbOptions"
-              :value="localHerbMap[indicator.id] ?? []"
-              class="herb-select"
-              mode="multiple"
-              size="small"
-              @change="(value) => onHerbChange(indicator.id, value as string[])"
-            />
+            <div class="group-title">{{ group.categoryName }}</div>
+            <div class="group-items">
+              <div
+                v-for="indicator in group.indicators"
+                :key="indicator.id"
+                class="indicator-item"
+              >
+                <Checkbox
+                  :checked="isIndicatorChecked(indicator.id)"
+                  @change="
+                    (e) =>
+                      onIndicatorChange(indicator.id, e.target.checked ?? false)
+                  "
+                >
+                  {{ indicator.name }}
+                </Checkbox>
+                <Select
+                  v-if="
+                    isIndicatorChecked(indicator.id) && needsHerbSelect(indicator)
+                  "
+                  :options="herbOptions"
+                  :value="localHerbMap[indicator.id] ?? []"
+                  class="herb-select"
+                  mode="multiple"
+                  placeholder="选择中药"
+                  size="small"
+                  @change="(value) => onHerbChange(indicator.id, value as string[])"
+                />
+              </div>
+            </div>
           </div>
-        </div>
-      </div>
-      <div v-if="displayGroups.length === 0" class="empty-tip">
-        暂无辅助点评结果
+        </template>
+        <div v-else class="indicator-empty">暂无点评指标</div>
       </div>
     </div>
   </Modal>
@@ -147,10 +136,20 @@ function onHerbChange(indicatorId: string, herbs: string[]) {
   overflow-y: auto;
 }
 
+.indicator-section {
+  padding: 16px;
+  background: #f5f5f5;
+  border-radius: 4px;
+}
+
 .indicator-group {
   margin-bottom: 16px;
 }
 
+.indicator-group:last-child {
+  margin-bottom: 0;
+}
+
 .group-title {
   margin-bottom: 8px;
   font-weight: 600;
@@ -172,10 +171,11 @@ function onHerbChange(indicatorId: string, herbs: string[]) {
   width: 140px;
 }
 
-.empty-tip {
-  padding: 24px;
-  text-align: center;
+.indicator-empty {
+  padding: 8px 0;
+  font-size: 13px;
   color: rgb(0 0 0 / 45%);
+  text-align: center;
 }
 
 :deep(.auxiliary-review-modal__header) {

+ 10 - 6
apps/smart-pharmacy/src/views/prescription-review/task/modules/expert-group-modal.vue

@@ -9,8 +9,10 @@ import { message, Select } from 'ant-design-vue';
 
 import {
   listReviewExpertsMethod,
+  toReviewExpertSelectOptions,
   updateReviewTaskExpertsMethod,
 } from '#/api';
+import { normalizeReviewExpertIds } from '#/api/model/review-record';
 
 const emit = defineEmits(['success']);
 
@@ -48,12 +50,12 @@ const [Modal, modalApi] = useVbenModal({
     }
     const data = modalApi.getData<PrescriptionReviewModel.ReviewTask>();
     taskId.value = data?.id ?? '';
-    selectedIds.value = [...(data?.expertIds ?? [])];
-    const result = await listReviewExpertsMethod(1, 100);
-    expertOptions.value = result.items.map((item) => ({
-      label: item.name,
-      value: item.id,
-    }));
+    const result = await listReviewExpertsMethod(1, 999);
+    expertOptions.value = toReviewExpertSelectOptions(result.items);
+    selectedIds.value = normalizeReviewExpertIds(
+      data?.expertIds ?? [],
+      expertOptions.value.map((item) => item.value),
+    );
   },
 });
 </script>
@@ -66,7 +68,9 @@ const [Modal, modalApi] = useVbenModal({
         :options="expertOptions"
         class="w-full"
         mode="multiple"
+        option-filter-prop="label"
         placeholder="请选择专家"
+        show-search
       />
     </div>
   </Modal>

+ 265 - 138
apps/smart-pharmacy/src/views/prescription-review/task/review.vue

@@ -5,6 +5,7 @@ import { computed, onMounted, ref, watch } from 'vue';
 import { useRoute, useRouter } from 'vue-router';
 
 import { Page, useVbenModal } from '@vben/common-ui';
+import { useUserStore } from '@vben/stores';
 
 import {
   Button,
@@ -21,19 +22,19 @@ import {
 import {
   getAuxiliaryReviewResultMethod,
   getReviewPrescriptionMethod,
-  getReviewTaskMethod,
-  listReviewIndicatorCategoriesMethod,
-  listReviewIndicatorsByCategoryMethod,
+  groupReviewIndicatorsByCategory,
+  listReviewIndicatorsForReviewMethod,
   listReviewPrescriptionsMethod,
   saveReviewPrescriptionResultMethod,
 } from '#/api';
 
+import { needsHerbSelect } from './data';
 import AuxiliaryReviewModal from './modules/auxiliary-review-modal.vue';
 
 const router = useRouter();
 const route = useRoute();
+const userStore = useUserStore();
 
-const taskLoading = ref(false);
 const listLoading = ref(false);
 const detailLoading = ref(false);
 const saving = ref(false);
@@ -46,7 +47,7 @@ const total = ref(0);
 const currentPage = ref(1);
 const pageSize = ref(50);
 
-const filterStatus = ref<string>();
+const filterStatus = ref<'unreviewed' | 'reviewed'>();
 const filterInstitution = ref<string>();
 const filterDepartment = ref<string>();
 const filterDoctor = ref<string>();
@@ -63,6 +64,8 @@ interface CategoryGroup {
 }
 
 const indicatorGroups = ref<CategoryGroup[]>([]);
+const indicatorsLoaded = ref(false);
+const indicatorsLoading = ref(false);
 
 const [AuxiliaryReviewModalComp, auxiliaryReviewModalApi] = useVbenModal({
   connectedComponent: AuxiliaryReviewModal,
@@ -103,9 +106,8 @@ const listColumns = [
 ];
 
 const statusLabelMap: Record<string, string> = {
-  pending: '待点评',
-  qualified: '合格',
-  unqualified: '不合格',
+  unreviewed: '未点评',
+  reviewed: '已点评',
 };
 
 const tableData = computed(() =>
@@ -118,74 +120,142 @@ const tableData = computed(() =>
 );
 
 function getHerbOptions() {
-  return (currentPrescription.value?.herbs ?? []).map((herb) => {
-    const name = herb.split(' ')[0] ?? herb;
-    return { label: name, value: name };
-  });
-}
-
-async function loadIndicators() {
-  const categories = await listReviewIndicatorCategoriesMethod();
-  const reviewCategories = categories.filter((item) => item.id !== 'cat-6');
-  const groups = await Promise.all(
-    reviewCategories.map(async (category) => ({
-      categoryId: category.id,
-      categoryName: category.name,
-      indicators: await listReviewIndicatorsByCategoryMethod(category.id, {
-        enabledOnly: true,
-        categoryName: category.name,
-      }),
-    })),
-  );
-  indicatorGroups.value = groups.filter((item) => item.indicators.length > 0);
+  const medicineList = currentPrescription.value?.medicineList ?? [];
+  if (medicineList.length > 0) {
+    return medicineList.map((item) => ({
+      label: item.medicineName,
+      value: item.medicineName,
+    }));
+  }
+  return [];
 }
 
-async function loadTask() {
-  if (!taskId.value) return;
-  taskLoading.value = true;
+async function ensureIndicatorsLoaded() {
+  if (indicatorsLoading.value) return;
+  indicatorsLoading.value = true;
   try {
-    const task = await getReviewTaskMethod(taskId.value);
-    taskName.value = task.name;
+    const items = await listReviewIndicatorsForReviewMethod();
+    indicatorGroups.value = groupReviewIndicatorsByCategory(items);
+    indicatorsLoaded.value = true;
+  } catch (error: any) {
+    indicatorsLoaded.value = false;
+    message.error(error.message || '加载点评指标失败');
   } finally {
-    taskLoading.value = false;
+    indicatorsLoading.value = false;
   }
 }
 
+function getListQuery() {
+  return {
+    departmentName: filterDepartment.value,
+    doctorName: filterDoctor.value,
+    institutionName: filterInstitution.value,
+    status: filterStatus.value,
+  };
+}
+
+async function fetchPrescriptionsPage(page: number) {
+  if (!taskId.value) {
+    return { items: [], total: 0 };
+  }
+  return listReviewPrescriptionsMethod(
+    taskId.value,
+    page,
+    pageSize.value,
+    getListQuery(),
+  );
+}
+
 async function loadPrescriptions() {
   if (!taskId.value) return;
   listLoading.value = true;
   try {
-    const result = await listReviewPrescriptionsMethod(
-      taskId.value,
-      currentPage.value,
-      pageSize.value,
-      {
-        departmentName: filterDepartment.value,
-        doctorName: filterDoctor.value,
-        institutionName: filterInstitution.value,
-        status: filterStatus.value,
-      },
-    );
+    const result = await fetchPrescriptionsPage(currentPage.value);
     prescriptions.value = result.items;
     total.value = result.total;
     if (!selectedId.value && result.items.length > 0) {
       const first = result.items[0];
       if (first) selectPrescription(first.id);
     }
+  } catch (error: any) {
+    prescriptions.value = [];
+    total.value = 0;
+    message.error(error.message || '加载处方列表失败');
   } finally {
     listLoading.value = false;
   }
 }
 
+function findNextPendingInList(
+  items: PrescriptionReviewModel.ReviewPrescriptionRecord[],
+  afterIndex: number,
+) {
+  for (let i = afterIndex + 1; i < items.length; i++) {
+    const item = items[i];
+    if (item?.status === 'unreviewed') return item;
+  }
+  for (let i = 0; i <= afterIndex; i++) {
+    const item = items[i];
+    if (item?.status === 'unreviewed') return item;
+  }
+  return undefined;
+}
+
+/** 保存后自动跳转到下一条未点评记录 */
+async function selectNextPendingPrescription(savedId: string) {
+  const savedIndex = prescriptions.value.findIndex((item) => item.id === savedId);
+  const nextInPage = findNextPendingInList(
+    prescriptions.value,
+    savedIndex >= 0 ? savedIndex : prescriptions.value.length - 1,
+  );
+  if (nextInPage) {
+    selectPrescription(nextInPage.id);
+    return;
+  }
+
+  const totalPages = Math.max(1, Math.ceil(total.value / pageSize.value));
+
+  for (let page = currentPage.value + 1; page <= totalPages; page++) {
+    const result = await fetchPrescriptionsPage(page);
+    const pending = result.items.find((item) => item.status === 'unreviewed');
+    if (pending) {
+      currentPage.value = page;
+      prescriptions.value = result.items;
+      selectPrescription(pending.id);
+      return;
+    }
+  }
+
+  for (let page = 1; page < currentPage.value; page++) {
+    const result = await fetchPrescriptionsPage(page);
+    const pending = result.items.find((item) => item.status === 'unreviewed');
+    if (pending) {
+      currentPage.value = page;
+      prescriptions.value = result.items;
+      selectPrescription(pending.id);
+      return;
+    }
+  }
+
+  selectedId.value = '';
+  currentPrescription.value = undefined;
+  message.info('当前任务处方已全部点评完成');
+}
+
 async function loadPrescriptionDetail(id: string) {
   detailLoading.value = true;
   try {
-    const detail = await getReviewPrescriptionMethod(id);
-    currentPrescription.value = detail;
+    const detail = await getReviewPrescriptionMethod(id, taskId.value);
+    currentPrescription.value = {
+      ...currentPrescription.value,
+      ...detail,
+    };
     qualified.value = detail.reviewResult?.qualified ?? true;
     reviewComment.value = detail.reviewResult?.comment ?? '';
     selectedIndicators.value = detail.reviewResult?.indicatorIds ?? [];
     herbSelections.value = { ...(detail.reviewResult?.herbIndicatorMap ?? {}) };
+  } catch (error: any) {
+    message.error(error.message || '加载处方详情失败');
   } finally {
     detailLoading.value = false;
   }
@@ -193,14 +263,24 @@ async function loadPrescriptionDetail(id: string) {
 
 function selectPrescription(id: string) {
   selectedId.value = id;
+  const row = prescriptions.value.find((item) => item.id === id);
+  if (row) {
+    currentPrescription.value = { ...row };
+    qualified.value = true;
+    reviewComment.value = '';
+    selectedIndicators.value = [];
+    herbSelections.value = {};
+  }
   loadPrescriptionDetail(id);
 }
 
-function onQualifiedChange() {
-  if (qualified.value) {
-    selectedIndicators.value = [];
-    herbSelections.value = {};
+async function onQualifiedChange() {
+  if (!qualified.value) {
+    await ensureIndicatorsLoaded();
+    return;
   }
+  selectedIndicators.value = [];
+  herbSelections.value = {};
 }
 
 function onIndicatorChange(indicatorId: string, checked: boolean) {
@@ -220,28 +300,37 @@ function isIndicatorChecked(indicatorId: string) {
   return selectedIndicators.value.includes(indicatorId);
 }
 
-function needsHerbSelect(indicator: PrescriptionReviewModel.ReviewIndicator) {
-  return indicator.categoryId === 'cat-2' || indicator.associatedChineseMedicine;
-}
-
 function getRowClassName(record: { id: string }) {
   return record.id === selectedId.value ? 'selected-row' : '';
 }
 
 async function openAuxiliaryReviewModal() {
-  if (!selectedId.value) return;
+  const prescriptionId = currentPrescription.value?.prescriptionId;
+  if (!selectedId.value || !prescriptionId) {
+    message.warning('请先选择处方');
+    return;
+  }
+  await ensureIndicatorsLoaded();
   try {
-    const result = await getAuxiliaryReviewResultMethod(selectedId.value);
+    const result = await getAuxiliaryReviewResultMethod(prescriptionId);
     auxiliaryReviewModalApi.setData({
       indicatorGroups: indicatorGroups.value,
       result,
       herbOptions: getHerbOptions(),
       onApply: (appliedResult: PrescriptionReviewModel.ReviewPrescriptionResult) => {
-        qualified.value = false;
-        selectedIndicators.value = [...(appliedResult.indicatorIds ?? [])];
-        herbSelections.value = {
-          ...(appliedResult.herbIndicatorMap ?? {}),
-        };
+        qualified.value = appliedResult.qualified ?? false;
+        if (appliedResult.qualified) {
+          selectedIndicators.value = [];
+          herbSelections.value = {};
+        } else {
+          selectedIndicators.value = [...(appliedResult.indicatorIds ?? [])];
+          herbSelections.value = {
+            ...(appliedResult.herbIndicatorMap ?? {}),
+          };
+        }
+        if (appliedResult.comment) {
+          reviewComment.value = appliedResult.comment;
+        }
       },
     });
     auxiliaryReviewModalApi.open();
@@ -251,18 +340,34 @@ async function openAuxiliaryReviewModal() {
 }
 
 async function onSave() {
-  if (!selectedId.value) return;
+  if (!selectedId.value || !currentPrescription.value || !taskId.value) return;
+  if (!qualified.value) {
+    await ensureIndicatorsLoaded();
+  }
   saving.value = true;
   try {
-    await saveReviewPrescriptionResultMethod(selectedId.value, {
-      qualified: qualified.value,
-      indicatorIds: qualified.value ? [] : selectedIndicators.value,
-      herbIndicatorMap: qualified.value ? {} : herbSelections.value,
-      comment: reviewComment.value,
+    const allIndicators = indicatorGroups.value.flatMap(
+      (group) => group.indicators,
+    );
+    await saveReviewPrescriptionResultMethod({
+      reviewDetailId: selectedId.value,
+      recordId: currentPrescription.value.recordId ?? taskId.value,
+      prescriptionId: currentPrescription.value.prescriptionId,
+      result: {
+        qualified: qualified.value,
+        indicatorIds: qualified.value ? [] : selectedIndicators.value,
+        herbIndicatorMap: qualified.value ? {} : herbSelections.value,
+        comment: reviewComment.value,
+      },
+      indicators: allIndicators,
+      excessMedicineOptions: currentPrescription.value.medicineList,
+      reviewUserId: userStore.userInfo?.userId,
+      reviewUserName: userStore.userInfo?.realName,
     });
     message.success('保存成功');
+    const savedId = selectedId.value;
     await loadPrescriptions();
-    await loadPrescriptionDetail(selectedId.value);
+    await selectNextPendingPrescription(savedId);
   } catch (error: any) {
     message.error(error.message || '保存失败');
   } finally {
@@ -287,15 +392,17 @@ watch(
   },
 );
 
-onMounted(async () => {
-  await Promise.all([loadTask(), loadIndicators()]);
-  await loadPrescriptions();
+onMounted(() => {
+  const name = route.query.name;
+  taskName.value = Array.isArray(name) ? (name[0] ?? '') : (name ?? '');
+  loadPrescriptions();
+  ensureIndicatorsLoaded();
 });
 </script>
 
 <template>
   <Page auto-content-height class="review-workspace-page">
-    <Spin :spinning="taskLoading">
+    <div>
       <div class="workspace-header">
         <Button type="link" @click="goBack">返回</Button>
         <span v-if="taskName" class="workspace-title">{{ taskName }}</span>
@@ -306,9 +413,8 @@ onMounted(async () => {
             <Select
               v-model:value="filterStatus"
               :options="[
-                { label: '待点评', value: 'pending' },
-                { label: '合格', value: 'qualified' },
-                { label: '不合格', value: 'unqualified' },
+                { label: '未点评', value: 'unreviewed' },
+                { label: '已点评', value: 'reviewed' },
               ]"
               allow-clear
               class="filter-item"
@@ -406,10 +512,15 @@ onMounted(async () => {
                 </div>
               </div>
               <div class="review-form">
-                <div v-if="qualified" class="qualified-panel">
+                <div
+                  :class="qualified ? 'qualified-panel' : 'review-form-panel'"
+                >
                   <div class="review-form-header">
                     <span class="form-title">点评结果:</span>
-                    <RadioGroup v-model:value="qualified" @change="onQualifiedChange">
+                    <RadioGroup
+                      v-model:value="qualified"
+                      @change="onQualifiedChange"
+                    >
                       <Radio :value="true">合格</Radio>
                       <Radio :value="false">不合格</Radio>
                     </RadioGroup>
@@ -420,6 +531,55 @@ onMounted(async () => {
                       辅助点评结果
                     </a>
                   </div>
+                  <div v-if="!qualified" class="indicator-section">
+                    <Spin :spinning="indicatorsLoading">
+                      <template v-if="indicatorGroups.length">
+                        <div
+                          v-for="group in indicatorGroups"
+                          :key="`${group.categoryId}-${group.categoryName}`"
+                          class="indicator-group"
+                        >
+                          <div class="group-title">{{ group.categoryName }}</div>
+                          <div class="group-items">
+                            <template
+                              v-for="indicator in group.indicators"
+                              :key="indicator.id"
+                            >
+                              <div class="indicator-item">
+                                <Checkbox
+                                  :checked="isIndicatorChecked(indicator.id)"
+                                  @change="
+                                    (e) =>
+                                      onIndicatorChange(
+                                        indicator.id,
+                                        e.target.checked ?? false,
+                                      )
+                                  "
+                                >
+                                  {{ indicator.name }}
+                                </Checkbox>
+                                <Select
+                                  v-if="
+                                    isIndicatorChecked(indicator.id) &&
+                                    needsHerbSelect(indicator)
+                                  "
+                                  v-model:value="herbSelections[indicator.id]"
+                                  :options="getHerbOptions()"
+                                  class="herb-select"
+                                  mode="multiple"
+                                  placeholder="选择中药"
+                                  size="small"
+                                />
+                              </div>
+                            </template>
+                          </div>
+                        </div>
+                      </template>
+                      <div v-else-if="!indicatorsLoading" class="indicator-empty">
+                        暂无点评指标
+                      </div>
+                    </Spin>
+                  </div>
                   <div class="comment-section">
                     <div class="comment-label">点评意见和说明</div>
                     <Textarea
@@ -429,70 +589,13 @@ onMounted(async () => {
                     />
                   </div>
                 </div>
-                <template v-else>
-                  <div class="review-form-header">
-                    <span class="form-title">点评结果:</span>
-                    <RadioGroup v-model:value="qualified" @change="onQualifiedChange">
-                      <Radio :value="true">合格</Radio>
-                      <Radio :value="false">不合格</Radio>
-                    </RadioGroup>
-                    <a
-                      class="auxiliary-link"
-                      @click.prevent="openAuxiliaryReviewModal"
-                    >
-                      辅助点评结果
-                    </a>
-                  </div>
-                  <div class="indicator-section">
-                    <div
-                      v-for="group in indicatorGroups"
-                      :key="group.categoryId"
-                      class="indicator-group"
-                    >
-                      <div class="group-title">{{ group.categoryName }}</div>
-                      <div class="group-items">
-                        <template
-                          v-for="indicator in group.indicators"
-                          :key="indicator.id"
-                        >
-                          <div class="indicator-item">
-                            <Checkbox
-                              :checked="isIndicatorChecked(indicator.id)"
-                              @change="
-                                (e) =>
-                                  onIndicatorChange(
-                                    indicator.id,
-                                    e.target.checked ?? false,
-                                  )
-                              "
-                            >
-                              {{ indicator.name }}
-                            </Checkbox>
-                            <Select
-                              v-if="
-                                isIndicatorChecked(indicator.id) &&
-                                needsHerbSelect(indicator)
-                              "
-                              v-model:value="herbSelections[indicator.id]"
-                              :options="getHerbOptions()"
-                              class="herb-select"
-                              mode="multiple"
-                              placeholder="选择中药"
-                              size="small"
-                            />
-                          </div>
-                        </template>
-                      </div>
-                    </div>
-                  </div>
-                </template>
               </div>
             </template>
             <div v-else class="empty-panel">请选择处方进行点评</div>
           </Spin>
         </div>
       </div>
-    </Spin>
+    </div>
     <AuxiliaryReviewModalComp />
     <div v-if="currentPrescription" class="review-save-bar">
       <Button :loading="saving" type="primary" @click="onSave">保存</Button>
@@ -505,7 +608,6 @@ onMounted(async () => {
   display: flex;
   align-items: center;
   gap: 12px;
-  margin-bottom: 12px;
 }
 
 .workspace-title {
@@ -600,12 +702,23 @@ onMounted(async () => {
   font-size: 13px;
 }
 
-.qualified-panel {
+.qualified-panel,
+.review-form-panel {
   padding: 16px;
   background: #f5f5f5;
   border-radius: 4px;
 }
 
+.review-form-panel .review-form-header {
+  margin-bottom: 12px;
+}
+
+.review-form-panel .indicator-section {
+  margin-bottom: 16px;
+  padding: 0;
+  background: transparent;
+}
+
 .review-form-header {
   display: flex;
   align-items: center;
@@ -616,6 +729,10 @@ onMounted(async () => {
   margin-bottom: 16px;
 }
 
+.review-form-panel .comment-section {
+  margin-top: 0;
+}
+
 .form-title {
   margin-right: 12px;
   font-weight: 500;
@@ -659,6 +776,12 @@ onMounted(async () => {
   width: 140px;
 }
 
+.indicator-empty {
+  padding: 8px 0;
+  font-size: 13px;
+  color: rgb(0 0 0 / 45%);
+}
+
 .comment-section {
   margin-bottom: 0;
 }
@@ -681,4 +804,8 @@ onMounted(async () => {
   text-align: center;
   color: rgb(0 0 0 / 45%);
 }
+
+.ant-btn {
+  padding: 0 3px;
+}
 </style>

+ 3 - 5
apps/smart-pharmacy/src/views/prescription-review/task/statistics-detail.vue

@@ -22,6 +22,8 @@ import {
   listReviewStatisticsDetailIndicatorsByCategoryMethod,
 } from '#/api';
 
+import { needsHerbSelect } from './data';
+
 interface CategoryGroup {
   categoryId: string;
   categoryName: string;
@@ -74,10 +76,6 @@ function isIndicatorChecked(indicatorId: string) {
   return selectedIndicatorIds.value.includes(indicatorId);
 }
 
-function needsHerbInput(indicator: PrescriptionReviewModel.ReviewIndicator) {
-  return indicator.categoryId === 'cat-2' || indicator.associatedChineseMedicine;
-}
-
 function getSelectedHerbs(indicatorId: string) {
   return herbIndicatorMap.value[indicatorId]?.join('、') ?? '';
 }
@@ -259,7 +257,7 @@ onMounted(loadDetail);
                 <Input
                   v-if="
                     isIndicatorChecked(indicator.id) &&
-                    needsHerbInput(indicator)
+                    needsHerbSelect(indicator)
                   "
                   :value="getSelectedHerbs(indicator.id)"
                   class="herb-input"