Prechádzať zdrojové kódy

feat(@six/smart-pharmacy): 智慧药事系统第二版-已归档处方列表静态页面新增

cmj 1 týždeň pred
rodič
commit
853d89ef08

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

@@ -133,6 +133,16 @@
         },
         "component": "/prescription-review/task/list"
       },
+      {
+        "id": "2508",
+        "path": "/prescription-review/archived/list",
+        "name": "PrescriptionReviewArchivedList",
+        "meta": {
+          "icon": "mdi:archive-outline",
+          "title": "已归档处方列表"
+        },
+        "component": "/prescription-review/archived/list"
+      },
       {
         "id": "2501",
         "path": "/prescription-review/expert",
@@ -192,6 +202,16 @@
           "hideInMenu": true
         },
         "component": "/prescription-review/task/statistics-detail"
+      },
+      {
+        "id": "2509",
+        "path": "/prescription-review/archived/log/:id",
+        "name": "PrescriptionReviewArchivedLog",
+        "meta": {
+          "title": "点评日志",
+          "hideInMenu": true
+        },
+        "component": "/prescription-review/archived/log"
       }
     ]
   },

+ 358 - 0
apps/smart-pharmacy/src/api/method/prescription-review.ts

@@ -212,6 +212,50 @@ export namespace PrescriptionReviewModel {
     topUnqualifiedReasons: string[];
     topExcessDosageHerbs: string[];
   }
+
+  /** 已归档处方列表行 */
+  export interface ArchivedPrescriptionRow extends TransformRecord {
+    prescriptionDate: string;
+    prescriptionNo: string;
+    institutionName: string;
+    campusName?: string;
+    departmentName: string;
+    doctorName: string;
+    reviewTime: string;
+    reviewExpert: string;
+    result: 'qualified' | 'unqualified';
+    samplingName: string;
+    taskId?: string;
+  }
+
+  /** 已归档处方 - 点评日志条目 */
+  export interface ArchivedPrescriptionReviewLogEntry extends TransformRecord {
+    reviewDate: string;
+    samplingName: string;
+    samplingTime: string;
+    reviewExpert: string;
+    reviewTime: string;
+    qualified: boolean;
+    comment?: string;
+    indicatorIds?: string[];
+    herbIndicatorMap?: Record<string, string[]>;
+  }
+
+  /** 已归档处方 - 点评日志详情 */
+  export interface ArchivedPrescriptionReviewLog {
+    prescription: ReviewStatisticsPrescriptionDetail;
+    entries: ArchivedPrescriptionReviewLogEntry[];
+  }
+
+  /** 已归档处方列表筛选项 */
+  export interface ArchivedPrescriptionFilterOptions {
+    samplingNames: string[];
+    institutions: string[];
+    campuses: string[];
+    departments: string[];
+    doctors: string[];
+    experts: string[];
+  }
 }
 
 /** 点评专家列表(userType=01) */
@@ -2083,3 +2127,317 @@ export function getReviewStatisticsSummaryMethod(
     topExcessDosageHerbs: ['秦艽', '知柏', '甘草', '黄芩', '当归'],
   });
 }
+
+const MOCK_ARCHIVED_PRESCRIPTIONS: PrescriptionReviewModel.ArchivedPrescriptionRow[] =
+  [
+    {
+      id: 'arch-1',
+      prescriptionDate: '2023-09-23',
+      prescriptionNo: '2023092323945',
+      institutionName: '蒋村社区卫生服务中心',
+      campusName: '',
+      departmentName: '全科医疗科',
+      doctorName: '虞毛瑾',
+      reviewTime: '2023-09-23 14:00:39',
+      reviewExpert: '于沐',
+      result: 'qualified',
+      samplingName: '2023年月度',
+      taskId: 'task-4',
+    },
+    {
+      id: 'arch-2',
+      prescriptionDate: '2023-09-23',
+      prescriptionNo: '2023092323934',
+      institutionName: '同仁堂',
+      campusName: '萧山馆',
+      departmentName: '中医专家',
+      doctorName: '郭可新',
+      reviewTime: '2023-09-23 15:29:38',
+      reviewExpert: '于沐',
+      result: 'qualified',
+      samplingName: '2023年月度',
+      taskId: 'task-4',
+    },
+    {
+      id: 'arch-3',
+      prescriptionDate: '2023-09-23',
+      prescriptionNo: '2023092323917',
+      institutionName: '浙江省中医院',
+      campusName: '湘湖院区',
+      departmentName: '中医妇科',
+      doctorName: '沈慧',
+      reviewTime: '2023-09-23 16:29:38',
+      reviewExpert: '于沐',
+      result: 'unqualified',
+      samplingName: '2022年度处方点评',
+      taskId: 'task-4',
+    },
+  ];
+
+const MOCK_ARCHIVED_REVIEW_LOGS: Record<
+  string,
+  PrescriptionReviewModel.ArchivedPrescriptionReviewLog
+> = {
+  'arch-1': {
+    prescription: {
+      id: 'arch-1',
+      prescriptionNo: '2023092323945',
+      institutionName: '蒋村社区卫生服务中心',
+      departmentName: '全科医疗科',
+      doctorName: '虞毛瑾',
+      pharmacistName: '崔红',
+      result: 'qualified',
+      unitDoseAmount: 28.5,
+      patientName: '张三',
+      patientGender: '男',
+      patientAge: 52,
+      pregnancy: false,
+      lactation: false,
+      tcmDisease: '感冒',
+      tcmSyndrome: '风寒束表证',
+      treatmentPrinciple: '辛温解表',
+      administrationMethod: '内服',
+      herbCount: 12,
+      doseCount: 5,
+      totalAmount: 142.5,
+      herbs: [
+        '麻黄 6g',
+        '桂枝 6g',
+        '杏仁 10g',
+        '甘草 6g',
+        '生姜 3片',
+        '大枣 3枚',
+      ],
+    },
+    entries: [
+      {
+        id: 'log-1-1',
+        reviewDate: '2023.09.23',
+        samplingName: '2023年月度',
+        samplingTime: '2023-09-23 10:00:23',
+        reviewExpert: '于沐',
+        reviewTime: '2023-09-23 14:00:39',
+        qualified: true,
+        comment: '处方用药合理,符合诊疗规范。',
+      },
+    ],
+  },
+  'arch-2': {
+    prescription: {
+      id: 'arch-2',
+      prescriptionNo: '2023092323934',
+      institutionName: '同仁堂',
+      departmentName: '中医专家',
+      doctorName: '郭可新',
+      pharmacistName: '',
+      result: 'qualified',
+      unitDoseAmount: 22.8,
+      patientName: '李四',
+      patientGender: '女',
+      patientAge: 38,
+      pregnancy: false,
+      lactation: false,
+      tcmDisease: '失眠',
+      tcmSyndrome: '心脾两虚证',
+      treatmentPrinciple: '补益心脾',
+      administrationMethod: '内服',
+      herbCount: 10,
+      doseCount: 7,
+      totalAmount: 159.6,
+      herbs: ['酸枣仁 15g', '茯苓 15g', '知母 10g', '川芎 10g'],
+    },
+    entries: [
+      {
+        id: 'log-2-1',
+        reviewDate: '2023.09.23',
+        samplingName: '2023年月度',
+        samplingTime: '2023-09-23 10:00:23',
+        reviewExpert: '于沐',
+        reviewTime: '2023-09-23 15:29:38',
+        qualified: true,
+        comment: '处方结构合理,用药得当。',
+      },
+    ],
+  },
+  'arch-3': {
+    prescription: {
+      id: 'arch-3',
+      prescriptionNo: '2023092323917',
+      institutionName: '浙江省中医院',
+      departmentName: '中医妇科',
+      doctorName: '沈慧',
+      pharmacistName: '崔红',
+      result: 'unqualified',
+      unqualifiedDetail: '剂量超标',
+      comment:
+        '点评意见和说明点评意见和说明点评意见和说明点评意见和说明点评意见和说明点评意见和说明点评意见和说明点评意见和说明',
+      unitDoseAmount: 33.43,
+      patientName: '鹿熙',
+      patientGender: '女',
+      patientAge: 48,
+      pregnancy: false,
+      lactation: false,
+      tcmDisease: '腰痛',
+      tcmSyndrome: '肝肾亏虚证',
+      treatmentPrinciple: '补益肝肾',
+      administrationMethod: '内服',
+      herbCount: 18,
+      doseCount: 7,
+      totalAmount: 234.01,
+      herbs: [
+        '熟地黄 15g',
+        '山茱萸 15g',
+        '山药 15g',
+        '茯苓 15g',
+        '泽泻 10g',
+        '牡丹皮 10g',
+        '秦艽 20g',
+        '杜仲 15g',
+        '续断 15g',
+        '牛膝 15g',
+        '当归 12g',
+        '白芍 12g',
+        '川芎 10g',
+        '甘草 6g',
+        '陈皮 10g',
+        '半夏 10g',
+        '生姜 3片',
+        '大枣 3枚',
+      ],
+      reviewExpert: '于沐',
+      reviewTime: '2023-09-23 14:00:39',
+      herbIssueLabels: { 秦艽: '剂量超标' },
+    },
+    entries: [
+      {
+        id: 'log-3-1',
+        reviewDate: '2022.03.30',
+        samplingName: '2023年月度',
+        samplingTime: '2023-09-23 10:00:23',
+        reviewExpert: '于沐',
+        reviewTime: '2023-09-23 14:00:39',
+        qualified: false,
+        comment:
+          '点评意见和说明点评意见和说明点评意见和说明点评意见和说明点评意见和说明点评意见和说明点评意见和说明点评意见和说明',
+        indicatorIds: ['ind-dose-exceed'],
+        herbIndicatorMap: { 'ind-dose-exceed': ['秦艽'] },
+      },
+      {
+        id: 'log-3-2',
+        reviewDate: '2022.12.31',
+        samplingName: '2022年度处方点评',
+        samplingTime: '2022-12-31 09:00:00',
+        reviewExpert: '于沐',
+        reviewTime: '2022-12-31 16:20:15',
+        qualified: true,
+        comment: '本次复查处方用药基本合理。',
+      },
+      {
+        id: 'log-3-3',
+        reviewDate: '2023.02.20',
+        samplingName: '2023年月度',
+        samplingTime: '2023-02-20 08:30:00',
+        reviewExpert: '专家2',
+        reviewTime: '2023-02-20 11:45:22',
+        qualified: false,
+        comment: '秦艽用量仍偏高,建议调整。',
+        indicatorIds: ['ind-dose-exceed'],
+        herbIndicatorMap: { 'ind-dose-exceed': ['秦艽'] },
+      },
+    ],
+  },
+};
+
+function buildArchivedPrescriptionFilterOptions(): PrescriptionReviewModel.ArchivedPrescriptionFilterOptions {
+  const unique = (values: string[]) =>
+    [...new Set(values.filter(Boolean))].map((value) => value);
+  return {
+    samplingNames: unique(
+      MOCK_ARCHIVED_PRESCRIPTIONS.map((item) => item.samplingName),
+    ),
+    institutions: unique(
+      MOCK_ARCHIVED_PRESCRIPTIONS.map((item) => item.institutionName),
+    ),
+    campuses: unique(
+      MOCK_ARCHIVED_PRESCRIPTIONS.map((item) => item.campusName ?? ''),
+    ),
+    departments: unique(
+      MOCK_ARCHIVED_PRESCRIPTIONS.map((item) => item.departmentName),
+    ),
+    doctors: unique(
+      MOCK_ARCHIVED_PRESCRIPTIONS.map((item) => item.doctorName),
+    ),
+    experts: unique(
+      MOCK_ARCHIVED_PRESCRIPTIONS.map((item) => item.reviewExpert),
+    ),
+  };
+}
+
+/** 已归档处方列表筛选项(当前为本地 mock) */
+export function listArchivedPrescriptionFilterOptionsMethod() {
+  return Promise.resolve(buildArchivedPrescriptionFilterOptions());
+}
+
+/** 已归档处方列表(当前为本地 mock) */
+export function listArchivedPrescriptionsMethod(
+  page = 1,
+  size = 10,
+  query?: {
+    campusName?: string;
+    departmentName?: string;
+    doctorName?: string;
+    institutionName?: string;
+    reviewDateEnd?: string;
+    reviewDateStart?: string;
+    reviewExpert?: string;
+    samplingName?: string;
+  },
+): Promise<TransformList<PrescriptionReviewModel.ArchivedPrescriptionRow>> {
+  let rows = [...MOCK_ARCHIVED_PRESCRIPTIONS];
+  if (query?.reviewDateStart) {
+    rows = rows.filter(
+      (item) => item.reviewTime.slice(0, 10) >= query.reviewDateStart!,
+    );
+  }
+  if (query?.reviewDateEnd) {
+    rows = rows.filter(
+      (item) => item.reviewTime.slice(0, 10) <= query.reviewDateEnd!,
+    );
+  }
+  if (query?.samplingName) {
+    rows = rows.filter((item) => item.samplingName === query.samplingName);
+  }
+  if (query?.institutionName) {
+    rows = rows.filter(
+      (item) => item.institutionName === query.institutionName,
+    );
+  }
+  if (query?.campusName) {
+    rows = rows.filter((item) => (item.campusName ?? '') === query.campusName);
+  }
+  if (query?.departmentName) {
+    rows = rows.filter(
+      (item) => item.departmentName === query.departmentName,
+    );
+  }
+  if (query?.doctorName) {
+    rows = rows.filter((item) => item.doctorName === query.doctorName);
+  }
+  if (query?.reviewExpert) {
+    rows = rows.filter((item) => item.reviewExpert === query.reviewExpert);
+  }
+  const start = (page - 1) * size;
+  return Promise.resolve({
+    items: rows.slice(start, start + size),
+    total: rows.length,
+  });
+}
+
+/** 已归档处方 - 点评日志详情(当前为本地 mock) */
+export function getArchivedPrescriptionReviewLogMethod(id: string) {
+  const log = MOCK_ARCHIVED_REVIEW_LOGS[id];
+  if (!log) {
+    return Promise.reject(new Error('点评日志不存在'));
+  }
+  return Promise.resolve(log);
+}

+ 13 - 0
apps/smart-pharmacy/src/api/method/system.ts

@@ -433,6 +433,19 @@ export function getUserMethod(id: string) {
   );
 }
 
+/** 重置用户登录密码 */
+export function resetUserPasswordMethod(data: {
+  oldPassword: string;
+  password: string;
+  userId: string;
+}) {
+  return http.put(`/manager/system/user/resetPwd`, {
+    oldPassword: data.oldPassword,
+    password: data.password,
+    userId: data.userId,
+  });
+}
+
 export function deleteUserMethod(data: Pick<SystemModel.User, 'id' | 'pid'>) {
   return deleteUsersMethod([data]);
 }

+ 3 - 1
apps/smart-pharmacy/src/api/model/menu.ts

@@ -49,9 +49,11 @@ export const HARDCODED_MENU_TREE_SELECT: TreeSelectMenuNode[] = [
     label: '处方点评',
     children: [
       { id: '2503', label: '处方点评' },
+      { id: '2508', label: '已归档处方列表' },
       { id: '2504', label: '处方点评详情' },
       { id: '2505', label: '统计分析' },
       { id: '2506', label: '点评详情' },
+      { id: '2509', label: '点评日志' },
       { id: '2501', label: '点评专家' },
       { id: '2502', label: '点评指标库' },
       { id: '2507', label: '均贴费用标准' },
@@ -68,7 +70,7 @@ export const HARDCODED_MENU_TREE_SELECT: TreeSelectMenuNode[] = [
 ];
 
 const HARDCODED_MENU_ROOT_IDS = ['2500', '2600'];
-const HARDCODED_MENU_LEAF_IDS = ['2415', '2416', '2504', '2505', '2506'];
+const HARDCODED_MENU_LEAF_IDS = ['2415', '2416', '2504', '2505', '2506', '2509'];
 
 /** 将本地写死菜单合并进后端 treeselect 结果 */
 export function mergeHardcodedMenuTree(

+ 175 - 0
apps/smart-pharmacy/src/views/prescription-review/archived/data.ts

@@ -0,0 +1,175 @@
+import type { VbenFormSchema } from '#/adapter/form';
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+import type { PrescriptionReviewModel } from '#/api/method/prescription-review';
+
+import { listArchivedPrescriptionFilterOptionsMethod } from '#/api';
+
+function toSelectOptions(values: string[]) {
+  return values.map((value) => ({ label: value, value }));
+}
+
+export function useArchivedPrescriptionSearchFormSchema(): VbenFormSchema[] {
+  return [
+    {
+      component: 'RangePicker',
+      componentProps: {
+        class: 'w-full',
+        placeholder: ['开始日期', '结束日期'],
+        valueFormat: 'YYYY-MM-DD',
+      },
+      fieldName: 'reviewDateRange',
+      label: '点评日期范围',
+    },
+    {
+      component: 'ApiSelect',
+      componentProps: {
+        allowClear: true,
+        api: async () => {
+          const options = await listArchivedPrescriptionFilterOptionsMethod();
+          return toSelectOptions(options.samplingNames);
+        },
+        class: 'w-full',
+        placeholder: '请选择',
+      },
+      fieldName: 'samplingName',
+      label: '抽样名称',
+    },
+    {
+      component: 'ApiSelect',
+      componentProps: {
+        allowClear: true,
+        api: async () => {
+          const options = await listArchivedPrescriptionFilterOptionsMethod();
+          return toSelectOptions(options.institutions);
+        },
+        class: 'w-full',
+        placeholder: '请选择',
+      },
+      fieldName: 'institutionName',
+      label: '医疗机构',
+    },
+    {
+      component: 'ApiSelect',
+      componentProps: {
+        allowClear: true,
+        api: async () => {
+          const options = await listArchivedPrescriptionFilterOptionsMethod();
+          return toSelectOptions(options.campuses);
+        },
+        class: 'w-full',
+        placeholder: '请选择',
+      },
+      fieldName: 'campusName',
+      label: '院区',
+    },
+    {
+      component: 'ApiSelect',
+      componentProps: {
+        allowClear: true,
+        api: async () => {
+          const options = await listArchivedPrescriptionFilterOptionsMethod();
+          return toSelectOptions(options.departments);
+        },
+        class: 'w-full',
+        placeholder: '请选择',
+      },
+      fieldName: 'departmentName',
+      label: '科室',
+    },
+    {
+      component: 'ApiSelect',
+      componentProps: {
+        allowClear: true,
+        api: async () => {
+          const options = await listArchivedPrescriptionFilterOptionsMethod();
+          return toSelectOptions(options.doctors);
+        },
+        class: 'w-full',
+        placeholder: '请选择',
+      },
+      fieldName: 'doctorName',
+      label: '医生',
+    },
+    {
+      component: 'ApiSelect',
+      componentProps: {
+        allowClear: true,
+        api: async () => {
+          const options = await listArchivedPrescriptionFilterOptionsMethod();
+          return toSelectOptions(options.experts);
+        },
+        class: 'w-full',
+        placeholder: '请选择',
+      },
+      fieldName: 'reviewExpert',
+      label: '点评专家',
+    },
+  ];
+}
+
+export function useArchivedPrescriptionTableColumns(): VxeTableGridOptions<PrescriptionReviewModel.ArchivedPrescriptionRow>['columns'] {
+  return [
+    {
+      type: 'seq',
+      title: '序号',
+      width: 70,
+    },
+    {
+      field: 'prescriptionDate',
+      minWidth: 120,
+      title: '处方日期',
+    },
+    {
+      field: 'prescriptionNo',
+      minWidth: 140,
+      title: '处方号',
+    },
+    {
+      field: 'institutionName',
+      minWidth: 180,
+      title: '医疗机构',
+    },
+    {
+      field: 'campusName',
+      minWidth: 100,
+      slots: {
+        default: ({ row }) => row.campusName || '',
+      },
+      title: '院区',
+    },
+    {
+      field: 'departmentName',
+      minWidth: 110,
+      title: '科室',
+    },
+    {
+      field: 'doctorName',
+      minWidth: 90,
+      title: '医生',
+    },
+    {
+      field: 'reviewTime',
+      minWidth: 160,
+      title: '点评时间',
+    },
+    {
+      field: 'reviewExpert',
+      minWidth: 100,
+      title: '点评专家',
+    },
+    {
+      field: 'result',
+      minWidth: 100,
+      slots: { default: 'result' },
+      title: '点评结果',
+    },
+    {
+      align: 'center',
+      field: 'operation',
+      fixed: 'right',
+      slots: { default: 'operation' },
+      title: '操作',
+      width: 100,
+    },
+  ];
+}

+ 90 - 0
apps/smart-pharmacy/src/views/prescription-review/archived/list.vue

@@ -0,0 +1,90 @@
+<script lang="ts" setup>
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+import type { PrescriptionReviewModel } from '#/api';
+
+import { useRouter } from 'vue-router';
+
+import { Page } from '@vben/common-ui';
+
+import { useVbenVxeGrid } from '#/adapter/vxe-table';
+import { listArchivedPrescriptionsMethod } from '#/api';
+
+import {
+  useArchivedPrescriptionSearchFormSchema,
+  useArchivedPrescriptionTableColumns,
+} from './data';
+
+const router = useRouter();
+
+const [Grid] = useVbenVxeGrid({
+  formOptions: {
+    schema: useArchivedPrescriptionSearchFormSchema(),
+    submitOnChange: false,
+    wrapperClass:
+      'grid-cols-1 md:grid-cols-2 lg:grid-cols-4 archived-prescription-search-form',
+  },
+  gridOptions: {
+    columns: useArchivedPrescriptionTableColumns(),
+    height: 'auto',
+    keepSource: true,
+    pagerConfig: {
+      pageSize: 10,
+    },
+    proxyConfig: {
+      ajax: {
+        query({ page }, formValues) {
+          const [reviewDateStart, reviewDateEnd] =
+            formValues.reviewDateRange ?? [];
+          return listArchivedPrescriptionsMethod(
+            page.currentPage,
+            page.pageSize,
+            {
+              campusName: formValues.campusName,
+              departmentName: formValues.departmentName,
+              doctorName: formValues.doctorName,
+              institutionName: formValues.institutionName,
+              reviewDateEnd,
+              reviewDateStart,
+              reviewExpert: formValues.reviewExpert,
+              samplingName: formValues.samplingName,
+            },
+          );
+        },
+      },
+    },
+    rowConfig: {
+      keyField: 'id',
+    },
+    stripe: true,
+  } as VxeTableGridOptions<PrescriptionReviewModel.ArchivedPrescriptionRow>,
+});
+
+function onViewLog(row: PrescriptionReviewModel.ArchivedPrescriptionRow) {
+  router.push(`/prescription-review/archived/log/${row.id}`);
+}
+</script>
+
+<template>
+  <Page auto-content-height class="archived-prescription-page">
+    <Grid>
+      <template #result="{ row }">
+        <span
+          :class="{
+            'text-red-500': row.result === 'unqualified',
+          }"
+        >
+          {{ row.result === 'qualified' ? '合格' : '不合格' }}
+        </span>
+      </template>
+      <template #operation="{ row }">
+        <a class="text-primary" @click="onViewLog(row)">点评日志</a>
+      </template>
+    </Grid>
+  </Page>
+</template>
+
+<style scoped>
+.archived-prescription-page :deep(.archived-prescription-search-form) {
+  margin-bottom: 0;
+}
+</style>

+ 448 - 0
apps/smart-pharmacy/src/views/prescription-review/archived/log.vue

@@ -0,0 +1,448 @@
+<script lang="ts" setup>
+import type { PrescriptionReviewModel } from '#/api';
+
+import { computed, onMounted, ref, watch } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
+
+import { Page } from '@vben/common-ui';
+
+import {
+  Button,
+  Checkbox,
+  Input,
+  Radio,
+  RadioGroup,
+  Spin,
+  Textarea,
+} from 'ant-design-vue';
+
+import {
+  getArchivedPrescriptionReviewLogMethod,
+  listReviewStatisticsDetailIndicatorCategoriesMethod,
+  listReviewStatisticsDetailIndicatorsByCategoryMethod,
+} from '#/api';
+
+interface CategoryGroup {
+  categoryId: string;
+  categoryName: string;
+  indicators: PrescriptionReviewModel.ReviewIndicator[];
+}
+
+const router = useRouter();
+const route = useRoute();
+
+const loading = ref(false);
+const logData = ref<PrescriptionReviewModel.ArchivedPrescriptionReviewLog>();
+const selectedEntryId = ref('');
+const indicatorGroups = ref<CategoryGroup[]>([]);
+
+const prescriptionId = computed(() => {
+  const id = route.params.id;
+  return Array.isArray(id) ? id[0] : id;
+});
+
+const prescription = computed(() => logData.value?.prescription);
+
+const entries = computed(() => logData.value?.entries ?? []);
+
+const currentEntry = computed(() =>
+  entries.value.find((item) => item.id === selectedEntryId.value),
+);
+
+const qualified = computed(
+  () => currentEntry.value?.qualified ?? prescription.value?.result === 'qualified',
+);
+
+const reviewComment = computed(() => currentEntry.value?.comment ?? '');
+
+const selectedIndicatorIds = computed(
+  () => currentEntry.value?.indicatorIds ?? [],
+);
+
+const herbIndicatorMap = computed(
+  () => currentEntry.value?.herbIndicatorMap ?? {},
+);
+
+function getHerbName(herb: string) {
+  return herb.split(' ')[0] ?? herb;
+}
+
+function getHerbIssueLabel(herb: string) {
+  const name = getHerbName(herb);
+  return prescription.value?.herbIssueLabels?.[name];
+}
+
+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('、') ?? '';
+}
+
+async function loadIndicatorGroups() {
+  const categories = await listReviewStatisticsDetailIndicatorCategoriesMethod();
+  const groups = await Promise.all(
+    categories.map(async (category) => ({
+      categoryId: category.id,
+      categoryName: category.name,
+      indicators: await listReviewStatisticsDetailIndicatorsByCategoryMethod(
+        category.id,
+      ),
+    })),
+  );
+  indicatorGroups.value = groups.filter((item) => item.indicators.length > 0);
+}
+
+async function loadDetail() {
+  if (!prescriptionId.value) return;
+  loading.value = true;
+  try {
+    const [data] = await Promise.all([
+      getArchivedPrescriptionReviewLogMethod(prescriptionId.value),
+      loadIndicatorGroups(),
+    ]);
+    logData.value = data;
+    selectedEntryId.value = data.entries[0]?.id ?? '';
+  } finally {
+    loading.value = false;
+  }
+}
+
+function goBack() {
+  router.push('/prescription-review/archived/list');
+}
+
+function selectEntry(entry: PrescriptionReviewModel.ArchivedPrescriptionReviewLogEntry) {
+  selectedEntryId.value = entry.id;
+}
+
+watch(prescriptionId, loadDetail);
+
+onMounted(loadDetail);
+</script>
+
+<template>
+  <Page auto-content-height class="archived-log-page">
+    <Spin :spinning="loading">
+      <div class="detail-header">
+        <Button type="link" @click="goBack">返回</Button>
+      </div>
+      <div v-if="prescription" class="detail-body">
+        <div class="prescription-panel">
+          <div class="panel-title">处方详情</div>
+          <div class="info-grid">
+            <div class="info-item">
+              <span class="info-label">医疗机构:</span>
+              <span>{{ prescription.institutionName }}</span>
+            </div>
+            <div class="info-item">
+              <span class="info-label">科室:</span>
+              <span>{{ prescription.departmentName }}</span>
+            </div>
+            <div class="info-item">
+              <span class="info-label">医生:</span>
+              <span>{{ prescription.doctorName }}</span>
+            </div>
+            <div class="info-item">
+              <span class="info-label">药师:</span>
+              <span>{{ prescription.pharmacistName || '—' }}</span>
+            </div>
+            <div class="info-item">
+              <span class="info-label">处方号:</span>
+              <a class="text-primary">{{ prescription.prescriptionNo }}</a>
+            </div>
+            <div class="info-item">
+              <span class="info-label">姓名:</span>
+              <span>{{ prescription.patientName }}</span>
+            </div>
+            <div class="info-item">
+              <span class="info-label">性别:</span>
+              <span>{{ prescription.patientGender }}</span>
+            </div>
+            <div class="info-item">
+              <span class="info-label">年龄:</span>
+              <span>{{ prescription.patientAge }}</span>
+            </div>
+            <div class="info-item">
+              <span class="info-label">中医病名:</span>
+              <span>{{ prescription.tcmDisease }}</span>
+            </div>
+            <div class="info-item">
+              <span class="info-label">中医证候:</span>
+              <span>{{ prescription.tcmSyndrome }}</span>
+            </div>
+            <div class="info-item">
+              <span class="info-label">治则治法:</span>
+              <span>{{ prescription.treatmentPrinciple }}</span>
+            </div>
+            <div class="info-item">
+              <span class="info-label">服法:</span>
+              <span>{{ prescription.administrationMethod }}</span>
+            </div>
+            <div class="info-item">
+              <span class="info-label">药味数:</span>
+              <span>{{ prescription.herbCount }}</span>
+            </div>
+            <div class="info-item">
+              <span class="info-label">妊娠:</span>
+              <span>{{ prescription.pregnancy ? '是' : '否' }}</span>
+            </div>
+            <div class="info-item">
+              <span class="info-label">哺乳:</span>
+              <span>{{ prescription.lactation ? '是' : '否' }}</span>
+            </div>
+            <div class="info-item">
+              <span class="info-label">剂数:</span>
+              <span>{{ prescription.doseCount }}</span>
+            </div>
+            <div class="info-item">
+              <span class="info-label">总金额:</span>
+              <span>{{ prescription.totalAmount }}</span>
+            </div>
+            <div class="info-item">
+              <span class="info-label">单剂金额:</span>
+              <span>{{ prescription.unitDoseAmount }}</span>
+            </div>
+          </div>
+          <div class="herb-section">
+            <div class="herb-title">明细</div>
+            <div class="herb-grid">
+              <span
+                v-for="(herb, idx) in prescription.herbs"
+                :key="idx"
+                class="herb-item"
+                :class="{ 'herb-item--issue': getHerbIssueLabel(herb) }"
+              >
+                {{ herb }}
+                <span v-if="getHerbIssueLabel(herb)" class="herb-issue">
+                  {{ getHerbIssueLabel(herb) }}
+                </span>
+              </span>
+            </div>
+          </div>
+        </div>
+        <div v-if="entries.length > 1" class="timeline-panel">
+          <div
+            v-for="entry in entries"
+            :key="entry.id"
+            class="timeline-item"
+            :class="{ 'timeline-item--active': entry.id === selectedEntryId }"
+            @click="selectEntry(entry)"
+          >
+            {{ entry.reviewDate }}
+          </div>
+        </div>
+        <div class="review-panel">
+          <div class="panel-title">点评结果</div>
+          <div class="review-status-row">
+            <RadioGroup :value="qualified">
+              <Radio :value="true">合格</Radio>
+              <Radio :value="false">不合格</Radio>
+            </RadioGroup>
+          </div>
+          <div v-if="currentEntry" class="review-meta">
+            <span>抽样名称:{{ currentEntry.samplingName }}</span>
+            <span>抽样时间:{{ currentEntry.samplingTime }}</span>
+            <span>点评专家:{{ currentEntry.reviewExpert || '—' }}</span>
+            <span>点评时间:{{ currentEntry.reviewTime || '—' }}</span>
+          </div>
+          <div class="comment-section">
+            <div class="comment-label">点评意见和说明</div>
+            <Textarea
+              :rows="4"
+              :value="reviewComment"
+              disabled
+              placeholder="暂无点评意见"
+            />
+          </div>
+          <div
+            v-for="group in indicatorGroups"
+            :key="group.categoryId"
+            class="indicator-group"
+          >
+            <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)"
+                  disabled
+                >
+                  {{ indicator.name }}
+                </Checkbox>
+                <Input
+                  v-if="
+                    isIndicatorChecked(indicator.id) &&
+                    needsHerbInput(indicator)
+                  "
+                  :value="getSelectedHerbs(indicator.id)"
+                  class="herb-input"
+                  disabled
+                  size="small"
+                />
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </Spin>
+  </Page>
+</template>
+
+<style scoped>
+.detail-header {
+  margin-bottom: 12px;
+}
+
+.detail-body {
+  display: flex;
+  gap: 0;
+  min-height: calc(100vh - 180px);
+  background: #fff;
+  border: 1px solid #f0f0f0;
+  border-radius: 4px;
+}
+
+.prescription-panel {
+  flex: 1;
+  min-width: 0;
+  padding: 16px 20px;
+  border-right: 1px solid #f0f0f0;
+}
+
+.timeline-panel {
+  display: flex;
+  flex-direction: column;
+  flex-shrink: 0;
+  gap: 8px;
+  width: 88px;
+  padding: 16px 8px;
+  border-right: 1px solid #f0f0f0;
+}
+
+.timeline-item {
+  padding: 6px 4px;
+  font-size: 12px;
+  line-height: 1.4;
+  color: rgb(0 0 0 / 65%);
+  text-align: center;
+  cursor: pointer;
+  border-radius: 4px;
+}
+
+.timeline-item--active {
+  color: #1677ff;
+  background: #e6f4ff;
+}
+
+.review-panel {
+  flex: 1;
+  min-width: 0;
+  padding: 16px 20px;
+  overflow-y: auto;
+}
+
+.panel-title {
+  margin-bottom: 16px;
+  font-size: 15px;
+  font-weight: 600;
+}
+
+.info-grid {
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  gap: 10px 24px;
+  margin-bottom: 20px;
+  font-size: 13px;
+}
+
+.info-label {
+  color: rgb(0 0 0 / 65%);
+}
+
+.herb-section {
+  padding: 12px;
+  border: 1px solid #d9d9d9;
+  border-radius: 4px;
+}
+
+.herb-title {
+  margin-bottom: 10px;
+  font-weight: 500;
+}
+
+.herb-grid {
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  gap: 8px 12px;
+}
+
+.herb-item {
+  font-size: 13px;
+}
+
+.herb-item--issue {
+  color: #ff4d4f;
+}
+
+.herb-issue {
+  margin-left: 4px;
+  color: #ff4d4f;
+}
+
+.review-status-row {
+  margin-bottom: 12px;
+}
+
+.review-meta {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  margin-bottom: 16px;
+  font-size: 13px;
+  color: rgb(0 0 0 / 65%);
+}
+
+.comment-section {
+  margin-bottom: 20px;
+}
+
+.comment-label {
+  margin-bottom: 8px;
+  font-size: 13px;
+  color: rgb(0 0 0 / 65%);
+}
+
+.indicator-group {
+  margin-bottom: 16px;
+}
+
+.group-title {
+  margin-bottom: 8px;
+  font-weight: 600;
+}
+
+.group-items {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px 20px;
+}
+
+.indicator-item {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.herb-input {
+  width: 100px;
+}
+</style>

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

@@ -4,7 +4,7 @@ import type { PersonnelQualificationModel } from '#/api';
 import { computed, ref } from 'vue';
 
 import { useVbenModal } from '@vben/common-ui';
-import { IconifyIcon } from '@vben/icons';
+import { ChevronLeft, ChevronRight } from '@vben/icons';
 
 import { Spin, Tabs } from 'ant-design-vue';
 
@@ -147,7 +147,7 @@ const [Modal, modalApi] = useVbenModal({
             type="button"
             @click="showPrevAttachment"
           >
-            <IconifyIcon class="size-5" icon="mdi:chevron-left" />
+            <ChevronLeft class="size-5" />
           </button>
           <div class="attachment-content">
             <template v-if="currentAttachment">
@@ -185,7 +185,7 @@ const [Modal, modalApi] = useVbenModal({
             type="button"
             @click="showNextAttachment"
           >
-            <IconifyIcon class="size-5" icon="mdi:chevron-right" />
+            <ChevronRight class="size-5" />
           </button>
         </div>
       </div>

+ 3 - 0
apps/smart-pharmacy/src/views/system/user/data.ts

@@ -53,6 +53,9 @@ export function useUserTableColumns<T = SystemModel.User>(
       field: 'name',
       title: $t('system.user.name'),
       minWidth: 100,
+      slots: {
+        default: 'userName',
+      },
     },
     {
       field: 'hospitalName',

+ 28 - 0
apps/smart-pharmacy/src/views/system/user/list.vue

@@ -21,13 +21,25 @@ import {
 import { $t } from '#/locales';
 
 import { useUserSearchFormSchema, useUserTableColumns } from './data';
+import Detail from './modules/detail.vue';
 import Form from './modules/form.vue';
+import Password from './modules/password.vue';
 
 const [FormModal, formModalApi] = useVbenModal({
   connectedComponent: Form,
   destroyOnClose: true,
 });
 
+const [DetailModal, detailModalApi] = useVbenModal({
+  connectedComponent: Detail,
+  destroyOnClose: true,
+});
+
+const [PasswordModal, passwordModalApi] = useVbenModal({
+  connectedComponent: Password,
+  destroyOnClose: true,
+});
+
 const [Grid, gridApi] = useVbenVxeGrid({
   formOptions: {
     schema: useUserSearchFormSchema(),
@@ -125,6 +137,15 @@ function onEditHandle(row?: SystemModel.User) {
   formModalApi.setData(row ?? {}).open();
 }
 
+function onDetailHandle(row: SystemModel.User) {
+  detailModalApi.setData(row).open();
+}
+
+function onChangePasswordHandle(row: SystemModel.User) {
+  detailModalApi.close();
+  passwordModalApi.setData(row).open();
+}
+
 async function onDeleteHandle(row: SystemModel.User) {
   const hideLoading = message.loading({
     content: $t('ui.actionMessage.deleting', [row.name]),
@@ -146,6 +167,8 @@ async function onDeleteHandle(row: SystemModel.User) {
 <template>
   <Page auto-content-height>
     <FormModal @success="onRefresh" />
+    <DetailModal @change-password="onChangePasswordHandle" />
+    <PasswordModal @success="onRefresh" />
     <Grid>
       <template #toolbar-tools>
         <Button type="primary" @click="onEditHandle()">
@@ -153,6 +176,11 @@ async function onDeleteHandle(row: SystemModel.User) {
           {{ $t('ui.actionTitle.create', [$t('system.user._')]) }}
         </Button>
       </template>
+      <template #userName="{ row }">
+        <Button size="small" type="link" @click="onDetailHandle(row)">
+          {{ row.name }}
+        </Button>
+      </template>
     </Grid>
   </Page>
 </template>

+ 110 - 0
apps/smart-pharmacy/src/views/system/user/modules/detail.vue

@@ -0,0 +1,110 @@
+<script lang="ts" setup>
+import type { SystemModel } from '#/api';
+
+import { computed, ref } from 'vue';
+
+import { useVbenModal } from '@vben/common-ui';
+
+import { Button } from 'ant-design-vue';
+
+import { maskPhone } from '#/utils/mask-phone';
+
+const emit = defineEmits<{
+  changePassword: [user: SystemModel.User];
+}>();
+
+const user = ref<SystemModel.User>();
+
+const roleNames = computed(() => {
+  const current = user.value;
+  if (!current) return '-';
+  return (
+    current.roleNames ||
+    current.roles
+      ?.map((item) =>
+        typeof item === 'string'
+          ? item
+          : (item?.name ?? item?.roleName ?? item?.rolename),
+      )
+      .filter(Boolean)
+      .join(',') ||
+    '-'
+  );
+});
+
+const [Modal, modalApi] = useVbenModal({
+  showCancelButton: false,
+  showConfirmButton: false,
+  onOpenChange(isOpen) {
+    if (isOpen) {
+      user.value = modalApi.getData<SystemModel.User>();
+      return;
+    }
+    user.value = undefined;
+  },
+});
+
+function displayValue(value?: string) {
+  if (value === undefined || value === null || value === '') return '';
+  return String(value);
+}
+
+function onChangePassword() {
+  if (!user.value) return;
+  emit('changePassword', user.value);
+}
+</script>
+
+<template>
+  <Modal class="user-detail-modal !w-[480px] !max-w-[90vw]">
+    <div class="user-detail">
+      <div class="user-detail-item">
+        <span class="user-detail-label">系统账号:</span>
+        <span>{{ displayValue(user?.access) || '-' }}</span>
+      </div>
+      <div class="user-detail-item">
+        <span class="user-detail-label">角色:</span>
+        <span>{{ roleNames }}</span>
+      </div>
+      <div class="user-detail-item">
+        <span class="user-detail-label">机构名称:</span>
+        <span>{{ displayValue(user?.hospitalName) || '-' }}</span>
+      </div>
+      <div class="user-detail-item">
+        <span class="user-detail-label">姓名:</span>
+        <span>{{ displayValue(user?.name) || '-' }}</span>
+      </div>
+      <div class="user-detail-item">
+        <span class="user-detail-label">手机号码:</span>
+        <span>{{ maskPhone(user?.phone) || '-' }}</span>
+      </div>
+      <div class="user-detail-item">
+        <span class="user-detail-label">备注:</span>
+        <span>{{ displayValue(user?.remark) || '' }}</span>
+      </div>
+      <div class="user-detail-action">
+        <Button type="primary" @click="onChangePassword">修改登录密码</Button>
+      </div>
+    </div>
+  </Modal>
+</template>
+
+<style scoped>
+.user-detail {
+  padding: 8px 32px 16px;
+}
+
+.user-detail-item {
+  line-height: 2;
+  color: rgb(0 0 0 / 85%);
+}
+
+.user-detail-label {
+  color: rgb(0 0 0 / 85%);
+}
+
+.user-detail-action {
+  margin-top: 32px;
+  text-align: center;
+}
+</style>

+ 119 - 0
apps/smart-pharmacy/src/views/system/user/modules/password.vue

@@ -0,0 +1,119 @@
+<script lang="ts" setup>
+import type { SystemModel } from '#/api';
+
+import { computed, ref } from 'vue';
+
+import { useVbenModal } from '@vben/common-ui';
+
+import { useRequest } from '@six/request';
+import { notification } from 'ant-design-vue';
+
+import { useVbenForm, z } from '#/adapter/form';
+import { resetUserPasswordMethod } from '#/api';
+
+const emit = defineEmits(['success']);
+
+const user = ref<SystemModel.User>();
+
+const passwordFormatRule = z
+  .string()
+  .min(1, { message: '请再次输入密码' })
+  .refine((value) => /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$/.test(value), {
+    message: '密码格式不正确 (由数字、字母组成,最少8位)',
+  });
+
+const resetPassword = useRequest(resetUserPasswordMethod, {
+  immediate: false,
+}).onSuccess(() => {
+  notification.success({ message: '密码修改成功' });
+  emit('success');
+});
+
+const [Form, formApi] = useVbenForm({
+  commonConfig: {
+    labelWidth: 90,
+  },
+  schema: [
+    {
+      component: 'InputPassword',
+      componentProps: {
+        placeholder: '请输入',
+      },
+      fieldName: 'oldPassword',
+      label: '旧密码',
+      rules: z.string().min(1, { message: '请输入' }),
+    },
+    {
+      component: 'InputPassword',
+      componentProps: {
+        placeholder: '请输入',
+      },
+      fieldName: 'newPassword',
+      label: '新密码',
+      rules: passwordFormatRule,
+    },
+    {
+      component: 'InputPassword',
+      componentProps: {
+        placeholder: '请输入',
+      },
+      dependencies: {
+        rules(values) {
+          return z
+            .string()
+            .min(1, { message: '请再次输入密码' })
+            .refine((value) => value === values.newPassword, {
+              message: '请再次输入密码',
+            });
+        },
+        triggerFields: ['newPassword'],
+      },
+      fieldName: 'confirmPassword',
+      label: '再次输入',
+    },
+  ],
+  showDefaultActions: false,
+});
+
+const modalTitle = computed(
+  () => `重置 ${user.value?.access ?? ''} 登录密码`,
+);
+
+const [Modal, modalApi] = useVbenModal({
+  confirmText: '提交',
+  async onConfirm() {
+    const { valid } = await formApi.validate();
+    if (!valid) return;
+
+    const values = await formApi.getValues();
+    const userId = user.value?.pid ?? user.value?.id;
+    if (!userId) return;
+
+    modalApi.lock();
+    try {
+      await resetPassword.send({
+        oldPassword: values.oldPassword,
+        password: values.newPassword,
+        userId,
+      });
+      await modalApi.close();
+    } finally {
+      modalApi.lock(false);
+    }
+  },
+  onOpenChange(isOpen) {
+    if (isOpen) {
+      user.value = modalApi.getData<SystemModel.User>();
+      formApi.resetForm();
+      return;
+    }
+    user.value = undefined;
+  },
+});
+</script>
+
+<template>
+  <Modal :title="modalTitle" class="user-password-modal !w-[520px] !max-w-[90vw]">
+    <Form class="mx-4" />
+  </Modal>
+</template>