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:
1 parent
de24d420a4
commit
4572e5fc9e
60 files changed
+6649
-3173
No files matched your search
@@ -32,3 +32,4 @@ lerna-debug.log*
|
||||
# misc
|
||||
.DS_Store
|
||||
wrangler.toml
|
||||
public*/
|
||||
@@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=http://localhost:8787
|
||||
@@ -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?
|
||||
@@ -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.
|
||||
@@ -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 变量
|
||||
}],
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+2970
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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),
|
||||
})),
|
||||
}))
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')!)
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
配置格式:模型名称 -> {'{'}input: 输入倍率, output: 输出倍率{'}'}
|
||||
。倍率基于1百万token=$1.00计算
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }),
|
||||
}))
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 |
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
@@ -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: [],
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
@@ -7,11 +7,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"chanfana": "^2.8.3",
|
||||
"hono": "^4.10.8",
|
||||
"hono": "^4.11.1",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"wrangler": "^4.54.0"
|
||||
"wrangler": "^4.56.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
|
||||
}
|
||||
Generated
+43
-43
@@ -12,15 +12,15 @@ importers:
|
||||
specifier: ^2.8.3
|
||||
version: 2.8.3
|
||||
hono:
|
||||
specifier: ^4.10.8
|
||||
version: 4.10.8
|
||||
specifier: ^4.11.1
|
||||
version: 4.11.1
|
||||
zod:
|
||||
specifier: ^3.25.76
|
||||
version: 3.25.76
|
||||
devDependencies:
|
||||
wrangler:
|
||||
specifier: ^4.54.0
|
||||
version: 4.54.0
|
||||
specifier: ^4.56.0
|
||||
version: 4.56.0
|
||||
|
||||
packages:
|
||||
|
||||
@@ -42,32 +42,32 @@ packages:
|
||||
workerd:
|
||||
optional: true
|
||||
|
||||
'@cloudflare/workerd-darwin-64@1.20251210.0':
|
||||
resolution: {integrity: sha512-Nn9X1moUDERA9xtFdCQ2XpQXgAS9pOjiCxvOT8sVx9UJLAiBLkfSCGbpsYdarODGybXCpjRlc77Yppuolvt7oQ==}
|
||||
'@cloudflare/workerd-darwin-64@1.20251217.0':
|
||||
resolution: {integrity: sha512-DN6vT+9ho61d/1/YuILW4VS+N1JBLaixWRL1vqNmhgbf8J8VHwWWotrRruEUYigJKx2yZyw6YsasE+yLXgx/Fw==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@cloudflare/workerd-darwin-arm64@1.20251210.0':
|
||||
resolution: {integrity: sha512-Mg8iYIZQFnbevq/ls9eW/eneWTk/EE13Pej1MwfkY5et0jVpdHnvOLywy/o+QtMJFef1AjsqXGULwAneYyBfHw==}
|
||||
'@cloudflare/workerd-darwin-arm64@1.20251217.0':
|
||||
resolution: {integrity: sha512-5nZOpRTkHmtcTc4Wbr1mj/O3dLb6aHZSiJuVBgtdbVcVmOXueSay3hnw1PXEyR+vpTKGUPkM+omUIslKHWnXDw==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@cloudflare/workerd-linux-64@1.20251210.0':
|
||||
resolution: {integrity: sha512-kjC2fCZhZ2Gkm1biwk2qByAYpGguK5Gf5ic8owzSCUw0FOUfQxTZUT9Lp3gApxsfTLbbnLBrX/xzWjywH9QR4g==}
|
||||
'@cloudflare/workerd-linux-64@1.20251217.0':
|
||||
resolution: {integrity: sha512-uoPGhMaZVXPpCsU0oG3HQzyVpXCGi5rU+jcHRjUI7DXM4EwctBGvZ380Knkja36qtl+ZvSKVR1pUFSGdK+45Pg==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@cloudflare/workerd-linux-arm64@1.20251210.0':
|
||||
resolution: {integrity: sha512-2IB37nXi7PZVQLa1OCuO7/6pNxqisRSO8DmCQ5x/3sezI5op1vwOxAcb1osAnuVsVN9bbvpw70HJvhKruFJTuA==}
|
||||
'@cloudflare/workerd-linux-arm64@1.20251217.0':
|
||||
resolution: {integrity: sha512-ixHnHKsiz1Xko+eDgCJOZ7EEUZKtmnYq3AjW3nkVcLFypSLks4C29E45zVewdaN4wq8sCLeyQCl6r1kS17+DQQ==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@cloudflare/workerd-windows-64@1.20251210.0':
|
||||
resolution: {integrity: sha512-Uaz6/9XE+D6E7pCY4OvkCuJHu7HcSDzeGcCGY1HLhojXhHd7yL52c3yfiyJdS8hPatiAa0nn5qSI/42+aTdDSw==}
|
||||
'@cloudflare/workerd-windows-64@1.20251217.0':
|
||||
resolution: {integrity: sha512-rP6USX+7ctynz3AtmKi+EvlLP3Xdr1ETrSdcnv693/I5QdUwBxq4yE1Lj6CV7GJizX6opXKYg8QMq0Q4eB9zRQ==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@@ -427,8 +427,8 @@ packages:
|
||||
glob-to-regexp@0.4.1:
|
||||
resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==}
|
||||
|
||||
hono@4.10.8:
|
||||
resolution: {integrity: sha512-DDT0A0r6wzhe8zCGoYOmMeuGu3dyTAE40HHjwUsWFTEy5WxK1x2WDSsBPlEXgPbRIFY6miDualuUDbasPogIww==}
|
||||
hono@4.11.1:
|
||||
resolution: {integrity: sha512-KsFcH0xxHes0J4zaQgWbYwmz3UPOOskdqZmItstUG93+Wk1ePBLkLGwbP9zlmh1BFUiL8Qp+Xfu9P7feJWpGNg==}
|
||||
engines: {node: '>=16.9.0'}
|
||||
|
||||
is-arrayish@0.3.4:
|
||||
@@ -447,8 +447,8 @@ packages:
|
||||
engines: {node: '>=10.0.0'}
|
||||
hasBin: true
|
||||
|
||||
miniflare@4.20251210.0:
|
||||
resolution: {integrity: sha512-k6kIoXwGVqlPZb0hcn+X7BmnK+8BjIIkusQPY22kCo2RaQJ/LzAjtxHQdGXerlHSnJyQivDQsL6BJHMpQfUFyw==}
|
||||
miniflare@4.20251217.0:
|
||||
resolution: {integrity: sha512-8xsTQbPS6YV+ABZl9qiJIbsum6hbpbhqiyKpOVdzZrhK+1N8EFpT8R6aBZff7kezGmxYZSntjgjqTwJmj3JLgA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -491,17 +491,17 @@ packages:
|
||||
unenv@2.0.0-rc.24:
|
||||
resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==}
|
||||
|
||||
workerd@1.20251210.0:
|
||||
resolution: {integrity: sha512-9MUUneP1BnRE9XAYi94FXxHmiLGbO75EHQZsgWqSiOXjoXSqJCw8aQbIEPxCy19TclEl/kHUFYce8ST2W+Qpjw==}
|
||||
workerd@1.20251217.0:
|
||||
resolution: {integrity: sha512-s3mHDSWwHTduyY8kpHOsl27ZJ4ziDBJlc18PfBvNMqNnhO7yBeemlxH7bo7yQyU1foJrIZ6IENHDDg0Z9N8zQA==}
|
||||
engines: {node: '>=16'}
|
||||
hasBin: true
|
||||
|
||||
wrangler@4.54.0:
|
||||
resolution: {integrity: sha512-bANFsjDwJLbprYoBK+hUDZsVbUv2SqJd8QvArLIcZk+fPq4h/Ohtj5vkKXD3k0s2bD1DXLk08D+hYmeNH+xC6A==}
|
||||
wrangler@4.56.0:
|
||||
resolution: {integrity: sha512-Nqi8duQeRbA+31QrD6QlWHW3IZVnuuRxMy7DEg46deUzywivmaRV/euBN5KKXDPtA24VyhYsK7I0tkb7P5DM2w==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@cloudflare/workers-types': ^4.20251210.0
|
||||
'@cloudflare/workers-types': ^4.20251217.0
|
||||
peerDependenciesMeta:
|
||||
'@cloudflare/workers-types':
|
||||
optional: true
|
||||
@@ -550,25 +550,25 @@ snapshots:
|
||||
dependencies:
|
||||
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:
|
||||
unenv: 2.0.0-rc.24
|
||||
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
|
||||
|
||||
'@cloudflare/workerd-darwin-arm64@1.20251210.0':
|
||||
'@cloudflare/workerd-darwin-arm64@1.20251217.0':
|
||||
optional: true
|
||||
|
||||
'@cloudflare/workerd-linux-64@1.20251210.0':
|
||||
'@cloudflare/workerd-linux-64@1.20251217.0':
|
||||
optional: true
|
||||
|
||||
'@cloudflare/workerd-linux-arm64@1.20251210.0':
|
||||
'@cloudflare/workerd-linux-arm64@1.20251217.0':
|
||||
optional: true
|
||||
|
||||
'@cloudflare/workerd-windows-64@1.20251210.0':
|
||||
'@cloudflare/workerd-windows-64@1.20251217.0':
|
||||
optional: true
|
||||
|
||||
'@cspotcode/source-map-support@0.8.1':
|
||||
@@ -832,7 +832,7 @@ snapshots:
|
||||
|
||||
glob-to-regexp@0.4.1: {}
|
||||
|
||||
hono@4.10.8: {}
|
||||
hono@4.11.1: {}
|
||||
|
||||
is-arrayish@0.3.4: {}
|
||||
|
||||
@@ -844,7 +844,7 @@ snapshots:
|
||||
|
||||
mime@3.0.0: {}
|
||||
|
||||
miniflare@4.20251210.0:
|
||||
miniflare@4.20251217.0:
|
||||
dependencies:
|
||||
'@cspotcode/source-map-support': 0.8.1
|
||||
acorn: 8.14.0
|
||||
@@ -854,7 +854,7 @@ snapshots:
|
||||
sharp: 0.33.5
|
||||
stoppable: 1.1.0
|
||||
undici: 7.14.0
|
||||
workerd: 1.20251210.0
|
||||
workerd: 1.20251217.0
|
||||
ws: 8.18.0
|
||||
youch: 4.1.0-beta.10
|
||||
zod: 3.22.3
|
||||
@@ -915,24 +915,24 @@ snapshots:
|
||||
dependencies:
|
||||
pathe: 2.0.3
|
||||
|
||||
workerd@1.20251210.0:
|
||||
workerd@1.20251217.0:
|
||||
optionalDependencies:
|
||||
'@cloudflare/workerd-darwin-64': 1.20251210.0
|
||||
'@cloudflare/workerd-darwin-arm64': 1.20251210.0
|
||||
'@cloudflare/workerd-linux-64': 1.20251210.0
|
||||
'@cloudflare/workerd-linux-arm64': 1.20251210.0
|
||||
'@cloudflare/workerd-windows-64': 1.20251210.0
|
||||
'@cloudflare/workerd-darwin-64': 1.20251217.0
|
||||
'@cloudflare/workerd-darwin-arm64': 1.20251217.0
|
||||
'@cloudflare/workerd-linux-64': 1.20251217.0
|
||||
'@cloudflare/workerd-linux-arm64': 1.20251217.0
|
||||
'@cloudflare/workerd-windows-64': 1.20251217.0
|
||||
|
||||
wrangler@4.54.0:
|
||||
wrangler@4.56.0:
|
||||
dependencies:
|
||||
'@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
|
||||
esbuild: 0.27.0
|
||||
miniflare: 4.20251210.0
|
||||
miniflare: 4.20251217.0
|
||||
path-to-regexp: 6.3.0
|
||||
unenv: 2.0.0-rc.24
|
||||
workerd: 1.20251210.0
|
||||
workerd: 1.20251217.0
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
transitivePeerDependencies:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user