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
|
get_code.go
|
||||||
|
|
||||||
chatgpt-owner-demote
|
chatgpt-owner-demote
|
||||||
|
batch_import_openai_rt.py
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
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 { 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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
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 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'
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user