feat: Initialize the core backend API server, frontend application structure, and implement batch RT import functionality.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -106,3 +106,4 @@ CodexAuth
|
||||
get_code.go
|
||||
|
||||
chatgpt-owner-demote
|
||||
batch_import_openai_rt.py
|
||||
|
||||
@@ -186,6 +186,11 @@ func startServer(cfg *config.Config) {
|
||||
// Owner 降级 API
|
||||
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() {
|
||||
webFS := web.GetFileSystem()
|
||||
|
||||
423
backend/internal/api/batch_rt_import.go
Normal file
423
backend/internal/api/batch_rt_import.go
Normal 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
|
||||
}
|
||||
@@ -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