mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-07 01:56:53 +00:00
feat(ui): redesign model square pricing page
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { ChevronDown, RotateCcw } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getLobeIcon } from '@/lib/lobe-icon'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
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(
|
||||
'group inline-flex max-w-full items-center gap-1.5 rounded-md border px-2 py-1 text-xs font-medium transition-all',
|
||||
props.active
|
||||
? 'border-foreground/30 bg-foreground/5 text-foreground shadow-sm'
|
||||
: 'border-border/70 bg-background text-muted-foreground hover:border-border 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-full px-1.5 py-0.5 text-[10px]',
|
||||
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-border/70 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-semibold'>
|
||||
{props.title}
|
||||
</span>
|
||||
<ChevronDown className='text-muted-foreground size-4 transition-transform group-data-[state=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-xl border p-3',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
<div className='mb-2.5 flex items-center justify-between gap-2'>
|
||||
<div>
|
||||
<h2 className='text-foreground text-sm font-bold'>{t('Filter')}</h2>
|
||||
<p className='text-muted-foreground mt-1 text-xs'>
|
||||
{t('Refine models by provider, group, type, and tags.')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={props.onClearFilters}
|
||||
disabled={!props.hasActiveFilters}
|
||||
className='h-7 gap-1.5 px-2 text-xs'
|
||||
>
|
||||
<RotateCcw className='size-3.5' />
|
||||
{t('Reset')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{props.hasActiveFilters && (
|
||||
<Badge variant='secondary' className='mb-3'>
|
||||
{t('Filters active')}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<div className='space-y-1'>
|
||||
<FilterSection
|
||||
title={t('Groups')}
|
||||
value={props.groupFilter}
|
||||
options={groupOptions}
|
||||
onChange={props.onGroupChange}
|
||||
/>
|
||||
<FilterSection
|
||||
title={t('All Vendors')}
|
||||
value={props.vendorFilter}
|
||||
options={vendorOptions}
|
||||
onChange={props.onVendorChange}
|
||||
/>
|
||||
<FilterSection
|
||||
title={t('Model Tags')}
|
||||
value={props.tagFilter}
|
||||
options={tagOptions}
|
||||
onChange={props.onTagChange}
|
||||
/>
|
||||
<FilterSection
|
||||
title={t('Pricing Type')}
|
||||
value={props.quotaTypeFilter}
|
||||
options={quotaOptions}
|
||||
onChange={props.onQuotaTypeChange}
|
||||
/>
|
||||
<FilterSection
|
||||
title={t('Endpoint Type')}
|
||||
value={props.endpointTypeFilter}
|
||||
options={endpointOptions}
|
||||
onChange={props.onEndpointTypeChange}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user