模型列表 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
469 lines
15 KiB
TypeScript
469 lines
15 KiB
TypeScript
// ============================================================
|
||
// 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 key(item.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>
|
||
);
|
||
}
|