Browse Source

feat(@six/wisdom-legacy): 成果管理 - 研究报告静态页面新增

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

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

@@ -1,4 +1,5 @@
 export * from './medical-case-library.api';
 export * from './monograph.api';
 export * from './paper.api';
+export * from './research-report.api';
 export * from './treatment-plan.api';

+ 136 - 0
apps/wisdom-legacy/src/api/outcome/research-report.api.ts

@@ -0,0 +1,136 @@
+import type {
+  ResearchReportSubmitVO,
+  ResearchReportVO,
+} from './research-report.schema';
+
+import type { PageQueryMethodArgs } from '#/request/schema';
+
+import { getEnvelopeData, httpClient } from '#/request';
+import {
+  pageQueryArgsTransform,
+  paginateTransform,
+  transform,
+} from '#/request/schema';
+
+import {
+  mockDeleteResearchReportMethod,
+  mockEditResearchReportMethod,
+  mockGetResearchReportMethod,
+  mockListResearchReportMethod,
+  USE_RESEARCH_REPORT_MOCK,
+} from './research-report.mock';
+import {
+  decodeResearchReport,
+  encodeResearchReport,
+  encodeResearchReportQuery,
+} from './research-report.schema';
+
+export { USE_RESEARCH_REPORT_MOCK } from './research-report.mock';
+export type {
+  ResearchReportCategory,
+  ResearchReportQueryVO,
+  ResearchReportStatus,
+  ResearchReportSubmitVO,
+  ResearchReportVO,
+} from './research-report.schema';
+export {
+  formatResearchPeriod,
+  getResearchReportCategoryLabel,
+  getResearchReportStatusColor,
+  getResearchReportStatusLabel,
+  RESEARCH_REPORT_CATEGORY_OPTIONS,
+  RESEARCH_REPORT_STATUS_OPTIONS,
+  ResearchReportVOSchema,
+} from './research-report.schema';
+
+/** 研究报告分页列表 */
+export function listResearchReportMethod(...args: PageQueryMethodArgs) {
+  if (USE_RESEARCH_REPORT_MOCK) {
+    return mockListResearchReportMethod(...args) as any;
+  }
+
+  const { params, data } = pageQueryArgsTransform(
+    args,
+    encodeResearchReportQuery,
+  );
+  return httpClient.Post(
+    `/wis-pc/outcome/researchReportManage/page`,
+    { ...params, ...data },
+    {
+      params,
+      hitSource: /^outcome-research-report:(edit|delete)/,
+      transform: paginateTransform(decodeResearchReport),
+    },
+  );
+}
+
+/** 新增研究报告 */
+export function createResearchReportMethod(vo: ResearchReportSubmitVO) {
+  if (USE_RESEARCH_REPORT_MOCK) {
+    return mockEditResearchReportMethod(vo) as any;
+  }
+
+  return httpClient.Post(
+    `/wis-pc/outcome/researchReportManage/add`,
+    encodeResearchReport(vo),
+    {
+      name: 'outcome-research-report:edit',
+      transform: getEnvelopeData<null | string>,
+    },
+  );
+}
+
+/** 修改研究报告 */
+export function updateResearchReportMethod(vo: ResearchReportSubmitVO) {
+  if (USE_RESEARCH_REPORT_MOCK) {
+    return mockEditResearchReportMethod(vo) as any;
+  }
+
+  return httpClient.Post(
+    `/wis-pc/outcome/researchReportManage/update`,
+    encodeResearchReport(vo),
+    {
+      name: 'outcome-research-report:edit',
+      transform: getEnvelopeData<null | string>,
+    },
+  );
+}
+
+/** 新增 / 修改研究报告 */
+export function editResearchReportMethod(vo: ResearchReportSubmitVO) {
+  return vo.id
+    ? updateResearchReportMethod(vo)
+    : createResearchReportMethod(vo);
+}
+
+/** 研究报告详情 */
+export function getResearchReportMethod(vo: Partial<ResearchReportVO>) {
+  if (USE_RESEARCH_REPORT_MOCK) {
+    return mockGetResearchReportMethod(vo) as any;
+  }
+
+  return httpClient.Post(
+    `/wis-pc/outcome/researchReportManage/detail/${vo.id}`,
+    {},
+    {
+      hitSource: /^outcome-research-report:edit/,
+      transform: transform(decodeResearchReport),
+    },
+  );
+}
+
+/** 删除研究报告 */
+export function deleteResearchReportMethod(vo: Pick<ResearchReportVO, 'id'>) {
+  if (USE_RESEARCH_REPORT_MOCK) {
+    return mockDeleteResearchReportMethod(vo) as any;
+  }
+
+  return httpClient.Post(
+    `/wis-pc/outcome/researchReportManage/delete/${vo.id}`,
+    {},
+    {
+      name: 'outcome-research-report:delete',
+      meta: { ignoreError: true },
+    },
+  );
+}

+ 283 - 0
apps/wisdom-legacy/src/api/outcome/research-report.mock.ts

@@ -0,0 +1,283 @@
+import type {
+  ResearchReportCategory,
+  ResearchReportDTO,
+  ResearchReportStatus,
+  ResearchReportSubmitVO,
+  ResearchReportVO,
+} from './research-report.schema';
+
+import type { PageQueryMethodArgs } from '#/request/schema';
+import type { PageVO } from '#/request/schema/record';
+
+import { pageQueryArgsTransform } from '#/request/schema';
+
+import {
+  decodeResearchReport,
+  encodeResearchReport,
+  encodeResearchReportQuery,
+} from './research-report.schema';
+
+/** 后端接口就绪后改为 false */
+export const USE_RESEARCH_REPORT_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<
+  ResearchReportDTO,
+  'createTime' | 'id' | 'personalStudioId' | 'updateTime'
+>[] = [
+  {
+    status: 'completed',
+    category: 'clinical',
+    title: '中医药治疗慢性心力衰竭的临床研究',
+    leader: '王教授课题组',
+    startDate: '2023-03',
+    endDate: '2025-12',
+    fundingSource: '国家自然科学基金',
+    projectNumber: '82370001',
+    keywords: '中医药;慢性心力衰竭;临床研究',
+    abstract:
+      '本研究旨在评价益气活血类中药复方对慢性心力衰竭患者心功能及生活质量的改善作用,采用随机双盲安慰剂对照试验设计。',
+    objectives:
+      '主要目标:评价中药复方对心衰患者6分钟步行距离的影响;次要目标:评估心功能指标及生活质量评分变化。',
+    methods:
+      '多中心、随机、双盲、安慰剂对照临床试验,纳入符合标准的慢性心力衰竭患者120例,随访12个月。',
+    expectedResults:
+      '预期证实中药复方可显著改善患者心功能,为中医药治疗心衰提供循证依据。',
+    viewCount: 320,
+    createBy: '王刚',
+    fileUrl: MOCK_PDF_URL,
+  },
+  {
+    status: 'in_progress',
+    category: 'basic',
+    title: '中药活性成分的抗炎机制研究',
+    leader: '李研究员团队',
+    startDate: '2024-01',
+    endDate: '2026-06',
+    fundingSource: '省自然科学基金',
+    projectNumber: '2024JZ0012',
+    keywords: '中药;抗炎;机制研究',
+    abstract:
+      '通过体外细胞实验与动物模型,探讨经典方剂中关键活性成分的抗炎信号通路及分子机制。',
+    objectives: '阐明活性成分调控炎症反应的关键靶点与通路。',
+    methods: '细胞培养、Western blot、动物炎症模型及分子生物学检测。',
+    expectedResults: '揭示抗炎机制,为新药研发提供理论基础。',
+    viewCount: 156,
+    createBy: '张许',
+    fileUrl: MOCK_PDF_URL,
+  },
+  {
+    status: 'paused',
+    category: 'epidemiological',
+    title: '某地区中医药服务利用现状流行病学调查',
+    leader: '陈教授课题组',
+    startDate: '2022-09',
+    endDate: '2024-08',
+    fundingSource: '卫健委专项',
+    projectNumber: 'WJ2022-045',
+    keywords: '流行病学;中医药服务;调查',
+    abstract:
+      '对目标地区居民中医药服务利用情况进行横断面调查,分析影响因素与服务可及性。',
+    viewCount: 89,
+    createBy: '李虎',
+    fileUrl: MOCK_PDF_URL,
+  },
+  {
+    status: 'in_progress',
+    category: 'clinical',
+    title: '针灸联合康复训练治疗脑卒中后运动功能障碍研究',
+    leader: '赵主任团队',
+    startDate: '2024-06',
+    endDate: '2027-05',
+    fundingSource: '国家重点研发计划',
+    projectNumber: '2024YFC3600100',
+    keywords: '针灸;脑卒中;康复',
+    abstract:
+      '评价针灸联合现代康复训练对脑卒中后运动功能障碍患者的疗效及安全性。',
+    viewCount: 210,
+    createBy: '王刚',
+    fileUrl: MOCK_PDF_URL,
+  },
+  {
+    status: 'completed',
+    category: 'translational',
+    title: '经典名方现代化制剂转化研究',
+    leader: '孙教授课题组',
+    startDate: '2021-01',
+    endDate: '2023-12',
+    fundingSource: '企业合作课题',
+    projectNumber: 'HZ2021-088',
+    keywords: '经典名方;制剂;转化',
+    abstract:
+      '围绕经典名方开展质量标准建立、工艺优化及临床前研究,推进成果转化。',
+    viewCount: 445,
+    createBy: '张许',
+    fileUrl: MOCK_PDF_URL,
+  },
+];
+
+function createInitialStore(): ResearchReportDTO[] {
+  const records: ResearchReportDTO[] = [];
+  for (let index = 0; index < 15; index += 1) {
+    const seed = SEED_RECORDS[index % SEED_RECORDS.length] ?? SEED_RECORDS[0];
+    if (!seed) continue;
+    const day = String((index % 28) + 1).padStart(2, '0');
+    const month = String((index % 12) + 1).padStart(2, '0');
+    records.push({
+      ...seed,
+      id: String(index + 1),
+      personalStudioId: '327477138296832',
+      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: ResearchReportDTO, keyword?: string) {
+  if (!keyword) return true;
+  const text = [
+    record.title,
+    record.leader,
+    record.keywords,
+    record.abstract,
+    record.projectNumber,
+    record.createBy,
+  ]
+    .filter(Boolean)
+    .join(' ');
+  return text.includes(keyword);
+}
+
+function matchWorkroom(record: ResearchReportDTO, workroomId?: string) {
+  if (!workroomId) return true;
+  return String(record.personalStudioId ?? '') === String(workroomId);
+}
+
+function matchCategory(
+  record: ResearchReportDTO,
+  category?: ResearchReportCategory,
+) {
+  if (!category) return true;
+  return record.category === category;
+}
+
+function matchStatus(record: ResearchReportDTO, status?: ResearchReportStatus) {
+  if (!status) return true;
+  return record.status === status;
+}
+
+function toVo(dto: ResearchReportDTO): ResearchReportVO {
+  return decodeResearchReport(dto);
+}
+
+export function mockListResearchReportMethod(...args: PageQueryMethodArgs) {
+  const { params, data } = pageQueryArgsTransform(
+    args,
+    encodeResearchReportQuery,
+  );
+  const pageNum = Number(params.pageNum ?? 1);
+  const pageSize = Number(params.pageSize ?? 10);
+  const keyword = data.mixture;
+  const workroomId = data.personalStudioId?.toString();
+  const category = data.category;
+  const status = data.status;
+
+  const filtered = store.filter(
+    (record) =>
+      matchKeyword(record, keyword) &&
+      matchWorkroom(record, workroomId) &&
+      matchCategory(record, category) &&
+      matchStatus(record, status),
+  );
+  const start = (pageNum - 1) * pageSize;
+  const items = filtered
+    .slice(start, start + pageSize)
+    .map((record) => toVo(record));
+
+  const result: PageVO<ResearchReportVO> = {
+    total: filtered.length,
+    items,
+  };
+  return delay(() => result);
+}
+
+export function mockGetResearchReportMethod(vo: Partial<ResearchReportVO>) {
+  return delay(() => {
+    const record = store.find((item) => String(item.id) === String(vo.id));
+    if (!record) {
+      throw new Error('研究报告不存在');
+    }
+    return toVo(record);
+  });
+}
+
+export function mockEditResearchReportMethod(vo: ResearchReportSubmitVO) {
+  return delay(() => {
+    const dto = encodeResearchReport(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,
+      viewCount: 0,
+      createBy: dto.createBy ?? '当前用户',
+      createTime: now,
+      updateTime: now,
+    });
+    return id;
+  });
+}
+
+export function mockDeleteResearchReportMethod(
+  vo: Pick<ResearchReportVO, '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 resetResearchReportMockStore() {
+  nextId = 100;
+  store = createInitialStore();
+}

+ 241 - 0
apps/wisdom-legacy/src/api/outcome/research-report.schema.ts

@@ -0,0 +1,241 @@
+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 ResearchReportCategory =
+  | 'basic'
+  | 'clinical'
+  | 'epidemiological'
+  | 'observational'
+  | 'translational';
+
+export type ResearchReportStatus = 'completed' | 'in_progress' | 'paused';
+
+export const RESEARCH_REPORT_CATEGORY_OPTIONS = [
+  { label: '临床研究', value: 'clinical' },
+  { label: '基础研究', value: 'basic' },
+  { label: '流行病学研究', value: 'epidemiological' },
+  { label: '观察性研究', value: 'observational' },
+  { label: '转化医学研究', value: 'translational' },
+] as const satisfies ReadonlyArray<{
+  label: string;
+  value: ResearchReportCategory;
+}>;
+
+export const RESEARCH_REPORT_STATUS_OPTIONS = [
+  { label: '进行中', value: 'in_progress' },
+  { label: '已完成', value: 'completed' },
+  { label: '已暂停', value: 'paused' },
+] as const satisfies ReadonlyArray<{
+  label: string;
+  value: ResearchReportStatus;
+}>;
+
+export function getResearchReportCategoryLabel(
+  category?: ResearchReportCategory,
+) {
+  return (
+    RESEARCH_REPORT_CATEGORY_OPTIONS.find((item) => item.value === category)
+      ?.label ?? ''
+  );
+}
+
+export function getResearchReportStatusLabel(status?: ResearchReportStatus) {
+  return (
+    RESEARCH_REPORT_STATUS_OPTIONS.find((item) => item.value === status)
+      ?.label ?? ''
+  );
+}
+
+export function getResearchReportStatusColor(status?: ResearchReportStatus) {
+  switch (status) {
+    case 'completed': {
+      return 'success';
+    }
+    case 'in_progress': {
+      return 'processing';
+    }
+    case 'paused': {
+      return 'default';
+    }
+    default: {
+      return 'default';
+    }
+  }
+}
+
+export function formatResearchPeriod(
+  startDate?: string,
+  endDate?: string,
+): string {
+  if (!startDate && !endDate) return '';
+  if (startDate && endDate) return `${startDate} 至 ${endDate}`;
+  return startDate ?? endDate ?? '';
+}
+
+// ---------------------------------------------------------------------------
+// DTO
+// ---------------------------------------------------------------------------
+
+export interface ResearchReportDTO extends AuditRecordDTO {
+  id?: number | string;
+  personalStudioId?: number | string;
+  status?: ResearchReportStatus;
+  category?: ResearchReportCategory;
+  title?: string;
+  leader?: string;
+  startDate?: string;
+  endDate?: string;
+  fundingSource?: string;
+  projectNumber?: string;
+  keywords?: string;
+  abstract?: string;
+  objectives?: string;
+  methods?: string;
+  expectedResults?: string;
+  fileUrl?: string;
+  viewCount?: number;
+}
+
+export interface ResearchReportQueryDTO {
+  mixture?: string;
+  personalStudioId?: number | string;
+  category?: ResearchReportCategory;
+  status?: ResearchReportStatus;
+  pageNum?: number;
+  pageSize?: number;
+}
+
+// ---------------------------------------------------------------------------
+// VO
+// ---------------------------------------------------------------------------
+
+export interface ResearchReportVO extends AuditRecordVO {
+  id?: string;
+  workroomId: string;
+  status: ResearchReportStatus;
+  category: ResearchReportCategory;
+  title: string;
+  leader: string;
+  startDate: string;
+  endDate?: string;
+  fundingSource?: string;
+  projectNumber?: string;
+  keywords?: string;
+  abstract: string;
+  objectives?: string;
+  methods?: string;
+  expectedResults?: string;
+  pdfUrl?: string;
+  viewCount?: number;
+}
+
+export type ResearchReportSubmitVO = ResearchReportVO;
+
+export interface ResearchReportQueryVO {
+  keyword?: string;
+  workroomId?: string;
+  category?: ResearchReportCategory;
+  status?: ResearchReportStatus;
+}
+
+// ---------------------------------------------------------------------------
+// Schema
+// ---------------------------------------------------------------------------
+
+export const ResearchReportVOSchema = z.object({
+  id: z.string().optional(),
+  workroomId: z.string().min(1, '工作室不能为空'),
+  status: z.enum(['completed', 'in_progress', 'paused'], {
+    message: '请选择状态',
+  }),
+  category: z.enum(
+    ['clinical', 'basic', 'epidemiological', 'observational', 'translational'],
+    { message: '请选择研究类型' },
+  ),
+  title: z.string().min(1, '请输入报告标题'),
+  leader: z.string().min(1, '请输入负责人'),
+  startDate: z.string().min(1, '请选择开始日期'),
+  endDate: z.string().optional(),
+  fundingSource: z.string().optional(),
+  projectNumber: z.string().optional(),
+  keywords: z.string().optional(),
+  abstract: z.string().min(1, '请输入研究摘要'),
+  objectives: z.string().optional(),
+  methods: z.string().optional(),
+  expectedResults: z.string().optional(),
+});
+
+// ---------------------------------------------------------------------------
+// 编解码
+// ---------------------------------------------------------------------------
+
+export function decodeResearchReport(dto: ResearchReportDTO): ResearchReportVO {
+  return {
+    ...decodeAuditRecord(dto),
+    id: dto.id?.toString(),
+    workroomId: dto.personalStudioId?.toString() ?? '',
+    status: dto.status ?? 'in_progress',
+    category: dto.category ?? 'clinical',
+    title: dto.title ?? '',
+    leader: dto.leader ?? '',
+    startDate: dto.startDate ?? '',
+    endDate: dto.endDate,
+    fundingSource: dto.fundingSource,
+    projectNumber: dto.projectNumber,
+    keywords: dto.keywords,
+    abstract: dto.abstract ?? '',
+    objectives: dto.objectives,
+    methods: dto.methods,
+    expectedResults: dto.expectedResults,
+    pdfUrl: dto.fileUrl,
+    viewCount: dto.viewCount ?? 0,
+  };
+}
+
+export function encodeResearchReportQuery(
+  query: Partial<ResearchReportQueryVO>,
+): ResearchReportQueryDTO {
+  return {
+    mixture: query.keyword,
+    personalStudioId: query.workroomId,
+    category: query.category,
+    status: query.status,
+  };
+}
+
+export function encodeResearchReport(
+  vo: ResearchReportSubmitVO,
+): ResearchReportDTO {
+  return {
+    id: vo.id,
+    personalStudioId: vo.workroomId,
+    status: vo.status,
+    category: vo.category,
+    title: vo.title,
+    leader: vo.leader,
+    startDate: vo.startDate,
+    endDate: vo.endDate,
+    fundingSource: vo.fundingSource,
+    projectNumber: vo.projectNumber,
+    keywords: vo.keywords,
+    abstract: vo.abstract,
+    objectives: vo.objectives,
+    methods: vo.methods,
+    expectedResults: vo.expectedResults,
+    fileUrl: vo.pdfUrl,
+  };
+}
+
+export function decodeResearchReportList(dto: ResearchReportDTO[]) {
+  return decodeList(dto, decodeResearchReport);
+}

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

@@ -10,5 +10,8 @@
   },
   "monograph": {
     "name": "论著"
+  },
+  "researchReport": {
+    "name": "研究报告"
   }
 }

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

@@ -6,6 +6,7 @@ const medicalCaseLibrary = () =>
 const treatmentPlan = () => import('#/views/outcome/TreatmentPlanList.vue');
 const paper = () => import('#/views/outcome/PaperList.vue');
 const monograph = () => import('#/views/outcome/MonographList.vue');
+const researchReport = () => import('#/views/outcome/ResearchReportList.vue');
 
 const routes: RouteRecordRaw[] = [
   {
@@ -61,7 +62,7 @@ const routes: RouteRecordRaw[] = [
           icon: 'carbon:user',
           title: '研究报告',
         },
-        component: placeholder,
+        component: researchReport,
       },
       {
         path: '/outcome/intellectual-property',

+ 250 - 0
apps/wisdom-legacy/src/views/outcome/ResearchReportList.vue

@@ -0,0 +1,250 @@
+<script setup lang="ts">
+import type {
+  ResearchReportCategory,
+  ResearchReportStatus,
+  ResearchReportVO,
+} from '#/api/outcome';
+
+import { computed, ref, shallowRef, triggerRef, watch } from 'vue';
+
+import { Page } from '@vben/common-ui';
+import { Plus } from '@vben/icons';
+
+import {
+  Button,
+  Empty,
+  Input,
+  message,
+  Pagination,
+  Select,
+  Spin,
+} from 'ant-design-vue';
+import { storeToRefs } from 'pinia';
+
+import { useShell } from '#/adapter/shell';
+import { invokeMethod } from '#/adapter/vxe-table/proxy/invoke-method';
+import {
+  deleteResearchReportMethod,
+  listResearchReportMethod,
+  RESEARCH_REPORT_CATEGORY_OPTIONS,
+  RESEARCH_REPORT_STATUS_OPTIONS,
+} from '#/api/outcome';
+import { useWorkroomStore } from '#/stores';
+
+import ResearchReportCard from './components/ResearchReportCard.vue';
+import ResearchReportEdit from './modules/ResearchReportEdit.vue';
+
+const workroomStore = useWorkroomStore();
+const { workroomId } = storeToRefs(workroomStore);
+
+const keyword = ref('');
+const searchKeyword = ref('');
+const category = ref<'' | ResearchReportCategory>('');
+const status = ref<'' | ResearchReportStatus>('');
+const pageNum = ref(1);
+const pageSize = ref(10);
+const loading = ref(false);
+const pageData = ref<{ items: ResearchReportVO[]; total: number }>({
+  total: 0,
+  items: [],
+});
+
+const deletingIds = shallowRef(new Set<string>());
+
+const categoryOptions = [
+  { label: '全部类型', value: '' },
+  ...RESEARCH_REPORT_CATEGORY_OPTIONS,
+];
+
+const statusOptions = [
+  { label: '全部状态', value: '' },
+  ...RESEARCH_REPORT_STATUS_OPTIONS,
+];
+
+async function loadList() {
+  if (!workroomId.value) {
+    pageData.value = { total: 0, items: [] };
+    loading.value = false;
+    return;
+  }
+
+  loading.value = true;
+  try {
+    pageData.value = await invokeMethod(
+      listResearchReportMethod(pageNum.value, pageSize.value, {
+        $filters: [],
+        $sorts: [],
+        keyword: searchKeyword.value || undefined,
+        workroomId: workroomId.value,
+        category: category.value || undefined,
+        status: status.value || undefined,
+      }),
+      { force: true },
+    );
+  } finally {
+    loading.value = false;
+  }
+}
+
+const items = computed(() => pageData.value.items);
+const total = computed(() => pageData.value.total);
+
+const [Edit, editApi] = useShell('modal', {
+  connectedComponent: ResearchReportEdit,
+});
+
+function isDeleting(id?: string) {
+  return id ? deletingIds.value.has(id) : false;
+}
+
+function setDeleting(id: string, value: boolean) {
+  if (value) {
+    deletingIds.value.add(id);
+  } else {
+    deletingIds.value.delete(id);
+  }
+  triggerRef(deletingIds);
+}
+
+async function openCreate() {
+  const result = await editApi
+    .setData({
+      workroomId: workroomId.value,
+      status: 'in_progress',
+      category: 'clinical',
+    } as ResearchReportVO)
+    .open<{ id?: string }>(Promise.withResolvers());
+  if (result?.id) {
+    pageNum.value = 1;
+    await loadList();
+  }
+}
+
+async function openEdit(row: ResearchReportVO) {
+  const result = await editApi
+    .setData(row)
+    .open<{ id?: string }>(Promise.withResolvers());
+  if (result?.id) {
+    await loadList();
+  }
+}
+
+function openView(row: ResearchReportVO) {
+  if (row.pdfUrl) {
+    window.open(row.pdfUrl, '_blank');
+  } else {
+    message.warning('暂无研究文档');
+  }
+}
+
+async function handleDelete(row: ResearchReportVO) {
+  if (!row.id || isDeleting(row.id)) return;
+  setDeleting(row.id, true);
+  try {
+    await invokeMethod(deleteResearchReportMethod(row), { force: true });
+    message.success('删除成功');
+    if (items.value.length <= 1 && pageNum.value > 1) {
+      pageNum.value -= 1;
+    }
+    await loadList();
+  } finally {
+    setDeleting(row.id, false);
+  }
+}
+
+function onSearch() {
+  searchKeyword.value = keyword.value.trim();
+  pageNum.value = 1;
+}
+
+function onPageChange(page: number, size: number) {
+  pageNum.value = page;
+  pageSize.value = size;
+}
+
+watch([category, status], () => {
+  pageNum.value = 1;
+});
+
+watch(workroomId, () => {
+  pageNum.value = 1;
+});
+
+watch(
+  [pageNum, pageSize, searchKeyword, category, status, workroomId],
+  () => {
+    void loadList();
+  },
+  { immediate: true },
+);
+</script>
+
+<template>
+  <Page auto-content-height title="研究报告管理">
+    <Edit />
+
+    <template #extra>
+      <Button type="primary" @click="openCreate()">
+        <Plus class="size-4" />
+        创建报告
+      </Button>
+    </template>
+
+    <div class="flex h-full flex-col gap-4">
+      <div class="flex flex-wrap items-center gap-3">
+        <Input.Search
+          v-model:value="keyword"
+          allow-clear
+          class="min-w-[240px] flex-1"
+          placeholder="搜索报告标题、作者或关键词..."
+          @search="onSearch"
+        />
+        <Select
+          v-model:value="category"
+          :options="categoryOptions"
+          class="w-36"
+        />
+        <Select v-model:value="status" :options="statusOptions" class="w-36" />
+      </div>
+
+      <Spin :spinning="loading">
+        <div class="min-h-48">
+          <Empty
+            v-if="!loading && items.length === 0"
+            :image="Empty.PRESENTED_IMAGE_SIMPLE"
+            description="暂无研究报告数据"
+          >
+            <Button type="primary" @click="openCreate()">创建报告</Button>
+          </Empty>
+
+          <div v-else class="flex flex-col gap-4">
+            <ResearchReportCard
+              v-for="item in items"
+              :key="item.id"
+              :data="item"
+              :deleting="isDeleting(item.id)"
+              @edit="openEdit"
+              @view="openView"
+              @delete="handleDelete"
+            />
+          </div>
+        </div>
+      </Spin>
+
+      <div
+        v-if="total > 0"
+        class="mt-auto flex flex-wrap items-center justify-between gap-3 border-t pt-4"
+      >
+        <span class="text-sm text-foreground/70">共 {{ total }} 条记录</span>
+        <Pagination
+          :current="pageNum"
+          :page-size="pageSize"
+          :total="total"
+          :show-size-changer="false"
+          show-less-items
+          @change="onPageChange"
+        />
+      </div>
+    </div>
+  </Page>
+</template>

+ 107 - 0
apps/wisdom-legacy/src/views/outcome/components/ResearchReportCard.vue

@@ -0,0 +1,107 @@
+<script setup lang="ts">
+import type { ResearchReportVO } from '#/api/outcome';
+
+import {
+  DeleteOutlined,
+  EditOutlined,
+  EyeOutlined,
+  FileTextOutlined,
+} from '@ant-design/icons-vue';
+import { Button, Card, Popconfirm, Tag } from 'ant-design-vue';
+
+import {
+  formatResearchPeriod,
+  getResearchReportCategoryLabel,
+  getResearchReportStatusColor,
+  getResearchReportStatusLabel,
+} from '#/api/outcome';
+
+const { data, deleting } = defineProps<{
+  data: ResearchReportVO;
+  deleting?: boolean;
+}>();
+
+const emit = defineEmits<{
+  delete: [ResearchReportVO];
+  edit: [ResearchReportVO];
+  view: [ResearchReportVO];
+}>();
+</script>
+
+<template>
+  <Card class="research-report-card" :bordered="true">
+    <div class="flex gap-4">
+      <div
+        class="flex size-14 shrink-0 items-center justify-center rounded-lg bg-primary/10"
+      >
+        <FileTextOutlined class="text-2xl text-primary" />
+      </div>
+
+      <div class="min-w-0 flex-1">
+        <div class="mb-2 flex items-start justify-between gap-3">
+          <div class="flex min-w-0 flex-wrap items-center gap-2">
+            <h3 class="text-base font-semibold leading-6 text-foreground">
+              {{ data.title }}
+            </h3>
+            <Tag :color="getResearchReportStatusColor(data.status)">
+              {{ getResearchReportStatusLabel(data.status) }}
+            </Tag>
+          </div>
+          <Tag color="processing" class="shrink-0">
+            {{ getResearchReportCategoryLabel(data.category) }}
+          </Tag>
+        </div>
+
+        <p class="mb-1 text-sm text-foreground/70">负责人:{{ data.leader }}</p>
+
+        <p
+          v-if="formatResearchPeriod(data.startDate, data.endDate)"
+          class="mb-1 text-sm text-foreground/70"
+        >
+          研究周期:{{ formatResearchPeriod(data.startDate, data.endDate) }}
+        </p>
+
+        <p
+          v-if="data.abstract"
+          class="mb-3 line-clamp-2 text-sm text-foreground/55"
+        >
+          {{ data.abstract }}
+        </p>
+
+        <div
+          class="flex flex-wrap items-center justify-end gap-1 border-t pt-3"
+        >
+          <Button type="link" class="!px-2" @click="emit('view', data)">
+            <EyeOutlined />
+            查看详情
+          </Button>
+          <Button
+            type="link"
+            class="!px-2 !text-primary"
+            @click="emit('edit', data)"
+          >
+            <EditOutlined />
+            编辑
+          </Button>
+          <Popconfirm
+            title="确定删除该研究报告吗?"
+            :ok-button-props="{ loading: deleting, danger: true }"
+            :cancel-button-props="{ disabled: deleting }"
+            @confirm="emit('delete', data)"
+          >
+            <Button type="link" danger class="!px-2" :disabled="deleting">
+              <DeleteOutlined />
+              删除
+            </Button>
+          </Popconfirm>
+        </div>
+      </div>
+    </div>
+  </Card>
+</template>
+
+<style scoped>
+.research-report-card :deep(.ant-card-body) {
+  padding: 20px 24px;
+}
+</style>

+ 216 - 0
apps/wisdom-legacy/src/views/outcome/modules/ResearchReportEdit.vue

@@ -0,0 +1,216 @@
+<script setup lang="ts">
+import type { UploadFile, UploadProps } from 'ant-design-vue';
+import type { SelectValue } from 'ant-design-vue/es/select';
+
+import type {
+  ResearchReportStatus,
+  ResearchReportSubmitVO,
+} from '#/api/outcome';
+
+import { ref } from 'vue';
+
+import { getPopupContainer } from '@vben/utils';
+
+import { InboxOutlined } from '@ant-design/icons-vue';
+import { Alert, message, Select, 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 { RESEARCH_REPORT_STATUS_OPTIONS } from '#/api/outcome';
+import { useWorkroomStore } from '#/stores';
+
+import { researchReportForm } from '../research-report.data';
+
+const workroomStore = useWorkroomStore();
+
+const PDF_MAX_SIZE = 50 * 1024 * 1024;
+
+const pdfFileList = ref<UploadFile[]>([]);
+const pdfUrl = ref<string>();
+const pdfUploading = ref(false);
+const statusValue = ref<ResearchReportStatus>('in_progress');
+
+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;
+  statusValue.value = 'in_progress';
+}
+
+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<ResearchReportSubmitVO>(
+  researchReportForm,
+  {
+    onLoaded(model) {
+      api.shell.setState({
+        title: model.id ? '编辑研究报告' : '创建报告',
+      });
+    },
+    handleLoad(model) {
+      resetUploads();
+      pdfUrl.value = model.pdfUrl;
+      pdfFileList.value = createUploadFile(model.pdfUrl, '研究文档.pdf');
+      statusValue.value = model.status ?? 'in_progress';
+      syncStatusToForm(statusValue.value);
+      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');
+      }
+
+      return {
+        ...values,
+        workroomId,
+        pdfUrl: pdfUrl.value,
+      };
+    },
+    onClosed: resetUploads,
+  },
+);
+
+function syncStatusToForm(status: ResearchReportStatus) {
+  void api.form.setFieldValue('status', status);
+}
+
+function onStatusChange(value: SelectValue) {
+  if (typeof value !== 'string') return;
+  statusValue.value = value as ResearchReportStatus;
+  syncStatusToForm(statusValue.value);
+}
+
+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">
+      <div
+        class="mb-4 flex items-center justify-between gap-4 border-b border-border pb-3"
+      >
+        <span class="text-base font-medium text-foreground">基本信息</span>
+        <div class="flex shrink-0 items-center gap-2">
+          <span class="text-sm text-foreground">
+            <span class="text-destructive">*</span>
+            状态
+          </span>
+          <Select
+            :value="statusValue"
+            :options="[...RESEARCH_REPORT_STATUS_OPTIONS]"
+            class="w-32"
+            placeholder="请选择状态"
+            :get-popup-container="getPopupContainer"
+            @update:value="onStatusChange"
+          />
+        </div>
+      </div>
+
+      <Form />
+
+      <div class="pb-4">
+        <h3 class="mb-3 text-base font-semibold text-foreground">相关附件</h3>
+        <div class="mb-2 text-sm">研究文档</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>
+        <p class="mt-2 text-xs text-foreground/55">
+          可上传研究计划书、伦理审查文件、知情同意书等相关文档
+        </p>
+      </div>
+
+      <Alert type="info" show-icon class="mb-4">
+        <template #message>温馨提示</template>
+        <template #description>
+          <ul class="mb-0 list-disc pl-4 text-sm">
+            <li>研究报告创建后可随时编辑和更新</li>
+            <li>建议定期更新研究进展和阶段性成果</li>
+            <li>涉及人体研究的课题需提供伦理审查批准文件</li>
+            <li>请妥善保管研究数据,确保真实性和完整性</li>
+          </ul>
+        </template>
+      </Alert>
+    </div>
+  </Shell>
+</template>

+ 199 - 0
apps/wisdom-legacy/src/views/outcome/research-report.data.ts

@@ -0,0 +1,199 @@
+import type { ResearchReportVO } from '#/api/outcome';
+
+import { getPopupContainer } from '@vben/utils';
+
+import { defineEditShell } from '#/adapter/shell/edit';
+import {
+  editResearchReportMethod,
+  getResearchReportMethod,
+  RESEARCH_REPORT_CATEGORY_OPTIONS,
+  RESEARCH_REPORT_STATUS_OPTIONS,
+  ResearchReportVOSchema,
+} from '#/api/outcome';
+
+export const researchReportForm = defineEditShell<ResearchReportVO>({
+  scope: 'outcome.researchReport',
+  title: '研究报告',
+  submit: editResearchReportMethod,
+  load: getResearchReportMethod,
+  shell: {
+    type: 'modal',
+    class: '!w-[760px]',
+    confirmText: '提交报告',
+  },
+  form: {
+    layout: 'vertical',
+    wrapperClass: 'grid-cols-2',
+  },
+  schema: [
+    {
+      component: 'Select',
+      fieldName: 'status',
+      label: '状态',
+      dependencies: {
+        show: false,
+        triggerFields: ['status'],
+      },
+      componentProps: {
+        options: [...RESEARCH_REPORT_STATUS_OPTIONS],
+        placeholder: '请选择状态',
+        getPopupContainer,
+      },
+      rules: ResearchReportVOSchema.shape.status,
+    },
+    {
+      component: 'Input',
+      fieldName: 'title',
+      label: '报告标题',
+      formItemClass: 'col-span-2',
+      componentProps: {
+        placeholder: '请输入研究报告标题',
+      },
+      rules: ResearchReportVOSchema.shape.title,
+    },
+    {
+      component: 'Input',
+      fieldName: 'leader',
+      label: '负责人',
+      componentProps: {
+        placeholder: '请输入负责人姓名',
+      },
+      rules: ResearchReportVOSchema.shape.leader,
+    },
+    {
+      component: 'Select',
+      fieldName: 'category',
+      label: '研究类型',
+      componentProps: {
+        options: [...RESEARCH_REPORT_CATEGORY_OPTIONS],
+        placeholder: '请选择研究类型',
+        getPopupContainer,
+      },
+      rules: ResearchReportVOSchema.shape.category,
+    },
+    {
+      component: 'DatePicker',
+      fieldName: 'startDate',
+      label: '开始日期',
+      componentProps: {
+        class: 'w-full',
+        format: 'YYYY-MM',
+        picker: 'month',
+        placeholder: '年 / 月',
+        valueFormat: 'YYYY-MM',
+        getPopupContainer,
+      },
+      rules: ResearchReportVOSchema.shape.startDate,
+    },
+    {
+      component: 'DatePicker',
+      fieldName: 'endDate',
+      label: '预计结束日期',
+      componentProps: {
+        class: 'w-full',
+        format: 'YYYY-MM',
+        picker: 'month',
+        placeholder: '年 / 月',
+        valueFormat: 'YYYY-MM',
+        getPopupContainer,
+      },
+      rules: ResearchReportVOSchema.shape.endDate,
+    },
+    {
+      component: 'Input',
+      fieldName: 'fundingSource',
+      label: '资助来源',
+      componentProps: {
+        placeholder: '例如:国家自然科学基金',
+      },
+      rules: ResearchReportVOSchema.shape.fundingSource,
+    },
+    {
+      component: 'Input',
+      fieldName: 'projectNumber',
+      label: '项目编号',
+      componentProps: {
+        placeholder: '请输入项目编号',
+      },
+      rules: ResearchReportVOSchema.shape.projectNumber,
+    },
+    {
+      component: 'Input',
+      fieldName: 'keywords',
+      label: '关键词',
+      formItemClass: 'col-span-2',
+      componentProps: {
+        placeholder: '多个关键词用分号分隔,例如:中医药;临床研究;疗效评价',
+      },
+      rules: ResearchReportVOSchema.shape.keywords,
+    },
+    {
+      component: 'Divider',
+      fieldName: '_section_content',
+      formItemClass: 'col-span-2 !mb-0',
+      hideLabel: true,
+      componentProps: {
+        orientation: 'left',
+        plain: true,
+      },
+      label: '研究内容',
+    },
+    {
+      component: 'Textarea',
+      fieldName: 'abstract',
+      label: '研究摘要',
+      formItemClass: 'col-span-2',
+      componentProps: {
+        placeholder: '请简要概述研究的背景、目的、方法和预期成果...',
+        rows: 4,
+        showCount: false,
+      },
+      rules: ResearchReportVOSchema.shape.abstract,
+    },
+    {
+      component: 'Textarea',
+      fieldName: 'objectives',
+      label: '研究目标',
+      formItemClass: 'col-span-2',
+      componentProps: {
+        placeholder: '列出本研究的主要目标和次要目标...',
+        rows: 3,
+        showCount: false,
+      },
+      rules: ResearchReportVOSchema.shape.objectives,
+    },
+    {
+      component: 'Textarea',
+      fieldName: 'methods',
+      label: '研究方法',
+      formItemClass: 'col-span-2',
+      componentProps: {
+        placeholder: '描述研究设计、样本选择、数据收集和分析方法...',
+        rows: 3,
+        showCount: false,
+      },
+      rules: ResearchReportVOSchema.shape.methods,
+    },
+    {
+      component: 'Textarea',
+      fieldName: 'expectedResults',
+      label: '预期成果',
+      formItemClass: 'col-span-2',
+      componentProps: {
+        placeholder: '描述预期的研究成果、创新点和应用价值...',
+        rows: 3,
+        showCount: false,
+      },
+      rules: ResearchReportVOSchema.shape.expectedResults,
+    },
+    {
+      component: 'Input',
+      fieldName: 'workroomId',
+      dependencies: {
+        show: false,
+        triggerFields: ['workroomId'],
+      },
+      rules: ResearchReportVOSchema.shape.workroomId,
+    },
+  ],
+});