feat: 使用 React + Vite + shadcn/ui 重构前端

- 使用 React 19 + TypeScript + Vite 7 重构整个前端
- 集成 shadcn/ui 组件库和 Tailwind CSS v4
- 使用 TanStack Query 管理服务端状态
- 使用 Zustand 管理客户端状态(认证)
- 使用 React Router v7 实现路由
- 实现浅粉色主题,支持深色模式
- 添加 API 拦截器和全局错误处理
- 优化 Toast 通知样式
- 管理页面按钮右对齐
- Token 显示脱敏处理

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
dreamhunter2333andClaude Opus 4.5 committed 2025-12-20 13:24:45 +08:00
1 parent de24d420a4
commit 4572e5fc9e
60 files changed
+6649 -3173

No files matched your search

+1
View File
@@ -32,3 +32,4 @@ lerna-debug.log*
# misc # misc
.DS_Store .DS_Store
wrangler.toml wrangler.toml
public*/
+1
View File
@@ -0,0 +1 @@
VITE_API_BASE_URL=http://localhost:8787
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+69
View File
@@ -0,0 +1,69 @@
# AWSL One API Frontend
Modern React + Vite + TypeScript frontend for AWSL One API management platform.
## Features
- Modern React 19 with TypeScript
- Vite for fast development and building
- Tailwind CSS for styling
- shadcn/ui components
- TanStack Query for data fetching
- Zustand for state management
- React Router for routing
- Full CRUD operations for channels, tokens, and pricing
- Dark mode support
## Development
```bash
# Install dependencies
pnpm install
# Start development server
pnpm dev
# Build for production
pnpm build
# Preview production build
pnpm preview
```
## Project Structure
```
src/
├── api/ # API client
├── components/ # React components
│ ├── layout/ # Layout components
│ └── ui/ # UI components (shadcn/ui)
├── lib/ # Utility functions
├── pages/ # Page components
├── store/ # Zustand stores
├── types/ # TypeScript types
├── App.tsx # Main app component
├── main.tsx # Entry point
└── index.css # Global styles
```
## Environment Variables
No environment variables required. The frontend communicates with the backend API at the same domain.
## Features
- **Dashboard**: Overview and quick start guide
- **API Test**: Test API endpoints with custom requests
- **Channels**: Manage OpenAI, Azure OpenAI, and Claude channels
- **Tokens**: Create and manage API tokens with quota limits
- **Pricing**: Configure model pricing multipliers
- **Database**: Initialize database tables
## Building
```bash
pnpm build
```
The built files will be in the `dist` directory.
+35
View File
@@ -0,0 +1,35 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-hooks/set-state-in-effect': 'off', // Allow setState in useEffect for React Query data syncing
'react-hooks/refs': 'off', // Allow ref access patterns
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': ['warn', {
argsIgnorePattern: '^_',
caughtErrors: 'none' // 忽略 catch 中未使用的 error 变量
}],
},
},
)
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🤖</text></svg>" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Awsl One API - 管理控制台</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+43
View File
@@ -0,0 +1,43 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@types/node": "^25.0.3",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2",
"autoprefixer": "^10.4.23",
"eslint": "^9.39.2",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"typescript": "~5.9.3",
"typescript-eslint": "^8.50.0",
"vite": "^7.2.4"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-slot": "^1.2.4",
"@tailwindcss/postcss": "^4.1.18",
"@tanstack/react-query": "^5.90.12",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.562.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-router-dom": "^7.11.0",
"tailwind-merge": "^3.4.0",
"zustand": "^5.0.9"
}
}
+2970
View File
File diff suppressed because it is too large. Load diff
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}
+87
View File
@@ -0,0 +1,87 @@
import { useEffect } from 'react'
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { AppLayout } from './components/layout/AppLayout'
import { Toaster } from './components/ui/toaster'
import { useAuthStore } from './store/auth'
import { Dashboard } from './pages/Dashboard'
import { Channels } from './pages/Channels'
import { Tokens } from './pages/Tokens'
import { Pricing } from './pages/Pricing'
import { ApiTest } from './pages/ApiTest'
import { Database } from './pages/Database'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
},
},
})
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated } = useAuthStore()
if (!isAuthenticated) {
return <Navigate to="/" replace />
}
return <>{children}</>
}
function App() {
const { checkAuth } = useAuthStore()
useEffect(() => {
checkAuth()
}, [checkAuth])
return (
<QueryClientProvider client={queryClient}>
<Router>
<AppLayout>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/api-test" element={<ApiTest />} />
<Route
path="/channels"
element={
<ProtectedRoute>
<Channels />
</ProtectedRoute>
}
/>
<Route
path="/tokens"
element={
<ProtectedRoute>
<Tokens />
</ProtectedRoute>
}
/>
<Route
path="/pricing"
element={
<ProtectedRoute>
<Pricing />
</ProtectedRoute>
}
/>
<Route
path="/database"
element={
<ProtectedRoute>
<Database />
</ProtectedRoute>
}
/>
</Routes>
</AppLayout>
<Toaster />
</Router>
</QueryClientProvider>
)
}
export default App
+181
View File
@@ -0,0 +1,181 @@
import { ApiResponse } from '@/types'
const BASE_URL = import.meta.env.VITE_API_BASE_URL || ''
// 错误类型
export class ApiError extends Error {
status: number
constructor(message: string, status: number) {
super(message)
this.name = 'ApiError'
this.status = status
}
}
// 全局错误处理回调
let onUnauthorized: (() => void) | null = null
let onError: ((error: ApiError) => void) | null = null
export function setErrorHandlers(handlers: {
onUnauthorized?: () => void
onError?: (error: ApiError) => void
}) {
onUnauthorized = handlers.onUnauthorized || null
onError = handlers.onError || null
}
// 请求拦截器
async function requestInterceptor(config: RequestInit): Promise<RequestInit> {
const headers = new Headers(config.headers)
// 设置默认 Content-Type
if (!headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json')
}
// 添加 admin token
const adminToken = localStorage.getItem('adminToken')
if (adminToken) {
headers.set('x-admin-token', adminToken)
}
return { ...config, headers }
}
// 响应拦截器
async function responseInterceptor(response: Response): Promise<any> {
if (!response.ok) {
let errorMessage: string
try {
const errorData = await response.json()
errorMessage = errorData.message || errorData.error || JSON.stringify(errorData)
} catch {
errorMessage = await response.text() || `请求失败: ${response.status}`
}
const error = new ApiError(errorMessage, response.status)
// 处理特定状态码
switch (response.status) {
case 401:
// 未授权 - 清除 token 并触发回调
localStorage.removeItem('adminToken')
onUnauthorized?.()
error.message = '认证已过期,请重新登录'
break
case 403:
error.message = '没有权限执行此操作'
break
case 404:
error.message = '请求的资源不存在'
break
case 500:
error.message = '服务器内部错误'
break
case 502:
case 503:
case 504:
error.message = '服务暂时不可用,请稍后重试'
break
}
// 触发全局错误回调
onError?.(error)
throw error
}
const contentType = response.headers.get('content-type')
if (contentType && contentType.includes('application/json')) {
return response.json()
}
return response.text()
}
// 统一请求方法
async function request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const url = `${BASE_URL}${endpoint}`
try {
const config = await requestInterceptor(options)
const response = await fetch(url, config)
return responseInterceptor(response)
} catch (error) {
// 处理网络错误
if (error instanceof TypeError && error.message.includes('fetch')) {
const networkError = new ApiError('网络连接失败,请检查网络', 0)
onError?.(networkError)
throw networkError
}
throw error
}
}
// API 方法
export const apiClient = {
// 通用方法
get: <T>(endpoint: string) => request<T>(endpoint, { method: 'GET' }),
post: <T>(endpoint: string, data?: unknown) => request<T>(endpoint, {
method: 'POST',
body: data ? JSON.stringify(data) : undefined,
}),
delete: <T>(endpoint: string) => request<T>(endpoint, { method: 'DELETE' }),
// Channel APIs
getChannels: () => request<ApiResponse>('/api/admin/channel', { method: 'GET' }),
saveChannel: (key: string, config: unknown) =>
request(`/api/admin/channel/${encodeURIComponent(key)}`, {
method: 'POST',
body: JSON.stringify(config),
}),
deleteChannel: (key: string) =>
request(`/api/admin/channel/${encodeURIComponent(key)}`, { method: 'DELETE' }),
// Token APIs
getTokens: () => request<ApiResponse>('/api/admin/token', { method: 'GET' }),
saveToken: (key: string, config: unknown) =>
request(`/api/admin/token/${encodeURIComponent(key)}`, {
method: 'POST',
body: JSON.stringify(config),
}),
deleteToken: (key: string) =>
request(`/api/admin/token/${encodeURIComponent(key)}`, { method: 'DELETE' }),
// Pricing APIs
getPricing: () => request<ApiResponse>('/api/admin/pricing', { method: 'GET' }),
savePricing: (config: unknown) =>
request('/api/admin/pricing', {
method: 'POST',
body: JSON.stringify(config),
}),
// Database APIs
initDatabase: () => request('/api/admin/db_initialize', { method: 'POST' }),
// API Test - 使用自定义 token,不走通用拦截器
testApi: async (endpoint: string, token: string, body: unknown) => {
const url = `${BASE_URL}${endpoint}`
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify(body),
})
return responseInterceptor(response)
},
// Auth check
checkAuth: () => request<ApiResponse>('/api/admin/channel', { method: 'GET' }),
}
@@ -0,0 +1,101 @@
import { ReactNode, useState } from 'react'
import { Sidebar } from './Sidebar'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
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'
interface AppLayoutProps {
children: ReactNode
}
export function AppLayout({ children }: AppLayoutProps) {
const [adminToken, setAdminToken] = useState('')
const [authError, setAuthError] = useState('')
const [isLoading, setIsLoading] = useState(false)
const { login, showAuthModal, closeAuthModal, openAuthModal } = useAuthStore()
const { addToast } = useToast()
const handleAuthSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setAuthError('')
setIsLoading(true)
try {
await login(adminToken)
setAdminToken('')
addToast('登录成功', 'success')
} catch (error) {
setAuthError(error instanceof Error ? error.message : '管理员令牌无效')
} finally {
setIsLoading(false)
}
}
return (
<div className="flex h-screen overflow-hidden">
<Sidebar onAuthClick={openAuthModal} />
<main className="flex-1 overflow-y-auto bg-background">
{children}
</main>
{/* Auth Dialog */}
<Dialog open={showAuthModal} onOpenChange={(open) => !open && closeAuthModal()}>
<DialogContent>
<DialogHeader>
<DialogTitle>管理员身份验证</DialogTitle>
<DialogDescription>
请输入管理员令牌以访问管理功能
</DialogDescription>
</DialogHeader>
<form onSubmit={handleAuthSubmit}>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="adminToken">管理员令牌</Label>
<Input
id="adminToken"
type="password"
placeholder="请输入管理员令牌"
value={adminToken}
onChange={(e) => setAdminToken(e.target.value)}
required
/>
</div>
{authError && (
<Alert variant="destructive">
<AlertDescription>{authError}</AlertDescription>
</Alert>
)}
</div>
<DialogFooter className="mt-6">
<Button
type="button"
variant="outline"
onClick={closeAuthModal}
>
取消
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? '验证中...' : '登录'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
)
}
+245
View File
@@ -0,0 +1,245 @@
import { Link, useLocation } from 'react-router-dom'
import { cn } from '@/lib/utils'
import {
LayoutDashboard,
TestTube2,
Link as LinkIcon,
Key,
DollarSign,
Database,
Github,
Moon,
Sun,
LogIn,
LogOut,
} from 'lucide-react'
import { useAuthStore } from '@/store/auth'
import { useState } from 'react'
import { Button } from '../ui/button'
interface NavItem {
title: string
href: string
icon: React.ComponentType<{ className?: string }>
adminOnly?: boolean
}
const navItems: NavItem[] = [
{
title: '仪表盘',
href: '/',
icon: LayoutDashboard,
},
{
title: 'API 测试',
href: '/api-test',
icon: TestTube2,
},
{
title: '频道管理',
href: '/channels',
icon: LinkIcon,
adminOnly: true,
},
{
title: '令牌管理',
href: '/tokens',
icon: Key,
adminOnly: true,
},
{
title: '定价管理',
href: '/pricing',
icon: DollarSign,
adminOnly: true,
},
{
title: '数据库',
href: '/database',
icon: Database,
adminOnly: true,
},
]
interface SidebarProps {
onAuthClick: () => void
}
export function Sidebar({ onAuthClick }: 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')
const isDark = savedTheme === 'dark' || (!savedTheme && document.documentElement.classList.contains('dark'))
if (isDark) {
document.documentElement.classList.add('dark')
}
return isDark ? 'dark' : 'light'
}
return 'light'
})
const toggleTheme = () => {
const newTheme = theme === 'light' ? 'dark' : 'light'
setTheme(newTheme)
localStorage.setItem('theme', newTheme)
if (newTheme === 'dark') {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
}
const handleAuthClick = () => {
if (isAuthenticated) {
logout()
} else {
onAuthClick()
}
}
return (
<aside className="w-64 border-r bg-card flex flex-col h-full">
{/* 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>
{/* 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>
</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>
<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>
</>
)}
</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">
<a
href="https://github.com/dreamhunter2333/awsl-one-api"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-primary hover:underline"
>
<Github className="h-4 w-4" />
GitHub
</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"
className={cn(
'w-full justify-start',
isAuthenticated && 'text-destructive border-destructive hover:bg-destructive hover:text-destructive-foreground'
)}
onClick={handleAuthClick}
>
{isAuthenticated ? (
<LogOut className="h-4 w-4" />
) : (
<LogIn className="h-4 w-4" />
)}
<span className="ml-2">
{isAuthenticated ? '退出登录' : '管理员登录'}
</span>
</Button>
</div>
</aside>
)
}
+55
View File
@@ -0,0 +1,55 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & {
variant?: "default" | "destructive" | "warning" | "success"
}
>(({ className, variant = "default", ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(
"relative w-full rounded-lg border p-4 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&:has(svg)]:pl-11",
{
"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":
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":
variant === "success",
},
className
)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }
+39
View File
@@ -0,0 +1,39 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"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",
warning:
"border-transparent bg-yellow-500 text-white hover:bg-yellow-600",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }
+55
View File
@@ -0,0 +1,55 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
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",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }
+78
View File
@@ -0,0 +1,78 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
+29
View File
@@ -0,0 +1,29 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface CheckboxProps extends React.InputHTMLAttributes<HTMLInputElement> {
onCheckedChange?: (checked: boolean) => void
}
const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
({ className, onCheckedChange, ...props }, ref) => {
return (
<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",
className
)}
ref={ref}
onChange={(e) => {
props.onChange?.(e)
onCheckedChange?.(e.target.checked)
}}
{...props}
/>
)
}
)
Checkbox.displayName = "Checkbox"
export { Checkbox }
+119
View File
@@ -0,0 +1,119 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<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",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Label = React.forwardRef<
HTMLLabelElement,
React.ComponentProps<"label">
>(({ className, ...props }, ref) => (
<label
ref={ref}
className={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className
)}
{...props}
/>
))
Label.displayName = "Label"
export { Label }
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Select = React.forwardRef<
HTMLSelectElement,
React.ComponentProps<"select">
>(({ className, ...props }, ref) => {
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",
className
)}
ref={ref}
{...props}
/>
)
})
Select.displayName = "Select"
export { Select }
+116
View File
@@ -0,0 +1,116 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => {
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",
className
)}
ref={ref}
{...props}
/>
)
})
Textarea.displayName = "Textarea"
export { Textarea }
+49
View File
@@ -0,0 +1,49 @@
import { X, CheckCircle, AlertCircle, AlertTriangle, Info } from 'lucide-react'
import { useToast } from './use-toast'
import { cn } from '@/lib/utils'
const toastStyles = {
success: 'border-green-400 bg-white text-green-700 dark:border-green-600 dark:bg-gray-900 dark:text-green-400',
error: 'border-red-400 bg-white text-red-700 dark:border-red-600 dark:bg-gray-900 dark:text-red-400',
warning: 'border-amber-400 bg-white text-amber-700 dark:border-amber-600 dark:bg-gray-900 dark:text-amber-400',
info: 'border-blue-400 bg-white text-blue-700 dark:border-blue-600 dark:bg-gray-900 dark:text-blue-400',
}
const toastIcons = {
success: CheckCircle,
error: AlertCircle,
warning: AlertTriangle,
info: Info,
}
export function Toaster() {
const { toasts, removeToast } = useToast()
return (
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2 w-full max-w-sm">
{toasts.map((toast) => {
const Icon = toastIcons[toast.type] || Info
return (
<div
key={toast.id}
className={cn(
"relative rounded-lg border p-4 shadow-lg animate-in slide-in-from-right",
toastStyles[toast.type] || toastStyles.info
)}
>
<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>
</div>
)
})}
</div>
)
}
+35
View File
@@ -0,0 +1,35 @@
import { create } from 'zustand'
type ToastType = 'success' | 'error' | 'info' | 'warning'
interface Toast {
id: string
message: string
type: ToastType
}
interface ToastState {
toasts: Toast[]
addToast: (message: string, type: ToastType) => void
removeToast: (id: string) => void
}
export const useToast = create<ToastState>((set) => ({
toasts: [],
addToast: (message, type) => {
const id = Math.random().toString(36).substring(7)
set((state) => ({
toasts: [...state.toasts, { id, message, type }],
}))
// Auto remove after 5 seconds
setTimeout(() => {
set((state) => ({
toasts: state.toasts.filter((t) => t.id !== id),
}))
}, 5000)
},
removeToast: (id) =>
set((state) => ({
toasts: state.toasts.filter((t) => t.id !== id),
})),
}))
+9
View File
@@ -0,0 +1,9 @@
export function setupCounter(element: HTMLButtonElement) {
let counter = 0
const setCounter = (count: number) => {
counter = count
element.innerHTML = `count is ${counter}`
}
element.addEventListener('click', () => setCounter(counter + 1))
setCounter(0)
}
+101
View File
@@ -0,0 +1,101 @@
@import 'tailwindcss';
@theme {
/* 现代简洁配色 - 粉色主题 */
--color-background: hsl(0 0% 100%);
--color-foreground: hsl(240 10% 4%);
--color-card: hsl(0 0% 100%);
--color-card-foreground: hsl(240 10% 4%);
--color-popover: hsl(0 0% 100%);
--color-popover-foreground: hsl(240 10% 4%);
/* 主色调 - 浅粉色 */
--color-primary: hsl(350 89% 70%);
--color-primary-foreground: hsl(0 0% 100%);
/* 次要色 */
--color-secondary: hsl(240 5% 96%);
--color-secondary-foreground: hsl(240 6% 10%);
/* 静音色 */
--color-muted: hsl(240 5% 96%);
--color-muted-foreground: hsl(240 4% 46%);
/* 强调色 - 淡粉 */
--color-accent: hsl(350 89% 95%);
--color-accent-foreground: hsl(350 70% 45%);
/* 危险色 */
--color-destructive: hsl(0 84% 60%);
--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%);
--radius: 0.5rem;
}
.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-primary: hsl(350 89% 75%);
--color-primary-foreground: hsl(240 10% 4%);
--color-secondary: hsl(240 4% 16%);
--color-secondary-foreground: hsl(0 0% 95%);
--color-muted: hsl(240 4% 16%);
--color-muted-foreground: hsl(240 5% 65%);
--color-accent: hsl(350 40% 18%);
--color-accent-foreground: hsl(350 89% 80%);
--color-destructive: hsl(0 63% 50%);
--color-destructive-foreground: hsl(0 0% 95%);
--color-border: hsl(240 4% 16%);
--color-input: hsl(240 4% 16%);
--color-ring: hsl(350 89% 75%);
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground antialiased;
}
}
/* 自定义滚动条 */
@layer utilities {
.scrollbar-thin {
scrollbar-width: thin;
}
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background: hsl(240 4% 46% / 0.3);
border-radius: 3px;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
background: hsl(240 4% 46% / 0.5);
}
}
+30
View File
@@ -0,0 +1,30 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function formatCurrency(value: number): string {
return `$${(value / 1000000).toFixed(2)}`
}
export function formatQuota(value: number): string {
if (value >= 1000000) {
return `$${(value / 1000000).toFixed(2)}`
}
return value.toString()
}
export function copyToClipboard(text: string): Promise<void> {
return navigator.clipboard.writeText(text)
}
export function generateTokenKey(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
let token = 'sk-'
for (let i = 0; i < 48; i++) {
token += chars.charAt(Math.floor(Math.random() * chars.length))
}
return token
}
+24
View File
@@ -0,0 +1,24 @@
import './style.css'
import typescriptLogo from './typescript.svg'
import viteLogo from '/vite.svg'
import { setupCounter } from './counter.ts'
document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
<div>
<a href="https://vite.dev" target="_blank">
<img src="${viteLogo}" class="logo" alt="Vite logo" />
</a>
<a href="https://www.typescriptlang.org/" target="_blank">
<img src="${typescriptLogo}" class="logo vanilla" alt="TypeScript logo" />
</a>
<h1>Vite + TypeScript</h1>
<div class="card">
<button id="counter" type="button"></button>
</div>
<p class="read-the-docs">
Click on the Vite and TypeScript logos to learn more
</p>
</div>
`
setupCounter(document.querySelector<HTMLButtonElement>('#counter')!)
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.tsx'
import './index.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+184
View File
@@ -0,0 +1,184 @@
import { useState } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } 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 { Select } from '@/components/ui/select'
import { Badge } from '@/components/ui/badge'
import { useToast } from '@/components/ui/use-toast'
import { apiClient } from '@/api/client'
import { Send } from 'lucide-react'
const requestTemplates: Record<string, any> = {
'/v1/chat/completions': {
model: 'gpt-3.5-turbo',
messages: [
{
role: 'user',
content: 'Hello, how are you?',
},
],
temperature: 0.7,
max_tokens: 100,
},
'/v1/messages': {
model: 'claude-3-5-sonnet-20241022',
max_tokens: 1024,
messages: [
{
role: 'user',
content: 'Hello, Claude!',
},
],
},
'/v1/completions': {
model: 'gpt-3.5-turbo-instruct',
prompt: 'Once upon a time',
max_tokens: 100,
temperature: 0.7,
},
}
export function ApiTest() {
const [endpoint, setEndpoint] = useState('/v1/chat/completions')
const [apiToken, setApiToken] = useState('')
const [requestBody, setRequestBody] = useState(
JSON.stringify(requestTemplates['/v1/chat/completions'], null, 2)
)
const [isLoading, setIsLoading] = useState(false)
const [response, setResponse] = useState<any>(null)
const [responseTime, setResponseTime] = useState<number>(0)
const [statusCode, setStatusCode] = useState<number | null>(null)
const { addToast } = useToast()
const handleEndpointChange = (newEndpoint: string) => {
setEndpoint(newEndpoint)
setRequestBody(JSON.stringify(requestTemplates[newEndpoint], null, 2))
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!apiToken) {
addToast('请输入API令牌', 'error')
return
}
let body: any
try {
body = JSON.parse(requestBody)
} catch (error) {
addToast('请求体JSON格式错误', 'error')
return
}
setIsLoading(true)
setResponse(null)
setStatusCode(null)
const startTime = Date.now()
try {
const result = await apiClient.testApi(endpoint, apiToken, body)
const endTime = Date.now()
setResponseTime(endTime - startTime)
setResponse(result)
setStatusCode(200)
addToast('请求成功', 'success')
} catch (error: any) {
const endTime = Date.now()
setResponseTime(endTime - startTime)
setStatusCode(error.status || 500)
setResponse({ error: error.message })
addToast('请求失败', 'error')
} finally {
setIsLoading(false)
}
}
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>
<Card>
<CardHeader>
<CardTitle>API 测试工具</CardTitle>
<CardDescription>测试您的 API 连接和配置</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="endpoint">API 端点</Label>
<Select
id="endpoint"
value={endpoint}
onChange={(e) => handleEndpointChange(e.target.value)}
>
<option value="/v1/chat/completions">/v1/chat/completions</option>
<option value="/v1/messages">/v1/messages</option>
<option value="/v1/completions">/v1/completions</option>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="apiToken">API 令牌</Label>
<Input
id="apiToken"
type="password"
value={apiToken}
onChange={(e) => setApiToken(e.target.value)}
placeholder="sk-..."
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="requestBody">请求体 (JSON)</Label>
<Textarea
id="requestBody"
value={requestBody}
onChange={(e) => setRequestBody(e.target.value)}
rows={12}
className="font-mono text-sm"
placeholder='{"model": "gpt-3.5-turbo", "messages": [...]}'
/>
<p className="text-sm text-muted-foreground">直接编辑 JSON 格式的请求体</p>
</div>
<Button type="submit" disabled={isLoading}>
<Send className="h-4 w-4" />
{isLoading ? '发送中...' : '发送请求'}
</Button>
</form>
{response && (
<div className="mt-6 space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-semibold">响应结果</h3>
<div className="flex gap-2">
<Badge variant={statusCode === 200 ? 'success' : 'destructive'}>
{statusCode === 200 ? '成功' : '失败'}
</Badge>
<Badge variant="outline">{responseTime}ms</Badge>
</div>
</div>
<Card>
<CardContent className="pt-6">
<pre className="whitespace-pre-wrap break-words text-sm font-mono max-h-96 overflow-y-auto">
{JSON.stringify(response, null, 2)}
</pre>
</CardContent>
</Card>
</div>
)}
</CardContent>
</Card>
</div>
)
}
+459
View File
@@ -0,0 +1,459 @@
import { useState } 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 { 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 {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Plus, RefreshCw, Pencil, Trash2, FileJson, FileText } from 'lucide-react'
type EditMode = 'form' | 'json'
export function Channels() {
const [view, setView] = useState<'list' | 'form'>('list')
const [editMode, setEditMode] = useState<EditMode>('form')
const [editingKey, setEditingKey] = useState<string | null>(null)
const [formData, setFormData] = useState<ChannelConfig>({
name: '',
type: 'azure-openai',
endpoint: '',
api_key: '',
api_version: '',
deployment_mapper: {},
})
const [channelKey, setChannelKey] = useState('')
const [jsonValue, setJsonValue] = useState('')
const [mapperRows, setMapperRows] = useState<Array<{ request: string; deployment: string }>>([])
const { addToast } = useToast()
const queryClient = useQueryClient()
const { data, isLoading, refetch } = useQuery({
queryKey: ['channels'],
queryFn: async () => {
const response = await apiClient.getChannels()
return response.data as Channel[]
},
})
const saveMutation = useMutation({
mutationFn: async ({ key, config }: { key: string; config: any }) => {
return apiClient.saveChannel(key, config)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['channels'] })
addToast(editingKey ? '频道更新成功' : '频道添加成功', 'success')
resetForm()
setView('list')
},
onError: (error: any) => {
addToast('保存失败:' + error.message, 'error')
},
})
const deleteMutation = useMutation({
mutationFn: async (key: string) => {
return apiClient.deleteChannel(key)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['channels'] })
addToast('频道已删除', 'success')
},
onError: (error: any) => {
addToast('删除失败:' + error.message, 'error')
},
})
const resetForm = () => {
setFormData({
name: '',
type: 'azure-openai',
endpoint: '',
api_key: '',
api_version: '',
deployment_mapper: {},
})
setChannelKey('')
setJsonValue('')
setMapperRows([])
setEditingKey(null)
setEditMode('form')
}
const handleAdd = () => {
resetForm()
setView('form')
}
const handleEdit = (channel: Channel) => {
setEditingKey(channel.key)
setChannelKey(channel.key)
const config = typeof channel.value === 'string' ? JSON.parse(channel.value) : channel.value
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,
deployment: deployment as string,
}))
setMapperRows(rows)
}
setView('form')
}
const handleDelete = (key: string) => {
if (confirm(`确定要删除频道 "${key}" 吗?`)) {
deleteMutation.mutate(key)
}
}
const handleSave = () => {
if (!channelKey) {
addToast('请填写频道标识', 'error')
return
}
let config: any
if (editMode === 'form') {
if (!formData.name || !formData.endpoint || !formData.api_key) {
addToast('请填写所有必填字段', 'error')
return
}
// Build deployment_mapper from rows
const deployment_mapper: Record<string, string> = {}
mapperRows.forEach((row) => {
if (row.request && row.deployment) {
deployment_mapper[row.request] = row.deployment
}
})
config = {
...formData,
deployment_mapper,
}
if (!formData.api_version) {
delete config.api_version
}
} else {
try {
config = JSON.parse(jsonValue)
} catch (error) {
addToast('JSON格式错误', 'error')
return
}
}
saveMutation.mutate({ key: channelKey, config })
}
const toggleEditMode = () => {
if (editMode === 'form') {
// Switch to JSON
const deployment_mapper: Record<string, string> = {}
mapperRows.forEach((row) => {
if (row.request && row.deployment) {
deployment_mapper[row.request] = row.deployment
}
})
const config = { ...formData, deployment_mapper }
setJsonValue(JSON.stringify(config, null, 2))
setEditMode('json')
} else {
// Switch to form
try {
const config = JSON.parse(jsonValue)
setFormData(config)
if (config.deployment_mapper) {
const rows = Object.entries(config.deployment_mapper).map(([request, deployment]) => ({
request,
deployment: deployment as string,
}))
setMapperRows(rows)
}
setEditMode('form')
} catch (error) {
addToast('JSON格式错误', 'error')
}
}
}
const addMapperRow = () => {
setMapperRows([...mapperRows, { request: '', deployment: '' }])
}
const removeMapperRow = (index: number) => {
setMapperRows(mapperRows.filter((_, i) => i !== index))
}
const updateMapperRow = (index: number, field: 'request' | 'deployment', value: string) => {
const newRows = [...mapperRows]
newRows[index][field] = value
setMapperRows(newRows)
}
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>
<Card>
<CardHeader>
<div className="flex justify-end gap-2">
<Button onClick={handleAdd}>
<Plus className="h-4 w-4" />
添加频道
</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((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>
)
}
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')}>
返回列表
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="channelKey">频道标识 *</Label>
<Input
id="channelKey"
value={channelKey}
onChange={(e) => setChannelKey(e.target.value)}
placeholder="例如: azure-openai-1"
disabled={!!editingKey}
/>
</div>
{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>
</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>
<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="例如: 2024-02-01"
/>
</div>
<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" />
添加映射
</Button>
</div>
{mapperRows.length === 0 ? (
<div className="text-center py-4 text-muted-foreground text-sm">
暂无映射,点击上方按钮添加
</div>
) : (
<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>
</>
) : (
<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='{"name": "Azure OpenAI", "type": "azure-openai", ...}'
/>
</div>
)}
</CardContent>
</Card>
</div>
)
}
+108
View File
@@ -0,0 +1,108 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Globe, Gauge, BarChart3, Shield } from 'lucide-react'
export function Dashboard() {
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>
</div>
</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>
</div>
)
}
+70
View File
@@ -0,0 +1,70 @@
import { useState } from 'react'
import { useMutation } from '@tanstack/react-query'
import { apiClient } from '@/api/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast'
import { Database as DatabaseIcon, Info } from 'lucide-react'
export function Database() {
const [result, setResult] = useState<any>(null)
const { addToast } = useToast()
const initMutation = useMutation({
mutationFn: async () => {
return apiClient.initDatabase()
},
onSuccess: (data) => {
setResult(data)
addToast('数据库初始化成功', 'success')
},
onError: (error: any) => {
setResult({ error: error.message })
addToast('数据库初始化失败:' + error.message, 'error')
},
})
const handleInit = () => {
if (confirm('确定要初始化数据库吗?此操作将创建必要的表结构。')) {
initMutation.mutate()
}
}
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>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>数据库初始化</CardTitle>
<Button onClick={handleInit} disabled={initMutation.isPending}>
<DatabaseIcon className="h-4 w-4" />
{initMutation.isPending ? '初始化中...' : '初始化数据库'}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Info className="h-4 w-4" />
<span>初始化数据库将创建必要的表结构。如果表已存在,操作将被忽略。</span>
</div>
{result && (
<div className="mt-4">
<Card>
<CardContent className="pt-6">
<pre className="whitespace-pre-wrap text-sm font-mono">
{JSON.stringify(result, null, 2)}
</pre>
</CardContent>
</Card>
</div>
)}
</CardContent>
</Card>
</div>
)
}
+269
View File
@@ -0,0 +1,269 @@
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 {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Plus, RefreshCw, Trash2, FileJson, FileText, Info } from 'lucide-react'
type EditMode = 'table' | 'json'
export function Pricing() {
const [editMode, setEditMode] = useState<EditMode>('table')
const [jsonValue, setJsonValue] = useState('')
const [pricingRows, setPricingRows] = useState<
Array<{ model: string; input: number; output: number }>
>([])
const [isInitialized, setIsInitialized] = useState(false)
const { addToast } = useToast()
const queryClient = useQueryClient()
const { data, isLoading, refetch } = useQuery({
queryKey: ['pricing'],
queryFn: async () => {
const response = await apiClient.getPricing()
return response.data as PricingConfig
},
})
// Initialize rows when data first loads - sync server state to local editable state
useEffect(() => {
if (data && !isInitialized) {
const rows = Object.entries(data).map(([model, pricing]) => ({
model,
input: pricing.input,
output: pricing.output,
}))
setPricingRows(rows)
setJsonValue(JSON.stringify(data, null, 2))
setIsInitialized(true)
}
}, [data, isInitialized])
const saveMutation = useMutation({
mutationFn: async (config: PricingConfig) => {
return apiClient.savePricing(config)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['pricing'] })
addToast('定价配置已保存', 'success')
},
onError: (error: Error) => {
addToast('保存失败:' + error.message, 'error')
},
})
const handleSave = () => {
let config: PricingConfig
if (editMode === 'table') {
if (pricingRows.length === 0) {
addToast('请至少添加一个模型定价', 'error')
return
}
config = {}
pricingRows.forEach((row) => {
if (row.model) {
config[row.model] = {
input: row.input || 0,
output: row.output || 0,
}
}
})
} else {
try {
config = JSON.parse(jsonValue)
} catch (error) {
addToast('JSON格式错误', 'error')
return
}
}
saveMutation.mutate(config)
}
const toggleEditMode = () => {
if (editMode === 'table') {
// Switch to JSON
const config: PricingConfig = {}
pricingRows.forEach((row) => {
if (row.model) {
config[row.model] = {
input: row.input || 0,
output: row.output || 0,
}
}
})
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]) => ({
model,
input: (pricing as { input: number }).input || 0,
output: (pricing as { output: number }).output || 0,
}))
setPricingRows(rows)
setEditMode('table')
} catch (error) {
addToast('JSON格式错误', 'error')
}
}
}
const addRow = () => {
setPricingRows([...pricingRows, { model: '', input: 0, output: 0 }])
}
const removeRow = (index: number) => {
setPricingRows(pricingRows.filter((_, i) => i !== index))
}
const updateRow = (index: number, field: 'model' | 'input' | 'output', value: any) => {
const newRows = [...pricingRows]
newRows[index][field] = value
setPricingRows(newRows)
}
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>
<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" />
刷新
</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>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>模型名称</TableHead>
<TableHead>输入倍率</TableHead>
<TableHead>输出倍率</TableHead>
<TableHead className="w-20">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pricingRows.map((row, index) => (
<TableRow key={index}>
<TableCell>
<Input
value={row.model}
onChange={(e) => updateRow(index, 'model', e.target.value)}
placeholder="例如: gpt-4"
/>
</TableCell>
<TableCell>
<Input
type="number"
value={row.input}
onChange={(e) =>
updateRow(index, 'input', parseFloat(e.target.value) || 0)
}
step="0.000001"
min="0"
placeholder="0.001"
/>
</TableCell>
<TableCell>
<Input
type="number"
value={row.output}
onChange={(e) =>
updateRow(index, 'output', parseFloat(e.target.value) || 0)
}
step="0.000001"
min="0"
placeholder="0.002"
/>
</TableCell>
<TableCell>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => removeRow(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
) : (
<div className="space-y-2">
<Label htmlFor="jsonValue">定价配置 (JSON)</Label>
<Textarea
id="jsonValue"
value={jsonValue}
onChange={(e) => setJsonValue(e.target.value)}
rows={20}
className="font-mono text-sm"
placeholder='{"gpt-3.5-turbo": {"input": 0.001, "output": 0.002}, "gpt-4": {"input": 0.03, "output": 0.06}}'
/>
<p className="text-sm text-muted-foreground">
配置格式:模型名称 -&gt; {'{'}input: 输入倍率, output: 输出倍率{'}'}
。倍率基于1百万token=$1.00计算
</p>
</div>
)}
</CardContent>
</Card>
</div>
)
}
+453
View File
@@ -0,0 +1,453 @@
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 { 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 {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Plus, RefreshCw, Pencil, Trash2, Copy, Dice5, FileJson, FileText } from 'lucide-react'
import { Checkbox } from '@/components/ui/checkbox'
type EditMode = 'form' | 'json'
export function Tokens() {
const [view, setView] = useState<'list' | 'form'>('list')
const [editMode, setEditMode] = useState<EditMode>('form')
const [editingKey, setEditingKey] = useState<string | null>(null)
const [formData, setFormData] = useState<TokenConfig>({
name: '',
channel_keys: [],
total_quota: 0,
})
const [tokenKey, setTokenKey] = useState('')
const [jsonValue, setJsonValue] = useState('')
const [availableChannels, setAvailableChannels] = useState<string[]>([])
const [selectedChannels, setSelectedChannels] = useState<string[]>([])
const { addToast } = useToast()
const queryClient = useQueryClient()
const { data, isLoading, refetch } = useQuery({
queryKey: ['tokens'],
queryFn: async () => {
const response = await apiClient.getTokens()
return response.data as Token[]
},
})
// Load available channels
useEffect(() => {
const loadChannels = async () => {
try {
const response = await apiClient.getChannels()
const channels = response.data as Channel[]
setAvailableChannels(channels.map((c) => c.key))
} catch (error) {
console.error('Failed to load channels:', error)
}
}
loadChannels()
}, [])
const saveMutation = useMutation({
mutationFn: async ({ key, config }: { key: string; config: any }) => {
return apiClient.saveToken(key, config)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tokens'] })
addToast(editingKey ? '令牌更新成功' : '令牌添加成功', 'success')
resetForm()
setView('list')
},
onError: (error: any) => {
addToast('保存失败:' + error.message, 'error')
},
})
const deleteMutation = useMutation({
mutationFn: async (key: string) => {
return apiClient.deleteToken(key)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tokens'] })
addToast('令牌已删除', 'success')
},
onError: (error: any) => {
addToast('删除失败:' + error.message, 'error')
},
})
const resetForm = () => {
setFormData({
name: '',
channel_keys: [],
total_quota: 0,
})
setTokenKey('')
setJsonValue('')
setSelectedChannels([])
setEditingKey(null)
setEditMode('form')
}
const handleAdd = () => {
resetForm()
setTokenKey(generateTokenKey())
setView('form')
}
const handleEdit = (token: Token) => {
setEditingKey(token.key)
setTokenKey(token.key)
const config = typeof token.value === 'string' ? JSON.parse(token.value) : token.value
setFormData(config)
setJsonValue(JSON.stringify(config, null, 2))
setSelectedChannels(config.channel_keys || [])
setView('form')
}
const handleDelete = (key: string) => {
if (confirm(`确定要删除令牌 "${key}" 吗?`)) {
deleteMutation.mutate(key)
}
}
const handleCopy = async (text: string) => {
try {
await copyToClipboard(text)
addToast('已复制到剪贴板', 'success')
} catch (error) {
addToast('复制失败', 'error')
}
}
const handleSave = () => {
if (!tokenKey) {
addToast('请填写令牌标识', 'error')
return
}
let config: any
if (editMode === 'form') {
if (!formData.name) {
addToast('请填写令牌名称', 'error')
return
}
config = {
...formData,
channel_keys: selectedChannels,
}
} else {
try {
config = JSON.parse(jsonValue)
} catch (error) {
addToast('JSON格式错误', 'error')
return
}
}
saveMutation.mutate({ key: tokenKey, config })
}
const toggleEditMode = () => {
if (editMode === 'form') {
const config = { ...formData, channel_keys: selectedChannels }
setJsonValue(JSON.stringify(config, null, 2))
setEditMode('json')
} else {
try {
const config = JSON.parse(jsonValue)
setFormData(config)
setSelectedChannels(config.channel_keys || [])
setEditMode('form')
} catch (error) {
addToast('JSON格式错误', 'error')
}
}
}
const toggleChannel = (channelKey: string) => {
setSelectedChannels((prev) =>
prev.includes(channelKey)
? prev.filter((k) => k !== channelKey)
: [...prev, channelKey]
)
}
const setQuotaPreset = (value: number) => {
setFormData({ ...formData, total_quota: value })
}
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>
<Card>
<CardHeader>
<div className="flex justify-end gap-2">
<Button onClick={handleAdd}>
<Plus className="h-4 w-4" />
添加令牌
</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(', ')
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 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')}>
返回列表
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="tokenKey">令牌标识 *</Label>
<div className="flex gap-2">
<Input
id="tokenKey"
value={tokenKey}
onChange={(e) => setTokenKey(e.target.value)}
placeholder="输入令牌标识或点击生成"
disabled={!!editingKey}
className="flex-1"
/>
{!editingKey && (
<Button
type="button"
variant="secondary"
onClick={() => setTokenKey(generateTokenKey())}
>
<Dice5 className="h-4 w-4" />
生成
</Button>
)}
</div>
</div>
{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>
<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">
<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>
<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>
</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>
<Textarea
id="jsonValue"
value={jsonValue}
onChange={(e) => setJsonValue(e.target.value)}
rows={15}
className="font-mono text-sm"
placeholder='{"name": "用户令牌1", "channel_keys": [], "total_quota": 1000000}'
/>
</div>
)}
</CardContent>
</Card>
</div>
)
}
+66
View File
@@ -0,0 +1,66 @@
import { create } from 'zustand'
import { apiClient } from '@/api/client'
interface AuthState {
isAuthenticated: boolean
isLoading: boolean
error: string | null
showAuthModal: boolean
login: (token: string) => Promise<void>
logout: () => void
checkAuth: () => Promise<void>
openAuthModal: () => void
closeAuthModal: () => void
}
export const useAuthStore = create<AuthState>((set) => ({
isAuthenticated: false,
isLoading: false,
error: null,
showAuthModal: false,
login: async (token: string) => {
set({ isLoading: true, error: null })
try {
// Store token
localStorage.setItem('adminToken', token)
// Verify token by making a test request
await apiClient.checkAuth()
set({ isAuthenticated: true, isLoading: false, showAuthModal: false })
} catch (error) {
localStorage.removeItem('adminToken')
set({
isAuthenticated: false,
isLoading: false,
error: error instanceof Error ? error.message : 'Authentication failed',
})
throw error
}
},
logout: () => {
localStorage.removeItem('adminToken')
set({ isAuthenticated: false, error: null })
},
checkAuth: async () => {
const token = localStorage.getItem('adminToken')
if (!token) {
set({ isAuthenticated: false })
return
}
try {
await apiClient.checkAuth()
set({ isAuthenticated: true })
} catch (error) {
localStorage.removeItem('adminToken')
set({ isAuthenticated: false })
}
},
openAuthModal: () => set({ showAuthModal: true }),
closeAuthModal: () => set({ showAuthModal: false }),
}))
+96
View File
@@ -0,0 +1,96 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.vanilla:hover {
filter: drop-shadow(0 0 2em #3178c6aa);
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}
+68
View File
@@ -0,0 +1,68 @@
export interface Channel {
key: string
value: string | ChannelConfig
usage?: number
}
export interface ChannelConfig {
name: string
type: 'openai' | 'azure-openai' | 'claude'
endpoint: string
api_key: string
api_version?: string
deployment_mapper?: Record<string, string>
}
export interface Token {
key: string
value: string | TokenConfig
usage?: number
}
export interface TokenConfig {
name: string
channel_keys?: string[]
total_quota: number
}
export interface PricingModel {
input: number
output: number
}
export type PricingConfig = Record<string, PricingModel>
export interface ApiResponse<T = any> {
data?: T
error?: string
message?: string
}
export interface TestRequest {
model: string
messages?: Array<{
role: string
content: string
}>
prompt?: string
max_tokens?: number
temperature?: number
stream?: boolean
}
export interface TestResponse {
id?: string
model?: string
choices?: Array<{
message?: {
role: string
content: string
}
text?: string
}>
usage?: {
prompt_tokens?: number
completion_tokens?: number
total_tokens?: number
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="32" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path fill="#007ACC" d="M0 128v128h256V0H0z"></path><path fill="#FFF" d="m56.612 128.85l-.081 10.483h33.32v94.68h23.568v-94.68h33.321v-10.28c0-5.69-.122-10.444-.284-10.566c-.122-.162-20.4-.244-44.983-.203l-44.74.122l-.121 10.443Zm149.955-10.742c6.501 1.625 11.459 4.51 16.01 9.224c2.357 2.52 5.851 7.111 6.136 8.208c.08.325-11.053 7.802-17.798 11.988c-.244.162-1.22-.894-2.317-2.52c-3.291-4.795-6.745-6.867-12.028-7.233c-7.76-.528-12.759 3.535-12.718 10.321c0 1.992.284 3.17 1.097 4.795c1.707 3.536 4.876 5.649 14.832 9.956c18.326 7.883 26.168 13.084 31.045 20.48c5.445 8.249 6.664 21.415 2.966 31.208c-4.063 10.646-14.14 17.879-28.323 20.276c-4.388.772-14.79.65-19.504-.203c-10.28-1.828-20.033-6.908-26.047-13.572c-2.357-2.6-6.949-9.387-6.664-9.874c.122-.163 1.178-.813 2.356-1.504c1.138-.65 5.446-3.129 9.509-5.485l7.355-4.267l1.544 2.276c2.154 3.29 6.867 7.801 9.712 9.305c8.167 4.307 19.383 3.698 24.909-1.26c2.357-2.153 3.332-4.388 3.332-7.68c0-2.966-.366-4.266-1.91-6.501c-1.99-2.845-6.054-5.242-17.595-10.24c-13.206-5.69-18.895-9.224-24.096-14.832c-3.007-3.25-5.852-8.452-7.03-12.8c-.975-3.617-1.22-12.678-.447-16.335c2.723-12.76 12.353-21.659 26.25-24.3c4.51-.853 14.994-.528 19.424.569Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+9
View File
@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
+76
View File
@@ -0,0 +1,76 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ["class"],
content: [
'./pages/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
'./app/**/*.{ts,tsx}',
'./src/**/*.{ts,tsx}',
],
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [],
}
+54
View File
@@ -0,0 +1,54 @@
import type { Config } from "tailwindcss";
export default {
darkMode: ["class"],
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
colors: {
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
},
},
},
plugins: [],
} satisfies Config;
+31
View File
@@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
/* Path mapping */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}
+13
View File
@@ -0,0 +1,13 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
build: {
outDir: '../public',
emptyOutDir: true,
},
})
+2 -2
View File
@@ -7,11 +7,11 @@
}, },
"dependencies": { "dependencies": {
"chanfana": "^2.8.3", "chanfana": "^2.8.3",
"hono": "^4.10.8", "hono": "^4.11.1",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
"wrangler": "^4.54.0" "wrangler": "^4.56.0"
}, },
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39" "packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
} }
+43 -43
View File
@@ -12,15 +12,15 @@ importers:
specifier: ^2.8.3 specifier: ^2.8.3
version: 2.8.3 version: 2.8.3
hono: hono:
specifier: ^4.10.8 specifier: ^4.11.1
version: 4.10.8 version: 4.11.1
zod: zod:
specifier: ^3.25.76 specifier: ^3.25.76
version: 3.25.76 version: 3.25.76
devDependencies: devDependencies:
wrangler: wrangler:
specifier: ^4.54.0 specifier: ^4.56.0
version: 4.54.0 version: 4.56.0
packages: packages:
@@ -42,32 +42,32 @@ packages:
workerd: workerd:
optional: true optional: true
'@cloudflare/workerd-darwin-64@1.20251210.0': '@cloudflare/workerd-darwin-64@1.20251217.0':
resolution: {integrity: sha512-Nn9X1moUDERA9xtFdCQ2XpQXgAS9pOjiCxvOT8sVx9UJLAiBLkfSCGbpsYdarODGybXCpjRlc77Yppuolvt7oQ==} resolution: {integrity: sha512-DN6vT+9ho61d/1/YuILW4VS+N1JBLaixWRL1vqNmhgbf8J8VHwWWotrRruEUYigJKx2yZyw6YsasE+yLXgx/Fw==}
engines: {node: '>=16'} engines: {node: '>=16'}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@cloudflare/workerd-darwin-arm64@1.20251210.0': '@cloudflare/workerd-darwin-arm64@1.20251217.0':
resolution: {integrity: sha512-Mg8iYIZQFnbevq/ls9eW/eneWTk/EE13Pej1MwfkY5et0jVpdHnvOLywy/o+QtMJFef1AjsqXGULwAneYyBfHw==} resolution: {integrity: sha512-5nZOpRTkHmtcTc4Wbr1mj/O3dLb6aHZSiJuVBgtdbVcVmOXueSay3hnw1PXEyR+vpTKGUPkM+omUIslKHWnXDw==}
engines: {node: '>=16'} engines: {node: '>=16'}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@cloudflare/workerd-linux-64@1.20251210.0': '@cloudflare/workerd-linux-64@1.20251217.0':
resolution: {integrity: sha512-kjC2fCZhZ2Gkm1biwk2qByAYpGguK5Gf5ic8owzSCUw0FOUfQxTZUT9Lp3gApxsfTLbbnLBrX/xzWjywH9QR4g==} resolution: {integrity: sha512-uoPGhMaZVXPpCsU0oG3HQzyVpXCGi5rU+jcHRjUI7DXM4EwctBGvZ380Knkja36qtl+ZvSKVR1pUFSGdK+45Pg==}
engines: {node: '>=16'} engines: {node: '>=16'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@cloudflare/workerd-linux-arm64@1.20251210.0': '@cloudflare/workerd-linux-arm64@1.20251217.0':
resolution: {integrity: sha512-2IB37nXi7PZVQLa1OCuO7/6pNxqisRSO8DmCQ5x/3sezI5op1vwOxAcb1osAnuVsVN9bbvpw70HJvhKruFJTuA==} resolution: {integrity: sha512-ixHnHKsiz1Xko+eDgCJOZ7EEUZKtmnYq3AjW3nkVcLFypSLks4C29E45zVewdaN4wq8sCLeyQCl6r1kS17+DQQ==}
engines: {node: '>=16'} engines: {node: '>=16'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@cloudflare/workerd-windows-64@1.20251210.0': '@cloudflare/workerd-windows-64@1.20251217.0':
resolution: {integrity: sha512-Uaz6/9XE+D6E7pCY4OvkCuJHu7HcSDzeGcCGY1HLhojXhHd7yL52c3yfiyJdS8hPatiAa0nn5qSI/42+aTdDSw==} resolution: {integrity: sha512-rP6USX+7ctynz3AtmKi+EvlLP3Xdr1ETrSdcnv693/I5QdUwBxq4yE1Lj6CV7GJizX6opXKYg8QMq0Q4eB9zRQ==}
engines: {node: '>=16'} engines: {node: '>=16'}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
@@ -427,8 +427,8 @@ packages:
glob-to-regexp@0.4.1: glob-to-regexp@0.4.1:
resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==}
hono@4.10.8: hono@4.11.1:
resolution: {integrity: sha512-DDT0A0r6wzhe8zCGoYOmMeuGu3dyTAE40HHjwUsWFTEy5WxK1x2WDSsBPlEXgPbRIFY6miDualuUDbasPogIww==} resolution: {integrity: sha512-KsFcH0xxHes0J4zaQgWbYwmz3UPOOskdqZmItstUG93+Wk1ePBLkLGwbP9zlmh1BFUiL8Qp+Xfu9P7feJWpGNg==}
engines: {node: '>=16.9.0'} engines: {node: '>=16.9.0'}
is-arrayish@0.3.4: is-arrayish@0.3.4:
@@ -447,8 +447,8 @@ packages:
engines: {node: '>=10.0.0'} engines: {node: '>=10.0.0'}
hasBin: true hasBin: true
miniflare@4.20251210.0: miniflare@4.20251217.0:
resolution: {integrity: sha512-k6kIoXwGVqlPZb0hcn+X7BmnK+8BjIIkusQPY22kCo2RaQJ/LzAjtxHQdGXerlHSnJyQivDQsL6BJHMpQfUFyw==} resolution: {integrity: sha512-8xsTQbPS6YV+ABZl9qiJIbsum6hbpbhqiyKpOVdzZrhK+1N8EFpT8R6aBZff7kezGmxYZSntjgjqTwJmj3JLgA==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
hasBin: true hasBin: true
@@ -491,17 +491,17 @@ packages:
unenv@2.0.0-rc.24: unenv@2.0.0-rc.24:
resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==}
workerd@1.20251210.0: workerd@1.20251217.0:
resolution: {integrity: sha512-9MUUneP1BnRE9XAYi94FXxHmiLGbO75EHQZsgWqSiOXjoXSqJCw8aQbIEPxCy19TclEl/kHUFYce8ST2W+Qpjw==} resolution: {integrity: sha512-s3mHDSWwHTduyY8kpHOsl27ZJ4ziDBJlc18PfBvNMqNnhO7yBeemlxH7bo7yQyU1foJrIZ6IENHDDg0Z9N8zQA==}
engines: {node: '>=16'} engines: {node: '>=16'}
hasBin: true hasBin: true
wrangler@4.54.0: wrangler@4.56.0:
resolution: {integrity: sha512-bANFsjDwJLbprYoBK+hUDZsVbUv2SqJd8QvArLIcZk+fPq4h/Ohtj5vkKXD3k0s2bD1DXLk08D+hYmeNH+xC6A==} resolution: {integrity: sha512-Nqi8duQeRbA+31QrD6QlWHW3IZVnuuRxMy7DEg46deUzywivmaRV/euBN5KKXDPtA24VyhYsK7I0tkb7P5DM2w==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
hasBin: true hasBin: true
peerDependencies: peerDependencies:
'@cloudflare/workers-types': ^4.20251210.0 '@cloudflare/workers-types': ^4.20251217.0
peerDependenciesMeta: peerDependenciesMeta:
'@cloudflare/workers-types': '@cloudflare/workers-types':
optional: true optional: true
@@ -550,25 +550,25 @@ snapshots:
dependencies: dependencies:
mime: 3.0.0 mime: 3.0.0
'@cloudflare/unenv-preset@2.7.13(unenv@2.0.0-rc.24)(workerd@1.20251210.0)': '@cloudflare/unenv-preset@2.7.13(unenv@2.0.0-rc.24)(workerd@1.20251217.0)':
dependencies: dependencies:
unenv: 2.0.0-rc.24 unenv: 2.0.0-rc.24
optionalDependencies: optionalDependencies:
workerd: 1.20251210.0 workerd: 1.20251217.0
'@cloudflare/workerd-darwin-64@1.20251210.0': '@cloudflare/workerd-darwin-64@1.20251217.0':
optional: true optional: true
'@cloudflare/workerd-darwin-arm64@1.20251210.0': '@cloudflare/workerd-darwin-arm64@1.20251217.0':
optional: true optional: true
'@cloudflare/workerd-linux-64@1.20251210.0': '@cloudflare/workerd-linux-64@1.20251217.0':
optional: true optional: true
'@cloudflare/workerd-linux-arm64@1.20251210.0': '@cloudflare/workerd-linux-arm64@1.20251217.0':
optional: true optional: true
'@cloudflare/workerd-windows-64@1.20251210.0': '@cloudflare/workerd-windows-64@1.20251217.0':
optional: true optional: true
'@cspotcode/source-map-support@0.8.1': '@cspotcode/source-map-support@0.8.1':
@@ -832,7 +832,7 @@ snapshots:
glob-to-regexp@0.4.1: {} glob-to-regexp@0.4.1: {}
hono@4.10.8: {} hono@4.11.1: {}
is-arrayish@0.3.4: {} is-arrayish@0.3.4: {}
@@ -844,7 +844,7 @@ snapshots:
mime@3.0.0: {} mime@3.0.0: {}
miniflare@4.20251210.0: miniflare@4.20251217.0:
dependencies: dependencies:
'@cspotcode/source-map-support': 0.8.1 '@cspotcode/source-map-support': 0.8.1
acorn: 8.14.0 acorn: 8.14.0
@@ -854,7 +854,7 @@ snapshots:
sharp: 0.33.5 sharp: 0.33.5
stoppable: 1.1.0 stoppable: 1.1.0
undici: 7.14.0 undici: 7.14.0
workerd: 1.20251210.0 workerd: 1.20251217.0
ws: 8.18.0 ws: 8.18.0
youch: 4.1.0-beta.10 youch: 4.1.0-beta.10
zod: 3.22.3 zod: 3.22.3
@@ -915,24 +915,24 @@ snapshots:
dependencies: dependencies:
pathe: 2.0.3 pathe: 2.0.3
workerd@1.20251210.0: workerd@1.20251217.0:
optionalDependencies: optionalDependencies:
'@cloudflare/workerd-darwin-64': 1.20251210.0 '@cloudflare/workerd-darwin-64': 1.20251217.0
'@cloudflare/workerd-darwin-arm64': 1.20251210.0 '@cloudflare/workerd-darwin-arm64': 1.20251217.0
'@cloudflare/workerd-linux-64': 1.20251210.0 '@cloudflare/workerd-linux-64': 1.20251217.0
'@cloudflare/workerd-linux-arm64': 1.20251210.0 '@cloudflare/workerd-linux-arm64': 1.20251217.0
'@cloudflare/workerd-windows-64': 1.20251210.0 '@cloudflare/workerd-windows-64': 1.20251217.0
wrangler@4.54.0: wrangler@4.56.0:
dependencies: dependencies:
'@cloudflare/kv-asset-handler': 0.4.1 '@cloudflare/kv-asset-handler': 0.4.1
'@cloudflare/unenv-preset': 2.7.13(unenv@2.0.0-rc.24)(workerd@1.20251210.0) '@cloudflare/unenv-preset': 2.7.13(unenv@2.0.0-rc.24)(workerd@1.20251217.0)
blake3-wasm: 2.1.5 blake3-wasm: 2.1.5
esbuild: 0.27.0 esbuild: 0.27.0
miniflare: 4.20251210.0 miniflare: 4.20251217.0
path-to-regexp: 6.3.0 path-to-regexp: 6.3.0
unenv: 2.0.0-rc.24 unenv: 2.0.0-rc.24
workerd: 1.20251210.0 workerd: 1.20251217.0
optionalDependencies: optionalDependencies:
fsevents: 2.3.3 fsevents: 2.3.3
transitivePeerDependencies: transitivePeerDependencies:
-981
View File
@@ -1,981 +0,0 @@
/* ===== CSS Variables (shadcn/ui inspired) ===== */
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 330 81% 60%;
--primary-foreground: 0 0% 100%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 330 45% 96%;
--accent-foreground: 330 81% 30%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 100%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 330 81% 60%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 330 81% 68%;
--primary-foreground: 330 20% 10%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 330 25% 15%;
--accent-foreground: 330 81% 68%;
--destructive: 0 62.8% 50%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 330 81% 68%;
}
/* ===== Base Styles ===== */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
background-color: hsl(var(--background));
color: hsl(var(--foreground));
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
/* ===== Layout ===== */
.app-container {
display: flex;
min-height: 100vh;
}
/* Sidebar */
.sidebar {
width: 280px;
border-right: 1px solid hsl(var(--border));
background-color: hsl(var(--card));
display: flex;
flex-direction: column;
position: fixed;
height: 100vh;
overflow-y: auto;
}
.sidebar-header {
padding: 1.5rem 1.5rem 1rem;
border-bottom: 1px solid hsl(var(--border));
}
.sidebar-brand {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 1.25rem;
font-weight: 600;
}
.sidebar-nav {
flex: 1;
padding: 1rem 0.75rem;
}
.nav-section {
margin-bottom: 1.5rem;
}
.nav-section-title {
padding: 0.5rem 0.75rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: hsl(var(--muted-foreground));
}
.nav-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 0.75rem;
margin: 0.125rem 0;
border-radius: var(--radius);
color: hsl(var(--foreground));
text-decoration: none;
cursor: pointer;
transition: all 0.15s;
font-size: 0.875rem;
font-weight: 500;
border: none;
background: none;
width: 100%;
text-align: left;
}
.nav-item:hover {
background-color: hsl(var(--accent));
color: hsl(var(--accent-foreground));
}
.nav-item.active {
background-color: hsl(var(--primary));
color: hsl(var(--primary-foreground));
}
.nav-item-icon {
font-size: 1.25rem;
width: 1.25rem;
height: 1.25rem;
display: flex;
align-items: center;
justify-content: center;
}
.sidebar-footer {
padding: 1rem 1.5rem;
border-top: 1px solid hsl(var(--border));
}
/* Main Content */
.main-content {
flex: 1;
margin-left: 280px;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.main-header {
position: sticky;
top: 0;
z-index: 10;
background-color: hsl(var(--background) / 0.95);
backdrop-filter: blur(8px);
border-bottom: 1px solid hsl(var(--border));
padding: 1rem 2rem;
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
max-width: 1400px;
}
.header-title {
font-size: 1.5rem;
font-weight: 600;
}
.header-actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
.content-wrapper {
flex: 1;
padding: 2rem;
max-width: 1400px;
}
/* ===== Components ===== */
/* Card */
.card {
background-color: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: calc(var(--radius) + 2px);
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.card-header {
padding: 1.5rem;
border-bottom: 1px solid hsl(var(--border));
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.card-header > .flex {
margin-left: auto;
}
.card-title {
font-size: 1.25rem;
font-weight: 600;
line-height: 1;
}
.card-description {
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
margin-top: 0.375rem;
}
.card-content {
padding: 1.5rem;
margin-top: 0;
}
.card-footer {
padding: 1.5rem;
border-top: 1px solid hsl(var(--border));
display: flex;
align-items: center;
justify-content: space-between;
}
/* Button */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
border-radius: var(--radius);
font-size: 0.875rem;
font-weight: 500;
padding: 0.625rem 1rem;
border: none;
cursor: pointer;
transition: all 0.15s;
text-decoration: none;
white-space: nowrap;
}
.btn:disabled {
pointer-events: none;
opacity: 0.5;
}
.btn-primary {
background-color: hsl(var(--primary));
color: hsl(var(--primary-foreground));
}
.btn-primary:hover {
background-color: hsl(var(--primary) / 0.9);
}
.btn-secondary {
background-color: hsl(var(--secondary));
color: hsl(var(--secondary-foreground));
}
.btn-secondary:hover {
background-color: hsl(var(--secondary) / 0.8);
}
.btn-outline {
background-color: transparent;
border: 1px solid hsl(var(--input));
color: hsl(var(--foreground));
}
.btn-outline:hover {
background-color: hsl(var(--accent));
color: hsl(var(--accent-foreground));
}
.btn-destructive {
background-color: hsl(var(--destructive));
color: hsl(var(--destructive-foreground));
}
.btn-destructive:hover {
background-color: hsl(var(--destructive) / 0.9);
}
.btn-ghost {
background-color: transparent;
color: hsl(var(--foreground));
}
.btn-ghost:hover {
background-color: hsl(var(--accent));
color: hsl(var(--accent-foreground));
}
.btn-sm {
padding: 0.375rem 0.75rem;
font-size: 0.8125rem;
}
.btn-lg {
padding: 0.75rem 1.5rem;
font-size: 1rem;
}
/* Input */
.input {
display: flex;
width: 100%;
border-radius: var(--radius);
border: 1px solid hsl(var(--input));
background-color: hsl(var(--background));
padding: 0.625rem 0.75rem;
font-size: 0.875rem;
transition: all 0.15s;
color: hsl(var(--foreground));
}
select.input[multiple] {
display: block;
padding: 0.5rem;
}
select.input[multiple] option {
padding: 0.5rem;
border-radius: 4px;
margin-bottom: 2px;
}
select.input[multiple] option:checked {
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
}
/* Multi-Select Component */
.multi-select-wrapper {
position: relative;
width: 100%;
}
.multi-select-trigger {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.625rem 0.75rem;
border: 1px solid hsl(var(--input));
border-radius: var(--radius);
background-color: hsl(var(--background));
cursor: pointer;
transition: all 0.15s;
}
.multi-select-trigger:hover {
border-color: hsl(var(--ring));
}
.multi-select-value {
flex: 1;
font-size: 0.875rem;
color: hsl(var(--foreground));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.multi-select-value.placeholder {
color: hsl(var(--muted-foreground));
}
.multi-select-arrow {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
transition: transform 0.2s;
}
.multi-select-trigger.open .multi-select-arrow {
transform: rotate(180deg);
}
.multi-select-dropdown {
position: absolute;
top: calc(100% + 0.25rem);
left: 0;
right: 0;
background: hsl(var(--background));
border: 1px solid hsl(var(--input));
border-radius: var(--radius);
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
z-index: 50;
max-height: 300px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.multi-select-search {
padding: 0.5rem;
border-bottom: 1px solid hsl(var(--border));
}
.multi-select-search .input {
padding: 0.5rem;
font-size: 0.875rem;
}
.multi-select-options {
overflow-y: auto;
max-height: 250px;
}
.multi-select-option {
display: flex;
align-items: center;
padding: 0.625rem 0.75rem;
cursor: pointer;
transition: background-color 0.15s;
gap: 0.5rem;
}
.multi-select-option:hover {
background-color: hsl(var(--accent));
}
.multi-select-option input[type="checkbox"] {
width: 1rem;
height: 1rem;
cursor: pointer;
}
.multi-select-option label {
flex: 1;
font-size: 0.875rem;
cursor: pointer;
margin: 0;
}
.multi-select-loading,
.multi-select-empty {
padding: 1rem;
text-align: center;
color: hsl(var(--muted-foreground));
font-size: 0.875rem;
}
.input:focus {
outline: none;
border-color: hsl(var(--ring));
box-shadow: 0 0 0 2px hsl(var(--ring) / 0.2);
}
.input:disabled {
cursor: not-allowed;
opacity: 0.5;
}
/* Label */
.label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.5rem;
color: hsl(var(--foreground));
}
/* Badge */
.badge {
display: inline-flex;
align-items: center;
border-radius: calc(var(--radius) - 2px);
padding: 0.25rem 0.625rem;
font-size: 0.75rem;
font-weight: 600;
line-height: 1;
transition: all 0.15s;
}
.badge-default {
background-color: hsl(var(--primary));
color: hsl(var(--primary-foreground));
}
.badge-secondary {
background-color: hsl(var(--secondary));
color: hsl(var(--secondary-foreground));
}
.badge-success {
background-color: hsl(142.1 76.2% 36.3%);
color: hsl(0 0% 100%);
}
.badge-warning {
background-color: hsl(47.9 95.8% 53.1%);
color: hsl(26 83.3% 14.1%);
}
.badge-destructive {
background-color: hsl(var(--destructive));
color: hsl(var(--destructive-foreground));
}
.badge-outline {
background-color: transparent;
border: 1px solid hsl(var(--border));
color: hsl(var(--foreground));
}
/* Table */
.table-container {
overflow-x: auto;
border-radius: var(--radius);
border: 1px solid hsl(var(--border));
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.table thead {
background-color: hsl(var(--muted));
}
.table th {
padding: 0.75rem 1rem;
text-align: left;
font-weight: 600;
color: hsl(var(--muted-foreground));
border-bottom: 1px solid hsl(var(--border));
}
.table td {
padding: 0.75rem 1rem;
border-bottom: 1px solid hsl(var(--border));
}
.table tbody tr:hover {
background-color: hsl(var(--muted) / 0.5);
}
.table tbody tr:last-child td {
border-bottom: none;
}
/* Alert */
.alert {
padding: 1rem;
border-radius: var(--radius);
border: 1px solid hsl(var(--border));
margin-bottom: 1rem;
}
.alert-info {
background-color: hsl(330 100% 97%);
border-color: hsl(330 81% 85%);
color: hsl(330 81% 30%);
}
.dark .alert-info {
background-color: hsl(330 40% 15%);
border-color: hsl(330 40% 25%);
color: hsl(330 81% 85%);
}
.alert-success {
background-color: hsl(142.1 76.2% 36.3% / 0.1);
border-color: hsl(142.1 76.2% 36.3% / 0.3);
}
.alert-warning {
background-color: hsl(47.9 95.8% 53.1% / 0.1);
border-color: hsl(47.9 95.8% 53.1% / 0.3);
}
.alert-error {
background-color: hsl(var(--destructive) / 0.1);
border-color: hsl(var(--destructive) / 0.3);
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
z-index: 50;
display: none;
align-items: center;
justify-content: center;
animation: fadeIn 0.2s ease;
}
.modal-overlay.active {
display: flex;
}
.modal-content {
background-color: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: calc(var(--radius) + 4px);
box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
max-width: 32rem;
width: 90%;
max-height: 90vh;
overflow-y: auto;
animation: slideUp 0.2s ease;
}
.modal-header {
padding: 1.5rem;
border-bottom: 1px solid hsl(var(--border));
}
.modal-title {
font-size: 1.25rem;
font-weight: 600;
}
.modal-body {
padding: 1.5rem;
}
.modal-footer {
padding: 1.5rem;
border-top: 1px solid hsl(var(--border));
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
/* Form Group */
.form-group {
margin-bottom: 1.25rem;
}
.form-group:last-child {
margin-bottom: 0;
}
/* Grid */
.grid {
display: grid;
gap: 1.5rem;
}
.grid-cols-2 {
grid-template-columns: repeat(2, 1fr);
}
.grid-cols-3 {
grid-template-columns: repeat(3, 1fr);
}
.grid-cols-4 {
grid-template-columns: repeat(4, 1fr);
}
/* Stats Card */
.stats-card {
background-color: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: calc(var(--radius) + 2px);
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.stats-card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.stats-card-title {
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--muted-foreground));
}
.stats-card-value {
font-size: 2rem;
font-weight: 700;
}
.stats-card-trend {
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
}
/* Animations */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes fadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
/* Utilities */
.hidden {
display: none !important;
}
.flex {
display: flex;
}
.items-center {
align-items: center;
}
.justify-between {
justify-content: space-between;
}
.gap-2 {
gap: 0.5rem;
}
.gap-4 {
gap: 1rem;
}
.mt-1 {
margin-top: 0.25rem;
}
.mt-2 {
margin-top: 0.5rem;
}
.mb-3 {
margin-bottom: 0.75rem;
}
.mb-4 {
margin-bottom: 1rem;
}
.mb-6 {
margin-bottom: 1.5rem;
}
.text-sm {
font-size: 0.875rem;
}
.text-center {
text-align: center;
}
.text-muted {
color: hsl(var(--muted-foreground));
}
.font-medium {
font-weight: 500;
}
.font-semibold {
font-weight: 600;
}
.w-full {
width: 100%;
}
code {
background-color: hsl(var(--muted));
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
font-size: 0.875em;
font-family: 'Courier New', monospace;
}
/* Tab Content */
.tab-content {
display: none;
}
.tab-content.active {
display: flex;
flex-direction: column;
flex: 1;
}
/* Responsive */
@media (max-width: 1024px) {
.sidebar {
transform: translateX(-100%);
transition: transform 0.3s;
}
.sidebar.mobile-open {
transform: translateX(0);
}
.main-content {
margin-left: 0;
}
.grid-cols-2,
.grid-cols-3,
.grid-cols-4 {
grid-template-columns: 1fr;
}
}
/* ===== Loading Overlay ===== */
#loadingOverlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
z-index: 9999;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
}
#loadingOverlay.active {
opacity: 1;
pointer-events: auto;
}
.loading-spinner {
width: 48px;
height: 48px;
border: 3px solid rgba(255, 255, 255, 0.2);
border-top-color: hsl(var(--primary));
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.loading-text {
margin-top: 1rem;
color: white;
font-size: 0.875rem;
font-weight: 500;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* ===== Item List Styles ===== */
#tokenListView,
#channelListView,
#pricingContent {
display: flex;
flex-direction: column;
flex: 1;
}
.item-list {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
padding-right: 1.5rem;
min-height: 0;
}
.item-list::-webkit-scrollbar {
width: 6px;
}
.item-list::-webkit-scrollbar-track {
background: hsl(var(--muted));
border-radius: 3px;
}
.item-list::-webkit-scrollbar-thumb {
background: hsl(var(--border));
border-radius: 3px;
}
.item-list::-webkit-scrollbar-thumb:hover {
background: hsl(var(--muted-foreground));
}
.item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 18px 20px;
border: 1px solid hsl(var(--border));
border-radius: var(--radius);
margin-bottom: 12px;
background: hsl(var(--card));
transition: all 0.2s ease;
}
.item:hover {
border-color: hsl(var(--primary));
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
transform: translateX(4px);
}
.item-info {
flex: 1;
min-width: 0;
}
.item-key {
font-weight: 600;
color: hsl(var(--foreground));
margin-bottom: 6px;
font-size: 15px;
}
.item-details {
font-size: 13px;
color: hsl(var(--muted-foreground));
line-height: 1.5;
}
.actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.btn-danger {
background-color: hsl(var(--destructive));
color: hsl(var(--destructive-foreground));
}
.btn-danger:hover {
opacity: 0.9;
}
-590
View File
@@ -1,590 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AWSL One API - 管理控制台</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🤖</text></svg>">
<link rel="stylesheet" href="/css/styles.css">
</head>
<body>
<div class="app-container">
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<div class="sidebar-brand">
<span>🤖</span>
<span>AWSL One API</span>
</div>
</div>
<nav class="sidebar-nav">
<div class="nav-section">
<div class="nav-section-title">主菜单</div>
<button class="nav-item active" onclick="switchTab('dashboard')" data-tab="dashboard">
<span class="nav-item-icon">📊</span>
<span>仪表盘</span>
</button>
<button class="nav-item" onclick="switchTab('api-test')" data-tab="api-test">
<span class="nav-item-icon">🧪</span>
<span>API 测试</span>
</button>
</div>
<div class="nav-section admin-only hidden">
<div class="nav-section-title">管理</div>
<button class="nav-item" onclick="switchTab('channels')" data-tab="channels">
<span class="nav-item-icon">🔗</span>
<span>频道管理</span>
</button>
<button class="nav-item" onclick="switchTab('tokens')" data-tab="tokens">
<span class="nav-item-icon">🔑</span>
<span>令牌管理</span>
</button>
<button class="nav-item" onclick="switchTab('pricing')" data-tab="pricing">
<span class="nav-item-icon">💰</span>
<span>定价管理</span>
</button>
</div>
<div class="nav-section admin-only hidden">
<div class="nav-section-title">系统</div>
<button class="nav-item" onclick="switchTab('database')" data-tab="database">
<span class="nav-item-icon">🗄️</span>
<span>数据库</span>
</button>
</div>
</nav>
<div class="sidebar-footer">
<div style="padding: 0.75rem 1rem; border-top: 1px solid hsl(var(--border)); display: flex; justify-content: space-between; align-items: center; font-size: 0.875rem; color: hsl(var(--muted-foreground)); margin-bottom: 0.75rem;">
<a href="https://github.com/dreamhunter2333/awsl-one-api" target="_blank" style="color: hsl(var(--primary)); text-decoration: none; display: flex; align-items: center; gap: 0.25rem; transition: opacity 0.2s;">
🔗 GitHub
</a>
<span>v1.0.0</span>
</div>
<button class="btn btn-outline w-full" onclick="toggleTheme()" style="justify-content: center; margin-bottom: 0.5rem;">
<span id="themeIcon">🌙</span>
<span style="margin-left: 0.5rem;">切换主题</span>
</button>
<button class="btn btn-outline w-full" onclick="toggleAuthModal()" id="authButton">
🔑 管理员登录
</button>
</div>
</aside>
<!-- Main Content -->
<main class="main-content">
<header class="main-header">
<div class="header-content">
<h1 class="header-title" id="pageTitle">仪表盘</h1>
</div>
</header>
<div class="content-wrapper">
<!-- Dashboard Tab -->
<div id="dashboard" class="tab-content active">
<div class="alert alert-info mb-6" id="loginPrompt">
<div class="flex items-center justify-between">
<div>
<strong>💡 提示</strong>
<p class="text-sm mt-1">要使用管理功能,请先进行身份验证</p>
</div>
<button class="btn btn-primary btn-sm" onclick="toggleAuthModal()">
立即登录
</button>
</div>
</div>
<div class="card mb-6">
<div class="card-header">
<h2 class="card-title">欢迎使用 AWSL One API</h2>
<p class="card-description">统一的 AI 模型接口管理服务</p>
</div>
<div class="card-content">
<p class="mb-6">
AWSL One API 是一个强大的 API 代理和管理平台,为您提供统一的 AI 模型接口管理服务。
</p>
<h3 class="font-semibold mb-4">核心特性</h3>
<div class="grid grid-cols-2 gap-4">
<div class="card">
<div class="card-content">
<h4 class="font-semibold mb-2">🌐 多提供商支持</h4>
<p class="text-sm text-muted">支持 OpenAI、Azure OpenAI 和 Claude API,轻松切换不同的 AI 服务。</p>
</div>
</div>
<div class="card">
<div class="card-content">
<h4 class="font-semibold mb-2">⚖️ 负载均衡</h4>
<p class="text-sm text-muted">自动在多个频道间分配请求,确保服务稳定性和高可用性。</p>
</div>
</div>
<div class="card">
<div class="card-content">
<h4 class="font-semibold mb-2">📊 用量追踪</h4>
<p class="text-sm text-muted">实时监控 token 使用情况,精确计算成本,防止超出配额。</p>
</div>
</div>
<div class="card">
<div class="card-content">
<h4 class="font-semibold mb-2">🔐 安全管理</h4>
<p class="text-sm text-muted">基于令牌的访问控制,为不同用户分配独立的 API 密钥。</p>
</div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3 class="card-title">快速开始</h3>
<p class="card-description">按照以下步骤开始使用</p>
</div>
<div class="card-content">
<ol style="list-style: decimal; padding-left: 1.5rem; line-height: 2;">
<li><strong>管理员登录:</strong>使用管理员令牌登录系统</li>
<li><strong>配置频道:</strong>添加 OpenAI、Azure 或 Claude API 频道</li>
<li><strong>创建令牌:</strong>为您的应用创建 API 令牌</li>
<li><strong>开始使用:</strong>使用生成的令牌调用 API</li>
</ol>
</div>
</div>
</div>
<!-- API Test Tab -->
<div id="api-test" class="tab-content">
<div class="card">
<div class="card-header">
<h2 class="card-title">API 测试</h2>
<p class="card-description">测试您的 API 连接和配置</p>
</div>
<div class="card-content">
<form id="apiTestForm" onsubmit="testAPI(event)">
<div class="grid grid-cols-2">
<div class="form-group">
<label class="label">API 端点</label>
<select class="input" id="testEndpoint" onchange="updateRequestTemplate()">
<option value="/v1/chat/completions">/v1/chat/completions</option>
<option value="/v1/messages">/v1/messages</option>
<option value="/v1/completions">/v1/completions</option>
</select>
</div>
<div class="form-group">
<label class="label">API 令牌</label>
<input type="text" class="input" id="testApiToken" placeholder="sk-..." required>
</div>
</div>
<div class="form-group">
<label class="label">请求体 (JSON)</label>
<textarea class="input" id="testRequestBody" rows="12" placeholder='{"model": "gpt-3.5-turbo", "messages": [...]}'></textarea>
<p class="text-sm text-muted mt-1">直接编辑 JSON 格式的请求体</p>
</div>
<button type="submit" class="btn btn-primary" id="testSubmitBtn">
🚀 发送请求
</button>
</form>
<div id="testResult" class="hidden" style="margin-top: 1.5rem;">
<div class="flex items-center justify-between mb-3">
<h3 class="font-semibold">响应结果</h3>
<div class="flex gap-2">
<span class="badge badge-secondary" id="testStatusBadge">等待中</span>
<span class="badge badge-outline" id="testTimeBadge">0ms</span>
</div>
</div>
<div class="card">
<div class="card-content">
<pre id="testResultContent" style="white-space: pre-wrap; word-wrap: break-word; font-size: 0.875rem; line-height: 1.6; max-height: 500px; overflow-y: auto;"></pre>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Channels Tab -->
<div id="channels" class="tab-content">
<!-- Channel List View -->
<div id="channelListView">
<div class="card">
<div class="card-header">
<div class="flex gap-2">
<button class="btn btn-primary" onclick="showChannelForm()">➕ 添加频道</button>
<button class="btn btn-secondary" onclick="loadChannels()">🔄 刷新</button>
</div>
</div>
<div id="channelsList" class="item-list"></div>
</div>
</div>
<!-- Channel Form View -->
<div id="channelFormView" style="display: none;">
<div class="card">
<div class="card-header">
<h3 class="card-title" id="channelFormTitle">添加频道</h3>
<div class="flex gap-2">
<button class="btn btn-secondary" onclick="toggleChannelEditMode()" id="channelModeToggle">📝 切换到JSON模式</button>
<button class="btn btn-primary" onclick="saveChannel()">💾 保存频道</button>
<button class="btn btn-secondary" onclick="showChannelList()">← 返回列表</button>
</div>
</div>
<div class="card-content">
<!-- Common Key Field -->
<div class="form-group">
<label for="channelKey">频道标识</label>
<input type="text" id="channelKey" class="input" placeholder="例如: azure-openai-1">
</div>
<!-- Form Mode -->
<div id="channelFormMode">
<div class="form-group">
<label for="channelName">频道名称</label>
<input type="text" id="channelName" class="input" placeholder="例如: Azure OpenAI">
</div>
<div class="form-group">
<label for="channelType">频道类型</label>
<select id="channelType" class="input">
<option value="azure-openai">Azure OpenAI</option>
<option value="openai">OpenAI</option>
<option value="claude">Claude</option>
</select>
</div>
<div class="form-group">
<label for="channelEndpoint">API 端点</label>
<input type="text" id="channelEndpoint" class="input" placeholder="https://your-resource.openai.azure.com/">
</div>
<div class="form-group">
<label for="channelApiKey">API 密钥</label>
<input type="password" id="channelApiKey" class="input" placeholder="your-api-key">
</div>
<div class="form-group">
<label for="channelApiVersion">API 版本(可选)</label>
<input type="text" id="channelApiVersion" class="input" placeholder="例如: 2024-02-01">
</div>
<div class="form-group">
<label for="channelMapperTable">模型部署映射</label>
<div style="margin-bottom: 0.5rem;">
<button type="button" class="btn btn-secondary btn-sm" onclick="window.addMapperRow()">➕ 添加映射</button>
</div>
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>请求模型名</th>
<th>部署模型名</th>
<th style="width: 100px; text-align: center;">操作</th>
</tr>
</thead>
<tbody id="channelMapperTable">
<tr>
<td colspan="3" class="text-center text-muted">暂无映射,点击上方按钮添加</td>
</tr>
</tbody>
</table>
</div>
<p class="text-sm text-muted mt-1">配置模型名称映射关系(主要用于Azure OpenAI)。留空表示不进行映射</p>
</div>
</div>
<!-- JSON Mode -->
<div id="channelJsonMode" style="display: none;">
<div class="form-group">
<label for="channelValueJson">频道配置 (JSON)</label>
<textarea id="channelValueJson" class="input" rows="15" placeholder='{"name": "Azure OpenAI", "type": "azure-openai", "endpoint": "https://xxx.openai.azure.com/", "api_key": "your-api-key", "api_version": "2024-02-01", "deployment_mapper": {"gpt-35-turbo": "gpt-35-turbo", "gpt-4": "gpt-4"}}'></textarea>
<p class="text-sm text-muted mt-1">直接编辑完整的频道配置JSON</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Tokens Tab -->
<div id="tokens" class="tab-content">
<!-- Token List View -->
<div id="tokenListView">
<div class="card">
<div class="card-header">
<div class="flex gap-2">
<button class="btn btn-primary" onclick="showTokenForm()">➕ 添加令牌</button>
<button class="btn btn-secondary" onclick="loadTokens()">🔄 刷新</button>
</div>
</div>
<div id="tokensList" class="item-list"></div>
</div>
</div>
<!-- Token Form View -->
<div id="tokenFormView" style="display: none;">
<div class="card">
<div class="card-header">
<h3 class="card-title" id="tokenFormTitle">添加令牌</h3>
<div class="flex gap-2">
<button class="btn btn-secondary" onclick="toggleTokenEditMode()" id="tokenModeToggle">📝 切换到JSON模式</button>
<button class="btn btn-primary" onclick="saveToken()">💾 保存令牌</button>
<button class="btn btn-secondary" onclick="showTokenList()">← 返回列表</button>
</div>
</div>
<div class="card-content">
<!-- Common Key Field -->
<div class="form-group">
<label for="tokenKey">令牌标识</label>
<div class="flex gap-2">
<input type="text" id="tokenKey" class="input" placeholder="输入令牌标识或点击生成">
<button type="button" id="generateTokenBtn" class="btn btn-secondary" onclick="generateTokenKey()">🎲 生成</button>
</div>
</div>
<!-- Form Mode -->
<div id="tokenFormMode">
<div class="form-group">
<label for="tokenName">令牌名称</label>
<input type="text" id="tokenName" class="input" placeholder="例如: 用户令牌1">
</div>
<div class="form-group">
<label for="channelKeysSelect">允许访问的频道</label>
<div class="multi-select-wrapper">
<div class="multi-select-trigger" id="channelSelectTrigger" onclick="window.toggleChannelDropdown()">
<span class="multi-select-value" id="channelSelectValue">请选择频道...</span>
<span class="multi-select-arrow">▼</span>
</div>
<div class="multi-select-dropdown hidden" id="channelSelectDropdown">
<div class="multi-select-search">
<input type="text" class="input" placeholder="搜索频道..." id="channelSearchInput" oninput="window.filterChannels()">
</div>
<div class="multi-select-options" id="channelOptionsContainer">
<div class="multi-select-loading">加载中...</div>
</div>
</div>
</div>
<p class="text-sm text-muted mt-1">不选表示允许访问所有频道</p>
</div>
<div class="form-group">
<label for="totalQuotaInput">总配额</label>
<input type="number" id="totalQuotaInput" class="input" placeholder="1000000" min="0" step="1">
<div style="margin-top: 10px;">
<small class="text-sm text-muted" style="display: block; margin-bottom: 8px;">快速选择:</small>
<div style="display: flex; gap: 5px; flex-wrap: wrap;">
<button type="button" class="btn btn-secondary btn-sm" onclick="setQuotaValue(1000000)">$1</button>
<button type="button" class="btn btn-secondary btn-sm" onclick="setQuotaValue(5000000)">$5</button>
<button type="button" class="btn btn-secondary btn-sm" onclick="setQuotaValue(10000000)">$10</button>
<button type="button" class="btn btn-secondary btn-sm" onclick="setQuotaValue(20000000)">$20</button>
<button type="button" class="btn btn-secondary btn-sm" onclick="setQuotaValue(50000000)">$50</button>
</div>
</div>
<p class="text-sm text-muted mt-2">设置令牌的总使用配额 (基础单位:1百万token = $1.00,例如:输入1000000表示1美元的配额)</p>
</div>
</div>
<!-- JSON Mode -->
<div id="tokenJsonMode" style="display: none;">
<div class="form-group">
<label for="tokenValueJson">令牌配置 (JSON)</label>
<textarea id="tokenValueJson" class="input" rows="10" placeholder='{"name": "用户令牌1", "channel_keys": ["azure-openai-1"], "total_quota": 1000000}'></textarea>
<p class="text-sm text-muted mt-1">直接编辑完整的令牌配置JSON</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Pricing Tab -->
<div id="pricing" class="tab-content">
<div class="card">
<div class="card-header">
<div class="flex gap-2">
<button class="btn btn-secondary" onclick="togglePricingEditMode()" id="pricingModeToggle">📝 切换到JSON模式</button>
<button class="btn btn-secondary" onclick="addPricingModel()" id="addPricingModelBtn">➕ 添加模型</button>
<button class="btn btn-primary" onclick="savePricing(event)">💾 更新定价</button>
<button class="btn btn-secondary" onclick="loadPricing()">🔄 刷新</button>
</div>
</div>
<div class="card-content">
<div style="background: hsl(var(--accent)); padding: 15px; border-radius: 6px; margin-bottom: 20px; border-left: 4px solid hsl(var(--primary));">
<h4 style="margin: 0 0 10px 0; color: hsl(var(--foreground)); font-size: 14px; font-weight: 600;">💡 定价说明</h4>
<p style="margin: 0; font-size: 13px; color: hsl(var(--foreground)); line-height: 1.5;">
<strong>基础配额单位:1百万token = $1.00</strong> | 定价配置为不同模型相对于基础单位的倍率 | 例如:gpt-4的input倍率为30,表示1百万token消耗30美元配额
</p>
</div>
<!-- Table Mode -->
<div id="pricingTableMode">
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>模型名称</th>
<th>输入倍率</th>
<th>输出倍率</th>
<th style="width: 100px; text-align: center;">操作</th>
</tr>
</thead>
<tbody id="pricingTableBody">
<tr>
<td colspan="4" class="text-center text-muted">加载中...</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- JSON Mode -->
<div id="pricingJsonMode" style="display: none;">
<div class="form-group">
<label for="pricingConfigJson">定价配置 (JSON)</label>
<textarea class="input" id="pricingConfigJson" rows="15" placeholder='{"gpt-3.5-turbo": {"input": 0.001, "output": 0.002}, "gpt-4": {"input": 0.03, "output": 0.06}}'></textarea>
<p class="text-sm text-muted mt-2">配置格式:模型名称 -> {input: 输入倍率, output: 输出倍率}。倍率基于1百万token=$1.00计算</p>
</div>
</div>
</div>
</div>
</div>
<!-- Database Tab -->
<div id="database" class="tab-content">
<div class="card">
<div class="card-content">
<div class="alert alert-warning mb-4">
<strong>⚠️ 警告:</strong>
<p class="text-sm mt-1">初始化数据库将创建必要的表结构。如果表已存在,操作将被忽略。</p>
</div>
<button class="btn btn-primary" onclick="initDatabase()">
🗄️ 初始化数据库
</button>
<div id="dbResult" class="hidden" style="margin-top: 1.5rem;">
<div class="card">
<div class="card-content">
<pre id="dbResultContent" style="white-space: pre-wrap; font-size: 0.875rem;"></pre>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
<!-- Auth Modal -->
<div class="modal-overlay" id="authModal">
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title">管理员身份验证</h2>
</div>
<div class="modal-body">
<form id="authForm" onsubmit="authenticate(event)">
<div class="form-group">
<label class="label">管理员令牌</label>
<input type="password" class="input" id="adminToken" placeholder="请输入管理员令牌" required>
</div>
<div id="authError" class="alert alert-error hidden">
<strong>❌ 错误:</strong>
<p class="text-sm mt-1" id="authErrorMessage"></p>
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-outline" onclick="toggleAuthModal()">取消</button>
<button class="btn btn-primary" onclick="authenticate(event)">登录</button>
</div>
</div>
</div>
<!-- Add Channel Modal -->
<div class="modal-overlay" id="addChannelModal">
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title">添加频道</h2>
</div>
<div class="modal-body">
<form id="addChannelForm">
<div class="form-group">
<label class="label">频道 Key</label>
<input type="text" class="input" id="channelKey" placeholder="channel-1" required>
</div>
<div class="form-group">
<label class="label">频道名称</label>
<input type="text" class="input" id="channelName" placeholder="My Channel" required>
</div>
<div class="form-group">
<label class="label">类型</label>
<select class="input" id="channelType" required>
<option value="openai">OpenAI</option>
<option value="azure-openai">Azure OpenAI</option>
<option value="claude">Claude</option>
</select>
</div>
<div class="form-group">
<label class="label">API 端点</label>
<input type="url" class="input" id="channelEndpoint" placeholder="https://api.openai.com" required>
</div>
<div class="form-group">
<label class="label">API 密钥</label>
<input type="password" class="input" id="channelApiKey" placeholder="sk-..." required>
</div>
<div class="form-group">
<label class="label">模型映射 (JSON)</label>
<textarea class="input" id="channelMapper" rows="4" placeholder='{"gpt-3.5-turbo": "gpt-3.5-turbo"}' required></textarea>
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-outline" onclick="toggleModal('addChannelModal')">取消</button>
<button class="btn btn-primary" onclick="addChannel(event)">添加</button>
</div>
</div>
</div>
<!-- Add Token Modal -->
<div class="modal-overlay" id="addTokenModal">
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title">创建令牌</h2>
</div>
<div class="modal-body">
<form id="addTokenForm">
<div class="form-group">
<label class="label">令牌 Key</label>
<input type="text" class="input" id="tokenKey" placeholder="sk-..." required>
</div>
<div class="form-group">
<label class="label">令牌名称</label>
<input type="text" class="input" id="tokenName" placeholder="My Token" required>
</div>
<div class="form-group">
<label class="label">总配额</label>
<input type="number" class="input" id="tokenQuota" placeholder="100" step="0.01" required>
</div>
<div class="form-group">
<label class="label">允许的频道 (留空允许所有)</label>
<input type="text" class="input" id="tokenChannels" placeholder="channel-1,channel-2">
<p class="text-sm text-muted mt-1">多个频道用逗号分隔</p>
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-outline" onclick="toggleModal('addTokenModal')">取消</button>
<button class="btn btn-primary" onclick="addToken(event)">创建</button>
</div>
</div>
</div>
<!-- Loading Overlay -->
<div id="loadingOverlay">
<div class="loading-spinner"></div>
<div class="loading-text">加载中...</div>
</div>
<script type="module" src="/js/main.js"></script>
</body>
</html>
-123
View File
@@ -1,123 +0,0 @@
// ===== API Test Module =====
import { showNotification } from './utils.js';
// API Test Template
export function updateRequestTemplate() {
const endpoint = document.getElementById('testEndpoint').value;
const requestBody = document.getElementById('testRequestBody');
const templates = {
'/v1/chat/completions': {
model: "gpt-3.5-turbo",
messages: [
{ role: "user", content: "你好,请介绍一下你自己。" }
],
temperature: 0.7,
max_tokens: 1024
},
'/v1/messages': {
model: "claude-haiku-4-5-20251001",
max_tokens: 1024,
messages: [
{ role: "user", content: "你好,请介绍一下你自己。" }
]
},
'/v1/completions': {
model: "gpt-3.5-turbo-instruct",
prompt: "你好,请介绍一下你自己。",
max_tokens: 1024,
temperature: 0.7
}
};
requestBody.value = JSON.stringify(templates[endpoint], null, 2);
}
export async function testAPI(event) {
event.preventDefault();
const endpoint = document.getElementById('testEndpoint').value;
const token = document.getElementById('testApiToken').value;
const requestBodyText = document.getElementById('testRequestBody').value;
const resultDiv = document.getElementById('testResult');
const resultContent = document.getElementById('testResultContent');
const statusBadge = document.getElementById('testStatusBadge');
const timeBadge = document.getElementById('testTimeBadge');
const submitBtn = document.getElementById('testSubmitBtn');
// Validate JSON
let requestBody;
try {
requestBody = JSON.parse(requestBodyText);
} catch (error) {
showNotification('JSON 格式错误:' + error.message, 'error');
return;
}
resultDiv.classList.remove('hidden');
resultContent.textContent = '发送请求中...';
statusBadge.textContent = '请求中';
statusBadge.className = 'badge badge-warning';
submitBtn.disabled = true;
const startTime = Date.now();
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(requestBody)
});
const endTime = Date.now();
const duration = endTime - startTime;
timeBadge.textContent = `${duration}ms`;
if (response.ok) {
statusBadge.textContent = '成功';
statusBadge.className = 'badge badge-success';
} else {
statusBadge.textContent = `失败 ${response.status}`;
statusBadge.className = 'badge badge-destructive';
}
// Handle streaming vs non-streaming
const isStreaming = requestBody.stream === true;
if (isStreaming) {
resultContent.textContent = '';
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
resultContent.textContent += chunk;
}
} else {
const data = await response.json();
resultContent.textContent = JSON.stringify(data, null, 2);
}
if (!response.ok) {
showNotification('API 请求失败', 'error');
}
} catch (error) {
const endTime = Date.now();
const duration = endTime - startTime;
timeBadge.textContent = `${duration}ms`;
statusBadge.textContent = '错误';
statusBadge.className = 'badge badge-destructive';
resultContent.textContent = '错误:' + error.message;
showNotification('请求失败:' + error.message, 'error');
} finally {
submitBtn.disabled = false;
}
}
-80
View File
@@ -1,80 +0,0 @@
// ===== Authentication Module =====
import { toggleModal, showNotification, showLoading, hideLoading } from './utils.js';
export function toggleAuthModal() {
const adminToken = localStorage.getItem('adminToken');
if (adminToken) {
// Logout
if (confirm('确定要退出登录吗?')) {
localStorage.removeItem('adminToken');
onLogout();
}
} else {
toggleModal('authModal');
}
}
export async function authenticate(event) {
event.preventDefault();
const token = document.getElementById('adminToken').value;
showLoading('验证令牌...');
try {
const response = await fetch('/api/admin/channel', {
headers: { 'x-admin-token': token }
});
if (response.ok) {
localStorage.setItem('adminToken', token);
toggleModal('authModal');
onAuthenticated();
showNotification('登录成功', 'success');
} else {
showAuthError('管理员令牌无效');
}
} catch (error) {
showAuthError('认证失败:' + error.message);
} finally {
hideLoading();
}
}
function showAuthError(message) {
document.getElementById('authError').classList.remove('hidden');
document.getElementById('authErrorMessage').textContent = message;
}
function onAuthenticated() {
document.querySelectorAll('.admin-only').forEach(el => {
el.classList.remove('hidden');
});
document.getElementById('loginPrompt').classList.add('hidden');
const authButton = document.getElementById('authButton');
authButton.textContent = '🚪 退出登录';
authButton.style.color = 'hsl(var(--destructive))';
authButton.style.borderColor = 'hsl(var(--destructive))';
}
function onLogout() {
document.querySelectorAll('.admin-only').forEach(el => {
el.classList.add('hidden');
});
document.getElementById('loginPrompt').classList.remove('hidden');
const authButton = document.getElementById('authButton');
authButton.textContent = '🔑 管理员登录';
authButton.style.color = '';
authButton.style.borderColor = '';
if (window.switchTab) {
window.switchTab('dashboard');
}
}
// Initialize on page load
export function initAuth() {
const adminToken = localStorage.getItem('adminToken');
if (adminToken) {
onAuthenticated();
}
}
-407
View File
@@ -1,407 +0,0 @@
// ===== Channel Management Module =====
import { toggleModal, showNotification, showLoading, hideLoading } from './utils.js';
let channelEditMode = 'form'; // 'form' or 'json'
let currentEditingKey = null;
// Add model mapper row
export function addMapperRow() {
const tbody = document.getElementById('channelMapperTable');
if (!tbody) return;
// Remove empty message if exists
if (tbody.querySelector('.text-center')) {
tbody.innerHTML = '';
}
const row = document.createElement('tr');
row.innerHTML = `
<td style="padding: 12px;">
<input type="text" class="input" placeholder="例如: gpt-4" style="padding: 0.5rem;">
</td>
<td style="padding: 12px;">
<input type="text" class="input" placeholder="例如: gpt-4-deployment" style="padding: 0.5rem;">
</td>
<td style="padding: 12px; text-align: center;">
<button type="button" class="btn btn-danger btn-sm" onclick="window.removeMapperRow(this)">🗑️ 删除</button>
</td>
`;
tbody.appendChild(row);
}
// Remove model mapper row
export function removeMapperRow(button) {
const row = button.closest('tr');
if (row) {
row.remove();
}
// If no rows left, show empty message
const tbody = document.getElementById('channelMapperTable');
if (tbody && tbody.children.length === 0) {
tbody.innerHTML = '<tr><td colspan="3" class="text-center text-muted">暂无映射,点击上方按钮添加</td></tr>';
}
}
// Display mapper table
function displayMapperTable(mapper) {
const tbody = document.getElementById('channelMapperTable');
if (!tbody) return;
tbody.innerHTML = '';
if (!mapper || Object.keys(mapper).length === 0) {
tbody.innerHTML = '<tr><td colspan="3" class="text-center text-muted">暂无映射,点击上方按钮添加</td></tr>';
return;
}
Object.entries(mapper).forEach(([requestModel, deploymentModel]) => {
const row = document.createElement('tr');
row.innerHTML = `
<td style="padding: 12px;">
<input type="text" class="input" value="${requestModel}" placeholder="例如: gpt-4" style="padding: 0.5rem;">
</td>
<td style="padding: 12px;">
<input type="text" class="input" value="${deploymentModel}" placeholder="例如: gpt-4-deployment" style="padding: 0.5rem;">
</td>
<td style="padding: 12px; text-align: center;">
<button type="button" class="btn btn-danger btn-sm" onclick="window.removeMapperRow(this)">🗑️ 删除</button>
</td>
`;
tbody.appendChild(row);
});
}
// Collect mapper table data
function collectMapperTableData() {
const tbody = document.getElementById('channelMapperTable');
const mapper = {};
if (!tbody) return mapper;
Array.from(tbody.children).forEach((row) => {
// Skip empty message row
if (row.querySelector('.text-center')) return;
const inputs = row.querySelectorAll('input');
if (inputs.length < 2) return;
const requestModel = inputs[0].value.trim();
const deploymentModel = inputs[1].value.trim();
if (requestModel && deploymentModel) {
mapper[requestModel] = deploymentModel;
}
});
return mapper;
}
// Load channels from API
export async function loadChannels() {
const adminToken = localStorage.getItem('adminToken');
showLoading('加载频道列表...');
try {
const response = await fetch('/api/admin/channel', {
headers: { 'x-admin-token': adminToken }
});
if (response.ok) {
const result = await response.json();
const channels = result.data || [];
displayChannels(channels);
}
} catch (error) {
console.error('Failed to load channels:', error);
showNotification('加载频道失败', 'error');
} finally {
hideLoading();
}
}
// Display channels in the list
function displayChannels(channels) {
const container = document.getElementById('channelsList');
if (!container) return;
if (!channels || channels.length === 0) {
container.innerHTML = '<div style="text-align: center; padding: 3rem 1rem; color: hsl(var(--muted-foreground));">📂 暂无频道数据</div>';
return;
}
container.innerHTML = channels.map(item => {
let parsedValue = {};
try {
parsedValue = typeof item.value === 'string' ? JSON.parse(item.value) : item.value;
} catch {
parsedValue = { name: 'Invalid JSON', type: 'unknown', endpoint: '' };
}
const name = parsedValue.name || 'No name';
const type = parsedValue.type || 'Unknown';
const endpoint = parsedValue.endpoint || '';
const endpointDisplay = endpoint.length > 50 ? endpoint.substring(0, 50) + '...' : endpoint;
const modelCount = Object.keys(parsedValue.deployment_mapper || {}).length;
return `
<div class="item">
<div class="item-info">
<div class="item-key">${name}</div>
<div class="item-details">
${item.key} | 类型: ${type} | 端点: ${endpointDisplay} | ${modelCount} 个模型
</div>
</div>
<div class="actions">
<button class="btn btn-secondary" onclick="window.editChannel('${item.key}')">✏️ 编辑</button>
<button class="btn btn-danger" onclick="window.deleteChannel('${item.key}')">🗑️ 删除</button>
</div>
</div>
`;
}).join('');
}
// Show channel list view
export function showChannelList() {
document.getElementById('channelListView').style.display = 'block';
document.getElementById('channelFormView').style.display = 'none';
loadChannels();
}
// Show channel form for adding
export function showChannelForm() {
currentEditingKey = null;
document.getElementById('channelFormTitle').textContent = '添加频道';
document.getElementById('channelListView').style.display = 'none';
document.getElementById('channelFormView').style.display = 'block';
clearChannelForm();
// Reset to form mode
channelEditMode = 'form';
document.getElementById('channelFormMode').style.display = 'block';
document.getElementById('channelJsonMode').style.display = 'none';
document.getElementById('channelModeToggle').textContent = '📝 切换到JSON模式';
// Channel key should be editable when creating
document.getElementById('channelKey').disabled = false;
document.getElementById('channelKey').placeholder = '例如: azure-openai-1';
}
// Edit existing channel
export async function editChannel(key) {
currentEditingKey = key;
document.getElementById('channelFormTitle').textContent = '编辑频道';
document.getElementById('channelListView').style.display = 'none';
document.getElementById('channelFormView').style.display = 'block';
const adminToken = localStorage.getItem('adminToken');
showLoading('加载频道数据...');
try {
const response = await fetch('/api/admin/channel', {
headers: { 'x-admin-token': adminToken }
});
if (response.ok) {
const result = await response.json();
const channels = result.data || [];
const channel = channels.find(c => c.key === key);
if (channel) {
const config = typeof channel.value === 'string' ? JSON.parse(channel.value) : channel.value;
fillChannelForm(key, config);
// Set channel key as readonly when editing
document.getElementById('channelKey').disabled = true;
}
}
} catch (error) {
showNotification('加载频道数据失败', 'error');
} finally {
hideLoading();
}
}
// Fill form with channel data
function fillChannelForm(key, config) {
document.getElementById('channelKey').value = key;
// Don't change disabled state here - it's set by editChannel
document.getElementById('channelName').value = config.name || '';
document.getElementById('channelType').value = config.type || 'azure-openai';
document.getElementById('channelEndpoint').value = config.endpoint || '';
document.getElementById('channelApiKey').value = config.api_key || '';
document.getElementById('channelApiVersion').value = config.api_version || '';
// Display mapper table
displayMapperTable(config.deployment_mapper || {});
// Update JSON mode
document.getElementById('channelValueJson').value = JSON.stringify(config, null, 2);
}
// Clear form
function clearChannelForm() {
document.getElementById('channelKey').value = '';
document.getElementById('channelKey').disabled = false;
document.getElementById('channelName').value = '';
document.getElementById('channelType').value = 'azure-openai';
document.getElementById('channelEndpoint').value = '';
document.getElementById('channelApiKey').value = '';
document.getElementById('channelApiVersion').value = '';
// Clear mapper table
const tbody = document.getElementById('channelMapperTable');
if (tbody) {
tbody.innerHTML = '<tr><td colspan="3" class="text-center text-muted">暂无映射,点击上方按钮添加</td></tr>';
}
document.getElementById('channelValueJson').value = '';
}
// Toggle between form and JSON mode
export function toggleChannelEditMode() {
if (channelEditMode === 'form') {
// Switch to JSON mode - collect form data and show in JSON
const formData = collectChannelFormData();
if (formData) {
document.getElementById('channelValueJson').value = JSON.stringify(formData, null, 2);
}
document.getElementById('channelFormMode').style.display = 'none';
document.getElementById('channelJsonMode').style.display = 'block';
document.getElementById('channelModeToggle').textContent = '📋 切换到表单模式';
channelEditMode = 'json';
} else {
// Switch to form mode - parse JSON and fill form
try {
const jsonValue = document.getElementById('channelValueJson').value;
if (jsonValue) {
const config = JSON.parse(jsonValue);
document.getElementById('channelName').value = config.name || '';
document.getElementById('channelType').value = config.type || 'azure-openai';
document.getElementById('channelEndpoint').value = config.endpoint || '';
document.getElementById('channelApiKey').value = config.api_key || '';
document.getElementById('channelApiVersion').value = config.api_version || '';
// Display mapper table
displayMapperTable(config.deployment_mapper || {});
}
} catch (error) {
showNotification('JSON格式错误', 'error');
return;
}
document.getElementById('channelFormMode').style.display = 'block';
document.getElementById('channelJsonMode').style.display = 'none';
document.getElementById('channelModeToggle').textContent = '📝 切换到JSON模式';
channelEditMode = 'form';
}
}
// Collect form data
function collectChannelFormData() {
const name = document.getElementById('channelName').value;
const type = document.getElementById('channelType').value;
const endpoint = document.getElementById('channelEndpoint').value;
const apiKey = document.getElementById('channelApiKey').value;
const apiVersion = document.getElementById('channelApiVersion').value;
if (!name || !endpoint || !apiKey) {
return null;
}
// Collect mapper data from table
const deploymentMapper = collectMapperTableData();
const config = {
name,
type,
endpoint,
api_key: apiKey,
deployment_mapper: deploymentMapper
};
if (apiVersion) {
config.api_version = apiVersion;
}
return config;
}
// Save channel (add or update)
export async function saveChannel() {
const key = document.getElementById('channelKey').value || currentEditingKey;
if (!key) {
showNotification('请填写频道标识', 'error');
return;
}
let config;
if (channelEditMode === 'form') {
config = collectChannelFormData();
if (!config) {
showNotification('请填写所有必填字段(频道名称、端点、API密钥)', 'error');
return;
}
} else {
const jsonValue = document.getElementById('channelValueJson').value;
if (!jsonValue) {
showNotification('请填写配置JSON', 'error');
return;
}
try {
config = JSON.parse(jsonValue);
} catch {
showNotification('JSON格式错误,请检查配置格式', 'error');
return;
}
}
const adminToken = localStorage.getItem('adminToken');
showLoading('保存频道...');
try {
const response = await fetch(`/api/admin/channel/${encodeURIComponent(key)}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-admin-token': adminToken
},
body: JSON.stringify(config)
});
if (response.ok) {
showNotification(currentEditingKey ? '频道更新成功' : '频道添加成功', 'success');
clearChannelForm();
showChannelList();
} else {
const error = await response.text();
showNotification('保存失败:' + error, 'error');
}
} catch (error) {
showNotification('保存失败:' + error.message, 'error');
} finally {
hideLoading();
}
}
// Delete channel
export async function deleteChannel(key) {
if (!confirm(`确定要删除频道 "${key}" 吗?`)) return;
const adminToken = localStorage.getItem('adminToken');
showLoading('删除频道...');
try {
const response = await fetch(`/api/admin/channel/${encodeURIComponent(key)}`, {
method: 'DELETE',
headers: { 'x-admin-token': adminToken }
});
if (response.ok) {
showNotification('频道已删除', 'success');
loadChannels();
} else {
showNotification('删除失败', 'error');
}
} catch (error) {
showNotification('删除失败:' + error.message, 'error');
} finally {
hideLoading();
}
}
-27
View File
@@ -1,27 +0,0 @@
// ===== Database Management Module =====
import { showNotification, showLoading, hideLoading } from './utils.js';
export async function initDatabase() {
const adminToken = localStorage.getItem('adminToken');
showLoading('初始化数据库...');
try {
const response = await fetch('/api/admin/db_initialize', {
method: 'POST',
headers: { 'x-admin-token': adminToken }
});
const data = await response.json();
document.getElementById('dbResult').classList.remove('hidden');
document.getElementById('dbResultContent').textContent = JSON.stringify(data, null, 2);
if (response.ok) {
showNotification('数据库初始化成功', 'success');
} else {
showNotification('数据库初始化失败', 'error');
}
} catch (error) {
showNotification('初始化失败:' + error.message, 'error');
} finally {
hideLoading();
}
}
-65
View File
@@ -1,65 +0,0 @@
// ===== Main Entry Point =====
import { toggleModal, switchTab, toggleTheme, showNotification, copyToClipboard } from './utils.js';
import { toggleAuthModal, authenticate, initAuth } from './auth.js';
import { updateRequestTemplate, testAPI } from './api-test.js';
import { loadChannels, showChannelList, showChannelForm, editChannel, saveChannel, toggleChannelEditMode, deleteChannel, addMapperRow, removeMapperRow } from './channels.js';
import { loadTokens, showTokenList, showTokenForm, editToken, saveToken, generateTokenKey, toggleTokenEditMode, deleteToken, setQuotaValue, toggleChannelDropdown, toggleChannelSelection, filterChannels, handleChannelCheckboxChange } from './tokens.js';
import { loadPricing, savePricing, addPricingModel, removePricingRow, togglePricingEditMode } from './pricing.js';
import { initDatabase } from './database.js';
// Expose functions to global scope for onclick handlers
window.toggleModal = toggleModal;
window.switchTab = switchTab;
window.toggleTheme = toggleTheme;
window.copyToClipboard = copyToClipboard;
window.toggleAuthModal = toggleAuthModal;
window.authenticate = authenticate;
window.updateRequestTemplate = updateRequestTemplate;
window.testAPI = testAPI;
window.loadChannels = loadChannels;
window.showChannelList = showChannelList;
window.showChannelForm = showChannelForm;
window.editChannel = editChannel;
window.saveChannel = saveChannel;
window.toggleChannelEditMode = toggleChannelEditMode;
window.deleteChannel = deleteChannel;
window.addMapperRow = addMapperRow;
window.removeMapperRow = removeMapperRow;
window.loadTokens = loadTokens;
window.showTokenList = showTokenList;
window.showTokenForm = showTokenForm;
window.editToken = editToken;
window.saveToken = saveToken;
window.generateTokenKey = generateTokenKey;
window.setQuotaValue = setQuotaValue;
window.toggleTokenEditMode = toggleTokenEditMode;
window.deleteToken = deleteToken;
window.toggleChannelDropdown = toggleChannelDropdown;
window.toggleChannelSelection = toggleChannelSelection;
window.handleChannelCheckboxChange = handleChannelCheckboxChange;
window.filterChannels = filterChannels;
window.loadPricing = loadPricing;
window.savePricing = savePricing;
window.addPricingModel = addPricingModel;
window.removePricingRow = removePricingRow;
window.togglePricingEditMode = togglePricingEditMode;
window.initDatabase = initDatabase;
// Initialize on DOMContentLoaded
document.addEventListener('DOMContentLoaded', () => {
// Initialize theme
const isDark = localStorage.getItem('theme') === 'dark';
if (isDark) {
document.documentElement.classList.add('dark');
document.getElementById('themeIcon').textContent = '☀️';
} else {
document.documentElement.classList.remove('dark');
document.getElementById('themeIcon').textContent = '🌙';
}
// Initialize authentication
initAuth();
// Initialize API test template
updateRequestTemplate();
});
-203
View File
@@ -1,203 +0,0 @@
// ===== Pricing Management Module =====
import { showNotification, showLoading, hideLoading } from './utils.js';
let pricingEditMode = 'table'; // 'table' or 'json'
export async function loadPricing() {
const adminToken = localStorage.getItem('adminToken');
showLoading('加载定价配置...');
try {
const response = await fetch('/api/admin/pricing', {
headers: { 'x-admin-token': adminToken }
});
if (response.ok) {
const data = await response.json();
const pricing = data.data || {};
if (pricingEditMode === 'table') {
displayPricingTable(pricing);
const addBtn = document.getElementById('addPricingModelBtn');
if (addBtn) addBtn.style.display = 'block';
} else {
document.getElementById('pricingConfigJson').value = JSON.stringify(pricing, null, 2);
const addBtn = document.getElementById('addPricingModelBtn');
if (addBtn) addBtn.style.display = 'none';
}
}
} catch (error) {
console.error('Failed to load pricing:', error);
showNotification('加载定价失败', 'error');
} finally {
hideLoading();
}
}
function displayPricingTable(pricing) {
const tbody = document.getElementById('pricingTableBody');
if (!tbody) return;
tbody.innerHTML = '';
// 如果没有定价数据,添加一个空行
if (!pricing || Object.keys(pricing).length === 0) {
addPricingModel();
return;
}
// 为每个模型创建一行
Object.entries(pricing).forEach(([modelName, modelPricing]) => {
const row = document.createElement('tr');
const inputPrice = modelPricing.input || 0;
const outputPrice = modelPricing.output || 0;
row.innerHTML = `
<td style="padding: 12px;">
<input type="text" class="input" value="${modelName}" placeholder="例如: gpt-4">
</td>
<td style="padding: 12px;">
<input type="number" class="input" value="${inputPrice}" step="0.000001" min="0" placeholder="0.001">
</td>
<td style="padding: 12px;">
<input type="number" class="input" value="${outputPrice}" step="0.000001" min="0" placeholder="0.002">
</td>
<td style="padding: 12px; text-align: center;">
<button class="btn btn-danger btn-sm" onclick="window.removePricingRow(this)">🗑️ 删除</button>
</td>
`;
tbody.appendChild(row);
});
}
export function addPricingModel() {
const tbody = document.getElementById('pricingTableBody');
if (!tbody) return;
const row = document.createElement('tr');
row.innerHTML = `
<td style="padding: 12px;">
<input type="text" class="input" placeholder="例如: gpt-4">
</td>
<td style="padding: 12px;">
<input type="number" class="input" step="0.000001" min="0" placeholder="0.001">
</td>
<td style="padding: 12px;">
<input type="number" class="input" step="0.000001" min="0" placeholder="0.002">
</td>
<td style="padding: 12px; text-align: center;">
<button class="btn btn-danger btn-sm" onclick="window.removePricingRow(this)">🗑️ 删除</button>
</td>
`;
tbody.appendChild(row);
}
export function removePricingRow(button) {
const row = button.closest('tr');
if (row) {
row.remove();
}
}
function collectPricingTableData() {
const tbody = document.getElementById('pricingTableBody');
const config = {};
if (!tbody) return config;
Array.from(tbody.children).forEach((row) => {
const inputs = row.querySelectorAll('input');
if (inputs.length < 3) return;
const modelName = inputs[0].value.trim();
const inputPrice = parseFloat(inputs[1].value) || 0;
const outputPrice = parseFloat(inputs[2].value) || 0;
if (modelName) {
config[modelName] = {
input: inputPrice,
output: outputPrice
};
}
});
return config;
}
export function togglePricingEditMode() {
if (pricingEditMode === 'table') {
// Switch to JSON mode - collect table data and show in JSON
const config = collectPricingTableData();
document.getElementById('pricingConfigJson').value = JSON.stringify(config, null, 2);
document.getElementById('pricingTableMode').style.display = 'none';
document.getElementById('pricingJsonMode').style.display = 'block';
document.getElementById('pricingModeToggle').textContent = '📊 切换到表格模式';
const addBtn = document.getElementById('addPricingModelBtn');
if (addBtn) addBtn.style.display = 'none';
pricingEditMode = 'json';
} else {
// Switch to table mode - parse JSON and display table
try {
const jsonValue = document.getElementById('pricingConfigJson').value;
const config = jsonValue ? JSON.parse(jsonValue) : {};
displayPricingTable(config);
} catch (error) {
showNotification('JSON格式错误', 'error');
return;
}
document.getElementById('pricingTableMode').style.display = 'block';
document.getElementById('pricingJsonMode').style.display = 'none';
document.getElementById('pricingModeToggle').textContent = '📝 切换到JSON模式';
const addBtn = document.getElementById('addPricingModelBtn');
if (addBtn) addBtn.style.display = 'block';
pricingEditMode = 'table';
}
}
export async function savePricing(event) {
if (event) event.preventDefault();
const adminToken = localStorage.getItem('adminToken');
let config;
try {
if (pricingEditMode === 'table') {
// Validate table data
config = collectPricingTableData();
if (Object.keys(config).length === 0) {
showNotification('请至少添加一个模型定价', 'error');
return;
}
} else {
// Validate JSON
const configText = document.getElementById('pricingConfigJson').value;
if (!configText) {
showNotification('请填写定价配置', 'error');
return;
}
config = JSON.parse(configText);
}
// Save to API
showLoading('保存定价配置...');
const response = await fetch('/api/admin/pricing', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-admin-token': adminToken
},
body: JSON.stringify(config)
});
if (response.ok) {
showNotification('定价配置已保存', 'success');
loadPricing();
} else {
const error = await response.text();
showNotification('保存失败:' + error, 'error');
}
} catch (error) {
showNotification('保存失败:' + error.message, 'error');
} finally {
hideLoading();
}
}
-472
View File
@@ -1,472 +0,0 @@
// ===== Token Management Module =====
import { toggleModal, showNotification, showLoading, hideLoading } from './utils.js';
let tokenEditMode = 'form'; // 'form' or 'json'
let currentEditingKey = null;
let availableChannels = []; // Store available channels for select options
let selectedChannels = []; // Store selected channel keys
// Load available channels
async function loadAvailableChannels() {
const adminToken = localStorage.getItem('adminToken');
try {
const response = await fetch('/api/admin/channel', {
headers: { 'x-admin-token': adminToken }
});
if (response.ok) {
const result = await response.json();
const channels = result.data || [];
availableChannels = channels.map(c => c.key);
populateChannelOptions();
}
} catch (error) {
console.error('Failed to load channels:', error);
}
}
// Populate channel options in the dropdown
function populateChannelOptions() {
const container = document.getElementById('channelOptionsContainer');
if (!container) return;
if (availableChannels.length === 0) {
container.innerHTML = '<div class="multi-select-empty">暂无可用频道</div>';
} else {
container.innerHTML = availableChannels.map(key => `
<div class="multi-select-option">
<input type="checkbox" id="channel_${key}" value="${key}" ${selectedChannels.includes(key) ? 'checked' : ''} onchange="window.handleChannelCheckboxChange('${key}')">
<label for="channel_${key}">${key}</label>
</div>
`).join('');
}
updateChannelDisplay();
}
// Toggle channel dropdown
export function toggleChannelDropdown() {
const dropdown = document.getElementById('channelSelectDropdown');
const trigger = document.getElementById('channelSelectTrigger');
if (dropdown.classList.contains('hidden')) {
dropdown.classList.remove('hidden');
trigger.classList.add('open');
// Close dropdown when clicking outside
setTimeout(() => {
document.addEventListener('click', closeChannelDropdownOnClickOutside);
}, 0);
} else {
dropdown.classList.add('hidden');
trigger.classList.remove('open');
document.removeEventListener('click', closeChannelDropdownOnClickOutside);
}
}
function closeChannelDropdownOnClickOutside(event) {
const wrapper = document.querySelector('.multi-select-wrapper');
if (wrapper && !wrapper.contains(event.target)) {
const dropdown = document.getElementById('channelSelectDropdown');
const trigger = document.getElementById('channelSelectTrigger');
dropdown.classList.add('hidden');
trigger.classList.remove('open');
document.removeEventListener('click', closeChannelDropdownOnClickOutside);
}
}
// Handle checkbox change event
export function handleChannelCheckboxChange(channelKey) {
const checkbox = document.getElementById(`channel_${channelKey}`);
if (!checkbox) return;
if (checkbox.checked) {
// Add to selected if not already present
if (!selectedChannels.includes(channelKey)) {
selectedChannels.push(channelKey);
}
} else {
// Remove from selected
selectedChannels = selectedChannels.filter(k => k !== channelKey);
}
updateChannelDisplay();
}
// Toggle channel selection (deprecated, kept for compatibility)
export function toggleChannelSelection(channelKey, event) {
event.stopPropagation();
const checkbox = document.getElementById(`channel_${channelKey}`);
if (checkbox) {
checkbox.checked = !checkbox.checked;
}
if (selectedChannels.includes(channelKey)) {
selectedChannels = selectedChannels.filter(k => k !== channelKey);
} else {
selectedChannels.push(channelKey);
}
updateChannelDisplay();
}
// Update the display of selected channels
function updateChannelDisplay() {
const valueElement = document.getElementById('channelSelectValue');
if (!valueElement) return;
if (selectedChannels.length === 0) {
valueElement.textContent = '请选择频道...';
valueElement.classList.add('placeholder');
} else if (selectedChannels.length === 1) {
valueElement.textContent = selectedChannels[0];
valueElement.classList.remove('placeholder');
} else {
valueElement.textContent = `已选择 ${selectedChannels.length} 个频道`;
valueElement.classList.remove('placeholder');
}
}
// Filter channels based on search
export function filterChannels() {
const searchInput = document.getElementById('channelSearchInput');
const searchTerm = searchInput.value.toLowerCase();
const options = document.querySelectorAll('.multi-select-option');
options.forEach(option => {
const label = option.querySelector('label').textContent.toLowerCase();
if (label.includes(searchTerm)) {
option.style.display = '';
} else {
option.style.display = 'none';
}
});
}
// Load tokens from API
export async function loadTokens() {
const adminToken = localStorage.getItem('adminToken');
showLoading('加载令牌列表...');
try {
const response = await fetch('/api/admin/token', {
headers: { 'x-admin-token': adminToken }
});
if (response.ok) {
const result = await response.json();
const tokens = result.data || [];
displayTokens(tokens);
}
} catch (error) {
console.error('Failed to load tokens:', error);
showNotification('加载令牌失败', 'error');
} finally {
hideLoading();
}
}
// Display tokens in the list
function displayTokens(tokens) {
const container = document.getElementById('tokensList');
if (!container) return;
if (!tokens || tokens.length === 0) {
container.innerHTML = '<div style="text-align: center; padding: 3rem 1rem; color: hsl(var(--muted-foreground));">🎫 暂无令牌数据</div>';
return;
}
container.innerHTML = tokens.map(item => {
let parsedValue = {};
try {
parsedValue = typeof item.value === 'string' ? JSON.parse(item.value) : item.value;
} catch {
parsedValue = { name: 'Invalid JSON', channel_keys: [], total_quota: 0 };
}
const name = parsedValue.name || '未命名';
const channelKeys = parsedValue.channel_keys || [];
const channelDisplay = channelKeys.length === 0 ? '所有频道' : channelKeys.slice(0, 2).join(', ') + (channelKeys.length > 2 ? '...' : '');
const usage = item.usage || 0;
const totalQuota = parsedValue.total_quota || 0;
// 将配额转换为美元显示
const totalQuotaValue = totalQuota || 0;
const quotaInDollars = (totalQuotaValue / 1000000).toFixed(2);
const usageValue = usage || 0;
const usageInDollars = (usageValue / 1000000).toFixed(2);
return `
<div class="item">
<div class="item-info">
<div class="item-key">${name}</div>
<div class="item-details">
频道: ${channelDisplay} | 配额: $${quotaInDollars} | 已用: $${usageInDollars}
</div>
</div>
<div class="actions">
<button class="btn btn-secondary" onclick="window.copyToClipboard('${item.key}')" title="复制token">📋 复制</button>
<button class="btn btn-secondary" onclick="window.editToken('${item.key}')">✏️ 编辑</button>
<button class="btn btn-danger" onclick="window.deleteToken('${item.key}')">🗑️ 删除</button>
</div>
</div>
`;
}).join('');
}
// Show token list view
export function showTokenList() {
document.getElementById('tokenListView').style.display = 'block';
document.getElementById('tokenFormView').style.display = 'none';
loadTokens();
}
// Show token form for adding
export async function showTokenForm() {
currentEditingKey = null;
document.getElementById('tokenFormTitle').textContent = '添加令牌';
document.getElementById('tokenListView').style.display = 'none';
document.getElementById('tokenFormView').style.display = 'block';
clearTokenForm();
// Reset to form mode
tokenEditMode = 'form';
document.getElementById('tokenFormMode').style.display = 'block';
document.getElementById('tokenJsonMode').style.display = 'none';
document.getElementById('tokenModeToggle').textContent = '📝 切换到JSON模式';
// Token key should be editable when creating
document.getElementById('tokenKey').disabled = false;
document.getElementById('tokenKey').placeholder = '输入令牌标识或点击生成';
// Enable generate button for new tokens
const generateBtn = document.getElementById('generateTokenBtn');
if (generateBtn) {
generateBtn.disabled = false;
}
// Load available channels and auto-generate token key
await loadAvailableChannels();
generateTokenKey();
}
// Edit existing token
export async function editToken(key) {
currentEditingKey = key;
document.getElementById('tokenFormTitle').textContent = '编辑令牌';
document.getElementById('tokenListView').style.display = 'none';
document.getElementById('tokenFormView').style.display = 'block';
showLoading('加载令牌数据...');
try {
// Load available channels first
await loadAvailableChannels();
const adminToken = localStorage.getItem('adminToken');
const response = await fetch('/api/admin/token', {
headers: { 'x-admin-token': adminToken }
});
if (response.ok) {
const result = await response.json();
const tokens = result.data || [];
const token = tokens.find(t => t.key === key);
if (token) {
const config = typeof token.value === 'string' ? JSON.parse(token.value) : token.value;
fillTokenForm(key, config, token.usage || 0);
// Set token key as readonly when editing
document.getElementById('tokenKey').disabled = true;
// Disable generate button when editing
const generateBtn = document.getElementById('generateTokenBtn');
if (generateBtn) {
generateBtn.disabled = true;
}
}
}
} catch (error) {
showNotification('加载令牌数据失败', 'error');
} finally {
hideLoading();
}
}
// Fill form with token data
function fillTokenForm(key, config, usage) {
document.getElementById('tokenKey').value = key;
// Don't change disabled state here - it's set by editToken
document.getElementById('tokenName').value = config.name || '';
// Set selected channels
selectedChannels = config.channel_keys && Array.isArray(config.channel_keys) ? config.channel_keys : [];
populateChannelOptions();
document.getElementById('totalQuotaInput').value = config.total_quota || 0;
// Update JSON mode
document.getElementById('tokenValueJson').value = JSON.stringify(config, null, 2);
}
// Clear form
function clearTokenForm() {
document.getElementById('tokenKey').value = '';
document.getElementById('tokenKey').disabled = false;
document.getElementById('tokenName').value = '';
// Clear selected channels
selectedChannels = [];
updateChannelDisplay();
document.getElementById('totalQuotaInput').value = '';
document.getElementById('tokenValueJson').value = '';
}
// Set quota value (for quick buttons)
export function setQuotaValue(value) {
document.getElementById('totalQuotaInput').value = value;
}
// Generate random token key
export function generateTokenKey() {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let token = 'sk-';
for (let i = 0; i < 48; i++) {
token += chars.charAt(Math.floor(Math.random() * chars.length));
}
document.getElementById('tokenKey').value = token;
}
// Toggle between form and JSON mode
export function toggleTokenEditMode() {
if (tokenEditMode === 'form') {
// Switch to JSON mode - collect form data and show in JSON
const formData = collectTokenFormData();
if (formData) {
document.getElementById('tokenValueJson').value = JSON.stringify(formData, null, 2);
}
document.getElementById('tokenFormMode').style.display = 'none';
document.getElementById('tokenJsonMode').style.display = 'block';
document.getElementById('tokenModeToggle').textContent = '📋 切换到表单模式';
tokenEditMode = 'json';
} else {
// Switch to form mode - parse JSON and fill form
try {
const jsonValue = document.getElementById('tokenValueJson').value;
if (jsonValue) {
const config = JSON.parse(jsonValue);
document.getElementById('tokenName').value = config.name || '';
// Set selected channels
selectedChannels = config.channel_keys && Array.isArray(config.channel_keys) ? config.channel_keys : [];
populateChannelOptions();
document.getElementById('totalQuotaInput').value = config.total_quota || 0;
}
} catch (error) {
showNotification('JSON格式错误', 'error');
return;
}
document.getElementById('tokenFormMode').style.display = 'block';
document.getElementById('tokenJsonMode').style.display = 'none';
document.getElementById('tokenModeToggle').textContent = '📝 切换到JSON模式';
tokenEditMode = 'form';
}
}
// Collect form data
function collectTokenFormData() {
const name = document.getElementById('tokenName').value;
const totalQuota = parseFloat(document.getElementById('totalQuotaInput').value) || 0;
if (!name) {
return null;
}
return {
name,
channel_keys: selectedChannels,
total_quota: totalQuota
};
}
// Save token (add or update)
export async function saveToken() {
const key = document.getElementById('tokenKey').value || currentEditingKey;
if (!key) {
showNotification('请填写令牌标识', 'error');
return;
}
let config;
if (tokenEditMode === 'form') {
config = collectTokenFormData();
if (!config) {
showNotification('请填写所有必填字段(令牌名称)', 'error');
return;
}
} else {
const jsonValue = document.getElementById('tokenValueJson').value;
if (!jsonValue) {
showNotification('请填写配置JSON', 'error');
return;
}
try {
config = JSON.parse(jsonValue);
} catch {
showNotification('JSON格式错误,请检查配置格式', 'error');
return;
}
}
const adminToken = localStorage.getItem('adminToken');
showLoading('保存令牌...');
try {
const response = await fetch(`/api/admin/token/${encodeURIComponent(key)}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-admin-token': adminToken
},
body: JSON.stringify(config)
});
if (response.ok) {
showNotification(currentEditingKey ? '令牌更新成功' : '令牌添加成功', 'success');
clearTokenForm();
showTokenList();
} else {
const error = await response.text();
showNotification('保存失败:' + error, 'error');
}
} catch (error) {
showNotification('保存失败:' + error.message, 'error');
} finally {
hideLoading();
}
}
// Delete token
export async function deleteToken(key) {
if (!confirm(`确定要删除令牌 "${key}" 吗?`)) return;
const adminToken = localStorage.getItem('adminToken');
showLoading('删除令牌...');
try {
const response = await fetch(`/api/admin/token/${encodeURIComponent(key)}`, {
method: 'DELETE',
headers: { 'x-admin-token': adminToken }
});
if (response.ok) {
showNotification('令牌已删除', 'success');
loadTokens();
} else {
showNotification('删除失败', 'error');
}
} catch (error) {
showNotification('删除失败:' + error.message, 'error');
} finally {
hideLoading();
}
}
-180
View File
@@ -1,180 +0,0 @@
// ===== Utility Functions =====
// Modal Management
export function toggleModal(modalId) {
const modal = document.getElementById(modalId);
modal.classList.toggle('active');
}
// Tab Switching
export function switchTab(tabName) {
// Update active tab content
document.querySelectorAll('.tab-content').forEach(tab => {
tab.classList.remove('active');
});
document.getElementById(tabName).classList.add('active');
// Update active nav item
document.querySelectorAll('.nav-item').forEach(item => {
item.classList.remove('active');
});
const targetNav = document.querySelector(`[data-tab="${tabName}"]`);
if (targetNav) {
targetNav.classList.add('active');
}
// Update page title
const titles = {
'dashboard': '仪表盘',
'api-test': 'API 测试',
'channels': '频道管理',
'tokens': '令牌管理',
'pricing': '定价管理',
'database': '数据库管理'
};
document.getElementById('pageTitle').textContent = titles[tabName] || tabName;
// Load data for specific tabs if authenticated
const adminToken = localStorage.getItem('adminToken');
if (adminToken) {
// Reset to list view and reload data for management pages
if (tabName === 'channels') {
window.showChannelList && window.showChannelList();
}
if (tabName === 'tokens') {
window.showTokenList && window.showTokenList();
}
if (tabName === 'pricing') {
window.loadPricing && window.loadPricing();
}
}
}
// Theme Toggle
export function toggleTheme() {
const isDark = document.documentElement.classList.contains('dark');
if (isDark) {
document.documentElement.classList.remove('dark');
localStorage.setItem('theme', 'light');
document.getElementById('themeIcon').textContent = '🌙';
} else {
document.documentElement.classList.add('dark');
localStorage.setItem('theme', 'dark');
document.getElementById('themeIcon').textContent = '☀️';
}
}
// Notification System
export function showNotification(message, type = 'info') {
const className = type === 'success' ? 'alert-success' : (type === 'error' ? 'alert-error' : 'alert-info');
console.log(`[${type.toUpperCase()}] ${message}`);
// Create toast notification
const toast = document.createElement('div');
toast.className = `alert ${className}`;
toast.style.cssText = 'position: fixed; top: 20px; right: 20px; z-index: 100; min-width: 300px; animation: slideIn 0.3s ease;';
toast.innerHTML = `<strong>${type === 'success' ? '✅' : type === 'error' ? '❌' : 'ℹ️'}</strong> <span>${message}</span>`;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'fadeOut 0.3s ease';
setTimeout(() => toast.remove(), 300);
}, 3000);
}
// Copy to Clipboard
export function copyToClipboard(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(() => {
showNotification('已复制到剪贴板', 'success');
}).catch(err => {
console.error('复制失败:', err);
fallbackCopy(text);
});
} else {
fallbackCopy(text);
}
}
// Fallback copy method for older browsers
function fallbackCopy(text) {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.top = '0';
textArea.style.left = '0';
textArea.style.width = '2em';
textArea.style.height = '2em';
textArea.style.padding = '0';
textArea.style.border = 'none';
textArea.style.outline = 'none';
textArea.style.boxShadow = 'none';
textArea.style.background = 'transparent';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
const successful = document.execCommand('copy');
if (successful) {
showNotification('已复制到剪贴板', 'success');
} else {
showNotification('复制失败', 'error');
}
} catch (err) {
console.error('复制失败:', err);
showNotification('复制失败', 'error');
}
document.body.removeChild(textArea);
}
// Loading Overlay
let loadingTimeout = null;
export function showLoading(message = '加载中...') {
const overlay = document.getElementById('loadingOverlay');
const textEl = overlay.querySelector('.loading-text');
if (textEl) {
textEl.textContent = message;
}
// 延迟显示,避免快速请求闪烁
clearTimeout(loadingTimeout);
loadingTimeout = setTimeout(() => {
overlay.classList.add('active');
}, 100);
}
export function hideLoading() {
clearTimeout(loadingTimeout);
const overlay = document.getElementById('loadingOverlay');
overlay.classList.remove('active');
}
// Fetch wrapper with automatic loading
export async function fetchWithLoading(url, options = {}, loadingMessage = '加载中...') {
const adminToken = localStorage.getItem('adminToken');
// Add admin token to headers
const headers = {
...options.headers,
'x-admin-token': adminToken
};
// Show loading
showLoading(loadingMessage);
try {
const response = await fetch(url, {
...options,
headers
});
return response;
} finally {
hideLoading();
}
}