视频提示SCHEMA管理后台配置联动修改三端

This commit is contained in:
2026-06-22 16:49:05 +08:00
parent 58b1372232
commit 270628049b
17 changed files with 1141 additions and 932 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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>
),
@@ -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>
+4
View File
@@ -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
View File
@@ -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>
);
};
+23 -10
View File
@@ -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>
+27 -10
View File
@@ -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;
}