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:
@@ -1,126 +1,37 @@
|
||||
// ============================================================
|
||||
// ModelInputForm — 动态模型输入表单
|
||||
// 根据选中模型的 param_schema + ui_config 动态渲染表单控件
|
||||
// ModelInputForm — 模型输入表单(纯布局外壳)
|
||||
//
|
||||
// 布局:Header(模型信息+meta) → Body(fields.map 渲染) → Footer(价格+提交)
|
||||
//
|
||||
// 与旧版的区别:
|
||||
// - 控件渲染逻辑已提取到 useUI/ 组件体系
|
||||
// - param_schema 解析已提取到 useModelUI Hook
|
||||
// - 本组件只负责:布局、Form 容器、提交逻辑
|
||||
// ============================================================
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useEffect, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Select,
|
||||
Slider,
|
||||
Button,
|
||||
Typography,
|
||||
Empty,
|
||||
Upload,
|
||||
Switch,
|
||||
Tag,
|
||||
Space,
|
||||
theme as antTheme,
|
||||
message,
|
||||
} from 'antd';
|
||||
import {
|
||||
SendOutlined,
|
||||
UploadOutlined,
|
||||
InboxOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { SendOutlined, ClockCircleOutlined } from '@ant-design/icons';
|
||||
import type { UploadFile } from 'antd/es/upload';
|
||||
|
||||
import { useHomeContext } from '@/pages/home/HomeContent';
|
||||
import { useAppContext } from '@/components/AppProvider';
|
||||
import { useSubmitTask } from '@/hooks/use-api';
|
||||
import { emit, EVENTS } from '@/utils/event-bus';
|
||||
import type { ModelsListResponseBody } from '@/services/modules/models';
|
||||
import type { TaskInfoItemResponseBody } from '@/services/modules/task';
|
||||
import { OutputTypeMap } from '@/services/modules/task';
|
||||
import type { OutputTypeString, TaskInfoItemResponseBody } from '@/services/modules/task';
|
||||
import { useModelUI, type UIFieldConfig } from './useModelUI';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
const { TextArea } = Input;
|
||||
const { Dragger } = Upload;
|
||||
|
||||
// ---------- 参数类型映射 ----------
|
||||
|
||||
/** 从 ui_config 中解析的参数描述 */
|
||||
interface ParamDescriptor {
|
||||
/** React key(item.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 { selectedModel } = useHomeContext();
|
||||
const [form] = Form.useForm();
|
||||
const [referenceFiles, setReferenceFiles] = useState<UploadFile[]>([]);
|
||||
|
||||
// 提交突变 Hook(自动处理 loading / error / 日志)
|
||||
// 提交突变 Hook
|
||||
const { execute: submitTask, loading: submitting, errorMessage } = useSubmitTask({
|
||||
onSuccess: useCallback(
|
||||
(_data: TaskInfoItemResponseBody) => {
|
||||
message.success('任务已提交');
|
||||
// 通过事件总线通知 TaskHistory 刷新(替代 Context 中的 refreshTaskSignal)
|
||||
emit(EVENTS.TASK_SUBMITTED);
|
||||
},
|
||||
[],
|
||||
),
|
||||
onSuccess: useCallback((_data: TaskInfoItemResponseBody) => {
|
||||
message.success('任务已提交');
|
||||
emit(EVENTS.TASK_SUBMITTED);
|
||||
}, []),
|
||||
});
|
||||
|
||||
// 选中模型时重置表单(仅当有选中模型时操作,避免 form 未连接警告)
|
||||
// ======== useModelUI:param_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(() => {
|
||||
if (selectedModel) {
|
||||
form.resetFields();
|
||||
}
|
||||
setReferenceFiles([]);
|
||||
}, [selectedModel?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 参数描述符
|
||||
const paramDescriptors = useMemo(() => {
|
||||
if (!selectedModel) return [];
|
||||
return buildParamDescriptors(selectedModel.param_schema || []);
|
||||
}, [selectedModel]);
|
||||
// ======== 提交 ========
|
||||
|
||||
// 提交
|
||||
const handleSubmit = async () => {
|
||||
if (!isLoggedIn) {
|
||||
message.warning('请先登录后再提交任务');
|
||||
@@ -168,128 +83,47 @@ export function ModelInputForm() {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
|
||||
// 分离固定字段和模型动态参数
|
||||
const { prompt, negative_prompt, num_outputs, ...dynamicParams } = values;
|
||||
// 将表单值全部转为字符串(CreatTaskRequestBody.params 为 Record<string, string>)
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
// 构建提交参数(全部转为字符串值)
|
||||
const params: Record<string, string> = {
|
||||
prompt: String(prompt ?? ''),
|
||||
negative_prompt: String(negative_prompt ?? ''),
|
||||
num_outputs: String(num_outputs ?? 1),
|
||||
};
|
||||
for (const field of fields) {
|
||||
const value = values[field.name];
|
||||
if (value === undefined || value === null) continue;
|
||||
|
||||
// 动态参数统一转字符串
|
||||
for (const [key, value] of Object.entries(dynamicParams)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
params[key] = String(value);
|
||||
// 文件上传 → 提取 URL 数组 → JSON 字符串
|
||||
if (field.isFileWidget) {
|
||||
const fileList = value as UploadFile[];
|
||||
const urls = fileList
|
||||
.filter((f) => f.status === 'done')
|
||||
.map((f) => f.response?.url || f.url || f.name)
|
||||
.filter(Boolean) as string[];
|
||||
if (urls.length > 0) {
|
||||
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);
|
||||
}
|
||||
|
||||
// 参考图
|
||||
if (referenceFiles.length > 0) {
|
||||
const refUrls = referenceFiles
|
||||
.filter((f) => f.status === 'done')
|
||||
.map((f) => f.response?.url || f.url || f.name)
|
||||
.filter(Boolean) as string[];
|
||||
if (refUrls.length > 0) {
|
||||
params.reference_images = JSON.stringify(refUrls);
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 mutation Hook 提交(自动处理 loading / error / 日志)
|
||||
await submitTask({ model_id: selectedModel.id, params });
|
||||
} catch (err) {
|
||||
if (err && typeof err === 'object' && 'errorFields' in err) {
|
||||
// 表单验证错误,antd 会自动提示
|
||||
return;
|
||||
return; // 表单验证错误,antd 自动提示
|
||||
}
|
||||
// 错误消息由 Hook 自动通过 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) {
|
||||
return (
|
||||
<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 (
|
||||
<div
|
||||
style={{
|
||||
@@ -315,7 +166,7 @@ export function ModelInputForm() {
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* 模型标题 */}
|
||||
{/* ======== Header:模型信息 ======== */}
|
||||
<div
|
||||
style={{
|
||||
padding: '16px 20px',
|
||||
@@ -323,130 +174,131 @@ export function ModelInputForm() {
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Title level={4} style={{ margin: 0, fontSize: 18 }}>
|
||||
{selectedModel.name}
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{selectedModel.provider_name} · {selectedModel.category_name}
|
||||
</Text>
|
||||
<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 }}>
|
||||
{selectedModel.name}
|
||||
</Title>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{selectedModel.provider_name}
|
||||
{group
|
||||
? ` · ${group}`
|
||||
: selectedModel.category_name
|
||||
? ` · ${selectedModel.category_name}`
|
||||
: ''}
|
||||
</Text>
|
||||
</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' }}>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
size="small"
|
||||
initialValues={{ num_outputs: 1 }}
|
||||
>
|
||||
{/* ======== 固定字段 ======== */}
|
||||
|
||||
{/* 提示词 */}
|
||||
<Form.Item
|
||||
name="prompt"
|
||||
label={<Text strong>提示词</Text>}
|
||||
rules={[{ required: true, message: '请输入提示词' }]}
|
||||
{fields.length === 0 ? (
|
||||
<Empty
|
||||
description="该模型无需额外参数"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
style={{ marginTop: 24 }}
|
||||
/>
|
||||
) : (
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
size="small"
|
||||
initialValues={initialValues}
|
||||
>
|
||||
<TextArea
|
||||
rows={4}
|
||||
placeholder="描述你想要的画面、视频或音频内容..."
|
||||
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>
|
||||
{paramDescriptors.map((desc) => (
|
||||
{fields.map((field) => (
|
||||
<Form.Item
|
||||
key={desc.key}
|
||||
name={desc.name}
|
||||
label={desc.label}
|
||||
rules={desc.required ? [{ required: true, message: `请输入${desc.label}` }] : undefined}
|
||||
initialValue={desc.defaultValue}
|
||||
valuePropName={desc.controlType === 'switch' ? 'checked' : 'value'}
|
||||
key={field.index}
|
||||
name={field.name}
|
||||
label={
|
||||
<span>
|
||||
<Text strong={field.must}>{field.label}</Text>
|
||||
{field.tips && (
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{ fontSize: 11, marginLeft: 6 }}
|
||||
>
|
||||
{field.tips.length > 60
|
||||
? `${field.tips.slice(0, 60)}...`
|
||||
: field.tips}
|
||||
</Text>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
rules={
|
||||
field.must
|
||||
? [{ required: true, message: `请输入${field.label}` }]
|
||||
: undefined
|
||||
}
|
||||
valuePropName={field.valuePropName}
|
||||
// 文件控件:从 Upload onChange 事件中提取 fileList
|
||||
{...(field.isFileWidget
|
||||
? { getValueFromEvent: (e: { fileList: UploadFile[] }) => e.fileList }
|
||||
: {})}
|
||||
>
|
||||
{renderParamControl(desc)}
|
||||
<field.component config={field.widgetConfig} />
|
||||
</Form.Item>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 提交按钮 */}
|
||||
{/* ======== Footer:价格 + 提交 ======== */}
|
||||
<div
|
||||
style={{
|
||||
padding: '12px 20px',
|
||||
borderTop: `1px solid ${token.colorBorderSecondary}`,
|
||||
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
|
||||
type="primary"
|
||||
icon={<SendOutlined />}
|
||||
|
||||
136
src/pages/home/components/useModelUI.ts
Normal file
136
src/pages/home/components/useModelUI.ts
Normal 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 的 valuePropName(checkbox 用 '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);
|
||||
}
|
||||
43
src/pages/home/components/useUI/AudioListInput.tsx
Normal file
43
src/pages/home/components/useUI/AudioListInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
src/pages/home/components/useUI/Checkbox.tsx
Normal file
17
src/pages/home/components/useUI/Checkbox.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
24
src/pages/home/components/useUI/ComboBox.tsx
Normal file
24
src/pages/home/components/useUI/ComboBox.tsx
Normal 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%' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
22
src/pages/home/components/useUI/DoubleSpinBox.tsx
Normal file
22
src/pages/home/components/useUI/DoubleSpinBox.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
50
src/pages/home/components/useUI/ImageInput.tsx
Normal file
50
src/pages/home/components/useUI/ImageInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
src/pages/home/components/useUI/ImageListInput.tsx
Normal file
55
src/pages/home/components/useUI/ImageListInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
src/pages/home/components/useUI/LineInput.tsx
Normal file
21
src/pages/home/components/useUI/LineInput.tsx
Normal 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
|
||||
/>
|
||||
);
|
||||
}
|
||||
55
src/pages/home/components/useUI/SeedanceContent.tsx
Normal file
55
src/pages/home/components/useUI/SeedanceContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
src/pages/home/components/useUI/SpinBox.tsx
Normal file
22
src/pages/home/components/useUI/SpinBox.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
23
src/pages/home/components/useUI/TextInput.tsx
Normal file
23
src/pages/home/components/useUI/TextInput.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
55
src/pages/home/components/useUI/index.ts
Normal file
55
src/pages/home/components/useUI/index.ts
Normal 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;
|
||||
19
src/pages/home/components/useUI/types.ts
Normal file
19
src/pages/home/components/useUI/types.ts
Normal 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>;
|
||||
}
|
||||
Reference in New Issue
Block a user