diff --git a/apps/web/.eslintrc.cjs b/apps/web/.eslintrc.cjs
new file mode 100644
index 0000000..dd29102
--- /dev/null
+++ b/apps/web/.eslintrc.cjs
@@ -0,0 +1,19 @@
+module.exports = {
+ root: true,
+ env: { browser: true, es2020: true },
+ extends: [
+ 'eslint:recommended',
+ 'plugin:@typescript-eslint/recommended',
+ 'plugin:react-hooks/recommended',
+ ],
+ ignorePatterns: ['dist', '.eslintrc.cjs'],
+ parser: '@typescript-eslint/parser',
+ plugins: ['react-refresh'],
+ rules: {
+ 'react-refresh/only-export-components': [
+ 'warn',
+ { allowConstantExport: true },
+ ],
+ '@typescript-eslint/no-explicit-any': 'off',
+ },
+};
diff --git a/apps/web/index.html b/apps/web/index.html
new file mode 100644
index 0000000..56ba376
--- /dev/null
+++ b/apps/web/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ 阿里云 VOD 媒体库管理器
+
+
+
+
+
+
diff --git a/apps/web/package.json b/apps/web/package.json
new file mode 100644
index 0000000..fa6b3b4
--- /dev/null
+++ b/apps/web/package.json
@@ -0,0 +1,33 @@
+{
+ "name": "@vod-manager/web",
+ "version": "0.1.0",
+ "description": "Web frontend for Aliyun VOD Media Library Manager",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "preview": "vite preview",
+ "lint": "eslint src --ext .ts,.tsx"
+ },
+ "dependencies": {
+ "antd": "^5.17.4",
+ "axios": "^1.7.2",
+ "dayjs": "^1.11.11",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-router-dom": "^6.23.1",
+ "zustand": "^4.5.2"
+ },
+ "devDependencies": {
+ "@types/react": "^18.3.3",
+ "@types/react-dom": "^18.3.0",
+ "@typescript-eslint/eslint-plugin": "^7.11.0",
+ "@typescript-eslint/parser": "^7.11.0",
+ "@vitejs/plugin-react": "^4.3.0",
+ "eslint": "^8.57.0",
+ "eslint-plugin-react-hooks": "^4.6.2",
+ "eslint-plugin-react-refresh": "^0.4.7",
+ "typescript": "^5.4.5",
+ "vite": "^5.2.12"
+ }
+}
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
new file mode 100644
index 0000000..0d20116
--- /dev/null
+++ b/apps/web/src/App.tsx
@@ -0,0 +1,37 @@
+import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
+import { useAuthStore } from './store/auth';
+import LoginPage from './pages/Login';
+import Layout from './components/Layout';
+import VideosPage from './pages/Videos';
+import AccountsPage from './pages/Accounts';
+import LogsPage from './pages/Logs';
+
+function ProtectedRoute({ children }: { children: React.ReactNode }) {
+ const token = useAuthStore((state) => state.token);
+ return token ? <>{children}> : ;
+}
+
+function App() {
+ return (
+
+
+ } />
+
+
+
+ }
+ >
+ } />
+ } />
+ } />
+ } />
+
+
+
+ );
+}
+
+export default App;
diff --git a/apps/web/src/api/accounts.ts b/apps/web/src/api/accounts.ts
new file mode 100644
index 0000000..b5bc887
--- /dev/null
+++ b/apps/web/src/api/accounts.ts
@@ -0,0 +1,44 @@
+import { apiClient, ApiResponse } from './client';
+
+export interface Account {
+ id: string;
+ name: string;
+ accessKeyId: string;
+ region: string;
+ endpoint?: string;
+ isActive: number;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface AccountInput {
+ name: string;
+ accessKeyId: string;
+ accessKeySecret: string;
+ region: string;
+ endpoint?: string;
+}
+
+export async function listAccounts(): Promise {
+ const res = await apiClient.get>('/accounts');
+ return res.data.data;
+}
+
+export async function createAccount(params: AccountInput): Promise {
+ const res = await apiClient.post>('/accounts', params);
+ return res.data.data;
+}
+
+export async function updateAccount(id: string, params: Partial): Promise {
+ const res = await apiClient.put>(`/accounts/${id}`, params);
+ return res.data.data;
+}
+
+export async function deleteAccount(id: string): Promise {
+ await apiClient.delete(`/accounts/${id}`);
+}
+
+export async function switchAccount(id: string): Promise {
+ const res = await apiClient.post>(`/accounts/${id}/switch`);
+ return res.data.data;
+}
diff --git a/apps/web/src/api/auth.ts b/apps/web/src/api/auth.ts
new file mode 100644
index 0000000..e6c17db
--- /dev/null
+++ b/apps/web/src/api/auth.ts
@@ -0,0 +1,22 @@
+import { apiClient, ApiResponse } from './client';
+import { User } from '../store/auth';
+
+export interface LoginParams {
+ username: string;
+ password: string;
+}
+
+export interface LoginResult {
+ token: string;
+ user: User;
+}
+
+export async function login(params: LoginParams): Promise {
+ const res = await apiClient.post>('/auth/login', params);
+ return res.data.data;
+}
+
+export async function getMe(): Promise {
+ const res = await apiClient.get>('/auth/me');
+ return res.data.data;
+}
diff --git a/apps/web/src/api/categories.ts b/apps/web/src/api/categories.ts
new file mode 100644
index 0000000..50e9ade
--- /dev/null
+++ b/apps/web/src/api/categories.ts
@@ -0,0 +1,6 @@
+import { apiClient, ApiResponse } from './client';
+
+export async function listCategories(): Promise[]> {
+ const res = await apiClient.get[]>>('/categories');
+ return res.data.data;
+}
diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts
new file mode 100644
index 0000000..5d66a21
--- /dev/null
+++ b/apps/web/src/api/client.ts
@@ -0,0 +1,34 @@
+import axios from 'axios';
+import { useAuthStore } from '../store/auth';
+
+export const apiClient = axios.create({
+ baseURL: '/api',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+});
+
+apiClient.interceptors.request.use((config) => {
+ const token = useAuthStore.getState().token;
+ if (token) {
+ config.headers.Authorization = `Bearer ${token}`;
+ }
+ return config;
+});
+
+apiClient.interceptors.response.use(
+ (response) => response,
+ (error) => {
+ if (error.response?.status === 401) {
+ useAuthStore.getState().clearAuth();
+ window.location.href = '/login';
+ }
+ return Promise.reject(error);
+ }
+);
+
+export interface ApiResponse {
+ code: number;
+ message?: string;
+ data: T;
+}
diff --git a/apps/web/src/api/logs.ts b/apps/web/src/api/logs.ts
new file mode 100644
index 0000000..01acd8e
--- /dev/null
+++ b/apps/web/src/api/logs.ts
@@ -0,0 +1,28 @@
+import { apiClient, ApiResponse } from './client';
+
+export interface OperationLog {
+ id: string;
+ userId: string | null;
+ username: string | null;
+ action: string;
+ targetType: string;
+ targetIds: string;
+ details: string;
+ createdAt: string;
+}
+
+export interface ListLogsResult {
+ total: number;
+ pageNo: number;
+ pageSize: number;
+ logs: OperationLog[];
+}
+
+export async function listLogs(params: {
+ pageNo?: number;
+ pageSize?: number;
+ action?: string;
+}): Promise {
+ const res = await apiClient.get>('/logs', { params });
+ return res.data.data;
+}
diff --git a/apps/web/src/api/videos.ts b/apps/web/src/api/videos.ts
new file mode 100644
index 0000000..ef1f516
--- /dev/null
+++ b/apps/web/src/api/videos.ts
@@ -0,0 +1,95 @@
+import { apiClient, ApiResponse } from './client';
+
+export interface VideoItem {
+ videoId: string;
+ title: string;
+ description?: string;
+ duration?: number;
+ size?: number;
+ coverURL?: string;
+ status?: string;
+ cateId?: number;
+ cateName?: string;
+ tags?: string;
+ creationTime?: string;
+ modificationTime?: string;
+}
+
+export interface PlayInfo {
+ playURL: string;
+ definition: string;
+ duration: string;
+ size: number;
+ format: string;
+}
+
+export interface SearchParams {
+ keyword?: string;
+ cateId?: number;
+ status?: string;
+ pageNo?: number;
+ pageSize?: number;
+ startTime?: string;
+ endTime?: string;
+ sortBy?: string;
+ sortOrder?: 'Asc' | 'Desc';
+}
+
+export interface SearchResult {
+ total: number;
+ pageNo: number;
+ pageSize: number;
+ videos: VideoItem[];
+}
+
+export async function searchVideos(params: SearchParams): Promise {
+ const res = await apiClient.get>('/videos', { params });
+ return res.data.data;
+}
+
+export async function getVideo(videoId: string): Promise {
+ const res = await apiClient.get>(`/videos/${videoId}`);
+ return res.data.data;
+}
+
+export async function getPlayInfo(videoId: string, definition?: string): Promise {
+ const res = await apiClient.post>(`/videos/${videoId}/play-info`, {
+ definition,
+ });
+ return res.data.data;
+}
+
+export async function batchDelete(videoIds: string[]): Promise<{ deleted: number }> {
+ const res = await apiClient.post>('/videos/batch-delete', {
+ videoIds,
+ });
+ return res.data.data;
+}
+
+export interface DownloadResult {
+ videoId: string;
+ title?: string;
+ urls: { definition: string; url: string }[];
+}
+
+export async function batchDownload(
+ videoIds: string[],
+ definition?: string
+): Promise {
+ const res = await apiClient.post>('/videos/batch-download', {
+ videoIds,
+ definition,
+ });
+ return res.data.data;
+}
+
+export async function batchUpdate(
+ videoIds: string[],
+ updates: { title?: string; description?: string; tags?: string; cateId?: number }
+): Promise<{ updated: number }> {
+ const res = await apiClient.post>('/videos/batch-update', {
+ videoIds,
+ ...updates,
+ });
+ return res.data.data;
+}
diff --git a/apps/web/src/components/Layout.tsx b/apps/web/src/components/Layout.tsx
new file mode 100644
index 0000000..42292fe
--- /dev/null
+++ b/apps/web/src/components/Layout.tsx
@@ -0,0 +1,93 @@
+import { Layout as AntLayout, Menu, Dropdown, Space, Button, theme } from 'antd';
+import {
+ VideoCameraOutlined,
+ SettingOutlined,
+ FileTextOutlined,
+ UserOutlined,
+ LogoutOutlined,
+} from '@ant-design/icons';
+import { Outlet, useLocation, useNavigate } from 'react-router-dom';
+import { useAuthStore } from '../store/auth';
+
+const { Header, Sider, Content } = AntLayout;
+
+export default function Layout() {
+ const location = useLocation();
+ const navigate = useNavigate();
+ const { user, clearAuth } = useAuthStore();
+ const {
+ token: { colorBgContainer, borderRadiusLG },
+ } = theme.useToken();
+
+ const menuItems = [
+ { key: '/videos', icon: , label: '视频库' },
+ { key: '/accounts', icon: , label: '账号配置' },
+ { key: '/logs', icon: , label: '操作日志' },
+ ];
+
+ const handleMenuClick = ({ key }: { key: string }) => {
+ navigate(key);
+ };
+
+ const handleLogout = () => {
+ clearAuth();
+ navigate('/login');
+ };
+
+ const userMenuItems = [
+ { key: 'user', label: user?.username, disabled: true },
+ { key: 'role', label: `角色: ${user?.role === 'admin' ? '管理员' : '只读'}`, disabled: true },
+ { type: 'divider' as const },
+ { key: 'logout', icon: , label: '退出登录', onClick: handleLogout },
+ ];
+
+ return (
+
+
+
+ VOD 管理器
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/index.css b/apps/web/src/index.css
new file mode 100644
index 0000000..08d8dda
--- /dev/null
+++ b/apps/web/src/index.css
@@ -0,0 +1,10 @@
+body {
+ margin: 0;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
+ 'Noto Sans', sans-serif;
+ background-color: #f0f2f5;
+}
+
+#root {
+ min-height: 100vh;
+}
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
new file mode 100644
index 0000000..4905d33
--- /dev/null
+++ b/apps/web/src/main.tsx
@@ -0,0 +1,14 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import { ConfigProvider } from 'antd';
+import zhCN from 'antd/locale/zh_CN';
+import App from './App';
+import './index.css';
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+
+
+
+);
diff --git a/apps/web/src/pages/Accounts.tsx b/apps/web/src/pages/Accounts.tsx
new file mode 100644
index 0000000..9243376
--- /dev/null
+++ b/apps/web/src/pages/Accounts.tsx
@@ -0,0 +1,162 @@
+import { useEffect, useState } from 'react';
+import {
+ Table,
+ Button,
+ Space,
+ Modal,
+ Form,
+ Input,
+ message,
+ Tag,
+ Popconfirm,
+} from 'antd';
+import { PlusOutlined } from '@ant-design/icons';
+import {
+ listAccounts,
+ createAccount,
+ deleteAccount,
+ switchAccount,
+ Account,
+ AccountInput,
+} from '../api/accounts';
+
+export default function AccountsPage() {
+ const [accounts, setAccounts] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [modalOpen, setModalOpen] = useState(false);
+ const [form] = Form.useForm();
+
+ const fetchAccounts = async () => {
+ setLoading(true);
+ try {
+ const data = await listAccounts();
+ setAccounts(data);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ fetchAccounts();
+ }, []);
+
+ const handleSubmit = async (values: AccountInput) => {
+ try {
+ await createAccount(values);
+ message.success('账号添加成功');
+ setModalOpen(false);
+ form.resetFields();
+ fetchAccounts();
+ } catch (error) {
+ message.error('添加失败');
+ }
+ };
+
+ const handleDelete = async (id: string) => {
+ try {
+ await deleteAccount(id);
+ message.success('已删除');
+ fetchAccounts();
+ } catch (error) {
+ message.error('删除失败');
+ }
+ };
+
+ const handleSwitch = async (id: string) => {
+ try {
+ await switchAccount(id);
+ message.success('已切换当前账号');
+ fetchAccounts();
+ } catch (error) {
+ message.error('切换失败');
+ }
+ };
+
+ const columns = [
+ { title: '名称', dataIndex: 'name' },
+ { title: 'AccessKey ID', dataIndex: 'accessKeyId', ellipsis: true },
+ { title: 'Region', dataIndex: 'region' },
+ {
+ title: '状态',
+ dataIndex: 'isActive',
+ render: (isActive: number) =>
+ isActive ? 当前使用 : 备用,
+ },
+ {
+ title: '操作',
+ key: 'action',
+ render: (_: unknown, record: Account) => (
+
+ {!record.isActive && (
+
+ )}
+ handleDelete(record.id)}>
+
+
+
+ ),
+ },
+ ];
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/pages/Login.tsx b/apps/web/src/pages/Login.tsx
new file mode 100644
index 0000000..a2065a3
--- /dev/null
+++ b/apps/web/src/pages/Login.tsx
@@ -0,0 +1,63 @@
+import { useState } from 'react';
+import { Form, Input, Button, Card, message } from 'antd';
+import { useNavigate } from 'react-router-dom';
+import { useAuthStore } from '../store/auth';
+import { login } from '../api/auth';
+
+export default function LoginPage() {
+ const navigate = useNavigate();
+ const setAuth = useAuthStore((state) => state.setAuth);
+ const [loading, setLoading] = useState(false);
+
+ const handleSubmit = async (values: { username: string; password: string }) => {
+ setLoading(true);
+ try {
+ const result = await login(values);
+ setAuth(result.token, result.user);
+ message.success('登录成功');
+ navigate('/videos');
+ } catch (error) {
+ message.error('登录失败,请检查用户名和密码');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/pages/Logs.tsx b/apps/web/src/pages/Logs.tsx
new file mode 100644
index 0000000..c8814cb
--- /dev/null
+++ b/apps/web/src/pages/Logs.tsx
@@ -0,0 +1,87 @@
+import { useEffect, useState } from 'react';
+import { Table, Tag } from 'antd';
+import dayjs from 'dayjs';
+import { listLogs, OperationLog } from '../api/logs';
+
+const actionMap: Record = {
+ BATCH_DELETE: '批量删除',
+ BATCH_DOWNLOAD: '批量下载',
+ BATCH_UPDATE: '批量更新',
+};
+
+const actionColor: Record = {
+ BATCH_DELETE: 'red',
+ BATCH_DOWNLOAD: 'blue',
+ BATCH_UPDATE: 'green',
+};
+
+export default function LogsPage() {
+ const [logs, setLogs] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [total, setTotal] = useState(0);
+ const [pageNo, setPageNo] = useState(1);
+ const [pageSize, setPageSize] = useState(20);
+
+ const fetchLogs = async (page: number, size: number) => {
+ setLoading(true);
+ try {
+ const result = await listLogs({ pageNo: page, pageSize: size });
+ setLogs(result.logs);
+ setTotal(result.total);
+ setPageNo(result.pageNo);
+ setPageSize(result.pageSize);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ fetchLogs(1, 20);
+ }, []);
+
+ const columns = [
+ {
+ title: '时间',
+ dataIndex: 'createdAt',
+ width: 180,
+ render: (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm:ss'),
+ },
+ { title: '用户', dataIndex: 'username', width: 120 },
+ {
+ title: '操作',
+ dataIndex: 'action',
+ width: 120,
+ render: (action: string) => {actionMap[action] || action},
+ },
+ { title: '目标类型', dataIndex: 'targetType', width: 100 },
+ {
+ title: '目标 ID',
+ dataIndex: 'targetIds',
+ ellipsis: true,
+ render: (ids: string | null) => ids || '-',
+ },
+ {
+ title: '详情',
+ dataIndex: 'details',
+ ellipsis: true,
+ render: (details: string | null) => (details ? JSON.stringify(JSON.parse(details)) : '-'),
+ },
+ ];
+
+ return (
+ `共 ${t} 条`,
+ onChange: (page, size) => fetchLogs(page, size || 20),
+ }}
+ />
+ );
+}
diff --git a/apps/web/src/pages/Videos.tsx b/apps/web/src/pages/Videos.tsx
new file mode 100644
index 0000000..c72a6b8
--- /dev/null
+++ b/apps/web/src/pages/Videos.tsx
@@ -0,0 +1,357 @@
+import { useEffect, useState } from 'react';
+import {
+ Table,
+ Input,
+ Button,
+ Space,
+ Form,
+ Select,
+ DatePicker,
+ message,
+ Modal,
+ Drawer,
+ Tag,
+ Image,
+ Popconfirm,
+ Typography,
+} from 'antd';
+import { DownloadOutlined, DeleteOutlined, EyeOutlined } from '@ant-design/icons';
+import dayjs from 'dayjs';
+import { searchVideos, batchDelete, batchDownload, VideoItem, DownloadResult } from '../api/videos';
+import { listCategories } from '../api/categories';
+import { useAuthStore } from '../store/auth';
+
+const { RangePicker } = DatePicker;
+const { Text } = Typography;
+
+const formatDuration = (seconds?: number) => {
+ if (!seconds) return '-';
+ const m = Math.floor(seconds / 60);
+ const s = Math.floor(seconds % 60);
+ return `${m}:${s.toString().padStart(2, '0')}`;
+};
+
+const formatSize = (bytes?: number) => {
+ if (!bytes) return '-';
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
+ let i = 0;
+ let size = bytes;
+ while (size >= 1024 && i < units.length - 1) {
+ size /= 1024;
+ i++;
+ }
+ return `${size.toFixed(2)} ${units[i]}`;
+};
+
+export default function VideosPage() {
+ const user = useAuthStore((state) => state.user);
+ const [videos, setVideos] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [selectedRows, setSelectedRows] = useState([]);
+ const [total, setTotal] = useState(0);
+ const [pageNo, setPageNo] = useState(1);
+ const [pageSize, setPageSize] = useState(20);
+ const [categories, setCategories] = useState[]>([]);
+ const [detail, setDetail] = useState(null);
+ const [drawerOpen, setDrawerOpen] = useState(false);
+ const [form] = Form.useForm();
+
+ const fetchVideos = async (params?: Record) => {
+ setLoading(true);
+ try {
+ const values = form.getFieldsValue();
+ const query: Record = {
+ pageNo,
+ pageSize,
+ ...params,
+ };
+
+ if (values.keyword) query.keyword = values.keyword;
+ if (values.cateId) query.cateId = values.cateId;
+ if (values.status) query.status = values.status;
+ if (values.dateRange?.[0] && values.dateRange?.[1]) {
+ query.startTime = values.dateRange[0].toISOString();
+ query.endTime = values.dateRange[1].toISOString();
+ }
+
+ const result = await searchVideos(query);
+ setVideos(result.videos);
+ setTotal(result.total);
+ setPageNo(result.pageNo);
+ setPageSize(result.pageSize);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const fetchCategories = async () => {
+ try {
+ const data = await listCategories();
+ setCategories(data);
+ } catch {
+ // ignore
+ }
+ };
+
+ useEffect(() => {
+ fetchVideos();
+ fetchCategories();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const handleSearch = () => {
+ setPageNo(1);
+ fetchVideos({ pageNo: 1 });
+ };
+
+ const handleReset = () => {
+ form.resetFields();
+ setPageNo(1);
+ fetchVideos({ pageNo: 1 });
+ };
+
+ const handleBatchDelete = async () => {
+ const ids = selectedRows.map((v) => v.videoId);
+ try {
+ await batchDelete(ids);
+ message.success(`已删除 ${ids.length} 个视频`);
+ setSelectedRows([]);
+ fetchVideos();
+ } catch (error) {
+ message.error('删除失败');
+ }
+ };
+
+ const handleBatchDownload = async () => {
+ const ids = selectedRows.map((v) => v.videoId);
+ try {
+ const results = await batchDownload(ids);
+ const valid = results.filter((r) => r.urls.length > 0);
+ showDownloadResults(valid);
+ setSelectedRows([]);
+ } catch (error) {
+ message.error('获取下载地址失败');
+ }
+ };
+
+ const showDownloadResults = (results: DownloadResult[]) => {
+ Modal.info({
+ title: '批量下载地址',
+ width: 800,
+ content: (
+
+ {results.map((item) => (
+
+
{item.title || item.videoId}
+
+ {item.urls.map((url, idx) => (
+ -
+ {url.url}
+ {url.definition}
+
+ ))}
+
+
+ ))}
+
+ ),
+ });
+ };
+
+ const handleRowClick = (record: VideoItem) => {
+ setDetail(record);
+ setDrawerOpen(true);
+ };
+
+ const columns = [
+ {
+ title: '封面',
+ dataIndex: 'coverURL',
+ width: 80,
+ render: (url?: string) =>
+ url ? : '-',
+ },
+ { title: '视频 ID', dataIndex: 'videoId', width: 180, ellipsis: true },
+ { title: '标题', dataIndex: 'title', ellipsis: true },
+ {
+ title: '分类',
+ dataIndex: 'cateName',
+ width: 120,
+ render: (name?: string) => name || '-',
+ },
+ {
+ title: '时长',
+ dataIndex: 'duration',
+ width: 100,
+ render: (duration?: number) => formatDuration(duration),
+ },
+ {
+ title: '大小',
+ dataIndex: 'size',
+ width: 120,
+ render: (size?: number) => formatSize(size),
+ },
+ {
+ title: '状态',
+ dataIndex: 'status',
+ width: 100,
+ render: (status?: string) => {status || '-'},
+ },
+ {
+ title: '创建时间',
+ dataIndex: 'creationTime',
+ width: 180,
+ render: (time?: string) => (time ? dayjs(time).format('YYYY-MM-DD HH:mm:ss') : '-'),
+ },
+ {
+ title: '操作',
+ key: 'action',
+ width: 100,
+ render: (_: unknown, record: VideoItem) => (
+ } type="link" onClick={() => handleRowClick(record)}>
+ 查看
+
+ ),
+ },
+ ];
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ disabled={selectedRows.length === 0}
+ onClick={handleBatchDownload}
+ >
+ 批量下载 ({selectedRows.length})
+
+ {user?.role === 'admin' && (
+
+ }
+ disabled={selectedRows.length === 0}
+ >
+ 批量删除 ({selectedRows.length})
+
+
+ )}
+
+
+
`共 ${t} 条`,
+ onChange: (page, size) => {
+ setPageNo(page);
+ setPageSize(size || 20);
+ fetchVideos({ pageNo: page, pageSize: size });
+ },
+ }}
+ rowSelection={{
+ selectedRowKeys: selectedRows.map((v) => v.videoId),
+ onChange: (_keys, rows) => setSelectedRows(rows),
+ }}
+ />
+
+ setDrawerOpen(false)}
+ >
+ {detail && (
+
+ {detail.coverURL && }
+
+ 视频 ID:
+ {detail.videoId}
+
+
+ 标题:
+ {detail.title}
+
+
+ 描述:
+ {detail.description || '-'}
+
+
+ 分类:
+ {detail.cateName || '-'}
+
+
+ 标签:
+ {detail.tags || '-'}
+
+
+ 时长:
+ {formatDuration(detail.duration)}
+
+
+ 大小:
+ {formatSize(detail.size)}
+
+
+ 状态:
+ {detail.status || '-'}
+
+
+ 创建时间:
+ {detail.creationTime
+ ? dayjs(detail.creationTime).format('YYYY-MM-DD HH:mm:ss')
+ : '-'}
+
+
+ )}
+
+
+ );
+}
diff --git a/apps/web/src/store/auth.ts b/apps/web/src/store/auth.ts
new file mode 100644
index 0000000..1ac3738
--- /dev/null
+++ b/apps/web/src/store/auth.ts
@@ -0,0 +1,27 @@
+import { create } from 'zustand';
+import { persist } from 'zustand/middleware';
+
+export interface User {
+ id: string;
+ username: string;
+ role: 'admin' | 'readonly';
+}
+
+interface AuthState {
+ token: string | null;
+ user: User | null;
+ setAuth: (token: string, user: User) => void;
+ clearAuth: () => void;
+}
+
+export const useAuthStore = create()(
+ persist(
+ (set) => ({
+ token: null,
+ user: null,
+ setAuth: (token, user) => set({ token, user }),
+ clearAuth: () => set({ token: null, user: null }),
+ }),
+ { name: 'vod-auth' }
+ )
+);
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
new file mode 100644
index 0000000..3934b8f
--- /dev/null
+++ b/apps/web/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src"],
+ "references": [{ "path": "./tsconfig.node.json" }]
+}
diff --git a/apps/web/tsconfig.node.json b/apps/web/tsconfig.node.json
new file mode 100644
index 0000000..42872c5
--- /dev/null
+++ b/apps/web/tsconfig.node.json
@@ -0,0 +1,10 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
new file mode 100644
index 0000000..13ffae5
--- /dev/null
+++ b/apps/web/vite.config.ts
@@ -0,0 +1,18 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ port: 5173,
+ proxy: {
+ '/api': {
+ target: 'http://localhost:3001',
+ changeOrigin: true,
+ },
+ },
+ },
+ build: {
+ outDir: 'dist',
+ },
+});