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
This commit is contained in:
@@ -24,9 +24,9 @@ import {
|
|||||||
type CreatTaskRequestBody,
|
type CreatTaskRequestBody,
|
||||||
type ModelsListResponseBody,
|
type ModelsListResponseBody,
|
||||||
type Banner,
|
type Banner,
|
||||||
type ModelCategory,
|
type ModelCategoryStringEnum,
|
||||||
} from '@/services/modules';
|
} from '@/services/modules';
|
||||||
import {useAsyncData, useAsyncMutation} from './use-async';
|
import {useAsyncData, UseAsyncDataReturn, useAsyncMutation} from './use-async';
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Banner
|
// Banner
|
||||||
@@ -47,53 +47,38 @@ export function useBannerList() {
|
|||||||
// 模型
|
// 模型
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
/** 模型列表数据 Hook(按类别分别请求后合并) */
|
/** 模型列表数据 Hook(单次请求全量模型 + 客户端按 category_name 分组)
|
||||||
|
|
||||||
|
* 因后端 category 查询参数实际不可用(传参返回空列表),
|
||||||
|
* 改为不带分类参数一次拉取全部,再由客户端根据响应中的
|
||||||
|
* category_name 字段手动分组。 */
|
||||||
export function useModelList() {
|
export function useModelList() {
|
||||||
const {isLoggedIn} = useAppContext();
|
const {isLoggedIn} = useAppContext();
|
||||||
|
|
||||||
// 使用单个 fetcher 返回合并后的结果
|
const result: UseAsyncDataReturn<ModelsListResponseBody[]> = useAsyncData<ModelsListResponseBody[]>(
|
||||||
const result = useAsyncData<ModelsListResponseBody[]>(
|
() => ModelAPI.fetchModels({page: 1, page_size: 200}),
|
||||||
async () => {
|
|
||||||
const categories: ModelCategory[] = ["image_to_image", "text_to_image", "image_to_video", "text_to_audio"];
|
|
||||||
const results = await Promise.allSettled(
|
|
||||||
categories.map((category) =>
|
|
||||||
ModelAPI.fetchModels({
|
|
||||||
provider_name: '',
|
|
||||||
category,
|
|
||||||
page: 1,
|
|
||||||
page_size: 50,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const allModels: ModelsListResponseBody[] = [];
|
|
||||||
results.forEach((r) => {
|
|
||||||
if (r.status === 'fulfilled') {
|
|
||||||
allModels.push(...r.value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// 按优先级降序排列
|
|
||||||
allModels.sort((a, b) => b.priority - a.priority);
|
|
||||||
return allModels;
|
|
||||||
},
|
|
||||||
[],
|
[],
|
||||||
{enabled: isLoggedIn, label: 'model-list'},
|
{enabled: isLoggedIn, label: 'model-list'},
|
||||||
);
|
);
|
||||||
|
|
||||||
// 按类别分组
|
// 按 category_name 分组(后端返回的 category_name 为 slug 格式如 img2img)
|
||||||
const groupedModels = useMemo(() => {
|
const groupedModels: Map<string, ModelsListResponseBody[]> = useMemo(() => {
|
||||||
const groups = new Map<string, ModelsListResponseBody[]>();
|
const groups = new Map<string, ModelsListResponseBody[]>();
|
||||||
result.data?.forEach((model) => {
|
result.data?.forEach((model: ModelsListResponseBody) => {
|
||||||
const cat = model.category_name || 'other';
|
const cat: ModelCategoryStringEnum = model.model_type;
|
||||||
if (!groups.has(cat)) groups.set(cat, []);
|
if (!groups.has(cat)) groups.set(cat, []);
|
||||||
groups.get(cat)!.push(model);
|
groups.get(cat)!.push(model);
|
||||||
});
|
});
|
||||||
|
// 每组内按优先级降序排列
|
||||||
|
// groups.forEach((models) => {
|
||||||
|
// models.sort((a, b) => b.priority - a.priority);
|
||||||
|
// });
|
||||||
return groups;
|
return groups;
|
||||||
}, [result.data]);
|
}, [result.data]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
/** 按类别分组后的模型 */
|
/** 按 model_type 分组后的模型(key 为后端 slug) */
|
||||||
groupedModels,
|
groupedModels,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -163,4 +148,4 @@ export function useSubmitTask(options?: {
|
|||||||
// ---------- 重新导出类型和工具 ----------
|
// ---------- 重新导出类型和工具 ----------
|
||||||
|
|
||||||
export {MODEL_CATEGORY_LABELS};
|
export {MODEL_CATEGORY_LABELS};
|
||||||
export type {ModelCategory, ModelsListResponseBody, TaskInfoItemResponseBody, Banner};
|
export type {ModelCategoryStringEnum, ModelsListResponseBody, TaskInfoItemResponseBody, Banner};
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import { useHomeContext } from '@/contexts/home-context';
|
|||||||
import { useAppContext } from '@/contexts/app-context';
|
import { useAppContext } from '@/contexts/app-context';
|
||||||
import { useSubmitTask } from '@/hooks/use-api';
|
import { useSubmitTask } from '@/hooks/use-api';
|
||||||
import { emit, EVENTS } from '@/utils/event-bus';
|
import { emit, EVENTS } from '@/utils/event-bus';
|
||||||
import type { TaskInfoItemResponseBody } from '@/services/modules';
|
import type { ModelsListResponseBody, TaskInfoItemResponseBody } from '@/services/modules';
|
||||||
|
|
||||||
const { Text, Title } = Typography;
|
const { Text, Title } = Typography;
|
||||||
const { TextArea } = Input;
|
const { TextArea } = Input;
|
||||||
@@ -39,7 +39,10 @@ const { Dragger } = Upload;
|
|||||||
|
|
||||||
/** 从 ui_config 中解析的参数描述 */
|
/** 从 ui_config 中解析的参数描述 */
|
||||||
interface ParamDescriptor {
|
interface ParamDescriptor {
|
||||||
|
/** React key(item.id,保证唯一) */
|
||||||
key: string;
|
key: string;
|
||||||
|
/** API 字段名(item.map_to) */
|
||||||
|
name: string;
|
||||||
label: string;
|
label: string;
|
||||||
/** 控件类型 */
|
/** 控件类型 */
|
||||||
controlType: 'text' | 'number' | 'textarea' | 'select' | 'slider' | 'switch' | 'image-upload';
|
controlType: 'text' | 'number' | 'textarea' | 'select' | 'slider' | 'switch' | 'image-upload';
|
||||||
@@ -90,21 +93,26 @@ function inferControlType(config: Record<string, unknown>): ParamDescriptor['con
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 从 param_schema + ui_config 构建参数描述符列表 */
|
/**
|
||||||
|
* 从 param_schema 构建参数描述符列表
|
||||||
|
*
|
||||||
|
* 后端 param_schema 格式为 ParamSchemaItem[](对象数组),
|
||||||
|
* 每项的 ui 字段内嵌了控件类型、标签、校验规则等 UI 配置。
|
||||||
|
*/
|
||||||
function buildParamDescriptors(
|
function buildParamDescriptors(
|
||||||
paramSchema: string[],
|
paramSchema: ModelsListResponseBody['param_schema'],
|
||||||
uiConfig: Record<string, unknown>,
|
|
||||||
): ParamDescriptor[] {
|
): ParamDescriptor[] {
|
||||||
return paramSchema.map((key) => {
|
return paramSchema.map((item) => {
|
||||||
const config = (uiConfig[key] || {}) as Record<string, unknown>;
|
const config = (item.ui || {}) as Record<string, unknown>;
|
||||||
const controlType = inferControlType(config);
|
const controlType = inferControlType(config);
|
||||||
return {
|
return {
|
||||||
key,
|
key: item.id,
|
||||||
label: (config.label as string) || key,
|
name: item.map_to,
|
||||||
|
label: (config.label as string) || item.map_to,
|
||||||
controlType,
|
controlType,
|
||||||
defaultValue: config.default ?? config.defaultValue,
|
defaultValue: config.default ?? config.defaultValue,
|
||||||
required: (config.required as boolean) || false,
|
required: (config.required as boolean) || false,
|
||||||
placeholder: (config.placeholder as string) || `请输入${config.label || key}`,
|
placeholder: (config.placeholder as string) || `请输入${config.label || item.map_to}`,
|
||||||
min: config.min as number | undefined,
|
min: config.min as number | undefined,
|
||||||
max: config.max as number | undefined,
|
max: config.max as number | undefined,
|
||||||
step: config.step as number | undefined,
|
step: config.step as number | undefined,
|
||||||
@@ -145,10 +153,7 @@ export function ModelInputForm() {
|
|||||||
// 参数描述符
|
// 参数描述符
|
||||||
const paramDescriptors = useMemo(() => {
|
const paramDescriptors = useMemo(() => {
|
||||||
if (!selectedModel) return [];
|
if (!selectedModel) return [];
|
||||||
return buildParamDescriptors(
|
return buildParamDescriptors(selectedModel.param_schema || []);
|
||||||
selectedModel.param_schema || [],
|
|
||||||
selectedModel.ui_config || {},
|
|
||||||
);
|
|
||||||
}, [selectedModel]);
|
}, [selectedModel]);
|
||||||
|
|
||||||
// 提交
|
// 提交
|
||||||
@@ -419,7 +424,7 @@ export function ModelInputForm() {
|
|||||||
{paramDescriptors.map((desc) => (
|
{paramDescriptors.map((desc) => (
|
||||||
<Form.Item
|
<Form.Item
|
||||||
key={desc.key}
|
key={desc.key}
|
||||||
name={desc.key}
|
name={desc.name}
|
||||||
label={desc.label}
|
label={desc.label}
|
||||||
rules={desc.required ? [{ required: true, message: `请输入${desc.label}` }] : undefined}
|
rules={desc.required ? [{ required: true, message: `请输入${desc.label}` }] : undefined}
|
||||||
initialValue={desc.defaultValue}
|
initialValue={desc.defaultValue}
|
||||||
|
|||||||
@@ -1,122 +1,235 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// ModelSelector — 模型选择器(垂直单选列表,按类别分组)
|
// ModelSelector — 模型选择器(Tabs 标签页分组 + 平铺列表)
|
||||||
// 使用 useModelList() Hook 获取数据,组件仅负责渲染
|
// 使用 useModelList() Hook 获取数据,组件仅负责渲染
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useMemo } from 'react';
|
import React, { useState, useMemo, useCallback } from 'react';
|
||||||
import { Menu, Spin, Empty, Tag, Typography, theme as antTheme } from 'antd';
|
import { Tabs, Spin, Empty, Tag, Typography, Button, Result, message, theme as antTheme } from 'antd';
|
||||||
import { AppstoreOutlined } from '@ant-design/icons';
|
import { AppstoreOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
import { useModelList, MODEL_CATEGORY_LABELS } from '@/hooks/use-api';
|
import { useModelList } from '@/hooks/use-api';
|
||||||
import { useHomeContext } from '@/contexts/home-context';
|
import { useHomeContext } from '@/contexts/home-context';
|
||||||
import type { ModelCategory } from '@/services/modules';
|
import { resolveCategoryLabel, MODEL_CATEGORIES } from '@/services/modules';
|
||||||
|
import type { ModelsListResponseBody, ModelCategoryStringEnum } from '@/services/modules';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
// ---------- 组件 ----------
|
/** "全部模型" 的虚拟 tab key */
|
||||||
|
const ALL_TAB_KEY = '__all__';
|
||||||
|
|
||||||
|
// ---------- 单模型列表项(平铺) ----------
|
||||||
|
|
||||||
|
/** 单个模型行:hover 高亮 + 选中态 + 状态标签 + 禁用态处理 */
|
||||||
|
interface ModelItemProps {
|
||||||
|
model: ModelsListResponseBody;
|
||||||
|
isSelected: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModelItem({ model, isSelected, onSelect }: ModelItemProps) {
|
||||||
|
const { token } = antTheme.useToken();
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
|
||||||
|
// 维护中的模型不可选
|
||||||
|
const isDisabled = model.status === 'maintenance';
|
||||||
|
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
if (isDisabled) {
|
||||||
|
message.warning(`${model.name} 正在维护中,暂不可用`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSelect();
|
||||||
|
}, [isDisabled, model.name, onSelect]);
|
||||||
|
|
||||||
|
// 背景色 / 边框色 优先级:选中 > hover > 默认
|
||||||
|
let bgColor = 'transparent';
|
||||||
|
let borderColor = 'transparent';
|
||||||
|
if (isSelected) {
|
||||||
|
bgColor = token.colorPrimaryBg;
|
||||||
|
borderColor = token.colorPrimaryBorder;
|
||||||
|
} else if (isHovered && !isDisabled) {
|
||||||
|
bgColor = token.colorFillSecondary;
|
||||||
|
borderColor = token.colorBorderSecondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={handleClick}
|
||||||
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
|
style={{
|
||||||
|
padding: '6px 12px',
|
||||||
|
cursor: isDisabled ? 'not-allowed' : 'pointer',
|
||||||
|
borderRadius: 6,
|
||||||
|
background: bgColor,
|
||||||
|
border: `1px solid ${borderColor}`,
|
||||||
|
opacity: isDisabled ? 0.5 : 1,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 8,
|
||||||
|
transition: 'background 0.15s ease, border-color 0.15s ease, opacity 0.15s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
fontSize: 13,
|
||||||
|
color: isSelected ? token.colorPrimaryText : token.colorText,
|
||||||
|
}}
|
||||||
|
title={model.name}
|
||||||
|
>
|
||||||
|
{model.name}
|
||||||
|
</Text>
|
||||||
|
{model.status !== 'active' && (
|
||||||
|
<Tag
|
||||||
|
color={model.status === 'inactive' ? 'default' : 'orange'}
|
||||||
|
style={{ fontSize: 10, lineHeight: '16px', padding: '0 4px' }}
|
||||||
|
>
|
||||||
|
{model.status === 'maintenance' ? '维护' : '停用'}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 主组件 ----------
|
||||||
|
|
||||||
export function ModelSelector() {
|
export function ModelSelector() {
|
||||||
const { token } = antTheme.useToken();
|
const { token } = antTheme.useToken();
|
||||||
const { selectedModel, setSelectedModel } = useHomeContext();
|
const { selectedModel, setSelectedModel } = useHomeContext();
|
||||||
const { data: models, loading, groupedModels } = useModelList();
|
const { data: models, loading, error, groupedModels, refetch } = useModelList();
|
||||||
|
const [activeTab, setActiveTab] = useState(ALL_TAB_KEY);
|
||||||
|
|
||||||
// 构建 Menu items:分组标题 + 模型项
|
// 当前 Tab 对应的模型列表
|
||||||
const menuItems = useMemo(() => {
|
const filteredModels:ModelsListResponseBody[] = useMemo(() => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
if (activeTab === ALL_TAB_KEY) return models ?? [];
|
||||||
const items: any[] = [];
|
return groupedModels.get(activeTab) ?? [];
|
||||||
|
}, [activeTab, models, groupedModels]);
|
||||||
|
|
||||||
groupedModels.forEach((groupModels, category) => {
|
// 构建 Tab 项(全部 + 各分类,顺序由 MODEL_CATEGORIES 控制)
|
||||||
const categoryLabel = MODEL_CATEGORY_LABELS[category as ModelCategory] || category;
|
const tabItems: { key: string; label: React.ReactNode }[] = useMemo(() => {
|
||||||
items.push({
|
const items: { key: string; label: React.ReactNode }[] = [
|
||||||
key: `group-${category}`,
|
{
|
||||||
label: (
|
key: ALL_TAB_KEY,
|
||||||
<Text strong style={{ fontSize: 12, color: token.colorTextSecondary }}>
|
label: `全部 (${models?.length ?? 0})`,
|
||||||
{categoryLabel} ({groupModels.length})
|
},
|
||||||
</Text>
|
];
|
||||||
),
|
|
||||||
type: 'group' as const,
|
|
||||||
children: groupModels.map((model) => ({
|
|
||||||
key: model.id,
|
|
||||||
label: (
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
fontSize: 13,
|
|
||||||
}}
|
|
||||||
title={model.name}
|
|
||||||
>
|
|
||||||
{model.name}
|
|
||||||
</Text>
|
|
||||||
{model.status !== 'active' && (
|
|
||||||
<Tag
|
|
||||||
color={model.status === 'inactive' ? 'default' : 'orange'}
|
|
||||||
style={{ fontSize: 10, lineHeight: '16px', padding: '0 4px' }}
|
|
||||||
>
|
|
||||||
{model.status === 'maintenance' ? '维护' : '停用'}
|
|
||||||
</Tag>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return items;
|
// 按 MODEL_CATEGORIES 定义的顺序遍历,确保 Tab 顺序可控
|
||||||
}, [groupedModels, token.colorTextSecondary]);
|
const seenCategories = new Set<string>();
|
||||||
|
MODEL_CATEGORIES.forEach((cat: ModelCategoryStringEnum) => {
|
||||||
|
const groupModels = groupedModels.get(cat);
|
||||||
|
if (groupModels && groupModels.length > 0) {
|
||||||
|
seenCategories.add(cat);
|
||||||
|
items.push({
|
||||||
|
key: cat,
|
||||||
|
label: `${resolveCategoryLabel(cat)} (${groupModels.length})`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 选中菜单项
|
// 兜底:API 返回了 MODEL_CATEGORIES 中未注册的新分类,追加到末尾
|
||||||
const handleSelect = ({ key }: { key: string }) => {
|
groupedModels.forEach((groupModels, category) => {
|
||||||
const model = models?.find((m) => m.id === key);
|
if (!seenCategories.has(category) && groupModels.length > 0) {
|
||||||
if (model) {
|
items.push({
|
||||||
setSelectedModel(model);
|
key: category,
|
||||||
}
|
label: `${resolveCategoryLabel(category)} (${groupModels.length})`,
|
||||||
};
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 加载态
|
return items;
|
||||||
if (loading) {
|
}, [models, groupedModels]);
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 24 }}>
|
|
||||||
<Spin size="small" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 空态
|
// ---------- 加载态 ----------
|
||||||
if (!models || models.length === 0) {
|
|
||||||
return <Empty description="暂无可用模型" image={Empty.PRESENTED_IMAGE_SIMPLE} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 选中 key
|
if (loading) {
|
||||||
const selectedKeys = selectedModel ? [selectedModel.id] : [];
|
return (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', padding: 24 }}>
|
||||||
|
<Spin size="small" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
// ---------- 错误态 ----------
|
||||||
<div style={{ flex: 1, overflow: 'auto', minHeight: 0 }}>
|
|
||||||
<div
|
if (error) {
|
||||||
style={{
|
return (
|
||||||
padding: '8px 12px',
|
<Result
|
||||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
status="error"
|
||||||
display: 'flex',
|
title="模型列表加载失败"
|
||||||
alignItems: 'center',
|
subTitle={error.message || '请检查网络连接后重试'}
|
||||||
gap: 6,
|
extra={
|
||||||
}}
|
<Button type="primary" size="small" onClick={refetch}>
|
||||||
>
|
重试
|
||||||
<AppstoreOutlined style={{ color: token.colorPrimary, fontSize: 14 }} />
|
</Button>
|
||||||
<Text strong style={{ fontSize: 13 }}>选择模型</Text>
|
}
|
||||||
</div>
|
/>
|
||||||
<Menu
|
);
|
||||||
mode="inline"
|
}
|
||||||
selectedKeys={selectedKeys}
|
|
||||||
onSelect={handleSelect}
|
// ---------- 空态 ----------
|
||||||
items={menuItems}
|
|
||||||
style={{
|
if (!models || models.length === 0) {
|
||||||
borderInlineEnd: 'none',
|
return <Empty description="暂无可用模型" image={Empty.PRESENTED_IMAGE_SIMPLE} />;
|
||||||
background: 'transparent',
|
}
|
||||||
}}
|
|
||||||
/>
|
// ---------- 正常渲染 ----------
|
||||||
</div>
|
|
||||||
);
|
return (
|
||||||
|
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||||
|
{/* 标题栏 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AppstoreOutlined style={{ color: token.colorPrimary, fontSize: 14 }} />
|
||||||
|
<Text strong style={{ fontSize: 13 }}>
|
||||||
|
选择模型
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 分类 Tabs */}
|
||||||
|
<Tabs
|
||||||
|
activeKey={activeTab}
|
||||||
|
onChange={setActiveTab}
|
||||||
|
items={tabItems}
|
||||||
|
size="small"
|
||||||
|
type="card"
|
||||||
|
tabBarStyle={{ margin: '0 0 4px 0', padding: '0 8px' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 模型列表 */}
|
||||||
|
<div style={{ flex: 1, overflow: 'auto', padding: '0 8px 8px' }}>
|
||||||
|
{filteredModels.length === 0 ? (
|
||||||
|
<Empty
|
||||||
|
description="该分类暂无模型"
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
style={{ marginTop: 24 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
{filteredModels.map((model) => (
|
||||||
|
<ModelItem
|
||||||
|
key={model.id}
|
||||||
|
model={model}
|
||||||
|
isSelected={selectedModel?.id === model.id}
|
||||||
|
onSelect={() => setSelectedModel(model)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -204,7 +204,7 @@ export function TaskHistory() {
|
|||||||
|
|
||||||
// 同步 context 分页 ↔ tableParams
|
// 同步 context 分页 ↔ tableParams
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTableParams((prev) => ({
|
setTableParams((prev:TableParams) => ({
|
||||||
...prev,
|
...prev,
|
||||||
pagination: { current: taskPage, pageSize: taskPageSize },
|
pagination: { current: taskPage, pageSize: taskPageSize },
|
||||||
}));
|
}));
|
||||||
@@ -212,9 +212,9 @@ export function TaskHistory() {
|
|||||||
|
|
||||||
// -------- 模型列表(用于模型列筛选选项 + 模型名→ID 映射)--------
|
// -------- 模型列表(用于模型列筛选选项 + 模型名→ID 映射)--------
|
||||||
|
|
||||||
// TODO: 与 ModelSelector 中的 useModelList 重复请求,后续统一缓存层
|
|
||||||
const { data: allModels } = useModelList();
|
const { data: allModels } = useModelList();
|
||||||
const modelFilters = useMemo(() => {
|
const modelFilters:{text:string, value:string}[] = useMemo(() => {
|
||||||
if (!allModels) return [];
|
if (!allModels) return [];
|
||||||
return allModels.map((m) => ({ text: m.name, value: m.id }));
|
return allModels.map((m) => ({ text: m.name, value: m.id }));
|
||||||
}, [allModels]);
|
}, [allModels]);
|
||||||
|
|||||||
@@ -31,12 +31,17 @@ export {
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
ModelAPI,
|
ModelAPI,
|
||||||
|
MODEL_CATEGORIES,
|
||||||
MODEL_CATEGORY_LABELS,
|
MODEL_CATEGORY_LABELS,
|
||||||
|
CATEGORY_SLUG_MAP,
|
||||||
|
resolveCategoryLabel,
|
||||||
|
toCategorySlug,
|
||||||
type ModelsListReqeustParams,
|
type ModelsListReqeustParams,
|
||||||
type ModelsListResponseBody,
|
type ModelsListResponseBody,
|
||||||
type ModelInfoRequestParams,
|
type ModelInfoRequestParams,
|
||||||
type ModelInfoResponseBody,
|
type ModelInfoResponseBody,
|
||||||
type ModelCategory,
|
type ModelCategoryStringEnum,
|
||||||
|
type ParamSchemaItem,
|
||||||
} from './models';
|
} from './models';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import {get} from '../request';
|
import {get} from '../request';
|
||||||
// 请注意后端没有关于字段category的查询
|
// 注意:后端 /api/v1/models 的 category 查询参数实际不可用(传参返回空列表),
|
||||||
|
// 因此客户端采用"全量拉取 + 按 category_name 字段分组"的策略。
|
||||||
|
// 后端分类 slug 格式为 img2img / txt2img 等,前端通过 CATEGORY_SLUG_MAP 映射。
|
||||||
// ---------- 路由常量 ----------
|
// ---------- 路由常量 ----------
|
||||||
|
|
||||||
const ModelsUrlObj = {
|
const ModelsUrlObj = {
|
||||||
@@ -10,23 +12,93 @@ const ModelsUrlObj = {
|
|||||||
|
|
||||||
// ---------- 类型 ----------
|
// ---------- 类型 ----------
|
||||||
|
|
||||||
/** 模型类别 */
|
/** 模型类别常量数组(单一数据源,类型由此推导) */
|
||||||
export type ModelCategory = 'image_to_image' | 'text_to_image' | 'image_to_video' | 'text_to_audio';
|
export const MODEL_CATEGORIES = [
|
||||||
|
'image_to_image',
|
||||||
|
'text_to_image',
|
||||||
|
'image_to_video',
|
||||||
|
'text_to_audio',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ModelCategoryStringEnum = typeof MODEL_CATEGORIES[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模型参数 Schema 项
|
||||||
|
*
|
||||||
|
* 后端 /api/v1/models 返回的 param_schema 为对象数组,
|
||||||
|
* 每项描述一个模型入参的元信息(标识、字段映射、规格约束、UI 配置)。
|
||||||
|
*/
|
||||||
|
export interface ParamSchemaItem {
|
||||||
|
id: string;
|
||||||
|
/** 映射到提交参数中的字段名 */
|
||||||
|
map_to: string;
|
||||||
|
/** 规格约束(类型、范围、默认值等) */
|
||||||
|
spec: Record<string, unknown>;
|
||||||
|
/** UI 控件配置 */
|
||||||
|
ui: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
/** 模型类别中文映射 */
|
/** 模型类别中文映射 */
|
||||||
export const MODEL_CATEGORY_LABELS: Record<ModelCategory, string> = {
|
export const MODEL_CATEGORY_LABELS: Record<ModelCategoryStringEnum, string> = {
|
||||||
image_to_image: '图生图',
|
image_to_image: '图生图',
|
||||||
text_to_image: '文生图',
|
text_to_image: '文生图',
|
||||||
image_to_video: '图生视频',
|
image_to_video: '图生视频',
|
||||||
text_to_audio: '文生音频',
|
text_to_audio: '文生音频',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 前端 ModelCategory → 后端分类 slug 映射
|
||||||
|
*
|
||||||
|
* 后端分类 API 使用简写 slug(如 img2img),而非前端的长标识符。
|
||||||
|
* 查询模型列表时需将前端类别转为后端 slug 作为 category 参数。
|
||||||
|
*/
|
||||||
|
export const CATEGORY_SLUG_MAP: Record<ModelCategoryStringEnum, string> = {
|
||||||
|
image_to_image: 'img2img',
|
||||||
|
text_to_image: 'txt2img',
|
||||||
|
image_to_video: 'img2video',
|
||||||
|
text_to_audio: 'txt2audio',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 后端 slug → 前端 ModelCategory 反向映射(由 CATEGORY_SLUG_MAP 自动推导) */
|
||||||
|
const SLUG_TO_CATEGORY: Record<string, ModelCategoryStringEnum> = Object.fromEntries(
|
||||||
|
Object.entries(CATEGORY_SLUG_MAP).map(([cat, slug]) => [slug, cat as ModelCategoryStringEnum]),
|
||||||
|
) as Record<string, ModelCategoryStringEnum>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据任意 category_name 值解析出最佳中文标签
|
||||||
|
*
|
||||||
|
* 解析优先级:
|
||||||
|
* 1. 匹配 MODEL_CATEGORY_LABELS(前端 ModelCategory 字面量)
|
||||||
|
* 2. 匹配 SLUG_TO_CATEGORY 反向映射(后端 slug → 中英文标签)
|
||||||
|
* 3. 兜底返回原始字符串
|
||||||
|
*/
|
||||||
|
export function resolveCategoryLabel(categoryName: string): string {
|
||||||
|
// 优先按 ModelCategory 字面量查找
|
||||||
|
if (categoryName in MODEL_CATEGORY_LABELS) {
|
||||||
|
return MODEL_CATEGORY_LABELS[categoryName as ModelCategoryStringEnum];
|
||||||
|
}
|
||||||
|
// 按后端 slug 反向查找
|
||||||
|
const cat = SLUG_TO_CATEGORY[categoryName];
|
||||||
|
if (cat) {
|
||||||
|
return MODEL_CATEGORY_LABELS[cat];
|
||||||
|
}
|
||||||
|
// 兜底返回原始值
|
||||||
|
return categoryName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将前端 ModelCategory 转为后端 API 用的分类 slug
|
||||||
|
*/
|
||||||
|
export function toCategorySlug(category: ModelCategoryStringEnum): string {
|
||||||
|
return CATEGORY_SLUG_MAP[category];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询可用模型的请求参数
|
* 查询可用模型的请求参数
|
||||||
*/
|
*/
|
||||||
export interface ModelsListReqeustParams {
|
export interface ModelsListReqeustParams {
|
||||||
provider_name?: string;
|
provider_name?: string;
|
||||||
category?: ModelCategory;
|
category?: ModelCategoryStringEnum;
|
||||||
page: number;
|
page: number;
|
||||||
page_size: number;
|
page_size: number;
|
||||||
}
|
}
|
||||||
@@ -38,14 +110,14 @@ export interface ModelsListResponseBody {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
provider_name: string;
|
provider_name: string;
|
||||||
model_type: string;
|
model_type: ModelCategoryStringEnum;
|
||||||
category_name: string;
|
category_name: ModelCategoryStringEnum | null;
|
||||||
status: string;
|
status: string;
|
||||||
priority: number;
|
priority: number;
|
||||||
response_mode: string;
|
response_mode: string;
|
||||||
pricing_config: Record<string, unknown>;
|
pricing_config: Record<string, unknown>;
|
||||||
param_schema: string[];
|
param_schema: ParamSchemaItem[];
|
||||||
quota_config: Record<string, unknown>;
|
quota_config: Record<string, unknown> | null;
|
||||||
ui_config: Record<string, unknown>;
|
ui_config: Record<string, unknown>;
|
||||||
meta_config: Record<string, unknown>;
|
meta_config: Record<string, unknown>;
|
||||||
constraints_config: Record<string, unknown>;
|
constraints_config: Record<string, unknown>;
|
||||||
|
|||||||
Reference in New Issue
Block a user