feat: Initialize the core backend API server, frontend application structure, and implement batch RT import functionality.

This commit is contained in:
2026-02-08 18:35:01 +08:00
parent 571322ffcb
commit 76de666560
7 changed files with 952 additions and 1 deletions

1
.gitignore vendored
View File

@@ -106,3 +106,4 @@ CodexAuth
get_code.go get_code.go
chatgpt-owner-demote chatgpt-owner-demote
batch_import_openai_rt.py

View File

@@ -186,6 +186,11 @@ func startServer(cfg *config.Config) {
// Owner 降级 API // Owner 降级 API
mux.HandleFunc("/api/demote/owner", api.CORS(api.HandleDemoteOwner)) mux.HandleFunc("/api/demote/owner", api.CORS(api.HandleDemoteOwner))
// 批量 RT 导入 API
mux.HandleFunc("/api/rt-import/start", api.CORS(api.HandleRTImportStart))
mux.HandleFunc("/api/rt-import/status", api.CORS(api.HandleRTImportStatus))
mux.HandleFunc("/api/rt-import/stop", api.CORS(api.HandleRTImportStop))
// 嵌入的前端静态文件 // 嵌入的前端静态文件
if web.IsEmbedded() { if web.IsEmbedded() {
webFS := web.GetFileSystem() webFS := web.GetFileSystem()

View File

@@ -0,0 +1,423 @@
package api
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"sync"
"sync/atomic"
"time"
"codex-pool/internal/config"
"codex-pool/internal/logger"
)
// ============================================================================
// 批量 RT 导入模块
// 功能: 读取 Refresh Token 列表,通过 S2A API 验证并创建账号
// ============================================================================
// rtImportRequest 导入请求
type rtImportRequest struct {
Tokens []string `json:"tokens"`
Prefix string `json:"prefix"` // "team" 或 "free"
Concurrency int `json:"concurrency"` // S2A 账号并发数
Priority int `json:"priority"` // S2A 账号优先级
GroupIDs []int `json:"group_ids"` // S2A 分组ID
ProxyID *int `json:"proxy_id"` // S2A 代理ID
RateMultiplier float64 `json:"rate_multiplier"` // 计费倍率
}
// rtImportResult 单条导入结果
type rtImportResult struct {
Index int `json:"index"`
RT string `json:"rt"` // 脱敏后的 RT 前缀
Email string `json:"email"` // 验证得到的邮箱
Success bool `json:"success"`
Error string `json:"error,omitempty"`
AcctID int `json:"account_id,omitempty"` // S2A 创建的账号ID
}
// rtImportState 导入任务状态
type rtImportState struct {
Running bool `json:"running"`
StartedAt time.Time `json:"started_at"`
Total int `json:"total"`
Completed int32 `json:"completed"`
Success int32 `json:"success"`
Failed int32 `json:"failed"`
Results []rtImportResult `json:"results"`
mu sync.Mutex
stopCh chan struct{}
}
var rtImportTaskState = &rtImportState{}
// isRTImportStopped 检查导入任务是否已被停止
func isRTImportStopped() bool {
select {
case <-rtImportTaskState.stopCh:
return true
default:
return false
}
}
// maskRT 脱敏处理 RT只显示前16个字符
func maskRT(rt string) string {
if len(rt) <= 16 {
return rt
}
return rt[:16] + "..."
}
// HandleRTImportStart POST /api/rt-import/start - 启动批量 RT 导入
func HandleRTImportStart(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "仅支持 POST")
return
}
// 检查是否正在运行
if rtImportTaskState.Running {
Error(w, http.StatusConflict, "已有导入任务正在运行")
return
}
// 检查 S2A 配置
if config.Global == nil || config.Global.S2AApiBase == "" || config.Global.S2AAdminKey == "" {
Error(w, http.StatusBadRequest, "S2A 配置未设置,请先配置 S2A API 地址和 Admin Key")
return
}
var req rtImportRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
Error(w, http.StatusBadRequest, fmt.Sprintf("请求格式错误: %v", err))
return
}
// 参数校验
if len(req.Tokens) == 0 {
Error(w, http.StatusBadRequest, "没有提供 Refresh Token")
return
}
if req.Prefix != "team" && req.Prefix != "free" {
Error(w, http.StatusBadRequest, "前缀必须是 team 或 free")
return
}
// 默认值
if req.Concurrency <= 0 {
if config.Global != nil && config.Global.Concurrency > 0 {
req.Concurrency = config.Global.Concurrency
} else {
req.Concurrency = 3
}
}
if req.Priority <= 0 {
if config.Global != nil && config.Global.Priority > 0 {
req.Priority = config.Global.Priority
} else {
req.Priority = 10
}
}
if req.RateMultiplier <= 0 {
req.RateMultiplier = 1.0
}
if len(req.GroupIDs) == 0 && config.Global != nil && len(config.Global.GroupIDs) > 0 {
req.GroupIDs = config.Global.GroupIDs
}
// 初始化状态
rtImportTaskState.Running = true
rtImportTaskState.stopCh = make(chan struct{})
rtImportTaskState.StartedAt = time.Now()
rtImportTaskState.Total = len(req.Tokens)
rtImportTaskState.Completed = 0
rtImportTaskState.Success = 0
rtImportTaskState.Failed = 0
rtImportTaskState.Results = make([]rtImportResult, 0, len(req.Tokens))
// 异步执行
go runRTImport(req)
logger.Info(fmt.Sprintf("RT 导入任务已启动: 共 %d 个 Token, 前缀: %s", len(req.Tokens), req.Prefix), "", "rt-import")
Success(w, map[string]interface{}{
"message": "导入任务已启动",
"total": len(req.Tokens),
"prefix": req.Prefix,
})
}
// HandleRTImportStatus GET /api/rt-import/status - 获取导入状态
func HandleRTImportStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "仅支持 GET")
return
}
rtImportTaskState.mu.Lock()
defer rtImportTaskState.mu.Unlock()
elapsed := int64(0)
if !rtImportTaskState.StartedAt.IsZero() {
elapsed = time.Since(rtImportTaskState.StartedAt).Milliseconds()
}
Success(w, map[string]interface{}{
"running": rtImportTaskState.Running,
"started_at": rtImportTaskState.StartedAt,
"total": rtImportTaskState.Total,
"completed": rtImportTaskState.Completed,
"success": rtImportTaskState.Success,
"failed": rtImportTaskState.Failed,
"results": rtImportTaskState.Results,
"elapsed_ms": elapsed,
})
}
// HandleRTImportStop POST /api/rt-import/stop - 停止导入
func HandleRTImportStop(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "仅支持 POST")
return
}
if !rtImportTaskState.Running {
Error(w, http.StatusBadRequest, "没有正在运行的导入任务")
return
}
rtImportTaskState.Running = false
if rtImportTaskState.stopCh != nil {
select {
case <-rtImportTaskState.stopCh:
// 已关闭
default:
close(rtImportTaskState.stopCh)
}
}
logger.Warning("RT 导入任务已收到停止信号", "", "rt-import")
Success(w, map[string]string{"message": "已发送停止信号"})
}
// runRTImport 执行批量导入
func runRTImport(req rtImportRequest) {
defer func() {
rtImportTaskState.Running = false
completed := atomic.LoadInt32(&rtImportTaskState.Completed)
success := atomic.LoadInt32(&rtImportTaskState.Success)
failed := atomic.LoadInt32(&rtImportTaskState.Failed)
logger.Success(fmt.Sprintf("RT 导入完成: 总数 %d, 成功 %d, 失败 %d",
completed, success, failed), "", "rt-import")
}()
for i, rt := range req.Tokens {
// 检查停止信号
if isRTImportStopped() {
logger.Warning(fmt.Sprintf("RT 导入已停止,跳过剩余 %d 个 Token", len(req.Tokens)-i), "", "rt-import")
break
}
result := processOneRT(i, rt, req)
rtImportTaskState.mu.Lock()
rtImportTaskState.Results = append(rtImportTaskState.Results, result)
rtImportTaskState.mu.Unlock()
atomic.AddInt32(&rtImportTaskState.Completed, 1)
if result.Success {
atomic.AddInt32(&rtImportTaskState.Success, 1)
} else {
atomic.AddInt32(&rtImportTaskState.Failed, 1)
}
// 限速,避免请求过快
time.Sleep(500 * time.Millisecond)
}
}
// processOneRT 处理单条 RT: 验证 → 创建账号
func processOneRT(index int, rt string, req rtImportRequest) rtImportResult {
result := rtImportResult{
Index: index + 1,
RT: maskRT(rt),
}
logger.Info(fmt.Sprintf("[%d/%d] 开始处理 RT: %s", index+1, req.Prefix, maskRT(rt)), "", "rt-import")
// Step 1: 通过 S2A 验证 RT
tokenInfo, err := validateRTViaS2A(rt, req.ProxyID)
if err != nil {
result.Error = fmt.Sprintf("验证失败: %v", err)
logger.Error(fmt.Sprintf("[%d/%d] %s", index+1, len(req.Tokens), result.Error), "", "rt-import")
return result
}
email, _ := tokenInfo["email"].(string)
if email == "" {
email = "unknown"
}
result.Email = email
logger.Info(fmt.Sprintf("[%d/%d] 验证成功: %s", index+1, len(req.Tokens), email), email, "rt-import")
// Step 2: 通过 S2A 创建账号
acctID, err := createAccountViaS2A(tokenInfo, req)
if err != nil {
result.Error = fmt.Sprintf("创建账号失败: %v", err)
logger.Error(fmt.Sprintf("[%d/%d] %s", index+1, len(req.Tokens), result.Error), email, "rt-import")
return result
}
result.Success = true
result.AcctID = acctID
logger.Success(fmt.Sprintf("[%d/%d] 账号创建成功: %s-%s (ID: %d)", index+1, len(req.Tokens), req.Prefix, email, acctID), email, "rt-import")
return result
}
// validateRTViaS2A 通过 S2A API 验证 Refresh Token
func validateRTViaS2A(rt string, proxyID *int) (map[string]interface{}, error) {
payload := map[string]interface{}{
"refresh_token": rt,
}
if proxyID != nil {
payload["proxy_id"] = *proxyID
}
body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("序列化请求失败: %v", err)
}
url := config.Global.S2AApiBase + "/api/v1/admin/openai/refresh-token"
httpReq, err := http.NewRequest("POST", url, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("创建请求失败: %v", err)
}
setS2AHeaders(httpReq)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("请求失败: %v", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取响应失败: %v", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
}
var result map[string]interface{}
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("解析响应失败: %v", err)
}
// 检查 S2A 包装的 data 字段
if data, ok := result["data"].(map[string]interface{}); ok {
return data, nil
}
return result, nil
}
// createAccountViaS2A 通过 S2A API 创建账号
func createAccountViaS2A(tokenInfo map[string]interface{}, req rtImportRequest) (int, error) {
email, _ := tokenInfo["email"].(string)
if email == "" {
email = "unknown"
}
name := fmt.Sprintf("%s-%s", req.Prefix, email)
// 构建 credentials
credentials := map[string]interface{}{}
for _, key := range []string{"access_token", "refresh_token", "token_type", "expires_in", "expires_at", "scope"} {
if v, ok := tokenInfo[key]; ok && v != nil {
credentials[key] = v
}
}
// 可选字段
for _, key := range []string{"chatgpt_account_id", "chatgpt_user_id", "organization_id"} {
if v, ok := tokenInfo[key]; ok && v != nil {
credentials[key] = v
}
}
// 构建 extra
extra := map[string]interface{}{}
if email != "" && email != "unknown" {
extra["email"] = email
}
if nameVal, ok := tokenInfo["name"]; ok && nameVal != nil {
extra["name"] = nameVal
}
payload := map[string]interface{}{
"name": name,
"platform": "openai",
"type": "oauth",
"credentials": credentials,
"concurrency": req.Concurrency,
"priority": req.Priority,
"group_ids": req.GroupIDs,
"rate_multiplier": req.RateMultiplier,
}
if len(extra) > 0 {
payload["extra"] = extra
}
if req.ProxyID != nil {
payload["proxy_id"] = *req.ProxyID
}
body, err := json.Marshal(payload)
if err != nil {
return 0, fmt.Errorf("序列化请求失败: %v", err)
}
url := config.Global.S2AApiBase + "/api/v1/admin/accounts"
httpReq, err := http.NewRequest("POST", url, bytes.NewReader(body))
if err != nil {
return 0, fmt.Errorf("创建请求失败: %v", err)
}
setS2AHeaders(httpReq)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(httpReq)
if err != nil {
return 0, fmt.Errorf("请求失败: %v", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return 0, fmt.Errorf("读取响应失败: %v", err)
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return 0, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
}
var result map[string]interface{}
if err := json.Unmarshal(respBody, &result); err != nil {
return 0, fmt.Errorf("解析响应失败: %v", err)
}
// 从响应中提取账号ID
if data, ok := result["data"].(map[string]interface{}); ok {
if id, ok := data["id"].(float64); ok {
return int(id), nil
}
}
if id, ok := result["id"].(float64); ok {
return int(id), nil
}
return 0, nil
}

View File

@@ -1,7 +1,7 @@
import { Routes, Route } from 'react-router-dom' import { Routes, Route } from 'react-router-dom'
import { ConfigProvider, RecordsProvider } from './context' import { ConfigProvider, RecordsProvider } from './context'
import { Layout } from './components/layout' 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() { function App() {
return ( return (
@@ -21,6 +21,7 @@ function App() {
<Route path="config/email" element={<EmailConfig />} /> <Route path="config/email" element={<EmailConfig />} />
<Route path="config/codex-proxy" element={<CodexProxyConfig />} /> <Route path="config/codex-proxy" element={<CodexProxyConfig />} />
<Route path="s2a-stats" element={<S2AStats />} /> <Route path="s2a-stats" element={<S2AStats />} />
<Route path="rt-import" element={<BatchRTImport />} />
</Route> </Route>
</Routes> </Routes>
</RecordsProvider> </RecordsProvider>

View File

@@ -17,6 +17,7 @@ import {
UserPlus, UserPlus,
Globe, Globe,
BarChart3, BarChart3,
KeyRound,
} from 'lucide-react' } from 'lucide-react'
interface SidebarProps { interface SidebarProps {
@@ -40,6 +41,7 @@ const navItems: NavItem[] = [
{ to: '/s2a-stats', icon: BarChart3, label: 'S2A 统计' }, { to: '/s2a-stats', icon: BarChart3, label: 'S2A 统计' },
{ to: '/cleaner', icon: Trash2, label: '定期清理' }, { to: '/cleaner', icon: Trash2, label: '定期清理' },
{ to: '/team-reg', icon: UserPlus, label: 'Team 注册' }, { to: '/team-reg', icon: UserPlus, label: 'Team 注册' },
{ to: '/rt-import', icon: KeyRound, label: 'RT 导入' },
{ {
to: '/config', to: '/config',
icon: Settings, icon: Settings,

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

View File

@@ -10,5 +10,6 @@ export { default as Cleaner } from './Cleaner'
export { default as TeamReg } from './TeamReg' export { default as TeamReg } from './TeamReg'
export { default as CodexProxyConfig } from './CodexProxyConfig' export { default as CodexProxyConfig } from './CodexProxyConfig'
export { default as S2AStats } from './S2AStats' export { default as S2AStats } from './S2AStats'
export { default as BatchRTImport } from './BatchRTImport'