Files
video-gen/video-gen-admin/src/pages/AdminShotTaskSetDetail.tsx
T
2026-07-22 14:48:29 +08:00

358 lines
18 KiB
TypeScript

import React, { useCallback, useEffect, useState } from 'react';
import {
Alert,
Button,
Card,
Collapse,
Descriptions,
Drawer,
Empty,
Select,
Space,
Spin,
Table,
Tag,
Tooltip,
Typography,
message,
} from 'antd';
import { ArrowLeftOutlined, EyeOutlined, PlayCircleOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
import { useNavigate, useParams } from 'react-router-dom';
import { getAdminShotSegmentDetail, getAdminShotSegments, getAdminShotTaskSetDetail } from '../api';
import type { ShotAiSuggestionOut, ShotSegmentDetailOut, ShotSegmentOut, ShotTaskSetDetailOut } from '../types';
import { formatDate } from '../utils/formatDate';
import { getStepCodeLabel } from './adminReplication/components/StatusTag';
import { getShotAnalysisStatusMeta, getShotReplicateStatusMeta, getShotSplitStatusMeta, getShotTaskStatusMeta } from '../utils/shotReplicateStatus';
const RAW_API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
const RESOURCE_BASE = RAW_API_BASE.replace(/\/api\/?$/i, '').replace(/\/$/, '');
const PAGE_SIZE = 20;
const SOURCE_OPTIONS = [
{ value: 'ai_suggestion', label: 'AI 建议' },
{ value: 'custom', 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 ANALYSIS_STATUS_OPTIONS = [
{ value: 'not_required', label: '无需分析' },
{ value: 'pending', label: '待分析' },
{ value: 'processing', label: '分析中' },
{ value: 'completed', label: '分析完成' },
{ value: 'failed', label: '分析失败' },
];
const REPLICATE_STATUS_OPTIONS = [
{ value: 'not_started', label: '未复刻' },
{ value: 'project_created', label: '已创建项目' },
{ value: 'processing', label: '复刻中' },
{ value: 'completed', label: '复刻完成' },
{ value: 'failed', label: '复刻失败' },
];
const apiUrl = (url?: string | null): string => {
if (!url) return '';
const value = String(url).trim();
if (!value) return '';
if (/^(https?:)?\/\//i.test(value) || /^(blob|data):/i.test(value)) return value;
return `${RESOURCE_BASE}${value.startsWith('/') ? value : `/${value}`}`;
};
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' | 'replicate' }> = ({ status, kind = 'task' }) => {
if (!status) return <Tag>-</Tag>;
const meta = kind === 'analysis'
? getShotAnalysisStatusMeta(status)
: (kind === 'split'
? getShotSplitStatusMeta(status)
: (kind === 'replicate' ? getShotReplicateStatusMeta(status) : getShotTaskStatusMeta(status)));
return <Tag color={meta.color}>{meta.text}</Tag>;
};
const JsonBlock: React.FC<{ value: unknown; maxHeight?: number }> = ({ value, maxHeight = 420 }) => (
<pre style={{ margin: 0, padding: 12, maxHeight, overflow: 'auto', background: '#0f172a', color: '#e2e8f0', borderRadius: 8, fontSize: 12, lineHeight: 1.6 }}>
{JSON.stringify(value ?? {}, null, 2)}
</pre>
);
const VideoPreview: React.FC<{ url?: string | null; height?: number }> = ({ url, height = 280 }) => {
if (!url) return <Empty description="暂无视频" image={Empty.PRESENTED_IMAGE_SIMPLE} />;
return <video src={apiUrl(url)} controls preload="metadata" style={{ width: '100%', maxHeight: height, borderRadius: 12, background: '#0f172a' }} />;
};
const SuggestionTable: React.FC<{ items?: ShotAiSuggestionOut[] }> = ({ items = [] }) => (
<Table
rowKey="index"
size="small"
pagination={false}
dataSource={items}
scroll={{ x: true }}
columns={[
{ title: '序号', dataIndex: 'index', width: 70 },
{ title: '时间节点', dataIndex: 'timeNode', width: 120 },
{ title: '开始', dataIndex: 'startSecond', width: 80, render: (v: number) => `${Number(v || 0).toFixed(2)}s` },
{ title: '结束', dataIndex: 'endSecond', width: 80, render: (v: number) => `${Number(v || 0).toFixed(2)}s` },
{ title: '内容', dataIndex: 'content', ellipsis: true },
{ title: '分类', dataIndex: 'category', width: 140 },
{ title: '受众', dataIndex: 'audience', ellipsis: true },
]}
/>
);
const AdminShotTaskSetDetail: React.FC = () => {
const { taskSetId } = useParams<{ taskSetId: string }>();
const navigate = useNavigate();
const [detail, setDetail] = useState<ShotTaskSetDetailOut | null>(null);
const [segments, setSegments] = useState<ShotSegmentOut[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [segmentLoading, setSegmentLoading] = useState(false);
const [reloadKey, setReloadKey] = useState(0);
const [sourceMode, setSourceMode] = useState('');
const [splitStatus, setSplitStatus] = useState('');
const [analysisStatus, setAnalysisStatus] = useState('');
const [replicateStatus, setReplicateStatus] = useState('');
const [drawerOpen, setDrawerOpen] = useState(false);
const [segmentDetail, setSegmentDetail] = useState<ShotSegmentDetailOut | null>(null);
const loadDetail = useCallback(async () => {
if (!taskSetId) return;
setLoading(true);
try {
const res = await getAdminShotTaskSetDetail(taskSetId);
setDetail(res);
} catch (e: any) {
message.error(e?.message || '加载拆镜任务详情失败');
} finally {
setLoading(false);
}
}, [taskSetId]);
const loadSegments = useCallback(async () => {
if (!taskSetId) return;
setSegmentLoading(true);
try {
const res = await getAdminShotSegments(taskSetId, {
sourceMode: sourceMode || undefined,
splitStatus: splitStatus || undefined,
analysisStatus: analysisStatus || undefined,
replicateStatus: replicateStatus || undefined,
page,
pageSize: PAGE_SIZE,
});
setSegments(res.items || []);
setTotal(res.total || 0);
} catch (e: any) {
message.error(e?.message || '加载拆镜片段失败');
} finally {
setSegmentLoading(false);
}
}, [analysisStatus, page, replicateStatus, sourceMode, splitStatus, taskSetId]);
useEffect(() => { loadDetail(); }, [loadDetail, reloadKey]);
useEffect(() => { loadSegments(); }, [loadSegments, reloadKey]);
const hasActiveTasks = Boolean(detail && (
getShotTaskStatusMeta(detail.status).active
|| getShotAnalysisStatusMeta(detail.analysisStatus).active
|| getShotSplitStatusMeta(detail.splitStatus).active
|| segments.some((item) => (
getShotSplitStatusMeta(item.splitStatus).active
|| getShotAnalysisStatusMeta(item.analysisStatus).active
|| getShotReplicateStatusMeta(item.replicateStatus).active
))
));
useEffect(() => {
if (!hasActiveTasks) return undefined;
const refresh = () => {
if (document.visibilityState !== 'visible') return;
void loadDetail();
void loadSegments();
};
const timer = window.setInterval(refresh, 20000);
document.addEventListener('visibilitychange', refresh);
return () => {
window.clearInterval(timer);
document.removeEventListener('visibilitychange', refresh);
};
}, [hasActiveTasks, loadDetail, loadSegments]);
const openSegmentDetail = async (segmentId: string) => {
setDrawerOpen(true);
setSegmentDetail(null);
try {
const res = await getAdminShotSegmentDetail(segmentId);
setSegmentDetail(res);
} catch (e: any) {
message.error(e?.message || '加载片段详情失败');
}
};
const resetFilters = () => {
setSourceMode('');
setSplitStatus('');
setAnalysisStatus('');
setReplicateStatus('');
setPage(1);
setReloadKey(v => v + 1);
};
if (loading && !detail) {
return <div style={{ padding: 64, textAlign: 'center' }}><Spin size="large" /></div>;
}
if (!detail) {
return <Card style={{ margin: 24 }}><Empty description="未找到拆镜任务详情" /></Card>;
}
return (
<div style={{ padding: 24 }}>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Space style={{ justifyContent: 'space-between', width: '100%' }}>
<Space>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/shot-replications')}>返回列表</Button>
<Typography.Title level={3} style={{ margin: 0 }}>拆镜任务详情</Typography.Title>
<StatusTag status={detail.status} />
</Space>
<Button icon={<ReloadOutlined />} onClick={() => setReloadKey(v => v + 1)}>刷新</Button>
</Space>
<Card>
<Descriptions title="总任务信息" column={3} bordered size="small">
<Descriptions.Item label="任务集ID">{detail.id}</Descriptions.Item>
<Descriptions.Item label="用户ID">{detail.userId || '-'}</Descriptions.Item>
<Descriptions.Item label="用户名">{detail.userName || '-'}</Descriptions.Item>
<Descriptions.Item label="标题">{detail.title || '-'}</Descriptions.Item>
<Descriptions.Item label="视频时长">{Number(detail.videoDurationSeconds || 0).toFixed(2)}s</Descriptions.Item>
<Descriptions.Item label="总状态"><StatusTag status={detail.status} kind="task" /></Descriptions.Item>
<Descriptions.Item label="分析状态"><StatusTag status={detail.analysisStatus} kind="analysis" /></Descriptions.Item>
<Descriptions.Item label="拆镜状态"><StatusTag status={detail.splitStatus} kind="split" /></Descriptions.Item>
<Descriptions.Item label="片段数量">{detail.completedSegmentCount}/{detail.segmentCount},失败 {detail.failedSegmentCount}</Descriptions.Item>
<Descriptions.Item label="原视频分类">{detail.originalVideoCategory || '-'}</Descriptions.Item>
<Descriptions.Item label="创建时间">{safeDate(detail.createdAt)}</Descriptions.Item>
<Descriptions.Item label="更新时间">{safeDate(detail.updatedAt)}</Descriptions.Item>
<Descriptions.Item label="原视频内容" span={3}>{detail.originalVideoContent || '-'}</Descriptions.Item>
<Descriptions.Item label="原视频受众" span={3}>{detail.originalVideoAudience || '-'}</Descriptions.Item>
{detail.analysisErrorMessage ? <Descriptions.Item label="分析错误" span={3}><Alert type="error" message={detail.analysisErrorMessage} /></Descriptions.Item> : null}
{detail.splitErrorMessage ? <Descriptions.Item label="拆镜错误" span={3}><Alert type="error" message={detail.splitErrorMessage} /></Descriptions.Item> : null}
</Descriptions>
</Card>
<Card title={<Space><PlayCircleOutlined />原视频预览</Space>}>
<VideoPreview url={detail.videoUrl} height={360} />
</Card>
<Card title="AI 建议拆镜列表">
{detail.aiSuggestions?.length ? <SuggestionTable items={detail.aiSuggestions} /> : <Empty description="暂无 AI 建议拆镜" />}
<Collapse
size="small"
ghost
style={{ marginTop: 12 }}
items={[{ key: 'raw', label: '查看原视频分析完整 JSON', children: <JsonBlock value={detail.analysisResultJson} /> }]}
/>
</Card>
<Card title="片段列表">
<Space wrap style={{ marginBottom: 16 }}>
<Select allowClear placeholder="片段来源" style={{ width: 140 }} value={sourceMode || undefined} options={SOURCE_OPTIONS} onChange={v => { setSourceMode(v || ''); setPage(1); }} />
<Select allowClear placeholder="切割状态" style={{ width: 140 }} value={splitStatus || undefined} options={SPLIT_STATUS_OPTIONS} onChange={v => { setSplitStatus(v || ''); setPage(1); }} />
<Select allowClear placeholder="分析状态" style={{ width: 140 }} value={analysisStatus || undefined} options={ANALYSIS_STATUS_OPTIONS} onChange={v => { setAnalysisStatus(v || ''); setPage(1); }} />
<Select allowClear placeholder="复刻状态" style={{ width: 150 }} value={replicateStatus || undefined} options={REPLICATE_STATUS_OPTIONS} onChange={v => { setReplicateStatus(v || ''); setPage(1); }} />
<Button icon={<SearchOutlined />} onClick={() => setReloadKey(v => v + 1)}>筛选</Button>
<Button onClick={resetFilters}>重置</Button>
</Space>
<Table
rowKey="id"
loading={segmentLoading}
dataSource={segments}
pagination={{ current: page, pageSize: PAGE_SIZE, total, showSizeChanger: false, showTotal: value => `共 ${value} 条`, onChange: setPage }}
scroll={{ x: 1550 }}
columns={[
{ title: '片段ID', dataIndex: 'id', width: 150, render: (v: string) => <Tooltip title={v}>{shortId(v)}</Tooltip> },
{ title: '序号', dataIndex: 'segmentIndex', width: 70 },
{ title: '来源', dataIndex: 'sourceMode', width: 100, render: (v: string) => v === 'ai_suggestion' ? <Tag color="purple">AI建议</Tag> : <Tag color="cyan">自定义</Tag> },
{ title: '时间节点', dataIndex: 'timeNode', width: 130 },
{ title: '时长', dataIndex: 'durationSeconds', width: 90, render: (v: number) => `${Number(v || 0).toFixed(2)}s` },
{ title: '切割', dataIndex: 'splitStatus', width: 100, render: (v: string) => <StatusTag status={v} kind="split" /> },
{ title: '分析', dataIndex: 'analysisStatus', width: 100, render: (v: string) => <StatusTag status={v} kind="analysis" /> },
{ title: '复刻', dataIndex: 'replicateStatus', width: 110, render: (v: string) => <StatusTag status={v} kind="replicate" /> },
{ title: '片段内容', dataIndex: 'segmentContent', width: 260, ellipsis: true, render: (v: string) => v || '-' },
{ title: '分类', dataIndex: 'segmentCategory', width: 120, render: (v: string) => v || '-' },
{
title: '关联项目',
width: 220,
render: (_, record) => record.moduleProjectId ? (
<Space direction="vertical" size={0}>
<Button type="link" style={{ padding: 0 }} onClick={() => navigate(`/shot-replications/projects/${record.moduleProjectId}?flow_version=${record.moduleProjectFlowVersion === 'v2' ? 'v2' : 'v1'}`)}>{shortId(record.moduleProjectId)}</Button>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{record.moduleProjectTitle || getStepCodeLabel(record.moduleProjectCurrentStepCode)}</Typography.Text>
<Space size={4}>
<StatusTag status={record.moduleProjectStatus} kind="replicate" />
<Tag color={record.moduleProjectFlowVersion === 'v2' ? 'blue' : 'default'}>
{String(record.moduleProjectFlowVersion || 'v1').toUpperCase()}
</Tag>
</Space>
</Space>
) : <Tag>未创建</Tag>,
},
{ title: '错误', dataIndex: 'splitLastError', width: 220, ellipsis: true, render: (v: string, record) => v || record.analysisErrorMessage || '-' },
{ title: '创建时间', dataIndex: 'createdAt', width: 170, render: safeDate },
{
title: '操作',
fixed: 'right',
width: 100,
render: (_, record) => <Button type="link" icon={<EyeOutlined />} onClick={() => openSegmentDetail(record.id)}>详情</Button>,
},
]}
/>
</Card>
</Space>
<Drawer title="片段详情" width={720} open={drawerOpen} onClose={() => setDrawerOpen(false)} destroyOnClose>
{!segmentDetail ? <Spin /> : (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Descriptions column={2} bordered size="small">
<Descriptions.Item label="片段ID" span={2}>{segmentDetail.id}</Descriptions.Item>
<Descriptions.Item label="时间节点">{segmentDetail.timeNode}</Descriptions.Item>
<Descriptions.Item label="时长">{Number(segmentDetail.durationSeconds || 0).toFixed(2)}s</Descriptions.Item>
<Descriptions.Item label="切割状态"><StatusTag status={segmentDetail.splitStatus} kind="split" /></Descriptions.Item>
<Descriptions.Item label="分析状态"><StatusTag status={segmentDetail.analysisStatus} kind="analysis" /></Descriptions.Item>
<Descriptions.Item label="复刻状态"><StatusTag status={segmentDetail.replicateStatus} kind="replicate" /></Descriptions.Item>
<Descriptions.Item label="关联项目">{segmentDetail.moduleProjectId ? <Button type="link" onClick={() => navigate(`/shot-replications/projects/${segmentDetail.moduleProjectId}?flow_version=${segmentDetail.moduleProjectFlowVersion === 'v2' ? 'v2' : 'v1'}`)}>{segmentDetail.moduleProjectId}</Button> : '-'}</Descriptions.Item>
<Descriptions.Item label="项目流程版本">{segmentDetail.moduleProjectId ? <Tag color={segmentDetail.moduleProjectFlowVersion === 'v2' ? 'blue' : 'default'}>{String(segmentDetail.moduleProjectFlowVersion || 'v1').toUpperCase()}</Tag> : '-'}</Descriptions.Item>
<Descriptions.Item label="片段内容" span={2}>{segmentDetail.segmentContent || '-'}</Descriptions.Item>
<Descriptions.Item label="片段分类">{segmentDetail.segmentCategory || '-'}</Descriptions.Item>
<Descriptions.Item label="片段受众">{segmentDetail.segmentAudience || '-'}</Descriptions.Item>
</Descriptions>
<Card size="small" title="片段视频">
<VideoPreview url={segmentDetail.segmentVideoUrl} height={300} />
</Card>
{segmentDetail.splitLastError ? <Alert type="error" message="切割错误" description={segmentDetail.splitLastError} /> : null}
{segmentDetail.analysisErrorMessage ? <Alert type="error" message="分析错误" description={segmentDetail.analysisErrorMessage} /> : null}
<Collapse
size="small"
items={[
{ key: 'analysis', label: '片段分析 JSON', children: <JsonBlock value={segmentDetail.analysisJson} /> },
{ key: 'suggestion', label: 'AI 建议原始 JSON', children: <JsonBlock value={segmentDetail.aiSuggestionJson} /> },
]}
/>
</Space>
)}
</Drawer>
</div>
);
};
export default AdminShotTaskSetDetail;