feat :修改文件结构

This commit is contained in:
2026-06-05 19:24:06 +08:00
parent 1da95eaef3
commit af85d31035
15 changed files with 577 additions and 273 deletions

View File

@@ -0,0 +1,463 @@
// ============================================================
// 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 { TaskInfoItemResponseBody } from '@/services/modules';
const { Text, Title } = Typography;
const { TextArea } = Input;
const { Dragger } = Upload;
// ---------- 参数类型映射 ----------
/** 从 ui_config 中解析的参数描述 */
interface ParamDescriptor {
key: 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 + ui_config 构建参数描述符列表 */
function buildParamDescriptors(
paramSchema: string[],
uiConfig: Record<string, unknown>,
): ParamDescriptor[] {
return paramSchema.map((key) => {
const config = (uiConfig[key] || {}) as Record<string, unknown>;
const controlType = inferControlType(config);
return {
key,
label: (config.label as string) || key,
controlType,
defaultValue: config.default ?? config.defaultValue,
required: (config.required as boolean) || false,
placeholder: (config.placeholder as string) || `请输入${config.label || key}`,
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.ui_config || {},
);
}, [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.key}
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>
);
}