diff --git a/.gitignore b/.gitignore index 5f4e6d5..dfb69a4 100644 --- a/.gitignore +++ b/.gitignore @@ -100,3 +100,4 @@ check_ban.py .kiro/specs/codex-pool-frontend/tasks.md backend/codex-pool.exe backend/codex-pool.exe +.claude/settings.local.json diff --git a/backend/cmd/main.go b/backend/cmd/main.go index 365734d..4976cc1 100644 --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -82,6 +82,9 @@ func main() { // 启动自动补号检查器(需在前端开启开关才会实际补号) api.StartAutoAddService() + // 启动错误账号定期清理服务(需在配置中启用) + api.StartErrorCleanerService() + // 启动服务器 startServer(cfg) } @@ -102,6 +105,7 @@ func startServer(cfg *config.Config) { mux.HandleFunc("/api/s2a/test", api.CORS(handleS2ATest)) mux.HandleFunc("/api/s2a/proxy/", api.CORS(handleS2AProxy)) // 通配代理 mux.HandleFunc("/api/s2a/clean-errors", api.CORS(api.HandleCleanErrorAccounts)) // 清理错误账号 + mux.HandleFunc("/api/s2a/cleaner/settings", api.CORS(handleCleanerSettings)) // 清理服务设置 // 邮箱服务 API mux.HandleFunc("/api/mail/services", api.CORS(handleMailServices)) @@ -128,6 +132,7 @@ func startServer(cfg *config.Config) { // 批次记录 API mux.HandleFunc("/api/batch/runs", api.CORS(handleBatchRuns)) mux.HandleFunc("/api/batch/stats", api.CORS(handleBatchStats)) + mux.HandleFunc("/api/batch/cleanup", api.CORS(handleBatchCleanup)) // 监控设置 API 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, "proxy_enabled": config.Global.ProxyEnabled, "default_proxy": config.Global.DefaultProxy, + "site_name": config.Global.SiteName, "mail_services_count": len(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"` ProxyEnabled *bool `json:"proxy_enabled"` DefaultProxy *string `json:"default_proxy"` + SiteName *string `json:"site_name"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { api.Error(w, http.StatusBadRequest, "请求格式错误") @@ -239,6 +246,9 @@ func handleConfig(w http.ResponseWriter, r *http.Request) { if req.DefaultProxy != nil { config.Global.DefaultProxy = *req.DefaultProxy } + if req.SiteName != nil { + config.Global.SiteName = *req.SiteName + } // 保存到数据库 (实时生效) if err := config.Update(config.Global); err != nil { @@ -291,6 +301,30 @@ func handleBatchStats(w http.ResponseWriter, r *http.Request) { 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 实时日志流 func handleLogStream(w http.ResponseWriter, r *http.Request) { // 设置 SSE 响应头 @@ -817,3 +851,62 @@ func getOutboundIP() string { } 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, "不支持的方法") + } +} diff --git a/backend/internal/api/error_cleaner.go b/backend/internal/api/error_cleaner.go new file mode 100644 index 0000000..1320a74 --- /dev/null +++ b/backend/internal/api/error_cleaner.go @@ -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), + } +} diff --git a/backend/internal/api/team_process.go b/backend/internal/api/team_process.go index 8c2b977..e999182 100644 --- a/backend/internal/api/team_process.go +++ b/backend/internal/api/team_process.go @@ -179,10 +179,6 @@ func HandleTeamProcessStop(w http.ResponseWriter, r *http.Request) { // runTeamProcess 执行 Team 批量处理 - 使用工作池模式 func runTeamProcess(req TeamProcessRequest) { - defer func() { - teamProcessState.Running = false - }() - totalOwners := len(req.Owners) workerCount := req.ConcurrentTeams // 同时运行的 worker 数量 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") // 任务队列 @@ -243,10 +263,7 @@ func runTeamProcess(req TeamProcessRequest) { close(resultChan) }() - // 统计总数 - var totalRegistered, totalAddedToS2A int - var allErrors []string - + // 收集结果并统计 for result := range resultChan { teamProcessState.mu.Lock() teamProcessState.Results = append(teamProcessState.Results, result) @@ -257,19 +274,6 @@ func runTeamProcess(req TeamProcessRequest) { 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 successRate := float64(0) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 14b08c6..f9309a8 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -42,6 +42,9 @@ type Config struct { AutoPauseOnExpired bool `json:"auto_pause_on_expired"` AccountsPath string `json:"accounts_path"` + // 站点配置 + SiteName string `json:"site_name"` + // 邮箱服务 MailServices []MailServiceConfig `json:"mail_services"` } diff --git a/backend/internal/database/sqlite.go b/backend/internal/database/sqlite.go index 05ecc83..26c320c 100644 --- a/backend/internal/database/sqlite.go +++ b/backend/internal/database/sqlite.go @@ -322,8 +322,9 @@ func (d *DB) GetOwnerStats() map[string]int { stats := map[string]int{ "total": 0, "valid": 0, - "registered": 0, - "pooled": 0, + "processing": 0, + "used": 0, + "invalid": 0, } 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 { stats["valid"] = count } - if err := d.db.QueryRow("SELECT COUNT(*) FROM team_owners WHERE status = 'registered'").Scan(&count); err == nil { - stats["registered"] = count + if err := d.db.QueryRow("SELECT COUNT(*) FROM team_owners WHERE status = 'processing'").Scan(&count); err == nil { + stats["processing"] = count } - if err := d.db.QueryRow("SELECT COUNT(*) FROM team_owners WHERE status = 'pooled'").Scan(&count); err == nil { - stats["pooled"] = count + if err := d.db.QueryRow("SELECT COUNT(*) FROM team_owners WHERE status = 'used'").Scan(&count); err == nil { + 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 @@ -439,6 +443,21 @@ func (d *DB) GetBatchRuns(limit int) ([]BatchRun, error) { 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 获取批次统计 func (d *DB) GetBatchRunStats() map[string]interface{} { stats := make(map[string]interface{}) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4385c9d..050af0e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 } from './pages' +import { Dashboard, Upload, Records, Accounts, Config, S2AConfig, EmailConfig, Monitor, Cleaner } from './pages' function App() { return ( @@ -14,6 +14,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 220ee45..f4ff33d 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -1,5 +1,6 @@ import { NavLink, useLocation } from 'react-router-dom' import { useState } from 'react' +import { useConfig } from '../../hooks/useConfig' import { LayoutDashboard, Upload, @@ -12,6 +13,7 @@ import { Server, Mail, Cog, + Trash2, } from 'lucide-react' interface SidebarProps { @@ -32,6 +34,7 @@ const navItems: NavItem[] = [ { to: '/records', icon: History, label: '加号记录' }, { to: '/accounts', icon: Users, label: '号池账号' }, { to: '/monitor', icon: Activity, label: '号池监控' }, + { to: '/cleaner', icon: Trash2, label: '定期清理' }, { to: '/config', icon: Settings, @@ -47,6 +50,7 @@ const navItems: NavItem[] = [ export default function Sidebar({ isOpen, onClose }: SidebarProps) { const location = useLocation() const [expandedItems, setExpandedItems] = useState(['/config']) + const { siteName } = useConfig() const toggleExpand = (path: string) => { setExpandedItems(prev => @@ -132,9 +136,9 @@ export default function Sidebar({ isOpen, onClose }: SidebarProps) {
- CP + {siteName.slice(0, 2).toUpperCase()}
- Codex Pool + {siteName}
diff --git a/frontend/src/components/upload/OwnerList.tsx b/frontend/src/components/upload/OwnerList.tsx index 49b9f52..b8b1902 100644 --- a/frontend/src/components/upload/OwnerList.tsx +++ b/frontend/src/components/upload/OwnerList.tsx @@ -230,8 +230,7 @@ export default function OwnerList({ onStatsChange }: OwnerListProps) { > - - + diff --git a/frontend/src/context/ConfigContext.tsx b/frontend/src/context/ConfigContext.tsx index 764d599..f393d8f 100644 --- a/frontend/src/context/ConfigContext.tsx +++ b/frontend/src/context/ConfigContext.tsx @@ -15,6 +15,7 @@ interface ConfigContextValue { testConnection: () => Promise s2aClient: S2AClient | null refreshConfig: () => Promise + siteName: string } const ConfigContext = createContext(null) @@ -23,6 +24,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) { const [config, setConfig] = useState(defaultConfig) const [isConnected, setIsConnected] = useState(false) const [s2aClient, setS2aClient] = useState(null) + const [siteName, setSiteName] = useState('Codex Pool') // Load config from server on mount const refreshConfig = useCallback(async () => { @@ -45,6 +47,10 @@ export function ConfigProvider({ children }: { children: ReactNode }) { groupIds: serverConfig.group_ids || [], }, })) + // 更新站点名称 + if (serverConfig.site_name) { + setSiteName(serverConfig.site_name) + } } } catch (error) { console.error('Failed to load config from server:', error) @@ -158,6 +164,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) { testConnection, s2aClient, refreshConfig, + siteName, }} > {children} diff --git a/frontend/src/pages/Cleaner.tsx b/frontend/src/pages/Cleaner.tsx new file mode 100644 index 0000000..e58afcf --- /dev/null +++ b/frontend/src/pages/Cleaner.tsx @@ -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(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 ( +
+ +
+ ) + } + + return ( +
+ {/* Header */} +
+
+

+ + 定期清理 +

+

自动清理 S2A 号池中的错误账号

+
+
+ + +
+
+ + {/* Message */} + {message && ( +
+ {message.type === 'success' ? ( + + ) : ( + + )} + {message.text} +
+ )} + + {/* Status Cards */} +
+ {/* 清理状态 */} + + +
+
+

清理服务

+

+ {cleanEnabled ? '已启用' : '已禁用'} +

+
+
+ {cleanEnabled ? ( + + ) : ( + + )} +
+
+
+
+ + {/* 清理间隔 */} + + +
+
+

清理间隔

+

+ {formatInterval(cleanInterval)} +

+
+
+ +
+
+
+
+ + {/* 上次清理 */} + + +
+
+

上次清理

+

+ {status ? formatLastCleanTime(status.last_clean_time) : '从未执行'} +

+
+
+ +
+
+
+
+
+ + {/* 清理设置 */} + + + + + 清理设置 + + + + +
+ {/* 清理间隔选择 */} +
+ + +

+ 每隔指定时间自动清理 S2A 中的错误账号 +

+
+ + {/* 手动清理 */} +
+ + +

+ 立即执行一次清理操作,删除所有错误账号 +

+
+
+
+
+ + {/* 说明信息 */} + + +
+ +
+

功能说明

+
    +
  • 定期清理功能会自动删除 S2A 号池中状态为"error"的账号
  • +
  • 清理操作是不可逆的,删除的账号无法恢复
  • +
  • 建议设置合理的清理间隔,避免过于频繁的清理操作
  • +
  • 清理日志可在"号池监控"页面的实时日志中查看
  • +
  • 启用后需要点击"保存设置"才会生效
  • +
+
+
+
+
+
+ ) +} diff --git a/frontend/src/pages/Config.tsx b/frontend/src/pages/Config.tsx index 0da38fc..6c63a32 100644 --- a/frontend/src/pages/Config.tsx +++ b/frontend/src/pages/Config.tsx @@ -1,3 +1,4 @@ +import { useState, useEffect } from 'react' import { Link } from 'react-router-dom' import { Server, @@ -6,13 +7,59 @@ import { Settings, RefreshCw, CheckCircle, - XCircle + XCircle, + Save, + Loader2, + Globe } 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' export default function Config() { 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 = [ { @@ -82,6 +129,58 @@ export default function Config() { ))} + {/* Site Settings */} + + + + + 基础配置 + + + + {/* Message */} + {message && ( +
+ {message.type === 'success' ? ( + + ) : ( + + )} + {message.text} +
+ )} + +
+ +
+ setSiteName(e.target.value)} + placeholder="输入站点名称,如:我的号池" + className="flex-1" + /> + +
+

+ 该名称将显示在侧边栏标题和浏览器标签页 +

+
+
+
+ {/* Info */} diff --git a/frontend/src/pages/Records.tsx b/frontend/src/pages/Records.tsx index 127452d..a563fec 100644 --- a/frontend/src/pages/Records.tsx +++ b/frontend/src/pages/Records.tsx @@ -1,5 +1,5 @@ -import { useState, useEffect } from 'react' -import { RefreshCw, Calendar, TrendingUp, CheckCircle, Clock, AlertCircle } from 'lucide-react' +import { useState, useEffect, useMemo } from 'react' +import { RefreshCw, Calendar, TrendingUp, CheckCircle, Clock, AlertCircle, Trash2 } from 'lucide-react' import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common' interface BatchRun { @@ -60,6 +60,28 @@ export default function Records() { 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) => { if (!startDate && !endDate) return true @@ -97,15 +119,28 @@ export default function Records() {

加号记录

查看历史入库记录

- +
+ {stuckRunningCount > 0 && ( + + )} + +
{/* Stats Cards */} diff --git a/frontend/src/pages/index.ts b/frontend/src/pages/index.ts index 39f4c2c..2f11d96 100644 --- a/frontend/src/pages/index.ts +++ b/frontend/src/pages/index.ts @@ -6,4 +6,5 @@ export { default as Config } from './Config' export { default as S2AConfig } from './S2AConfig' export { default as EmailConfig } from './EmailConfig' export { default as Monitor } from './Monitor' +export { default as Cleaner } from './Cleaner'