- 新建 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>
322 lines
11 KiB
TypeScript
322 lines
11 KiB
TypeScript
// ============================================================
|
||
// ModelInputForm — 模型输入表单(纯布局外壳)
|
||
//
|
||
// 布局:Header(模型信息+meta) → Body(fields.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);
|
||
}, []),
|
||
});
|
||
|
||
// ======== useModelUI:param_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>
|
||
);
|
||
}
|