- 删除 8 个占位页面文件(Account/Vip/Recharge/Billing/Orders/Projects/Quota/ComplianceAssets) - PageDispatcher: PAGE_MAP 支持无组件页面自动渲染 PlaceholderPage - 新增页面只需在 PAGE_MAP 加一行配置,无需创建独立文件 - 删除 services/modules/index.ts barrel,13 个文件改为直接导入 - IDE 跳转到定义一步到位,import 自文档化 - TypeScript 编译零错误 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
234 lines
8.2 KiB
TypeScript
234 lines
8.2 KiB
TypeScript
// ============================================================
|
||
// 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, type ModelsListResponseBody, type ModelCategoryStringEnum } from '@/services/modules/models';
|
||
|
||
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>
|
||
);
|
||
} |