feat: Initialize core application structure with backend configuration, database, API, and a comprehensive frontend UI for account pooling and management.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -100,3 +100,4 @@ check_ban.py
|
|||||||
.kiro/specs/codex-pool-frontend/tasks.md
|
.kiro/specs/codex-pool-frontend/tasks.md
|
||||||
backend/codex-pool.exe
|
backend/codex-pool.exe
|
||||||
backend/codex-pool.exe
|
backend/codex-pool.exe
|
||||||
|
.claude/settings.local.json
|
||||||
|
|||||||
@@ -82,6 +82,9 @@ func main() {
|
|||||||
// 启动自动补号检查器(需在前端开启开关才会实际补号)
|
// 启动自动补号检查器(需在前端开启开关才会实际补号)
|
||||||
api.StartAutoAddService()
|
api.StartAutoAddService()
|
||||||
|
|
||||||
|
// 启动错误账号定期清理服务(需在配置中启用)
|
||||||
|
api.StartErrorCleanerService()
|
||||||
|
|
||||||
// 启动服务器
|
// 启动服务器
|
||||||
startServer(cfg)
|
startServer(cfg)
|
||||||
}
|
}
|
||||||
@@ -102,6 +105,7 @@ func startServer(cfg *config.Config) {
|
|||||||
mux.HandleFunc("/api/s2a/test", api.CORS(handleS2ATest))
|
mux.HandleFunc("/api/s2a/test", api.CORS(handleS2ATest))
|
||||||
mux.HandleFunc("/api/s2a/proxy/", api.CORS(handleS2AProxy)) // 通配代理
|
mux.HandleFunc("/api/s2a/proxy/", api.CORS(handleS2AProxy)) // 通配代理
|
||||||
mux.HandleFunc("/api/s2a/clean-errors", api.CORS(api.HandleCleanErrorAccounts)) // 清理错误账号
|
mux.HandleFunc("/api/s2a/clean-errors", api.CORS(api.HandleCleanErrorAccounts)) // 清理错误账号
|
||||||
|
mux.HandleFunc("/api/s2a/cleaner/settings", api.CORS(handleCleanerSettings)) // 清理服务设置
|
||||||
|
|
||||||
// 邮箱服务 API
|
// 邮箱服务 API
|
||||||
mux.HandleFunc("/api/mail/services", api.CORS(handleMailServices))
|
mux.HandleFunc("/api/mail/services", api.CORS(handleMailServices))
|
||||||
@@ -128,6 +132,7 @@ func startServer(cfg *config.Config) {
|
|||||||
// 批次记录 API
|
// 批次记录 API
|
||||||
mux.HandleFunc("/api/batch/runs", api.CORS(handleBatchRuns))
|
mux.HandleFunc("/api/batch/runs", api.CORS(handleBatchRuns))
|
||||||
mux.HandleFunc("/api/batch/stats", api.CORS(handleBatchStats))
|
mux.HandleFunc("/api/batch/stats", api.CORS(handleBatchStats))
|
||||||
|
mux.HandleFunc("/api/batch/cleanup", api.CORS(handleBatchCleanup))
|
||||||
|
|
||||||
// 监控设置 API
|
// 监控设置 API
|
||||||
mux.HandleFunc("/api/monitor/settings", api.CORS(api.HandleGetMonitorSettings))
|
mux.HandleFunc("/api/monitor/settings", api.CORS(api.HandleGetMonitorSettings))
|
||||||
@@ -197,6 +202,7 @@ func handleConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
"group_ids": config.Global.GroupIDs,
|
"group_ids": config.Global.GroupIDs,
|
||||||
"proxy_enabled": config.Global.ProxyEnabled,
|
"proxy_enabled": config.Global.ProxyEnabled,
|
||||||
"default_proxy": config.Global.DefaultProxy,
|
"default_proxy": config.Global.DefaultProxy,
|
||||||
|
"site_name": config.Global.SiteName,
|
||||||
"mail_services_count": len(config.Global.MailServices),
|
"mail_services_count": len(config.Global.MailServices),
|
||||||
"mail_services": config.Global.MailServices,
|
"mail_services": config.Global.MailServices,
|
||||||
})
|
})
|
||||||
@@ -211,6 +217,7 @@ func handleConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
GroupIDs []int `json:"group_ids"`
|
GroupIDs []int `json:"group_ids"`
|
||||||
ProxyEnabled *bool `json:"proxy_enabled"`
|
ProxyEnabled *bool `json:"proxy_enabled"`
|
||||||
DefaultProxy *string `json:"default_proxy"`
|
DefaultProxy *string `json:"default_proxy"`
|
||||||
|
SiteName *string `json:"site_name"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
api.Error(w, http.StatusBadRequest, "请求格式错误")
|
api.Error(w, http.StatusBadRequest, "请求格式错误")
|
||||||
@@ -239,6 +246,9 @@ func handleConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
if req.DefaultProxy != nil {
|
if req.DefaultProxy != nil {
|
||||||
config.Global.DefaultProxy = *req.DefaultProxy
|
config.Global.DefaultProxy = *req.DefaultProxy
|
||||||
}
|
}
|
||||||
|
if req.SiteName != nil {
|
||||||
|
config.Global.SiteName = *req.SiteName
|
||||||
|
}
|
||||||
|
|
||||||
// 保存到数据库 (实时生效)
|
// 保存到数据库 (实时生效)
|
||||||
if err := config.Update(config.Global); err != nil {
|
if err := config.Update(config.Global); err != nil {
|
||||||
@@ -291,6 +301,30 @@ func handleBatchStats(w http.ResponseWriter, r *http.Request) {
|
|||||||
api.Success(w, stats)
|
api.Success(w, stats)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleBatchCleanup 清理卡住的 running 状态批次记录
|
||||||
|
func handleBatchCleanup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
api.Error(w, http.StatusMethodNotAllowed, "仅支持 POST")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if database.Instance == nil {
|
||||||
|
api.Error(w, http.StatusInternalServerError, "数据库未初始化")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
affected, err := database.Instance.CleanupStuckBatchRuns()
|
||||||
|
if err != nil {
|
||||||
|
api.Error(w, http.StatusInternalServerError, fmt.Sprintf("清理失败: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
api.Success(w, map[string]interface{}{
|
||||||
|
"message": "清理完成",
|
||||||
|
"affected": affected,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// handleLogStream SSE 实时日志流
|
// handleLogStream SSE 实时日志流
|
||||||
func handleLogStream(w http.ResponseWriter, r *http.Request) {
|
func handleLogStream(w http.ResponseWriter, r *http.Request) {
|
||||||
// 设置 SSE 响应头
|
// 设置 SSE 响应头
|
||||||
@@ -817,3 +851,62 @@ func getOutboundIP() string {
|
|||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleCleanerSettings GET/POST /api/s2a/cleaner/settings - 获取/保存清理服务设置
|
||||||
|
func handleCleanerSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if database.Instance == nil {
|
||||||
|
api.Error(w, http.StatusInternalServerError, "数据库未初始化")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
// 获取清理设置
|
||||||
|
enabled := false
|
||||||
|
interval := 3600 // 默认 1 小时
|
||||||
|
|
||||||
|
if val, _ := database.Instance.GetConfig("error_clean_enabled"); val == "true" {
|
||||||
|
enabled = true
|
||||||
|
}
|
||||||
|
if val, _ := database.Instance.GetConfig("error_clean_interval"); val != "" {
|
||||||
|
if v, err := strconv.Atoi(val); err == nil {
|
||||||
|
interval = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
api.Success(w, map[string]interface{}{
|
||||||
|
"enabled": enabled,
|
||||||
|
"interval": interval,
|
||||||
|
"status": api.GetCleanerStatus(),
|
||||||
|
})
|
||||||
|
|
||||||
|
case http.MethodPost:
|
||||||
|
// 保存清理设置
|
||||||
|
var req struct {
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
|
Interval *int `json:"interval"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
api.Error(w, http.StatusBadRequest, "请求格式错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Enabled != nil {
|
||||||
|
database.Instance.SetConfig("error_clean_enabled", strconv.FormatBool(*req.Enabled))
|
||||||
|
if *req.Enabled {
|
||||||
|
logger.Success("定期清理错误账号已启用", "", "cleaner")
|
||||||
|
} else {
|
||||||
|
logger.Info("定期清理错误账号已禁用", "", "cleaner")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if req.Interval != nil && *req.Interval >= 60 {
|
||||||
|
database.Instance.SetConfig("error_clean_interval", strconv.Itoa(*req.Interval))
|
||||||
|
logger.Info(fmt.Sprintf("清理间隔已设置为 %d 秒", *req.Interval), "", "cleaner")
|
||||||
|
}
|
||||||
|
|
||||||
|
api.Success(w, map[string]string{"message": "清理设置已保存"})
|
||||||
|
|
||||||
|
default:
|
||||||
|
api.Error(w, http.StatusMethodNotAllowed, "不支持的方法")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
141
backend/internal/api/error_cleaner.go
Normal file
141
backend/internal/api/error_cleaner.go
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"codex-pool/internal/database"
|
||||||
|
"codex-pool/internal/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
cleanerRunning bool
|
||||||
|
cleanerMu sync.Mutex
|
||||||
|
cleanerStopChan chan struct{}
|
||||||
|
lastCleanTime time.Time
|
||||||
|
)
|
||||||
|
|
||||||
|
// StartErrorCleanerService 启动定期清理错误账号服务
|
||||||
|
func StartErrorCleanerService() {
|
||||||
|
cleanerMu.Lock()
|
||||||
|
if cleanerRunning {
|
||||||
|
cleanerMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cleanerRunning = true
|
||||||
|
cleanerStopChan = make(chan struct{})
|
||||||
|
cleanerMu.Unlock()
|
||||||
|
|
||||||
|
logger.Info("错误账号清理服务已启动(需在配置中启用)", "", "cleaner")
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
// 读取清理间隔配置 (默认 3600 秒 = 1 小时)
|
||||||
|
cleanInterval := 3600
|
||||||
|
if database.Instance != nil {
|
||||||
|
if val, _ := database.Instance.GetConfig("error_clean_interval"); val != "" {
|
||||||
|
if v, err := strconv.Atoi(val); err == nil && v >= 60 {
|
||||||
|
cleanInterval = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-cleanerStopChan:
|
||||||
|
logger.Info("错误账号清理服务已停止", "", "cleaner")
|
||||||
|
return
|
||||||
|
case <-time.After(time.Duration(cleanInterval) * time.Second):
|
||||||
|
checkAndCleanErrors()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopErrorCleanerService 停止错误账号清理服务
|
||||||
|
func StopErrorCleanerService() {
|
||||||
|
cleanerMu.Lock()
|
||||||
|
defer cleanerMu.Unlock()
|
||||||
|
|
||||||
|
if cleanerRunning && cleanerStopChan != nil {
|
||||||
|
close(cleanerStopChan)
|
||||||
|
cleanerRunning = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkAndCleanErrors 检查配置并清理错误账号
|
||||||
|
func checkAndCleanErrors() {
|
||||||
|
if database.Instance == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否启用了自动清理
|
||||||
|
enabled := false
|
||||||
|
if val, _ := database.Instance.GetConfig("error_clean_enabled"); val == "true" {
|
||||||
|
enabled = true
|
||||||
|
}
|
||||||
|
if !enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行清理
|
||||||
|
logger.Info("开始定期清理错误账号...", "", "cleaner")
|
||||||
|
|
||||||
|
errorAccounts, err := fetchAllErrorAccounts()
|
||||||
|
if err != nil {
|
||||||
|
logger.Error(fmt.Sprintf("获取错误账号列表失败: %v", err), "", "cleaner")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(errorAccounts) == 0 {
|
||||||
|
logger.Info("没有错误账号需要清理", "", "cleaner")
|
||||||
|
lastCleanTime = time.Now()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info(fmt.Sprintf("找到 %d 个错误账号,开始删除...", len(errorAccounts)), "", "cleaner")
|
||||||
|
|
||||||
|
success := 0
|
||||||
|
failed := 0
|
||||||
|
|
||||||
|
for _, account := range errorAccounts {
|
||||||
|
err := deleteS2AAccount(account.ID)
|
||||||
|
if err != nil {
|
||||||
|
failed++
|
||||||
|
logger.Warning(fmt.Sprintf("删除账号失败: ID=%d, Email=%s, Error=%v", account.ID, account.Email, err), account.Email, "cleaner")
|
||||||
|
} else {
|
||||||
|
success++
|
||||||
|
logger.Success(fmt.Sprintf("删除账号成功: ID=%d, Email=%s", account.ID, account.Email), account.Email, "cleaner")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lastCleanTime = time.Now()
|
||||||
|
logger.Success(fmt.Sprintf("定期清理错误账号完成: 成功=%d, 失败=%d, 总数=%d", success, failed, len(errorAccounts)), "", "cleaner")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCleanerStatus 获取清理服务状态
|
||||||
|
func GetCleanerStatus() map[string]interface{} {
|
||||||
|
cleanerMu.Lock()
|
||||||
|
defer cleanerMu.Unlock()
|
||||||
|
|
||||||
|
enabled := false
|
||||||
|
interval := 3600
|
||||||
|
if database.Instance != nil {
|
||||||
|
if val, _ := database.Instance.GetConfig("error_clean_enabled"); val == "true" {
|
||||||
|
enabled = true
|
||||||
|
}
|
||||||
|
if val, _ := database.Instance.GetConfig("error_clean_interval"); val != "" {
|
||||||
|
if v, err := strconv.Atoi(val); err == nil {
|
||||||
|
interval = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"running": cleanerRunning,
|
||||||
|
"enabled": enabled,
|
||||||
|
"interval": interval,
|
||||||
|
"last_clean_time": lastCleanTime.Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -179,10 +179,6 @@ func HandleTeamProcessStop(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// runTeamProcess 执行 Team 批量处理 - 使用工作池模式
|
// runTeamProcess 执行 Team 批量处理 - 使用工作池模式
|
||||||
func runTeamProcess(req TeamProcessRequest) {
|
func runTeamProcess(req TeamProcessRequest) {
|
||||||
defer func() {
|
|
||||||
teamProcessState.Running = false
|
|
||||||
}()
|
|
||||||
|
|
||||||
totalOwners := len(req.Owners)
|
totalOwners := len(req.Owners)
|
||||||
workerCount := req.ConcurrentTeams // 同时运行的 worker 数量
|
workerCount := req.ConcurrentTeams // 同时运行的 worker 数量
|
||||||
if workerCount > totalOwners {
|
if workerCount > totalOwners {
|
||||||
@@ -202,6 +198,30 @@ func runTeamProcess(req TeamProcessRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 统计变量(在 defer 中使用)
|
||||||
|
var totalRegistered, totalAddedToS2A int
|
||||||
|
var allErrors []string
|
||||||
|
|
||||||
|
// 确保任务结束时更新状态和批次记录
|
||||||
|
defer func() {
|
||||||
|
teamProcessState.Running = false
|
||||||
|
|
||||||
|
// 无论任务是正常完成还是异常中断,都更新批次记录状态
|
||||||
|
if database.Instance != nil && batchID > 0 {
|
||||||
|
errorsStr := ""
|
||||||
|
if len(allErrors) > 0 {
|
||||||
|
// 只保留前10条错误
|
||||||
|
if len(allErrors) > 10 {
|
||||||
|
allErrors = allErrors[:10]
|
||||||
|
}
|
||||||
|
errorsStr = fmt.Sprintf("%v", allErrors)
|
||||||
|
}
|
||||||
|
if err := database.Instance.UpdateBatchRun(batchID, totalRegistered, totalAddedToS2A, errorsStr); err != nil {
|
||||||
|
logger.Error(fmt.Sprintf("更新批次记录失败: %v", err), "", "team")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
logger.Info(fmt.Sprintf("开始批量处理: 共 %d 个 Team, 并发数: %d", totalOwners, workerCount), "", "team")
|
logger.Info(fmt.Sprintf("开始批量处理: 共 %d 个 Team, 并发数: %d", totalOwners, workerCount), "", "team")
|
||||||
|
|
||||||
// 任务队列
|
// 任务队列
|
||||||
@@ -243,10 +263,7 @@ func runTeamProcess(req TeamProcessRequest) {
|
|||||||
close(resultChan)
|
close(resultChan)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// 统计总数
|
// 收集结果并统计
|
||||||
var totalRegistered, totalAddedToS2A int
|
|
||||||
var allErrors []string
|
|
||||||
|
|
||||||
for result := range resultChan {
|
for result := range resultChan {
|
||||||
teamProcessState.mu.Lock()
|
teamProcessState.mu.Lock()
|
||||||
teamProcessState.Results = append(teamProcessState.Results, result)
|
teamProcessState.Results = append(teamProcessState.Results, result)
|
||||||
@@ -257,19 +274,6 @@ func runTeamProcess(req TeamProcessRequest) {
|
|||||||
allErrors = append(allErrors, result.Errors...)
|
allErrors = append(allErrors, result.Errors...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新批次记录
|
|
||||||
if database.Instance != nil && batchID > 0 {
|
|
||||||
errorsStr := ""
|
|
||||||
if len(allErrors) > 0 {
|
|
||||||
// 只保留前10条错误
|
|
||||||
if len(allErrors) > 10 {
|
|
||||||
allErrors = allErrors[:10]
|
|
||||||
}
|
|
||||||
errorsStr = fmt.Sprintf("%v", allErrors)
|
|
||||||
}
|
|
||||||
database.Instance.UpdateBatchRun(batchID, totalRegistered, totalAddedToS2A, errorsStr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算成功率
|
// 计算成功率
|
||||||
expectedTotal := totalOwners * req.MembersPerTeam
|
expectedTotal := totalOwners * req.MembersPerTeam
|
||||||
successRate := float64(0)
|
successRate := float64(0)
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ type Config struct {
|
|||||||
AutoPauseOnExpired bool `json:"auto_pause_on_expired"`
|
AutoPauseOnExpired bool `json:"auto_pause_on_expired"`
|
||||||
AccountsPath string `json:"accounts_path"`
|
AccountsPath string `json:"accounts_path"`
|
||||||
|
|
||||||
|
// 站点配置
|
||||||
|
SiteName string `json:"site_name"`
|
||||||
|
|
||||||
// 邮箱服务
|
// 邮箱服务
|
||||||
MailServices []MailServiceConfig `json:"mail_services"`
|
MailServices []MailServiceConfig `json:"mail_services"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -322,8 +322,9 @@ func (d *DB) GetOwnerStats() map[string]int {
|
|||||||
stats := map[string]int{
|
stats := map[string]int{
|
||||||
"total": 0,
|
"total": 0,
|
||||||
"valid": 0,
|
"valid": 0,
|
||||||
"registered": 0,
|
"processing": 0,
|
||||||
"pooled": 0,
|
"used": 0,
|
||||||
|
"invalid": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
var count int
|
var count int
|
||||||
@@ -333,11 +334,14 @@ func (d *DB) GetOwnerStats() map[string]int {
|
|||||||
if err := d.db.QueryRow("SELECT COUNT(*) FROM team_owners WHERE status = 'valid'").Scan(&count); err == nil {
|
if err := d.db.QueryRow("SELECT COUNT(*) FROM team_owners WHERE status = 'valid'").Scan(&count); err == nil {
|
||||||
stats["valid"] = count
|
stats["valid"] = count
|
||||||
}
|
}
|
||||||
if err := d.db.QueryRow("SELECT COUNT(*) FROM team_owners WHERE status = 'registered'").Scan(&count); err == nil {
|
if err := d.db.QueryRow("SELECT COUNT(*) FROM team_owners WHERE status = 'processing'").Scan(&count); err == nil {
|
||||||
stats["registered"] = count
|
stats["processing"] = count
|
||||||
}
|
}
|
||||||
if err := d.db.QueryRow("SELECT COUNT(*) FROM team_owners WHERE status = 'pooled'").Scan(&count); err == nil {
|
if err := d.db.QueryRow("SELECT COUNT(*) FROM team_owners WHERE status = 'used'").Scan(&count); err == nil {
|
||||||
stats["pooled"] = count
|
stats["used"] = count
|
||||||
|
}
|
||||||
|
if err := d.db.QueryRow("SELECT COUNT(*) FROM team_owners WHERE status = 'invalid'").Scan(&count); err == nil {
|
||||||
|
stats["invalid"] = count
|
||||||
}
|
}
|
||||||
|
|
||||||
return stats
|
return stats
|
||||||
@@ -439,6 +443,21 @@ func (d *DB) GetBatchRuns(limit int) ([]BatchRun, error) {
|
|||||||
return runs, nil
|
return runs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CleanupStuckBatchRuns 清理卡住的 running 状态批次记录
|
||||||
|
func (d *DB) CleanupStuckBatchRuns() (int64, error) {
|
||||||
|
result, err := d.db.Exec(`
|
||||||
|
UPDATE batch_runs
|
||||||
|
SET status = 'completed',
|
||||||
|
finished_at = COALESCE(finished_at, started_at),
|
||||||
|
duration_seconds = 0
|
||||||
|
WHERE status = 'running'
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected()
|
||||||
|
}
|
||||||
|
|
||||||
// GetBatchRunStats 获取批次统计
|
// GetBatchRunStats 获取批次统计
|
||||||
func (d *DB) GetBatchRunStats() map[string]interface{} {
|
func (d *DB) GetBatchRunStats() map[string]interface{} {
|
||||||
stats := make(map[string]interface{})
|
stats := make(map[string]interface{})
|
||||||
|
|||||||
@@ -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 } from './pages'
|
import { Dashboard, Upload, Records, Accounts, Config, S2AConfig, EmailConfig, Monitor, Cleaner } from './pages'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@@ -14,6 +14,7 @@ function App() {
|
|||||||
<Route path="records" element={<Records />} />
|
<Route path="records" element={<Records />} />
|
||||||
<Route path="accounts" element={<Accounts />} />
|
<Route path="accounts" element={<Accounts />} />
|
||||||
<Route path="monitor" element={<Monitor />} />
|
<Route path="monitor" element={<Monitor />} />
|
||||||
|
<Route path="cleaner" element={<Cleaner />} />
|
||||||
<Route path="config" element={<Config />} />
|
<Route path="config" element={<Config />} />
|
||||||
<Route path="config/s2a" element={<S2AConfig />} />
|
<Route path="config/s2a" element={<S2AConfig />} />
|
||||||
<Route path="config/email" element={<EmailConfig />} />
|
<Route path="config/email" element={<EmailConfig />} />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NavLink, useLocation } from 'react-router-dom'
|
import { NavLink, useLocation } from 'react-router-dom'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { useConfig } from '../../hooks/useConfig'
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Upload,
|
Upload,
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
Server,
|
Server,
|
||||||
Mail,
|
Mail,
|
||||||
Cog,
|
Cog,
|
||||||
|
Trash2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
@@ -32,6 +34,7 @@ const navItems: NavItem[] = [
|
|||||||
{ to: '/records', icon: History, label: '加号记录' },
|
{ to: '/records', icon: History, label: '加号记录' },
|
||||||
{ to: '/accounts', icon: Users, label: '号池账号' },
|
{ to: '/accounts', icon: Users, label: '号池账号' },
|
||||||
{ to: '/monitor', icon: Activity, label: '号池监控' },
|
{ to: '/monitor', icon: Activity, label: '号池监控' },
|
||||||
|
{ to: '/cleaner', icon: Trash2, label: '定期清理' },
|
||||||
{
|
{
|
||||||
to: '/config',
|
to: '/config',
|
||||||
icon: Settings,
|
icon: Settings,
|
||||||
@@ -47,6 +50,7 @@ const navItems: NavItem[] = [
|
|||||||
export default function Sidebar({ isOpen, onClose }: SidebarProps) {
|
export default function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const [expandedItems, setExpandedItems] = useState<string[]>(['/config'])
|
const [expandedItems, setExpandedItems] = useState<string[]>(['/config'])
|
||||||
|
const { siteName } = useConfig()
|
||||||
|
|
||||||
const toggleExpand = (path: string) => {
|
const toggleExpand = (path: string) => {
|
||||||
setExpandedItems(prev =>
|
setExpandedItems(prev =>
|
||||||
@@ -132,9 +136,9 @@ export default function Sidebar({ isOpen, onClose }: SidebarProps) {
|
|||||||
<div className="flex items-center justify-between h-16 px-6 border-b border-slate-200/50 dark:border-slate-800/50 lg:hidden">
|
<div className="flex items-center justify-between h-16 px-6 border-b border-slate-200/50 dark:border-slate-800/50 lg:hidden">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="h-8 w-8 rounded-xl bg-gradient-to-br from-blue-600 to-indigo-600 flex items-center justify-center shadow-lg shadow-blue-500/30">
|
<div className="h-8 w-8 rounded-xl bg-gradient-to-br from-blue-600 to-indigo-600 flex items-center justify-center shadow-lg shadow-blue-500/30">
|
||||||
<span className="text-white font-bold text-sm">CP</span>
|
<span className="text-white font-bold text-sm">{siteName.slice(0, 2).toUpperCase()}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="font-bold text-lg text-slate-900 dark:text-slate-100 tracking-tight">Codex Pool</span>
|
<span className="font-bold text-lg text-slate-900 dark:text-slate-100 tracking-tight truncate">{siteName}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -149,9 +153,9 @@ export default function Sidebar({ isOpen, onClose }: SidebarProps) {
|
|||||||
<div className="hidden lg:flex items-center h-16 px-6 border-b border-slate-200/50 dark:border-slate-800/50">
|
<div className="hidden lg:flex items-center h-16 px-6 border-b border-slate-200/50 dark:border-slate-800/50">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="h-8 w-8 rounded-xl bg-gradient-to-br from-blue-600 to-indigo-600 flex items-center justify-center shadow-lg shadow-blue-500/30">
|
<div className="h-8 w-8 rounded-xl bg-gradient-to-br from-blue-600 to-indigo-600 flex items-center justify-center shadow-lg shadow-blue-500/30">
|
||||||
<span className="text-white font-bold text-sm">CP</span>
|
<span className="text-white font-bold text-sm">{siteName.slice(0, 2).toUpperCase()}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="font-bold text-lg text-slate-900 dark:text-slate-100 tracking-tight">Codex Pool</span>
|
<span className="font-bold text-lg text-slate-900 dark:text-slate-100 tracking-tight truncate">{siteName}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -230,8 +230,7 @@ export default function OwnerList({ onStatsChange }: OwnerListProps) {
|
|||||||
>
|
>
|
||||||
<option value="">全部状态</option>
|
<option value="">全部状态</option>
|
||||||
<option value="valid">有效</option>
|
<option value="valid">有效</option>
|
||||||
<option value="registered">已注册</option>
|
<option value="processing">处理中</option>
|
||||||
<option value="pooled">已入库</option>
|
|
||||||
<option value="used">已使用</option>
|
<option value="used">已使用</option>
|
||||||
<option value="invalid">无效</option>
|
<option value="invalid">无效</option>
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ interface ConfigContextValue {
|
|||||||
testConnection: () => Promise<boolean>
|
testConnection: () => Promise<boolean>
|
||||||
s2aClient: S2AClient | null
|
s2aClient: S2AClient | null
|
||||||
refreshConfig: () => Promise<void>
|
refreshConfig: () => Promise<void>
|
||||||
|
siteName: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const ConfigContext = createContext<ConfigContextValue | null>(null)
|
const ConfigContext = createContext<ConfigContextValue | null>(null)
|
||||||
@@ -23,6 +24,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||||||
const [config, setConfig] = useState<AppConfig>(defaultConfig)
|
const [config, setConfig] = useState<AppConfig>(defaultConfig)
|
||||||
const [isConnected, setIsConnected] = useState(false)
|
const [isConnected, setIsConnected] = useState(false)
|
||||||
const [s2aClient, setS2aClient] = useState<S2AClient | null>(null)
|
const [s2aClient, setS2aClient] = useState<S2AClient | null>(null)
|
||||||
|
const [siteName, setSiteName] = useState('Codex Pool')
|
||||||
|
|
||||||
// Load config from server on mount
|
// Load config from server on mount
|
||||||
const refreshConfig = useCallback(async () => {
|
const refreshConfig = useCallback(async () => {
|
||||||
@@ -45,6 +47,10 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||||||
groupIds: serverConfig.group_ids || [],
|
groupIds: serverConfig.group_ids || [],
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
// 更新站点名称
|
||||||
|
if (serverConfig.site_name) {
|
||||||
|
setSiteName(serverConfig.site_name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load config from server:', error)
|
console.error('Failed to load config from server:', error)
|
||||||
@@ -158,6 +164,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||||||
testConnection,
|
testConnection,
|
||||||
s2aClient,
|
s2aClient,
|
||||||
refreshConfig,
|
refreshConfig,
|
||||||
|
siteName,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
322
frontend/src/pages/Cleaner.tsx
Normal file
322
frontend/src/pages/Cleaner.tsx
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Trash2, Clock, Loader2, Save, RefreshCw, CheckCircle, XCircle, ToggleLeft, ToggleRight, AlertTriangle } from 'lucide-react'
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent, Button } from '../components/common'
|
||||||
|
|
||||||
|
interface CleanerStatus {
|
||||||
|
running: boolean
|
||||||
|
enabled: boolean
|
||||||
|
interval: number
|
||||||
|
last_clean_time: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Cleaner() {
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [cleanEnabled, setCleanEnabled] = useState(false)
|
||||||
|
const [cleanInterval, setCleanInterval] = useState(3600)
|
||||||
|
const [savingClean, setSavingClean] = useState(false)
|
||||||
|
const [cleaning, setCleaning] = useState(false)
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null)
|
||||||
|
const [status, setStatus] = useState<CleanerStatus | null>(null)
|
||||||
|
|
||||||
|
// 加载清理设置
|
||||||
|
const fetchCleanerSettings = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/s2a/cleaner/settings')
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.code === 0 && data.data) {
|
||||||
|
setCleanEnabled(data.data.enabled || false)
|
||||||
|
setCleanInterval(data.data.interval || 3600)
|
||||||
|
setStatus(data.data.status || null)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch cleaner settings:', error)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCleanerSettings()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 保存清理设置
|
||||||
|
const handleSaveCleanerSettings = async () => {
|
||||||
|
setSavingClean(true)
|
||||||
|
setMessage(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/s2a/cleaner/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
enabled: cleanEnabled,
|
||||||
|
interval: cleanInterval,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.code === 0) {
|
||||||
|
setMessage({ type: 'success', text: '清理设置已保存' })
|
||||||
|
// 刷新状态
|
||||||
|
fetchCleanerSettings()
|
||||||
|
} else {
|
||||||
|
setMessage({ type: 'error', text: data.message || '保存失败' })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: '网络错误' })
|
||||||
|
} finally {
|
||||||
|
setSavingClean(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 手动清理错误账号
|
||||||
|
const handleCleanNow = async () => {
|
||||||
|
if (!confirm('确认立即清理所有错误账号?\n\n此操作将删除 S2A 号池中所有状态为"错误"的账号。')) return
|
||||||
|
setCleaning(true)
|
||||||
|
setMessage(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/s2a/clean-errors', { method: 'POST' })
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.code === 0) {
|
||||||
|
setMessage({ type: 'success', text: data.data.message || '清理完成' })
|
||||||
|
// 刷新状态
|
||||||
|
fetchCleanerSettings()
|
||||||
|
} else {
|
||||||
|
setMessage({ type: 'error', text: data.message || '清理失败' })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: '网络错误' })
|
||||||
|
} finally {
|
||||||
|
setCleaning(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化时间间隔
|
||||||
|
const formatInterval = (seconds: number): string => {
|
||||||
|
if (seconds < 60) return `${seconds} 秒`
|
||||||
|
if (seconds < 3600) return `${Math.floor(seconds / 60)} 分钟`
|
||||||
|
return `${Math.floor(seconds / 3600)} 小时`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化上次清理时间
|
||||||
|
const formatLastCleanTime = (timeStr: string): string => {
|
||||||
|
if (!timeStr || timeStr === '0001-01-01T00:00:00Z') return '从未执行'
|
||||||
|
try {
|
||||||
|
const date = new Date(timeStr)
|
||||||
|
return date.toLocaleString('zh-CN')
|
||||||
|
} catch {
|
||||||
|
return '未知'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100 flex items-center gap-2">
|
||||||
|
<Trash2 className="h-7 w-7 text-red-500" />
|
||||||
|
定期清理
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">自动清理 S2A 号池中的错误账号</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={fetchCleanerSettings}
|
||||||
|
icon={<RefreshCw className="h-4 w-4" />}
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSaveCleanerSettings}
|
||||||
|
disabled={savingClean}
|
||||||
|
icon={savingClean ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||||
|
>
|
||||||
|
{savingClean ? '保存中...' : '保存设置'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message */}
|
||||||
|
{message && (
|
||||||
|
<div className={`p-3 rounded-lg text-sm flex items-center gap-2 ${message.type === 'success'
|
||||||
|
? 'bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||||
|
: 'bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||||
|
}`}>
|
||||||
|
{message.type === 'success' ? (
|
||||||
|
<CheckCircle className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{message.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
{/* 清理状态 */}
|
||||||
|
<Card className="stat-card">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">清理服务</p>
|
||||||
|
<p className={`text-2xl font-bold ${cleanEnabled ? 'text-green-600' : 'text-slate-400'}`}>
|
||||||
|
{cleanEnabled ? '已启用' : '已禁用'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className={`h-12 w-12 rounded-xl flex items-center justify-center ${cleanEnabled ? 'bg-green-100 dark:bg-green-900/30' : 'bg-slate-100 dark:bg-slate-800'
|
||||||
|
}`}>
|
||||||
|
{cleanEnabled ? (
|
||||||
|
<CheckCircle className="h-6 w-6 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="h-6 w-6 text-slate-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 清理间隔 */}
|
||||||
|
<Card className="stat-card">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">清理间隔</p>
|
||||||
|
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">
|
||||||
|
{formatInterval(cleanInterval)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="h-12 w-12 rounded-xl bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
|
||||||
|
<Clock className="h-6 w-6 text-blue-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 上次清理 */}
|
||||||
|
<Card className="stat-card">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">上次清理</p>
|
||||||
|
<p className="text-lg font-medium text-slate-900 dark:text-slate-100">
|
||||||
|
{status ? formatLastCleanTime(status.last_clean_time) : '从未执行'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="h-12 w-12 rounded-xl bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
|
||||||
|
<Trash2 className="h-6 w-6 text-purple-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 清理设置 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Trash2 className="h-5 w-5 text-red-500" />
|
||||||
|
清理设置
|
||||||
|
</CardTitle>
|
||||||
|
<button
|
||||||
|
onClick={() => setCleanEnabled(!cleanEnabled)}
|
||||||
|
className="flex items-center gap-2 text-sm"
|
||||||
|
>
|
||||||
|
{cleanEnabled ? (
|
||||||
|
<>
|
||||||
|
<ToggleRight className="h-6 w-6 text-green-500" />
|
||||||
|
<span className="text-green-600 dark:text-green-400">已启用</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ToggleLeft className="h-6 w-6 text-slate-400" />
|
||||||
|
<span className="text-slate-500">已禁用</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{/* 清理间隔选择 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||||
|
清理间隔
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={cleanInterval}
|
||||||
|
onChange={(e) => setCleanInterval(Number(e.target.value))}
|
||||||
|
disabled={!cleanEnabled}
|
||||||
|
className={`w-full px-4 py-3 text-sm rounded-xl border transition-colors
|
||||||
|
bg-white dark:bg-slate-800
|
||||||
|
text-slate-900 dark:text-slate-100
|
||||||
|
border-slate-300 dark:border-slate-600
|
||||||
|
focus:border-blue-500 focus:ring-blue-500
|
||||||
|
focus:outline-none focus:ring-2
|
||||||
|
${!cleanEnabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
|
>
|
||||||
|
<option value={300}>5 分钟</option>
|
||||||
|
<option value={600}>10 分钟</option>
|
||||||
|
<option value={1800}>30 分钟</option>
|
||||||
|
<option value={3600}>1 小时</option>
|
||||||
|
<option value={7200}>2 小时</option>
|
||||||
|
<option value={14400}>4 小时</option>
|
||||||
|
<option value={21600}>6 小时</option>
|
||||||
|
<option value={43200}>12 小时</option>
|
||||||
|
<option value={86400}>24 小时</option>
|
||||||
|
</select>
|
||||||
|
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
每隔指定时间自动清理 S2A 中的错误账号
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 手动清理 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||||
|
手动清理
|
||||||
|
</label>
|
||||||
|
<Button
|
||||||
|
onClick={handleCleanNow}
|
||||||
|
disabled={cleaning}
|
||||||
|
variant="outline"
|
||||||
|
icon={cleaning ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
|
||||||
|
className="w-full h-12 text-red-500 hover:text-red-600 border-red-300 hover:border-red-400 dark:border-red-800 dark:hover:border-red-600"
|
||||||
|
>
|
||||||
|
{cleaning ? '清理中...' : '立即清理所有错误账号'}
|
||||||
|
</Button>
|
||||||
|
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
立即执行一次清理操作,删除所有错误账号
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 说明信息 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-4">
|
||||||
|
<div className="flex items-start gap-3 text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-amber-500 flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="font-medium text-slate-700 dark:text-slate-300">功能说明</p>
|
||||||
|
<ul className="list-disc list-inside space-y-1">
|
||||||
|
<li>定期清理功能会自动删除 S2A 号池中状态为"error"的账号</li>
|
||||||
|
<li>清理操作是<strong>不可逆</strong>的,删除的账号无法恢复</li>
|
||||||
|
<li>建议设置合理的清理间隔,避免过于频繁的清理操作</li>
|
||||||
|
<li>清理日志可在"号池监控"页面的实时日志中查看</li>
|
||||||
|
<li>启用后需要点击"保存设置"才会生效</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
Server,
|
Server,
|
||||||
@@ -6,13 +7,59 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
XCircle
|
XCircle,
|
||||||
|
Save,
|
||||||
|
Loader2,
|
||||||
|
Globe
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Card, CardContent, Button } from '../components/common'
|
import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common'
|
||||||
import { useConfig } from '../hooks/useConfig'
|
import { useConfig } from '../hooks/useConfig'
|
||||||
|
|
||||||
export default function Config() {
|
export default function Config() {
|
||||||
const { config, isConnected, refreshConfig } = useConfig()
|
const { config, isConnected, refreshConfig } = useConfig()
|
||||||
|
const [siteName, setSiteName] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null)
|
||||||
|
|
||||||
|
// 加载站点名称配置
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchSiteName = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/config')
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.code === 0 && data.data) {
|
||||||
|
setSiteName(data.data.site_name || 'Codex Pool')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch site name:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchSiteName()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 保存站点名称
|
||||||
|
const handleSaveSiteName = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
setMessage(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/config', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ site_name: siteName }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.code === 0) {
|
||||||
|
setMessage({ type: 'success', text: '站点名称已保存' })
|
||||||
|
refreshConfig()
|
||||||
|
} else {
|
||||||
|
setMessage({ type: 'error', text: data.message || '保存失败' })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: '网络错误' })
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const configItems = [
|
const configItems = [
|
||||||
{
|
{
|
||||||
@@ -82,6 +129,58 @@ export default function Config() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Site Settings */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Globe className="h-5 w-5 text-purple-500" />
|
||||||
|
基础配置
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Message */}
|
||||||
|
{message && (
|
||||||
|
<div className={`p-3 rounded-lg text-sm flex items-center gap-2 ${message.type === 'success'
|
||||||
|
? 'bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||||
|
: 'bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||||
|
}`}>
|
||||||
|
{message.type === 'success' ? (
|
||||||
|
<CheckCircle className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{message.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||||
|
站点名称
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Input
|
||||||
|
value={siteName}
|
||||||
|
onChange={(e) => setSiteName(e.target.value)}
|
||||||
|
placeholder="输入站点名称,如:我的号池"
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSaveSiteName}
|
||||||
|
disabled={saving}
|
||||||
|
icon={saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
{saving ? '保存中...' : '保存名称'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
该名称将显示在侧边栏标题和浏览器标签页
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Info */}
|
{/* Info */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
import { RefreshCw, Calendar, TrendingUp, CheckCircle, Clock, AlertCircle } from 'lucide-react'
|
import { RefreshCw, Calendar, TrendingUp, CheckCircle, Clock, AlertCircle, Trash2 } from 'lucide-react'
|
||||||
import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common'
|
import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common'
|
||||||
|
|
||||||
interface BatchRun {
|
interface BatchRun {
|
||||||
@@ -60,6 +60,28 @@ export default function Records() {
|
|||||||
fetchData()
|
fetchData()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// 检查是否有卡住的运行中记录
|
||||||
|
const stuckRunningCount = useMemo(() => {
|
||||||
|
return runs.filter(r => r.status === 'running').length
|
||||||
|
}, [runs])
|
||||||
|
|
||||||
|
// 清理卡住的记录
|
||||||
|
const handleCleanup = async () => {
|
||||||
|
if (!window.confirm('确定要将所有"运行中"状态的记录标记为完成吗?')) return
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/batch/cleanup', { method: 'POST' })
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.code === 0) {
|
||||||
|
alert(`已清理 ${data.data.affected} 条记录`)
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('清理失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 筛选记录
|
// 筛选记录
|
||||||
const filteredRuns = runs.filter((run) => {
|
const filteredRuns = runs.filter((run) => {
|
||||||
if (!startDate && !endDate) return true
|
if (!startDate && !endDate) return true
|
||||||
@@ -97,6 +119,18 @@ export default function Records() {
|
|||||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">加号记录</h1>
|
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">加号记录</h1>
|
||||||
<p className="text-sm text-slate-500 dark:text-slate-400">查看历史入库记录</p>
|
<p className="text-sm text-slate-500 dark:text-slate-400">查看历史入库记录</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{stuckRunningCount > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCleanup}
|
||||||
|
icon={<Trash2 className="h-4 w-4" />}
|
||||||
|
className="text-orange-600 border-orange-300 hover:bg-orange-50 dark:text-orange-400 dark:border-orange-700 dark:hover:bg-orange-900/20"
|
||||||
|
>
|
||||||
|
清理卡住记录 ({stuckRunningCount})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -107,6 +141,7 @@ export default function Records() {
|
|||||||
刷新
|
刷新
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Stats Cards */}
|
{/* Stats Cards */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
|||||||
@@ -6,4 +6,5 @@ export { default as Config } from './Config'
|
|||||||
export { default as S2AConfig } from './S2AConfig'
|
export { default as S2AConfig } from './S2AConfig'
|
||||||
export { default as EmailConfig } from './EmailConfig'
|
export { default as EmailConfig } from './EmailConfig'
|
||||||
export { default as Monitor } from './Monitor'
|
export { default as Monitor } from './Monitor'
|
||||||
|
export { default as Cleaner } from './Cleaner'
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user