模型列表 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
159 lines
4.9 KiB
TypeScript
159 lines
4.9 KiB
TypeScript
import {get} from '../request';
|
||
// 注意:后端 /api/v1/models 的 category 查询参数实际不可用(传参返回空列表),
|
||
// 因此客户端采用"全量拉取 + 按 category_name 字段分组"的策略。
|
||
// 后端分类 slug 格式为 img2img / txt2img 等,前端通过 CATEGORY_SLUG_MAP 映射。
|
||
// ---------- 路由常量 ----------
|
||
|
||
const ModelsUrlObj = {
|
||
modelList: '/api/v1/models',
|
||
getModelInfo: '/api/v1/models/{model_id}',
|
||
estimateCost: '/api/v1/models/{model_id}/estimate'
|
||
} as const;
|
||
|
||
// ---------- 类型 ----------
|
||
|
||
/** 模型类别常量数组(单一数据源,类型由此推导) */
|
||
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<ModelCategoryStringEnum, string> = {
|
||
image_to_image: '图生图',
|
||
text_to_image: '文生图',
|
||
image_to_video: '图生视频',
|
||
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 {
|
||
provider_name?: string;
|
||
category?: ModelCategoryStringEnum;
|
||
page: number;
|
||
page_size: number;
|
||
}
|
||
|
||
/**
|
||
* 查询可用模型的响应体
|
||
*/
|
||
export interface ModelsListResponseBody {
|
||
id: string;
|
||
name: string;
|
||
provider_name: string;
|
||
model_type: ModelCategoryStringEnum;
|
||
category_name: ModelCategoryStringEnum | null;
|
||
status: string;
|
||
priority: number;
|
||
response_mode: string;
|
||
pricing_config: Record<string, unknown>;
|
||
param_schema: ParamSchemaItem[];
|
||
quota_config: Record<string, unknown> | null;
|
||
ui_config: Record<string, unknown>;
|
||
meta_config: Record<string, unknown>;
|
||
constraints_config: Record<string, unknown>;
|
||
expires_at: string;
|
||
created_at: string;
|
||
updated_at: string;
|
||
}
|
||
|
||
/**
|
||
* 模型信息请求参数
|
||
*/
|
||
export interface ModelInfoRequestParams {
|
||
model_id: string;
|
||
}
|
||
|
||
/** 模型详情响应体(与列表项结构一致) */
|
||
export type ModelInfoResponseBody = ModelsListResponseBody;
|
||
|
||
// ---------- API ----------
|
||
|
||
|
||
export class ModelAPI {
|
||
/**
|
||
* 获取可用模型列表
|
||
* GET /api/v1/models
|
||
*/
|
||
static fetchModels(params: ModelsListReqeustParams): Promise<ModelsListResponseBody[]> {
|
||
return get<ModelsListResponseBody[]>(ModelsUrlObj.modelList, params as unknown as Record<string, unknown>);
|
||
}
|
||
|
||
/**
|
||
* 获取单个模型详情
|
||
* GET /api/v1/models/:id
|
||
*/
|
||
static fetchModelInfo(modelId: string): Promise<ModelInfoResponseBody> {
|
||
const url = ModelsUrlObj.getModelInfo.replace('{model_id}', modelId);
|
||
return get<ModelInfoResponseBody>(url);
|
||
}
|
||
} |