Parcourir la source

feat(@six/wisdom-legacy): 成果管理 - 视频管理静态页面新增

Co-authored-by: Cursor <cursoragent@cursor.com>
cmj il y a 2 jours
Parent
commit
e523e257c8

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

@@ -6,3 +6,4 @@ export * from './new-drug-certificate.api';
 export * from './paper.api';
 export * from './research-report.api';
 export * from './treatment-plan.api';
+export * from './video.api';

+ 120 - 0
apps/wisdom-legacy/src/api/outcome/video.api.ts

@@ -0,0 +1,120 @@
+import type { VideoSubmitVO, VideoVO } from './video.schema';
+
+import type { PageQueryMethodArgs } from '#/request/schema';
+
+import { getEnvelopeData, httpClient } from '#/request';
+import {
+  pageQueryArgsTransform,
+  paginateTransform,
+  transform,
+} from '#/request/schema';
+
+import {
+  mockDeleteVideoMethod,
+  mockEditVideoMethod,
+  mockGetVideoMethod,
+  mockListVideoMethod,
+  USE_VIDEO_MOCK,
+} from './video.mock';
+import { decodeVideo, encodeVideo, encodeVideoQuery } from './video.schema';
+
+export { USE_VIDEO_MOCK } from './video.mock';
+export type {
+  VideoCategory,
+  VideoQueryVO,
+  VideoSortType,
+  VideoSubmitVO,
+  VideoVO,
+} from './video.schema';
+export {
+  getVideoCategoryLabel,
+  VIDEO_CATEGORY_OPTIONS,
+  VideoVOSchema,
+} from './video.schema';
+
+/** 视频分页列表 */
+export function listVideoMethod(...args: PageQueryMethodArgs) {
+  if (USE_VIDEO_MOCK) {
+    return mockListVideoMethod(...args) as any;
+  }
+
+  const { params, data } = pageQueryArgsTransform(args, encodeVideoQuery);
+  return httpClient.Post(
+    `/wis-pc/outcome/videoExplainManage/page`,
+    { ...params, ...data },
+    {
+      params,
+      hitSource: /^outcome-video:(edit|delete)/,
+      transform: paginateTransform(decodeVideo),
+    },
+  );
+}
+
+/** 新增视频 */
+export function createVideoMethod(vo: VideoSubmitVO) {
+  if (USE_VIDEO_MOCK) {
+    return mockEditVideoMethod(vo) as any;
+  }
+
+  return httpClient.Post(
+    `/wis-pc/outcome/videoExplainManage/add`,
+    encodeVideo(vo),
+    {
+      name: 'outcome-video:edit',
+      transform: getEnvelopeData<null | string>,
+    },
+  );
+}
+
+/** 修改视频 */
+export function updateVideoMethod(vo: VideoSubmitVO) {
+  if (USE_VIDEO_MOCK) {
+    return mockEditVideoMethod(vo) as any;
+  }
+
+  return httpClient.Post(
+    `/wis-pc/outcome/videoExplainManage/update`,
+    encodeVideo(vo),
+    {
+      name: 'outcome-video:edit',
+      transform: getEnvelopeData<null | string>,
+    },
+  );
+}
+
+/** 新增 / 修改视频 */
+export function editVideoMethod(vo: VideoSubmitVO) {
+  return vo.id ? updateVideoMethod(vo) : createVideoMethod(vo);
+}
+
+/** 视频详情 */
+export function getVideoMethod(vo: Partial<VideoVO>) {
+  if (USE_VIDEO_MOCK) {
+    return mockGetVideoMethod(vo) as any;
+  }
+
+  return httpClient.Post(
+    `/wis-pc/outcome/videoExplainManage/detail/${vo.id}`,
+    {},
+    {
+      hitSource: /^outcome-video:edit/,
+      transform: transform(decodeVideo),
+    },
+  );
+}
+
+/** 删除视频 */
+export function deleteVideoMethod(vo: Pick<VideoVO, 'id'>) {
+  if (USE_VIDEO_MOCK) {
+    return mockDeleteVideoMethod(vo) as any;
+  }
+
+  return httpClient.Post(
+    `/wis-pc/outcome/videoExplainManage/delete/${vo.id}`,
+    {},
+    {
+      name: 'outcome-video:delete',
+      meta: { ignoreError: true },
+    },
+  );
+}

+ 246 - 0
apps/wisdom-legacy/src/api/outcome/video.mock.ts

@@ -0,0 +1,246 @@
+import type {
+  VideoCategory,
+  VideoDTO,
+  VideoSortType,
+  VideoSubmitVO,
+  VideoVO,
+} from './video.schema';
+
+import type { PageQueryMethodArgs } from '#/request/schema';
+import type { PageVO } from '#/request/schema/record';
+
+import { pageQueryArgsTransform } from '#/request/schema';
+
+import { decodeVideo, encodeVideo, encodeVideoQuery } from './video.schema';
+
+/** 后端接口就绪后改为 false */
+export const USE_VIDEO_MOCK = true;
+
+type MethodLike<T> = PromiseLike<T> & {
+  send?: (force?: boolean) => PromiseLike<T>;
+};
+
+const MOCK_VIDEO_URL =
+  'https://wx.hzliuzhi.com:4433/cmhs-bucket/2026/6/27/3cc818e7-a06c-48ee-b30f-7bd6c7720537.mp4';
+
+const SEED_RECORDS: Omit<
+  VideoDTO,
+  'createTime' | 'id' | 'personalStudioId' | 'updateTime'
+>[] = [
+  {
+    videoName: '中医四诊技巧详解',
+    speakerName: '王教授',
+    category: 'clinical_teaching',
+    duration: '45:30',
+    viewCount: 3456,
+    status: true,
+    videoUrl: MOCK_VIDEO_URL,
+    createBy: '王教授',
+  },
+  {
+    videoName: '针灸手法实操演示',
+    speakerName: '李主任',
+    category: 'clinical_skill',
+    duration: '32:15',
+    viewCount: 2180,
+    status: true,
+    videoUrl: MOCK_VIDEO_URL,
+    createBy: '李主任',
+  },
+  {
+    videoName: '经典方剂配伍规律解析',
+    speakerName: '张教授',
+    category: 'theory',
+    duration: '58:42',
+    viewCount: 1890,
+    status: true,
+    videoUrl: MOCK_VIDEO_URL,
+    createBy: '张教授',
+  },
+  {
+    videoName: '推拿手法基础教学',
+    speakerName: '赵医师',
+    category: 'clinical_teaching',
+    duration: '28:05',
+    viewCount: 1567,
+    status: true,
+    videoUrl: MOCK_VIDEO_URL,
+    createBy: '赵医师',
+  },
+  {
+    videoName: '中药炮制工艺要点',
+    speakerName: '陈教授',
+    category: 'clinical_skill',
+    duration: '41:20',
+    viewCount: 980,
+    status: false,
+    videoUrl: MOCK_VIDEO_URL,
+    createBy: '陈教授',
+  },
+  {
+    videoName: '伤寒论六经辨证精讲',
+    speakerName: '刘教授',
+    category: 'theory',
+    duration: '1:12:08',
+    viewCount: 4200,
+    status: true,
+    videoUrl: MOCK_VIDEO_URL,
+    createBy: '刘教授',
+  },
+];
+
+function createInitialStore(): VideoDTO[] {
+  const records: VideoDTO[] = [];
+  for (let index = 0; index < 24; 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: VideoDTO, keyword?: string) {
+  if (!keyword) return true;
+  const text = [record.videoName, record.speakerName, record.createBy]
+    .filter(Boolean)
+    .join(' ');
+  return text.includes(keyword);
+}
+
+function matchWorkroom(record: VideoDTO, workroomId?: string) {
+  if (!workroomId) return true;
+  return String(record.personalStudioId ?? '') === String(workroomId);
+}
+
+function matchCategory(record: VideoDTO, category?: VideoCategory) {
+  if (!category) return true;
+  return record.category === category;
+}
+
+function sortRecords(records: VideoDTO[], sortType?: VideoSortType) {
+  const sorted = [...records];
+  if (sortType === 'popular') {
+    sorted.sort((a, b) => (b.viewCount ?? 0) - (a.viewCount ?? 0));
+    return sorted;
+  }
+  sorted.sort((a, b) => {
+    const timeA = a.createTime ? Date.parse(a.createTime) : 0;
+    const timeB = b.createTime ? Date.parse(b.createTime) : 0;
+    return timeB - timeA;
+  });
+  return sorted;
+}
+
+function toVo(dto: VideoDTO): VideoVO {
+  return decodeVideo(dto);
+}
+
+export function mockListVideoMethod(...args: PageQueryMethodArgs) {
+  const { params, data } = pageQueryArgsTransform(args, encodeVideoQuery);
+  const pageNum = Number(params.pageNum ?? 1);
+  const pageSize = Number(params.pageSize ?? 12);
+  const keyword = data.mixture;
+  const workroomId = data.personalStudioId?.toString();
+  const category = data.category;
+  const sortType = data.sortType as undefined | VideoSortType;
+
+  const filtered = sortRecords(
+    store.filter(
+      (record) =>
+        matchKeyword(record, keyword) &&
+        matchWorkroom(record, workroomId) &&
+        matchCategory(record, category),
+    ),
+    sortType,
+  );
+  const start = (pageNum - 1) * pageSize;
+  const items = filtered
+    .slice(start, start + pageSize)
+    .map((record) => toVo(record));
+
+  const result: PageVO<VideoVO> = {
+    total: filtered.length,
+    items,
+  };
+  return delay(() => result);
+}
+
+export function mockGetVideoMethod(vo: Partial<VideoVO>) {
+  return delay(() => {
+    const record = store.find((item) => String(item.id) === String(vo.id));
+    if (!record) {
+      throw new Error('视频不存在');
+    }
+    return toVo(record);
+  });
+}
+
+export function mockEditVideoMethod(vo: VideoSubmitVO) {
+  return delay(() => {
+    const dto = encodeVideo(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 mockDeleteVideoMethod(vo: Pick<VideoVO, '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 resetVideoMockStore() {
+  nextId = 100;
+  store = createInitialStore();
+}

+ 145 - 0
apps/wisdom-legacy/src/api/outcome/video.schema.ts

@@ -0,0 +1,145 @@
+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 VideoCategory = 'clinical_skill' | 'clinical_teaching' | 'theory';
+
+export type VideoSortType = 'latest' | 'popular';
+
+export const VIDEO_CATEGORY_OPTIONS = [
+  { label: '临床技能', value: 'clinical_skill' },
+  { label: '临床教学', value: 'clinical_teaching' },
+  { label: '理论讲解', value: 'theory' },
+] as const satisfies ReadonlyArray<{
+  label: string;
+  value: VideoCategory;
+}>;
+
+export function getVideoCategoryLabel(category?: VideoCategory) {
+  return (
+    VIDEO_CATEGORY_OPTIONS.find((item) => item.value === category)?.label ?? ''
+  );
+}
+
+// ---------------------------------------------------------------------------
+// DTO
+// ---------------------------------------------------------------------------
+
+export interface VideoDTO extends AuditRecordDTO {
+  id?: number | string;
+  personalStudioId?: number | string;
+  videoName?: string;
+  speakerName?: string;
+  category?: VideoCategory;
+  videoUrl?: string;
+  videoFirstUrl?: string;
+  duration?: string;
+  viewCount?: number;
+  status?: boolean;
+}
+
+export interface VideoQueryDTO {
+  mixture?: string;
+  personalStudioId?: number | string;
+  category?: VideoCategory;
+  sortType?: VideoSortType;
+  pageNum?: number;
+  pageSize?: number;
+}
+
+// ---------------------------------------------------------------------------
+// VO
+// ---------------------------------------------------------------------------
+
+export interface VideoVO extends AuditRecordVO {
+  id?: string;
+  workroomId: string;
+  name: string;
+  speaker?: string;
+  category: VideoCategory;
+  videoUrl?: string;
+  thumbnailUrl?: string;
+  duration?: string;
+  viewCount?: number;
+  status: boolean;
+}
+
+export type VideoSubmitVO = VideoVO;
+
+export interface VideoQueryVO {
+  keyword?: string;
+  workroomId?: string;
+  category?: VideoCategory;
+  sortType?: VideoSortType;
+}
+
+// ---------------------------------------------------------------------------
+// Schema
+// ---------------------------------------------------------------------------
+
+export const VideoVOSchema = z.object({
+  id: z.string().optional(),
+  workroomId: z.string().min(1, '工作室不能为空'),
+  name: z.string().min(1, '请输入视频名称'),
+  speaker: z.string().optional(),
+  category: z.enum(['clinical_skill', 'clinical_teaching', 'theory'], {
+    message: '请选择视频分类',
+  }),
+  status: z.boolean(),
+});
+
+// ---------------------------------------------------------------------------
+// 编解码
+// ---------------------------------------------------------------------------
+
+export function decodeVideo(dto: VideoDTO): VideoVO {
+  return {
+    ...decodeAuditRecord(dto),
+    id: dto.id?.toString(),
+    workroomId: dto.personalStudioId?.toString() ?? '',
+    name: dto.videoName ?? '',
+    speaker: dto.speakerName,
+    category: dto.category ?? 'clinical_skill',
+    videoUrl: dto.videoUrl,
+    thumbnailUrl: dto.videoFirstUrl,
+    duration: dto.duration,
+    viewCount: dto.viewCount ?? 0,
+    status: dto.status ?? true,
+  };
+}
+
+export function encodeVideoQuery(query: Partial<VideoQueryVO>): VideoQueryDTO {
+  return {
+    mixture: query.keyword,
+    personalStudioId: query.workroomId,
+    category: query.category,
+    sortType: query.sortType,
+  };
+}
+
+export function encodeVideo(vo: VideoSubmitVO): VideoDTO {
+  return {
+    id: vo.id,
+    personalStudioId: vo.workroomId,
+    videoName: vo.name,
+    speakerName: vo.speaker,
+    category: vo.category,
+    videoUrl: vo.videoUrl,
+    videoFirstUrl: vo.thumbnailUrl,
+    duration: vo.duration,
+    status: vo.status,
+  };
+}
+
+export function decodeVideoList(dto: VideoDTO[]) {
+  return decodeList(dto, decodeVideo);
+}

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

@@ -13,6 +13,7 @@ const hospitalPreparation = () =>
   import('#/views/outcome/HospitalPreparationList.vue');
 const newDrugCertificate = () =>
   import('#/views/outcome/NewDrugCertificateList.vue');
+const video = () => import('#/views/outcome/VideoList.vue');
 
 const routes: RouteRecordRaw[] = [
   {
@@ -113,7 +114,7 @@ const routes: RouteRecordRaw[] = [
           icon: 'carbon:user',
           title: '视频管理',
         },
-        component: placeholder,
+        component: video,
       },
       {
         path: '/outcome/reading-notes',

+ 269 - 0
apps/wisdom-legacy/src/views/outcome/VideoList.vue

@@ -0,0 +1,269 @@
+<script setup lang="ts">
+import type { VideoCategory, VideoSortType, VideoVO } from '#/api/outcome';
+
+import { computed, ref, shallowRef, triggerRef, watch } from 'vue';
+
+import { Page } from '@vben/common-ui';
+import { Plus } from '@vben/icons';
+
+import { ClockCircleOutlined, FireOutlined } from '@ant-design/icons-vue';
+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 {
+  deleteVideoMethod,
+  listVideoMethod,
+  VIDEO_CATEGORY_OPTIONS,
+} from '#/api/outcome';
+import { useWorkroomStore } from '#/stores';
+
+import VideoCard from './components/VideoCard.vue';
+import VideoEdit from './modules/VideoEdit.vue';
+
+const workroomStore = useWorkroomStore();
+const { workroomId } = storeToRefs(workroomStore);
+
+const keyword = ref('');
+const searchKeyword = ref('');
+const category = ref<'' | VideoCategory>('');
+const sortType = ref<VideoSortType>('latest');
+const pageNum = ref(1);
+const pageSize = ref(12);
+const loading = ref(false);
+const pageData = ref<{ items: VideoVO[]; total: number }>({
+  total: 0,
+  items: [],
+});
+
+const deletingIds = shallowRef(new Set<string>());
+
+const categoryOptions = [
+  { label: '全部类别', value: '' },
+  ...VIDEO_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(
+      listVideoMethod(pageNum.value, pageSize.value, {
+        $filters: [],
+        $sorts: [],
+        keyword: searchKeyword.value || undefined,
+        workroomId: workroomId.value,
+        category: category.value || undefined,
+        sortType: sortType.value,
+      }),
+      { force: true },
+    );
+  } finally {
+    loading.value = false;
+  }
+}
+
+const items = computed(() => pageData.value.items);
+const total = computed(() => pageData.value.total);
+
+const [Edit, editApi] = useShell('modal', {
+  connectedComponent: VideoEdit,
+});
+
+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_skill',
+      status: true,
+    } as VideoVO)
+    .open<{ id?: string }>(Promise.withResolvers());
+  if (result?.id) {
+    pageNum.value = 1;
+    await loadList();
+  }
+}
+
+async function openEdit(row: VideoVO) {
+  const result = await editApi
+    .setData(row)
+    .open<{ id?: string }>(Promise.withResolvers());
+  if (result?.id) {
+    await loadList();
+  }
+}
+
+function openPlay(row: VideoVO) {
+  if (row.videoUrl) {
+    window.open(row.videoUrl, '_blank');
+  } else {
+    message.warning('暂无视频文件');
+  }
+}
+
+async function handleDelete(row: VideoVO) {
+  if (!row.id || isDeleting(row.id)) return;
+  setDeleting(row.id, true);
+  try {
+    await invokeMethod(deleteVideoMethod(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;
+}
+
+function setSortType(value: VideoSortType) {
+  if (sortType.value === value) return;
+  sortType.value = value;
+  pageNum.value = 1;
+}
+
+watch(category, () => {
+  pageNum.value = 1;
+});
+
+watch(workroomId, () => {
+  pageNum.value = 1;
+});
+
+watch(
+  [pageNum, pageSize, searchKeyword, category, sortType, 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 class="ml-auto flex items-center gap-2">
+          <Button
+            :type="sortType === 'latest' ? 'primary' : 'default'"
+            :ghost="sortType === 'latest'"
+            class="inline-flex items-center gap-1"
+            @click="setSortType('latest')"
+          >
+            <ClockCircleOutlined />
+            最新
+          </Button>
+          <Button
+            :type="sortType === 'popular' ? 'primary' : 'default'"
+            :ghost="sortType === 'popular'"
+            class="inline-flex items-center gap-1"
+            @click="setSortType('popular')"
+          >
+            <FireOutlined />
+            热门
+          </Button>
+        </div>
+      </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="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3"
+          >
+            <VideoCard
+              v-for="item in items"
+              :key="item.id"
+              :data="item"
+              :deleting="isDeleting(item.id)"
+              @edit="openEdit"
+              @play="openPlay"
+              @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>

+ 124 - 0
apps/wisdom-legacy/src/views/outcome/components/VideoCard.vue

@@ -0,0 +1,124 @@
+<script setup lang="ts">
+import type { VideoVO } from '#/api/outcome';
+
+import {
+  DeleteOutlined,
+  EditOutlined,
+  EyeOutlined,
+  PlayCircleFilled,
+} from '@ant-design/icons-vue';
+import { Button, Popconfirm, Tag } from 'ant-design-vue';
+
+import { getVideoCategoryLabel } from '#/api/outcome';
+
+const { data, deleting } = defineProps<{
+  data: VideoVO;
+  deleting?: boolean;
+}>();
+
+const emit = defineEmits<{
+  delete: [VideoVO];
+  edit: [VideoVO];
+  play: [VideoVO];
+}>();
+
+function formatUploadDate(value?: string) {
+  if (!value) return '';
+  return String(value).slice(0, 10);
+}
+</script>
+
+<template>
+  <div
+    class="video-card group overflow-hidden rounded-lg border border-border bg-card"
+  >
+    <div
+      class="relative aspect-video cursor-pointer overflow-hidden bg-muted"
+      @click="emit('play', data)"
+    >
+      <img
+        v-if="data.thumbnailUrl"
+        :alt="data.name"
+        :src="data.thumbnailUrl"
+        class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
+      />
+      <div v-else class="flex size-full items-center justify-center bg-muted">
+        <PlayCircleFilled class="text-5xl text-white/80" />
+      </div>
+
+      <div
+        class="pointer-events-none absolute inset-0 flex items-center justify-center bg-black/10 opacity-0 transition-opacity group-hover:opacity-100"
+      >
+        <PlayCircleFilled class="text-5xl text-white drop-shadow" />
+      </div>
+
+      <span
+        v-if="data.duration"
+        class="absolute bottom-2 right-2 rounded bg-black/70 px-1.5 py-0.5 text-xs text-white"
+      >
+        {{ data.duration }}
+      </span>
+
+      <div
+        class="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100"
+      >
+        <Button size="small" type="primary" @click.stop="emit('edit', data)">
+          <EditOutlined />
+        </Button>
+        <Popconfirm
+          cancel-text="取消"
+          ok-text="确定"
+          title="确定删除该视频吗?"
+          @confirm="emit('delete', data)"
+        >
+          <Button :loading="deleting" danger size="small" @click.stop>
+            <DeleteOutlined />
+          </Button>
+        </Popconfirm>
+      </div>
+    </div>
+
+    <div class="p-4">
+      <h3
+        class="mb-2 line-clamp-2 text-base font-semibold leading-6 text-foreground"
+        :title="data.name"
+      >
+        {{ data.name }}
+      </h3>
+
+      <div
+        class="mb-3 flex flex-wrap items-center gap-1 text-sm text-foreground/70"
+      >
+        <Tag color="processing" class="m-0">
+          {{ getVideoCategoryLabel(data.category) }}
+        </Tag>
+        <template v-if="data.speaker">
+          <span class="text-foreground/40">·</span>
+          <span>{{ data.speaker }}</span>
+        </template>
+      </div>
+
+      <div
+        class="flex flex-wrap items-center justify-between gap-2 text-xs text-foreground/55"
+      >
+        <span class="inline-flex items-center gap-1">
+          <EyeOutlined />
+          {{ data.viewCount ?? 0 }} 次观看
+        </span>
+        <span v-if="data.createdAt">
+          上传于 {{ formatUploadDate(data.createdAt) }}
+        </span>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.video-card {
+  transition: box-shadow 0.2s ease;
+}
+
+.video-card:hover {
+  box-shadow: 0 4px 12px hsl(var(--foreground) / 8%);
+}
+</style>

+ 257 - 0
apps/wisdom-legacy/src/views/outcome/modules/VideoEdit.vue

@@ -0,0 +1,257 @@
+<script setup lang="ts">
+import type { UploadFile, UploadProps } from 'ant-design-vue';
+
+import type { VideoSubmitVO } from '#/api/outcome';
+
+import { ref } from 'vue';
+
+import { PictureOutlined, UploadOutlined } from '@ant-design/icons-vue';
+import { message, Upload, UploadDragger } from 'ant-design-vue';
+
+import { useEditShell } from '#/adapter/shell';
+import { invokeMethod } from '#/adapter/vxe-table/proxy/invoke-method';
+import { uploadFileMethod } from '#/api/common';
+import { useWorkroomStore } from '#/stores';
+
+import { videoForm } from '../video.data';
+
+const workroomStore = useWorkroomStore();
+
+const VIDEO_MAX_SIZE = 50 * 1024 * 1024;
+
+const videoFileList = ref<UploadFile[]>([]);
+const thumbnailFileList = ref<UploadFile[]>([]);
+const videoUrl = ref<string>();
+const thumbnailUrl = ref<string>();
+const duration = ref<string>();
+const videoUploading = ref(false);
+const thumbnailUploading = ref(false);
+
+function createUploadFile(url: string | undefined, name: string): UploadFile[] {
+  if (!url) return [];
+  return [
+    {
+      uid: '-1',
+      name,
+      status: 'done',
+      url,
+    },
+  ];
+}
+
+function resetUploads() {
+  videoFileList.value = [];
+  thumbnailFileList.value = [];
+  videoUrl.value = void 0;
+  thumbnailUrl.value = void 0;
+  duration.value = void 0;
+  videoUploading.value = false;
+  thumbnailUploading.value = false;
+}
+
+function formatDuration(totalSeconds: number) {
+  const hours = Math.floor(totalSeconds / 3600);
+  const minutes = Math.floor((totalSeconds % 3600) / 60);
+  const seconds = Math.floor(totalSeconds % 60);
+  if (hours > 0) {
+    return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
+  }
+  return `${minutes}:${String(seconds).padStart(2, '0')}`;
+}
+
+async function readVideoDuration(file: File) {
+  return new Promise<string>((resolve) => {
+    const video = document.createElement('video');
+    video.preload = 'metadata';
+    video.addEventListener('loadedmetadata', () => {
+      URL.revokeObjectURL(video.src);
+      resolve(formatDuration(Math.floor(video.duration)));
+    });
+    video.addEventListener('error', () => {
+      URL.revokeObjectURL(video.src);
+      resolve('');
+    });
+    video.src = URL.createObjectURL(file);
+  });
+}
+
+async function uploadImmediately(
+  file: File,
+  listRef: typeof videoFileList,
+  urlRef: typeof videoUrl,
+  uploadingRef: typeof videoUploading,
+  onSuccess?: () => Promise<void> | void,
+) {
+  const uploadFile: UploadFile = {
+    uid: `${Date.now()}`,
+    name: file.name,
+    status: 'uploading',
+    percent: 0,
+  };
+  listRef.value = [uploadFile];
+  uploadingRef.value = true;
+
+  try {
+    const url = await invokeMethod(uploadFileMethod(file), { force: true });
+    if (!url) {
+      throw new Error('upload empty url');
+    }
+    urlRef.value = url;
+    listRef.value = [
+      {
+        ...uploadFile,
+        status: 'done',
+        url,
+      },
+    ];
+    await onSuccess?.();
+  } catch {
+    urlRef.value = void 0;
+    listRef.value = [
+      {
+        ...uploadFile,
+        status: 'error',
+      },
+    ];
+    message.error(`${file.name} 上传失败,请重试`);
+  } finally {
+    uploadingRef.value = false;
+  }
+}
+
+const { Form, Shell, api } = useEditShell<VideoSubmitVO>(videoForm, {
+  onLoaded(model) {
+    api.shell.setState({
+      title: model.id ? '编辑视频' : '上传视频',
+    });
+  },
+  handleLoad(model) {
+    resetUploads();
+    videoUrl.value = model.videoUrl;
+    thumbnailUrl.value = model.thumbnailUrl;
+    duration.value = model.duration;
+    videoFileList.value = createUploadFile(
+      model.videoUrl,
+      model.name || '视频',
+    );
+    thumbnailFileList.value = createUploadFile(model.thumbnailUrl, '视频首图');
+    return model;
+  },
+  handleSubmit(values) {
+    const workroomId = values.workroomId || workroomStore.workroomId;
+    if (!workroomId) {
+      message.error('请先选择工作室');
+      throw new Error('workroom required');
+    }
+    if (videoUploading.value || thumbnailUploading.value) {
+      message.warning('文件上传中,请稍候');
+      throw new Error('uploading');
+    }
+    if (!videoUrl.value) {
+      message.error('请上传视频');
+      throw new Error('video required');
+    }
+    if (!thumbnailUrl.value) {
+      message.error('请上传视频首图');
+      throw new Error('thumbnail required');
+    }
+
+    return {
+      ...values,
+      workroomId,
+      videoUrl: videoUrl.value,
+      thumbnailUrl: thumbnailUrl.value,
+      duration: duration.value,
+    };
+  },
+  onClosed: resetUploads,
+});
+
+const beforeVideoUpload: UploadProps['beforeUpload'] = (file) => {
+  if (file.size > VIDEO_MAX_SIZE) {
+    message.error('上传视频大小不超过50M');
+    return Upload.LIST_IGNORE;
+  }
+  void uploadImmediately(
+    file,
+    videoFileList,
+    videoUrl,
+    videoUploading,
+    async () => {
+      duration.value = await readVideoDuration(file);
+    },
+  );
+  return false;
+};
+
+const beforeThumbnailUpload: UploadProps['beforeUpload'] = (file) => {
+  const isImage = file.type.startsWith('image/');
+  if (!isImage) {
+    message.error('请上传图片文件');
+    return Upload.LIST_IGNORE;
+  }
+  void uploadImmediately(
+    file,
+    thumbnailFileList,
+    thumbnailUrl,
+    thumbnailUploading,
+  );
+  return false;
+};
+
+function onVideoRemove() {
+  videoUrl.value = void 0;
+  duration.value = void 0;
+}
+
+function onThumbnailRemove() {
+  thumbnailUrl.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="videoFileList"
+          :before-upload="beforeVideoUpload"
+          :max-count="1"
+          accept="video/*"
+          @remove="onVideoRemove"
+        >
+          <p class="ant-upload-drag-icon">
+            <UploadOutlined />
+          </p>
+          <p class="ant-upload-text">点击上传或拖拽视频到此处</p>
+          <p class="ant-upload-hint">上传视频大小不超过50M</p>
+        </UploadDragger>
+      </div>
+
+      <div class="pb-2">
+        <div class="mb-2 text-sm">
+          <span class="text-destructive mr-1">*</span>
+          视频首图
+        </div>
+        <Upload
+          v-model:file-list="thumbnailFileList"
+          :before-upload="beforeThumbnailUpload"
+          :max-count="1"
+          accept="image/*"
+          list-type="picture-card"
+          @remove="onThumbnailRemove"
+        >
+          <div v-if="thumbnailFileList.length === 0" class="text-gray-400">
+            <PictureOutlined class="text-2xl" />
+          </div>
+        </Upload>
+      </div>
+    </div>
+  </Shell>
+</template>

+ 78 - 0
apps/wisdom-legacy/src/views/outcome/video.data.ts

@@ -0,0 +1,78 @@
+import type { VideoVO } from '#/api/outcome';
+
+import { getPopupContainer } from '@vben/utils';
+
+import { defineEditShell } from '#/adapter/shell/edit';
+import {
+  editVideoMethod,
+  getVideoMethod,
+  VIDEO_CATEGORY_OPTIONS,
+  VideoVOSchema,
+} from '#/api/outcome';
+
+export const videoForm = defineEditShell<VideoVO>({
+  scope: 'outcome.video',
+  title: '视频',
+  submit: editVideoMethod,
+  load: getVideoMethod,
+  shell: {
+    type: 'modal',
+    class: '!w-[640px]',
+    confirmText: '确定',
+  },
+  form: {
+    layout: 'vertical',
+    wrapperClass: 'grid-cols-1',
+  },
+  schema: [
+    {
+      component: 'Input',
+      fieldName: 'name',
+      label: '视频名称',
+      componentProps: {
+        placeholder: '请输入',
+      },
+      rules: VideoVOSchema.shape.name,
+    },
+    {
+      component: 'Input',
+      fieldName: 'speaker',
+      label: '主讲人',
+      componentProps: {
+        placeholder: '请输入',
+      },
+    },
+    {
+      component: 'Select',
+      fieldName: 'category',
+      label: '视频分类',
+      defaultValue: 'clinical_skill',
+      componentProps: {
+        options: [...VIDEO_CATEGORY_OPTIONS],
+        placeholder: '请选择视频分类',
+        getPopupContainer,
+      },
+      rules: VideoVOSchema.shape.category,
+    },
+    {
+      component: 'Switch',
+      fieldName: 'status',
+      label: '状态',
+      defaultValue: true,
+      componentProps: {
+        checkedChildren: '启用',
+        unCheckedChildren: '禁用',
+      },
+      rules: VideoVOSchema.shape.status,
+    },
+    {
+      component: 'Input',
+      fieldName: 'workroomId',
+      dependencies: {
+        show: false,
+        triggerFields: ['workroomId'],
+      },
+      rules: VideoVOSchema.shape.workroomId,
+    },
+  ],
+});