فهرست منبع

feat: enhance UI and functionality in various components

CaIon 1 ماه پیش
والد
کامیت
28f7e9eb2e
40فایلهای تغییر یافته به همراه1569 افزوده شده و 472 حذف شده
  1. 2 0
      .gitignore
  2. 7 1
      web/default/src/components/data-table/mobile-card-list.tsx
  3. 76 59
      web/default/src/components/layout/components/workspace-switcher.tsx
  4. 14 0
      web/default/src/features/channels/components/channels-table.tsx
  5. 11 8
      web/default/src/features/dashboard/api.ts
  6. 120 0
      web/default/src/features/dashboard/components/models/consumption-distribution-chart.tsx
  7. 5 2
      web/default/src/features/dashboard/components/models/log-stat-cards.tsx
  8. 12 11
      web/default/src/features/dashboard/components/models/model-charts.tsx
  9. 36 51
      web/default/src/features/dashboard/components/users/user-charts.tsx
  10. 17 0
      web/default/src/features/dashboard/index.tsx
  11. 239 90
      web/default/src/features/dashboard/lib/charts.ts
  12. 2 4
      web/default/src/features/dashboard/lib/filters.ts
  13. 2 0
      web/default/src/features/dashboard/types.ts
  14. 173 0
      web/default/src/features/keys/components/api-key-group-combobox.tsx
  15. 1 1
      web/default/src/features/keys/components/api-keys-columns.tsx
  16. 15 0
      web/default/src/features/keys/components/api-keys-dialogs.tsx
  17. 33 30
      web/default/src/features/keys/components/api-keys-mutate-drawer.tsx
  18. 17 11
      web/default/src/features/keys/components/api-keys-table.tsx
  19. 0 4
      web/default/src/features/keys/index.tsx
  20. 2 3
      web/default/src/features/redemption-codes/components/redemptions-mutate-drawer.tsx
  21. 66 51
      web/default/src/features/system-settings/general/pricing-section.tsx
  22. 121 98
      web/default/src/features/usage-logs/components/columns/common-logs-columns.tsx
  23. 279 0
      web/default/src/features/usage-logs/components/common-logs-filter-bar.tsx
  24. 28 14
      web/default/src/features/usage-logs/components/common-logs-stats.tsx
  25. 199 0
      web/default/src/features/usage-logs/components/compact-date-time-range-picker.tsx
  26. 5 0
      web/default/src/features/usage-logs/components/usage-logs-provider.tsx
  27. 18 17
      web/default/src/features/usage-logs/components/usage-logs-table.tsx
  28. 3 3
      web/default/src/features/usage-logs/index.tsx
  29. 2 3
      web/default/src/features/users/components/user-quota-dialog.tsx
  30. 2 3
      web/default/src/features/users/components/users-mutate-drawer.tsx
  31. 5 0
      web/default/src/i18n/locales/en.json
  32. 5 0
      web/default/src/i18n/locales/fr.json
  33. 5 0
      web/default/src/i18n/locales/ja.json
  34. 5 0
      web/default/src/i18n/locales/ru.json
  35. 5 0
      web/default/src/i18n/locales/vi.json
  36. 5 0
      web/default/src/i18n/locales/zh.json
  37. 1 1
      web/default/src/lib/api.ts
  38. 23 0
      web/default/src/lib/colors.ts
  39. 6 5
      web/default/src/lib/currency.ts
  40. 2 2
      web/default/src/lib/format.ts

+ 2 - 0
.gitignore

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

+ 7 - 1
web/default/src/components/data-table/mobile-card-list.tsx

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

+ 76 - 59
web/default/src/components/layout/components/workspace-switcher.tsx

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

+ 14 - 0
web/default/src/features/channels/components/channels-table.tsx

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

+ 11 - 8
web/default/src/features/dashboard/api.ts

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

+ 120 - 0
web/default/src/features/dashboard/components/models/consumption-distribution-chart.tsx

@@ -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>
+  )
+}

+ 5 - 2
web/default/src/features/dashboard/components/models/log-stat-cards.tsx

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

+ 12 - 11
web/default/src/features/dashboard/components/models/model-charts.tsx

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

+ 36 - 51
web/default/src/features/dashboard/components/users/user-charts.tsx

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

+ 17 - 0
web/default/src/features/dashboard/index.tsx

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

+ 239 - 90
web/default/src/features/dashboard/lib/charts.ts

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

+ 2 - 4
web/default/src/features/dashboard/lib/filters.ts

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

+ 2 - 0
web/default/src/features/dashboard/types.ts

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

+ 173 - 0
web/default/src/features/keys/components/api-key-group-combobox.tsx

@@ -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>
+  )
+}

+ 1 - 1
web/default/src/features/keys/components/api-keys-columns.tsx

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

+ 15 - 0
web/default/src/features/keys/components/api-keys-dialogs.tsx

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

+ 33 - 30
web/default/src/features/keys/components/api-keys-mutate-drawer.tsx

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

+ 17 - 11
web/default/src/features/keys/components/api-keys-table.tsx

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

+ 0 - 4
web/default/src/features/keys/index.tsx

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

+ 2 - 3
web/default/src/features/redemption-codes/components/redemptions-mutate-drawer.tsx

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

+ 66 - 51
web/default/src/features/system-settings/general/pricing-section.tsx

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

+ 121 - 98
web/default/src/features/usage-logs/components/columns/common-logs-columns.tsx

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

+ 279 - 0
web/default/src/features/usage-logs/components/common-logs-filter-bar.tsx

@@ -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>
+  )
+}

+ 28 - 14
web/default/src/features/usage-logs/components/common-logs-stats.tsx

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

+ 199 - 0
web/default/src/features/usage-logs/components/compact-date-time-range-picker.tsx

@@ -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>
+  )
+}

+ 5 - 0
web/default/src/features/usage-logs/components/usage-logs-provider.tsx

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

+ 18 - 17
web/default/src/features/usage-logs/components/usage-logs-table.tsx

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

+ 3 - 3
web/default/src/features/usage-logs/index.tsx

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

+ 2 - 3
web/default/src/features/users/components/user-quota-dialog.tsx

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

+ 2 - 3
web/default/src/features/users/components/users-mutate-drawer.tsx

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

+ 5 - 0
web/default/src/i18n/locales/en.json

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

+ 5 - 0
web/default/src/i18n/locales/fr.json

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

+ 5 - 0
web/default/src/i18n/locales/ja.json

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

+ 5 - 0
web/default/src/i18n/locales/ru.json

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

+ 5 - 0
web/default/src/i18n/locales/vi.json

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

+ 5 - 0
web/default/src/i18n/locales/zh.json

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

+ 1 - 1
web/default/src/lib/api.ts

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

+ 23 - 0
web/default/src/lib/colors.ts

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

+ 6 - 5
web/default/src/lib/currency.ts

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

+ 2 - 2
web/default/src/lib/format.ts

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