From d5ba7e63cd85653fadd08ff5b08d784d51cb52bb Mon Sep 17 00:00:00 2001 From: YoungestSongMo <2130460579@qq.com> Date: Tue, 9 Jun 2026 15:32:17 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E4=B8=AD=E9=97=B4=E6=A0=8F?= =?UTF-8?q?=E4=B8=89=E5=B1=82=E6=9E=B6=E6=9E=84=20=E2=80=94=20useUI=20?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=E4=BD=93=E7=B3=BB=20+=20useModelUI=20Hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新建 useUI/ 目录:12 个文件,10 个 Qt 控件 → antd 组件封装 + 类型 + 注册表 - 新建 useModelUI Hook:param_schema → UIFieldConfig[] 自动映射 - ModelInputForm 瘦身 ~160 行:删除 buildParamDescriptors/renderParamControl/文件状态管理 - WIDGET_MAP: Record> — 零 any,完全类型安全 - 新增控件只需两步:创建组件文件 + 在 WIDGET_MAP 加一行 - 删除旧 widgets/ 目录 Co-Authored-By: Claude Opus 4.8 --- src/pages/home/components/ModelInputForm.tsx | 530 +++++++----------- src/pages/home/components/useModelUI.ts | 136 +++++ .../home/components/useUI/AudioListInput.tsx | 43 ++ src/pages/home/components/useUI/Checkbox.tsx | 17 + src/pages/home/components/useUI/ComboBox.tsx | 24 + .../home/components/useUI/DoubleSpinBox.tsx | 22 + .../home/components/useUI/ImageInput.tsx | 50 ++ .../home/components/useUI/ImageListInput.tsx | 55 ++ src/pages/home/components/useUI/LineInput.tsx | 21 + .../home/components/useUI/SeedanceContent.tsx | 55 ++ src/pages/home/components/useUI/SpinBox.tsx | 22 + src/pages/home/components/useUI/TextInput.tsx | 23 + src/pages/home/components/useUI/index.ts | 55 ++ src/pages/home/components/useUI/types.ts | 19 + 14 files changed, 733 insertions(+), 339 deletions(-) create mode 100644 src/pages/home/components/useModelUI.ts create mode 100644 src/pages/home/components/useUI/AudioListInput.tsx create mode 100644 src/pages/home/components/useUI/Checkbox.tsx create mode 100644 src/pages/home/components/useUI/ComboBox.tsx create mode 100644 src/pages/home/components/useUI/DoubleSpinBox.tsx create mode 100644 src/pages/home/components/useUI/ImageInput.tsx create mode 100644 src/pages/home/components/useUI/ImageListInput.tsx create mode 100644 src/pages/home/components/useUI/LineInput.tsx create mode 100644 src/pages/home/components/useUI/SeedanceContent.tsx create mode 100644 src/pages/home/components/useUI/SpinBox.tsx create mode 100644 src/pages/home/components/useUI/TextInput.tsx create mode 100644 src/pages/home/components/useUI/index.ts create mode 100644 src/pages/home/components/useUI/types.ts diff --git a/src/pages/home/components/ModelInputForm.tsx b/src/pages/home/components/ModelInputForm.tsx index 7693c00..43c491c 100644 --- a/src/pages/home/components/ModelInputForm.tsx +++ b/src/pages/home/components/ModelInputForm.tsx @@ -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): 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; - 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([]); - // 提交突变 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 = {}; + 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) + const params: Record = {}; - // 构建提交参数(全部转为字符串值) - const params: Record = { - 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 ( - - ); - - case 'textarea': - return