feat: Implement DeepSeek integration, refactor model adapters for streaming and tool calls, enhance admin and account management, and introduce new UI features for settings, API testing, and Vercel sync.

This commit is contained in:
CJACK
2026-02-22 17:25:48 +08:00
parent 5d3989a9a7
commit 6c48429b90
152 changed files with 13583 additions and 11817 deletions

View File

@@ -1,346 +1,3 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import {
Routes,
Route,
Navigate,
useNavigate,
useLocation
} from 'react-router-dom'
import {
LayoutDashboard,
Key,
Upload,
Cloud,
Settings as SettingsIcon,
LogOut,
Menu,
X,
Server,
Users
} from 'lucide-react'
import clsx from 'clsx'
import AppRoutes from './app/AppRoutes'
import AccountManager from './components/AccountManager'
import ApiTester from './components/ApiTester'
import BatchImport from './components/BatchImport'
import VercelSync from './components/VercelSync'
import Settings from './components/Settings'
import Login from './components/Login'
import LandingPage from './components/LandingPage'
import LanguageToggle from './components/LanguageToggle'
import { useI18n } from './i18n'
import { detectRuntimeEnv } from './utils/runtimeEnv'
function Dashboard({ token, onLogout, config, fetchConfig, showMessage, message, onForceLogout, isVercel }) {
const { t } = useI18n()
const [activeTab, setActiveTab] = useState('accounts')
const [sidebarOpen, setSidebarOpen] = useState(false)
const navItems = [
{ id: 'accounts', label: t('nav.accounts.label'), icon: Users, description: t('nav.accounts.desc') },
{ id: 'test', label: t('nav.test.label'), icon: Server, description: t('nav.test.desc') },
{ id: 'import', label: t('nav.import.label'), icon: Upload, description: t('nav.import.desc') },
{ id: 'vercel', label: t('nav.vercel.label'), icon: Cloud, description: t('nav.vercel.desc') },
{ id: 'settings', label: t('nav.settings.label'), icon: SettingsIcon, description: t('nav.settings.desc') },
]
const authFetch = useCallback(async (url, options = {}) => {
const headers = {
...options.headers,
'Authorization': `Bearer ${token}`
}
const res = await fetch(url, { ...options, headers })
if (res.status === 401) {
onLogout()
throw new Error(t('auth.expired'))
}
return res
}, [onLogout, t, token])
const renderTab = () => {
switch (activeTab) {
case 'accounts':
return <AccountManager config={config} onRefresh={fetchConfig} onMessage={showMessage} authFetch={authFetch} />
case 'test':
return <ApiTester config={config} onMessage={showMessage} authFetch={authFetch} />
case 'import':
return <BatchImport onRefresh={fetchConfig} onMessage={showMessage} authFetch={authFetch} />
case 'vercel':
return <VercelSync onMessage={showMessage} authFetch={authFetch} isVercel={isVercel} />
case 'settings':
return <Settings onRefresh={fetchConfig} onMessage={showMessage} authFetch={authFetch} onForceLogout={onForceLogout} isVercel={isVercel} />
default:
return null
}
}
return (
<div className="flex h-screen bg-background overflow-hidden text-foreground">
{sidebarOpen && (
<div
className="fixed inset-0 bg-background/80 backdrop-blur-sm z-40 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
<aside className={clsx(
"fixed lg:static inset-y-0 left-0 z-50 w-64 bg-card border-r border-border transition-transform duration-300 ease-in-out lg:transform-none flex flex-col shadow-2xl lg:shadow-none",
sidebarOpen ? "translate-x-0" : "-translate-x-full"
)}>
<div className="p-6">
<div className="flex items-center gap-2.5 font-bold text-xl text-foreground tracking-tight">
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center text-primary-foreground shadow-lg shadow-primary/20">
<LayoutDashboard className="w-5 h-5" />
</div>
<span>DS2API</span>
</div>
<div className="flex items-center justify-between mt-2">
<p className="text-[10px] text-muted-foreground font-semibold tracking-[0.1em] uppercase opacity-60 px-1">{t('sidebar.onlineAdminConsole')}</p>
<LanguageToggle />
</div>
</div>
<nav className="flex-1 px-3 space-y-1 overflow-y-auto pt-2">
{navItems.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 border",
isActive
? "bg-secondary text-primary border-border shadow-sm"
: "text-muted-foreground border-transparent hover:bg-secondary/80 hover:text-foreground"
)}
>
<Icon className={clsx("w-4 h-4 transition-colors", isActive ? "text-primary" : "text-muted-foreground group-hover:text-foreground")} />
<span className="flex-1 text-left">{item.label}</span>
{isActive && <div className="w-1.5 h-1.5 rounded-full bg-primary" />}
</button>
)
})}
</nav>
<div className="p-4 border-t border-border bg-card">
<div className="space-y-4">
<div className="flex items-center justify-between text-sm px-1">
<span className="text-muted-foreground font-semibold text-[10px] uppercase tracking-wider">{t('sidebar.systemStatus')}</span>
<span className="flex items-center gap-1.5 text-[10px] font-bold text-emerald-500 bg-emerald-500/10 px-2 py-0.5 rounded-full border border-emerald-500/20">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse"></span>
{t('sidebar.statusOnline')}
</span>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="bg-background rounded-lg p-3 border border-border shadow-sm">
<div className="text-[9px] text-muted-foreground font-bold uppercase tracking-wider mb-0.5 opacity-70">{t('sidebar.accounts')}</div>
<div className="text-lg font-bold text-foreground leading-tight">{config.accounts?.length || 0}</div>
</div>
<div className="bg-background rounded-lg p-3 border border-border shadow-sm">
<div className="text-[9px] text-muted-foreground font-bold uppercase tracking-wider mb-0.5 opacity-70">{t('sidebar.keys')}</div>
<div className="text-lg font-bold text-foreground">{config.keys?.length || 0}</div>
</div>
</div>
<button
onClick={onLogout}
className="w-full h-10 flex items-center justify-center gap-2 rounded-lg border border-border text-xs font-medium text-muted-foreground hover:bg-destructive/10 hover:text-destructive hover:border-destructive/20 transition-all"
>
<LogOut className="w-3.5 h-3.5" />
{t('sidebar.signOut')}
</button>
</div>
</div>
</aside>
<main className="flex-1 flex flex-col min-w-0 overflow-hidden relative">
<header className="lg:hidden h-14 flex items-center justify-between px-4 border-b border-border bg-card">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded bg-primary flex items-center justify-center text-primary-foreground text-[10px]">
<LayoutDashboard className="w-3.5 h-3.5" />
</div>
<span className="font-semibold text-sm">DS2API</span>
</div>
<div className="flex items-center gap-2">
<LanguageToggle />
<button
onClick={() => setSidebarOpen(true)}
className="p-2 -mr-2 text-muted-foreground hover:text-foreground"
>
<Menu className="w-5 h-5" />
</button>
</div>
</header>
<div className="flex-1 overflow-auto bg-background p-4 lg:p-10">
<div className="max-w-6xl mx-auto space-y-4 lg:space-y-6">
<div className="hidden lg:block mb-8">
<h1 className="text-3xl font-bold tracking-tight mb-2">
{navItems.find(n => n.id === activeTab)?.label}
</h1>
<p className="text-muted-foreground">
{navItems.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">
{renderTab()}
</div>
</div>
</div>
</main>
</div>
)
}
export default function App() {
const { t } = useI18n()
const navigate = useNavigate()
const location = useLocation()
const [config, setConfig] = useState({ keys: [], accounts: [] })
const [message, setMessage] = useState(null)
const [token, setToken] = useState(null)
const [authChecking, setAuthChecking] = useState(true)
const isProduction = import.meta.env.MODE === 'production'
const isAdminRoute = location.pathname.startsWith('/admin') || isProduction
const runtimeEnv = useMemo(() => detectRuntimeEnv(), [])
const isVercel = runtimeEnv.isVercel
const showMessage = useCallback((type, text) => {
setMessage({ type, text })
setTimeout(() => setMessage(null), 5000)
}, [])
const handleLogout = useCallback(() => {
setToken(null)
localStorage.removeItem('ds2api_token')
localStorage.removeItem('ds2api_token_expires')
sessionStorage.removeItem('ds2api_token')
sessionStorage.removeItem('ds2api_token_expires')
}, [])
useEffect(() => {
// Only check auth status on admin routes.
if (!isAdminRoute) {
setAuthChecking(false)
return
}
const checkAuth = async () => {
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()) {
try {
const res = await fetch('/admin/verify', {
headers: { 'Authorization': `Bearer ${storedToken}` }
})
if (res.ok) {
setToken(storedToken)
} else {
handleLogout()
}
} catch {
setToken(storedToken)
}
}
setAuthChecking(false)
}
checkAuth()
}, [handleLogout, isAdminRoute])
const fetchConfig = useCallback(async () => {
if (!token) return
try {
const res = await fetch('/admin/config', {
headers: { 'Authorization': `Bearer ${token}` }
})
if (res.ok) {
const data = await res.json()
setConfig(data)
}
} catch (e) {
console.error('Failed to fetch config:', e)
showMessage('error', t('errors.fetchConfig', { error: e.message }))
}
}, [showMessage, t, token])
useEffect(() => {
if (token) {
fetchConfig()
}
}, [fetchConfig, token])
const handleLogin = (newToken) => {
setToken(newToken)
}
// Wait for auth checks on admin routes.
if (isAdminRoute && authChecking) {
return (
<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">{t('auth.checking')}</p>
</div>
</div>
)
}
return (
<Routes>
{!isProduction && (
<Route path="/" element={<LandingPage onEnter={() => navigate('/admin')} />} />
)}
<Route path={isProduction ? "/" : "/admin"} element={
token ? (
<Dashboard
token={token}
onLogout={handleLogout}
config={config}
fetchConfig={fetchConfig}
showMessage={showMessage}
message={message}
onForceLogout={handleLogout}
isVercel={isVercel}
/>
) : (
<div className="min-h-screen flex flex-col bg-background relative overflow-hidden">
<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={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>
)}
<Login onLogin={handleLogin} onMessage={showMessage} />
</div>
)
} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)
}
export default AppRoutes

View File

@@ -0,0 +1,84 @@
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'
import clsx from 'clsx'
import LandingPage from '../components/LandingPage'
import Login from '../components/Login'
import DashboardShell from '../layout/DashboardShell'
import { useI18n } from '../i18n'
import { useAdminAuth } from './useAdminAuth'
import { useAdminConfig } from './useAdminConfig'
export default function AppRoutes() {
const { t } = useI18n()
const navigate = useNavigate()
const location = useLocation()
const isProduction = import.meta.env.MODE === 'production'
const {
token,
authChecking,
message,
isAdminRoute,
isVercel,
showMessage,
handleLogin,
handleLogout,
} = useAdminAuth({ isProduction, location, t })
const {
config,
fetchConfig,
} = useAdminConfig({ token, showMessage, t })
if (isAdminRoute && authChecking) {
return (
<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">{t('auth.checking')}</p>
</div>
</div>
)
}
return (
<Routes>
{!isProduction && (
<Route path="/" element={<LandingPage onEnter={() => navigate('/admin')} />} />
)}
<Route path={isProduction ? "/" : "/admin"} element={
token ? (
<DashboardShell
token={token}
onLogout={handleLogout}
config={config}
fetchConfig={fetchConfig}
showMessage={showMessage}
message={message}
onForceLogout={handleLogout}
isVercel={isVercel}
/>
) : (
<div className="min-h-screen flex flex-col bg-background relative overflow-hidden">
<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={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>
)}
<Login onLogin={handleLogin} onMessage={showMessage} />
</div>
)
} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)
}

View File

@@ -0,0 +1,70 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { detectRuntimeEnv } from '../utils/runtimeEnv'
export function useAdminAuth({ isProduction, location, t }) {
const [message, setMessage] = useState(null)
const [token, setToken] = useState(null)
const [authChecking, setAuthChecking] = useState(true)
const isAdminRoute = location.pathname.startsWith('/admin') || isProduction
const runtimeEnv = useMemo(() => detectRuntimeEnv(), [])
const isVercel = runtimeEnv.isVercel
const showMessage = useCallback((type, text) => {
setMessage({ type, text })
setTimeout(() => setMessage(null), 5000)
}, [])
const handleLogout = useCallback(() => {
setToken(null)
localStorage.removeItem('ds2api_token')
localStorage.removeItem('ds2api_token_expires')
sessionStorage.removeItem('ds2api_token')
sessionStorage.removeItem('ds2api_token_expires')
}, [])
const handleLogin = useCallback((newToken) => {
setToken(newToken)
}, [])
useEffect(() => {
if (!isAdminRoute) {
setAuthChecking(false)
return
}
const checkAuth = async () => {
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()) {
try {
const res = await fetch('/admin/verify', {
headers: { 'Authorization': `Bearer ${storedToken}` }
})
if (res.ok) {
setToken(storedToken)
} else {
handleLogout()
}
} catch {
setToken(storedToken)
}
}
setAuthChecking(false)
}
checkAuth()
}, [handleLogout, isAdminRoute, t])
return {
token,
authChecking,
message,
isAdminRoute,
isVercel,
showMessage,
handleLogin,
handleLogout,
}
}

View File

@@ -0,0 +1,32 @@
import { useCallback, useEffect, useState } from 'react'
export function useAdminConfig({ token, showMessage, t }) {
const [config, setConfig] = useState({ keys: [], accounts: [] })
const fetchConfig = useCallback(async () => {
if (!token) return
try {
const res = await fetch('/admin/config', {
headers: { 'Authorization': `Bearer ${token}` }
})
if (res.ok) {
const data = await res.json()
setConfig(data)
}
} catch (e) {
console.error('Failed to fetch config:', e)
showMessage('error', t('errors.fetchConfig', { error: e.message }))
}
}, [showMessage, t, token])
useEffect(() => {
if (token) {
fetchConfig()
}
}, [fetchConfig, token])
return {
config,
fetchConfig,
}
}

View File

@@ -1,578 +1,3 @@
import { useState, useEffect } from 'react'
import {
Plus,
Trash2,
CheckCircle2,
Play,
X,
Server,
ShieldCheck,
Copy,
Check,
ChevronLeft,
ChevronRight,
ChevronDown
} from 'lucide-react'
import clsx from 'clsx'
import { useI18n } from '../i18n'
import AccountManagerContainer from '../features/account/AccountManagerContainer'
export default function AccountManager({ config, onRefresh, onMessage, authFetch }) {
const { t } = useI18n()
const [showAddKey, setShowAddKey] = useState(false)
const [showAddAccount, setShowAddAccount] = useState(false)
const [newKey, setNewKey] = useState('')
const [copiedKey, setCopiedKey] = useState(null)
const [newAccount, setNewAccount] = useState({ email: '', mobile: '', password: '' })
const [loading, setLoading] = useState(false)
const [testing, setTesting] = useState({})
const [testingAll, setTestingAll] = useState(false)
const [batchProgress, setBatchProgress] = useState({ current: 0, total: 0, results: [] })
const [queueStatus, setQueueStatus] = useState(null)
const [keysExpanded, setKeysExpanded] = useState(false)
// 分页状态
const [accounts, setAccounts] = useState([])
const [page, setPage] = useState(1)
const [pageSize] = useState(10)
const [totalPages, setTotalPages] = useState(1)
const [totalAccounts, setTotalAccounts] = useState(0)
const [loadingAccounts, setLoadingAccounts] = useState(false)
const apiFetch = authFetch || fetch
const resolveAccountIdentifier = (acc) => {
if (!acc || typeof acc !== 'object') return ''
return String(acc.identifier || acc.email || acc.mobile || '').trim()
}
const fetchAccounts = async (targetPage = page) => {
setLoadingAccounts(true)
try {
const res = await apiFetch(`/admin/accounts?page=${targetPage}&page_size=${pageSize}`)
if (res.ok) {
const data = await res.json()
setAccounts(data.items || [])
setTotalPages(data.total_pages || 1)
setTotalAccounts(data.total || 0)
setPage(data.page || 1)
}
} catch (e) {
console.error('Failed to fetch accounts:', e)
} finally {
setLoadingAccounts(false)
}
}
const fetchQueueStatus = async () => {
try {
const res = await apiFetch('/admin/queue/status')
if (res.ok) {
const data = await res.json()
setQueueStatus(data)
}
} catch (e) {
console.error('Failed to fetch queue status:', e)
}
}
useEffect(() => {
fetchAccounts()
fetchQueueStatus()
const interval = setInterval(fetchQueueStatus, 5000)
return () => clearInterval(interval)
}, [])
const addKey = async () => {
if (!newKey.trim()) return
setLoading(true)
try {
const res = await apiFetch('/admin/keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: newKey.trim() }),
})
if (res.ok) {
onMessage('success', t('accountManager.addKeySuccess'))
setNewKey('')
setShowAddKey(false)
onRefresh()
} else {
const data = await res.json()
onMessage('error', data.detail || t('messages.failedToAdd'))
}
} catch (e) {
onMessage('error', t('messages.networkError'))
} finally {
setLoading(false)
}
}
const deleteKey = async (key) => {
if (!confirm(t('accountManager.deleteKeyConfirm'))) return
try {
const res = await apiFetch(`/admin/keys/${encodeURIComponent(key)}`, { method: 'DELETE' })
if (res.ok) {
onMessage('success', t('messages.deleted'))
onRefresh()
} else {
onMessage('error', t('messages.deleteFailed'))
}
} catch (e) {
onMessage('error', t('messages.networkError'))
}
}
const addAccount = async () => {
if (!newAccount.password || (!newAccount.email && !newAccount.mobile)) {
onMessage('error', t('accountManager.requiredFields'))
return
}
setLoading(true)
try {
const res = await apiFetch('/admin/accounts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newAccount),
})
if (res.ok) {
onMessage('success', t('accountManager.addAccountSuccess'))
setNewAccount({ email: '', mobile: '', password: '' })
setShowAddAccount(false)
fetchAccounts(1) // 添加后回到第一页
onRefresh()
} else {
const data = await res.json()
onMessage('error', data.detail || t('messages.failedToAdd'))
}
} catch (e) {
onMessage('error', t('messages.networkError'))
} finally {
setLoading(false)
}
}
const deleteAccount = async (id) => {
const identifier = String(id || '').trim()
if (!identifier) {
onMessage('error', t('accountManager.invalidIdentifier'))
return
}
if (!confirm(t('accountManager.deleteAccountConfirm'))) return
try {
const res = await apiFetch(`/admin/accounts/${encodeURIComponent(identifier)}`, { method: 'DELETE' })
if (res.ok) {
onMessage('success', t('messages.deleted'))
fetchAccounts() // 刷新当前页
onRefresh()
} else {
onMessage('error', t('messages.deleteFailed'))
}
} catch (e) {
onMessage('error', t('messages.networkError'))
}
}
const testAccount = async (identifier) => {
const accountID = String(identifier || '').trim()
if (!accountID) {
onMessage('error', t('accountManager.invalidIdentifier'))
return
}
setTesting(prev => ({ ...prev, [accountID]: true }))
try {
const res = await apiFetch('/admin/accounts/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier: accountID }),
})
const data = await res.json()
const statusMessage = data.success
? t('apiTester.testSuccess', { account: accountID, time: data.response_time })
: `${accountID}: ${data.message}`
onMessage(data.success ? 'success' : 'error', statusMessage)
fetchAccounts() // 刷新当前页
onRefresh()
} catch (e) {
onMessage('error', t('accountManager.testFailed', { error: e.message }))
} finally {
setTesting(prev => ({ ...prev, [accountID]: false }))
}
}
const testAllAccounts = async () => {
if (!confirm(t('accountManager.testAllConfirm'))) return
const allAccounts = config.accounts || []
if (allAccounts.length === 0) return
setTestingAll(true)
setBatchProgress({ current: 0, total: allAccounts.length, results: [] })
let successCount = 0
const results = []
for (let i = 0; i < allAccounts.length; i++) {
const acc = allAccounts[i]
const id = resolveAccountIdentifier(acc)
if (!id) {
results.push({ id: '-', success: false, message: t('accountManager.invalidIdentifier') })
setBatchProgress({ current: i + 1, total: allAccounts.length, results: [...results] })
continue
}
try {
const res = await apiFetch('/admin/accounts/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier: id }),
})
const data = await res.json()
results.push({ id, success: data.success, message: data.message, time: data.response_time })
if (data.success) successCount++
} catch (e) {
results.push({ id, success: false, message: e.message })
}
setBatchProgress({ current: i + 1, total: allAccounts.length, results: [...results] })
}
onMessage('success', t('accountManager.testAllCompleted', { success: successCount, total: allAccounts.length }))
fetchAccounts() // 刷新当前页
onRefresh()
setTestingAll(false)
}
return (
<div className="space-y-6">
{/* Queue Status - Flat & Clean */}
{
queueStatus && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-card border border-border rounded-xl p-4 flex flex-col justify-between shadow-sm relative overflow-hidden group">
<div className="absolute right-0 top-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
<CheckCircle2 className="w-16 h-16" />
</div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-widest">{t('accountManager.available')}</p>
<div className="mt-2 flex items-baseline gap-2">
<span className="text-3xl font-bold text-foreground">{queueStatus.available}</span>
<span className="text-xs text-muted-foreground">{t('accountManager.accountsUnit')}</span>
</div>
</div>
<div className="bg-card border border-border rounded-xl p-4 flex flex-col justify-between shadow-sm relative overflow-hidden group">
<div className="absolute right-0 top-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
<Server className="w-16 h-16" />
</div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-widest">{t('accountManager.inUse')}</p>
<div className="mt-2 flex items-baseline gap-2">
<span className="text-3xl font-bold text-foreground">{queueStatus.in_use}</span>
<span className="text-xs text-muted-foreground">{t('accountManager.threadsUnit')}</span>
</div>
</div>
<div className="bg-card border border-border rounded-xl p-4 flex flex-col justify-between shadow-sm relative overflow-hidden group">
<div className="absolute right-0 top-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
<ShieldCheck className="w-16 h-16" />
</div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-widest">{t('accountManager.totalPool')}</p>
<div className="mt-2 flex items-baseline gap-2">
<span className="text-3xl font-bold text-foreground">{queueStatus.total}</span>
<span className="text-xs text-muted-foreground">{t('accountManager.accountsUnit')}</span>
</div>
</div>
</div>
)
}
{/* API Keys Section */}
<div className="bg-card border border-border rounded-xl overflow-hidden shadow-sm">
<div
className="p-6 flex flex-col md:flex-row md:items-center justify-between gap-4 cursor-pointer select-none hover:bg-muted/30 transition-colors"
onClick={() => setKeysExpanded(!keysExpanded)}
>
<div className="flex items-center gap-3">
<ChevronDown className={clsx(
"w-5 h-5 text-muted-foreground transition-transform duration-200",
keysExpanded ? "rotate-0" : "-rotate-90"
)} />
<div>
<h2 className="text-lg font-semibold">{t('accountManager.apiKeysTitle')}</h2>
<p className="text-sm text-muted-foreground">{t('accountManager.apiKeysDesc')} ({config.keys?.length || 0})</p>
</div>
</div>
<button
onClick={(e) => { e.stopPropagation(); setShowAddKey(true) }}
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors font-medium text-sm shadow-sm"
>
<Plus className="w-4 h-4" />
{t('accountManager.addKey')}
</button>
</div>
{keysExpanded && (
<div className="divide-y divide-border border-t border-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="flex items-center gap-2">
<div className="font-mono text-sm bg-muted/50 px-3 py-1 rounded inline-block">
{key.slice(0, 16)}****
</div>
{copiedKey === key && (
<span className="text-xs text-green-500 animate-pulse">{t('accountManager.copied')}</span>
)}
</div>
<div className="flex items-center gap-1">
<button
onClick={() => {
navigator.clipboard.writeText(key)
setCopiedKey(key)
setTimeout(() => setCopiedKey(null), 2000)
}}
className="p-2 text-muted-foreground hover:text-primary hover:bg-primary/10 rounded-md transition-colors opacity-0 group-hover:opacity-100"
title={t('accountManager.copyKeyTitle')}
>
{copiedKey === key ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
</button>
<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"
title={t('accountManager.deleteKeyTitle')}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))
) : (
<div className="p-8 text-center text-muted-foreground">{t('accountManager.noApiKeys')}</div>
)}
</div>
)}
</div>
{/* 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">{t('accountManager.accountsTitle')}</h2>
<p className="text-sm text-muted-foreground">{t('accountManager.accountsDesc')}</p>
</div>
<div className="flex flex-wrap gap-2">
<button
onClick={testAllAccounts}
disabled={testingAll || totalAccounts === 0}
className="flex items-center px-3 py-2 bg-secondary text-secondary-foreground rounded-lg hover:bg-secondary/80 transition-colors text-xs font-medium border border-border disabled:opacity-50"
>
{testingAll ? <span className="animate-spin mr-2"></span> : <Play className="w-3 h-3 mr-2" />}
{t('accountManager.testAll')}
</button>
<button
onClick={() => setShowAddAccount(true)}
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors font-medium text-sm shadow-sm"
>
<Plus className="w-4 h-4" />
{t('accountManager.addAccount')}
</button>
</div>
</div>
{/* Batch Progress */}
{testingAll && batchProgress.total > 0 && (
<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">{t('accountManager.testingAllAccounts')}</span>
<span className="text-muted-foreground">{batchProgress.current} / {batchProgress.total}</span>
</div>
<div className="w-full bg-muted rounded-full h-2 overflow-hidden mb-4">
<div
className="bg-primary h-full transition-all duration-300"
style={{ width: `${(batchProgress.current / batchProgress.total) * 100}%` }}
/>
</div>
{batchProgress.results.length > 0 && (
<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={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>
)}
</div>
)}
<div className="divide-y divide-border">
{loadingAccounts ? (
<div className="p-8 text-center text-muted-foreground">{t('actions.loading')}</div>
) : accounts.length > 0 ? (
accounts.map((acc, i) => {
const id = resolveAccountIdentifier(acc)
return (
<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 ? t('accountManager.sessionActive') : t('accountManager.reauthRequired')}</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="flex items-center gap-2 self-start lg:self-auto ml-5 lg:ml-0">
<button
onClick={() => testAccount(id)}
disabled={testing[id]}
className="px-2 lg:px-3 py-1 lg:py-1.5 text-[10px] lg:text-xs font-medium border border-border rounded-md hover:bg-secondary transition-colors disabled:opacity-50"
>
{testing[id] ? t('actions.testing') : t('actions.test')}
</button>
<button
onClick={() => deleteAccount(id)}
className="p-1 lg:p-1.5 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-md transition-colors"
>
<Trash2 className="w-3.5 h-3.5 lg:w-4 lg:h-4" />
</button>
</div>
</div>
)
})
) : (
<div className="p-8 text-center text-muted-foreground">{t('accountManager.noAccounts')}</div>
)}
</div>
{/* 分页控件 */}
{totalPages > 1 && (
<div className="p-4 border-t border-border flex items-center justify-between">
<div className="text-sm text-muted-foreground">
{t('accountManager.pageInfo', { current: page, total: totalPages, count: totalAccounts })}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => fetchAccounts(page - 1)}
disabled={page <= 1 || loadingAccounts}
className="p-2 border border-border rounded-md hover:bg-secondary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-sm font-medium px-2">{page} / {totalPages}</span>
<button
onClick={() => fetchAccounts(page + 1)}
disabled={page >= totalPages || loadingAccounts}
className="p-2 border border-border rounded-md hover:bg-secondary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
)}
</div>
{/* Modals */}
{
showAddKey && (
<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">{t('accountManager.modalAddKeyTitle')}</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">{t('accountManager.newKeyLabel')}</label>
<div className="flex gap-2">
<input
type="text"
className="input-field bg-[#09090b] flex-1"
placeholder={t('accountManager.newKeyPlaceholder')}
value={newKey}
onChange={e => setNewKey(e.target.value)}
autoFocus
/>
<button
type="button"
onClick={() => setNewKey('sk-' + crypto.randomUUID().replace(/-/g, ''))}
className="px-3 py-2 bg-secondary text-secondary-foreground rounded-lg hover:bg-secondary/80 transition-colors text-sm font-medium border border-border whitespace-nowrap"
>
{t('accountManager.generate')}
</button>
</div>
<p className="text-xs text-muted-foreground mt-1.5">{t('accountManager.generateHint')}</p>
</div>
<div className="flex justify-end gap-2 pt-2">
<button onClick={() => setShowAddKey(false)} className="px-4 py-2 rounded-lg border border-border hover:bg-secondary transition-colors text-sm font-medium">{t('actions.cancel')}</button>
<button onClick={addKey} disabled={loading} className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors text-sm font-medium disabled:opacity-50">
{loading ? t('accountManager.addKeyLoading') : t('accountManager.addKeyAction')}
</button>
</div>
</div>
</div>
</div>
)
}
{
showAddAccount && (
<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">{t('accountManager.modalAddAccountTitle')}</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">{t('accountManager.emailOptional')}</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">{t('accountManager.mobileOptional')}</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">{t('accountManager.passwordLabel')} <span className="text-destructive">*</span></label>
<input
type="password"
className="input-field bg-[#09090b]"
placeholder={t('accountManager.passwordPlaceholder')}
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="px-4 py-2 rounded-lg border border-border hover:bg-secondary transition-colors text-sm font-medium">{t('actions.cancel')}</button>
<button onClick={addAccount} disabled={loading} className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors text-sm font-medium disabled:opacity-50">
{loading ? t('accountManager.addAccountLoading') : t('accountManager.addAccountAction')}
</button>
</div>
</div>
</div>
</div>
)
}
</div >
)
}
export default AccountManagerContainer

View File

@@ -1,447 +1,3 @@
import { useEffect, useRef, useState } from 'react'
import {
Send,
Square,
MessageSquare,
Cpu,
Search as SearchIcon,
Sparkles,
Bot,
User,
Loader2,
CheckCircle2,
AlertCircle,
ChevronDown,
ShieldCheck,
Terminal,
Zap,
ToggleLeft,
ToggleRight
} from 'lucide-react'
import clsx from 'clsx'
import { useI18n } from '../i18n'
import ApiTesterContainer from '../features/apiTester/ApiTesterContainer'
export default function ApiTester({ config, onMessage, authFetch }) {
const { t } = useI18n()
const [model, setModel] = useState('deepseek-chat')
const defaultMessage = t('apiTester.defaultMessage')
const [message, setMessage] = useState(defaultMessage)
const [apiKey, setApiKey] = useState('')
const [selectedAccount, setSelectedAccount] = useState('')
const [response, setResponse] = useState(null)
const [loading, setLoading] = useState(false)
const [streamingContent, setStreamingContent] = useState('')
const [streamingThinking, setStreamingThinking] = useState('')
const [isStreaming, setIsStreaming] = useState(false)
const [streamingMode, setStreamingMode] = useState(true)
const abortControllerRef = useRef(null)
const defaultMessageRef = useRef(defaultMessage)
const [sidebarOpen, setSidebarOpen] = useState(false)
const [configExpanded, setConfigExpanded] = useState(false)
const apiFetch = authFetch || fetch
const accounts = config.accounts || []
const resolveAccountIdentifier = (acc) => {
if (!acc || typeof acc !== 'object') return ''
return String(acc.identifier || acc.email || acc.mobile || '').trim()
}
const configuredKeys = config.keys || []
const trimmedApiKey = apiKey.trim()
const defaultKey = configuredKeys[0] || ''
const effectiveKey = trimmedApiKey || defaultKey
const customKeyActive = trimmedApiKey !== ''
const customKeyManaged = customKeyActive && configuredKeys.includes(trimmedApiKey)
const models = [
{ id: "deepseek-chat", name: "deepseek-chat", icon: MessageSquare, desc: t('apiTester.models.chat'), color: "text-amber-500" },
{ id: "deepseek-reasoner", name: "deepseek-reasoner", icon: Cpu, desc: t('apiTester.models.reasoner'), color: "text-amber-600" },
{ id: "deepseek-chat-search", name: "deepseek-chat-search", icon: SearchIcon, desc: t('apiTester.models.chatSearch'), color: "text-cyan-500" },
{ id: "deepseek-reasoner-search", name: "deepseek-reasoner-search", icon: SearchIcon, desc: t('apiTester.models.reasonerSearch'), color: "text-cyan-600" },
]
const stopGeneration = () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort()
abortControllerRef.current = null
}
setLoading(false)
setIsStreaming(false)
}
const extractErrorMessage = async (res) => {
let raw = ''
try {
raw = await res.text()
} catch {
return t('apiTester.requestFailed')
}
if (!raw) {
return t('apiTester.requestFailed')
}
try {
const data = JSON.parse(raw)
const fromErrorObject = data?.error?.message
const fromErrorString = typeof data?.error === 'string' ? data.error : ''
const detail = typeof data?.detail === 'string' ? data.detail : ''
const message = typeof data?.message === 'string' ? data.message : ''
return fromErrorObject || fromErrorString || detail || message || t('apiTester.requestFailed')
} catch {
return raw.length > 240 ? `${raw.slice(0, 240)}...` : raw
}
}
const runTest = async () => {
if (loading) return
const startedAt = Date.now()
setLoading(true)
setIsStreaming(true)
setResponse(null)
setStreamingContent('')
setStreamingThinking('')
abortControllerRef.current = new AbortController()
try {
if (!effectiveKey) {
onMessage('error', t('apiTester.missingApiKey'))
setLoading(false)
setIsStreaming(false)
return
}
const headers = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${effectiveKey}`,
}
if (selectedAccount) {
headers['X-Ds2-Target-Account'] = selectedAccount
}
const endpoint = streamingMode ? '/v1/chat/completions' : '/v1/chat/completions?__go=1'
const res = await fetch(endpoint, {
method: 'POST',
headers,
body: JSON.stringify({
model,
messages: [{ role: 'user', content: message }],
stream: streamingMode,
}),
signal: abortControllerRef.current.signal,
})
if (!res.ok) {
const errorMsg = await extractErrorMessage(res)
setResponse({ success: false, error: errorMsg })
onMessage('error', errorMsg)
setLoading(false)
setIsStreaming(false)
return
}
if (streamingMode) {
setResponse({ success: true, status_code: res.status })
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed || !trimmed.startsWith('data: ')) continue
const dataStr = trimmed.slice(6)
if (dataStr === '[DONE]') continue
try {
const json = JSON.parse(dataStr)
const choice = json.choices?.[0]
if (choice?.delta) {
const delta = choice.delta
if (delta.reasoning_content) {
setStreamingThinking(prev => prev + delta.reasoning_content)
}
if (delta.content) {
setStreamingContent(prev => prev + delta.content)
}
}
} catch (e) {
console.error('Invalid JSON hunk:', dataStr, e)
}
}
}
} else {
const data = await res.json()
setResponse({ success: true, status_code: res.status, ...data })
const elapsed = Math.max(0, Date.now() - startedAt)
onMessage('success', t('apiTester.testSuccess', { account: selectedAccount || 'Auto', time: elapsed }))
}
} catch (e) {
if (e.name === 'AbortError') {
onMessage('info', t('messages.generationStopped'))
} else {
onMessage('error', t('apiTester.networkError', { error: e.message }))
setResponse({ error: e.message, success: false })
}
} finally {
setLoading(false)
setIsStreaming(false)
abortControllerRef.current = null
}
}
useEffect(() => {
setMessage((prev) => (prev === defaultMessageRef.current ? defaultMessage : prev))
defaultMessageRef.current = defaultMessage
}, [defaultMessage])
return (
<div className="flex flex-col lg:grid lg:grid-cols-12 gap-6 h-[calc(100vh-140px)]">
{/* Configuration Panel */}
<div className={clsx(
"lg:col-span-3 flex flex-col transition-all duration-300 ease-in-out z-20",
configExpanded ? "h-auto" : "h-14 lg:h-full"
)}>
<div className="bg-card border border-border rounded-xl flex flex-col h-full shadow-sm">
{/* Mobile Toggle Header */}
<button
onClick={() => setConfigExpanded(!configExpanded)}
className="lg:hidden flex items-center justify-between p-4 w-full bg-muted/20 hover:bg-muted/30 transition-colors"
>
<div className="flex items-center gap-2.5 font-medium text-sm text-foreground">
<div className="p-1.5 rounded-md bg-transparent text-foreground">
<Terminal className="w-4 h-4" />
</div>
<span>{t('apiTester.config')}</span>
</div>
<div className={clsx("transition-transform duration-300 text-muted-foreground", configExpanded ? "rotate-180" : "")}>
<ChevronDown className="w-4 h-4" />
</div>
</button>
<div className={clsx(
"p-4 space-y-6 overflow-y-auto custom-scrollbar flex-1",
!configExpanded && "hidden lg:block"
)}>
<div className="space-y-3">
<label className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider ml-0.5">{t('apiTester.modelLabel')}</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(
"group relative flex items-start gap-3 p-3 rounded-lg border text-left transition-all duration-200",
model === m.id
? "bg-secondary border-primary/50 shadow-sm"
: "bg-transparent border-transparent hover:bg-muted"
)}
>
<div className={clsx(
"p-1.5 rounded-md shrink-0 transition-colors",
model === m.id ? m.color : "text-muted-foreground group-hover:text-foreground"
)}>
<Icon className="w-4 h-4" />
</div>
<div className="min-w-0 flex-1">
<div className={clsx("font-medium text-sm", model === m.id ? "text-foreground" : "text-foreground/80")}>
{m.name}
</div>
<div className="text-[11px] text-muted-foreground mt-0.5">{m.desc}</div>
</div>
{model === m.id && (
<div className={clsx("absolute top-3 right-3", m.color)}>
<div className="w-1.5 h-1.5 rounded-full bg-current" />
</div>
)}
</button>
)
})}
</div>
</div>
<div className="space-y-2">
<label className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider ml-0.5">{t('apiTester.streamMode')}</label>
<button
onClick={() => setStreamingMode(!streamingMode)}
className={clsx(
"w-full flex items-center justify-between px-3 py-2 rounded-lg border transition-all duration-200",
streamingMode
? "bg-primary/10 border-primary/50 text-foreground"
: "bg-background border-border text-muted-foreground hover:bg-muted/50"
)}
>
<div className="flex items-center gap-2">
<div className={clsx("p-1.5 rounded-md", streamingMode ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground")}>
<Zap className="w-4 h-4" />
</div>
<span className="text-sm font-medium">{t('apiTester.streamMode')}</span>
</div>
{streamingMode ? <ToggleRight className="w-5 h-5 text-primary" /> : <ToggleLeft className="w-5 h-5 text-muted-foreground" />}
</button>
</div>
<div className="space-y-2">
<label className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider ml-0.5">{t('apiTester.accountSelector')}</label>
<div className="relative">
<select
className="w-full h-10 pl-3 pr-8 bg-secondary border border-border rounded-lg text-sm appearance-none focus:outline-none focus:ring-1 focus:ring-ring focus:border-ring transition-all cursor-pointer hover:bg-muted"
value={selectedAccount}
onChange={e => setSelectedAccount(e.target.value)}
>
<option value="" className="bg-popover text-popover-foreground">{t('apiTester.autoRandom')}</option>
{accounts.map((acc, i) => {
const id = resolveAccountIdentifier(acc)
if (!id) return null
return (
<option key={i} value={id} className="bg-popover text-popover-foreground">
👤 {id}
</option>
)
})}
</select>
<ChevronDown className="absolute right-2.5 top-3 w-4 h-4 text-muted-foreground pointer-events-none" />
</div>
</div>
<div className="space-y-2">
<label className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider ml-0.5">{t('apiTester.apiKeyOptional')}</label>
<input
type="text"
autoComplete="off"
spellCheck={false}
className="w-full h-10 px-3 bg-muted/30 border border-border rounded-lg text-sm font-mono placeholder:text-muted-foreground/40 focus:outline-none focus:ring-1 focus:ring-ring focus:border-ring transition-all"
placeholder={config.keys?.[0] ? t('apiTester.apiKeyDefault', { suffix: config.keys[0].slice(-6) }) : t('apiTester.apiKeyPlaceholder')}
value={apiKey}
onChange={e => setApiKey(e.target.value)}
/>
{customKeyActive && (
<p className={clsx(
"text-[11px] mt-1",
customKeyManaged ? "text-emerald-600" : "text-amber-600"
)}>
{customKeyManaged ? t('apiTester.modeManaged') : t('apiTester.modeDirect')}
</p>
)}
</div>
</div>
</div>
</div>
{/* Chat Interface */}
<div className="lg:col-span-9 flex flex-col bg-card border border-border rounded-xl shadow-sm overflow-hidden min-h-0 flex-1 relative">
{/* Messages Area */}
<div className="flex-1 overflow-y-auto p-4 lg:p-6 space-y-8 custom-scrollbar scroll-smooth">
{/* User Message */}
<div className="flex gap-4 max-w-4xl mx-auto flex-row-reverse group">
<div className="w-8 h-8 rounded-lg bg-secondary flex items-center justify-center shrink-0 border border-border">
<User className="w-4 h-4 text-muted-foreground" />
</div>
<div className="space-y-1 max-w-[85%] lg:max-w-[75%]">
<div className="bg-primary text-primary-foreground rounded-2xl rounded-tr-sm px-5 py-3 text-sm leading-relaxed shadow-sm">
{message}
</div>
</div>
</div>
{/* AI Response */}
{(response || isStreaming) && (
<div className="flex gap-4 max-w-4xl mx-auto animate-in fade-in slide-in-from-bottom-2 duration-300">
<div className={clsx(
"w-8 h-8 rounded-lg flex items-center justify-center shrink-0 border border-border",
response?.success !== false ? "bg-muted" : "bg-destructive/10 border-destructive/20"
)}>
<Bot className={clsx("w-4 h-4", response?.success !== false ? "text-foreground" : "text-destructive")} />
</div>
<div className="space-y-3 flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-semibold text-sm text-foreground">
DeepSeek
</span>
{response && (
<span className={clsx(
"text-[10px] px-1.5 py-0.5 rounded-sm border uppercase font-medium tracking-wider",
response.success ? "border-emerald-500/20 text-emerald-500 bg-emerald-500/10" : "border-destructive/20 text-destructive bg-destructive/10"
)}>
{response.status_code || t('apiTester.statusError')}
</span>
)}
</div>
{(streamingThinking || response?.choices?.[0]?.message?.reasoning_content) && (
<div className="text-xs bg-secondary/50 border border-border rounded-lg p-3 space-y-1.5">
<div className="flex items-center gap-1.5 text-muted-foreground">
<Zap className="w-3.5 h-3.5" />
<span className="font-medium">{t('apiTester.reasoningTrace')}</span>
</div>
<div className="whitespace-pre-wrap leading-relaxed text-muted-foreground font-mono text-[11px] max-h-60 overflow-y-auto custom-scrollbar pl-5 border-l-2 border-border/50">
{streamingThinking || response?.choices?.[0]?.message?.reasoning_content}
</div>
</div>
)}
<div className="text-sm leading-7 text-foreground whitespace-pre-wrap">
{streamingContent || response?.choices?.[0]?.message?.content || (response?.error && <span className="text-destructive font-medium">{response.error}</span>) || (loading && <span className="text-muted-foreground italic">{t('apiTester.generating')}</span>)}
{isStreaming && <span className="inline-block w-1.5 h-4 bg-primary ml-1 align-middle animate-pulse" />}
</div>
</div>
</div>
)}
</div>
{/* Input Area */}
<div className="p-4 lg:p-6 border-t border-border bg-card">
<div className="max-w-4xl mx-auto relative group">
<textarea
className="w-full bg-[#09090b] border border-border rounded-xl pl-4 pr-12 py-3 text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all resize-none custom-scrollbar placeholder:text-muted-foreground/50 text-foreground shadow-inner"
placeholder={t('apiTester.enterMessage')}
rows={1}
style={{ minHeight: '52px' }}
value={message}
onChange={e => setMessage(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
runTest()
}
}}
/>
<div className="absolute right-2 bottom-2">
{loading && isStreaming ? (
<button
onClick={stopGeneration}
className="p-2 text-muted-foreground hover:text-destructive transition-colors"
>
<Square className="w-4 h-4 fill-current" />
</button>
) : (
<button
onClick={runTest}
disabled={loading || !message.trim()}
className="p-2 text-primary hover:text-primary/80 transition-colors 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 className="max-w-4xl mx-auto mt-3 flex justify-center">
<span className="text-[10px] text-muted-foreground/40 font-medium">{t('apiTester.adminConsoleLabel')}</span>
</div>
</div>
</div>
</div>
)
}
export default ApiTesterContainer

View File

@@ -1,435 +1,3 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { AlertTriangle, Download, Lock, Save, Upload } from 'lucide-react'
import { useI18n } from '../i18n'
import SettingsContainer from '../features/settings/SettingsContainer'
const MAX_AUTO_FETCH_FAILURES = 3
export default function Settings({ onRefresh, onMessage, authFetch, onForceLogout, isVercel = false }) {
const { t } = useI18n()
const apiFetch = authFetch || fetch
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [changingPassword, setChangingPassword] = useState(false)
const [importing, setImporting] = useState(false)
const [exportData, setExportData] = useState(null)
const [importMode, setImportMode] = useState('merge')
const [importText, setImportText] = useState('')
const [newPassword, setNewPassword] = useState('')
const [consecutiveFailures, setConsecutiveFailures] = useState(0)
const [autoFetchPaused, setAutoFetchPaused] = useState(false)
const [lastError, setLastError] = useState('')
const [settingsMeta, setSettingsMeta] = useState({ default_password_warning: false, env_backed: false, needs_vercel_sync: false })
const [form, setForm] = useState({
admin: { jwt_expire_hours: 24 },
runtime: { account_max_inflight: 2, account_max_queue: 10, global_max_inflight: 10 },
toolcall: { mode: 'feature_match', early_emit_confidence: 'high' },
responses: { store_ttl_seconds: 900 },
embeddings: { provider: '' },
claude_mapping_text: '{\n "fast": "deepseek-chat",\n "slow": "deepseek-reasoner"\n}',
model_aliases_text: '{}',
})
const parseJSONMap = (raw, fieldName) => {
const text = String(raw || '').trim()
if (!text) {
return {}
}
let parsed
try {
parsed = JSON.parse(text)
} catch (_e) {
throw new Error(t('settings.invalidJsonField', { field: fieldName }))
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error(t('settings.invalidJsonField', { field: fieldName }))
}
return parsed
}
const parseJSONResponse = useCallback(async (res) => {
const contentType = String(res.headers.get('content-type') || '').toLowerCase()
if (!contentType.includes('application/json')) {
throw new Error(t('settings.nonJsonResponse', { status: res.status }))
}
return res.json()
}, [t])
const loadSettings = useCallback(async ({ manual = false } = {}) => {
if (isVercel && autoFetchPaused && !manual) {
return
}
setLoading(true)
try {
const res = await apiFetch('/admin/settings')
const data = await parseJSONResponse(res)
if (!res.ok) {
const detail = data.detail || t('settings.loadFailed')
setLastError(detail)
onMessage('error', detail)
setConsecutiveFailures((prev) => {
const next = prev + 1
if (isVercel && next >= MAX_AUTO_FETCH_FAILURES) {
setAutoFetchPaused(true)
}
return next
})
return
}
setConsecutiveFailures(0)
setAutoFetchPaused(false)
setLastError('')
setSettingsMeta({
default_password_warning: Boolean(data.admin?.default_password_warning),
env_backed: Boolean(data.env_backed),
needs_vercel_sync: Boolean(data.needs_vercel_sync),
})
setForm({
admin: { jwt_expire_hours: Number(data.admin?.jwt_expire_hours || 24) },
runtime: {
account_max_inflight: Number(data.runtime?.account_max_inflight || 2),
account_max_queue: Number(data.runtime?.account_max_queue || 10),
global_max_inflight: Number(data.runtime?.global_max_inflight || 10),
},
toolcall: {
mode: data.toolcall?.mode || 'feature_match',
early_emit_confidence: data.toolcall?.early_emit_confidence || 'high',
},
responses: {
store_ttl_seconds: Number(data.responses?.store_ttl_seconds || 900),
},
embeddings: {
provider: data.embeddings?.provider || '',
},
claude_mapping_text: JSON.stringify(data.claude_mapping || {}, null, 2),
model_aliases_text: JSON.stringify(data.model_aliases || {}, null, 2),
})
} catch (e) {
const detail = e?.message || t('settings.loadFailed')
setLastError(detail)
onMessage('error', detail)
setConsecutiveFailures((prev) => {
const next = prev + 1
if (isVercel && next >= MAX_AUTO_FETCH_FAILURES) {
setAutoFetchPaused(true)
}
return next
})
// eslint-disable-next-line no-console
console.error(e)
} finally {
setLoading(false)
}
}, [apiFetch, autoFetchPaused, isVercel, onMessage, parseJSONResponse, t])
useEffect(() => {
loadSettings()
}, [loadSettings])
const retryLoadSettings = () => {
setAutoFetchPaused(false)
loadSettings({ manual: true })
}
const saveSettings = async () => {
let claudeMapping = {}
let modelAliases = {}
try {
claudeMapping = parseJSONMap(form.claude_mapping_text, 'claude_mapping')
modelAliases = parseJSONMap(form.model_aliases_text, 'model_aliases')
} catch (e) {
onMessage('error', e.message)
return
}
const payload = {
admin: { jwt_expire_hours: Number(form.admin.jwt_expire_hours) },
runtime: {
account_max_inflight: Number(form.runtime.account_max_inflight),
account_max_queue: Number(form.runtime.account_max_queue),
global_max_inflight: Number(form.runtime.global_max_inflight),
},
toolcall: {
mode: String(form.toolcall.mode || '').trim(),
early_emit_confidence: String(form.toolcall.early_emit_confidence || '').trim(),
},
responses: { store_ttl_seconds: Number(form.responses.store_ttl_seconds) },
embeddings: { provider: String(form.embeddings.provider || '').trim() },
claude_mapping: claudeMapping,
model_aliases: modelAliases,
}
setSaving(true)
try {
const res = await apiFetch('/admin/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const data = await res.json()
if (!res.ok) {
onMessage('error', data.detail || t('settings.saveFailed'))
return
}
onMessage('success', t('settings.saveSuccess'))
if (typeof onRefresh === 'function') {
onRefresh()
}
await loadSettings()
} catch (e) {
onMessage('error', t('settings.saveFailed'))
// eslint-disable-next-line no-console
console.error(e)
} finally {
setSaving(false)
}
}
const updatePassword = async () => {
if (String(newPassword || '').trim().length < 4) {
onMessage('error', t('settings.passwordTooShort'))
return
}
setChangingPassword(true)
try {
const res = await apiFetch('/admin/settings/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ new_password: newPassword.trim() }),
})
const data = await res.json()
if (!res.ok) {
onMessage('error', data.detail || t('settings.passwordUpdateFailed'))
return
}
onMessage('success', t('settings.passwordUpdated'))
setNewPassword('')
if (typeof onForceLogout === 'function') {
onForceLogout()
}
} catch (e) {
onMessage('error', t('settings.passwordUpdateFailed'))
} finally {
setChangingPassword(false)
}
}
const loadExportData = async () => {
try {
const res = await apiFetch('/admin/config/export')
const data = await res.json()
if (!res.ok) {
onMessage('error', data.detail || t('settings.exportFailed'))
return
}
setExportData(data)
onMessage('success', t('settings.exportLoaded'))
} catch (e) {
onMessage('error', t('settings.exportFailed'))
}
}
const doImport = async () => {
if (!String(importText || '').trim()) {
onMessage('error', t('settings.importEmpty'))
return
}
let parsed
try {
parsed = JSON.parse(importText)
} catch (_e) {
onMessage('error', t('settings.importInvalidJson'))
return
}
setImporting(true)
try {
const res = await apiFetch(`/admin/config/import?mode=${encodeURIComponent(importMode)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config: parsed, mode: importMode }),
})
const data = await res.json()
if (!res.ok) {
onMessage('error', data.detail || t('settings.importFailed'))
return
}
onMessage('success', t('settings.importSuccess', { mode: importMode }))
if (typeof onRefresh === 'function') {
onRefresh()
}
await loadSettings()
} catch (e) {
onMessage('error', t('settings.importFailed'))
} finally {
setImporting(false)
}
}
const syncHintVisible = useMemo(() => settingsMeta.env_backed || settingsMeta.needs_vercel_sync, [settingsMeta.env_backed, settingsMeta.needs_vercel_sync])
return (
<div className="space-y-6">
{autoFetchPaused && (
<div className="p-4 rounded-lg border border-destructive/30 bg-destructive/10 text-destructive flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<AlertTriangle className="w-4 h-4" />
<span className="text-sm">
{t('settings.autoFetchPaused', { count: consecutiveFailures, error: lastError || t('settings.loadFailed') })}
</span>
</div>
<button
type="button"
onClick={retryLoadSettings}
className="px-3 py-1.5 text-xs rounded-md border border-destructive/40 hover:bg-destructive/10"
>
{t('settings.retryLoad')}
</button>
</div>
)}
{settingsMeta.default_password_warning && (
<div className="p-4 rounded-lg border border-amber-300/30 bg-amber-500/10 text-amber-700 flex items-center gap-2">
<AlertTriangle className="w-4 h-4" />
<span className="text-sm">{t('settings.defaultPasswordWarning')}</span>
</div>
)}
{syncHintVisible && (
<div className="p-4 rounded-lg border border-amber-300/30 bg-amber-500/10 text-amber-700 flex items-center gap-2">
<AlertTriangle className="w-4 h-4" />
<span className="text-sm">{t('settings.vercelSyncHint')}</span>
</div>
)}
<div className="bg-card border border-border rounded-xl p-5 space-y-4">
<h3 className="font-semibold">{t('settings.securityTitle')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="text-sm space-y-2">
<span className="text-muted-foreground">{t('settings.jwtExpireHours')}</span>
<input
type="number"
min={1}
max={720}
value={form.admin.jwt_expire_hours}
onChange={(e) => setForm((prev) => ({ ...prev, admin: { ...prev.admin, jwt_expire_hours: Number(e.target.value || 1) } }))}
className="w-full bg-background border border-border rounded-lg px-3 py-2"
/>
</label>
<label className="text-sm space-y-2">
<span className="text-muted-foreground">{t('settings.newPassword')}</span>
<div className="flex gap-2">
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder={t('settings.newPasswordPlaceholder')}
className="w-full bg-background border border-border rounded-lg px-3 py-2"
/>
<button
type="button"
onClick={updatePassword}
disabled={changingPassword}
className="px-3 py-2 rounded-lg bg-secondary border border-border hover:bg-secondary/80 text-sm flex items-center gap-1"
>
<Lock className="w-4 h-4" />
{changingPassword ? t('settings.updating') : t('settings.updatePassword')}
</button>
</div>
</label>
</div>
</div>
<div className="bg-card border border-border rounded-xl p-5 space-y-4">
<h3 className="font-semibold">{t('settings.runtimeTitle')}</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<label className="text-sm space-y-2">
<span className="text-muted-foreground">{t('settings.accountMaxInflight')}</span>
<input type="number" min={1} value={form.runtime.account_max_inflight} onChange={(e) => setForm((prev) => ({ ...prev, runtime: { ...prev.runtime, account_max_inflight: Number(e.target.value || 1) } }))} className="w-full bg-background border border-border rounded-lg px-3 py-2" />
</label>
<label className="text-sm space-y-2">
<span className="text-muted-foreground">{t('settings.accountMaxQueue')}</span>
<input type="number" min={1} value={form.runtime.account_max_queue} onChange={(e) => setForm((prev) => ({ ...prev, runtime: { ...prev.runtime, account_max_queue: Number(e.target.value || 1) } }))} className="w-full bg-background border border-border rounded-lg px-3 py-2" />
</label>
<label className="text-sm space-y-2">
<span className="text-muted-foreground">{t('settings.globalMaxInflight')}</span>
<input type="number" min={1} value={form.runtime.global_max_inflight} onChange={(e) => setForm((prev) => ({ ...prev, runtime: { ...prev.runtime, global_max_inflight: Number(e.target.value || 1) } }))} className="w-full bg-background border border-border rounded-lg px-3 py-2" />
</label>
</div>
</div>
<div className="bg-card border border-border rounded-xl p-5 space-y-4">
<h3 className="font-semibold">{t('settings.behaviorTitle')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="text-sm space-y-2">
<span className="text-muted-foreground">{t('settings.toolcallMode')}</span>
<select value={form.toolcall.mode} onChange={(e) => setForm((prev) => ({ ...prev, toolcall: { ...prev.toolcall, mode: e.target.value } }))} className="w-full bg-background border border-border rounded-lg px-3 py-2">
<option value="feature_match">feature_match</option>
<option value="off">off</option>
</select>
</label>
<label className="text-sm space-y-2">
<span className="text-muted-foreground">{t('settings.earlyEmitConfidence')}</span>
<select value={form.toolcall.early_emit_confidence} onChange={(e) => setForm((prev) => ({ ...prev, toolcall: { ...prev.toolcall, early_emit_confidence: e.target.value } }))} className="w-full bg-background border border-border rounded-lg px-3 py-2">
<option value="high">high</option>
<option value="low">low</option>
<option value="off">off</option>
</select>
</label>
<label className="text-sm space-y-2">
<span className="text-muted-foreground">{t('settings.responsesTTL')}</span>
<input type="number" min={30} value={form.responses.store_ttl_seconds} onChange={(e) => setForm((prev) => ({ ...prev, responses: { ...prev.responses, store_ttl_seconds: Number(e.target.value || 30) } }))} className="w-full bg-background border border-border rounded-lg px-3 py-2" />
</label>
<label className="text-sm space-y-2">
<span className="text-muted-foreground">{t('settings.embeddingsProvider')}</span>
<input type="text" value={form.embeddings.provider} onChange={(e) => setForm((prev) => ({ ...prev, embeddings: { ...prev.embeddings, provider: e.target.value } }))} className="w-full bg-background border border-border rounded-lg px-3 py-2" />
</label>
</div>
</div>
<div className="bg-card border border-border rounded-xl p-5 space-y-4">
<h3 className="font-semibold">{t('settings.modelTitle')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="text-sm space-y-2">
<span className="text-muted-foreground">{t('settings.claudeMapping')}</span>
<textarea value={form.claude_mapping_text} onChange={(e) => setForm((prev) => ({ ...prev, claude_mapping_text: e.target.value }))} rows={8} className="w-full bg-background border border-border rounded-lg px-3 py-2 font-mono text-xs" />
</label>
<label className="text-sm space-y-2">
<span className="text-muted-foreground">{t('settings.modelAliases')}</span>
<textarea value={form.model_aliases_text} onChange={(e) => setForm((prev) => ({ ...prev, model_aliases_text: e.target.value }))} rows={8} className="w-full bg-background border border-border rounded-lg px-3 py-2 font-mono text-xs" />
</label>
</div>
</div>
<div className="bg-card border border-border rounded-xl p-5 space-y-4">
<h3 className="font-semibold">{t('settings.backupTitle')}</h3>
<div className="flex flex-wrap items-center gap-3">
<button type="button" onClick={loadExportData} className="px-3 py-2 rounded-lg bg-secondary border border-border hover:bg-secondary/80 text-sm flex items-center gap-2">
<Download className="w-4 h-4" />
{t('settings.loadExport')}
</button>
<select value={importMode} onChange={(e) => setImportMode(e.target.value)} className="bg-background border border-border rounded-lg px-3 py-2 text-sm">
<option value="merge">{t('settings.importModeMerge')}</option>
<option value="replace">{t('settings.importModeReplace')}</option>
</select>
<button type="button" onClick={doImport} disabled={importing} className="px-3 py-2 rounded-lg bg-secondary border border-border hover:bg-secondary/80 text-sm flex items-center gap-2">
<Upload className="w-4 h-4" />
{importing ? t('settings.importing') : t('settings.importNow')}
</button>
</div>
<textarea value={importText} onChange={(e) => setImportText(e.target.value)} rows={8} className="w-full bg-background border border-border rounded-lg px-3 py-2 font-mono text-xs" placeholder={t('settings.importPlaceholder')} />
{exportData && (
<div className="space-y-2">
<label className="text-sm text-muted-foreground">{t('settings.exportJson')}</label>
<textarea value={exportData.json || ''} readOnly rows={6} className="w-full bg-background border border-border rounded-lg px-3 py-2 font-mono text-xs" />
</div>
)}
</div>
<div className="flex justify-end">
<button type="button" onClick={saveSettings} disabled={loading || saving} className="px-4 py-2 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 flex items-center gap-2">
<Save className="w-4 h-4" />
{saving ? t('settings.saving') : t('settings.save')}
</button>
</div>
</div>
)
}
export default SettingsContainer

View File

@@ -1,332 +1,3 @@
import { useState, useEffect, useCallback } from 'react'
import { Cloud, ArrowRight, ExternalLink, Info, CheckCircle2, XCircle, RefreshCw } from 'lucide-react'
import clsx from 'clsx'
import { useI18n } from '../i18n'
import VercelSyncContainer from '../features/vercel/VercelSyncContainer'
const MAX_POLL_FAILURES = 3
function pollDelayMs(attempt) {
if (attempt <= 0) return 15000
if (attempt === 1) return 30000
return 60000
}
export default function VercelSync({ onMessage, authFetch, isVercel = false }) {
const { t } = useI18n()
const [vercelToken, setVercelToken] = useState('')
const [projectId, setProjectId] = useState('')
const [teamId, setTeamId] = useState('')
const [loading, setLoading] = useState(false)
const [result, setResult] = useState(null)
const [preconfig, setPreconfig] = useState(null)
const [syncStatus, setSyncStatus] = useState(null)
const [pollPaused, setPollPaused] = useState(false)
const [pollFailures, setPollFailures] = useState(0)
const [nextRetryAt, setNextRetryAt] = useState(null)
const apiFetch = authFetch || fetch
const fetchSyncStatus = useCallback(async ({ manual = false } = {}) => {
try {
const res = await apiFetch('/admin/vercel/status')
if (!res.ok) {
throw new Error(`status ${res.status}`)
}
const data = await res.json()
setSyncStatus(data)
setPollFailures(0)
setPollPaused(false)
setNextRetryAt(null)
} catch (e) {
setPollFailures((prev) => {
const next = prev + 1
if (isVercel) {
if (next >= MAX_POLL_FAILURES) {
setPollPaused(true)
setNextRetryAt(null)
} else {
setNextRetryAt(Date.now() + pollDelayMs(next))
}
}
return next
})
if (manual) {
onMessage('error', t('vercel.networkError'))
}
console.error('Failed to fetch sync status:', e)
}
}, [apiFetch, isVercel, onMessage, t])
useEffect(() => {
const loadPreconfig = async () => {
try {
const res = await apiFetch('/admin/vercel/config')
if (res.ok) {
const data = await res.json()
setPreconfig(data)
if (data.project_id) setProjectId(data.project_id)
if (data.team_id) setTeamId(data.team_id)
}
} catch (e) {
console.error('Failed to load preconfig:', e)
}
}
loadPreconfig()
fetchSyncStatus()
}, [fetchSyncStatus])
useEffect(() => {
if (!isVercel) {
const interval = setInterval(() => {
fetchSyncStatus()
}, 15000)
return () => clearInterval(interval)
}
if (pollPaused) {
return undefined
}
const delay = nextRetryAt && nextRetryAt > Date.now() ? nextRetryAt - Date.now() : pollDelayMs(pollFailures)
const timer = setTimeout(() => {
fetchSyncStatus()
}, Math.max(1000, delay))
return () => clearTimeout(timer)
}, [fetchSyncStatus, isVercel, nextRetryAt, pollFailures, pollPaused])
const handleManualRefresh = () => {
setPollPaused(false)
setPollFailures(0)
setNextRetryAt(null)
fetchSyncStatus({ manual: true })
}
const handleSync = async () => {
const tokenToUse = preconfig?.has_token && !vercelToken ? '__USE_PRECONFIG__' : vercelToken
if (!tokenToUse && !preconfig?.has_token) {
onMessage('error', t('vercel.tokenRequired'))
return
}
if (!projectId) {
onMessage('error', t('vercel.projectRequired'))
return
}
setLoading(true)
setResult(null)
try {
const res = await apiFetch('/admin/vercel/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
vercel_token: tokenToUse,
project_id: projectId,
team_id: teamId || undefined,
}),
})
const data = await res.json()
if (res.ok) {
setResult({ ...data, success: true })
onMessage('success', data.message)
fetchSyncStatus()
} else {
setResult({ ...data, success: false })
onMessage('error', data.detail || t('vercel.syncFailed'))
}
} catch (e) {
onMessage('error', t('vercel.networkError'))
} finally {
setLoading(false)
}
}
return (
<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">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold flex items-center gap-2">
<Cloud className="w-6 h-6 text-primary" />
{t('vercel.title')}
</h2>
{syncStatus && (
<div className={clsx(
"flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1 rounded-full border transition-colors",
syncStatus.synced
? "text-emerald-500 bg-emerald-500/10 border-emerald-500/20"
: syncStatus.has_synced_before
? "text-amber-500 bg-amber-500/10 border-amber-500/20"
: "text-muted-foreground bg-muted/50 border-border"
)}>
<span className={clsx(
"w-1.5 h-1.5 rounded-full",
syncStatus.synced ? "bg-emerald-500" : syncStatus.has_synced_before ? "bg-amber-500 animate-pulse" : "bg-muted-foreground"
)} />
{syncStatus.synced
? t('vercel.statusSynced')
: syncStatus.has_synced_before
? t('vercel.statusNotSynced')
: t('vercel.statusNeverSynced')}
</div>
)}
</div>
<p className="text-muted-foreground text-sm mt-1">
{t('vercel.description')}
</p>
{pollPaused && (
<div className="mt-2 flex flex-wrap items-center gap-2">
<p className="text-xs text-destructive">
{t('vercel.pollPaused', { count: pollFailures })}
</p>
<button
type="button"
onClick={handleManualRefresh}
className="px-2 py-1 text-xs rounded border border-border hover:bg-secondary/50"
>
{t('vercel.manualRefresh')}
</button>
</div>
)}
{syncStatus?.last_sync_time && (
<p className="text-xs text-muted-foreground/60 mt-1.5 flex items-center gap-1">
<RefreshCw className="w-3 h-3" />
{t('vercel.lastSyncTime', { time: new Date(syncStatus.last_sync_time * 1000).toLocaleString() })}
</p>
)}
</div>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium flex items-center justify-between">
{t('vercel.tokenLabel')}
<a href="https://vercel.com/account/tokens" target="_blank" rel="noopener noreferrer" className="text-xs text-primary hover:underline flex items-center gap-1">
{t('vercel.getToken')} <ExternalLink className="w-3 h-3" />
</a>
</label>
<div className="relative">
<input
type="password"
className="w-full h-10 px-3 bg-background border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring transition-all pr-10"
placeholder={preconfig?.has_token ? t('vercel.tokenPlaceholderPreconfig') : t('vercel.tokenPlaceholder')}
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">{t('vercel.projectIdLabel')}</label>
<input
type="text"
className="w-full h-10 px-3 bg-background border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring transition-all"
placeholder="prj_xxxxxxxxxxxx or Project Name"
value={projectId}
onChange={e => setProjectId(e.target.value)}
/>
<p className="text-xs text-muted-foreground">{t('vercel.projectIdHint')}</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium flex items-center gap-2">
{t('vercel.teamIdLabel')} <span className="text-xs text-muted-foreground font-normal">({t('vercel.optional')})</span>
</label>
<input
type="text"
className="w-full h-10 px-3 bg-background border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring transition-all"
placeholder="team_xxxxxxxxxxxx"
value={teamId}
onChange={e => setTeamId(e.target.value)}
/>
</div>
</div>
<div className="pt-4">
<button
onClick={handleSync}
disabled={loading}
className="w-full flex items-center justify-center gap-2 py-3 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-all font-medium text-sm shadow-sm hover:shadow-md disabled:opacity-50 disabled:shadow-none"
>
{loading ? (
<span className="flex items-center gap-2">
<span className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
{t('vercel.syncing')}
</span>
) : (
<span className="flex items-center gap-2">
{t('vercel.syncRedeploy')} <ArrowRight className="w-4 h-4" />
</span>
)}
</button>
<p className="text-xs text-center text-muted-foreground mt-4">
{t('vercel.redeployHint')}
</p>
</div>
</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 ? t('vercel.syncSucceeded') : t('vercel.syncFailedLabel')}
</h3>
<p className="text-sm opacity-90">{result.message}</p>
{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">
{t('vercel.openDeployment')} <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" />
{t('vercel.howItWorks')}
</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">{t('vercel.steps.one')}</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">{t('vercel.steps.two')}</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">
{t('vercel.steps.three')} <code className="bg-background px-1 py-0.5 rounded border border-border text-xs">DS2API_CONFIG_JSON</code>
</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">{t('vercel.steps.four')}</p>
</li>
</ul>
</div>
</div>
</div>
)
}
export default VercelSyncContainer

View File

@@ -0,0 +1,113 @@
import { useI18n } from '../../i18n'
import { useAccountsData } from './useAccountsData'
import { useAccountActions } from './useAccountActions'
import QueueCards from './QueueCards'
import ApiKeysPanel from './ApiKeysPanel'
import AccountsTable from './AccountsTable'
import AddKeyModal from './AddKeyModal'
import AddAccountModal from './AddAccountModal'
export default function AccountManagerContainer({ config, onRefresh, onMessage, authFetch }) {
const { t } = useI18n()
const apiFetch = authFetch || fetch
const {
queueStatus,
keysExpanded,
setKeysExpanded,
accounts,
page,
totalPages,
totalAccounts,
loadingAccounts,
fetchAccounts,
resolveAccountIdentifier,
} = useAccountsData({ apiFetch })
const {
showAddKey,
setShowAddKey,
showAddAccount,
setShowAddAccount,
newKey,
setNewKey,
copiedKey,
setCopiedKey,
newAccount,
setNewAccount,
loading,
testing,
testingAll,
batchProgress,
addKey,
deleteKey,
addAccount,
deleteAccount,
testAccount,
testAllAccounts,
} = useAccountActions({
apiFetch,
t,
onMessage,
onRefresh,
config,
fetchAccounts,
resolveAccountIdentifier,
})
return (
<div className="space-y-6">
<QueueCards queueStatus={queueStatus} t={t} />
<ApiKeysPanel
t={t}
config={config}
keysExpanded={keysExpanded}
setKeysExpanded={setKeysExpanded}
setShowAddKey={setShowAddKey}
copiedKey={copiedKey}
setCopiedKey={setCopiedKey}
onDeleteKey={deleteKey}
/>
<AccountsTable
t={t}
accounts={accounts}
loadingAccounts={loadingAccounts}
testing={testing}
testingAll={testingAll}
batchProgress={batchProgress}
totalAccounts={totalAccounts}
page={page}
totalPages={totalPages}
resolveAccountIdentifier={resolveAccountIdentifier}
onTestAll={testAllAccounts}
onShowAddAccount={() => setShowAddAccount(true)}
onTestAccount={testAccount}
onDeleteAccount={deleteAccount}
onPrevPage={() => fetchAccounts(page - 1)}
onNextPage={() => fetchAccounts(page + 1)}
/>
<AddKeyModal
show={showAddKey}
t={t}
newKey={newKey}
setNewKey={setNewKey}
loading={loading}
onClose={() => setShowAddKey(false)}
onAdd={addKey}
/>
<AddAccountModal
show={showAddAccount}
t={t}
newAccount={newAccount}
setNewAccount={setNewAccount}
loading={loading}
onClose={() => setShowAddAccount(false)}
onAdd={addAccount}
/>
</div>
)
}

View File

@@ -0,0 +1,149 @@
import { ChevronLeft, ChevronRight, Play, Plus, Trash2 } from 'lucide-react'
import clsx from 'clsx'
export default function AccountsTable({
t,
accounts,
loadingAccounts,
testing,
testingAll,
batchProgress,
totalAccounts,
page,
totalPages,
resolveAccountIdentifier,
onTestAll,
onShowAddAccount,
onTestAccount,
onDeleteAccount,
onPrevPage,
onNextPage,
}) {
return (
<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">{t('accountManager.accountsTitle')}</h2>
<p className="text-sm text-muted-foreground">{t('accountManager.accountsDesc')}</p>
</div>
<div className="flex flex-wrap gap-2">
<button
onClick={onTestAll}
disabled={testingAll || totalAccounts === 0}
className="flex items-center px-3 py-2 bg-secondary text-secondary-foreground rounded-lg hover:bg-secondary/80 transition-colors text-xs font-medium border border-border disabled:opacity-50"
>
{testingAll ? <span className="animate-spin mr-2"></span> : <Play className="w-3 h-3 mr-2" />}
{t('accountManager.testAll')}
</button>
<button
onClick={onShowAddAccount}
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors font-medium text-sm shadow-sm"
>
<Plus className="w-4 h-4" />
{t('accountManager.addAccount')}
</button>
</div>
</div>
{testingAll && batchProgress.total > 0 && (
<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">{t('accountManager.testingAllAccounts')}</span>
<span className="text-muted-foreground">{batchProgress.current} / {batchProgress.total}</span>
</div>
<div className="w-full bg-muted rounded-full h-2 overflow-hidden mb-4">
<div
className="bg-primary h-full transition-all duration-300"
style={{ width: `${(batchProgress.current / batchProgress.total) * 100}%` }}
/>
</div>
{batchProgress.results.length > 0 && (
<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={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>
)}
</div>
)}
<div className="divide-y divide-border">
{loadingAccounts ? (
<div className="p-8 text-center text-muted-foreground">{t('actions.loading')}</div>
) : accounts.length > 0 ? (
accounts.map((acc, i) => {
const id = resolveAccountIdentifier(acc)
return (
<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 ? t('accountManager.sessionActive') : t('accountManager.reauthRequired')}</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="flex items-center gap-2 self-start lg:self-auto ml-5 lg:ml-0">
<button
onClick={() => onTestAccount(id)}
disabled={testing[id]}
className="px-2 lg:px-3 py-1 lg:py-1.5 text-[10px] lg:text-xs font-medium border border-border rounded-md hover:bg-secondary transition-colors disabled:opacity-50"
>
{testing[id] ? t('actions.testing') : t('actions.test')}
</button>
<button
onClick={() => onDeleteAccount(id)}
className="p-1 lg:p-1.5 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-md transition-colors"
>
<Trash2 className="w-3.5 h-3.5 lg:w-4 lg:h-4" />
</button>
</div>
</div>
)
})
) : (
<div className="p-8 text-center text-muted-foreground">{t('accountManager.noAccounts')}</div>
)}
</div>
{totalPages > 1 && (
<div className="p-4 border-t border-border flex items-center justify-between">
<div className="text-sm text-muted-foreground">
{t('accountManager.pageInfo', { current: page, total: totalPages, count: totalAccounts })}
</div>
<div className="flex items-center gap-2">
<button
onClick={onPrevPage}
disabled={page <= 1 || loadingAccounts}
className="p-2 border border-border rounded-md hover:bg-secondary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-sm font-medium px-2">{page} / {totalPages}</span>
<button
onClick={onNextPage}
disabled={page >= totalPages || loadingAccounts}
className="p-2 border border-border rounded-md hover:bg-secondary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,66 @@
import { X } from 'lucide-react'
export default function AddAccountModal({
show,
t,
newAccount,
setNewAccount,
loading,
onClose,
onAdd,
}) {
if (!show) {
return null
}
return (
<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">{t('accountManager.modalAddAccountTitle')}</h3>
<button onClick={onClose} 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">{t('accountManager.emailOptional')}</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">{t('accountManager.mobileOptional')}</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">{t('accountManager.passwordLabel')} <span className="text-destructive">*</span></label>
<input
type="password"
className="input-field bg-[#09090b]"
placeholder={t('accountManager.passwordPlaceholder')}
value={newAccount.password}
onChange={e => setNewAccount({ ...newAccount, password: e.target.value })}
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<button onClick={onClose} className="px-4 py-2 rounded-lg border border-border hover:bg-secondary transition-colors text-sm font-medium">{t('actions.cancel')}</button>
<button onClick={onAdd} disabled={loading} className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors text-sm font-medium disabled:opacity-50">
{loading ? t('accountManager.addAccountLoading') : t('accountManager.addAccountAction')}
</button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,49 @@
import { X } from 'lucide-react'
export default function AddKeyModal({ show, t, newKey, setNewKey, loading, onClose, onAdd }) {
if (!show) {
return null
}
return (
<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">{t('accountManager.modalAddKeyTitle')}</h3>
<button onClick={onClose} 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">{t('accountManager.newKeyLabel')}</label>
<div className="flex gap-2">
<input
type="text"
className="input-field bg-[#09090b] flex-1"
placeholder={t('accountManager.newKeyPlaceholder')}
value={newKey}
onChange={e => setNewKey(e.target.value)}
autoFocus
/>
<button
type="button"
onClick={() => setNewKey('sk-' + crypto.randomUUID().replace(/-/g, ''))}
className="px-3 py-2 bg-secondary text-secondary-foreground rounded-lg hover:bg-secondary/80 transition-colors text-sm font-medium border border-border whitespace-nowrap"
>
{t('accountManager.generate')}
</button>
</div>
<p className="text-xs text-muted-foreground mt-1.5">{t('accountManager.generateHint')}</p>
</div>
<div className="flex justify-end gap-2 pt-2">
<button onClick={onClose} className="px-4 py-2 rounded-lg border border-border hover:bg-secondary transition-colors text-sm font-medium">{t('actions.cancel')}</button>
<button onClick={onAdd} disabled={loading} className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors text-sm font-medium disabled:opacity-50">
{loading ? t('accountManager.addKeyLoading') : t('accountManager.addKeyAction')}
</button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,81 @@
import { Check, ChevronDown, Copy, Plus, Trash2 } from 'lucide-react'
import clsx from 'clsx'
export default function ApiKeysPanel({
t,
config,
keysExpanded,
setKeysExpanded,
setShowAddKey,
copiedKey,
setCopiedKey,
onDeleteKey,
}) {
return (
<div className="bg-card border border-border rounded-xl overflow-hidden shadow-sm">
<div
className="p-6 flex flex-col md:flex-row md:items-center justify-between gap-4 cursor-pointer select-none hover:bg-muted/30 transition-colors"
onClick={() => setKeysExpanded(!keysExpanded)}
>
<div className="flex items-center gap-3">
<ChevronDown className={clsx(
"w-5 h-5 text-muted-foreground transition-transform duration-200",
keysExpanded ? "rotate-0" : "-rotate-90"
)} />
<div>
<h2 className="text-lg font-semibold">{t('accountManager.apiKeysTitle')}</h2>
<p className="text-sm text-muted-foreground">{t('accountManager.apiKeysDesc')} ({config.keys?.length || 0})</p>
</div>
</div>
<button
onClick={(e) => { e.stopPropagation(); setShowAddKey(true) }}
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors font-medium text-sm shadow-sm"
>
<Plus className="w-4 h-4" />
{t('accountManager.addKey')}
</button>
</div>
{keysExpanded && (
<div className="divide-y divide-border border-t border-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="flex items-center gap-2">
<div className="font-mono text-sm bg-muted/50 px-3 py-1 rounded inline-block">
{key.slice(0, 16)}****
</div>
{copiedKey === key && (
<span className="text-xs text-green-500 animate-pulse">{t('accountManager.copied')}</span>
)}
</div>
<div className="flex items-center gap-1">
<button
onClick={() => {
navigator.clipboard.writeText(key)
setCopiedKey(key)
setTimeout(() => setCopiedKey(null), 2000)
}}
className="p-2 text-muted-foreground hover:text-primary hover:bg-primary/10 rounded-md transition-colors opacity-0 group-hover:opacity-100"
title={t('accountManager.copyKeyTitle')}
>
{copiedKey === key ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
</button>
<button
onClick={() => onDeleteKey(key)}
className="p-2 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-md transition-colors opacity-0 group-hover:opacity-100"
title={t('accountManager.deleteKeyTitle')}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))
) : (
<div className="p-8 text-center text-muted-foreground">{t('accountManager.noApiKeys')}</div>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,42 @@
import { CheckCircle2, Server, ShieldCheck } from 'lucide-react'
export default function QueueCards({ queueStatus, t }) {
if (!queueStatus) {
return null
}
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-card border border-border rounded-xl p-4 flex flex-col justify-between shadow-sm relative overflow-hidden group">
<div className="absolute right-0 top-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
<CheckCircle2 className="w-16 h-16" />
</div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-widest">{t('accountManager.available')}</p>
<div className="mt-2 flex items-baseline gap-2">
<span className="text-3xl font-bold text-foreground">{queueStatus.available}</span>
<span className="text-xs text-muted-foreground">{t('accountManager.accountsUnit')}</span>
</div>
</div>
<div className="bg-card border border-border rounded-xl p-4 flex flex-col justify-between shadow-sm relative overflow-hidden group">
<div className="absolute right-0 top-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
<Server className="w-16 h-16" />
</div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-widest">{t('accountManager.inUse')}</p>
<div className="mt-2 flex items-baseline gap-2">
<span className="text-3xl font-bold text-foreground">{queueStatus.in_use}</span>
<span className="text-xs text-muted-foreground">{t('accountManager.threadsUnit')}</span>
</div>
</div>
<div className="bg-card border border-border rounded-xl p-4 flex flex-col justify-between shadow-sm relative overflow-hidden group">
<div className="absolute right-0 top-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
<ShieldCheck className="w-16 h-16" />
</div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-widest">{t('accountManager.totalPool')}</p>
<div className="mt-2 flex items-baseline gap-2">
<span className="text-3xl font-bold text-foreground">{queueStatus.total}</span>
<span className="text-xs text-muted-foreground">{t('accountManager.accountsUnit')}</span>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,195 @@
import { useState } from 'react'
export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, fetchAccounts, resolveAccountIdentifier }) {
const [showAddKey, setShowAddKey] = useState(false)
const [showAddAccount, setShowAddAccount] = useState(false)
const [newKey, setNewKey] = useState('')
const [copiedKey, setCopiedKey] = useState(null)
const [newAccount, setNewAccount] = useState({ email: '', mobile: '', password: '' })
const [loading, setLoading] = useState(false)
const [testing, setTesting] = useState({})
const [testingAll, setTestingAll] = useState(false)
const [batchProgress, setBatchProgress] = useState({ current: 0, total: 0, results: [] })
const addKey = async () => {
if (!newKey.trim()) return
setLoading(true)
try {
const res = await apiFetch('/admin/keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: newKey.trim() }),
})
if (res.ok) {
onMessage('success', t('accountManager.addKeySuccess'))
setNewKey('')
setShowAddKey(false)
onRefresh()
} else {
const data = await res.json()
onMessage('error', data.detail || t('messages.failedToAdd'))
}
} catch (e) {
onMessage('error', t('messages.networkError'))
} finally {
setLoading(false)
}
}
const deleteKey = async (key) => {
if (!confirm(t('accountManager.deleteKeyConfirm'))) return
try {
const res = await apiFetch(`/admin/keys/${encodeURIComponent(key)}`, { method: 'DELETE' })
if (res.ok) {
onMessage('success', t('messages.deleted'))
onRefresh()
} else {
onMessage('error', t('messages.deleteFailed'))
}
} catch (e) {
onMessage('error', t('messages.networkError'))
}
}
const addAccount = async () => {
if (!newAccount.password || (!newAccount.email && !newAccount.mobile)) {
onMessage('error', t('accountManager.requiredFields'))
return
}
setLoading(true)
try {
const res = await apiFetch('/admin/accounts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newAccount),
})
if (res.ok) {
onMessage('success', t('accountManager.addAccountSuccess'))
setNewAccount({ email: '', mobile: '', password: '' })
setShowAddAccount(false)
fetchAccounts(1)
onRefresh()
} else {
const data = await res.json()
onMessage('error', data.detail || t('messages.failedToAdd'))
}
} catch (e) {
onMessage('error', t('messages.networkError'))
} finally {
setLoading(false)
}
}
const deleteAccount = async (id) => {
const identifier = String(id || '').trim()
if (!identifier) {
onMessage('error', t('accountManager.invalidIdentifier'))
return
}
if (!confirm(t('accountManager.deleteAccountConfirm'))) return
try {
const res = await apiFetch(`/admin/accounts/${encodeURIComponent(identifier)}`, { method: 'DELETE' })
if (res.ok) {
onMessage('success', t('messages.deleted'))
fetchAccounts()
onRefresh()
} else {
onMessage('error', t('messages.deleteFailed'))
}
} catch (e) {
onMessage('error', t('messages.networkError'))
}
}
const testAccount = async (identifier) => {
const accountID = String(identifier || '').trim()
if (!accountID) {
onMessage('error', t('accountManager.invalidIdentifier'))
return
}
setTesting(prev => ({ ...prev, [accountID]: true }))
try {
const res = await apiFetch('/admin/accounts/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier: accountID }),
})
const data = await res.json()
const statusMessage = data.success
? t('apiTester.testSuccess', { account: accountID, time: data.response_time })
: `${accountID}: ${data.message}`
onMessage(data.success ? 'success' : 'error', statusMessage)
fetchAccounts()
onRefresh()
} catch (e) {
onMessage('error', t('accountManager.testFailed', { error: e.message }))
} finally {
setTesting(prev => ({ ...prev, [accountID]: false }))
}
}
const testAllAccounts = async () => {
if (!confirm(t('accountManager.testAllConfirm'))) return
const allAccounts = config.accounts || []
if (allAccounts.length === 0) return
setTestingAll(true)
setBatchProgress({ current: 0, total: allAccounts.length, results: [] })
let successCount = 0
const results = []
for (let i = 0; i < allAccounts.length; i++) {
const acc = allAccounts[i]
const id = resolveAccountIdentifier(acc)
if (!id) {
results.push({ id: '-', success: false, message: t('accountManager.invalidIdentifier') })
setBatchProgress({ current: i + 1, total: allAccounts.length, results: [...results] })
continue
}
try {
const res = await apiFetch('/admin/accounts/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier: id }),
})
const data = await res.json()
results.push({ id, success: data.success, message: data.message, time: data.response_time })
if (data.success) successCount++
} catch (e) {
results.push({ id, success: false, message: e.message })
}
setBatchProgress({ current: i + 1, total: allAccounts.length, results: [...results] })
}
onMessage('success', t('accountManager.testAllCompleted', { success: successCount, total: allAccounts.length }))
fetchAccounts()
onRefresh()
setTestingAll(false)
}
return {
showAddKey,
setShowAddKey,
showAddAccount,
setShowAddAccount,
newKey,
setNewKey,
copiedKey,
setCopiedKey,
newAccount,
setNewAccount,
loading,
testing,
testingAll,
batchProgress,
addKey,
deleteKey,
addAccount,
deleteAccount,
testAccount,
testAllAccounts,
}
}

View File

@@ -0,0 +1,68 @@
import { useEffect, useState } from 'react'
export function useAccountsData({ apiFetch }) {
const [queueStatus, setQueueStatus] = useState(null)
const [keysExpanded, setKeysExpanded] = useState(false)
const [accounts, setAccounts] = useState([])
const [page, setPage] = useState(1)
const [pageSize] = useState(10)
const [totalPages, setTotalPages] = useState(1)
const [totalAccounts, setTotalAccounts] = useState(0)
const [loadingAccounts, setLoadingAccounts] = useState(false)
const resolveAccountIdentifier = (acc) => {
if (!acc || typeof acc !== 'object') return ''
return String(acc.identifier || acc.email || acc.mobile || '').trim()
}
const fetchAccounts = async (targetPage = page) => {
setLoadingAccounts(true)
try {
const res = await apiFetch(`/admin/accounts?page=${targetPage}&page_size=${pageSize}`)
if (res.ok) {
const data = await res.json()
setAccounts(data.items || [])
setTotalPages(data.total_pages || 1)
setTotalAccounts(data.total || 0)
setPage(data.page || 1)
}
} catch (e) {
console.error('Failed to fetch accounts:', e)
} finally {
setLoadingAccounts(false)
}
}
const fetchQueueStatus = async () => {
try {
const res = await apiFetch('/admin/queue/status')
if (res.ok) {
const data = await res.json()
setQueueStatus(data)
}
} catch (e) {
console.error('Failed to fetch queue status:', e)
}
}
useEffect(() => {
fetchAccounts()
fetchQueueStatus()
const interval = setInterval(fetchQueueStatus, 5000)
return () => clearInterval(interval)
}, [])
return {
queueStatus,
keysExpanded,
setKeysExpanded,
accounts,
page,
totalPages,
totalAccounts,
loadingAccounts,
fetchAccounts,
resolveAccountIdentifier,
}
}

View File

@@ -0,0 +1,109 @@
import clsx from 'clsx'
import { useI18n } from '../../i18n'
import { useApiTesterState } from './useApiTesterState'
import { useChatStreamClient } from './useChatStreamClient'
import ConfigPanel from './ConfigPanel'
import ChatPanel from './ChatPanel'
export default function ApiTesterContainer({ config, onMessage, authFetch }) {
const { t } = useI18n()
const {
model,
setModel,
message,
setMessage,
apiKey,
setApiKey,
selectedAccount,
setSelectedAccount,
response,
setResponse,
loading,
setLoading,
streamingContent,
setStreamingContent,
streamingThinking,
setStreamingThinking,
isStreaming,
setIsStreaming,
streamingMode,
setStreamingMode,
configExpanded,
setConfigExpanded,
abortControllerRef,
} = useApiTesterState({ t })
const accounts = config.accounts || []
const resolveAccountIdentifier = (acc) => {
if (!acc || typeof acc !== 'object') return ''
return String(acc.identifier || acc.email || acc.mobile || '').trim()
}
const configuredKeys = config.keys || []
const trimmedApiKey = apiKey.trim()
const defaultKey = configuredKeys[0] || ''
const effectiveKey = trimmedApiKey || defaultKey
const customKeyActive = trimmedApiKey !== ''
const customKeyManaged = customKeyActive && configuredKeys.includes(trimmedApiKey)
const models = [
{ id: 'deepseek-chat', name: 'deepseek-chat', icon: 'MessageSquare', desc: t('apiTester.models.chat'), color: 'text-amber-500' },
{ id: 'deepseek-reasoner', name: 'deepseek-reasoner', icon: 'Cpu', desc: t('apiTester.models.reasoner'), color: 'text-amber-600' },
{ id: 'deepseek-chat-search', name: 'deepseek-chat-search', icon: 'SearchIcon', desc: t('apiTester.models.chatSearch'), color: 'text-cyan-500' },
{ id: 'deepseek-reasoner-search', name: 'deepseek-reasoner-search', icon: 'SearchIcon', desc: t('apiTester.models.reasonerSearch'), color: 'text-cyan-600' },
]
const { runTest, stopGeneration } = useChatStreamClient({
t,
onMessage,
model,
message,
effectiveKey,
selectedAccount,
streamingMode,
abortControllerRef,
setLoading,
setIsStreaming,
setResponse,
setStreamingContent,
setStreamingThinking,
})
return (
<div className={clsx('flex flex-col lg:grid lg:grid-cols-12 gap-6 h-[calc(100vh-140px)]')}>
<ConfigPanel
t={t}
configExpanded={configExpanded}
setConfigExpanded={setConfigExpanded}
models={models}
model={model}
setModel={setModel}
streamingMode={streamingMode}
setStreamingMode={setStreamingMode}
selectedAccount={selectedAccount}
setSelectedAccount={setSelectedAccount}
accounts={accounts}
resolveAccountIdentifier={resolveAccountIdentifier}
apiKey={apiKey}
setApiKey={setApiKey}
config={config}
customKeyActive={customKeyActive}
customKeyManaged={customKeyManaged}
/>
<ChatPanel
t={t}
message={message}
setMessage={setMessage}
response={response}
isStreaming={isStreaming}
loading={loading}
streamingThinking={streamingThinking}
streamingContent={streamingContent}
onRunTest={runTest}
onStopGeneration={stopGeneration}
/>
</div>
)
}

View File

@@ -0,0 +1,110 @@
import { Bot, Loader2, Send, Square, User, Zap } from 'lucide-react'
import clsx from 'clsx'
export default function ChatPanel({
t,
message,
setMessage,
response,
isStreaming,
loading,
streamingThinking,
streamingContent,
onRunTest,
onStopGeneration,
}) {
return (
<div className="lg:col-span-9 flex flex-col bg-card border border-border rounded-xl shadow-sm overflow-hidden min-h-0 flex-1 relative">
<div className="flex-1 overflow-y-auto p-4 lg:p-6 space-y-8 custom-scrollbar scroll-smooth">
<div className="flex gap-4 max-w-4xl mx-auto flex-row-reverse group">
<div className="w-8 h-8 rounded-lg bg-secondary flex items-center justify-center shrink-0 border border-border">
<User className="w-4 h-4 text-muted-foreground" />
</div>
<div className="space-y-1 max-w-[85%] lg:max-w-[75%]">
<div className="bg-primary text-primary-foreground rounded-2xl rounded-tr-sm px-5 py-3 text-sm leading-relaxed shadow-sm">
{message}
</div>
</div>
</div>
{(response || isStreaming) && (
<div className="flex gap-4 max-w-4xl mx-auto animate-in fade-in slide-in-from-bottom-2 duration-300">
<div className={clsx(
"w-8 h-8 rounded-lg flex items-center justify-center shrink-0 border border-border",
response?.success !== false ? "bg-muted" : "bg-destructive/10 border-destructive/20"
)}>
<Bot className={clsx("w-4 h-4", response?.success !== false ? "text-foreground" : "text-destructive")} />
</div>
<div className="space-y-3 flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-semibold text-sm text-foreground">DeepSeek</span>
{response && (
<span className={clsx(
"text-[10px] px-1.5 py-0.5 rounded-sm border uppercase font-medium tracking-wider",
response.success ? "border-emerald-500/20 text-emerald-500 bg-emerald-500/10" : "border-destructive/20 text-destructive bg-destructive/10"
)}>
{response.status_code || t('apiTester.statusError')}
</span>
)}
</div>
{(streamingThinking || response?.choices?.[0]?.message?.reasoning_content) && (
<div className="text-xs bg-secondary/50 border border-border rounded-lg p-3 space-y-1.5">
<div className="flex items-center gap-1.5 text-muted-foreground">
<Zap className="w-3.5 h-3.5" />
<span className="font-medium">{t('apiTester.reasoningTrace')}</span>
</div>
<div className="whitespace-pre-wrap leading-relaxed text-muted-foreground font-mono text-[11px] max-h-60 overflow-y-auto custom-scrollbar pl-5 border-l-2 border-border/50">
{streamingThinking || response?.choices?.[0]?.message?.reasoning_content}
</div>
</div>
)}
<div className="text-sm leading-7 text-foreground whitespace-pre-wrap">
{streamingContent || response?.choices?.[0]?.message?.content || (response?.error && <span className="text-destructive font-medium">{response.error}</span>) || (loading && <span className="text-muted-foreground italic">{t('apiTester.generating')}</span>)}
{isStreaming && <span className="inline-block w-1.5 h-4 bg-primary ml-1 align-middle animate-pulse" />}
</div>
</div>
</div>
)}
</div>
<div className="p-4 lg:p-6 border-t border-border bg-card">
<div className="max-w-4xl mx-auto relative group">
<textarea
className="w-full bg-[#09090b] border border-border rounded-xl pl-4 pr-12 py-3 text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all resize-none custom-scrollbar placeholder:text-muted-foreground/50 text-foreground shadow-inner"
placeholder={t('apiTester.enterMessage')}
rows={1}
style={{ minHeight: '52px' }}
value={message}
onChange={e => setMessage(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
onRunTest()
}
}}
/>
<div className="absolute right-2 bottom-2">
{loading && isStreaming ? (
<button onClick={onStopGeneration} className="p-2 text-muted-foreground hover:text-destructive transition-colors">
<Square className="w-4 h-4 fill-current" />
</button>
) : (
<button
onClick={onRunTest}
disabled={loading || !message.trim()}
className="p-2 text-primary hover:text-primary/80 transition-colors 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 className="max-w-4xl mx-auto mt-3 flex justify-center">
<span className="text-[10px] text-muted-foreground/40 font-medium">{t('apiTester.adminConsoleLabel')}</span>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,174 @@
import {
ChevronDown,
MessageSquare,
Cpu,
Search as SearchIcon,
Terminal,
Zap,
ToggleLeft,
ToggleRight
} from 'lucide-react'
import clsx from 'clsx'
export default function ConfigPanel({
t,
configExpanded,
setConfigExpanded,
models,
model,
setModel,
streamingMode,
setStreamingMode,
selectedAccount,
setSelectedAccount,
accounts,
resolveAccountIdentifier,
apiKey,
setApiKey,
config,
customKeyActive,
customKeyManaged,
}) {
const iconMap = {
MessageSquare,
Cpu,
SearchIcon,
Terminal,
Zap,
ToggleLeft,
ToggleRight,
}
return (
<div className={clsx(
"lg:col-span-3 flex flex-col transition-all duration-300 ease-in-out z-20",
configExpanded ? "h-auto" : "h-14 lg:h-full"
)}>
<div className="bg-card border border-border rounded-xl flex flex-col h-full shadow-sm">
<button
onClick={() => setConfigExpanded(!configExpanded)}
className="lg:hidden flex items-center justify-between p-4 w-full bg-muted/20 hover:bg-muted/30 transition-colors"
>
<div className="flex items-center gap-2.5 font-medium text-sm text-foreground">
<div className="p-1.5 rounded-md bg-transparent text-foreground">
<Terminal className="w-4 h-4" />
</div>
<span>{t('apiTester.config')}</span>
</div>
<div className={clsx("transition-transform duration-300 text-muted-foreground", configExpanded ? "rotate-180" : "") }>
<ChevronDown className="w-4 h-4" />
</div>
</button>
<div className={clsx(
"p-4 space-y-6 overflow-y-auto custom-scrollbar flex-1",
!configExpanded && "hidden lg:block"
)}>
<div className="space-y-3">
<label className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider ml-0.5">{t('apiTester.modelLabel')}</label>
<div className="grid grid-cols-1 gap-2">
{models.map(m => {
const Icon = iconMap[m.icon] || MessageSquare
return (
<button
key={m.id}
onClick={() => setModel(m.id)}
className={clsx(
"group relative flex items-start gap-3 p-3 rounded-lg border text-left transition-all duration-200",
model === m.id
? "bg-secondary border-primary/50 shadow-sm"
: "bg-transparent border-transparent hover:bg-muted"
)}
>
<div className={clsx(
"p-1.5 rounded-md shrink-0 transition-colors",
model === m.id ? m.color : "text-muted-foreground group-hover:text-foreground"
)}>
<Icon className="w-4 h-4" />
</div>
<div className="min-w-0 flex-1">
<div className={clsx("font-medium text-sm", model === m.id ? "text-foreground" : "text-foreground/80") }>
{m.name}
</div>
<div className="text-[11px] text-muted-foreground mt-0.5">{m.desc}</div>
</div>
{model === m.id && (
<div className={clsx("absolute top-3 right-3", m.color)}>
<div className="w-1.5 h-1.5 rounded-full bg-current" />
</div>
)}
</button>
)
})}
</div>
</div>
<div className="space-y-2">
<label className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider ml-0.5">{t('apiTester.streamMode')}</label>
<button
onClick={() => setStreamingMode(!streamingMode)}
className={clsx(
"w-full flex items-center justify-between px-3 py-2 rounded-lg border transition-all duration-200",
streamingMode
? "bg-primary/10 border-primary/50 text-foreground"
: "bg-background border-border text-muted-foreground hover:bg-muted/50"
)}
>
<div className="flex items-center gap-2">
<div className={clsx("p-1.5 rounded-md", streamingMode ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground")}>
<Zap className="w-4 h-4" />
</div>
<span className="text-sm font-medium">{t('apiTester.streamMode')}</span>
</div>
{streamingMode ? <ToggleRight className="w-5 h-5 text-primary" /> : <ToggleLeft className="w-5 h-5 text-muted-foreground" />}
</button>
</div>
<div className="space-y-2">
<label className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider ml-0.5">{t('apiTester.accountSelector')}</label>
<div className="relative">
<select
className="w-full h-10 pl-3 pr-8 bg-secondary border border-border rounded-lg text-sm appearance-none focus:outline-none focus:ring-1 focus:ring-ring focus:border-ring transition-all cursor-pointer hover:bg-muted"
value={selectedAccount}
onChange={e => setSelectedAccount(e.target.value)}
>
<option value="" className="bg-popover text-popover-foreground">{t('apiTester.autoRandom')}</option>
{accounts.map((acc, i) => {
const id = resolveAccountIdentifier(acc)
if (!id) return null
return (
<option key={i} value={id} className="bg-popover text-popover-foreground">
👤 {id}
</option>
)
})}
</select>
<ChevronDown className="absolute right-2.5 top-3 w-4 h-4 text-muted-foreground pointer-events-none" />
</div>
</div>
<div className="space-y-2">
<label className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider ml-0.5">{t('apiTester.apiKeyOptional')}</label>
<input
type="text"
autoComplete="off"
spellCheck={false}
className="w-full h-10 px-3 bg-muted/30 border border-border rounded-lg text-sm font-mono placeholder:text-muted-foreground/40 focus:outline-none focus:ring-1 focus:ring-ring focus:border-ring transition-all"
placeholder={config.keys?.[0] ? t('apiTester.apiKeyDefault', { suffix: config.keys[0].slice(-6) }) : t('apiTester.apiKeyPlaceholder')}
value={apiKey}
onChange={e => setApiKey(e.target.value)}
/>
{customKeyActive && (
<p className={clsx(
"text-[11px] mt-1",
customKeyManaged ? "text-emerald-600" : "text-amber-600"
)}>
{customKeyManaged ? t('apiTester.modeManaged') : t('apiTester.modeDirect')}
</p>
)}
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,50 @@
import { useEffect, useRef, useState } from 'react'
export function useApiTesterState({ t }) {
const [model, setModel] = useState('deepseek-chat')
const defaultMessage = t('apiTester.defaultMessage')
const [message, setMessage] = useState(defaultMessage)
const [apiKey, setApiKey] = useState('')
const [selectedAccount, setSelectedAccount] = useState('')
const [response, setResponse] = useState(null)
const [loading, setLoading] = useState(false)
const [streamingContent, setStreamingContent] = useState('')
const [streamingThinking, setStreamingThinking] = useState('')
const [isStreaming, setIsStreaming] = useState(false)
const [streamingMode, setStreamingMode] = useState(true)
const [configExpanded, setConfigExpanded] = useState(false)
const abortControllerRef = useRef(null)
const defaultMessageRef = useRef(defaultMessage)
useEffect(() => {
setMessage((prev) => (prev === defaultMessageRef.current ? defaultMessage : prev))
defaultMessageRef.current = defaultMessage
}, [defaultMessage])
return {
model,
setModel,
message,
setMessage,
apiKey,
setApiKey,
selectedAccount,
setSelectedAccount,
response,
setResponse,
loading,
setLoading,
streamingContent,
setStreamingContent,
streamingThinking,
setStreamingThinking,
isStreaming,
setIsStreaming,
streamingMode,
setStreamingMode,
configExpanded,
setConfigExpanded,
abortControllerRef,
}
}

View File

@@ -0,0 +1,172 @@
import { useCallback } from 'react'
export function useChatStreamClient({
t,
onMessage,
model,
message,
effectiveKey,
selectedAccount,
streamingMode,
abortControllerRef,
setLoading,
setIsStreaming,
setResponse,
setStreamingContent,
setStreamingThinking,
}) {
const stopGeneration = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort()
abortControllerRef.current = null
}
setLoading(false)
setIsStreaming(false)
}, [abortControllerRef, setIsStreaming, setLoading])
const extractErrorMessage = useCallback(async (res) => {
let raw = ''
try {
raw = await res.text()
} catch {
return t('apiTester.requestFailed')
}
if (!raw) {
return t('apiTester.requestFailed')
}
try {
const data = JSON.parse(raw)
const fromErrorObject = data?.error?.message
const fromErrorString = typeof data?.error === 'string' ? data.error : ''
const detail = typeof data?.detail === 'string' ? data.detail : ''
const msg = typeof data?.message === 'string' ? data.message : ''
return fromErrorObject || fromErrorString || detail || msg || t('apiTester.requestFailed')
} catch {
return raw.length > 240 ? `${raw.slice(0, 240)}...` : raw
}
}, [t])
const runTest = useCallback(async () => {
if (!effectiveKey) {
onMessage('error', t('apiTester.missingApiKey'))
return
}
const startedAt = Date.now()
setLoading(true)
setIsStreaming(true)
setResponse(null)
setStreamingContent('')
setStreamingThinking('')
abortControllerRef.current = new AbortController()
try {
const headers = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${effectiveKey}`,
}
if (selectedAccount) {
headers['X-Ds2-Target-Account'] = selectedAccount
}
const endpoint = streamingMode ? '/v1/chat/completions' : '/v1/chat/completions?__go=1'
const res = await fetch(endpoint, {
method: 'POST',
headers,
body: JSON.stringify({
model,
messages: [{ role: 'user', content: message }],
stream: streamingMode,
}),
signal: abortControllerRef.current.signal,
})
if (!res.ok) {
const errorMsg = await extractErrorMessage(res)
setResponse({ success: false, error: errorMsg })
onMessage('error', errorMsg)
setLoading(false)
setIsStreaming(false)
return
}
if (streamingMode) {
setResponse({ success: true, status_code: res.status })
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed || !trimmed.startsWith('data: ')) continue
const dataStr = trimmed.slice(6)
if (dataStr === '[DONE]') continue
try {
const json = JSON.parse(dataStr)
const choice = json.choices?.[0]
if (choice?.delta) {
const delta = choice.delta
if (delta.reasoning_content) {
setStreamingThinking(prev => prev + delta.reasoning_content)
}
if (delta.content) {
setStreamingContent(prev => prev + delta.content)
}
}
} catch (e) {
console.error('Invalid JSON hunk:', dataStr, e)
}
}
}
} else {
const data = await res.json()
setResponse({ success: true, status_code: res.status, ...data })
const elapsed = Math.max(0, Date.now() - startedAt)
onMessage('success', t('apiTester.testSuccess', { account: selectedAccount || 'Auto', time: elapsed }))
}
} catch (e) {
if (e.name === 'AbortError') {
onMessage('info', t('messages.generationStopped'))
} else {
onMessage('error', t('apiTester.networkError', { error: e.message }))
setResponse({ error: e.message, success: false })
}
} finally {
setLoading(false)
setIsStreaming(false)
abortControllerRef.current = null
}
}, [
abortControllerRef,
effectiveKey,
extractErrorMessage,
message,
model,
onMessage,
selectedAccount,
setIsStreaming,
setLoading,
setResponse,
setStreamingContent,
setStreamingThinking,
streamingMode,
t,
])
return {
runTest,
stopGeneration,
}
}

View File

@@ -0,0 +1,64 @@
import { Download, Upload } from 'lucide-react'
export default function BackupSection({
t,
importMode,
setImportMode,
importing,
onLoadExportData,
onImport,
importText,
setImportText,
exportData,
}) {
return (
<div className="bg-card border border-border rounded-xl p-5 space-y-4">
<h3 className="font-semibold">{t('settings.backupTitle')}</h3>
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={onLoadExportData}
className="px-3 py-2 rounded-lg bg-secondary border border-border hover:bg-secondary/80 text-sm flex items-center gap-2"
>
<Download className="w-4 h-4" />
{t('settings.loadExport')}
</button>
<select
value={importMode}
onChange={(e) => setImportMode(e.target.value)}
className="bg-background border border-border rounded-lg px-3 py-2 text-sm"
>
<option value="merge">{t('settings.importModeMerge')}</option>
<option value="replace">{t('settings.importModeReplace')}</option>
</select>
<button
type="button"
onClick={onImport}
disabled={importing}
className="px-3 py-2 rounded-lg bg-secondary border border-border hover:bg-secondary/80 text-sm flex items-center gap-2"
>
<Upload className="w-4 h-4" />
{importing ? t('settings.importing') : t('settings.importNow')}
</button>
</div>
<textarea
value={importText}
onChange={(e) => setImportText(e.target.value)}
rows={8}
className="w-full bg-background border border-border rounded-lg px-3 py-2 font-mono text-xs"
placeholder={t('settings.importPlaceholder')}
/>
{exportData && (
<div className="space-y-2">
<label className="text-sm text-muted-foreground">{t('settings.exportJson')}</label>
<textarea
value={exportData.json || ''}
readOnly
rows={6}
className="w-full bg-background border border-border rounded-lg px-3 py-2 font-mono text-xs"
/>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,63 @@
export default function BehaviorSection({ t, form, setForm }) {
return (
<div className="bg-card border border-border rounded-xl p-5 space-y-4">
<h3 className="font-semibold">{t('settings.behaviorTitle')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="text-sm space-y-2">
<span className="text-muted-foreground">{t('settings.toolcallMode')}</span>
<select
value={form.toolcall.mode}
onChange={(e) => setForm((prev) => ({
...prev,
toolcall: { ...prev.toolcall, mode: e.target.value },
}))}
className="w-full bg-background border border-border rounded-lg px-3 py-2"
>
<option value="feature_match">feature_match</option>
<option value="off">off</option>
</select>
</label>
<label className="text-sm space-y-2">
<span className="text-muted-foreground">{t('settings.earlyEmitConfidence')}</span>
<select
value={form.toolcall.early_emit_confidence}
onChange={(e) => setForm((prev) => ({
...prev,
toolcall: { ...prev.toolcall, early_emit_confidence: e.target.value },
}))}
className="w-full bg-background border border-border rounded-lg px-3 py-2"
>
<option value="high">high</option>
<option value="low">low</option>
<option value="off">off</option>
</select>
</label>
<label className="text-sm space-y-2">
<span className="text-muted-foreground">{t('settings.responsesTTL')}</span>
<input
type="number"
min={30}
value={form.responses.store_ttl_seconds}
onChange={(e) => setForm((prev) => ({
...prev,
responses: { ...prev.responses, store_ttl_seconds: Number(e.target.value || 30) },
}))}
className="w-full bg-background border border-border rounded-lg px-3 py-2"
/>
</label>
<label className="text-sm space-y-2">
<span className="text-muted-foreground">{t('settings.embeddingsProvider')}</span>
<input
type="text"
value={form.embeddings.provider}
onChange={(e) => setForm((prev) => ({
...prev,
embeddings: { ...prev.embeddings, provider: e.target.value },
}))}
className="w-full bg-background border border-border rounded-lg px-3 py-2"
/>
</label>
</div>
</div>
)
}

View File

@@ -0,0 +1,27 @@
export default function ModelSection({ t, form, setForm }) {
return (
<div className="bg-card border border-border rounded-xl p-5 space-y-4">
<h3 className="font-semibold">{t('settings.modelTitle')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="text-sm space-y-2">
<span className="text-muted-foreground">{t('settings.claudeMapping')}</span>
<textarea
value={form.claude_mapping_text}
onChange={(e) => setForm((prev) => ({ ...prev, claude_mapping_text: e.target.value }))}
rows={8}
className="w-full bg-background border border-border rounded-lg px-3 py-2 font-mono text-xs"
/>
</label>
<label className="text-sm space-y-2">
<span className="text-muted-foreground">{t('settings.modelAliases')}</span>
<textarea
value={form.model_aliases_text}
onChange={(e) => setForm((prev) => ({ ...prev, model_aliases_text: e.target.value }))}
rows={8}
className="w-full bg-background border border-border rounded-lg px-3 py-2 font-mono text-xs"
/>
</label>
</div>
</div>
)
}

View File

@@ -0,0 +1,48 @@
export default function RuntimeSection({ t, form, setForm }) {
return (
<div className="bg-card border border-border rounded-xl p-5 space-y-4">
<h3 className="font-semibold">{t('settings.runtimeTitle')}</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<label className="text-sm space-y-2">
<span className="text-muted-foreground">{t('settings.accountMaxInflight')}</span>
<input
type="number"
min={1}
value={form.runtime.account_max_inflight}
onChange={(e) => setForm((prev) => ({
...prev,
runtime: { ...prev.runtime, account_max_inflight: Number(e.target.value || 1) },
}))}
className="w-full bg-background border border-border rounded-lg px-3 py-2"
/>
</label>
<label className="text-sm space-y-2">
<span className="text-muted-foreground">{t('settings.accountMaxQueue')}</span>
<input
type="number"
min={1}
value={form.runtime.account_max_queue}
onChange={(e) => setForm((prev) => ({
...prev,
runtime: { ...prev.runtime, account_max_queue: Number(e.target.value || 1) },
}))}
className="w-full bg-background border border-border rounded-lg px-3 py-2"
/>
</label>
<label className="text-sm space-y-2">
<span className="text-muted-foreground">{t('settings.globalMaxInflight')}</span>
<input
type="number"
min={1}
value={form.runtime.global_max_inflight}
onChange={(e) => setForm((prev) => ({
...prev,
runtime: { ...prev.runtime, global_max_inflight: Number(e.target.value || 1) },
}))}
className="w-full bg-background border border-border rounded-lg px-3 py-2"
/>
</label>
</div>
</div>
)
}

View File

@@ -0,0 +1,54 @@
import { Lock } from 'lucide-react'
export default function SecuritySection({
t,
form,
setForm,
newPassword,
setNewPassword,
changingPassword,
onUpdatePassword,
}) {
return (
<div className="bg-card border border-border rounded-xl p-5 space-y-4">
<h3 className="font-semibold">{t('settings.securityTitle')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="text-sm space-y-2">
<span className="text-muted-foreground">{t('settings.jwtExpireHours')}</span>
<input
type="number"
min={1}
max={720}
value={form.admin.jwt_expire_hours}
onChange={(e) => setForm((prev) => ({
...prev,
admin: { ...prev.admin, jwt_expire_hours: Number(e.target.value || 1) },
}))}
className="w-full bg-background border border-border rounded-lg px-3 py-2"
/>
</label>
<label className="text-sm space-y-2">
<span className="text-muted-foreground">{t('settings.newPassword')}</span>
<div className="flex gap-2">
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder={t('settings.newPasswordPlaceholder')}
className="w-full bg-background border border-border rounded-lg px-3 py-2"
/>
<button
type="button"
onClick={onUpdatePassword}
disabled={changingPassword}
className="px-3 py-2 rounded-lg bg-secondary border border-border hover:bg-secondary/80 text-sm flex items-center gap-1"
>
<Lock className="w-4 h-4" />
{changingPassword ? t('settings.updating') : t('settings.updatePassword')}
</button>
</div>
</label>
</div>
</div>
)
}

View File

@@ -0,0 +1,121 @@
import { AlertTriangle, Save } from 'lucide-react'
import { useI18n } from '../../i18n'
import { useSettingsForm } from './useSettingsForm'
import SecuritySection from './SecuritySection'
import RuntimeSection from './RuntimeSection'
import BehaviorSection from './BehaviorSection'
import ModelSection from './ModelSection'
import BackupSection from './BackupSection'
export default function SettingsContainer({ onRefresh, onMessage, authFetch, onForceLogout, isVercel = false }) {
const { t } = useI18n()
const apiFetch = authFetch || fetch
const {
form,
setForm,
loading,
saving,
changingPassword,
importing,
exportData,
importMode,
setImportMode,
importText,
setImportText,
newPassword,
setNewPassword,
consecutiveFailures,
autoFetchPaused,
lastError,
settingsMeta,
syncHintVisible,
retryLoadSettings,
saveSettings,
updatePassword,
loadExportData,
doImport,
} = useSettingsForm({
apiFetch,
t,
onMessage,
onRefresh,
onForceLogout,
isVercel,
})
return (
<div className="space-y-6">
{autoFetchPaused && (
<div className="p-4 rounded-lg border border-destructive/30 bg-destructive/10 text-destructive flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<AlertTriangle className="w-4 h-4" />
<span className="text-sm">
{t('settings.autoFetchPaused', { count: consecutiveFailures, error: lastError || t('settings.loadFailed') })}
</span>
</div>
<button
type="button"
onClick={retryLoadSettings}
className="px-3 py-1.5 text-xs rounded-md border border-destructive/40 hover:bg-destructive/10"
>
{t('settings.retryLoad')}
</button>
</div>
)}
{settingsMeta.default_password_warning && (
<div className="p-4 rounded-lg border border-amber-300/30 bg-amber-500/10 text-amber-700 flex items-center gap-2">
<AlertTriangle className="w-4 h-4" />
<span className="text-sm">{t('settings.defaultPasswordWarning')}</span>
</div>
)}
{syncHintVisible && (
<div className="p-4 rounded-lg border border-amber-300/30 bg-amber-500/10 text-amber-700 flex items-center gap-2">
<AlertTriangle className="w-4 h-4" />
<span className="text-sm">{t('settings.vercelSyncHint')}</span>
</div>
)}
<SecuritySection
t={t}
form={form}
setForm={setForm}
newPassword={newPassword}
setNewPassword={setNewPassword}
changingPassword={changingPassword}
onUpdatePassword={updatePassword}
/>
<RuntimeSection t={t} form={form} setForm={setForm} />
<BehaviorSection t={t} form={form} setForm={setForm} />
<ModelSection t={t} form={form} setForm={setForm} />
<BackupSection
t={t}
importMode={importMode}
setImportMode={setImportMode}
importing={importing}
onLoadExportData={loadExportData}
onImport={doImport}
importText={importText}
setImportText={setImportText}
exportData={exportData}
/>
<div className="flex justify-end">
<button
type="button"
onClick={saveSettings}
disabled={loading || saving}
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 flex items-center gap-2"
>
<Save className="w-4 h-4" />
{saving ? t('settings.saving') : t('settings.save')}
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,49 @@
export async function parseJSONResponse(res, t) {
const contentType = String(res.headers.get('content-type') || '').toLowerCase()
if (!contentType.includes('application/json')) {
throw new Error(t('settings.nonJsonResponse', { status: res.status }))
}
return res.json()
}
export async function fetchSettings(apiFetch, t) {
const res = await apiFetch('/admin/settings')
const data = await parseJSONResponse(res, t)
return { res, data }
}
export async function putSettings(apiFetch, payload) {
const res = await apiFetch('/admin/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const data = await res.json()
return { res, data }
}
export async function postPassword(apiFetch, newPassword) {
const res = await apiFetch('/admin/settings/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ new_password: newPassword }),
})
const data = await res.json()
return { res, data }
}
export async function getExportData(apiFetch) {
const res = await apiFetch('/admin/config/export')
const data = await res.json()
return { res, data }
}
export async function postImportData(apiFetch, mode, config) {
const res = await apiFetch(`/admin/config/import?mode=${encodeURIComponent(mode)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config, mode }),
})
const data = await res.json()
return { res, data }
}

View File

@@ -0,0 +1,290 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
fetchSettings,
getExportData,
postImportData,
postPassword,
putSettings,
} from './settingsApi'
const MAX_AUTO_FETCH_FAILURES = 3
const DEFAULT_FORM = {
admin: { jwt_expire_hours: 24 },
runtime: { account_max_inflight: 2, account_max_queue: 10, global_max_inflight: 10 },
toolcall: { mode: 'feature_match', early_emit_confidence: 'high' },
responses: { store_ttl_seconds: 900 },
embeddings: { provider: '' },
claude_mapping_text: '{\n "fast": "deepseek-chat",\n "slow": "deepseek-reasoner"\n}',
model_aliases_text: '{}',
}
function parseJSONMap(raw, fieldName, t) {
const text = String(raw || '').trim()
if (!text) {
return {}
}
let parsed
try {
parsed = JSON.parse(text)
} catch (_e) {
throw new Error(t('settings.invalidJsonField', { field: fieldName }))
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error(t('settings.invalidJsonField', { field: fieldName }))
}
return parsed
}
function fromServerForm(data) {
return {
admin: { jwt_expire_hours: Number(data.admin?.jwt_expire_hours || 24) },
runtime: {
account_max_inflight: Number(data.runtime?.account_max_inflight || 2),
account_max_queue: Number(data.runtime?.account_max_queue || 10),
global_max_inflight: Number(data.runtime?.global_max_inflight || 10),
},
toolcall: {
mode: data.toolcall?.mode || 'feature_match',
early_emit_confidence: data.toolcall?.early_emit_confidence || 'high',
},
responses: {
store_ttl_seconds: Number(data.responses?.store_ttl_seconds || 900),
},
embeddings: {
provider: data.embeddings?.provider || '',
},
claude_mapping_text: JSON.stringify(data.claude_mapping || {}, null, 2),
model_aliases_text: JSON.stringify(data.model_aliases || {}, null, 2),
}
}
function toServerPayload(form) {
return {
admin: { jwt_expire_hours: Number(form.admin.jwt_expire_hours) },
runtime: {
account_max_inflight: Number(form.runtime.account_max_inflight),
account_max_queue: Number(form.runtime.account_max_queue),
global_max_inflight: Number(form.runtime.global_max_inflight),
},
toolcall: {
mode: String(form.toolcall.mode || '').trim(),
early_emit_confidence: String(form.toolcall.early_emit_confidence || '').trim(),
},
responses: { store_ttl_seconds: Number(form.responses.store_ttl_seconds) },
embeddings: { provider: String(form.embeddings.provider || '').trim() },
}
}
export function useSettingsForm({ apiFetch, t, onMessage, onRefresh, onForceLogout, isVercel = false }) {
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [changingPassword, setChangingPassword] = useState(false)
const [importing, setImporting] = useState(false)
const [exportData, setExportData] = useState(null)
const [importMode, setImportMode] = useState('merge')
const [importText, setImportText] = useState('')
const [newPassword, setNewPassword] = useState('')
const [consecutiveFailures, setConsecutiveFailures] = useState(0)
const [autoFetchPaused, setAutoFetchPaused] = useState(false)
const [lastError, setLastError] = useState('')
const [settingsMeta, setSettingsMeta] = useState({
default_password_warning: false,
env_backed: false,
needs_vercel_sync: false,
})
const [form, setForm] = useState(DEFAULT_FORM)
const trackLoadFailure = useCallback(() => {
setConsecutiveFailures((prev) => {
const next = prev + 1
if (isVercel && next >= MAX_AUTO_FETCH_FAILURES) {
setAutoFetchPaused(true)
}
return next
})
}, [isVercel])
const loadSettings = useCallback(async ({ manual = false } = {}) => {
if (isVercel && autoFetchPaused && !manual) {
return
}
setLoading(true)
try {
const { res, data } = await fetchSettings(apiFetch, t)
if (!res.ok) {
const detail = data.detail || t('settings.loadFailed')
setLastError(detail)
onMessage('error', detail)
trackLoadFailure()
return
}
setConsecutiveFailures(0)
setAutoFetchPaused(false)
setLastError('')
setSettingsMeta({
default_password_warning: Boolean(data.admin?.default_password_warning),
env_backed: Boolean(data.env_backed),
needs_vercel_sync: Boolean(data.needs_vercel_sync),
})
setForm(fromServerForm(data))
} catch (e) {
const detail = e?.message || t('settings.loadFailed')
setLastError(detail)
onMessage('error', detail)
trackLoadFailure()
// eslint-disable-next-line no-console
console.error(e)
} finally {
setLoading(false)
}
}, [apiFetch, autoFetchPaused, isVercel, onMessage, t, trackLoadFailure])
useEffect(() => {
loadSettings()
}, [loadSettings])
const retryLoadSettings = useCallback(() => {
setAutoFetchPaused(false)
loadSettings({ manual: true })
}, [loadSettings])
const saveSettings = useCallback(async () => {
let claudeMapping = {}
let modelAliases = {}
try {
claudeMapping = parseJSONMap(form.claude_mapping_text, 'claude_mapping', t)
modelAliases = parseJSONMap(form.model_aliases_text, 'model_aliases', t)
} catch (e) {
onMessage('error', e.message)
return
}
const payload = {
...toServerPayload(form),
claude_mapping: claudeMapping,
model_aliases: modelAliases,
}
setSaving(true)
try {
const { res, data } = await putSettings(apiFetch, payload)
if (!res.ok) {
onMessage('error', data.detail || t('settings.saveFailed'))
return
}
onMessage('success', t('settings.saveSuccess'))
if (typeof onRefresh === 'function') {
onRefresh()
}
await loadSettings()
} catch (e) {
onMessage('error', t('settings.saveFailed'))
// eslint-disable-next-line no-console
console.error(e)
} finally {
setSaving(false)
}
}, [apiFetch, form, loadSettings, onMessage, onRefresh, t])
const updatePassword = useCallback(async () => {
if (String(newPassword || '').trim().length < 4) {
onMessage('error', t('settings.passwordTooShort'))
return
}
setChangingPassword(true)
try {
const { res, data } = await postPassword(apiFetch, newPassword.trim())
if (!res.ok) {
onMessage('error', data.detail || t('settings.passwordUpdateFailed'))
return
}
onMessage('success', t('settings.passwordUpdated'))
setNewPassword('')
if (typeof onForceLogout === 'function') {
onForceLogout()
}
} catch (_e) {
onMessage('error', t('settings.passwordUpdateFailed'))
} finally {
setChangingPassword(false)
}
}, [apiFetch, newPassword, onForceLogout, onMessage, t])
const loadExportData = useCallback(async () => {
try {
const { res, data } = await getExportData(apiFetch)
if (!res.ok) {
onMessage('error', data.detail || t('settings.exportFailed'))
return
}
setExportData(data)
onMessage('success', t('settings.exportLoaded'))
} catch (_e) {
onMessage('error', t('settings.exportFailed'))
}
}, [apiFetch, onMessage, t])
const doImport = useCallback(async () => {
if (!String(importText || '').trim()) {
onMessage('error', t('settings.importEmpty'))
return
}
let parsed
try {
parsed = JSON.parse(importText)
} catch (_e) {
onMessage('error', t('settings.importInvalidJson'))
return
}
setImporting(true)
try {
const { res, data } = await postImportData(apiFetch, importMode, parsed)
if (!res.ok) {
onMessage('error', data.detail || t('settings.importFailed'))
return
}
onMessage('success', t('settings.importSuccess', { mode: importMode }))
if (typeof onRefresh === 'function') {
onRefresh()
}
await loadSettings()
} catch (_e) {
onMessage('error', t('settings.importFailed'))
} finally {
setImporting(false)
}
}, [apiFetch, importMode, importText, loadSettings, onMessage, onRefresh, t])
const syncHintVisible = useMemo(
() => settingsMeta.env_backed || settingsMeta.needs_vercel_sync,
[settingsMeta.env_backed, settingsMeta.needs_vercel_sync],
)
return {
form,
setForm,
loading,
saving,
changingPassword,
importing,
exportData,
importMode,
setImportMode,
importText,
setImportText,
newPassword,
setNewPassword,
consecutiveFailures,
autoFetchPaused,
lastError,
settingsMeta,
syncHintVisible,
retryLoadSettings,
saveSettings,
updatePassword,
loadExportData,
doImport,
}
}

View File

@@ -0,0 +1,32 @@
import { Info } from 'lucide-react'
export default function VercelGuide({ t }) {
return (
<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" />
{t('vercel.howItWorks')}
</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">{t('vercel.steps.one')}</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">{t('vercel.steps.two')}</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">
{t('vercel.steps.three')} <code className="bg-background px-1 py-0.5 rounded border border-border text-xs">DS2API_CONFIG_JSON</code>
</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">{t('vercel.steps.four')}</p>
</li>
</ul>
</div>
)
}

View File

@@ -0,0 +1,58 @@
import { useI18n } from '../../i18n'
import { useVercelSyncState } from './useVercelSyncState'
import VercelSyncForm from './VercelSyncForm'
import VercelSyncStatus from './VercelSyncStatus'
import VercelGuide from './VercelGuide'
export default function VercelSyncContainer({ onMessage, authFetch, isVercel = false }) {
const { t } = useI18n()
const apiFetch = authFetch || fetch
const {
vercelToken,
setVercelToken,
projectId,
setProjectId,
teamId,
setTeamId,
loading,
result,
preconfig,
syncStatus,
pollPaused,
pollFailures,
handleManualRefresh,
handleSync,
} = useVercelSyncState({
apiFetch,
onMessage,
t,
isVercel,
})
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 max-w-5xl mx-auto h-[calc(100vh-140px)]">
<VercelSyncForm
t={t}
syncStatus={syncStatus}
pollPaused={pollPaused}
pollFailures={pollFailures}
onManualRefresh={handleManualRefresh}
preconfig={preconfig}
vercelToken={vercelToken}
setVercelToken={setVercelToken}
projectId={projectId}
setProjectId={setProjectId}
teamId={teamId}
setTeamId={setTeamId}
loading={loading}
onSync={handleSync}
/>
<div className="space-y-6">
<VercelSyncStatus t={t} result={result} />
<VercelGuide t={t} />
</div>
</div>
)
}

View File

@@ -0,0 +1,147 @@
import { ArrowRight, CheckCircle2, Cloud, ExternalLink, RefreshCw } from 'lucide-react'
import clsx from 'clsx'
export default function VercelSyncForm({
t,
syncStatus,
pollPaused,
pollFailures,
onManualRefresh,
preconfig,
vercelToken,
setVercelToken,
projectId,
setProjectId,
teamId,
setTeamId,
loading,
onSync,
}) {
return (
<div className="bg-card border border-border rounded-xl shadow-sm p-6 space-y-6">
<div className="border-b border-border pb-6">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold flex items-center gap-2">
<Cloud className="w-6 h-6 text-primary" />
{t('vercel.title')}
</h2>
{syncStatus && (
<div className={clsx(
"flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1 rounded-full border transition-colors",
syncStatus.synced
? "text-emerald-500 bg-emerald-500/10 border-emerald-500/20"
: syncStatus.has_synced_before
? "text-amber-500 bg-amber-500/10 border-amber-500/20"
: "text-muted-foreground bg-muted/50 border-border",
)}>
<span className={clsx(
"w-1.5 h-1.5 rounded-full",
syncStatus.synced ? "bg-emerald-500" : syncStatus.has_synced_before ? "bg-amber-500 animate-pulse" : "bg-muted-foreground",
)} />
{syncStatus.synced
? t('vercel.statusSynced')
: syncStatus.has_synced_before
? t('vercel.statusNotSynced')
: t('vercel.statusNeverSynced')}
</div>
)}
</div>
<p className="text-muted-foreground text-sm mt-1">
{t('vercel.description')}
</p>
{pollPaused && (
<div className="mt-2 flex flex-wrap items-center gap-2">
<p className="text-xs text-destructive">
{t('vercel.pollPaused', { count: pollFailures })}
</p>
<button
type="button"
onClick={onManualRefresh}
className="px-2 py-1 text-xs rounded border border-border hover:bg-secondary/50"
>
{t('vercel.manualRefresh')}
</button>
</div>
)}
{syncStatus?.last_sync_time && (
<p className="text-xs text-muted-foreground/60 mt-1.5 flex items-center gap-1">
<RefreshCw className="w-3 h-3" />
{t('vercel.lastSyncTime', { time: new Date(syncStatus.last_sync_time * 1000).toLocaleString() })}
</p>
)}
</div>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium flex items-center justify-between">
{t('vercel.tokenLabel')}
<a href="https://vercel.com/account/tokens" target="_blank" rel="noopener noreferrer" className="text-xs text-primary hover:underline flex items-center gap-1">
{t('vercel.getToken')} <ExternalLink className="w-3 h-3" />
</a>
</label>
<div className="relative">
<input
type="password"
className="w-full h-10 px-3 bg-background border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring transition-all pr-10"
placeholder={preconfig?.has_token ? t('vercel.tokenPlaceholderPreconfig') : t('vercel.tokenPlaceholder')}
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">{t('vercel.projectIdLabel')}</label>
<input
type="text"
className="w-full h-10 px-3 bg-background border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring transition-all"
placeholder="prj_xxxxxxxxxxxx or Project Name"
value={projectId}
onChange={e => setProjectId(e.target.value)}
/>
<p className="text-xs text-muted-foreground">{t('vercel.projectIdHint')}</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium flex items-center gap-2">
{t('vercel.teamIdLabel')} <span className="text-xs text-muted-foreground font-normal">({t('vercel.optional')})</span>
</label>
<input
type="text"
className="w-full h-10 px-3 bg-background border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring transition-all"
placeholder="team_xxxxxxxxxxxx"
value={teamId}
onChange={e => setTeamId(e.target.value)}
/>
</div>
</div>
<div className="pt-4">
<button
onClick={onSync}
disabled={loading}
className="w-full flex items-center justify-center gap-2 py-3 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-all font-medium text-sm shadow-sm hover:shadow-md disabled:opacity-50 disabled:shadow-none"
>
{loading ? (
<span className="flex items-center gap-2">
<span className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
{t('vercel.syncing')}
</span>
) : (
<span className="flex items-center gap-2">
{t('vercel.syncRedeploy')} <ArrowRight className="w-4 h-4" />
</span>
)}
</button>
<p className="text-xs text-center text-muted-foreground mt-4">
{t('vercel.redeployHint')}
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,37 @@
import { CheckCircle2, ExternalLink, XCircle } from 'lucide-react'
export default function VercelSyncStatus({ t, result }) {
if (!result) {
return null
}
return (
<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 ? t('vercel.syncSucceeded') : t('vercel.syncFailedLabel')}
</h3>
<p className="text-sm opacity-90">{result.message}</p>
{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">
{t('vercel.openDeployment')} <ExternalLink className="w-3 h-3" />
</a>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,154 @@
import { useCallback, useEffect, useState } from 'react'
const MAX_POLL_FAILURES = 3
function pollDelayMs(attempt) {
if (attempt <= 0) return 15000
if (attempt === 1) return 30000
return 60000
}
export function useVercelSyncState({ apiFetch, onMessage, t, isVercel = false }) {
const [vercelToken, setVercelToken] = useState('')
const [projectId, setProjectId] = useState('')
const [teamId, setTeamId] = useState('')
const [loading, setLoading] = useState(false)
const [result, setResult] = useState(null)
const [preconfig, setPreconfig] = useState(null)
const [syncStatus, setSyncStatus] = useState(null)
const [pollPaused, setPollPaused] = useState(false)
const [pollFailures, setPollFailures] = useState(0)
const [nextRetryAt, setNextRetryAt] = useState(null)
const fetchSyncStatus = useCallback(async ({ manual = false } = {}) => {
try {
const res = await apiFetch('/admin/vercel/status')
if (!res.ok) {
throw new Error(`status ${res.status}`)
}
const data = await res.json()
setSyncStatus(data)
setPollFailures(0)
setPollPaused(false)
setNextRetryAt(null)
} catch (e) {
setPollFailures((prev) => {
const next = prev + 1
if (isVercel) {
if (next >= MAX_POLL_FAILURES) {
setPollPaused(true)
setNextRetryAt(null)
} else {
setNextRetryAt(Date.now() + pollDelayMs(next))
}
}
return next
})
if (manual) {
onMessage('error', t('vercel.networkError'))
}
// eslint-disable-next-line no-console
console.error('Failed to fetch sync status:', e)
}
}, [apiFetch, isVercel, onMessage, t])
useEffect(() => {
const loadPreconfig = async () => {
try {
const res = await apiFetch('/admin/vercel/config')
if (res.ok) {
const data = await res.json()
setPreconfig(data)
if (data.project_id) setProjectId(data.project_id)
if (data.team_id) setTeamId(data.team_id)
}
} catch (e) {
// eslint-disable-next-line no-console
console.error('Failed to load preconfig:', e)
}
}
loadPreconfig()
fetchSyncStatus()
}, [apiFetch, fetchSyncStatus])
useEffect(() => {
if (!isVercel) {
const interval = setInterval(() => {
fetchSyncStatus()
}, 15000)
return () => clearInterval(interval)
}
if (pollPaused) {
return undefined
}
const delay = nextRetryAt && nextRetryAt > Date.now() ? nextRetryAt - Date.now() : pollDelayMs(pollFailures)
const timer = setTimeout(() => {
fetchSyncStatus()
}, Math.max(1000, delay))
return () => clearTimeout(timer)
}, [fetchSyncStatus, isVercel, nextRetryAt, pollFailures, pollPaused])
const handleManualRefresh = useCallback(() => {
setPollPaused(false)
setPollFailures(0)
setNextRetryAt(null)
fetchSyncStatus({ manual: true })
}, [fetchSyncStatus])
const handleSync = useCallback(async () => {
const tokenToUse = preconfig?.has_token && !vercelToken ? '__USE_PRECONFIG__' : vercelToken
if (!tokenToUse && !preconfig?.has_token) {
onMessage('error', t('vercel.tokenRequired'))
return
}
if (!projectId) {
onMessage('error', t('vercel.projectRequired'))
return
}
setLoading(true)
setResult(null)
try {
const res = await apiFetch('/admin/vercel/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
vercel_token: tokenToUse,
project_id: projectId,
team_id: teamId || undefined,
}),
})
const data = await res.json()
if (res.ok) {
setResult({ ...data, success: true })
onMessage('success', data.message)
fetchSyncStatus()
} else {
setResult({ ...data, success: false })
onMessage('error', data.detail || t('vercel.syncFailed'))
}
} catch (_e) {
onMessage('error', t('vercel.networkError'))
} finally {
setLoading(false)
}
}, [apiFetch, fetchSyncStatus, onMessage, preconfig?.has_token, projectId, t, teamId, vercelToken])
return {
vercelToken,
setVercelToken,
projectId,
setProjectId,
teamId,
setTeamId,
loading,
result,
preconfig,
syncStatus,
pollPaused,
pollFailures,
handleManualRefresh,
handleSync,
}
}

View File

@@ -0,0 +1,198 @@
import { useCallback, useState } from 'react'
import {
LayoutDashboard,
Upload,
Cloud,
Settings as SettingsIcon,
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 Settings from '../components/Settings'
import LanguageToggle from '../components/LanguageToggle'
import { useI18n } from '../i18n'
export default function DashboardShell({ token, onLogout, config, fetchConfig, showMessage, message, onForceLogout, isVercel }) {
const { t } = useI18n()
const [activeTab, setActiveTab] = useState('accounts')
const [sidebarOpen, setSidebarOpen] = useState(false)
const navItems = [
{ id: 'accounts', label: t('nav.accounts.label'), icon: Users, description: t('nav.accounts.desc') },
{ id: 'test', label: t('nav.test.label'), icon: Server, description: t('nav.test.desc') },
{ id: 'import', label: t('nav.import.label'), icon: Upload, description: t('nav.import.desc') },
{ id: 'vercel', label: t('nav.vercel.label'), icon: Cloud, description: t('nav.vercel.desc') },
{ id: 'settings', label: t('nav.settings.label'), icon: SettingsIcon, description: t('nav.settings.desc') },
]
const authFetch = useCallback(async (url, options = {}) => {
const headers = {
...options.headers,
'Authorization': `Bearer ${token}`
}
const res = await fetch(url, { ...options, headers })
if (res.status === 401) {
onLogout()
throw new Error(t('auth.expired'))
}
return res
}, [onLogout, t, token])
const renderTab = () => {
switch (activeTab) {
case 'accounts':
return <AccountManager config={config} onRefresh={fetchConfig} onMessage={showMessage} authFetch={authFetch} />
case 'test':
return <ApiTester config={config} onMessage={showMessage} authFetch={authFetch} />
case 'import':
return <BatchImport onRefresh={fetchConfig} onMessage={showMessage} authFetch={authFetch} />
case 'vercel':
return <VercelSync onMessage={showMessage} authFetch={authFetch} isVercel={isVercel} />
case 'settings':
return <Settings onRefresh={fetchConfig} onMessage={showMessage} authFetch={authFetch} onForceLogout={onForceLogout} isVercel={isVercel} />
default:
return null
}
}
return (
<div className="flex h-screen bg-background overflow-hidden text-foreground">
{sidebarOpen && (
<div
className="fixed inset-0 bg-background/80 backdrop-blur-sm z-40 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
<aside className={clsx(
"fixed lg:static inset-y-0 left-0 z-50 w-64 bg-card border-r border-border transition-transform duration-300 ease-in-out lg:transform-none flex flex-col shadow-2xl lg:shadow-none",
sidebarOpen ? "translate-x-0" : "-translate-x-full"
)}>
<div className="p-6">
<div className="flex items-center gap-2.5 font-bold text-xl text-foreground tracking-tight">
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center text-primary-foreground shadow-lg shadow-primary/20">
<LayoutDashboard className="w-5 h-5" />
</div>
<span>DS2API</span>
</div>
<div className="flex items-center justify-between mt-2">
<p className="text-[10px] text-muted-foreground font-semibold tracking-[0.1em] uppercase opacity-60 px-1">{t('sidebar.onlineAdminConsole')}</p>
<LanguageToggle />
</div>
</div>
<nav className="flex-1 px-3 space-y-1 overflow-y-auto pt-2">
{navItems.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 border",
isActive
? "bg-secondary text-primary border-border shadow-sm"
: "text-muted-foreground border-transparent hover:bg-secondary/80 hover:text-foreground"
)}
>
<Icon className={clsx("w-4 h-4 transition-colors", isActive ? "text-primary" : "text-muted-foreground group-hover:text-foreground")} />
<span className="flex-1 text-left">{item.label}</span>
{isActive && <div className="w-1.5 h-1.5 rounded-full bg-primary" />}
</button>
)
})}
</nav>
<div className="p-4 border-t border-border bg-card">
<div className="space-y-4">
<div className="flex items-center justify-between text-sm px-1">
<span className="text-muted-foreground font-semibold text-[10px] uppercase tracking-wider">{t('sidebar.systemStatus')}</span>
<span className="flex items-center gap-1.5 text-[10px] font-bold text-emerald-500 bg-emerald-500/10 px-2 py-0.5 rounded-full border border-emerald-500/20">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse"></span>
{t('sidebar.statusOnline')}
</span>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="bg-background rounded-lg p-3 border border-border shadow-sm">
<div className="text-[9px] text-muted-foreground font-bold uppercase tracking-wider mb-0.5 opacity-70">{t('sidebar.accounts')}</div>
<div className="text-lg font-bold text-foreground leading-tight">{config.accounts?.length || 0}</div>
</div>
<div className="bg-background rounded-lg p-3 border border-border shadow-sm">
<div className="text-[9px] text-muted-foreground font-bold uppercase tracking-wider mb-0.5 opacity-70">{t('sidebar.keys')}</div>
<div className="text-lg font-bold text-foreground">{config.keys?.length || 0}</div>
</div>
</div>
<button
onClick={onLogout}
className="w-full h-10 flex items-center justify-center gap-2 rounded-lg border border-border text-xs font-medium text-muted-foreground hover:bg-destructive/10 hover:text-destructive hover:border-destructive/20 transition-all"
>
<LogOut className="w-3.5 h-3.5" />
{t('sidebar.signOut')}
</button>
</div>
</div>
</aside>
<main className="flex-1 flex flex-col min-w-0 overflow-hidden relative">
<header className="lg:hidden h-14 flex items-center justify-between px-4 border-b border-border bg-card">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded bg-primary flex items-center justify-center text-primary-foreground text-[10px]">
<LayoutDashboard className="w-3.5 h-3.5" />
</div>
<span className="font-semibold text-sm">DS2API</span>
</div>
<div className="flex items-center gap-2">
<LanguageToggle />
<button
onClick={() => setSidebarOpen(true)}
className="p-2 -mr-2 text-muted-foreground hover:text-foreground"
>
<Menu className="w-5 h-5" />
</button>
</div>
</header>
<div className="flex-1 overflow-auto bg-background p-4 lg:p-10">
<div className="max-w-6xl mx-auto space-y-4 lg:space-y-6">
<div className="hidden lg:block mb-8">
<h1 className="text-3xl font-bold tracking-tight mb-2">
{navItems.find(n => n.id === activeTab)?.label}
</h1>
<p className="text-muted-foreground">
{navItems.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">
{renderTab()}
</div>
</div>
</div>
</main>
</div>
)
}