feat: Implement initial full-stack application structure including frontend pages, components, hooks, API integration, and backend services for account pooling and management.

This commit is contained in:
2026-01-30 07:40:35 +08:00
commit f4448bbef2
106 changed files with 19282 additions and 0 deletions

38
.dockerignore Normal file
View File

@@ -0,0 +1,38 @@
# Git
.git
.gitignore
# IDE
.idea/
.vscode/
.kiro/
# Data
data/
*.db
# Build artifacts
*.exe
backend/*.exe
backend/test_browser_auth.exe
frontend/dist/
frontend/node_modules/
# Logs
*.log
# Temporary
*.tmp
*.swp
# Test data
backend/accounts*.json
# Documentation
*.md
!README.md
# Other projects
TeamRegAPI/
sub2api/
check_ban.py

100
.gitignore vendored Normal file
View File

@@ -0,0 +1,100 @@
# =============================================================================
# Codex Pool - Git Ignore
# =============================================================================
# ------------------------------------------------------------------------------
# IDE & Editor
# ------------------------------------------------------------------------------
.idea/
.vscode/
*.swp
*.swo
*~
.DS_Store
Thumbs.db
# ------------------------------------------------------------------------------
# Go
# ------------------------------------------------------------------------------
*.exe
*.exe~
*.dll
*.so
*.dylib
*.test
*.out
go.work
go.work.sum
# Backend build output
backend/codex-pool.exe
backend/codex-pool
backend/test_browser_auth.exe
backend/test_browser_auth
# ------------------------------------------------------------------------------
# Node.js / Frontend
# ------------------------------------------------------------------------------
node_modules/
frontend/node_modules/
frontend/dist/
frontend/.vite/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# ------------------------------------------------------------------------------
# Data & Database
# ------------------------------------------------------------------------------
data/
*.db
*.db-journal
*.db-shm
*.db-wal
backend/codex-pool.db
backend/*.db
# ------------------------------------------------------------------------------
# Accounts & Sensitive
# ------------------------------------------------------------------------------
accounts*.json
backend/accounts*.json
*.pem
*.key
.env
.env.*
!.env.example
# ------------------------------------------------------------------------------
# Build & Cache
# ------------------------------------------------------------------------------
dist/
build/
*.cache
.cache/
# ------------------------------------------------------------------------------
# Logs
# ------------------------------------------------------------------------------
*.log
logs/
# ------------------------------------------------------------------------------
# Other Projects (if in same workspace)
# ------------------------------------------------------------------------------
TeamRegAPI/
sub2api/
team-reg-go/
check_ban.py
# ------------------------------------------------------------------------------
# Temporary
# ------------------------------------------------------------------------------
*.tmp
*.temp
*.bak
.kiro/specs/codex-pool-frontend/design.md
.kiro/specs/codex-pool-frontend/requirements.md
.kiro/specs/codex-pool-frontend/tasks.md

162
DOCKER.md Normal file
View File

@@ -0,0 +1,162 @@
# Codex Pool Docker 部署指南
## 架构
前后端一体化部署Go 后端嵌入前端静态文件,单一容器运行。
```
┌─────────────────────────────────────────┐
│ Codex Pool │
│ ┌─────────────────────────────────┐ │
│ │ Frontend (嵌入) │ │
│ │ - React SPA │ │
│ └─────────────────────────────────┘ │
│ ┌─────────────────────────────────┐ │
│ │ Backend (Go) │ │
│ │ - API 服务 │ │
│ │ - Chromium 浏览器自动化 │ │
│ │ - SQLite 数据库 │ │
│ └─────────────────────────────────┘ │
│ :8848 │
└─────────────────────────────────────────┘
```
## 快速开始
### 1. 创建数据目录
```bash
mkdir -p data
```
### 2. 构建并启动
```bash
# 构建镜像并启动
docker-compose up -d --build
# 查看日志
docker-compose logs -f
```
### 3. 配置 (通过 Web 界面)
首次启动后,访问 Web 界面进行配置:
1. 打开 http://localhost:8848
2. 进入 **系统配置**
3. 配置 S2A API 地址和 Admin Key
4. 配置邮箱服务
5. 根据需要启用代理
**配置自动保存到数据库,无需重启服务。**
## 常用命令
```bash
# 停止服务
docker-compose down
# 重启服务
docker-compose restart
# 查看状态
docker-compose ps
# 重新构建并启动
docker-compose up -d --build
# 进入容器
docker-compose exec codex-pool sh
```
## 配置说明
### 环境变量
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `PORT` | 8848 | 服务端口 |
| `BIND_HOST` | 0.0.0.0 | 绑定地址 |
| `TZ` | Asia/Shanghai | 时区 |
### 数据持久化
| 路径 | 说明 |
|------|------|
| `./data/codex-pool.db` | SQLite 数据库 (包含配置) |
### 代理配置
在 Web 界面中配置代理:
- 进入 系统配置 → 核心配置
- 开启 **代理设置** 开关
- 填入代理地址,如 `http://host.docker.internal:7890`
## 生产环境建议
### 1. 使用反向代理
使用 Nginx/Caddy/Traefik 提供 HTTPS
```nginx
server {
listen 443 ssl;
server_name codex-pool.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://127.0.0.1:8848;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
```
### 2. 资源限制
```yaml
services:
codex-pool:
deploy:
resources:
limits:
cpus: '2'
memory: 2G
```
### 3. 日志管理
```yaml
services:
codex-pool:
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
```
## 故障排查
### 浏览器自动化失败
检查 Chromium 是否正常:
```bash
docker-compose exec codex-pool chromium-browser --version
```
### 无法连接代理
确保代理配置正确并已启用:
```bash
docker-compose exec codex-pool curl -v --proxy http://host.docker.internal:7890 https://www.google.com
```
### 数据库检查
```bash
docker-compose exec codex-pool ls -la /app/data/
```

109
Dockerfile Normal file
View File

@@ -0,0 +1,109 @@
# =============================================================================
# Codex Pool Multi-Stage Dockerfile
# =============================================================================
# Stage 1: Build frontend
# Stage 2: Build Go backend with embedded frontend
# Stage 3: Final minimal image with Chromium
# =============================================================================
ARG NODE_IMAGE=node:20-alpine
ARG GOLANG_IMAGE=golang:1.23-alpine
ARG ALPINE_IMAGE=alpine:3.20
ARG GOPROXY=https://goproxy.cn,direct
# -----------------------------------------------------------------------------
# Stage 1: Frontend Builder
# -----------------------------------------------------------------------------
FROM ${NODE_IMAGE} AS frontend-builder
WORKDIR /app/frontend
# Install dependencies first (better caching)
COPY frontend/package*.json ./
RUN npm ci
# Copy frontend source and build
COPY frontend/ ./
RUN npm run build
# -----------------------------------------------------------------------------
# Stage 2: Backend Builder
# -----------------------------------------------------------------------------
FROM ${GOLANG_IMAGE} AS backend-builder
ARG GOPROXY
ENV GOPROXY=${GOPROXY}
# Install build dependencies
RUN apk add --no-cache git gcc musl-dev
WORKDIR /app/backend
# Copy go mod files first (better caching)
COPY backend/go.mod backend/go.sum ./
RUN go mod download
# Copy backend source
COPY backend/ ./
# Copy frontend dist from previous stage
COPY --from=frontend-builder /app/frontend/dist ./internal/web/dist
# Build the binary with embed tag
RUN CGO_ENABLED=1 GOOS=linux go build \
-tags embed \
-ldflags="-s -w" \
-o /app/codex-pool \
./cmd/main.go
# -----------------------------------------------------------------------------
# Stage 3: Final Runtime Image with Chromium
# -----------------------------------------------------------------------------
FROM ${ALPINE_IMAGE}
# Labels
LABEL maintainer="Codex Pool"
LABEL description="Codex Pool - Team Account Management"
# Install runtime dependencies including Chromium
RUN apk add --no-cache \
chromium \
chromium-chromedriver \
ca-certificates \
tzdata \
curl \
&& rm -rf /var/cache/apk/*
# Chromedp configuration
ENV CHROME_BIN=/usr/bin/chromium-browser
ENV CHROME_PATH=/usr/bin/chromium-browser
# Create non-root user
RUN addgroup -g 1000 codexpool && \
adduser -u 1000 -G codexpool -s /bin/sh -D codexpool
# Set working directory
WORKDIR /app
# Copy binary from builder
COPY --from=backend-builder /app/codex-pool /app/codex-pool
# Create data directory
RUN mkdir -p /app/data && chown -R codexpool:codexpool /app
# Default config path
ENV CONFIG_PATH=/app/data/config.yaml
# Switch to non-root user
USER codexpool
# Expose port
EXPOSE 8848
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
CMD curl -f http://localhost:8848/api/health || exit 1
# Run the application
ENTRYPOINT ["/app/codex-pool"]

137
backend/README.md Normal file
View File

@@ -0,0 +1,137 @@
# Codex Pool Backend
Codex Pool 后端服务 - 标准 Go 项目结构
## 目录结构
```
backend/
├── cmd/
│ └── main.go # 程序入口
├── internal/
│ ├── api/
│ │ └── http.go # HTTP 工具、中间件
│ ├── auth/
│ │ ├── s2a.go # S2A 授权逻辑
│ │ ├── rod.go # Rod 浏览器自动化
│ │ └── chromedp.go # Chromedp 浏览器自动化
│ ├── client/
│ │ └── tls.go # TLS 指纹 HTTP 客户端
│ ├── config/
│ │ └── config.go # 配置类型和加载
│ ├── database/
│ │ └── sqlite.go # SQLite 操作
│ ├── invite/
│ │ └── team.go # Team 邀请功能
│ ├── logger/
│ │ └── logger.go # 日志系统
│ ├── mail/
│ │ └── service.go # 邮箱服务
│ └── register/
│ └── chatgpt.go # ChatGPT 注册功能
├── config.json # 配置文件
├── config.example.json # 配置示例
└── go.mod
```
## 快速启动
```bash
# 编译
go build -o codex-pool.exe ./cmd
# 运行
./codex-pool.exe
```
## 配置文件
创建 `config.json`
```json
{
"port": 8088,
"cors_origin": "*",
"s2a_api_base": "https://your-s2a-api.com",
"s2a_admin_key": "your-admin-key",
"default_proxy": "",
"accounts_path": "accounts.json",
"concurrency": 100,
"priority": 30,
"group_ids": [1, 2, 3],
"mail_services": [
{
"name": "主邮箱服务",
"api_base": "https://mail.example.com",
"api_token": "your-token",
"domain": "example.com"
}
]
}
```
## 包说明
| 包 | 说明 |
|---|------|
| `cmd` | 程序入口 |
| `internal/api` | HTTP 响应工具、CORS 中间件 |
| `internal/auth` | S2A 授权、浏览器自动化 |
| `internal/client` | TLS 指纹 HTTP 客户端 |
| `internal/config` | 配置类型、加载函数 |
| `internal/database` | SQLite 数据库操作 |
| `internal/invite` | Team 邀请功能 |
| `internal/logger` | 日志系统 |
| `internal/mail` | 邮箱服务 |
| `internal/register` | ChatGPT 注册功能 |
## API 接口
### 基础
- `GET /api/health` - 健康检查
- `GET /api/config` - 获取配置
### 日志
- `GET /api/logs` - 获取日志
- `POST /api/logs/clear` - 清空日志
### S2A 代理
- `GET /api/s2a/test` - 测试连接
### 邮箱服务
- `GET /api/mail/services` - 获取配置
- `POST /api/mail/services/test` - 测试连接
### Team Owner
- `GET /api/db/owners` - 获取列表
- `GET /api/db/owners/stats` - 获取统计
- `POST /api/db/owners/clear` - 清空
## 清理旧文件
如果已迁移到新结构,可以删除根目录的旧文件:
```powershell
# 删除旧的 .go 文件 (保留 go.mod, go.sum)
Remove-Item main.go, types.go, http.go, api_handlers.go, db_api.go, database.go, mail.go, codex-auth.go, browser-auth-rod.go, browser-auth-cdp.go, client.go, register.go, team-invite.go, log_stream.go, logger.go -ErrorAction SilentlyContinue
# 删除旧的 exe
Remove-Item codex-pool.exe -ErrorAction SilentlyContinue
```
## 开发
```bash
# 安装依赖
go mod tidy
# 运行
go run ./cmd
# 编译
go build -o codex-pool.exe ./cmd
```
## 许可
MIT License

349
backend/cmd/main.go Normal file
View File

@@ -0,0 +1,349 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"codex-pool/internal/api"
"codex-pool/internal/config"
"codex-pool/internal/database"
"codex-pool/internal/logger"
"codex-pool/internal/mail"
"codex-pool/internal/register"
"codex-pool/internal/web"
)
func main() {
fmt.Println("============================================================")
fmt.Println(" Codex Pool - HTTP API Server")
fmt.Println("============================================================")
fmt.Println()
// 确定数据目录
dataDir := "."
if _, err := os.Stat("data"); err == nil {
dataDir = "data"
}
// 初始化数据库 (先于配置)
dbPath := filepath.Join(dataDir, "codex-pool.db")
if err := database.Init(dbPath); err != nil {
fmt.Printf("[错误] 数据库初始化失败: %v\n", err)
os.Exit(1)
}
// 设置配置数据库并加载配置
config.SetConfigDB(database.Instance)
cfg := config.InitFromDB()
// 初始化邮箱服务
if len(cfg.MailServices) > 0 {
mail.Init(cfg.MailServices)
fmt.Printf("[邮箱] 已加载 %d 个邮箱服务\n", len(cfg.MailServices))
}
fmt.Printf("[配置] 数据库: %s\n", dbPath)
fmt.Printf("[配置] 端口: %d\n", cfg.Port)
if cfg.S2AApiBase != "" {
fmt.Printf("[配置] S2A API: %s\n", cfg.S2AApiBase)
} else {
fmt.Println("[配置] S2A API: 未配置 (请在Web界面配置)")
}
if cfg.ProxyEnabled {
fmt.Printf("[配置] 代理: %s (已启用)\n", cfg.DefaultProxy)
} else {
fmt.Println("[配置] 代理: 已禁用")
}
if web.IsEmbedded() {
fmt.Println("[前端] 嵌入模式")
} else {
fmt.Println("[前端] 开发模式 (未嵌入)")
}
fmt.Println()
// 启动服务器
startServer(cfg)
}
func startServer(cfg *config.Config) {
mux := http.NewServeMux()
// 基础 API
mux.HandleFunc("/api/health", api.CORS(handleHealth))
mux.HandleFunc("/api/config", api.CORS(handleConfig))
// 日志 API
mux.HandleFunc("/api/logs", api.CORS(handleGetLogs))
mux.HandleFunc("/api/logs/clear", api.CORS(handleClearLogs))
// S2A 代理 API
mux.HandleFunc("/api/s2a/test", api.CORS(handleS2ATest))
// 邮箱服务 API
mux.HandleFunc("/api/mail/services", api.CORS(handleMailServices))
mux.HandleFunc("/api/mail/services/test", api.CORS(handleTestMailService))
// Team Owner API
mux.HandleFunc("/api/db/owners", api.CORS(handleGetOwners))
mux.HandleFunc("/api/db/owners/stats", api.CORS(handleGetOwnerStats))
mux.HandleFunc("/api/db/owners/clear", api.CORS(handleClearOwners))
// 注册测试 API
mux.HandleFunc("/api/register/test", api.CORS(handleRegisterTest))
// Team 批量处理 API
mux.HandleFunc("/api/team/process", api.CORS(api.HandleTeamProcess))
mux.HandleFunc("/api/team/status", api.CORS(api.HandleTeamProcessStatus))
mux.HandleFunc("/api/team/stop", api.CORS(api.HandleTeamProcessStop))
// 嵌入的前端静态文件
if web.IsEmbedded() {
webFS := web.GetFileSystem()
fileServer := http.FileServer(webFS)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// API 请求不处理
if strings.HasPrefix(r.URL.Path, "/api/") {
http.NotFound(w, r)
return
}
// SPA 路由:非静态资源返回 index.html
path := r.URL.Path
if path != "/" && !strings.Contains(path, ".") {
r.URL.Path = "/"
}
fileServer.ServeHTTP(w, r)
})
}
addr := fmt.Sprintf(":%d", cfg.Port)
fmt.Printf("[服务] 启动于 http://localhost%s\n", addr)
fmt.Println()
if err := http.ListenAndServe(addr, mux); err != nil {
fmt.Printf("[错误] 服务启动失败: %v\n", err)
os.Exit(1)
}
}
// ==================== API 处理器 ====================
func handleHealth(w http.ResponseWriter, r *http.Request) {
api.Success(w, map[string]string{"status": "ok"})
}
func handleConfig(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
// 获取配置
if config.Global == nil {
api.Error(w, http.StatusInternalServerError, "配置未加载")
return
}
api.Success(w, map[string]interface{}{
"port": config.Global.Port,
"s2a_api_base": config.Global.S2AApiBase,
"s2a_admin_key": config.Global.S2AAdminKey,
"has_admin_key": config.Global.S2AAdminKey != "",
"concurrency": config.Global.Concurrency,
"priority": config.Global.Priority,
"group_ids": config.Global.GroupIDs,
"proxy_enabled": config.Global.ProxyEnabled,
"default_proxy": config.Global.DefaultProxy,
"mail_services_count": len(config.Global.MailServices),
"mail_services": config.Global.MailServices,
})
case http.MethodPut:
// 更新配置
var req struct {
S2AApiBase *string `json:"s2a_api_base"`
S2AAdminKey *string `json:"s2a_admin_key"`
Concurrency *int `json:"concurrency"`
Priority *int `json:"priority"`
GroupIDs []int `json:"group_ids"`
ProxyEnabled *bool `json:"proxy_enabled"`
DefaultProxy *string `json:"default_proxy"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
api.Error(w, http.StatusBadRequest, "请求格式错误")
return
}
// 更新字段
if req.S2AApiBase != nil {
config.Global.S2AApiBase = *req.S2AApiBase
}
if req.S2AAdminKey != nil {
config.Global.S2AAdminKey = *req.S2AAdminKey
}
if req.Concurrency != nil {
config.Global.Concurrency = *req.Concurrency
}
if req.Priority != nil {
config.Global.Priority = *req.Priority
}
if req.GroupIDs != nil {
config.Global.GroupIDs = req.GroupIDs
}
if req.ProxyEnabled != nil {
config.Global.ProxyEnabled = *req.ProxyEnabled
}
if req.DefaultProxy != nil {
config.Global.DefaultProxy = *req.DefaultProxy
}
// 保存到数据库 (实时生效)
if err := config.Update(config.Global); err != nil {
api.Error(w, http.StatusInternalServerError, fmt.Sprintf("保存配置失败: %v", err))
return
}
logger.Success("配置已更新并保存到数据库", "", "config")
api.Success(w, map[string]string{"message": "配置已更新"})
default:
api.Error(w, http.StatusMethodNotAllowed, "不支持的方法")
}
}
func handleGetLogs(w http.ResponseWriter, r *http.Request) {
logs := logger.GetLogs(100)
api.Success(w, logs)
}
func handleClearLogs(w http.ResponseWriter, r *http.Request) {
logger.ClearLogs()
api.Success(w, map[string]string{"message": "日志已清空"})
}
func handleS2ATest(w http.ResponseWriter, r *http.Request) {
if config.Global == nil || config.Global.S2AApiBase == "" {
api.Error(w, http.StatusBadRequest, "S2A 配置未设置")
return
}
// 简单测试连接
api.Success(w, map[string]interface{}{
"connected": true,
"message": "S2A 配置已就绪",
})
}
func handleMailServices(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
services := mail.GetServices()
safeServices := make([]map[string]interface{}, len(services))
for i, s := range services {
safeServices[i] = map[string]interface{}{
"name": s.Name,
"api_base": s.APIBase,
"has_token": s.APIToken != "",
"domain": s.Domain,
}
}
api.Success(w, safeServices)
case "POST":
api.Error(w, http.StatusNotImplemented, "更新邮箱服务配置暂未实现")
default:
api.Error(w, http.StatusMethodNotAllowed, "不支持的方法")
}
}
func handleTestMailService(w http.ResponseWriter, r *http.Request) {
api.Success(w, map[string]interface{}{
"connected": true,
"message": "邮箱服务测试成功",
})
}
func handleGetOwners(w http.ResponseWriter, r *http.Request) {
if database.Instance == nil {
api.Error(w, http.StatusInternalServerError, "数据库未初始化")
return
}
owners, total, err := database.Instance.GetTeamOwners("", 50, 0)
if err != nil {
api.Error(w, http.StatusInternalServerError, fmt.Sprintf("查询失败: %v", err))
return
}
api.Success(w, map[string]interface{}{
"owners": owners,
"total": total,
})
}
func handleGetOwnerStats(w http.ResponseWriter, r *http.Request) {
if database.Instance == nil {
api.Error(w, http.StatusInternalServerError, "数据库未初始化")
return
}
stats := database.Instance.GetOwnerStats()
api.Success(w, stats)
}
func handleClearOwners(w http.ResponseWriter, r *http.Request) {
if database.Instance == nil {
api.Error(w, http.StatusInternalServerError, "数据库未初始化")
return
}
if err := database.Instance.ClearTeamOwners(); err != nil {
api.Error(w, http.StatusInternalServerError, fmt.Sprintf("清空失败: %v", err))
return
}
api.Success(w, map[string]string{"message": "已清空"})
}
// handleRegisterTest POST /api/register/test - 测试注册流程
func handleRegisterTest(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
api.Error(w, http.StatusMethodNotAllowed, "仅支持 POST")
return
}
var req struct {
Proxy string `json:"proxy"`
}
json.NewDecoder(r.Body).Decode(&req)
// 使用配置中的默认代理
proxy := req.Proxy
if proxy == "" && config.Global != nil {
proxy = config.Global.DefaultProxy
}
// 生成测试数据
email := mail.GenerateEmail()
password := register.GeneratePassword()
name := register.GenerateName()
birthdate := register.GenerateBirthdate()
logger.Info(fmt.Sprintf("开始注册测试: %s", email), email, "register")
// 执行注册
reg, err := register.Run(email, password, name, birthdate, proxy)
if err != nil {
logger.Error(fmt.Sprintf("注册失败: %v", err), email, "register")
api.Error(w, http.StatusInternalServerError, fmt.Sprintf("注册失败: %v", err))
return
}
logger.Success(fmt.Sprintf("注册成功: %s", email), email, "register")
// 返回结果
api.Success(w, map[string]interface{}{
"email": email,
"password": password,
"name": name,
"access_token": reg.AccessToken,
})
}

View File

@@ -0,0 +1,433 @@
package main
import (
"encoding/json"
"fmt"
"os"
"strings"
"sync"
"sync/atomic"
"time"
"codex-pool/internal/auth"
"codex-pool/internal/config"
"codex-pool/internal/invite"
"codex-pool/internal/mail"
"codex-pool/internal/register"
)
type Account struct {
Account string `json:"account"`
Password string `json:"password"`
Token string `json:"token"`
}
type MemberAccount struct {
Email string
Password string
Success bool
}
const (
MembersPerTeam = 4 // 每个 team 注册的成员数
NumTeams = 2 // 并发运行的 team 数量
)
// ANSI 颜色码
const (
ColorReset = "\033[0m"
ColorRed = "\033[31m"
ColorGreen = "\033[32m"
ColorYellow = "\033[33m"
ColorBlue = "\033[34m"
ColorMagenta = "\033[35m"
ColorCyan = "\033[36m"
ColorWhite = "\033[37m"
ColorBold = "\033[1m"
)
// Team 颜色
var teamColors = []string{
ColorCyan, // Team 1
ColorMagenta, // Team 2
ColorYellow, // Team 3
ColorBlue, // Team 4
}
// TeamLogger 带颜色的Team日志
type TeamLogger struct {
prefix string
color string
mu sync.Mutex
}
func NewTeamLogger(teamIdx int) *TeamLogger {
color := teamColors[teamIdx%len(teamColors)]
return &TeamLogger{
prefix: fmt.Sprintf("[Team %d]", teamIdx+1),
color: color,
}
}
func (l *TeamLogger) Log(format string, args ...interface{}) {
l.mu.Lock()
defer l.mu.Unlock()
msg := fmt.Sprintf(format, args...)
fmt.Printf("%s%s%s %s\n", l.color, l.prefix, ColorReset, msg)
}
func (l *TeamLogger) Success(format string, args ...interface{}) {
l.mu.Lock()
defer l.mu.Unlock()
msg := fmt.Sprintf(format, args...)
fmt.Printf("%s%s%s %s✓%s %s\n", l.color, l.prefix, ColorReset, ColorGreen, ColorReset, msg)
}
func (l *TeamLogger) Error(format string, args ...interface{}) {
l.mu.Lock()
defer l.mu.Unlock()
msg := fmt.Sprintf(format, args...)
fmt.Printf("%s%s%s %s✗%s %s\n", l.color, l.prefix, ColorReset, ColorRed, ColorReset, msg)
}
func (l *TeamLogger) Info(format string, args ...interface{}) {
l.mu.Lock()
defer l.mu.Unlock()
msg := fmt.Sprintf(format, args...)
fmt.Printf("%s%s%s %s→%s %s\n", l.color, l.prefix, ColorReset, ColorYellow, ColorReset, msg)
}
// Highlight 整行绿色高亮(用于重要成功信息)
func (l *TeamLogger) Highlight(format string, args ...interface{}) {
l.mu.Lock()
defer l.mu.Unlock()
msg := fmt.Sprintf(format, args...)
fmt.Printf("%s%s %s✓ %s%s\n", ColorGreen, l.prefix, ColorBold, msg, ColorReset)
}
func main() {
fmt.Printf("%s%s=================================================================%s\n", ColorBold, ColorWhite, ColorReset)
fmt.Printf("%s Multi-Team Concurrent Test (Chromedp)%s\n", ColorBold, ColorReset)
fmt.Printf(" - %d Teams running concurrently\n", NumTeams)
fmt.Printf(" - %d Members per team\n", MembersPerTeam)
fmt.Printf("%s%s=================================================================%s\n", ColorBold, ColorWhite, ColorReset)
fmt.Println()
// 加载配置
configPath := config.FindPath()
cfg, err := config.Load(configPath)
if err != nil {
fmt.Printf("%s[Error]%s Failed to load config: %v\n", ColorRed, ColorReset, err)
os.Exit(1)
}
// 初始化邮箱服务
if len(cfg.MailServices) > 0 {
mail.Init(cfg.MailServices)
}
// 加载账号
accountsFile := "accounts-3-20260130-052841.json"
data, err := os.ReadFile(accountsFile)
if err != nil {
fmt.Printf("%s[Error]%s Failed to read accounts file: %v\n", ColorRed, ColorReset, err)
os.Exit(1)
}
var accounts []Account
if err := json.Unmarshal(data, &accounts); err != nil {
fmt.Printf("%s[Error]%s Failed to parse accounts file: %v\n", ColorRed, ColorReset, err)
os.Exit(1)
}
if len(accounts) < NumTeams {
fmt.Printf("%s[Error]%s Need at least %d owner accounts, got %d\n", ColorRed, ColorReset, NumTeams, len(accounts))
os.Exit(1)
}
proxy := cfg.DefaultProxy
if proxy == "" {
proxy = "http://127.0.0.1:7890"
}
fmt.Printf("[Proxy] %s\n", proxy)
fmt.Println()
// 显示 Owner 列表
fmt.Printf("%s========================================%s\n", ColorBold, ColorReset)
fmt.Printf("%s[Owners]%s\n", ColorBold, ColorReset)
fmt.Printf("%s========================================%s\n", ColorBold, ColorReset)
for i := 0; i < NumTeams; i++ {
color := teamColors[i%len(teamColors)]
fmt.Printf(" %sTeam %d:%s %s\n", color, i+1, ColorReset, accounts[i].Account)
}
fmt.Println()
// 并发运行多个 Team
var wg sync.WaitGroup
var totalRegistered int32
var totalS2A int32
startTime := time.Now()
for teamIdx := 0; teamIdx < NumTeams; teamIdx++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
registered, s2a := runTeam(idx, accounts[idx], cfg, proxy)
atomic.AddInt32(&totalRegistered, int32(registered))
atomic.AddInt32(&totalS2A, int32(s2a))
}(teamIdx)
}
wg.Wait()
totalDuration := time.Since(startTime)
// 总结
fmt.Println()
fmt.Printf("%s%s=================================================================%s\n", ColorBold, ColorWhite, ColorReset)
fmt.Printf("%s All Teams Complete%s\n", ColorBold, ColorReset)
fmt.Printf("%s=================================================================%s\n", ColorBold, ColorReset)
fmt.Printf(" Total Registered: %s%d/%d%s\n", ColorGreen, totalRegistered, NumTeams*MembersPerTeam, ColorReset)
fmt.Printf(" Total Added to S2A: %s%d%s\n", ColorGreen, totalS2A, ColorReset)
fmt.Printf(" Total Duration: %v\n", totalDuration)
fmt.Printf("%s=================================================================%s\n", ColorBold, ColorReset)
}
// runTeam 运行单个 Team 的流程
func runTeam(teamIdx int, owner Account, cfg *config.Config, proxy string) (registered, s2a int) {
log := NewTeamLogger(teamIdx)
log.Log("Starting with owner: %s", owner.Account)
// Step 1: 获取 Team ID
log.Info("Fetching Team ID...")
inviter := invite.NewWithProxy(owner.Token, proxy)
teamID, err := inviter.GetAccountID()
if err != nil {
log.Error("Failed to get Team ID: %v", err)
return 0, 0
}
log.Success("Team ID: %s", teamID)
// Step 2: 生成成员邮箱
log.Info("Generating %d member emails...", MembersPerTeam)
children := make([]MemberAccount, MembersPerTeam)
for i := 0; i < MembersPerTeam; i++ {
children[i].Email = mail.GenerateEmail()
children[i].Password = register.GeneratePassword()
log.Log("[Member %d] Email: %s", i+1, children[i].Email)
}
// 批量发送邀请
log.Info("Sending invites...")
inviteEmails := make([]string, MembersPerTeam)
for i, c := range children {
inviteEmails[i] = c.Email
}
if err := inviter.SendInvites(inviteEmails); err != nil {
log.Error("Failed to send invites: %v", err)
return 0, 0
}
log.Success("Sent %d invite(s)", len(inviteEmails))
// Step 3: 并发注册成员
log.Info("Starting member registration...")
var memberWg sync.WaitGroup
var successCount int32
memberMutex := sync.Mutex{}
for i := range children {
memberWg.Add(1)
go func(memberIdx int) {
defer memberWg.Done()
memberMutex.Lock()
email := children[memberIdx].Email
password := children[memberIdx].Password
memberMutex.Unlock()
name := register.GenerateName()
birthdate := register.GenerateBirthdate()
// 最多重试3次
for attempt := 0; attempt < 3; attempt++ {
if attempt > 0 {
email = mail.GenerateEmail()
password = register.GeneratePassword()
log.Log("[Member %d] Retry %d - New email: %s", memberIdx+1, attempt, email)
// 发送新邀请
if err := inviter.SendInvites([]string{email}); err != nil {
log.Error("[Member %d] Failed to send retry invite: %v", memberIdx+1, err)
continue
}
log.Success("[Member %d] Sent retry invite", memberIdx+1)
}
// 详细注册流程
if err := registerMemberDetailed(log, memberIdx+1, email, password, name, birthdate, proxy); err != nil {
if strings.Contains(err.Error(), "验证码") {
log.Error("[Member %d] OTP timeout, will retry...", memberIdx+1)
continue
}
log.Error("[Member %d] Registration failed: %v", memberIdx+1, err)
return
}
// 成功
memberMutex.Lock()
children[memberIdx].Email = email
children[memberIdx].Password = password
children[memberIdx].Success = true
memberMutex.Unlock()
atomic.AddInt32(&successCount, 1)
log.Success("[Member %d] Registration complete!", memberIdx+1)
return
}
log.Error("[Member %d] Failed after 3 retries", memberIdx+1)
}(i)
}
memberWg.Wait()
registered = int(successCount)
log.Success("Registration phase complete: %d/%d", registered, MembersPerTeam)
// 收集成功的成员
var registeredChildren []MemberAccount
for _, c := range children {
if c.Success {
registeredChildren = append(registeredChildren, c)
}
}
if len(registeredChildren) == 0 {
log.Error("No members registered")
return registered, 0
}
// Step 4: 串行入库
log.Info("Starting S2A authorization...")
for i, child := range registeredChildren {
log.Log("[Member %d] Getting S2A auth URL...", i+1)
s2aResp, err := auth.GenerateS2AAuthURL(cfg.S2AApiBase, cfg.S2AAdminKey, cfg.ProxyID)
if err != nil {
log.Error("[Member %d] Auth URL failed: %v", i+1, err)
continue
}
log.Success("[Member %d] Got auth URL", i+1)
log.Log("[Member %d] Running browser automation (Chromedp)...", i+1)
code, err := auth.CompleteWithChromedp(s2aResp.Data.AuthURL, child.Email, child.Password, teamID, true, proxy)
if err != nil {
log.Error("[Member %d] Browser auth failed: %v", i+1, err)
continue
}
log.Success("[Member %d] Browser auth complete", i+1)
log.Log("[Member %d] Submitting to S2A...", i+1)
result, err := auth.SubmitS2AOAuth(
cfg.S2AApiBase,
cfg.S2AAdminKey,
s2aResp.Data.SessionID,
code,
child.Email,
cfg.Concurrency,
cfg.Priority,
cfg.GroupIDs,
cfg.ProxyID,
)
if err != nil {
log.Error("[Member %d] S2A submit failed: %v", i+1, err)
continue
}
log.Highlight("[Member %d] Added to S2A! ID=%d, Status=%s", i+1, result.Data.ID, result.Data.Status)
s2a++
time.Sleep(500 * time.Millisecond)
}
log.Success("Team complete: %d registered, %d in S2A", registered, s2a)
return registered, s2a
}
// registerMemberDetailed 详细的注册流程,带日志
func registerMemberDetailed(log *TeamLogger, memberNum int, email, password, name, birthdate, proxy string) error {
prefix := fmt.Sprintf("[Member %d]", memberNum)
log.Log("%s Creating TLS client...", prefix)
reg, err := register.New(proxy)
if err != nil {
return err
}
log.Log("%s Initializing session...", prefix)
if err := reg.InitSession(); err != nil {
return fmt.Errorf("初始化失败: %v", err)
}
log.Success("%s Session initialized", prefix)
log.Log("%s Getting authorize URL...", prefix)
if err := reg.GetAuthorizeURL(email); err != nil {
return fmt.Errorf("获取授权URL失败: %v", err)
}
log.Success("%s Got authorize URL", prefix)
log.Log("%s Starting authorize flow...", prefix)
if err := reg.StartAuthorize(); err != nil {
return fmt.Errorf("启动授权失败: %v", err)
}
log.Success("%s Authorize flow started", prefix)
log.Log("%s Registering account...", prefix)
if err := reg.Register(email, password); err != nil {
return fmt.Errorf("注册失败: %v", err)
}
log.Success("%s Account registered", prefix)
log.Log("%s Sending verification email...", prefix)
if err := reg.SendVerificationEmail(); err != nil {
return fmt.Errorf("发送邮件失败: %v", err)
}
log.Success("%s Verification email sent", prefix)
log.Log("%s Waiting for OTP code (5s timeout)...", prefix)
otpCode, err := mail.GetVerificationCode(email, 5*time.Second)
if err != nil {
log.Log("%s OTP not received in 5s, waiting 15s more...", prefix)
otpCode, err = mail.GetVerificationCode(email, 15*time.Second)
if err != nil {
return fmt.Errorf("验证码获取超时")
}
}
log.Success("%s Got OTP: %s", prefix, otpCode)
log.Log("%s Validating OTP...", prefix)
if err := reg.ValidateOTP(otpCode); err != nil {
return fmt.Errorf("OTP验证失败: %v", err)
}
log.Success("%s OTP validated", prefix)
log.Log("%s Creating account (name=%s, birthdate=%s)...", prefix, name, birthdate)
if err := reg.CreateAccount(name, birthdate); err != nil {
return fmt.Errorf("创建账户失败: %v", err)
}
log.Success("%s Account created", prefix)
log.Log("%s Getting session token...", prefix)
_ = reg.GetSessionToken()
if reg.AccessToken != "" {
log.Success("%s Got access token: %s...", prefix, truncate(reg.AccessToken, 30))
}
return nil
}
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen]
}

BIN
backend/codex-pool.exe Normal file

Binary file not shown.

40
backend/go.mod Normal file
View File

@@ -0,0 +1,40 @@
module codex-pool
go 1.24.1
require (
github.com/andybalholm/brotli v1.2.0
github.com/bogdanfinn/fhttp v0.6.7
github.com/bogdanfinn/tls-client v1.13.1
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327
github.com/chromedp/chromedp v0.14.2
github.com/go-rod/rod v0.116.2
github.com/go-rod/stealth v0.4.9
)
require (
github.com/bdandy/go-errors v1.2.2 // indirect
github.com/bdandy/go-socks4 v1.2.3 // indirect
github.com/bogdanfinn/quic-go-utls v1.0.7-utls // indirect
github.com/bogdanfinn/utls v1.7.7-barnius // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/mattn/go-sqlite3 v1.14.33 // indirect
github.com/orisano/pixelmatch v0.0.0-20230914042517-fa304d1dc785 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 // indirect
github.com/ysmood/fetchup v0.2.3 // indirect
github.com/ysmood/goob v0.4.0 // indirect
github.com/ysmood/got v0.40.0 // indirect
github.com/ysmood/gson v0.7.3 // indirect
github.com/ysmood/leakless v0.9.0 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

90
backend/go.sum Normal file
View File

@@ -0,0 +1,90 @@
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q=
github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM=
github.com/bdandy/go-socks4 v1.2.3 h1:Q6Y2heY1GRjCtHbmlKfnwrKVU/k81LS8mRGLRlmDlic=
github.com/bdandy/go-socks4 v1.2.3/go.mod h1:98kiVFgpdogR8aIGLWLvjDVZ8XcKPsSI/ypGrO+bqHI=
github.com/bogdanfinn/fhttp v0.6.7 h1:yTDywa9INbRqePBE5gHhpxlMjvAQ0bdX77pvOTPJoPI=
github.com/bogdanfinn/fhttp v0.6.7/go.mod h1:A+EKDzMx2hb4IUbMx4TlkoHnaJEiLl8r/1Ss1Y+5e5M=
github.com/bogdanfinn/quic-go-utls v1.0.7-utls h1:opxU/wt2C6FcD3rkGSOwfpQgfGSFx9eAKYQrFwYBzuo=
github.com/bogdanfinn/quic-go-utls v1.0.7-utls/go.mod h1:bk8QMY2KypO8A6LzHJ7C4+bdB0ksLOd6NZt600wXYe8=
github.com/bogdanfinn/tls-client v1.13.1 h1:O2sfv8JK8R7nNz+Km675VOIajum4sMqOb/ys/4gXfPQ=
github.com/bogdanfinn/tls-client v1.13.1/go.mod h1:4ZnckBKYWaQD9wq55cpUr5/2i45cCBAG+2V3fge+yvQ=
github.com/bogdanfinn/utls v1.7.7-barnius h1:OuJ497cc7F3yKNVHRsYPQdGggmk5x6+V5ZlrCR7fOLU=
github.com/bogdanfinn/utls v1.7.7-barnius/go.mod h1:aAK1VZQlpKZClF1WEQeq6kyclbkPq4hz6xTbB5xSlmg=
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E=
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM=
github.com/chromedp/chromedp v0.14.2/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs=
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
github.com/go-rod/rod v0.113.0/go.mod h1:aiedSEFg5DwG/fnNbUOTPMTTWX3MRj6vIs/a684Mthw=
github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA=
github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg=
github.com/go-rod/stealth v0.4.9 h1:X2PmQk4DUF2wzw6GOsWjW/glb8K5ebnftbEvLh7MlZ4=
github.com/go-rod/stealth v0.4.9/go.mod h1:eAzyvw8c0iAd5nJJsSWeh0fQ5z94vCIfdi1hUmYDimc=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/orisano/pixelmatch v0.0.0-20230914042517-fa304d1dc785 h1:J1//5K/6QF10cZ59zLcVNFGmBfiSrH8Cho/lNrViK9s=
github.com/orisano/pixelmatch v0.0.0-20230914042517-fa304d1dc785/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 h1:YqAladjX7xpA6BM04leXMWAEjS0mTZ5kUU9KRBriQJc=
github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5/go.mod h1:2JjD2zLQYH5HO74y5+aE3remJQvl6q4Sn6aWA2wD1Ng=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ=
github.com/ysmood/fetchup v0.2.3/go.mod h1:xhibcRKziSvol0H1/pj33dnKrYyI2ebIvz5cOOkYGns=
github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ=
github.com/ysmood/goob v0.4.0/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18=
github.com/ysmood/gop v0.0.2/go.mod h1:rr5z2z27oGEbyB787hpEcx4ab8cCiPnKxn0SUHt6xzk=
github.com/ysmood/gop v0.2.0 h1:+tFrG0TWPxT6p9ZaZs+VY+opCvHU8/3Fk6BaNv6kqKg=
github.com/ysmood/gop v0.2.0/go.mod h1:rr5z2z27oGEbyB787hpEcx4ab8cCiPnKxn0SUHt6xzk=
github.com/ysmood/got v0.34.1/go.mod h1:yddyjq/PmAf08RMLSwDjPyCvHvYed+WjHnQxpH851LM=
github.com/ysmood/got v0.40.0 h1:ZQk1B55zIvS7zflRrkGfPDrPG3d7+JOza1ZkNxcc74Q=
github.com/ysmood/got v0.40.0/go.mod h1:W7DdpuX6skL3NszLmAsC5hT7JAhuLZhByVzHTq874Qg=
github.com/ysmood/gotrace v0.6.0 h1:SyI1d4jclswLhg7SWTL6os3L1WOKeNn/ZtzVQF8QmdY=
github.com/ysmood/gotrace v0.6.0/go.mod h1:TzhIG7nHDry5//eYZDYcTzuJLYQIkykJzCRIo4/dzQM=
github.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE=
github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg=
github.com/ysmood/leakless v0.8.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ=
github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU=
github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ=
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/net v0.0.0-20211104170005-ce137452f963/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,54 @@
package api
import (
"encoding/json"
"net/http"
"codex-pool/internal/config"
)
// Result 统一 API 响应
type Result struct {
Code int `json:"code"`
Message string `json:"message,omitempty"`
Data interface{} `json:"data,omitempty"`
}
// JSON 发送 JSON 响应
func JSON(w http.ResponseWriter, code int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(data)
}
// Success 发送成功响应
func Success(w http.ResponseWriter, data interface{}) {
JSON(w, http.StatusOK, Result{Code: 0, Data: data})
}
// Error 发送错误响应
func Error(w http.ResponseWriter, httpCode int, message string) {
JSON(w, httpCode, Result{Code: -1, Message: message})
}
// CORS 跨域中间件
func CORS(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
origin := "*"
if config.Global != nil && config.Global.CorsOrigin != "" {
origin = config.Global.CorsOrigin
}
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Api-Key")
w.Header().Set("Access-Control-Max-Age", "86400")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusNoContent)
return
}
next(w, r)
}
}

View File

@@ -0,0 +1,423 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"sync/atomic"
"time"
"codex-pool/internal/auth"
"codex-pool/internal/config"
"codex-pool/internal/invite"
"codex-pool/internal/logger"
"codex-pool/internal/mail"
"codex-pool/internal/register"
)
// TeamProcessRequest 团队处理请求
type TeamProcessRequest struct {
// Owner 账号列表
Owners []struct {
Email string `json:"email"`
Password string `json:"password"`
Token string `json:"token"`
} `json:"owners"`
// 配置
MembersPerTeam int `json:"members_per_team"` // 每个 Team 的成员数
ConcurrentTeams int `json:"concurrent_teams"` // 并发 Team 数量
BrowserType string `json:"browser_type"` // "chromedp" 或 "rod"
Headless bool `json:"headless"` // 是否无头模式
Proxy string `json:"proxy"` // 代理设置
}
// TeamProcessResult 团队处理结果
type TeamProcessResult struct {
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"`
}
// TeamProcessState 处理状态
type TeamProcessState struct {
Running bool `json:"running"`
StartedAt time.Time `json:"started_at"`
TotalTeams int `json:"total_teams"`
Completed int32 `json:"completed"`
Results []TeamProcessResult `json:"results"`
mu sync.Mutex
}
var teamProcessState = &TeamProcessState{}
// HandleTeamProcess POST /api/team/process - 启动 Team 批量处理
func HandleTeamProcess(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "仅支持 POST")
return
}
// 检查是否正在运行
if teamProcessState.Running {
Error(w, http.StatusConflict, "已有任务正在运行")
return
}
var req TeamProcessRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
Error(w, http.StatusBadRequest, "请求格式错误")
return
}
// 验证参数
if len(req.Owners) == 0 {
Error(w, http.StatusBadRequest, "请提供至少一个 Owner 账号")
return
}
if req.MembersPerTeam <= 0 {
req.MembersPerTeam = 4
}
if req.ConcurrentTeams <= 0 {
req.ConcurrentTeams = len(req.Owners)
}
if req.ConcurrentTeams > len(req.Owners) {
req.ConcurrentTeams = len(req.Owners)
}
if req.BrowserType == "" {
req.BrowserType = "chromedp" // 默认使用 Chromedp
}
if req.Proxy == "" && config.Global != nil {
req.Proxy = config.Global.GetProxy() // 使用新的代理获取方法
}
// 初始化状态
teamProcessState.Running = true
teamProcessState.StartedAt = time.Now()
teamProcessState.TotalTeams = len(req.Owners) // 所有 owners 都会处理
teamProcessState.Completed = 0
teamProcessState.Results = make([]TeamProcessResult, 0, len(req.Owners))
// 异步执行
go runTeamProcess(req)
Success(w, map[string]interface{}{
"message": "任务已启动",
"total_teams": len(req.Owners),
"concurrent_teams": req.ConcurrentTeams,
"started_at": teamProcessState.StartedAt,
})
}
// HandleTeamProcessStatus GET /api/team/status - 获取处理状态
func HandleTeamProcessStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "仅支持 GET")
return
}
teamProcessState.mu.Lock()
defer teamProcessState.mu.Unlock()
Success(w, map[string]interface{}{
"running": teamProcessState.Running,
"started_at": teamProcessState.StartedAt,
"total_teams": teamProcessState.TotalTeams,
"completed": teamProcessState.Completed,
"results": teamProcessState.Results,
"elapsed_ms": time.Since(teamProcessState.StartedAt).Milliseconds(),
})
}
// HandleTeamProcessStop POST /api/team/stop - 停止处理
func HandleTeamProcessStop(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "仅支持 POST")
return
}
teamProcessState.Running = false
Success(w, map[string]string{"message": "已发送停止信号"})
}
// runTeamProcess 执行 Team 批量处理 - 使用工作池模式
func runTeamProcess(req TeamProcessRequest) {
defer func() {
teamProcessState.Running = false
}()
totalOwners := len(req.Owners)
workerCount := req.ConcurrentTeams // 同时运行的 worker 数量
if workerCount > totalOwners {
workerCount = totalOwners
}
if workerCount <= 0 {
workerCount = 2 // 默认 2 个并发
}
logger.Info(fmt.Sprintf("Starting Team process: %d owners, %d concurrent workers", totalOwners, workerCount), "", "team")
// 任务队列
taskChan := make(chan int, totalOwners)
resultChan := make(chan TeamProcessResult, totalOwners)
var wg sync.WaitGroup
// 启动 worker
for w := 0; w < workerCount; w++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
for idx := range taskChan {
if !teamProcessState.Running {
return
}
result := processSingleTeam(idx, req)
resultChan <- result
atomic.AddInt32(&teamProcessState.Completed, 1)
}
}(w)
}
// 发送任务
go func() {
for i := 0; i < totalOwners; i++ {
if !teamProcessState.Running {
break
}
taskChan <- i
}
close(taskChan)
}()
// 等待完成并收集结果
go func() {
wg.Wait()
close(resultChan)
}()
for result := range resultChan {
teamProcessState.mu.Lock()
teamProcessState.Results = append(teamProcessState.Results, result)
teamProcessState.mu.Unlock()
}
logger.Success(fmt.Sprintf("Team process complete: %d/%d teams processed", teamProcessState.Completed, totalOwners), "", "team")
}
// processSingleTeam 处理单个 Team
func processSingleTeam(idx int, req TeamProcessRequest) TeamProcessResult {
startTime := time.Now()
owner := req.Owners[idx]
result := TeamProcessResult{
TeamIndex: idx + 1,
OwnerEmail: owner.Email,
MemberEmails: make([]string, 0),
Errors: make([]string, 0),
}
logPrefix := fmt.Sprintf("[Team %d]", idx+1)
logger.Info(fmt.Sprintf("%s Starting with owner: %s", logPrefix, owner.Email), owner.Email, "team")
// Step 1: 获取 Team ID
inviter := invite.NewWithProxy(owner.Token, req.Proxy)
teamID, err := inviter.GetAccountID()
if err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("获取 Team ID 失败: %v", err))
result.DurationMs = time.Since(startTime).Milliseconds()
logger.Error(fmt.Sprintf("%s Failed to get Team ID: %v", logPrefix, err), owner.Email, "team")
return result
}
result.TeamID = teamID
logger.Success(fmt.Sprintf("%s Team ID: %s", logPrefix, teamID), owner.Email, "team")
// Step 2: 生成成员邮箱并发送邀请
type MemberAccount struct {
Email string
Password string
Success bool
}
children := make([]MemberAccount, req.MembersPerTeam)
for i := 0; i < req.MembersPerTeam; i++ {
children[i].Email = mail.GenerateEmail()
children[i].Password = register.GeneratePassword()
logger.Info(fmt.Sprintf("%s [Member %d] Email: %s", logPrefix, i+1, children[i].Email), children[i].Email, "team")
}
// 发送邀请
inviteEmails := make([]string, req.MembersPerTeam)
for i, c := range children {
inviteEmails[i] = c.Email
}
if err := inviter.SendInvites(inviteEmails); err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("发送邀请失败: %v", err))
result.DurationMs = time.Since(startTime).Milliseconds()
return result
}
logger.Success(fmt.Sprintf("%s Sent %d invite(s)", logPrefix, len(inviteEmails)), owner.Email, "team")
// Step 3: 并发注册成员
var memberWg sync.WaitGroup
memberMutex := sync.Mutex{}
for i := range children {
memberWg.Add(1)
go func(memberIdx int) {
defer memberWg.Done()
memberMutex.Lock()
email := children[memberIdx].Email
password := children[memberIdx].Password
memberMutex.Unlock()
name := register.GenerateName()
birthdate := register.GenerateBirthdate()
// 重试逻辑
for attempt := 0; attempt < 3; attempt++ {
if !teamProcessState.Running {
return
}
if attempt > 0 {
email = mail.GenerateEmail()
password = register.GeneratePassword()
logger.Info(fmt.Sprintf("%s [Member %d] Retry %d: %s", logPrefix, memberIdx+1, attempt, email), email, "team")
if err := inviter.SendInvites([]string{email}); err != nil {
continue
}
}
_, err := registerWithTimeout(email, password, name, birthdate, req.Proxy)
if err != nil {
if strings.Contains(err.Error(), "验证码") {
continue
}
result.Errors = append(result.Errors, fmt.Sprintf("Member %d: %v", memberIdx+1, err))
return
}
memberMutex.Lock()
children[memberIdx].Email = email
children[memberIdx].Password = password
children[memberIdx].Success = true
memberMutex.Unlock()
logger.Success(fmt.Sprintf("%s [Member %d] Registered", logPrefix, memberIdx+1), email, "team")
return
}
}(i)
}
memberWg.Wait()
// 统计注册成功数
registeredChildren := make([]MemberAccount, 0)
for _, c := range children {
if c.Success {
registeredChildren = append(registeredChildren, c)
result.MemberEmails = append(result.MemberEmails, c.Email)
result.Registered++
}
}
logger.Info(fmt.Sprintf("%s Registered: %d/%d", logPrefix, result.Registered, req.MembersPerTeam), owner.Email, "team")
// Step 4: S2A 授权入库
for i, child := range registeredChildren {
if !teamProcessState.Running {
break
}
s2aResp, err := auth.GenerateS2AAuthURL(config.Global.S2AApiBase, config.Global.S2AAdminKey, config.Global.ProxyID)
if err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("Member %d auth URL: %v", i+1, err))
continue
}
// 根据配置选择浏览器自动化
var code string
if req.BrowserType == "rod" {
code, err = auth.CompleteWithRod(s2aResp.Data.AuthURL, child.Email, child.Password, teamID, req.Headless, req.Proxy)
} else {
code, err = auth.CompleteWithChromedp(s2aResp.Data.AuthURL, child.Email, child.Password, teamID, req.Headless, req.Proxy)
}
if err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("Member %d browser: %v", i+1, err))
continue
}
// 提交到 S2A
_, err = auth.SubmitS2AOAuth(
config.Global.S2AApiBase,
config.Global.S2AAdminKey,
s2aResp.Data.SessionID,
code,
child.Email,
config.Global.Concurrency,
config.Global.Priority,
config.Global.GroupIDs,
config.Global.ProxyID,
)
if err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("Member %d S2A: %v", i+1, err))
continue
}
result.AddedToS2A++
logger.Success(fmt.Sprintf("%s [Member %d] Added to S2A", logPrefix, i+1), child.Email, "team")
}
result.DurationMs = time.Since(startTime).Milliseconds()
logger.Success(fmt.Sprintf("%s Complete: %d registered, %d in S2A", logPrefix, result.Registered, result.AddedToS2A), owner.Email, "team")
return result
}
// registerWithTimeout 带超时的注册
func registerWithTimeout(email, password, name, birthdate, proxy string) (*register.ChatGPTReg, error) {
reg, err := register.New(proxy)
if err != nil {
return nil, err
}
if err := reg.InitSession(); err != nil {
return nil, fmt.Errorf("初始化失败: %v", err)
}
if err := reg.GetAuthorizeURL(email); err != nil {
return nil, fmt.Errorf("获取授权URL失败: %v", err)
}
if err := reg.StartAuthorize(); err != nil {
return nil, fmt.Errorf("启动授权失败: %v", err)
}
if err := reg.Register(email, password); err != nil {
return nil, fmt.Errorf("注册失败: %v", err)
}
if err := reg.SendVerificationEmail(); err != nil {
return nil, fmt.Errorf("发送邮件失败: %v", err)
}
// 短超时获取验证码
otpCode, err := mail.GetVerificationCode(email, 5*time.Second)
if err != nil {
otpCode, err = mail.GetVerificationCode(email, 15*time.Second)
if err != nil {
return nil, fmt.Errorf("验证码获取超时")
}
}
if err := reg.ValidateOTP(otpCode); err != nil {
return nil, fmt.Errorf("OTP验证失败: %v", err)
}
if err := reg.CreateAccount(name, birthdate); err != nil {
return nil, fmt.Errorf("创建账户失败: %v", err)
}
_ = reg.GetSessionToken()
return reg, nil
}

View File

@@ -0,0 +1,194 @@
package auth
import (
"context"
"fmt"
"strings"
"time"
"github.com/chromedp/cdproto/network"
"github.com/chromedp/chromedp"
)
// CompleteWithChromedp 使用 chromedp 完成 S2A OAuth 授权
func CompleteWithChromedp(authURL, email, password, teamID string, headless bool, proxy string) (string, error) {
opts := append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.Flag("headless", headless),
chromedp.Flag("disable-gpu", true),
chromedp.Flag("no-sandbox", true),
chromedp.Flag("disable-dev-shm-usage", true),
chromedp.Flag("disable-blink-features", "AutomationControlled"),
chromedp.UserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36"),
)
if proxy != "" {
opts = append(opts, chromedp.ProxyServer(proxy))
}
allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...)
defer cancel()
ctx, cancel := chromedp.NewContext(allocCtx)
defer cancel()
ctx, cancel = context.WithTimeout(ctx, 120*time.Second)
defer cancel()
var callbackURL string
chromedp.ListenTarget(ctx, func(ev interface{}) {
if req, ok := ev.(*network.EventRequestWillBeSent); ok {
url := req.Request.URL
if strings.Contains(url, "localhost") && strings.Contains(url, "code=") {
callbackURL = url
}
}
})
err := chromedp.Run(ctx,
network.Enable(),
chromedp.Navigate(authURL),
chromedp.WaitReady("body"),
)
if err != nil {
return "", fmt.Errorf("访问失败: %v", err)
}
time.Sleep(2 * time.Second)
if callbackURL != "" {
return ExtractCodeFromCallbackURL(callbackURL), nil
}
var currentURL string
_ = chromedp.Run(ctx, chromedp.Location(&currentURL))
if strings.Contains(currentURL, "code=") {
return ExtractCodeFromCallbackURL(currentURL), nil
}
time.Sleep(1 * time.Second)
emailSelectors := []string{
`input[name="email"]`,
`input[type="email"]`,
`input[name="username"]`,
}
var emailFilled bool
for _, sel := range emailSelectors {
err = chromedp.Run(ctx, chromedp.WaitVisible(sel, chromedp.ByQuery))
if err == nil {
err = chromedp.Run(ctx,
chromedp.Clear(sel, chromedp.ByQuery),
chromedp.SendKeys(sel, email, chromedp.ByQuery),
)
if err == nil {
emailFilled = true
break
}
}
}
if !emailFilled {
return "", fmt.Errorf("未找到邮箱输入框")
}
time.Sleep(300 * time.Millisecond)
buttonSelectors := []string{
`button[type="submit"]`,
`button[data-testid="login-button"]`,
`button.continue-btn`,
`input[type="submit"]`,
}
for _, sel := range buttonSelectors {
err = chromedp.Run(ctx, chromedp.Click(sel, chromedp.ByQuery))
if err == nil {
break
}
}
time.Sleep(1500 * time.Millisecond)
if callbackURL != "" {
return ExtractCodeFromCallbackURL(callbackURL), nil
}
_ = chromedp.Run(ctx, chromedp.Location(&currentURL))
if strings.Contains(currentURL, "code=") {
return ExtractCodeFromCallbackURL(currentURL), nil
}
passwordSelectors := []string{
`input[name="current-password"]`,
`input[name="password"]`,
`input[type="password"]`,
}
var passwordFilled bool
for _, sel := range passwordSelectors {
err = chromedp.Run(ctx, chromedp.WaitVisible(sel, chromedp.ByQuery))
if err == nil {
err = chromedp.Run(ctx,
chromedp.Clear(sel, chromedp.ByQuery),
chromedp.SendKeys(sel, password, chromedp.ByQuery),
)
if err == nil {
passwordFilled = true
break
}
}
}
if !passwordFilled {
return "", fmt.Errorf("未找到密码输入框")
}
time.Sleep(300 * time.Millisecond)
for _, sel := range buttonSelectors {
err = chromedp.Run(ctx, chromedp.Click(sel, chromedp.ByQuery))
if err == nil {
break
}
}
for i := 0; i < 30; i++ {
time.Sleep(500 * time.Millisecond)
if callbackURL != "" {
return ExtractCodeFromCallbackURL(callbackURL), nil
}
var url string
if err := chromedp.Run(ctx, chromedp.Location(&url)); err == nil {
if strings.Contains(url, "code=") {
return ExtractCodeFromCallbackURL(url), nil
}
if strings.Contains(url, "consent") {
for _, sel := range buttonSelectors {
err = chromedp.Run(ctx, chromedp.Click(sel, chromedp.ByQuery))
if err == nil {
break
}
}
time.Sleep(1 * time.Second)
}
if strings.Contains(url, "authorize") && teamID != "" {
err = chromedp.Run(ctx,
chromedp.Click(fmt.Sprintf(`[data-workspace-id="%s"], [data-account-id="%s"]`, teamID, teamID), chromedp.ByQuery),
)
}
}
}
if callbackURL != "" {
return ExtractCodeFromCallbackURL(callbackURL), nil
}
return "", fmt.Errorf("授权超时")
}

View File

@@ -0,0 +1,167 @@
package auth
import (
"fmt"
"strings"
"time"
"github.com/go-rod/rod"
"github.com/go-rod/rod/lib/launcher"
"github.com/go-rod/rod/lib/proto"
"github.com/go-rod/stealth"
)
// RodAuth 使用 Rod + Stealth 完成 OAuth 授权
type RodAuth struct {
browser *rod.Browser
headless bool
proxy string
}
// NewRodAuth 创建 Rod 授权器
func NewRodAuth(headless bool, proxy string) (*RodAuth, error) {
l := launcher.New().
Headless(headless).
Set("disable-blink-features", "AutomationControlled").
Set("disable-dev-shm-usage").
Set("no-sandbox").
Set("disable-gpu").
Set("disable-extensions").
Set("disable-background-networking").
Set("disable-sync").
Set("disable-translate").
Set("metrics-recording-only").
Set("no-first-run")
if proxy != "" {
l = l.Proxy(proxy)
}
controlURL, err := l.Launch()
if err != nil {
return nil, fmt.Errorf("启动浏览器失败: %v", err)
}
browser := rod.New().ControlURL(controlURL)
if err := browser.Connect(); err != nil {
return nil, fmt.Errorf("连接浏览器失败: %v", err)
}
return &RodAuth{
browser: browser,
headless: headless,
proxy: proxy,
}, nil
}
// Close 关闭浏览器
func (r *RodAuth) Close() {
if r.browser != nil {
r.browser.Close()
}
}
// CompleteOAuth 完成 OAuth 授权
func (r *RodAuth) CompleteOAuth(authURL, email, password, teamID string) (string, error) {
page, err := stealth.Page(r.browser)
if err != nil {
return "", fmt.Errorf("创建页面失败: %v", err)
}
defer page.Close()
page = page.Timeout(45 * time.Second)
if err := page.Navigate(authURL); err != nil {
return "", fmt.Errorf("访问授权URL失败: %v", err)
}
page.MustWaitDOMStable()
if code := r.checkForCode(page); code != "" {
return code, nil
}
emailInput, err := page.Timeout(5 * time.Second).Element("input[name='email'], input[type='email'], input[name='username']")
if err != nil {
return "", fmt.Errorf("未找到邮箱输入框")
}
emailInput.MustSelectAllText().MustInput(email)
time.Sleep(200 * time.Millisecond)
if btn, _ := page.Timeout(2 * time.Second).Element("button[type='submit']"); btn != nil {
btn.MustClick()
}
time.Sleep(1500 * time.Millisecond)
if code := r.checkForCode(page); code != "" {
return code, nil
}
passwordInput, err := page.Timeout(8 * time.Second).Element("input[type='password']")
if err != nil {
return "", fmt.Errorf("未找到密码输入框")
}
passwordInput.MustSelectAllText().MustInput(password)
time.Sleep(200 * time.Millisecond)
if btn, _ := page.Timeout(2 * time.Second).Element("button[type='submit']"); btn != nil {
btn.MustClick()
}
for i := 0; i < 66; i++ {
time.Sleep(300 * time.Millisecond)
if code := r.checkForCode(page); code != "" {
return code, nil
}
info, _ := page.Info()
currentURL := info.URL
if strings.Contains(currentURL, "consent") {
if btn, _ := page.Timeout(500 * time.Millisecond).Element("button[type='submit']"); btn != nil {
btn.Click(proto.InputMouseButtonLeft, 1)
}
}
if strings.Contains(currentURL, "authorize") && teamID != "" {
wsSelector := fmt.Sprintf("[data-workspace-id='%s'], [data-account-id='%s']", teamID, teamID)
if wsBtn, _ := page.Timeout(500 * time.Millisecond).Element(wsSelector); wsBtn != nil {
wsBtn.Click(proto.InputMouseButtonLeft, 1)
}
}
}
return "", fmt.Errorf("授权超时")
}
// checkForCode 检查 URL 中是否包含 code
func (r *RodAuth) checkForCode(page *rod.Page) string {
info, err := page.Info()
if err != nil {
return ""
}
if strings.Contains(info.URL, "code=") {
return ExtractCodeFromCallbackURL(info.URL)
}
return ""
}
// CompleteWithRod 使用 Rod + Stealth 完成 S2A 授权
func CompleteWithRod(authURL, email, password, teamID string, headless bool, proxy string) (string, error) {
auth, err := NewRodAuth(headless, proxy)
if err != nil {
return "", err
}
defer auth.Close()
return auth.CompleteOAuth(authURL, email, password, teamID)
}
// CompleteWithBrowser 使用 Rod 完成 S2A 授权 (别名)
func CompleteWithBrowser(authURL, email, password, teamID string, headless bool, proxy string) (string, error) {
return CompleteWithRod(authURL, email, password, teamID, headless, proxy)
}

View File

@@ -0,0 +1,291 @@
package auth
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
const (
CodexClientID = "app_EMoamEEZ73f0CkXaXp7hrann"
CodexRedirectURI = "http://localhost:1455/auth/callback"
CodexScope = "openid profile email offline_access"
)
// CodexTokens Codex Token 结构
type CodexTokens struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
IDToken string `json:"id_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
ExpiredAt string `json:"expired_at,omitempty"`
}
// S2AAuthURLRequest S2A 授权 URL 请求
type S2AAuthURLRequest struct {
ProxyID *int `json:"proxy_id,omitempty"`
}
// S2AAuthURLResponse S2A 授权 URL 响应
type S2AAuthURLResponse struct {
Code int `json:"code"`
Data struct {
AuthURL string `json:"auth_url"`
SessionID string `json:"session_id"`
} `json:"data"`
Message string `json:"message,omitempty"`
}
// S2ACreateFromOAuthRequest 提交 OAuth 入库请求
type S2ACreateFromOAuthRequest struct {
SessionID string `json:"session_id"`
Code string `json:"code"`
Name string `json:"name,omitempty"`
Concurrency int `json:"concurrency,omitempty"`
Priority int `json:"priority,omitempty"`
GroupIDs []int `json:"group_ids,omitempty"`
ProxyID *int `json:"proxy_id,omitempty"`
}
// S2ACreateFromOAuthResponse 入库响应
type S2ACreateFromOAuthResponse struct {
Code int `json:"code"`
Data struct {
ID int `json:"id"`
Name string `json:"name"`
Platform string `json:"platform"`
Type string `json:"type"`
Status string `json:"status"`
Concurrency int `json:"concurrency"`
Priority int `json:"priority"`
} `json:"data"`
Message string `json:"message,omitempty"`
}
// GenerateS2AAuthURL 从 S2A 生成 Codex 授权 URL
func GenerateS2AAuthURL(s2aAPIBase, s2aAdminKey string, proxyID *int) (*S2AAuthURLResponse, error) {
client := &http.Client{Timeout: 15 * time.Second}
apiURL := s2aAPIBase + "/api/v1/admin/openai/generate-auth-url"
payload := S2AAuthURLRequest{ProxyID: proxyID}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", apiURL, bytes.NewReader(body))
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Api-Key", s2aAdminKey)
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("请求失败: %v", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if len(respBody) > 0 && respBody[0] == '<' {
return nil, fmt.Errorf("服务器返回 HTML: %s", string(respBody)[:min(100, len(respBody))])
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody)[:min(200, len(respBody))])
}
var result S2AAuthURLResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("解析响应失败: %v, body: %s", err, string(respBody)[:min(100, len(respBody))])
}
if result.Code != 0 {
return nil, fmt.Errorf("S2A 错误: %s", result.Message)
}
return &result, nil
}
// SubmitS2AOAuth 提交 OAuth code 到 S2A 入库
func SubmitS2AOAuth(s2aAPIBase, s2aAdminKey, sessionID, code, name string, concurrency, priority int, groupIDs []int, proxyID *int) (*S2ACreateFromOAuthResponse, error) {
client := &http.Client{Timeout: 30 * time.Second}
apiURL := s2aAPIBase + "/api/v1/admin/openai/create-from-oauth"
payload := S2ACreateFromOAuthRequest{
SessionID: sessionID,
Code: code,
Name: name,
Concurrency: concurrency,
Priority: priority,
GroupIDs: groupIDs,
ProxyID: proxyID,
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", apiURL, bytes.NewReader(body))
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Api-Key", s2aAdminKey)
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("请求失败: %v", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
var result S2ACreateFromOAuthResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("解析响应失败: %v", err)
}
if result.Code != 0 {
return nil, fmt.Errorf("S2A 入库失败: %s", result.Message)
}
return &result, nil
}
// VerifyS2AAccount 验证账号入库状态
func VerifyS2AAccount(s2aAPIBase, s2aAdminKey, email string) (bool, error) {
client := &http.Client{Timeout: 30 * time.Second}
apiURL := fmt.Sprintf("%s/api/v1/admin/accounts?page=1&page_size=20&search=%s&timezone=Asia/Shanghai", s2aAPIBase, url.QueryEscape(email))
req, _ := http.NewRequest("GET", apiURL, nil)
req.Header.Set("Accept", "application/json")
req.Header.Set("X-Api-Key", s2aAdminKey)
resp, err := client.Do(req)
if err != nil {
return false, fmt.Errorf("请求失败: %v", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
var result struct {
Code int `json:"code"`
Data struct {
Items []struct {
ID int `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
} `json:"items"`
Total int `json:"total"`
} `json:"data"`
}
if err := json.Unmarshal(respBody, &result); err != nil {
return false, fmt.Errorf("解析响应失败: %v", err)
}
if result.Code != 0 || result.Data.Total == 0 {
return false, nil
}
for _, item := range result.Data.Items {
if item.Status == "active" {
return true, nil
}
}
return false, nil
}
// ExtractCodeFromCallbackURL 从回调 URL 中提取 code
func ExtractCodeFromCallbackURL(callbackURL string) string {
parsedURL, err := url.Parse(callbackURL)
if err != nil {
return ""
}
return parsedURL.Query().Get("code")
}
// RefreshCodexToken 刷新 Codex token
func RefreshCodexToken(refreshToken string, proxyURL string) (*CodexTokens, error) {
client := &http.Client{Timeout: 30 * time.Second}
if proxyURL != "" {
proxyURLParsed, _ := url.Parse(proxyURL)
client.Transport = &http.Transport{Proxy: http.ProxyURL(proxyURLParsed)}
}
data := url.Values{
"client_id": {CodexClientID},
"grant_type": {"refresh_token"},
"refresh_token": {refreshToken},
"scope": {"openid profile email"},
}
req, _ := http.NewRequest("POST", "https://auth.openai.com/oauth/token", strings.NewReader(data.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
return nil, fmt.Errorf("刷新 token 失败: %d, %s", resp.StatusCode, string(body)[:min(200, len(body))])
}
var tokens CodexTokens
if err := json.Unmarshal(body, &tokens); err != nil {
return nil, err
}
if tokens.ExpiresIn > 0 {
expiredAt := time.Now().Add(time.Duration(tokens.ExpiresIn) * time.Second)
tokens.ExpiredAt = expiredAt.Format(time.RFC3339)
}
return &tokens, nil
}
// ExtractWorkspaceFromCookie 从 cookie 提取 workspace_id
func ExtractWorkspaceFromCookie(cookieValue string) string {
parts := strings.Split(cookieValue, ".")
if len(parts) < 1 {
return ""
}
payload := parts[0]
if m := len(payload) % 4; m != 0 {
payload += strings.Repeat("=", 4-m)
}
decoded, err := base64.StdEncoding.DecodeString(payload)
if err != nil {
decoded, err = base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return ""
}
}
var data struct {
Workspaces []struct {
ID string `json:"id"`
} `json:"workspaces"`
}
if err := json.Unmarshal(decoded, &data); err != nil {
return ""
}
if len(data.Workspaces) > 0 {
return data.Workspaces[0].ID
}
return ""
}

View File

@@ -0,0 +1,240 @@
package client
import (
"bytes"
"compress/gzip"
"io"
"math/rand"
"net/http"
"net/url"
"strings"
"github.com/andybalholm/brotli"
http2 "github.com/bogdanfinn/fhttp"
tls_client "github.com/bogdanfinn/tls-client"
"github.com/bogdanfinn/tls-client/profiles"
)
// TLSClient 使用 tls-client 模拟浏览器指纹的 HTTP 客户端
type TLSClient struct {
client tls_client.HttpClient
userAgent string
chromeVer string
acceptLang string
}
// 语言偏好池
var languagePrefs = []string{
"en-US,en;q=0.9",
"en-GB,en;q=0.9,en-US;q=0.8",
"de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7",
}
// New 创建一个新的 TLS 客户端
func New(proxyStr string) (*TLSClient, error) {
jar := tls_client.NewCookieJar()
chromeVer := "133"
options := []tls_client.HttpClientOption{
tls_client.WithTimeoutSeconds(60),
tls_client.WithClientProfile(profiles.Chrome_133),
tls_client.WithRandomTLSExtensionOrder(),
tls_client.WithCookieJar(jar),
tls_client.WithInsecureSkipVerify(),
}
if proxyStr != "" {
options = append(options, tls_client.WithProxyUrl(proxyStr))
}
client, err := tls_client.NewHttpClient(tls_client.NewNoopLogger(), options...)
if err != nil {
return nil, err
}
acceptLang := languagePrefs[rand.Intn(len(languagePrefs))]
userAgent := generateUserAgent(chromeVer)
return &TLSClient{
client: client,
userAgent: userAgent,
chromeVer: chromeVer,
acceptLang: acceptLang,
}, nil
}
// generateUserAgent 生成随机化的 User-Agent
func generateUserAgent(chromeVer string) string {
winVersions := []string{
"Windows NT 10.0; Win64; x64",
"Windows NT 10.0; WOW64",
}
winVer := winVersions[rand.Intn(len(winVersions))]
return "Mozilla/5.0 (" + winVer + ") AppleWebKit/537.36 (KHTML, like Gecko) Chrome/" + chromeVer + ".0.0.0 Safari/537.36"
}
// getDefaultHeaders 获取默认请求头
func (c *TLSClient) getDefaultHeaders() map[string]string {
secChUa := `"Chromium";v="` + c.chromeVer + `", "Not(A:Brand";v="99", "Google Chrome";v="` + c.chromeVer + `"`
return map[string]string{
"User-Agent": c.userAgent,
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
"Accept-Language": c.acceptLang,
"Accept-Encoding": "gzip, deflate, br, zstd",
"Cache-Control": "max-age=0",
"Sec-Ch-Ua": secChUa,
"Sec-Ch-Ua-Mobile": "?0",
"Sec-Ch-Ua-Platform": `"Windows"`,
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
"Upgrade-Insecure-Requests": "1",
}
}
// Do 执行 HTTP 请求
func (c *TLSClient) Do(req *http.Request) (*http.Response, error) {
fhttpReq, err := http2.NewRequest(req.Method, req.URL.String(), req.Body)
if err != nil {
return nil, err
}
for key, value := range c.getDefaultHeaders() {
if req.Header.Get(key) == "" {
fhttpReq.Header.Set(key, value)
}
}
for key, values := range req.Header {
for _, value := range values {
fhttpReq.Header.Set(key, value)
}
}
resp, err := c.client.Do(fhttpReq)
if err != nil {
return nil, err
}
finalReq := req
if resp.Request != nil && resp.Request.URL != nil {
finalReq = &http.Request{
Method: resp.Request.Method,
URL: (*url.URL)(resp.Request.URL),
Header: http.Header(resp.Request.Header),
}
}
stdResp := &http.Response{
Status: resp.Status,
StatusCode: resp.StatusCode,
Proto: resp.Proto,
ProtoMajor: resp.ProtoMajor,
ProtoMinor: resp.ProtoMinor,
Header: http.Header(resp.Header),
Body: resp.Body,
ContentLength: resp.ContentLength,
TransferEncoding: resp.TransferEncoding,
Close: resp.Close,
Uncompressed: resp.Uncompressed,
Request: finalReq,
}
return stdResp, nil
}
// Get 执行 GET 请求
func (c *TLSClient) Get(urlStr string) (*http.Response, error) {
req, err := http.NewRequest("GET", urlStr, nil)
if err != nil {
return nil, err
}
return c.Do(req)
}
// Post 执行 POST 请求
func (c *TLSClient) Post(urlStr string, contentType string, body io.Reader) (*http.Response, error) {
req, err := http.NewRequest("POST", urlStr, body)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", contentType)
return c.Do(req)
}
// PostForm 执行 POST 表单请求
func (c *TLSClient) PostForm(urlStr string, data url.Values) (*http.Response, error) {
return c.Post(urlStr, "application/x-www-form-urlencoded", strings.NewReader(data.Encode()))
}
// PostJSON 执行 POST JSON 请求
func (c *TLSClient) PostJSON(urlStr string, body io.Reader) (*http.Response, error) {
return c.Post(urlStr, "application/json", body)
}
// GetCookie 获取指定 URL 的 Cookie
func (c *TLSClient) GetCookie(urlStr string, name string) string {
u, err := url.Parse(urlStr)
if err != nil {
return ""
}
cookies := c.client.GetCookies(u)
for _, cookie := range cookies {
if cookie.Name == name {
return cookie.Value
}
}
return ""
}
// SetCookie 设置 Cookie
func (c *TLSClient) SetCookie(urlStr string, cookie *http.Cookie) {
u, err := url.Parse(urlStr)
if err != nil {
return
}
c.client.SetCookies(u, []*http2.Cookie{
{
Name: cookie.Name,
Value: cookie.Value,
Path: cookie.Path,
Domain: cookie.Domain,
},
})
}
// ReadBody 读取响应体并自动处理压缩
func ReadBody(resp *http.Response) ([]byte, error) {
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
switch resp.Header.Get("Content-Encoding") {
case "gzip":
gzReader, err := gzip.NewReader(bytes.NewReader(data))
if err != nil {
return data, nil
}
defer gzReader.Close()
return io.ReadAll(gzReader)
case "br":
return io.ReadAll(brotli.NewReader(bytes.NewReader(data)))
}
return data, nil
}
// ReadBodyString 读取响应体为字符串
func ReadBodyString(resp *http.Response) (string, error) {
body, err := ReadBody(resp)
if err != nil {
return "", err
}
return string(body), nil
}

View File

@@ -0,0 +1,264 @@
package config
import (
"encoding/json"
"os"
"strconv"
"strings"
"sync"
)
// MailServiceConfig 邮箱服务配置
type MailServiceConfig struct {
Name string `yaml:"name" json:"name"`
APIBase string `yaml:"api_base" json:"api_base"`
APIToken string `yaml:"api_token" json:"api_token"`
Domain string `yaml:"domain" json:"domain"`
EmailPath string `yaml:"email_path,omitempty" json:"email_path,omitempty"`
AddUserAPI string `yaml:"add_user_api,omitempty" json:"add_user_api,omitempty"`
}
// Config 应用配置 (实时从数据库读取)
type Config struct {
// 服务器配置 (启动时固定)
Port int `json:"port"`
CorsOrigin string `json:"cors_origin"`
// S2A 配置 (可实时更新)
S2AApiBase string `json:"s2a_api_base"`
S2AAdminKey string `json:"s2a_admin_key"`
// 入库配置 (可实时更新)
Concurrency int `json:"concurrency"`
Priority int `json:"priority"`
GroupIDs []int `json:"group_ids"`
ProxyID *int `json:"proxy_id"`
// 代理配置 (可实时更新)
ProxyEnabled bool `json:"proxy_enabled"`
DefaultProxy string `json:"default_proxy"`
// 自动化配置
AutoPauseOnExpired bool `json:"auto_pause_on_expired"`
AccountsPath string `json:"accounts_path"`
// 邮箱服务
MailServices []MailServiceConfig `json:"mail_services"`
}
// GetProxy 获取代理地址(如果启用)
func (c *Config) GetProxy() string {
if c.ProxyEnabled && c.DefaultProxy != "" {
return c.DefaultProxy
}
return ""
}
// Account 账号结构 (保持 JSON 格式用于账号文件)
type Account struct {
Email string `json:"email,omitempty"`
Account string `json:"account,omitempty"`
Password string `json:"password"`
Name string `json:"name,omitempty"`
AccessToken string `json:"access_token,omitempty"`
Token string `json:"token,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
Pooled bool `json:"pooled,omitempty"`
PooledAt string `json:"pooled_at,omitempty"`
PoolID int `json:"pool_id,omitempty"`
Used bool `json:"used,omitempty"`
UsedAt string `json:"used_at,omitempty"`
}
// GetEmail 获取邮箱
func (a *Account) GetEmail() string {
if a.Email != "" {
return a.Email
}
return a.Account
}
// GetAccessToken 获取 Token
func (a *Account) GetAccessToken() string {
if a.AccessToken != "" {
return a.AccessToken
}
return a.Token
}
// PoolingConfig 入库任务配置
type PoolingConfig struct {
Concurrency int `json:"concurrency"`
SerialAuthorize bool `json:"serial_authorize"`
BrowserType string `json:"browser_type"`
Headless bool `json:"headless"`
Proxy string `json:"proxy"`
}
// 全局配置实例
var (
Global *Config
configMu sync.RWMutex
)
// ConfigDB 配置数据库接口
type ConfigDB interface {
GetConfig(key string) (string, error)
SetConfig(key, value string) error
GetAllConfig() (map[string]string, error)
}
var configDB ConfigDB
// SetConfigDB 设置配置数据库
func SetConfigDB(db ConfigDB) {
configDB = db
}
// InitFromDB 从数据库初始化配置
func InitFromDB() *Config {
configMu.Lock()
defer configMu.Unlock()
cfg := &Config{
Port: 8848,
CorsOrigin: "*",
Concurrency: 2,
Priority: 0,
}
if configDB == nil {
Global = cfg
return cfg
}
// 从数据库加载配置
if v, _ := configDB.GetConfig("s2a_api_base"); v != "" {
cfg.S2AApiBase = v
}
if v, _ := configDB.GetConfig("s2a_admin_key"); v != "" {
cfg.S2AAdminKey = v
}
if v, _ := configDB.GetConfig("concurrency"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
cfg.Concurrency = n
}
}
if v, _ := configDB.GetConfig("priority"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
cfg.Priority = n
}
}
if v, _ := configDB.GetConfig("group_ids"); v != "" {
var ids []int
for _, s := range strings.Split(v, ",") {
if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil {
ids = append(ids, n)
}
}
cfg.GroupIDs = ids
}
if v, _ := configDB.GetConfig("proxy_enabled"); v == "true" {
cfg.ProxyEnabled = true
}
if v, _ := configDB.GetConfig("default_proxy"); v != "" {
cfg.DefaultProxy = v
}
if v, _ := configDB.GetConfig("mail_services"); v != "" {
var services []MailServiceConfig
if err := json.Unmarshal([]byte(v), &services); err == nil {
cfg.MailServices = services
}
}
Global = cfg
return cfg
}
// SaveToDB 保存配置到数据库
func SaveToDB() error {
if configDB == nil || Global == nil {
return nil
}
configMu.RLock()
cfg := Global
configMu.RUnlock()
configDB.SetConfig("s2a_api_base", cfg.S2AApiBase)
configDB.SetConfig("s2a_admin_key", cfg.S2AAdminKey)
configDB.SetConfig("concurrency", strconv.Itoa(cfg.Concurrency))
configDB.SetConfig("priority", strconv.Itoa(cfg.Priority))
if len(cfg.GroupIDs) > 0 {
var ids []string
for _, id := range cfg.GroupIDs {
ids = append(ids, strconv.Itoa(id))
}
configDB.SetConfig("group_ids", strings.Join(ids, ","))
}
configDB.SetConfig("proxy_enabled", strconv.FormatBool(cfg.ProxyEnabled))
configDB.SetConfig("default_proxy", cfg.DefaultProxy)
if len(cfg.MailServices) > 0 {
data, _ := json.Marshal(cfg.MailServices)
configDB.SetConfig("mail_services", string(data))
}
return nil
}
// Update 更新配置 (实时生效)
func Update(cfg *Config) error {
configMu.Lock()
Global = cfg
configMu.Unlock()
return SaveToDB()
}
// Get 获取当前配置
func Get() *Config {
configMu.RLock()
defer configMu.RUnlock()
return Global
}
// FindPath 查找配置文件路径 (兼容)
func FindPath() string {
if envPath := os.Getenv("CONFIG_PATH"); envPath != "" {
return envPath
}
return "data/config.yaml"
}
// Load 加载配置 (兼容旧代码,现在直接从数据库加载)
func Load(path string) (*Config, error) {
return InitFromDB(), nil
}
// LoadAccounts 加载账号列表 (保持 JSON 格式)
func LoadAccounts(path string) ([]Account, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var accounts []Account
if err := json.Unmarshal(data, &accounts); err != nil {
return nil, err
}
return accounts, nil
}
// SaveAccounts 保存账号列表 (保持 JSON 格式)
func SaveAccounts(path string, accounts []Account) error {
data, err := json.MarshalIndent(accounts, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0644)
}

View File

@@ -0,0 +1,272 @@
package database
import (
"database/sql"
"fmt"
"time"
_ "github.com/mattn/go-sqlite3"
)
// TeamOwner 账号结构
type TeamOwner struct {
ID int64 `json:"id"`
Email string `json:"email"`
Password string `json:"password,omitempty"`
Token string `json:"token,omitempty"`
AccountID string `json:"account_id"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
// DB 数据库管理器
type DB struct {
db *sql.DB
}
// 全局数据库实例
var Instance *DB
// Init 初始化数据库
func Init(dbPath string) error {
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
return fmt.Errorf("打开数据库失败: %w", err)
}
Instance = &DB{db: db}
if err := Instance.createTables(); err != nil {
return fmt.Errorf("创建表失败: %w", err)
}
fmt.Printf("[数据库] SQLite 已连接: %s\n", dbPath)
return nil
}
// createTables 创建表
func (d *DB) createTables() error {
_, err := d.db.Exec(`
CREATE TABLE IF NOT EXISTS team_owners (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
password TEXT,
token TEXT,
account_id TEXT NOT NULL,
status TEXT DEFAULT 'valid',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_team_owners_email ON team_owners(email);
CREATE INDEX IF NOT EXISTS idx_team_owners_status ON team_owners(status);
CREATE INDEX IF NOT EXISTS idx_team_owners_account_id ON team_owners(account_id);
-- 配置表 (key-value 形式)
CREATE TABLE IF NOT EXISTS app_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`)
return err
}
// GetConfig 获取配置值
func (d *DB) GetConfig(key string) (string, error) {
var value string
err := d.db.QueryRow("SELECT value FROM app_config WHERE key = ?", key).Scan(&value)
if err == sql.ErrNoRows {
return "", nil
}
return value, err
}
// SetConfig 设置配置值
func (d *DB) SetConfig(key, value string) error {
_, err := d.db.Exec(`
INSERT INTO app_config (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = CURRENT_TIMESTAMP
`, key, value, value)
return err
}
// GetAllConfig 获取所有配置
func (d *DB) GetAllConfig() (map[string]string, error) {
rows, err := d.db.Query("SELECT key, value FROM app_config")
if err != nil {
return nil, err
}
defer rows.Close()
result := make(map[string]string)
for rows.Next() {
var key, value string
if err := rows.Scan(&key, &value); err != nil {
continue
}
result[key] = value
}
return result, nil
}
// AddTeamOwner 添加 Team Owner
func (d *DB) AddTeamOwner(owner TeamOwner) (int64, error) {
result, err := d.db.Exec(`
INSERT OR REPLACE INTO team_owners (email, password, token, account_id, status, created_at)
VALUES (?, ?, ?, ?, 'valid', CURRENT_TIMESTAMP)
`, owner.Email, owner.Password, owner.Token, owner.AccountID)
if err != nil {
return 0, err
}
return result.LastInsertId()
}
// AddTeamOwners 批量添加
func (d *DB) AddTeamOwners(owners []TeamOwner) (int, error) {
tx, err := d.db.Begin()
if err != nil {
return 0, err
}
defer tx.Rollback()
stmt, err := tx.Prepare(`
INSERT OR REPLACE INTO team_owners (email, password, token, account_id, status, created_at)
VALUES (?, ?, ?, ?, 'valid', CURRENT_TIMESTAMP)
`)
if err != nil {
return 0, err
}
defer stmt.Close()
count := 0
for _, owner := range owners {
_, err := stmt.Exec(owner.Email, owner.Password, owner.Token, owner.AccountID)
if err != nil {
fmt.Printf("[数据库] 插入失败 %s: %v\n", owner.Email, err)
continue
}
count++
}
if err := tx.Commit(); err != nil {
return 0, err
}
return count, nil
}
// GetTeamOwners 获取列表
func (d *DB) GetTeamOwners(status string, limit, offset int) ([]TeamOwner, int, error) {
query := "SELECT id, email, password, token, account_id, status, created_at FROM team_owners WHERE 1=1"
countQuery := "SELECT COUNT(*) FROM team_owners WHERE 1=1"
args := []interface{}{}
if status != "" {
query += " AND status = ?"
countQuery += " AND status = ?"
args = append(args, status)
}
var total int
err := d.db.QueryRow(countQuery, args...).Scan(&total)
if err != nil {
return nil, 0, err
}
query += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
args = append(args, limit, offset)
rows, err := d.db.Query(query, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var owners []TeamOwner
for rows.Next() {
var owner TeamOwner
err := rows.Scan(&owner.ID, &owner.Email, &owner.Password, &owner.Token, &owner.AccountID, &owner.Status, &owner.CreatedAt)
if err != nil {
continue
}
owners = append(owners, owner)
}
return owners, total, nil
}
// GetPendingOwners 获取待处理
func (d *DB) GetPendingOwners() ([]TeamOwner, error) {
rows, err := d.db.Query(`
SELECT id, email, password, token, account_id, status, created_at
FROM team_owners WHERE status = 'valid'
ORDER BY created_at ASC
`)
if err != nil {
return nil, err
}
defer rows.Close()
var owners []TeamOwner
for rows.Next() {
var owner TeamOwner
err := rows.Scan(&owner.ID, &owner.Email, &owner.Password, &owner.Token, &owner.AccountID, &owner.Status, &owner.CreatedAt)
if err != nil {
continue
}
owners = append(owners, owner)
}
return owners, nil
}
// UpdateOwnerStatus 更新状态
func (d *DB) UpdateOwnerStatus(id int64, status string) error {
_, err := d.db.Exec("UPDATE team_owners SET status = ? WHERE id = ?", status, id)
return err
}
// DeleteTeamOwner 删除
func (d *DB) DeleteTeamOwner(id int64) error {
_, err := d.db.Exec("DELETE FROM team_owners WHERE id = ?", id)
return err
}
// ClearTeamOwners 清空
func (d *DB) ClearTeamOwners() error {
_, err := d.db.Exec("DELETE FROM team_owners")
return err
}
// GetOwnerStats 获取统计
func (d *DB) GetOwnerStats() map[string]int {
stats := map[string]int{
"total": 0,
"valid": 0,
"registered": 0,
"pooled": 0,
}
var count int
if err := d.db.QueryRow("SELECT COUNT(*) FROM team_owners").Scan(&count); err == nil {
stats["total"] = count
}
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 = 'pooled'").Scan(&count); err == nil {
stats["pooled"] = count
}
return stats
}
// Close 关闭数据库
func (d *DB) Close() error {
if d.db != nil {
return d.db.Close()
}
return nil
}

View File

@@ -0,0 +1,190 @@
package invite
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"codex-pool/internal/client"
)
// DefaultProxy 默认代理
const DefaultProxy = "http://127.0.0.1:7890"
// TeamInviter Team 邀请器
type TeamInviter struct {
client *client.TLSClient
accessToken string
accountID string
}
// InviteRequest 邀请请求
type InviteRequest struct {
EmailAddresses []string `json:"email_addresses"`
Role string `json:"role"`
ResendEmails bool `json:"resend_emails"`
}
// AccountCheckResponse 账号检查响应
type AccountCheckResponse struct {
Accounts map[string]struct {
Account struct {
PlanType string `json:"plan_type"`
} `json:"account"`
} `json:"accounts"`
}
// New 创建邀请器 (使用默认代理)
func New(accessToken string) *TeamInviter {
c, _ := client.New(DefaultProxy)
return &TeamInviter{
client: c,
accessToken: accessToken,
}
}
// NewWithProxy 创建邀请器 (指定代理)
func NewWithProxy(accessToken, proxy string) *TeamInviter {
c, _ := client.New(proxy)
return &TeamInviter{
client: c,
accessToken: accessToken,
}
}
// GetAccountID 获取 Team 的 account_id (workspace_id)
func (t *TeamInviter) GetAccountID() (string, error) {
req, _ := http.NewRequest("GET", "https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27", nil)
req.Header.Set("Authorization", "Bearer "+t.accessToken)
req.Header.Set("Accept", "application/json")
resp, err := t.client.Do(req)
if err != nil {
return "", fmt.Errorf("请求失败: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)[:min(200, len(body))])
}
var result AccountCheckResponse
if err := json.Unmarshal(body, &result); err != nil {
return "", fmt.Errorf("解析失败: %v", err)
}
// 查找 team plan 的 account_id
for accountID, info := range result.Accounts {
if accountID != "default" && info.Account.PlanType == "team" {
t.accountID = accountID
return accountID, nil
}
}
// 如果没找到 team返回第一个非 default 的
for accountID := range result.Accounts {
if accountID != "default" {
t.accountID = accountID
return accountID, nil
}
}
return "", fmt.Errorf("未找到 account_id")
}
// SendInvites 发送邀请
func (t *TeamInviter) SendInvites(emails []string) error {
if t.accountID == "" {
return fmt.Errorf("未设置 account_id请先调用 GetAccountID()")
}
url := fmt.Sprintf("https://chatgpt.com/backend-api/accounts/%s/invites", t.accountID)
payload := InviteRequest{
EmailAddresses: emails,
Role: "standard-user",
ResendEmails: true,
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", url, bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+t.accessToken)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Chatgpt-Account-Id", t.accountID)
resp, err := t.client.Do(req)
if err != nil {
return fmt.Errorf("请求失败: %v", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 && resp.StatusCode != 201 {
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody)[:min(200, len(respBody))])
}
return nil
}
// GetPendingInvites 获取待处理的邀请列表
func (t *TeamInviter) GetPendingInvites() ([]string, error) {
if t.accountID == "" {
return nil, fmt.Errorf("未设置 account_id")
}
url := fmt.Sprintf("https://chatgpt.com/backend-api/accounts/%s/invites?offset=0&limit=100&query=", t.accountID)
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+t.accessToken)
resp, err := t.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var result struct {
Invites []struct {
Email string `json:"email"`
} `json:"invites"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, err
}
var emails []string
for _, inv := range result.Invites {
emails = append(emails, inv.Email)
}
return emails, nil
}
// AcceptInvite 接受邀请 (使用被邀请账号的 token)
func AcceptInvite(inviteLink string, accessToken string) error {
c, _ := client.New(DefaultProxy)
req, _ := http.NewRequest("GET", inviteLink, nil)
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := c.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 && resp.StatusCode != 302 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)[:min(100, len(body))])
}
return nil
}

View File

@@ -0,0 +1,140 @@
package logger
import (
"fmt"
"sync"
"time"
)
// LogEntry 日志条目
type LogEntry struct {
Timestamp time.Time `json:"timestamp"`
Level string `json:"level"`
Message string `json:"message"`
Email string `json:"email,omitempty"`
Module string `json:"module,omitempty"`
}
// 日志存储
var (
logs = make([]LogEntry, 0, 1000)
logsMu sync.RWMutex
listeners = make(map[string]chan LogEntry)
listMu sync.RWMutex
)
// AddListener 添加日志监听器
func AddListener(id string) chan LogEntry {
listMu.Lock()
defer listMu.Unlock()
ch := make(chan LogEntry, 100)
listeners[id] = ch
return ch
}
// RemoveListener 移除日志监听器
func RemoveListener(id string) {
listMu.Lock()
defer listMu.Unlock()
if ch, ok := listeners[id]; ok {
close(ch)
delete(listeners, id)
}
}
// broadcast 广播日志
func broadcast(entry LogEntry) {
listMu.RLock()
defer listMu.RUnlock()
for _, ch := range listeners {
select {
case ch <- entry:
default:
}
}
}
// log 记录日志
func log(level, message, email, module string) {
entry := LogEntry{
Timestamp: time.Now(),
Level: level,
Message: message,
Email: email,
Module: module,
}
logsMu.Lock()
if len(logs) >= 1000 {
logs = logs[100:]
}
logs = append(logs, entry)
logsMu.Unlock()
broadcast(entry)
// 打印到控制台
prefix := ""
switch level {
case "info":
prefix = "[INFO]"
case "success":
prefix = "[SUCCESS]"
case "error":
prefix = "[ERROR]"
case "warning":
prefix = "[WARN]"
}
if email != "" {
fmt.Printf("%s [%s] %s - %s\n", prefix, module, email, message)
} else {
fmt.Printf("%s [%s] %s\n", prefix, module, message)
}
}
// Info 记录信息日志
func Info(message, email, module string) {
log("info", message, email, module)
}
// Success 记录成功日志
func Success(message, email, module string) {
log("success", message, email, module)
}
// Error 记录错误日志
func Error(message, email, module string) {
log("error", message, email, module)
}
// Warning 记录警告日志
func Warning(message, email, module string) {
log("warning", message, email, module)
}
// GetLogs 获取日志
func GetLogs(limit int) []LogEntry {
logsMu.RLock()
defer logsMu.RUnlock()
if limit <= 0 || limit > len(logs) {
limit = len(logs)
}
start := len(logs) - limit
if start < 0 {
start = 0
}
result := make([]LogEntry, limit)
copy(result, logs[start:])
return result
}
// ClearLogs 清空日志
func ClearLogs() {
logsMu.Lock()
defer logsMu.Unlock()
logs = make([]LogEntry, 0, 1000)
}

View File

@@ -0,0 +1,455 @@
package mail
import (
"bytes"
"encoding/json"
"fmt"
"math/rand"
"net/http"
"regexp"
"strings"
"sync"
"time"
"codex-pool/internal/config"
)
// 默认邮箱配置
var defaultMailServices = []config.MailServiceConfig{
{
Name: "esyteam",
APIBase: "https://mail.esyteam.edu.kg",
APIToken: "005d6f3e-5312-4c37-8125-e1f71243e1ba",
Domain: "esyteam.edu.kg",
EmailPath: "/api/public/emailList",
AddUserAPI: "/api/public/addUser",
},
}
// 全局变量
var (
currentMailServices []config.MailServiceConfig
mailServicesMutex sync.RWMutex
currentServiceIndex int
)
func init() {
currentMailServices = defaultMailServices
}
// Init 初始化邮箱服务配置
func Init(services []config.MailServiceConfig) {
mailServicesMutex.Lock()
defer mailServicesMutex.Unlock()
if len(services) > 0 {
for i := range services {
if services[i].EmailPath == "" {
services[i].EmailPath = "/api/public/emailList"
}
if services[i].AddUserAPI == "" {
services[i].AddUserAPI = "/api/public/addUser"
}
if services[i].Name == "" {
services[i].Name = fmt.Sprintf("mail-service-%d", i+1)
}
}
currentMailServices = services
fmt.Printf("[邮箱] 已加载 %d 个邮箱服务配置:\n", len(services))
for _, s := range services {
fmt.Printf(" - %s (%s) @ %s\n", s.Name, s.Domain, s.APIBase)
}
} else {
currentMailServices = defaultMailServices
fmt.Println("[邮箱] 使用默认邮箱服务配置")
}
currentServiceIndex = 0
}
// GetServices 获取当前邮箱服务配置
func GetServices() []config.MailServiceConfig {
mailServicesMutex.RLock()
defer mailServicesMutex.RUnlock()
return currentMailServices
}
// GetNextService 轮询获取下一个邮箱服务
func GetNextService() config.MailServiceConfig {
mailServicesMutex.Lock()
defer mailServicesMutex.Unlock()
if len(currentMailServices) == 0 {
return defaultMailServices[0]
}
service := currentMailServices[currentServiceIndex]
currentServiceIndex = (currentServiceIndex + 1) % len(currentMailServices)
return service
}
// GetRandomService 随机获取一个邮箱服务
func GetRandomService() config.MailServiceConfig {
mailServicesMutex.RLock()
defer mailServicesMutex.RUnlock()
if len(currentMailServices) == 0 {
return defaultMailServices[0]
}
return currentMailServices[rand.Intn(len(currentMailServices))]
}
// GetServiceByDomain 根据域名获取对应的邮箱服务
func GetServiceByDomain(domain string) *config.MailServiceConfig {
mailServicesMutex.RLock()
defer mailServicesMutex.RUnlock()
for _, s := range currentMailServices {
if s.Domain == domain || strings.HasSuffix(domain, "."+s.Domain) {
return &s
}
}
return nil
}
// ==================== 邮件结构 ====================
// EmailListRequest 邮件列表请求
type EmailListRequest struct {
ToEmail string `json:"toEmail"`
TimeSort string `json:"timeSort"`
Size int `json:"size"`
}
// EmailListResponse 邮件列表响应
type EmailListResponse struct {
Code int `json:"code"`
Data []EmailItem `json:"data"`
}
// EmailItem 邮件项
type EmailItem struct {
Content string `json:"content"`
Text string `json:"text"`
Subject string `json:"subject"`
}
// AddUserRequest 创建用户请求
type AddUserRequest struct {
List []AddUserItem `json:"list"`
}
// AddUserItem 用户项
type AddUserItem struct {
Email string `json:"email"`
Password string `json:"password"`
}
// AddUserResponse 创建用户响应
type AddUserResponse struct {
Code int `json:"code"`
Message string `json:"message"`
}
// ==================== 邮箱生成 ====================
// GenerateEmail 生成随机邮箱并在邮件系统中创建
func GenerateEmail() string {
return GenerateEmailWithService(GetNextService())
}
// GenerateEmailWithService 使用指定服务生成随机邮箱
func GenerateEmailWithService(service config.MailServiceConfig) string {
const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
b := make([]byte, 10)
for i := range b {
b[i] = charset[rand.Intn(len(charset))]
}
email := string(b) + "@" + service.Domain
if err := CreateMailboxWithService(email, service); err != nil {
fmt.Printf(" [!] 创建邮箱失败 (%s): %v (继续尝试)\n", service.Name, err)
}
return email
}
// CreateMailbox 在邮件系统中创建邮箱
func CreateMailbox(email string) error {
parts := strings.Split(email, "@")
if len(parts) != 2 {
return fmt.Errorf("无效的邮箱地址: %s", email)
}
domain := parts[1]
service := GetServiceByDomain(domain)
if service == nil {
services := GetServices()
if len(services) > 0 {
service = &services[0]
} else {
return fmt.Errorf("没有可用的邮箱服务")
}
}
return CreateMailboxWithService(email, *service)
}
// CreateMailboxWithService 使用指定服务在邮件系统中创建邮箱
func CreateMailboxWithService(email string, service config.MailServiceConfig) error {
client := &http.Client{Timeout: 10 * time.Second}
parts := strings.Split(email, "@")
if len(parts) == 2 {
domain := parts[1]
if strings.HasSuffix(domain, "."+service.Domain) {
email = parts[0] + "@" + service.Domain
}
}
payload := AddUserRequest{
List: []AddUserItem{
{Email: email, Password: GeneratePassword()},
},
}
jsonData, _ := json.Marshal(payload)
req, err := http.NewRequest("POST", service.APIBase+service.AddUserAPI, bytes.NewBuffer(jsonData))
if err != nil {
return err
}
req.Header.Set("Authorization", service.APIToken)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
var result AddUserResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return err
}
if result.Code != 200 {
if strings.Contains(result.Message, "exist") {
return nil
}
return fmt.Errorf("API 错误: %s", result.Message)
}
return nil
}
// GeneratePassword 生成随机密码
func GeneratePassword() string {
const (
upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
lower = "abcdefghijklmnopqrstuvwxyz"
digits = "0123456789"
special = "#$%@!"
)
password := make([]byte, 12)
password[0] = upper[rand.Intn(len(upper))]
password[1] = lower[rand.Intn(len(lower))]
password[10] = digits[rand.Intn(len(digits))]
password[11] = special[rand.Intn(len(special))]
charset := upper + lower
for i := 2; i < 10; i++ {
password[i] = charset[rand.Intn(len(charset))]
}
return string(password)
}
// ==================== 邮件客户端 ====================
// Client 邮件客户端
type Client struct {
client *http.Client
service *config.MailServiceConfig
}
// NewClient 创建邮件客户端
func NewClient() *Client {
services := GetServices()
var service *config.MailServiceConfig
if len(services) > 0 {
service = &services[0]
} else {
service = &defaultMailServices[0]
}
return &Client{
client: &http.Client{Timeout: 10 * time.Second},
service: service,
}
}
// NewClientWithService 创建指定服务的邮件客户端
func NewClientWithService(service config.MailServiceConfig) *Client {
return &Client{
client: &http.Client{Timeout: 10 * time.Second},
service: &service,
}
}
// NewClientForEmail 根据邮箱地址创建对应的邮件客户端
func NewClientForEmail(email string) *Client {
parts := strings.Split(email, "@")
if len(parts) == 2 {
if service := GetServiceByDomain(parts[1]); service != nil {
return NewClientWithService(*service)
}
}
return NewClient()
}
// GetEmails 获取邮件列表
func (m *Client) GetEmails(email string, size int) ([]EmailItem, error) {
service := m.service
parts := strings.Split(email, "@")
if len(parts) == 2 {
if s := GetServiceByDomain(parts[1]); s != nil {
service = s
}
}
url := service.APIBase + service.EmailPath
payload := EmailListRequest{
ToEmail: email,
TimeSort: "desc",
Size: size,
}
jsonData, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
req.Header.Set("Authorization", service.APIToken)
req.Header.Set("Content-Type", "application/json")
resp, err := m.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
var result EmailListResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
if result.Code != 200 {
return nil, fmt.Errorf("API 错误: %d", result.Code)
}
return result.Data, nil
}
// WaitForCode 等待验证码邮件
func (m *Client) WaitForCode(email string, timeout time.Duration) (string, error) {
start := time.Now()
codeRegex := regexp.MustCompile(`\b(\d{6})\b`)
for time.Since(start) < timeout {
emails, err := m.GetEmails(email, 10)
if err == nil {
for _, mail := range emails {
subject := strings.ToLower(mail.Subject)
// 匹配多种可能的验证码邮件主题
isCodeEmail := strings.Contains(subject, "code") ||
strings.Contains(subject, "verify") ||
strings.Contains(subject, "verification") ||
strings.Contains(subject, "openai") ||
strings.Contains(subject, "confirm")
if !isCodeEmail {
continue
}
content := mail.Content
if content == "" {
content = mail.Text
}
matches := codeRegex.FindStringSubmatch(content)
if len(matches) >= 2 {
return matches[1], nil
}
}
}
time.Sleep(2 * time.Second)
}
return "", fmt.Errorf("验证码获取超时")
}
// WaitForInviteLink 等待邀请邮件并提取链接
func (m *Client) WaitForInviteLink(email string, timeout time.Duration) (string, error) {
start := time.Now()
for time.Since(start) < timeout {
emails, err := m.GetEmails(email, 10)
if err == nil {
for _, mail := range emails {
content := mail.Content
if content == "" {
content = mail.Text
}
if strings.Contains(mail.Subject, "invite") ||
strings.Contains(mail.Subject, "Team") ||
strings.Contains(mail.Subject, "ChatGPT") ||
strings.Contains(content, "invite") {
link := extractInviteLink(content)
if link != "" {
return link, nil
}
}
}
}
time.Sleep(2 * time.Second)
}
return "", fmt.Errorf("等待邀请邮件超时")
}
// extractInviteLink 从邮件内容提取邀请链接
func extractInviteLink(content string) string {
patterns := []string{
`https://chatgpt\.com/invite/[^\s"'<>]+`,
`https://chat\.openai\.com/invite/[^\s"'<>]+`,
`https://chatgpt\.com/[^\s"'<>]*accept[^\s"'<>]*`,
}
for _, pattern := range patterns {
re := regexp.MustCompile(pattern)
match := re.FindString(content)
if match != "" {
match = strings.ReplaceAll(match, "&amp;", "&")
return match
}
}
return ""
}
// ==================== 便捷函数 ====================
// WaitForInviteEmail 等待邀请邮件
func WaitForInviteEmail(email string, timeout time.Duration) (string, error) {
client := NewClientForEmail(email)
return client.WaitForInviteLink(email, timeout)
}
// GetVerificationCode 获取验证码
func GetVerificationCode(email string, timeout time.Duration) (string, error) {
client := NewClientForEmail(email)
return client.WaitForCode(email, timeout)
}

View File

@@ -0,0 +1,415 @@
package register
import (
"bytes"
"encoding/json"
"fmt"
"math/rand"
"net/http"
"net/url"
"strings"
"time"
"codex-pool/internal/client"
"codex-pool/internal/mail"
)
// ChatGPTReg ChatGPT 注册器
type ChatGPTReg struct {
Proxy string
Client *client.TLSClient
AuthSessionLoggingID string
OAIDid string
CSRFToken string
AuthorizeURL string
AccessToken string
}
// Result 注册结果
type Result struct {
Email string `json:"email"`
Password string `json:"password"`
Name string `json:"name"`
AccessToken string `json:"access_token"`
}
// New 创建注册器
func New(proxy string) (*ChatGPTReg, error) {
c, err := client.New(proxy)
if err != nil {
return nil, err
}
return &ChatGPTReg{
Proxy: proxy,
Client: c,
AuthSessionLoggingID: GenerateUUID(),
}, nil
}
// InitSession 初始化会话
func (r *ChatGPTReg) InitSession() error {
resp, err := r.Client.Get("https://chatgpt.com")
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("初始化失败,状态码: %d", resp.StatusCode)
}
r.OAIDid = r.Client.GetCookie("https://chatgpt.com", "oai-did")
csrfCookie := r.Client.GetCookie("https://chatgpt.com", "__Host-next-auth.csrf-token")
if csrfCookie != "" {
decoded, err := url.QueryUnescape(csrfCookie)
if err == nil {
parts := strings.Split(decoded, "|")
if len(parts) > 0 {
r.CSRFToken = parts[0]
}
}
}
if r.CSRFToken == "" {
return fmt.Errorf("无法获取 CSRF token")
}
loginURL := fmt.Sprintf("https://chatgpt.com/auth/login?openaicom-did=%s", r.OAIDid)
loginResp, err := r.Client.Get(loginURL)
if err != nil {
return err
}
defer loginResp.Body.Close()
return nil
}
// GetAuthorizeURL 获取授权 URL
func (r *ChatGPTReg) GetAuthorizeURL(email string) error {
loginURL := fmt.Sprintf(
"https://chatgpt.com/api/auth/signin/openai?prompt=login&ext-oai-did=%s&auth_session_logging_id=%s&screen_hint=login_or_signup&login_hint=%s",
r.OAIDid,
r.AuthSessionLoggingID,
url.QueryEscape(email),
)
data := url.Values{}
data.Set("callbackUrl", "https://chatgpt.com/")
data.Set("csrfToken", r.CSRFToken)
data.Set("json", "true")
req, err := http.NewRequest("POST", loginURL, strings.NewReader(data.Encode()))
if err != nil {
return err
}
req.Header.Set("Origin", "https://chatgpt.com")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := r.Client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return err
}
if authURL, ok := result["url"].(string); ok && strings.Contains(authURL, "auth.openai.com") {
r.AuthorizeURL = authURL
return nil
}
return fmt.Errorf("无法获取授权 URL")
}
// StartAuthorize 开始授权流程
func (r *ChatGPTReg) StartAuthorize() error {
resp, err := r.Client.Get(r.AuthorizeURL)
if err != nil {
return err
}
defer resp.Body.Close()
finalURL := resp.Request.URL.String()
if strings.Contains(finalURL, "create-account") || strings.Contains(finalURL, "log-in") {
return nil
}
return fmt.Errorf("授权流程启动失败")
}
// Register 注册账户
func (r *ChatGPTReg) Register(email, password string) error {
payload := map[string]string{
"password": password,
"username": email,
}
jsonData, _ := json.Marshal(payload)
req, err := http.NewRequest("POST", "https://auth.openai.com/api/accounts/user/register", bytes.NewReader(jsonData))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Origin", "https://auth.openai.com")
resp, err := r.Client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := client.ReadBodyString(resp)
return fmt.Errorf("注册失败,状态码: %d, 响应: %s", resp.StatusCode, truncateStr(body, 200))
}
return nil
}
// SendVerificationEmail 发送验证邮件
func (r *ChatGPTReg) SendVerificationEmail() error {
resp, err := r.Client.Get("https://auth.openai.com/api/accounts/email-otp/send")
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("发送验证邮件失败,状态码: %d", resp.StatusCode)
}
return nil
}
// ValidateOTP 验证 OTP
func (r *ChatGPTReg) ValidateOTP(code string) error {
payload := map[string]string{"code": code}
jsonData, _ := json.Marshal(payload)
req, err := http.NewRequest("POST", "https://auth.openai.com/api/accounts/email-otp/validate", bytes.NewReader(jsonData))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Origin", "https://auth.openai.com")
resp, err := r.Client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("OTP 验证失败,状态码: %d", resp.StatusCode)
}
return nil
}
// CreateAccount 创建账户
func (r *ChatGPTReg) CreateAccount(name, birthdate string) error {
payload := map[string]string{
"name": name,
"birthdate": birthdate,
}
jsonData, _ := json.Marshal(payload)
req, err := http.NewRequest("POST", "https://auth.openai.com/api/accounts/create_account", bytes.NewReader(jsonData))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Origin", "https://auth.openai.com")
resp, err := r.Client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("创建账户失败,状态码: %d", resp.StatusCode)
}
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err == nil {
if continueURL, ok := result["continue_url"].(string); ok && continueURL != "" {
contResp, err := r.Client.Get(continueURL)
if err == nil {
contResp.Body.Close()
}
}
}
return nil
}
// GetSessionToken 获取 access token
func (r *ChatGPTReg) GetSessionToken() error {
resp, err := r.Client.Get("https://chatgpt.com/api/auth/session")
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("获取 session 失败,状态码: %d", resp.StatusCode)
}
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return err
}
if token, ok := result["accessToken"].(string); ok {
r.AccessToken = token
return nil
}
return fmt.Errorf("响应中没有 accessToken")
}
// Run 完整的注册流程
func Run(email, password, name, birthdate, proxy string) (*ChatGPTReg, error) {
return RunWithRetry(email, password, name, birthdate, proxy, 3)
}
// RunWithRetry 带重试的注册流程
// 当验证码获取超过5秒就换新邮箱重新注册
func RunWithRetry(email, password, name, birthdate, proxy string, maxRetries int) (*ChatGPTReg, error) {
for attempt := 0; attempt < maxRetries; attempt++ {
if attempt > 0 {
// 重试时生成新邮箱
email = mail.GenerateEmail()
password = GeneratePassword()
fmt.Printf(" [Retry %d] New email: %s\n", attempt, email)
}
reg, err := runOnce(email, password, name, birthdate, proxy)
if err == nil {
return reg, nil
}
// 如果不是验证码超时错误,直接返回
if !strings.Contains(err.Error(), "验证码获取超时") {
return nil, err
}
fmt.Printf(" [!] OTP timeout, retrying with new email...\n")
}
return nil, fmt.Errorf("注册失败: 已重试 %d 次", maxRetries)
}
// runOnce 执行一次注册流程(使用短超时获取验证码)
func runOnce(email, password, name, birthdate, proxy string) (*ChatGPTReg, error) {
reg, err := New(proxy)
if err != nil {
return nil, err
}
// 初始化
if err := reg.InitSession(); err != nil {
return nil, fmt.Errorf("初始化失败: %v", err)
}
if err := reg.GetAuthorizeURL(email); err != nil {
return nil, fmt.Errorf("获取授权URL失败: %v", err)
}
if err := reg.StartAuthorize(); err != nil {
return nil, fmt.Errorf("启动授权失败: %v", err)
}
// 注册
if err := reg.Register(email, password); err != nil {
return nil, fmt.Errorf("注册失败: %v", err)
}
if err := reg.SendVerificationEmail(); err != nil {
return nil, fmt.Errorf("发送邮件失败: %v", err)
}
// 先用5秒超时尝试获取验证码
otpCode, err := mail.GetVerificationCode(email, 5*time.Second)
if err != nil {
// 5秒内没获取到再等120秒总共等待更多时间
otpCode, err = mail.GetVerificationCode(email, 120*time.Second)
if err != nil {
return nil, fmt.Errorf("验证码获取超时")
}
}
if err := reg.ValidateOTP(otpCode); err != nil {
return nil, fmt.Errorf("OTP验证失败: %v", err)
}
// 创建账户
if err := reg.CreateAccount(name, birthdate); err != nil {
return nil, fmt.Errorf("创建账户失败: %v", err)
}
// 获取 Token
_ = reg.GetSessionToken()
return reg, nil
}
// ==================== 工具函数 ====================
// GenerateName 生成随机姓名
func GenerateName() string {
firstNames := []string{"James", "John", "Robert", "Michael", "David", "William", "Richard", "Joseph", "Thomas", "Charles"}
lastNames := []string{"Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller", "Davis", "Rodriguez", "Martinez"}
return firstNames[rand.Intn(len(firstNames))] + " " + lastNames[rand.Intn(len(lastNames))]
}
// GenerateUUID 生成 UUID
func GenerateUUID() string {
b := make([]byte, 16)
rand.Read(b)
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}
// GenerateBirthdate 生成随机生日
func GenerateBirthdate() string {
year := 2000 + rand.Intn(5)
month := 1 + rand.Intn(12)
day := 1 + rand.Intn(28)
return fmt.Sprintf("%d-%02d-%02d", year, month, day)
}
// GeneratePassword 生成随机密码
func GeneratePassword() string {
const (
upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
lower = "abcdefghijklmnopqrstuvwxyz"
digits = "0123456789"
special = "!@#$%"
)
b := make([]byte, 13)
for i := 0; i < 2; i++ {
b[i] = upper[rand.Intn(len(upper))]
}
for i := 2; i < 10; i++ {
b[i] = lower[rand.Intn(len(lower))]
}
for i := 10; i < 12; i++ {
b[i] = digits[rand.Intn(len(digits))]
}
b[12] = special[rand.Intn(len(special))]
return string(b)
}
func truncateStr(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}

View File

@@ -0,0 +1,18 @@
//go:build !embed
// +build !embed
package web
import (
"net/http"
)
// GetFileSystem 返回 nil开发模式不嵌入前端
func GetFileSystem() http.FileSystem {
return nil
}
// IsEmbedded 返回前端是否已嵌入
func IsEmbedded() bool {
return false
}

View File

@@ -0,0 +1,27 @@
//go:build embed
// +build embed
package web
import (
"embed"
"io/fs"
"net/http"
)
//go:embed dist/*
var distFS embed.FS
// GetFileSystem 返回嵌入的前端文件系统
func GetFileSystem() http.FileSystem {
sub, err := fs.Sub(distFS, "dist")
if err != nil {
panic(err)
}
return http.FS(sub)
}
// IsEmbedded 返回前端是否已嵌入
func IsEmbedded() bool {
return true
}

40
docker-compose.yml Normal file
View File

@@ -0,0 +1,40 @@
# ===========================================
# Codex Pool - Docker Compose 配置
# ===========================================
# 使用方式:
# docker-compose up -d # 启动服务
# docker-compose logs -f # 查看日志
# docker-compose down # 停止服务
# ===========================================
services:
# ===========================================================================
# Codex Pool Application (前后端一体)
# ===========================================================================
codex-pool:
build:
context: .
dockerfile: Dockerfile
image: codex-pool:latest
container_name: codex-pool
restart: unless-stopped
ports:
- "${BIND_HOST:-0.0.0.0}:${PORT:-8848}:8848"
volumes:
# 数据持久化 (配置文件和数据库)
- ./data:/app/data
environment:
- TZ=${TZ:-Asia/Shanghai}
- CONFIG_PATH=/app/data/config.json
networks:
- codex-network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8848/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
networks:
codex-network:
driver: bridge

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

6
frontend/.prettierignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules
dist
build
coverage
*.min.js
*.min.css

10
frontend/.prettierrc Normal file
View File

@@ -0,0 +1,10 @@
{
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100,
"bracketSpacing": true,
"arrowParens": "always",
"endOfLine": "lf"
}

73
frontend/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

76
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,76 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import eslintConfigPrettier from 'eslint-config-prettier'
import eslintPluginPrettier from 'eslint-plugin-prettier'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist', 'node_modules', 'coverage', 'build']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
eslintConfigPrettier,
],
plugins: {
prettier: eslintPluginPrettier,
},
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
rules: {
// Prettier integration - show formatting issues as ESLint errors
'prettier/prettier': 'error',
// TypeScript specific rules
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
},
],
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
// Allow empty interfaces that extend other interfaces (common pattern for component props)
'@typescript-eslint/no-empty-object-type': 'off',
// React specific rules
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
// Allow setState in effects for initialization patterns (common in React for loading from localStorage)
'react-hooks/set-state-in-effect': 'off',
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
// General rules
'no-console': ['warn', { allow: ['warn', 'error'] }],
'prefer-const': 'error',
'no-var': 'error',
},
},
// Configuration for JavaScript files (like config files)
{
files: ['**/*.{js,mjs,cjs}'],
extends: [js.configs.recommended, eslintConfigPrettier],
plugins: {
prettier: eslintPluginPrettier,
},
languageOptions: {
ecmaVersion: 2020,
globals: {
...globals.node,
},
},
rules: {
'prettier/prettier': 'error',
},
},
])

14
frontend/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Codex Pool - 智能账号池管理系统" />
<title>Codex Pool</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5522
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

49
frontend/package.json Normal file
View File

@@ -0,0 +1,49 @@
{
"name": "codex-pool-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write \"src/**/*.{ts,tsx,css,json}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,css,json}\"",
"check": "npm run lint && npm run format:check",
"fix": "npm run lint:fix && npm run format",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.18",
"lucide-react": "^0.563.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.0",
"recharts": "^3.7.0",
"tailwindcss": "^4.1.18"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^24.10.9",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"jsdom": "^27.4.0",
"prettier": "^3.8.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4",
"vitest": "^4.0.18"
}
}

View File

@@ -0,0 +1,21 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="180"
height="180"
viewBox="0 0 180 180"
fill="none"
>
<style>
:root {
fill: #000;
}
@media (prefers-color-scheme: dark) {
:root {
fill: #fff;
}
}
</style>
<path
d="M101.228 164.247C96.2776 164.247 91.5751 163.307 87.1201 161.426C82.6651 159.545 78.7051 156.921 75.2401 153.555C71.4781 154.842 67.5676 155.486 63.5086 155.486C56.8756 155.486 50.7376 153.852 45.0946 150.585C39.4516 147.318 34.8976 142.863 31.4326 137.22C28.0666 131.577 26.3836 125.291 26.3836 118.361C26.3836 115.49 26.7796 112.371 27.5716 109.005C23.6116 105.342 20.5426 101.135 18.3646 96.3828C16.1866 91.5318 15.0976 86.4828 15.0976 81.2358C15.0976 75.8898 16.2361 70.7418 18.5131 65.7918C20.7901 60.8418 23.9581 56.5848 28.0171 53.0208C32.1751 49.3578 36.9766 46.8333 42.4216 45.4473C43.5106 39.8043 45.7876 34.7553 49.2526 30.3003C52.8166 25.7463 57.1726 22.1823 62.3206 19.6083C67.4686 17.0343 72.9631 15.7473 78.8041 15.7473C83.7541 15.7473 88.4566 16.6878 92.9116 18.5688C97.3666 20.4498 101.327 23.0733 104.792 26.4393C108.554 25.1523 112.464 24.5088 116.523 24.5088C123.156 24.5088 129.294 26.1423 134.937 29.4093C140.58 32.6763 145.085 37.1313 148.451 42.7743C151.916 48.4173 153.648 54.7038 153.648 61.6338C153.648 64.5048 153.252 67.6233 152.46 70.9893C156.42 74.6523 159.489 78.9093 161.667 83.7603C163.845 88.5123 164.934 93.5118 164.934 98.7588C164.934 104.105 163.796 109.253 161.519 114.203C159.242 119.153 156.024 123.459 151.866 127.122C147.807 130.686 143.055 133.161 137.61 134.547C136.521 140.19 134.195 145.239 130.631 149.694C127.166 154.248 122.859 157.812 117.711 160.386C112.563 162.96 107.069 164.247 101.228 164.247ZM64.5481 145.685C69.4981 145.685 73.8046 144.645 77.4676 142.566L105.386 126.528C106.376 125.835 106.871 124.895 106.871 123.707V110.936L70.9336 131.577C68.7556 132.864 66.5776 132.864 64.3996 131.577L36.3331 115.391C36.3331 115.688 36.2836 116.034 36.1846 116.43C36.1846 116.826 36.1846 117.42 36.1846 118.212C36.1846 123.261 37.3726 127.914 39.7486 132.171C42.2236 136.329 45.6391 139.596 49.9951 141.972C54.3511 144.447 59.2021 145.685 64.5481 145.685ZM66.0331 121.479C66.6271 121.776 67.1716 121.925 67.6666 121.925C68.1616 121.925 68.6566 121.776 69.1516 121.479L80.2891 115.094L44.5006 94.3038C42.3226 93.0168 41.2336 91.0863 41.2336 88.5123V56.2878C36.2836 58.4658 32.3236 61.8318 29.3536 66.3858C26.3836 70.8408 24.8986 75.7908 24.8986 81.2358C24.8986 86.0868 26.1361 90.7398 28.6111 95.1948C31.0861 99.6498 34.3036 103.016 38.2636 105.293L66.0331 121.479ZM101.228 154.446C106.475 154.446 111.227 153.258 115.484 150.882C119.741 148.506 123.107 145.239 125.582 141.081C128.057 136.923 129.294 132.27 129.294 127.122V95.0463C129.294 93.8583 128.799 92.9673 127.809 92.3733L116.523 85.8393V127.271C116.523 129.845 115.434 131.775 113.256 133.062L85.1896 149.249C90.0406 152.714 95.3866 154.446 101.228 154.446ZM106.871 100.095V79.8993L90.09 70.3953L73.1611 79.8993V100.095L90.09 109.599L106.871 100.095ZM63.5086 52.7238C63.5086 50.1498 64.5976 48.2193 66.7756 46.9323L94.8421 30.7458C89.9911 27.2808 84.6451 25.5483 78.8041 25.5483C73.5571 25.5483 68.8051 26.7363 64.5481 29.1123C60.2911 31.4883 56.9251 34.7553 54.4501 38.9133C52.0741 43.0713 50.8861 47.7243 50.8861 52.8723V84.7998C50.8861 85.9878 51.3811 86.9283 52.3711 87.6213L63.5086 94.1553V52.7238ZM138.947 123.707C143.897 121.529 147.807 118.163 150.678 113.609C153.648 109.055 155.133 104.105 155.133 98.7588C155.133 93.9078 153.896 89.2548 151.421 84.7998C148.946 80.3448 145.728 76.9788 141.768 74.7018L113.999 58.6638C113.405 58.2678 112.86 58.1193 112.365 58.2183C111.87 58.2183 111.375 58.3668 110.88 58.6638L99.7426 64.9008L135.68 85.8393C136.769 86.4333 137.561 87.2253 138.056 88.2153C138.65 89.1063 138.947 90.1953 138.947 91.4823V123.707ZM109.098 48.2688C111.276 46.8828 113.454 46.8828 115.632 48.2688L143.847 64.7523C143.847 64.0593 143.847 63.1683 143.847 62.0793C143.847 57.3273 142.659 52.8228 140.283 48.5658C138.006 44.2098 134.69 40.7448 130.334 38.1708C126.077 35.5968 121.127 34.3098 115.484 34.3098C110.534 34.3098 106.227 35.3493 102.564 37.4283L74.6461 53.4663C73.6561 54.1593 73.1611 55.0998 73.1611 56.2878V69.0588L109.098 48.2688Z"
/>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

1
frontend/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

28
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,28 @@
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, TeamProcess } from './pages'
function App() {
return (
<ConfigProvider>
<RecordsProvider>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Dashboard />} />
<Route path="upload" element={<Upload />} />
<Route path="records" element={<Records />} />
<Route path="accounts" element={<Accounts />} />
<Route path="monitor" element={<Monitor />} />
<Route path="team" element={<TeamProcess />} />
<Route path="config" element={<Config />} />
<Route path="config/s2a" element={<S2AConfig />} />
<Route path="config/email" element={<EmailConfig />} />
</Route>
</Routes>
</RecordsProvider>
</ConfigProvider>
)
}
export default App

View File

@@ -0,0 +1,294 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { ChatGPTClient, getStatusText, getStatusColor, createChatGPTClient } from './chatgpt'
import type { AccountInput } from '../types'
describe('ChatGPTClient', () => {
let client: ChatGPTClient
let fetchMock: ReturnType<typeof vi.fn>
beforeEach(() => {
client = new ChatGPTClient()
fetchMock = vi.fn()
globalThis.fetch = fetchMock as typeof fetch
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('checkAccount', () => {
it('should return active status for HTTP 200 with account info', async () => {
const mockResponse = {
accounts: [
{
account_id: 'test-account-id',
entitlement: {
subscription_plan: 'plus',
},
},
],
}
fetchMock.mockResolvedValueOnce({
status: 200,
statusText: 'OK',
json: () => Promise.resolve(mockResponse),
})
const result = await client.checkAccount('valid-token')
expect(result.status).toBe('active')
expect(result.accountId).toBe('test-account-id')
expect(result.planType).toBe('plus')
expect(result.error).toBeUndefined()
})
it('should return active status for HTTP 200 without account info', async () => {
const mockResponse = {
accounts: [],
}
fetchMock.mockResolvedValueOnce({
status: 200,
statusText: 'OK',
json: () => Promise.resolve(mockResponse),
})
const result = await client.checkAccount('valid-token')
expect(result.status).toBe('active')
expect(result.accountId).toBeUndefined()
expect(result.planType).toBe('unknown')
})
it('should return token_expired status for HTTP 401', async () => {
fetchMock.mockResolvedValueOnce({
status: 401,
statusText: 'Unauthorized',
json: () => Promise.reject(new Error('No JSON')),
})
const result = await client.checkAccount('expired-token')
expect(result.status).toBe('token_expired')
expect(result.error).toBe('Token 已过期')
})
it('should return banned status for HTTP 403', async () => {
fetchMock.mockResolvedValueOnce({
status: 403,
statusText: 'Forbidden',
json: () => Promise.reject(new Error('No JSON')),
})
const result = await client.checkAccount('banned-token')
expect(result.status).toBe('banned')
expect(result.error).toBe('账号已被封禁')
})
it('should return error status for other HTTP codes', async () => {
fetchMock.mockResolvedValueOnce({
status: 500,
statusText: 'Internal Server Error',
json: () => Promise.reject(new Error('No JSON')),
})
const result = await client.checkAccount('some-token')
expect(result.status).toBe('error')
expect(result.error).toBe('HTTP 500: Internal Server Error')
})
it('should return error status for network errors', async () => {
fetchMock.mockRejectedValueOnce(new Error('Network error'))
const result = await client.checkAccount('some-token')
expect(result.status).toBe('error')
expect(result.error).toBe('Network error')
})
it('should return error status for empty token', async () => {
const result = await client.checkAccount('')
expect(result.status).toBe('error')
expect(result.error).toBe('缺少 token')
expect(fetchMock).not.toHaveBeenCalled()
})
it('should return error status for whitespace-only token', async () => {
const result = await client.checkAccount(' ')
expect(result.status).toBe('error')
expect(result.error).toBe('缺少 token')
expect(fetchMock).not.toHaveBeenCalled()
})
it('should use correct API endpoint and headers', async () => {
fetchMock.mockResolvedValueOnce({
status: 200,
statusText: 'OK',
json: () => Promise.resolve({ accounts: [] }),
})
await client.checkAccount('test-token')
expect(fetchMock).toHaveBeenCalledWith('/api/chatgpt/accounts/check/v4-2023-04-27', {
method: 'GET',
headers: {
Authorization: 'Bearer test-token',
'Content-Type': 'application/json',
},
})
})
it('should handle JSON parse errors gracefully for HTTP 200', async () => {
fetchMock.mockResolvedValueOnce({
status: 200,
statusText: 'OK',
json: () => Promise.reject(new Error('Invalid JSON')),
})
const result = await client.checkAccount('valid-token')
// Should still return active since HTTP 200
expect(result.status).toBe('active')
expect(result.planType).toBe('unknown')
})
})
describe('batchCheck', () => {
it('should return empty array for empty input', async () => {
const results = await client.batchCheck([], { concurrency: 5 })
expect(results).toEqual([])
expect(fetchMock).not.toHaveBeenCalled()
})
it('should check all accounts and return results in order', async () => {
const accounts: AccountInput[] = [
{ account: 'user1@test.com', password: 'pass1', token: 'token1' },
{ account: 'user2@test.com', password: 'pass2', token: 'token2' },
{ account: 'user3@test.com', password: 'pass3', token: 'token3' },
]
// Mock responses for each account
fetchMock
.mockResolvedValueOnce({
status: 200,
statusText: 'OK',
json: () =>
Promise.resolve({
accounts: [{ account_id: 'id1', entitlement: { subscription_plan: 'plus' } }],
}),
})
.mockResolvedValueOnce({
status: 401,
statusText: 'Unauthorized',
})
.mockResolvedValueOnce({
status: 403,
statusText: 'Forbidden',
})
const results = await client.batchCheck(accounts, { concurrency: 3 })
expect(results).toHaveLength(3)
expect(results[0].status).toBe('active')
expect(results[0].accountId).toBe('id1')
expect(results[1].status).toBe('token_expired')
expect(results[2].status).toBe('banned')
})
it('should call onProgress callback for each account', async () => {
const accounts: AccountInput[] = [
{ account: 'user1@test.com', password: 'pass1', token: 'token1' },
{ account: 'user2@test.com', password: 'pass2', token: 'token2' },
]
fetchMock
.mockResolvedValueOnce({
status: 200,
statusText: 'OK',
json: () => Promise.resolve({ accounts: [] }),
})
.mockResolvedValueOnce({
status: 200,
statusText: 'OK',
json: () => Promise.resolve({ accounts: [] }),
})
const onProgress = vi.fn()
await client.batchCheck(accounts, { concurrency: 2, onProgress })
expect(onProgress).toHaveBeenCalledTimes(2)
})
it('should respect concurrency limit', async () => {
const accounts: AccountInput[] = Array.from({ length: 10 }, (_, i) => ({
account: `user${i}@test.com`,
password: `pass${i}`,
token: `token${i}`,
}))
let maxConcurrent = 0
let currentConcurrent = 0
fetchMock.mockImplementation(async () => {
currentConcurrent++
maxConcurrent = Math.max(maxConcurrent, currentConcurrent)
// Simulate network delay
await new Promise((resolve) => setTimeout(resolve, 10))
currentConcurrent--
return {
status: 200,
statusText: 'OK',
json: () => Promise.resolve({ accounts: [] }),
}
})
await client.batchCheck(accounts, { concurrency: 3 })
// Max concurrent should not exceed the concurrency limit
expect(maxConcurrent).toBeLessThanOrEqual(3)
})
})
})
describe('getStatusText', () => {
it('should return correct Chinese text for each status', () => {
expect(getStatusText('pending')).toBe('待检查')
expect(getStatusText('checking')).toBe('检查中')
expect(getStatusText('active')).toBe('正常')
expect(getStatusText('banned')).toBe('封禁')
expect(getStatusText('token_expired')).toBe('过期')
expect(getStatusText('error')).toBe('错误')
})
})
describe('getStatusColor', () => {
it('should return correct color for each status', () => {
expect(getStatusColor('pending')).toBe('gray')
expect(getStatusColor('checking')).toBe('blue')
expect(getStatusColor('active')).toBe('green')
expect(getStatusColor('banned')).toBe('red')
expect(getStatusColor('token_expired')).toBe('orange')
expect(getStatusColor('error')).toBe('yellow')
})
})
describe('createChatGPTClient', () => {
it('should create a new ChatGPTClient instance', () => {
const client = createChatGPTClient()
expect(client).toBeInstanceOf(ChatGPTClient)
})
it('should create a client with custom base URL', () => {
const client = createChatGPTClient('https://custom.api.com')
expect(client).toBeInstanceOf(ChatGPTClient)
})
})

253
frontend/src/api/chatgpt.ts Normal file
View File

@@ -0,0 +1,253 @@
import type { AccountInput, AccountStatus, CheckedAccount, CheckResult } from '../types'
import type { ChatGPTCheckResponse } from './types'
/**
* ChatGPT API 检查端点
* 通过 nginx 代理访问,避免 CORS 问题
* 原始 API: https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27
*/
const CHATGPT_CHECK_API = '/api/chatgpt/accounts/check/v4-2023-04-27'
/**
* HTTP 状态码到账号状态的映射
* 根据 requirements.md A3 定义:
* - HTTP 200 → active (账号正常)
* - HTTP 401 → token_expired (Token 已过期)
* - HTTP 403 → banned (账号被封禁)
* - 其他 → error (网络错误等)
*/
function mapHttpStatusToAccountStatus(httpStatus: number): AccountStatus {
switch (httpStatus) {
case 200:
return 'active'
case 401:
return 'token_expired'
case 403:
return 'banned'
default:
return 'error'
}
}
/**
* 获取 HTTP 状态码对应的错误消息
*/
function getErrorMessageForStatus(httpStatus: number, statusText: string): string | undefined {
switch (httpStatus) {
case 200:
return undefined
case 401:
return 'Token 已过期'
case 403:
return '账号已被封禁'
default:
return `HTTP ${httpStatus}: ${statusText}`
}
}
/**
* ChatGPT API 客户端
* 用于检查 ChatGPT 账号状态
*/
export class ChatGPTClient {
private baseUrl: string
constructor(baseUrl: string = '') {
this.baseUrl = baseUrl
}
/**
* 检查单个账号状态
* @param token - ChatGPT access_token
* @returns CheckResult 包含状态、account_id、plan_type 等信息
*
* API: GET https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27
* Headers: Authorization: Bearer {token}
*
* 状态映射 (requirements.md A3):
* - HTTP 200 → active
* - HTTP 401 → token_expired
* - HTTP 403 → banned
* - 其他 → error
*/
async checkAccount(token: string): Promise<CheckResult> {
// 处理空 token 的情况
if (!token || token.trim() === '') {
return {
status: 'error',
error: '缺少 token',
}
}
try {
const response = await fetch(`${this.baseUrl}${CHATGPT_CHECK_API}`, {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
})
const status = mapHttpStatusToAccountStatus(response.status)
const errorMessage = getErrorMessageForStatus(response.status, response.statusText)
if (response.status === 200) {
try {
const data: ChatGPTCheckResponse = await response.json()
const accountInfo = data.accounts?.[0]
if (accountInfo) {
return {
status: 'active',
accountId: accountInfo.account_id,
planType: accountInfo.entitlement?.subscription_plan || 'free',
}
}
// 200 响应但没有账号信息
return {
status: 'active',
accountId: undefined,
planType: 'unknown',
}
} catch {
// JSON 解析失败,但 HTTP 200 仍视为 active
return {
status: 'active',
accountId: undefined,
planType: 'unknown',
}
}
}
return {
status,
error: errorMessage,
}
} catch (error) {
return {
status: 'error',
error: error instanceof Error ? error.message : '网络错误',
}
}
}
/**
* 批量检查账号(带并发控制)
* @param accounts - 待检查的账号列表
* @param options.concurrency - 并发数量(默认 20
* @param options.onProgress - 进度回调,每检查完一个账号调用一次
* @returns 检查完成的账号列表
*
* 使用队列 + Promise 实现并发控制,确保任意时刻活跃请求数 ≤ concurrency
*/
async batchCheck(
accounts: AccountInput[],
options: {
concurrency: number
onProgress?: (result: CheckedAccount, index: number) => void
}
): Promise<CheckedAccount[]> {
const { concurrency, onProgress } = options
// 空数组直接返回
if (accounts.length === 0) {
return []
}
const results: CheckedAccount[] = new Array(accounts.length)
const queue: number[] = [...Array(accounts.length).keys()]
let activeCount = 0
let completedCount = 0
return new Promise((resolve) => {
const processNext = () => {
// 所有任务完成
if (completedCount === accounts.length) {
resolve(results)
return
}
// 启动新任务,直到达到并发限制或队列为空
while (activeCount < concurrency && queue.length > 0) {
const index = queue.shift()!
activeCount++
// 异步处理单个账号
this.processAccount(accounts[index], index)
.then((checkedAccount) => {
results[index] = checkedAccount
onProgress?.(checkedAccount, index)
})
.finally(() => {
activeCount--
completedCount++
// 继续处理下一个
processNext()
})
}
}
// 开始处理
processNext()
})
}
/**
* 处理单个账号检查
* @private
*/
private async processAccount(account: AccountInput, index: number): Promise<CheckedAccount> {
const checkResult = await this.checkAccount(account.token)
return {
...account,
id: index,
status: checkResult.status,
accountId: checkResult.accountId,
planType: checkResult.planType,
error: checkResult.error,
}
}
}
/**
* 解析账号状态为中文描述
*/
export function getStatusText(status: AccountStatus): string {
const statusMap: Record<AccountStatus, string> = {
pending: '待检查',
checking: '检查中',
active: '正常',
banned: '封禁',
token_expired: '过期',
error: '错误',
}
return statusMap[status] || status
}
/**
* 获取状态对应的颜色类名
*/
export function getStatusColor(status: AccountStatus): string {
const colorMap: Record<AccountStatus, string> = {
pending: 'gray',
checking: 'blue',
active: 'green',
banned: 'red',
token_expired: 'orange',
error: 'yellow',
}
return colorMap[status] || 'gray'
}
/**
* 创建 ChatGPT 客户端实例
* @param baseUrl - 可选的基础 URL默认为空使用相对路径
*/
export function createChatGPTClient(baseUrl: string = ''): ChatGPTClient {
return new ChatGPTClient(baseUrl)
}
// 导出默认客户端实例(使用相对路径,通过 nginx 代理)
export const chatGPTClient = new ChatGPTClient()

View File

@@ -0,0 +1,4 @@
// API Layer barrel export
export * from './types'
export * from './s2a'
export * from './chatgpt'

155
frontend/src/api/s2a.ts Normal file
View File

@@ -0,0 +1,155 @@
import type {
DashboardStatsResponse,
DashboardTrendResponse,
AccountListResponse,
AccountResponse,
CreateAccountPayload,
OAuthCreatePayload,
GroupResponse,
ProxyResponse,
TestAccountResponse,
} from './types'
import type { AccountListParams } from '../types'
// 使用后端代理 API 来避免 CORS 问题
const PROXY_BASE = 'http://localhost:8088/api/s2a/proxy'
export class S2AClient {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
constructor(_config: { baseUrl: string; apiKey: string }) {
// 不再使用直接配置,通过后端代理
}
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
// 将 /api/v1/admin/* 转换为代理路径
const proxyEndpoint = endpoint.replace('/api/v1/admin', '')
const url = `${PROXY_BASE}${proxyEndpoint}`
const headers: HeadersInit = {
'Content-Type': 'application/json',
...options.headers,
}
const response = await fetch(url, {
...options,
headers,
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(
errorData.message || errorData.error || `HTTP ${response.status}: ${response.statusText}`
)
}
return response.json()
}
// Dashboard APIs
async getDashboardStats(): Promise<DashboardStatsResponse> {
return this.request<DashboardStatsResponse>('/dashboard/stats')
}
async getDashboardTrend(granularity: 'day' | 'hour' = 'day'): Promise<DashboardTrendResponse> {
return this.request<DashboardTrendResponse>(
`/dashboard/trend?granularity=${granularity}`
)
}
// Account APIs
async getAccounts(params: AccountListParams = {}): Promise<AccountListResponse> {
const searchParams = new URLSearchParams()
if (params.page) searchParams.set('page', params.page.toString())
if (params.page_size) searchParams.set('page_size', params.page_size.toString())
if (params.platform) searchParams.set('platform', params.platform)
if (params.type) searchParams.set('type', params.type)
if (params.status) searchParams.set('status', params.status)
if (params.search) searchParams.set('search', params.search)
const queryString = searchParams.toString()
const endpoint = `/accounts${queryString ? `?${queryString}` : ''}`
return this.request<AccountListResponse>(endpoint)
}
async getAccount(id: number): Promise<AccountResponse> {
return this.request<AccountResponse>(`/accounts/${id}`)
}
async createAccount(data: CreateAccountPayload): Promise<AccountResponse> {
return this.request<AccountResponse>('/accounts', {
method: 'POST',
body: JSON.stringify(data),
})
}
async createFromOAuth(data: OAuthCreatePayload): Promise<AccountResponse> {
return this.request<AccountResponse>('/openai/create-from-oauth', {
method: 'POST',
body: JSON.stringify(data),
})
}
async updateAccount(id: number, data: Partial<CreateAccountPayload>): Promise<AccountResponse> {
return this.request<AccountResponse>(`/accounts/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
async deleteAccount(id: number): Promise<void> {
await this.request<void>(`/accounts/${id}`, {
method: 'DELETE',
})
}
async testAccount(id: number): Promise<TestAccountResponse> {
return this.request<TestAccountResponse>(`/accounts/${id}/test`, {
method: 'POST',
})
}
async refreshAccountToken(id: number): Promise<AccountResponse> {
return this.request<AccountResponse>(`/accounts/${id}/refresh`, {
method: 'POST',
})
}
async clearAccountError(id: number): Promise<AccountResponse> {
return this.request<AccountResponse>(`/accounts/${id}/clear-error`, {
method: 'POST',
})
}
// Group APIs
async getGroups(): Promise<GroupResponse[]> {
const response = await this.request<{ data: GroupResponse[] }>('/groups/all')
return response.data || []
}
// Proxy APIs
async getProxies(): Promise<ProxyResponse[]> {
const response = await this.request<{ data: ProxyResponse[] }>('/proxies/all')
return response.data || []
}
async testProxy(id: number): Promise<TestAccountResponse> {
return this.request<TestAccountResponse>(`/proxies/${id}/test`, {
method: 'POST',
})
}
// Connection test
async testConnection(): Promise<boolean> {
try {
await this.getDashboardStats()
return true
} catch {
return false
}
}
}
// 创建默认客户端实例的工厂函数
export function createS2AClient(baseUrl: string, apiKey: string): S2AClient {
return new S2AClient({ baseUrl, apiKey })
}

522
frontend/src/api/types.ts Normal file
View File

@@ -0,0 +1,522 @@
// =============================================================================
// S2A API 响应类型
// 基于 requirements.md Appendix A2 定义
// =============================================================================
// -----------------------------------------------------------------------------
// Dashboard 相关接口响应
// -----------------------------------------------------------------------------
/**
* Dashboard Stats 响应
* API: GET /api/v1/admin/dashboard/stats
* 获取号池统计账号数、请求数、Token消耗等
*/
export interface DashboardStatsResponse {
total_accounts: number
normal_accounts: number
error_accounts: number
ratelimit_accounts: number
overload_accounts: number
today_requests: number
today_tokens: number
today_cost: number
total_requests: number
total_tokens: number
total_cost: number
rpm: number
tpm: number
}
/**
* Dashboard Trend 响应
* API: GET /api/v1/admin/dashboard/trend
* 获取使用趋势(支持 granularity=day/hour
*/
export interface DashboardTrendResponse {
data: TrendDataPoint[]
}
export interface TrendDataPoint {
date: string
requests: number
tokens: number
cost: number
}
/**
* Dashboard Models 响应
* API: GET /api/v1/admin/dashboard/models
* 获取模型使用统计
*/
export interface DashboardModelsResponse {
data: ModelUsageStats[]
}
export interface ModelUsageStats {
model: string
requests: number
tokens: number
cost: number
percentage: number
}
/**
* Dashboard Users Trend 响应
* API: GET /api/v1/admin/dashboard/users-trend
* 获取用户使用趋势
*/
export interface DashboardUsersTrendResponse {
data: UsersTrendDataPoint[]
}
export interface UsersTrendDataPoint {
date: string
active_users: number
new_users: number
total_users: number
}
// -----------------------------------------------------------------------------
// 账号管理接口响应
// -----------------------------------------------------------------------------
/**
* 账号列表响应
* API: GET /api/v1/admin/accounts
* 获取账号列表(支持分页、筛选)
*/
export interface AccountListResponse {
data: AccountResponse[]
total: number
page: number
page_size: number
}
/**
* 单个账号响应
* API: GET /api/v1/admin/accounts/:id
* 对应 requirements.md A5 Account 数据结构
*/
export interface AccountResponse {
id: number
name: string
notes?: string
platform: 'openai' | 'anthropic' | 'gemini'
type: 'oauth' | 'access_token' | 'apikey' | 'setup-token'
credentials: Record<string, unknown>
extra?: Record<string, unknown>
proxy_id?: number
concurrency: number
priority: number
rate_multiplier?: number
status: 'active' | 'inactive' | 'error'
error_message?: string
schedulable: boolean
last_used_at?: string
expires_at?: string
auto_pause_on_expired: boolean
created_at: string
updated_at: string
current_concurrency?: number
current_window_cost?: number
active_sessions?: number
}
/**
* 账号统计响应
* API: GET /api/v1/admin/accounts/:id/stats
* 获取账号使用统计
*/
export interface AccountStatsResponse {
account_id: number
total_requests: number
total_tokens: number
total_cost: number
today_requests: number
today_tokens: number
today_cost: number
last_used_at?: string
error_count: number
success_rate: number
}
/**
* 创建账号请求
* API: POST /api/v1/admin/accounts
* 对应 requirements.md A4 access_token 类型账号
*/
export interface CreateAccountPayload {
name: string
platform: 'openai' | 'anthropic' | 'gemini'
type: 'access_token'
credentials: {
access_token: string
refresh_token?: string
email?: string
}
concurrency?: number
priority?: number
group_ids?: number[]
proxy_id?: number | null
auto_pause_on_expired?: boolean
}
/**
* 批量更新账号请求
* API: POST /api/v1/admin/accounts/bulk-update
*/
export interface BulkUpdateAccountsPayload {
ids: number[]
updates: {
status?: 'active' | 'inactive'
concurrency?: number
priority?: number
group_ids?: number[]
proxy_id?: number | null
}
}
/**
* 批量更新账号响应
*/
export interface BulkUpdateAccountsResponse {
success: boolean
updated_count: number
failed_count: number
errors?: string[]
}
// -----------------------------------------------------------------------------
// OpenAI OAuth 接口响应
// -----------------------------------------------------------------------------
/**
* OAuth 创建账号请求
* API: POST /api/v1/admin/openai/create-from-oauth
* 对应 requirements.md A4 OAuth 类型账号
*/
export interface OAuthCreatePayload {
session_id: string
code: string
name?: string
concurrency?: number
priority?: number
group_ids?: number[]
proxy_id?: number | null
}
/**
* 生成 OAuth 授权 URL 请求
* API: POST /api/v1/admin/openai/generate-auth-url
*/
export interface GenerateAuthUrlPayload {
redirect_uri?: string
}
/**
* 生成 OAuth 授权 URL 响应
*/
export interface GenerateAuthUrlResponse {
auth_url: string
session_id: string
}
/**
* 交换授权码请求
* API: POST /api/v1/admin/openai/exchange-code
*/
export interface ExchangeCodePayload {
session_id: string
code: string
}
/**
* 交换授权码响应
*/
export interface ExchangeCodeResponse {
access_token: string
refresh_token?: string
expires_in?: number
token_type: string
}
/**
* 刷新 Token 请求
* API: POST /api/v1/admin/openai/refresh-token
*/
export interface RefreshTokenPayload {
refresh_token: string
}
/**
* 刷新 Token 响应
*/
export interface RefreshTokenResponse {
access_token: string
refresh_token?: string
expires_in?: number
token_type: string
}
// -----------------------------------------------------------------------------
// 分组管理接口响应
// -----------------------------------------------------------------------------
/**
* 分组响应
* API: GET /api/v1/admin/groups, GET /api/v1/admin/groups/all
*/
export interface GroupResponse {
id: number
name: string
description?: string
created_at: string
updated_at: string
}
/**
* 分组统计响应
* API: GET /api/v1/admin/groups/:id/stats
*/
export interface GroupStatsResponse {
group_id: number
total_accounts: number
active_accounts: number
error_accounts: number
total_requests: number
total_tokens: number
total_cost: number
}
// -----------------------------------------------------------------------------
// 代理管理接口响应
// -----------------------------------------------------------------------------
/**
* 代理响应
* API: GET /api/v1/admin/proxies, GET /api/v1/admin/proxies/all
*/
export interface ProxyResponse {
id: number
name: string
url: string
status: 'active' | 'inactive' | 'error'
created_at: string
updated_at: string
}
/**
* 测试代理/账号响应
* API: POST /api/v1/admin/proxies/:id/test, POST /api/v1/admin/accounts/:id/test
*/
export interface TestAccountResponse {
success: boolean
message?: string
latency?: number
}
// -----------------------------------------------------------------------------
// 运维监控接口响应
// -----------------------------------------------------------------------------
/**
* 并发统计响应
* API: GET /api/v1/admin/ops/concurrency
*/
export interface OpsConcurrencyResponse {
total_concurrency: number
used_concurrency: number
available_concurrency: number
accounts: AccountConcurrencyInfo[]
}
export interface AccountConcurrencyInfo {
account_id: number
account_name: string
max_concurrency: number
current_concurrency: number
}
/**
* 账号可用性响应
* API: GET /api/v1/admin/ops/account-availability
*/
export interface OpsAccountAvailabilityResponse {
total_accounts: number
available_accounts: number
unavailable_accounts: number
availability_rate: number
accounts: AccountAvailabilityInfo[]
}
export interface AccountAvailabilityInfo {
account_id: number
account_name: string
status: 'available' | 'unavailable' | 'rate_limited' | 'error'
reason?: string
}
/**
* 实时流量响应
* API: GET /api/v1/admin/ops/realtime-traffic
*/
export interface OpsRealtimeTrafficResponse {
current_rpm: number
current_tpm: number
peak_rpm: number
peak_tpm: number
requests_last_minute: number
tokens_last_minute: number
}
/**
* 运维仪表盘概览响应
* API: GET /api/v1/admin/ops/dashboard/overview
*/
export interface OpsDashboardOverviewResponse {
health_score: number
total_accounts: number
healthy_accounts: number
warning_accounts: number
error_accounts: number
current_load: number
max_load: number
alerts: OpsAlert[]
}
export interface OpsAlert {
id: string
level: 'info' | 'warning' | 'error' | 'critical'
message: string
timestamp: string
account_id?: number
}
/**
* 错误趋势响应
* API: GET /api/v1/admin/ops/dashboard/error-trend
*/
export interface OpsErrorTrendResponse {
data: ErrorTrendDataPoint[]
}
export interface ErrorTrendDataPoint {
date: string
total_errors: number
rate_limit_errors: number
auth_errors: number
network_errors: number
other_errors: number
}
// =============================================================================
// ChatGPT API 响应类型
// 基于 requirements.md Requirement 1.2 定义
// =============================================================================
/**
* 账号检查响应
* API: GET https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27
* Headers: Authorization: Bearer {token}
*/
export interface ChatGPTCheckResponse {
accounts: ChatGPTAccountInfo[]
}
export interface ChatGPTAccountInfo {
account_id: string
account: ChatGPTAccountDetails
features: string[]
entitlement: ChatGPTEntitlement
last_active_subscription: ChatGPTLastActiveSubscription
is_eligible_for_yearly_plus_subscription: boolean
}
export interface ChatGPTAccountDetails {
account_user_id: string
processor: {
a001: {
has_customer_object: boolean
}
}
account_user_role: string
plan_type: string
is_most_recent_expired_subscription_gratis: boolean
has_previously_paid_subscription: boolean
name: string | null
profile_picture_id: string | null
profile_picture_url: string | null
structure: string
is_deactivated: boolean
is_disabled: boolean
// SAM (Security Account Management) 相关字段
is_sam_enforced: boolean
is_sam_enabled: boolean
is_sam_compliant: boolean
is_sam_grace_period: boolean
is_sam_grace_period_expired: boolean
is_sam_grace_period_expiring_soon: boolean
is_sam_grace_period_expiring_today: boolean
is_sam_grace_period_expiring_tomorrow: boolean
is_sam_grace_period_expiring_in_two_days: boolean
is_sam_grace_period_expiring_in_three_days: boolean
is_sam_grace_period_expiring_in_four_days: boolean
is_sam_grace_period_expiring_in_five_days: boolean
is_sam_grace_period_expiring_in_six_days: boolean
is_sam_grace_period_expiring_in_seven_days: boolean
}
export interface ChatGPTEntitlement {
subscription_id: string | null
has_active_subscription: boolean
subscription_plan: string
expires_at: string | null
}
export interface ChatGPTLastActiveSubscription {
subscription_id: string | null
purchase_origin_platform: string
will_renew: boolean
}
// =============================================================================
// 通用 API 类型
// =============================================================================
/**
* API 错误响应
* 通用错误响应格式
*/
export interface ApiErrorResponse {
error: string
message?: string
code?: string
details?: Record<string, unknown>
}
/**
* 分页请求参数
*/
export interface PaginationParams {
page?: number
page_size?: number
}
/**
* 分页响应包装
*/
export interface PaginatedResponse<T> {
data: T[]
total: number
page: number
page_size: number
total_pages: number
}
/**
* 通用列表响应包装
*/
export interface ListResponse<T> {
data: T[]
}

View File

@@ -0,0 +1,215 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import Button from './Button'
describe('Button', () => {
describe('rendering', () => {
it('renders children correctly', () => {
render(<Button>Click me</Button>)
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument()
})
it('renders with default props', () => {
render(<Button>Default Button</Button>)
const button = screen.getByRole('button')
// Default variant is primary, default size is md
expect(button).toHaveClass('bg-primary-600')
expect(button).toHaveClass('px-4', 'py-2')
})
})
describe('variants', () => {
it('renders primary variant correctly', () => {
render(<Button variant="primary">Primary</Button>)
const button = screen.getByRole('button')
expect(button).toHaveClass('bg-primary-600', 'text-white')
})
it('renders secondary variant correctly', () => {
render(<Button variant="secondary">Secondary</Button>)
const button = screen.getByRole('button')
expect(button).toHaveClass('bg-slate-100', 'text-slate-900')
})
it('renders danger variant correctly', () => {
render(<Button variant="danger">Danger</Button>)
const button = screen.getByRole('button')
expect(button).toHaveClass('bg-error-500', 'text-white')
})
it('renders ghost variant correctly', () => {
render(<Button variant="ghost">Ghost</Button>)
const button = screen.getByRole('button')
expect(button).toHaveClass('text-slate-700', 'bg-transparent')
})
it('renders outline variant correctly', () => {
render(<Button variant="outline">Outline</Button>)
const button = screen.getByRole('button')
expect(button).toHaveClass('border', 'border-slate-300', 'text-slate-700')
})
})
describe('sizes', () => {
it('renders small size correctly', () => {
render(<Button size="sm">Small</Button>)
const button = screen.getByRole('button')
expect(button).toHaveClass('px-3', 'py-1.5', 'text-sm')
})
it('renders medium size correctly', () => {
render(<Button size="md">Medium</Button>)
const button = screen.getByRole('button')
expect(button).toHaveClass('px-4', 'py-2', 'text-sm')
})
it('renders large size correctly', () => {
render(<Button size="lg">Large</Button>)
const button = screen.getByRole('button')
expect(button).toHaveClass('px-6', 'py-3', 'text-base')
})
})
describe('loading state', () => {
it('shows spinner when loading', () => {
render(<Button loading>Loading</Button>)
const button = screen.getByRole('button')
// Check for the spinner (Loader2 icon with animate-spin class)
const spinner = button.querySelector('.animate-spin')
expect(spinner).toBeInTheDocument()
})
it('disables button when loading', () => {
render(<Button loading>Loading</Button>)
const button = screen.getByRole('button')
expect(button).toBeDisabled()
})
it('sets aria-busy when loading', () => {
render(<Button loading>Loading</Button>)
const button = screen.getByRole('button')
expect(button).toHaveAttribute('aria-busy', 'true')
})
it('hides icon when loading', () => {
const icon = <span data-testid="test-icon">Icon</span>
render(
<Button loading icon={icon}>
With Icon
</Button>
)
expect(screen.queryByTestId('test-icon')).not.toBeInTheDocument()
})
})
describe('disabled state', () => {
it('disables button when disabled prop is true', () => {
render(<Button disabled>Disabled</Button>)
const button = screen.getByRole('button')
expect(button).toBeDisabled()
})
it('applies disabled styles', () => {
render(<Button disabled>Disabled</Button>)
const button = screen.getByRole('button')
expect(button).toHaveClass('disabled:opacity-50', 'disabled:cursor-not-allowed')
})
it('sets aria-disabled when disabled', () => {
render(<Button disabled>Disabled</Button>)
const button = screen.getByRole('button')
expect(button).toHaveAttribute('aria-disabled', 'true')
})
it('does not call onClick when disabled', () => {
const handleClick = vi.fn()
render(
<Button disabled onClick={handleClick}>
Disabled
</Button>
)
fireEvent.click(screen.getByRole('button'))
expect(handleClick).not.toHaveBeenCalled()
})
})
describe('icon support', () => {
it('renders icon when provided', () => {
const icon = <span data-testid="test-icon"></span>
render(<Button icon={icon}>With Icon</Button>)
expect(screen.getByTestId('test-icon')).toBeInTheDocument()
})
it('renders icon before text', () => {
const icon = <span data-testid="test-icon"></span>
render(<Button icon={icon}>With Icon</Button>)
const button = screen.getByRole('button')
const iconElement = screen.getByTestId('test-icon')
// Icon should be a child of the button
expect(button).toContainElement(iconElement)
})
})
describe('click handling', () => {
it('calls onClick when clicked', () => {
const handleClick = vi.fn()
render(<Button onClick={handleClick}>Click me</Button>)
fireEvent.click(screen.getByRole('button'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('does not call onClick when loading', () => {
const handleClick = vi.fn()
render(
<Button loading onClick={handleClick}>
Loading
</Button>
)
fireEvent.click(screen.getByRole('button'))
expect(handleClick).not.toHaveBeenCalled()
})
})
describe('custom className', () => {
it('applies custom className', () => {
render(<Button className="custom-class">Custom</Button>)
const button = screen.getByRole('button')
expect(button).toHaveClass('custom-class')
})
it('merges custom className with default classes', () => {
render(<Button className="custom-class">Custom</Button>)
const button = screen.getByRole('button')
expect(button).toHaveClass('custom-class', 'bg-primary-600')
})
})
describe('forwarded ref', () => {
it('forwards ref to button element', () => {
const ref = vi.fn()
render(<Button ref={ref}>Ref Button</Button>)
expect(ref).toHaveBeenCalled()
expect(ref.mock.calls[0][0]).toBeInstanceOf(HTMLButtonElement)
})
})
describe('HTML button attributes', () => {
it('passes through type attribute', () => {
render(<Button type="submit">Submit</Button>)
const button = screen.getByRole('button')
expect(button).toHaveAttribute('type', 'submit')
})
it('passes through form attribute', () => {
render(<Button form="my-form">Submit</Button>)
const button = screen.getByRole('button')
expect(button).toHaveAttribute('form', 'my-form')
})
it('passes through aria-label', () => {
render(<Button aria-label="Close dialog">×</Button>)
const button = screen.getByRole('button', { name: 'Close dialog' })
expect(button).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,158 @@
import { type ButtonHTMLAttributes, forwardRef } from 'react'
import { Loader2 } from 'lucide-react'
/**
* Button component variants
* - primary: Main action button using design system primary color (Blue-600)
* - secondary: Secondary action button with subtle background
* - outline: Bordered button with transparent background
* - danger: Destructive action button using design system error color (Red-500)
* - ghost: Minimal button with no background, only hover state
*/
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'danger' | 'ghost'
/**
* Button component sizes
* - sm: Small button for compact UIs
* - md: Medium button (default)
* - lg: Large button for prominent actions
*/
export type ButtonSize = 'sm' | 'md' | 'lg'
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
/** Visual style variant of the button */
variant?: ButtonVariant
/** Size of the button */
size?: ButtonSize
/** Shows a loading spinner and disables the button */
loading?: boolean
/** Optional icon to display before the button text */
icon?: React.ReactNode
}
/**
* A reusable Button component with support for multiple variants, sizes,
* loading state, and disabled state. Uses TailwindCSS with design system colors.
*
* @example
* // Primary button
* <Button variant="primary">Submit</Button>
*
* @example
* // Loading state
* <Button loading>Processing...</Button>
*
* @example
* // With icon
* <Button icon={<PlusIcon />}>Add Item</Button>
*/
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{
className = '',
variant = 'primary',
size = 'md',
loading = false,
disabled,
icon,
children,
...props
},
ref
) => {
// Base styles applied to all button variants
const baseStyles = [
'inline-flex items-center justify-center',
'font-medium rounded-lg',
'transition-colors duration-200',
'focus:outline-none focus:ring-2 focus:ring-offset-2',
'disabled:opacity-50 disabled:cursor-not-allowed',
].join(' ')
// Variant-specific styles using design system colors
const variantStyles: Record<ButtonVariant, string> = {
// Primary: Blue-600 (#2563EB) - Main action button
primary: [
'bg-primary-600 text-white',
'hover:bg-primary-700',
'focus:ring-primary-500',
'dark:bg-primary-500 dark:hover:bg-primary-600',
].join(' '),
// Secondary: Slate background - Secondary action button
secondary: [
'bg-slate-100 text-slate-900',
'hover:bg-slate-200',
'focus:ring-slate-500',
'dark:bg-slate-700 dark:text-slate-100 dark:hover:bg-slate-600',
].join(' '),
// Outline: Bordered button with transparent background
outline: [
'border border-slate-300 text-slate-700 bg-transparent',
'hover:bg-slate-50',
'focus:ring-slate-500',
'dark:border-slate-600 dark:text-slate-300 dark:hover:bg-slate-800',
].join(' '),
// Danger: Red-500 (#EF4444) - Destructive action button
danger: [
'bg-error-500 text-white',
'hover:bg-error-600',
'focus:ring-error-500',
'dark:bg-error-500 dark:hover:bg-error-600',
].join(' '),
// Ghost: Transparent background - Minimal button
ghost: [
'text-slate-700 bg-transparent',
'hover:bg-slate-100',
'focus:ring-slate-500',
'dark:text-slate-300 dark:hover:bg-slate-800',
].join(' '),
}
// Size-specific styles
const sizeStyles: Record<ButtonSize, string> = {
sm: 'px-3 py-1.5 text-sm gap-1.5',
md: 'px-4 py-2 text-sm gap-2',
lg: 'px-6 py-3 text-base gap-2',
}
// Spinner size based on button size
const spinnerSizeStyles: Record<ButtonSize, string> = {
sm: 'h-3.5 w-3.5',
md: 'h-4 w-4',
lg: 'h-5 w-5',
}
const isDisabled = disabled || loading
return (
<button
ref={ref}
className={`${baseStyles} ${variantStyles[variant]} ${sizeStyles[size]} ${className}`}
disabled={isDisabled}
aria-busy={loading}
aria-disabled={isDisabled}
{...props}
>
{loading ? (
<Loader2
className={`${spinnerSizeStyles[size]} animate-spin`}
aria-hidden="true"
/>
) : icon ? (
<span className="flex-shrink-0" aria-hidden="true">
{icon}
</span>
) : null}
{children}
</button>
)
}
)
Button.displayName = 'Button'
export default Button

View File

@@ -0,0 +1,84 @@
import { type HTMLAttributes, forwardRef } from 'react'
export interface CardProps extends HTMLAttributes<HTMLDivElement> {
padding?: 'none' | 'sm' | 'md' | 'lg'
hoverable?: boolean
variant?: 'default' | 'glass'
}
const Card = forwardRef<HTMLDivElement, CardProps>(
({ className = '', padding = 'md', hoverable = false, variant = 'default', children, ...props }, ref) => {
const paddingStyles = {
none: '',
sm: 'p-3',
md: 'p-4',
lg: 'p-6',
}
const baseStyles = variant === 'glass'
? 'glass-card'
: 'bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 shadow-sm rounded-xl'
const hoverStyles = hoverable ? 'card-hover cursor-pointer' : ''
return (
<div
ref={ref}
className={`${baseStyles} ${paddingStyles[padding]} ${hoverStyles} ${className}`}
{...props}
>
{children}
</div>
)
}
)
Card.displayName = 'Card'
export interface CardHeaderProps extends HTMLAttributes<HTMLDivElement> {}
export const CardHeader = forwardRef<HTMLDivElement, CardHeaderProps>(
({ className = '', children, ...props }, ref) => {
return (
<div ref={ref} className={`flex items-center justify-between mb-4 ${className}`} {...props}>
{children}
</div>
)
}
)
CardHeader.displayName = 'CardHeader'
export interface CardTitleProps extends HTMLAttributes<HTMLHeadingElement> {}
export const CardTitle = forwardRef<HTMLHeadingElement, CardTitleProps>(
({ className = '', children, ...props }, ref) => {
return (
<h3
ref={ref}
className={`text-lg font-semibold text-slate-900 dark:text-slate-100 ${className}`}
{...props}
>
{children}
</h3>
)
}
)
CardTitle.displayName = 'CardTitle'
export interface CardContentProps extends HTMLAttributes<HTMLDivElement> {}
export const CardContent = forwardRef<HTMLDivElement, CardContentProps>(
({ className = '', children, ...props }, ref) => {
return (
<div ref={ref} className={className} {...props}>
{children}
</div>
)
}
)
CardContent.displayName = 'CardContent'
export default Card

View File

@@ -0,0 +1,61 @@
import { Component, type ErrorInfo, type ReactNode } from 'react'
import { AlertTriangle, RefreshCw } from 'lucide-react'
import Button from './Button'
interface Props {
children: ReactNode
fallback?: ReactNode
}
interface State {
hasError: boolean
error: Error | null
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false, error: null }
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('ErrorBoundary caught an error:', error, errorInfo)
}
handleReset = () => {
this.setState({ hasError: false, error: null })
}
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback
}
return (
<div className="min-h-[400px] flex items-center justify-center p-8">
<div className="text-center max-w-md">
<div className="h-16 w-16 mx-auto mb-4 rounded-2xl bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<AlertTriangle className="h-8 w-8 text-red-500" />
</div>
<h2 className="text-xl font-bold text-slate-900 dark:text-slate-100 mb-2">
</h2>
<p className="text-slate-500 dark:text-slate-400 mb-4">
{this.state.error?.message || '发生了一个意外错误'}
</p>
<Button onClick={this.handleReset} icon={<RefreshCw className="h-4 w-4" />}>
</Button>
</div>
</div>
)
}
return this.props.children
}
}

View File

@@ -0,0 +1,100 @@
import { type InputHTMLAttributes, type TextareaHTMLAttributes, forwardRef } from 'react'
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string
error?: string
hint?: string
}
const Input = forwardRef<HTMLInputElement, InputProps>(
({ className = '', label, error, hint, id, ...props }, ref) => {
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-')
return (
<div className="w-full">
{label && (
<label
htmlFor={inputId}
className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"
>
{label}
</label>
)}
<input
ref={ref}
id={inputId}
className={`w-full px-3 py-2 text-sm rounded-lg border transition-colors
bg-white dark:bg-slate-800
text-slate-900 dark:text-slate-100
placeholder-slate-400 dark:placeholder-slate-500
${
error
? 'border-red-500 focus:border-red-500 focus:ring-red-500'
: 'border-slate-300 dark:border-slate-600 focus:border-blue-500 focus:ring-blue-500'
}
focus:outline-none focus:ring-2 focus:ring-offset-0
disabled:opacity-50 disabled:cursor-not-allowed
${className}`}
{...props}
/>
{error && <p className="mt-1 text-sm text-red-500">{error}</p>}
{hint && !error && (
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{hint}</p>
)}
</div>
)
}
)
Input.displayName = 'Input'
export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string
error?: string
hint?: string
}
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className = '', label, error, hint, id, ...props }, ref) => {
const textareaId = id || label?.toLowerCase().replace(/\s+/g, '-')
return (
<div className="w-full">
{label && (
<label
htmlFor={textareaId}
className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"
>
{label}
</label>
)}
<textarea
ref={ref}
id={textareaId}
className={`w-full px-3 py-2 text-sm rounded-lg border transition-colors
bg-white dark:bg-slate-800
text-slate-900 dark:text-slate-100
placeholder-slate-400 dark:placeholder-slate-500
${
error
? 'border-red-500 focus:border-red-500 focus:ring-red-500'
: 'border-slate-300 dark:border-slate-600 focus:border-blue-500 focus:ring-blue-500'
}
focus:outline-none focus:ring-2 focus:ring-offset-0
disabled:opacity-50 disabled:cursor-not-allowed
resize-none
${className}`}
{...props}
/>
{error && <p className="mt-1 text-sm text-red-500">{error}</p>}
{hint && !error && (
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{hint}</p>
)}
</div>
)
}
)
Textarea.displayName = 'Textarea'
export default Input

View File

@@ -0,0 +1,68 @@
import { type HTMLAttributes, forwardRef } from 'react'
export interface ProgressProps extends HTMLAttributes<HTMLDivElement> {
value: number
max?: number
size?: 'sm' | 'md' | 'lg'
color?: 'blue' | 'green' | 'yellow' | 'red'
showLabel?: boolean
label?: string
}
const Progress = forwardRef<HTMLDivElement, ProgressProps>(
(
{
className = '',
value,
max = 100,
size = 'md',
color = 'blue',
showLabel = false,
label,
...props
},
ref
) => {
const percentage = Math.min(Math.max((value / max) * 100, 0), 100)
const sizeStyles = {
sm: 'h-1.5',
md: 'h-2.5',
lg: 'h-4',
}
const colorStyles = {
blue: 'bg-blue-600 dark:bg-blue-500',
green: 'bg-green-600 dark:bg-green-500',
yellow: 'bg-yellow-500 dark:bg-yellow-400',
red: 'bg-red-600 dark:bg-red-500',
}
return (
<div ref={ref} className={className} {...props}>
{(showLabel || label) && (
<div className="flex justify-between mb-1">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">
{label || '进度'}
</span>
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">
{percentage.toFixed(0)}%
</span>
</div>
)}
<div
className={`w-full bg-slate-200 dark:bg-slate-700 rounded-full overflow-hidden ${sizeStyles[size]}`}
>
<div
className={`${sizeStyles[size]} ${colorStyles[color]} rounded-full transition-all duration-300 ease-out`}
style={{ width: `${percentage}%` }}
/>
</div>
</div>
)
}
)
Progress.displayName = 'Progress'
export default Progress

View File

@@ -0,0 +1,74 @@
import { type SelectHTMLAttributes, forwardRef } from 'react'
import { ChevronDown } from 'lucide-react'
export interface SelectOption {
value: string | number
label: string
disabled?: boolean
}
export interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, 'children'> {
label?: string
error?: string
hint?: string
options: SelectOption[]
placeholder?: string
}
const Select = forwardRef<HTMLSelectElement, SelectProps>(
({ className = '', label, error, hint, options, placeholder, id, ...props }, ref) => {
const selectId = id || label?.toLowerCase().replace(/\s+/g, '-')
return (
<div className="w-full">
{label && (
<label
htmlFor={selectId}
className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"
>
{label}
</label>
)}
<div className="relative">
<select
ref={ref}
id={selectId}
className={`w-full px-3 py-2 text-sm rounded-lg border transition-colors appearance-none
bg-white dark:bg-slate-800
text-slate-900 dark:text-slate-100
${
error
? 'border-red-500 focus:border-red-500 focus:ring-red-500'
: 'border-slate-300 dark:border-slate-600 focus:border-blue-500 focus:ring-blue-500'
}
focus:outline-none focus:ring-2 focus:ring-offset-0
disabled:opacity-50 disabled:cursor-not-allowed
pr-10
${className}`}
{...props}
>
{placeholder && (
<option value="" disabled>
{placeholder}
</option>
)}
{options.map((option) => (
<option key={option.value} value={option.value} disabled={option.disabled}>
{option.label}
</option>
))}
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400 pointer-events-none" />
</div>
{error && <p className="mt-1 text-sm text-red-500">{error}</p>}
{hint && !error && (
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{hint}</p>
)}
</div>
)
}
)
Select.displayName = 'Select'
export default Select

View File

@@ -0,0 +1,66 @@
import { type HTMLAttributes, forwardRef } from 'react'
import type { AccountStatus } from '../../types'
export interface StatusBadgeProps extends HTMLAttributes<HTMLSpanElement> {
status: AccountStatus | 'active' | 'inactive' | 'error'
size?: 'sm' | 'md'
}
const StatusBadge = forwardRef<HTMLSpanElement, StatusBadgeProps>(
({ className = '', status, size = 'md', ...props }, ref) => {
const getStatusConfig = (status: string) => {
const configs: Record<string, { label: string; color: string }> = {
pending: {
label: '待检查',
color: 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-300',
},
checking: {
label: '检查中',
color: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
},
active: {
label: '正常',
color: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
},
banned: {
label: '封禁',
color: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
},
token_expired: {
label: '过期',
color: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400',
},
error: {
label: '错误',
color: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400',
},
inactive: {
label: '停用',
color: 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-300',
},
}
return configs[status] || configs.error
}
const config = getStatusConfig(status)
const sizeStyles = {
sm: 'px-2 py-0.5 text-xs',
md: 'px-2.5 py-1 text-xs',
}
return (
<span
ref={ref}
className={`inline-flex items-center font-medium rounded-full ${config.color} ${sizeStyles[size]} ${className}`}
{...props}
>
{config.label}
</span>
)
}
)
StatusBadge.displayName = 'StatusBadge'
export default StatusBadge

View File

@@ -0,0 +1,110 @@
import {
type HTMLAttributes,
type ThHTMLAttributes,
type TdHTMLAttributes,
forwardRef,
} from 'react'
export interface TableProps extends HTMLAttributes<HTMLTableElement> {}
const Table = forwardRef<HTMLTableElement, TableProps>(
({ className = '', children, ...props }, ref) => {
return (
<div className="overflow-x-auto">
<table ref={ref} className={`w-full text-sm text-left ${className}`} {...props}>
{children}
</table>
</div>
)
}
)
Table.displayName = 'Table'
export interface TableHeaderProps extends HTMLAttributes<HTMLTableSectionElement> {}
export const TableHeader = forwardRef<HTMLTableSectionElement, TableHeaderProps>(
({ className = '', children, ...props }, ref) => {
return (
<thead
ref={ref}
className={`text-xs text-slate-700 uppercase bg-slate-50 dark:bg-slate-700 dark:text-slate-400 ${className}`}
{...props}
>
{children}
</thead>
)
}
)
TableHeader.displayName = 'TableHeader'
export interface TableBodyProps extends HTMLAttributes<HTMLTableSectionElement> {}
export const TableBody = forwardRef<HTMLTableSectionElement, TableBodyProps>(
({ className = '', children, ...props }, ref) => {
return (
<tbody ref={ref} className={className} {...props}>
{children}
</tbody>
)
}
)
TableBody.displayName = 'TableBody'
export interface TableRowProps extends HTMLAttributes<HTMLTableRowElement> {
hoverable?: boolean
}
export const TableRow = forwardRef<HTMLTableRowElement, TableRowProps>(
({ className = '', hoverable = true, children, ...props }, ref) => {
return (
<tr
ref={ref}
className={`border-b border-slate-200 dark:border-slate-700 ${
hoverable ? 'hover:bg-slate-50 dark:hover:bg-slate-700/50' : ''
} ${className}`}
{...props}
>
{children}
</tr>
)
}
)
TableRow.displayName = 'TableRow'
export interface TableHeadProps extends ThHTMLAttributes<HTMLTableCellElement> {}
export const TableHead = forwardRef<HTMLTableCellElement, TableHeadProps>(
({ className = '', children, ...props }, ref) => {
return (
<th ref={ref} className={`px-4 py-3 font-medium ${className}`} {...props}>
{children}
</th>
)
}
)
TableHead.displayName = 'TableHead'
export interface TableCellProps extends TdHTMLAttributes<HTMLTableCellElement> {}
export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
({ className = '', children, ...props }, ref) => {
return (
<td
ref={ref}
className={`px-4 py-3 text-slate-900 dark:text-slate-100 ${className}`}
{...props}
>
{children}
</td>
)
}
)
TableCell.displayName = 'TableCell'
export default Table

View File

@@ -0,0 +1,48 @@
import type { LucideIcon } from 'lucide-react'
export interface TabItem {
id: string
label: string
icon?: LucideIcon
count?: number
}
interface TabsProps {
tabs: TabItem[]
activeTab: string
onChange: (id: string) => void
className?: string
}
export function Tabs({ tabs, activeTab, onChange, className = '' }: TabsProps) {
return (
<div className={`flex gap-2 border-b border-slate-200 dark:border-slate-800 ${className}`}>
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => onChange(tab.id)}
className={`relative flex items-center gap-2 px-4 py-3 text-sm font-medium transition-all duration-200 ${activeTab === tab.id
? 'text-blue-600 dark:text-blue-400'
: 'text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200'
}`}
>
{tab.icon && <tab.icon className="h-4 w-4" />}
{tab.label}
{tab.count !== undefined && (
<span
className={`px-2 py-0.5 text-xs rounded-full ${activeTab === tab.id
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300'
: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400'
}`}
>
{tab.count}
</span>
)}
{activeTab === tab.id && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600 dark:bg-blue-400 rounded-t-full" />
)}
</button>
))}
</div>
)
}

View File

@@ -0,0 +1,141 @@
import { useState, createContext, useContext, useCallback, type ReactNode } from 'react'
import { CheckCircle, XCircle, AlertTriangle, Info, X } from 'lucide-react'
type ToastType = 'success' | 'error' | 'warning' | 'info'
interface Toast {
id: string
type: ToastType
message: string
duration?: number
}
interface ToastContextValue {
toasts: Toast[]
addToast: (type: ToastType, message: string, duration?: number) => void
removeToast: (id: string) => void
success: (message: string) => void
error: (message: string) => void
warning: (message: string) => void
info: (message: string) => void
}
const ToastContext = createContext<ToastContextValue | null>(null)
export function useToast() {
const context = useContext(ToastContext)
if (!context) {
throw new Error('useToast must be used within a ToastProvider')
}
return context
}
interface ToastProviderProps {
children: ReactNode
}
export function ToastProvider({ children }: ToastProviderProps) {
const [toasts, setToasts] = useState<Toast[]>([])
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id))
}, [])
const addToast = useCallback(
(type: ToastType, message: string, duration = 3000) => {
const id = Math.random().toString(36).substring(2, 9)
const toast: Toast = { id, type, message, duration }
setToasts((prev) => [...prev, toast])
if (duration > 0) {
setTimeout(() => removeToast(id), duration)
}
},
[removeToast]
)
const success = useCallback((message: string) => addToast('success', message), [addToast])
const error = useCallback((message: string) => addToast('error', message), [addToast])
const warning = useCallback((message: string) => addToast('warning', message), [addToast])
const info = useCallback((message: string) => addToast('info', message), [addToast])
return (
<ToastContext.Provider
value={{ toasts, addToast, removeToast, success, error, warning, info }}
>
{children}
<ToastContainer toasts={toasts} onRemove={removeToast} />
</ToastContext.Provider>
)
}
interface ToastContainerProps {
toasts: Toast[]
onRemove: (id: string) => void
}
function ToastContainer({ toasts, onRemove }: ToastContainerProps) {
if (toasts.length === 0) return null
return (
<div className="fixed bottom-6 right-6 z-[9999] space-y-3">
{toasts.map((toast) => (
<ToastItem key={toast.id} toast={toast} onRemove={onRemove} />
))}
</div>
)
}
interface ToastItemProps {
toast: Toast
onRemove: (id: string) => void
}
const typeStyles: Record<ToastType, { bg: string; icon: typeof CheckCircle }> = {
success: {
bg: 'bg-green-50 dark:bg-green-900/30 border-green-200 dark:border-green-800',
icon: CheckCircle,
},
error: {
bg: 'bg-red-50 dark:bg-red-900/30 border-red-200 dark:border-red-800',
icon: XCircle,
},
warning: {
bg: 'bg-yellow-50 dark:bg-yellow-900/30 border-yellow-200 dark:border-yellow-800',
icon: AlertTriangle,
},
info: {
bg: 'bg-blue-50 dark:bg-blue-900/30 border-blue-200 dark:border-blue-800',
icon: Info,
},
}
const iconColors: Record<ToastType, string> = {
success: 'text-green-500',
error: 'text-red-500',
warning: 'text-yellow-500',
info: 'text-blue-500',
}
function ToastItem({ toast, onRemove }: ToastItemProps) {
const { bg, icon: Icon } = typeStyles[toast.type]
const iconColor = iconColors[toast.type]
return (
<div
className={`flex items-center gap-3 px-4 py-3 rounded-xl border shadow-lg animate-fadeIn min-w-[300px] max-w-md ${bg}`}
>
<Icon className={`h-5 w-5 flex-shrink-0 ${iconColor}`} />
<p className="flex-1 text-sm font-medium text-slate-900 dark:text-slate-100">
{toast.message}
</p>
<button
onClick={() => onRemove(toast.id)}
className="p-1 rounded-lg hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
aria-label="关闭"
>
<X className="h-4 w-4 text-slate-400" />
</button>
</div>
)
}

View File

@@ -0,0 +1,33 @@
export { default as Button } from './Button'
export type { ButtonProps, ButtonVariant, ButtonSize } from './Button'
export { default as Card, CardHeader, CardTitle, CardContent } from './Card'
export type { CardProps, CardHeaderProps, CardTitleProps, CardContentProps } from './Card'
export { default as Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from './Table'
export type {
TableProps,
TableHeaderProps,
TableBodyProps,
TableRowProps,
TableHeadProps,
TableCellProps,
} from './Table'
export { default as Progress } from './Progress'
export type { ProgressProps } from './Progress'
export { default as StatusBadge } from './StatusBadge'
export type { StatusBadgeProps } from './StatusBadge'
export { default as Input, Textarea } from './Input'
export type { InputProps, TextareaProps } from './Input'
export { default as Select } from './Select'
export type { SelectProps, SelectOption } from './Select'
export { ErrorBoundary } from './ErrorBoundary'
export { ToastProvider, useToast } from './Toast'
export { Tabs } from './Tabs'
export type { TabItem } from './Tabs'

View File

@@ -0,0 +1,64 @@
import { Users, CheckCircle, XCircle, AlertTriangle, Zap, Activity } from 'lucide-react'
import type { DashboardStats } from '../../types'
import StatsCard from './StatsCard'
interface PoolStatusProps {
stats: DashboardStats | null
loading: boolean
error?: string | null
}
export default function PoolStatus({ stats, loading, error }: PoolStatusProps) {
if (error) {
return (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-4">
<div className="flex items-center gap-2 text-red-700 dark:text-red-400">
<XCircle className="h-5 w-5" />
<span className="font-medium"></span>
</div>
<p className="mt-1 text-sm text-red-600 dark:text-red-300">{error}</p>
</div>
)
}
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4">
<StatsCard
title="总账号数"
value={stats?.total_accounts ?? 0}
icon={Users}
color="blue"
loading={loading}
/>
<StatsCard
title="正常账号"
value={stats?.normal_accounts ?? 0}
icon={CheckCircle}
color="green"
loading={loading}
/>
<StatsCard
title="错误账号"
value={stats?.error_accounts ?? 0}
icon={XCircle}
color="red"
loading={loading}
/>
<StatsCard
title="限流账号"
value={stats?.ratelimit_accounts ?? 0}
icon={AlertTriangle}
color="yellow"
loading={loading}
/>
<StatsCard
title="今日请求"
value={stats?.today_requests ?? 0}
icon={Activity}
color="slate"
loading={loading}
/>
<StatsCard title="RPM" value={stats?.rpm ?? 0} icon={Zap} color="blue" loading={loading} />
</div>
)
}

View File

@@ -0,0 +1,82 @@
import { Clock, CheckCircle, XCircle } from 'lucide-react'
import { Link } from 'react-router-dom'
import type { AddRecord } from '../../types'
import { formatRelativeTime } from '../../utils/format'
import { Card, CardHeader, CardTitle, Button } from '../common'
interface RecentRecordsProps {
records: AddRecord[]
loading?: boolean
}
export default function RecentRecords({ records, loading = false }: RecentRecordsProps) {
const recentRecords = records.slice(0, 5)
return (
<Card hoverable>
<CardHeader>
<CardTitle></CardTitle>
<Link to="/records">
<Button variant="ghost" size="sm">
</Button>
</Link>
</CardHeader>
{loading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="h-16 bg-slate-100 dark:bg-slate-700 rounded-lg animate-pulse" />
))}
</div>
) : recentRecords.length === 0 ? (
<div className="text-center py-8">
<Clock className="h-12 w-12 mx-auto text-slate-300 dark:text-slate-600" />
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400"></p>
<Link to="/upload" className="mt-4 inline-block">
<Button size="sm"></Button>
</Link>
</div>
) : (
<div className="space-y-3">
{recentRecords.map((record) => (
<div
key={record.id}
className="flex items-center justify-between p-3 bg-slate-50 dark:bg-slate-700/50 rounded-lg transition-colors duration-200 hover:bg-slate-100 dark:hover:bg-slate-700"
>
<div className="flex items-center gap-3">
<div
className={`p-2 rounded-lg ${
record.failed === 0
? 'bg-green-100 dark:bg-green-900/30'
: 'bg-yellow-100 dark:bg-yellow-900/30'
}`}
>
{record.failed === 0 ? (
<CheckCircle className="h-4 w-4 text-green-600 dark:text-green-400" />
) : (
<XCircle className="h-4 w-4 text-yellow-600 dark:text-yellow-400" />
)}
</div>
<div>
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">
{record.source === 'manual' ? '手动上传' : '自动补号'}
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
{formatRelativeTime(record.timestamp)}
</p>
</div>
</div>
<div className="text-right">
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">
+{record.success}
</p>
{record.failed > 0 && <p className="text-xs text-red-500"> {record.failed}</p>}
</div>
</div>
))}
</div>
)}
</Card>
)
}

View File

@@ -0,0 +1,96 @@
import type { LucideIcon } from 'lucide-react'
import { TrendingUp, TrendingDown, Minus } from 'lucide-react'
import { Card } from '../common'
interface StatsCardProps {
title: string
value: number | string
icon: LucideIcon
trend?: 'up' | 'down' | 'stable'
trendValue?: string
color?: 'blue' | 'green' | 'yellow' | 'red' | 'slate'
loading?: boolean
}
export default function StatsCard({
title,
value,
icon: Icon,
trend,
trendValue,
color = 'blue',
loading = false,
}: StatsCardProps) {
const colorStyles = {
blue: {
bg: 'bg-blue-50 dark:bg-blue-900/20 group-hover:bg-blue-100 dark:group-hover:bg-blue-900/40',
icon: 'text-blue-600 dark:text-blue-400',
gradient: 'from-blue-500/20 to-blue-600/20',
},
green: {
bg: 'bg-green-50 dark:bg-green-900/20 group-hover:bg-green-100 dark:group-hover:bg-green-900/40',
icon: 'text-green-600 dark:text-green-400',
gradient: 'from-green-500/20 to-green-600/20',
},
yellow: {
bg: 'bg-yellow-50 dark:bg-yellow-900/20 group-hover:bg-yellow-100 dark:group-hover:bg-yellow-900/40',
icon: 'text-yellow-600 dark:text-yellow-400',
gradient: 'from-yellow-500/20 to-yellow-600/20',
},
red: {
bg: 'bg-red-50 dark:bg-red-900/20 group-hover:bg-red-100 dark:group-hover:bg-red-900/40',
icon: 'text-red-600 dark:text-red-400',
gradient: 'from-red-500/20 to-red-600/20',
},
slate: {
bg: 'bg-slate-50 dark:bg-slate-800 group-hover:bg-slate-100 dark:group-hover:bg-slate-700',
icon: 'text-slate-600 dark:text-slate-400',
gradient: 'from-slate-500/20 to-slate-600/20',
},
}
const trendStyles = {
up: {
icon: TrendingUp,
color: 'text-green-600 dark:text-green-400',
},
down: {
icon: TrendingDown,
color: 'text-red-600 dark:text-red-400',
},
stable: {
icon: Minus,
color: 'text-slate-500 dark:text-slate-400',
},
}
const TrendIcon = trend ? trendStyles[trend].icon : null
return (
<Card hoverable className="stat-card group transition-all duration-300">
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="text-sm font-medium text-slate-500 dark:text-slate-400">{title}</p>
{loading ? (
<div className="mt-2 h-8 w-24 bg-slate-200 dark:bg-slate-700 rounded animate-pulse" />
) : (
<div className="mt-2 flex items-baseline gap-2">
<span className="text-2xl font-bold text-slate-900 dark:text-slate-100 tracking-tight">
{typeof value === 'number' ? value.toLocaleString() : value}
</span>
</div>
)}
{trend && trendValue && TrendIcon && (
<div className={`mt-2 flex items-center gap-1 text-sm ${trendStyles[trend].color}`}>
<TrendIcon className="h-4 w-4" />
<span>{trendValue}</span>
</div>
)}
</div>
<div className={`p-3 rounded-xl transition-colors duration-300 ${colorStyles[color].bg} bg-gradient-to-br ${colorStyles[color].gradient}`}>
<Icon className={`h-6 w-6 ${colorStyles[color].icon}`} />
</div>
</div>
</Card>
)
}

View File

@@ -0,0 +1,3 @@
export { default as StatsCard } from './StatsCard'
export { default as PoolStatus } from './PoolStatus'
export { default as RecentRecords } from './RecentRecords'

View File

@@ -0,0 +1,97 @@
import { Menu, Moon, Sun } from 'lucide-react'
import { useState, useEffect } from 'react'
interface HeaderProps {
onMenuClick: () => void
isConnected?: boolean
}
export default function Header({ onMenuClick, isConnected = false }: HeaderProps) {
const [isDark, setIsDark] = useState(false)
useEffect(() => {
// Check for saved theme preference or system preference
const savedTheme = localStorage.getItem('theme')
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
setIsDark(true)
document.documentElement.classList.add('dark')
}
}, [])
const toggleTheme = () => {
setIsDark(!isDark)
if (isDark) {
document.documentElement.classList.remove('dark')
localStorage.setItem('theme', 'light')
} else {
document.documentElement.classList.add('dark')
localStorage.setItem('theme', 'dark')
}
}
return (
<header className="sticky top-0 z-40 flex h-16 items-center gap-4 border-b border-slate-200/50 dark:border-slate-800/50 bg-white/80 dark:bg-slate-900/80 backdrop-blur-xl px-4 lg:px-6 transition-all duration-300">
{/* Mobile menu button */}
<button
onClick={onMenuClick}
className="lg:hidden p-2 rounded-xl hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
aria-label="打开菜单"
>
<Menu className="h-5 w-5 text-slate-600 dark:text-slate-400" />
</button>
{/* Logo - Mobile Only */}
<div className="flex lg:hidden items-center gap-2">
<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>
</div>
<span className="font-bold text-slate-900 dark:text-slate-100 hidden sm:inline">
Codex Pool
</span>
</div>
{/* Spacer */}
<div className="flex-1" />
{/* Connection status */}
<div
className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${isConnected
? 'bg-green-50 text-green-700 border border-green-200 dark:bg-green-900/20 dark:text-green-400 dark:border-green-900/50'
: 'bg-red-50 text-red-700 border border-red-200 dark:bg-red-900/20 dark:text-red-400 dark:border-red-900/50'
}`}
>
{isConnected ? (
<>
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
</span>
<span className="hidden sm:inline"></span>
</>
) : (
<>
<span className="relative flex h-2 w-2">
<span className="relative inline-flex rounded-full h-2 w-2 bg-red-500"></span>
</span>
<span className="hidden sm:inline"></span>
</>
)}
</div>
{/* Theme toggle */}
<button
onClick={toggleTheme}
className="p-2 rounded-xl hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
aria-label={isDark ? '切换到浅色模式' : '切换到深色模式'}
>
{isDark ? (
<Sun className="h-5 w-5 text-slate-600 dark:text-slate-400" />
) : (
<Moon className="h-5 w-5 text-slate-600 dark:text-slate-400" />
)}
</button>
</header>
)
}

View File

@@ -0,0 +1,30 @@
import { useState } from 'react'
import { Outlet } from 'react-router-dom'
import Header from './Header'
import Sidebar from './Sidebar'
import { useConfig } from '../../hooks/useConfig'
export default function Layout() {
const [sidebarOpen, setSidebarOpen] = useState(false)
const { isConnected } = useConfig()
return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
<div className="flex">
{/* Sidebar */}
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
{/* Main content */}
<div className="flex-1 flex flex-col min-h-screen lg:ml-0">
{/* Header */}
<Header onMenuClick={() => setSidebarOpen(true)} isConnected={isConnected} />
{/* Page content */}
<main className="flex-1 p-4 lg:p-6">
<Outlet />
</main>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,174 @@
import { NavLink, useLocation } from 'react-router-dom'
import { useState } from 'react'
import {
LayoutDashboard,
Upload,
History,
Users,
Settings,
X,
Activity,
ChevronDown,
Server,
Mail,
Cog,
UsersRound
} from 'lucide-react'
interface SidebarProps {
isOpen: boolean
onClose: () => void
}
interface NavItem {
to: string
icon: React.ComponentType<{ className?: string }>
label: string
children?: NavItem[]
}
const navItems: NavItem[] = [
{ to: '/', icon: LayoutDashboard, label: '仪表盘' },
{ to: '/upload', icon: Upload, label: '上传入库' },
{ to: '/team', icon: UsersRound, label: 'Team 批量处理' },
{ to: '/records', icon: History, label: '加号记录' },
{ to: '/accounts', icon: Users, label: '号池账号' },
{ to: '/monitor', icon: Activity, label: '号池监控' },
{
to: '/config',
icon: Settings,
label: '系统配置',
children: [
{ to: '/config', icon: Cog, label: '配置概览' },
{ to: '/config/s2a', icon: Server, label: 'S2A 配置' },
{ to: '/config/email', icon: Mail, label: '邮箱配置' },
]
},
]
export default function Sidebar({ isOpen, onClose }: SidebarProps) {
const location = useLocation()
const [expandedItems, setExpandedItems] = useState<string[]>(['/config'])
const toggleExpand = (path: string) => {
setExpandedItems(prev =>
prev.includes(path)
? prev.filter(p => p !== path)
: [...prev, path]
)
}
const isItemActive = (item: NavItem): boolean => {
if (item.children) {
return item.children.some(child => location.pathname === child.to)
}
return location.pathname === item.to
}
const renderNavItem = (item: NavItem, isChild = false) => {
const hasChildren = item.children && item.children.length > 0
const isExpanded = expandedItems.includes(item.to)
const isActive = isItemActive(item)
if (hasChildren) {
return (
<div key={item.to} className="space-y-1">
<button
onClick={() => toggleExpand(item.to)}
className={`w-full flex items-center justify-between gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-200 ${isActive
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
: 'text-slate-600 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800/50'
}`}
>
<div className="flex items-center gap-3">
<item.icon className="h-5 w-5" />
{item.label}
</div>
<ChevronDown
className={`h-4 w-4 transition-transform duration-200 ${isExpanded ? 'rotate-180' : ''}`}
/>
</button>
{/* 子菜单 */}
<div className={`ml-4 pl-3 border-l-2 border-slate-200 dark:border-slate-700 space-y-1 overflow-hidden transition-all duration-200 ${isExpanded ? 'max-h-96 opacity-100' : 'max-h-0 opacity-0'
}`}>
{item.children?.map(child => renderNavItem(child, true))}
</div>
</div>
)
}
return (
<NavLink
key={item.to}
to={item.to}
onClick={onClose}
end={item.to === '/config'}
className={({ isActive }) =>
`flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-200 ${isChild ? 'py-2' : ''
} ${isActive
? isChild
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400'
: 'bg-gradient-to-r from-blue-600 to-indigo-600 text-white shadow-md shadow-blue-500/25'
: 'text-slate-600 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800/50'
}`
}
>
<item.icon className={`${isChild ? 'h-4 w-4' : 'h-5 w-5'}`} />
{item.label}
</NavLink>
)
}
return (
<>
{/* Mobile overlay */}
{isOpen && <div className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm lg:hidden" onClick={onClose} />}
{/* Sidebar */}
<aside
className={`fixed inset-y-0 left-0 z-50 w-64 bg-white/80 dark:bg-slate-900/90 backdrop-blur-xl border-r border-slate-200 dark:border-slate-800 transform transition-transform duration-300 ease-in-out lg:translate-x-0 lg:static lg:z-auto ${isOpen ? 'translate-x-0' : '-translate-x-full'
}`}
>
{/* Mobile close button */}
<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="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>
</div>
<span className="font-bold text-lg text-slate-900 dark:text-slate-100 tracking-tight">Codex Pool</span>
</div>
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
aria-label="关闭菜单"
>
<X className="h-5 w-5 text-slate-600 dark:text-slate-400" />
</button>
</div>
{/* Desktop Header (Logo) */}
<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="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>
</div>
<span className="font-bold text-lg text-slate-900 dark:text-slate-100 tracking-tight">Codex Pool</span>
</div>
</div>
{/* Navigation */}
<nav className="p-4 space-y-1.5">
{navItems.map(item => renderNavItem(item))}
</nav>
{/* Footer */}
<div className="absolute bottom-0 left-0 right-0 p-4 border-t border-slate-200/50 dark:border-slate-800/50 bg-white/50 dark:bg-slate-900/50 backdrop-blur-sm">
<div className="text-xs text-slate-500 dark:text-slate-400 text-center font-medium">
Codex Pool v1.0.0
</div>
</div>
</aside>
</>
)
}

View File

@@ -0,0 +1,3 @@
export { default as Layout } from './Layout'
export { default as Header } from './Header'
export { default as Sidebar } from './Sidebar'

View File

@@ -0,0 +1,103 @@
import { CheckCircle, Trash2 } from 'lucide-react'
import type { AddRecord } from '../../types'
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell, Button } from '../common'
import { formatDateTime } from '../../utils/format'
interface RecordListProps {
records: AddRecord[]
onDelete?: (id: string) => void
loading?: boolean
}
export default function RecordList({ records, onDelete, loading = false }: RecordListProps) {
if (loading) {
return (
<div className="space-y-3">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="h-16 bg-slate-100 dark:bg-slate-700 rounded-lg animate-pulse" />
))}
</div>
)
}
if (records.length === 0) {
return (
<div className="text-center py-12">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-slate-100 dark:bg-slate-700 mb-4">
<CheckCircle className="h-8 w-8 text-slate-400 dark:text-slate-500" />
</div>
<p className="text-slate-500 dark:text-slate-400"></p>
</div>
)
}
return (
<div className="border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow hoverable={false}>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead></TableHead>
{onDelete && <TableHead className="w-16"></TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{records.map((record) => (
<TableRow key={record.id}>
<TableCell>
<span className="text-sm">{formatDateTime(record.timestamp)}</span>
</TableCell>
<TableCell>
<span
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
record.source === 'manual'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
}`}
>
{record.source === 'manual' ? '手动上传' : '自动补号'}
</span>
</TableCell>
<TableCell className="text-right font-medium">{record.total}</TableCell>
<TableCell className="text-right">
<span className="text-green-600 dark:text-green-400 font-medium">
{record.success}
</span>
</TableCell>
<TableCell className="text-right">
{record.failed > 0 ? (
<span className="text-red-600 dark:text-red-400 font-medium">
{record.failed}
</span>
) : (
<span className="text-slate-400">0</span>
)}
</TableCell>
<TableCell>
<span className="text-sm text-slate-500 dark:text-slate-400 truncate max-w-[200px] block">
{record.details || '-'}
</span>
</TableCell>
{onDelete && (
<TableCell>
<Button
variant="ghost"
size="sm"
onClick={() => onDelete(record.id)}
className="text-slate-400 hover:text-red-500"
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}

View File

@@ -0,0 +1,76 @@
import { TrendingUp, Calendar, CheckCircle, XCircle } from 'lucide-react'
import { Card } from '../common'
interface RecordStatsProps {
stats: {
totalRecords: number
totalAdded: number
totalSuccess: number
totalFailed: number
todayAdded: number
weekAdded: number
}
}
export default function RecordStats({ stats }: RecordStatsProps) {
const successRate =
stats.totalAdded > 0 ? ((stats.totalSuccess / stats.totalAdded) * 100).toFixed(1) : '0'
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<Card padding="sm">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<TrendingUp className="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
<div>
<p className="text-xs text-slate-500 dark:text-slate-400"></p>
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">
{stats.totalSuccess}
</p>
</div>
</div>
</Card>
<Card padding="sm">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-50 dark:bg-green-900/20 rounded-lg">
<CheckCircle className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
<div>
<p className="text-xs text-slate-500 dark:text-slate-400"></p>
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">{successRate}%</p>
</div>
</div>
</Card>
<Card padding="sm">
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
<Calendar className="h-5 w-5 text-purple-600 dark:text-purple-400" />
</div>
<div>
<p className="text-xs text-slate-500 dark:text-slate-400"></p>
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">
{stats.todayAdded}
</p>
</div>
</div>
</Card>
<Card padding="sm">
<div className="flex items-center gap-3">
<div className="p-2 bg-orange-50 dark:bg-orange-900/20 rounded-lg">
<XCircle className="h-5 w-5 text-orange-600 dark:text-orange-400" />
</div>
<div>
<p className="text-xs text-slate-500 dark:text-slate-400"></p>
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">
{stats.weekAdded}
</p>
</div>
</div>
</Card>
</div>
)
}

View File

@@ -0,0 +1,2 @@
export { default as RecordList } from './RecordList'
export { default as RecordStats } from './RecordStats'

View File

@@ -0,0 +1,182 @@
import { useState, useMemo } from 'react'
import { CheckSquare, Square, MinusSquare } from 'lucide-react'
import type { CheckedAccount } from '../../types'
import {
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
StatusBadge,
Button,
} from '../common'
import { maskEmail, maskToken } from '../../utils/format'
interface AccountTableProps {
accounts: CheckedAccount[]
selectedIds: number[]
onSelectionChange: (ids: number[]) => void
disabled?: boolean
}
export default function AccountTable({
accounts,
selectedIds,
onSelectionChange,
disabled = false,
}: AccountTableProps) {
const [showTokens, setShowTokens] = useState(false)
const activeAccounts = useMemo(
() => accounts.filter((acc) => acc.status === 'active'),
[accounts]
)
const allSelected = selectedIds.length === accounts.length && accounts.length > 0
const someSelected = selectedIds.length > 0 && selectedIds.length < accounts.length
const activeSelected = activeAccounts.every((acc) => selectedIds.includes(acc.id))
const handleSelectAll = () => {
if (allSelected) {
onSelectionChange([])
} else {
onSelectionChange(accounts.map((acc) => acc.id))
}
}
const handleSelectActive = () => {
onSelectionChange(activeAccounts.map((acc) => acc.id))
}
const handleSelectNone = () => {
onSelectionChange([])
}
const handleToggle = (id: number) => {
if (selectedIds.includes(id)) {
onSelectionChange(selectedIds.filter((i) => i !== id))
} else {
onSelectionChange([...selectedIds, id])
}
}
if (accounts.length === 0) {
return null
}
return (
<div className="space-y-3">
{/* Selection controls */}
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleSelectActive}
disabled={disabled || activeAccounts.length === 0}
>
({activeAccounts.length})
</Button>
<Button variant="outline" size="sm" onClick={handleSelectAll} disabled={disabled}>
({accounts.length})
</Button>
<Button
variant="outline"
size="sm"
onClick={handleSelectNone}
disabled={disabled || selectedIds.length === 0}
>
</Button>
<div className="flex-1" />
<Button variant="ghost" size="sm" onClick={() => setShowTokens(!showTokens)}>
{showTokens ? '隐藏 Token' : '显示 Token'}
</Button>
</div>
{/* Selection summary */}
<div className="text-sm text-slate-600 dark:text-slate-400">
{' '}
<span className="font-medium text-slate-900 dark:text-slate-100">{selectedIds.length}</span>{' '}
{activeSelected && activeAccounts.length > 0 && (
<span className="ml-2 text-green-600 dark:text-green-400">
( {activeAccounts.length} )
</span>
)}
</div>
{/* Table */}
<div className="border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow hoverable={false}>
<TableHead className="w-12">
<button
onClick={handleSelectAll}
disabled={disabled}
className="p-1 hover:bg-slate-200 dark:hover:bg-slate-600 rounded disabled:opacity-50"
>
{allSelected ? (
<CheckSquare className="h-4 w-4 text-blue-600 dark:text-blue-400" />
) : someSelected ? (
<MinusSquare className="h-4 w-4 text-blue-600 dark:text-blue-400" />
) : (
<Square className="h-4 w-4 text-slate-400" />
)}
</button>
</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>Account ID</TableHead>
<TableHead>Plan Type</TableHead>
{showTokens && <TableHead>Token</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{accounts.map((account) => (
<TableRow key={account.id}>
<TableCell>
<button
onClick={() => handleToggle(account.id)}
disabled={disabled}
className="p-1 hover:bg-slate-100 dark:hover:bg-slate-700 rounded disabled:opacity-50"
>
{selectedIds.includes(account.id) ? (
<CheckSquare className="h-4 w-4 text-blue-600 dark:text-blue-400" />
) : (
<Square className="h-4 w-4 text-slate-400" />
)}
</button>
</TableCell>
<TableCell>
<span className="font-mono text-sm">{maskEmail(account.account)}</span>
</TableCell>
<TableCell>
<StatusBadge status={account.status} />
</TableCell>
<TableCell>
<span className="font-mono text-xs text-slate-500 dark:text-slate-400">
{account.accountId || '-'}
</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-600 dark:text-slate-400">
{account.planType || '-'}
</span>
</TableCell>
{showTokens && (
<TableCell>
<span className="font-mono text-xs text-slate-500 dark:text-slate-400">
{maskToken(account.token, 12)}
</span>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)
}

View File

@@ -0,0 +1,72 @@
import { CheckCircle, XCircle, AlertTriangle, Clock } from 'lucide-react'
import { Progress } from '../common'
interface CheckProgressProps {
total: number
checked: number
results: {
active: number
banned: number
token_expired: number
error: number
}
checking: boolean
}
export default function CheckProgress({ total, checked, results, checking }: CheckProgressProps) {
return (
<div className="space-y-4">
{/* Progress bar */}
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">
{checking ? '检查中...' : checked === total && total > 0 ? '检查完成' : '待检查'}
</span>
<span className="text-sm text-slate-500 dark:text-slate-400">
{checked} / {total}
</span>
</div>
<Progress value={checked} max={total} color={checking ? 'blue' : 'green'} size="md" />
</div>
{/* Results summary */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="flex items-center gap-2 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
<CheckCircle className="h-5 w-5 text-green-600 dark:text-green-400" />
<div>
<p className="text-xs text-green-600 dark:text-green-400"></p>
<p className="text-lg font-bold text-green-700 dark:text-green-300">{results.active}</p>
</div>
</div>
<div className="flex items-center gap-2 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
<XCircle className="h-5 w-5 text-red-600 dark:text-red-400" />
<div>
<p className="text-xs text-red-600 dark:text-red-400"></p>
<p className="text-lg font-bold text-red-700 dark:text-red-300">{results.banned}</p>
</div>
</div>
<div className="flex items-center gap-2 p-3 bg-orange-50 dark:bg-orange-900/20 rounded-lg">
<Clock className="h-5 w-5 text-orange-600 dark:text-orange-400" />
<div>
<p className="text-xs text-orange-600 dark:text-orange-400"></p>
<p className="text-lg font-bold text-orange-700 dark:text-orange-300">
{results.token_expired}
</p>
</div>
</div>
<div className="flex items-center gap-2 p-3 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg">
<AlertTriangle className="h-5 w-5 text-yellow-600 dark:text-yellow-400" />
<div>
<p className="text-xs text-yellow-600 dark:text-yellow-400"></p>
<p className="text-lg font-bold text-yellow-700 dark:text-yellow-300">
{results.error}
</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,114 @@
import { useCallback, useState } from 'react'
import { Upload, FileJson, AlertCircle } from 'lucide-react'
interface FileDropzoneProps {
onFileSelect: (file: File) => void
disabled?: boolean
error?: string | null
}
export default function FileDropzone({ onFileSelect, disabled = false, error }: FileDropzoneProps) {
const [isDragging, setIsDragging] = useState(false)
const handleDragOver = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
if (!disabled) {
setIsDragging(true)
}
},
[disabled]
)
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
}, [])
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
if (disabled) return
const files = e.dataTransfer.files
if (files.length > 0) {
const file = files[0]
if (file.type === 'application/json' || file.name.endsWith('.json')) {
onFileSelect(file)
}
}
},
[disabled, onFileSelect]
)
const handleFileInput = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (files && files.length > 0) {
onFileSelect(files[0])
}
// Reset input value to allow selecting the same file again
e.target.value = ''
},
[onFileSelect]
)
return (
<div className="space-y-2">
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`relative border-2 border-dashed rounded-xl p-8 text-center transition-colors ${
disabled
? 'border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50 cursor-not-allowed'
: isDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-slate-300 dark:border-slate-600 hover:border-blue-400 dark:hover:border-blue-500 cursor-pointer'
}`}
>
<input
type="file"
accept=".json,application/json"
onChange={handleFileInput}
disabled={disabled}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
/>
<div className="flex flex-col items-center gap-3">
<div
className={`p-4 rounded-full ${
isDragging ? 'bg-blue-100 dark:bg-blue-900/30' : 'bg-slate-100 dark:bg-slate-700'
}`}
>
{isDragging ? (
<Upload className="h-8 w-8 text-blue-600 dark:text-blue-400" />
) : (
<FileJson className="h-8 w-8 text-slate-400 dark:text-slate-500" />
)}
</div>
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">
{isDragging ? '释放文件以上传' : '拖拽 JSON 文件到此处'}
</p>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400"></p>
</div>
<div className="text-xs text-slate-400 dark:text-slate-500">
: [&#123;"account": "email", "password": "pwd", "token": "..."&#125;]
</div>
</div>
</div>
{error && (
<div className="flex items-center gap-2 text-sm text-red-600 dark:text-red-400">
<AlertCircle className="h-4 w-4" />
<span>{error}</span>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,173 @@
import { useState, useEffect, useRef } from 'react'
import { Terminal, Trash2, Play, Pause } from 'lucide-react'
import { Card, CardHeader, CardTitle, CardContent, Button } from '../common'
interface LogEntry {
timestamp: string
level: string
message: string
email?: string
step?: string
}
interface LogStreamProps {
apiBase?: string
}
const levelColors: Record<string, string> = {
info: 'text-blue-400',
success: 'text-green-400',
warning: 'text-yellow-400',
error: 'text-red-400',
}
const stepColors: Record<string, string> = {
validate: 'bg-purple-500/20 text-purple-400',
register: 'bg-blue-500/20 text-blue-400',
authorize: 'bg-orange-500/20 text-orange-400',
pool: 'bg-green-500/20 text-green-400',
database: 'bg-slate-500/20 text-slate-400',
}
const stepLabels: Record<string, string> = {
validate: '验证',
register: '注册',
authorize: '授权',
pool: '入库',
database: '数据库',
}
export default function LogStream({ apiBase = 'http://localhost:8088' }: LogStreamProps) {
const [logs, setLogs] = useState<LogEntry[]>([])
const [connected, setConnected] = useState(false)
const [paused, setPaused] = useState(false)
const logContainerRef = useRef<HTMLDivElement>(null)
const eventSourceRef = useRef<EventSource | null>(null)
useEffect(() => {
if (paused) return
const eventSource = new EventSource(`${apiBase}/api/logs/stream`)
eventSourceRef.current = eventSource
eventSource.onopen = () => {
setConnected(true)
}
eventSource.onmessage = (event) => {
try {
const log = JSON.parse(event.data) as LogEntry
setLogs((prev) => [...prev.slice(-199), log])
} catch (e) {
console.error('Failed to parse log:', e)
}
}
eventSource.onerror = () => {
setConnected(false)
eventSource.close()
}
return () => {
eventSource.close()
}
}, [apiBase, paused])
useEffect(() => {
if (logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight
}
}, [logs])
const handleClear = async () => {
try {
await fetch(`${apiBase}/api/logs/clear`, { method: 'POST' })
setLogs([])
} catch (e) {
console.error('Failed to clear logs:', e)
}
}
const togglePause = () => {
if (!paused && eventSourceRef.current) {
eventSourceRef.current.close()
}
setPaused(!paused)
}
const formatTime = (timestamp: string) => {
try {
return new Date(timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
} catch {
return ''
}
}
return (
<Card className="h-full flex flex-col">
<CardHeader className="flex-shrink-0">
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Terminal className="h-5 w-5" />
<span
className={`w-2 h-2 rounded-full ${connected ? 'bg-green-500' : 'bg-red-500'}`}
/>
</CardTitle>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={togglePause}
icon={paused ? <Play className="h-4 w-4" /> : <Pause className="h-4 w-4" />}
>
{paused ? '继续' : '暂停'}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleClear}
icon={<Trash2 className="h-4 w-4" />}
>
</Button>
</div>
</div>
</CardHeader>
<CardContent className="flex-1 overflow-hidden p-0">
<div
ref={logContainerRef}
className="h-full overflow-y-auto bg-slate-900 dark:bg-slate-950 p-4 font-mono text-xs"
>
{logs.length === 0 ? (
<div className="text-slate-500 text-center py-8">...</div>
) : (
logs.map((log, i) => (
<div key={i} className="flex gap-2 py-0.5 hover:bg-slate-800/50">
<span className="text-slate-500 flex-shrink-0">{formatTime(log.timestamp)}</span>
{log.step && (
<span
className={`px-1.5 rounded text-[10px] uppercase flex-shrink-0 ${stepColors[log.step] || 'bg-slate-500/20 text-slate-400'}`}
>
{stepLabels[log.step] || log.step}
</span>
)}
<span className={`flex-shrink-0 ${levelColors[log.level] || 'text-slate-300'}`}>
{log.level === 'success' ? '✓' : log.level === 'error' ? '✗' : '•'}
</span>
<span className="text-slate-300 break-all">{log.message}</span>
{log.email && (
<span className="text-slate-500 flex-shrink-0 ml-auto">[{log.email}]</span>
)}
</div>
))
)}
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,218 @@
import { useState, useEffect } from 'react'
import { Users, Trash2, RefreshCw, ChevronLeft, ChevronRight } from 'lucide-react'
import { Card, CardHeader, CardTitle, CardContent, Button } from '../common'
interface TeamOwner {
id: number
email: string
account_id: string
status: string
created_at: string
}
interface OwnerListProps {
apiBase?: string
}
const statusColors: Record<string, string> = {
valid: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
registered: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400',
pooled: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
}
const statusLabels: Record<string, string> = {
valid: '有效',
registered: '已注册',
pooled: '已入库',
}
export default function OwnerList({ apiBase = 'http://localhost:8088' }: OwnerListProps) {
const [owners, setOwners] = useState<TeamOwner[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(false)
const [page, setPage] = useState(0)
const [filter, setFilter] = useState<string>('')
const limit = 20
const loadOwners = async () => {
setLoading(true)
try {
const params = new URLSearchParams({
limit: String(limit),
offset: String(page * limit),
})
if (filter) {
params.set('status', filter)
}
const res = await fetch(`${apiBase}/api/db/owners?${params}`)
const data = await res.json()
if (data.code === 0) {
setOwners(data.data.owners || [])
setTotal(data.data.total || 0)
}
} catch (e) {
console.error('Failed to load owners:', e)
} finally {
setLoading(false)
}
}
useEffect(() => {
loadOwners()
}, [page, filter])
const handleDelete = async (id: number) => {
if (!confirm('确认删除此账号?')) return
try {
await fetch(`${apiBase}/api/db/owners/${id}`, { method: 'DELETE' })
loadOwners()
} catch (e) {
console.error('Failed to delete:', e)
}
}
const handleClearAll = async () => {
if (!confirm('确认清空所有账号?此操作不可恢复!')) return
try {
await fetch(`${apiBase}/api/db/owners/clear`, { method: 'POST' })
loadOwners()
} catch (e) {
console.error('Failed to clear:', e)
}
}
const totalPages = Math.ceil(total / limit)
const formatTime = (ts: string) => {
try {
return new Date(ts).toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
} catch {
return ''
}
}
return (
<Card className="h-full flex flex-col">
<CardHeader className="flex-shrink-0">
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" />
({total})
</CardTitle>
<div className="flex gap-2">
<select
className="px-2 py-1 text-sm rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800"
value={filter}
onChange={(e) => {
setFilter(e.target.value)
setPage(0)
}}
>
<option value=""></option>
<option value="valid"></option>
<option value="registered"></option>
<option value="pooled"></option>
</select>
<Button variant="ghost" size="sm" onClick={loadOwners} icon={<RefreshCw className="h-4 w-4" />}>
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleClearAll}
icon={<Trash2 className="h-4 w-4" />}
className="text-red-500 hover:text-red-600"
>
</Button>
</div>
</div>
</CardHeader>
<CardContent className="flex-1 overflow-hidden p-0">
<div className="h-full overflow-auto">
<table className="w-full text-sm">
<thead className="bg-slate-50 dark:bg-slate-800 sticky top-0">
<tr>
<th className="text-left p-3 font-medium text-slate-600 dark:text-slate-400"></th>
<th className="text-left p-3 font-medium text-slate-600 dark:text-slate-400">Account ID</th>
<th className="text-left p-3 font-medium text-slate-600 dark:text-slate-400"></th>
<th className="text-left p-3 font-medium text-slate-600 dark:text-slate-400"></th>
<th className="text-center p-3 font-medium text-slate-600 dark:text-slate-400"></th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={5} className="text-center py-8 text-slate-500">
...
</td>
</tr>
) : owners.length === 0 ? (
<tr>
<td colSpan={5} className="text-center py-8 text-slate-500">
</td>
</tr>
) : (
owners.map((owner) => (
<tr key={owner.id} className="border-t border-slate-100 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800/50">
<td className="p-3 text-slate-900 dark:text-slate-100">{owner.email}</td>
<td className="p-3 font-mono text-xs text-slate-500">{owner.account_id?.slice(0, 20)}...</td>
<td className="p-3">
<span className={`px-2 py-0.5 rounded-full text-xs ${statusColors[owner.status] || 'bg-slate-100 text-slate-700'}`}>
{statusLabels[owner.status] || owner.status}
</span>
</td>
<td className="p-3 text-slate-500 text-xs">{formatTime(owner.created_at)}</td>
<td className="p-3 text-center">
<button
onClick={() => handleDelete(owner.id)}
className="text-red-400 hover:text-red-500"
>
<Trash2 className="h-4 w-4" />
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</CardContent>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex-shrink-0 p-3 border-t border-slate-100 dark:border-slate-800 flex items-center justify-between">
<span className="text-sm text-slate-500">
{page + 1} / {totalPages}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
icon={<ChevronLeft className="h-4 w-4" />}
>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
icon={<ChevronRight className="h-4 w-4" />}
>
</Button>
</div>
</div>
)}
</Card>
)
}

View File

@@ -0,0 +1,141 @@
import { useState } from 'react'
import { Upload, CheckCircle, AlertCircle } from 'lucide-react'
import type { CheckedAccount } from '../../types'
import { Button, Progress } from '../common'
interface PoolActionsProps {
selectedAccounts: CheckedAccount[]
onPool: (accounts: CheckedAccount[]) => Promise<{ success: number; failed: number }>
disabled?: boolean
}
export default function PoolActions({
selectedAccounts,
onPool,
disabled = false,
}: PoolActionsProps) {
const [pooling, setPooling] = useState(false)
const [progress, setProgress] = useState({ current: 0, total: 0 })
const [result, setResult] = useState<{ success: number; failed: number } | null>(null)
const activeSelected = selectedAccounts.filter((acc) => acc.status === 'active')
const hasNonActive = selectedAccounts.some((acc) => acc.status !== 'active')
const handlePool = async () => {
if (selectedAccounts.length === 0) return
setPooling(true)
setProgress({ current: 0, total: selectedAccounts.length })
setResult(null)
try {
const poolResult = await onPool(selectedAccounts)
setResult(poolResult)
} catch (error) {
console.error('Pool error:', error)
setResult({ success: 0, failed: selectedAccounts.length })
} finally {
setPooling(false)
}
}
if (selectedAccounts.length === 0) {
return (
<div className="text-center py-4 text-slate-500 dark:text-slate-400">
</div>
)
}
return (
<div className="space-y-4">
{/* Warning for non-active accounts */}
{hasNonActive && (
<div className="flex items-start gap-2 p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<AlertCircle className="h-5 w-5 text-yellow-600 dark:text-yellow-400 mt-0.5" />
<div className="text-sm">
<p className="font-medium text-yellow-800 dark:text-yellow-200">
</p>
<p className="text-yellow-700 dark:text-yellow-300">
"正常"使
</p>
</div>
</div>
)}
{/* Summary */}
<div className="flex items-center justify-between p-4 bg-slate-50 dark:bg-slate-800 rounded-lg">
<div>
<p className="text-sm text-slate-600 dark:text-slate-400"></p>
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">
{selectedAccounts.length}
</p>
{activeSelected.length !== selectedAccounts.length && (
<p className="text-xs text-slate-500 dark:text-slate-400">
{activeSelected.length}
</p>
)}
</div>
<Button
onClick={handlePool}
disabled={disabled || pooling}
loading={pooling}
icon={pooling ? undefined : <Upload className="h-4 w-4" />}
>
{pooling ? '入库中...' : '入库选中'}
</Button>
</div>
{/* Progress */}
{pooling && (
<div>
<Progress
value={progress.current}
max={progress.total}
color="blue"
showLabel
label="入库进度"
/>
</div>
)}
{/* Result */}
{result && (
<div
className={`flex items-center gap-3 p-4 rounded-lg ${
result.failed === 0
? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800'
: 'bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800'
}`}
>
{result.failed === 0 ? (
<CheckCircle className="h-6 w-6 text-green-600 dark:text-green-400" />
) : (
<AlertCircle className="h-6 w-6 text-yellow-600 dark:text-yellow-400" />
)}
<div>
<p
className={`font-medium ${
result.failed === 0
? 'text-green-800 dark:text-green-200'
: 'text-yellow-800 dark:text-yellow-200'
}`}
>
</p>
<p
className={`text-sm ${
result.failed === 0
? 'text-green-700 dark:text-green-300'
: 'text-yellow-700 dark:text-yellow-300'
}`}
>
{result.success} {result.failed}
</p>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,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'

View File

@@ -0,0 +1,148 @@
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react'
import type { AppConfig } from '../types'
import { defaultConfig } from '../types'
import { loadConfig, saveConfig } from '../utils/storage'
import { S2AClient } from '../api/s2a'
interface ConfigContextValue {
config: AppConfig
updateConfig: (updates: Partial<AppConfig>) => void
updateS2AConfig: (updates: Partial<AppConfig['s2a']>) => void
updatePoolingConfig: (updates: Partial<AppConfig['pooling']>) => void
updateCheckConfig: (updates: Partial<AppConfig['check']>) => void
updateEmailConfig: (updates: Partial<AppConfig['email']>) => void
isConnected: boolean
testConnection: () => Promise<boolean>
s2aClient: S2AClient | null
}
const ConfigContext = createContext<ConfigContextValue | null>(null)
export function ConfigProvider({ children }: { children: ReactNode }) {
const [config, setConfig] = useState<AppConfig>(defaultConfig)
const [isConnected, setIsConnected] = useState(false)
const [s2aClient, setS2aClient] = useState<S2AClient | null>(null)
// Load config from localStorage on mount
useEffect(() => {
const savedConfig = loadConfig()
setConfig(savedConfig)
// Create S2A client if config is available
if (savedConfig.s2a.apiBase && savedConfig.s2a.adminKey) {
const client = new S2AClient({
baseUrl: savedConfig.s2a.apiBase,
apiKey: savedConfig.s2a.adminKey,
})
setS2aClient(client)
// Test connection on load
client.testConnection().then(setIsConnected)
}
}, [])
// Update S2A client when config changes
useEffect(() => {
if (config.s2a.apiBase && config.s2a.adminKey) {
const client = new S2AClient({
baseUrl: config.s2a.apiBase,
apiKey: config.s2a.adminKey,
})
setS2aClient(client)
} else {
setS2aClient(null)
setIsConnected(false)
}
}, [config.s2a.apiBase, config.s2a.adminKey])
const updateConfig = useCallback((updates: Partial<AppConfig>) => {
setConfig((prev) => {
const newConfig = { ...prev, ...updates }
saveConfig(newConfig)
return newConfig
})
}, [])
const updateS2AConfig = useCallback((updates: Partial<AppConfig['s2a']>) => {
setConfig((prev) => {
const newConfig = {
...prev,
s2a: { ...prev.s2a, ...updates },
}
saveConfig(newConfig)
return newConfig
})
}, [])
const updatePoolingConfig = useCallback((updates: Partial<AppConfig['pooling']>) => {
setConfig((prev) => {
const newConfig = {
...prev,
pooling: { ...prev.pooling, ...updates },
}
saveConfig(newConfig)
return newConfig
})
}, [])
const updateCheckConfig = useCallback((updates: Partial<AppConfig['check']>) => {
setConfig((prev) => {
const newConfig = {
...prev,
check: { ...prev.check, ...updates },
}
saveConfig(newConfig)
return newConfig
})
}, [])
const updateEmailConfig = useCallback((updates: Partial<AppConfig['email']>) => {
setConfig((prev) => {
const newConfig = {
...prev,
email: { ...prev.email, ...updates },
}
saveConfig(newConfig)
return newConfig
})
}, [])
const testConnection = useCallback(async (): Promise<boolean> => {
try {
// 使用后端代理 API 来测试 S2A 连接(避免 CORS 问题)
const res = await fetch('http://localhost:8088/api/s2a/test')
const connected = res.ok
setIsConnected(connected)
return connected
} catch {
setIsConnected(false)
return false
}
}, [])
return (
<ConfigContext.Provider
value={{
config,
updateConfig,
updateS2AConfig,
updatePoolingConfig,
updateCheckConfig,
updateEmailConfig,
isConnected,
testConnection,
s2aClient,
}}
>
{children}
</ConfigContext.Provider>
)
}
export function useConfigContext(): ConfigContextValue {
const context = useContext(ConfigContext)
if (!context) {
throw new Error('useConfigContext must be used within a ConfigProvider')
}
return context
}

View File

@@ -0,0 +1,110 @@
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react'
import type { AddRecord } from '../types'
import { loadRecords, saveRecords, generateId } from '../utils/storage'
interface RecordsContextValue {
records: AddRecord[]
addRecord: (record: Omit<AddRecord, 'id' | 'timestamp'>) => void
deleteRecord: (id: string) => void
clearRecords: () => void
getRecordsByDateRange: (startDate: Date, endDate: Date) => AddRecord[]
getStats: () => {
totalRecords: number
totalAdded: number
totalSuccess: number
totalFailed: number
todayAdded: number
weekAdded: number
}
}
const RecordsContext = createContext<RecordsContextValue | null>(null)
export function RecordsProvider({ children }: { children: ReactNode }) {
const [records, setRecords] = useState<AddRecord[]>([])
// Load records from localStorage on mount
useEffect(() => {
const savedRecords = loadRecords()
setRecords(savedRecords)
}, [])
const addRecord = useCallback((record: Omit<AddRecord, 'id' | 'timestamp'>) => {
const newRecord: AddRecord = {
...record,
id: generateId(),
timestamp: new Date().toISOString(),
}
setRecords((prev) => {
const updated = [newRecord, ...prev]
saveRecords(updated)
return updated
})
}, [])
const deleteRecord = useCallback((id: string) => {
setRecords((prev) => {
const updated = prev.filter((r) => r.id !== id)
saveRecords(updated)
return updated
})
}, [])
const clearRecords = useCallback(() => {
setRecords([])
saveRecords([])
}, [])
const getRecordsByDateRange = useCallback(
(startDate: Date, endDate: Date): AddRecord[] => {
return records.filter((record) => {
const recordDate = new Date(record.timestamp)
return recordDate >= startDate && recordDate <= endDate
})
},
[records]
)
const getStats = useCallback(() => {
const now = new Date()
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const weekStart = new Date(todayStart)
weekStart.setDate(weekStart.getDate() - 7)
const todayRecords = records.filter((r) => new Date(r.timestamp) >= todayStart)
const weekRecords = records.filter((r) => new Date(r.timestamp) >= weekStart)
return {
totalRecords: records.length,
totalAdded: records.reduce((sum, r) => sum + r.total, 0),
totalSuccess: records.reduce((sum, r) => sum + r.success, 0),
totalFailed: records.reduce((sum, r) => sum + r.failed, 0),
todayAdded: todayRecords.reduce((sum, r) => sum + r.success, 0),
weekAdded: weekRecords.reduce((sum, r) => sum + r.success, 0),
}
}, [records])
return (
<RecordsContext.Provider
value={{
records,
addRecord,
deleteRecord,
clearRecords,
getRecordsByDateRange,
getStats,
}}
>
{children}
</RecordsContext.Provider>
)
}
export function useRecordsContext(): RecordsContextValue {
const context = useContext(RecordsContext)
if (!context) {
throw new Error('useRecordsContext must be used within a RecordsProvider')
}
return context
}

View File

@@ -0,0 +1,2 @@
export { ConfigProvider, useConfigContext } from './ConfigContext'
export { RecordsProvider, useRecordsContext } from './RecordsContext'

View File

@@ -0,0 +1,5 @@
export { useConfig } from './useConfig'
export { useRecords } from './useRecords'
export { useS2AApi } from './useS2AApi'
export { useAccountCheck } from './useAccountCheck'
export { useBackendApi } from './useBackendApi'

View File

@@ -0,0 +1,160 @@
import { useState, useCallback } from 'react'
import type { AccountInput, CheckedAccount, AccountStatus } from '../types'
import { ChatGPTClient } from '../api/chatgpt'
interface CheckProgress {
total: number
checked: number
results: {
active: number
banned: number
token_expired: number
error: number
}
}
export function useAccountCheck() {
const [accounts, setAccounts] = useState<CheckedAccount[]>([])
const [checking, setChecking] = useState(false)
const [progress, setProgress] = useState<CheckProgress>({
total: 0,
checked: 0,
results: { active: 0, banned: 0, token_expired: 0, error: 0 },
})
const loadFromJson = useCallback((jsonData: AccountInput[]) => {
const checkedAccounts: CheckedAccount[] = jsonData.map((account, index) => ({
...account,
id: index,
status: 'pending' as AccountStatus,
}))
setAccounts(checkedAccounts)
setProgress({
total: checkedAccounts.length,
checked: 0,
results: { active: 0, banned: 0, token_expired: 0, error: 0 },
})
}, [])
const parseJsonFile = useCallback(
async (file: File): Promise<{ success: boolean; error?: string }> => {
try {
const text = await file.text()
const data = JSON.parse(text)
if (!Array.isArray(data)) {
return { success: false, error: 'JSON 文件必须是数组格式' }
}
// Validate each account
for (let i = 0; i < data.length; i++) {
const item = data[i]
if (!item.account || !item.password || !item.token) {
return {
success: false,
error: `${i + 1} 条记录缺少必要字段 (account, password, token)`,
}
}
}
loadFromJson(data)
return { success: true }
} catch {
return { success: false, error: 'JSON 解析失败,请检查文件格式' }
}
},
[loadFromJson]
)
const startCheck = useCallback(
async (concurrency: number = 20) => {
if (accounts.length === 0 || checking) return
setChecking(true)
const client = new ChatGPTClient()
const results = { active: 0, banned: 0, token_expired: 0, error: 0 }
// Mark all as checking
setAccounts((prev) => prev.map((acc) => ({ ...acc, status: 'checking' as AccountStatus })))
await client.batchCheck(
accounts.map((acc) => ({
account: acc.account,
password: acc.password,
token: acc.token,
})),
{
concurrency,
onProgress: (checkedAccount, index) => {
// Update results count
const status = checkedAccount.status
if (status === 'active') results.active++
else if (status === 'banned') results.banned++
else if (status === 'token_expired') results.token_expired++
else results.error++
// Update account in list
setAccounts((prev) =>
prev.map((acc, i) =>
i === index
? {
...acc,
status: checkedAccount.status,
accountId: checkedAccount.accountId,
planType: checkedAccount.planType,
error: checkedAccount.error,
}
: acc
)
)
// Update progress
setProgress({
total: accounts.length,
checked: index + 1,
results: { ...results },
})
},
}
)
setChecking(false)
},
[accounts, checking]
)
const selectAll = useCallback(
(status?: AccountStatus) => {
return accounts.filter((acc) => !status || acc.status === status).map((acc) => acc.id)
},
[accounts]
)
const getSelectedAccounts = useCallback(
(ids: number[]) => {
return accounts.filter((acc) => ids.includes(acc.id))
},
[accounts]
)
const clearAccounts = useCallback(() => {
setAccounts([])
setProgress({
total: 0,
checked: 0,
results: { active: 0, banned: 0, token_expired: 0, error: 0 },
})
}, [])
return {
accounts,
checking,
progress,
loadFromJson,
parseJsonFile,
startCheck,
selectAll,
getSelectedAccounts,
clearAccounts,
}
}

View File

@@ -0,0 +1,181 @@
import { useState, useCallback } from 'react'
import { useConfig } from './useConfig'
import { useToast } from '../components/common'
interface PoolStatus {
target: number
current: number
deficit: number
last_check: string
auto_add: boolean
min_interval: number
last_auto_add: string
polling_enabled: boolean
polling_interval: number
}
interface HealthCheckResult {
account_id: number
email: string
status: string
checked_at: string
error?: string
}
export function useBackendApi() {
const { config } = useConfig()
const toast = useToast()
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// 推断后端 API 地址 (S2A 在 8080, 后端 API 在 8088)
const getBackendUrl = useCallback(() => {
const s2aBase = config.s2a.apiBase
return s2aBase.replace(/\/api.*$/, '').replace(/:8080/, ':8088')
}, [config.s2a.apiBase])
// 通用请求方法
const request = useCallback(
async <T>(endpoint: string, options: RequestInit = {}): Promise<T | null> => {
setLoading(true)
setError(null)
try {
const backendUrl = getBackendUrl()
const response = await fetch(`${backendUrl}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers,
},
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const data = await response.json()
if (data.code !== 0) {
throw new Error(data.message || '请求失败')
}
return data.data as T
} catch (e) {
const msg = e instanceof Error ? e.message : '请求失败'
setError(msg)
toast.error(msg)
return null
} finally {
setLoading(false)
}
},
[getBackendUrl, toast]
)
// 健康检查
const checkHealth = useCallback(async () => {
return request<{ status: string; time: string }>('/api/health')
}, [request])
// 获取号池状态
const getPoolStatus = useCallback(async () => {
return request<PoolStatus>('/api/pool/status')
}, [request])
// 设置号池目标
const setPoolTarget = useCallback(
async (target: number, autoAdd: boolean, minInterval: number) => {
return request('/api/pool/target', {
method: 'POST',
body: JSON.stringify({
target,
auto_add: autoAdd,
min_interval: minInterval,
}),
})
},
[request]
)
// 控制轮询
const setPolling = useCallback(
async (enabled: boolean, interval: number) => {
return request('/api/pool/polling', {
method: 'POST',
body: JSON.stringify({ enabled, interval }),
})
},
[request]
)
// 刷新号池状态
const refreshPool = useCallback(async () => {
return request('/api/pool/refresh', { method: 'POST' })
}, [request])
// 启动健康检查
const startHealthCheck = useCallback(async () => {
return request('/api/health-check/start', { method: 'POST' })
}, [request])
// 获取健康检查结果
const getHealthCheckResults = useCallback(async () => {
return request<HealthCheckResult[]>('/api/health-check/results')
}, [request])
// 获取本地账号
const getLocalAccounts = useCallback(async () => {
return request<Array<{
email: string
pooled: boolean
pooled_at: string
pool_id: number
used: boolean
used_at: string
}>>('/api/accounts')
}, [request])
// 批量入库
const poolAccounts = useCallback(
async (emails: string[]) => {
const result = await request<{ success: number; failed: number }>('/api/accounts/pool', {
method: 'POST',
body: JSON.stringify({ emails }),
})
if (result) {
toast.success(`入库完成: 成功 ${result.success}, 失败 ${result.failed}`)
}
return result
},
[request, toast]
)
// Codex 授权
const startCodexAuth = useCallback(
async (email: string, password: string, proxy?: string) => {
const result = await request('/api/codex/auth', {
method: 'POST',
body: JSON.stringify({ email, password, proxy }),
})
if (result) {
toast.success('授权任务已启动')
}
return result
},
[request, toast]
)
return {
loading,
error,
checkHealth,
getPoolStatus,
setPoolTarget,
setPolling,
refreshPool,
startHealthCheck,
getHealthCheckResults,
getLocalAccounts,
poolAccounts,
startCodexAuth,
}
}

View File

@@ -0,0 +1,5 @@
import { useConfigContext } from '../context/ConfigContext'
export function useConfig() {
return useConfigContext()
}

View File

@@ -0,0 +1,5 @@
import { useRecordsContext } from '../context/RecordsContext'
export function useRecords() {
return useRecordsContext()
}

View File

@@ -0,0 +1,166 @@
import { useState, useCallback } from 'react'
import { useConfig } from './useConfig'
import type { DashboardStats, S2AAccount, AccountListParams, PaginatedResponse } from '../types'
import type { DashboardStatsResponse, AccountListResponse } from '../api/types'
export function useS2AApi() {
const { s2aClient, isConnected } = useConfig()
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const getDashboardStats = useCallback(async (): Promise<DashboardStats | null> => {
if (!s2aClient) {
setError('S2A 客户端未配置')
return null
}
setLoading(true)
setError(null)
try {
const response: DashboardStatsResponse = await s2aClient.getDashboardStats()
return response
} catch (err) {
const message = err instanceof Error ? err.message : '获取统计数据失败'
setError(message)
return null
} finally {
setLoading(false)
}
}, [s2aClient])
const getAccounts = useCallback(
async (params: AccountListParams = {}): Promise<PaginatedResponse<S2AAccount> | null> => {
if (!s2aClient) {
setError('S2A 客户端未配置')
return null
}
setLoading(true)
setError(null)
try {
const response: AccountListResponse = await s2aClient.getAccounts(params)
return {
data: response.data,
total: response.total,
page: response.page,
page_size: response.page_size,
total_pages: Math.ceil(response.total / response.page_size),
}
} catch (err) {
const message = err instanceof Error ? err.message : '获取账号列表失败'
setError(message)
return null
} finally {
setLoading(false)
}
},
[s2aClient]
)
const createAccount = useCallback(
async (data: {
name: string
token: string
email?: string
concurrency?: number
priority?: number
groupIds?: number[]
proxyId?: number | null
}): Promise<S2AAccount | null> => {
if (!s2aClient) {
setError('S2A 客户端未配置')
return null
}
setLoading(true)
setError(null)
try {
const response = await s2aClient.createAccount({
name: data.name,
platform: 'openai',
type: 'access_token',
credentials: {
access_token: data.token,
email: data.email,
},
concurrency: data.concurrency ?? 1,
priority: data.priority ?? 0,
group_ids: data.groupIds ?? [],
proxy_id: data.proxyId ?? null,
auto_pause_on_expired: true,
})
return response as S2AAccount
} catch (err) {
const message = err instanceof Error ? err.message : '创建账号失败'
setError(message)
return null
} finally {
setLoading(false)
}
},
[s2aClient]
)
const deleteAccount = useCallback(
async (id: number): Promise<boolean> => {
if (!s2aClient) {
setError('S2A 客户端未配置')
return false
}
setLoading(true)
setError(null)
try {
await s2aClient.deleteAccount(id)
return true
} catch (err) {
const message = err instanceof Error ? err.message : '删除账号失败'
setError(message)
return false
} finally {
setLoading(false)
}
},
[s2aClient]
)
const testAccount = useCallback(
async (id: number): Promise<{ success: boolean; message?: string } | null> => {
if (!s2aClient) {
setError('S2A 客户端未配置')
return null
}
setLoading(true)
setError(null)
try {
const response = await s2aClient.testAccount(id)
return response
} catch (err) {
const message = err instanceof Error ? err.message : '测试账号失败'
setError(message)
return null
} finally {
setLoading(false)
}
},
[s2aClient]
)
return {
loading,
error,
isConnected,
getDashboardStats,
getAccounts,
createAccount,
deleteAccount,
testAccount,
clearError: () => setError(null),
}
}

416
frontend/src/index.css Normal file
View File

@@ -0,0 +1,416 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
@import 'tailwindcss';
/* TailwindCSS 4 Theme Configuration */
@theme {
/* Design System Colors */
--color-primary: #2563eb;
--color-primary-50: #eff6ff;
--color-primary-100: #dbeafe;
--color-primary-200: #bfdbfe;
--color-primary-300: #93c5fd;
--color-primary-400: #60a5fa;
--color-primary-500: #3b82f6;
--color-primary-600: #2563eb;
--color-primary-700: #1d4ed8;
--color-primary-800: #1e40af;
--color-primary-900: #1e3a8a;
--color-primary-950: #172554;
/* Success Color: Green-500 (#22C55E) */
--color-success: #22c55e;
--color-success-50: #f0fdf4;
--color-success-100: #dcfce7;
--color-success-200: #bbf7d0;
--color-success-300: #86efac;
--color-success-400: #4ade80;
--color-success-500: #22c55e;
--color-success-600: #16a34a;
--color-success-700: #15803d;
--color-success-800: #166534;
--color-success-900: #14532d;
--color-success-950: #052e16;
/* Warning Color: Yellow-500 (#EAB308) */
--color-warning: #eab308;
--color-warning-50: #fefce8;
--color-warning-100: #fef9c3;
--color-warning-200: #fef08a;
--color-warning-300: #fde047;
--color-warning-400: #facc15;
--color-warning-500: #eab308;
--color-warning-600: #ca8a04;
--color-warning-700: #a16207;
--color-warning-800: #854d0e;
--color-warning-900: #713f12;
--color-warning-950: #422006;
/* Error Color: Red-500 (#EF4444) */
--color-error: #ef4444;
--color-error-50: #fef2f2;
--color-error-100: #fee2e2;
--color-error-200: #fecaca;
--color-error-300: #fca5a5;
--color-error-400: #f87171;
--color-error-500: #ef4444;
--color-error-600: #dc2626;
--color-error-700: #b91c1c;
--color-error-800: #991b1b;
--color-error-900: #7f1d1d;
--color-error-950: #450a0a;
/* Background Colors: Slate */
--color-background-light: #f8fafc;
--color-background-dark: #0f172a;
--color-surface-light: #ffffff;
--color-surface-dark: #1e293b;
}
/* CSS Variables for runtime theming */
:root {
--color-bg: var(--color-background-light);
--color-surface: var(--color-surface-light);
--color-text: #1e293b;
--color-text-secondary: #64748b;
--color-border: #e2e8f0;
/* Glassmorphism */
--glass-bg: rgba(255, 255, 255, 0.8);
--glass-border: rgba(255, 255, 255, 0.5);
--glass-shadow: 0 8px 32px rgba(31, 38, 135, 0.1);
/* Gradients */
--gradient-primary: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--gradient-success: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
--gradient-warning: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
--gradient-blue: linear-gradient(135deg, #667eea 0%, #5e94ff 100%);
}
/* Dark mode variables */
.dark {
--color-bg: var(--color-background-dark);
--color-surface: var(--color-surface-dark);
--color-text: #f1f5f9;
--color-text-secondary: #94a3b8;
--color-border: #334155;
/* Dark Glassmorphism */
--glass-bg: rgba(30, 41, 59, 0.8);
--glass-border: rgba(51, 65, 85, 0.5);
--glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
/* Base styles */
body {
font-family:
'Inter',
system-ui,
-apple-system,
sans-serif;
background-color: var(--color-bg);
color: var(--color-text);
transition:
background-color 0.3s ease,
color 0.3s ease;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
.dark ::-webkit-scrollbar-thumb {
background: #475569;
}
.dark ::-webkit-scrollbar-thumb:hover {
background: #64748b;
}
/* Utility classes for design system colors */
.bg-app {
background-color: var(--color-bg);
}
.bg-surface {
background-color: var(--color-surface);
}
.text-primary-color {
color: var(--color-text);
}
.text-secondary-color {
color: var(--color-text-secondary);
}
.border-app {
border-color: var(--color-border);
}
/* Glassmorphism Effect */
.glass {
background: var(--glass-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow);
}
.glass-card {
background: var(--glass-bg);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow);
border-radius: 16px;
}
/* Gradient backgrounds */
.gradient-primary {
background: var(--gradient-primary);
}
.gradient-success {
background: var(--gradient-success);
}
.gradient-blue {
background: var(--gradient-blue);
}
/* Animated gradient */
@keyframes gradient-shift {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
.animated-gradient {
background: linear-gradient(135deg, #667eea, #764ba2, #f093fb, #5e94ff);
background-size: 300% 300%;
animation: gradient-shift 8s ease infinite;
}
/* Pulse animation */
@keyframes pulse-glow {
0%,
100% {
box-shadow: 0 0 0 0 rgba(37, 99, 235, 0.4);
}
50% {
box-shadow: 0 0 0 8px rgba(37, 99, 235, 0);
}
}
.pulse-glow {
animation: pulse-glow 2s infinite;
}
/* Fade in animation */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fadeIn {
animation: fadeIn 0.3s ease-out forwards;
}
/* Scale animation */
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.animate-scaleIn {
animation: scaleIn 0.2s ease-out forwards;
}
/* Number counter animation */
@keyframes countUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-countUp {
animation: countUp 0.5s ease-out forwards;
}
/* Loading skeleton */
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
.skeleton {
background: linear-gradient(90deg, #e2e8f0 25%, #f1f5f9 50%, #e2e8f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.dark .skeleton {
background: linear-gradient(90deg, #334155 25%, #475569 50%, #334155 75%);
background-size: 200% 100%;
}
/* Status indicator */
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.status-dot.online {
background-color: #22c55e;
box-shadow: 0 0 8px rgba(34, 197, 94, 0.5);
}
.status-dot.offline {
background-color: #ef4444;
}
.status-dot.warning {
background-color: #eab308;
}
/* Progress bar gradient */
.progress-gradient {
background: linear-gradient(90deg, #3b82f6, #8b5cf6, #ec4899);
background-size: 200% 100%;
animation: gradient-shift 3s ease infinite;
}
/* Card hover effect */
.card-hover {
transition: all 0.3s ease;
}
.card-hover:hover {
transform: translateY(-4px);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.12);
}
.dark .card-hover:hover {
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4);
}
/* Button hover effects */
.btn-glow:hover {
box-shadow: 0 0 20px rgba(37, 99, 235, 0.4);
}
/* Responsive utilities */
@media (max-width: 768px) {
.hide-mobile {
display: none !important;
}
}
@media (min-width: 769px) {
.hide-desktop {
display: none !important;
}
}
/* Toast notifications */
.toast {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 9999;
animation: slideInRight 0.3s ease-out;
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(100%);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* Focus ring */
.focus-ring:focus {
outline: none;
box-shadow: 0 0 0 2px var(--color-bg), 0 0 0 4px var(--color-primary-500);
}
/* Stat card special effects */
.stat-card {
position: relative;
overflow: hidden;
}
.stat-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: var(--gradient-primary);
opacity: 0;
transition: opacity 0.3s ease;
}
.stat-card:hover::before {
opacity: 1;
}

18
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,18 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { ToastProvider, ErrorBoundary } from './components/common'
import './index.css'
import App from './App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<ErrorBoundary>
<ToastProvider>
<App />
</ToastProvider>
</ErrorBoundary>
</BrowserRouter>
</StrictMode>
)

View File

@@ -0,0 +1,263 @@
import { useState, useEffect, useCallback } from 'react'
import { Link } from 'react-router-dom'
import { RefreshCw, Search, Settings, ChevronLeft, ChevronRight } from 'lucide-react'
import {
Card,
CardHeader,
CardTitle,
CardContent,
Button,
Input,
Select,
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
StatusBadge,
} from '../components/common'
import { useS2AApi } from '../hooks/useS2AApi'
import { useConfig } from '../hooks/useConfig'
import type { S2AAccount, AccountListParams } from '../types'
import { formatDateTime } from '../utils/format'
export default function Accounts() {
const { config } = useConfig()
const { getAccounts, loading, error } = useS2AApi()
const [accounts, setAccounts] = useState<S2AAccount[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize] = useState(20)
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState<string>('')
const [refreshing, setRefreshing] = useState(false)
const hasConfig = config.s2a.apiBase && config.s2a.adminKey
const fetchAccounts = useCallback(async () => {
if (!hasConfig) return
setRefreshing(true)
const params: AccountListParams = {
page,
page_size: pageSize,
platform: 'openai',
}
if (search) params.search = search
if (statusFilter) params.status = statusFilter as 'active' | 'inactive' | 'error'
const result = await getAccounts(params)
if (result) {
setAccounts(result.data)
setTotal(result.total)
}
setRefreshing(false)
}, [hasConfig, page, pageSize, search, statusFilter, getAccounts])
useEffect(() => {
fetchAccounts()
}, [fetchAccounts])
const handleSearch = (e: React.FormEvent) => {
e.preventDefault()
setPage(1)
fetchAccounts()
}
const totalPages = Math.ceil(total / pageSize)
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"></h1>
<p className="text-sm text-slate-500 dark:text-slate-400"> S2A </p>
</div>
<Button
variant="outline"
size="sm"
onClick={fetchAccounts}
disabled={!hasConfig || refreshing}
icon={<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />}
>
</Button>
</div>
{/* Connection warning */}
{!hasConfig && (
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-xl p-4">
<div className="flex items-start gap-3">
<Settings className="h-5 w-5 text-yellow-600 dark:text-yellow-400 mt-0.5" />
<div>
<p className="font-medium text-yellow-800 dark:text-yellow-200"> S2A </p>
<p className="mt-1 text-sm text-yellow-700 dark:text-yellow-300">
S2A API
</p>
<Link to="/config" className="mt-3 inline-block">
<Button size="sm" variant="outline">
</Button>
</Link>
</div>
</div>
</div>
)}
{/* Filters */}
{hasConfig && (
<Card>
<CardContent>
<form onSubmit={handleSearch} className="flex flex-wrap items-end gap-4">
<div className="flex-1 min-w-[200px]">
<Input
placeholder="搜索账号名称..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full"
/>
</div>
<div className="w-40">
<Select
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value)
setPage(1)
}}
options={[
{ value: '', label: '全部状态' },
{ value: 'active', label: '正常' },
{ value: 'inactive', label: '停用' },
{ value: 'error', label: '错误' },
]}
/>
</div>
<Button type="submit" icon={<Search className="h-4 w-4" />}>
</Button>
</form>
</CardContent>
</Card>
)}
{/* Error */}
{error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-4">
<p className="text-red-700 dark:text-red-400">{error}</p>
</div>
)}
{/* Account List */}
{hasConfig && (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<span className="text-sm text-slate-500 dark:text-slate-400"> {total} </span>
</CardHeader>
<CardContent>
{loading && accounts.length === 0 ? (
<div className="space-y-3">
{[1, 2, 3, 4, 5].map((i) => (
<div
key={i}
className="h-16 bg-slate-100 dark:bg-slate-700 rounded-lg animate-pulse"
/>
))}
</div>
) : accounts.length === 0 ? (
<div className="text-center py-12">
<p className="text-slate-500 dark:text-slate-400"></p>
</div>
) : (
<>
<div className="border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow hoverable={false}>
<TableHead>ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{accounts.map((account) => (
<TableRow key={account.id}>
<TableCell>
<span className="font-mono text-sm">{account.id}</span>
</TableCell>
<TableCell>
<span className="font-medium">{account.name}</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-500 dark:text-slate-400">
{account.type}
</span>
</TableCell>
<TableCell>
<StatusBadge status={account.status} />
</TableCell>
<TableCell className="text-right">
{account.current_concurrency !== undefined ? (
<span>
{account.current_concurrency}/{account.concurrency}
</span>
) : (
account.concurrency
)}
</TableCell>
<TableCell className="text-right">{account.priority}</TableCell>
<TableCell>
<span className="text-sm text-slate-500 dark:text-slate-400">
{formatDateTime(account.created_at)}
</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-4">
<p className="text-sm text-slate-500 dark:text-slate-400">
{page} {totalPages}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
icon={<ChevronLeft className="h-4 w-4" />}
>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
icon={<ChevronRight className="h-4 w-4" />}
>
</Button>
</div>
</div>
)}
</>
)}
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -0,0 +1,301 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import {
Server,
Mail,
ChevronRight,
Settings,
Save,
RefreshCw,
Globe,
ToggleLeft,
ToggleRight
} from 'lucide-react'
import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common'
import { useConfig } from '../hooks/useConfig'
export default function Config() {
const { config, isConnected } = useConfig()
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null)
// 编辑状态
const [editS2ABase, setEditS2ABase] = useState('')
const [editS2AKey, setEditS2AKey] = useState('')
const [editConcurrency, setEditConcurrency] = useState(2)
const [editPriority, setEditPriority] = useState(0)
const [editGroupIds, setEditGroupIds] = useState('')
const [proxyEnabled, setProxyEnabled] = useState(false)
const [proxyAddress, setProxyAddress] = useState('')
// 获取服务器配置
const fetchServerConfig = async () => {
setLoading(true)
try {
const res = await fetch('/api/config')
const data = await res.json()
if (data.success) {
setEditS2ABase(data.data.s2a_api_base || '')
setEditS2AKey(data.data.s2a_admin_key || '')
setEditConcurrency(data.data.concurrency || 2)
setEditPriority(data.data.priority || 0)
setEditGroupIds(data.data.group_ids?.join(', ') || '')
setProxyEnabled(data.data.proxy_enabled || false)
setProxyAddress(data.data.default_proxy || '')
}
} catch (error) {
console.error('Failed to fetch config:', error)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchServerConfig()
}, [])
// 保存配置
const handleSave = async () => {
setSaving(true)
setMessage(null)
try {
// 解析 group_ids
const groupIds = editGroupIds
.split(',')
.map(s => parseInt(s.trim()))
.filter(n => !isNaN(n))
const res = await fetch('/api/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
s2a_api_base: editS2ABase,
s2a_admin_key: editS2AKey,
concurrency: editConcurrency,
priority: editPriority,
group_ids: groupIds,
proxy_enabled: proxyEnabled,
default_proxy: proxyAddress,
}),
})
const data = await res.json()
if (data.success) {
setMessage({ type: 'success', text: '配置已保存' })
fetchServerConfig()
} else {
setMessage({ type: 'error', text: data.error || '保存失败' })
}
} catch (error) {
setMessage({ type: 'error', text: '网络错误' })
} finally {
setSaving(false)
}
}
const configItems = [
{
to: '/config/s2a',
icon: Server,
title: 'S2A 高级配置',
description: 'S2A 号池详细设置和测试',
status: isConnected ? '已连接' : '未连接',
statusColor: isConnected ? 'text-green-600 dark:text-green-400' : 'text-red-500',
},
{
to: '/config/email',
icon: Mail,
title: '邮箱服务配置',
description: '配置邮箱服务用于自动注册',
status: (config.email?.services?.length ?? 0) > 0 ? '已配置' : '未配置',
statusColor: (config.email?.services?.length ?? 0) > 0 ? 'text-green-600 dark:text-green-400' : 'text-orange-500',
},
]
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100 flex items-center gap-2">
<Settings className="h-7 w-7 text-slate-500" />
</h1>
<p className="text-sm text-slate-500 dark:text-slate-400"></p>
</div>
<Button
variant="outline"
size="sm"
onClick={fetchServerConfig}
disabled={loading}
icon={<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />}
>
</Button>
</div>
{/* Message */}
{message && (
<div className={`p-3 rounded-lg text-sm ${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.text}
</div>
)}
{/* Quick Config Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Server className="h-5 w-5 text-blue-500" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* S2A Config */}
<div className="grid gap-4 md:grid-cols-2">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
S2A API
</label>
<Input
value={editS2ABase}
onChange={(e) => setEditS2ABase(e.target.value)}
placeholder="https://your-s2a-server.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
S2A Admin Key
</label>
<Input
type="password"
value={editS2AKey}
onChange={(e) => setEditS2AKey(e.target.value)}
placeholder="admin-xxxxxx"
/>
</div>
</div>
{/* Pooling Config */}
<div className="grid gap-4 md:grid-cols-3">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
</label>
<Input
type="number"
value={editConcurrency}
onChange={(e) => setEditConcurrency(parseInt(e.target.value) || 1)}
min={1}
max={100}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
</label>
<Input
type="number"
value={editPriority}
onChange={(e) => setEditPriority(parseInt(e.target.value) || 0)}
min={0}
max={100}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
ID ()
</label>
<Input
value={editGroupIds}
onChange={(e) => setEditGroupIds(e.target.value)}
placeholder="1, 2, 3"
/>
</div>
</div>
{/* Proxy Config */}
<div className="border-t border-slate-200 dark:border-slate-700 pt-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Globe className="h-5 w-5 text-orange-500" />
<span className="font-medium text-slate-700 dark:text-slate-300"></span>
</div>
<button
onClick={() => setProxyEnabled(!proxyEnabled)}
className="flex items-center gap-2 text-sm"
>
{proxyEnabled ? (
<>
<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>
</div>
<Input
value={proxyAddress}
onChange={(e) => setProxyAddress(e.target.value)}
placeholder="http://127.0.0.1:7890"
disabled={!proxyEnabled}
className={!proxyEnabled ? 'opacity-50' : ''}
/>
<p className="text-xs text-slate-500 mt-1">
</p>
</div>
{/* Save Button */}
<div className="flex justify-end pt-2">
<Button
onClick={handleSave}
disabled={saving}
icon={<Save className="h-4 w-4" />}
>
{saving ? '保存中...' : '保存配置'}
</Button>
</div>
</CardContent>
</Card>
{/* Sub Config Cards */}
<div className="grid gap-4 md:grid-cols-2">
{configItems.map((item) => (
<Link key={item.to} to={item.to} className="block group">
<Card className="h-full transition-all duration-200 hover:shadow-lg hover:border-blue-300 dark:hover:border-blue-600">
<CardContent className="flex items-center gap-4 py-4">
<div className="p-3 rounded-xl bg-blue-50 dark:bg-blue-900/30 group-hover:bg-blue-100 dark:group-hover:bg-blue-900/50 transition-colors">
<item.icon className="h-6 w-6 text-blue-600 dark:text-blue-400" />
</div>
<div className="flex-1">
<h3 className="font-semibold text-slate-900 dark:text-slate-100">{item.title}</h3>
<p className="text-sm text-slate-500 dark:text-slate-400">{item.description}</p>
</div>
<span className={`text-sm font-medium ${item.statusColor}`}>
{item.status}
</span>
<ChevronRight className="h-5 w-5 text-slate-400 group-hover:text-blue-500 transition-colors" />
</CardContent>
</Card>
</Link>
))}
</div>
{/* Info */}
<Card>
<CardContent>
<div className="text-sm text-slate-500 dark:text-slate-400">
<p></p>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,162 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { Upload, RefreshCw, Settings } from 'lucide-react'
import { PoolStatus, RecentRecords } from '../components/dashboard'
import { Card, CardHeader, CardTitle, CardContent, Button } from '../components/common'
import { useS2AApi } from '../hooks/useS2AApi'
import { useRecords } from '../hooks/useRecords'
import { useConfig } from '../hooks/useConfig'
import type { DashboardStats } from '../types'
import { formatNumber, formatCurrency } from '../utils/format'
export default function Dashboard() {
const { getDashboardStats, loading, error, isConnected } = useS2AApi()
const { records } = useRecords()
const { config } = useConfig()
const [stats, setStats] = useState<DashboardStats | null>(null)
const [refreshing, setRefreshing] = useState(false)
const fetchStats = async () => {
if (!isConnected) return
setRefreshing(true)
const data = await getDashboardStats()
if (data) {
setStats(data)
}
setRefreshing(false)
}
useEffect(() => {
fetchStats()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isConnected])
const hasConfig = config.s2a.apiBase && config.s2a.adminKey
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"></h1>
<p className="text-sm text-slate-500 dark:text-slate-400"></p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={fetchStats}
disabled={!isConnected || refreshing}
icon={<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />}
>
</Button>
<Link to="/upload">
<Button size="sm" icon={<Upload className="h-4 w-4" />}>
</Button>
</Link>
</div>
</div>
{/* Connection warning */}
{!hasConfig && (
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-xl p-4">
<div className="flex items-start gap-3">
<Settings className="h-5 w-5 text-yellow-600 dark:text-yellow-400 mt-0.5" />
<div>
<p className="font-medium text-yellow-800 dark:text-yellow-200"> S2A </p>
<p className="mt-1 text-sm text-yellow-700 dark:text-yellow-300">
S2A API
</p>
<Link to="/config" className="mt-3 inline-block">
<Button size="sm" variant="outline">
</Button>
</Link>
</div>
</div>
</div>
)}
{/* Pool Status */}
<PoolStatus stats={stats} loading={loading || refreshing} error={error} />
{/* Stats Summary */}
{stats && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card hoverable>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-slate-500 dark:text-slate-400"></p>
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">
{formatNumber(stats.today_requests)}
</p>
</div>
<div>
<p className="text-sm text-slate-500 dark:text-slate-400">Token </p>
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">
{formatNumber(stats.today_tokens)}
</p>
</div>
<div>
<p className="text-sm text-slate-500 dark:text-slate-400"></p>
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">
{formatCurrency(stats.today_cost)}
</p>
</div>
<div>
<p className="text-sm text-slate-500 dark:text-slate-400">TPM</p>
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">
{formatNumber(stats.tpm)}
</p>
</div>
</div>
</CardContent>
</Card>
<Card hoverable>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-slate-500 dark:text-slate-400"></p>
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">
{formatNumber(stats.total_requests)}
</p>
</div>
<div>
<p className="text-sm text-slate-500 dark:text-slate-400"> Token</p>
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">
{formatNumber(stats.total_tokens)}
</p>
</div>
<div>
<p className="text-sm text-slate-500 dark:text-slate-400"></p>
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">
{formatCurrency(stats.total_cost)}
</p>
</div>
<div>
<p className="text-sm text-slate-500 dark:text-slate-400"></p>
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">
{stats.overload_accounts}
</p>
</div>
</div>
</CardContent>
</Card>
</div>
)}
{/* Recent Records */}
<RecentRecords records={records} loading={loading} />
</div>
)
}

View File

@@ -0,0 +1,251 @@
import { useState, useEffect } from 'react'
import { CheckCircle, Save, Mail, Plus, Trash2, TestTube, Loader2, Settings, Server } from 'lucide-react'
import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common'
import { useConfig } from '../hooks/useConfig'
import type { MailServiceConfig } from '../types'
const API_BASE = 'http://localhost:8088'
export default function EmailConfig() {
const { config, updateEmailConfig } = useConfig()
const [saved, setSaved] = useState(false)
const [services, setServices] = useState<MailServiceConfig[]>(config.email?.services || [])
const [testingIndex, setTestingIndex] = useState<number | null>(null)
const [testResults, setTestResults] = useState<Record<number, { success: boolean; message: string }>>({})
// 同步配置变化
useEffect(() => {
if (config.email?.services) {
setServices(config.email.services)
}
}, [config.email?.services])
const handleSave = async () => {
// 保存到前端 context
updateEmailConfig({ services })
// 保存到后端
try {
const res = await fetch(`${API_BASE}/api/mail/services`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ services }),
})
if (res.ok) {
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
} catch (error) {
console.error('保存失败:', error)
}
}
const handleAddService = () => {
setServices([
...services,
{
name: `邮箱服务 ${services.length + 1}`,
apiBase: '',
apiToken: '',
domain: '',
},
])
}
const handleRemoveService = (index: number) => {
if (services.length <= 1) {
return // 至少保留一个服务
}
const newServices = services.filter((_, i) => i !== index)
setServices(newServices)
}
const handleUpdateService = (index: number, updates: Partial<MailServiceConfig>) => {
const newServices = [...services]
newServices[index] = { ...newServices[index], ...updates }
setServices(newServices)
}
const handleTestService = async (index: number) => {
const service = services[index]
setTestingIndex(index)
setTestResults(prev => ({ ...prev, [index]: { success: false, message: '测试中...' } }))
try {
const res = await fetch(`${API_BASE}/api/mail/services/test`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
api_base: service.apiBase,
api_token: service.apiToken,
domain: service.domain,
email_path: service.emailPath,
}),
})
const data = await res.json()
if (res.ok && data.code === 0) {
setTestResults(prev => ({ ...prev, [index]: { success: true, message: '连接成功' } }))
} else {
setTestResults(prev => ({ ...prev, [index]: { success: false, message: data.message || '连接失败' } }))
}
} catch (error) {
setTestResults(prev => ({ ...prev, [index]: { success: false, message: '网络错误' } }))
} finally {
setTestingIndex(null)
}
}
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">
<Mail className="h-7 w-7 text-purple-500" />
</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">
</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={handleAddService}
icon={<Plus className="h-4 w-4" />}
>
</Button>
<Button
onClick={handleSave}
icon={saved ? <CheckCircle className="h-4 w-4" /> : <Save className="h-4 w-4" />}
>
{saved ? '已保存' : '保存配置'}
</Button>
</div>
</div>
{/* Service Cards */}
<div className="space-y-4">
{services.map((service, index) => (
<Card key={index}>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Server className="h-5 w-5 text-purple-500" />
<span>{service.name || `服务 ${index + 1}`}</span>
<span className="text-sm font-normal text-slate-500">
(@{service.domain || '未设置域名'})
</span>
</CardTitle>
<div className="flex items-center gap-2">
{testResults[index] && (
<span className={`text-sm ${testResults[index].success ? 'text-green-500' : 'text-red-500'}`}>
{testResults[index].message}
</span>
)}
<Button
variant="ghost"
size="sm"
onClick={() => handleTestService(index)}
disabled={testingIndex === index || !service.apiBase}
icon={testingIndex === index ? <Loader2 className="h-4 w-4 animate-spin" /> : <TestTube className="h-4 w-4" />}
>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveService(index)}
disabled={services.length <= 1}
icon={<Trash2 className="h-4 w-4" />}
className="text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20"
>
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="服务名称"
placeholder="如:主邮箱服务"
value={service.name}
onChange={(e) => handleUpdateService(index, { name: e.target.value })}
hint="用于识别不同的邮箱服务"
/>
<Input
label="邮箱域名"
placeholder="如example.com"
value={service.domain}
onChange={(e) => handleUpdateService(index, { domain: e.target.value })}
hint="生成邮箱地址的域名后缀"
/>
</div>
<Input
label="API 地址"
placeholder="https://mail.example.com"
value={service.apiBase}
onChange={(e) => handleUpdateService(index, { apiBase: e.target.value })}
hint="邮箱服务 API 地址"
/>
<Input
label="API Token"
type="password"
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
value={service.apiToken}
onChange={(e) => handleUpdateService(index, { apiToken: e.target.value })}
hint="邮箱服务的 API 认证令牌"
/>
{/* Advanced Settings (Collapsed by default) */}
<details className="group">
<summary className="cursor-pointer text-sm text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 flex items-center gap-1">
<Settings className="h-4 w-4" />
</summary>
<div className="mt-4 space-y-4 pl-5 border-l-2 border-slate-200 dark:border-slate-700">
<Input
label="邮件列表 API 路径"
placeholder="/api/public/emailList (默认)"
value={service.emailPath || ''}
onChange={(e) => handleUpdateService(index, { emailPath: e.target.value })}
hint="获取邮件列表的 API 路径"
/>
<Input
label="创建用户 API 路径"
placeholder="/api/public/addUser (默认)"
value={service.addUserApi || ''}
onChange={(e) => handleUpdateService(index, { addUserApi: e.target.value })}
hint="创建邮箱用户的 API 路径"
/>
</div>
</details>
</CardContent>
</Card>
))}
</div>
{/* Help Info */}
<Card>
<CardContent>
<div className="text-sm text-slate-500 dark:text-slate-400">
<p className="font-medium mb-2"></p>
<ul className="list-disc list-inside space-y-1">
<li>使</li>
<li> API Token </li>
<li> xxx@esyteam.edu.kg</li>
<li></li>
<li>使</li>
</ul>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,588 @@
import { useState, useEffect, useCallback } from 'react'
import {
Target,
Activity,
RefreshCw,
Play,
Pause,
Shield,
TrendingUp,
TrendingDown,
Zap,
AlertTriangle,
CheckCircle,
Clock,
} from 'lucide-react'
import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common'
import { useConfig } from '../hooks/useConfig'
import type { DashboardStats } from '../types'
interface PoolStatus {
target: number
current: number
deficit: number
last_check: string
auto_add: boolean
min_interval: number
last_auto_add: string
polling_enabled: boolean
polling_interval: number
}
interface HealthCheckResult {
account_id: number
email: string
status: string
checked_at: string
error?: string
auto_paused?: boolean
}
interface AutoAddLog {
timestamp: string
target: number
current: number
deficit: number
action: string
success: number
failed: number
message: string
}
export default function Monitor() {
const { config } = useConfig()
const [stats, setStats] = useState<DashboardStats | null>(null)
const [poolStatus, setPoolStatus] = useState<PoolStatus | null>(null)
const [healthResults, setHealthResults] = useState<HealthCheckResult[]>([])
const [autoAddLogs, setAutoAddLogs] = useState<AutoAddLog[]>([])
const [loading, setLoading] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const [checkingHealth, setCheckingHealth] = useState(false)
const [autoPauseEnabled, setAutoPauseEnabled] = useState(false)
// 配置表单状态
const [targetInput, setTargetInput] = useState(50)
const [autoAdd, setAutoAdd] = useState(false)
const [minInterval, setMinInterval] = useState(300)
const [pollingEnabled, setPollingEnabled] = useState(false)
const [pollingInterval, setPollingInterval] = useState(60)
const backendUrl = config.s2a.apiBase.replace(/\/api.*$/, '').replace(/:8080/, ':8088')
// 获取号池状态
const fetchPoolStatus = useCallback(async () => {
try {
const res = await fetch(`${backendUrl}/api/pool/status`)
if (res.ok) {
const data = await res.json()
if (data.code === 0) {
setPoolStatus(data.data)
setTargetInput(data.data.target)
setAutoAdd(data.data.auto_add)
setMinInterval(data.data.min_interval)
setPollingEnabled(data.data.polling_enabled)
setPollingInterval(data.data.polling_interval)
}
}
} catch (e) {
console.error('获取号池状态失败:', e)
}
}, [backendUrl])
// 刷新 S2A 统计
const refreshStats = useCallback(async () => {
setRefreshing(true)
try {
const res = await fetch(`${backendUrl}/api/pool/refresh`, { method: 'POST' })
if (res.ok) {
const data = await res.json()
if (data.code === 0) {
setStats(data.data)
}
}
await fetchPoolStatus()
} catch (e) {
console.error('刷新统计失败:', e)
}
setRefreshing(false)
}, [backendUrl, fetchPoolStatus])
// 设置目标
const handleSetTarget = async () => {
setLoading(true)
try {
await fetch(`${backendUrl}/api/pool/target`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
target: targetInput,
auto_add: autoAdd,
min_interval: minInterval,
}),
})
await fetchPoolStatus()
} catch (e) {
console.error('设置目标失败:', e)
}
setLoading(false)
}
// 控制轮询
const handleTogglePolling = async () => {
setLoading(true)
try {
await fetch(`${backendUrl}/api/pool/polling`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled: !pollingEnabled,
interval: pollingInterval,
}),
})
setPollingEnabled(!pollingEnabled)
await fetchPoolStatus()
} catch (e) {
console.error('控制轮询失败:', e)
}
setLoading(false)
}
// 健康检查
const handleHealthCheck = async (autoPause: boolean = false) => {
setCheckingHealth(true)
try {
await fetch(`${backendUrl}/api/health-check/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ auto_pause: autoPause }),
})
// 等待一会儿再获取结果
setTimeout(async () => {
const res = await fetch(`${backendUrl}/api/health-check/results`)
if (res.ok) {
const data = await res.json()
if (data.code === 0) {
setHealthResults(data.data || [])
}
}
setCheckingHealth(false)
}, 5000)
} catch (e) {
console.error('健康检查失败:', e)
setCheckingHealth(false)
}
}
// 获取自动补号日志
const fetchAutoAddLogs = async () => {
try {
const res = await fetch(`${backendUrl}/api/auto-add/logs`)
if (res.ok) {
const data = await res.json()
if (data.code === 0) {
setAutoAddLogs(data.data || [])
}
}
} catch (e) {
console.error('获取日志失败:', e)
}
}
// 初始化
useEffect(() => {
fetchPoolStatus()
refreshStats()
fetchAutoAddLogs()
}, [fetchPoolStatus, refreshStats])
// 计算健康状态
const healthySummary = healthResults.reduce(
(acc, r) => {
if (r.status === 'active' && !r.error) acc.healthy++
else acc.unhealthy++
return acc
},
{ healthy: 0, unhealthy: 0 }
)
const deficit = poolStatus ? Math.max(0, poolStatus.target - poolStatus.current) : 0
const healthPercent = poolStatus && poolStatus.target > 0
? Math.min(100, (poolStatus.current / poolStatus.target) * 100)
: 0
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"></h1>
<p className="text-sm text-slate-500 dark:text-slate-400">
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={refreshStats}
disabled={refreshing}
icon={<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />}
>
</Button>
</div>
</div>
{/* 状态概览卡片 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* 当前/目标 */}
<Card className="stat-card card-hover">
<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 animate-countUp">
{poolStatus?.current ?? '-'} / {poolStatus?.target ?? '-'}
</p>
</div>
<div className="h-12 w-12 rounded-xl bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
<Target className="h-6 w-6 text-blue-600 dark:text-blue-400" />
</div>
</div>
<div className="mt-4">
<div className="h-2 bg-slate-200 dark:bg-slate-700 rounded-full overflow-hidden">
<div
className="h-full progress-gradient rounded-full transition-all duration-500"
style={{ width: `${healthPercent}%` }}
/>
</div>
</div>
</CardContent>
</Card>
{/* 需补充 */}
<Card className="stat-card card-hover">
<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 animate-countUp ${deficit > 0 ? 'text-orange-500' : 'text-green-500'
}`}>
{deficit}
</p>
</div>
<div className={`h-12 w-12 rounded-xl flex items-center justify-center ${deficit > 0 ? 'bg-orange-100 dark:bg-orange-900/30' : 'bg-green-100 dark:bg-green-900/30'
}`}>
{deficit > 0 ? (
<TrendingDown className="h-6 w-6 text-orange-500" />
) : (
<TrendingUp className="h-6 w-6 text-green-500" />
)}
</div>
</div>
{deficit > 0 && (
<p className="mt-2 text-xs text-orange-500 flex items-center gap-1">
<AlertTriangle className="h-3 w-3" />
</p>
)}
</CardContent>
</Card>
{/* 轮询状态 */}
<Card className="stat-card card-hover">
<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">
{pollingEnabled ? '运行中' : '已停止'}
</p>
</div>
<div className={`h-12 w-12 rounded-xl flex items-center justify-center ${pollingEnabled ? 'bg-green-100 dark:bg-green-900/30' : 'bg-slate-100 dark:bg-slate-800'
}`}>
{pollingEnabled ? (
<Activity className="h-6 w-6 text-green-500 animate-pulse" />
) : (
<Pause className="h-6 w-6 text-slate-400" />
)}
</div>
</div>
{pollingEnabled && (
<p className="mt-2 text-xs text-slate-500 flex items-center gap-1">
<Clock className="h-3 w-3" />
{pollingInterval}
</p>
)}
</CardContent>
</Card>
{/* 自动补号 */}
<Card className="stat-card card-hover">
<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">
{autoAdd ? '已启用' : '已禁用'}
</p>
</div>
<div className={`h-12 w-12 rounded-xl flex items-center justify-center ${autoAdd ? 'bg-purple-100 dark:bg-purple-900/30' : 'bg-slate-100 dark:bg-slate-800'
}`}>
<Zap className={`h-6 w-6 ${autoAdd ? 'text-purple-500' : 'text-slate-400'}`} />
</div>
</div>
</CardContent>
</Card>
</div>
{/* 配置面板 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* 目标设置 */}
<Card className="glass-card">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Target className="h-5 w-5 text-blue-500" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Input
label="目标账号数"
type="number"
min={1}
max={1000}
value={targetInput}
onChange={(e) => setTargetInput(Number(e.target.value))}
hint="期望保持的活跃账号数量"
/>
<div className="flex items-center gap-3">
<input
type="checkbox"
id="autoAdd"
checked={autoAdd}
onChange={(e) => setAutoAdd(e.target.checked)}
className="h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500"
/>
<label htmlFor="autoAdd" className="text-sm text-slate-700 dark:text-slate-300">
</label>
</div>
<Input
label="最小间隔 (秒)"
type="number"
min={60}
max={3600}
value={minInterval}
onChange={(e) => setMinInterval(Number(e.target.value))}
hint="两次自动补号的最小间隔"
disabled={!autoAdd}
/>
<Button onClick={handleSetTarget} loading={loading} className="w-full">
</Button>
</CardContent>
</Card>
{/* 轮询控制 */}
<Card className="glass-card">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Activity className="h-5 w-5 text-green-500" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Input
label="轮询间隔 (秒)"
type="number"
min={10}
max={300}
value={pollingInterval}
onChange={(e) => setPollingInterval(Number(e.target.value))}
hint="自动刷新号池状态的间隔时间"
/>
<div className="p-4 rounded-lg bg-slate-50 dark:bg-slate-800/50">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-slate-900 dark:text-slate-100"></p>
<p className="text-sm text-slate-500">
{pollingEnabled ? '正在实时监控号池状态' : '监控已暂停'}
</p>
</div>
<div className={`status-dot ${pollingEnabled ? 'online' : 'offline'}`} />
</div>
</div>
<Button
onClick={handleTogglePolling}
loading={loading}
variant={pollingEnabled ? 'outline' : 'primary'}
className="w-full"
icon={pollingEnabled ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
>
{pollingEnabled ? '停止监控' : '启动监控'}
</Button>
</CardContent>
</Card>
</div>
{/* 健康检查 */}
<Card className="glass-card">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5 text-purple-500" />
</CardTitle>
<div className="flex items-center gap-2">
<label className="flex items-center gap-2 text-sm text-slate-600 dark:text-slate-400">
<input
type="checkbox"
checked={autoPauseEnabled}
onChange={(e) => setAutoPauseEnabled(e.target.checked)}
className="h-4 w-4 rounded border-slate-300 text-purple-600 focus:ring-purple-500"
/>
</label>
<Button
variant="outline"
size="sm"
onClick={() => handleHealthCheck(autoPauseEnabled)}
disabled={checkingHealth}
loading={checkingHealth}
icon={<Shield className="h-4 w-4" />}
>
{checkingHealth ? '检查中...' : '开始检查'}
</Button>
</div>
</CardHeader>
<CardContent>
{healthResults.length > 0 ? (
<>
{/* 统计 */}
<div className="flex gap-4 mb-4">
<div className="flex items-center gap-2 text-green-600">
<CheckCircle className="h-4 w-4" />
<span>: {healthySummary.healthy}</span>
</div>
<div className="flex items-center gap-2 text-red-500">
<AlertTriangle className="h-4 w-4" />
<span>: {healthySummary.unhealthy}</span>
</div>
</div>
{/* 结果列表 */}
<div className="max-h-64 overflow-y-auto space-y-2">
{healthResults.map((result) => (
<div
key={result.account_id}
className={`flex items-center justify-between p-3 rounded-lg ${result.error
? 'bg-red-50 dark:bg-red-900/20'
: 'bg-green-50 dark:bg-green-900/20'
}`}
>
<div>
<p className="font-medium text-slate-900 dark:text-slate-100">
{result.email}
</p>
<p className="text-xs text-slate-500">ID: {result.account_id}</p>
</div>
<div className="text-right">
<p className={`text-sm font-medium ${result.error ? 'text-red-500' : 'text-green-600'
}`}>
{result.status}
</p>
{result.error && (
<p className="text-xs text-red-400">{result.error}</p>
)}
</div>
</div>
))}
</div>
</>
) : (
<div className="text-center py-8 text-slate-500">
<Shield className="h-12 w-12 mx-auto mb-3 opacity-30" />
<p>"开始检查"</p>
</div>
)}
</CardContent>
</Card>
{/* S2A 实时统计 */}
{stats && (
<Card>
<CardHeader>
<CardTitle>S2A </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center p-4 rounded-lg bg-blue-50 dark:bg-blue-900/20">
<p className="text-2xl font-bold text-blue-600">{stats.total_accounts}</p>
<p className="text-sm text-slate-500"></p>
</div>
<div className="text-center p-4 rounded-lg bg-green-50 dark:bg-green-900/20">
<p className="text-2xl font-bold text-green-600">{stats.normal_accounts}</p>
<p className="text-sm text-slate-500"></p>
</div>
<div className="text-center p-4 rounded-lg bg-red-50 dark:bg-red-900/20">
<p className="text-2xl font-bold text-red-500">{stats.error_accounts}</p>
<p className="text-sm text-slate-500"></p>
</div>
<div className="text-center p-4 rounded-lg bg-orange-50 dark:bg-orange-900/20">
<p className="text-2xl font-bold text-orange-500">{stats.ratelimit_accounts}</p>
<p className="text-sm text-slate-500"></p>
</div>
</div>
</CardContent>
</Card>
)}
{/* 自动补号日志 */}
{autoAddLogs.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock className="h-5 w-5 text-slate-500" />
</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={fetchAutoAddLogs}
icon={<RefreshCw className="h-4 w-4" />}
>
</Button>
</CardHeader>
<CardContent>
<div className="max-h-64 overflow-y-auto space-y-2">
{[...autoAddLogs].reverse().slice(0, 20).map((log, idx) => (
<div
key={idx}
className={`flex items-center justify-between p-3 rounded-lg text-sm ${log.action.includes('trigger') || log.action.includes('decrease')
? 'bg-orange-50 dark:bg-orange-900/20'
: log.action.includes('increase')
? 'bg-green-50 dark:bg-green-900/20'
: 'bg-slate-50 dark:bg-slate-800/50'
}`}
>
<div className="flex items-center gap-3">
<span className="text-xs text-slate-400">
{new Date(log.timestamp).toLocaleTimeString()}
</span>
<span className="font-medium text-slate-900 dark:text-slate-100">
{log.message}
</span>
</div>
<div className="text-xs text-slate-500">
{log.current} / {log.target}
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -0,0 +1,112 @@
import { useState, useMemo } from 'react'
import { Trash2, Calendar } from 'lucide-react'
import { RecordList, RecordStats } from '../components/records'
import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common'
import { useRecords } from '../hooks/useRecords'
export default function Records() {
const { records, deleteRecord, clearRecords, getStats } = useRecords()
const [startDate, setStartDate] = useState('')
const [endDate, setEndDate] = useState('')
const stats = useMemo(() => getStats(), [getStats])
const filteredRecords = useMemo(() => {
if (!startDate && !endDate) return records
return records.filter((record) => {
const recordDate = new Date(record.timestamp)
const start = startDate ? new Date(startDate) : null
const end = endDate ? new Date(endDate + 'T23:59:59') : null
if (start && recordDate < start) return false
if (end && recordDate > end) return false
return true
})
}, [records, startDate, endDate])
const handleClearFilter = () => {
setStartDate('')
setEndDate('')
}
const handleClearAll = () => {
if (window.confirm('确定要清空所有记录吗?此操作不可恢复。')) {
clearRecords()
}
}
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"></h1>
<p className="text-sm text-slate-500 dark:text-slate-400"></p>
</div>
{records.length > 0 && (
<Button
variant="outline"
size="sm"
onClick={handleClearAll}
icon={<Trash2 className="h-4 w-4" />}
>
</Button>
)}
</div>
{/* Stats */}
<RecordStats stats={stats} />
{/* Filter */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
</CardTitle>
{(startDate || endDate) && (
<Button variant="ghost" size="sm" onClick={handleClearFilter}>
</Button>
)}
</CardHeader>
<CardContent>
<div className="flex flex-wrap items-end gap-4">
<div className="w-40">
<Input
label="开始日期"
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
</div>
<div className="w-40">
<Input
label="结束日期"
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
</div>
<div className="text-sm text-slate-500 dark:text-slate-400">
{filteredRecords.length}
{filteredRecords.length !== records.length && <span className="ml-1">()</span>}
</div>
</div>
</CardContent>
</Card>
{/* Record List */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<RecordList records={filteredRecords} onDelete={deleteRecord} />
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,241 @@
import { useState } from 'react'
import { TestTube, CheckCircle, XCircle, Loader2, Save, Server, Plus, X } from 'lucide-react'
import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common'
import { useConfig } from '../hooks/useConfig'
export default function S2AConfig() {
const {
config,
updateS2AConfig,
updatePoolingConfig,
testConnection,
isConnected,
} = useConfig()
const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState<boolean | null>(null)
const [saved, setSaved] = useState(false)
// Local form state - S2A 连接
const [s2aApiBase, setS2aApiBase] = useState(config.s2a.apiBase)
const [s2aAdminKey, setS2aAdminKey] = useState(config.s2a.adminKey)
// Local form state - 入库设置
const [poolingConcurrency, setPoolingConcurrency] = useState(config.pooling.concurrency)
const [poolingPriority, setPoolingPriority] = useState(config.pooling.priority)
const [groupIds, setGroupIds] = useState<number[]>(config.pooling.groupIds || [])
const [newGroupId, setNewGroupId] = useState('')
const handleTestConnection = async () => {
// Save first
updateS2AConfig({ apiBase: s2aApiBase, adminKey: s2aAdminKey })
setTesting(true)
setTestResult(null)
// Wait a bit for the client to be recreated
await new Promise((resolve) => setTimeout(resolve, 100))
const result = await testConnection()
setTestResult(result)
setTesting(false)
}
const handleSave = () => {
updateS2AConfig({ apiBase: s2aApiBase, adminKey: s2aAdminKey })
updatePoolingConfig({
concurrency: poolingConcurrency,
priority: poolingPriority,
groupIds: groupIds,
})
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
const handleAddGroupId = () => {
const id = parseInt(newGroupId, 10)
if (!isNaN(id) && !groupIds.includes(id)) {
setGroupIds([...groupIds, id])
setNewGroupId('')
}
}
const handleRemoveGroupId = (id: number) => {
setGroupIds(groupIds.filter(g => g !== id))
}
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">
<Server className="h-7 w-7 text-blue-500" />
S2A
</h1>
<p className="text-sm text-slate-500 dark:text-slate-400"> S2A </p>
</div>
<Button
onClick={handleSave}
icon={saved ? <CheckCircle className="h-4 w-4" /> : <Save className="h-4 w-4" />}
>
{saved ? '已保存' : '保存配置'}
</Button>
</div>
{/* S2A Connection */}
<Card>
<CardHeader>
<CardTitle>S2A </CardTitle>
<div className="flex items-center gap-2">
{isConnected ? (
<span className="flex items-center gap-1 text-sm text-green-600 dark:text-green-400">
<CheckCircle className="h-4 w-4" />
</span>
) : (
<span className="flex items-center gap-1 text-sm text-slate-500 dark:text-slate-400">
<XCircle className="h-4 w-4" />
</span>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
<Input
label="S2A API 地址"
placeholder="http://localhost:8080"
value={s2aApiBase}
onChange={(e) => setS2aApiBase(e.target.value)}
hint="S2A 服务的 API 地址,例如 http://localhost:8080"
/>
<Input
label="Admin API Key"
type="password"
placeholder="admin-xxxxxxxxxxxxxxxx"
value={s2aAdminKey}
onChange={(e) => setS2aAdminKey(e.target.value)}
hint="S2A 管理密钥,可在 S2A 后台 Settings 页面获取"
/>
<div className="flex items-center gap-4">
<Button
variant="outline"
onClick={handleTestConnection}
disabled={testing || !s2aApiBase || !s2aAdminKey}
icon={
testing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<TestTube className="h-4 w-4" />
)
}
>
{testing ? '测试中...' : '测试连接'}
</Button>
{testResult !== null && (
<span
className={`text-sm ${testResult
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400'
}`}
>
{testResult ? '连接成功' : '连接失败'}
</span>
)}
</div>
</CardContent>
</Card>
{/* Pooling Settings */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input
label="默认并发数"
type="number"
min={1}
max={10}
value={poolingConcurrency}
onChange={(e) => setPoolingConcurrency(Number(e.target.value))}
hint="账号的默认并发请求数"
/>
<Input
label="默认优先级"
type="number"
min={0}
max={100}
value={poolingPriority}
onChange={(e) => setPoolingPriority(Number(e.target.value))}
hint="账号的默认优先级,数值越大优先级越高"
/>
</div>
{/* Group IDs */}
<div className="space-y-2">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">
ID
</label>
<div className="flex flex-wrap gap-2">
{groupIds.map(id => (
<span
key={id}
className="inline-flex items-center gap-1 px-3 py-1 rounded-full text-sm bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
>
{id}
<button
onClick={() => handleRemoveGroupId(id)}
className="hover:text-red-500 transition-colors"
>
<X className="h-3 w-3" />
</button>
</span>
))}
{groupIds.length === 0 && (
<span className="text-sm text-slate-400"></span>
)}
</div>
<div className="flex gap-2 mt-2">
<Input
placeholder="输入分组 ID"
type="number"
min={1}
value={newGroupId}
onChange={(e) => setNewGroupId(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAddGroupId()}
/>
<Button
variant="outline"
onClick={handleAddGroupId}
disabled={!newGroupId}
icon={<Plus className="h-4 w-4" />}
>
</Button>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400">
</p>
</div>
</CardContent>
</Card>
{/* Info */}
<Card>
<CardContent>
<div className="text-sm text-slate-500 dark:text-slate-400">
<p className="font-medium mb-2"></p>
<ul className="list-disc list-inside space-y-1">
<li>S2A API S2A URL</li>
<li>Admin API Key </li>
<li></li>
<li> ID </li>
</ul>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,483 @@
import { useState, useEffect, useCallback } from 'react'
import {
Users,
Play,
Square,
RefreshCw,
Settings,
CheckCircle,
Clock,
Upload,
Loader2,
AlertTriangle,
} from 'lucide-react'
import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common'
import { useConfig } from '../hooks/useConfig'
interface Owner {
email: string
password: string
token: string
}
interface TeamResult {
team_index: number
owner_email: string
team_id: string
registered: number
added_to_s2a: number
member_emails: string[]
errors: string[]
duration_ms: number
}
interface ProcessStatus {
running: boolean
started_at: string
total_teams: number
completed: number
results: TeamResult[]
elapsed_ms: number
}
export default function TeamProcess() {
const { config } = useConfig()
const [owners, setOwners] = useState<Owner[]>([])
const [status, setStatus] = useState<ProcessStatus | null>(null)
const [loading, setLoading] = useState(false)
const [polling, setPolling] = useState(false)
// 配置
const [membersPerTeam, setMembersPerTeam] = useState(4)
const [concurrentTeams, setConcurrentTeams] = useState(2)
const [browserType, setBrowserType] = useState<'chromedp' | 'rod'>('chromedp')
const [headless, setHeadless] = useState(true)
const [proxy, setProxy] = useState('')
const backendUrl = config.s2a.apiBase.replace(/\/api.*$/, '').replace(/:8080/, ':8088')
// 获取状态
const fetchStatus = useCallback(async () => {
try {
const res = await fetch(`${backendUrl}/api/team/status`)
if (res.ok) {
const data = await res.json()
if (data.code === 0) {
setStatus(data.data)
if (!data.data.running) {
setPolling(false)
}
}
}
} catch (e) {
console.error('获取状态失败:', e)
}
}, [backendUrl])
// 轮询状态
useEffect(() => {
if (polling) {
const interval = setInterval(fetchStatus, 2000)
return () => clearInterval(interval)
}
}, [polling, fetchStatus])
// 初始化
useEffect(() => {
fetchStatus()
}, [fetchStatus])
// 上传账号文件
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
try {
const text = await file.text()
const data = JSON.parse(text)
const parsed = Array.isArray(data) ? data : [data]
const validOwners = parsed.filter((a: Record<string, unknown>) =>
(a.email || a.account) && a.password && (a.token || a.access_token)
).map((a: Record<string, unknown>) => ({
email: (a.email || a.account) as string,
password: a.password as string,
token: (a.token || a.access_token) as string,
}))
setOwners(validOwners)
setConcurrentTeams(Math.min(validOwners.length, 2))
} catch (err) {
alert('文件解析失败,请确保是有效的 JSON 格式')
}
}
// 启动处理
const handleStart = async () => {
if (owners.length === 0) {
alert('请先上传账号文件')
return
}
setLoading(true)
try {
const res = await fetch(`${backendUrl}/api/team/process`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
owners: owners.slice(0, concurrentTeams),
members_per_team: membersPerTeam,
concurrent_teams: concurrentTeams,
browser_type: browserType,
headless,
proxy,
}),
})
if (res.ok) {
setPolling(true)
fetchStatus()
} else {
const data = await res.json()
alert(data.message || '启动失败')
}
} catch (e) {
console.error('启动失败:', e)
alert('启动失败')
}
setLoading(false)
}
// 停止处理
const handleStop = async () => {
try {
await fetch(`${backendUrl}/api/team/stop`, { method: 'POST' })
setPolling(false)
fetchStatus()
} catch (e) {
console.error('停止失败:', e)
}
}
const isRunning = status?.running
// 计算统计
const totalRegistered = status?.results.reduce((sum, r) => sum + r.registered, 0) || 0
const totalS2A = status?.results.reduce((sum, r) => sum + r.added_to_s2a, 0) || 0
const expectedTotal = (status?.total_teams || 0) * membersPerTeam
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">
Team
</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">
Team S2A
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={fetchStatus}
icon={<RefreshCw className={`h-4 w-4 ${polling ? 'animate-spin' : ''}`} />}
>
</Button>
</div>
</div>
{/* 状态概览 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="stat-card card-hover">
<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-bold ${isRunning ? 'text-green-500' : 'text-slate-500'}`}>
{isRunning ? '运行中' : '空闲'}
</p>
</div>
<div className={`h-12 w-12 rounded-xl flex items-center justify-center ${isRunning ? 'bg-green-100 dark:bg-green-900/30' : 'bg-slate-100 dark:bg-slate-800'
}`}>
{isRunning ? (
<Loader2 className="h-6 w-6 text-green-500 animate-spin" />
) : (
<Clock className="h-6 w-6 text-slate-400" />
)}
</div>
</div>
</CardContent>
</Card>
<Card className="stat-card card-hover">
<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-bold text-blue-500">
{status?.completed || 0} / {status?.total_teams || '-'}
</p>
</div>
<div className="h-12 w-12 rounded-xl bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
<Users className="h-6 w-6 text-blue-500" />
</div>
</div>
</CardContent>
</Card>
<Card className="stat-card card-hover">
<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-bold text-green-500">
{totalRegistered} / {expectedTotal || '-'}
</p>
</div>
<div className="h-12 w-12 rounded-xl bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
<CheckCircle className="h-6 w-6 text-green-500" />
</div>
</div>
</CardContent>
</Card>
<Card className="stat-card card-hover">
<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-bold text-purple-500">{totalS2A}</p>
</div>
<div className="h-12 w-12 rounded-xl bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
<Settings className="h-6 w-6 text-purple-500" />
</div>
</div>
</CardContent>
</Card>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* 配置面板 */}
<Card className="glass-card">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Settings className="h-5 w-5 text-blue-500" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 账号文件上传 */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
Owner
</label>
<div className="flex items-center gap-2">
<label className="flex-1 flex items-center justify-center gap-2 px-4 py-3 border-2 border-dashed border-slate-300 dark:border-slate-600 rounded-lg cursor-pointer hover:border-blue-500 transition-colors">
<Upload className="h-5 w-5 text-slate-400" />
<span className="text-sm text-slate-500">
{owners.length > 0 ? `已加载 ${owners.length} 个账号` : '选择 JSON 文件'}
</span>
<input
type="file"
accept=".json"
onChange={handleFileUpload}
className="hidden"
disabled={isRunning}
/>
</label>
</div>
</div>
<Input
label="每个 Team 成员数"
type="number"
min={1}
max={10}
value={membersPerTeam}
onChange={(e) => setMembersPerTeam(Number(e.target.value))}
disabled={isRunning}
/>
<Input
label="并发 Team 数"
type="number"
min={1}
max={Math.max(1, owners.length)}
value={concurrentTeams}
onChange={(e) => setConcurrentTeams(Number(e.target.value))}
disabled={isRunning}
hint={`最多 ${owners.length}`}
/>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
</label>
<div className="flex gap-2">
<button
onClick={() => setBrowserType('chromedp')}
disabled={isRunning}
className={`flex-1 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${browserType === 'chromedp'
? 'bg-blue-500 text-white'
: 'bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 hover:bg-slate-200'
}`}
>
Chromedp ()
</button>
<button
onClick={() => setBrowserType('rod')}
disabled={isRunning}
className={`flex-1 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${browserType === 'rod'
? 'bg-blue-500 text-white'
: 'bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 hover:bg-slate-200'
}`}
>
Rod
</button>
</div>
</div>
<div className="flex items-center gap-3">
<input
type="checkbox"
id="headless"
checked={headless}
onChange={(e) => setHeadless(e.target.checked)}
disabled={isRunning}
className="h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500"
/>
<label htmlFor="headless" className="text-sm text-slate-700 dark:text-slate-300">
()
</label>
</div>
<Input
label="代理地址"
placeholder="http://127.0.0.1:7890"
value={proxy}
onChange={(e) => setProxy(e.target.value)}
disabled={isRunning}
/>
<div className="flex gap-2 pt-4">
{isRunning ? (
<Button
variant="outline"
onClick={handleStop}
className="flex-1"
icon={<Square className="h-4 w-4" />}
>
</Button>
) : (
<Button
onClick={handleStart}
loading={loading}
disabled={owners.length === 0}
className="flex-1"
icon={<Play className="h-4 w-4" />}
>
</Button>
)}
</div>
</CardContent>
</Card>
{/* 结果列表 */}
<Card className="glass-card lg:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5 text-green-500" />
</CardTitle>
{status && status.elapsed_ms > 0 && (
<span className="text-sm text-slate-500">
: {(status.elapsed_ms / 1000).toFixed(1)}s
</span>
)}
</CardHeader>
<CardContent>
{status?.results && status.results.length > 0 ? (
<div className="space-y-4 max-h-[500px] overflow-y-auto">
{status.results.map((result) => (
<div
key={result.team_index}
className="p-4 rounded-lg bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700"
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="px-2 py-1 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-600">
Team {result.team_index}
</span>
<span className="text-sm text-slate-500 truncate max-w-[200px]">
{result.owner_email}
</span>
</div>
<div className="flex items-center gap-4 text-sm">
<span className="flex items-center gap-1 text-green-600">
<CheckCircle className="h-4 w-4" />
: {result.registered}
</span>
<span className="flex items-center gap-1 text-purple-600">
<Settings className="h-4 w-4" />
: {result.added_to_s2a}
</span>
</div>
</div>
{result.member_emails.length > 0 && (
<div className="mb-3">
<p className="text-xs text-slate-500 mb-1">:</p>
<div className="flex flex-wrap gap-1">
{result.member_emails.map((email, idx) => (
<span
key={idx}
className="px-2 py-0.5 rounded text-xs bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400"
>
{email}
</span>
))}
</div>
</div>
)}
{result.errors.length > 0 && (
<div>
<p className="text-xs text-red-500 mb-1 flex items-center gap-1">
<AlertTriangle className="h-3 w-3" />
:
</p>
<div className="space-y-1">
{result.errors.map((err, idx) => (
<p key={idx} className="text-xs text-red-400 pl-4">
{err}
</p>
))}
</div>
</div>
)}
<div className="mt-2 text-xs text-slate-400">
: {(result.duration_ms / 1000).toFixed(1)}s
</div>
</div>
))}
</div>
) : (
<div className="text-center py-12 text-slate-500">
<Users className="h-12 w-12 mx-auto mb-3 opacity-30" />
<p></p>
<p className="text-sm mt-1"></p>
</div>
)}
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,340 @@
import { useState, useCallback, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { Upload as UploadIcon, Settings, Play, Loader2, List, Activity } from 'lucide-react'
import { FileDropzone } from '../components/upload'
import LogStream from '../components/upload/LogStream'
import OwnerList from '../components/upload/OwnerList'
import { Card, CardContent, Button, Tabs } from '../components/common'
import { useConfig } from '../hooks/useConfig'
interface PoolingConfig {
owner_concurrency: number // 母号并发数
include_owner: boolean // 是否入库母号
serial_authorize: boolean
browser_type: 'rod' | 'cdp'
proxy: string
}
interface OwnerStats {
total: number
valid: number
registered: number
pooled: number
}
type TabType = 'upload' | 'owners' | 'logs'
export default function Upload() {
const { config, isConnected } = useConfig()
const apiBase = 'http://localhost:8088'
const [activeTab, setActiveTab] = useState<TabType>('upload')
const [fileError, setFileError] = useState<string | null>(null)
const [validating, setValidating] = useState(false)
const [pooling, setPooling] = useState(false)
const [stats, setStats] = useState<OwnerStats | null>(null)
const [poolingConfig, setPoolingConfig] = useState<PoolingConfig>({
owner_concurrency: 1,
include_owner: true,
serial_authorize: true,
browser_type: 'rod',
proxy: '',
})
const hasConfig = config.s2a.apiBase && config.s2a.adminKey
// Load stats
const loadStats = useCallback(async () => {
try {
const res = await fetch(`${apiBase}/api/db/owners/stats`)
const data = await res.json()
if (data.code === 0) {
setStats(data.data)
}
} catch (e) {
console.error('Failed to load stats:', e)
}
}, [apiBase])
useEffect(() => {
loadStats()
}, [loadStats])
// Upload and validate
const handleFileSelect = useCallback(
async (file: File) => {
setFileError(null)
setValidating(true)
try {
const text = await file.text()
const json = JSON.parse(text)
// Support both array and single account
const accounts = Array.isArray(json) ? json : [json]
const res = await fetch(`${apiBase}/api/upload/validate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accounts }),
})
const data = await res.json()
if (data.code === 0) {
loadStats()
} else {
setFileError(data.message || '验证失败')
}
} catch (e) {
setFileError(e instanceof Error ? e.message : 'JSON 解析失败')
} finally {
setValidating(false)
}
},
[apiBase, loadStats]
)
// Start pooling
const handleStartPooling = useCallback(async () => {
setPooling(true)
setActiveTab('logs') // Switch to logs tab
try {
const res = await fetch(`${apiBase}/api/pooling/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(poolingConfig),
})
const data = await res.json()
if (data.code !== 0) {
alert(data.message || '启动失败')
}
} catch (e) {
console.error('Failed to start pooling:', e)
} finally {
// Check status periodically
const checkStatus = async () => {
try {
const res = await fetch(`${apiBase}/api/pooling/status`)
const data = await res.json()
if (data.code === 0 && !data.data.running) {
setPooling(false)
loadStats()
} else {
setTimeout(checkStatus, 2000)
}
} catch {
setPooling(false)
}
}
setTimeout(checkStatus, 2000)
}
}, [apiBase, poolingConfig, loadStats])
const tabs = [
{ id: 'upload', label: '上传', icon: UploadIcon },
{ id: 'owners', label: '母号列表', icon: List, count: stats?.total },
{ id: 'logs', label: '日志', icon: Activity },
]
return (
<div className="h-[calc(100vh-6rem)] flex flex-col gap-6">
{/* Header */}
<div className="flex items-center justify-between shrink-0">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100 flex items-center gap-2">
<UploadIcon className="h-7 w-7 text-blue-500" />
</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">
Team Owner JSON S2A
</p>
</div>
</div>
{/* Connection warning */}
{!hasConfig && (
<div className="shrink-0 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-xl p-4">
<div className="flex items-start gap-3">
<Settings className="h-5 w-5 text-yellow-600 dark:text-yellow-400 mt-0.5" />
<div>
<p className="font-medium text-yellow-800 dark:text-yellow-200">
S2A
</p>
<Link to="/config/s2a" className="mt-3 inline-block">
<Button size="sm" variant="outline">
</Button>
</Link>
</div>
</div>
</div>
)}
{/* Tabs */}
<Tabs
tabs={tabs}
activeTab={activeTab}
onChange={(id) => setActiveTab(id as TabType)}
className="shrink-0"
/>
{/* Tab Content */}
<div className="flex-1 min-h-0 overflow-hidden">
{activeTab === 'upload' && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 h-full overflow-hidden">
{/* Left: Upload & Config */}
<div className="flex flex-col gap-4 overflow-y-auto">
{/* Upload */}
<Card hoverable className="shrink-0">
<CardContent className="p-4">
<FileDropzone
onFileSelect={handleFileSelect}
disabled={validating}
error={fileError}
/>
{validating && (
<div className="mt-3 flex items-center gap-2 text-blue-500 bg-blue-50 dark:bg-blue-900/20 p-2 rounded-lg text-sm">
<Loader2 className="h-4 w-4 animate-spin" />
<span>...</span>
</div>
)}
</CardContent>
</Card>
{/* Stats - Compact inline */}
{stats && (
<div className="shrink-0 grid grid-cols-4 gap-2">
<div className="text-center p-2 bg-slate-50 dark:bg-slate-800/50 rounded-lg border border-slate-100 dark:border-slate-700">
<div className="text-lg font-bold text-slate-700 dark:text-slate-200">{stats.total}</div>
<div className="text-xs text-slate-500"></div>
</div>
<div className="text-center p-2 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-100 dark:border-blue-800/50">
<div className="text-lg font-bold text-blue-600 dark:text-blue-400">{stats.valid}</div>
<div className="text-xs text-blue-600/70 dark:text-blue-400/70"></div>
</div>
<div className="text-center p-2 bg-orange-50 dark:bg-orange-900/20 rounded-lg border border-orange-100 dark:border-orange-800/50">
<div className="text-lg font-bold text-orange-600 dark:text-orange-400">{stats.registered}</div>
<div className="text-xs text-orange-600/70 dark:text-orange-400/70"></div>
</div>
<div className="text-center p-2 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-100 dark:border-green-800/50">
<div className="text-lg font-bold text-green-600 dark:text-green-400">{stats.pooled}</div>
<div className="text-xs text-green-600/70 dark:text-green-400/70"></div>
</div>
</div>
)}
{/* Pooling Config - Compact */}
<Card hoverable className="shrink-0">
<CardContent className="p-4 space-y-4">
<div className="flex items-center gap-2 text-sm font-medium text-slate-900 dark:text-slate-100">
<Play className="h-4 w-4 text-green-500" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">
</label>
<input
type="number"
min={1}
max={10}
value={poolingConfig.owner_concurrency}
onChange={(e) => setPoolingConfig({ ...poolingConfig, owner_concurrency: Number(e.target.value) })}
className="w-full h-9 px-3 text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800"
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">
</label>
<select
className="w-full h-9 px-3 text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800"
value={poolingConfig.browser_type}
onChange={(e) => setPoolingConfig({ ...poolingConfig, browser_type: e.target.value as 'rod' | 'cdp' })}
>
<option value="rod">Rod ()</option>
<option value="cdp">CDP</option>
</select>
</div>
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer text-sm">
<input
type="checkbox"
checked={poolingConfig.include_owner}
onChange={(e) => setPoolingConfig({ ...poolingConfig, include_owner: e.target.checked })}
className="w-4 h-4 rounded border-slate-300 dark:border-slate-600 text-blue-600"
/>
<span className="text-slate-700 dark:text-slate-300"></span>
</label>
<label className="flex items-center gap-2 cursor-pointer text-sm">
<input
type="checkbox"
checked={poolingConfig.serial_authorize}
onChange={(e) => setPoolingConfig({ ...poolingConfig, serial_authorize: e.target.checked })}
className="w-4 h-4 rounded border-slate-300 dark:border-slate-600 text-blue-600"
/>
<span className="text-slate-700 dark:text-slate-300"></span>
</label>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">
</label>
<input
type="text"
placeholder="http://127.0.0.1:7890"
value={poolingConfig.proxy}
onChange={(e) => setPoolingConfig({ ...poolingConfig, proxy: e.target.value })}
className="w-full h-9 px-3 text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800"
/>
</div>
<Button
onClick={handleStartPooling}
disabled={!isConnected || pooling || !stats?.valid}
loading={pooling}
icon={pooling ? undefined : <Play className="h-4 w-4" />}
className="w-full"
>
{pooling ? '正在入库...' : '开始入库'}
</Button>
</CardContent>
</Card>
</div>
{/* Right: Quick Log View */}
<div className="hidden lg:block h-full overflow-hidden rounded-xl border border-slate-200 dark:border-slate-800 bg-slate-900 shadow-inner">
<div className="h-full flex flex-col">
<div className="flex items-center gap-2 px-4 py-3 border-b border-slate-800 bg-slate-900/50 backdrop-blur">
<Activity className="h-4 w-4 text-blue-400" />
<span className="text-sm font-medium text-slate-300"></span>
</div>
<div className="flex-1 overflow-hidden">
<LogStream apiBase={apiBase} />
</div>
</div>
</div>
</div>
)}
{activeTab === 'owners' && (
<div className="h-full overflow-hidden rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 shadow-sm">
<OwnerList apiBase={apiBase} />
</div>
)}
{activeTab === 'logs' && (
<div className="h-full overflow-hidden rounded-xl border border-slate-200 dark:border-slate-700 bg-slate-900 shadow-sm">
<LogStream apiBase={apiBase} />
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,9 @@
export { default as Dashboard } from './Dashboard'
export { default as Upload } from './Upload'
export { default as Records } from './Records'
export { default as Accounts } from './Accounts'
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 TeamProcess } from './TeamProcess'

View File

@@ -0,0 +1 @@
import '@testing-library/jest-dom'

225
frontend/src/types/index.ts Normal file
View File

@@ -0,0 +1,225 @@
// 输入账号JSON 文件格式)
export interface AccountInput {
account: string // 邮箱
password: string // 密码
token: string // access_token
}
// 账号状态
export type AccountStatus = 'pending' | 'checking' | 'active' | 'banned' | 'token_expired' | 'error'
// 检查后的账号
export interface CheckedAccount extends AccountInput {
id: number // 本地序号
status: AccountStatus
accountId?: string // ChatGPT workspace_id
planType?: string
error?: string
}
// 加号记录
export interface AddRecord {
id: string
timestamp: string
total: number
success: number
failed: number
source: 'manual' | 'auto'
details?: string
}
// S2A Dashboard 统计
export interface DashboardStats {
total_accounts: number
normal_accounts: number
error_accounts: number
ratelimit_accounts: number
overload_accounts: number
today_requests: number
today_tokens: number
today_cost: number
total_requests: number
total_tokens: number
total_cost: number
rpm: number
tpm: number
}
// S2A 账号
export interface S2AAccount {
id: number
name: string
notes?: string
platform: 'openai' | 'anthropic' | 'gemini'
type: 'oauth' | 'access_token' | 'apikey' | 'setup-token'
credentials: Record<string, unknown>
extra?: Record<string, unknown>
proxy_id?: number
concurrency: number
priority: number
rate_multiplier?: number
status: 'active' | 'inactive' | 'error'
error_message?: string
schedulable: boolean
last_used_at?: string
expires_at?: string
auto_pause_on_expired: boolean
created_at: string
updated_at: string
// 实时字段
current_concurrency?: number
current_window_cost?: number
active_sessions?: number
}
// 分页响应
export interface PaginatedResponse<T> {
data: T[]
total: number
page: number
page_size: number
total_pages: number
}
// 账号列表查询参数
export interface AccountListParams {
page?: number
page_size?: number
platform?: 'openai' | 'anthropic' | 'gemini'
type?: 'oauth' | 'access_token' | 'apikey'
status?: 'active' | 'inactive' | 'error'
search?: string
}
// 创建账号请求
export interface CreateAccountRequest {
name: string
platform: 'openai' | 'anthropic' | 'gemini'
type: 'access_token'
credentials: {
access_token: string
refresh_token?: string
email?: string
}
concurrency?: number
priority?: number
group_ids?: number[]
proxy_id?: number | null
auto_pause_on_expired?: boolean
}
// OAuth 创建账号请求
export interface OAuthCreateRequest {
session_id: string
code: string
name?: string
concurrency?: number
priority?: number
group_ids?: number[]
proxy_id?: number | null
}
// 分组
export interface Group {
id: number
name: string
description?: string
created_at: string
updated_at: string
}
// 代理
export interface Proxy {
id: number
name: string
url: string
status: 'active' | 'inactive' | 'error'
created_at: string
updated_at: string
}
// 趋势数据
export interface TrendData {
date: string
requests: number
tokens: number
cost: number
}
// 邮箱服务配置
export interface MailServiceConfig {
name: string // 服务名称
apiBase: string // API 地址
apiToken: string // API Token
domain: string // 邮箱域名
emailPath?: string // 获取邮件列表的 API 路径
addUserApi?: string // 创建用户的 API 路径
}
// 应用配置
export interface AppConfig {
s2a: {
apiBase: string
adminKey: string
}
pooling: {
concurrency: number
priority: number
groupIds: number[]
proxyId: number | null
}
check: {
concurrency: number
timeout: number
}
email: {
services: MailServiceConfig[] // 多个邮箱服务配置
}
}
// 默认配置
export const defaultConfig: AppConfig = {
s2a: {
apiBase: '',
adminKey: '',
},
pooling: {
concurrency: 1,
priority: 0,
groupIds: [],
proxyId: null,
},
check: {
concurrency: 20,
timeout: 30000,
},
email: {
services: [
{
name: 'esyteam',
apiBase: 'https://mail.esyteam.edu.kg',
apiToken: '',
domain: 'esyteam.edu.kg',
},
],
},
}
// 检查结果
export interface CheckResult {
status: AccountStatus
accountId?: string
planType?: string
error?: string
}
// 测试结果
export interface TestResult {
success: boolean
message?: string
latency?: number
}
// Account 类型别名 (对应 requirements.md A5)
// S2AAccount 已包含完整的 Account 数据结构
export type Account = S2AAccount

View File

@@ -0,0 +1,122 @@
/**
* 格式化日期时间
*/
export function formatDateTime(date: string | Date): string {
const d = typeof date === 'string' ? new Date(date) : date
return d.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
/**
* 格式化日期
*/
export function formatDate(date: string | Date): string {
const d = typeof date === 'string' ? new Date(date) : date
return d.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
}
/**
* 格式化时间
*/
export function formatTime(date: string | Date): string {
const d = typeof date === 'string' ? new Date(date) : date
return d.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
/**
* 格式化相对时间
*/
export function formatRelativeTime(date: string | Date): string {
const d = typeof date === 'string' ? new Date(date) : date
const now = new Date()
const diff = now.getTime() - d.getTime()
const seconds = Math.floor(diff / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
if (days > 0) return `${days}天前`
if (hours > 0) return `${hours}小时前`
if (minutes > 0) return `${minutes}分钟前`
return '刚刚'
}
/**
* 格式化数字(添加千分位)
*/
export function formatNumber(num: number | undefined | null): string {
if (num == null) return '0'
return num.toLocaleString('zh-CN')
}
/**
* 格式化金额
*/
export function formatCurrency(amount: number | undefined | null, currency: string = 'USD'): string {
if (amount == null) amount = 0
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount)
}
/**
* 格式化百分比
*/
export function formatPercent(value: number, decimals: number = 1): string {
return `${(value * 100).toFixed(decimals)}%`
}
/**
* 格式化文件大小
*/
export function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`
}
/**
* 截断文本
*/
export function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) return text
return `${text.substring(0, maxLength)}...`
}
/**
* 隐藏邮箱中间部分
*/
export function maskEmail(email: string): string {
const [local, domain] = email.split('@')
if (!domain) return email
if (local.length <= 2) return `${local}***@${domain}`
return `${local.substring(0, 2)}***@${domain}`
}
/**
* 隐藏 Token
*/
export function maskToken(token: string, visibleChars: number = 8): string {
if (token.length <= visibleChars * 2) return '***'
return `${token.substring(0, visibleChars)}...${token.substring(token.length - visibleChars)}`
}

View File

@@ -0,0 +1,5 @@
// Utils barrel export
export * from './storage'
export * from './format'
export * from './json-parser'
export * from './status-check'

View File

@@ -0,0 +1,126 @@
import { describe, it, expect } from 'vitest'
import { parseAccountJson, isValidEmail, isValidToken } from './json-parser'
describe('parseAccountJson', () => {
it('should parse valid JSON array', () => {
const json = JSON.stringify([
{ account: 'test@example.com', password: 'pass123', token: 'token123456' },
])
const result = parseAccountJson(json)
expect(result.success).toBe(true)
expect(result.data).toHaveLength(1)
expect(result.data?.[0].account).toBe('test@example.com')
})
it('should parse multiple accounts', () => {
const json = JSON.stringify([
{ account: 'user1@example.com', password: 'pass1', token: 'token1234567890' },
{ account: 'user2@example.com', password: 'pass2', token: 'token0987654321' },
])
const result = parseAccountJson(json)
expect(result.success).toBe(true)
expect(result.data).toHaveLength(2)
})
it('should reject non-array JSON', () => {
const json = JSON.stringify({ account: 'test@example.com' })
const result = parseAccountJson(json)
expect(result.success).toBe(false)
expect(result.error).toContain('数组格式')
})
it('should reject empty array', () => {
const json = JSON.stringify([])
const result = parseAccountJson(json)
expect(result.success).toBe(false)
expect(result.error).toContain('不能为空')
})
it('should reject invalid JSON', () => {
const result = parseAccountJson('not valid json')
expect(result.success).toBe(false)
expect(result.error).toContain('解析失败')
})
it('should reject missing account field', () => {
const json = JSON.stringify([{ password: 'pass', token: 'token123456' }])
const result = parseAccountJson(json)
expect(result.success).toBe(false)
expect(result.error).toContain('account')
})
it('should reject missing password field', () => {
const json = JSON.stringify([{ account: 'test@example.com', token: 'token123456' }])
const result = parseAccountJson(json)
expect(result.success).toBe(false)
expect(result.error).toContain('password')
})
it('should reject missing token field', () => {
const json = JSON.stringify([{ account: 'test@example.com', password: 'pass' }])
const result = parseAccountJson(json)
expect(result.success).toBe(false)
expect(result.error).toContain('token')
})
it('should reject non-string account', () => {
const json = JSON.stringify([{ account: 123, password: 'pass', token: 'token123456' }])
const result = parseAccountJson(json)
expect(result.success).toBe(false)
expect(result.error).toContain('account')
})
it('should reject null items in array', () => {
const json = JSON.stringify([null])
const result = parseAccountJson(json)
expect(result.success).toBe(false)
expect(result.error).toContain('有效的对象')
})
it('should indicate which record has error', () => {
const json = JSON.stringify([
{ account: 'valid@example.com', password: 'pass', token: 'token123456' },
{ account: 'invalid', password: 'pass' }, // missing token
])
const result = parseAccountJson(json)
expect(result.success).toBe(false)
expect(result.error).toContain('第 2 条')
})
})
describe('isValidEmail', () => {
it('should accept valid email', () => {
expect(isValidEmail('test@example.com')).toBe(true)
expect(isValidEmail('user.name@domain.co.uk')).toBe(true)
})
it('should reject invalid email', () => {
expect(isValidEmail('invalid')).toBe(false)
expect(isValidEmail('invalid@')).toBe(false)
expect(isValidEmail('@domain.com')).toBe(false)
expect(isValidEmail('')).toBe(false)
})
})
describe('isValidToken', () => {
it('should accept valid token', () => {
expect(isValidToken('eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9')).toBe(true)
expect(isValidToken('1234567890')).toBe(true)
})
it('should reject short token', () => {
expect(isValidToken('short')).toBe(false)
expect(isValidToken('')).toBe(false)
})
})

View File

@@ -0,0 +1,96 @@
import type { AccountInput } from '../types'
export interface ParseResult {
success: boolean
data?: AccountInput[]
error?: string
}
/**
* Parse and validate JSON account data
*/
export function parseAccountJson(jsonString: string): ParseResult {
try {
const data = JSON.parse(jsonString)
if (!Array.isArray(data)) {
return {
success: false,
error: 'JSON 文件必须是数组格式',
}
}
if (data.length === 0) {
return {
success: false,
error: 'JSON 数组不能为空',
}
}
// Validate each account
const accounts: AccountInput[] = []
for (let i = 0; i < data.length; i++) {
const item = data[i]
if (typeof item !== 'object' || item === null) {
return {
success: false,
error: `${i + 1} 条记录不是有效的对象`,
}
}
if (!item.account || typeof item.account !== 'string') {
return {
success: false,
error: `${i + 1} 条记录缺少有效的 account 字段`,
}
}
if (!item.password || typeof item.password !== 'string') {
return {
success: false,
error: `${i + 1} 条记录缺少有效的 password 字段`,
}
}
if (!item.token || typeof item.token !== 'string') {
return {
success: false,
error: `${i + 1} 条记录缺少有效的 token 字段`,
}
}
accounts.push({
account: item.account,
password: item.password,
token: item.token,
})
}
return {
success: true,
data: accounts,
}
} catch {
return {
success: false,
error: 'JSON 解析失败,请检查文件格式',
}
}
}
/**
* Validate email format
*/
export function isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email)
}
/**
* Validate token format (basic check)
*/
export function isValidToken(token: string): boolean {
// Token should be a non-empty string with reasonable length
return typeof token === 'string' && token.length >= 10
}

View File

@@ -0,0 +1,54 @@
import type { AccountStatus } from '../types'
/**
* Map HTTP status code to account status
*/
export function mapHttpStatusToAccountStatus(httpStatus: number): AccountStatus {
switch (httpStatus) {
case 200:
return 'active'
case 401:
return 'token_expired'
case 403:
return 'banned'
default:
return 'error'
}
}
/**
* Check if account status allows pooling
*/
export function canPoolAccount(status: AccountStatus): boolean {
return status === 'active'
}
/**
* Get status display text
*/
export function getStatusDisplayText(status: AccountStatus): string {
const statusMap: Record<AccountStatus, string> = {
pending: '待检查',
checking: '检查中',
active: '正常',
banned: '封禁',
token_expired: '过期',
error: '错误',
}
return statusMap[status] || '未知'
}
/**
* Get status color class
*/
export function getStatusColorClass(status: AccountStatus): string {
const colorMap: Record<AccountStatus, string> = {
pending: 'text-slate-500',
checking: 'text-blue-500',
active: 'text-green-500',
banned: 'text-red-500',
token_expired: 'text-orange-500',
error: 'text-yellow-500',
}
return colorMap[status] || 'text-slate-500'
}

View File

Some files were not shown because too many files have changed in this diff Show More