feat(ui): improve table controls and analytics filters
This commit is contained in:
parent
8bff691089
commit
8f3c41ae77
@ -7,3 +7,9 @@ export { DataTableBulkActions } from './bulk-actions'
|
||||
export { TableSkeleton } from './table-skeleton'
|
||||
export { TableEmpty } from './table-empty'
|
||||
export { MobileCardList } from './mobile-card-list'
|
||||
|
||||
export const DISABLED_ROW_DESKTOP =
|
||||
'bg-muted/85 hover:bg-muted [&>td:first-child]:border-l-muted-foreground/35 dark:bg-zinc-700/55 dark:hover:bg-zinc-700/70 [&>td:first-child]:border-l-4 [&>td:first-child]:pl-1 dark:[&>td:first-child]:border-l-zinc-300/70'
|
||||
|
||||
export const DISABLED_ROW_MOBILE =
|
||||
'border-l-4 border-l-muted-foreground/35 bg-muted/85 dark:border-l-zinc-300/70 dark:bg-zinc-700/55'
|
||||
|
||||
@ -28,6 +28,7 @@ import {
|
||||
} from '@/components/ui/tooltip'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { DataTableColumnHeader } from '@/components/data-table/column-header'
|
||||
import { GroupBadge } from '@/components/group-badge'
|
||||
import {
|
||||
StatusBadge,
|
||||
dotColorMap,
|
||||
@ -876,8 +877,8 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
const group = row.getValue('group') as string
|
||||
const groupArray = parseGroupsList(group)
|
||||
|
||||
const groupBadges = groupArray.map((g, idx) => (
|
||||
<StatusBadge key={idx} label={g} autoColor={g} size='sm' />
|
||||
const groupBadges = groupArray.map((g) => (
|
||||
<GroupBadge key={g} group={g} size='sm' />
|
||||
))
|
||||
|
||||
return (
|
||||
@ -1035,7 +1036,7 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
|
||||
return <DataTableRowActions row={row} />
|
||||
},
|
||||
size: 100,
|
||||
size: 132,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
|
||||
@ -26,6 +26,8 @@ import {
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
DISABLED_ROW_DESKTOP,
|
||||
DISABLED_ROW_MOBILE,
|
||||
DataTableToolbar,
|
||||
TableSkeleton,
|
||||
TableEmpty,
|
||||
@ -368,9 +370,7 @@ export function ChannelsTable() {
|
||||
emptyTitle='No Channels Found'
|
||||
emptyDescription='No channels available. Create your first channel to get started.'
|
||||
getRowClassName={(row) =>
|
||||
isDisabledChannelRow(row.original)
|
||||
? 'border-l-4 border-l-muted-foreground/35 bg-muted/85 dark:border-l-zinc-300/70 dark:bg-zinc-700/55'
|
||||
: undefined
|
||||
isDisabledChannelRow(row.original) ? DISABLED_ROW_MOBILE : undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
@ -419,7 +419,7 @@ export function ChannelsTable() {
|
||||
data-state={row.getIsSelected() && 'selected'}
|
||||
className={cn(
|
||||
isDisabledChannelRow(row.original) &&
|
||||
'bg-muted/85 hover:bg-muted [&>td:first-child]:border-l-muted-foreground/35 dark:bg-zinc-700/55 dark:hover:bg-zinc-700/70 [&>td:first-child]:border-l-4 [&>td:first-child]:pl-1 dark:[&>td:first-child]:border-l-zinc-300/70'
|
||||
DISABLED_ROW_DESKTOP
|
||||
)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
Boxes,
|
||||
Pencil,
|
||||
TestTube,
|
||||
Gauge,
|
||||
DollarSign,
|
||||
Download,
|
||||
Copy,
|
||||
@ -14,6 +15,7 @@ import {
|
||||
Key,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@ -25,10 +27,17 @@ import {
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { MODEL_FETCHABLE_TYPES } from '../constants'
|
||||
import {
|
||||
channelsQueryKeys,
|
||||
handleDeleteChannel,
|
||||
handleTestChannel,
|
||||
handleToggleChannelStatus,
|
||||
isChannelEnabled,
|
||||
isMultiKeyChannel,
|
||||
@ -47,6 +56,8 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
const { setOpen, setCurrentRow, upstream } = useChannels()
|
||||
const queryClient = useQueryClient()
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
|
||||
const [isTesting, setIsTesting] = useState(false)
|
||||
const [isTogglingStatus, setIsTogglingStatus] = useState(false)
|
||||
|
||||
const isEnabled = isChannelEnabled(channel)
|
||||
const isMultiKey = isMultiKeyChannel(channel)
|
||||
@ -61,6 +72,18 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
setOpen('test-channel')
|
||||
}
|
||||
|
||||
const handleDirectTest = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation()
|
||||
setIsTesting(true)
|
||||
try {
|
||||
await handleTestChannel(channel.id, undefined, () => {
|
||||
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
})
|
||||
} finally {
|
||||
setIsTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleQueryBalance = () => {
|
||||
setCurrentRow(channel)
|
||||
setOpen('balance-query')
|
||||
@ -86,148 +109,184 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
setOpen('multi-key-manage')
|
||||
}
|
||||
|
||||
const handleToggleStatus = () => {
|
||||
handleToggleChannelStatus(channel.id, channel.status, queryClient)
|
||||
const handleToggleStatus = async (
|
||||
e?: React.MouseEvent<HTMLButtonElement>
|
||||
) => {
|
||||
e?.stopPropagation()
|
||||
setIsTogglingStatus(true)
|
||||
try {
|
||||
await handleToggleChannelStatus(channel.id, channel.status, queryClient)
|
||||
} finally {
|
||||
setIsTogglingStatus(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='data-[state=open]:bg-muted flex h-8 w-8 p-0'
|
||||
>
|
||||
<MoreHorizontal className='h-4 w-4' />
|
||||
<span className='sr-only'>{t('Open menu')}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-48'>
|
||||
{/* Edit */}
|
||||
<DropdownMenuItem onClick={handleEdit}>
|
||||
{t('Edit')}
|
||||
<DropdownMenuShortcut>
|
||||
<Pencil size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* Test Connection */}
|
||||
<DropdownMenuItem onClick={handleTest}>
|
||||
{t('Test Connection')}
|
||||
<DropdownMenuShortcut>
|
||||
<TestTube size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* Query Balance */}
|
||||
<DropdownMenuItem onClick={handleQueryBalance}>
|
||||
{t('Query Balance')}
|
||||
<DropdownMenuShortcut>
|
||||
<DollarSign size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* Fetch Models */}
|
||||
<DropdownMenuItem onClick={handleFetchModels}>
|
||||
{t('Fetch Models')}
|
||||
<DropdownMenuShortcut>
|
||||
<Download size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* Detect Upstream Updates (only for fetchable channel types) */}
|
||||
{MODEL_FETCHABLE_TYPES.has(channel.type) && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
const meta = parseUpstreamUpdateMeta(channel.settings)
|
||||
if (
|
||||
meta.pendingAddModels.length > 0 ||
|
||||
meta.pendingRemoveModels.length > 0
|
||||
) {
|
||||
upstream.openModal(
|
||||
channel,
|
||||
meta.pendingAddModels,
|
||||
meta.pendingRemoveModels,
|
||||
meta.pendingAddModels.length > 0 ? 'add' : 'remove'
|
||||
)
|
||||
} else {
|
||||
upstream.detectChannelUpdates(channel)
|
||||
}
|
||||
}}
|
||||
<div className='flex items-center justify-end gap-1'>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
onClick={handleDirectTest}
|
||||
disabled={isTesting}
|
||||
aria-label={t('Test Connection')}
|
||||
>
|
||||
{t('Upstream Updates')}
|
||||
{isTesting ? (
|
||||
<Loader2 className='size-4 animate-spin' />
|
||||
) : (
|
||||
<Gauge className='size-4' />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('Test Connection')}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
onClick={handleToggleStatus}
|
||||
disabled={isTogglingStatus}
|
||||
aria-label={isEnabled ? t('Disable') : t('Enable')}
|
||||
className={
|
||||
isEnabled
|
||||
? 'text-destructive hover:text-destructive'
|
||||
: 'text-emerald-600 hover:text-emerald-600 dark:text-emerald-400 dark:hover:text-emerald-400'
|
||||
}
|
||||
>
|
||||
{isTogglingStatus ? (
|
||||
<Loader2 className='size-4 animate-spin' />
|
||||
) : isEnabled ? (
|
||||
<PowerOff className='size-4' />
|
||||
) : (
|
||||
<Power className='size-4' />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isEnabled ? t('Disable') : t('Enable')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='data-[state=open]:bg-muted flex h-8 w-8 p-0'
|
||||
>
|
||||
<MoreHorizontal className='h-4 w-4' />
|
||||
<span className='sr-only'>{t('Open menu')}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-48'>
|
||||
{/* Edit */}
|
||||
<DropdownMenuItem onClick={handleEdit}>
|
||||
{t('Edit')}
|
||||
<DropdownMenuShortcut>
|
||||
<RefreshCw size={16} />
|
||||
<Pencil size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{/* Ollama Models (only for Ollama channels) */}
|
||||
{channel.type === 4 && (
|
||||
<DropdownMenuItem onClick={handleManageOllamaModels}>
|
||||
{t('Manage Ollama Models')}
|
||||
{/* Test Connection */}
|
||||
<DropdownMenuItem onClick={handleTest}>
|
||||
{t('Test Connection')}
|
||||
<DropdownMenuShortcut>
|
||||
<Boxes size={16} />
|
||||
<TestTube size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Copy Channel */}
|
||||
<DropdownMenuItem onClick={handleCopy}>
|
||||
{t('Copy Channel')}
|
||||
<DropdownMenuShortcut>
|
||||
<Copy size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* Manage Keys (only for multi-key channels) */}
|
||||
{isMultiKey && (
|
||||
<DropdownMenuItem onClick={handleManageKeys}>
|
||||
{t('Manage Keys')}
|
||||
{/* Query Balance */}
|
||||
<DropdownMenuItem onClick={handleQueryBalance}>
|
||||
{t('Query Balance')}
|
||||
<DropdownMenuShortcut>
|
||||
<Key size={16} />
|
||||
<DollarSign size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
{/* Fetch Models */}
|
||||
<DropdownMenuItem onClick={handleFetchModels}>
|
||||
{t('Fetch Models')}
|
||||
<DropdownMenuShortcut>
|
||||
<Download size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* Enable/Disable */}
|
||||
<DropdownMenuItem onClick={handleToggleStatus}>
|
||||
{isEnabled ? (
|
||||
<>
|
||||
{t('Disable')}
|
||||
{/* Detect Upstream Updates (only for fetchable channel types) */}
|
||||
{MODEL_FETCHABLE_TYPES.has(channel.type) && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
const meta = parseUpstreamUpdateMeta(channel.settings)
|
||||
if (
|
||||
meta.pendingAddModels.length > 0 ||
|
||||
meta.pendingRemoveModels.length > 0
|
||||
) {
|
||||
upstream.openModal(
|
||||
channel,
|
||||
meta.pendingAddModels,
|
||||
meta.pendingRemoveModels,
|
||||
meta.pendingAddModels.length > 0 ? 'add' : 'remove'
|
||||
)
|
||||
} else {
|
||||
upstream.detectChannelUpdates(channel)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('Upstream Updates')}
|
||||
<DropdownMenuShortcut>
|
||||
<PowerOff size={16} />
|
||||
<RefreshCw size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{t('Enable')}
|
||||
<DropdownMenuShortcut>
|
||||
<Power size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
{/* Ollama Models (only for Ollama channels) */}
|
||||
{channel.type === 4 && (
|
||||
<DropdownMenuItem onClick={handleManageOllamaModels}>
|
||||
{t('Manage Ollama Models')}
|
||||
<DropdownMenuShortcut>
|
||||
<Boxes size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{/* Delete */}
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
e.preventDefault()
|
||||
setDeleteConfirmOpen(true)
|
||||
}}
|
||||
className='text-destructive focus:text-destructive'
|
||||
>
|
||||
{t('Delete')}
|
||||
<DropdownMenuShortcut>
|
||||
<Trash2 size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Copy Channel */}
|
||||
<DropdownMenuItem onClick={handleCopy}>
|
||||
{t('Copy Channel')}
|
||||
<DropdownMenuShortcut>
|
||||
<Copy size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* Manage Keys (only for multi-key channels) */}
|
||||
{isMultiKey && (
|
||||
<DropdownMenuItem onClick={handleManageKeys}>
|
||||
{t('Manage Keys')}
|
||||
<DropdownMenuShortcut>
|
||||
<Key size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Delete */}
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
e.preventDefault()
|
||||
setDeleteConfirmOpen(true)
|
||||
}}
|
||||
className='text-destructive focus:text-destructive'
|
||||
>
|
||||
{t('Delete')}
|
||||
<DropdownMenuShortcut>
|
||||
<Trash2 size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteConfirmOpen}
|
||||
@ -241,6 +300,6 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
setDeleteConfirmOpen(false)
|
||||
}}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -24,6 +24,7 @@ import {
|
||||
} from '@/components/ui/select'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { GroupBadge } from '@/components/group-badge'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import {
|
||||
editTagChannels,
|
||||
@ -388,17 +389,14 @@ export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) {
|
||||
</Label>
|
||||
<div className='flex min-h-[60px] flex-wrap gap-2 rounded-md border p-3'>
|
||||
{availableGroups.map((group) => (
|
||||
<StatusBadge
|
||||
<GroupBadge
|
||||
key={group}
|
||||
variant={
|
||||
selectedGroups.includes(group) ? 'info' : 'neutral'
|
||||
}
|
||||
className='cursor-pointer transition-opacity hover:opacity-70'
|
||||
copyable={false}
|
||||
group={group}
|
||||
className={`cursor-pointer rounded-sm transition-opacity hover:opacity-70 ${
|
||||
selectedGroups.includes(group) ? 'bg-muted/70 px-1' : ''
|
||||
}`}
|
||||
onClick={() => handleToggleGroup(group)}
|
||||
>
|
||||
{group}
|
||||
</StatusBadge>
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -5,43 +5,51 @@ import { useTranslation } from 'react-i18next'
|
||||
import type { TimeGranularity } from '@/lib/time'
|
||||
import { VCHART_OPTION } from '@/lib/vchart'
|
||||
import { useTheme } from '@/context/theme-provider'
|
||||
import { DEFAULT_TIME_GRANULARITY } from '@/features/dashboard/constants'
|
||||
import {
|
||||
CONSUMPTION_DISTRIBUTION_CHART_OPTIONS,
|
||||
DEFAULT_TIME_GRANULARITY,
|
||||
} from '@/features/dashboard/constants'
|
||||
import { processChartData } from '@/features/dashboard/lib'
|
||||
import type { QuotaDataItem } from '@/features/dashboard/types'
|
||||
import type {
|
||||
ConsumptionDistributionChartType,
|
||||
QuotaDataItem,
|
||||
} from '@/features/dashboard/types'
|
||||
|
||||
let themeManagerPromise: Promise<
|
||||
(typeof import('@visactor/vchart'))['ThemeManager']
|
||||
> | null = null
|
||||
|
||||
type DistributionChartType = 'bar' | 'area'
|
||||
|
||||
interface ConsumptionDistributionChartProps {
|
||||
data: QuotaDataItem[]
|
||||
loading?: boolean
|
||||
timeGranularity?: TimeGranularity
|
||||
defaultChartType?: ConsumptionDistributionChartType
|
||||
}
|
||||
|
||||
const CHART_TYPES: Array<{
|
||||
value: DistributionChartType
|
||||
labelKey: string
|
||||
icon: typeof BarChart3
|
||||
}> = [
|
||||
{ value: 'bar', labelKey: 'Bar Chart', icon: BarChart3 },
|
||||
{ value: 'area', labelKey: 'Area Chart', icon: AreaChart },
|
||||
]
|
||||
const CHART_TYPE_ICONS: Record<ConsumptionDistributionChartType, typeof BarChart3> =
|
||||
{
|
||||
bar: BarChart3,
|
||||
area: AreaChart,
|
||||
}
|
||||
|
||||
export function ConsumptionDistributionChart(
|
||||
props: ConsumptionDistributionChartProps
|
||||
) {
|
||||
const { t } = useTranslation()
|
||||
const { resolvedTheme } = useTheme()
|
||||
const [chartType, setChartType] = useState<DistributionChartType>('bar')
|
||||
const [chartType, setChartType] = useState<ConsumptionDistributionChartType>(
|
||||
props.defaultChartType ?? 'bar'
|
||||
)
|
||||
const [themeReady, setThemeReady] = useState(false)
|
||||
const themeManagerRef = useRef<
|
||||
(typeof import('@visactor/vchart'))['ThemeManager'] | null
|
||||
>(null)
|
||||
const timeGranularity = props.timeGranularity ?? DEFAULT_TIME_GRANULARITY
|
||||
|
||||
useEffect(() => {
|
||||
if (props.defaultChartType) setChartType(props.defaultChartType)
|
||||
}, [props.defaultChartType])
|
||||
|
||||
useEffect(() => {
|
||||
const updateTheme = async () => {
|
||||
setThemeReady(false)
|
||||
@ -81,8 +89,8 @@ export function ConsumptionDistributionChart(
|
||||
</div>
|
||||
|
||||
<div className='bg-muted/60 inline-flex h-8 rounded-md border p-0.5'>
|
||||
{CHART_TYPES.map((item) => {
|
||||
const Icon = item.icon
|
||||
{CONSUMPTION_DISTRIBUTION_CHART_OPTIONS.map((item) => {
|
||||
const Icon = CHART_TYPE_ICONS[item.value]
|
||||
return (
|
||||
<button
|
||||
key={item.value}
|
||||
|
||||
@ -5,47 +5,51 @@ import { useTranslation } from 'react-i18next'
|
||||
import type { TimeGranularity } from '@/lib/time'
|
||||
import { VCHART_OPTION } from '@/lib/vchart'
|
||||
import { useTheme } from '@/context/theme-provider'
|
||||
import { DEFAULT_TIME_GRANULARITY } from '@/features/dashboard/constants'
|
||||
import {
|
||||
DEFAULT_TIME_GRANULARITY,
|
||||
MODEL_ANALYTICS_CHART_OPTIONS,
|
||||
} from '@/features/dashboard/constants'
|
||||
import { processChartData } from '@/features/dashboard/lib'
|
||||
import type { QuotaDataItem } from '@/features/dashboard/types'
|
||||
import type {
|
||||
ModelAnalyticsChartTab,
|
||||
QuotaDataItem,
|
||||
} from '@/features/dashboard/types'
|
||||
|
||||
let themeManagerPromise: Promise<
|
||||
(typeof import('@visactor/vchart'))['ThemeManager']
|
||||
> | null = null
|
||||
|
||||
type ChartTab = 'trend' | 'proportion' | 'top'
|
||||
type ChartSpecKey = 'spec_model_line' | 'spec_pie' | 'spec_rank_bar'
|
||||
|
||||
const CHART_TABS: {
|
||||
value: ChartTab
|
||||
labelKey: string
|
||||
specKey: ChartSpecKey
|
||||
}[] = [
|
||||
{ value: 'trend', labelKey: 'Call Trend', specKey: 'spec_model_line' },
|
||||
{
|
||||
value: 'proportion',
|
||||
labelKey: 'Call Count Distribution',
|
||||
specKey: 'spec_pie',
|
||||
},
|
||||
{ value: 'top', labelKey: 'Call Count Ranking', specKey: 'spec_rank_bar' },
|
||||
]
|
||||
const CHART_SPEC_KEYS: Record<ModelAnalyticsChartTab, ChartSpecKey> = {
|
||||
trend: 'spec_model_line',
|
||||
proportion: 'spec_pie',
|
||||
top: 'spec_rank_bar',
|
||||
}
|
||||
|
||||
interface ModelChartsProps {
|
||||
data: QuotaDataItem[]
|
||||
loading?: boolean
|
||||
timeGranularity?: TimeGranularity
|
||||
defaultChartTab?: ModelAnalyticsChartTab
|
||||
}
|
||||
|
||||
export function ModelCharts(props: ModelChartsProps) {
|
||||
const { t } = useTranslation()
|
||||
const { resolvedTheme } = useTheme()
|
||||
const [activeTab, setActiveTab] = useState<ChartTab>('trend')
|
||||
const [activeTab, setActiveTab] = useState<ModelAnalyticsChartTab>(
|
||||
props.defaultChartTab ?? 'trend'
|
||||
)
|
||||
const [themeReady, setThemeReady] = useState(false)
|
||||
const themeManagerRef = useRef<
|
||||
(typeof import('@visactor/vchart'))['ThemeManager'] | null
|
||||
>(null)
|
||||
const timeGranularity = props.timeGranularity ?? DEFAULT_TIME_GRANULARITY
|
||||
|
||||
useEffect(() => {
|
||||
if (props.defaultChartTab) setActiveTab(props.defaultChartTab)
|
||||
}, [props.defaultChartTab])
|
||||
|
||||
useEffect(() => {
|
||||
const updateTheme = async () => {
|
||||
setThemeReady(false)
|
||||
@ -70,8 +74,7 @@ export function ModelCharts(props: ModelChartsProps) {
|
||||
[props.data, props.loading, timeGranularity, t]
|
||||
)
|
||||
|
||||
const activeSpec = CHART_TABS.find((tab) => tab.value === activeTab)
|
||||
const spec = activeSpec ? chartData[activeSpec.specKey] : null
|
||||
const spec = chartData[CHART_SPEC_KEYS[activeTab]]
|
||||
|
||||
return (
|
||||
<div className='overflow-hidden rounded-lg border'>
|
||||
@ -87,7 +90,7 @@ export function ModelCharts(props: ModelChartsProps) {
|
||||
</div>
|
||||
|
||||
<div className='bg-muted/60 inline-flex h-8 rounded-md border p-0.5'>
|
||||
{CHART_TABS.map((tab) => (
|
||||
{MODEL_ANALYTICS_CHART_OPTIONS.map((tab) => (
|
||||
<button
|
||||
key={tab.value}
|
||||
type='button'
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Filter, RotateCcw, Calendar, Search } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
@ -26,20 +26,20 @@ import {
|
||||
} from '@/components/ui/select'
|
||||
import { DateTimePicker } from '@/components/datetime-picker'
|
||||
import {
|
||||
DEFAULT_TIME_GRANULARITY,
|
||||
TIME_GRANULARITY_OPTIONS,
|
||||
TIME_RANGE_PRESETS,
|
||||
EMPTY_DASHBOARD_FILTERS,
|
||||
} from '@/features/dashboard/constants'
|
||||
import {
|
||||
buildDefaultDashboardFilters,
|
||||
cleanFilters,
|
||||
getSavedGranularity,
|
||||
saveGranularity,
|
||||
getDefaultDays,
|
||||
} from '@/features/dashboard/lib'
|
||||
import { type DashboardFilters } from '@/features/dashboard/types'
|
||||
import type {
|
||||
DashboardChartPreferences,
|
||||
DashboardFilters,
|
||||
} from '@/features/dashboard/types'
|
||||
|
||||
interface ModelsFilterProps {
|
||||
preferences: DashboardChartPreferences
|
||||
onFilterChange: (filters: DashboardFilters) => void
|
||||
onReset: () => void
|
||||
}
|
||||
@ -58,30 +58,27 @@ const SectionDivider = ({ label }: { label: string }) => (
|
||||
</div>
|
||||
)
|
||||
|
||||
export function ModelsFilter({ onFilterChange, onReset }: ModelsFilterProps) {
|
||||
export function ModelsFilter(props: ModelsFilterProps) {
|
||||
const { t } = useTranslation()
|
||||
// 使用已缓存的用户数据,避免重复调用 API
|
||||
const user = useAuthStore((state) => state.auth.user)
|
||||
const isAdmin = user?.role && user.role >= 10
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [filters, setFilters] = useState<DashboardFilters>(() => {
|
||||
const granularity = getSavedGranularity()
|
||||
const days = getDefaultDays(granularity)
|
||||
const { start, end } = getNormalizedDateRange(days)
|
||||
return {
|
||||
...EMPTY_DASHBOARD_FILTERS,
|
||||
start_timestamp: start,
|
||||
end_timestamp: end,
|
||||
time_granularity: granularity,
|
||||
}
|
||||
})
|
||||
const [filters, setFilters] = useState<DashboardFilters>(() =>
|
||||
buildDefaultDashboardFilters(props.preferences)
|
||||
)
|
||||
const [selectedRange, setSelectedRange] = useState<number | null>(() =>
|
||||
getDefaultDays()
|
||||
props.preferences.defaultTimeRangeDays
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setFilters(buildDefaultDashboardFilters(props.preferences))
|
||||
setSelectedRange(props.preferences.defaultTimeRangeDays)
|
||||
}, [props.preferences])
|
||||
|
||||
const handleApply = () => {
|
||||
onFilterChange(
|
||||
props.onFilterChange(
|
||||
cleanFilters(
|
||||
filters as unknown as Record<string, unknown>
|
||||
) as typeof filters
|
||||
@ -90,17 +87,15 @@ export function ModelsFilter({ onFilterChange, onReset }: ModelsFilterProps) {
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
const days = getDefaultDays(DEFAULT_TIME_GRANULARITY)
|
||||
const days = props.preferences.defaultTimeRangeDays
|
||||
const { start, end } = getNormalizedDateRange(days)
|
||||
setFilters({
|
||||
...EMPTY_DASHBOARD_FILTERS,
|
||||
...buildDefaultDashboardFilters(props.preferences),
|
||||
start_timestamp: start,
|
||||
end_timestamp: end,
|
||||
time_granularity: DEFAULT_TIME_GRANULARITY,
|
||||
})
|
||||
setSelectedRange(days)
|
||||
saveGranularity(DEFAULT_TIME_GRANULARITY)
|
||||
onReset()
|
||||
props.onReset()
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
@ -111,8 +106,6 @@ export function ModelsFilter({ onFilterChange, onReset }: ModelsFilterProps) {
|
||||
setFilters((prev) => ({ ...prev, [field]: value }))
|
||||
if (field === 'start_timestamp' || field === 'end_timestamp')
|
||||
setSelectedRange(null)
|
||||
if (field === 'time_granularity' && typeof value === 'string')
|
||||
saveGranularity(value as TimeGranularity)
|
||||
}
|
||||
|
||||
const handleQuickRange = (days: number) => {
|
||||
|
||||
22
web/default/src/features/dashboard/constants.ts
vendored
22
web/default/src/features/dashboard/constants.ts
vendored
@ -1,9 +1,18 @@
|
||||
import type { DashboardFilters } from './types'
|
||||
import type { DashboardChartPreferences, DashboardFilters } from './types'
|
||||
|
||||
export const TIME_GRANULARITY_STORAGE_KEY = 'data_export_default_time'
|
||||
export const DASHBOARD_CHART_PREFERENCES_STORAGE_KEY =
|
||||
'dashboard_models_chart_preferences'
|
||||
export const DEFAULT_TIME_GRANULARITY = 'hour' as const
|
||||
export const MAX_CHART_TREND_POINTS = 7
|
||||
|
||||
export const DEFAULT_DASHBOARD_CHART_PREFERENCES: DashboardChartPreferences = {
|
||||
consumptionDistributionChart: 'bar',
|
||||
modelAnalyticsChart: 'trend',
|
||||
defaultTimeRangeDays: 1,
|
||||
defaultTimeGranularity: DEFAULT_TIME_GRANULARITY,
|
||||
}
|
||||
|
||||
export const TIME_RANGE_BY_GRANULARITY = {
|
||||
hour: 1,
|
||||
day: 7,
|
||||
@ -23,6 +32,17 @@ export const TIME_RANGE_PRESETS = [
|
||||
{ label: '29 Days', days: 29 },
|
||||
] as const
|
||||
|
||||
export const CONSUMPTION_DISTRIBUTION_CHART_OPTIONS = [
|
||||
{ value: 'bar', labelKey: 'Bar Chart' },
|
||||
{ value: 'area', labelKey: 'Area Chart' },
|
||||
] as const
|
||||
|
||||
export const MODEL_ANALYTICS_CHART_OPTIONS = [
|
||||
{ value: 'trend', labelKey: 'Call Trend' },
|
||||
{ value: 'proportion', labelKey: 'Call Count Distribution' },
|
||||
{ value: 'top', labelKey: 'Call Count Ranking' },
|
||||
] as const
|
||||
|
||||
export const EMPTY_DASHBOARD_FILTERS: DashboardFilters = {
|
||||
start_timestamp: undefined,
|
||||
end_timestamp: undefined,
|
||||
|
||||
88
web/default/src/features/dashboard/index.tsx
vendored
88
web/default/src/features/dashboard/index.tsx
vendored
@ -11,6 +11,12 @@ import {
|
||||
CardStaggerItem,
|
||||
FadeIn,
|
||||
} from '@/components/page-transition'
|
||||
import {
|
||||
buildDefaultDashboardFilters,
|
||||
getSavedChartPreferences,
|
||||
saveChartPreferences,
|
||||
} from './lib'
|
||||
import { ModelsChartPreferences } from './components/models/models-chart-preferences'
|
||||
import { ModelsFilter } from './components/models/models-filter-dialog'
|
||||
import { AnnouncementsPanel } from './components/overview/announcements-panel'
|
||||
import { ApiInfoPanel } from './components/overview/api-info-panel'
|
||||
@ -23,7 +29,11 @@ import {
|
||||
DASHBOARD_DEFAULT_SECTION,
|
||||
DASHBOARD_SECTION_IDS,
|
||||
} from './section-registry'
|
||||
import { type DashboardFilters, type QuotaDataItem } from './types'
|
||||
import {
|
||||
type DashboardChartPreferences,
|
||||
type DashboardFilters,
|
||||
type QuotaDataItem,
|
||||
} from './types'
|
||||
|
||||
const route = getRouteApi('/_authenticated/dashboard/$section')
|
||||
|
||||
@ -107,17 +117,21 @@ export function Dashboard() {
|
||||
const activeSection = (params.section ??
|
||||
DASHBOARD_DEFAULT_SECTION) as DashboardSectionId
|
||||
|
||||
const [modelFilters, setModelFilters] = useState<DashboardFilters>({})
|
||||
const [modelData, setModelData] = useState<QuotaDataItem[]>([])
|
||||
const [dataLoading, setDataLoading] = useState(false)
|
||||
const [chartPreferences, setChartPreferences] =
|
||||
useState<DashboardChartPreferences>(() => getSavedChartPreferences())
|
||||
const [modelFilters, setModelFilters] = useState<DashboardFilters>(() =>
|
||||
buildDefaultDashboardFilters(getSavedChartPreferences())
|
||||
)
|
||||
|
||||
const handleFilterChange = useCallback((filters: DashboardFilters) => {
|
||||
setModelFilters(filters)
|
||||
}, [])
|
||||
|
||||
const handleResetFilters = useCallback(() => {
|
||||
setModelFilters({})
|
||||
}, [])
|
||||
setModelFilters(buildDefaultDashboardFilters(chartPreferences))
|
||||
}, [chartPreferences])
|
||||
|
||||
const handleDataUpdate = useCallback(
|
||||
(data: QuotaDataItem[], loading: boolean) => {
|
||||
@ -127,6 +141,15 @@ export function Dashboard() {
|
||||
[]
|
||||
)
|
||||
|
||||
const handleChartPreferencesChange = useCallback(
|
||||
(preferences: DashboardChartPreferences) => {
|
||||
setChartPreferences(preferences)
|
||||
setModelFilters(buildDefaultDashboardFilters(preferences))
|
||||
saveChartPreferences(preferences)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const meta = SECTION_META[activeSection] ?? SECTION_META.overview
|
||||
const isAdmin = Boolean(userRole && userRole >= ROLE.ADMIN)
|
||||
const visibleSections = useMemo(
|
||||
@ -146,6 +169,20 @@ export function Dashboard() {
|
||||
[navigate]
|
||||
)
|
||||
const showSectionTabs = activeSection !== 'overview' && visibleSections.length > 1
|
||||
const modelActions =
|
||||
activeSection === 'models' ? (
|
||||
<>
|
||||
<ModelsChartPreferences
|
||||
preferences={chartPreferences}
|
||||
onPreferencesChange={handleChartPreferencesChange}
|
||||
/>
|
||||
<ModelsFilter
|
||||
preferences={chartPreferences}
|
||||
onFilterChange={handleFilterChange}
|
||||
onReset={handleResetFilters}
|
||||
/>
|
||||
</>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<SectionPageLayout>
|
||||
@ -153,26 +190,29 @@ export function Dashboard() {
|
||||
<SectionPageLayout.Description>
|
||||
{t(meta.descriptionKey)}
|
||||
</SectionPageLayout.Description>
|
||||
{activeSection === 'models' && (
|
||||
<SectionPageLayout.Actions>
|
||||
<ModelsFilter
|
||||
onFilterChange={handleFilterChange}
|
||||
onReset={handleResetFilters}
|
||||
/>
|
||||
</SectionPageLayout.Actions>
|
||||
)}
|
||||
<SectionPageLayout.Content>
|
||||
<div className='space-y-4'>
|
||||
{showSectionTabs && (
|
||||
<Tabs value={activeSection} onValueChange={handleSectionChange}>
|
||||
<TabsList className='h-auto max-w-full flex-wrap justify-start'>
|
||||
{visibleSections.map((section) => (
|
||||
<TabsTrigger key={section} value={section}>
|
||||
{t(SECTION_META[section].titleKey)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
{activeSection !== 'overview' && (
|
||||
<div className='flex flex-wrap items-center justify-between gap-2'>
|
||||
{showSectionTabs ? (
|
||||
<Tabs value={activeSection} onValueChange={handleSectionChange}>
|
||||
<TabsList className='h-auto max-w-full flex-wrap justify-start'>
|
||||
{visibleSections.map((section) => (
|
||||
<TabsTrigger key={section} value={section}>
|
||||
{t(SECTION_META[section].titleKey)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
{modelActions != null && (
|
||||
<div className='flex shrink-0 flex-wrap items-center gap-2'>
|
||||
{modelActions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{activeSection === 'overview' && (
|
||||
<>
|
||||
@ -208,6 +248,9 @@ export function Dashboard() {
|
||||
<LazyConsumptionDistributionChart
|
||||
data={modelData}
|
||||
loading={dataLoading}
|
||||
defaultChartType={
|
||||
chartPreferences.consumptionDistributionChart
|
||||
}
|
||||
timeGranularity={
|
||||
modelFilters.time_granularity || DEFAULT_TIME_GRANULARITY
|
||||
}
|
||||
@ -219,6 +262,7 @@ export function Dashboard() {
|
||||
<LazyModelCharts
|
||||
data={modelData}
|
||||
loading={dataLoading}
|
||||
defaultChartTab={chartPreferences.modelAnalyticsChart}
|
||||
timeGranularity={
|
||||
modelFilters.time_granularity || DEFAULT_TIME_GRANULARITY
|
||||
}
|
||||
|
||||
14
web/default/src/features/dashboard/lib/charts.ts
vendored
14
web/default/src/features/dashboard/lib/charts.ts
vendored
@ -910,8 +910,18 @@ export function processUserChartData(
|
||||
},
|
||||
},
|
||||
},
|
||||
area: { style: { fillOpacity: 0.15 } },
|
||||
line: { style: { lineWidth: 2 } },
|
||||
area: {
|
||||
style: {
|
||||
fillOpacity: 0.15,
|
||||
curveType: 'monotone',
|
||||
},
|
||||
},
|
||||
line: {
|
||||
style: {
|
||||
lineWidth: 2,
|
||||
curveType: 'monotone',
|
||||
},
|
||||
},
|
||||
point: { visible: false },
|
||||
color: { specified: userColorMap },
|
||||
background: { fill: 'transparent' },
|
||||
|
||||
106
web/default/src/features/dashboard/lib/filters.ts
vendored
106
web/default/src/features/dashboard/lib/filters.ts
vendored
@ -1,9 +1,46 @@
|
||||
import type { TimeGranularity } from '@/lib/time'
|
||||
import { getNormalizedDateRange } from '@/lib/time'
|
||||
import {
|
||||
DASHBOARD_CHART_PREFERENCES_STORAGE_KEY,
|
||||
DEFAULT_DASHBOARD_CHART_PREFERENCES,
|
||||
DEFAULT_TIME_GRANULARITY,
|
||||
EMPTY_DASHBOARD_FILTERS,
|
||||
TIME_GRANULARITY_STORAGE_KEY,
|
||||
TIME_RANGE_PRESETS,
|
||||
TIME_RANGE_BY_GRANULARITY,
|
||||
} from '@/features/dashboard/constants'
|
||||
import type {
|
||||
ConsumptionDistributionChartType,
|
||||
DashboardChartPreferences,
|
||||
DashboardFilters,
|
||||
ModelAnalyticsChartTab,
|
||||
} from '@/features/dashboard/types'
|
||||
|
||||
function isTimeGranularity(value: unknown): value is TimeGranularity {
|
||||
return value === 'hour' || value === 'day' || value === 'week'
|
||||
}
|
||||
|
||||
function getLegacySavedGranularity(): TimeGranularity {
|
||||
if (typeof window === 'undefined') return DEFAULT_TIME_GRANULARITY
|
||||
const saved = localStorage.getItem(TIME_GRANULARITY_STORAGE_KEY)
|
||||
return isTimeGranularity(saved) ? saved : DEFAULT_TIME_GRANULARITY
|
||||
}
|
||||
|
||||
function isConsumptionDistributionChartType(
|
||||
value: unknown
|
||||
): value is ConsumptionDistributionChartType {
|
||||
return value === 'bar' || value === 'area'
|
||||
}
|
||||
|
||||
function isModelAnalyticsChartTab(
|
||||
value: unknown
|
||||
): value is ModelAnalyticsChartTab {
|
||||
return value === 'trend' || value === 'proportion' || value === 'top'
|
||||
}
|
||||
|
||||
function isTimeRangePresetDays(value: unknown): value is number {
|
||||
return TIME_RANGE_PRESETS.some((preset) => preset.days === value)
|
||||
}
|
||||
|
||||
export function cleanFilters<T extends Record<string, unknown>>(
|
||||
filters: T
|
||||
@ -25,20 +62,81 @@ export function getSavedGranularity(
|
||||
override?: TimeGranularity
|
||||
): TimeGranularity {
|
||||
if (override) return override
|
||||
if (typeof window === 'undefined') return DEFAULT_TIME_GRANULARITY
|
||||
const saved = localStorage.getItem(TIME_GRANULARITY_STORAGE_KEY)
|
||||
if (saved === 'hour' || saved === 'day' || saved === 'week') return saved
|
||||
return DEFAULT_TIME_GRANULARITY
|
||||
return getSavedChartPreferences().defaultTimeGranularity
|
||||
}
|
||||
|
||||
export function saveGranularity(granularity: TimeGranularity): void {
|
||||
if (typeof window === 'undefined') return
|
||||
saveChartPreferences({
|
||||
...getSavedChartPreferences(),
|
||||
defaultTimeGranularity: granularity,
|
||||
})
|
||||
localStorage.setItem(TIME_GRANULARITY_STORAGE_KEY, granularity)
|
||||
}
|
||||
|
||||
export function getSavedChartPreferences(): DashboardChartPreferences {
|
||||
if (typeof window === 'undefined') return DEFAULT_DASHBOARD_CHART_PREFERENCES
|
||||
|
||||
const fallbackPreferences = {
|
||||
...DEFAULT_DASHBOARD_CHART_PREFERENCES,
|
||||
defaultTimeGranularity: getLegacySavedGranularity(),
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(DASHBOARD_CHART_PREFERENCES_STORAGE_KEY)
|
||||
if (!raw) return fallbackPreferences
|
||||
|
||||
const parsed = JSON.parse(raw) as Partial<DashboardChartPreferences>
|
||||
return {
|
||||
consumptionDistributionChart: isConsumptionDistributionChartType(
|
||||
parsed.consumptionDistributionChart
|
||||
)
|
||||
? parsed.consumptionDistributionChart
|
||||
: fallbackPreferences.consumptionDistributionChart,
|
||||
modelAnalyticsChart: isModelAnalyticsChartTab(parsed.modelAnalyticsChart)
|
||||
? parsed.modelAnalyticsChart
|
||||
: fallbackPreferences.modelAnalyticsChart,
|
||||
defaultTimeRangeDays: isTimeRangePresetDays(parsed.defaultTimeRangeDays)
|
||||
? parsed.defaultTimeRangeDays
|
||||
: fallbackPreferences.defaultTimeRangeDays,
|
||||
defaultTimeGranularity: isTimeGranularity(
|
||||
parsed.defaultTimeGranularity
|
||||
)
|
||||
? parsed.defaultTimeGranularity
|
||||
: fallbackPreferences.defaultTimeGranularity,
|
||||
}
|
||||
} catch {
|
||||
return fallbackPreferences
|
||||
}
|
||||
}
|
||||
|
||||
export function saveChartPreferences(
|
||||
preferences: DashboardChartPreferences
|
||||
): void {
|
||||
if (typeof window === 'undefined') return
|
||||
localStorage.setItem(
|
||||
DASHBOARD_CHART_PREFERENCES_STORAGE_KEY,
|
||||
JSON.stringify(preferences)
|
||||
)
|
||||
}
|
||||
|
||||
export function getDefaultDays(granularity?: TimeGranularity): number {
|
||||
if (!granularity) return getSavedChartPreferences().defaultTimeRangeDays
|
||||
return TIME_RANGE_BY_GRANULARITY[getSavedGranularity(granularity)]
|
||||
}
|
||||
|
||||
export function buildDefaultDashboardFilters(
|
||||
preferences: DashboardChartPreferences = getSavedChartPreferences()
|
||||
): DashboardFilters {
|
||||
const { start, end } = getNormalizedDateRange(preferences.defaultTimeRangeDays)
|
||||
return {
|
||||
...EMPTY_DASHBOARD_FILTERS,
|
||||
start_timestamp: start,
|
||||
end_timestamp: end,
|
||||
time_granularity: preferences.defaultTimeGranularity,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildQueryParams(
|
||||
timeRange: { start_timestamp: number; end_timestamp: number },
|
||||
filters?: { time_granularity?: TimeGranularity; username?: string }
|
||||
|
||||
@ -4,6 +4,9 @@ export {
|
||||
getSavedGranularity,
|
||||
saveGranularity,
|
||||
getDefaultDays,
|
||||
getSavedChartPreferences,
|
||||
saveChartPreferences,
|
||||
buildDefaultDashboardFilters,
|
||||
} from './filters'
|
||||
export {
|
||||
getLatencyColorClass,
|
||||
|
||||
11
web/default/src/features/dashboard/types.ts
vendored
11
web/default/src/features/dashboard/types.ts
vendored
@ -42,6 +42,17 @@ export interface DashboardFilters {
|
||||
username?: string
|
||||
}
|
||||
|
||||
export type ConsumptionDistributionChartType = 'bar' | 'area'
|
||||
|
||||
export type ModelAnalyticsChartTab = 'trend' | 'proportion' | 'top'
|
||||
|
||||
export interface DashboardChartPreferences {
|
||||
consumptionDistributionChart: ConsumptionDistributionChartType
|
||||
modelAnalyticsChart: ModelAnalyticsChartTab
|
||||
defaultTimeRangeDays: number
|
||||
defaultTimeGranularity: TimeGranularity
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API Info Types
|
||||
// ============================================================================
|
||||
|
||||
@ -14,6 +14,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { DataTableColumnHeader } from '@/components/data-table'
|
||||
import { GroupBadge } from '@/components/group-badge'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { getSystemOptions } from '@/features/system-settings/api'
|
||||
import { API_KEY_STATUSES } from '../constants'
|
||||
@ -31,16 +32,6 @@ function getQuotaProgressColor(percentage: number): string {
|
||||
return '[&_[data-slot=progress-indicator]]:bg-emerald-500'
|
||||
}
|
||||
|
||||
function getGroupRatioClassName(ratio: number): string {
|
||||
if (ratio > 1) {
|
||||
return 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/60 dark:bg-amber-950/40 dark:text-amber-300'
|
||||
}
|
||||
if (ratio < 1) {
|
||||
return 'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-900/60 dark:bg-sky-950/40 dark:text-sky-300'
|
||||
}
|
||||
return 'border-border bg-muted text-muted-foreground'
|
||||
}
|
||||
|
||||
function useGroupRatios(): Record<string, number> {
|
||||
const isAdmin = useAuthStore((s) =>
|
||||
Boolean(s.auth.user?.role && s.auth.user.role >= 10)
|
||||
@ -230,7 +221,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className='inline-flex items-center gap-1.5 text-xs'>
|
||||
<span className='text-muted-foreground'>{t('Auto')}</span>
|
||||
<GroupBadge group='auto' />
|
||||
{apiKey.cross_group_retry && (
|
||||
<>
|
||||
<span className='text-muted-foreground/30'>·</span>
|
||||
@ -251,22 +242,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className='inline-flex items-center gap-2 text-xs'>
|
||||
<span className='font-medium'>{group || t('Default')}</span>
|
||||
{ratio != null && (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 font-mono text-[11px] leading-none tabular-nums',
|
||||
getGroupRatioClassName(ratio)
|
||||
)}
|
||||
>
|
||||
<span className='size-1 rounded-full bg-current opacity-60' />
|
||||
<span>{ratio}x</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
return <GroupBadge group={group} ratio={ratio} />
|
||||
},
|
||||
meta: { label: t('Group'), mobileHidden: true },
|
||||
},
|
||||
@ -354,6 +330,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
id: 'actions',
|
||||
cell: ({ row }) => <DataTableRowActions row={row} />,
|
||||
meta: { label: t('Actions') },
|
||||
size: 88,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@ -27,6 +27,8 @@ import {
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
DISABLED_ROW_DESKTOP,
|
||||
DISABLED_ROW_MOBILE,
|
||||
DataTablePagination,
|
||||
DataTableToolbar,
|
||||
TableSkeleton,
|
||||
@ -35,7 +37,7 @@ import {
|
||||
} from '@/components/data-table'
|
||||
import { PageFooterPortal } from '@/components/layout'
|
||||
import { getApiKeys, searchApiKeys } from '../api'
|
||||
import { API_KEY_STATUS_OPTIONS, ERROR_MESSAGES } from '../constants'
|
||||
import { API_KEY_STATUS, API_KEY_STATUS_OPTIONS, ERROR_MESSAGES } from '../constants'
|
||||
import { type ApiKey } from '../types'
|
||||
import { useApiKeysColumns } from './api-keys-columns'
|
||||
import { ApiKeysPrimaryButtons } from './api-keys-primary-buttons'
|
||||
@ -44,6 +46,10 @@ import { DataTableBulkActions } from './data-table-bulk-actions'
|
||||
|
||||
const route = getRouteApi('/_authenticated/keys/')
|
||||
|
||||
function isDisabledApiKeyRow(apiKey: ApiKey) {
|
||||
return apiKey.status !== API_KEY_STATUS.ENABLED
|
||||
}
|
||||
|
||||
export function ApiKeysTable() {
|
||||
const { t } = useTranslation()
|
||||
const { refreshTrigger } = useApiKeys()
|
||||
@ -185,6 +191,11 @@ export function ApiKeysTable() {
|
||||
emptyDescription={t(
|
||||
'No API keys available. Create your first API key to get started.'
|
||||
)}
|
||||
getRowClassName={(row) =>
|
||||
isDisabledApiKeyRow(row.original)
|
||||
? DISABLED_ROW_MOBILE
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
@ -226,11 +237,10 @@ export function ApiKeysTable() {
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && 'selected'}
|
||||
className={
|
||||
(row.original as ApiKey).status !== 1
|
||||
? 'opacity-60'
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
isDisabledApiKeyRow(row.original) &&
|
||||
DISABLED_ROW_DESKTOP
|
||||
)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { DotsHorizontalIcon } from '@radix-ui/react-icons'
|
||||
import { type Row } from '@tanstack/react-table'
|
||||
import {
|
||||
@ -10,6 +10,7 @@ import {
|
||||
ArrowRightLeft,
|
||||
Copy,
|
||||
Link,
|
||||
Loader2,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
@ -26,6 +27,11 @@ import {
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { useChatPresets } from '@/features/chat/hooks/use-chat-presets'
|
||||
import { resolveChatUrl, type ChatPreset } from '@/features/chat/lib/chat-links'
|
||||
import { sendToFluent } from '@/features/chat/lib/send-to-fluent'
|
||||
@ -73,6 +79,7 @@ export function DataTableRowActions<TData>({
|
||||
} = useApiKeys()
|
||||
const isEnabled = apiKey.status === API_KEY_STATUS.ENABLED
|
||||
const { chatPresets, serverAddress } = useChatPresets()
|
||||
const [isTogglingStatus, setIsTogglingStatus] = useState(false)
|
||||
|
||||
const hasChatPresets = chatPresets.length > 0
|
||||
|
||||
@ -117,11 +124,15 @@ export function DataTableRowActions<TData>({
|
||||
[resolveRealKey, apiKey.id, serverAddress, t]
|
||||
)
|
||||
|
||||
const handleToggleStatus = async () => {
|
||||
const handleToggleStatus = async (
|
||||
e?: React.MouseEvent<HTMLButtonElement>
|
||||
) => {
|
||||
e?.stopPropagation()
|
||||
const newStatus = isEnabled
|
||||
? API_KEY_STATUS.DISABLED
|
||||
: API_KEY_STATUS.ENABLED
|
||||
|
||||
setIsTogglingStatus(true)
|
||||
try {
|
||||
const result = await updateApiKeyStatus(apiKey.id, newStatus)
|
||||
if (result.success) {
|
||||
@ -135,125 +146,143 @@ export function DataTableRowActions<TData>({
|
||||
}
|
||||
} catch {
|
||||
toast.error(t(ERROR_MESSAGES.UNEXPECTED))
|
||||
} finally {
|
||||
setIsTogglingStatus(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='data-[state=open]:bg-muted flex h-8 w-8 p-0'
|
||||
>
|
||||
<DotsHorizontalIcon className='h-4 w-4' />
|
||||
<span className='sr-only'>{t('Open menu')}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-[200px]'>
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
const realKey = await resolveRealKey(apiKey.id)
|
||||
if (!realKey) return
|
||||
const ok = await copyToClipboard(realKey)
|
||||
if (ok) toast.success(t('Copied'))
|
||||
}}
|
||||
>
|
||||
{t('Copy Key')}
|
||||
<DropdownMenuShortcut>
|
||||
<Copy size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
const realKey = await resolveRealKey(apiKey.id)
|
||||
if (!realKey) return
|
||||
const connStr = encodeConnectionString(realKey, getServerAddress())
|
||||
const ok = await copyToClipboard(connStr)
|
||||
if (ok) toast.success(t('Copied'))
|
||||
}}
|
||||
>
|
||||
{t('Copy Connection Info')}
|
||||
<DropdownMenuShortcut>
|
||||
<Link size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setCurrentRow(apiKey)
|
||||
setOpen('update')
|
||||
}}
|
||||
>
|
||||
{t('Edit')}
|
||||
<DropdownMenuShortcut>
|
||||
<Edit size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleToggleStatus}>
|
||||
{isEnabled ? (
|
||||
<>
|
||||
{t('Disable')}
|
||||
<DropdownMenuShortcut>
|
||||
<PowerOff size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{t('Enable')}
|
||||
<DropdownMenuShortcut>
|
||||
<Power size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</>
|
||||
<div className='flex items-center justify-end gap-1'>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
onClick={handleToggleStatus}
|
||||
disabled={isTogglingStatus}
|
||||
aria-label={isEnabled ? t('Disable') : t('Enable')}
|
||||
className={
|
||||
isEnabled
|
||||
? 'text-destructive hover:text-destructive'
|
||||
: 'text-emerald-600 hover:text-emerald-600 dark:text-emerald-400 dark:hover:text-emerald-400'
|
||||
}
|
||||
>
|
||||
{isTogglingStatus ? (
|
||||
<Loader2 className='size-4 animate-spin' />
|
||||
) : isEnabled ? (
|
||||
<PowerOff className='size-4' />
|
||||
) : (
|
||||
<Power className='size-4' />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isEnabled ? t('Disable') : t('Enable')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='data-[state=open]:bg-muted flex h-8 w-8 p-0'
|
||||
>
|
||||
<DotsHorizontalIcon className='h-4 w-4' />
|
||||
<span className='sr-only'>{t('Open menu')}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-[200px]'>
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
const realKey = await resolveRealKey(apiKey.id)
|
||||
if (!realKey) return
|
||||
const ok = await copyToClipboard(realKey)
|
||||
if (ok) toast.success(t('Copied'))
|
||||
}}
|
||||
>
|
||||
{t('Copy Key')}
|
||||
<DropdownMenuShortcut>
|
||||
<Copy size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
const realKey = await resolveRealKey(apiKey.id)
|
||||
if (!realKey) return
|
||||
const connStr = encodeConnectionString(
|
||||
realKey,
|
||||
getServerAddress()
|
||||
)
|
||||
const ok = await copyToClipboard(connStr)
|
||||
if (ok) toast.success(t('Copied'))
|
||||
}}
|
||||
>
|
||||
{t('Copy Connection Info')}
|
||||
<DropdownMenuShortcut>
|
||||
<Link size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setCurrentRow(apiKey)
|
||||
setOpen('update')
|
||||
}}
|
||||
>
|
||||
{t('Edit')}
|
||||
<DropdownMenuShortcut>
|
||||
<Edit size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
const realKey = await resolveRealKey(apiKey.id)
|
||||
if (!realKey) return
|
||||
setResolvedKey(realKey)
|
||||
setCurrentRow(apiKey)
|
||||
setOpen('cc-switch')
|
||||
}}
|
||||
>
|
||||
{t('CC Switch')}
|
||||
<DropdownMenuShortcut>
|
||||
<ArrowRightLeft size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
{hasChatPresets && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>{t('Chat')}</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
{chatPresets.map((preset) => (
|
||||
<DropdownMenuItem
|
||||
key={preset.id}
|
||||
onClick={() => handleOpenChatPreset(preset)}
|
||||
>
|
||||
{preset.name}
|
||||
{preset.type !== 'web' && (
|
||||
<DropdownMenuShortcut>
|
||||
<ExternalLink size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
const realKey = await resolveRealKey(apiKey.id)
|
||||
if (!realKey) return
|
||||
setResolvedKey(realKey)
|
||||
setCurrentRow(apiKey)
|
||||
setOpen('cc-switch')
|
||||
}}
|
||||
>
|
||||
{t('CC Switch')}
|
||||
<DropdownMenuShortcut>
|
||||
<ArrowRightLeft size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
{hasChatPresets && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>{t('Chat')}</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
{chatPresets.map((preset) => (
|
||||
<DropdownMenuItem
|
||||
key={preset.id}
|
||||
onClick={() => handleOpenChatPreset(preset)}
|
||||
>
|
||||
{preset.name}
|
||||
{preset.type !== 'web' && (
|
||||
<DropdownMenuShortcut>
|
||||
<ExternalLink size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setCurrentRow(apiKey)
|
||||
setOpen('delete')
|
||||
}}
|
||||
className='text-destructive focus:text-destructive'
|
||||
>
|
||||
{t('Delete')}
|
||||
<DropdownMenuShortcut>
|
||||
<Trash2 size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setCurrentRow(apiKey)
|
||||
setOpen('delete')
|
||||
}}
|
||||
className='text-destructive focus:text-destructive'
|
||||
>
|
||||
{t('Delete')}
|
||||
<DropdownMenuShortcut>
|
||||
<Trash2 size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { DataTableColumnHeader } from '@/components/data-table/column-header'
|
||||
import { GroupBadge } from '@/components/group-badge'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import {
|
||||
getModelStatusConfig,
|
||||
@ -443,8 +444,8 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
||||
return <span className='text-muted-foreground text-xs'>-</span>
|
||||
}
|
||||
|
||||
const groupBadges = groups.map((g, idx) => (
|
||||
<StatusBadge key={idx} label={g} autoColor={g} size='sm' />
|
||||
const groupBadges = groups.map((g) => (
|
||||
<GroupBadge key={g} group={g} size='sm' />
|
||||
))
|
||||
|
||||
return (
|
||||
|
||||
@ -14,6 +14,7 @@ import {
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { CopyButton } from '@/components/copy-button'
|
||||
import { GroupBadge } from '@/components/group-badge'
|
||||
import { PublicLayout } from '@/components/layout'
|
||||
import { DEFAULT_TOKEN_UNIT, QUOTA_TYPE_VALUES } from '../constants'
|
||||
import { usePricingData } from '../hooks/use-pricing-data'
|
||||
@ -275,9 +276,7 @@ function AutoGroupChain(props: { model: PricingModel; autoGroups: string[] }) {
|
||||
<span className='text-muted-foreground/40'>→</span>
|
||||
{autoChain.map((g, idx) => (
|
||||
<span key={g} className='flex items-center gap-1'>
|
||||
<span className='bg-muted text-foreground rounded px-1.5 py-0.5 text-[11px] font-medium'>
|
||||
{g}
|
||||
</span>
|
||||
<GroupBadge group={g} size='sm' />
|
||||
{idx < autoChain.length - 1 && (
|
||||
<span className='text-muted-foreground/40'>→</span>
|
||||
)}
|
||||
@ -388,7 +387,9 @@ function GroupPricingSection(props: {
|
||||
const ratio = groupRatio[group] || 1
|
||||
return (
|
||||
<TableRow key={group}>
|
||||
<TableCell className='py-2.5 font-medium'>{group}</TableCell>
|
||||
<TableCell className='py-2.5'>
|
||||
<GroupBadge group={group} size='sm' />
|
||||
</TableCell>
|
||||
<TableCell className='text-muted-foreground py-2.5 font-mono text-xs'>
|
||||
{ratio}x
|
||||
</TableCell>
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { DataTableColumnHeader } from '@/components/data-table/column-header'
|
||||
import { GroupBadge } from '@/components/group-badge'
|
||||
import { DEFAULT_TOKEN_UNIT, QUOTA_TYPE_VALUES } from '../constants'
|
||||
import { parseTags } from '../lib/filters'
|
||||
import { isTokenBasedModel } from '../lib/model-helpers'
|
||||
@ -49,6 +50,28 @@ function renderLimitedTags(
|
||||
)
|
||||
}
|
||||
|
||||
function renderLimitedGroupBadges(
|
||||
groups: string[],
|
||||
maxDisplay: number = 2
|
||||
): React.ReactNode {
|
||||
if (groups.length === 0)
|
||||
return <span className='text-muted-foreground/50 text-xs'>—</span>
|
||||
|
||||
const displayed = groups.slice(0, maxDisplay)
|
||||
const remaining = groups.length - maxDisplay
|
||||
|
||||
return (
|
||||
<div className='flex max-w-full items-center gap-1 overflow-hidden'>
|
||||
{displayed.map((group) => (
|
||||
<GroupBadge key={group} group={group} size='sm' />
|
||||
))}
|
||||
{remaining > 0 && (
|
||||
<span className='text-muted-foreground/50 text-xs'>+{remaining}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function usePricingColumns(
|
||||
options: PricingColumnsOptions = {}
|
||||
): ColumnDef<PricingModel>[] {
|
||||
@ -312,11 +335,15 @@ export function usePricingColumns(
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>{renderLimitedTags(groups, 2)}</div>
|
||||
<div>{renderLimitedGroupBadges(groups, 2)}</div>
|
||||
</TooltipTrigger>
|
||||
{groups.length > 2 && (
|
||||
<TooltipContent side='top' className='max-w-[280px] p-2'>
|
||||
<span className='text-xs'>{groups.join(', ')}</span>
|
||||
<div className='flex flex-wrap gap-1'>
|
||||
{groups.map((group) => (
|
||||
<GroupBadge key={group} group={group} size='sm' />
|
||||
))}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
|
||||
@ -26,6 +26,8 @@ import {
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
DISABLED_ROW_DESKTOP,
|
||||
DISABLED_ROW_MOBILE,
|
||||
DataTablePagination,
|
||||
DataTableToolbar,
|
||||
TableSkeleton,
|
||||
@ -36,12 +38,20 @@ import { PageFooterPortal } from '@/components/layout'
|
||||
import { getRedemptions, searchRedemptions } from '../api'
|
||||
import { REDEMPTION_STATUS, getRedemptionStatusOptions } from '../constants'
|
||||
import { isRedemptionExpired } from '../lib'
|
||||
import type { Redemption } from '../types'
|
||||
import { DataTableBulkActions } from './data-table-bulk-actions'
|
||||
import { useRedemptionsColumns } from './redemptions-columns'
|
||||
import { useRedemptions } from './redemptions-provider'
|
||||
|
||||
const route = getRouteApi('/_authenticated/redemption-codes/')
|
||||
|
||||
function isDisabledRedemptionRow(redemption: Redemption) {
|
||||
return (
|
||||
redemption.status !== REDEMPTION_STATUS.ENABLED ||
|
||||
isRedemptionExpired(redemption.expired_time, redemption.status)
|
||||
)
|
||||
}
|
||||
|
||||
export function RedemptionsTable() {
|
||||
const { t } = useTranslation()
|
||||
const columns = useRedemptionsColumns()
|
||||
@ -164,6 +174,11 @@ export function RedemptionsTable() {
|
||||
emptyDescription={t(
|
||||
'No redemption codes available. Create your first redemption code to get started.'
|
||||
)}
|
||||
getRowClassName={(row) =>
|
||||
isDisabledRedemptionRow(row.original)
|
||||
? DISABLED_ROW_MOBILE
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
@ -209,18 +224,15 @@ export function RedemptionsTable() {
|
||||
) : (
|
||||
table.getRowModel().rows.map((row) => {
|
||||
const redemption = row.original
|
||||
const isDisabled =
|
||||
redemption.status !== REDEMPTION_STATUS.ENABLED ||
|
||||
isRedemptionExpired(
|
||||
redemption.expired_time,
|
||||
redemption.status
|
||||
)
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && 'selected'}
|
||||
className={isDisabled ? 'opacity-50' : undefined}
|
||||
className={cn(
|
||||
isDisabledRedemptionRow(redemption) &&
|
||||
DISABLED_ROW_DESKTOP
|
||||
)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
|
||||
@ -17,6 +17,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { GroupBadge } from '@/components/group-badge'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
paySubscriptionStripe,
|
||||
@ -209,11 +210,11 @@ export function SubscriptionPurchaseDialog(props: Props) {
|
||||
</span>
|
||||
</div>
|
||||
{plan.upgrade_group && (
|
||||
<div className='flex justify-between'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
{t('Upgrade Group')}
|
||||
</span>
|
||||
<span className='text-sm'>{plan.upgrade_group}</span>
|
||||
<GroupBadge group={plan.upgrade_group} />
|
||||
</div>
|
||||
)}
|
||||
<Separator />
|
||||
|
||||
@ -2,6 +2,7 @@ import { useMemo } from 'react'
|
||||
import { type ColumnDef } from '@tanstack/react-table'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { DataTableColumnHeader } from '@/components/data-table'
|
||||
import { GroupBadge } from '@/components/group-badge'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { formatDuration, formatResetPeriod } from '../lib'
|
||||
import type { PlanRecord } from '../types'
|
||||
@ -172,11 +173,10 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] {
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const group = row.original.plan.upgrade_group
|
||||
return (
|
||||
<span className='text-muted-foreground'>
|
||||
{group || t('No Upgrade')}
|
||||
</span>
|
||||
)
|
||||
if (!group) {
|
||||
return <span className='text-muted-foreground'>{t('No Upgrade')}</span>
|
||||
}
|
||||
return <GroupBadge group={group} />
|
||||
},
|
||||
size: 100,
|
||||
},
|
||||
|
||||
@ -246,7 +246,7 @@ export function UsageLogsFilterDialog({
|
||||
<FilterInput
|
||||
id='mjId'
|
||||
label={t('Task ID')}
|
||||
placeholder={t('Filter by Midjourney task ID')}
|
||||
placeholder={t('Filter by task ID')}
|
||||
value={drawingFilters.mjId || ''}
|
||||
onChange={(value) => handleChange('mjId', value)}
|
||||
/>
|
||||
|
||||
@ -27,7 +27,6 @@ import {
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
DataTablePagination,
|
||||
DataTableToolbar,
|
||||
DataTableViewOptions,
|
||||
TableSkeleton,
|
||||
TableEmpty,
|
||||
@ -40,6 +39,7 @@ import { fetchLogsByCategory } from '../lib/utils'
|
||||
import type { LogCategory } from '../types'
|
||||
import { CommonLogsFilterBar } from './common-logs-filter-bar'
|
||||
import { CommonLogsStats } from './common-logs-stats'
|
||||
import { TaskLogsFilterBar } from './task-logs-filter-bar'
|
||||
|
||||
const route = getRouteApi('/_authenticated/usage-logs/$section')
|
||||
|
||||
@ -194,11 +194,12 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<DataTableToolbar
|
||||
table={table}
|
||||
filters={[]}
|
||||
customSearch={null}
|
||||
/>
|
||||
<div className='rounded-md border bg-card/50 p-3 shadow-xs'>
|
||||
<TaskLogsFilterBar
|
||||
logCategory={logCategory}
|
||||
viewOptions={<DataTableViewOptions table={table} />}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isMobile ? (
|
||||
<MobileCardList
|
||||
|
||||
@ -7,7 +7,6 @@ import { SectionPageLayout } from '@/components/layout'
|
||||
import type { NavGroup } from '@/components/layout/types'
|
||||
import { CacheStatsDialog } from '@/features/system-settings/general/channel-affinity/cache-stats-dialog'
|
||||
import { UserInfoDialog } from './components/dialogs/user-info-dialog'
|
||||
import { UsageLogsPrimaryButtons } from './components/usage-logs-primary-buttons'
|
||||
import {
|
||||
UsageLogsProvider,
|
||||
useUsageLogsContext,
|
||||
@ -105,11 +104,6 @@ function UsageLogsContent() {
|
||||
<SectionPageLayout.Description>
|
||||
{t(pageMeta.descriptionKey)}
|
||||
</SectionPageLayout.Description>
|
||||
<SectionPageLayout.Actions>
|
||||
{activeCategory !== 'common' && (
|
||||
<UsageLogsPrimaryButtons logCategory={activeCategory} />
|
||||
)}
|
||||
</SectionPageLayout.Actions>
|
||||
<SectionPageLayout.Content>
|
||||
<div className='space-y-4'>
|
||||
{showTaskSwitcher && (
|
||||
|
||||
@ -10,17 +10,23 @@ import {
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { DataTableColumnHeader } from '@/components/data-table'
|
||||
import { GroupBadge } from '@/components/group-badge'
|
||||
import { LongText } from '@/components/long-text'
|
||||
import { StatusBadge, dotColorMap } from '@/components/status-badge'
|
||||
import {
|
||||
USER_STATUSES,
|
||||
USER_ROLES,
|
||||
DEFAULT_GROUP,
|
||||
isUserDeleted,
|
||||
} from '../constants'
|
||||
import { type User } from '../types'
|
||||
import { DataTableRowActions } from './data-table-row-actions'
|
||||
|
||||
function getQuotaProgressColor(percentage: number): string {
|
||||
if (percentage <= 10) return '[&_[data-slot=progress-indicator]]:bg-rose-500'
|
||||
if (percentage <= 30) return '[&_[data-slot=progress-indicator]]:bg-amber-500'
|
||||
return '[&_[data-slot=progress-indicator]]:bg-emerald-500'
|
||||
}
|
||||
|
||||
export function useUsersColumns(): ColumnDef<User>[] {
|
||||
const { t } = useTranslation()
|
||||
return [
|
||||
@ -66,24 +72,32 @@ export function useUsersColumns(): ColumnDef<User>[] {
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const username = row.getValue('username') as string
|
||||
const displayName = row.original.display_name
|
||||
const remark = row.original.remark
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-2'>
|
||||
<LongText className='max-w-[120px] font-medium'>
|
||||
{username}
|
||||
</LongText>
|
||||
{remark && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<StatusBadge variant='success' copyable={false}>
|
||||
<LongText className='max-w-[80px]'>{remark}</LongText>
|
||||
</StatusBadge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className='text-xs'>{remark}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className='flex min-w-[160px] flex-col gap-1'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<LongText className='max-w-[140px] font-medium'>
|
||||
{username}
|
||||
</LongText>
|
||||
{remark && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<StatusBadge variant='success' copyable={false}>
|
||||
<LongText className='max-w-[80px]'>{remark}</LongText>
|
||||
</StatusBadge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className='text-xs'>{remark}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{displayName && displayName !== username && (
|
||||
<LongText className='text-muted-foreground max-w-[180px] text-xs'>
|
||||
{displayName}
|
||||
</LongText>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@ -91,20 +105,6 @@ export function useUsersColumns(): ColumnDef<User>[] {
|
||||
enableHiding: false,
|
||||
meta: { label: t('Username'), mobileTitle: true },
|
||||
},
|
||||
{
|
||||
accessorKey: 'display_name',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Display Name')} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<LongText className='max-w-[150px]'>
|
||||
{row.getValue('display_name') || '-'}
|
||||
</LongText>
|
||||
)
|
||||
},
|
||||
meta: { label: t('Display Name'), mobileHidden: true },
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: ({ column }) => (
|
||||
@ -176,12 +176,17 @@ export function useUsersColumns(): ColumnDef<User>[] {
|
||||
<TooltipTrigger asChild>
|
||||
<div className='w-[150px] cursor-help space-y-1'>
|
||||
<div className='flex justify-between text-xs'>
|
||||
<span>{formatQuota(remaining)}</span>
|
||||
<span className='text-muted-foreground'>
|
||||
<span className='font-medium tabular-nums'>
|
||||
{formatQuota(remaining)}
|
||||
</span>
|
||||
<span className='text-muted-foreground tabular-nums'>
|
||||
{formatQuota(total)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={percentage} className='h-2' />
|
||||
<Progress
|
||||
value={percentage}
|
||||
className={cn('h-1.5', getQuotaProgressColor(percentage))}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
@ -212,16 +217,10 @@ export function useUsersColumns(): ColumnDef<User>[] {
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const group = row.getValue('group') as string
|
||||
return (
|
||||
<StatusBadge
|
||||
label={group || DEFAULT_GROUP}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
/>
|
||||
)
|
||||
return <GroupBadge group={group} />
|
||||
},
|
||||
filterFn: (row, id, value) => {
|
||||
const group = String(row.getValue(id) || DEFAULT_GROUP).toLowerCase()
|
||||
const group = String(row.getValue(id) || t('User Group')).toLowerCase()
|
||||
const searchValue = String(value).toLowerCase()
|
||||
return group.includes(searchValue)
|
||||
},
|
||||
|
||||
@ -27,6 +27,8 @@ import {
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
DISABLED_ROW_DESKTOP,
|
||||
DISABLED_ROW_MOBILE,
|
||||
DataTablePagination,
|
||||
DataTableToolbar,
|
||||
TableSkeleton,
|
||||
@ -41,12 +43,17 @@ import {
|
||||
getUserRoleOptions,
|
||||
isUserDeleted,
|
||||
} from '../constants'
|
||||
import type { User } from '../types'
|
||||
import { DataTableBulkActions } from './data-table-bulk-actions'
|
||||
import { useUsersColumns } from './users-columns'
|
||||
import { useUsers } from './users-provider'
|
||||
|
||||
const route = getRouteApi('/_authenticated/users/')
|
||||
|
||||
function isDisabledUserRow(user: User) {
|
||||
return isUserDeleted(user) || user.status === USER_STATUS.DISABLED
|
||||
}
|
||||
|
||||
export function UsersTable() {
|
||||
const { t } = useTranslation()
|
||||
const columns = useUsersColumns()
|
||||
@ -186,6 +193,9 @@ export function UsersTable() {
|
||||
emptyDescription={t(
|
||||
'No users available. Try adjusting your search or filters.'
|
||||
)}
|
||||
getRowClassName={(row) =>
|
||||
isDisabledUserRow(row.original) ? DISABLED_ROW_MOBILE : undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
@ -226,16 +236,14 @@ export function UsersTable() {
|
||||
) : (
|
||||
table.getRowModel().rows.map((row) => {
|
||||
const user = row.original
|
||||
const isDeleted = isUserDeleted(user)
|
||||
const isDisabled = user.status === USER_STATUS.DISABLED
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && 'selected'}
|
||||
className={
|
||||
isDeleted || isDisabled ? 'opacity-50' : undefined
|
||||
}
|
||||
className={cn(
|
||||
isDisabledUserRow(user) && DISABLED_ROW_DESKTOP
|
||||
)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
|
||||
6
web/default/src/hooks/use-sidebar-data.ts
vendored
6
web/default/src/hooks/use-sidebar-data.ts
vendored
@ -80,6 +80,12 @@ export function useSidebarData(): SidebarData {
|
||||
configUrls: ['/usage-logs/drawing', '/usage-logs/task'],
|
||||
icon: ListTodo,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'personal',
|
||||
title: t('Personal'),
|
||||
items: [
|
||||
{
|
||||
title: t('Wallet'),
|
||||
url: '/wallet',
|
||||
|
||||
15
web/default/src/i18n/locales/en.json
vendored
15
web/default/src/i18n/locales/en.json
vendored
@ -561,6 +561,7 @@
|
||||
"channel(s)? This action cannot be undone.": "channel(s)? This action cannot be undone.",
|
||||
"Channels": "Channels",
|
||||
"Channels deleted successfully": "Channels deleted successfully",
|
||||
"Chart Preferences": "Chart Preferences",
|
||||
"Chart Settings": "Chart Settings",
|
||||
"Chat": "Chat",
|
||||
"Chat area": "Chat area",
|
||||
@ -600,6 +601,8 @@
|
||||
"Choose how to filter IP addresses": "Choose how to filter IP addresses",
|
||||
"Choose the bundle type and define the items inside it.": "Choose the bundle type and define the items inside it.",
|
||||
"Choose where to fetch upstream metadata.": "Choose where to fetch upstream metadata.",
|
||||
"Choose the default charts, range, and time granularity for model analytics.": "Choose the default charts, range, and time granularity for model analytics.",
|
||||
"Choose which charts are selected by default when opening model analytics.": "Choose which charts are selected by default when opening model analytics.",
|
||||
"Classic (Legacy Frontend)": "Classic (Legacy Frontend)",
|
||||
"Claude": "Claude",
|
||||
"Claude CLI Header Passthrough": "Claude CLI Header Passthrough",
|
||||
@ -929,6 +932,7 @@
|
||||
"Daily Check-in": "Daily Check-in",
|
||||
"Dark": "Dark",
|
||||
"Dashboard": "Dashboard",
|
||||
"Dashboard Preferences": "Dashboard Preferences",
|
||||
"Dashboards, tokens, and usage analytics.": "Dashboards, tokens, and usage analytics.",
|
||||
"Data Dashboard": "Data Dashboard",
|
||||
"Data directory:": "Data directory:",
|
||||
@ -949,7 +953,10 @@
|
||||
"Default API Version *": "Default API Version *",
|
||||
"Default API version for this channel": "Default API version for this channel",
|
||||
"Default Collapse Sidebar": "Default Collapse Sidebar",
|
||||
"Default consumption chart": "Default consumption chart",
|
||||
"Default Max Tokens": "Default Max Tokens",
|
||||
"Default model call chart": "Default model call chart",
|
||||
"Default range": "Default range",
|
||||
"Default Responses API version, if empty, will use the API version above": "Default Responses API version, if empty, will use the API version above",
|
||||
"Default system prompt for this channel": "Default system prompt for this channel",
|
||||
"Default time granularity": "Default time granularity",
|
||||
@ -1901,6 +1908,7 @@
|
||||
"Leave empty to use username": "Leave empty to use username",
|
||||
"Legacy Format (JSON Object)": "Legacy Format (JSON Object)",
|
||||
"Legacy format must be a JSON object": "Legacy format must be a JSON object",
|
||||
"Legacy Format Template": "Legacy Format Template",
|
||||
"Less": "Less",
|
||||
"Less Than": "Less Than",
|
||||
"Less Than or Equal": "Less Than or Equal",
|
||||
@ -2524,6 +2532,7 @@
|
||||
"Permit Passkey registration on non-HTTPS origins (only recommended for development)": "Permit Passkey registration on non-HTTPS origins (only recommended for development)",
|
||||
"Perplexity": "Perplexity",
|
||||
"Persist your data file": "Persist your data file",
|
||||
"Personal": "Personal",
|
||||
"Personal area": "Personal area",
|
||||
"Personal Center Area": "Personal Center Area",
|
||||
"Personal info settings": "Personal info settings",
|
||||
@ -2981,6 +2990,7 @@
|
||||
"Save drawing settings": "Save drawing settings",
|
||||
"Save Epay settings": "Save Epay settings",
|
||||
"Save failed": "Save failed",
|
||||
"Save Preferences": "Save Preferences",
|
||||
"Save failed, please retry": "Save failed, please retry",
|
||||
"Save general settings": "Save general settings",
|
||||
"Save group ratios": "Save group ratios",
|
||||
@ -3064,6 +3074,8 @@
|
||||
"Select channel type": "Select channel type",
|
||||
"Select currency": "Select currency",
|
||||
"Select date": "Select date",
|
||||
"Select default chart": "Select default chart",
|
||||
"Select default range": "Select default range",
|
||||
"Select display mode": "Select display mode",
|
||||
"Select end time": "Select end time",
|
||||
"Select from presets or type custom identifier.": "Select from presets or type custom identifier.",
|
||||
@ -3863,7 +3875,6 @@
|
||||
"Your Turnstile site key": "Your Turnstile site key",
|
||||
"Zhipu": "Zhipu",
|
||||
"Zhipu V4": "Zhipu V4",
|
||||
"Zoom": "Zoom",
|
||||
"Legacy Format Template": "Legacy Format Template"
|
||||
"Zoom": "Zoom"
|
||||
}
|
||||
}
|
||||
|
||||
15
web/default/src/i18n/locales/fr.json
vendored
15
web/default/src/i18n/locales/fr.json
vendored
@ -561,6 +561,7 @@
|
||||
"channel(s)? This action cannot be undone.": "canal(aux) ? Cette action ne peut pas être annulée.",
|
||||
"Channels": "Canaux",
|
||||
"Channels deleted successfully": "Canaux supprimés avec succès",
|
||||
"Chart Preferences": "Préférences des graphiques",
|
||||
"Chart Settings": "Paramètres du graphique",
|
||||
"Chat": "Discuter",
|
||||
"Chat area": "Zone de chat",
|
||||
@ -600,6 +601,8 @@
|
||||
"Choose how to filter IP addresses": "Choisissez comment filtrer les adresses IP",
|
||||
"Choose the bundle type and define the items inside it.": "Choisissez le type de bundle et définissez les éléments qu'il contient.",
|
||||
"Choose where to fetch upstream metadata.": "Choisissez où récupérer les métadonnées amont.",
|
||||
"Choose the default charts, range, and time granularity for model analytics.": "Choisissez les graphiques, la plage et la granularité temporelle par défaut pour l'analyse des modèles.",
|
||||
"Choose which charts are selected by default when opening model analytics.": "Choisissez les graphiques sélectionnés par défaut à l'ouverture de l'analyse des modèles.",
|
||||
"Classic (Legacy Frontend)": "Classique (Ancien frontend)",
|
||||
"Claude": "Claude",
|
||||
"Claude CLI Header Passthrough": "Passthrough en-tête Claude CLI",
|
||||
@ -929,6 +932,7 @@
|
||||
"Daily Check-in": "Connexion quotidienne",
|
||||
"Dark": "Sombre",
|
||||
"Dashboard": "Tableau de bord",
|
||||
"Dashboard Preferences": "Préférences du tableau de bord",
|
||||
"Dashboards, tokens, and usage analytics.": "Tableaux de bord, jetons et analyses d'utilisation.",
|
||||
"Data Dashboard": "Tableau de bord des données",
|
||||
"Data directory:": "Répertoire des données :",
|
||||
@ -949,7 +953,10 @@
|
||||
"Default API Version *": "Version API par défaut *",
|
||||
"Default API version for this channel": "Version API par défaut pour ce canal",
|
||||
"Default Collapse Sidebar": "Réduire la barre latérale par défaut",
|
||||
"Default consumption chart": "Graphique de consommation par défaut",
|
||||
"Default Max Tokens": "Jetons max par défaut",
|
||||
"Default model call chart": "Graphique d'appels de modèle par défaut",
|
||||
"Default range": "Plage par défaut",
|
||||
"Default Responses API version, if empty, will use the API version above": "Version API des réponses par défaut, si vide, utilisera la version API ci-dessus",
|
||||
"Default system prompt for this channel": "Invite système par défaut pour ce canal",
|
||||
"Default time granularity": "Granularité temporelle par défaut",
|
||||
@ -1901,6 +1908,7 @@
|
||||
"Leave empty to use username": "Laissez vide pour utiliser le nom d'utilisateur",
|
||||
"Legacy Format (JSON Object)": "Ancien format (objet JSON)",
|
||||
"Legacy format must be a JSON object": "L'ancien format doit être un objet JSON",
|
||||
"Legacy Format Template": "Modèle ancien format",
|
||||
"Less": "Moins",
|
||||
"Less Than": "Inférieur à",
|
||||
"Less Than or Equal": "Inférieur ou égal",
|
||||
@ -2524,6 +2532,7 @@
|
||||
"Permit Passkey registration on non-HTTPS origins (only recommended for development)": "Autoriser l'enregistrement de Passkey sur des origines non-HTTPS (recommandé uniquement pour le développement)",
|
||||
"Perplexity": "Perplexity",
|
||||
"Persist your data file": "Conserver votre fichier de données",
|
||||
"Personal": "Personnel",
|
||||
"Personal area": "Espace personnel",
|
||||
"Personal Center Area": "Espace personnel",
|
||||
"Personal info settings": "Paramètres d'informations personnelles",
|
||||
@ -2981,6 +2990,7 @@
|
||||
"Save drawing settings": "Enregistrer les paramètres de dessin",
|
||||
"Save Epay settings": "Enregistrer les paramètres Epay",
|
||||
"Save failed": "Échec de l'enregistrement",
|
||||
"Save Preferences": "Enregistrer les préférences",
|
||||
"Save failed, please retry": "Échec de l'enregistrement, veuillez réessayer",
|
||||
"Save general settings": "Enregistrer les paramètres généraux",
|
||||
"Save group ratios": "Enregistrer les ratios de groupes",
|
||||
@ -3064,6 +3074,8 @@
|
||||
"Select channel type": "Sélectionner le type de canal",
|
||||
"Select currency": "Sélectionner la devise",
|
||||
"Select date": "Sélectionner la date",
|
||||
"Select default chart": "Sélectionner le graphique par défaut",
|
||||
"Select default range": "Sélectionner la plage par défaut",
|
||||
"Select display mode": "Sélectionner le mode d'affichage",
|
||||
"Select end time": "Sélectionner l'heure de fin",
|
||||
"Select from presets or type custom identifier.": "Sélectionner parmi les préréglages ou saisir un identifiant personnalisé.",
|
||||
@ -3863,7 +3875,6 @@
|
||||
"Your Turnstile site key": "Votre clé de site Turnstile",
|
||||
"Zhipu": "Zhipu",
|
||||
"Zhipu V4": "Zhipu V4",
|
||||
"Zoom": "Zoom",
|
||||
"Legacy Format Template": "Modèle ancien format"
|
||||
"Zoom": "Zoom"
|
||||
}
|
||||
}
|
||||
|
||||
15
web/default/src/i18n/locales/ja.json
vendored
15
web/default/src/i18n/locales/ja.json
vendored
@ -561,6 +561,7 @@
|
||||
"channel(s)? This action cannot be undone.": "チャネルを削除しますか?この操作は元に戻せません。",
|
||||
"Channels": "チャネル",
|
||||
"Channels deleted successfully": "チャンネルが正常に削除されました",
|
||||
"Chart Preferences": "チャートの環境設定",
|
||||
"Chart Settings": "チャート設定",
|
||||
"Chat": "チャット",
|
||||
"Chat area": "チャットエリア",
|
||||
@ -600,6 +601,8 @@
|
||||
"Choose how to filter IP addresses": "IPアドレスをフィルタリングする方法を選択してください",
|
||||
"Choose the bundle type and define the items inside it.": "バンドルタイプを選択し、その中のアイテムを定義してください。",
|
||||
"Choose where to fetch upstream metadata.": "アップストリームのメタデータをどこからフェッチするかを選択してください。",
|
||||
"Choose the default charts, range, and time granularity for model analytics.": "モデル分析のデフォルトチャート、範囲、時間粒度を選択します。",
|
||||
"Choose which charts are selected by default when opening model analytics.": "モデル分析を開いたときにデフォルトで選択されるチャートを選択します。",
|
||||
"Classic (Legacy Frontend)": "クラシック(旧フロントエンド)",
|
||||
"Claude": "Claude",
|
||||
"Claude CLI Header Passthrough": "Claude CLI ヘッダーパススルー",
|
||||
@ -929,6 +932,7 @@
|
||||
"Daily Check-in": "毎日のチェックイン",
|
||||
"Dark": "ダーク",
|
||||
"Dashboard": "ダッシュボード",
|
||||
"Dashboard Preferences": "ダッシュボードの環境設定",
|
||||
"Dashboards, tokens, and usage analytics.": "ダッシュボード、トークン、使用状況アナリティクス。",
|
||||
"Data Dashboard": "データダッシュボード",
|
||||
"Data directory:": "データディレクトリ:",
|
||||
@ -949,7 +953,10 @@
|
||||
"Default API Version *": "デフォルトのAPIバージョン *",
|
||||
"Default API version for this channel": "このチャンネルのデフォルトのAPIバージョン",
|
||||
"Default Collapse Sidebar": "デフォルトのサイドバー折りたたみ",
|
||||
"Default consumption chart": "デフォルトの消費チャート",
|
||||
"Default Max Tokens": "デフォルトの最大トークン",
|
||||
"Default model call chart": "デフォルトのモデル呼び出しチャート",
|
||||
"Default range": "デフォルト範囲",
|
||||
"Default Responses API version, if empty, will use the API version above": "デフォルトの応答APIバージョン。空の場合、上記のAPIバージョンが使用されます",
|
||||
"Default system prompt for this channel": "このチャンネルのデフォルトのシステムプロンプト",
|
||||
"Default time granularity": "デフォルトの時間粒度",
|
||||
@ -1901,6 +1908,7 @@
|
||||
"Leave empty to use username": "ユーザー名を使用するには空のままにしてください",
|
||||
"Legacy Format (JSON Object)": "旧形式(JSONオブジェクト)",
|
||||
"Legacy format must be a JSON object": "旧形式はJSONオブジェクトである必要があります",
|
||||
"Legacy Format Template": "旧フォーマットテンプレート",
|
||||
"Less": "少ない",
|
||||
"Less Than": "より小さい",
|
||||
"Less Than or Equal": "以下",
|
||||
@ -2524,6 +2532,7 @@
|
||||
"Permit Passkey registration on non-HTTPS origins (only recommended for development)": "非HTTPSオリジンでのパスキー登録を許可する(開発でのみ推奨)",
|
||||
"Perplexity": "Perplexity",
|
||||
"Persist your data file": "データファイルを永続化する",
|
||||
"Personal": "個人",
|
||||
"Personal area": "個人エリア",
|
||||
"Personal Center Area": "パーソナルセンターエリア",
|
||||
"Personal info settings": "個人情報設定",
|
||||
@ -2981,6 +2990,7 @@
|
||||
"Save drawing settings": "描画設定を保存",
|
||||
"Save Epay settings": "Epay設定を保存",
|
||||
"Save failed": "保存に失敗しました",
|
||||
"Save Preferences": "設定を保存",
|
||||
"Save failed, please retry": "保存に失敗しました。もう一度お試しください",
|
||||
"Save general settings": "一般設定を保存",
|
||||
"Save group ratios": "グループ比率を保存",
|
||||
@ -3064,6 +3074,8 @@
|
||||
"Select channel type": "チャネルタイプを選択",
|
||||
"Select currency": "通貨を選択",
|
||||
"Select date": "日付を選択",
|
||||
"Select default chart": "デフォルトチャートを選択",
|
||||
"Select default range": "デフォルト範囲を選択",
|
||||
"Select display mode": "表示モードを選択",
|
||||
"Select end time": "終了時間を選択",
|
||||
"Select from presets or type custom identifier.": "プリセットから選択するか、カスタム識別子を入力してください。",
|
||||
@ -3863,7 +3875,6 @@
|
||||
"Your Turnstile site key": "あなたのTurnstileサイトキー",
|
||||
"Zhipu": "Zhipu",
|
||||
"Zhipu V4": "Zhipu V 4",
|
||||
"Zoom": "ズーム",
|
||||
"Legacy Format Template": "旧フォーマットテンプレート"
|
||||
"Zoom": "ズーム"
|
||||
}
|
||||
}
|
||||
|
||||
15
web/default/src/i18n/locales/ru.json
vendored
15
web/default/src/i18n/locales/ru.json
vendored
@ -561,6 +561,7 @@
|
||||
"channel(s)? This action cannot be undone.": "канал(ы)? Это действие нельзя отменить.",
|
||||
"Channels": "Каналы",
|
||||
"Channels deleted successfully": "Каналы успешно удалены",
|
||||
"Chart Preferences": "Настройки графиков",
|
||||
"Chart Settings": "Настройки диаграммы",
|
||||
"Chat": "Чат",
|
||||
"Chat area": "Область чата",
|
||||
@ -600,6 +601,8 @@
|
||||
"Choose how to filter IP addresses": "Выберите, как фильтровать IP-адреса",
|
||||
"Choose the bundle type and define the items inside it.": "Выберите тип пакета и определите элементы внутри него.",
|
||||
"Choose where to fetch upstream metadata.": "Выберите, откуда получать метаданные вышестоящего источника.",
|
||||
"Choose the default charts, range, and time granularity for model analytics.": "Выберите графики, диапазон и временную детализацию по умолчанию для аналитики моделей.",
|
||||
"Choose which charts are selected by default when opening model analytics.": "Выберите графики, которые будут выбраны по умолчанию при открытии аналитики моделей.",
|
||||
"Classic (Legacy Frontend)": "Классический (Старый интерфейс)",
|
||||
"Claude": "Клод",
|
||||
"Claude CLI Header Passthrough": "Проброс заголовков Claude CLI",
|
||||
@ -929,6 +932,7 @@
|
||||
"Daily Check-in": "Ежедневный вход",
|
||||
"Dark": "Тёмная",
|
||||
"Dashboard": "Панель управления",
|
||||
"Dashboard Preferences": "Настройки панели управления",
|
||||
"Dashboards, tokens, and usage analytics.": "Панели управления, токены и аналитика использования.",
|
||||
"Data Dashboard": "Панель мониторинга данных",
|
||||
"Data directory:": "Каталог данных:",
|
||||
@ -949,7 +953,10 @@
|
||||
"Default API Version *": "Версия API по умолчанию *",
|
||||
"Default API version for this channel": "Версия API по умолчанию для этого канала",
|
||||
"Default Collapse Sidebar": "Сворачивать боковую панель по умолчанию",
|
||||
"Default consumption chart": "График потребления по умолчанию",
|
||||
"Default Max Tokens": "Максимальное количество токенов по умолчанию",
|
||||
"Default model call chart": "График вызовов моделей по умолчанию",
|
||||
"Default range": "Диапазон по умолчанию",
|
||||
"Default Responses API version, if empty, will use the API version above": "Версия API ответов по умолчанию; если пусто, будет использоваться версия API, указанная выше",
|
||||
"Default system prompt for this channel": "Системный промпт по умолчанию для этого канала",
|
||||
"Default time granularity": "Гранулярность времени по умолчанию",
|
||||
@ -1901,6 +1908,7 @@
|
||||
"Leave empty to use username": "Оставьте пустым, чтобы использовать имя пользователя",
|
||||
"Legacy Format (JSON Object)": "Старый формат (JSON-объект)",
|
||||
"Legacy format must be a JSON object": "Старый формат должен быть JSON-объектом",
|
||||
"Legacy Format Template": "Шаблон старого формата",
|
||||
"Less": "Меньше",
|
||||
"Less Than": "Меньше",
|
||||
"Less Than or Equal": "Меньше или равно",
|
||||
@ -2524,6 +2532,7 @@
|
||||
"Permit Passkey registration on non-HTTPS origins (only recommended for development)": "Разрешить регистрацию Passkey на не-HTTPS источниках (рекомендуется только для разработки)",
|
||||
"Perplexity": "Perplexity",
|
||||
"Persist your data file": "Сохранить ваш файл данных",
|
||||
"Personal": "Личное",
|
||||
"Personal area": "Личный кабинет",
|
||||
"Personal Center Area": "Область личного кабинета",
|
||||
"Personal info settings": "Настройки личной информации",
|
||||
@ -2981,6 +2990,7 @@
|
||||
"Save drawing settings": "Сохранить настройки рисования",
|
||||
"Save Epay settings": "Сохранить настройки Epay",
|
||||
"Save failed": "Не удалось сохранить",
|
||||
"Save Preferences": "Сохранить настройки",
|
||||
"Save failed, please retry": "Не удалось сохранить, попробуйте снова",
|
||||
"Save general settings": "Сохранить общие настройки",
|
||||
"Save group ratios": "Сохранить коэффициенты групп",
|
||||
@ -3064,6 +3074,8 @@
|
||||
"Select channel type": "Выбрать тип канала",
|
||||
"Select currency": "Выберите валюту",
|
||||
"Select date": "Выберите дату",
|
||||
"Select default chart": "Выберите график по умолчанию",
|
||||
"Select default range": "Выберите диапазон по умолчанию",
|
||||
"Select display mode": "Выбрать режим отображения",
|
||||
"Select end time": "Выбрать время окончания",
|
||||
"Select from presets or type custom identifier.": "Выберите из предустановок или введите пользовательский идентификатор.",
|
||||
@ -3863,7 +3875,6 @@
|
||||
"Your Turnstile site key": "Ключ сайта Turnstile",
|
||||
"Zhipu": "Zhipu",
|
||||
"Zhipu V4": "Zhipu V4",
|
||||
"Zoom": "Zoom",
|
||||
"Legacy Format Template": "Шаблон старого формата"
|
||||
"Zoom": "Zoom"
|
||||
}
|
||||
}
|
||||
|
||||
15
web/default/src/i18n/locales/vi.json
vendored
15
web/default/src/i18n/locales/vi.json
vendored
@ -561,6 +561,7 @@
|
||||
"channel(s)? This action cannot be undone.": "kênh(s)? Hành động này không thể hoàn tác.",
|
||||
"Channels": "Kênh",
|
||||
"Channels deleted successfully": "Xóa kênh thành công",
|
||||
"Chart Preferences": "Tùy chọn biểu đồ",
|
||||
"Chart Settings": "Cài đặt Biểu đồ",
|
||||
"Chat": "Trò chuyện",
|
||||
"Chat area": "Khu vực trò chuyện",
|
||||
@ -600,6 +601,8 @@
|
||||
"Choose how to filter IP addresses": "Chọn cách lọc địa chỉ IP",
|
||||
"Choose the bundle type and define the items inside it.": "Chọn loại gói và định nghĩa các mục bên trong nó.",
|
||||
"Choose where to fetch upstream metadata.": "Chọn nơi để tìm nạp siêu dữ liệu thượng nguồn.",
|
||||
"Choose the default charts, range, and time granularity for model analytics.": "Chọn biểu đồ, khoảng thời gian và độ chi tiết thời gian mặc định cho phân tích mô hình.",
|
||||
"Choose which charts are selected by default when opening model analytics.": "Chọn biểu đồ được chọn mặc định khi mở phân tích mô hình.",
|
||||
"Classic (Legacy Frontend)": "Cổ điển (Frontend cũ)",
|
||||
"Claude": "Claude",
|
||||
"Claude CLI Header Passthrough": "Chuyển tiếp header Claude CLI",
|
||||
@ -929,6 +932,7 @@
|
||||
"Daily Check-in": "Điểm danh hàng ngày",
|
||||
"Dark": "Tối",
|
||||
"Dashboard": "Bảng điều khiển",
|
||||
"Dashboard Preferences": "Tùy chọn bảng điều khiển",
|
||||
"Dashboards, tokens, and usage analytics.": "Bảng điều khiển, token và phân tích sử dụng.",
|
||||
"Data Dashboard": "Bảng dữ liệu",
|
||||
"Data directory:": "Thư mục dữ liệu:",
|
||||
@ -949,7 +953,10 @@
|
||||
"Default API Version *": "Phiên bản API mặc định *",
|
||||
"Default API version for this channel": "Phiên bản API mặc định cho kênh này",
|
||||
"Default Collapse Sidebar": "Mặc định Thu gọn Thanh bên",
|
||||
"Default consumption chart": "Biểu đồ tiêu thụ mặc định",
|
||||
"Default Max Tokens": "Tokens Tối đa Mặc định",
|
||||
"Default model call chart": "Biểu đồ lượt gọi mô hình mặc định",
|
||||
"Default range": "Khoảng mặc định",
|
||||
"Default Responses API version, if empty, will use the API version above": "Phiên bản API phản hồi mặc định, nếu để trống, sẽ sử dụng phiên bản API ở trên",
|
||||
"Default system prompt for this channel": "Lời nhắc hệ thống mặc định cho kênh này",
|
||||
"Default time granularity": "Độ chi tiết thời gian mặc định",
|
||||
@ -1901,6 +1908,7 @@
|
||||
"Leave empty to use username": "Để trống để sử dụng tên người dùng",
|
||||
"Legacy Format (JSON Object)": "Định dạng cũ (đối tượng JSON)",
|
||||
"Legacy format must be a JSON object": "Định dạng cũ phải là đối tượng JSON",
|
||||
"Legacy Format Template": "Mẫu định dạng cũ",
|
||||
"Less": "Ít hơn",
|
||||
"Less Than": "Nhỏ hơn",
|
||||
"Less Than or Equal": "Nhỏ hơn hoặc bằng",
|
||||
@ -2524,6 +2532,7 @@
|
||||
"Permit Passkey registration on non-HTTPS origins (only recommended for development)": "Cho phép đăng ký Passkey trên các nguồn gốc không phải HTTPS (chỉ khuyến nghị cho mục đích phát triển)",
|
||||
"Perplexity": "Sự bối rối",
|
||||
"Persist your data file": "Lưu trữ tệp dữ liệu của bạn",
|
||||
"Personal": "Cá nhân",
|
||||
"Personal area": "Khu vực cá nhân",
|
||||
"Personal Center Area": "Khu vực cá nhân",
|
||||
"Personal info settings": "Cài đặt thông tin cá nhân",
|
||||
@ -2981,6 +2990,7 @@
|
||||
"Save drawing settings": "Lưu cài đặt bản vẽ",
|
||||
"Save Epay settings": "Lưu cài đặt Epay",
|
||||
"Save failed": "Lưu thất bại",
|
||||
"Save Preferences": "Lưu tùy chọn",
|
||||
"Save failed, please retry": "Lưu thất bại, vui lòng thử lại",
|
||||
"Save general settings": "Lưu cài đặt chung",
|
||||
"Save group ratios": "Lưu tỷ lệ nhóm",
|
||||
@ -3064,6 +3074,8 @@
|
||||
"Select channel type": "Chọn loại kênh",
|
||||
"Select currency": "Chọn tiền tệ",
|
||||
"Select date": "Chọn ngày",
|
||||
"Select default chart": "Chọn biểu đồ mặc định",
|
||||
"Select default range": "Chọn khoảng mặc định",
|
||||
"Select display mode": "Chọn chế độ hiển thị",
|
||||
"Select end time": "Chọn thời gian kết thúc",
|
||||
"Select from presets or type custom identifier.": "Chọn từ các cài đặt sẵn hoặc nhập mã định danh tùy chỉnh.",
|
||||
@ -3863,7 +3875,6 @@
|
||||
"Your Turnstile site key": "Khóa site Turnstile của bạn",
|
||||
"Zhipu": "Zhipu",
|
||||
"Zhipu V4": "Zhipu V4",
|
||||
"Zoom": "Zoom",
|
||||
"Legacy Format Template": "Mẫu định dạng cũ"
|
||||
"Zoom": "Zoom"
|
||||
}
|
||||
}
|
||||
|
||||
15
web/default/src/i18n/locales/zh.json
vendored
15
web/default/src/i18n/locales/zh.json
vendored
@ -561,6 +561,7 @@
|
||||
"channel(s)? This action cannot be undone.": "渠道?此操作无法撤销。",
|
||||
"Channels": "渠道",
|
||||
"Channels deleted successfully": "渠道删除成功",
|
||||
"Chart Preferences": "图表偏好设置",
|
||||
"Chart Settings": "图表设置",
|
||||
"Chat": "聊天",
|
||||
"Chat area": "聊天区域",
|
||||
@ -600,6 +601,8 @@
|
||||
"Choose how to filter IP addresses": "选择如何过滤 IP 地址",
|
||||
"Choose the bundle type and define the items inside it.": "选择捆绑包类型并定义其中的项目。",
|
||||
"Choose where to fetch upstream metadata.": "选择从何处获取上游元数据。",
|
||||
"Choose the default charts, range, and time granularity for model analytics.": "选择模型调用分析的默认图表、范围和时间粒度。",
|
||||
"Choose which charts are selected by default when opening model analytics.": "选择打开模型调用分析时默认选中的图表。",
|
||||
"Classic (Legacy Frontend)": "经典前端",
|
||||
"Claude": "Claude",
|
||||
"Claude CLI Header Passthrough": "Claude CLI 请求头透传",
|
||||
@ -929,6 +932,7 @@
|
||||
"Daily Check-in": "每日签到",
|
||||
"Dark": "深色",
|
||||
"Dashboard": "数据看板",
|
||||
"Dashboard Preferences": "看板偏好设置",
|
||||
"Dashboards, tokens, and usage analytics.": "仪表板、令牌和使用分析。",
|
||||
"Data Dashboard": "数据仪表板",
|
||||
"Data directory:": "数据目录:",
|
||||
@ -949,7 +953,10 @@
|
||||
"Default API Version *": "默认 API 版本 *",
|
||||
"Default API version for this channel": "此渠道的默认 API 版本",
|
||||
"Default Collapse Sidebar": "默认折叠侧边栏",
|
||||
"Default consumption chart": "默认消耗分布图",
|
||||
"Default Max Tokens": "默认最大 Token 数",
|
||||
"Default model call chart": "默认模型调用图",
|
||||
"Default range": "默认范围",
|
||||
"Default Responses API version, if empty, will use the API version above": "默认响应 API 版本,如果为空,将使用上面的 API 版本",
|
||||
"Default system prompt for this channel": "此渠道的默认系统提示",
|
||||
"Default time granularity": "默认时间粒度",
|
||||
@ -1901,6 +1908,7 @@
|
||||
"Leave empty to use username": "留空以使用用户名",
|
||||
"Legacy Format (JSON Object)": "旧格式(JSON 对象)",
|
||||
"Legacy format must be a JSON object": "旧格式必须是 JSON 对象",
|
||||
"Legacy Format Template": "旧格式模板",
|
||||
"Less": "更少",
|
||||
"Less Than": "小于",
|
||||
"Less Than or Equal": "小于等于",
|
||||
@ -2524,6 +2532,7 @@
|
||||
"Permit Passkey registration on non-HTTPS origins (only recommended for development)": "允许在非 HTTPS 源上注册通行密钥(仅建议用于开发)",
|
||||
"Perplexity": "Perplexity",
|
||||
"Persist your data file": "持久化您的数据文件",
|
||||
"Personal": "个人",
|
||||
"Personal area": "个人中心",
|
||||
"Personal Center Area": "个人中心区域",
|
||||
"Personal info settings": "个人信息设置",
|
||||
@ -2981,6 +2990,7 @@
|
||||
"Save drawing settings": "保存绘图设置",
|
||||
"Save Epay settings": "保存 Epay 设置",
|
||||
"Save failed": "保存失败",
|
||||
"Save Preferences": "保存偏好设置",
|
||||
"Save failed, please retry": "保存失败,请重试",
|
||||
"Save general settings": "保存通用设置",
|
||||
"Save group ratios": "保存分组比率",
|
||||
@ -3064,6 +3074,8 @@
|
||||
"Select channel type": "选择渠道类型",
|
||||
"Select currency": "选择货币",
|
||||
"Select date": "选择日期",
|
||||
"Select default chart": "选择默认图表",
|
||||
"Select default range": "选择默认范围",
|
||||
"Select display mode": "选择显示模式",
|
||||
"Select end time": "选择结束时间",
|
||||
"Select from presets or type custom identifier.": "从预设中选择或输入自定义标识符。",
|
||||
@ -3863,7 +3875,6 @@
|
||||
"Your Turnstile site key": "您的 Turnstile 站点密钥",
|
||||
"Zhipu": "智谱",
|
||||
"Zhipu V4": "智谱 V4",
|
||||
"Zoom": "缩放",
|
||||
"Legacy Format Template": "旧格式模板"
|
||||
"Zoom": "缩放"
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user