708 lines
32 KiB
TypeScript
708 lines
32 KiB
TypeScript
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
import {
|
|
Alert,
|
|
Button,
|
|
Card,
|
|
Col,
|
|
Collapse,
|
|
Divider,
|
|
Form,
|
|
Input,
|
|
InputNumber,
|
|
Modal,
|
|
Popconfirm,
|
|
Row,
|
|
Select,
|
|
Space,
|
|
Spin,
|
|
Switch,
|
|
Tabs,
|
|
Tag,
|
|
Typography,
|
|
message,
|
|
} from 'antd';
|
|
import {
|
|
DeleteOutlined,
|
|
DownloadOutlined,
|
|
EyeOutlined,
|
|
PlusOutlined,
|
|
ReloadOutlined,
|
|
SaveOutlined,
|
|
UploadOutlined,
|
|
} from '@ant-design/icons';
|
|
import {
|
|
exportVideoPromptSchemaConfig,
|
|
getVideoPromptSchemaConfig,
|
|
importVideoPromptSchemaConfig,
|
|
previewVideoPromptSchemaConfig,
|
|
resetVideoPromptSchemaConfig,
|
|
saveVideoPromptSchemaConfig,
|
|
} from '../api';
|
|
import type {
|
|
VideoPromptFlowItemField,
|
|
VideoPromptSchemaConfigData,
|
|
VideoPromptSchemaNode,
|
|
VideoPromptSchemaNodeType,
|
|
VideoPromptTimePlanRule,
|
|
} from '../types';
|
|
|
|
const { Title, Text, Paragraph } = Typography;
|
|
const { TextArea } = Input;
|
|
|
|
const NODE_TYPES: { label: string; value: VideoPromptSchemaNodeType }[] = [
|
|
{ label: '对象', value: 'object' },
|
|
{ label: '数组', value: 'array' },
|
|
{ label: '字符串', value: 'string' },
|
|
{ label: '数字', value: 'number' },
|
|
{ label: '布尔', value: 'boolean' },
|
|
{ label: '流程数组', value: 'flow' },
|
|
];
|
|
|
|
function clone<T>(value: T): T {
|
|
return JSON.parse(JSON.stringify(value ?? null));
|
|
}
|
|
|
|
function pick<T = any>(obj: any, camelKey: string, snakeKey: string, fallback?: T): T {
|
|
if (!obj || typeof obj !== 'object') return fallback as T;
|
|
if (obj[camelKey] !== undefined) return obj[camelKey] as T;
|
|
if (obj[snakeKey] !== undefined) return obj[snakeKey] as T;
|
|
return fallback as T;
|
|
}
|
|
|
|
function normalizeFlowField(field: any): VideoPromptFlowItemField {
|
|
return {
|
|
key: String(field?.key || field?.label || '新流程字段'),
|
|
label: String(field?.label || field?.key || '新流程字段'),
|
|
enabled: field?.enabled ?? true,
|
|
editable: field?.editable ?? true,
|
|
maxLength: Number(pick(field, 'maxLength', 'max_length', 800) || 800),
|
|
value: field?.value ?? '',
|
|
};
|
|
}
|
|
|
|
function normalizeNode(node: any): VideoPromptSchemaNode {
|
|
const type = String(node?.type || 'string') as VideoPromptSchemaNodeType;
|
|
const normalized: VideoPromptSchemaNode = {
|
|
key: String(node?.key || node?.label || '新字段'),
|
|
label: String(node?.label || node?.key || '新字段'),
|
|
type,
|
|
enabled: node?.enabled ?? true,
|
|
editable: node?.editable ?? true,
|
|
maxLength: Number(pick(node, 'maxLength', 'max_length', type === 'flow' ? 800 : 1000) || (type === 'flow' ? 800 : 1000)),
|
|
};
|
|
|
|
if (type === 'object') {
|
|
normalized.children = Array.isArray(node?.children) ? node.children.map(normalizeNode) : [];
|
|
} else if (type === 'flow') {
|
|
normalized.contentKey = String(pick(node, 'contentKey', 'content_key', node?.key === '镜头流程' ? '镜头内容' : '动作内容'));
|
|
const aliases = pick<any[]>(node, 'contentAliases', 'content_aliases', []);
|
|
normalized.contentAliases = Array.isArray(aliases) ? aliases.map(item => String(item)).filter(Boolean) : [];
|
|
const itemFields = pick<any[]>(node, 'itemFields', 'item_fields', []);
|
|
normalized.itemFields = Array.isArray(itemFields) && itemFields.length > 0
|
|
? itemFields.map(normalizeFlowField)
|
|
: [{ key: normalized.contentKey, label: normalized.contentKey, enabled: true, editable: true, maxLength: normalized.maxLength || 800, value: '' }];
|
|
} else {
|
|
normalized.value = node?.value ?? (type === 'array' ? [] : type === 'boolean' ? false : type === 'number' ? 0 : '无');
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
function normalizeTimeRule(rule: any): VideoPromptTimePlanRule {
|
|
const rawSegments = Array.isArray(rule?.segments) ? rule.segments : [];
|
|
const rawRatios = Array.isArray(rule?.ratios) ? rule.ratios : [];
|
|
return {
|
|
minDuration: Number(pick(rule, 'minDuration', 'min_duration', 1) || 1),
|
|
maxDuration: Number(pick(rule, 'maxDuration', 'max_duration', 5) || 5),
|
|
ratios: rawRatios.map((item: any) => Number(item || 0)),
|
|
segments: rawSegments.map((segment: any) => ({
|
|
stage: String(segment?.stage || '新阶段'),
|
|
description: String(segment?.description || segment?.desc || '阶段说明'),
|
|
})),
|
|
};
|
|
}
|
|
|
|
function normalizeConfig(data: any): VideoPromptSchemaConfigData {
|
|
const rawSections = Array.isArray(data?.sections) ? data.sections : [];
|
|
const rawRules = Array.isArray(data?.timePlanRules)
|
|
? data.timePlanRules
|
|
: Array.isArray(data?.time_plan_rules)
|
|
? data.time_plan_rules
|
|
: [];
|
|
return {
|
|
version: data?.version || 'video_prompt_schema_config_v1',
|
|
enabled: data?.enabled ?? true,
|
|
sections: rawSections.map(normalizeNode),
|
|
timePlanRules: rawRules.map(normalizeTimeRule),
|
|
};
|
|
}
|
|
|
|
function toApiNode(node: VideoPromptSchemaNode): any {
|
|
const base: any = {
|
|
key: node.key,
|
|
label: node.label,
|
|
type: node.type,
|
|
enabled: !!node.enabled,
|
|
editable: !!node.editable,
|
|
max_length: node.maxLength,
|
|
};
|
|
if (node.type === 'object') {
|
|
base.children = (node.children || []).map(toApiNode);
|
|
} else if (node.type === 'flow') {
|
|
base.content_key = node.contentKey;
|
|
base.content_aliases = node.contentAliases || [];
|
|
base.item_fields = (node.itemFields || []).map(field => ({
|
|
key: field.key,
|
|
label: field.label,
|
|
enabled: !!field.enabled,
|
|
editable: !!field.editable,
|
|
max_length: field.maxLength,
|
|
value: field.value ?? '',
|
|
}));
|
|
} else {
|
|
base.value = node.value;
|
|
}
|
|
return base;
|
|
}
|
|
|
|
function toApiConfig(config: VideoPromptSchemaConfigData, enabled: boolean): any {
|
|
return {
|
|
version: config.version,
|
|
enabled,
|
|
sections: (config.sections || []).map(toApiNode),
|
|
time_plan_rules: (config.timePlanRules || []).map(rule => ({
|
|
min_duration: rule.minDuration,
|
|
max_duration: rule.maxDuration,
|
|
ratios: rule.ratios || [],
|
|
segments: (rule.segments || []).map(segment => ({
|
|
stage: segment.stage,
|
|
description: segment.description,
|
|
})),
|
|
})),
|
|
};
|
|
}
|
|
|
|
function runtimePreviewSchema(result: any): any {
|
|
return result?.runtimeSchema || result?.runtime_schema || {};
|
|
}
|
|
|
|
function runtimePreviewTimePlan(result: any): any[] {
|
|
const value = result?.timePlan || result?.time_plan || runtimePreviewSchema(result)?.动态时间规划 || [];
|
|
return Array.isArray(value) ? value : [];
|
|
}
|
|
|
|
function runtimePreviewSnapshot(result: any): any {
|
|
return result?.schemaConfigSnapshot || result?.schema_config_snapshot || {};
|
|
}
|
|
|
|
function downloadJson(filename: string, data: any): void {
|
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json;charset=utf-8' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
const createNode = (type: VideoPromptSchemaNodeType = 'string'): VideoPromptSchemaNode => ({
|
|
key: '新字段',
|
|
label: '新字段',
|
|
type,
|
|
enabled: true,
|
|
editable: true,
|
|
maxLength: type === 'flow' ? 800 : 1000,
|
|
value: type === 'array' ? [] : type === 'boolean' ? false : type === 'number' ? 0 : '无',
|
|
children: type === 'object' ? [] : undefined,
|
|
contentKey: type === 'flow' ? '动作内容' : undefined,
|
|
contentAliases: type === 'flow' ? ['动作内容', '动作', '动作说明', '内容', '说明'] : undefined,
|
|
itemFields: type === 'flow' ? [{ key: '动作内容', label: '动作内容', enabled: true, editable: true, maxLength: 800, value: '' }] : undefined,
|
|
});
|
|
|
|
const createTimeRule = (): VideoPromptTimePlanRule => ({
|
|
minDuration: 1,
|
|
maxDuration: 5,
|
|
ratios: [0.2, 0.4, 0.4],
|
|
segments: [
|
|
{ stage: '开场吸引', description: '快速吸引注意力' },
|
|
{ stage: '核心展示', description: '展示主体动作、核心卖点或主要视觉内容' },
|
|
{ stage: '收尾引导', description: '给出行动引导并稳定落版' },
|
|
],
|
|
});
|
|
|
|
const AdminVideoPromptSchemaConfig: React.FC = () => {
|
|
const [loading, setLoading] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [enabled, setEnabled] = useState(true);
|
|
const [usingDefault, setUsingDefault] = useState(false);
|
|
const [config, setConfig] = useState<VideoPromptSchemaConfigData | null>(null);
|
|
const [defaultConfig, setDefaultConfig] = useState<VideoPromptSchemaConfigData | null>(null);
|
|
const [previewOpen, setPreviewOpen] = useState(false);
|
|
const [previewResult, setPreviewResult] = useState<any>(null);
|
|
const [previewForm] = Form.useForm();
|
|
const importInputRef = useRef<HTMLInputElement | null>(null);
|
|
|
|
const sections = config?.sections || [];
|
|
const timePlanRules = config?.timePlanRules || [];
|
|
|
|
const load = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const res = await getVideoPromptSchemaConfig();
|
|
setEnabled(Boolean((res as any).isEnabled ?? (res as any).is_enabled ?? res.data?.enabled ?? false));
|
|
setUsingDefault(Boolean((res as any).usingDefault ?? (res as any).using_default ?? false));
|
|
setConfig(normalizeConfig(res.data));
|
|
setDefaultConfig(normalizeConfig((res as any).defaultData ?? (res as any).default_data));
|
|
} catch (error: any) {
|
|
message.error(error?.message || '加载失败');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
load();
|
|
}, []);
|
|
|
|
const updateConfig = (updater: (draft: VideoPromptSchemaConfigData) => void) => {
|
|
setConfig(prev => {
|
|
const next = normalizeConfig(clone(prev));
|
|
updater(next);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const save = async () => {
|
|
if (!config) return;
|
|
setSaving(true);
|
|
try {
|
|
const res = await saveVideoPromptSchemaConfig({ is_enabled: enabled, data: toApiConfig(config, enabled) });
|
|
setConfig(normalizeConfig(res.data));
|
|
setEnabled(Boolean((res as any).isEnabled ?? (res as any).is_enabled ?? res.data?.enabled ?? false));
|
|
setUsingDefault(Boolean((res as any).usingDefault ?? (res as any).using_default ?? false));
|
|
message.success('保存成功');
|
|
} catch (error: any) {
|
|
message.error(error?.message || '保存失败');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const resetDefault = async () => {
|
|
setSaving(true);
|
|
try {
|
|
const res = await resetVideoPromptSchemaConfig();
|
|
setConfig(normalizeConfig(res.data));
|
|
setDefaultConfig(normalizeConfig((res as any).defaultData ?? (res as any).default_data));
|
|
setEnabled(Boolean((res as any).isEnabled ?? (res as any).is_enabled ?? res.data?.enabled ?? false));
|
|
setUsingDefault(Boolean((res as any).usingDefault ?? (res as any).using_default ?? false));
|
|
message.success('已恢复默认配置');
|
|
} catch (error: any) {
|
|
message.error(error?.message || '恢复默认失败');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const doExport = async () => {
|
|
try {
|
|
const res = await exportVideoPromptSchemaConfig();
|
|
downloadJson('video_prompt_schema_config.json', res);
|
|
} catch (error: any) {
|
|
message.error(error?.message || '导出失败');
|
|
}
|
|
};
|
|
|
|
const onImportFile = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = event.target.files?.[0];
|
|
event.target.value = '';
|
|
if (!file) return;
|
|
try {
|
|
const text = await file.text();
|
|
const json = JSON.parse(text);
|
|
const data = json.data || json;
|
|
const isEnabled = json.isEnabled ?? json.is_enabled ?? enabled;
|
|
const res = await importVideoPromptSchemaConfig({ is_enabled: !!isEnabled, data });
|
|
setConfig(normalizeConfig(res.data));
|
|
setEnabled(Boolean((res as any).isEnabled ?? (res as any).is_enabled ?? res.data?.enabled ?? false));
|
|
setUsingDefault(Boolean((res as any).usingDefault ?? (res as any).using_default ?? false));
|
|
message.success('导入成功');
|
|
} catch (error: any) {
|
|
message.error(error?.message || '导入失败,请检查 JSON 格式');
|
|
}
|
|
};
|
|
|
|
const openPreview = () => {
|
|
previewForm.setFieldsValue({ duration: 8, aspectRatio: '9:16', resolution: '1080p', frameRate: '30fps' });
|
|
setPreviewResult(null);
|
|
setPreviewOpen(true);
|
|
};
|
|
|
|
const doPreview = async () => {
|
|
if (!config) return;
|
|
const values = await previewForm.validateFields();
|
|
try {
|
|
const res = await previewVideoPromptSchemaConfig({
|
|
is_enabled: enabled,
|
|
data: toApiConfig(config, enabled),
|
|
duration: Number(values.duration || 8),
|
|
aspect_ratio: values.aspectRatio,
|
|
resolution: values.resolution,
|
|
frame_rate: values.frameRate,
|
|
});
|
|
setPreviewResult(res);
|
|
} catch (error: any) {
|
|
message.error(error?.message || '预览失败');
|
|
}
|
|
};
|
|
|
|
const addSection = () => {
|
|
updateConfig(draft => {
|
|
draft.sections.push(createNode('object'));
|
|
});
|
|
};
|
|
|
|
const updateSection = (index: number, patch: Partial<VideoPromptSchemaNode>) => {
|
|
updateConfig(draft => {
|
|
draft.sections[index] = { ...draft.sections[index], ...patch };
|
|
if (patch.type === 'object') {
|
|
draft.sections[index].children = draft.sections[index].children || [];
|
|
}
|
|
if (patch.type === 'flow') {
|
|
draft.sections[index].contentKey = draft.sections[index].contentKey || '动作内容';
|
|
draft.sections[index].contentAliases = draft.sections[index].contentAliases || ['动作内容', '动作', '动作说明', '内容', '说明'];
|
|
draft.sections[index].itemFields = draft.sections[index].itemFields || [{ key: '动作内容', label: '动作内容', enabled: true, editable: true, maxLength: 800, value: '' }];
|
|
}
|
|
});
|
|
};
|
|
|
|
const deleteSection = (index: number) => {
|
|
updateConfig(draft => {
|
|
draft.sections.splice(index, 1);
|
|
});
|
|
};
|
|
|
|
const addChild = (sectionIndex: number) => {
|
|
updateConfig(draft => {
|
|
const section = draft.sections[sectionIndex];
|
|
section.children = section.children || [];
|
|
section.children.push(createNode('string'));
|
|
});
|
|
};
|
|
|
|
const updateChild = (sectionIndex: number, childIndex: number, patch: Partial<VideoPromptSchemaNode>) => {
|
|
updateConfig(draft => {
|
|
const children = draft.sections[sectionIndex].children || [];
|
|
children[childIndex] = { ...children[childIndex], ...patch };
|
|
draft.sections[sectionIndex].children = children;
|
|
});
|
|
};
|
|
|
|
const deleteChild = (sectionIndex: number, childIndex: number) => {
|
|
updateConfig(draft => {
|
|
draft.sections[sectionIndex].children?.splice(childIndex, 1);
|
|
});
|
|
};
|
|
|
|
const addFlowField = (sectionIndex: number) => {
|
|
updateConfig(draft => {
|
|
const section = draft.sections[sectionIndex];
|
|
section.itemFields = section.itemFields || [];
|
|
section.itemFields.push({ key: '新流程字段', label: '新流程字段', enabled: true, editable: true, maxLength: 800, value: '' });
|
|
});
|
|
};
|
|
|
|
const updateFlowField = (sectionIndex: number, fieldIndex: number, patch: Partial<VideoPromptFlowItemField>) => {
|
|
updateConfig(draft => {
|
|
const fields = draft.sections[sectionIndex].itemFields || [];
|
|
fields[fieldIndex] = { ...fields[fieldIndex], ...patch };
|
|
draft.sections[sectionIndex].itemFields = fields;
|
|
});
|
|
};
|
|
|
|
const deleteFlowField = (sectionIndex: number, fieldIndex: number) => {
|
|
updateConfig(draft => {
|
|
draft.sections[sectionIndex].itemFields?.splice(fieldIndex, 1);
|
|
});
|
|
};
|
|
|
|
const addTimeRule = () => {
|
|
updateConfig(draft => {
|
|
draft.timePlanRules.push(createTimeRule());
|
|
});
|
|
};
|
|
|
|
const updateTimeRule = (index: number, patch: Partial<VideoPromptTimePlanRule>) => {
|
|
updateConfig(draft => {
|
|
draft.timePlanRules[index] = { ...draft.timePlanRules[index], ...patch };
|
|
});
|
|
};
|
|
|
|
const deleteTimeRule = (index: number) => {
|
|
updateConfig(draft => {
|
|
draft.timePlanRules.splice(index, 1);
|
|
});
|
|
};
|
|
|
|
const updateSegment = (ruleIndex: number, segmentIndex: number, patch: Partial<{ stage: string; description: string; ratio: number }>) => {
|
|
updateConfig(draft => {
|
|
const rule = draft.timePlanRules[ruleIndex];
|
|
if (patch.ratio !== undefined) rule.ratios[segmentIndex] = Number(patch.ratio || 0);
|
|
if (patch.stage !== undefined) rule.segments[segmentIndex].stage = patch.stage;
|
|
if (patch.description !== undefined) rule.segments[segmentIndex].description = patch.description;
|
|
});
|
|
};
|
|
|
|
const addSegment = (ruleIndex: number) => {
|
|
updateConfig(draft => {
|
|
const rule = draft.timePlanRules[ruleIndex];
|
|
rule.ratios.push(1);
|
|
rule.segments.push({ stage: '新阶段', description: '阶段说明' });
|
|
});
|
|
};
|
|
|
|
const deleteSegment = (ruleIndex: number, segmentIndex: number) => {
|
|
updateConfig(draft => {
|
|
const rule = draft.timePlanRules[ruleIndex];
|
|
rule.ratios.splice(segmentIndex, 1);
|
|
rule.segments.splice(segmentIndex, 1);
|
|
});
|
|
};
|
|
|
|
const sectionItems = useMemo(() => sections.map((section, sectionIndex) => ({
|
|
key: `${section.key}-${sectionIndex}`,
|
|
label: (
|
|
<Space>
|
|
<Text strong>{section.label || section.key || '未命名分组'}</Text>
|
|
{section.key && section.key !== section.label && <Tag color="blue">key: {section.key}</Tag>}
|
|
<Tag>{section.type}</Tag>
|
|
{!section.enabled && <Tag color="default">已禁用</Tag>}
|
|
</Space>
|
|
),
|
|
children: (
|
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
|
<Row gutter={12} align="middle">
|
|
<Col span={4}><Input addonBefore="key" value={section.key} maxLength={64} placeholder="传给 AI 的字段名" onChange={e => updateSection(sectionIndex, { key: e.target.value })} /></Col>
|
|
<Col span={4}><Input addonBefore="label" value={section.label} maxLength={64} placeholder="后台显示名称" onChange={e => updateSection(sectionIndex, { label: e.target.value })} /></Col>
|
|
<Col span={4}>
|
|
<Select value={section.type as any} options={NODE_TYPES} style={{ width: '100%' }} onChange={value => updateSection(sectionIndex, { type: value })} />
|
|
</Col>
|
|
<Col span={3}><Space>启用<Switch checked={section.enabled} onChange={checked => updateSection(sectionIndex, { enabled: checked })} /></Space></Col>
|
|
<Col span={3}><Space>可编辑<Switch checked={section.editable} onChange={checked => updateSection(sectionIndex, { editable: checked })} /></Space></Col>
|
|
<Col span={4}><InputNumber min={1} max={20000} value={section.maxLength} addonBefore="长度" style={{ width: '100%' }} onChange={value => updateSection(sectionIndex, { maxLength: Number(value || 1000) })} /></Col>
|
|
<Col span={2} style={{ textAlign: 'right' }}>
|
|
<Popconfirm title="确认删除该分组?" onConfirm={() => deleteSection(sectionIndex)}>
|
|
<Button danger icon={<DeleteOutlined />} />
|
|
</Popconfirm>
|
|
</Col>
|
|
</Row>
|
|
|
|
{section.type === 'object' && (
|
|
<Card size="small" title="对象字段" extra={<Button size="small" icon={<PlusOutlined />} onClick={() => addChild(sectionIndex)}>新增字段</Button>}>
|
|
<Space direction="vertical" style={{ width: '100%' }}>
|
|
{(section.children || []).map((child, childIndex) => (
|
|
<Row gutter={8} align="middle" key={`${child.key}-${childIndex}`}>
|
|
<Col span={4}><Input addonBefore="key" value={child.key} maxLength={64} placeholder="传给 AI 的字段名" onChange={e => updateChild(sectionIndex, childIndex, { key: e.target.value })} /></Col>
|
|
<Col span={4}><Input addonBefore="label" value={child.label} maxLength={64} placeholder="后台显示名称" onChange={e => updateChild(sectionIndex, childIndex, { label: e.target.value })} /></Col>
|
|
<Col span={3}><Select value={child.type as any} options={NODE_TYPES.filter(item => item.value !== 'object' && item.value !== 'flow')} style={{ width: '100%' }} onChange={value => updateChild(sectionIndex, childIndex, { type: value })} /></Col>
|
|
<Col span={2}><Space>启用<Switch checked={child.enabled} onChange={checked => updateChild(sectionIndex, childIndex, { enabled: checked })} /></Space></Col>
|
|
<Col span={2}><Space>编辑<Switch checked={child.editable} onChange={checked => updateChild(sectionIndex, childIndex, { editable: checked })} /></Space></Col>
|
|
<Col span={3}><InputNumber min={1} max={20000} value={child.maxLength} addonBefore="长度" style={{ width: '100%' }} onChange={value => updateChild(sectionIndex, childIndex, { maxLength: Number(value || 1000) })} /></Col>
|
|
<Col span={5}>
|
|
<Input value={Array.isArray(child.value) ? child.value.join('/') : String(child.value ?? '')} maxLength={child.maxLength || 1000} placeholder="默认值" onChange={e => updateChild(sectionIndex, childIndex, { value: child.type === 'array' ? e.target.value.split('/').filter(Boolean) : e.target.value })} />
|
|
</Col>
|
|
<Col span={1} style={{ textAlign: 'right' }}>
|
|
<Button danger size="small" icon={<DeleteOutlined />} onClick={() => deleteChild(sectionIndex, childIndex)} />
|
|
</Col>
|
|
</Row>
|
|
))}
|
|
</Space>
|
|
</Card>
|
|
)}
|
|
|
|
{section.type === 'flow' && (
|
|
<Card size="small" title="流程数组对象字段" extra={<Button size="small" icon={<PlusOutlined />} onClick={() => addFlowField(sectionIndex)}>新增流程字段</Button>}>
|
|
<Row gutter={12} style={{ marginBottom: 12 }}>
|
|
<Col span={6}><Input addonBefore="内容字段" value={section.contentKey} maxLength={64} onChange={e => updateSection(sectionIndex, { contentKey: e.target.value })} /></Col>
|
|
<Col span={18}><Input addonBefore="别名 / 分隔" value={(section.contentAliases || []).join('/')} onChange={e => updateSection(sectionIndex, { contentAliases: e.target.value.split('/').map(item => item.trim()).filter(Boolean) })} /></Col>
|
|
</Row>
|
|
<Space direction="vertical" style={{ width: '100%' }}>
|
|
{(section.itemFields || []).map((field, fieldIndex) => (
|
|
<Row gutter={8} align="middle" key={`${field.key}-${fieldIndex}`}>
|
|
<Col span={5}><Input addonBefore="key" value={field.key} maxLength={64} placeholder="流程对象字段名" onChange={e => updateFlowField(sectionIndex, fieldIndex, { key: e.target.value })} /></Col>
|
|
<Col span={5}><Input addonBefore="label" value={field.label} maxLength={64} placeholder="后台显示名称" onChange={e => updateFlowField(sectionIndex, fieldIndex, { label: e.target.value })} /></Col>
|
|
<Col span={3}><Space>启用<Switch checked={field.enabled} onChange={checked => updateFlowField(sectionIndex, fieldIndex, { enabled: checked })} /></Space></Col>
|
|
<Col span={3}><Space>编辑<Switch checked={field.editable} onChange={checked => updateFlowField(sectionIndex, fieldIndex, { editable: checked })} /></Space></Col>
|
|
<Col span={4}><InputNumber min={1} max={20000} value={field.maxLength} addonBefore="长度" style={{ width: '100%' }} onChange={value => updateFlowField(sectionIndex, fieldIndex, { maxLength: Number(value || 800) })} /></Col>
|
|
<Col span={3}><Input value={String(field.value ?? '')} maxLength={field.maxLength || 800} placeholder="默认值" onChange={e => updateFlowField(sectionIndex, fieldIndex, { value: e.target.value })} /></Col>
|
|
<Col span={1} style={{ textAlign: 'right' }}><Button danger size="small" icon={<DeleteOutlined />} onClick={() => deleteFlowField(sectionIndex, fieldIndex)} /></Col>
|
|
</Row>
|
|
))}
|
|
</Space>
|
|
</Card>
|
|
)}
|
|
|
|
{section.type !== 'object' && section.type !== 'flow' && (
|
|
<Card size="small" title="默认值">
|
|
<TextArea rows={3} value={Array.isArray(section.value) ? section.value.join('\n') : String(section.value ?? '')} maxLength={section.maxLength || 1000} showCount onChange={e => updateSection(sectionIndex, { value: section.type === 'array' ? e.target.value.split('\n').filter(Boolean) : e.target.value })} />
|
|
</Card>
|
|
)}
|
|
</Space>
|
|
),
|
|
})), [sections]);
|
|
|
|
if (loading || !config) {
|
|
return <Spin />;
|
|
}
|
|
|
|
return (
|
|
<div style={{ padding: 24 }}>
|
|
<Card>
|
|
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
|
<Row justify="space-between" align="middle">
|
|
<Col>
|
|
<Title level={3} style={{ margin: 0 }}>视频提词 Schema 配置</Title>
|
|
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
|
配置会被爆款开头复刻和拆镜复刻第 4 步视频 AI 提词共同复用。传给 AI 时只保留纯键值 schema,不携带显示名、启用、可编辑等管理字段。
|
|
</Paragraph>
|
|
</Col>
|
|
<Col>
|
|
<Space>
|
|
<Text>启用自定义配置</Text>
|
|
<Switch checked={enabled} onChange={setEnabled} />
|
|
{usingDefault && <Tag color="orange">当前使用默认配置兜底</Tag>}
|
|
{defaultConfig && <Tag>默认分组 {defaultConfig.sections.length}</Tag>}
|
|
</Space>
|
|
</Col>
|
|
</Row>
|
|
|
|
<Alert
|
|
type="info"
|
|
showIcon
|
|
message="长度限制策略"
|
|
description="只对管理后台字段和用户可编辑字段做长度校验,不会自动裁剪 AI 生成结果或第 5 步最终视频 prompt。若供应商因为 prompt 过长拒绝,会按真实失败进入现有日志/退款/重试流程。"
|
|
/>
|
|
|
|
<Alert
|
|
type="warning"
|
|
showIcon
|
|
message="预览说明"
|
|
description="预览会跟随右上角「启用自定义配置」开关。关闭时会按默认 CLIENT_SCHEMA_V1 兜底预览;打开后才会按当前页面编辑的 sections 和秒数切片规则预览。"
|
|
/>
|
|
|
|
<Space wrap>
|
|
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={save}>保存配置</Button>
|
|
<Button icon={<EyeOutlined />} onClick={openPreview}>预览运行时 Schema</Button>
|
|
<Button icon={<DownloadOutlined />} onClick={doExport}>导出 JSON</Button>
|
|
<Button icon={<UploadOutlined />} onClick={() => importInputRef.current?.click()}>导入 JSON</Button>
|
|
<Popconfirm title="确认恢复默认配置?" description="会用 CLIENT_SCHEMA_V1 生成的默认配置覆盖当前配置。" onConfirm={resetDefault}>
|
|
<Button icon={<ReloadOutlined />}>恢复默认</Button>
|
|
</Popconfirm>
|
|
<input ref={importInputRef} type="file" accept="application/json,.json" style={{ display: 'none' }} onChange={onImportFile} />
|
|
</Space>
|
|
|
|
<Tabs
|
|
items={[
|
|
{
|
|
key: 'fields',
|
|
label: 'Schema 字段配置',
|
|
children: (
|
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
|
<Alert
|
|
type="warning"
|
|
showIcon
|
|
message="字段说明"
|
|
description="key 是最终传给 AI 和保存到 prompt_schema 的真实字段名,例如「素材理解」「动作流程」;label 只用于管理后台显示,帮助运营/管理员理解这个字段,不会传给 AI;启用关闭后该字段不会进入运行时 schema;可编辑关闭后用户在第 4 步修改时不能改这个字段。"
|
|
/>
|
|
<Button icon={<PlusOutlined />} onClick={addSection}>新增一级分组</Button>
|
|
<Collapse items={sectionItems} />
|
|
</Space>
|
|
),
|
|
},
|
|
{
|
|
key: 'time',
|
|
label: '秒数切片规则',
|
|
children: (
|
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
|
<Alert
|
|
type="info"
|
|
showIcon
|
|
message="切片规则说明"
|
|
description="这里决定指定视频时长会被拆成几段,以及每段的阶段名称和说明。运行时会同步生成「动态时间规划」「动作流程」「镜头流程」三个数组,三者时间段保持一致。"
|
|
/>
|
|
<Button icon={<PlusOutlined />} onClick={addTimeRule}>新增秒数规则</Button>
|
|
{timePlanRules.map((rule, ruleIndex) => (
|
|
<Card
|
|
key={ruleIndex}
|
|
size="small"
|
|
title={`${rule.minDuration}-${rule.maxDuration} 秒:${rule.segments.length} 段`}
|
|
extra={<Button danger size="small" icon={<DeleteOutlined />} onClick={() => deleteTimeRule(ruleIndex)}>删除规则</Button>}
|
|
>
|
|
<Row gutter={12} style={{ marginBottom: 12 }}>
|
|
<Col span={4}><InputNumber min={1} value={rule.minDuration} addonBefore="最小秒" style={{ width: '100%' }} onChange={value => updateTimeRule(ruleIndex, { minDuration: Number(value || 1) })} /></Col>
|
|
<Col span={4}><InputNumber min={1} value={rule.maxDuration} addonBefore="最大秒" style={{ width: '100%' }} onChange={value => updateTimeRule(ruleIndex, { maxDuration: Number(value || 1) })} /></Col>
|
|
<Col span={16}><Button size="small" icon={<PlusOutlined />} onClick={() => addSegment(ruleIndex)}>新增片段</Button></Col>
|
|
</Row>
|
|
<Space direction="vertical" style={{ width: '100%' }}>
|
|
{rule.segments.map((segment, segmentIndex) => (
|
|
<Row gutter={8} align="middle" key={segmentIndex}>
|
|
<Col span={3}><InputNumber min={0.01} step={0.01} value={rule.ratios[segmentIndex]} addonBefore="比例" style={{ width: '100%' }} onChange={value => updateSegment(ruleIndex, segmentIndex, { ratio: Number(value || 0.01) })} /></Col>
|
|
<Col span={5}><Input value={segment.stage} maxLength={64} placeholder="阶段" onChange={e => updateSegment(ruleIndex, segmentIndex, { stage: e.target.value })} /></Col>
|
|
<Col span={15}><Input value={segment.description} maxLength={300} placeholder="说明" onChange={e => updateSegment(ruleIndex, segmentIndex, { description: e.target.value })} /></Col>
|
|
<Col span={1}><Button danger size="small" icon={<DeleteOutlined />} onClick={() => deleteSegment(ruleIndex, segmentIndex)} /></Col>
|
|
</Row>
|
|
))}
|
|
</Space>
|
|
</Card>
|
|
))}
|
|
</Space>
|
|
),
|
|
},
|
|
{
|
|
key: 'json',
|
|
label: 'JSON 高级查看',
|
|
children: <TextArea rows={24} value={JSON.stringify(toApiConfig(config, enabled), null, 2)} readOnly />,
|
|
},
|
|
]}
|
|
/>
|
|
</Space>
|
|
</Card>
|
|
|
|
<Modal title="预览运行时 Schema" open={previewOpen} onCancel={() => setPreviewOpen(false)} width={980} footer={<Space><Button onClick={() => setPreviewOpen(false)}>关闭</Button><Button type="primary" onClick={doPreview}>生成预览</Button></Space>}>
|
|
<Form form={previewForm} layout="inline" style={{ marginBottom: 16 }}>
|
|
<Form.Item name="duration" label="时长" rules={[{ required: true }]}><InputNumber min={1} max={3600} /></Form.Item>
|
|
<Form.Item name="aspectRatio" label="比例" rules={[{ required: true }]}><Input /></Form.Item>
|
|
<Form.Item name="resolution" label="清晰度" rules={[{ required: true }]}><Input /></Form.Item>
|
|
<Form.Item name="frameRate" label="帧率" rules={[{ required: true }]}><Input /></Form.Item>
|
|
</Form>
|
|
<Divider />
|
|
{previewResult ? (
|
|
<Tabs
|
|
items={[
|
|
{
|
|
key: 'runtime',
|
|
label: '运行时 Schema',
|
|
children: <TextArea rows={24} value={JSON.stringify(runtimePreviewSchema(previewResult), null, 2)} readOnly />,
|
|
},
|
|
{
|
|
key: 'timePlan',
|
|
label: `动态时间规划(${runtimePreviewTimePlan(previewResult).length} 段)`,
|
|
children: <TextArea rows={24} value={JSON.stringify(runtimePreviewTimePlan(previewResult), null, 2)} readOnly />,
|
|
},
|
|
{
|
|
key: 'snapshot',
|
|
label: '配置快照',
|
|
children: <TextArea rows={24} value={JSON.stringify(runtimePreviewSnapshot(previewResult), null, 2)} readOnly />,
|
|
},
|
|
]}
|
|
/>
|
|
) : (
|
|
<Alert type="info" showIcon message="点击生成预览后,可以看到传给 AI 的纯 schema、动态时间规划和本次配置快照。" />
|
|
)}
|
|
</Modal>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default AdminVideoPromptSchemaConfig;
|