feat: enhance UI and functionality in various components

This commit is contained in:
CaIon
2026-04-28 18:38:02 +08:00
parent fc377dae3e
commit 28f7e9eb2e
40 changed files with 1567 additions and 470 deletions
@@ -0,0 +1,173 @@
import { useMemo, useState } from 'react'
import { Check, ChevronsUpDown } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
export type ApiKeyGroupOption = {
value: string
label: string
desc?: string
ratio?: number | string
}
type ApiKeyGroupComboboxProps = {
options: ApiKeyGroupOption[]
value?: string
onValueChange: (value: string) => void
placeholder?: string
disabled?: boolean
}
function formatGroupRatio(ratio: ApiKeyGroupOption['ratio'], ratioLabel: string) {
if (ratio === undefined || ratio === null || ratio === '') return null
return `${ratio}x ${ratioLabel}`
}
function getRatioBadgeClassName(ratio: ApiKeyGroupOption['ratio']) {
if (typeof ratio !== 'number') {
return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/40 dark:text-emerald-300'
}
if (ratio > 5) {
return 'border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-900/60 dark:bg-rose-950/40 dark:text-rose-300'
}
if (ratio > 3) {
return 'border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-900/60 dark:bg-orange-950/40 dark:text-orange-300'
}
if (ratio > 1) {
return 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/60 dark:bg-blue-950/40 dark:text-blue-300'
}
return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/40 dark:text-emerald-300'
}
function GroupRatioBadge({ ratio }: { ratio: ApiKeyGroupOption['ratio'] }) {
const { t } = useTranslation()
const label = formatGroupRatio(ratio, t('Ratio'))
if (!label) return null
return (
<Badge variant='outline' className={getRatioBadgeClassName(ratio)}>
{label}
</Badge>
)
}
export function ApiKeyGroupCombobox({
options,
value,
onValueChange,
placeholder,
disabled,
}: ApiKeyGroupComboboxProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [searchValue, setSearchValue] = useState('')
const selectedOption = options.find((option) => option.value === value)
const filteredOptions = useMemo(() => {
const search = searchValue.trim().toLowerCase()
if (!search) return options
return options.filter((option) => {
const ratioText = String(option.ratio ?? '').toLowerCase()
return (
option.value.toLowerCase().includes(search) ||
option.label.toLowerCase().includes(search) ||
option.desc?.toLowerCase().includes(search) ||
ratioText.includes(search)
)
})
}, [options, searchValue])
const handleSelect = (selectedValue: string) => {
onValueChange(selectedValue)
setOpen(false)
setSearchValue('')
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type='button'
variant='outline'
role='combobox'
aria-expanded={open}
disabled={disabled}
className='h-auto min-h-10 w-full justify-between gap-3 px-3 py-2 text-start'
>
<span className='flex min-w-0 flex-1 items-center justify-between gap-3'>
<span className='min-w-0'>
<span className='block truncate font-medium'>
{selectedOption?.value || placeholder || t('Select a group')}
</span>
{selectedOption?.desc && (
<span className='text-muted-foreground block truncate text-xs'>
{selectedOption.desc}
</span>
)}
</span>
<GroupRatioBadge ratio={selectedOption?.ratio} />
</span>
<ChevronsUpDown className='h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-[var(--radix-popover-trigger-width)] p-0'>
<Command shouldFilter={false}>
<CommandInput
placeholder={t('Search...')}
value={searchValue}
onValueChange={setSearchValue}
/>
<CommandList className='max-h-[360px]'>
<CommandEmpty>{t('No group found.')}</CommandEmpty>
<CommandGroup>
{filteredOptions.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={handleSelect}
className='items-start gap-3 px-3 py-3'
>
<Check
className={cn(
'mt-0.5 h-4 w-4',
value === option.value ? 'opacity-100' : 'opacity-0'
)}
/>
<span className='min-w-0 flex-1'>
<span className='block truncate font-medium'>
{option.value}
</span>
{option.desc && (
<span className='text-muted-foreground block truncate text-xs'>
{option.desc}
</span>
)}
</span>
<GroupRatioBadge ratio={option.ratio} />
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
@@ -62,7 +62,7 @@ function useGroupRatios(): Record<string, number> {
if (!res.success || !res.data) return {}
const ratios: Record<string, number> = {}
for (const [group, info] of Object.entries(res.data)) {
if (info.ratio !== undefined) {
if (typeof info.ratio === 'number') {
ratios[group] = info.ratio
}
}
@@ -1,3 +1,4 @@
import { useEffect, useState } from 'react'
import { ApiKeysDeleteDialog } from './api-keys-delete-dialog'
import { ApiKeysMutateDrawer } from './api-keys-mutate-drawer'
import { useApiKeys } from './api-keys-provider'
@@ -5,6 +6,19 @@ import { CCSwitchDialog } from './dialogs/cc-switch-dialog'
export function ApiKeysDialogs() {
const { open, setOpen, currentRow, resolvedKey } = useApiKeys()
const [lastMutateSide, setLastMutateSide] = useState<'left' | 'right'>(
'right'
)
const mutateSide =
open === 'create' ? 'left' : open === 'update' ? 'right' : lastMutateSide
useEffect(() => {
if (open === 'create') {
setLastMutateSide('left')
} else if (open === 'update') {
setLastMutateSide('right')
}
}, [open])
return (
<>
@@ -12,6 +26,7 @@ export function ApiKeysDialogs() {
open={open === 'create' || open === 'update'}
onOpenChange={(isOpen) => !isOpen && setOpen(null)}
currentRow={open === 'update' ? currentRow || undefined : undefined}
side={mutateSide}
/>
<ApiKeysDeleteDialog />
<CCSwitchDialog
@@ -23,13 +23,6 @@ import {
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Sheet,
SheetClose,
@@ -53,18 +46,24 @@ import {
transformApiKeyToFormDefaults,
} from '../lib'
import { type ApiKey } from '../types'
import {
ApiKeyGroupCombobox,
type ApiKeyGroupOption,
} from './api-key-group-combobox'
import { useApiKeys } from './api-keys-provider'
type ApiKeyMutateDrawerProps = {
open: boolean
onOpenChange: (open: boolean) => void
currentRow?: ApiKey
side?: 'left' | 'right'
}
export function ApiKeysMutateDrawer({
open,
onOpenChange,
currentRow,
side = 'right',
}: ApiKeyMutateDrawerProps) {
const { t } = useTranslation()
const isUpdate = !!currentRow
@@ -88,14 +87,22 @@ export function ApiKeysMutateDrawer({
const models = modelsData?.data || []
const groupsRaw = groupsData?.data || {}
const groups = Object.entries(groupsRaw).map(([key, info]) => ({
value: key,
label: info.desc || key,
}))
const groups: ApiKeyGroupOption[] = Object.entries(groupsRaw).map(
([key, info]) => ({
value: key,
label: key,
desc: info.desc || key,
ratio: info.ratio,
})
)
// Add auto group if configured
if (!groups.some((g) => g.value === 'auto')) {
groups.unshift({ value: 'auto', label: t('Auto (Circuit Breaker)') })
groups.unshift({
value: 'auto',
label: 'auto',
desc: t('Auto (Circuit Breaker)'),
})
}
const form = useForm<ApiKeyFormValues>({
@@ -187,10 +194,9 @@ export function ApiKeysMutateDrawer({
form.setValue('expired_time', now)
}
const { config: currencyConfig, meta: currencyMeta } = getCurrencyDisplay()
const { meta: currencyMeta } = getCurrencyDisplay()
const currencyLabel = getCurrencyLabel()
const tokensOnly =
!currencyConfig.displayInCurrency || currencyMeta.kind === 'tokens'
const tokensOnly = currencyMeta.kind === 'tokens'
const quotaLabel = t('Quota ({{currency}})', { currency: currencyLabel })
const quotaPlaceholder = tokensOnly
? t('Enter quota in tokens')
@@ -206,7 +212,10 @@ export function ApiKeysMutateDrawer({
}
}}
>
<SheetContent className='flex w-full flex-col sm:max-w-[600px]'>
<SheetContent
side={side}
className='flex w-full flex-col sm:max-w-[600px]'
>
<SheetHeader className='text-start'>
<SheetTitle>
{isUpdate ? t('Update API Key') : t('Create API Key')}
@@ -244,20 +253,14 @@ export function ApiKeysMutateDrawer({
render={({ field }) => (
<FormItem>
<FormLabel>{t('Group')}</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('Select a group')} />
</SelectTrigger>
</FormControl>
<SelectContent>
{groups.map((group) => (
<SelectItem key={group.value} value={group.value}>
{group.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormControl>
<ApiKeyGroupCombobox
options={groups}
value={field.value}
onValueChange={field.onChange}
placeholder={t('Select a group')}
/>
</FormControl>
<FormDescription>
{t('Auto group enables circuit breaker mechanism')}
</FormDescription>
+17 -11
View File
@@ -38,6 +38,7 @@ import { getApiKeys, searchApiKeys } from '../api'
import { API_KEY_STATUS_OPTIONS, ERROR_MESSAGES } from '../constants'
import { type ApiKey } from '../types'
import { useApiKeysColumns } from './api-keys-columns'
import { ApiKeysPrimaryButtons } from './api-keys-primary-buttons'
import { useApiKeys } from './api-keys-provider'
import { DataTableBulkActions } from './data-table-bulk-actions'
@@ -160,17 +161,22 @@ export function ApiKeysTable() {
return (
<>
<div className='space-y-4'>
<DataTableToolbar
table={table}
searchPlaceholder={t('Filter by name or key...')}
filters={[
{
columnId: 'status',
title: t('Status'),
options: API_KEY_STATUS_OPTIONS,
},
]}
/>
<div className='flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between'>
<ApiKeysPrimaryButtons />
<div className='min-w-0 sm:flex sm:justify-end'>
<DataTableToolbar
table={table}
searchPlaceholder={t('Filter by name or key...')}
filters={[
{
columnId: 'status',
title: t('Status'),
options: API_KEY_STATUS_OPTIONS,
},
]}
/>
</div>
</div>
{isMobile ? (
<MobileCardList
table={table}