Files
new-api/web/default/src/features/pricing/components/pricing-sidebar.tsx
T
t0ng7u 9d1ca545e2 ♻️ refactor(web): refine data-table cards and pricing page layout
Replace collapsible card details with always-visible label/value rows, add badge-list full display for cards, and polish pricing/channel toolbars and mobile cards.
2026-07-11 06:02:16 +08:00

301 lines
8.4 KiB
TypeScript
Vendored

/*
Copyright (C) 2023-2026 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.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { ChevronDown, RotateCcw } from 'lucide-react'
import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/design-system/button'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { getLobeIcon } from '@/lib/lobe-icon'
import { cn } from '@/lib/utils'
import {
ENDPOINT_TYPES,
FILTER_ALL,
QUOTA_TYPES,
getEndpointTypeLabels,
getQuotaTypeLabels,
} from '../constants'
import { parseTags } from '../lib/filters'
import type { PricingModel, PricingVendor } from '../types'
type FilterOption = {
value: string
label: string
count?: number
suffix?: string
icon?: ReactNode
}
type FilterSectionProps = {
title: string
value: string
options: FilterOption[]
onChange: (value: string) => void
}
export interface PricingSidebarProps {
quotaTypeFilter: string
endpointTypeFilter: string
vendorFilter: string
groupFilter: string
tagFilter: string
onQuotaTypeChange: (value: string) => void
onEndpointTypeChange: (value: string) => void
onVendorChange: (value: string) => void
onGroupChange: (value: string) => void
onTagChange: (value: string) => void
vendors: PricingVendor[]
groups: string[]
groupRatios?: Record<string, number>
tags: string[]
models: PricingModel[]
hasActiveFilters: boolean
onClearFilters: () => void
className?: string
}
function countBy(
models: PricingModel[],
predicate: (model: PricingModel) => boolean
): number {
return models.reduce((count, model) => count + (predicate(model) ? 1 : 0), 0)
}
function formatGroupRatio(ratio: number | undefined): string | undefined {
if (ratio == null) return undefined
const formatted = Number.isInteger(ratio)
? ratio.toString()
: ratio.toFixed(3).replace(/0+$/, '').replace(/\.$/, '')
return `x${formatted}`
}
function FilterChip(props: {
option: FilterOption
active: boolean
onClick: () => void
}) {
return (
<button
type='button'
onClick={props.onClick}
className={cn(
'inline-flex min-h-6 max-w-full items-center gap-1.5 rounded-md border px-2 py-1 text-xs font-medium transition-colors',
props.active
? 'border-foreground/30 bg-muted text-foreground'
: 'border-border bg-background text-muted-foreground hover:bg-muted/50 hover:text-foreground'
)}
title={props.option.label}
>
{props.option.icon && (
<span className='shrink-0'>{props.option.icon}</span>
)}
<span className='truncate'>{props.option.label}</span>
{(props.option.suffix || props.option.count != null) && (
<span
className={cn(
'rounded-md px-1.5 py-0.5 text-xs',
props.active
? 'bg-background text-foreground'
: 'bg-muted text-muted-foreground'
)}
>
{props.option.suffix ?? props.option.count}
</span>
)}
</button>
)
}
function FilterSection(props: FilterSectionProps) {
return (
<Collapsible defaultOpen className='border-b pb-3 last:border-b-0'>
<CollapsibleTrigger className='group flex w-full items-center justify-between py-2.5 text-left'>
<span className='text-foreground text-sm font-medium'>
{props.title}
</span>
<ChevronDown
aria-hidden='true'
className='text-muted-foreground size-4 transition-transform group-data-[panel-open]:rotate-180'
/>
</CollapsibleTrigger>
<CollapsibleContent>
<div className='flex flex-wrap gap-1.5'>
{props.options.map((option) => (
<FilterChip
key={option.value}
option={option}
active={props.value === option.value}
onClick={() => props.onChange(option.value)}
/>
))}
</div>
</CollapsibleContent>
</Collapsible>
)
}
export function PricingSidebar(props: PricingSidebarProps) {
const { t } = useTranslation()
const quotaTypeLabels = getQuotaTypeLabels(t)
const endpointTypeLabels = getEndpointTypeLabels(t)
const vendorOptions: FilterOption[] = [
{
value: FILTER_ALL,
label: t('All Vendors'),
count: props.models.length,
},
...props.vendors
.map((vendor) => ({
value: vendor.name,
label: vendor.name,
count: countBy(
props.models,
(model) => model.vendor_name === vendor.name
),
icon: vendor.icon ? getLobeIcon(vendor.icon, 14) : undefined,
}))
.filter((vendor) => vendor.count > 0),
]
const groupOptions: FilterOption[] = [
{
value: FILTER_ALL,
label: t('All Groups'),
},
...props.groups.map((group) => ({
value: group,
label: group,
suffix: formatGroupRatio(props.groupRatios?.[group]),
})),
]
const quotaOptions: FilterOption[] = [
{
value: QUOTA_TYPES.ALL,
label: quotaTypeLabels[QUOTA_TYPES.ALL],
count: props.models.length,
},
{
value: QUOTA_TYPES.TOKEN,
label: quotaTypeLabels[QUOTA_TYPES.TOKEN],
count: countBy(props.models, (model) => model.quota_type === 0),
},
{
value: QUOTA_TYPES.REQUEST,
label: quotaTypeLabels[QUOTA_TYPES.REQUEST],
count: countBy(props.models, (model) => model.quota_type === 1),
},
]
const tagOptions: FilterOption[] = [
{
value: FILTER_ALL,
label: t('All Tags'),
count: props.models.length,
},
...props.tags.map((tag) => ({
value: tag,
label: tag,
count: countBy(props.models, (model) =>
parseTags(model.tags)
.map((item) => item.toLowerCase())
.includes(tag.toLowerCase())
),
})),
]
const endpointOptions: FilterOption[] = [
{
value: ENDPOINT_TYPES.ALL,
label: endpointTypeLabels[ENDPOINT_TYPES.ALL],
count: props.models.length,
},
...Object.entries(endpointTypeLabels)
.filter(([value]) => value !== ENDPOINT_TYPES.ALL)
.map(([value, label]) => ({
value,
label,
count: countBy(
props.models,
(model) => model.supported_endpoint_types?.includes(value) ?? false
),
})),
]
return (
<aside className={cn('rounded-lg border p-3', props.className)}>
<div className='mb-2 flex items-center justify-between gap-2'>
<p className='text-muted-foreground text-xs'>
{props.hasActiveFilters
? t('Filters active')
: t('Refine models by provider, group, type, and tags.')}
</p>
<Button
type='button'
variant='ghost'
onClick={props.onClearFilters}
disabled={!props.hasActiveFilters}
>
<RotateCcw aria-hidden='true' />
{t('Reset')}
</Button>
</div>
<div className='space-y-1'>
<FilterSection
title={t('All Vendors')}
value={props.vendorFilter}
options={vendorOptions}
onChange={props.onVendorChange}
/>
<FilterSection
title={t('Endpoint Type')}
value={props.endpointTypeFilter}
options={endpointOptions}
onChange={props.onEndpointTypeChange}
/>
<FilterSection
title={t('Pricing Type')}
value={props.quotaTypeFilter}
options={quotaOptions}
onChange={props.onQuotaTypeChange}
/>
<FilterSection
title={t('Groups')}
value={props.groupFilter}
options={groupOptions}
onChange={props.onGroupChange}
/>
<FilterSection
title={t('Model Tags')}
value={props.tagFilter}
options={tagOptions}
onChange={props.onTagChange}
/>
</div>
</aside>
)
}