mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-08 02:15:27 +08:00
refactor: migrate UI to Tailwind CSS for a modernized design system.
This commit is contained in:
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user