style: optimize UI

This commit is contained in:
CaIon
2026-04-28 15:56:09 +08:00
parent df14a0bf18
commit fc377dae3e
5 changed files with 137 additions and 341 deletions
@@ -176,7 +176,7 @@ export function DynamicPricingBreakdown({
if (!hasTiers && !hasRules) { if (!hasTiers && !hasRules) {
return ( return (
<section className='py-4'> <section className='min-w-0 py-4'>
<div className='mb-3 flex items-center gap-2'> <div className='mb-3 flex items-center gap-2'>
<span className='inline-flex size-6 items-center justify-center rounded-full bg-amber-100 text-amber-700 shadow-sm dark:bg-amber-500/20 dark:text-amber-300'> <span className='inline-flex size-6 items-center justify-center rounded-full bg-amber-100 text-amber-700 shadow-sm dark:bg-amber-500/20 dark:text-amber-300'>
<TagIcon className='size-3.5' /> <TagIcon className='size-3.5' />
@@ -201,7 +201,7 @@ export function DynamicPricingBreakdown({
}) })
return ( return (
<section className='py-4'> <section className='min-w-0 py-4'>
<div className='mb-4 flex items-start gap-2'> <div className='mb-4 flex items-start gap-2'>
<span className='mt-0.5 inline-flex size-6 items-center justify-center rounded-full bg-amber-100 text-amber-700 shadow-sm dark:bg-amber-500/20 dark:text-amber-300'> <span className='mt-0.5 inline-flex size-6 items-center justify-center rounded-full bg-amber-100 text-amber-700 shadow-sm dark:bg-amber-500/20 dark:text-amber-300'>
<TagIcon className='size-3.5' /> <TagIcon className='size-3.5' />
@@ -221,7 +221,7 @@ export function DynamicPricingBreakdown({
<div className='text-foreground mb-2 text-sm font-semibold'> <div className='text-foreground mb-2 text-sm font-semibold'>
{t('Tiered price table')} {t('Tiered price table')}
</div> </div>
<div className='-mx-4 sm:mx-0'> <div className='-mx-4 max-w-[calc(100%+2rem)] overflow-x-auto sm:mx-0 sm:max-w-full'>
<Table className='text-sm'> <Table className='text-sm'>
<TableHeader> <TableHeader>
<TableRow className='hover:bg-transparent'> <TableRow className='hover:bg-transparent'>
@@ -1,234 +0,0 @@
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { ChevronDown, ChevronUp, Copy, Settings } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { api } from '@/lib/api'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { StatusBadge } from '@/components/status-badge'
const MODEL_CATEGORIES = [
{ key: 'all', label: 'All', filter: () => true },
{
key: 'gpt',
label: 'GPT',
filter: (m: string) => /^(gpt|o[0-9]|chatgpt)/i.test(m),
},
{ key: 'claude', label: 'Claude', filter: (m: string) => /claude/i.test(m) },
{
key: 'gemini',
label: 'Gemini',
filter: (m: string) => /gemini|gemma/i.test(m),
},
{ key: 'llama', label: 'Llama', filter: (m: string) => /llama/i.test(m) },
{
key: 'mistral',
label: 'Mistral',
filter: (m: string) => /mistral|mixtral/i.test(m),
},
{
key: 'deepseek',
label: 'DeepSeek',
filter: (m: string) => /deepseek/i.test(m),
},
{ key: 'qwen', label: 'Qwen', filter: (m: string) => /qwen/i.test(m) },
{
key: 'embedding',
label: 'Embedding',
filter: (m: string) => /embed/i.test(m),
},
{
key: 'image',
label: 'Image',
filter: (m: string) =>
/dall-e|stable-diffusion|midjourney|sd[x3]|flux|imagen/i.test(m),
},
{
key: 'tts',
label: 'TTS',
filter: (m: string) => /tts|whisper|speech/i.test(m),
},
] as const
const MODELS_DISPLAY_COUNT = 25
export function AvailableModelsCard() {
const { t } = useTranslation()
const [activeCategory, setActiveCategory] = useState('all')
const [isExpanded, setIsExpanded] = useState(() => {
try {
return JSON.parse(localStorage.getItem('modelsExpanded') ?? 'false')
} catch {
return false
}
})
const { data: models = [], isLoading } = useQuery({
queryKey: ['user-available-models'],
queryFn: async () => {
const res = await api.get('/api/user/models')
if (!res.data.success || !Array.isArray(res.data.data)) return []
return res.data.data as string[]
},
staleTime: 5 * 60 * 1000,
})
const toggleExpand = (val: boolean) => {
setIsExpanded(val)
localStorage.setItem('modelsExpanded', JSON.stringify(val))
}
const copyModel = (model: string) => {
navigator.clipboard.writeText(model)
toast.success(t('Copied: {{model}}', { model }))
}
const categoriesWithCounts = useMemo(
() =>
MODEL_CATEGORIES.map((cat) => ({
...cat,
count:
cat.key === 'all' ? models.length : models.filter(cat.filter).length,
})).filter((cat) => cat.key === 'all' || cat.count > 0),
[models]
)
const filteredModels = useMemo(() => {
const cat = MODEL_CATEGORIES.find((c) => c.key === activeCategory)
if (!cat || cat.key === 'all') return models
return models.filter(cat.filter)
}, [models, activeCategory])
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle className='flex items-center gap-2'>
<Settings className='h-4 w-4' />
{t('Available Models')}
</CardTitle>
<CardDescription>
{t('View all currently available models')}
</CardDescription>
</CardHeader>
<CardContent>
<div className='flex flex-wrap gap-2'>
{Array.from({ length: 12 }).map((_, i) => (
<Skeleton key={i} className='h-7 w-24 rounded-full' />
))}
</div>
</CardContent>
</Card>
)
}
if (models.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className='flex items-center gap-2'>
<Settings className='h-4 w-4' />
{t('Available Models')}
</CardTitle>
</CardHeader>
<CardContent>
<p className='text-muted-foreground text-sm'>
{t('No available models')}
</p>
</CardContent>
</Card>
)
}
const needsExpand = filteredModels.length > MODELS_DISPLAY_COUNT
const displayModels =
needsExpand && !isExpanded
? filteredModels.slice(0, MODELS_DISPLAY_COUNT)
: filteredModels
return (
<Card>
<CardHeader>
<CardTitle className='flex items-center gap-2'>
<Settings className='h-4 w-4' />
{t('Available Models')}
</CardTitle>
<CardDescription>
{t('View all currently available models')} · {models.length}{' '}
{t('models')}
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<Tabs value={activeCategory} onValueChange={setActiveCategory}>
<TabsList className='h-auto flex-wrap'>
{categoriesWithCounts.map((cat) => (
<TabsTrigger key={cat.key} value={cat.key} className='text-xs'>
{cat.label}
<StatusBadge
label={String(cat.count)}
variant={activeCategory === cat.key ? 'info' : 'neutral'}
className='ml-1'
copyable={false}
/>
</TabsTrigger>
))}
</TabsList>
</Tabs>
<div className='flex flex-wrap gap-1.5'>
{displayModels.map((model) => (
<StatusBadge
key={model}
variant='neutral'
className='cursor-pointer font-normal transition-opacity hover:opacity-70'
onClick={() => copyModel(model)}
copyable={false}
>
<Copy className='h-2.5 w-2.5 opacity-50' />
{model}
</StatusBadge>
))}
{needsExpand && !isExpanded && (
<Button
variant='ghost'
size='sm'
className='h-6 gap-1 text-xs'
onClick={() => toggleExpand(true)}
>
<ChevronDown className='h-3 w-3' />
{t('More')} {filteredModels.length - MODELS_DISPLAY_COUNT}{' '}
{t('models')}
</Button>
)}
{needsExpand && isExpanded && (
<Button
variant='ghost'
size='sm'
className='h-6 gap-1 text-xs'
onClick={() => toggleExpand(false)}
>
<ChevronUp className='h-3 w-3' />
{t('Collapse')}
</Button>
)}
</div>
{filteredModels.length === 0 && (
<p className='text-muted-foreground py-4 text-center text-sm'>
{t('No models available in this category')}
</p>
)}
</CardContent>
</Card>
)
}
-5
View File
@@ -5,7 +5,6 @@ import {
CardStaggerContainer, CardStaggerContainer,
CardStaggerItem, CardStaggerItem,
} from '@/components/page-transition' } from '@/components/page-transition'
import { AvailableModelsCard } from './components/available-models-card'
import { CheckinCalendarCard } from './components/checkin-calendar-card' import { CheckinCalendarCard } from './components/checkin-calendar-card'
import { PasskeyCard } from './components/passkey-card' import { PasskeyCard } from './components/passkey-card'
import { ProfileHeader } from './components/profile-header' import { ProfileHeader } from './components/profile-header'
@@ -37,10 +36,6 @@ export function Profile() {
<ProfileHeader profile={profile} loading={loading} /> <ProfileHeader profile={profile} loading={loading} />
</CardStaggerItem> </CardStaggerItem>
<CardStaggerItem>
<AvailableModelsCard />
</CardStaggerItem>
<CardStaggerItem> <CardStaggerItem>
<div className='grid gap-6 lg:grid-cols-2 lg:items-start'> <div className='grid gap-6 lg:grid-cols-2 lg:items-start'>
<div className='space-y-6'> <div className='space-y-6'>
@@ -33,6 +33,7 @@ import {
getTimeColor, getTimeColor,
formatModelName, formatModelName,
getTieredBillingSummary, getTieredBillingSummary,
hasAnyCacheTokens,
parseLogOther, parseLogOther,
isViolationFeeLog, isViolationFeeLog,
} from '../../lib/format' } from '../../lib/format'
@@ -45,7 +46,6 @@ import {
import type { LogOtherData } from '../../types' import type { LogOtherData } from '../../types'
import { DetailsDialog } from '../dialogs/details-dialog' import { DetailsDialog } from '../dialogs/details-dialog'
import { useUsageLogsContext } from '../usage-logs-provider' import { useUsageLogsContext } from '../usage-logs-provider'
import { CacheTooltip } from './column-helpers'
interface DetailSegment { interface DetailSegment {
text: string text: string
@@ -90,33 +90,60 @@ function buildDetailSegments(
const segments: DetailSegment[] = [] const segments: DetailSegment[] = []
const userGroupRatio = other.user_group_ratio
const groupRatio = other.group_ratio
const isUserGroup =
userGroupRatio != null &&
Number.isFinite(userGroupRatio) &&
userGroupRatio !== -1
const effectiveRatio = isUserGroup ? userGroupRatio : groupRatio
const ratioLabel = isUserGroup ? t('User Exclusive Ratio') : t('Group Ratio')
if (effectiveRatio != null && Number.isFinite(effectiveRatio)) {
segments.push({
text: `${ratioLabel} ${formatRatioCompact(effectiveRatio)}x`,
})
}
const priceOpts = { digitsLarge: 4, digitsSmall: 6, abbreviate: false } const priceOpts = { digitsLarge: 4, digitsSmall: 6, abbreviate: false }
const formatPrice = (price: number) =>
`${formatBillingCurrencyFromUSD(price, priceOpts)}/M`
const formatPriceCompact = (price: number) =>
formatBillingCurrencyFromUSD(price, priceOpts)
const formatPriceList = (prices: string[], showUnit: boolean) => {
const text = prices.join(' / ')
return showUnit ? `${text}/M` : text
}
const tieredSummary = getTieredBillingSummary(other) const tieredSummary = getTieredBillingSummary(other)
if (tieredSummary) { if (tieredSummary) {
if (tieredSummary.tier.label) { const baseEntries = tieredSummary.priceEntries
.filter((entry) => ['inputPrice', 'outputPrice'].includes(entry.field))
.map((entry) => formatPriceCompact(entry.price))
if (baseEntries.length > 0) {
const tierLabel = tieredSummary.tier.label || t('Default')
segments.push({ segments.push({
text: `${t('Tier')} ${tieredSummary.tier.label}`, text: `${tierLabel} · ${formatPriceList(baseEntries, true)}`,
})
}
const cacheEntries = tieredSummary.priceEntries
.filter((entry) =>
[
'cacheReadPrice',
'cacheCreatePrice',
'cacheCreate1hPrice',
].includes(entry.field)
)
.map((entry) => {
return formatPriceCompact(entry.price)
})
if (cacheEntries.length > 0) {
segments.push({
text: `${t('Cache')} ${formatPriceList(cacheEntries, false)}`,
muted: true, muted: true,
}) })
} }
for (const entry of tieredSummary.priceEntries) {
const otherEntries = tieredSummary.priceEntries
.filter(
(entry) =>
![
'inputPrice',
'outputPrice',
'cacheReadPrice',
'cacheCreatePrice',
'cacheCreate1hPrice',
].includes(entry.field)
)
.map((entry) => `${t(entry.shortLabel)} ${formatPrice(entry.price)}`)
if (otherEntries.length > 0) {
segments.push({ segments.push({
text: `${t(entry.shortLabel)} ${formatBillingCurrencyFromUSD(entry.price, priceOpts)}/M`, text: otherEntries.join(' · '),
muted: true, muted: true,
}) })
} }
@@ -124,15 +151,59 @@ function buildDetailSegments(
const isPerCall = isPerCallBilling(other.model_price) const isPerCall = isPerCallBilling(other.model_price)
if (isPerCall) { if (isPerCall) {
segments.push({ segments.push({
text: `${t('Model Price')} ${formatBillingCurrencyFromUSD(other.model_price!, priceOpts)}`, text: `${t('Per-call')} · ${formatBillingCurrencyFromUSD(other.model_price!, priceOpts)}`,
muted: true,
}) })
} else if (other.model_ratio != null) { } else if (other.model_ratio != null) {
const inputPriceUSD = other.model_ratio * 2.0 const inputPriceUSD = other.model_ratio * 2.0
const baseEntries = [formatPriceCompact(inputPriceUSD)]
if (other.completion_ratio != null) {
baseEntries.push(
formatPriceCompact(inputPriceUSD * other.completion_ratio)
)
}
segments.push({ segments.push({
text: `${t('Input')} ${formatBillingCurrencyFromUSD(inputPriceUSD, priceOpts)}/M`, text: `${t('Standard')} · ${formatPriceList(baseEntries, true)}`,
muted: true,
}) })
if (hasAnyCacheTokens(other)) {
const cacheEntries = [
other.cache_ratio != null && other.cache_ratio !== 1
? formatPriceCompact(inputPriceUSD * other.cache_ratio)
: null,
other.cache_creation_ratio != null &&
other.cache_creation_ratio !== 1
? formatPriceCompact(inputPriceUSD * other.cache_creation_ratio)
: null,
other.cache_creation_ratio_1h != null &&
other.cache_creation_ratio_1h !== 0
? formatPriceCompact(inputPriceUSD * other.cache_creation_ratio_1h)
: null,
].filter(Boolean) as string[]
if (cacheEntries.length > 0) {
segments.push({
text: `${t('Cache')} ${formatPriceList(cacheEntries, false)}`,
muted: true,
})
}
}
} else {
const userGroupRatio = other.user_group_ratio
const groupRatio = other.group_ratio
const isUserGroup =
userGroupRatio != null &&
Number.isFinite(userGroupRatio) &&
userGroupRatio !== -1
const effectiveRatio = isUserGroup ? userGroupRatio : groupRatio
const ratioLabel = isUserGroup
? t('User Exclusive Ratio')
: t('Group Ratio')
if (effectiveRatio != null && Number.isFinite(effectiveRatio)) {
segments.push({
text: `${ratioLabel} ${formatRatioCompact(effectiveRatio)}x`,
})
}
} }
} }
@@ -482,7 +553,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
{ {
accessorKey: 'prompt_tokens', accessorKey: 'prompt_tokens',
header: ({ column }) => ( header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Input')} /> <DataTableColumnHeader column={column} title='Tokens' />
), ),
cell: ({ row }) => { cell: ({ row }) => {
const log = row.original const log = row.original
@@ -494,84 +565,41 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
} }
const promptTokens = log.prompt_tokens || 0 const promptTokens = log.prompt_tokens || 0
if (promptTokens === 0) { const completionTokens = log.completion_tokens || 0
if (promptTokens === 0 && completionTokens === 0) {
return <span className='text-muted-foreground text-xs'>-</span> return <span className='text-muted-foreground text-xs'>-</span>
} }
const cacheReadTokens = other?.cache_tokens || 0 const cacheReadTokens = other?.cache_tokens || 0
return (
<div className='flex flex-col gap-0.5'>
<span className='font-mono text-xs font-medium'>
{promptTokens.toLocaleString()}
</span>
{cacheReadTokens > 0 && (
<span className='flex items-center gap-1 text-[11px]'>
<CacheTooltip
tokens={cacheReadTokens}
label={t('Cache Read')}
color='fill-amber-500 text-amber-500'
/>
<span className='text-muted-foreground/60'>
{t('Cache Read')} {cacheReadTokens.toLocaleString()}
</span>
</span>
)}
</div>
)
},
meta: { label: t('Input'), mobileHidden: true },
},
{
accessorKey: 'completion_tokens',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Output')} />
),
cell: ({ row }) => {
const log = row.original
if (!isDisplayableLogType(log.type)) return null
const other = parseLogOther(log.other)
if (isPerCallBilling(other?.model_price)) {
return <span className='text-muted-foreground text-xs'>-</span>
}
const completionTokens = log.completion_tokens || 0
if (completionTokens === 0) {
return <span className='text-muted-foreground text-xs'>-</span>
}
const cacheWrite5m = other?.cache_creation_tokens_5m || 0 const cacheWrite5m = other?.cache_creation_tokens_5m || 0
const cacheWrite1h = other?.cache_creation_tokens_1h || 0 const cacheWrite1h = other?.cache_creation_tokens_1h || 0
const hasSplitCache = cacheWrite5m > 0 || cacheWrite1h > 0 const hasSplitCache = cacheWrite5m > 0 || cacheWrite1h > 0
const cacheWriteTokens = hasSplitCache const cacheWriteTokens = hasSplitCache
? cacheWrite5m + cacheWrite1h ? cacheWrite5m + cacheWrite1h
: other?.cache_creation_tokens || 0 : other?.cache_creation_tokens || 0
const cacheSegments = [
cacheReadTokens > 0
? `${t('Cache')}${cacheReadTokens.toLocaleString()}`
: null,
cacheWriteTokens > 0
? `${cacheWriteTokens.toLocaleString()}`
: null,
].filter(Boolean)
return ( return (
<div className='flex flex-col gap-0.5'> <div className='flex flex-col gap-0.5'>
<span className='font-mono text-xs font-medium'> <span className='font-mono text-xs font-medium'>
{completionTokens.toLocaleString()} {promptTokens.toLocaleString()} / {completionTokens.toLocaleString()}
</span> </span>
{cacheWriteTokens > 0 && ( {cacheSegments.length > 0 && (
<span className='flex items-center gap-1 text-[11px]'> <span className='text-muted-foreground/60 text-[11px]'>
<CacheTooltip {cacheSegments.join(' · ')}
tokens={cacheWriteTokens}
label={t('Cache Write')}
color='fill-blue-500 text-blue-500'
/>
<span className='text-muted-foreground/60'>
{hasSplitCache
? `${t('Cache Write')} ${cacheWrite5m.toLocaleString()}/${cacheWrite1h.toLocaleString()}`
: `${t('Cache Write')} ${cacheWriteTokens.toLocaleString()}`}
</span>
</span> </span>
)} )}
</div> </div>
) )
}, },
meta: { label: t('Output'), mobileHidden: true }, meta: { label: 'Tokens', mobileHidden: true },
}, },
{ {
@@ -175,7 +175,7 @@ function BillingBreakdown(props: {
}) })
} }
if (!tieredSummary && isClaude) { if (!tieredSummary && isClaude && hasAnyCacheTokens(other)) {
if (other.cache_ratio != null && other.cache_ratio !== 1) { if (other.cache_ratio != null && other.cache_ratio !== 1) {
rows.push({ rows.push({
label: t('Cache Read'), label: t('Cache Read'),
@@ -376,6 +376,11 @@ export function DetailsDialog(props: DetailsDialogProps) {
const isTopup = props.log.type === 1 const isTopup = props.log.type === 1
const isManage = props.log.type === 3 const isManage = props.log.type === 3
const isSubscription = other?.billing_source === 'subscription' const isSubscription = other?.billing_source === 'subscription'
const isTieredBilling =
isConsume &&
!isViolation &&
other?.billing_mode === 'tiered_expr' &&
!!other?.expr_b64
const hasAudioTokens = other?.ws || other?.audio const hasAudioTokens = other?.ws || other?.audio
const showTiming = isTimingLogType(props.log.type) const showTiming = isTimingLogType(props.log.type)
const showAdminIp = const showAdminIp =
@@ -446,7 +451,12 @@ export function DetailsDialog(props: DetailsDialogProps) {
return ( return (
<Dialog open={props.open} onOpenChange={props.onOpenChange}> <Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent className='sm:max-w-lg'> <DialogContent
className={cn(
'min-w-0',
isTieredBilling ? 'sm:max-w-4xl lg:max-w-5xl' : 'sm:max-w-lg'
)}
>
<DialogHeader> <DialogHeader>
<DialogTitle className='flex items-center gap-2 text-base'> <DialogTitle className='flex items-center gap-2 text-base'>
{t('Log Details')} {t('Log Details')}
@@ -462,8 +472,8 @@ export function DetailsDialog(props: DetailsDialogProps) {
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<ScrollArea className='max-h-[70vh] pr-4'> <ScrollArea className='max-h-[70vh] min-w-0 pr-4'>
<div className='space-y-3 py-1'> <div className='min-w-0 space-y-3 py-1'>
{/* Overview section - key identifiers */} {/* Overview section - key identifiers */}
<div className='space-y-1.5'> <div className='space-y-1.5'>
{props.log.request_id && ( {props.log.request_id && (
@@ -797,18 +807,15 @@ export function DetailsDialog(props: DetailsDialogProps) {
)} )}
{/* Tiered pricing breakdown (when billing_mode is tiered_expr) */} {/* Tiered pricing breakdown (when billing_mode is tiered_expr) */}
{isConsume && {isTieredBilling && other?.expr_b64 && (
!isViolation && <div className='bg-muted/30 min-w-0 rounded-md border px-3'>
other?.billing_mode === 'tiered_expr' && <DynamicPricingBreakdown
other?.expr_b64 && ( billingExpr={decodeBillingExprB64(other.expr_b64)}
<div className='bg-muted/30 rounded-md border px-3'> matchedTierLabel={other.matched_tier}
<DynamicPricingBreakdown hideCacheColumns={!hasAnyCacheTokens(other)}
billingExpr={decodeBillingExprB64(other.expr_b64)} />
matchedTierLabel={other.matched_tier} </div>
hideCacheColumns={!hasAnyCacheTokens(other)} )}
/>
</div>
)}
{/* Admin billing mode indicator for non-consume */} {/* Admin billing mode indicator for non-consume */}
{props.isAdmin && {props.isAdmin &&