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

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

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

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

No files matched your search

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

After

Width:  |  Height:  |  Size: 1.4 KiB

+9
View File
@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
+76
View File
@@ -0,0 +1,76 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ["class"],
content: [
'./pages/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
'./app/**/*.{ts,tsx}',
'./src/**/*.{ts,tsx}',
],
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [],
}
+54
View File
@@ -0,0 +1,54 @@
import type { Config } from "tailwindcss";
export default {
darkMode: ["class"],
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
colors: {
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
},
},
},
plugins: [],
} satisfies Config;
+31
View File
@@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
/* Path mapping */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}
+13
View File
@@ -0,0 +1,13 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
build: {
outDir: '../public',
emptyOutDir: true,
},
})