refactor: 模型页面布局调整为左右结构(类 tokendance.models)

新增:
- ModelsSidebar.jsx 左侧筛选面板(作者/服务商/输入模态/供应商)
- ModelsContent.jsx 右侧内容区(全部/文本/图像/音频/视频 Tab + 搜索)
- index.css models-* 样式类

修改:
- PricingPage.jsx 布局改为 flex 左右结构替代 Semi UI Layout
- PricingCardView.jsx 新增 gridCols 参数支持 2 列网格布局
  当 gridCols=2 时:grid gap-4 + 卡片 !w-full

布局: 左 280px 筛选栏 + 右自适应内容区
Tab: 全部/文本/图像/音频/视频 (映射到 filterEndpointType)
卡片: 每行 2 个模型卡片
This commit is contained in:
xiezhouwei 2026-06-17 14:33:53 +08:00
parent 5fa5f87e1e
commit 61b0d902b6
5 changed files with 390 additions and 16 deletions

View File

@ -0,0 +1,75 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import { Search } from 'lucide-react';
import PricingCardView from '../view/card/PricingCardView';
const TYPE_TABS = [
{ key: 'all', labelKey: '全部' },
{ key: 'text', labelKey: '文本' },
{ key: 'image', labelKey: '图像' },
{ key: 'audio', labelKey: '音频' },
{ key: 'video', labelKey: '视频' },
];
const ModelsContent = ({
filteredModels,
filterEndpointType, setFilterEndpointType,
searchValue, setSearchValue,
loading,
isMobile,
blurPricing,
t,
...cardProps
}) => {
return (
<>
{/* Type Tabs */}
<div className='models-tabs-bar'>
{TYPE_TABS.map((tab) => (
<button
key={tab.key}
className={`models-tab-btn${filterEndpointType === tab.key ? ' active' : ''}`}
onClick={() => setFilterEndpointType(tab.key)}
>
{t(tab.labelKey)}
</button>
))}
<div className='flex-1' />
<div className='relative'>
<Search size={15} className='absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground/60' />
<input
type='text'
placeholder={t('搜索模型名称...')}
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
className='w-48 bg-muted/30 border border-input rounded-lg pl-9 pr-3 py-1.5 text-sm text-foreground placeholder-muted-foreground/50 focus:outline-none focus:border-ring focus:bg-muted/50 transition-all'
/>
</div>
</div>
{/* Model Cards Grid */}
<div className='flex-1 overflow-y-auto'>
<PricingCardView
filteredModels={filteredModels}
loading={loading}
blurPricing={blurPricing}
t={t}
gridCols={2}
{...cardProps}
/>
</div>
</>
);
};
export default ModelsContent;

View File

@ -0,0 +1,175 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import { usePricingFilterCounts } from '../../../../hooks/model-pricing/usePricingFilterCounts';
import { getLobeHubIcon } from '../../../../helpers';
const ModelsSidebar = ({
models,
filterVendor, setFilterVendor,
filterSupplierType, setFilterSupplierType,
filterTag, setFilterTag,
filterSupplier, setFilterSupplier,
filterGroup, filterQuotaType, filterEndpointType, searchValue,
loading, t,
}) => {
const { vendorModels, tagModels, supplierTypeModels } = usePricingFilterCounts({
models,
filterGroup,
filterQuotaType,
filterEndpointType,
filterVendor,
filterTag,
filterSupplierType,
searchValue,
});
// (Vendors)
const vendors = React.useMemo(() => {
const names = [...new Set(models.map((m) => m.vendor_name).filter(Boolean))].sort();
return names.map((name) => {
const icon = models.find((m) => m.vendor_name === name)?.vendor_icon;
return {
name,
icon,
count: vendorModels.filter((m) => m.vendor_name === name).length,
};
});
}, [models, vendorModels]);
// (Supplier Types)
const supplierTypes = React.useMemo(() => {
const types = new Set();
models.forEach((m) => {
(m.channel_list || []).forEach((ch) => {
if (ch.supplier_type) types.add(ch.supplier_type);
});
});
return [...types].sort();
}, [models]);
// (Tags)
const tags = React.useMemo(() => {
const allTags = new Set();
models.forEach((m) => {
if (m.tags) {
String(m.tags).split(/[,;|]/).map(s => s.trim()).filter(Boolean).forEach(t => allTags.add(t.toLowerCase()));
}
});
return [...allTags].sort();
}, [models]);
// (Suppliers)
const suppliers = React.useMemo(() => {
const aliases = new Set();
models.forEach((m) => {
(m.channel_list || []).forEach((ch) => {
if (ch.supplier_alias) aliases.add(ch.supplier_alias);
});
});
return [...aliases].sort();
}, [models]);
const FilterItem = ({ label, count, active, onClick }) => (
<div
className={`models-filter-item${active ? ' active' : ''}`}
onClick={onClick}
>
<span>{label}</span>
<span className='count'>{count}</span>
</div>
);
return (
<div>
{/* 作者 */}
<div className='models-filter-section'>
<div className='models-filter-title'>{t('作者')}</div>
<FilterItem label={t('全部')} count={models.length} active={filterVendor === 'all'} onClick={() => setFilterVendor('all')} />
{vendors.map((v) => (
<FilterItem
key={v.name}
label={
<span className='flex items-center gap-2'>
{v.icon && (
<img
src={getLobeHubIcon(v.icon)}
alt=''
className='w-4 h-4 rounded-full'
onError={(e) => { e.target.style.display = 'none'; }}
/>
)}
<span>{v.name}</span>
</span>
}
count={v.count}
active={filterVendor === v.name}
onClick={() => setFilterVendor(filterVendor === v.name ? 'all' : v.name)}
/>
))}
</div>
{/* 服务商 */}
<div className='models-filter-section'>
<div className='models-filter-title'>{t('服务商')}</div>
<FilterItem label={t('全部')} count={models.length} active={filterSupplierType === 'all'} onClick={() => setFilterSupplierType('all')} />
{supplierTypes.map((type) => (
<FilterItem
key={type}
label={type}
count={supplierTypeModels.filter((m) =>
(m.channel_list || []).some((ch) => ch.supplier_type === type)
).length}
active={filterSupplierType === type}
onClick={() => setFilterSupplierType(filterSupplierType === type ? 'all' : type)}
/>
))}
</div>
{/* 输入模态 */}
<div className='models-filter-section'>
<div className='models-filter-title'>{t('输入模态')}</div>
<FilterItem label={t('全部')} count={models.length} active={filterTag === 'all'} onClick={() => setFilterTag('all')} />
{tags.map((tag) => (
<FilterItem
key={tag}
label={tag}
count={tagModels.filter((m) =>
m.tags && String(m.tags).split(/[,;|]/).map(s => s.trim()).includes(tag)
).length}
active={filterTag === tag}
onClick={() => setFilterTag(filterTag === tag ? 'all' : tag)}
/>
))}
</div>
{/* 供应商 */}
<div className='models-filter-section'>
<div className='models-filter-title'>{t('供应商')}</div>
<FilterItem label={t('全部')} count={models.length} active={filterSupplier === 'all'} onClick={() => setFilterSupplier('all')} />
{suppliers.map((sup) => (
<FilterItem
key={sup}
label={sup}
count={models.filter((m) =>
(m.channel_list || []).some((ch) => ch.supplier_alias === sup)
).length}
active={filterSupplier === sup}
onClick={() => setFilterSupplier(filterSupplier === sup ? 'all' : sup)}
/>
))}
</div>
</div>
);
};
export default ModelsSidebar;

View File

@ -18,9 +18,9 @@ For commercial licensing, please contact support@quantumnous.com
*/
import React, { useContext, useMemo } from 'react';
import { Layout, ImagePreview } from '@douyinfe/semi-ui';
import PricingSidebar from './PricingSidebar';
import PricingContent from './content/PricingContent';
import { ImagePreview } from '@douyinfe/semi-ui';
import ModelsSidebar from './ModelsSidebar';
import ModelsContent from './ModelsContent';
import ModelDetailSideSheet from '../modal/ModelDetailSideSheet';
import { useModelPricingData } from '../../../../hooks/model-pricing/useModelPricingData';
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
@ -29,7 +29,6 @@ import { UserContext } from '../../../../context/User';
const PricingPage = () => {
const pricingData = useModelPricingData();
const { Sider, Content } = Layout;
const isMobile = useIsMobile();
const [showRatio, setShowRatio] = React.useState(false);
const [viewMode, setViewMode] = React.useState('card');
@ -60,22 +59,22 @@ const PricingPage = () => {
};
return (
<div className='bg-white'>
<Layout className='pricing-layout'>
<div className='bg-background'>
<div className='models-page-layout'>
{!isMobile && (
<Sider className='pricing-scroll-hide pricing-sidebar'>
<PricingSidebar {...allProps} />
</Sider>
<aside className='models-sidebar'>
<ModelsSidebar {...allProps} />
</aside>
)}
<Content className='pricing-scroll-hide pricing-content'>
<PricingContent
<main className='models-content'>
<ModelsContent
{...allProps}
isMobile={isMobile}
sidebarProps={allProps}
/>
</Content>
</Layout>
</main>
</div>
<ImagePreview
src={pricingData.modalImageUrl}

View File

@ -87,6 +87,7 @@ const PricingCardView = ({
channelVideoRatio = {},
channelVideoCompletionRatio = {},
channelVideoPrice = {},
gridCols = 0, // > 0 使 grid gridCols=2 2
}) => {
const showSkeleton = useMinimumLoadingTime(loading);
const startIndex = (currentPage - 1) * pageSize;
@ -858,8 +859,8 @@ const PricingCardView = ({
return (
<>
<div className='px-2 pt-2'>
<div className='flex flex-wrap gap-4'>
<div className={gridCols > 0 ? '' : 'px-2 pt-2'}>
<div className={gridCols > 0 ? 'grid gap-4' : 'flex flex-wrap gap-4'} style={gridCols > 0 ? { gridTemplateColumns: `repeat(${gridCols}, 1fr)` } : undefined}>
{paginatedModels.map((model, index) => {
const modelKey = getModelKey(model);
const isSelected = selectedRowKeys.includes(modelKey);
@ -884,7 +885,7 @@ const PricingCardView = ({
return (
<Card
key={modelKey || index}
className={`flex-1 min-w-[350px] max-w-[600px] !rounded-2xl transition-all duration-200 hover:shadow-lg border ${blurPricing ? '' : 'cursor-pointer'} ${isSelected ? CARD_STYLES.selected : CARD_STYLES.default}`}
className={`${gridCols > 0 ? '!w-full' : 'flex-1 min-w-[350px] max-w-[600px]'} !rounded-2xl transition-all duration-200 hover:shadow-lg border ${blurPricing ? '' : 'cursor-pointer'} ${isSelected ? CARD_STYLES.selected : CARD_STYLES.default}`}
bodyStyle={{ height: '100%' }}
onClick={() =>
!blurPricing && openModelDetail && openModelDetail(model)

124
web/src/index.css vendored
View File

@ -1432,6 +1432,130 @@ html.dark .with-pastel-balls::before {
min-height: 0;
}
/* ─── Models 新布局 ─── */
.models-page-layout {
display: flex;
max-width: 1440px;
margin: 0 auto;
padding: 0 24px;
gap: 32px;
height: calc(100vh - 60px);
overflow: hidden;
}
.models-sidebar {
width: 280px;
flex-shrink: 0;
overflow-y: auto;
padding: 24px 16px 24px 0;
border-right: 1px solid var(--border-color, #e5e7eb);
}
.models-sidebar::-webkit-scrollbar {
width: 4px;
}
.models-sidebar::-webkit-scrollbar-thumb {
background: transparent;
}
.models-sidebar:hover::-webkit-scrollbar-thumb {
background: rgba(0,0,0,0.15);
border-radius: 2px;
}
.models-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.models-tabs-bar {
display: flex;
gap: 8px;
padding: 16px 0;
flex-shrink: 0;
border-bottom: 1px solid var(--border-color, #e5e7eb);
}
.models-tab-btn {
padding: 6px 20px;
border-radius: 20px;
border: 1px solid var(--border-color, #d1d5db);
background: transparent;
cursor: pointer;
font-size: 14px;
color: var(--text-muted, #6b7280);
transition: all 0.2s;
white-space: nowrap;
}
.models-tab-btn:hover {
border-color: var(--primary-color, #3b82f6);
color: var(--primary-color, #3b82f6);
}
.models-tab-btn.active {
background: var(--primary-color, #3b82f6);
color: white;
border-color: var(--primary-color, #3b82f6);
}
.models-card-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
padding: 16px 0;
overflow-y: auto;
flex: 1;
}
.models-card-grid::-webkit-scrollbar {
width: 6px;
}
.models-card-grid::-webkit-scrollbar-thumb {
background: rgba(0,0,0,0.12);
border-radius: 3px;
}
/* 筛选面板标题 */
.models-filter-section {
margin-bottom: 24px;
}
.models-filter-title {
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted, #6b7280);
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border-color, #e5e7eb);
}
.models-filter-item {
display: flex;
align-items: center;
padding: 6px 10px;
border-radius: 8px;
cursor: pointer;
transition: all 0.15s;
font-size: 14px;
color: var(--text-color, #374151);
gap: 8px;
}
.models-filter-item:hover {
background: var(--hover-bg, #f3f4f6);
}
.models-filter-item.active {
background: var(--primary-light, #eff6ff);
color: var(--primary-color, #3b82f6);
font-weight: 500;
}
.models-filter-item .count {
margin-left: auto;
font-size: 12px;
color: var(--text-muted, #9ca3af);
}
/* ==================== semi-ui 缁勪欢鑷畾涔夋牱寮?==================== */
.semi-card-header,
.semi-card-body {