拆镜复刻、爆款开头复刻管理后台完成
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import { Collapse, Empty } from 'antd';
|
||||
|
||||
interface JsonBlockProps {
|
||||
value: unknown;
|
||||
maxHeight?: number;
|
||||
}
|
||||
|
||||
export const JsonBlock: React.FC<JsonBlockProps> = ({ value, maxHeight = 420 }) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无 JSON 数据" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
interface JsonCollapseProps {
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
raw?: unknown;
|
||||
inputLabel?: string;
|
||||
outputLabel?: string;
|
||||
rawLabel?: string;
|
||||
}
|
||||
|
||||
const JsonCollapse: React.FC<JsonCollapseProps> = ({
|
||||
input,
|
||||
output,
|
||||
raw,
|
||||
inputLabel = '查看 input_json',
|
||||
outputLabel = '查看 output_json',
|
||||
rawLabel = '查看原始 JSON',
|
||||
}) => {
|
||||
const items = [];
|
||||
if (input !== undefined) items.push({ key: 'input', label: inputLabel, children: <JsonBlock value={input} /> });
|
||||
if (output !== undefined) items.push({ key: 'output', label: outputLabel, children: <JsonBlock value={output} /> });
|
||||
if (raw !== undefined) items.push({ key: 'raw', label: rawLabel, children: <JsonBlock value={raw} /> });
|
||||
|
||||
if (!items.length) return null;
|
||||
return <Collapse size="small" style={{ marginTop: 12 }} items={items} />;
|
||||
};
|
||||
|
||||
export default JsonCollapse;
|
||||
@@ -0,0 +1,126 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Alert, Button, Empty, Image, Space, Typography, message } from 'antd';
|
||||
import { CopyOutlined, LinkOutlined } from '@ant-design/icons';
|
||||
|
||||
const RAW_API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
|
||||
const RESOURCE_BASE = RAW_API_BASE.replace(/\/api\/?$/i, '').replace(/\/$/, '');
|
||||
|
||||
export function resolveResourceUrl(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}`}`;
|
||||
}
|
||||
|
||||
interface MediaPreviewProps {
|
||||
url?: string | null;
|
||||
type: 'image' | 'video';
|
||||
height?: number;
|
||||
emptyDescription?: string;
|
||||
errorDescription?: string;
|
||||
}
|
||||
|
||||
const MediaPreview: React.FC<MediaPreviewProps> = ({
|
||||
url,
|
||||
type,
|
||||
height = 260,
|
||||
emptyDescription = '暂无资源',
|
||||
errorDescription = '资源加载失败,可能文件不存在、签名过期或访问权限受限。',
|
||||
}) => {
|
||||
const resolvedUrl = useMemo(() => resolveResourceUrl(url), [url]);
|
||||
const [loadFailed, setLoadFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setLoadFailed(false);
|
||||
}, [resolvedUrl]);
|
||||
|
||||
const copyUrl = async () => {
|
||||
if (!resolvedUrl) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(resolvedUrl);
|
||||
message.success('资源地址已复制');
|
||||
} catch {
|
||||
message.error('复制失败,请手动复制');
|
||||
}
|
||||
};
|
||||
|
||||
const tools = resolvedUrl ? (
|
||||
<Space wrap style={{ marginTop: 8 }}>
|
||||
<Button size="small" icon={<CopyOutlined />} onClick={copyUrl}>复制地址</Button>
|
||||
<Button size="small" icon={<LinkOutlined />} onClick={() => window.open(resolvedUrl, '_blank', 'noopener,noreferrer')}>新窗口打开</Button>
|
||||
<Typography.Text copyable={{ text: resolvedUrl }} type="secondary" style={{ maxWidth: 460 }} ellipsis>
|
||||
{resolvedUrl}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
) : null;
|
||||
|
||||
if (!resolvedUrl) {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
height,
|
||||
borderRadius: 12,
|
||||
border: '1px dashed #cbd5e1',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#f8fafc',
|
||||
}}
|
||||
>
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={emptyDescription} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{loadFailed ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="资源预览失败"
|
||||
description={errorDescription}
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{type === 'image' ? (
|
||||
<Image
|
||||
src={resolvedUrl}
|
||||
onError={() => setLoadFailed(true)}
|
||||
style={{
|
||||
width: '100%',
|
||||
maxHeight: height,
|
||||
objectFit: 'contain',
|
||||
borderRadius: 12,
|
||||
background: '#f8fafc',
|
||||
border: '1px solid #f1f5f9',
|
||||
}}
|
||||
fallback=""
|
||||
preview={!loadFailed}
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
src={resolvedUrl}
|
||||
controls
|
||||
preload="metadata"
|
||||
onError={() => setLoadFailed(true)}
|
||||
style={{
|
||||
width: '100%',
|
||||
maxHeight: height,
|
||||
borderRadius: 12,
|
||||
background: '#0f172a',
|
||||
border: '1px solid #f1f5f9',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tools}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MediaPreview;
|
||||
@@ -0,0 +1,105 @@
|
||||
import React from 'react';
|
||||
import { Tag, Tooltip } from 'antd';
|
||||
|
||||
interface LabelMeta {
|
||||
text: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, LabelMeta> = {
|
||||
// 通用任务状态
|
||||
pending: { text: '待处理', color: 'default' },
|
||||
waiting_user: { text: '等待用户操作', color: 'processing' },
|
||||
processing: { text: '处理中', color: 'warning' },
|
||||
completed: { text: '已完成', color: 'success' },
|
||||
failed: { text: '失败', color: 'error' },
|
||||
cancelled: { text: '已取消', color: 'default' },
|
||||
canceled: { text: '已取消', color: 'default' },
|
||||
timeout: { text: '已超时', color: 'error' },
|
||||
queued: { text: '已入队', color: 'processing' },
|
||||
preparing: { text: '准备中', color: 'processing' },
|
||||
|
||||
// 生成任务 pipeline / download stage
|
||||
creating_provider_task: { text: '创建远端任务', color: 'processing' },
|
||||
waiting_remote: { text: '等待远端结果', color: 'processing' },
|
||||
polling: { text: '轮询远端结果', color: 'processing' },
|
||||
result_ready: { text: '结果已就绪', color: 'success' },
|
||||
downloading: { text: '下载中', color: 'processing' },
|
||||
done: { text: '已完成', color: 'success' },
|
||||
download_failed: { text: '下载失败', color: 'error' },
|
||||
retry_waiting: { text: '等待重试', color: 'orange' },
|
||||
|
||||
// 拆镜总任务状态
|
||||
pending_analysis: { text: '等待分析', color: 'default' },
|
||||
analyzing: { text: '分析中', color: 'processing' },
|
||||
analysis_completed: { text: '分析完成', color: 'success' },
|
||||
analysis_failed: { text: '分析失败', color: 'error' },
|
||||
splitting: { text: '拆镜中', color: 'warning' },
|
||||
split_completed: { text: '拆镜完成', color: 'success' },
|
||||
partial_failed: { text: '部分失败', color: 'orange' },
|
||||
|
||||
// 拆镜片段状态
|
||||
none: { text: '未拆镜', color: 'default' },
|
||||
not_required: { text: '无需处理', color: 'default' },
|
||||
not_started: { text: '未开始', color: 'default' },
|
||||
project_created: { text: '已创建项目', color: 'processing' },
|
||||
|
||||
// 布尔/兜底类
|
||||
true: { text: '是', color: 'success' },
|
||||
false: { text: '否', color: 'default' },
|
||||
};
|
||||
|
||||
const STEP_LABELS: Record<string, string> = {
|
||||
material_input: '素材输入',
|
||||
image_prompt_optimize: '图片 AI 提词',
|
||||
image_generate: '图片生成',
|
||||
video_prompt_optimize: '视频 AI 提词',
|
||||
video_generate: '视频生成',
|
||||
};
|
||||
|
||||
const MODULE_LABELS: Record<string, string> = {
|
||||
hot_opening_replicate: '爆款开头复刻',
|
||||
shot_replicate: '拆镜复刻',
|
||||
};
|
||||
|
||||
const SOURCE_MODE_LABELS: Record<string, LabelMeta> = {
|
||||
ai_suggestion: { text: 'AI 建议', color: 'purple' },
|
||||
custom: { text: '自定义', color: 'cyan' },
|
||||
original: { text: '原视频', color: 'blue' },
|
||||
};
|
||||
|
||||
export function getModuleLabel(value?: string | null): string {
|
||||
if (!value) return '-';
|
||||
return MODULE_LABELS[value] || value;
|
||||
}
|
||||
|
||||
export function getStepCodeLabel(value?: string | null): string {
|
||||
if (!value) return '-';
|
||||
return STEP_LABELS[value] || value;
|
||||
}
|
||||
|
||||
export function getStatusLabel(value?: string | null): string {
|
||||
if (!value) return '-';
|
||||
return STATUS_LABELS[value]?.text || STEP_LABELS[value] || MODULE_LABELS[value] || SOURCE_MODE_LABELS[value]?.text || value;
|
||||
}
|
||||
|
||||
export function getStatusColor(value?: string | null): string {
|
||||
if (!value) return 'default';
|
||||
return STATUS_LABELS[value]?.color || SOURCE_MODE_LABELS[value]?.color || 'blue';
|
||||
}
|
||||
|
||||
interface StatusTagProps {
|
||||
status?: string | boolean | null;
|
||||
tooltipRaw?: boolean;
|
||||
}
|
||||
|
||||
const StatusTag: React.FC<StatusTagProps> = ({ status, tooltipRaw = true }) => {
|
||||
if (status === undefined || status === null || status === '') return <Tag>-</Tag>;
|
||||
const raw = String(status);
|
||||
const label = getStatusLabel(raw);
|
||||
const tag = <Tag color={getStatusColor(raw)}>{label}</Tag>;
|
||||
if (!tooltipRaw || label === raw) return tag;
|
||||
return <Tooltip title={raw}>{tag}</Tooltip>;
|
||||
};
|
||||
|
||||
export default StatusTag;
|
||||
@@ -0,0 +1,314 @@
|
||||
import React from 'react';
|
||||
import { Card, Collapse, Descriptions, Empty, Space, Table, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { JsonBlock } from './JsonCollapse';
|
||||
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
schema_version: 'Schema 版本',
|
||||
generation_type: '生成类型',
|
||||
business_type: '业务类型',
|
||||
usage: '用途',
|
||||
product_type: '产品类型',
|
||||
target_audience: '目标受众',
|
||||
style: '风格',
|
||||
aspect_ratio: '画面比例',
|
||||
resolution: '分辨率',
|
||||
duration: '时长',
|
||||
fps: '帧率',
|
||||
final_prompt: '最终提示词',
|
||||
prompt: '提示词',
|
||||
description: '说明',
|
||||
content: '内容',
|
||||
time: '时间',
|
||||
time_range: '时间段',
|
||||
start_time: '开始时间',
|
||||
end_time: '结束时间',
|
||||
stage: '阶段',
|
||||
scene: '场景',
|
||||
action: '动作',
|
||||
camera: '镜头',
|
||||
shot: '镜头',
|
||||
lens: '镜头',
|
||||
subtitle: '字幕',
|
||||
voiceover: '口播',
|
||||
audio: '音频',
|
||||
rhythm: '节奏',
|
||||
notes: '备注',
|
||||
};
|
||||
|
||||
const SECTION_LABELS: Record<string, string> = {
|
||||
basic: '基础信息',
|
||||
basic_info: '基础信息',
|
||||
material: '素材理解',
|
||||
material_understanding: '素材理解',
|
||||
business: '业务属性',
|
||||
business_info: '业务属性',
|
||||
visual: '画面与风格',
|
||||
visual_style: '画面与风格',
|
||||
time_plan: '时间规划',
|
||||
timeline: '时间规划',
|
||||
scene_timeline: '场景时间线',
|
||||
action_flow: '动作流程',
|
||||
motion_flow: '动作流程',
|
||||
character_action_flow: '角色动作流程',
|
||||
camera_flow: '镜头流程',
|
||||
shot_flow: '镜头流程',
|
||||
lens_flow: '镜头流程',
|
||||
subtitle: '字幕',
|
||||
subtitles: '字幕',
|
||||
voiceover: '口播',
|
||||
audio: '音频',
|
||||
rhythm: '节奏',
|
||||
compliance: '合规控制',
|
||||
final_prompt: '最终提示词',
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
const labelOf = (key: string): string => SECTION_LABELS[key] || FIELD_LABELS[key] || key;
|
||||
|
||||
const isLongText = (value: unknown): boolean => typeof value === 'string' && value.length > 80;
|
||||
|
||||
|
||||
const EMPTY_TEXTS = new Set(['', '无', 'null', 'None', 'none', '未提及', '不适用']);
|
||||
const PLACEHOLDER_FLOW_TEXTS = new Set([
|
||||
'展示主体动作、核心卖点或主要视觉内容',
|
||||
'展示主要动作、核心卖点或主要视觉内容',
|
||||
'展示核心卖点或主要视觉内容',
|
||||
'展示主体动作',
|
||||
'无',
|
||||
]);
|
||||
const ACTION_CONTENT_KEYS = ['动作内容', '动作', '动作说明', '内容', '说明', '主体动作', '动作变化'];
|
||||
const CAMERA_CONTENT_KEYS = ['镜头内容', '镜头', '镜头说明', '运镜', '运镜说明', '内容', '说明'];
|
||||
|
||||
const toText = (value: unknown): string => {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (typeof value === 'string') return value.trim();
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
};
|
||||
|
||||
const isEmptyText = (value: unknown): boolean => EMPTY_TEXTS.has(toText(value));
|
||||
|
||||
const isActionFlowSection = (sectionKey: string): boolean => {
|
||||
const lower = sectionKey.toLowerCase();
|
||||
return lower.includes('action') || lower.includes('motion') || lower.includes('动作');
|
||||
};
|
||||
|
||||
const isCameraFlowSection = (sectionKey: string): boolean => {
|
||||
const lower = sectionKey.toLowerCase();
|
||||
return lower.includes('camera') || lower.includes('shot') || lower.includes('lens') || lower.includes('镜头');
|
||||
};
|
||||
|
||||
const isTimePlanSection = (sectionKey: string): boolean => {
|
||||
const lower = sectionKey.toLowerCase();
|
||||
return lower.includes('time') || lower.includes('timeline') || lower.includes('时间规划') || lower.includes('动态时间规划');
|
||||
};
|
||||
|
||||
const pickFirstContent = (item: Record<string, unknown>, keys: string[]): string => {
|
||||
for (const key of keys) {
|
||||
if (key in item && !isEmptyText(item[key])) return toText(item[key]);
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const normalizeFlowContent = (baseContent: string, extras: string[]): string => {
|
||||
const cleanBase = baseContent.trim();
|
||||
const uniqueExtras = extras.filter((item, index) => item && extras.indexOf(item) === index);
|
||||
if (uniqueExtras.length && (!cleanBase || PLACEHOLDER_FLOW_TEXTS.has(cleanBase))) {
|
||||
return uniqueExtras.join(';');
|
||||
}
|
||||
const parts = cleanBase && !EMPTY_TEXTS.has(cleanBase) ? [cleanBase] : [];
|
||||
uniqueExtras.forEach((item) => {
|
||||
if (item && !parts.includes(item)) parts.push(item);
|
||||
});
|
||||
return parts.join(';') || '-';
|
||||
};
|
||||
|
||||
const normalizeFlowItemForDisplay = (
|
||||
item: Record<string, unknown>,
|
||||
contentKey: '动作内容' | '镜头内容',
|
||||
contentKeys: string[],
|
||||
): Record<string, unknown> => {
|
||||
const allowed = new Set(['时间段', contentKey, ...contentKeys]);
|
||||
const extras: string[] = [];
|
||||
Object.entries(item).forEach(([field, value]) => {
|
||||
if (allowed.has(field)) return;
|
||||
const fieldText = toText(field);
|
||||
const valueText = toText(value);
|
||||
if (fieldText && !EMPTY_TEXTS.has(fieldText)) extras.push(fieldText);
|
||||
if (valueText && !EMPTY_TEXTS.has(valueText) && valueText !== fieldText) extras.push(valueText);
|
||||
});
|
||||
return {
|
||||
时间段: toText(item['时间段']) || '-',
|
||||
[contentKey]: normalizeFlowContent(pickFirstContent(item, contentKeys), extras),
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeRecordForSection = (sectionKey: string, item: Record<string, unknown>): Record<string, unknown> => {
|
||||
if (isActionFlowSection(sectionKey)) {
|
||||
return normalizeFlowItemForDisplay(item, '动作内容', ACTION_CONTENT_KEYS);
|
||||
}
|
||||
if (isCameraFlowSection(sectionKey)) {
|
||||
return normalizeFlowItemForDisplay(item, '镜头内容', CAMERA_CONTENT_KEYS);
|
||||
}
|
||||
if (isTimePlanSection(sectionKey)) {
|
||||
return {
|
||||
时间段: toText(item['时间段']) || '-',
|
||||
阶段: toText(item['阶段']) || '-',
|
||||
说明: toText(item['说明']) || '-',
|
||||
};
|
||||
}
|
||||
return item;
|
||||
};
|
||||
|
||||
const renderValue = (value: unknown): React.ReactNode => {
|
||||
if (value === null || value === undefined || value === '') return <Typography.Text type="secondary">-</Typography.Text>;
|
||||
if (typeof value === 'boolean') return value ? '是' : '否';
|
||||
if (typeof value === 'number') return value;
|
||||
if (typeof value === 'string') {
|
||||
return <Typography.Paragraph style={{ marginBottom: 0, whiteSpace: 'pre-wrap' }}>{value}</Typography.Paragraph>;
|
||||
}
|
||||
return <JsonBlock value={value} maxHeight={220} />;
|
||||
};
|
||||
|
||||
const getArrayMode = (key: string): 'card' | 'table' => {
|
||||
const lower = key.toLowerCase();
|
||||
if (
|
||||
lower.includes('action') ||
|
||||
lower.includes('motion') ||
|
||||
lower.includes('camera') ||
|
||||
lower.includes('shot') ||
|
||||
lower.includes('lens') ||
|
||||
lower.includes('flow') ||
|
||||
lower.includes('动作') ||
|
||||
lower.includes('镜头')
|
||||
) {
|
||||
return 'card';
|
||||
}
|
||||
return 'table';
|
||||
};
|
||||
|
||||
const renderCardArray = (sectionKey: string, items: Record<string, unknown>[]): React.ReactNode => (
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
{items.map((item, index) => {
|
||||
const displayItem = normalizeRecordForSection(sectionKey, item);
|
||||
return (
|
||||
<Card
|
||||
key={`${sectionKey}-${index}`}
|
||||
size="small"
|
||||
title={`${labelOf(sectionKey)} ${index + 1}`}
|
||||
styles={{ body: { padding: 12 } }}
|
||||
>
|
||||
<Descriptions size="small" column={1} bordered>
|
||||
{Object.entries(displayItem).map(([field, value]) => (
|
||||
<Descriptions.Item key={field} label={labelOf(field)}>
|
||||
{renderValue(value)}
|
||||
</Descriptions.Item>
|
||||
))}
|
||||
</Descriptions>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
);
|
||||
|
||||
const renderTableArray = (sectionKey: string, items: Record<string, unknown>[]): React.ReactNode => {
|
||||
const displayItems = items.map(item => normalizeRecordForSection(sectionKey, item));
|
||||
const fields = Array.from(new Set(displayItems.flatMap(item => Object.keys(item))));
|
||||
const columns: ColumnsType<Record<string, unknown>> = fields.map(field => ({
|
||||
title: labelOf(field),
|
||||
dataIndex: field,
|
||||
key: field,
|
||||
width: isLongText(displayItems.find(item => item[field])?.[field]) ? 280 : 160,
|
||||
render: (value: unknown) => renderValue(value),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Table
|
||||
size="small"
|
||||
rowKey={(_, index) => `${sectionKey}-${index}`}
|
||||
columns={columns}
|
||||
dataSource={displayItems}
|
||||
pagination={false}
|
||||
scroll={{ x: Math.max(900, fields.length * 180) }}
|
||||
tableLayout="fixed"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderArray = (sectionKey: string, value: unknown[]): React.ReactNode => {
|
||||
if (!value.length) return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无数据" />;
|
||||
if (value.every(isRecord)) {
|
||||
return getArrayMode(sectionKey) === 'card'
|
||||
? renderCardArray(sectionKey, value)
|
||||
: renderTableArray(sectionKey, value);
|
||||
}
|
||||
return <JsonBlock value={value} maxHeight={260} />;
|
||||
};
|
||||
|
||||
const renderRecord = (value: Record<string, unknown>): React.ReactNode => (
|
||||
<Descriptions size="small" column={2} bordered>
|
||||
{Object.entries(value).map(([field, fieldValue]) => (
|
||||
<Descriptions.Item key={field} label={labelOf(field)} span={Array.isArray(fieldValue) || isRecord(fieldValue) || isLongText(fieldValue) ? 2 : 1}>
|
||||
{Array.isArray(fieldValue)
|
||||
? renderArray(field, fieldValue)
|
||||
: isRecord(fieldValue)
|
||||
? renderRecord(fieldValue)
|
||||
: renderValue(fieldValue)}
|
||||
</Descriptions.Item>
|
||||
))}
|
||||
</Descriptions>
|
||||
);
|
||||
|
||||
const renderSection = (key: string, value: unknown): React.ReactNode => {
|
||||
if (Array.isArray(value)) return renderArray(key, value);
|
||||
if (isRecord(value)) return renderRecord(value);
|
||||
return renderValue(value);
|
||||
};
|
||||
|
||||
interface VideoPromptSchemaViewerProps {
|
||||
schema?: Record<string, any> | null;
|
||||
finalPrompt?: string | null;
|
||||
}
|
||||
|
||||
const VideoPromptSchemaViewer: React.FC<VideoPromptSchemaViewerProps> = ({ schema, finalPrompt }) => {
|
||||
const hasSchema = !!schema && Object.keys(schema).length > 0;
|
||||
if (!hasSchema && !finalPrompt) {
|
||||
return <Empty description="暂无视频提词 schema" />;
|
||||
}
|
||||
|
||||
const schemaItems = hasSchema
|
||||
? Object.entries(schema || {}).map(([key, value]) => ({
|
||||
key,
|
||||
label: labelOf(key),
|
||||
children: renderSection(key, value),
|
||||
}))
|
||||
: [];
|
||||
|
||||
const defaultKeys = finalPrompt ? ['final_prompt'] : [];
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
{finalPrompt ? (
|
||||
<Card size="small" title="最终视频提示词">
|
||||
<Typography.Paragraph style={{ whiteSpace: 'pre-wrap', marginBottom: 0 }}>{finalPrompt}</Typography.Paragraph>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{schemaItems.length ? (
|
||||
<Collapse
|
||||
size="small"
|
||||
defaultActiveKey={defaultKeys}
|
||||
items={schemaItems}
|
||||
/>
|
||||
) : null}
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoPromptSchemaViewer;
|
||||
Reference in New Issue
Block a user