feat: enhance UI and functionality in various components
This commit is contained in:
parent
fc377dae3e
commit
28f7e9eb2e
2
.gitignore
vendored
2
.gitignore
vendored
@ -10,6 +10,8 @@ build
|
||||
logs
|
||||
web/default/dist
|
||||
web/classic/dist
|
||||
web/node_modules
|
||||
web/dist
|
||||
.env
|
||||
one-api
|
||||
new-api
|
||||
|
||||
@ -14,6 +14,7 @@ import {
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/empty'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface MobileCardListProps<TData> {
|
||||
table: Table<TData>
|
||||
@ -21,6 +22,7 @@ interface MobileCardListProps<TData> {
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
getRowKey?: (row: Row<TData>) => string | number
|
||||
getRowClassName?: (row: Row<TData>) => string | undefined
|
||||
}
|
||||
|
||||
interface MobileColumnMeta {
|
||||
@ -238,6 +240,7 @@ export function MobileCardList<TData>(props: MobileCardListProps<TData>) {
|
||||
emptyTitle,
|
||||
emptyDescription,
|
||||
getRowKey,
|
||||
getRowClassName,
|
||||
} = props
|
||||
const { t } = useTranslation()
|
||||
|
||||
@ -278,7 +281,10 @@ export function MobileCardList<TData>(props: MobileCardListProps<TData>) {
|
||||
{rows.map((row) => {
|
||||
const key = getRowKey ? getRowKey(row) : row.id
|
||||
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} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -120,70 +120,87 @@ export function WorkspaceSwitcher({
|
||||
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 (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
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'
|
||||
{canSwitchWorkspace ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size='lg'
|
||||
className='data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground'
|
||||
>
|
||||
{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>
|
||||
{workspaceButtonContent}
|
||||
</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 ? (
|
||||
<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>
|
||||
</SidebarMenu>
|
||||
)
|
||||
|
||||
@ -35,6 +35,7 @@ import { PageFooterPortal } from '@/components/layout'
|
||||
import { getChannels, searchChannels, getGroups } from '../api'
|
||||
import {
|
||||
DEFAULT_PAGE_SIZE,
|
||||
CHANNEL_STATUS,
|
||||
CHANNEL_STATUS_OPTIONS,
|
||||
CHANNEL_TYPE_OPTIONS,
|
||||
} from '../constants'
|
||||
@ -50,6 +51,10 @@ import { DataTableBulkActions } from './data-table-bulk-actions'
|
||||
|
||||
const route = getRouteApi('/_authenticated/channels/')
|
||||
|
||||
function isDisabledChannelRow(channel: Channel) {
|
||||
return !isTagAggregateRow(channel) && channel.status !== CHANNEL_STATUS.ENABLED
|
||||
}
|
||||
|
||||
export function ChannelsTable() {
|
||||
const { t } = useTranslation()
|
||||
const { enableTagMode, idSort } = useChannels()
|
||||
@ -318,6 +323,11 @@ export function ChannelsTable() {
|
||||
isLoading={isLoading}
|
||||
emptyTitle='No Channels Found'
|
||||
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
|
||||
key={row.id}
|
||||
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) => (
|
||||
<TableCell key={cell.id}>
|
||||
|
||||
19
web/default/src/features/dashboard/api.ts
vendored
19
web/default/src/features/dashboard/api.ts
vendored
@ -10,14 +10,17 @@ import type { QuotaDataItem, UptimeGroupResult } from './types'
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// Get user quota data within a time range
|
||||
// Admin users can specify 'username' to view other users' data
|
||||
export async function getUserQuotaDates(params: {
|
||||
start_timestamp: number
|
||||
end_timestamp: number
|
||||
default_time?: string
|
||||
username?: string
|
||||
}) {
|
||||
const endpoint = params.username ? '/api/data' : '/api/data/self'
|
||||
// Admin users get all users' data by default (matching classic frontend behavior)
|
||||
export async function getUserQuotaDates(
|
||||
params: {
|
||||
start_timestamp: number
|
||||
end_timestamp: number
|
||||
default_time?: string
|
||||
username?: string
|
||||
},
|
||||
isAdmin = false
|
||||
) {
|
||||
const endpoint = isAdmin ? '/api/data' : '/api/data/self'
|
||||
const res = await api.get<{ success: boolean; data: QuotaDataItem[] }>(
|
||||
endpoint,
|
||||
{ params }
|
||||
|
||||
120
web/default/src/features/dashboard/components/models/consumption-distribution-chart.tsx
vendored
Normal file
120
web/default/src/features/dashboard/components/models/consumption-distribution-chart.tsx
vendored
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { formatNumber, formatQuota } from '@/lib/format'
|
||||
import { computeTimeRange } from '@/lib/time'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { getUserQuotaDates } from '@/features/dashboard/api'
|
||||
import { useModelStatCardsConfig } from '@/features/dashboard/hooks/use-dashboard-config'
|
||||
@ -21,6 +22,8 @@ interface LogStatCardsProps {
|
||||
|
||||
export function LogStatCards(props: LogStatCardsProps) {
|
||||
const statCardsConfig = useModelStatCardsConfig()
|
||||
const user = useAuthStore((state) => state.auth.user)
|
||||
const isAdmin = !!(user?.role && user.role >= 10)
|
||||
const [stats, setStats] = useState<{
|
||||
totalQuota: number
|
||||
totalCount: number
|
||||
@ -49,7 +52,7 @@ export function LogStatCards(props: LogStatCardsProps) {
|
||||
const timeDiff = (timeRange.end_timestamp - timeRange.start_timestamp) / 60
|
||||
setTimeRangeMinutes(timeDiff)
|
||||
|
||||
getUserQuotaDates(buildQueryParams(timeRange, filters))
|
||||
getUserQuotaDates(buildQueryParams(timeRange, filters), isAdmin)
|
||||
.then((res) => {
|
||||
if (abortController.signal.aborted) return
|
||||
const data = res?.data || []
|
||||
@ -71,7 +74,7 @@ export function LogStatCards(props: LogStatCardsProps) {
|
||||
return () => {
|
||||
abortController.abort()
|
||||
}
|
||||
}, [filters, onDataUpdate])
|
||||
}, [filters, isAdmin, onDataUpdate])
|
||||
|
||||
const adaptedStats = {
|
||||
rpm: stats?.totalCount ?? 0,
|
||||
|
||||
@ -7,26 +7,27 @@ 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 {
|
||||
ProcessedChartData,
|
||||
QuotaDataItem,
|
||||
} from '@/features/dashboard/types'
|
||||
import type { QuotaDataItem } from '@/features/dashboard/types'
|
||||
|
||||
let themeManagerPromise: Promise<
|
||||
(typeof import('@visactor/vchart'))['ThemeManager']
|
||||
> | 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: {
|
||||
value: ChartTab
|
||||
labelKey: string
|
||||
specKey: keyof ProcessedChartData
|
||||
specKey: ChartSpecKey
|
||||
}[] = [
|
||||
{ value: '1', labelKey: 'Quota Distribution', specKey: 'spec_line' },
|
||||
{ value: '2', labelKey: 'Call Trend', specKey: 'spec_model_line' },
|
||||
{ value: '3', labelKey: 'Call Proportion', specKey: 'spec_pie' },
|
||||
{ value: '4', labelKey: 'Top Models', specKey: 'spec_rank_bar' },
|
||||
{ value: 'trend', labelKey: 'Call Trend', specKey: 'spec_model_line' },
|
||||
{
|
||||
value: 'proportion',
|
||||
labelKey: 'Call Count Distribution',
|
||||
specKey: 'spec_pie',
|
||||
},
|
||||
{ value: 'top', labelKey: 'Call Count Ranking', specKey: 'spec_rank_bar' },
|
||||
]
|
||||
|
||||
interface ModelChartsProps {
|
||||
@ -38,7 +39,7 @@ interface ModelChartsProps {
|
||||
export function ModelCharts(props: ModelChartsProps) {
|
||||
const { t } = useTranslation()
|
||||
const { resolvedTheme } = useTheme()
|
||||
const [activeTab, setActiveTab] = useState<ChartTab>('1')
|
||||
const [activeTab, setActiveTab] = useState<ChartTab>('trend')
|
||||
const [themeReady, setThemeReady] = useState(false)
|
||||
const themeManagerRef = useRef<
|
||||
(typeof import('@visactor/vchart'))['ThemeManager'] | null
|
||||
|
||||
@ -24,10 +24,8 @@ let themeManagerPromise: Promise<
|
||||
(typeof import('@visactor/vchart'))['ThemeManager']
|
||||
> | null = null
|
||||
|
||||
type UserChartTab = 'rank' | 'trend'
|
||||
|
||||
const CHART_TABS: {
|
||||
value: UserChartTab
|
||||
const USER_CHARTS: {
|
||||
value: string
|
||||
labelKey: string
|
||||
specKey: keyof ProcessedUserChartData
|
||||
}[] = [
|
||||
@ -46,7 +44,6 @@ const CHART_TABS: {
|
||||
export function UserCharts() {
|
||||
const { t } = useTranslation()
|
||||
const { resolvedTheme } = useTheme()
|
||||
const [activeTab, setActiveTab] = useState<UserChartTab>('rank')
|
||||
const [themeReady, setThemeReady] = useState(false)
|
||||
const themeManagerRef = useRef<
|
||||
(typeof import('@visactor/vchart'))['ThemeManager'] | null
|
||||
@ -121,9 +118,6 @@ export function UserCharts() {
|
||||
[userData, isLoading, timeGranularity, t]
|
||||
)
|
||||
|
||||
const activeSpec = CHART_TABS.find((tab) => tab.value === activeTab)
|
||||
const spec = activeSpec ? chartData[activeSpec.specKey] : null
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
{/* Toolbar: time range presets + granularity */}
|
||||
@ -169,50 +163,41 @@ export function UserCharts() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chart card */}
|
||||
<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'>
|
||||
<Users className='text-muted-foreground/60 size-4' />
|
||||
<div className='text-sm font-semibold'>{t('User Analytics')}</div>
|
||||
</div>
|
||||
<div className='grid gap-4'>
|
||||
{USER_CHARTS.map((chart) => {
|
||||
const spec = chartData[chart.specKey]
|
||||
|
||||
<div className='bg-muted/60 inline-flex h-8 rounded-md border p-0.5'>
|
||||
{CHART_TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.value}
|
||||
type='button'
|
||||
onClick={() => setActiveTab(tab.value)}
|
||||
className={`rounded-[5px] px-3 text-xs font-medium transition-colors ${
|
||||
activeTab === tab.value
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{t(tab.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<div
|
||||
key={chart.value}
|
||||
className='overflow-hidden rounded-lg border'
|
||||
>
|
||||
<div className='flex w-full items-center gap-2 border-b px-4 py-3 sm:px-5'>
|
||||
<Users className='text-muted-foreground/60 size-4' />
|
||||
<div className='text-sm font-semibold'>{t(chart.labelKey)}</div>
|
||||
</div>
|
||||
|
||||
<div className='h-96 p-2'>
|
||||
{isLoading ? (
|
||||
<Skeleton className='h-full w-full' />
|
||||
) : (
|
||||
themeReady &&
|
||||
spec && (
|
||||
<VChart
|
||||
key={`user-${activeTab}-${resolvedTheme}`}
|
||||
spec={{
|
||||
...spec,
|
||||
theme: resolvedTheme === 'dark' ? 'dark' : 'light',
|
||||
background: 'transparent',
|
||||
}}
|
||||
option={VCHART_OPTION}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<div className='h-96 p-2'>
|
||||
{isLoading ? (
|
||||
<Skeleton className='h-full w-full' />
|
||||
) : (
|
||||
themeReady &&
|
||||
spec && (
|
||||
<VChart
|
||||
key={`user-${chart.value}-${resolvedTheme}`}
|
||||
spec={{
|
||||
...spec,
|
||||
theme: resolvedTheme === 'dark' ? 'dark' : 'light',
|
||||
background: 'transparent',
|
||||
}}
|
||||
option={VCHART_OPTION}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
17
web/default/src/features/dashboard/index.tsx
vendored
17
web/default/src/features/dashboard/index.tsx
vendored
@ -35,6 +35,12 @@ const LazyModelCharts = lazy(() =>
|
||||
}))
|
||||
)
|
||||
|
||||
const LazyConsumptionDistributionChart = lazy(() =>
|
||||
import('./components/models/consumption-distribution-chart').then((m) => ({
|
||||
default: m.ConsumptionDistributionChart,
|
||||
}))
|
||||
)
|
||||
|
||||
const LazyUserCharts = lazy(() =>
|
||||
import('./components/users/user-charts').then((m) => ({
|
||||
default: m.UserCharts,
|
||||
@ -163,6 +169,17 @@ export function Dashboard() {
|
||||
</Suspense>
|
||||
</FadeIn>
|
||||
<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 />}>
|
||||
<LazyModelCharts
|
||||
data={modelData}
|
||||
|
||||
329
web/default/src/features/dashboard/lib/charts.ts
vendored
329
web/default/src/features/dashboard/lib/charts.ts
vendored
@ -1,5 +1,5 @@
|
||||
import { getChartColor } from '@/lib/colors'
|
||||
import { formatQuotaWithCurrency, getCurrencyDisplay } from '@/lib/currency'
|
||||
import { dataScheme as vchartDefaultDataScheme } from '@visactor/vchart/esm/theme/color-scheme/builtin/default'
|
||||
import { getCurrencyDisplay } from '@/lib/currency'
|
||||
import { formatChartTime, type TimeGranularity } from '@/lib/time'
|
||||
import { MAX_CHART_TREND_POINTS } from '@/features/dashboard/constants'
|
||||
import type {
|
||||
@ -10,6 +10,38 @@ import type {
|
||||
|
||||
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
|
||||
*/
|
||||
@ -19,9 +51,61 @@ export function processChartData(
|
||||
t?: TFunction
|
||||
): ProcessedChartData {
|
||||
const tt: TFunction = t ?? ((x) => x)
|
||||
const otherLabel = tt('Other')
|
||||
|
||||
const formatInt = (value: number) =>
|
||||
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) {
|
||||
return {
|
||||
@ -35,7 +119,7 @@ export function processChartData(
|
||||
categoryField: 'type',
|
||||
title: {
|
||||
visible: true,
|
||||
text: tt('Call Proportion'),
|
||||
text: tt('Call Count Distribution'),
|
||||
subtext: tt('No data available'),
|
||||
},
|
||||
legends: { visible: false },
|
||||
@ -54,15 +138,15 @@ export function processChartData(
|
||||
seriesField: 'Model',
|
||||
stack: true,
|
||||
legends: { visible: true, selectMode: 'single' },
|
||||
title: {
|
||||
visible: true,
|
||||
text: tt('Quota Distribution'),
|
||||
subtext: `${tt('Total:')} ${formatQuotaWithCurrency(0, {
|
||||
digitsLarge: 2,
|
||||
digitsSmall: 2,
|
||||
abbreviate: false,
|
||||
})}`,
|
||||
},
|
||||
},
|
||||
spec_area: {
|
||||
type: 'area',
|
||||
data: [{ id: 'areaData', values: [] }],
|
||||
xField: 'Time',
|
||||
yField: 'Usage',
|
||||
seriesField: 'Model',
|
||||
stack: true,
|
||||
legends: { visible: true, selectMode: 'single' },
|
||||
},
|
||||
spec_model_line: {
|
||||
type: 'line',
|
||||
@ -86,10 +170,11 @@ export function processChartData(
|
||||
legends: { visible: true, selectMode: 'single' },
|
||||
title: {
|
||||
visible: true,
|
||||
text: tt('Top Models'),
|
||||
text: tt('Call Count Ranking'),
|
||||
subtext: `${tt('Total:')} ${formatInt(0)}`,
|
||||
},
|
||||
},
|
||||
totalQuotaDisplay: formatQuotaTotal(0),
|
||||
}
|
||||
}
|
||||
|
||||
@ -142,6 +227,7 @@ export function processChartData(
|
||||
const allModels = Array.from(modelTotalsMap.keys())
|
||||
const sortedTimes = Array.from(timeModelMap.keys()).sort()
|
||||
const sortedModels = [...allModels].sort()
|
||||
const modelColor = buildModelColorSpec([...sortedModels, otherLabel])
|
||||
|
||||
// Pad time points if too few (default 7 points)
|
||||
const MAX_TREND_POINTS = MAX_CHART_TREND_POINTS
|
||||
@ -166,14 +252,6 @@ export function processChartData(
|
||||
}
|
||||
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(
|
||||
(sum, x) => sum + (Number(x.count) || 0),
|
||||
0
|
||||
@ -223,14 +301,70 @@ export function processChartData(
|
||||
})
|
||||
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<{
|
||||
Time: string
|
||||
Model: string
|
||||
Count: number
|
||||
}> = []
|
||||
chartTimes.forEach((time) => {
|
||||
const timeData = sortedModels.map((model) => {
|
||||
const timeData = topTrendModels.map((model) => {
|
||||
const stats = timeModelMap.get(time)?.get(model)
|
||||
return {
|
||||
Time: time,
|
||||
@ -238,6 +372,17 @@ export function processChartData(
|
||||
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.sort((a, b) => a.Time.localeCompare(b.Time))
|
||||
@ -257,7 +402,7 @@ export function processChartData(
|
||||
const otherCount = allRankValues
|
||||
.slice(MAX_RANK_MODELS)
|
||||
.reduce((sum, item) => sum + item.Count, 0)
|
||||
rankValues = [...topModels, { Model: tt('Other'), Count: otherCount }]
|
||||
rankValues = [...topModels, { Model: otherLabel, Count: otherCount }]
|
||||
} else {
|
||||
rankValues = allRankValues
|
||||
}
|
||||
@ -280,11 +425,12 @@ export function processChartData(
|
||||
},
|
||||
title: {
|
||||
visible: true,
|
||||
text: tt('Call Proportion'),
|
||||
text: tt('Call Count Distribution'),
|
||||
subtext: `${tt('Total:')} ${formatInt(totalTimes)}`,
|
||||
},
|
||||
legends: { visible: true, orient: 'left' },
|
||||
label: { visible: true },
|
||||
color: modelColor,
|
||||
tooltip: {
|
||||
mark: {
|
||||
content: [
|
||||
@ -296,7 +442,6 @@ export function processChartData(
|
||||
],
|
||||
},
|
||||
},
|
||||
color: { specified: modelColorMap },
|
||||
background: { fill: 'transparent' },
|
||||
animation: true,
|
||||
},
|
||||
@ -308,15 +453,7 @@ export function processChartData(
|
||||
seriesField: 'Model',
|
||||
stack: true,
|
||||
legends: { visible: true, selectMode: 'single' },
|
||||
title: {
|
||||
visible: true,
|
||||
text: tt('Quota Distribution'),
|
||||
subtext: `${tt('Total:')} ${formatQuotaWithCurrency(totalQuotaRaw, {
|
||||
digitsLarge: 2,
|
||||
digitsSmall: 2,
|
||||
abbreviate: false,
|
||||
})}`,
|
||||
},
|
||||
color: modelColor,
|
||||
bar: {
|
||||
state: {
|
||||
hover: { stroke: '#000', lineWidth: 1 },
|
||||
@ -328,11 +465,7 @@ export function processChartData(
|
||||
{
|
||||
key: (datum: Record<string, unknown>) => datum?.Model,
|
||||
value: (datum: Record<string, unknown>) =>
|
||||
formatQuotaWithCurrency(Number(datum?.rawQuota) || 0, {
|
||||
digitsLarge: 4,
|
||||
digitsSmall: 4,
|
||||
abbreviate: false,
|
||||
}),
|
||||
formatQuotaValue(Number(datum?.rawQuota) || 0),
|
||||
},
|
||||
],
|
||||
},
|
||||
@ -344,58 +477,67 @@ export function processChartData(
|
||||
Number(datum?.rawQuota) || 0,
|
||||
},
|
||||
],
|
||||
updateContent: (
|
||||
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
|
||||
},
|
||||
updateContent: makeTooltipDimensionUpdateContent(),
|
||||
},
|
||||
},
|
||||
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' },
|
||||
animation: true,
|
||||
},
|
||||
spec_model_line: {
|
||||
type: 'line',
|
||||
type: 'area',
|
||||
data: [{ id: 'lineData', values: modelLineValues }],
|
||||
xField: 'Time',
|
||||
yField: 'Count',
|
||||
seriesField: 'Model',
|
||||
stack: false,
|
||||
legends: { visible: true, selectMode: 'single' },
|
||||
color: modelColor,
|
||||
title: {
|
||||
visible: true,
|
||||
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 },
|
||||
background: { fill: 'transparent' },
|
||||
animation: true,
|
||||
@ -454,9 +607,10 @@ export function processChartData(
|
||||
yField: 'Count',
|
||||
seriesField: 'Model',
|
||||
legends: { visible: true, selectMode: 'single' },
|
||||
color: modelColor,
|
||||
title: {
|
||||
visible: true,
|
||||
text: tt('Top Models'),
|
||||
text: tt('Call Count Ranking'),
|
||||
subtext: `${tt('Total:')} ${formatInt(totalTimes)}`,
|
||||
},
|
||||
bar: {
|
||||
@ -475,10 +629,10 @@ export function processChartData(
|
||||
],
|
||||
},
|
||||
},
|
||||
color: { specified: modelColorMap },
|
||||
background: { fill: 'transparent' },
|
||||
animation: true,
|
||||
},
|
||||
totalQuotaDisplay: formatQuotaTotal(totalQuotaRaw),
|
||||
}
|
||||
}
|
||||
|
||||
@ -505,12 +659,7 @@ export function processUserChartData(
|
||||
const { config } = getCurrencyDisplay()
|
||||
const quotaPerUnit = config.quotaPerUnit
|
||||
|
||||
const formatVal = (raw: number) =>
|
||||
formatQuotaWithCurrency(raw, {
|
||||
digitsLarge: 2,
|
||||
digitsSmall: 2,
|
||||
abbreviate: false,
|
||||
})
|
||||
const formatVal = (raw: number) => renderQuotaCompat(raw, 2)
|
||||
|
||||
const emptyResult: ProcessedUserChartData = {
|
||||
spec_user_rank: {
|
||||
|
||||
@ -45,14 +45,12 @@ export function buildQueryParams(
|
||||
): {
|
||||
start_timestamp: number
|
||||
end_timestamp: number
|
||||
default_time?: string
|
||||
default_time: string
|
||||
username?: string
|
||||
} {
|
||||
return {
|
||||
...timeRange,
|
||||
...(filters?.time_granularity && {
|
||||
default_time: filters.time_granularity,
|
||||
}),
|
||||
default_time: getSavedGranularity(filters?.time_granularity),
|
||||
...(filters?.username && { username: filters.username }),
|
||||
}
|
||||
}
|
||||
|
||||
2
web/default/src/features/dashboard/types.ts
vendored
2
web/default/src/features/dashboard/types.ts
vendored
@ -71,8 +71,10 @@ type VChartSpec = Record<string, any>
|
||||
export interface ProcessedChartData {
|
||||
spec_pie: VChartSpec
|
||||
spec_line: VChartSpec
|
||||
spec_area: VChartSpec
|
||||
spec_model_line: VChartSpec
|
||||
spec_rank_bar: VChartSpec
|
||||
totalQuotaDisplay: string
|
||||
}
|
||||
|
||||
export interface ProcessedUserChartData {
|
||||
|
||||
173
web/default/src/features/keys/components/api-key-group-combobox.tsx
vendored
Normal file
173
web/default/src/features/keys/components/api-key-group-combobox.tsx
vendored
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -62,7 +62,7 @@ function useGroupRatios(): Record<string, number> {
|
||||
if (!res.success || !res.data) return {}
|
||||
const ratios: Record<string, number> = {}
|
||||
for (const [group, info] of Object.entries(res.data)) {
|
||||
if (info.ratio !== undefined) {
|
||||
if (typeof info.ratio === 'number') {
|
||||
ratios[group] = info.ratio
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ApiKeysDeleteDialog } from './api-keys-delete-dialog'
|
||||
import { ApiKeysMutateDrawer } from './api-keys-mutate-drawer'
|
||||
import { useApiKeys } from './api-keys-provider'
|
||||
@ -5,6 +6,19 @@ import { CCSwitchDialog } from './dialogs/cc-switch-dialog'
|
||||
|
||||
export function ApiKeysDialogs() {
|
||||
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 (
|
||||
<>
|
||||
@ -12,6 +26,7 @@ export function ApiKeysDialogs() {
|
||||
open={open === 'create' || open === 'update'}
|
||||
onOpenChange={(isOpen) => !isOpen && setOpen(null)}
|
||||
currentRow={open === 'update' ? currentRow || undefined : undefined}
|
||||
side={mutateSide}
|
||||
/>
|
||||
<ApiKeysDeleteDialog />
|
||||
<CCSwitchDialog
|
||||
|
||||
@ -23,13 +23,6 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
@ -53,18 +46,24 @@ import {
|
||||
transformApiKeyToFormDefaults,
|
||||
} from '../lib'
|
||||
import { type ApiKey } from '../types'
|
||||
import {
|
||||
ApiKeyGroupCombobox,
|
||||
type ApiKeyGroupOption,
|
||||
} from './api-key-group-combobox'
|
||||
import { useApiKeys } from './api-keys-provider'
|
||||
|
||||
type ApiKeyMutateDrawerProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
currentRow?: ApiKey
|
||||
side?: 'left' | 'right'
|
||||
}
|
||||
|
||||
export function ApiKeysMutateDrawer({
|
||||
open,
|
||||
onOpenChange,
|
||||
currentRow,
|
||||
side = 'right',
|
||||
}: ApiKeyMutateDrawerProps) {
|
||||
const { t } = useTranslation()
|
||||
const isUpdate = !!currentRow
|
||||
@ -88,14 +87,22 @@ export function ApiKeysMutateDrawer({
|
||||
|
||||
const models = modelsData?.data || []
|
||||
const groupsRaw = groupsData?.data || {}
|
||||
const groups = Object.entries(groupsRaw).map(([key, info]) => ({
|
||||
value: key,
|
||||
label: info.desc || key,
|
||||
}))
|
||||
const groups: ApiKeyGroupOption[] = Object.entries(groupsRaw).map(
|
||||
([key, info]) => ({
|
||||
value: key,
|
||||
label: key,
|
||||
desc: info.desc || key,
|
||||
ratio: info.ratio,
|
||||
})
|
||||
)
|
||||
|
||||
// Add auto group if configured
|
||||
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>({
|
||||
@ -187,10 +194,9 @@ export function ApiKeysMutateDrawer({
|
||||
form.setValue('expired_time', now)
|
||||
}
|
||||
|
||||
const { config: currencyConfig, meta: currencyMeta } = getCurrencyDisplay()
|
||||
const { meta: currencyMeta } = getCurrencyDisplay()
|
||||
const currencyLabel = getCurrencyLabel()
|
||||
const tokensOnly =
|
||||
!currencyConfig.displayInCurrency || currencyMeta.kind === 'tokens'
|
||||
const tokensOnly = currencyMeta.kind === 'tokens'
|
||||
const quotaLabel = t('Quota ({{currency}})', { currency: currencyLabel })
|
||||
const quotaPlaceholder = tokensOnly
|
||||
? 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'>
|
||||
<SheetTitle>
|
||||
{isUpdate ? t('Update API Key') : t('Create API Key')}
|
||||
@ -244,20 +253,14 @@ export function ApiKeysMutateDrawer({
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Group')}</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('Select a group')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{groups.map((group) => (
|
||||
<SelectItem key={group.value} value={group.value}>
|
||||
{group.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormControl>
|
||||
<ApiKeyGroupCombobox
|
||||
options={groups}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
placeholder={t('Select a group')}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Auto group enables circuit breaker mechanism')}
|
||||
</FormDescription>
|
||||
|
||||
@ -38,6 +38,7 @@ import { getApiKeys, searchApiKeys } from '../api'
|
||||
import { API_KEY_STATUS_OPTIONS, ERROR_MESSAGES } from '../constants'
|
||||
import { type ApiKey } from '../types'
|
||||
import { useApiKeysColumns } from './api-keys-columns'
|
||||
import { ApiKeysPrimaryButtons } from './api-keys-primary-buttons'
|
||||
import { useApiKeys } from './api-keys-provider'
|
||||
import { DataTableBulkActions } from './data-table-bulk-actions'
|
||||
|
||||
@ -160,17 +161,22 @@ export function ApiKeysTable() {
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-4'>
|
||||
<DataTableToolbar
|
||||
table={table}
|
||||
searchPlaceholder={t('Filter by name or key...')}
|
||||
filters={[
|
||||
{
|
||||
columnId: 'status',
|
||||
title: t('Status'),
|
||||
options: API_KEY_STATUS_OPTIONS,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div className='flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between'>
|
||||
<ApiKeysPrimaryButtons />
|
||||
<div className='min-w-0 sm:flex sm:justify-end'>
|
||||
<DataTableToolbar
|
||||
table={table}
|
||||
searchPlaceholder={t('Filter by name or key...')}
|
||||
filters={[
|
||||
{
|
||||
columnId: 'status',
|
||||
title: t('Status'),
|
||||
options: API_KEY_STATUS_OPTIONS,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{isMobile ? (
|
||||
<MobileCardList
|
||||
table={table}
|
||||
|
||||
4
web/default/src/features/keys/index.tsx
vendored
4
web/default/src/features/keys/index.tsx
vendored
@ -1,7 +1,6 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SectionPageLayout } from '@/components/layout'
|
||||
import { ApiKeysDialogs } from './components/api-keys-dialogs'
|
||||
import { ApiKeysPrimaryButtons } from './components/api-keys-primary-buttons'
|
||||
import { ApiKeysProvider } from './components/api-keys-provider'
|
||||
import { ApiKeysTable } from './components/api-keys-table'
|
||||
|
||||
@ -14,9 +13,6 @@ export function ApiKeys() {
|
||||
<SectionPageLayout.Description>
|
||||
{t('Manage your API keys for accessing the service')}
|
||||
</SectionPageLayout.Description>
|
||||
<SectionPageLayout.Actions>
|
||||
<ApiKeysPrimaryButtons />
|
||||
</SectionPageLayout.Actions>
|
||||
<SectionPageLayout.Content>
|
||||
<ApiKeysTable />
|
||||
</SectionPageLayout.Content>
|
||||
|
||||
@ -115,10 +115,9 @@ export function RedemptionsMutateDrawer({
|
||||
form.setValue('expired_time', newDate)
|
||||
}
|
||||
|
||||
const { config: currencyConfig, meta: currencyMeta } = getCurrencyDisplay()
|
||||
const { meta: currencyMeta } = getCurrencyDisplay()
|
||||
const currencyLabel = getCurrencyLabel()
|
||||
const tokensOnly =
|
||||
!currencyConfig.displayInCurrency || currencyMeta.kind === 'tokens'
|
||||
const tokensOnly = currencyMeta.kind === 'tokens'
|
||||
const quotaLabel = t('Quota ({{currency}})', { currency: currencyLabel })
|
||||
const quotaPlaceholder = tokensOnly
|
||||
? t('Enter quota in tokens')
|
||||
|
||||
@ -3,6 +3,7 @@ import type { Resolver } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { RotateCcw } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { DEFAULT_CURRENCY_CONFIG } from '@/stores/system-config-store'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
@ -110,6 +111,12 @@ export function PricingSection({ defaultValues }: PricingSectionProps) {
|
||||
})
|
||||
|
||||
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 (
|
||||
<>
|
||||
@ -122,30 +129,32 @@ export function PricingSection({ defaultValues }: PricingSectionProps) {
|
||||
<Form {...form}>
|
||||
<form onSubmit={handleSubmit} className='space-y-6'>
|
||||
<FormDirtyIndicator isDirty={isDirty} />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='QuotaPerUnit'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Quota Per Unit')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
step='0.01'
|
||||
value={field.value as number}
|
||||
onChange={(e) => field.onChange(e.target.valueAsNumber)}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
ref={field.ref}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Number of tokens per unit quota')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{showQuotaPerUnit && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='QuotaPerUnit'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Quota Per Unit')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
step='0.01'
|
||||
value={field.value as number}
|
||||
disabled
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
ref={field.ref}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Number of tokens per unit quota')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
@ -165,7 +174,11 @@ export function PricingSection({ defaultValues }: PricingSectionProps) {
|
||||
<SelectItem value='CUSTOM'>
|
||||
{t('Custom Currency')}
|
||||
</SelectItem>
|
||||
<SelectItem value='TOKENS'>{t('Tokens Only')}</SelectItem>
|
||||
{showTokensOnlyOption && (
|
||||
<SelectItem value='TOKENS'>
|
||||
{t('Tokens Only')}
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
@ -272,32 +285,34 @@ export function PricingSection({ defaultValues }: PricingSectionProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='DisplayInCurrencyEnabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Display in Currency')}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{displayType === 'TOKENS'
|
||||
? t(
|
||||
'Tokens-only mode will show raw quota values regardless of this toggle.'
|
||||
)
|
||||
: t('Show prices in currency instead of quota.')}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{showDisplayInCurrencyOption && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='DisplayInCurrencyEnabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Display in Currency')}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{displayType === 'TOKENS'
|
||||
? t(
|
||||
'Tokens-only mode will show raw quota values regardless of this toggle.'
|
||||
)
|
||||
: t('Show prices in currency instead of quota.')}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
formatLogQuota,
|
||||
formatTimestampToDate,
|
||||
} from '@/lib/format'
|
||||
import { getAvatarColorClass } from '@/lib/colors'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
@ -241,19 +242,6 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
size='sm'
|
||||
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>
|
||||
)
|
||||
},
|
||||
@ -267,45 +255,47 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
]
|
||||
|
||||
if (isAdmin) {
|
||||
columns.push({
|
||||
id: 'source',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Source')} />
|
||||
),
|
||||
cell: function SourceCell({ row }) {
|
||||
const {
|
||||
setAffinityTarget,
|
||||
setAffinityDialogOpen,
|
||||
setSelectedUserId,
|
||||
setUserInfoDialogOpen,
|
||||
} = useUsageLogsContext()
|
||||
const log = row.original
|
||||
columns.push(
|
||||
{
|
||||
id: 'channel',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Channel')} />
|
||||
),
|
||||
cell: function ChannelCell({ row }) {
|
||||
const {
|
||||
sensitiveVisible,
|
||||
setAffinityTarget,
|
||||
setAffinityDialogOpen,
|
||||
} = useUsageLogsContext()
|
||||
const log = row.original
|
||||
|
||||
if (!isDisplayableLogType(log.type)) return null
|
||||
if (!isDisplayableLogType(log.type)) return null
|
||||
|
||||
const other = parseLogOther(log.other)
|
||||
const affinity = other?.admin_info?.channel_affinity
|
||||
const useChannel = other?.admin_info?.use_channel
|
||||
const channelChain =
|
||||
useChannel && useChannel.length > 0
|
||||
? useChannel.join(' → ')
|
||||
: undefined
|
||||
const channelDisplay = log.channel_name
|
||||
? `${log.channel_name} #${log.channel}`
|
||||
: `#${log.channel}`
|
||||
const other = parseLogOther(log.other)
|
||||
const affinity = other?.admin_info?.channel_affinity
|
||||
const useChannel = other?.admin_info?.use_channel
|
||||
const channelChain =
|
||||
useChannel && useChannel.length > 0
|
||||
? useChannel.join(' → ')
|
||||
: undefined
|
||||
const channelDisplay = log.channel_name
|
||||
? `${log.channel_name} #${log.channel}`
|
||||
: `#${log.channel}`
|
||||
const channelIdDisplay = `#${log.channel}`
|
||||
const channelName = sensitiveVisible ? log.channel_name : '••••'
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className='flex items-center gap-1'>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className='relative'>
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className='flex max-w-[160px] flex-col gap-0.5'>
|
||||
<div className='relative inline-flex w-fit'>
|
||||
<StatusBadge
|
||||
label={channelDisplay}
|
||||
autoColor={log.channel_name || String(log.channel)}
|
||||
label={channelIdDisplay}
|
||||
autoColor={String(log.channel)}
|
||||
copyText={String(log.channel)}
|
||||
size='sm'
|
||||
className='font-mono'
|
||||
/>
|
||||
{affinity && (
|
||||
<button
|
||||
@ -329,57 +319,89 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className='space-y-1'>
|
||||
<p>{channelDisplay}</p>
|
||||
{channelChain && (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('Chain')}: {channelChain}
|
||||
{log.channel_name && (
|
||||
<span className='text-muted-foreground/70 truncate text-[11px]'>
|
||||
{channelName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
{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>
|
||||
{t('Group')}:{' '}
|
||||
{affinity.using_group ||
|
||||
<p>
|
||||
{t('Group')}:{' '}
|
||||
{sensitiveVisible
|
||||
? affinity.using_group ||
|
||||
affinity.selected_group ||
|
||||
'-'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
{log.username && (
|
||||
<button
|
||||
type='button'
|
||||
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>
|
||||
)
|
||||
'-'
|
||||
: '••••'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
},
|
||||
meta: { label: t('Channel'), mobileHidden: true },
|
||||
},
|
||||
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(
|
||||
@ -389,6 +411,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
<DataTableColumnHeader column={column} title={t('Model')} />
|
||||
),
|
||||
cell: function ModelCell({ row }) {
|
||||
const { sensitiveVisible } = useUsageLogsContext()
|
||||
const log = row.original
|
||||
if (!isDisplayableLogType(log.type)) return null
|
||||
|
||||
@ -450,8 +473,8 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
)
|
||||
|
||||
const metaParts: string[] = []
|
||||
if (tokenName) metaParts.push(tokenName)
|
||||
if (group) metaParts.push(group)
|
||||
if (tokenName) metaParts.push(sensitiveVisible ? tokenName : '••••')
|
||||
if (group) metaParts.push(sensitiveVisible ? group : '••••')
|
||||
|
||||
return (
|
||||
<div className='flex max-w-[220px] flex-col gap-0.5'>
|
||||
|
||||
279
web/default/src/features/usage-logs/components/common-logs-filter-bar.tsx
vendored
Normal file
279
web/default/src/features/usage-logs/components/common-logs-filter-bar.tsx
vendored
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -5,10 +5,10 @@ import { formatLogQuota } from '@/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useIsAdmin } from '@/hooks/use-admin'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { dotColorMap, textColorMap } from '@/components/status-badge'
|
||||
import { getLogStats, getUserLogStats } from '../api'
|
||||
import { DEFAULT_LOG_STATS } from '../constants'
|
||||
import { buildApiParams } from '../lib/utils'
|
||||
import { useUsageLogsContext } from './usage-logs-provider'
|
||||
|
||||
const route = getRouteApi('/_authenticated/usage-logs/$section')
|
||||
|
||||
@ -16,6 +16,7 @@ export function CommonLogsStats() {
|
||||
const { t } = useTranslation()
|
||||
const isAdmin = useIsAdmin()
|
||||
const searchParams = route.useSearch()
|
||||
const { sensitiveVisible } = useUsageLogsContext()
|
||||
|
||||
const { data: stats, isLoading } = useQuery({
|
||||
queryKey: ['usage-logs-stats', isAdmin, searchParams],
|
||||
@ -41,29 +42,42 @@ export function CommonLogsStats() {
|
||||
|
||||
if (isLoading) {
|
||||
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-[58px] rounded-md' />
|
||||
<Skeleton className='h-6 w-[58px] rounded-md' />
|
||||
<Skeleton className='h-6 w-[76px] rounded-md' />
|
||||
<Skeleton className='h-6 w-[92px] rounded-md' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const tagClass =
|
||||
'inline-flex h-6 items-center rounded-md border px-2.5 text-xs font-medium shadow-xs'
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-1.5 text-xs font-medium'>
|
||||
<div className='flex flex-wrap items-center gap-1.5'>
|
||||
<span
|
||||
className={cn('size-1.5 shrink-0 rounded-full', dotColorMap.blue)}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<span className={cn(textColorMap.blue)}>
|
||||
{t('Usage')}: {formatLogQuota(stats?.quota || 0)}
|
||||
className={cn(
|
||||
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'
|
||||
)}
|
||||
>
|
||||
{t('Usage')}:{' '}
|
||||
{sensitiveVisible ? formatLogQuota(stats?.quota || 0) : '••••'}
|
||||
</span>
|
||||
<span className='text-muted-foreground/30'>·</span>
|
||||
<span className={cn(textColorMap.pink)}>
|
||||
<span
|
||||
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}
|
||||
</span>
|
||||
<span className='text-muted-foreground/30'>·</span>
|
||||
<span className='text-muted-foreground'>
|
||||
<span
|
||||
className={cn(
|
||||
tagClass,
|
||||
'border-border bg-background text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{t('TPM')}: {stats?.tpm || 0}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
199
web/default/src/features/usage-logs/components/compact-date-time-range-picker.tsx
vendored
Normal file
199
web/default/src/features/usage-logs/components/compact-date-time-range-picker.tsx
vendored
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -11,6 +11,8 @@ interface UsageLogsContextValue {
|
||||
setAffinityTarget: (target: ChannelAffinityInfo | null) => void
|
||||
affinityDialogOpen: boolean
|
||||
setAffinityDialogOpen: (open: boolean) => void
|
||||
sensitiveVisible: boolean
|
||||
setSensitiveVisible: (visible: boolean) => void
|
||||
}
|
||||
|
||||
const UsageLogsContext = createContext<UsageLogsContextValue | undefined>(
|
||||
@ -23,6 +25,7 @@ export function UsageLogsProvider({ children }: { children: ReactNode }) {
|
||||
const [affinityTarget, setAffinityTarget] =
|
||||
useState<ChannelAffinityInfo | null>(null)
|
||||
const [affinityDialogOpen, setAffinityDialogOpen] = useState(false)
|
||||
const [sensitiveVisible, setSensitiveVisible] = useState(true)
|
||||
|
||||
return (
|
||||
<UsageLogsContext.Provider
|
||||
@ -35,6 +38,8 @@ export function UsageLogsProvider({ children }: { children: ReactNode }) {
|
||||
setAffinityTarget,
|
||||
affinityDialogOpen,
|
||||
setAffinityDialogOpen,
|
||||
sensitiveVisible,
|
||||
setSensitiveVisible,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@ -28,15 +28,18 @@ import {
|
||||
import {
|
||||
DataTablePagination,
|
||||
DataTableToolbar,
|
||||
DataTableViewOptions,
|
||||
TableSkeleton,
|
||||
TableEmpty,
|
||||
MobileCardList,
|
||||
} from '@/components/data-table'
|
||||
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 { fetchLogsByCategory } from '../lib/utils'
|
||||
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')
|
||||
|
||||
@ -147,25 +150,23 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
|
||||
ensurePageInRange(pageCount)
|
||||
}, [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 (
|
||||
<>
|
||||
<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 ? (
|
||||
<MobileCardList
|
||||
table={table}
|
||||
|
||||
@ -2,7 +2,6 @@ import { getRouteApi } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SectionPageLayout } from '@/components/layout'
|
||||
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 { UsageLogsPrimaryButtons } from './components/usage-logs-primary-buttons'
|
||||
import {
|
||||
@ -60,8 +59,9 @@ function UsageLogsContent() {
|
||||
{description}
|
||||
</SectionPageLayout.Description>
|
||||
<SectionPageLayout.Actions>
|
||||
{activeCategory === 'common' && <CommonLogsStats />}
|
||||
<UsageLogsPrimaryButtons logCategory={activeCategory} />
|
||||
{activeCategory !== 'common' && (
|
||||
<UsageLogsPrimaryButtons logCategory={activeCategory} />
|
||||
)}
|
||||
</SectionPageLayout.Actions>
|
||||
<SectionPageLayout.Content>
|
||||
<UsageLogsTable logCategory={activeCategory} />
|
||||
|
||||
@ -32,10 +32,9 @@ export function UserQuotaDialog(props: UserQuotaDialogProps) {
|
||||
const [amount, setAmount] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const { config: currencyConfig, meta: currencyMeta } = getCurrencyDisplay()
|
||||
const { meta: currencyMeta } = getCurrencyDisplay()
|
||||
const currencyLabel = getCurrencyLabel()
|
||||
const tokensOnly =
|
||||
!currencyConfig.displayInCurrency || currencyMeta.kind === 'tokens'
|
||||
const tokensOnly = currencyMeta.kind === 'tokens'
|
||||
|
||||
const amountValue = parseFloat(amount) || 0
|
||||
const quotaValue = parseQuotaFromDollars(Math.abs(amountValue))
|
||||
|
||||
@ -95,10 +95,9 @@ export function UsersMutateDrawer({
|
||||
}
|
||||
}, [open, isUpdate, currentRow, form])
|
||||
|
||||
const { config: currencyConfig, meta: currencyMeta } = getCurrencyDisplay()
|
||||
const { meta: currencyMeta } = getCurrencyDisplay()
|
||||
const currencyLabel = getCurrencyLabel()
|
||||
const tokensOnly =
|
||||
!currencyConfig.displayInCurrency || currencyMeta.kind === 'tokens'
|
||||
const tokensOnly = currencyMeta.kind === 'tokens'
|
||||
|
||||
const currentQuotaRaw = form.watch('quota_dollars') || 0
|
||||
|
||||
|
||||
5
web/default/src/i18n/locales/en.json
vendored
5
web/default/src/i18n/locales/en.json
vendored
@ -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.": "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",
|
||||
"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 updated: {{balance}}",
|
||||
"Bar Chart": "Bar Chart",
|
||||
"Bark Push URL": "Bark Push URL",
|
||||
"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.",
|
||||
@ -497,6 +499,8 @@
|
||||
"Calculated price: ${{price}} per 1M tokens": "Calculated price: ${{price}} per 1M tokens",
|
||||
"Calculated ratio: {{ratio}}": "Calculated ratio: {{ratio}}",
|
||||
"Calculating...": "Calculating...",
|
||||
"Call Count Distribution": "Call Count Distribution",
|
||||
"Call Count Ranking": "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 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 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",
|
||||
|
||||
5
web/default/src/i18n/locales/fr.json
vendored
5
web/default/src/i18n/locales/fr.json
vendored
@ -331,6 +331,7 @@
|
||||
"Are you sure?": "Êtes-vous sûr ?",
|
||||
"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.",
|
||||
"Area Chart": "Graphique en aires",
|
||||
"Asc": "Asc",
|
||||
"Ask anything": "Demandez n'importe quoi",
|
||||
"Async task refund": "Remboursement de tâche asynchrone",
|
||||
@ -407,6 +408,7 @@
|
||||
"Balance queried successfully": "Solde interrogé avec succès",
|
||||
"Balance updated successfully": "Solde mis à jour avec succès",
|
||||
"Balance updated: {{balance}}": "Solde mis à jour : {{balance}}",
|
||||
"Bar Chart": "Graphique en barres",
|
||||
"Bark Push URL": "URL de notification Bark",
|
||||
"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.",
|
||||
@ -497,6 +499,8 @@
|
||||
"Calculated price: ${{price}} per 1M tokens": "Prix calculé : ${{price}} par 1M tokens",
|
||||
"Calculated ratio: {{ratio}}": "Ratio calculé : {{ratio}}",
|
||||
"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 Trend": "Tendance des appels",
|
||||
"Callback address": "Adresse de rappel",
|
||||
@ -3236,6 +3240,7 @@
|
||||
"this token group": "ce groupe de jetons",
|
||||
"this user group": "ce groupe d'utilisateurs",
|
||||
"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 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",
|
||||
|
||||
5
web/default/src/i18n/locales/ja.json
vendored
5
web/default/src/i18n/locales/ja.json
vendored
@ -331,6 +331,7 @@
|
||||
"Are you sure?": "よろしいですか?",
|
||||
"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つのキーと値のペアを持つオブジェクトです。",
|
||||
"Area Chart": "面グラフ",
|
||||
"Asc": "昇順",
|
||||
"Ask anything": "何でも質問する",
|
||||
"Async task refund": "非同期タスク返金",
|
||||
@ -407,6 +408,7 @@
|
||||
"Balance queried successfully": "残高の取得に成功しました",
|
||||
"Balance updated successfully": "残高が正常に更新されました",
|
||||
"Balance updated: {{balance}}": "残高更新:{{balance}}",
|
||||
"Bar Chart": "棒グラフ",
|
||||
"Bark Push URL": "BarkプッシュURL",
|
||||
"Base address provided by your Epay service": "Epayサービスによって提供されるベースアドレス",
|
||||
"Base amount. Actual deduction = base amount × system group rate.": "基本金額。実際の控除 = 基本金額 × システムグループ倍率。",
|
||||
@ -497,6 +499,8 @@
|
||||
"Calculated price: ${{price}} per 1M tokens": "計算価格:${{price}} / 1M トークン",
|
||||
"Calculated ratio: {{ratio}}": "計算倍率:{{ratio}}",
|
||||
"Calculating...": "計算中...",
|
||||
"Call Count Distribution": "呼び出し回数分布",
|
||||
"Call Count Ranking": "呼び出し回数ランキング",
|
||||
"Call Proportion": "呼び出し比率",
|
||||
"Call Trend": "呼び出し傾向",
|
||||
"Callback address": "コールバックアドレス",
|
||||
@ -3236,6 +3240,7 @@
|
||||
"this token group": "このトークングループ",
|
||||
"this user group": "このユーザーグループ",
|
||||
"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 clear custom pricing ratios and revert to upstream defaults.": "これにより、カスタム料金比率がクリアされ、上位のデフォルトに戻ります。",
|
||||
"This will delete all": "これによりすべて削除されます",
|
||||
|
||||
5
web/default/src/i18n/locales/ru.json
vendored
5
web/default/src/i18n/locales/ru.json
vendored
@ -331,6 +331,7 @@
|
||||
"Are you sure?": "Вы уверены?",
|
||||
"Args (space separated)": "Аргументы (разделённые пробелами)",
|
||||
"Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.": "Массив предустановок чат-клиентов. Каждый элемент представляет собой объект с одной парой ключ-значение: имя клиента и его URL.",
|
||||
"Area Chart": "Диаграмма с областями",
|
||||
"Asc": "По возрастанию",
|
||||
"Ask anything": "Спросите что угодно",
|
||||
"Async task refund": "Возврат асинхронной задачи",
|
||||
@ -407,6 +408,7 @@
|
||||
"Balance queried successfully": "Баланс успешно запрошен",
|
||||
"Balance updated successfully": "Баланс успешно обновлён",
|
||||
"Balance updated: {{balance}}": "Баланс обновлён: {{balance}}",
|
||||
"Bar Chart": "Столбчатая диаграмма",
|
||||
"Bark Push URL": "URL для push-уведомлений Bark",
|
||||
"Base address provided by your Epay service": "Базовый адрес, предоставленный вашим сервисом Epay",
|
||||
"Base amount. Actual deduction = base amount × system group rate.": "Базовая сумма. Фактический вычет = базовая сумма × коэффициент группы.",
|
||||
@ -497,6 +499,8 @@
|
||||
"Calculated price: ${{price}} per 1M tokens": "Расчётная цена: ${{price}} за 1М токенов",
|
||||
"Calculated ratio: {{ratio}}": "Расчётный коэффициент: {{ratio}}",
|
||||
"Calculating...": "Вычисление...",
|
||||
"Call Count Distribution": "Распределение количества вызовов",
|
||||
"Call Count Ranking": "Рейтинг по количеству вызовов",
|
||||
"Call Proportion": "Доля вызовов",
|
||||
"Call Trend": "Тенденция вызовов",
|
||||
"Callback address": "Адрес обратного вызова",
|
||||
@ -3236,6 +3240,7 @@
|
||||
"this token group": "эта группа токенов",
|
||||
"this user group": "эта группа пользователей",
|
||||
"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 clear custom pricing ratios and revert to upstream defaults.": "Это очистит пользовательские ценовые коэффициенты и вернет к стандартным значениям поставщика.",
|
||||
"This will delete all": "Это удалит все",
|
||||
|
||||
5
web/default/src/i18n/locales/vi.json
vendored
5
web/default/src/i18n/locales/vi.json
vendored
@ -331,6 +331,7 @@
|
||||
"Are you sure?": "Bạn có chắc khô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",
|
||||
"Area Chart": "Biểu đồ vùng",
|
||||
"Asc": "Asc",
|
||||
"Ask anything": "Hỏi gì cũng được",
|
||||
"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 updated successfully": "Đã cập nhật số dư thành công",
|
||||
"Balance updated: {{balance}}": "Số dư đã cập nhật: {{balance}}",
|
||||
"Bar Chart": "Biểu đồ cột",
|
||||
"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 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 ratio: {{ratio}}": "Tỷ lệ tính toán: {{ratio}}",
|
||||
"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 Trend": "Xu hướng cuộc gọi",
|
||||
"Callback address": "Địa chỉ callback",
|
||||
@ -3236,6 +3240,7 @@
|
||||
"this token group": "nhóm token 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 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 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ả",
|
||||
|
||||
5
web/default/src/i18n/locales/zh.json
vendored
5
web/default/src/i18n/locales/zh.json
vendored
@ -331,6 +331,7 @@
|
||||
"Are you sure?": "您确定吗?",
|
||||
"Args (space separated)": "参数 (空格分隔)",
|
||||
"Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.": "聊天客户端预设数组。每个项目都是一个对象,包含一个键值对:客户端名称及其 URL。",
|
||||
"Area Chart": "面积图",
|
||||
"Asc": "升序",
|
||||
"Ask anything": "随便问",
|
||||
"Async task refund": "异步任务退款",
|
||||
@ -407,6 +408,7 @@
|
||||
"Balance queried successfully": "余额查询成功",
|
||||
"Balance updated successfully": "余额更新成功",
|
||||
"Balance updated: {{balance}}": "余额已更新:{{balance}}",
|
||||
"Bar Chart": "柱状图",
|
||||
"Bark Push URL": "Bark 推送 URL",
|
||||
"Base address provided by your Epay service": "您的 Epay 服务提供的基础地址",
|
||||
"Base amount. Actual deduction = base amount × system group rate.": "基础金额,实际扣费 = 基础金额 × 系统分组倍率。",
|
||||
@ -497,6 +499,8 @@
|
||||
"Calculated price: ${{price}} per 1M tokens": "计算价格:${{price}} / 1M tokens",
|
||||
"Calculated ratio: {{ratio}}": "计算倍率:{{ratio}}",
|
||||
"Calculating...": "计算中...",
|
||||
"Call Count Distribution": "调用次数分布",
|
||||
"Call Count Ranking": "调用次数排行",
|
||||
"Call Proportion": "调用比例",
|
||||
"Call Trend": "调用趋势",
|
||||
"Callback address": "回调地址",
|
||||
@ -3236,6 +3240,7 @@
|
||||
"this token group": "此令牌分组",
|
||||
"this user group": "此用户分组",
|
||||
"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 clear custom pricing ratios and revert to upstream defaults.": "这将清除自定义定价比例并恢复到上游默认值。",
|
||||
"This will delete all": "这将删除所有",
|
||||
|
||||
2
web/default/src/lib/api.ts
vendored
2
web/default/src/lib/api.ts
vendored
@ -175,7 +175,7 @@ export async function getUserModels(): Promise<{
|
||||
export async function getUserGroups(): Promise<{
|
||||
success: boolean
|
||||
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')
|
||||
return res.data
|
||||
|
||||
23
web/default/src/lib/colors.ts
vendored
23
web/default/src/lib/colors.ts
vendored
@ -35,6 +35,29 @@ export const colorToBgClass: Record<SemanticColor, string> = {
|
||||
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 {
|
||||
if (!color) return colorToBgClass.blue
|
||||
return (
|
||||
|
||||
11
web/default/src/lib/currency.ts
vendored
11
web/default/src/lib/currency.ts
vendored
@ -262,6 +262,7 @@ function formatCurrencyValue(
|
||||
const formatted = new Intl.NumberFormat(undefined, {
|
||||
style: 'currency',
|
||||
currency: meta.currencyCode,
|
||||
currencyDisplay: 'narrowSymbol',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: digits,
|
||||
}).format(adjustedValue)
|
||||
@ -337,11 +338,11 @@ export function formatCurrencyFromUSD(
|
||||
const { config, meta } = getCurrencyDisplay()
|
||||
const merged = mergeOptions(options)
|
||||
|
||||
if (!config.displayInCurrency || meta.kind === 'tokens') {
|
||||
if (meta.kind === 'tokens') {
|
||||
const tokens = amountUSD * config.quotaPerUnit
|
||||
return formatNumberWithSuffix(
|
||||
tokens,
|
||||
meta.kind === 'tokens' ? 0 : merged.digitsLarge,
|
||||
0,
|
||||
merged.digitsSmall,
|
||||
merged.abbreviate
|
||||
)
|
||||
@ -463,7 +464,7 @@ export function formatQuotaWithCurrency(
|
||||
export function getCurrencyLabel(): string {
|
||||
const { config, meta } = getCurrencyDisplay()
|
||||
|
||||
if (!config.displayInCurrency || meta.kind === 'tokens') {
|
||||
if (meta.kind === 'tokens') {
|
||||
return 'Tokens'
|
||||
}
|
||||
|
||||
@ -494,8 +495,8 @@ export function getCurrencyLabel(): string {
|
||||
* Use this to conditionally show currency-specific UI elements
|
||||
*/
|
||||
export function isCurrencyDisplayEnabled(): boolean {
|
||||
const { config, meta } = getCurrencyDisplay()
|
||||
return config.displayInCurrency && meta.kind !== 'tokens'
|
||||
const { meta } = getCurrencyDisplay()
|
||||
return meta.kind !== 'tokens'
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
4
web/default/src/lib/format.ts
vendored
4
web/default/src/lib/format.ts
vendored
@ -61,7 +61,7 @@ export function parseQuotaFromDollars(amount: number): number {
|
||||
const { config, meta } = getCurrencyDisplay()
|
||||
|
||||
// Tokens-only or raw quota mode
|
||||
if (!config.displayInCurrency || meta.kind === 'tokens') {
|
||||
if (meta.kind === 'tokens') {
|
||||
return Math.round(amount)
|
||||
}
|
||||
|
||||
@ -80,7 +80,7 @@ export function parseQuotaFromDollars(amount: number): number {
|
||||
export function quotaUnitsToDollars(units: number): number {
|
||||
const { config, meta } = getCurrencyDisplay()
|
||||
|
||||
if (!config.displayInCurrency || meta.kind === 'tokens') {
|
||||
if (meta.kind === 'tokens') {
|
||||
return units
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user