diff --git a/src/hooks/use-api.ts b/src/hooks/use-api.ts index 667fae7..0ad3ffc 100644 --- a/src/hooks/use-api.ts +++ b/src/hooks/use-api.ts @@ -24,9 +24,9 @@ import { type CreatTaskRequestBody, type ModelsListResponseBody, type Banner, - type ModelCategory, + type ModelCategoryStringEnum, } from '@/services/modules'; -import {useAsyncData, useAsyncMutation} from './use-async'; +import {useAsyncData, UseAsyncDataReturn, useAsyncMutation} from './use-async'; // ============================================================ // Banner @@ -47,53 +47,38 @@ export function useBannerList() { // 模型 // ============================================================ -/** 模型列表数据 Hook(按类别分别请求后合并) */ +/** 模型列表数据 Hook(单次请求全量模型 + 客户端按 category_name 分组) + + * 因后端 category 查询参数实际不可用(传参返回空列表), + * 改为不带分类参数一次拉取全部,再由客户端根据响应中的 + * category_name 字段手动分组。 */ export function useModelList() { const {isLoggedIn} = useAppContext(); - // 使用单个 fetcher 返回合并后的结果 - const result = useAsyncData( - 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; - }, + const result: UseAsyncDataReturn = useAsyncData( + () => ModelAPI.fetchModels({page: 1, page_size: 200}), [], {enabled: isLoggedIn, label: 'model-list'}, ); - // 按类别分组 - const groupedModels = useMemo(() => { + // 按 category_name 分组(后端返回的 category_name 为 slug 格式如 img2img) + const groupedModels: Map = useMemo(() => { const groups = new Map(); - result.data?.forEach((model) => { - const cat = model.category_name || 'other'; + result.data?.forEach((model: ModelsListResponseBody) => { + const cat: ModelCategoryStringEnum = model.model_type; if (!groups.has(cat)) groups.set(cat, []); groups.get(cat)!.push(model); }); + // 每组内按优先级降序排列 + // groups.forEach((models) => { + // models.sort((a, b) => b.priority - a.priority); + // }); return groups; }, [result.data]); return { ...result, - /** 按类别分组后的模型 */ + /** 按 model_type 分组后的模型(key 为后端 slug) */ groupedModels, }; } @@ -163,4 +148,4 @@ export function useSubmitTask(options?: { // ---------- 重新导出类型和工具 ---------- export {MODEL_CATEGORY_LABELS}; -export type {ModelCategory, ModelsListResponseBody, TaskInfoItemResponseBody, Banner}; +export type {ModelCategoryStringEnum, ModelsListResponseBody, TaskInfoItemResponseBody, Banner}; diff --git a/src/pages/home/components/ModelInputForm.tsx b/src/pages/home/components/ModelInputForm.tsx index 14d2055..53afc06 100644 --- a/src/pages/home/components/ModelInputForm.tsx +++ b/src/pages/home/components/ModelInputForm.tsx @@ -29,7 +29,7 @@ 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'; +import type { ModelsListResponseBody, TaskInfoItemResponseBody } from '@/services/modules'; const { Text, Title } = Typography; const { TextArea } = Input; @@ -39,7 +39,10 @@ const { Dragger } = Upload; /** 从 ui_config 中解析的参数描述 */ interface ParamDescriptor { + /** React key(item.id,保证唯一) */ key: string; + /** API 字段名(item.map_to) */ + name: string; label: string; /** 控件类型 */ controlType: 'text' | 'number' | 'textarea' | 'select' | 'slider' | 'switch' | 'image-upload'; @@ -90,21 +93,26 @@ function inferControlType(config: Record): ParamDescriptor['con } } -/** 从 param_schema + ui_config 构建参数描述符列表 */ +/** + * 从 param_schema 构建参数描述符列表 + * + * 后端 param_schema 格式为 ParamSchemaItem[](对象数组), + * 每项的 ui 字段内嵌了控件类型、标签、校验规则等 UI 配置。 + */ function buildParamDescriptors( - paramSchema: string[], - uiConfig: Record, + paramSchema: ModelsListResponseBody['param_schema'], ): ParamDescriptor[] { - return paramSchema.map((key) => { - const config = (uiConfig[key] || {}) as Record; + return paramSchema.map((item) => { + const config = (item.ui || {}) as Record; const controlType = inferControlType(config); return { - key, - label: (config.label as string) || key, + key: item.id, + name: item.map_to, + label: (config.label as string) || item.map_to, controlType, defaultValue: config.default ?? config.defaultValue, 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, max: config.max as number | undefined, step: config.step as number | undefined, @@ -145,10 +153,7 @@ export function ModelInputForm() { // 参数描述符 const paramDescriptors = useMemo(() => { if (!selectedModel) return []; - return buildParamDescriptors( - selectedModel.param_schema || [], - selectedModel.ui_config || {}, - ); + return buildParamDescriptors(selectedModel.param_schema || []); }, [selectedModel]); // 提交 @@ -419,7 +424,7 @@ export function ModelInputForm() { {paramDescriptors.map((desc) => ( 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 ( +
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', + }} + > + + {model.name} + + {model.status !== 'active' && ( + + {model.status === 'maintenance' ? '维护' : '停用'} + + )} +
+ ); +} + +// ---------- 主组件 ---------- export function ModelSelector() { - const { token } = antTheme.useToken(); - const { selectedModel, setSelectedModel } = useHomeContext(); - const { data: models, loading, groupedModels } = useModelList(); + const { token } = antTheme.useToken(); + const { selectedModel, setSelectedModel } = useHomeContext(); + const { data: models, loading, error, groupedModels, refetch } = useModelList(); + const [activeTab, setActiveTab] = useState(ALL_TAB_KEY); - // 构建 Menu items:分组标题 + 模型项 - const menuItems = useMemo(() => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const items: any[] = []; + // 当前 Tab 对应的模型列表 + const filteredModels:ModelsListResponseBody[] = useMemo(() => { + if (activeTab === ALL_TAB_KEY) return models ?? []; + return groupedModels.get(activeTab) ?? []; + }, [activeTab, models, groupedModels]); - groupedModels.forEach((groupModels, category) => { - const categoryLabel = MODEL_CATEGORY_LABELS[category as ModelCategory] || category; - items.push({ - key: `group-${category}`, - label: ( - - {categoryLabel} ({groupModels.length}) - - ), - type: 'group' as const, - children: groupModels.map((model) => ({ - key: model.id, - label: ( -
- - {model.name} - - {model.status !== 'active' && ( - - {model.status === 'maintenance' ? '维护' : '停用'} - - )} -
- ), - })), - }); - }); + // 构建 Tab 项(全部 + 各分类,顺序由 MODEL_CATEGORIES 控制) + const tabItems: { key: string; label: React.ReactNode }[] = useMemo(() => { + const items: { key: string; label: React.ReactNode }[] = [ + { + key: ALL_TAB_KEY, + label: `全部 (${models?.length ?? 0})`, + }, + ]; - return items; - }, [groupedModels, token.colorTextSecondary]); + // 按 MODEL_CATEGORIES 定义的顺序遍历,确保 Tab 顺序可控 + const seenCategories = new Set(); + 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})`, + }); + } + }); - // 选中菜单项 - const handleSelect = ({ key }: { key: string }) => { - const model = models?.find((m) => m.id === key); - if (model) { - setSelectedModel(model); - } - }; + // 兜底:API 返回了 MODEL_CATEGORIES 中未注册的新分类,追加到末尾 + groupedModels.forEach((groupModels, category) => { + if (!seenCategories.has(category) && groupModels.length > 0) { + items.push({ + key: category, + label: `${resolveCategoryLabel(category)} (${groupModels.length})`, + }); + } + }); - // 加载态 - if (loading) { - return ( -
- -
- ); - } + return items; + }, [models, groupedModels]); - // 空态 - if (!models || models.length === 0) { - return ; - } + // ---------- 加载态 ---------- - // 选中 key - const selectedKeys = selectedModel ? [selectedModel.id] : []; + if (loading) { + return ( +
+ +
+ ); + } - return ( -
-
- - 选择模型 -
- -
- ); -} + // ---------- 错误态 ---------- + + if (error) { + return ( + + 重试 + + } + /> + ); + } + + // ---------- 空态 ---------- + + if (!models || models.length === 0) { + return ; + } + + // ---------- 正常渲染 ---------- + + return ( +
+ {/* 标题栏 */} +
+ + + 选择模型 + +
+ + {/* 分类 Tabs */} + + + {/* 模型列表 */} +
+ {filteredModels.length === 0 ? ( + + ) : ( +
+ {filteredModels.map((model) => ( + setSelectedModel(model)} + /> + ))} +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/src/pages/home/components/TaskHistory.tsx b/src/pages/home/components/TaskHistory.tsx index 4d31a50..d826678 100644 --- a/src/pages/home/components/TaskHistory.tsx +++ b/src/pages/home/components/TaskHistory.tsx @@ -204,7 +204,7 @@ export function TaskHistory() { // 同步 context 分页 ↔ tableParams useEffect(() => { - setTableParams((prev) => ({ + setTableParams((prev:TableParams) => ({ ...prev, pagination: { current: taskPage, pageSize: taskPageSize }, })); @@ -212,9 +212,9 @@ export function TaskHistory() { // -------- 模型列表(用于模型列筛选选项 + 模型名→ID 映射)-------- - // TODO: 与 ModelSelector 中的 useModelList 重复请求,后续统一缓存层 + const { data: allModels } = useModelList(); - const modelFilters = useMemo(() => { + const modelFilters:{text:string, value:string}[] = useMemo(() => { if (!allModels) return []; return allModels.map((m) => ({ text: m.name, value: m.id })); }, [allModels]); diff --git a/src/services/modules/index.ts b/src/services/modules/index.ts index 4b67077..5b398e3 100644 --- a/src/services/modules/index.ts +++ b/src/services/modules/index.ts @@ -31,12 +31,17 @@ export { export { ModelAPI, + MODEL_CATEGORIES, MODEL_CATEGORY_LABELS, + CATEGORY_SLUG_MAP, + resolveCategoryLabel, + toCategorySlug, type ModelsListReqeustParams, type ModelsListResponseBody, type ModelInfoRequestParams, type ModelInfoResponseBody, - type ModelCategory, + type ModelCategoryStringEnum, + type ParamSchemaItem, } from './models'; export { diff --git a/src/services/modules/models.ts b/src/services/modules/models.ts index 8fda3a8..2044ed1 100644 --- a/src/services/modules/models.ts +++ b/src/services/modules/models.ts @@ -1,5 +1,7 @@ import {get} from '../request'; -// 请注意后端没有关于字段category的查询 +// 注意:后端 /api/v1/models 的 category 查询参数实际不可用(传参返回空列表), +// 因此客户端采用"全量拉取 + 按 category_name 字段分组"的策略。 +// 后端分类 slug 格式为 img2img / txt2img 等,前端通过 CATEGORY_SLUG_MAP 映射。 // ---------- 路由常量 ---------- 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; + /** UI 控件配置 */ + ui: Record; +} /** 模型类别中文映射 */ -export const MODEL_CATEGORY_LABELS: Record = { +export const MODEL_CATEGORY_LABELS: Record = { 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 = { + 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 = Object.fromEntries( + Object.entries(CATEGORY_SLUG_MAP).map(([cat, slug]) => [slug, cat as ModelCategoryStringEnum]), +) as Record; + +/** + * 根据任意 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?: ModelCategory; + category?: ModelCategoryStringEnum; page: number; page_size: number; } @@ -38,14 +110,14 @@ export interface ModelsListResponseBody { id: string; name: string; provider_name: string; - model_type: string; - category_name: string; + model_type: ModelCategoryStringEnum; + category_name: ModelCategoryStringEnum | null; status: string; priority: number; response_mode: string; pricing_config: Record; - param_schema: string[]; - quota_config: Record; + param_schema: ParamSchemaItem[]; + quota_config: Record | null; ui_config: Record; meta_config: Record; constraints_config: Record;