2
0
فهرست منبع

✨ feat(vod): 新增视频点播管理功能

- 添加视频上传组件,支持腾讯云VOD SDK集成
- 创建视频管理页面,包含列表、搜索、分页功能
- 实现视频的创建、编辑、删除操作
- 添加视频状态管理和文件信息展示
- 集成到管理菜单和路由系统
- 创建视频实体、Schema和服务层
- 支持视频标题、描述、文件ID、播放地址等字段管理
yourname 6 ماه پیش
والد
کامیت
01e52b54f9

+ 287 - 0
src/client/admin/components/vod/VodUpload.tsx

@@ -0,0 +1,287 @@
+import React, { useRef, useState } from "react";
+import { Button, Card, message, Progress, Space, Tag } from "antd";
+import TcVod from "vod-js-sdk-v6";
+import { vodClient } from "@/client/api";
+
+interface UploadTask {
+  file: File;
+  progress: number;
+  status: "pending" | "uploading" | "success" | "error" | "canceled";
+  fileId?: string;
+  videoUrl?: string;
+  coverUrl?: string;
+  cancel?: () => void;
+}
+
+interface VodUploadProps {
+  onUploadSuccess?: (result: { fileId: string; videoUrl: string; coverUrl?: string }) => void;
+  onUploadError?: (error: Error) => void;
+}
+
+export const VodUpload: React.FC<VodUploadProps> = ({ 
+  onUploadSuccess, 
+  onUploadError 
+}) => {
+  const videoInputRef = useRef<HTMLInputElement>(null);
+  const coverInputRef = useRef<HTMLInputElement>(null);
+  const [uploadTasks, setUploadTasks] = useState<UploadTask[]>([]);
+  const [selectedVideo, setSelectedVideo] = useState<File | null>(null);
+  const [selectedCover, setSelectedCover] = useState<File | null>(null);
+
+  // 获取上传签名
+  const getSignature = async () => {
+    try {
+      const response = await vodClient.signature.$get();
+      if (response.status !== 200) {
+        throw new Error("获取上传签名失败");
+      }
+      const { signature } = await response.json();
+      return signature;
+    } catch (error) {
+      message.error("获取上传签名失败");
+      throw error;
+    }
+  };
+
+  // 初始化VOD SDK
+  const tcVod = new TcVod({
+    getSignature: getSignature,
+  });
+
+  // 处理视频文件选择
+  const handleVideoSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
+    if (e.target.files && e.target.files[0]) {
+      setSelectedVideo(e.target.files[0]);
+    }
+  };
+
+  // 处理封面文件选择
+  const handleCoverSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
+    if (e.target.files && e.target.files[0]) {
+      setSelectedCover(e.target.files[0]);
+    }
+  };
+
+  // 开始上传
+  const startUpload = async () => {
+    if (!selectedVideo) {
+      message.warning("请先选择视频文件");
+      return;
+    }
+
+    const newTask: UploadTask = {
+      file: selectedVideo,
+      progress: 0,
+      status: "pending",
+    };
+
+    setUploadTasks((prev) => [...prev, newTask]);
+
+    try {
+      const uploader = tcVod.upload({
+        mediaFile: selectedVideo,
+        coverFile: selectedCover || undefined,
+      });
+
+      const updatedTask: UploadTask = {
+        ...newTask,
+        status: "uploading",
+        cancel: () => {
+          uploader.cancel();
+          setUploadTasks((prev) =>
+            prev.map((task) =>
+              task.file === newTask.file
+                ? { ...task, status: "canceled" }
+                : task
+            )
+          );
+        },
+      };
+
+      setUploadTasks((prev) =>
+        prev.map((task) => task.file === newTask.file ? updatedTask : task)
+      );
+
+      // 监听上传进度
+      uploader.on("media_progress", (info) => {
+        setUploadTasks((prev) =>
+          prev.map((task) =>
+            task.file === newTask.file
+              ? { ...task, progress: info.percent * 100 }
+              : task
+          )
+        );
+      });
+
+      // 监听上传完成
+      uploader.on("media_upload", (info) => {
+        setUploadTasks((prev) =>
+          prev.map((task) =>
+            task.file === newTask.file ? { ...task, status: "success" } : task
+          )
+        );
+      });
+
+      // 执行上传
+      const result = await uploader.done();
+
+      const successTask = {
+        ...updatedTask,
+        fileId: result.fileId,
+        videoUrl: result.video.url,
+        coverUrl: result.cover?.url,
+        status: "success",
+      };
+
+      setUploadTasks((prev) =>
+        prev.map((task) =>
+          task.file === newTask.file ? successTask : task
+        )
+      );
+
+      message.success("视频上传成功");
+      
+      // 调用成功回调
+      if (onUploadSuccess) {
+        onUploadSuccess({
+          fileId: result.fileId,
+          videoUrl: result.video.url,
+          coverUrl: result.cover?.url
+        });
+      }
+    } catch (error) {
+      setUploadTasks((prev) =>
+        prev.map((task) =>
+          task.file === newTask.file ? { ...task, status: "error" } : task
+        )
+      );
+      message.error("视频上传失败");
+      
+      // 调用错误回调
+      if (onUploadError) {
+        onUploadError(error as Error);
+      }
+    } finally {
+      setSelectedVideo(null);
+      setSelectedCover(null);
+      if (videoInputRef.current) videoInputRef.current.value = "";
+      if (coverInputRef.current) coverInputRef.current.value = "";
+    }
+  };
+
+  // 渲染上传状态标签
+  const renderStatusTag = (status: UploadTask["status"]) => {
+    switch (status) {
+      case "pending":
+        return <Tag color="default">等待上传</Tag>;
+      case "uploading":
+        return <Tag color="processing">上传中</Tag>;
+      case "success":
+        return <Tag color="success">上传成功</Tag>;
+      case "error":
+        return <Tag color="error">上传失败</Tag>;
+      case "canceled":
+        return <Tag color="warning">已取消</Tag>;
+      default:
+        return <Tag color="default">未知状态</Tag>;
+    }
+  };
+
+  return (
+    <Card title="视频上传" className="mb-4">
+      <Space direction="vertical" style={{ width: "100%" }}>
+        <div>
+          <input
+            type="file"
+            ref={videoInputRef}
+            onChange={handleVideoSelect}
+            accept="video/*"
+            style={{ display: "none" }}
+          />
+          <Button
+            onClick={() => videoInputRef.current?.click()}
+            type="primary"
+          >
+            {selectedVideo ? selectedVideo.name : "选择视频文件"}
+          </Button>
+
+          <input
+            type="file"
+            ref={coverInputRef}
+            onChange={handleCoverSelect}
+            accept="image/*"
+            style={{ display: "none", marginLeft: 16 }}
+          />
+          <Button
+            onClick={() => coverInputRef.current?.click()}
+            style={{ marginLeft: 16 }}
+          >
+            {selectedCover ? selectedCover.name : "选择封面(可选)"}
+          </Button>
+
+          <Button
+            type="primary"
+            onClick={startUpload}
+            disabled={!selectedVideo}
+            style={{ marginLeft: 16 }}
+          >
+            开始上传
+          </Button>
+        </div>
+
+        <div style={{ marginTop: 24 }}>
+          {uploadTasks.map((task, index) => (
+            <div key={index} style={{ marginBottom: 16 }}>
+              <div style={{ marginBottom: 8 }}>
+                <span style={{ marginRight: 8 }}>{task.file.name}</span>
+                {renderStatusTag(task.status)}
+                {task.status === "uploading" && task.cancel && (
+                  <Button
+                    type="link"
+                    danger
+                    onClick={task.cancel}
+                    style={{ marginLeft: 8 }}
+                  >
+                    取消上传
+                  </Button>
+                )}
+              </div>
+              {task.status === "uploading" && (
+                <Progress percent={task.progress} status="active" />
+              )}
+              {task.fileId && (
+                <div style={{ marginTop: 8 }}>
+                  <div>File ID: {task.fileId}</div>
+                  {task.videoUrl && (
+                    <div>
+                      视频地址:{" "}
+                      <a
+                        href={task.videoUrl}
+                        target="_blank"
+                        rel="noreferrer"
+                      >
+                        {task.videoUrl}
+                      </a>
+                    </div>
+                  )}
+                  {task.coverUrl && (
+                    <div>
+                      封面地址:{" "}
+                      <a
+                        href={task.coverUrl}
+                        target="_blank"
+                        rel="noreferrer"
+                      >
+                        {task.coverUrl}
+                      </a>
+                    </div>
+                  )}
+                </div>
+              )}
+            </div>
+          ))}
+        </div>
+      </Space>
+    </Card>
+  );
+};

+ 9 - 1
src/client/admin/menu.tsx

@@ -13,7 +13,8 @@ import {
   Code,
   Code,
   Calendar,
   Calendar,
   File,
   File,
-  MessageSquare
+  MessageSquare,
+  Video
 } from 'lucide-react';
 } from 'lucide-react';
 
 
 export interface MenuItem {
 export interface MenuItem {
@@ -140,6 +141,13 @@ export const useMenu = () => {
       icon: <MessageSquare className="h-4 w-4" />,
       icon: <MessageSquare className="h-4 w-4" />,
       path: '/admin/chat-messages',
       path: '/admin/chat-messages',
       permission: 'chat:manage'
       permission: 'chat:manage'
+    },
+    {
+      key: 'vod-videos',
+      label: '视频管理',
+      icon: <Video className="h-4 w-4" />,
+      path: '/admin/vod-videos',
+      permission: 'vod:manage'
     }
     }
   ];
   ];
 
 

+ 599 - 0
src/client/admin/pages/VodVideos.tsx

@@ -0,0 +1,599 @@
+import React, { useState } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { format } from 'date-fns';
+import { zhCN } from 'date-fns/locale';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useForm } from 'react-hook-form';
+import { toast } from 'sonner';
+import { Plus, Search, Edit, Trash2, Eye, Video, Play, Clock, File } from 'lucide-react';
+
+import { vodVideoClient } from '@/client/api';
+import type { InferResponseType, InferRequestType } from 'hono/client';
+import { VodVideoSchema, CreateVodVideoDto, UpdateVodVideoDto, VodVideoStatus } from '@/server/modules/vod/vod-video.schema';
+import { VodUpload } from '@/client/admin/components/vod/VodUpload';
+
+import { Button } from '@/client/components/ui/button';
+import { Input } from '@/client/components/ui/input';
+import { Badge } from '@/client/components/ui/badge';
+import { Card, CardContent, CardHeader, CardTitle } from '@/client/components/ui/card';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/client/components/ui/table';
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/client/components/ui/dialog';
+import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/client/components/ui/form';
+import { Textarea } from '@/client/components/ui/textarea';
+import { Skeleton } from '@/client/components/ui/skeleton';
+import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/client/components/ui/alert-dialog';
+
+import { DataTablePagination } from '@/client/admin/components/DataTablePagination';
+
+type VodVideoListResponse = InferResponseType<typeof vodVideoClient.$get, 200>;
+type VodVideoDetailResponse = InferResponseType<typeof vodVideoClient[':id']['$get'], 200>;
+type CreateVodVideoRequest = InferRequestType<typeof vodVideoClient.$post>['json'];
+type UpdateVodVideoRequest = InferRequestType<typeof vodVideoClient[':id']['$put']>['json'];
+
+// 创建表单schema
+const createFormSchema = CreateVodVideoDto;
+const updateFormSchema = UpdateVodVideoDto;
+
+// 视频状态映射
+const videoStatusMap = {
+  [VodVideoStatus.PENDING]: { label: '待处理', color: 'default' },
+  [VodVideoStatus.PROCESSING]: { label: '处理中', color: 'processing' },
+  [VodVideoStatus.COMPLETED]: { label: '已完成', color: 'success' },
+  [VodVideoStatus.FAILED]: { label: '失败', color: 'destructive' }
+};
+
+// 格式化文件大小
+const formatFileSize = (bytes: number): string => {
+  if (bytes === 0) return '0 B';
+  const k = 1024;
+  const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
+  const i = Math.floor(Math.log(bytes) / Math.log(k));
+  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
+};
+
+// 格式化视频时长
+const formatDuration = (seconds: number): string => {
+  const hours = Math.floor(seconds / 3600);
+  const minutes = Math.floor((seconds % 3600) / 60);
+  const secs = seconds % 60;
+  
+  if (hours > 0) {
+    return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
+  }
+  return `${minutes}:${secs.toString().padStart(2, '0')}`;
+};
+
+// VOD视频管理页面
+export const VodVideosPage = () => {
+  const [searchParams, setSearchParams] = useState({
+    page: 1,
+    limit: 10,
+    search: ''
+  });
+  const [isModalOpen, setIsModalOpen] = useState(false);
+  const [isCreateForm, setIsCreateForm] = useState(true);
+  const [editingVideo, setEditingVideo] = useState<any>(null);
+  const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+  const [videoToDelete, setVideoToDelete] = useState<number | null>(null);
+  const [uploadedFileInfo, setUploadedFileInfo] = useState<{
+    fileId: string;
+    videoUrl: string;
+    coverUrl?: string;
+  } | null>(null);
+
+  const queryClient = useQueryClient();
+
+  // 数据查询
+  const { data, isLoading, refetch } = useQuery({
+    queryKey: ['vod-videos', searchParams],
+    queryFn: async () => {
+      const res = await vodVideoClient.$get({
+        query: {
+          page: searchParams.page,
+          pageSize: searchParams.limit,
+          keyword: searchParams.search
+        }
+      });
+      if (res.status !== 200) throw new Error('获取视频列表失败');
+      return await res.json();
+    }
+  });
+
+  // 创建视频
+  const createMutation = useMutation({
+    mutationFn: async (data: CreateVodVideoRequest) => {
+      const res = await vodVideoClient.$post({ json: data });
+      if (res.status !== 201) throw new Error('创建视频失败');
+      return await res.json();
+    },
+    onSuccess: () => {
+      toast.success('视频创建成功');
+      setIsModalOpen(false);
+      createForm.reset();
+      setUploadedFileInfo(null);
+      queryClient.invalidateQueries({ queryKey: ['vod-videos'] });
+    },
+    onError: (error) => {
+      toast.error(`创建视频失败: ${error.message}`);
+    }
+  });
+
+  // 更新视频
+  const updateMutation = useMutation({
+    mutationFn: async ({ id, data }: { id: number; data: UpdateVodVideoRequest }) => {
+      const res = await vodVideoClient[':id']['$put']({
+        param: { id: id.toString() },
+        json: data
+      });
+      if (res.status !== 200) throw new Error('更新视频失败');
+      return await res.json();
+    },
+    onSuccess: () => {
+      toast.success('视频更新成功');
+      setIsModalOpen(false);
+      updateForm.reset();
+      setEditingVideo(null);
+      queryClient.invalidateQueries({ queryKey: ['vod-videos'] });
+    },
+    onError: (error) => {
+      toast.error(`更新视频失败: ${error.message}`);
+    }
+  });
+
+  // 删除视频
+  const deleteMutation = useMutation({
+    mutationFn: async (id: number) => {
+      const res = await vodVideoClient[':id']['$delete']({
+        param: { id: id.toString() }
+      });
+      if (res.status !== 204) throw new Error('删除视频失败');
+      return { success: true };
+    },
+    onSuccess: () => {
+      toast.success('视频删除成功');
+      setDeleteDialogOpen(false);
+      queryClient.invalidateQueries({ queryKey: ['vod-videos'] });
+    },
+    onError: (error) => {
+      toast.error(`删除视频失败: ${error.message}`);
+    }
+  });
+
+  // 表单实例
+  const createForm = useForm<CreateVodVideoRequest>({
+    resolver: zodResolver(createFormSchema),
+    defaultValues: {
+      title: '',
+      description: '',
+      fileId: '',
+      videoUrl: '',
+      coverUrl: '',
+      duration: 0,
+      size: 0,
+      status: VodVideoStatus.PENDING
+    }
+  });
+
+  const updateForm = useForm<UpdateVodVideoRequest>({
+    resolver: zodResolver(updateFormSchema),
+    defaultValues: {
+      title: '',
+      description: '',
+      fileId: '',
+      videoUrl: '',
+      coverUrl: '',
+      duration: 0,
+      size: 0,
+      status: VodVideoStatus.PENDING
+    }
+  });
+
+  // 处理搜索
+  const handleSearch = (e: React.FormEvent) => {
+    e.preventDefault();
+    refetch();
+  };
+
+  // 处理创建视频
+  const handleCreateVideo = () => {
+    setIsCreateForm(true);
+    setEditingVideo(null);
+    createForm.reset();
+    setUploadedFileInfo(null);
+    setIsModalOpen(true);
+  };
+
+  // 处理编辑视频
+  const handleEditVideo = (video: any) => {
+    setIsCreateForm(false);
+    setEditingVideo(video);
+    updateForm.reset({
+      title: video.title,
+      description: video.description || '',
+      fileId: video.fileId,
+      videoUrl: video.videoUrl,
+      coverUrl: video.coverUrl || '',
+      duration: video.duration,
+      size: video.size,
+      status: video.status
+    });
+    setIsModalOpen(true);
+  };
+
+  // 处理删除视频
+  const handleDeleteVideo = (id: number) => {
+    setVideoToDelete(id);
+    setDeleteDialogOpen(true);
+  };
+
+  // 确认删除
+  const confirmDelete = () => {
+    if (videoToDelete) {
+      deleteMutation.mutate(videoToDelete);
+    }
+  };
+
+  // 处理表单提交
+  const handleCreateSubmit = (data: CreateVodVideoRequest) => {
+    createMutation.mutate(data);
+  };
+
+  const handleUpdateSubmit = (data: UpdateVodVideoRequest) => {
+    if (editingVideo) {
+      updateMutation.mutate({ id: editingVideo.id, data });
+    }
+  };
+
+  // 处理上传成功
+  const handleUploadSuccess = (result: { fileId: string; videoUrl: string; coverUrl?: string }) => {
+    setUploadedFileInfo(result);
+    createForm.setValue('fileId', result.fileId);
+    createForm.setValue('videoUrl', result.videoUrl);
+    if (result.coverUrl) {
+      createForm.setValue('coverUrl', result.coverUrl);
+    }
+    toast.success('视频上传成功,请填写其他信息');
+  };
+
+  // 渲染视频状态标签
+  const renderStatusBadge = (status: number) => {
+    const statusInfo = videoStatusMap[status as keyof typeof videoStatusMap] || { label: '未知', color: 'default' };
+    return <Badge variant={statusInfo.color as any}>{statusInfo.label}</Badge>;
+  };
+
+  return (
+    <div className="space-y-6">
+      {/* 页面标题和操作按钮 */}
+      <div className="flex items-center justify-between">
+        <div>
+          <h1 className="text-2xl font-bold">视频管理</h1>
+          <p className="text-sm text-muted-foreground">管理VOD视频资源</p>
+        </div>
+        <Button onClick={handleCreateVideo}>
+          <Plus className="h-4 w-4 mr-2" />
+          新增视频
+        </Button>
+      </div>
+
+      {/* 搜索区域 */}
+      <Card>
+        <CardHeader>
+          <CardTitle className="text-lg">搜索</CardTitle>
+        </CardHeader>
+        <CardContent>
+          <form onSubmit={handleSearch} className="flex gap-4">
+            <Input
+              placeholder="搜索标题、描述或文件ID..."
+              value={searchParams.search}
+              onChange={(e) => setSearchParams({ ...searchParams, search: e.target.value })}
+              className="max-w-sm"
+            />
+            <Button type="submit" variant="outline">
+              <Search className="h-4 w-4 mr-2" />
+              搜索
+            </Button>
+          </form>
+        </CardContent>
+      </Card>
+
+      {/* 数据表格 */}
+      <Card>
+        <CardHeader>
+          <CardTitle className="text-lg">视频列表</CardTitle>
+        </CardHeader>
+        <CardContent>
+          {isLoading ? (
+            <div className="space-y-4">
+              {Array.from({ length: 5 }).map((_, index) => (
+                <div key={index} className="flex items-center space-x-4">
+                  <Skeleton className="h-12 w-12 rounded-full" />
+                  <div className="space-y-2">
+                    <Skeleton className="h-4 w-[250px]" />
+                    <Skeleton className="h-4 w-[200px]" />
+                  </div>
+                </div>
+              ))}
+            </div>
+          ) : (
+            <>
+              <Table>
+                <TableHeader>
+                  <TableRow>
+                    <TableHead>标题</TableHead>
+                    <TableHead>描述</TableHead>
+                    <TableHead>文件ID</TableHead>
+                    <TableHead>状态</TableHead>
+                    <TableHead>时长</TableHead>
+                    <TableHead>大小</TableHead>
+                    <TableHead>创建时间</TableHead>
+                    <TableHead>操作</TableHead>
+                  </TableRow>
+                </TableHeader>
+                <TableBody>
+                  {data?.data?.length ? (
+                    data.data.map((video: any) => (
+                      <TableRow key={video.id}>
+                        <TableCell className="font-medium">{video.title}</TableCell>
+                        <TableCell className="max-w-xs truncate">{video.description || '-'}</TableCell>
+                        <TableCell className="font-mono text-sm">{video.fileId}</TableCell>
+                        <TableCell>{renderStatusBadge(video.status)}</TableCell>
+                        <TableCell>
+                          <div className="flex items-center">
+                            <Clock className="h-4 w-4 mr-1" />
+                            {formatDuration(video.duration)}
+                          </div>
+                        </TableCell>
+                        <TableCell>
+                          <div className="flex items-center">
+                            <File className="h-4 w-4 mr-1" />
+                            {formatFileSize(video.size)}
+                          </div>
+                        </TableCell>
+                        <TableCell>
+                          {format(new Date(video.createdAt), 'yyyy-MM-dd HH:mm', { locale: zhCN })}
+                        </TableCell>
+                        <TableCell>
+                          <div className="flex space-x-2">
+                            <Button
+                              variant="outline"
+                              size="sm"
+                              onClick={() => handleEditVideo(video)}
+                            >
+                              <Edit className="h-4 w-4" />
+                            </Button>
+                            <Button
+                              variant="outline"
+                              size="sm"
+                              onClick={() => window.open(video.videoUrl, '_blank')}
+                            >
+                              <Play className="h-4 w-4" />
+                            </Button>
+                            <Button
+                              variant="destructive"
+                              size="sm"
+                              onClick={() => handleDeleteVideo(video.id)}
+                            >
+                              <Trash2 className="h-4 w-4" />
+                            </Button>
+                          </div>
+                        </TableCell>
+                      </TableRow>
+                    ))
+                  ) : (
+                    <TableRow>
+                      <TableCell colSpan={8} className="text-center text-muted-foreground">
+                        暂无数据
+                      </TableCell>
+                    </TableRow>
+                  )}
+                </TableBody>
+              </Table>
+
+              {/* 分页控件 */}
+              {data?.pagination && (
+                <DataTablePagination
+                  currentPage={data.pagination.current}
+                  pageSize={data.pagination.pageSize}
+                  totalCount={data.pagination.total}
+                  onPageChange={(page, limit) => setSearchParams({ ...searchParams, page, limit })}
+                />
+              )}
+            </>
+          )}
+        </CardContent>
+      </Card>
+
+      {/* 创建/编辑模态框 */}
+      <Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
+        <DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
+          <DialogHeader>
+            <DialogTitle>
+              {isCreateForm ? '创建视频' : '编辑视频'}
+            </DialogTitle>
+            <DialogDescription>
+              {isCreateForm ? '创建一个新的视频记录' : '编辑现有视频信息'}
+            </DialogDescription>
+          </DialogHeader>
+
+          {isCreateForm ? (
+            // 创建表单
+            <Form {...createForm}>
+              <form onSubmit={createForm.handleSubmit(handleCreateSubmit)} className="space-y-4">
+                {/* 视频上传组件 */}
+                <VodUpload
+                  onUploadSuccess={handleUploadSuccess}
+                  onUploadError={(error) => toast.error(`上传失败: ${error.message}`)}
+                />
+
+                {/* 标题字段 */}
+                <FormField
+                  control={createForm.control}
+                  name="title"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel className="flex items-center">
+                        视频标题
+                        <span className="text-red-500 ml-1">*</span>
+                      </FormLabel>
+                      <FormControl>
+                        <Input placeholder="请输入视频标题..." {...field} />
+                      </FormControl>
+                      <FormDescription>视频的标题信息</FormDescription>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                {/* 描述字段 */}
+                <FormField
+                  control={createForm.control}
+                  name="description"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>视频描述</FormLabel>
+                      <FormControl>
+                        <Textarea
+                          placeholder="请输入视频描述..."
+                          className="resize-none"
+                          {...field}
+                          value={field.value || ''}
+                        />
+                      </FormControl>
+                      <FormDescription>视频的详细描述信息(可选)</FormDescription>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                {/* 隐藏字段 */}
+                <FormField
+                  control={createForm.control}
+                  name="fileId"
+                  render={({ field }) => (
+                    <FormItem className="hidden">
+                      <FormControl>
+                        <Input {...field} />
+                      </FormControl>
+                    </FormItem>
+                  )}
+                />
+
+                <FormField
+                  control={createForm.control}
+                  name="videoUrl"
+                  render={({ field }) => (
+                    <FormItem className="hidden">
+                      <FormControl>
+                        <Input {...field} />
+                      </FormControl>
+                    </FormItem>
+                  )}
+                />
+
+                <FormField
+                  control={createForm.control}
+                  name="coverUrl"
+                  render={({ field }) => (
+                    <FormItem className="hidden">
+                      <FormControl>
+                        <Input {...field} value={field.value || ''} />
+                      </FormControl>
+                    </FormItem>
+                  )}
+                />
+
+                <DialogFooter>
+                  <Button type="button" variant="outline" onClick={() => setIsModalOpen(false)}>
+                    取消
+                  </Button>
+                  <Button 
+                    type="submit" 
+                    disabled={!uploadedFileInfo || createMutation.isPending}
+                  >
+                    {createMutation.isPending ? '创建中...' : '创建'}
+                  </Button>
+                </DialogFooter>
+              </form>
+            </Form>
+          ) : (
+            // 编辑表单
+            <Form {...updateForm}>
+              <form onSubmit={updateForm.handleSubmit(handleUpdateSubmit)} className="space-y-4">
+                {/* 标题字段 */}
+                <FormField
+                  control={updateForm.control}
+                  name="title"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel className="flex items-center">
+                        视频标题
+                        <span className="text-red-500 ml-1">*</span>
+                      </FormLabel>
+                      <FormControl>
+                        <Input placeholder="请输入视频标题..." {...field} />
+                      </FormControl>
+                      <FormDescription>视频的标题信息</FormDescription>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                {/* 描述字段 */}
+                <FormField
+                  control={updateForm.control}
+                  name="description"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>视频描述</FormLabel>
+                      <FormControl>
+                        <Textarea
+                          placeholder="请输入视频描述..."
+                          className="resize-none"
+                          {...field}
+                          value={field.value || ''}
+                        />
+                      </FormControl>
+                      <FormDescription>视频的详细描述信息(可选)</FormDescription>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <DialogFooter>
+                  <Button type="button" variant="outline" onClick={() => setIsModalOpen(false)}>
+                    取消
+                  </Button>
+                  <Button type="submit" disabled={updateMutation.isPending}>
+                    {updateMutation.isPending ? '更新中...' : '更新'}
+                  </Button>
+                </DialogFooter>
+              </form>
+            </Form>
+          )}
+        </DialogContent>
+      </Dialog>
+
+      {/* 删除确认对话框 */}
+      <AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
+        <AlertDialogContent>
+          <AlertDialogHeader>
+            <AlertDialogTitle>确认删除</AlertDialogTitle>
+            <AlertDialogDescription>
+              确定要删除这个视频吗?此操作无法撤销,视频文件将无法恢复。
+            </AlertDialogDescription>
+          </AlertDialogHeader>
+          <AlertDialogFooter>
+            <AlertDialogCancel>取消</AlertDialogCancel>
+            <AlertDialogAction 
+              onClick={confirmDelete}
+              className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+              disabled={deleteMutation.isPending}
+            >
+              {deleteMutation.isPending ? '删除中...' : '删除'}
+            </AlertDialogAction>
+          </AlertDialogFooter>
+        </AlertDialogContent>
+      </AlertDialog>
+    </div>
+  );
+};

+ 6 - 0
src/client/admin/routes.tsx

@@ -14,6 +14,7 @@ import { DateNotesPage } from './pages/DateNotesPage';
 import { LoginPage } from './pages/Login';
 import { LoginPage } from './pages/Login';
 import { FilesPage } from './pages/Files';
 import { FilesPage } from './pages/Files';
 import { ChatMessagesPage } from './pages/ChatMessages';
 import { ChatMessagesPage } from './pages/ChatMessages';
+import { VodVideosPage } from './pages/VodVideos';
 
 
 export const router = createBrowserRouter([
 export const router = createBrowserRouter([
   {
   {
@@ -81,6 +82,11 @@ export const router = createBrowserRouter([
         element: <ChatMessagesPage />,
         element: <ChatMessagesPage />,
         errorElement: <ErrorPage />
         errorElement: <ErrorPage />
       },
       },
+      {
+        path: 'vod-videos',
+        element: <VodVideosPage />,
+        errorElement: <ErrorPage />
+      },
       {
       {
         path: '*',
         path: '*',
         element: <NotFoundPage />,
         element: <NotFoundPage />,

+ 5 - 1
src/client/api.ts

@@ -3,7 +3,7 @@ import type {
   AuthRoutes, UserRoutes, RoleRoutes, FileRoutes,
   AuthRoutes, UserRoutes, RoleRoutes, FileRoutes,
   ClassroomDataRoutes, SubmissionRecordsRoutes,
   ClassroomDataRoutes, SubmissionRecordsRoutes,
   StockDataRoutes, StockXunlianCodesRoutes, DateNotesRoutes, AliyunRoutes,
   StockDataRoutes, StockXunlianCodesRoutes, DateNotesRoutes, AliyunRoutes,
-  WechatAuthRoutes, ChatMessageRoutes, VodRoutes
+  WechatAuthRoutes, ChatMessageRoutes, VodRoutes, VodVideoRoutes
 } from '@/server/api';
 } from '@/server/api';
 import { axiosFetch } from './utils/axios-fetch';
 import { axiosFetch } from './utils/axios-fetch';
 
 
@@ -58,3 +58,7 @@ export const chatMessageClient = hc<ChatMessageRoutes>('/', {
 export const vodClient = hc<VodRoutes>('/', {
 export const vodClient = hc<VodRoutes>('/', {
   fetch: axiosFetch,
   fetch: axiosFetch,
 }).api.v1.vod;
 }).api.v1.vod;
+
+export const vodVideoClient = hc<VodVideoRoutes>('/', {
+  fetch: axiosFetch,
+}).api.v1['vod-videos'];

+ 3 - 0
src/server/api.ts

@@ -14,6 +14,7 @@ import aliyunRoute from './api/aliyun/index'
 import wechatRoutes from './api/auth/wechat/index'
 import wechatRoutes from './api/auth/wechat/index'
 import chatMessageRoutes from './api/chat-messages/index'
 import chatMessageRoutes from './api/chat-messages/index'
 import vodRoutes from './api/vod/index'
 import vodRoutes from './api/vod/index'
+import vodVideoRoutes from './api/vod-videos/index'
 import { AuthContext } from './types/context'
 import { AuthContext } from './types/context'
 import { AppDataSource } from './data-source'
 import { AppDataSource } from './data-source'
 import { Hono } from 'hono'
 import { Hono } from 'hono'
@@ -97,6 +98,7 @@ const aliyunApi = api.route('/api/v1/aliyun', aliyunRoute)
 const wechatAuthApi = api.route('/api/v1/auth/wechat', wechatRoutes)
 const wechatAuthApi = api.route('/api/v1/auth/wechat', wechatRoutes)
 const chatMessageApi = api.route('/api/v1/chat-messages', chatMessageRoutes)
 const chatMessageApi = api.route('/api/v1/chat-messages', chatMessageRoutes)
 const vodApi = api.route('/api/v1/vod', vodRoutes)
 const vodApi = api.route('/api/v1/vod', vodRoutes)
+const vodVideoApi = api.route('/api/v1/vod-videos', vodVideoRoutes)
 
 
 export type AuthRoutes = typeof authRoutes
 export type AuthRoutes = typeof authRoutes
 export type UserRoutes = typeof userRoutes
 export type UserRoutes = typeof userRoutes
@@ -111,6 +113,7 @@ export type AliyunRoutes = typeof aliyunApi
 export type WechatAuthRoutes = typeof wechatAuthApi
 export type WechatAuthRoutes = typeof wechatAuthApi
 export type ChatMessageRoutes = typeof chatMessageApi
 export type ChatMessageRoutes = typeof chatMessageApi
 export type VodRoutes = typeof vodApi
 export type VodRoutes = typeof vodApi
+export type VodVideoRoutes = typeof vodVideoApi
 
 
 app.route('/', api)
 app.route('/', api)
 export default app
 export default app

+ 20 - 0
src/server/api/vod-videos/index.ts

@@ -0,0 +1,20 @@
+import { createCrudRoutes } from '@/server/utils/generic-crud.routes';
+import { VodVideo } from '@/server/modules/vod/vod-video.entity';
+import { VodVideoSchema, CreateVodVideoDto, UpdateVodVideoDto } from '@/server/modules/vod/vod-video.schema';
+import { authMiddleware } from '@/server/middleware/auth.middleware';
+
+const vodVideoRoutes = createCrudRoutes({
+  entity: VodVideo,
+  createSchema: CreateVodVideoDto,
+  updateSchema: UpdateVodVideoDto,
+  getSchema: VodVideoSchema,
+  listSchema: VodVideoSchema,
+  searchFields: ['title', 'description', 'fileId'],
+  middleware: [authMiddleware],
+  userTracking: {
+    createdByField: 'createdBy',
+    updatedByField: 'updatedBy'
+  }
+});
+
+export default vodVideoRoutes;

+ 2 - 1
src/server/data-source.ts

@@ -12,6 +12,7 @@ import { StockXunlianCodes } from "./modules/stock/stock-xunlian-codes.entity"
 import { SubmissionRecords } from "./modules/submission/submission-records.entity"
 import { SubmissionRecords } from "./modules/submission/submission-records.entity"
 import { File } from "./modules/files/file.entity"
 import { File } from "./modules/files/file.entity"
 import { ChatMessage } from "./modules/chat/chat-message.entity"
 import { ChatMessage } from "./modules/chat/chat-message.entity"
+import { VodVideo } from "./modules/vod/vod-video.entity"
 
 
 export const AppDataSource = new DataSource({
 export const AppDataSource = new DataSource({
   type: "mysql",
   type: "mysql",
@@ -21,7 +22,7 @@ export const AppDataSource = new DataSource({
   password: process.env.DB_PASSWORD || "",
   password: process.env.DB_PASSWORD || "",
   database: process.env.DB_DATABASE || "d8dai",
   database: process.env.DB_DATABASE || "d8dai",
   entities: [
   entities: [
-    User, Role, File, ClassroomData, DateNotes, StockData, StockXunlianCodes, SubmissionRecords, ChatMessage
+    User, Role, File, ClassroomData, DateNotes, StockData, StockXunlianCodes, SubmissionRecords, ChatMessage, VodVideo
   ],
   ],
   migrations: [],
   migrations: [],
   synchronize: process.env.DB_SYNCHRONIZE !== "false",
   synchronize: process.env.DB_SYNCHRONIZE !== "false",

+ 98 - 0
src/server/modules/vod/vod-video.entity.ts

@@ -0,0 +1,98 @@
+import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
+
+@Entity('vod_videos')
+export class VodVideo {
+  @PrimaryGeneratedColumn({ unsigned: true })
+  id!: number;
+
+  @Column({ 
+    name: 'title', 
+    type: 'varchar', 
+    length: 255,
+    comment: '视频标题'
+  })
+  title!: string;
+
+  @Column({ 
+    name: 'description', 
+    type: 'text', 
+    nullable: true,
+    comment: '视频描述'
+  })
+  description!: string | null;
+
+  @Column({ 
+    name: 'file_id', 
+    type: 'varchar', 
+    length: 255,
+    comment: 'VOD文件ID'
+  })
+  fileId!: string;
+
+  @Column({ 
+    name: 'video_url', 
+    type: 'varchar', 
+    length: 500,
+    comment: '视频播放地址'
+  })
+  videoUrl!: string;
+
+  @Column({ 
+    name: 'cover_url', 
+    type: 'varchar', 
+    length: 500,
+    nullable: true,
+    comment: '封面图片地址'
+  })
+  coverUrl!: string | null;
+
+  @Column({ 
+    name: 'duration', 
+    type: 'int', 
+    unsigned: true,
+    default: 0,
+    comment: '视频时长(秒)'
+  })
+  duration!: number;
+
+  @Column({ 
+    name: 'size', 
+    type: 'bigint', 
+    unsigned: true,
+    default: 0,
+    comment: '视频大小(字节)'
+  })
+  size!: number;
+
+  @Column({ 
+    name: 'status', 
+    type: 'tinyint', 
+    default: 0,
+    comment: '视频状态(0: 待处理, 1: 处理中, 2: 已完成, 3: 失败)'
+  })
+  status!: number;
+
+  @Column({ 
+    name: 'created_by', 
+    type: 'int', 
+    unsigned: true,
+    nullable: true,
+    comment: '创建人ID'
+  })
+  createdBy!: number | null;
+
+  @Column({ 
+    name: 'updated_by', 
+    type: 'int', 
+    unsigned: true,
+    nullable: true,
+    comment: '更新人ID'
+  })
+  updatedBy!: number | null;
+
+  @CreateDateColumn({ name: 'created_at' })
+  createdAt!: Date;
+
+  @UpdateDateColumn({ name: 'updated_at' })
+  updatedAt!: Date;
+}

+ 137 - 0
src/server/modules/vod/vod-video.schema.ts

@@ -0,0 +1,137 @@
+import { z } from '@hono/zod-openapi';
+
+// 视频状态枚举
+export const VodVideoStatus = {
+  PENDING: 0,
+  PROCESSING: 1,
+  COMPLETED: 2,
+  FAILED: 3
+} as const;
+
+// 实体完整Schema(用于响应)
+export const VodVideoSchema = z.object({
+  id: z.coerce.number<number>().int('必须是整数').positive('必须是正整数').openapi({
+    description: '视频ID',
+    example: 1
+  }),
+  title: z.string().min(1, '标题不能为空').max(255, '标题最多255个字符').openapi({
+    description: '视频标题',
+    example: '示例视频标题'
+  }),
+  description: z.string().max(2000, '描述最多2000个字符').nullable().openapi({
+    description: '视频描述',
+    example: '这是一个示例视频描述'
+  }),
+  fileId: z.string().min(1, '文件ID不能为空').max(255, '文件ID最多255个字符').openapi({
+    description: 'VOD文件ID',
+    example: 'file_1234567890'
+  }),
+  videoUrl: z.string().url('视频地址格式不正确').max(500, '视频地址最多500个字符').openapi({
+    description: '视频播放地址',
+    example: 'https://vod.example.com/video.mp4'
+  }),
+  coverUrl: z.string().url('封面地址格式不正确').max(500, '封面地址最多500个字符').nullable().openapi({
+    description: '封面图片地址',
+    example: 'https://vod.example.com/cover.jpg'
+  }),
+  duration: z.coerce.number<number>().int('必须是整数').min(0, '时长不能为负数').openapi({
+    description: '视频时长(秒)',
+    example: 3600
+  }),
+  size: z.coerce.number<number>().int('必须是整数').min(0, '大小不能为负数').openapi({
+    description: '视频大小(字节)',
+    example: 1024000
+  }),
+  status: z.coerce.number<number>().int('必须是整数').min(0, '最小值为0').max(3, '最大值为3').openapi({
+    description: '视频状态(0: 待处理, 1: 处理中, 2: 已完成, 3: 失败)',
+    example: 2
+  }),
+  createdBy: z.coerce.number<number>().int('必须是整数').positive('必须是正整数').nullable().openapi({
+    description: '创建人ID',
+    example: 1
+  }),
+  updatedBy: z.coerce.number<number>().int('必须是整数').positive('必须是正整数').nullable().openapi({
+    description: '更新人ID',
+    example: 1
+  }),
+  createdAt: z.coerce.date<Date>('创建时间格式不正确').openapi({
+    description: '创建时间',
+    example: '2024-01-01T12:00:00Z'
+  }),
+  updatedAt: z.coerce.date<Date>('更新时间格式不正确').openapi({
+    description: '更新时间',
+    example: '2024-01-01T12:00:00Z'
+  })
+});
+
+// 创建DTO Schema(用于创建请求验证)
+export const CreateVodVideoDto = z.object({
+  title: z.string().min(1, '标题不能为空').max(255, '标题最多255个字符').openapi({
+    description: '视频标题',
+    example: '示例视频标题'
+  }),
+  description: z.string().max(2000, '描述最多2000个字符').nullable().optional().openapi({
+    description: '视频描述(选填)',
+    example: '这是一个示例视频描述'
+  }),
+  fileId: z.string().min(1, '文件ID不能为空').max(255, '文件ID最多255个字符').openapi({
+    description: 'VOD文件ID',
+    example: 'file_1234567890'
+  }),
+  videoUrl: z.string().url('视频地址格式不正确').max(500, '视频地址最多500个字符').openapi({
+    description: '视频播放地址',
+    example: 'https://vod.example.com/video.mp4'
+  }),
+  coverUrl: z.string().url('封面地址格式不正确').max(500, '封面地址最多500个字符').nullable().optional().openapi({
+    description: '封面图片地址(选填)',
+    example: 'https://vod.example.com/cover.jpg'
+  }),
+  duration: z.coerce.number<number>().int('必须是整数').min(0, '时长不能为负数').default(0).openapi({
+    description: '视频时长(秒)',
+    example: 3600
+  }),
+  size: z.coerce.number<number>().int('必须是整数').min(0, '大小不能为负数').default(0).openapi({
+    description: '视频大小(字节)',
+    example: 1024000
+  }),
+  status: z.coerce.number<number>().int('必须是整数').min(0, '最小值为0').max(3, '最大值为3').default(0).openapi({
+    description: '视频状态(0: 待处理, 1: 处理中, 2: 已完成, 3: 失败)',
+    example: 0
+  })
+});
+
+// 更新DTO Schema(用于更新请求验证)
+export const UpdateVodVideoDto = z.object({
+  title: z.string().min(1, '标题不能为空').max(255, '标题最多255个字符').optional().openapi({
+    description: '视频标题',
+    example: '更新后的视频标题'
+  }),
+  description: z.string().max(2000, '描述最多2000个字符').nullable().optional().openapi({
+    description: '视频描述',
+    example: '更新后的视频描述'
+  }),
+  fileId: z.string().min(1, '文件ID不能为空').max(255, '文件ID最多255个字符').optional().openapi({
+    description: 'VOD文件ID',
+    example: 'file_updated_1234567890'
+  }),
+  videoUrl: z.string().url('视频地址格式不正确').max(500, '视频地址最多500个字符').optional().openapi({
+    description: '视频播放地址',
+    example: 'https://vod.example.com/updated_video.mp4'
+  }),
+  coverUrl: z.string().url('封面地址格式不正确').max(500, '封面地址最多500个字符').nullable().optional().openapi({
+    description: '封面图片地址',
+    example: 'https://vod.example.com/updated_cover.jpg'
+  }),
+  duration: z.coerce.number<number>().int('必须是整数').min(0, '时长不能为负数').optional().openapi({
+    description: '视频时长(秒)',
+    example: 1800
+  }),
+  size: z.coerce.number<number>().int('必须是整数').min(0, '大小不能为负数').optional().openapi({
+    description: '视频大小(字节)',
+    example: 512000
+  }),
+  status: z.coerce.number<number>().int('必须是整数').min(0, '最小值为0').max(3, '最大值为3').optional().openapi({
+    description: '视频状态(0: 待处理, 1: 处理中, 2: 已完成, 3: 失败)',
+    example: 2
+  })
+});

+ 81 - 0
src/server/modules/vod/vod-video.service.ts

@@ -0,0 +1,81 @@
+import { GenericCrudService } from '@/server/utils/generic-crud.service';
+import { DataSource } from 'typeorm';
+import { VodVideo } from './vod-video.entity';
+
+export class VodVideoService extends GenericCrudService<VodVideo> {
+  constructor(dataSource: DataSource) {
+    super(dataSource, VodVideo);
+  }
+
+  /**
+   * 重写获取列表方法,添加默认过滤条件
+   */
+  async getList(
+    page: number = 1,
+    pageSize: number = 10,
+    keyword?: string,
+    searchFields?: string[],
+    where: Partial<VodVideo> = {},
+    relations: string[] = [],
+    order: { [P in keyof VodVideo]?: 'ASC' | 'DESC' } = {},
+    filters?: any
+  ): Promise<[VodVideo[], number]> {
+    // 可以在这里添加自定义的业务逻辑
+    // 例如:默认按创建时间倒序排序
+    if (Object.keys(order).length === 0) {
+      order.createdAt = 'DESC';
+    }
+
+    return super.getList(page, pageSize, keyword, searchFields, where, relations, order, filters);
+  }
+
+  /**
+   * 根据状态获取视频列表
+   */
+  async getVideosByStatus(status: number, page: number = 1, pageSize: number = 10): Promise<[VodVideo[], number]> {
+    return this.getList(page, pageSize, undefined, undefined, { status });
+  }
+
+  /**
+   * 获取处理中的视频
+   */
+  async getProcessingVideos(page: number = 1, pageSize: number = 10): Promise<[VodVideo[], number]> {
+    return this.getVideosByStatus(1, page, pageSize);
+  }
+
+  /**
+   * 获取已完成的视频
+   */
+  async getCompletedVideos(page: number = 1, pageSize: number = 10): Promise<[VodVideo[], number]> {
+    return this.getVideosByStatus(2, page, pageSize);
+  }
+
+  /**
+   * 更新视频状态
+   */
+  async updateVideoStatus(id: number, status: number, userId?: number): Promise<VodVideo | null> {
+    return this.update(id, { status }, userId);
+  }
+
+  /**
+   * 更新视频处理信息(状态、时长、大小等)
+   */
+  async updateVideoProcessingInfo(
+    id: number, 
+    status: number, 
+    duration?: number, 
+    size?: number,
+    videoUrl?: string,
+    coverUrl?: string,
+    userId?: number
+  ): Promise<VodVideo | null> {
+    const updateData: any = { status };
+    
+    if (duration !== undefined) updateData.duration = duration;
+    if (size !== undefined) updateData.size = size;
+    if (videoUrl !== undefined) updateData.videoUrl = videoUrl;
+    if (coverUrl !== undefined) updateData.coverUrl = coverUrl;
+
+    return this.update(id, updateData, userId);
+  }
+}