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:
parent
5fa5f87e1e
commit
61b0d902b6
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue