feat(ui): improve table controls and analytics filters

This commit is contained in:
CaIon 2026-04-30 13:57:10 +08:00
parent 8bff691089
commit 8f3c41ae77
No known key found for this signature in database
GPG Key ID: 0CFA613529A9921D
35 changed files with 858 additions and 472 deletions

View File

@ -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'

View File

@ -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,
},

View File

@ -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) => (

View File

@ -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>
)
}

View File

@ -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>

View File

@ -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}

View File

@ -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'

View File

@ -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) => {

View File

@ -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,

View File

@ -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
}

View File

@ -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' },

View File

@ -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 }

View File

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

View File

@ -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
// ============================================================================

View File

@ -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,
},
]
}

View File

@ -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}>

View File

@ -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>
)
}

View File

@ -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 (

View File

@ -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>

View File

@ -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>

View File

@ -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}>

View File

@ -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 />

View File

@ -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,
},

View File

@ -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)}
/>

View File

@ -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

View File

@ -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 && (

View File

@ -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)
},

View File

@ -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}>

View File

@ -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',

View File

@ -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"
}
}

View File

@ -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"
}
}

View File

@ -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": "ズーム"
}
}

View File

@ -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"
}
}

View File

@ -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"
}
}

View File

@ -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": "缩放"
}
}