拆镜复刻、爆款开头复刻管理后台完成
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user