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:
@@ -1,17 +1,19 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// ModelInputForm — 模型输入表单(纯布局外壳)
|
// ModelInputForm — 模型输入表单(纯布局外壳)
|
||||||
//
|
//
|
||||||
// 布局:Header(模型信息+meta) → Body(fields.map 渲染) → Footer(价格+提交)
|
// 布局:Header(模型信息+meta) → Body(fields.map 渲染) → Footer(标签+重置+提交)
|
||||||
//
|
//
|
||||||
// 与旧版的区别:
|
// 与旧版的区别:
|
||||||
// - 控件渲染逻辑已提取到 useUI/ 组件体系
|
// - 控件渲染逻辑已提取到 useUI/ 组件体系
|
||||||
// - param_schema 解析已提取到 useModelUI Hook
|
// - param_schema 解析已提取到 useModelUI Hook
|
||||||
// - 本组件只负责:布局、Form 容器、提交逻辑
|
// - 字段间条件依赖(constraints_config.requires)自动处理禁用
|
||||||
|
// - 本组件只负责:布局、Form 容器、提交逻辑、依赖禁用
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useEffect, useMemo, useCallback } from 'react';
|
import { useState, useLayoutEffect, useMemo, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
|
Input,
|
||||||
Button,
|
Button,
|
||||||
Typography,
|
Typography,
|
||||||
Empty,
|
Empty,
|
||||||
@@ -20,7 +22,8 @@ import {
|
|||||||
theme as antTheme,
|
theme as antTheme,
|
||||||
message,
|
message,
|
||||||
} from 'antd';
|
} 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 type { UploadFile } from 'antd/es/upload';
|
||||||
|
|
||||||
import { useHomeContext } from '@/pages/home/HomeContent';
|
import { useHomeContext } from '@/pages/home/HomeContent';
|
||||||
@@ -29,17 +32,70 @@ import { useSubmitTask } from '@/hooks/use-api';
|
|||||||
import { emit, EVENTS } from '@/utils/event-bus';
|
import { emit, EVENTS } from '@/utils/event-bus';
|
||||||
import { OutputTypeMap } from '@/services/modules/task';
|
import { OutputTypeMap } from '@/services/modules/task';
|
||||||
import type { OutputTypeString, TaskInfoItemResponseBody } 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;
|
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() {
|
export function ModelInputForm() {
|
||||||
const { token } = antTheme.useToken();
|
const { token } = antTheme.useToken();
|
||||||
const { isLoggedIn } = useAppContext();
|
const { isLoggedIn } = useAppContext();
|
||||||
const { selectedModel } = useHomeContext();
|
const { selectedModel } = useHomeContext();
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
|
const [taskTag, setTaskTag] = useState('');
|
||||||
|
|
||||||
// 提交突变 Hook
|
// 提交突变 Hook
|
||||||
const { execute: submitTask, loading: submitting, errorMessage } = useSubmitTask({
|
const { execute: submitTask, loading: submitting, errorMessage } = useSubmitTask({
|
||||||
@@ -51,9 +107,13 @@ export function ModelInputForm() {
|
|||||||
|
|
||||||
// ======== useModelUI:param_schema → UIFieldConfig[] ========
|
// ======== 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<string, unknown>) || EMPTY_OBJ,
|
||||||
|
);
|
||||||
|
|
||||||
// 构建初始值
|
// 构建初始值(用作 setFieldsValue 的数据源,
|
||||||
|
// antd Form 在有外部 form 实例时会忽略 initialValues prop)
|
||||||
const initialValues = useMemo(() => {
|
const initialValues = useMemo(() => {
|
||||||
const iv: Record<string, unknown> = {};
|
const iv: Record<string, unknown> = {};
|
||||||
fields.forEach((f) => {
|
fields.forEach((f) => {
|
||||||
@@ -64,12 +124,22 @@ export function ModelInputForm() {
|
|||||||
return iv;
|
return iv;
|
||||||
}, [fields]);
|
}, [fields]);
|
||||||
|
|
||||||
// 选中模型时重置表单
|
|
||||||
useEffect(() => {
|
// 选中模型时加载默认值(useLayoutEffect 在 DOM 更新后、浏览器绘制前执行,避免闪烁)
|
||||||
if (selectedModel) {
|
// antd 有外部 form 实例时 initialValues prop 被忽略,必须手动 setFieldsValue
|
||||||
form.resetFields();
|
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 {
|
try {
|
||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
|
|
||||||
// 将表单值全部转为字符串(CreatTaskRequestBody.params 为 Record<string, string>)
|
|
||||||
const params: Record<string, string> = {};
|
const params: Record<string, string> = {};
|
||||||
|
|
||||||
for (const field of fields) {
|
for (const field of fields) {
|
||||||
const value = values[field.name];
|
const value = values[field.name];
|
||||||
if (value === undefined || value === null) continue;
|
if (value === undefined || value === null) continue;
|
||||||
|
|
||||||
// 文件上传 → 提取 URL 数组 → JSON 字符串
|
|
||||||
if (field.isFileWidget) {
|
if (field.isFileWidget) {
|
||||||
const fileList = value as UploadFile[];
|
const fileList = value as UploadFile[];
|
||||||
const urls = fileList
|
const urls = fileList
|
||||||
@@ -103,20 +171,22 @@ export function ModelInputForm() {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 布尔值 → "true"/"false"
|
|
||||||
if (typeof value === 'boolean') {
|
if (typeof value === 'boolean') {
|
||||||
params[field.name] = String(value);
|
params[field.name] = String(value);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 数值 / 字符串 → String
|
|
||||||
params[field.name] = String(value);
|
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) {
|
} catch (err) {
|
||||||
if (err && typeof err === 'object' && 'errorFields' in err) {
|
if (err && typeof err === 'object' && 'errorFields' in err) {
|
||||||
return; // 表单验证错误,antd 自动提示
|
return;
|
||||||
}
|
}
|
||||||
message.error(errorMessage || '任务提交失败,请重试');
|
message.error(errorMessage || '任务提交失败,请重试');
|
||||||
}
|
}
|
||||||
@@ -232,12 +302,10 @@ export function ModelInputForm() {
|
|||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
size="small"
|
|
||||||
initialValues={initialValues}
|
|
||||||
>
|
>
|
||||||
{fields.map((field) => (
|
{fields.map((field) => (
|
||||||
<Form.Item
|
<Form.Item
|
||||||
key={field.index}
|
key={`${selectedModel.id}-${field.name}`}
|
||||||
name={field.name}
|
name={field.name}
|
||||||
label={
|
label={
|
||||||
<span>
|
<span>
|
||||||
@@ -245,7 +313,7 @@ export function ModelInputForm() {
|
|||||||
{field.tips && (
|
{field.tips && (
|
||||||
<Text
|
<Text
|
||||||
type="secondary"
|
type="secondary"
|
||||||
style={{ fontSize: 11, marginLeft: 6 }}
|
style={{ fontSize: 12, marginLeft: 6 }}
|
||||||
>
|
>
|
||||||
{field.tips.length > 60
|
{field.tips.length > 60
|
||||||
? `${field.tips.slice(0, 60)}...`
|
? `${field.tips.slice(0, 60)}...`
|
||||||
@@ -260,19 +328,18 @@ export function ModelInputForm() {
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
valuePropName={field.valuePropName}
|
valuePropName={field.valuePropName}
|
||||||
// 文件控件:从 Upload onChange 事件中提取 fileList
|
|
||||||
{...(field.isFileWidget
|
{...(field.isFileWidget
|
||||||
? { getValueFromEvent: (e: { fileList: UploadFile[] }) => e.fileList }
|
? { getValueFromEvent: (e: { fileList: UploadFile[] }) => e.fileList }
|
||||||
: {})}
|
: {})}
|
||||||
>
|
>
|
||||||
<field.component config={field.widgetConfig} />
|
<DependentFormItem field={field} form={form} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
))}
|
))}
|
||||||
</Form>
|
</Form>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ======== Footer:价格 + 提交 ======== */}
|
{/* ======== Footer:标签 + 重置 + 提交 ======== */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: '12px 20px',
|
padding: '12px 20px',
|
||||||
@@ -280,24 +347,24 @@ export function ModelInputForm() {
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 12,
|
gap: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{price !== undefined && priceType === 'SIMPLE' && (
|
<Input
|
||||||
<Text type="secondary" style={{ fontSize: 12, whiteSpace: 'nowrap' }}>
|
placeholder="自定义任务标签(可选)"
|
||||||
¥{price}/{priceUnit || '次'}
|
value={taskTag}
|
||||||
</Text>
|
onChange={(e) => setTaskTag(e.target.value)}
|
||||||
)}
|
allowClear
|
||||||
{priceType === 'MULTI_DIMENSION' && (
|
maxLength={50}
|
||||||
<Text type="secondary" style={{ fontSize: 12, whiteSpace: 'nowrap' }}>
|
style={{ flex: 1, minWidth: 0 }}
|
||||||
按规格计费
|
/>
|
||||||
</Text>
|
|
||||||
)}
|
<Button
|
||||||
{priceType === 'BILLING_ADAPTER' && (
|
icon={<ReloadOutlined />}
|
||||||
<Text type="secondary" style={{ fontSize: 12, whiteSpace: 'nowrap' }}>
|
onClick={handleReset}
|
||||||
按用量计费
|
disabled={!selectedModel}
|
||||||
</Text>
|
title="重置参数"
|
||||||
)}
|
/>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
@@ -305,15 +372,18 @@ export function ModelInputForm() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
loading={submitting}
|
loading={submitting}
|
||||||
disabled={!isLoggedIn || !selectedModel}
|
disabled={!isLoggedIn || !selectedModel}
|
||||||
block
|
|
||||||
size="large"
|
size="large"
|
||||||
style={{
|
style={{
|
||||||
background: token.colorPrimary,
|
background: token.colorPrimary,
|
||||||
height: 44,
|
height: 40,
|
||||||
fontSize: 16,
|
fontSize: 14,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
开始生成
|
{priceType === 'SIMPLE' && price !== undefined
|
||||||
|
? `消费 ¥${price}/${priceUnit || '次'}`
|
||||||
|
: '消费'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
// 职责:
|
// 职责:
|
||||||
// - 遍历模型的 param_schema,解析 spec/ui 字段
|
// - 遍历模型的 param_schema,解析 spec/ui 字段
|
||||||
// - 查 WIDGET_MAP 获取对应的 React 组件
|
// - 查 WIDGET_MAP 获取对应的 React 组件
|
||||||
|
// - 解析 constraints_config.requires 计算字段间依赖
|
||||||
// - 组装出可直接渲染的 UIFieldConfig 数组
|
// - 组装出可直接渲染的 UIFieldConfig 数组
|
||||||
//
|
//
|
||||||
// ModelInputForm 只需遍历 fields 渲染 Form.Item 即可,
|
// ModelInputForm 只需遍历 fields 渲染 Form.Item 即可,
|
||||||
@@ -43,6 +44,14 @@ export interface UIFieldConfig {
|
|||||||
valuePropName?: string;
|
valuePropName?: string;
|
||||||
/** 是否为文件上传类控件(需要 getValueFromEvent 提取 fileList) */
|
/** 是否为文件上传类控件(需要 getValueFromEvent 提取 fileList) */
|
||||||
isFileWidget: boolean;
|
isFileWidget: boolean;
|
||||||
|
/**
|
||||||
|
* 条件依赖:当这些字段的值为空时,本字段应被禁用。
|
||||||
|
*
|
||||||
|
* 来源于 constraints_config.requires 的逆向推导:
|
||||||
|
* { if: "sref", then: ["sw", "sv"] }
|
||||||
|
* → sw.dependsOn = ["sref"], sv.dependsOn = ["sref"]
|
||||||
|
*/
|
||||||
|
dependsOn?: string[];
|
||||||
/** 传给 widget 组件的配置(placeholder/options/min/max 等) */
|
/** 传给 widget 组件的配置(placeholder/options/min/max 等) */
|
||||||
widgetConfig: Record<string, unknown>;
|
widgetConfig: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
@@ -51,49 +60,76 @@ export interface UIFieldConfig {
|
|||||||
|
|
||||||
const FILE_WIDGETS = new Set(['imageInput', 'imageList', 'audioList']);
|
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<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 ----------
|
// ---------- 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[] — 渲染就绪的字段配置列表
|
* @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(
|
export function useModelUI(
|
||||||
paramSchema: ModelsListResponseBody['param_schema'],
|
paramSchema: ModelsListResponseBody['param_schema'],
|
||||||
|
constraintsConfig: Record<string, unknown> = {},
|
||||||
): UIFieldConfig[] {
|
): UIFieldConfig[] {
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
if (!paramSchema || paramSchema.length === 0) return [];
|
if (!paramSchema || paramSchema.length === 0) return [];
|
||||||
|
|
||||||
|
const depMap = buildDependencyMap(constraintsConfig);
|
||||||
|
|
||||||
return paramSchema.map((item, index) => {
|
return paramSchema.map((item, index) => {
|
||||||
const spec = (item.spec || {}) as Record<string, unknown>;
|
const spec = (item.spec || {}) as Record<string, unknown>;
|
||||||
const ui = (item.ui || {}) as Record<string, unknown>;
|
const ui = (item.ui || {}) as Record<string, unknown>;
|
||||||
const widget = (ui.widget as string) || 'lineEdit';
|
const widget = (ui.widget as string) || 'lineEdit';
|
||||||
|
|
||||||
// 查找组件(未知 widget 用 LineInput 兜底)
|
|
||||||
const component = WIDGET_MAP[widget] || FALLBACK_WIDGET;
|
const component = WIDGET_MAP[widget] || FALLBACK_WIDGET;
|
||||||
|
|
||||||
// 构建传给组件的配置
|
|
||||||
const widgetConfig: Record<string, unknown> = {
|
const widgetConfig: Record<string, unknown> = {
|
||||||
placeholder: ui.placeholder || `请输入${ui.label || item.map_to}`,
|
placeholder: ui.placeholder || `请输入${ui.label || item.map_to}`,
|
||||||
required: spec.required,
|
required: spec.required,
|
||||||
// comboBox
|
|
||||||
enumValues: spec.enum,
|
enumValues: spec.enum,
|
||||||
// spinBox / doubleSpinBox
|
|
||||||
minValue: spec.min_value,
|
minValue: spec.min_value,
|
||||||
maxValue: spec.max_value,
|
maxValue: spec.max_value,
|
||||||
step: spec.single_step,
|
step: spec.single_step,
|
||||||
// textEdit / lineEdit
|
|
||||||
minLength: spec.min_length,
|
minLength: spec.min_length,
|
||||||
maxLength: spec.max_length,
|
maxLength: spec.max_length,
|
||||||
// imageInput / imageList / audioList
|
// imageInput / imageList / audioList
|
||||||
@@ -101,36 +137,43 @@ export function useModelUI(
|
|||||||
maxFileSizeMb: spec.max_file_size_mb,
|
maxFileSizeMb: spec.max_file_size_mb,
|
||||||
minFiles: spec.min_files,
|
minFiles: spec.min_files,
|
||||||
maxFiles: spec.max_files,
|
maxFiles: spec.max_files,
|
||||||
// imageInput
|
|
||||||
enableSwitch: ui.enable_switch,
|
enableSwitch: ui.enable_switch,
|
||||||
};
|
};
|
||||||
|
|
||||||
const isFile = FILE_WIDGETS.has(widget);
|
const isFile = FILE_WIDGETS.has(widget);
|
||||||
const isCheckbox = widget === 'checkbox';
|
const isCheckbox = widget === 'checkbox';
|
||||||
|
const name = item.map_to;
|
||||||
|
const dependsOn = depMap.get(name);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
index,
|
index,
|
||||||
name: item.map_to,
|
name,
|
||||||
component,
|
component,
|
||||||
label: (ui.label as string) || item.map_to,
|
label: (ui.label as string) || name,
|
||||||
must: (spec.required as boolean) || false,
|
must: (spec.required as boolean) || false,
|
||||||
defaultValue: spec.default,
|
defaultValue: spec.default,
|
||||||
tips: (ui.tips as string) || undefined,
|
tips: (ui.tips as string) || undefined,
|
||||||
valuePropName: isCheckbox ? 'checked' : undefined,
|
valuePropName: isCheckbox ? 'checked' : undefined,
|
||||||
isFileWidget: isFile,
|
isFileWidget: isFile,
|
||||||
|
dependsOn: dependsOn && dependsOn.length > 0 ? dependsOn : undefined,
|
||||||
widgetConfig,
|
widgetConfig,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [paramSchema]);
|
}, [paramSchema, constraintsConfig]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 判断 widget 类型是否为文件上传类控件 */
|
||||||
* 判断一个 widget 类型是否为文件上传类控件。
|
|
||||||
*
|
|
||||||
* 文件控件在 Form.Item 中需要特殊处理:
|
|
||||||
* - getValueFromEvent 提取 e.fileList
|
|
||||||
* - value 类型为 UploadFile[] 而非 string
|
|
||||||
*/
|
|
||||||
export function isFileWidget(widgetName: string): boolean {
|
export function isFileWidget(widgetName: string): boolean {
|
||||||
return FILE_WIDGETS.has(widgetName);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ export function DoubleSpinBox({ value, onChange, disabled, config }: WidgetProps
|
|||||||
min={config.minValue as number | undefined}
|
min={config.minValue as number | undefined}
|
||||||
max={config.maxValue as number | undefined}
|
max={config.maxValue as number | undefined}
|
||||||
step={config.step as number || 0.1}
|
step={config.step as number || 0.1}
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%', minWidth: 140 }}
|
||||||
placeholder={(config.placeholder as string) || undefined}
|
placeholder={(config.placeholder as string) || undefined}
|
||||||
|
controls
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// ImageInput — imageInput → Upload(单张图片)
|
// 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 { UploadOutlined } from '@ant-design/icons';
|
||||||
import type { UploadFile } from 'antd/es/upload';
|
import type { UploadFile } from 'antd/es/upload';
|
||||||
import type { WidgetProps } from './types';
|
import type { WidgetProps } from './types';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
/** 构建图片 accept 字符串 */
|
/** 构建图片 accept 字符串 */
|
||||||
function buildAccept(filters?: string[]): string {
|
function buildAccept(filters?: string[]): string {
|
||||||
if (!filters || filters.length === 0) return 'image/png,image/jpg,image/jpeg,image/webp';
|
if (!filters || filters.length === 0) return 'image/png,image/jpg,image/jpeg,image/webp';
|
||||||
@@ -17,15 +25,47 @@ function buildAccept(filters?: string[]): string {
|
|||||||
export function ImageInput({ value, onChange, disabled, config }: WidgetProps) {
|
export function ImageInput({ value, onChange, disabled, config }: WidgetProps) {
|
||||||
const fileList = (value as UploadFile[]) || [];
|
const fileList = (value as UploadFile[]) || [];
|
||||||
const maxSizeMb = (config.maxFileSizeMb as number) || 10;
|
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 (
|
return (
|
||||||
|
<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"
|
listType="picture-card"
|
||||||
maxCount={1}
|
maxCount={1}
|
||||||
accept={buildAccept(config.fileFilter as string[] | undefined)}
|
accept={buildAccept(config.fileFilter as string[] | undefined)}
|
||||||
fileList={fileList}
|
fileList={fileList}
|
||||||
onChange={({ fileList: newList }) => onChange?.(newList)}
|
onChange={({ fileList: newList }) => onChange?.(newList as unknown as UploadFile[])}
|
||||||
disabled={disabled}
|
disabled={isUploadDisabled}
|
||||||
beforeUpload={(file) => {
|
beforeUpload={(file) => {
|
||||||
const isImage = file.type.startsWith('image/');
|
const isImage = file.type.startsWith('image/');
|
||||||
if (!isImage) {
|
if (!isImage) {
|
||||||
@@ -40,11 +80,12 @@ export function ImageInput({ value, onChange, disabled, config }: WidgetProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{fileList.length < 1 && (
|
{fileList.length < 1 && (
|
||||||
<div>
|
<div style={{ opacity: isUploadDisabled ? 0.4 : 1 }}>
|
||||||
<UploadOutlined />
|
<UploadOutlined />
|
||||||
<div style={{ marginTop: 4, fontSize: 12 }}>上传</div>
|
<div style={{ marginTop: 4, fontSize: 12 }}>上传</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Upload>
|
</Upload>
|
||||||
|
</Space>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ export function SpinBox({ value, onChange, disabled, config }: WidgetProps) {
|
|||||||
min={config.minValue as number | undefined}
|
min={config.minValue as number | undefined}
|
||||||
max={config.maxValue as number | undefined}
|
max={config.maxValue as number | undefined}
|
||||||
step={1}
|
step={1}
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%', minWidth: 140 }}
|
||||||
placeholder={(config.placeholder as string) || undefined}
|
placeholder={(config.placeholder as string) || undefined}
|
||||||
|
controls
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user