视频提示SCHEMA管理后台配置联动修改三端
This commit is contained in:
@@ -1,283 +1,209 @@
|
||||
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 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>} */}
|
||||
</Space>
|
||||
{editableArray && (
|
||||
<Button size="small" type="link" onClick={() => onChange(addArrayItem(safeValue, path, sample))}>
|
||||
+ 添加
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ border: '1px solid #e5e7eb', borderRadius: 10, padding: 12, background: locked ? '#f8fafc' : '#fff' }}>
|
||||
{nodeValue.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));
|
||||
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>
|
||||
);
|
||||
}
|
||||
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;
|
||||
|
||||
return (
|
||||
<div key={path.join('.')} style={{ marginBottom: 12 }}>
|
||||
<Space style={{ marginBottom: 4 }}>
|
||||
<Text style={{ color: '#64748b', fontSize: 12 }}>{fieldTitle(key)}</Text>
|
||||
{/* {locked && <Tag color="default">只读12312</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>
|
||||
{locked ? (
|
||||
<ReadonlyBlock value={nodeValue} />
|
||||
// <></>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
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>
|
||||
) : (
|
||||
rows.map((item, index) => {
|
||||
const row = isRecord(item) ? item : {};
|
||||
return (
|
||||
<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 }}>{field.label}</Text>
|
||||
{renderFieldTag(field)}
|
||||
</Space>
|
||||
{field.editable ? (
|
||||
<EditableInput
|
||||
value={row[field.key]}
|
||||
maxLength={field.maxLength}
|
||||
onChange={(nextText) => onChange(setArrayObjectField(safeValue, section.key, index, field.key, nextText))}
|
||||
/>
|
||||
) : (
|
||||
<ReadonlyBlock value={row[field.key]} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<EditableInput value={nodeValue} onChange={(nextText) => updatePath(path, nextText)} />
|
||||
<ReadonlyBlock value={safeValue[section.key]} />
|
||||
)}
|
||||
</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));
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoPromptSchemaEditor;
|
||||
export default VideoPromptSchemaEditor;
|
||||
Reference in New Issue
Block a user