diff --git a/backend/cmd/main.go b/backend/cmd/main.go index 71bd805..c1ebe25 100644 --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -102,7 +102,8 @@ func startServer(cfg *config.Config) { // 日志 API mux.HandleFunc("/api/logs", api.CORS(handleGetLogs)) mux.HandleFunc("/api/logs/clear", api.CORS(handleClearLogs)) - mux.HandleFunc("/api/logs/stream", handleLogStream) // SSE 实时日志 + mux.HandleFunc("/api/logs/query", api.CORS(handleQueryLogs)) // 按模块查询日志 + mux.HandleFunc("/api/logs/stream", handleLogStream) // SSE 实时日志 // S2A 代理 API mux.HandleFunc("/api/s2a/test", api.CORS(handleS2ATest)) @@ -137,6 +138,10 @@ func startServer(cfg *config.Config) { mux.HandleFunc("/api/team/status", api.CORS(api.HandleTeamProcessStatus)) mux.HandleFunc("/api/team/stop", api.CORS(api.HandleTeamProcessStop)) + // 批次历史 API(分页 + 详情) + mux.HandleFunc("/api/batch/history", api.CORS(api.HandleBatchHistory)) + mux.HandleFunc("/api/batch/detail", api.CORS(api.HandleBatchDetail)) + // 批次记录 API mux.HandleFunc("/api/batch/runs", api.CORS(handleBatchRuns)) mux.HandleFunc("/api/batch/stats", api.CORS(handleBatchStats)) @@ -282,6 +287,39 @@ func handleClearLogs(w http.ResponseWriter, r *http.Request) { api.Success(w, map[string]string{"message": "日志已清空"}) } +// handleQueryLogs GET /api/logs/query?module=cleaner&page=1&page_size=5 +func handleQueryLogs(w http.ResponseWriter, r *http.Request) { + module := r.URL.Query().Get("module") + if module == "" { + api.Error(w, http.StatusBadRequest, "缺少 module 参数") + return + } + + page := 1 + pageSize := 5 + if v := r.URL.Query().Get("page"); v != "" { + if p, err := strconv.Atoi(v); err == nil && p > 0 { + page = p + } + } + if v := r.URL.Query().Get("page_size"); v != "" { + if ps, err := strconv.Atoi(v); err == nil && ps > 0 && ps <= 100 { + pageSize = ps + } + } + + entries, total := logger.GetLogsByModule(module, page, pageSize) + totalPages := (total + pageSize - 1) / pageSize + + api.Success(w, map[string]interface{}{ + "logs": entries, + "total": total, + "page": page, + "page_size": pageSize, + "total_pages": totalPages, + }) +} + // handleBatchRuns 获取批次运行记录 func handleBatchRuns(w http.ResponseWriter, r *http.Request) { if database.Instance == nil { diff --git a/backend/internal/api/owner_ban_check.go b/backend/internal/api/owner_ban_check.go index f12be55..0aba43c 100644 --- a/backend/internal/api/owner_ban_check.go +++ b/backend/internal/api/owner_ban_check.go @@ -394,6 +394,8 @@ func checkSingleOwnerBan(owner database.TeamOwner, proxy string) BanCheckResult // 账户被封禁 logger.Warning(fmt.Sprintf("母号被封禁: %s - %s", owner.Email, accountStatus.Error), owner.Email, "ban-check") database.Instance.MarkOwnerAsInvalid(owner.Email) + database.Instance.DeleteTeamOwnerByEmail(owner.Email) + logger.Info(fmt.Sprintf("母号被封禁已删除: %s", owner.Email), owner.Email, "ban-check") result.Status = "banned" result.Message = accountStatus.Error @@ -401,6 +403,8 @@ func checkSingleOwnerBan(owner database.TeamOwner, proxy string) BanCheckResult // Token 过期 logger.Warning(fmt.Sprintf("母号 Token 过期: %s", owner.Email), owner.Email, "ban-check") database.Instance.MarkOwnerAsInvalid(owner.Email) + database.Instance.DeleteTeamOwnerByEmail(owner.Email) + logger.Info(fmt.Sprintf("母号Token过期已删除: %s", owner.Email), owner.Email, "ban-check") result.Status = "banned" result.Message = "Token 已过期" diff --git a/backend/internal/api/team_process.go b/backend/internal/api/team_process.go index abb26f7..a59d701 100644 --- a/backend/internal/api/team_process.go +++ b/backend/internal/api/team_process.go @@ -177,6 +177,85 @@ func HandleTeamProcessStop(w http.ResponseWriter, r *http.Request) { Success(w, map[string]string{"message": "已发送停止信号"}) } +// HandleBatchHistory GET /api/batch/history - 获取历史批次(分页) +func HandleBatchHistory(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + Error(w, http.StatusMethodNotAllowed, "仅支持 GET") + return + } + + if database.Instance == nil { + Error(w, http.StatusInternalServerError, "数据库未初始化") + return + } + + // 获取分页参数 + page := 1 + pageSize := 5 + if p := r.URL.Query().Get("page"); p != "" { + if v, err := fmt.Sscanf(p, "%d", &page); err == nil && v > 0 { + // page已设置 + } + } + if ps := r.URL.Query().Get("page_size"); ps != "" { + if v, err := fmt.Sscanf(ps, "%d", &pageSize); err == nil && v > 0 { + // pageSize已设置 + } + } + + runs, total, err := database.Instance.GetBatchRunsWithPagination(page, pageSize) + if err != nil { + Error(w, http.StatusInternalServerError, fmt.Sprintf("查询失败: %v", err)) + return + } + + totalPages := (total + pageSize - 1) / pageSize + + Success(w, map[string]interface{}{ + "runs": runs, + "total": total, + "page": page, + "page_size": pageSize, + "total_pages": totalPages, + }) +} + +// HandleBatchDetail GET /api/batch/detail?id=xxx - 获取批次详情(包含Team结果) +func HandleBatchDetail(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + Error(w, http.StatusMethodNotAllowed, "仅支持 GET") + return + } + + if database.Instance == nil { + Error(w, http.StatusInternalServerError, "数据库未初始化") + return + } + + idStr := r.URL.Query().Get("id") + if idStr == "" { + Error(w, http.StatusBadRequest, "缺少批次ID") + return + } + + var batchID int64 + if _, err := fmt.Sscanf(idStr, "%d", &batchID); err != nil { + Error(w, http.StatusBadRequest, "无效的批次ID") + return + } + + run, results, err := database.Instance.GetBatchRunWithResults(batchID) + if err != nil { + Error(w, http.StatusInternalServerError, fmt.Sprintf("查询失败: %v", err)) + return + } + + Success(w, map[string]interface{}{ + "batch": run, + "results": results, + }) +} + // runTeamProcess 执行 Team 批量处理 - 使用工作池模式 func runTeamProcess(req TeamProcessRequest) { totalOwners := len(req.Owners) @@ -272,6 +351,23 @@ func runTeamProcess(req TeamProcessRequest) { totalRegistered += result.Registered totalAddedToS2A += result.AddedToS2A allErrors = append(allErrors, result.Errors...) + + // 保存单个Team结果到数据库 + if database.Instance != nil && batchID > 0 { + dbResult := database.BatchTeamResult{ + TeamIndex: result.TeamIndex, + OwnerEmail: result.OwnerEmail, + TeamID: result.TeamID, + Registered: result.Registered, + AddedToS2A: result.AddedToS2A, + MemberEmails: result.MemberEmails, + Errors: result.Errors, + DurationMs: result.DurationMs, + } + if err := database.Instance.SaveBatchTeamResult(batchID, dbResult); err != nil { + logger.Error(fmt.Sprintf("保存Team结果失败: %v", err), result.OwnerEmail, "team") + } + } } // 计算成功率 @@ -324,6 +420,8 @@ func processSingleTeam(idx int, req TeamProcessRequest) (result TeamProcessResul if database.Instance != nil { if success { database.Instance.MarkOwnerAsUsed(owner.Email) + database.Instance.DeleteTeamOwnerByEmail(owner.Email) + logger.Info(fmt.Sprintf("%s 母号已使用并删除: %s", logPrefix, owner.Email), owner.Email, "team") } else { // 失败时恢复为 valid,允许重试 database.Instance.MarkOwnerAsFailed(owner.Email) @@ -356,6 +454,8 @@ func processSingleTeam(idx int, req TeamProcessRequest) (result TeamProcessResul logger.Warning(fmt.Sprintf("%s Token 无效或过期,标记为无效", logPrefix), owner.Email, "team") if database.Instance != nil { database.Instance.MarkOwnerAsInvalid(owner.Email) + database.Instance.DeleteTeamOwnerByEmail(owner.Email) + logger.Info(fmt.Sprintf("%s 母号无效已删除: %s", logPrefix, owner.Email), owner.Email, "team") } return result } @@ -377,6 +477,8 @@ func processSingleTeam(idx int, req TeamProcessRequest) (result TeamProcessResul logger.Warning(fmt.Sprintf("%s Team 邀请已满,标记母号为已使用: %v", logPrefix, err), owner.Email, "team") if database.Instance != nil { database.Instance.MarkOwnerAsUsed(owner.Email) + database.Instance.DeleteTeamOwnerByEmail(owner.Email) + logger.Info(fmt.Sprintf("%s 母号已使用并删除(邀请已满): %s", logPrefix, owner.Email), owner.Email, "team") } result.Errors = append(result.Errors, "Team 邀请已满") result.DurationMs = time.Since(startTime).Milliseconds() @@ -390,6 +492,8 @@ func processSingleTeam(idx int, req TeamProcessRequest) (result TeamProcessResul logger.Error(fmt.Sprintf("%s Team 被封禁,标记为无效: %v", logPrefix, err), owner.Email, "team") if database.Instance != nil { database.Instance.MarkOwnerAsInvalid(owner.Email) + database.Instance.DeleteTeamOwnerByEmail(owner.Email) + logger.Info(fmt.Sprintf("%s 母号无效已删除(Team被封禁): %s", logPrefix, owner.Email), owner.Email, "team") } result.Errors = append(result.Errors, "Team 被封禁") result.DurationMs = time.Since(startTime).Milliseconds() @@ -426,6 +530,8 @@ func processSingleTeam(idx int, req TeamProcessRequest) (result TeamProcessResul logger.Warning(fmt.Sprintf("%s Team 邀请已满,标记母号为已使用,停止后续处理", logPrefix), owner.Email, "team") if database.Instance != nil { database.Instance.MarkOwnerAsUsed(owner.Email) + database.Instance.DeleteTeamOwnerByEmail(owner.Email) + logger.Info(fmt.Sprintf("%s 母号已使用并删除(Team耗尽): %s", logPrefix, owner.Email), owner.Email, "team") } } } diff --git a/backend/internal/database/sqlite.go b/backend/internal/database/sqlite.go index 189ab44..6e3ad14 100644 --- a/backend/internal/database/sqlite.go +++ b/backend/internal/database/sqlite.go @@ -2,6 +2,7 @@ package database import ( "database/sql" + "encoding/json" "fmt" "time" @@ -104,8 +105,26 @@ func (d *DB) createTables() error { status TEXT DEFAULT 'running', errors TEXT ); - + CREATE INDEX IF NOT EXISTS idx_batch_runs_started_at ON batch_runs(started_at); + + -- 批次Team处理结果表 + CREATE TABLE IF NOT EXISTS batch_team_results ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + batch_id INTEGER NOT NULL, + team_index INTEGER NOT NULL, + owner_email TEXT NOT NULL, + team_id TEXT, + registered INTEGER DEFAULT 0, + added_to_s2a INTEGER DEFAULT 0, + member_emails TEXT, + errors TEXT, + duration_ms INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (batch_id) REFERENCES batch_runs(id) + ); + + CREATE INDEX IF NOT EXISTS idx_batch_team_results_batch_id ON batch_team_results(batch_id); `) return err } @@ -346,6 +365,15 @@ func (d *DB) DeleteTeamOwner(id int64) error { return err } +// DeleteTeamOwnerByEmail 按邮箱删除母号 +func (d *DB) DeleteTeamOwnerByEmail(email string) (int64, error) { + result, err := d.db.Exec("DELETE FROM team_owners WHERE email = ?", email) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + // ClearTeamOwners 清空 func (d *DB) ClearTeamOwners() error { _, err := d.db.Exec("DELETE FROM team_owners") @@ -569,6 +597,146 @@ func (d *DB) GetBatchRunStats() map[string]interface{} { return stats } +// BatchTeamResult 批次Team处理结果 +type BatchTeamResult struct { + ID int64 `json:"id"` + BatchID int64 `json:"batch_id"` + TeamIndex int `json:"team_index"` + OwnerEmail string `json:"owner_email"` + TeamID string `json:"team_id"` + Registered int `json:"registered"` + AddedToS2A int `json:"added_to_s2a"` + MemberEmails []string `json:"member_emails"` + Errors []string `json:"errors"` + DurationMs int64 `json:"duration_ms"` + CreatedAt time.Time `json:"created_at"` +} + +// SaveBatchTeamResult 保存单个Team处理结果 +func (d *DB) SaveBatchTeamResult(batchID int64, result BatchTeamResult) error { + memberEmailsJSON, _ := json.Marshal(result.MemberEmails) + errorsJSON, _ := json.Marshal(result.Errors) + + _, err := d.db.Exec(` + INSERT INTO batch_team_results (batch_id, team_index, owner_email, team_id, registered, added_to_s2a, member_emails, errors, duration_ms) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `, batchID, result.TeamIndex, result.OwnerEmail, result.TeamID, result.Registered, result.AddedToS2A, string(memberEmailsJSON), string(errorsJSON), result.DurationMs) + return err +} + +// GetBatchTeamResults 获取某批次的所有Team结果 +func (d *DB) GetBatchTeamResults(batchID int64) ([]BatchTeamResult, error) { + rows, err := d.db.Query(` + SELECT id, batch_id, team_index, owner_email, COALESCE(team_id, ''), registered, added_to_s2a, + COALESCE(member_emails, '[]'), COALESCE(errors, '[]'), duration_ms, created_at + FROM batch_team_results + WHERE batch_id = ? + ORDER BY team_index ASC + `, batchID) + if err != nil { + return nil, err + } + defer rows.Close() + + var results []BatchTeamResult + for rows.Next() { + var r BatchTeamResult + var memberEmailsJSON, errorsJSON string + err := rows.Scan(&r.ID, &r.BatchID, &r.TeamIndex, &r.OwnerEmail, &r.TeamID, &r.Registered, + &r.AddedToS2A, &memberEmailsJSON, &errorsJSON, &r.DurationMs, &r.CreatedAt) + if err != nil { + continue + } + json.Unmarshal([]byte(memberEmailsJSON), &r.MemberEmails) + json.Unmarshal([]byte(errorsJSON), &r.Errors) + if r.MemberEmails == nil { + r.MemberEmails = []string{} + } + if r.Errors == nil { + r.Errors = []string{} + } + results = append(results, r) + } + return results, nil +} + +// GetBatchRunsWithPagination 分页获取批次记录 +func (d *DB) GetBatchRunsWithPagination(page, pageSize int) ([]BatchRun, int, error) { + if page < 1 { + page = 1 + } + if pageSize <= 0 { + pageSize = 5 + } + + // 获取总数 + var total int + err := d.db.QueryRow("SELECT COUNT(*) FROM batch_runs").Scan(&total) + if err != nil { + return nil, 0, err + } + + offset := (page - 1) * pageSize + rows, err := d.db.Query(` + SELECT id, started_at, finished_at, total_owners, total_registered, total_added_to_s2a, + success_rate, duration_seconds, status, COALESCE(errors, '') + FROM batch_runs + ORDER BY started_at DESC + LIMIT ? OFFSET ? + `, pageSize, offset) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + var runs []BatchRun + for rows.Next() { + var run BatchRun + var finishedAt sql.NullTime + err := rows.Scan( + &run.ID, &run.StartedAt, &finishedAt, &run.TotalOwners, &run.TotalRegistered, + &run.TotalAddedToS2A, &run.SuccessRate, &run.DurationSeconds, &run.Status, &run.Errors, + ) + if err != nil { + continue + } + if finishedAt.Valid { + run.FinishedAt = finishedAt.Time + } + runs = append(runs, run) + } + return runs, total, nil +} + +// GetBatchRunWithResults 获取批次详情(包含Team结果) +func (d *DB) GetBatchRunWithResults(batchID int64) (*BatchRun, []BatchTeamResult, error) { + // 获取批次信息 + var run BatchRun + var finishedAt sql.NullTime + err := d.db.QueryRow(` + SELECT id, started_at, finished_at, total_owners, total_registered, total_added_to_s2a, + success_rate, duration_seconds, status, COALESCE(errors, '') + FROM batch_runs WHERE id = ? + `, batchID).Scan( + &run.ID, &run.StartedAt, &finishedAt, &run.TotalOwners, &run.TotalRegistered, + &run.TotalAddedToS2A, &run.SuccessRate, &run.DurationSeconds, &run.Status, &run.Errors, + ) + if err != nil { + return nil, nil, err + } + if finishedAt.Valid { + run.FinishedAt = finishedAt.Time + } + + // 获取Team结果 + results, err := d.GetBatchTeamResults(batchID) + if err != nil { + return &run, nil, err + } + + return &run, results, nil +} + // Close 关闭数据库 func (d *DB) Close() error { if d.db != nil { diff --git a/backend/internal/logger/logger.go b/backend/internal/logger/logger.go index 330e9a6..eba6bb1 100644 --- a/backend/internal/logger/logger.go +++ b/backend/internal/logger/logger.go @@ -203,6 +203,39 @@ func GetLogs(limit int) []LogEntry { return result } +// GetLogsByModule 按模块筛选日志并分页(最新的在前) +func GetLogsByModule(module string, page, pageSize int) ([]LogEntry, int) { + logsMu.RLock() + defer logsMu.RUnlock() + + // 倒序收集匹配的日志 + var filtered []LogEntry + for i := len(logs) - 1; i >= 0; i-- { + if logs[i].Module == module { + filtered = append(filtered, logs[i]) + } + } + + total := len(filtered) + if page < 1 { + page = 1 + } + if pageSize <= 0 { + pageSize = 5 + } + + start := (page - 1) * pageSize + if start >= total { + return []LogEntry{}, total + } + end := start + pageSize + if end > total { + end = total + } + + return filtered[start:end], total +} + // ClearLogs 清空日志 func ClearLogs() { logsMu.Lock() diff --git a/frontend/src/components/upload/BatchResultHistory.tsx b/frontend/src/components/upload/BatchResultHistory.tsx new file mode 100644 index 0000000..70b0836 --- /dev/null +++ b/frontend/src/components/upload/BatchResultHistory.tsx @@ -0,0 +1,430 @@ +import { useState, useEffect, useCallback } from 'react' +import { + ChevronDown, + ChevronRight, + ChevronLeft, + CheckCircle, + AlertTriangle, + Clock, + Users, + Settings, + RefreshCw, + FileText, +} from 'lucide-react' +import { Button } from '../common' + +interface TeamResult { + id: number + batch_id: number + team_index: number + owner_email: string + team_id: string + registered: number + added_to_s2a: number + member_emails: string[] + errors: string[] + duration_ms: number + created_at: string +} + +interface BatchRun { + id: number + started_at: string + finished_at: string + total_owners: number + total_registered: number + total_added_to_s2a: number + success_rate: number + duration_seconds: number + status: string + errors: string +} + +interface BatchHistoryResponse { + runs: BatchRun[] + total: number + page: number + page_size: number + total_pages: number +} + +interface BatchDetailResponse { + batch: BatchRun + results: TeamResult[] +} + +// 格式化时间 +function formatTime(dateStr: string): string { + if (!dateStr) return '-' + const date = new Date(dateStr) + const now = new Date() + const isToday = date.toDateString() === now.toDateString() + + if (isToday) { + return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) + } + return date.toLocaleString('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) +} + +// 格式化耗时 +function formatDuration(seconds: number): string { + if (seconds < 60) return `${seconds}s` + const minutes = Math.floor(seconds / 60) + const secs = seconds % 60 + if (minutes < 60) return `${minutes}m ${secs}s` + const hours = Math.floor(minutes / 60) + const mins = minutes % 60 + return `${hours}h ${mins}m` +} + +// Team 详情行组件 +function TeamResultRow({ result }: { result: TeamResult }) { + const [expanded, setExpanded] = useState(false) + const hasErrors = result.errors && result.errors.length > 0 + + return ( +
+ {/* 折叠头部 */} +
setExpanded(!expanded)} + className="flex items-center justify-between px-4 py-3 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors" + > +
+ {expanded ? ( + + ) : ( + + )} + + Team {result.team_index} + + + {result.owner_email} + + {hasErrors && ( + + )} +
+
+ + + {result.registered} + + + + {result.added_to_s2a} + + + {(result.duration_ms / 1000).toFixed(1)}s + +
+
+ + {/* 展开详情 */} + {expanded && ( +
+ {result.team_id && ( +
+ Team ID: {result.team_id} +
+ )} + + {result.member_emails && result.member_emails.length > 0 && ( +
+

成员邮箱:

+
+ {result.member_emails.map((email, idx) => ( + + {email} + + ))} +
+
+ )} + + {hasErrors && ( +
+

+ + 错误: +

+
+ {result.errors.map((err, idx) => ( +

+ • {err} +

+ ))} +
+
+ )} +
+ )} +
+ ) +} + +// 批次卡片组件 +function BatchCard({ batch, onLoadDetail }: { batch: BatchRun; onLoadDetail: (id: number) => Promise }) { + const [expanded, setExpanded] = useState(false) + const [results, setResults] = useState(null) + const [loading, setLoading] = useState(false) + + const handleToggle = async () => { + if (!expanded && results === null) { + setLoading(true) + try { + const data = await onLoadDetail(batch.id) + setResults(data) + } catch (e) { + console.error('加载批次详情失败:', e) + } + setLoading(false) + } + setExpanded(!expanded) + } + + const errorCount = results?.filter(r => r.errors && r.errors.length > 0).length || 0 + + return ( +
+ {/* 批次头部 */} +
+
+ {loading ? ( + + ) : expanded ? ( + + ) : ( + + )} +
+
+ + + 批次 #{batch.id} + + + {batch.status === 'completed' ? '已完成' : '运行中'} + +
+
+ + {formatTime(batch.started_at)} +
+
+
+ + {/* 汇总统计 */} +
+
+ + {batch.total_owners} +
+
+ + {batch.total_registered} +
+
+ + {batch.total_added_to_s2a} +
+ {expanded && errorCount > 0 && ( +
+ + {errorCount} +
+ )} + + {formatDuration(batch.duration_seconds)} + +
+
+ + {/* 展开的 Team 列表 */} + {expanded && ( +
+ {loading ? ( +
+ + 加载中... +
+ ) : results && results.length > 0 ? ( +
+ {results.map((result) => ( + + ))} +
+ ) : ( +
+ 暂无 Team 处理记录 +
+ )} +
+ )} +
+ ) +} + +// 分页组件 +function Pagination({ + page, + totalPages, + onPageChange, +}: { + page: number + totalPages: number + onPageChange: (page: number) => void +}) { + if (totalPages <= 1) return null + + return ( +
+ + + 第 {page} / {totalPages} 页 + + +
+ ) +} + +// 主组件 +export default function BatchResultHistory() { + const [batches, setBatches] = useState([]) + const [page, setPage] = useState(1) + const [totalPages, setTotalPages] = useState(1) + const [total, setTotal] = useState(0) + const [loading, setLoading] = useState(true) + + // 加载批次列表 + const loadBatches = useCallback(async (p: number) => { + setLoading(true) + try { + const res = await fetch(`/api/batch/history?page=${p}&page_size=5`) + if (res.ok) { + const data = await res.json() + if (data.code === 0) { + const result = data.data as BatchHistoryResponse + setBatches(result.runs || []) + setTotalPages(result.total_pages) + setTotal(result.total) + setPage(result.page) + } + } + } catch (e) { + console.error('加载批次历史失败:', e) + } + setLoading(false) + }, []) + + // 加载批次详情 + const loadBatchDetail = useCallback(async (batchId: number): Promise => { + try { + const res = await fetch(`/api/batch/detail?id=${batchId}`) + if (res.ok) { + const data = await res.json() + if (data.code === 0) { + const result = data.data as BatchDetailResponse + return result.results || [] + } + } + } catch (e) { + console.error('加载批次详情失败:', e) + } + return [] + }, []) + + useEffect(() => { + loadBatches(1) + }, [loadBatches]) + + const handlePageChange = (newPage: number) => { + loadBatches(newPage) + } + + const handleRefresh = () => { + loadBatches(page) + } + + return ( +
+ {/* 头部 */} +
+
+ +

处理历史

+ 共 {total} 个批次 +
+ +
+ + {/* 内容区域 */} +
+ {loading && batches.length === 0 ? ( +
+ + 加载中... +
+ ) : batches.length === 0 ? ( +
+ +

暂无处理记录

+

上传账号文件并点击开始处理

+
+ ) : ( +
+ {batches.map((batch) => ( + + ))} +
+ )} + + {/* 分页 */} + +
+
+ ) +} diff --git a/frontend/src/components/upload/index.ts b/frontend/src/components/upload/index.ts index f745a5f..3bac86f 100644 --- a/frontend/src/components/upload/index.ts +++ b/frontend/src/components/upload/index.ts @@ -2,3 +2,4 @@ export { default as FileDropzone } from './FileDropzone' export { default as AccountTable } from './AccountTable' export { default as CheckProgress } from './CheckProgress' export { default as PoolActions } from './PoolActions' +export { default as BatchResultHistory } from './BatchResultHistory' diff --git a/frontend/src/pages/Cleaner.tsx b/frontend/src/pages/Cleaner.tsx index 5410c4b..60faa02 100644 --- a/frontend/src/pages/Cleaner.tsx +++ b/frontend/src/pages/Cleaner.tsx @@ -1,5 +1,5 @@ -import { useState, useEffect } from 'react' -import { Trash2, Clock, Loader2, Save, RefreshCw, CheckCircle, XCircle, ToggleLeft, ToggleRight, AlertTriangle } from 'lucide-react' +import { useState, useEffect, useCallback } from 'react' +import { Trash2, Clock, Loader2, Save, RefreshCw, CheckCircle, XCircle, ToggleLeft, ToggleRight, AlertTriangle, ChevronLeft, ChevronRight, ScrollText } from 'lucide-react' import { Card, CardHeader, CardTitle, CardContent, Button } from '../components/common' interface CleanerStatus { @@ -9,6 +9,14 @@ interface CleanerStatus { last_clean_time: string } +interface CleanerLogEntry { + timestamp: string + level: string + message: string + email?: string + module?: string +} + export default function Cleaner() { const [loading, setLoading] = useState(true) const [cleanEnabled, setCleanEnabled] = useState(false) @@ -18,6 +26,14 @@ export default function Cleaner() { const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null) const [status, setStatus] = useState(null) + // 清理日志状态 + const [logEntries, setLogEntries] = useState([]) + const [logPage, setLogPage] = useState(1) + const [logTotalPages, setLogTotalPages] = useState(0) + const [logTotal, setLogTotal] = useState(0) + const [logLoading, setLogLoading] = useState(false) + const logPageSize = 5 + // 加载清理设置 const fetchCleanerSettings = async () => { setLoading(true) @@ -36,10 +52,37 @@ export default function Cleaner() { } } + // 加载清理日志 + const fetchCleanerLogs = useCallback(async (page = 1) => { + setLogLoading(true) + try { + const params = new URLSearchParams({ + module: 'cleaner', + page: String(page), + page_size: String(logPageSize), + }) + const res = await fetch(`/api/logs/query?${params}`) + const data = await res.json() + if (data.code === 0 && data.data) { + setLogEntries(data.data.logs || []) + setLogTotalPages(data.data.total_pages || 0) + setLogTotal(data.data.total || 0) + } + } catch (error) { + console.error('Failed to fetch cleaner logs:', error) + } finally { + setLogLoading(false) + } + }, [logPageSize]) + useEffect(() => { fetchCleanerSettings() }, []) + useEffect(() => { + fetchCleanerLogs(logPage) + }, [logPage, fetchCleanerLogs]) + // 保存清理设置 const handleSaveCleanerSettings = async () => { setSavingClean(true) @@ -78,8 +121,9 @@ export default function Cleaner() { const data = await res.json() if (data.code === 0) { setMessage({ type: 'success', text: data.data.message || '清理完成' }) - // 刷新状态 + // 刷新状态和日志 fetchCleanerSettings() + fetchCleanerLogs(logPage) } else { setMessage({ type: 'error', text: data.message || '清理失败' }) } @@ -132,6 +176,21 @@ export default function Cleaner() { } } + // 日志级别样式 + const levelColors: Record = { + success: 'text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-900/20', + error: 'text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20', + warning: 'text-yellow-600 dark:text-yellow-400 bg-yellow-50 dark:bg-yellow-900/20', + info: 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20', + } + + const levelLabels: Record = { + success: 'SUCCESS', + error: 'ERROR', + warning: 'WARN', + info: 'INFO', + } + if (loading) { return (
@@ -326,6 +385,91 @@ export default function Cleaner() { + {/* 清理日志 */} + + + + + 清理日志 + +
+ + 共 {logTotal} 条 + + +
+
+ + {logLoading && logEntries.length === 0 ? ( +
+ + 加载中... +
+ ) : logEntries.length === 0 ? ( +
+ +

暂无清理日志

+
+ ) : ( +
+ {logEntries.map((log, i) => ( +
+ + {new Date(log.timestamp).toLocaleString('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + })} + + + {levelLabels[log.level] || log.level?.toUpperCase()} + + + {log.message} + +
+ ))} +
+ )} +
+ {logTotalPages > 1 && ( +
+ + 第 {logPage} / {logTotalPages} 页 + +
+ + +
+
+ )} +
+ {/* 说明信息 */} @@ -337,7 +481,7 @@ export default function Cleaner() {
  • 定期清理功能会自动删除 S2A 号池中状态为"error"的账号
  • 清理操作是不可逆的,删除的账号无法恢复
  • 建议设置合理的清理间隔,避免过于频繁的清理操作
  • -
  • 清理日志可在"号池监控"页面的实时日志中查看
  • +
  • 母号使用完毕或检测为无效后会自动从列表中删除
  • 启用后需要点击"保存设置"才会生效
  • diff --git a/frontend/src/pages/Upload.tsx b/frontend/src/pages/Upload.tsx index a3ed17d..effd7cd 100644 --- a/frontend/src/pages/Upload.tsx +++ b/frontend/src/pages/Upload.tsx @@ -16,6 +16,7 @@ import { import { FileDropzone } from '../components/upload' import LogStream from '../components/upload/LogStream' import OwnerList from '../components/upload/OwnerList' +import BatchResultHistory from '../components/upload/BatchResultHistory' import { Card, CardHeader, CardTitle, CardContent, Button, Tabs, Input, Switch } from '../components/common' import { useConfig } from '../hooks/useConfig' @@ -65,6 +66,7 @@ export default function Upload() { account_id_ok: number account_id_fail: number } | null>(null) + const [batchCount, setBatchCount] = useState(0) // 配置 const [membersPerTeam, setMembersPerTeam] = useState(4) @@ -89,6 +91,19 @@ export default function Upload() { } }, []) + // Load batch count + const loadBatchCount = useCallback(async () => { + try { + const res = await fetch('/api/batch/history?page=1&page_size=1') + const data = await res.json() + if (data.code === 0) { + setBatchCount(data.data.total || 0) + } + } catch (e) { + console.error('Failed to load batch count:', e) + } + }, []) + // 获取状态 const fetchStatus = useCallback(async () => { try { @@ -100,13 +115,14 @@ export default function Upload() { if (!data.data.running) { setPolling(false) loadStats() // 刷新统计 + loadBatchCount() // 刷新批次数 } } } } catch (e) { console.error('获取状态失败:', e) } - }, [loadStats]) + }, [loadStats, loadBatchCount]) // 轮询状态 useEffect(() => { @@ -119,7 +135,8 @@ export default function Upload() { useEffect(() => { loadStats() fetchStatus() - }, [loadStats, fetchStatus]) + loadBatchCount() + }, [loadStats, fetchStatus, loadBatchCount]) // Upload and validate const handleFileSelect = useCallback( @@ -217,7 +234,7 @@ export default function Upload() { { id: 'upload', label: '上传', icon: UploadIcon }, { id: 'owners', label: '母号列表', icon: List, count: stats?.total }, { id: 'logs', label: '日志', icon: Activity }, - { id: 'results', label: '处理结果', icon: CheckCircle, count: status?.results?.length }, + { id: 'results', label: '处理结果', icon: CheckCircle, count: batchCount || undefined }, ] return ( @@ -240,7 +257,7 @@ export default function Upload() { disabled={refreshing} onClick={async () => { setRefreshing(true) - await Promise.all([loadStats(), fetchStatus()]) + await Promise.all([loadStats(), fetchStatus(), loadBatchCount()]) setTimeout(() => setRefreshing(false), 500) }} icon={} @@ -526,95 +543,8 @@ export default function Upload() { )} {activeTab === 'results' && ( -
    - - - - - 处理结果 - - {status && status.elapsed_ms > 0 && ( - - 耗时: {(status.elapsed_ms / 1000).toFixed(1)}s - - )} - - - {status?.results && status.results.length > 0 ? ( -
    - {status.results.map((result) => ( -
    -
    -
    - - Team {result.team_index} - - - {result.owner_email} - -
    -
    - - - 注册: {result.registered} - - - - 入库: {result.added_to_s2a} - -
    -
    - - {result.member_emails.length > 0 && ( -
    -

    成员邮箱:

    -
    - {result.member_emails.map((email, idx) => ( - - {email} - - ))} -
    -
    - )} - - {result.errors.length > 0 && ( -
    -

    - - 错误: -

    -
    - {result.errors.map((err, idx) => ( -

    - • {err} -

    - ))} -
    -
    - )} - -
    - 耗时: {(result.duration_ms / 1000).toFixed(1)}s -
    -
    - ))} -
    - ) : ( -
    - -

    暂无处理结果

    -

    上传账号文件并点击开始处理

    -
    - )} -
    -
    +
    +
    )}