= {};
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() {
- {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 }}
+ />
+
+ }
+ onClick={handleReset}
+ disabled={!selectedModel}
+ title="重置参数"
+ />
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
/>
);
}