| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558 |
- import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
- import { useSearchParams } from 'react-router-dom';
- import { PageId } from '../components/Sidebar';
- import Pagination from '../components/Pagination';
- import { Model } from '../types/models';
- import { modelApi } from '../services/modelApi';
- import { Search, Zap, Flame, Loader2 } from '../icons/commonIcons';
- import ModelCard from '../components/ModelCard';
- import LocalModelList from '../components/LocalModelList';
- interface ModelSquareProps {}
- const ModelSquare: React.FC<ModelSquareProps> = () => {
- const [searchParams, setSearchParams] = useSearchParams();
- const modelSource = useMemo(() => searchParams.get('source') || 'cloud', [searchParams]);
- const [activeCategory, setActiveCategory] = useState('全部');
- const activeGroupName = useMemo(() => searchParams.get('group') || '全部', [searchParams]);
- const setActiveGroupName = (name: string) => {
- setSearchParams(prev => {
- const newParams = new URLSearchParams(prev);
- if (name === '全部') {
- newParams.delete('group');
- } else {
- newParams.set('group', name);
- }
- newParams.delete('page');
- return newParams;
- });
- };
- const [groupNames, setGroupNames] = useState<string[]>([]);
- const [models, setModels] = useState<Model[]>([]);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState<string | null>(null);
- const [searchKeyword, setSearchKeyword] = useState('');
-
- // 从 URL 读取页码,如果没有则默认为 1
- const currentPage = useMemo(() => {
- const pageParam = searchParams.get('page');
- const page = pageParam ? parseInt(pageParam, 10) : 1;
- return isNaN(page) || page < 1 ? 1 : page;
- }, [searchParams]);
-
- const [pagination, setPagination] = useState({
- page: currentPage,
- pageSize: 21,
- total: 0,
- totalPages: 0
- });
- const [showFilterPanel, setShowFilterPanel] = useState(false);
- const [selectedCompanies, setSelectedCompanies] = useState<string[]>([]);
- const [selectedModalTypes, setSelectedModalTypes] = useState<string[]>([]);
- const [isNewFilter, setIsNewFilter] = useState<boolean | undefined>(undefined);
- const [isFeaturedFilter, setIsFeaturedFilter] = useState<boolean | undefined>(undefined);
- const [isApiEnabledFilter, setIsApiEnabledFilter] = useState<boolean | undefined>(undefined);
- const [sortBy, setSortBy] = useState<string>('createdAt');
- const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
- const filterPanelRef = useRef<HTMLDivElement>(null);
- const filterButtonRef = useRef<HTMLButtonElement>(null);
- // 简单内存缓存,用于切换时快速展示已加载的数据(key 为 source|category|keyword)
- const modelsCacheRef = useRef<Record<string, Model[]>>({});
- const fetchTimerRef = useRef<number | null>(null);
- // 供应商列表(通义合并为一个,不做细分)
- const allSuppliers = ['通义', 'DeepSeek', 'Moonshot', '智谱AI', 'Black Forest Labs', 'MiniMax', 'Stability AI'];
- // 标签分组定义(与数据库实际标签值对应)
- const modalTypeGroups = {
- '版本': ['最新版本'],
- '翻译': ['翻译'],
- '视觉': ['视频生成']
- };
-
- // 模型分类列表(与后端 ModelCategory 枚举一致)
- const modelCategories = ['全部', '语言模型', '多模态模型', 'TTS模型', 'STT模型', '生图模型', '生视频模型', '图像编辑', 'Embedding', 'Rerank'];
-
- // 不再需要加载百炼模型数据,直接使用API
- const getCategoryTagStyle = (category: string) => {
- switch (category) {
- case '语言模型': return 'bg-blue-50 text-blue-600 border-blue-200';
- case '多模态模型': return 'bg-indigo-50 text-indigo-600 border-indigo-200';
- case 'TTS模型': return 'bg-emerald-50 text-emerald-600 border-emerald-200';
- case 'STT模型': return 'bg-teal-50 text-teal-600 border-teal-200';
- case '生图模型': return 'bg-purple-50 text-purple-600 border-purple-200';
- case '生视频模型': return 'bg-orange-50 text-orange-600 border-orange-200';
- case '图像编辑': return 'bg-pink-50 text-pink-600 border-pink-200';
- case 'Embedding': return 'bg-cyan-50 text-cyan-600 border-cyan-200';
- case 'Rerank': return 'bg-amber-50 text-amber-600 border-amber-200';
- default: return 'bg-gray-50 text-gray-500 border-gray-100/50';
- }
- };
- // 获取模型列表(从API获取)
- const fetchModels = useCallback(async () => {
- try {
- setError(null);
- // 防抖:清理已有定时器,延迟实际请求以合并快速的切换/输入
- if (fetchTimerRef.current) {
- window.clearTimeout(fetchTimerRef.current);
- }
- // 计算缓存键
- const categoryParam = modelSource === 'local'
- ? (activeCategory !== '全部' ? activeCategory : '多模态模型')
- : (activeCategory !== '全部' ? activeCategory : undefined);
- const groupNameParam = modelSource === 'cloud' && activeGroupName !== '全部' ? activeGroupName : undefined;
- const cacheKey = `${modelSource}|${categoryParam || 'all'}|${groupNameParam || 'all'}|${searchKeyword || ''}`;
- // 如果有缓存,先使用缓存快速展示(不覆盖后续刷新结果)
- const cached = modelsCacheRef.current[cacheKey];
- if (cached && cached.length > 0) {
- setModels(cached);
- setPagination(prev => ({ ...prev, total: cached.length, totalPages: 1, page: 1 }));
- setLoading(false);
- } else {
- // 如果没有缓存且是本地且期望显示开源模型,立即展示占位(快速反馈)
- if (modelSource === 'local') {
- setModels([]);
- setLoading(true);
- } else {
- setLoading(true);
- }
- }
- // 延迟实际网络请求,合并短时间内的多次变更
- fetchTimerRef.current = window.setTimeout(async () => {
- // 处理供应商筛选:将显示名称映射到实际的供应商名称
- let supplierFilter: string | undefined;
- if (selectedCompanies.length > 0) {
- const supplierMap: Record<string, string> = {
- '通义': 'Qwen',
- 'DeepSeek': 'DeepSeek',
- 'Moonshot': 'Moonshot',
- '智谱AI': '智谱AI',
- 'Black Forest Labs': 'Black Forest Labs',
- 'MiniMax': 'MiniMax',
- 'Stability AI': 'Stability AI'
- };
- supplierFilter = supplierMap[selectedCompanies[0]] || selectedCompanies[0];
- }
- // 处理标签筛选:后端 filter_tag 参数支持在 tag1 或 tag2 中精确匹配
- let tagFilter: string | undefined;
- if (selectedModalTypes.length > 0) {
- tagFilter = selectedModalTypes[0];
- }
- const response = await modelApi.getModels({
- keyword: modelSource === 'local' ? '通义千问3开源模型' : (searchKeyword || undefined),
- page: currentPage,
- pageSize: 21,
- category: categoryParam,
- supplier: modelSource === 'local' ? undefined : supplierFilter,
- group_name: groupNameParam,
- filter_tag: modelSource === 'local' ? undefined : tagFilter,
- is_api_enabled: modelSource === 'local' ? undefined : isApiEnabledFilter,
- isNew: modelSource === 'local' ? undefined : isNewFilter,
- isHot: modelSource === 'local' ? undefined : isFeaturedFilter,
- sortBy,
- order: sortOrder
- }, false);
- if (response.code === 200 && response.data) {
- let list = response.data.list;
- let pagination = response.data.pagination;
- // 如果是本地模型且列表为空,但当前分类是多模态,则注入mock
- if (modelSource === 'local' && list.length === 0 && (activeCategory === '多模态模型' || activeCategory === '全部' && categoryParam === '多模态模型')) {
- const mockModel: Model = {
- id: -1,
- title: 'qwen3-vl-32b-thinking',
- name: '通义千问3开源模型',
- img: '/icons/Tongyi.svg',
- description: '通义千问3系列开源模型,支持多模态输入,具备强大的视觉理解与逻辑推理能力。',
- tag1: '开源',
- tag2: '32B',
- category: 1,
- supplier: 'Qwen',
- is_featured: true,
- created_at: new Date().toISOString(),
- updated_at: new Date().toISOString(),
- pricing_mode: 'free',
- input_price: '0',
- output_price: '0',
- price_unit: '1k tokens',
- price_currency: 'CNY',
- price_tiers: [],
- keyword: '开源,多模态,Qwen3'
- };
- list = [mockModel];
- pagination = {
- page: 1,
- pageSize: 21,
- total: 1,
- totalPages: 1
- };
- }
- // 云端模型:按正常顺序显示,不硬编码任何模型
- // 更新缓存并设置展示
- modelsCacheRef.current[cacheKey] = list;
- setModels(list);
- setPagination(pagination);
- setLoading(false);
- } else {
- throw new Error(response.message || '获取模型列表失败');
- }
- }, 150); // 150ms 防抖
- return;
- } catch (err) {
- setError(err instanceof Error ? err.message : '网络错误,请稍后重试');
- console.error('Failed to fetch models:', err);
- } finally {
- // 仅在没有缓存展示时才关闭 loading(缓存路径上已经设置过)
- if (!models || models.length === 0) setLoading(false);
- }
- }, [activeCategory, activeGroupName, searchKeyword, selectedCompanies, selectedModalTypes, isNewFilter, isFeaturedFilter, isApiEnabledFilter, sortBy, sortOrder, currentPage, modelSource]);
- // 获取分组列表
- const fetchGroupNames = useCallback(async () => {
- if (modelSource !== 'cloud') return;
- try {
- const response = await modelApi.getGroupNames();
- if (response.code === 200 && response.data) {
- setGroupNames(response.data);
- }
- } catch (err) {
- console.error('Failed to fetch group names:', err);
- }
- }, [modelSource]);
- // 初始加载分组列表
- useEffect(() => {
- fetchGroupNames();
- }, [fetchGroupNames]);
- // 初始加载和URL页码变化时,重新获取数据并立即跳转到顶部
- useEffect(() => {
- fetchModels();
- // 立即跳转到顶部
- const mainElement = document.querySelector('main');
- if (mainElement) {
- mainElement.scrollTop = 0;
- }
- }, [currentPage, fetchModels]);
- // 初始加载和分类切换时,重置页码为 1
- useEffect(() => {
- setSearchParams(prev => {
- const newParams = new URLSearchParams(prev);
- newParams.delete('page');
- newParams.set('source', modelSource);
- return newParams;
- });
- }, [activeCategory, activeGroupName, modelSource]); // eslint-disable-line react-hooks/exhaustive-deps
- // 筛选条件改变时自动应用筛选并重置页码为 1
- useEffect(() => {
- // 使用一个标志来避免初始加载时触发
- const timer = setTimeout(() => {
- if (pagination.total > 0 || models.length > 0) {
- setSearchParams(prev => {
- const newParams = new URLSearchParams(prev);
- newParams.delete('page');
- newParams.set('source', modelSource);
- return newParams;
- });
- }
- }, 100);
- return () => clearTimeout(timer);
- }, [selectedCompanies, selectedModalTypes, isNewFilter, isFeaturedFilter, isApiEnabledFilter, sortBy, sortOrder, modelSource]); // eslint-disable-line react-hooks/exhaustive-deps
- // 点击外部关闭筛选面板
- useEffect(() => {
- const handleClickOutside = (event: MouseEvent) => {
- if (
- filterPanelRef.current &&
- !filterPanelRef.current.contains(event.target as Node) &&
- filterButtonRef.current &&
- !filterButtonRef.current.contains(event.target as Node)
- ) {
- setShowFilterPanel(false);
- }
- };
- if (showFilterPanel) {
- document.addEventListener('mousedown', handleClickOutside);
- }
- return () => {
- document.removeEventListener('mousedown', handleClickOutside);
- };
- }, [showFilterPanel]);
- // 切换厂商选择
- const toggleCompany = (company: string) => {
- setSelectedCompanies(prev =>
- prev.includes(company)
- ? prev.filter(c => c !== company)
- : [...prev, company]
- );
- };
- // 切换模态类型选择
- const toggleModalType = (modalType: string) => {
- setSelectedModalTypes(prev =>
- prev.includes(modalType)
- ? prev.filter(m => m !== modalType)
- : [...prev, modalType]
- );
- };
- // 清除所有筛选条件
- const clearFilters = () => {
- setSelectedCompanies([]);
- setSelectedModalTypes([]);
- setIsNewFilter(undefined);
- setIsFeaturedFilter(undefined);
- setIsApiEnabledFilter(undefined);
- setSortBy('createdAt');
- setSortOrder('desc');
- };
- // 计算选中数量
- const selectedCount = selectedCompanies.length + selectedModalTypes.length +
- (isNewFilter !== undefined ? 1 : 0) + (isFeaturedFilter !== undefined ? 1 : 0) + (isApiEnabledFilter !== undefined ? 1 : 0);
- // 检查是否有活动的筛选条件
- const hasActiveFilters = selectedCompanies.length > 0 || selectedModalTypes.length > 0 ||
- isNewFilter !== undefined || isFeaturedFilter !== undefined || isApiEnabledFilter !== undefined || sortBy !== 'createdAt' || sortOrder !== 'desc';
- // 搜索处理
- const handleSearch = (e: React.FormEvent) => {
- e.preventDefault();
- setSearchParams(prev => {
- const newParams = new URLSearchParams(prev);
- newParams.delete('page');
- newParams.set('source', modelSource);
- return newParams;
- });
- };
- return (
- <div className="bg-white p-4 sm:p-6 md:p-8 rounded-2xl border border-gray-100 shadow-sm">
- <div className="space-y-4 sm:space-y-6 md:space-y-8 animate-in fade-in duration-500">
- <div className="flex flex-col md:flex-row md:items-center justify-between gap-3 sm:gap-4 md:gap-6">
- <div className="flex-1">
- <h2 className="text-xl sm:text-2xl font-bold text-gray-900 tracking-tight">模型广场</h2>
- <p className="text-sm text-gray-500 mt-1">
- {modelSource === 'local' ? '运行于本地节点的私域模型,确保数据隐私与高效响应' : '探索前沿 AI 基础模型,一键集成到您的创作流'}
- </p>
- </div>
- {/* 云端/本地模型切换Tab - 居中 */}
- <div className="flex items-center justify-center">
- <div className="flex items-center bg-gray-100 rounded-xl p-1">
- <button
- onClick={() => {
- if (modelSource !== 'cloud') {
- setActiveCategory('全部');
- clearFilters();
- setSearchParams(prev => {
- const newParams = new URLSearchParams(prev);
- newParams.set('source', 'cloud');
- newParams.delete('page');
- return newParams;
- });
- }
- }}
- className={`px-5 py-2 rounded-lg text-sm font-medium transition-all duration-200 ${
- modelSource === 'cloud'
- ? 'bg-white text-blue-600 shadow-sm'
- : 'text-gray-500 hover:text-gray-700'
- }`}
- >
- 云端模型
- </button>
- <button
- onClick={() => {
- if (modelSource !== 'local') {
- setActiveCategory('全部');
- clearFilters();
- setSearchParams(prev => {
- const newParams = new URLSearchParams(prev);
- newParams.set('source', 'local');
- newParams.delete('page');
- return newParams;
- });
- }
- }}
- className={`px-5 py-2 rounded-lg text-sm font-medium transition-all duration-200 ${
- modelSource === 'local'
- ? 'bg-white text-blue-600 shadow-sm'
- : 'text-gray-500 hover:text-gray-700'
- }`}
- >
- 私域模型
- </button>
- </div>
- </div>
- {/* 右侧占位,保持Tab居中 */}
- <div className="flex-1"></div>
- </div>
- {/* 分类选择器 + 搜索筛选 - 仅云端模型显示 */}
- {modelSource === 'cloud' && (
- <div className="flex flex-col gap-3 pb-2">
- {/* 分组筛选按钮行 - 在分类按钮上方 */}
- {groupNames.length > 0 && (
- <div className="flex items-center space-x-2 overflow-x-auto scrollbar-hide">
- <button
- onClick={() => setActiveGroupName('全部')}
- className={`px-4 py-1.5 rounded-full text-xs font-medium whitespace-nowrap transition-all duration-200 ${
- activeGroupName === '全部'
- ? 'bg-violet-600 text-white shadow-md shadow-violet-600/20'
- : 'bg-white text-gray-500 border border-gray-100 hover:border-violet-200 hover:text-violet-600'
- }`}
- >
- 全部分组
- </button>
- {groupNames.map(name => (
- <button
- key={name}
- onClick={() => setActiveGroupName(name)}
- className={`px-4 py-1.5 rounded-full text-xs font-medium whitespace-nowrap transition-all duration-200 ${
- activeGroupName === name
- ? 'bg-violet-600 text-white shadow-md shadow-violet-600/20'
- : 'bg-white text-gray-500 border border-gray-100 hover:border-violet-200 hover:text-violet-600'
- }`}
- >
- {name}
- </button>
- ))}
- </div>
- )}
- {/* 模型分类按钮 + 搜索框 */}
- <div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 sm:gap-4">
- <div className="flex items-center space-x-2 overflow-x-auto scrollbar-hide w-full sm:w-auto pb-1 sm:pb-0">
- {modelCategories.map(cat => (
- <button
- key={cat}
- onClick={() => setActiveCategory(cat)}
- className={`px-6 py-2 rounded-full text-xs font-bold whitespace-nowrap transition-all duration-200 ${
- cat === activeCategory
- ? 'bg-blue-600 text-white shadow-lg shadow-blue-600/20'
- : 'bg-white text-gray-500 border border-gray-100 hover:border-blue-200 hover:text-blue-600'
- }`}
- >
- {cat}
- </button>
- ))}
- </div>
-
- {/* 搜索框 */}
- <div className="flex items-center gap-2 flex-shrink-0 w-full sm:w-auto">
- <form onSubmit={handleSearch} className="relative group w-full sm:w-auto">
- <Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-4 h-4 group-focus-within:text-blue-500 transition-colors" />
- <input
- type="text"
- value={searchKeyword}
- onChange={(e) => setSearchKeyword(e.target.value)}
- placeholder="搜索模型名称"
- className="pl-10 pr-4 py-2 bg-white border border-gray-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 w-full sm:w-48 transition-all shadow-sm"
- />
- </form>
- </div>
- </div>
- </div>
- )}
- {/* 主体区域:模型列表(移除右侧分组面板,分组已移至顶部) */}
- <div className="flex gap-4">
- {/* 模型内容区 */}
- <div className="flex-1 min-w-0 space-y-4">
- {/* 本地模型列表 */}
- {modelSource === 'local' && (
- <LocalModelList />
- )}
- {/* 云端模型加载状态 */}
- {modelSource === 'cloud' && loading && (
- <div className="flex items-center justify-center py-20">
- <Loader2 className="w-8 h-8 text-blue-600 animate-spin" />
- <span className="ml-3 text-gray-600">加载中...</span>
- </div>
- )}
- {/* 云端模型错误状态 */}
- {modelSource === 'cloud' && error && !loading && (
- <div className="bg-red-50 border border-red-200 rounded-xl p-4 text-center">
- <p className="text-red-600">{error}</p>
- <button
- onClick={() => fetchModels()}
- className="mt-2 text-sm text-red-600 font-bold hover:underline"
- >
- 重试
- </button>
- </div>
- )}
- {/* 云端模型列表或空状态 */}
- {modelSource === 'cloud' && !loading && !error && (
- <>
- {models.length > 0 ? (
- <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4">
- {models.map((m) => (
- <ModelCard
- key={m.id}
- model={m}
- versionDisplayMode="standard"
- />
- ))}
- </div>
- ) : (
- <div className="py-20 flex flex-col items-center justify-center text-center bg-gray-50 rounded-3xl border-2 border-dashed border-gray-200">
- <div className="w-16 h-16 bg-white rounded-full flex items-center justify-center shadow-sm mb-4">
- <Search className="w-8 h-8 text-gray-300" />
- </div>
- <h3 className="text-lg font-bold text-gray-900">未找到相关模型</h3>
- <p className="text-sm text-gray-500 mt-2">试试更换分类或搜索关键词</p>
- <button
- onClick={() => {
- setActiveCategory('全部');
- setSearchKeyword('');
- }}
- className="mt-6 text-sm text-blue-600 font-bold hover:underline"
- >
- 查看全部模型
- </button>
- </div>
- )}
- </>
- )}
- {/* 云端模型分页信息 */}
- {modelSource === 'cloud' && !loading && !error && (
- <Pagination
- total={pagination.total}
- totalPages={pagination.totalPages}
- currentPage={currentPage}
- />
- )}
- </div>
- {/* 右侧分组面板已移至顶部,此处不再显示 */}
- </div>
- </div>
- </div>
- );
- };
- export default ModelSquare;
|