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:
38
.dockerignore
Normal file
38
.dockerignore
Normal 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
100
.gitignore
vendored
Normal 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
162
DOCKER.md
Normal 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
109
Dockerfile
Normal 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
137
backend/README.md
Normal 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
349
backend/cmd/main.go
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
433
backend/cmd/test_browser_auth/main.go
Normal file
433
backend/cmd/test_browser_auth/main.go
Normal 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
BIN
backend/codex-pool.exe
Normal file
Binary file not shown.
40
backend/go.mod
Normal file
40
backend/go.mod
Normal 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
90
backend/go.sum
Normal 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=
|
||||||
54
backend/internal/api/http.go
Normal file
54
backend/internal/api/http.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
423
backend/internal/api/team_process.go
Normal file
423
backend/internal/api/team_process.go
Normal 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
|
||||||
|
}
|
||||||
194
backend/internal/auth/chromedp.go
Normal file
194
backend/internal/auth/chromedp.go
Normal 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(¤tURL))
|
||||||
|
|
||||||
|
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(¤tURL))
|
||||||
|
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("授权超时")
|
||||||
|
}
|
||||||
167
backend/internal/auth/rod.go
Normal file
167
backend/internal/auth/rod.go
Normal 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)
|
||||||
|
}
|
||||||
291
backend/internal/auth/s2a.go
Normal file
291
backend/internal/auth/s2a.go
Normal 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 ""
|
||||||
|
}
|
||||||
240
backend/internal/client/tls.go
Normal file
240
backend/internal/client/tls.go
Normal 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
|
||||||
|
}
|
||||||
264
backend/internal/config/config.go
Normal file
264
backend/internal/config/config.go
Normal 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)
|
||||||
|
}
|
||||||
272
backend/internal/database/sqlite.go
Normal file
272
backend/internal/database/sqlite.go
Normal 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
|
||||||
|
}
|
||||||
190
backend/internal/invite/team.go
Normal file
190
backend/internal/invite/team.go
Normal 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
|
||||||
|
}
|
||||||
140
backend/internal/logger/logger.go
Normal file
140
backend/internal/logger/logger.go
Normal 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)
|
||||||
|
}
|
||||||
455
backend/internal/mail/service.go
Normal file
455
backend/internal/mail/service.go
Normal 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, "&", "&")
|
||||||
|
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)
|
||||||
|
}
|
||||||
415
backend/internal/register/chatgpt.go
Normal file
415
backend/internal/register/chatgpt.go
Normal 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] + "..."
|
||||||
|
}
|
||||||
18
backend/internal/web/dev.go
Normal file
18
backend/internal/web/dev.go
Normal 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
|
||||||
|
}
|
||||||
27
backend/internal/web/embed.go
Normal file
27
backend/internal/web/embed.go
Normal 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
40
docker-compose.yml
Normal 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
24
frontend/.gitignore
vendored
Normal 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
6
frontend/.prettierignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
coverage
|
||||||
|
*.min.js
|
||||||
|
*.min.css
|
||||||
10
frontend/.prettierrc
Normal file
10
frontend/.prettierrc
Normal 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
73
frontend/README.md
Normal 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
76
frontend/eslint.config.js
Normal 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
14
frontend/index.html
Normal 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
5522
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
49
frontend/package.json
Normal file
49
frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
21
frontend/public/favicon.svg
Normal file
21
frontend/public/favicon.svg
Normal 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
1
frontend/public/vite.svg
Normal 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
28
frontend/src/App.tsx
Normal 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
|
||||||
294
frontend/src/api/chatgpt.test.ts
Normal file
294
frontend/src/api/chatgpt.test.ts
Normal 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
253
frontend/src/api/chatgpt.ts
Normal 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()
|
||||||
4
frontend/src/api/index.ts
Normal file
4
frontend/src/api/index.ts
Normal 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
155
frontend/src/api/s2a.ts
Normal 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
522
frontend/src/api/types.ts
Normal 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[]
|
||||||
|
}
|
||||||
215
frontend/src/components/common/Button.test.tsx
Normal file
215
frontend/src/components/common/Button.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
158
frontend/src/components/common/Button.tsx
Normal file
158
frontend/src/components/common/Button.tsx
Normal 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
|
||||||
84
frontend/src/components/common/Card.tsx
Normal file
84
frontend/src/components/common/Card.tsx
Normal 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
|
||||||
61
frontend/src/components/common/ErrorBoundary.tsx
Normal file
61
frontend/src/components/common/ErrorBoundary.tsx
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
100
frontend/src/components/common/Input.tsx
Normal file
100
frontend/src/components/common/Input.tsx
Normal 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
|
||||||
68
frontend/src/components/common/Progress.tsx
Normal file
68
frontend/src/components/common/Progress.tsx
Normal 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
|
||||||
74
frontend/src/components/common/Select.tsx
Normal file
74
frontend/src/components/common/Select.tsx
Normal 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
|
||||||
66
frontend/src/components/common/StatusBadge.tsx
Normal file
66
frontend/src/components/common/StatusBadge.tsx
Normal 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
|
||||||
110
frontend/src/components/common/Table.tsx
Normal file
110
frontend/src/components/common/Table.tsx
Normal 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
|
||||||
48
frontend/src/components/common/Tabs.tsx
Normal file
48
frontend/src/components/common/Tabs.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
141
frontend/src/components/common/Toast.tsx
Normal file
141
frontend/src/components/common/Toast.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
33
frontend/src/components/common/index.ts
Normal file
33
frontend/src/components/common/index.ts
Normal 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'
|
||||||
64
frontend/src/components/dashboard/PoolStatus.tsx
Normal file
64
frontend/src/components/dashboard/PoolStatus.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
82
frontend/src/components/dashboard/RecentRecords.tsx
Normal file
82
frontend/src/components/dashboard/RecentRecords.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
96
frontend/src/components/dashboard/StatsCard.tsx
Normal file
96
frontend/src/components/dashboard/StatsCard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
3
frontend/src/components/dashboard/index.ts
Normal file
3
frontend/src/components/dashboard/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { default as StatsCard } from './StatsCard'
|
||||||
|
export { default as PoolStatus } from './PoolStatus'
|
||||||
|
export { default as RecentRecords } from './RecentRecords'
|
||||||
97
frontend/src/components/layout/Header.tsx
Normal file
97
frontend/src/components/layout/Header.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
30
frontend/src/components/layout/Layout.tsx
Normal file
30
frontend/src/components/layout/Layout.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
174
frontend/src/components/layout/Sidebar.tsx
Normal file
174
frontend/src/components/layout/Sidebar.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
3
frontend/src/components/layout/index.ts
Normal file
3
frontend/src/components/layout/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { default as Layout } from './Layout'
|
||||||
|
export { default as Header } from './Header'
|
||||||
|
export { default as Sidebar } from './Sidebar'
|
||||||
103
frontend/src/components/records/RecordList.tsx
Normal file
103
frontend/src/components/records/RecordList.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
76
frontend/src/components/records/RecordStats.tsx
Normal file
76
frontend/src/components/records/RecordStats.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
2
frontend/src/components/records/index.ts
Normal file
2
frontend/src/components/records/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as RecordList } from './RecordList'
|
||||||
|
export { default as RecordStats } from './RecordStats'
|
||||||
182
frontend/src/components/upload/AccountTable.tsx
Normal file
182
frontend/src/components/upload/AccountTable.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
72
frontend/src/components/upload/CheckProgress.tsx
Normal file
72
frontend/src/components/upload/CheckProgress.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
114
frontend/src/components/upload/FileDropzone.tsx
Normal file
114
frontend/src/components/upload/FileDropzone.tsx
Normal 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">
|
||||||
|
支持格式: [{"account": "email", "password": "pwd", "token": "..."}]
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
173
frontend/src/components/upload/LogStream.tsx
Normal file
173
frontend/src/components/upload/LogStream.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
218
frontend/src/components/upload/OwnerList.tsx
Normal file
218
frontend/src/components/upload/OwnerList.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
141
frontend/src/components/upload/PoolActions.tsx
Normal file
141
frontend/src/components/upload/PoolActions.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
4
frontend/src/components/upload/index.ts
Normal file
4
frontend/src/components/upload/index.ts
Normal 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'
|
||||||
148
frontend/src/context/ConfigContext.tsx
Normal file
148
frontend/src/context/ConfigContext.tsx
Normal 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
|
||||||
|
}
|
||||||
110
frontend/src/context/RecordsContext.tsx
Normal file
110
frontend/src/context/RecordsContext.tsx
Normal 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
|
||||||
|
}
|
||||||
2
frontend/src/context/index.ts
Normal file
2
frontend/src/context/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { ConfigProvider, useConfigContext } from './ConfigContext'
|
||||||
|
export { RecordsProvider, useRecordsContext } from './RecordsContext'
|
||||||
5
frontend/src/hooks/index.ts
Normal file
5
frontend/src/hooks/index.ts
Normal 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'
|
||||||
160
frontend/src/hooks/useAccountCheck.ts
Normal file
160
frontend/src/hooks/useAccountCheck.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
181
frontend/src/hooks/useBackendApi.ts
Normal file
181
frontend/src/hooks/useBackendApi.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
5
frontend/src/hooks/useConfig.ts
Normal file
5
frontend/src/hooks/useConfig.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { useConfigContext } from '../context/ConfigContext'
|
||||||
|
|
||||||
|
export function useConfig() {
|
||||||
|
return useConfigContext()
|
||||||
|
}
|
||||||
5
frontend/src/hooks/useRecords.ts
Normal file
5
frontend/src/hooks/useRecords.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { useRecordsContext } from '../context/RecordsContext'
|
||||||
|
|
||||||
|
export function useRecords() {
|
||||||
|
return useRecordsContext()
|
||||||
|
}
|
||||||
166
frontend/src/hooks/useS2AApi.ts
Normal file
166
frontend/src/hooks/useS2AApi.ts
Normal 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
416
frontend/src/index.css
Normal 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
18
frontend/src/main.tsx
Normal 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>
|
||||||
|
)
|
||||||
263
frontend/src/pages/Accounts.tsx
Normal file
263
frontend/src/pages/Accounts.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
301
frontend/src/pages/Config.tsx
Normal file
301
frontend/src/pages/Config.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
162
frontend/src/pages/Dashboard.tsx
Normal file
162
frontend/src/pages/Dashboard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
251
frontend/src/pages/EmailConfig.tsx
Normal file
251
frontend/src/pages/EmailConfig.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
588
frontend/src/pages/Monitor.tsx
Normal file
588
frontend/src/pages/Monitor.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
112
frontend/src/pages/Records.tsx
Normal file
112
frontend/src/pages/Records.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
241
frontend/src/pages/S2AConfig.tsx
Normal file
241
frontend/src/pages/S2AConfig.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
483
frontend/src/pages/TeamProcess.tsx
Normal file
483
frontend/src/pages/TeamProcess.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
340
frontend/src/pages/Upload.tsx
Normal file
340
frontend/src/pages/Upload.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
9
frontend/src/pages/index.ts
Normal file
9
frontend/src/pages/index.ts
Normal 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'
|
||||||
1
frontend/src/test/setup.ts
Normal file
1
frontend/src/test/setup.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import '@testing-library/jest-dom'
|
||||||
225
frontend/src/types/index.ts
Normal file
225
frontend/src/types/index.ts
Normal 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
|
||||||
122
frontend/src/utils/format.ts
Normal file
122
frontend/src/utils/format.ts
Normal 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)}`
|
||||||
|
}
|
||||||
5
frontend/src/utils/index.ts
Normal file
5
frontend/src/utils/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// Utils barrel export
|
||||||
|
export * from './storage'
|
||||||
|
export * from './format'
|
||||||
|
export * from './json-parser'
|
||||||
|
export * from './status-check'
|
||||||
126
frontend/src/utils/json-parser.test.ts
Normal file
126
frontend/src/utils/json-parser.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
96
frontend/src/utils/json-parser.ts
Normal file
96
frontend/src/utils/json-parser.ts
Normal 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
|
||||||
|
}
|
||||||
54
frontend/src/utils/status-check.ts
Normal file
54
frontend/src/utils/status-check.ts
Normal 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'
|
||||||
|
}
|
||||||
0
frontend/src/utils/storage.test.ts
Normal file
0
frontend/src/utils/storage.test.ts
Normal file
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user