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:
2026-06-08 19:02:32 +08:00
parent a2741edf01
commit 59059caae7
6 changed files with 343 additions and 163 deletions

View File

@@ -1,122 +1,235 @@
// ============================================================
// ModelSelector — 模型选择器(垂直单选列表,按类别分组
// ModelSelector — 模型选择器(Tabs 标签页分组 + 平铺列表
// 使用 useModelList() Hook 获取数据,组件仅负责渲染
// ============================================================
import { useMemo } from 'react';
import { Menu, Spin, Empty, Tag, Typography, theme as antTheme } from 'antd';
import React, { useState, useMemo, useCallback } from 'react';
import { Tabs, Spin, Empty, Tag, Typography, Button, Result, message, theme as antTheme } from 'antd';
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 type { ModelCategory } from '@/services/modules';
import { resolveCategoryLabel, MODEL_CATEGORIES } from '@/services/modules';
import type { ModelsListResponseBody, ModelCategoryStringEnum } from '@/services/modules';
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() {
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: (
<Text strong style={{ fontSize: 12, color: token.colorTextSecondary }}>
{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>
),
})),
});
});
// 构建 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<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})`,
});
}
});
// 选中菜单项
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 (
<div style={{ display: 'flex', justifyContent: 'center', padding: 24 }}>
<Spin size="small" />
</div>
);
}
return items;
}, [models, groupedModels]);
// 空态
if (!models || models.length === 0) {
return <Empty description="暂无可用模型" image={Empty.PRESENTED_IMAGE_SIMPLE} />;
}
// ---------- 加载态 ----------
// 选中 key
const selectedKeys = selectedModel ? [selectedModel.id] : [];
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 24 }}>
<Spin size="small" />
</div>
);
}
return (
<div style={{ flex: 1, overflow: 'auto', minHeight: 0 }}>
<div
style={{
padding: '8px 12px',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
display: 'flex',
alignItems: 'center',
gap: 6,
}}
>
<AppstoreOutlined style={{ color: token.colorPrimary, fontSize: 14 }} />
<Text strong style={{ fontSize: 13 }}></Text>
</div>
<Menu
mode="inline"
selectedKeys={selectedKeys}
onSelect={handleSelect}
items={menuItems}
style={{
borderInlineEnd: 'none',
background: 'transparent',
}}
/>
</div>
);
}
// ---------- 错误态 ----------
if (error) {
return (
<Result
status="error"
title="模型列表加载失败"
subTitle={error.message || '请检查网络连接后重试'}
extra={
<Button type="primary" size="small" onClick={refetch}>
</Button>
}
/>
);
}
// ---------- 空态 ----------
if (!models || models.length === 0) {
return <Empty description="暂无可用模型" image={Empty.PRESENTED_IMAGE_SIMPLE} />;
}
// ---------- 正常渲染 ----------
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>
);
}