224 lines
11 KiB
TypeScript
224 lines
11 KiB
TypeScript
import React, { useCallback, useEffect, useState } from 'react';
|
|
import { Button, Card, DatePicker, Input, Progress, Select, Space, Table, Tag, Tooltip, Typography, message } from 'antd';
|
|
import { CameraOutlined, EyeOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { getAdminShotTaskSets } from '../api';
|
|
import type { ShotTaskSetOut } from '../types';
|
|
import { formatDate } from '../utils/formatDate';
|
|
import { getShotAnalysisStatusMeta, getShotSplitStatusMeta, getShotTaskStatusMeta } from '../utils/shotReplicateStatus';
|
|
|
|
const PAGE_SIZE = 20;
|
|
|
|
const TASK_STATUS_OPTIONS = [
|
|
{ value: 'pending_analysis', label: '等待分析' },
|
|
{ value: 'analyzing', label: '分析中' },
|
|
{ value: 'analysis_completed', label: '分析完成' },
|
|
{ value: 'analysis_failed', label: '分析失败' },
|
|
{ value: 'splitting', label: '拆镜中' },
|
|
{ value: 'split_completed', label: '拆镜完成' },
|
|
{ value: 'partial_failed', label: '部分失败' },
|
|
{ value: 'failed', label: '失败' },
|
|
];
|
|
|
|
const ANALYSIS_STATUS_OPTIONS = [
|
|
{ value: 'pending', label: '待分析' },
|
|
{ value: 'processing', label: '分析中' },
|
|
{ value: 'completed', label: '分析完成' },
|
|
{ value: 'failed', label: '分析失败' },
|
|
];
|
|
|
|
const SPLIT_STATUS_OPTIONS = [
|
|
{ value: 'none', label: '未拆镜' },
|
|
{ value: 'pending', label: '待拆镜' },
|
|
{ value: 'processing', label: '拆镜中' },
|
|
{ value: 'completed', label: '拆镜完成' },
|
|
{ value: 'failed', label: '拆镜失败' },
|
|
{ value: 'retry_waiting', label: '等待重试' },
|
|
];
|
|
|
|
const safeDate = (value?: string | null): string => (value ? formatDate(value) : '-');
|
|
const shortId = (value?: string | null): string => (!value ? '-' : value.length > 16 ? `${value.slice(0, 10)}...` : value);
|
|
|
|
const StatusTag: React.FC<{ status?: string | null; kind?: 'task' | 'analysis' | 'split' }> = ({ status, kind = 'task' }) => {
|
|
if (!status) return <Tag>-</Tag>;
|
|
const meta = kind === 'analysis'
|
|
? getShotAnalysisStatusMeta(status)
|
|
: (kind === 'split' ? getShotSplitStatusMeta(status) : getShotTaskStatusMeta(status));
|
|
return <Tag color={meta.color}>{meta.text}</Tag>;
|
|
};
|
|
|
|
const AdminShotReplications: React.FC = () => {
|
|
const navigate = useNavigate();
|
|
const [items, setItems] = useState<ShotTaskSetOut[]>([]);
|
|
const [total, setTotal] = useState(0);
|
|
const [loading, setLoading] = useState(false);
|
|
const [page, setPage] = useState(1);
|
|
|
|
const [status, setStatus] = useState('');
|
|
const [analysisStatus, setAnalysisStatus] = useState('');
|
|
const [splitStatus, setSplitStatus] = useState('');
|
|
const [inputKeyword, setInputKeyword] = useState('');
|
|
const [inputUserId, setInputUserId] = useState('');
|
|
const [inputUserName, setInputUserName] = useState('');
|
|
const [createdRange, setCreatedRange] = useState<any>(null);
|
|
|
|
const [queryKeyword, setQueryKeyword] = useState('');
|
|
const [queryUserId, setQueryUserId] = useState('');
|
|
const [queryUserName, setQueryUserName] = useState('');
|
|
const [queryCreatedRange, setQueryCreatedRange] = useState<any>(null);
|
|
const [reloadKey, setReloadKey] = useState(0);
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const res = await getAdminShotTaskSets({
|
|
status: status || undefined,
|
|
analysisStatus: analysisStatus || undefined,
|
|
splitStatus: splitStatus || undefined,
|
|
keyword: queryKeyword || undefined,
|
|
userId: queryUserId || undefined,
|
|
userName: queryUserName || undefined,
|
|
createdStart: queryCreatedRange?.[0]?.toISOString?.(),
|
|
createdEnd: queryCreatedRange?.[1]?.toISOString?.(),
|
|
page,
|
|
pageSize: PAGE_SIZE,
|
|
});
|
|
setItems(res.items || []);
|
|
setTotal(res.total || 0);
|
|
} catch (e: any) {
|
|
message.error(e?.message || '加载拆镜复刻任务失败');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [analysisStatus, page, queryCreatedRange, queryKeyword, queryUserId, queryUserName, splitStatus, status]);
|
|
|
|
useEffect(() => {
|
|
load();
|
|
}, [load, reloadKey]);
|
|
|
|
const hasActiveTasks = items.some((item) => (
|
|
getShotTaskStatusMeta(item.status).active
|
|
|| getShotAnalysisStatusMeta(item.analysisStatus).active
|
|
|| getShotSplitStatusMeta(item.splitStatus).active
|
|
));
|
|
|
|
useEffect(() => {
|
|
if (!hasActiveTasks) return undefined;
|
|
const refresh = () => {
|
|
if (document.visibilityState === 'visible') void load();
|
|
};
|
|
const timer = window.setInterval(refresh, 30000);
|
|
document.addEventListener('visibilitychange', refresh);
|
|
return () => {
|
|
window.clearInterval(timer);
|
|
document.removeEventListener('visibilitychange', refresh);
|
|
};
|
|
}, [hasActiveTasks, load]);
|
|
|
|
const doSearch = () => {
|
|
setQueryKeyword(inputKeyword.trim());
|
|
setQueryUserId(inputUserId.trim());
|
|
setQueryUserName(inputUserName.trim());
|
|
setQueryCreatedRange(createdRange);
|
|
setPage(1);
|
|
};
|
|
|
|
const resetSearch = () => {
|
|
setStatus('');
|
|
setAnalysisStatus('');
|
|
setSplitStatus('');
|
|
setInputKeyword('');
|
|
setInputUserId('');
|
|
setInputUserName('');
|
|
setCreatedRange(null);
|
|
setQueryKeyword('');
|
|
setQueryUserId('');
|
|
setQueryUserName('');
|
|
setQueryCreatedRange(null);
|
|
setPage(1);
|
|
setReloadKey(v => v + 1);
|
|
};
|
|
|
|
return (
|
|
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
|
|
<Space>
|
|
<CameraOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
|
<Typography.Text strong style={{ fontSize: 16 }}>拆镜复刻</Typography.Text>
|
|
<Tag color="purple">{total} 条记录</Tag>
|
|
</Space>
|
|
<Button icon={<ReloadOutlined />} onClick={() => setReloadKey(v => v + 1)}>刷新</Button>
|
|
</div>
|
|
|
|
<Space wrap>
|
|
<Select allowClear placeholder="总任务状态" style={{ width: 150 }} value={status || undefined} onChange={v => { setStatus(v || ''); setPage(1); }} options={TASK_STATUS_OPTIONS} />
|
|
<Select allowClear placeholder="分析状态" style={{ width: 140 }} value={analysisStatus || undefined} onChange={v => { setAnalysisStatus(v || ''); setPage(1); }} options={ANALYSIS_STATUS_OPTIONS} />
|
|
<Select allowClear placeholder="拆镜状态" style={{ width: 140 }} value={splitStatus || undefined} onChange={v => { setSplitStatus(v || ''); setPage(1); }} options={SPLIT_STATUS_OPTIONS} />
|
|
<Input allowClear prefix={<SearchOutlined />} placeholder="关键词:标题/内容/分类/受众" style={{ width: 260 }} value={inputKeyword} onChange={e => setInputKeyword(e.target.value)} onPressEnter={doSearch} />
|
|
<Input allowClear placeholder="用户ID(管理员)" style={{ width: 190 }} value={inputUserId} onChange={e => setInputUserId(e.target.value)} onPressEnter={doSearch} />
|
|
<Input allowClear placeholder="用户名(管理员)" style={{ width: 230 }} value={inputUserName} onChange={e => setInputUserName(e.target.value)} onPressEnter={doSearch} />
|
|
<DatePicker.RangePicker showTime value={createdRange} onChange={setCreatedRange} placeholder={['创建开始', '创建结束']} />
|
|
<Button type="primary" icon={<SearchOutlined />} onClick={doSearch}>搜索</Button>
|
|
<Button onClick={resetSearch}>重置</Button>
|
|
</Space>
|
|
|
|
<Table
|
|
rowKey="id"
|
|
loading={loading}
|
|
dataSource={items}
|
|
pagination={{ current: page, pageSize: PAGE_SIZE, total, showSizeChanger: false, showTotal: value => `共 ${value} 条`, onChange: setPage }}
|
|
scroll={{ x: 1500 }}
|
|
columns={[
|
|
{ title: '任务集ID', dataIndex: 'id', width: 150, render: (v: string) => <Tooltip title={v}>{shortId(v)}</Tooltip> },
|
|
{
|
|
title: '用户',
|
|
width: 180,
|
|
render: (_, record) => (
|
|
<Space direction="vertical" size={0}>
|
|
<Typography.Text>{record.userName || '-'}</Typography.Text>
|
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{shortId(record.userId)}</Typography.Text>
|
|
</Space>
|
|
),
|
|
},
|
|
{
|
|
title: '任务信息',
|
|
width: 300,
|
|
render: (_, record) => (
|
|
<Space direction="vertical" size={2}>
|
|
<Typography.Text strong>{record.title || '-'}</Typography.Text>
|
|
<Typography.Text type="secondary" ellipsis style={{ maxWidth: 280 }}>分类:{record.originalVideoCategory || '-'}</Typography.Text>
|
|
<Typography.Text type="secondary" ellipsis style={{ maxWidth: 280 }}>受众:{record.originalVideoAudience || '-'}</Typography.Text>
|
|
</Space>
|
|
),
|
|
},
|
|
{ title: '总状态', dataIndex: 'status', width: 120, render: (v: string) => <StatusTag status={v} kind="task" /> },
|
|
{ title: '分析状态', dataIndex: 'analysisStatus', width: 110, render: (v: string) => <StatusTag status={v} kind="analysis" /> },
|
|
{ title: '拆镜状态', dataIndex: 'splitStatus', width: 110, render: (v: string) => <StatusTag status={v} kind="split" /> },
|
|
{
|
|
title: '切片进度',
|
|
width: 180,
|
|
render: (_, record) => {
|
|
const totalSegments = record.segmentCount || 0;
|
|
const done = record.completedSegmentCount || 0;
|
|
const percent = totalSegments ? Math.round((done / totalSegments) * 100) : 0;
|
|
return <Progress size="small" percent={percent} format={() => `${done}/${totalSegments}`} />;
|
|
},
|
|
},
|
|
{ title: '失败片段', dataIndex: 'failedSegmentCount', width: 90, render: (v: number) => v ? <Tag color="error">{v}</Tag> : <Tag>0</Tag> },
|
|
{ title: '视频时长', dataIndex: 'videoDurationSeconds', width: 100, render: (v: number) => `${Number(v || 0).toFixed(1)}s` },
|
|
{ title: '创建时间', dataIndex: 'createdAt', width: 170, render: safeDate },
|
|
{ title: '更新时间', dataIndex: 'updatedAt', width: 170, render: safeDate },
|
|
{
|
|
title: '操作',
|
|
fixed: 'right',
|
|
width: 120,
|
|
render: (_, record) => <Button type="link" icon={<EyeOutlined />} onClick={() => navigate(`/shot-replications/task-sets/${record.id}`)}>查看切片</Button>,
|
|
},
|
|
]}
|
|
/>
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
export default AdminShotReplications;
|