feat: Implement S2A pool management dashboard and monitoring pages with supporting backend API.

This commit is contained in:
2026-01-30 14:53:19 +08:00
parent d7f4724473
commit b307dd85f1
4 changed files with 304 additions and 15 deletions

View File

@@ -95,6 +95,7 @@ func startServer(cfg *config.Config) {
// S2A 代理 API // S2A 代理 API
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)) // 清理错误账号
// 邮箱服务 API // 邮箱服务 API
mux.HandleFunc("/api/mail/services", api.CORS(handleMailServices)) mux.HandleFunc("/api/mail/services", api.CORS(handleMailServices))

View File

@@ -0,0 +1,224 @@
fpackage api
import (
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"codex-pool/internal/config"
"codex-pool/internal/logger"
)
// S2AAccountItem S2A 账号信息
type S2AAccountItem struct {
ID int `json:"id"`
Email string `json:"email"`
Status string `json:"status"`
}
// S2AAccountsResponse S2A 账号列表响应
type S2AAccountsResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data struct {
Items []S2AAccountItem `json:"items"`
Total int `json:"total"`
Pages int `json:"pages"`
} `json:"data"`
}
// S2ADeleteResponse S2A 删除响应
type S2ADeleteResponse struct {
Code int `json:"code"`
Message string `json:"message"`
}
// HandleCleanErrorAccounts POST /api/s2a/clean-errors - 批量删除错误账号
func HandleCleanErrorAccounts(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "仅支持 POST")
return
}
if config.Global == nil || config.Global.S2AApiBase == "" || config.Global.S2AAdminKey == "" {
Error(w, http.StatusBadRequest, "S2A 配置未设置")
return
}
logger.Info("开始清理错误账号...", "", "s2a")
// Step 1: 获取所有错误账号
errorAccounts, err := fetchAllErrorAccounts()
if err != nil {
logger.Error(fmt.Sprintf("获取错误账号列表失败: %v", err), "", "s2a")
Error(w, http.StatusInternalServerError, fmt.Sprintf("获取错误账号列表失败: %v", err))
return
}
if len(errorAccounts) == 0 {
logger.Info("没有错误账号需要清理", "", "s2a")
Success(w, map[string]interface{}{
"message": "没有错误账号需要清理",
"total": 0,
"success": 0,
"failed": 0,
})
return
}
logger.Info(fmt.Sprintf("找到 %d 个错误账号,开始删除...", len(errorAccounts)), "", "s2a")
// Step 2: 逐条删除
success := 0
failed := 0
var details []map[string]interface{}
for _, account := range errorAccounts {
err := deleteS2AAccount(account.ID)
if err != nil {
failed++
details = append(details, map[string]interface{}{
"id": account.ID,
"email": account.Email,
"success": false,
"error": err.Error(),
})
logger.Warning(fmt.Sprintf("删除账号失败: ID=%d, Email=%s, Error=%v", account.ID, account.Email, err), account.Email, "s2a")
} else {
success++
details = append(details, map[string]interface{}{
"id": account.ID,
"email": account.Email,
"success": true,
})
logger.Success(fmt.Sprintf("删除账号成功: ID=%d, Email=%s", account.ID, account.Email), account.Email, "s2a")
}
}
logger.Success(fmt.Sprintf("清理错误账号完成: 成功=%d, 失败=%d, 总数=%d", success, failed, len(errorAccounts)), "", "s2a")
Success(w, map[string]interface{}{
"message": fmt.Sprintf("清理完成: 成功 %d, 失败 %d", success, failed),
"total": len(errorAccounts),
"success": success,
"failed": failed,
"details": details,
})
}
// fetchAllErrorAccounts 分页获取所有错误账号
func fetchAllErrorAccounts() ([]S2AAccountItem, error) {
var allAccounts []S2AAccountItem
page := 1
pageSize := 100
client := &http.Client{Timeout: 30 * time.Second}
for {
url := fmt.Sprintf("%s/api/v1/admin/accounts?page=%d&page_size=%d&status=error&timezone=Asia/Shanghai",
config.Global.S2AApiBase, page, pageSize)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("创建请求失败: %v", err)
}
setS2AHeaders(req)
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("请求失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取响应失败: %v", err)
}
var result S2AAccountsResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("解析响应失败: %v", err)
}
if result.Code != 0 {
return nil, fmt.Errorf("API 错误: %s", result.Message)
}
if len(result.Data.Items) == 0 {
break
}
allAccounts = append(allAccounts, result.Data.Items...)
logger.Info(fmt.Sprintf("获取错误账号: 第 %d 页, 本页 %d 个, 累计 %d 个",
page, len(result.Data.Items), len(allAccounts)), "", "s2a")
if page >= result.Data.Pages {
break
}
page++
}
return allAccounts, nil
}
// deleteS2AAccount 删除单个 S2A 账号
func deleteS2AAccount(accountID int) error {
client := &http.Client{Timeout: 30 * time.Second}
url := fmt.Sprintf("%s/api/v1/admin/accounts/%d", config.Global.S2AApiBase, accountID)
req, err := http.NewRequest("DELETE", url, nil)
if err != nil {
return fmt.Errorf("创建请求失败: %v", err)
}
setS2AHeaders(req)
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("请求失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("读取响应失败: %v", err)
}
var result S2ADeleteResponse
if err := json.Unmarshal(body, &result); err != nil {
return fmt.Errorf("解析响应失败: %v", err)
}
if result.Code != 0 {
return fmt.Errorf("%s", result.Message)
}
return nil
}
// setS2AHeaders 设置 S2A 请求头
func setS2AHeaders(req *http.Request) {
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
// 认证方式: x-api-key 或 authorization
if config.Global.S2AAdminKey != "" {
req.Header.Set("X-API-Key", config.Global.S2AAdminKey)
}
// 也设置 Authorization 作为备用
req.Header.Set("Authorization", "Bearer "+config.Global.S2AAdminKey)
}

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { Upload, RefreshCw, Settings } from 'lucide-react' import { Upload, RefreshCw, Settings, Trash2 } from 'lucide-react'
import { PoolStatus, RecentRecords } from '../components/dashboard' import { PoolStatus, RecentRecords } from '../components/dashboard'
import { Card, CardHeader, CardTitle, CardContent, Button } from '../components/common' import { Card, CardHeader, CardTitle, CardContent, Button } from '../components/common'
import { useS2AApi } from '../hooks/useS2AApi' import { useS2AApi } from '../hooks/useS2AApi'
@@ -15,6 +15,7 @@ export default function Dashboard() {
const { config } = useConfig() const { config } = useConfig()
const [stats, setStats] = useState<DashboardStats | null>(null) const [stats, setStats] = useState<DashboardStats | null>(null)
const [refreshing, setRefreshing] = useState(false) const [refreshing, setRefreshing] = useState(false)
const [cleaning, setCleaning] = useState(false)
const fetchStats = async () => { const fetchStats = async () => {
if (!isConnected) return if (!isConnected) return
@@ -26,6 +27,40 @@ export default function Dashboard() {
setRefreshing(false) setRefreshing(false)
} }
const handleCleanErrors = async () => {
if (!isConnected) return
const errorCount = stats?.error_accounts || 0
if (errorCount === 0) {
alert('没有错误账号需要清理')
return
}
const confirmed = window.confirm(
`确定要清理 ${errorCount} 个错误账号吗?此操作不可撤销。`
)
if (!confirmed) return
setCleaning(true)
try {
const res = await fetch('/api/s2a/clean-errors', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
const data = await res.json()
if (res.ok && data.code === 0) {
alert(`清理完成!成功: ${data.data.success}, 失败: ${data.data.failed}`)
// 刷新统计数据
fetchStats()
} else {
alert('清理失败: ' + (data.message || '未知错误'))
}
} catch (e) {
alert('清理失败: ' + (e instanceof Error ? e.message : '网络错误'))
}
setCleaning(false)
}
useEffect(() => { useEffect(() => {
fetchStats() fetchStats()
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -56,6 +91,17 @@ export default function Dashboard() {
</Button> </Button>
</Link> </Link>
<Button
variant="outline"
size="sm"
onClick={handleCleanErrors}
disabled={!isConnected || cleaning || (stats?.error_accounts || 0) === 0}
loading={cleaning}
icon={<Trash2 className="h-4 w-4" />}
className="text-red-600 hover:text-red-700 border-red-200 hover:border-red-300 dark:text-red-400 dark:border-red-800 dark:hover:border-red-700"
>
{stats?.error_accounts ? `(${stats.error_accounts})` : ''}
</Button>
</div> </div>
</div> </div>

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback, useRef } from 'react'
import { import {
Target, Target,
Activity, Activity,
@@ -69,6 +69,9 @@ export default function Monitor() {
// 倒计时状态 // 倒计时状态
const [countdown, setCountdown] = useState(60) const [countdown, setCountdown] = useState(60)
// 保存的轮询间隔(用于定时器,避免输入时触发重置)
const savedPollingIntervalRef = useRef(60)
// 使用后端 S2A 代理访问 S2A 服务器 // 使用后端 S2A 代理访问 S2A 服务器
const proxyBase = '/api/s2a/proxy' const proxyBase = '/api/s2a/proxy'
@@ -207,7 +210,7 @@ export default function Monitor() {
const handleSavePollingSettings = async () => { const handleSavePollingSettings = async () => {
setLoading(true) setLoading(true)
try { try {
await fetch('/api/monitor/settings/save', { const res = await fetch('/api/monitor/settings/save', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -218,10 +221,19 @@ export default function Monitor() {
polling_interval: pollingInterval, polling_interval: pollingInterval,
}), }),
}) })
// 重置倒计时 const data = await res.json()
if (res.ok && data.code === 0) {
// 保存成功,更新 ref 并重置倒计时
savedPollingIntervalRef.current = pollingInterval
setCountdown(pollingInterval) setCountdown(pollingInterval)
console.log('轮询设置已保存')
} else {
console.error('保存轮询设置失败:', data.message)
alert('保存失败: ' + (data.message || '未知错误'))
}
} catch (e) { } catch (e) {
console.error('保存轮询设置失败:', e) console.error('保存轮询设置失败:', e)
alert('保存失败: ' + (e instanceof Error ? e.message : '网络错误'))
} }
setLoading(false) setLoading(false)
} }
@@ -268,7 +280,10 @@ export default function Monitor() {
setAutoAdd(s.auto_add || false) setAutoAdd(s.auto_add || false)
setMinInterval(s.min_interval || 300) setMinInterval(s.min_interval || 300)
setPollingEnabled(s.polling_enabled || false) setPollingEnabled(s.polling_enabled || false)
setPollingInterval(s.polling_interval || 60) const interval = s.polling_interval || 60
setPollingInterval(interval)
savedPollingIntervalRef.current = interval // 同步更新 ref
setCountdown(interval)
} }
} }
} catch (e) { } catch (e) {
@@ -276,34 +291,37 @@ export default function Monitor() {
} }
} }
// 初始化 // 初始化 - 只在组件挂载时执行一次
useEffect(() => { useEffect(() => {
loadMonitorSettings() loadMonitorSettings()
fetchPoolStatus() fetchPoolStatus()
refreshStats() refreshStats()
fetchAutoAddLogs() fetchAutoAddLogs()
}, [fetchPoolStatus, refreshStats]) // eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// 倒计时定时器 - 当启用轮询时 // 倒计时定时器 - 当启用轮询时
useEffect(() => { useEffect(() => {
// 初始化倒计时 if (!pollingEnabled) {
setCountdown(pollingInterval) setCountdown(savedPollingIntervalRef.current)
return
if (!pollingEnabled) return }
const timer = setInterval(() => { const timer = setInterval(() => {
setCountdown(prev => { setCountdown(prev => {
if (prev <= 1) { if (prev <= 1) {
// 倒计时结束,刷新数据并重置 // 倒计时结束,刷新数据并重置
refreshStats() refreshStats()
return pollingInterval return savedPollingIntervalRef.current
} }
return prev - 1 return prev - 1
}) })
}, 1000) }, 1000)
return () => clearInterval(timer) return () => clearInterval(timer)
}, [pollingEnabled, pollingInterval, refreshStats]) // 只依赖 pollingEnabled,不依赖 pollingInterval(避免用户输入时重置)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pollingEnabled])
// 计算健康状态 // 计算健康状态
const healthySummary = healthResults.reduce( const healthySummary = healthResults.reduce(