feat: enhance UI and functionality in various components
This commit is contained in:
+120
@@ -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<DistributionChartType>('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 (
|
||||
<div className='overflow-hidden rounded-lg border'>
|
||||
<div className='flex w-full flex-col gap-3 border-b px-4 py-3 sm:px-5 lg:flex-row lg:items-center lg:justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<WalletCards className='text-muted-foreground/60 size-4' />
|
||||
<div className='text-sm font-semibold'>
|
||||
{t('Quota Distribution')}
|
||||
</div>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{t('Total:')} {chartData.totalQuotaDisplay}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='bg-muted/60 inline-flex h-8 rounded-md border p-0.5'>
|
||||
{CHART_TYPES.map((item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<button
|
||||
key={item.value}
|
||||
type='button'
|
||||
onClick={() => setChartType(item.value)}
|
||||
className={`inline-flex items-center gap-1.5 rounded-[5px] px-3 text-xs font-medium transition-colors ${
|
||||
chartType === item.value
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<Icon className='size-3.5' />
|
||||
{t(item.labelKey)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='h-96 p-2'>
|
||||
{themeReady && spec && (
|
||||
<VChart
|
||||
key={`${chartType}-${resolvedTheme}`}
|
||||
spec={{
|
||||
...spec,
|
||||
theme: resolvedTheme === 'dark' ? 'dark' : 'light',
|
||||
background: 'transparent',
|
||||
}}
|
||||
option={VCHART_OPTION}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ChartTab>('1')
|
||||
const [activeTab, setActiveTab] = useState<ChartTab>('trend')
|
||||
const [themeReady, setThemeReady] = useState(false)
|
||||
const themeManagerRef = useRef<
|
||||
(typeof import('@visactor/vchart'))['ThemeManager'] | null
|
||||
|
||||
@@ -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<UserChartTab>('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 (
|
||||
<div className='space-y-4'>
|
||||
{/* Toolbar: time range presets + granularity */}
|
||||
@@ -169,50 +163,41 @@ export function UserCharts() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chart card */}
|
||||
<div className='overflow-hidden rounded-lg border'>
|
||||
<div className='flex w-full flex-col gap-3 border-b px-4 py-3 sm:px-5 lg:flex-row lg:items-center lg:justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Users className='text-muted-foreground/60 size-4' />
|
||||
<div className='text-sm font-semibold'>{t('User Analytics')}</div>
|
||||
</div>
|
||||
<div className='grid gap-4'>
|
||||
{USER_CHARTS.map((chart) => {
|
||||
const spec = chartData[chart.specKey]
|
||||
|
||||
<div className='bg-muted/60 inline-flex h-8 rounded-md border p-0.5'>
|
||||
{CHART_TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.value}
|
||||
type='button'
|
||||
onClick={() => setActiveTab(tab.value)}
|
||||
className={`rounded-[5px] px-3 text-xs font-medium transition-colors ${
|
||||
activeTab === tab.value
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{t(tab.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<div
|
||||
key={chart.value}
|
||||
className='overflow-hidden rounded-lg border'
|
||||
>
|
||||
<div className='flex w-full items-center gap-2 border-b px-4 py-3 sm:px-5'>
|
||||
<Users className='text-muted-foreground/60 size-4' />
|
||||
<div className='text-sm font-semibold'>{t(chart.labelKey)}</div>
|
||||
</div>
|
||||
|
||||
<div className='h-96 p-2'>
|
||||
{isLoading ? (
|
||||
<Skeleton className='h-full w-full' />
|
||||
) : (
|
||||
themeReady &&
|
||||
spec && (
|
||||
<VChart
|
||||
key={`user-${activeTab}-${resolvedTheme}`}
|
||||
spec={{
|
||||
...spec,
|
||||
theme: resolvedTheme === 'dark' ? 'dark' : 'light',
|
||||
background: 'transparent',
|
||||
}}
|
||||
option={VCHART_OPTION}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<div className='h-96 p-2'>
|
||||
{isLoading ? (
|
||||
<Skeleton className='h-full w-full' />
|
||||
) : (
|
||||
themeReady &&
|
||||
spec && (
|
||||
<VChart
|
||||
key={`user-${chart.value}-${resolvedTheme}`}
|
||||
spec={{
|
||||
...spec,
|
||||
theme: resolvedTheme === 'dark' ? 'dark' : 'light',
|
||||
background: 'transparent',
|
||||
}}
|
||||
option={VCHART_OPTION}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user