fix: 修复模型默认值不加载 + DependentFormItem prop 透传断裂

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 17:00:44 +08:00
parent d5ba7e63cd
commit 4261915679
5 changed files with 287 additions and 131 deletions

View File

@@ -1,17 +1,19 @@
// ============================================================
// ModelInputForm — 模型输入表单(纯布局外壳)
//
// 布局Header模型信息+meta → Bodyfields.map 渲染) → Footer价格+提交)
// 布局Header模型信息+meta → Bodyfields.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 (
<Component
value={value}
onChange={onChange}
config={effectiveConfig}
/>
);
}
/** 稳定空数组引用,避免 useMemo 重建 */
const EMPTY_ARRAY: never[] = [];
/** 稳定空对象引用,避免每次渲染创建新 {} 导致 useMemo 失效 */
const EMPTY_OBJ: Record<string, unknown> = {};
// ---------- 主组件 ----------
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() {
// ======== useModelUIparam_schema → UIFieldConfig[] ========
const fields: UIFieldConfig[] = useModelUI(selectedModel?.param_schema || []);
const fields: UIFieldConfig[] = useModelUI(
selectedModel?.param_schema || EMPTY_ARRAY,
(selectedModel?.constraints_config as Record<string, unknown>) || EMPTY_OBJ,
);
// 构建初始值
// 构建初始值(用作 setFieldsValue 的数据源,
// antd Form 在有外部 form 实例时会忽略 initialValues prop
const initialValues = useMemo(() => {
const iv: Record<string, unknown> = {};
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<string, string>
const params: Record<string, string> = {};
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() {
<Form
form={form}
layout="vertical"
size="small"
initialValues={initialValues}
>
{fields.map((field) => (
<Form.Item
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 }
: {})}
>
<field.component config={field.widgetConfig} />
</Form.Item>
))}
<Form.Item
key={`${selectedModel.id}-${field.name}`}
name={field.name}
label={
<span>
<Text strong={field.must}>{field.label}</Text>
{field.tips && (
<Text
type="secondary"
style={{ fontSize: 12, 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}
{...(field.isFileWidget
? { getValueFromEvent: (e: { fileList: UploadFile[] }) => e.fileList }
: {})}
>
<DependentFormItem field={field} form={form} />
</Form.Item>
))}
</Form>
)}
</div>
{/* ======== Footer价格 + 提交 ======== */}
{/* ======== Footer标签 + 重置 + 提交 ======== */}
<div
style={{
padding: '12px 20px',
@@ -280,24 +347,24 @@ export function ModelInputForm() {
flexShrink: 0,
display: 'flex',
alignItems: 'center',
gap: 12,
gap: 8,
}}
>
{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>
)}
<Input
placeholder="自定义任务标签(可选)"
value={taskTag}
onChange={(e) => setTaskTag(e.target.value)}
allowClear
maxLength={50}
style={{ flex: 1, minWidth: 0 }}
/>
<Button
icon={<ReloadOutlined />}
onClick={handleReset}
disabled={!selectedModel}
title="重置参数"
/>
<Button
type="primary"
@@ -305,15 +372,18 @@ export function ModelInputForm() {
onClick={handleSubmit}
loading={submitting}
disabled={!isLoggedIn || !selectedModel}
block
size="large"
style={{
background: token.colorPrimary,
height: 44,
fontSize: 16,
height: 40,
fontSize: 14,
whiteSpace: 'nowrap',
flexShrink: 0,
}}
>
{priceType === 'SIMPLE' && price !== undefined
? `消费 ¥${price}/${priceUnit || '次'}`
: '消费'}
</Button>
</div>
</div>

View File

@@ -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<string, unknown>;
}
@@ -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 依赖 srefsref 为空时它们应被禁用。
*/
function buildDependencyMap(
constraintsConfig: Record<string, unknown>,
): Map<string, string[]> {
const depMap = new Map<string, string[]>();
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<string, unknown> = {};
* fields.forEach(f => { if (f.defaultValue !== undefined) iv[f.name] = f.defaultValue; });
* return iv;
* }, [fields]);
* ```
*/
export function useModelUI(
paramSchema: ModelsListResponseBody['param_schema'],
constraintsConfig: Record<string, unknown> = {},
): UIFieldConfig[] {
return useMemo(() => {
if (!paramSchema || paramSchema.length === 0) return [];
const depMap = buildDependencyMap(constraintsConfig);
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
@@ -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;
}

View File

@@ -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
/>
);
}

View File

@@ -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 (
<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>
<Space orientation={"vertical"}
style={{ width: '100%' }} size={8}>
{hasEnableSwitch && (
<Space size={8}>
<Switch
size="small"
checked={switchOn}
onChange={handleSwitchChange}
disabled={disabled}
/>
<Text type={switchOn ? 'success' : 'secondary'} style={{ fontSize: 12 }}>
{switchOn ? '已启用' : '点击开关启用上传'}
</Text>
</Space>
)}
</Upload>
<Upload
listType="picture-card"
maxCount={1}
accept={buildAccept(config.fileFilter as string[] | undefined)}
fileList={fileList}
onChange={({ fileList: newList }) => 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 && (
<div style={{ opacity: isUploadDisabled ? 0.4 : 1 }}>
<UploadOutlined />
<div style={{ marginTop: 4, fontSize: 12 }}></div>
</div>
)}
</Upload>
</Space>
);
}

View File

@@ -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
/>
);
}