From c714ea95acf62f1d9ba152728b541b9cd9fe40f6 Mon Sep 17 00:00:00 2001 From: William Date: Mon, 29 Jun 2026 17:46:41 +0600 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=90=8E=E5=8F=B0=EF=BC=88React=20+=20Vite?= =?UTF-8?q?=20+=20Ant=20Design=20+=20Zustand=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/.eslintrc.cjs | 19 ++ apps/web/index.html | 13 ++ apps/web/package.json | 33 +++ apps/web/src/App.tsx | 37 +++ apps/web/src/api/accounts.ts | 44 ++++ apps/web/src/api/auth.ts | 22 ++ apps/web/src/api/categories.ts | 6 + apps/web/src/api/client.ts | 34 +++ apps/web/src/api/logs.ts | 28 +++ apps/web/src/api/videos.ts | 95 ++++++++ apps/web/src/components/Layout.tsx | 93 ++++++++ apps/web/src/index.css | 10 + apps/web/src/main.tsx | 14 ++ apps/web/src/pages/Accounts.tsx | 162 +++++++++++++ apps/web/src/pages/Login.tsx | 63 +++++ apps/web/src/pages/Logs.tsx | 87 +++++++ apps/web/src/pages/Videos.tsx | 357 +++++++++++++++++++++++++++++ apps/web/src/store/auth.ts | 27 +++ apps/web/tsconfig.json | 21 ++ apps/web/tsconfig.node.json | 10 + apps/web/vite.config.ts | 18 ++ 21 files changed, 1193 insertions(+) create mode 100644 apps/web/.eslintrc.cjs create mode 100644 apps/web/index.html create mode 100644 apps/web/package.json create mode 100644 apps/web/src/App.tsx create mode 100644 apps/web/src/api/accounts.ts create mode 100644 apps/web/src/api/auth.ts create mode 100644 apps/web/src/api/categories.ts create mode 100644 apps/web/src/api/client.ts create mode 100644 apps/web/src/api/logs.ts create mode 100644 apps/web/src/api/videos.ts create mode 100644 apps/web/src/components/Layout.tsx create mode 100644 apps/web/src/index.css create mode 100644 apps/web/src/main.tsx create mode 100644 apps/web/src/pages/Accounts.tsx create mode 100644 apps/web/src/pages/Login.tsx create mode 100644 apps/web/src/pages/Logs.tsx create mode 100644 apps/web/src/pages/Videos.tsx create mode 100644 apps/web/src/store/auth.ts create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/tsconfig.node.json create mode 100644 apps/web/vite.config.ts 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 ( +
+
+ +
+ + + + setModalOpen(false)} + footer={null} + > +
+ + + + + + + + + + + + + + + + + + + +
+ + ); +} 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) => ( + + ), + }, + ]; + + return ( +
+
+ + + + + + + + + + + + + + + + + + + + + + + {user?.role === 'admin' && ( + + + + )} + + +
`共 ${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', + }, +});