409 lines
21 KiB
TypeScript
409 lines
21 KiB
TypeScript
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;
|