Просмотр исходного кода

feat(logs): add username to TaskLog interface and implement log avatar styling

CaIon 1 месяц назад
Родитель
Сommit
75af3db11f

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

@@ -110,7 +110,7 @@ export function ApiKeyGroupCombobox({
           role='combobox'
           aria-expanded={open}
           disabled={disabled}
-          className='h-auto min-h-10 w-full justify-between gap-3 px-3 py-2 text-start'
+          className='border-input bg-muted/40 h-auto min-h-20 w-full justify-between gap-3 rounded-lg px-4 py-3 text-start shadow-none transition-[background-color,border-color,box-shadow] duration-150 hover:bg-muted/55 hover:text-foreground active:bg-background data-[state=open]:border-ring data-[state=open]:bg-background data-[state=open]:ring-ring/20 data-[state=open]:ring-[3px]'
         >
           <span className='flex min-w-0 flex-1 items-center justify-between gap-3'>
             <span className='min-w-0'>
@@ -128,7 +128,7 @@ export function ApiKeyGroupCombobox({
           <ChevronsUpDown className='h-4 w-4 shrink-0 opacity-50' />
         </Button>
       </PopoverTrigger>
-      <PopoverContent className='w-[var(--radix-popover-trigger-width)] p-0'>
+      <PopoverContent className='data-[state=closed]:zoom-out-100 data-[state=open]:zoom-in-100 data-[side=bottom]:slide-in-from-top-0 data-[side=left]:slide-in-from-right-0 data-[side=right]:slide-in-from-left-0 data-[side=top]:slide-in-from-bottom-0 w-[var(--radix-popover-trigger-width)] overflow-hidden rounded-xl p-0 shadow-lg data-[state=closed]:duration-75 data-[state=open]:duration-100'>
         <Command shouldFilter={false}>
           <CommandInput
             placeholder={t('Search...')}
@@ -143,7 +143,7 @@ export function ApiKeyGroupCombobox({
                   key={option.value}
                   value={option.value}
                   onSelect={handleSelect}
-                  className='items-start gap-3 px-3 py-3'
+                  className='items-start gap-3 rounded-lg px-3 py-3 transition-colors data-[selected=true]:bg-muted'
                 >
                   <Check
                     className={cn(

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

@@ -31,6 +31,16 @@ 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)
@@ -242,15 +252,18 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
           )
         }
         return (
-          <span className='inline-flex items-center gap-1.5 text-xs'>
+          <span className='inline-flex items-center gap-2 text-xs'>
             <span className='font-medium'>{group || t('Default')}</span>
             {ratio != null && (
-              <>
-                <span className='text-muted-foreground/30'>·</span>
-                <span className='text-muted-foreground/60 font-mono tabular-nums'>
-                  {ratio}x
-                </span>
-              </>
+              <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>
         )

+ 285 - 212
web/default/src/features/keys/components/api-keys-mutate-drawer.tsx

@@ -1,12 +1,19 @@
-import { useEffect, useState } from 'react'
+import { useEffect, useState, type ReactNode } from 'react'
 import { useForm } from 'react-hook-form'
 import { zodResolver } from '@hookform/resolvers/zod'
 import { useQuery } from '@tanstack/react-query'
-import { ChevronDown } from 'lucide-react'
+import {
+  ChevronDown,
+  KeyRound,
+  Settings2,
+  WalletCards,
+  type LucideIcon,
+} from 'lucide-react'
 import { useTranslation } from 'react-i18next'
 import { toast } from 'sonner'
 import { getUserModels, getUserGroups } from '@/lib/api'
 import { getCurrencyDisplay, getCurrencyLabel } from '@/lib/currency'
+import { cn } from '@/lib/utils'
 import { Button } from '@/components/ui/button'
 import {
   Collapsible,
@@ -59,6 +66,34 @@ type ApiKeyMutateDrawerProps = {
   side?: 'left' | 'right'
 }
 
+type ApiKeyFormSectionProps = {
+  title: string
+  description: string
+  icon: LucideIcon
+  children: ReactNode
+}
+
+function ApiKeyFormSection(props: ApiKeyFormSectionProps) {
+  const Icon = props.icon
+
+  return (
+    <section className='bg-card rounded-lg border'>
+      <div className='flex items-center gap-3 border-b px-4 py-3'>
+        <div className='bg-muted text-muted-foreground flex size-10 shrink-0 items-center justify-center rounded-lg border'>
+          <Icon className='size-5' />
+        </div>
+        <div className='min-w-0'>
+          <h3 className='text-sm font-medium leading-none'>{props.title}</h3>
+          <p className='text-muted-foreground mt-1 text-xs'>
+            {props.description}
+          </p>
+        </div>
+      </div>
+      <div className='space-y-4 p-4'>{props.children}</div>
+    </section>
+  )
+}
+
 export function ApiKeysMutateDrawer({
   open,
   onOpenChange,
@@ -201,6 +236,8 @@ export function ApiKeysMutateDrawer({
   const quotaPlaceholder = tokensOnly
     ? t('Enter quota in tokens')
     : t('Enter quota in {{currency}}', { currency: currencyLabel })
+  const selectedGroup = form.watch('group')
+  const unlimitedQuota = form.watch('unlimited_quota')
 
   return (
     <Sheet
@@ -214,10 +251,10 @@ export function ApiKeysMutateDrawer({
     >
       <SheetContent
         side={side}
-        className='flex w-full flex-col sm:max-w-[600px]'
+        className='bg-background flex w-full gap-0 overflow-hidden p-0 sm:max-w-[620px]'
       >
-        <SheetHeader className='text-start'>
-          <SheetTitle>
+        <SheetHeader className='bg-background border-b px-5 py-4 text-start'>
+          <SheetTitle className='text-lg'>
             {isUpdate ? t('Update API Key') : t('Create API Key')}
           </SheetTitle>
           <SheetDescription>
@@ -231,278 +268,314 @@ export function ApiKeysMutateDrawer({
           <form
             id='api-key-form'
             onSubmit={form.handleSubmit(onSubmit)}
-            className='flex-1 space-y-6 overflow-y-auto px-4'
+            className='flex-1 space-y-4 overflow-y-auto px-4 py-4'
           >
-            <FormField
-              control={form.control}
-              name='name'
-              render={({ field }) => (
-                <FormItem>
-                  <FormLabel>{t('Name')}</FormLabel>
-                  <FormControl>
-                    <Input {...field} placeholder={t('Enter a name')} />
-                  </FormControl>
-                  <FormMessage />
-                </FormItem>
-              )}
-            />
-
-            <FormField
-              control={form.control}
-              name='group'
-              render={({ field }) => (
-                <FormItem>
-                  <FormLabel>{t('Group')}</FormLabel>
-                  <FormControl>
-                    <ApiKeyGroupCombobox
-                      options={groups}
-                      value={field.value}
-                      onValueChange={field.onChange}
-                      placeholder={t('Select a group')}
-                    />
-                  </FormControl>
-                  <FormDescription>
-                    {t('Auto group enables circuit breaker mechanism')}
-                  </FormDescription>
-                  <FormMessage />
-                </FormItem>
-              )}
-            />
-
-            {form.watch('group') === 'auto' && (
+            <ApiKeyFormSection
+              title={t('Basic Information')}
+              description={t('Set API key basic information')}
+              icon={KeyRound}
+            >
               <FormField
                 control={form.control}
-                name='cross_group_retry'
+                name='name'
                 render={({ field }) => (
-                  <FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
-                    <div className='space-y-0.5'>
-                      <FormLabel className='text-base'>
-                        {t('Cross-group retry')}
-                      </FormLabel>
-                      <FormDescription>
-                        {t(
-                          'When enabled, if channels in the current group fail, it will try channels in the next group in order.'
-                        )}
-                      </FormDescription>
-                    </div>
+                  <FormItem>
+                    <FormLabel>{t('Name')}</FormLabel>
                     <FormControl>
-                      <Switch
-                        checked={!!field.value}
-                        onCheckedChange={field.onChange}
+                      <Input
+                        {...field}
+                        placeholder={t('Enter a name')}
                       />
                     </FormControl>
+                    <FormMessage />
                   </FormItem>
                 )}
               />
-            )}
-
-            <FormField
-              control={form.control}
-              name='unlimited_quota'
-              render={({ field }) => (
-                <FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
-                  <div className='space-y-0.5'>
-                    <FormLabel className='text-base'>
-                      {t('Unlimited Quota')}
-                    </FormLabel>
-                    <FormDescription>
-                      {t('Enable unlimited quota for this API key')}
-                    </FormDescription>
-                  </div>
-                  <FormControl>
-                    <Switch
-                      checked={field.value}
-                      onCheckedChange={field.onChange}
-                    />
-                  </FormControl>
-                </FormItem>
-              )}
-            />
 
-            {!form.watch('unlimited_quota') && (
               <FormField
                 control={form.control}
-                name='remain_quota_dollars'
+                name='group'
                 render={({ field }) => (
                   <FormItem>
-                    <FormLabel>{quotaLabel}</FormLabel>
+                    <FormLabel>{t('Group')}</FormLabel>
                     <FormControl>
-                      <Input
-                        {...field}
-                        type='number'
-                        step={tokensOnly ? 1 : 0.01}
-                        placeholder={quotaPlaceholder}
-                        onChange={(e) =>
-                          field.onChange(parseFloat(e.target.value) || 0)
-                        }
+                      <ApiKeyGroupCombobox
+                        options={groups}
+                        value={field.value}
+                        onValueChange={field.onChange}
+                        placeholder={t('Select a group')}
                       />
                     </FormControl>
-                    <FormDescription>
-                      {tokensOnly
-                        ? t('Enter the quota amount in tokens')
-                        : t('Enter the quota amount in {{currency}}', {
-                            currency: currencyLabel,
-                          })}
-                    </FormDescription>
                     <FormMessage />
                   </FormItem>
                 )}
               />
-            )}
 
-            <FormField
-              control={form.control}
-              name='expired_time'
-              render={({ field }) => (
-                <FormItem>
-                  <FormLabel>{t('Expiration Time')}</FormLabel>
-                  <div className='space-y-2'>
-                    <FormControl>
-                      <DateTimePicker
-                        value={field.value}
-                        onChange={field.onChange}
-                        placeholder={t('Never expires')}
-                      />
-                    </FormControl>
-                    <div className='flex gap-2'>
-                      <Button
-                        type='button'
-                        variant='outline'
-                        size='sm'
-                        onClick={() => handleSetExpiry(0, 0, 0)}
-                      >
-                        {t('Never')}
-                      </Button>
-                      <Button
-                        type='button'
-                        variant='outline'
-                        size='sm'
-                        onClick={() => handleSetExpiry(1, 0, 0)}
-                      >
-                        {t('1 Month')}
-                      </Button>
-                      <Button
-                        type='button'
-                        variant='outline'
-                        size='sm'
-                        onClick={() => handleSetExpiry(0, 1, 0)}
-                      >
-                        {t('1 Day')}
-                      </Button>
-                      <Button
-                        type='button'
-                        variant='outline'
-                        size='sm'
-                        onClick={() => handleSetExpiry(0, 0, 1)}
-                      >
-                        {t('1 Hour')}
-                      </Button>
-                    </div>
-                  </div>
-                  <FormDescription>
-                    {t('Leave empty for never expires')}
-                  </FormDescription>
-                  <FormMessage />
-                </FormItem>
+              {selectedGroup === 'auto' && (
+                <FormField
+                  control={form.control}
+                  name='cross_group_retry'
+                  render={({ field }) => (
+                    <FormItem className='flex min-h-20 flex-row items-center justify-between gap-4 rounded-lg border px-4 py-3'>
+                      <div className='space-y-0.5'>
+                        <FormLabel className='text-sm'>
+                          {t('Cross-group retry')}
+                        </FormLabel>
+                        <FormDescription className='text-xs'>
+                          {t(
+                            'When enabled, if channels in the current group fail, it will try channels in the next group in order.'
+                          )}
+                        </FormDescription>
+                      </div>
+                      <FormControl>
+                        <Switch
+                          checked={!!field.value}
+                          onCheckedChange={field.onChange}
+                        />
+                      </FormControl>
+                    </FormItem>
+                  )}
+                />
               )}
-            />
 
-            {!isUpdate && (
               <FormField
                 control={form.control}
-                name='tokenCount'
+                name='expired_time'
                 render={({ field }) => (
                   <FormItem>
-                    <FormLabel>{t('Quantity')}</FormLabel>
-                    <FormControl>
-                      <Input
-                        {...field}
-                        type='number'
-                        min='1'
-                        placeholder={t('Number of keys to create')}
-                        onChange={(e) =>
-                          field.onChange(parseInt(e.target.value, 10) || 1)
-                        }
-                      />
-                    </FormControl>
-                    <FormDescription>
-                      {t(
-                        'Create multiple API keys at once (random suffix will be added to names)'
-                      )}
-                    </FormDescription>
+                    <FormLabel>{t('Expiration Time')}</FormLabel>
+                    <div className='grid gap-2 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center'>
+                      <FormControl>
+                        <DateTimePicker
+                          value={field.value}
+                          onChange={field.onChange}
+                          placeholder={t('Never expires')}
+                          className='min-w-0'
+                        />
+                      </FormControl>
+                      <div className='grid grid-cols-4 gap-2 sm:flex'>
+                        <Button
+                          type='button'
+                          variant='outline'
+                          size='sm'
+                          className='px-3'
+                          onClick={() => handleSetExpiry(0, 0, 0)}
+                        >
+                          {t('Never')}
+                        </Button>
+                        <Button
+                          type='button'
+                          variant='outline'
+                          size='sm'
+                          className='px-3'
+                          onClick={() => handleSetExpiry(1, 0, 0)}
+                        >
+                          {t('1 Month')}
+                        </Button>
+                        <Button
+                          type='button'
+                          variant='outline'
+                          size='sm'
+                          className='px-3'
+                          onClick={() => handleSetExpiry(0, 1, 0)}
+                        >
+                          {t('1 Day')}
+                        </Button>
+                        <Button
+                          type='button'
+                          variant='outline'
+                          size='sm'
+                          className='px-3'
+                          onClick={() => handleSetExpiry(0, 0, 1)}
+                        >
+                          {t('1 Hour')}
+                        </Button>
+                      </div>
+                    </div>
                     <FormMessage />
                   </FormItem>
                 )}
               />
-            )}
 
-            <Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
-              <CollapsibleTrigger asChild>
-                <Button
-                  type='button'
-                  variant='outline'
-                  className='flex w-full items-center justify-between'
-                >
-                  <span className='font-medium'>{t('Advanced Options')}</span>
-                  <ChevronDown
-                    className={`h-4 w-4 transition-transform duration-200 ${
-                      advancedOpen ? 'rotate-180' : ''
-                    }`}
-                  />
-                </Button>
-              </CollapsibleTrigger>
-              <CollapsibleContent className='space-y-6 pt-6'>
+              {!isUpdate && (
                 <FormField
                   control={form.control}
-                  name='model_limits'
+                  name='tokenCount'
                   render={({ field }) => (
                     <FormItem>
-                      <FormLabel>{t('Model Limits')}</FormLabel>
+                      <FormLabel>{t('Quantity')}</FormLabel>
                       <FormControl>
-                        <MultiSelect
-                          options={models.map((m) => ({ label: m, value: m }))}
-                          selected={field.value}
-                          onChange={field.onChange}
-                          placeholder={t('Select models (empty for allow all)')}
+                        <Input
+                          {...field}
+                          type='number'
+                          min='1'
+                          placeholder={t('Number of keys to create')}
+                          onChange={(e) =>
+                            field.onChange(parseInt(e.target.value, 10) || 1)
+                          }
                         />
                       </FormControl>
                       <FormDescription>
-                        {t('Limit which models can be used with this key')}
+                        {t(
+                          'Create multiple API keys at once (random suffix will be added to names)'
+                        )}
                       </FormDescription>
                       <FormMessage />
                     </FormItem>
                   )}
                 />
+              )}
+            </ApiKeyFormSection>
 
+            <ApiKeyFormSection
+              title={t('Quota Settings')}
+              description={t('Set quota amount and limits')}
+              icon={WalletCards}
+            >
+              {!unlimitedQuota && (
                 <FormField
                   control={form.control}
-                  name='allow_ips'
+                  name='remain_quota_dollars'
                   render={({ field }) => (
                     <FormItem>
-                      <FormLabel>{t('IP Whitelist (supports CIDR)')}</FormLabel>
+                      <FormLabel>{quotaLabel}</FormLabel>
                       <FormControl>
-                        <Textarea
+                        <Input
                           {...field}
-                          placeholder={t(
-                            'One IP per line (empty for no restriction)'
-                          )}
-                          rows={3}
+                          type='number'
+                          step={tokensOnly ? 1 : 0.01}
+                          placeholder={quotaPlaceholder}
+                          onChange={(e) =>
+                            field.onChange(parseFloat(e.target.value) || 0)
+                          }
                         />
                       </FormControl>
                       <FormDescription>
-                        {t(
-                          'Do not over-trust this feature. IP may be spoofed. Please use with nginx, CDN and other gateways.'
-                        )}
+                        {tokensOnly
+                          ? t('Enter the quota amount in tokens')
+                          : t('Enter the quota amount in {{currency}}', {
+                              currency: currencyLabel,
+                            })}
                       </FormDescription>
                       <FormMessage />
                     </FormItem>
                   )}
                 />
-              </CollapsibleContent>
+              )}
+
+              <FormField
+                control={form.control}
+                name='unlimited_quota'
+                render={({ field }) => (
+                  <FormItem className='flex min-h-20 flex-row items-center justify-between gap-4 rounded-lg border px-4 py-3'>
+                    <div className='space-y-0.5'>
+                      <FormLabel className='text-sm'>
+                        {t('Unlimited Quota')}
+                      </FormLabel>
+                      <FormDescription className='text-xs'>
+                        {t('Enable unlimited quota for this API key')}
+                      </FormDescription>
+                    </div>
+                    <FormControl>
+                      <Switch
+                        checked={field.value}
+                        onCheckedChange={field.onChange}
+                      />
+                    </FormControl>
+                  </FormItem>
+                )}
+              />
+            </ApiKeyFormSection>
+
+            <Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
+              <section className='bg-card rounded-lg border'>
+                <CollapsibleTrigger asChild>
+                  <button
+                    type='button'
+                    className='hover:bg-muted/50 flex w-full items-center gap-3 px-4 py-3 text-left transition-colors'
+                  >
+                    <div className='bg-muted text-muted-foreground flex size-10 shrink-0 items-center justify-center rounded-lg border'>
+                      <Settings2 className='size-5' />
+                    </div>
+                    <div className='min-w-0 flex-1'>
+                      <h3 className='text-sm font-medium leading-none'>
+                        {t('Advanced Settings')}
+                      </h3>
+                      <p className='text-muted-foreground mt-1 text-xs'>
+                        {t('Set API key access restrictions')}
+                      </p>
+                    </div>
+                    <ChevronDown
+                      className={cn(
+                        'text-muted-foreground size-4 shrink-0 transition-transform',
+                        advancedOpen && 'rotate-180'
+                      )}
+                    />
+                  </button>
+                </CollapsibleTrigger>
+                <CollapsibleContent>
+                  <div className='space-y-4 border-t p-4'>
+                    <FormField
+                      control={form.control}
+                      name='model_limits'
+                      render={({ field }) => (
+                        <FormItem>
+                          <FormLabel>{t('Model Limits')}</FormLabel>
+                          <FormControl>
+                            <MultiSelect
+                              options={models.map((m) => ({
+                                label: m,
+                                value: m,
+                              }))}
+                              selected={field.value}
+                              onChange={field.onChange}
+                              placeholder={t(
+                                'Select models (empty for allow all)'
+                              )}
+                            />
+                          </FormControl>
+                          <FormDescription>
+                            {t('Limit which models can be used with this key')}
+                          </FormDescription>
+                          <FormMessage />
+                        </FormItem>
+                      )}
+                    />
+
+                    <FormField
+                      control={form.control}
+                      name='allow_ips'
+                      render={({ field }) => (
+                        <FormItem>
+                          <FormLabel>
+                            {t('IP Whitelist (supports CIDR)')}
+                          </FormLabel>
+                          <FormControl>
+                            <Textarea
+                              {...field}
+                              className='min-h-20 resize-none'
+                              placeholder={t(
+                                'One IP per line (empty for no restriction)'
+                              )}
+                              rows={3}
+                            />
+                          </FormControl>
+                          <FormDescription>
+                            {t(
+                              'Do not over-trust this feature. IP may be spoofed. Please use with nginx, CDN and other gateways.'
+                            )}
+                          </FormDescription>
+                          <FormMessage />
+                        </FormItem>
+                      )}
+                    />
+                  </div>
+                </CollapsibleContent>
+              </section>
             </Collapsible>
           </form>
         </Form>
-        <SheetFooter className='gap-2'>
+        <SheetFooter className='bg-background gap-2 border-t px-5 py-4 sm:flex-row sm:justify-end'>
           <SheetClose asChild>
             <Button variant='outline'>{t('Close')}</Button>
           </SheetClose>

+ 84 - 27
web/default/src/features/usage-logs/components/columns/column-helpers.tsx

@@ -1,9 +1,9 @@
 /* eslint-disable react-refresh/only-export-components */
 import { useState } from 'react'
 import type { ColumnDef } from '@tanstack/react-table'
-import { Clock, Zap } from 'lucide-react'
+import { Zap } from 'lucide-react'
 import { formatTimestampToDate, formatTokens } from '@/lib/format'
-import { Button } from '@/components/ui/button'
+import { cn } from '@/lib/utils'
 import {
   Tooltip,
   TooltipContent,
@@ -50,7 +50,7 @@ export function CacheTooltip({
 // ============================================================================
 
 /**
- * Create a timestamp column
+ * Create a timestamp column - compact mono style matching common logs
  */
 export function createTimestampColumn<T>(config: {
   accessorKey: string
@@ -66,10 +66,13 @@ export function createTimestampColumn<T>(config: {
     ),
     cell: ({ row }) => {
       const timestamp = row.getValue(accessorKey) as number
+      if (!timestamp) {
+        return <span className='text-muted-foreground/60 text-xs'>-</span>
+      }
       return (
-        <div className='min-w-[140px] font-mono text-sm'>
+        <span className='font-mono text-xs tabular-nums'>
           {formatTimestampToDate(timestamp, unit)}
-        </div>
+        </span>
       )
     },
     meta: { label: title },
@@ -77,19 +80,51 @@ export function createTimestampColumn<T>(config: {
 }
 
 /**
- * Create a duration column
+ * Duration pill colors matching common logs timing column
+ */
+const durationPillBg: Record<string, string> = {
+  green:
+    'border border-emerald-200/60 bg-emerald-50/70 dark:border-emerald-800/50 dark:bg-emerald-950/25',
+  red: 'border border-rose-200/70 bg-rose-50/70 dark:border-rose-800/50 dark:bg-rose-950/25',
+  success:
+    'border border-emerald-200/60 bg-emerald-50/50 dark:border-emerald-800/50 dark:bg-emerald-950/20',
+  info: 'border border-sky-200/60 bg-sky-50/50 dark:border-sky-800/50 dark:bg-sky-950/20',
+  warning:
+    'border border-amber-200/60 bg-amber-50/50 dark:border-amber-800/50 dark:bg-amber-950/20',
+}
+
+const durationTextColor: Record<string, string> = {
+  green: 'text-emerald-700 dark:text-emerald-400',
+  red: 'text-rose-700 dark:text-rose-400',
+  success: 'text-emerald-700 dark:text-emerald-400',
+  info: 'text-sky-700 dark:text-sky-400',
+  warning: 'text-amber-700 dark:text-amber-400',
+}
+
+const durationDotColor: Record<string, string> = {
+  green: 'bg-emerald-500',
+  red: 'bg-rose-500',
+  success: 'bg-emerald-500',
+  info: 'bg-sky-500',
+  warning: 'bg-amber-500',
+}
+
+/**
+ * Create a duration column - pill style matching common logs timing
  */
 export function createDurationColumn<T>(config: {
   submitTimeKey: string
   finishTimeKey: string
   unit?: 'seconds' | 'milliseconds'
   headerLabel: string
+  warningThresholdSec?: number
 }): ColumnDef<T> {
   const {
     submitTimeKey,
     finishTimeKey,
     unit = 'milliseconds',
     headerLabel,
+    warningThresholdSec = 60,
   } = config
 
   return {
@@ -106,17 +141,28 @@ export function createDurationColumn<T>(config: {
       )
 
       if (!duration) {
-        return <div className='text-muted-foreground text-sm'>-</div>
+        return <span className='text-muted-foreground/60 text-xs'>-</span>
       }
 
+      const variant = duration.durationSec > warningThresholdSec ? 'red' : 'green'
+
       return (
-        <StatusBadge
-          label={`${duration.durationSec.toFixed(1)}s`}
-          variant={duration.variant}
-          icon={Clock}
-          size='sm'
-          copyable={false}
-        />
+        <span
+          className={cn(
+            'inline-flex w-fit items-center gap-1 rounded-md px-1.5 py-0.5 font-mono text-xs font-medium',
+            durationPillBg[variant],
+            durationTextColor[variant]
+          )}
+        >
+          <span
+            className={cn(
+              'size-1.5 shrink-0 rounded-full',
+              durationDotColor[variant]
+            )}
+            aria-hidden='true'
+          />
+          {duration.durationSec.toFixed(1)}s
+        </span>
       )
     },
     meta: { label: headerLabel },
@@ -124,7 +170,7 @@ export function createDurationColumn<T>(config: {
 }
 
 /**
- * Create a channel column (admin only)
+ * Create a channel column (admin only) - #id badge matching common logs
  */
 export function createChannelColumn<T>(config: {
   accessorKey?: string
@@ -139,11 +185,16 @@ export function createChannelColumn<T>(config: {
     ),
     cell: ({ row }) => {
       const channelId = row.getValue(accessorKey) as number
+      if (!channelId) {
+        return <span className='text-muted-foreground/60 text-xs'>-</span>
+      }
       return (
         <StatusBadge
-          label={`${channelId}`}
-          autoColor={`channel-${channelId}`}
+          label={`#${channelId}`}
+          autoColor={String(channelId)}
+          copyText={String(channelId)}
           size='sm'
+          className='font-mono'
         />
       )
     },
@@ -152,7 +203,7 @@ export function createChannelColumn<T>(config: {
 }
 
 /**
- * Create a fail reason column
+ * Create a fail reason column - text-xs truncate, hover underline, dialog
  */
 export function createFailReasonColumn<T>(config: {
   accessorKey?: string
@@ -171,19 +222,21 @@ export function createFailReasonColumn<T>(config: {
       const [dialogOpen, setDialogOpen] = useState(false)
 
       if (!failReason) {
-        return <span className='text-muted-foreground text-sm'>-</span>
+        return <span className='text-muted-foreground/60 text-xs'>-</span>
       }
 
       return (
         <>
-          <Button
-            variant='ghost'
-            className='h-auto max-w-[200px] justify-start overflow-hidden p-0 text-left text-sm font-normal text-red-600 hover:underline'
+          <button
+            type='button'
+            className='group flex max-w-[200px] items-center gap-1 text-left text-xs'
             onClick={() => setDialogOpen(true)}
             title={cellTitle}
           >
-            <span className='truncate'>{failReason}</span>
-          </Button>
+            <span className='truncate leading-snug text-red-600 group-hover:underline dark:text-red-400'>
+              {failReason}
+            </span>
+          </button>
           <FailReasonDialog
             failReason={failReason}
             open={dialogOpen}
@@ -197,7 +250,7 @@ export function createFailReasonColumn<T>(config: {
 }
 
 /**
- * Create a progress column
+ * Create a progress column - compact mono pill
  */
 export function createProgressColumn<T>(config: {
   accessorKey?: string
@@ -213,9 +266,13 @@ export function createProgressColumn<T>(config: {
     cell: ({ row }) => {
       const progress = row.getValue(accessorKey) as string
       if (!progress) {
-        return <span className='text-muted-foreground text-sm'>-</span>
+        return <span className='text-muted-foreground/60 text-xs'>-</span>
       }
-      return <div className='font-mono text-sm'>{progress}</div>
+      return (
+        <span className='inline-flex items-center rounded-md border border-border/60 bg-muted/30 px-1.5 py-0.5 font-mono text-xs'>
+          {progress}
+        </span>
+      )
     },
     meta: { label: headerLabel },
   }

+ 61 - 48
web/default/src/features/usage-logs/components/columns/common-logs-columns.tsx

@@ -8,8 +8,8 @@ import {
   formatLogQuota,
   formatTimestampToDate,
 } from '@/lib/format'
-import { getAvatarColorClass } from '@/lib/colors'
 import { cn } from '@/lib/utils'
+import { Avatar, AvatarFallback } from '@/components/ui/avatar'
 import {
   Popover,
   PopoverContent,
@@ -30,13 +30,15 @@ import {
 } from '@/components/status-badge'
 import type { UsageLog } from '../../data/schema'
 import {
-  getTimeColor,
   formatModelName,
+  getFirstResponseTimeColor,
+  getResponseTimeColor,
   getTieredBillingSummary,
   hasAnyCacheTokens,
   parseLogOther,
   isViolationFeeLog,
 } from '../../lib/format'
+import { getLogAvatarStyle } from '../../lib/avatar-color'
 import {
   isDisplayableLogType,
   isTimingLogType,
@@ -55,7 +57,27 @@ interface DetailSegment {
 
 function formatRatioCompact(ratio: number | undefined): string {
   if (ratio == null || !Number.isFinite(ratio)) return '-'
-  return ratio % 1 === 0 ? String(ratio) : ratio.toFixed(4)
+  return ratio % 1 === 0
+    ? String(ratio)
+    : ratio.toFixed(4).replace(/\.?0+$/, '')
+}
+
+function getGroupRatioText(other: LogOtherData | null): string | null {
+  const userGroupRatio = other?.user_group_ratio
+  if (
+    userGroupRatio != null &&
+    userGroupRatio !== -1 &&
+    Number.isFinite(userGroupRatio)
+  ) {
+    return `${formatRatioCompact(userGroupRatio)}x`
+  }
+
+  const groupRatio = other?.group_ratio
+  if (groupRatio != null && groupRatio !== 1 && Number.isFinite(groupRatio)) {
+    return `${formatRatioCompact(groupRatio)}x`
+  }
+
+  return null
 }
 
 function buildDetailSegments(
@@ -382,16 +404,23 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
                 setUserInfoDialogOpen(true)
               }}
             >
-              <span
-                className={cn(
-                  'flex size-6 items-center justify-center rounded-full text-xs font-bold ring-1 ring-border/60 saturate-[1.2] brightness-95 dark:brightness-110',
-                  sensitiveVisible
-                    ? getAvatarColorClass(log.username)
-                    : 'bg-muted text-muted-foreground'
-                )}
-              >
-                {sensitiveVisible ? log.username.charAt(0).toUpperCase() : '•'}
-              </span>
+              <Avatar className='size-6 ring-1 ring-border/60'>
+                <AvatarFallback
+                  className={cn(
+                    'text-[11px] font-semibold',
+                    !sensitiveVisible && 'bg-muted text-muted-foreground'
+                  )}
+                  style={
+                    sensitiveVisible
+                      ? getLogAvatarStyle(log.username)
+                      : undefined
+                  }
+                >
+                  {sensitiveVisible
+                    ? log.username.charAt(0).toUpperCase()
+                    : '•'}
+                </AvatarFallback>
+              </Avatar>
               <span className='text-muted-foreground truncate text-sm hover:underline'>
                 {sensitiveVisible ? log.username : '••••'}
               </span>
@@ -423,11 +452,10 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
           <StatusBadge
             label={displayName}
             icon={KeyRound}
-            autoColor={tokenName}
             copyText={sensitiveVisible ? tokenName : undefined}
             size='sm'
             showDot={false}
-            className='max-w-full overflow-hidden rounded-md border border-border/60 bg-muted/30 px-1.5 py-0.5 font-mono'
+            className='max-w-full overflow-hidden rounded-md border border-border/60 bg-muted/30 px-1.5 py-0.5 font-mono text-foreground'
           />
         </div>
       )
@@ -504,7 +532,11 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
         )
 
         const metaParts: string[] = []
-        if (group) metaParts.push(sensitiveVisible ? group : '••••')
+        const groupRatioText = getGroupRatioText(other)
+        if (group) {
+          metaParts.push(sensitiveVisible ? group : '••••')
+        }
+        if (groupRatioText) metaParts.push(groupRatioText)
 
         return (
           <div className='flex max-w-[220px] flex-col gap-0.5'>
@@ -532,15 +564,23 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
         const useTime = row.getValue('use_time') as number
         const other = parseLogOther(log.other)
         const frt = other?.frt
-        const timeVariant = getTimeColor(useTime)
-        const frtVariant = frt ? getTimeColor(frt / 1000) : null
+        const tokensPerSecond =
+          useTime > 0 && log.completion_tokens > 0
+            ? log.completion_tokens / useTime
+            : null
+        const timeVariant = getResponseTimeColor(
+          useTime,
+          log.completion_tokens
+        )
+        const frtVariant = frt ? getFirstResponseTimeColor(frt / 1000) : null
 
         const pillBg: Record<string, string> = {
           success:
             'border border-emerald-200/60 bg-emerald-50/50 dark:border-emerald-800/50 dark:bg-emerald-950/20',
-          info: 'border border-sky-200/60 bg-sky-50/50 dark:border-sky-800/50 dark:bg-sky-950/20',
           warning:
             'border border-amber-200/60 bg-amber-50/50 dark:border-amber-800/50 dark:bg-amber-950/20',
+          danger:
+            'border border-rose-200/70 bg-rose-50/60 dark:border-rose-800/50 dark:bg-rose-950/25',
         }
 
         return (
@@ -581,15 +621,11 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
             <div className='flex items-center gap-1 text-[11px]'>
               <span className='text-muted-foreground/60'>
                 {log.is_stream ? t('Stream') : t('Non-stream')}
-                {useTime > 0 && (log.prompt_tokens + log.completion_tokens) > 0 && (
+                {tokensPerSecond != null && (
                   <>
                     {' · '}
                     <span className='font-mono tabular-nums'>
-                      {Math.round(
-                        (log.is_stream
-                          ? log.completion_tokens
-                          : log.prompt_tokens + log.completion_tokens) / useTime
-                      )}
+                      {Math.round(tokensPerSecond)}
                     </span>
                     {' t/s'}
                   </>
@@ -717,29 +753,6 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
             <span className='border-border/80 inline-flex w-fit items-center rounded-md border bg-muted/60 px-1.5 py-0.5 font-mono text-xs font-semibold tabular-nums'>
               {quotaStr}
             </span>
-            {(() => {
-              const userGroupRatio = other?.user_group_ratio
-              if (
-                userGroupRatio != null &&
-                userGroupRatio !== -1 &&
-                Number.isFinite(userGroupRatio)
-              ) {
-                return (
-                  <span className='text-muted-foreground/60 text-[11px]'>
-                    {t('User Group: {{ratio}}x', { ratio: userGroupRatio })}
-                  </span>
-                )
-              }
-              const groupRatio = other?.group_ratio
-              if (groupRatio != null && groupRatio !== 1) {
-                return (
-                  <span className='text-muted-foreground/60 text-[11px]'>
-                    {t('Group: {{ratio}}x', { ratio: groupRatio })}
-                  </span>
-                )
-              }
-              return null
-            })()}
           </div>
         )
       },

+ 142 - 83
web/default/src/features/usage-logs/components/columns/drawing-logs-columns.tsx

@@ -1,8 +1,28 @@
 import { useState } from 'react'
 import type { ColumnDef } from '@tanstack/react-table'
+import {
+  Blend,
+  FileText,
+  HelpCircle,
+  ImageIcon,
+  Maximize2,
+  Move,
+  Paintbrush,
+  RefreshCw,
+  Scissors,
+  Shuffle,
+  Upload,
+  UserRound,
+  Video,
+  WandSparkles,
+  ZoomIn,
+  type LucideIcon,
+} from 'lucide-react'
 import { useTranslation } from 'react-i18next'
-import { Button } from '@/components/ui/button'
+import { formatTimestampToDate } from '@/lib/format'
+import { DataTableColumnHeader } from '@/components/data-table'
 import { StatusBadge } from '@/components/status-badge'
+import { MJ_TASK_TYPES } from '../../constants'
 import {
   mjTaskTypeMapper,
   mjStatusMapper,
@@ -12,84 +32,136 @@ import type { MidjourneyLog } from '../../types'
 import { ImageDialog } from '../dialogs/image-dialog'
 import { PromptDialog } from '../dialogs/prompt-dialog'
 import {
-  createTimestampColumn,
   createDurationColumn,
   createChannelColumn,
   createProgressColumn,
   createFailReasonColumn,
 } from './column-helpers'
 
+const drawingTypeIconMap: Record<string, LucideIcon> = {
+  [MJ_TASK_TYPES.IMAGINE]: ImageIcon,
+  [MJ_TASK_TYPES.UPSCALE]: Maximize2,
+  [MJ_TASK_TYPES.VIDEO]: Video,
+  [MJ_TASK_TYPES.EDITS]: Paintbrush,
+  [MJ_TASK_TYPES.VARIATION]: Shuffle,
+  [MJ_TASK_TYPES.HIGH_VARIATION]: Shuffle,
+  [MJ_TASK_TYPES.LOW_VARIATION]: Shuffle,
+  [MJ_TASK_TYPES.PAN]: Move,
+  [MJ_TASK_TYPES.DESCRIBE]: FileText,
+  [MJ_TASK_TYPES.BLEND]: Blend,
+  [MJ_TASK_TYPES.UPLOAD]: Upload,
+  [MJ_TASK_TYPES.SHORTEN]: Scissors,
+  [MJ_TASK_TYPES.REROLL]: RefreshCw,
+  [MJ_TASK_TYPES.INPAINT]: WandSparkles,
+  [MJ_TASK_TYPES.SWAP_FACE]: UserRound,
+  [MJ_TASK_TYPES.ZOOM]: ZoomIn,
+  [MJ_TASK_TYPES.CUSTOM_ZOOM]: ZoomIn,
+}
+
+function getDrawingTypeIcon(action: string): LucideIcon {
+  return drawingTypeIconMap[action] ?? HelpCircle
+}
+
 export function useDrawingLogsColumns(
   isAdmin: boolean
 ): ColumnDef<MidjourneyLog>[] {
   const { t } = useTranslation()
   const columns: ColumnDef<MidjourneyLog>[] = [
-    createTimestampColumn<MidjourneyLog>({
+    {
       accessorKey: 'submit_time',
-      title: t('Submit Time'),
-    }),
-    createDurationColumn<MidjourneyLog>({
-      submitTimeKey: 'submit_time',
-      finishTimeKey: 'finish_time',
-      headerLabel: t('Duration'),
-    }),
+      header: ({ column }) => (
+        <DataTableColumnHeader column={column} title={t('Submit Time')} />
+      ),
+      cell: ({ row }) => {
+        const log = row.original
+        const submitTime = row.getValue('submit_time') as number
+
+        return (
+          <div className='flex flex-col gap-0.5'>
+            <span className='font-mono text-xs tabular-nums'>
+              {formatTimestampToDate(submitTime)}
+            </span>
+            <StatusBadge
+              label={t(mjStatusMapper.getLabel(log.status))}
+              variant={mjStatusMapper.getVariant(log.status)}
+              size='sm'
+              copyable={false}
+            />
+          </div>
+        )
+      },
+      meta: { label: t('Submit Time') },
+    },
   ]
 
-  // Channel (admin only)
   if (isAdmin) {
     columns.push(
       createChannelColumn<MidjourneyLog>({ headerLabel: t('Channel') })
     )
   }
 
-  columns.push(
-    // Type (using 'action' field from backend)
-    {
-      accessorKey: 'action',
-      header: t('Type'),
-      cell: ({ row }) => {
-        const action = row.getValue('action') as string
-        return (
-          <StatusBadge
-            label={t(mjTaskTypeMapper.getLabel(action))}
-            variant={mjTaskTypeMapper.getVariant(action)}
-            size='sm'
-            copyable={false}
-          />
-        )
-      },
-      meta: { label: t('Type') },
+  columns.push({
+    accessorKey: 'action',
+    header: ({ column }) => (
+      <DataTableColumnHeader column={column} title={t('Type')} />
+    ),
+    cell: ({ row }) => {
+      const action = row.getValue('action') as string
+      return (
+        <StatusBadge
+          label={t(mjTaskTypeMapper.getLabel(action))}
+          variant={mjTaskTypeMapper.getVariant(action)}
+          icon={getDrawingTypeIcon(action)}
+          size='sm'
+          copyable={false}
+          showDot={false}
+        />
+      )
     },
+    meta: { label: t('Type') },
+  })
 
-    // Task ID
-    {
-      accessorKey: 'mj_id',
-      header: t('Task ID'),
-      cell: ({ row }) => {
-        const mjId = row.getValue('mj_id') as string
+  columns.push({
+    accessorKey: 'mj_id',
+    header: ({ column }) => (
+      <DataTableColumnHeader column={column} title={t('Task ID')} />
+    ),
+    cell: ({ row }) => {
+      const mjId = row.getValue('mj_id') as string
 
-        if (!mjId) {
-          return <span className='text-muted-foreground text-sm'>-</span>
-        }
+      if (!mjId) {
+        return <span className='text-muted-foreground/60 text-xs'>-</span>
+      }
 
-        return (
+      return (
+        <div className='flex max-w-[160px] flex-col gap-0.5'>
           <StatusBadge
             label={mjId}
             autoColor={mjId}
             size='sm'
-            className='font-mono'
+            showDot={false}
+            className='max-w-full truncate rounded-md border border-border/60 bg-muted/30 px-1.5 py-0.5 font-mono'
           />
-        )
-      },
-      meta: { label: t('Task ID'), mobileHidden: true },
-    }
+        </div>
+      )
+    },
+    meta: { label: t('Task ID'), mobileTitle: true },
+  })
+
+  columns.push(
+    createDurationColumn<MidjourneyLog>({
+      submitTimeKey: 'submit_time',
+      finishTimeKey: 'finish_time',
+      headerLabel: t('Duration'),
+    })
   )
 
-  // Submit Result (admin only)
   if (isAdmin) {
     columns.push({
       accessorKey: 'code',
-      header: t('Submit Result'),
+      header: ({ column }) => (
+        <DataTableColumnHeader column={column} title={t('Submit Result')} />
+      ),
       cell: ({ row }) => {
         const code = row.getValue('code') as number
 
@@ -108,49 +180,33 @@ export function useDrawingLogsColumns(
   }
 
   columns.push(
-    // Status
-    {
-      accessorKey: 'status',
-      header: t('Status'),
-      cell: ({ row }) => {
-        const status = row.getValue('status') as string
-        return (
-          <StatusBadge
-            label={t(mjStatusMapper.getLabel(status))}
-            variant={mjStatusMapper.getVariant(status)}
-            size='sm'
-            copyable={false}
-            showDot
-          />
-        )
-      },
-      meta: { label: t('Status') },
-    },
-
     createProgressColumn<MidjourneyLog>({ headerLabel: t('Progress') }),
-
-    // Image
     {
       accessorKey: 'image_url',
-      header: t('Image'),
+      header: ({ column }) => (
+        <DataTableColumnHeader column={column} title={t('Image')} />
+      ),
       cell: function ImageCell({ row }) {
         const log = row.original
         const imageUrl = row.getValue('image_url') as string
         const [dialogOpen, setDialogOpen] = useState(false)
 
         if (!imageUrl) {
-          return <span className='text-muted-foreground text-sm'>-</span>
+          return <span className='text-muted-foreground/60 text-xs'>-</span>
         }
 
         return (
           <>
-            <Button
-              variant='ghost'
-              className='text-primary h-auto p-0 text-sm font-normal hover:underline'
+            <button
+              type='button'
+              className='group text-left text-xs'
               onClick={() => setDialogOpen(true)}
+              title={t('Click to view image')}
             >
-              {t('View')}
-            </Button>
+              <span className='text-foreground truncate leading-snug group-hover:underline'>
+                {t('View')}
+              </span>
+            </button>
             <ImageDialog
               imageUrl={imageUrl}
               taskId={log.mj_id}
@@ -162,30 +218,32 @@ export function useDrawingLogsColumns(
       },
       meta: { label: t('Image'), mobileHidden: true },
     },
-
-    // Prompt (clickable)
     {
       accessorKey: 'prompt',
-      header: t('Prompt'),
+      header: ({ column }) => (
+        <DataTableColumnHeader column={column} title={t('Prompt')} />
+      ),
       cell: function PromptCell({ row }) {
         const log = row.original
         const prompt = row.getValue('prompt') as string
         const [dialogOpen, setDialogOpen] = useState(false)
 
         if (!prompt) {
-          return <span className='text-muted-foreground text-sm'>-</span>
+          return <span className='text-muted-foreground/60 text-xs'>-</span>
         }
 
         return (
           <>
-            <Button
-              variant='ghost'
-              className='h-auto max-w-[300px] justify-start overflow-hidden p-0 text-left text-sm font-normal hover:underline'
+            <button
+              type='button'
+              className='group flex max-w-[220px] items-center text-left text-xs'
               onClick={() => setDialogOpen(true)}
               title={t('Click to view full prompt')}
             >
-              <span className='truncate'>{prompt}</span>
-            </Button>
+              <span className='text-muted-foreground truncate leading-snug group-hover:underline'>
+                {prompt}
+              </span>
+            </button>
             <PromptDialog
               prompt={prompt}
               promptEn={log.prompt_en}
@@ -196,8 +254,9 @@ export function useDrawingLogsColumns(
         )
       },
       meta: { label: t('Prompt'), mobileHidden: true },
+      size: 200,
+      maxSize: 220,
     },
-
     createFailReasonColumn<MidjourneyLog>({
       headerLabel: t('Fail Reason'),
       cellTitle: t('Click to view full error message'),

+ 130 - 88
web/default/src/features/usage-logs/components/columns/task-logs-columns.tsx

@@ -3,22 +3,25 @@ import { useState, useMemo } from 'react'
 import type { ColumnDef } from '@tanstack/react-table'
 import { Music } from 'lucide-react'
 import { useTranslation } from 'react-i18next'
-import { Button } from '@/components/ui/button'
+import { formatTimestampToDate } from '@/lib/format'
+import { cn } from '@/lib/utils'
+import { DataTableColumnHeader } from '@/components/data-table'
 import { StatusBadge } from '@/components/status-badge'
+import { Avatar, AvatarFallback } from '@/components/ui/avatar'
 import { TASK_ACTIONS, TASK_STATUS } from '../../constants'
 import {
   taskActionMapper,
   taskStatusMapper,
-  taskPlatformMapper,
 } from '../../lib/mappers'
 import type { TaskLog } from '../../types'
+import { getLogAvatarStyle } from '../../lib/avatar-color'
+import { useUsageLogsContext } from '../usage-logs-provider'
 import {
   AudioPreviewDialog,
   type AudioClip,
 } from '../dialogs/audio-preview-dialog'
 import { FailReasonDialog } from '../dialogs/fail-reason-dialog'
 import {
-  createTimestampColumn,
   createDurationColumn,
   createChannelColumn,
   createProgressColumn,
@@ -52,14 +55,16 @@ function AudioPreviewCell({ log }: { log: TaskLog }) {
 
   return (
     <>
-      <Button
-        variant='link'
-        className='h-auto p-0 text-sm'
+      <button
+        type='button'
+        className='group flex items-center gap-1 text-left text-xs'
         onClick={() => setOpen(true)}
       >
-        <Music className='mr-1 h-3 w-3' />
-        {t('Click to preview audio')}
-      </Button>
+        <Music className='size-3 text-muted-foreground' />
+        <span className='text-foreground leading-snug group-hover:underline'>
+          {t('Click to preview audio')}
+        </span>
+      </button>
       <AudioPreviewDialog
         open={open}
         onOpenChange={setOpen}
@@ -72,88 +77,128 @@ function AudioPreviewCell({ log }: { log: TaskLog }) {
 export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
   const { t } = useTranslation()
   const columns: ColumnDef<TaskLog>[] = [
-    createTimestampColumn<TaskLog>({
-      accessorKey: 'submit_time',
-      title: t('Submit Time'),
-      unit: 'seconds',
-    }),
-    createTimestampColumn<TaskLog>({
-      accessorKey: 'finish_time',
-      title: t('Finish Time'),
-      unit: 'seconds',
-    }),
-    createDurationColumn<TaskLog>({
-      submitTimeKey: 'submit_time',
-      finishTimeKey: 'finish_time',
-      unit: 'seconds',
-      headerLabel: t('Duration'),
-    }),
-  ]
-
-  // Channel (admin only)
-  if (isAdmin) {
-    columns.push(createChannelColumn<TaskLog>({ headerLabel: t('Channel') }))
-  }
-
-  columns.push(
-    // Platform
     {
-      accessorKey: 'platform',
-      header: t('Platform'),
+      accessorKey: 'submit_time',
+      header: ({ column }) => (
+        <DataTableColumnHeader column={column} title={t('Submit Time')} />
+      ),
       cell: ({ row }) => {
-        const platform = row.getValue('platform') as string
-        return (
-          <StatusBadge
-            label={t(platform)}
-            variant={taskPlatformMapper.getVariant(platform)}
-            size='sm'
-            copyable={false}
-          />
-        )
-      },
-      meta: { label: t('Platform') },
-    },
+        const log = row.original
+        const submitTime = row.getValue('submit_time') as number
 
-    // Type/Action
-    {
-      accessorKey: 'action',
-      header: t('Type'),
-      cell: ({ row }) => {
-        const action = row.getValue('action') as string
         return (
-          <StatusBadge
-            label={t(taskActionMapper.getLabel(action))}
-            variant={taskActionMapper.getVariant(action)}
-            size='sm'
-            copyable={false}
-          />
+          <div className='flex flex-col gap-0.5'>
+            <span className='font-mono text-xs tabular-nums'>
+              {formatTimestampToDate(submitTime, 'seconds')}
+            </span>
+            {log.finish_time ? (
+              <span className='text-muted-foreground/60 font-mono text-[11px] tabular-nums'>
+                {formatTimestampToDate(log.finish_time, 'seconds')}
+              </span>
+            ) : (
+              <span className='text-muted-foreground/50 text-[11px]'>-</span>
+            )}
+          </div>
         )
       },
-      meta: { label: t('Type') },
+      meta: { label: t('Submit Time') },
     },
+  ]
+
+  if (isAdmin) {
+    columns.push(
+      createChannelColumn<TaskLog>({ headerLabel: t('Channel') }),
+      {
+        id: 'user',
+        header: ({ column }) => (
+          <DataTableColumnHeader column={column} title={t('User')} />
+        ),
+        cell: function UserCell({ row }) {
+          const {
+            sensitiveVisible,
+            setSelectedUserId,
+            setUserInfoDialogOpen,
+          } = useUsageLogsContext()
+          const log = row.original
+          const displayName = log.username || String(log.user_id || '?')
 
-    // Task ID
+          return (
+            <button
+              type='button'
+              className='flex items-center gap-1.5 text-left'
+              onClick={(e) => {
+                e.stopPropagation()
+                setSelectedUserId(log.user_id)
+                setUserInfoDialogOpen(true)
+              }}
+            >
+              <Avatar className='size-6 ring-1 ring-border/60'>
+                <AvatarFallback
+                  className={cn(
+                    'text-[11px] font-semibold',
+                    !sensitiveVisible && 'bg-muted text-muted-foreground'
+                  )}
+                  style={
+                    sensitiveVisible ? getLogAvatarStyle(displayName) : undefined
+                  }
+                >
+                  {sensitiveVisible
+                    ? displayName.charAt(0).toUpperCase()
+                    : '•'}
+                </AvatarFallback>
+              </Avatar>
+              <span className='text-muted-foreground truncate text-sm hover:underline'>
+                {sensitiveVisible ? displayName : '••••'}
+              </span>
+            </button>
+          )
+        },
+        meta: { label: t('User'), mobileHidden: true },
+      }
+    )
+  }
+
+  columns.push(
     {
       accessorKey: 'task_id',
-      header: t('Task ID'),
+      header: ({ column }) => (
+        <DataTableColumnHeader column={column} title={t('Task ID')} />
+      ),
       cell: ({ row }) => {
+        const log = row.original
         const taskId = row.getValue('task_id') as string
+        if (!taskId) {
+          return <span className='text-muted-foreground/60 text-xs'>-</span>
+        }
         return (
-          <StatusBadge
-            label={taskId}
-            autoColor={taskId}
-            size='sm'
-            className='font-mono'
-          />
+          <div className='flex max-w-[170px] flex-col gap-0.5'>
+            <StatusBadge
+              label={taskId}
+              autoColor={taskId}
+              size='sm'
+              showDot={false}
+              className='max-w-full truncate rounded-md border border-border/60 bg-muted/30 px-1.5 py-0.5 font-mono'
+            />
+            <span className='text-muted-foreground/60 truncate text-[11px]'>
+              {t(log.platform)} · {t(taskActionMapper.getLabel(log.action))}
+            </span>
+          </div>
         )
       },
-      meta: { label: t('Task ID'), mobileHidden: true },
+      meta: { label: t('Task ID'), mobileTitle: true },
     },
-
-    // Status
+    createDurationColumn<TaskLog>({
+      submitTimeKey: 'submit_time',
+      finishTimeKey: 'finish_time',
+      unit: 'seconds',
+      headerLabel: t('Duration'),
+      warningThresholdSec: 300,
+    }),
     {
       accessorKey: 'status',
-      header: t('Status'),
+      header: ({ column }) => (
+        <DataTableColumnHeader column={column} title={t('Status')} />
+      ),
       cell: ({ row }) => {
         const status = row.getValue('status') as string
         return (
@@ -168,20 +213,18 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
       },
       meta: { label: t('Status') },
     },
-
     createProgressColumn<TaskLog>({ headerLabel: t('Progress') }),
-
-    // Result/Fail Reason - Combined column
     {
       accessorKey: 'fail_reason',
-      header: t('Details'),
+      header: ({ column }) => (
+        <DataTableColumnHeader column={column} title={t('Details')} />
+      ),
       cell: function DetailsCell({ row }) {
         const log = row.original
         const failReason = row.getValue('fail_reason') as string
         const status = log.status
         const [dialogOpen, setDialogOpen] = useState(false)
 
-        // Suno audio preview
         const isSunoSuccess =
           log.platform === 'suno' && status === TASK_STATUS.SUCCESS
         if (isSunoSuccess) {
@@ -198,7 +241,6 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
           }
         }
 
-        // For video generation tasks that succeeded, fail_reason contains the result URL
         const isVideoTask =
           log.action === TASK_ACTIONS.GENERATE ||
           log.action === TASK_ACTIONS.TEXT_GENERATE ||
@@ -208,7 +250,6 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
         const isSuccess = status === TASK_STATUS.SUCCESS
         const isUrl = failReason?.startsWith('http')
 
-        // If success and is a URL, show as result link
         if (isSuccess && isVideoTask && isUrl) {
           const videoUrl = `/v1/videos/${log.task_id}/content`
           return (
@@ -216,28 +257,29 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
               href={videoUrl}
               target='_blank'
               rel='noopener noreferrer'
-              className='text-primary text-sm hover:underline'
+              className='text-xs text-foreground hover:underline'
             >
               {t('Click to preview video')}
             </a>
           )
         }
 
-        // Otherwise, show fail reason (if any) using the existing dialog
         if (!failReason) {
-          return <span className='text-muted-foreground text-sm'>-</span>
+          return <span className='text-muted-foreground/60 text-xs'>-</span>
         }
 
         return (
           <>
-            <Button
-              variant='ghost'
-              className='h-auto max-w-[200px] justify-start overflow-hidden p-0 text-left text-sm font-normal text-red-600 hover:underline'
+            <button
+              type='button'
+              className='group flex max-w-[200px] items-center gap-1 text-left text-xs'
               onClick={() => setDialogOpen(true)}
               title={t('Click to view full error message')}
             >
-              <span className='truncate'>{failReason}</span>
-            </Button>
+              <span className='truncate leading-snug text-red-600 group-hover:underline dark:text-red-400'>
+                {failReason}
+              </span>
+            </button>
             <FailReasonDialog
               failReason={failReason}
               open={dialogOpen}

+ 11 - 2
web/default/src/features/usage-logs/components/common-logs-filter-bar.tsx

@@ -22,6 +22,13 @@ import { CompactDateTimeRangePicker } from './compact-date-time-range-picker'
 import { useUsageLogsContext } from './usage-logs-provider'
 
 const route = getRouteApi('/_authenticated/usage-logs/$section')
+const logTypeValues = ['0', '1', '2', '3', '4', '5', '6'] as const
+
+type LogTypeValue = (typeof logTypeValues)[number]
+
+function isLogTypeValue(value: string): value is LogTypeValue {
+  return (logTypeValues as readonly string[]).includes(value)
+}
 
 interface CommonLogsFilterBarProps {
   stats?: ReactNode
@@ -45,7 +52,7 @@ export function CommonLogsFilterBar({
     const { start, end } = getDefaultTimeRange()
     return { startTime: start, endTime: end }
   })
-  const [logType, setLogType] = useState<string>('')
+  const [logType, setLogType] = useState<LogTypeValue | ''>('')
 
   useEffect(() => {
     const next: Partial<CommonLogFilters> = {}
@@ -163,7 +170,9 @@ export function CommonLogsFilterBar({
         />
         <Select
           value={logType}
-          onValueChange={(v) => setLogType(v === 'all' ? '' : v)}
+          onValueChange={(value) => {
+            setLogType(isLogTypeValue(value) ? value : '')
+          }}
         >
           <SelectTrigger className='h-9'>
             <SelectValue placeholder={t('All Types')} />

+ 24 - 7
web/default/src/features/usage-logs/components/dialogs/details-dialog.tsx

@@ -38,7 +38,8 @@ import {
   getTieredBillingSummary,
   hasAnyCacheTokens,
   isViolationFeeLog,
-  getTimeColor,
+  getFirstResponseTimeColor,
+  getResponseTimeColor,
 } from '../../lib/format'
 import {
   getLogTypeConfig,
@@ -47,6 +48,14 @@ import {
 } from '../../lib/utils'
 import type { LogOtherData } from '../../types'
 
+function timingTextColorClass(
+  variant: 'success' | 'warning' | 'danger'
+): string {
+  if (variant === 'success') return 'text-emerald-600'
+  if (variant === 'warning') return 'text-amber-600'
+  return 'text-rose-600'
+}
+
 function DetailRow(props: {
   label: React.ReactNode
   value: React.ReactNode
@@ -545,18 +554,26 @@ export function DetailsDialog(props: DetailsDialogProps) {
                     <span
                       className={cn(
                         'font-medium',
-                        getTimeColor(props.log.use_time) === 'success'
-                          ? 'text-emerald-600'
-                          : getTimeColor(props.log.use_time) === 'info'
-                            ? 'text-sky-600'
-                            : 'text-amber-600'
+                        timingTextColorClass(
+                          getResponseTimeColor(
+                            props.log.use_time,
+                            props.log.completion_tokens
+                          )
+                        )
                       )}
                     >
                       {formatUseTime(props.log.use_time)}
                       {props.log.is_stream &&
                         other?.frt != null &&
                         other.frt > 0 && (
-                          <span className='text-muted-foreground font-normal'>
+                          <span
+                            className={cn(
+                              'font-normal',
+                              timingTextColorClass(
+                                getFirstResponseTimeColor(other.frt / 1000)
+                              )
+                            )}
+                          >
                             {' '}
                             (FRT: {formatUseTime(other.frt / 1000)})
                           </span>

+ 4 - 21
web/default/src/features/usage-logs/components/usage-logs-table.tsx

@@ -43,15 +43,6 @@ import { CommonLogsStats } from './common-logs-stats'
 
 const route = getRouteApi('/_authenticated/usage-logs/$section')
 
-const logTypeBorderColor: Record<number, string> = {
-  [LOG_TYPE_ENUM.TOPUP]: 'border-l-cyan-400 dark:border-l-cyan-500',
-  [LOG_TYPE_ENUM.CONSUME]: 'border-l-emerald-400 dark:border-l-emerald-500',
-  [LOG_TYPE_ENUM.MANAGE]: 'border-l-orange-400 dark:border-l-orange-500',
-  [LOG_TYPE_ENUM.SYSTEM]: 'border-l-purple-400 dark:border-l-purple-500',
-  [LOG_TYPE_ENUM.ERROR]: 'border-l-rose-400 dark:border-l-rose-500',
-  [LOG_TYPE_ENUM.REFUND]: 'border-l-blue-400 dark:border-l-blue-500',
-}
-
 const logTypeRowTint: Record<number, string> = {
   [LOG_TYPE_ENUM.ERROR]: 'bg-rose-50/40 dark:bg-rose-950/20',
   [LOG_TYPE_ENUM.REFUND]: 'bg-blue-50/30 dark:bg-blue-950/15',
@@ -76,7 +67,7 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
   } = useTableUrlState({
     search: route.useSearch(),
     navigate: route.useNavigate(),
-    pagination: { defaultPage: 1, defaultPageSize: 20 },
+    pagination: { defaultPage: 1, defaultPageSize: 100 },
     globalFilter: { enabled: false },
     columnFilters: [
       { columnId: 'created_at', searchKey: 'type', type: 'array' as const },
@@ -174,24 +165,16 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
       const logType = (row.original as Record<string, unknown>).type as
         | number
         | undefined
-      const borderClass =
-        isCommon && logType != null
-          ? logTypeBorderColor[logType] ?? 'border-l-transparent'
-          : ''
       const tintClass =
         isCommon && logType != null ? (logTypeRowTint[logType] ?? '') : ''
 
       return (
         <TableRow
           key={row.id}
-          className={cn(
-            '!border-l-[3px] transition-colors',
-            borderClass,
-            tintClass
-          )}
+          className={cn('transition-colors', tintClass)}
         >
           {row.getVisibleCells().map((cell) => (
-            <TableCell key={cell.id} className='py-2'>
+            <TableCell key={cell.id} className={isCommon ? 'py-2' : 'py-3.5'}>
               {flexRender(cell.column.columnDef.cell, cell.getContext())}
             </TableCell>
           ))}
@@ -236,7 +219,7 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
             <Table>
               <TableHeader className='bg-muted/30 sticky top-0 z-10'>
                 {table.getHeaderGroups().map((headerGroup) => (
-                  <TableRow key={headerGroup.id} className='border-l-[3px] border-l-transparent'>
+                  <TableRow key={headerGroup.id}>
                     {headerGroup.headers.map((header) => (
                       <TableHead key={header.id} colSpan={header.colSpan}>
                         {header.isPlaceholder

+ 24 - 0
web/default/src/features/usage-logs/lib/avatar-color.ts

@@ -0,0 +1,24 @@
+export interface LogAvatarStyle {
+  backgroundColor: string
+  color: string
+}
+
+function hashString(value: string): number {
+  let hash = 0
+  for (let i = 0; i < value.length; i++) {
+    hash = (hash * 31 + value.charCodeAt(i)) >>> 0
+  }
+  return hash
+}
+
+export function getLogAvatarStyle(name: string): LogAvatarStyle {
+  const hash = hashString(name)
+  const hue = hash % 360
+  const saturation = 54 + (hash % 8)
+  const lightness = 52 + ((hash >> 4) % 8)
+
+  return {
+    backgroundColor: `hsl(${hue} ${saturation}% ${lightness}% / 0.82)`,
+    color: 'white',
+  }
+}

+ 39 - 4
web/default/src/features/usage-logs/lib/format.ts

@@ -87,10 +87,45 @@ export function parseLogOther(other: string): LogOtherData | null {
 /**
  * Get time color based on duration (in seconds)
  */
-export function getTimeColor(seconds: number): 'success' | 'info' | 'warning' {
-  if (seconds < 3) return 'success'
-  if (seconds < 10) return 'info'
-  return 'warning'
+export function getTimeColor(
+  seconds: number
+): 'success' | 'warning' | 'danger' {
+  if (seconds < 10) return 'success'
+  if (seconds < 30) return 'warning'
+  return 'danger'
+}
+
+/**
+ * Get first-response-token color based on latency (in seconds)
+ */
+export function getFirstResponseTimeColor(
+  seconds: number
+): 'success' | 'warning' | 'danger' {
+  if (seconds < 5) return 'success'
+  if (seconds < 10) return 'warning'
+  return 'danger'
+}
+
+/**
+ * Get throughput color based on generated tokens per second
+ */
+export function getThroughputColor(
+  tokensPerSecond: number
+): 'success' | 'warning' | 'danger' {
+  if (tokensPerSecond >= 30) return 'success'
+  if (tokensPerSecond >= 15) return 'warning'
+  return 'danger'
+}
+
+/**
+ * Get response color using throughput only when enough output tokens exist.
+ */
+export function getResponseTimeColor(
+  seconds: number,
+  completionTokens: number
+): 'success' | 'warning' | 'danger' {
+  if (completionTokens < 100 || seconds <= 0) return getTimeColor(seconds)
+  return getThroughputColor(completionTokens / seconds)
 }
 
 /**

+ 1 - 0
web/default/src/features/usage-logs/types.ts

@@ -216,6 +216,7 @@ export interface MidjourneyLog {
 export interface TaskLog {
   id: number
   user_id: number
+  username?: string
   platform: string // suno, kling, runway, etc.
   task_id: string
   action: string // MUSIC, LYRICS, GENERATE, TEXT_GENERATE, etc.

+ 3 - 1
web/default/src/i18n/locales/en.json

@@ -365,7 +365,6 @@
     "Auto detect (default)": "Auto detect (default)",
     "Auto Disabled": "Auto Disabled",
     "Auto Group Chain": "Auto Group Chain",
-    "Auto group enables circuit breaker mechanism": "Auto group enables circuit breaker mechanism",
     "Auto refresh": "Auto refresh",
     "Auto Sync Upstream Models": "Auto Sync Upstream Models",
     "Auto-disable status codes": "Auto-disable status codes",
@@ -2975,8 +2974,11 @@
     "Set a tag for": "Set a tag for",
     "Set filters to customize your dashboard statistics and charts.": "Set filters to customize your dashboard statistics and charts.",
     "Set filters to narrow down your log search results.": "Set filters to narrow down your log search results.",
+    "Set API key access restrictions": "Set API key access restrictions",
+    "Set API key basic information": "Set API key basic information",
     "Set Header": "Set Header",
     "Set Project to io.cloud when creating/selecting key": "Set Project to io.cloud when creating/selecting key",
+    "Set quota amount and limits": "Set quota amount and limits",
     "Set Tag": "Set Tag",
     "Set tag for selected channels": "Set tag for selected channels",
     "Set the user's role (cannot be Root)": "Set the user's role (cannot be Root)",

+ 3 - 1
web/default/src/i18n/locales/fr.json

@@ -365,7 +365,6 @@
     "Auto detect (default)": "Détection automatique (par défaut)",
     "Auto Disabled": "Désactivé automatiquement",
     "Auto Group Chain": "Chaîne de groupes automatique",
-    "Auto group enables circuit breaker mechanism": "Le groupe automatique active le mécanisme de disjoncteur",
     "Auto refresh": "Actualisation automatique",
     "Auto Sync Upstream Models": "Synchronisation automatique des modèles en amont",
     "Auto-disable status codes": "Codes de statut de désactivation auto",
@@ -2975,8 +2974,11 @@
     "Set a tag for": "Définir un tag pour",
     "Set filters to customize your dashboard statistics and charts.": "Définir des filtres pour personnaliser les statistiques et les graphiques de votre tableau de bord.",
     "Set filters to narrow down your log search results.": "Définir des filtres pour affiner vos résultats de recherche de journaux.",
+    "Set API key access restrictions": "Définir les restrictions d'accès de la clé API",
+    "Set API key basic information": "Définir les informations de base de la clé API",
     "Set Header": "Définir l'en-tête",
     "Set Project to io.cloud when creating/selecting key": "Définir le projet sur io.cloud lors de la création/sélection de la clé",
+    "Set quota amount and limits": "Définir le quota et les limites",
     "Set Tag": "Définir un tag",
     "Set tag for selected channels": "Définir un tag pour les canaux sélectionnés",
     "Set the user's role (cannot be Root)": "Définir le rôle de l'utilisateur (ne peut pas être Root)",

+ 3 - 1
web/default/src/i18n/locales/ja.json

@@ -365,7 +365,6 @@
     "Auto detect (default)": "自動検出 (デフォルト)",
     "Auto Disabled": "自動無効化",
     "Auto Group Chain": "自動グループチェーン",
-    "Auto group enables circuit breaker mechanism": "自動グループはサーキットブレーカーメカニズムを有効にします",
     "Auto refresh": "自動更新",
     "Auto Sync Upstream Models": "アップストリームモデルの自動同期",
     "Auto-disable status codes": "自動無効化するステータスコード",
@@ -2975,8 +2974,11 @@
     "Set a tag for": "のタグを設定",
     "Set filters to customize your dashboard statistics and charts.": "ダッシュボードの統計とグラフをカスタマイズするためにフィルターを設定します。",
     "Set filters to narrow down your log search results.": "ログ検索結果を絞り込むためにフィルターを設定します。",
+    "Set API key access restrictions": "API キーのアクセス制限を設定",
+    "Set API key basic information": "API キーの基本情報を設定",
     "Set Header": "ヘッダーを設定",
     "Set Project to io.cloud when creating/selecting key": "キーを作成/選択する際にプロジェクトを io.cloud に設定",
+    "Set quota amount and limits": "クォータ量と制限を設定",
     "Set Tag": "タグを設定",
     "Set tag for selected channels": "選択したチャネルにタグを設定",
     "Set the user's role (cannot be Root)": "ユーザーのロールを設定します(Rootにはできません)",

+ 3 - 1
web/default/src/i18n/locales/ru.json

@@ -365,7 +365,6 @@
     "Auto detect (default)": "Автоматическое определение (по умолчанию)",
     "Auto Disabled": "Автоматически отключено",
     "Auto Group Chain": "Автоматическая цепочка групп",
-    "Auto group enables circuit breaker mechanism": "Автоматическая группировка включает механизм автоматического выключателя",
     "Auto refresh": "Автообновление",
     "Auto Sync Upstream Models": "Автоматическая синхронизация моделей провайдера",
     "Auto-disable status codes": "Коды автоотключения",
@@ -2975,8 +2974,11 @@
     "Set a tag for": "Установить тег для",
     "Set filters to customize your dashboard statistics and charts.": "Установите фильтры, чтобы настроить статистику и диаграммы вашей панели управления.",
     "Set filters to narrow down your log search results.": "Установите фильтры, чтобы сузить результаты поиска по журналам.",
+    "Set API key access restrictions": "Настройте ограничения доступа API-ключа",
+    "Set API key basic information": "Настройте основные сведения API-ключа",
     "Set Header": "Установить заголовок",
     "Set Project to io.cloud when creating/selecting key": "Установите Проект в io.cloud при создании/выборе ключа",
+    "Set quota amount and limits": "Настройте квоту и лимиты",
     "Set Tag": "Установить тег",
     "Set tag for selected channels": "Установить тег для выбранных каналов",
     "Set the user's role (cannot be Root)": "Установить роль пользователя (не может быть Root)",

+ 3 - 1
web/default/src/i18n/locales/vi.json

@@ -365,7 +365,6 @@
     "Auto detect (default)": "Tự động phát hiện (mặc định)",
     "Auto Disabled": "Vô hiệu hóa tự động",
     "Auto Group Chain": "Chuỗi nhóm tự động",
-    "Auto group enables circuit breaker mechanism": "Nhóm tự động kích hoạt cơ chế ngắt mạch",
     "Auto refresh": "Tự động làm mới",
     "Auto Sync Upstream Models": "Tự động đồng bộ mô hình nguồn",
     "Auto-disable status codes": "Mã trạng thái tự tắt",
@@ -2975,8 +2974,11 @@
     "Set a tag for": "Gắn thẻ cho",
     "Set filters to customize your dashboard statistics and charts.": "Đặt bộ lọc để tùy chỉnh số liệu thống kê và biểu đồ trên bảng điều khiển của bạn.",
     "Set filters to narrow down your log search results.": "Đặt bộ lọc để thu hẹp kết quả tìm kiếm nhật ký của bạn.",
+    "Set API key access restrictions": "Thiết lập hạn chế truy cập cho khóa API",
+    "Set API key basic information": "Thiết lập thông tin cơ bản cho khóa API",
     "Set Header": "Đặt tiêu đề",
     "Set Project to io.cloud when creating/selecting key": "Đặt Dự án thành io.cloud khi tạo/chọn khóa",
+    "Set quota amount and limits": "Thiết lập hạn mức và giới hạn",
     "Set Tag": "Gán Thẻ",
     "Set tag for selected channels": "Đặt thẻ cho các kênh đã chọn",
     "Set the user's role (cannot be Root)": "Đặt vai trò của người dùng (không được là Root)",

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

@@ -359,13 +359,12 @@
     "Authorization Endpoint (Optional)": "授权端点(可选)",
     "Authorize": "授权",
     "Auto": "自动",
-    "Auto (Circuit Breaker)": "自动(熔断机制)",
+    "Auto (Circuit Breaker)": "自动分组(熔断)",
     "Auto assignment order": "自动分配顺序",
     "Auto Ban": "自动封禁",
     "Auto detect (default)": "自动检测(默认)",
     "Auto Disabled": "自动禁用",
     "Auto Group Chain": "自动分组链",
-    "Auto group enables circuit breaker mechanism": "自动分组启用熔断机制",
     "Auto refresh": "自动刷新",
     "Auto Sync Upstream Models": "自动同步上游模型",
     "Auto-disable status codes": "自动禁用状态码",
@@ -377,7 +376,7 @@
     "Automatically disable channels when tests fail": "当测试失败时自动禁用渠道",
     "Automatically probe all channels in the background": "在后台自动探测所有渠道",
     "Automatically replaces upstream callback URLs with the server address.": "自动将上游回调 URL 替换为服务器地址。",
-    "Automatically selects the best available group with circuit breaker mechanism": "自动选择最佳可用组,带有断路器机制",
+    "Automatically selects the best available group with circuit breaker mechanism": "自动选择可用分组,失败时触发熔断切换",
     "Automatically sync model list when upstream changes are detected": "检测到上游模型变更时自动同步模型列表",
     "Automatically test channels and notify users when limits are hit": "自动测试渠道并在达到限制时通知用户",
     "Available": "可用",
@@ -2975,8 +2974,11 @@
     "Set a tag for": "设置标签为",
     "Set filters to customize your dashboard statistics and charts.": "设置筛选器以自定义您的仪表板统计数据和图表。",
     "Set filters to narrow down your log search results.": "设置筛选器以缩小日志搜索结果范围。",
+    "Set API key access restrictions": "设置令牌的访问限制",
+    "Set API key basic information": "设置令牌的基本信息",
     "Set Header": "设请求头",
     "Set Project to io.cloud when creating/selecting key": "创建/选择密钥时将项目设置为 io.cloud",
+    "Set quota amount and limits": "设置令牌可用额度和数量",
     "Set Tag": "设置标签",
     "Set tag for selected channels": "为选定的渠道设置标签",
     "Set the user's role (cannot be Root)": "设置用户角色(不能是 Root)",