feat: remove Get Started arrow, redesign pricing table with Model/Group/Billing/PerRequest/Input/CachedInput/Output columns, default to table view
Docker Build / Build and Push Docker Image (push) Successful in 4m13s

This commit is contained in:
2026-06-14 20:42:06 +08:00
parent 6faf989549
commit 7caf77db63
5 changed files with 226 additions and 341 deletions
+2 -5
View File
@@ -20,7 +20,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { Link } from '@tanstack/react-router'
import {
ArrowDown,
ArrowRight,
Check,
Copy,
} from 'lucide-react'
@@ -197,20 +196,18 @@ export function Hero(props: HeroProps) {
<div className='mt-8 flex items-center justify-center gap-3'>
{props.isAuthenticated ? (
<Button
className='group h-10 rounded-lg px-6 text-sm font-semibold'
className='h-10 rounded-lg px-6 text-sm font-semibold'
render={<Link to='/dashboard' />}
>
{t('Dashboard')}
<ArrowRight className='ml-1 size-3.5 transition-transform duration-200 group-hover:translate-x-0.5' />
</Button>
) : (
<>
<Button
className='group h-10 rounded-lg px-6 text-sm font-semibold'
className='h-10 rounded-lg px-6 text-sm font-semibold'
render={<Link to='/sign-up' />}
>
{t('Get Started')}
<ArrowRight className='ml-1 size-3.5 transition-transform duration-200 group-hover:translate-x-0.5' />
</Button>
<Button
variant='outline'
+214 -332
View File
@@ -33,7 +33,6 @@ import {
getDynamicDisplayGroupRatio,
getDynamicPricingSummary,
} from '../lib/dynamic-price'
import { parseTags } from '../lib/filters'
import { isTokenBasedModel } from '../lib/model-helpers'
import {
formatPrice,
@@ -53,22 +52,6 @@ export interface PricingColumnsOptions {
showRechargePrice?: boolean
}
function renderLimitedTags(
items: string[],
maxDisplay: number = 3
): React.ReactNode {
return (
<StatusBadgeList
items={items}
max={maxDisplay}
getKey={(item) => item}
renderItem={(item) => (
<StatusBadge label={item} autoColor={item} size='sm' copyable={false} />
)}
/>
)
}
function renderLimitedGroupBadges(
groups: string[],
maxDisplay: number = 2
@@ -110,7 +93,7 @@ export function usePricingColumns(
const modelIcon = modelIconKey ? getLobeIcon(modelIconKey, 14) : null
return (
<div className='flex min-w-[200px] items-center gap-2'>
<div className='flex items-center gap-2'>
{modelIcon}
<span className='truncate font-mono text-sm font-medium'>
{model.model_name}
@@ -118,323 +101,14 @@ export function usePricingColumns(
</div>
)
},
minSize: 200,
minSize: 180,
},
// Type column
{
accessorKey: 'quota_type',
meta: { label: t('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),
})
if (dynamicSummary) {
if (dynamicSummary.isSpecialExpression) {
return (
<div className='max-w-[320px] min-w-[200px]'>
<div className='text-xs font-medium text-amber-700 dark:text-amber-300'>
{t('Special billing expression')}
</div>
<div className='text-muted-foreground text-[11px]'>
{t('Unable to parse structured pricing')}
</div>
<code className='text-muted-foreground/70 mt-1 line-clamp-2 block font-mono text-[10px] 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='min-w-[180px]'>
<span className='font-mono 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-[10px]'>
/ {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
)
)
const outputPrice = stripTrailingZeros(
formatPrice(
model,
'output',
tokenUnit,
showRechargePrice,
priceRate,
usdExchangeRate
)
)
return (
<div className='min-w-[160px]'>
<span className='font-mono text-sm tabular-nums'>
{inputPrice}
<span className='text-muted-foreground/40 mx-1'>/</span>
{outputPrice}
</span>
<div className='text-muted-foreground/50 text-[10px]'>
/ {tokenUnitLabel} tokens
</div>
</div>
)
}
const price = stripTrailingZeros(
formatRequestPrice(
model,
showRechargePrice,
priceRate,
usdExchangeRate
)
)
return (
<div className='min-w-[100px]'>
<span className='font-mono text-sm tabular-nums'>{price}</span>
<div className='text-muted-foreground/50 text-[10px]'>
/ {t('request')}
</div>
</div>
)
},
size: 180,
enableSorting: false,
},
// Cached price column (Vercel AI Gateway style)
{
id: 'cached_price',
meta: { label: t('Cached') },
header: t('Cached'),
cell: ({ row }) => {
const model = row.original
const dynamicSummary = getDynamicPricingSummary(model, {
tokenUnit,
showRechargePrice,
priceRate,
usdExchangeRate,
groupRatioMultiplier: getDynamicDisplayGroupRatio(model),
})
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='min-w-[80px]'>
<span className='font-mono text-sm tabular-nums'>
{stripTrailingZeros(cacheEntry.formatted)}
</span>
<div className='text-muted-foreground/50 text-[10px]'>
/ {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
)
)
return (
<div className='min-w-[80px]'>
<span className='font-mono text-sm tabular-nums'>
{cachedPrice}
</span>
<div className='text-muted-foreground/50 text-[10px]'>
/ {tokenUnitLabel}
</div>
</div>
)
},
size: 110,
enableSorting: false,
},
// Vendor column
{
accessorKey: 'vendor_name',
meta: { label: t('Vendor') },
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 (
<span className='flex items-center gap-1.5'>
{vendorIcon}
<StatusBadge
label={model.vendor_name}
autoColor={model.vendor_name}
size='sm'
copyable={false}
/>
</span>
)
},
size: 130,
enableSorting: false,
},
// Tags column
{
accessorKey: 'tags',
meta: { label: t('Tags') },
header: t('Tags'),
cell: ({ row }) => {
const tags = parseTags(row.original.tags)
if (tags.length === 0) {
return <span className='text-muted-foreground/50 text-xs'></span>
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={<div />}>
{renderLimitedTags(tags, 2)}
</TooltipTrigger>
{tags.length > 2 && (
<TooltipContent side='top' className='max-w-[280px] p-2'>
<span className='text-xs'>{tags.join(', ')}</span>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)
},
size: 140,
enableSorting: false,
},
// Endpoints column
{
accessorKey: 'supported_endpoint_types',
meta: { label: t('Endpoints') },
header: t('Endpoints'),
cell: ({ row }) => {
const endpoints = row.original.supported_endpoint_types || []
if (endpoints.length === 0) {
return <span className='text-muted-foreground/50 text-xs'></span>
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={<div />}>
{renderLimitedTags(endpoints, 2)}
</TooltipTrigger>
{endpoints.length > 2 && (
<TooltipContent side='top' className='max-w-[280px] p-2'>
<span className='text-xs'>{endpoints.join(', ')}</span>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)
},
size: 130,
enableSorting: false,
},
// Enable Groups column
// Group column
{
accessorKey: 'enable_groups',
meta: { label: t('Groups') },
header: t('Groups'),
meta: { label: t('Group') },
header: t('Group'),
cell: ({ row }) => {
const groups = row.original.enable_groups || []
if (groups.length === 0) {
@@ -460,8 +134,216 @@ export function usePricingColumns(
</TooltipProvider>
)
},
size: 130,
size: 120,
enableSorting: false,
},
// Billing Mode column
{
accessorKey: 'quota_type',
meta: { label: t('Billing Mode') },
header: t('Billing Mode'),
cell: ({ row }) => {
const isTokenBased = row.original.quota_type === QUOTA_TYPE_VALUES.TOKEN
return (
<span className='text-xs font-medium'>
{isTokenBased ? t('By Token') : t('By Request')}
</span>
)
},
size: 90,
enableSorting: false,
},
// Per-request price column
{
id: 'per_request_price',
meta: { label: t('Per Request') },
header: t('Per Request'),
cell: ({ row }) => {
const model = row.original
const isTokenBased = isTokenBasedModel(model)
if (isTokenBased) {
return <span className='text-muted-foreground/30 text-xs'></span>
}
const price = stripTrailingZeros(
formatRequestPrice(
model,
showRechargePrice,
priceRate,
usdExchangeRate
)
)
return (
<span className='font-mono text-sm tabular-nums'>{price}</span>
)
},
size: 90,
enableSorting: false,
},
// Input price column
{
id: 'input_price',
meta: { label: t('Input') },
header: ({ column }) => (
<DataTableColumnHeader column={column} title={`${t('Input')} / ${tokenUnitLabel}`} />
),
cell: ({ row }) => {
const model = row.original
const isTokenBased = isTokenBasedModel(model)
if (!isTokenBased) {
return <span className='text-muted-foreground/30 text-xs'></span>
}
const dynamicSummary = getDynamicPricingSummary(model, {
tokenUnit,
showRechargePrice,
priceRate,
usdExchangeRate,
groupRatioMultiplier: getDynamicDisplayGroupRatio(model),
})
if (dynamicSummary && !dynamicSummary.isSpecialExpression) {
const inputEntry = dynamicSummary.entries.find(
(entry) => entry.field === 'inputPrice'
)
if (inputEntry) {
return (
<span className='font-mono text-sm tabular-nums'>
{stripTrailingZeros(inputEntry.formatted)}
</span>
)
}
}
const inputPrice = stripTrailingZeros(
formatPrice(
model,
'input',
tokenUnit,
showRechargePrice,
priceRate,
usdExchangeRate
)
)
return (
<span className='font-mono text-sm tabular-nums'>{inputPrice}</span>
)
},
size: 100,
},
// Cached input price column
{
id: 'cached_input_price',
meta: { label: t('Cached Input') },
header: t('Cached Input'),
cell: ({ row }) => {
const model = row.original
const isTokenBased = isTokenBasedModel(model)
if (!isTokenBased || model.cache_ratio == null) {
return <span className='text-muted-foreground/30 text-xs'></span>
}
const dynamicSummary = getDynamicPricingSummary(model, {
tokenUnit,
showRechargePrice,
priceRate,
usdExchangeRate,
groupRatioMultiplier: getDynamicDisplayGroupRatio(model),
})
if (dynamicSummary && !dynamicSummary.isSpecialExpression) {
const cacheEntry = dynamicSummary.entries.find(
(entry) => entry.field === 'cacheReadPrice'
)
if (cacheEntry) {
return (
<span className='font-mono text-sm tabular-nums'>
{stripTrailingZeros(cacheEntry.formatted)}
</span>
)
}
}
const cachedPrice = stripTrailingZeros(
formatPrice(
model,
'cache',
tokenUnit,
showRechargePrice,
priceRate,
usdExchangeRate
)
)
return (
<span className='font-mono text-sm tabular-nums'>{cachedPrice}</span>
)
},
size: 100,
enableSorting: false,
},
// Output price column
{
id: 'output_price',
meta: { label: t('Output') },
header: ({ column }) => (
<DataTableColumnHeader column={column} title={`${t('Output')} / ${tokenUnitLabel}`} />
),
cell: ({ row }) => {
const model = row.original
const isTokenBased = isTokenBasedModel(model)
if (!isTokenBased) {
return <span className='text-muted-foreground/30 text-xs'></span>
}
const dynamicSummary = getDynamicPricingSummary(model, {
tokenUnit,
showRechargePrice,
priceRate,
usdExchangeRate,
groupRatioMultiplier: getDynamicDisplayGroupRatio(model),
})
if (dynamicSummary && !dynamicSummary.isSpecialExpression) {
const outputEntry = dynamicSummary.entries.find(
(entry) => entry.field === 'outputPrice'
)
if (outputEntry) {
return (
<span className='font-mono text-sm tabular-nums'>
{stripTrailingZeros(outputEntry.formatted)}
</span>
)
}
}
const outputPrice = stripTrailingZeros(
formatPrice(
model,
'output',
tokenUnit,
showRechargePrice,
priceRate,
usdExchangeRate
)
)
return (
<span className='font-mono text-sm tabular-nums'>{outputPrice}</span>
)
},
size: 100,
},
]
}
+4 -4
View File
@@ -44,10 +44,10 @@ type FilterState = {
}
function normalizeViewMode(value: unknown): ViewMode {
if (value === VIEW_MODES.TABLE) {
return VIEW_MODES.TABLE
if (value === VIEW_MODES.CARD) {
return VIEW_MODES.CARD
}
return VIEW_MODES.CARD
return VIEW_MODES.TABLE
}
export function useFilters(models: PricingModel[]) {
@@ -129,7 +129,7 @@ export function useFilters(models: PricingModel[]) {
)
const setViewMode = useCallback(
(v: ViewMode) =>
updateFilters({ view: v === VIEW_MODES.CARD ? undefined : v }),
updateFilters({ view: v === VIEW_MODES.TABLE ? undefined : v }),
[updateFilters]
)
const setShowRechargePrice = useCallback(
+3
View File
@@ -538,6 +538,8 @@
"Billing Details": "Billing Details",
"Billing History": "Billing History",
"Billing Mode": "Billing Mode",
"By Token": "By Token",
"By Request": "By Request",
"Billing Process": "Billing Process",
"Billing Source": "Billing Source",
"Bind": "Bind",
@@ -608,6 +610,7 @@
"Cache Write (5m)": "Cache Write (5m)",
"Cache write price": "Cache write price",
"Cached": "Cached",
"Cached Input": "Cached Input",
"Cached input": "Cached input",
"Calculated price: ${{price}} per 1M tokens": "Calculated price: ${{price}} per 1M tokens",
"Calculated ratio: {{ratio}}": "Calculated ratio: {{ratio}}",
+3
View File
@@ -538,6 +538,8 @@
"Billing Details": "计费详情",
"Billing History": "计费历史",
"Billing Mode": "计费模式",
"By Token": "按 Token",
"By Request": "按次",
"Billing Process": "计费过程",
"Billing Source": "计费来源",
"Bind": "绑定",
@@ -608,6 +610,7 @@
"Cache Write (5m)": "缓存写入 (5m)",
"Cache write price": "缓存写入价格",
"Cached": "缓存",
"Cached Input": "缓存输入",
"Cached input": "缓存输入",
"Calculated price: ${{price}} per 1M tokens": "计算价格:${{price}} / 1M tokens",
"Calculated ratio: {{ratio}}": "计算倍率:{{ratio}}",