Explorar el Código

feat(@six/wisdom-legacy): 成果管理 - 经验页面新增

cmj hace 1 día
padre
commit
03361658af

+ 124 - 0
apps/wisdom-legacy/src/api/outcome/experience.api.ts

@@ -0,0 +1,124 @@
+import type { ExperienceSubmitVO, ExperienceVO } from './experience.schema';
+
+import type { PageQueryMethodArgs } from '#/request/schema';
+
+import { getEnvelopeData, httpClient } from '#/request';
+import {
+  pageQueryArgsTransform,
+  paginateTransform,
+  transform,
+} from '#/request/schema';
+
+import {
+  mockDeleteExperienceMethod,
+  mockEditExperienceMethod,
+  mockGetExperienceMethod,
+  mockListExperienceMethod,
+  USE_EXPERIENCE_MOCK,
+} from './experience.mock';
+import {
+  decodeExperience,
+  encodeExperience,
+  encodeExperienceQuery,
+} from './experience.schema';
+
+export { USE_EXPERIENCE_MOCK } from './experience.mock';
+export type {
+  ExperienceCategory,
+  ExperienceQueryVO,
+  ExperienceSubmitVO,
+  ExperienceVO,
+} from './experience.schema';
+export {
+  EXPERIENCE_CATEGORY_OPTIONS,
+  ExperienceVOSchema,
+  getExperienceCategoryLabel,
+  parseExperienceTags,
+} from './experience.schema';
+
+/** 经验分页列表 */
+export function listExperienceMethod(...args: PageQueryMethodArgs) {
+  if (USE_EXPERIENCE_MOCK) {
+    return mockListExperienceMethod(...args) as any;
+  }
+
+  const { params, data } = pageQueryArgsTransform(args, encodeExperienceQuery);
+  return httpClient.Post(
+    `/wis-pc/outcome/tyeeManage/page`,
+    { ...params, ...data },
+    {
+      params,
+      hitSource: /^outcome-experience:(edit|delete)/,
+      transform: paginateTransform(decodeExperience),
+    },
+  );
+}
+
+/** 新增经验 */
+export function createExperienceMethod(vo: ExperienceSubmitVO) {
+  if (USE_EXPERIENCE_MOCK) {
+    return mockEditExperienceMethod(vo) as any;
+  }
+
+  return httpClient.Post(
+    `/wis-pc/outcome/tyeeManage/add`,
+    encodeExperience(vo),
+    {
+      name: 'outcome-experience:edit',
+      transform: getEnvelopeData<null | string>,
+    },
+  );
+}
+
+/** 修改经验 */
+export function updateExperienceMethod(vo: ExperienceSubmitVO) {
+  if (USE_EXPERIENCE_MOCK) {
+    return mockEditExperienceMethod(vo) as any;
+  }
+
+  return httpClient.Post(
+    `/wis-pc/outcome/tyeeManage/update`,
+    encodeExperience(vo),
+    {
+      name: 'outcome-experience:edit',
+      transform: getEnvelopeData<null | string>,
+    },
+  );
+}
+
+/** 新增 / 修改经验 */
+export function editExperienceMethod(vo: ExperienceSubmitVO) {
+  return vo.id ? updateExperienceMethod(vo) : createExperienceMethod(vo);
+}
+
+/** 经验详情 */
+export function getExperienceMethod(vo: Partial<ExperienceVO>) {
+  if (USE_EXPERIENCE_MOCK) {
+    return mockGetExperienceMethod(vo) as any;
+  }
+
+  return httpClient.Post(
+    `/wis-pc/outcome/tyeeManage/detail/${vo.id}`,
+    {},
+    {
+      hitSource: /^outcome-experience:edit/,
+      transform: transform(decodeExperience),
+    },
+  );
+}
+
+/** 删除经验 */
+export function deleteExperienceMethod(vo: Pick<ExperienceVO, 'id'>) {
+  if (USE_EXPERIENCE_MOCK) {
+    return mockDeleteExperienceMethod(vo) as any;
+  }
+
+  return httpClient.Post(
+    `/wis-pc/outcome/tyeeManage/delete/${vo.id}`,
+    {},
+    {
+      name: 'outcome-experience:delete',
+      meta: { ignoreError: true },
+    },
+  );
+}

+ 250 - 0
apps/wisdom-legacy/src/api/outcome/experience.mock.ts

@@ -0,0 +1,250 @@
+import type {
+  ExperienceDTO,
+  ExperienceSubmitVO,
+  ExperienceVO,
+} from './experience.schema';
+
+import type { PageQueryMethodArgs } from '#/request/schema';
+import type { PageVO } from '#/request/schema/record';
+
+import { pageQueryArgsTransform } from '#/request/schema';
+
+import {
+  decodeExperience,
+  encodeExperience,
+  encodeExperienceQuery,
+} from './experience.schema';
+
+/** 后端接口就绪后改为 false */
+export const USE_EXPERIENCE_MOCK = false;
+
+type MethodLike<T> = PromiseLike<T> & {
+  send?: (force?: boolean) => PromiseLike<T>;
+};
+
+const SEED_RECORDS: Omit<
+  ExperienceDTO,
+  'createTime' | 'id' | 'personalStudioId' | 'updateTime'
+>[] = [
+  {
+    name: '慢性胃炎辨证施治经验',
+    type: '0',
+    tag: '脾胃病,辨证论治,经方应用',
+    experiencer: '王医师',
+    profile:
+      '慢性胃炎临床多见脾胃虚弱、肝胃不和等证型。辨证时须细察舌脉,分清虚实寒热,灵活运用经方加减,疗效更为显著。',
+    browseCount: 328,
+    createBy: '王医师',
+    fileUrl: '',
+  },
+  {
+    name: '针灸治疗失眠的穴位配伍心得',
+    type: '1',
+    tag: '针灸,失眠,穴位配伍',
+    experiencer: '李主任',
+    profile:
+      '失眠治疗以安神定志为要,配合百会、神门、三阴交等穴位,根据证型加减配穴,临床收效良好。',
+    browseCount: 256,
+    createBy: '李主任',
+    fileUrl: '',
+  },
+  {
+    name: '中医体质调理实践总结',
+    type: '2',
+    tag: '体质辨识,预防保健,调理方案',
+    experiencer: '张教授',
+    profile:
+      '体质辨识是中医预防保健的基础。针对不同体质制定个性化调理方案,可有效改善亚健康状态。',
+    browseCount: 189,
+    createBy: '张教授',
+    fileUrl: '',
+  },
+  {
+    name: '经方治疗咳嗽的临床应用',
+    type: '0',
+    tag: '咳嗽,经方,肺系病',
+    experiencer: '赵医师',
+    profile:
+      '咳嗽治疗须辨明外感内伤、寒热虚实。小青龙汤、麻杏石甘汤等经方在临床中应用广泛,需因人制宜。',
+    browseCount: 142,
+    createBy: '赵医师',
+    fileUrl: '',
+  },
+  {
+    name: '推拿手法治疗颈肩腰腿痛要点',
+    type: '1',
+    tag: '推拿,颈肩腰腿痛,手法',
+    experiencer: '陈医师',
+    profile:
+      '推拿治疗筋骨病证,手法力度与方向是关键。结合局部与远端取穴,配合功能锻炼,可显著提高疗效。',
+    browseCount: 215,
+    createBy: '陈医师',
+    fileUrl: '',
+  },
+  {
+    name: '四季养生与食疗调理经验',
+    type: '2',
+    tag: '四季养生,食疗,体质调理',
+    experiencer: '刘教授',
+    profile:
+      '顺应四时变化调整起居饮食,配合药食同源之品,可达到未病先防、既病防变的目的。',
+    browseCount: 167,
+    createBy: '刘教授',
+    fileUrl: '',
+  },
+];
+
+function createInitialStore(): ExperienceDTO[] {
+  const records: ExperienceDTO[] = [];
+  for (let index = 0; index < 48; 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',
+      browseCount: (seed.browseCount ?? 0) + (index % 20),
+      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: ExperienceDTO, keyword?: string) {
+  if (!keyword) return true;
+  const text = [
+    record.name,
+    record.tag,
+    record.experiencer,
+    record.createBy,
+    record.profile,
+  ]
+    .filter(Boolean)
+    .join(' ');
+  return text.includes(keyword);
+}
+
+function matchWorkroom(record: ExperienceDTO, workroomId?: string) {
+  if (!workroomId) return true;
+  return String(record.personalStudioId ?? '') === String(workroomId);
+}
+
+function matchCategory(record: ExperienceDTO, type?: string) {
+  if (!type) return true;
+  return String(record.type ?? '') === String(type);
+}
+
+function sortRecords(records: ExperienceDTO[]) {
+  return records.toSorted((a, b) => {
+    const timeA = a.createTime ? Date.parse(a.createTime) : 0;
+    const timeB = b.createTime ? Date.parse(b.createTime) : 0;
+    return timeB - timeA;
+  });
+}
+
+function toVo(dto: ExperienceDTO): ExperienceVO {
+  return decodeExperience(dto);
+}
+
+export function mockListExperienceMethod(...args: PageQueryMethodArgs) {
+  const { params, data } = pageQueryArgsTransform(args, encodeExperienceQuery);
+  const pageNum = Number(params.pageNum ?? 1);
+  const pageSize = Number(params.pageSize ?? 20);
+  const keyword = data.mixture;
+  const workroomId = data.personalStudioId?.toString();
+  const type = data.type?.toString();
+
+  const filtered = sortRecords(
+    store.filter(
+      (record) =>
+        matchKeyword(record, keyword) &&
+        matchWorkroom(record, workroomId) &&
+        matchCategory(record, type),
+    ),
+  );
+  const start = (pageNum - 1) * pageSize;
+  const items = filtered
+    .slice(start, start + pageSize)
+    .map((record) => toVo(record));
+
+  const result: PageVO<ExperienceVO> = {
+    total: filtered.length,
+    items,
+  };
+  return delay(() => result);
+}
+
+export function mockGetExperienceMethod(vo: Partial<ExperienceVO>) {
+  return delay(() => {
+    const record = store.find((item) => String(item.id) === String(vo.id));
+    if (!record) {
+      throw new Error('经验不存在');
+    }
+    return toVo(record);
+  });
+}
+
+export function mockEditExperienceMethod(vo: ExperienceSubmitVO) {
+  return delay(() => {
+    const dto = encodeExperience(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,
+      browseCount: 0,
+      createBy: dto.experiencer ?? '当前用户',
+      createTime: now,
+      updateTime: now,
+    });
+    return id;
+  });
+}
+
+export function mockDeleteExperienceMethod(vo: Pick<ExperienceVO, '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 resetExperienceMockStore() {
+  nextId = 100;
+  store = createInitialStore();
+}

+ 201 - 0
apps/wisdom-legacy/src/api/outcome/experience.schema.ts

@@ -0,0 +1,201 @@
+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 ExperienceCategory =
+  | 'clinical_experience'
+  | 'health_care'
+  | 'operational_experience';
+
+/** 后端 `type` 字段,对应字典:智慧传承系统-经验类型 */
+export type ExperienceTypeDTO = '0' | '1' | '2';
+
+const EXPERIENCE_TYPE_TO_DTO: Record<ExperienceCategory, ExperienceTypeDTO> = {
+  clinical_experience: '0',
+  operational_experience: '1',
+  health_care: '2',
+};
+
+const EXPERIENCE_TYPE_FROM_DTO: Record<ExperienceTypeDTO, ExperienceCategory> =
+  {
+    '0': 'clinical_experience',
+    '1': 'operational_experience',
+    '2': 'health_care',
+  };
+
+export const EXPERIENCE_CATEGORY_OPTIONS = [
+  { label: '临床经验', value: 'clinical_experience' },
+  { label: '操作经验', value: 'operational_experience' },
+  { label: '养生保健', value: 'health_care' },
+] as const satisfies ReadonlyArray<{
+  label: string;
+  value: ExperienceCategory;
+}>;
+
+export function getExperienceCategoryLabel(category?: ExperienceCategory) {
+  return (
+    EXPERIENCE_CATEGORY_OPTIONS.find((item) => item.value === category)
+      ?.label ?? ''
+  );
+}
+
+export function decodeExperienceType(
+  type?: number | string,
+): ExperienceCategory {
+  const normalized = type?.toString();
+  if (normalized === '0' || normalized === '1' || normalized === '2') {
+    return EXPERIENCE_TYPE_FROM_DTO[normalized];
+  }
+  return 'clinical_experience';
+}
+
+function encodeExperienceType(category: ExperienceCategory): ExperienceTypeDTO {
+  return EXPERIENCE_TYPE_TO_DTO[category];
+}
+
+export function parseExperienceTags(tags?: string): string[] {
+  if (!tags) return [];
+  return tags
+    .split(/[,,]/)
+    .map((item) => item.trim())
+    .filter(Boolean);
+}
+
+export function formatExperienceTags(tags?: string | string[]): string {
+  if (Array.isArray(tags)) {
+    return tags.filter(Boolean).join(',');
+  }
+  return tags?.trim() ?? '';
+}
+
+// ---------------------------------------------------------------------------
+// DTO
+// ---------------------------------------------------------------------------
+
+/** 治疗经验 DTO,对应 `OutcomeTherapyExperienceDetail` */
+export interface ExperienceDTO extends AuditRecordDTO {
+  id?: number | string;
+  status?: string;
+  remark?: string;
+  personalStudioId?: number | string;
+  fileUrl?: string;
+  downloadCount?: number;
+  browseCount?: number;
+  commentCount?: number;
+  praiseCount?: number;
+  /** 经验类型,详见字典:智慧传承系统-经验类型 */
+  type?: ExperienceTypeDTO | string;
+  name?: string;
+  /** 经验标签,多个标签用逗号分隔 */
+  tag?: string;
+  experiencer?: string;
+  profile?: string;
+}
+
+export interface ExperienceQueryDTO {
+  mixture?: string;
+  personalStudioId?: number | string;
+  type?: ExperienceTypeDTO | string;
+  status?: string;
+  pageNum?: number;
+  pageSize?: number;
+}
+
+// ---------------------------------------------------------------------------
+// VO
+// ---------------------------------------------------------------------------
+
+export interface ExperienceVO extends AuditRecordVO {
+  id?: string;
+  workroomId: string;
+  title: string;
+  category: ExperienceCategory;
+  tags?: string;
+  author?: string;
+  content: string;
+  fileUrl?: string;
+  viewCount?: number;
+}
+
+export type ExperienceSubmitVO = ExperienceVO;
+
+export interface ExperienceQueryVO {
+  keyword?: string;
+  workroomId?: string;
+  category?: ExperienceCategory;
+}
+
+// ---------------------------------------------------------------------------
+// Schema
+// ---------------------------------------------------------------------------
+
+export const ExperienceVOSchema = z.object({
+  id: z.string().optional(),
+  workroomId: z.string().min(1, '工作室不能为空'),
+  title: z.string().min(1, '请输入经验名称'),
+  category: z.enum(
+    ['clinical_experience', 'operational_experience', 'health_care'],
+    {
+      message: '请选择经验分类',
+    },
+  ),
+  tags: z.string().optional(),
+  author: z.string().min(1, '请输入经验人'),
+  content: z.string().min(1, '请输入经验介绍'),
+  fileUrl: z.string().optional(),
+});
+
+// ---------------------------------------------------------------------------
+// 编解码
+// ---------------------------------------------------------------------------
+
+export function decodeExperience(dto: ExperienceDTO): ExperienceVO {
+  return {
+    ...decodeAuditRecord(dto),
+    id: dto.id?.toString(),
+    workroomId: dto.personalStudioId?.toString() ?? '',
+    title: dto.name ?? '',
+    category: decodeExperienceType(dto.type),
+    tags: dto.tag,
+    author: dto.experiencer,
+    content: dto.profile ?? '',
+    fileUrl: dto.fileUrl,
+    viewCount: dto.browseCount ?? 0,
+  };
+}
+
+export function encodeExperienceQuery(
+  query: Partial<ExperienceQueryVO>,
+): ExperienceQueryDTO {
+  return {
+    mixture: query.keyword,
+    personalStudioId: query.workroomId,
+    type: query.category ? encodeExperienceType(query.category) : undefined,
+  };
+}
+
+export function encodeExperience(vo: ExperienceSubmitVO): ExperienceDTO {
+  return {
+    id: vo.id,
+    personalStudioId: vo.workroomId,
+    fileUrl: vo.fileUrl ?? '',
+    type: encodeExperienceType(vo.category),
+    name: vo.title,
+    tag: formatExperienceTags(vo.tags),
+    experiencer: vo.author,
+    profile: vo.content,
+  };
+}
+
+export function decodeExperienceList(dto: ExperienceDTO[]) {
+  return decodeList(dto, decodeExperience);
+}

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

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

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

@@ -15,6 +15,7 @@ const newDrugCertificate = () =>
   import('#/views/outcome/NewDrugCertificateList.vue');
 const video = () => import('#/views/outcome/VideoList.vue');
 const readingNote = () => import('#/views/outcome/ReadingNoteList.vue');
+const experience = () => import('#/views/outcome/ExperienceList.vue');
 
 const routes: RouteRecordRaw[] = [
   {
@@ -133,7 +134,7 @@ const routes: RouteRecordRaw[] = [
           icon: 'carbon:user',
           title: '经验',
         },
-        component: placeholder,
+        component: experience,
       },
       {
         path: '/outcome/award',

+ 282 - 0
apps/wisdom-legacy/src/views/outcome/ExperienceList.vue

@@ -0,0 +1,282 @@
+<script setup lang="ts">
+import type { ExperienceCategory, ExperienceVO } from '#/api/outcome';
+
+import { computed, h, ref, shallowRef, triggerRef, watch } from 'vue';
+
+import { Page } from '@vben/common-ui';
+import { Plus } from '@vben/icons';
+
+import { watchDebounced } from '@vueuse/core';
+import {
+  Button,
+  Empty,
+  Input,
+  message,
+  Modal,
+  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 {
+  deleteExperienceMethod,
+  EXPERIENCE_CATEGORY_OPTIONS,
+  getExperienceCategoryLabel,
+  listExperienceMethod,
+  parseExperienceTags,
+} from '#/api/outcome';
+import { useWorkroomStore } from '#/stores';
+
+import ExperienceCard from './components/ExperienceCard.vue';
+import ExperienceEdit from './modules/ExperienceEdit.vue';
+
+const workroomStore = useWorkroomStore();
+const { workroomId } = storeToRefs(workroomStore);
+
+const keyword = ref('');
+const searchKeyword = ref('');
+const category = ref<'' | ExperienceCategory>('');
+const pageNum = ref(1);
+const pageSize = ref(20);
+const loading = ref(false);
+const pageData = ref<{ items: ExperienceVO[]; total: number }>({
+  total: 0,
+  items: [],
+});
+
+const deletingIds = shallowRef(new Set<string>());
+
+const categoryOptions = [
+  { label: '全部类别', value: '' },
+  ...EXPERIENCE_CATEGORY_OPTIONS,
+];
+
+async function loadList() {
+  if (!workroomId.value) {
+    pageData.value = { total: 0, items: [] };
+    loading.value = false;
+    return;
+  }
+
+  loading.value = true;
+  try {
+    pageData.value = await invokeMethod(
+      listExperienceMethod(pageNum.value, pageSize.value, {
+        $filters: [],
+        $sorts: [],
+        keyword: searchKeyword.value || undefined,
+        workroomId: workroomId.value,
+        category: category.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: ExperienceEdit,
+});
+
+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,
+      category: 'clinical_experience',
+    } as ExperienceVO)
+    .open<{ id?: string }>(Promise.withResolvers());
+  if (result?.id) {
+    pageNum.value = 1;
+    await loadList();
+  }
+}
+
+async function openEdit(row: ExperienceVO) {
+  const result = await editApi
+    .setData(row)
+    .open<{ id?: string }>(Promise.withResolvers());
+  if (result?.id) {
+    await loadList();
+  }
+}
+
+function formatDate(value?: string) {
+  if (!value) return '';
+  return String(value).slice(0, 10);
+}
+
+function openView(row: ExperienceVO) {
+  if (row.fileUrl) {
+    window.open(row.fileUrl, '_blank');
+    return;
+  }
+
+  const tags = parseExperienceTags(row.tags);
+  const meta = [
+    getExperienceCategoryLabel(row.category),
+    row.author ? `经验人:${row.author}` : '',
+    row.createdAt ? formatDate(row.createdAt) : '',
+  ]
+    .filter(Boolean)
+    .join(' · ');
+
+  Modal.info({
+    title: row.title,
+    width: 640,
+    okText: '关闭',
+    content: h('div', { class: 'pt-2' }, [
+      meta ? h('p', { class: 'mb-3 text-sm text-foreground/70' }, meta) : null,
+      tags.length > 0
+        ? h(
+            'p',
+            { class: 'mb-4 text-sm text-foreground/70' },
+            `标签:${tags.join('、')}`,
+          )
+        : null,
+      h(
+        'div',
+        { class: 'whitespace-pre-wrap text-sm leading-7 text-foreground/85' },
+        row.content || '暂无内容',
+      ),
+    ]),
+  });
+}
+
+async function handleDelete(row: ExperienceVO) {
+  if (!row.id || isDeleting(row.id)) return;
+  setDeleting(row.id, true);
+  try {
+    await invokeMethod(deleteExperienceMethod(row), { force: true });
+    message.success('删除成功');
+    if (items.value.length <= 1 && pageNum.value > 1) {
+      pageNum.value -= 1;
+    }
+    await loadList();
+  } finally {
+    setDeleting(row.id, false);
+  }
+}
+
+function applySearch(value: string) {
+  const next = value.trim();
+  if (next === searchKeyword.value) return;
+  searchKeyword.value = next;
+  pageNum.value = 1;
+}
+
+watchDebounced(keyword, applySearch, { debounce: 300 });
+
+function onSearch() {
+  applySearch(keyword.value);
+}
+
+function onPageChange(page: number, size: number) {
+  pageNum.value = page;
+  pageSize.value = size;
+}
+
+watch(category, () => {
+  pageNum.value = 1;
+});
+
+watch(workroomId, () => {
+  pageNum.value = 1;
+});
+
+watch(
+  [pageNum, pageSize, searchKeyword, category, 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"
+        />
+      </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">
+            <ExperienceCard
+              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>

+ 152 - 0
apps/wisdom-legacy/src/views/outcome/components/ExperienceCard.vue

@@ -0,0 +1,152 @@
+<script setup lang="ts">
+import type { ExperienceVO } from '#/api/outcome';
+
+import { computed } from 'vue';
+
+import {
+  DeleteOutlined,
+  EditOutlined,
+  EyeOutlined,
+  FolderOutlined,
+  MedicineBoxOutlined,
+} from '@ant-design/icons-vue';
+import { Button, Card, Popconfirm, Tag } from 'ant-design-vue';
+
+import { getExperienceCategoryLabel, parseExperienceTags } from '#/api/outcome';
+
+const { data, deleting } = defineProps<{
+  data: ExperienceVO;
+  deleting?: boolean;
+}>();
+
+const emit = defineEmits<{
+  delete: [ExperienceVO];
+  edit: [ExperienceVO];
+  view: [ExperienceVO];
+}>();
+
+function formatDate(value?: string) {
+  if (!value) return '';
+  return String(value).slice(0, 10);
+}
+
+const tagList = computed(() => parseExperienceTags(data.tags));
+</script>
+
+<template>
+  <Card class="experience-card" :bordered="true">
+    <div class="flex gap-4">
+      <div
+        class="flex size-14 shrink-0 items-center justify-center rounded-lg bg-orange-100"
+      >
+        <MedicineBoxOutlined class="text-2xl text-orange-500" />
+      </div>
+
+      <div class="min-w-0 flex-1">
+        <div class="mb-2 flex items-start justify-between gap-3">
+          <h3
+            class="text-base font-semibold leading-6 text-foreground"
+            :title="data.title"
+          >
+            {{ data.title }}
+          </h3>
+          <Tag class="experience-category-tag shrink-0">
+            {{ getExperienceCategoryLabel(data.category) }}
+          </Tag>
+        </div>
+
+        <div
+          v-if="tagList.length > 0"
+          class="mb-2 flex flex-wrap items-center gap-2"
+        >
+          <Tag
+            v-for="tag in tagList"
+            :key="tag"
+            class="experience-tag m-0 border-0 bg-muted text-foreground/70"
+          >
+            <FolderOutlined class="mr-1" />
+            {{ tag }}
+          </Tag>
+          <span
+            class="ml-auto inline-flex shrink-0 items-center gap-1 text-sm text-foreground/55"
+          >
+            <EyeOutlined />
+            {{ data.viewCount ?? 0 }} 阅读
+          </span>
+        </div>
+        <div v-else class="mb-2 flex justify-end text-sm text-foreground/55">
+          <span class="inline-flex items-center gap-1">
+            <EyeOutlined />
+            {{ data.viewCount ?? 0 }} 阅读
+          </span>
+        </div>
+
+        <p
+          v-if="data.content"
+          class="mb-3 line-clamp-2 text-sm text-foreground/55"
+        >
+          {{ data.content }}
+        </p>
+
+        <div
+          class="flex flex-wrap items-center justify-between gap-2 border-t pt-3"
+        >
+          <span class="text-sm text-foreground/55">
+            <template v-if="data.author">{{ data.author }}</template>
+            <template v-if="data.author && data.createdAt">
+              <span class="mx-1 text-foreground/40">·</span>
+            </template>
+            <template v-if="data.createdAt">
+              {{ formatDate(data.createdAt) }}
+            </template>
+          </span>
+
+          <div class="flex flex-wrap items-center gap-1">
+            <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-text="确定"
+              cancel-text="取消"
+              :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>
+    </div>
+  </Card>
+</template>
+
+<style scoped>
+.experience-card :deep(.ant-card-body) {
+  padding: 20px 24px;
+}
+
+.experience-category-tag {
+  margin: 0;
+  color: hsl(var(--primary));
+  background: hsl(var(--primary) / 8%);
+  border-color: hsl(var(--primary) / 35%);
+}
+
+.experience-tag :deep(.anticon) {
+  font-size: 12px;
+}
+</style>

+ 88 - 0
apps/wisdom-legacy/src/views/outcome/experience.data.ts

@@ -0,0 +1,88 @@
+import type { ExperienceVO } from '#/api/outcome';
+
+import { getPopupContainer } from '@vben/utils';
+
+import { defineEditShell } from '#/adapter/shell/edit';
+import {
+  editExperienceMethod,
+  EXPERIENCE_CATEGORY_OPTIONS,
+  ExperienceVOSchema,
+  getExperienceMethod,
+} from '#/api/outcome';
+
+export const experienceForm = defineEditShell<ExperienceVO>({
+  scope: 'outcome.experience',
+  title: '经验',
+  submit: editExperienceMethod,
+  load: getExperienceMethod,
+  shell: {
+    type: 'modal',
+    class: '!w-[640px]',
+    confirmText: '确定',
+  },
+  form: {
+    layout: 'vertical',
+    wrapperClass: 'grid-cols-1',
+  },
+  schema: [
+    {
+      component: 'Input',
+      fieldName: 'title',
+      label: '经验名称',
+      componentProps: {
+        placeholder: '请输入',
+      },
+      rules: ExperienceVOSchema.shape.title,
+    },
+    {
+      component: 'Select',
+      fieldName: 'category',
+      label: '经验分类',
+      defaultValue: 'clinical_experience',
+      componentProps: {
+        class: 'w-full',
+        options: [...EXPERIENCE_CATEGORY_OPTIONS],
+        placeholder: '请选择经验分类',
+        getPopupContainer,
+      },
+      rules: ExperienceVOSchema.shape.category,
+    },
+    {
+      component: 'Input',
+      fieldName: 'tags',
+      label: '经验标签',
+      componentProps: {
+        placeholder: '请输入标签,多个标签用逗号隔开',
+      },
+    },
+    {
+      component: 'Input',
+      fieldName: 'author',
+      label: '经验人',
+      componentProps: {
+        placeholder: '请输入',
+      },
+      rules: ExperienceVOSchema.shape.author,
+    },
+    {
+      component: 'Textarea',
+      fieldName: 'content',
+      label: '经验介绍',
+      componentProps: {
+        placeholder: '请输入',
+        rows: 6,
+        showCount: false,
+      },
+      rules: ExperienceVOSchema.shape.content,
+    },
+    {
+      component: 'Input',
+      fieldName: 'workroomId',
+      dependencies: {
+        show: false,
+        triggerFields: ['workroomId'],
+      },
+      rules: ExperienceVOSchema.shape.workroomId,
+    },
+  ],
+});

+ 165 - 0
apps/wisdom-legacy/src/views/outcome/modules/ExperienceEdit.vue

@@ -0,0 +1,165 @@
+<script setup lang="ts">
+import type { UploadFile, UploadProps } from 'ant-design-vue';
+
+import type { ExperienceSubmitVO } 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 { experienceForm } from '../experience.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<ExperienceSubmitVO>(experienceForm, {
+  onLoaded(model) {
+    api.shell.setState({
+      title: model.id ? '编辑经验' : '添加经验',
+    });
+  },
+  handleLoad(model) {
+    resetUploads();
+    pdfUrl.value = model.fileUrl;
+    pdfFileList.value = createUploadFile(model.fileUrl, '附件.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('请上传附件PDF文件');
+      throw new Error('pdf required');
+    }
+
+    return {
+      ...values,
+      workroomId,
+      fileUrl: 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>