From 42619156795bdff80b5634166c0a240e7b10ff2b Mon Sep 17 00:00:00 2001 From: YoungestSongMo <2130460579@qq.com> Date: Tue, 9 Jun 2026 17:00:44 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E5=80=BC=E4=B8=8D=E5=8A=A0=E8=BD=BD=20+=20De?= =?UTF-8?q?pendentFormItem=20prop=20=E9=80=8F=E4=BC=A0=E6=96=AD=E8=A3=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DependentFormItem 透传 Form.Item 注入的 value/onChange/disabled prop - 稳定化 constraintsConfig 空对象引用(EMPTY_OBJ),避免 useMemo 失效导致无限重渲染 - useLayoutEffect 手动 setFieldsValue(initialValues)替代 antd 被忽略的 initialValues prop - handleReset 使用 resetFields + setFieldsValue 组合确保默认值归位 Co-Authored-By: Claude Opus 4.8 --- src/pages/home/components/ModelInputForm.tsx | 218 ++++++++++++------ src/pages/home/components/useModelUI.ts | 99 +++++--- .../home/components/useUI/DoubleSpinBox.tsx | 3 +- .../home/components/useUI/ImageInput.tsx | 95 +++++--- src/pages/home/components/useUI/SpinBox.tsx | 3 +- 5 files changed, 287 insertions(+), 131 deletions(-) diff --git a/src/pages/home/components/ModelInputForm.tsx b/src/pages/home/components/ModelInputForm.tsx index 43c491c..00f442c 100644 --- a/src/pages/home/components/ModelInputForm.tsx +++ b/src/pages/home/components/ModelInputForm.tsx @@ -1,17 +1,19 @@ // ============================================================ // ModelInputForm — 模型输入表单(纯布局外壳) // -// 布局:Header(模型信息+meta) → Body(fields.map 渲染) → Footer(价格+提交) +// 布局:Header(模型信息+meta) → Body(fields.map 渲染) → Footer(标签+重置+提交) // // 与旧版的区别: // - 控件渲染逻辑已提取到 useUI/ 组件体系 // - param_schema 解析已提取到 useModelUI Hook -// - 本组件只负责:布局、Form 容器、提交逻辑 +// - 字段间条件依赖(constraints_config.requires)自动处理禁用 +// - 本组件只负责:布局、Form 容器、提交逻辑、依赖禁用 // ============================================================ -import { useEffect, useMemo, useCallback } from 'react'; +import { useState, useLayoutEffect, useMemo, useCallback } from 'react'; import { Form, + Input, Button, Typography, Empty, @@ -20,7 +22,8 @@ import { theme as antTheme, message, } from 'antd'; -import { SendOutlined, ClockCircleOutlined } from '@ant-design/icons'; +import type { FormInstance } from 'antd'; +import { SendOutlined, ClockCircleOutlined, ReloadOutlined } from '@ant-design/icons'; import type { UploadFile } from 'antd/es/upload'; import { useHomeContext } from '@/pages/home/HomeContent'; @@ -29,17 +32,70 @@ import { useSubmitTask } from '@/hooks/use-api'; import { emit, EVENTS } from '@/utils/event-bus'; import { OutputTypeMap } from '@/services/modules/task'; import type { OutputTypeString, TaskInfoItemResponseBody } from '@/services/modules/task'; -import { useModelUI, type UIFieldConfig } from './useModelUI'; +import { useModelUI, isValueEmpty, type UIFieldConfig } from './useModelUI'; const { Text, Title } = Typography; -// ---------- 组件 ---------- +// ---------- 依赖字段包装组件 ---------- + +/** + * DependentFormItem — 为单个字段处理条件依赖禁用。 + * + * 通过 Form.useWatch 监听 dependsOn 列表中的字段值, + * 任一依赖为空时自动禁用本字段(注入 disabled: true 到 widgetConfig)。 + * + * 每个 DependentFormItem 实例的 dependsOn 长度固定, + * 切换模型时通过外层 key 变化自动重新挂载,满足 React hooks 规则。 + */ +function DependentFormItem({ + field, + form, + value, + onChange, + disabled: formItemDisabled, +}: { + field: UIFieldConfig; + form: FormInstance; + /** Form.Item 通过 React.cloneElement 注入,必须透传给 widget */ + value?: unknown; + onChange?: (value: unknown) => void; + disabled?: boolean; +}) { + const depValues = (field.dependsOn || []).map((depName) => + Form.useWatch(depName, form), + ); + + const isDepDisabled = + field.dependsOn?.some((_, i) => isValueEmpty(depValues[i])) ?? false; + + const effectiveDisabled = formItemDisabled || isDepDisabled; + const effectiveConfig = effectiveDisabled + ? { ...field.widgetConfig, disabled: true } + : field.widgetConfig; + + const Component = field.component; + return ( + + ); +} + +/** 稳定空数组引用,避免 useMemo 重建 */ +const EMPTY_ARRAY: never[] = []; +/** 稳定空对象引用,避免每次渲染创建新 {} 导致 useMemo 失效 */ +const EMPTY_OBJ: Record = {}; + +// ---------- 主组件 ---------- export function ModelInputForm() { const { token } = antTheme.useToken(); const { isLoggedIn } = useAppContext(); const { selectedModel } = useHomeContext(); const [form] = Form.useForm(); + const [taskTag, setTaskTag] = useState(''); // 提交突变 Hook const { execute: submitTask, loading: submitting, errorMessage } = useSubmitTask({ @@ -51,9 +107,13 @@ export function ModelInputForm() { // ======== useModelUI:param_schema → UIFieldConfig[] ======== - const fields: UIFieldConfig[] = useModelUI(selectedModel?.param_schema || []); + const fields: UIFieldConfig[] = useModelUI( + selectedModel?.param_schema || EMPTY_ARRAY, + (selectedModel?.constraints_config as Record) || EMPTY_OBJ, + ); - // 构建初始值 + // 构建初始值(用作 setFieldsValue 的数据源, + // antd Form 在有外部 form 实例时会忽略 initialValues prop) const initialValues = useMemo(() => { const iv: Record = {}; fields.forEach((f) => { @@ -64,12 +124,22 @@ export function ModelInputForm() { return iv; }, [fields]); - // 选中模型时重置表单 - useEffect(() => { - if (selectedModel) { - form.resetFields(); + + // 选中模型时加载默认值(useLayoutEffect 在 DOM 更新后、浏览器绘制前执行,避免闪烁) + // antd 有外部 form 实例时 initialValues prop 被忽略,必须手动 setFieldsValue + useLayoutEffect(() => { + if (selectedModel && Object.keys(initialValues).length > 0) { + form.setFieldsValue(initialValues); + setTaskTag(''); } - }, [selectedModel?.id]); // eslint-disable-line react-hooks/exhaustive-deps + }, [selectedModel?.id, initialValues]); + + // 重置参数(回到默认值 + 清空标签) + const handleReset = useCallback(() => { + form.resetFields(); + form.setFieldsValue(initialValues); + setTaskTag(''); + }, [form, initialValues]); // ======== 提交 ======== @@ -83,14 +153,12 @@ export function ModelInputForm() { try { const values = await form.validateFields(); - // 将表单值全部转为字符串(CreatTaskRequestBody.params 为 Record) const params: Record = {}; for (const field of fields) { const value = values[field.name]; if (value === undefined || value === null) continue; - // 文件上传 → 提取 URL 数组 → JSON 字符串 if (field.isFileWidget) { const fileList = value as UploadFile[]; const urls = fileList @@ -103,20 +171,22 @@ export function ModelInputForm() { continue; } - // 布尔值 → "true"/"false" if (typeof value === 'boolean') { params[field.name] = String(value); continue; } - // 数值 / 字符串 → String params[field.name] = String(value); } - await submitTask({ model_id: selectedModel.id, params }); + await submitTask({ + model_id: selectedModel.id, + params, + ...(taskTag.trim() ? { tags: [taskTag.trim()] } : {}), + }); } catch (err) { if (err && typeof err === 'object' && 'errorFields' in err) { - return; // 表单验证错误,antd 自动提示 + return; } message.error(errorMessage || '任务提交失败,请重试'); } @@ -232,47 +302,44 @@ export function ModelInputForm() {
{fields.map((field) => ( - - {field.label} - {field.tips && ( - - {field.tips.length > 60 - ? `${field.tips.slice(0, 60)}...` - : field.tips} - - )} - - } - rules={ - field.must - ? [{ required: true, message: `请输入${field.label}` }] - : undefined - } - valuePropName={field.valuePropName} - // 文件控件:从 Upload onChange 事件中提取 fileList - {...(field.isFileWidget - ? { getValueFromEvent: (e: { fileList: UploadFile[] }) => e.fileList } - : {})} - > - - - ))} + + {field.label} + {field.tips && ( + + {field.tips.length > 60 + ? `${field.tips.slice(0, 60)}...` + : field.tips} + + )} + + } + rules={ + field.must + ? [{ required: true, message: `请输入${field.label}` }] + : undefined + } + valuePropName={field.valuePropName} + {...(field.isFileWidget + ? { getValueFromEvent: (e: { fileList: UploadFile[] }) => e.fileList } + : {})} + > + + + ))}
)} - {/* ======== Footer:价格 + 提交 ======== */} + {/* ======== Footer:标签 + 重置 + 提交 ======== */}
- {price !== undefined && priceType === 'SIMPLE' && ( - - ¥{price}/{priceUnit || '次'} - - )} - {priceType === 'MULTI_DIMENSION' && ( - - 按规格计费 - - )} - {priceType === 'BILLING_ADAPTER' && ( - - 按用量计费 - - )} + setTaskTag(e.target.value)} + allowClear + maxLength={50} + style={{ flex: 1, minWidth: 0 }} + /> + +
diff --git a/src/pages/home/components/useModelUI.ts b/src/pages/home/components/useModelUI.ts index 3382a2c..003f192 100644 --- a/src/pages/home/components/useModelUI.ts +++ b/src/pages/home/components/useModelUI.ts @@ -4,6 +4,7 @@ // 职责: // - 遍历模型的 param_schema,解析 spec/ui 字段 // - 查 WIDGET_MAP 获取对应的 React 组件 +// - 解析 constraints_config.requires 计算字段间依赖 // - 组装出可直接渲染的 UIFieldConfig 数组 // // ModelInputForm 只需遍历 fields 渲染 Form.Item 即可, @@ -43,6 +44,14 @@ export interface UIFieldConfig { valuePropName?: string; /** 是否为文件上传类控件(需要 getValueFromEvent 提取 fileList) */ isFileWidget: boolean; + /** + * 条件依赖:当这些字段的值为空时,本字段应被禁用。 + * + * 来源于 constraints_config.requires 的逆向推导: + * { if: "sref", then: ["sw", "sv"] } + * → sw.dependsOn = ["sref"], sv.dependsOn = ["sref"] + */ + dependsOn?: string[]; /** 传给 widget 组件的配置(placeholder/options/min/max 等) */ widgetConfig: Record; } @@ -51,49 +60,76 @@ export interface UIFieldConfig { const FILE_WIDGETS = new Set(['imageInput', 'imageList', 'audioList']); +// ---------- 约束解析 ---------- + +interface ConstraintRule { + if: string; + then: string[]; +} + +/** + * 从 constraints_config.requires 构建反向依赖表。 + * + * 输入: + * [{ if: "sref", then: ["sw", "sv"] }] + * + * 输出: + * { sw: ["sref"], sv: ["sref"] } + * + * 含义:sw 和 sv 依赖 sref,sref 为空时它们应被禁用。 + */ +function buildDependencyMap( + constraintsConfig: Record, +): Map { + const depMap = new Map(); + const requires = constraintsConfig.requires as ConstraintRule[] | undefined; + + if (!requires || !Array.isArray(requires)) return depMap; + + for (const rule of requires) { + if (!rule.if || !rule.then || !Array.isArray(rule.then)) continue; + for (const target of rule.then) { + const existing = depMap.get(target) || []; + existing.push(rule.if); + depMap.set(target, existing); + } + } + + return depMap; +} + // ---------- Hook ---------- /** - * 根据模型的 param_schema 构建 UI 字段配置数组。 + * 根据模型的 param_schema 和 constraints_config 构建 UI 字段配置数组。 * - * @param paramSchema — 来自 selectedModel.param_schema(可为空数组) + * @param paramSchema — 来自 selectedModel.param_schema + * @param constraintsConfig — 来自 selectedModel.constraints_config * @returns UIFieldConfig[] — 渲染就绪的字段配置列表 - * - * 使用方式: - * ```tsx - * const fields = useModelUI(selectedModel?.param_schema || []); - * const initialValues = useMemo(() => { - * const iv: Record = {}; - * fields.forEach(f => { if (f.defaultValue !== undefined) iv[f.name] = f.defaultValue; }); - * return iv; - * }, [fields]); - * ``` */ export function useModelUI( paramSchema: ModelsListResponseBody['param_schema'], + constraintsConfig: Record = {}, ): UIFieldConfig[] { return useMemo(() => { if (!paramSchema || paramSchema.length === 0) return []; + const depMap = buildDependencyMap(constraintsConfig); + return paramSchema.map((item, index) => { const spec = (item.spec || {}) as Record; const ui = (item.ui || {}) as Record; const widget = (ui.widget as string) || 'lineEdit'; - // 查找组件(未知 widget 用 LineInput 兜底) const component = WIDGET_MAP[widget] || FALLBACK_WIDGET; - // 构建传给组件的配置 const widgetConfig: Record = { 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 @@ -101,36 +137,43 @@ export function useModelUI( 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'; + const name = item.map_to; + const dependsOn = depMap.get(name); return { index, - name: item.map_to, + name, component, - label: (ui.label as string) || item.map_to, + label: (ui.label as string) || name, must: (spec.required as boolean) || false, defaultValue: spec.default, tips: (ui.tips as string) || undefined, valuePropName: isCheckbox ? 'checked' : undefined, isFileWidget: isFile, + dependsOn: dependsOn && dependsOn.length > 0 ? dependsOn : undefined, widgetConfig, }; }); - }, [paramSchema]); + }, [paramSchema, constraintsConfig]); } -/** - * 判断一个 widget 类型是否为文件上传类控件。 - * - * 文件控件在 Form.Item 中需要特殊处理: - * - getValueFromEvent 提取 e.fileList - * - value 类型为 UploadFile[] 而非 string - */ +/** 判断 widget 类型是否为文件上传类控件 */ export function isFileWidget(widgetName: string): boolean { return FILE_WIDGETS.has(widgetName); } + +// ---------- 值判空工具 ---------- + +/** 判断表单值是否为"空"(用于依赖禁用判断) */ +export function isValueEmpty(value: unknown): boolean { + if (value === undefined || value === null) return true; + if (typeof value === 'string' && value.trim() === '') return true; + if (Array.isArray(value) && value.length === 0) return true; + if (typeof value === 'boolean' && !value) return true; + return false; +} diff --git a/src/pages/home/components/useUI/DoubleSpinBox.tsx b/src/pages/home/components/useUI/DoubleSpinBox.tsx index 58f8db3..286b854 100644 --- a/src/pages/home/components/useUI/DoubleSpinBox.tsx +++ b/src/pages/home/components/useUI/DoubleSpinBox.tsx @@ -15,8 +15,9 @@ export function DoubleSpinBox({ value, onChange, disabled, config }: WidgetProps min={config.minValue as number | undefined} max={config.maxValue as number | undefined} step={config.step as number || 0.1} - style={{ width: '100%' }} + style={{ width: '100%', minWidth: 140 }} placeholder={(config.placeholder as string) || undefined} + controls /> ); } diff --git a/src/pages/home/components/useUI/ImageInput.tsx b/src/pages/home/components/useUI/ImageInput.tsx index 63ec35c..9cf77c5 100644 --- a/src/pages/home/components/useUI/ImageInput.tsx +++ b/src/pages/home/components/useUI/ImageInput.tsx @@ -1,13 +1,21 @@ // ============================================================ // ImageInput — imageInput → Upload(单张图片) // 用于垫图、参考图等单图上传场景 +// +// enable_switch 支持: +// 当 API ui.enable_switch === true 时,默认禁用(灰色), +// 需点击 Switch 开关后才能上传——匹配原 Qt 客户端行为。 +// 开关关闭时清除已上传文件。 // ============================================================ -import { Upload, message } from 'antd'; +import { useState, useCallback } from 'react'; +import { Upload, Switch, Space, Typography, message } from 'antd'; import { UploadOutlined } from '@ant-design/icons'; import type { UploadFile } from 'antd/es/upload'; import type { WidgetProps } from './types'; +const { Text } = Typography; + /** 构建图片 accept 字符串 */ function buildAccept(filters?: string[]): string { if (!filters || filters.length === 0) return 'image/png,image/jpg,image/jpeg,image/webp'; @@ -17,34 +25,67 @@ function buildAccept(filters?: string[]): string { export function ImageInput({ value, onChange, disabled, config }: WidgetProps) { const fileList = (value as UploadFile[]) || []; const maxSizeMb = (config.maxFileSizeMb as number) || 10; + const hasEnableSwitch = (config.enableSwitch as boolean) || false; + + // 有 enable_switch 时默认禁用,需手动开启 + const [switchOn, setSwitchOn] = useState(!hasEnableSwitch); + const isUploadDisabled = disabled || !switchOn; + + const handleSwitchChange = useCallback( + (on: boolean) => { + setSwitchOn(on); + if (!on) { + // 关闭开关 → 清除已上传文件 + onChange?.([] as unknown as UploadFile[]); + } + }, + [onChange], + ); return ( - 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 && ( -
- -
上传
-
+ + {hasEnableSwitch && ( + + + + {switchOn ? '已启用' : '点击开关启用上传'} + + )} -
+ + onChange?.(newList as unknown as UploadFile[])} + disabled={isUploadDisabled} + 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 && ( +
+ +
上传
+
+ )} +
+ ); } diff --git a/src/pages/home/components/useUI/SpinBox.tsx b/src/pages/home/components/useUI/SpinBox.tsx index be6abe0..5cbd8df 100644 --- a/src/pages/home/components/useUI/SpinBox.tsx +++ b/src/pages/home/components/useUI/SpinBox.tsx @@ -15,8 +15,9 @@ export function SpinBox({ value, onChange, disabled, config }: WidgetProps) { min={config.minValue as number | undefined} max={config.maxValue as number | undefined} step={1} - style={{ width: '100%' }} + style={{ width: '100%', minWidth: 140 }} placeholder={(config.placeholder as string) || undefined} + controls /> ); }