feat: 实现前端管理后台(React + Vite + Ant Design + Zustand)

This commit is contained in:
2026-06-29 17:46:41 +06:00
parent da06866360
commit c714ea95ac
21 changed files with 1193 additions and 0 deletions
+37
View File
@@ -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}</> : <Navigate to="/login" replace />;
}
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/"
element={
<ProtectedRoute>
<Layout />
</ProtectedRoute>
}
>
<Route index element={<Navigate to="/videos" replace />} />
<Route path="videos" element={<VideosPage />} />
<Route path="accounts" element={<AccountsPage />} />
<Route path="logs" element={<LogsPage />} />
</Route>
</Routes>
</BrowserRouter>
);
}
export default App;
+44
View File
@@ -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<Account[]> {
const res = await apiClient.get<ApiResponse<Account[]>>('/accounts');
return res.data.data;
}
export async function createAccount(params: AccountInput): Promise<Account> {
const res = await apiClient.post<ApiResponse<Account>>('/accounts', params);
return res.data.data;
}
export async function updateAccount(id: string, params: Partial<AccountInput>): Promise<Account> {
const res = await apiClient.put<ApiResponse<Account>>(`/accounts/${id}`, params);
return res.data.data;
}
export async function deleteAccount(id: string): Promise<void> {
await apiClient.delete(`/accounts/${id}`);
}
export async function switchAccount(id: string): Promise<Account> {
const res = await apiClient.post<ApiResponse<Account>>(`/accounts/${id}/switch`);
return res.data.data;
}
+22
View File
@@ -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<LoginResult> {
const res = await apiClient.post<ApiResponse<LoginResult>>('/auth/login', params);
return res.data.data;
}
export async function getMe(): Promise<User> {
const res = await apiClient.get<ApiResponse<User>>('/auth/me');
return res.data.data;
}
+6
View File
@@ -0,0 +1,6 @@
import { apiClient, ApiResponse } from './client';
export async function listCategories(): Promise<Record<string, unknown>[]> {
const res = await apiClient.get<ApiResponse<Record<string, unknown>[]>>('/categories');
return res.data.data;
}
+34
View File
@@ -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<T> {
code: number;
message?: string;
data: T;
}
+28
View File
@@ -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<ListLogsResult> {
const res = await apiClient.get<ApiResponse<ListLogsResult>>('/logs', { params });
return res.data.data;
}
+95
View File
@@ -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<SearchResult> {
const res = await apiClient.get<ApiResponse<SearchResult>>('/videos', { params });
return res.data.data;
}
export async function getVideo(videoId: string): Promise<VideoItem> {
const res = await apiClient.get<ApiResponse<VideoItem>>(`/videos/${videoId}`);
return res.data.data;
}
export async function getPlayInfo(videoId: string, definition?: string): Promise<PlayInfo[]> {
const res = await apiClient.post<ApiResponse<PlayInfo[]>>(`/videos/${videoId}/play-info`, {
definition,
});
return res.data.data;
}
export async function batchDelete(videoIds: string[]): Promise<{ deleted: number }> {
const res = await apiClient.post<ApiResponse<{ deleted: number }>>('/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<DownloadResult[]> {
const res = await apiClient.post<ApiResponse<DownloadResult[]>>('/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<ApiResponse<{ updated: number }>>('/videos/batch-update', {
videoIds,
...updates,
});
return res.data.data;
}
+93
View File
@@ -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: <VideoCameraOutlined />, label: '视频库' },
{ key: '/accounts', icon: <SettingOutlined />, label: '账号配置' },
{ key: '/logs', icon: <FileTextOutlined />, 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: <LogoutOutlined />, label: '退出登录', onClick: handleLogout },
];
return (
<AntLayout style={{ minHeight: '100vh' }}>
<Sider trigger={null} collapsible theme="light">
<div
style={{
height: 64,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 'bold',
fontSize: 16,
}}
>
VOD
</div>
<Menu
mode="inline"
selectedKeys={[location.pathname]}
items={menuItems}
onClick={handleMenuClick}
/>
</Sider>
<AntLayout>
<Header style={{ padding: '0 24px', background: colorBgContainer }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', height: '100%' }}>
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
<Button type="text">
<Space>
<UserOutlined />
{user?.username}
</Space>
</Button>
</Dropdown>
</div>
</Header>
<Content
style={{
margin: 24,
padding: 24,
background: colorBgContainer,
borderRadius: borderRadiusLG,
minHeight: 280,
}}
>
<Outlet />
</Content>
</AntLayout>
</AntLayout>
);
}
+10
View File
@@ -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;
}
+14
View File
@@ -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(
<React.StrictMode>
<ConfigProvider locale={zhCN}>
<App />
</ConfigProvider>
</React.StrictMode>
);
+162
View File
@@ -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<Account[]>([]);
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 ? <Tag color="green">使</Tag> : <Tag></Tag>,
},
{
title: '操作',
key: 'action',
render: (_: unknown, record: Account) => (
<Space>
{!record.isActive && (
<Button type="link" onClick={() => handleSwitch(record.id)}>
</Button>
)}
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
<Button type="link" danger>
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<div style={{ marginBottom: 16 }}>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)}>
</Button>
</div>
<Table rowKey="id" columns={columns} dataSource={accounts} loading={loading} />
<Modal
title="添加阿里云 VOD 账号"
open={modalOpen}
onCancel={() => setModalOpen(false)}
footer={null}
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Form.Item
label="名称"
name="name"
rules={[{ required: true, message: '请输入名称' }]}
>
<Input placeholder="用于区分不同账号" />
</Form.Item>
<Form.Item
label="AccessKey ID"
name="accessKeyId"
rules={[{ required: true, message: '请输入 AccessKey ID' }]}
>
<Input />
</Form.Item>
<Form.Item
label="AccessKey Secret"
name="accessKeySecret"
rules={[{ required: true, message: '请输入 AccessKey Secret' }]}
>
<Input.Password />
</Form.Item>
<Form.Item
label="Region"
name="region"
rules={[{ required: true, message: '请输入 Region' }]}
>
<Input placeholder="如 cn-shanghai" />
</Form.Item>
<Form.Item label="Endpoint(可选)" name="endpoint">
<Input placeholder="默认 vod.{region}.aliyuncs.com" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block>
</Button>
</Form.Item>
</Form>
</Modal>
</div>
);
}
+63
View File
@@ -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 (
<div
style={{
height: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#f0f2f5',
}}
>
<Card title="阿里云 VOD 媒体库管理器" style={{ width: 400 }}>
<Form name="login" onFinish={handleSubmit} autoComplete="off" layout="vertical">
<Form.Item
label="用户名"
name="username"
rules={[{ required: true, message: '请输入用户名' }]}
>
<Input placeholder="admin" />
</Form.Item>
<Form.Item
label="密码"
name="password"
rules={[{ required: true, message: '请输入密码' }]}
>
<Input.Password placeholder="admin" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={loading} block>
</Button>
</Form.Item>
</Form>
</Card>
</div>
);
}
+87
View File
@@ -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<string, string> = {
BATCH_DELETE: '批量删除',
BATCH_DOWNLOAD: '批量下载',
BATCH_UPDATE: '批量更新',
};
const actionColor: Record<string, string> = {
BATCH_DELETE: 'red',
BATCH_DOWNLOAD: 'blue',
BATCH_UPDATE: 'green',
};
export default function LogsPage() {
const [logs, setLogs] = useState<OperationLog[]>([]);
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) => <Tag color={actionColor[action] || 'default'}>{actionMap[action] || action}</Tag>,
},
{ 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 (
<Table
rowKey="id"
columns={columns}
dataSource={logs}
loading={loading}
pagination={{
current: pageNo,
pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `${t}`,
onChange: (page, size) => fetchLogs(page, size || 20),
}}
/>
);
}
+357
View File
@@ -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<VideoItem[]>([]);
const [loading, setLoading] = useState(false);
const [selectedRows, setSelectedRows] = useState<VideoItem[]>([]);
const [total, setTotal] = useState(0);
const [pageNo, setPageNo] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [categories, setCategories] = useState<Record<string, unknown>[]>([]);
const [detail, setDetail] = useState<VideoItem | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [form] = Form.useForm();
const fetchVideos = async (params?: Record<string, unknown>) => {
setLoading(true);
try {
const values = form.getFieldsValue();
const query: Record<string, unknown> = {
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: (
<div style={{ maxHeight: 400, overflow: 'auto' }}>
{results.map((item) => (
<div key={item.videoId} style={{ marginBottom: 12 }}>
<Text strong>{item.title || item.videoId}</Text>
<ul style={{ margin: 4, paddingLeft: 20 }}>
{item.urls.map((url, idx) => (
<li key={idx}>
<Text copyable>{url.url}</Text>
<Tag style={{ marginLeft: 8 }}>{url.definition}</Tag>
</li>
))}
</ul>
</div>
))}
</div>
),
});
};
const handleRowClick = (record: VideoItem) => {
setDetail(record);
setDrawerOpen(true);
};
const columns = [
{
title: '封面',
dataIndex: 'coverURL',
width: 80,
render: (url?: string) =>
url ? <Image src={url} width={60} height={40} style={{ objectFit: 'cover' }} /> : '-',
},
{ 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) => <Tag>{status || '-'}</Tag>,
},
{
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) => (
<Button icon={<EyeOutlined />} type="link" onClick={() => handleRowClick(record)}>
</Button>
),
},
];
return (
<div>
<Form form={form} layout="inline" style={{ marginBottom: 16 }}>
<Form.Item name="keyword">
<Input placeholder="搜索标题 / 视频 ID" allowClear />
</Form.Item>
<Form.Item name="cateId">
<Select placeholder="选择分类" allowClear style={{ width: 160 }}>
{categories.map((cat: Record<string, unknown>) => (
<Select.Option key={String(cat.CateId)} value={cat.CateId as number}>
{String(cat.CateName)}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item name="status">
<Select placeholder="状态" allowClear style={{ width: 120 }}>
<Select.Option value="Normal"></Select.Option>
<Select.Option value="Uploading"></Select.Option>
<Select.Option value="Transcoding"></Select.Option>
<Select.Option value="Checking"></Select.Option>
<Select.Option value="Blocked"></Select.Option>
</Select>
</Form.Item>
<Form.Item name="dateRange">
<RangePicker showTime />
</Form.Item>
<Form.Item>
<Button type="primary" onClick={handleSearch}>
</Button>
</Form.Item>
<Form.Item>
<Button onClick={handleReset}></Button>
</Form.Item>
</Form>
<Space style={{ marginBottom: 16 }}>
<Button
type="primary"
icon={<DownloadOutlined />}
disabled={selectedRows.length === 0}
onClick={handleBatchDownload}
>
({selectedRows.length})
</Button>
{user?.role === 'admin' && (
<Popconfirm
title="确认删除"
description={`确定要删除选中的 ${selectedRows.length} 个视频吗?删除后不可恢复。`}
onConfirm={handleBatchDelete}
disabled={selectedRows.length === 0}
>
<Button
danger
icon={<DeleteOutlined />}
disabled={selectedRows.length === 0}
>
({selectedRows.length})
</Button>
</Popconfirm>
)}
</Space>
<Table
rowKey="videoId"
columns={columns}
dataSource={videos}
loading={loading}
pagination={{
current: pageNo,
pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `${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),
}}
/>
<Drawer
title="视频详情"
width={560}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
>
{detail && (
<Space direction="vertical" style={{ width: '100%' }}>
{detail.coverURL && <Image src={detail.coverURL} style={{ width: '100%' }} />}
<p>
<Text strong> ID</Text>
<Text copyable>{detail.videoId}</Text>
</p>
<p>
<Text strong></Text>
{detail.title}
</p>
<p>
<Text strong></Text>
{detail.description || '-'}
</p>
<p>
<Text strong></Text>
{detail.cateName || '-'}
</p>
<p>
<Text strong></Text>
{detail.tags || '-'}
</p>
<p>
<Text strong></Text>
{formatDuration(detail.duration)}
</p>
<p>
<Text strong></Text>
{formatSize(detail.size)}
</p>
<p>
<Text strong></Text>
<Tag>{detail.status || '-'}</Tag>
</p>
<p>
<Text strong></Text>
{detail.creationTime
? dayjs(detail.creationTime).format('YYYY-MM-DD HH:mm:ss')
: '-'}
</p>
</Space>
)}
</Drawer>
</div>
);
}
+27
View File
@@ -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<AuthState>()(
persist(
(set) => ({
token: null,
user: null,
setAuth: (token, user) => set({ token, user }),
clearAuth: () => set({ token: null, user: null }),
}),
{ name: 'vod-auth' }
)
);