refactor: 中间栏三层架构 — useUI 组件体系 + useModelUI Hook

- 新建 useUI/ 目录:12 个文件,10 个 Qt 控件 → antd 组件封装 + 类型 + 注册表
- 新建 useModelUI Hook:param_schema → UIFieldConfig[] 自动映射
- ModelInputForm 瘦身 ~160 行:删除 buildParamDescriptors/renderParamControl/文件状态管理
- WIDGET_MAP: Record<string, ComponentType<WidgetProps>> — 零 any,完全类型安全
- 新增控件只需两步:创建组件文件 + 在 WIDGET_MAP 加一行
- 删除旧 widgets/ 目录

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 15:32:17 +08:00
parent 76e66a37c8
commit d5ba7e63cd
14 changed files with 733 additions and 339 deletions

View File

@@ -1,126 +1,37 @@
// ============================================================ // ============================================================
// ModelInputForm — 动态模型输入表单 // ModelInputForm — 模型输入表单(纯布局外壳)
// 根据选中模型的 param_schema + ui_config 动态渲染表单控件 //
// 布局Header模型信息+meta → Bodyfields.map 渲染) → Footer价格+提交)
//
// 与旧版的区别:
// - 控件渲染逻辑已提取到 useUI/ 组件体系
// - param_schema 解析已提取到 useModelUI Hook
// - 本组件只负责布局、Form 容器、提交逻辑
// ============================================================ // ============================================================
import { useState, useEffect, useMemo, useCallback } from 'react'; import { useEffect, useMemo, useCallback } from 'react';
import { import {
Form, Form,
Input,
InputNumber,
Select,
Slider,
Button, Button,
Typography, Typography,
Empty, Empty,
Upload, Tag,
Switch, Space,
theme as antTheme, theme as antTheme,
message, message,
} from 'antd'; } from 'antd';
import { import { SendOutlined, ClockCircleOutlined } from '@ant-design/icons';
SendOutlined,
UploadOutlined,
InboxOutlined,
} from '@ant-design/icons';
import type { UploadFile } from 'antd/es/upload'; import type { UploadFile } from 'antd/es/upload';
import { useHomeContext } from '@/pages/home/HomeContent'; import { useHomeContext } from '@/pages/home/HomeContent';
import { useAppContext } from '@/components/AppProvider'; import { useAppContext } from '@/components/AppProvider';
import { useSubmitTask } from '@/hooks/use-api'; import { useSubmitTask } from '@/hooks/use-api';
import { emit, EVENTS } from '@/utils/event-bus'; import { emit, EVENTS } from '@/utils/event-bus';
import type { ModelsListResponseBody } from '@/services/modules/models'; import { OutputTypeMap } from '@/services/modules/task';
import type { TaskInfoItemResponseBody } from '@/services/modules/task'; import type { OutputTypeString, TaskInfoItemResponseBody } from '@/services/modules/task';
import { useModelUI, type UIFieldConfig } from './useModelUI';
const { Text, Title } = Typography; const { Text, Title } = Typography;
const { TextArea } = Input;
const { Dragger } = Upload;
// ---------- 参数类型映射 ----------
/** 从 ui_config 中解析的参数描述 */
interface ParamDescriptor {
/** React keyitem.id保证唯一 */
key: string;
/** API 字段名item.map_to */
name: string;
label: string;
/** 控件类型 */
controlType: 'text' | 'number' | 'textarea' | 'select' | 'slider' | 'switch' | 'image-upload';
defaultValue?: unknown;
required?: boolean;
placeholder?: string;
/** 数值型:最小值 */
min?: number;
/** 数值型:最大值 */
max?: number;
/** 数值型:步长 */
step?: number;
/** select 型:选项列表 */
options?: Array<{ label: string; value: string | number }>;
}
/** 从 ui_config 对象中解析控件类型 */
function inferControlType(config: Record<string, unknown>): ParamDescriptor['controlType'] {
const type = config.type as string | undefined;
if (!type) return 'text';
switch (type) {
case 'number':
case 'integer':
case 'float':
return 'number';
case 'textarea':
case 'text_area':
case 'prompt':
return 'textarea';
case 'select':
case 'dropdown':
case 'enum':
return 'select';
case 'slider':
case 'range':
return 'slider';
case 'switch':
case 'boolean':
case 'bool':
return 'switch';
case 'image':
case 'image-upload':
case 'file':
return 'image-upload';
default:
return 'text';
}
}
/**
* 从 param_schema 构建参数描述符列表
*
* 后端 param_schema 格式为 ParamSchemaItem[](对象数组),
* 每项的 ui 字段内嵌了控件类型、标签、校验规则等 UI 配置。
*/
function buildParamDescriptors(
paramSchema: ModelsListResponseBody['param_schema'],
): ParamDescriptor[] {
return paramSchema.map((item) => {
const config = (item.ui || {}) as Record<string, unknown>;
const controlType = inferControlType(config);
return {
key: item.id,
name: item.map_to,
label: (config.label as string) || item.map_to,
controlType,
defaultValue: config.default ?? config.defaultValue,
required: (config.required as boolean) || false,
placeholder: (config.placeholder as string) || `请输入${config.label || item.map_to}`,
min: config.min as number | undefined,
max: config.max as number | undefined,
step: config.step as number | undefined,
options: (config.options as ParamDescriptor['options']) || undefined,
};
});
}
// ---------- 组件 ---------- // ---------- 组件 ----------
@@ -129,35 +40,39 @@ export function ModelInputForm() {
const { isLoggedIn } = useAppContext(); const { isLoggedIn } = useAppContext();
const { selectedModel } = useHomeContext(); const { selectedModel } = useHomeContext();
const [form] = Form.useForm(); const [form] = Form.useForm();
const [referenceFiles, setReferenceFiles] = useState<UploadFile[]>([]);
// 提交突变 Hook(自动处理 loading / error / 日志) // 提交突变 Hook
const { execute: submitTask, loading: submitting, errorMessage } = useSubmitTask({ const { execute: submitTask, loading: submitting, errorMessage } = useSubmitTask({
onSuccess: useCallback( onSuccess: useCallback((_data: TaskInfoItemResponseBody) => {
(_data: TaskInfoItemResponseBody) => {
message.success('任务已提交'); message.success('任务已提交');
// 通过事件总线通知 TaskHistory 刷新(替代 Context 中的 refreshTaskSignal
emit(EVENTS.TASK_SUBMITTED); emit(EVENTS.TASK_SUBMITTED);
}, }, []),
[],
),
}); });
// 选中模型时重置表单(仅当有选中模型时操作,避免 form 未连接警告) // ======== useModelUIparam_schema → UIFieldConfig[] ========
const fields: UIFieldConfig[] = useModelUI(selectedModel?.param_schema || []);
// 构建初始值
const initialValues = useMemo(() => {
const iv: Record<string, unknown> = {};
fields.forEach((f) => {
if (f.defaultValue !== undefined && f.defaultValue !== null) {
iv[f.name] = f.defaultValue;
}
});
return iv;
}, [fields]);
// 选中模型时重置表单
useEffect(() => { useEffect(() => {
if (selectedModel) { if (selectedModel) {
form.resetFields(); form.resetFields();
} }
setReferenceFiles([]);
}, [selectedModel?.id]); // eslint-disable-line react-hooks/exhaustive-deps }, [selectedModel?.id]); // eslint-disable-line react-hooks/exhaustive-deps
// 参数描述符 // ======== 提交 ========
const paramDescriptors = useMemo(() => {
if (!selectedModel) return [];
return buildParamDescriptors(selectedModel.param_schema || []);
}, [selectedModel]);
// 提交
const handleSubmit = async () => { const handleSubmit = async () => {
if (!isLoggedIn) { if (!isLoggedIn) {
message.warning('请先登录后再提交任务'); message.warning('请先登录后再提交任务');
@@ -168,128 +83,47 @@ export function ModelInputForm() {
try { try {
const values = await form.validateFields(); const values = await form.validateFields();
// 分离固定字段和模型动态参数 // 将表单值全部转为字符串CreatTaskRequestBody.params 为 Record<string, string>
const { prompt, negative_prompt, num_outputs, ...dynamicParams } = values; const params: Record<string, string> = {};
// 构建提交参数(全部转为字符串值) for (const field of fields) {
const params: Record<string, string> = { const value = values[field.name];
prompt: String(prompt ?? ''), if (value === undefined || value === null) continue;
negative_prompt: String(negative_prompt ?? ''),
num_outputs: String(num_outputs ?? 1),
};
// 动态参数统一转字符串 // 文件上传 → 提取 URL 数组 → JSON 字符串
for (const [key, value] of Object.entries(dynamicParams)) { if (field.isFileWidget) {
if (value !== undefined && value !== null) { const fileList = value as UploadFile[];
params[key] = String(value); const urls = fileList
}
}
// 参考图
if (referenceFiles.length > 0) {
const refUrls = referenceFiles
.filter((f) => f.status === 'done') .filter((f) => f.status === 'done')
.map((f) => f.response?.url || f.url || f.name) .map((f) => f.response?.url || f.url || f.name)
.filter(Boolean) as string[]; .filter(Boolean) as string[];
if (refUrls.length > 0) { if (urls.length > 0) {
params.reference_images = JSON.stringify(refUrls); params[field.name] = urls.length === 1 ? urls[0] : JSON.stringify(urls);
} }
continue;
}
// 布尔值 → "true"/"false"
if (typeof value === 'boolean') {
params[field.name] = String(value);
continue;
}
// 数值 / 字符串 → String
params[field.name] = String(value);
} }
// 使用 mutation Hook 提交(自动处理 loading / error / 日志)
await submitTask({ model_id: selectedModel.id, params }); await submitTask({ model_id: selectedModel.id, params });
} catch (err) { } catch (err) {
if (err && typeof err === 'object' && 'errorFields' in err) { if (err && typeof err === 'object' && 'errorFields' in err) {
// 表单验证错误antd 自动提示 return; // 表单验证错误antd 自动提示
return;
} }
// 错误消息由 Hook 自动通过 errorMessage 提供
message.error(errorMessage || '任务提交失败,请重试'); message.error(errorMessage || '任务提交失败,请重试');
} }
}; };
// 渲染单个参数控件 // ======== 未选择模型 ========
const renderParamControl = (desc: ParamDescriptor) => {
const commonProps = {
placeholder: desc.placeholder,
style: { width: '100%' },
};
switch (desc.controlType) {
case 'number':
return (
<InputNumber
{...commonProps}
min={desc.min}
max={desc.max}
step={desc.step}
style={{ width: '100%' }}
/>
);
case 'textarea':
return <TextArea rows={3} {...commonProps} />;
case 'select':
return (
<Select
{...commonProps}
options={desc.options}
allowClear
/>
);
case 'slider':
return (
<Slider
min={desc.min ?? 1}
max={desc.max ?? 100}
step={desc.step ?? 1}
marks={
desc.min !== undefined && desc.max !== undefined
? { [desc.min]: `${desc.min}`, [desc.max]: `${desc.max}` }
: undefined
}
tooltip={{ formatter: (v) => v }}
/>
);
case 'switch':
return <Switch />;
case 'image-upload':
return (
<Upload
listType="picture-card"
maxCount={1}
accept="image/png,image/jpg,image/jpeg,image/webp"
beforeUpload={(file) => {
const isImage = file.type.startsWith('image/');
const isLt10M = file.size / 1024 / 1024 < 10;
if (!isImage) {
message.error('只能上传图片文件');
return Upload.LIST_IGNORE;
}
if (!isLt10M) {
message.error('图片大小不能超过 10MB');
return Upload.LIST_IGNORE;
}
return false; // 手动上传
}}
>
<div>
<UploadOutlined />
<div style={{ marginTop: 4, fontSize: 12 }}></div>
</div>
</Upload>
);
default:
return <Input {...commonProps} />;
}
};
// ---- 未选择模型 ----
if (!selectedModel) { if (!selectedModel) {
return ( return (
<div <div
@@ -305,7 +139,24 @@ export function ModelInputForm() {
); );
} }
// ---- 表单 ---- // ======== 提取 meta 信息 ========
const metaConfig = (selectedModel.meta_config || {}) as Record<string, unknown>;
const uiConfig = (selectedModel.ui_config || {}) as Record<string, unknown>;
const pricingConfig = (selectedModel.pricing_config || {}) as Record<string, unknown>;
const outputType = metaConfig.output_type as OutputTypeString | undefined;
const estimatedDuration = metaConfig.estimated_duration_seconds as number | undefined;
const badge = uiConfig.badge as string | undefined;
const group = uiConfig.group as string | undefined;
const helpUrl = metaConfig.help_url as string | undefined;
const priceUnit = (pricingConfig.unit as Record<string, unknown>)?.name as string | undefined;
const price = pricingConfig.price as number | undefined;
const priceType = pricingConfig.price_type as string | undefined;
// ======== 正常渲染 ========
return ( return (
<div <div
style={{ style={{
@@ -315,7 +166,7 @@ export function ModelInputForm() {
overflow: 'hidden', overflow: 'hidden',
}} }}
> >
{/* 模型标题 */} {/* ======== Header模型信息 ======== */}
<div <div
style={{ style={{
padding: '16px 20px', padding: '16px 20px',
@@ -323,130 +174,131 @@ export function ModelInputForm() {
flexShrink: 0, flexShrink: 0,
}} }}
> >
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
{badge && <span style={{ fontSize: 20 }}>{badge}</span>}
<Title level={4} style={{ margin: 0, fontSize: 18 }}> <Title level={4} style={{ margin: 0, fontSize: 18 }}>
{selectedModel.name} {selectedModel.name}
</Title> </Title>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<Text type="secondary" style={{ fontSize: 12 }}> <Text type="secondary" style={{ fontSize: 12 }}>
{selectedModel.provider_name} · {selectedModel.category_name} {selectedModel.provider_name}
{group
? ` · ${group}`
: selectedModel.category_name
? ` · ${selectedModel.category_name}`
: ''}
</Text> </Text>
</div> </div>
{/* 可滚动表单区 */} <Space size={4} style={{ marginTop: 8 }} wrap>
{outputType && (
<Tag color="blue" style={{ fontSize: 11, lineHeight: '18px' }}>
{OutputTypeMap[outputType] || outputType}
</Tag>
)}
{estimatedDuration !== undefined && (
<Tag
icon={<ClockCircleOutlined />}
color="default"
style={{ fontSize: 11, lineHeight: '18px' }}
>
{estimatedDuration}
</Tag>
)}
{helpUrl && (
<a
href={helpUrl}
target="_blank"
rel="noopener noreferrer"
style={{ fontSize: 11 }}
>
使
</a>
)}
</Space>
</div>
{/* ======== Body参数列表useModelUI 驱动) ======== */}
<div style={{ flex: 1, overflow: 'auto', padding: '16px 20px' }}> <div style={{ flex: 1, overflow: 'auto', padding: '16px 20px' }}>
{fields.length === 0 ? (
<Empty
description="该模型无需额外参数"
image={Empty.PRESENTED_IMAGE_SIMPLE}
style={{ marginTop: 24 }}
/>
) : (
<Form <Form
form={form} form={form}
layout="vertical" layout="vertical"
size="small" size="small"
initialValues={{ num_outputs: 1 }} initialValues={initialValues}
> >
{/* ======== 固定字段 ======== */} {fields.map((field) => (
{/* 提示词 */}
<Form.Item <Form.Item
name="prompt" key={field.index}
label={<Text strong></Text>} name={field.name}
rules={[{ required: true, message: '请输入提示词' }]} label={
<span>
<Text strong={field.must}>{field.label}</Text>
{field.tips && (
<Text
type="secondary"
style={{ fontSize: 11, marginLeft: 6 }}
> >
<TextArea {field.tips.length > 60
rows={4} ? `${field.tips.slice(0, 60)}...`
placeholder="描述你想要的画面、视频或音频内容..." : field.tips}
showCount
maxLength={2000}
/>
</Form.Item>
{/* 负面提示词 */}
<Form.Item
name="negative_prompt"
label={<Text type="secondary"></Text>}
>
<TextArea
rows={2}
placeholder="描述你不希望在结果中出现的内容(可选)"
maxLength={1000}
/>
</Form.Item>
{/* 参考图 */}
<Form.Item label={<Text strong></Text>}>
<Dragger
multiple
listType="picture"
fileList={referenceFiles}
onChange={({ fileList }) => setReferenceFiles(fileList)}
accept="image/png,image/jpg,image/jpeg,image/webp"
beforeUpload={(file) => {
const isImage = file.type.startsWith('image/');
const isLt10M = file.size / 1024 / 1024 < 10;
if (!isImage) {
message.error('只能上传图片文件');
return Upload.LIST_IGNORE;
}
if (!isLt10M) {
message.error('图片大小不能超过 10MB');
return Upload.LIST_IGNORE;
}
return false;
}}
maxCount={5}
style={{ marginBottom: 8 }}
>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text"></p>
<p className="ant-upload-hint">
PNG / JPG / WebP 10MB
</p>
</Dragger>
</Form.Item>
{/* 生成数量 */}
<Form.Item
name="num_outputs"
label={<Text strong></Text>}
>
<InputNumber min={1} max={10} style={{ width: '100%' }} />
</Form.Item>
{/* ======== 模型动态参数 ======== */}
{paramDescriptors.length > 0 && (
<div
style={{
marginTop: 16,
paddingTop: 16,
borderTop: `1px solid ${token.colorBorderSecondary}`,
}}
>
<Text strong style={{ display: 'block', marginBottom: 12 }}>
</Text> </Text>
{paramDescriptors.map((desc) => ( )}
<Form.Item </span>
key={desc.key} }
name={desc.name} rules={
label={desc.label} field.must
rules={desc.required ? [{ required: true, message: `请输入${desc.label}` }] : undefined} ? [{ required: true, message: `请输入${field.label}` }]
initialValue={desc.defaultValue} : undefined
valuePropName={desc.controlType === 'switch' ? 'checked' : 'value'} }
valuePropName={field.valuePropName}
// 文件控件:从 Upload onChange 事件中提取 fileList
{...(field.isFileWidget
? { getValueFromEvent: (e: { fileList: UploadFile[] }) => e.fileList }
: {})}
> >
{renderParamControl(desc)} <field.component config={field.widgetConfig} />
</Form.Item> </Form.Item>
))} ))}
</div>
)}
</Form> </Form>
)}
</div> </div>
{/* 提交按钮 */} {/* ======== Footer价格 + 提交 ======== */}
<div <div
style={{ style={{
padding: '12px 20px', padding: '12px 20px',
borderTop: `1px solid ${token.colorBorderSecondary}`, borderTop: `1px solid ${token.colorBorderSecondary}`,
flexShrink: 0, flexShrink: 0,
display: 'flex',
alignItems: 'center',
gap: 12,
}} }}
> >
{price !== undefined && priceType === 'SIMPLE' && (
<Text type="secondary" style={{ fontSize: 12, whiteSpace: 'nowrap' }}>
¥{price}/{priceUnit || '次'}
</Text>
)}
{priceType === 'MULTI_DIMENSION' && (
<Text type="secondary" style={{ fontSize: 12, whiteSpace: 'nowrap' }}>
</Text>
)}
{priceType === 'BILLING_ADAPTER' && (
<Text type="secondary" style={{ fontSize: 12, whiteSpace: 'nowrap' }}>
</Text>
)}
<Button <Button
type="primary" type="primary"
icon={<SendOutlined />} icon={<SendOutlined />}

View File

@@ -0,0 +1,136 @@
// ============================================================
// useModelUI — param_schema → UIFieldConfig[] 转换 Hook
//
// 职责:
// - 遍历模型的 param_schema解析 spec/ui 字段
// - 查 WIDGET_MAP 获取对应的 React 组件
// - 组装出可直接渲染的 UIFieldConfig 数组
//
// ModelInputForm 只需遍历 fields 渲染 Form.Item 即可,
// 不需要关心 param_schema 的解析逻辑。
// ============================================================
import { useMemo } from 'react';
import type { ComponentType } from 'react';
import type { ModelsListResponseBody } from '@/services/modules/models';
import { WIDGET_MAP, FALLBACK_WIDGET } from './useUI';
import type { WidgetProps } from './useUI';
// ---------- 输出类型 ----------
/**
* 单个字段的 UI 配置。
*
* ModelInputForm 遍历此数组,每一项渲染一个 Form.Item。
*/
export interface UIFieldConfig {
/** 渲染顺序param_schema 中的索引,保证稳定) */
index: number;
/** 表单字段名map_to对应 Form.Item name */
name: string;
/** 要渲染的 React 组件 */
component: ComponentType<WidgetProps>;
/** 显示标签ui.label → Form.Item label */
label: string;
/** 是否必填spec.required → Form.Item rules */
must: boolean;
/** 默认值spec.default → Form initialValues */
defaultValue?: unknown;
/** 提示文本ui.tips */
tips?: string;
/** Form.Item 的 valuePropNamecheckbox 用 'checked',其他用默认 'value' */
valuePropName?: string;
/** 是否为文件上传类控件(需要 getValueFromEvent 提取 fileList */
isFileWidget: boolean;
/** 传给 widget 组件的配置placeholder/options/min/max 等) */
widgetConfig: Record<string, unknown>;
}
// ---------- 文件类型 widget 集合 ----------
const FILE_WIDGETS = new Set(['imageInput', 'imageList', 'audioList']);
// ---------- Hook ----------
/**
* 根据模型的 param_schema 构建 UI 字段配置数组。
*
* @param paramSchema — 来自 selectedModel.param_schema可为空数组
* @returns UIFieldConfig[] — 渲染就绪的字段配置列表
*
* 使用方式:
* ```tsx
* const fields = useModelUI(selectedModel?.param_schema || []);
* const initialValues = useMemo(() => {
* const iv: Record<string, unknown> = {};
* fields.forEach(f => { if (f.defaultValue !== undefined) iv[f.name] = f.defaultValue; });
* return iv;
* }, [fields]);
* ```
*/
export function useModelUI(
paramSchema: ModelsListResponseBody['param_schema'],
): UIFieldConfig[] {
return useMemo(() => {
if (!paramSchema || paramSchema.length === 0) return [];
return paramSchema.map((item, index) => {
const spec = (item.spec || {}) as Record<string, unknown>;
const ui = (item.ui || {}) as Record<string, unknown>;
const widget = (ui.widget as string) || 'lineEdit';
// 查找组件(未知 widget 用 LineInput 兜底)
const component = WIDGET_MAP[widget] || FALLBACK_WIDGET;
// 构建传给组件的配置
const widgetConfig: Record<string, unknown> = {
placeholder: ui.placeholder || `请输入${ui.label || item.map_to}`,
required: spec.required,
// comboBox
enumValues: spec.enum,
// spinBox / doubleSpinBox
minValue: spec.min_value,
maxValue: spec.max_value,
step: spec.single_step,
// textEdit / lineEdit
minLength: spec.min_length,
maxLength: spec.max_length,
// imageInput / imageList / audioList
fileFilter: spec.file_filter,
maxFileSizeMb: spec.max_file_size_mb,
minFiles: spec.min_files,
maxFiles: spec.max_files,
// imageInput
enableSwitch: ui.enable_switch,
};
const isFile = FILE_WIDGETS.has(widget);
const isCheckbox = widget === 'checkbox';
return {
index,
name: item.map_to,
component,
label: (ui.label as string) || item.map_to,
must: (spec.required as boolean) || false,
defaultValue: spec.default,
tips: (ui.tips as string) || undefined,
valuePropName: isCheckbox ? 'checked' : undefined,
isFileWidget: isFile,
widgetConfig,
};
});
}, [paramSchema]);
}
/**
* 判断一个 widget 类型是否为文件上传类控件。
*
* 文件控件在 Form.Item 中需要特殊处理:
* - getValueFromEvent 提取 e.fileList
* - value 类型为 UploadFile[] 而非 string
*/
export function isFileWidget(widgetName: string): boolean {
return FILE_WIDGETS.has(widgetName);
}

View File

@@ -0,0 +1,43 @@
// ============================================================
// AudioListInput — audioList → Dragger音频文件拖拽上传
// 用于声纹克隆的音频样本上传场景
// ============================================================
import { Upload } from 'antd';
import { InboxOutlined } from '@ant-design/icons';
import type { UploadFile } from 'antd/es/upload';
import type { WidgetProps } from './types';
const { Dragger } = Upload;
export function AudioListInput({ value, onChange, disabled, config }: WidgetProps) {
const fileList = (value as UploadFile[]) || [];
const maxSizeMb = (config.maxFileSizeMb as number) || 10;
const maxCount = (config.maxFiles as number) || 10;
const filters = config.fileFilter as string[] | undefined;
return (
<Dragger
multiple
accept={
filters
? filters.map((ext) => `.${ext}`).join(',')
: '.wav,.mp3,.m4a,.flac'
}
fileList={fileList}
onChange={({ fileList: newList }) => onChange?.(newList)}
disabled={disabled}
beforeUpload={() => false}
maxCount={maxCount}
>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text"></p>
<p className="ant-upload-hint">
{filters?.join(' / ').toUpperCase() || 'WAV / MP3 / M4A / FLAC'}
{maxSizeMb}MB
</p>
</Dragger>
);
}

View File

@@ -0,0 +1,17 @@
// ============================================================
// Checkbox — checkbox → Switch
// 用于布尔开关原始模式、平铺、Base64 输出等
// ============================================================
import { Switch } from 'antd';
import type { WidgetProps } from './types';
export function Checkbox({ value, onChange, disabled }: WidgetProps) {
return (
<Switch
checked={value as boolean}
onChange={(v) => onChange?.(v)}
disabled={disabled}
/>
);
}

View File

@@ -0,0 +1,24 @@
// ============================================================
// ComboBox — comboBox → Select
// 用于枚举选择:宽高比、音色、分辨率、模型等
// ============================================================
import { Select } from 'antd';
import type { WidgetProps } from './types';
export function ComboBox({ value, onChange, disabled, config }: WidgetProps) {
const enumValues = config.enumValues as string[] | undefined;
const options = enumValues?.map((v) => ({ label: v, value: v })) || [];
return (
<Select
value={value as string}
onChange={(v) => onChange?.(v)}
disabled={disabled}
placeholder={(config.placeholder as string) || '请选择'}
options={options}
allowClear={!config.required}
style={{ width: '100%' }}
/>
);
}

View File

@@ -0,0 +1,22 @@
// ============================================================
// DoubleSpinBox — doubleSpinBox → InputNumber (step=config.step)
// 用于小数输入:语速、音量、相似度等
// ============================================================
import { InputNumber } from 'antd';
import type { WidgetProps } from './types';
export function DoubleSpinBox({ value, onChange, disabled, config }: WidgetProps) {
return (
<InputNumber
value={value as number}
onChange={(v) => onChange?.(v != null ? v : undefined)}
disabled={disabled}
min={config.minValue as number | undefined}
max={config.maxValue as number | undefined}
step={config.step as number || 0.1}
style={{ width: '100%' }}
placeholder={(config.placeholder as string) || undefined}
/>
);
}

View File

@@ -0,0 +1,50 @@
// ============================================================
// ImageInput — imageInput → Upload单张图片
// 用于垫图、参考图等单图上传场景
// ============================================================
import { Upload, message } from 'antd';
import { UploadOutlined } from '@ant-design/icons';
import type { UploadFile } from 'antd/es/upload';
import type { WidgetProps } from './types';
/** 构建图片 accept 字符串 */
function buildAccept(filters?: string[]): string {
if (!filters || filters.length === 0) return 'image/png,image/jpg,image/jpeg,image/webp';
return filters.map((ext) => `image/${ext}`).join(',');
}
export function ImageInput({ value, onChange, disabled, config }: WidgetProps) {
const fileList = (value as UploadFile[]) || [];
const maxSizeMb = (config.maxFileSizeMb as number) || 10;
return (
<Upload
listType="picture-card"
maxCount={1}
accept={buildAccept(config.fileFilter as string[] | undefined)}
fileList={fileList}
onChange={({ fileList: newList }) => onChange?.(newList)}
disabled={disabled}
beforeUpload={(file) => {
const isImage = file.type.startsWith('image/');
if (!isImage) {
message.error('只能上传图片文件');
return Upload.LIST_IGNORE;
}
if (file.size / 1024 / 1024 >= maxSizeMb) {
message.error(`图片大小不能超过 ${maxSizeMb}MB`);
return Upload.LIST_IGNORE;
}
return false;
}}
>
{fileList.length < 1 && (
<div>
<UploadOutlined />
<div style={{ marginTop: 4, fontSize: 12 }}></div>
</div>
)}
</Upload>
);
}

View File

@@ -0,0 +1,55 @@
// ============================================================
// ImageListInput — imageList → Dragger多张图片拖拽上传
// 用于图生图模型的多图输入场景
// ============================================================
import { Upload, message } from 'antd';
import { InboxOutlined } from '@ant-design/icons';
import type { UploadFile } from 'antd/es/upload';
import type { WidgetProps } from './types';
const { Dragger } = Upload;
export function ImageListInput({ value, onChange, disabled, config }: WidgetProps) {
const fileList = (value as UploadFile[]) || [];
const maxSizeMb = (config.maxFileSizeMb as number) || 10;
const maxCount = (config.maxFiles as number) || 10;
const filters = config.fileFilter as string[] | undefined;
return (
<Dragger
multiple
listType="picture"
accept={
filters
? filters.map((ext) => `image/${ext}`).join(',')
: 'image/png,image/jpg,image/jpeg,image/webp'
}
fileList={fileList}
onChange={({ fileList: newList }) => onChange?.(newList)}
disabled={disabled}
beforeUpload={(file) => {
const isImage = file.type.startsWith('image/');
if (!isImage) {
message.error('只能上传图片文件');
return Upload.LIST_IGNORE;
}
if (file.size / 1024 / 1024 >= maxSizeMb) {
message.error(`图片大小不能超过 ${maxSizeMb}MB`);
return Upload.LIST_IGNORE;
}
return false;
}}
maxCount={maxCount}
>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text"></p>
<p className="ant-upload-hint">
{filters?.join(' / ').toUpperCase() || 'PNG / JPG / WebP'}
{maxSizeMb}MB
</p>
</Dragger>
);
}

View File

@@ -0,0 +1,21 @@
// ============================================================
// LineInput — lineEdit → Input
// 用于单行文本输入(如 custom_voice_id
// 同时作为未知 widget 类型的兜底控件
// ============================================================
import { Input } from 'antd';
import type { WidgetProps } from './types';
export function LineInput({ value, onChange, disabled, config }: WidgetProps) {
return (
<Input
value={value as string}
onChange={(e) => onChange?.(e.target.value)}
disabled={disabled}
placeholder={(config.placeholder as string) || '请输入'}
maxLength={config.maxLength as number | undefined}
allowClear
/>
);
}

View File

@@ -0,0 +1,55 @@
// ============================================================
// SeedanceContent — seedance2Content → 自定义内容编排控件
//
// 对应 Seedance 2.0 视频生成模型的 content 参数json_array
// 最终形态:文本 + 参考图 + 参考视频自由组合编排
//
// TODO: 当前为占位实现,后续需完善拖拽编排能力
// ============================================================
import { Input, Typography, theme as antTheme } from 'antd';
import { PictureOutlined, VideoCameraOutlined } from '@ant-design/icons';
import type { WidgetProps } from './types';
const { TextArea } = Input;
const { Text } = Typography;
export function SeedanceContent({ value, onChange, disabled, config }: WidgetProps) {
const { token } = antTheme.useToken();
return (
<div
style={{
border: `1px dashed ${token.colorBorder}`,
borderRadius: 6,
padding: 12,
background: token.colorFillQuaternary,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<PictureOutlined style={{ color: token.colorPrimary, fontSize: 14 }} />
<VideoCameraOutlined style={{ color: token.colorPrimary, fontSize: 14 }} />
<Text type="secondary" style={{ fontSize: 12 }}>
+
</Text>
</div>
<TextArea
rows={4}
value={value as string}
onChange={(e) => onChange?.(e.target.value)}
disabled={disabled}
placeholder={
(config.placeholder as string) ||
'输入视频内容的文字描述(后续版本将支持拖拽编排图片/视频参考素材)'
}
maxLength={(config.maxLength as number) || 5000}
showCount
/>
<Text type="secondary" style={{ display: 'block', marginTop: 6, fontSize: 11 }}>
💡 线 + +
</Text>
</div>
);
}

View File

@@ -0,0 +1,22 @@
// ============================================================
// SpinBox — spinBox → InputNumber (step=1)
// 用于整数输入:混沌、风格化、怪异、时长、音调等
// ============================================================
import { InputNumber } from 'antd';
import type { WidgetProps } from './types';
export function SpinBox({ value, onChange, disabled, config }: WidgetProps) {
return (
<InputNumber
value={value as number}
onChange={(v) => onChange?.(v != null ? v : undefined)}
disabled={disabled}
min={config.minValue as number | undefined}
max={config.maxValue as number | undefined}
step={1}
style={{ width: '100%' }}
placeholder={(config.placeholder as string) || undefined}
/>
);
}

View File

@@ -0,0 +1,23 @@
// ============================================================
// TextInput — textEdit → Input.TextArea
// 用于提示词、文本合成等长文本输入场景
// ============================================================
import { Input } from 'antd';
import type { WidgetProps } from './types';
const { TextArea } = Input;
export function TextInput({ value, onChange, disabled, config }: WidgetProps) {
return (
<TextArea
rows={4}
value={value as string}
onChange={(e) => onChange?.(e.target.value)}
disabled={disabled}
placeholder={(config.placeholder as string) || '请输入'}
maxLength={config.maxLength as number | undefined}
showCount={config.maxLength !== undefined}
/>
);
}

View File

@@ -0,0 +1,55 @@
// ============================================================
// useUI/index.ts — Widget 注册表
//
// 职责:
// 1. 定义 WidgetProps 统一接口
// 2. 维护 Qt 控件名 → React 组件的 WIDGET_MAP 映射
// 3. 导出 WidgetType 联合类型供外部使用
//
// 新增控件类型只需两步:
// ① 在 useUI/ 下创建组件文件
// ② 在 WIDGET_MAP 中加一行
// ============================================================
import type { ComponentType } from 'react';
import type { WidgetProps } from './types';
import { TextInput } from './TextInput';
import { LineInput } from './LineInput';
import { ComboBox } from './ComboBox';
import { Checkbox } from './Checkbox';
import { SpinBox } from './SpinBox';
import { DoubleSpinBox } from './DoubleSpinBox';
import { ImageInput } from './ImageInput';
import { ImageListInput } from './ImageListInput';
import { AudioListInput } from './AudioListInput';
import { SeedanceContent } from './SeedanceContent';
export type { WidgetProps } from './types';
// ---------- 注册表 ----------
/**
* Qt 控件名 → React 组件的映射表。
*
* 键来自 API param_schema[].ui.widget 字段(原 Python Qt 客户端的组件类名),
* 值为 useUI/ 下的对应 React 组件。
*/
export const WIDGET_MAP: Record<string, ComponentType<WidgetProps>> = {
textEdit: TextInput,
lineEdit: LineInput,
comboBox: ComboBox,
checkbox: Checkbox,
spinBox: SpinBox,
doubleSpinBox: DoubleSpinBox,
imageInput: ImageInput,
imageList: ImageListInput,
audioList: AudioListInput,
seedance2Content: SeedanceContent,
};
/** 已知的 widget 类型字面量联合 */
export type WidgetType = keyof typeof WIDGET_MAP;
/** 未知控件类型的兜底组件(渲染为普通 Input */
export const FALLBACK_WIDGET: ComponentType<WidgetProps> = LineInput;

View File

@@ -0,0 +1,19 @@
// ============================================================
// useUI/types.ts — Widget 类型定义(与 index.ts 分离以避免循环引用)
// ============================================================
/**
* 所有 widget 组件的统一 Props非泛型
*
* 不设类型参数的原因WIDGET_MAP 需要统一存储不同值类型的组件,
* 带泛型的 WidgetProps<string> 和 WidgetProps<number> 因函数参数逆变
* 而互不兼容ComponentType 协变检查失败),改为统一 unknown 接口,
* 各组件内部通过类型断言收窄到已知类型。
*/
export interface WidgetProps {
value?: unknown;
onChange?: (value: unknown) => void;
disabled?: boolean;
/** 从 param_schema 提取的控件专属配置placeholder / options / min / max 等) */
config: Record<string, unknown>;
}