refactor: migrate UI to Tailwind CSS for a modernized design system.

This commit is contained in:
CJACK
2026-02-01 05:33:50 +08:00
parent 1d1361616b
commit 1f6043ff70
12 changed files with 1087 additions and 1174 deletions

View File

@@ -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>

View File

@@ -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
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -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>
)
}

View File

@@ -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)}>&times;</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)}>&times;</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>
)}

View File

@@ -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 或回退到普通 fetchadmin API 用 authFetchOpenAI 兼容 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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>

View File

@@ -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 IDSettings 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>
)

View File

@@ -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
View 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: [],
}

View File

@@ -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: {