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
+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>
);
}