Selaa lähdekoodia

feat(@six/smart-pharmacy): 智慧药事系统第二版-点评指标库静态页面新增

cmj 1 kuukausi sitten
vanhempi
commit
636056eea2

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

@@ -112,6 +112,16 @@
           "title": "点评专家"
         },
         "component": "/prescription-review/expert/list"
+      },
+      {
+        "id": "2502",
+        "path": "/prescription-review/indicator-library",
+        "name": "PrescriptionReviewIndicatorLibrary",
+        "meta": {
+          "icon": "mdi:chart-box-outline",
+          "title": "点评指标库"
+        },
+        "component": "/prescription-review/indicator-library/list"
       }
     ]
   }

+ 404 - 1
apps/smart-pharmacy/src/api/method/prescription-review.ts

@@ -1,6 +1,6 @@
 import type { TransformList, TransformRecord } from '#/api';
 
-/** 点评专家(接口就绪后替换为真实请求) */
+/** 处方点评(接口就绪后替换为真实请求) */
 export namespace PrescriptionReviewModel {
   export interface ReviewExpert extends TransformRecord {
     access: string;
@@ -12,6 +12,29 @@ export namespace PrescriptionReviewModel {
     institutionId?: string;
     pid?: string;
   }
+
+  /** 点评指标来源:系统内置 / 用户新增 */
+  export type ReviewIndicatorSource = 'custom' | 'system';
+
+  export interface ReviewIndicatorCategory extends TransformRecord {
+    name: string;
+    /** 系统内置分类不可删除 */
+    source: ReviewIndicatorSource;
+    /** 分类下点评项数量 */
+    indicatorCount?: number;
+  }
+
+  export interface ReviewIndicator extends TransformRecord {
+    categoryId: string;
+    categoryName: string;
+    name: string;
+    source: ReviewIndicatorSource;
+    /** 是否关联中药 */
+    associatedChineseMedicine: boolean;
+    remark?: string;
+    /** 0 启用,1 禁用 */
+    status: 0 | 1;
+  }
 }
 
 const MOCK_EXPERTS: PrescriptionReviewModel.ReviewExpert[] = [
@@ -166,3 +189,383 @@ export function updateReviewExpertStatusMethod(
   expert.status = status;
   return Promise.resolve(true);
 }
+
+const MOCK_INDICATOR_CATEGORIES: PrescriptionReviewModel.ReviewIndicatorCategory[] =
+  [
+    { id: 'cat-1', name: '适应症', source: 'system' },
+    { id: 'cat-2', name: '用法用量', source: 'system' },
+    { id: 'cat-3', name: '配伍禁忌', source: 'system' },
+    { id: 'cat-4', name: '特殊人群禁忌', source: 'system' },
+    { id: 'cat-5', name: '其他禁忌', source: 'system' },
+  ];
+
+const MOCK_INDICATORS: PrescriptionReviewModel.ReviewIndicator[] = [
+  {
+    id: 'ind-1',
+    categoryId: 'cat-1',
+    categoryName: '适应症',
+    name: '病证禁忌',
+    source: 'system',
+    associatedChineseMedicine: false,
+    status: 1,
+  },
+  {
+    id: 'ind-2',
+    categoryId: 'cat-1',
+    categoryName: '适应症',
+    name: '慎用',
+    source: 'system',
+    associatedChineseMedicine: false,
+    status: 1,
+  },
+  {
+    id: 'ind-3',
+    categoryId: 'cat-1',
+    categoryName: '适应症',
+    name: '禁用',
+    source: 'system',
+    associatedChineseMedicine: false,
+    status: 1,
+  },
+  {
+    id: 'ind-4',
+    categoryId: 'cat-1',
+    categoryName: '适应症',
+    name: '忌用',
+    source: 'system',
+    associatedChineseMedicine: false,
+    status: 1,
+  },
+  {
+    id: 'ind-5',
+    categoryId: 'cat-2',
+    categoryName: '用法用量',
+    name: '超剂量用药',
+    source: 'system',
+    associatedChineseMedicine: false,
+    status: 1,
+  },
+  {
+    id: 'ind-6',
+    categoryId: 'cat-3',
+    categoryName: '配伍禁忌',
+    name: '十八反十九畏',
+    source: 'system',
+    associatedChineseMedicine: true,
+    status: 0,
+  },
+  {
+    id: 'ind-7',
+    categoryId: 'cat-1',
+    categoryName: '适应症',
+    name: '点评项1',
+    source: 'custom',
+    associatedChineseMedicine: true,
+    createUser: '陆长林',
+    createTime: '2023-09-23 15:29:38',
+    status: 0,
+  },
+  {
+    id: 'ind-8',
+    categoryId: 'cat-1',
+    categoryName: '适应症',
+    name: '点评项2',
+    source: 'custom',
+    associatedChineseMedicine: true,
+    createUser: '陆长林',
+    createTime: '2023-09-23 15:29:38',
+    status: 0,
+  },
+];
+
+function getCategoryIndicatorCount(categoryId: string) {
+  return MOCK_INDICATORS.filter((item) => item.categoryId === categoryId).length;
+}
+
+function withCategoryIndicatorCount(
+  categories: PrescriptionReviewModel.ReviewIndicatorCategory[],
+) {
+  return categories.map((item) => ({
+    ...item,
+    indicatorCount: getCategoryIndicatorCount(item.id),
+  }));
+}
+
+function getNextCustomCategoryName() {
+  const prefix = '新加分类';
+  let index = 1;
+  while (
+    MOCK_INDICATOR_CATEGORIES.some((item) => item.name === `${prefix}${index}`)
+  ) {
+    index += 1;
+  }
+  return `${prefix}${index}`;
+}
+
+function syncIndicatorCategoryName(
+  indicator: PrescriptionReviewModel.ReviewIndicator,
+) {
+  const category = MOCK_INDICATOR_CATEGORIES.find(
+    (item) => item.id === indicator.categoryId,
+  );
+  indicator.categoryName = category?.name ?? indicator.categoryName;
+}
+
+function filterIndicators(
+  items: PrescriptionReviewModel.ReviewIndicator[],
+  query?: Partial<
+    PrescriptionReviewModel.ReviewIndicator & { categoryId?: string }
+  >,
+) {
+  let result = [...items];
+  if (query?.categoryId) {
+    result = result.filter((item) => item.categoryId === query.categoryId);
+  }
+  if (query?.name?.trim()) {
+    const keyword = query.name.trim();
+    result = result.filter((item) => item.name.includes(keyword));
+  }
+  return result;
+}
+
+/** 点评指标分类列表(当前为本地 mock) */
+export function listReviewIndicatorCategoriesMethod(): Promise<
+  PrescriptionReviewModel.ReviewIndicatorCategory[]
+> {
+  return Promise.resolve(withCategoryIndicatorCount([...MOCK_INDICATOR_CATEGORIES]));
+}
+
+/** 新增点评指标分类(当前为本地 mock) */
+export function createReviewIndicatorCategoryMethod(name?: string) {
+  const trimmed = (name ?? getNextCustomCategoryName()).trim();
+  if (!trimmed) {
+    return Promise.reject(new Error('分类名称不能为空'));
+  }
+  if (MOCK_INDICATOR_CATEGORIES.some((item) => item.name === trimmed)) {
+    return Promise.reject(new Error('分类名称已存在'));
+  }
+  const category: PrescriptionReviewModel.ReviewIndicatorCategory = {
+    id: `cat-${Date.now()}`,
+    name: trimmed,
+    source: 'custom',
+    indicatorCount: 0,
+  };
+  MOCK_INDICATOR_CATEGORIES.push(category);
+  return Promise.resolve(category);
+}
+
+/** 更新点评指标分类名称(当前为本地 mock,系统内置不可改) */
+export function updateReviewIndicatorCategoryMethod(
+  categoryId: string,
+  { name }: { name: string },
+) {
+  const category = MOCK_INDICATOR_CATEGORIES.find((item) => item.id === categoryId);
+  if (!category) {
+    return Promise.reject(new Error('分类不存在'));
+  }
+  if (category.source === 'system') {
+    return Promise.reject(new Error('系统内置分类不可修改'));
+  }
+  const trimmed = name.trim();
+  if (!trimmed) {
+    return Promise.reject(new Error('分类名称不能为空'));
+  }
+  if (
+    MOCK_INDICATOR_CATEGORIES.some(
+      (item) => item.id !== categoryId && item.name === trimmed,
+    )
+  ) {
+    return Promise.reject(new Error('分类名称已存在'));
+  }
+  category.name = trimmed;
+  MOCK_INDICATORS.forEach((item) => {
+    if (item.categoryId === categoryId) {
+      item.categoryName = trimmed;
+    }
+  });
+  return Promise.resolve({
+    ...category,
+    indicatorCount: getCategoryIndicatorCount(categoryId),
+  });
+}
+
+/** 点评指标分类排序(当前为本地 mock) */
+export function sortReviewIndicatorCategoriesMethod(orderedIds: string[]) {
+  if (orderedIds.length !== MOCK_INDICATOR_CATEGORIES.length) {
+    return Promise.reject(new Error('分类排序数据不完整'));
+  }
+  const categoryMap = new Map(
+    MOCK_INDICATOR_CATEGORIES.map((item) => [item.id, item]),
+  );
+  const sorted = orderedIds.map((id) => categoryMap.get(id)).filter(Boolean) as
+    PrescriptionReviewModel.ReviewIndicatorCategory[];
+  if (sorted.length !== MOCK_INDICATOR_CATEGORIES.length) {
+    return Promise.reject(new Error('分类排序数据无效'));
+  }
+  MOCK_INDICATOR_CATEGORIES.splice(0, MOCK_INDICATOR_CATEGORIES.length, ...sorted);
+  return Promise.resolve(withCategoryIndicatorCount([...MOCK_INDICATOR_CATEGORIES]));
+}
+
+/** 删除点评指标分类(当前为本地 mock,系统内置不可删) */
+export function deleteReviewIndicatorCategoryMethod(categoryId: string) {
+  const index = MOCK_INDICATOR_CATEGORIES.findIndex(
+    (item) => item.id === categoryId,
+  );
+  if (index === -1) {
+    return Promise.reject(new Error('分类不存在'));
+  }
+  const category = MOCK_INDICATOR_CATEGORIES[index];
+  if (!category) {
+    return Promise.reject(new Error('分类不存在'));
+  }
+  if (category.source === 'system') {
+    return Promise.reject(new Error('系统内置分类不可删除'));
+  }
+  const inUse = MOCK_INDICATORS.some((item) => item.categoryId === categoryId);
+  if (inUse) {
+    return Promise.reject(new Error('该分类下存在点评项,无法删除'));
+  }
+  MOCK_INDICATOR_CATEGORIES.splice(index, 1);
+  return Promise.resolve(true);
+}
+
+/** 按分类获取点评指标列表(当前为本地 mock) */
+export function listReviewIndicatorsByCategoryMethod(categoryId: string) {
+  const category = MOCK_INDICATOR_CATEGORIES.find((item) => item.id === categoryId);
+  if (!category) {
+    return Promise.reject(new Error('分类不存在'));
+  }
+  return Promise.resolve(
+    MOCK_INDICATORS.filter((item) => item.categoryId === categoryId),
+  );
+}
+
+/** 点评指标排序(当前为本地 mock,按分类内顺序) */
+export function sortReviewIndicatorsMethod(
+  categoryId: string,
+  orderedIds: string[],
+) {
+  const categoryIndicators = MOCK_INDICATORS.filter(
+    (item) => item.categoryId === categoryId,
+  );
+  if (orderedIds.length !== categoryIndicators.length) {
+    return Promise.reject(new Error('点评项排序数据不完整'));
+  }
+  const indicatorMap = new Map(categoryIndicators.map((item) => [item.id, item]));
+  const sorted = orderedIds.map((id) => indicatorMap.get(id)).filter(Boolean) as
+    PrescriptionReviewModel.ReviewIndicator[];
+  if (sorted.length !== categoryIndicators.length) {
+    return Promise.reject(new Error('点评项排序数据无效'));
+  }
+  const others = MOCK_INDICATORS.filter((item) => item.categoryId !== categoryId);
+  MOCK_INDICATORS.splice(0, MOCK_INDICATORS.length, ...others, ...sorted);
+  return Promise.resolve(sorted);
+}
+
+/** 点评指标列表(当前为本地 mock,后期对接后端分页接口) */
+export function listReviewIndicatorsMethod(
+  page = 1,
+  size = 10,
+  query?: Partial<
+    PrescriptionReviewModel.ReviewIndicator & { categoryId?: string }
+  >,
+): Promise<TransformList<PrescriptionReviewModel.ReviewIndicator>> {
+  const filtered = filterIndicators(MOCK_INDICATORS, query);
+  const start = (page - 1) * size;
+  const items = filtered.slice(start, start + size);
+  return Promise.resolve({
+    items,
+    total: filtered.length,
+    data: { page, size, total: filtered.length },
+  });
+}
+
+/** 新增点评指标(当前为本地 mock) */
+export function createReviewIndicatorMethod(
+  data: Pick<
+    PrescriptionReviewModel.ReviewIndicator,
+    'categoryId' | 'name' | 'associatedChineseMedicine' | 'remark' | 'status'
+  >,
+) {
+  const category = MOCK_INDICATOR_CATEGORIES.find(
+    (item) => item.id === data.categoryId,
+  );
+  if (!category) {
+    return Promise.reject(new Error('点评项分类不存在'));
+  }
+  const indicator: PrescriptionReviewModel.ReviewIndicator = {
+    id: `ind-${Date.now()}`,
+    categoryId: data.categoryId,
+    categoryName: category.name,
+    name: data.name.trim(),
+    source: 'custom',
+    associatedChineseMedicine: data.associatedChineseMedicine,
+    remark: data.remark,
+    status: data.status ?? 0,
+    createUser: '陆长林',
+    createTime: new Date().toISOString().slice(0, 19).replace('T', ' '),
+  };
+  MOCK_INDICATORS.push(indicator);
+  return Promise.resolve(indicator);
+}
+
+/** 编辑点评指标(当前为本地 mock,系统内置仅可改部分字段) */
+export function updateReviewIndicatorMethod(
+  indicatorId: string,
+  data: Partial<
+    Pick<
+      PrescriptionReviewModel.ReviewIndicator,
+      | 'categoryId'
+      | 'name'
+      | 'associatedChineseMedicine'
+      | 'remark'
+      | 'status'
+    >
+  >,
+) {
+  const indicator = MOCK_INDICATORS.find((item) => item.id === indicatorId);
+  if (!indicator) {
+    return Promise.reject(new Error('点评项不存在'));
+  }
+  if (data.categoryId) {
+    indicator.categoryId = data.categoryId;
+    syncIndicatorCategoryName(indicator);
+  }
+  if (data.name !== undefined) indicator.name = data.name.trim();
+  if (data.associatedChineseMedicine !== undefined) {
+    indicator.associatedChineseMedicine = data.associatedChineseMedicine;
+  }
+  if (data.remark !== undefined) indicator.remark = data.remark;
+  if (data.status !== undefined) indicator.status = data.status;
+  return Promise.resolve(indicator);
+}
+
+/** 删除点评指标(当前为本地 mock,系统内置不可删) */
+export function deleteReviewIndicatorMethod(indicatorId: string) {
+  const index = MOCK_INDICATORS.findIndex((item) => item.id === indicatorId);
+  if (index === -1) {
+    return Promise.reject(new Error('点评项不存在'));
+  }
+  const indicator = MOCK_INDICATORS[index];
+  if (!indicator) {
+    return Promise.reject(new Error('点评项不存在'));
+  }
+  if (indicator.source === 'system') {
+    return Promise.reject(new Error('系统内置点评项不可删除'));
+  }
+  MOCK_INDICATORS.splice(index, 1);
+  return Promise.resolve(true);
+}
+
+/** 点评指标状态更改(当前为本地 mock,后期对接后端接口) */
+export function updateReviewIndicatorStatusMethod(
+  indicatorId: string,
+  { status }: { status: 0 | 1 },
+) {
+  const indicator = MOCK_INDICATORS.find((item) => item.id === indicatorId);
+  if (!indicator) {
+    return Promise.reject(new Error('点评项不存在'));
+  }
+  indicator.status = status;
+  return Promise.resolve(true);
+}

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

@@ -39,7 +39,10 @@ export const HARDCODED_MENU_TREE_SELECT: TreeSelectMenuNode[] = [
   {
     id: '2500',
     label: '处方点评',
-    children: [{ id: '2501', label: '点评专家' }],
+    children: [
+      { id: '2501', label: '点评专家' },
+      { id: '2502', label: '点评指标库' },
+    ],
   },
 ];
 

+ 192 - 0
apps/smart-pharmacy/src/views/prescription-review/indicator-library/data.ts

@@ -0,0 +1,192 @@
+import type { VbenFormSchema } from '#/adapter/form';
+import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
+import type { PrescriptionReviewModel } from '#/api/method/prescription-review';
+
+import { listReviewIndicatorCategoriesMethod } from '#/api';
+
+const SOURCE_LABEL: Record<PrescriptionReviewModel.ReviewIndicatorSource, string> =
+  {
+    system: '系统',
+    custom: '新增',
+  };
+
+export function useReviewIndicatorSearchFormSchema(): VbenFormSchema[] {
+  return [
+    {
+      component: 'ApiSelect',
+      componentProps: {
+        allowClear: true,
+        api: async () => {
+          const categories = await listReviewIndicatorCategoriesMethod();
+          return categories.map((item) => ({
+            label: item.name,
+            value: item.id,
+          }));
+        },
+        class: 'w-full',
+        placeholder: '请选择',
+      },
+      fieldName: 'categoryId',
+      label: '点评项分类',
+    },
+    {
+      component: 'Input',
+      componentProps: {
+        placeholder: '请输入',
+      },
+      fieldName: 'name',
+      label: '点评项',
+    },
+  ];
+}
+
+export function useReviewIndicatorTableColumns(
+  onActionClick: OnActionClickFn<PrescriptionReviewModel.ReviewIndicator>,
+  onStatusChange?: (
+    newStatus: 0 | 1,
+    row: PrescriptionReviewModel.ReviewIndicator,
+  ) => PromiseLike<boolean | undefined>,
+): VxeTableGridOptions<PrescriptionReviewModel.ReviewIndicator>['columns'] {
+  return [
+    {
+      type: 'seq',
+      title: '序号',
+      width: 70,
+    },
+    {
+      field: 'categoryName',
+      minWidth: 120,
+      title: '点评项分类',
+    },
+    {
+      field: 'name',
+      minWidth: 140,
+      title: '点评项',
+    },
+    {
+      field: 'source',
+      minWidth: 90,
+      title: '来源',
+      slots: {
+        default: ({ row }) => SOURCE_LABEL[row.source],
+      },
+    },
+    {
+      field: 'createUser',
+      minWidth: 100,
+      title: '创建人',
+      slots: {
+        default: ({ row }) => (row.source === 'system' ? '/' : row.createUser ?? ''),
+      },
+    },
+    {
+      field: 'createTime',
+      minWidth: 160,
+      title: '创建时间',
+      slots: {
+        default: ({ row }) => row.createTime ?? '',
+      },
+    },
+    {
+      field: 'associatedChineseMedicine',
+      minWidth: 100,
+      title: '关联中药',
+      slots: {
+        default: ({ row }) => (row.associatedChineseMedicine ? '是' : '否'),
+      },
+    },
+    {
+      field: 'remark',
+      minWidth: 120,
+      title: '备注',
+    },
+    {
+      cellRender: {
+        attrs: { beforeChange: onStatusChange },
+        props: { _props: { checkedValue: 0, unCheckedValue: 1 } },
+        name: onStatusChange ? 'CellSwitch' : 'CellTag',
+      },
+      field: 'status',
+      minWidth: 100,
+      title: '启用状态',
+    },
+    {
+      align: 'center',
+      cellRender: {
+        attrs: {
+          nameField: 'name',
+          nameTitle: '点评项',
+          onClick: onActionClick,
+        },
+        name: 'CellOperation',
+        options: [
+          { code: 'edit', text: '修改' },
+          {
+            code: 'delete',
+            danger: true,
+            show: (row: PrescriptionReviewModel.ReviewIndicator) =>
+              row.source === 'custom',
+            text: '删除',
+          },
+        ],
+      },
+      field: 'operation',
+      fixed: 'right',
+      title: '操作',
+      width: 140,
+    },
+  ];
+}
+
+export function useReviewIndicatorFormSchema(): VbenFormSchema[] {
+  return [
+    {
+      component: 'ApiSelect',
+      componentProps: {
+        api: async () => {
+          const categories = await listReviewIndicatorCategoriesMethod();
+          return categories.map((item) => ({
+            label: item.name,
+            value: item.id,
+          }));
+        },
+        class: 'w-full',
+        placeholder: '请选择',
+      },
+      fieldName: 'categoryId',
+      label: '点评项分类',
+      rules: 'required',
+    },
+    {
+      component: 'Input',
+      componentProps: {
+        placeholder: '请输入',
+      },
+      fieldName: 'name',
+      label: '点评项',
+      rules: 'required',
+    },
+    {
+      component: 'RadioGroup',
+      componentProps: {
+        options: [
+          { label: '是', value: true },
+          { label: '否', value: false },
+        ],
+      },
+      defaultValue: true,
+      fieldName: 'associatedChineseMedicine',
+      label: '关联中药',
+      rules: 'selectRequired',
+    },
+    {
+      component: 'Textarea',
+      componentProps: {
+        placeholder: '请输入',
+        rows: 3,
+      },
+      fieldName: 'remark',
+      label: '备注',
+    },
+  ];
+}

+ 190 - 0
apps/smart-pharmacy/src/views/prescription-review/indicator-library/list.vue

@@ -0,0 +1,190 @@
+<script lang="ts" setup>
+import type { Recordable } from '@vben/types';
+
+import type {
+  OnActionClickParams,
+  VxeTableGridOptions,
+} from '#/adapter/vxe-table';
+import type { PrescriptionReviewModel } from '#/api';
+
+import { Page, useVbenModal } from '@vben/common-ui';
+import { Plus } from '@vben/icons';
+
+import { Button, message, Modal, notification } from 'ant-design-vue';
+
+import { useVbenVxeGrid } from '#/adapter/vxe-table';
+import {
+  deleteReviewIndicatorMethod,
+  listReviewIndicatorsMethod,
+  updateReviewIndicatorStatusMethod,
+} from '#/api';
+
+import {
+  useReviewIndicatorSearchFormSchema,
+  useReviewIndicatorTableColumns,
+} from './data';
+import CategoryModal from './modules/category-modal.vue';
+import Form from './modules/form.vue';
+
+const [FormModal, formModalApi] = useVbenModal({
+  connectedComponent: Form,
+  destroyOnClose: true,
+});
+
+const [CategoryFormModal, categoryModalApi] = useVbenModal({
+  connectedComponent: CategoryModal,
+  destroyOnClose: true,
+});
+
+const [Grid, gridApi] = useVbenVxeGrid({
+  formOptions: {
+    schema: useReviewIndicatorSearchFormSchema(),
+    submitOnChange: false,
+  },
+  gridOptions: {
+    columns: useReviewIndicatorTableColumns(onActionClick, onStatusChange),
+    height: 'auto',
+    keepSource: true,
+    proxyConfig: {
+      ajax: {
+        query({ page }, formValues) {
+          return listReviewIndicatorsMethod(
+            page.currentPage,
+            page.pageSize,
+            formValues,
+          );
+        },
+      },
+    },
+    rowConfig: {
+      keyField: 'id',
+    },
+    stripe: true,
+  } as VxeTableGridOptions<PrescriptionReviewModel.ReviewIndicator>,
+});
+
+function onActionClick(
+  e: OnActionClickParams<PrescriptionReviewModel.ReviewIndicator>,
+) {
+  switch (e.code) {
+    case 'delete': {
+      onDeleteHandle(e.row);
+      break;
+    }
+    case 'edit': {
+      onEditHandle(e.row);
+      break;
+    }
+  }
+}
+
+function confirm(content: string, title: string) {
+  return new Promise((reslove, reject) => {
+    Modal.confirm({
+      content,
+      onCancel() {
+        reject(new Error('已取消'));
+      },
+      onOk() {
+        reslove(true);
+      },
+      title,
+    });
+  });
+}
+
+async function onStatusChange(
+  newStatus: 0 | 1,
+  row: PrescriptionReviewModel.ReviewIndicator,
+) {
+  const status: Recordable<string> = {
+    0: '启用',
+    1: '禁用',
+  };
+  try {
+    await confirm(
+      `你要将${row.name}的状态切换为 【${status[newStatus.toString()]}】 吗?`,
+      '切换状态',
+    );
+
+    try {
+      await updateReviewIndicatorStatusMethod(row.id, {
+        status: newStatus,
+      });
+      notification.success({
+        message: '切换状态成功',
+      });
+      return true;
+    } catch (error: any) {
+      notification.error({
+        message: error.message || '切换状态失败',
+      });
+      return false;
+    }
+  } catch {
+    return false;
+  }
+}
+
+function onRefresh() {
+  gridApi.query();
+}
+
+function onEditHandle(row: PrescriptionReviewModel.ReviewIndicator) {
+  formModalApi.setData(row).open();
+}
+
+function onAddHandle() {
+  formModalApi.setData(undefined).open();
+}
+
+function onCategoryHandle() {
+  categoryModalApi.open();
+}
+
+async function onDeleteHandle(row: PrescriptionReviewModel.ReviewIndicator) {
+  if (row.source === 'system') {
+    message.warning('系统内置点评项不可删除');
+    return;
+  }
+  try {
+    await confirm(`确定删除点评项【${row.name}】吗?`, '删除确认');
+    await deleteReviewIndicatorMethod(row.id);
+    message.success('删除成功');
+    onRefresh();
+  } catch (error: any) {
+    if (error.message !== '已取消') {
+      message.error(error.message || '删除失败');
+    }
+  }
+}
+</script>
+<template>
+  <Page auto-content-height>
+    <FormModal @success="onRefresh" />
+    <CategoryFormModal @success="onRefresh" />
+    <Grid>
+      <template #toolbar-tools>
+        <Button class="category-btn mr-2" type="primary" @click="onCategoryHandle">
+          点评项分类
+        </Button>
+        <Button type="primary" @click="onAddHandle">
+          <Plus class="size-5" />
+          新增点评项
+        </Button>
+      </template>
+    </Grid>
+  </Page>
+</template>
+<style scoped>
+.category-btn {
+  background-color: #fa8c16;
+  border-color: #fa8c16;
+}
+
+.category-btn:hover,
+.category-btn:focus {
+  background-color: #ffa940;
+  border-color: #ffa940;
+}
+</style>

+ 434 - 0
apps/smart-pharmacy/src/views/prescription-review/indicator-library/modules/category-modal.vue

@@ -0,0 +1,434 @@
+<script lang="ts" setup>
+import type { PrescriptionReviewModel } from '#/api';
+
+import { computed, ref, watch } from 'vue';
+
+import { useVbenModal } from '@vben/common-ui';
+import { IconifyIcon } from '@vben/icons';
+
+import { Input, message } from 'ant-design-vue';
+
+import {
+  createReviewIndicatorCategoryMethod,
+  deleteReviewIndicatorCategoryMethod,
+  listReviewIndicatorCategoriesMethod,
+  listReviewIndicatorsByCategoryMethod,
+  sortReviewIndicatorCategoriesMethod,
+  sortReviewIndicatorsMethod,
+  updateReviewIndicatorCategoryMethod,
+} from '#/api';
+
+const emit = defineEmits(['success']);
+
+type ViewMode = 'categories' | 'indicators';
+
+const viewMode = ref<ViewMode>('categories');
+const categories = ref<PrescriptionReviewModel.ReviewIndicatorCategory[]>([]);
+const indicators = ref<PrescriptionReviewModel.ReviewIndicator[]>([]);
+const currentCategory = ref<PrescriptionReviewModel.ReviewIndicatorCategory>();
+const loading = ref(false);
+const dragIndex = ref(-1);
+const editingNames = ref<Record<string, string>>({});
+
+const modalTitle = '点评项分类';
+
+const sortableList = computed(() =>
+  viewMode.value === 'categories' ? categories.value : indicators.value,
+);
+
+function resetState() {
+  viewMode.value = 'categories';
+  currentCategory.value = undefined;
+  indicators.value = [];
+  dragIndex.value = -1;
+  editingNames.value = {};
+}
+
+async function loadCategories() {
+  loading.value = true;
+  try {
+    categories.value = await listReviewIndicatorCategoriesMethod();
+    categories.value.forEach((item) => {
+      if (item.source === 'custom') {
+        editingNames.value[item.id] = item.name;
+      }
+    });
+  } finally {
+    loading.value = false;
+  }
+}
+
+async function loadIndicators(categoryId: string) {
+  loading.value = true;
+  try {
+    indicators.value = await listReviewIndicatorsByCategoryMethod(categoryId);
+  } finally {
+    loading.value = false;
+  }
+}
+
+async function onAddCategory() {
+  try {
+    const category = await createReviewIndicatorCategoryMethod();
+    editingNames.value[category.id] = category.name;
+    await loadCategories();
+    emit('success');
+  } catch (error: any) {
+    message.error(error.message || '新增分类失败');
+  }
+}
+
+async function onSaveCategoryName(
+  category: PrescriptionReviewModel.ReviewIndicatorCategory,
+) {
+  const newName = editingNames.value[category.id]?.trim();
+  if (!newName || newName === category.name) {
+    editingNames.value[category.id] = category.name;
+    return;
+  }
+  try {
+    await updateReviewIndicatorCategoryMethod(category.id, { name: newName });
+    message.success('修改分类成功');
+    await loadCategories();
+    emit('success');
+  } catch (error: any) {
+    message.error(error.message || '修改分类失败');
+    editingNames.value[category.id] = category.name;
+  }
+}
+
+async function onDeleteCategory(
+  category: PrescriptionReviewModel.ReviewIndicatorCategory,
+) {
+  if (category.indicatorCount && category.indicatorCount > 0) {
+    message.warning('该分类下存在点评项,无法删除');
+    return;
+  }
+  try {
+    await deleteReviewIndicatorCategoryMethod(category.id);
+    message.success('删除分类成功');
+    delete editingNames.value[category.id];
+    await loadCategories();
+    emit('success');
+  } catch (error: any) {
+    message.error(error.message || '删除分类失败');
+  }
+}
+
+async function onOpenIndicators(
+  category: PrescriptionReviewModel.ReviewIndicatorCategory,
+) {
+  currentCategory.value = category;
+  viewMode.value = 'indicators';
+  await loadIndicators(category.id);
+}
+
+function onBackToCategories() {
+  viewMode.value = 'categories';
+  currentCategory.value = undefined;
+  indicators.value = [];
+}
+
+function onDragStart(index: number, event: DragEvent) {
+  dragIndex.value = index;
+  if (event.dataTransfer) {
+    event.dataTransfer.effectAllowed = 'move';
+    event.dataTransfer.setData('text/plain', String(index));
+  }
+}
+
+function onDragEnd() {
+  dragIndex.value = -1;
+}
+
+async function onDrop(targetIndex: number) {
+  const sourceIndex = dragIndex.value;
+  dragIndex.value = -1;
+  if (sourceIndex < 0 || sourceIndex === targetIndex) return;
+
+  if (viewMode.value === 'categories') {
+    const list = [...categories.value];
+    const [moved] = list.splice(sourceIndex, 1);
+    if (!moved) return;
+    list.splice(targetIndex, 0, moved);
+    categories.value = list;
+    try {
+      await sortReviewIndicatorCategoriesMethod(list.map((item) => item.id));
+      emit('success');
+    } catch (error: any) {
+      message.error(error.message || '排序失败');
+      await loadCategories();
+    }
+    return;
+  }
+
+  const list = [...indicators.value];
+  const [moved] = list.splice(sourceIndex, 1);
+  if (!moved || !currentCategory.value) return;
+  list.splice(targetIndex, 0, moved);
+  indicators.value = list;
+  try {
+    await sortReviewIndicatorsMethod(
+      currentCategory.value.id,
+      list.map((item) => item.id),
+    );
+    emit('success');
+  } catch (error: any) {
+    message.error(error.message || '排序失败');
+    await loadIndicators(currentCategory.value.id);
+  }
+}
+
+const [Modal] = useVbenModal({
+  class: 'category-modal',
+  footer: false,
+  onOpenChange(isOpen) {
+    if (isOpen) {
+      resetState();
+      loadCategories();
+    } else {
+      resetState();
+    }
+  },
+});
+
+watch(viewMode, () => {
+  dragIndex.value = -1;
+});
+</script>
+
+<template>
+  <Modal :title="modalTitle" class="w-[560px]">
+    <div class="category-modal-body mx-4">
+      <button
+        v-if="viewMode === 'indicators'"
+        class="back-link mb-3"
+        type="button"
+        @click="onBackToCategories"
+      >
+        返回
+      </button>
+
+      <div v-if="viewMode === 'categories'" class="list-header">
+        <span class="list-header-title">点评项分类</span>
+        <button class="icon-btn" type="button" @click="onAddCategory">
+          <IconifyIcon class="size-4" icon="lucide:plus" />
+        </button>
+      </div>
+
+      <div v-if="loading" class="py-8 text-center text-gray-400">加载中...</div>
+
+      <ul v-else class="sortable-list">
+        <li
+          v-for="(item, index) in sortableList"
+          :key="item.id"
+          :class="{ 'is-dragging': dragIndex === index }"
+          class="sortable-item"
+          @dragover.prevent
+          @drop="onDrop(index)"
+        >
+          <span
+            class="drag-handle"
+            draggable="true"
+            title="拖动调整顺序"
+            @dragend="onDragEnd"
+            @dragstart="onDragStart(index, $event)"
+          >
+            <IconifyIcon class="size-4" icon="lucide:grip-vertical" />
+          </span>
+
+          <template v-if="viewMode === 'categories'">
+            <div class="item-main">
+              <Input
+                v-if="(item as PrescriptionReviewModel.ReviewIndicatorCategory).source === 'custom'"
+                v-model:value="editingNames[item.id]"
+                class="category-input"
+                @blur="onSaveCategoryName(item as PrescriptionReviewModel.ReviewIndicatorCategory)"
+                @press-enter="onSaveCategoryName(item as PrescriptionReviewModel.ReviewIndicatorCategory)"
+              />
+              <span v-else class="category-name">
+                {{ (item as PrescriptionReviewModel.ReviewIndicatorCategory).name }}
+              </span>
+              <button
+                v-if="(item as PrescriptionReviewModel.ReviewIndicatorCategory).source === 'custom'"
+                :disabled="Boolean((item as PrescriptionReviewModel.ReviewIndicatorCategory).indicatorCount)"
+                :title="
+                  (item as PrescriptionReviewModel.ReviewIndicatorCategory).indicatorCount
+                    ? '该分类下存在点评项,无法删除'
+                    : '删除分类'
+                "
+                class="icon-btn icon-btn-danger"
+                type="button"
+                @click="onDeleteCategory(item as PrescriptionReviewModel.ReviewIndicatorCategory)"
+              >
+                <IconifyIcon class="size-4" icon="lucide:minus" />
+              </button>
+            </div>
+            <button
+              class="review-link"
+              type="button"
+              @click="onOpenIndicators(item as PrescriptionReviewModel.ReviewIndicatorCategory)"
+            >
+              点评项
+            </button>
+          </template>
+
+          <template v-else>
+            <span class="category-name flex-1">
+              {{ (item as PrescriptionReviewModel.ReviewIndicator).name }}
+            </span>
+          </template>
+        </li>
+      </ul>
+
+      <p v-if="!loading && sortableList.length > 0" class="drag-hint">
+        拖动调整顺序
+      </p>
+    </div>
+  </Modal>
+</template>
+
+<style scoped>
+.category-modal-body {
+  min-height: 200px;
+}
+
+.back-link {
+  color: #1677ff;
+  cursor: pointer;
+  font-size: 14px;
+  background: none;
+  border: none;
+  padding: 0;
+}
+
+.back-link:hover {
+  color: #4096ff;
+}
+
+.list-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 10px 12px;
+  border: 1px solid #d9d9d9;
+  border-bottom: none;
+  background: #fafafa;
+}
+
+.list-header-title {
+  font-weight: 500;
+  color: #333;
+}
+
+.sortable-list {
+  margin: 0;
+  padding: 0;
+  list-style: none;
+  border: 1px solid #d9d9d9;
+}
+
+.sortable-item {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 10px 12px;
+  border-bottom: 1px solid #f0f0f0;
+  background: #fff;
+  transition: background-color 0.2s;
+}
+
+.sortable-item:last-child {
+  border-bottom: none;
+}
+
+.sortable-item.is-dragging {
+  background: #f5f5f5;
+}
+
+.drag-handle {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 24px;
+  height: 24px;
+  border-radius: 50%;
+  border: 1px solid #d9d9d9;
+  color: #8c8c8c;
+  cursor: grab;
+  flex-shrink: 0;
+}
+
+.drag-handle:active {
+  cursor: grabbing;
+}
+
+.item-main {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  flex: 1;
+  min-width: 0;
+}
+
+.category-input {
+  flex: 1;
+  min-width: 0;
+}
+
+.category-name {
+  flex: 1;
+  color: #333;
+  line-height: 32px;
+}
+
+.review-link {
+  flex-shrink: 0;
+  color: #1677ff;
+  cursor: pointer;
+  font-size: 14px;
+  background: none;
+  border: none;
+  padding: 0 4px;
+}
+
+.review-link:hover {
+  color: #4096ff;
+}
+
+.icon-btn {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 24px;
+  height: 24px;
+  padding: 0;
+  border: 1px solid #d9d9d9;
+  border-radius: 4px;
+  background: #fff;
+  color: #595959;
+  cursor: pointer;
+}
+
+.icon-btn:hover {
+  color: #1677ff;
+  border-color: #1677ff;
+}
+
+.icon-btn-danger:hover:not(:disabled) {
+  color: #ff4d4f;
+  border-color: #ff4d4f;
+}
+
+.icon-btn:disabled {
+  cursor: not-allowed;
+  opacity: 0.45;
+}
+
+.drag-hint {
+  margin: 16px 0 8px;
+  text-align: center;
+  color: #8c8c8c;
+  font-size: 13px;
+}
+</style>

+ 148 - 0
apps/smart-pharmacy/src/views/prescription-review/indicator-library/modules/form.vue

@@ -0,0 +1,148 @@
+<script lang="ts" setup>
+import type { PrescriptionReviewModel } from '#/api';
+
+import { computed, ref } from 'vue';
+
+import { useVbenModal } from '@vben/common-ui';
+
+import { message } from 'ant-design-vue';
+
+import { useVbenForm } from '#/adapter/form';
+import {
+  createReviewIndicatorMethod,
+  updateReviewIndicatorMethod,
+} from '#/api';
+
+import { useReviewIndicatorFormSchema } from '../data';
+
+const emit = defineEmits(['success']);
+
+const formData = ref<PrescriptionReviewModel.ReviewIndicator>();
+const getTitle = computed(() =>
+  formData.value?.id ? '修改点评项' : '新增点评项',
+);
+
+const [Form, formApi] = useVbenForm({
+  commonConfig: {
+    labelWidth: 110,
+  },
+  schema: useReviewIndicatorFormSchema(),
+  showDefaultActions: false,
+});
+
+const [Modal, modalApi] = useVbenModal({
+  cancelText: '取消',
+  centered: true,
+  class: 'review-indicator-form-modal w-[520px]',
+  confirmText: '保存',
+  footerClass: 'review-indicator-form-modal__footer',
+  fullscreenButton: false,
+  headerClass: 'review-indicator-form-modal__header',
+  async onConfirm() {
+    const { valid } = await formApi.validate();
+    if (!valid) return;
+    modalApi.lock();
+    const values = await formApi.getValues();
+    try {
+      if (formData.value?.id) {
+        await updateReviewIndicatorMethod(formData.value.id, values);
+        message.success('修改成功');
+      } else {
+        await createReviewIndicatorMethod({
+          associatedChineseMedicine: values.associatedChineseMedicine,
+          categoryId: values.categoryId,
+          name: values.name,
+          remark: values.remark,
+          status: 0,
+        });
+        message.success('新增成功');
+      }
+      emit('success');
+      await modalApi.close();
+    } catch (error: any) {
+      message.error(error.message || '保存失败');
+    } finally {
+      modalApi.unlock();
+    }
+  },
+  onOpenChange(isOpen) {
+    if (!isOpen) return;
+    const data = modalApi.getData<PrescriptionReviewModel.ReviewIndicator>();
+    formData.value = data;
+    formApi.resetForm();
+    if (data?.id) {
+      formApi.setValues({
+        categoryId: data.categoryId,
+        name: data.name,
+        associatedChineseMedicine: data.associatedChineseMedicine,
+        remark: data.remark,
+      });
+    }
+  },
+});
+</script>
+
+<template>
+  <Modal :title="getTitle">
+    <Form class="review-indicator-form-modal__body" />
+  </Modal>
+</template>
+
+<style scoped>
+:deep(.review-indicator-form-modal__header) {
+  background-color: #1677ff;
+  color: #fff;
+  padding: 12px 20px;
+}
+
+:deep(.review-indicator-form-modal__header h2) {
+  color: #fff;
+  font-size: 16px;
+  font-weight: 500;
+}
+
+:deep(.review-indicator-form-modal__header button) {
+  color: #fff;
+  opacity: 0.85;
+}
+
+:deep(.review-indicator-form-modal__header button:hover) {
+  color: #fff;
+  opacity: 1;
+}
+
+:deep(.review-indicator-form-modal__body) {
+  padding: 24px 32px 8px;
+}
+
+:deep(.review-indicator-form-modal__footer) {
+  justify-content: center;
+  gap: 12px;
+  padding: 16px 24px 24px;
+  border-top: none;
+}
+
+:deep(.review-indicator-form-modal__footer button:first-child) {
+  min-width: 88px;
+  color: #1677ff;
+  border-color: #1677ff;
+  background: #fff;
+}
+
+:deep(.review-indicator-form-modal__footer button:first-child:hover) {
+  color: #4096ff;
+  border-color: #4096ff;
+  background: #fff;
+}
+
+:deep(.review-indicator-form-modal__footer button:last-child) {
+  min-width: 88px;
+  background-color: #1677ff;
+  border-color: #1677ff;
+}
+
+:deep(.review-indicator-form-modal__footer button:last-child:hover) {
+  background-color: #4096ff;
+  border-color: #4096ff;
+}
+</style>