diff --git a/.gitignore b/.gitignore index 92e9e89e..bbc5717e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ build logs web/default/dist web/classic/dist +web/node_modules +web/dist .env one-api new-api diff --git a/web/default/src/components/data-table/mobile-card-list.tsx b/web/default/src/components/data-table/mobile-card-list.tsx index dbcd46fa..feefe183 100644 --- a/web/default/src/components/data-table/mobile-card-list.tsx +++ b/web/default/src/components/data-table/mobile-card-list.tsx @@ -14,6 +14,7 @@ import { EmptyTitle, } from '@/components/ui/empty' import { Skeleton } from '@/components/ui/skeleton' +import { cn } from '@/lib/utils' interface MobileCardListProps { table: Table @@ -21,6 +22,7 @@ interface MobileCardListProps { emptyTitle?: string emptyDescription?: string getRowKey?: (row: Row) => string | number + getRowClassName?: (row: Row) => string | undefined } interface MobileColumnMeta { @@ -238,6 +240,7 @@ export function MobileCardList(props: MobileCardListProps) { emptyTitle, emptyDescription, getRowKey, + getRowClassName, } = props const { t } = useTranslation() @@ -278,7 +281,10 @@ export function MobileCardList(props: MobileCardListProps) { {rows.map((row) => { const key = getRowKey ? getRowKey(row) : row.id return ( -
+
) diff --git a/web/default/src/components/layout/components/workspace-switcher.tsx b/web/default/src/components/layout/components/workspace-switcher.tsx index 0d7890a8..fc1801d8 100644 --- a/web/default/src/components/layout/components/workspace-switcher.tsx +++ b/web/default/src/components/layout/components/workspace-switcher.tsx @@ -120,70 +120,87 @@ export function WorkspaceSwitcher({ return null } + const canSwitchWorkspace = availableWorkspaces.length > 1 + const workspaceButtonContent = ( + <> + {activeWorkspace.id === WORKSPACE_IDS.SYSTEM_SETTINGS ? ( +
+ +
+ ) : ( +
+ {t('Logo')} +
+ )} +
+ {activeWorkspace.name} + {activeWorkspace.plan} +
+ {canSwitchWorkspace && ( + + )} + + ) + return ( - - - - {activeWorkspace.id === WORKSPACE_IDS.SYSTEM_SETTINGS ? ( -
- -
- ) : ( -
- {t('Logo')} -
- )} -
- - {activeWorkspace.name} - - {activeWorkspace.plan} -
- -
-
- - - {t('Workspaces')} - - {availableWorkspaces.map((workspace, index) => ( - handleWorkspaceChange(workspace)} - className='gap-2 p-2' + {canSwitchWorkspace ? ( + + + - {index === 0 ? ( -
- Logo -
- ) : ( -
- -
- )} - {workspace.name} -
- ))} -
-
+ {workspaceButtonContent} + + + + + {t('Workspaces')} + + {availableWorkspaces.map((workspace, index) => ( + handleWorkspaceChange(workspace)} + className='gap-2 p-2' + > + {index === 0 ? ( +
+ Logo +
+ ) : ( +
+ +
+ )} + {workspace.name} +
+ ))} +
+ + ) : ( + +
{workspaceButtonContent}
+
+ )}
) diff --git a/web/default/src/features/channels/components/channels-table.tsx b/web/default/src/features/channels/components/channels-table.tsx index 2a905384..1c089d08 100644 --- a/web/default/src/features/channels/components/channels-table.tsx +++ b/web/default/src/features/channels/components/channels-table.tsx @@ -35,6 +35,7 @@ import { PageFooterPortal } from '@/components/layout' import { getChannels, searchChannels, getGroups } from '../api' import { DEFAULT_PAGE_SIZE, + CHANNEL_STATUS, CHANNEL_STATUS_OPTIONS, CHANNEL_TYPE_OPTIONS, } from '../constants' @@ -50,6 +51,10 @@ import { DataTableBulkActions } from './data-table-bulk-actions' const route = getRouteApi('/_authenticated/channels/') +function isDisabledChannelRow(channel: Channel) { + return !isTagAggregateRow(channel) && channel.status !== CHANNEL_STATUS.ENABLED +} + export function ChannelsTable() { const { t } = useTranslation() const { enableTagMode, idSort } = useChannels() @@ -318,6 +323,11 @@ export function ChannelsTable() { isLoading={isLoading} 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 + } /> ) : ( <> @@ -363,6 +373,10 @@ export function ChannelsTable() { td:first-child]:border-l-4 [&>td:first-child]:border-l-muted-foreground/35 [&>td:first-child]:pl-1 dark:[&>td:first-child]:border-l-zinc-300/70' + )} > {row.getVisibleCells().map((cell) => ( diff --git a/web/default/src/features/dashboard/api.ts b/web/default/src/features/dashboard/api.ts index 4f541714..4bdda398 100644 --- a/web/default/src/features/dashboard/api.ts +++ b/web/default/src/features/dashboard/api.ts @@ -10,14 +10,17 @@ import type { QuotaDataItem, UptimeGroupResult } from './types' // ---------------------------------------------------------------------------- // Get user quota data within a time range -// Admin users can specify 'username' to view other users' data -export async function getUserQuotaDates(params: { - start_timestamp: number - end_timestamp: number - default_time?: string - username?: string -}) { - const endpoint = params.username ? '/api/data' : '/api/data/self' +// Admin users get all users' data by default (matching classic frontend behavior) +export async function getUserQuotaDates( + params: { + start_timestamp: number + end_timestamp: number + default_time?: string + username?: string + }, + isAdmin = false +) { + const endpoint = isAdmin ? '/api/data' : '/api/data/self' const res = await api.get<{ success: boolean; data: QuotaDataItem[] }>( endpoint, { params } diff --git a/web/default/src/features/dashboard/components/models/consumption-distribution-chart.tsx b/web/default/src/features/dashboard/components/models/consumption-distribution-chart.tsx new file mode 100644 index 00000000..33565cd7 --- /dev/null +++ b/web/default/src/features/dashboard/components/models/consumption-distribution-chart.tsx @@ -0,0 +1,120 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { VChart } from '@visactor/react-vchart' +import { AreaChart, BarChart3, WalletCards } from 'lucide-react' +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 { processChartData } from '@/features/dashboard/lib' +import type { 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 +} + +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 }, +] + +export function ConsumptionDistributionChart( + props: ConsumptionDistributionChartProps +) { + const { t } = useTranslation() + const { resolvedTheme } = useTheme() + const [chartType, setChartType] = useState('bar') + const [themeReady, setThemeReady] = useState(false) + const themeManagerRef = useRef< + (typeof import('@visactor/vchart'))['ThemeManager'] | null + >(null) + const timeGranularity = props.timeGranularity ?? DEFAULT_TIME_GRANULARITY + + useEffect(() => { + const updateTheme = async () => { + setThemeReady(false) + + if (!themeManagerPromise) { + themeManagerPromise = import('@visactor/vchart').then( + (m) => m.ThemeManager + ) + } + + const ThemeManager = await themeManagerPromise + themeManagerRef.current = ThemeManager + ThemeManager.setCurrentTheme(resolvedTheme === 'dark' ? 'dark' : 'light') + setThemeReady(true) + } + + updateTheme() + }, [resolvedTheme]) + + const chartData = useMemo( + () => processChartData(props.loading ? [] : props.data, timeGranularity, t), + [props.data, props.loading, timeGranularity, t] + ) + const spec = chartType === 'bar' ? chartData.spec_line : chartData.spec_area + + return ( +
+
+
+ +
+ {t('Quota Distribution')} +
+ + {t('Total:')} {chartData.totalQuotaDisplay} + +
+ +
+ {CHART_TYPES.map((item) => { + const Icon = item.icon + return ( + + ) + })} +
+
+ +
+ {themeReady && spec && ( + + )} +
+
+ ) +} diff --git a/web/default/src/features/dashboard/components/models/log-stat-cards.tsx b/web/default/src/features/dashboard/components/models/log-stat-cards.tsx index 6694a38b..fc8c31d5 100644 --- a/web/default/src/features/dashboard/components/models/log-stat-cards.tsx +++ b/web/default/src/features/dashboard/components/models/log-stat-cards.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' import { formatNumber, formatQuota } from '@/lib/format' import { computeTimeRange } from '@/lib/time' +import { useAuthStore } from '@/stores/auth-store' import { Skeleton } from '@/components/ui/skeleton' import { getUserQuotaDates } from '@/features/dashboard/api' import { useModelStatCardsConfig } from '@/features/dashboard/hooks/use-dashboard-config' @@ -21,6 +22,8 @@ interface LogStatCardsProps { export function LogStatCards(props: LogStatCardsProps) { const statCardsConfig = useModelStatCardsConfig() + const user = useAuthStore((state) => state.auth.user) + const isAdmin = !!(user?.role && user.role >= 10) const [stats, setStats] = useState<{ totalQuota: number totalCount: number @@ -49,7 +52,7 @@ export function LogStatCards(props: LogStatCardsProps) { const timeDiff = (timeRange.end_timestamp - timeRange.start_timestamp) / 60 setTimeRangeMinutes(timeDiff) - getUserQuotaDates(buildQueryParams(timeRange, filters)) + getUserQuotaDates(buildQueryParams(timeRange, filters), isAdmin) .then((res) => { if (abortController.signal.aborted) return const data = res?.data || [] @@ -71,7 +74,7 @@ export function LogStatCards(props: LogStatCardsProps) { return () => { abortController.abort() } - }, [filters, onDataUpdate]) + }, [filters, isAdmin, onDataUpdate]) const adaptedStats = { rpm: stats?.totalCount ?? 0, diff --git a/web/default/src/features/dashboard/components/models/model-charts.tsx b/web/default/src/features/dashboard/components/models/model-charts.tsx index 8bfc9c30..1d79d6cf 100644 --- a/web/default/src/features/dashboard/components/models/model-charts.tsx +++ b/web/default/src/features/dashboard/components/models/model-charts.tsx @@ -7,26 +7,27 @@ import { VCHART_OPTION } from '@/lib/vchart' import { useTheme } from '@/context/theme-provider' import { DEFAULT_TIME_GRANULARITY } from '@/features/dashboard/constants' import { processChartData } from '@/features/dashboard/lib' -import type { - ProcessedChartData, - QuotaDataItem, -} from '@/features/dashboard/types' +import type { QuotaDataItem } from '@/features/dashboard/types' let themeManagerPromise: Promise< (typeof import('@visactor/vchart'))['ThemeManager'] > | null = null -type ChartTab = '1' | '2' | '3' | '4' +type ChartTab = 'trend' | 'proportion' | 'top' +type ChartSpecKey = 'spec_model_line' | 'spec_pie' | 'spec_rank_bar' const CHART_TABS: { value: ChartTab labelKey: string - specKey: keyof ProcessedChartData + specKey: ChartSpecKey }[] = [ - { value: '1', labelKey: 'Quota Distribution', specKey: 'spec_line' }, - { value: '2', labelKey: 'Call Trend', specKey: 'spec_model_line' }, - { value: '3', labelKey: 'Call Proportion', specKey: 'spec_pie' }, - { value: '4', labelKey: 'Top Models', specKey: 'spec_rank_bar' }, + { 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' }, ] interface ModelChartsProps { @@ -38,7 +39,7 @@ interface ModelChartsProps { export function ModelCharts(props: ModelChartsProps) { const { t } = useTranslation() const { resolvedTheme } = useTheme() - const [activeTab, setActiveTab] = useState('1') + const [activeTab, setActiveTab] = useState('trend') const [themeReady, setThemeReady] = useState(false) const themeManagerRef = useRef< (typeof import('@visactor/vchart'))['ThemeManager'] | null diff --git a/web/default/src/features/dashboard/components/users/user-charts.tsx b/web/default/src/features/dashboard/components/users/user-charts.tsx index 50ba14d6..8108f8bd 100644 --- a/web/default/src/features/dashboard/components/users/user-charts.tsx +++ b/web/default/src/features/dashboard/components/users/user-charts.tsx @@ -24,10 +24,8 @@ let themeManagerPromise: Promise< (typeof import('@visactor/vchart'))['ThemeManager'] > | null = null -type UserChartTab = 'rank' | 'trend' - -const CHART_TABS: { - value: UserChartTab +const USER_CHARTS: { + value: string labelKey: string specKey: keyof ProcessedUserChartData }[] = [ @@ -46,7 +44,6 @@ const CHART_TABS: { export function UserCharts() { const { t } = useTranslation() const { resolvedTheme } = useTheme() - const [activeTab, setActiveTab] = useState('rank') const [themeReady, setThemeReady] = useState(false) const themeManagerRef = useRef< (typeof import('@visactor/vchart'))['ThemeManager'] | null @@ -121,9 +118,6 @@ export function UserCharts() { [userData, isLoading, timeGranularity, t] ) - const activeSpec = CHART_TABS.find((tab) => tab.value === activeTab) - const spec = activeSpec ? chartData[activeSpec.specKey] : null - return (
{/* Toolbar: time range presets + granularity */} @@ -169,50 +163,41 @@ export function UserCharts() { )}
- {/* Chart card */} -
-
-
- -
{t('User Analytics')}
-
+
+ {USER_CHARTS.map((chart) => { + const spec = chartData[chart.specKey] -
- {CHART_TABS.map((tab) => ( - - ))} -
-
+ return ( +
+
+ +
{t(chart.labelKey)}
+
-
- {isLoading ? ( - - ) : ( - themeReady && - spec && ( - - ) - )} -
+
+ {isLoading ? ( + + ) : ( + themeReady && + spec && ( + + ) + )} +
+
+ ) + })}
) diff --git a/web/default/src/features/dashboard/index.tsx b/web/default/src/features/dashboard/index.tsx index 751fc69b..8d76031a 100644 --- a/web/default/src/features/dashboard/index.tsx +++ b/web/default/src/features/dashboard/index.tsx @@ -35,6 +35,12 @@ const LazyModelCharts = lazy(() => })) ) +const LazyConsumptionDistributionChart = lazy(() => + import('./components/models/consumption-distribution-chart').then((m) => ({ + default: m.ConsumptionDistributionChart, + })) +) + const LazyUserCharts = lazy(() => import('./components/users/user-charts').then((m) => ({ default: m.UserCharts, @@ -163,6 +169,17 @@ export function Dashboard() { + }> + + + + }> string +function getVChartDefaultColors(domainLength: number) { + const scheme = + vchartDefaultDataScheme.find( + (item) => !item.maxDomainLength || domainLength <= item.maxDomainLength + ) ?? vchartDefaultDataScheme[vchartDefaultDataScheme.length - 1] + + return scheme.scheme +} + +function buildModelColorSpec(models: string[]) { + const domain = Array.from(new Set(models)) + return { + type: 'ordinal', + domain, + range: getVChartDefaultColors(domain.length), + } +} + +function renderQuotaCompat(rawQuota: number, digits = 4): string { + const { config, meta } = getCurrencyDisplay() + if (meta.kind === 'tokens') return rawQuota.toLocaleString() + const usd = rawQuota / config.quotaPerUnit + const rate = 'exchangeRate' in meta ? meta.exchangeRate : 1 + const symbol = 'symbol' in meta ? meta.symbol : '$' + const value = usd * rate + const fixed = value.toFixed(digits) + if (parseFloat(fixed) === 0 && rawQuota > 0 && value > 0) { + return symbol + Math.pow(10, -digits).toFixed(digits) + } + return symbol + fixed +} + /** * Process and aggregate chart data */ @@ -19,9 +51,61 @@ export function processChartData( t?: TFunction ): ProcessedChartData { const tt: TFunction = t ?? ((x) => x) + const otherLabel = tt('Other') const formatInt = (value: number) => Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(value) + const formatQuotaValue = (value: number) => renderQuotaCompat(value, 4) + const formatQuotaTotal = (value: number) => renderQuotaCompat(value, 2) + + const MAX_TOOLTIP_MODELS = 15 + + const makeTooltipDimensionUpdateContent = () => { + return ( + array: Array<{ + key: string + value: string | number + datum?: Record + }> + ) => { + array.sort((a, b) => (Number(b.value) || 0) - (Number(a.value) || 0)) + let sum = 0 + for (let i = 0; i < array.length; i++) { + if (array[i].key === 'Other' || array[i].key === otherLabel) continue + const v = Number(array[i].value) || 0 + if ( + array[i].datum && + (array[i].datum as Record)?.TimeSum + ) { + sum = + Number((array[i].datum as Record)?.TimeSum) || sum + } + array[i].value = formatQuotaValue(v) + } + + if (array.length > MAX_TOOLTIP_MODELS) { + const visible = array.slice(0, MAX_TOOLTIP_MODELS) + let otherSum = 0 + for (let i = MAX_TOOLTIP_MODELS; i < array.length; i++) { + const raw = array[i].datum + ? Number((array[i].datum as Record)?.rawQuota) || 0 + : 0 + otherSum += raw + } + visible.push({ + key: otherLabel, + value: formatQuotaValue(otherSum), + }) + array = visible + } + + array.unshift({ + key: tt('Total:'), + value: formatQuotaValue(sum), + }) + return array + } + } if (!data || data.length === 0) { return { @@ -35,7 +119,7 @@ export function processChartData( categoryField: 'type', title: { visible: true, - text: tt('Call Proportion'), + text: tt('Call Count Distribution'), subtext: tt('No data available'), }, legends: { visible: false }, @@ -54,15 +138,15 @@ export function processChartData( seriesField: 'Model', stack: true, legends: { visible: true, selectMode: 'single' }, - title: { - visible: true, - text: tt('Quota Distribution'), - subtext: `${tt('Total:')} ${formatQuotaWithCurrency(0, { - digitsLarge: 2, - digitsSmall: 2, - abbreviate: false, - })}`, - }, + }, + spec_area: { + type: 'area', + data: [{ id: 'areaData', values: [] }], + xField: 'Time', + yField: 'Usage', + seriesField: 'Model', + stack: true, + legends: { visible: true, selectMode: 'single' }, }, spec_model_line: { type: 'line', @@ -86,10 +170,11 @@ export function processChartData( legends: { visible: true, selectMode: 'single' }, title: { visible: true, - text: tt('Top Models'), + text: tt('Call Count Ranking'), subtext: `${tt('Total:')} ${formatInt(0)}`, }, }, + totalQuotaDisplay: formatQuotaTotal(0), } } @@ -142,6 +227,7 @@ export function processChartData( const allModels = Array.from(modelTotalsMap.keys()) const sortedTimes = Array.from(timeModelMap.keys()).sort() const sortedModels = [...allModels].sort() + const modelColor = buildModelColorSpec([...sortedModels, otherLabel]) // Pad time points if too few (default 7 points) const MAX_TREND_POINTS = MAX_CHART_TREND_POINTS @@ -166,14 +252,6 @@ export function processChartData( } const chartTimes = fillTimePoints(sortedTimes) - const modelColorMap = sortedModels.reduce>( - (acc, model, index) => { - acc[model] = getChartColor(index) - return acc - }, - {} - ) - const totalTimes = Array.from(modelTotalsMap.values()).reduce( (sum, x) => sum + (Number(x.count) || 0), 0 @@ -223,14 +301,70 @@ export function processChartData( }) lineValues.sort((a, b) => a.Time.localeCompare(b.Time)) - // Line chart: model call trend + // Area chart: top models by quota + "Other" bucket (too many series = unreadable) + const MAX_AREA_MODELS = 15 + const rankedQuotaModels = Array.from(modelTotalsMap.entries()) + .map(([model, stats]) => ({ + Model: model, + Quota: Number(stats.quota) || 0, + })) + .sort((a, b) => b.Quota - a.Quota) + const topAreaModels = new Set( + rankedQuotaModels.slice(0, MAX_AREA_MODELS).map((m) => m.Model) + ) + + const areaValues: typeof lineValues = [] + chartTimes.forEach((time) => { + const buckets = new Map() + const modelMap = timeModelMap.get(time) + let timeSum = 0 + sortedModels.forEach((model) => { + const stats = modelMap?.get(model) + const rawQuota = Number(stats?.quota) || 0 + const usd = rawQuota ? rawQuota / quotaPerUnit : 0 + const usage = usd ? Number(usd.toFixed(4)) : 0 + timeSum += rawQuota + const key = topAreaModels.has(model) ? model : otherLabel + const prev = buckets.get(key) || { rawQuota: 0, usage: 0 } + buckets.set(key, { + rawQuota: prev.rawQuota + rawQuota, + usage: Number((prev.usage + usage).toFixed(4)), + }) + }) + for (const [model, vals] of buckets) { + areaValues.push({ + Time: time, + Model: model, + rawQuota: vals.rawQuota, + Usage: vals.usage, + TimeSum: timeSum, + }) + } + }) + areaValues.sort((a, b) => a.Time.localeCompare(b.Time)) + + // Line chart: model call trend (top models + "Other" bucket) + const MAX_TREND_MODELS = 20 + const rankedTrendModels = Array.from(modelTotalsMap.entries()) + .map(([model, stats]) => ({ + Model: model, + Count: Number(stats.count) || 0, + })) + .sort((a, b) => b.Count - a.Count) + const topTrendModels = rankedTrendModels + .slice(0, MAX_TREND_MODELS) + .map((item) => item.Model) + const otherTrendModels = rankedTrendModels + .slice(MAX_TREND_MODELS) + .map((item) => item.Model) + const modelLineValues: Array<{ Time: string Model: string Count: number }> = [] chartTimes.forEach((time) => { - const timeData = sortedModels.map((model) => { + const timeData = topTrendModels.map((model) => { const stats = timeModelMap.get(time)?.get(model) return { Time: time, @@ -238,6 +372,17 @@ export function processChartData( Count: Number(stats?.count) || 0, } }) + if (otherTrendModels.length > 0) { + const otherCount = otherTrendModels.reduce((sum, model) => { + const stats = timeModelMap.get(time)?.get(model) + return sum + (Number(stats?.count) || 0) + }, 0) + timeData.push({ + Time: time, + Model: otherLabel, + Count: otherCount, + }) + } modelLineValues.push(...timeData) }) modelLineValues.sort((a, b) => a.Time.localeCompare(b.Time)) @@ -257,7 +402,7 @@ export function processChartData( const otherCount = allRankValues .slice(MAX_RANK_MODELS) .reduce((sum, item) => sum + item.Count, 0) - rankValues = [...topModels, { Model: tt('Other'), Count: otherCount }] + rankValues = [...topModels, { Model: otherLabel, Count: otherCount }] } else { rankValues = allRankValues } @@ -280,11 +425,12 @@ export function processChartData( }, title: { visible: true, - text: tt('Call Proportion'), + text: tt('Call Count Distribution'), subtext: `${tt('Total:')} ${formatInt(totalTimes)}`, }, legends: { visible: true, orient: 'left' }, label: { visible: true }, + color: modelColor, tooltip: { mark: { content: [ @@ -296,7 +442,6 @@ export function processChartData( ], }, }, - color: { specified: modelColorMap }, background: { fill: 'transparent' }, animation: true, }, @@ -308,15 +453,7 @@ export function processChartData( seriesField: 'Model', stack: true, legends: { visible: true, selectMode: 'single' }, - title: { - visible: true, - text: tt('Quota Distribution'), - subtext: `${tt('Total:')} ${formatQuotaWithCurrency(totalQuotaRaw, { - digitsLarge: 2, - digitsSmall: 2, - abbreviate: false, - })}`, - }, + color: modelColor, bar: { state: { hover: { stroke: '#000', lineWidth: 1 }, @@ -328,11 +465,7 @@ export function processChartData( { key: (datum: Record) => datum?.Model, value: (datum: Record) => - formatQuotaWithCurrency(Number(datum?.rawQuota) || 0, { - digitsLarge: 4, - digitsSmall: 4, - abbreviate: false, - }), + formatQuotaValue(Number(datum?.rawQuota) || 0), }, ], }, @@ -344,58 +477,67 @@ export function processChartData( Number(datum?.rawQuota) || 0, }, ], - updateContent: ( - array: Array<{ - key: string - value: string | number - datum?: Record - }> - ) => { - array.sort( - (a, b) => (Number(b.value) || 0) - (Number(a.value) || 0) - ) - let sum = 0 - for (let i = 0; i < array.length; i++) { - if (array[i].key === 'Other') continue - const v = Number(array[i].value) || 0 - if ( - array[i].datum && - (array[i].datum as Record)?.TimeSum - ) { - sum = - Number( - (array[i].datum as Record)?.TimeSum - ) || sum - } - array[i].value = formatQuotaWithCurrency(v, { - digitsLarge: 4, - digitsSmall: 4, - abbreviate: false, - }) - } - array.unshift({ - key: tt('Total:'), - value: formatQuotaWithCurrency(sum, { - digitsLarge: 4, - digitsSmall: 4, - abbreviate: false, - }), - }) - return array - }, + updateContent: makeTooltipDimensionUpdateContent(), }, }, - color: { specified: modelColorMap }, + background: { fill: 'transparent' }, + animation: true, + }, + spec_area: { + type: 'area', + data: [{ id: 'areaData', values: areaValues }], + xField: 'Time', + yField: 'Usage', + seriesField: 'Model', + stack: false, + legends: { visible: true, selectMode: 'single' }, + color: modelColor, + tooltip: { + mark: { + content: [ + { + key: (datum: Record) => datum?.Model, + value: (datum: Record) => + formatQuotaValue(Number(datum?.rawQuota) || 0), + }, + ], + }, + dimension: { + content: [ + { + key: (datum: Record) => datum?.Model, + value: (datum: Record) => + Number(datum?.rawQuota) || 0, + }, + ], + updateContent: makeTooltipDimensionUpdateContent(), + }, + }, + area: { + style: { + fillOpacity: 0.08, + curveType: 'monotone', + }, + }, + line: { + style: { + lineWidth: 2, + curveType: 'monotone', + }, + }, + point: { visible: false }, background: { fill: 'transparent' }, animation: true, }, spec_model_line: { - type: 'line', + type: 'area', data: [{ id: 'lineData', values: modelLineValues }], xField: 'Time', yField: 'Count', seriesField: 'Model', + stack: false, legends: { visible: true, selectMode: 'single' }, + color: modelColor, title: { visible: true, text: tt('Call Trend'), @@ -442,7 +584,18 @@ export function processChartData( }, }, }, - color: { specified: modelColorMap }, + area: { + style: { + fillOpacity: 0.08, + curveType: 'monotone', + }, + }, + line: { + style: { + lineWidth: 2, + curveType: 'monotone', + }, + }, point: { visible: false }, background: { fill: 'transparent' }, animation: true, @@ -454,9 +607,10 @@ export function processChartData( yField: 'Count', seriesField: 'Model', legends: { visible: true, selectMode: 'single' }, + color: modelColor, title: { visible: true, - text: tt('Top Models'), + text: tt('Call Count Ranking'), subtext: `${tt('Total:')} ${formatInt(totalTimes)}`, }, bar: { @@ -475,10 +629,10 @@ export function processChartData( ], }, }, - color: { specified: modelColorMap }, background: { fill: 'transparent' }, animation: true, }, + totalQuotaDisplay: formatQuotaTotal(totalQuotaRaw), } } @@ -505,12 +659,7 @@ export function processUserChartData( const { config } = getCurrencyDisplay() const quotaPerUnit = config.quotaPerUnit - const formatVal = (raw: number) => - formatQuotaWithCurrency(raw, { - digitsLarge: 2, - digitsSmall: 2, - abbreviate: false, - }) + const formatVal = (raw: number) => renderQuotaCompat(raw, 2) const emptyResult: ProcessedUserChartData = { spec_user_rank: { diff --git a/web/default/src/features/dashboard/lib/filters.ts b/web/default/src/features/dashboard/lib/filters.ts index c33972aa..494e7c11 100644 --- a/web/default/src/features/dashboard/lib/filters.ts +++ b/web/default/src/features/dashboard/lib/filters.ts @@ -45,14 +45,12 @@ export function buildQueryParams( ): { start_timestamp: number end_timestamp: number - default_time?: string + default_time: string username?: string } { return { ...timeRange, - ...(filters?.time_granularity && { - default_time: filters.time_granularity, - }), + default_time: getSavedGranularity(filters?.time_granularity), ...(filters?.username && { username: filters.username }), } } diff --git a/web/default/src/features/dashboard/types.ts b/web/default/src/features/dashboard/types.ts index 2ab3eec0..32f5f117 100644 --- a/web/default/src/features/dashboard/types.ts +++ b/web/default/src/features/dashboard/types.ts @@ -71,8 +71,10 @@ type VChartSpec = Record export interface ProcessedChartData { spec_pie: VChartSpec spec_line: VChartSpec + spec_area: VChartSpec spec_model_line: VChartSpec spec_rank_bar: VChartSpec + totalQuotaDisplay: string } export interface ProcessedUserChartData { diff --git a/web/default/src/features/keys/components/api-key-group-combobox.tsx b/web/default/src/features/keys/components/api-key-group-combobox.tsx new file mode 100644 index 00000000..b1fd143c --- /dev/null +++ b/web/default/src/features/keys/components/api-key-group-combobox.tsx @@ -0,0 +1,173 @@ +import { useMemo, useState } from 'react' +import { Check, ChevronsUpDown } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { cn } from '@/lib/utils' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover' + +export type ApiKeyGroupOption = { + value: string + label: string + desc?: string + ratio?: number | string +} + +type ApiKeyGroupComboboxProps = { + options: ApiKeyGroupOption[] + value?: string + onValueChange: (value: string) => void + placeholder?: string + disabled?: boolean +} + +function formatGroupRatio(ratio: ApiKeyGroupOption['ratio'], ratioLabel: string) { + if (ratio === undefined || ratio === null || ratio === '') return null + return `${ratio}x ${ratioLabel}` +} + +function getRatioBadgeClassName(ratio: ApiKeyGroupOption['ratio']) { + if (typeof ratio !== 'number') { + return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/40 dark:text-emerald-300' + } + + if (ratio > 5) { + return 'border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-900/60 dark:bg-rose-950/40 dark:text-rose-300' + } + if (ratio > 3) { + return 'border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-900/60 dark:bg-orange-950/40 dark:text-orange-300' + } + if (ratio > 1) { + return 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/60 dark:bg-blue-950/40 dark:text-blue-300' + } + return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/40 dark:text-emerald-300' +} + +function GroupRatioBadge({ ratio }: { ratio: ApiKeyGroupOption['ratio'] }) { + const { t } = useTranslation() + const label = formatGroupRatio(ratio, t('Ratio')) + + if (!label) return null + + return ( + + {label} + + ) +} + +export function ApiKeyGroupCombobox({ + options, + value, + onValueChange, + placeholder, + disabled, +}: ApiKeyGroupComboboxProps) { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + const [searchValue, setSearchValue] = useState('') + const selectedOption = options.find((option) => option.value === value) + + const filteredOptions = useMemo(() => { + const search = searchValue.trim().toLowerCase() + if (!search) return options + + return options.filter((option) => { + const ratioText = String(option.ratio ?? '').toLowerCase() + return ( + option.value.toLowerCase().includes(search) || + option.label.toLowerCase().includes(search) || + option.desc?.toLowerCase().includes(search) || + ratioText.includes(search) + ) + }) + }, [options, searchValue]) + + const handleSelect = (selectedValue: string) => { + onValueChange(selectedValue) + setOpen(false) + setSearchValue('') + } + + return ( + + + + + + + + + {t('No group found.')} + + {filteredOptions.map((option) => ( + + + + + {option.value} + + {option.desc && ( + + {option.desc} + + )} + + + + ))} + + + + + + ) +} diff --git a/web/default/src/features/keys/components/api-keys-columns.tsx b/web/default/src/features/keys/components/api-keys-columns.tsx index ab9da015..ab022403 100644 --- a/web/default/src/features/keys/components/api-keys-columns.tsx +++ b/web/default/src/features/keys/components/api-keys-columns.tsx @@ -62,7 +62,7 @@ function useGroupRatios(): Record { if (!res.success || !res.data) return {} const ratios: Record = {} for (const [group, info] of Object.entries(res.data)) { - if (info.ratio !== undefined) { + if (typeof info.ratio === 'number') { ratios[group] = info.ratio } } diff --git a/web/default/src/features/keys/components/api-keys-dialogs.tsx b/web/default/src/features/keys/components/api-keys-dialogs.tsx index fa1159cc..e6f3b7cb 100644 --- a/web/default/src/features/keys/components/api-keys-dialogs.tsx +++ b/web/default/src/features/keys/components/api-keys-dialogs.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from 'react' import { ApiKeysDeleteDialog } from './api-keys-delete-dialog' import { ApiKeysMutateDrawer } from './api-keys-mutate-drawer' import { useApiKeys } from './api-keys-provider' @@ -5,6 +6,19 @@ import { CCSwitchDialog } from './dialogs/cc-switch-dialog' export function ApiKeysDialogs() { const { open, setOpen, currentRow, resolvedKey } = useApiKeys() + const [lastMutateSide, setLastMutateSide] = useState<'left' | 'right'>( + 'right' + ) + const mutateSide = + open === 'create' ? 'left' : open === 'update' ? 'right' : lastMutateSide + + useEffect(() => { + if (open === 'create') { + setLastMutateSide('left') + } else if (open === 'update') { + setLastMutateSide('right') + } + }, [open]) return ( <> @@ -12,6 +26,7 @@ export function ApiKeysDialogs() { open={open === 'create' || open === 'update'} onOpenChange={(isOpen) => !isOpen && setOpen(null)} currentRow={open === 'update' ? currentRow || undefined : undefined} + side={mutateSide} /> void currentRow?: ApiKey + side?: 'left' | 'right' } export function ApiKeysMutateDrawer({ open, onOpenChange, currentRow, + side = 'right', }: ApiKeyMutateDrawerProps) { const { t } = useTranslation() const isUpdate = !!currentRow @@ -88,14 +87,22 @@ export function ApiKeysMutateDrawer({ const models = modelsData?.data || [] const groupsRaw = groupsData?.data || {} - const groups = Object.entries(groupsRaw).map(([key, info]) => ({ - value: key, - label: info.desc || key, - })) + const groups: ApiKeyGroupOption[] = Object.entries(groupsRaw).map( + ([key, info]) => ({ + value: key, + label: key, + desc: info.desc || key, + ratio: info.ratio, + }) + ) // Add auto group if configured if (!groups.some((g) => g.value === 'auto')) { - groups.unshift({ value: 'auto', label: t('Auto (Circuit Breaker)') }) + groups.unshift({ + value: 'auto', + label: 'auto', + desc: t('Auto (Circuit Breaker)'), + }) } const form = useForm({ @@ -187,10 +194,9 @@ export function ApiKeysMutateDrawer({ form.setValue('expired_time', now) } - const { config: currencyConfig, meta: currencyMeta } = getCurrencyDisplay() + const { meta: currencyMeta } = getCurrencyDisplay() const currencyLabel = getCurrencyLabel() - const tokensOnly = - !currencyConfig.displayInCurrency || currencyMeta.kind === 'tokens' + const tokensOnly = currencyMeta.kind === 'tokens' const quotaLabel = t('Quota ({{currency}})', { currency: currencyLabel }) const quotaPlaceholder = tokensOnly ? t('Enter quota in tokens') @@ -206,7 +212,10 @@ export function ApiKeysMutateDrawer({ } }} > - + {isUpdate ? t('Update API Key') : t('Create API Key')} @@ -244,20 +253,14 @@ export function ApiKeysMutateDrawer({ render={({ field }) => ( {t('Group')} - + + + {t('Auto group enables circuit breaker mechanism')} diff --git a/web/default/src/features/keys/components/api-keys-table.tsx b/web/default/src/features/keys/components/api-keys-table.tsx index e7191415..402fb2b3 100644 --- a/web/default/src/features/keys/components/api-keys-table.tsx +++ b/web/default/src/features/keys/components/api-keys-table.tsx @@ -38,6 +38,7 @@ import { getApiKeys, searchApiKeys } from '../api' import { API_KEY_STATUS_OPTIONS, ERROR_MESSAGES } from '../constants' import { type ApiKey } from '../types' import { useApiKeysColumns } from './api-keys-columns' +import { ApiKeysPrimaryButtons } from './api-keys-primary-buttons' import { useApiKeys } from './api-keys-provider' import { DataTableBulkActions } from './data-table-bulk-actions' @@ -160,17 +161,22 @@ export function ApiKeysTable() { return ( <>
- +
+ +
+ +
+
{isMobile ? ( {t('Manage your API keys for accessing the service')} - - - diff --git a/web/default/src/features/redemption-codes/components/redemptions-mutate-drawer.tsx b/web/default/src/features/redemption-codes/components/redemptions-mutate-drawer.tsx index 169ee8b8..39811ec0 100644 --- a/web/default/src/features/redemption-codes/components/redemptions-mutate-drawer.tsx +++ b/web/default/src/features/redemption-codes/components/redemptions-mutate-drawer.tsx @@ -115,10 +115,9 @@ export function RedemptionsMutateDrawer({ form.setValue('expired_time', newDate) } - const { config: currencyConfig, meta: currencyMeta } = getCurrencyDisplay() + const { meta: currencyMeta } = getCurrencyDisplay() const currencyLabel = getCurrencyLabel() - const tokensOnly = - !currencyConfig.displayInCurrency || currencyMeta.kind === 'tokens' + const tokensOnly = currencyMeta.kind === 'tokens' const quotaLabel = t('Quota ({{currency}})', { currency: currencyLabel }) const quotaPlaceholder = tokensOnly ? t('Enter quota in tokens') diff --git a/web/default/src/features/system-settings/general/pricing-section.tsx b/web/default/src/features/system-settings/general/pricing-section.tsx index cfcc0f92..9e471f44 100644 --- a/web/default/src/features/system-settings/general/pricing-section.tsx +++ b/web/default/src/features/system-settings/general/pricing-section.tsx @@ -3,6 +3,7 @@ import type { Resolver } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { RotateCcw } from 'lucide-react' import { useTranslation } from 'react-i18next' +import { DEFAULT_CURRENCY_CONFIG } from '@/stores/system-config-store' import { Button } from '@/components/ui/button' import { Form, @@ -110,6 +111,12 @@ export function PricingSection({ defaultValues }: PricingSectionProps) { }) const displayType = form.watch('general_setting.quota_display_type') ?? 'USD' + const displayInCurrencyEnabled = form.watch('DisplayInCurrencyEnabled') + const showTokensOnlyOption = displayType === 'TOKENS' + const showQuotaPerUnit = + displayType === 'TOKENS' || + defaultValues.QuotaPerUnit !== DEFAULT_CURRENCY_CONFIG.quotaPerUnit + const showDisplayInCurrencyOption = displayInCurrencyEnabled === false return ( <> @@ -122,30 +129,32 @@ export function PricingSection({ defaultValues }: PricingSectionProps) {
- ( - - {t('Quota Per Unit')} - - field.onChange(e.target.valueAsNumber)} - name={field.name} - onBlur={field.onBlur} - ref={field.ref} - /> - - - {t('Number of tokens per unit quota')} - - - - )} - /> + {showQuotaPerUnit && ( + ( + + {t('Quota Per Unit')} + + + + + {t('Number of tokens per unit quota')} + + + + )} + /> + )} {t('Custom Currency')} - {t('Tokens Only')} + {showTokensOnlyOption && ( + + {t('Tokens Only')} + + )} @@ -272,32 +285,34 @@ export function PricingSection({ defaultValues }: PricingSectionProps) {
)} - ( - -
- - {t('Display in Currency')} - - - {displayType === 'TOKENS' - ? t( - 'Tokens-only mode will show raw quota values regardless of this toggle.' - ) - : t('Show prices in currency instead of quota.')} - -
- - - -
- )} - /> + {showDisplayInCurrencyOption && ( + ( + +
+ + {t('Display in Currency')} + + + {displayType === 'TOKENS' + ? t( + 'Tokens-only mode will show raw quota values regardless of this toggle.' + ) + : t('Show prices in currency instead of quota.')} + +
+ + + +
+ )} + /> + )} [] { size='sm' copyable={false} /> - {log.request_id && ( - 18 - ? `${log.request_id.slice(0, 18)}…` - : log.request_id - } - variant='neutral' - size='sm' - copyText={log.request_id} - className='max-w-[140px] truncate font-mono' - /> - )}
) }, @@ -267,45 +255,47 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef[] { ] if (isAdmin) { - columns.push({ - id: 'source', - header: ({ column }) => ( - - ), - cell: function SourceCell({ row }) { - const { - setAffinityTarget, - setAffinityDialogOpen, - setSelectedUserId, - setUserInfoDialogOpen, - } = useUsageLogsContext() - const log = row.original + columns.push( + { + id: 'channel', + header: ({ column }) => ( + + ), + cell: function ChannelCell({ row }) { + const { + sensitiveVisible, + setAffinityTarget, + setAffinityDialogOpen, + } = useUsageLogsContext() + const log = row.original - if (!isDisplayableLogType(log.type)) return null + if (!isDisplayableLogType(log.type)) return null - const other = parseLogOther(log.other) - const affinity = other?.admin_info?.channel_affinity - const useChannel = other?.admin_info?.use_channel - const channelChain = - useChannel && useChannel.length > 0 - ? useChannel.join(' → ') - : undefined - const channelDisplay = log.channel_name - ? `${log.channel_name} #${log.channel}` - : `#${log.channel}` + const other = parseLogOther(log.other) + const affinity = other?.admin_info?.channel_affinity + const useChannel = other?.admin_info?.use_channel + const channelChain = + useChannel && useChannel.length > 0 + ? useChannel.join(' → ') + : undefined + const channelDisplay = log.channel_name + ? `${log.channel_name} #${log.channel}` + : `#${log.channel}` + const channelIdDisplay = `#${log.channel}` + const channelName = sensitiveVisible ? log.channel_name : '••••' - return ( -
-
- - - -
+ return ( + + + +
+
{affinity && ( )}
- - -
-

{channelDisplay}

- {channelChain && ( -

- {t('Chain')}: {channelChain} + {log.channel_name && ( + + {channelName} + + )} +

+ + +
+

{sensitiveVisible ? channelDisplay : channelIdDisplay}

+ {channelChain && ( +

+ {t('Chain')}: {channelChain} +

+ )} + {affinity && ( +
+

{t('Channel Affinity')}

+

+ {t('Rule')}: {affinity.rule_name || '-'}

- )} - {affinity && ( -
-

{t('Channel Affinity')}

-

- {t('Rule')}: {affinity.rule_name || '-'} -

-

- {t('Group')}:{' '} - {affinity.using_group || +

+ {t('Group')}:{' '} + {sensitiveVisible + ? affinity.using_group || affinity.selected_group || - '-'} -

-
- )} -
- - - -
- {log.username && ( - - )} -
- ) + '-' + : '••••'} +

+
+ )} +
+ + + + ) + }, + meta: { label: t('Channel'), mobileHidden: true }, }, - meta: { label: t('Source'), mobileHidden: true }, - }) + { + id: 'user', + header: ({ column }) => ( + + ), + cell: function UserCell({ row }) { + const { + sensitiveVisible, + setSelectedUserId, + setUserInfoDialogOpen, + } = useUsageLogsContext() + const log = row.original + + if (!isDisplayableLogType(log.type) || !log.username) return null + + return ( + + ) + }, + meta: { label: t('User'), mobileHidden: true }, + } + ) } columns.push( @@ -389,6 +411,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef[] { ), cell: function ModelCell({ row }) { + const { sensitiveVisible } = useUsageLogsContext() const log = row.original if (!isDisplayableLogType(log.type)) return null @@ -450,8 +473,8 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef[] { ) const metaParts: string[] = [] - if (tokenName) metaParts.push(tokenName) - if (group) metaParts.push(group) + if (tokenName) metaParts.push(sensitiveVisible ? tokenName : '••••') + if (group) metaParts.push(sensitiveVisible ? group : '••••') return (
diff --git a/web/default/src/features/usage-logs/components/common-logs-filter-bar.tsx b/web/default/src/features/usage-logs/components/common-logs-filter-bar.tsx new file mode 100644 index 00000000..488ea5e0 --- /dev/null +++ b/web/default/src/features/usage-logs/components/common-logs-filter-bar.tsx @@ -0,0 +1,279 @@ +import { useState, useEffect, useCallback, type ReactNode } from 'react' +import { useNavigate, getRouteApi } from '@tanstack/react-router' +import { ChevronDown, Eye, EyeOff, RotateCcw, Search } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { cn } from '@/lib/utils' +import { useIsAdmin } from '@/hooks/use-admin' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { LOG_TYPES } from '../constants' +import { buildSearchParams } from '../lib/filter' +import { getDefaultTimeRange } from '../lib/utils' +import type { CommonLogFilters } from '../types' +import { CompactDateTimeRangePicker } from './compact-date-time-range-picker' +import { useUsageLogsContext } from './usage-logs-provider' + +const route = getRouteApi('/_authenticated/usage-logs/$section') + +interface CommonLogsFilterBarProps { + stats?: ReactNode + viewOptions?: ReactNode +} + +export function CommonLogsFilterBar({ + stats, + viewOptions, +}: CommonLogsFilterBarProps) { + const { t } = useTranslation() + const navigate = useNavigate() + const searchParams = route.useSearch() + const isAdmin = useIsAdmin() + const { sensitiveVisible, setSensitiveVisible } = useUsageLogsContext() + + const [expanded, setExpanded] = useState(false) + const [filters, setFilters] = useState(() => { + const { start, end } = getDefaultTimeRange() + return { startTime: start, endTime: end } + }) + const [logType, setLogType] = useState('') + + useEffect(() => { + const next: Partial = {} + if (searchParams.startTime) + next.startTime = new Date(searchParams.startTime) + if (searchParams.endTime) next.endTime = new Date(searchParams.endTime) + if (searchParams.channel) next.channel = String(searchParams.channel) + if (searchParams.model) next.model = searchParams.model + if (searchParams.token) next.token = searchParams.token + if (searchParams.group) next.group = searchParams.group + if (searchParams.username) next.username = searchParams.username + if (searchParams.requestId) next.requestId = searchParams.requestId + + if (Object.keys(next).length > 0) { + setFilters((prev) => ({ ...prev, ...next })) + } + + const typeArr = searchParams.type + if (Array.isArray(typeArr) && typeArr.length === 1) { + setLogType(typeArr[0]) + } + }, [ + searchParams.startTime, + searchParams.endTime, + searchParams.channel, + searchParams.model, + searchParams.token, + searchParams.group, + searchParams.username, + searchParams.requestId, + searchParams.type, + ]) + + const handleChange = useCallback( + (field: keyof CommonLogFilters, value: Date | string | undefined) => { + setFilters((prev) => ({ ...prev, [field]: value })) + }, + [] + ) + + const handleApply = useCallback(() => { + const filterParams = buildSearchParams(filters, 'common') + navigate({ + to: '/usage-logs/$section', + params: { section: 'common' }, + search: (prev: Record) => ({ + ...prev, + ...filterParams, + ...(logType ? { type: [logType] } : { type: undefined }), + page: 1, + }), + }) + }, [filters, logType, navigate]) + + const handleReset = useCallback(() => { + const { start, end } = getDefaultTimeRange() + const resetFilters: CommonLogFilters = { startTime: start, endTime: end } + setFilters(resetFilters) + setLogType('') + + navigate({ + to: '/usage-logs/$section', + params: { section: 'common' }, + search: { + page: 1, + startTime: start.getTime(), + endTime: end.getTime(), + }, + }) + }, [navigate]) + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter') handleApply() + }, + [handleApply] + ) + + const hasExpandedFilters = + !!filters.token || + !!filters.username || + !!filters.channel || + !!filters.requestId + + return ( +
+ {/* Primary filter row */} +
+ { + handleChange('startTime', start) + handleChange('endTime', end) + }} + className='col-span-2 lg:col-span-1' + /> + handleChange('model', e.target.value)} + onKeyDown={handleKeyDown} + className='h-9' + /> + handleChange('group', e.target.value)} + onKeyDown={handleKeyDown} + className='h-9' + /> + + +
+ + {/* Expandable filter row */} +
+
+
+ handleChange('token', e.target.value)} + onKeyDown={handleKeyDown} + className='h-9' + /> + {isAdmin && ( + handleChange('username', e.target.value)} + onKeyDown={handleKeyDown} + className='h-9' + /> + )} + {isAdmin && ( + handleChange('channel', e.target.value)} + onKeyDown={handleKeyDown} + className='h-9' + /> + )} + handleChange('requestId', e.target.value)} + onKeyDown={handleKeyDown} + className='h-9' + /> +
+
+
+ + {/* Actions row */} +
+
+ {stats &&
{stats}
} + +
+ +
+ + + {viewOptions} +
+
+
+ ) +} diff --git a/web/default/src/features/usage-logs/components/common-logs-stats.tsx b/web/default/src/features/usage-logs/components/common-logs-stats.tsx index 1e94c89c..708f30ce 100644 --- a/web/default/src/features/usage-logs/components/common-logs-stats.tsx +++ b/web/default/src/features/usage-logs/components/common-logs-stats.tsx @@ -5,10 +5,10 @@ import { formatLogQuota } from '@/lib/format' import { cn } from '@/lib/utils' import { useIsAdmin } from '@/hooks/use-admin' import { Skeleton } from '@/components/ui/skeleton' -import { dotColorMap, textColorMap } from '@/components/status-badge' import { getLogStats, getUserLogStats } from '../api' import { DEFAULT_LOG_STATS } from '../constants' import { buildApiParams } from '../lib/utils' +import { useUsageLogsContext } from './usage-logs-provider' const route = getRouteApi('/_authenticated/usage-logs/$section') @@ -16,6 +16,7 @@ export function CommonLogsStats() { const { t } = useTranslation() const isAdmin = useIsAdmin() const searchParams = route.useSearch() + const { sensitiveVisible } = useUsageLogsContext() const { data: stats, isLoading } = useQuery({ queryKey: ['usage-logs-stats', isAdmin, searchParams], @@ -41,29 +42,42 @@ export function CommonLogsStats() { if (isLoading) { return ( -
+
- - + +
) } + const tagClass = + 'inline-flex h-6 items-center rounded-md border px-2.5 text-xs font-medium shadow-xs' + return ( -
+
diff --git a/web/default/src/features/usage-logs/components/compact-date-time-range-picker.tsx b/web/default/src/features/usage-logs/components/compact-date-time-range-picker.tsx new file mode 100644 index 00000000..5168b080 --- /dev/null +++ b/web/default/src/features/usage-logs/components/compact-date-time-range-picker.tsx @@ -0,0 +1,199 @@ +import { useMemo, useState } from 'react' +import { CalendarDays } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import dayjs from '@/lib/dayjs' +import { cn } from '@/lib/utils' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover' + +interface CompactDateTimeRangePickerProps { + start?: Date + end?: Date + onChange: (range: { start?: Date; end?: Date }) => void + className?: string +} + +function toInputValue(date?: Date): string { + return date ? dayjs(date).format('YYYY-MM-DDTHH:mm') : '' +} + +function fromInputValue(value: string): Date | undefined { + if (!value) return undefined + const date = new Date(value) + return Number.isNaN(date.getTime()) ? undefined : date +} + +export function CompactDateTimeRangePicker({ + start, + end, + onChange, + className, +}: CompactDateTimeRangePickerProps) { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + const [draftStart, setDraftStart] = useState(toInputValue(start)) + const [draftEnd, setDraftEnd] = useState(toInputValue(end)) + + const label = useMemo(() => { + if (!start && !end) return t('Date Range') + const startText = start ? dayjs(start).format('YYYY-MM-DD HH:mm:ss') : '-' + const endText = end ? dayjs(end).format('YYYY-MM-DD HH:mm:ss') : '-' + return `${startText} ~ ${endText}` + }, [end, start, t]) + + const handleOpenChange = (nextOpen: boolean) => { + if (nextOpen) { + setDraftStart(toInputValue(start)) + setDraftEnd(toInputValue(end)) + } + setOpen(nextOpen) + } + + const applyDraft = () => { + onChange({ + start: fromInputValue(draftStart), + end: fromInputValue(draftEnd), + }) + setOpen(false) + } + + const applyPreset = (kind: 'today' | '7d' | 'week' | '30d' | 'month') => { + const now = dayjs() + const presets = { + today: { + start: now.startOf('day').toDate(), + end: now.endOf('day').toDate(), + }, + '7d': { + start: now.subtract(6, 'day').startOf('day').toDate(), + end: now.endOf('day').toDate(), + }, + week: { + start: now.startOf('week').toDate(), + end: now.endOf('week').toDate(), + }, + '30d': { + start: now.subtract(29, 'day').startOf('day').toDate(), + end: now.endOf('day').toDate(), + }, + month: { + start: now.startOf('month').toDate(), + end: now.endOf('month').toDate(), + }, + } + const range = presets[kind] + setDraftStart(toInputValue(range.start)) + setDraftEnd(toInputValue(range.end)) + onChange(range) + setOpen(false) + } + + return ( + + + + + +
+
+
+
+ {t('Start Time')} +
+ setDraftStart(e.target.value)} + className='h-8 font-mono text-xs' + /> +
+ + ~ + +
+
+ {t('End Time')} +
+ setDraftEnd(e.target.value)} + className='h-8 font-mono text-xs' + /> +
+
+ +
+ + + + + +
+ +
+ +
+
+
+
+ ) +} diff --git a/web/default/src/features/usage-logs/components/usage-logs-provider.tsx b/web/default/src/features/usage-logs/components/usage-logs-provider.tsx index 21c9d6cf..6584a560 100644 --- a/web/default/src/features/usage-logs/components/usage-logs-provider.tsx +++ b/web/default/src/features/usage-logs/components/usage-logs-provider.tsx @@ -11,6 +11,8 @@ interface UsageLogsContextValue { setAffinityTarget: (target: ChannelAffinityInfo | null) => void affinityDialogOpen: boolean setAffinityDialogOpen: (open: boolean) => void + sensitiveVisible: boolean + setSensitiveVisible: (visible: boolean) => void } const UsageLogsContext = createContext( @@ -23,6 +25,7 @@ export function UsageLogsProvider({ children }: { children: ReactNode }) { const [affinityTarget, setAffinityTarget] = useState(null) const [affinityDialogOpen, setAffinityDialogOpen] = useState(false) + const [sensitiveVisible, setSensitiveVisible] = useState(true) return ( {children} diff --git a/web/default/src/features/usage-logs/components/usage-logs-table.tsx b/web/default/src/features/usage-logs/components/usage-logs-table.tsx index cf9c9a1d..837a8432 100644 --- a/web/default/src/features/usage-logs/components/usage-logs-table.tsx +++ b/web/default/src/features/usage-logs/components/usage-logs-table.tsx @@ -28,15 +28,18 @@ import { import { DataTablePagination, DataTableToolbar, + DataTableViewOptions, TableSkeleton, TableEmpty, MobileCardList, } from '@/components/data-table' import { PageFooterPortal } from '@/components/layout' -import { LOG_TYPE_FILTERS, DEFAULT_LOGS_DATA } from '../constants' +import { DEFAULT_LOGS_DATA } from '../constants' import { useColumnsByCategory } from '../lib/columns' import { fetchLogsByCategory } from '../lib/utils' import type { LogCategory } from '../types' +import { CommonLogsFilterBar } from './common-logs-filter-bar' +import { CommonLogsStats } from './common-logs-stats' const route = getRouteApi('/_authenticated/usage-logs/$section') @@ -147,25 +150,23 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) { ensurePageInRange(pageCount) }, [pageCount, ensurePageInRange]) - const filters = - logCategory === 'common' - ? [ - { - columnId: 'created_at', - title: t('Log Type'), - options: LOG_TYPE_FILTERS.map((opt) => ({ - value: opt.value, - label: t(opt.label), - })), - singleSelect: true, - }, - ] - : [] - return ( <>
- + {logCategory === 'common' ? ( +
+ } + viewOptions={} + /> +
+ ) : ( + + )} {isMobile ? ( - {activeCategory === 'common' && } - + {activeCategory !== 'common' && ( + + )} diff --git a/web/default/src/features/users/components/user-quota-dialog.tsx b/web/default/src/features/users/components/user-quota-dialog.tsx index 9efd883c..f104b568 100644 --- a/web/default/src/features/users/components/user-quota-dialog.tsx +++ b/web/default/src/features/users/components/user-quota-dialog.tsx @@ -32,10 +32,9 @@ export function UserQuotaDialog(props: UserQuotaDialogProps) { const [amount, setAmount] = useState('') const [loading, setLoading] = useState(false) - const { config: currencyConfig, meta: currencyMeta } = getCurrencyDisplay() + const { meta: currencyMeta } = getCurrencyDisplay() const currencyLabel = getCurrencyLabel() - const tokensOnly = - !currencyConfig.displayInCurrency || currencyMeta.kind === 'tokens' + const tokensOnly = currencyMeta.kind === 'tokens' const amountValue = parseFloat(amount) || 0 const quotaValue = parseQuotaFromDollars(Math.abs(amountValue)) diff --git a/web/default/src/features/users/components/users-mutate-drawer.tsx b/web/default/src/features/users/components/users-mutate-drawer.tsx index 9cc23d8a..0c0c5d6a 100644 --- a/web/default/src/features/users/components/users-mutate-drawer.tsx +++ b/web/default/src/features/users/components/users-mutate-drawer.tsx @@ -95,10 +95,9 @@ export function UsersMutateDrawer({ } }, [open, isUpdate, currentRow, form]) - const { config: currencyConfig, meta: currencyMeta } = getCurrencyDisplay() + const { meta: currencyMeta } = getCurrencyDisplay() const currencyLabel = getCurrencyLabel() - const tokensOnly = - !currencyConfig.displayInCurrency || currencyMeta.kind === 'tokens' + const tokensOnly = currencyMeta.kind === 'tokens' const currentQuotaRaw = form.watch('quota_dollars') || 0 diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index ec1edb12..25d1153d 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -331,6 +331,7 @@ "Are you sure?": "Are you sure?", "Args (space separated)": "Args (space separated)", "Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.": "Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.", + "Area Chart": "Area Chart", "Asc": "Asc", "Ask anything": "Ask anything", "Async task refund": "Async task refund", @@ -407,6 +408,7 @@ "Balance queried successfully": "Balance queried successfully", "Balance updated successfully": "Balance updated successfully", "Balance updated: {{balance}}": "Balance updated: {{balance}}", + "Bar Chart": "Bar Chart", "Bark Push URL": "Bark Push URL", "Base address provided by your Epay service": "Base address provided by your Epay service", "Base amount. Actual deduction = base amount × system group rate.": "Base amount. Actual deduction = base amount × system group rate.", @@ -497,6 +499,8 @@ "Calculated price: ${{price}} per 1M tokens": "Calculated price: ${{price}} per 1M tokens", "Calculated ratio: {{ratio}}": "Calculated ratio: {{ratio}}", "Calculating...": "Calculating...", + "Call Count Distribution": "Call Count Distribution", + "Call Count Ranking": "Call Count Ranking", "Call Proportion": "Call Proportion", "Call Trend": "Call Trend", "Callback address": "Callback address", @@ -3236,6 +3240,7 @@ "this token group": "this token group", "this user group": "this user group", "This user has no bindings": "This user has no bindings", + "This week": "This week", "This will append 2 template rules (Codex CLI and Claude CLI) to the existing rule list.": "This will append 2 template rules (Codex CLI and Claude CLI) to the existing rule list.", "This will clear custom pricing ratios and revert to upstream defaults.": "This will clear custom pricing ratios and revert to upstream defaults.", "This will delete all": "This will delete all", diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json index e27b72e0..1a919f36 100644 --- a/web/default/src/i18n/locales/fr.json +++ b/web/default/src/i18n/locales/fr.json @@ -331,6 +331,7 @@ "Are you sure?": "Êtes-vous sûr ?", "Args (space separated)": "Arguments (séparés par des espaces)", "Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.": "Tableau de préréglages de clients de chat. Chaque élément est un objet avec une paire clé-valeur : nom du client et son URL.", + "Area Chart": "Graphique en aires", "Asc": "Asc", "Ask anything": "Demandez n'importe quoi", "Async task refund": "Remboursement de tâche asynchrone", @@ -407,6 +408,7 @@ "Balance queried successfully": "Solde interrogé avec succès", "Balance updated successfully": "Solde mis à jour avec succès", "Balance updated: {{balance}}": "Solde mis à jour : {{balance}}", + "Bar Chart": "Graphique en barres", "Bark Push URL": "URL de notification Bark", "Base address provided by your Epay service": "Adresse de base fournie par votre service Epay", "Base amount. Actual deduction = base amount × system group rate.": "Montant de base. Déduction réelle = montant de base × taux du groupe système.", @@ -497,6 +499,8 @@ "Calculated price: ${{price}} per 1M tokens": "Prix calculé : ${{price}} par 1M tokens", "Calculated ratio: {{ratio}}": "Ratio calculé : {{ratio}}", "Calculating...": "Calcul en cours...", + "Call Count Distribution": "Distribution du nombre d'appels", + "Call Count Ranking": "Classement du nombre d'appels", "Call Proportion": "Proportion d'appels", "Call Trend": "Tendance des appels", "Callback address": "Adresse de rappel", @@ -3236,6 +3240,7 @@ "this token group": "ce groupe de jetons", "this user group": "ce groupe d'utilisateurs", "This user has no bindings": "Cet utilisateur n'a aucune liaison", + "This week": "Cette semaine", "This will append 2 template rules (Codex CLI and Claude CLI) to the existing rule list.": "Cela ajoutera 2 règles modèles (Codex CLI et Claude CLI) à la liste de règles existante.", "This will clear custom pricing ratios and revert to upstream defaults.": "Cela effacera les ratios de tarification personnalisés et rétablira les valeurs par défaut du fournisseur.", "This will delete all": "Cela supprimera tout", diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index 56f1ae5f..600c7dab 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -331,6 +331,7 @@ "Are you sure?": "よろしいですか?", "Args (space separated)": "引数 (スペース区切り)", "Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.": "チャットクライアントプリセットの配列。各項目は、クライアント名とそのURLという1つのキーと値のペアを持つオブジェクトです。", + "Area Chart": "面グラフ", "Asc": "昇順", "Ask anything": "何でも質問する", "Async task refund": "非同期タスク返金", @@ -407,6 +408,7 @@ "Balance queried successfully": "残高の取得に成功しました", "Balance updated successfully": "残高が正常に更新されました", "Balance updated: {{balance}}": "残高更新:{{balance}}", + "Bar Chart": "棒グラフ", "Bark Push URL": "BarkプッシュURL", "Base address provided by your Epay service": "Epayサービスによって提供されるベースアドレス", "Base amount. Actual deduction = base amount × system group rate.": "基本金額。実際の控除 = 基本金額 × システムグループ倍率。", @@ -497,6 +499,8 @@ "Calculated price: ${{price}} per 1M tokens": "計算価格:${{price}} / 1M トークン", "Calculated ratio: {{ratio}}": "計算倍率:{{ratio}}", "Calculating...": "計算中...", + "Call Count Distribution": "呼び出し回数分布", + "Call Count Ranking": "呼び出し回数ランキング", "Call Proportion": "呼び出し比率", "Call Trend": "呼び出し傾向", "Callback address": "コールバックアドレス", @@ -3236,6 +3240,7 @@ "this token group": "このトークングループ", "this user group": "このユーザーグループ", "This user has no bindings": "このユーザーには連携がありません", + "This week": "今週", "This will append 2 template rules (Codex CLI and Claude CLI) to the existing rule list.": "既存のルールリストに2つのテンプレートルール(Codex CLIとClaude CLI)を追加します。", "This will clear custom pricing ratios and revert to upstream defaults.": "これにより、カスタム料金比率がクリアされ、上位のデフォルトに戻ります。", "This will delete all": "これによりすべて削除されます", diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index eef5e0a0..54de3867 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -331,6 +331,7 @@ "Are you sure?": "Вы уверены?", "Args (space separated)": "Аргументы (разделённые пробелами)", "Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.": "Массив предустановок чат-клиентов. Каждый элемент представляет собой объект с одной парой ключ-значение: имя клиента и его URL.", + "Area Chart": "Диаграмма с областями", "Asc": "По возрастанию", "Ask anything": "Спросите что угодно", "Async task refund": "Возврат асинхронной задачи", @@ -407,6 +408,7 @@ "Balance queried successfully": "Баланс успешно запрошен", "Balance updated successfully": "Баланс успешно обновлён", "Balance updated: {{balance}}": "Баланс обновлён: {{balance}}", + "Bar Chart": "Столбчатая диаграмма", "Bark Push URL": "URL для push-уведомлений Bark", "Base address provided by your Epay service": "Базовый адрес, предоставленный вашим сервисом Epay", "Base amount. Actual deduction = base amount × system group rate.": "Базовая сумма. Фактический вычет = базовая сумма × коэффициент группы.", @@ -497,6 +499,8 @@ "Calculated price: ${{price}} per 1M tokens": "Расчётная цена: ${{price}} за 1М токенов", "Calculated ratio: {{ratio}}": "Расчётный коэффициент: {{ratio}}", "Calculating...": "Вычисление...", + "Call Count Distribution": "Распределение количества вызовов", + "Call Count Ranking": "Рейтинг по количеству вызовов", "Call Proportion": "Доля вызовов", "Call Trend": "Тенденция вызовов", "Callback address": "Адрес обратного вызова", @@ -3236,6 +3240,7 @@ "this token group": "эта группа токенов", "this user group": "эта группа пользователей", "This user has no bindings": "У этого пользователя нет привязок", + "This week": "На этой неделе", "This will append 2 template rules (Codex CLI and Claude CLI) to the existing rule list.": "Это добавит 2 шаблонных правила (Codex CLI и Claude CLI) к существующему списку правил.", "This will clear custom pricing ratios and revert to upstream defaults.": "Это очистит пользовательские ценовые коэффициенты и вернет к стандартным значениям поставщика.", "This will delete all": "Это удалит все", diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index 4affe277..79248138 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -331,6 +331,7 @@ "Are you sure?": "Bạn có chắc không?", "Args (space separated)": "Đối số (cách nhau bằng khoảng trắng)", "Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.": "Mảng các thiết lập sẵn của ứng dụng trò chuyện. Mỗi mục là một đối tượng với", + "Area Chart": "Biểu đồ vùng", "Asc": "Asc", "Ask anything": "Hỏi gì cũng được", "Async task refund": "Hoàn tiền tác vụ bất đồng bộ", @@ -407,6 +408,7 @@ "Balance queried successfully": "Truy vấn số dư thành công", "Balance updated successfully": "Đã cập nhật số dư thành công", "Balance updated: {{balance}}": "Số dư đã cập nhật: {{balance}}", + "Bar Chart": "Biểu đồ cột", "Bark Push URL": "URL đẩy Bark", "Base address provided by your Epay service": "Địa chỉ cơ sở được cung cấp bởi dịch vụ Epay của bạn", "Base amount. Actual deduction = base amount × system group rate.": "Số tiền cơ sở. Số tiền trừ thực tế = số tiền cơ sở × tỷ lệ nhóm hệ thống.", @@ -497,6 +499,8 @@ "Calculated price: ${{price}} per 1M tokens": "Giá tính toán: ${{price}} mỗi 1M token", "Calculated ratio: {{ratio}}": "Tỷ lệ tính toán: {{ratio}}", "Calculating...": "Đang tính...", + "Call Count Distribution": "Phân bổ số lượt gọi", + "Call Count Ranking": "Xếp hạng số lượt gọi", "Call Proportion": "Tỷ lệ cuộc gọi", "Call Trend": "Xu hướng cuộc gọi", "Callback address": "Địa chỉ callback", @@ -3236,6 +3240,7 @@ "this token group": "nhóm token này", "this user group": "nhóm người dùng này", "This user has no bindings": "Người dùng này không có liên kết nào", + "This week": "Tuần này", "This will append 2 template rules (Codex CLI and Claude CLI) to the existing rule list.": "Thao tác này sẽ thêm 2 quy tắc mẫu (Codex CLI và Claude CLI) vào danh sách quy tắc hiện có.", "This will clear custom pricing ratios and revert to upstream defaults.": "Điều này sẽ xóa các tỷ lệ giá tùy chỉnh và trở về mặc định ban đầu.", "This will delete all": "Thao tác này sẽ xóa tất cả", diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index cef84341..56c74722 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -331,6 +331,7 @@ "Are you sure?": "您确定吗?", "Args (space separated)": "参数 (空格分隔)", "Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.": "聊天客户端预设数组。每个项目都是一个对象,包含一个键值对:客户端名称及其 URL。", + "Area Chart": "面积图", "Asc": "升序", "Ask anything": "随便问", "Async task refund": "异步任务退款", @@ -407,6 +408,7 @@ "Balance queried successfully": "余额查询成功", "Balance updated successfully": "余额更新成功", "Balance updated: {{balance}}": "余额已更新:{{balance}}", + "Bar Chart": "柱状图", "Bark Push URL": "Bark 推送 URL", "Base address provided by your Epay service": "您的 Epay 服务提供的基础地址", "Base amount. Actual deduction = base amount × system group rate.": "基础金额,实际扣费 = 基础金额 × 系统分组倍率。", @@ -497,6 +499,8 @@ "Calculated price: ${{price}} per 1M tokens": "计算价格:${{price}} / 1M tokens", "Calculated ratio: {{ratio}}": "计算倍率:{{ratio}}", "Calculating...": "计算中...", + "Call Count Distribution": "调用次数分布", + "Call Count Ranking": "调用次数排行", "Call Proportion": "调用比例", "Call Trend": "调用趋势", "Callback address": "回调地址", @@ -3236,6 +3240,7 @@ "this token group": "此令牌分组", "this user group": "此用户分组", "This user has no bindings": "该用户无任何绑定", + "This week": "本周", "This will append 2 template rules (Codex CLI and Claude CLI) to the existing rule list.": "这将在现有规则列表中追加 2 条模板规则(Codex CLI 和 Claude CLI)。", "This will clear custom pricing ratios and revert to upstream defaults.": "这将清除自定义定价比例并恢复到上游默认值。", "This will delete all": "这将删除所有", diff --git a/web/default/src/lib/api.ts b/web/default/src/lib/api.ts index 5a73031e..3abb9944 100644 --- a/web/default/src/lib/api.ts +++ b/web/default/src/lib/api.ts @@ -175,7 +175,7 @@ export async function getUserModels(): Promise<{ export async function getUserGroups(): Promise<{ success: boolean message?: string - data?: Record + data?: Record }> { const res = await api.get('/api/user/self/groups') return res.data diff --git a/web/default/src/lib/colors.ts b/web/default/src/lib/colors.ts index 78efb9a8..9be26f0f 100644 --- a/web/default/src/lib/colors.ts +++ b/web/default/src/lib/colors.ts @@ -35,6 +35,29 @@ export const colorToBgClass: Record = { grey: 'bg-gray-500', } +export const avatarColorMap: Record = { + blue: 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-400', + green: 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-400', + cyan: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-500/20 dark:text-cyan-400', + purple: 'bg-purple-100 text-purple-700 dark:bg-purple-500/20 dark:text-purple-400', + pink: 'bg-pink-100 text-pink-700 dark:bg-pink-500/20 dark:text-pink-400', + red: 'bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-400', + orange: 'bg-orange-100 text-orange-700 dark:bg-orange-500/20 dark:text-orange-400', + amber: 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400', + yellow: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-500/20 dark:text-yellow-400', + lime: 'bg-lime-100 text-lime-700 dark:bg-lime-500/20 dark:text-lime-400', + 'light-green': 'bg-green-50 text-green-600 dark:bg-green-400/20 dark:text-green-300', + teal: 'bg-teal-100 text-teal-700 dark:bg-teal-500/20 dark:text-teal-400', + 'light-blue': 'bg-sky-100 text-sky-700 dark:bg-sky-500/20 dark:text-sky-400', + indigo: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-400', + violet: 'bg-violet-100 text-violet-700 dark:bg-violet-500/20 dark:text-violet-400', + grey: 'bg-gray-100 text-gray-700 dark:bg-gray-500/20 dark:text-gray-400', +} + +export function getAvatarColorClass(name: string): string { + return avatarColorMap[stringToColor(name)] +} + export function getBgColorClass(color?: string): string { if (!color) return colorToBgClass.blue return ( diff --git a/web/default/src/lib/currency.ts b/web/default/src/lib/currency.ts index edca4c76..8f0a1a73 100644 --- a/web/default/src/lib/currency.ts +++ b/web/default/src/lib/currency.ts @@ -262,6 +262,7 @@ function formatCurrencyValue( const formatted = new Intl.NumberFormat(undefined, { style: 'currency', currency: meta.currencyCode, + currencyDisplay: 'narrowSymbol', minimumFractionDigits: 0, maximumFractionDigits: digits, }).format(adjustedValue) @@ -337,11 +338,11 @@ export function formatCurrencyFromUSD( const { config, meta } = getCurrencyDisplay() const merged = mergeOptions(options) - if (!config.displayInCurrency || meta.kind === 'tokens') { + if (meta.kind === 'tokens') { const tokens = amountUSD * config.quotaPerUnit return formatNumberWithSuffix( tokens, - meta.kind === 'tokens' ? 0 : merged.digitsLarge, + 0, merged.digitsSmall, merged.abbreviate ) @@ -463,7 +464,7 @@ export function formatQuotaWithCurrency( export function getCurrencyLabel(): string { const { config, meta } = getCurrencyDisplay() - if (!config.displayInCurrency || meta.kind === 'tokens') { + if (meta.kind === 'tokens') { return 'Tokens' } @@ -494,8 +495,8 @@ export function getCurrencyLabel(): string { * Use this to conditionally show currency-specific UI elements */ export function isCurrencyDisplayEnabled(): boolean { - const { config, meta } = getCurrencyDisplay() - return config.displayInCurrency && meta.kind !== 'tokens' + const { meta } = getCurrencyDisplay() + return meta.kind !== 'tokens' } /** diff --git a/web/default/src/lib/format.ts b/web/default/src/lib/format.ts index 37111eb0..ad7d2e53 100644 --- a/web/default/src/lib/format.ts +++ b/web/default/src/lib/format.ts @@ -61,7 +61,7 @@ export function parseQuotaFromDollars(amount: number): number { const { config, meta } = getCurrencyDisplay() // Tokens-only or raw quota mode - if (!config.displayInCurrency || meta.kind === 'tokens') { + if (meta.kind === 'tokens') { return Math.round(amount) } @@ -80,7 +80,7 @@ export function parseQuotaFromDollars(amount: number): number { export function quotaUnitsToDollars(units: number): number { const { config, meta } = getCurrencyDisplay() - if (!config.displayInCurrency || meta.kind === 'tokens') { + if (meta.kind === 'tokens') { return units }