feat: enhance UI and functionality in various components

This commit is contained in:
CaIon 2026-04-28 18:38:02 +08:00
parent fc377dae3e
commit 28f7e9eb2e
No known key found for this signature in database
GPG Key ID: 0CFA613529A9921D
40 changed files with 1567 additions and 470 deletions

2
.gitignore vendored
View File

@ -10,6 +10,8 @@ build
logs logs
web/default/dist web/default/dist
web/classic/dist web/classic/dist
web/node_modules
web/dist
.env .env
one-api one-api
new-api new-api

View File

@ -14,6 +14,7 @@ import {
EmptyTitle, EmptyTitle,
} from '@/components/ui/empty' } from '@/components/ui/empty'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/utils'
interface MobileCardListProps<TData> { interface MobileCardListProps<TData> {
table: Table<TData> table: Table<TData>
@ -21,6 +22,7 @@ interface MobileCardListProps<TData> {
emptyTitle?: string emptyTitle?: string
emptyDescription?: string emptyDescription?: string
getRowKey?: (row: Row<TData>) => string | number getRowKey?: (row: Row<TData>) => string | number
getRowClassName?: (row: Row<TData>) => string | undefined
} }
interface MobileColumnMeta { interface MobileColumnMeta {
@ -238,6 +240,7 @@ export function MobileCardList<TData>(props: MobileCardListProps<TData>) {
emptyTitle, emptyTitle,
emptyDescription, emptyDescription,
getRowKey, getRowKey,
getRowClassName,
} = props } = props
const { t } = useTranslation() const { t } = useTranslation()
@ -278,7 +281,10 @@ export function MobileCardList<TData>(props: MobileCardListProps<TData>) {
{rows.map((row) => { {rows.map((row) => {
const key = getRowKey ? getRowKey(row) : row.id const key = getRowKey ? getRowKey(row) : row.id
return ( return (
<div key={key} className='bg-card px-3 py-2.5'> <div
key={key}
className={cn('bg-card px-3 py-2.5', getRowClassName?.(row))}
>
<RowComponent row={row} /> <RowComponent row={row} />
</div> </div>
) )

View File

@ -120,70 +120,87 @@ export function WorkspaceSwitcher({
return null return null
} }
const canSwitchWorkspace = availableWorkspaces.length > 1
const workspaceButtonContent = (
<>
{activeWorkspace.id === WORKSPACE_IDS.SYSTEM_SETTINGS ? (
<div className='bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg'>
<activeWorkspace.logo className='size-4' />
</div>
) : (
<div className='flex aspect-square size-8 items-center justify-center overflow-hidden rounded-lg'>
<img
src={logo}
alt={t('Logo')}
className='size-full rounded-lg object-cover'
/>
</div>
)}
<div className='grid flex-1 text-start text-sm leading-tight group-data-[collapsible=icon]:hidden'>
<span className='truncate font-semibold'>{activeWorkspace.name}</span>
<span className='truncate text-xs'>{activeWorkspace.plan}</span>
</div>
{canSwitchWorkspace && (
<ChevronsUpDown className='ms-auto group-data-[collapsible=icon]:hidden' />
)}
</>
)
return ( return (
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
<DropdownMenu> {canSwitchWorkspace ? (
<DropdownMenuTrigger asChild> <DropdownMenu>
<SidebarMenuButton <DropdownMenuTrigger asChild>
size='lg' <SidebarMenuButton
className='data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground' size='lg'
> className='data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground'
{activeWorkspace.id === WORKSPACE_IDS.SYSTEM_SETTINGS ? (
<div className='bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg'>
<activeWorkspace.logo className='size-4' />
</div>
) : (
<div className='flex aspect-square size-8 items-center justify-center overflow-hidden rounded-lg'>
<img
src={logo}
alt={t('Logo')}
className='size-full rounded-lg object-cover'
/>
</div>
)}
<div className='grid flex-1 text-start text-sm leading-tight group-data-[collapsible=icon]:hidden'>
<span className='truncate font-semibold'>
{activeWorkspace.name}
</span>
<span className='truncate text-xs'>{activeWorkspace.plan}</span>
</div>
<ChevronsUpDown className='ms-auto group-data-[collapsible=icon]:hidden' />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className='w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg'
align='start'
side={isMobile ? 'bottom' : 'right'}
sideOffset={4}
>
<DropdownMenuLabel className='text-muted-foreground text-xs'>
{t('Workspaces')}
</DropdownMenuLabel>
{availableWorkspaces.map((workspace, index) => (
<DropdownMenuItem
key={workspace.id}
onClick={() => handleWorkspaceChange(workspace)}
className='gap-2 p-2'
> >
{index === 0 ? ( {workspaceButtonContent}
<div className='flex size-6 items-center justify-center overflow-hidden rounded-sm border'> </SidebarMenuButton>
<img </DropdownMenuTrigger>
src={logo} <DropdownMenuContent
alt='Logo' className='w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg'
className='size-full object-cover' align='start'
/> side={isMobile ? 'bottom' : 'right'}
</div> sideOffset={4}
) : ( >
<div className='flex size-6 items-center justify-center rounded-sm border'> <DropdownMenuLabel className='text-muted-foreground text-xs'>
<workspace.logo className='size-4 shrink-0' /> {t('Workspaces')}
</div> </DropdownMenuLabel>
)} {availableWorkspaces.map((workspace, index) => (
{workspace.name} <DropdownMenuItem
</DropdownMenuItem> key={workspace.id}
))} onClick={() => handleWorkspaceChange(workspace)}
</DropdownMenuContent> className='gap-2 p-2'
</DropdownMenu> >
{index === 0 ? (
<div className='flex size-6 items-center justify-center overflow-hidden rounded-sm border'>
<img
src={logo}
alt='Logo'
className='size-full object-cover'
/>
</div>
) : (
<div className='flex size-6 items-center justify-center rounded-sm border'>
<workspace.logo className='size-4 shrink-0' />
</div>
)}
{workspace.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : (
<SidebarMenuButton
asChild
size='lg'
className='cursor-default hover:bg-transparent hover:text-sidebar-foreground active:bg-transparent active:text-sidebar-foreground'
>
<div>{workspaceButtonContent}</div>
</SidebarMenuButton>
)}
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
) )

View File

@ -35,6 +35,7 @@ import { PageFooterPortal } from '@/components/layout'
import { getChannels, searchChannels, getGroups } from '../api' import { getChannels, searchChannels, getGroups } from '../api'
import { import {
DEFAULT_PAGE_SIZE, DEFAULT_PAGE_SIZE,
CHANNEL_STATUS,
CHANNEL_STATUS_OPTIONS, CHANNEL_STATUS_OPTIONS,
CHANNEL_TYPE_OPTIONS, CHANNEL_TYPE_OPTIONS,
} from '../constants' } from '../constants'
@ -50,6 +51,10 @@ import { DataTableBulkActions } from './data-table-bulk-actions'
const route = getRouteApi('/_authenticated/channels/') const route = getRouteApi('/_authenticated/channels/')
function isDisabledChannelRow(channel: Channel) {
return !isTagAggregateRow(channel) && channel.status !== CHANNEL_STATUS.ENABLED
}
export function ChannelsTable() { export function ChannelsTable() {
const { t } = useTranslation() const { t } = useTranslation()
const { enableTagMode, idSort } = useChannels() const { enableTagMode, idSort } = useChannels()
@ -318,6 +323,11 @@ export function ChannelsTable() {
isLoading={isLoading} isLoading={isLoading}
emptyTitle='No Channels Found' emptyTitle='No Channels Found'
emptyDescription='No channels available. Create your first channel to get started.' emptyDescription='No channels available. Create your first channel to get started.'
getRowClassName={(row) =>
isDisabledChannelRow(row.original)
? 'border-l-4 border-l-muted-foreground/35 bg-muted/85 dark:border-l-zinc-300/70 dark:bg-zinc-700/55'
: undefined
}
/> />
) : ( ) : (
<> <>
@ -363,6 +373,10 @@ export function ChannelsTable() {
<TableRow <TableRow
key={row.id} key={row.id}
data-state={row.getIsSelected() && 'selected'} data-state={row.getIsSelected() && 'selected'}
className={cn(
isDisabledChannelRow(row.original) &&
'bg-muted/85 hover:bg-muted dark:bg-zinc-700/55 dark:hover:bg-zinc-700/70 [&>td:first-child]:border-l-4 [&>td:first-child]:border-l-muted-foreground/35 [&>td:first-child]:pl-1 dark:[&>td:first-child]:border-l-zinc-300/70'
)}
> >
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}> <TableCell key={cell.id}>

View File

@ -10,14 +10,17 @@ import type { QuotaDataItem, UptimeGroupResult } from './types'
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// Get user quota data within a time range // Get user quota data within a time range
// Admin users can specify 'username' to view other users' data // Admin users get all users' data by default (matching classic frontend behavior)
export async function getUserQuotaDates(params: { export async function getUserQuotaDates(
start_timestamp: number params: {
end_timestamp: number start_timestamp: number
default_time?: string end_timestamp: number
username?: string default_time?: string
}) { username?: string
const endpoint = params.username ? '/api/data' : '/api/data/self' },
isAdmin = false
) {
const endpoint = isAdmin ? '/api/data' : '/api/data/self'
const res = await api.get<{ success: boolean; data: QuotaDataItem[] }>( const res = await api.get<{ success: boolean; data: QuotaDataItem[] }>(
endpoint, endpoint,
{ params } { params }

View File

@ -0,0 +1,120 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { VChart } from '@visactor/react-vchart'
import { AreaChart, BarChart3, WalletCards } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { TimeGranularity } from '@/lib/time'
import { VCHART_OPTION } from '@/lib/vchart'
import { useTheme } from '@/context/theme-provider'
import { DEFAULT_TIME_GRANULARITY } from '@/features/dashboard/constants'
import { processChartData } from '@/features/dashboard/lib'
import type { QuotaDataItem } from '@/features/dashboard/types'
let themeManagerPromise: Promise<
(typeof import('@visactor/vchart'))['ThemeManager']
> | null = null
type DistributionChartType = 'bar' | 'area'
interface ConsumptionDistributionChartProps {
data: QuotaDataItem[]
loading?: boolean
timeGranularity?: TimeGranularity
}
const CHART_TYPES: Array<{
value: DistributionChartType
labelKey: string
icon: typeof BarChart3
}> = [
{ value: 'bar', labelKey: 'Bar Chart', icon: BarChart3 },
{ value: 'area', labelKey: 'Area Chart', icon: AreaChart },
]
export function ConsumptionDistributionChart(
props: ConsumptionDistributionChartProps
) {
const { t } = useTranslation()
const { resolvedTheme } = useTheme()
const [chartType, setChartType] = useState<DistributionChartType>('bar')
const [themeReady, setThemeReady] = useState(false)
const themeManagerRef = useRef<
(typeof import('@visactor/vchart'))['ThemeManager'] | null
>(null)
const timeGranularity = props.timeGranularity ?? DEFAULT_TIME_GRANULARITY
useEffect(() => {
const updateTheme = async () => {
setThemeReady(false)
if (!themeManagerPromise) {
themeManagerPromise = import('@visactor/vchart').then(
(m) => m.ThemeManager
)
}
const ThemeManager = await themeManagerPromise
themeManagerRef.current = ThemeManager
ThemeManager.setCurrentTheme(resolvedTheme === 'dark' ? 'dark' : 'light')
setThemeReady(true)
}
updateTheme()
}, [resolvedTheme])
const chartData = useMemo(
() => processChartData(props.loading ? [] : props.data, timeGranularity, t),
[props.data, props.loading, timeGranularity, t]
)
const spec = chartType === 'bar' ? chartData.spec_line : chartData.spec_area
return (
<div className='overflow-hidden rounded-lg border'>
<div className='flex w-full flex-col gap-3 border-b px-4 py-3 sm:px-5 lg:flex-row lg:items-center lg:justify-between'>
<div className='flex items-center gap-2'>
<WalletCards className='text-muted-foreground/60 size-4' />
<div className='text-sm font-semibold'>
{t('Quota Distribution')}
</div>
<span className='text-muted-foreground text-xs'>
{t('Total:')} {chartData.totalQuotaDisplay}
</span>
</div>
<div className='bg-muted/60 inline-flex h-8 rounded-md border p-0.5'>
{CHART_TYPES.map((item) => {
const Icon = item.icon
return (
<button
key={item.value}
type='button'
onClick={() => setChartType(item.value)}
className={`inline-flex items-center gap-1.5 rounded-[5px] px-3 text-xs font-medium transition-colors ${
chartType === item.value
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<Icon className='size-3.5' />
{t(item.labelKey)}
</button>
)
})}
</div>
</div>
<div className='h-96 p-2'>
{themeReady && spec && (
<VChart
key={`${chartType}-${resolvedTheme}`}
spec={{
...spec,
theme: resolvedTheme === 'dark' ? 'dark' : 'light',
background: 'transparent',
}}
option={VCHART_OPTION}
/>
)}
</div>
</div>
)
}

View File

@ -1,6 +1,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { formatNumber, formatQuota } from '@/lib/format' import { formatNumber, formatQuota } from '@/lib/format'
import { computeTimeRange } from '@/lib/time' import { computeTimeRange } from '@/lib/time'
import { useAuthStore } from '@/stores/auth-store'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { getUserQuotaDates } from '@/features/dashboard/api' import { getUserQuotaDates } from '@/features/dashboard/api'
import { useModelStatCardsConfig } from '@/features/dashboard/hooks/use-dashboard-config' import { useModelStatCardsConfig } from '@/features/dashboard/hooks/use-dashboard-config'
@ -21,6 +22,8 @@ interface LogStatCardsProps {
export function LogStatCards(props: LogStatCardsProps) { export function LogStatCards(props: LogStatCardsProps) {
const statCardsConfig = useModelStatCardsConfig() const statCardsConfig = useModelStatCardsConfig()
const user = useAuthStore((state) => state.auth.user)
const isAdmin = !!(user?.role && user.role >= 10)
const [stats, setStats] = useState<{ const [stats, setStats] = useState<{
totalQuota: number totalQuota: number
totalCount: number totalCount: number
@ -49,7 +52,7 @@ export function LogStatCards(props: LogStatCardsProps) {
const timeDiff = (timeRange.end_timestamp - timeRange.start_timestamp) / 60 const timeDiff = (timeRange.end_timestamp - timeRange.start_timestamp) / 60
setTimeRangeMinutes(timeDiff) setTimeRangeMinutes(timeDiff)
getUserQuotaDates(buildQueryParams(timeRange, filters)) getUserQuotaDates(buildQueryParams(timeRange, filters), isAdmin)
.then((res) => { .then((res) => {
if (abortController.signal.aborted) return if (abortController.signal.aborted) return
const data = res?.data || [] const data = res?.data || []
@ -71,7 +74,7 @@ export function LogStatCards(props: LogStatCardsProps) {
return () => { return () => {
abortController.abort() abortController.abort()
} }
}, [filters, onDataUpdate]) }, [filters, isAdmin, onDataUpdate])
const adaptedStats = { const adaptedStats = {
rpm: stats?.totalCount ?? 0, rpm: stats?.totalCount ?? 0,

View File

@ -7,26 +7,27 @@ import { VCHART_OPTION } from '@/lib/vchart'
import { useTheme } from '@/context/theme-provider' import { useTheme } from '@/context/theme-provider'
import { DEFAULT_TIME_GRANULARITY } from '@/features/dashboard/constants' import { DEFAULT_TIME_GRANULARITY } from '@/features/dashboard/constants'
import { processChartData } from '@/features/dashboard/lib' import { processChartData } from '@/features/dashboard/lib'
import type { import type { QuotaDataItem } from '@/features/dashboard/types'
ProcessedChartData,
QuotaDataItem,
} from '@/features/dashboard/types'
let themeManagerPromise: Promise< let themeManagerPromise: Promise<
(typeof import('@visactor/vchart'))['ThemeManager'] (typeof import('@visactor/vchart'))['ThemeManager']
> | null = null > | null = null
type ChartTab = '1' | '2' | '3' | '4' type ChartTab = 'trend' | 'proportion' | 'top'
type ChartSpecKey = 'spec_model_line' | 'spec_pie' | 'spec_rank_bar'
const CHART_TABS: { const CHART_TABS: {
value: ChartTab value: ChartTab
labelKey: string labelKey: string
specKey: keyof ProcessedChartData specKey: ChartSpecKey
}[] = [ }[] = [
{ value: '1', labelKey: 'Quota Distribution', specKey: 'spec_line' }, { value: 'trend', labelKey: 'Call Trend', specKey: 'spec_model_line' },
{ value: '2', labelKey: 'Call Trend', specKey: 'spec_model_line' }, {
{ value: '3', labelKey: 'Call Proportion', specKey: 'spec_pie' }, value: 'proportion',
{ value: '4', labelKey: 'Top Models', specKey: 'spec_rank_bar' }, labelKey: 'Call Count Distribution',
specKey: 'spec_pie',
},
{ value: 'top', labelKey: 'Call Count Ranking', specKey: 'spec_rank_bar' },
] ]
interface ModelChartsProps { interface ModelChartsProps {
@ -38,7 +39,7 @@ interface ModelChartsProps {
export function ModelCharts(props: ModelChartsProps) { export function ModelCharts(props: ModelChartsProps) {
const { t } = useTranslation() const { t } = useTranslation()
const { resolvedTheme } = useTheme() const { resolvedTheme } = useTheme()
const [activeTab, setActiveTab] = useState<ChartTab>('1') const [activeTab, setActiveTab] = useState<ChartTab>('trend')
const [themeReady, setThemeReady] = useState(false) const [themeReady, setThemeReady] = useState(false)
const themeManagerRef = useRef< const themeManagerRef = useRef<
(typeof import('@visactor/vchart'))['ThemeManager'] | null (typeof import('@visactor/vchart'))['ThemeManager'] | null

View File

@ -24,10 +24,8 @@ let themeManagerPromise: Promise<
(typeof import('@visactor/vchart'))['ThemeManager'] (typeof import('@visactor/vchart'))['ThemeManager']
> | null = null > | null = null
type UserChartTab = 'rank' | 'trend' const USER_CHARTS: {
value: string
const CHART_TABS: {
value: UserChartTab
labelKey: string labelKey: string
specKey: keyof ProcessedUserChartData specKey: keyof ProcessedUserChartData
}[] = [ }[] = [
@ -46,7 +44,6 @@ const CHART_TABS: {
export function UserCharts() { export function UserCharts() {
const { t } = useTranslation() const { t } = useTranslation()
const { resolvedTheme } = useTheme() const { resolvedTheme } = useTheme()
const [activeTab, setActiveTab] = useState<UserChartTab>('rank')
const [themeReady, setThemeReady] = useState(false) const [themeReady, setThemeReady] = useState(false)
const themeManagerRef = useRef< const themeManagerRef = useRef<
(typeof import('@visactor/vchart'))['ThemeManager'] | null (typeof import('@visactor/vchart'))['ThemeManager'] | null
@ -121,9 +118,6 @@ export function UserCharts() {
[userData, isLoading, timeGranularity, t] [userData, isLoading, timeGranularity, t]
) )
const activeSpec = CHART_TABS.find((tab) => tab.value === activeTab)
const spec = activeSpec ? chartData[activeSpec.specKey] : null
return ( return (
<div className='space-y-4'> <div className='space-y-4'>
{/* Toolbar: time range presets + granularity */} {/* Toolbar: time range presets + granularity */}
@ -169,50 +163,41 @@ export function UserCharts() {
)} )}
</div> </div>
{/* Chart card */} <div className='grid gap-4'>
<div className='overflow-hidden rounded-lg border'> {USER_CHARTS.map((chart) => {
<div className='flex w-full flex-col gap-3 border-b px-4 py-3 sm:px-5 lg:flex-row lg:items-center lg:justify-between'> const spec = chartData[chart.specKey]
<div className='flex items-center gap-2'>
<Users className='text-muted-foreground/60 size-4' />
<div className='text-sm font-semibold'>{t('User Analytics')}</div>
</div>
<div className='bg-muted/60 inline-flex h-8 rounded-md border p-0.5'> return (
{CHART_TABS.map((tab) => ( <div
<button key={chart.value}
key={tab.value} className='overflow-hidden rounded-lg border'
type='button' >
onClick={() => setActiveTab(tab.value)} <div className='flex w-full items-center gap-2 border-b px-4 py-3 sm:px-5'>
className={`rounded-[5px] px-3 text-xs font-medium transition-colors ${ <Users className='text-muted-foreground/60 size-4' />
activeTab === tab.value <div className='text-sm font-semibold'>{t(chart.labelKey)}</div>
? 'bg-background text-foreground shadow-sm' </div>
: 'text-muted-foreground hover:text-foreground'
}`}
>
{t(tab.labelKey)}
</button>
))}
</div>
</div>
<div className='h-96 p-2'> <div className='h-96 p-2'>
{isLoading ? ( {isLoading ? (
<Skeleton className='h-full w-full' /> <Skeleton className='h-full w-full' />
) : ( ) : (
themeReady && themeReady &&
spec && ( spec && (
<VChart <VChart
key={`user-${activeTab}-${resolvedTheme}`} key={`user-${chart.value}-${resolvedTheme}`}
spec={{ spec={{
...spec, ...spec,
theme: resolvedTheme === 'dark' ? 'dark' : 'light', theme: resolvedTheme === 'dark' ? 'dark' : 'light',
background: 'transparent', background: 'transparent',
}} }}
option={VCHART_OPTION} option={VCHART_OPTION}
/> />
) )
)} )}
</div> </div>
</div>
)
})}
</div> </div>
</div> </div>
) )

View File

@ -35,6 +35,12 @@ const LazyModelCharts = lazy(() =>
})) }))
) )
const LazyConsumptionDistributionChart = lazy(() =>
import('./components/models/consumption-distribution-chart').then((m) => ({
default: m.ConsumptionDistributionChart,
}))
)
const LazyUserCharts = lazy(() => const LazyUserCharts = lazy(() =>
import('./components/users/user-charts').then((m) => ({ import('./components/users/user-charts').then((m) => ({
default: m.UserCharts, default: m.UserCharts,
@ -163,6 +169,17 @@ export function Dashboard() {
</Suspense> </Suspense>
</FadeIn> </FadeIn>
<FadeIn delay={0.1}> <FadeIn delay={0.1}>
<Suspense fallback={<ModelChartsFallback />}>
<LazyConsumptionDistributionChart
data={modelData}
loading={dataLoading}
timeGranularity={
modelFilters.time_granularity || DEFAULT_TIME_GRANULARITY
}
/>
</Suspense>
</FadeIn>
<FadeIn delay={0.15}>
<Suspense fallback={<ModelChartsFallback />}> <Suspense fallback={<ModelChartsFallback />}>
<LazyModelCharts <LazyModelCharts
data={modelData} data={modelData}

View File

@ -1,5 +1,5 @@
import { getChartColor } from '@/lib/colors' import { dataScheme as vchartDefaultDataScheme } from '@visactor/vchart/esm/theme/color-scheme/builtin/default'
import { formatQuotaWithCurrency, getCurrencyDisplay } from '@/lib/currency' import { getCurrencyDisplay } from '@/lib/currency'
import { formatChartTime, type TimeGranularity } from '@/lib/time' import { formatChartTime, type TimeGranularity } from '@/lib/time'
import { MAX_CHART_TREND_POINTS } from '@/features/dashboard/constants' import { MAX_CHART_TREND_POINTS } from '@/features/dashboard/constants'
import type { import type {
@ -10,6 +10,38 @@ import type {
type TFunction = (key: string) => string type TFunction = (key: string) => string
function getVChartDefaultColors(domainLength: number) {
const scheme =
vchartDefaultDataScheme.find(
(item) => !item.maxDomainLength || domainLength <= item.maxDomainLength
) ?? vchartDefaultDataScheme[vchartDefaultDataScheme.length - 1]
return scheme.scheme
}
function buildModelColorSpec(models: string[]) {
const domain = Array.from(new Set(models))
return {
type: 'ordinal',
domain,
range: getVChartDefaultColors(domain.length),
}
}
function renderQuotaCompat(rawQuota: number, digits = 4): string {
const { config, meta } = getCurrencyDisplay()
if (meta.kind === 'tokens') return rawQuota.toLocaleString()
const usd = rawQuota / config.quotaPerUnit
const rate = 'exchangeRate' in meta ? meta.exchangeRate : 1
const symbol = 'symbol' in meta ? meta.symbol : '$'
const value = usd * rate
const fixed = value.toFixed(digits)
if (parseFloat(fixed) === 0 && rawQuota > 0 && value > 0) {
return symbol + Math.pow(10, -digits).toFixed(digits)
}
return symbol + fixed
}
/** /**
* Process and aggregate chart data * Process and aggregate chart data
*/ */
@ -19,9 +51,61 @@ export function processChartData(
t?: TFunction t?: TFunction
): ProcessedChartData { ): ProcessedChartData {
const tt: TFunction = t ?? ((x) => x) const tt: TFunction = t ?? ((x) => x)
const otherLabel = tt('Other')
const formatInt = (value: number) => const formatInt = (value: number) =>
Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(value) Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(value)
const formatQuotaValue = (value: number) => renderQuotaCompat(value, 4)
const formatQuotaTotal = (value: number) => renderQuotaCompat(value, 2)
const MAX_TOOLTIP_MODELS = 15
const makeTooltipDimensionUpdateContent = () => {
return (
array: Array<{
key: string
value: string | number
datum?: Record<string, unknown>
}>
) => {
array.sort((a, b) => (Number(b.value) || 0) - (Number(a.value) || 0))
let sum = 0
for (let i = 0; i < array.length; i++) {
if (array[i].key === 'Other' || array[i].key === otherLabel) continue
const v = Number(array[i].value) || 0
if (
array[i].datum &&
(array[i].datum as Record<string, unknown>)?.TimeSum
) {
sum =
Number((array[i].datum as Record<string, unknown>)?.TimeSum) || sum
}
array[i].value = formatQuotaValue(v)
}
if (array.length > MAX_TOOLTIP_MODELS) {
const visible = array.slice(0, MAX_TOOLTIP_MODELS)
let otherSum = 0
for (let i = MAX_TOOLTIP_MODELS; i < array.length; i++) {
const raw = array[i].datum
? Number((array[i].datum as Record<string, unknown>)?.rawQuota) || 0
: 0
otherSum += raw
}
visible.push({
key: otherLabel,
value: formatQuotaValue(otherSum),
})
array = visible
}
array.unshift({
key: tt('Total:'),
value: formatQuotaValue(sum),
})
return array
}
}
if (!data || data.length === 0) { if (!data || data.length === 0) {
return { return {
@ -35,7 +119,7 @@ export function processChartData(
categoryField: 'type', categoryField: 'type',
title: { title: {
visible: true, visible: true,
text: tt('Call Proportion'), text: tt('Call Count Distribution'),
subtext: tt('No data available'), subtext: tt('No data available'),
}, },
legends: { visible: false }, legends: { visible: false },
@ -54,15 +138,15 @@ export function processChartData(
seriesField: 'Model', seriesField: 'Model',
stack: true, stack: true,
legends: { visible: true, selectMode: 'single' }, legends: { visible: true, selectMode: 'single' },
title: { },
visible: true, spec_area: {
text: tt('Quota Distribution'), type: 'area',
subtext: `${tt('Total:')} ${formatQuotaWithCurrency(0, { data: [{ id: 'areaData', values: [] }],
digitsLarge: 2, xField: 'Time',
digitsSmall: 2, yField: 'Usage',
abbreviate: false, seriesField: 'Model',
})}`, stack: true,
}, legends: { visible: true, selectMode: 'single' },
}, },
spec_model_line: { spec_model_line: {
type: 'line', type: 'line',
@ -86,10 +170,11 @@ export function processChartData(
legends: { visible: true, selectMode: 'single' }, legends: { visible: true, selectMode: 'single' },
title: { title: {
visible: true, visible: true,
text: tt('Top Models'), text: tt('Call Count Ranking'),
subtext: `${tt('Total:')} ${formatInt(0)}`, subtext: `${tt('Total:')} ${formatInt(0)}`,
}, },
}, },
totalQuotaDisplay: formatQuotaTotal(0),
} }
} }
@ -142,6 +227,7 @@ export function processChartData(
const allModels = Array.from(modelTotalsMap.keys()) const allModels = Array.from(modelTotalsMap.keys())
const sortedTimes = Array.from(timeModelMap.keys()).sort() const sortedTimes = Array.from(timeModelMap.keys()).sort()
const sortedModels = [...allModels].sort() const sortedModels = [...allModels].sort()
const modelColor = buildModelColorSpec([...sortedModels, otherLabel])
// Pad time points if too few (default 7 points) // Pad time points if too few (default 7 points)
const MAX_TREND_POINTS = MAX_CHART_TREND_POINTS const MAX_TREND_POINTS = MAX_CHART_TREND_POINTS
@ -166,14 +252,6 @@ export function processChartData(
} }
const chartTimes = fillTimePoints(sortedTimes) const chartTimes = fillTimePoints(sortedTimes)
const modelColorMap = sortedModels.reduce<Record<string, string>>(
(acc, model, index) => {
acc[model] = getChartColor(index)
return acc
},
{}
)
const totalTimes = Array.from(modelTotalsMap.values()).reduce( const totalTimes = Array.from(modelTotalsMap.values()).reduce(
(sum, x) => sum + (Number(x.count) || 0), (sum, x) => sum + (Number(x.count) || 0),
0 0
@ -223,14 +301,70 @@ export function processChartData(
}) })
lineValues.sort((a, b) => a.Time.localeCompare(b.Time)) lineValues.sort((a, b) => a.Time.localeCompare(b.Time))
// Line chart: model call trend // Area chart: top models by quota + "Other" bucket (too many series = unreadable)
const MAX_AREA_MODELS = 15
const rankedQuotaModels = Array.from(modelTotalsMap.entries())
.map(([model, stats]) => ({
Model: model,
Quota: Number(stats.quota) || 0,
}))
.sort((a, b) => b.Quota - a.Quota)
const topAreaModels = new Set(
rankedQuotaModels.slice(0, MAX_AREA_MODELS).map((m) => m.Model)
)
const areaValues: typeof lineValues = []
chartTimes.forEach((time) => {
const buckets = new Map<string, { rawQuota: number; usage: number }>()
const modelMap = timeModelMap.get(time)
let timeSum = 0
sortedModels.forEach((model) => {
const stats = modelMap?.get(model)
const rawQuota = Number(stats?.quota) || 0
const usd = rawQuota ? rawQuota / quotaPerUnit : 0
const usage = usd ? Number(usd.toFixed(4)) : 0
timeSum += rawQuota
const key = topAreaModels.has(model) ? model : otherLabel
const prev = buckets.get(key) || { rawQuota: 0, usage: 0 }
buckets.set(key, {
rawQuota: prev.rawQuota + rawQuota,
usage: Number((prev.usage + usage).toFixed(4)),
})
})
for (const [model, vals] of buckets) {
areaValues.push({
Time: time,
Model: model,
rawQuota: vals.rawQuota,
Usage: vals.usage,
TimeSum: timeSum,
})
}
})
areaValues.sort((a, b) => a.Time.localeCompare(b.Time))
// Line chart: model call trend (top models + "Other" bucket)
const MAX_TREND_MODELS = 20
const rankedTrendModels = Array.from(modelTotalsMap.entries())
.map(([model, stats]) => ({
Model: model,
Count: Number(stats.count) || 0,
}))
.sort((a, b) => b.Count - a.Count)
const topTrendModels = rankedTrendModels
.slice(0, MAX_TREND_MODELS)
.map((item) => item.Model)
const otherTrendModels = rankedTrendModels
.slice(MAX_TREND_MODELS)
.map((item) => item.Model)
const modelLineValues: Array<{ const modelLineValues: Array<{
Time: string Time: string
Model: string Model: string
Count: number Count: number
}> = [] }> = []
chartTimes.forEach((time) => { chartTimes.forEach((time) => {
const timeData = sortedModels.map((model) => { const timeData = topTrendModels.map((model) => {
const stats = timeModelMap.get(time)?.get(model) const stats = timeModelMap.get(time)?.get(model)
return { return {
Time: time, Time: time,
@ -238,6 +372,17 @@ export function processChartData(
Count: Number(stats?.count) || 0, Count: Number(stats?.count) || 0,
} }
}) })
if (otherTrendModels.length > 0) {
const otherCount = otherTrendModels.reduce((sum, model) => {
const stats = timeModelMap.get(time)?.get(model)
return sum + (Number(stats?.count) || 0)
}, 0)
timeData.push({
Time: time,
Model: otherLabel,
Count: otherCount,
})
}
modelLineValues.push(...timeData) modelLineValues.push(...timeData)
}) })
modelLineValues.sort((a, b) => a.Time.localeCompare(b.Time)) modelLineValues.sort((a, b) => a.Time.localeCompare(b.Time))
@ -257,7 +402,7 @@ export function processChartData(
const otherCount = allRankValues const otherCount = allRankValues
.slice(MAX_RANK_MODELS) .slice(MAX_RANK_MODELS)
.reduce((sum, item) => sum + item.Count, 0) .reduce((sum, item) => sum + item.Count, 0)
rankValues = [...topModels, { Model: tt('Other'), Count: otherCount }] rankValues = [...topModels, { Model: otherLabel, Count: otherCount }]
} else { } else {
rankValues = allRankValues rankValues = allRankValues
} }
@ -280,11 +425,12 @@ export function processChartData(
}, },
title: { title: {
visible: true, visible: true,
text: tt('Call Proportion'), text: tt('Call Count Distribution'),
subtext: `${tt('Total:')} ${formatInt(totalTimes)}`, subtext: `${tt('Total:')} ${formatInt(totalTimes)}`,
}, },
legends: { visible: true, orient: 'left' }, legends: { visible: true, orient: 'left' },
label: { visible: true }, label: { visible: true },
color: modelColor,
tooltip: { tooltip: {
mark: { mark: {
content: [ content: [
@ -296,7 +442,6 @@ export function processChartData(
], ],
}, },
}, },
color: { specified: modelColorMap },
background: { fill: 'transparent' }, background: { fill: 'transparent' },
animation: true, animation: true,
}, },
@ -308,15 +453,7 @@ export function processChartData(
seriesField: 'Model', seriesField: 'Model',
stack: true, stack: true,
legends: { visible: true, selectMode: 'single' }, legends: { visible: true, selectMode: 'single' },
title: { color: modelColor,
visible: true,
text: tt('Quota Distribution'),
subtext: `${tt('Total:')} ${formatQuotaWithCurrency(totalQuotaRaw, {
digitsLarge: 2,
digitsSmall: 2,
abbreviate: false,
})}`,
},
bar: { bar: {
state: { state: {
hover: { stroke: '#000', lineWidth: 1 }, hover: { stroke: '#000', lineWidth: 1 },
@ -328,11 +465,7 @@ export function processChartData(
{ {
key: (datum: Record<string, unknown>) => datum?.Model, key: (datum: Record<string, unknown>) => datum?.Model,
value: (datum: Record<string, unknown>) => value: (datum: Record<string, unknown>) =>
formatQuotaWithCurrency(Number(datum?.rawQuota) || 0, { formatQuotaValue(Number(datum?.rawQuota) || 0),
digitsLarge: 4,
digitsSmall: 4,
abbreviate: false,
}),
}, },
], ],
}, },
@ -344,58 +477,67 @@ export function processChartData(
Number(datum?.rawQuota) || 0, Number(datum?.rawQuota) || 0,
}, },
], ],
updateContent: ( updateContent: makeTooltipDimensionUpdateContent(),
array: Array<{
key: string
value: string | number
datum?: Record<string, unknown>
}>
) => {
array.sort(
(a, b) => (Number(b.value) || 0) - (Number(a.value) || 0)
)
let sum = 0
for (let i = 0; i < array.length; i++) {
if (array[i].key === 'Other') continue
const v = Number(array[i].value) || 0
if (
array[i].datum &&
(array[i].datum as Record<string, unknown>)?.TimeSum
) {
sum =
Number(
(array[i].datum as Record<string, unknown>)?.TimeSum
) || sum
}
array[i].value = formatQuotaWithCurrency(v, {
digitsLarge: 4,
digitsSmall: 4,
abbreviate: false,
})
}
array.unshift({
key: tt('Total:'),
value: formatQuotaWithCurrency(sum, {
digitsLarge: 4,
digitsSmall: 4,
abbreviate: false,
}),
})
return array
},
}, },
}, },
color: { specified: modelColorMap }, background: { fill: 'transparent' },
animation: true,
},
spec_area: {
type: 'area',
data: [{ id: 'areaData', values: areaValues }],
xField: 'Time',
yField: 'Usage',
seriesField: 'Model',
stack: false,
legends: { visible: true, selectMode: 'single' },
color: modelColor,
tooltip: {
mark: {
content: [
{
key: (datum: Record<string, unknown>) => datum?.Model,
value: (datum: Record<string, unknown>) =>
formatQuotaValue(Number(datum?.rawQuota) || 0),
},
],
},
dimension: {
content: [
{
key: (datum: Record<string, unknown>) => datum?.Model,
value: (datum: Record<string, unknown>) =>
Number(datum?.rawQuota) || 0,
},
],
updateContent: makeTooltipDimensionUpdateContent(),
},
},
area: {
style: {
fillOpacity: 0.08,
curveType: 'monotone',
},
},
line: {
style: {
lineWidth: 2,
curveType: 'monotone',
},
},
point: { visible: false },
background: { fill: 'transparent' }, background: { fill: 'transparent' },
animation: true, animation: true,
}, },
spec_model_line: { spec_model_line: {
type: 'line', type: 'area',
data: [{ id: 'lineData', values: modelLineValues }], data: [{ id: 'lineData', values: modelLineValues }],
xField: 'Time', xField: 'Time',
yField: 'Count', yField: 'Count',
seriesField: 'Model', seriesField: 'Model',
stack: false,
legends: { visible: true, selectMode: 'single' }, legends: { visible: true, selectMode: 'single' },
color: modelColor,
title: { title: {
visible: true, visible: true,
text: tt('Call Trend'), text: tt('Call Trend'),
@ -442,7 +584,18 @@ export function processChartData(
}, },
}, },
}, },
color: { specified: modelColorMap }, area: {
style: {
fillOpacity: 0.08,
curveType: 'monotone',
},
},
line: {
style: {
lineWidth: 2,
curveType: 'monotone',
},
},
point: { visible: false }, point: { visible: false },
background: { fill: 'transparent' }, background: { fill: 'transparent' },
animation: true, animation: true,
@ -454,9 +607,10 @@ export function processChartData(
yField: 'Count', yField: 'Count',
seriesField: 'Model', seriesField: 'Model',
legends: { visible: true, selectMode: 'single' }, legends: { visible: true, selectMode: 'single' },
color: modelColor,
title: { title: {
visible: true, visible: true,
text: tt('Top Models'), text: tt('Call Count Ranking'),
subtext: `${tt('Total:')} ${formatInt(totalTimes)}`, subtext: `${tt('Total:')} ${formatInt(totalTimes)}`,
}, },
bar: { bar: {
@ -475,10 +629,10 @@ export function processChartData(
], ],
}, },
}, },
color: { specified: modelColorMap },
background: { fill: 'transparent' }, background: { fill: 'transparent' },
animation: true, animation: true,
}, },
totalQuotaDisplay: formatQuotaTotal(totalQuotaRaw),
} }
} }
@ -505,12 +659,7 @@ export function processUserChartData(
const { config } = getCurrencyDisplay() const { config } = getCurrencyDisplay()
const quotaPerUnit = config.quotaPerUnit const quotaPerUnit = config.quotaPerUnit
const formatVal = (raw: number) => const formatVal = (raw: number) => renderQuotaCompat(raw, 2)
formatQuotaWithCurrency(raw, {
digitsLarge: 2,
digitsSmall: 2,
abbreviate: false,
})
const emptyResult: ProcessedUserChartData = { const emptyResult: ProcessedUserChartData = {
spec_user_rank: { spec_user_rank: {

View File

@ -45,14 +45,12 @@ export function buildQueryParams(
): { ): {
start_timestamp: number start_timestamp: number
end_timestamp: number end_timestamp: number
default_time?: string default_time: string
username?: string username?: string
} { } {
return { return {
...timeRange, ...timeRange,
...(filters?.time_granularity && { default_time: getSavedGranularity(filters?.time_granularity),
default_time: filters.time_granularity,
}),
...(filters?.username && { username: filters.username }), ...(filters?.username && { username: filters.username }),
} }
} }

View File

@ -71,8 +71,10 @@ type VChartSpec = Record<string, any>
export interface ProcessedChartData { export interface ProcessedChartData {
spec_pie: VChartSpec spec_pie: VChartSpec
spec_line: VChartSpec spec_line: VChartSpec
spec_area: VChartSpec
spec_model_line: VChartSpec spec_model_line: VChartSpec
spec_rank_bar: VChartSpec spec_rank_bar: VChartSpec
totalQuotaDisplay: string
} }
export interface ProcessedUserChartData { export interface ProcessedUserChartData {

View File

@ -0,0 +1,173 @@
import { useMemo, useState } from 'react'
import { Check, ChevronsUpDown } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
export type ApiKeyGroupOption = {
value: string
label: string
desc?: string
ratio?: number | string
}
type ApiKeyGroupComboboxProps = {
options: ApiKeyGroupOption[]
value?: string
onValueChange: (value: string) => void
placeholder?: string
disabled?: boolean
}
function formatGroupRatio(ratio: ApiKeyGroupOption['ratio'], ratioLabel: string) {
if (ratio === undefined || ratio === null || ratio === '') return null
return `${ratio}x ${ratioLabel}`
}
function getRatioBadgeClassName(ratio: ApiKeyGroupOption['ratio']) {
if (typeof ratio !== 'number') {
return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/40 dark:text-emerald-300'
}
if (ratio > 5) {
return 'border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-900/60 dark:bg-rose-950/40 dark:text-rose-300'
}
if (ratio > 3) {
return 'border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-900/60 dark:bg-orange-950/40 dark:text-orange-300'
}
if (ratio > 1) {
return 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/60 dark:bg-blue-950/40 dark:text-blue-300'
}
return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/40 dark:text-emerald-300'
}
function GroupRatioBadge({ ratio }: { ratio: ApiKeyGroupOption['ratio'] }) {
const { t } = useTranslation()
const label = formatGroupRatio(ratio, t('Ratio'))
if (!label) return null
return (
<Badge variant='outline' className={getRatioBadgeClassName(ratio)}>
{label}
</Badge>
)
}
export function ApiKeyGroupCombobox({
options,
value,
onValueChange,
placeholder,
disabled,
}: ApiKeyGroupComboboxProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [searchValue, setSearchValue] = useState('')
const selectedOption = options.find((option) => option.value === value)
const filteredOptions = useMemo(() => {
const search = searchValue.trim().toLowerCase()
if (!search) return options
return options.filter((option) => {
const ratioText = String(option.ratio ?? '').toLowerCase()
return (
option.value.toLowerCase().includes(search) ||
option.label.toLowerCase().includes(search) ||
option.desc?.toLowerCase().includes(search) ||
ratioText.includes(search)
)
})
}, [options, searchValue])
const handleSelect = (selectedValue: string) => {
onValueChange(selectedValue)
setOpen(false)
setSearchValue('')
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type='button'
variant='outline'
role='combobox'
aria-expanded={open}
disabled={disabled}
className='h-auto min-h-10 w-full justify-between gap-3 px-3 py-2 text-start'
>
<span className='flex min-w-0 flex-1 items-center justify-between gap-3'>
<span className='min-w-0'>
<span className='block truncate font-medium'>
{selectedOption?.value || placeholder || t('Select a group')}
</span>
{selectedOption?.desc && (
<span className='text-muted-foreground block truncate text-xs'>
{selectedOption.desc}
</span>
)}
</span>
<GroupRatioBadge ratio={selectedOption?.ratio} />
</span>
<ChevronsUpDown className='h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-[var(--radix-popover-trigger-width)] p-0'>
<Command shouldFilter={false}>
<CommandInput
placeholder={t('Search...')}
value={searchValue}
onValueChange={setSearchValue}
/>
<CommandList className='max-h-[360px]'>
<CommandEmpty>{t('No group found.')}</CommandEmpty>
<CommandGroup>
{filteredOptions.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={handleSelect}
className='items-start gap-3 px-3 py-3'
>
<Check
className={cn(
'mt-0.5 h-4 w-4',
value === option.value ? 'opacity-100' : 'opacity-0'
)}
/>
<span className='min-w-0 flex-1'>
<span className='block truncate font-medium'>
{option.value}
</span>
{option.desc && (
<span className='text-muted-foreground block truncate text-xs'>
{option.desc}
</span>
)}
</span>
<GroupRatioBadge ratio={option.ratio} />
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}

View File

@ -62,7 +62,7 @@ function useGroupRatios(): Record<string, number> {
if (!res.success || !res.data) return {} if (!res.success || !res.data) return {}
const ratios: Record<string, number> = {} const ratios: Record<string, number> = {}
for (const [group, info] of Object.entries(res.data)) { for (const [group, info] of Object.entries(res.data)) {
if (info.ratio !== undefined) { if (typeof info.ratio === 'number') {
ratios[group] = info.ratio ratios[group] = info.ratio
} }
} }

View File

@ -1,3 +1,4 @@
import { useEffect, useState } from 'react'
import { ApiKeysDeleteDialog } from './api-keys-delete-dialog' import { ApiKeysDeleteDialog } from './api-keys-delete-dialog'
import { ApiKeysMutateDrawer } from './api-keys-mutate-drawer' import { ApiKeysMutateDrawer } from './api-keys-mutate-drawer'
import { useApiKeys } from './api-keys-provider' import { useApiKeys } from './api-keys-provider'
@ -5,6 +6,19 @@ import { CCSwitchDialog } from './dialogs/cc-switch-dialog'
export function ApiKeysDialogs() { export function ApiKeysDialogs() {
const { open, setOpen, currentRow, resolvedKey } = useApiKeys() const { open, setOpen, currentRow, resolvedKey } = useApiKeys()
const [lastMutateSide, setLastMutateSide] = useState<'left' | 'right'>(
'right'
)
const mutateSide =
open === 'create' ? 'left' : open === 'update' ? 'right' : lastMutateSide
useEffect(() => {
if (open === 'create') {
setLastMutateSide('left')
} else if (open === 'update') {
setLastMutateSide('right')
}
}, [open])
return ( return (
<> <>
@ -12,6 +26,7 @@ export function ApiKeysDialogs() {
open={open === 'create' || open === 'update'} open={open === 'create' || open === 'update'}
onOpenChange={(isOpen) => !isOpen && setOpen(null)} onOpenChange={(isOpen) => !isOpen && setOpen(null)}
currentRow={open === 'update' ? currentRow || undefined : undefined} currentRow={open === 'update' ? currentRow || undefined : undefined}
side={mutateSide}
/> />
<ApiKeysDeleteDialog /> <ApiKeysDeleteDialog />
<CCSwitchDialog <CCSwitchDialog

View File

@ -23,13 +23,6 @@ import {
FormMessage, FormMessage,
} from '@/components/ui/form' } from '@/components/ui/form'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { import {
Sheet, Sheet,
SheetClose, SheetClose,
@ -53,18 +46,24 @@ import {
transformApiKeyToFormDefaults, transformApiKeyToFormDefaults,
} from '../lib' } from '../lib'
import { type ApiKey } from '../types' import { type ApiKey } from '../types'
import {
ApiKeyGroupCombobox,
type ApiKeyGroupOption,
} from './api-key-group-combobox'
import { useApiKeys } from './api-keys-provider' import { useApiKeys } from './api-keys-provider'
type ApiKeyMutateDrawerProps = { type ApiKeyMutateDrawerProps = {
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
currentRow?: ApiKey currentRow?: ApiKey
side?: 'left' | 'right'
} }
export function ApiKeysMutateDrawer({ export function ApiKeysMutateDrawer({
open, open,
onOpenChange, onOpenChange,
currentRow, currentRow,
side = 'right',
}: ApiKeyMutateDrawerProps) { }: ApiKeyMutateDrawerProps) {
const { t } = useTranslation() const { t } = useTranslation()
const isUpdate = !!currentRow const isUpdate = !!currentRow
@ -88,14 +87,22 @@ export function ApiKeysMutateDrawer({
const models = modelsData?.data || [] const models = modelsData?.data || []
const groupsRaw = groupsData?.data || {} const groupsRaw = groupsData?.data || {}
const groups = Object.entries(groupsRaw).map(([key, info]) => ({ const groups: ApiKeyGroupOption[] = Object.entries(groupsRaw).map(
value: key, ([key, info]) => ({
label: info.desc || key, value: key,
})) label: key,
desc: info.desc || key,
ratio: info.ratio,
})
)
// Add auto group if configured // Add auto group if configured
if (!groups.some((g) => g.value === 'auto')) { if (!groups.some((g) => g.value === 'auto')) {
groups.unshift({ value: 'auto', label: t('Auto (Circuit Breaker)') }) groups.unshift({
value: 'auto',
label: 'auto',
desc: t('Auto (Circuit Breaker)'),
})
} }
const form = useForm<ApiKeyFormValues>({ const form = useForm<ApiKeyFormValues>({
@ -187,10 +194,9 @@ export function ApiKeysMutateDrawer({
form.setValue('expired_time', now) form.setValue('expired_time', now)
} }
const { config: currencyConfig, meta: currencyMeta } = getCurrencyDisplay() const { meta: currencyMeta } = getCurrencyDisplay()
const currencyLabel = getCurrencyLabel() const currencyLabel = getCurrencyLabel()
const tokensOnly = const tokensOnly = currencyMeta.kind === 'tokens'
!currencyConfig.displayInCurrency || currencyMeta.kind === 'tokens'
const quotaLabel = t('Quota ({{currency}})', { currency: currencyLabel }) const quotaLabel = t('Quota ({{currency}})', { currency: currencyLabel })
const quotaPlaceholder = tokensOnly const quotaPlaceholder = tokensOnly
? t('Enter quota in tokens') ? t('Enter quota in tokens')
@ -206,7 +212,10 @@ export function ApiKeysMutateDrawer({
} }
}} }}
> >
<SheetContent className='flex w-full flex-col sm:max-w-[600px]'> <SheetContent
side={side}
className='flex w-full flex-col sm:max-w-[600px]'
>
<SheetHeader className='text-start'> <SheetHeader className='text-start'>
<SheetTitle> <SheetTitle>
{isUpdate ? t('Update API Key') : t('Create API Key')} {isUpdate ? t('Update API Key') : t('Create API Key')}
@ -244,20 +253,14 @@ export function ApiKeysMutateDrawer({
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t('Group')}</FormLabel> <FormLabel>{t('Group')}</FormLabel>
<Select onValueChange={field.onChange} value={field.value}> <FormControl>
<FormControl> <ApiKeyGroupCombobox
<SelectTrigger> options={groups}
<SelectValue placeholder={t('Select a group')} /> value={field.value}
</SelectTrigger> onValueChange={field.onChange}
</FormControl> placeholder={t('Select a group')}
<SelectContent> />
{groups.map((group) => ( </FormControl>
<SelectItem key={group.value} value={group.value}>
{group.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription> <FormDescription>
{t('Auto group enables circuit breaker mechanism')} {t('Auto group enables circuit breaker mechanism')}
</FormDescription> </FormDescription>

View File

@ -38,6 +38,7 @@ import { getApiKeys, searchApiKeys } from '../api'
import { API_KEY_STATUS_OPTIONS, ERROR_MESSAGES } from '../constants' import { API_KEY_STATUS_OPTIONS, ERROR_MESSAGES } from '../constants'
import { type ApiKey } from '../types' import { type ApiKey } from '../types'
import { useApiKeysColumns } from './api-keys-columns' import { useApiKeysColumns } from './api-keys-columns'
import { ApiKeysPrimaryButtons } from './api-keys-primary-buttons'
import { useApiKeys } from './api-keys-provider' import { useApiKeys } from './api-keys-provider'
import { DataTableBulkActions } from './data-table-bulk-actions' import { DataTableBulkActions } from './data-table-bulk-actions'
@ -160,17 +161,22 @@ export function ApiKeysTable() {
return ( return (
<> <>
<div className='space-y-4'> <div className='space-y-4'>
<DataTableToolbar <div className='flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between'>
table={table} <ApiKeysPrimaryButtons />
searchPlaceholder={t('Filter by name or key...')} <div className='min-w-0 sm:flex sm:justify-end'>
filters={[ <DataTableToolbar
{ table={table}
columnId: 'status', searchPlaceholder={t('Filter by name or key...')}
title: t('Status'), filters={[
options: API_KEY_STATUS_OPTIONS, {
}, columnId: 'status',
]} title: t('Status'),
/> options: API_KEY_STATUS_OPTIONS,
},
]}
/>
</div>
</div>
{isMobile ? ( {isMobile ? (
<MobileCardList <MobileCardList
table={table} table={table}

View File

@ -1,7 +1,6 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { SectionPageLayout } from '@/components/layout' import { SectionPageLayout } from '@/components/layout'
import { ApiKeysDialogs } from './components/api-keys-dialogs' import { ApiKeysDialogs } from './components/api-keys-dialogs'
import { ApiKeysPrimaryButtons } from './components/api-keys-primary-buttons'
import { ApiKeysProvider } from './components/api-keys-provider' import { ApiKeysProvider } from './components/api-keys-provider'
import { ApiKeysTable } from './components/api-keys-table' import { ApiKeysTable } from './components/api-keys-table'
@ -14,9 +13,6 @@ export function ApiKeys() {
<SectionPageLayout.Description> <SectionPageLayout.Description>
{t('Manage your API keys for accessing the service')} {t('Manage your API keys for accessing the service')}
</SectionPageLayout.Description> </SectionPageLayout.Description>
<SectionPageLayout.Actions>
<ApiKeysPrimaryButtons />
</SectionPageLayout.Actions>
<SectionPageLayout.Content> <SectionPageLayout.Content>
<ApiKeysTable /> <ApiKeysTable />
</SectionPageLayout.Content> </SectionPageLayout.Content>

View File

@ -115,10 +115,9 @@ export function RedemptionsMutateDrawer({
form.setValue('expired_time', newDate) form.setValue('expired_time', newDate)
} }
const { config: currencyConfig, meta: currencyMeta } = getCurrencyDisplay() const { meta: currencyMeta } = getCurrencyDisplay()
const currencyLabel = getCurrencyLabel() const currencyLabel = getCurrencyLabel()
const tokensOnly = const tokensOnly = currencyMeta.kind === 'tokens'
!currencyConfig.displayInCurrency || currencyMeta.kind === 'tokens'
const quotaLabel = t('Quota ({{currency}})', { currency: currencyLabel }) const quotaLabel = t('Quota ({{currency}})', { currency: currencyLabel })
const quotaPlaceholder = tokensOnly const quotaPlaceholder = tokensOnly
? t('Enter quota in tokens') ? t('Enter quota in tokens')

View File

@ -3,6 +3,7 @@ import type { Resolver } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { RotateCcw } from 'lucide-react' import { RotateCcw } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { DEFAULT_CURRENCY_CONFIG } from '@/stores/system-config-store'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
Form, Form,
@ -110,6 +111,12 @@ export function PricingSection({ defaultValues }: PricingSectionProps) {
}) })
const displayType = form.watch('general_setting.quota_display_type') ?? 'USD' const displayType = form.watch('general_setting.quota_display_type') ?? 'USD'
const displayInCurrencyEnabled = form.watch('DisplayInCurrencyEnabled')
const showTokensOnlyOption = displayType === 'TOKENS'
const showQuotaPerUnit =
displayType === 'TOKENS' ||
defaultValues.QuotaPerUnit !== DEFAULT_CURRENCY_CONFIG.quotaPerUnit
const showDisplayInCurrencyOption = displayInCurrencyEnabled === false
return ( return (
<> <>
@ -122,30 +129,32 @@ export function PricingSection({ defaultValues }: PricingSectionProps) {
<Form {...form}> <Form {...form}>
<form onSubmit={handleSubmit} className='space-y-6'> <form onSubmit={handleSubmit} className='space-y-6'>
<FormDirtyIndicator isDirty={isDirty} /> <FormDirtyIndicator isDirty={isDirty} />
<FormField {showQuotaPerUnit && (
control={form.control} <FormField
name='QuotaPerUnit' control={form.control}
render={({ field }) => ( name='QuotaPerUnit'
<FormItem> render={({ field }) => (
<FormLabel>{t('Quota Per Unit')}</FormLabel> <FormItem>
<FormControl> <FormLabel>{t('Quota Per Unit')}</FormLabel>
<Input <FormControl>
type='number' <Input
step='0.01' type='number'
value={field.value as number} step='0.01'
onChange={(e) => field.onChange(e.target.valueAsNumber)} value={field.value as number}
name={field.name} disabled
onBlur={field.onBlur} name={field.name}
ref={field.ref} onBlur={field.onBlur}
/> ref={field.ref}
</FormControl> />
<FormDescription> </FormControl>
{t('Number of tokens per unit quota')} <FormDescription>
</FormDescription> {t('Number of tokens per unit quota')}
<FormMessage /> </FormDescription>
</FormItem> <FormMessage />
)} </FormItem>
/> )}
/>
)}
<FormField <FormField
control={form.control} control={form.control}
@ -165,7 +174,11 @@ export function PricingSection({ defaultValues }: PricingSectionProps) {
<SelectItem value='CUSTOM'> <SelectItem value='CUSTOM'>
{t('Custom Currency')} {t('Custom Currency')}
</SelectItem> </SelectItem>
<SelectItem value='TOKENS'>{t('Tokens Only')}</SelectItem> {showTokensOnlyOption && (
<SelectItem value='TOKENS'>
{t('Tokens Only')}
</SelectItem>
)}
</SelectContent> </SelectContent>
</Select> </Select>
<FormDescription> <FormDescription>
@ -272,32 +285,34 @@ export function PricingSection({ defaultValues }: PricingSectionProps) {
</div> </div>
)} )}
<FormField {showDisplayInCurrencyOption && (
control={form.control} <FormField
name='DisplayInCurrencyEnabled' control={form.control}
render={({ field }) => ( name='DisplayInCurrencyEnabled'
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'> render={({ field }) => (
<div className='space-y-0.5'> <FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<FormLabel className='text-base'> <div className='space-y-0.5'>
{t('Display in Currency')} <FormLabel className='text-base'>
</FormLabel> {t('Display in Currency')}
<FormDescription> </FormLabel>
{displayType === 'TOKENS' <FormDescription>
? t( {displayType === 'TOKENS'
'Tokens-only mode will show raw quota values regardless of this toggle.' ? t(
) 'Tokens-only mode will show raw quota values regardless of this toggle.'
: t('Show prices in currency instead of quota.')} )
</FormDescription> : t('Show prices in currency instead of quota.')}
</div> </FormDescription>
<FormControl> </div>
<Switch <FormControl>
checked={field.value} <Switch
onCheckedChange={field.onChange} checked={field.value}
/> onCheckedChange={field.onChange}
</FormControl> />
</FormItem> </FormControl>
)} </FormItem>
/> )}
/>
)}
<FormField <FormField
control={form.control} control={form.control}

View File

@ -8,6 +8,7 @@ import {
formatLogQuota, formatLogQuota,
formatTimestampToDate, formatTimestampToDate,
} from '@/lib/format' } from '@/lib/format'
import { getAvatarColorClass } from '@/lib/colors'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
@ -241,19 +242,6 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
size='sm' size='sm'
copyable={false} copyable={false}
/> />
{log.request_id && (
<StatusBadge
label={
log.request_id.length > 18
? `${log.request_id.slice(0, 18)}`
: log.request_id
}
variant='neutral'
size='sm'
copyText={log.request_id}
className='max-w-[140px] truncate font-mono'
/>
)}
</div> </div>
) )
}, },
@ -267,45 +255,47 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
] ]
if (isAdmin) { if (isAdmin) {
columns.push({ columns.push(
id: 'source', {
header: ({ column }) => ( id: 'channel',
<DataTableColumnHeader column={column} title={t('Source')} /> header: ({ column }) => (
), <DataTableColumnHeader column={column} title={t('Channel')} />
cell: function SourceCell({ row }) { ),
const { cell: function ChannelCell({ row }) {
setAffinityTarget, const {
setAffinityDialogOpen, sensitiveVisible,
setSelectedUserId, setAffinityTarget,
setUserInfoDialogOpen, setAffinityDialogOpen,
} = useUsageLogsContext() } = useUsageLogsContext()
const log = row.original const log = row.original
if (!isDisplayableLogType(log.type)) return null if (!isDisplayableLogType(log.type)) return null
const other = parseLogOther(log.other) const other = parseLogOther(log.other)
const affinity = other?.admin_info?.channel_affinity const affinity = other?.admin_info?.channel_affinity
const useChannel = other?.admin_info?.use_channel const useChannel = other?.admin_info?.use_channel
const channelChain = const channelChain =
useChannel && useChannel.length > 0 useChannel && useChannel.length > 0
? useChannel.join(' → ') ? useChannel.join(' → ')
: undefined : undefined
const channelDisplay = log.channel_name const channelDisplay = log.channel_name
? `${log.channel_name} #${log.channel}` ? `${log.channel_name} #${log.channel}`
: `#${log.channel}` : `#${log.channel}`
const channelIdDisplay = `#${log.channel}`
const channelName = sensitiveVisible ? log.channel_name : '••••'
return ( return (
<div className='flex flex-col gap-1'> <TooltipProvider>
<div className='flex items-center gap-1'> <Tooltip>
<TooltipProvider> <TooltipTrigger asChild>
<Tooltip> <div className='flex max-w-[160px] flex-col gap-0.5'>
<TooltipTrigger asChild> <div className='relative inline-flex w-fit'>
<div className='relative'>
<StatusBadge <StatusBadge
label={channelDisplay} label={channelIdDisplay}
autoColor={log.channel_name || String(log.channel)} autoColor={String(log.channel)}
copyText={String(log.channel)} copyText={String(log.channel)}
size='sm' size='sm'
className='font-mono'
/> />
{affinity && ( {affinity && (
<button <button
@ -329,57 +319,89 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
</button> </button>
)} )}
</div> </div>
</TooltipTrigger> {log.channel_name && (
<TooltipContent> <span className='text-muted-foreground/70 truncate text-[11px]'>
<div className='space-y-1'> {channelName}
<p>{channelDisplay}</p> </span>
{channelChain && ( )}
<p className='text-muted-foreground text-xs'> </div>
{t('Chain')}: {channelChain} </TooltipTrigger>
<TooltipContent>
<div className='space-y-1'>
<p>{sensitiveVisible ? channelDisplay : channelIdDisplay}</p>
{channelChain && (
<p className='text-muted-foreground text-xs'>
{t('Chain')}: {channelChain}
</p>
)}
{affinity && (
<div className='border-t pt-1 text-xs'>
<p className='font-medium'>{t('Channel Affinity')}</p>
<p>
{t('Rule')}: {affinity.rule_name || '-'}
</p> </p>
)} <p>
{affinity && ( {t('Group')}:{' '}
<div className='border-t pt-1 text-xs'> {sensitiveVisible
<p className='font-medium'>{t('Channel Affinity')}</p> ? affinity.using_group ||
<p>
{t('Rule')}: {affinity.rule_name || '-'}
</p>
<p>
{t('Group')}:{' '}
{affinity.using_group ||
affinity.selected_group || affinity.selected_group ||
'-'} '-'
</p> : '••••'}
</div> </p>
)} </div>
</div> )}
</TooltipContent> </div>
</Tooltip> </TooltipContent>
</TooltipProvider> </Tooltip>
</div> </TooltipProvider>
{log.username && ( )
<button },
type='button' meta: { label: t('Channel'), mobileHidden: true },
className='flex items-center gap-1 text-left'
onClick={(e) => {
e.stopPropagation()
setSelectedUserId(log.user_id)
setUserInfoDialogOpen(true)
}}
>
<span className='bg-primary/10 text-primary flex size-4 items-center justify-center rounded-full text-[10px] font-bold'>
{log.username.charAt(0).toUpperCase()}
</span>
<span className='text-muted-foreground truncate text-xs hover:underline'>
{log.username}
</span>
</button>
)}
</div>
)
}, },
meta: { label: t('Source'), mobileHidden: true }, {
}) id: 'user',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('User')} />
),
cell: function UserCell({ row }) {
const {
sensitiveVisible,
setSelectedUserId,
setUserInfoDialogOpen,
} = useUsageLogsContext()
const log = row.original
if (!isDisplayableLogType(log.type) || !log.username) return null
return (
<button
type='button'
className='flex items-center gap-1.5 text-left'
onClick={(e) => {
e.stopPropagation()
setSelectedUserId(log.user_id)
setUserInfoDialogOpen(true)
}}
>
<span
className={cn(
'flex size-5 items-center justify-center rounded-full text-[11px] font-bold',
sensitiveVisible
? getAvatarColorClass(log.username)
: 'bg-muted text-muted-foreground'
)}
>
{sensitiveVisible ? log.username.charAt(0).toUpperCase() : '•'}
</span>
<span className='text-muted-foreground truncate text-sm hover:underline'>
{sensitiveVisible ? log.username : '••••'}
</span>
</button>
)
},
meta: { label: t('User'), mobileHidden: true },
}
)
} }
columns.push( columns.push(
@ -389,6 +411,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
<DataTableColumnHeader column={column} title={t('Model')} /> <DataTableColumnHeader column={column} title={t('Model')} />
), ),
cell: function ModelCell({ row }) { cell: function ModelCell({ row }) {
const { sensitiveVisible } = useUsageLogsContext()
const log = row.original const log = row.original
if (!isDisplayableLogType(log.type)) return null if (!isDisplayableLogType(log.type)) return null
@ -450,8 +473,8 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
) )
const metaParts: string[] = [] const metaParts: string[] = []
if (tokenName) metaParts.push(tokenName) if (tokenName) metaParts.push(sensitiveVisible ? tokenName : '••••')
if (group) metaParts.push(group) if (group) metaParts.push(sensitiveVisible ? group : '••••')
return ( return (
<div className='flex max-w-[220px] flex-col gap-0.5'> <div className='flex max-w-[220px] flex-col gap-0.5'>

View File

@ -0,0 +1,279 @@
import { useState, useEffect, useCallback, type ReactNode } from 'react'
import { useNavigate, getRouteApi } from '@tanstack/react-router'
import { ChevronDown, Eye, EyeOff, RotateCcw, Search } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { useIsAdmin } from '@/hooks/use-admin'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { LOG_TYPES } from '../constants'
import { buildSearchParams } from '../lib/filter'
import { getDefaultTimeRange } from '../lib/utils'
import type { CommonLogFilters } from '../types'
import { CompactDateTimeRangePicker } from './compact-date-time-range-picker'
import { useUsageLogsContext } from './usage-logs-provider'
const route = getRouteApi('/_authenticated/usage-logs/$section')
interface CommonLogsFilterBarProps {
stats?: ReactNode
viewOptions?: ReactNode
}
export function CommonLogsFilterBar({
stats,
viewOptions,
}: CommonLogsFilterBarProps) {
const { t } = useTranslation()
const navigate = useNavigate()
const searchParams = route.useSearch()
const isAdmin = useIsAdmin()
const { sensitiveVisible, setSensitiveVisible } = useUsageLogsContext()
const [expanded, setExpanded] = useState(false)
const [filters, setFilters] = useState<CommonLogFilters>(() => {
const { start, end } = getDefaultTimeRange()
return { startTime: start, endTime: end }
})
const [logType, setLogType] = useState<string>('')
useEffect(() => {
const next: Partial<CommonLogFilters> = {}
if (searchParams.startTime)
next.startTime = new Date(searchParams.startTime)
if (searchParams.endTime) next.endTime = new Date(searchParams.endTime)
if (searchParams.channel) next.channel = String(searchParams.channel)
if (searchParams.model) next.model = searchParams.model
if (searchParams.token) next.token = searchParams.token
if (searchParams.group) next.group = searchParams.group
if (searchParams.username) next.username = searchParams.username
if (searchParams.requestId) next.requestId = searchParams.requestId
if (Object.keys(next).length > 0) {
setFilters((prev) => ({ ...prev, ...next }))
}
const typeArr = searchParams.type
if (Array.isArray(typeArr) && typeArr.length === 1) {
setLogType(typeArr[0])
}
}, [
searchParams.startTime,
searchParams.endTime,
searchParams.channel,
searchParams.model,
searchParams.token,
searchParams.group,
searchParams.username,
searchParams.requestId,
searchParams.type,
])
const handleChange = useCallback(
(field: keyof CommonLogFilters, value: Date | string | undefined) => {
setFilters((prev) => ({ ...prev, [field]: value }))
},
[]
)
const handleApply = useCallback(() => {
const filterParams = buildSearchParams(filters, 'common')
navigate({
to: '/usage-logs/$section',
params: { section: 'common' },
search: (prev: Record<string, unknown>) => ({
...prev,
...filterParams,
...(logType ? { type: [logType] } : { type: undefined }),
page: 1,
}),
})
}, [filters, logType, navigate])
const handleReset = useCallback(() => {
const { start, end } = getDefaultTimeRange()
const resetFilters: CommonLogFilters = { startTime: start, endTime: end }
setFilters(resetFilters)
setLogType('')
navigate({
to: '/usage-logs/$section',
params: { section: 'common' },
search: {
page: 1,
startTime: start.getTime(),
endTime: end.getTime(),
},
})
}, [navigate])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter') handleApply()
},
[handleApply]
)
const hasExpandedFilters =
!!filters.token ||
!!filters.username ||
!!filters.channel ||
!!filters.requestId
return (
<div className='space-y-3'>
{/* Primary filter row */}
<div className='grid grid-cols-2 gap-2 sm:grid-cols-4 lg:grid-cols-[minmax(280px,2fr)_minmax(140px,1fr)_minmax(120px,1fr)_minmax(120px,0.8fr)_auto]'>
<CompactDateTimeRangePicker
start={filters.startTime}
end={filters.endTime}
onChange={({ start, end }) => {
handleChange('startTime', start)
handleChange('endTime', end)
}}
className='col-span-2 lg:col-span-1'
/>
<Input
placeholder={t('Model Name')}
value={filters.model || ''}
onChange={(e) => handleChange('model', e.target.value)}
onKeyDown={handleKeyDown}
className='h-9'
/>
<Input
placeholder={t('Group')}
type={sensitiveVisible ? 'text' : 'password'}
value={filters.group || ''}
onChange={(e) => handleChange('group', e.target.value)}
onKeyDown={handleKeyDown}
className='h-9'
/>
<Select
value={logType}
onValueChange={(v) => setLogType(v === 'all' ? '' : v)}
>
<SelectTrigger className='h-9'>
<SelectValue placeholder={t('All Types')} />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>{t('All Types')}</SelectItem>
{LOG_TYPES.map((type) => (
<SelectItem key={type.value} value={String(type.value)}>
{t(type.label)}
</SelectItem>
))}
</SelectContent>
</Select>
<button
type='button'
className={cn(
'text-muted-foreground hover:text-foreground flex h-9 items-center gap-1 rounded-md px-2 text-xs transition-colors',
hasExpandedFilters && !expanded && 'text-primary'
)}
onClick={() => setExpanded((p) => !p)}
>
<ChevronDown
className={cn(
'size-3.5 transition-transform duration-200',
expanded && 'rotate-180'
)}
/>
{expanded ? t('Collapse') : t('Expand')}
</button>
</div>
{/* Expandable filter row */}
<div
className={cn(
'grid gap-2 overflow-hidden transition-all duration-200',
expanded
? 'grid-rows-[1fr] opacity-100'
: 'grid-rows-[0fr] opacity-0'
)}
>
<div className='min-h-0 overflow-hidden'>
<div className='grid grid-cols-2 gap-2 sm:grid-cols-4'>
<Input
placeholder={t('Token Name')}
type={sensitiveVisible ? 'text' : 'password'}
value={filters.token || ''}
onChange={(e) => handleChange('token', e.target.value)}
onKeyDown={handleKeyDown}
className='h-9'
/>
{isAdmin && (
<Input
placeholder={t('Username')}
type={sensitiveVisible ? 'text' : 'password'}
value={filters.username || ''}
onChange={(e) => handleChange('username', e.target.value)}
onKeyDown={handleKeyDown}
className='h-9'
/>
)}
{isAdmin && (
<Input
placeholder={t('Channel ID')}
value={filters.channel || ''}
onChange={(e) => handleChange('channel', e.target.value)}
onKeyDown={handleKeyDown}
className='h-9'
/>
)}
<Input
placeholder={t('Request ID')}
value={filters.requestId || ''}
onChange={(e) => handleChange('requestId', e.target.value)}
onKeyDown={handleKeyDown}
className='h-9'
/>
</div>
</div>
</div>
{/* Actions row */}
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
<div className='flex min-w-0 flex-wrap items-center gap-2 sm:gap-3'>
{stats && <div className='min-w-0'>{stats}</div>}
<button
type='button'
className='text-muted-foreground hover:text-foreground inline-flex h-6 items-center gap-1 rounded px-1 text-xs transition-colors'
title={sensitiveVisible ? t('Hide') : t('Show')}
aria-label={sensitiveVisible ? t('Hide') : t('Show')}
onClick={() => setSensitiveVisible(!sensitiveVisible)}
>
{sensitiveVisible ? (
<Eye className='size-3.5' />
) : (
<EyeOff className='size-3.5' />
)}
</button>
</div>
<div className='flex shrink-0 items-center gap-2 self-end sm:self-auto'>
<Button
variant='outline'
size='sm'
className='h-8'
onClick={handleReset}
>
<RotateCcw className='size-3.5' />
{t('Reset')}
</Button>
<Button size='sm' className='h-8' onClick={handleApply}>
<Search className='size-3.5' />
{t('Search')}
</Button>
{viewOptions}
</div>
</div>
</div>
)
}

View File

@ -5,10 +5,10 @@ import { formatLogQuota } from '@/lib/format'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useIsAdmin } from '@/hooks/use-admin' import { useIsAdmin } from '@/hooks/use-admin'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { dotColorMap, textColorMap } from '@/components/status-badge'
import { getLogStats, getUserLogStats } from '../api' import { getLogStats, getUserLogStats } from '../api'
import { DEFAULT_LOG_STATS } from '../constants' import { DEFAULT_LOG_STATS } from '../constants'
import { buildApiParams } from '../lib/utils' import { buildApiParams } from '../lib/utils'
import { useUsageLogsContext } from './usage-logs-provider'
const route = getRouteApi('/_authenticated/usage-logs/$section') const route = getRouteApi('/_authenticated/usage-logs/$section')
@ -16,6 +16,7 @@ export function CommonLogsStats() {
const { t } = useTranslation() const { t } = useTranslation()
const isAdmin = useIsAdmin() const isAdmin = useIsAdmin()
const searchParams = route.useSearch() const searchParams = route.useSearch()
const { sensitiveVisible } = useUsageLogsContext()
const { data: stats, isLoading } = useQuery({ const { data: stats, isLoading } = useQuery({
queryKey: ['usage-logs-stats', isAdmin, searchParams], queryKey: ['usage-logs-stats', isAdmin, searchParams],
@ -41,29 +42,42 @@ export function CommonLogsStats() {
if (isLoading) { if (isLoading) {
return ( return (
<div className='flex items-center gap-2'> <div className='flex items-center gap-1.5'>
<Skeleton className='h-6 w-[126px] rounded-md' /> <Skeleton className='h-6 w-[126px] rounded-md' />
<Skeleton className='h-6 w-[58px] rounded-md' /> <Skeleton className='h-6 w-[76px] rounded-md' />
<Skeleton className='h-6 w-[58px] rounded-md' /> <Skeleton className='h-6 w-[92px] rounded-md' />
</div> </div>
) )
} }
const tagClass =
'inline-flex h-6 items-center rounded-md border px-2.5 text-xs font-medium shadow-xs'
return ( return (
<div className='flex items-center gap-1.5 text-xs font-medium'> <div className='flex flex-wrap items-center gap-1.5'>
<span <span
className={cn('size-1.5 shrink-0 rounded-full', dotColorMap.blue)} className={cn(
aria-hidden='true' tagClass,
/> 'border-blue-200/70 bg-blue-50 text-blue-700 dark:border-blue-500/20 dark:bg-blue-500/10 dark:text-blue-300'
<span className={cn(textColorMap.blue)}> )}
{t('Usage')}: {formatLogQuota(stats?.quota || 0)} >
{t('Usage')}:{' '}
{sensitiveVisible ? formatLogQuota(stats?.quota || 0) : '••••'}
</span> </span>
<span className='text-muted-foreground/30'>·</span> <span
<span className={cn(textColorMap.pink)}> className={cn(
tagClass,
'border-pink-200/70 bg-pink-50 text-pink-700 dark:border-pink-500/20 dark:bg-pink-500/10 dark:text-pink-300'
)}
>
{t('RPM')}: {stats?.rpm || 0} {t('RPM')}: {stats?.rpm || 0}
</span> </span>
<span className='text-muted-foreground/30'>·</span> <span
<span className='text-muted-foreground'> className={cn(
tagClass,
'border-border bg-background text-muted-foreground'
)}
>
{t('TPM')}: {stats?.tpm || 0} {t('TPM')}: {stats?.tpm || 0}
</span> </span>
</div> </div>

View File

@ -0,0 +1,199 @@
import { useMemo, useState } from 'react'
import { CalendarDays } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import dayjs from '@/lib/dayjs'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
interface CompactDateTimeRangePickerProps {
start?: Date
end?: Date
onChange: (range: { start?: Date; end?: Date }) => void
className?: string
}
function toInputValue(date?: Date): string {
return date ? dayjs(date).format('YYYY-MM-DDTHH:mm') : ''
}
function fromInputValue(value: string): Date | undefined {
if (!value) return undefined
const date = new Date(value)
return Number.isNaN(date.getTime()) ? undefined : date
}
export function CompactDateTimeRangePicker({
start,
end,
onChange,
className,
}: CompactDateTimeRangePickerProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [draftStart, setDraftStart] = useState(toInputValue(start))
const [draftEnd, setDraftEnd] = useState(toInputValue(end))
const label = useMemo(() => {
if (!start && !end) return t('Date Range')
const startText = start ? dayjs(start).format('YYYY-MM-DD HH:mm:ss') : '-'
const endText = end ? dayjs(end).format('YYYY-MM-DD HH:mm:ss') : '-'
return `${startText} ~ ${endText}`
}, [end, start, t])
const handleOpenChange = (nextOpen: boolean) => {
if (nextOpen) {
setDraftStart(toInputValue(start))
setDraftEnd(toInputValue(end))
}
setOpen(nextOpen)
}
const applyDraft = () => {
onChange({
start: fromInputValue(draftStart),
end: fromInputValue(draftEnd),
})
setOpen(false)
}
const applyPreset = (kind: 'today' | '7d' | 'week' | '30d' | 'month') => {
const now = dayjs()
const presets = {
today: {
start: now.startOf('day').toDate(),
end: now.endOf('day').toDate(),
},
'7d': {
start: now.subtract(6, 'day').startOf('day').toDate(),
end: now.endOf('day').toDate(),
},
week: {
start: now.startOf('week').toDate(),
end: now.endOf('week').toDate(),
},
'30d': {
start: now.subtract(29, 'day').startOf('day').toDate(),
end: now.endOf('day').toDate(),
},
month: {
start: now.startOf('month').toDate(),
end: now.endOf('month').toDate(),
},
}
const range = presets[kind]
setDraftStart(toInputValue(range.start))
setDraftEnd(toInputValue(range.end))
onChange(range)
setOpen(false)
}
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
type='button'
variant='outline'
className={cn(
'h-9 w-full justify-start gap-2 px-3 font-mono text-xs font-normal',
!start && !end && 'text-muted-foreground',
className
)}
>
<CalendarDays className='text-muted-foreground size-4 shrink-0' />
<span className='truncate'>{label}</span>
</Button>
</PopoverTrigger>
<PopoverContent align='start' className='w-[min(520px,calc(100vw-2rem))] p-3'>
<div className='space-y-3'>
<div className='grid gap-2 sm:grid-cols-[1fr_auto_1fr] sm:items-end'>
<div className='space-y-1.5'>
<div className='text-muted-foreground text-xs'>
{t('Start Time')}
</div>
<Input
type='datetime-local'
value={draftStart}
onChange={(e) => setDraftStart(e.target.value)}
className='h-8 font-mono text-xs'
/>
</div>
<span className='text-muted-foreground hidden pb-2 text-xs sm:block'>
~
</span>
<div className='space-y-1.5'>
<div className='text-muted-foreground text-xs'>
{t('End Time')}
</div>
<Input
type='datetime-local'
value={draftEnd}
onChange={(e) => setDraftEnd(e.target.value)}
className='h-8 font-mono text-xs'
/>
</div>
</div>
<div className='flex flex-wrap gap-1.5'>
<Button
type='button'
variant='secondary'
size='sm'
className='h-7 flex-1 px-2 text-xs'
onClick={() => applyPreset('today')}
>
{t('Today')}
</Button>
<Button
type='button'
variant='secondary'
size='sm'
className='h-7 flex-1 px-2 text-xs'
onClick={() => applyPreset('7d')}
>
{t('7 Days')}
</Button>
<Button
type='button'
variant='secondary'
size='sm'
className='h-7 flex-1 px-2 text-xs'
onClick={() => applyPreset('week')}
>
{t('This week')}
</Button>
<Button
type='button'
variant='secondary'
size='sm'
className='h-7 flex-1 px-2 text-xs'
onClick={() => applyPreset('30d')}
>
{t('30 Days')}
</Button>
<Button
type='button'
variant='secondary'
size='sm'
className='h-7 flex-1 px-2 text-xs'
onClick={() => applyPreset('month')}
>
{t('This month')}
</Button>
</div>
<div className='flex justify-end'>
<Button size='sm' className='h-8' onClick={applyDraft}>
{t('Confirm')}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
)
}

View File

@ -11,6 +11,8 @@ interface UsageLogsContextValue {
setAffinityTarget: (target: ChannelAffinityInfo | null) => void setAffinityTarget: (target: ChannelAffinityInfo | null) => void
affinityDialogOpen: boolean affinityDialogOpen: boolean
setAffinityDialogOpen: (open: boolean) => void setAffinityDialogOpen: (open: boolean) => void
sensitiveVisible: boolean
setSensitiveVisible: (visible: boolean) => void
} }
const UsageLogsContext = createContext<UsageLogsContextValue | undefined>( const UsageLogsContext = createContext<UsageLogsContextValue | undefined>(
@ -23,6 +25,7 @@ export function UsageLogsProvider({ children }: { children: ReactNode }) {
const [affinityTarget, setAffinityTarget] = const [affinityTarget, setAffinityTarget] =
useState<ChannelAffinityInfo | null>(null) useState<ChannelAffinityInfo | null>(null)
const [affinityDialogOpen, setAffinityDialogOpen] = useState(false) const [affinityDialogOpen, setAffinityDialogOpen] = useState(false)
const [sensitiveVisible, setSensitiveVisible] = useState(true)
return ( return (
<UsageLogsContext.Provider <UsageLogsContext.Provider
@ -35,6 +38,8 @@ export function UsageLogsProvider({ children }: { children: ReactNode }) {
setAffinityTarget, setAffinityTarget,
affinityDialogOpen, affinityDialogOpen,
setAffinityDialogOpen, setAffinityDialogOpen,
sensitiveVisible,
setSensitiveVisible,
}} }}
> >
{children} {children}

View File

@ -28,15 +28,18 @@ import {
import { import {
DataTablePagination, DataTablePagination,
DataTableToolbar, DataTableToolbar,
DataTableViewOptions,
TableSkeleton, TableSkeleton,
TableEmpty, TableEmpty,
MobileCardList, MobileCardList,
} from '@/components/data-table' } from '@/components/data-table'
import { PageFooterPortal } from '@/components/layout' import { PageFooterPortal } from '@/components/layout'
import { LOG_TYPE_FILTERS, DEFAULT_LOGS_DATA } from '../constants' import { DEFAULT_LOGS_DATA } from '../constants'
import { useColumnsByCategory } from '../lib/columns' import { useColumnsByCategory } from '../lib/columns'
import { fetchLogsByCategory } from '../lib/utils' import { fetchLogsByCategory } from '../lib/utils'
import type { LogCategory } from '../types' import type { LogCategory } from '../types'
import { CommonLogsFilterBar } from './common-logs-filter-bar'
import { CommonLogsStats } from './common-logs-stats'
const route = getRouteApi('/_authenticated/usage-logs/$section') const route = getRouteApi('/_authenticated/usage-logs/$section')
@ -147,25 +150,23 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
ensurePageInRange(pageCount) ensurePageInRange(pageCount)
}, [pageCount, ensurePageInRange]) }, [pageCount, ensurePageInRange])
const filters =
logCategory === 'common'
? [
{
columnId: 'created_at',
title: t('Log Type'),
options: LOG_TYPE_FILTERS.map((opt) => ({
value: opt.value,
label: t(opt.label),
})),
singleSelect: true,
},
]
: []
return ( return (
<> <>
<div className='space-y-4'> <div className='space-y-4'>
<DataTableToolbar table={table} filters={filters} customSearch={null} /> {logCategory === 'common' ? (
<div className='rounded-md border bg-card/50 p-3 shadow-xs'>
<CommonLogsFilterBar
stats={<CommonLogsStats />}
viewOptions={<DataTableViewOptions table={table} />}
/>
</div>
) : (
<DataTableToolbar
table={table}
filters={[]}
customSearch={null}
/>
)}
{isMobile ? ( {isMobile ? (
<MobileCardList <MobileCardList
table={table} table={table}

View File

@ -2,7 +2,6 @@ import { getRouteApi } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { SectionPageLayout } from '@/components/layout' import { SectionPageLayout } from '@/components/layout'
import { CacheStatsDialog } from '@/features/system-settings/general/channel-affinity/cache-stats-dialog' import { CacheStatsDialog } from '@/features/system-settings/general/channel-affinity/cache-stats-dialog'
import { CommonLogsStats } from './components/common-logs-stats'
import { UserInfoDialog } from './components/dialogs/user-info-dialog' import { UserInfoDialog } from './components/dialogs/user-info-dialog'
import { UsageLogsPrimaryButtons } from './components/usage-logs-primary-buttons' import { UsageLogsPrimaryButtons } from './components/usage-logs-primary-buttons'
import { import {
@ -60,8 +59,9 @@ function UsageLogsContent() {
{description} {description}
</SectionPageLayout.Description> </SectionPageLayout.Description>
<SectionPageLayout.Actions> <SectionPageLayout.Actions>
{activeCategory === 'common' && <CommonLogsStats />} {activeCategory !== 'common' && (
<UsageLogsPrimaryButtons logCategory={activeCategory} /> <UsageLogsPrimaryButtons logCategory={activeCategory} />
)}
</SectionPageLayout.Actions> </SectionPageLayout.Actions>
<SectionPageLayout.Content> <SectionPageLayout.Content>
<UsageLogsTable logCategory={activeCategory} /> <UsageLogsTable logCategory={activeCategory} />

View File

@ -32,10 +32,9 @@ export function UserQuotaDialog(props: UserQuotaDialogProps) {
const [amount, setAmount] = useState('') const [amount, setAmount] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const { config: currencyConfig, meta: currencyMeta } = getCurrencyDisplay() const { meta: currencyMeta } = getCurrencyDisplay()
const currencyLabel = getCurrencyLabel() const currencyLabel = getCurrencyLabel()
const tokensOnly = const tokensOnly = currencyMeta.kind === 'tokens'
!currencyConfig.displayInCurrency || currencyMeta.kind === 'tokens'
const amountValue = parseFloat(amount) || 0 const amountValue = parseFloat(amount) || 0
const quotaValue = parseQuotaFromDollars(Math.abs(amountValue)) const quotaValue = parseQuotaFromDollars(Math.abs(amountValue))

View File

@ -95,10 +95,9 @@ export function UsersMutateDrawer({
} }
}, [open, isUpdate, currentRow, form]) }, [open, isUpdate, currentRow, form])
const { config: currencyConfig, meta: currencyMeta } = getCurrencyDisplay() const { meta: currencyMeta } = getCurrencyDisplay()
const currencyLabel = getCurrencyLabel() const currencyLabel = getCurrencyLabel()
const tokensOnly = const tokensOnly = currencyMeta.kind === 'tokens'
!currencyConfig.displayInCurrency || currencyMeta.kind === 'tokens'
const currentQuotaRaw = form.watch('quota_dollars') || 0 const currentQuotaRaw = form.watch('quota_dollars') || 0

View File

@ -331,6 +331,7 @@
"Are you sure?": "Are you sure?", "Are you sure?": "Are you sure?",
"Args (space separated)": "Args (space separated)", "Args (space separated)": "Args (space separated)",
"Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.": "Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.", "Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.": "Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.",
"Area Chart": "Area Chart",
"Asc": "Asc", "Asc": "Asc",
"Ask anything": "Ask anything", "Ask anything": "Ask anything",
"Async task refund": "Async task refund", "Async task refund": "Async task refund",
@ -407,6 +408,7 @@
"Balance queried successfully": "Balance queried successfully", "Balance queried successfully": "Balance queried successfully",
"Balance updated successfully": "Balance updated successfully", "Balance updated successfully": "Balance updated successfully",
"Balance updated: {{balance}}": "Balance updated: {{balance}}", "Balance updated: {{balance}}": "Balance updated: {{balance}}",
"Bar Chart": "Bar Chart",
"Bark Push URL": "Bark Push URL", "Bark Push URL": "Bark Push URL",
"Base address provided by your Epay service": "Base address provided by your Epay service", "Base address provided by your Epay service": "Base address provided by your Epay service",
"Base amount. Actual deduction = base amount × system group rate.": "Base amount. Actual deduction = base amount × system group rate.", "Base amount. Actual deduction = base amount × system group rate.": "Base amount. Actual deduction = base amount × system group rate.",
@ -497,6 +499,8 @@
"Calculated price: ${{price}} per 1M tokens": "Calculated price: ${{price}} per 1M tokens", "Calculated price: ${{price}} per 1M tokens": "Calculated price: ${{price}} per 1M tokens",
"Calculated ratio: {{ratio}}": "Calculated ratio: {{ratio}}", "Calculated ratio: {{ratio}}": "Calculated ratio: {{ratio}}",
"Calculating...": "Calculating...", "Calculating...": "Calculating...",
"Call Count Distribution": "Call Count Distribution",
"Call Count Ranking": "Call Count Ranking",
"Call Proportion": "Call Proportion", "Call Proportion": "Call Proportion",
"Call Trend": "Call Trend", "Call Trend": "Call Trend",
"Callback address": "Callback address", "Callback address": "Callback address",
@ -3236,6 +3240,7 @@
"this token group": "this token group", "this token group": "this token group",
"this user group": "this user group", "this user group": "this user group",
"This user has no bindings": "This user has no bindings", "This user has no bindings": "This user has no bindings",
"This week": "This week",
"This will append 2 template rules (Codex CLI and Claude CLI) to the existing rule list.": "This will append 2 template rules (Codex CLI and Claude CLI) to the existing rule list.", "This will append 2 template rules (Codex CLI and Claude CLI) to the existing rule list.": "This will append 2 template rules (Codex CLI and Claude CLI) to the existing rule list.",
"This will clear custom pricing ratios and revert to upstream defaults.": "This will clear custom pricing ratios and revert to upstream defaults.", "This will clear custom pricing ratios and revert to upstream defaults.": "This will clear custom pricing ratios and revert to upstream defaults.",
"This will delete all": "This will delete all", "This will delete all": "This will delete all",

View File

@ -331,6 +331,7 @@
"Are you sure?": "Êtes-vous sûr ?", "Are you sure?": "Êtes-vous sûr ?",
"Args (space separated)": "Arguments (séparés par des espaces)", "Args (space separated)": "Arguments (séparés par des espaces)",
"Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.": "Tableau de préréglages de clients de chat. Chaque élément est un objet avec une paire clé-valeur : nom du client et son URL.", "Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.": "Tableau de préréglages de clients de chat. Chaque élément est un objet avec une paire clé-valeur : nom du client et son URL.",
"Area Chart": "Graphique en aires",
"Asc": "Asc", "Asc": "Asc",
"Ask anything": "Demandez n'importe quoi", "Ask anything": "Demandez n'importe quoi",
"Async task refund": "Remboursement de tâche asynchrone", "Async task refund": "Remboursement de tâche asynchrone",
@ -407,6 +408,7 @@
"Balance queried successfully": "Solde interrogé avec succès", "Balance queried successfully": "Solde interrogé avec succès",
"Balance updated successfully": "Solde mis à jour avec succès", "Balance updated successfully": "Solde mis à jour avec succès",
"Balance updated: {{balance}}": "Solde mis à jour : {{balance}}", "Balance updated: {{balance}}": "Solde mis à jour : {{balance}}",
"Bar Chart": "Graphique en barres",
"Bark Push URL": "URL de notification Bark", "Bark Push URL": "URL de notification Bark",
"Base address provided by your Epay service": "Adresse de base fournie par votre service Epay", "Base address provided by your Epay service": "Adresse de base fournie par votre service Epay",
"Base amount. Actual deduction = base amount × system group rate.": "Montant de base. Déduction réelle = montant de base × taux du groupe système.", "Base amount. Actual deduction = base amount × system group rate.": "Montant de base. Déduction réelle = montant de base × taux du groupe système.",
@ -497,6 +499,8 @@
"Calculated price: ${{price}} per 1M tokens": "Prix calculé : ${{price}} par 1M tokens", "Calculated price: ${{price}} per 1M tokens": "Prix calculé : ${{price}} par 1M tokens",
"Calculated ratio: {{ratio}}": "Ratio calculé : {{ratio}}", "Calculated ratio: {{ratio}}": "Ratio calculé : {{ratio}}",
"Calculating...": "Calcul en cours...", "Calculating...": "Calcul en cours...",
"Call Count Distribution": "Distribution du nombre d'appels",
"Call Count Ranking": "Classement du nombre d'appels",
"Call Proportion": "Proportion d'appels", "Call Proportion": "Proportion d'appels",
"Call Trend": "Tendance des appels", "Call Trend": "Tendance des appels",
"Callback address": "Adresse de rappel", "Callback address": "Adresse de rappel",
@ -3236,6 +3240,7 @@
"this token group": "ce groupe de jetons", "this token group": "ce groupe de jetons",
"this user group": "ce groupe d'utilisateurs", "this user group": "ce groupe d'utilisateurs",
"This user has no bindings": "Cet utilisateur n'a aucune liaison", "This user has no bindings": "Cet utilisateur n'a aucune liaison",
"This week": "Cette semaine",
"This will append 2 template rules (Codex CLI and Claude CLI) to the existing rule list.": "Cela ajoutera 2 règles modèles (Codex CLI et Claude CLI) à la liste de règles existante.", "This will append 2 template rules (Codex CLI and Claude CLI) to the existing rule list.": "Cela ajoutera 2 règles modèles (Codex CLI et Claude CLI) à la liste de règles existante.",
"This will clear custom pricing ratios and revert to upstream defaults.": "Cela effacera les ratios de tarification personnalisés et rétablira les valeurs par défaut du fournisseur.", "This will clear custom pricing ratios and revert to upstream defaults.": "Cela effacera les ratios de tarification personnalisés et rétablira les valeurs par défaut du fournisseur.",
"This will delete all": "Cela supprimera tout", "This will delete all": "Cela supprimera tout",

View File

@ -331,6 +331,7 @@
"Are you sure?": "よろしいですか?", "Are you sure?": "よろしいですか?",
"Args (space separated)": "引数 (スペース区切り)", "Args (space separated)": "引数 (スペース区切り)",
"Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.": "チャットクライアントプリセットの配列。各項目は、クライアント名とそのURLという1つのキーと値のペアを持つオブジェクトです。", "Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.": "チャットクライアントプリセットの配列。各項目は、クライアント名とそのURLという1つのキーと値のペアを持つオブジェクトです。",
"Area Chart": "面グラフ",
"Asc": "昇順", "Asc": "昇順",
"Ask anything": "何でも質問する", "Ask anything": "何でも質問する",
"Async task refund": "非同期タスク返金", "Async task refund": "非同期タスク返金",
@ -407,6 +408,7 @@
"Balance queried successfully": "残高の取得に成功しました", "Balance queried successfully": "残高の取得に成功しました",
"Balance updated successfully": "残高が正常に更新されました", "Balance updated successfully": "残高が正常に更新されました",
"Balance updated: {{balance}}": "残高更新:{{balance}}", "Balance updated: {{balance}}": "残高更新:{{balance}}",
"Bar Chart": "棒グラフ",
"Bark Push URL": "BarkプッシュURL", "Bark Push URL": "BarkプッシュURL",
"Base address provided by your Epay service": "Epayサービスによって提供されるベースアドレス", "Base address provided by your Epay service": "Epayサービスによって提供されるベースアドレス",
"Base amount. Actual deduction = base amount × system group rate.": "基本金額。実際の控除 = 基本金額 × システムグループ倍率。", "Base amount. Actual deduction = base amount × system group rate.": "基本金額。実際の控除 = 基本金額 × システムグループ倍率。",
@ -497,6 +499,8 @@
"Calculated price: ${{price}} per 1M tokens": "計算価格:${{price}} / 1M トークン", "Calculated price: ${{price}} per 1M tokens": "計算価格:${{price}} / 1M トークン",
"Calculated ratio: {{ratio}}": "計算倍率:{{ratio}}", "Calculated ratio: {{ratio}}": "計算倍率:{{ratio}}",
"Calculating...": "計算中...", "Calculating...": "計算中...",
"Call Count Distribution": "呼び出し回数分布",
"Call Count Ranking": "呼び出し回数ランキング",
"Call Proportion": "呼び出し比率", "Call Proportion": "呼び出し比率",
"Call Trend": "呼び出し傾向", "Call Trend": "呼び出し傾向",
"Callback address": "コールバックアドレス", "Callback address": "コールバックアドレス",
@ -3236,6 +3240,7 @@
"this token group": "このトークングループ", "this token group": "このトークングループ",
"this user group": "このユーザーグループ", "this user group": "このユーザーグループ",
"This user has no bindings": "このユーザーには連携がありません", "This user has no bindings": "このユーザーには連携がありません",
"This week": "今週",
"This will append 2 template rules (Codex CLI and Claude CLI) to the existing rule list.": "既存のルールリストに2つのテンプレートルールCodex CLIとClaude CLIを追加します。", "This will append 2 template rules (Codex CLI and Claude CLI) to the existing rule list.": "既存のルールリストに2つのテンプレートルールCodex CLIとClaude CLIを追加します。",
"This will clear custom pricing ratios and revert to upstream defaults.": "これにより、カスタム料金比率がクリアされ、上位のデフォルトに戻ります。", "This will clear custom pricing ratios and revert to upstream defaults.": "これにより、カスタム料金比率がクリアされ、上位のデフォルトに戻ります。",
"This will delete all": "これによりすべて削除されます", "This will delete all": "これによりすべて削除されます",

View File

@ -331,6 +331,7 @@
"Are you sure?": "Вы уверены?", "Are you sure?": "Вы уверены?",
"Args (space separated)": "Аргументы (разделённые пробелами)", "Args (space separated)": "Аргументы (разделённые пробелами)",
"Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.": "Массив предустановок чат-клиентов. Каждый элемент представляет собой объект с одной парой ключ-значение: имя клиента и его URL.", "Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.": "Массив предустановок чат-клиентов. Каждый элемент представляет собой объект с одной парой ключ-значение: имя клиента и его URL.",
"Area Chart": "Диаграмма с областями",
"Asc": "По возрастанию", "Asc": "По возрастанию",
"Ask anything": "Спросите что угодно", "Ask anything": "Спросите что угодно",
"Async task refund": "Возврат асинхронной задачи", "Async task refund": "Возврат асинхронной задачи",
@ -407,6 +408,7 @@
"Balance queried successfully": "Баланс успешно запрошен", "Balance queried successfully": "Баланс успешно запрошен",
"Balance updated successfully": "Баланс успешно обновлён", "Balance updated successfully": "Баланс успешно обновлён",
"Balance updated: {{balance}}": "Баланс обновлён: {{balance}}", "Balance updated: {{balance}}": "Баланс обновлён: {{balance}}",
"Bar Chart": "Столбчатая диаграмма",
"Bark Push URL": "URL для push-уведомлений Bark", "Bark Push URL": "URL для push-уведомлений Bark",
"Base address provided by your Epay service": "Базовый адрес, предоставленный вашим сервисом Epay", "Base address provided by your Epay service": "Базовый адрес, предоставленный вашим сервисом Epay",
"Base amount. Actual deduction = base amount × system group rate.": "Базовая сумма. Фактический вычет = базовая сумма × коэффициент группы.", "Base amount. Actual deduction = base amount × system group rate.": "Базовая сумма. Фактический вычет = базовая сумма × коэффициент группы.",
@ -497,6 +499,8 @@
"Calculated price: ${{price}} per 1M tokens": "Расчётная цена: ${{price}} за 1М токенов", "Calculated price: ${{price}} per 1M tokens": "Расчётная цена: ${{price}} за 1М токенов",
"Calculated ratio: {{ratio}}": "Расчётный коэффициент: {{ratio}}", "Calculated ratio: {{ratio}}": "Расчётный коэффициент: {{ratio}}",
"Calculating...": "Вычисление...", "Calculating...": "Вычисление...",
"Call Count Distribution": "Распределение количества вызовов",
"Call Count Ranking": "Рейтинг по количеству вызовов",
"Call Proportion": "Доля вызовов", "Call Proportion": "Доля вызовов",
"Call Trend": "Тенденция вызовов", "Call Trend": "Тенденция вызовов",
"Callback address": "Адрес обратного вызова", "Callback address": "Адрес обратного вызова",
@ -3236,6 +3240,7 @@
"this token group": "эта группа токенов", "this token group": "эта группа токенов",
"this user group": "эта группа пользователей", "this user group": "эта группа пользователей",
"This user has no bindings": "У этого пользователя нет привязок", "This user has no bindings": "У этого пользователя нет привязок",
"This week": "На этой неделе",
"This will append 2 template rules (Codex CLI and Claude CLI) to the existing rule list.": "Это добавит 2 шаблонных правила (Codex CLI и Claude CLI) к существующему списку правил.", "This will append 2 template rules (Codex CLI and Claude CLI) to the existing rule list.": "Это добавит 2 шаблонных правила (Codex CLI и Claude CLI) к существующему списку правил.",
"This will clear custom pricing ratios and revert to upstream defaults.": "Это очистит пользовательские ценовые коэффициенты и вернет к стандартным значениям поставщика.", "This will clear custom pricing ratios and revert to upstream defaults.": "Это очистит пользовательские ценовые коэффициенты и вернет к стандартным значениям поставщика.",
"This will delete all": "Это удалит все", "This will delete all": "Это удалит все",

View File

@ -331,6 +331,7 @@
"Are you sure?": "Bạn có chắc không?", "Are you sure?": "Bạn có chắc không?",
"Args (space separated)": "Đối số (cách nhau bằng khoảng trắng)", "Args (space separated)": "Đối số (cách nhau bằng khoảng trắng)",
"Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.": "Mảng các thiết lập sẵn của ứng dụng trò chuyện. Mỗi mục là một đối tượng với", "Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.": "Mảng các thiết lập sẵn của ứng dụng trò chuyện. Mỗi mục là một đối tượng với",
"Area Chart": "Biểu đồ vùng",
"Asc": "Asc", "Asc": "Asc",
"Ask anything": "Hỏi gì cũng được", "Ask anything": "Hỏi gì cũng được",
"Async task refund": "Hoàn tiền tác vụ bất đồng bộ", "Async task refund": "Hoàn tiền tác vụ bất đồng bộ",
@ -407,6 +408,7 @@
"Balance queried successfully": "Truy vấn số dư thành công", "Balance queried successfully": "Truy vấn số dư thành công",
"Balance updated successfully": "Đã cập nhật số dư thành công", "Balance updated successfully": "Đã cập nhật số dư thành công",
"Balance updated: {{balance}}": "Số dư đã cập nhật: {{balance}}", "Balance updated: {{balance}}": "Số dư đã cập nhật: {{balance}}",
"Bar Chart": "Biểu đồ cột",
"Bark Push URL": "URL đẩy Bark", "Bark Push URL": "URL đẩy Bark",
"Base address provided by your Epay service": "Địa chỉ cơ sở được cung cấp bởi dịch vụ Epay của bạn", "Base address provided by your Epay service": "Địa chỉ cơ sở được cung cấp bởi dịch vụ Epay của bạn",
"Base amount. Actual deduction = base amount × system group rate.": "Số tiền cơ sở. Số tiền trừ thực tế = số tiền cơ sở × tỷ lệ nhóm hệ thống.", "Base amount. Actual deduction = base amount × system group rate.": "Số tiền cơ sở. Số tiền trừ thực tế = số tiền cơ sở × tỷ lệ nhóm hệ thống.",
@ -497,6 +499,8 @@
"Calculated price: ${{price}} per 1M tokens": "Giá tính toán: ${{price}} mỗi 1M token", "Calculated price: ${{price}} per 1M tokens": "Giá tính toán: ${{price}} mỗi 1M token",
"Calculated ratio: {{ratio}}": "Tỷ lệ tính toán: {{ratio}}", "Calculated ratio: {{ratio}}": "Tỷ lệ tính toán: {{ratio}}",
"Calculating...": "Đang tính...", "Calculating...": "Đang tính...",
"Call Count Distribution": "Phân bổ số lượt gọi",
"Call Count Ranking": "Xếp hạng số lượt gọi",
"Call Proportion": "Tỷ lệ cuộc gọi", "Call Proportion": "Tỷ lệ cuộc gọi",
"Call Trend": "Xu hướng cuộc gọi", "Call Trend": "Xu hướng cuộc gọi",
"Callback address": "Địa chỉ callback", "Callback address": "Địa chỉ callback",
@ -3236,6 +3240,7 @@
"this token group": "nhóm token này", "this token group": "nhóm token này",
"this user group": "nhóm người dùng này", "this user group": "nhóm người dùng này",
"This user has no bindings": "Người dùng này không có liên kết nào", "This user has no bindings": "Người dùng này không có liên kết nào",
"This week": "Tuần này",
"This will append 2 template rules (Codex CLI and Claude CLI) to the existing rule list.": "Thao tác này sẽ thêm 2 quy tắc mẫu (Codex CLI và Claude CLI) vào danh sách quy tắc hiện có.", "This will append 2 template rules (Codex CLI and Claude CLI) to the existing rule list.": "Thao tác này sẽ thêm 2 quy tắc mẫu (Codex CLI và Claude CLI) vào danh sách quy tắc hiện có.",
"This will clear custom pricing ratios and revert to upstream defaults.": "Điều này sẽ xóa các tỷ lệ giá tùy chỉnh và trở về mặc định ban đầu.", "This will clear custom pricing ratios and revert to upstream defaults.": "Điều này sẽ xóa các tỷ lệ giá tùy chỉnh và trở về mặc định ban đầu.",
"This will delete all": "Thao tác này sẽ xóa tất cả", "This will delete all": "Thao tác này sẽ xóa tất cả",

View File

@ -331,6 +331,7 @@
"Are you sure?": "您确定吗?", "Are you sure?": "您确定吗?",
"Args (space separated)": "参数 (空格分隔)", "Args (space separated)": "参数 (空格分隔)",
"Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.": "聊天客户端预设数组。每个项目都是一个对象,包含一个键值对:客户端名称及其 URL。", "Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.": "聊天客户端预设数组。每个项目都是一个对象,包含一个键值对:客户端名称及其 URL。",
"Area Chart": "面积图",
"Asc": "升序", "Asc": "升序",
"Ask anything": "随便问", "Ask anything": "随便问",
"Async task refund": "异步任务退款", "Async task refund": "异步任务退款",
@ -407,6 +408,7 @@
"Balance queried successfully": "余额查询成功", "Balance queried successfully": "余额查询成功",
"Balance updated successfully": "余额更新成功", "Balance updated successfully": "余额更新成功",
"Balance updated: {{balance}}": "余额已更新:{{balance}}", "Balance updated: {{balance}}": "余额已更新:{{balance}}",
"Bar Chart": "柱状图",
"Bark Push URL": "Bark 推送 URL", "Bark Push URL": "Bark 推送 URL",
"Base address provided by your Epay service": "您的 Epay 服务提供的基础地址", "Base address provided by your Epay service": "您的 Epay 服务提供的基础地址",
"Base amount. Actual deduction = base amount × system group rate.": "基础金额,实际扣费 = 基础金额 × 系统分组倍率。", "Base amount. Actual deduction = base amount × system group rate.": "基础金额,实际扣费 = 基础金额 × 系统分组倍率。",
@ -497,6 +499,8 @@
"Calculated price: ${{price}} per 1M tokens": "计算价格:${{price}} / 1M tokens", "Calculated price: ${{price}} per 1M tokens": "计算价格:${{price}} / 1M tokens",
"Calculated ratio: {{ratio}}": "计算倍率:{{ratio}}", "Calculated ratio: {{ratio}}": "计算倍率:{{ratio}}",
"Calculating...": "计算中...", "Calculating...": "计算中...",
"Call Count Distribution": "调用次数分布",
"Call Count Ranking": "调用次数排行",
"Call Proportion": "调用比例", "Call Proportion": "调用比例",
"Call Trend": "调用趋势", "Call Trend": "调用趋势",
"Callback address": "回调地址", "Callback address": "回调地址",
@ -3236,6 +3240,7 @@
"this token group": "此令牌分组", "this token group": "此令牌分组",
"this user group": "此用户分组", "this user group": "此用户分组",
"This user has no bindings": "该用户无任何绑定", "This user has no bindings": "该用户无任何绑定",
"This week": "本周",
"This will append 2 template rules (Codex CLI and Claude CLI) to the existing rule list.": "这将在现有规则列表中追加 2 条模板规则Codex CLI 和 Claude CLI。", "This will append 2 template rules (Codex CLI and Claude CLI) to the existing rule list.": "这将在现有规则列表中追加 2 条模板规则Codex CLI 和 Claude CLI。",
"This will clear custom pricing ratios and revert to upstream defaults.": "这将清除自定义定价比例并恢复到上游默认值。", "This will clear custom pricing ratios and revert to upstream defaults.": "这将清除自定义定价比例并恢复到上游默认值。",
"This will delete all": "这将删除所有", "This will delete all": "这将删除所有",

View File

@ -175,7 +175,7 @@ export async function getUserModels(): Promise<{
export async function getUserGroups(): Promise<{ export async function getUserGroups(): Promise<{
success: boolean success: boolean
message?: string message?: string
data?: Record<string, { desc: string; ratio: number }> data?: Record<string, { desc: string; ratio: number | string }>
}> { }> {
const res = await api.get('/api/user/self/groups') const res = await api.get('/api/user/self/groups')
return res.data return res.data

View File

@ -35,6 +35,29 @@ export const colorToBgClass: Record<SemanticColor, string> = {
grey: 'bg-gray-500', grey: 'bg-gray-500',
} }
export const avatarColorMap: Record<SemanticColor, string> = {
blue: 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-400',
green: 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-400',
cyan: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-500/20 dark:text-cyan-400',
purple: 'bg-purple-100 text-purple-700 dark:bg-purple-500/20 dark:text-purple-400',
pink: 'bg-pink-100 text-pink-700 dark:bg-pink-500/20 dark:text-pink-400',
red: 'bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-400',
orange: 'bg-orange-100 text-orange-700 dark:bg-orange-500/20 dark:text-orange-400',
amber: 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400',
yellow: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-500/20 dark:text-yellow-400',
lime: 'bg-lime-100 text-lime-700 dark:bg-lime-500/20 dark:text-lime-400',
'light-green': 'bg-green-50 text-green-600 dark:bg-green-400/20 dark:text-green-300',
teal: 'bg-teal-100 text-teal-700 dark:bg-teal-500/20 dark:text-teal-400',
'light-blue': 'bg-sky-100 text-sky-700 dark:bg-sky-500/20 dark:text-sky-400',
indigo: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-400',
violet: 'bg-violet-100 text-violet-700 dark:bg-violet-500/20 dark:text-violet-400',
grey: 'bg-gray-100 text-gray-700 dark:bg-gray-500/20 dark:text-gray-400',
}
export function getAvatarColorClass(name: string): string {
return avatarColorMap[stringToColor(name)]
}
export function getBgColorClass(color?: string): string { export function getBgColorClass(color?: string): string {
if (!color) return colorToBgClass.blue if (!color) return colorToBgClass.blue
return ( return (

View File

@ -262,6 +262,7 @@ function formatCurrencyValue(
const formatted = new Intl.NumberFormat(undefined, { const formatted = new Intl.NumberFormat(undefined, {
style: 'currency', style: 'currency',
currency: meta.currencyCode, currency: meta.currencyCode,
currencyDisplay: 'narrowSymbol',
minimumFractionDigits: 0, minimumFractionDigits: 0,
maximumFractionDigits: digits, maximumFractionDigits: digits,
}).format(adjustedValue) }).format(adjustedValue)
@ -337,11 +338,11 @@ export function formatCurrencyFromUSD(
const { config, meta } = getCurrencyDisplay() const { config, meta } = getCurrencyDisplay()
const merged = mergeOptions(options) const merged = mergeOptions(options)
if (!config.displayInCurrency || meta.kind === 'tokens') { if (meta.kind === 'tokens') {
const tokens = amountUSD * config.quotaPerUnit const tokens = amountUSD * config.quotaPerUnit
return formatNumberWithSuffix( return formatNumberWithSuffix(
tokens, tokens,
meta.kind === 'tokens' ? 0 : merged.digitsLarge, 0,
merged.digitsSmall, merged.digitsSmall,
merged.abbreviate merged.abbreviate
) )
@ -463,7 +464,7 @@ export function formatQuotaWithCurrency(
export function getCurrencyLabel(): string { export function getCurrencyLabel(): string {
const { config, meta } = getCurrencyDisplay() const { config, meta } = getCurrencyDisplay()
if (!config.displayInCurrency || meta.kind === 'tokens') { if (meta.kind === 'tokens') {
return 'Tokens' return 'Tokens'
} }
@ -494,8 +495,8 @@ export function getCurrencyLabel(): string {
* Use this to conditionally show currency-specific UI elements * Use this to conditionally show currency-specific UI elements
*/ */
export function isCurrencyDisplayEnabled(): boolean { export function isCurrencyDisplayEnabled(): boolean {
const { config, meta } = getCurrencyDisplay() const { meta } = getCurrencyDisplay()
return config.displayInCurrency && meta.kind !== 'tokens' return meta.kind !== 'tokens'
} }
/** /**

View File

@ -61,7 +61,7 @@ export function parseQuotaFromDollars(amount: number): number {
const { config, meta } = getCurrencyDisplay() const { config, meta } = getCurrencyDisplay()
// Tokens-only or raw quota mode // Tokens-only or raw quota mode
if (!config.displayInCurrency || meta.kind === 'tokens') { if (meta.kind === 'tokens') {
return Math.round(amount) return Math.round(amount)
} }
@ -80,7 +80,7 @@ export function parseQuotaFromDollars(amount: number): number {
export function quotaUnitsToDollars(units: number): number { export function quotaUnitsToDollars(units: number): number {
const { config, meta } = getCurrencyDisplay() const { config, meta } = getCurrencyDisplay()
if (!config.displayInCurrency || meta.kind === 'tokens') { if (meta.kind === 'tokens') {
return units return units
} }