Files
Aliyun-VOD-Media-Library-Ma…/apps/web/src/pages/Videos.tsx
T

358 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}