拆镜复刻、爆款开头复刻管理后台完成
This commit is contained in:
@@ -0,0 +1,340 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Collapse,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Empty,
|
||||
Input,
|
||||
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';
|
||||
|
||||
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 STATUS_MAP: Record<string, { color: string; text: string }> = {
|
||||
pending_analysis: { color: 'default', text: '等待分析' },
|
||||
analyzing: { color: 'processing', text: '分析中' },
|
||||
analysis_completed: { color: 'success', text: '分析完成' },
|
||||
analysis_failed: { color: 'error', text: '分析失败' },
|
||||
splitting: { color: 'warning', text: '拆镜中' },
|
||||
split_completed: { color: 'success', text: '拆镜完成' },
|
||||
partial_failed: { color: 'orange', text: '部分失败' },
|
||||
failed: { color: 'error', text: '失败' },
|
||||
none: { color: 'default', text: '未拆镜' },
|
||||
pending: { color: 'default', text: '待处理' },
|
||||
processing: { color: 'warning', text: '处理中' },
|
||||
completed: { color: 'success', text: '完成' },
|
||||
retry_waiting: { color: 'orange', text: '等待重试' },
|
||||
not_required: { color: 'default', text: '无需分析' },
|
||||
not_started: { color: 'default', text: '未复刻' },
|
||||
project_created: { color: 'processing', text: '已创建项目' },
|
||||
};
|
||||
|
||||
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 }> = ({ status }) => {
|
||||
if (!status) return <Tag>-</Tag>;
|
||||
const meta = STATUS_MAP[status] || { color: 'blue', text: 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 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} /></Descriptions.Item>
|
||||
<Descriptions.Item label="分析状态"><StatusTag status={detail.analysisStatus} /></Descriptions.Item>
|
||||
<Descriptions.Item label="拆镜状态"><StatusTag status={detail.splitStatus} /></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} /> },
|
||||
{ title: '分析', dataIndex: 'analysisStatus', width: 100, render: (v: string) => <StatusTag status={v} /> },
|
||||
{ title: '复刻', dataIndex: 'replicateStatus', width: 110, render: (v: string) => <StatusTag status={v} /> },
|
||||
{ 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}`)}>{shortId(record.moduleProjectId)}</Button>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{record.moduleProjectTitle || getStepCodeLabel(record.moduleProjectCurrentStepCode)}</Typography.Text>
|
||||
<StatusTag status={record.moduleProjectStatus} />
|
||||
</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} /></Descriptions.Item>
|
||||
<Descriptions.Item label="分析状态"><StatusTag status={segmentDetail.analysisStatus} /></Descriptions.Item>
|
||||
<Descriptions.Item label="复刻状态"><StatusTag status={segmentDetail.replicateStatus} /></Descriptions.Item>
|
||||
<Descriptions.Item label="关联项目">{segmentDetail.moduleProjectId ? <Button type="link" onClick={() => navigate(`/shot-replications/projects/${segmentDetail.moduleProjectId}`)}>{segmentDetail.moduleProjectId}</Button> : '-'}</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;
|
||||
Reference in New Issue
Block a user