feat: 优化前端 UI 设计和用户体验
- 重构所有 UI 组件,采用现代化设计风格 - 新增 PageContainer 组件统一页面布局 - 优化 Sidebar 支持折叠功能 - 改进 Toast 通知样式和交互 - 统一所有页面的视觉风格和间距 - 提升整体界面的专业性和易用性 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
066437fcb3
commit
6a648f3fa7
20 files changed
+1586
-947
No files matched your search
@@ -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()}>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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">
|
||||
配置格式:模型名称 -> {'{'}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
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user