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 React, { useContext, useMemo } from 'react';
|
||||||
import { Layout, ImagePreview } from '@douyinfe/semi-ui';
|
import { ImagePreview } from '@douyinfe/semi-ui';
|
||||||
import PricingSidebar from './PricingSidebar';
|
import ModelsSidebar from './ModelsSidebar';
|
||||||
import PricingContent from './content/PricingContent';
|
import ModelsContent from './ModelsContent';
|
||||||
import ModelDetailSideSheet from '../modal/ModelDetailSideSheet';
|
import ModelDetailSideSheet from '../modal/ModelDetailSideSheet';
|
||||||
import { useModelPricingData } from '../../../../hooks/model-pricing/useModelPricingData';
|
import { useModelPricingData } from '../../../../hooks/model-pricing/useModelPricingData';
|
||||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||||
|
|
@ -29,7 +29,6 @@ import { UserContext } from '../../../../context/User';
|
||||||
|
|
||||||
const PricingPage = () => {
|
const PricingPage = () => {
|
||||||
const pricingData = useModelPricingData();
|
const pricingData = useModelPricingData();
|
||||||
const { Sider, Content } = Layout;
|
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const [showRatio, setShowRatio] = React.useState(false);
|
const [showRatio, setShowRatio] = React.useState(false);
|
||||||
const [viewMode, setViewMode] = React.useState('card');
|
const [viewMode, setViewMode] = React.useState('card');
|
||||||
|
|
@ -60,22 +59,22 @@ const PricingPage = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='bg-white'>
|
<div className='bg-background'>
|
||||||
<Layout className='pricing-layout'>
|
<div className='models-page-layout'>
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<Sider className='pricing-scroll-hide pricing-sidebar'>
|
<aside className='models-sidebar'>
|
||||||
<PricingSidebar {...allProps} />
|
<ModelsSidebar {...allProps} />
|
||||||
</Sider>
|
</aside>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Content className='pricing-scroll-hide pricing-content'>
|
<main className='models-content'>
|
||||||
<PricingContent
|
<ModelsContent
|
||||||
{...allProps}
|
{...allProps}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
sidebarProps={allProps}
|
sidebarProps={allProps}
|
||||||
/>
|
/>
|
||||||
</Content>
|
</main>
|
||||||
</Layout>
|
</div>
|
||||||
|
|
||||||
<ImagePreview
|
<ImagePreview
|
||||||
src={pricingData.modalImageUrl}
|
src={pricingData.modalImageUrl}
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,7 @@ const PricingCardView = ({
|
||||||
channelVideoRatio = {},
|
channelVideoRatio = {},
|
||||||
channelVideoCompletionRatio = {},
|
channelVideoCompletionRatio = {},
|
||||||
channelVideoPrice = {},
|
channelVideoPrice = {},
|
||||||
|
gridCols = 0, // 当 > 0 时使用 grid 布局(如 gridCols=2 表示 2 列)
|
||||||
}) => {
|
}) => {
|
||||||
const showSkeleton = useMinimumLoadingTime(loading);
|
const showSkeleton = useMinimumLoadingTime(loading);
|
||||||
const startIndex = (currentPage - 1) * pageSize;
|
const startIndex = (currentPage - 1) * pageSize;
|
||||||
|
|
@ -858,8 +859,8 @@ const PricingCardView = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className='px-2 pt-2'>
|
<div className={gridCols > 0 ? '' : 'px-2 pt-2'}>
|
||||||
<div className='flex flex-wrap gap-4'>
|
<div className={gridCols > 0 ? 'grid gap-4' : 'flex flex-wrap gap-4'} style={gridCols > 0 ? { gridTemplateColumns: `repeat(${gridCols}, 1fr)` } : undefined}>
|
||||||
{paginatedModels.map((model, index) => {
|
{paginatedModels.map((model, index) => {
|
||||||
const modelKey = getModelKey(model);
|
const modelKey = getModelKey(model);
|
||||||
const isSelected = selectedRowKeys.includes(modelKey);
|
const isSelected = selectedRowKeys.includes(modelKey);
|
||||||
|
|
@ -884,7 +885,7 @@ const PricingCardView = ({
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
key={modelKey || index}
|
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%' }}
|
bodyStyle={{ height: '100%' }}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
!blurPricing && openModelDetail && openModelDetail(model)
|
!blurPricing && openModelDetail && openModelDetail(model)
|
||||||
|
|
|
||||||
|
|
@ -1432,6 +1432,130 @@ html.dark .with-pastel-balls::before {
|
||||||
min-height: 0;
|
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-ui 缁勪欢鑷畾涔夋牱寮?==================== */
|
||||||
.semi-card-header,
|
.semi-card-header,
|
||||||
.semi-card-body {
|
.semi-card-body {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue