feat: 优化前端 UI 设计和用户体验

- 重构所有 UI 组件,采用现代化设计风格
- 新增 PageContainer 组件统一页面布局
- 优化 Sidebar 支持折叠功能
- 改进 Toast 通知样式和交互
- 统一所有页面的视觉风格和间距
- 提升整体界面的专业性和易用性

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
dreamhunter2333andClaude Opus 4.5 committed 2026-01-30 14:11:51 +08:00
1 parent 066437fcb3
commit 6a648f3fa7
20 files changed
+1586 -947

No files matched your search

+54 -4
View File
@@ -14,6 +14,7 @@ import { Button } from '@/components/ui/button'
import { useAuthStore } from '@/store/auth'
import { useToast } from '@/components/ui/use-toast'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Menu, Sparkles } from 'lucide-react'
interface AppLayoutProps {
children: ReactNode
@@ -23,6 +24,8 @@ export function AppLayout({ children }: AppLayoutProps) {
const [adminToken, setAdminToken] = useState('')
const [authError, setAuthError] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [isMobileNavOpen, setIsMobileNavOpen] = useState(false)
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false)
const { login, showAuthModal, closeAuthModal, openAuthModal } = useAuthStore()
const { addToast } = useToast()
@@ -44,11 +47,58 @@ export function AppLayout({ children }: AppLayoutProps) {
return (
<div className="flex h-screen overflow-hidden">
<Sidebar onAuthClick={openAuthModal} />
<Sidebar
onAuthClick={openAuthModal}
className="hidden lg:flex"
collapsed={isSidebarCollapsed}
onToggleCollapse={() => setIsSidebarCollapsed((prev) => !prev)}
showCollapseToggle
/>
<main className="flex-1 overflow-y-auto bg-background">
{children}
</main>
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex items-center justify-between border-b bg-card/80 backdrop-blur-sm px-4 py-3 lg:hidden sticky top-0 z-30">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setIsMobileNavOpen(true)}
aria-label="打开侧边栏"
>
<Menu className="h-5 w-5" />
</Button>
<div className="flex items-center gap-2.5">
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-primary to-primary/60 flex items-center justify-center">
<Sparkles className="h-4 w-4 text-white" />
</div>
<span className="font-semibold tracking-tight">Awsl One API</span>
</div>
<div className="h-10 w-10" />
</header>
<main className="flex-1 overflow-y-auto bg-background">
<div className="mx-auto max-w-7xl w-full">
{children}
</div>
</main>
</div>
{isMobileNavOpen && (
<div className="fixed inset-0 z-40 lg:hidden">
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={() => setIsMobileNavOpen(false)}
/>
<div className="absolute inset-y-0 left-0 w-72 max-w-[85%] shadow-2xl">
<Sidebar
onAuthClick={openAuthModal}
onNavigate={() => setIsMobileNavOpen(false)}
onClose={() => setIsMobileNavOpen(false)}
collapsed={false}
showCollapseToggle={false}
/>
</div>
</div>
)}
{/* Auth Dialog */}
<Dialog open={showAuthModal} onOpenChange={(open) => !open && closeAuthModal()}>
+192 -109
View File
@@ -12,10 +12,13 @@ import {
Sun,
LogIn,
LogOut,
X,
PanelLeftClose,
PanelLeftOpen,
Zap,
} from 'lucide-react'
import { useAuthStore } from '@/store/auth'
import { useState } from 'react'
import { Button } from '../ui/button'
interface NavItem {
title: string
@@ -63,12 +66,25 @@ const navItems: NavItem[] = [
interface SidebarProps {
onAuthClick: () => void
className?: string
onNavigate?: () => void
onClose?: () => void
collapsed?: boolean
onToggleCollapse?: () => void
showCollapseToggle?: boolean
}
export function Sidebar({ onAuthClick }: SidebarProps) {
export function Sidebar({
onAuthClick,
className,
onNavigate,
onClose,
collapsed = false,
onToggleCollapse,
showCollapseToggle = false,
}: SidebarProps) {
const location = useLocation()
const { isAuthenticated, logout } = useAuthStore()
// Initialize theme from localStorage on mount
const [theme, setTheme] = useState<'light' | 'dark'>(() => {
if (typeof window !== 'undefined') {
const savedTheme = localStorage.getItem('theme')
@@ -98,147 +114,214 @@ export function Sidebar({ onAuthClick }: SidebarProps) {
} else {
onAuthClick()
}
onNavigate?.()
}
const NavLink = ({ item }: { item: NavItem }) => {
const isActive = location.pathname === item.href
return (
<Link
to={item.href}
onClick={() => onNavigate?.()}
title={collapsed ? item.title : undefined}
className={cn(
'group relative flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-200',
collapsed && 'justify-center px-0 w-10 h-10 mx-auto',
isActive
? 'bg-primary text-primary-foreground shadow-md'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
)}
>
<item.icon className={cn(
"h-[18px] w-[18px] flex-shrink-0",
!isActive && "group-hover:scale-110 transition-transform duration-200"
)} />
{!collapsed && <span>{item.title}</span>}
</Link>
)
}
return (
<aside className="w-64 border-r bg-card flex flex-col h-full">
<aside
className={cn(
"bg-card border-r flex flex-col h-full transition-all duration-300",
collapsed ? "w-[72px]" : "w-64",
className
)}
>
{/* Header */}
<div className="p-6 border-b">
<div className="flex items-center gap-2">
<div className="text-2xl">🤖</div>
<span className="font-semibold text-lg">Awsl One API</span>
</div>
<div className={cn(
"h-16 flex items-center border-b px-4",
collapsed ? "justify-center px-0" : "justify-between"
)}>
{collapsed ? (
<div className="flex flex-col items-center gap-1">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-primary/70 flex items-center justify-center">
<Zap className="h-5 w-5 text-white" />
</div>
</div>
) : (
<>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-primary/70 flex items-center justify-center shadow-lg shadow-primary/20">
<Zap className="h-5 w-5 text-white" />
</div>
<div className="flex flex-col">
<span className="font-semibold text-[15px] tracking-tight">Awsl One API</span>
<span className="text-[11px] text-muted-foreground">统一 AI 接口网关</span>
</div>
</div>
{onClose && (
<button
type="button"
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors lg:hidden"
onClick={onClose}
>
<X className="h-4 w-4" />
</button>
)}
</>
)}
</div>
{/* Collapse Toggle - PC Only */}
{showCollapseToggle && (
<div className={cn("px-3 py-2 hidden lg:flex", collapsed && "px-0 justify-center")}>
<button
type="button"
onClick={onToggleCollapse}
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-lg text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors",
collapsed ? "w-10 h-10 p-0 justify-center" : "w-full"
)}
>
{collapsed ? (
<PanelLeftOpen className="h-4 w-4" />
) : (
<>
<PanelLeftClose className="h-4 w-4" />
<span>收起菜单</span>
</>
)}
</button>
</div>
)}
{/* Navigation */}
<nav className="flex-1 p-4 space-y-6 overflow-y-auto">
<div>
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 px-3">
主菜单
</div>
<div className="space-y-1">
{navItems
.filter((item) => !item.adminOnly)
.map((item) => (
<Link
key={item.href}
to={item.href}
className={cn(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors',
location.pathname === item.href
? 'bg-primary text-primary-foreground'
: 'hover:bg-accent hover:text-accent-foreground'
)}
>
<item.icon className="h-4 w-4" />
<span>{item.title}</span>
</Link>
))}
</div>
<nav className="flex-1 px-3 py-2 space-y-6 overflow-y-auto scrollbar-thin">
<div className="space-y-1">
{!collapsed && (
<div className="px-3 py-2 text-[11px] font-semibold text-muted-foreground/60 uppercase tracking-wider">
概览
</div>
)}
{navItems
.filter((item) => !item.adminOnly)
.map((item) => (
<NavLink key={item.href} item={item} />
))}
</div>
{isAuthenticated && (
<>
<div>
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 px-3">
管理
</div>
<div className="space-y-1">
{navItems
.filter((item) => item.adminOnly && item.href !== '/database')
.map((item) => (
<Link
key={item.href}
to={item.href}
className={cn(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors',
location.pathname === item.href
? 'bg-primary text-primary-foreground'
: 'hover:bg-accent hover:text-accent-foreground'
)}
>
<item.icon className="h-4 w-4" />
<span>{item.title}</span>
</Link>
))}
</div>
<div className="space-y-1">
{!collapsed && (
<div className="px-3 py-2 text-[11px] font-semibold text-muted-foreground/60 uppercase tracking-wider">
管理
</div>
)}
{navItems
.filter((item) => item.adminOnly && item.href !== '/database')
.map((item) => (
<NavLink key={item.href} item={item} />
))}
</div>
<div>
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 px-3">
系统
</div>
<div className="space-y-1">
{navItems
.filter((item) => item.href === '/database')
.map((item) => (
<Link
key={item.href}
to={item.href}
className={cn(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors',
location.pathname === item.href
? 'bg-primary text-primary-foreground'
: 'hover:bg-accent hover:text-accent-foreground'
)}
>
<item.icon className="h-4 w-4" />
<span>{item.title}</span>
</Link>
))}
</div>
<div className="space-y-1">
{!collapsed && (
<div className="px-3 py-2 text-[11px] font-semibold text-muted-foreground/60 uppercase tracking-wider">
系统
</div>
)}
{navItems
.filter((item) => item.href === '/database')
.map((item) => (
<NavLink key={item.href} item={item} />
))}
</div>
</>
)}
</nav>
{/* Footer */}
<div className="p-4 border-t space-y-2">
<div className="flex items-center justify-between text-sm text-muted-foreground px-3 mb-3">
<div className={cn("p-3 border-t", collapsed && "p-2")}>
{/* Action Buttons */}
<div className={cn(
"flex gap-1",
collapsed ? "flex-col items-center" : "mb-3"
)}>
<button
type="button"
title={collapsed ? (theme === 'dark' ? '浅色模式' : '深色模式') : undefined}
className={cn(
"flex items-center justify-center rounded-lg transition-all duration-200",
collapsed
? "w-10 h-10 hover:bg-muted text-muted-foreground hover:text-foreground"
: "flex-1 h-9 gap-2 px-3 bg-muted/50 hover:bg-muted text-muted-foreground hover:text-foreground text-xs font-medium"
)}
onClick={toggleTheme}
>
{theme === 'dark' ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
{!collapsed && <span>{theme === 'dark' ? '浅色' : '深色'}</span>}
</button>
<a
href="https://github.com/dreamhunter2333/awsl-one-api"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-primary hover:underline"
title={collapsed ? "GitHub" : undefined}
className={cn(
"flex items-center justify-center rounded-lg transition-all duration-200",
collapsed
? "w-10 h-10 hover:bg-muted text-muted-foreground hover:text-foreground"
: "flex-1 h-9 gap-2 px-3 bg-muted/50 hover:bg-muted text-muted-foreground hover:text-foreground text-xs font-medium"
)}
>
<Github className="h-4 w-4" />
GitHub
{!collapsed && <span>GitHub</span>}
</a>
<span>v1.0.0</span>
</div>
<Button
variant="outline"
size="sm"
className="w-full justify-start"
onClick={toggleTheme}
>
{theme === 'dark' ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
<span className="ml-2">切换主题</span>
</Button>
<Button
variant="outline"
size="sm"
{/* Auth Button */}
<button
type="button"
title={collapsed ? (isAuthenticated ? '退出登录' : '管理员登录') : undefined}
className={cn(
'w-full justify-start',
isAuthenticated && 'text-destructive border-destructive hover:bg-destructive hover:text-destructive-foreground'
'flex items-center justify-center gap-2 w-full rounded-lg text-sm font-medium transition-all duration-200',
collapsed ? 'h-10' : 'h-10 px-3',
isAuthenticated
? 'bg-destructive/10 text-destructive hover:bg-destructive/20'
: 'bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm'
)}
onClick={handleAuthClick}
>
{isAuthenticated ? (
<LogOut className="h-4 w-4" />
<>
<LogOut className="h-4 w-4" />
{!collapsed && <span>退出登录</span>}
</>
) : (
<LogIn className="h-4 w-4" />
<>
<LogIn className="h-4 w-4" />
{!collapsed && <span>管理员登录</span>}
</>
)}
<span className="ml-2">
{isAuthenticated ? '退出登录' : '管理员登录'}
</span>
</Button>
</button>
</div>
</aside>
)
+2 -2
View File
@@ -16,9 +16,9 @@ const Alert = React.forwardRef<
"bg-background text-foreground": variant === "default",
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive":
variant === "destructive",
"border-orange-200/50 bg-transparent text-orange-400 dark:border-amber-700 dark:bg-amber-950/50 dark:text-amber-300 [&>svg]:text-orange-300":
"border-warning/50 bg-warning/10 text-warning dark:border-warning/50 dark:bg-warning/10 dark:text-warning [&>svg]:text-warning":
variant === "warning",
"border-green-300 bg-green-50 text-green-800 dark:border-green-600 dark:bg-green-950 dark:text-green-200 [&>svg]:text-green-600":
"border-success/50 bg-success/10 text-success dark:border-success/50 dark:bg-success/10 dark:text-success [&>svg]:text-success":
variant === "success",
},
className
+2 -2
View File
@@ -15,9 +15,9 @@ const badgeVariants = cva(
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
success:
"border-transparent bg-green-500 text-white hover:bg-green-600",
"border-transparent bg-success text-success-foreground hover:bg-success/80",
warning:
"border-transparent bg-yellow-500 text-white hover:bg-yellow-600",
"border-transparent bg-warning text-warning-foreground hover:bg-warning/80",
},
},
defaultVariants: {
+8 -7
View File
@@ -4,24 +4,25 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium ring-offset-background transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer active:scale-[0.97] [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
default: "bg-primary text-primary-foreground shadow-sm hover:bg-primary/90 hover:shadow-md",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground hover:border-accent-foreground/20",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
success: "bg-success text-success-foreground shadow-sm hover:bg-success/90",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
sm: "h-9 rounded-lg px-3",
lg: "h-11 rounded-lg px-8",
icon: "h-10 w-10",
},
},
+1 -1
View File
@@ -8,7 +8,7 @@ const Card = React.forwardRef<
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
"rounded-xl border bg-card text-card-foreground shadow-soft transition-shadow duration-200",
className
)}
{...props}
+1 -1
View File
@@ -11,7 +11,7 @@ const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
<input
type="checkbox"
className={cn(
"h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-primary focus:ring-offset-2",
"h-4 w-4 rounded border-input text-primary focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 cursor-pointer disabled:cursor-not-allowed",
className
)}
ref={ref}
+1 -1
View File
@@ -7,7 +7,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm shadow-sm ring-offset-background transition-all duration-200 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:border-ring hover:border-muted-foreground/30 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
@@ -0,0 +1,33 @@
import * as React from "react"
import { cn } from "@/lib/utils"
interface PageContainerProps {
title: string
description?: string
actions?: React.ReactNode
children: React.ReactNode
className?: string
}
export function PageContainer({
title,
description,
actions,
children,
className,
}: PageContainerProps) {
return (
<div className={cn("p-4 md:p-6 lg:p-8 animate-in", className)}>
<div className="mb-6 md:mb-8 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">{title}</h1>
{description && (
<p className="text-muted-foreground mt-1.5 text-sm md:text-base">{description}</p>
)}
</div>
{actions && <div className="flex items-center gap-2 flex-shrink-0">{actions}</div>}
</div>
{children}
</div>
)
}
+1 -1
View File
@@ -8,7 +8,7 @@ const Select = React.forwardRef<
return (
<select
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm shadow-sm ring-offset-background transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 hover:border-muted-foreground/30 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
+1 -1
View File
@@ -72,7 +72,7 @@ const TableHead = React.forwardRef<
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
"h-11 px-4 text-left align-middle font-semibold text-xs uppercase tracking-wider text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
+1 -1
View File
@@ -8,7 +8,7 @@ const Textarea = React.forwardRef<
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"flex min-h-[80px] w-full rounded-lg border border-input bg-background px-3 py-2 text-sm shadow-sm ring-offset-background transition-all duration-200 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 hover:border-muted-foreground/30 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
+20 -28
View File
@@ -1,26 +1,22 @@
import { X, CheckCircle, AlertCircle, AlertTriangle, Info } from 'lucide-react'
import { CheckCircle, AlertCircle, AlertTriangle, Info } from 'lucide-react'
import { useToast } from './use-toast'
import { cn } from '@/lib/utils'
const toastConfig = {
success: {
border: 'border-green-300 dark:border-green-600',
text: 'text-green-500 dark:text-green-400',
bg: 'bg-emerald-500',
Icon: CheckCircle,
},
error: {
border: 'border-red-300 dark:border-red-600',
text: 'text-red-500 dark:text-red-400',
bg: 'bg-red-500',
Icon: AlertCircle,
},
warning: {
border: 'border-amber-300 dark:border-amber-600',
text: 'text-amber-500 dark:text-amber-400',
bg: 'bg-amber-500',
Icon: AlertTriangle,
},
info: {
border: 'border-blue-300 dark:border-blue-600',
text: 'text-blue-500 dark:text-blue-400',
bg: 'bg-blue-500',
Icon: Info,
},
} as const
@@ -30,34 +26,30 @@ type ToastType = keyof typeof toastConfig
export function Toaster() {
const { toasts, removeToast } = useToast()
// 检测是否为深色模式
const isDark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
return (
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2 w-full max-w-sm">
{toasts.map((toast) => {
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 flex flex-col items-center gap-2">
{toasts.map((toast, index) => {
const config = toastConfig[toast.type as ToastType] || toastConfig.info
const Icon = config.Icon
return (
<div
key={toast.id}
style={{ backgroundColor: isDark ? '#1f2937' : '#ffffff' }}
onClick={() => removeToast(toast.id)}
className={cn(
"relative rounded-lg border p-4 shadow-lg animate-in slide-in-from-right",
config.border,
config.text
"flex items-center gap-2.5 px-4 py-2.5 rounded-full cursor-pointer",
"bg-foreground text-background",
"shadow-lg shadow-black/10",
"animate-in fade-in-0 slide-in-from-bottom-4 duration-300",
"hover:scale-[1.02] transition-transform"
)}
style={{
animationDelay: `${index * 50}ms`,
}}
>
<div className="flex items-start gap-3">
<Icon className="h-5 w-5 shrink-0 mt-0.5" />
<p className="text-sm font-medium flex-1">{toast.message}</p>
<button
onClick={() => removeToast(toast.id)}
className="rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none"
>
<X className="h-4 w-4" />
</button>
</div>
<span className={cn("flex items-center justify-center w-5 h-5 rounded-full", config.bg)}>
<Icon className="h-3 w-3 text-white" />
</span>
<span className="text-sm font-medium pr-1">{toast.message}</span>
</div>
)
})}
+2 -2
View File
@@ -21,12 +21,12 @@ export const useToast = create<ToastState>((set) => ({
set((state) => ({
toasts: [...state.toasts, { id, message, type }],
}))
// Auto remove after 5 seconds
// Auto remove after 3 seconds
setTimeout(() => {
set((state) => ({
toasts: state.toasts.filter((t) => t.id !== id),
}))
}, 5000)
}, 3000)
},
removeToast: (id) =>
set((state) => ({
+186 -44
View File
@@ -1,77 +1,140 @@
@import 'tailwindcss';
@theme {
/* 现代简洁配色 - 粉色主题 */
--color-background: hsl(0 0% 100%);
--color-foreground: hsl(240 10% 4%);
/* 现代科技简约配色 - 深邃蓝主题 */
--color-background: hsl(210 20% 98%);
--color-foreground: hsl(222 47% 11%);
--color-card: hsl(0 0% 100%);
--color-card-foreground: hsl(240 10% 4%);
--color-card-foreground: hsl(222 47% 11%);
--color-popover: hsl(0 0% 100%);
--color-popover-foreground: hsl(240 10% 4%);
--color-popover-foreground: hsl(222 47% 11%);
/* 主色调 - 浅粉色 */
--color-primary: hsl(350 89% 70%);
/* 主色调 - 靛蓝色 */
--color-primary: hsl(226 70% 55%);
--color-primary-foreground: hsl(0 0% 100%);
/* 次要色 */
--color-secondary: hsl(240 5% 96%);
--color-secondary-foreground: hsl(240 6% 10%);
/* 次要色 - 柔和灰蓝 */
--color-secondary: hsl(220 14% 96%);
--color-secondary-foreground: hsl(222 47% 11%);
/* 静音色 */
--color-muted: hsl(240 5% 96%);
--color-muted-foreground: hsl(240 4% 46%);
--color-muted: hsl(220 14% 96%);
--color-muted-foreground: hsl(220 9% 46%);
/* 强调色 - 淡粉 */
--color-accent: hsl(350 89% 95%);
--color-accent-foreground: hsl(350 70% 45%);
/* 强调色 */
--color-accent: hsl(220 14% 96%);
--color-accent-foreground: hsl(222 47% 11%);
/* 危险色 */
--color-destructive: hsl(0 84% 60%);
/* 危险色 - 柔和红 */
--color-destructive: hsl(0 72% 51%);
--color-destructive-foreground: hsl(0 0% 100%);
/* 边框和输入框 */
--color-border: hsl(240 6% 90%);
--color-input: hsl(240 6% 90%);
--color-ring: hsl(350 89% 70%);
/* 成功色 - 翡翠绿 */
--color-success: hsl(160 84% 39%);
--color-success-foreground: hsl(0 0% 100%);
--radius: 0.5rem;
/* 警告色 - 琥珀黄 */
--color-warning: hsl(38 92% 50%);
--color-warning-foreground: hsl(0 0% 100%);
/* 信息色 - 天蓝 */
--color-info: hsl(199 89% 48%);
--color-info-foreground: hsl(0 0% 100%);
/* 边框和输入框 */
--color-border: hsl(220 13% 91%);
--color-input: hsl(220 13% 91%);
--color-ring: hsl(226 70% 55%);
/* 侧边栏专用色 */
--color-sidebar: hsl(222 47% 11%);
--color-sidebar-foreground: hsl(210 20% 98%);
--color-sidebar-muted: hsl(220 17% 17%);
--color-sidebar-accent: hsl(226 70% 55%);
--radius: 0.625rem;
}
.dark {
--color-background: hsl(240 10% 4%);
--color-foreground: hsl(0 0% 95%);
--color-card: hsl(240 10% 4%);
--color-card-foreground: hsl(0 0% 95%);
--color-popover: hsl(240 10% 4%);
--color-popover-foreground: hsl(0 0% 95%);
--color-background: hsl(222 47% 6%);
--color-foreground: hsl(210 20% 98%);
--color-card: hsl(222 47% 9%);
--color-card-foreground: hsl(210 20% 98%);
--color-popover: hsl(222 47% 9%);
--color-popover-foreground: hsl(210 20% 98%);
/* 主色调 - 深色模式下的浅粉色 */
--color-primary: hsl(350 89% 75%);
--color-primary-foreground: hsl(240 10% 4%);
/* 主色调 - 深色模式下的靛蓝 */
--color-primary: hsl(226 76% 60%);
--color-primary-foreground: hsl(0 0% 100%);
--color-secondary: hsl(240 4% 16%);
--color-secondary-foreground: hsl(0 0% 95%);
--color-secondary: hsl(222 30% 14%);
--color-secondary-foreground: hsl(210 20% 98%);
--color-muted: hsl(240 4% 16%);
--color-muted-foreground: hsl(240 5% 65%);
--color-muted: hsl(222 30% 14%);
--color-muted-foreground: hsl(220 9% 60%);
--color-accent: hsl(350 40% 18%);
--color-accent-foreground: hsl(350 89% 80%);
--color-accent: hsl(222 30% 14%);
--color-accent-foreground: hsl(210 20% 98%);
--color-destructive: hsl(0 63% 50%);
--color-destructive-foreground: hsl(0 0% 95%);
--color-destructive: hsl(0 62% 50%);
--color-destructive-foreground: hsl(0 0% 98%);
--color-border: hsl(240 4% 16%);
--color-input: hsl(240 4% 16%);
--color-ring: hsl(350 89% 75%);
/* 成功色 - 深色模式 */
--color-success: hsl(160 84% 35%);
--color-success-foreground: hsl(0 0% 98%);
/* 警告色 - 深色模式 */
--color-warning: hsl(38 92% 45%);
--color-warning-foreground: hsl(0 0% 98%);
/* 信息色 - 深色模式 */
--color-info: hsl(199 89% 42%);
--color-info-foreground: hsl(0 0% 98%);
--color-border: hsl(222 30% 18%);
--color-input: hsl(222 30% 18%);
--color-ring: hsl(226 76% 60%);
/* 侧边栏专用色 - 深色模式 */
--color-sidebar: hsl(222 47% 8%);
--color-sidebar-foreground: hsl(210 20% 98%);
--color-sidebar-muted: hsl(222 30% 12%);
--color-sidebar-accent: hsl(226 76% 60%);
}
@layer base {
* {
@apply border-border;
}
html {
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
@apply bg-background text-foreground antialiased;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
a,
button,
[role='button'],
label {
cursor: pointer;
}
a:active,
button:active,
[role='button']:active {
opacity: 0.85;
}
/* 优化标题字重 */
h1, h2, h3, h4, h5, h6 {
font-weight: 600;
letter-spacing: -0.02em;
}
}
@@ -79,6 +142,7 @@
@layer utilities {
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: hsl(220 9% 46% / 0.3) transparent;
}
.scrollbar-thin::-webkit-scrollbar {
@@ -91,11 +155,89 @@
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background: hsl(240 4% 46% / 0.3);
background: hsl(220 9% 46% / 0.3);
border-radius: 3px;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
background: hsl(240 4% 46% / 0.5);
background: hsl(220 9% 46% / 0.5);
}
/* 玻璃效果 */
.glass {
backdrop-filter: blur(12px) saturate(180%);
background: hsl(0 0% 100% / 0.8);
}
.dark .glass {
background: hsl(222 47% 9% / 0.8);
}
/* 渐变边框效果 */
.gradient-border {
position: relative;
}
.gradient-border::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
padding: 1px;
background: linear-gradient(135deg, hsl(226 70% 55% / 0.3), hsl(160 84% 39% / 0.3));
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
/* 柔和阴影 */
.shadow-soft {
box-shadow:
0 1px 2px hsl(220 9% 46% / 0.04),
0 4px 8px hsl(220 9% 46% / 0.04),
0 8px 16px hsl(220 9% 46% / 0.04);
}
.dark .shadow-soft {
box-shadow:
0 1px 2px hsl(0 0% 0% / 0.2),
0 4px 8px hsl(0 0% 0% / 0.2),
0 8px 16px hsl(0 0% 0% / 0.2);
}
/* 动画效果 */
.animate-in {
animation: animate-in 0.2s ease-out;
}
@keyframes animate-in {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 悬停提升效果 */
.hover-lift {
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.hover-lift:hover {
transform: translateY(-2px);
box-shadow:
0 4px 12px hsl(220 9% 46% / 0.1),
0 8px 24px hsl(220 9% 46% / 0.1);
}
.dark .hover-lift:hover {
box-shadow:
0 4px 12px hsl(0 0% 0% / 0.3),
0 8px 24px hsl(0 0% 0% / 0.3);
}
}
+7 -10
View File
@@ -9,6 +9,7 @@ import { Badge } from '@/components/ui/badge'
import { useToast } from '@/components/ui/use-toast'
import { apiClient } from '@/api/client'
import { Send } from 'lucide-react'
import { PageContainer } from '@/components/ui/page-container'
const requestTemplates: Record<string, any> = {
'/v1/chat/completions': {
@@ -103,11 +104,7 @@ export function ApiTest() {
}
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold tracking-tight">API 测试</h1>
</div>
<PageContainer title="API 测试">
<Card>
<CardHeader>
<CardTitle>API 测试工具</CardTitle>
@@ -115,7 +112,7 @@ export function ApiTest() {
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="endpoint">API 端点</Label>
<Select
@@ -156,7 +153,7 @@ export function ApiTest() {
<p className="text-sm text-muted-foreground">直接编辑 JSON 格式的请求体</p>
</div>
<Button type="submit" disabled={isLoading}>
<Button type="submit" disabled={isLoading} className="w-full sm:w-auto">
<Send className="h-4 w-4" />
{isLoading ? '发送中...' : '发送请求'}
</Button>
@@ -164,7 +161,7 @@ export function ApiTest() {
{response && (
<div className="mt-6 space-y-3">
<div className="flex items-center justify-between">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<h3 className="font-semibold">响应结果</h3>
<div className="flex gap-2">
<Badge variant={statusCode === 200 ? 'success' : 'destructive'}>
@@ -174,7 +171,7 @@ export function ApiTest() {
</div>
</div>
<Card>
<CardContent className="pt-6">
<CardContent className="p-4">
<pre className="whitespace-pre-wrap break-words text-sm font-mono max-h-96 overflow-y-auto">
{JSON.stringify(response, null, 2)}
</pre>
@@ -184,6 +181,6 @@ export function ApiTest() {
)}
</CardContent>
</Card>
</div>
</PageContainer>
)
}
+384 -243
View File
@@ -1,26 +1,43 @@
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { apiClient } from '@/api/client'
import { Channel, ChannelConfig } from '@/types'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { useToast } from '@/components/ui/use-toast'
import { cn } from '@/lib/utils'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Plus, RefreshCw, Pencil, Trash2, FileJson, FileText } from 'lucide-react'
Plus,
RefreshCw,
Pencil,
Trash2,
FileJson,
FileText,
Link as LinkIcon,
ArrowLeft,
Check,
MoreHorizontal,
Search,
ArrowRight,
Globe,
Cpu,
} from 'lucide-react'
import { PageContainer } from '@/components/ui/page-container'
type EditMode = 'form' | 'json'
const channelTypes = [
{ value: 'azure-openai', label: 'Azure OpenAI' },
{ value: 'openai', label: 'OpenAI' },
{ value: 'claude', label: 'Claude' },
{ value: 'openai-responses', label: 'OpenAI Responses' },
{ value: 'azure-openai-responses', label: 'Azure OpenAI Responses' },
]
export function Channels() {
const [view, setView] = useState<'list' | 'form'>('list')
const [editMode, setEditMode] = useState<EditMode>('form')
@@ -36,11 +53,13 @@ export function Channels() {
const [channelKey, setChannelKey] = useState('')
const [jsonValue, setJsonValue] = useState('')
const [mapperRows, setMapperRows] = useState<Array<{ request: string; deployment: string }>>([])
const [searchQuery, setSearchQuery] = useState('')
const [openMenu, setOpenMenu] = useState<string | null>(null)
const { addToast } = useToast()
const queryClient = useQueryClient()
const { data, isLoading, refetch } = useQuery({
const { data, isLoading, isFetching, refetch } = useQuery({
queryKey: ['channels'],
queryFn: async () => {
const response = await apiClient.getChannels()
@@ -48,6 +67,12 @@ export function Channels() {
},
})
useEffect(() => {
const handleClick = () => setOpenMenu(null)
document.addEventListener('click', handleClick)
return () => document.removeEventListener('click', handleClick)
}, [])
const saveMutation = useMutation({
mutationFn: async ({ key, config }: { key: string; config: any }) => {
return apiClient.saveChannel(key, config)
@@ -104,7 +129,6 @@ export function Channels() {
setFormData(config)
setJsonValue(JSON.stringify(config, null, 2))
// Convert deployment_mapper to rows
if (config.deployment_mapper) {
const rows = Object.entries(config.deployment_mapper).map(([request, deployment]) => ({
request,
@@ -112,12 +136,11 @@ export function Channels() {
}))
setMapperRows(rows)
}
setView('form')
}
const handleDelete = (key: string) => {
if (confirm(`确定要删除频道 "${key}" 吗?`)) {
if (confirm(`确定要删除此频道吗?`)) {
deleteMutation.mutate(key)
}
}
@@ -135,7 +158,6 @@ export function Channels() {
return
}
// Build deployment_mapper from rows
const deployment_mapper: Record<string, string> = {}
mapperRows.forEach((row) => {
if (row.request && row.deployment) {
@@ -143,17 +165,14 @@ export function Channels() {
}
})
config = {
...formData,
deployment_mapper,
}
config = { ...formData, deployment_mapper }
if (!formData.api_version) {
delete config.api_version
}
} else {
try {
config = JSON.parse(jsonValue)
} catch (error) {
} catch {
addToast('JSON格式错误', 'error')
return
}
@@ -164,7 +183,6 @@ export function Channels() {
const toggleEditMode = () => {
if (editMode === 'form') {
// Switch to JSON
const deployment_mapper: Record<string, string> = {}
mapperRows.forEach((row) => {
if (row.request && row.deployment) {
@@ -175,7 +193,6 @@ export function Channels() {
setJsonValue(JSON.stringify(config, null, 2))
setEditMode('json')
} else {
// Switch to form
try {
const config = JSON.parse(jsonValue)
setFormData(config)
@@ -187,7 +204,7 @@ export function Channels() {
setMapperRows(rows)
}
setEditMode('form')
} catch (error) {
} catch {
addToast('JSON格式错误', 'error')
}
}
@@ -207,257 +224,381 @@ export function Channels() {
setMapperRows(newRows)
}
const getTypeLabel = (type: string) => {
return channelTypes.find((t) => t.value === type)?.label || type
}
const filteredData = data?.filter((channel) => {
if (!searchQuery) return true
const config = typeof channel.value === 'string' ? JSON.parse(channel.value) : channel.value
return (
config.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
channel.key.toLowerCase().includes(searchQuery.toLowerCase())
)
})
// List View
if (view === 'list') {
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold tracking-tight">频道管理</h1>
</div>
<PageContainer
title="频道管理"
description="配置 AI 服务提供商连接"
actions={
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => refetch()} disabled={isFetching}>
<RefreshCw className={cn("h-4 w-4", isFetching && "animate-spin")} />
</Button>
<Button size="sm" onClick={handleAdd}>
<Plus className="h-4 w-4" />
<span className="hidden sm:inline ml-1">添加</span>
</Button>
</div>
}
>
{/* Search */}
{data && data.length > 0 && (
<div className="mb-4">
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索频道..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
</div>
)}
<Card>
<CardHeader>
<div className="flex justify-end gap-2">
<Button onClick={handleAdd}>
<Plus className="h-4 w-4" />
{isLoading ? (
<div className="flex items-center justify-center py-20">
<div className="flex flex-col items-center gap-3">
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
<span className="text-sm text-muted-foreground">加载中...</span>
</div>
</div>
) : !data || data.length === 0 ? (
<Card className="border-dashed border-2">
<CardContent className="flex flex-col items-center justify-center py-16 px-4">
<div className="w-14 h-14 rounded-2xl bg-primary/10 flex items-center justify-center mb-4">
<LinkIcon className="h-7 w-7 text-primary" />
</div>
<h3 className="font-semibold text-lg mb-2">添加您的第一个频道</h3>
<p className="text-muted-foreground text-sm text-center max-w-sm mb-6">
频道连接到 AI 服务提供商(如 OpenAI、Azure、Claude),用于代理和转发 API 请求。
</p>
<Button onClick={handleAdd} size="lg">
<Plus className="h-4 w-4 mr-2" />
添加频道
</Button>
<Button variant="secondary" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4" />
刷新
</Button>
</CardContent>
</Card>
) : (
<Card>
<div className="divide-y">
{filteredData?.map((channel) => {
const config = typeof channel.value === 'string' ? JSON.parse(channel.value) : channel.value
const modelCount = Object.keys(config.deployment_mapper || {}).length
const isMenuOpen = openMenu === channel.key
return (
<div
key={channel.key}
className="p-4 hover:bg-muted/30 transition-colors"
>
{/* Mobile Layout */}
<div className="md:hidden space-y-3">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{config.name}</div>
<div className="text-xs text-muted-foreground font-mono mt-0.5">{channel.key}</div>
</div>
<div className="relative">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation()
setOpenMenu(isMenuOpen ? null : channel.key)
}}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
{isMenuOpen && (
<div className="absolute right-0 top-full mt-1 w-32 bg-popover border rounded-lg shadow-lg py-1 z-10">
<button
className="w-full px-3 py-2 text-sm text-left hover:bg-muted flex items-center gap-2"
onClick={() => handleEdit(channel)}
>
<Pencil className="h-4 w-4" />
编辑
</button>
<button
className="w-full px-3 py-2 text-sm text-left hover:bg-muted flex items-center gap-2 text-destructive"
onClick={() => handleDelete(channel.key)}
>
<Trash2 className="h-4 w-4" />
删除
</button>
</div>
)}
</div>
</div>
<div className="flex items-center gap-3 text-sm">
<span className="px-2 py-0.5 rounded bg-muted text-muted-foreground text-xs">
{getTypeLabel(config.type)}
</span>
<span className="text-muted-foreground">
{modelCount} 个模型
</span>
</div>
</div>
{/* Desktop Layout */}
<div className="hidden md:flex md:items-center md:gap-4">
<div className="flex-1 min-w-0">
<div className="font-medium">{config.name}</div>
<div className="text-xs text-muted-foreground font-mono">{channel.key}</div>
</div>
<div className="flex-shrink-0">
<span className="px-2 py-1 rounded-md bg-muted text-muted-foreground text-xs font-medium whitespace-nowrap">
{getTypeLabel(config.type)}
</span>
</div>
<div className="w-40 text-sm text-muted-foreground truncate font-mono" title={config.endpoint}>
{config.endpoint.replace(/^https?:\/\//, '').split('/')[0]}
</div>
<div className="w-20 text-sm text-center flex-shrink-0">
<span className="text-muted-foreground">{modelCount} 模型</span>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<Button variant="ghost" size="sm" onClick={() => handleEdit(channel)}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive" onClick={() => handleDelete(channel.key)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)
})}
{filteredData?.length === 0 && searchQuery && (
<div className="p-8 text-center text-muted-foreground">
未找到匹配的频道
</div>
)}
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">加载中...</div>
) : !data || data.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">暂无频道数据</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>名称</TableHead>
<TableHead>标识</TableHead>
<TableHead>类型</TableHead>
<TableHead>端点</TableHead>
<TableHead>模型数</TableHead>
<TableHead className="text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((channel) => {
const config =
typeof channel.value === 'string'
? JSON.parse(channel.value)
: channel.value
return (
<TableRow key={channel.key}>
<TableCell className="font-medium">{config.name}</TableCell>
<TableCell className="font-mono text-sm">{channel.key}</TableCell>
<TableCell>{config.type}</TableCell>
<TableCell className="max-w-xs truncate">{config.endpoint}</TableCell>
<TableCell>
{Object.keys(config.deployment_mapper || {}).length}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleEdit(channel)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => handleDelete(channel.key)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
</Card>
)}
</PageContainer>
)
}
// Form View
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold tracking-tight">
{editingKey ? '编辑频道' : '添加频道'}
</h1>
</div>
<Card>
<CardHeader>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={toggleEditMode}>
{editMode === 'form' ? <FileJson className="h-4 w-4" /> : <FileText className="h-4 w-4" />}
{editMode === 'form' ? '切换到JSON模式' : '切换到表单模式'}
</Button>
<Button onClick={handleSave}>保存频道</Button>
<Button variant="secondary" onClick={() => setView('list')}>
返回列表
<div className="p-4 md:p-6 lg:p-8 animate-in">
<div className="max-w-2xl mx-auto">
{/* Header */}
<div className="mb-6">
<Button variant="ghost" size="sm" className="mb-3 -ml-2 text-muted-foreground" onClick={() => setView('list')}>
<ArrowLeft className="h-4 w-4 mr-1" />
返回列表
</Button>
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold tracking-tight">{editingKey ? '编辑频道' : '添加频道'}</h1>
<Button variant="outline" size="sm" onClick={toggleEditMode}>
{editMode === 'form' ? <FileJson className="h-4 w-4 mr-1" /> : <FileText className="h-4 w-4 mr-1" />}
{editMode === 'form' ? 'JSON' : '表单'}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="channelKey">频道标识 *</Label>
</div>
<div className="space-y-6">
{/* Channel Key */}
<Card>
<CardContent className="p-5">
<h3 className="font-medium mb-4">频道标识</h3>
<p className="text-sm text-muted-foreground mb-3">用于内部识别的唯一标识</p>
<Input
id="channelKey"
value={channelKey}
onChange={(e) => setChannelKey(e.target.value)}
placeholder="例如: azure-openai-1"
placeholder="例如:azure-gpt4-east"
disabled={!!editingKey}
className="font-mono text-sm"
/>
</div>
</CardContent>
</Card>
{editMode === 'form' ? (
<>
<div className="space-y-2">
<Label htmlFor="name">频道名称 *</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="例如: Azure OpenAI"
/>
</div>
<div className="space-y-2">
<Label htmlFor="type">频道类型 *</Label>
<Select
id="type"
value={formData.type}
onChange={(e) =>
setFormData({ ...formData, type: e.target.value as any })
}
>
<option value="azure-openai">Azure OpenAI</option>
<option value="openai">OpenAI</option>
<option value="claude">Claude</option>
<option value="openai-responses">OpenAI Responses</option>
<option value="azure-openai-responses">Azure OpenAI Responses</option>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="endpoint">API 端点 *</Label>
<Input
id="endpoint"
value={formData.endpoint}
onChange={(e) => setFormData({ ...formData, endpoint: e.target.value })}
placeholder="https://your-resource.openai.azure.com/"
/>
</div>
<div className="space-y-2">
<Label htmlFor="api_key">API 密钥 *</Label>
<Input
id="api_key"
type="password"
value={formData.api_key}
onChange={(e) => setFormData({ ...formData, api_key: e.target.value })}
placeholder="your-api-key"
/>
</div>
{(formData.type === 'azure-openai' || formData.type === 'claude') && (
<div className="space-y-2">
<Label htmlFor="api_version">API 版本(可选)</Label>
<Input
id="api_version"
value={formData.api_version || ''}
onChange={(e) => setFormData({ ...formData, api_version: e.target.value })}
placeholder="Claude: 2023-06-01 / Azure: 2024-02-01"
/>
{editMode === 'form' ? (
<>
{/* Basic Info */}
<Card>
<CardContent className="p-5">
<h3 className="font-medium mb-4">基本信息</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-sm">频道名称 <span className="text-destructive">*</span></Label>
<Input
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="例如:Azure GPT-4 东部"
/>
</div>
<div className="space-y-2">
<Label className="text-sm">频道类型 <span className="text-destructive">*</span></Label>
<Select
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value as any })}
>
{channelTypes.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</Select>
</div>
</div>
)}
</CardContent>
</Card>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>模型部署映射</Label>
<Button type="button" variant="secondary" size="sm" onClick={addMapperRow}>
<Plus className="h-4 w-4" />
添加映射
{/* Connection */}
<Card>
<CardContent className="p-5">
<h3 className="font-medium mb-4">连接配置</h3>
<div className="space-y-4">
<div className="space-y-2">
<Label className="text-sm flex items-center gap-2">
<Globe className="h-4 w-4 text-muted-foreground" />
API 端点 <span className="text-destructive">*</span>
</Label>
<Input
value={formData.endpoint}
onChange={(e) => setFormData({ ...formData, endpoint: e.target.value })}
placeholder="https://your-resource.openai.azure.com/"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-sm">API 密钥 <span className="text-destructive">*</span></Label>
<Input
type="password"
value={formData.api_key}
onChange={(e) => setFormData({ ...formData, api_key: e.target.value })}
placeholder="sk-..."
/>
</div>
{(formData.type === 'azure-openai' || formData.type === 'claude' || formData.type === 'azure-openai-responses') && (
<div className="space-y-2">
<Label className="text-sm">API 版本</Label>
<Input
value={formData.api_version || ''}
onChange={(e) => setFormData({ ...formData, api_version: e.target.value })}
placeholder="2024-02-01"
/>
</div>
)}
</div>
</div>
</CardContent>
</Card>
{/* Model Mappings */}
<Card>
<CardContent className="p-5">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="font-medium flex items-center gap-2">
<Cpu className="h-4 w-4 text-muted-foreground" />
模型映射
</h3>
<p className="text-sm text-muted-foreground">将请求模型名映射到实际部署名</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={addMapperRow}>
<Plus className="h-4 w-4 mr-1" />
添加
</Button>
</div>
{mapperRows.length === 0 ? (
<div className="text-center py-4 text-muted-foreground text-sm">
暂无映射,点击上方按钮添加
</div>
<button
type="button"
onClick={addMapperRow}
className="w-full py-8 border-2 border-dashed rounded-xl text-sm text-muted-foreground hover:text-foreground hover:border-primary/50 transition-colors flex flex-col items-center justify-center gap-2"
>
<Plus className="h-5 w-5" />
添加模型映射
</button>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>请求模型名</TableHead>
<TableHead>部署模型名</TableHead>
<TableHead className="w-20">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{mapperRows.map((row, index) => (
<TableRow key={index}>
<TableCell>
<Input
value={row.request}
onChange={(e) =>
updateMapperRow(index, 'request', e.target.value)
}
placeholder="例如: gpt-4"
/>
</TableCell>
<TableCell>
<Input
value={row.deployment}
onChange={(e) =>
updateMapperRow(index, 'deployment', e.target.value)
}
placeholder="例如: gpt-4-deployment"
/>
</TableCell>
<TableCell>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => removeMapperRow(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="space-y-2">
{mapperRows.map((row, index) => (
<div key={index} className="flex items-center gap-2 p-3 bg-muted/50 rounded-lg">
<Input
value={row.request}
onChange={(e) => updateMapperRow(index, 'request', e.target.value)}
placeholder="gpt-4"
className="flex-1 bg-background text-sm"
/>
<ArrowRight className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<Input
value={row.deployment}
onChange={(e) => updateMapperRow(index, 'deployment', e.target.value)}
placeholder="gpt-4-0613"
className="flex-1 bg-background text-sm"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 text-destructive hover:text-destructive flex-shrink-0"
onClick={() => removeMapperRow(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</div>
</>
) : (
<div className="space-y-2">
<Label htmlFor="jsonValue">频道配置 (JSON)</Label>
</CardContent>
</Card>
</>
) : (
<Card>
<CardContent className="p-5">
<h3 className="font-medium mb-4">JSON 配置</h3>
<Textarea
id="jsonValue"
value={jsonValue}
onChange={(e) => setJsonValue(e.target.value)}
rows={20}
rows={18}
className="font-mono text-sm"
placeholder='{"name": "Azure OpenAI", "type": "azure-openai", ...}'
/>
</div>
)}
</CardContent>
</Card>
</CardContent>
</Card>
)}
{/* Actions */}
<div className="flex items-center justify-end gap-3 pt-2">
<Button variant="outline" onClick={() => setView('list')}>
取消
</Button>
<Button onClick={handleSave} disabled={saveMutation.isPending}>
{saveMutation.isPending ? (
<>
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
保存中...
</>
) : (
<>
<Check className="h-4 w-4 mr-2" />
保存频道
</>
)}
</Button>
</div>
</div>
</div>
</div>
)
}
+133 -99
View File
@@ -1,108 +1,142 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Globe, Gauge, BarChart3, Shield } from 'lucide-react'
import { Globe, Gauge, BarChart3, Shield, Zap, ArrowRight } from 'lucide-react'
import { PageContainer } from '@/components/ui/page-container'
import { Button } from '@/components/ui/button'
import { useAuthStore } from '@/store/auth'
import { Link } from 'react-router-dom'
const features = [
{
icon: Globe,
title: '多提供商支持',
description: '支持 OpenAI、Azure OpenAI 和 Claude API,轻松切换不同的 AI 服务。',
color: 'from-blue-500 to-blue-600',
},
{
icon: Gauge,
title: '负载均衡',
description: '自动在多个频道间分配请求,确保服务稳定性和高可用性。',
color: 'from-emerald-500 to-emerald-600',
},
{
icon: BarChart3,
title: '用量追踪',
description: '实时监控 token 使用情况,精确计算成本,防止超出配额。',
color: 'from-amber-500 to-amber-600',
},
{
icon: Shield,
title: '安全管理',
description: '基于令牌的访问控制,为不同用户分配独立的 API 密钥。',
color: 'from-purple-500 to-purple-600',
},
]
const steps = [
{ num: '01', title: '管理员登录', desc: '使用管理员令牌登录系统' },
{ num: '02', title: '配置频道', desc: '添加 OpenAI、Azure 或 Claude API 频道' },
{ num: '03', title: '创建令牌', desc: '为您的应用创建 API 令牌' },
{ num: '04', title: '开始使用', desc: '使用生成的令牌调用 API' },
]
export function Dashboard() {
const { isAuthenticated, openAuthModal } = useAuthStore()
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">仪表盘</h1>
</div>
<Card>
<CardHeader>
<CardTitle>欢迎使用 Awsl One API</CardTitle>
<CardDescription>统一的 AI 模型接口管理服务</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<p className="text-muted-foreground">
Awsl One API 是一个强大的 API 代理和管理平台,为您提供统一的 AI 模型接口管理服务。
</p>
<div>
<h3 className="font-semibold mb-4">核心特性</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-start gap-3">
<Globe className="h-5 w-5 text-primary mt-1" />
<div>
<h4 className="font-semibold mb-2">多提供商支持</h4>
<p className="text-sm text-muted-foreground">
支持 OpenAI、Azure OpenAI 和 Claude API,轻松切换不同的 AI 服务。
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-start gap-3">
<Gauge className="h-5 w-5 text-primary mt-1" />
<div>
<h4 className="font-semibold mb-2">负载均衡</h4>
<p className="text-sm text-muted-foreground">
自动在多个频道间分配请求,确保服务稳定性和高可用性。
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-start gap-3">
<BarChart3 className="h-5 w-5 text-primary mt-1" />
<div>
<h4 className="font-semibold mb-2">用量追踪</h4>
<p className="text-sm text-muted-foreground">
实时监控 token 使用情况,精确计算成本,防止超出配额。
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-start gap-3">
<Shield className="h-5 w-5 text-primary mt-1" />
<div>
<h4 className="font-semibold mb-2">安全管理</h4>
<p className="text-sm text-muted-foreground">
基于令牌的访问控制,为不同用户分配独立的 API 密钥。
</p>
</div>
</div>
</CardContent>
</Card>
<div className="p-4 md:p-6 lg:p-8 animate-in">
<div className="space-y-8">
{/* Hero Section */}
<Card className="overflow-hidden border-0 bg-gradient-to-br from-primary/10 via-background to-background">
<CardContent className="p-8 md:p-12">
<div className="max-w-2xl">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-primary/10 text-primary text-sm font-medium mb-4">
<Zap className="h-4 w-4" />
统一 AI 接口网关
</div>
<h1 className="text-3xl md:text-4xl font-bold tracking-tight mb-4">
欢迎使用 Awsl One API
</h1>
<p className="text-lg text-muted-foreground mb-6">
一个强大的 API 代理和管理平台,为您提供统一的 AI 模型接口管理服务。
支持多种 AI 服务提供商,实现智能负载均衡和精确用量追踪。
</p>
<div className="flex flex-wrap gap-3">
{isAuthenticated ? (
<>
<Button asChild>
<Link to="/channels">
管理频道
<ArrowRight className="h-4 w-4" />
</Link>
</Button>
<Button variant="outline" asChild>
<Link to="/api-test">测试 API</Link>
</Button>
</>
) : (
<>
<Button onClick={openAuthModal}>
管理员登录
<ArrowRight className="h-4 w-4" />
</Button>
<Button variant="outline" asChild>
<Link to="/api-test">体验 API</Link>
</Button>
</>
)}
</div>
</div>
</div>
</CardContent>
</Card>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>快速开始</CardTitle>
<CardDescription>按照以下步骤开始使用</CardDescription>
</CardHeader>
<CardContent>
<ol className="list-decimal list-inside space-y-2 text-sm text-muted-foreground">
<li>
<strong className="text-foreground">管理员登录:</strong>使用管理员令牌登录系统
</li>
<li>
<strong className="text-foreground">配置频道:</strong>添加 OpenAI、Azure 或 Claude API 频道
</li>
<li>
<strong className="text-foreground">创建令牌:</strong>为您的应用创建 API 令牌
</li>
<li>
<strong className="text-foreground">开始使用:</strong>使用生成的令牌调用 API
</li>
</ol>
</CardContent>
</Card>
{/* Features Grid */}
<div>
<h2 className="text-xl font-semibold mb-4">核心特性</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{features.map((feature) => (
<Card key={feature.title} className="group hover-lift">
<CardContent className="p-6">
<div className={`w-10 h-10 rounded-xl bg-gradient-to-br ${feature.color} flex items-center justify-center mb-4 shadow-lg group-hover:scale-110 transition-transform duration-200`}>
<feature.icon className="h-5 w-5 text-white" />
</div>
<h3 className="font-semibold mb-2">{feature.title}</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
{feature.description}
</p>
</CardContent>
</Card>
))}
</div>
</div>
{/* Quick Start */}
<Card>
<CardHeader>
<CardTitle>快速开始</CardTitle>
<CardDescription>按照以下步骤开始使用</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{steps.map((step, index) => (
<div key={step.num} className="relative">
{index < steps.length - 1 && (
<div className="hidden lg:block absolute top-6 left-full w-full h-px bg-border -translate-x-4" />
)}
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-12 h-12 rounded-xl bg-muted flex items-center justify-center">
<span className="text-lg font-bold text-muted-foreground">{step.num}</span>
</div>
<div>
<h4 className="font-semibold mb-1">{step.title}</h4>
<p className="text-sm text-muted-foreground">{step.desc}</p>
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
)
}
+166 -138
View File
@@ -2,22 +2,24 @@ import { useState, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { apiClient } from '@/api/client'
import { PricingConfig } from '@/types'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { useToast } from '@/components/ui/use-toast'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { cn } from '@/lib/utils'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Plus, RefreshCw, Trash2, FileJson, FileText, Info } from 'lucide-react'
Plus,
RefreshCw,
Trash2,
FileJson,
FileText,
DollarSign,
Check,
Info,
Search,
} from 'lucide-react'
import { PageContainer } from '@/components/ui/page-container'
type EditMode = 'table' | 'json'
@@ -27,12 +29,12 @@ export function Pricing() {
const [pricingRows, setPricingRows] = useState<
Array<{ model: string; input: number; output: number }>
>([])
const [isInitialized, setIsInitialized] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const { addToast } = useToast()
const queryClient = useQueryClient()
const { data, isLoading, refetch } = useQuery({
const { data, isLoading, isFetching, refetch } = useQuery({
queryKey: ['pricing'],
queryFn: async () => {
const response = await apiClient.getPricing()
@@ -40,9 +42,8 @@ export function Pricing() {
},
})
// Initialize rows when data first loads - sync server state to local editable state
useEffect(() => {
if (data && !isInitialized) {
if (data) {
const rows = Object.entries(data).map(([model, pricing]) => ({
model,
input: pricing.input,
@@ -50,9 +51,8 @@ export function Pricing() {
}))
setPricingRows(rows)
setJsonValue(JSON.stringify(data, null, 2))
setIsInitialized(true)
}
}, [data, isInitialized])
}, [data])
const saveMutation = useMutation({
mutationFn: async (config: PricingConfig) => {
@@ -88,7 +88,7 @@ export function Pricing() {
} else {
try {
config = JSON.parse(jsonValue)
} catch (error) {
} catch {
addToast('JSON格式错误', 'error')
return
}
@@ -99,7 +99,6 @@ export function Pricing() {
const toggleEditMode = () => {
if (editMode === 'table') {
// Switch to JSON
const config: PricingConfig = {}
pricingRows.forEach((row) => {
if (row.model) {
@@ -112,7 +111,6 @@ export function Pricing() {
setJsonValue(JSON.stringify(config, null, 2))
setEditMode('json')
} else {
// Switch to table
try {
const config = JSON.parse(jsonValue)
const rows = Object.entries(config).map(([model, pricing]: [string, unknown]) => ({
@@ -122,7 +120,7 @@ export function Pricing() {
}))
setPricingRows(rows)
setEditMode('table')
} catch (error) {
} catch {
addToast('JSON格式错误', 'error')
}
}
@@ -136,134 +134,164 @@ export function Pricing() {
setPricingRows(pricingRows.filter((_, i) => i !== index))
}
const updateRow = (index: number, field: 'model' | 'input' | 'output', value: any) => {
const updateRow = (index: number, field: 'model' | 'input' | 'output', value: string | number) => {
const newRows = [...pricingRows]
newRows[index][field] = value
;(newRows[index] as any)[field] = value
setPricingRows(newRows)
}
const filteredRows = pricingRows
.map((row, index) => ({ ...row, _i: index }))
.filter((row) => row.model.toLowerCase().includes(searchQuery.toLowerCase()))
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold tracking-tight">定价管理</h1>
<PageContainer
title="定价管理"
description="配置模型使用成本倍率"
actions={
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => refetch()} disabled={isFetching}>
<RefreshCw className={cn("h-4 w-4", isFetching && "animate-spin")} />
</Button>
<Button variant="outline" size="sm" onClick={toggleEditMode}>
{editMode === 'table' ? <FileJson className="h-4 w-4" /> : <FileText className="h-4 w-4" />}
<span className="hidden sm:inline ml-1">{editMode === 'table' ? 'JSON' : '表格'}</span>
</Button>
<Button size="sm" onClick={handleSave} disabled={saveMutation.isPending}>
<Check className="h-4 w-4 mr-1" />
保存
</Button>
</div>
}
>
{/* Info */}
<div className="mb-4 px-4 py-3 rounded-lg bg-muted/50 text-sm text-muted-foreground flex items-start gap-2.5">
<Info className="h-4 w-4 mt-0.5 flex-shrink-0 text-primary" />
<span>
基础配额单位:<span className="font-mono text-foreground">1M tokens = $1.00</span>。
倍率表示相对成本,如输入倍率 30 = 每百万 token 消耗 $30 配额。
</span>
</div>
<Card>
<CardHeader>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={toggleEditMode}>
{editMode === 'table' ? <FileJson className="h-4 w-4" /> : <FileText className="h-4 w-4" />}
{editMode === 'table' ? '切换到JSON模式' : '切换到表格模式'}
</Button>
{editMode === 'table' && (
<Button variant="secondary" onClick={addRow}>
<Plus className="h-4 w-4" />
添加模型
</Button>
)}
<Button onClick={handleSave}>更新定价</Button>
<Button variant="secondary" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4" />
刷新
{editMode === 'table' ? (
isLoading ? (
<div className="flex items-center justify-center py-20">
<div className="flex flex-col items-center gap-3">
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
<span className="text-sm text-muted-foreground">加载中...</span>
</div>
</div>
) : pricingRows.length === 0 ? (
<div className="rounded-xl border border-dashed flex flex-col items-center justify-center py-16">
<div className="w-16 h-16 rounded-2xl bg-muted flex items-center justify-center mb-4">
<DollarSign className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="font-semibold text-lg mb-1">暂无定价配置</h3>
<p className="text-muted-foreground text-sm mb-4">添加模型定价倍率</p>
<Button onClick={addRow}>
<Plus className="h-4 w-4 mr-1" />
添加模型
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
<strong>定价说明:</strong>
基础配额单位:1百万token = $1.00 | 定价配置为不同模型相对于基础单位的倍率 |
例如:gpt-4的input倍率为30,表示1百万token消耗30美元配额
</AlertDescription>
</Alert>
{editMode === 'table' ? (
isLoading ? (
<div className="text-center py-8 text-muted-foreground">加载中...</div>
) : pricingRows.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
暂无定价配置,点击"添加模型"按钮添加
) : (
<div className="space-y-3">
{/* Search */}
{pricingRows.length > 5 && (
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索模型..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>模型名称</TableHead>
<TableHead>输入倍率</TableHead>
<TableHead>输出倍率</TableHead>
<TableHead className="w-20">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pricingRows.map((row, index) => (
<TableRow key={index}>
<TableCell>
<Input
value={row.model}
onChange={(e) => updateRow(index, 'model', e.target.value)}
placeholder="例如: gpt-4"
/>
</TableCell>
<TableCell>
<Input
type="number"
value={row.input}
onChange={(e) =>
updateRow(index, 'input', parseFloat(e.target.value) || 0)
}
step="0.000001"
min="0"
placeholder="0.001"
/>
</TableCell>
<TableCell>
<Input
type="number"
value={row.output}
onChange={(e) =>
updateRow(index, 'output', parseFloat(e.target.value) || 0)
}
step="0.000001"
min="0"
placeholder="0.002"
/>
</TableCell>
<TableCell>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => removeRow(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
) : (
<div className="space-y-2">
<Label htmlFor="jsonValue">定价配置 (JSON)</Label>
<Textarea
id="jsonValue"
value={jsonValue}
onChange={(e) => setJsonValue(e.target.value)}
rows={20}
className="font-mono text-sm"
placeholder='{"gpt-3.5-turbo": {"input": 0.001, "output": 0.002}, "gpt-4": {"input": 0.03, "output": 0.06}}'
/>
<p className="text-sm text-muted-foreground">
配置格式:模型名称 -&gt; {'{'}input: 输入倍率, output: 输出倍率{'}'}
。倍率基于1百万token=$1.00计算
</p>
)}
{/* Table */}
<div className="rounded-xl border bg-card overflow-hidden">
{/* Header */}
<div className="grid gap-2 px-4 py-2.5 border-b bg-muted/30" style={{ gridTemplateColumns: '2fr 1fr 1fr 40px' }}>
<span className="text-xs font-medium text-muted-foreground">模型名称</span>
<span className="text-xs font-medium text-muted-foreground">输入倍率</span>
<span className="text-xs font-medium text-muted-foreground">输出倍率</span>
<span />
</div>
{/* Rows */}
<div className="divide-y">
{filteredRows.map((row) => (
<div key={row._i} className="grid gap-2 px-4 py-2 items-center hover:bg-muted/20 transition-colors" style={{ gridTemplateColumns: '2fr 1fr 1fr 40px' }}>
<Input
value={row.model}
onChange={(e) => updateRow(row._i, 'model', e.target.value)}
placeholder="模型名称"
className="font-mono text-sm h-8"
/>
<Input
type="number"
value={row.input}
onChange={(e) => updateRow(row._i, 'input', parseFloat(e.target.value) || 0)}
step="0.001"
min="0"
className="font-mono text-sm h-8"
/>
<Input
type="number"
value={row.output}
onChange={(e) => updateRow(row._i, 'output', parseFloat(e.target.value) || 0)}
step="0.001"
min="0"
className="font-mono text-sm h-8"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={() => removeRow(row._i)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
{filteredRows.length === 0 && searchQuery && (
<div className="py-8 text-center text-sm text-muted-foreground">
没有匹配的模型
</div>
)}
</div>
)}
</CardContent>
</Card>
</div>
{/* Add */}
<button
type="button"
onClick={addRow}
className="w-full py-2.5 border border-dashed rounded-lg text-sm text-muted-foreground hover:text-foreground hover:border-primary/50 transition-colors flex items-center justify-center gap-2"
>
<Plus className="h-4 w-4" />
添加模型
</button>
</div>
)
) : (
<div className="rounded-xl border bg-card p-4">
<div className="space-y-2">
<Label className="text-sm font-medium">JSON 配置</Label>
<Textarea
value={jsonValue}
onChange={(e) => setJsonValue(e.target.value)}
rows={20}
className="font-mono text-sm"
placeholder='{"gpt-4": {"input": 30, "output": 60}, ...}'
/>
<p className="text-xs text-muted-foreground">
格式:模型名称 → {"{"} input: 输入倍率, output: 输出倍率 {"}"}
</p>
</div>
</div>
)}
</PageContainer>
)
}
+391 -253
View File
@@ -2,23 +2,32 @@ import { useState, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { apiClient } from '@/api/client'
import { Token, TokenConfig, Channel } from '@/types'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { useToast } from '@/components/ui/use-toast'
import { formatCurrency, copyToClipboard, generateTokenKey } from '@/lib/utils'
import { formatCurrency, copyToClipboard, generateTokenKey, cn } from '@/lib/utils'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Plus, RefreshCw, Pencil, Trash2, Copy, Dice5, FileJson, FileText } from 'lucide-react'
Plus,
RefreshCw,
Pencil,
Trash2,
Copy,
Sparkles,
FileJson,
FileText,
Key,
ArrowLeft,
Check,
MoreHorizontal,
ChevronRight,
AlertCircle,
Search,
} from 'lucide-react'
import { Checkbox } from '@/components/ui/checkbox'
import { PageContainer } from '@/components/ui/page-container'
type EditMode = 'form' | 'json'
@@ -35,11 +44,13 @@ export function Tokens() {
const [jsonValue, setJsonValue] = useState('')
const [availableChannels, setAvailableChannels] = useState<string[]>([])
const [selectedChannels, setSelectedChannels] = useState<string[]>([])
const [searchQuery, setSearchQuery] = useState('')
const [openMenu, setOpenMenu] = useState<string | null>(null)
const { addToast } = useToast()
const queryClient = useQueryClient()
const { data, isLoading, refetch } = useQuery({
const { data, isLoading, isFetching, refetch } = useQuery({
queryKey: ['tokens'],
queryFn: async () => {
const response = await apiClient.getTokens()
@@ -47,7 +58,6 @@ export function Tokens() {
},
})
// Load available channels
useEffect(() => {
const loadChannels = async () => {
try {
@@ -61,6 +71,13 @@ export function Tokens() {
loadChannels()
}, [])
// Close menu when clicking outside
useEffect(() => {
const handleClick = () => setOpenMenu(null)
document.addEventListener('click', handleClick)
return () => document.removeEventListener('click', handleClick)
}, [])
const saveMutation = useMutation({
mutationFn: async ({ key, config }: { key: string; config: any }) => {
return apiClient.saveToken(key, config)
@@ -90,11 +107,7 @@ export function Tokens() {
})
const resetForm = () => {
setFormData({
name: '',
channel_keys: [],
total_quota: 0,
})
setFormData({ name: '', channel_keys: [], total_quota: 0 })
setTokenKey('')
setJsonValue('')
setSelectedChannels([])
@@ -119,7 +132,7 @@ export function Tokens() {
}
const handleDelete = (key: string) => {
if (confirm(`确定要删除令牌 "${key}" 吗?`)) {
if (confirm(`确定要删除此令牌吗?`)) {
deleteMutation.mutate(key)
}
}
@@ -128,7 +141,7 @@ export function Tokens() {
try {
await copyToClipboard(text)
addToast('已复制到剪贴板', 'success')
} catch (error) {
} catch {
addToast('复制失败', 'error')
}
}
@@ -145,15 +158,11 @@ export function Tokens() {
addToast('请填写令牌名称', 'error')
return
}
config = {
...formData,
channel_keys: selectedChannels,
}
config = { ...formData, channel_keys: selectedChannels }
} else {
try {
config = JSON.parse(jsonValue)
} catch (error) {
} catch {
addToast('JSON格式错误', 'error')
return
}
@@ -173,7 +182,7 @@ export function Tokens() {
setFormData(config)
setSelectedChannels(config.channel_keys || [])
setEditMode('form')
} catch (error) {
} catch {
addToast('JSON格式错误', 'error')
}
}
@@ -181,273 +190,402 @@ export function Tokens() {
const toggleChannel = (channelKey: string) => {
setSelectedChannels((prev) =>
prev.includes(channelKey)
? prev.filter((k) => k !== channelKey)
: [...prev, channelKey]
prev.includes(channelKey) ? prev.filter((k) => k !== channelKey) : [...prev, channelKey]
)
}
const setQuotaPreset = (value: number) => {
setFormData({ ...formData, total_quota: value })
}
const quotaPresets = [
{ label: '$1', value: 1000000 },
{ label: '$5', value: 5000000 },
{ label: '$10', value: 10000000 },
{ label: '$20', value: 20000000 },
{ label: '$50', value: 50000000 },
{ label: '$100', value: 100000000 },
]
const filteredData = data?.filter((token) => {
if (!searchQuery) return true
const config = typeof token.value === 'string' ? JSON.parse(token.value) : token.value
return (
config.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
token.key.toLowerCase().includes(searchQuery.toLowerCase())
)
})
// List View
if (view === 'list') {
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold tracking-tight">令牌管理</h1>
</div>
<PageContainer
title="令牌管理"
description="管理 API 访问令牌和配额"
actions={
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => refetch()} disabled={isFetching}>
<RefreshCw className={cn("h-4 w-4", isFetching && "animate-spin")} />
</Button>
<Button size="sm" onClick={handleAdd}>
<Plus className="h-4 w-4" />
<span className="hidden sm:inline ml-1">添加</span>
</Button>
</div>
}
>
{/* Search */}
{data && data.length > 0 && (
<div className="mb-4">
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索令牌..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
</div>
)}
<Card>
<CardHeader>
<div className="flex justify-end gap-2">
<Button onClick={handleAdd}>
<Plus className="h-4 w-4" />
{isLoading ? (
<div className="flex items-center justify-center py-20">
<div className="flex flex-col items-center gap-3">
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
<span className="text-sm text-muted-foreground">加载中...</span>
</div>
</div>
) : !data || data.length === 0 ? (
<Card className="border-dashed border-2">
<CardContent className="flex flex-col items-center justify-center py-16 px-4">
<div className="w-14 h-14 rounded-2xl bg-primary/10 flex items-center justify-center mb-4">
<Key className="h-7 w-7 text-primary" />
</div>
<h3 className="font-semibold text-lg mb-2">创建您的第一个令牌</h3>
<p className="text-muted-foreground text-sm text-center max-w-sm mb-6">
令牌用于验证 API 请求。每个令牌可以设置独立的访问权限和使用配额。
</p>
<Button onClick={handleAdd} size="lg">
<Plus className="h-4 w-4 mr-2" />
添加令牌
</Button>
<Button variant="secondary" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4" />
刷新
</Button>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">加载中...</div>
) : !data || data.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">暂无令牌数据</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>名称</TableHead>
<TableHead>标识</TableHead>
<TableHead>频道</TableHead>
<TableHead>配额</TableHead>
<TableHead>已用</TableHead>
<TableHead className="text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((token) => {
const config =
typeof token.value === 'string' ? JSON.parse(token.value) : token.value
const channelKeys = config.channel_keys || []
const channelDisplay =
channelKeys.length === 0
? '所有频道'
: channelKeys.length > 2
? `${channelKeys.slice(0, 2).join(', ')}...`
: channelKeys.join(', ')
</CardContent>
</Card>
) : (
<Card>
<div className="divide-y">
{filteredData?.map((token) => {
const config = typeof token.value === 'string' ? JSON.parse(token.value) : token.value
const channelKeys = config.channel_keys || []
const usagePercent = config.total_quota > 0
? Math.min(100, ((token.usage || 0) / config.total_quota) * 100)
: 0
const isMenuOpen = openMenu === token.key
return (
<TableRow key={token.key}>
<TableCell className="font-medium">{config.name}</TableCell>
<TableCell className="font-mono text-sm text-muted-foreground">
{token.key.slice(0, 8)}...{token.key.slice(-4)}
</TableCell>
<TableCell className="max-w-xs truncate">{channelDisplay}</TableCell>
<TableCell>{formatCurrency(config.total_quota || 0)}</TableCell>
<TableCell>{formatCurrency(token.usage || 0)}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleCopy(token.key)}
>
<Copy className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleEdit(token)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => handleDelete(token.key)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
return (
<div
key={token.key}
className="p-4 hover:bg-muted/30 transition-colors"
>
{/* Mobile Layout */}
<div className="md:hidden space-y-3">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{config.name}</div>
<button
onClick={() => handleCopy(token.key)}
className="text-xs text-muted-foreground hover:text-foreground font-mono flex items-center gap-1.5 mt-0.5"
>
{token.key.slice(0, 16)}...
<Copy className="h-3.5 w-3.5" />
</button>
</div>
<div className="relative">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation()
setOpenMenu(isMenuOpen ? null : token.key)
}}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
{isMenuOpen && (
<div className="absolute right-0 top-full mt-1 w-32 bg-popover border rounded-lg shadow-lg py-1 z-10">
<button
className="w-full px-3 py-2 text-sm text-left hover:bg-muted flex items-center gap-2"
onClick={() => handleEdit(token)}
>
<Pencil className="h-4 w-4" />
编辑
</button>
<button
className="w-full px-3 py-2 text-sm text-left hover:bg-muted flex items-center gap-2 text-destructive"
onClick={() => handleDelete(token.key)}
>
<Trash2 className="h-4 w-4" />
删除
</button>
</div>
)}
</div>
</div>
<div className="flex items-center gap-4 text-sm">
<span className="text-muted-foreground">
频道: <span className="text-foreground">{channelKeys.length === 0 ? '全部' : `${channelKeys.length}个`}</span>
</span>
<span className="text-muted-foreground">
配额: <span className="text-foreground">{formatCurrency(token.usage || 0)}/{formatCurrency(config.total_quota || 0)}</span>
</span>
</div>
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
<div
className={cn(
"h-full rounded-full transition-all",
usagePercent > 90 ? "bg-destructive" : usagePercent > 70 ? "bg-warning" : "bg-primary"
)}
style={{ width: `${usagePercent}%` }}
/>
</div>
</div>
{/* Desktop Layout */}
<div className="hidden md:flex md:items-center md:gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium">{config.name}</span>
<span className="text-xs text-muted-foreground font-mono">
{token.key.slice(0, 12)}...{token.key.slice(-4)}
</span>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => handleCopy(token.key)}>
<Copy className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div className="w-24 text-sm text-center flex-shrink-0">
<span className="px-2 py-1 rounded-md bg-muted text-muted-foreground">
{channelKeys.length === 0 ? '全部' : `${channelKeys.length} 频道`}
</span>
</div>
<div className="w-48 flex-shrink-0">
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-muted-foreground">
{formatCurrency(token.usage || 0)} / {formatCurrency(config.total_quota || 0)}
</span>
<span className={cn(
"font-medium",
usagePercent > 90 ? "text-destructive" : usagePercent > 70 ? "text-warning" : "text-muted-foreground"
)}>
{usagePercent.toFixed(0)}%
</span>
</div>
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
<div
className={cn(
"h-full rounded-full transition-all",
usagePercent > 90 ? "bg-destructive" : usagePercent > 70 ? "bg-warning" : "bg-primary"
)}
style={{ width: `${usagePercent}%` }}
/>
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => handleEdit(token)}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive hover:text-destructive" onClick={() => handleDelete(token.key)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)
})}
{filteredData?.length === 0 && searchQuery && (
<div className="p-8 text-center text-muted-foreground">
未找到匹配的令牌
</div>
)}
</div>
</Card>
)}
</PageContainer>
)
}
// Form View
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold tracking-tight">
{editingKey ? '编辑令牌' : '添加令牌'}
</h1>
</div>
<Card>
<CardHeader>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={toggleEditMode}>
{editMode === 'form' ? <FileJson className="h-4 w-4" /> : <FileText className="h-4 w-4" />}
{editMode === 'form' ? '切换到JSON模式' : '切换到表单模式'}
</Button>
<Button onClick={handleSave}>保存令牌</Button>
<Button variant="secondary" onClick={() => setView('list')}>
返回列表
<div className="p-4 md:p-6 lg:p-8 animate-in">
<div className="max-w-2xl mx-auto">
{/* Header */}
<div className="mb-6">
<Button variant="ghost" size="sm" className="mb-3 -ml-2 text-muted-foreground" onClick={() => setView('list')}>
<ArrowLeft className="h-4 w-4 mr-1" />
返回列表
</Button>
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold tracking-tight">{editingKey ? '编辑令牌' : '添加令牌'}</h1>
<Button variant="outline" size="sm" onClick={toggleEditMode}>
{editMode === 'form' ? <FileJson className="h-4 w-4 mr-1" /> : <FileText className="h-4 w-4 mr-1" />}
{editMode === 'form' ? 'JSON' : '表单'}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="tokenKey">令牌标识 *</Label>
</div>
<div className="space-y-6">
{/* Token Key Section */}
<Card>
<CardContent className="p-5">
<h3 className="font-medium mb-1">令牌标识</h3>
<p className="text-sm text-muted-foreground mb-3">用于 API 认证的唯一标识</p>
<div className="flex gap-2">
<Input
id="tokenKey"
value={tokenKey}
onChange={(e) => setTokenKey(e.target.value)}
placeholder="输入令牌标识或点击生成"
placeholder="sk-xxxxxxxxxxxxxxxx"
disabled={!!editingKey}
className="flex-1"
className="font-mono text-sm"
/>
{!editingKey && (
<Button
type="button"
variant="secondary"
onClick={() => setTokenKey(generateTokenKey())}
>
<Dice5 className="h-4 w-4" />
生成
<Button type="button" variant="outline" onClick={() => setTokenKey(generateTokenKey())}>
<Sparkles className="h-4 w-4" />
</Button>
)}
</div>
</div>
</CardContent>
</Card>
{editMode === 'form' ? (
<>
<div className="space-y-2">
<Label htmlFor="name">令牌名称 *</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="例如: 用户令牌1"
/>
</div>
{editMode === 'form' ? (
<>
{/* Basic Info */}
<Card>
<CardContent className="p-5">
<h3 className="font-medium mb-4">基本信息</h3>
<div className="space-y-4">
<div className="space-y-2">
<Label className="text-sm">令牌名称 <span className="text-destructive">*</span></Label>
<Input
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="例如:生产环境、测试用户"
/>
</div>
</div>
</CardContent>
</Card>
<div className="space-y-2">
<Label>允许访问的频道</Label>
<div className="border rounded-md p-4 space-y-2 max-h-48 overflow-y-auto">
{availableChannels.length === 0 ? (
<div className="text-center text-sm text-muted-foreground">暂无可用频道</div>
) : (
availableChannels.map((channelKey) => (
<div key={channelKey} className="flex items-center space-x-2">
{/* Channel Access */}
<Card>
<CardContent className="p-5">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="font-medium">频道访问权限</h3>
<p className="text-sm text-muted-foreground">
{selectedChannels.length === 0 ? '允许访问所有频道' : `已选择 ${selectedChannels.length} 个频道`}
</p>
</div>
</div>
{availableChannels.length === 0 ? (
<div className="flex items-center gap-3 p-4 bg-muted/50 rounded-lg">
<AlertCircle className="h-5 w-5 text-muted-foreground" />
<div>
<p className="text-sm font-medium">暂无可用频道</p>
<p className="text-xs text-muted-foreground">请先在频道管理中添加频道</p>
</div>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{availableChannels.map((channelKey) => (
<label
key={channelKey}
className={cn(
"flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-colors",
selectedChannels.includes(channelKey)
? "border-primary bg-primary/5"
: "border-transparent bg-muted/50 hover:bg-muted"
)}
>
<Checkbox
id={`channel-${channelKey}`}
checked={selectedChannels.includes(channelKey)}
onCheckedChange={() => toggleChannel(channelKey)}
/>
<label
htmlFor={`channel-${channelKey}`}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
{channelKey}
</label>
</div>
))
)}
</div>
<p className="text-sm text-muted-foreground">不选表示允许访问所有频道</p>
</div>
<span className="text-sm font-medium">{channelKey}</span>
</label>
))}
</div>
)}
</CardContent>
</Card>
<div className="space-y-2">
<Label htmlFor="total_quota">总配额</Label>
<Input
id="total_quota"
type="number"
value={formData.total_quota}
onChange={(e) =>
setFormData({ ...formData, total_quota: parseInt(e.target.value) || 0 })
}
placeholder="1000000"
min="0"
step="1"
/>
<div className="space-y-2">
<p className="text-sm text-muted-foreground">快速选择:</p>
<div className="flex gap-2 flex-wrap">
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => setQuotaPreset(1000000)}
>
$1
</Button>
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => setQuotaPreset(5000000)}
>
$5
</Button>
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => setQuotaPreset(10000000)}
>
$10
</Button>
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => setQuotaPreset(20000000)}
>
$20
</Button>
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => setQuotaPreset(50000000)}
>
$50
</Button>
{/* Quota */}
<Card>
<CardContent className="p-5">
<h3 className="font-medium mb-1">使用配额</h3>
<p className="text-sm text-muted-foreground mb-4">设置令牌的最大使用额度(1百万 token = $1.00)</p>
<div className="space-y-4">
<Input
type="number"
value={formData.total_quota}
onChange={(e) => setFormData({ ...formData, total_quota: parseInt(e.target.value) || 0 })}
placeholder="1000000"
min="0"
/>
<div className="flex flex-wrap gap-2">
{quotaPresets.map((preset) => (
<button
key={preset.value}
type="button"
onClick={() => setFormData({ ...formData, total_quota: preset.value })}
className={cn(
"px-4 py-2 rounded-lg text-sm font-medium transition-all",
formData.total_quota === preset.value
? "bg-primary text-primary-foreground shadow-sm"
: "bg-muted hover:bg-muted/80"
)}
>
{preset.label}
</button>
))}
</div>
</div>
<p className="text-sm text-muted-foreground">
设置令牌的总使用配额 (基础单位:1百万token = $1.00)
</p>
</div>
</>
) : (
<div className="space-y-2">
<Label htmlFor="jsonValue">令牌配置 (JSON)</Label>
</CardContent>
</Card>
</>
) : (
<Card>
<CardContent className="p-5">
<h3 className="font-medium mb-4">JSON 配置</h3>
<Textarea
id="jsonValue"
value={jsonValue}
onChange={(e) => setJsonValue(e.target.value)}
rows={15}
rows={14}
className="font-mono text-sm"
placeholder='{"name": "用户令牌1", "channel_keys": [], "total_quota": 1000000}'
placeholder='{"name": "令牌名称", "channel_keys": [], "total_quota": 1000000}'
/>
</div>
)}
</CardContent>
</Card>
</CardContent>
</Card>
)}
{/* Actions */}
<div className="flex items-center justify-end gap-3 pt-2">
<Button variant="outline" onClick={() => setView('list')}>
取消
</Button>
<Button onClick={handleSave} disabled={saveMutation.isPending}>
{saveMutation.isPending ? (
<>
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
保存中...
</>
) : (
<>
<Check className="h-4 w-4 mr-2" />
保存令牌
</>
)}
</Button>
</div>
</div>
</div>
</div>
)
}