mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-04 16:35:27 +08:00
refactor: migrate UI to Tailwind CSS for a modernized design system.
This commit is contained in:
@@ -1,12 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" rel="stylesheet">
|
||||
<title>DS2API Admin</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
</html>
|
||||
@@ -9,11 +9,17 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.563.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
"react-dom": "^18.2.0",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
6
webui/postcss.config.js
Normal file
6
webui/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -1,15 +1,28 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Key,
|
||||
Upload,
|
||||
Cloud,
|
||||
LogOut,
|
||||
Menu,
|
||||
X,
|
||||
Server,
|
||||
Users
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import AccountManager from './components/AccountManager'
|
||||
import ApiTester from './components/ApiTester'
|
||||
import BatchImport from './components/BatchImport'
|
||||
import VercelSync from './components/VercelSync'
|
||||
import Login from './components/Login'
|
||||
|
||||
const TABS = [
|
||||
{ id: 'accounts', label: '🔑 账号管理' },
|
||||
{ id: 'test', label: '🧪 API 测试' },
|
||||
{ id: 'import', label: '📦 批量导入' },
|
||||
{ id: 'vercel', label: '☁️ Vercel 同步' },
|
||||
const NAV_ITEMS = [
|
||||
{ id: 'accounts', label: '账号管理', icon: Users, description: '管理 DeepSeek 账号池' },
|
||||
{ id: 'test', label: 'API 测试', icon: Server, description: '测试 API 连接与响应' },
|
||||
{ id: 'import', label: '批量导入', icon: Upload, description: '批量导入账号配置' },
|
||||
{ id: 'vercel', label: 'Vercel 同步', icon: Cloud, description: '同步配置到 Vercel' },
|
||||
]
|
||||
|
||||
export default function App() {
|
||||
@@ -19,16 +32,15 @@ export default function App() {
|
||||
const [message, setMessage] = useState(null)
|
||||
const [token, setToken] = useState(null)
|
||||
const [authChecking, setAuthChecking] = useState(true)
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
|
||||
// 检查已存储的 Token
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
// 检查 localStorage 或 sessionStorage
|
||||
const storedToken = localStorage.getItem('ds2api_token') || sessionStorage.getItem('ds2api_token')
|
||||
const expiresAt = parseInt(localStorage.getItem('ds2api_token_expires') || sessionStorage.getItem('ds2api_token_expires') || '0')
|
||||
|
||||
if (storedToken && expiresAt > Date.now()) {
|
||||
// 验证 token 是否有效
|
||||
try {
|
||||
const res = await fetch('/admin/verify', {
|
||||
headers: { 'Authorization': `Bearer ${storedToken}` }
|
||||
@@ -36,14 +48,9 @@ export default function App() {
|
||||
if (res.ok) {
|
||||
setToken(storedToken)
|
||||
} else {
|
||||
// Token 无效,清除
|
||||
localStorage.removeItem('ds2api_token')
|
||||
localStorage.removeItem('ds2api_token_expires')
|
||||
sessionStorage.removeItem('ds2api_token')
|
||||
sessionStorage.removeItem('ds2api_token_expires')
|
||||
handleLogout()
|
||||
}
|
||||
} catch {
|
||||
// 网络错误,保留 token 重试
|
||||
setToken(storedToken)
|
||||
}
|
||||
}
|
||||
@@ -52,7 +59,6 @@ export default function App() {
|
||||
checkAuth()
|
||||
}, [])
|
||||
|
||||
// 带认证的 fetch
|
||||
const authFetch = async (url, options = {}) => {
|
||||
const headers = {
|
||||
...options.headers,
|
||||
@@ -60,7 +66,6 @@ export default function App() {
|
||||
}
|
||||
const res = await fetch(url, { ...options, headers })
|
||||
|
||||
// 401 时自动登出
|
||||
if (res.status === 401) {
|
||||
handleLogout()
|
||||
throw new Error('认证已过期,请重新登录')
|
||||
@@ -123,27 +128,32 @@ export default function App() {
|
||||
}
|
||||
}
|
||||
|
||||
// 认证检查中
|
||||
if (authChecking) {
|
||||
return (
|
||||
<div className="app">
|
||||
<div className="login-container">
|
||||
<div className="login-card">
|
||||
<div className="empty-state">
|
||||
<span className="loading"></span> 检查登录状态...
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin"></div>
|
||||
<p className="text-muted-foreground animate-pulse">检查登录状态...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 未登录
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="app">
|
||||
<div className="min-h-screen flex flex-col bg-background relative overflow-hidden">
|
||||
{/* Background decorative elements */}
|
||||
<div className="absolute top-0 left-0 w-full h-full overflow-hidden pointer-events-none z-0">
|
||||
<div className="absolute top-[-10%] right-[-10%] w-[50%] h-[50%] bg-primary/5 rounded-full blur-[120px]"></div>
|
||||
<div className="absolute bottom-[-10%] left-[-10%] w-[50%] h-[50%] bg-accent/5 rounded-full blur-[120px]"></div>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`alert alert-${message.type}`}>
|
||||
<div className={clsx(
|
||||
"fixed top-4 right-4 z-50 px-4 py-3 rounded-lg shadow-lg border animate-in slide-in-from-top-2 fade-in",
|
||||
message.type === 'error' ? "bg-destructive/10 border-destructive/20 text-destructive" :
|
||||
"bg-primary/10 border-primary/20 text-primary"
|
||||
)}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
@@ -152,60 +162,133 @@ export default function App() {
|
||||
)
|
||||
}
|
||||
|
||||
// 已登录
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="header">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<h1>DS2API Admin</h1>
|
||||
<p>账号管理 · API 测试 · Vercel 部署</p>
|
||||
</div>
|
||||
<button className="btn btn-secondary btn-sm" onClick={handleLogout}>
|
||||
🚪 登出
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{message && (
|
||||
<div className={`alert alert-${message.type}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
<div className="flex h-screen bg-background overflow-hidden text-foreground">
|
||||
{/* Mobile Sidebar Overlay */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-background/80 backdrop-blur-sm z-40 lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="stats">
|
||||
<div className="stat">
|
||||
<div className="stat-value">{config.keys?.length || 0}</div>
|
||||
<div className="stat-label">API Keys</div>
|
||||
{/* Sidebar */}
|
||||
<aside className={clsx(
|
||||
"fixed lg:static inset-y-0 left-0 z-50 w-64 bg-card border-r border-border transition-transform duration-200 ease-in-out lg:transform-none flex flex-col",
|
||||
sidebarOpen ? "translate-x-0" : "-translate-x-full"
|
||||
)}>
|
||||
<div className="p-6 border-b border-border">
|
||||
<div className="flex items-center gap-2 font-bold text-xl text-primary">
|
||||
<LayoutDashboard className="w-6 h-6" />
|
||||
<span>DS2API Admin</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1 ml-8">V1.0.0 Control Panel</p>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="stat-value">{config.accounts?.length || 0}</div>
|
||||
<div className="stat-label">账号</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tabs">
|
||||
{TABS.map(tab => (
|
||||
<nav className="flex-1 p-4 space-y-1 overflow-y-auto">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const Icon = item.icon
|
||||
const isActive = activeTab === item.id
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
setActiveTab(item.id)
|
||||
setSidebarOpen(false)
|
||||
}}
|
||||
className={clsx(
|
||||
"w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 group",
|
||||
isActive
|
||||
? "bg-primary text-primary-foreground shadow-md shadow-primary/20"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<Icon className={clsx("w-4 h-4", isActive ? "text-primary-foreground" : "text-muted-foreground group-hover:text-accent-foreground")} />
|
||||
{item.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="p-4 border-t border-border bg-card/50">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">API Status</span>
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium text-green-500 bg-green-500/10 px-2 py-0.5 rounded-full">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse"></span>
|
||||
Online
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="bg-background rounded p-2 border border-border">
|
||||
<div className="text-xs text-muted-foreground">Accounts</div>
|
||||
<div className="text-lg font-bold">{config.accounts?.length || 0}</div>
|
||||
</div>
|
||||
<div className="bg-background rounded p-2 border border-border">
|
||||
<div className="text-xs text-muted-foreground">Api Keys</div>
|
||||
<div className="text-lg font-bold">{config.keys?.length || 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-md border border-border text-sm font-medium text-muted-foreground hover:bg-destructive/10 hover:text-destructive hover:border-destructive/20 transition-colors"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 flex flex-col min-w-0 overflow-hidden relative">
|
||||
{/* Mobile Header */}
|
||||
<header className="lg:hidden flex items-center justify-between p-4 border-b border-border bg-card">
|
||||
<span className="font-semibold">{NAV_ITEMS.find(n => n.id === activeTab)?.label}</span>
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`tab ${activeTab === tab.id ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
className="p-2 -mr-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{tab.label}
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{loading ? (
|
||||
<div className="card">
|
||||
<div className="empty-state">
|
||||
<span className="loading"></span> 加载中...
|
||||
{/* Content Area */}
|
||||
<div className="flex-1 overflow-auto bg-background/50 p-4 lg:p-8">
|
||||
<div className="max-w-6xl mx-auto space-y-6">
|
||||
<div className="hidden lg:block mb-8">
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">
|
||||
{NAV_ITEMS.find(n => n.id === activeTab)?.label}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{NAV_ITEMS.find(n => n.id === activeTab)?.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={clsx(
|
||||
"p-4 rounded-lg border flex items-center gap-3 animate-in fade-in slide-in-from-top-2",
|
||||
message.type === 'error' ? "bg-destructive/10 border-destructive/20 text-destructive" :
|
||||
"bg-emerald-500/10 border-emerald-500/20 text-emerald-500"
|
||||
)}>
|
||||
{message.type === 'error' ? <X className="w-5 h-5" /> : <div className="w-5 h-5 rounded-full border-2 border-emerald-500 flex items-center justify-center text-[10px]">✓</div>}
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="animate-in fade-in duration-500">
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin mb-4"></div>
|
||||
<p>Please wait while loading data...</p>
|
||||
</div>
|
||||
) : (
|
||||
renderTab()
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
renderTab()
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Plus,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Search,
|
||||
Play,
|
||||
MoreHorizontal,
|
||||
X,
|
||||
Server,
|
||||
ShieldCheck
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export default function AccountManager({ config, onRefresh, onMessage, authFetch }) {
|
||||
const [showAddKey, setShowAddKey] = useState(false)
|
||||
@@ -6,17 +20,15 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch
|
||||
const [newKey, setNewKey] = useState('')
|
||||
const [newAccount, setNewAccount] = useState({ email: '', mobile: '', password: '' })
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [validating, setValidating] = useState({}) // 单个账号验证状态
|
||||
const [validating, setValidating] = useState({})
|
||||
const [validatingAll, setValidatingAll] = useState(false)
|
||||
const [testing, setTesting] = useState({}) // 单个账号测试状态
|
||||
const [testing, setTesting] = useState({})
|
||||
const [testingAll, setTestingAll] = useState(false)
|
||||
const [batchProgress, setBatchProgress] = useState({ current: 0, total: 0, results: [] })
|
||||
const [queueStatus, setQueueStatus] = useState(null)
|
||||
|
||||
// 使用 authFetch 或回退到普通 fetch
|
||||
const apiFetch = authFetch || fetch
|
||||
|
||||
// 获取队列状态
|
||||
const fetchQueueStatus = async () => {
|
||||
try {
|
||||
const res = await apiFetch('/admin/queue/status')
|
||||
@@ -25,13 +37,13 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch
|
||||
setQueueStatus(data)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取队列状态失败:', e)
|
||||
console.error('Failed to fetch queue status:', e)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchQueueStatus()
|
||||
const interval = setInterval(fetchQueueStatus, 5000) // 每5秒刷新
|
||||
const interval = setInterval(fetchQueueStatus, 5000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
@@ -45,39 +57,39 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch
|
||||
body: JSON.stringify({ key: newKey.trim() }),
|
||||
})
|
||||
if (res.ok) {
|
||||
onMessage('success', 'API Key 添加成功')
|
||||
onMessage('success', 'API Key added successfully')
|
||||
setNewKey('')
|
||||
setShowAddKey(false)
|
||||
onRefresh()
|
||||
} else {
|
||||
const data = await res.json()
|
||||
onMessage('error', data.detail || '添加失败')
|
||||
onMessage('error', data.detail || 'Failed to add')
|
||||
}
|
||||
} catch (e) {
|
||||
onMessage('error', '网络错误')
|
||||
onMessage('error', 'Network error')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteKey = async (key) => {
|
||||
if (!confirm('确定删除此 API Key?')) return
|
||||
if (!confirm('Are you sure you want to delete this API Key?')) return
|
||||
try {
|
||||
const res = await apiFetch(`/admin/keys/${encodeURIComponent(key)}`, { method: 'DELETE' })
|
||||
if (res.ok) {
|
||||
onMessage('success', '删除成功')
|
||||
onMessage('success', 'Deleted successfully')
|
||||
onRefresh()
|
||||
} else {
|
||||
onMessage('error', '删除失败')
|
||||
onMessage('error', 'Delete failed')
|
||||
}
|
||||
} catch (e) {
|
||||
onMessage('error', '网络错误')
|
||||
onMessage('error', 'Network error')
|
||||
}
|
||||
}
|
||||
|
||||
const addAccount = async () => {
|
||||
if (!newAccount.password || (!newAccount.email && !newAccount.mobile)) {
|
||||
onMessage('error', '请填写密码和邮箱/手机号')
|
||||
onMessage('error', 'Password and Email/Mobile are required')
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
@@ -88,37 +100,36 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch
|
||||
body: JSON.stringify(newAccount),
|
||||
})
|
||||
if (res.ok) {
|
||||
onMessage('success', '账号添加成功')
|
||||
onMessage('success', 'Account added successfully')
|
||||
setNewAccount({ email: '', mobile: '', password: '' })
|
||||
setShowAddAccount(false)
|
||||
onRefresh()
|
||||
} else {
|
||||
const data = await res.json()
|
||||
onMessage('error', data.detail || '添加失败')
|
||||
onMessage('error', data.detail || 'Failed to add')
|
||||
}
|
||||
} catch (e) {
|
||||
onMessage('error', '网络错误')
|
||||
onMessage('error', 'Network error')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteAccount = async (id) => {
|
||||
if (!confirm('确定删除此账号?')) return
|
||||
if (!confirm('Are you sure you want to delete this account?')) return
|
||||
try {
|
||||
const res = await apiFetch(`/admin/accounts/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
||||
if (res.ok) {
|
||||
onMessage('success', '删除成功')
|
||||
onMessage('success', 'Deleted successfully')
|
||||
onRefresh()
|
||||
} else {
|
||||
onMessage('error', '删除失败')
|
||||
onMessage('error', 'Delete failed')
|
||||
}
|
||||
} catch (e) {
|
||||
onMessage('error', '网络错误')
|
||||
onMessage('error', 'Network error')
|
||||
}
|
||||
}
|
||||
|
||||
// 验证单个账号
|
||||
const validateAccount = async (identifier) => {
|
||||
setValidating(prev => ({ ...prev, [identifier]: true }))
|
||||
try {
|
||||
@@ -128,22 +139,17 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch
|
||||
body: JSON.stringify({ identifier }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.valid) {
|
||||
onMessage('success', `${identifier}: ${data.message}`)
|
||||
} else {
|
||||
onMessage('error', `${identifier}: ${data.message}`)
|
||||
}
|
||||
onMessage(data.valid ? 'success' : 'error', `${identifier}: ${data.message}`)
|
||||
onRefresh()
|
||||
} catch (e) {
|
||||
onMessage('error', '验证失败: ' + e.message)
|
||||
onMessage('error', 'Validation failed: ' + e.message)
|
||||
} finally {
|
||||
setValidating(prev => ({ ...prev, [identifier]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
// 批量验证所有账号(带进度)
|
||||
const validateAllAccounts = async () => {
|
||||
if (!confirm('确定要验证所有账号?')) return
|
||||
if (!confirm('Validate ALL accounts? This might take a while.')) return
|
||||
const accounts = config.accounts || []
|
||||
if (accounts.length === 0) return
|
||||
|
||||
@@ -173,12 +179,11 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch
|
||||
setBatchProgress({ current: i + 1, total: accounts.length, results: [...results] })
|
||||
}
|
||||
|
||||
onMessage('success', `验证完成: ${validCount}/${accounts.length} 个账号有效`)
|
||||
onMessage('success', `Completed: ${validCount}/${accounts.length} valid`)
|
||||
onRefresh()
|
||||
setValidatingAll(false)
|
||||
}
|
||||
|
||||
// 测试单个账号 API
|
||||
const testAccount = async (identifier) => {
|
||||
setTesting(prev => ({ ...prev, [identifier]: true }))
|
||||
try {
|
||||
@@ -188,22 +193,17 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch
|
||||
body: JSON.stringify({ identifier }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
onMessage('success', `${identifier}: API 测试成功 (${data.response_time}ms)`)
|
||||
} else {
|
||||
onMessage('error', `${identifier}: ${data.message}`)
|
||||
}
|
||||
onMessage(data.success ? 'success' : 'error', `${identifier}: ${data.success ? `Success (${data.response_time}ms)` : data.message}`)
|
||||
onRefresh()
|
||||
} catch (e) {
|
||||
onMessage('error', 'API 测试失败: ' + e.message)
|
||||
onMessage('error', 'Test failed: ' + e.message)
|
||||
} finally {
|
||||
setTesting(prev => ({ ...prev, [identifier]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
// 批量测试所有账号 API(带进度)
|
||||
const testAllAccounts = async () => {
|
||||
if (!confirm('确定要测试所有账号的 API?')) return
|
||||
if (!confirm('Test API connectivity for ALL accounts?')) return
|
||||
const accounts = config.accounts || []
|
||||
if (accounts.length === 0) return
|
||||
|
||||
@@ -233,100 +233,144 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch
|
||||
setBatchProgress({ current: i + 1, total: accounts.length, results: [...results] })
|
||||
}
|
||||
|
||||
onMessage('success', `API 测试完成: ${successCount}/${accounts.length} 个账号可用`)
|
||||
onMessage('success', `Completed: ${successCount}/${accounts.length} available`)
|
||||
onRefresh()
|
||||
setTestingAll(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="section">
|
||||
{/* 队列状态监控 */}
|
||||
<div className="space-y-6">
|
||||
{/* Queue Status */}
|
||||
{queueStatus && (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<span className="card-title">📊 轮询队列状态</span>
|
||||
<button className="btn btn-secondary" onClick={fetchQueueStatus}>刷新</button>
|
||||
</div>
|
||||
<div className="queue-status">
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">可用账号:</span>
|
||||
<span className="stat-value stat-success">{queueStatus.available}</span>
|
||||
<span className="stat-label" style={{ marginLeft: '20px' }}>使用中:</span>
|
||||
<span className="stat-value stat-warning">{queueStatus.in_use}</span>
|
||||
<span className="stat-label" style={{ marginLeft: '20px' }}>总计:</span>
|
||||
<span className="stat-value">{queueStatus.total}</span>
|
||||
</div>
|
||||
{queueStatus.in_use > 0 && (
|
||||
<div className="stat-detail">
|
||||
正在使用: {queueStatus.in_use_accounts.join(', ')}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-card border border-border rounded-xl p-4 flex items-center justify-between shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-emerald-500/10 text-emerald-500 rounded-lg">
|
||||
<CheckCircle2 className="w-5 h-5" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Available</p>
|
||||
<p className="text-2xl font-bold">{queueStatus.available}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-card border border-border rounded-xl p-4 flex items-center justify-between shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-amber-500/10 text-amber-500 rounded-lg">
|
||||
<Server className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">In Use</p>
|
||||
<p className="text-2xl font-bold">{queueStatus.in_use}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-card border border-border rounded-xl p-4 flex items-center justify-between shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-primary/10 text-primary rounded-lg">
|
||||
<ShieldCheck className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Total Accounts</p>
|
||||
<p className="text-2xl font-bold">{queueStatus.total}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Keys */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<span className="card-title">🔑 API Keys</span>
|
||||
<button className="btn btn-primary" onClick={() => setShowAddKey(true)}>+ 添加</button>
|
||||
{/* API Keys Section */}
|
||||
<div className="bg-card border border-border rounded-xl overflow-hidden shadow-sm">
|
||||
<div className="p-6 border-b border-border flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">API Keys</h2>
|
||||
<p className="text-sm text-muted-foreground">Manage access keys for the API</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAddKey(true)}
|
||||
className="btn btn-primary flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
New Key
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{config.keys?.length > 0 ? (
|
||||
<div className="list">
|
||||
{config.keys.map((key, i) => (
|
||||
<div key={i} className="list-item">
|
||||
<span className="list-item-text">{key.slice(0, 16)}****</span>
|
||||
<button className="btn btn-danger" onClick={() => deleteKey(key)}>删除</button>
|
||||
<div className="divide-y divide-border">
|
||||
{config.keys?.length > 0 ? (
|
||||
config.keys.map((key, i) => (
|
||||
<div key={i} className="p-4 flex items-center justify-between hover:bg-muted/50 transition-colors group">
|
||||
<div className="font-mono text-sm bg-muted/50 px-3 py-1 rounded inline-block">
|
||||
{key.slice(0, 16)}****
|
||||
</div>
|
||||
<button
|
||||
onClick={() => deleteKey(key)}
|
||||
className="p-2 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-md transition-colors opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-state">暂无 API Key</div>
|
||||
)}
|
||||
))
|
||||
) : (
|
||||
<div className="p-8 text-center text-muted-foreground">No API keys found</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Accounts */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<span className="card-title">👤 DeepSeek 账号</span>
|
||||
<div className="btn-group-inline">
|
||||
{/* Accounts Section */}
|
||||
<div className="bg-card border border-border rounded-xl overflow-hidden shadow-sm">
|
||||
<div className="p-6 border-b border-border flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">DeepSeek Accounts</h2>
|
||||
<p className="text-sm text-muted-foreground">Manage your account pool</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={testAllAccounts}
|
||||
disabled={testingAll || validatingAll || !config.accounts?.length}
|
||||
className="btn btn-secondary text-xs"
|
||||
>
|
||||
{testingAll ? <span className="loading"></span> : '🧪 批量测试'}
|
||||
{testingAll ? <span className="animate-spin mr-2">⟳</span> : <Play className="w-3 h-3 mr-2" />}
|
||||
Test All
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={validateAllAccounts}
|
||||
disabled={validatingAll || testingAll || !config.accounts?.length}
|
||||
className="btn btn-secondary text-xs"
|
||||
>
|
||||
{validatingAll ? <span className="loading"></span> : '✅ 批量验证'}
|
||||
{validatingAll ? <span className="animate-spin mr-2">⟳</span> : <CheckCircle2 className="w-3 h-3 mr-2" />}
|
||||
Validate All
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowAddAccount(true)}
|
||||
className="btn btn-primary flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Account
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={() => setShowAddAccount(true)}>+ 添加</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 批量操作进度条 */}
|
||||
{/* Batch Progress */}
|
||||
{(testingAll || validatingAll) && batchProgress.total > 0 && (
|
||||
<div className="batch-progress">
|
||||
<div className="progress-header">
|
||||
<span>{testingAll ? '🧪 批量测试中...' : '✅ 批量验证中...'}</span>
|
||||
<span>{batchProgress.current}/{batchProgress.total}</span>
|
||||
<div className="p-4 border-b border-border bg-muted/30">
|
||||
<div className="flex items-center justify-between text-sm mb-2">
|
||||
<span className="font-medium">{testingAll ? 'Testing all accounts...' : 'Validating all accounts...'}</span>
|
||||
<span className="text-muted-foreground">{batchProgress.current} / {batchProgress.total}</span>
|
||||
</div>
|
||||
<div className="progress-bar">
|
||||
<div className="w-full bg-muted rounded-full h-2 overflow-hidden mb-4">
|
||||
<div
|
||||
className="progress-fill"
|
||||
className="bg-primary h-full transition-all duration-300"
|
||||
style={{ width: `${(batchProgress.current / batchProgress.total) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
{batchProgress.results.length > 0 && (
|
||||
<div className="progress-results">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 max-h-32 overflow-y-auto custom-scrollbar">
|
||||
{batchProgress.results.map((r, i) => (
|
||||
<div key={i} className={`progress-result ${r.success ? 'success' : 'failed'}`}>
|
||||
{r.success ? '✓' : '✗'} {r.id} {r.time ? `(${r.time}ms)` : ''}
|
||||
<div key={i} className={clsx(
|
||||
"text-xs px-2 py-1 rounded border truncate",
|
||||
r.success ? "bg-emerald-500/10 border-emerald-500/20 text-emerald-500" : "bg-destructive/10 border-destructive/20 text-destructive"
|
||||
)}>
|
||||
{r.success ? '✓' : '✗'} {r.id}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -334,121 +378,140 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config.accounts?.length > 0 ? (
|
||||
<div className="list">
|
||||
{config.accounts.map((acc, i) => {
|
||||
<div className="divide-y divide-border">
|
||||
{config.accounts?.length > 0 ? (
|
||||
config.accounts.map((acc, i) => {
|
||||
const id = acc.email || acc.mobile
|
||||
return (
|
||||
<div key={i} className="list-item">
|
||||
<div className="list-item-info">
|
||||
<span className="list-item-text">{id}</span>
|
||||
<span className={`badge ${acc.has_token ? 'badge-success' : 'badge-warning'}`}>
|
||||
{acc.has_token ? '已登录' : '未登录'}
|
||||
</span>
|
||||
{acc.token_preview && (
|
||||
<span className="token-preview" title="Token 预览">
|
||||
🔑 {acc.token_preview}
|
||||
</span>
|
||||
)}
|
||||
<div key={i} className="p-4 flex flex-col md:flex-row md:items-center justify-between gap-4 hover:bg-muted/50 transition-colors">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className={clsx(
|
||||
"w-2 h-2 rounded-full shrink-0",
|
||||
acc.has_token ? "bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]" : "bg-amber-500"
|
||||
)} />
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium truncate">{id}</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5">
|
||||
<span>{acc.has_token ? 'Active Session' : 'Login Required'}</span>
|
||||
{acc.token_preview && (
|
||||
<span className="font-mono bg-muted px-1.5 py-0.5 rounded text-[10px]">
|
||||
{acc.token_preview}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="btn-group-inline">
|
||||
<div className="flex items-center gap-2 self-end md:self-auto">
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => testAccount(id)}
|
||||
disabled={testing[id]}
|
||||
className="px-3 py-1.5 text-xs font-medium border border-border rounded-md hover:bg-secondary transition-colors disabled:opacity-50"
|
||||
>
|
||||
{testing[id] ? <span className="loading"></span> : '测试'}
|
||||
{testing[id] ? 'Testing...' : 'Test'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => validateAccount(id)}
|
||||
disabled={validating[id]}
|
||||
className="px-3 py-1.5 text-xs font-medium border border-border rounded-md hover:bg-secondary transition-colors disabled:opacity-50"
|
||||
>
|
||||
{validating[id] ? <span className="loading"></span> : '验证'}
|
||||
{validating[id] ? 'Validating...' : 'Validate'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteAccount(id)}
|
||||
className="p-1.5 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-md transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button className="btn btn-danger btn-sm" onClick={() => deleteAccount(id)}>删除</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-state">暂无账号</div>
|
||||
)}
|
||||
})
|
||||
) : (
|
||||
<div className="p-8 text-center text-muted-foreground">No accounts found</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Key Modal */}
|
||||
{/* Modals */}
|
||||
{showAddKey && (
|
||||
<div className="modal-overlay" onClick={() => setShowAddKey(false)}>
|
||||
<div className="modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<span className="modal-title">添加 API Key</span>
|
||||
<button className="modal-close" onClick={() => setShowAddKey(false)}>×</button>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">API Key</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder="输入你自定义的 API Key"
|
||||
value={newKey}
|
||||
onChange={e => setNewKey(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="btn-group">
|
||||
<button className="btn btn-secondary" onClick={() => setShowAddKey(false)}>取消</button>
|
||||
<button className="btn btn-primary" onClick={addKey} disabled={loading}>
|
||||
{loading ? <span className="loading"></span> : '添加'}
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4 animate-in fade-in">
|
||||
<div className="bg-card w-full max-w-md rounded-xl border border-border shadow-2xl overflow-hidden animate-in zoom-in-95">
|
||||
<div className="p-4 border-b border-border flex justify-between items-center">
|
||||
<h3 className="font-semibold">Add API Key</h3>
|
||||
<button onClick={() => setShowAddKey(false)} className="text-muted-foreground hover:text-foreground">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5">New Key value</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-field"
|
||||
placeholder="Enter custom API key"
|
||||
value={newKey}
|
||||
onChange={e => setNewKey(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button onClick={() => setShowAddKey(false)} className="btn btn-secondary">Cancel</button>
|
||||
<button onClick={addKey} disabled={loading} className="btn btn-primary">
|
||||
{loading ? 'Adding...' : 'Add Key'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Account Modal */}
|
||||
{showAddAccount && (
|
||||
<div className="modal-overlay" onClick={() => setShowAddAccount(false)}>
|
||||
<div className="modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<span className="modal-title">添加 DeepSeek 账号</span>
|
||||
<button className="modal-close" onClick={() => setShowAddAccount(false)}>×</button>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Email(可选)</label>
|
||||
<input
|
||||
type="email"
|
||||
className="form-input"
|
||||
placeholder="user@example.com"
|
||||
value={newAccount.email}
|
||||
onChange={e => setNewAccount({ ...newAccount, email: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">手机号(可选)</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder="+86..."
|
||||
value={newAccount.mobile}
|
||||
onChange={e => setNewAccount({ ...newAccount, mobile: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">密码(必填)</label>
|
||||
<input
|
||||
type="password"
|
||||
className="form-input"
|
||||
placeholder="DeepSeek 账号密码"
|
||||
value={newAccount.password}
|
||||
onChange={e => setNewAccount({ ...newAccount, password: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="btn-group">
|
||||
<button className="btn btn-secondary" onClick={() => setShowAddAccount(false)}>取消</button>
|
||||
<button className="btn btn-primary" onClick={addAccount} disabled={loading}>
|
||||
{loading ? <span className="loading"></span> : '添加'}
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4 animate-in fade-in">
|
||||
<div className="bg-card w-full max-w-md rounded-xl border border-border shadow-2xl overflow-hidden animate-in zoom-in-95">
|
||||
<div className="p-4 border-b border-border flex justify-between items-center">
|
||||
<h3 className="font-semibold">Add DeepSeek Account</h3>
|
||||
<button onClick={() => setShowAddAccount(false)} className="text-muted-foreground hover:text-foreground">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5">Email (Optional)</label>
|
||||
<input
|
||||
type="email"
|
||||
className="input-field"
|
||||
placeholder="user@example.com"
|
||||
value={newAccount.email}
|
||||
onChange={e => setNewAccount({ ...newAccount, email: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5">Mobile (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-field"
|
||||
placeholder="+86..."
|
||||
value={newAccount.mobile}
|
||||
onChange={e => setNewAccount({ ...newAccount, mobile: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5">Password <span className="text-destructive">*</span></label>
|
||||
<input
|
||||
type="password"
|
||||
className="input-field"
|
||||
placeholder="Account password"
|
||||
value={newAccount.password}
|
||||
onChange={e => setNewAccount({ ...newAccount, password: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button onClick={() => setShowAddAccount(false)} className="btn btn-secondary">Cancel</button>
|
||||
<button onClick={addAccount} disabled={loading} className="btn btn-primary">
|
||||
{loading ? 'Adding...' : 'Add Account'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,17 +1,30 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useState, useRef } from 'react'
|
||||
import {
|
||||
Send,
|
||||
Square,
|
||||
MessageSquare,
|
||||
Cpu,
|
||||
Search as SearchIcon,
|
||||
Sparkles,
|
||||
Bot,
|
||||
User,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
AlertCircle
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
const MODELS = [
|
||||
{ id: 'deepseek-chat', name: 'deepseek-chat' },
|
||||
{ id: 'deepseek-reasoner', name: 'deepseek-reasoner' },
|
||||
{ id: 'deepseek-chat-search', name: 'deepseek-chat-search' },
|
||||
{ id: 'deepseek-reasoner-search', name: 'deepseek-reasoner-search' },
|
||||
{ id: 'deepseek-chat', name: 'DeepSeek Chat', icon: MessageSquare, desc: 'General purpose chat model' },
|
||||
{ id: 'deepseek-reasoner', name: 'DeepSeek Reasoner', icon: Cpu, desc: 'Optimized for reasoning tasks' },
|
||||
// Removed search models as they might be deprecated or identical to chat with search tool
|
||||
]
|
||||
|
||||
export default function ApiTester({ config, onMessage, authFetch }) {
|
||||
const [model, setModel] = useState('deepseek-chat')
|
||||
const [message, setMessage] = useState('你好,请用一句话介绍你自己。')
|
||||
const [message, setMessage] = useState('Hello, please introduce yourself in one sentence.')
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [selectedAccount, setSelectedAccount] = useState('') // 空为随机
|
||||
const [selectedAccount, setSelectedAccount] = useState('')
|
||||
const [response, setResponse] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [streamingContent, setStreamingContent] = useState('')
|
||||
@@ -19,16 +32,9 @@ export default function ApiTester({ config, onMessage, authFetch }) {
|
||||
const [isStreaming, setIsStreaming] = useState(false)
|
||||
const abortControllerRef = useRef(null)
|
||||
|
||||
// 使用 authFetch 或回退到普通 fetch(admin API 用 authFetch,OpenAI 兼容 API 用普通 fetch)
|
||||
const apiFetch = authFetch || fetch
|
||||
|
||||
// 获取账号列表
|
||||
const accounts = config.accounts || []
|
||||
|
||||
const testApi = async () => {
|
||||
// ... (保留旧的 server-side test作为备用,或者完全移除?保留吧但不使用)
|
||||
}
|
||||
|
||||
const stopGeneration = () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort()
|
||||
@@ -52,7 +58,7 @@ export default function ApiTester({ config, onMessage, authFetch }) {
|
||||
try {
|
||||
const key = apiKey || (config.keys?.[0] || '')
|
||||
if (!key) {
|
||||
onMessage('error', '请提供 API Key')
|
||||
onMessage('error', 'Please provide an API Key')
|
||||
setLoading(false)
|
||||
setIsStreaming(false)
|
||||
return
|
||||
@@ -74,8 +80,8 @@ export default function ApiTester({ config, onMessage, authFetch }) {
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
setResponse({ success: false, error: data.error?.message || '请求失败' })
|
||||
onMessage('error', data.error?.message || '请求失败')
|
||||
setResponse({ success: false, error: data.error?.message || 'Request failed' })
|
||||
onMessage('error', data.error?.message || 'Request failed')
|
||||
setLoading(false)
|
||||
setIsStreaming(false)
|
||||
return
|
||||
@@ -83,7 +89,6 @@ export default function ApiTester({ config, onMessage, authFetch }) {
|
||||
|
||||
setResponse({ success: true, status_code: res.status })
|
||||
|
||||
// 处理流式响应
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
@@ -108,12 +113,9 @@ export default function ApiTester({ config, onMessage, authFetch }) {
|
||||
const choice = json.choices?.[0]
|
||||
if (choice?.delta) {
|
||||
const delta = choice.delta
|
||||
|
||||
// DeepSeek 官方格式使用 reasoning_content 表示思考内容
|
||||
if (delta.reasoning_content) {
|
||||
setStreamingThinking(prev => prev + delta.reasoning_content)
|
||||
}
|
||||
// 正常内容
|
||||
if (delta.content) {
|
||||
setStreamingContent(prev => prev + delta.content)
|
||||
}
|
||||
@@ -125,9 +127,9 @@ export default function ApiTester({ config, onMessage, authFetch }) {
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.name === 'AbortError') {
|
||||
onMessage('info', '已停止生成')
|
||||
onMessage('info', 'Generation stopped')
|
||||
} else {
|
||||
onMessage('error', '网络错误: ' + e.message)
|
||||
onMessage('error', 'Network error: ' + e.message)
|
||||
setResponse({ error: e.message, success: false })
|
||||
}
|
||||
} finally {
|
||||
@@ -137,9 +139,7 @@ export default function ApiTester({ config, onMessage, authFetch }) {
|
||||
}
|
||||
}
|
||||
|
||||
// 智能测试:根据是否选择账号决定测试方式
|
||||
const sendTest = async () => {
|
||||
// 如果选择了指定账号,使用账号测试接口(暂时保持非流式,或者后续改为支持流式)
|
||||
if (selectedAccount) {
|
||||
setLoading(true)
|
||||
setResponse(null)
|
||||
@@ -161,12 +161,12 @@ export default function ApiTester({ config, onMessage, authFetch }) {
|
||||
account: selectedAccount,
|
||||
})
|
||||
if (data.success) {
|
||||
onMessage('success', `${selectedAccount}: 测试成功 (${data.response_time}ms)`)
|
||||
onMessage('success', `${selectedAccount}: Test Success (${data.response_time}ms)`)
|
||||
} else {
|
||||
onMessage('error', `${selectedAccount}: ${data.message}`)
|
||||
}
|
||||
} catch (e) {
|
||||
onMessage('error', '网络错误: ' + e.message)
|
||||
onMessage('error', 'Network error: ' + e.message)
|
||||
setResponse({ error: e.message })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
@@ -174,174 +174,180 @@ export default function ApiTester({ config, onMessage, authFetch }) {
|
||||
return
|
||||
}
|
||||
|
||||
// 随机账号:使用标准 API (流式)
|
||||
directTest()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="section">
|
||||
<div className="card">
|
||||
<div className="card-title" style={{ marginBottom: '1rem' }}>🧪 API 测试</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 h-[calc(100vh-140px)]">
|
||||
{/* Configuration Panel */}
|
||||
<div className="lg:col-span-1 space-y-4 overflow-y-auto pr-2">
|
||||
<div className="bg-card border border-border rounded-xl p-5 shadow-sm space-y-5">
|
||||
<h3 className="font-semibold flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4 text-primary" />
|
||||
Configuration
|
||||
</h3>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">模型</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={model}
|
||||
onChange={e => setModel(e.target.value)}
|
||||
>
|
||||
{MODELS.map(m => (
|
||||
<option key={m.id} value={m.id}>{m.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<label className="text-sm font-medium text-muted-foreground">Model</label>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{MODELS.map(m => {
|
||||
const Icon = m.icon
|
||||
return (
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => setModel(m.id)}
|
||||
className={clsx(
|
||||
"flex items-center gap-3 p-3 rounded-lg border text-left transition-all",
|
||||
model === m.id
|
||||
? "border-primary bg-primary/5 ring-1 ring-primary/20"
|
||||
: "border-border hover:bg-secondary/50"
|
||||
)}
|
||||
>
|
||||
<div className={clsx("p-2 rounded-md", model === m.id ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground")}>
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-sm">{m.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{m.desc}</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">账号(指定测试哪个账号)</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={selectedAccount}
|
||||
onChange={e => setSelectedAccount(e.target.value)}
|
||||
>
|
||||
<option value="">🎲 随机选择 (流式)</option>
|
||||
{accounts.map((acc, i) => {
|
||||
const id = acc.email || acc.mobile
|
||||
return <option key={i} value={id}>{id} {acc.has_token ? '✅' : '⚠️'}</option>
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-muted-foreground">Account Strategy</label>
|
||||
<select
|
||||
className="input-field"
|
||||
value={selectedAccount}
|
||||
onChange={e => setSelectedAccount(e.target.value)}
|
||||
>
|
||||
<option value="">🎲 Random (Streaming)</option>
|
||||
{accounts.map((acc, i) => (
|
||||
<option key={i} value={acc.email || acc.mobile}>
|
||||
👤 {acc.email || acc.mobile}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">API Key(留空使用第一个配置的 Key)</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder={config.keys?.[0] ? `默认: ${config.keys[0].slice(0, 8)}...` : '请先添加 API Key'}
|
||||
value={apiKey}
|
||||
onChange={e => setApiKey(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">消息内容</label>
|
||||
<textarea
|
||||
className="form-input"
|
||||
value={message}
|
||||
onChange={e => setMessage(e.target.value)}
|
||||
placeholder="输入测试消息..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="btn-group">
|
||||
{loading && isStreaming ? (
|
||||
<button className="btn btn-warning" onClick={stopGeneration}>
|
||||
⏹ 停止生成
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn btn-primary" onClick={sendTest} disabled={loading}>
|
||||
{loading ? <span className="loading"></span> :
|
||||
selectedAccount ? `🚀 使用 ${selectedAccount} 发送` : '🚀 发送请求 (流式)'}
|
||||
</button>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-muted-foreground">API Key (Optional)</label>
|
||||
<input
|
||||
type="password"
|
||||
className="input-field font-mono text-xs"
|
||||
placeholder={config.keys?.[0] ? `Default: ${config.keys[0].slice(0, 8)}...` : 'Enter custom API Key'}
|
||||
value={apiKey}
|
||||
onChange={e => setApiKey(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(response || isStreaming) && (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<span className="card-title">响应结果</span>
|
||||
{response && (
|
||||
<span className={`badge ${response.success ? 'badge-success' : 'badge-error'}`}>
|
||||
{response.success ? '成功' : '失败'} {response.status_code && `(${response.status_code})`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 流式响应显示区域 */}
|
||||
{(streamingContent || streamingThinking || isStreaming) && !selectedAccount ? (
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
{streamingThinking && (
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<div className="form-label" style={{ color: '#888' }}>🤔 思考过程:</div>
|
||||
<div style={{
|
||||
padding: '1rem',
|
||||
background: 'rgba(0,0,0,0.05)',
|
||||
borderLeft: '4px solid #666',
|
||||
color: '#666',
|
||||
fontSize: '0.9em',
|
||||
whiteSpace: 'pre-wrap',
|
||||
maxHeight: '200px',
|
||||
overflowY: 'auto'
|
||||
}}>
|
||||
{streamingThinking}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-label">🤖 AI 回复:</div>
|
||||
<div style={{
|
||||
padding: '1rem',
|
||||
background: 'var(--bg-tertiary)',
|
||||
borderRadius: 'var(--radius)',
|
||||
whiteSpace: 'pre-wrap',
|
||||
minHeight: '60px'
|
||||
}}>
|
||||
{streamingContent}
|
||||
{isStreaming && <span className="cursor-blink">|</span>}
|
||||
{/* Chat Interface */}
|
||||
<div className="lg:col-span-2 flex flex-col bg-card border border-border rounded-xl shadow-sm overflow-hidden h-full">
|
||||
{/* Messages Area */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-6 custom-scrollbar">
|
||||
{/* User Message */}
|
||||
<div className="flex gap-4 max-w-3xl mx-auto">
|
||||
<div className="w-8 h-8 rounded-full bg-secondary flex items-center justify-center shrink-0">
|
||||
<User className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="space-y-1 flew-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">You</span>
|
||||
</div>
|
||||
<div className="bg-secondary/50 rounded-2xl rounded-tl-none px-4 py-3 text-sm border border-border">
|
||||
{message}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// 非流式响应显示(如JSON或指定账号测试结果)
|
||||
<div className="code-block">
|
||||
{JSON.stringify(response?.response || response?.error || {}, null, 2)}
|
||||
</div>
|
||||
|
||||
{/* AI Response */}
|
||||
{(response || isStreaming) && (
|
||||
<div className="flex gap-4 max-w-3xl mx-auto animate-in fade-in slide-in-from-bottom-2">
|
||||
<div className={clsx(
|
||||
"w-8 h-8 rounded-full flex items-center justify-center shrink-0",
|
||||
response?.success !== false ? "bg-primary text-primary-foreground" : "bg-destructive text-destructive-foreground"
|
||||
)}>
|
||||
<Bot className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="space-y-2 flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">DeepSeek</span>
|
||||
{response && (
|
||||
<span className={clsx(
|
||||
"text-[10px] px-1.5 py-0.5 rounded border uppercase font-bold tracking-wider",
|
||||
response.success ? "border-emerald-500/20 text-emerald-500 bg-emerald-500/5" : "border-destructive/20 text-destructive bg-destructive/5"
|
||||
)}>
|
||||
{response.status_code || 'ERROR'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(streamingThinking || response?.response?.thinking) && (
|
||||
<div className="text-xs text-muted-foreground bg-muted/30 border border-border rounded-lg p-3 space-y-1">
|
||||
<div className="flex items-center gap-1.5 opacity-70 mb-1">
|
||||
<Sparkles className="w-3 h-3" />
|
||||
<span>Reasoning Process</span>
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap leading-relaxed opacity-90 font-mono">
|
||||
{streamingThinking || response?.response?.thinking}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-sm leading-relaxed whitespace-pre-wrap">
|
||||
{!selectedAccount ? (
|
||||
streamingContent || (response?.error && <span className="text-destructive">{response.error}</span>)
|
||||
) : (
|
||||
response?.response?.message || <span className="text-muted-foreground italic">...</span>
|
||||
)}
|
||||
{isStreaming && <span className="inline-block w-1.5 h-4 bg-primary ml-1 align-middle animate-pulse" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 指定账号测试的特定显示 */}
|
||||
{selectedAccount && response?.success && (
|
||||
<>
|
||||
{response.response?.thinking && (
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
<div className="form-label" style={{ color: '#888' }}>🤔 思考过程:</div>
|
||||
<div style={{
|
||||
padding: '1rem',
|
||||
background: 'rgba(0,0,0,0.05)',
|
||||
borderLeft: '4px solid #666',
|
||||
color: '#666',
|
||||
fontSize: '0.9em',
|
||||
whiteSpace: 'pre-wrap'
|
||||
}}>
|
||||
{response.response.thinking}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{response.response?.message && (
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
<div className="form-label">AI 回复 ({response.account}):</div>
|
||||
<div style={{
|
||||
padding: '1rem',
|
||||
background: 'var(--bg-tertiary)',
|
||||
borderRadius: 'var(--radius)',
|
||||
whiteSpace: 'pre-wrap'
|
||||
}}>
|
||||
{response.response.message}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
.cursor-blink {
|
||||
animation: blink 1s step-end infinite;
|
||||
}
|
||||
@keyframes blink {
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
`}</style>
|
||||
{/* Input Area */}
|
||||
<div className="p-4 border-t border-border bg-card">
|
||||
<div className="max-w-3xl mx-auto relative">
|
||||
<textarea
|
||||
className="w-full bg-secondary/30 border border-border rounded-xl pl-4 pr-14 py-3 text-sm focus:bg-background focus:ring-1 focus:ring-primary focus:border-primary transition-all resize-none custom-scrollbar"
|
||||
placeholder="Type your message here..."
|
||||
rows={3}
|
||||
value={message}
|
||||
onChange={e => setMessage(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
sendTest()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="absolute right-2 bottom-2">
|
||||
{loading && isStreaming ? (
|
||||
<button
|
||||
onClick={stopGeneration}
|
||||
className="p-2 bg-destructive text-destructive-foreground rounded-lg hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<Square className="w-4 h-4 fill-current" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={sendTest}
|
||||
disabled={loading || !message.trim()}
|
||||
className="p-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState } from 'react'
|
||||
import { FileCode, Download, Upload, Copy, Check, AlertTriangle } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
// 模板配置
|
||||
const TEMPLATES = {
|
||||
full: {
|
||||
name: '完整模板',
|
||||
desc: '包含所有配置项',
|
||||
name: 'Full Configuration',
|
||||
desc: 'Includes keys, accounts, and model mappings',
|
||||
config: {
|
||||
keys: ["your-api-key-1", "your-api-key-2"],
|
||||
accounts: [
|
||||
@@ -19,8 +20,8 @@ const TEMPLATES = {
|
||||
}
|
||||
},
|
||||
email_only: {
|
||||
name: '邮箱账号模板',
|
||||
desc: '仅邮箱账号',
|
||||
name: 'Email Only',
|
||||
desc: 'Batch import email accounts',
|
||||
config: {
|
||||
keys: ["your-api-key"],
|
||||
accounts: [
|
||||
@@ -31,8 +32,8 @@ const TEMPLATES = {
|
||||
}
|
||||
},
|
||||
mobile_only: {
|
||||
name: '手机号账号模板',
|
||||
desc: '仅手机号账号',
|
||||
name: 'Mobile Only',
|
||||
desc: 'Batch import mobile number accounts',
|
||||
config: {
|
||||
keys: ["your-api-key"],
|
||||
accounts: [
|
||||
@@ -43,8 +44,8 @@ const TEMPLATES = {
|
||||
}
|
||||
},
|
||||
keys_only: {
|
||||
name: '仅 API Keys',
|
||||
desc: '只添加 API Keys',
|
||||
name: 'API Keys Only',
|
||||
desc: 'Just adding API access keys',
|
||||
config: {
|
||||
keys: ["key-1", "key-2", "key-3"]
|
||||
}
|
||||
@@ -55,13 +56,13 @@ export default function BatchImport({ onRefresh, onMessage, authFetch }) {
|
||||
const [jsonInput, setJsonInput] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [result, setResult] = useState(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
// 使用 authFetch 或回退到普通 fetch
|
||||
const apiFetch = authFetch || fetch
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!jsonInput.trim()) {
|
||||
onMessage('error', '请输入 JSON 配置')
|
||||
onMessage('error', 'Please enter JSON configuration')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -69,7 +70,7 @@ export default function BatchImport({ onRefresh, onMessage, authFetch }) {
|
||||
try {
|
||||
config = JSON.parse(jsonInput)
|
||||
} catch (e) {
|
||||
onMessage('error', 'JSON 格式无效')
|
||||
onMessage('error', 'Invalid JSON format')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -84,13 +85,13 @@ export default function BatchImport({ onRefresh, onMessage, authFetch }) {
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
setResult(data)
|
||||
onMessage('success', `导入成功: ${data.imported_keys} 个 Key, ${data.imported_accounts} 个账号`)
|
||||
onMessage('success', `Imported: ${data.imported_keys} Keys, ${data.imported_accounts} Accounts`)
|
||||
onRefresh()
|
||||
} else {
|
||||
onMessage('error', data.detail || '导入失败')
|
||||
onMessage('error', data.detail || 'Import failed')
|
||||
}
|
||||
} catch (e) {
|
||||
onMessage('error', '网络错误')
|
||||
onMessage('error', 'Network error')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -100,7 +101,7 @@ export default function BatchImport({ onRefresh, onMessage, authFetch }) {
|
||||
const tpl = TEMPLATES[key]
|
||||
if (tpl) {
|
||||
setJsonInput(JSON.stringify(tpl.config, null, 2))
|
||||
onMessage('info', `已加载「${tpl.name}」`)
|
||||
onMessage('info', `Loaded template: ${tpl.name}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,10 +111,10 @@ export default function BatchImport({ onRefresh, onMessage, authFetch }) {
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setJsonInput(JSON.stringify(JSON.parse(data.json), null, 2))
|
||||
onMessage('success', '已加载当前配置')
|
||||
onMessage('success', 'Configuration loaded')
|
||||
}
|
||||
} catch (e) {
|
||||
onMessage('error', '获取配置失败')
|
||||
onMessage('error', 'Failed to fetch config')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,81 +124,109 @@ export default function BatchImport({ onRefresh, onMessage, authFetch }) {
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
await navigator.clipboard.writeText(data.base64)
|
||||
onMessage('success', 'Base64 已复制到剪贴板')
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
onMessage('success', 'Base64 copied to clipboard')
|
||||
}
|
||||
} catch (e) {
|
||||
onMessage('error', '复制失败')
|
||||
onMessage('error', 'Copy failed')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="section">
|
||||
{/* 模板选择 */}
|
||||
<div className="card">
|
||||
<div className="card-title" style={{ marginBottom: '1rem' }}>📋 快速模板</div>
|
||||
<div className="grid grid-2">
|
||||
{Object.entries(TEMPLATES).map(([key, tpl]) => (
|
||||
<div
|
||||
key={key}
|
||||
style={{
|
||||
padding: '1rem',
|
||||
background: 'var(--bg-tertiary)',
|
||||
borderRadius: 'var(--radius)',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
border: '1px solid transparent'
|
||||
}}
|
||||
onClick={() => loadTemplate(key)}
|
||||
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--accent)'}
|
||||
onMouseLeave={e => e.currentTarget.style.borderColor = 'transparent'}
|
||||
>
|
||||
<div style={{ fontWeight: 600, marginBottom: '0.25rem' }}>{tpl.name}</div>
|
||||
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>{tpl.desc}</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 h-[calc(100vh-140px)]">
|
||||
{/* Templates Panel */}
|
||||
<div className="md:col-span-1 space-y-4">
|
||||
<div className="bg-card border border-border rounded-xl p-5 shadow-sm">
|
||||
<h3 className="font-semibold flex items-center gap-2 mb-4">
|
||||
<FileCode className="w-4 h-4 text-primary" />
|
||||
Quick Templates
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(TEMPLATES).map(([key, tpl]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => loadTemplate(key)}
|
||||
className="w-full text-left p-3 rounded-lg border border-border bg-secondary/20 hover:bg-secondary/50 hover:border-primary/50 transition-all custom-focus group"
|
||||
>
|
||||
<div className="font-medium text-sm group-hover:text-primary transition-colors">{tpl.name}</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">{tpl.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-linear-to-br from-primary/10 to-transparent border border-primary/20 rounded-xl p-5 shadow-sm">
|
||||
<h3 className="font-semibold flex items-center gap-2 mb-2 text-primary">
|
||||
<Download className="w-4 h-4" />
|
||||
Export Data
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Get your configuration as a Base64 string for Vercel environment variables.
|
||||
</p>
|
||||
<button
|
||||
onClick={copyBase64}
|
||||
className="w-full btn btn-primary bg-primary/90 hover:bg-primary shadow-lg shadow-primary/20"
|
||||
>
|
||||
{copied ? <Check className="w-4 h-4 mr-2" /> : <Copy className="w-4 h-4 mr-2" />}
|
||||
{copied ? 'Copied!' : 'Copy Base64 Config'}
|
||||
</button>
|
||||
<p className="text-[10px] text-muted-foreground mt-2 text-center">
|
||||
Variable Name: <code className="bg-background px-1 py-0.5 rounded border border-border">DS2API_CONFIG_JSON</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 导入区域 */}
|
||||
<div className="card">
|
||||
<div className="card-title" style={{ marginBottom: '1rem' }}>📦 批量导入</div>
|
||||
{/* Editor Panel */}
|
||||
<div className="md:col-span-2 flex flex-col bg-card border border-border rounded-xl shadow-sm overflow-hidden h-full">
|
||||
<div className="p-4 border-b border-border flex items-center justify-between bg-muted/20">
|
||||
<h3 className="font-semibold flex items-center gap-2">
|
||||
<Upload className="w-4 h-4 text-primary" />
|
||||
JSON Editor
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleExport} className="btn btn-secondary text-xs h-8">
|
||||
Load Current
|
||||
</button>
|
||||
<button onClick={handleImport} disabled={loading} className="btn btn-primary text-xs h-8">
|
||||
{loading ? 'Importing...' : 'Simulate Import'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">JSON 配置(点击上方模板快速填充)</label>
|
||||
<div className="flex-1 relative">
|
||||
<textarea
|
||||
className="form-input"
|
||||
style={{ minHeight: '200px' }}
|
||||
className="absolute inset-0 w-full h-full p-4 font-mono text-sm bg-secondary/10 resize-none focus:outline-none custom-scrollbar"
|
||||
value={jsonInput}
|
||||
onChange={e => setJsonInput(e.target.value)}
|
||||
placeholder='{\n "keys": ["你的API密钥"],\n "accounts": [\n {"email": "邮箱", "password": "密码", "token": ""}\n ]\n}'
|
||||
placeholder={'{\n "keys": ["your-api-key"],\n "accounts": [\n {"email": "...", "password": "...", "token": ""}\n ]\n}'}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="btn-group" style={{ marginBottom: '1rem' }}>
|
||||
<button className="btn btn-secondary" onClick={handleExport}>
|
||||
⬇️ 导出当前
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={handleImport} disabled={loading}>
|
||||
{loading ? <span className="loading"></span> : '📥 导入配置'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<div className="alert alert-success">
|
||||
✅ 导入完成:{result.imported_keys} 个 API Key,{result.imported_accounts} 个账号
|
||||
<div className={clsx(
|
||||
"p-4 border-t",
|
||||
result.imported_keys || result.imported_accounts ? "bg-emerald-500/10 border-emerald-500/20" : "bg-destructive/10 border-destructive/20"
|
||||
)}>
|
||||
<div className="flex items-start gap-3">
|
||||
{result.imported_keys || result.imported_accounts ? (
|
||||
<Check className="w-5 h-5 text-emerald-500 mt-0.5" />
|
||||
) : (
|
||||
<AlertTriangle className="w-5 h-5 text-destructive mt-0.5" />
|
||||
)}
|
||||
<div>
|
||||
<h4 className={clsx("font-medium", result.imported_keys || result.imported_accounts ? "text-emerald-500" : "text-destructive")}>
|
||||
Import Operation Completed
|
||||
</h4>
|
||||
<p className="text-sm opacity-80 mt-1">
|
||||
Successfully imported {result.imported_keys} API keys and updated {result.imported_accounts} accounts.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-title" style={{ marginBottom: '1rem' }}>📤 导出 Base64</div>
|
||||
<p style={{ color: 'var(--text-secondary)', marginBottom: '1rem' }}>
|
||||
导出 Base64 格式配置,可直接粘贴到 Vercel 环境变量 <code>DS2API_CONFIG_JSON</code>
|
||||
</p>
|
||||
<button className="btn btn-success" onClick={copyBase64}>
|
||||
📋 复制 Base64 到剪贴板
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { Key, ArrowRight, ShieldCheck, Lock } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export default function Login({ onLogin, onMessage }) {
|
||||
const [adminKey, setAdminKey] = useState('')
|
||||
@@ -7,6 +9,8 @@ export default function Login({ onLogin, onMessage }) {
|
||||
|
||||
const handleLogin = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!adminKey.trim()) return
|
||||
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
@@ -19,7 +23,6 @@ export default function Login({ onLogin, onMessage }) {
|
||||
const data = await res.json()
|
||||
|
||||
if (res.ok && data.success) {
|
||||
// 存储 token
|
||||
const storage = remember ? localStorage : sessionStorage
|
||||
storage.setItem('ds2api_token', data.token)
|
||||
storage.setItem('ds2api_token_expires', Date.now() + data.expires_in * 1000)
|
||||
@@ -39,50 +42,84 @@ export default function Login({ onLogin, onMessage }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-container">
|
||||
<div className="login-card">
|
||||
<div className="login-header">
|
||||
<h1>🔐 DS2API Admin</h1>
|
||||
<p>请输入管理密钥登录</p>
|
||||
<div className="flex flex-col items-center justify-center p-4 w-full max-w-md relative z-10">
|
||||
<div className="w-full bg-card/50 backdrop-blur-xl border border-white/10 shadow-2xl rounded-2xl p-8 space-y-8 animate-in fade-in zoom-in-95 duration-300">
|
||||
<div className="text-center space-y-2">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary/20 text-primary mb-4 ring-1 ring-white/10 shadow-inner">
|
||||
<Lock className="w-8 h-8" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-white">
|
||||
Welcome Back
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Enter your admin key to access the dashboard
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleLogin}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">管理密钥</label>
|
||||
<input
|
||||
type="password"
|
||||
className="form-input"
|
||||
placeholder="输入 DS2API_ADMIN_KEY..."
|
||||
value={adminKey}
|
||||
onChange={e => setAdminKey(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<form onSubmit={handleLogin} className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-muted-foreground ml-1 uppercase tracking-wider">
|
||||
Admin Key
|
||||
</label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-muted-foreground group-focus-within:text-primary transition-colors">
|
||||
<Key className="w-4 h-4" />
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
className="block w-full pl-10 pr-3 py-2.5 bg-secondary/50 border border-border rounded-lg text-sm placeholder:text-muted-foreground/50 focus:border-primary/50 focus:ring-2 focus:ring-primary/20 focus:bg-secondary focus:outline-none transition-all duration-200"
|
||||
placeholder="Enter your DS2API_ADMIN_KEY"
|
||||
value={adminKey}
|
||||
onChange={e => setAdminKey(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group" style={{ flexDirection: 'row', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="remember"
|
||||
checked={remember}
|
||||
onChange={e => setRemember(e.target.checked)}
|
||||
/>
|
||||
<label htmlFor="remember" style={{ cursor: 'pointer' }}>
|
||||
记住登录状态
|
||||
</label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={remember}
|
||||
onClick={() => setRemember(!remember)}
|
||||
className={clsx(
|
||||
"w-4 h-4 rounded border flex items-center justify-center transition-all duration-200",
|
||||
remember ? "bg-primary border-primary text-primary-foreground" : "border-muted-foreground/50 bg-transparent"
|
||||
)}
|
||||
>
|
||||
{remember && <div className="w-2 h-2 rounded-[1px] bg-current" />}
|
||||
</button>
|
||||
<span
|
||||
onClick={() => setRemember(!remember)}
|
||||
className="text-sm text-muted-foreground cursor-pointer select-none hover:text-foreground transition-colors"
|
||||
>
|
||||
Keep me signed in
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={loading}
|
||||
style={{ width: '100%', justifyContent: 'center' }}
|
||||
className="w-full flex items-center justify-center py-2.5 px-4 rounded-lg bg-primary hover:bg-primary/90 text-primary-foreground font-medium text-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-primary/20 disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-primary/25 hover:shadow-primary/40 hover:-translate-y-0.5 active:translate-y-0"
|
||||
>
|
||||
{loading ? <span className="loading"></span> : '🚀 登录'}
|
||||
{loading ? (
|
||||
<div className="w-5 h-5 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<span>Access Dashboard</span>
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="login-footer">
|
||||
<p>Session 有效期 24 小时</p>
|
||||
<div className="pt-6 border-t border-border/50 text-center">
|
||||
<div className="flex items-center justify-center gap-2 text-xs text-muted-foreground/70">
|
||||
<ShieldCheck className="w-3 h-3" />
|
||||
<span>Secure Session • 24h Expiration</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Cloud, ArrowRight, ExternalLink, Info, CheckCircle2, XCircle } from 'lucide-react'
|
||||
|
||||
export default function VercelSync({ onMessage, authFetch }) {
|
||||
const [vercelToken, setVercelToken] = useState('')
|
||||
@@ -8,10 +9,8 @@ export default function VercelSync({ onMessage, authFetch }) {
|
||||
const [result, setResult] = useState(null)
|
||||
const [preconfig, setPreconfig] = useState(null)
|
||||
|
||||
// 使用 authFetch 或回退到普通 fetch
|
||||
const apiFetch = authFetch || fetch
|
||||
|
||||
// 自动加载预配置的 Vercel 信息
|
||||
useEffect(() => {
|
||||
const loadPreconfig = async () => {
|
||||
try {
|
||||
@@ -23,22 +22,21 @@ export default function VercelSync({ onMessage, authFetch }) {
|
||||
if (data.team_id) setTeamId(data.team_id)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载 Vercel 预配置失败:', e)
|
||||
console.error('Failed to load preconfig:', e)
|
||||
}
|
||||
}
|
||||
loadPreconfig()
|
||||
}, [])
|
||||
|
||||
const handleSync = async () => {
|
||||
// 如果预配置了 token,使用特殊标记让后端使用预配置的 token
|
||||
const tokenToUse = preconfig?.has_token && !vercelToken ? '__USE_PRECONFIG__' : vercelToken
|
||||
|
||||
if (!tokenToUse && !preconfig?.has_token) {
|
||||
onMessage('error', '请填写 Vercel Token')
|
||||
onMessage('error', 'Vercel Token is required')
|
||||
return
|
||||
}
|
||||
if (!projectId) {
|
||||
onMessage('error', '请填写 Project ID')
|
||||
onMessage('error', 'Project ID is required')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -56,140 +54,162 @@ export default function VercelSync({ onMessage, authFetch }) {
|
||||
})
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
setResult(data)
|
||||
setResult({ ...data, success: true })
|
||||
onMessage('success', data.message)
|
||||
} else {
|
||||
onMessage('error', data.detail || '同步失败')
|
||||
setResult({ ...data, success: false })
|
||||
onMessage('error', data.detail || 'Sync failed')
|
||||
}
|
||||
} catch (e) {
|
||||
onMessage('error', '网络错误')
|
||||
onMessage('error', 'Network error')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="section">
|
||||
<div className="card">
|
||||
<div className="card-title" style={{ marginBottom: '1rem' }}>☁️ Vercel 同步</div>
|
||||
|
||||
<div className="alert alert-info" style={{ marginBottom: '1rem' }}>
|
||||
<strong>说明:</strong>同步配置到 Vercel 后会自动触发重新部署,约需 30-60 秒生效。
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 max-w-5xl mx-auto h-[calc(100vh-140px)]">
|
||||
{/* Configuration Form */}
|
||||
<div className="bg-card border border-border rounded-xl shadow-sm p-6 space-y-6">
|
||||
<div className="border-b border-border pb-6">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Cloud className="w-6 h-6 text-primary" />
|
||||
Vercel Deployment
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm mt-1">
|
||||
Sync your current key and account configuration directly to Vercel environment variables.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">
|
||||
Vercel Token
|
||||
<a
|
||||
href="https://vercel.com/account/tokens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ marginLeft: '0.5rem', fontSize: '0.8rem' }}
|
||||
>
|
||||
获取 Token →
|
||||
</a>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className="form-input"
|
||||
placeholder="输入 Vercel API Token"
|
||||
value={vercelToken}
|
||||
onChange={e => setVercelToken(e.target.value)}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium flex items-center justify-between">
|
||||
Vercel Token
|
||||
<a href="https://vercel.com/account/tokens" target="_blank" rel="noopener noreferrer" className="text-xs text-primary hover:underline flex items-center gap-1">
|
||||
Get Token <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="password"
|
||||
className="input-field pr-10"
|
||||
placeholder={preconfig?.has_token ? "Using pre-configured token" : "Enter Vercel Access Token"}
|
||||
value={vercelToken}
|
||||
onChange={e => setVercelToken(e.target.value)}
|
||||
/>
|
||||
{preconfig?.has_token && !vercelToken && (
|
||||
<div className="absolute right-3 top-2.5 text-emerald-500">
|
||||
<CheckCircle2 className="w-5 h-5" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Project ID</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-field"
|
||||
placeholder="prj_xxxxxxxxxxxx or Project Name"
|
||||
value={projectId}
|
||||
onChange={e => setProjectId(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Found in Project Settings → General</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium flex items-center gap-2">
|
||||
Team ID <span className="text-xs text-muted-foreground font-normal">(Optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-field"
|
||||
placeholder="team_xxxxxxxxxxxx"
|
||||
value={teamId}
|
||||
onChange={e => setTeamId(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">
|
||||
Project ID
|
||||
<span style={{ marginLeft: '0.5rem', fontSize: '0.8rem', color: 'var(--text-secondary)' }}>
|
||||
(可在 Vercel 项目设置中找到)
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder="prj_xxxxxxxxxxxx 或项目名称"
|
||||
value={projectId}
|
||||
onChange={e => setProjectId(e.target.value)}
|
||||
/>
|
||||
<div className="pt-4">
|
||||
<button
|
||||
onClick={handleSync}
|
||||
disabled={loading}
|
||||
className="w-full btn btn-primary flex justify-center items-center py-3 text-base shadow-lg shadow-primary/20 hover:shadow-primary/30"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
Syncing...
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-2">
|
||||
Sync & Redeploy <ArrowRight className="w-5 h-5" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<p className="text-xs text-center text-muted-foreground mt-4">
|
||||
This will trigger a new deployment on Vercel which takes about 30-60 seconds.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">
|
||||
Team ID(可选)
|
||||
<span style={{ marginLeft: '0.5rem', fontSize: '0.8rem', color: 'var(--text-secondary)' }}>
|
||||
(个人项目无需填写)
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder="team_xxxxxxxxxxxx"
|
||||
value={teamId}
|
||||
onChange={e => setTeamId(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleSync}
|
||||
disabled={loading}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span className="loading"></span>
|
||||
同步中...
|
||||
</>
|
||||
) : (
|
||||
'🚀 同步到 Vercel 并重新部署'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<div className="card">
|
||||
<div className="card-title" style={{ marginBottom: '1rem' }}>同步结果</div>
|
||||
<div className={`alert ${result.success ? 'alert-success' : 'alert-error'}`}>
|
||||
{result.message}
|
||||
</div>
|
||||
{result.deployment_url && (
|
||||
<p>
|
||||
部署地址:
|
||||
<a
|
||||
href={`https://${result.deployment_url}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: 'var(--accent)' }}
|
||||
>
|
||||
{result.deployment_url}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
{result.manual_deploy_required && (
|
||||
<p style={{ color: 'var(--warning)' }}>
|
||||
⚠️ 需要手动在 Vercel 控制台触发重新部署
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Status & Guide */}
|
||||
<div className="space-y-6">
|
||||
{result && (
|
||||
<div className={`p-6 rounded-xl border ${result.success ? 'bg-emerald-500/10 border-emerald-500/20' : 'bg-destructive/10 border-destructive/20'} animate-in fade-in slide-in-from-right-4`}>
|
||||
<div className="flex items-start gap-4">
|
||||
{result.success ? (
|
||||
<div className="p-2 bg-emerald-500 text-white rounded-full shadow-lg shadow-emerald-500/30">
|
||||
<CheckCircle2 className="w-6 h-6" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2 bg-destructive text-white rounded-full shadow-lg shadow-destructive/30">
|
||||
<XCircle className="w-6 h-6" />
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<h3 className={`font-semibold text-lg ${result.success ? 'text-emerald-500' : 'text-destructive'}`}>
|
||||
{result.success ? 'Sync Successful' : 'Sync Failed'}
|
||||
</h3>
|
||||
<p className="text-sm opacity-90">{result.message}</p>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-title" style={{ marginBottom: '1rem' }}>📖 使用说明</div>
|
||||
<ol style={{ paddingLeft: '1.5rem', color: 'var(--text-secondary)' }}>
|
||||
<li style={{ marginBottom: '0.5rem' }}>
|
||||
前往 <a href="https://vercel.com/account/tokens" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--accent)' }}>Vercel Token 页面</a> 创建一个新 Token
|
||||
</li>
|
||||
<li style={{ marginBottom: '0.5rem' }}>
|
||||
在 Vercel 项目设置中找到 Project ID(Settings → General → Project ID)
|
||||
</li>
|
||||
<li style={{ marginBottom: '0.5rem' }}>
|
||||
如果是团队项目,还需要填写 Team ID
|
||||
</li>
|
||||
<li style={{ marginBottom: '0.5rem' }}>
|
||||
点击同步按钮,配置将自动更新到 Vercel 环境变量并触发重新部署
|
||||
</li>
|
||||
</ol>
|
||||
{result.deployment_url && (
|
||||
<div className="pt-3 mt-3 border-t border-emerald-500/20">
|
||||
<a href={`https://${result.deployment_url}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-sm font-medium hover:underline">
|
||||
Visit Deployment <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-secondary/20 border border-border rounded-xl p-6">
|
||||
<h3 className="font-semibold flex items-center gap-2 mb-4">
|
||||
<Info className="w-5 h-5 text-primary" />
|
||||
How it works
|
||||
</h3>
|
||||
<ul className="space-y-4">
|
||||
<li className="flex gap-3">
|
||||
<span className="shrink-0 w-6 h-6 rounded-full bg-background border border-border flex items-center justify-center text-xs font-bold text-muted-foreground">1</span>
|
||||
<p className="text-sm text-muted-foreground">Current configuration (Keys & Accounts) is exported to a JSON string.</p>
|
||||
</li>
|
||||
<li className="flex gap-3">
|
||||
<span className="shrink-0 w-6 h-6 rounded-full bg-background border border-border flex items-center justify-center text-xs font-bold text-muted-foreground">2</span>
|
||||
<p className="text-sm text-muted-foreground">The JSON is encoded to Base64 to ensure format compatibility.</p>
|
||||
</li>
|
||||
<li className="flex gap-3">
|
||||
<span className="shrink-0 w-6 h-6 rounded-full bg-background border border-border flex items-center justify-center text-xs font-bold text-muted-foreground">3</span>
|
||||
<p className="text-sm text-muted-foreground">We update the <code className="bg-background px-1 py-0.5 rounded border border-border text-xs">DS2API_CONFIG_JSON</code> env variable in your Vercel project.</p>
|
||||
</li>
|
||||
<li className="flex gap-3">
|
||||
<span className="shrink-0 w-6 h-6 rounded-full bg-background border border-border flex items-center justify-center text-xs font-bold text-muted-foreground">4</span>
|
||||
<p className="text-sm text-muted-foreground">A redeployment is triggered to apply the new environment variables.</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,569 +1,172 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Custom CSS Variables for Design Tokens */
|
||||
:root {
|
||||
--bg-primary: #0f0f0f;
|
||||
--bg-secondary: #1a1a1a;
|
||||
--bg-tertiary: #242424;
|
||||
--text-primary: #e5e5e5;
|
||||
--text-secondary: #a0a0a0;
|
||||
--accent: #3b82f6;
|
||||
--accent-hover: #2563eb;
|
||||
--success: #22c55e;
|
||||
--error: #ef4444;
|
||||
--warning: #f59e0b;
|
||||
--border: #333;
|
||||
--radius: 8px;
|
||||
--shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
||||
--color-border: hsl(217.2 32.6% 17.5%);
|
||||
--color-input: hsl(217.2 32.6% 17.5%);
|
||||
--color-ring: hsl(212.7 26.8% 83.9%);
|
||||
--color-background: hsl(222.2 84% 4.9%);
|
||||
--color-foreground: hsl(210 40% 98%);
|
||||
|
||||
--color-primary: hsl(210 40% 98%);
|
||||
--color-primary-foreground: hsl(222.2 47.4% 11.2%);
|
||||
|
||||
--color-secondary: hsl(217.2 32.6% 17.5%);
|
||||
--color-secondary-foreground: hsl(210 40% 98%);
|
||||
|
||||
--color-destructive: hsl(0 62.8% 30.6%);
|
||||
--color-destructive-foreground: hsl(210 40% 98%);
|
||||
|
||||
--color-muted: hsl(217.2 32.6% 17.5%);
|
||||
--color-muted-foreground: hsl(215 20.2% 65.1%);
|
||||
|
||||
--color-accent: hsl(217.2 32.6% 17.5%);
|
||||
--color-accent-foreground: hsl(210 40% 98%);
|
||||
|
||||
--color-popover: hsl(222.2 84% 4.9%);
|
||||
--color-popover-foreground: hsl(210 40% 98%);
|
||||
|
||||
--color-card: hsl(222.2 84% 4.9%);
|
||||
--color-card-foreground: hsl(210 40% 98%);
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
/* Base styles */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
font-feature-settings: "rlig" 1, "calt" 1;
|
||||
}
|
||||
|
||||
.app {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2rem;
|
||||
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-muted);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.header p {
|
||||
color: var(--text-secondary);
|
||||
margin-top: 0.5rem;
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-muted-foreground);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.95rem;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.form-input::placeholder {
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
textarea.form-input {
|
||||
min-height: 120px;
|
||||
resize: vertical;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 0.85rem;
|
||||
/* Custom scrollbar utility */
|
||||
.custom-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-muted) transparent;
|
||||
}
|
||||
|
||||
/* Button components */
|
||||
.btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #333;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background: #16a34a;
|
||||
.btn:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
.btn-primary {
|
||||
background-color: var(--color-primary);
|
||||
color: var(--color-primary-foreground);
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
.btn-primary:hover {
|
||||
background-color: rgba(248, 250, 252, 0.9);
|
||||
}
|
||||
|
||||
.list-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius);
|
||||
.btn-primary:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.list-item-text {
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 0.9rem;
|
||||
.btn-secondary {
|
||||
background-color: var(--color-secondary);
|
||||
color: var(--color-secondary-foreground);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.list-item-info {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
.btn-secondary:hover {
|
||||
background-color: var(--color-accent);
|
||||
color: var(--color-accent-foreground);
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
.btn-danger {
|
||||
background-color: var(--color-destructive);
|
||||
color: var(--color-destructive-foreground);
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background: rgba(245, 158, 11, 0.2);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.badge-error {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 1rem;
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
border: 1px solid var(--success);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid var(--error);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border: 1px solid var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.code-block {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem;
|
||||
overflow-x: auto;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 0.85rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: inline-block;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: white;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.5rem;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.grid-2 {
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.stat {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Queue Status */
|
||||
.queue-status {
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.stat-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-success {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.stat-warning {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.stat-detail {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
}
|
||||
|
||||
/* Button Group Inline */
|
||||
.btn-group-inline {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
.btn-danger:hover {
|
||||
background-color: rgba(127, 29, 29, 0.9);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.4rem 0.8rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Token Preview */
|
||||
.token-preview {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
background: var(--bg-tertiary);
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: var(--radius);
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* Batch Progress */
|
||||
.batch-progress {
|
||||
margin: 1rem 0;
|
||||
padding: 1rem;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.progress-header {
|
||||
/* Input field */
|
||||
.input-field {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 8px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--primary), var(--primary-hover));
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-results {
|
||||
margin-top: 0.75rem;
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.progress-result {
|
||||
padding: 0.25rem 0;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
}
|
||||
|
||||
.progress-result.success {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.progress-result.failed {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
/* Login Page */
|
||||
.login-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 70vh;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 2.5rem;
|
||||
height: 2.5rem;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
box-shadow: var(--shadow);
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--color-input);
|
||||
background-color: rgba(30, 41, 59, 0.3);
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
.input-field::placeholder {
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
|
||||
.login-header h1 {
|
||||
font-size: 1.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
.input-field:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--color-ring);
|
||||
}
|
||||
|
||||
.login-header p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
.input-field:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
margin-top: 1.5rem;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.app {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tab {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
/* Card */
|
||||
.card {
|
||||
background-color: var(--color-card);
|
||||
color: var(--color-card-foreground);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
}
|
||||
52
webui/tailwind.config.js
Normal file
52
webui/tailwind.config.js
Normal file
@@ -0,0 +1,52 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
border: "var(--color-border)",
|
||||
input: "var(--color-input)",
|
||||
ring: "var(--color-ring)",
|
||||
background: "var(--color-background)",
|
||||
foreground: "var(--color-foreground)",
|
||||
primary: {
|
||||
DEFAULT: "var(--color-primary)",
|
||||
foreground: "var(--color-primary-foreground)",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "var(--color-secondary)",
|
||||
foreground: "var(--color-secondary-foreground)",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "var(--color-destructive)",
|
||||
foreground: "var(--color-destructive-foreground)",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "var(--color-muted)",
|
||||
foreground: "var(--color-muted-foreground)",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "var(--color-accent)",
|
||||
foreground: "var(--color-accent-foreground)",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "var(--color-popover)",
|
||||
foreground: "var(--color-popover-foreground)",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "var(--color-card)",
|
||||
foreground: "var(--color-card-foreground)",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -2,7 +2,9 @@ import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [
|
||||
react(),
|
||||
],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
|
||||
Reference in New Issue
Block a user