Files
ele-HeiXiu/src/pages/home/components/ModelInputForm.tsx
YoungestSongMo 59059caae7 feat: 重构模型列表UI + 修复后端数据格式适配
模型列表 UI 重构:
- ModelSelector 从 Menu 折叠分组改为 Tabs 标签页 + 平铺列表
- 新增「全部模型」Tab + 各分类由 MODEL_CATEGORIES 数组控制顺序
- ModelItem 新增 hover 高亮效果 + 维护中模型禁用 + toast 提示
- 新增错误态(Result + 重试按钮)

后端数据格式适配:
- param_schema 类型从 string[] 改为 ParamSchemaItem[]({id, map_to, spec, ui})
- 修复 Form.Item 重复 key 报错(item.id 作 React key,item.map_to 作表单字段名)
- ModelsListResponseBody 字段类型收紧(model_type / category_name / quota_config)

数据层重构:
- useModelList 从 4 次并行请求改为 1 次全量拉取(后端 category 参数不可用)
- 分组键从 category_name 改为 model_type
- page_size 提升至 200

类型体系增强:
- type 别名改为 as const 数组推导(MODEL_CATEGORIES 单一数据源)
- 新增 CATEGORY_SLUG_MAP + resolveCategoryLabel + toCategorySlug 映射层
- 类型重命名 ModelCategory → ModelCategoryStringEnum
2026-06-08 19:02:32 +08:00

469 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// ModelInputForm — 动态模型输入表单
// 根据选中模型的 param_schema + ui_config 动态渲染表单控件
// ============================================================
import { useState, useEffect, useMemo, useCallback } from 'react';
import {
Form,
Input,
InputNumber,
Select,
Slider,
Button,
Typography,
Empty,
Upload,
Switch,
theme as antTheme,
message,
} from 'antd';
import {
SendOutlined,
UploadOutlined,
InboxOutlined,
} from '@ant-design/icons';
import type { UploadFile } from 'antd/es/upload';
import { useHomeContext } from '@/contexts/home-context';
import { useAppContext } from '@/contexts/app-context';
import { useSubmitTask } from '@/hooks/use-api';
import { emit, EVENTS } from '@/utils/event-bus';
import type { ModelsListResponseBody, TaskInfoItemResponseBody } from '@/services/modules';
const { Text, Title } = Typography;
const { TextArea } = Input;
const { Dragger } = Upload;
// ---------- 参数类型映射 ----------
/** 从 ui_config 中解析的参数描述 */
interface ParamDescriptor {
/** React keyitem.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<string, unknown>): 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<string, unknown>;
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,
};
});
}
// ---------- 组件 ----------
export function ModelInputForm() {
const { token } = antTheme.useToken();
const { isLoggedIn } = useAppContext();
const { selectedModel } = useHomeContext();
const [form] = Form.useForm();
const [referenceFiles, setReferenceFiles] = useState<UploadFile[]>([]);
// 提交突变 Hook自动处理 loading / error / 日志)
const { execute: submitTask, loading: submitting, errorMessage } = useSubmitTask({
onSuccess: useCallback(
(_data: TaskInfoItemResponseBody) => {
message.success('任务已提交');
// 通过事件总线通知 TaskHistory 刷新(替代 Context 中的 refreshTaskSignal
emit(EVENTS.TASK_SUBMITTED);
},
[],
),
});
// 选中模型时重置表单(仅当有选中模型时操作,避免 form 未连接警告)
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('请先登录后再提交任务');
return;
}
if (!selectedModel) return;
try {
const values = await form.validateFields();
// 分离固定字段和模型动态参数
const { prompt, negative_prompt, num_outputs, ...dynamicParams } = values;
// 构建提交参数(全部转为字符串值)
const params: Record<string, string> = {
prompt: String(prompt ?? ''),
negative_prompt: String(negative_prompt ?? ''),
num_outputs: String(num_outputs ?? 1),
};
// 动态参数统一转字符串
for (const [key, value] of Object.entries(dynamicParams)) {
if (value !== undefined && value !== null) {
params[key] = 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;
}
// 错误消息由 Hook 自动通过 errorMessage 提供
message.error(errorMessage || '任务提交失败,请重试');
}
};
// 渲染单个参数控件
const renderParamControl = (desc: ParamDescriptor) => {
const commonProps = {
placeholder: desc.placeholder,
style: { width: '100%' },
};
switch (desc.controlType) {
case 'number':
return (
<InputNumber
{...commonProps}
min={desc.min}
max={desc.max}
step={desc.step}
style={{ width: '100%' }}
/>
);
case 'textarea':
return <TextArea rows={3} {...commonProps} />;
case 'select':
return (
<Select
{...commonProps}
options={desc.options}
allowClear
/>
);
case 'slider':
return (
<Slider
min={desc.min ?? 1}
max={desc.max ?? 100}
step={desc.step ?? 1}
marks={
desc.min !== undefined && desc.max !== undefined
? { [desc.min]: `${desc.min}`, [desc.max]: `${desc.max}` }
: undefined
}
tooltip={{ formatter: (v) => v }}
/>
);
case 'switch':
return <Switch />;
case 'image-upload':
return (
<Upload
listType="picture-card"
maxCount={1}
accept="image/png,image/jpg,image/jpeg,image/webp"
beforeUpload={(file) => {
const isImage = file.type.startsWith('image/');
const isLt10M = file.size / 1024 / 1024 < 10;
if (!isImage) {
message.error('只能上传图片文件');
return Upload.LIST_IGNORE;
}
if (!isLt10M) {
message.error('图片大小不能超过 10MB');
return Upload.LIST_IGNORE;
}
return false; // 手动上传
}}
>
<div>
<UploadOutlined />
<div style={{ marginTop: 4, fontSize: 12 }}></div>
</div>
</Upload>
);
default:
return <Input {...commonProps} />;
}
};
// ---- 未选择模型 ----
if (!selectedModel) {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
}}
>
<Empty description="请在左侧选择一个模型" image={Empty.PRESENTED_IMAGE_SIMPLE} />
</div>
);
}
// ---- 表单 ----
return (
<div
style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
{/* 模型标题 */}
<div
style={{
padding: '16px 20px',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
flexShrink: 0,
}}
>
<Title level={4} style={{ margin: 0, fontSize: 18 }}>
{selectedModel.name}
</Title>
<Text type="secondary" style={{ fontSize: 12 }}>
{selectedModel.provider_name} · {selectedModel.category_name}
</Text>
</div>
{/* 可滚动表单区 */}
<div style={{ flex: 1, overflow: 'auto', padding: '16px 20px' }}>
<Form
form={form}
layout="vertical"
size="small"
initialValues={{ num_outputs: 1 }}
>
{/* ======== 固定字段 ======== */}
{/* 提示词 */}
<Form.Item
name="prompt"
label={<Text strong></Text>}
rules={[{ required: true, message: '请输入提示词' }]}
>
<TextArea
rows={4}
placeholder="描述你想要的画面、视频或音频内容..."
showCount
maxLength={2000}
/>
</Form.Item>
{/* 负面提示词 */}
<Form.Item
name="negative_prompt"
label={<Text type="secondary"></Text>}
>
<TextArea
rows={2}
placeholder="描述你不希望在结果中出现的内容(可选)"
maxLength={1000}
/>
</Form.Item>
{/* 参考图 */}
<Form.Item label={<Text strong></Text>}>
<Dragger
multiple
listType="picture"
fileList={referenceFiles}
onChange={({ fileList }) => setReferenceFiles(fileList)}
accept="image/png,image/jpg,image/jpeg,image/webp"
beforeUpload={(file) => {
const isImage = file.type.startsWith('image/');
const isLt10M = file.size / 1024 / 1024 < 10;
if (!isImage) {
message.error('只能上传图片文件');
return Upload.LIST_IGNORE;
}
if (!isLt10M) {
message.error('图片大小不能超过 10MB');
return Upload.LIST_IGNORE;
}
return false;
}}
maxCount={5}
style={{ marginBottom: 8 }}
>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text"></p>
<p className="ant-upload-hint">
PNG / JPG / WebP 10MB
</p>
</Dragger>
</Form.Item>
{/* 生成数量 */}
<Form.Item
name="num_outputs"
label={<Text strong></Text>}
>
<InputNumber min={1} max={10} style={{ width: '100%' }} />
</Form.Item>
{/* ======== 模型动态参数 ======== */}
{paramDescriptors.length > 0 && (
<div
style={{
marginTop: 16,
paddingTop: 16,
borderTop: `1px solid ${token.colorBorderSecondary}`,
}}
>
<Text strong style={{ display: 'block', marginBottom: 12 }}>
</Text>
{paramDescriptors.map((desc) => (
<Form.Item
key={desc.key}
name={desc.name}
label={desc.label}
rules={desc.required ? [{ required: true, message: `请输入${desc.label}` }] : undefined}
initialValue={desc.defaultValue}
valuePropName={desc.controlType === 'switch' ? 'checked' : 'value'}
>
{renderParamControl(desc)}
</Form.Item>
))}
</div>
)}
</Form>
</div>
{/* 提交按钮 */}
<div
style={{
padding: '12px 20px',
borderTop: `1px solid ${token.colorBorderSecondary}`,
flexShrink: 0,
}}
>
<Button
type="primary"
icon={<SendOutlined />}
onClick={handleSubmit}
loading={submitting}
disabled={!isLoggedIn || !selectedModel}
block
size="large"
style={{
background: token.colorPrimary,
height: 44,
fontSize: 16,
}}
>
</Button>
</div>
</div>
);
}