feat: Implement CodexAuth proxy pool management with a new frontend configuration page and a dedicated backend service for API, database, and proxy testing.
This commit is contained in:
@@ -164,6 +164,7 @@ func startServer(cfg *config.Config) {
|
|||||||
|
|
||||||
// CodexAuth 代理池 API
|
// CodexAuth 代理池 API
|
||||||
mux.HandleFunc("/api/codex-proxy", api.CORS(api.HandleCodexProxies))
|
mux.HandleFunc("/api/codex-proxy", api.CORS(api.HandleCodexProxies))
|
||||||
|
mux.HandleFunc("/api/codex-proxy/test", api.CORS(api.HandleCodexProxies))
|
||||||
|
|
||||||
// 嵌入的前端静态文件
|
// 嵌入的前端静态文件
|
||||||
if web.IsEmbedded() {
|
if web.IsEmbedded() {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"codex-pool/internal/database"
|
"codex-pool/internal/database"
|
||||||
|
"codex-pool/internal/proxyutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HandleCodexProxies 处理代理池请求
|
// HandleCodexProxies 处理代理池请求
|
||||||
@@ -15,6 +16,10 @@ func HandleCodexProxies(w http.ResponseWriter, r *http.Request) {
|
|||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
listCodexProxies(w, r)
|
listCodexProxies(w, r)
|
||||||
case http.MethodPost:
|
case http.MethodPost:
|
||||||
|
if strings.HasSuffix(r.URL.Path, "/test") {
|
||||||
|
testCodexProxy(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
addCodexProxy(w, r)
|
addCodexProxy(w, r)
|
||||||
case http.MethodDelete:
|
case http.MethodDelete:
|
||||||
deleteCodexProxy(w, r)
|
deleteCodexProxy(w, r)
|
||||||
@@ -170,3 +175,56 @@ func toggleCodexProxy(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
Success(w, map[string]string{"message": "状态已切换"})
|
Success(w, map[string]string{"message": "状态已切换"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// testCodexProxy 测试代理连通性
|
||||||
|
func testCodexProxy(w http.ResponseWriter, r *http.Request) {
|
||||||
|
idStr := r.URL.Query().Get("id")
|
||||||
|
if idStr == "" {
|
||||||
|
Error(w, http.StatusBadRequest, "缺少代理 ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
Error(w, http.StatusBadRequest, "无效的代理 ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取代理详情
|
||||||
|
proxies, err := database.Instance.GetCodexProxies()
|
||||||
|
if err != nil {
|
||||||
|
Error(w, http.StatusInternalServerError, "获取代理失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetProxy *database.CodexProxy
|
||||||
|
for _, p := range proxies {
|
||||||
|
if p.ID == id {
|
||||||
|
targetProxy = &p
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if targetProxy == nil {
|
||||||
|
Error(w, http.StatusNotFound, "未找到代理")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行测试 (异步执行以防前端超时,但用户想要同步结果,所以这里同步执行)
|
||||||
|
// 因为是在管理后台点击,15s 超时是可以接受的
|
||||||
|
result, err := proxyutil.TestProxy(targetProxy.ProxyURL)
|
||||||
|
if err != nil {
|
||||||
|
database.Instance.UpdateCodexProxyTestResult(id, "", false)
|
||||||
|
Error(w, http.StatusInternalServerError, "测试过程出错: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新数据库
|
||||||
|
err = database.Instance.UpdateCodexProxyTestResult(id, result.Location, result.Success)
|
||||||
|
if err != nil {
|
||||||
|
Error(w, http.StatusInternalServerError, "更新测试结果失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Success(w, result)
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"codex-pool/internal/proxyutil"
|
"codex-pool/internal/proxyutil"
|
||||||
|
|
||||||
"github.com/Noooste/azuretls-client"
|
"github.com/Noooste/azuretls-client"
|
||||||
|
fhttp "github.com/Noooste/fhttp"
|
||||||
"github.com/andybalholm/brotli"
|
"github.com/andybalholm/brotli"
|
||||||
http2 "github.com/bogdanfinn/fhttp"
|
http2 "github.com/bogdanfinn/fhttp"
|
||||||
tls_client "github.com/bogdanfinn/tls-client"
|
tls_client "github.com/bogdanfinn/tls-client"
|
||||||
@@ -475,14 +476,29 @@ func (c *TLSClient) PostJSON(urlStr string, body io.Reader) (*http.Response, err
|
|||||||
|
|
||||||
// GetCookie 获取指定 URL 的 Cookie
|
// GetCookie 获取指定 URL 的 Cookie
|
||||||
func (c *TLSClient) GetCookie(urlStr string, name string) string {
|
func (c *TLSClient) GetCookie(urlStr string, name string) string {
|
||||||
if c.clientType == ClientTypeAzureTLS {
|
|
||||||
return "" // azuretls cookie 管理待完善,目前主要依赖 tls-client 或手动 handle
|
|
||||||
}
|
|
||||||
|
|
||||||
u, err := url.Parse(urlStr)
|
u, err := url.Parse(urlStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// azuretls 使用标准的 http.CookieJar
|
||||||
|
if c.clientType == ClientTypeAzureTLS {
|
||||||
|
if c.azureSession == nil || c.azureSession.CookieJar == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
cookies := c.azureSession.CookieJar.Cookies(u)
|
||||||
|
for _, cookie := range cookies {
|
||||||
|
if cookie.Name == name {
|
||||||
|
return cookie.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// tls-client
|
||||||
|
if c.tlsClient == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
cookies := c.tlsClient.GetCookies(u)
|
cookies := c.tlsClient.GetCookies(u)
|
||||||
for _, cookie := range cookies {
|
for _, cookie := range cookies {
|
||||||
if cookie.Name == name {
|
if cookie.Name == name {
|
||||||
@@ -494,12 +510,29 @@ func (c *TLSClient) GetCookie(urlStr string, name string) string {
|
|||||||
|
|
||||||
// SetCookie 设置 Cookie
|
// SetCookie 设置 Cookie
|
||||||
func (c *TLSClient) SetCookie(urlStr string, cookie *http.Cookie) {
|
func (c *TLSClient) SetCookie(urlStr string, cookie *http.Cookie) {
|
||||||
if c.clientType == ClientTypeAzureTLS {
|
u, err := url.Parse(urlStr)
|
||||||
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
u, err := url.Parse(urlStr)
|
// azuretls 使用 Noooste/fhttp 的 Cookie 类型
|
||||||
if err != nil {
|
if c.clientType == ClientTypeAzureTLS {
|
||||||
|
if c.azureSession == nil || c.azureSession.CookieJar == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.azureSession.CookieJar.SetCookies(u, []*fhttp.Cookie{
|
||||||
|
{
|
||||||
|
Name: cookie.Name,
|
||||||
|
Value: cookie.Value,
|
||||||
|
Path: cookie.Path,
|
||||||
|
Domain: cookie.Domain,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// tls-client
|
||||||
|
if c.tlsClient == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.tlsClient.SetCookies(u, []*http2.Cookie{
|
c.tlsClient.SetCookies(u, []*http2.Cookie{
|
||||||
|
|||||||
@@ -54,10 +54,14 @@ func Init(dbPath string) error {
|
|||||||
// migrate 数据库迁移
|
// migrate 数据库迁移
|
||||||
func (d *DB) migrate() error {
|
func (d *DB) migrate() error {
|
||||||
// 添加 last_checked_at 列(如果不存在)
|
// 添加 last_checked_at 列(如果不存在)
|
||||||
_, err := d.db.Exec(`ALTER TABLE team_owners ADD COLUMN last_checked_at DATETIME`)
|
_, _ = d.db.Exec(`ALTER TABLE team_owners ADD COLUMN last_checked_at DATETIME`)
|
||||||
if err != nil && !isColumnExistsError(err) {
|
|
||||||
return err
|
// 添加 last_test_at 列 (如果不存在)
|
||||||
}
|
_, _ = d.db.Exec(`ALTER TABLE codex_auth_proxies ADD COLUMN last_test_at DATETIME`)
|
||||||
|
|
||||||
|
// 添加 location 列 (如果不存在)
|
||||||
|
_, _ = d.db.Exec(`ALTER TABLE codex_auth_proxies ADD COLUMN location TEXT`)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +139,8 @@ func (d *DB) createTables() error {
|
|||||||
last_used_at DATETIME,
|
last_used_at DATETIME,
|
||||||
success_count INTEGER DEFAULT 0,
|
success_count INTEGER DEFAULT 0,
|
||||||
fail_count INTEGER DEFAULT 0,
|
fail_count INTEGER DEFAULT 0,
|
||||||
|
location TEXT,
|
||||||
|
last_test_at DATETIME,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -272,7 +278,7 @@ func (d *DB) GetTeamOwners(status string, limit, offset int) ([]TeamOwner, int,
|
|||||||
return owners, total, nil
|
return owners, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPendingOwners 获取待处理(排除已使用和处理中的)
|
// GetPendingOwners 获取待处理(排除已使用 and 处理中的)
|
||||||
func (d *DB) GetPendingOwners() ([]TeamOwner, error) {
|
func (d *DB) GetPendingOwners() ([]TeamOwner, error) {
|
||||||
rows, err := d.db.Query(`
|
rows, err := d.db.Query(`
|
||||||
SELECT id, email, password, token, account_id, status, created_at, last_checked_at
|
SELECT id, email, password, token, account_id, status, created_at, last_checked_at
|
||||||
@@ -760,6 +766,8 @@ type CodexProxy struct {
|
|||||||
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||||
SuccessCount int `json:"success_count"`
|
SuccessCount int `json:"success_count"`
|
||||||
FailCount int `json:"fail_count"`
|
FailCount int `json:"fail_count"`
|
||||||
|
Location string `json:"location"`
|
||||||
|
LastTestAt *time.Time `json:"last_test_at,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -813,7 +821,7 @@ func (d *DB) AddCodexProxies(proxies []string) (int, error) {
|
|||||||
// GetCodexProxies 获取代理列表
|
// GetCodexProxies 获取代理列表
|
||||||
func (d *DB) GetCodexProxies() ([]CodexProxy, error) {
|
func (d *DB) GetCodexProxies() ([]CodexProxy, error) {
|
||||||
rows, err := d.db.Query(`
|
rows, err := d.db.Query(`
|
||||||
SELECT id, proxy_url, COALESCE(description, ''), is_enabled, last_used_at, success_count, fail_count, created_at
|
SELECT id, proxy_url, COALESCE(description, ''), is_enabled, last_used_at, success_count, fail_count, COALESCE(location, ''), last_test_at, created_at
|
||||||
FROM codex_auth_proxies
|
FROM codex_auth_proxies
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
`)
|
`)
|
||||||
@@ -825,14 +833,17 @@ func (d *DB) GetCodexProxies() ([]CodexProxy, error) {
|
|||||||
var proxies []CodexProxy
|
var proxies []CodexProxy
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var p CodexProxy
|
var p CodexProxy
|
||||||
var lastUsedAt sql.NullTime
|
var lastUsedAt, lastTestAt sql.NullTime
|
||||||
err := rows.Scan(&p.ID, &p.ProxyURL, &p.Description, &p.IsEnabled, &lastUsedAt, &p.SuccessCount, &p.FailCount, &p.CreatedAt)
|
err := rows.Scan(&p.ID, &p.ProxyURL, &p.Description, &p.IsEnabled, &lastUsedAt, &p.SuccessCount, &p.FailCount, &p.Location, &lastTestAt, &p.CreatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if lastUsedAt.Valid {
|
if lastUsedAt.Valid {
|
||||||
p.LastUsedAt = &lastUsedAt.Time
|
p.LastUsedAt = &lastUsedAt.Time
|
||||||
}
|
}
|
||||||
|
if lastTestAt.Valid {
|
||||||
|
p.LastTestAt = &lastTestAt.Time
|
||||||
|
}
|
||||||
proxies = append(proxies, p)
|
proxies = append(proxies, p)
|
||||||
}
|
}
|
||||||
return proxies, nil
|
return proxies, nil
|
||||||
@@ -841,7 +852,7 @@ func (d *DB) GetCodexProxies() ([]CodexProxy, error) {
|
|||||||
// GetEnabledCodexProxies 获取已启用的代理列表
|
// GetEnabledCodexProxies 获取已启用的代理列表
|
||||||
func (d *DB) GetEnabledCodexProxies() ([]CodexProxy, error) {
|
func (d *DB) GetEnabledCodexProxies() ([]CodexProxy, error) {
|
||||||
rows, err := d.db.Query(`
|
rows, err := d.db.Query(`
|
||||||
SELECT id, proxy_url, COALESCE(description, ''), is_enabled, last_used_at, success_count, fail_count, created_at
|
SELECT id, proxy_url, COALESCE(description, ''), is_enabled, last_used_at, success_count, fail_count, COALESCE(location, ''), last_test_at, created_at
|
||||||
FROM codex_auth_proxies
|
FROM codex_auth_proxies
|
||||||
WHERE is_enabled = 1
|
WHERE is_enabled = 1
|
||||||
ORDER BY success_count DESC, fail_count ASC
|
ORDER BY success_count DESC, fail_count ASC
|
||||||
@@ -854,14 +865,17 @@ func (d *DB) GetEnabledCodexProxies() ([]CodexProxy, error) {
|
|||||||
var proxies []CodexProxy
|
var proxies []CodexProxy
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var p CodexProxy
|
var p CodexProxy
|
||||||
var lastUsedAt sql.NullTime
|
var lastUsedAt, lastTestAt sql.NullTime
|
||||||
err := rows.Scan(&p.ID, &p.ProxyURL, &p.Description, &p.IsEnabled, &lastUsedAt, &p.SuccessCount, &p.FailCount, &p.CreatedAt)
|
err := rows.Scan(&p.ID, &p.ProxyURL, &p.Description, &p.IsEnabled, &lastUsedAt, &p.SuccessCount, &p.FailCount, &p.Location, &lastTestAt, &p.CreatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if lastUsedAt.Valid {
|
if lastUsedAt.Valid {
|
||||||
p.LastUsedAt = &lastUsedAt.Time
|
p.LastUsedAt = &lastUsedAt.Time
|
||||||
}
|
}
|
||||||
|
if lastTestAt.Valid {
|
||||||
|
p.LastTestAt = &lastTestAt.Time
|
||||||
|
}
|
||||||
proxies = append(proxies, p)
|
proxies = append(proxies, p)
|
||||||
}
|
}
|
||||||
return proxies, nil
|
return proxies, nil
|
||||||
@@ -897,6 +911,24 @@ func (d *DB) UpdateCodexProxyStats(proxyURL string, success bool) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateCodexProxyTestResult 更新代理测试结果
|
||||||
|
func (d *DB) UpdateCodexProxyTestResult(id int64, location string, success bool) error {
|
||||||
|
if success {
|
||||||
|
_, err := d.db.Exec(`
|
||||||
|
UPDATE codex_auth_proxies
|
||||||
|
SET location = ?, last_test_at = CURRENT_TIMESTAMP, success_count = success_count + 1
|
||||||
|
WHERE id = ?
|
||||||
|
`, location, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := d.db.Exec(`
|
||||||
|
UPDATE codex_auth_proxies
|
||||||
|
SET last_test_at = CURRENT_TIMESTAMP, fail_count = fail_count + 1
|
||||||
|
WHERE id = ?
|
||||||
|
`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// ToggleCodexProxy 切换代理启用状态
|
// ToggleCodexProxy 切换代理启用状态
|
||||||
func (d *DB) ToggleCodexProxy(id int64) error {
|
func (d *DB) ToggleCodexProxy(id int64) error {
|
||||||
_, err := d.db.Exec("UPDATE codex_auth_proxies SET is_enabled = 1 - is_enabled WHERE id = ?", id)
|
_, err := d.db.Exec("UPDATE codex_auth_proxies SET is_enabled = 1 - is_enabled WHERE id = ?", id)
|
||||||
|
|||||||
77
backend/internal/proxyutil/test_proxy.go
Normal file
77
backend/internal/proxyutil/test_proxy.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package proxyutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProxyTestResult 代理测试结果
|
||||||
|
type ProxyTestResult struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Location string `json:"location"`
|
||||||
|
IP string `json:"ip"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPApiResponse ip-api.com 响应结构
|
||||||
|
type IPApiResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Country string `json:"country"`
|
||||||
|
CountryCode string `json:"countryCode"`
|
||||||
|
Region string `json:"region"`
|
||||||
|
RegionName string `json:"regionName"`
|
||||||
|
City string `json:"city"`
|
||||||
|
Query string `json:"query"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProxy 测试代理连通性并获取归属地
|
||||||
|
func TestProxy(proxyURL string) (*ProxyTestResult, error) {
|
||||||
|
normalized, err := Normalize(proxyURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("代理地址格式错误: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析代理 URL
|
||||||
|
parsedProxy, err := url.Parse(normalized)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("解析代理错误: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建带代理的客户端
|
||||||
|
client := &http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
Proxy: http.ProxyURL(parsedProxy),
|
||||||
|
},
|
||||||
|
Timeout: 15 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 请求 IP 查询接口
|
||||||
|
resp, err := client.Get("http://ip-api.com/json/")
|
||||||
|
if err != nil {
|
||||||
|
return &ProxyTestResult{Success: false, Error: err.Error()}, nil
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return &ProxyTestResult{Success: false, Error: fmt.Sprintf("HTTP %d", resp.StatusCode)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var ipInfo IPApiResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&ipInfo); err != nil {
|
||||||
|
return &ProxyTestResult{Success: false, Error: "解析响应失败: " + err.Error()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if ipInfo.Status != "success" {
|
||||||
|
return &ProxyTestResult{Success: false, Error: "查询失败: " + ipInfo.Message}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ProxyTestResult{
|
||||||
|
Success: true,
|
||||||
|
Location: ipInfo.CountryCode,
|
||||||
|
IP: ipInfo.Query,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react'
|
|||||||
import {
|
import {
|
||||||
Globe, Plus, Trash2, ToggleLeft, ToggleRight,
|
Globe, Plus, Trash2, ToggleLeft, ToggleRight,
|
||||||
Loader2, Save, RefreshCcw, CheckCircle, XCircle,
|
Loader2, Save, RefreshCcw, CheckCircle, XCircle,
|
||||||
AlertTriangle, Clock
|
AlertTriangle, Clock, MapPin, Play
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common'
|
import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common'
|
||||||
|
|
||||||
@@ -14,6 +14,8 @@ interface CodexProxy {
|
|||||||
last_used_at: string | null
|
last_used_at: string | null
|
||||||
success_count: number
|
success_count: number
|
||||||
fail_count: number
|
fail_count: number
|
||||||
|
location: string
|
||||||
|
last_test_at: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,6 +30,7 @@ export default function CodexProxyConfig() {
|
|||||||
const [stats, setStats] = useState<ProxyStats>({ total: 0, enabled: 0, disabled: 0 })
|
const [stats, setStats] = useState<ProxyStats>({ total: 0, enabled: 0, disabled: 0 })
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [testingIds, setTestingIds] = useState<Set<number>>(new Set())
|
||||||
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null)
|
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null)
|
||||||
|
|
||||||
// 单个添加
|
// 单个添加
|
||||||
@@ -143,6 +146,37 @@ export default function CodexProxyConfig() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 测试代理
|
||||||
|
const handleTestProxy = async (id: number) => {
|
||||||
|
if (testingIds.has(id)) return
|
||||||
|
setTestingIds(prev => new Set(prev).add(id))
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/codex-proxy/test?id=${id}`, { method: 'POST' })
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.code === 0) {
|
||||||
|
// 局部更新代理信息
|
||||||
|
setProxies(prev => prev.map(p =>
|
||||||
|
p.id === id
|
||||||
|
? { ...p, location: data.data.location, last_test_at: new Date().toISOString(), success_count: p.success_count + 1 }
|
||||||
|
: p
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
alert(`测试失败: ${data.message}`)
|
||||||
|
// 虽然失败也需要刷新列表以获取最新的统计数据
|
||||||
|
fetchProxies()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('测试代理出错:', error)
|
||||||
|
alert('网络错误,测试失败')
|
||||||
|
} finally {
|
||||||
|
setTestingIds(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.delete(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 清空所有
|
// 清空所有
|
||||||
const handleClearAll = async () => {
|
const handleClearAll = async () => {
|
||||||
if (!confirm('确定要清空所有代理吗?此操作不可恢复!')) return
|
if (!confirm('确定要清空所有代理吗?此操作不可恢复!')) return
|
||||||
@@ -204,7 +238,7 @@ export default function CodexProxyConfig() {
|
|||||||
CodexAuth 代理池
|
CodexAuth 代理池
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||||
管理 CodexAuth API 授权使用的代理池,支持随机轮换
|
管理 CodexAuth API 授权使用的代理池,支持归属地自动识别
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -357,10 +391,12 @@ export default function CodexProxyConfig() {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{proxies.map((proxy) => {
|
{proxies.map((proxy) => {
|
||||||
const successRate = getSuccessRate(proxy)
|
const successRate = getSuccessRate(proxy)
|
||||||
|
const istesting = testingIds.has(proxy.id)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={proxy.id}
|
key={proxy.id}
|
||||||
className={`p-4 rounded-lg border ${proxy.is_enabled
|
className={`p-4 rounded-lg border transition-all ${proxy.is_enabled
|
||||||
? 'bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700'
|
? 'bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700'
|
||||||
: 'bg-slate-50 dark:bg-slate-800/50 border-slate-200 dark:border-slate-700 opacity-60'
|
: 'bg-slate-50 dark:bg-slate-800/50 border-slate-200 dark:border-slate-700 opacity-60'
|
||||||
}`}
|
}`}
|
||||||
@@ -371,16 +407,22 @@ export default function CodexProxyConfig() {
|
|||||||
<span className="font-mono text-sm text-slate-900 dark:text-slate-100 truncate">
|
<span className="font-mono text-sm text-slate-900 dark:text-slate-100 truncate">
|
||||||
{formatProxyDisplay(proxy.proxy_url)}
|
{formatProxyDisplay(proxy.proxy_url)}
|
||||||
</span>
|
</span>
|
||||||
|
{proxy.location && (
|
||||||
|
<span className="px-1.5 py-0.5 rounded bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 text-[10px] font-bold flex items-center gap-0.5">
|
||||||
|
<MapPin className="h-2.5 w-2.5" />
|
||||||
|
{proxy.location}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{proxy.description && (
|
{proxy.description && (
|
||||||
<span className="text-xs text-slate-500 dark:text-slate-400">
|
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
({proxy.description})
|
({proxy.description})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 mt-1 text-xs text-slate-500 dark:text-slate-400">
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 mt-1 text-xs text-slate-500 dark:text-slate-400">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1" title="最后测试时间">
|
||||||
<Clock className="h-3 w-3" />
|
<Clock className="h-3 w-3" />
|
||||||
{formatTime(proxy.last_used_at)}
|
测试: {formatTime(proxy.last_test_at)}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<CheckCircle className="h-3 w-3 text-green-500" />
|
<CheckCircle className="h-3 w-3 text-green-500" />
|
||||||
@@ -400,9 +442,21 @@ export default function CodexProxyConfig() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleTestProxy(proxy.id)}
|
||||||
|
disabled={istesting}
|
||||||
|
className={`p-1.5 hover:bg-blue-50 dark:hover:bg-blue-900/30 rounded text-blue-500 transition-colors ${istesting ? 'animate-pulse opacity-50' : ''}`}
|
||||||
|
title="立即测试"
|
||||||
|
>
|
||||||
|
{istesting ? (
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Play className="h-5 w-5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleToggle(proxy.id)}
|
onClick={() => handleToggle(proxy.id)}
|
||||||
className="p-1 hover:bg-slate-100 dark:hover:bg-slate-700 rounded"
|
className="p-1 hover:bg-slate-100 dark:hover:bg-slate-700 rounded transition-colors"
|
||||||
title={proxy.is_enabled ? '禁用' : '启用'}
|
title={proxy.is_enabled ? '禁用' : '启用'}
|
||||||
>
|
>
|
||||||
{proxy.is_enabled ? (
|
{proxy.is_enabled ? (
|
||||||
@@ -413,7 +467,7 @@ export default function CodexProxyConfig() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(proxy.id)}
|
onClick={() => handleDelete(proxy.id)}
|
||||||
className="p-1 hover:bg-red-50 dark:hover:bg-red-900/30 rounded text-red-500"
|
className="p-1 hover:bg-red-50 dark:hover:bg-red-900/30 rounded text-red-500 transition-colors"
|
||||||
title="删除"
|
title="删除"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-5 w-5" />
|
<Trash2 className="h-5 w-5" />
|
||||||
@@ -437,8 +491,8 @@ export default function CodexProxyConfig() {
|
|||||||
<li>代理池中的代理将在 CodexAuth API 授权时随机选择使用</li>
|
<li>代理池中的代理将在 CodexAuth API 授权时随机选择使用</li>
|
||||||
<li>支持 HTTP / HTTPS / SOCKS5 代理协议</li>
|
<li>支持 HTTP / HTTPS / SOCKS5 代理协议</li>
|
||||||
<li>支持带认证的代理格式:<code className="px-1 bg-slate-100 dark:bg-slate-800 rounded">http://user:pass@host:port</code></li>
|
<li>支持带认证的代理格式:<code className="px-1 bg-slate-100 dark:bg-slate-800 rounded">http://user:pass@host:port</code></li>
|
||||||
<li>系统会自动统计每个代理的成功/失败次数</li>
|
<li>点击“测试”按钮可检查联通性并识别出口归属地(使用 ip-api.com)</li>
|
||||||
<li>禁用的代理不会被选中使用</li>
|
<li>系统会自动统计每个代理的成功/失败次数,禁用的代理不会被选中</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
Reference in New Issue
Block a user