Browse Source

feat: 成果管理医案库页面新增

Co-authored-by: Cursor <cursoragent@cursor.com>
cmj 2 days ago
parent
commit
83b7905c5b

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

@@ -1 +1,2 @@
 export * from './illness.api';
+export * from './upload.api';

+ 26 - 0
apps/wisdom-legacy/src/api/common/upload.api.ts

@@ -0,0 +1,26 @@
+import { httpClient } from '#/request';
+import { transform } from '#/request/schema';
+
+interface UploadResultDTO {
+  fileName?: string;
+  url?: string;
+}
+
+function decodeUploadResult(data: unknown): string {
+  if (typeof data === 'string') return data;
+  if (data && typeof data === 'object') {
+    const result = data as UploadResultDTO;
+    return result.url ?? result.fileName ?? '';
+  }
+  return '';
+}
+
+/** 通用文件上传,返回文件访问地址 */
+export function uploadFileMethod(file: File) {
+  const formData = new FormData();
+  formData.append('file', file);
+  return httpClient.Post<string>('/wis-pc/common/upload', formData, {
+    name: 'common:upload',
+    transform: transform(decodeUploadResult),
+  });
+}

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

@@ -0,0 +1 @@
+export * from './medical-case-library.api';

+ 99 - 0
apps/wisdom-legacy/src/api/outcome/medical-case-library.api.ts

@@ -0,0 +1,99 @@
+import type {
+  MedicalCaseLibrarySubmitVO,
+  MedicalCaseLibraryVO,
+} from './medical-case-library.schema';
+
+import type { PageQueryMethodArgs } from '#/request/schema';
+
+import { getEnvelopeData, httpClient } from '#/request';
+import {
+  pageQueryArgsTransform,
+  paginateTransform,
+  transform,
+} from '#/request/schema';
+
+import {
+  decodeMedicalCaseLibrary,
+  encodeMedicalCaseLibrary,
+  encodeMedicalCaseLibraryQuery,
+} from './medical-case-library.schema';
+
+export type {
+  MedicalCaseLibrarySubmitVO,
+  MedicalCaseLibraryVO,
+} from './medical-case-library.schema';
+export { MedicalCaseLibraryVOSchema } from './medical-case-library.schema';
+
+/** 医案库分页列表 */
+export function listMedicalCaseLibraryMethod(...args: PageQueryMethodArgs) {
+  const { params, data } = pageQueryArgsTransform(
+    args,
+    encodeMedicalCaseLibraryQuery,
+  );
+  return httpClient.Post(
+    `/wis-pc/outcome/mlcsManage/page`,
+    { ...params, ...data },
+    {
+      params,
+      hitSource: /^outcome-mlcs:(edit|delete)/,
+      transform: paginateTransform(decodeMedicalCaseLibrary),
+    },
+  );
+}
+
+/** 新增医案库 */
+export function createMedicalCaseLibraryMethod(vo: MedicalCaseLibrarySubmitVO) {
+  return httpClient.Post(
+    `/wis-pc/outcome/mlcsManage/add`,
+    encodeMedicalCaseLibrary(vo),
+    {
+      name: 'outcome-mlcs:edit',
+      transform: getEnvelopeData<null | string>,
+    },
+  );
+}
+
+/** 修改医案库 */
+export function updateMedicalCaseLibraryMethod(vo: MedicalCaseLibrarySubmitVO) {
+  return httpClient.Post(
+    `/wis-pc/outcome/mlcsManage/update`,
+    encodeMedicalCaseLibrary(vo),
+    {
+      name: 'outcome-mlcs:edit',
+      transform: getEnvelopeData<null | string>,
+    },
+  );
+}
+
+/** 新增 / 修改医案库 */
+export function editMedicalCaseLibraryMethod(vo: MedicalCaseLibrarySubmitVO) {
+  return vo.id
+    ? updateMedicalCaseLibraryMethod(vo)
+    : createMedicalCaseLibraryMethod(vo);
+}
+
+/** 医案库详情 */
+export function getMedicalCaseLibraryMethod(vo: Partial<MedicalCaseLibraryVO>) {
+  return httpClient.Post(
+    `/wis-pc/outcome/mlcsManage/detail/${vo.id}`,
+    {},
+    {
+      hitSource: /^outcome-mlcs:edit/,
+      transform: transform(decodeMedicalCaseLibrary),
+    },
+  );
+}
+
+/** 删除医案库 */
+export function deleteMedicalCaseLibraryMethod(
+  vo: Pick<MedicalCaseLibraryVO, 'id'>,
+) {
+  return httpClient.Post(
+    `/wis-pc/outcome/mlcsManage/delete/${vo.id}`,
+    {},
+    {
+      name: 'outcome-mlcs:delete',
+      meta: { ignoreError: true },
+    },
+  );
+}

+ 191 - 0
apps/wisdom-legacy/src/api/outcome/medical-case-library.schema.ts

@@ -0,0 +1,191 @@
+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';
+
+// ---------------------------------------------------------------------------
+// DTO
+// ---------------------------------------------------------------------------
+
+/** 医案库 DTO,对应 `OutcomeMedicalCasesDetail` */
+export interface MedicalCaseLibraryDTO extends AuditRecordDTO {
+  id?: number | string;
+  personalStudioId?: number | string;
+  fileUrl?: string;
+  patientName?: string;
+  patientGender?: '1' | '2';
+  patientAge?: number | string;
+  patientCardno?: string;
+  disease?: string;
+  syndrome?: string;
+  doctorName?: string;
+  firstVisitTime?: string;
+  outcomeVideoExplainId?: number | string;
+  videoName?: string;
+  videoUrl?: string;
+  videoFirstUrl?: string;
+  duration?: string;
+  isHaveLinkVideo?: boolean;
+}
+
+export interface MedicalCaseLibraryQueryDTO {
+  mixture?: string;
+  personalStudioId?: number | string;
+  pageNum?: number;
+  pageSize?: number;
+}
+
+// ---------------------------------------------------------------------------
+// VO
+// ---------------------------------------------------------------------------
+
+export interface MedicalCaseLibraryVO extends AuditRecordVO {
+  id?: string;
+  workroomId: string;
+  patient: {
+    age?: number;
+    gender: '1' | '2';
+    idCard?: string;
+    name: string;
+  };
+  disease: {
+    name: string;
+  };
+  syndrome: {
+    name: string;
+  };
+  doctor: string;
+  firstVisitDate: string;
+  pdfUrl?: string;
+  video?: {
+    name?: string;
+    thumbnailUrl?: string;
+    url?: string;
+  };
+  isHaveLinkVideo?: boolean;
+}
+
+export interface MedicalCaseLibrarySubmitVO extends MedicalCaseLibraryVO {
+  pdfFile?: File;
+  videoFile?: File;
+  videoThumbnailFile?: File;
+}
+
+export interface MedicalCaseLibraryQueryVO {
+  keyword?: string;
+  workroomId?: string;
+}
+
+// ---------------------------------------------------------------------------
+// Schema
+// ---------------------------------------------------------------------------
+
+export const MedicalCaseLibraryVOSchema = z.object({
+  id: z.string().optional(),
+  workroomId: z.string().min(1, '工作室不能为空'),
+  patient: z.object({
+    name: z.string().min(1, '请输入患者姓名'),
+    gender: z.enum(['1', '2'], { message: '请选择性别' }),
+    age: z.number({ message: '请输入年龄' }).int().positive('请输入有效年龄'),
+    idCard: z.string().optional(),
+  }),
+  disease: z.object({
+    name: z.string().min(1, '请选择病名'),
+  }),
+  syndrome: z.object({
+    name: z.string().min(1, '请选择证型'),
+  }),
+  doctor: z.string().min(1, '请输入医生姓名'),
+  firstVisitDate: z.string().min(1, '请选择首次就诊日期'),
+  video: z
+    .object({
+      name: z.string().optional(),
+    })
+    .optional(),
+});
+
+// ---------------------------------------------------------------------------
+// 编解码
+// ---------------------------------------------------------------------------
+
+function parsePatientAge(value?: number | string) {
+  if (value == null || value === '') return void 0;
+  const age = Number(value);
+  return Number.isFinite(age) ? age : void 0;
+}
+
+function formatFirstVisitDate(value?: string) {
+  if (!value) return '';
+  return value.slice(0, 10);
+}
+
+export function decodeMedicalCaseLibrary(
+  dto: MedicalCaseLibraryDTO,
+): MedicalCaseLibraryVO {
+  return {
+    ...decodeAuditRecord(dto),
+    id: dto.id?.toString(),
+    workroomId: dto.personalStudioId?.toString() ?? '',
+    patient: {
+      name: dto.patientName ?? '',
+      gender: dto.patientGender ?? '1',
+      age: parsePatientAge(dto.patientAge),
+      idCard: dto.patientCardno,
+    },
+    disease: {
+      name: dto.disease ?? '',
+    },
+    syndrome: {
+      name: dto.syndrome ?? '',
+    },
+    doctor: dto.doctorName ?? '',
+    firstVisitDate: formatFirstVisitDate(dto.firstVisitTime),
+    pdfUrl: dto.fileUrl,
+    video: {
+      name: dto.videoName,
+      url: dto.videoUrl,
+      thumbnailUrl: dto.videoFirstUrl,
+    },
+    isHaveLinkVideo: dto.isHaveLinkVideo ?? !!dto.videoUrl,
+  };
+}
+
+export function encodeMedicalCaseLibraryQuery(
+  query: Partial<MedicalCaseLibraryQueryVO>,
+): MedicalCaseLibraryQueryDTO {
+  return {
+    mixture: query.keyword,
+    personalStudioId: query.workroomId,
+  };
+}
+
+export function encodeMedicalCaseLibrary(
+  vo: MedicalCaseLibrarySubmitVO,
+): MedicalCaseLibraryDTO {
+  const videoUrl = vo.video?.url;
+  return {
+    id: vo.id,
+    personalStudioId: vo.workroomId,
+    fileUrl: vo.pdfUrl,
+    patientName: vo.patient.name,
+    patientGender: vo.patient.gender,
+    patientAge: vo.patient.age == null ? void 0 : String(vo.patient.age),
+    patientCardno: vo.patient.idCard,
+    disease: vo.disease.name,
+    syndrome: vo.syndrome.name,
+    doctorName: vo.doctor,
+    firstVisitTime: vo.firstVisitDate,
+    videoName: vo.video?.name,
+    videoUrl,
+    videoFirstUrl: vo.video?.thumbnailUrl,
+    isHaveLinkVideo: !!videoUrl,
+  };
+}
+
+export function decodeMedicalCaseLibraryList(dto: MedicalCaseLibraryDTO[]) {
+  return decodeList(dto, decodeMedicalCaseLibrary);
+}

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

@@ -0,0 +1,5 @@
+{
+  "medicalCaseLibrary": {
+    "name": "医案"
+  }
+}

+ 148 - 0
apps/wisdom-legacy/src/router/routes/modules/outcome.route.ts

@@ -0,0 +1,148 @@
+import type { RouteRecordRaw } from 'vue-router';
+
+const placeholder = () => import('#/views/outcome/Placeholder.vue');
+const medicalCaseLibrary = () =>
+  import('#/views/outcome/MedicalCaseLibraryList.vue');
+
+const routes: RouteRecordRaw[] = [
+  {
+    meta: {
+      icon: 'carbon:trophy',
+      order: 2,
+      title: '成果管理',
+    },
+    name: 'Outcome',
+    path: '/outcome',
+    redirect: '/outcome/medical-case-library',
+    children: [
+      {
+        path: '/outcome/medical-case-library',
+        name: 'OutcomeMedicalCaseLibrary',
+        meta: {
+          icon: 'carbon:home',
+          title: '医案库',
+        },
+        component: medicalCaseLibrary,
+      },
+      {
+        path: '/outcome/treatment-plan',
+        name: 'OutcomeTreatmentPlan',
+        meta: {
+          icon: 'carbon:user',
+          title: '诊疗方案',
+        },
+        component: placeholder,
+      },
+      {
+        path: '/outcome/paper',
+        name: 'OutcomePaper',
+        meta: {
+          icon: 'carbon:email',
+          title: '论文',
+        },
+        component: placeholder,
+      },
+      {
+        path: '/outcome/monograph',
+        name: 'OutcomeMonograph',
+        meta: {
+          icon: 'carbon:user',
+          title: '论著',
+        },
+        component: placeholder,
+      },
+      {
+        path: '/outcome/research-report',
+        name: 'OutcomeResearchReport',
+        meta: {
+          icon: 'carbon:user',
+          title: '研究报告',
+        },
+        component: placeholder,
+      },
+      {
+        path: '/outcome/intellectual-property',
+        name: 'OutcomeIntellectualProperty',
+        meta: {
+          icon: 'carbon:user',
+          title: '知识产权',
+        },
+        component: placeholder,
+      },
+      {
+        path: '/outcome/hospital-preparation',
+        name: 'OutcomeHospitalPreparation',
+        meta: {
+          icon: 'carbon:user',
+          title: '院内制剂',
+        },
+        component: placeholder,
+      },
+      {
+        path: '/outcome/new-drug-certificate',
+        name: 'OutcomeNewDrugCertificate',
+        meta: {
+          icon: 'carbon:user',
+          title: '新药证书',
+        },
+        component: placeholder,
+      },
+      {
+        path: '/outcome/continuing-education',
+        name: 'OutcomeContinuingEducation',
+        meta: {
+          icon: 'carbon:user',
+          title: '继续教育项目',
+        },
+        component: placeholder,
+      },
+      {
+        path: '/outcome/video',
+        name: 'OutcomeVideo',
+        meta: {
+          icon: 'carbon:user',
+          title: '视频管理',
+        },
+        component: placeholder,
+      },
+      {
+        path: '/outcome/reading-notes',
+        name: 'OutcomeReadingNotes',
+        meta: {
+          icon: 'carbon:user',
+          title: '读书心得',
+        },
+        component: placeholder,
+      },
+      {
+        path: '/outcome/experience',
+        name: 'OutcomeExperience',
+        meta: {
+          icon: 'carbon:user',
+          title: '经验',
+        },
+        component: placeholder,
+      },
+      {
+        path: '/outcome/award',
+        name: 'OutcomeAward',
+        meta: {
+          icon: 'carbon:user',
+          title: '获奖',
+        },
+        component: placeholder,
+      },
+      {
+        path: '/outcome/medical-talk',
+        name: 'OutcomeMedicalTalk',
+        meta: {
+          icon: 'carbon:user',
+          title: '医话',
+        },
+        component: placeholder,
+      },
+    ],
+  },
+];
+
+export default routes;

+ 87 - 0
apps/wisdom-legacy/src/views/outcome/MedicalCaseLibraryList.vue

@@ -0,0 +1,87 @@
+<script setup lang="ts">
+import type { MedicalCaseLibraryVO } 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 { deleteMedicalCaseLibraryMethod } from '#/api/outcome';
+import { useWorkroomStore } from '#/stores';
+
+import {
+  formatPatientInfo,
+  maskPatientName,
+  medicalCaseLibraryGrid,
+} from './medical-case-library.data';
+import MedicalCaseLibraryEdit from './modules/MedicalCaseLibraryEdit.vue';
+
+const workroomStore = useWorkroomStore();
+const { workroomId } = storeToRefs(workroomStore);
+
+const { Grid, Edit, actions, grid } = useGridPage(medicalCaseLibraryGrid, {
+  params: { workroomId: () => workroomId.value },
+  edit: editModal(MedicalCaseLibraryEdit),
+  delete: deleteMedicalCaseLibraryMethod,
+  view: ({ row }: { row: MedicalCaseLibraryVO }) => {
+    if (row.pdfUrl) {
+      window.open(row.pdfUrl, '_blank');
+    } else {
+      message.warning('暂无PDF文件');
+    }
+    return void 0;
+  },
+});
+
+watch(workroomId, () => grid.reload());
+
+function openVideo(row: MedicalCaseLibraryVO) {
+  if (row.video?.url) {
+    window.open(row.video.url, '_blank');
+  }
+}
+
+function hasLinkedVideo(row: MedicalCaseLibraryVO) {
+  return row.isHaveLinkVideo || !!row.video?.url;
+}
+</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>
+
+      <template #patientInfo="{ row }">
+        <div class="text-left leading-5">
+          <div>{{ maskPatientName(row.patient?.name) }}</div>
+          <div class="text-xs text-gray-400">
+            {{ formatPatientInfo({ row }) }}
+          </div>
+        </div>
+      </template>
+
+      <template #linkedVideo="{ row }">
+        <a-button
+          v-if="hasLinkedVideo(row)"
+          type="link"
+          size="small"
+          class="px-0"
+          @click="openVideo(row)"
+        >
+          有
+        </a-button>
+        <span v-else>无</span>
+      </template>
+    </Grid>
+  </Page>
+</template>

+ 14 - 0
apps/wisdom-legacy/src/views/outcome/Placeholder.vue

@@ -0,0 +1,14 @@
+<script setup lang="ts">
+import { useRoute } from 'vue-router';
+
+import { Page } from '@vben/common-ui';
+
+const route = useRoute();
+const title = (route.meta?.title as string) || '成果管理';
+</script>
+
+<template>
+  <Page :title="title" auto-content-height>
+    <div class="p-4 text-gray-400">页面开发中,敬请期待</div>
+  </Page>
+</template>

+ 229 - 0
apps/wisdom-legacy/src/views/outcome/medical-case-library.data.ts

@@ -0,0 +1,229 @@
+import type { MedicalCaseLibraryVO } from '#/api/outcome';
+
+import { getPopupContainer } from '@vben/utils';
+
+import { defineEditShell } from '#/adapter/shell/edit';
+import { defineGrid } from '#/adapter/vxe-table';
+import { listDiseaseMethod, listSymptomMethod } from '#/api/common';
+import {
+  editMedicalCaseLibraryMethod,
+  getMedicalCaseLibraryMethod,
+  listMedicalCaseLibraryMethod,
+  MedicalCaseLibraryVOSchema,
+} from '#/api/outcome';
+
+const genderOptions = [
+  { label: '男', value: '1' },
+  { label: '女', value: '2' },
+] as const;
+
+function maskPatientName(name?: string) {
+  if (!name) return '';
+  if (name.length <= 1) return `${name}*`;
+  return `${name[0]}${'*'.repeat(name.length - 1)}`;
+}
+
+function formatGender(cellValue?: string) {
+  return genderOptions.find((item) => item.value === cellValue)?.label ?? '';
+}
+
+function formatPatientInfo({ row }: { row: MedicalCaseLibraryVO }) {
+  const gender = formatGender(row.patient?.gender);
+  const age = row.patient?.age == null ? '' : `${row.patient.age}岁`;
+  const profile = [age, gender].filter(Boolean).join(' / ');
+  return profile;
+}
+
+export const medicalCaseLibraryGrid = defineGrid<MedicalCaseLibraryVO>({
+  scope: 'outcome.medicalCaseLibrary',
+  query: listMedicalCaseLibraryMethod,
+  form: {
+    showCollapseButton: false,
+    wrapperClass: 'grid-cols-1',
+  },
+  fields: [
+    {
+      component: 'Input',
+      fieldName: 'keyword',
+      labelWidth: 0,
+      componentProps: {
+        allowClear: true,
+        placeholder: '搜索患者姓名、病名或医生...',
+      },
+    },
+  ],
+  columns: (col) => [
+    col.seq({ title: '编号', width: 80 }),
+    {
+      field: 'patient.name',
+      title: '患者信息',
+      minWidth: 140,
+      align: 'left',
+      slots: { default: 'patientInfo' },
+    },
+    {
+      field: 'disease.name',
+      title: '病名',
+      minWidth: 100,
+    },
+    {
+      field: 'syndrome.name',
+      title: '证型',
+      minWidth: 140,
+    },
+    {
+      field: 'doctor',
+      title: '医生',
+      width: 120,
+    },
+    {
+      field: 'firstVisitDate',
+      title: '首次就诊日期',
+      width: 130,
+    },
+    {
+      field: 'video.url',
+      title: '关联视频',
+      width: 100,
+      slots: { default: 'linkedVideo' },
+    },
+    {
+      field: 'createdAt',
+      title: '创建日期',
+      width: 120,
+      formatter: ({ cellValue }) =>
+        cellValue ? String(cellValue).slice(0, 10) : '',
+    },
+    col.actions(['view', 'edit', 'delete'], 160),
+  ],
+});
+
+export const medicalCaseLibraryForm = defineEditShell<MedicalCaseLibraryVO>({
+  scope: 'outcome.medicalCaseLibrary',
+  title: '医案',
+  submit: editMedicalCaseLibraryMethod,
+  load: getMedicalCaseLibraryMethod,
+  shell: {
+    type: 'modal',
+    class: '!w-[640px]',
+  },
+  form: {
+    layout: 'vertical',
+    wrapperClass: 'grid-cols-2',
+  },
+  schema: [
+    {
+      component: 'Input',
+      fieldName: 'patient.name',
+      label: '患者姓名',
+      formItemClass: 'col-span-2',
+      componentProps: {
+        placeholder: '请输入患者姓名',
+      },
+      rules: MedicalCaseLibraryVOSchema.shape.patient.shape.name,
+    },
+    {
+      component: 'Select',
+      fieldName: 'patient.gender',
+      label: '性别',
+      componentProps: {
+        allowClear: true,
+        options: [...genderOptions],
+        placeholder: '请选择性别',
+        getPopupContainer,
+      },
+      rules: MedicalCaseLibraryVOSchema.shape.patient.shape.gender,
+    },
+    {
+      component: 'InputNumber',
+      fieldName: 'patient.age',
+      label: '年龄',
+      componentProps: {
+        min: 0,
+        max: 150,
+        placeholder: '请输入年龄',
+        class: 'w-full',
+      },
+      rules: MedicalCaseLibraryVOSchema.shape.patient.shape.age,
+    },
+    {
+      component: 'Input',
+      fieldName: 'patient.idCard',
+      label: '身份证号',
+      formItemClass: 'col-span-2',
+      componentProps: {
+        placeholder: '请输入身份证号',
+      },
+    },
+    {
+      component: 'ApiSelectPageList',
+      fieldName: 'disease.name',
+      label: '病名',
+      formItemClass: 'col-span-2',
+      componentProps: {
+        api: listDiseaseMethod,
+        fieldNames: { label: 'name', value: 'name' },
+        placeholder: '请选择病名',
+        getPopupContainer,
+      },
+      rules: MedicalCaseLibraryVOSchema.shape.disease.shape.name,
+    },
+    {
+      component: 'ApiSelectPageList',
+      fieldName: 'syndrome.name',
+      label: '证型',
+      formItemClass: 'col-span-2',
+      componentProps: {
+        api: listSymptomMethod,
+        fieldNames: { label: 'name', value: 'name' },
+        placeholder: '请选择证型',
+        getPopupContainer,
+      },
+      rules: MedicalCaseLibraryVOSchema.shape.syndrome.shape.name,
+    },
+    {
+      component: 'Input',
+      fieldName: 'doctor',
+      label: '医生',
+      formItemClass: 'col-span-2',
+      componentProps: {
+        placeholder: '请输入医生姓名',
+      },
+      rules: MedicalCaseLibraryVOSchema.shape.doctor,
+    },
+    {
+      component: 'DatePicker',
+      fieldName: 'firstVisitDate',
+      label: '首次就诊日期',
+      formItemClass: 'col-span-2',
+      componentProps: {
+        class: 'w-full',
+        format: 'YYYY-MM-DD',
+        placeholder: '年 / 月 / 日',
+        valueFormat: 'YYYY-MM-DD',
+        getPopupContainer,
+      },
+      rules: MedicalCaseLibraryVOSchema.shape.firstVisitDate,
+    },
+    {
+      component: 'Input',
+      fieldName: 'video.name',
+      label: '讲解视频名称',
+      formItemClass: 'col-span-2',
+      componentProps: {
+        placeholder: '请输入',
+      },
+    },
+    {
+      component: 'Input',
+      fieldName: 'workroomId',
+      dependencies: {
+        show: false,
+        triggerFields: ['workroomId'],
+      },
+      rules: MedicalCaseLibraryVOSchema.shape.workroomId,
+    },
+  ],
+});
+
+export { formatGender, formatPatientInfo, maskPatientName };

+ 261 - 0
apps/wisdom-legacy/src/views/outcome/modules/MedicalCaseLibraryEdit.vue

@@ -0,0 +1,261 @@
+<script setup lang="ts">
+import type { UploadFile, UploadProps } from 'ant-design-vue';
+
+import type { MedicalCaseLibrarySubmitVO } from '#/api/outcome';
+
+import { ref } from 'vue';
+
+import {
+  InboxOutlined,
+  PictureOutlined,
+  UploadOutlined,
+} 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 { medicalCaseLibraryForm } from '../medical-case-library.data';
+
+const workroomStore = useWorkroomStore();
+
+const pdfFileList = ref<UploadFile[]>([]);
+const videoFileList = ref<UploadFile[]>([]);
+const thumbnailFileList = ref<UploadFile[]>([]);
+const pdfUrl = ref<string>();
+const videoUrl = ref<string>();
+const videoThumbnailUrl = ref<string>();
+const pdfUploading = ref(false);
+const videoUploading = ref(false);
+const thumbnailUploading = ref(false);
+
+function createUploadFile(url: string | undefined, name: string): UploadFile[] {
+  if (!url) return [];
+  return [
+    {
+      uid: '-1',
+      name,
+      status: 'done',
+      url,
+    },
+  ];
+}
+
+function resetUploads() {
+  pdfFileList.value = [];
+  videoFileList.value = [];
+  thumbnailFileList.value = [];
+  pdfUrl.value = void 0;
+  videoUrl.value = void 0;
+  videoThumbnailUrl.value = void 0;
+  pdfUploading.value = false;
+  videoUploading.value = false;
+  thumbnailUploading.value = false;
+}
+
+async function uploadImmediately(
+  file: File,
+  listRef: typeof pdfFileList,
+  urlRef: typeof pdfUrl,
+  uploadingRef: typeof pdfUploading,
+) {
+  const uploadFile: UploadFile = {
+    uid: `${Date.now()}`,
+    name: file.name,
+    status: 'uploading',
+    percent: 0,
+  };
+  listRef.value = [uploadFile];
+  uploadingRef.value = true;
+
+  try {
+    const url = await invokeMethod(uploadFileMethod(file), { force: true });
+    if (!url) {
+      throw new Error('upload empty url');
+    }
+    urlRef.value = url;
+    listRef.value = [
+      {
+        ...uploadFile,
+        status: 'done',
+        url,
+      },
+    ];
+  } catch {
+    urlRef.value = void 0;
+    listRef.value = [
+      {
+        ...uploadFile,
+        status: 'error',
+      },
+    ];
+    message.error(`${file.name} 上传失败,请重试`);
+  } finally {
+    uploadingRef.value = false;
+  }
+}
+
+const { Form, Shell } = useEditShell<MedicalCaseLibrarySubmitVO>(
+  medicalCaseLibraryForm,
+  {
+    handleLoad(model) {
+      resetUploads();
+      pdfUrl.value = model.pdfUrl;
+      videoUrl.value = model.video?.url;
+      videoThumbnailUrl.value = model.video?.thumbnailUrl;
+      pdfFileList.value = createUploadFile(model.pdfUrl, '医案.pdf');
+      videoFileList.value = createUploadFile(model.video?.url, '讲解视频');
+      thumbnailFileList.value = createUploadFile(
+        model.video?.thumbnailUrl,
+        '视频首图',
+      );
+      return model;
+    },
+    handleSubmit(values) {
+      const workroomId = values.workroomId || workroomStore.workroomId;
+      if (!workroomId) {
+        message.error('请先选择工作室');
+        throw new Error('workroom required');
+      }
+      if (
+        pdfUploading.value ||
+        videoUploading.value ||
+        thumbnailUploading.value
+      ) {
+        message.warning('文件上传中,请稍候');
+        throw new Error('uploading');
+      }
+      if (!pdfUrl.value) {
+        message.error('请上传医案PDF文件');
+        throw new Error('pdf required');
+      }
+
+      return {
+        ...values,
+        workroomId,
+        pdfUrl: pdfUrl.value,
+        video: {
+          ...values.video,
+          url: videoUrl.value,
+          thumbnailUrl: videoThumbnailUrl.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;
+  }
+  void uploadImmediately(file, pdfFileList, pdfUrl, pdfUploading);
+  return false;
+};
+
+const beforeVideoUpload: UploadProps['beforeUpload'] = (file) => {
+  const maxSize = 10 * 1024 * 1024;
+  if (file.size > maxSize) {
+    message.error('上传视频大小不超过10M');
+    return Upload.LIST_IGNORE;
+  }
+  void uploadImmediately(file, videoFileList, videoUrl, videoUploading);
+  return false;
+};
+
+const beforeThumbnailUpload: UploadProps['beforeUpload'] = (file) => {
+  const isImage = file.type.startsWith('image/');
+  if (!isImage) {
+    message.error('请上传图片文件');
+    return Upload.LIST_IGNORE;
+  }
+  void uploadImmediately(
+    file,
+    thumbnailFileList,
+    videoThumbnailUrl,
+    thumbnailUploading,
+  );
+  return false;
+};
+
+function onPdfRemove() {
+  pdfUrl.value = void 0;
+}
+
+function onVideoRemove() {
+  videoUrl.value = void 0;
+}
+
+function onThumbnailRemove() {
+  videoThumbnailUrl.value = void 0;
+}
+</script>
+
+<template>
+  <Shell>
+    <div class="mx-4 max-h-[70vh] overflow-y-auto pr-1">
+      <Form />
+
+      <div class="col-span-2 pb-4">
+        <div class="mb-2 text-sm">
+          <span class="text-red-500">*</span>
+          医案PDF文件
+        </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格式文件</p>
+        </UploadDragger>
+      </div>
+
+      <div class="col-span-2 pb-4">
+        <div class="mb-2 text-sm">讲解视频</div>
+        <Upload
+          v-model:file-list="videoFileList"
+          :before-upload="beforeVideoUpload"
+          :max-count="1"
+          accept="video/*"
+          list-type="picture"
+          @remove="onVideoRemove"
+        >
+          <a-button :loading="videoUploading">
+            <UploadOutlined />
+            上传
+          </a-button>
+          <template #tip>
+            <div class="mt-1 text-xs text-gray-400">上传视频大小不超过10M</div>
+          </template>
+        </Upload>
+      </div>
+
+      <div class="col-span-2 pb-2">
+        <div class="mb-2 text-sm">讲解视频首图</div>
+        <Upload
+          v-model:file-list="thumbnailFileList"
+          :before-upload="beforeThumbnailUpload"
+          :max-count="1"
+          accept="image/*"
+          list-type="picture-card"
+          @remove="onThumbnailRemove"
+        >
+          <div v-if="thumbnailFileList.length === 0" class="text-gray-400">
+            <PictureOutlined class="text-2xl" />
+          </div>
+        </Upload>
+      </div>
+    </div>
+  </Shell>
+</template>

+ 1 - 1
apps/wisdom-legacy/src/views/special-disease/MedicalCaseList.vue

@@ -34,6 +34,6 @@ watch(specialDiseaseId, () => grid.reload());
           {{ $t('ui.actionTitle.create', ['医案']) }}
         </a-button>
       </template>
-</Grid>>
+    </Grid>
   </Page>
 </template>