📊 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:
t0ng7u
2025-09-26 02:41:46 +08:00
parent 456987a3d4
commit 99fcc354e3
18 changed files with 3606 additions and 230 deletions

View File

@@ -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) => {

View File

@@ -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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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>

View 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>
)
}

View 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(),
}
}

View 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,
}
}

View 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,
}
}

View File

@@ -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
View 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
View 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
View 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
View 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
View 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
View 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
}

View File

@@ -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>