Explorar el Código

feat(ui): improve table controls and analytics filters

CaIon hace 1 semana
padre
commit
8f3c41ae77
Se han modificado 35 ficheros con 859 adiciones y 473 borrados
  1. 6 0
      web/default/src/components/data-table/index.ts
  2. 4 3
      web/default/src/features/channels/components/channels-columns.tsx
  3. 4 4
      web/default/src/features/channels/components/channels-table.tsx
  4. 181 122
      web/default/src/features/channels/components/data-table-row-actions.tsx
  5. 7 9
      web/default/src/features/channels/components/dialogs/edit-tag-dialog.tsx
  6. 23 15
      web/default/src/features/dashboard/components/models/consumption-distribution-chart.tsx
  7. 23 20
      web/default/src/features/dashboard/components/models/model-charts.tsx
  8. 21 28
      web/default/src/features/dashboard/components/models/models-filter-dialog.tsx
  9. 21 1
      web/default/src/features/dashboard/constants.ts
  10. 66 22
      web/default/src/features/dashboard/index.tsx
  11. 12 2
      web/default/src/features/dashboard/lib/charts.ts
  12. 102 4
      web/default/src/features/dashboard/lib/filters.ts
  13. 3 0
      web/default/src/features/dashboard/lib/index.ts
  14. 11 0
      web/default/src/features/dashboard/types.ts
  15. 4 27
      web/default/src/features/keys/components/api-keys-columns.tsx
  16. 16 6
      web/default/src/features/keys/components/api-keys-table.tsx
  17. 146 117
      web/default/src/features/keys/components/data-table-row-actions.tsx
  18. 3 2
      web/default/src/features/models/components/models-columns.tsx
  19. 5 4
      web/default/src/features/pricing/components/model-details.tsx
  20. 29 2
      web/default/src/features/pricing/components/pricing-columns.tsx
  21. 19 7
      web/default/src/features/redemption-codes/components/redemptions-table.tsx
  22. 3 2
      web/default/src/features/subscriptions/components/dialogs/subscription-purchase-dialog.tsx
  23. 5 5
      web/default/src/features/subscriptions/components/subscriptions-columns.tsx
  24. 1 1
      web/default/src/features/usage-logs/components/dialogs/usage-logs-filter-dialog.tsx
  25. 7 6
      web/default/src/features/usage-logs/components/usage-logs-table.tsx
  26. 0 6
      web/default/src/features/usage-logs/index.tsx
  27. 40 41
      web/default/src/features/users/components/users-columns.tsx
  28. 13 5
      web/default/src/features/users/components/users-table.tsx
  29. 6 0
      web/default/src/hooks/use-sidebar-data.ts
  30. 13 2
      web/default/src/i18n/locales/en.json
  31. 13 2
      web/default/src/i18n/locales/fr.json
  32. 13 2
      web/default/src/i18n/locales/ja.json
  33. 13 2
      web/default/src/i18n/locales/ru.json
  34. 13 2
      web/default/src/i18n/locales/vi.json
  35. 13 2
      web/default/src/i18n/locales/zh.json

+ 6 - 0
web/default/src/components/data-table/index.ts

@@ -7,3 +7,9 @@ export { DataTableBulkActions } from './bulk-actions'
 export { TableSkeleton } from './table-skeleton'
 export { TableEmpty } from './table-empty'
 export { MobileCardList } from './mobile-card-list'
+
+export const DISABLED_ROW_DESKTOP =
+  'bg-muted/85 hover:bg-muted [&>td:first-child]:border-l-muted-foreground/35 dark:bg-zinc-700/55 dark:hover:bg-zinc-700/70 [&>td:first-child]:border-l-4 [&>td:first-child]:pl-1 dark:[&>td:first-child]:border-l-zinc-300/70'
+
+export const DISABLED_ROW_MOBILE =
+  'border-l-4 border-l-muted-foreground/35 bg-muted/85 dark:border-l-zinc-300/70 dark:bg-zinc-700/55'

+ 4 - 3
web/default/src/features/channels/components/channels-columns.tsx

@@ -28,6 +28,7 @@ import {
 } from '@/components/ui/tooltip'
 import { ConfirmDialog } from '@/components/confirm-dialog'
 import { DataTableColumnHeader } from '@/components/data-table/column-header'
+import { GroupBadge } from '@/components/group-badge'
 import {
   StatusBadge,
   dotColorMap,
@@ -876,8 +877,8 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
         const group = row.getValue('group') as string
         const groupArray = parseGroupsList(group)
 
-        const groupBadges = groupArray.map((g, idx) => (
-          <StatusBadge key={idx} label={g} autoColor={g} size='sm' />
+        const groupBadges = groupArray.map((g) => (
+          <GroupBadge key={g} group={g} size='sm' />
         ))
 
         return (
@@ -1035,7 +1036,7 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
 
         return <DataTableRowActions row={row} />
       },
-      size: 100,
+      size: 132,
       enableSorting: false,
       enableHiding: false,
     },

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

@@ -26,6 +26,8 @@ import {
   TableRow,
 } from '@/components/ui/table'
 import {
+  DISABLED_ROW_DESKTOP,
+  DISABLED_ROW_MOBILE,
   DataTableToolbar,
   TableSkeleton,
   TableEmpty,
@@ -368,9 +370,7 @@ export function ChannelsTable() {
             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
+              isDisabledChannelRow(row.original) ? DISABLED_ROW_MOBILE : undefined
             }
           />
         ) : (
@@ -419,7 +419,7 @@ export function ChannelsTable() {
                         data-state={row.getIsSelected() && 'selected'}
                         className={cn(
                           isDisabledChannelRow(row.original) &&
-                            'bg-muted/85 hover:bg-muted [&>td:first-child]:border-l-muted-foreground/35 dark:bg-zinc-700/55 dark:hover:bg-zinc-700/70 [&>td:first-child]:border-l-4 [&>td:first-child]:pl-1 dark:[&>td:first-child]:border-l-zinc-300/70'
+                            DISABLED_ROW_DESKTOP
                         )}
                       >
                         {row.getVisibleCells().map((cell) => (

+ 181 - 122
web/default/src/features/channels/components/data-table-row-actions.tsx

@@ -6,6 +6,7 @@ import {
   Boxes,
   Pencil,
   TestTube,
+  Gauge,
   DollarSign,
   Download,
   Copy,
@@ -14,6 +15,7 @@ import {
   Key,
   Trash2,
   RefreshCw,
+  Loader2,
 } from 'lucide-react'
 import { useTranslation } from 'react-i18next'
 import { Button } from '@/components/ui/button'
@@ -25,10 +27,17 @@ import {
   DropdownMenuShortcut,
   DropdownMenuTrigger,
 } from '@/components/ui/dropdown-menu'
+import {
+  Tooltip,
+  TooltipContent,
+  TooltipTrigger,
+} from '@/components/ui/tooltip'
 import { ConfirmDialog } from '@/components/confirm-dialog'
 import { MODEL_FETCHABLE_TYPES } from '../constants'
 import {
+  channelsQueryKeys,
   handleDeleteChannel,
+  handleTestChannel,
   handleToggleChannelStatus,
   isChannelEnabled,
   isMultiKeyChannel,
@@ -47,6 +56,8 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
   const { setOpen, setCurrentRow, upstream } = useChannels()
   const queryClient = useQueryClient()
   const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
+  const [isTesting, setIsTesting] = useState(false)
+  const [isTogglingStatus, setIsTogglingStatus] = useState(false)
 
   const isEnabled = isChannelEnabled(channel)
   const isMultiKey = isMultiKeyChannel(channel)
@@ -61,6 +72,18 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
     setOpen('test-channel')
   }
 
+  const handleDirectTest = async (e: React.MouseEvent<HTMLButtonElement>) => {
+    e.stopPropagation()
+    setIsTesting(true)
+    try {
+      await handleTestChannel(channel.id, undefined, () => {
+        queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
+      })
+    } finally {
+      setIsTesting(false)
+    }
+  }
+
   const handleQueryBalance = () => {
     setCurrentRow(channel)
     setOpen('balance-query')
@@ -86,148 +109,184 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
     setOpen('multi-key-manage')
   }
 
-  const handleToggleStatus = () => {
-    handleToggleChannelStatus(channel.id, channel.status, queryClient)
+  const handleToggleStatus = async (
+    e?: React.MouseEvent<HTMLButtonElement>
+  ) => {
+    e?.stopPropagation()
+    setIsTogglingStatus(true)
+    try {
+      await handleToggleChannelStatus(channel.id, channel.status, queryClient)
+    } finally {
+      setIsTogglingStatus(false)
+    }
   }
 
   return (
-    <DropdownMenu>
-      <DropdownMenuTrigger asChild>
-        <Button
-          variant='ghost'
-          className='data-[state=open]:bg-muted flex h-8 w-8 p-0'
-        >
-          <MoreHorizontal className='h-4 w-4' />
-          <span className='sr-only'>{t('Open menu')}</span>
-        </Button>
-      </DropdownMenuTrigger>
-      <DropdownMenuContent align='end' className='w-48'>
-        {/* Edit */}
-        <DropdownMenuItem onClick={handleEdit}>
-          {t('Edit')}
-          <DropdownMenuShortcut>
-            <Pencil size={16} />
-          </DropdownMenuShortcut>
-        </DropdownMenuItem>
-
-        {/* Test Connection */}
-        <DropdownMenuItem onClick={handleTest}>
-          {t('Test Connection')}
-          <DropdownMenuShortcut>
-            <TestTube size={16} />
-          </DropdownMenuShortcut>
-        </DropdownMenuItem>
-
-        {/* Query Balance */}
-        <DropdownMenuItem onClick={handleQueryBalance}>
-          {t('Query Balance')}
-          <DropdownMenuShortcut>
-            <DollarSign size={16} />
-          </DropdownMenuShortcut>
-        </DropdownMenuItem>
-
-        {/* Fetch Models */}
-        <DropdownMenuItem onClick={handleFetchModels}>
-          {t('Fetch Models')}
-          <DropdownMenuShortcut>
-            <Download size={16} />
-          </DropdownMenuShortcut>
-        </DropdownMenuItem>
-
-        {/* Detect Upstream Updates (only for fetchable channel types) */}
-        {MODEL_FETCHABLE_TYPES.has(channel.type) && (
-          <DropdownMenuItem
-            onClick={() => {
-              const meta = parseUpstreamUpdateMeta(channel.settings)
-              if (
-                meta.pendingAddModels.length > 0 ||
-                meta.pendingRemoveModels.length > 0
-              ) {
-                upstream.openModal(
-                  channel,
-                  meta.pendingAddModels,
-                  meta.pendingRemoveModels,
-                  meta.pendingAddModels.length > 0 ? 'add' : 'remove'
-                )
-              } else {
-                upstream.detectChannelUpdates(channel)
-              }
-            }}
+    <div className='flex items-center justify-end gap-1'>
+      <Tooltip>
+        <TooltipTrigger asChild>
+          <Button
+            variant='ghost'
+            size='icon-sm'
+            onClick={handleDirectTest}
+            disabled={isTesting}
+            aria-label={t('Test Connection')}
+          >
+            {isTesting ? (
+              <Loader2 className='size-4 animate-spin' />
+            ) : (
+              <Gauge className='size-4' />
+            )}
+          </Button>
+        </TooltipTrigger>
+        <TooltipContent>{t('Test Connection')}</TooltipContent>
+      </Tooltip>
+
+      <Tooltip>
+        <TooltipTrigger asChild>
+          <Button
+            variant='ghost'
+            size='icon-sm'
+            onClick={handleToggleStatus}
+            disabled={isTogglingStatus}
+            aria-label={isEnabled ? t('Disable') : t('Enable')}
+            className={
+              isEnabled
+                ? 'text-destructive hover:text-destructive'
+                : 'text-emerald-600 hover:text-emerald-600 dark:text-emerald-400 dark:hover:text-emerald-400'
+            }
+          >
+            {isTogglingStatus ? (
+              <Loader2 className='size-4 animate-spin' />
+            ) : isEnabled ? (
+              <PowerOff className='size-4' />
+            ) : (
+              <Power className='size-4' />
+            )}
+          </Button>
+        </TooltipTrigger>
+        <TooltipContent>
+          {isEnabled ? t('Disable') : t('Enable')}
+        </TooltipContent>
+      </Tooltip>
+
+      <DropdownMenu>
+        <DropdownMenuTrigger asChild>
+          <Button
+            variant='ghost'
+            className='data-[state=open]:bg-muted flex h-8 w-8 p-0'
           >
-            {t('Upstream Updates')}
+            <MoreHorizontal className='h-4 w-4' />
+            <span className='sr-only'>{t('Open menu')}</span>
+          </Button>
+        </DropdownMenuTrigger>
+        <DropdownMenuContent align='end' className='w-48'>
+          {/* Edit */}
+          <DropdownMenuItem onClick={handleEdit}>
+            {t('Edit')}
+            <DropdownMenuShortcut>
+              <Pencil size={16} />
+            </DropdownMenuShortcut>
+          </DropdownMenuItem>
+
+          {/* Test Connection */}
+          <DropdownMenuItem onClick={handleTest}>
+            {t('Test Connection')}
             <DropdownMenuShortcut>
-              <RefreshCw size={16} />
+              <TestTube size={16} />
             </DropdownMenuShortcut>
           </DropdownMenuItem>
-        )}
 
-        {/* Ollama Models (only for Ollama channels) */}
-        {channel.type === 4 && (
-          <DropdownMenuItem onClick={handleManageOllamaModels}>
-            {t('Manage Ollama Models')}
+          {/* Query Balance */}
+          <DropdownMenuItem onClick={handleQueryBalance}>
+            {t('Query Balance')}
             <DropdownMenuShortcut>
-              <Boxes size={16} />
+              <DollarSign size={16} />
             </DropdownMenuShortcut>
           </DropdownMenuItem>
-        )}
-
-        <DropdownMenuSeparator />
-
-        {/* Copy Channel */}
-        <DropdownMenuItem onClick={handleCopy}>
-          {t('Copy Channel')}
-          <DropdownMenuShortcut>
-            <Copy size={16} />
-          </DropdownMenuShortcut>
-        </DropdownMenuItem>
-
-        {/* Manage Keys (only for multi-key channels) */}
-        {isMultiKey && (
-          <DropdownMenuItem onClick={handleManageKeys}>
-            {t('Manage Keys')}
+
+          {/* Fetch Models */}
+          <DropdownMenuItem onClick={handleFetchModels}>
+            {t('Fetch Models')}
             <DropdownMenuShortcut>
-              <Key size={16} />
+              <Download size={16} />
             </DropdownMenuShortcut>
           </DropdownMenuItem>
-        )}
 
-        <DropdownMenuSeparator />
+          {/* Detect Upstream Updates (only for fetchable channel types) */}
+          {MODEL_FETCHABLE_TYPES.has(channel.type) && (
+            <DropdownMenuItem
+              onClick={() => {
+                const meta = parseUpstreamUpdateMeta(channel.settings)
+                if (
+                  meta.pendingAddModels.length > 0 ||
+                  meta.pendingRemoveModels.length > 0
+                ) {
+                  upstream.openModal(
+                    channel,
+                    meta.pendingAddModels,
+                    meta.pendingRemoveModels,
+                    meta.pendingAddModels.length > 0 ? 'add' : 'remove'
+                  )
+                } else {
+                  upstream.detectChannelUpdates(channel)
+                }
+              }}
+            >
+              {t('Upstream Updates')}
+              <DropdownMenuShortcut>
+                <RefreshCw size={16} />
+              </DropdownMenuShortcut>
+            </DropdownMenuItem>
+          )}
 
-        {/* Enable/Disable */}
-        <DropdownMenuItem onClick={handleToggleStatus}>
-          {isEnabled ? (
-            <>
-              {t('Disable')}
+          {/* Ollama Models (only for Ollama channels) */}
+          {channel.type === 4 && (
+            <DropdownMenuItem onClick={handleManageOllamaModels}>
+              {t('Manage Ollama Models')}
               <DropdownMenuShortcut>
-                <PowerOff size={16} />
+                <Boxes size={16} />
               </DropdownMenuShortcut>
-            </>
-          ) : (
-            <>
-              {t('Enable')}
+            </DropdownMenuItem>
+          )}
+
+          <DropdownMenuSeparator />
+
+          {/* Copy Channel */}
+          <DropdownMenuItem onClick={handleCopy}>
+            {t('Copy Channel')}
+            <DropdownMenuShortcut>
+              <Copy size={16} />
+            </DropdownMenuShortcut>
+          </DropdownMenuItem>
+
+          {/* Manage Keys (only for multi-key channels) */}
+          {isMultiKey && (
+            <DropdownMenuItem onClick={handleManageKeys}>
+              {t('Manage Keys')}
               <DropdownMenuShortcut>
-                <Power size={16} />
+                <Key size={16} />
               </DropdownMenuShortcut>
-            </>
+            </DropdownMenuItem>
           )}
-        </DropdownMenuItem>
-
-        <DropdownMenuSeparator />
-
-        {/* Delete */}
-        <DropdownMenuItem
-          onSelect={(e) => {
-            e.preventDefault()
-            setDeleteConfirmOpen(true)
-          }}
-          className='text-destructive focus:text-destructive'
-        >
-          {t('Delete')}
-          <DropdownMenuShortcut>
-            <Trash2 size={16} />
-          </DropdownMenuShortcut>
-        </DropdownMenuItem>
-      </DropdownMenuContent>
+
+          <DropdownMenuSeparator />
+
+          {/* Delete */}
+          <DropdownMenuItem
+            onSelect={(e) => {
+              e.preventDefault()
+              setDeleteConfirmOpen(true)
+            }}
+            className='text-destructive focus:text-destructive'
+          >
+            {t('Delete')}
+            <DropdownMenuShortcut>
+              <Trash2 size={16} />
+            </DropdownMenuShortcut>
+          </DropdownMenuItem>
+        </DropdownMenuContent>
+      </DropdownMenu>
 
       <ConfirmDialog
         open={deleteConfirmOpen}
@@ -241,6 +300,6 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
           setDeleteConfirmOpen(false)
         }}
       />
-    </DropdownMenu>
+    </div>
   )
 }

+ 7 - 9
web/default/src/features/channels/components/dialogs/edit-tag-dialog.tsx

@@ -24,6 +24,7 @@ import {
 } from '@/components/ui/select'
 import { Separator } from '@/components/ui/separator'
 import { Textarea } from '@/components/ui/textarea'
+import { GroupBadge } from '@/components/group-badge'
 import { StatusBadge } from '@/components/status-badge'
 import {
   editTagChannels,
@@ -388,17 +389,14 @@ export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) {
               </Label>
               <div className='flex min-h-[60px] flex-wrap gap-2 rounded-md border p-3'>
                 {availableGroups.map((group) => (
-                  <StatusBadge
+                  <GroupBadge
                     key={group}
-                    variant={
-                      selectedGroups.includes(group) ? 'info' : 'neutral'
-                    }
-                    className='cursor-pointer transition-opacity hover:opacity-70'
-                    copyable={false}
+                    group={group}
+                    className={`cursor-pointer rounded-sm transition-opacity hover:opacity-70 ${
+                      selectedGroups.includes(group) ? 'bg-muted/70 px-1' : ''
+                    }`}
                     onClick={() => handleToggleGroup(group)}
-                  >
-                    {group}
-                  </StatusBadge>
+                  />
                 ))}
               </div>
             </div>

+ 23 - 15
web/default/src/features/dashboard/components/models/consumption-distribution-chart.tsx

@@ -5,43 +5,51 @@ 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 {
+  CONSUMPTION_DISTRIBUTION_CHART_OPTIONS,
+  DEFAULT_TIME_GRANULARITY,
+} from '@/features/dashboard/constants'
 import { processChartData } from '@/features/dashboard/lib'
-import type { QuotaDataItem } from '@/features/dashboard/types'
+import type {
+  ConsumptionDistributionChartType,
+  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
+  defaultChartType?: ConsumptionDistributionChartType
 }
 
-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 },
-]
+const CHART_TYPE_ICONS: Record<ConsumptionDistributionChartType, typeof BarChart3> =
+  {
+    bar: BarChart3,
+    area: AreaChart,
+  }
 
 export function ConsumptionDistributionChart(
   props: ConsumptionDistributionChartProps
 ) {
   const { t } = useTranslation()
   const { resolvedTheme } = useTheme()
-  const [chartType, setChartType] = useState<DistributionChartType>('bar')
+  const [chartType, setChartType] = useState<ConsumptionDistributionChartType>(
+    props.defaultChartType ?? 'bar'
+  )
   const [themeReady, setThemeReady] = useState(false)
   const themeManagerRef = useRef<
     (typeof import('@visactor/vchart'))['ThemeManager'] | null
   >(null)
   const timeGranularity = props.timeGranularity ?? DEFAULT_TIME_GRANULARITY
 
+  useEffect(() => {
+    if (props.defaultChartType) setChartType(props.defaultChartType)
+  }, [props.defaultChartType])
+
   useEffect(() => {
     const updateTheme = async () => {
       setThemeReady(false)
@@ -81,8 +89,8 @@ export function ConsumptionDistributionChart(
         </div>
 
         <div className='bg-muted/60 inline-flex h-8 rounded-md border p-0.5'>
-          {CHART_TYPES.map((item) => {
-            const Icon = item.icon
+          {CONSUMPTION_DISTRIBUTION_CHART_OPTIONS.map((item) => {
+            const Icon = CHART_TYPE_ICONS[item.value]
             return (
               <button
                 key={item.value}

+ 23 - 20
web/default/src/features/dashboard/components/models/model-charts.tsx

@@ -5,47 +5,51 @@ 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 {
+  DEFAULT_TIME_GRANULARITY,
+  MODEL_ANALYTICS_CHART_OPTIONS,
+} from '@/features/dashboard/constants'
 import { processChartData } from '@/features/dashboard/lib'
-import type { QuotaDataItem } from '@/features/dashboard/types'
+import type {
+  ModelAnalyticsChartTab,
+  QuotaDataItem,
+} from '@/features/dashboard/types'
 
 let themeManagerPromise: Promise<
   (typeof import('@visactor/vchart'))['ThemeManager']
 > | null = null
 
-type ChartTab = 'trend' | 'proportion' | 'top'
 type ChartSpecKey = 'spec_model_line' | 'spec_pie' | 'spec_rank_bar'
 
-const CHART_TABS: {
-  value: ChartTab
-  labelKey: string
-  specKey: ChartSpecKey
-}[] = [
-  { 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' },
-]
+const CHART_SPEC_KEYS: Record<ModelAnalyticsChartTab, ChartSpecKey> = {
+  trend: 'spec_model_line',
+  proportion: 'spec_pie',
+  top: 'spec_rank_bar',
+}
 
 interface ModelChartsProps {
   data: QuotaDataItem[]
   loading?: boolean
   timeGranularity?: TimeGranularity
+  defaultChartTab?: ModelAnalyticsChartTab
 }
 
 export function ModelCharts(props: ModelChartsProps) {
   const { t } = useTranslation()
   const { resolvedTheme } = useTheme()
-  const [activeTab, setActiveTab] = useState<ChartTab>('trend')
+  const [activeTab, setActiveTab] = useState<ModelAnalyticsChartTab>(
+    props.defaultChartTab ?? 'trend'
+  )
   const [themeReady, setThemeReady] = useState(false)
   const themeManagerRef = useRef<
     (typeof import('@visactor/vchart'))['ThemeManager'] | null
   >(null)
   const timeGranularity = props.timeGranularity ?? DEFAULT_TIME_GRANULARITY
 
+  useEffect(() => {
+    if (props.defaultChartTab) setActiveTab(props.defaultChartTab)
+  }, [props.defaultChartTab])
+
   useEffect(() => {
     const updateTheme = async () => {
       setThemeReady(false)
@@ -70,8 +74,7 @@ export function ModelCharts(props: ModelChartsProps) {
     [props.data, props.loading, timeGranularity, t]
   )
 
-  const activeSpec = CHART_TABS.find((tab) => tab.value === activeTab)
-  const spec = activeSpec ? chartData[activeSpec.specKey] : null
+  const spec = chartData[CHART_SPEC_KEYS[activeTab]]
 
   return (
     <div className='overflow-hidden rounded-lg border'>
@@ -87,7 +90,7 @@ export function ModelCharts(props: ModelChartsProps) {
         </div>
 
         <div className='bg-muted/60 inline-flex h-8 rounded-md border p-0.5'>
-          {CHART_TABS.map((tab) => (
+          {MODEL_ANALYTICS_CHART_OPTIONS.map((tab) => (
             <button
               key={tab.value}
               type='button'

+ 21 - 28
web/default/src/features/dashboard/components/models/models-filter-dialog.tsx

@@ -1,4 +1,4 @@
-import { useState } from 'react'
+import { useEffect, useState } from 'react'
 import { Filter, RotateCcw, Calendar, Search } from 'lucide-react'
 import { useTranslation } from 'react-i18next'
 import { useAuthStore } from '@/stores/auth-store'
@@ -26,20 +26,20 @@ import {
 } from '@/components/ui/select'
 import { DateTimePicker } from '@/components/datetime-picker'
 import {
-  DEFAULT_TIME_GRANULARITY,
   TIME_GRANULARITY_OPTIONS,
   TIME_RANGE_PRESETS,
-  EMPTY_DASHBOARD_FILTERS,
 } from '@/features/dashboard/constants'
 import {
+  buildDefaultDashboardFilters,
   cleanFilters,
-  getSavedGranularity,
-  saveGranularity,
-  getDefaultDays,
 } from '@/features/dashboard/lib'
-import { type DashboardFilters } from '@/features/dashboard/types'
+import type {
+  DashboardChartPreferences,
+  DashboardFilters,
+} from '@/features/dashboard/types'
 
 interface ModelsFilterProps {
+  preferences: DashboardChartPreferences
   onFilterChange: (filters: DashboardFilters) => void
   onReset: () => void
 }
@@ -58,30 +58,27 @@ const SectionDivider = ({ label }: { label: string }) => (
   </div>
 )
 
-export function ModelsFilter({ onFilterChange, onReset }: ModelsFilterProps) {
+export function ModelsFilter(props: ModelsFilterProps) {
   const { t } = useTranslation()
   // 使用已缓存的用户数据,避免重复调用 API
   const user = useAuthStore((state) => state.auth.user)
   const isAdmin = user?.role && user.role >= 10
 
   const [open, setOpen] = useState(false)
-  const [filters, setFilters] = useState<DashboardFilters>(() => {
-    const granularity = getSavedGranularity()
-    const days = getDefaultDays(granularity)
-    const { start, end } = getNormalizedDateRange(days)
-    return {
-      ...EMPTY_DASHBOARD_FILTERS,
-      start_timestamp: start,
-      end_timestamp: end,
-      time_granularity: granularity,
-    }
-  })
+  const [filters, setFilters] = useState<DashboardFilters>(() =>
+    buildDefaultDashboardFilters(props.preferences)
+  )
   const [selectedRange, setSelectedRange] = useState<number | null>(() =>
-    getDefaultDays()
+    props.preferences.defaultTimeRangeDays
   )
 
+  useEffect(() => {
+    setFilters(buildDefaultDashboardFilters(props.preferences))
+    setSelectedRange(props.preferences.defaultTimeRangeDays)
+  }, [props.preferences])
+
   const handleApply = () => {
-    onFilterChange(
+    props.onFilterChange(
       cleanFilters(
         filters as unknown as Record<string, unknown>
       ) as typeof filters
@@ -90,17 +87,15 @@ export function ModelsFilter({ onFilterChange, onReset }: ModelsFilterProps) {
   }
 
   const handleReset = () => {
-    const days = getDefaultDays(DEFAULT_TIME_GRANULARITY)
+    const days = props.preferences.defaultTimeRangeDays
     const { start, end } = getNormalizedDateRange(days)
     setFilters({
-      ...EMPTY_DASHBOARD_FILTERS,
+      ...buildDefaultDashboardFilters(props.preferences),
       start_timestamp: start,
       end_timestamp: end,
-      time_granularity: DEFAULT_TIME_GRANULARITY,
     })
     setSelectedRange(days)
-    saveGranularity(DEFAULT_TIME_GRANULARITY)
-    onReset()
+    props.onReset()
     setOpen(false)
   }
 
@@ -111,8 +106,6 @@ export function ModelsFilter({ onFilterChange, onReset }: ModelsFilterProps) {
     setFilters((prev) => ({ ...prev, [field]: value }))
     if (field === 'start_timestamp' || field === 'end_timestamp')
       setSelectedRange(null)
-    if (field === 'time_granularity' && typeof value === 'string')
-      saveGranularity(value as TimeGranularity)
   }
 
   const handleQuickRange = (days: number) => {

+ 21 - 1
web/default/src/features/dashboard/constants.ts

@@ -1,9 +1,18 @@
-import type { DashboardFilters } from './types'
+import type { DashboardChartPreferences, DashboardFilters } from './types'
 
 export const TIME_GRANULARITY_STORAGE_KEY = 'data_export_default_time'
+export const DASHBOARD_CHART_PREFERENCES_STORAGE_KEY =
+  'dashboard_models_chart_preferences'
 export const DEFAULT_TIME_GRANULARITY = 'hour' as const
 export const MAX_CHART_TREND_POINTS = 7
 
+export const DEFAULT_DASHBOARD_CHART_PREFERENCES: DashboardChartPreferences = {
+  consumptionDistributionChart: 'bar',
+  modelAnalyticsChart: 'trend',
+  defaultTimeRangeDays: 1,
+  defaultTimeGranularity: DEFAULT_TIME_GRANULARITY,
+}
+
 export const TIME_RANGE_BY_GRANULARITY = {
   hour: 1,
   day: 7,
@@ -23,6 +32,17 @@ export const TIME_RANGE_PRESETS = [
   { label: '29 Days', days: 29 },
 ] as const
 
+export const CONSUMPTION_DISTRIBUTION_CHART_OPTIONS = [
+  { value: 'bar', labelKey: 'Bar Chart' },
+  { value: 'area', labelKey: 'Area Chart' },
+] as const
+
+export const MODEL_ANALYTICS_CHART_OPTIONS = [
+  { value: 'trend', labelKey: 'Call Trend' },
+  { value: 'proportion', labelKey: 'Call Count Distribution' },
+  { value: 'top', labelKey: 'Call Count Ranking' },
+] as const
+
 export const EMPTY_DASHBOARD_FILTERS: DashboardFilters = {
   start_timestamp: undefined,
   end_timestamp: undefined,

+ 66 - 22
web/default/src/features/dashboard/index.tsx

@@ -11,6 +11,12 @@ import {
   CardStaggerItem,
   FadeIn,
 } from '@/components/page-transition'
+import {
+  buildDefaultDashboardFilters,
+  getSavedChartPreferences,
+  saveChartPreferences,
+} from './lib'
+import { ModelsChartPreferences } from './components/models/models-chart-preferences'
 import { ModelsFilter } from './components/models/models-filter-dialog'
 import { AnnouncementsPanel } from './components/overview/announcements-panel'
 import { ApiInfoPanel } from './components/overview/api-info-panel'
@@ -23,7 +29,11 @@ import {
   DASHBOARD_DEFAULT_SECTION,
   DASHBOARD_SECTION_IDS,
 } from './section-registry'
-import { type DashboardFilters, type QuotaDataItem } from './types'
+import {
+  type DashboardChartPreferences,
+  type DashboardFilters,
+  type QuotaDataItem,
+} from './types'
 
 const route = getRouteApi('/_authenticated/dashboard/$section')
 
@@ -107,17 +117,21 @@ export function Dashboard() {
   const activeSection = (params.section ??
     DASHBOARD_DEFAULT_SECTION) as DashboardSectionId
 
-  const [modelFilters, setModelFilters] = useState<DashboardFilters>({})
   const [modelData, setModelData] = useState<QuotaDataItem[]>([])
   const [dataLoading, setDataLoading] = useState(false)
+  const [chartPreferences, setChartPreferences] =
+    useState<DashboardChartPreferences>(() => getSavedChartPreferences())
+  const [modelFilters, setModelFilters] = useState<DashboardFilters>(() =>
+    buildDefaultDashboardFilters(getSavedChartPreferences())
+  )
 
   const handleFilterChange = useCallback((filters: DashboardFilters) => {
     setModelFilters(filters)
   }, [])
 
   const handleResetFilters = useCallback(() => {
-    setModelFilters({})
-  }, [])
+    setModelFilters(buildDefaultDashboardFilters(chartPreferences))
+  }, [chartPreferences])
 
   const handleDataUpdate = useCallback(
     (data: QuotaDataItem[], loading: boolean) => {
@@ -127,6 +141,15 @@ export function Dashboard() {
     []
   )
 
+  const handleChartPreferencesChange = useCallback(
+    (preferences: DashboardChartPreferences) => {
+      setChartPreferences(preferences)
+      setModelFilters(buildDefaultDashboardFilters(preferences))
+      saveChartPreferences(preferences)
+    },
+    []
+  )
+
   const meta = SECTION_META[activeSection] ?? SECTION_META.overview
   const isAdmin = Boolean(userRole && userRole >= ROLE.ADMIN)
   const visibleSections = useMemo(
@@ -146,6 +169,20 @@ export function Dashboard() {
     [navigate]
   )
   const showSectionTabs = activeSection !== 'overview' && visibleSections.length > 1
+  const modelActions =
+    activeSection === 'models' ? (
+      <>
+        <ModelsChartPreferences
+          preferences={chartPreferences}
+          onPreferencesChange={handleChartPreferencesChange}
+        />
+        <ModelsFilter
+          preferences={chartPreferences}
+          onFilterChange={handleFilterChange}
+          onReset={handleResetFilters}
+        />
+      </>
+    ) : null
 
   return (
     <SectionPageLayout>
@@ -153,26 +190,29 @@ export function Dashboard() {
       <SectionPageLayout.Description>
         {t(meta.descriptionKey)}
       </SectionPageLayout.Description>
-      {activeSection === 'models' && (
-        <SectionPageLayout.Actions>
-          <ModelsFilter
-            onFilterChange={handleFilterChange}
-            onReset={handleResetFilters}
-          />
-        </SectionPageLayout.Actions>
-      )}
       <SectionPageLayout.Content>
         <div className='space-y-4'>
-          {showSectionTabs && (
-            <Tabs value={activeSection} onValueChange={handleSectionChange}>
-              <TabsList className='h-auto max-w-full flex-wrap justify-start'>
-                {visibleSections.map((section) => (
-                  <TabsTrigger key={section} value={section}>
-                    {t(SECTION_META[section].titleKey)}
-                  </TabsTrigger>
-                ))}
-              </TabsList>
-            </Tabs>
+          {activeSection !== 'overview' && (
+            <div className='flex flex-wrap items-center justify-between gap-2'>
+              {showSectionTabs ? (
+                <Tabs value={activeSection} onValueChange={handleSectionChange}>
+                  <TabsList className='h-auto max-w-full flex-wrap justify-start'>
+                    {visibleSections.map((section) => (
+                      <TabsTrigger key={section} value={section}>
+                        {t(SECTION_META[section].titleKey)}
+                      </TabsTrigger>
+                    ))}
+                  </TabsList>
+                </Tabs>
+              ) : (
+                <div />
+              )}
+              {modelActions != null && (
+                <div className='flex shrink-0 flex-wrap items-center gap-2'>
+                  {modelActions}
+                </div>
+              )}
+            </div>
           )}
           {activeSection === 'overview' && (
             <>
@@ -208,6 +248,9 @@ export function Dashboard() {
                   <LazyConsumptionDistributionChart
                     data={modelData}
                     loading={dataLoading}
+                    defaultChartType={
+                      chartPreferences.consumptionDistributionChart
+                    }
                     timeGranularity={
                       modelFilters.time_granularity || DEFAULT_TIME_GRANULARITY
                     }
@@ -219,6 +262,7 @@ export function Dashboard() {
                   <LazyModelCharts
                     data={modelData}
                     loading={dataLoading}
+                    defaultChartTab={chartPreferences.modelAnalyticsChart}
                     timeGranularity={
                       modelFilters.time_granularity || DEFAULT_TIME_GRANULARITY
                     }

+ 12 - 2
web/default/src/features/dashboard/lib/charts.ts

@@ -910,8 +910,18 @@ export function processUserChartData(
           },
         },
       },
-      area: { style: { fillOpacity: 0.15 } },
-      line: { style: { lineWidth: 2 } },
+      area: {
+        style: {
+          fillOpacity: 0.15,
+          curveType: 'monotone',
+        },
+      },
+      line: {
+        style: {
+          lineWidth: 2,
+          curveType: 'monotone',
+        },
+      },
       point: { visible: false },
       color: { specified: userColorMap },
       background: { fill: 'transparent' },

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

@@ -1,9 +1,46 @@
 import type { TimeGranularity } from '@/lib/time'
+import { getNormalizedDateRange } from '@/lib/time'
 import {
+  DASHBOARD_CHART_PREFERENCES_STORAGE_KEY,
+  DEFAULT_DASHBOARD_CHART_PREFERENCES,
   DEFAULT_TIME_GRANULARITY,
+  EMPTY_DASHBOARD_FILTERS,
   TIME_GRANULARITY_STORAGE_KEY,
+  TIME_RANGE_PRESETS,
   TIME_RANGE_BY_GRANULARITY,
 } from '@/features/dashboard/constants'
+import type {
+  ConsumptionDistributionChartType,
+  DashboardChartPreferences,
+  DashboardFilters,
+  ModelAnalyticsChartTab,
+} from '@/features/dashboard/types'
+
+function isTimeGranularity(value: unknown): value is TimeGranularity {
+  return value === 'hour' || value === 'day' || value === 'week'
+}
+
+function getLegacySavedGranularity(): TimeGranularity {
+  if (typeof window === 'undefined') return DEFAULT_TIME_GRANULARITY
+  const saved = localStorage.getItem(TIME_GRANULARITY_STORAGE_KEY)
+  return isTimeGranularity(saved) ? saved : DEFAULT_TIME_GRANULARITY
+}
+
+function isConsumptionDistributionChartType(
+  value: unknown
+): value is ConsumptionDistributionChartType {
+  return value === 'bar' || value === 'area'
+}
+
+function isModelAnalyticsChartTab(
+  value: unknown
+): value is ModelAnalyticsChartTab {
+  return value === 'trend' || value === 'proportion' || value === 'top'
+}
+
+function isTimeRangePresetDays(value: unknown): value is number {
+  return TIME_RANGE_PRESETS.some((preset) => preset.days === value)
+}
 
 export function cleanFilters<T extends Record<string, unknown>>(
   filters: T
@@ -25,20 +62,81 @@ export function getSavedGranularity(
   override?: TimeGranularity
 ): TimeGranularity {
   if (override) return override
-  if (typeof window === 'undefined') return DEFAULT_TIME_GRANULARITY
-  const saved = localStorage.getItem(TIME_GRANULARITY_STORAGE_KEY)
-  if (saved === 'hour' || saved === 'day' || saved === 'week') return saved
-  return DEFAULT_TIME_GRANULARITY
+  return getSavedChartPreferences().defaultTimeGranularity
 }
 
 export function saveGranularity(granularity: TimeGranularity): void {
+  if (typeof window === 'undefined') return
+  saveChartPreferences({
+    ...getSavedChartPreferences(),
+    defaultTimeGranularity: granularity,
+  })
   localStorage.setItem(TIME_GRANULARITY_STORAGE_KEY, granularity)
 }
 
+export function getSavedChartPreferences(): DashboardChartPreferences {
+  if (typeof window === 'undefined') return DEFAULT_DASHBOARD_CHART_PREFERENCES
+
+  const fallbackPreferences = {
+    ...DEFAULT_DASHBOARD_CHART_PREFERENCES,
+    defaultTimeGranularity: getLegacySavedGranularity(),
+  }
+
+  try {
+    const raw = localStorage.getItem(DASHBOARD_CHART_PREFERENCES_STORAGE_KEY)
+    if (!raw) return fallbackPreferences
+
+    const parsed = JSON.parse(raw) as Partial<DashboardChartPreferences>
+    return {
+      consumptionDistributionChart: isConsumptionDistributionChartType(
+        parsed.consumptionDistributionChart
+      )
+        ? parsed.consumptionDistributionChart
+        : fallbackPreferences.consumptionDistributionChart,
+      modelAnalyticsChart: isModelAnalyticsChartTab(parsed.modelAnalyticsChart)
+        ? parsed.modelAnalyticsChart
+        : fallbackPreferences.modelAnalyticsChart,
+      defaultTimeRangeDays: isTimeRangePresetDays(parsed.defaultTimeRangeDays)
+        ? parsed.defaultTimeRangeDays
+        : fallbackPreferences.defaultTimeRangeDays,
+      defaultTimeGranularity: isTimeGranularity(
+        parsed.defaultTimeGranularity
+      )
+        ? parsed.defaultTimeGranularity
+        : fallbackPreferences.defaultTimeGranularity,
+    }
+  } catch {
+    return fallbackPreferences
+  }
+}
+
+export function saveChartPreferences(
+  preferences: DashboardChartPreferences
+): void {
+  if (typeof window === 'undefined') return
+  localStorage.setItem(
+    DASHBOARD_CHART_PREFERENCES_STORAGE_KEY,
+    JSON.stringify(preferences)
+  )
+}
+
 export function getDefaultDays(granularity?: TimeGranularity): number {
+  if (!granularity) return getSavedChartPreferences().defaultTimeRangeDays
   return TIME_RANGE_BY_GRANULARITY[getSavedGranularity(granularity)]
 }
 
+export function buildDefaultDashboardFilters(
+  preferences: DashboardChartPreferences = getSavedChartPreferences()
+): DashboardFilters {
+  const { start, end } = getNormalizedDateRange(preferences.defaultTimeRangeDays)
+  return {
+    ...EMPTY_DASHBOARD_FILTERS,
+    start_timestamp: start,
+    end_timestamp: end,
+    time_granularity: preferences.defaultTimeGranularity,
+  }
+}
+
 export function buildQueryParams(
   timeRange: { start_timestamp: number; end_timestamp: number },
   filters?: { time_granularity?: TimeGranularity; username?: string }

+ 3 - 0
web/default/src/features/dashboard/lib/index.ts

@@ -4,6 +4,9 @@ export {
   getSavedGranularity,
   saveGranularity,
   getDefaultDays,
+  getSavedChartPreferences,
+  saveChartPreferences,
+  buildDefaultDashboardFilters,
 } from './filters'
 export {
   getLatencyColorClass,

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

@@ -42,6 +42,17 @@ export interface DashboardFilters {
   username?: string
 }
 
+export type ConsumptionDistributionChartType = 'bar' | 'area'
+
+export type ModelAnalyticsChartTab = 'trend' | 'proportion' | 'top'
+
+export interface DashboardChartPreferences {
+  consumptionDistributionChart: ConsumptionDistributionChartType
+  modelAnalyticsChart: ModelAnalyticsChartTab
+  defaultTimeRangeDays: number
+  defaultTimeGranularity: TimeGranularity
+}
+
 // ============================================================================
 // API Info Types
 // ============================================================================

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

@@ -14,6 +14,7 @@ import {
   TooltipTrigger,
 } from '@/components/ui/tooltip'
 import { DataTableColumnHeader } from '@/components/data-table'
+import { GroupBadge } from '@/components/group-badge'
 import { StatusBadge } from '@/components/status-badge'
 import { getSystemOptions } from '@/features/system-settings/api'
 import { API_KEY_STATUSES } from '../constants'
@@ -31,16 +32,6 @@ function getQuotaProgressColor(percentage: number): string {
   return '[&_[data-slot=progress-indicator]]:bg-emerald-500'
 }
 
-function getGroupRatioClassName(ratio: number): string {
-  if (ratio > 1) {
-    return 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/60 dark:bg-amber-950/40 dark:text-amber-300'
-  }
-  if (ratio < 1) {
-    return 'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-900/60 dark:bg-sky-950/40 dark:text-sky-300'
-  }
-  return 'border-border bg-muted text-muted-foreground'
-}
-
 function useGroupRatios(): Record<string, number> {
   const isAdmin = useAuthStore((s) =>
     Boolean(s.auth.user?.role && s.auth.user.role >= 10)
@@ -230,7 +221,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
             <Tooltip>
               <TooltipTrigger asChild>
                 <span className='inline-flex items-center gap-1.5 text-xs'>
-                  <span className='text-muted-foreground'>{t('Auto')}</span>
+                  <GroupBadge group='auto' />
                   {apiKey.cross_group_retry && (
                     <>
                       <span className='text-muted-foreground/30'>·</span>
@@ -251,22 +242,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
             </Tooltip>
           )
         }
-        return (
-          <span className='inline-flex items-center gap-2 text-xs'>
-            <span className='font-medium'>{group || t('Default')}</span>
-            {ratio != null && (
-              <span
-                className={cn(
-                  'inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 font-mono text-[11px] leading-none tabular-nums',
-                  getGroupRatioClassName(ratio)
-                )}
-              >
-                <span className='size-1 rounded-full bg-current opacity-60' />
-                <span>{ratio}x</span>
-              </span>
-            )}
-          </span>
-        )
+        return <GroupBadge group={group} ratio={ratio} />
       },
       meta: { label: t('Group'), mobileHidden: true },
     },
@@ -354,6 +330,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
       id: 'actions',
       cell: ({ row }) => <DataTableRowActions row={row} />,
       meta: { label: t('Actions') },
+      size: 88,
     },
   ]
 }

+ 16 - 6
web/default/src/features/keys/components/api-keys-table.tsx

@@ -27,6 +27,8 @@ import {
   TableRow,
 } from '@/components/ui/table'
 import {
+  DISABLED_ROW_DESKTOP,
+  DISABLED_ROW_MOBILE,
   DataTablePagination,
   DataTableToolbar,
   TableSkeleton,
@@ -35,7 +37,7 @@ import {
 } from '@/components/data-table'
 import { PageFooterPortal } from '@/components/layout'
 import { getApiKeys, searchApiKeys } from '../api'
-import { API_KEY_STATUS_OPTIONS, ERROR_MESSAGES } from '../constants'
+import { API_KEY_STATUS, 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'
@@ -44,6 +46,10 @@ import { DataTableBulkActions } from './data-table-bulk-actions'
 
 const route = getRouteApi('/_authenticated/keys/')
 
+function isDisabledApiKeyRow(apiKey: ApiKey) {
+  return apiKey.status !== API_KEY_STATUS.ENABLED
+}
+
 export function ApiKeysTable() {
   const { t } = useTranslation()
   const { refreshTrigger } = useApiKeys()
@@ -185,6 +191,11 @@ export function ApiKeysTable() {
             emptyDescription={t(
               'No API keys available. Create your first API key to get started.'
             )}
+            getRowClassName={(row) =>
+              isDisabledApiKeyRow(row.original)
+                ? DISABLED_ROW_MOBILE
+                : undefined
+            }
           />
         ) : (
           <div
@@ -226,11 +237,10 @@ export function ApiKeysTable() {
                     <TableRow
                       key={row.id}
                       data-state={row.getIsSelected() && 'selected'}
-                      className={
-                        (row.original as ApiKey).status !== 1
-                          ? 'opacity-60'
-                          : undefined
-                      }
+                      className={cn(
+                        isDisabledApiKeyRow(row.original) &&
+                          DISABLED_ROW_DESKTOP
+                      )}
                     >
                       {row.getVisibleCells().map((cell) => (
                         <TableCell key={cell.id}>

+ 146 - 117
web/default/src/features/keys/components/data-table-row-actions.tsx

@@ -1,4 +1,4 @@
-import { useCallback } from 'react'
+import { useCallback, useState } from 'react'
 import { DotsHorizontalIcon } from '@radix-ui/react-icons'
 import { type Row } from '@tanstack/react-table'
 import {
@@ -10,6 +10,7 @@ import {
   ArrowRightLeft,
   Copy,
   Link,
+  Loader2,
 } from 'lucide-react'
 import { useTranslation } from 'react-i18next'
 import { toast } from 'sonner'
@@ -26,6 +27,11 @@ import {
   DropdownMenuShortcut,
   DropdownMenuTrigger,
 } from '@/components/ui/dropdown-menu'
+import {
+  Tooltip,
+  TooltipContent,
+  TooltipTrigger,
+} from '@/components/ui/tooltip'
 import { useChatPresets } from '@/features/chat/hooks/use-chat-presets'
 import { resolveChatUrl, type ChatPreset } from '@/features/chat/lib/chat-links'
 import { sendToFluent } from '@/features/chat/lib/send-to-fluent'
@@ -73,6 +79,7 @@ export function DataTableRowActions<TData>({
   } = useApiKeys()
   const isEnabled = apiKey.status === API_KEY_STATUS.ENABLED
   const { chatPresets, serverAddress } = useChatPresets()
+  const [isTogglingStatus, setIsTogglingStatus] = useState(false)
 
   const hasChatPresets = chatPresets.length > 0
 
@@ -117,11 +124,15 @@ export function DataTableRowActions<TData>({
     [resolveRealKey, apiKey.id, serverAddress, t]
   )
 
-  const handleToggleStatus = async () => {
+  const handleToggleStatus = async (
+    e?: React.MouseEvent<HTMLButtonElement>
+  ) => {
+    e?.stopPropagation()
     const newStatus = isEnabled
       ? API_KEY_STATUS.DISABLED
       : API_KEY_STATUS.ENABLED
 
+    setIsTogglingStatus(true)
     try {
       const result = await updateApiKeyStatus(apiKey.id, newStatus)
       if (result.success) {
@@ -135,125 +146,143 @@ export function DataTableRowActions<TData>({
       }
     } catch {
       toast.error(t(ERROR_MESSAGES.UNEXPECTED))
+    } finally {
+      setIsTogglingStatus(false)
     }
   }
 
   return (
-    <DropdownMenu modal={false}>
-      <DropdownMenuTrigger asChild>
-        <Button
-          variant='ghost'
-          className='data-[state=open]:bg-muted flex h-8 w-8 p-0'
-        >
-          <DotsHorizontalIcon className='h-4 w-4' />
-          <span className='sr-only'>{t('Open menu')}</span>
-        </Button>
-      </DropdownMenuTrigger>
-      <DropdownMenuContent align='end' className='w-[200px]'>
-        <DropdownMenuItem
-          onClick={async () => {
-            const realKey = await resolveRealKey(apiKey.id)
-            if (!realKey) return
-            const ok = await copyToClipboard(realKey)
-            if (ok) toast.success(t('Copied'))
-          }}
-        >
-          {t('Copy Key')}
-          <DropdownMenuShortcut>
-            <Copy size={16} />
-          </DropdownMenuShortcut>
-        </DropdownMenuItem>
-        <DropdownMenuItem
-          onClick={async () => {
-            const realKey = await resolveRealKey(apiKey.id)
-            if (!realKey) return
-            const connStr = encodeConnectionString(realKey, getServerAddress())
-            const ok = await copyToClipboard(connStr)
-            if (ok) toast.success(t('Copied'))
-          }}
-        >
-          {t('Copy Connection Info')}
-          <DropdownMenuShortcut>
-            <Link size={16} />
-          </DropdownMenuShortcut>
-        </DropdownMenuItem>
-        <DropdownMenuSeparator />
-        <DropdownMenuItem
-          onClick={() => {
-            setCurrentRow(apiKey)
-            setOpen('update')
-          }}
-        >
-          {t('Edit')}
-          <DropdownMenuShortcut>
-            <Edit size={16} />
-          </DropdownMenuShortcut>
-        </DropdownMenuItem>
-        <DropdownMenuItem onClick={handleToggleStatus}>
-          {isEnabled ? (
-            <>
-              {t('Disable')}
-              <DropdownMenuShortcut>
-                <PowerOff size={16} />
-              </DropdownMenuShortcut>
-            </>
-          ) : (
-            <>
-              {t('Enable')}
-              <DropdownMenuShortcut>
-                <Power size={16} />
-              </DropdownMenuShortcut>
-            </>
+    <div className='flex items-center justify-end gap-1'>
+      <Tooltip>
+        <TooltipTrigger asChild>
+          <Button
+            variant='ghost'
+            size='icon-sm'
+            onClick={handleToggleStatus}
+            disabled={isTogglingStatus}
+            aria-label={isEnabled ? t('Disable') : t('Enable')}
+            className={
+              isEnabled
+                ? 'text-destructive hover:text-destructive'
+                : 'text-emerald-600 hover:text-emerald-600 dark:text-emerald-400 dark:hover:text-emerald-400'
+            }
+          >
+            {isTogglingStatus ? (
+              <Loader2 className='size-4 animate-spin' />
+            ) : isEnabled ? (
+              <PowerOff className='size-4' />
+            ) : (
+              <Power className='size-4' />
+            )}
+          </Button>
+        </TooltipTrigger>
+        <TooltipContent>
+          {isEnabled ? t('Disable') : t('Enable')}
+        </TooltipContent>
+      </Tooltip>
+
+      <DropdownMenu modal={false}>
+        <DropdownMenuTrigger asChild>
+          <Button
+            variant='ghost'
+            className='data-[state=open]:bg-muted flex h-8 w-8 p-0'
+          >
+            <DotsHorizontalIcon className='h-4 w-4' />
+            <span className='sr-only'>{t('Open menu')}</span>
+          </Button>
+        </DropdownMenuTrigger>
+        <DropdownMenuContent align='end' className='w-[200px]'>
+          <DropdownMenuItem
+            onClick={async () => {
+              const realKey = await resolveRealKey(apiKey.id)
+              if (!realKey) return
+              const ok = await copyToClipboard(realKey)
+              if (ok) toast.success(t('Copied'))
+            }}
+          >
+            {t('Copy Key')}
+            <DropdownMenuShortcut>
+              <Copy size={16} />
+            </DropdownMenuShortcut>
+          </DropdownMenuItem>
+          <DropdownMenuItem
+            onClick={async () => {
+              const realKey = await resolveRealKey(apiKey.id)
+              if (!realKey) return
+              const connStr = encodeConnectionString(
+                realKey,
+                getServerAddress()
+              )
+              const ok = await copyToClipboard(connStr)
+              if (ok) toast.success(t('Copied'))
+            }}
+          >
+            {t('Copy Connection Info')}
+            <DropdownMenuShortcut>
+              <Link size={16} />
+            </DropdownMenuShortcut>
+          </DropdownMenuItem>
+          <DropdownMenuSeparator />
+          <DropdownMenuItem
+            onClick={() => {
+              setCurrentRow(apiKey)
+              setOpen('update')
+            }}
+          >
+            {t('Edit')}
+            <DropdownMenuShortcut>
+              <Edit size={16} />
+            </DropdownMenuShortcut>
+          </DropdownMenuItem>
+          <DropdownMenuItem
+            onClick={async () => {
+              const realKey = await resolveRealKey(apiKey.id)
+              if (!realKey) return
+              setResolvedKey(realKey)
+              setCurrentRow(apiKey)
+              setOpen('cc-switch')
+            }}
+          >
+            {t('CC Switch')}
+            <DropdownMenuShortcut>
+              <ArrowRightLeft size={16} />
+            </DropdownMenuShortcut>
+          </DropdownMenuItem>
+          {hasChatPresets && (
+            <DropdownMenuSub>
+              <DropdownMenuSubTrigger>{t('Chat')}</DropdownMenuSubTrigger>
+              <DropdownMenuSubContent>
+                {chatPresets.map((preset) => (
+                  <DropdownMenuItem
+                    key={preset.id}
+                    onClick={() => handleOpenChatPreset(preset)}
+                  >
+                    {preset.name}
+                    {preset.type !== 'web' && (
+                      <DropdownMenuShortcut>
+                        <ExternalLink size={16} />
+                      </DropdownMenuShortcut>
+                    )}
+                  </DropdownMenuItem>
+                ))}
+              </DropdownMenuSubContent>
+            </DropdownMenuSub>
           )}
-        </DropdownMenuItem>
-        <DropdownMenuItem
-          onClick={async () => {
-            const realKey = await resolveRealKey(apiKey.id)
-            if (!realKey) return
-            setResolvedKey(realKey)
-            setCurrentRow(apiKey)
-            setOpen('cc-switch')
-          }}
-        >
-          {t('CC Switch')}
-          <DropdownMenuShortcut>
-            <ArrowRightLeft size={16} />
-          </DropdownMenuShortcut>
-        </DropdownMenuItem>
-        {hasChatPresets && (
-          <DropdownMenuSub>
-            <DropdownMenuSubTrigger>{t('Chat')}</DropdownMenuSubTrigger>
-            <DropdownMenuSubContent>
-              {chatPresets.map((preset) => (
-                <DropdownMenuItem
-                  key={preset.id}
-                  onClick={() => handleOpenChatPreset(preset)}
-                >
-                  {preset.name}
-                  {preset.type !== 'web' && (
-                    <DropdownMenuShortcut>
-                      <ExternalLink size={16} />
-                    </DropdownMenuShortcut>
-                  )}
-                </DropdownMenuItem>
-              ))}
-            </DropdownMenuSubContent>
-          </DropdownMenuSub>
-        )}
-        <DropdownMenuSeparator />
-        <DropdownMenuItem
-          onClick={() => {
-            setCurrentRow(apiKey)
-            setOpen('delete')
-          }}
-          className='text-destructive focus:text-destructive'
-        >
-          {t('Delete')}
-          <DropdownMenuShortcut>
-            <Trash2 size={16} />
-          </DropdownMenuShortcut>
-        </DropdownMenuItem>
-      </DropdownMenuContent>
-    </DropdownMenu>
+          <DropdownMenuSeparator />
+          <DropdownMenuItem
+            onClick={() => {
+              setCurrentRow(apiKey)
+              setOpen('delete')
+            }}
+            className='text-destructive focus:text-destructive'
+          >
+            {t('Delete')}
+            <DropdownMenuShortcut>
+              <Trash2 size={16} />
+            </DropdownMenuShortcut>
+          </DropdownMenuItem>
+        </DropdownMenuContent>
+      </DropdownMenu>
+    </div>
   )
 }

+ 3 - 2
web/default/src/features/models/components/models-columns.tsx

@@ -10,6 +10,7 @@ import {
   TooltipTrigger,
 } from '@/components/ui/tooltip'
 import { DataTableColumnHeader } from '@/components/data-table/column-header'
+import { GroupBadge } from '@/components/group-badge'
 import { StatusBadge } from '@/components/status-badge'
 import {
   getModelStatusConfig,
@@ -443,8 +444,8 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
           return <span className='text-muted-foreground text-xs'>-</span>
         }
 
-        const groupBadges = groups.map((g, idx) => (
-          <StatusBadge key={idx} label={g} autoColor={g} size='sm' />
+        const groupBadges = groups.map((g) => (
+          <GroupBadge key={g} group={g} size='sm' />
         ))
 
         return (

+ 5 - 4
web/default/src/features/pricing/components/model-details.tsx

@@ -14,6 +14,7 @@ import {
   TableRow,
 } from '@/components/ui/table'
 import { CopyButton } from '@/components/copy-button'
+import { GroupBadge } from '@/components/group-badge'
 import { PublicLayout } from '@/components/layout'
 import { DEFAULT_TOKEN_UNIT, QUOTA_TYPE_VALUES } from '../constants'
 import { usePricingData } from '../hooks/use-pricing-data'
@@ -275,9 +276,7 @@ function AutoGroupChain(props: { model: PricingModel; autoGroups: string[] }) {
       <span className='text-muted-foreground/40'>→</span>
       {autoChain.map((g, idx) => (
         <span key={g} className='flex items-center gap-1'>
-          <span className='bg-muted text-foreground rounded px-1.5 py-0.5 text-[11px] font-medium'>
-            {g}
-          </span>
+          <GroupBadge group={g} size='sm' />
           {idx < autoChain.length - 1 && (
             <span className='text-muted-foreground/40'>→</span>
           )}
@@ -388,7 +387,9 @@ function GroupPricingSection(props: {
               const ratio = groupRatio[group] || 1
               return (
                 <TableRow key={group}>
-                  <TableCell className='py-2.5 font-medium'>{group}</TableCell>
+                  <TableCell className='py-2.5'>
+                    <GroupBadge group={group} size='sm' />
+                  </TableCell>
                   <TableCell className='text-muted-foreground py-2.5 font-mono text-xs'>
                     {ratio}x
                   </TableCell>

+ 29 - 2
web/default/src/features/pricing/components/pricing-columns.tsx

@@ -8,6 +8,7 @@ import {
   TooltipTrigger,
 } from '@/components/ui/tooltip'
 import { DataTableColumnHeader } from '@/components/data-table/column-header'
+import { GroupBadge } from '@/components/group-badge'
 import { DEFAULT_TOKEN_UNIT, QUOTA_TYPE_VALUES } from '../constants'
 import { parseTags } from '../lib/filters'
 import { isTokenBasedModel } from '../lib/model-helpers'
@@ -49,6 +50,28 @@ function renderLimitedTags(
   )
 }
 
+function renderLimitedGroupBadges(
+  groups: string[],
+  maxDisplay: number = 2
+): React.ReactNode {
+  if (groups.length === 0)
+    return <span className='text-muted-foreground/50 text-xs'>—</span>
+
+  const displayed = groups.slice(0, maxDisplay)
+  const remaining = groups.length - maxDisplay
+
+  return (
+    <div className='flex max-w-full items-center gap-1 overflow-hidden'>
+      {displayed.map((group) => (
+        <GroupBadge key={group} group={group} size='sm' />
+      ))}
+      {remaining > 0 && (
+        <span className='text-muted-foreground/50 text-xs'>+{remaining}</span>
+      )}
+    </div>
+  )
+}
+
 export function usePricingColumns(
   options: PricingColumnsOptions = {}
 ): ColumnDef<PricingModel>[] {
@@ -312,11 +335,15 @@ export function usePricingColumns(
           <TooltipProvider>
             <Tooltip>
               <TooltipTrigger asChild>
-                <div>{renderLimitedTags(groups, 2)}</div>
+                <div>{renderLimitedGroupBadges(groups, 2)}</div>
               </TooltipTrigger>
               {groups.length > 2 && (
                 <TooltipContent side='top' className='max-w-[280px] p-2'>
-                  <span className='text-xs'>{groups.join(', ')}</span>
+                  <div className='flex flex-wrap gap-1'>
+                    {groups.map((group) => (
+                      <GroupBadge key={group} group={group} size='sm' />
+                    ))}
+                  </div>
                 </TooltipContent>
               )}
             </Tooltip>

+ 19 - 7
web/default/src/features/redemption-codes/components/redemptions-table.tsx

@@ -26,6 +26,8 @@ import {
   TableRow,
 } from '@/components/ui/table'
 import {
+  DISABLED_ROW_DESKTOP,
+  DISABLED_ROW_MOBILE,
   DataTablePagination,
   DataTableToolbar,
   TableSkeleton,
@@ -36,12 +38,20 @@ import { PageFooterPortal } from '@/components/layout'
 import { getRedemptions, searchRedemptions } from '../api'
 import { REDEMPTION_STATUS, getRedemptionStatusOptions } from '../constants'
 import { isRedemptionExpired } from '../lib'
+import type { Redemption } from '../types'
 import { DataTableBulkActions } from './data-table-bulk-actions'
 import { useRedemptionsColumns } from './redemptions-columns'
 import { useRedemptions } from './redemptions-provider'
 
 const route = getRouteApi('/_authenticated/redemption-codes/')
 
+function isDisabledRedemptionRow(redemption: Redemption) {
+  return (
+    redemption.status !== REDEMPTION_STATUS.ENABLED ||
+    isRedemptionExpired(redemption.expired_time, redemption.status)
+  )
+}
+
 export function RedemptionsTable() {
   const { t } = useTranslation()
   const columns = useRedemptionsColumns()
@@ -164,6 +174,11 @@ export function RedemptionsTable() {
             emptyDescription={t(
               'No redemption codes available. Create your first redemption code to get started.'
             )}
+            getRowClassName={(row) =>
+              isDisabledRedemptionRow(row.original)
+                ? DISABLED_ROW_MOBILE
+                : undefined
+            }
           />
         ) : (
           <>
@@ -209,18 +224,15 @@ export function RedemptionsTable() {
                   ) : (
                     table.getRowModel().rows.map((row) => {
                       const redemption = row.original
-                      const isDisabled =
-                        redemption.status !== REDEMPTION_STATUS.ENABLED ||
-                        isRedemptionExpired(
-                          redemption.expired_time,
-                          redemption.status
-                        )
 
                       return (
                         <TableRow
                           key={row.id}
                           data-state={row.getIsSelected() && 'selected'}
-                          className={isDisabled ? 'opacity-50' : undefined}
+                          className={cn(
+                            isDisabledRedemptionRow(redemption) &&
+                              DISABLED_ROW_DESKTOP
+                          )}
                         >
                           {row.getVisibleCells().map((cell) => (
                             <TableCell key={cell.id}>

+ 3 - 2
web/default/src/features/subscriptions/components/dialogs/subscription-purchase-dialog.tsx

@@ -17,6 +17,7 @@ import {
   SelectTrigger,
   SelectValue,
 } from '@/components/ui/select'
+import { GroupBadge } from '@/components/group-badge'
 import { Separator } from '@/components/ui/separator'
 import {
   paySubscriptionStripe,
@@ -209,11 +210,11 @@ export function SubscriptionPurchaseDialog(props: Props) {
               </span>
             </div>
             {plan.upgrade_group && (
-              <div className='flex justify-between'>
+              <div className='flex items-center justify-between'>
                 <span className='text-muted-foreground text-sm'>
                   {t('Upgrade Group')}
                 </span>
-                <span className='text-sm'>{plan.upgrade_group}</span>
+                <GroupBadge group={plan.upgrade_group} />
               </div>
             )}
             <Separator />

+ 5 - 5
web/default/src/features/subscriptions/components/subscriptions-columns.tsx

@@ -2,6 +2,7 @@ import { useMemo } from 'react'
 import { type ColumnDef } from '@tanstack/react-table'
 import { useTranslation } from 'react-i18next'
 import { DataTableColumnHeader } from '@/components/data-table'
+import { GroupBadge } from '@/components/group-badge'
 import { StatusBadge } from '@/components/status-badge'
 import { formatDuration, formatResetPeriod } from '../lib'
 import type { PlanRecord } from '../types'
@@ -172,11 +173,10 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] {
         ),
         cell: ({ row }) => {
           const group = row.original.plan.upgrade_group
-          return (
-            <span className='text-muted-foreground'>
-              {group || t('No Upgrade')}
-            </span>
-          )
+          if (!group) {
+            return <span className='text-muted-foreground'>{t('No Upgrade')}</span>
+          }
+          return <GroupBadge group={group} />
         },
         size: 100,
       },

+ 1 - 1
web/default/src/features/usage-logs/components/dialogs/usage-logs-filter-dialog.tsx

@@ -246,7 +246,7 @@ export function UsageLogsFilterDialog({
           <FilterInput
             id='mjId'
             label={t('Task ID')}
-            placeholder={t('Filter by Midjourney task ID')}
+            placeholder={t('Filter by task ID')}
             value={drawingFilters.mjId || ''}
             onChange={(value) => handleChange('mjId', value)}
           />

+ 7 - 6
web/default/src/features/usage-logs/components/usage-logs-table.tsx

@@ -27,7 +27,6 @@ import {
 } from '@/components/ui/table'
 import {
   DataTablePagination,
-  DataTableToolbar,
   DataTableViewOptions,
   TableSkeleton,
   TableEmpty,
@@ -40,6 +39,7 @@ import { fetchLogsByCategory } from '../lib/utils'
 import type { LogCategory } from '../types'
 import { CommonLogsFilterBar } from './common-logs-filter-bar'
 import { CommonLogsStats } from './common-logs-stats'
+import { TaskLogsFilterBar } from './task-logs-filter-bar'
 
 const route = getRouteApi('/_authenticated/usage-logs/$section')
 
@@ -194,11 +194,12 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
             />
           </div>
         ) : (
-          <DataTableToolbar
-            table={table}
-            filters={[]}
-            customSearch={null}
-          />
+          <div className='rounded-md border bg-card/50 p-3 shadow-xs'>
+            <TaskLogsFilterBar
+              logCategory={logCategory}
+              viewOptions={<DataTableViewOptions table={table} />}
+            />
+          </div>
         )}
         {isMobile ? (
           <MobileCardList

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

@@ -7,7 +7,6 @@ import { SectionPageLayout } from '@/components/layout'
 import type { NavGroup } from '@/components/layout/types'
 import { CacheStatsDialog } from '@/features/system-settings/general/channel-affinity/cache-stats-dialog'
 import { UserInfoDialog } from './components/dialogs/user-info-dialog'
-import { UsageLogsPrimaryButtons } from './components/usage-logs-primary-buttons'
 import {
   UsageLogsProvider,
   useUsageLogsContext,
@@ -105,11 +104,6 @@ function UsageLogsContent() {
         <SectionPageLayout.Description>
           {t(pageMeta.descriptionKey)}
         </SectionPageLayout.Description>
-        <SectionPageLayout.Actions>
-          {activeCategory !== 'common' && (
-            <UsageLogsPrimaryButtons logCategory={activeCategory} />
-          )}
-        </SectionPageLayout.Actions>
         <SectionPageLayout.Content>
           <div className='space-y-4'>
             {showTaskSwitcher && (

+ 40 - 41
web/default/src/features/users/components/users-columns.tsx

@@ -10,17 +10,23 @@ import {
   TooltipTrigger,
 } from '@/components/ui/tooltip'
 import { DataTableColumnHeader } from '@/components/data-table'
+import { GroupBadge } from '@/components/group-badge'
 import { LongText } from '@/components/long-text'
 import { StatusBadge, dotColorMap } from '@/components/status-badge'
 import {
   USER_STATUSES,
   USER_ROLES,
-  DEFAULT_GROUP,
   isUserDeleted,
 } from '../constants'
 import { type User } from '../types'
 import { DataTableRowActions } from './data-table-row-actions'
 
+function getQuotaProgressColor(percentage: number): string {
+  if (percentage <= 10) return '[&_[data-slot=progress-indicator]]:bg-rose-500'
+  if (percentage <= 30) return '[&_[data-slot=progress-indicator]]:bg-amber-500'
+  return '[&_[data-slot=progress-indicator]]:bg-emerald-500'
+}
+
 export function useUsersColumns(): ColumnDef<User>[] {
   const { t } = useTranslation()
   return [
@@ -66,24 +72,32 @@ export function useUsersColumns(): ColumnDef<User>[] {
       ),
       cell: ({ row }) => {
         const username = row.getValue('username') as string
+        const displayName = row.original.display_name
         const remark = row.original.remark
 
         return (
-          <div className='flex items-center gap-2'>
-            <LongText className='max-w-[120px] font-medium'>
-              {username}
-            </LongText>
-            {remark && (
-              <Tooltip>
-                <TooltipTrigger asChild>
-                  <StatusBadge variant='success' copyable={false}>
-                    <LongText className='max-w-[80px]'>{remark}</LongText>
-                  </StatusBadge>
-                </TooltipTrigger>
-                <TooltipContent>
-                  <p className='text-xs'>{remark}</p>
-                </TooltipContent>
-              </Tooltip>
+          <div className='flex min-w-[160px] flex-col gap-1'>
+            <div className='flex items-center gap-2'>
+              <LongText className='max-w-[140px] font-medium'>
+                {username}
+              </LongText>
+              {remark && (
+                <Tooltip>
+                  <TooltipTrigger asChild>
+                    <StatusBadge variant='success' copyable={false}>
+                      <LongText className='max-w-[80px]'>{remark}</LongText>
+                    </StatusBadge>
+                  </TooltipTrigger>
+                  <TooltipContent>
+                    <p className='text-xs'>{remark}</p>
+                  </TooltipContent>
+                </Tooltip>
+              )}
+            </div>
+            {displayName && displayName !== username && (
+              <LongText className='text-muted-foreground max-w-[180px] text-xs'>
+                {displayName}
+              </LongText>
             )}
           </div>
         )
@@ -91,20 +105,6 @@ export function useUsersColumns(): ColumnDef<User>[] {
       enableHiding: false,
       meta: { label: t('Username'), mobileTitle: true },
     },
-    {
-      accessorKey: 'display_name',
-      header: ({ column }) => (
-        <DataTableColumnHeader column={column} title={t('Display Name')} />
-      ),
-      cell: ({ row }) => {
-        return (
-          <LongText className='max-w-[150px]'>
-            {row.getValue('display_name') || '-'}
-          </LongText>
-        )
-      },
-      meta: { label: t('Display Name'), mobileHidden: true },
-    },
     {
       accessorKey: 'status',
       header: ({ column }) => (
@@ -176,12 +176,17 @@ export function useUsersColumns(): ColumnDef<User>[] {
             <TooltipTrigger asChild>
               <div className='w-[150px] cursor-help space-y-1'>
                 <div className='flex justify-between text-xs'>
-                  <span>{formatQuota(remaining)}</span>
-                  <span className='text-muted-foreground'>
+                  <span className='font-medium tabular-nums'>
+                    {formatQuota(remaining)}
+                  </span>
+                  <span className='text-muted-foreground tabular-nums'>
                     {formatQuota(total)}
                   </span>
                 </div>
-                <Progress value={percentage} className='h-2' />
+                <Progress
+                  value={percentage}
+                  className={cn('h-1.5', getQuotaProgressColor(percentage))}
+                />
               </div>
             </TooltipTrigger>
             <TooltipContent>
@@ -212,16 +217,10 @@ export function useUsersColumns(): ColumnDef<User>[] {
       ),
       cell: ({ row }) => {
         const group = row.getValue('group') as string
-        return (
-          <StatusBadge
-            label={group || DEFAULT_GROUP}
-            variant='neutral'
-            copyable={false}
-          />
-        )
+        return <GroupBadge group={group} />
       },
       filterFn: (row, id, value) => {
-        const group = String(row.getValue(id) || DEFAULT_GROUP).toLowerCase()
+        const group = String(row.getValue(id) || t('User Group')).toLowerCase()
         const searchValue = String(value).toLowerCase()
         return group.includes(searchValue)
       },

+ 13 - 5
web/default/src/features/users/components/users-table.tsx

@@ -27,6 +27,8 @@ import {
   TableRow,
 } from '@/components/ui/table'
 import {
+  DISABLED_ROW_DESKTOP,
+  DISABLED_ROW_MOBILE,
   DataTablePagination,
   DataTableToolbar,
   TableSkeleton,
@@ -41,12 +43,17 @@ import {
   getUserRoleOptions,
   isUserDeleted,
 } from '../constants'
+import type { User } from '../types'
 import { DataTableBulkActions } from './data-table-bulk-actions'
 import { useUsersColumns } from './users-columns'
 import { useUsers } from './users-provider'
 
 const route = getRouteApi('/_authenticated/users/')
 
+function isDisabledUserRow(user: User) {
+  return isUserDeleted(user) || user.status === USER_STATUS.DISABLED
+}
+
 export function UsersTable() {
   const { t } = useTranslation()
   const columns = useUsersColumns()
@@ -186,6 +193,9 @@ export function UsersTable() {
             emptyDescription={t(
               'No users available. Try adjusting your search or filters.'
             )}
+            getRowClassName={(row) =>
+              isDisabledUserRow(row.original) ? DISABLED_ROW_MOBILE : undefined
+            }
           />
         ) : (
           <>
@@ -226,16 +236,14 @@ export function UsersTable() {
                   ) : (
                     table.getRowModel().rows.map((row) => {
                       const user = row.original
-                      const isDeleted = isUserDeleted(user)
-                      const isDisabled = user.status === USER_STATUS.DISABLED
 
                       return (
                         <TableRow
                           key={row.id}
                           data-state={row.getIsSelected() && 'selected'}
-                          className={
-                            isDeleted || isDisabled ? 'opacity-50' : undefined
-                          }
+                          className={cn(
+                            isDisabledUserRow(user) && DISABLED_ROW_DESKTOP
+                          )}
                         >
                           {row.getVisibleCells().map((cell) => (
                             <TableCell key={cell.id}>

+ 6 - 0
web/default/src/hooks/use-sidebar-data.ts

@@ -80,6 +80,12 @@ export function useSidebarData(): SidebarData {
             configUrls: ['/usage-logs/drawing', '/usage-logs/task'],
             icon: ListTodo,
           },
+        ],
+      },
+      {
+        id: 'personal',
+        title: t('Personal'),
+        items: [
           {
             title: t('Wallet'),
             url: '/wallet',

+ 13 - 2
web/default/src/i18n/locales/en.json

@@ -561,6 +561,7 @@
     "channel(s)? This action cannot be undone.": "channel(s)? This action cannot be undone.",
     "Channels": "Channels",
     "Channels deleted successfully": "Channels deleted successfully",
+    "Chart Preferences": "Chart Preferences",
     "Chart Settings": "Chart Settings",
     "Chat": "Chat",
     "Chat area": "Chat area",
@@ -600,6 +601,8 @@
     "Choose how to filter IP addresses": "Choose how to filter IP addresses",
     "Choose the bundle type and define the items inside it.": "Choose the bundle type and define the items inside it.",
     "Choose where to fetch upstream metadata.": "Choose where to fetch upstream metadata.",
+    "Choose the default charts, range, and time granularity for model analytics.": "Choose the default charts, range, and time granularity for model analytics.",
+    "Choose which charts are selected by default when opening model analytics.": "Choose which charts are selected by default when opening model analytics.",
     "Classic (Legacy Frontend)": "Classic (Legacy Frontend)",
     "Claude": "Claude",
     "Claude CLI Header Passthrough": "Claude CLI Header Passthrough",
@@ -929,6 +932,7 @@
     "Daily Check-in": "Daily Check-in",
     "Dark": "Dark",
     "Dashboard": "Dashboard",
+    "Dashboard Preferences": "Dashboard Preferences",
     "Dashboards, tokens, and usage analytics.": "Dashboards, tokens, and usage analytics.",
     "Data Dashboard": "Data Dashboard",
     "Data directory:": "Data directory:",
@@ -949,7 +953,10 @@
     "Default API Version *": "Default API Version *",
     "Default API version for this channel": "Default API version for this channel",
     "Default Collapse Sidebar": "Default Collapse Sidebar",
+    "Default consumption chart": "Default consumption chart",
     "Default Max Tokens": "Default Max Tokens",
+    "Default model call chart": "Default model call chart",
+    "Default range": "Default range",
     "Default Responses API version, if empty, will use the API version above": "Default Responses API version, if empty, will use the API version above",
     "Default system prompt for this channel": "Default system prompt for this channel",
     "Default time granularity": "Default time granularity",
@@ -1901,6 +1908,7 @@
     "Leave empty to use username": "Leave empty to use username",
     "Legacy Format (JSON Object)": "Legacy Format (JSON Object)",
     "Legacy format must be a JSON object": "Legacy format must be a JSON object",
+    "Legacy Format Template": "Legacy Format Template",
     "Less": "Less",
     "Less Than": "Less Than",
     "Less Than or Equal": "Less Than or Equal",
@@ -2524,6 +2532,7 @@
     "Permit Passkey registration on non-HTTPS origins (only recommended for development)": "Permit Passkey registration on non-HTTPS origins (only recommended for development)",
     "Perplexity": "Perplexity",
     "Persist your data file": "Persist your data file",
+    "Personal": "Personal",
     "Personal area": "Personal area",
     "Personal Center Area": "Personal Center Area",
     "Personal info settings": "Personal info settings",
@@ -2981,6 +2990,7 @@
     "Save drawing settings": "Save drawing settings",
     "Save Epay settings": "Save Epay settings",
     "Save failed": "Save failed",
+    "Save Preferences": "Save Preferences",
     "Save failed, please retry": "Save failed, please retry",
     "Save general settings": "Save general settings",
     "Save group ratios": "Save group ratios",
@@ -3064,6 +3074,8 @@
     "Select channel type": "Select channel type",
     "Select currency": "Select currency",
     "Select date": "Select date",
+    "Select default chart": "Select default chart",
+    "Select default range": "Select default range",
     "Select display mode": "Select display mode",
     "Select end time": "Select end time",
     "Select from presets or type custom identifier.": "Select from presets or type custom identifier.",
@@ -3863,7 +3875,6 @@
     "Your Turnstile site key": "Your Turnstile site key",
     "Zhipu": "Zhipu",
     "Zhipu V4": "Zhipu V4",
-    "Zoom": "Zoom",
-    "Legacy Format Template": "Legacy Format Template"
+    "Zoom": "Zoom"
   }
 }

+ 13 - 2
web/default/src/i18n/locales/fr.json

@@ -561,6 +561,7 @@
     "channel(s)? This action cannot be undone.": "canal(aux) ? Cette action ne peut pas être annulée.",
     "Channels": "Canaux",
     "Channels deleted successfully": "Canaux supprimés avec succès",
+    "Chart Preferences": "Préférences des graphiques",
     "Chart Settings": "Paramètres du graphique",
     "Chat": "Discuter",
     "Chat area": "Zone de chat",
@@ -600,6 +601,8 @@
     "Choose how to filter IP addresses": "Choisissez comment filtrer les adresses IP",
     "Choose the bundle type and define the items inside it.": "Choisissez le type de bundle et définissez les éléments qu'il contient.",
     "Choose where to fetch upstream metadata.": "Choisissez où récupérer les métadonnées amont.",
+    "Choose the default charts, range, and time granularity for model analytics.": "Choisissez les graphiques, la plage et la granularité temporelle par défaut pour l'analyse des modèles.",
+    "Choose which charts are selected by default when opening model analytics.": "Choisissez les graphiques sélectionnés par défaut à l'ouverture de l'analyse des modèles.",
     "Classic (Legacy Frontend)": "Classique (Ancien frontend)",
     "Claude": "Claude",
     "Claude CLI Header Passthrough": "Passthrough en-tête Claude CLI",
@@ -929,6 +932,7 @@
     "Daily Check-in": "Connexion quotidienne",
     "Dark": "Sombre",
     "Dashboard": "Tableau de bord",
+    "Dashboard Preferences": "Préférences du tableau de bord",
     "Dashboards, tokens, and usage analytics.": "Tableaux de bord, jetons et analyses d'utilisation.",
     "Data Dashboard": "Tableau de bord des données",
     "Data directory:": "Répertoire des données :",
@@ -949,7 +953,10 @@
     "Default API Version *": "Version API par défaut *",
     "Default API version for this channel": "Version API par défaut pour ce canal",
     "Default Collapse Sidebar": "Réduire la barre latérale par défaut",
+    "Default consumption chart": "Graphique de consommation par défaut",
     "Default Max Tokens": "Jetons max par défaut",
+    "Default model call chart": "Graphique d'appels de modèle par défaut",
+    "Default range": "Plage par défaut",
     "Default Responses API version, if empty, will use the API version above": "Version API des réponses par défaut, si vide, utilisera la version API ci-dessus",
     "Default system prompt for this channel": "Invite système par défaut pour ce canal",
     "Default time granularity": "Granularité temporelle par défaut",
@@ -1901,6 +1908,7 @@
     "Leave empty to use username": "Laissez vide pour utiliser le nom d'utilisateur",
     "Legacy Format (JSON Object)": "Ancien format (objet JSON)",
     "Legacy format must be a JSON object": "L'ancien format doit être un objet JSON",
+    "Legacy Format Template": "Modèle ancien format",
     "Less": "Moins",
     "Less Than": "Inférieur à",
     "Less Than or Equal": "Inférieur ou égal",
@@ -2524,6 +2532,7 @@
     "Permit Passkey registration on non-HTTPS origins (only recommended for development)": "Autoriser l'enregistrement de Passkey sur des origines non-HTTPS (recommandé uniquement pour le développement)",
     "Perplexity": "Perplexity",
     "Persist your data file": "Conserver votre fichier de données",
+    "Personal": "Personnel",
     "Personal area": "Espace personnel",
     "Personal Center Area": "Espace personnel",
     "Personal info settings": "Paramètres d'informations personnelles",
@@ -2981,6 +2990,7 @@
     "Save drawing settings": "Enregistrer les paramètres de dessin",
     "Save Epay settings": "Enregistrer les paramètres Epay",
     "Save failed": "Échec de l'enregistrement",
+    "Save Preferences": "Enregistrer les préférences",
     "Save failed, please retry": "Échec de l'enregistrement, veuillez réessayer",
     "Save general settings": "Enregistrer les paramètres généraux",
     "Save group ratios": "Enregistrer les ratios de groupes",
@@ -3064,6 +3074,8 @@
     "Select channel type": "Sélectionner le type de canal",
     "Select currency": "Sélectionner la devise",
     "Select date": "Sélectionner la date",
+    "Select default chart": "Sélectionner le graphique par défaut",
+    "Select default range": "Sélectionner la plage par défaut",
     "Select display mode": "Sélectionner le mode d'affichage",
     "Select end time": "Sélectionner l'heure de fin",
     "Select from presets or type custom identifier.": "Sélectionner parmi les préréglages ou saisir un identifiant personnalisé.",
@@ -3863,7 +3875,6 @@
     "Your Turnstile site key": "Votre clé de site Turnstile",
     "Zhipu": "Zhipu",
     "Zhipu V4": "Zhipu V4",
-    "Zoom": "Zoom",
-    "Legacy Format Template": "Modèle ancien format"
+    "Zoom": "Zoom"
   }
 }

+ 13 - 2
web/default/src/i18n/locales/ja.json

@@ -561,6 +561,7 @@
     "channel(s)? This action cannot be undone.": "チャネルを削除しますか?この操作は元に戻せません。",
     "Channels": "チャネル",
     "Channels deleted successfully": "チャンネルが正常に削除されました",
+    "Chart Preferences": "チャートの環境設定",
     "Chart Settings": "チャート設定",
     "Chat": "チャット",
     "Chat area": "チャットエリア",
@@ -600,6 +601,8 @@
     "Choose how to filter IP addresses": "IPアドレスをフィルタリングする方法を選択してください",
     "Choose the bundle type and define the items inside it.": "バンドルタイプを選択し、その中のアイテムを定義してください。",
     "Choose where to fetch upstream metadata.": "アップストリームのメタデータをどこからフェッチするかを選択してください。",
+    "Choose the default charts, range, and time granularity for model analytics.": "モデル分析のデフォルトチャート、範囲、時間粒度を選択します。",
+    "Choose which charts are selected by default when opening model analytics.": "モデル分析を開いたときにデフォルトで選択されるチャートを選択します。",
     "Classic (Legacy Frontend)": "クラシック(旧フロントエンド)",
     "Claude": "Claude",
     "Claude CLI Header Passthrough": "Claude CLI ヘッダーパススルー",
@@ -929,6 +932,7 @@
     "Daily Check-in": "毎日のチェックイン",
     "Dark": "ダーク",
     "Dashboard": "ダッシュボード",
+    "Dashboard Preferences": "ダッシュボードの環境設定",
     "Dashboards, tokens, and usage analytics.": "ダッシュボード、トークン、使用状況アナリティクス。",
     "Data Dashboard": "データダッシュボード",
     "Data directory:": "データディレクトリ:",
@@ -949,7 +953,10 @@
     "Default API Version *": "デフォルトのAPIバージョン *",
     "Default API version for this channel": "このチャンネルのデフォルトのAPIバージョン",
     "Default Collapse Sidebar": "デフォルトのサイドバー折りたたみ",
+    "Default consumption chart": "デフォルトの消費チャート",
     "Default Max Tokens": "デフォルトの最大トークン",
+    "Default model call chart": "デフォルトのモデル呼び出しチャート",
+    "Default range": "デフォルト範囲",
     "Default Responses API version, if empty, will use the API version above": "デフォルトの応答APIバージョン。空の場合、上記のAPIバージョンが使用されます",
     "Default system prompt for this channel": "このチャンネルのデフォルトのシステムプロンプト",
     "Default time granularity": "デフォルトの時間粒度",
@@ -1901,6 +1908,7 @@
     "Leave empty to use username": "ユーザー名を使用するには空のままにしてください",
     "Legacy Format (JSON Object)": "旧形式(JSONオブジェクト)",
     "Legacy format must be a JSON object": "旧形式はJSONオブジェクトである必要があります",
+    "Legacy Format Template": "旧フォーマットテンプレート",
     "Less": "少ない",
     "Less Than": "より小さい",
     "Less Than or Equal": "以下",
@@ -2524,6 +2532,7 @@
     "Permit Passkey registration on non-HTTPS origins (only recommended for development)": "非HTTPSオリジンでのパスキー登録を許可する(開発でのみ推奨)",
     "Perplexity": "Perplexity",
     "Persist your data file": "データファイルを永続化する",
+    "Personal": "個人",
     "Personal area": "個人エリア",
     "Personal Center Area": "パーソナルセンターエリア",
     "Personal info settings": "個人情報設定",
@@ -2981,6 +2990,7 @@
     "Save drawing settings": "描画設定を保存",
     "Save Epay settings": "Epay設定を保存",
     "Save failed": "保存に失敗しました",
+    "Save Preferences": "設定を保存",
     "Save failed, please retry": "保存に失敗しました。もう一度お試しください",
     "Save general settings": "一般設定を保存",
     "Save group ratios": "グループ比率を保存",
@@ -3064,6 +3074,8 @@
     "Select channel type": "チャネルタイプを選択",
     "Select currency": "通貨を選択",
     "Select date": "日付を選択",
+    "Select default chart": "デフォルトチャートを選択",
+    "Select default range": "デフォルト範囲を選択",
     "Select display mode": "表示モードを選択",
     "Select end time": "終了時間を選択",
     "Select from presets or type custom identifier.": "プリセットから選択するか、カスタム識別子を入力してください。",
@@ -3863,7 +3875,6 @@
     "Your Turnstile site key": "あなたのTurnstileサイトキー",
     "Zhipu": "Zhipu",
     "Zhipu V4": "Zhipu V 4",
-    "Zoom": "ズーム",
-    "Legacy Format Template": "旧フォーマットテンプレート"
+    "Zoom": "ズーム"
   }
 }

+ 13 - 2
web/default/src/i18n/locales/ru.json

@@ -561,6 +561,7 @@
     "channel(s)? This action cannot be undone.": "канал(ы)? Это действие нельзя отменить.",
     "Channels": "Каналы",
     "Channels deleted successfully": "Каналы успешно удалены",
+    "Chart Preferences": "Настройки графиков",
     "Chart Settings": "Настройки диаграммы",
     "Chat": "Чат",
     "Chat area": "Область чата",
@@ -600,6 +601,8 @@
     "Choose how to filter IP addresses": "Выберите, как фильтровать IP-адреса",
     "Choose the bundle type and define the items inside it.": "Выберите тип пакета и определите элементы внутри него.",
     "Choose where to fetch upstream metadata.": "Выберите, откуда получать метаданные вышестоящего источника.",
+    "Choose the default charts, range, and time granularity for model analytics.": "Выберите графики, диапазон и временную детализацию по умолчанию для аналитики моделей.",
+    "Choose which charts are selected by default when opening model analytics.": "Выберите графики, которые будут выбраны по умолчанию при открытии аналитики моделей.",
     "Classic (Legacy Frontend)": "Классический (Старый интерфейс)",
     "Claude": "Клод",
     "Claude CLI Header Passthrough": "Проброс заголовков Claude CLI",
@@ -929,6 +932,7 @@
     "Daily Check-in": "Ежедневный вход",
     "Dark": "Тёмная",
     "Dashboard": "Панель управления",
+    "Dashboard Preferences": "Настройки панели управления",
     "Dashboards, tokens, and usage analytics.": "Панели управления, токены и аналитика использования.",
     "Data Dashboard": "Панель мониторинга данных",
     "Data directory:": "Каталог данных:",
@@ -949,7 +953,10 @@
     "Default API Version *": "Версия API по умолчанию *",
     "Default API version for this channel": "Версия API по умолчанию для этого канала",
     "Default Collapse Sidebar": "Сворачивать боковую панель по умолчанию",
+    "Default consumption chart": "График потребления по умолчанию",
     "Default Max Tokens": "Максимальное количество токенов по умолчанию",
+    "Default model call chart": "График вызовов моделей по умолчанию",
+    "Default range": "Диапазон по умолчанию",
     "Default Responses API version, if empty, will use the API version above": "Версия API ответов по умолчанию; если пусто, будет использоваться версия API, указанная выше",
     "Default system prompt for this channel": "Системный промпт по умолчанию для этого канала",
     "Default time granularity": "Гранулярность времени по умолчанию",
@@ -1901,6 +1908,7 @@
     "Leave empty to use username": "Оставьте пустым, чтобы использовать имя пользователя",
     "Legacy Format (JSON Object)": "Старый формат (JSON-объект)",
     "Legacy format must be a JSON object": "Старый формат должен быть JSON-объектом",
+    "Legacy Format Template": "Шаблон старого формата",
     "Less": "Меньше",
     "Less Than": "Меньше",
     "Less Than or Equal": "Меньше или равно",
@@ -2524,6 +2532,7 @@
     "Permit Passkey registration on non-HTTPS origins (only recommended for development)": "Разрешить регистрацию Passkey на не-HTTPS источниках (рекомендуется только для разработки)",
     "Perplexity": "Perplexity",
     "Persist your data file": "Сохранить ваш файл данных",
+    "Personal": "Личное",
     "Personal area": "Личный кабинет",
     "Personal Center Area": "Область личного кабинета",
     "Personal info settings": "Настройки личной информации",
@@ -2981,6 +2990,7 @@
     "Save drawing settings": "Сохранить настройки рисования",
     "Save Epay settings": "Сохранить настройки Epay",
     "Save failed": "Не удалось сохранить",
+    "Save Preferences": "Сохранить настройки",
     "Save failed, please retry": "Не удалось сохранить, попробуйте снова",
     "Save general settings": "Сохранить общие настройки",
     "Save group ratios": "Сохранить коэффициенты групп",
@@ -3064,6 +3074,8 @@
     "Select channel type": "Выбрать тип канала",
     "Select currency": "Выберите валюту",
     "Select date": "Выберите дату",
+    "Select default chart": "Выберите график по умолчанию",
+    "Select default range": "Выберите диапазон по умолчанию",
     "Select display mode": "Выбрать режим отображения",
     "Select end time": "Выбрать время окончания",
     "Select from presets or type custom identifier.": "Выберите из предустановок или введите пользовательский идентификатор.",
@@ -3863,7 +3875,6 @@
     "Your Turnstile site key": "Ключ сайта Turnstile",
     "Zhipu": "Zhipu",
     "Zhipu V4": "Zhipu V4",
-    "Zoom": "Zoom",
-    "Legacy Format Template": "Шаблон старого формата"
+    "Zoom": "Zoom"
   }
 }

+ 13 - 2
web/default/src/i18n/locales/vi.json

@@ -561,6 +561,7 @@
     "channel(s)? This action cannot be undone.": "kênh(s)? Hành động này không thể hoàn tác.",
     "Channels": "Kênh",
     "Channels deleted successfully": "Xóa kênh thành công",
+    "Chart Preferences": "Tùy chọn biểu đồ",
     "Chart Settings": "Cài đặt Biểu đồ",
     "Chat": "Trò chuyện",
     "Chat area": "Khu vực trò chuyện",
@@ -600,6 +601,8 @@
     "Choose how to filter IP addresses": "Chọn cách lọc địa chỉ IP",
     "Choose the bundle type and define the items inside it.": "Chọn loại gói và định nghĩa các mục bên trong nó.",
     "Choose where to fetch upstream metadata.": "Chọn nơi để tìm nạp siêu dữ liệu thượng nguồn.",
+    "Choose the default charts, range, and time granularity for model analytics.": "Chọn biểu đồ, khoảng thời gian và độ chi tiết thời gian mặc định cho phân tích mô hình.",
+    "Choose which charts are selected by default when opening model analytics.": "Chọn biểu đồ được chọn mặc định khi mở phân tích mô hình.",
     "Classic (Legacy Frontend)": "Cổ điển (Frontend cũ)",
     "Claude": "Claude",
     "Claude CLI Header Passthrough": "Chuyển tiếp header Claude CLI",
@@ -929,6 +932,7 @@
     "Daily Check-in": "Điểm danh hàng ngày",
     "Dark": "Tối",
     "Dashboard": "Bảng điều khiển",
+    "Dashboard Preferences": "Tùy chọn bảng điều khiển",
     "Dashboards, tokens, and usage analytics.": "Bảng điều khiển, token và phân tích sử dụng.",
     "Data Dashboard": "Bảng dữ liệu",
     "Data directory:": "Thư mục dữ liệu:",
@@ -949,7 +953,10 @@
     "Default API Version *": "Phiên bản API mặc định *",
     "Default API version for this channel": "Phiên bản API mặc định cho kênh này",
     "Default Collapse Sidebar": "Mặc định Thu gọn Thanh bên",
+    "Default consumption chart": "Biểu đồ tiêu thụ mặc định",
     "Default Max Tokens": "Tokens Tối đa Mặc định",
+    "Default model call chart": "Biểu đồ lượt gọi mô hình mặc định",
+    "Default range": "Khoảng mặc định",
     "Default Responses API version, if empty, will use the API version above": "Phiên bản API phản hồi mặc định, nếu để trống, sẽ sử dụng phiên bản API ở trên",
     "Default system prompt for this channel": "Lời nhắc hệ thống mặc định cho kênh này",
     "Default time granularity": "Độ chi tiết thời gian mặc định",
@@ -1901,6 +1908,7 @@
     "Leave empty to use username": "Để trống để sử dụng tên người dùng",
     "Legacy Format (JSON Object)": "Định dạng cũ (đối tượng JSON)",
     "Legacy format must be a JSON object": "Định dạng cũ phải là đối tượng JSON",
+    "Legacy Format Template": "Mẫu định dạng cũ",
     "Less": "Ít hơn",
     "Less Than": "Nhỏ hơn",
     "Less Than or Equal": "Nhỏ hơn hoặc bằng",
@@ -2524,6 +2532,7 @@
     "Permit Passkey registration on non-HTTPS origins (only recommended for development)": "Cho phép đăng ký Passkey trên các nguồn gốc không phải HTTPS (chỉ khuyến nghị cho mục đích phát triển)",
     "Perplexity": "Sự bối rối",
     "Persist your data file": "Lưu trữ tệp dữ liệu của bạn",
+    "Personal": "Cá nhân",
     "Personal area": "Khu vực cá nhân",
     "Personal Center Area": "Khu vực cá nhân",
     "Personal info settings": "Cài đặt thông tin cá nhân",
@@ -2981,6 +2990,7 @@
     "Save drawing settings": "Lưu cài đặt bản vẽ",
     "Save Epay settings": "Lưu cài đặt Epay",
     "Save failed": "Lưu thất bại",
+    "Save Preferences": "Lưu tùy chọn",
     "Save failed, please retry": "Lưu thất bại, vui lòng thử lại",
     "Save general settings": "Lưu cài đặt chung",
     "Save group ratios": "Lưu tỷ lệ nhóm",
@@ -3064,6 +3074,8 @@
     "Select channel type": "Chọn loại kênh",
     "Select currency": "Chọn tiền tệ",
     "Select date": "Chọn ngày",
+    "Select default chart": "Chọn biểu đồ mặc định",
+    "Select default range": "Chọn khoảng mặc định",
     "Select display mode": "Chọn chế độ hiển thị",
     "Select end time": "Chọn thời gian kết thúc",
     "Select from presets or type custom identifier.": "Chọn từ các cài đặt sẵn hoặc nhập mã định danh tùy chỉnh.",
@@ -3863,7 +3875,6 @@
     "Your Turnstile site key": "Khóa site Turnstile của bạn",
     "Zhipu": "Zhipu",
     "Zhipu V4": "Zhipu V4",
-    "Zoom": "Zoom",
-    "Legacy Format Template": "Mẫu định dạng cũ"
+    "Zoom": "Zoom"
   }
 }

+ 13 - 2
web/default/src/i18n/locales/zh.json

@@ -561,6 +561,7 @@
     "channel(s)? This action cannot be undone.": "渠道?此操作无法撤销。",
     "Channels": "渠道",
     "Channels deleted successfully": "渠道删除成功",
+    "Chart Preferences": "图表偏好设置",
     "Chart Settings": "图表设置",
     "Chat": "聊天",
     "Chat area": "聊天区域",
@@ -600,6 +601,8 @@
     "Choose how to filter IP addresses": "选择如何过滤 IP 地址",
     "Choose the bundle type and define the items inside it.": "选择捆绑包类型并定义其中的项目。",
     "Choose where to fetch upstream metadata.": "选择从何处获取上游元数据。",
+    "Choose the default charts, range, and time granularity for model analytics.": "选择模型调用分析的默认图表、范围和时间粒度。",
+    "Choose which charts are selected by default when opening model analytics.": "选择打开模型调用分析时默认选中的图表。",
     "Classic (Legacy Frontend)": "经典前端",
     "Claude": "Claude",
     "Claude CLI Header Passthrough": "Claude CLI 请求头透传",
@@ -929,6 +932,7 @@
     "Daily Check-in": "每日签到",
     "Dark": "深色",
     "Dashboard": "数据看板",
+    "Dashboard Preferences": "看板偏好设置",
     "Dashboards, tokens, and usage analytics.": "仪表板、令牌和使用分析。",
     "Data Dashboard": "数据仪表板",
     "Data directory:": "数据目录:",
@@ -949,7 +953,10 @@
     "Default API Version *": "默认 API 版本 *",
     "Default API version for this channel": "此渠道的默认 API 版本",
     "Default Collapse Sidebar": "默认折叠侧边栏",
+    "Default consumption chart": "默认消耗分布图",
     "Default Max Tokens": "默认最大 Token 数",
+    "Default model call chart": "默认模型调用图",
+    "Default range": "默认范围",
     "Default Responses API version, if empty, will use the API version above": "默认响应 API 版本,如果为空,将使用上面的 API 版本",
     "Default system prompt for this channel": "此渠道的默认系统提示",
     "Default time granularity": "默认时间粒度",
@@ -1901,6 +1908,7 @@
     "Leave empty to use username": "留空以使用用户名",
     "Legacy Format (JSON Object)": "旧格式(JSON 对象)",
     "Legacy format must be a JSON object": "旧格式必须是 JSON 对象",
+    "Legacy Format Template": "旧格式模板",
     "Less": "更少",
     "Less Than": "小于",
     "Less Than or Equal": "小于等于",
@@ -2524,6 +2532,7 @@
     "Permit Passkey registration on non-HTTPS origins (only recommended for development)": "允许在非 HTTPS 源上注册通行密钥(仅建议用于开发)",
     "Perplexity": "Perplexity",
     "Persist your data file": "持久化您的数据文件",
+    "Personal": "个人",
     "Personal area": "个人中心",
     "Personal Center Area": "个人中心区域",
     "Personal info settings": "个人信息设置",
@@ -2981,6 +2990,7 @@
     "Save drawing settings": "保存绘图设置",
     "Save Epay settings": "保存 Epay 设置",
     "Save failed": "保存失败",
+    "Save Preferences": "保存偏好设置",
     "Save failed, please retry": "保存失败,请重试",
     "Save general settings": "保存通用设置",
     "Save group ratios": "保存分组比率",
@@ -3064,6 +3074,8 @@
     "Select channel type": "选择渠道类型",
     "Select currency": "选择货币",
     "Select date": "选择日期",
+    "Select default chart": "选择默认图表",
+    "Select default range": "选择默认范围",
     "Select display mode": "选择显示模式",
     "Select end time": "选择结束时间",
     "Select from presets or type custom identifier.": "从预设中选择或输入自定义标识符。",
@@ -3863,7 +3875,6 @@
     "Your Turnstile site key": "您的 Turnstile 站点密钥",
     "Zhipu": "智谱",
     "Zhipu V4": "智谱 V4",
-    "Zoom": "缩放",
-    "Legacy Format Template": "旧格式模板"
+    "Zoom": "缩放"
   }
 }