Files
video-gen/video-gen-admin/src/pages/AdminVideoPromptSchemaConfig.tsx
T

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;