拆镜复刻、爆款开头复刻管理后台完成

This commit is contained in:
2026-06-17 15:42:07 +08:00
parent 5d5e6485ee
commit 0d647a7171
31 changed files with 3042 additions and 2777 deletions
@@ -0,0 +1,384 @@
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 } 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 STEP_ORDER = [
'material_input',
'image_prompt_optimize',
'image_generate',
'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 [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)
: await getAdminShotProjectDetail(projectId);
setDetail(res);
} catch (e: any) {
message.error(e?.message || '加载复刻项目详情失败');
} finally {
setLoading(false);
}
}, [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 stepItems = useMemo(() => STEP_ORDER.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,
};
}), [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={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} />
<StepRawJson step={videoPromptStep} />
</Space>
),
},
{
key: 'video_generate',
label: <StepHeader index={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>
),
},
];
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="状态"><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 }}>
<Card size="small" title="最终图片">
<MediaPreview type="image" url={detail.finalImageUrl || detail.imageGeneration?.resultImageUrl} height={220} emptyDescription="暂无最终图片" />
</Card>
<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={collapseItems} />
<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;