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

feat(@six/wisdom-legacy): 成果管理 -院内制剂静态页面新增

Co-authored-by: Cursor <cursoragent@cursor.com>
cmj преди 2 дни
родител
ревизия
b8de9d60c7

+ 140 - 0
apps/wisdom-legacy/src/api/outcome/hospital-preparation.api.ts

@@ -0,0 +1,140 @@
+import type {
+  HospitalPreparationSubmitVO,
+  HospitalPreparationVO,
+} from './hospital-preparation.schema';
+
+import type { PageQueryMethodArgs } from '#/request/schema';
+
+import { getEnvelopeData, httpClient } from '#/request';
+import {
+  pageQueryArgsTransform,
+  paginateTransform,
+  transform,
+} from '#/request/schema';
+
+import {
+  mockDeleteHospitalPreparationMethod,
+  mockEditHospitalPreparationMethod,
+  mockGetHospitalPreparationMethod,
+  mockListHospitalPreparationMethod,
+  USE_HOSPITAL_PREPARATION_MOCK,
+} from './hospital-preparation.mock';
+import {
+  decodeHospitalPreparation,
+  encodeHospitalPreparation,
+  encodeHospitalPreparationQuery,
+} from './hospital-preparation.schema';
+
+export { USE_HOSPITAL_PREPARATION_MOCK } from './hospital-preparation.mock';
+export type {
+  DosageForm,
+  HospitalPreparationQueryVO,
+  HospitalPreparationSubmitVO,
+  HospitalPreparationVO,
+} from './hospital-preparation.schema';
+export {
+  DOSAGE_FORM_OPTIONS,
+  getDosageFormLabel,
+  HospitalPreparationVOSchema,
+  PREPARATION_STATUS_OPTIONS,
+} from './hospital-preparation.schema';
+
+/** 院内制剂分页列表 */
+export function listHospitalPreparationMethod(...args: PageQueryMethodArgs) {
+  if (USE_HOSPITAL_PREPARATION_MOCK) {
+    return mockListHospitalPreparationMethod(...args) as any;
+  }
+
+  const { params, data } = pageQueryArgsTransform(
+    args,
+    encodeHospitalPreparationQuery,
+  );
+  return httpClient.Post(
+    `/wis-pc/outcome/hospitalPreparationManage/page`,
+    { ...params, ...data },
+    {
+      params,
+      hitSource: /^outcome-hospital-preparation:(edit|delete)/,
+      transform: paginateTransform(decodeHospitalPreparation),
+    },
+  );
+}
+
+/** 新增院内制剂 */
+export function createHospitalPreparationMethod(
+  vo: HospitalPreparationSubmitVO,
+) {
+  if (USE_HOSPITAL_PREPARATION_MOCK) {
+    return mockEditHospitalPreparationMethod(vo) as any;
+  }
+
+  return httpClient.Post(
+    `/wis-pc/outcome/hospitalPreparationManage/add`,
+    encodeHospitalPreparation(vo),
+    {
+      name: 'outcome-hospital-preparation:edit',
+      transform: getEnvelopeData<null | string>,
+    },
+  );
+}
+
+/** 修改院内制剂 */
+export function updateHospitalPreparationMethod(
+  vo: HospitalPreparationSubmitVO,
+) {
+  if (USE_HOSPITAL_PREPARATION_MOCK) {
+    return mockEditHospitalPreparationMethod(vo) as any;
+  }
+
+  return httpClient.Post(
+    `/wis-pc/outcome/hospitalPreparationManage/update`,
+    encodeHospitalPreparation(vo),
+    {
+      name: 'outcome-hospital-preparation:edit',
+      transform: getEnvelopeData<null | string>,
+    },
+  );
+}
+
+/** 新增 / 修改院内制剂 */
+export function editHospitalPreparationMethod(vo: HospitalPreparationSubmitVO) {
+  return vo.id
+    ? updateHospitalPreparationMethod(vo)
+    : createHospitalPreparationMethod(vo);
+}
+
+/** 院内制剂详情 */
+export function getHospitalPreparationMethod(
+  vo: Partial<HospitalPreparationVO>,
+) {
+  if (USE_HOSPITAL_PREPARATION_MOCK) {
+    return mockGetHospitalPreparationMethod(vo) as any;
+  }
+
+  return httpClient.Post(
+    `/wis-pc/outcome/hospitalPreparationManage/detail/${vo.id}`,
+    {},
+    {
+      hitSource: /^outcome-hospital-preparation:edit/,
+      transform: transform(decodeHospitalPreparation),
+    },
+  );
+}
+
+/** 删除院内制剂 */
+export function deleteHospitalPreparationMethod(
+  vo: Pick<HospitalPreparationVO, 'id'>,
+) {
+  if (USE_HOSPITAL_PREPARATION_MOCK) {
+    return mockDeleteHospitalPreparationMethod(vo) as any;
+  }
+
+  return httpClient.Post(
+    `/wis-pc/outcome/hospitalPreparationManage/delete/${vo.id}`,
+    {},
+    {
+      name: 'outcome-hospital-preparation:delete',
+      meta: { ignoreError: true },
+    },
+  );
+}

+ 258 - 0
apps/wisdom-legacy/src/api/outcome/hospital-preparation.mock.ts

@@ -0,0 +1,258 @@
+import type {
+  DosageForm,
+  HospitalPreparationDTO,
+  HospitalPreparationSubmitVO,
+  HospitalPreparationVO,
+} from './hospital-preparation.schema';
+
+import type { PageQueryMethodArgs } from '#/request/schema';
+import type { PageVO } from '#/request/schema/record';
+
+import { pageQueryArgsTransform } from '#/request/schema';
+
+import {
+  decodeHospitalPreparation,
+  encodeHospitalPreparation,
+  encodeHospitalPreparationQuery,
+} from './hospital-preparation.schema';
+
+/** 后端接口就绪后改为 false */
+export const USE_HOSPITAL_PREPARATION_MOCK = true;
+
+type MethodLike<T> = PromiseLike<T> & {
+  send?: (force?: boolean) => PromiseLike<T>;
+};
+
+const MOCK_PDF_URL =
+  'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf';
+
+const SEED_RECORDS: Omit<
+  HospitalPreparationDTO,
+  'createTime' | 'id' | 'personalStudioId' | 'updateTime'
+>[] = [
+  {
+    preparationNumber: 'YN2023001',
+    name: '健脾和胃颗粒',
+    dosageForm: 'granules',
+    mainIngredients: '党参、白术、茯苓、甘草等',
+    indications: '用于脾胃虚弱,食少便溏',
+    approvalDate: '2023-06-15',
+    status: true,
+    fileUrl: MOCK_PDF_URL,
+  },
+  {
+    preparationNumber: 'YN2024002',
+    name: '清肝明目丸',
+    dosageForm: 'pills',
+    mainIngredients: '菊花、决明子、枸杞子、熟地黄等',
+    indications: '用于肝火上炎,目赤肿痛',
+    approvalDate: '2024-03-20',
+    status: true,
+    fileUrl: MOCK_PDF_URL,
+  },
+  {
+    preparationNumber: 'YN2022003',
+    name: '止咳化痰口服液',
+    dosageForm: 'oral_liquid',
+    mainIngredients: '川贝、枇杷叶、桔梗、甘草等',
+    indications: '用于咳嗽痰多,胸闷气促',
+    approvalDate: '2022-11-10',
+    status: false,
+    fileUrl: MOCK_PDF_URL,
+  },
+  {
+    preparationNumber: 'YN2023004',
+    name: '温阳散寒汤',
+    dosageForm: 'decoction',
+    mainIngredients: '附子、干姜、肉桂、甘草等',
+    indications: '用于阳虚寒凝,四肢不温',
+    approvalDate: '2023-09-08',
+    status: true,
+    fileUrl: MOCK_PDF_URL,
+  },
+  {
+    preparationNumber: 'YN2024005',
+    name: '润肤止痒膏',
+    dosageForm: 'cream',
+    mainIngredients: '苦参、白鲜皮、地肤子、冰片等',
+    indications: '用于湿疹瘙痒,皮肤干燥',
+    approvalDate: '2024-01-12',
+    status: true,
+    fileUrl: MOCK_PDF_URL,
+  },
+  {
+    preparationNumber: 'YN2023006',
+    name: '益气复脉颗粒',
+    dosageForm: 'granules',
+    mainIngredients: '黄芪、党参、麦冬、五味子',
+    indications: '用于气阴两虚,心悸气短',
+    approvalDate: '2023-04-22',
+    status: false,
+    fileUrl: MOCK_PDF_URL,
+  },
+];
+
+function createInitialStore(): HospitalPreparationDTO[] {
+  const records: HospitalPreparationDTO[] = [];
+  for (let index = 0; index < 18; index += 1) {
+    const seed = SEED_RECORDS[index % SEED_RECORDS.length] ?? SEED_RECORDS[0];
+    if (!seed) continue;
+    const year = 2022 + (index % 3);
+    const month = String((index % 12) + 1).padStart(2, '0');
+    const day = String((index % 28) + 1).padStart(2, '0');
+    records.push({
+      ...seed,
+      id: String(index + 1),
+      personalStudioId: '327477138296832',
+      preparationNumber: `YN${year}${String(index + 1).padStart(3, '0')}`,
+      approvalDate: `${year}-${month}-${day}`,
+      createTime: `2026-${month}-${day}T10:00:00`,
+      updateTime: `2026-05-${day}T10:00:00`,
+    });
+  }
+  return records;
+}
+
+let nextId = 100;
+let store = createInitialStore();
+
+function delay<T>(runner: () => Promise<T> | T, ms = 120): MethodLike<T> {
+  const run = async () => {
+    await new Promise((resolve) => setTimeout(resolve, ms));
+    return runner();
+  };
+  const promise = run();
+  return Object.assign(promise, { send: run });
+}
+
+function matchKeyword(record: HospitalPreparationDTO, keyword?: string) {
+  if (!keyword) return true;
+  const text = [
+    record.name,
+    record.preparationNumber,
+    record.mainIngredients,
+    record.indications,
+  ]
+    .filter(Boolean)
+    .join(' ');
+  return text.includes(keyword);
+}
+
+function matchWorkroom(record: HospitalPreparationDTO, workroomId?: string) {
+  if (!workroomId) return true;
+  return String(record.personalStudioId ?? '') === String(workroomId);
+}
+
+function matchDosageForm(
+  record: HospitalPreparationDTO,
+  dosageForm?: DosageForm,
+) {
+  if (!dosageForm) return true;
+  return record.dosageForm === dosageForm;
+}
+
+function matchStatus(record: HospitalPreparationDTO, status?: boolean) {
+  if (status === undefined) return true;
+  return record.status === status;
+}
+
+function toVo(dto: HospitalPreparationDTO): HospitalPreparationVO {
+  return decodeHospitalPreparation(dto);
+}
+
+export function mockListHospitalPreparationMethod(
+  ...args: PageQueryMethodArgs
+) {
+  const { params, data } = pageQueryArgsTransform(
+    args,
+    encodeHospitalPreparationQuery,
+  );
+  const pageNum = Number(params.pageNum ?? 1);
+  const pageSize = Number(params.pageSize ?? 10);
+  const keyword = data.mixture;
+  const workroomId = data.personalStudioId?.toString();
+  const dosageForm = data.dosageForm;
+  const status = data.status;
+
+  const filtered = store.filter(
+    (record) =>
+      matchKeyword(record, keyword) &&
+      matchWorkroom(record, workroomId) &&
+      matchDosageForm(record, dosageForm) &&
+      matchStatus(record, status),
+  );
+  const start = (pageNum - 1) * pageSize;
+  const items = filtered
+    .slice(start, start + pageSize)
+    .map((record) => toVo(record));
+
+  const result: PageVO<HospitalPreparationVO> = {
+    total: filtered.length,
+    items,
+  };
+  return delay(() => result);
+}
+
+export function mockGetHospitalPreparationMethod(
+  vo: Partial<HospitalPreparationVO>,
+) {
+  return delay(() => {
+    const record = store.find((item) => String(item.id) === String(vo.id));
+    if (!record) {
+      throw new Error('院内制剂不存在');
+    }
+    return toVo(record);
+  });
+}
+
+export function mockEditHospitalPreparationMethod(
+  vo: HospitalPreparationSubmitVO,
+) {
+  return delay(() => {
+    const dto = encodeHospitalPreparation(vo);
+    const now = new Date().toISOString();
+
+    if (vo.id) {
+      const index = store.findIndex(
+        (item) => String(item.id) === String(vo.id),
+      );
+      if (index === -1) {
+        throw new Error('院内制剂不存在');
+      }
+      store[index] = {
+        ...store[index],
+        ...dto,
+        updateTime: now,
+      };
+      return String(vo.id);
+    }
+
+    const id = String(nextId++);
+    store.unshift({
+      ...dto,
+      id,
+      createTime: now,
+      updateTime: now,
+    });
+    return id;
+  });
+}
+
+export function mockDeleteHospitalPreparationMethod(
+  vo: Pick<HospitalPreparationVO, 'id'>,
+) {
+  return delay(() => {
+    const before = store.length;
+    store = store.filter((item) => String(item.id) !== String(vo.id));
+    if (store.length === before) {
+      throw new Error('院内制剂不存在');
+    }
+    return null;
+  });
+}
+
+/** 仅用于本地调试,重置 mock 数据 */
+export function resetHospitalPreparationMockStore() {
+  nextId = 100;
+  store = createInitialStore();
+}

+ 173 - 0
apps/wisdom-legacy/src/api/outcome/hospital-preparation.schema.ts

@@ -0,0 +1,173 @@
+import type {
+  AuditRecordDTO,
+  AuditRecordVO,
+} from '#/request/schema/audit-record';
+
+import { z } from '#/adapter/form';
+import { decodeList } from '#/request/schema';
+import { decodeAuditRecord } from '#/request/schema/audit-record';
+
+// ---------------------------------------------------------------------------
+// 枚举
+// ---------------------------------------------------------------------------
+
+export type DosageForm =
+  | 'cream'
+  | 'decoction'
+  | 'granules'
+  | 'oral_liquid'
+  | 'pills';
+
+export const DOSAGE_FORM_OPTIONS = [
+  { label: '颗粒剂', value: 'granules', color: 'processing' },
+  { label: '丸剂', value: 'pills', color: 'processing' },
+  { label: '口服液', value: 'oral_liquid', color: 'processing' },
+  { label: '汤剂', value: 'decoction', color: 'processing' },
+  { label: '膏剂', value: 'cream', color: 'processing' },
+] as const satisfies ReadonlyArray<{
+  color: string;
+  label: string;
+  value: DosageForm;
+}>;
+
+export const PREPARATION_STATUS_OPTIONS = [
+  { label: '启用', value: true, color: 'success' },
+  { label: '禁用', value: false, color: 'default' },
+] as const satisfies ReadonlyArray<{
+  color: string;
+  label: string;
+  value: boolean;
+}>;
+
+export function getDosageFormLabel(form?: DosageForm) {
+  return DOSAGE_FORM_OPTIONS.find((item) => item.value === form)?.label ?? '';
+}
+
+// ---------------------------------------------------------------------------
+// DTO
+// ---------------------------------------------------------------------------
+
+export interface HospitalPreparationDTO extends AuditRecordDTO {
+  id?: number | string;
+  personalStudioId?: number | string;
+  preparationNumber?: string;
+  name?: string;
+  dosageForm?: DosageForm;
+  mainIngredients?: string;
+  indications?: string;
+  approvalDate?: string;
+  status?: boolean;
+  fileUrl?: string;
+}
+
+export interface HospitalPreparationQueryDTO {
+  mixture?: string;
+  personalStudioId?: number | string;
+  dosageForm?: DosageForm;
+  status?: boolean;
+  pageNum?: number;
+  pageSize?: number;
+}
+
+// ---------------------------------------------------------------------------
+// VO
+// ---------------------------------------------------------------------------
+
+export interface HospitalPreparationVO extends AuditRecordVO {
+  id?: string;
+  workroomId: string;
+  preparationNumber: string;
+  name: string;
+  dosageForm: DosageForm;
+  mainIngredients: string;
+  indications: string;
+  approvalDate: string;
+  status: boolean;
+  pdfUrl?: string;
+}
+
+export type HospitalPreparationSubmitVO = HospitalPreparationVO;
+
+export interface HospitalPreparationQueryVO {
+  keyword?: string;
+  workroomId?: string;
+  dosageForm?: DosageForm;
+  status?: boolean;
+}
+
+// ---------------------------------------------------------------------------
+// Schema
+// ---------------------------------------------------------------------------
+
+export const HospitalPreparationVOSchema = z.object({
+  id: z.string().optional(),
+  workroomId: z.string().min(1, '工作室不能为空'),
+  preparationNumber: z.string().min(1, '请输入院内制剂编号'),
+  name: z.string().min(1, '请输入院内制剂名称'),
+  dosageForm: z.enum(
+    ['granules', 'pills', 'oral_liquid', 'decoction', 'cream'],
+    { message: '请选择剂型' },
+  ),
+  mainIngredients: z.string().min(1, '请输入制剂主要成分'),
+  indications: z.string().min(1, '请输入制剂的适应症'),
+  approvalDate: z.string().min(1, '请选择批准日期'),
+  status: z.boolean(),
+});
+
+// ---------------------------------------------------------------------------
+// 编解码
+// ---------------------------------------------------------------------------
+
+export function decodeHospitalPreparation(
+  dto: HospitalPreparationDTO,
+): HospitalPreparationVO {
+  return {
+    ...decodeAuditRecord(dto),
+    id: dto.id?.toString(),
+    workroomId: dto.personalStudioId?.toString() ?? '',
+    preparationNumber: dto.preparationNumber ?? '',
+    name: dto.name ?? '',
+    dosageForm: dto.dosageForm ?? 'granules',
+    mainIngredients: dto.mainIngredients ?? '',
+    indications: dto.indications ?? '',
+    approvalDate: dto.approvalDate ?? '',
+    status: dto.status ?? true,
+    pdfUrl: dto.fileUrl,
+  };
+}
+
+export function encodeHospitalPreparationQuery(
+  query: Partial<HospitalPreparationQueryVO & Record<string, unknown>>,
+): HospitalPreparationQueryDTO {
+  const status = query.status as boolean | string | undefined;
+  return {
+    mixture: query.keyword || undefined,
+    personalStudioId: query.workroomId,
+    dosageForm: query.dosageForm || undefined,
+    status:
+      status === '' || status === undefined || status === null
+        ? undefined
+        : Boolean(status),
+  };
+}
+
+export function encodeHospitalPreparation(
+  vo: HospitalPreparationSubmitVO,
+): HospitalPreparationDTO {
+  return {
+    id: vo.id,
+    personalStudioId: vo.workroomId,
+    preparationNumber: vo.preparationNumber,
+    name: vo.name,
+    dosageForm: vo.dosageForm,
+    mainIngredients: vo.mainIngredients,
+    indications: vo.indications,
+    approvalDate: vo.approvalDate,
+    status: vo.status,
+    fileUrl: vo.pdfUrl,
+  };
+}
+
+export function decodeHospitalPreparationList(dto: HospitalPreparationDTO[]) {
+  return decodeList(dto, decodeHospitalPreparation);
+}

+ 1 - 0
apps/wisdom-legacy/src/api/outcome/index.ts

@@ -1,3 +1,4 @@
+export * from './hospital-preparation.api';
 export * from './intellectual-property.api';
 export * from './medical-case-library.api';
 export * from './monograph.api';

+ 3 - 0
apps/wisdom-legacy/src/locales/langs/zh-CN/outcome.json

@@ -16,5 +16,8 @@
   },
   "intellectualProperty": {
     "name": "知识产权"
+  },
+  "hospitalPreparation": {
+    "name": "院内制剂"
   }
 }

+ 3 - 1
apps/wisdom-legacy/src/router/routes/modules/outcome.route.ts

@@ -9,6 +9,8 @@ const monograph = () => import('#/views/outcome/MonographList.vue');
 const researchReport = () => import('#/views/outcome/ResearchReportList.vue');
 const intellectualProperty = () =>
   import('#/views/outcome/IntellectualPropertyList.vue');
+const hospitalPreparation = () =>
+  import('#/views/outcome/HospitalPreparationList.vue');
 
 const routes: RouteRecordRaw[] = [
   {
@@ -82,7 +84,7 @@ const routes: RouteRecordRaw[] = [
           icon: 'carbon:user',
           title: '院内制剂',
         },
-        component: placeholder,
+        component: hospitalPreparation,
       },
       {
         path: '/outcome/new-drug-certificate',

+ 51 - 0
apps/wisdom-legacy/src/views/outcome/HospitalPreparationList.vue

@@ -0,0 +1,51 @@
+<script setup lang="ts">
+import type { HospitalPreparationVO } from '#/api/outcome';
+
+import { watch } from 'vue';
+
+import { Page } from '@vben/common-ui';
+import { Plus } from '@vben/icons';
+
+import { message } from 'ant-design-vue';
+import { storeToRefs } from 'pinia';
+
+import { editModal, useGridPage } from '#/adapter/vxe-table';
+import { deleteHospitalPreparationMethod } from '#/api/outcome';
+import { useWorkroomStore } from '#/stores';
+
+import { hospitalPreparationGrid } from './hospital-preparation.data';
+import HospitalPreparationEdit from './modules/HospitalPreparationEdit.vue';
+
+const workroomStore = useWorkroomStore();
+const { workroomId } = storeToRefs(workroomStore);
+
+const { Grid, Edit, actions, grid } = useGridPage(hospitalPreparationGrid, {
+  params: { workroomId: () => workroomId.value },
+  edit: editModal(HospitalPreparationEdit),
+  delete: deleteHospitalPreparationMethod,
+  view: ({ row }: { row: HospitalPreparationVO }) => {
+    if (row.pdfUrl) {
+      window.open(row.pdfUrl, '_blank');
+    } else {
+      message.warning('暂无附件');
+    }
+    return void 0;
+  },
+});
+
+watch(workroomId, () => grid.reload());
+</script>
+
+<template>
+  <Page auto-content-height>
+    <Edit />
+    <Grid table-title="院内制剂管理">
+      <template #toolbar-tools>
+        <a-button type="primary" @click="actions.create()">
+          <Plus class="size-5" />
+          添加制剂
+        </a-button>
+      </template>
+    </Grid>
+  </Page>
+</template>

+ 208 - 0
apps/wisdom-legacy/src/views/outcome/hospital-preparation.data.ts

@@ -0,0 +1,208 @@
+import type { HospitalPreparationVO } from '#/api/outcome';
+
+import { getPopupContainer } from '@vben/utils';
+
+import { defineEditShell } from '#/adapter/shell/edit';
+import { defineGrid, defineTagRender } from '#/adapter/vxe-table';
+import {
+  DOSAGE_FORM_OPTIONS,
+  editHospitalPreparationMethod,
+  getHospitalPreparationMethod,
+  HospitalPreparationVOSchema,
+  listHospitalPreparationMethod,
+  PREPARATION_STATUS_OPTIONS,
+} from '#/api/outcome';
+
+const dosageFormFilterOptions = [
+  { label: '全部类别', value: '' },
+  ...DOSAGE_FORM_OPTIONS.map(({ label, value }) => ({ label, value })),
+];
+
+const statusFilterOptions = [
+  { label: '全部状态', value: '' },
+  { label: '启用', value: true },
+  { label: '禁用', value: false },
+];
+
+export const hospitalPreparationGrid = defineGrid<HospitalPreparationVO>({
+  scope: 'outcome.hospitalPreparation',
+  query: listHospitalPreparationMethod,
+  form: {
+    showCollapseButton: false,
+    wrapperClass: 'grid-cols-1 md:grid-cols-[1fr_auto_auto]',
+  },
+  fields: [
+    {
+      component: 'Input',
+      fieldName: 'keyword',
+      labelWidth: 0,
+      componentProps: {
+        allowClear: true,
+        placeholder: '搜索制剂名称、编号或适应症...',
+      },
+    },
+    {
+      component: 'Select',
+      fieldName: 'dosageForm',
+      labelWidth: 0,
+      componentProps: {
+        allowClear: true,
+        options: dosageFormFilterOptions,
+        placeholder: '全部类别',
+        getPopupContainer,
+      },
+    },
+    {
+      component: 'Select',
+      fieldName: 'status',
+      labelWidth: 0,
+      componentProps: {
+        allowClear: true,
+        options: statusFilterOptions,
+        placeholder: '全部状态',
+        getPopupContainer,
+      },
+    },
+  ],
+  columns: (col) => [
+    {
+      field: 'preparationNumber',
+      title: '编号',
+      width: 120,
+    },
+    {
+      field: 'name',
+      title: '制剂名称',
+      minWidth: 140,
+    },
+    {
+      field: 'dosageForm',
+      title: '剂型',
+      width: 100,
+      cellRender: defineTagRender([...DOSAGE_FORM_OPTIONS]),
+    },
+    {
+      field: 'mainIngredients',
+      title: '主要成分',
+      minWidth: 180,
+    },
+    {
+      field: 'indications',
+      title: '适应症',
+      minWidth: 180,
+    },
+    {
+      field: 'approvalDate',
+      title: '批准日期',
+      width: 120,
+    },
+    {
+      field: 'status',
+      title: '状态',
+      width: 90,
+      cellRender: defineTagRender([...PREPARATION_STATUS_OPTIONS]),
+    },
+    col.actions(['view', 'edit', 'delete'], 160),
+  ],
+});
+
+export const hospitalPreparationForm = defineEditShell<HospitalPreparationVO>({
+  scope: 'outcome.hospitalPreparation',
+  title: '院内制剂',
+  submit: editHospitalPreparationMethod,
+  load: getHospitalPreparationMethod,
+  shell: {
+    type: 'modal',
+    class: '!w-[560px]',
+    confirmText: '确定',
+  },
+  form: {
+    layout: 'vertical',
+    wrapperClass: 'grid-cols-1',
+  },
+  schema: [
+    {
+      component: 'Input',
+      fieldName: 'preparationNumber',
+      label: '编号',
+      componentProps: {
+        placeholder: '请输入院内制剂编号',
+      },
+      rules: HospitalPreparationVOSchema.shape.preparationNumber,
+    },
+    {
+      component: 'Input',
+      fieldName: 'name',
+      label: '制剂名称',
+      componentProps: {
+        placeholder: '请输入院内制剂名称',
+      },
+      rules: HospitalPreparationVOSchema.shape.name,
+    },
+    {
+      component: 'Select',
+      fieldName: 'dosageForm',
+      label: '剂型',
+      defaultValue: 'granules',
+      componentProps: {
+        options: [...DOSAGE_FORM_OPTIONS],
+        placeholder: '请选择剂型',
+        getPopupContainer,
+      },
+      rules: HospitalPreparationVOSchema.shape.dosageForm,
+    },
+    {
+      component: 'Textarea',
+      fieldName: 'mainIngredients',
+      label: '主要成分',
+      componentProps: {
+        placeholder: '请输入制剂主要成分',
+        rows: 3,
+      },
+      rules: HospitalPreparationVOSchema.shape.mainIngredients,
+    },
+    {
+      component: 'Textarea',
+      fieldName: 'indications',
+      label: '适应症',
+      componentProps: {
+        placeholder: '请输入制剂的适应症',
+        rows: 3,
+      },
+      rules: HospitalPreparationVOSchema.shape.indications,
+    },
+    {
+      component: 'DatePicker',
+      fieldName: 'approvalDate',
+      label: '批准日期',
+      componentProps: {
+        class: 'w-full',
+        format: 'YYYY/MM/DD',
+        placeholder: '年/月/日',
+        valueFormat: 'YYYY-MM-DD',
+        getPopupContainer,
+      },
+      rules: HospitalPreparationVOSchema.shape.approvalDate,
+    },
+    {
+      component: 'Switch',
+      fieldName: 'status',
+      label: '状态',
+      defaultValue: true,
+      componentProps: {
+        checkedChildren: '启用',
+        unCheckedChildren: '禁用',
+      },
+      rules: HospitalPreparationVOSchema.shape.status,
+    },
+    {
+      component: 'Input',
+      fieldName: 'workroomId',
+      dependencies: {
+        show: false,
+        triggerFields: ['workroomId'],
+      },
+      rules: HospitalPreparationVOSchema.shape.workroomId,
+    },
+  ],
+});

+ 168 - 0
apps/wisdom-legacy/src/views/outcome/modules/HospitalPreparationEdit.vue

@@ -0,0 +1,168 @@
+<script setup lang="ts">
+import type { UploadFile, UploadProps } from 'ant-design-vue';
+
+import type { HospitalPreparationSubmitVO } from '#/api/outcome';
+
+import { ref } from 'vue';
+
+import { InboxOutlined } from '@ant-design/icons-vue';
+import { message, Upload, UploadDragger } from 'ant-design-vue';
+
+import { useEditShell } from '#/adapter/shell';
+import { invokeMethod } from '#/adapter/vxe-table/proxy/invoke-method';
+import { uploadFileMethod } from '#/api/common';
+import { useWorkroomStore } from '#/stores';
+
+import { hospitalPreparationForm } from '../hospital-preparation.data';
+
+const workroomStore = useWorkroomStore();
+
+const PDF_MAX_SIZE = 50 * 1024 * 1024;
+
+const pdfFileList = ref<UploadFile[]>([]);
+const pdfUrl = ref<string>();
+const pdfUploading = ref(false);
+
+function createUploadFile(url: string | undefined, name: string): UploadFile[] {
+  if (!url) return [];
+  return [
+    {
+      uid: '-1',
+      name,
+      status: 'done',
+      url,
+    },
+  ];
+}
+
+function resetUploads() {
+  pdfFileList.value = [];
+  pdfUrl.value = void 0;
+  pdfUploading.value = false;
+}
+
+async function uploadImmediately(file: File) {
+  const uploadFile: UploadFile = {
+    uid: `${Date.now()}`,
+    name: file.name,
+    status: 'uploading',
+    percent: 0,
+  };
+  pdfFileList.value = [uploadFile];
+  pdfUploading.value = true;
+
+  try {
+    const url = await invokeMethod(uploadFileMethod(file), { force: true });
+    if (!url) {
+      throw new Error('upload empty url');
+    }
+    pdfUrl.value = url;
+    pdfFileList.value = [
+      {
+        ...uploadFile,
+        status: 'done',
+        url,
+      },
+    ];
+  } catch {
+    pdfUrl.value = void 0;
+    pdfFileList.value = [
+      {
+        ...uploadFile,
+        status: 'error',
+      },
+    ];
+    message.error(`${file.name} 上传失败,请重试`);
+  } finally {
+    pdfUploading.value = false;
+  }
+}
+
+const { Form, Shell, api } = useEditShell<HospitalPreparationSubmitVO>(
+  hospitalPreparationForm,
+  {
+    onLoaded(model) {
+      api.shell.setState({
+        title: model.id ? '编辑院内制剂' : '添加院内制剂',
+      });
+    },
+    handleLoad(model) {
+      resetUploads();
+      pdfUrl.value = model.pdfUrl;
+      pdfFileList.value = createUploadFile(model.pdfUrl, '制剂附件.pdf');
+      return model;
+    },
+    handleSubmit(values) {
+      const workroomId = values.workroomId || workroomStore.workroomId;
+      if (!workroomId) {
+        message.error('请先选择工作室');
+        throw new Error('workroom required');
+      }
+      if (pdfUploading.value) {
+        message.warning('文件上传中,请稍候');
+        throw new Error('uploading');
+      }
+      if (!pdfUrl.value) {
+        message.error('请上传制剂附件');
+        throw new Error('pdf required');
+      }
+
+      return {
+        ...values,
+        workroomId,
+        pdfUrl: pdfUrl.value,
+      };
+    },
+    onClosed: resetUploads,
+  },
+);
+
+const beforePdfUpload: UploadProps['beforeUpload'] = (file) => {
+  const isPdf =
+    file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf');
+  if (!isPdf) {
+    message.error('仅支持PDF格式文件');
+    return Upload.LIST_IGNORE;
+  }
+  if (file.size > PDF_MAX_SIZE) {
+    message.error('文件大小不能超过50MB');
+    return Upload.LIST_IGNORE;
+  }
+  void uploadImmediately(file);
+  return false;
+};
+
+function onPdfRemove() {
+  pdfUrl.value = void 0;
+}
+</script>
+
+<template>
+  <Shell>
+    <div class="mx-4">
+      <Form />
+
+      <div class="pb-4">
+        <div class="mb-2 text-sm">
+          <span class="text-destructive mr-1">*</span>
+          附件
+        </div>
+        <UploadDragger
+          v-model:file-list="pdfFileList"
+          :before-upload="beforePdfUpload"
+          :max-count="1"
+          accept=".pdf,application/pdf"
+          @remove="onPdfRemove"
+        >
+          <p class="ant-upload-drag-icon">
+            <InboxOutlined />
+          </p>
+          <p class="ant-upload-text">点击上传或拖拽PDF文件到此处</p>
+          <p class="ant-upload-hint">
+            仅支持PDF格式文件,建议文件大小不超过50MB
+          </p>
+        </UploadDragger>
+      </div>
+    </div>
+  </Shell>
+</template>