feat: Initialize the core backend API server, frontend application structure, and implement batch RT import functionality.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { Routes, Route } from 'react-router-dom'
|
||||
import { ConfigProvider, RecordsProvider } from './context'
|
||||
import { Layout } from './components/layout'
|
||||
import { Dashboard, Upload, Records, Accounts, Config, S2AConfig, EmailConfig, Monitor, Cleaner, TeamReg, CodexProxyConfig, S2AStats } from './pages'
|
||||
import { Dashboard, Upload, Records, Accounts, Config, S2AConfig, EmailConfig, Monitor, Cleaner, TeamReg, CodexProxyConfig, S2AStats, BatchRTImport } from './pages'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@@ -21,6 +21,7 @@ function App() {
|
||||
<Route path="config/email" element={<EmailConfig />} />
|
||||
<Route path="config/codex-proxy" element={<CodexProxyConfig />} />
|
||||
<Route path="s2a-stats" element={<S2AStats />} />
|
||||
<Route path="rt-import" element={<BatchRTImport />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</RecordsProvider>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
UserPlus,
|
||||
Globe,
|
||||
BarChart3,
|
||||
KeyRound,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface SidebarProps {
|
||||
@@ -40,6 +41,7 @@ const navItems: NavItem[] = [
|
||||
{ to: '/s2a-stats', icon: BarChart3, label: 'S2A 统计' },
|
||||
{ to: '/cleaner', icon: Trash2, label: '定期清理' },
|
||||
{ to: '/team-reg', icon: UserPlus, label: 'Team 注册' },
|
||||
{ to: '/rt-import', icon: KeyRound, label: 'RT 导入' },
|
||||
{
|
||||
to: '/config',
|
||||
icon: Settings,
|
||||
|
||||
518
frontend/src/pages/BatchRTImport.tsx
Normal file
518
frontend/src/pages/BatchRTImport.tsx
Normal file
@@ -0,0 +1,518 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
KeyRound,
|
||||
Settings,
|
||||
Play,
|
||||
Square,
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
RefreshCw,
|
||||
Upload,
|
||||
FileText,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react'
|
||||
import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common'
|
||||
import { useConfig } from '../hooks/useConfig'
|
||||
|
||||
interface RTImportResult {
|
||||
index: number
|
||||
rt: string
|
||||
email: string
|
||||
success: boolean
|
||||
error?: string
|
||||
account_id?: number
|
||||
}
|
||||
|
||||
interface RTImportStatus {
|
||||
running: boolean
|
||||
started_at: string
|
||||
total: number
|
||||
completed: number
|
||||
success: number
|
||||
failed: number
|
||||
results: RTImportResult[]
|
||||
elapsed_ms: number
|
||||
}
|
||||
|
||||
export default function BatchRTImport() {
|
||||
const { config } = useConfig()
|
||||
|
||||
const [tokens, setTokens] = useState<string[]>([])
|
||||
const [prefix, setPrefix] = useState<'team' | 'free'>('team')
|
||||
const [concurrency, setConcurrency] = useState(3)
|
||||
const [priority, setPriority] = useState(10)
|
||||
const [rateMultiplier, setRateMultiplier] = useState(1.0)
|
||||
const [status, setStatus] = useState<RTImportStatus | null>(null)
|
||||
const [polling, setPolling] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [fileError, setFileError] = useState<string | null>(null)
|
||||
const [fileName, setFileName] = useState<string | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const hasConfig = config.s2a.apiBase && config.s2a.adminKey
|
||||
|
||||
// 轮询状态
|
||||
const fetchStatus = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/rt-import/status')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
if (data.code === 0) {
|
||||
setStatus(data.data)
|
||||
if (!data.data.running) {
|
||||
setPolling(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取状态失败:', e)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus()
|
||||
}, [fetchStatus])
|
||||
|
||||
useEffect(() => {
|
||||
if (polling) {
|
||||
const interval = setInterval(fetchStatus, 2000)
|
||||
return () => clearInterval(interval)
|
||||
}
|
||||
}, [polling, fetchStatus])
|
||||
|
||||
// 处理文件上传
|
||||
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
setFileError(null)
|
||||
setFileName(file.name)
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = (ev) => {
|
||||
const text = ev.target?.result as string
|
||||
const lines = text
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0)
|
||||
|
||||
if (lines.length === 0) {
|
||||
setFileError('文件中没有找到任何 Refresh Token')
|
||||
setTokens([])
|
||||
return
|
||||
}
|
||||
setTokens(lines)
|
||||
}
|
||||
reader.onerror = () => {
|
||||
setFileError('文件读取失败')
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}, [])
|
||||
|
||||
// 拖拽
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragOver(false)
|
||||
const file = e.dataTransfer.files?.[0]
|
||||
if (!file) return
|
||||
setFileError(null)
|
||||
setFileName(file.name)
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = (ev) => {
|
||||
const text = ev.target?.result as string
|
||||
const lines = text
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0)
|
||||
|
||||
if (lines.length === 0) {
|
||||
setFileError('文件中没有找到任何 Refresh Token')
|
||||
setTokens([])
|
||||
return
|
||||
}
|
||||
setTokens(lines)
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}, [])
|
||||
|
||||
// 开始导入
|
||||
const handleStart = useCallback(async () => {
|
||||
if (tokens.length === 0) {
|
||||
setFileError('请先上传 Refresh Token 文件')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/rt-import/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
tokens,
|
||||
prefix,
|
||||
concurrency,
|
||||
priority,
|
||||
rate_multiplier: rateMultiplier,
|
||||
group_ids: [],
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setPolling(true)
|
||||
fetchStatus()
|
||||
} else {
|
||||
const data = await res.json()
|
||||
setFileError(data.message || '启动失败')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('启动失败:', e)
|
||||
setFileError('启动失败')
|
||||
}
|
||||
setLoading(false)
|
||||
}, [tokens, prefix, concurrency, priority, rateMultiplier, fetchStatus])
|
||||
|
||||
// 停止导入
|
||||
const handleStop = useCallback(async () => {
|
||||
try {
|
||||
await fetch('/api/rt-import/stop', { method: 'POST' })
|
||||
setPolling(false)
|
||||
fetchStatus()
|
||||
} catch (e) {
|
||||
console.error('停止失败:', e)
|
||||
}
|
||||
}, [fetchStatus])
|
||||
|
||||
const isRunning = status?.running || polling
|
||||
const progressPercent = status && status.total > 0
|
||||
? Math.round((status.completed / status.total) * 100)
|
||||
: 0
|
||||
|
||||
return (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl sm:text-2xl font-bold text-slate-900 dark:text-slate-100 flex items-center gap-2">
|
||||
<KeyRound className="h-6 w-6 sm:h-7 sm:w-7 text-purple-500" />
|
||||
RT 批量导入
|
||||
</h1>
|
||||
<p className="text-xs sm:text-sm text-slate-500 dark:text-slate-400">
|
||||
上传 Refresh Token 文件,验证并批量创建 S2A 账号
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fetchStatus()}
|
||||
icon={<RefreshCw className={`h-4 w-4 ${polling ? 'animate-spin' : ''}`} />}
|
||||
>
|
||||
<span className="hidden sm:inline">刷新</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Connection Warning */}
|
||||
{!hasConfig && (
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Settings className="h-5 w-5 text-yellow-600 dark:text-yellow-400 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-yellow-800 dark:text-yellow-200">请先配置 S2A 连接</p>
|
||||
<Link to="/config/s2a" className="mt-3 inline-block">
|
||||
<Button size="sm" variant="outline">前往设置</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Overview */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 sm:gap-3">
|
||||
<div className={`p-2.5 sm:p-3 rounded-lg border ${isRunning ? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800' : 'bg-slate-50 dark:bg-slate-800/50 border-slate-200 dark:border-slate-700'}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
{isRunning ? (
|
||||
<Loader2 className="h-4 w-4 sm:h-5 sm:w-5 text-green-500 animate-spin shrink-0" />
|
||||
) : (
|
||||
<div className="h-4 w-4 sm:h-5 sm:w-5 rounded-full bg-slate-300 dark:bg-slate-600 shrink-0" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs text-slate-500">状态</div>
|
||||
<div className={`font-bold text-sm sm:text-base ${isRunning ? 'text-green-600' : 'text-slate-600 dark:text-slate-300'}`}>
|
||||
{isRunning ? '导入中' : '空闲'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2.5 sm:p-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800/50">
|
||||
<div className="text-xs text-blue-600/70 dark:text-blue-400/70">总计</div>
|
||||
<div className="font-bold text-sm sm:text-base text-blue-600 dark:text-blue-400">{status?.total || tokens.length || 0}</div>
|
||||
</div>
|
||||
<div className="p-2.5 sm:p-3 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800/50">
|
||||
<div className="text-xs text-green-600/70 dark:text-green-400/70">成功</div>
|
||||
<div className="font-bold text-sm sm:text-base text-green-600 dark:text-green-400">{status?.success || 0}</div>
|
||||
</div>
|
||||
<div className="p-2.5 sm:p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800/50">
|
||||
<div className="text-xs text-red-600/70 dark:text-red-400/70">失败</div>
|
||||
<div className="font-bold text-sm sm:text-base text-red-600 dark:text-red-400">{status?.failed || 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{isRunning && status && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between text-sm text-slate-600 dark:text-slate-400">
|
||||
<span>进度: {status.completed}/{status.total}</span>
|
||||
<span>{progressPercent}%</span>
|
||||
</div>
|
||||
<div className="h-2.5 bg-slate-200 dark:bg-slate-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-purple-500 to-indigo-500 rounded-full transition-all duration-500"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Left: Upload & Config */}
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* File Upload */}
|
||||
<Card hoverable>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Upload className="h-4 w-4 text-purple-500" />
|
||||
上传 RT 文件
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".txt,.text"
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-xl p-6 sm:p-8 text-center cursor-pointer transition-all duration-200 ${dragOver
|
||||
? 'border-purple-400 bg-purple-50 dark:bg-purple-900/20'
|
||||
: 'border-slate-300 dark:border-slate-600 hover:border-purple-300 dark:hover:border-purple-700'
|
||||
} ${isRunning ? 'opacity-50 pointer-events-none' : ''}`}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<FileText className="h-10 w-10 text-slate-400 dark:text-slate-500 mx-auto mb-3" />
|
||||
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
点击或拖拽上传 refresh_tokens.txt
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
|
||||
每行一个 Refresh Token
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* File info / error */}
|
||||
{fileName && tokens.length > 0 && (
|
||||
<div className="mt-3 p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg flex items-center gap-2">
|
||||
<CheckCircle className="h-4 w-4 text-green-500 shrink-0" />
|
||||
<span className="text-sm text-green-700 dark:text-green-300">
|
||||
<strong>{fileName}</strong>: 共 {tokens.length} 个 Refresh Token
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{fileError && (
|
||||
<div className="mt-3 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-red-500 shrink-0" />
|
||||
<span className="text-sm text-red-700 dark:text-red-300">{fileError}</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Config */}
|
||||
<Card hoverable>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Settings className="h-4 w-4 text-blue-500" />
|
||||
导入配置
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Prefix selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
账号前缀
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={() => setPrefix('team')}
|
||||
disabled={isRunning}
|
||||
className={`p-3 rounded-xl border-2 text-center font-medium text-sm transition-all duration-200 ${prefix === 'team'
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 shadow-sm'
|
||||
: 'border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400 hover:border-blue-300 dark:hover:border-blue-700'
|
||||
} ${isRunning ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
>
|
||||
<div className="text-lg mb-0.5">👥</div>
|
||||
<div>team</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">team-email 格式</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPrefix('free')}
|
||||
disabled={isRunning}
|
||||
className={`p-3 rounded-xl border-2 text-center font-medium text-sm transition-all duration-200 ${prefix === 'free'
|
||||
? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 shadow-sm'
|
||||
: 'border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400 hover:border-emerald-300 dark:hover:border-emerald-700'
|
||||
} ${isRunning ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
>
|
||||
<div className="text-lg mb-0.5">🆓</div>
|
||||
<div>free</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">free-email 格式</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Parameters */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Input
|
||||
label="并发数"
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
value={concurrency}
|
||||
onChange={(e) => setConcurrency(Number(e.target.value))}
|
||||
disabled={isRunning}
|
||||
/>
|
||||
<Input
|
||||
label="优先级"
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(Number(e.target.value))}
|
||||
disabled={isRunning}
|
||||
/>
|
||||
<Input
|
||||
label="计费倍率"
|
||||
type="number"
|
||||
min={0.1}
|
||||
max={10}
|
||||
step={0.1}
|
||||
value={rateMultiplier}
|
||||
onChange={(e) => setRateMultiplier(Number(e.target.value))}
|
||||
disabled={isRunning}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
{isRunning ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleStop}
|
||||
className="flex-1"
|
||||
icon={<Square className="h-4 w-4" />}
|
||||
>
|
||||
停止导入
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleStart}
|
||||
loading={loading}
|
||||
disabled={!hasConfig || tokens.length === 0}
|
||||
className="flex-1"
|
||||
icon={<Play className="h-4 w-4" />}
|
||||
>
|
||||
开始导入 ({tokens.length} 个)
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right: Results */}
|
||||
<Card hoverable>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
导入结果
|
||||
{status && status.results.length > 0 && (
|
||||
<span className="text-xs font-normal text-slate-500 ml-1">
|
||||
({status.results.length} 条)
|
||||
</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{(!status || status.results.length === 0) ? (
|
||||
<div className="text-center py-12 text-slate-400 dark:text-slate-500">
|
||||
<KeyRound className="h-12 w-12 mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm">暂无导入记录</p>
|
||||
<p className="text-xs mt-1">上传 RT 文件并开始导入后,结果将显示在这里</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5 max-h-[500px] overflow-y-auto pr-1">
|
||||
{status.results.map((r) => (
|
||||
<div
|
||||
key={r.index}
|
||||
className={`flex items-center gap-2 p-2 rounded-lg text-sm ${r.success
|
||||
? 'bg-green-50 dark:bg-green-900/10 border border-green-200/50 dark:border-green-800/30'
|
||||
: 'bg-red-50 dark:bg-red-900/10 border border-red-200/50 dark:border-red-800/30'
|
||||
}`}
|
||||
>
|
||||
{r.success ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-500 shrink-0" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 text-red-500 shrink-0" />
|
||||
)}
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400 shrink-0 w-8">
|
||||
#{r.index}
|
||||
</span>
|
||||
<span className="font-medium text-slate-700 dark:text-slate-300 truncate flex-1 min-w-0">
|
||||
{r.email || r.rt}
|
||||
</span>
|
||||
{r.success && r.account_id ? (
|
||||
<span className="text-xs text-green-600 dark:text-green-400 shrink-0">
|
||||
ID: {r.account_id}
|
||||
</span>
|
||||
) : r.error ? (
|
||||
<span className="text-xs text-red-600 dark:text-red-400 shrink-0 max-w-[200px] truncate" title={r.error}>
|
||||
{r.error}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
{status && !status.running && status.completed > 0 && (
|
||||
<div className="mt-4 p-3 bg-slate-50 dark:bg-slate-800/50 rounded-lg border border-slate-200 dark:border-slate-700">
|
||||
<div className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">导入完成</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-center text-sm">
|
||||
<div>
|
||||
<div className="text-xs text-slate-500">总计</div>
|
||||
<div className="font-bold text-slate-700 dark:text-slate-300">{status.completed}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-green-600">成功</div>
|
||||
<div className="font-bold text-green-600">{status.success}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-red-600">失败</div>
|
||||
<div className="font-bold text-red-600">{status.failed}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mt-2 text-center">
|
||||
耗时: {(status.elapsed_ms / 1000).toFixed(1)} 秒
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -10,5 +10,6 @@ export { default as Cleaner } from './Cleaner'
|
||||
export { default as TeamReg } from './TeamReg'
|
||||
export { default as CodexProxyConfig } from './CodexProxyConfig'
|
||||
export { default as S2AStats } from './S2AStats'
|
||||
export { default as BatchRTImport } from './BatchRTImport'
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user