🌍 feat: implement complete i18n for Dashboard module

Migrate Dashboard module from hardcoded text to full internationalization
support with Chinese and English translations.

## Changes Made

### Dashboard Components i18n Implementation
- **Dashboard main page** (`index.tsx`): Added i18n for page title, welcome message,
  search button, tab labels, and toast notifications
- **StatsCards** (`stats-cards.tsx`): Implemented i18n for 4 grouped cards with
  8 data metrics (account data, usage stats, resource consumption, performance metrics)
- **Overview** (`overview.tsx`): Added i18n for chart titles, tooltips, and error states
- **ModelUsageChart** (`model-usage-chart.tsx`): Implemented i18n for chart content
  and status messages
- **ModelMonitoringStats** (`model-monitoring-stats.tsx`): Added i18n for monitoring
  statistics cards and descriptions
- **ModelMonitoringTable** (`model-monitoring-table.tsx`): Implemented i18n for table
  headers, pagination, search functionality, and action menus

### Language Files Updates
- **Chinese** (`zh.json`): Added comprehensive Dashboard translations with proper
  key structure and interpolation support
- **English** (`en.json`): Added complete English translations with consistent
  terminology and interpolation variables

### Key Structure Improvements
- Organized i18n keys in logical hierarchy: `dashboard.stats.*`, `dashboard.overview.*`,
  `dashboard.model_usage.*`, `dashboard.monitoring.*`, `dashboard.search.*`
- Added common UI elements to `common.*` namespace for reusability
- Support for interpolation variables (e.g., `{{name}}`, `{{count}}`, `{{percentage}}`)

### Bug Fixes
- **Fixed duplicate JSON keys**: Resolved conflicts between `dashboard.search` (string)
  and `dashboard.search` (object) by renaming to `dashboard.search_button`
- **Fixed duplicate overview keys**: Resolved conflicts between `dashboard.overview`
  (string) and `dashboard.overview` (object) by renaming to `dashboard.overview_tab`
- Updated component references to use corrected i18n keys

### Technical Features
- Full React i18next integration with `useTranslation` hook
- Maintains accessibility standards and semantic HTML structure
- Consistent error handling and loading states across all components
- Support for plural forms and complex interpolation scenarios

## Breaking Changes
None - All changes are additive and maintain backward compatibility.

## Testing
-  JSON validation for both language files
-  No linter errors in Dashboard components
-  No duplicate keys in translation files
-  All i18n keys properly referenced in components

Closes: Dashboard i18n migration task
This commit is contained in:
t0ng7u
2025-09-26 13:10:50 +08:00
parent 26a18346b2
commit 2e994abdd9
16 changed files with 760 additions and 647 deletions

View File

@@ -4,6 +4,7 @@ import { format } from 'date-fns'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { CalendarIcon, Search, RotateCcw } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { getStoredUser } from '@/lib/auth'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
@@ -38,19 +39,6 @@ import {
} from '@/components/ui/select'
import type { DashboardFilters } from '../hooks/use-dashboard-data'
const searchSchema = z
.object({
startDate: z.date(),
endDate: z.date(),
username: z.string().optional(),
timeGranularity: z.enum(['hour', 'day', 'week']),
modelFilter: z.string().optional(),
})
.refine((data) => data.endDate >= data.startDate, {
message: 'End date must be after start date',
path: ['endDate'],
})
interface DashboardSearchDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
@@ -64,10 +52,24 @@ export function DashboardSearchDialog({
onSearch,
currentFilters,
}: DashboardSearchDialogProps) {
const { t } = useTranslation()
const [loading, setLoading] = useState(false)
const user = getStoredUser()
const isAdmin = user && (user as any).role >= 10
const searchSchema = z
.object({
startDate: z.date(),
endDate: z.date(),
username: z.string().optional(),
timeGranularity: z.enum(['hour', 'day', 'week']),
modelFilter: z.string().optional(),
})
.refine((data) => data.endDate >= data.startDate, {
message: t('dashboard.search.end_date_after_start'),
path: ['endDate'],
})
const form = useForm<z.infer<typeof searchSchema>>({
resolver: zodResolver(searchSchema),
defaultValues: {
@@ -123,9 +125,9 @@ export function DashboardSearchDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='max-w-2xl'>
<DialogHeader>
<DialogTitle>Advanced Dashboard Search</DialogTitle>
<DialogTitle>{t('dashboard.search.title')}</DialogTitle>
<DialogDescription>
Search and filter your usage data with advanced criteria
{t('dashboard.search.description')}
</DialogDescription>
</DialogHeader>
@@ -142,7 +144,7 @@ export function DashboardSearchDialog({
size='sm'
onClick={() => handleQuickTimeRange(1)}
>
Last 24h
{t('dashboard.search.last_24h')}
</Button>
<Button
type='button'
@@ -150,7 +152,7 @@ export function DashboardSearchDialog({
size='sm'
onClick={() => handleQuickTimeRange(7)}
>
Last 7 days
{t('dashboard.search.last_7_days')}
</Button>
<Button
type='button'
@@ -158,7 +160,7 @@ export function DashboardSearchDialog({
size='sm'
onClick={() => handleQuickTimeRange(30)}
>
Last 30 days
{t('dashboard.search.last_30_days')}
</Button>
<Button
type='button'
@@ -166,7 +168,7 @@ export function DashboardSearchDialog({
size='sm'
onClick={() => handleQuickTimeRange(90)}
>
Last 90 days
{t('dashboard.search.last_90_days')}
</Button>
</div>
@@ -177,7 +179,7 @@ export function DashboardSearchDialog({
name='startDate'
render={({ field }) => (
<FormItem>
<FormLabel>Start Date</FormLabel>
<FormLabel>{t('dashboard.search.start_date')}</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
@@ -191,7 +193,7 @@ export function DashboardSearchDialog({
{field.value ? (
format(field.value, 'PPP')
) : (
<span>Pick a date</span>
<span>{t('dashboard.search.pick_date')}</span>
)}
<CalendarIcon className='ml-auto h-4 w-4 opacity-50' />
</Button>
@@ -219,7 +221,7 @@ export function DashboardSearchDialog({
name='endDate'
render={({ field }) => (
<FormItem>
<FormLabel>End Date</FormLabel>
<FormLabel>{t('dashboard.search.end_date')}</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
@@ -233,7 +235,7 @@ export function DashboardSearchDialog({
{field.value ? (
format(field.value, 'PPP')
) : (
<span>Pick a date</span>
<span>{t('dashboard.search.pick_date')}</span>
)}
<CalendarIcon className='ml-auto h-4 w-4 opacity-50' />
</Button>
@@ -263,20 +265,32 @@ export function DashboardSearchDialog({
name='timeGranularity'
render={({ field }) => (
<FormItem>
<FormLabel>Time Granularity</FormLabel>
<FormLabel>
{t('dashboard.search.time_granularity')}
</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder='Select time granularity' />
<SelectValue
placeholder={t(
'dashboard.search.select_time_granularity'
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value='hour'>Hourly</SelectItem>
<SelectItem value='day'>Daily</SelectItem>
<SelectItem value='week'>Weekly</SelectItem>
<SelectItem value='hour'>
{t('dashboard.search.hourly')}
</SelectItem>
<SelectItem value='day'>
{t('dashboard.search.daily')}
</SelectItem>
<SelectItem value='week'>
{t('dashboard.search.weekly')}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
@@ -291,10 +305,12 @@ export function DashboardSearchDialog({
name='username'
render={({ field }) => (
<FormItem>
<FormLabel>Filter by Username (Admin)</FormLabel>
<FormLabel>
{t('dashboard.search.filter_by_username')}
</FormLabel>
<FormControl>
<Input
placeholder='Enter username to filter (optional)'
placeholder={t('dashboard.search.username_placeholder')}
{...field}
/>
</FormControl>
@@ -310,10 +326,12 @@ export function DashboardSearchDialog({
name='modelFilter'
render={({ field }) => (
<FormItem>
<FormLabel>Model Filter</FormLabel>
<FormLabel>{t('dashboard.search.model_filter')}</FormLabel>
<FormControl>
<Input
placeholder='Filter by model name (optional)'
placeholder={t(
'dashboard.search.model_filter_placeholder'
)}
{...field}
/>
</FormControl>
@@ -331,22 +349,14 @@ export function DashboardSearchDialog({
disabled={loading}
>
<RotateCcw className='mr-2 h-4 w-4' />
Reset
{t('dashboard.search.reset')}
</Button>
<Button type='submit' disabled={loading}>
<Search className='mr-2 h-4 w-4' />
{loading
? t('dashboard.search.searching')
: t('dashboard.search.search')}
</Button>
<div className='space-x-2'>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
disabled={loading}
>
Cancel
</Button>
<Button type='submit' disabled={loading}>
<Search className='mr-2 h-4 w-4' />
{loading ? 'Searching...' : 'Search'}
</Button>
</div>
</div>
</form>
</Form>

View File

@@ -1,5 +1,6 @@
import type { ModelMonitoringStats } from '@/types/api'
import { Activity, BarChart3, CheckCircle, Zap } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { formatNumber } from '@/lib/formatters'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
@@ -15,32 +16,38 @@ export function ModelMonitoringStats({
loading,
error,
}: ModelMonitoringStatsProps) {
const { t } = useTranslation()
const cards = [
{
title: '模型总数',
title: t('dashboard.monitoring.total_models'),
value: stats.total_models.toString(),
description: '系统中的模型总数量',
description: t('dashboard.monitoring.total_models_desc'),
icon: <Zap className='text-muted-foreground h-4 w-4' />,
trend: null,
},
{
title: '活跃模型',
title: t('dashboard.monitoring.active_models'),
value: stats.active_models.toString(),
description: `${stats.total_models > 0 ? ((stats.active_models / stats.total_models) * 100).toFixed(1) : 0}% 的模型有调用`,
description: t('dashboard.monitoring.active_models_desc', {
percentage:
stats.total_models > 0
? ((stats.active_models / stats.total_models) * 100).toFixed(1)
: 0,
}),
icon: <Activity className='text-muted-foreground h-4 w-4' />,
trend: null,
},
{
title: '调用总次数',
title: t('dashboard.monitoring.total_requests'),
value: formatNumber(stats.total_requests),
description: '所有模型的调用总数',
description: t('dashboard.monitoring.total_requests_desc'),
icon: <BarChart3 className='text-muted-foreground h-4 w-4' />,
trend: null,
},
{
title: '平均成功率',
title: t('dashboard.monitoring.avg_success_rate'),
value: `${stats.avg_success_rate.toFixed(1)}%`,
description: '所有模型的平均成功率',
description: t('dashboard.monitoring.avg_success_rate_desc'),
icon: <CheckCircle className='text-muted-foreground h-4 w-4' />,
trend: null,
},
@@ -77,7 +84,9 @@ export function ModelMonitoringStats({
{card.icon}
</CardHeader>
<CardContent>
<div className='text-2xl font-bold text-red-500'>Error</div>
<div className='text-2xl font-bold text-red-500'>
{t('common.error')}
</div>
<p className='text-muted-foreground text-xs'>{error}</p>
</CardContent>
</Card>

View File

@@ -9,6 +9,7 @@ import {
AlertCircle,
CheckCircle,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { stringToColor } from '@/lib/colors'
import { formatQuota, formatNumber, formatTokens } from '@/lib/formatters'
import { Badge } from '@/components/ui/badge'
@@ -61,6 +62,7 @@ export function ModelMonitoringTable({
onBusinessGroupChange,
onRefresh,
}: ModelMonitoringTableProps) {
const { t } = useTranslation()
const [currentPage, setCurrentPage] = useState(1)
// 分页逻辑
@@ -94,7 +96,7 @@ export function ModelMonitoringTable({
return (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardTitle>{t('dashboard.monitoring.model_list')}</CardTitle>
</CardHeader>
<CardContent>
<div className='space-y-4'>
@@ -120,16 +122,18 @@ export function ModelMonitoringTable({
return (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardTitle>{t('dashboard.monitoring.model_list')}</CardTitle>
</CardHeader>
<CardContent>
<div className='py-8 text-center'>
<AlertCircle className='mx-auto mb-4 h-12 w-12 text-red-500' />
<p className='text-lg font-medium'></p>
<p className='text-lg font-medium'>
{t('dashboard.monitoring.load_failed')}
</p>
<p className='text-muted-foreground mt-2'>{error}</p>
<Button onClick={onRefresh} className='mt-4'>
<RefreshCcw className='mr-2 h-4 w-4' />
{t('common.retry')}
</Button>
</div>
</CardContent>
@@ -141,12 +145,12 @@ export function ModelMonitoringTable({
<Card>
<CardHeader>
<div className='flex items-center justify-between'>
<CardTitle></CardTitle>
<CardTitle>{t('dashboard.monitoring.model_list')}</CardTitle>
<div className='flex items-center space-x-2'>
<div className='flex items-center space-x-2'>
<Search className='text-muted-foreground h-4 w-4' />
<Input
placeholder='搜索模型Code...'
placeholder={t('dashboard.monitoring.search_model_placeholder')}
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
className='w-64'
@@ -154,10 +158,14 @@ export function ModelMonitoringTable({
</div>
<Select value={businessGroup} onValueChange={onBusinessGroupChange}>
<SelectTrigger className='w-40'>
<SelectValue placeholder='业务空间' />
<SelectValue
placeholder={t('dashboard.monitoring.business_space')}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value='all'></SelectItem>
<SelectItem value='all'>
{t('dashboard.monitoring.all_spaces')}
</SelectItem>
{businessGroups.map((group) => (
<SelectItem key={group} value={group}>
{group}
@@ -174,7 +182,7 @@ export function ModelMonitoringTable({
<CardContent>
{models.length === 0 ? (
<div className='text-muted-foreground py-8 text-center'>
<p></p>
<p>{t('dashboard.monitoring.no_matching_models')}</p>
</div>
) : (
<>
@@ -183,14 +191,30 @@ export function ModelMonitoringTable({
<Table>
<TableHeader>
<TableRow>
<TableHead>Code</TableHead>
<TableHead></TableHead>
<TableHead className='text-right'></TableHead>
<TableHead className='text-right'></TableHead>
<TableHead className='text-right'></TableHead>
<TableHead className='text-right'></TableHead>
<TableHead className='text-right'>Token</TableHead>
<TableHead className='text-right'></TableHead>
<TableHead>
{t('dashboard.monitoring.model_code')}
</TableHead>
<TableHead>
{t('dashboard.monitoring.business_space')}
</TableHead>
<TableHead className='text-right'>
{t('dashboard.monitoring.total_calls')}
</TableHead>
<TableHead className='text-right'>
{t('dashboard.monitoring.failed_calls')}
</TableHead>
<TableHead className='text-right'>
{t('dashboard.monitoring.failure_rate')}
</TableHead>
<TableHead className='text-right'>
{t('dashboard.monitoring.avg_cost')}
</TableHead>
<TableHead className='text-right'>
{t('dashboard.monitoring.avg_tokens')}
</TableHead>
<TableHead className='text-right'>
{t('dashboard.monitoring.actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -250,9 +274,15 @@ export function ModelMonitoringTable({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem></DropdownMenuItem>
<DropdownMenuItem></DropdownMenuItem>
<DropdownMenuItem></DropdownMenuItem>
<DropdownMenuItem>
{t('dashboard.monitoring.monitor')}
</DropdownMenuItem>
<DropdownMenuItem>
{t('dashboard.monitoring.details')}
</DropdownMenuItem>
<DropdownMenuItem>
{t('dashboard.monitoring.settings')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
@@ -266,8 +296,11 @@ export function ModelMonitoringTable({
{totalPages > 1 && (
<div className='mt-4 flex items-center justify-between'>
<div className='text-muted-foreground text-sm'>
{startIndex + 1} - {' '}
{Math.min(endIndex, models.length)} {models.length}
{t('dashboard.monitoring.pagination_info', {
start: startIndex + 1,
end: Math.min(endIndex, models.length),
total: models.length,
})}
</div>
<div className='flex items-center space-x-2'>
<Button
@@ -277,7 +310,7 @@ export function ModelMonitoringTable({
disabled={currentPage === 1}
>
<ChevronLeft className='h-4 w-4' />
{t('common.previous')}
</Button>
<div className='flex items-center space-x-1'>
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
@@ -313,7 +346,7 @@ export function ModelMonitoringTable({
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages}
>
{t('common.next')}
<ChevronRight className='h-4 w-4' />
</Button>
</div>

View File

@@ -1,5 +1,6 @@
import { useMemo } from 'react'
import type { ModelUsageData } from '@/types/api'
import { useTranslation } from 'react-i18next'
import {
PieChart,
Pie,
@@ -9,6 +10,7 @@ import {
Legend,
} from 'recharts'
import { modelToColor } from '@/lib/colors'
import { formatValue } from '@/lib/formatters'
import { Badge } from '@/components/ui/badge'
import {
Card,
@@ -37,33 +39,18 @@ interface ChartDataPoint {
color: string
}
const formatValue = (
value: number,
type: 'quota' | 'tokens' | 'count'
): string => {
switch (type) {
case 'quota':
if (value >= 1000000) return `$${(value / 1000000).toFixed(1)}M`
if (value >= 1000) return `$${(value / 1000).toFixed(1)}K`
return `$${value.toFixed(2)}`
case 'tokens':
if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`
if (value >= 1000) return `${(value / 1000).toFixed(1)}K`
return value.toString()
case 'count':
return value.toString()
default:
return value.toString()
}
}
export function ModelUsageChart({
data = [],
loading = false,
error = null,
title = 'Model Usage Distribution',
description = 'Quota usage by model',
title,
description,
}: ModelUsageChartProps) {
const { t } = useTranslation()
const defaultTitle = title || t('dashboard.model_usage.title')
const defaultDescription =
description || t('dashboard.model_usage.description')
const chartData = useMemo((): ChartDataPoint[] => {
if (!data || data.length === 0) return []
@@ -89,14 +76,16 @@ export function ModelUsageChart({
<p className='mb-2 text-sm font-medium'>{data.name}</p>
<div className='space-y-1 text-xs'>
<p className='text-primary'>
Quota: {formatValue(data.quota, 'quota')} (
{data.percentage.toFixed(1)}%)
{t('dashboard.model_usage.quota')}:{' '}
{formatValue(data.quota, 'quota')} ({data.percentage.toFixed(1)}%)
</p>
<p className='text-blue-600'>
Tokens: {formatValue(data.tokens, 'tokens')}
{t('dashboard.model_usage.tokens')}:{' '}
{formatValue(data.tokens, 'tokens')}
</p>
<p className='text-green-600'>
Requests: {formatValue(data.count, 'count')}
{t('dashboard.model_usage.requests')}:{' '}
{formatValue(data.count, 'count')}
</p>
</div>
</div>
@@ -132,8 +121,10 @@ export function ModelUsageChart({
return (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
{description && <CardDescription>{description}</CardDescription>}
<CardTitle>{defaultTitle}</CardTitle>
{defaultDescription && (
<CardDescription>{defaultDescription}</CardDescription>
)}
</CardHeader>
<CardContent>
<Skeleton className='h-[350px] w-full' />
@@ -146,13 +137,17 @@ export function ModelUsageChart({
return (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
{description && <CardDescription>{description}</CardDescription>}
<CardTitle>{defaultTitle}</CardTitle>
{defaultDescription && (
<CardDescription>{defaultDescription}</CardDescription>
)}
</CardHeader>
<CardContent>
<div className='text-muted-foreground flex h-[350px] items-center justify-center'>
<div className='text-center'>
<p className='text-sm font-medium'>Failed to load data</p>
<p className='text-sm font-medium'>
{t('dashboard.model_usage.failed_to_load')}
</p>
<p className='mt-1 text-xs'>{error}</p>
</div>
</div>
@@ -165,15 +160,19 @@ export function ModelUsageChart({
return (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
{description && <CardDescription>{description}</CardDescription>}
<CardTitle>{defaultTitle}</CardTitle>
{defaultDescription && (
<CardDescription>{defaultDescription}</CardDescription>
)}
</CardHeader>
<CardContent>
<div className='text-muted-foreground flex h-[350px] items-center justify-center'>
<div className='text-center'>
<p className='text-sm font-medium'>No model usage data</p>
<p className='text-sm font-medium'>
{t('dashboard.model_usage.no_data')}
</p>
<p className='mt-1 text-xs'>
Start making API calls to see usage
{t('dashboard.model_usage.start_making_calls')}
</p>
</div>
</div>
@@ -185,8 +184,10 @@ export function ModelUsageChart({
return (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
{description && <CardDescription>{description}</CardDescription>}
<CardTitle>{defaultTitle}</CardTitle>
{defaultDescription && (
<CardDescription>{defaultDescription}</CardDescription>
)}
</CardHeader>
<CardContent>
<ResponsiveContainer width='100%' height={350}>

View File

@@ -1,5 +1,6 @@
import { useMemo } from 'react'
import type { TrendDataPoint } from '@/types/api'
import { useTranslation } from 'react-i18next'
import {
Bar,
BarChart,
@@ -8,6 +9,7 @@ import {
YAxis,
Tooltip,
} from 'recharts'
import { formatChartTimestamp, formatValue } from '@/lib/formatters'
import {
Card,
CardContent,
@@ -33,48 +35,24 @@ interface ChartDataPoint {
timestamp: number
}
const formatTimestamp = (timestamp: number): string => {
const date = new Date(timestamp * 1000)
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
})
}
const formatValue = (
value: number,
type: 'quota' | 'tokens' | 'count'
): string => {
switch (type) {
case 'quota':
if (value >= 1000000) return `$${(value / 1000000).toFixed(1)}M`
if (value >= 1000) return `$${(value / 1000).toFixed(1)}K`
return `$${value.toFixed(2)}`
case 'tokens':
if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`
if (value >= 1000) return `${(value / 1000).toFixed(1)}K`
return value.toString()
case 'count':
return value.toString()
default:
return value.toString()
}
}
export function Overview({
data = [],
loading = false,
error = null,
title = 'Usage Overview',
description = 'Quota usage over time',
title,
description,
}: OverviewProps) {
const { t } = useTranslation()
const defaultTitle = title || t('dashboard.overview.title')
const defaultDescription = description || t('dashboard.overview.description')
const chartData = useMemo((): ChartDataPoint[] => {
if (!data || data.length === 0) return []
return data
.sort((a, b) => a.timestamp - b.timestamp)
.map((item) => ({
name: formatTimestamp(item.timestamp),
name: formatChartTimestamp(item.timestamp),
quota: item.quota,
tokens: item.tokens,
count: item.count,
@@ -90,13 +68,16 @@ export function Overview({
<p className='mb-2 text-sm font-medium'>{label}</p>
<div className='space-y-1 text-xs'>
<p className='text-primary'>
Quota: {formatValue(data.quota, 'quota')}
{t('dashboard.overview.quota')}:{' '}
{formatValue(data.quota, 'quota')}
</p>
<p className='text-blue-600'>
Tokens: {formatValue(data.tokens, 'tokens')}
{t('dashboard.overview.tokens')}:{' '}
{formatValue(data.tokens, 'tokens')}
</p>
<p className='text-green-600'>
Requests: {formatValue(data.count, 'count')}
{t('dashboard.overview.requests')}:{' '}
{formatValue(data.count, 'count')}
</p>
</div>
</div>
@@ -109,8 +90,10 @@ export function Overview({
return (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
{description && <CardDescription>{description}</CardDescription>}
<CardTitle>{defaultTitle}</CardTitle>
{defaultDescription && (
<CardDescription>{defaultDescription}</CardDescription>
)}
</CardHeader>
<CardContent>
<Skeleton className='h-[350px] w-full' />
@@ -123,13 +106,17 @@ export function Overview({
return (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
{description && <CardDescription>{description}</CardDescription>}
<CardTitle>{defaultTitle}</CardTitle>
{defaultDescription && (
<CardDescription>{defaultDescription}</CardDescription>
)}
</CardHeader>
<CardContent>
<div className='text-muted-foreground flex h-[350px] items-center justify-center'>
<div className='text-center'>
<p className='text-sm font-medium'>Failed to load data</p>
<p className='text-sm font-medium'>
{t('dashboard.overview.failed_to_load')}
</p>
<p className='mt-1 text-xs'>{error}</p>
</div>
</div>
@@ -142,14 +129,20 @@ export function Overview({
return (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
{description && <CardDescription>{description}</CardDescription>}
<CardTitle>{defaultTitle}</CardTitle>
{defaultDescription && (
<CardDescription>{defaultDescription}</CardDescription>
)}
</CardHeader>
<CardContent>
<div className='text-muted-foreground flex h-[350px] items-center justify-center'>
<div className='text-center'>
<p className='text-sm font-medium'>No data available</p>
<p className='mt-1 text-xs'>Try adjusting your time range</p>
<p className='text-sm font-medium'>
{t('dashboard.overview.no_data_available')}
</p>
<p className='mt-1 text-xs'>
{t('dashboard.overview.try_adjusting_time_range')}
</p>
</div>
</div>
</CardContent>
@@ -158,33 +151,43 @@ export function Overview({
}
return (
<ResponsiveContainer width='100%' height={350}>
<BarChart
data={chartData}
margin={{ top: 20, right: 30, left: 20, bottom: 5 }}
>
<XAxis
dataKey='name'
stroke='hsl(var(--muted-foreground))'
fontSize={12}
tickLine={false}
axisLine={false}
/>
<YAxis
stroke='hsl(var(--muted-foreground))'
fontSize={12}
tickLine={false}
axisLine={false}
tickFormatter={(value) => formatValue(value, 'quota')}
/>
<Tooltip content={<CustomTooltip />} />
<Bar
dataKey='quota'
fill='hsl(var(--primary))'
radius={[4, 4, 0, 0]}
name='Quota'
/>
</BarChart>
</ResponsiveContainer>
<Card>
<CardHeader>
<CardTitle>{defaultTitle}</CardTitle>
{defaultDescription && (
<CardDescription>{defaultDescription}</CardDescription>
)}
</CardHeader>
<CardContent>
<ResponsiveContainer width='100%' height={350}>
<BarChart
data={chartData}
margin={{ top: 20, right: 30, left: 20, bottom: 5 }}
>
<XAxis
dataKey='name'
stroke='hsl(var(--muted-foreground))'
fontSize={12}
tickLine={false}
axisLine={false}
/>
<YAxis
stroke='hsl(var(--muted-foreground))'
fontSize={12}
tickLine={false}
axisLine={false}
tickFormatter={(value) => formatValue(value, 'quota')}
/>
<Tooltip content={<CustomTooltip />} />
<Bar
dataKey='quota'
fill='hsl(var(--primary))'
radius={[4, 4, 0, 0]}
name='Quota'
/>
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
)
}

View File

@@ -1,83 +0,0 @@
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
export function RecentSales() {
return (
<div className='space-y-8'>
<div className='flex items-center gap-4'>
<Avatar className='h-9 w-9'>
<AvatarImage src='/avatars/01.png' alt='Avatar' />
<AvatarFallback>OM</AvatarFallback>
</Avatar>
<div className='flex flex-1 flex-wrap items-center justify-between'>
<div className='space-y-1'>
<p className='text-sm leading-none font-medium'>Olivia Martin</p>
<p className='text-muted-foreground text-sm'>
olivia.martin@email.com
</p>
</div>
<div className='font-medium'>+$1,999.00</div>
</div>
</div>
<div className='flex items-center gap-4'>
<Avatar className='flex h-9 w-9 items-center justify-center space-y-0 border'>
<AvatarImage src='/avatars/02.png' alt='Avatar' />
<AvatarFallback>JL</AvatarFallback>
</Avatar>
<div className='flex flex-1 flex-wrap items-center justify-between'>
<div className='space-y-1'>
<p className='text-sm leading-none font-medium'>Jackson Lee</p>
<p className='text-muted-foreground text-sm'>
jackson.lee@email.com
</p>
</div>
<div className='font-medium'>+$39.00</div>
</div>
</div>
<div className='flex items-center gap-4'>
<Avatar className='h-9 w-9'>
<AvatarImage src='/avatars/03.png' alt='Avatar' />
<AvatarFallback>IN</AvatarFallback>
</Avatar>
<div className='flex flex-1 flex-wrap items-center justify-between'>
<div className='space-y-1'>
<p className='text-sm leading-none font-medium'>Isabella Nguyen</p>
<p className='text-muted-foreground text-sm'>
isabella.nguyen@email.com
</p>
</div>
<div className='font-medium'>+$299.00</div>
</div>
</div>
<div className='flex items-center gap-4'>
<Avatar className='h-9 w-9'>
<AvatarImage src='/avatars/04.png' alt='Avatar' />
<AvatarFallback>WK</AvatarFallback>
</Avatar>
<div className='flex flex-1 flex-wrap items-center justify-between'>
<div className='space-y-1'>
<p className='text-sm leading-none font-medium'>William Kim</p>
<p className='text-muted-foreground text-sm'>will@email.com</p>
</div>
<div className='font-medium'>+$99.00</div>
</div>
</div>
<div className='flex items-center gap-4'>
<Avatar className='h-9 w-9'>
<AvatarImage src='/avatars/05.png' alt='Avatar' />
<AvatarFallback>SD</AvatarFallback>
</Avatar>
<div className='flex flex-1 flex-wrap items-center justify-between'>
<div className='space-y-1'>
<p className='text-sm leading-none font-medium'>Sofia Davis</p>
<p className='text-muted-foreground text-sm'>
sofia.davis@email.com
</p>
</div>
<div className='font-medium'>+$39.00</div>
</div>
</div>
</div>
)
}

View File

@@ -1,120 +1,35 @@
import type { DashboardStats } from '@/types/api'
import type { DashboardStats, UserSelf } from '@/types/api'
import {
TrendingUp,
TrendingDown,
DollarSign,
Activity,
Users,
Zap,
Wallet,
BarChart3,
Clock,
Timer,
} from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { useTranslation } from 'react-i18next'
import { formatCurrency, formatNumber } from '@/lib/formatters'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
interface StatsCardsProps {
stats: DashboardStats
userStats?: UserSelf | null
loading?: boolean
error?: string | null
className?: string
}
interface StatCardProps {
title: string
value: string
description?: string
icon: React.ReactNode
trend?: {
value: number
isPositive: boolean
period: string
}
loading?: boolean
}
const formatCurrency = (value: number): string => {
if (value >= 1000000) {
return `$${(value / 1000000).toFixed(1)}M`
} else if (value >= 1000) {
return `$${(value / 1000).toFixed(1)}K`
} else {
return `$${value.toFixed(2)}`
}
}
const formatNumber = (value: number): string => {
if (value >= 1000000) {
return `${(value / 1000000).toFixed(1)}M`
} else if (value >= 1000) {
return `${(value / 1000).toFixed(1)}K`
} else {
return value.toString()
}
}
function StatCard({
title,
value,
description,
icon,
trend,
loading,
}: StatCardProps) {
if (loading) {
return (
<Card>
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
<Skeleton className='h-4 w-24' />
<Skeleton className='h-4 w-4' />
</CardHeader>
<CardContent>
<Skeleton className='mb-2 h-8 w-20' />
<Skeleton className='h-3 w-32' />
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
<CardTitle className='text-sm font-medium'>{title}</CardTitle>
<div className='text-muted-foreground'>{icon}</div>
</CardHeader>
<CardContent>
<div className='text-2xl font-bold'>{value}</div>
{description && (
<p className='text-muted-foreground mt-1 text-xs'>{description}</p>
)}
{trend && (
<div className='mt-2 flex items-center'>
<Badge
variant={trend.isPositive ? 'default' : 'secondary'}
className='text-xs'
>
{trend.isPositive ? (
<TrendingUp className='mr-1 h-3 w-3' />
) : (
<TrendingDown className='mr-1 h-3 w-3' />
)}
{trend.isPositive ? '+' : ''}
{trend.value.toFixed(1)}%
</Badge>
<span className='text-muted-foreground ml-2 text-xs'>
{trend.period}
</span>
</div>
)}
</CardContent>
</Card>
)
}
export function StatsCards({
stats,
userStats,
loading = false,
error = null,
className,
}: StatsCardsProps) {
const { t } = useTranslation()
if (error) {
return (
<div
@@ -124,7 +39,9 @@ export function StatsCards({
<Card key={index}>
<CardContent className='flex h-32 items-center justify-center'>
<div className='text-muted-foreground text-center'>
<p className='text-sm font-medium'>Error loading stats</p>
<p className='text-sm font-medium'>
{t('dashboard.error_loading_stats')}
</p>
<p className='mt-1 text-xs'>{error}</p>
</div>
</CardContent>
@@ -134,67 +51,145 @@ export function StatsCards({
)
}
const cards = [
// 计算性能指标
const calculateRPM = () => {
const timeSpanMinutes = 7 * 24 * 60 // 7天转换为分钟
return stats.totalRequests > 0
? (stats.totalRequests / timeSpanMinutes).toFixed(3)
: '0'
}
const calculateTPM = () => {
const timeSpanMinutes = 7 * 24 * 60 // 7天转换为分钟
return stats.totalTokens > 0
? (stats.totalTokens / timeSpanMinutes).toFixed(3)
: '0'
}
const cardGroups = [
{
title: 'Total Quota Used',
value: formatCurrency(stats.totalQuota),
description: 'Cumulative quota consumption',
icon: <DollarSign className='h-4 w-4' />,
trend: {
value: 12.5, // 这里可以从历史数据计算
isPositive: true,
period: 'from last month',
},
title: t('dashboard.stats.account_data'),
icon: <Wallet className='text-muted-foreground h-4 w-4' />,
items: [
{
label: t('dashboard.stats.current_balance'),
value: formatCurrency(userStats?.quota || 0),
icon: <DollarSign className='text-muted-foreground h-4 w-4' />,
},
{
label: t('dashboard.stats.historical_consumption'),
value: formatCurrency(userStats?.used_quota || 0),
icon: <BarChart3 className='text-muted-foreground h-4 w-4' />,
},
],
},
{
title: 'Total Tokens',
value: formatNumber(stats.totalTokens),
description: 'Tokens processed',
icon: <Zap className='h-4 w-4' />,
trend: {
value: 8.3,
isPositive: true,
period: 'from last month',
},
title: t('dashboard.stats.usage_statistics'),
icon: <Activity className='text-muted-foreground h-4 w-4' />,
items: [
{
label: t('dashboard.stats.request_count'),
value: formatNumber(userStats?.request_count || 0),
icon: <Users className='text-muted-foreground h-4 w-4' />,
},
{
label: t('dashboard.stats.statistical_count'),
value: formatNumber(stats.totalRequests),
icon: <Activity className='text-muted-foreground h-4 w-4' />,
},
],
},
{
title: 'Total Requests',
value: formatNumber(stats.totalRequests),
description: 'API calls made',
icon: <Activity className='h-4 w-4' />,
trend: {
value: 15.2,
isPositive: true,
period: 'from last month',
},
title: t('dashboard.stats.resource_consumption'),
icon: <Zap className='text-muted-foreground h-4 w-4' />,
items: [
{
label: t('dashboard.stats.statistical_quota'),
value: formatCurrency(stats.totalQuota),
icon: <DollarSign className='text-muted-foreground h-4 w-4' />,
},
{
label: t('dashboard.stats.statistical_tokens'),
value: formatNumber(stats.totalTokens),
icon: <Zap className='text-muted-foreground h-4 w-4' />,
},
],
},
{
title: 'Avg Cost/Request',
value: formatCurrency(stats.avgQuotaPerRequest),
description: 'Average quota per request',
icon: <Users className='h-4 w-4' />,
trend: {
value: 2.1,
isPositive: false,
period: 'from last month',
},
title: t('dashboard.stats.performance_metrics'),
icon: <Clock className='text-muted-foreground h-4 w-4' />,
items: [
{
label: t('dashboard.stats.average_rpm'),
value: calculateRPM(),
icon: <Clock className='text-muted-foreground h-4 w-4' />,
},
{
label: t('dashboard.stats.average_tpm'),
value: calculateTPM(),
icon: <Timer className='text-muted-foreground h-4 w-4' />,
},
],
},
]
if (loading) {
return (
<div
className={`grid gap-4 md:grid-cols-2 lg:grid-cols-4 ${className || ''}`}
>
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i}>
<CardHeader className='pb-3'>
<div className='flex items-center justify-between'>
<Skeleton className='h-5 w-24' />
<Skeleton className='h-5 w-5 rounded-full' />
</div>
</CardHeader>
<CardContent className='space-y-3'>
{Array.from({ length: 2 }).map((_, j) => (
<div key={j} className='flex items-center justify-between'>
<div className='flex items-center space-x-2'>
<Skeleton className='h-4 w-4 rounded-full' />
<Skeleton className='h-4 w-16' />
</div>
<Skeleton className='h-6 w-20' />
</div>
))}
</CardContent>
</Card>
))}
</div>
)
}
return (
<div
className={`grid gap-4 md:grid-cols-2 lg:grid-cols-4 ${className || ''}`}
>
{cards.map((card, index) => (
<StatCard
key={index}
title={card.title}
value={card.value}
description={card.description}
icon={card.icon}
trend={card.trend}
loading={loading}
/>
{cardGroups.map((group, index) => (
<Card key={index}>
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
<CardTitle className='text-sm font-medium'>{group.title}</CardTitle>
{group.icon}
</CardHeader>
<CardContent className='space-y-3'>
{group.items.map((item, itemIndex) => (
<div
key={itemIndex}
className='flex items-center justify-between'
>
<div className='flex items-center space-x-2'>
{item.icon}
<span className='text-muted-foreground text-sm'>
{item.label}
</span>
</div>
<span className='text-xl font-bold'>{item.value}</span>
</div>
))}
</CardContent>
</Card>
))}
</div>
)

View File

@@ -6,7 +6,7 @@ import type {
TrendDataPoint,
ModelUsageData,
} from '@/types/api'
import { getStoredUser } from '@/lib/auth'
import { isAdmin } from '@/lib/auth'
import { get } from '@/lib/http'
export interface DashboardFilters {
@@ -29,11 +29,6 @@ const DEFAULT_FILTERS: DashboardFilters = {
defaultTime: 'day',
}
function isAdmin(): boolean {
const user = getStoredUser()
return !!(user && (user as any).role >= 10)
}
function processQuotaData(data: QuotaDataItem[]): ProcessedDashboardData {
if (!data || data.length === 0) {
return {

View File

@@ -7,7 +7,7 @@ import {
QuotaDataItem,
} from '@/types/api'
import { toast } from 'sonner'
import { getStoredUser } from '@/lib/auth'
import { isAdmin } from '@/lib/auth'
import { get } from '@/lib/http'
export interface ModelMonitoringFilters {
@@ -22,11 +22,6 @@ const initialFilters: ModelMonitoringFilters = {
endTimestamp: Math.floor(Date.now() / 1000),
}
function isAdmin(): boolean {
const user = getStoredUser()
return !!(user && (user as any).role >= 10)
}
// 处理原始数据生成模型监控数据
function processModelData(data: QuotaDataItem[]): ModelMonitoringData {
if (!data || data.length === 0) {

View File

@@ -1,6 +1,7 @@
import { useState, useCallback, useEffect } from 'react'
import type { SelfResponse, UserSelf } from '@/types/api'
import { getStoredUser } from '@/lib/auth'
import { formatBalance, calculateUsagePercentage } from '@/lib/formatters'
import { get } from '@/lib/http'
export interface UserStatsData {
@@ -12,22 +13,6 @@ export interface UserStatsData {
error: string | null
}
const formatBalance = (quota: number, usedQuota: number): string => {
const remaining = Math.max(0, quota - usedQuota)
if (remaining >= 1000000) {
return `$${(remaining / 1000000).toFixed(1)}M`
} else if (remaining >= 1000) {
return `$${(remaining / 1000).toFixed(1)}K`
} else {
return `$${remaining.toFixed(2)}`
}
}
const calculateUsagePercentage = (quota: number, usedQuota: number): number => {
if (quota <= 0) return 0
return Math.min(100, (usedQuota / quota) * 100)
}
export function useUserStats() {
const [data, setData] = useState<UserStatsData>({
user: null,

View File

@@ -1,11 +1,8 @@
import { useState, useCallback } from 'react'
import { format } from 'date-fns'
import { CalendarIcon, DownloadIcon, RefreshCcw, Search } from 'lucide-react'
import { RefreshCcw, Search } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Calendar } from '@/components/ui/calendar'
import {
Card,
CardContent,
@@ -13,11 +10,6 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { ConfigDrawer } from '@/components/config-drawer'
import { LanguageSwitch } from '@/components/language-switch'
@@ -38,11 +30,6 @@ import { useUserStats } from './hooks/use-user-stats'
export function Dashboard() {
const { t } = useTranslation()
const [dateRange, setDateRange] = useState<{ from: Date; to: Date }>({
from: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), // 7 days ago
to: new Date(),
})
const [isCalendarOpen, setIsCalendarOpen] = useState(false)
const [searchDialogOpen, setSearchDialogOpen] = useState(false)
const {
@@ -52,10 +39,9 @@ export function Dashboard() {
refresh: refreshDashboard,
fetchData,
filters,
isAdmin,
} = useDashboardData()
const { user } = useUserStats()
const { user, isLoading: userLoading } = useUserStats()
// 模型监控数据
const {
@@ -67,46 +53,23 @@ export function Dashboard() {
filters: modelMonitoringFilters,
} = useModelMonitoring()
const handleDateRangeChange = useCallback(
(range: { from: Date; to: Date }) => {
setDateRange(range)
setIsCalendarOpen(false)
// TODO: Update dashboard data with new date range
toast.success('Date range updated')
},
[]
)
const handleRefresh = useCallback(() => {
refreshDashboard()
toast.success('Dashboard refreshed')
}, [refreshDashboard])
const handleExport = useCallback(() => {
// TODO: Implement data export functionality
toast.success('Export started')
}, [])
toast.success(t('dashboard.refresh_success'))
}, [refreshDashboard, t])
const handleAdvancedSearch = useCallback(
(newFilters: any) => {
fetchData(newFilters)
toast.success('Search updated')
toast.success(t('dashboard.search_updated'))
},
[fetchData]
[fetchData, t]
)
const openSearchDialog = useCallback(() => {
setSearchDialogOpen(true)
}, [])
const formatDateRange = () => {
if (!dateRange.from || !dateRange.to) return 'Select date range'
if (dateRange.from.toDateString() === dateRange.to.toDateString()) {
return format(dateRange.from, 'MMM dd, yyyy')
}
return `${format(dateRange.from, 'MMM dd')} - ${format(dateRange.to, 'MMM dd, yyyy')}`
}
return (
<>
{/* ===== Top Heading ===== */}
@@ -124,75 +87,42 @@ export function Dashboard() {
<Main>
<div className='mb-2 flex items-center justify-between space-y-2'>
<div>
<h1 className='text-2xl font-bold tracking-tight'>Dashboard</h1>
<h1 className='text-2xl font-bold tracking-tight'>
{t('dashboard.title')}
</h1>
<p className='text-muted-foreground'>
{user
? `Welcome back, ${user.display_name || user.username}`
: 'Overview of your API usage'}
? t('dashboard.welcome_back', {
name: user.display_name || user.username,
})
: t('dashboard.overview_subtitle')}
</p>
</div>
<div className='flex items-center space-x-2'>
<Popover open={isCalendarOpen} onOpenChange={setIsCalendarOpen}>
<PopoverTrigger asChild>
<Button
variant='outline'
className={cn(
'w-[280px] justify-start text-left font-normal',
!dateRange.from && 'text-muted-foreground'
)}
>
<CalendarIcon className='mr-2 h-4 w-4' />
{formatDateRange()}
</Button>
</PopoverTrigger>
<PopoverContent className='w-auto p-0' align='end'>
<Calendar
mode='range'
defaultMonth={dateRange.from}
selected={dateRange}
onSelect={(range) => {
if (range?.from && range?.to) {
handleDateRangeChange({ from: range.from, to: range.to })
}
}}
numberOfMonths={2}
/>
</PopoverContent>
</Popover>
<Button variant='outline' size='icon' onClick={handleRefresh}>
<RefreshCcw className='h-4 w-4' />
</Button>
<Button variant='outline' onClick={openSearchDialog}>
<Search className='mr-2 h-4 w-4' />
{t('dashboard.advanced_search')}
</Button>
<Button onClick={handleExport}>
<DownloadIcon className='mr-2 h-4 w-4' />
{t('dashboard.export')}
{t('dashboard.search_button')}
</Button>
</div>
</div>
<Tabs defaultValue='overview' className='space-y-4'>
<TabsList>
<TabsTrigger value='overview'>Overview</TabsTrigger>
<TabsTrigger value='analytics'>
{t('dashboard.analytics')}
<TabsTrigger value='overview'>
{t('dashboard.overview_tab')}
</TabsTrigger>
<TabsTrigger value='models'>{t('dashboard.models')}</TabsTrigger>
<TabsTrigger value='monitoring'>
{t('dashboard.monitoring')}
</TabsTrigger>
{isAdmin && (
<TabsTrigger value='admin'>{t('dashboard.admin')}</TabsTrigger>
)}
</TabsList>
<TabsContent value='overview' className='space-y-4'>
{/* Stats Cards */}
<StatsCards
stats={dashboardData.stats}
loading={dashboardLoading}
userStats={user}
loading={dashboardLoading || userLoading}
error={dashboardError}
/>
@@ -215,91 +145,78 @@ export function Dashboard() {
</div>
</TabsContent>
<TabsContent value='analytics' className='space-y-4'>
<Card>
<CardHeader>
<CardTitle>Advanced Analytics</CardTitle>
<CardDescription>
Detailed usage analytics and insights
</CardDescription>
</CardHeader>
<CardContent>
<div className='text-muted-foreground flex h-[400px] items-center justify-center'>
<div className='text-center'>
<p className='text-lg font-medium'>
{t('dashboard.coming_soon')}
</p>
<p className='mt-2 text-sm'>
Advanced analytics features are in development
</p>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value='models' className='space-y-4'>
<div className='grid grid-cols-1 gap-4'>
<ModelUsageChart
data={dashboardData.modelUsage}
loading={dashboardLoading}
error={dashboardError}
title='Detailed Model Usage'
description='Comprehensive breakdown of usage by model'
/>
{/* Model Usage Table */}
<Card>
<CardHeader>
<CardTitle>Model Usage Details</CardTitle>
<CardDescription>
Detailed statistics for each model
</CardDescription>
</CardHeader>
<CardContent>
{dashboardData.modelUsage.length > 0 ? (
<div className='space-y-2'>
{dashboardData.modelUsage.slice(0, 10).map((model) => (
<div
key={model.model}
className='flex items-center justify-between rounded-lg border p-3'
>
<div>
<p className='font-medium'>{model.model}</p>
<p className='text-muted-foreground text-sm'>
{model.count} requests
</p>
</div>
<div className='text-right'>
<p className='font-medium'>
{model.percentage.toFixed(1)}%
</p>
<p className='text-muted-foreground text-sm'>
${model.quota.toFixed(2)}
</p>
</div>
</div>
))}
</div>
) : (
<div className='text-muted-foreground flex h-[200px] items-center justify-center'>
<p>No model usage data available</p>
</div>
)}
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value='monitoring' className='space-y-4'>
{/* 模型监控统计 */}
{/* 模型监控统计卡片 */}
<ModelMonitoringStats
stats={modelMonitoringData.stats}
loading={modelMonitoringLoading}
error={modelMonitoringError}
/>
{/* 模型监控表格 */}
{/* 图表区域 */}
<div className='grid grid-cols-1 gap-4 lg:grid-cols-7'>
<div className='col-span-1 lg:col-span-4'>
<ModelUsageChart
data={dashboardData.modelUsage}
loading={dashboardLoading}
error={dashboardError}
title={t('dashboard.model_usage_distribution')}
description={t('dashboard.model_usage_description')}
/>
</div>
<div className='col-span-1 lg:col-span-3'>
<Card>
<CardHeader>
<CardTitle>{t('dashboard.top_models_ranking')}</CardTitle>
<CardDescription>
{t('dashboard.top_models_description')}
</CardDescription>
</CardHeader>
<CardContent>
{dashboardData.modelUsage.length > 0 ? (
<div className='space-y-3'>
{dashboardData.modelUsage
.slice(0, 10)
.map((model, index) => (
<div
key={model.model}
className='flex items-center justify-between rounded-lg border p-3'
>
<div className='flex items-center space-x-3'>
<div className='bg-primary text-primary-foreground flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold'>
{index + 1}
</div>
<div>
<p className='font-medium'>{model.model}</p>
<p className='text-muted-foreground text-sm'>
{t('dashboard.requests_count', {
count: model.count,
})}
</p>
</div>
</div>
<div className='text-right'>
<p className='font-medium'>
{model.percentage.toFixed(1)}%
</p>
<p className='text-muted-foreground text-sm'>
${model.quota.toFixed(2)}
</p>
</div>
</div>
))}
</div>
) : (
<div className='text-muted-foreground flex h-[200px] items-center justify-center'>
<p>{t('dashboard.no_model_usage_data')}</p>
</div>
)}
</CardContent>
</Card>
</div>
</div>
{/* 详细模型监控表格 */}
<ModelMonitoringTable
models={modelMonitoringData.models}
loading={modelMonitoringLoading}
@@ -315,29 +232,6 @@ export function Dashboard() {
onRefresh={refreshModelMonitoring}
/>
</TabsContent>
{isAdmin && (
<TabsContent value='admin' className='space-y-4'>
<Card>
<CardHeader>
<CardTitle>Admin Dashboard</CardTitle>
<CardDescription>
System-wide statistics and management
</CardDescription>
</CardHeader>
<CardContent>
<div className='text-muted-foreground flex h-[400px] items-center justify-center'>
<div className='text-center'>
<p className='text-lg font-medium'>Admin Features</p>
<p className='mt-2 text-sm'>
Advanced admin features are in development
</p>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
)}
</Tabs>
</Main>

View File

@@ -29,3 +29,12 @@ export function getStoredUserId(): number | undefined {
export function clearStoredUser() {
setStoredUser(null)
}
/**
* 检查当前用户是否为管理员
* @returns 是否为管理员
*/
export function isAdmin(): boolean {
const user = getStoredUser()
return !!(user && (user as any).role >= 10)
}

View File

@@ -275,3 +275,78 @@ export function truncateText(text: string, maxWidth: number = 200): string {
if (text.length <= maxChars) return text
return text.slice(0, maxChars - 3) + '...'
}
/**
* 格式化货币(通用版本)
* @param value 数值
* @returns 格式化的货币字符串
*/
export function formatCurrency(value: number): string {
if (value >= 1000000) {
return `$${(value / 1000000).toFixed(1)}M`
} else if (value >= 1000) {
return `$${(value / 1000).toFixed(1)}K`
} else {
return `$${value.toFixed(2)}`
}
}
/**
* 格式化时间戳为图表标签
* @param timestamp 时间戳(秒)
* @returns 格式化的时间字符串
*/
export function formatChartTimestamp(timestamp: number): string {
const date = new Date(timestamp * 1000)
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
})
}
/**
* 通用数值格式化函数(支持不同类型)
* @param value 数值
* @param type 类型quota配额、tokens令牌、count计数
* @returns 格式化的字符串
*/
export function formatValue(
value: number,
type: 'quota' | 'tokens' | 'count'
): string {
switch (type) {
case 'quota':
return formatCurrency(value)
case 'tokens':
return formatTokens(value)
case 'count':
return formatNumber(value)
default:
return value.toString()
}
}
/**
* 格式化余额(配额减去已使用)
* @param quota 总配额
* @param usedQuota 已使用配额
* @returns 格式化的余额字符串
*/
export function formatBalance(quota: number, usedQuota: number): string {
const remaining = Math.max(0, quota - usedQuota)
return formatCurrency(remaining)
}
/**
* 计算配额使用百分比
* @param quota 总配额
* @param usedQuota 已使用配额
* @returns 使用百分比0-100
*/
export function calculateUsagePercentage(
quota: number,
usedQuota: number
): number {
if (quota <= 0) return 0
return Math.min(100, (usedQuota / quota) * 100)
}

View File

@@ -35,6 +35,11 @@ export {
formatPrice,
formatApiCalls,
truncateText,
formatCurrency,
formatChartTimestamp,
formatValue,
formatBalance,
calculateUsagePercentage,
} from './formatters'
// 验证工具
@@ -89,6 +94,7 @@ export {
getStoredUser,
getStoredUserId,
clearStoredUser,
isAdmin,
} from './auth'
// Cookie相关

View File

@@ -9,14 +9,103 @@
"title": "Dashboard",
"welcome_back": "Welcome back, {{name}}",
"overview_subtitle": "Overview of your API usage",
"advanced_search": "Advanced Search",
"export": "Export",
"analytics": "Analytics",
"models": "Models",
"monitoring": "Monitoring",
"admin": "Admin",
"coming_soon": "Coming Soon",
"admin_features": "Admin Features"
"search_button": "Search",
"refresh_success": "Dashboard refreshed",
"search_updated": "Search updated",
"overview_tab": "Overview",
"models": "Model Monitoring",
"model_usage_distribution": "Model Usage Distribution",
"model_usage_description": "Usage and cost distribution by model",
"top_models_ranking": "Top Models Ranking",
"top_models_description": "Top 10 models sorted by usage",
"requests_count": "{{count}} requests",
"no_model_usage_data": "No model usage data",
"error_loading_stats": "Error loading stats",
"stats": {
"account_data": "Account Data",
"current_balance": "Current Balance",
"historical_consumption": "Historical Consumption",
"usage_statistics": "Usage Statistics",
"request_count": "Request Count",
"statistical_count": "Statistical Count",
"resource_consumption": "Resource Consumption",
"statistical_quota": "Statistical Quota",
"statistical_tokens": "Statistical Tokens",
"performance_metrics": "Performance Metrics",
"average_rpm": "Average RPM",
"average_tpm": "Average TPM"
},
"overview": {
"title": "Usage Overview",
"description": "Quota usage over time",
"quota": "Quota",
"tokens": "Tokens",
"requests": "Requests",
"failed_to_load": "Failed to load data",
"no_data_available": "No data available",
"try_adjusting_time_range": "Try adjusting your time range"
},
"model_usage": {
"title": "Model Usage Distribution",
"description": "Quota usage by model",
"quota": "Quota",
"tokens": "Tokens",
"requests": "Requests",
"failed_to_load": "Failed to load data",
"no_data": "No model usage data",
"start_making_calls": "Start making API calls to see usage"
},
"monitoring": {
"total_models": "Total Models",
"total_models_desc": "Total number of models in the system",
"active_models": "Active Models",
"active_models_desc": "{{percentage}}% of models have calls",
"total_requests": "Total Requests",
"total_requests_desc": "Total number of calls for all models",
"avg_success_rate": "Average Success Rate",
"avg_success_rate_desc": "Average success rate across all models",
"model_list": "Model List",
"load_failed": "Load Failed",
"search_model_placeholder": "Search model code...",
"business_space": "Business Space",
"all_spaces": "All Spaces",
"no_matching_models": "No matching models found",
"model_code": "Model Code",
"total_calls": "Total Calls",
"failed_calls": "Failed Calls",
"failure_rate": "Failure Rate",
"avg_cost": "Average Cost",
"avg_tokens": "Average Tokens",
"actions": "Actions",
"monitor": "Monitor",
"details": "Details",
"settings": "Settings",
"pagination_info": "Showing {{start}} to {{end}} of {{total}} entries"
},
"search": {
"title": "Advanced Search",
"description": "Search and filter your usage data with advanced criteria",
"last_24h": "Last 24h",
"last_7_days": "Last 7 days",
"last_30_days": "Last 30 days",
"last_90_days": "Last 90 days",
"start_date": "Start Date",
"end_date": "End Date",
"pick_date": "Pick a date",
"time_granularity": "Time Granularity",
"select_time_granularity": "Select time granularity",
"hourly": "Hourly",
"daily": "Daily",
"weekly": "Weekly",
"filter_by_username": "Filter by Username (Admin)",
"username_placeholder": "Enter username to filter (optional)",
"model_filter": "Model Filter",
"model_filter_placeholder": "Filter by model name (optional)",
"reset": "Reset",
"search": "Search",
"searching": "Searching...",
"end_date_after_start": "End date must be after start date"
}
},
"sidebar": {
"general": "General",
@@ -50,6 +139,10 @@
"help_center": "Help Center"
},
"common": {
"error": "Error",
"retry": "Retry",
"previous": "Previous",
"next": "Next",
"table": {
"rows_per_page": "Rows per page",
"page_of": "Page {{page}} of {{total}}",

View File

@@ -9,14 +9,103 @@
"title": "仪表盘",
"welcome_back": "欢迎回来,{{name}}",
"overview_subtitle": "你的 API 使用概览",
"advanced_search": "高级搜索",
"export": "导出",
"analytics": "分析",
"models": "模型",
"monitoring": "模型观测",
"admin": "管理",
"coming_soon": "即将推出",
"admin_features": "管理功能"
"search_button": "搜索",
"refresh_success": "仪表盘已刷新",
"search_updated": "搜索已更新",
"overview_tab": "概览",
"models": "模型观测",
"model_usage_distribution": "模型使用分布",
"model_usage_description": "各模型的调用量和费用分布",
"top_models_ranking": "Top 模型排行",
"top_models_description": "按调用量排序的前10个模型",
"requests_count": "{{count}} 次请求",
"no_model_usage_data": "暂无模型使用数据",
"error_loading_stats": "统计数据加载失败",
"stats": {
"account_data": "账户数据",
"current_balance": "当前余额",
"historical_consumption": "历史消耗",
"usage_statistics": "使用统计",
"request_count": "请求次数",
"statistical_count": "统计次数",
"resource_consumption": "资源消耗",
"statistical_quota": "统计额度",
"statistical_tokens": "统计Tokens",
"performance_metrics": "性能指标",
"average_rpm": "平均RPM",
"average_tpm": "平均TPM"
},
"overview": {
"title": "使用概览",
"description": "配额使用趋势",
"quota": "配额",
"tokens": "令牌",
"requests": "请求",
"failed_to_load": "数据加载失败",
"no_data_available": "暂无数据",
"try_adjusting_time_range": "请尝试调整时间范围"
},
"model_usage": {
"title": "模型使用分布",
"description": "按模型的配额使用情况",
"quota": "配额",
"tokens": "令牌",
"requests": "请求",
"failed_to_load": "数据加载失败",
"no_data": "暂无模型使用数据",
"start_making_calls": "开始调用 API 以查看使用情况"
},
"monitoring": {
"total_models": "模型总数",
"total_models_desc": "系统中的模型总数量",
"active_models": "活跃模型",
"active_models_desc": "{{percentage}}% 的模型有调用",
"total_requests": "调用总次数",
"total_requests_desc": "所有模型的调用总数",
"avg_success_rate": "平均成功率",
"avg_success_rate_desc": "所有模型的平均成功率",
"model_list": "模型列表",
"load_failed": "加载失败",
"search_model_placeholder": "搜索模型Code...",
"business_space": "业务空间",
"all_spaces": "全部空间",
"no_matching_models": "没有找到匹配的模型",
"model_code": "模型Code",
"total_calls": "调用总数",
"failed_calls": "调用失败数",
"failure_rate": "失败率",
"avg_cost": "平均调用耗费",
"avg_tokens": "平均调用Token",
"actions": "操作",
"monitor": "监控",
"details": "详情",
"settings": "设置",
"pagination_info": "显示第 {{start}} 条 - 第 {{end}} 条,共 {{total}} 条"
},
"search": {
"title": "高级搜索",
"description": "使用高级条件搜索和筛选使用数据",
"last_24h": "过去24小时",
"last_7_days": "过去7天",
"last_30_days": "过去30天",
"last_90_days": "过去90天",
"start_date": "开始日期",
"end_date": "结束日期",
"pick_date": "选择日期",
"time_granularity": "时间粒度",
"select_time_granularity": "选择时间粒度",
"hourly": "每小时",
"daily": "每天",
"weekly": "每周",
"filter_by_username": "按用户名筛选(管理员)",
"username_placeholder": "输入用户名进行筛选(可选)",
"model_filter": "模型筛选",
"model_filter_placeholder": "按模型名称筛选(可选)",
"reset": "重置",
"search": "搜索",
"searching": "搜索中...",
"end_date_after_start": "结束日期必须在开始日期之后"
}
},
"sidebar": {
"general": "通用",
@@ -50,6 +139,10 @@
"help_center": "帮助中心"
},
"common": {
"error": "错误",
"retry": "重试",
"previous": "上一页",
"next": "下一页",
"table": {
"rows_per_page": "每页行数",
"page_of": "第 {{page}} / 共 {{total}} 页",