视频提示SCHEMA管理后台配置联动修改三端
This commit is contained in:
-407
File diff suppressed because one or more lines are too long
+407
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -28,7 +28,7 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-Te6nFn2L.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-wN5bY_f7.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -251,7 +251,14 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
|
||||
<Descriptions.Item label="提词参数" span={3}><JsonBlock value={detail.videoGeneration?.promptParams || {}} maxHeight={160} /></Descriptions.Item>
|
||||
</Descriptions>
|
||||
{renderErrorAlert(videoPromptStep?.errorMessage || detail.videoGeneration?.errorMessage)}
|
||||
<VideoPromptSchemaViewer schema={detail.videoGeneration?.promptSchema} finalPrompt={detail.videoGeneration?.finalPrompt} />
|
||||
<VideoPromptSchemaViewer
|
||||
schema={detail.videoGeneration?.promptSchema}
|
||||
finalPrompt={detail.videoGeneration?.finalPrompt}
|
||||
schemaConfigSnapshot={detail.videoGeneration?.schemaConfigSnapshot}
|
||||
schemaConfigSource={detail.videoGeneration?.schemaConfigSource}
|
||||
schemaConfigVersion={detail.videoGeneration?.schemaConfigVersion}
|
||||
schemaConfigIsFallback={detail.videoGeneration?.schemaConfigIsFallback}
|
||||
/>
|
||||
<StepRawJson step={videoPromptStep} />
|
||||
</Space>
|
||||
),
|
||||
|
||||
+100
-245
@@ -1,171 +1,19 @@
|
||||
import React from 'react';
|
||||
import { Card, Collapse, Descriptions, Empty, Space, Table, Typography } from 'antd';
|
||||
import { Card, Collapse, Descriptions, Empty, Space, Table, Tag, 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: '最终提示词',
|
||||
};
|
||||
import {
|
||||
VideoPromptSchemaFieldConfig,
|
||||
VideoPromptSchemaSectionConfig,
|
||||
hasSchemaConfigSnapshot,
|
||||
inferSchemaSections,
|
||||
normalizeSchemaSections,
|
||||
stringifySchemaValue,
|
||||
} from '../../../utils/videoPromptSchema';
|
||||
|
||||
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 ? '是' : '否';
|
||||
@@ -176,64 +24,55 @@ const renderValue = (value: unknown): React.ReactNode => {
|
||||
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 tagOfEditable = (editable?: boolean) => editable ? <Tag color="processing">可编辑</Tag> : <Tag color="default">只读</Tag>;
|
||||
|
||||
const readSectionValue = (schema: Record<string, any>, key: string): unknown => schema[key];
|
||||
|
||||
const renderObjectSection = (schema: Record<string, any>, section: VideoPromptSchemaSectionConfig): React.ReactNode => {
|
||||
const value = readSectionValue(schema, section.key);
|
||||
if (!isRecord(value)) return <JsonBlock value={value ?? {}} maxHeight={220} />;
|
||||
const fields = section.children.filter((field) => field.enabled);
|
||||
if (!fields.length) return <JsonBlock value={value} maxHeight={220} />;
|
||||
|
||||
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 size="small" column={2} bordered>
|
||||
{fields.map((field) => (
|
||||
<Descriptions.Item key={field.key} label={<Space size={4}>{field.label}{tagOfEditable(field.editable)}</Space>} span={isLongText(value[field.key]) || isRecord(value[field.key]) || Array.isArray(value[field.key]) ? 2 : 1}>
|
||||
{renderValue(value[field.key])}
|
||||
</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),
|
||||
const buildFlowFields = (section: VideoPromptSchemaSectionConfig, rows: Record<string, unknown>[]): VideoPromptSchemaFieldConfig[] => {
|
||||
const configured = section.itemFields.filter((field) => field.enabled);
|
||||
if (configured.length) {
|
||||
return [{ key: '时间段', label: '时间段', enabled: true, editable: false }, ...configured];
|
||||
}
|
||||
const inferred = Array.from(new Set(rows.flatMap((row) => Object.keys(row))));
|
||||
return inferred.map((key) => ({ key, label: key, enabled: true, editable: false }));
|
||||
};
|
||||
|
||||
const renderFlowSection = (schema: Record<string, any>, section: VideoPromptSchemaSectionConfig): React.ReactNode => {
|
||||
const value = readSectionValue(schema, section.key);
|
||||
if (!Array.isArray(value) || !value.every(isRecord)) return <JsonBlock value={value ?? []} maxHeight={260} />;
|
||||
const rows = value as Record<string, unknown>[];
|
||||
const fields = buildFlowFields(section, rows);
|
||||
const columns: ColumnsType<Record<string, unknown>> = fields.map((field) => ({
|
||||
title: <Space size={4}>{field.label}{tagOfEditable(field.editable)}</Space>,
|
||||
dataIndex: field.key,
|
||||
key: field.key,
|
||||
width: isLongText(rows.find((row) => row[field.key])?.[field.key]) ? 320 : 160,
|
||||
render: (cell: unknown) => renderValue(cell),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Table
|
||||
size="small"
|
||||
rowKey={(_, index) => `${sectionKey}-${index}`}
|
||||
rowKey={(_, index) => `${section.key}-${index}`}
|
||||
columns={columns}
|
||||
dataSource={displayItems}
|
||||
dataSource={rows}
|
||||
pagination={false}
|
||||
scroll={{ x: Math.max(900, fields.length * 180) }}
|
||||
tableLayout="fixed"
|
||||
@@ -241,59 +80,64 @@ const renderTableArray = (sectionKey: string, items: Record<string, unknown>[]):
|
||||
);
|
||||
};
|
||||
|
||||
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);
|
||||
const renderPrimitiveSection = (schema: Record<string, any>, section: VideoPromptSchemaSectionConfig): React.ReactNode => {
|
||||
const value = readSectionValue(schema, section.key);
|
||||
return renderValue(value);
|
||||
};
|
||||
|
||||
interface VideoPromptSchemaViewerProps {
|
||||
schema?: Record<string, any> | null;
|
||||
finalPrompt?: string | null;
|
||||
schemaConfigSnapshot?: Record<string, any> | null;
|
||||
schemaConfigSource?: string | null;
|
||||
schemaConfigVersion?: string | null;
|
||||
schemaConfigIsFallback?: boolean | null;
|
||||
}
|
||||
|
||||
const VideoPromptSchemaViewer: React.FC<VideoPromptSchemaViewerProps> = ({ schema, finalPrompt }) => {
|
||||
const VideoPromptSchemaViewer: React.FC<VideoPromptSchemaViewerProps> = ({
|
||||
schema,
|
||||
finalPrompt,
|
||||
schemaConfigSnapshot,
|
||||
schemaConfigSource,
|
||||
schemaConfigVersion,
|
||||
schemaConfigIsFallback,
|
||||
}) => {
|
||||
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'] : [];
|
||||
const safeSchema = hasSchema ? schema as Record<string, any> : {};
|
||||
const hasSnapshot = hasSchemaConfigSnapshot(schemaConfigSnapshot);
|
||||
const sections = hasSnapshot ? normalizeSchemaSections(schemaConfigSnapshot) : inferSchemaSections(safeSchema);
|
||||
const schemaItems = sections
|
||||
.filter((section) => section.key in safeSchema)
|
||||
.map((section) => ({
|
||||
key: section.key,
|
||||
label: (
|
||||
<Space size={8} wrap>
|
||||
<span>{section.label}</span>
|
||||
{tagOfEditable(section.editable)}
|
||||
{!hasSnapshot ? <Tag color="warning">管理后台兜底展示</Tag> : null}
|
||||
</Space>
|
||||
),
|
||||
children: section.type === 'object'
|
||||
? renderObjectSection(safeSchema, section)
|
||||
: section.type === 'flow'
|
||||
? renderFlowSection(safeSchema, section)
|
||||
: renderPrimitiveSection(safeSchema, section),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<Card size="small" title="Schema 配置来源">
|
||||
<Descriptions size="small" column={3} bordered>
|
||||
<Descriptions.Item label="来源">{schemaConfigSource || schemaConfigSnapshot?.source || (hasSnapshot ? '-' : 'viewer_fallback')}</Descriptions.Item>
|
||||
<Descriptions.Item label="版本">{schemaConfigVersion || schemaConfigSnapshot?.version || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="历史兜底">{schemaConfigIsFallback || !hasSnapshot ? <Tag color="warning">是</Tag> : <Tag color="success">否</Tag>}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
{finalPrompt ? (
|
||||
<Card size="small" title="最终视频提示词">
|
||||
<Typography.Paragraph style={{ whiteSpace: 'pre-wrap', marginBottom: 0 }}>{finalPrompt}</Typography.Paragraph>
|
||||
@@ -301,10 +145,21 @@ const VideoPromptSchemaViewer: React.FC<VideoPromptSchemaViewerProps> = ({ schem
|
||||
) : null}
|
||||
|
||||
{schemaItems.length ? (
|
||||
<Collapse size="small" items={schemaItems} />
|
||||
) : hasSchema ? (
|
||||
<Card size="small" title="视频提词 Schema">
|
||||
<JsonBlock value={safeSchema} maxHeight={360} />
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{hasSchema ? (
|
||||
<Collapse
|
||||
size="small"
|
||||
defaultActiveKey={defaultKeys}
|
||||
items={schemaItems}
|
||||
items={[{
|
||||
key: 'raw_schema',
|
||||
label: '原始 JSON / 历史脏字段排查',
|
||||
children: <JsonBlock value={safeSchema} maxHeight={420} />,
|
||||
}]}
|
||||
/>
|
||||
) : null}
|
||||
</Space>
|
||||
|
||||
@@ -403,6 +403,10 @@ export interface ReplicationVideoGenerationOut {
|
||||
promptSchema?: Record<string, any> | null;
|
||||
finalPrompt?: string | null;
|
||||
promptParams?: Record<string, any> | null;
|
||||
schemaConfigSnapshot?: Record<string, any> | null;
|
||||
schemaConfigSource?: string | null;
|
||||
schemaConfigVersion?: string | null;
|
||||
schemaConfigIsFallback?: boolean | null;
|
||||
engineId?: string | null;
|
||||
engineName?: string | null;
|
||||
params?: Record<string, any> | null;
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
export type JsonValue = any;
|
||||
export type JsonRecord = Record<string, JsonValue>;
|
||||
|
||||
export interface VideoPromptSchemaFieldConfig {
|
||||
key: string;
|
||||
label: string;
|
||||
type?: string;
|
||||
enabled: boolean;
|
||||
editable: boolean;
|
||||
maxLength?: number;
|
||||
}
|
||||
|
||||
export interface VideoPromptSchemaSectionConfig extends VideoPromptSchemaFieldConfig {
|
||||
contentKey?: string;
|
||||
itemFields: VideoPromptSchemaFieldConfig[];
|
||||
children: VideoPromptSchemaFieldConfig[];
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is JsonRecord {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readString(source: JsonRecord, ...keys: string[]): string {
|
||||
for (const key of keys) {
|
||||
const value = source[key];
|
||||
if (value !== undefined && value !== null && String(value).trim() !== '') return String(value);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function readBool(source: JsonRecord, defaultValue: boolean, ...keys: string[]): boolean {
|
||||
for (const key of keys) {
|
||||
const value = source[key];
|
||||
if (value === true || value === false) return value;
|
||||
if (value === 'true') return true;
|
||||
if (value === 'false') return false;
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
function readNumber(source: JsonRecord, ...keys: string[]): number | undefined {
|
||||
for (const key of keys) {
|
||||
const value = source[key];
|
||||
if (typeof value === 'number' && Number.isFinite(value) && value > 0) return value;
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeField(raw: unknown): VideoPromptSchemaFieldConfig | null {
|
||||
if (!isRecord(raw)) return null;
|
||||
const key = readString(raw, 'key');
|
||||
if (!key) return null;
|
||||
return {
|
||||
key,
|
||||
label: readString(raw, 'label') || key,
|
||||
type: readString(raw, 'type') || undefined,
|
||||
enabled: readBool(raw, true, 'enabled'),
|
||||
editable: readBool(raw, true, 'editable'),
|
||||
maxLength: readNumber(raw, 'maxLength', 'max_length'),
|
||||
};
|
||||
}
|
||||
|
||||
export function getSchemaConfigData(snapshot: unknown): JsonRecord | null {
|
||||
if (!isRecord(snapshot)) return null;
|
||||
const data = snapshot.data;
|
||||
if (isRecord(data)) return data;
|
||||
if (Array.isArray(snapshot.sections)) return snapshot;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function hasSchemaConfigSnapshot(snapshot: unknown): boolean {
|
||||
return !!getSchemaConfigData(snapshot);
|
||||
}
|
||||
|
||||
export function normalizeSchemaSections(snapshot: unknown): VideoPromptSchemaSectionConfig[] {
|
||||
const data = getSchemaConfigData(snapshot);
|
||||
const rawSections = Array.isArray(data?.sections) ? data.sections : [];
|
||||
return rawSections
|
||||
.map((raw): VideoPromptSchemaSectionConfig | null => {
|
||||
const base = normalizeField(raw);
|
||||
if (!base || !isRecord(raw)) return null;
|
||||
const children = Array.isArray(raw.children) ? raw.children.map(normalizeField).filter(Boolean) as VideoPromptSchemaFieldConfig[] : [];
|
||||
const itemFields = Array.isArray(raw.itemFields)
|
||||
? raw.itemFields.map(normalizeField).filter(Boolean) as VideoPromptSchemaFieldConfig[]
|
||||
: Array.isArray(raw.item_fields)
|
||||
? raw.item_fields.map(normalizeField).filter(Boolean) as VideoPromptSchemaFieldConfig[]
|
||||
: [];
|
||||
return {
|
||||
...base,
|
||||
contentKey: readString(raw, 'contentKey', 'content_key') || undefined,
|
||||
children,
|
||||
itemFields,
|
||||
};
|
||||
})
|
||||
.filter((section): section is VideoPromptSchemaSectionConfig => !!section && section.enabled);
|
||||
}
|
||||
|
||||
export function inferSchemaSections(schema: unknown): VideoPromptSchemaSectionConfig[] {
|
||||
if (!isRecord(schema)) return [];
|
||||
return Object.entries(schema)
|
||||
.filter(([key]) => !['schemaVersion', 'schemaUsage', 'schema_version', 'schema_usage'].includes(key))
|
||||
.map(([key, value]) => {
|
||||
if (Array.isArray(value) && value.every(isRecord)) {
|
||||
const fields = Array.from(new Set(value.flatMap((item) => Object.keys(item))));
|
||||
return {
|
||||
key,
|
||||
label: key,
|
||||
type: 'flow',
|
||||
enabled: true,
|
||||
editable: false,
|
||||
itemFields: fields.filter((field) => field !== '时间段').map((field) => ({ key: field, label: field, enabled: true, editable: false })),
|
||||
children: [],
|
||||
} as VideoPromptSchemaSectionConfig;
|
||||
}
|
||||
if (isRecord(value)) {
|
||||
return {
|
||||
key,
|
||||
label: key,
|
||||
type: 'object',
|
||||
enabled: true,
|
||||
editable: false,
|
||||
children: Object.keys(value).map((field) => ({ key: field, label: field, enabled: true, editable: false })),
|
||||
itemFields: [],
|
||||
} as VideoPromptSchemaSectionConfig;
|
||||
}
|
||||
return {
|
||||
key,
|
||||
label: key,
|
||||
type: 'text',
|
||||
enabled: true,
|
||||
editable: false,
|
||||
children: [],
|
||||
itemFields: [],
|
||||
} as VideoPromptSchemaSectionConfig;
|
||||
});
|
||||
}
|
||||
|
||||
export function stringifySchemaValue(value: JsonValue): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (typeof value === 'object') {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/app.tsx","./src/env.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/crypto.ts","./src/api/index.ts","./src/pages/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationairecords.tsx","./src/pages/admingenerationrecords.tsx","./src/pages/adminhotopeningreplicationdetail.tsx","./src/pages/adminhotopeningreplications.tsx","./src/pages/adminimageengines.tsx","./src/pages/adminindustries.tsx","./src/pages/adminlayout.tsx","./src/pages/adminloginpage.tsx","./src/pages/adminmenuconfig.tsx","./src/pages/adminmodels.tsx","./src/pages/adminnotificationmanager.tsx","./src/pages/adminoauthapplist.tsx","./src/pages/adminoperationlogs.tsx","./src/pages/adminpaymentconfig.tsx","./src/pages/adminpaymentstats.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminreplicationprojectdetail.tsx","./src/pages/adminsettings.tsx","./src/pages/adminshotreplications.tsx","./src/pages/adminshottasksetdetail.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/pages/adminvideopromptschemaconfig.tsx","./src/pages/adminreplication/components/jsoncollapse.tsx","./src/pages/adminreplication/components/mediapreview.tsx","./src/pages/adminreplication/components/statustag.tsx","./src/pages/adminreplication/components/videopromptschemaviewer.tsx","./src/store/index.ts","./src/types/index.ts","./src/utils/formatdate.ts"],"version":"6.0.3"}
|
||||
{"root":["./src/app.tsx","./src/env.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/crypto.ts","./src/api/index.ts","./src/pages/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationairecords.tsx","./src/pages/admingenerationrecords.tsx","./src/pages/adminhotopeningreplicationdetail.tsx","./src/pages/adminhotopeningreplications.tsx","./src/pages/adminimageengines.tsx","./src/pages/adminindustries.tsx","./src/pages/adminlayout.tsx","./src/pages/adminloginpage.tsx","./src/pages/adminmenuconfig.tsx","./src/pages/adminmodels.tsx","./src/pages/adminnotificationmanager.tsx","./src/pages/adminoauthapplist.tsx","./src/pages/adminoperationlogs.tsx","./src/pages/adminpaymentconfig.tsx","./src/pages/adminpaymentstats.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminreplicationprojectdetail.tsx","./src/pages/adminsettings.tsx","./src/pages/adminshotreplications.tsx","./src/pages/adminshottasksetdetail.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/pages/adminvideopromptschemaconfig.tsx","./src/pages/adminreplication/components/jsoncollapse.tsx","./src/pages/adminreplication/components/mediapreview.tsx","./src/pages/adminreplication/components/statustag.tsx","./src/pages/adminreplication/components/videopromptschemaviewer.tsx","./src/store/index.ts","./src/types/index.ts","./src/utils/formatdate.ts","./src/utils/videopromptschema.ts"],"version":"6.0.3"}
|
||||
@@ -415,6 +415,7 @@ class HotOpeningVideoGenerationOut(BaseModel):
|
||||
schema_config_snapshot: dict[str, Any] | None = Field(None, description="第4步生成视频提词时备份的 VIDEO_SCHEMA 配置快照。修改时按快照判断可编辑字段")
|
||||
schema_config_source: str | None = Field(None, description="VIDEO_SCHEMA 配置来源:database=后台配置,default=CLIENT_SCHEMA_V1 默认配置")
|
||||
schema_config_version: str | None = Field(None, description="VIDEO_SCHEMA 配置版本")
|
||||
schema_config_is_fallback: bool = Field(False, description="是否为接口运行时兼容历史数据临时补齐的默认 VIDEO_SCHEMA 配置。true 表示数据库原始第4步没有 schema_config_snapshot")
|
||||
engine_id: str | None = Field(None, description="视频生成引擎ID")
|
||||
engine_name: str | None = Field(None, description="视频生成引擎名称")
|
||||
params: dict[str, Any] | None = Field(None, description="视频生成实际参数。第5步只传 engine_id,其它参数继承第4步")
|
||||
|
||||
@@ -418,6 +418,7 @@ class ShotReplicateVideoGenerationOut(BaseModel):
|
||||
schema_config_snapshot: dict[str, Any] | None = Field(None, description="第4步生成视频提词时备份的 VIDEO_SCHEMA 配置快照。修改时按快照判断可编辑字段")
|
||||
schema_config_source: str | None = Field(None, description="VIDEO_SCHEMA 配置来源:database=后台配置,default=CLIENT_SCHEMA_V1 默认配置")
|
||||
schema_config_version: str | None = Field(None, description="VIDEO_SCHEMA 配置版本")
|
||||
schema_config_is_fallback: bool = Field(False, description="是否为接口运行时兼容历史数据临时补齐的默认 VIDEO_SCHEMA 配置。true 表示数据库原始第4步没有 schema_config_snapshot")
|
||||
engine_id: str | None = Field(None, description="视频生成引擎ID")
|
||||
engine_name: str | None = Field(None, description="视频生成引擎名称")
|
||||
params: dict[str, Any] | None = Field(None, description="视频生成实际参数。第5步只传 engine_id,其它参数继承第4步")
|
||||
|
||||
@@ -83,7 +83,7 @@ from app.services.module_generation_step_update_service import (
|
||||
update_module_video_prompt_schema,
|
||||
)
|
||||
from app.services.resource_signed_url_service import build_resource_signed_url
|
||||
from app.services.video_prompt_schema_config_service import get_runtime_schema_snapshot
|
||||
from app.services.video_prompt_schema_config_service import fallback_runtime_schema_snapshot, get_runtime_schema_snapshot
|
||||
from app.utils.id_gen import generate_id
|
||||
|
||||
MODULE = ModuleCodeEnum.HOT_OPENING_REPLICATE.value
|
||||
@@ -180,6 +180,29 @@ def _unwrap_step_output(value: Any) -> dict[str, Any]:
|
||||
return _common_unwrap_step_output(value, schema_version=STEP_IO_SCHEMA_VERSION)
|
||||
|
||||
|
||||
def _resolve_video_schema_config_snapshot(video_prompt_output: dict[str, Any]) -> tuple[dict[str, Any] | None, str | None, str | None, bool]:
|
||||
"""
|
||||
详情接口运行时补齐历史第4步缺失的 schema_config_snapshot。
|
||||
|
||||
注意:这里仅用于接口返回,不修改 step.output_json,避免把历史脏数据伪装成生成当时真实快照。
|
||||
"""
|
||||
prompt_schema = video_prompt_output.get("prompt_schema")
|
||||
if not isinstance(prompt_schema, dict) or not prompt_schema:
|
||||
return None, None, None, False
|
||||
|
||||
raw_snapshot = video_prompt_output.get("schema_config_snapshot")
|
||||
has_real_snapshot = isinstance(raw_snapshot, dict) and isinstance(raw_snapshot.get("data"), dict)
|
||||
snapshot = fallback_runtime_schema_snapshot(raw_snapshot if has_real_snapshot else None)
|
||||
return (
|
||||
snapshot,
|
||||
str(snapshot.get("source") or video_prompt_output.get("schema_config_source") or "") or None,
|
||||
str(snapshot.get("version") or video_prompt_output.get("schema_config_version") or "") or None,
|
||||
not has_real_snapshot,
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
async def log_module_event(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
@@ -375,6 +398,8 @@ async def project_to_detail_out(db: AsyncSession, project: ModuleGenerationProje
|
||||
video_url = video_generate_output.get("result_video_url") or (video_chat.video_url if video_chat else None) or project.final_video_url
|
||||
cover_url = video_generate_output.get("result_video_cover_url") or (video_chat.video_cover_url if video_chat else None) or project.final_video_cover_url
|
||||
|
||||
schema_config_snapshot, schema_config_source, schema_config_version, schema_config_is_fallback = _resolve_video_schema_config_snapshot(video_prompt_output)
|
||||
|
||||
user_name: str | None = None
|
||||
if project.user_id:
|
||||
user_result = await db.execute(select(User.username).where(User.id == project.user_id).limit(1))
|
||||
@@ -419,9 +444,10 @@ async def project_to_detail_out(db: AsyncSession, project: ModuleGenerationProje
|
||||
prompt_schema=video_prompt_output.get("prompt_schema"),
|
||||
final_prompt=video_prompt_output.get("final_prompt"),
|
||||
prompt_params=video_prompt_output.get("params_used_for_prompt") or video_prompt_input.get("video_config"),
|
||||
schema_config_snapshot=video_prompt_output.get("schema_config_snapshot"),
|
||||
schema_config_source=video_prompt_output.get("schema_config_source"),
|
||||
schema_config_version=video_prompt_output.get("schema_config_version"),
|
||||
schema_config_snapshot=schema_config_snapshot,
|
||||
schema_config_source=schema_config_source,
|
||||
schema_config_version=schema_config_version,
|
||||
schema_config_is_fallback=schema_config_is_fallback,
|
||||
engine_id=video_snapshot.get("id") or video_generate_input.get("engine_id"),
|
||||
engine_name=video_snapshot.get("name") or video_generate_input.get("engine_name"),
|
||||
params=video_generate_input.get("params") or video_generate_input,
|
||||
|
||||
@@ -18,6 +18,7 @@ from app.services.module_generation_flow_base_service import (
|
||||
soft_delete_steps_from_index,
|
||||
)
|
||||
from app.enums.module_generation_flow import ModuleGenerationFlowConfig
|
||||
from app.services.video_prompt_schema_config_service import fallback_runtime_schema_snapshot
|
||||
from app.services.module_generation_step_common_service import (
|
||||
build_step_input,
|
||||
build_step_output,
|
||||
@@ -291,7 +292,7 @@ async def update_module_video_prompt_schema(
|
||||
if not isinstance(video_config, dict) or not video_config.get("duration") or not video_config.get("aspect_ratio") or not video_config.get("resolution"):
|
||||
raise HTTPException(status_code=400, detail="缺少第4步视频参数快照,不能安全修改视频 schema")
|
||||
|
||||
schema_config_snapshot = output_data.get("schema_config_snapshot")
|
||||
schema_config_snapshot = fallback_runtime_schema_snapshot(output_data.get("schema_config_snapshot"))
|
||||
try:
|
||||
patched_schema = patch_video_prompt_schema_from_client(
|
||||
server_schema=server_schema,
|
||||
|
||||
@@ -87,7 +87,7 @@ from app.services.module_generation_step_update_service import (
|
||||
update_module_video_prompt_schema,
|
||||
)
|
||||
from app.services.resource_signed_url_service import build_resource_signed_url
|
||||
from app.services.video_prompt_schema_config_service import get_runtime_schema_snapshot
|
||||
from app.services.video_prompt_schema_config_service import fallback_runtime_schema_snapshot, get_runtime_schema_snapshot
|
||||
from app.utils.id_gen import generate_id
|
||||
from app.models.shot_replicate_segment import ShotReplicateSegment
|
||||
from app.enums.shot_replicate import ShotSegmentReplicateStatusEnum, ShotSplitStatusEnum
|
||||
@@ -187,6 +187,29 @@ def _unwrap_step_output(value: Any) -> dict[str, Any]:
|
||||
return _common_unwrap_step_output(value, schema_version=STEP_IO_SCHEMA_VERSION)
|
||||
|
||||
|
||||
def _resolve_video_schema_config_snapshot(video_prompt_output: dict[str, Any]) -> tuple[dict[str, Any] | None, str | None, str | None, bool]:
|
||||
"""
|
||||
详情接口运行时补齐历史第4步缺失的 schema_config_snapshot。
|
||||
|
||||
注意:这里仅用于接口返回,不修改 step.output_json,避免把历史脏数据伪装成生成当时真实快照。
|
||||
"""
|
||||
prompt_schema = video_prompt_output.get("prompt_schema")
|
||||
if not isinstance(prompt_schema, dict) or not prompt_schema:
|
||||
return None, None, None, False
|
||||
|
||||
raw_snapshot = video_prompt_output.get("schema_config_snapshot")
|
||||
has_real_snapshot = isinstance(raw_snapshot, dict) and isinstance(raw_snapshot.get("data"), dict)
|
||||
snapshot = fallback_runtime_schema_snapshot(raw_snapshot if has_real_snapshot else None)
|
||||
return (
|
||||
snapshot,
|
||||
str(snapshot.get("source") or video_prompt_output.get("schema_config_source") or "") or None,
|
||||
str(snapshot.get("version") or video_prompt_output.get("schema_config_version") or "") or None,
|
||||
not has_real_snapshot,
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
async def log_module_event(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
@@ -382,6 +405,8 @@ async def project_to_detail_out(db: AsyncSession, project: ModuleGenerationProje
|
||||
video_url = video_generate_output.get("result_video_url") or (video_chat.video_url if video_chat else None) or project.final_video_url
|
||||
cover_url = video_generate_output.get("result_video_cover_url") or (video_chat.video_cover_url if video_chat else None) or project.final_video_cover_url
|
||||
|
||||
schema_config_snapshot, schema_config_source, schema_config_version, schema_config_is_fallback = _resolve_video_schema_config_snapshot(video_prompt_output)
|
||||
|
||||
user_name: str | None = None
|
||||
if project.user_id:
|
||||
user_result = await db.execute(select(User.username).where(User.id == project.user_id).limit(1))
|
||||
@@ -426,9 +451,10 @@ async def project_to_detail_out(db: AsyncSession, project: ModuleGenerationProje
|
||||
prompt_schema=video_prompt_output.get("prompt_schema"),
|
||||
final_prompt=video_prompt_output.get("final_prompt"),
|
||||
prompt_params=video_prompt_output.get("params_used_for_prompt") or video_prompt_input.get("video_config"),
|
||||
schema_config_snapshot=video_prompt_output.get("schema_config_snapshot"),
|
||||
schema_config_source=video_prompt_output.get("schema_config_source"),
|
||||
schema_config_version=video_prompt_output.get("schema_config_version"),
|
||||
schema_config_snapshot=schema_config_snapshot,
|
||||
schema_config_source=schema_config_source,
|
||||
schema_config_version=schema_config_version,
|
||||
schema_config_is_fallback=schema_config_is_fallback,
|
||||
engine_id=video_snapshot.get("id") or video_generate_input.get("engine_id"),
|
||||
engine_name=video_snapshot.get("name") or video_generate_input.get("engine_name"),
|
||||
params=video_generate_input.get("params") or video_generate_input,
|
||||
|
||||
@@ -1,281 +1,207 @@
|
||||
import React from 'react';
|
||||
import { Button, Input, Space, Tag, Typography } from 'antd';
|
||||
import { Alert, Input, Space, Tag, Typography } from 'antd';
|
||||
import {
|
||||
JsonValue,
|
||||
VideoPromptSchemaFieldConfig,
|
||||
VideoPromptSchemaSectionConfig,
|
||||
clonePlain,
|
||||
hasSchemaConfigSnapshot,
|
||||
normalizeSchemaSections,
|
||||
setArrayObjectField,
|
||||
setObjectField,
|
||||
shouldUseSchemaTextArea,
|
||||
stringifySchemaValue,
|
||||
} from '../utils/videoPromptSchema';
|
||||
|
||||
const { Text } = Typography;
|
||||
const { TextArea } = Input;
|
||||
|
||||
type JsonValue = any;
|
||||
|
||||
type VideoPromptSchemaEditorProps = {
|
||||
value: Record<string, JsonValue>;
|
||||
schemaConfigSnapshot?: Record<string, any> | null;
|
||||
onChange: (nextValue: Record<string, JsonValue>) => void;
|
||||
};
|
||||
|
||||
const LOCKED_TOP_LEVEL_KEYS = new Set([
|
||||
'schema_version',
|
||||
'schema_usage',
|
||||
'动态时间规划',
|
||||
'输出规格限制',
|
||||
'合规控制',
|
||||
'质量控制',
|
||||
]);
|
||||
|
||||
const HIDDEN_TOP_LEVEL_KEYS = new Set([
|
||||
'schemaUsage',
|
||||
'schemaVersion',
|
||||
'输出规格限制',
|
||||
]);
|
||||
|
||||
const LOCKED_FRAME_KEYS = new Set([
|
||||
'视频时长',
|
||||
'视频比例',
|
||||
'清晰度',
|
||||
'帧率',
|
||||
'推荐分辨率',
|
||||
]);
|
||||
|
||||
const EDITABLE_FRAME_KEYS = new Set([
|
||||
'主体描述',
|
||||
'主体数量',
|
||||
'主体位置',
|
||||
'主体占比',
|
||||
'场景描述',
|
||||
'构图方式',
|
||||
'画面风格',
|
||||
'光影色彩',
|
||||
]);
|
||||
|
||||
const EDITABLE_FINAL_PROMPT_KEYS = new Set([
|
||||
'主提示词',
|
||||
'动作提示词',
|
||||
'镜头提示词',
|
||||
'字幕提示词',
|
||||
'音频提示词',
|
||||
'风格提示词',
|
||||
'负面提示词',
|
||||
]);
|
||||
|
||||
function clonePlain<T>(value: T): T {
|
||||
return value === undefined ? value : JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function pathIncludes(path: Array<string | number>, key: string): boolean {
|
||||
return path.some((item) => String(item) === key);
|
||||
}
|
||||
|
||||
function getPathValue(root: JsonValue, path: Array<string | number>): JsonValue {
|
||||
let current = root;
|
||||
for (const key of path) {
|
||||
if (current === undefined || current === null) return undefined;
|
||||
current = current[key as keyof typeof current];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function setPathValue(root: JsonValue, path: Array<string | number>, value: JsonValue): JsonValue {
|
||||
const next = clonePlain(root);
|
||||
let current = next;
|
||||
for (let index = 0; index < path.length - 1; index += 1) {
|
||||
current = current[path[index] as keyof typeof current];
|
||||
}
|
||||
current[path[path.length - 1] as keyof typeof current] = value;
|
||||
return next;
|
||||
}
|
||||
|
||||
function removeArrayItem(root: JsonValue, path: Array<string | number>, index: number): JsonValue {
|
||||
const arrayValue = getPathValue(root, path);
|
||||
if (!Array.isArray(arrayValue)) return root;
|
||||
return setPathValue(root, path, arrayValue.filter((_, itemIndex) => itemIndex !== index));
|
||||
}
|
||||
|
||||
function addArrayItem(root: JsonValue, path: Array<string | number>, sampleValue: JsonValue): JsonValue {
|
||||
const arrayValue = getPathValue(root, path);
|
||||
if (!Array.isArray(arrayValue)) return root;
|
||||
const nextItem = typeof sampleValue === 'object' && sampleValue !== null ? clonePlain(sampleValue) : '';
|
||||
return setPathValue(root, path, [...arrayValue, nextItem]);
|
||||
}
|
||||
|
||||
function stringifyReadonly(value: JsonValue): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (typeof value === 'object') return JSON.stringify(value, null, 2);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function shouldUseTextArea(value: JsonValue): boolean {
|
||||
const text = stringifyReadonly(value);
|
||||
return text.length > 40 || text.includes('\n') || text.includes(',') || text.includes('。');
|
||||
}
|
||||
|
||||
function isLockedPath(path: Array<string | number>): boolean {
|
||||
const rootKey = String(path[0] ?? '');
|
||||
const currentKey = String(path[path.length - 1] ?? '');
|
||||
|
||||
if (LOCKED_TOP_LEVEL_KEYS.has(rootKey)) return true;
|
||||
if (rootKey === '画面属性') {
|
||||
if (LOCKED_FRAME_KEYS.has(currentKey)) return true;
|
||||
if (!EDITABLE_FRAME_KEYS.has(currentKey)) return false;
|
||||
}
|
||||
if (rootKey === '最终提示词' && path.length === 2) {
|
||||
return !EDITABLE_FINAL_PROMPT_KEYS.has(currentKey);
|
||||
}
|
||||
if ((rootKey === '动作流程' || rootKey === '镜头流程') && currentKey === '时间段') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function canAddOrRemoveArray(path: Array<string | number>): boolean {
|
||||
const rootKey = String(path[0] ?? '');
|
||||
if (LOCKED_TOP_LEVEL_KEYS.has(rootKey)) return false;
|
||||
if (rootKey === '动作流程' || rootKey === '镜头流程') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function fieldTitle(key: string | number): string {
|
||||
return typeof key === 'number' ? `第 ${key + 1} 项` : key;
|
||||
function isRecord(value: unknown): value is Record<string, JsonValue> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function ReadonlyBlock({ value }: { value: JsonValue }) {
|
||||
const text = stringifyReadonly(value);
|
||||
return shouldUseTextArea(value) ? (
|
||||
const text = stringifySchemaValue(value);
|
||||
return shouldUseSchemaTextArea(value) ? (
|
||||
<TextArea value={text} rows={Math.min(6, Math.max(2, Math.ceil(text.length / 42)))} disabled style={{ borderRadius: 8, color: '#64748b' }} />
|
||||
) : (
|
||||
<Input value={text} disabled style={{ borderRadius: 8, color: '#64748b' }} />
|
||||
);
|
||||
}
|
||||
|
||||
function EditableInput({ value, onChange }: { value: JsonValue; onChange: (nextValue: JsonValue) => void }) {
|
||||
const text = stringifyReadonly(value);
|
||||
if (shouldUseTextArea(value)) {
|
||||
function EditableInput({ value, maxLength, onChange }: { value: JsonValue; maxLength?: number; onChange: (nextValue: JsonValue) => void }) {
|
||||
const text = stringifySchemaValue(value);
|
||||
const commonProps = {
|
||||
value: text,
|
||||
maxLength,
|
||||
showCount: !!maxLength,
|
||||
onChange: (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => onChange(event.target.value),
|
||||
style: { borderRadius: 8 },
|
||||
};
|
||||
|
||||
if (shouldUseSchemaTextArea(value)) {
|
||||
return <TextArea {...commonProps} rows={Math.min(8, Math.max(3, Math.ceil(text.length / 42)))} />;
|
||||
}
|
||||
|
||||
return <Input {...commonProps} />;
|
||||
}
|
||||
|
||||
function renderFieldTag(field: VideoPromptSchemaFieldConfig) {
|
||||
return field.editable ? <Tag color="processing">可编辑</Tag> : <Tag color="default">只读</Tag>;
|
||||
}
|
||||
|
||||
const VideoPromptSchemaEditor: React.FC<VideoPromptSchemaEditorProps> = ({ value, schemaConfigSnapshot, onChange }) => {
|
||||
const safeValue = isRecord(value) ? value : {};
|
||||
const sections = normalizeSchemaSections(schemaConfigSnapshot);
|
||||
|
||||
if (!hasSchemaConfigSnapshot(schemaConfigSnapshot)) {
|
||||
return (
|
||||
<TextArea
|
||||
value={text}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
rows={Math.min(8, Math.max(3, Math.ceil(text.length / 42)))}
|
||||
style={{ borderRadius: 8 }}
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="视频提词配置缺失,暂不能编辑"
|
||||
description="请刷新详情后重试;如果仍然缺失,请联系管理员排查第4步 schema_config_snapshot 返回。"
|
||||
/>
|
||||
{Object.keys(safeValue).length ? (
|
||||
<pre style={{ margin: 0, padding: 12, borderRadius: 10, background: '#f8fafc', maxHeight: 420, overflow: 'auto', color: '#475569' }}>
|
||||
{JSON.stringify(safeValue, null, 2)}
|
||||
</pre>
|
||||
) : null}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
return <Input value={text} onChange={(event) => onChange(event.target.value)} style={{ borderRadius: 8 }} />;
|
||||
if (!sections.length) {
|
||||
return <Alert type="warning" showIcon message="视频提词配置为空" description="当前 Schema 配置没有可展示字段。" />;
|
||||
}
|
||||
|
||||
const VideoPromptSchemaEditor: React.FC<VideoPromptSchemaEditorProps> = ({ value, onChange }) => {
|
||||
const safeValue = value && typeof value === 'object' ? value : {};
|
||||
const renderObjectSection = (section: VideoPromptSchemaSectionConfig) => {
|
||||
const sectionValue = isRecord(safeValue[section.key]) ? safeValue[section.key] as Record<string, JsonValue> : {};
|
||||
const fields = section.children.filter((field) => field.enabled);
|
||||
if (!fields.length) return null;
|
||||
|
||||
const updatePath = (path: Array<string | number>, nextValue: JsonValue) => {
|
||||
onChange(setPathValue(safeValue, path, nextValue));
|
||||
};
|
||||
|
||||
const renderNode = (key: string | number, nodeValue: JsonValue, path: Array<string | number>, depth = 0): React.ReactNode => {
|
||||
const locked = isLockedPath(path);
|
||||
const rootKey = String(path[0] ?? '');
|
||||
|
||||
if (Array.isArray(nodeValue)) {
|
||||
const editableArray = !locked && canAddOrRemoveArray(path);
|
||||
const sample = nodeValue.find((item) => item !== undefined) ?? '';
|
||||
return (
|
||||
<div key={path.join('.')} style={{ marginBottom: 18 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<Space>
|
||||
<Text strong style={{ color: '#334155', fontSize: 13 }}>{fieldTitle(key)}</Text>
|
||||
{/* {!editableArray && <Tag color="default">锁定结构</Tag>} */}
|
||||
<div key={section.key} style={{ marginBottom: 18 }}>
|
||||
<Space style={{ marginBottom: 8 }}>
|
||||
<Text strong style={{ color: '#334155', fontSize: 13 }}>{section.label}</Text>
|
||||
{renderFieldTag(section)}
|
||||
</Space>
|
||||
{editableArray && (
|
||||
<Button size="small" type="link" onClick={() => onChange(addArrayItem(safeValue, path, sample))}>
|
||||
+ 添加
|
||||
</Button>
|
||||
<div style={{ borderLeft: '3px solid #6366f1', paddingLeft: 12, marginLeft: 4 }}>
|
||||
{fields.map((field) => (
|
||||
<div key={field.key} style={{ marginBottom: 12 }}>
|
||||
<Space style={{ marginBottom: 4 }}>
|
||||
<Text style={{ color: '#64748b', fontSize: 12 }}>{field.label}</Text>
|
||||
{renderFieldTag(field)}
|
||||
</Space>
|
||||
{field.editable ? (
|
||||
<EditableInput
|
||||
value={sectionValue[field.key]}
|
||||
maxLength={field.maxLength}
|
||||
onChange={(nextText) => onChange(setObjectField(safeValue, section.key, field.key, nextText))}
|
||||
/>
|
||||
) : (
|
||||
<ReadonlyBlock value={sectionValue[field.key]} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
<div style={{ border: '1px solid #e5e7eb', borderRadius: 10, padding: 12, background: locked ? '#f8fafc' : '#fff' }}>
|
||||
{nodeValue.length === 0 ? (
|
||||
const renderFlowSection = (section: VideoPromptSchemaSectionConfig) => {
|
||||
const rows = Array.isArray(safeValue[section.key]) ? safeValue[section.key] as Record<string, JsonValue>[] : [];
|
||||
const fields = section.itemFields.filter((field) => field.enabled);
|
||||
const displayFields = [{ key: '时间段', label: '时间段', enabled: true, editable: false } as VideoPromptSchemaFieldConfig, ...fields];
|
||||
|
||||
return (
|
||||
<div key={section.key} style={{ marginBottom: 18 }}>
|
||||
<Space style={{ marginBottom: 8 }}>
|
||||
<Text strong style={{ color: '#334155', fontSize: 13 }}>{section.label}</Text>
|
||||
<Tag color="default">锁定条目和时间段</Tag>
|
||||
</Space>
|
||||
<div style={{ border: '1px solid #e5e7eb', borderRadius: 10, padding: 12, background: '#fff' }}>
|
||||
{rows.length === 0 ? (
|
||||
<Text style={{ color: '#94a3b8', fontSize: 12 }}>暂无内容</Text>
|
||||
) : (
|
||||
nodeValue.map((item, index) => (
|
||||
<div key={`${path.join('.')}.${index}`} style={{ marginBottom: index === nodeValue.length - 1 ? 0 : 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
|
||||
<Text style={{ color: '#94a3b8', fontSize: 12, marginTop: 7, minWidth: 28 }}>{index + 1}.</Text>
|
||||
<div style={{ flex: 1 }}>
|
||||
{typeof item === 'object' && item !== null ? (
|
||||
renderObjectFields(item, [...path, index], depth + 1)
|
||||
) : locked ? (
|
||||
<ReadonlyBlock value={item} />
|
||||
) : (
|
||||
<EditableInput value={item} onChange={(nextText) => updatePath([...path, index], nextText)} />
|
||||
)}
|
||||
</div>
|
||||
{editableArray && (
|
||||
<Button size="small" type="text" danger onClick={() => onChange(removeArrayItem(safeValue, path, index))}>
|
||||
删除
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* {(rootKey === '动作流程' || rootKey === '镜头流程') && (
|
||||
<Text style={{ display: 'block', marginTop: 6, color: '#94a3b8', fontSize: 12 }}>
|
||||
时间段和条目数量由后端锁定,只允许编辑每段里的动作、镜头、说明等内容。
|
||||
</Text>
|
||||
)} */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof nodeValue === 'object' && nodeValue !== null) {
|
||||
const lockedSection = locked || LOCKED_TOP_LEVEL_KEYS.has(String(key));
|
||||
rows.map((item, index) => {
|
||||
const row = isRecord(item) ? item : {};
|
||||
return (
|
||||
<div key={path.join('.')} style={{ marginBottom: 18 }}>
|
||||
<Space style={{ marginBottom: 8 }}>
|
||||
<Text strong style={{ color: '#334155', fontSize: 13 }}>{fieldTitle(key)}</Text>
|
||||
{/* {lockedSection && <Tag color="default">只读</Tag>} */}
|
||||
</Space>
|
||||
<div
|
||||
style={{
|
||||
borderLeft: depth === 0 ? '3px solid #6366f1' : '2px solid #e2e8f0',
|
||||
paddingLeft: 12,
|
||||
marginLeft: 4,
|
||||
background: lockedSection ? '#f8fafc' : 'transparent',
|
||||
}}
|
||||
>
|
||||
{renderObjectFields(nodeValue, path, depth + 1)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={path.join('.')} style={{ marginBottom: 12 }}>
|
||||
<div key={`${section.key}-${index}`} style={{ marginBottom: index === rows.length - 1 ? 0 : 14, paddingBottom: index === rows.length - 1 ? 0 : 14, borderBottom: index === rows.length - 1 ? 'none' : '1px dashed #e2e8f0' }}>
|
||||
<Text strong style={{ display: 'block', marginBottom: 8, color: '#475569', fontSize: 12 }}>第 {index + 1} 段</Text>
|
||||
{displayFields.map((field) => (
|
||||
<div key={field.key} style={{ marginBottom: 10 }}>
|
||||
<Space style={{ marginBottom: 4 }}>
|
||||
<Text style={{ color: '#64748b', fontSize: 12 }}>{fieldTitle(key)}</Text>
|
||||
{/* {locked && <Tag color="default">只读12312</Tag>} */}
|
||||
<Text style={{ color: '#64748b', fontSize: 12 }}>{field.label}</Text>
|
||||
{renderFieldTag(field)}
|
||||
</Space>
|
||||
{locked ? (
|
||||
<ReadonlyBlock value={nodeValue} />
|
||||
// <></>
|
||||
{field.editable ? (
|
||||
<EditableInput
|
||||
value={row[field.key]}
|
||||
maxLength={field.maxLength}
|
||||
onChange={(nextText) => onChange(setArrayObjectField(safeValue, section.key, index, field.key, nextText))}
|
||||
/>
|
||||
) : (
|
||||
<EditableInput value={nodeValue} onChange={(nextText) => updatePath(path, nextText)} />
|
||||
<ReadonlyBlock value={row[field.key]} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderObjectFields = (objectValue: Record<string, JsonValue>, parentPath: Array<string | number>, depth = 0): React.ReactNode => {
|
||||
return Object.entries(objectValue).map(([childKey, childValue]) => renderNode(childKey, childValue, [...parentPath, childKey], depth));
|
||||
const renderPrimitiveSection = (section: VideoPromptSchemaSectionConfig) => {
|
||||
if (!(section.key in safeValue)) return null;
|
||||
return (
|
||||
<div key={section.key} style={{ marginBottom: 18 }}>
|
||||
<Space style={{ marginBottom: 8 }}>
|
||||
<Text strong style={{ color: '#334155', fontSize: 13 }}>{section.label}</Text>
|
||||
{renderFieldTag(section)}
|
||||
</Space>
|
||||
{section.editable ? (
|
||||
<EditableInput
|
||||
value={safeValue[section.key]}
|
||||
maxLength={section.maxLength}
|
||||
onChange={(nextText) => {
|
||||
const next = clonePlain(safeValue);
|
||||
next[section.key] = nextText;
|
||||
onChange(next);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ReadonlyBlock value={safeValue[section.key]} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* <div style={{ marginBottom: 14, padding: 12, borderRadius: 10, background: '#f8fafc', color: '#64748b', fontSize: 13, lineHeight: 1.7 }}>
|
||||
视频时长、比例、清晰度、帧率、推荐分辨率、动态时间规划、输出规格、质量控制、合规控制、schema 协议字段为只读;动作/镜头流程保留原时间段和条目数量,只允许编辑内容描述。
|
||||
</div> */}
|
||||
{Object.entries(safeValue)
|
||||
.filter(([key]) => !HIDDEN_TOP_LEVEL_KEYS.has(key))
|
||||
.map(([key, childValue]) => renderNode(key, childValue, [key]))}
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 14 }}
|
||||
message="仅可修改后台 Schema 配置允许编辑的字段"
|
||||
description="视频规格、时间段、流程条目数量、输出规格、质量控制、合规控制等锁定内容由服务端最终校验。"
|
||||
/>
|
||||
{sections.map((section) => {
|
||||
if (!(section.key in safeValue)) return null;
|
||||
if (section.type === 'object') return renderObjectSection(section);
|
||||
if (section.type === 'flow') return renderFlowSection(section);
|
||||
return renderPrimitiveSection(section);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ArrowLeftOutlined, PlayCircleOutlined, CheckCircleOutlined, EditOutline
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { getReplicationList, getReplicationDetail, gettwo, getthree, getfour, getEngine, updateHotOpeningVideoPromptSchema, updateImagePrompt } from '../api/index';
|
||||
import VideoPromptSchemaEditor from '../components/VideoPromptSchemaEditor';
|
||||
import { validateVideoPromptSchemaByConfig } from '../utils/videoPromptSchema';
|
||||
import './css/InitialInfo.css';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
@@ -22,6 +23,7 @@ function InitialInfo() {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [currentType, setCurrentType] = useState<string>('image');
|
||||
const [formData, setFormData] = useState<any>({});
|
||||
const [videoSchemaConfigSnapshot, setVideoSchemaConfigSnapshot] = useState<any>(null);
|
||||
const [editingPromptStepId, setEditingPromptStepId] = useState<string>('');
|
||||
const [promptSaving, setPromptSaving] = useState(false);
|
||||
const [pollingTimer, setPollingTimer] = useState<any>(null);
|
||||
@@ -187,14 +189,16 @@ function InitialInfo() {
|
||||
}
|
||||
}, [steps]);
|
||||
|
||||
const handleOpenModal = (prompt?: any, type?: string, stepId?: string | number) => {
|
||||
const handleOpenModal = (prompt?: any, type?: string, stepId?: string | number, schemaConfigSnapshot?: any) => {
|
||||
setCurrentType(type || 'image');
|
||||
setEditingPromptStepId(stepId ? String(stepId) : '');
|
||||
|
||||
if (type === 'video' && typeof prompt === 'object' && prompt) {
|
||||
setFormData(clonePlain(prompt));
|
||||
if (type === 'video') {
|
||||
setVideoSchemaConfigSnapshot(schemaConfigSnapshot || null);
|
||||
setFormData(prompt && typeof prompt === 'object' ? clonePlain(prompt) : {});
|
||||
setPromptText('');
|
||||
} else {
|
||||
setVideoSchemaConfigSnapshot(null);
|
||||
setPromptText(prompt || '');
|
||||
setFormData({});
|
||||
}
|
||||
@@ -237,6 +241,15 @@ function InitialInfo() {
|
||||
message.warning('视频提示词不能为空');
|
||||
return;
|
||||
}
|
||||
if (!videoSchemaConfigSnapshot) {
|
||||
message.warning('视频提词配置缺失,暂不能保存');
|
||||
return;
|
||||
}
|
||||
const schemaError = validateVideoPromptSchemaByConfig(formData, videoSchemaConfigSnapshot);
|
||||
if (schemaError) {
|
||||
message.warning(schemaError);
|
||||
return;
|
||||
}
|
||||
|
||||
setPromptSaving(true);
|
||||
try {
|
||||
@@ -1058,19 +1071,19 @@ function InitialInfo() {
|
||||
{step.childId === 4 && (
|
||||
<>
|
||||
<div style={{ padding: '12px 14px', background: 'rgba(255,255,255,0.6)', borderRadius: 10, border: '1px solid rgba(99, 102, 241, 0.08)', marginBottom: 4 }}>
|
||||
<Text style={{ color: '#475569', fontSize: 13, lineHeight: 1.8, whiteSpace: 'pre-wrap' }}>
|
||||
{step?.output?.payload?.finalPrompt || '暂无提示词'}
|
||||
<Text style={{ color: '#64748b', fontSize: 13, lineHeight: 1.8 }}>
|
||||
视频提词已生成,可点击按钮查看并修改后台 Schema 允许编辑的字段。
|
||||
</Text>
|
||||
</div>
|
||||
<Space style={{ marginTop: 16, gap: 12, width: '100%' }}>
|
||||
<Button
|
||||
type="default"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleOpenModal(step?.output?.payload?.promptSchema, 'video', step.id)}
|
||||
onClick={() => handleOpenModal(taskDetail?.videoGeneration?.promptSchema, 'video', step.id, taskDetail?.videoGeneration?.schemaConfigSnapshot)}
|
||||
style={{ flex: 1, borderRadius: 10, borderColor: 'rgba(99, 102, 241, 0.3)', color: '#6366f1', height: 36, fontWeight: 500, background: 'rgba(99, 102, 241, 0.04)' }}
|
||||
disabled={step.status !== 'completed'}
|
||||
disabled={step.status !== 'completed' || !taskDetail?.videoGeneration?.promptSchema || !taskDetail?.videoGeneration?.schemaConfigSnapshot}
|
||||
>
|
||||
修改提示词
|
||||
查看视频提词
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => { message.info('正在生成视频,请稍候...'); createvideo(step.id, step.engineId); }}
|
||||
@@ -1143,7 +1156,7 @@ function InitialInfo() {
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div style={{ width: 4, height: 18, background: 'linear-gradient(180deg, #6366f1 0%, #8b5cf6 100%)', borderRadius: 2 }} />
|
||||
<span style={{ fontSize: 15, fontWeight: 700, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent', backgroundClip: 'text' }}>
|
||||
{currentType === 'video' ? '修改视频提示词' : '修改提示词'}
|
||||
{currentType === 'video' ? '查看/编辑视频提词' : '修改提示词'}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
@@ -1170,7 +1183,7 @@ function InitialInfo() {
|
||||
/>
|
||||
) : (
|
||||
<div style={{ maxHeight: 500, overflowY: 'auto', paddingRight: 10 }}>
|
||||
<VideoPromptSchemaEditor value={formData} onChange={setFormData} />
|
||||
<VideoPromptSchemaEditor value={formData} schemaConfigSnapshot={videoSchemaConfigSnapshot} onChange={setFormData} />
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
@@ -4,11 +4,16 @@ import { ArrowLeftOutlined, PlayCircleOutlined, CheckCircleOutlined, EditOutline
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { getShotReplicationList, removeDetail, removeone, removetwo, removethree, removefour, getEngine, updateShotImagePrompt, updateShotVideoPromptSchema } from '../api/index';
|
||||
import VideoPromptSchemaEditor from '../components/VideoPromptSchemaEditor';
|
||||
import { validateVideoPromptSchemaByConfig } from '../utils/videoPromptSchema';
|
||||
import './css/InitialInfo.css';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { TextArea } = Input;
|
||||
|
||||
function clonePlain<T>(value: T): T {
|
||||
return value === undefined ? value : JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function InitialInfo() {
|
||||
const navigate = useNavigate();
|
||||
const { creatID } = useParams<{ creatID: string }>();
|
||||
@@ -18,6 +23,7 @@ function InitialInfo() {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [currentType, setCurrentType] = useState<string>('image');
|
||||
const [formData, setFormData] = useState<any>({});
|
||||
const [videoSchemaConfigSnapshot, setVideoSchemaConfigSnapshot] = useState<any>(null);
|
||||
const [pollingTimer, setPollingTimer] = useState<any>(null);
|
||||
const [editingPromptStepId, setEditingPromptStepId] = useState('');
|
||||
const [promptSaving, setPromptSaving] = useState(false);
|
||||
@@ -186,14 +192,16 @@ function InitialInfo() {
|
||||
}
|
||||
}, [steps]);
|
||||
|
||||
const handleOpenModal = (prompt?: any, type?: string, stepId?: string | number) => {
|
||||
const handleOpenModal = (prompt?: any, type?: string, stepId?: string | number, schemaConfigSnapshot?: any) => {
|
||||
setCurrentType(type || 'image');
|
||||
setEditingPromptStepId(stepId ? String(stepId) : '');
|
||||
|
||||
if (type === 'video' && typeof prompt === 'object') {
|
||||
setFormData(prompt);
|
||||
if (type === 'video') {
|
||||
setVideoSchemaConfigSnapshot(schemaConfigSnapshot || null);
|
||||
setFormData(prompt && typeof prompt === 'object' ? clonePlain(prompt) : {});
|
||||
setPromptText('');
|
||||
} else {
|
||||
setVideoSchemaConfigSnapshot(null);
|
||||
setPromptText(prompt || '');
|
||||
setFormData({});
|
||||
}
|
||||
@@ -236,6 +244,15 @@ function InitialInfo() {
|
||||
message.warning('视频提示词不能为空');
|
||||
return;
|
||||
}
|
||||
if (!videoSchemaConfigSnapshot) {
|
||||
message.warning('视频提词配置缺失,暂不能保存');
|
||||
return;
|
||||
}
|
||||
const schemaError = validateVideoPromptSchemaByConfig(formData, videoSchemaConfigSnapshot);
|
||||
if (schemaError) {
|
||||
message.warning(schemaError);
|
||||
return;
|
||||
}
|
||||
|
||||
setPromptSaving(true);
|
||||
try {
|
||||
@@ -1057,19 +1074,19 @@ function InitialInfo() {
|
||||
{step.childId === 4 && (
|
||||
<>
|
||||
<div style={{ padding: '12px 14px', background: 'rgba(255,255,255,0.6)', borderRadius: 10, border: '1px solid rgba(99, 102, 241, 0.08)', marginBottom: 4 }}>
|
||||
<Text style={{ color: '#475569', fontSize: 13, lineHeight: 1.8, whiteSpace: 'pre-wrap' }}>
|
||||
{step?.output?.payload?.finalPrompt || '暂无提示词'}
|
||||
<Text style={{ color: '#64748b', fontSize: 13, lineHeight: 1.8 }}>
|
||||
视频提词已生成,可点击按钮查看并修改后台 Schema 允许编辑的字段。
|
||||
</Text>
|
||||
</div>
|
||||
<Space style={{ marginTop: 16, gap: 12, width: '100%' }}>
|
||||
<Button
|
||||
type="default"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleOpenModal(step?.output?.payload?.promptSchema, 'video', step.id)}
|
||||
onClick={() => handleOpenModal(taskDetail?.videoGeneration?.promptSchema, 'video', step.id, taskDetail?.videoGeneration?.schemaConfigSnapshot)}
|
||||
style={{ flex: 1, borderRadius: 10, borderColor: 'rgba(99, 102, 241, 0.3)', color: '#6366f1', height: 36, fontWeight: 500, background: 'rgba(99, 102, 241, 0.04)' }}
|
||||
disabled={step.status !== 'completed'}
|
||||
disabled={step.status !== 'completed' || !taskDetail?.videoGeneration?.promptSchema || !taskDetail?.videoGeneration?.schemaConfigSnapshot}
|
||||
>
|
||||
修改提示词
|
||||
查看视频提词
|
||||
</Button>
|
||||
<Button onClick={() => { message.info('正在生成视频,请稍候...'); createvideo(step.id, step.engineId); }} type="primary" style={{ flex: 1, borderRadius: 10, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none', height: 36, fontWeight: 500, boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)' }} disabled={step.status !== 'completed'}>
|
||||
下一步:生成视频
|
||||
@@ -1131,7 +1148,7 @@ function InitialInfo() {
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div style={{ width: 4, height: 18, background: 'linear-gradient(180deg, #6366f1 0%, #8b5cf6 100%)', borderRadius: 2 }} />
|
||||
<span style={{ fontSize: 15, fontWeight: 700, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent', backgroundClip: 'text' }}>
|
||||
{currentType === 'video' ? '修改视频提示词' : '修改提示词'}
|
||||
{currentType === 'video' ? '查看/编辑视频提词' : '修改提示词'}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
@@ -1158,7 +1175,7 @@ function InitialInfo() {
|
||||
/>
|
||||
) : (
|
||||
<div style={{ maxHeight: 500, overflowY: 'auto', paddingRight: 10 }}>
|
||||
<VideoPromptSchemaEditor value={formData} onChange={setFormData} />
|
||||
<VideoPromptSchemaEditor value={formData} schemaConfigSnapshot={videoSchemaConfigSnapshot} onChange={setFormData} />
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
export type JsonValue = any;
|
||||
export type JsonRecord = Record<string, JsonValue>;
|
||||
|
||||
export interface VideoPromptSchemaFieldConfig {
|
||||
key: string;
|
||||
label: string;
|
||||
type?: string;
|
||||
enabled: boolean;
|
||||
editable: boolean;
|
||||
maxLength?: number;
|
||||
}
|
||||
|
||||
export interface VideoPromptSchemaSectionConfig extends VideoPromptSchemaFieldConfig {
|
||||
contentKey?: string;
|
||||
itemFields: VideoPromptSchemaFieldConfig[];
|
||||
children: VideoPromptSchemaFieldConfig[];
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is JsonRecord {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readString(source: JsonRecord, ...keys: string[]): string {
|
||||
for (const key of keys) {
|
||||
const value = source[key];
|
||||
if (value !== undefined && value !== null && String(value).trim() !== '') return String(value);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function readBool(source: JsonRecord, defaultValue: boolean, ...keys: string[]): boolean {
|
||||
for (const key of keys) {
|
||||
const value = source[key];
|
||||
if (value === true || value === false) return value;
|
||||
if (value === 'true') return true;
|
||||
if (value === 'false') return false;
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
function readNumber(source: JsonRecord, ...keys: string[]): number | undefined {
|
||||
for (const key of keys) {
|
||||
const value = source[key];
|
||||
if (typeof value === 'number' && Number.isFinite(value) && value > 0) return value;
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeField(raw: unknown): VideoPromptSchemaFieldConfig | null {
|
||||
if (!isRecord(raw)) return null;
|
||||
const key = readString(raw, 'key');
|
||||
if (!key) return null;
|
||||
return {
|
||||
key,
|
||||
label: readString(raw, 'label') || key,
|
||||
type: readString(raw, 'type') || undefined,
|
||||
enabled: readBool(raw, true, 'enabled'),
|
||||
editable: readBool(raw, true, 'editable'),
|
||||
maxLength: readNumber(raw, 'maxLength', 'max_length'),
|
||||
};
|
||||
}
|
||||
|
||||
export function getSchemaConfigData(snapshot: unknown): JsonRecord | null {
|
||||
if (!isRecord(snapshot)) return null;
|
||||
const data = snapshot.data;
|
||||
if (isRecord(data)) return data;
|
||||
if (Array.isArray(snapshot.sections)) return snapshot;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function hasSchemaConfigSnapshot(snapshot: unknown): boolean {
|
||||
return !!getSchemaConfigData(snapshot);
|
||||
}
|
||||
|
||||
export function normalizeSchemaSections(snapshot: unknown): VideoPromptSchemaSectionConfig[] {
|
||||
const data = getSchemaConfigData(snapshot);
|
||||
const rawSections = Array.isArray(data?.sections) ? data.sections : [];
|
||||
return rawSections
|
||||
.map((raw): VideoPromptSchemaSectionConfig | null => {
|
||||
const base = normalizeField(raw);
|
||||
if (!base || !isRecord(raw)) return null;
|
||||
const children = Array.isArray(raw.children) ? raw.children.map(normalizeField).filter(Boolean) as VideoPromptSchemaFieldConfig[] : [];
|
||||
const itemFields = Array.isArray(raw.itemFields)
|
||||
? raw.itemFields.map(normalizeField).filter(Boolean) as VideoPromptSchemaFieldConfig[]
|
||||
: Array.isArray(raw.item_fields)
|
||||
? raw.item_fields.map(normalizeField).filter(Boolean) as VideoPromptSchemaFieldConfig[]
|
||||
: [];
|
||||
return {
|
||||
...base,
|
||||
contentKey: readString(raw, 'contentKey', 'content_key') || undefined,
|
||||
children,
|
||||
itemFields,
|
||||
};
|
||||
})
|
||||
.filter((section): section is VideoPromptSchemaSectionConfig => !!section && section.enabled);
|
||||
}
|
||||
|
||||
export function stringifySchemaValue(value: JsonValue): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (typeof value === 'object') {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function shouldUseSchemaTextArea(value: JsonValue): boolean {
|
||||
const text = stringifySchemaValue(value);
|
||||
return text.length > 40 || text.includes('\n') || text.includes(',') || text.includes('。') || text.includes(';') || text.includes(';');
|
||||
}
|
||||
|
||||
export function clonePlain<T>(value: T): T {
|
||||
return value === undefined ? value : JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
export function setObjectField(root: JsonRecord, sectionKey: string, fieldKey: string, value: JsonValue): JsonRecord {
|
||||
const next = clonePlain(root || {});
|
||||
if (!isRecord(next[sectionKey])) next[sectionKey] = {};
|
||||
(next[sectionKey] as JsonRecord)[fieldKey] = value;
|
||||
return next;
|
||||
}
|
||||
|
||||
export function setArrayObjectField(root: JsonRecord, sectionKey: string, index: number, fieldKey: string, value: JsonValue): JsonRecord {
|
||||
const next = clonePlain(root || {});
|
||||
if (!Array.isArray(next[sectionKey])) next[sectionKey] = [];
|
||||
if (!isRecord(next[sectionKey][index])) next[sectionKey][index] = {};
|
||||
next[sectionKey][index][fieldKey] = value;
|
||||
return next;
|
||||
}
|
||||
|
||||
export function validateVideoPromptSchemaByConfig(schema: unknown, snapshot: unknown): string | null {
|
||||
if (!isRecord(schema)) return '视频提示词不能为空';
|
||||
const sections = normalizeSchemaSections(snapshot);
|
||||
if (!sections.length) return '视频提词配置缺失,无法保存';
|
||||
|
||||
for (const section of sections) {
|
||||
if (!section.editable) continue;
|
||||
const sectionValue = schema[section.key];
|
||||
if (section.type === 'object') {
|
||||
if (!isRecord(sectionValue)) continue;
|
||||
for (const child of section.children) {
|
||||
if (!child.enabled || !child.editable || !child.maxLength) continue;
|
||||
const text = stringifySchemaValue(sectionValue[child.key]);
|
||||
if (text.length > child.maxLength) {
|
||||
return `字段【${section.label}.${child.label}】长度不能超过 ${child.maxLength} 个字符`;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (section.type === 'flow') {
|
||||
if (!Array.isArray(sectionValue)) continue;
|
||||
for (let index = 0; index < sectionValue.length; index += 1) {
|
||||
const item = sectionValue[index];
|
||||
if (!isRecord(item)) continue;
|
||||
for (const field of section.itemFields) {
|
||||
if (!field.enabled || !field.editable || !field.maxLength) continue;
|
||||
const text = stringifySchemaValue(item[field.key]);
|
||||
if (text.length > field.maxLength) {
|
||||
return `字段【${section.label} 第 ${index + 1} 项.${field.label}】长度不能超过 ${field.maxLength} 个字符`;
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (section.editable && section.maxLength) {
|
||||
const text = stringifySchemaValue(sectionValue);
|
||||
if (text.length > section.maxLength) {
|
||||
return `字段【${section.label}】长度不能超过 ${section.maxLength} 个字符`;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user