Remove hard-coded and capped border radius overrides so shared controls and feature actions consistently follow the active theme radius. - Replace fixed radius utilities with semantic theme-aware radius tokens - Remove redundant `rounded-full` and pixel-based overrides from header, toolbar, Playground, and utility actions - Drop unused `StatusBadge` rounded prop usage - Keep existing component behavior intact while improving global theme consistency
239 lines
7.5 KiB
TypeScript
Vendored
239 lines
7.5 KiB
TypeScript
Vendored
import { useEffect, useMemo, useState, useRef, useCallback } from 'react'
|
|
import { useQuery } from '@tanstack/react-query'
|
|
import { VChart } from '@visactor/react-vchart'
|
|
import { Users, Loader2 } from 'lucide-react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { getRollingDateRange, type TimeGranularity } from '@/lib/time'
|
|
import { VCHART_OPTION } from '@/lib/vchart'
|
|
import { useThemeCustomization } from '@/context/theme-customization-provider'
|
|
import { useTheme } from '@/context/theme-provider'
|
|
import { Skeleton } from '@/components/ui/skeleton'
|
|
import { getUserQuotaDataByUsers } from '@/features/dashboard/api'
|
|
import {
|
|
TIME_GRANULARITY_OPTIONS,
|
|
TIME_RANGE_PRESETS,
|
|
} from '@/features/dashboard/constants'
|
|
import {
|
|
getDefaultDays,
|
|
getSavedGranularity,
|
|
saveGranularity,
|
|
processUserChartData,
|
|
} from '@/features/dashboard/lib'
|
|
import type { ProcessedUserChartData } from '@/features/dashboard/types'
|
|
|
|
let themeManagerPromise: Promise<
|
|
(typeof import('@visactor/vchart'))['ThemeManager']
|
|
> | null = null
|
|
|
|
const USER_CHARTS: {
|
|
value: string
|
|
labelKey: string
|
|
specKey: keyof ProcessedUserChartData
|
|
}[] = [
|
|
{
|
|
value: 'rank',
|
|
labelKey: 'User Consumption Ranking',
|
|
specKey: 'spec_user_rank',
|
|
},
|
|
{
|
|
value: 'trend',
|
|
labelKey: 'User Consumption Trend',
|
|
specKey: 'spec_user_trend',
|
|
},
|
|
]
|
|
|
|
const TOP_USER_LIMIT_OPTIONS = [5, 10, 20, 50]
|
|
|
|
export function UserCharts() {
|
|
const { t } = useTranslation()
|
|
const { resolvedTheme } = useTheme()
|
|
const { customization } = useThemeCustomization()
|
|
const [themeReady, setThemeReady] = useState(false)
|
|
const themeManagerRef = useRef<
|
|
(typeof import('@visactor/vchart'))['ThemeManager'] | null
|
|
>(null)
|
|
|
|
const [timeGranularity, setTimeGranularity] = useState<TimeGranularity>(() =>
|
|
getSavedGranularity()
|
|
)
|
|
const [selectedRange, setSelectedRange] = useState<number>(() =>
|
|
getDefaultDays(timeGranularity)
|
|
)
|
|
const [topUserLimit, setTopUserLimit] = useState(10)
|
|
const [timeRange, setTimeRange] = useState(() => {
|
|
const days = getDefaultDays(timeGranularity)
|
|
const { start, end } = getRollingDateRange(days)
|
|
return {
|
|
start_timestamp: Math.floor(start.getTime() / 1000),
|
|
end_timestamp: Math.floor(end.getTime() / 1000),
|
|
}
|
|
})
|
|
|
|
const handleRangeChange = useCallback((days: number) => {
|
|
setSelectedRange(days)
|
|
const { start, end } = getRollingDateRange(days)
|
|
setTimeRange({
|
|
start_timestamp: Math.floor(start.getTime() / 1000),
|
|
end_timestamp: Math.floor(end.getTime() / 1000),
|
|
})
|
|
}, [])
|
|
|
|
const handleGranularityChange = useCallback(
|
|
(g: TimeGranularity) => {
|
|
setTimeGranularity(g)
|
|
saveGranularity(g)
|
|
const days = getDefaultDays(g)
|
|
if (days !== selectedRange) {
|
|
handleRangeChange(days)
|
|
}
|
|
},
|
|
[selectedRange, handleRangeChange]
|
|
)
|
|
|
|
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 { data: userData, isLoading } = useQuery({
|
|
queryKey: ['dashboard', 'user-quota', timeRange],
|
|
queryFn: () => getUserQuotaDataByUsers(timeRange),
|
|
select: (res) => (res.success ? res.data : []),
|
|
staleTime: 60_000,
|
|
})
|
|
|
|
const chartData = useMemo(
|
|
() =>
|
|
processUserChartData(
|
|
isLoading ? [] : (userData ?? []),
|
|
timeGranularity,
|
|
t,
|
|
topUserLimit,
|
|
customization.preset
|
|
),
|
|
[
|
|
userData,
|
|
isLoading,
|
|
timeGranularity,
|
|
t,
|
|
topUserLimit,
|
|
customization.preset,
|
|
customization.radius,
|
|
]
|
|
)
|
|
|
|
return (
|
|
<div className='space-y-3'>
|
|
<div className='flex items-center gap-1.5 overflow-x-auto pb-1 sm:gap-2'>
|
|
<div className='flex shrink-0 items-center gap-1.5 rounded-lg border p-0.5'>
|
|
{TIME_RANGE_PRESETS.map((preset) => (
|
|
<button
|
|
key={preset.days}
|
|
type='button'
|
|
onClick={() => handleRangeChange(preset.days)}
|
|
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
|
|
selectedRange === preset.days
|
|
? 'bg-primary text-primary-foreground shadow-sm'
|
|
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
|
}`}
|
|
>
|
|
{t(preset.label)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className='flex shrink-0 items-center gap-1.5 rounded-lg border p-0.5'>
|
|
{TIME_GRANULARITY_OPTIONS.map((opt) => (
|
|
<button
|
|
key={opt.value}
|
|
type='button'
|
|
onClick={() =>
|
|
handleGranularityChange(opt.value as TimeGranularity)
|
|
}
|
|
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
|
|
timeGranularity === opt.value
|
|
? 'bg-primary text-primary-foreground shadow-sm'
|
|
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
|
}`}
|
|
>
|
|
{t(opt.label)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className='flex shrink-0 items-center gap-1.5 rounded-lg border p-0.5'>
|
|
<span className='text-muted-foreground px-2 text-xs font-medium'>
|
|
{t('Top Users')}
|
|
</span>
|
|
{TOP_USER_LIMIT_OPTIONS.map((limit) => (
|
|
<button
|
|
key={limit}
|
|
type='button'
|
|
onClick={() => setTopUserLimit(limit)}
|
|
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
|
|
topUserLimit === limit
|
|
? 'bg-primary text-primary-foreground shadow-sm'
|
|
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
|
}`}
|
|
>
|
|
{t('Top {{count}}', { count: limit })}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{isLoading && (
|
|
<Loader2 className='text-muted-foreground size-4 animate-spin' />
|
|
)}
|
|
</div>
|
|
|
|
<div className='grid gap-3'>
|
|
{USER_CHARTS.map((chart) => {
|
|
const spec = chartData[chart.specKey]
|
|
|
|
return (
|
|
<div
|
|
key={chart.value}
|
|
className='overflow-hidden rounded-lg border'
|
|
>
|
|
<div className='flex w-full items-center gap-2 border-b px-3 py-2 sm:px-5 sm:py-3'>
|
|
<Users className='text-muted-foreground/60 size-4' />
|
|
<div className='text-sm font-semibold'>{t(chart.labelKey)}</div>
|
|
</div>
|
|
|
|
<div className='h-[300px] p-1.5 sm:h-96 sm:p-2'>
|
|
{isLoading ? (
|
|
<Skeleton className='h-full w-full' />
|
|
) : (
|
|
themeReady &&
|
|
spec && (
|
|
<VChart
|
|
key={`user-${chart.value}-${topUserLimit}-${resolvedTheme}-${customization.preset}`}
|
|
spec={{
|
|
...spec,
|
|
theme: resolvedTheme === 'dark' ? 'dark' : 'light',
|
|
background: 'transparent',
|
|
}}
|
|
option={VCHART_OPTION}
|
|
/>
|
|
)
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|