Files
ele-HeiXiu/src/pages/home/components/ModelInputForm.tsx
YoungestSongMo d5ba7e63cd refactor: 中间栏三层架构 — useUI 组件体系 + useModelUI Hook
- 新建 useUI/ 目录:12 个文件,10 个 Qt 控件 → antd 组件封装 + 类型 + 注册表
- 新建 useModelUI Hook:param_schema → UIFieldConfig[] 自动映射
- ModelInputForm 瘦身 ~160 行:删除 buildParamDescriptors/renderParamControl/文件状态管理
- WIDGET_MAP: Record<string, ComponentType<WidgetProps>> — 零 any,完全类型安全
- 新增控件只需两步:创建组件文件 + 在 WIDGET_MAP 加一行
- 删除旧 widgets/ 目录

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 15:32:17 +08:00

322 lines
11 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 — 模型输入表单(纯布局外壳)
//
// 布局Header模型信息+meta → Bodyfields.map 渲染) → Footer价格+提交)
//
// 与旧版的区别:
// - 控件渲染逻辑已提取到 useUI/ 组件体系
// - param_schema 解析已提取到 useModelUI Hook
// - 本组件只负责布局、Form 容器、提交逻辑
// ============================================================
import { useEffect, useMemo, useCallback } from 'react';
import {
Form,
Button,
Typography,
Empty,
Tag,
Space,
theme as antTheme,
message,
} from 'antd';
import { SendOutlined, ClockCircleOutlined } from '@ant-design/icons';
import type { UploadFile } from 'antd/es/upload';
import { useHomeContext } from '@/pages/home/HomeContent';
import { useAppContext } from '@/components/AppProvider';
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';
const { Text, Title } = Typography;
// ---------- 组件 ----------
export function ModelInputForm() {
const { token } = antTheme.useToken();
const { isLoggedIn } = useAppContext();
const { selectedModel } = useHomeContext();
const [form] = Form.useForm();
// 提交突变 Hook
const { execute: submitTask, loading: submitting, errorMessage } = useSubmitTask({
onSuccess: useCallback((_data: TaskInfoItemResponseBody) => {
message.success('任务已提交');
emit(EVENTS.TASK_SUBMITTED);
}, []),
});
// ======== useModelUIparam_schema → UIFieldConfig[] ========
const fields: UIFieldConfig[] = useModelUI(selectedModel?.param_schema || []);
// 构建初始值
const initialValues = useMemo(() => {
const iv: Record<string, unknown> = {};
fields.forEach((f) => {
if (f.defaultValue !== undefined && f.defaultValue !== null) {
iv[f.name] = f.defaultValue;
}
});
return iv;
}, [fields]);
// 选中模型时重置表单
useEffect(() => {
if (selectedModel) {
form.resetFields();
}
}, [selectedModel?.id]); // eslint-disable-line react-hooks/exhaustive-deps
// ======== 提交 ========
const handleSubmit = async () => {
if (!isLoggedIn) {
message.warning('请先登录后再提交任务');
return;
}
if (!selectedModel) return;
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
.filter((f) => f.status === 'done')
.map((f) => f.response?.url || f.url || f.name)
.filter(Boolean) as string[];
if (urls.length > 0) {
params[field.name] = urls.length === 1 ? urls[0] : JSON.stringify(urls);
}
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 });
} catch (err) {
if (err && typeof err === 'object' && 'errorFields' in err) {
return; // 表单验证错误antd 自动提示
}
message.error(errorMessage || '任务提交失败,请重试');
}
};
// ======== 未选择模型 ========
if (!selectedModel) {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
}}
>
<Empty description="请在左侧选择一个模型" image={Empty.PRESENTED_IMAGE_SIMPLE} />
</div>
);
}
// ======== 提取 meta 信息 ========
const metaConfig = (selectedModel.meta_config || {}) as Record<string, unknown>;
const uiConfig = (selectedModel.ui_config || {}) as Record<string, unknown>;
const pricingConfig = (selectedModel.pricing_config || {}) as Record<string, unknown>;
const outputType = metaConfig.output_type as OutputTypeString | undefined;
const estimatedDuration = metaConfig.estimated_duration_seconds as number | undefined;
const badge = uiConfig.badge as string | undefined;
const group = uiConfig.group as string | undefined;
const helpUrl = metaConfig.help_url as string | undefined;
const priceUnit = (pricingConfig.unit as Record<string, unknown>)?.name as string | undefined;
const price = pricingConfig.price as number | undefined;
const priceType = pricingConfig.price_type as string | undefined;
// ======== 正常渲染 ========
return (
<div
style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
{/* ======== Header模型信息 ======== */}
<div
style={{
padding: '16px 20px',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
flexShrink: 0,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
{badge && <span style={{ fontSize: 20 }}>{badge}</span>}
<Title level={4} style={{ margin: 0, fontSize: 18 }}>
{selectedModel.name}
</Title>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<Text type="secondary" style={{ fontSize: 12 }}>
{selectedModel.provider_name}
{group
? ` · ${group}`
: selectedModel.category_name
? ` · ${selectedModel.category_name}`
: ''}
</Text>
</div>
<Space size={4} style={{ marginTop: 8 }} wrap>
{outputType && (
<Tag color="blue" style={{ fontSize: 11, lineHeight: '18px' }}>
{OutputTypeMap[outputType] || outputType}
</Tag>
)}
{estimatedDuration !== undefined && (
<Tag
icon={<ClockCircleOutlined />}
color="default"
style={{ fontSize: 11, lineHeight: '18px' }}
>
{estimatedDuration}
</Tag>
)}
{helpUrl && (
<a
href={helpUrl}
target="_blank"
rel="noopener noreferrer"
style={{ fontSize: 11 }}
>
使
</a>
)}
</Space>
</div>
{/* ======== Body参数列表useModelUI 驱动) ======== */}
<div style={{ flex: 1, overflow: 'auto', padding: '16px 20px' }}>
{fields.length === 0 ? (
<Empty
description="该模型无需额外参数"
image={Empty.PRESENTED_IMAGE_SIMPLE}
style={{ marginTop: 24 }}
/>
) : (
<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>
)}
</div>
{/* ======== Footer价格 + 提交 ======== */}
<div
style={{
padding: '12px 20px',
borderTop: `1px solid ${token.colorBorderSecondary}`,
flexShrink: 0,
display: 'flex',
alignItems: 'center',
gap: 12,
}}
>
{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>
)}
<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>
);
}