358 lines
10 KiB
TypeScript
358 lines
10 KiB
TypeScript
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>
|
||
);
|
||
}
|