mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-04-18 04:47:27 +00:00
📊 feat: add comprehensive model monitoring dashboard
This commit introduces a complete model monitoring system that provides real-time insights into model performance and usage statistics. ## ✨ Features Added ### Core Components - **ModelMonitoringStats**: Four key metric cards displaying total models, active models, total requests, and average success rate - **ModelMonitoringTable**: Interactive table with search, filtering, and pagination for detailed model data - **useModelMonitoring**: Custom hook for data fetching and state management ### Dashboard Integration - Added new "模型观测" (Model Monitoring) tab to main dashboard - Integrated monitoring components with existing dashboard layout - Maintained consistent UI/UX with shadcn/ui design system ### Data Processing - Smart data aggregation from existing `/api/data/` endpoints - Automatic calculation of success rates and average metrics - Support for both admin and user-specific data views ### Interactive Features - Real-time search by model name - Business group filtering - Pagination with 10 items per page - Color-coded success rate indicators (green >95%, yellow 90-95%, red <90%) - Refresh capability for up-to-date data ## 🔧 Technical Implementation ### Type Safety - Added comprehensive TypeScript interfaces in `@/types/api.ts` - Defined `ModelInfo`, `ModelMonitoringStats`, and related types ### Utility Functions - Enhanced color utilities with `modelToColor()` for consistent model identification - Improved formatters for quota, tokens, and percentage display - Maintained existing utility function architecture ### Architecture - Follows established patterns from dashboard components - Reuses existing HTTP client and authentication utilities - Consistent error handling and loading states ### Code Quality - All components pass linter checks - Proper import organization and formatting - Responsive design for mobile compatibility ## 🎯 User Experience ### Visual Design - Color-coded model indicators for quick identification - Success rate visualization with icons and colors - Clean table layout with proper spacing and typography - Skeleton loading states for smooth UX ### Functionality - Search models by name with instant filtering - Filter by business groups for organized viewing - Navigate through paginated results efficiently - Refresh data manually when needed ## 📋 Files Modified - `web/src/types/api.ts`: Added model monitoring type definitions - `web/src/features/dashboard/hooks/use-model-monitoring.ts`: Core data hook - `web/src/features/dashboard/components/model-monitoring-stats.tsx`: Stats cards - `web/src/features/dashboard/components/model-monitoring-table.tsx`: Data table - `web/src/features/dashboard/index.tsx`: Dashboard integration - Various formatting and import organization improvements This implementation provides a comprehensive solution for model monitoring that aligns with the existing codebase architecture while delivering powerful insights into model performance and usage patterns.
This commit is contained in:
@@ -1,6 +1,23 @@
|
||||
import React from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { ArrowRight, ChevronRight, Laptop, Moon, Sun } from 'lucide-react'
|
||||
import {
|
||||
ArrowRight,
|
||||
ChevronRight,
|
||||
Laptop,
|
||||
Moon,
|
||||
Sun,
|
||||
Search,
|
||||
Users,
|
||||
Key,
|
||||
Server,
|
||||
BarChart3,
|
||||
Settings,
|
||||
Database,
|
||||
Activity,
|
||||
FileText,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
import { getStoredUser } from '@/lib/auth'
|
||||
import { useSearch } from '@/context/search-provider'
|
||||
import { useTheme } from '@/context/theme-provider'
|
||||
import {
|
||||
@@ -19,6 +36,10 @@ export function CommandMenu() {
|
||||
const navigate = useNavigate()
|
||||
const { setTheme } = useTheme()
|
||||
const { open, setOpen } = useSearch()
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
|
||||
const user = getStoredUser()
|
||||
const isAdmin = user && (user as any).role >= 10
|
||||
|
||||
const runCommand = React.useCallback(
|
||||
(command: () => unknown) => {
|
||||
@@ -28,12 +49,94 @@ export function CommandMenu() {
|
||||
[setOpen]
|
||||
)
|
||||
|
||||
const quickActions = [
|
||||
{
|
||||
id: 'dashboard-search',
|
||||
title: 'Search Dashboard Data',
|
||||
description: 'Search usage data by date range and user',
|
||||
icon: <BarChart3 className='h-4 w-4' />,
|
||||
action: () => navigate({ to: '/dashboard', search: { tab: 'search' } }),
|
||||
},
|
||||
{
|
||||
id: 'user-search',
|
||||
title: 'Search Users',
|
||||
description: 'Find users by ID, username, email or group',
|
||||
icon: <Users className='h-4 w-4' />,
|
||||
action: () => navigate({ to: '/users', search: { filter: 'search' } }),
|
||||
adminOnly: false,
|
||||
},
|
||||
{
|
||||
id: 'token-search',
|
||||
title: 'Search API Tokens',
|
||||
description: 'Find and manage API tokens',
|
||||
icon: <Key className='h-4 w-4' />,
|
||||
action: () => navigate({ to: '/tokens', search: { filter: 'search' } }),
|
||||
},
|
||||
{
|
||||
id: 'channel-search',
|
||||
title: 'Search Channels',
|
||||
description: 'Find channels by name, model, or group',
|
||||
icon: <Server className='h-4 w-4' />,
|
||||
action: () => navigate({ to: '/channels', search: { filter: 'search' } }),
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: 'model-search',
|
||||
title: 'Search Models',
|
||||
description: 'Find models by name or vendor',
|
||||
icon: <Zap className='h-4 w-4' />,
|
||||
action: () => navigate({ to: '/models', search: { filter: 'search' } }),
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: 'logs-search',
|
||||
title: 'Search Usage Logs',
|
||||
description: 'Find API call logs and usage statistics',
|
||||
icon: <Activity className='h-4 w-4' />,
|
||||
action: () => navigate({ to: '/logs', search: { filter: 'search' } }),
|
||||
},
|
||||
]
|
||||
|
||||
const filteredActions = quickActions.filter(
|
||||
(action) =>
|
||||
action.adminOnly === undefined ||
|
||||
action.adminOnly === false ||
|
||||
(action.adminOnly && isAdmin)
|
||||
)
|
||||
|
||||
return (
|
||||
<CommandDialog modal open={open} onOpenChange={setOpen}>
|
||||
<CommandInput placeholder='Type a command or search...' />
|
||||
<CommandList>
|
||||
<ScrollArea type='hover' className='h-72 pe-1'>
|
||||
<ScrollArea type='hover' className='h-96 pe-1'>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
|
||||
{/* Quick Search Actions */}
|
||||
{filteredActions.length > 0 && (
|
||||
<CommandGroup heading='Quick Search'>
|
||||
{filteredActions.map((action) => (
|
||||
<CommandItem
|
||||
key={action.id}
|
||||
value={`${action.title} ${action.description}`}
|
||||
onSelect={() => runCommand(action.action)}
|
||||
>
|
||||
<div className='mr-3 flex h-4 w-4 items-center justify-center'>
|
||||
{action.icon}
|
||||
</div>
|
||||
<div className='flex flex-col'>
|
||||
<span className='font-medium'>{action.title}</span>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{action.description}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
<CommandSeparator />
|
||||
|
||||
{/* Navigation */}
|
||||
{sidebarData.navGroups.map((group) => (
|
||||
<CommandGroup key={group.title} heading={group.title}>
|
||||
{group.items.map((navItem, i) => {
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { z } from 'zod'
|
||||
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 { getStoredUser } from '@/lib/auth'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Calendar } from '@/components/ui/calendar'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} 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
|
||||
onSearch: (filters: DashboardFilters) => void
|
||||
currentFilters: DashboardFilters
|
||||
}
|
||||
|
||||
export function DashboardSearchDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSearch,
|
||||
currentFilters,
|
||||
}: DashboardSearchDialogProps) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const user = getStoredUser()
|
||||
const isAdmin = user && (user as any).role >= 10
|
||||
|
||||
const form = useForm<z.infer<typeof searchSchema>>({
|
||||
resolver: zodResolver(searchSchema),
|
||||
defaultValues: {
|
||||
startDate: new Date(currentFilters.startTimestamp * 1000),
|
||||
endDate: new Date(currentFilters.endTimestamp * 1000),
|
||||
username: currentFilters.username || '',
|
||||
timeGranularity: currentFilters.defaultTime || 'day',
|
||||
modelFilter: '',
|
||||
},
|
||||
})
|
||||
|
||||
const handleSearch = useCallback(
|
||||
async (values: z.infer<typeof searchSchema>) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const filters: DashboardFilters = {
|
||||
startTimestamp: Math.floor(values.startDate.getTime() / 1000),
|
||||
endTimestamp: Math.floor(values.endDate.getTime() / 1000),
|
||||
defaultTime: values.timeGranularity,
|
||||
username: values.username || undefined,
|
||||
}
|
||||
|
||||
onSearch(filters)
|
||||
onOpenChange(false)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
},
|
||||
[onSearch, onOpenChange]
|
||||
)
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
form.reset({
|
||||
startDate: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), // 7 days ago
|
||||
endDate: new Date(),
|
||||
username: '',
|
||||
timeGranularity: 'day',
|
||||
modelFilter: '',
|
||||
})
|
||||
}, [form])
|
||||
|
||||
const handleQuickTimeRange = useCallback(
|
||||
(days: number) => {
|
||||
const endDate = new Date()
|
||||
const startDate = new Date(Date.now() - days * 24 * 60 * 60 * 1000)
|
||||
form.setValue('startDate', startDate)
|
||||
form.setValue('endDate', endDate)
|
||||
},
|
||||
[form]
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='max-w-2xl'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Advanced Dashboard Search</DialogTitle>
|
||||
<DialogDescription>
|
||||
Search and filter your usage data with advanced criteria
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSearch)}
|
||||
className='space-y-6'
|
||||
>
|
||||
{/* Quick Time Range Buttons */}
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => handleQuickTimeRange(1)}
|
||||
>
|
||||
Last 24h
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => handleQuickTimeRange(7)}
|
||||
>
|
||||
Last 7 days
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => handleQuickTimeRange(30)}
|
||||
>
|
||||
Last 30 days
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => handleQuickTimeRange(90)}
|
||||
>
|
||||
Last 90 days
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Date Range */}
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='startDate'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Start Date</FormLabel>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant='outline'
|
||||
className={cn(
|
||||
'w-full pl-3 text-left font-normal',
|
||||
!field.value && 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{field.value ? (
|
||||
format(field.value, 'PPP')
|
||||
) : (
|
||||
<span>Pick a date</span>
|
||||
)}
|
||||
<CalendarIcon className='ml-auto h-4 w-4 opacity-50' />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-auto p-0' align='start'>
|
||||
<Calendar
|
||||
mode='single'
|
||||
selected={field.value}
|
||||
onSelect={field.onChange}
|
||||
disabled={(date) =>
|
||||
date > new Date() || date < new Date('1900-01-01')
|
||||
}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='endDate'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>End Date</FormLabel>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant='outline'
|
||||
className={cn(
|
||||
'w-full pl-3 text-left font-normal',
|
||||
!field.value && 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{field.value ? (
|
||||
format(field.value, 'PPP')
|
||||
) : (
|
||||
<span>Pick a date</span>
|
||||
)}
|
||||
<CalendarIcon className='ml-auto h-4 w-4 opacity-50' />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-auto p-0' align='start'>
|
||||
<Calendar
|
||||
mode='single'
|
||||
selected={field.value}
|
||||
onSelect={field.onChange}
|
||||
disabled={(date) =>
|
||||
date > new Date() || date < new Date('1900-01-01')
|
||||
}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Time Granularity */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='timeGranularity'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Time Granularity</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select time granularity' />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value='hour'>Hourly</SelectItem>
|
||||
<SelectItem value='day'>Daily</SelectItem>
|
||||
<SelectItem value='week'>Weekly</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Admin-only Username Filter */}
|
||||
{isAdmin && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='username'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Filter by Username (Admin)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='Enter username to filter (optional)'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Model Filter */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='modelFilter'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Model Filter</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='Filter by model name (optional)'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className='flex justify-between space-x-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={handleReset}
|
||||
disabled={loading}
|
||||
>
|
||||
<RotateCcw className='mr-2 h-4 w-4' />
|
||||
Reset
|
||||
</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>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
105
web/src/features/dashboard/components/model-monitoring-stats.tsx
Normal file
105
web/src/features/dashboard/components/model-monitoring-stats.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { ModelMonitoringStats } from '@/types/api'
|
||||
import { Activity, BarChart3, CheckCircle, Zap } from 'lucide-react'
|
||||
import { formatNumber } from '@/lib/formatters'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
interface ModelMonitoringStatsProps {
|
||||
stats: ModelMonitoringStats
|
||||
loading?: boolean
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
export function ModelMonitoringStats({
|
||||
stats,
|
||||
loading,
|
||||
error,
|
||||
}: ModelMonitoringStatsProps) {
|
||||
const cards = [
|
||||
{
|
||||
title: '模型总数',
|
||||
value: stats.total_models.toString(),
|
||||
description: '系统中的模型总数量',
|
||||
icon: <Zap className='text-muted-foreground h-4 w-4' />,
|
||||
trend: null,
|
||||
},
|
||||
{
|
||||
title: '活跃模型',
|
||||
value: stats.active_models.toString(),
|
||||
description: `${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: '调用总次数',
|
||||
value: formatNumber(stats.total_requests),
|
||||
description: '所有模型的调用总数',
|
||||
icon: <BarChart3 className='text-muted-foreground h-4 w-4' />,
|
||||
trend: null,
|
||||
},
|
||||
{
|
||||
title: '平均成功率',
|
||||
value: `${stats.avg_success_rate.toFixed(1)}%`,
|
||||
description: '所有模型的平均成功率',
|
||||
icon: <CheckCircle className='text-muted-foreground h-4 w-4' />,
|
||||
trend: null,
|
||||
},
|
||||
]
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className='grid gap-4 sm:grid-cols-2 lg:grid-cols-4'>
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
|
||||
<Skeleton className='h-4 w-1/2' />
|
||||
<Skeleton className='h-4 w-4 rounded-full' />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className='mb-2 h-8 w-3/4' />
|
||||
<Skeleton className='h-3 w-1/2' />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className='grid gap-4 sm:grid-cols-2 lg:grid-cols-4'>
|
||||
{cards.map((card, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
|
||||
<CardTitle className='text-sm font-medium'>
|
||||
{card.title}
|
||||
</CardTitle>
|
||||
{card.icon}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='text-2xl font-bold text-red-500'>Error</div>
|
||||
<p className='text-muted-foreground text-xs'>{error}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='grid gap-4 sm:grid-cols-2 lg:grid-cols-4'>
|
||||
{cards.map((card, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
|
||||
<CardTitle className='text-sm font-medium'>{card.title}</CardTitle>
|
||||
{card.icon}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='text-2xl font-bold'>{card.value}</div>
|
||||
<p className='text-muted-foreground text-xs'>{card.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
327
web/src/features/dashboard/components/model-monitoring-table.tsx
Normal file
327
web/src/features/dashboard/components/model-monitoring-table.tsx
Normal file
@@ -0,0 +1,327 @@
|
||||
import { useState } from 'react'
|
||||
import type { ModelInfo } from '@/types/api'
|
||||
import {
|
||||
Search,
|
||||
RefreshCcw,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
MoreHorizontal,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
} from 'lucide-react'
|
||||
import { stringToColor } from '@/lib/colors'
|
||||
import { formatQuota, formatNumber, formatTokens } from '@/lib/formatters'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
|
||||
interface ModelMonitoringTableProps {
|
||||
models: ModelInfo[]
|
||||
loading?: boolean
|
||||
error?: string | null
|
||||
searchTerm: string
|
||||
onSearchChange: (term: string) => void
|
||||
businessGroup: string
|
||||
onBusinessGroupChange: (group: string) => void
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
||||
const ITEMS_PER_PAGE = 10
|
||||
|
||||
export function ModelMonitoringTable({
|
||||
models,
|
||||
loading,
|
||||
error,
|
||||
searchTerm,
|
||||
onSearchChange,
|
||||
businessGroup,
|
||||
onBusinessGroupChange,
|
||||
onRefresh,
|
||||
}: ModelMonitoringTableProps) {
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
|
||||
// 分页逻辑
|
||||
const totalPages = Math.ceil(models.length / ITEMS_PER_PAGE)
|
||||
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE
|
||||
const endIndex = startIndex + ITEMS_PER_PAGE
|
||||
const currentModels = models.slice(startIndex, endIndex)
|
||||
|
||||
// 获取业务组列表
|
||||
const businessGroups = Array.from(
|
||||
new Set(models.map((m) => m.business_group))
|
||||
)
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page)
|
||||
}
|
||||
|
||||
const getSuccessRateColor = (rate: number) => {
|
||||
if (rate >= 95) return 'text-green-600'
|
||||
if (rate >= 90) return 'text-yellow-600'
|
||||
return 'text-red-600'
|
||||
}
|
||||
|
||||
const getSuccessRateIcon = (rate: number) => {
|
||||
if (rate >= 95) return <CheckCircle className='h-4 w-4 text-green-600' />
|
||||
if (rate >= 90) return <AlertCircle className='h-4 w-4 text-yellow-600' />
|
||||
return <AlertCircle className='h-4 w-4 text-red-600' />
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>模型列表</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='space-y-4'>
|
||||
{/* 搜索栏骨架 */}
|
||||
<div className='flex items-center space-x-2'>
|
||||
<Skeleton className='h-10 flex-1' />
|
||||
<Skeleton className='h-10 w-32' />
|
||||
<Skeleton className='h-10 w-10' />
|
||||
</div>
|
||||
{/* 表格骨架 */}
|
||||
<div className='space-y-2'>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className='h-12 w-full' />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>模型列表</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-muted-foreground mt-2'>{error}</p>
|
||||
<Button onClick={onRefresh} className='mt-4'>
|
||||
<RefreshCcw className='mr-2 h-4 w-4' />
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className='flex items-center justify-between'>
|
||||
<CardTitle>模型列表</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...'
|
||||
value={searchTerm}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className='w-64'
|
||||
/>
|
||||
</div>
|
||||
<Select value={businessGroup} onValueChange={onBusinessGroupChange}>
|
||||
<SelectTrigger className='w-40'>
|
||||
<SelectValue placeholder='业务空间' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='all'>全部空间</SelectItem>
|
||||
{businessGroups.map((group) => (
|
||||
<SelectItem key={group} value={group}>
|
||||
{group}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant='outline' size='icon' onClick={onRefresh}>
|
||||
<RefreshCcw className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{models.length === 0 ? (
|
||||
<div className='text-muted-foreground py-8 text-center'>
|
||||
<p>没有找到匹配的模型</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 数据表格 */}
|
||||
<div className='rounded-md border'>
|
||||
<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>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{currentModels.map((model) => (
|
||||
<TableRow key={model.id}>
|
||||
<TableCell className='font-medium'>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<div
|
||||
className='h-2 w-2 rounded-full'
|
||||
style={{
|
||||
backgroundColor: stringToColor(model.model_name),
|
||||
}}
|
||||
/>
|
||||
<span>{model.model_name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant='outline' className='font-mono text-xs'>
|
||||
{model.business_group}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<span className='font-mono'>
|
||||
{formatNumber(model.quota_used + model.quota_failed)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<span className='font-mono text-red-600'>
|
||||
{formatNumber(model.quota_failed)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<div className='flex items-center justify-end space-x-1'>
|
||||
{getSuccessRateIcon(model.success_rate)}
|
||||
<span
|
||||
className={`font-mono ${getSuccessRateColor(model.success_rate)}`}
|
||||
>
|
||||
{(100 - model.success_rate).toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<span className='font-mono text-green-600'>
|
||||
{formatQuota(model.avg_quota_per_request)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<span className='font-mono text-blue-600'>
|
||||
{formatTokens(model.avg_tokens_per_request)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='ghost' size='icon'>
|
||||
<MoreHorizontal className='h-4 w-4' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuItem>监控</DropdownMenuItem>
|
||||
<DropdownMenuItem>详情</DropdownMenuItem>
|
||||
<DropdownMenuItem>设置</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
{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} 条
|
||||
</div>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronLeft className='h-4 w-4' />
|
||||
上一页
|
||||
</Button>
|
||||
<div className='flex items-center space-x-1'>
|
||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
let pageNumber
|
||||
if (totalPages <= 5) {
|
||||
pageNumber = i + 1
|
||||
} else if (currentPage <= 3) {
|
||||
pageNumber = i + 1
|
||||
} else if (currentPage >= totalPages - 2) {
|
||||
pageNumber = totalPages - 4 + i
|
||||
} else {
|
||||
pageNumber = currentPage - 2 + i
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={pageNumber}
|
||||
variant={
|
||||
currentPage === pageNumber ? 'default' : 'outline'
|
||||
}
|
||||
size='sm'
|
||||
onClick={() => handlePageChange(pageNumber)}
|
||||
className='h-8 w-8 p-0'
|
||||
>
|
||||
{pageNumber}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
下一页
|
||||
<ChevronRight className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
215
web/src/features/dashboard/components/model-usage-chart.tsx
Normal file
215
web/src/features/dashboard/components/model-usage-chart.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { ModelUsageData } from '@/types/api'
|
||||
import {
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
Legend,
|
||||
} from 'recharts'
|
||||
import { modelToColor } from '@/lib/colors'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
interface ModelUsageChartProps {
|
||||
data: ModelUsageData[]
|
||||
loading?: boolean
|
||||
error?: string | null
|
||||
title?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
interface ChartDataPoint {
|
||||
name: string
|
||||
value: number
|
||||
quota: number
|
||||
tokens: number
|
||||
count: number
|
||||
percentage: number
|
||||
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',
|
||||
}: ModelUsageChartProps) {
|
||||
const chartData = useMemo((): ChartDataPoint[] => {
|
||||
if (!data || data.length === 0) return []
|
||||
|
||||
// 只取前12个模型,避免图表过于拥挤
|
||||
const topModels = data.slice(0, 12)
|
||||
|
||||
return topModels.map((item) => ({
|
||||
name: item.model,
|
||||
value: item.quota,
|
||||
quota: item.quota,
|
||||
tokens: item.tokens,
|
||||
count: item.count,
|
||||
percentage: item.percentage,
|
||||
color: modelToColor(item.model),
|
||||
}))
|
||||
}, [data])
|
||||
|
||||
const CustomTooltip = ({ active, payload }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload
|
||||
return (
|
||||
<div className='bg-background border-border min-w-[200px] rounded-lg border p-3 shadow-lg'>
|
||||
<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)}%)
|
||||
</p>
|
||||
<p className='text-blue-600'>
|
||||
Tokens: {formatValue(data.tokens, 'tokens')}
|
||||
</p>
|
||||
<p className='text-green-600'>
|
||||
Requests: {formatValue(data.count, 'count')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const CustomLegend = ({ payload }: any) => {
|
||||
if (!payload || payload.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className='mt-4 flex flex-wrap justify-center gap-2'>
|
||||
{payload.map((entry: any, index: number) => (
|
||||
<Badge
|
||||
key={index}
|
||||
variant='outline'
|
||||
className='text-xs'
|
||||
style={{ borderColor: entry.color }}
|
||||
>
|
||||
<div
|
||||
className='mr-1 h-2 w-2 rounded-full'
|
||||
style={{ backgroundColor: entry.color }}
|
||||
/>
|
||||
{entry.value}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
{description && <CardDescription>{description}</CardDescription>}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className='h-[350px] w-full' />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
{description && <CardDescription>{description}</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='mt-1 text-xs'>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (!chartData || chartData.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
{description && <CardDescription>{description}</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='mt-1 text-xs'>
|
||||
Start making API calls to see usage
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
{description && <CardDescription>{description}</CardDescription>}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width='100%' height={350}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={chartData}
|
||||
cx='50%'
|
||||
cy='50%'
|
||||
labelLine={false}
|
||||
outerRadius={100}
|
||||
fill='#8884d8'
|
||||
dataKey='value'
|
||||
label={({ percentage }) => `${percentage.toFixed(1)}%`}
|
||||
>
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend content={<CustomLegend />} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,79 +1,188 @@
|
||||
import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis } from 'recharts'
|
||||
import { useMemo } from 'react'
|
||||
import type { TrendDataPoint } from '@/types/api'
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
ResponsiveContainer,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
} from 'recharts'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
const data = [
|
||||
{
|
||||
name: 'Jan',
|
||||
total: Math.floor(Math.random() * 5000) + 1000,
|
||||
},
|
||||
{
|
||||
name: 'Feb',
|
||||
total: Math.floor(Math.random() * 5000) + 1000,
|
||||
},
|
||||
{
|
||||
name: 'Mar',
|
||||
total: Math.floor(Math.random() * 5000) + 1000,
|
||||
},
|
||||
{
|
||||
name: 'Apr',
|
||||
total: Math.floor(Math.random() * 5000) + 1000,
|
||||
},
|
||||
{
|
||||
name: 'May',
|
||||
total: Math.floor(Math.random() * 5000) + 1000,
|
||||
},
|
||||
{
|
||||
name: 'Jun',
|
||||
total: Math.floor(Math.random() * 5000) + 1000,
|
||||
},
|
||||
{
|
||||
name: 'Jul',
|
||||
total: Math.floor(Math.random() * 5000) + 1000,
|
||||
},
|
||||
{
|
||||
name: 'Aug',
|
||||
total: Math.floor(Math.random() * 5000) + 1000,
|
||||
},
|
||||
{
|
||||
name: 'Sep',
|
||||
total: Math.floor(Math.random() * 5000) + 1000,
|
||||
},
|
||||
{
|
||||
name: 'Oct',
|
||||
total: Math.floor(Math.random() * 5000) + 1000,
|
||||
},
|
||||
{
|
||||
name: 'Nov',
|
||||
total: Math.floor(Math.random() * 5000) + 1000,
|
||||
},
|
||||
{
|
||||
name: 'Dec',
|
||||
total: Math.floor(Math.random() * 5000) + 1000,
|
||||
},
|
||||
]
|
||||
interface OverviewProps {
|
||||
data: TrendDataPoint[]
|
||||
loading?: boolean
|
||||
error?: string | null
|
||||
title?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
interface ChartDataPoint {
|
||||
name: string
|
||||
quota: number
|
||||
tokens: number
|
||||
count: number
|
||||
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',
|
||||
}: OverviewProps) {
|
||||
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),
|
||||
quota: item.quota,
|
||||
tokens: item.tokens,
|
||||
count: item.count,
|
||||
timestamp: item.timestamp,
|
||||
}))
|
||||
}, [data])
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload
|
||||
return (
|
||||
<div className='bg-background border-border rounded-lg border p-3 shadow-lg'>
|
||||
<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')}
|
||||
</p>
|
||||
<p className='text-blue-600'>
|
||||
Tokens: {formatValue(data.tokens, 'tokens')}
|
||||
</p>
|
||||
<p className='text-green-600'>
|
||||
Requests: {formatValue(data.count, 'count')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
{description && <CardDescription>{description}</CardDescription>}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className='h-[350px] w-full' />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
{description && <CardDescription>{description}</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='mt-1 text-xs'>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (!chartData || chartData.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
{description && <CardDescription>{description}</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>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function Overview() {
|
||||
return (
|
||||
<ResponsiveContainer width='100%' height={350}>
|
||||
<BarChart data={data}>
|
||||
<BarChart
|
||||
data={chartData}
|
||||
margin={{ top: 20, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<XAxis
|
||||
dataKey='name'
|
||||
stroke='#888888'
|
||||
stroke='hsl(var(--muted-foreground))'
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
stroke='#888888'
|
||||
stroke='hsl(var(--muted-foreground))'
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => `$${value}`}
|
||||
tickFormatter={(value) => formatValue(value, 'quota')}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Bar
|
||||
dataKey='total'
|
||||
fill='currentColor'
|
||||
dataKey='quota'
|
||||
fill='hsl(var(--primary))'
|
||||
radius={[4, 4, 0, 0]}
|
||||
className='fill-primary'
|
||||
name='Quota'
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
201
web/src/features/dashboard/components/stats-cards.tsx
Normal file
201
web/src/features/dashboard/components/stats-cards.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import type { DashboardStats } from '@/types/api'
|
||||
import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
DollarSign,
|
||||
Activity,
|
||||
Users,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
interface StatsCardsProps {
|
||||
stats: DashboardStats
|
||||
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,
|
||||
loading = false,
|
||||
error = null,
|
||||
className,
|
||||
}: StatsCardsProps) {
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
className={`grid gap-4 md:grid-cols-2 lg:grid-cols-4 ${className || ''}`}
|
||||
>
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<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='mt-1 text-xs'>{error}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const cards = [
|
||||
{
|
||||
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: '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: '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: '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',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
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}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
208
web/src/features/dashboard/hooks/use-dashboard-data.ts
Normal file
208
web/src/features/dashboard/hooks/use-dashboard-data.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import type {
|
||||
QuotaDataResponse,
|
||||
QuotaDataItem,
|
||||
DashboardStats,
|
||||
TrendDataPoint,
|
||||
ModelUsageData,
|
||||
} from '@/types/api'
|
||||
import { getStoredUser } from '@/lib/auth'
|
||||
import { get } from '@/lib/http'
|
||||
|
||||
export interface DashboardFilters {
|
||||
startTimestamp: number
|
||||
endTimestamp: number
|
||||
username?: string
|
||||
defaultTime?: 'hour' | 'day' | 'week'
|
||||
}
|
||||
|
||||
export interface ProcessedDashboardData {
|
||||
stats: DashboardStats
|
||||
trendData: TrendDataPoint[]
|
||||
modelUsage: ModelUsageData[]
|
||||
rawData: QuotaDataItem[]
|
||||
}
|
||||
|
||||
const DEFAULT_FILTERS: DashboardFilters = {
|
||||
startTimestamp: Math.floor((Date.now() - 7 * 24 * 60 * 60 * 1000) / 1000), // 7 days ago
|
||||
endTimestamp: Math.floor(Date.now() / 1000),
|
||||
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 {
|
||||
stats: {
|
||||
totalQuota: 0,
|
||||
totalTokens: 0,
|
||||
totalRequests: 0,
|
||||
avgQuotaPerRequest: 0,
|
||||
},
|
||||
trendData: [],
|
||||
modelUsage: [],
|
||||
rawData: [],
|
||||
}
|
||||
}
|
||||
|
||||
// 计算总统计
|
||||
const totalQuota = data.reduce((sum, item) => sum + (item.quota || 0), 0)
|
||||
const totalTokens = data.reduce((sum, item) => sum + (item.tokens || 0), 0)
|
||||
const totalRequests = data.reduce((sum, item) => sum + (item.count || 0), 0)
|
||||
const avgQuotaPerRequest = totalRequests > 0 ? totalQuota / totalRequests : 0
|
||||
|
||||
const stats: DashboardStats = {
|
||||
totalQuota,
|
||||
totalTokens,
|
||||
totalRequests,
|
||||
avgQuotaPerRequest,
|
||||
}
|
||||
|
||||
// 按时间聚合趋势数据
|
||||
const timeAggregation = new Map<
|
||||
number,
|
||||
{ quota: number; tokens: number; count: number }
|
||||
>()
|
||||
|
||||
data.forEach((item) => {
|
||||
// 按小时/天/周聚合(这里简化为按天)
|
||||
const dayTimestamp = Math.floor(item.created_at / 86400) * 86400
|
||||
const existing = timeAggregation.get(dayTimestamp) || {
|
||||
quota: 0,
|
||||
tokens: 0,
|
||||
count: 0,
|
||||
}
|
||||
timeAggregation.set(dayTimestamp, {
|
||||
quota: existing.quota + (item.quota || 0),
|
||||
tokens: existing.tokens + (item.tokens || 0),
|
||||
count: existing.count + (item.count || 0),
|
||||
})
|
||||
})
|
||||
|
||||
const trendData: TrendDataPoint[] = Array.from(timeAggregation.entries())
|
||||
.map(([timestamp, data]) => ({
|
||||
timestamp,
|
||||
...data,
|
||||
}))
|
||||
.sort((a, b) => a.timestamp - b.timestamp)
|
||||
|
||||
// 按模型聚合使用分布
|
||||
const modelAggregation = new Map<
|
||||
string,
|
||||
{ quota: number; tokens: number; count: number }
|
||||
>()
|
||||
|
||||
data.forEach((item) => {
|
||||
const model = item.model_name || 'unknown'
|
||||
const existing = modelAggregation.get(model) || {
|
||||
quota: 0,
|
||||
tokens: 0,
|
||||
count: 0,
|
||||
}
|
||||
modelAggregation.set(model, {
|
||||
quota: existing.quota + (item.quota || 0),
|
||||
tokens: existing.tokens + (item.tokens || 0),
|
||||
count: existing.count + (item.count || 0),
|
||||
})
|
||||
})
|
||||
|
||||
const modelUsage: ModelUsageData[] = Array.from(modelAggregation.entries())
|
||||
.map(([model, data]) => ({
|
||||
model,
|
||||
...data,
|
||||
percentage: totalQuota > 0 ? (data.quota / totalQuota) * 100 : 0,
|
||||
}))
|
||||
.sort((a, b) => b.quota - a.quota)
|
||||
|
||||
return {
|
||||
stats,
|
||||
trendData,
|
||||
modelUsage,
|
||||
rawData: data,
|
||||
}
|
||||
}
|
||||
|
||||
export function useDashboardData() {
|
||||
const [data, setData] = useState<ProcessedDashboardData>({
|
||||
stats: {
|
||||
totalQuota: 0,
|
||||
totalTokens: 0,
|
||||
totalRequests: 0,
|
||||
avgQuotaPerRequest: 0,
|
||||
},
|
||||
trendData: [],
|
||||
modelUsage: [],
|
||||
rawData: [],
|
||||
})
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [filters, setFilters] = useState<DashboardFilters>(DEFAULT_FILTERS)
|
||||
|
||||
const fetchData = useCallback(
|
||||
async (customFilters?: Partial<DashboardFilters>) => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const currentFilters = { ...filters, ...customFilters }
|
||||
const admin = isAdmin()
|
||||
|
||||
let url: string
|
||||
if (admin) {
|
||||
url = `/api/data/?start_timestamp=${currentFilters.startTimestamp}&end_timestamp=${currentFilters.endTimestamp}&default_time=${currentFilters.defaultTime || 'day'}`
|
||||
if (currentFilters.username) {
|
||||
url += `&username=${encodeURIComponent(currentFilters.username)}`
|
||||
}
|
||||
} else {
|
||||
url = `/api/data/self?start_timestamp=${currentFilters.startTimestamp}&end_timestamp=${currentFilters.endTimestamp}&default_time=${currentFilters.defaultTime || 'day'}`
|
||||
}
|
||||
|
||||
const response = await get<QuotaDataResponse>(url)
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to fetch dashboard data')
|
||||
}
|
||||
|
||||
const processedData = processQuotaData(response.data || [])
|
||||
setData(processedData)
|
||||
setFilters(currentFilters)
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : 'An unknown error occurred'
|
||||
setError(message)
|
||||
console.error('Dashboard data fetch error:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
},
|
||||
[filters]
|
||||
)
|
||||
|
||||
const updateFilters = useCallback((newFilters: Partial<DashboardFilters>) => {
|
||||
setFilters((prev) => ({ ...prev, ...newFilters }))
|
||||
}, [])
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
fetchData()
|
||||
}, [fetchData])
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
return {
|
||||
data,
|
||||
loading,
|
||||
error,
|
||||
filters,
|
||||
updateFilters,
|
||||
fetchData,
|
||||
refresh,
|
||||
isAdmin: isAdmin(),
|
||||
}
|
||||
}
|
||||
213
web/src/features/dashboard/hooks/use-model-monitoring.ts
Normal file
213
web/src/features/dashboard/hooks/use-model-monitoring.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
ApiResponse,
|
||||
ModelMonitoringData,
|
||||
ModelMonitoringStats,
|
||||
ModelInfo,
|
||||
QuotaDataItem,
|
||||
} from '@/types/api'
|
||||
import { toast } from 'sonner'
|
||||
import { getStoredUser } from '@/lib/auth'
|
||||
import { get } from '@/lib/http'
|
||||
|
||||
export interface ModelMonitoringFilters {
|
||||
startTimestamp: number
|
||||
endTimestamp: number
|
||||
businessGroup?: string
|
||||
searchTerm?: string
|
||||
}
|
||||
|
||||
const initialFilters: ModelMonitoringFilters = {
|
||||
startTimestamp: Math.floor((Date.now() - 7 * 24 * 60 * 60 * 1000) / 1000), // 7 days ago
|
||||
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) {
|
||||
return {
|
||||
stats: {
|
||||
total_models: 0,
|
||||
active_models: 0,
|
||||
total_requests: 0,
|
||||
avg_success_rate: 0,
|
||||
},
|
||||
models: [],
|
||||
}
|
||||
}
|
||||
|
||||
// 按模型分组统计
|
||||
const modelMap = new Map<
|
||||
string,
|
||||
{
|
||||
quota_used: number
|
||||
quota_failed: number
|
||||
total_requests: number
|
||||
total_tokens: number
|
||||
}
|
||||
>()
|
||||
|
||||
let totalRequests = 0
|
||||
|
||||
data.forEach((item) => {
|
||||
const modelName = item.model_name
|
||||
const current = modelMap.get(modelName) || {
|
||||
quota_used: 0,
|
||||
quota_failed: 0,
|
||||
total_requests: 0,
|
||||
total_tokens: 0,
|
||||
}
|
||||
|
||||
current.quota_used += item.quota
|
||||
current.total_requests += item.count
|
||||
current.total_tokens += item.tokens || 0
|
||||
// 假设失败数据在某个字段中,这里用示例数据
|
||||
// current.quota_failed += item.failed_quota || 0
|
||||
|
||||
modelMap.set(modelName, current)
|
||||
totalRequests += item.count
|
||||
})
|
||||
|
||||
// 生成模型列表
|
||||
const models: ModelInfo[] = Array.from(modelMap.entries())
|
||||
.map(([modelName, stats], index) => {
|
||||
const successRate =
|
||||
stats.total_requests > 0
|
||||
? ((stats.total_requests - (stats.quota_failed || 0)) /
|
||||
stats.total_requests) *
|
||||
100
|
||||
: 0
|
||||
|
||||
return {
|
||||
id: index + 1,
|
||||
model_name: modelName,
|
||||
business_group: '默认业务空间', // 示例数据
|
||||
quota_used: stats.quota_used,
|
||||
quota_failed: stats.quota_failed || 0,
|
||||
success_rate: successRate,
|
||||
avg_quota_per_request:
|
||||
stats.total_requests > 0
|
||||
? stats.quota_used / stats.total_requests
|
||||
: 0,
|
||||
avg_tokens_per_request:
|
||||
stats.total_requests > 0
|
||||
? stats.total_tokens / stats.total_requests
|
||||
: 0,
|
||||
operations: ['监控'],
|
||||
}
|
||||
})
|
||||
.sort((a, b) => b.quota_used - a.quota_used) // 按使用量排序
|
||||
|
||||
// 生成统计数据
|
||||
const activeModels = models.filter((m) => m.quota_used > 0).length
|
||||
const avgSuccessRate =
|
||||
models.length > 0
|
||||
? models.reduce((sum, m) => sum + m.success_rate, 0) / models.length
|
||||
: 0
|
||||
|
||||
const stats: ModelMonitoringStats = {
|
||||
total_models: models.length,
|
||||
active_models: activeModels,
|
||||
total_requests: totalRequests,
|
||||
avg_success_rate: avgSuccessRate,
|
||||
}
|
||||
|
||||
return { stats, models }
|
||||
}
|
||||
|
||||
export function useModelMonitoring() {
|
||||
const [data, setData] = useState<ModelMonitoringData>({
|
||||
stats: {
|
||||
total_models: 0,
|
||||
active_models: 0,
|
||||
total_requests: 0,
|
||||
avg_success_rate: 0,
|
||||
},
|
||||
models: [],
|
||||
})
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [filters, setFilters] = useState<ModelMonitoringFilters>(initialFilters)
|
||||
|
||||
const currentIsAdmin = isAdmin()
|
||||
|
||||
const fetchData = useCallback(
|
||||
async (currentFilters: ModelMonitoringFilters) => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const { startTimestamp, endTimestamp } = currentFilters
|
||||
const params = new URLSearchParams()
|
||||
params.append('start_timestamp', String(startTimestamp))
|
||||
params.append('end_timestamp', String(endTimestamp))
|
||||
params.append('default_time', 'day')
|
||||
|
||||
// 使用现有的数据接口
|
||||
const url = currentIsAdmin
|
||||
? `/api/data/?${params.toString()}`
|
||||
: `/api/data/self?${params.toString()}`
|
||||
|
||||
const res = await get<ApiResponse<QuotaDataItem[]>>(url)
|
||||
|
||||
if (res.success) {
|
||||
const processedData = processModelData(res.data || [])
|
||||
setData(processedData)
|
||||
} else {
|
||||
setError(res.message || 'Failed to fetch model monitoring data')
|
||||
toast.error(res.message || 'Failed to fetch model monitoring data')
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'An unexpected error occurred')
|
||||
toast.error(err.message || 'An unexpected error occurred')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
},
|
||||
[currentIsAdmin]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
fetchData(filters)
|
||||
}, [filters, fetchData])
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
fetchData(filters)
|
||||
}, [fetchData, filters])
|
||||
|
||||
const updateFilters = useCallback(
|
||||
(newFilters: Partial<ModelMonitoringFilters>) => {
|
||||
setFilters((prev) => ({ ...prev, ...newFilters }))
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
// 根据搜索词和业务组过滤模型
|
||||
const filteredModels = data.models.filter((model) => {
|
||||
const searchMatch =
|
||||
!filters.searchTerm ||
|
||||
model.model_name.toLowerCase().includes(filters.searchTerm.toLowerCase())
|
||||
|
||||
const groupMatch =
|
||||
!filters.businessGroup ||
|
||||
filters.businessGroup === 'all' ||
|
||||
model.business_group === filters.businessGroup
|
||||
|
||||
return searchMatch && groupMatch
|
||||
})
|
||||
|
||||
return {
|
||||
data: { ...data, models: filteredModels },
|
||||
originalData: data,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
updateFilters,
|
||||
filters,
|
||||
isAdmin: currentIsAdmin,
|
||||
}
|
||||
}
|
||||
96
web/src/features/dashboard/hooks/use-user-stats.ts
Normal file
96
web/src/features/dashboard/hooks/use-user-stats.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import type { SelfResponse, UserSelf } from '@/types/api'
|
||||
import { getStoredUser } from '@/lib/auth'
|
||||
import { get } from '@/lib/http'
|
||||
|
||||
export interface UserStatsData {
|
||||
user: UserSelf | null
|
||||
quotaUsagePercentage: number
|
||||
requestsThisMonth: number
|
||||
balanceFormatted: string
|
||||
isLoading: boolean
|
||||
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,
|
||||
quotaUsagePercentage: 0,
|
||||
requestsThisMonth: 0,
|
||||
balanceFormatted: '$0.00',
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const fetchUserStats = useCallback(async () => {
|
||||
setData((prev) => ({ ...prev, isLoading: true, error: null }))
|
||||
|
||||
try {
|
||||
// 优先从本地存储获取基本用户信息
|
||||
const storedUser = getStoredUser()
|
||||
if (!storedUser) {
|
||||
throw new Error('User not logged in')
|
||||
}
|
||||
|
||||
// 获取最新的用户详细信息
|
||||
const response = await get<SelfResponse>('/api/user/self')
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
throw new Error(response.message || 'Failed to fetch user stats')
|
||||
}
|
||||
|
||||
const user = response.data
|
||||
const quota = user.quota || 0
|
||||
const usedQuota = user.used_quota || 0
|
||||
const requestCount = user.request_count || 0
|
||||
|
||||
setData({
|
||||
user,
|
||||
quotaUsagePercentage: calculateUsagePercentage(quota, usedQuota),
|
||||
requestsThisMonth: requestCount,
|
||||
balanceFormatted: formatBalance(quota, usedQuota),
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : 'An unknown error occurred'
|
||||
setData((prev) => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: message,
|
||||
}))
|
||||
console.error('User stats fetch error:', err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
fetchUserStats()
|
||||
}, [fetchUserStats])
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
fetchUserStats()
|
||||
}, [fetchUserStats])
|
||||
|
||||
return {
|
||||
...data,
|
||||
refresh,
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,10 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { format } from 'date-fns'
|
||||
import { CalendarIcon, DownloadIcon, RefreshCcw, Search } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Calendar } from '@/components/ui/calendar'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -6,25 +12,104 @@ 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 { Header } from '@/components/layout/header'
|
||||
import { Main } from '@/components/layout/main'
|
||||
import { TopNav } from '@/components/layout/top-nav'
|
||||
import { ProfileDropdown } from '@/components/profile-dropdown'
|
||||
import { Search } from '@/components/search'
|
||||
import { Search as GlobalSearch } from '@/components/search'
|
||||
import { ThemeSwitch } from '@/components/theme-switch'
|
||||
import { DashboardSearchDialog } from './components/dashboard-search-dialog'
|
||||
import { ModelMonitoringStats } from './components/model-monitoring-stats'
|
||||
import { ModelMonitoringTable } from './components/model-monitoring-table'
|
||||
import { ModelUsageChart } from './components/model-usage-chart'
|
||||
import { Overview } from './components/overview'
|
||||
import { RecentSales } from './components/recent-sales'
|
||||
import { StatsCards } from './components/stats-cards'
|
||||
import { useDashboardData } from './hooks/use-dashboard-data'
|
||||
import { useModelMonitoring } from './hooks/use-model-monitoring'
|
||||
import { useUserStats } from './hooks/use-user-stats'
|
||||
|
||||
export function Dashboard() {
|
||||
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 {
|
||||
data: dashboardData,
|
||||
loading: dashboardLoading,
|
||||
error: dashboardError,
|
||||
refresh: refreshDashboard,
|
||||
fetchData,
|
||||
filters,
|
||||
isAdmin,
|
||||
} = useDashboardData()
|
||||
|
||||
const { user } = useUserStats()
|
||||
|
||||
// 模型监控数据
|
||||
const {
|
||||
data: modelMonitoringData,
|
||||
loading: modelMonitoringLoading,
|
||||
error: modelMonitoringError,
|
||||
refresh: refreshModelMonitoring,
|
||||
updateFilters: updateModelMonitoringFilters,
|
||||
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')
|
||||
}, [])
|
||||
|
||||
const handleAdvancedSearch = useCallback(
|
||||
(newFilters: any) => {
|
||||
fetchData(newFilters)
|
||||
toast.success('Search updated')
|
||||
},
|
||||
[fetchData]
|
||||
)
|
||||
|
||||
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 ===== */}
|
||||
<Header>
|
||||
<TopNav links={topNav} />
|
||||
<GlobalSearch />
|
||||
<div className='ms-auto flex items-center space-x-4'>
|
||||
<Search />
|
||||
<ThemeSwitch />
|
||||
<ConfigDrawer />
|
||||
<ProfileDropdown />
|
||||
@@ -34,185 +119,223 @@ export function Dashboard() {
|
||||
{/* ===== Main ===== */}
|
||||
<Main>
|
||||
<div className='mb-2 flex items-center justify-between space-y-2'>
|
||||
<h1 className='text-2xl font-bold tracking-tight'>Dashboard</h1>
|
||||
<div>
|
||||
<h1 className='text-2xl font-bold tracking-tight'>Dashboard</h1>
|
||||
<p className='text-muted-foreground'>
|
||||
{user
|
||||
? `Welcome back, ${user.display_name || user.username}`
|
||||
: 'Overview of your API usage'}
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<Button>Download</Button>
|
||||
<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' />
|
||||
Advanced Search
|
||||
</Button>
|
||||
<Button onClick={handleExport}>
|
||||
<DownloadIcon className='mr-2 h-4 w-4' />
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Tabs
|
||||
orientation='vertical'
|
||||
defaultValue='overview'
|
||||
className='space-y-4'
|
||||
>
|
||||
<div className='w-full overflow-x-auto pb-2'>
|
||||
<TabsList>
|
||||
<TabsTrigger value='overview'>Overview</TabsTrigger>
|
||||
<TabsTrigger value='analytics' disabled>
|
||||
Analytics
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value='reports' disabled>
|
||||
Reports
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value='notifications' disabled>
|
||||
Notifications
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue='overview' className='space-y-4'>
|
||||
<TabsList>
|
||||
<TabsTrigger value='overview'>Overview</TabsTrigger>
|
||||
<TabsTrigger value='analytics'>Analytics</TabsTrigger>
|
||||
<TabsTrigger value='models'>Models</TabsTrigger>
|
||||
<TabsTrigger value='monitoring'>模型观测</TabsTrigger>
|
||||
{isAdmin && <TabsTrigger value='admin'>Admin</TabsTrigger>}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='overview' className='space-y-4'>
|
||||
<div className='grid gap-4 sm:grid-cols-2 lg:grid-cols-4'>
|
||||
<Card>
|
||||
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
|
||||
<CardTitle className='text-sm font-medium'>
|
||||
Total Revenue
|
||||
</CardTitle>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth='2'
|
||||
className='text-muted-foreground h-4 w-4'
|
||||
>
|
||||
<path d='M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6' />
|
||||
</svg>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='text-2xl font-bold'>$45,231.89</div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
+20.1% from last month
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
|
||||
<CardTitle className='text-sm font-medium'>
|
||||
Subscriptions
|
||||
</CardTitle>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth='2'
|
||||
className='text-muted-foreground h-4 w-4'
|
||||
>
|
||||
<path d='M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2' />
|
||||
<circle cx='9' cy='7' r='4' />
|
||||
<path d='M22 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75' />
|
||||
</svg>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='text-2xl font-bold'>+2350</div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
+180.1% from last month
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
|
||||
<CardTitle className='text-sm font-medium'>Sales</CardTitle>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth='2'
|
||||
className='text-muted-foreground h-4 w-4'
|
||||
>
|
||||
<rect width='20' height='14' x='2' y='5' rx='2' />
|
||||
<path d='M2 10h20' />
|
||||
</svg>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='text-2xl font-bold'>+12,234</div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
+19% from last month
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
|
||||
<CardTitle className='text-sm font-medium'>
|
||||
Active Now
|
||||
</CardTitle>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth='2'
|
||||
className='text-muted-foreground h-4 w-4'
|
||||
>
|
||||
<path d='M22 12h-4l-3 9L9 3l-3 9H2' />
|
||||
</svg>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='text-2xl font-bold'>+573</div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
+201 since last hour
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
{/* Stats Cards */}
|
||||
<StatsCards
|
||||
stats={dashboardData.stats}
|
||||
loading={dashboardLoading}
|
||||
error={dashboardError}
|
||||
/>
|
||||
|
||||
{/* Charts Grid */}
|
||||
<div className='grid grid-cols-1 gap-4 lg:grid-cols-7'>
|
||||
<Card className='col-span-1 lg:col-span-4'>
|
||||
<div className='col-span-1 lg:col-span-4'>
|
||||
<Overview
|
||||
data={dashboardData.trendData}
|
||||
loading={dashboardLoading}
|
||||
error={dashboardError}
|
||||
/>
|
||||
</div>
|
||||
<div className='col-span-1 lg:col-span-3'>
|
||||
<ModelUsageChart
|
||||
data={dashboardData.modelUsage}
|
||||
loading={dashboardLoading}
|
||||
error={dashboardError}
|
||||
/>
|
||||
</div>
|
||||
</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'>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>Overview</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='ps-2'>
|
||||
<Overview />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className='col-span-1 lg:col-span-3'>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Sales</CardTitle>
|
||||
<CardTitle>Model Usage Details</CardTitle>
|
||||
<CardDescription>
|
||||
You made 265 sales this month.
|
||||
Detailed statistics for each model
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RecentSales />
|
||||
{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}
|
||||
/>
|
||||
|
||||
{/* 模型监控表格 */}
|
||||
<ModelMonitoringTable
|
||||
models={modelMonitoringData.models}
|
||||
loading={modelMonitoringLoading}
|
||||
error={modelMonitoringError}
|
||||
searchTerm={modelMonitoringFilters.searchTerm || ''}
|
||||
onSearchChange={(term) =>
|
||||
updateModelMonitoringFilters({ searchTerm: term })
|
||||
}
|
||||
businessGroup={modelMonitoringFilters.businessGroup || 'all'}
|
||||
onBusinessGroupChange={(group) =>
|
||||
updateModelMonitoringFilters({ businessGroup: group })
|
||||
}
|
||||
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>
|
||||
|
||||
{/* Advanced Search Dialog */}
|
||||
<DashboardSearchDialog
|
||||
open={searchDialogOpen}
|
||||
onOpenChange={setSearchDialogOpen}
|
||||
onSearch={handleAdvancedSearch}
|
||||
currentFilters={filters}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const topNav = [
|
||||
{
|
||||
title: 'Overview',
|
||||
href: 'dashboard/overview',
|
||||
isActive: true,
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
title: 'Customers',
|
||||
href: 'dashboard/customers',
|
||||
isActive: false,
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
title: 'Products',
|
||||
href: 'dashboard/products',
|
||||
isActive: false,
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
title: 'Settings',
|
||||
href: 'dashboard/settings',
|
||||
isActive: false,
|
||||
disabled: true,
|
||||
},
|
||||
]
|
||||
|
||||
138
web/src/lib/clipboard.ts
Normal file
138
web/src/lib/clipboard.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 剪贴板操作工具函数
|
||||
*/
|
||||
|
||||
/**
|
||||
* 复制文本到剪贴板
|
||||
* @param text 要复制的文本
|
||||
* @returns Promise<boolean> 是否复制成功
|
||||
*/
|
||||
export async function copy(text: string): Promise<boolean> {
|
||||
try {
|
||||
// 首先尝试使用现代API
|
||||
await navigator.clipboard.writeText(text)
|
||||
return true
|
||||
} catch (e) {
|
||||
// 降级到旧方法
|
||||
try {
|
||||
const input = document.createElement('input')
|
||||
input.value = text
|
||||
document.body.appendChild(input)
|
||||
input.select()
|
||||
const result = document.execCommand('copy')
|
||||
document.body.removeChild(input)
|
||||
return result
|
||||
} catch (e) {
|
||||
console.error('Failed to copy text:', e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从剪贴板读取文本
|
||||
* @returns Promise<string> 剪贴板中的文本
|
||||
*/
|
||||
export async function paste(): Promise<string> {
|
||||
try {
|
||||
if (navigator.clipboard && navigator.clipboard.readText) {
|
||||
return await navigator.clipboard.readText()
|
||||
}
|
||||
throw new Error('Clipboard API not supported')
|
||||
} catch (e) {
|
||||
console.error('Failed to read from clipboard:', e)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否支持剪贴板API
|
||||
* @returns 是否支持
|
||||
*/
|
||||
export function isClipboardSupported(): boolean {
|
||||
return !!(navigator.clipboard && navigator.clipboard.writeText)
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制JSON对象到剪贴板
|
||||
* @param obj 要复制的对象
|
||||
* @param pretty 是否格式化JSON
|
||||
* @returns Promise<boolean> 是否复制成功
|
||||
*/
|
||||
export async function copyJSON(
|
||||
obj: any,
|
||||
pretty: boolean = true
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const jsonString = pretty
|
||||
? JSON.stringify(obj, null, 2)
|
||||
: JSON.stringify(obj)
|
||||
return await copy(jsonString)
|
||||
} catch (e) {
|
||||
console.error('Failed to copy JSON:', e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制表格数据为CSV格式
|
||||
* @param data 表格数据
|
||||
* @param headers 表头
|
||||
* @returns Promise<boolean> 是否复制成功
|
||||
*/
|
||||
export async function copyAsCSV(
|
||||
data: any[],
|
||||
headers?: string[]
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
let csvContent = ''
|
||||
|
||||
// 添加表头
|
||||
if (headers) {
|
||||
csvContent += headers.join(',') + '\n'
|
||||
}
|
||||
|
||||
// 添加数据行
|
||||
data.forEach((row) => {
|
||||
const values = Object.values(row).map((value) =>
|
||||
typeof value === 'string' && value.includes(',')
|
||||
? `"${value}"`
|
||||
: String(value)
|
||||
)
|
||||
csvContent += values.join(',') + '\n'
|
||||
})
|
||||
|
||||
return await copy(csvContent)
|
||||
} catch (e) {
|
||||
console.error('Failed to copy CSV:', e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制链接地址
|
||||
* @param url 链接地址
|
||||
* @param title 可选的标题
|
||||
* @returns Promise<boolean> 是否复制成功
|
||||
*/
|
||||
export async function copyLink(url: string, title?: string): Promise<boolean> {
|
||||
const text = title ? `[${title}](${url})` : url
|
||||
return await copy(text)
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制代码块
|
||||
* @param code 代码内容
|
||||
* @param language 编程语言
|
||||
* @returns Promise<boolean> 是否复制成功
|
||||
*/
|
||||
export async function copyCode(
|
||||
code: string,
|
||||
language?: string
|
||||
): Promise<boolean> {
|
||||
let text = code
|
||||
if (language) {
|
||||
text = `\`\`\`${language}\n${code}\n\`\`\``
|
||||
}
|
||||
return await copy(text)
|
||||
}
|
||||
206
web/src/lib/colors.ts
Normal file
206
web/src/lib/colors.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* 颜色相关工具函数
|
||||
* 包括字符串转颜色、模型颜色映射等
|
||||
*/
|
||||
|
||||
// 基础颜色调色板
|
||||
const baseColors = [
|
||||
'rgb(255,99,132)', // 红色
|
||||
'rgb(54,162,235)', // 蓝色
|
||||
'rgb(255,205,86)', // 黄色
|
||||
'rgb(75,192,192)', // 青色
|
||||
'rgb(153,102,255)', // 紫色
|
||||
'rgb(255,159,64)', // 橙色
|
||||
'rgb(199,199,199)', // 灰色
|
||||
'rgb(83,102,255)', // 靛色
|
||||
]
|
||||
|
||||
// 扩展颜色调色板
|
||||
const extendedColors = [
|
||||
...baseColors,
|
||||
'rgb(255,192,203)', // 粉红色
|
||||
'rgb(255,160,122)', // 浅珊瑚色
|
||||
'rgb(219,112,147)', // 苍紫罗兰色
|
||||
'rgb(255,105,180)', // 热粉色
|
||||
'rgb(255,182,193)', // 浅粉红
|
||||
'rgb(255,140,0)', // 深橙色
|
||||
'rgb(255,165,0)', // 橙色
|
||||
'rgb(255,215,0)', // 金色
|
||||
'rgb(245,245,220)', // 米色
|
||||
'rgb(65,105,225)', // 皇家蓝
|
||||
'rgb(25,25,112)', // 午夜蓝
|
||||
]
|
||||
|
||||
// Semi UI 标准颜色
|
||||
const semiColors = [
|
||||
'amber',
|
||||
'blue',
|
||||
'cyan',
|
||||
'green',
|
||||
'grey',
|
||||
'indigo',
|
||||
'light-blue',
|
||||
'lime',
|
||||
'orange',
|
||||
'pink',
|
||||
'purple',
|
||||
'red',
|
||||
'teal',
|
||||
'violet',
|
||||
'yellow',
|
||||
]
|
||||
|
||||
// 预定义模型颜色映射
|
||||
const modelColorMap: Record<string, string> = {
|
||||
'gpt-3.5-turbo': 'rgb(16,163,127)',
|
||||
'gpt-3.5-turbo-0125': 'rgb(16,163,127)',
|
||||
'gpt-3.5-turbo-0301': 'rgb(16,163,127)',
|
||||
'gpt-3.5-turbo-0613': 'rgb(16,163,127)',
|
||||
'gpt-3.5-turbo-1106': 'rgb(16,163,127)',
|
||||
'gpt-3.5-turbo-16k': 'rgb(16,163,127)',
|
||||
'gpt-3.5-turbo-16k-0613': 'rgb(16,163,127)',
|
||||
'gpt-3.5-turbo-instruct': 'rgb(16,163,127)',
|
||||
'gpt-4': 'rgb(171,104,255)',
|
||||
'gpt-4-0125-preview': 'rgb(171,104,255)',
|
||||
'gpt-4-0314': 'rgb(171,104,255)',
|
||||
'gpt-4-0613': 'rgb(171,104,255)',
|
||||
'gpt-4-1106-preview': 'rgb(171,104,255)',
|
||||
'gpt-4-32k': 'rgb(171,104,255)',
|
||||
'gpt-4-32k-0314': 'rgb(171,104,255)',
|
||||
'gpt-4-32k-0613': 'rgb(171,104,255)',
|
||||
'gpt-4-turbo': 'rgb(171,104,255)',
|
||||
'gpt-4-turbo-2024-04-09': 'rgb(171,104,255)',
|
||||
'gpt-4-turbo-preview': 'rgb(171,104,255)',
|
||||
'gpt-4o': 'rgb(171,104,255)',
|
||||
'gpt-4o-2024-05-13': 'rgb(171,104,255)',
|
||||
'gpt-4o-2024-08-06': 'rgb(171,104,255)',
|
||||
'gpt-4o-mini': 'rgb(171,104,255)',
|
||||
'gpt-4o-mini-2024-07-18': 'rgb(171,104,255)',
|
||||
'claude-3-opus-20240229': 'rgb(255,132,31)',
|
||||
'claude-3-sonnet-20240229': 'rgb(253,135,93)',
|
||||
'claude-3-haiku-20240307': 'rgb(255,175,146)',
|
||||
'claude-2.1': 'rgb(255,209,190)',
|
||||
}
|
||||
|
||||
/**
|
||||
* 将字符串转换为颜色(基于哈希算法)
|
||||
* @param str 输入字符串
|
||||
* @returns 颜色值
|
||||
*/
|
||||
export function stringToColor(str: string): string {
|
||||
let sum = 0
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
sum += str.charCodeAt(i)
|
||||
}
|
||||
const index = sum % semiColors.length
|
||||
return semiColors[index]
|
||||
}
|
||||
|
||||
/**
|
||||
* 将字符串转换为RGB颜色(基于哈希算法)
|
||||
* @param str 输入字符串
|
||||
* @returns RGB颜色值
|
||||
*/
|
||||
export function stringToRgbColor(str: string): string {
|
||||
let sum = 0
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
sum += str.charCodeAt(i)
|
||||
}
|
||||
const index = sum % baseColors.length
|
||||
return baseColors[index]
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据模型名称获取颜色
|
||||
* @param modelName 模型名称
|
||||
* @returns 颜色值
|
||||
*/
|
||||
export function modelToColor(modelName: string): string {
|
||||
// 1. 如果模型在预定义的 modelColorMap 中,使用预定义颜色
|
||||
if (modelColorMap[modelName]) {
|
||||
return modelColorMap[modelName]
|
||||
}
|
||||
|
||||
// 2. 生成一个稳定的数字作为索引
|
||||
let hash = 0
|
||||
for (let i = 0; i < modelName.length; i++) {
|
||||
hash = (hash << 5) - hash + modelName.charCodeAt(i)
|
||||
hash = hash & hash // Convert to 32-bit integer
|
||||
}
|
||||
hash = Math.abs(hash)
|
||||
|
||||
// 3. 根据模型名称长度选择不同的色板
|
||||
const colorPalette = modelName.length > 10 ? extendedColors : baseColors
|
||||
|
||||
// 4. 使用hash值选择颜色
|
||||
const index = hash % colorPalette.length
|
||||
return colorPalette[index]
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据比率获取颜色
|
||||
* @param ratio 比率值
|
||||
* @returns 颜色名称
|
||||
*/
|
||||
export function getRatioColor(ratio: number): string {
|
||||
if (ratio > 5) return 'red'
|
||||
if (ratio > 3) return 'orange'
|
||||
if (ratio > 1) return 'blue'
|
||||
return 'green'
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据分组名获取标签颜色
|
||||
* @param group 分组名称
|
||||
* @returns 颜色名称
|
||||
*/
|
||||
export function getGroupColor(group: string): string {
|
||||
const tagColors: Record<string, string> = {
|
||||
vip: 'yellow',
|
||||
pro: 'yellow',
|
||||
svip: 'red',
|
||||
premium: 'red',
|
||||
}
|
||||
|
||||
return tagColors[group.toLowerCase()] || stringToColor(group)
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成十六进制颜色
|
||||
* @param str 输入字符串
|
||||
* @returns 十六进制颜色值
|
||||
*/
|
||||
export function generateHexColor(str: string): string {
|
||||
let hash = 0
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash)
|
||||
}
|
||||
|
||||
let color = '#'
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const value = (hash >> (i * 8)) & 0xff
|
||||
color += ('00' + value.toString(16)).substr(-2)
|
||||
}
|
||||
|
||||
return color
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查颜色是否为深色
|
||||
* @param color 颜色值(hex格式)
|
||||
* @returns 是否为深色
|
||||
*/
|
||||
export function isDarkColor(color: string): boolean {
|
||||
// 移除 # 符号
|
||||
const hex = color.replace('#', '')
|
||||
|
||||
// 解析RGB值
|
||||
const r = parseInt(hex.substr(0, 2), 16)
|
||||
const g = parseInt(hex.substr(2, 2), 16)
|
||||
const b = parseInt(hex.substr(4, 2), 16)
|
||||
|
||||
// 计算亮度
|
||||
const brightness = (r * 299 + g * 587 + b * 114) / 1000
|
||||
|
||||
return brightness < 128
|
||||
}
|
||||
270
web/src/lib/comparisons.ts
Normal file
270
web/src/lib/comparisons.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* 对象比较和差异检测工具函数
|
||||
*/
|
||||
|
||||
export interface PropertyChange {
|
||||
key: string
|
||||
oldValue: any
|
||||
newValue: any
|
||||
}
|
||||
|
||||
/**
|
||||
* 比较两个对象的属性,找出有变化的属性
|
||||
* @param oldObject 旧对象
|
||||
* @param newObject 新对象
|
||||
* @returns 包含变化属性信息的数组
|
||||
*/
|
||||
export function compareObjects(
|
||||
oldObject: Record<string, any>,
|
||||
newObject: Record<string, any>
|
||||
): PropertyChange[] {
|
||||
const changedProperties: PropertyChange[] = []
|
||||
|
||||
// 比较两个对象的属性
|
||||
for (const key in oldObject) {
|
||||
if (oldObject.hasOwnProperty(key) && newObject.hasOwnProperty(key)) {
|
||||
if (oldObject[key] !== newObject[key]) {
|
||||
changedProperties.push({
|
||||
key: key,
|
||||
oldValue: oldObject[key],
|
||||
newValue: newObject[key],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查新对象中新增的属性
|
||||
for (const key in newObject) {
|
||||
if (newObject.hasOwnProperty(key) && !oldObject.hasOwnProperty(key)) {
|
||||
changedProperties.push({
|
||||
key: key,
|
||||
oldValue: undefined,
|
||||
newValue: newObject[key],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return changedProperties
|
||||
}
|
||||
|
||||
/**
|
||||
* 深度比较两个对象是否相等
|
||||
* @param obj1 对象1
|
||||
* @param obj2 对象2
|
||||
* @returns 是否相等
|
||||
*/
|
||||
export function deepEqual(obj1: any, obj2: any): boolean {
|
||||
if (obj1 === obj2) return true
|
||||
|
||||
if (obj1 == null || obj2 == null) return false
|
||||
|
||||
if (typeof obj1 !== typeof obj2) return false
|
||||
|
||||
if (typeof obj1 !== 'object') return obj1 === obj2
|
||||
|
||||
if (Array.isArray(obj1) !== Array.isArray(obj2)) return false
|
||||
|
||||
const keys1 = Object.keys(obj1)
|
||||
const keys2 = Object.keys(obj2)
|
||||
|
||||
if (keys1.length !== keys2.length) return false
|
||||
|
||||
for (const key of keys1) {
|
||||
if (!keys2.includes(key)) return false
|
||||
if (!deepEqual(obj1[key], obj2[key])) return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取对象的差异
|
||||
* @param source 源对象
|
||||
* @param target 目标对象
|
||||
* @returns 差异对象
|
||||
*/
|
||||
export function getDifference(
|
||||
source: Record<string, any>,
|
||||
target: Record<string, any>
|
||||
): Record<string, any> {
|
||||
const diff: Record<string, any> = {}
|
||||
|
||||
// 检查修改和新增的属性
|
||||
for (const key in target) {
|
||||
if (!deepEqual(source[key], target[key])) {
|
||||
diff[key] = target[key]
|
||||
}
|
||||
}
|
||||
|
||||
// 检查删除的属性(设为undefined)
|
||||
for (const key in source) {
|
||||
if (!(key in target)) {
|
||||
diff[key] = undefined
|
||||
}
|
||||
}
|
||||
|
||||
return diff
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并对象(深度合并)
|
||||
* @param target 目标对象
|
||||
* @param sources 源对象数组
|
||||
* @returns 合并后的对象
|
||||
*/
|
||||
export function deepMerge(target: any, ...sources: any[]): any {
|
||||
if (!sources.length) return target
|
||||
const source = sources.shift()
|
||||
|
||||
if (isObject(target) && isObject(source)) {
|
||||
for (const key in source) {
|
||||
if (isObject(source[key])) {
|
||||
if (!target[key]) Object.assign(target, { [key]: {} })
|
||||
deepMerge(target[key], source[key])
|
||||
} else {
|
||||
Object.assign(target, { [key]: source[key] })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return deepMerge(target, ...sources)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为对象
|
||||
* @param item 检查项
|
||||
* @returns 是否为对象
|
||||
*/
|
||||
function isObject(item: any): boolean {
|
||||
return item && typeof item === 'object' && !Array.isArray(item)
|
||||
}
|
||||
|
||||
/**
|
||||
* 克隆对象(深度克隆)
|
||||
* @param obj 要克隆的对象
|
||||
* @returns 克隆后的对象
|
||||
*/
|
||||
export function deepClone<T>(obj: T): T {
|
||||
if (obj === null || typeof obj !== 'object') return obj
|
||||
|
||||
if (obj instanceof Date) return new Date(obj.getTime()) as T
|
||||
|
||||
if (obj instanceof Array) {
|
||||
return obj.map((item) => deepClone(item)) as T
|
||||
}
|
||||
|
||||
if (typeof obj === 'object') {
|
||||
const cloned = {} as T
|
||||
Object.keys(obj).forEach((key) => {
|
||||
;(cloned as any)[key] = deepClone((obj as any)[key])
|
||||
})
|
||||
return cloned
|
||||
}
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查对象是否为空
|
||||
* @param obj 对象
|
||||
* @returns 是否为空
|
||||
*/
|
||||
export function isEmpty(obj: any): boolean {
|
||||
if (obj == null) return true
|
||||
if (Array.isArray(obj)) return obj.length === 0
|
||||
if (typeof obj === 'object') return Object.keys(obj).length === 0
|
||||
if (typeof obj === 'string') return obj.trim().length === 0
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择对象的特定属性
|
||||
* @param obj 源对象
|
||||
* @param keys 要选择的属性键
|
||||
* @returns 包含选定属性的新对象
|
||||
*/
|
||||
export function pick<T extends Record<string, any>, K extends keyof T>(
|
||||
obj: T,
|
||||
keys: K[]
|
||||
): Pick<T, K> {
|
||||
const result = {} as Pick<T, K>
|
||||
keys.forEach((key) => {
|
||||
if (key in obj) {
|
||||
result[key] = obj[key]
|
||||
}
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 省略对象的特定属性
|
||||
* @param obj 源对象
|
||||
* @param keys 要省略的属性键
|
||||
* @returns 省略指定属性后的新对象
|
||||
*/
|
||||
export function omit<T, K extends keyof T>(obj: T, keys: K[]): Omit<T, K> {
|
||||
const result = { ...obj } as any
|
||||
keys.forEach((key) => {
|
||||
delete result[key]
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 扁平化嵌套对象
|
||||
* @param obj 嵌套对象
|
||||
* @param prefix 键前缀
|
||||
* @returns 扁平化后的对象
|
||||
*/
|
||||
export function flatten(
|
||||
obj: Record<string, any>,
|
||||
prefix: string = ''
|
||||
): Record<string, any> {
|
||||
let flattened: Record<string, any> = {}
|
||||
|
||||
for (const key in obj) {
|
||||
if (obj.hasOwnProperty(key)) {
|
||||
const newKey = prefix ? `${prefix}.${key}` : key
|
||||
|
||||
if (
|
||||
typeof obj[key] === 'object' &&
|
||||
obj[key] !== null &&
|
||||
!Array.isArray(obj[key])
|
||||
) {
|
||||
Object.assign(flattened, flatten(obj[key], newKey))
|
||||
} else {
|
||||
flattened[newKey] = obj[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return flattened
|
||||
}
|
||||
|
||||
/**
|
||||
* 反扁平化对象
|
||||
* @param obj 扁平化的对象
|
||||
* @returns 嵌套对象
|
||||
*/
|
||||
export function unflatten(obj: Record<string, any>): Record<string, any> {
|
||||
const result: Record<string, any> = {}
|
||||
|
||||
for (const key in obj) {
|
||||
if (obj.hasOwnProperty(key)) {
|
||||
const keys = key.split('.')
|
||||
let current = result
|
||||
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
const k = keys[i]
|
||||
if (!(k in current)) {
|
||||
current[k] = {}
|
||||
}
|
||||
current = current[k]
|
||||
}
|
||||
|
||||
current[keys[keys.length - 1]] = obj[key]
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
277
web/src/lib/formatters.ts
Normal file
277
web/src/lib/formatters.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* 格式化相关工具函数
|
||||
* 包括时间、数字、文本、价格等格式化函数
|
||||
*/
|
||||
|
||||
/**
|
||||
* 时间戳转字符串
|
||||
* @param timestamp 时间戳(秒)
|
||||
* @returns 格式化的时间字符串
|
||||
*/
|
||||
export function timestamp2string(timestamp: number): string {
|
||||
const date = new Date(timestamp * 1000)
|
||||
const year = date.getFullYear().toString()
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0')
|
||||
const day = date.getDate().toString().padStart(2, '0')
|
||||
const hour = date.getHours().toString().padStart(2, '0')
|
||||
const minute = date.getMinutes().toString().padStart(2, '0')
|
||||
const second = date.getSeconds().toString().padStart(2, '0')
|
||||
|
||||
return `${year}-${month}-${day} ${hour}:${minute}:${second}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间戳转简化字符串(用于图表)
|
||||
* @param timestamp 时间戳(秒)
|
||||
* @param granularity 时间粒度
|
||||
* @returns 格式化的时间字符串
|
||||
*/
|
||||
export function timestamp2string1(
|
||||
timestamp: number,
|
||||
granularity: 'hour' | 'day' | 'week' = 'hour'
|
||||
): string {
|
||||
const date = new Date(timestamp * 1000)
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0')
|
||||
const day = date.getDate().toString().padStart(2, '0')
|
||||
const hour = date.getHours().toString().padStart(2, '0')
|
||||
|
||||
let str = `${month}-${day}`
|
||||
|
||||
if (granularity === 'hour') {
|
||||
str += ` ${hour}:00`
|
||||
} else if (granularity === 'week') {
|
||||
const nextWeek = new Date(timestamp * 1000 + 6 * 24 * 60 * 60 * 1000)
|
||||
const nextMonth = (nextWeek.getMonth() + 1).toString().padStart(2, '0')
|
||||
const nextDay = nextWeek.getDate().toString().padStart(2, '0')
|
||||
str += ` - ${nextMonth}-${nextDay}`
|
||||
}
|
||||
|
||||
return str
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算相对时间(几天前、几小时前等)
|
||||
* @param publishDate 发布日期
|
||||
* @returns 相对时间描述
|
||||
*/
|
||||
export function getRelativeTime(publishDate: string | number | Date): string {
|
||||
if (!publishDate) return ''
|
||||
|
||||
const now = new Date()
|
||||
const pubDate = new Date(publishDate)
|
||||
|
||||
// 如果日期无效,返回原始字符串
|
||||
if (isNaN(pubDate.getTime())) return publishDate.toString()
|
||||
|
||||
const diffMs = now.getTime() - pubDate.getTime()
|
||||
const diffSeconds = Math.floor(diffMs / 1000)
|
||||
const diffMinutes = Math.floor(diffSeconds / 60)
|
||||
const diffHours = Math.floor(diffMinutes / 60)
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
const diffWeeks = Math.floor(diffDays / 7)
|
||||
const diffMonths = Math.floor(diffDays / 30)
|
||||
const diffYears = Math.floor(diffDays / 365)
|
||||
|
||||
// 如果是未来时间,显示具体日期
|
||||
if (diffMs < 0) {
|
||||
return formatDateString(pubDate)
|
||||
}
|
||||
|
||||
// 根据时间差返回相应的描述
|
||||
if (diffSeconds < 60) return '刚刚'
|
||||
if (diffMinutes < 60) return `${diffMinutes} 分钟前`
|
||||
if (diffHours < 24) return `${diffHours} 小时前`
|
||||
if (diffDays < 7) return `${diffDays} 天前`
|
||||
if (diffWeeks < 4) return `${diffWeeks} 周前`
|
||||
if (diffMonths < 12) return `${diffMonths} 个月前`
|
||||
if (diffYears < 2) return '1 年前'
|
||||
|
||||
// 超过2年显示具体日期
|
||||
return formatDateString(pubDate)
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期字符串
|
||||
* @param date 日期对象
|
||||
* @returns 格式化的日期字符串
|
||||
*/
|
||||
export function formatDateString(date: Date): string {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期时间字符串(包含时间)
|
||||
* @param date 日期对象
|
||||
* @returns 格式化的日期时间字符串
|
||||
*/
|
||||
export function formatDateTimeString(date: Date): string {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 截断文本
|
||||
* @param text 原始文本
|
||||
* @param limit 长度限制
|
||||
* @returns 截断后的文本
|
||||
*/
|
||||
export function renderText(text: string, limit: number): string {
|
||||
if (!text) return ''
|
||||
if (text.length > limit) {
|
||||
return text.slice(0, limit - 3) + '...'
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化配额(金额)
|
||||
* @param quota 配额值
|
||||
* @returns 格式化的配额字符串
|
||||
*/
|
||||
export function formatQuota(quota: number): string {
|
||||
if (quota >= 1000000) {
|
||||
return `$${(quota / 1000000).toFixed(1)}M`
|
||||
} else if (quota >= 1000) {
|
||||
return `$${(quota / 1000).toFixed(1)}K`
|
||||
} else {
|
||||
return `$${quota.toFixed(2)}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化数字(带单位)
|
||||
* @param value 数值
|
||||
* @returns 格式化的数字字符串
|
||||
*/
|
||||
export function 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()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化Token数量
|
||||
* @param tokens Token数量
|
||||
* @returns 格式化的Token字符串
|
||||
*/
|
||||
export function formatTokens(tokens: number): string {
|
||||
if (tokens >= 1000000) {
|
||||
return `${(tokens / 1000000).toFixed(1)}M`
|
||||
} else if (tokens >= 1000) {
|
||||
return `${(tokens / 1000).toFixed(1)}K`
|
||||
} else {
|
||||
return tokens.toString()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化百分比
|
||||
* @param value 数值
|
||||
* @param total 总数
|
||||
* @param precision 精度
|
||||
* @returns 格式化的百分比字符串
|
||||
*/
|
||||
export function formatPercentage(
|
||||
value: number,
|
||||
total: number,
|
||||
precision: number = 1
|
||||
): string {
|
||||
if (total === 0) return '0.0%'
|
||||
const percentage = (value / total) * 100
|
||||
return `${percentage.toFixed(precision)}%`
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化字节大小
|
||||
* @param bytes 字节数
|
||||
* @returns 格式化的大小字符串
|
||||
*/
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取今天开始时间戳
|
||||
* @returns 今天0点的时间戳(秒)
|
||||
*/
|
||||
export function getTodayStartTimestamp(): number {
|
||||
const now = new Date()
|
||||
now.setHours(0, 0, 0, 0)
|
||||
return Math.floor(now.getTime() / 1000)
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除URL尾部斜杠
|
||||
* @param url URL字符串
|
||||
* @returns 处理后的URL
|
||||
*/
|
||||
export function removeTrailingSlash(url: string): string {
|
||||
if (!url) return ''
|
||||
return url.endsWith('/') ? url.slice(0, -1) : url
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化模型价格
|
||||
* @param price 价格值
|
||||
* @param currency 货币类型
|
||||
* @param precision 精度
|
||||
* @returns 格式化的价格字符串
|
||||
*/
|
||||
export function formatPrice(
|
||||
price: number,
|
||||
currency: 'USD' | 'CNY' = 'USD',
|
||||
precision: number = 4
|
||||
): string {
|
||||
const symbol = currency === 'CNY' ? '¥' : '$'
|
||||
return `${symbol}${price.toFixed(precision)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化API调用次数
|
||||
* @param count 调用次数
|
||||
* @returns 格式化的次数字符串
|
||||
*/
|
||||
export function formatApiCalls(count: number): string {
|
||||
if (count >= 1000000) {
|
||||
return `${(count / 1000000).toFixed(1)}M calls`
|
||||
} else if (count >= 1000) {
|
||||
return `${(count / 1000).toFixed(1)}K calls`
|
||||
} else {
|
||||
return `${count} calls`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 截断文本(考虑移动端)
|
||||
* @param text 原始文本
|
||||
* @param maxWidth 最大宽度
|
||||
* @returns 截断后的文本
|
||||
*/
|
||||
export function truncateText(text: string, maxWidth: number = 200): string {
|
||||
const isMobileScreen = window.matchMedia('(max-width: 767px)').matches
|
||||
if (!isMobileScreen || !text) return text
|
||||
|
||||
// 简化版本:基于字符长度估算
|
||||
const estimatedCharWidth = 14 // 假设每个字符14px宽
|
||||
const maxChars = Math.floor(maxWidth / estimatedCharWidth)
|
||||
|
||||
if (text.length <= maxChars) return text
|
||||
return text.slice(0, maxChars - 3) + '...'
|
||||
}
|
||||
101
web/src/lib/index.ts
Normal file
101
web/src/lib/index.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* 工具函数统一导出
|
||||
* 提供便捷的导入方式
|
||||
*/
|
||||
|
||||
// 通用工具
|
||||
export { cn, sleep, getPageNumbers } from './utils'
|
||||
|
||||
// 颜色工具
|
||||
export {
|
||||
stringToColor,
|
||||
stringToRgbColor,
|
||||
modelToColor,
|
||||
getRatioColor,
|
||||
getGroupColor,
|
||||
generateHexColor,
|
||||
isDarkColor,
|
||||
} from './colors'
|
||||
|
||||
// 格式化工具
|
||||
export {
|
||||
timestamp2string,
|
||||
timestamp2string1,
|
||||
getRelativeTime,
|
||||
formatDateString,
|
||||
formatDateTimeString,
|
||||
renderText,
|
||||
formatQuota,
|
||||
formatNumber,
|
||||
formatTokens,
|
||||
formatPercentage,
|
||||
formatBytes,
|
||||
getTodayStartTimestamp,
|
||||
removeTrailingSlash,
|
||||
formatPrice,
|
||||
formatApiCalls,
|
||||
truncateText,
|
||||
} from './formatters'
|
||||
|
||||
// 验证工具
|
||||
export {
|
||||
verifyJSON,
|
||||
verifyJSONPromise,
|
||||
toBoolean,
|
||||
isValidEmail,
|
||||
isValidUrl,
|
||||
isValidPhone,
|
||||
validatePasswordStrength,
|
||||
isValidIP,
|
||||
isValidPort,
|
||||
isValidDomain,
|
||||
isValidUsername,
|
||||
isValidApiKey,
|
||||
isValidModelName,
|
||||
isInRange,
|
||||
isPositiveInteger,
|
||||
isNonNegativeNumber,
|
||||
deepEqual,
|
||||
sanitizeText,
|
||||
} from './validators'
|
||||
|
||||
// 剪贴板工具
|
||||
export {
|
||||
copy,
|
||||
paste,
|
||||
isClipboardSupported,
|
||||
copyJSON,
|
||||
copyAsCSV,
|
||||
copyLink,
|
||||
copyCode,
|
||||
} from './clipboard'
|
||||
|
||||
// 对象比较工具
|
||||
export {
|
||||
compareObjects,
|
||||
getDifference,
|
||||
deepMerge,
|
||||
deepClone,
|
||||
isEmpty,
|
||||
pick,
|
||||
omit,
|
||||
flatten,
|
||||
unflatten,
|
||||
} from './comparisons'
|
||||
|
||||
// 认证相关
|
||||
export {
|
||||
setStoredUser,
|
||||
getStoredUser,
|
||||
getStoredUserId,
|
||||
clearStoredUser,
|
||||
} from './auth'
|
||||
|
||||
// Cookie相关
|
||||
export { setCookie, getCookie, removeCookie } from './cookies'
|
||||
|
||||
// HTTP客户端
|
||||
export { http } from './http'
|
||||
|
||||
// 错误处理
|
||||
export { handleServerError } from './handle-server-error'
|
||||
263
web/src/lib/validators.ts
Normal file
263
web/src/lib/validators.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* 验证相关工具函数
|
||||
* 包括JSON验证、布尔值转换、数据校验等
|
||||
*/
|
||||
|
||||
/**
|
||||
* 验证JSON字符串
|
||||
* @param str JSON字符串
|
||||
* @returns 是否为有效JSON
|
||||
*/
|
||||
export function verifyJSON(str: string): boolean {
|
||||
try {
|
||||
JSON.parse(str)
|
||||
return true
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证JSON字符串(Promise版本)
|
||||
* @param value JSON字符串
|
||||
* @returns Promise,成功解析则resolve,否则reject
|
||||
*/
|
||||
export function verifyJSONPromise(value: string): Promise<void> {
|
||||
try {
|
||||
JSON.parse(value)
|
||||
return Promise.resolve()
|
||||
} catch (e) {
|
||||
return Promise.reject('不是合法的 JSON 字符串')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 布尔值转换
|
||||
* @param value 待转换的值
|
||||
* @returns 布尔值
|
||||
*/
|
||||
export function toBoolean(value: unknown): boolean {
|
||||
// 兼容字符串、数字以及布尔原生类型
|
||||
if (typeof value === 'boolean') return value
|
||||
if (typeof value === 'number') return value === 1
|
||||
if (typeof value === 'string') {
|
||||
const v = value.toLowerCase()
|
||||
return v === 'true' || v === '1'
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证邮箱格式
|
||||
* @param email 邮箱地址
|
||||
* @returns 是否为有效邮箱
|
||||
*/
|
||||
export function isValidEmail(email: string): boolean {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
return emailRegex.test(email)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证URL格式
|
||||
* @param url URL地址
|
||||
* @returns 是否为有效URL
|
||||
*/
|
||||
export function isValidUrl(url: string): boolean {
|
||||
try {
|
||||
new URL(url)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证手机号格式(中国大陆)
|
||||
* @param phone 手机号
|
||||
* @returns 是否为有效手机号
|
||||
*/
|
||||
export function isValidPhone(phone: string): boolean {
|
||||
const phoneRegex = /^1[3-9]\d{9}$/
|
||||
return phoneRegex.test(phone)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证密码强度
|
||||
* @param password 密码
|
||||
* @returns 密码强度等级 (weak|medium|strong)
|
||||
*/
|
||||
export function validatePasswordStrength(
|
||||
password: string
|
||||
): 'weak' | 'medium' | 'strong' {
|
||||
if (password.length < 6) return 'weak'
|
||||
|
||||
let score = 0
|
||||
|
||||
// 长度检查
|
||||
if (password.length >= 8) score++
|
||||
if (password.length >= 12) score++
|
||||
|
||||
// 字符类型检查
|
||||
if (/[a-z]/.test(password)) score++ // 小写字母
|
||||
if (/[A-Z]/.test(password)) score++ // 大写字母
|
||||
if (/\d/.test(password)) score++ // 数字
|
||||
if (/[^a-zA-Z0-9]/.test(password)) score++ // 特殊字符
|
||||
|
||||
if (score <= 2) return 'weak'
|
||||
if (score <= 4) return 'medium'
|
||||
return 'strong'
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证IP地址格式
|
||||
* @param ip IP地址
|
||||
* @returns 是否为有效IP地址
|
||||
*/
|
||||
export function isValidIP(ip: string): boolean {
|
||||
const ipv4Regex =
|
||||
/^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$/
|
||||
const ipv6Regex =
|
||||
/^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/
|
||||
|
||||
return ipv4Regex.test(ip) || ipv6Regex.test(ip)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证端口号
|
||||
* @param port 端口号
|
||||
* @returns 是否为有效端口号
|
||||
*/
|
||||
export function isValidPort(port: number | string): boolean {
|
||||
const portNum = typeof port === 'string' ? parseInt(port, 10) : port
|
||||
return !isNaN(portNum) && portNum >= 1 && portNum <= 65535
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证域名格式
|
||||
* @param domain 域名
|
||||
* @returns 是否为有效域名
|
||||
*/
|
||||
export function isValidDomain(domain: string): boolean {
|
||||
const domainRegex =
|
||||
/^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/
|
||||
return domainRegex.test(domain)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证用户名格式
|
||||
* @param username 用户名
|
||||
* @returns 是否为有效用户名
|
||||
*/
|
||||
export function isValidUsername(username: string): boolean {
|
||||
// 3-20位字母、数字、下划线、横线
|
||||
const usernameRegex = /^[a-zA-Z0-9_-]{3,20}$/
|
||||
return usernameRegex.test(username)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证API Key格式
|
||||
* @param apiKey API密钥
|
||||
* @returns 是否为有效API Key
|
||||
*/
|
||||
export function isValidApiKey(apiKey: string): boolean {
|
||||
// 基本格式验证:至少20位字符,包含字母和数字
|
||||
if (apiKey.length < 20) return false
|
||||
|
||||
// 检查是否包含字母和数字
|
||||
const hasLetter = /[a-zA-Z]/.test(apiKey)
|
||||
const hasNumber = /\d/.test(apiKey)
|
||||
|
||||
return hasLetter && hasNumber
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证模型名称格式
|
||||
* @param modelName 模型名称
|
||||
* @returns 是否为有效模型名称
|
||||
*/
|
||||
export function isValidModelName(modelName: string): boolean {
|
||||
// 允许字母、数字、横线、下划线、点号
|
||||
const modelNameRegex = /^[a-zA-Z0-9._-]+$/
|
||||
return modelNameRegex.test(modelName) && modelName.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证数值范围
|
||||
* @param value 数值
|
||||
* @param min 最小值
|
||||
* @param max 最大值
|
||||
* @returns 是否在有效范围内
|
||||
*/
|
||||
export function isInRange(value: number, min: number, max: number): boolean {
|
||||
return value >= min && value <= max
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证正整数
|
||||
* @param value 值
|
||||
* @returns 是否为正整数
|
||||
*/
|
||||
export function isPositiveInteger(value: unknown): boolean {
|
||||
const num = typeof value === 'string' ? parseInt(value, 10) : value
|
||||
return typeof num === 'number' && Number.isInteger(num) && num > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证非负数
|
||||
* @param value 值
|
||||
* @returns 是否为非负数
|
||||
*/
|
||||
export function isNonNegativeNumber(value: unknown): boolean {
|
||||
const num = typeof value === 'string' ? parseFloat(value) : value
|
||||
return typeof num === 'number' && !isNaN(num) && num >= 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 深度比较两个对象
|
||||
* @param obj1 对象1
|
||||
* @param obj2 对象2
|
||||
* @returns 是否相等
|
||||
*/
|
||||
export function deepEqual(obj1: any, obj2: any): boolean {
|
||||
if (obj1 === obj2) return true
|
||||
|
||||
if (obj1 == null || obj2 == null) return false
|
||||
|
||||
if (typeof obj1 !== typeof obj2) return false
|
||||
|
||||
if (typeof obj1 !== 'object') return obj1 === obj2
|
||||
|
||||
if (Array.isArray(obj1) !== Array.isArray(obj2)) return false
|
||||
|
||||
const keys1 = Object.keys(obj1)
|
||||
const keys2 = Object.keys(obj2)
|
||||
|
||||
if (keys1.length !== keys2.length) return false
|
||||
|
||||
for (const key of keys1) {
|
||||
if (!keys2.includes(key)) return false
|
||||
if (!deepEqual(obj1[key], obj2[key])) return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理和验证文本输入
|
||||
* @param text 输入文本
|
||||
* @param maxLength 最大长度
|
||||
* @returns 清理后的文本
|
||||
*/
|
||||
export function sanitizeText(text: string, maxLength?: number): string {
|
||||
if (!text) return ''
|
||||
|
||||
// 移除多余的空白字符
|
||||
let cleaned = text.trim().replace(/\s+/g, ' ')
|
||||
|
||||
// 限制长度
|
||||
if (maxLength && cleaned.length > maxLength) {
|
||||
cleaned = cleaned.slice(0, maxLength)
|
||||
}
|
||||
|
||||
return cleaned
|
||||
}
|
||||
@@ -40,4 +40,69 @@ export type LoginTwoFAData = { require_2fa: true }
|
||||
export type LoginSuccessData = UserBasic
|
||||
export type LoginResponse = ApiResponse<LoginTwoFAData | LoginSuccessData>
|
||||
export type Verify2FAResponse = ApiResponse<UserBasic>
|
||||
// Dashboard 相关数据类型
|
||||
export interface QuotaDataItem {
|
||||
count: number
|
||||
model_name: string
|
||||
quota: number
|
||||
created_at: number
|
||||
tokens?: number
|
||||
}
|
||||
|
||||
export type QuotaDataResponse = ApiResponse<QuotaDataItem[]>
|
||||
|
||||
// 统计数据接口
|
||||
export interface DashboardStats {
|
||||
totalQuota: number
|
||||
totalTokens: number
|
||||
totalRequests: number
|
||||
avgQuotaPerRequest: number
|
||||
}
|
||||
|
||||
// 图表趋势数据
|
||||
export interface TrendDataPoint {
|
||||
timestamp: number
|
||||
quota: number
|
||||
tokens: number
|
||||
count: number
|
||||
}
|
||||
|
||||
// 模型使用分布数据
|
||||
export interface ModelUsageData {
|
||||
model: string
|
||||
quota: number
|
||||
tokens: number
|
||||
count: number
|
||||
percentage: number
|
||||
}
|
||||
|
||||
// 模型详细信息
|
||||
export interface ModelInfo {
|
||||
id: number
|
||||
model_name: string
|
||||
business_group: string
|
||||
quota_used: number
|
||||
quota_failed: number
|
||||
success_rate: number
|
||||
avg_quota_per_request: number
|
||||
avg_tokens_per_request: number
|
||||
operations: string[]
|
||||
}
|
||||
|
||||
// 模型监控统计数据
|
||||
export interface ModelMonitoringStats {
|
||||
total_models: number
|
||||
active_models: number
|
||||
total_requests: number
|
||||
avg_success_rate: number
|
||||
}
|
||||
|
||||
// 模型监控数据响应
|
||||
export interface ModelMonitoringData {
|
||||
stats: ModelMonitoringStats
|
||||
models: ModelInfo[]
|
||||
}
|
||||
|
||||
export type ModelMonitoringResponse = ApiResponse<ModelMonitoringData>
|
||||
|
||||
export type SelfResponse = ApiResponse<UserSelf>
|
||||
|
||||
Reference in New Issue
Block a user