Files
ele-HeiXiu/src/pages/home/components/ModelSelector.tsx
YoungestSongMo 53df926973 refactor: Context 与 Provider 合并 — 消除 contexts/ 目录,一文件一状态单元
- ThemeContext + useTheme → ThemeProvider.tsx(原 hooks/use-theme.ts 删除)
- AppContext + useAppContext → AppProvider.tsx(原 contexts/app-context.ts 删除)
- SettingsContext + useSettings → SettingsProvider.tsx(原 contexts/settings-context.ts 删除)
- HomeContext + useHomeContext → HomeContent.tsx(原 contexts/home-context.ts 删除)
- 删除空的 contexts/ 目录,20+ 导入路径同步更新
- TypeScript 编译通过(npx tsc --noEmit 零错误)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 14:02:48 +08:00

235 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// ModelSelector — 模型选择器Tabs 标签页分组 + 平铺列表)
// 使用 useModelList() Hook 获取数据,组件仅负责渲染
// ============================================================
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 } from '@/hooks/use-api';
import { useHomeContext } from '@/pages/home/HomeContent';
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 linear, border-color 0.15s linear, opacity 0.15s linear',
}}
>
<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, error, groupedModels, refetch } = useModelList();
const [activeTab, setActiveTab] = useState(ALL_TAB_KEY);
// 当前 Tab 对应的模型列表
const filteredModels:ModelsListResponseBody[] = useMemo(() => {
if (activeTab === ALL_TAB_KEY) return models ?? [];
return groupedModels.get(activeTab) ?? [];
}, [activeTab, models, groupedModels]);
// 构建 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})`,
},
];
// 按 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})`,
});
}
});
// 兜底API 返回了 MODEL_CATEGORIES 中未注册的新分类,追加到末尾
groupedModels.forEach((groupModels, category) => {
if (!seenCategories.has(category) && groupModels.length > 0) {
items.push({
key: category,
label: `${resolveCategoryLabel(category)} (${groupModels.length})`,
});
}
});
return items;
}, [models, groupedModels]);
// ---------- 加载态 ----------
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 24 }}>
<Spin size="small" />
</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>
);
}