Browse Source

feat(@six/wisdom-legacy): 成果管理 - 继续教育项目静态页面新增

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

+ 76 - 0
apps/wisdom-legacy/src/api/outcome/continuing-education.api.ts

@@ -0,0 +1,76 @@
+import type {
+  ContinuingEducationSubmitVO,
+  ContinuingEducationVO,
+} from './continuing-education.schema';
+
+import type { PageQueryMethodArgs } from '#/request/schema';
+
+import {
+  mockDeleteContinuingEducationMethod,
+  mockEditContinuingEducationMethod,
+  mockGetContinuingEducationMethod,
+  mockListContinuingEducationMethod,
+  mockUnpublishContinuingEducationMethod,
+  USE_CONTINUING_EDUCATION_MOCK,
+} from './continuing-education.mock';
+
+export { USE_CONTINUING_EDUCATION_MOCK } from './continuing-education.mock';
+export type {
+  ContinuingEducationCategory,
+  ContinuingEducationPublishStatus,
+  ContinuingEducationStatus,
+  ContinuingEducationSubmitVO,
+  ContinuingEducationVO,
+} from './continuing-education.schema';
+export {
+  CONTINUING_EDUCATION_CATEGORY_OPTIONS,
+  ContinuingEducationVOSchema,
+  getContinuingEducationCategoryLabel,
+  getContinuingEducationStatusLabel,
+} from './continuing-education.schema';
+
+/** 继续教育项目分页列表 */
+export function listContinuingEducationMethod(...args: PageQueryMethodArgs) {
+  if (USE_CONTINUING_EDUCATION_MOCK) {
+    return mockListContinuingEducationMethod(...args) as any;
+  }
+  throw new Error('继续教育项目接口尚未接入');
+}
+
+/** 获取继续教育项目详情 */
+export function getContinuingEducationMethod(
+  vo: Partial<ContinuingEducationVO>,
+) {
+  if (USE_CONTINUING_EDUCATION_MOCK) {
+    return mockGetContinuingEducationMethod(vo) as any;
+  }
+  throw new Error('继续教育项目接口尚未接入');
+}
+
+/** 新增/编辑继续教育项目 */
+export function editContinuingEducationMethod(vo: ContinuingEducationSubmitVO) {
+  if (USE_CONTINUING_EDUCATION_MOCK) {
+    return mockEditContinuingEducationMethod(vo) as any;
+  }
+  throw new Error('继续教育项目接口尚未接入');
+}
+
+/** 删除继续教育项目 */
+export function deleteContinuingEducationMethod(
+  vo: Pick<ContinuingEducationVO, 'id'>,
+) {
+  if (USE_CONTINUING_EDUCATION_MOCK) {
+    return mockDeleteContinuingEducationMethod(vo) as any;
+  }
+  throw new Error('继续教育项目接口尚未接入');
+}
+
+/** 取消发布继续教育项目 */
+export function unpublishContinuingEducationMethod(
+  vo: Pick<ContinuingEducationVO, 'id'>,
+) {
+  if (USE_CONTINUING_EDUCATION_MOCK) {
+    return mockUnpublishContinuingEducationMethod(vo) as any;
+  }
+  throw new Error('继续教育项目接口尚未接入');
+}

+ 268 - 0
apps/wisdom-legacy/src/api/outcome/continuing-education.mock.ts

@@ -0,0 +1,268 @@
+import type {
+  ContinuingEducationCategory,
+  ContinuingEducationPublishStatus,
+  ContinuingEducationStatus,
+  ContinuingEducationSubmitVO,
+  ContinuingEducationVO,
+} from './continuing-education.schema';
+
+import type { PageQueryMethodArgs } from '#/request/schema';
+import type { PageVO } from '#/request/schema/record';
+
+import { pageQueryArgsTransform } from '#/request/schema';
+
+/** 暂无后端接口,始终使用 mock */
+export const USE_CONTINUING_EDUCATION_MOCK = true;
+
+type MethodLike<T> = PromiseLike<T> & {
+  send?: (force?: boolean) => PromiseLike<T>;
+};
+
+interface ContinuingEducationRecord extends ContinuingEducationVO {
+  id: string;
+  workroomId: string;
+  projectNumber: string;
+  projectName: string;
+  category: ContinuingEducationCategory;
+  credits: number;
+  leader: string;
+  organizer: string;
+  startDate: string;
+  endDate?: string;
+  isLongTerm?: boolean;
+  maxParticipants: number;
+  registeredCount: number;
+  registrationDeadline: string;
+  fileUrl?: string;
+  status: ContinuingEducationStatus;
+  publishStatus: ContinuingEducationPublishStatus;
+}
+
+const SEED_RECORDS: Omit<ContinuingEducationRecord, 'id' | 'workroomId'>[] = [
+  {
+    projectNumber: 'CEP2026001',
+    projectName: '中医药临床诊疗技能提升培训班',
+    category: 'clinical_skill',
+    credits: 10,
+    leader: '王安',
+    organizer: '王安',
+    startDate: '2026-07-10',
+    endDate: '2026-07-20',
+    maxParticipants: 50,
+    registeredCount: 45,
+    registrationDeadline: '2026-07-05',
+    status: 'registering',
+    publishStatus: 'published',
+  },
+  {
+    projectNumber: 'CEP2026002',
+    projectName: '名老中医学术思想研修班',
+    category: 'academic_research',
+    credits: 15,
+    leader: '王安',
+    organizer: '王安',
+    startDate: '2026-08-01',
+    endDate: '2026-08-15',
+    maxParticipants: 40,
+    registeredCount: 30,
+    registrationDeadline: '2026-07-25',
+    status: 'registering',
+    publishStatus: 'published',
+  },
+  {
+    projectNumber: 'CEP2026003',
+    projectName: '针灸推拿实战技能培训',
+    category: 'operation_skill',
+    credits: 12,
+    leader: '王安',
+    organizer: '王安',
+    startDate: '2026-03-01',
+    endDate: '2026-03-10',
+    maxParticipants: 50,
+    registeredCount: 50,
+    registrationDeadline: '2026-02-20',
+    status: 'ended',
+    publishStatus: 'published',
+  },
+];
+
+function createInitialStore(): ContinuingEducationRecord[] {
+  return SEED_RECORDS.map((record, index) => ({
+    ...record,
+    id: String(index + 1),
+    workroomId: '327477138296832',
+  }));
+}
+
+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: ContinuingEducationRecord, keyword?: string) {
+  if (!keyword) return true;
+  const text = [
+    record.projectName,
+    record.projectNumber,
+    record.leader,
+    record.organizer,
+  ]
+    .filter(Boolean)
+    .join(' ');
+  return text.includes(keyword);
+}
+
+function matchWorkroom(record: ContinuingEducationRecord, workroomId?: string) {
+  if (!workroomId) return true;
+  return String(record.workroomId) === String(workroomId);
+}
+
+function matchCategory(
+  record: ContinuingEducationRecord,
+  category?: ContinuingEducationCategory,
+) {
+  if (!category) return true;
+  return record.category === category;
+}
+
+function matchPublished(record: ContinuingEducationRecord) {
+  return record.publishStatus === 'published';
+}
+
+function resolveStatus(
+  endDate?: string,
+  isLongTerm?: boolean,
+): ContinuingEducationStatus {
+  if (isLongTerm || !endDate) return 'registering';
+  const today = new Date().toISOString().slice(0, 10);
+  return endDate < today ? 'ended' : 'registering';
+}
+
+export function mockListContinuingEducationMethod(
+  ...args: PageQueryMethodArgs
+) {
+  const { params, data } = pageQueryArgsTransform(args, (query) => query);
+  const pageNum = Number(params.pageNum ?? 1);
+  const pageSize = Number(params.pageSize ?? 20);
+  const keyword = (data as { keyword?: string }).keyword;
+  const workroomId = (data as { workroomId?: string }).workroomId;
+  const category = (data as { category?: ContinuingEducationCategory })
+    .category;
+
+  const filtered = store.filter(
+    (record) =>
+      matchPublished(record) &&
+      matchKeyword(record, keyword) &&
+      matchWorkroom(record, workroomId) &&
+      matchCategory(record, category),
+  );
+
+  const start = (pageNum - 1) * pageSize;
+  const items = filtered.slice(start, start + pageSize);
+
+  const result: PageVO<ContinuingEducationVO> = {
+    total: filtered.length,
+    items,
+  };
+  return delay(() => result);
+}
+
+export function mockGetContinuingEducationMethod(
+  vo: Partial<ContinuingEducationVO>,
+) {
+  return delay(() => {
+    const record = store.find((item) => String(item.id) === String(vo.id));
+    if (!record) {
+      throw new Error('继续教育项目不存在');
+    }
+    return { ...record };
+  });
+}
+
+export function mockEditContinuingEducationMethod(
+  vo: ContinuingEducationSubmitVO,
+) {
+  return delay(() => {
+    const publishStatus = vo.publishStatus ?? 'published';
+    const status = resolveStatus(vo.endDate, vo.isLongTerm);
+    const payload: Omit<ContinuingEducationRecord, 'id'> = {
+      workroomId: String(vo.workroomId ?? ''),
+      projectNumber: vo.projectNumber ?? '',
+      projectName: vo.projectName ?? '',
+      category: vo.category ?? 'clinical_skill',
+      credits: Number(vo.credits ?? 0),
+      leader: vo.leader ?? '',
+      organizer: vo.leader ?? '',
+      startDate: vo.startDate ?? '',
+      endDate: vo.isLongTerm ? undefined : vo.endDate,
+      isLongTerm: vo.isLongTerm,
+      maxParticipants: Number(vo.maxParticipants ?? 0),
+      registeredCount: vo.registeredCount ?? 0,
+      registrationDeadline: vo.registrationDeadline ?? '',
+      fileUrl: vo.fileUrl,
+      status,
+      publishStatus,
+    };
+
+    if (vo.id) {
+      const index = store.findIndex(
+        (item) => String(item.id) === String(vo.id),
+      );
+      if (index === -1) {
+        throw new Error('继续教育项目不存在');
+      }
+      const current = store[index];
+      if (!current) {
+        throw new Error('继续教育项目不存在');
+      }
+      store[index] = {
+        ...current,
+        ...payload,
+        id: current.id,
+        registeredCount: current.registeredCount,
+      };
+      return String(vo.id);
+    }
+
+    const id = String(nextId++);
+    store.unshift({
+      ...payload,
+      id,
+      registeredCount: 0,
+    });
+    return id;
+  });
+}
+
+export function mockDeleteContinuingEducationMethod(
+  vo: Pick<ContinuingEducationVO, '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;
+  });
+}
+
+export function mockUnpublishContinuingEducationMethod(
+  vo: Pick<ContinuingEducationVO, 'id'>,
+) {
+  return delay(() => {
+    const record = store.find((item) => String(item.id) === String(vo.id));
+    if (!record) {
+      throw new Error('继续教育项目不存在');
+    }
+    record.publishStatus = 'draft';
+    return null;
+  });
+}

+ 86 - 0
apps/wisdom-legacy/src/api/outcome/continuing-education.schema.ts

@@ -0,0 +1,86 @@
+import { z } from '#/adapter/form';
+
+// ---------------------------------------------------------------------------
+// 枚举
+// ---------------------------------------------------------------------------
+
+export type ContinuingEducationCategory =
+  | 'academic_research'
+  | 'clinical_skill'
+  | 'operation_skill';
+
+export type ContinuingEducationStatus = 'ended' | 'registering';
+
+export type ContinuingEducationPublishStatus = 'draft' | 'published';
+
+export const CONTINUING_EDUCATION_CATEGORY_OPTIONS = [
+  { label: '临床技能', value: 'clinical_skill' },
+  { label: '学术研究', value: 'academic_research' },
+  { label: '操作技能', value: 'operation_skill' },
+] as const satisfies ReadonlyArray<{
+  label: string;
+  value: ContinuingEducationCategory;
+}>;
+
+export function getContinuingEducationCategoryLabel(
+  category?: ContinuingEducationCategory,
+) {
+  return (
+    CONTINUING_EDUCATION_CATEGORY_OPTIONS.find(
+      (item) => item.value === category,
+    )?.label ?? ''
+  );
+}
+
+export function getContinuingEducationStatusLabel(
+  status?: ContinuingEducationStatus,
+) {
+  if (status === 'ended') return '已结束';
+  if (status === 'registering') return '报名中';
+  return '';
+}
+
+// ---------------------------------------------------------------------------
+// VO
+// ---------------------------------------------------------------------------
+
+export interface ContinuingEducationVO {
+  id?: string;
+  workroomId?: string;
+  projectNumber?: string;
+  projectName?: string;
+  category?: ContinuingEducationCategory;
+  credits?: number;
+  leader?: string;
+  organizer?: string;
+  startDate?: string;
+  endDate?: string;
+  isLongTerm?: boolean;
+  maxParticipants?: number;
+  registeredCount?: number;
+  registrationDeadline?: string;
+  fileUrl?: string;
+  status?: ContinuingEducationStatus;
+  publishStatus?: ContinuingEducationPublishStatus;
+}
+
+export type ContinuingEducationSubmitVO = ContinuingEducationVO;
+
+export const ContinuingEducationVOSchema = z.object({
+  id: z.string().optional(),
+  workroomId: z.string().min(1, '请先选择工作室'),
+  projectNumber: z.string().min(1, '请输入项目编号'),
+  projectName: z.string().min(1, '请输入项目名称'),
+  category: z.enum(['clinical_skill', 'academic_research', 'operation_skill'], {
+    required_error: '请选择继续教育分类',
+  }),
+  credits: z.coerce.number().min(1, '请输入学分'),
+  leader: z.string().min(1, '请输入项目负责人'),
+  startDate: z.string().min(1, '请选择开始日期'),
+  endDate: z.string().optional(),
+  isLongTerm: z.boolean().optional(),
+  maxParticipants: z.coerce.number().min(1, '请输入最多参加人数'),
+  registrationDeadline: z.string().min(1, '请选择报名截止日期'),
+  fileUrl: z.string().optional(),
+  publishStatus: z.enum(['draft', 'published']).optional(),
+});

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

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

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

@@ -1,6 +1,5 @@
 import type { RouteRecordRaw } from 'vue-router';
 
-const placeholder = () => import('#/views/outcome/Placeholder.vue');
 const medicalCaseLibrary = () =>
   import('#/views/outcome/MedicalCaseLibraryList.vue');
 const treatmentPlan = () => import('#/views/outcome/TreatmentPlanList.vue');
@@ -13,6 +12,8 @@ const hospitalPreparation = () =>
   import('#/views/outcome/HospitalPreparationList.vue');
 const newDrugCertificate = () =>
   import('#/views/outcome/NewDrugCertificateList.vue');
+const continuingEducation = () =>
+  import('#/views/outcome/ContinuingEducationList.vue');
 const video = () => import('#/views/outcome/VideoList.vue');
 const readingNote = () => import('#/views/outcome/ReadingNoteList.vue');
 const experience = () => import('#/views/outcome/ExperienceList.vue');
@@ -109,7 +110,7 @@ const routes: RouteRecordRaw[] = [
           icon: 'carbon:user',
           title: '继续教育项目',
         },
-        component: placeholder,
+        component: continuingEducation,
       },
       {
         path: '/outcome/video',

+ 299 - 0
apps/wisdom-legacy/src/views/outcome/ContinuingEducationList.vue

@@ -0,0 +1,299 @@
+<script setup lang="ts">
+import type {
+  ContinuingEducationCategory,
+  ContinuingEducationVO,
+} 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 {
+  CONTINUING_EDUCATION_CATEGORY_OPTIONS,
+  deleteContinuingEducationMethod,
+  getContinuingEducationCategoryLabel,
+  getContinuingEducationStatusLabel,
+  listContinuingEducationMethod,
+  unpublishContinuingEducationMethod,
+} from '#/api/outcome';
+import { useWorkroomStore } from '#/stores';
+
+import ContinuingEducationCard from './components/ContinuingEducationCard.vue';
+import ContinuingEducationEdit from './modules/ContinuingEducationEdit.vue';
+
+const workroomStore = useWorkroomStore();
+const { workroomId } = storeToRefs(workroomStore);
+
+const keyword = ref('');
+const searchKeyword = ref('');
+const category = ref<'' | ContinuingEducationCategory>('');
+const pageNum = ref(1);
+const pageSize = ref(20);
+const loading = ref(false);
+const pageData = ref<{ items: ContinuingEducationVO[]; total: number }>({
+  total: 0,
+  items: [],
+});
+
+const deletingIds = shallowRef(new Set<string>());
+const unpublishingIds = shallowRef(new Set<string>());
+
+const categoryOptions = [
+  { label: '全部类别', value: '' },
+  ...CONTINUING_EDUCATION_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(
+      listContinuingEducationMethod(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: ContinuingEducationEdit,
+});
+
+function isDeleting(id?: string) {
+  return id ? deletingIds.value.has(id) : false;
+}
+
+function isUnpublishing(id?: string) {
+  return id ? unpublishingIds.value.has(id) : false;
+}
+
+function setDeleting(id: string, value: boolean) {
+  if (value) {
+    deletingIds.value.add(id);
+  } else {
+    deletingIds.value.delete(id);
+  }
+  triggerRef(deletingIds);
+}
+
+function setUnpublishing(id: string, value: boolean) {
+  if (value) {
+    unpublishingIds.value.add(id);
+  } else {
+    unpublishingIds.value.delete(id);
+  }
+  triggerRef(unpublishingIds);
+}
+
+async function openCreate() {
+  const result = await editApi
+    .setData({
+      workroomId: workroomId.value,
+      category: 'clinical_skill',
+    } as ContinuingEducationVO)
+    .open<{ id?: string }>(Promise.withResolvers());
+  if (result?.id) {
+    pageNum.value = 1;
+    await loadList();
+  }
+}
+
+function formatDate(value?: string) {
+  if (!value) return '长期';
+  return String(value).slice(0, 10);
+}
+
+function openView(row: ContinuingEducationVO) {
+  Modal.info({
+    title: row.projectName,
+    width: 640,
+    okText: '关闭',
+    content: h('div', { class: 'pt-2 text-sm leading-7 text-foreground/85' }, [
+      h('p', `项目编号:${row.projectNumber ?? '-'}`),
+      h(
+        'p',
+        `继续教育分类:${getContinuingEducationCategoryLabel(row.category)}`,
+      ),
+      h('p', `学分:${row.credits ?? '-'}分`),
+      h('p', `项目负责人:${row.leader ?? '-'}`),
+      h('p', `开始日期:${formatDate(row.startDate)}`),
+      h('p', `结束日期:${formatDate(row.endDate)}`),
+      h(
+        'p',
+        `报名情况:${row.registeredCount ?? 0}/${row.maxParticipants ?? 0}人`,
+      ),
+      h('p', `报名截止:${formatDate(row.registrationDeadline)}`),
+      h('p', `状态:${getContinuingEducationStatusLabel(row.status)}`),
+    ]),
+  });
+}
+
+function openManageRegistration(row: ContinuingEducationVO) {
+  message.info(`管理报名:${row.projectName ?? ''}`);
+}
+
+async function handleDelete(row: ContinuingEducationVO) {
+  if (!row.id || isDeleting(row.id)) return;
+  setDeleting(row.id, true);
+  try {
+    await invokeMethod(deleteContinuingEducationMethod(row), { force: true });
+    message.success('删除成功');
+    if (items.value.length <= 1 && pageNum.value > 1) {
+      pageNum.value -= 1;
+    }
+    await loadList();
+  } finally {
+    setDeleting(row.id, false);
+  }
+}
+
+async function handleUnpublish(row: ContinuingEducationVO) {
+  if (!row.id || isUnpublishing(row.id)) return;
+  setUnpublishing(row.id, true);
+  try {
+    await invokeMethod(unpublishContinuingEducationMethod(row), {
+      force: true,
+    });
+    message.success('已取消发布');
+    await loadList();
+  } finally {
+    setUnpublishing(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">
+            <ContinuingEducationCard
+              v-for="item in items"
+              :key="item.id"
+              :data="item"
+              :deleting="isDeleting(item.id)"
+              :unpublishing="isUnpublishing(item.id)"
+              @manage-registration="openManageRegistration"
+              @view="openView"
+              @delete="handleDelete"
+              @unpublish="handleUnpublish"
+            />
+          </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>

+ 169 - 0
apps/wisdom-legacy/src/views/outcome/components/ContinuingEducationCard.vue

@@ -0,0 +1,169 @@
+<script setup lang="ts">
+import type { ContinuingEducationVO } from '#/api/outcome';
+
+import {
+  CalendarOutlined,
+  DeleteOutlined,
+  EyeOutlined,
+  ReadOutlined,
+  TeamOutlined,
+} from '@ant-design/icons-vue';
+import { Button, Card, Popconfirm, Tag } from 'ant-design-vue';
+
+import {
+  getContinuingEducationCategoryLabel,
+  getContinuingEducationStatusLabel,
+} from '#/api/outcome';
+
+const { data, deleting, unpublishing } = defineProps<{
+  data: ContinuingEducationVO;
+  deleting?: boolean;
+  unpublishing?: boolean;
+}>();
+
+const emit = defineEmits<{
+  delete: [ContinuingEducationVO];
+  manageRegistration: [ContinuingEducationVO];
+  unpublish: [ContinuingEducationVO];
+  view: [ContinuingEducationVO];
+}>();
+
+function formatDate(value?: string) {
+  if (!value) return '长期';
+  return String(value).slice(0, 10);
+}
+</script>
+
+<template>
+  <Card class="continuing-education-card" :bordered="true">
+    <div class="flex gap-4">
+      <div
+        class="flex size-14 shrink-0 items-center justify-center rounded-lg bg-blue-100"
+      >
+        <ReadOutlined class="text-2xl text-blue-500" />
+      </div>
+
+      <div class="min-w-0 flex-1">
+        <div class="mb-2 flex items-start justify-between gap-3">
+          <h3
+            class="text-lg font-semibold leading-7 text-primary"
+            :title="data.projectName"
+          >
+            {{ data.projectName }}
+          </h3>
+          <div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
+            <Tag class="category-tag m-0">
+              {{ getContinuingEducationCategoryLabel(data.category) }}
+            </Tag>
+            <Tag
+              class="status-tag m-0"
+              :class="
+                data.status === 'registering'
+                  ? 'status-tag--active'
+                  : 'status-tag--ended'
+              "
+            >
+              {{ getContinuingEducationStatusLabel(data.status) }}
+            </Tag>
+          </div>
+        </div>
+
+        <p class="mb-4 text-sm text-foreground/55">
+          项目编号:{{ data.projectNumber }}
+          <span class="mx-2 text-foreground/30">|</span>
+          学分:{{ data.credits }}分
+          <span class="mx-2 text-foreground/30">|</span>
+          项目负责人:{{ data.leader }}
+        </p>
+
+        <div class="mb-4 grid gap-3 sm:grid-cols-3">
+          <div class="flex items-center gap-2 text-sm text-foreground/70">
+            <CalendarOutlined class="text-foreground/45" />
+            <span>开始时间:{{ formatDate(data.startDate) }}</span>
+          </div>
+          <div class="flex items-center gap-2 text-sm text-foreground/70">
+            <CalendarOutlined class="text-foreground/45" />
+            <span>结束时间:{{ formatDate(data.endDate) }}</span>
+          </div>
+          <div class="flex items-center gap-2 text-sm text-foreground/70">
+            <TeamOutlined class="text-foreground/45" />
+            <span>
+              报名情况:{{ data.registeredCount ?? 0 }}/{{
+                data.maxParticipants ?? 0
+              }}人
+            </span>
+          </div>
+        </div>
+
+        <div class="flex flex-col gap-3 border-t pt-4">
+          <div class="grid gap-3 sm:grid-cols-2">
+            <Button
+              type="primary"
+              class="!h-10 !border-[#1a2744] !bg-[#1a2744] hover:!border-[#243456] hover:!bg-[#243456]"
+              @click="emit('manageRegistration', data)"
+            >
+              <TeamOutlined />
+              管理报名
+            </Button>
+            <Popconfirm
+              title="确定取消发布该项目吗?"
+              ok-text="确定"
+              cancel-text="取消"
+              :ok-button-props="{ loading: unpublishing }"
+              :cancel-button-props="{ disabled: unpublishing }"
+              @confirm="emit('unpublish', data)"
+            >
+              <Button class="!h-10" :disabled="unpublishing">取消发布</Button>
+            </Popconfirm>
+          </div>
+
+          <div class="flex flex-wrap items-center gap-1">
+            <Button type="link" class="!px-2" @click="emit('view', data)">
+              <EyeOutlined />
+              查看详情
+            </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>
+.continuing-education-card :deep(.ant-card-body) {
+  padding: 20px 24px;
+}
+
+.category-tag {
+  color: #7c3aed;
+  background: #f3e8ff;
+  border-color: #e9d5ff;
+}
+
+.status-tag {
+  border: 0;
+}
+
+.status-tag--active {
+  color: #16a34a;
+  background: #dcfce7;
+}
+
+.status-tag--ended {
+  color: #64748b;
+  background: #f1f5f9;
+}
+</style>

+ 171 - 0
apps/wisdom-legacy/src/views/outcome/continuing-education.data.ts

@@ -0,0 +1,171 @@
+import type { ContinuingEducationVO } from '#/api/outcome';
+
+import { getPopupContainer } from '@vben/utils';
+
+import { defineEditShell } from '#/adapter/shell/edit';
+import {
+  CONTINUING_EDUCATION_CATEGORY_OPTIONS,
+  ContinuingEducationVOSchema,
+  editContinuingEducationMethod,
+  getContinuingEducationMethod,
+} from '#/api/outcome';
+
+export const continuingEducationForm = defineEditShell<ContinuingEducationVO>({
+  scope: 'outcome.continuingEducation',
+  title: '继续教育项目',
+  submit: editContinuingEducationMethod,
+  load: getContinuingEducationMethod,
+  shell: {
+    type: 'modal',
+    class: '!w-[640px]',
+    showConfirmButton: false,
+    showCancelButton: false,
+  },
+  form: {
+    layout: 'vertical',
+    wrapperClass: 'grid-cols-2',
+  },
+  schema: [
+    {
+      component: 'Input',
+      fieldName: 'projectNumber',
+      formItemClass: 'col-span-2',
+      label: '项目编号',
+      componentProps: {
+        placeholder: '请输入',
+      },
+      rules: ContinuingEducationVOSchema.shape.projectNumber,
+    },
+    {
+      component: 'Input',
+      fieldName: 'projectName',
+      formItemClass: 'col-span-2',
+      label: '项目名称',
+      componentProps: {
+        placeholder: '请输入',
+      },
+      rules: ContinuingEducationVOSchema.shape.projectName,
+    },
+    {
+      component: 'Select',
+      fieldName: 'category',
+      formItemClass: 'col-span-2',
+      label: '继续教育分类',
+      defaultValue: 'clinical_skill',
+      componentProps: {
+        class: 'w-full',
+        options: [...CONTINUING_EDUCATION_CATEGORY_OPTIONS],
+        placeholder: '请选择继续教育分类',
+        getPopupContainer,
+      },
+      rules: ContinuingEducationVOSchema.shape.category,
+    },
+    {
+      component: 'Input',
+      fieldName: 'credits',
+      formItemClass: 'col-span-2',
+      label: '学分',
+      componentProps: {
+        placeholder: '请输入',
+        addonAfter: '分',
+      },
+      rules: ContinuingEducationVOSchema.shape.credits,
+    },
+    {
+      component: 'Input',
+      fieldName: 'leader',
+      formItemClass: 'col-span-2',
+      label: '项目负责人',
+      componentProps: {
+        placeholder: '请输入',
+      },
+      rules: ContinuingEducationVOSchema.shape.leader,
+    },
+    {
+      component: 'DatePicker',
+      fieldName: 'startDate',
+      formItemClass: 'col-span-2',
+      label: '开始日期',
+      componentProps: {
+        class: 'w-full',
+        format: 'YYYY/MM/DD',
+        placeholder: '年/月/日',
+        valueFormat: 'YYYY-MM-DD',
+        getPopupContainer,
+      },
+      rules: ContinuingEducationVOSchema.shape.startDate,
+    },
+    {
+      component: 'DatePicker',
+      fieldName: 'endDate',
+      label: '结束日期',
+      formItemClass: 'col-span-1',
+      componentProps: {
+        class: 'w-full',
+        format: 'YYYY/MM/DD',
+        placeholder: '年/月/日',
+        valueFormat: 'YYYY-MM-DD',
+        getPopupContainer,
+      },
+      dependencies: {
+        triggerFields: ['isLongTerm'],
+        disabled(values) {
+          return Boolean(values.isLongTerm);
+        },
+      },
+    },
+    {
+      component: 'Checkbox',
+      fieldName: 'isLongTerm',
+      label: ' ',
+      formItemClass: 'col-span-1 !mb-0 flex items-end pb-1',
+      renderComponentContent() {
+        return {
+          default: () => '长期',
+        };
+      },
+    },
+    {
+      component: 'Input',
+      fieldName: 'maxParticipants',
+      formItemClass: 'col-span-2',
+      label: '最多参加人数',
+      componentProps: {
+        placeholder: '请输入',
+        addonAfter: '人',
+      },
+      rules: ContinuingEducationVOSchema.shape.maxParticipants,
+    },
+    {
+      component: 'DatePicker',
+      fieldName: 'registrationDeadline',
+      formItemClass: 'col-span-2',
+      label: '报名截止日期',
+      componentProps: {
+        class: 'w-full',
+        format: 'YYYY/MM/DD',
+        placeholder: '年/月/日',
+        valueFormat: 'YYYY-MM-DD',
+        getPopupContainer,
+      },
+      rules: ContinuingEducationVOSchema.shape.registrationDeadline,
+    },
+    {
+      component: 'Input',
+      fieldName: 'workroomId',
+      dependencies: {
+        show: false,
+        triggerFields: ['workroomId'],
+      },
+      rules: ContinuingEducationVOSchema.shape.workroomId,
+    },
+    {
+      component: 'Input',
+      fieldName: 'publishStatus',
+      dependencies: {
+        show: false,
+        triggerFields: ['publishStatus'],
+      },
+    },
+  ],
+});

+ 190 - 0
apps/wisdom-legacy/src/views/outcome/modules/ContinuingEducationEdit.vue

@@ -0,0 +1,190 @@
+<script setup lang="ts">
+import type { UploadFile, UploadProps } from 'ant-design-vue';
+
+import type { ContinuingEducationSubmitVO } from '#/api/outcome';
+
+import { ref } from 'vue';
+
+import { InboxOutlined } from '@ant-design/icons-vue';
+import { Button, message, Upload, UploadDragger } from 'ant-design-vue';
+
+import { useEditShell } from '#/adapter/shell';
+import { useWorkroomStore } from '#/stores';
+
+import { continuingEducationForm } from '../continuing-education.data';
+
+const workroomStore = useWorkroomStore();
+
+const PDF_MAX_SIZE = 50 * 1024 * 1024;
+
+const pdfFileList = ref<UploadFile[]>([]);
+const pdfUrl = ref<string>();
+const pdfUploading = ref(false);
+const submittingAction = ref<'draft' | 'publish' | null>(null);
+
+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;
+  submittingAction.value = null;
+}
+
+const { Form, Shell, api } = useEditShell<ContinuingEducationSubmitVO>(
+  continuingEducationForm,
+  {
+    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');
+      }
+      if (!values.isLongTerm && !values.endDate) {
+        message.error('请选择结束日期或勾选长期');
+        throw new Error('endDate required');
+      }
+
+      return {
+        ...values,
+        workroomId,
+        endDate: values.isLongTerm ? undefined : values.endDate,
+        fileUrl: pdfUrl.value,
+        publishStatus:
+          submittingAction.value === 'draft' ? 'draft' : 'published',
+      };
+    },
+    onClosed: resetUploads,
+  },
+);
+
+async function handleClose() {
+  await api.close();
+}
+
+async function handleSaveDraft() {
+  submittingAction.value = 'draft';
+  try {
+    await api.submit();
+    message.success('草稿已保存');
+  } finally {
+    submittingAction.value = null;
+  }
+}
+
+async function handlePublish() {
+  submittingAction.value = 'publish';
+  try {
+    await api.submit();
+    message.success('发布成功');
+  } finally {
+    submittingAction.value = null;
+  }
+}
+
+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;
+  }
+
+  pdfUrl.value = URL.createObjectURL(file);
+  pdfFileList.value = [
+    {
+      uid: `${Date.now()}`,
+      name: file.name,
+      status: 'done',
+      url: pdfUrl.value,
+    },
+  ];
+  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 font-medium">
+          <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>
+
+    <template #footer>
+      <div class="flex w-full justify-end gap-2">
+        <Button @click="handleClose">取消</Button>
+        <Button
+          :loading="submittingAction === 'draft'"
+          @click="handleSaveDraft"
+        >
+          保存草稿
+        </Button>
+        <Button
+          type="primary"
+          :loading="submittingAction === 'publish'"
+          @click="handlePublish"
+        >
+          发布
+        </Button>
+      </div>
+    </template>
+  </Shell>
+</template>