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
@@ -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,
|
||||
},
|
||||
})
|
||||
Reference in new issue
Block a user