Parcourir la source

fix:静态页面调整及页面国际化

韩洋 il y a 1 mois
Parent
commit
799c61b3d0
33 fichiers modifiés avec 945 ajouts et 312 suppressions
  1. 51 2
      default/docs/permission-identifiers.json
  2. 1 3
      default/src/components/date-picker.tsx
  3. 187 149
      default/src/features/keys/components/api-key-group-combobox.tsx
  4. 14 3
      default/src/features/keys/components/api-keys-mutate-drawer.tsx
  5. 4 2
      default/src/features/keys/lib/api-key-form.ts
  6. 9 9
      default/src/features/pricing/components/pricing-sidebar.tsx
  7. 6 6
      default/src/features/pricing/components/pricing-toolbar.tsx
  8. 3 3
      default/src/features/pricing/constants.ts
  9. 1 1
      default/src/features/pricing/index.tsx
  10. 5 4
      default/src/features/roles/api.ts
  11. 262 16
      default/src/features/roles/index.tsx
  12. 3 3
      default/src/features/system-settings/general/system-info-section.tsx
  13. 147 56
      default/src/features/tenants/index.tsx
  14. 12 5
      default/src/features/usage-logs/components/common-logs-filter-bar.tsx
  15. 3 1
      default/src/features/usage-logs/components/common-logs-stats.tsx
  16. 2 2
      default/src/features/usage-logs/components/task-logs-filter-bar.tsx
  17. 39 7
      default/src/features/usage-logs/components/usage-logs-provider.tsx
  18. 1 1
      default/src/features/usage-logs/components/usage-logs-table.tsx
  19. 15 11
      default/src/features/usage-logs/index.tsx
  20. 1 1
      default/src/features/usage-logs/types.ts
  21. 116 21
      default/src/features/users/components/users-mutate-drawer.tsx
  22. 1 0
      default/src/features/users/lib/user-form.ts
  23. 1 1
      default/src/hooks/use-sidebar-data.ts
  24. 14 0
      default/src/i18n/locales/en.json
  25. 4 0
      default/src/i18n/locales/fr.json
  26. 4 0
      default/src/i18n/locales/ja.json
  27. 4 0
      default/src/i18n/locales/ru.json
  28. 4 0
      default/src/i18n/locales/vi.json
  29. 4 0
      default/src/i18n/locales/zh-TW.json
  30. 10 0
      default/src/i18n/locales/zh.json
  31. 3 0
      default/src/lib/admin-permissions.ts
  32. 1 0
      default/src/lib/landing-page.ts
  33. 13 5
      default/src/routes/_authenticated/usage-logs/$section.tsx

+ 51 - 2
default/docs/permission-identifiers.json

@@ -3,6 +3,8 @@
     "module_name": "总览",
     "module_code": "route.dashboard.view",
     "module_label": "Dashboard",
+    "is_only_open_for_root": 0,
+    "for_role_template_type": 10,
     "children": [
       {
         "perm_name": "查看",
@@ -16,6 +18,8 @@
     "module_name": "渠道管理",
     "module_code": "route.channel.view",
     "module_label": "Channel Management",
+    "is_only_open_for_root": 1,
+    "for_role_template_type": 20,
     "children": [
       {
         "perm_name": "查看",
@@ -53,6 +57,8 @@
     "module_name": "模型管理",
     "module_code": "route.models.view",
     "module_label": "Model Management",
+    "is_only_open_for_root": 1,
+    "for_role_template_type": 20,
     "children": [
       {
         "perm_name": "查看",
@@ -87,9 +93,11 @@
     ]
   },
   {
-    "module_name": "用户管理",
+    "module_name": "平台管理",
     "module_code": "route.user.view",
     "module_label": "User Management",
+    "is_only_open_for_root": 0,
+    "for_role_template_type": 20,
     "children": [
       {
         "perm_name": "查看",
@@ -127,6 +135,8 @@
     "module_name": "平台角色",
     "module_code": "route.role.view",
     "module_label": "Role Management",
+    "is_only_open_for_root": 0,
+    "for_role_template_type": 30,
     "children": [
       {
         "perm_name": "查看",
@@ -158,6 +168,8 @@
     "module_name": "租户管理",
     "module_code": "route.tenant.view",
     "module_label": "Tenant Management",
+    "is_only_open_for_root": 0,
+    "for_role_template_type": 20,
     "children": [
       {
         "perm_name": "查看",
@@ -189,6 +201,8 @@
     "module_name": "个人信息",
     "module_code": "route.profile.view",
     "module_label": "Profile",
+    "is_only_open_for_root": 0,
+    "for_role_template_type": 10,
     "children": [
       {
         "perm_name": "查看",
@@ -202,6 +216,8 @@
     "module_name": "API 密钥",
     "module_code": "route.keys.view",
     "module_label": "API Keys",
+    "is_only_open_for_root": 0,
+    "for_role_template_type": 10,
     "children": [
       {
         "perm_name": "查看",
@@ -239,6 +255,8 @@
     "module_name": "模型列表",
     "module_code": "route.pricing.view",
     "module_label": "Model List",
+    "is_only_open_for_root": 0,
+    "for_role_template_type": 10,
     "children": [
       {
         "perm_name": "查看",
@@ -252,12 +270,41 @@
     "module_name": "调用日志",
     "module_code": "route.usage-logs.view",
     "module_label": "Usage Logs",
+    "is_only_open_for_root": 0,
+    "for_role_template_type": 10,
     "children": [
       {
-        "perm_name": "查看",
+        "perm_name": "查看(仅自己)",
         "front_perm_code": "usage-logs.view",
         "api_perm_code": "",
         "perm_label": "View Usage Logs"
+      },
+      {
+        "perm_name": "查看(全部数据)",
+        "front_perm_code": "usage-logs.all",
+        "api_perm_code": "",
+        "perm_label": "View All Usage Logs"
+      }
+    ]
+  },
+  {
+    "module_name": "审计日志",
+    "module_code": "route.audit-logs.view",
+    "module_label": "Audit Logs",
+    "is_only_open_for_root": 0,
+    "for_role_template_type": 10,
+    "children": [
+      {
+        "perm_name": "查看(仅自己)",
+        "front_perm_code": "audit-logs.view",
+        "api_perm_code": "",
+        "perm_label": "View Audit Logs"
+      },
+      {
+        "perm_name": "查看(全部数据)",
+        "front_perm_code": "audit-logs.all",
+        "api_perm_code": "",
+        "perm_label": "View All Audit Logs"
       }
     ]
   },
@@ -265,6 +312,8 @@
     "module_name": "系统设置",
     "module_code": "route.settings.view",
     "module_label": "System Settings",
+    "is_only_open_for_root": 1,
+    "for_role_template_type": 20,
     "children": [
       {
         "perm_name": "查看",

+ 1 - 3
default/src/components/date-picker.tsx

@@ -78,9 +78,7 @@ export function DatePicker({
           selected={selected}
           onSelect={onSelect}
           locale={calendarLocale}
-          disabled={(date: Date) =>
-            date > new Date() || date < new Date('1900-01-01')
-          }
+          disabled={(date: Date) => date < new Date('1900-01-01')}
         />
       </PopoverContent>
     </Popover>

+ 187 - 149
default/src/features/keys/components/api-key-group-combobox.tsx

@@ -16,25 +16,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
 
 For commercial licensing, please contact support@quantumnous.com
 */
-import { Check, ChevronsUpDown } from 'lucide-react'
-import { useMemo, useState } from 'react'
+import { ListChecks, XCircle } from 'lucide-react'
+import { useState } from 'react'
 import { useTranslation } from 'react-i18next'
 
-import { Badge } from '@/components/ui/badge'
+import { MultiSelect, type Option } from '@/components/multi-select'
 import { Button } from '@/components/ui/button'
-import {
-  Command,
-  CommandEmpty,
-  CommandGroup,
-  CommandInput,
-  CommandItem,
-  CommandList,
-} from '@/components/ui/command'
-import {
-  Popover,
-  PopoverContent,
-  PopoverTrigger,
-} from '@/components/ui/popover'
+import { Checkbox } from '@/components/ui/checkbox'
 import { cn } from '@/lib/utils'
 
 export type ApiKeyGroupOption = {
@@ -50,143 +38,151 @@ type ApiKeyGroupComboboxProps = {
   onValueChange: (value: string) => void
   placeholder?: string
   disabled?: boolean
-}
-
-function formatGroupRatio(
-  ratio: ApiKeyGroupOption['ratio'],
-  ratioLabel: string
-) {
-  if (ratio === undefined || ratio === null || ratio === '') return null
-  return `${ratio}x ${ratioLabel}`
-}
-
-function getRatioBadgeClassName(ratio: ApiKeyGroupOption['ratio']) {
-  if (typeof ratio !== 'number') {
-    return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/40 dark:text-emerald-300'
-  }
-
-  if (ratio > 5) {
-    return 'border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-900/60 dark:bg-rose-950/40 dark:text-rose-300'
-  }
-  if (ratio > 3) {
-    return 'border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-900/60 dark:bg-orange-950/40 dark:text-orange-300'
-  }
-  if (ratio > 1) {
-    return 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/60 dark:bg-blue-950/40 dark:text-blue-300'
-  }
-  return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/40 dark:text-emerald-300'
-}
-
-function GroupRatioBadge({ ratio }: { ratio: ApiKeyGroupOption['ratio'] }) {
-  const { t } = useTranslation()
-  const label = formatGroupRatio(ratio, t('Ratio'))
-
-  if (!label) return null
-
-  return (
-    <Badge
-      variant='outline'
-      className={cn(
-        'max-w-24 shrink-0 truncate text-[10px] sm:max-w-none sm:text-xs',
-        getRatioBadgeClassName(ratio)
-      )}
-    >
-      {label}
-    </Badge>
-  )
+  multiple?: boolean
+  showModelSelector?: boolean
+} & Omit<React.ComponentProps<'div'>, 'onChange'>
+
+const MOCK_MODEL_OPTIONS: Option[] = [
+  { value: 'gpt-4o', label: 'GPT-4o' },
+  { value: 'gpt-4o-mini', label: 'GPT-4o mini' },
+  { value: 'claude-3-5-sonnet', label: 'Claude 3.5 Sonnet' },
+  { value: 'gemini-1.5-pro', label: 'Gemini 1.5 Pro' },
+  { value: 'deepseek-v3', label: 'DeepSeek V3' },
+]
+
+// function formatGroupRatio(
+//   ratio: ApiKeyGroupOption['ratio'],
+//   ratioLabel: string
+// ) {
+//   if (ratio === undefined || ratio === null || ratio === '') return null
+//   return `${ratio}x ${ratioLabel}`
+// }
+
+// function getRatioBadgeClassName(ratio: ApiKeyGroupOption['ratio']) {
+//   if (typeof ratio !== 'number') {
+//     return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/40 dark:text-emerald-300'
+//   }
+
+//   if (ratio > 5) {
+//     return 'border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-900/60 dark:bg-rose-950/40 dark:text-rose-300'
+//   }
+//   if (ratio > 3) {
+//     return 'border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-900/60 dark:bg-orange-950/40 dark:text-orange-300'
+//   }
+//   if (ratio > 1) {
+//     return 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/60 dark:bg-blue-950/40 dark:text-blue-300'
+//   }
+//   return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/40 dark:text-emerald-300'
+// }
+
+// function GroupRatioBadge({ ratio }: { ratio: ApiKeyGroupOption['ratio'] }) {
+//   const { t } = useTranslation()
+//   const label = formatGroupRatio(ratio, t('Ratio'))
+
+//   if (!label) return null
+
+//   return (
+//     <Badge
+//       variant='outline'
+//       className={cn(
+//         'max-w-24 shrink-0 truncate text-[10px] sm:max-w-none sm:text-xs',
+//         getRatioBadgeClassName(ratio)
+//       )}
+//     >
+//       {label}
+//     </Badge>
+//   )
+// }
+
+function getSelectedGroups(value?: string) {
+  return (value || '')
+    .split(',')
+    .map((item) => item.trim())
+    .filter(Boolean)
 }
 
 export function ApiKeyGroupCombobox({
   options,
   value,
   onValueChange,
-  placeholder,
   disabled,
+  multiple = true,
+  showModelSelector = true,
+  className,
+  ...props
 }: ApiKeyGroupComboboxProps) {
   const { t } = useTranslation()
-  const [open, setOpen] = useState(false)
-  const [searchValue, setSearchValue] = useState('')
-  const selectedOption = options.find((option) => option.value === value)
-
-  const filteredOptions = useMemo(() => {
-    const search = searchValue.trim().toLowerCase()
-    if (!search) return options
-
-    return options.filter((option) => {
-      const ratioText = String(option.ratio ?? '').toLowerCase()
-      return (
-        option.value.toLowerCase().includes(search) ||
-        option.label.toLowerCase().includes(search) ||
-        option.desc?.toLowerCase().includes(search) ||
-        ratioText.includes(search)
-      )
-    })
-  }, [options, searchValue])
-
-  const handleSelect = (selectedValue: string) => {
-    onValueChange(selectedValue)
-    setOpen(false)
-    setSearchValue('')
+  const selectedGroups = getSelectedGroups(value)
+  const selectedGroupSet = new Set(selectedGroups)
+  const [selectedModelsByGroup, setSelectedModelsByGroup] = useState<
+    Record<string, string[]>
+  >({})
+
+  const handleToggle = (optionValue: string) => {
+    if (!multiple) {
+      onValueChange(optionValue)
+      return
+    }
+
+    const nextGroups = selectedGroupSet.has(optionValue)
+      ? selectedGroups.filter((group) => group !== optionValue)
+      : [...selectedGroups, optionValue]
+
+    onValueChange(nextGroups.join(','))
+  }
+
+  const handleSelectAllModels = (groupValue: string) => {
+    setSelectedModelsByGroup((current) => ({
+      ...current,
+      [groupValue]: MOCK_MODEL_OPTIONS.map((option) => option.value),
+    }))
+  }
+
+  const handleClearAllModels = (groupValue: string) => {
+    setSelectedModelsByGroup((current) => ({
+      ...current,
+      [groupValue]: [],
+    }))
+  }
+
+  const handleModelsChange = (groupValue: string, models: string[]) => {
+    setSelectedModelsByGroup((current) => ({
+      ...current,
+      [groupValue]: models,
+    }))
   }
 
   return (
-    <Popover open={open} onOpenChange={setOpen}>
-      <PopoverTrigger
-        render={
-          <Button
-            type='button'
-            variant='outline'
-            role='combobox'
-            aria-expanded={open}
-            disabled={disabled}
-            className='border-input bg-muted/40 hover:bg-muted/55 hover:text-foreground active:bg-background data-popup-open:border-ring data-popup-open:bg-background data-popup-open:ring-ring/20 h-auto min-h-14 w-full justify-between gap-2 rounded-lg px-3 py-2 text-start shadow-none transition-[background-color,border-color,box-shadow] duration-150 data-popup-open:ring-[3px] sm:min-h-20 sm:gap-3 sm:px-4 sm:py-3'
-          />
-        }
-      >
-        <span className='flex min-w-0 flex-1 items-center justify-between gap-2 sm:gap-3'>
-          <span className='min-w-0'>
-            <span className='block truncate font-medium'>
-              {selectedOption?.label || placeholder || t('Select a group')}
-            </span>
-            {selectedOption?.desc && (
-              <span className='text-muted-foreground block truncate text-[11px] sm:text-xs'>
-                {selectedOption.desc}
-              </span>
-            )}
-          </span>
-          <span className='hidden sm:block'>
-            <GroupRatioBadge ratio={selectedOption?.ratio} />
-          </span>
-        </span>
-        <ChevronsUpDown className='h-4 w-4 shrink-0 opacity-50' />
-      </PopoverTrigger>
-      <PopoverContent
-        className='data-closed:zoom-out-100 data-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(--anchor-width)] overflow-hidden rounded-xl p-0 shadow-lg data-closed:duration-75 data-open:duration-100'
-        onWheel={(event) => event.stopPropagation()}
-        onTouchMove={(event) => event.stopPropagation()}
-        onPointerDown={(event) => event.stopPropagation()}
-      >
-        <Command shouldFilter={false}>
-          <CommandInput
-            placeholder={t('Search...')}
-            value={searchValue}
-            onValueChange={setSearchValue}
-          />
-          <CommandList className='max-h-[360px]'>
-            <CommandEmpty>{t('No group found.')}</CommandEmpty>
-            <CommandGroup>
-              {filteredOptions.map((option) => (
-                <CommandItem
-                  key={option.value}
-                  value={option.value}
-                  onSelect={() => handleSelect(option.value)}
-                  className='data-[selected=true]:bg-muted items-start gap-3 rounded-lg px-3 py-3 transition-colors'
-                >
-                  <Check
-                    className={cn(
-                      'mt-0.5 h-4 w-4',
-                      value === option.value ? 'opacity-100' : 'opacity-0'
-                    )}
+    <div
+      className={cn(
+        'border-input bg-muted/30 grid max-h-[360px] w-full min-w-0 gap-2 overflow-y-auto rounded-xl border p-2',
+        className
+      )}
+      {...props}
+    >
+      {options.length > 0 ? (
+        options.map((option) => {
+          const checked = selectedGroupSet.has(option.value)
+          const selectedModels = selectedModelsByGroup[option.value] ?? []
+
+          return (
+            <div
+              key={option.value}
+              className={cn(
+                'hover:bg-muted/65 grid w-full gap-3 rounded-lg border px-3 py-3 transition-colors',
+                checked
+                  ? 'border-primary/45 bg-primary/5 shadow-sm'
+                  : 'border-transparent bg-background/60',
+                disabled && 'pointer-events-none opacity-60'
+              )}
+            >
+              <div className='flex items-start gap-3'>
+                <label className='flex min-w-0 flex-1 cursor-pointer items-start gap-3'>
+                  <Checkbox
+                    checked={checked}
+                    disabled={disabled}
+                    onCheckedChange={() => handleToggle(option.value)}
+                    className='mt-0.5'
                   />
                   <span className='min-w-0 flex-1'>
                     <span className='block truncate font-medium'>
@@ -198,13 +194,55 @@ export function ApiKeyGroupCombobox({
                       </span>
                     )}
                   </span>
-                  <GroupRatioBadge ratio={option.ratio} />
-                </CommandItem>
-              ))}
-            </CommandGroup>
-          </CommandList>
-        </Command>
-      </PopoverContent>
-    </Popover>
+                </label>
+                {showModelSelector && (
+                  <span className='flex shrink-0 items-center gap-1.5'>
+                    <Button
+                      type='button'
+                      variant='ghost'
+                      size='sm'
+                      disabled={disabled || MOCK_MODEL_OPTIONS.length === 0}
+                      onClick={() => handleSelectAllModels(option.value)}
+                      className='text-primary hover:text-primary h-7 px-2 text-xs'
+                    >
+                      <ListChecks className='size-3.5' aria-hidden='true' />
+                      {t('All Models')}
+                    </Button>
+                    <Button
+                      type='button'
+                      variant='ghost'
+                      size='sm'
+                      disabled={disabled || selectedModels.length === 0}
+                      onClick={() => handleClearAllModels(option.value)}
+                      className='text-muted-foreground hover:text-foreground h-7 px-2 text-xs'
+                    >
+                      <XCircle className='size-3.5' aria-hidden='true' />
+                      {t('Clear all')}
+                    </Button>
+                  </span>
+                )}
+              </div>
+              {showModelSelector && (
+                <MultiSelect
+                  options={MOCK_MODEL_OPTIONS}
+                  selected={selectedModels}
+                  onChange={(models) =>
+                    handleModelsChange(option.value, models)
+                  }
+                  placeholder={t('Select items...')}
+                  disabled={disabled}
+                  maxVisibleChips={2}
+                />
+              )}
+              {/* <GroupRatioBadge ratio={option.ratio} /> */}
+            </div>
+          )
+        })
+      ) : (
+        <div className='text-muted-foreground px-3 py-4 text-center text-sm'>
+          {t('No group found.')}
+        </div>
+      )}
+    </div>
   )
 }

+ 14 - 3
default/src/features/keys/components/api-keys-mutate-drawer.tsx

@@ -133,11 +133,16 @@ export function ApiKeysMutateDrawer({
     }
   }, [open, isUpdate, currentRow, form, defaultUseAutoGroup, backendHasAuto])
 
-  // Correct group after groups load: if the form value is not in available groups, fall back
   useEffect(() => {
     if (groups.length === 0) return
     const currentGroup = form.getValues('group')
-    if (currentGroup && !groups.some((g) => g.value === currentGroup)) {
+    const availableGroups = new Set(groups.map((g) => g.value))
+    const validGroups = (currentGroup || '')
+      .split(',')
+      .map((group) => group.trim())
+      .filter((group) => availableGroups.has(group))
+
+    if (currentGroup && validGroups.length === 0) {
       const fallback =
         groups.find((g) => g.value === 'default')?.value ??
         groups[0]?.value ??
@@ -146,6 +151,8 @@ export function ApiKeysMutateDrawer({
       if (currentGroup === 'auto') {
         form.setValue('cross_group_retry', false)
       }
+    } else if (currentGroup && validGroups.join(',') !== currentGroup) {
+      form.setValue('group', validGroups.join(','))
     }
   }, [groups, form])
 
@@ -230,6 +237,10 @@ export function ApiKeysMutateDrawer({
     ? t('Enter quota in tokens')
     : t('Enter quota in {{currency}}', { currency: currencyLabel })
   const selectedGroup = form.watch('group')
+  const selectedGroups = (selectedGroup || '')
+    .split(',')
+    .map((group) => group.trim())
+    .filter(Boolean)
   const unlimitedQuota = form.watch('unlimited_quota')
 
   return (
@@ -301,7 +312,7 @@ export function ApiKeysMutateDrawer({
                 )}
               />
 
-              {selectedGroup === 'auto' && (
+              {selectedGroups.includes('auto') && (
                 <FormField
                   control={form.control}
                   name='cross_group_retry'

+ 4 - 2
default/src/features/keys/lib/api-key-form.ts

@@ -22,7 +22,7 @@ import { z } from 'zod'
 import { parseQuotaFromDollars, quotaUnitsToDollars } from '@/lib/format'
 
 import { DEFAULT_GROUP } from '../constants'
-import { type ApiKeyFormData, type ApiKey } from '../types'
+import type { ApiKey, ApiKeyFormData } from '../types'
 
 // ============================================================================
 // Form Schema
@@ -110,7 +110,9 @@ export function transformFormDataToPayload(
     model_limits: data.model_limits.join(','),
     allow_ips: data.allow_ips || '',
     group: data.group || '',
-    cross_group_retry: data.group === 'auto' ? !!data.cross_group_retry : false,
+    cross_group_retry: (data.group || '').split(',').includes('auto')
+      ? !!data.cross_group_retry
+      : false,
   }
 }
 

+ 9 - 9
default/src/features/pricing/components/pricing-sidebar.tsx

@@ -198,7 +198,7 @@ export function PricingSidebar(props: PricingSidebarProps) {
   const vendorOptions: FilterOption[] = [
     {
       value: FILTER_ALL,
-      label: t('全部供应商'),
+      label: t('All vendors'),
       count: props.models.length,
     },
     ...props.vendors
@@ -217,7 +217,7 @@ export function PricingSidebar(props: PricingSidebarProps) {
   const groupOptions: FilterOption[] = [
     {
       value: FILTER_ALL,
-      label: t('全部分组'),
+      label: t('All groups'),
     },
     ...props.groups.map((group) => ({
       value: group,
@@ -229,7 +229,7 @@ export function PricingSidebar(props: PricingSidebarProps) {
   const tagOptions: FilterOption[] = [
     {
       value: FILTER_ALL,
-      label: t('全部标签'),
+      label: t('All tags'),
       count: props.models.length,
     },
     ...props.tags.map((tag) => ({
@@ -256,24 +256,24 @@ export function PricingSidebar(props: PricingSidebarProps) {
             value={props.searchValue}
             onChange={props.onSearchChange}
             onClear={props.onSearchClear}
-            placeholder={t('搜索模型名称、供应商或标签...')}
+            placeholder={t('Search model name, provider, or tags...')}
             className='w-full sm:max-w-[360px]'
           />
           <div className='grid min-w-0 flex-1 grid-cols-1 gap-2 sm:grid-cols-3 lg:flex lg:flex-none'>
             <FilterSelect
-              title={t('分组')}
+              title={t('Group')}
               value={props.groupFilter}
               options={groupOptions}
               onChange={props.onGroupChange}
             />
             <FilterSelect
-              title={t('供应商')}
+              title={t('Vendor')}
               value={props.vendorFilter}
               options={vendorOptions}
               onChange={props.onVendorChange}
             />
             <FilterSelect
-              title={t('模型标签')}
+              title={t('Model Tags')}
               value={props.tagFilter}
               options={tagOptions}
               onChange={props.onTagChange}
@@ -283,7 +283,7 @@ export function PricingSidebar(props: PricingSidebarProps) {
         <div className='flex shrink-0 items-center gap-2'>
           {props.hasActiveFilters && (
             <Badge variant='secondary' className='hidden sm:inline-flex'>
-              {t('已启用筛选')}
+              {t('Filters active')}
             </Badge>
           )}
           <Button
@@ -295,7 +295,7 @@ export function PricingSidebar(props: PricingSidebarProps) {
             className='h-7 w-full gap-1.5 px-2 text-xs sm:w-auto'
           >
             <RotateCcw className='size-3.5' />
-            {t('重置')}
+            {t('Reset')}
           </Button>
         </div>
       </div>

+ 6 - 6
default/src/features/pricing/components/pricing-toolbar.tsx

@@ -176,7 +176,7 @@ export function PricingToolbar(props: PricingToolbarProps) {
             className='gap-1.5 xl:hidden'
           >
             <Filter className='size-4' />
-            {t('筛选')}
+            {t('Filter')}
             {props.activeFilterCount > 0 && (
               <Badge className='ml-0.5 size-5 justify-center p-0 text-[10px]'>
                 {props.activeFilterCount}
@@ -188,7 +188,7 @@ export function PricingToolbar(props: PricingToolbarProps) {
             <span className='text-foreground font-semibold tabular-nums'>
               {props.filteredCount.toLocaleString()}
             </span>
-            <span>{t('个模型')}</span>
+            <span>{t('models')}</span>
             {props.hasActiveFilters && props.totalCount && (
               <span className='text-muted-foreground/60 text-xs'>
                 / {props.totalCount.toLocaleString()}
@@ -201,8 +201,8 @@ export function PricingToolbar(props: PricingToolbarProps) {
           <div className='hidden items-center gap-2 sm:flex'>
             <SegmentedControl
               options={[
-                { value: 'standard', label: t('标准价格') },
-                { value: 'recharge', label: t('充值价格') },
+                { value: 'standard', label: t('Standard Price') },
+                { value: 'recharge', label: t('Recharge Price') },
               ]}
               value={props.showRechargePrice ? 'recharge' : 'standard'}
               onChange={handleRechargePriceChange}
@@ -278,9 +278,9 @@ export function PricingToolbar(props: PricingToolbarProps) {
           className={sideDrawerContentClassName('sm:max-w-md')}
         >
           <SheetHeader className={sideDrawerHeaderClassName()}>
-            <SheetTitle>{t('筛选')}</SheetTitle>
+            <SheetTitle>{t('Filter')}</SheetTitle>
             <SheetDescription>
-              {t('按供应商、分组和标签筛选模型')}
+              {t('Filter models by vendor, group and tags.')}
             </SheetDescription>
           </SheetHeader>
           <div className={sideDrawerFormClassName('gap-0')}>

+ 3 - 3
default/src/features/pricing/constants.ts

@@ -35,9 +35,9 @@ export type SortOption = (typeof SORT_OPTIONS)[keyof typeof SORT_OPTIONS]
 
 export function getSortLabels(t: TFunction): Record<SortOption, string> {
   return {
-    [SORT_OPTIONS.NAME]: t('名称'),
-    [SORT_OPTIONS.PRICE_LOW]: t('价格从低到高'),
-    [SORT_OPTIONS.PRICE_HIGH]: t('价格从高到低'),
+    [SORT_OPTIONS.NAME]: t('Name'),
+    [SORT_OPTIONS.PRICE_LOW]: t('Price: Low to High'),
+    [SORT_OPTIONS.PRICE_HIGH]: t('Price: High to Low'),
   }
 }
 

+ 1 - 1
default/src/features/pricing/index.tsx

@@ -178,7 +178,7 @@ export function PricingContent({
       <PageTransition className='relative mx-auto w-full max-w-[1800px] px-3 pt-16 pb-8 sm:px-6 sm:pt-2 sm:pb-10 xl:px-8'>
         <header className='shrink-0 px-3 pt-3 pb-2 sm:px-4 sm:pt-5 sm:pb-2.5'>
           <h2 className='text-base font-bold tracking-tight sm:text-lg'>
-            {t('模型列表')}
+            {t('Model Square')}
           </h2>
         </header>
 

+ 5 - 4
default/src/features/roles/api.ts

@@ -120,8 +120,9 @@ function normalizeGetRolesResponse(
 }
 
 function normalizePermissionCode(moduleCode: string, permissionCode: string) {
-  if (permissionCode.includes('.') || permissionCode.includes(':'))
+  if (permissionCode.includes('.') || permissionCode.includes(':')) {
     return permissionCode
+  }
   return `${moduleCode}.${permissionCode}`
 }
 
@@ -175,7 +176,7 @@ export async function searchRoles(
   return res.data
 }
 
-export async function getRole(id: number): Promise<ApiResponse<Role>> {
+export async function getRole(id: number | string): Promise<ApiResponse<Role>> {
   const res = await api.get<RawRoleResponse>(`/api/roles/${id}`)
   return {
     ...res.data,
@@ -191,14 +192,14 @@ export async function createRole(
 }
 
 export async function updateRole(
-  id: number,
+  id: number | string,
   data: RoleFormData
 ): Promise<ApiResponse<Role>> {
   const res = await api.put(`/api/roles/${id}`, toCreateRolePayload(data))
   return res.data
 }
 
-export async function deleteRole(id: number): Promise<ApiResponse> {
+export async function deleteRole(id: number | string): Promise<ApiResponse> {
   const res = await api.delete(`/api/roles/${id}`, {
     skipBusinessError: true,
   } as ApiRequestConfig)

+ 262 - 16
default/src/features/roles/index.tsx

@@ -71,7 +71,11 @@ import { Textarea } from '@/components/ui/textarea'
 import { PERMISSION_CODES, hasPermissionCode } from '@/lib/admin-permissions'
 import { useAuthStore } from '@/stores/auth-store'
 
-import { getPermissions, type PermissionItem } from '../permissions/api'
+import {
+  getPermissions,
+  getPermissionsWithRole,
+  type PermissionItem,
+} from '../permissions/api'
 import { getRoles, getRole, createRole, updateRole, deleteRole } from './api'
 import type { Role } from './types'
 
@@ -178,22 +182,75 @@ function getPermissionResourceKey(permission: PermissionItem) {
   return key.includes('.') ? key.split('.')[0] : key
 }
 
+function getPermissionResourceKeyFromCode(key: string) {
+  if (!key.startsWith('route.')) {
+    return key.includes('.') ? key.split('.')[0] : key
+  }
+
+  const routePermissionMatch = key.match(/^route\.(.+)\.view$/)
+  return routePermissionMatch?.[1] ?? key.split('.')[1] ?? key
+}
+
+function getRoutePermissionResourceKey(permission: PermissionItem) {
+  return getPermissionResourceKeyFromCode(getPermissionKey(permission))
+}
+
 function getPermissionModuleKey(
   permission: PermissionItem,
   actions: RolePermissionModule['actions']
 ) {
-  const actionKey = actions.find((action) => action.key.includes('.'))?.key
-  if (actionKey) return actionKey.split('.')[0]
+  const routeResourceKey = getRoutePermissionResourceKey(permission)
+  if (routeResourceKey !== getPermissionResourceKey(permission)) {
+    return routeResourceKey
+  }
+
+  const action = actions.find((item) => item.key.includes('.'))
+  if (action) return getPermissionResourceKeyFromCode(action.key)
 
   const key = getPermissionKey(permission)
   if (key.startsWith('route.')) {
-    const parts = key.split('.')
-    return parts[1] ?? key
+    return getRoutePermissionResourceKey(permission)
   }
 
   return getPermissionResourceKey(permission)
 }
 
+function permissionHasRole(permission: PermissionItem) {
+  return (
+    permission.has_permission ??
+    permission.checked ??
+    permission.selected ??
+    false
+  )
+}
+
+function toRoleFormPermissions(permissions: PermissionItem[]) {
+  return toRolePermissionModules(permissions).reduce<RoleFormValues['permissions']>(
+    (formPermissions, module) => {
+      const sourceModule = permissions.find(
+        (permission) =>
+          getPermissionModuleKey(permission, module.actions) === module.key
+      )
+      const actionsSource =
+        sourceModule?.module_relation_permissions ??
+        sourceModule?.actions ??
+        sourceModule?.children ??
+        sourceModule?.permissions ??
+        []
+      formPermissions[module.key] = actionsSource
+        .filter(
+          (action) =>
+            permissionHasRole(action) ||
+            permissionHasRole(action.ExtPermission ?? action)
+        )
+        .map((action) => getPermissionKey(action.ExtPermission ?? action))
+        .filter((key) => key)
+      return formPermissions
+    },
+    {}
+  )
+}
+
 function getViewPermissionKey(module: RolePermissionModule) {
   const viewAction = module.actions.find(
     (action) => action.key.endsWith('.view') || action.key === 'view'
@@ -201,6 +258,60 @@ function getViewPermissionKey(module: RolePermissionModule) {
   return viewAction?.key ?? module.viewPermissionKey ?? `${module.key}.view`
 }
 
+function getOwnDataViewPermissionKey(module: RolePermissionModule) {
+  return module.actions.find((action) => isOwnDataViewAction(action))?.key
+}
+
+function getAllDataViewPermissionKey(module: RolePermissionModule) {
+  return module.actions.find((action) => isAllDataViewAction(action))?.key
+}
+
+function isOwnDataViewAction(action: RolePermissionModule['actions'][number]) {
+  const actionKey = action.key.toLowerCase()
+  const actionLabel = action.label.toLowerCase()
+
+  if (actionLabel.includes('仅自己') || actionLabel.includes('only mine')) {
+    return true
+  }
+  if (actionLabel.includes('全部数据') || actionLabel.includes('all data')) {
+    return false
+  }
+
+  return (
+    actionKey.includes('view_self') ||
+    actionKey.includes('view_own') ||
+    actionKey.includes('read_self') ||
+    actionKey.includes('read_own') ||
+    actionKey.includes('only_mine') ||
+    actionKey.includes('.self.') ||
+    actionKey.includes('.own.') ||
+    actionKey.endsWith('.self') ||
+    actionKey.endsWith('.own') ||
+    actionLabel.includes('own data') ||
+    actionLabel.includes('self data')
+  )
+}
+
+function isAllDataViewAction(action: RolePermissionModule['actions'][number]) {
+  const actionKey = action.key.toLowerCase()
+  const actionLabel = action.label.toLowerCase()
+
+  if (actionLabel.includes('全部数据') || actionLabel.includes('all data')) {
+    return true
+  }
+  if (actionLabel.includes('仅自己') || actionLabel.includes('only mine')) {
+    return false
+  }
+
+  return (
+    actionKey.includes('view_all') ||
+    actionKey.includes('read_all') ||
+    actionKey.includes('all_data') ||
+    actionKey.includes('.all.') ||
+    actionKey.endsWith('.all')
+  )
+}
+
 function isRolePermissionModule(module: RolePermissionModule) {
   return module.key === 'role' || module.key === 'roles'
 }
@@ -254,6 +365,105 @@ function getPermissionTitle(permission: PermissionItem, fallback: string) {
   )
 }
 
+const PERMISSION_MODULE_TITLE_MAP: Record<string, string> = {
+  dashboard: 'Dashboard',
+  channel: 'Channel Management',
+  models: 'Model Management',
+  model: 'Model Management',
+  user: 'User Management',
+  role: 'Platform Roles',
+  tenant: 'Tenant Management',
+  finance: 'Finance Management',
+  billing: 'Finance Management',
+  wallet: 'Finance Management',
+  pricing: 'Pricing',
+  key: 'Keys',
+  keys: 'Keys',
+  profile: 'Profile',
+  settings: 'Settings',
+  'usage-logs': 'Usage Logs',
+  'audit-logs': 'Audit Logs',
+}
+
+const PERMISSION_ACTION_TITLE_MAP: Record<string, string> = {
+  view: 'View',
+  create: 'Create',
+  update: 'Edit',
+  edit: 'Edit',
+  delete: 'Delete',
+  disable: 'Disable',
+  enable: 'Enable',
+  export: 'Export',
+  audit: 'Audit',
+  all: 'All',
+  own: 'Own',
+}
+
+const PERMISSION_MODULE_FALLBACK_MAP: Record<string, string> = {
+  用户管理: 'User Management',
+  平台角色: 'Platform Roles',
+  租户管理: 'Tenant Management',
+  渠道管理: 'Channel Management',
+  模型管理: 'Model Management',
+  财务管理: 'Finance Management',
+}
+
+const PERMISSION_ACTION_FALLBACK_MAP: Record<string, string> = {
+  查看: 'View',
+  新增: 'Create',
+  编辑: 'Edit',
+  删除: 'Delete',
+  禁用: 'Disable',
+  启用: 'Enable',
+  导出: 'Export',
+  审计: 'Audit',
+}
+
+function getPermissionModuleSegment(permission: PermissionItem) {
+  const moduleCode =
+    permission.module_code ??
+    permission.permission_code ??
+    permission.permission_key ??
+    permission.front_perm_code ??
+    ''
+
+  return moduleCode
+    .replace(/^route\./, '')
+    .split('.')
+    .filter(Boolean)[0]
+}
+
+function getPermissionActionSegment(permission: PermissionItem) {
+  const actionCode =
+    permission.front_perm_code ??
+    permission.permission_code ??
+    permission.permission_key ??
+    ''
+
+  const segments = actionCode.split('.').filter(Boolean)
+  return segments[segments.length - 1]
+}
+
+function getLocalizedPermissionModuleTitle(permission: PermissionItem, fallback: string) {
+  const moduleSegment = getPermissionModuleSegment(permission)
+  if (moduleSegment && PERMISSION_MODULE_TITLE_MAP[moduleSegment]) {
+    return PERMISSION_MODULE_TITLE_MAP[moduleSegment]
+  }
+
+  const rawTitle = getPermissionTitle(permission, fallback)
+  return PERMISSION_MODULE_FALLBACK_MAP[rawTitle] ?? rawTitle
+}
+
+function getLocalizedPermissionActionTitle(permission: PermissionItem, fallback: string) {
+  const actionSegment = getPermissionActionSegment(permission)
+  if (actionSegment && PERMISSION_ACTION_TITLE_MAP[actionSegment]) {
+    return PERMISSION_ACTION_TITLE_MAP[actionSegment]
+  }
+
+  const rawTitle = getPermissionTitle(permission, fallback)
+  return PERMISSION_ACTION_FALLBACK_MAP[rawTitle] ?? rawTitle
+}
+
 function getPermissionDescription(permission: PermissionItem) {
   return (
     permission.module_desc ??
@@ -303,7 +513,7 @@ function toRolePermissionModules(permissions: PermissionItem[]) {
           return {
             id: getPermissionId(permissionAction),
             key: getPermissionKey(permissionAction),
-            label: getPermissionTitle(
+            label: getLocalizedPermissionActionTitle(
               permissionAction,
               getPermissionKey(permissionAction)
             ),
@@ -315,7 +525,7 @@ function toRolePermissionModules(permissions: PermissionItem[]) {
       return {
         id: getPermissionId(permission),
         key,
-        title: getPermissionTitle(permission, key),
+        title: getLocalizedPermissionModuleTitle(permission, key),
         description: getPermissionDescription(permission),
         viewPermissionKey: getPermissionKey(permission),
         actions,
@@ -379,6 +589,15 @@ export function RolesSettings() {
         permissions: result.data.permissions ?? {},
       })
     } catch {}
+
+    try {
+      const permissions = await getPermissionsWithRole(role.id)
+      form.reset({
+        name: role.name,
+        description: role.description || '',
+        permissions: toRoleFormPermissions(permissions),
+      })
+    } catch {}
   }
 
   // #region debug-point role-actions-open-state
@@ -745,7 +964,7 @@ export function RolesSettings() {
         } catch {}
         setLocalRoles((prev) => [
           {
-            id: Math.max(0, ...prev.map((role) => role.id)) + 1,
+            id: Math.max(0, ...prev.map((role) => Number(role.id) || 0)) + 1,
             name: submitData.name,
             description: submitData.description,
             permission_modules_count: Object.keys(submitData.permissions)
@@ -873,6 +1092,10 @@ export function RolesSettings() {
                         selectableActionKeys.includes(permission)
                       )
                       const viewPermissionKey = getViewPermissionKey(module)
+                      const ownDataViewPermissionKey =
+                        getOwnDataViewPermissionKey(module)
+                      const allDataViewPermissionKey =
+                        getAllDataViewPermissionKey(module)
                       const isAllSelected =
                         selectableActionKeys.length > 0 &&
                         selectableActionKeys.every((actionKey) =>
@@ -905,17 +1128,21 @@ export function RolesSettings() {
                             />
                           </div>
                           {module.actions.length ? (
-                            <div className='grid grid-cols-2 gap-2 sm:grid-cols-4'>
+                            <div className='flex flex-wrap gap-2'>
                               {module.actions.map((action) => {
                                 const isViewAction =
                                   action.key === viewPermissionKey
+                                const isOwnDataViewAction =
+                                  action.key === ownDataViewPermissionKey
+                                const isAllDataViewAction =
+                                  action.key === allDataViewPermissionKey
                                 const isSelectable =
                                   isPermissionActionSelectable(module, action)
 
                                 return (
                                   <label
                                     key={`${module.key}-${action.key}`}
-                                    className={`flex items-center gap-2 rounded-md border px-3 py-2 text-sm ${isSelectable ? 'hover:bg-accent cursor-pointer' : 'cursor-not-allowed opacity-50'}`}
+                                    className={`inline-flex w-fit items-center gap-2 rounded-md border px-3 py-2 text-sm ${isSelectable ? 'hover:bg-accent cursor-pointer' : 'cursor-not-allowed opacity-50'}`}
                                   >
                                     <Checkbox
                                       checked={
@@ -930,15 +1157,32 @@ export function RolesSettings() {
                                             (key) => key !== action.key
                                           )
                                         if (checked) {
+                                          const nextSelectedActions = [
+                                            ...selectedActions,
+                                            viewPermissionKey,
+                                            action.key,
+                                          ]
+                                          if (
+                                            isAllDataViewAction &&
+                                            ownDataViewPermissionKey
+                                          ) {
+                                            nextSelectedActions.push(
+                                              ownDataViewPermissionKey
+                                            )
+                                          }
                                           nextActions = [
-                                            ...new Set([
-                                              ...selectedActions,
-                                              viewPermissionKey,
-                                              action.key,
-                                            ]),
+                                            ...new Set(nextSelectedActions),
                                           ]
                                         } else if (isViewAction) {
                                           nextActions = []
+                                        } else if (
+                                          isOwnDataViewAction &&
+                                          allDataViewPermissionKey
+                                        ) {
+                                          nextActions = nextActions.filter(
+                                            (key) =>
+                                              key !== allDataViewPermissionKey
+                                          )
                                         }
                                         field.onChange({
                                           ...field.value,
@@ -946,7 +1190,9 @@ export function RolesSettings() {
                                         })
                                       }}
                                     />
-                                    <span>{t(action.label)}</span>
+                                    <span className='whitespace-nowrap'>
+                                      {t(action.label)}
+                                    </span>
                                   </label>
                                 )
                               })}

+ 3 - 3
default/src/features/system-settings/general/system-info-section.tsx

@@ -270,7 +270,7 @@ export function SystemInfoSection({ defaultValues }: SystemInfoSectionProps) {
               <div className='grid grid-cols-1 gap-6 md:grid-cols-2'>
                 <div className='space-y-2'>
                   <label className='text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70'>
-                    App 图标
+                    {t('App Icon')}
                   </label>
                   <div
                     className='cursor-pointer rounded-lg border-2 border-dashed border-gray-300 p-6 text-center transition-colors hover:border-gray-400'
@@ -316,8 +316,8 @@ export function SystemInfoSection({ defaultValues }: SystemInfoSectionProps) {
                           d='M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z'
                         />
                       </svg>
-                      <p className='text-sm text-gray-600'>点击或拖拽上传</p>
-                      <p className='text-xs text-gray-400'>仅支持 PNG</p>
+                      <p className='text-sm text-gray-600'>{t('Click or drag to upload')}</p>
+                      <p className='text-xs text-gray-400'>{t('PNG only')}</p>
                     </div>
                     <input
                       id='app-icon-input'

+ 147 - 56
default/src/features/tenants/index.tsx

@@ -37,6 +37,7 @@ import { toast } from 'sonner'
 import { z } from 'zod'
 
 import { DataTablePage, useDataTable } from '@/components/data-table'
+import { DatePicker } from '@/components/date-picker'
 import {
   SideDrawerSection,
   sideDrawerContentClassName,
@@ -66,14 +67,6 @@ import {
   FormMessage,
 } from '@/components/ui/form'
 import { Input } from '@/components/ui/input'
-import {
-  Select,
-  SelectContent,
-  SelectGroup,
-  SelectItem,
-  SelectTrigger,
-  SelectValue,
-} from '@/components/ui/select'
 import {
   Sheet,
   SheetClose,
@@ -92,9 +85,14 @@ import {
 } from '@/components/ui/tooltip'
 import { PERMISSION_CODES, hasPermissionCode } from '@/lib/admin-permissions'
 import { copyToClipboard } from '@/lib/copy-to-clipboard'
+import dayjs from '@/lib/dayjs'
 import { formatNumber, formatTimestamp } from '@/lib/format'
 import { useAuthStore } from '@/stores/auth-store'
 
+import {
+  ApiKeyGroupCombobox,
+  type ApiKeyGroupOption,
+} from '../keys/components/api-key-group-combobox'
 import { getPermissions, type PermissionItem } from '../permissions/api'
 import { getGroups } from '../users/api'
 import {
@@ -168,6 +166,62 @@ function getTenantPermissionTitle(
   )
 }
 
+const TENANT_PERMISSION_MODULE_TITLE_MAP: Record<string, string> = {
+  dashboard: 'Dashboard',
+  channel: 'Channel Management',
+  models: 'Model Management',
+  model: 'Model Management',
+  user: 'User Management',
+  role: 'Platform Roles',
+  tenant: 'Tenant Management',
+  finance: 'Finance Management',
+  billing: 'Finance Management',
+  wallet: 'Finance Management',
+  pricing: 'Pricing',
+  key: 'Keys',
+  keys: 'Keys',
+  profile: 'Profile',
+  settings: 'Settings',
+  'usage-logs': 'Usage Logs',
+  'audit-logs': 'Audit Logs',
+}
+
+const TENANT_PERMISSION_MODULE_FALLBACK_MAP: Record<string, string> = {
+  用户管理: 'User Management',
+  平台角色: 'Platform Roles',
+  租户管理: 'Tenant Management',
+  渠道管理: 'Channel Management',
+  模型管理: 'Model Management',
+  财务管理: 'Finance Management',
+}
+
+function getTenantPermissionModuleSegment(permission: PermissionItem) {
+  const moduleCode =
+    permission.module_code ??
+    permission.permission_code ??
+    permission.permission_key ??
+    permission.front_perm_code ??
+    ''
+
+  return moduleCode
+    .replace(/^route\./, '')
+    .split('.')
+    .filter(Boolean)[0]
+}
+
+function getLocalizedTenantPermissionTitle(
+  permission: PermissionItem,
+  fallback: string
+) {
+  const moduleSegment = getTenantPermissionModuleSegment(permission)
+  if (moduleSegment && TENANT_PERMISSION_MODULE_TITLE_MAP[moduleSegment]) {
+    return TENANT_PERMISSION_MODULE_TITLE_MAP[moduleSegment]
+  }
+
+  const rawTitle = getTenantPermissionTitle(permission, fallback)
+  return TENANT_PERMISSION_MODULE_FALLBACK_MAP[rawTitle] ?? rawTitle
+}
+
 function permissionHasRole(permission: PermissionItem) {
   return (
     permission.has_permission ??
@@ -185,7 +239,7 @@ function toTenantPermissionModules(permissions: PermissionItem[]) {
       const key = getTenantPermissionKey(permission)
       return {
         key,
-        title: getTenantPermissionTitle(permission, key),
+        title: getLocalizedTenantPermissionTitle(permission, key),
         description: permission.module_desc ?? permission.description ?? '',
       }
     })
@@ -214,6 +268,12 @@ function toDateInputValue(timestamp: number | null) {
   return new Date(timestamp * 1000).toISOString().slice(0, 10)
 }
 
+function toDatePickerValue(value: string) {
+  if (!value) return undefined
+  const date = dayjs(value, 'YYYY-MM-DD', true)
+  return date.isValid() ? date.toDate() : undefined
+}
+
 function toApiDateTime(value: string, time: string) {
   if (!value) return undefined
   return `${value}T${time}+08:00`
@@ -331,7 +391,15 @@ export function Tenants() {
     enabled: (viewOpen || editOpen) && Boolean(tenantDetailUniqueId),
   })
 
-  const tenantGroupOptions = groupsData?.data ?? []
+  const tenantGroupOptions = useMemo<ApiKeyGroupOption[]>(
+    () =>
+      (groupsData?.data ?? []).map((group) => ({
+        value: group,
+        label: group,
+        desc: group,
+      })),
+    [groupsData?.data]
+  )
 
   const permissionModules = useMemo(
     () => toTenantPermissionModules(rolePermissions),
@@ -345,24 +413,38 @@ export function Tenants() {
       : permissionModules.map((module) => module.key)
   }, [permissionModules, rolePermissions])
 
-  const tenantFormSchema = z.object({
-    name: z.string().min(1, t('Tenant name is required')),
-    group: z.string().min(1, t('Group is required')),
-    contact_name: z.string(),
-    contact_phone: z.string(),
-    contact_email: z
-      .string()
-      .refine((value) => !value || z.email().safeParse(value).success, {
-        message: t('Invalid email address'),
-      }),
-    quota_limit: z.union([
-      z.literal(''),
-      z.coerce.number().min(0, t('Quota must be greater than or equal to 0')),
-    ]),
-    starts_at: z.string(),
-    ends_at: z.string(),
-    permission_keys: z.array(z.string()),
-  })
+  const tenantFormSchema = z
+    .object({
+      name: z.string().min(1, t('Tenant name is required')),
+      group: z.string().min(1, t('Group is required')),
+      contact_name: z.string(),
+      contact_phone: z.string(),
+      contact_email: z
+        .string()
+        .refine((value) => !value || z.email().safeParse(value).success, {
+          message: t('Invalid email address'),
+        }),
+      quota_limit: z.union([
+        z.literal(''),
+        z.coerce
+          .number()
+          .min(0, t('Quota must be greater than or equal to 0')),
+      ]),
+      starts_at: z.string(),
+      ends_at: z.string(),
+      permission_keys: z.array(z.string()),
+    })
+    .refine(
+      (data) =>
+        !data.starts_at ||
+        !data.ends_at ||
+        dayjs(data.ends_at).isSame(dayjs(data.starts_at)) ||
+        dayjs(data.ends_at).isAfter(dayjs(data.starts_at)),
+      {
+        path: ['ends_at'],
+        message: t('End date cannot be earlier than start date'),
+      }
+    )
 
   const form = useForm<TenantFormData>({
     resolver: zodResolver(
@@ -652,33 +734,26 @@ export function Tenants() {
                 </FormItem>
               )}
             />
-            <FormField
-              control={form.control}
-              name='group'
-              render={({ field }) => (
-                <FormItem>
-                  <FormLabel>{t('Group')}</FormLabel>
-                  <Select value={field.value} onValueChange={field.onChange}>
-                    <FormControl>
-                      <SelectTrigger className='w-full'>
-                        <SelectValue placeholder={t('Select group')} />
-                      </SelectTrigger>
-                    </FormControl>
-                    <SelectContent alignItemWithTrigger={false}>
-                      <SelectGroup>
-                        {tenantGroupOptions.map((group) => (
-                          <SelectItem key={group} value={group}>
-                            {group}
-                          </SelectItem>
-                        ))}
-                      </SelectGroup>
-                    </SelectContent>
-                  </Select>
-                  <FormMessage />
-                </FormItem>
-              )}
-            />
           </div>
+          <FormField
+            control={form.control}
+            name='group'
+            render={({ field }) => (
+              <FormItem className='mt-4 w-full min-w-0'>
+                <FormLabel>{t('Group')}</FormLabel>
+                <FormControl>
+                  <ApiKeyGroupCombobox
+                    options={tenantGroupOptions}
+                    value={field.value}
+                    onValueChange={field.onChange}
+                    placeholder={t('Select group')}
+                    className='w-full min-w-0'
+                  />
+                </FormControl>
+                <FormMessage />
+              </FormItem>
+            )}
+          />
         </SideDrawerSection>
 
         <SideDrawerSection>
@@ -696,7 +771,15 @@ export function Tenants() {
                 <FormItem>
                   <FormLabel>{t('Start Date')}</FormLabel>
                   <FormControl>
-                    <Input {...field} type='date' />
+                    <DatePicker
+                      selected={toDatePickerValue(field.value)}
+                      onSelect={(date) => {
+                        field.onChange(
+                          date ? dayjs(date).format('YYYY-MM-DD') : ''
+                        )
+                      }}
+                      placeholder={t('Select date')}
+                    />
                   </FormControl>
                   <FormMessage />
                 </FormItem>
@@ -709,7 +792,15 @@ export function Tenants() {
                 <FormItem>
                   <FormLabel>{t('End Date')}</FormLabel>
                   <FormControl>
-                    <Input {...field} type='date' />
+                    <DatePicker
+                      selected={toDatePickerValue(field.value)}
+                      onSelect={(date) => {
+                        field.onChange(
+                          date ? dayjs(date).format('YYYY-MM-DD') : ''
+                        )
+                      }}
+                      placeholder={t('Select date')}
+                    />
                   </FormControl>
                   <FormMessage />
                 </FormItem>

+ 12 - 5
default/src/features/usage-logs/components/common-logs-filter-bar.tsx

@@ -40,7 +40,7 @@ import {
 
 import { buildSearchParams } from '../lib/filter'
 import { getDefaultTimeRange } from '../lib/utils'
-import type { CommonLogFilters } from '../types'
+import type { CommonLogFilters, LogCategory } from '../types'
 import { CommonLogsStats } from './common-logs-stats'
 import { CompactDateTimeRangePicker } from './compact-date-time-range-picker'
 import {
@@ -57,7 +57,12 @@ import { useLogsViewScope, useUsageLogsContext } from './usage-logs-provider'
 
 const route = getRouteApi('/_authenticated/usage-logs/$section')
 const auditLogTypeItems = LOG_TYPE_FILTERS.filter(
-  (type) => type.value !== String(LOG_TYPE_ENUM.CONSUME)
+  (type) =>
+    ![
+      LOG_TYPE_ENUM.TOPUP,
+      LOG_TYPE_ENUM.CONSUME,
+      LOG_TYPE_ENUM.REFUND,
+    ].includes(Number(type.value))
 )
 const auditLogTypeValues = new Set(auditLogTypeItems.map((type) => type.value))
 
@@ -115,7 +120,9 @@ export function CommonLogsFilterBar<TData>(
   const navigate = useNavigate()
   const queryClient = useQueryClient()
   const searchParams = route.useSearch()
-  const { isAdminView: isAdmin } = useLogsViewScope()
+  const routeParams = route.useParams()
+  const logCategory = routeParams.section as LogCategory
+  const { isAdminView: isAdmin } = useLogsViewScope(logCategory)
   const { sensitiveVisible, setSensitiveVisible } = useUsageLogsContext()
   const fetchingLogs = useIsFetching({ queryKey: ['logs'] })
 
@@ -269,7 +276,7 @@ export function CommonLogsFilterBar<TData>(
   const consumeTypeItems = [
     { value: 'all', label: t('All') },
     { value: 'platform', label: t('Platform') },
-    { value: 'user', label: t('User') },
+    { value: 'tenant', label: t('Tenant') },
   ]
   const consumeTypeLabel =
     consumeTypeItems.find((item) => item.value === filters.consumeType)?.label ??
@@ -325,7 +332,7 @@ export function CommonLogsFilterBar<TData>(
         onValueChange={(value) => {
           handleChange(
             'consumeType',
-            value === 'platform' || value === 'user' ? value : 'all'
+            value === 'platform' || value === 'tenant' ? value : 'all'
           )
         }}
       >

+ 3 - 1
default/src/features/usage-logs/components/common-logs-stats.tsx

@@ -27,6 +27,7 @@ import { cn } from '@/lib/utils'
 import { getLogStats, getUserLogStats } from '../api'
 import { DEFAULT_LOG_STATS, LOG_TYPE_ENUM } from '../constants'
 import { buildApiParams } from '../lib/utils'
+import type { LogCategory } from '../types'
 import { useLogsViewScope, useUsageLogsContext } from './usage-logs-provider'
 
 const route = getRouteApi('/_authenticated/usage-logs/$section')
@@ -49,9 +50,10 @@ function StatBadge(props: {
 
 export function CommonLogsStats() {
   const { t } = useTranslation()
-  const { isAdminView: isAdmin } = useLogsViewScope()
   const searchParams = route.useSearch()
   const routeParams = route.useParams()
+  const logCategory = routeParams.section as LogCategory
+  const { isAdminView: isAdmin } = useLogsViewScope(logCategory)
   const { sensitiveVisible } = useUsageLogsContext()
 
   const { data: stats, isLoading } = useQuery({

+ 2 - 2
default/src/features/usage-logs/components/task-logs-filter-bar.tsx

@@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
 */
 import { useQueryClient, useIsFetching } from '@tanstack/react-query'
 import { useNavigate, getRouteApi } from '@tanstack/react-router'
-import { type Table } from '@tanstack/react-table'
+import type { Table } from '@tanstack/react-table'
 import { useState, useEffect, useCallback } from 'react'
 import { useTranslation } from 'react-i18next'
 
@@ -69,7 +69,7 @@ export function TaskLogsFilterBar<TData>(props: TaskLogsFilterBarProps<TData>) {
   const navigate = useNavigate()
   const queryClient = useQueryClient()
   const searchParams = route.useSearch()
-  const { isAdminView: isAdmin } = useLogsViewScope()
+  const { isAdminView: isAdmin } = useLogsViewScope(props.logCategory)
   const fetchingLogs = useIsFetching({ queryKey: ['logs'] })
 
   const [filters, setFilters] = useState<TaskLogsFilters>(() => {

+ 39 - 7
default/src/features/usage-logs/components/usage-logs-provider.tsx

@@ -17,11 +17,18 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
 For commercial licensing, please contact support@quantumnous.com
 */
 /* eslint-disable react-refresh/only-export-components */
-import { createContext, useContext, useState, type ReactNode } from 'react'
+import {
+  createContext,
+  useContext,
+  useEffect,
+  useState,
+  type ReactNode,
+} from 'react'
 
-import { useIsAdmin } from '@/hooks/use-admin'
+import { PERMISSION_CODES, hasPermissionCode } from '@/lib/admin-permissions'
+import { useAuthStore } from '@/stores/auth-store'
 
-import type { ChannelAffinityInfo } from '../types'
+import type { ChannelAffinityInfo, LogCategory } from '../types'
 
 export type LogsViewScope = 'all' | 'self'
 
@@ -83,6 +90,18 @@ export function useUsageLogsContext() {
   return context
 }
 
+function getLogScopePermissionCodes(logCategory: LogCategory) {
+  return logCategory === 'audit'
+    ? {
+        view: PERMISSION_CODES.AUDIT_LOGS_VIEW,
+        all: PERMISSION_CODES.AUDIT_LOGS_ALL,
+      }
+    : {
+        view: PERMISSION_CODES.USAGE_LOGS_VIEW,
+        all: PERMISSION_CODES.USAGE_LOGS_ALL,
+      }
+}
+
 /**
  * Resolves the effective admin scope for usage logs: whether the current
  * user is allowed to view all users' logs (`canManageScope`), and whether
@@ -91,14 +110,27 @@ export function useUsageLogsContext() {
  * `isAdminView` rather than raw role, so an admin who switches to "only
  * mine" is treated exactly like a regular user for that view.
  */
-export function useLogsViewScope() {
-  const canManageScope = useIsAdmin()
+export function useLogsViewScope(logCategory: LogCategory = 'common') {
+  const currentUser = useAuthStore((state) => state.auth.user)
   const { viewScope, setViewScope } = useUsageLogsContext()
+  const permissionCodes = getLogScopePermissionCodes(logCategory)
+  const canViewSelf = hasPermissionCode(currentUser, permissionCodes.view)
+  const canViewAll = hasPermissionCode(currentUser, permissionCodes.all)
+  const canManageScope = canViewSelf && canViewAll
+  const resolvedViewScope = canViewAll && viewScope === 'all' ? 'all' : 'self'
+
+  useEffect(() => {
+    if (!canViewAll && viewScope === 'all') {
+      setViewScope('self')
+    }
+  }, [canViewAll, setViewScope, viewScope])
 
   return {
     canManageScope,
-    viewScope,
+    viewScope: resolvedViewScope,
     setViewScope,
-    isAdminView: canManageScope && viewScope === 'all',
+    canViewSelf,
+    canViewAll,
+    isAdminView: canViewAll && resolvedViewScope === 'all',
   }
 }

+ 1 - 1
default/src/features/usage-logs/components/usage-logs-table.tsx

@@ -77,7 +77,7 @@ interface UsageLogsTableProps {
 
 export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
   const { t } = useTranslation()
-  const { isAdminView: isAdmin } = useLogsViewScope()
+  const { isAdminView: isAdmin } = useLogsViewScope(logCategory)
   const isMobile = useMediaQuery('(max-width: 640px)')
   const searchParams = route.useSearch()
 

+ 15 - 11
default/src/features/usage-logs/index.tsx

@@ -74,7 +74,8 @@ function UsageLogsContent() {
     affinityDialogOpen,
     setAffinityDialogOpen,
   } = useUsageLogsContext()
-  const { canManageScope, viewScope, setViewScope } = useLogsViewScope()
+  const { canViewSelf, canViewAll, viewScope, setViewScope } =
+    useLogsViewScope(activeCategory)
   const tabNavGroups = useMemo<NavGroup[]>(
     () => [
       {
@@ -132,16 +133,19 @@ function UsageLogsContent() {
         <SectionPageLayout.Title>
           {t(pageMeta.titleKey)}
         </SectionPageLayout.Title>
-        {canManageScope && (
-          <SectionPageLayout.Actions>
-            <Tabs value={viewScope} onValueChange={handleViewScopeChange}>
-              <TabsList>
-                <TabsTrigger value='all'>{t('All')}</TabsTrigger>
-                <TabsTrigger value='self'>{t('Only Mine')}</TabsTrigger>
-              </TabsList>
-            </Tabs>
-          </SectionPageLayout.Actions>
-        )}
+        {(activeCategory === 'common' || activeCategory === 'audit') &&
+          canViewSelf && (
+            <SectionPageLayout.Actions>
+              <Tabs value={viewScope} onValueChange={handleViewScopeChange}>
+                <TabsList>
+                  <TabsTrigger value='all' disabled={!canViewAll}>
+                    {t('All')}
+                  </TabsTrigger>
+                  <TabsTrigger value='self'>{t('Only Mine')}</TabsTrigger>
+                </TabsList>
+              </Tabs>
+            </SectionPageLayout.Actions>
+          )}
         <SectionPageLayout.Content>
           <div className='flex h-full min-h-0 flex-col gap-4'>
             {showTaskSwitcher && (

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

@@ -47,7 +47,7 @@ export interface CommonFilters {
  * Common logs specific filters
  */
 export interface CommonLogFilters extends CommonFilters {
-  consumeType?: 'all' | 'platform' | 'user'
+  consumeType?: 'all' | 'platform' | 'tenant'
   logType?: string
   model?: string
   token?: string

+ 116 - 21
default/src/features/users/components/users-mutate-drawer.tsx

@@ -59,9 +59,15 @@ import {
   SheetTitle,
 } from '@/components/ui/sheet'
 import { Textarea } from '@/components/ui/textarea'
+import {
+  ApiKeyGroupCombobox,
+  type ApiKeyGroupOption,
+} from '@/features/keys/components/api-key-group-combobox'
 import { getRoles } from '@/features/roles/api'
+import { formatQuota } from '@/lib/format'
+import { Pencil } from 'lucide-react'
 
-import { createUser, updateUser, getUser } from '../api'
+import { createUser, updateUser, getUser, getGroups } from '../api'
 import { ERROR_MESSAGES, SUCCESS_MESSAGES, isUserRoot } from '../constants'
 import {
   userFormSchema,
@@ -74,6 +80,7 @@ import {
   transformUserToFormDefaults,
 } from '../lib'
 import type { User } from '../types'
+import { UserQuotaDialog } from './user-quota-dialog'
 import { useUsers } from './users-provider'
 
 type UsersMutateDrawerProps = {
@@ -92,6 +99,10 @@ export function UsersMutateDrawer({
   const isRootUser = isUserRoot(currentRow)
   const { triggerRefresh } = useUsers()
   const [isSubmitting, setIsSubmitting] = useState(false)
+  const [quotaDialogOpen, setQuotaDialogOpen] = useState(false)
+  const [updatedQuota, setUpdatedQuota] = useState<number | null>(null)
+
+  const displayQuota = updatedQuota !== null ? updatedQuota : (currentRow?.quota ?? 0)
 
   const { data: rolesData } = useQuery({
     queryKey: ['roles', 'user-form'],
@@ -99,6 +110,22 @@ export function UsersMutateDrawer({
     staleTime: 5 * 60 * 1000,
   })
 
+  const { data: groupsData } = useQuery({
+    queryKey: ['groups'],
+    queryFn: getGroups,
+    staleTime: 5 * 60 * 1000,
+  })
+
+  const userGroupOptions = useMemo<ApiKeyGroupOption[]>(
+    () =>
+      (groupsData?.data ?? []).map((group) => ({
+        value: group,
+        label: group,
+        desc: group,
+      })),
+    [groupsData?.data]
+  )
+
   const roleOptions = useMemo(
     () =>
       rolesData?.data?.items.map((role) => ({
@@ -123,6 +150,7 @@ export function UsersMutateDrawer({
   useEffect(() => {
     if (open && isUpdate && currentRow) {
       // For update, fetch fresh data
+      setUpdatedQuota(null)
       void getUser(currentRow.id).then((result) => {
         if (result.success && result.data) {
           form.reset(transformUserToFormDefaults(result.data))
@@ -130,6 +158,7 @@ export function UsersMutateDrawer({
       })
     } else if (open && !isUpdate) {
       // For create, reset to defaults
+      setUpdatedQuota(null)
       form.reset(USER_FORM_DEFAULT_VALUES)
     }
   }, [open, isUpdate, currentRow, form])
@@ -212,16 +241,17 @@ export function UsersMutateDrawer({
   }
 
   return (
-    <Sheet
-      open={open}
-      onOpenChange={(v) => {
-        onOpenChange(v)
-        if (!v) {
-          form.reset()
-        }
-      }}
-    >
-      <SheetContent className={sideDrawerContentClassName('sm:max-w-[600px]')}>
+    <>
+      <Sheet
+        open={open}
+        onOpenChange={(v) => {
+          onOpenChange(v)
+          if (!v) {
+            form.reset()
+          }
+        }}
+      >
+        <SheetContent className={sideDrawerContentClassName('sm:max-w-[600px]')}>
         <SheetHeader className={sideDrawerHeaderClassName()}>
           <SheetTitle>
             {isUpdate ? t('Update') : t('Create')} {t('User')}
@@ -313,6 +343,26 @@ export function UsersMutateDrawer({
                 )}
               />
 
+              <FormField
+                control={form.control}
+                name='group'
+                render={({ field }) => (
+                  <FormItem className='mt-4 w-full min-w-0'>
+                    <FormLabel>{t('Group')}</FormLabel>
+                    <FormControl>
+                      <ApiKeyGroupCombobox
+                        options={userGroupOptions}
+                        value={field.value}
+                        onValueChange={field.onChange}
+                        placeholder={t('Select group')}
+                        className='w-full min-w-0'
+                      />
+                    </FormControl>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
               <FormField
                 control={form.control}
                 name='display_name'
@@ -330,6 +380,32 @@ export function UsersMutateDrawer({
                 )}
               />
 
+              {isUpdate && currentRow && (
+                <FormItem>
+                  <FormLabel>{t('Quota')}</FormLabel>
+                  <div className='flex items-center gap-2'>
+                    <FormControl>
+                      <Input
+                        value={formatQuota(displayQuota)}
+                        disabled
+                        className='flex-1'
+                      />
+                    </FormControl>
+                    <Button
+                      type='button'
+                      variant='outline'
+                      onClick={() => setQuotaDialogOpen(true)}
+                    >
+                      <Pencil className='mr-2 h-4 w-4' />
+                      {t('Adjust Quota')}
+                    </Button>
+                  </div>
+                  <FormDescription>
+                    {t('Click adjust to modify user quota')}
+                  </FormDescription>
+                </FormItem>
+              )}
+
               <FormField
                 control={form.control}
                 name='password'
@@ -419,15 +495,34 @@ export function UsersMutateDrawer({
               )} */}
           </form>
         </Form>
-        <SheetFooter className={sideDrawerFooterClassName()}>
-          <SheetClose render={<Button variant='outline' />}>
-            {t('Close')}
-          </SheetClose>
-          <Button form='user-form' type='submit' disabled={isSubmitting}>
-            {isSubmitting ? t('Saving...') : t('Save')}
-          </Button>
-        </SheetFooter>
-      </SheetContent>
-    </Sheet>
+          <SheetFooter className={sideDrawerFooterClassName()}>
+            <SheetClose render={<Button variant='outline' />}>
+              {t('Close')}
+            </SheetClose>
+            <Button form='user-form' type='submit' disabled={isSubmitting}>
+              {isSubmitting ? t('Saving...') : t('Save')}
+            </Button>
+          </SheetFooter>
+        </SheetContent>
+      </Sheet>
+
+      {isUpdate && currentRow && (
+        <UserQuotaDialog
+          open={quotaDialogOpen}
+          onOpenChange={setQuotaDialogOpen}
+          userId={currentRow.id}
+          currentQuota={displayQuota}
+          onSuccess={() => {
+            triggerRefresh()
+            void getUser(currentRow.id).then((result) => {
+              if (result.success && result.data) {
+                setUpdatedQuota(result.data.quota)
+                form.reset(transformUserToFormDefaults(result.data))
+              }
+            })
+          }}
+        />
+      )}
+    </>
   )
 }

+ 1 - 0
default/src/features/users/lib/user-form.ts

@@ -117,6 +117,7 @@ export function transformFormDataToPayload(
     if (!isRootUser) {
       payload.role_ids = roleIds
     }
+    payload.group = data.group
   } else {
     // For update: quota is adjusted atomically via /api/user/manage, not sent here
     if (!isRootUser) {

+ 1 - 1
default/src/hooks/use-sidebar-data.ts

@@ -175,7 +175,7 @@ export function useSidebarData(): SidebarData {
             title: t('Audit Logs'),
             url: '/usage-logs/audit',
             icon: FileText,
-            permissionCode: PERMISSION_CODES.USAGE_LOGS_VIEW,
+            permissionCode: PERMISSION_CODES.AUDIT_LOGS_VIEW,
           },
         ],
       },

+ 14 - 0
default/src/i18n/locales/en.json

@@ -140,6 +140,9 @@
     "Account used when authenticating with the SMTP server": "Account used when authenticating with the SMTP server",
     "acknowledge the related legal risks": "acknowledge the related legal risks",
     "Across all groups": "Across all groups",
+    "All groups": "All groups",
+    "All tags": "All tags",
+    "All vendors": "All vendors",
     "Action": "Action",
     "Action confirmation": "Action Confirmation",
     "Actions": "Actions",
@@ -1992,6 +1995,7 @@
     "Filter...": "Filter...",
     "Filters": "Filters",
     "Filters active": "Filters active",
+    "Filter models by vendor, group and tags.": "Filter models by vendor, group and tags.",
     "Final Consumed": "Final Consumed",
     "Final cost = base × multiplier when conditions match": "Final cost = base × multiplier when conditions match",
     "Final price multiplier (0.95 = 5% discount": "Final price multiplier (0.95 = 5% discount",
@@ -3642,6 +3646,7 @@
     "Recently launched models gaining traction": "Recently launched models gaining traction",
     "Recharge": "Recharge",
     "Recharge Amount": "Recharge Amount",
+    "Recharge Price": "Recharge Price",
     "Recharge Amount (USD)": "Recharge Amount (USD)",
     "Recommended": "Recommended",
     "Recommended actions": "Recommended actions",
@@ -3994,6 +3999,7 @@
     "Search method identifiers...": "Search method identifiers...",
     "Search missing models": "Search missing models",
     "Search model name, provider, endpoint, or tag...": "Search model name, provider, endpoint, or tag...",
+    "Search model name, provider, or tags...": "Search model name, provider, or tags...",
     "Search model name...": "Search model name...",
     "Search models": "Search models",
     "Search models or fields...": "Search models or fields...",
@@ -4249,6 +4255,7 @@
     "SSRF Protection": "SSRF Protection",
     "stale": "stale",
     "Standard": "Standard",
+    "Standard Price": "Standard Price",
     "Standard price": "Standard price",
     "Start": "Start",
     "Start a conversation to see messages here": "Start a conversation to see messages here",
@@ -4376,6 +4383,10 @@
     "System default": "System default",
     "System Info": "System Info",
     "System Information": "System Information",
+    "App Icon": "App Icon",
+    "Click or drag to upload": "Click or drag to upload",
+    "PNG only": "PNG only",
+    "App icon uploaded successfully": "App icon uploaded successfully",
     "System initialized successfully! Redirecting…": "System initialized successfully! Redirecting…",
     "System logo": "System logo",
     "System maintenance": "System maintenance",
@@ -5262,7 +5273,9 @@
     "View wallet, billing, top-up, redemption, and financial records": "View wallet, billing, top-up, redemption, and financial records",
     "Export": "Export",
     "Audit": "Audit",
+    "Own": "Own",
     "Are you sure you want to delete role {{name}}?": "Are you sure you want to delete role {{name}}?",
+    "Keys": "Keys",
     "Tenant Management": "Tenant Management",
     "Tenant Name": "Tenant Name",
     "Tenant Code": "Tenant Code",
@@ -5307,6 +5320,7 @@
     "Filter by tenant name, code, group or administrator...": "Filter by tenant name, code, group or administrator...",
     "Are you sure you want to delete tenant {{name}}?": "Are you sure you want to delete tenant {{name}}?",
     "Invalid email address": "Invalid email address",
+    "End date cannot be earlier than start date": "End date cannot be earlier than start date",
     "Invitation sent to {{email}}": "Invitation sent to {{email}}",
     "Tenant enabled successfully": "Tenant enabled successfully",
     "Tenant disabled successfully": "Tenant disabled successfully",

+ 4 - 0
default/src/i18n/locales/fr.json

@@ -4372,6 +4372,10 @@
     "System default": "Système par défaut",
     "System Info": "Infos système",
     "System Information": "Informations système",
+    "App Icon": "Icône de l'application",
+    "Click or drag to upload": "Cliquez ou glissez-déposez pour télécharger",
+    "PNG only": "PNG uniquement",
+    "App icon uploaded successfully": "Icône de l'application téléchargée avec succès",
     "System initialized successfully! Redirecting…": "Système initialisé avec succès ! Redirection…",
     "System logo": "Logo du système",
     "System maintenance": "Maintenance du système",

+ 4 - 0
default/src/i18n/locales/ja.json

@@ -4372,6 +4372,10 @@
     "System default": "システムデフォルト",
     "System Info": "システム情報",
     "System Information": "システム情報",
+    "App Icon": "アプリアイコン",
+    "Click or drag to upload": "クリックまたはドラッグしてアップロード",
+    "PNG only": "PNGのみ",
+    "App icon uploaded successfully": "アプリアイコンが正常にアップロードされました",
     "System initialized successfully! Redirecting…": "システムが正常に初期化されました!リダイレクト中…",
     "System logo": "システムロゴ",
     "System maintenance": "システムメンテナンス",

+ 4 - 0
default/src/i18n/locales/ru.json

@@ -4372,6 +4372,10 @@
     "System default": "По умолчанию",
     "System Info": "Информация о системе",
     "System Information": "Системная информация",
+    "App Icon": "Иконка приложения",
+    "Click or drag to upload": "Нажмите или перетащите для загрузки",
+    "PNG only": "Только PNG",
+    "App icon uploaded successfully": "Иконка приложения успешно загружена",
     "System initialized successfully! Redirecting…": "Система успешно инициализирована! Перенаправление…",
     "System logo": "Логотип системы",
     "System maintenance": "Обслуживание системы",

+ 4 - 0
default/src/i18n/locales/vi.json

@@ -4372,6 +4372,10 @@
     "System default": "Mặc định hệ thống",
     "System Info": "Thông tin hệ thống",
     "System Information": "Thông tin hệ thống",
+    "App Icon": "Biểu tượng ứng dụng",
+    "Click or drag to upload": "Nhấp hoặc kéo thả để tải lên",
+    "PNG only": "Chỉ hỗ trợ PNG",
+    "App icon uploaded successfully": "Tải lên biểu tượng ứng dụng thành công",
     "System initialized successfully! Redirecting…": "Hệ thống đã được khởi tạo thành công! Đang chuyển hướng…",
     "System logo": "Logo hệ thống",
     "System maintenance": "Bảo trì hệ thống",

+ 4 - 0
default/src/i18n/locales/zh-TW.json

@@ -4372,6 +4372,10 @@
     "System default": "系統預設",
     "System Info": "系統資訊",
     "System Information": "系統資訊",
+    "App Icon": "App圖示",
+    "Click or drag to upload": "點擊或拖曳上傳",
+    "PNG only": "僅支援 PNG",
+    "App icon uploaded successfully": "App圖示上傳成功",
     "System initialized successfully! Redirecting…": "系統初始化成功!正在重新導向…",
     "System logo": "系統徽標",
     "System maintenance": "系統維護",

+ 10 - 0
default/src/i18n/locales/zh.json

@@ -1992,6 +1992,7 @@
     "Filter...": "筛选...",
     "Filters": "筛选器",
     "Filters active": "筛选已启用",
+    "Filter models by vendor, group and tags.": "按供应商、分组和标签筛选模型",
     "Final Consumed": "最终消耗",
     "Final cost = base × multiplier when conditions match": "匹配条件时,最终费用 = 基础费用 × 倍率",
     "Final price multiplier (0.95 = 5% discount": "最终价格乘数 (0.95 = 5% 折扣",
@@ -3643,6 +3644,7 @@
     "Recently launched models gaining traction": "近期发布并快速增长的模型",
     "Recharge": "充值",
     "Recharge Amount": "充值金额",
+    "Recharge Price": "充值价格",
     "Recharge Amount (USD)": "充值金额 (USD)",
     "Recommended": "推荐",
     "Recommended actions": "推荐操作",
@@ -4250,6 +4252,7 @@
     "SSRF Protection": "SSRF 保护",
     "stale": "失联",
     "Standard": "标准",
+    "Standard Price": "标准价格",
     "Standard price": "标准价格",
     "Start": "开始",
     "Start a conversation to see messages here": "开始对话以在此处查看消息",
@@ -4377,6 +4380,10 @@
     "System default": "系统默认",
     "System Info": "系统信息",
     "System Information": "系统信息",
+    "App Icon": "App图标",
+    "Click or drag to upload": "点击或拖拽上传",
+    "PNG only": "仅支持 PNG",
+    "App icon uploaded successfully": "App图标上传成功",
     "System initialized successfully! Redirecting…": "系统初始化成功!正在重定向…",
     "System logo": "系统徽标",
     "System maintenance": "系统维护",
@@ -5263,7 +5270,9 @@
     "View wallet, billing, top-up, redemption, and financial records": "查看钱包、账单、充值、兑换和财务记录",
     "Export": "导出",
     "Audit": "审计",
+    "Own": "仅自己",
     "Are you sure you want to delete role {{name}}?": "确定要删除角色“{{name}}”吗?",
+    "Keys": "密钥",
     "Tenant Management": "租户管理",
     "Tenant Name": "租户名称",
     "Tenant Code": "租户编码",
@@ -5308,6 +5317,7 @@
     "Filter by tenant name, code, group or administrator...": "按租户名称、编码、分组或管理员筛选...",
     "Are you sure you want to delete tenant {{name}}?": "确定要删除租户“{{name}}”吗?",
     "Invalid email address": "邮箱地址格式不正确",
+    "End date cannot be earlier than start date": "结束时间不能早于开始时间",
     "Invitation sent to {{email}}": "邀请已发送至 {{email}}",
     "Tenant enabled successfully": "租户启用成功",
     "Tenant disabled successfully": "租户禁用成功",

+ 3 - 0
default/src/lib/admin-permissions.ts

@@ -50,6 +50,9 @@ export const PERMISSION_CODES = {
   KEYS_DISABLE: 'keys.disable',
   PRICING_VIEW: 'pricing.view',
   USAGE_LOGS_VIEW: 'usage-logs.view',
+  USAGE_LOGS_ALL: 'usage-logs.all',
+  AUDIT_LOGS_VIEW: 'audit-logs.view',
+  AUDIT_LOGS_ALL: 'audit-logs.all',
   SETTINGS_VIEW: 'settings.view',
 } as const
 

+ 1 - 0
default/src/lib/landing-page.ts

@@ -38,6 +38,7 @@ export const LANDING_PAGE_CANDIDATES: ReadonlyArray<{
   { url: '/keys', code: PERMISSION_CODES.KEYS_VIEW },
   { url: '/pricing', code: PERMISSION_CODES.PRICING_VIEW },
   { url: '/usage-logs/common', code: PERMISSION_CODES.USAGE_LOGS_VIEW },
+  { url: '/usage-logs/audit', code: PERMISSION_CODES.AUDIT_LOGS_VIEW },
   { url: '/system-settings/site', code: PERMISSION_CODES.SETTINGS_VIEW },
 ]
 

+ 13 - 5
default/src/routes/_authenticated/usage-logs/$section.tsx

@@ -52,20 +52,28 @@ const usageLogsSearchSchema = z.object({
   endTime: z.number().optional(),
 })
 
+function getSectionPermissionCode(section: string) {
+  return section === 'audit'
+    ? PERMISSION_CODES.AUDIT_LOGS_VIEW
+    : PERMISSION_CODES.USAGE_LOGS_VIEW
+}
+
 export const Route = createFileRoute('/_authenticated/usage-logs/$section')({
   beforeLoad: ({ params, search }) => {
     const { auth } = useAuthStore.getState()
 
-    if (!hasPermissionCode(auth.user, PERMISSION_CODES.USAGE_LOGS_VIEW)) {
+    if (!isUsageLogsSectionId(params.section)) {
       throw redirect({
-        to: '/403',
+        to: '/usage-logs/$section',
+        params: { section: USAGE_LOGS_DEFAULT_SECTION },
       })
     }
 
-    if (!isUsageLogsSectionId(params.section)) {
+    if (
+      !hasPermissionCode(auth.user, getSectionPermissionCode(params.section))
+    ) {
       throw redirect({
-        to: '/usage-logs/$section',
-        params: { section: USAGE_LOGS_DEFAULT_SECTION },
+        to: '/403',
       })
     }
     const hasTypeSearch = Array.isArray(search?.type)