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
|
logs
|
||||||
web/default/dist
|
web/default/dist
|
||||||
web/classic/dist
|
web/classic/dist
|
||||||
|
web/node_modules
|
||||||
|
web/dist
|
||||||
.env
|
.env
|
||||||
one-api
|
one-api
|
||||||
new-api
|
new-api
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import {
|
|||||||
EmptyTitle,
|
EmptyTitle,
|
||||||
} from '@/components/ui/empty'
|
} from '@/components/ui/empty'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
interface MobileCardListProps<TData> {
|
interface MobileCardListProps<TData> {
|
||||||
table: Table<TData>
|
table: Table<TData>
|
||||||
@ -21,6 +22,7 @@ interface MobileCardListProps<TData> {
|
|||||||
emptyTitle?: string
|
emptyTitle?: string
|
||||||
emptyDescription?: string
|
emptyDescription?: string
|
||||||
getRowKey?: (row: Row<TData>) => string | number
|
getRowKey?: (row: Row<TData>) => string | number
|
||||||
|
getRowClassName?: (row: Row<TData>) => string | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MobileColumnMeta {
|
interface MobileColumnMeta {
|
||||||
@ -238,6 +240,7 @@ export function MobileCardList<TData>(props: MobileCardListProps<TData>) {
|
|||||||
emptyTitle,
|
emptyTitle,
|
||||||
emptyDescription,
|
emptyDescription,
|
||||||
getRowKey,
|
getRowKey,
|
||||||
|
getRowClassName,
|
||||||
} = props
|
} = props
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
@ -278,7 +281,10 @@ export function MobileCardList<TData>(props: MobileCardListProps<TData>) {
|
|||||||
{rows.map((row) => {
|
{rows.map((row) => {
|
||||||
const key = getRowKey ? getRowKey(row) : row.id
|
const key = getRowKey ? getRowKey(row) : row.id
|
||||||
return (
|
return (
|
||||||
<div key={key} className='bg-card px-3 py-2.5'>
|
<div
|
||||||
|
key={key}
|
||||||
|
className={cn('bg-card px-3 py-2.5', getRowClassName?.(row))}
|
||||||
|
>
|
||||||
<RowComponent row={row} />
|
<RowComponent row={row} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -120,70 +120,87 @@ export function WorkspaceSwitcher({
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canSwitchWorkspace = availableWorkspaces.length > 1
|
||||||
|
const workspaceButtonContent = (
|
||||||
|
<>
|
||||||
|
{activeWorkspace.id === WORKSPACE_IDS.SYSTEM_SETTINGS ? (
|
||||||
|
<div className='bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg'>
|
||||||
|
<activeWorkspace.logo className='size-4' />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className='flex aspect-square size-8 items-center justify-center overflow-hidden rounded-lg'>
|
||||||
|
<img
|
||||||
|
src={logo}
|
||||||
|
alt={t('Logo')}
|
||||||
|
className='size-full rounded-lg object-cover'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className='grid flex-1 text-start text-sm leading-tight group-data-[collapsible=icon]:hidden'>
|
||||||
|
<span className='truncate font-semibold'>{activeWorkspace.name}</span>
|
||||||
|
<span className='truncate text-xs'>{activeWorkspace.plan}</span>
|
||||||
|
</div>
|
||||||
|
{canSwitchWorkspace && (
|
||||||
|
<ChevronsUpDown className='ms-auto group-data-[collapsible=icon]:hidden' />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<DropdownMenu>
|
{canSwitchWorkspace ? (
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenu>
|
||||||
<SidebarMenuButton
|
<DropdownMenuTrigger asChild>
|
||||||
size='lg'
|
<SidebarMenuButton
|
||||||
className='data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground'
|
size='lg'
|
||||||
>
|
className='data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground'
|
||||||
{activeWorkspace.id === WORKSPACE_IDS.SYSTEM_SETTINGS ? (
|
|
||||||
<div className='bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg'>
|
|
||||||
<activeWorkspace.logo className='size-4' />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className='flex aspect-square size-8 items-center justify-center overflow-hidden rounded-lg'>
|
|
||||||
<img
|
|
||||||
src={logo}
|
|
||||||
alt={t('Logo')}
|
|
||||||
className='size-full rounded-lg object-cover'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className='grid flex-1 text-start text-sm leading-tight group-data-[collapsible=icon]:hidden'>
|
|
||||||
<span className='truncate font-semibold'>
|
|
||||||
{activeWorkspace.name}
|
|
||||||
</span>
|
|
||||||
<span className='truncate text-xs'>{activeWorkspace.plan}</span>
|
|
||||||
</div>
|
|
||||||
<ChevronsUpDown className='ms-auto group-data-[collapsible=icon]:hidden' />
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent
|
|
||||||
className='w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg'
|
|
||||||
align='start'
|
|
||||||
side={isMobile ? 'bottom' : 'right'}
|
|
||||||
sideOffset={4}
|
|
||||||
>
|
|
||||||
<DropdownMenuLabel className='text-muted-foreground text-xs'>
|
|
||||||
{t('Workspaces')}
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
{availableWorkspaces.map((workspace, index) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={workspace.id}
|
|
||||||
onClick={() => handleWorkspaceChange(workspace)}
|
|
||||||
className='gap-2 p-2'
|
|
||||||
>
|
>
|
||||||
{index === 0 ? (
|
{workspaceButtonContent}
|
||||||
<div className='flex size-6 items-center justify-center overflow-hidden rounded-sm border'>
|
</SidebarMenuButton>
|
||||||
<img
|
</DropdownMenuTrigger>
|
||||||
src={logo}
|
<DropdownMenuContent
|
||||||
alt='Logo'
|
className='w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg'
|
||||||
className='size-full object-cover'
|
align='start'
|
||||||
/>
|
side={isMobile ? 'bottom' : 'right'}
|
||||||
</div>
|
sideOffset={4}
|
||||||
) : (
|
>
|
||||||
<div className='flex size-6 items-center justify-center rounded-sm border'>
|
<DropdownMenuLabel className='text-muted-foreground text-xs'>
|
||||||
<workspace.logo className='size-4 shrink-0' />
|
{t('Workspaces')}
|
||||||
</div>
|
</DropdownMenuLabel>
|
||||||
)}
|
{availableWorkspaces.map((workspace, index) => (
|
||||||
{workspace.name}
|
<DropdownMenuItem
|
||||||
</DropdownMenuItem>
|
key={workspace.id}
|
||||||
))}
|
onClick={() => handleWorkspaceChange(workspace)}
|
||||||
</DropdownMenuContent>
|
className='gap-2 p-2'
|
||||||
</DropdownMenu>
|
>
|
||||||
|
{index === 0 ? (
|
||||||
|
<div className='flex size-6 items-center justify-center overflow-hidden rounded-sm border'>
|
||||||
|
<img
|
||||||
|
src={logo}
|
||||||
|
alt='Logo'
|
||||||
|
className='size-full object-cover'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className='flex size-6 items-center justify-center rounded-sm border'>
|
||||||
|
<workspace.logo className='size-4 shrink-0' />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{workspace.name}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : (
|
||||||
|
<SidebarMenuButton
|
||||||
|
asChild
|
||||||
|
size='lg'
|
||||||
|
className='cursor-default hover:bg-transparent hover:text-sidebar-foreground active:bg-transparent active:text-sidebar-foreground'
|
||||||
|
>
|
||||||
|
<div>{workspaceButtonContent}</div>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
)}
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -35,6 +35,7 @@ import { PageFooterPortal } from '@/components/layout'
|
|||||||
import { getChannels, searchChannels, getGroups } from '../api'
|
import { getChannels, searchChannels, getGroups } from '../api'
|
||||||
import {
|
import {
|
||||||
DEFAULT_PAGE_SIZE,
|
DEFAULT_PAGE_SIZE,
|
||||||
|
CHANNEL_STATUS,
|
||||||
CHANNEL_STATUS_OPTIONS,
|
CHANNEL_STATUS_OPTIONS,
|
||||||
CHANNEL_TYPE_OPTIONS,
|
CHANNEL_TYPE_OPTIONS,
|
||||||
} from '../constants'
|
} from '../constants'
|
||||||
@ -50,6 +51,10 @@ import { DataTableBulkActions } from './data-table-bulk-actions'
|
|||||||
|
|
||||||
const route = getRouteApi('/_authenticated/channels/')
|
const route = getRouteApi('/_authenticated/channels/')
|
||||||
|
|
||||||
|
function isDisabledChannelRow(channel: Channel) {
|
||||||
|
return !isTagAggregateRow(channel) && channel.status !== CHANNEL_STATUS.ENABLED
|
||||||
|
}
|
||||||
|
|
||||||
export function ChannelsTable() {
|
export function ChannelsTable() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { enableTagMode, idSort } = useChannels()
|
const { enableTagMode, idSort } = useChannels()
|
||||||
@ -318,6 +323,11 @@ export function ChannelsTable() {
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
emptyTitle='No Channels Found'
|
emptyTitle='No Channels Found'
|
||||||
emptyDescription='No channels available. Create your first channel to get started.'
|
emptyDescription='No channels available. Create your first channel to get started.'
|
||||||
|
getRowClassName={(row) =>
|
||||||
|
isDisabledChannelRow(row.original)
|
||||||
|
? 'border-l-4 border-l-muted-foreground/35 bg-muted/85 dark:border-l-zinc-300/70 dark:bg-zinc-700/55'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@ -363,6 +373,10 @@ export function ChannelsTable() {
|
|||||||
<TableRow
|
<TableRow
|
||||||
key={row.id}
|
key={row.id}
|
||||||
data-state={row.getIsSelected() && 'selected'}
|
data-state={row.getIsSelected() && 'selected'}
|
||||||
|
className={cn(
|
||||||
|
isDisabledChannelRow(row.original) &&
|
||||||
|
'bg-muted/85 hover:bg-muted dark:bg-zinc-700/55 dark:hover:bg-zinc-700/70 [&>td:first-child]:border-l-4 [&>td:first-child]:border-l-muted-foreground/35 [&>td:first-child]:pl-1 dark:[&>td:first-child]:border-l-zinc-300/70'
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{row.getVisibleCells().map((cell) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
<TableCell key={cell.id}>
|
<TableCell key={cell.id}>
|
||||||
|
|||||||
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
|
// Get user quota data within a time range
|
||||||
// Admin users can specify 'username' to view other users' data
|
// Admin users get all users' data by default (matching classic frontend behavior)
|
||||||
export async function getUserQuotaDates(params: {
|
export async function getUserQuotaDates(
|
||||||
start_timestamp: number
|
params: {
|
||||||
end_timestamp: number
|
start_timestamp: number
|
||||||
default_time?: string
|
end_timestamp: number
|
||||||
username?: string
|
default_time?: string
|
||||||
}) {
|
username?: string
|
||||||
const endpoint = params.username ? '/api/data' : '/api/data/self'
|
},
|
||||||
|
isAdmin = false
|
||||||
|
) {
|
||||||
|
const endpoint = isAdmin ? '/api/data' : '/api/data/self'
|
||||||
const res = await api.get<{ success: boolean; data: QuotaDataItem[] }>(
|
const res = await api.get<{ success: boolean; data: QuotaDataItem[] }>(
|
||||||
endpoint,
|
endpoint,
|
||||||
{ params }
|
{ params }
|
||||||
|
|||||||
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 { useEffect, useState } from 'react'
|
||||||
import { formatNumber, formatQuota } from '@/lib/format'
|
import { formatNumber, formatQuota } from '@/lib/format'
|
||||||
import { computeTimeRange } from '@/lib/time'
|
import { computeTimeRange } from '@/lib/time'
|
||||||
|
import { useAuthStore } from '@/stores/auth-store'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { getUserQuotaDates } from '@/features/dashboard/api'
|
import { getUserQuotaDates } from '@/features/dashboard/api'
|
||||||
import { useModelStatCardsConfig } from '@/features/dashboard/hooks/use-dashboard-config'
|
import { useModelStatCardsConfig } from '@/features/dashboard/hooks/use-dashboard-config'
|
||||||
@ -21,6 +22,8 @@ interface LogStatCardsProps {
|
|||||||
|
|
||||||
export function LogStatCards(props: LogStatCardsProps) {
|
export function LogStatCards(props: LogStatCardsProps) {
|
||||||
const statCardsConfig = useModelStatCardsConfig()
|
const statCardsConfig = useModelStatCardsConfig()
|
||||||
|
const user = useAuthStore((state) => state.auth.user)
|
||||||
|
const isAdmin = !!(user?.role && user.role >= 10)
|
||||||
const [stats, setStats] = useState<{
|
const [stats, setStats] = useState<{
|
||||||
totalQuota: number
|
totalQuota: number
|
||||||
totalCount: number
|
totalCount: number
|
||||||
@ -49,7 +52,7 @@ export function LogStatCards(props: LogStatCardsProps) {
|
|||||||
const timeDiff = (timeRange.end_timestamp - timeRange.start_timestamp) / 60
|
const timeDiff = (timeRange.end_timestamp - timeRange.start_timestamp) / 60
|
||||||
setTimeRangeMinutes(timeDiff)
|
setTimeRangeMinutes(timeDiff)
|
||||||
|
|
||||||
getUserQuotaDates(buildQueryParams(timeRange, filters))
|
getUserQuotaDates(buildQueryParams(timeRange, filters), isAdmin)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (abortController.signal.aborted) return
|
if (abortController.signal.aborted) return
|
||||||
const data = res?.data || []
|
const data = res?.data || []
|
||||||
@ -71,7 +74,7 @@ export function LogStatCards(props: LogStatCardsProps) {
|
|||||||
return () => {
|
return () => {
|
||||||
abortController.abort()
|
abortController.abort()
|
||||||
}
|
}
|
||||||
}, [filters, onDataUpdate])
|
}, [filters, isAdmin, onDataUpdate])
|
||||||
|
|
||||||
const adaptedStats = {
|
const adaptedStats = {
|
||||||
rpm: stats?.totalCount ?? 0,
|
rpm: stats?.totalCount ?? 0,
|
||||||
|
|||||||
@ -7,26 +7,27 @@ import { VCHART_OPTION } from '@/lib/vchart'
|
|||||||
import { useTheme } from '@/context/theme-provider'
|
import { useTheme } from '@/context/theme-provider'
|
||||||
import { DEFAULT_TIME_GRANULARITY } from '@/features/dashboard/constants'
|
import { DEFAULT_TIME_GRANULARITY } from '@/features/dashboard/constants'
|
||||||
import { processChartData } from '@/features/dashboard/lib'
|
import { processChartData } from '@/features/dashboard/lib'
|
||||||
import type {
|
import type { QuotaDataItem } from '@/features/dashboard/types'
|
||||||
ProcessedChartData,
|
|
||||||
QuotaDataItem,
|
|
||||||
} from '@/features/dashboard/types'
|
|
||||||
|
|
||||||
let themeManagerPromise: Promise<
|
let themeManagerPromise: Promise<
|
||||||
(typeof import('@visactor/vchart'))['ThemeManager']
|
(typeof import('@visactor/vchart'))['ThemeManager']
|
||||||
> | null = null
|
> | null = null
|
||||||
|
|
||||||
type ChartTab = '1' | '2' | '3' | '4'
|
type ChartTab = 'trend' | 'proportion' | 'top'
|
||||||
|
type ChartSpecKey = 'spec_model_line' | 'spec_pie' | 'spec_rank_bar'
|
||||||
|
|
||||||
const CHART_TABS: {
|
const CHART_TABS: {
|
||||||
value: ChartTab
|
value: ChartTab
|
||||||
labelKey: string
|
labelKey: string
|
||||||
specKey: keyof ProcessedChartData
|
specKey: ChartSpecKey
|
||||||
}[] = [
|
}[] = [
|
||||||
{ value: '1', labelKey: 'Quota Distribution', specKey: 'spec_line' },
|
{ value: 'trend', labelKey: 'Call Trend', specKey: 'spec_model_line' },
|
||||||
{ value: '2', labelKey: 'Call Trend', specKey: 'spec_model_line' },
|
{
|
||||||
{ value: '3', labelKey: 'Call Proportion', specKey: 'spec_pie' },
|
value: 'proportion',
|
||||||
{ value: '4', labelKey: 'Top Models', specKey: 'spec_rank_bar' },
|
labelKey: 'Call Count Distribution',
|
||||||
|
specKey: 'spec_pie',
|
||||||
|
},
|
||||||
|
{ value: 'top', labelKey: 'Call Count Ranking', specKey: 'spec_rank_bar' },
|
||||||
]
|
]
|
||||||
|
|
||||||
interface ModelChartsProps {
|
interface ModelChartsProps {
|
||||||
@ -38,7 +39,7 @@ interface ModelChartsProps {
|
|||||||
export function ModelCharts(props: ModelChartsProps) {
|
export function ModelCharts(props: ModelChartsProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { resolvedTheme } = useTheme()
|
const { resolvedTheme } = useTheme()
|
||||||
const [activeTab, setActiveTab] = useState<ChartTab>('1')
|
const [activeTab, setActiveTab] = useState<ChartTab>('trend')
|
||||||
const [themeReady, setThemeReady] = useState(false)
|
const [themeReady, setThemeReady] = useState(false)
|
||||||
const themeManagerRef = useRef<
|
const themeManagerRef = useRef<
|
||||||
(typeof import('@visactor/vchart'))['ThemeManager'] | null
|
(typeof import('@visactor/vchart'))['ThemeManager'] | null
|
||||||
|
|||||||
@ -24,10 +24,8 @@ let themeManagerPromise: Promise<
|
|||||||
(typeof import('@visactor/vchart'))['ThemeManager']
|
(typeof import('@visactor/vchart'))['ThemeManager']
|
||||||
> | null = null
|
> | null = null
|
||||||
|
|
||||||
type UserChartTab = 'rank' | 'trend'
|
const USER_CHARTS: {
|
||||||
|
value: string
|
||||||
const CHART_TABS: {
|
|
||||||
value: UserChartTab
|
|
||||||
labelKey: string
|
labelKey: string
|
||||||
specKey: keyof ProcessedUserChartData
|
specKey: keyof ProcessedUserChartData
|
||||||
}[] = [
|
}[] = [
|
||||||
@ -46,7 +44,6 @@ const CHART_TABS: {
|
|||||||
export function UserCharts() {
|
export function UserCharts() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { resolvedTheme } = useTheme()
|
const { resolvedTheme } = useTheme()
|
||||||
const [activeTab, setActiveTab] = useState<UserChartTab>('rank')
|
|
||||||
const [themeReady, setThemeReady] = useState(false)
|
const [themeReady, setThemeReady] = useState(false)
|
||||||
const themeManagerRef = useRef<
|
const themeManagerRef = useRef<
|
||||||
(typeof import('@visactor/vchart'))['ThemeManager'] | null
|
(typeof import('@visactor/vchart'))['ThemeManager'] | null
|
||||||
@ -121,9 +118,6 @@ export function UserCharts() {
|
|||||||
[userData, isLoading, timeGranularity, t]
|
[userData, isLoading, timeGranularity, t]
|
||||||
)
|
)
|
||||||
|
|
||||||
const activeSpec = CHART_TABS.find((tab) => tab.value === activeTab)
|
|
||||||
const spec = activeSpec ? chartData[activeSpec.specKey] : null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='space-y-4'>
|
<div className='space-y-4'>
|
||||||
{/* Toolbar: time range presets + granularity */}
|
{/* Toolbar: time range presets + granularity */}
|
||||||
@ -169,50 +163,41 @@ export function UserCharts() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chart card */}
|
<div className='grid gap-4'>
|
||||||
<div className='overflow-hidden rounded-lg border'>
|
{USER_CHARTS.map((chart) => {
|
||||||
<div className='flex w-full flex-col gap-3 border-b px-4 py-3 sm:px-5 lg:flex-row lg:items-center lg:justify-between'>
|
const spec = chartData[chart.specKey]
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<Users className='text-muted-foreground/60 size-4' />
|
|
||||||
<div className='text-sm font-semibold'>{t('User Analytics')}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='bg-muted/60 inline-flex h-8 rounded-md border p-0.5'>
|
return (
|
||||||
{CHART_TABS.map((tab) => (
|
<div
|
||||||
<button
|
key={chart.value}
|
||||||
key={tab.value}
|
className='overflow-hidden rounded-lg border'
|
||||||
type='button'
|
>
|
||||||
onClick={() => setActiveTab(tab.value)}
|
<div className='flex w-full items-center gap-2 border-b px-4 py-3 sm:px-5'>
|
||||||
className={`rounded-[5px] px-3 text-xs font-medium transition-colors ${
|
<Users className='text-muted-foreground/60 size-4' />
|
||||||
activeTab === tab.value
|
<div className='text-sm font-semibold'>{t(chart.labelKey)}</div>
|
||||||
? 'bg-background text-foreground shadow-sm'
|
</div>
|
||||||
: 'text-muted-foreground hover:text-foreground'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t(tab.labelKey)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='h-96 p-2'>
|
<div className='h-96 p-2'>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Skeleton className='h-full w-full' />
|
<Skeleton className='h-full w-full' />
|
||||||
) : (
|
) : (
|
||||||
themeReady &&
|
themeReady &&
|
||||||
spec && (
|
spec && (
|
||||||
<VChart
|
<VChart
|
||||||
key={`user-${activeTab}-${resolvedTheme}`}
|
key={`user-${chart.value}-${resolvedTheme}`}
|
||||||
spec={{
|
spec={{
|
||||||
...spec,
|
...spec,
|
||||||
theme: resolvedTheme === 'dark' ? 'dark' : 'light',
|
theme: resolvedTheme === 'dark' ? 'dark' : 'light',
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
}}
|
}}
|
||||||
option={VCHART_OPTION}
|
option={VCHART_OPTION}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
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(() =>
|
const LazyUserCharts = lazy(() =>
|
||||||
import('./components/users/user-charts').then((m) => ({
|
import('./components/users/user-charts').then((m) => ({
|
||||||
default: m.UserCharts,
|
default: m.UserCharts,
|
||||||
@ -163,6 +169,17 @@ export function Dashboard() {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</FadeIn>
|
</FadeIn>
|
||||||
<FadeIn delay={0.1}>
|
<FadeIn delay={0.1}>
|
||||||
|
<Suspense fallback={<ModelChartsFallback />}>
|
||||||
|
<LazyConsumptionDistributionChart
|
||||||
|
data={modelData}
|
||||||
|
loading={dataLoading}
|
||||||
|
timeGranularity={
|
||||||
|
modelFilters.time_granularity || DEFAULT_TIME_GRANULARITY
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</FadeIn>
|
||||||
|
<FadeIn delay={0.15}>
|
||||||
<Suspense fallback={<ModelChartsFallback />}>
|
<Suspense fallback={<ModelChartsFallback />}>
|
||||||
<LazyModelCharts
|
<LazyModelCharts
|
||||||
data={modelData}
|
data={modelData}
|
||||||
|
|||||||
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 { dataScheme as vchartDefaultDataScheme } from '@visactor/vchart/esm/theme/color-scheme/builtin/default'
|
||||||
import { formatQuotaWithCurrency, getCurrencyDisplay } from '@/lib/currency'
|
import { getCurrencyDisplay } from '@/lib/currency'
|
||||||
import { formatChartTime, type TimeGranularity } from '@/lib/time'
|
import { formatChartTime, type TimeGranularity } from '@/lib/time'
|
||||||
import { MAX_CHART_TREND_POINTS } from '@/features/dashboard/constants'
|
import { MAX_CHART_TREND_POINTS } from '@/features/dashboard/constants'
|
||||||
import type {
|
import type {
|
||||||
@ -10,6 +10,38 @@ import type {
|
|||||||
|
|
||||||
type TFunction = (key: string) => string
|
type TFunction = (key: string) => string
|
||||||
|
|
||||||
|
function getVChartDefaultColors(domainLength: number) {
|
||||||
|
const scheme =
|
||||||
|
vchartDefaultDataScheme.find(
|
||||||
|
(item) => !item.maxDomainLength || domainLength <= item.maxDomainLength
|
||||||
|
) ?? vchartDefaultDataScheme[vchartDefaultDataScheme.length - 1]
|
||||||
|
|
||||||
|
return scheme.scheme
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildModelColorSpec(models: string[]) {
|
||||||
|
const domain = Array.from(new Set(models))
|
||||||
|
return {
|
||||||
|
type: 'ordinal',
|
||||||
|
domain,
|
||||||
|
range: getVChartDefaultColors(domain.length),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderQuotaCompat(rawQuota: number, digits = 4): string {
|
||||||
|
const { config, meta } = getCurrencyDisplay()
|
||||||
|
if (meta.kind === 'tokens') return rawQuota.toLocaleString()
|
||||||
|
const usd = rawQuota / config.quotaPerUnit
|
||||||
|
const rate = 'exchangeRate' in meta ? meta.exchangeRate : 1
|
||||||
|
const symbol = 'symbol' in meta ? meta.symbol : '$'
|
||||||
|
const value = usd * rate
|
||||||
|
const fixed = value.toFixed(digits)
|
||||||
|
if (parseFloat(fixed) === 0 && rawQuota > 0 && value > 0) {
|
||||||
|
return symbol + Math.pow(10, -digits).toFixed(digits)
|
||||||
|
}
|
||||||
|
return symbol + fixed
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Process and aggregate chart data
|
* Process and aggregate chart data
|
||||||
*/
|
*/
|
||||||
@ -19,9 +51,61 @@ export function processChartData(
|
|||||||
t?: TFunction
|
t?: TFunction
|
||||||
): ProcessedChartData {
|
): ProcessedChartData {
|
||||||
const tt: TFunction = t ?? ((x) => x)
|
const tt: TFunction = t ?? ((x) => x)
|
||||||
|
const otherLabel = tt('Other')
|
||||||
|
|
||||||
const formatInt = (value: number) =>
|
const formatInt = (value: number) =>
|
||||||
Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(value)
|
Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(value)
|
||||||
|
const formatQuotaValue = (value: number) => renderQuotaCompat(value, 4)
|
||||||
|
const formatQuotaTotal = (value: number) => renderQuotaCompat(value, 2)
|
||||||
|
|
||||||
|
const MAX_TOOLTIP_MODELS = 15
|
||||||
|
|
||||||
|
const makeTooltipDimensionUpdateContent = () => {
|
||||||
|
return (
|
||||||
|
array: Array<{
|
||||||
|
key: string
|
||||||
|
value: string | number
|
||||||
|
datum?: Record<string, unknown>
|
||||||
|
}>
|
||||||
|
) => {
|
||||||
|
array.sort((a, b) => (Number(b.value) || 0) - (Number(a.value) || 0))
|
||||||
|
let sum = 0
|
||||||
|
for (let i = 0; i < array.length; i++) {
|
||||||
|
if (array[i].key === 'Other' || array[i].key === otherLabel) continue
|
||||||
|
const v = Number(array[i].value) || 0
|
||||||
|
if (
|
||||||
|
array[i].datum &&
|
||||||
|
(array[i].datum as Record<string, unknown>)?.TimeSum
|
||||||
|
) {
|
||||||
|
sum =
|
||||||
|
Number((array[i].datum as Record<string, unknown>)?.TimeSum) || sum
|
||||||
|
}
|
||||||
|
array[i].value = formatQuotaValue(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array.length > MAX_TOOLTIP_MODELS) {
|
||||||
|
const visible = array.slice(0, MAX_TOOLTIP_MODELS)
|
||||||
|
let otherSum = 0
|
||||||
|
for (let i = MAX_TOOLTIP_MODELS; i < array.length; i++) {
|
||||||
|
const raw = array[i].datum
|
||||||
|
? Number((array[i].datum as Record<string, unknown>)?.rawQuota) || 0
|
||||||
|
: 0
|
||||||
|
otherSum += raw
|
||||||
|
}
|
||||||
|
visible.push({
|
||||||
|
key: otherLabel,
|
||||||
|
value: formatQuotaValue(otherSum),
|
||||||
|
})
|
||||||
|
array = visible
|
||||||
|
}
|
||||||
|
|
||||||
|
array.unshift({
|
||||||
|
key: tt('Total:'),
|
||||||
|
value: formatQuotaValue(sum),
|
||||||
|
})
|
||||||
|
return array
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!data || data.length === 0) {
|
if (!data || data.length === 0) {
|
||||||
return {
|
return {
|
||||||
@ -35,7 +119,7 @@ export function processChartData(
|
|||||||
categoryField: 'type',
|
categoryField: 'type',
|
||||||
title: {
|
title: {
|
||||||
visible: true,
|
visible: true,
|
||||||
text: tt('Call Proportion'),
|
text: tt('Call Count Distribution'),
|
||||||
subtext: tt('No data available'),
|
subtext: tt('No data available'),
|
||||||
},
|
},
|
||||||
legends: { visible: false },
|
legends: { visible: false },
|
||||||
@ -54,15 +138,15 @@ export function processChartData(
|
|||||||
seriesField: 'Model',
|
seriesField: 'Model',
|
||||||
stack: true,
|
stack: true,
|
||||||
legends: { visible: true, selectMode: 'single' },
|
legends: { visible: true, selectMode: 'single' },
|
||||||
title: {
|
},
|
||||||
visible: true,
|
spec_area: {
|
||||||
text: tt('Quota Distribution'),
|
type: 'area',
|
||||||
subtext: `${tt('Total:')} ${formatQuotaWithCurrency(0, {
|
data: [{ id: 'areaData', values: [] }],
|
||||||
digitsLarge: 2,
|
xField: 'Time',
|
||||||
digitsSmall: 2,
|
yField: 'Usage',
|
||||||
abbreviate: false,
|
seriesField: 'Model',
|
||||||
})}`,
|
stack: true,
|
||||||
},
|
legends: { visible: true, selectMode: 'single' },
|
||||||
},
|
},
|
||||||
spec_model_line: {
|
spec_model_line: {
|
||||||
type: 'line',
|
type: 'line',
|
||||||
@ -86,10 +170,11 @@ export function processChartData(
|
|||||||
legends: { visible: true, selectMode: 'single' },
|
legends: { visible: true, selectMode: 'single' },
|
||||||
title: {
|
title: {
|
||||||
visible: true,
|
visible: true,
|
||||||
text: tt('Top Models'),
|
text: tt('Call Count Ranking'),
|
||||||
subtext: `${tt('Total:')} ${formatInt(0)}`,
|
subtext: `${tt('Total:')} ${formatInt(0)}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
totalQuotaDisplay: formatQuotaTotal(0),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -142,6 +227,7 @@ export function processChartData(
|
|||||||
const allModels = Array.from(modelTotalsMap.keys())
|
const allModels = Array.from(modelTotalsMap.keys())
|
||||||
const sortedTimes = Array.from(timeModelMap.keys()).sort()
|
const sortedTimes = Array.from(timeModelMap.keys()).sort()
|
||||||
const sortedModels = [...allModels].sort()
|
const sortedModels = [...allModels].sort()
|
||||||
|
const modelColor = buildModelColorSpec([...sortedModels, otherLabel])
|
||||||
|
|
||||||
// Pad time points if too few (default 7 points)
|
// Pad time points if too few (default 7 points)
|
||||||
const MAX_TREND_POINTS = MAX_CHART_TREND_POINTS
|
const MAX_TREND_POINTS = MAX_CHART_TREND_POINTS
|
||||||
@ -166,14 +252,6 @@ export function processChartData(
|
|||||||
}
|
}
|
||||||
const chartTimes = fillTimePoints(sortedTimes)
|
const chartTimes = fillTimePoints(sortedTimes)
|
||||||
|
|
||||||
const modelColorMap = sortedModels.reduce<Record<string, string>>(
|
|
||||||
(acc, model, index) => {
|
|
||||||
acc[model] = getChartColor(index)
|
|
||||||
return acc
|
|
||||||
},
|
|
||||||
{}
|
|
||||||
)
|
|
||||||
|
|
||||||
const totalTimes = Array.from(modelTotalsMap.values()).reduce(
|
const totalTimes = Array.from(modelTotalsMap.values()).reduce(
|
||||||
(sum, x) => sum + (Number(x.count) || 0),
|
(sum, x) => sum + (Number(x.count) || 0),
|
||||||
0
|
0
|
||||||
@ -223,14 +301,70 @@ export function processChartData(
|
|||||||
})
|
})
|
||||||
lineValues.sort((a, b) => a.Time.localeCompare(b.Time))
|
lineValues.sort((a, b) => a.Time.localeCompare(b.Time))
|
||||||
|
|
||||||
// Line chart: model call trend
|
// Area chart: top models by quota + "Other" bucket (too many series = unreadable)
|
||||||
|
const MAX_AREA_MODELS = 15
|
||||||
|
const rankedQuotaModels = Array.from(modelTotalsMap.entries())
|
||||||
|
.map(([model, stats]) => ({
|
||||||
|
Model: model,
|
||||||
|
Quota: Number(stats.quota) || 0,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.Quota - a.Quota)
|
||||||
|
const topAreaModels = new Set(
|
||||||
|
rankedQuotaModels.slice(0, MAX_AREA_MODELS).map((m) => m.Model)
|
||||||
|
)
|
||||||
|
|
||||||
|
const areaValues: typeof lineValues = []
|
||||||
|
chartTimes.forEach((time) => {
|
||||||
|
const buckets = new Map<string, { rawQuota: number; usage: number }>()
|
||||||
|
const modelMap = timeModelMap.get(time)
|
||||||
|
let timeSum = 0
|
||||||
|
sortedModels.forEach((model) => {
|
||||||
|
const stats = modelMap?.get(model)
|
||||||
|
const rawQuota = Number(stats?.quota) || 0
|
||||||
|
const usd = rawQuota ? rawQuota / quotaPerUnit : 0
|
||||||
|
const usage = usd ? Number(usd.toFixed(4)) : 0
|
||||||
|
timeSum += rawQuota
|
||||||
|
const key = topAreaModels.has(model) ? model : otherLabel
|
||||||
|
const prev = buckets.get(key) || { rawQuota: 0, usage: 0 }
|
||||||
|
buckets.set(key, {
|
||||||
|
rawQuota: prev.rawQuota + rawQuota,
|
||||||
|
usage: Number((prev.usage + usage).toFixed(4)),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
for (const [model, vals] of buckets) {
|
||||||
|
areaValues.push({
|
||||||
|
Time: time,
|
||||||
|
Model: model,
|
||||||
|
rawQuota: vals.rawQuota,
|
||||||
|
Usage: vals.usage,
|
||||||
|
TimeSum: timeSum,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
areaValues.sort((a, b) => a.Time.localeCompare(b.Time))
|
||||||
|
|
||||||
|
// Line chart: model call trend (top models + "Other" bucket)
|
||||||
|
const MAX_TREND_MODELS = 20
|
||||||
|
const rankedTrendModels = Array.from(modelTotalsMap.entries())
|
||||||
|
.map(([model, stats]) => ({
|
||||||
|
Model: model,
|
||||||
|
Count: Number(stats.count) || 0,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.Count - a.Count)
|
||||||
|
const topTrendModels = rankedTrendModels
|
||||||
|
.slice(0, MAX_TREND_MODELS)
|
||||||
|
.map((item) => item.Model)
|
||||||
|
const otherTrendModels = rankedTrendModels
|
||||||
|
.slice(MAX_TREND_MODELS)
|
||||||
|
.map((item) => item.Model)
|
||||||
|
|
||||||
const modelLineValues: Array<{
|
const modelLineValues: Array<{
|
||||||
Time: string
|
Time: string
|
||||||
Model: string
|
Model: string
|
||||||
Count: number
|
Count: number
|
||||||
}> = []
|
}> = []
|
||||||
chartTimes.forEach((time) => {
|
chartTimes.forEach((time) => {
|
||||||
const timeData = sortedModels.map((model) => {
|
const timeData = topTrendModels.map((model) => {
|
||||||
const stats = timeModelMap.get(time)?.get(model)
|
const stats = timeModelMap.get(time)?.get(model)
|
||||||
return {
|
return {
|
||||||
Time: time,
|
Time: time,
|
||||||
@ -238,6 +372,17 @@ export function processChartData(
|
|||||||
Count: Number(stats?.count) || 0,
|
Count: Number(stats?.count) || 0,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
if (otherTrendModels.length > 0) {
|
||||||
|
const otherCount = otherTrendModels.reduce((sum, model) => {
|
||||||
|
const stats = timeModelMap.get(time)?.get(model)
|
||||||
|
return sum + (Number(stats?.count) || 0)
|
||||||
|
}, 0)
|
||||||
|
timeData.push({
|
||||||
|
Time: time,
|
||||||
|
Model: otherLabel,
|
||||||
|
Count: otherCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
modelLineValues.push(...timeData)
|
modelLineValues.push(...timeData)
|
||||||
})
|
})
|
||||||
modelLineValues.sort((a, b) => a.Time.localeCompare(b.Time))
|
modelLineValues.sort((a, b) => a.Time.localeCompare(b.Time))
|
||||||
@ -257,7 +402,7 @@ export function processChartData(
|
|||||||
const otherCount = allRankValues
|
const otherCount = allRankValues
|
||||||
.slice(MAX_RANK_MODELS)
|
.slice(MAX_RANK_MODELS)
|
||||||
.reduce((sum, item) => sum + item.Count, 0)
|
.reduce((sum, item) => sum + item.Count, 0)
|
||||||
rankValues = [...topModels, { Model: tt('Other'), Count: otherCount }]
|
rankValues = [...topModels, { Model: otherLabel, Count: otherCount }]
|
||||||
} else {
|
} else {
|
||||||
rankValues = allRankValues
|
rankValues = allRankValues
|
||||||
}
|
}
|
||||||
@ -280,11 +425,12 @@ export function processChartData(
|
|||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
visible: true,
|
visible: true,
|
||||||
text: tt('Call Proportion'),
|
text: tt('Call Count Distribution'),
|
||||||
subtext: `${tt('Total:')} ${formatInt(totalTimes)}`,
|
subtext: `${tt('Total:')} ${formatInt(totalTimes)}`,
|
||||||
},
|
},
|
||||||
legends: { visible: true, orient: 'left' },
|
legends: { visible: true, orient: 'left' },
|
||||||
label: { visible: true },
|
label: { visible: true },
|
||||||
|
color: modelColor,
|
||||||
tooltip: {
|
tooltip: {
|
||||||
mark: {
|
mark: {
|
||||||
content: [
|
content: [
|
||||||
@ -296,7 +442,6 @@ export function processChartData(
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
color: { specified: modelColorMap },
|
|
||||||
background: { fill: 'transparent' },
|
background: { fill: 'transparent' },
|
||||||
animation: true,
|
animation: true,
|
||||||
},
|
},
|
||||||
@ -308,15 +453,7 @@ export function processChartData(
|
|||||||
seriesField: 'Model',
|
seriesField: 'Model',
|
||||||
stack: true,
|
stack: true,
|
||||||
legends: { visible: true, selectMode: 'single' },
|
legends: { visible: true, selectMode: 'single' },
|
||||||
title: {
|
color: modelColor,
|
||||||
visible: true,
|
|
||||||
text: tt('Quota Distribution'),
|
|
||||||
subtext: `${tt('Total:')} ${formatQuotaWithCurrency(totalQuotaRaw, {
|
|
||||||
digitsLarge: 2,
|
|
||||||
digitsSmall: 2,
|
|
||||||
abbreviate: false,
|
|
||||||
})}`,
|
|
||||||
},
|
|
||||||
bar: {
|
bar: {
|
||||||
state: {
|
state: {
|
||||||
hover: { stroke: '#000', lineWidth: 1 },
|
hover: { stroke: '#000', lineWidth: 1 },
|
||||||
@ -328,11 +465,7 @@ export function processChartData(
|
|||||||
{
|
{
|
||||||
key: (datum: Record<string, unknown>) => datum?.Model,
|
key: (datum: Record<string, unknown>) => datum?.Model,
|
||||||
value: (datum: Record<string, unknown>) =>
|
value: (datum: Record<string, unknown>) =>
|
||||||
formatQuotaWithCurrency(Number(datum?.rawQuota) || 0, {
|
formatQuotaValue(Number(datum?.rawQuota) || 0),
|
||||||
digitsLarge: 4,
|
|
||||||
digitsSmall: 4,
|
|
||||||
abbreviate: false,
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -344,58 +477,67 @@ export function processChartData(
|
|||||||
Number(datum?.rawQuota) || 0,
|
Number(datum?.rawQuota) || 0,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
updateContent: (
|
updateContent: makeTooltipDimensionUpdateContent(),
|
||||||
array: Array<{
|
|
||||||
key: string
|
|
||||||
value: string | number
|
|
||||||
datum?: Record<string, unknown>
|
|
||||||
}>
|
|
||||||
) => {
|
|
||||||
array.sort(
|
|
||||||
(a, b) => (Number(b.value) || 0) - (Number(a.value) || 0)
|
|
||||||
)
|
|
||||||
let sum = 0
|
|
||||||
for (let i = 0; i < array.length; i++) {
|
|
||||||
if (array[i].key === 'Other') continue
|
|
||||||
const v = Number(array[i].value) || 0
|
|
||||||
if (
|
|
||||||
array[i].datum &&
|
|
||||||
(array[i].datum as Record<string, unknown>)?.TimeSum
|
|
||||||
) {
|
|
||||||
sum =
|
|
||||||
Number(
|
|
||||||
(array[i].datum as Record<string, unknown>)?.TimeSum
|
|
||||||
) || sum
|
|
||||||
}
|
|
||||||
array[i].value = formatQuotaWithCurrency(v, {
|
|
||||||
digitsLarge: 4,
|
|
||||||
digitsSmall: 4,
|
|
||||||
abbreviate: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
array.unshift({
|
|
||||||
key: tt('Total:'),
|
|
||||||
value: formatQuotaWithCurrency(sum, {
|
|
||||||
digitsLarge: 4,
|
|
||||||
digitsSmall: 4,
|
|
||||||
abbreviate: false,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
return array
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
color: { specified: modelColorMap },
|
background: { fill: 'transparent' },
|
||||||
|
animation: true,
|
||||||
|
},
|
||||||
|
spec_area: {
|
||||||
|
type: 'area',
|
||||||
|
data: [{ id: 'areaData', values: areaValues }],
|
||||||
|
xField: 'Time',
|
||||||
|
yField: 'Usage',
|
||||||
|
seriesField: 'Model',
|
||||||
|
stack: false,
|
||||||
|
legends: { visible: true, selectMode: 'single' },
|
||||||
|
color: modelColor,
|
||||||
|
tooltip: {
|
||||||
|
mark: {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
key: (datum: Record<string, unknown>) => datum?.Model,
|
||||||
|
value: (datum: Record<string, unknown>) =>
|
||||||
|
formatQuotaValue(Number(datum?.rawQuota) || 0),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
dimension: {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
key: (datum: Record<string, unknown>) => datum?.Model,
|
||||||
|
value: (datum: Record<string, unknown>) =>
|
||||||
|
Number(datum?.rawQuota) || 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
updateContent: makeTooltipDimensionUpdateContent(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
area: {
|
||||||
|
style: {
|
||||||
|
fillOpacity: 0.08,
|
||||||
|
curveType: 'monotone',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
line: {
|
||||||
|
style: {
|
||||||
|
lineWidth: 2,
|
||||||
|
curveType: 'monotone',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
point: { visible: false },
|
||||||
background: { fill: 'transparent' },
|
background: { fill: 'transparent' },
|
||||||
animation: true,
|
animation: true,
|
||||||
},
|
},
|
||||||
spec_model_line: {
|
spec_model_line: {
|
||||||
type: 'line',
|
type: 'area',
|
||||||
data: [{ id: 'lineData', values: modelLineValues }],
|
data: [{ id: 'lineData', values: modelLineValues }],
|
||||||
xField: 'Time',
|
xField: 'Time',
|
||||||
yField: 'Count',
|
yField: 'Count',
|
||||||
seriesField: 'Model',
|
seriesField: 'Model',
|
||||||
|
stack: false,
|
||||||
legends: { visible: true, selectMode: 'single' },
|
legends: { visible: true, selectMode: 'single' },
|
||||||
|
color: modelColor,
|
||||||
title: {
|
title: {
|
||||||
visible: true,
|
visible: true,
|
||||||
text: tt('Call Trend'),
|
text: tt('Call Trend'),
|
||||||
@ -442,7 +584,18 @@ export function processChartData(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
color: { specified: modelColorMap },
|
area: {
|
||||||
|
style: {
|
||||||
|
fillOpacity: 0.08,
|
||||||
|
curveType: 'monotone',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
line: {
|
||||||
|
style: {
|
||||||
|
lineWidth: 2,
|
||||||
|
curveType: 'monotone',
|
||||||
|
},
|
||||||
|
},
|
||||||
point: { visible: false },
|
point: { visible: false },
|
||||||
background: { fill: 'transparent' },
|
background: { fill: 'transparent' },
|
||||||
animation: true,
|
animation: true,
|
||||||
@ -454,9 +607,10 @@ export function processChartData(
|
|||||||
yField: 'Count',
|
yField: 'Count',
|
||||||
seriesField: 'Model',
|
seriesField: 'Model',
|
||||||
legends: { visible: true, selectMode: 'single' },
|
legends: { visible: true, selectMode: 'single' },
|
||||||
|
color: modelColor,
|
||||||
title: {
|
title: {
|
||||||
visible: true,
|
visible: true,
|
||||||
text: tt('Top Models'),
|
text: tt('Call Count Ranking'),
|
||||||
subtext: `${tt('Total:')} ${formatInt(totalTimes)}`,
|
subtext: `${tt('Total:')} ${formatInt(totalTimes)}`,
|
||||||
},
|
},
|
||||||
bar: {
|
bar: {
|
||||||
@ -475,10 +629,10 @@ export function processChartData(
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
color: { specified: modelColorMap },
|
|
||||||
background: { fill: 'transparent' },
|
background: { fill: 'transparent' },
|
||||||
animation: true,
|
animation: true,
|
||||||
},
|
},
|
||||||
|
totalQuotaDisplay: formatQuotaTotal(totalQuotaRaw),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -505,12 +659,7 @@ export function processUserChartData(
|
|||||||
const { config } = getCurrencyDisplay()
|
const { config } = getCurrencyDisplay()
|
||||||
const quotaPerUnit = config.quotaPerUnit
|
const quotaPerUnit = config.quotaPerUnit
|
||||||
|
|
||||||
const formatVal = (raw: number) =>
|
const formatVal = (raw: number) => renderQuotaCompat(raw, 2)
|
||||||
formatQuotaWithCurrency(raw, {
|
|
||||||
digitsLarge: 2,
|
|
||||||
digitsSmall: 2,
|
|
||||||
abbreviate: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const emptyResult: ProcessedUserChartData = {
|
const emptyResult: ProcessedUserChartData = {
|
||||||
spec_user_rank: {
|
spec_user_rank: {
|
||||||
|
|||||||
@ -45,14 +45,12 @@ export function buildQueryParams(
|
|||||||
): {
|
): {
|
||||||
start_timestamp: number
|
start_timestamp: number
|
||||||
end_timestamp: number
|
end_timestamp: number
|
||||||
default_time?: string
|
default_time: string
|
||||||
username?: string
|
username?: string
|
||||||
} {
|
} {
|
||||||
return {
|
return {
|
||||||
...timeRange,
|
...timeRange,
|
||||||
...(filters?.time_granularity && {
|
default_time: getSavedGranularity(filters?.time_granularity),
|
||||||
default_time: filters.time_granularity,
|
|
||||||
}),
|
|
||||||
...(filters?.username && { username: filters.username }),
|
...(filters?.username && { username: filters.username }),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 {
|
export interface ProcessedChartData {
|
||||||
spec_pie: VChartSpec
|
spec_pie: VChartSpec
|
||||||
spec_line: VChartSpec
|
spec_line: VChartSpec
|
||||||
|
spec_area: VChartSpec
|
||||||
spec_model_line: VChartSpec
|
spec_model_line: VChartSpec
|
||||||
spec_rank_bar: VChartSpec
|
spec_rank_bar: VChartSpec
|
||||||
|
totalQuotaDisplay: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProcessedUserChartData {
|
export interface ProcessedUserChartData {
|
||||||
|
|||||||
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 {}
|
if (!res.success || !res.data) return {}
|
||||||
const ratios: Record<string, number> = {}
|
const ratios: Record<string, number> = {}
|
||||||
for (const [group, info] of Object.entries(res.data)) {
|
for (const [group, info] of Object.entries(res.data)) {
|
||||||
if (info.ratio !== undefined) {
|
if (typeof info.ratio === 'number') {
|
||||||
ratios[group] = info.ratio
|
ratios[group] = info.ratio
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
import { ApiKeysDeleteDialog } from './api-keys-delete-dialog'
|
import { ApiKeysDeleteDialog } from './api-keys-delete-dialog'
|
||||||
import { ApiKeysMutateDrawer } from './api-keys-mutate-drawer'
|
import { ApiKeysMutateDrawer } from './api-keys-mutate-drawer'
|
||||||
import { useApiKeys } from './api-keys-provider'
|
import { useApiKeys } from './api-keys-provider'
|
||||||
@ -5,6 +6,19 @@ import { CCSwitchDialog } from './dialogs/cc-switch-dialog'
|
|||||||
|
|
||||||
export function ApiKeysDialogs() {
|
export function ApiKeysDialogs() {
|
||||||
const { open, setOpen, currentRow, resolvedKey } = useApiKeys()
|
const { open, setOpen, currentRow, resolvedKey } = useApiKeys()
|
||||||
|
const [lastMutateSide, setLastMutateSide] = useState<'left' | 'right'>(
|
||||||
|
'right'
|
||||||
|
)
|
||||||
|
const mutateSide =
|
||||||
|
open === 'create' ? 'left' : open === 'update' ? 'right' : lastMutateSide
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open === 'create') {
|
||||||
|
setLastMutateSide('left')
|
||||||
|
} else if (open === 'update') {
|
||||||
|
setLastMutateSide('right')
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -12,6 +26,7 @@ export function ApiKeysDialogs() {
|
|||||||
open={open === 'create' || open === 'update'}
|
open={open === 'create' || open === 'update'}
|
||||||
onOpenChange={(isOpen) => !isOpen && setOpen(null)}
|
onOpenChange={(isOpen) => !isOpen && setOpen(null)}
|
||||||
currentRow={open === 'update' ? currentRow || undefined : undefined}
|
currentRow={open === 'update' ? currentRow || undefined : undefined}
|
||||||
|
side={mutateSide}
|
||||||
/>
|
/>
|
||||||
<ApiKeysDeleteDialog />
|
<ApiKeysDeleteDialog />
|
||||||
<CCSwitchDialog
|
<CCSwitchDialog
|
||||||
|
|||||||
@ -23,13 +23,6 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@/components/ui/form'
|
} from '@/components/ui/form'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select'
|
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetClose,
|
SheetClose,
|
||||||
@ -53,18 +46,24 @@ import {
|
|||||||
transformApiKeyToFormDefaults,
|
transformApiKeyToFormDefaults,
|
||||||
} from '../lib'
|
} from '../lib'
|
||||||
import { type ApiKey } from '../types'
|
import { type ApiKey } from '../types'
|
||||||
|
import {
|
||||||
|
ApiKeyGroupCombobox,
|
||||||
|
type ApiKeyGroupOption,
|
||||||
|
} from './api-key-group-combobox'
|
||||||
import { useApiKeys } from './api-keys-provider'
|
import { useApiKeys } from './api-keys-provider'
|
||||||
|
|
||||||
type ApiKeyMutateDrawerProps = {
|
type ApiKeyMutateDrawerProps = {
|
||||||
open: boolean
|
open: boolean
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
currentRow?: ApiKey
|
currentRow?: ApiKey
|
||||||
|
side?: 'left' | 'right'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ApiKeysMutateDrawer({
|
export function ApiKeysMutateDrawer({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
currentRow,
|
currentRow,
|
||||||
|
side = 'right',
|
||||||
}: ApiKeyMutateDrawerProps) {
|
}: ApiKeyMutateDrawerProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const isUpdate = !!currentRow
|
const isUpdate = !!currentRow
|
||||||
@ -88,14 +87,22 @@ export function ApiKeysMutateDrawer({
|
|||||||
|
|
||||||
const models = modelsData?.data || []
|
const models = modelsData?.data || []
|
||||||
const groupsRaw = groupsData?.data || {}
|
const groupsRaw = groupsData?.data || {}
|
||||||
const groups = Object.entries(groupsRaw).map(([key, info]) => ({
|
const groups: ApiKeyGroupOption[] = Object.entries(groupsRaw).map(
|
||||||
value: key,
|
([key, info]) => ({
|
||||||
label: info.desc || key,
|
value: key,
|
||||||
}))
|
label: key,
|
||||||
|
desc: info.desc || key,
|
||||||
|
ratio: info.ratio,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
// Add auto group if configured
|
// Add auto group if configured
|
||||||
if (!groups.some((g) => g.value === 'auto')) {
|
if (!groups.some((g) => g.value === 'auto')) {
|
||||||
groups.unshift({ value: 'auto', label: t('Auto (Circuit Breaker)') })
|
groups.unshift({
|
||||||
|
value: 'auto',
|
||||||
|
label: 'auto',
|
||||||
|
desc: t('Auto (Circuit Breaker)'),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const form = useForm<ApiKeyFormValues>({
|
const form = useForm<ApiKeyFormValues>({
|
||||||
@ -187,10 +194,9 @@ export function ApiKeysMutateDrawer({
|
|||||||
form.setValue('expired_time', now)
|
form.setValue('expired_time', now)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { config: currencyConfig, meta: currencyMeta } = getCurrencyDisplay()
|
const { meta: currencyMeta } = getCurrencyDisplay()
|
||||||
const currencyLabel = getCurrencyLabel()
|
const currencyLabel = getCurrencyLabel()
|
||||||
const tokensOnly =
|
const tokensOnly = currencyMeta.kind === 'tokens'
|
||||||
!currencyConfig.displayInCurrency || currencyMeta.kind === 'tokens'
|
|
||||||
const quotaLabel = t('Quota ({{currency}})', { currency: currencyLabel })
|
const quotaLabel = t('Quota ({{currency}})', { currency: currencyLabel })
|
||||||
const quotaPlaceholder = tokensOnly
|
const quotaPlaceholder = tokensOnly
|
||||||
? t('Enter quota in tokens')
|
? t('Enter quota in tokens')
|
||||||
@ -206,7 +212,10 @@ export function ApiKeysMutateDrawer({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SheetContent className='flex w-full flex-col sm:max-w-[600px]'>
|
<SheetContent
|
||||||
|
side={side}
|
||||||
|
className='flex w-full flex-col sm:max-w-[600px]'
|
||||||
|
>
|
||||||
<SheetHeader className='text-start'>
|
<SheetHeader className='text-start'>
|
||||||
<SheetTitle>
|
<SheetTitle>
|
||||||
{isUpdate ? t('Update API Key') : t('Create API Key')}
|
{isUpdate ? t('Update API Key') : t('Create API Key')}
|
||||||
@ -244,20 +253,14 @@ export function ApiKeysMutateDrawer({
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t('Group')}</FormLabel>
|
<FormLabel>{t('Group')}</FormLabel>
|
||||||
<Select onValueChange={field.onChange} value={field.value}>
|
<FormControl>
|
||||||
<FormControl>
|
<ApiKeyGroupCombobox
|
||||||
<SelectTrigger>
|
options={groups}
|
||||||
<SelectValue placeholder={t('Select a group')} />
|
value={field.value}
|
||||||
</SelectTrigger>
|
onValueChange={field.onChange}
|
||||||
</FormControl>
|
placeholder={t('Select a group')}
|
||||||
<SelectContent>
|
/>
|
||||||
{groups.map((group) => (
|
</FormControl>
|
||||||
<SelectItem key={group.value} value={group.value}>
|
|
||||||
{group.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{t('Auto group enables circuit breaker mechanism')}
|
{t('Auto group enables circuit breaker mechanism')}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
|
|||||||
@ -38,6 +38,7 @@ import { getApiKeys, searchApiKeys } from '../api'
|
|||||||
import { API_KEY_STATUS_OPTIONS, ERROR_MESSAGES } from '../constants'
|
import { API_KEY_STATUS_OPTIONS, ERROR_MESSAGES } from '../constants'
|
||||||
import { type ApiKey } from '../types'
|
import { type ApiKey } from '../types'
|
||||||
import { useApiKeysColumns } from './api-keys-columns'
|
import { useApiKeysColumns } from './api-keys-columns'
|
||||||
|
import { ApiKeysPrimaryButtons } from './api-keys-primary-buttons'
|
||||||
import { useApiKeys } from './api-keys-provider'
|
import { useApiKeys } from './api-keys-provider'
|
||||||
import { DataTableBulkActions } from './data-table-bulk-actions'
|
import { DataTableBulkActions } from './data-table-bulk-actions'
|
||||||
|
|
||||||
@ -160,17 +161,22 @@ export function ApiKeysTable() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className='space-y-4'>
|
<div className='space-y-4'>
|
||||||
<DataTableToolbar
|
<div className='flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between'>
|
||||||
table={table}
|
<ApiKeysPrimaryButtons />
|
||||||
searchPlaceholder={t('Filter by name or key...')}
|
<div className='min-w-0 sm:flex sm:justify-end'>
|
||||||
filters={[
|
<DataTableToolbar
|
||||||
{
|
table={table}
|
||||||
columnId: 'status',
|
searchPlaceholder={t('Filter by name or key...')}
|
||||||
title: t('Status'),
|
filters={[
|
||||||
options: API_KEY_STATUS_OPTIONS,
|
{
|
||||||
},
|
columnId: 'status',
|
||||||
]}
|
title: t('Status'),
|
||||||
/>
|
options: API_KEY_STATUS_OPTIONS,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
<MobileCardList
|
<MobileCardList
|
||||||
table={table}
|
table={table}
|
||||||
|
|||||||
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 { useTranslation } from 'react-i18next'
|
||||||
import { SectionPageLayout } from '@/components/layout'
|
import { SectionPageLayout } from '@/components/layout'
|
||||||
import { ApiKeysDialogs } from './components/api-keys-dialogs'
|
import { ApiKeysDialogs } from './components/api-keys-dialogs'
|
||||||
import { ApiKeysPrimaryButtons } from './components/api-keys-primary-buttons'
|
|
||||||
import { ApiKeysProvider } from './components/api-keys-provider'
|
import { ApiKeysProvider } from './components/api-keys-provider'
|
||||||
import { ApiKeysTable } from './components/api-keys-table'
|
import { ApiKeysTable } from './components/api-keys-table'
|
||||||
|
|
||||||
@ -14,9 +13,6 @@ export function ApiKeys() {
|
|||||||
<SectionPageLayout.Description>
|
<SectionPageLayout.Description>
|
||||||
{t('Manage your API keys for accessing the service')}
|
{t('Manage your API keys for accessing the service')}
|
||||||
</SectionPageLayout.Description>
|
</SectionPageLayout.Description>
|
||||||
<SectionPageLayout.Actions>
|
|
||||||
<ApiKeysPrimaryButtons />
|
|
||||||
</SectionPageLayout.Actions>
|
|
||||||
<SectionPageLayout.Content>
|
<SectionPageLayout.Content>
|
||||||
<ApiKeysTable />
|
<ApiKeysTable />
|
||||||
</SectionPageLayout.Content>
|
</SectionPageLayout.Content>
|
||||||
|
|||||||
@ -115,10 +115,9 @@ export function RedemptionsMutateDrawer({
|
|||||||
form.setValue('expired_time', newDate)
|
form.setValue('expired_time', newDate)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { config: currencyConfig, meta: currencyMeta } = getCurrencyDisplay()
|
const { meta: currencyMeta } = getCurrencyDisplay()
|
||||||
const currencyLabel = getCurrencyLabel()
|
const currencyLabel = getCurrencyLabel()
|
||||||
const tokensOnly =
|
const tokensOnly = currencyMeta.kind === 'tokens'
|
||||||
!currencyConfig.displayInCurrency || currencyMeta.kind === 'tokens'
|
|
||||||
const quotaLabel = t('Quota ({{currency}})', { currency: currencyLabel })
|
const quotaLabel = t('Quota ({{currency}})', { currency: currencyLabel })
|
||||||
const quotaPlaceholder = tokensOnly
|
const quotaPlaceholder = tokensOnly
|
||||||
? t('Enter quota in tokens')
|
? t('Enter quota in tokens')
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import type { Resolver } from 'react-hook-form'
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import { RotateCcw } from 'lucide-react'
|
import { RotateCcw } from 'lucide-react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { DEFAULT_CURRENCY_CONFIG } from '@/stores/system-config-store'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
@ -110,6 +111,12 @@ export function PricingSection({ defaultValues }: PricingSectionProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const displayType = form.watch('general_setting.quota_display_type') ?? 'USD'
|
const displayType = form.watch('general_setting.quota_display_type') ?? 'USD'
|
||||||
|
const displayInCurrencyEnabled = form.watch('DisplayInCurrencyEnabled')
|
||||||
|
const showTokensOnlyOption = displayType === 'TOKENS'
|
||||||
|
const showQuotaPerUnit =
|
||||||
|
displayType === 'TOKENS' ||
|
||||||
|
defaultValues.QuotaPerUnit !== DEFAULT_CURRENCY_CONFIG.quotaPerUnit
|
||||||
|
const showDisplayInCurrencyOption = displayInCurrencyEnabled === false
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -122,30 +129,32 @@ export function PricingSection({ defaultValues }: PricingSectionProps) {
|
|||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={handleSubmit} className='space-y-6'>
|
<form onSubmit={handleSubmit} className='space-y-6'>
|
||||||
<FormDirtyIndicator isDirty={isDirty} />
|
<FormDirtyIndicator isDirty={isDirty} />
|
||||||
<FormField
|
{showQuotaPerUnit && (
|
||||||
control={form.control}
|
<FormField
|
||||||
name='QuotaPerUnit'
|
control={form.control}
|
||||||
render={({ field }) => (
|
name='QuotaPerUnit'
|
||||||
<FormItem>
|
render={({ field }) => (
|
||||||
<FormLabel>{t('Quota Per Unit')}</FormLabel>
|
<FormItem>
|
||||||
<FormControl>
|
<FormLabel>{t('Quota Per Unit')}</FormLabel>
|
||||||
<Input
|
<FormControl>
|
||||||
type='number'
|
<Input
|
||||||
step='0.01'
|
type='number'
|
||||||
value={field.value as number}
|
step='0.01'
|
||||||
onChange={(e) => field.onChange(e.target.valueAsNumber)}
|
value={field.value as number}
|
||||||
name={field.name}
|
disabled
|
||||||
onBlur={field.onBlur}
|
name={field.name}
|
||||||
ref={field.ref}
|
onBlur={field.onBlur}
|
||||||
/>
|
ref={field.ref}
|
||||||
</FormControl>
|
/>
|
||||||
<FormDescription>
|
</FormControl>
|
||||||
{t('Number of tokens per unit quota')}
|
<FormDescription>
|
||||||
</FormDescription>
|
{t('Number of tokens per unit quota')}
|
||||||
<FormMessage />
|
</FormDescription>
|
||||||
</FormItem>
|
<FormMessage />
|
||||||
)}
|
</FormItem>
|
||||||
/>
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@ -165,7 +174,11 @@ export function PricingSection({ defaultValues }: PricingSectionProps) {
|
|||||||
<SelectItem value='CUSTOM'>
|
<SelectItem value='CUSTOM'>
|
||||||
{t('Custom Currency')}
|
{t('Custom Currency')}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value='TOKENS'>{t('Tokens Only')}</SelectItem>
|
{showTokensOnlyOption && (
|
||||||
|
<SelectItem value='TOKENS'>
|
||||||
|
{t('Tokens Only')}
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
@ -272,32 +285,34 @@ export function PricingSection({ defaultValues }: PricingSectionProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<FormField
|
{showDisplayInCurrencyOption && (
|
||||||
control={form.control}
|
<FormField
|
||||||
name='DisplayInCurrencyEnabled'
|
control={form.control}
|
||||||
render={({ field }) => (
|
name='DisplayInCurrencyEnabled'
|
||||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
render={({ field }) => (
|
||||||
<div className='space-y-0.5'>
|
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||||
<FormLabel className='text-base'>
|
<div className='space-y-0.5'>
|
||||||
{t('Display in Currency')}
|
<FormLabel className='text-base'>
|
||||||
</FormLabel>
|
{t('Display in Currency')}
|
||||||
<FormDescription>
|
</FormLabel>
|
||||||
{displayType === 'TOKENS'
|
<FormDescription>
|
||||||
? t(
|
{displayType === 'TOKENS'
|
||||||
'Tokens-only mode will show raw quota values regardless of this toggle.'
|
? t(
|
||||||
)
|
'Tokens-only mode will show raw quota values regardless of this toggle.'
|
||||||
: t('Show prices in currency instead of quota.')}
|
)
|
||||||
</FormDescription>
|
: t('Show prices in currency instead of quota.')}
|
||||||
</div>
|
</FormDescription>
|
||||||
<FormControl>
|
</div>
|
||||||
<Switch
|
<FormControl>
|
||||||
checked={field.value}
|
<Switch
|
||||||
onCheckedChange={field.onChange}
|
checked={field.value}
|
||||||
/>
|
onCheckedChange={field.onChange}
|
||||||
</FormControl>
|
/>
|
||||||
</FormItem>
|
</FormControl>
|
||||||
)}
|
</FormItem>
|
||||||
/>
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
formatLogQuota,
|
formatLogQuota,
|
||||||
formatTimestampToDate,
|
formatTimestampToDate,
|
||||||
} from '@/lib/format'
|
} from '@/lib/format'
|
||||||
|
import { getAvatarColorClass } from '@/lib/colors'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import {
|
import {
|
||||||
@ -241,19 +242,6 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
|||||||
size='sm'
|
size='sm'
|
||||||
copyable={false}
|
copyable={false}
|
||||||
/>
|
/>
|
||||||
{log.request_id && (
|
|
||||||
<StatusBadge
|
|
||||||
label={
|
|
||||||
log.request_id.length > 18
|
|
||||||
? `${log.request_id.slice(0, 18)}…`
|
|
||||||
: log.request_id
|
|
||||||
}
|
|
||||||
variant='neutral'
|
|
||||||
size='sm'
|
|
||||||
copyText={log.request_id}
|
|
||||||
className='max-w-[140px] truncate font-mono'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@ -267,45 +255,47 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
|||||||
]
|
]
|
||||||
|
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
columns.push({
|
columns.push(
|
||||||
id: 'source',
|
{
|
||||||
header: ({ column }) => (
|
id: 'channel',
|
||||||
<DataTableColumnHeader column={column} title={t('Source')} />
|
header: ({ column }) => (
|
||||||
),
|
<DataTableColumnHeader column={column} title={t('Channel')} />
|
||||||
cell: function SourceCell({ row }) {
|
),
|
||||||
const {
|
cell: function ChannelCell({ row }) {
|
||||||
setAffinityTarget,
|
const {
|
||||||
setAffinityDialogOpen,
|
sensitiveVisible,
|
||||||
setSelectedUserId,
|
setAffinityTarget,
|
||||||
setUserInfoDialogOpen,
|
setAffinityDialogOpen,
|
||||||
} = useUsageLogsContext()
|
} = useUsageLogsContext()
|
||||||
const log = row.original
|
const log = row.original
|
||||||
|
|
||||||
if (!isDisplayableLogType(log.type)) return null
|
if (!isDisplayableLogType(log.type)) return null
|
||||||
|
|
||||||
const other = parseLogOther(log.other)
|
const other = parseLogOther(log.other)
|
||||||
const affinity = other?.admin_info?.channel_affinity
|
const affinity = other?.admin_info?.channel_affinity
|
||||||
const useChannel = other?.admin_info?.use_channel
|
const useChannel = other?.admin_info?.use_channel
|
||||||
const channelChain =
|
const channelChain =
|
||||||
useChannel && useChannel.length > 0
|
useChannel && useChannel.length > 0
|
||||||
? useChannel.join(' → ')
|
? useChannel.join(' → ')
|
||||||
: undefined
|
: undefined
|
||||||
const channelDisplay = log.channel_name
|
const channelDisplay = log.channel_name
|
||||||
? `${log.channel_name} #${log.channel}`
|
? `${log.channel_name} #${log.channel}`
|
||||||
: `#${log.channel}`
|
: `#${log.channel}`
|
||||||
|
const channelIdDisplay = `#${log.channel}`
|
||||||
|
const channelName = sensitiveVisible ? log.channel_name : '••••'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex flex-col gap-1'>
|
<TooltipProvider>
|
||||||
<div className='flex items-center gap-1'>
|
<Tooltip>
|
||||||
<TooltipProvider>
|
<TooltipTrigger asChild>
|
||||||
<Tooltip>
|
<div className='flex max-w-[160px] flex-col gap-0.5'>
|
||||||
<TooltipTrigger asChild>
|
<div className='relative inline-flex w-fit'>
|
||||||
<div className='relative'>
|
|
||||||
<StatusBadge
|
<StatusBadge
|
||||||
label={channelDisplay}
|
label={channelIdDisplay}
|
||||||
autoColor={log.channel_name || String(log.channel)}
|
autoColor={String(log.channel)}
|
||||||
copyText={String(log.channel)}
|
copyText={String(log.channel)}
|
||||||
size='sm'
|
size='sm'
|
||||||
|
className='font-mono'
|
||||||
/>
|
/>
|
||||||
{affinity && (
|
{affinity && (
|
||||||
<button
|
<button
|
||||||
@ -329,57 +319,89 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
{log.channel_name && (
|
||||||
<TooltipContent>
|
<span className='text-muted-foreground/70 truncate text-[11px]'>
|
||||||
<div className='space-y-1'>
|
{channelName}
|
||||||
<p>{channelDisplay}</p>
|
</span>
|
||||||
{channelChain && (
|
)}
|
||||||
<p className='text-muted-foreground text-xs'>
|
</div>
|
||||||
{t('Chain')}: {channelChain}
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<div className='space-y-1'>
|
||||||
|
<p>{sensitiveVisible ? channelDisplay : channelIdDisplay}</p>
|
||||||
|
{channelChain && (
|
||||||
|
<p className='text-muted-foreground text-xs'>
|
||||||
|
{t('Chain')}: {channelChain}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{affinity && (
|
||||||
|
<div className='border-t pt-1 text-xs'>
|
||||||
|
<p className='font-medium'>{t('Channel Affinity')}</p>
|
||||||
|
<p>
|
||||||
|
{t('Rule')}: {affinity.rule_name || '-'}
|
||||||
</p>
|
</p>
|
||||||
)}
|
<p>
|
||||||
{affinity && (
|
{t('Group')}:{' '}
|
||||||
<div className='border-t pt-1 text-xs'>
|
{sensitiveVisible
|
||||||
<p className='font-medium'>{t('Channel Affinity')}</p>
|
? affinity.using_group ||
|
||||||
<p>
|
|
||||||
{t('Rule')}: {affinity.rule_name || '-'}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
{t('Group')}:{' '}
|
|
||||||
{affinity.using_group ||
|
|
||||||
affinity.selected_group ||
|
affinity.selected_group ||
|
||||||
'-'}
|
'-'
|
||||||
</p>
|
: '••••'}
|
||||||
</div>
|
</p>
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
</TooltipContent>
|
</div>
|
||||||
</Tooltip>
|
</TooltipContent>
|
||||||
</TooltipProvider>
|
</Tooltip>
|
||||||
</div>
|
</TooltipProvider>
|
||||||
{log.username && (
|
)
|
||||||
<button
|
},
|
||||||
type='button'
|
meta: { label: t('Channel'), mobileHidden: true },
|
||||||
className='flex items-center gap-1 text-left'
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
setSelectedUserId(log.user_id)
|
|
||||||
setUserInfoDialogOpen(true)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className='bg-primary/10 text-primary flex size-4 items-center justify-center rounded-full text-[10px] font-bold'>
|
|
||||||
{log.username.charAt(0).toUpperCase()}
|
|
||||||
</span>
|
|
||||||
<span className='text-muted-foreground truncate text-xs hover:underline'>
|
|
||||||
{log.username}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
meta: { label: t('Source'), mobileHidden: true },
|
{
|
||||||
})
|
id: 'user',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title={t('User')} />
|
||||||
|
),
|
||||||
|
cell: function UserCell({ row }) {
|
||||||
|
const {
|
||||||
|
sensitiveVisible,
|
||||||
|
setSelectedUserId,
|
||||||
|
setUserInfoDialogOpen,
|
||||||
|
} = useUsageLogsContext()
|
||||||
|
const log = row.original
|
||||||
|
|
||||||
|
if (!isDisplayableLogType(log.type) || !log.username) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className='flex items-center gap-1.5 text-left'
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setSelectedUserId(log.user_id)
|
||||||
|
setUserInfoDialogOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'flex size-5 items-center justify-center rounded-full text-[11px] font-bold',
|
||||||
|
sensitiveVisible
|
||||||
|
? getAvatarColorClass(log.username)
|
||||||
|
: 'bg-muted text-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{sensitiveVisible ? log.username.charAt(0).toUpperCase() : '•'}
|
||||||
|
</span>
|
||||||
|
<span className='text-muted-foreground truncate text-sm hover:underline'>
|
||||||
|
{sensitiveVisible ? log.username : '••••'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: { label: t('User'), mobileHidden: true },
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
columns.push(
|
columns.push(
|
||||||
@ -389,6 +411,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
|||||||
<DataTableColumnHeader column={column} title={t('Model')} />
|
<DataTableColumnHeader column={column} title={t('Model')} />
|
||||||
),
|
),
|
||||||
cell: function ModelCell({ row }) {
|
cell: function ModelCell({ row }) {
|
||||||
|
const { sensitiveVisible } = useUsageLogsContext()
|
||||||
const log = row.original
|
const log = row.original
|
||||||
if (!isDisplayableLogType(log.type)) return null
|
if (!isDisplayableLogType(log.type)) return null
|
||||||
|
|
||||||
@ -450,8 +473,8 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const metaParts: string[] = []
|
const metaParts: string[] = []
|
||||||
if (tokenName) metaParts.push(tokenName)
|
if (tokenName) metaParts.push(sensitiveVisible ? tokenName : '••••')
|
||||||
if (group) metaParts.push(group)
|
if (group) metaParts.push(sensitiveVisible ? group : '••••')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex max-w-[220px] flex-col gap-0.5'>
|
<div className='flex max-w-[220px] flex-col gap-0.5'>
|
||||||
|
|||||||
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 { cn } from '@/lib/utils'
|
||||||
import { useIsAdmin } from '@/hooks/use-admin'
|
import { useIsAdmin } from '@/hooks/use-admin'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { dotColorMap, textColorMap } from '@/components/status-badge'
|
|
||||||
import { getLogStats, getUserLogStats } from '../api'
|
import { getLogStats, getUserLogStats } from '../api'
|
||||||
import { DEFAULT_LOG_STATS } from '../constants'
|
import { DEFAULT_LOG_STATS } from '../constants'
|
||||||
import { buildApiParams } from '../lib/utils'
|
import { buildApiParams } from '../lib/utils'
|
||||||
|
import { useUsageLogsContext } from './usage-logs-provider'
|
||||||
|
|
||||||
const route = getRouteApi('/_authenticated/usage-logs/$section')
|
const route = getRouteApi('/_authenticated/usage-logs/$section')
|
||||||
|
|
||||||
@ -16,6 +16,7 @@ export function CommonLogsStats() {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const isAdmin = useIsAdmin()
|
const isAdmin = useIsAdmin()
|
||||||
const searchParams = route.useSearch()
|
const searchParams = route.useSearch()
|
||||||
|
const { sensitiveVisible } = useUsageLogsContext()
|
||||||
|
|
||||||
const { data: stats, isLoading } = useQuery({
|
const { data: stats, isLoading } = useQuery({
|
||||||
queryKey: ['usage-logs-stats', isAdmin, searchParams],
|
queryKey: ['usage-logs-stats', isAdmin, searchParams],
|
||||||
@ -41,29 +42,42 @@ export function CommonLogsStats() {
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className='flex items-center gap-2'>
|
<div className='flex items-center gap-1.5'>
|
||||||
<Skeleton className='h-6 w-[126px] rounded-md' />
|
<Skeleton className='h-6 w-[126px] rounded-md' />
|
||||||
<Skeleton className='h-6 w-[58px] rounded-md' />
|
<Skeleton className='h-6 w-[76px] rounded-md' />
|
||||||
<Skeleton className='h-6 w-[58px] rounded-md' />
|
<Skeleton className='h-6 w-[92px] rounded-md' />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tagClass =
|
||||||
|
'inline-flex h-6 items-center rounded-md border px-2.5 text-xs font-medium shadow-xs'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex items-center gap-1.5 text-xs font-medium'>
|
<div className='flex flex-wrap items-center gap-1.5'>
|
||||||
<span
|
<span
|
||||||
className={cn('size-1.5 shrink-0 rounded-full', dotColorMap.blue)}
|
className={cn(
|
||||||
aria-hidden='true'
|
tagClass,
|
||||||
/>
|
'border-blue-200/70 bg-blue-50 text-blue-700 dark:border-blue-500/20 dark:bg-blue-500/10 dark:text-blue-300'
|
||||||
<span className={cn(textColorMap.blue)}>
|
)}
|
||||||
{t('Usage')}: {formatLogQuota(stats?.quota || 0)}
|
>
|
||||||
|
{t('Usage')}:{' '}
|
||||||
|
{sensitiveVisible ? formatLogQuota(stats?.quota || 0) : '••••'}
|
||||||
</span>
|
</span>
|
||||||
<span className='text-muted-foreground/30'>·</span>
|
<span
|
||||||
<span className={cn(textColorMap.pink)}>
|
className={cn(
|
||||||
|
tagClass,
|
||||||
|
'border-pink-200/70 bg-pink-50 text-pink-700 dark:border-pink-500/20 dark:bg-pink-500/10 dark:text-pink-300'
|
||||||
|
)}
|
||||||
|
>
|
||||||
{t('RPM')}: {stats?.rpm || 0}
|
{t('RPM')}: {stats?.rpm || 0}
|
||||||
</span>
|
</span>
|
||||||
<span className='text-muted-foreground/30'>·</span>
|
<span
|
||||||
<span className='text-muted-foreground'>
|
className={cn(
|
||||||
|
tagClass,
|
||||||
|
'border-border bg-background text-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
{t('TPM')}: {stats?.tpm || 0}
|
{t('TPM')}: {stats?.tpm || 0}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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
|
setAffinityTarget: (target: ChannelAffinityInfo | null) => void
|
||||||
affinityDialogOpen: boolean
|
affinityDialogOpen: boolean
|
||||||
setAffinityDialogOpen: (open: boolean) => void
|
setAffinityDialogOpen: (open: boolean) => void
|
||||||
|
sensitiveVisible: boolean
|
||||||
|
setSensitiveVisible: (visible: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const UsageLogsContext = createContext<UsageLogsContextValue | undefined>(
|
const UsageLogsContext = createContext<UsageLogsContextValue | undefined>(
|
||||||
@ -23,6 +25,7 @@ export function UsageLogsProvider({ children }: { children: ReactNode }) {
|
|||||||
const [affinityTarget, setAffinityTarget] =
|
const [affinityTarget, setAffinityTarget] =
|
||||||
useState<ChannelAffinityInfo | null>(null)
|
useState<ChannelAffinityInfo | null>(null)
|
||||||
const [affinityDialogOpen, setAffinityDialogOpen] = useState(false)
|
const [affinityDialogOpen, setAffinityDialogOpen] = useState(false)
|
||||||
|
const [sensitiveVisible, setSensitiveVisible] = useState(true)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UsageLogsContext.Provider
|
<UsageLogsContext.Provider
|
||||||
@ -35,6 +38,8 @@ export function UsageLogsProvider({ children }: { children: ReactNode }) {
|
|||||||
setAffinityTarget,
|
setAffinityTarget,
|
||||||
affinityDialogOpen,
|
affinityDialogOpen,
|
||||||
setAffinityDialogOpen,
|
setAffinityDialogOpen,
|
||||||
|
sensitiveVisible,
|
||||||
|
setSensitiveVisible,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@ -28,15 +28,18 @@ import {
|
|||||||
import {
|
import {
|
||||||
DataTablePagination,
|
DataTablePagination,
|
||||||
DataTableToolbar,
|
DataTableToolbar,
|
||||||
|
DataTableViewOptions,
|
||||||
TableSkeleton,
|
TableSkeleton,
|
||||||
TableEmpty,
|
TableEmpty,
|
||||||
MobileCardList,
|
MobileCardList,
|
||||||
} from '@/components/data-table'
|
} from '@/components/data-table'
|
||||||
import { PageFooterPortal } from '@/components/layout'
|
import { PageFooterPortal } from '@/components/layout'
|
||||||
import { LOG_TYPE_FILTERS, DEFAULT_LOGS_DATA } from '../constants'
|
import { DEFAULT_LOGS_DATA } from '../constants'
|
||||||
import { useColumnsByCategory } from '../lib/columns'
|
import { useColumnsByCategory } from '../lib/columns'
|
||||||
import { fetchLogsByCategory } from '../lib/utils'
|
import { fetchLogsByCategory } from '../lib/utils'
|
||||||
import type { LogCategory } from '../types'
|
import type { LogCategory } from '../types'
|
||||||
|
import { CommonLogsFilterBar } from './common-logs-filter-bar'
|
||||||
|
import { CommonLogsStats } from './common-logs-stats'
|
||||||
|
|
||||||
const route = getRouteApi('/_authenticated/usage-logs/$section')
|
const route = getRouteApi('/_authenticated/usage-logs/$section')
|
||||||
|
|
||||||
@ -147,25 +150,23 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
|
|||||||
ensurePageInRange(pageCount)
|
ensurePageInRange(pageCount)
|
||||||
}, [pageCount, ensurePageInRange])
|
}, [pageCount, ensurePageInRange])
|
||||||
|
|
||||||
const filters =
|
|
||||||
logCategory === 'common'
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
columnId: 'created_at',
|
|
||||||
title: t('Log Type'),
|
|
||||||
options: LOG_TYPE_FILTERS.map((opt) => ({
|
|
||||||
value: opt.value,
|
|
||||||
label: t(opt.label),
|
|
||||||
})),
|
|
||||||
singleSelect: true,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className='space-y-4'>
|
<div className='space-y-4'>
|
||||||
<DataTableToolbar table={table} filters={filters} customSearch={null} />
|
{logCategory === 'common' ? (
|
||||||
|
<div className='rounded-md border bg-card/50 p-3 shadow-xs'>
|
||||||
|
<CommonLogsFilterBar
|
||||||
|
stats={<CommonLogsStats />}
|
||||||
|
viewOptions={<DataTableViewOptions table={table} />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DataTableToolbar
|
||||||
|
table={table}
|
||||||
|
filters={[]}
|
||||||
|
customSearch={null}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
<MobileCardList
|
<MobileCardList
|
||||||
table={table}
|
table={table}
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import { getRouteApi } from '@tanstack/react-router'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { SectionPageLayout } from '@/components/layout'
|
import { SectionPageLayout } from '@/components/layout'
|
||||||
import { CacheStatsDialog } from '@/features/system-settings/general/channel-affinity/cache-stats-dialog'
|
import { CacheStatsDialog } from '@/features/system-settings/general/channel-affinity/cache-stats-dialog'
|
||||||
import { CommonLogsStats } from './components/common-logs-stats'
|
|
||||||
import { UserInfoDialog } from './components/dialogs/user-info-dialog'
|
import { UserInfoDialog } from './components/dialogs/user-info-dialog'
|
||||||
import { UsageLogsPrimaryButtons } from './components/usage-logs-primary-buttons'
|
import { UsageLogsPrimaryButtons } from './components/usage-logs-primary-buttons'
|
||||||
import {
|
import {
|
||||||
@ -60,8 +59,9 @@ function UsageLogsContent() {
|
|||||||
{description}
|
{description}
|
||||||
</SectionPageLayout.Description>
|
</SectionPageLayout.Description>
|
||||||
<SectionPageLayout.Actions>
|
<SectionPageLayout.Actions>
|
||||||
{activeCategory === 'common' && <CommonLogsStats />}
|
{activeCategory !== 'common' && (
|
||||||
<UsageLogsPrimaryButtons logCategory={activeCategory} />
|
<UsageLogsPrimaryButtons logCategory={activeCategory} />
|
||||||
|
)}
|
||||||
</SectionPageLayout.Actions>
|
</SectionPageLayout.Actions>
|
||||||
<SectionPageLayout.Content>
|
<SectionPageLayout.Content>
|
||||||
<UsageLogsTable logCategory={activeCategory} />
|
<UsageLogsTable logCategory={activeCategory} />
|
||||||
|
|||||||
@ -32,10 +32,9 @@ export function UserQuotaDialog(props: UserQuotaDialogProps) {
|
|||||||
const [amount, setAmount] = useState('')
|
const [amount, setAmount] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
const { config: currencyConfig, meta: currencyMeta } = getCurrencyDisplay()
|
const { meta: currencyMeta } = getCurrencyDisplay()
|
||||||
const currencyLabel = getCurrencyLabel()
|
const currencyLabel = getCurrencyLabel()
|
||||||
const tokensOnly =
|
const tokensOnly = currencyMeta.kind === 'tokens'
|
||||||
!currencyConfig.displayInCurrency || currencyMeta.kind === 'tokens'
|
|
||||||
|
|
||||||
const amountValue = parseFloat(amount) || 0
|
const amountValue = parseFloat(amount) || 0
|
||||||
const quotaValue = parseQuotaFromDollars(Math.abs(amountValue))
|
const quotaValue = parseQuotaFromDollars(Math.abs(amountValue))
|
||||||
|
|||||||
@ -95,10 +95,9 @@ export function UsersMutateDrawer({
|
|||||||
}
|
}
|
||||||
}, [open, isUpdate, currentRow, form])
|
}, [open, isUpdate, currentRow, form])
|
||||||
|
|
||||||
const { config: currencyConfig, meta: currencyMeta } = getCurrencyDisplay()
|
const { meta: currencyMeta } = getCurrencyDisplay()
|
||||||
const currencyLabel = getCurrencyLabel()
|
const currencyLabel = getCurrencyLabel()
|
||||||
const tokensOnly =
|
const tokensOnly = currencyMeta.kind === 'tokens'
|
||||||
!currencyConfig.displayInCurrency || currencyMeta.kind === 'tokens'
|
|
||||||
|
|
||||||
const currentQuotaRaw = form.watch('quota_dollars') || 0
|
const currentQuotaRaw = form.watch('quota_dollars') || 0
|
||||||
|
|
||||||
|
|||||||
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?",
|
"Are you sure?": "Are you sure?",
|
||||||
"Args (space separated)": "Args (space separated)",
|
"Args (space separated)": "Args (space separated)",
|
||||||
"Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.": "Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.",
|
"Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.": "Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.",
|
||||||
|
"Area Chart": "Area Chart",
|
||||||
"Asc": "Asc",
|
"Asc": "Asc",
|
||||||
"Ask anything": "Ask anything",
|
"Ask anything": "Ask anything",
|
||||||
"Async task refund": "Async task refund",
|
"Async task refund": "Async task refund",
|
||||||
@ -407,6 +408,7 @@
|
|||||||
"Balance queried successfully": "Balance queried successfully",
|
"Balance queried successfully": "Balance queried successfully",
|
||||||
"Balance updated successfully": "Balance updated successfully",
|
"Balance updated successfully": "Balance updated successfully",
|
||||||
"Balance updated: {{balance}}": "Balance updated: {{balance}}",
|
"Balance updated: {{balance}}": "Balance updated: {{balance}}",
|
||||||
|
"Bar Chart": "Bar Chart",
|
||||||
"Bark Push URL": "Bark Push URL",
|
"Bark Push URL": "Bark Push URL",
|
||||||
"Base address provided by your Epay service": "Base address provided by your Epay service",
|
"Base address provided by your Epay service": "Base address provided by your Epay service",
|
||||||
"Base amount. Actual deduction = base amount × system group rate.": "Base amount. Actual deduction = base amount × system group rate.",
|
"Base amount. Actual deduction = base amount × system group rate.": "Base amount. Actual deduction = base amount × system group rate.",
|
||||||
@ -497,6 +499,8 @@
|
|||||||
"Calculated price: ${{price}} per 1M tokens": "Calculated price: ${{price}} per 1M tokens",
|
"Calculated price: ${{price}} per 1M tokens": "Calculated price: ${{price}} per 1M tokens",
|
||||||
"Calculated ratio: {{ratio}}": "Calculated ratio: {{ratio}}",
|
"Calculated ratio: {{ratio}}": "Calculated ratio: {{ratio}}",
|
||||||
"Calculating...": "Calculating...",
|
"Calculating...": "Calculating...",
|
||||||
|
"Call Count Distribution": "Call Count Distribution",
|
||||||
|
"Call Count Ranking": "Call Count Ranking",
|
||||||
"Call Proportion": "Call Proportion",
|
"Call Proportion": "Call Proportion",
|
||||||
"Call Trend": "Call Trend",
|
"Call Trend": "Call Trend",
|
||||||
"Callback address": "Callback address",
|
"Callback address": "Callback address",
|
||||||
@ -3236,6 +3240,7 @@
|
|||||||
"this token group": "this token group",
|
"this token group": "this token group",
|
||||||
"this user group": "this user group",
|
"this user group": "this user group",
|
||||||
"This user has no bindings": "This user has no bindings",
|
"This user has no bindings": "This user has no bindings",
|
||||||
|
"This week": "This week",
|
||||||
"This will append 2 template rules (Codex CLI and Claude CLI) to the existing rule list.": "This will append 2 template rules (Codex CLI and Claude CLI) to the existing rule list.",
|
"This will append 2 template rules (Codex CLI and Claude CLI) to the existing rule list.": "This will append 2 template rules (Codex CLI and Claude CLI) to the existing rule list.",
|
||||||
"This will clear custom pricing ratios and revert to upstream defaults.": "This will clear custom pricing ratios and revert to upstream defaults.",
|
"This will clear custom pricing ratios and revert to upstream defaults.": "This will clear custom pricing ratios and revert to upstream defaults.",
|
||||||
"This will delete all": "This will delete all",
|
"This will delete all": "This will delete all",
|
||||||
|
|||||||
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 ?",
|
"Are you sure?": "Êtes-vous sûr ?",
|
||||||
"Args (space separated)": "Arguments (séparés par des espaces)",
|
"Args (space separated)": "Arguments (séparés par des espaces)",
|
||||||
"Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.": "Tableau de préréglages de clients de chat. Chaque élément est un objet avec une paire clé-valeur : nom du client et son URL.",
|
"Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.": "Tableau de préréglages de clients de chat. Chaque élément est un objet avec une paire clé-valeur : nom du client et son URL.",
|
||||||
|
"Area Chart": "Graphique en aires",
|
||||||
"Asc": "Asc",
|
"Asc": "Asc",
|
||||||
"Ask anything": "Demandez n'importe quoi",
|
"Ask anything": "Demandez n'importe quoi",
|
||||||
"Async task refund": "Remboursement de tâche asynchrone",
|
"Async task refund": "Remboursement de tâche asynchrone",
|
||||||
@ -407,6 +408,7 @@
|
|||||||
"Balance queried successfully": "Solde interrogé avec succès",
|
"Balance queried successfully": "Solde interrogé avec succès",
|
||||||
"Balance updated successfully": "Solde mis à jour avec succès",
|
"Balance updated successfully": "Solde mis à jour avec succès",
|
||||||
"Balance updated: {{balance}}": "Solde mis à jour : {{balance}}",
|
"Balance updated: {{balance}}": "Solde mis à jour : {{balance}}",
|
||||||
|
"Bar Chart": "Graphique en barres",
|
||||||
"Bark Push URL": "URL de notification Bark",
|
"Bark Push URL": "URL de notification Bark",
|
||||||
"Base address provided by your Epay service": "Adresse de base fournie par votre service Epay",
|
"Base address provided by your Epay service": "Adresse de base fournie par votre service Epay",
|
||||||
"Base amount. Actual deduction = base amount × system group rate.": "Montant de base. Déduction réelle = montant de base × taux du groupe système.",
|
"Base amount. Actual deduction = base amount × system group rate.": "Montant de base. Déduction réelle = montant de base × taux du groupe système.",
|
||||||
@ -497,6 +499,8 @@
|
|||||||
"Calculated price: ${{price}} per 1M tokens": "Prix calculé : ${{price}} par 1M tokens",
|
"Calculated price: ${{price}} per 1M tokens": "Prix calculé : ${{price}} par 1M tokens",
|
||||||
"Calculated ratio: {{ratio}}": "Ratio calculé : {{ratio}}",
|
"Calculated ratio: {{ratio}}": "Ratio calculé : {{ratio}}",
|
||||||
"Calculating...": "Calcul en cours...",
|
"Calculating...": "Calcul en cours...",
|
||||||
|
"Call Count Distribution": "Distribution du nombre d'appels",
|
||||||
|
"Call Count Ranking": "Classement du nombre d'appels",
|
||||||
"Call Proportion": "Proportion d'appels",
|
"Call Proportion": "Proportion d'appels",
|
||||||
"Call Trend": "Tendance des appels",
|
"Call Trend": "Tendance des appels",
|
||||||
"Callback address": "Adresse de rappel",
|
"Callback address": "Adresse de rappel",
|
||||||
@ -3236,6 +3240,7 @@
|
|||||||
"this token group": "ce groupe de jetons",
|
"this token group": "ce groupe de jetons",
|
||||||
"this user group": "ce groupe d'utilisateurs",
|
"this user group": "ce groupe d'utilisateurs",
|
||||||
"This user has no bindings": "Cet utilisateur n'a aucune liaison",
|
"This user has no bindings": "Cet utilisateur n'a aucune liaison",
|
||||||
|
"This week": "Cette semaine",
|
||||||
"This will append 2 template rules (Codex CLI and Claude CLI) to the existing rule list.": "Cela ajoutera 2 règles modèles (Codex CLI et Claude CLI) à la liste de règles existante.",
|
"This will append 2 template rules (Codex CLI and Claude CLI) to the existing rule list.": "Cela ajoutera 2 règles modèles (Codex CLI et Claude CLI) à la liste de règles existante.",
|
||||||
"This will clear custom pricing ratios and revert to upstream defaults.": "Cela effacera les ratios de tarification personnalisés et rétablira les valeurs par défaut du fournisseur.",
|
"This will clear custom pricing ratios and revert to upstream defaults.": "Cela effacera les ratios de tarification personnalisés et rétablira les valeurs par défaut du fournisseur.",
|
||||||
"This will delete all": "Cela supprimera tout",
|
"This will delete all": "Cela supprimera tout",
|
||||||
|
|||||||
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?": "よろしいですか?",
|
"Are you sure?": "よろしいですか?",
|
||||||
"Args (space separated)": "引数 (スペース区切り)",
|
"Args (space separated)": "引数 (スペース区切り)",
|
||||||
"Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.": "チャットクライアントプリセットの配列。各項目は、クライアント名とそのURLという1つのキーと値のペアを持つオブジェクトです。",
|
"Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.": "チャットクライアントプリセットの配列。各項目は、クライアント名とそのURLという1つのキーと値のペアを持つオブジェクトです。",
|
||||||
|
"Area Chart": "面グラフ",
|
||||||
"Asc": "昇順",
|
"Asc": "昇順",
|
||||||
"Ask anything": "何でも質問する",
|
"Ask anything": "何でも質問する",
|
||||||
"Async task refund": "非同期タスク返金",
|
"Async task refund": "非同期タスク返金",
|
||||||
@ -407,6 +408,7 @@
|
|||||||
"Balance queried successfully": "残高の取得に成功しました",
|
"Balance queried successfully": "残高の取得に成功しました",
|
||||||
"Balance updated successfully": "残高が正常に更新されました",
|
"Balance updated successfully": "残高が正常に更新されました",
|
||||||
"Balance updated: {{balance}}": "残高更新:{{balance}}",
|
"Balance updated: {{balance}}": "残高更新:{{balance}}",
|
||||||
|
"Bar Chart": "棒グラフ",
|
||||||
"Bark Push URL": "BarkプッシュURL",
|
"Bark Push URL": "BarkプッシュURL",
|
||||||
"Base address provided by your Epay service": "Epayサービスによって提供されるベースアドレス",
|
"Base address provided by your Epay service": "Epayサービスによって提供されるベースアドレス",
|
||||||
"Base amount. Actual deduction = base amount × system group rate.": "基本金額。実際の控除 = 基本金額 × システムグループ倍率。",
|
"Base amount. Actual deduction = base amount × system group rate.": "基本金額。実際の控除 = 基本金額 × システムグループ倍率。",
|
||||||
@ -497,6 +499,8 @@
|
|||||||
"Calculated price: ${{price}} per 1M tokens": "計算価格:${{price}} / 1M トークン",
|
"Calculated price: ${{price}} per 1M tokens": "計算価格:${{price}} / 1M トークン",
|
||||||
"Calculated ratio: {{ratio}}": "計算倍率:{{ratio}}",
|
"Calculated ratio: {{ratio}}": "計算倍率:{{ratio}}",
|
||||||
"Calculating...": "計算中...",
|
"Calculating...": "計算中...",
|
||||||
|
"Call Count Distribution": "呼び出し回数分布",
|
||||||
|
"Call Count Ranking": "呼び出し回数ランキング",
|
||||||
"Call Proportion": "呼び出し比率",
|
"Call Proportion": "呼び出し比率",
|
||||||
"Call Trend": "呼び出し傾向",
|
"Call Trend": "呼び出し傾向",
|
||||||
"Callback address": "コールバックアドレス",
|
"Callback address": "コールバックアドレス",
|
||||||
@ -3236,6 +3240,7 @@
|
|||||||
"this token group": "このトークングループ",
|
"this token group": "このトークングループ",
|
||||||
"this user group": "このユーザーグループ",
|
"this user group": "このユーザーグループ",
|
||||||
"This user has no bindings": "このユーザーには連携がありません",
|
"This user has no bindings": "このユーザーには連携がありません",
|
||||||
|
"This week": "今週",
|
||||||
"This will append 2 template rules (Codex CLI and Claude CLI) to the existing rule list.": "既存のルールリストに2つのテンプレートルール(Codex CLIとClaude CLI)を追加します。",
|
"This will append 2 template rules (Codex CLI and Claude CLI) to the existing rule list.": "既存のルールリストに2つのテンプレートルール(Codex CLIとClaude CLI)を追加します。",
|
||||||
"This will clear custom pricing ratios and revert to upstream defaults.": "これにより、カスタム料金比率がクリアされ、上位のデフォルトに戻ります。",
|
"This will clear custom pricing ratios and revert to upstream defaults.": "これにより、カスタム料金比率がクリアされ、上位のデフォルトに戻ります。",
|
||||||
"This will delete all": "これによりすべて削除されます",
|
"This will delete all": "これによりすべて削除されます",
|
||||||
|
|||||||
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?": "Вы уверены?",
|
"Are you sure?": "Вы уверены?",
|
||||||
"Args (space separated)": "Аргументы (разделённые пробелами)",
|
"Args (space separated)": "Аргументы (разделённые пробелами)",
|
||||||
"Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.": "Массив предустановок чат-клиентов. Каждый элемент представляет собой объект с одной парой ключ-значение: имя клиента и его URL.",
|
"Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.": "Массив предустановок чат-клиентов. Каждый элемент представляет собой объект с одной парой ключ-значение: имя клиента и его URL.",
|
||||||
|
"Area Chart": "Диаграмма с областями",
|
||||||
"Asc": "По возрастанию",
|
"Asc": "По возрастанию",
|
||||||
"Ask anything": "Спросите что угодно",
|
"Ask anything": "Спросите что угодно",
|
||||||
"Async task refund": "Возврат асинхронной задачи",
|
"Async task refund": "Возврат асинхронной задачи",
|
||||||
@ -407,6 +408,7 @@
|
|||||||
"Balance queried successfully": "Баланс успешно запрошен",
|
"Balance queried successfully": "Баланс успешно запрошен",
|
||||||
"Balance updated successfully": "Баланс успешно обновлён",
|
"Balance updated successfully": "Баланс успешно обновлён",
|
||||||
"Balance updated: {{balance}}": "Баланс обновлён: {{balance}}",
|
"Balance updated: {{balance}}": "Баланс обновлён: {{balance}}",
|
||||||
|
"Bar Chart": "Столбчатая диаграмма",
|
||||||
"Bark Push URL": "URL для push-уведомлений Bark",
|
"Bark Push URL": "URL для push-уведомлений Bark",
|
||||||
"Base address provided by your Epay service": "Базовый адрес, предоставленный вашим сервисом Epay",
|
"Base address provided by your Epay service": "Базовый адрес, предоставленный вашим сервисом Epay",
|
||||||
"Base amount. Actual deduction = base amount × system group rate.": "Базовая сумма. Фактический вычет = базовая сумма × коэффициент группы.",
|
"Base amount. Actual deduction = base amount × system group rate.": "Базовая сумма. Фактический вычет = базовая сумма × коэффициент группы.",
|
||||||
@ -497,6 +499,8 @@
|
|||||||
"Calculated price: ${{price}} per 1M tokens": "Расчётная цена: ${{price}} за 1М токенов",
|
"Calculated price: ${{price}} per 1M tokens": "Расчётная цена: ${{price}} за 1М токенов",
|
||||||
"Calculated ratio: {{ratio}}": "Расчётный коэффициент: {{ratio}}",
|
"Calculated ratio: {{ratio}}": "Расчётный коэффициент: {{ratio}}",
|
||||||
"Calculating...": "Вычисление...",
|
"Calculating...": "Вычисление...",
|
||||||
|
"Call Count Distribution": "Распределение количества вызовов",
|
||||||
|
"Call Count Ranking": "Рейтинг по количеству вызовов",
|
||||||
"Call Proportion": "Доля вызовов",
|
"Call Proportion": "Доля вызовов",
|
||||||
"Call Trend": "Тенденция вызовов",
|
"Call Trend": "Тенденция вызовов",
|
||||||
"Callback address": "Адрес обратного вызова",
|
"Callback address": "Адрес обратного вызова",
|
||||||
@ -3236,6 +3240,7 @@
|
|||||||
"this token group": "эта группа токенов",
|
"this token group": "эта группа токенов",
|
||||||
"this user group": "эта группа пользователей",
|
"this user group": "эта группа пользователей",
|
||||||
"This user has no bindings": "У этого пользователя нет привязок",
|
"This user has no bindings": "У этого пользователя нет привязок",
|
||||||
|
"This week": "На этой неделе",
|
||||||
"This will append 2 template rules (Codex CLI and Claude CLI) to the existing rule list.": "Это добавит 2 шаблонных правила (Codex CLI и Claude CLI) к существующему списку правил.",
|
"This will append 2 template rules (Codex CLI and Claude CLI) to the existing rule list.": "Это добавит 2 шаблонных правила (Codex CLI и Claude CLI) к существующему списку правил.",
|
||||||
"This will clear custom pricing ratios and revert to upstream defaults.": "Это очистит пользовательские ценовые коэффициенты и вернет к стандартным значениям поставщика.",
|
"This will clear custom pricing ratios and revert to upstream defaults.": "Это очистит пользовательские ценовые коэффициенты и вернет к стандартным значениям поставщика.",
|
||||||
"This will delete all": "Это удалит все",
|
"This will delete all": "Это удалит все",
|
||||||
|
|||||||
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?",
|
"Are you sure?": "Bạn có chắc không?",
|
||||||
"Args (space separated)": "Đối số (cách nhau bằng khoảng trắng)",
|
"Args (space separated)": "Đối số (cách nhau bằng khoảng trắng)",
|
||||||
"Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.": "Mảng các thiết lập sẵn của ứng dụng trò chuyện. Mỗi mục là một đối tượng với",
|
"Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.": "Mảng các thiết lập sẵn của ứng dụng trò chuyện. Mỗi mục là một đối tượng với",
|
||||||
|
"Area Chart": "Biểu đồ vùng",
|
||||||
"Asc": "Asc",
|
"Asc": "Asc",
|
||||||
"Ask anything": "Hỏi gì cũng được",
|
"Ask anything": "Hỏi gì cũng được",
|
||||||
"Async task refund": "Hoàn tiền tác vụ bất đồng bộ",
|
"Async task refund": "Hoàn tiền tác vụ bất đồng bộ",
|
||||||
@ -407,6 +408,7 @@
|
|||||||
"Balance queried successfully": "Truy vấn số dư thành công",
|
"Balance queried successfully": "Truy vấn số dư thành công",
|
||||||
"Balance updated successfully": "Đã cập nhật số dư thành công",
|
"Balance updated successfully": "Đã cập nhật số dư thành công",
|
||||||
"Balance updated: {{balance}}": "Số dư đã cập nhật: {{balance}}",
|
"Balance updated: {{balance}}": "Số dư đã cập nhật: {{balance}}",
|
||||||
|
"Bar Chart": "Biểu đồ cột",
|
||||||
"Bark Push URL": "URL đẩy Bark",
|
"Bark Push URL": "URL đẩy Bark",
|
||||||
"Base address provided by your Epay service": "Địa chỉ cơ sở được cung cấp bởi dịch vụ Epay của bạn",
|
"Base address provided by your Epay service": "Địa chỉ cơ sở được cung cấp bởi dịch vụ Epay của bạn",
|
||||||
"Base amount. Actual deduction = base amount × system group rate.": "Số tiền cơ sở. Số tiền trừ thực tế = số tiền cơ sở × tỷ lệ nhóm hệ thống.",
|
"Base amount. Actual deduction = base amount × system group rate.": "Số tiền cơ sở. Số tiền trừ thực tế = số tiền cơ sở × tỷ lệ nhóm hệ thống.",
|
||||||
@ -497,6 +499,8 @@
|
|||||||
"Calculated price: ${{price}} per 1M tokens": "Giá tính toán: ${{price}} mỗi 1M token",
|
"Calculated price: ${{price}} per 1M tokens": "Giá tính toán: ${{price}} mỗi 1M token",
|
||||||
"Calculated ratio: {{ratio}}": "Tỷ lệ tính toán: {{ratio}}",
|
"Calculated ratio: {{ratio}}": "Tỷ lệ tính toán: {{ratio}}",
|
||||||
"Calculating...": "Đang tính...",
|
"Calculating...": "Đang tính...",
|
||||||
|
"Call Count Distribution": "Phân bổ số lượt gọi",
|
||||||
|
"Call Count Ranking": "Xếp hạng số lượt gọi",
|
||||||
"Call Proportion": "Tỷ lệ cuộc gọi",
|
"Call Proportion": "Tỷ lệ cuộc gọi",
|
||||||
"Call Trend": "Xu hướng cuộc gọi",
|
"Call Trend": "Xu hướng cuộc gọi",
|
||||||
"Callback address": "Địa chỉ callback",
|
"Callback address": "Địa chỉ callback",
|
||||||
@ -3236,6 +3240,7 @@
|
|||||||
"this token group": "nhóm token này",
|
"this token group": "nhóm token này",
|
||||||
"this user group": "nhóm người dùng này",
|
"this user group": "nhóm người dùng này",
|
||||||
"This user has no bindings": "Người dùng này không có liên kết nào",
|
"This user has no bindings": "Người dùng này không có liên kết nào",
|
||||||
|
"This week": "Tuần này",
|
||||||
"This will append 2 template rules (Codex CLI and Claude CLI) to the existing rule list.": "Thao tác này sẽ thêm 2 quy tắc mẫu (Codex CLI và Claude CLI) vào danh sách quy tắc hiện có.",
|
"This will append 2 template rules (Codex CLI and Claude CLI) to the existing rule list.": "Thao tác này sẽ thêm 2 quy tắc mẫu (Codex CLI và Claude CLI) vào danh sách quy tắc hiện có.",
|
||||||
"This will clear custom pricing ratios and revert to upstream defaults.": "Điều này sẽ xóa các tỷ lệ giá tùy chỉnh và trở về mặc định ban đầu.",
|
"This will clear custom pricing ratios and revert to upstream defaults.": "Điều này sẽ xóa các tỷ lệ giá tùy chỉnh và trở về mặc định ban đầu.",
|
||||||
"This will delete all": "Thao tác này sẽ xóa tất cả",
|
"This will delete all": "Thao tác này sẽ xóa tất cả",
|
||||||
|
|||||||
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?": "您确定吗?",
|
"Are you sure?": "您确定吗?",
|
||||||
"Args (space separated)": "参数 (空格分隔)",
|
"Args (space separated)": "参数 (空格分隔)",
|
||||||
"Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.": "聊天客户端预设数组。每个项目都是一个对象,包含一个键值对:客户端名称及其 URL。",
|
"Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.": "聊天客户端预设数组。每个项目都是一个对象,包含一个键值对:客户端名称及其 URL。",
|
||||||
|
"Area Chart": "面积图",
|
||||||
"Asc": "升序",
|
"Asc": "升序",
|
||||||
"Ask anything": "随便问",
|
"Ask anything": "随便问",
|
||||||
"Async task refund": "异步任务退款",
|
"Async task refund": "异步任务退款",
|
||||||
@ -407,6 +408,7 @@
|
|||||||
"Balance queried successfully": "余额查询成功",
|
"Balance queried successfully": "余额查询成功",
|
||||||
"Balance updated successfully": "余额更新成功",
|
"Balance updated successfully": "余额更新成功",
|
||||||
"Balance updated: {{balance}}": "余额已更新:{{balance}}",
|
"Balance updated: {{balance}}": "余额已更新:{{balance}}",
|
||||||
|
"Bar Chart": "柱状图",
|
||||||
"Bark Push URL": "Bark 推送 URL",
|
"Bark Push URL": "Bark 推送 URL",
|
||||||
"Base address provided by your Epay service": "您的 Epay 服务提供的基础地址",
|
"Base address provided by your Epay service": "您的 Epay 服务提供的基础地址",
|
||||||
"Base amount. Actual deduction = base amount × system group rate.": "基础金额,实际扣费 = 基础金额 × 系统分组倍率。",
|
"Base amount. Actual deduction = base amount × system group rate.": "基础金额,实际扣费 = 基础金额 × 系统分组倍率。",
|
||||||
@ -497,6 +499,8 @@
|
|||||||
"Calculated price: ${{price}} per 1M tokens": "计算价格:${{price}} / 1M tokens",
|
"Calculated price: ${{price}} per 1M tokens": "计算价格:${{price}} / 1M tokens",
|
||||||
"Calculated ratio: {{ratio}}": "计算倍率:{{ratio}}",
|
"Calculated ratio: {{ratio}}": "计算倍率:{{ratio}}",
|
||||||
"Calculating...": "计算中...",
|
"Calculating...": "计算中...",
|
||||||
|
"Call Count Distribution": "调用次数分布",
|
||||||
|
"Call Count Ranking": "调用次数排行",
|
||||||
"Call Proportion": "调用比例",
|
"Call Proportion": "调用比例",
|
||||||
"Call Trend": "调用趋势",
|
"Call Trend": "调用趋势",
|
||||||
"Callback address": "回调地址",
|
"Callback address": "回调地址",
|
||||||
@ -3236,6 +3240,7 @@
|
|||||||
"this token group": "此令牌分组",
|
"this token group": "此令牌分组",
|
||||||
"this user group": "此用户分组",
|
"this user group": "此用户分组",
|
||||||
"This user has no bindings": "该用户无任何绑定",
|
"This user has no bindings": "该用户无任何绑定",
|
||||||
|
"This week": "本周",
|
||||||
"This will append 2 template rules (Codex CLI and Claude CLI) to the existing rule list.": "这将在现有规则列表中追加 2 条模板规则(Codex CLI 和 Claude CLI)。",
|
"This will append 2 template rules (Codex CLI and Claude CLI) to the existing rule list.": "这将在现有规则列表中追加 2 条模板规则(Codex CLI 和 Claude CLI)。",
|
||||||
"This will clear custom pricing ratios and revert to upstream defaults.": "这将清除自定义定价比例并恢复到上游默认值。",
|
"This will clear custom pricing ratios and revert to upstream defaults.": "这将清除自定义定价比例并恢复到上游默认值。",
|
||||||
"This will delete all": "这将删除所有",
|
"This will delete all": "这将删除所有",
|
||||||
|
|||||||
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<{
|
export async function getUserGroups(): Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
message?: string
|
message?: string
|
||||||
data?: Record<string, { desc: string; ratio: number }>
|
data?: Record<string, { desc: string; ratio: number | string }>
|
||||||
}> {
|
}> {
|
||||||
const res = await api.get('/api/user/self/groups')
|
const res = await api.get('/api/user/self/groups')
|
||||||
return res.data
|
return res.data
|
||||||
|
|||||||
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',
|
grey: 'bg-gray-500',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const avatarColorMap: Record<SemanticColor, string> = {
|
||||||
|
blue: 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-400',
|
||||||
|
green: 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-400',
|
||||||
|
cyan: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-500/20 dark:text-cyan-400',
|
||||||
|
purple: 'bg-purple-100 text-purple-700 dark:bg-purple-500/20 dark:text-purple-400',
|
||||||
|
pink: 'bg-pink-100 text-pink-700 dark:bg-pink-500/20 dark:text-pink-400',
|
||||||
|
red: 'bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-400',
|
||||||
|
orange: 'bg-orange-100 text-orange-700 dark:bg-orange-500/20 dark:text-orange-400',
|
||||||
|
amber: 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400',
|
||||||
|
yellow: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-500/20 dark:text-yellow-400',
|
||||||
|
lime: 'bg-lime-100 text-lime-700 dark:bg-lime-500/20 dark:text-lime-400',
|
||||||
|
'light-green': 'bg-green-50 text-green-600 dark:bg-green-400/20 dark:text-green-300',
|
||||||
|
teal: 'bg-teal-100 text-teal-700 dark:bg-teal-500/20 dark:text-teal-400',
|
||||||
|
'light-blue': 'bg-sky-100 text-sky-700 dark:bg-sky-500/20 dark:text-sky-400',
|
||||||
|
indigo: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-400',
|
||||||
|
violet: 'bg-violet-100 text-violet-700 dark:bg-violet-500/20 dark:text-violet-400',
|
||||||
|
grey: 'bg-gray-100 text-gray-700 dark:bg-gray-500/20 dark:text-gray-400',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAvatarColorClass(name: string): string {
|
||||||
|
return avatarColorMap[stringToColor(name)]
|
||||||
|
}
|
||||||
|
|
||||||
export function getBgColorClass(color?: string): string {
|
export function getBgColorClass(color?: string): string {
|
||||||
if (!color) return colorToBgClass.blue
|
if (!color) return colorToBgClass.blue
|
||||||
return (
|
return (
|
||||||
|
|||||||
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, {
|
const formatted = new Intl.NumberFormat(undefined, {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
currency: meta.currencyCode,
|
currency: meta.currencyCode,
|
||||||
|
currencyDisplay: 'narrowSymbol',
|
||||||
minimumFractionDigits: 0,
|
minimumFractionDigits: 0,
|
||||||
maximumFractionDigits: digits,
|
maximumFractionDigits: digits,
|
||||||
}).format(adjustedValue)
|
}).format(adjustedValue)
|
||||||
@ -337,11 +338,11 @@ export function formatCurrencyFromUSD(
|
|||||||
const { config, meta } = getCurrencyDisplay()
|
const { config, meta } = getCurrencyDisplay()
|
||||||
const merged = mergeOptions(options)
|
const merged = mergeOptions(options)
|
||||||
|
|
||||||
if (!config.displayInCurrency || meta.kind === 'tokens') {
|
if (meta.kind === 'tokens') {
|
||||||
const tokens = amountUSD * config.quotaPerUnit
|
const tokens = amountUSD * config.quotaPerUnit
|
||||||
return formatNumberWithSuffix(
|
return formatNumberWithSuffix(
|
||||||
tokens,
|
tokens,
|
||||||
meta.kind === 'tokens' ? 0 : merged.digitsLarge,
|
0,
|
||||||
merged.digitsSmall,
|
merged.digitsSmall,
|
||||||
merged.abbreviate
|
merged.abbreviate
|
||||||
)
|
)
|
||||||
@ -463,7 +464,7 @@ export function formatQuotaWithCurrency(
|
|||||||
export function getCurrencyLabel(): string {
|
export function getCurrencyLabel(): string {
|
||||||
const { config, meta } = getCurrencyDisplay()
|
const { config, meta } = getCurrencyDisplay()
|
||||||
|
|
||||||
if (!config.displayInCurrency || meta.kind === 'tokens') {
|
if (meta.kind === 'tokens') {
|
||||||
return 'Tokens'
|
return 'Tokens'
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -494,8 +495,8 @@ export function getCurrencyLabel(): string {
|
|||||||
* Use this to conditionally show currency-specific UI elements
|
* Use this to conditionally show currency-specific UI elements
|
||||||
*/
|
*/
|
||||||
export function isCurrencyDisplayEnabled(): boolean {
|
export function isCurrencyDisplayEnabled(): boolean {
|
||||||
const { config, meta } = getCurrencyDisplay()
|
const { meta } = getCurrencyDisplay()
|
||||||
return config.displayInCurrency && meta.kind !== 'tokens'
|
return meta.kind !== 'tokens'
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
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()
|
const { config, meta } = getCurrencyDisplay()
|
||||||
|
|
||||||
// Tokens-only or raw quota mode
|
// Tokens-only or raw quota mode
|
||||||
if (!config.displayInCurrency || meta.kind === 'tokens') {
|
if (meta.kind === 'tokens') {
|
||||||
return Math.round(amount)
|
return Math.round(amount)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -80,7 +80,7 @@ export function parseQuotaFromDollars(amount: number): number {
|
|||||||
export function quotaUnitsToDollars(units: number): number {
|
export function quotaUnitsToDollars(units: number): number {
|
||||||
const { config, meta } = getCurrencyDisplay()
|
const { config, meta } = getCurrencyDisplay()
|
||||||
|
|
||||||
if (!config.displayInCurrency || meta.kind === 'tokens') {
|
if (meta.kind === 'tokens') {
|
||||||
return units
|
return units
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user