Files
video-gen/video-gen-admin/src/pages/AdminReplicationProjectDetail.tsx
T

409 lines
21 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 React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Alert,
Button,
Card,
Collapse,
Descriptions,
Empty,
Space,
Spin,
Steps,
Table,
Tag,
Tooltip,
Typography,
message,
} from 'antd';
import {
ArrowLeftOutlined,
FileImageOutlined,
PlayCircleOutlined,
VideoCameraOutlined,
} from '@ant-design/icons';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { getAdminHotOpeningTaskDetail, getAdminShotProjectDetail } from '../api';
import type { ReplicationProjectDetailOut, ReplicationStepOut } from '../types';
import { formatDate } from '../utils/formatDate';
import JsonCollapse, { JsonBlock } from './adminReplication/components/JsonCollapse';
import MediaPreview from './adminReplication/components/MediaPreview';
import StatusTag, { getModuleLabel, getStatusLabel, getStepCodeLabel } from './adminReplication/components/StatusTag';
import VideoPromptSchemaViewer from './adminReplication/components/VideoPromptSchemaViewer';
type ReplicationModuleType = 'hot_opening_replicate' | 'shot_replicate';
interface AdminReplicationProjectDetailProps {
moduleType?: ReplicationModuleType;
}
const V1_STEP_ORDER = [
'material_input',
'image_prompt_optimize',
'image_generate',
'video_prompt_optimize',
'video_generate',
];
const V2_STEP_ORDER = [
'material_input',
'video_prompt_optimize',
'video_generate',
];
const STEP_DESCRIPTIONS: Record<string, string> = {
material_input: '参考素材、项目名称和核心内容点',
image_prompt_optimize: '图片 AI 提词优化结果',
image_generate: '图片引擎、生成参数和结果图',
video_prompt_optimize: '视频 JSON schema、动作流程和最终提词',
video_generate: '视频引擎、生成参数、封面和结果视频',
};
const safeDate = (value?: string | null): string => (value ? formatDate(value) : '-');
const shortId = (value?: string | null): string => {
if (!value) return '-';
return value.length > 18 ? `${value.slice(0, 10)}...${value.slice(-4)}` : value;
};
const EmptyText: React.FC<{ text?: string }> = ({ text = '-' }) => (
<Typography.Text type="secondary">{text}</Typography.Text>
);
const StepRawJson: React.FC<{ step?: ReplicationStepOut | null }> = ({ step }) => {
if (!step) return null;
return <JsonCollapse input={step.input} output={step.output} />;
};
const StepHeader: React.FC<{ index: number; stepCode: string; step?: ReplicationStepOut | null; current?: boolean }> = ({ index, stepCode, step, current }) => (
<Space wrap size={8}>
<Typography.Text strong>{`第${index}步:${getStepCodeLabel(stepCode)}`}</Typography.Text>
<StatusTag status={step?.status || 'not_started'} />
{current ? <Tag color="blue">当前步骤</Tag> : null}
<Typography.Text type="secondary">{STEP_DESCRIPTIONS[stepCode] || stepCode}</Typography.Text>
{step?.updatedAt ? <Typography.Text type="secondary">更新时间:{safeDate(step.updatedAt)}</Typography.Text> : null}
{step?.chatTaskId ? <Tooltip title={step.chatTaskId}><Tag>Chat{shortId(step.chatTaskId)}</Tag></Tooltip> : null}
</Space>
);
const buildDefaultActiveKeys = (detail: ReplicationProjectDetailOut | null, stepsByCode: Record<string, ReplicationStepOut>): string[] => {
if (!detail) return [];
const keys = new Set<string>();
keys.add('material_input');
if (detail.currentStepCode) keys.add(detail.currentStepCode);
Object.values(stepsByCode).forEach(step => {
if (step.status === 'failed' || step.errorMessage) keys.add(step.stepCode);
});
if (detail.errorMessage) keys.add(detail.currentStepCode || 'material_input');
return Array.from(keys);
};
const renderErrorAlert = (messageText?: string | null, title = '错误信息') => {
if (!messageText) return null;
return <Alert type="error" showIcon message={title} description={messageText} style={{ marginTop: 12, marginBottom: 12 }} />;
};
const renderPromptText = (value?: string | null, empty = '暂无提词') => {
if (!value) return <EmptyText text={empty} />;
return <Typography.Paragraph style={{ whiteSpace: 'pre-wrap', marginBottom: 0 }}>{value}</Typography.Paragraph>;
};
const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps> = ({ moduleType = 'shot_replicate' }) => {
const { projectId } = useParams<{ projectId: string }>();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const flowVersion: 'v1' | 'v2' = searchParams.get('flow_version') === 'v2' ? 'v2' : 'v1';
const [detail, setDetail] = useState<ReplicationProjectDetailOut | null>(null);
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
if (!projectId) return;
setLoading(true);
try {
const res = moduleType === 'hot_opening_replicate'
? await getAdminHotOpeningTaskDetail(projectId, flowVersion)
: await getAdminShotProjectDetail(projectId, flowVersion);
setDetail(res);
} catch (e: any) {
message.error(e?.message || '加载复刻项目详情失败');
} finally {
setLoading(false);
}
}, [flowVersion, moduleType, projectId]);
useEffect(() => {
load();
}, [load]);
const stepsByCode = useMemo(() => {
const map: Record<string, ReplicationStepOut> = {};
(detail?.steps || []).forEach(step => { map[step.stepCode] = step; });
return map;
}, [detail]);
const moduleValue = detail?.module || moduleType;
const moduleName = getModuleLabel(moduleValue);
const isV2 = detail?.flowVersion === 'v2';
const effectiveStepOrder = isV2 ? V2_STEP_ORDER : V1_STEP_ORDER;
const stepItems = useMemo(() => effectiveStepOrder.map(code => {
const step = stepsByCode[code];
let status: 'wait' | 'process' | 'finish' | 'error' = 'wait';
if (step?.status === 'completed') status = 'finish';
else if (step?.status === 'processing') status = 'process';
else if (step?.status === 'failed') status = 'error';
return {
title: getStepCodeLabel(code),
description: step ? <StatusTag status={step.status} /> : '未创建',
status,
};
}), [effectiveStepOrder, stepsByCode]);
const defaultActiveKeys = useMemo(() => buildDefaultActiveKeys(detail, stepsByCode), [detail, stepsByCode]);
if (loading && !detail) {
return <div style={{ padding: 64, textAlign: 'center' }}><Spin size="large" /></div>;
}
if (!detail) {
return (
<Card style={{ margin: 24 }}>
<Empty description="未找到复刻项目详情" />
</Card>
);
}
const materialStep = stepsByCode.material_input;
const imagePromptStep = stepsByCode.image_prompt_optimize;
const imageGenerateStep = stepsByCode.image_generate;
const videoPromptStep = stepsByCode.video_prompt_optimize;
const videoGenerateStep = stepsByCode.video_generate;
const imageResultUrl = detail.imageGeneration?.resultImageUrl || detail.finalImageUrl;
const videoCoverUrl = detail.videoGeneration?.resultVideoCoverUrl || detail.finalVideoCoverUrl;
const videoResultUrl = detail.videoGeneration?.resultVideoUrl || detail.finalVideoUrl;
const collapseItems = [
{
key: 'material_input',
label: <StepHeader index={1} stepCode="material_input" step={materialStep} current={detail.currentStepCode === 'material_input'} />,
children: (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Descriptions column={2} bordered size="small">
<Descriptions.Item label="模块类型"><StatusTag status={moduleValue} /></Descriptions.Item>
<Descriptions.Item label="素材步骤ID">{detail.material?.materialStepId || '-'}</Descriptions.Item>
<Descriptions.Item label="参考素材项目名">{detail.material?.sourceProjectName || '-'}</Descriptions.Item>
<Descriptions.Item label="生成项目名称">{detail.material?.targetProjectName || '-'}</Descriptions.Item>
<Descriptions.Item label="核心内容点" span={2}>{detail.material?.coreContentPoint || '-'}</Descriptions.Item>
</Descriptions>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: 16 }}>
<Card size="small" title={<Space><PlayCircleOutlined />素材视频</Space>}>
<MediaPreview type="video" url={detail.material?.materialVideoUrl} emptyDescription="暂无素材视频" />
</Card>
<Card size="small" title={<Space><FileImageOutlined />素材图片</Space>}>
<MediaPreview type="image" url={detail.material?.materialImageUrl} emptyDescription="暂无素材图片" />
</Card>
</div>
{renderErrorAlert(materialStep?.errorMessage)}
<StepRawJson step={materialStep} />
</Space>
),
},
{
key: 'image_prompt_optimize',
label: <StepHeader index={2} stepCode="image_prompt_optimize" step={imagePromptStep} current={detail.currentStepCode === 'image_prompt_optimize'} />,
children: (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Descriptions column={2} bordered size="small">
<Descriptions.Item label="提词步骤ID">{detail.imageGeneration?.promptStepId || '-'}</Descriptions.Item>
<Descriptions.Item label="步骤状态"><StatusTag status={imagePromptStep?.status || detail.imageGeneration?.status} /></Descriptions.Item>
<Descriptions.Item label="图片提示词" span={2}>{renderPromptText(detail.imageGeneration?.prompt, '暂无图片提示词')}</Descriptions.Item>
</Descriptions>
{renderErrorAlert(imagePromptStep?.errorMessage || detail.imageGeneration?.errorMessage)}
<StepRawJson step={imagePromptStep} />
</Space>
),
},
{
key: 'image_generate',
label: <StepHeader index={3} stepCode="image_generate" step={imageGenerateStep} current={detail.currentStepCode === 'image_generate'} />,
children: (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Descriptions column={3} bordered size="small">
<Descriptions.Item label="生成步骤ID">{detail.imageGeneration?.generateStepId || '-'}</Descriptions.Item>
<Descriptions.Item label="Chat任务ID">{detail.imageGeneration?.chatTaskId || '-'}</Descriptions.Item>
<Descriptions.Item label="状态"><StatusTag status={detail.imageGeneration?.status || imageGenerateStep?.status} /></Descriptions.Item>
<Descriptions.Item label="图片引擎ID">{detail.imageGeneration?.engineId || '-'}</Descriptions.Item>
<Descriptions.Item label="图片引擎名称">{detail.imageGeneration?.engineName || '-'}</Descriptions.Item>
<Descriptions.Item label="生成参数" span={3}><JsonBlock value={detail.imageGeneration?.params || {}} maxHeight={160} /></Descriptions.Item>
</Descriptions>
{renderErrorAlert(detail.imageGeneration?.errorMessage || imageGenerateStep?.errorMessage)}
<Card size="small" title={<Space><FileImageOutlined />图片结果</Space>}>
<MediaPreview
type="image"
url={imageResultUrl}
height={360}
emptyDescription={imageGenerateStep?.status === 'failed' || detail.imageGeneration?.errorMessage ? '图片生成失败,未返回图片地址' : '暂无图片结果'}
errorDescription="图片资源加载失败,可能图片文件不存在、签名过期或访问权限受限。"
/>
</Card>
<StepRawJson step={imageGenerateStep} />
</Space>
),
},
{
key: 'video_prompt_optimize',
label: <StepHeader index={isV2 ? 2 : 4} stepCode="video_prompt_optimize" step={videoPromptStep} current={detail.currentStepCode === 'video_prompt_optimize'} />,
children: (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Descriptions column={3} bordered size="small">
<Descriptions.Item label="提词步骤ID">{detail.videoGeneration?.promptStepId || '-'}</Descriptions.Item>
<Descriptions.Item label="步骤状态"><StatusTag status={videoPromptStep?.status} /></Descriptions.Item>
<Descriptions.Item label="提词参数" span={3}><JsonBlock value={detail.videoGeneration?.promptParams || {}} maxHeight={160} /></Descriptions.Item>
</Descriptions>
{renderErrorAlert(videoPromptStep?.errorMessage || detail.videoGeneration?.errorMessage)}
<VideoPromptSchemaViewer
schema={detail.videoGeneration?.promptSchema}
finalPrompt={detail.videoGeneration?.finalPrompt}
schemaConfigSnapshot={detail.videoGeneration?.schemaConfigSnapshot}
schemaConfigSource={detail.videoGeneration?.schemaConfigSource}
schemaConfigVersion={detail.videoGeneration?.schemaConfigVersion}
schemaConfigIsFallback={detail.videoGeneration?.schemaConfigIsFallback}
/>
<StepRawJson step={videoPromptStep} />
</Space>
),
},
{
key: 'video_generate',
label: <StepHeader index={isV2 ? 3 : 5} stepCode="video_generate" step={videoGenerateStep} current={detail.currentStepCode === 'video_generate'} />,
children: (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Descriptions column={3} bordered size="small">
<Descriptions.Item label="生成步骤ID">{detail.videoGeneration?.generateStepId || '-'}</Descriptions.Item>
<Descriptions.Item label="Chat任务ID">{detail.videoGeneration?.chatTaskId || '-'}</Descriptions.Item>
<Descriptions.Item label="状态"><StatusTag status={detail.videoGeneration?.status || videoGenerateStep?.status} /></Descriptions.Item>
<Descriptions.Item label="视频引擎ID">{detail.videoGeneration?.engineId || '-'}</Descriptions.Item>
<Descriptions.Item label="视频引擎名称">{detail.videoGeneration?.engineName || '-'}</Descriptions.Item>
<Descriptions.Item label="生成参数" span={3}><JsonBlock value={detail.videoGeneration?.params || {}} maxHeight={160} /></Descriptions.Item>
</Descriptions>
{renderErrorAlert(detail.videoGeneration?.errorMessage || videoGenerateStep?.errorMessage)}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: 16 }}>
<Card size="small" title={<Space><FileImageOutlined />视频封面</Space>}>
<MediaPreview
type="image"
url={videoCoverUrl}
emptyDescription={videoGenerateStep?.status === 'failed' || detail.videoGeneration?.errorMessage ? '视频生成失败,未返回封面' : '暂无视频封面'}
errorDescription="视频封面加载失败,可能文件不存在、签名过期或访问权限受限。"
/>
</Card>
<Card size="small" title={<Space><VideoCameraOutlined />最终视频</Space>}>
<MediaPreview
type="video"
url={videoResultUrl}
emptyDescription={videoGenerateStep?.status === 'failed' || detail.videoGeneration?.errorMessage ? '视频生成失败,未返回视频地址' : '暂无最终视频'}
errorDescription="视频资源加载失败,可能文件不存在、签名过期或访问权限受限。"
/>
</Card>
</div>
<StepRawJson step={videoGenerateStep} />
</Space>
),
},
];
const visibleCollapseItems = isV2
? collapseItems.filter(item => !['image_prompt_optimize', 'image_generate'].includes(String(item.key)))
: collapseItems;
return (
<div style={{ padding: 24 }}>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Space wrap style={{ justifyContent: 'space-between', width: '100%' }}>
<Space wrap>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate(-1)}>返回</Button>
<Typography.Title level={3} style={{ margin: 0 }}>{moduleName}项目详情</Typography.Title>
<StatusTag status={detail.status} />
{detail.module && detail.module !== moduleType ? <Tag color="gold">接口模块:{getModuleLabel(detail.module)}</Tag> : null}
</Space>
<Button onClick={load} loading={loading}>刷新</Button>
</Space>
<Card>
<Descriptions title="基础信息" column={3} bordered size="small">
<Descriptions.Item label="项目ID">{detail.id}</Descriptions.Item>
<Descriptions.Item label="模块类型"><StatusTag status={moduleValue} /></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="当前步骤"><Tooltip title={detail.currentStepCode || ''}>{getStepCodeLabel(detail.currentStepCode)}</Tooltip></Descriptions.Item>
<Descriptions.Item label="流程版本"><Tag color={isV2 ? 'blue' : 'default'}>{String(detail.flowVersion || 'v1').toUpperCase()}</Tag></Descriptions.Item>
<Descriptions.Item label="步骤数量">{detail.stepCount || effectiveStepOrder.length}</Descriptions.Item>
<Descriptions.Item label="状态"><StatusTag status={detail.status} /></Descriptions.Item>
<Descriptions.Item label="创建时间">{safeDate(detail.createdAt)}</Descriptions.Item>
<Descriptions.Item label="更新时间">{safeDate(detail.updatedAt)}</Descriptions.Item>
<Descriptions.Item label="完成时间">{safeDate(detail.completedAt)}</Descriptions.Item>
{detail.errorMessage ? <Descriptions.Item label="错误信息" span={3}><Alert type="error" showIcon message={detail.errorMessage} /></Descriptions.Item> : null}
</Descriptions>
</Card>
<Card title="步骤进度">
<Steps items={stepItems} />
</Card>
<Card title="最终结果预览">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: 16 }}>
{!isV2 ? (
<Card size="small" title="最终图片">
<MediaPreview type="image" url={detail.finalImageUrl || detail.imageGeneration?.resultImageUrl} height={220} emptyDescription="暂无最终图片" />
</Card>
) : null}
<Card size="small" title="最终视频封面">
<MediaPreview type="image" url={detail.finalVideoCoverUrl || detail.videoGeneration?.resultVideoCoverUrl} height={220} emptyDescription="暂无最终视频封面" />
</Card>
<Card size="small" title="最终视频">
<MediaPreview type="video" url={detail.finalVideoUrl || detail.videoGeneration?.resultVideoUrl} height={220} emptyDescription="暂无最终视频" />
</Card>
</div>
</Card>
<Collapse defaultActiveKey={defaultActiveKeys} items={visibleCollapseItems} />
<Collapse
items={[
{
key: 'steps-table',
label: '完整步骤列表',
children: (
<Table
rowKey="id"
size="small"
pagination={false}
dataSource={detail.steps || []}
scroll={{ x: 1100 }}
columns={[
{ title: '序号', dataIndex: 'stepIndex', width: 70 },
{ title: '步骤', dataIndex: 'stepCode', width: 160, render: (v: string) => <Tooltip title={v}>{getStepCodeLabel(v)}</Tooltip> },
{ title: '状态', dataIndex: 'status', width: 120, render: (v: string) => <StatusTag status={v} /> },
{ title: '版本', dataIndex: 'version', width: 70 },
{ title: '当前有效', dataIndex: 'isCurrent', width: 90, render: (v: boolean) => v ? <Tag color="success"></Tag> : <Tag></Tag> },
{ title: 'Chat任务', dataIndex: 'chatTaskId', width: 160, render: (v: string) => <Tooltip title={v || ''}>{shortId(v)}</Tooltip> },
{ title: '创建时间', dataIndex: 'createdAt', width: 170, render: safeDate },
{ title: '完成时间', dataIndex: 'completedAt', width: 170, render: safeDate },
{ title: '错误', dataIndex: 'errorMessage', ellipsis: true, render: (v: string) => v || '-' },
]}
/>
),
},
{
key: 'raw-detail',
label: '完整详情原始 JSON',
children: <JsonBlock value={detail} maxHeight={520} />,
},
]}
/>
</Space>
</div>
);
};
export default AdminReplicationProjectDetail;