mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-14 16:33:06 +00:00
🔤 Typography - Fix --font-sans stack: 'Public Sans Variable' was never applied (wrong family name), add CJK fallbacks (PingFang SC / Microsoft YaHei UI / Noto Sans SC) - Sync <html lang> with i18n language for correct CJK glyph selection - Remove dead fake font system (font-provider.tsx, config/fonts.ts, --font-inter/--font-manrope) - Typography contract: 3-tier headings, arbitrary text-[10px]/[11px] → text-xs (46 files), no uppercase table headers, font-bold → font-semibold - Numbers use tabular-nums only; font-mono reserved for keys/IDs/code (29 files) 🎨 Color discipline - Retire StatusBadge string-hash rainbow (autoColor → neutral); all variants resolve to 5 semantic voices - Add tintedBadgeClassMap as the single tinted-pill recipe - Replace hardcoded emerald/amber/rose/sky classes with semantic tokens across ~30 files (timing badges, progress bars, system panels, pricing, rankings, log row tints) - Drop all !important text overrides and [font-family:var(--font-body)] hacks 🧘 Motion restraint - Remove admin entrance animations: table row stagger, card hover lift/shadow, button press scale, dashboard shimmer line - Neuter page-transition stagger components into plain wrappers; page transitions reduced to pure opacity fade - Decorative motion stays landing-only 📐 Shape - Single radius source: --radius 1rem → 0.625rem - Sparklines use solid chart tokens instead of decorative gradients - PanelWrapper: border-only (no shadow), align radius with Card 📏 Guardrails - Add "Design System Discipline" section (3.10.1) to web/default/AGENTS.md ✅ Verified: tsgo typecheck clean, rsbuild production build passes
418 lines
12 KiB
TypeScript
Vendored
418 lines
12 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 type { ColumnDef } from '@tanstack/react-table'
|
|
import { useTranslation } from 'react-i18next'
|
|
|
|
import {
|
|
BadgeCell,
|
|
BadgeListCell,
|
|
DataTableColumnHeader,
|
|
} from '@/components/data-table'
|
|
import { GroupBadge } from '@/components/group-badge'
|
|
import { StatusBadge } from '@/components/status-badge'
|
|
import { getLobeIcon } from '@/lib/lobe-icon'
|
|
|
|
import { DEFAULT_TOKEN_UNIT, QUOTA_TYPE_VALUES } from '../constants'
|
|
import {
|
|
getDynamicDisplayGroupRatio,
|
|
getDynamicPricingSummary,
|
|
} from '../lib/dynamic-price'
|
|
import { parseTags } from '../lib/filters'
|
|
import { isTokenBasedModel } from '../lib/model-helpers'
|
|
import {
|
|
formatPrice,
|
|
formatRequestPrice,
|
|
stripTrailingZeros,
|
|
} from '../lib/price'
|
|
import type { PricingModel, TokenUnit } from '../types'
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Pricing Table Columns
|
|
// ----------------------------------------------------------------------------
|
|
|
|
export interface PricingColumnsOptions {
|
|
tokenUnit?: TokenUnit
|
|
priceRate?: number
|
|
usdExchangeRate?: number
|
|
showRechargePrice?: boolean
|
|
selectedGroup?: string
|
|
}
|
|
|
|
export function usePricingColumns(
|
|
options: PricingColumnsOptions = {}
|
|
): ColumnDef<PricingModel>[] {
|
|
const { t } = useTranslation()
|
|
const {
|
|
tokenUnit = DEFAULT_TOKEN_UNIT,
|
|
priceRate = 1,
|
|
usdExchangeRate = 1,
|
|
showRechargePrice = false,
|
|
selectedGroup,
|
|
} = options
|
|
|
|
const tokenUnitLabel = tokenUnit === 'K' ? '1K' : '1M'
|
|
|
|
return [
|
|
// Model column
|
|
{
|
|
accessorKey: 'model_name',
|
|
meta: { label: t('Model') },
|
|
header: ({ column }) => (
|
|
<DataTableColumnHeader column={column} title={t('Model')} />
|
|
),
|
|
cell: ({ row }) => {
|
|
const model = row.original
|
|
const modelIconKey = model.icon || model.vendor_icon
|
|
const modelIcon = modelIconKey ? getLobeIcon(modelIconKey, 14) : null
|
|
|
|
return (
|
|
<div className='flex max-w-full min-w-0 items-center gap-2'>
|
|
{modelIcon}
|
|
<span className='truncate font-mono text-sm font-medium'>
|
|
{model.model_name}
|
|
</span>
|
|
</div>
|
|
)
|
|
},
|
|
minSize: 200,
|
|
},
|
|
|
|
// Type column
|
|
{
|
|
accessorKey: 'quota_type',
|
|
header: t('Type'),
|
|
cell: ({ row }) => {
|
|
const isTokenBased = row.original.quota_type === QUOTA_TYPE_VALUES.TOKEN
|
|
return (
|
|
<StatusBadge
|
|
label={isTokenBased ? t('Token') : t('Request')}
|
|
variant={isTokenBased ? 'info' : 'neutral'}
|
|
copyable={false}
|
|
/>
|
|
)
|
|
},
|
|
size: 80,
|
|
enableSorting: false,
|
|
},
|
|
|
|
// Price column
|
|
{
|
|
accessorKey: 'price',
|
|
meta: { label: t('Price') },
|
|
header: ({ column }) => (
|
|
<DataTableColumnHeader column={column} title={t('Price')} />
|
|
),
|
|
cell: ({ row }) => {
|
|
const model = row.original
|
|
const dynamicSummary = getDynamicPricingSummary(model, {
|
|
tokenUnit,
|
|
showRechargePrice,
|
|
priceRate,
|
|
usdExchangeRate,
|
|
groupRatioMultiplier: getDynamicDisplayGroupRatio(
|
|
model,
|
|
selectedGroup
|
|
),
|
|
})
|
|
|
|
if (dynamicSummary) {
|
|
if (dynamicSummary.isSpecialExpression) {
|
|
return (
|
|
<div className='max-w-full min-w-0'>
|
|
<div className='text-warning text-xs font-medium'>
|
|
{t('Special billing expression')}
|
|
</div>
|
|
<div className='text-muted-foreground text-xs'>
|
|
{t('Unable to parse structured pricing')}
|
|
</div>
|
|
<code className='text-muted-foreground/70 mt-1 line-clamp-2 block font-mono text-xs leading-relaxed break-all'>
|
|
{dynamicSummary.rawExpression}
|
|
</code>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const primaryEntries = dynamicSummary.primaryEntries.slice(0, 2)
|
|
if (primaryEntries.length === 0) {
|
|
return (
|
|
<span className='text-muted-foreground text-xs'>
|
|
{t('Dynamic Pricing')}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className='max-w-full min-w-0'>
|
|
<span className='text-sm tabular-nums'>
|
|
{primaryEntries.map((entry, index) => (
|
|
<span key={entry.key}>
|
|
{index > 0 && (
|
|
<span className='text-muted-foreground/40 mx-1'>/</span>
|
|
)}
|
|
{stripTrailingZeros(entry.formatted)}
|
|
</span>
|
|
))}
|
|
</span>
|
|
<div className='text-muted-foreground/50 text-xs'>
|
|
/ {tokenUnitLabel} tokens
|
|
{dynamicSummary.tierCount > 1 &&
|
|
` · ${t('{{count}} tiers', {
|
|
count: dynamicSummary.tierCount,
|
|
})}`}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const isTokenBased = isTokenBasedModel(model)
|
|
|
|
if (isTokenBased) {
|
|
const inputPrice = stripTrailingZeros(
|
|
formatPrice(
|
|
model,
|
|
'input',
|
|
tokenUnit,
|
|
showRechargePrice,
|
|
priceRate,
|
|
usdExchangeRate,
|
|
selectedGroup
|
|
)
|
|
)
|
|
const outputPrice = stripTrailingZeros(
|
|
formatPrice(
|
|
model,
|
|
'output',
|
|
tokenUnit,
|
|
showRechargePrice,
|
|
priceRate,
|
|
usdExchangeRate,
|
|
selectedGroup
|
|
)
|
|
)
|
|
|
|
return (
|
|
<div className='max-w-full min-w-0'>
|
|
<span className='text-sm tabular-nums'>
|
|
{inputPrice}
|
|
<span className='text-muted-foreground/40 mx-1'>/</span>
|
|
{outputPrice}
|
|
</span>
|
|
<div className='text-muted-foreground/50 text-xs'>
|
|
/ {tokenUnitLabel} tokens
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const price = stripTrailingZeros(
|
|
formatRequestPrice(
|
|
model,
|
|
showRechargePrice,
|
|
priceRate,
|
|
usdExchangeRate,
|
|
selectedGroup
|
|
)
|
|
)
|
|
|
|
return (
|
|
<div className='max-w-full min-w-0'>
|
|
<span className='text-sm tabular-nums'>{price}</span>
|
|
<div className='text-muted-foreground/50 text-xs'>
|
|
/ {t('request')}
|
|
</div>
|
|
</div>
|
|
)
|
|
},
|
|
size: 180,
|
|
enableSorting: false,
|
|
},
|
|
|
|
// Cached price column (Vercel AI Gateway style)
|
|
{
|
|
id: 'cached_price',
|
|
header: t('Cached'),
|
|
cell: ({ row }) => {
|
|
const model = row.original
|
|
const dynamicSummary = getDynamicPricingSummary(model, {
|
|
tokenUnit,
|
|
showRechargePrice,
|
|
priceRate,
|
|
usdExchangeRate,
|
|
groupRatioMultiplier: getDynamicDisplayGroupRatio(
|
|
model,
|
|
selectedGroup
|
|
),
|
|
})
|
|
|
|
if (dynamicSummary) {
|
|
if (dynamicSummary.isSpecialExpression) {
|
|
return (
|
|
<span className='text-muted-foreground/50 text-xs'>
|
|
{t('Special billing expression')}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
const cacheEntry = dynamicSummary.entries.find(
|
|
(entry) => entry.field === 'cacheReadPrice'
|
|
)
|
|
if (!cacheEntry) {
|
|
return <span className='text-muted-foreground/30 text-xs'>—</span>
|
|
}
|
|
|
|
return (
|
|
<div className='max-w-full min-w-0'>
|
|
<span className='text-sm tabular-nums'>
|
|
{stripTrailingZeros(cacheEntry.formatted)}
|
|
</span>
|
|
<div className='text-muted-foreground/50 text-xs'>
|
|
/ {tokenUnitLabel}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const isTokenBased = isTokenBasedModel(model)
|
|
|
|
if (!isTokenBased || model.cache_ratio == null) {
|
|
return <span className='text-muted-foreground/30 text-xs'>—</span>
|
|
}
|
|
|
|
const cachedPrice = stripTrailingZeros(
|
|
formatPrice(
|
|
model,
|
|
'cache',
|
|
tokenUnit,
|
|
showRechargePrice,
|
|
priceRate,
|
|
usdExchangeRate,
|
|
selectedGroup
|
|
)
|
|
)
|
|
|
|
return (
|
|
<div className='max-w-full min-w-0'>
|
|
<span className='text-sm tabular-nums'>{cachedPrice}</span>
|
|
<div className='text-muted-foreground/50 text-xs'>
|
|
/ {tokenUnitLabel}
|
|
</div>
|
|
</div>
|
|
)
|
|
},
|
|
size: 110,
|
|
enableSorting: false,
|
|
},
|
|
|
|
// Vendor column
|
|
{
|
|
accessorKey: 'vendor_name',
|
|
header: t('Vendor'),
|
|
cell: ({ row }) => {
|
|
const model = row.original
|
|
if (!model.vendor_name) {
|
|
return <span className='text-muted-foreground/50 text-xs'>—</span>
|
|
}
|
|
const vendorIcon = model.vendor_icon
|
|
? getLobeIcon(model.vendor_icon, 12)
|
|
: null
|
|
return (
|
|
<BadgeCell className='gap-1.5'>
|
|
{vendorIcon}
|
|
<StatusBadge
|
|
label={model.vendor_name}
|
|
autoColor={model.vendor_name}
|
|
size='sm'
|
|
copyable={false}
|
|
/>
|
|
</BadgeCell>
|
|
)
|
|
},
|
|
size: 130,
|
|
enableSorting: false,
|
|
},
|
|
|
|
// Tags column
|
|
{
|
|
accessorKey: 'tags',
|
|
header: t('Tags'),
|
|
cell: ({ row }) => {
|
|
const tags = parseTags(row.original.tags)
|
|
return (
|
|
<BadgeListCell
|
|
items={tags.map((tag) => (
|
|
<StatusBadge
|
|
key={tag}
|
|
label={tag}
|
|
autoColor={tag}
|
|
size='sm'
|
|
copyable={false}
|
|
/>
|
|
))}
|
|
/>
|
|
)
|
|
},
|
|
size: 140,
|
|
enableSorting: false,
|
|
},
|
|
|
|
// Endpoints column
|
|
{
|
|
accessorKey: 'supported_endpoint_types',
|
|
header: t('Endpoints'),
|
|
cell: ({ row }) => {
|
|
const endpoints = row.original.supported_endpoint_types || []
|
|
return (
|
|
<BadgeListCell
|
|
items={endpoints.map((ep) => (
|
|
<StatusBadge
|
|
key={ep}
|
|
label={ep}
|
|
autoColor={ep}
|
|
size='sm'
|
|
copyable={false}
|
|
/>
|
|
))}
|
|
/>
|
|
)
|
|
},
|
|
size: 130,
|
|
enableSorting: false,
|
|
},
|
|
|
|
// Enable Groups column
|
|
{
|
|
accessorKey: 'enable_groups',
|
|
header: t('Groups'),
|
|
cell: ({ row }) => {
|
|
const groups = row.original.enable_groups || []
|
|
return (
|
|
<BadgeListCell
|
|
items={groups.map((group) => (
|
|
<GroupBadge key={group} group={group} size='sm' />
|
|
))}
|
|
tooltipClassName='max-w-[280px] p-2'
|
|
/>
|
|
)
|
|
},
|
|
size: 130,
|
|
enableSorting: false,
|
|
},
|
|
]
|
|
}
|