diff --git a/video-gen-admin/src/App.tsx b/video-gen-admin/src/App.tsx index e42381ed..1c08d379 100644 --- a/video-gen-admin/src/App.tsx +++ b/video-gen-admin/src/App.tsx @@ -27,6 +27,7 @@ import AdminHotOpeningReplicationDetail from './pages/AdminHotOpeningReplication import AdminShotReplications from './pages/AdminShotReplications'; import AdminShotTaskSetDetail from './pages/AdminShotTaskSetDetail'; import AdminReplicationProjectDetail from './pages/AdminReplicationProjectDetail'; +import AdminVideoPromptSchemaConfig from './pages/AdminVideoPromptSchemaConfig'; import { useAdminStore } from './store'; const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { @@ -84,6 +85,7 @@ const App = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/video-gen-admin/src/api/index.ts b/video-gen-admin/src/api/index.ts index e4e76123..a1f0c8fa 100644 --- a/video-gen-admin/src/api/index.ts +++ b/video-gen-admin/src/api/index.ts @@ -10,6 +10,8 @@ import type { AdminHotOpeningTaskQueryParams, HotOpeningTaskListOut, ReplicationProjectDetailOut, AdminShotTaskSetQueryParams, ShotTaskSetListOut, ShotTaskSetDetailOut, AdminShotSegmentQueryParams, ShotSegmentListOut, ShotSegmentDetailOut, + VideoPromptSchemaConfigOut, VideoPromptSchemaConfigSavePayload, + VideoPromptSchemaPreviewPayload, VideoPromptSchemaPreviewOut, VideoPromptSchemaExportOut, } from '../types'; // ── Auth ────────────────────────────────────────────────── @@ -511,3 +513,30 @@ export async function getAdminShotSegmentDetail(segmentId: string): Promise { return api.get(`/shot-replications/projects/${projectId}`); } + + +// ── Video Prompt Schema Config (Admin) ───────────────────── + +export async function getVideoPromptSchemaConfig(): Promise { + return api.get('/admin/video-prompt-schema-config'); +} + +export async function saveVideoPromptSchemaConfig(payload: VideoPromptSchemaConfigSavePayload): Promise { + return api.put('/admin/video-prompt-schema-config', payload); +} + +export async function resetVideoPromptSchemaConfig(): Promise { + return api.post('/admin/video-prompt-schema-config/reset-default'); +} + +export async function exportVideoPromptSchemaConfig(): Promise { + return api.get('/admin/video-prompt-schema-config/export'); +} + +export async function importVideoPromptSchemaConfig(payload: VideoPromptSchemaConfigSavePayload): Promise { + return api.post('/admin/video-prompt-schema-config/import', payload); +} + +export async function previewVideoPromptSchemaConfig(payload: VideoPromptSchemaPreviewPayload): Promise { + return api.post('/admin/video-prompt-schema-config/preview', payload); +} diff --git a/video-gen-admin/src/pages/AdminVideoPromptSchemaConfig.tsx b/video-gen-admin/src/pages/AdminVideoPromptSchemaConfig.tsx new file mode 100644 index 00000000..a4f53adc --- /dev/null +++ b/video-gen-admin/src/pages/AdminVideoPromptSchemaConfig.tsx @@ -0,0 +1,707 @@ +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(value: T): T { + return JSON.parse(JSON.stringify(value ?? null)); +} + +function pick(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(node, 'contentAliases', 'content_aliases', []); + normalized.contentAliases = Array.isArray(aliases) ? aliases.map(item => String(item)).filter(Boolean) : []; + const itemFields = pick(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(null); + const [defaultConfig, setDefaultConfig] = useState(null); + const [previewOpen, setPreviewOpen] = useState(false); + const [previewResult, setPreviewResult] = useState(null); + const [previewForm] = Form.useForm(); + const importInputRef = useRef(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) => { + 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) => { + 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) => { + 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) => { + 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) => { + 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: ( + + {section.label || section.key || '未命名分组'} + {section.key && section.key !== section.label && key: {section.key}} + {section.type} + {!section.enabled && 已禁用} + + ), + children: ( + + + updateSection(sectionIndex, { key: e.target.value })} /> + updateSection(sectionIndex, { label: e.target.value })} /> + + updateChild(sectionIndex, childIndex, { key: e.target.value })} /> + updateChild(sectionIndex, childIndex, { label: e.target.value })} /> + updateChild(sectionIndex, childIndex, { value: child.type === 'array' ? e.target.value.split('/').filter(Boolean) : e.target.value })} /> + + + }> + + updateSection(sectionIndex, { contentKey: e.target.value })} /> + updateSection(sectionIndex, { contentAliases: e.target.value.split('/').map(item => item.trim()).filter(Boolean) })} /> + + + {(section.itemFields || []).map((field, fieldIndex) => ( + + updateFlowField(sectionIndex, fieldIndex, { key: e.target.value })} /> + updateFlowField(sectionIndex, fieldIndex, { label: e.target.value })} /> + 启用 updateFlowField(sectionIndex, fieldIndex, { enabled: checked })} /> + 编辑 updateFlowField(sectionIndex, fieldIndex, { editable: checked })} /> + updateFlowField(sectionIndex, fieldIndex, { maxLength: Number(value || 800) })} /> + updateFlowField(sectionIndex, fieldIndex, { value: e.target.value })} /> +