first commit

This commit is contained in:
2026-04-05 10:23:02 +08:00
commit 8824aa1fb1
48 changed files with 12943 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

2
.env.example Normal file
View File

@@ -0,0 +1,2 @@
# Optional: if omitted, backend will auto-generate a token into docker-data/backend/admin_token.txt
# APP_ADMIN_TOKEN=your-strong-admin-token

144
README.md Normal file
View File

@@ -0,0 +1,144 @@
# gpt-auto
一个用于**账号池维护(清理 + 补号)**的本地工具,包含:
- Python 后端(`api_server.py` + `auto_pool_maintainer.py`
- Preact 前端控制台(`frontend/`
- 一键启动脚本(`dev_services.sh`
---
## 新手快速上手(最短路径)
> 目标:第一次就能正确拉起项目并进入前端面板。
### 1) 安装依赖
在项目根目录执行:
```bash
# 1. Python 依赖
python3 -m venv .venv
./.venv/bin/pip install -r requirements.txt
# 2. 前端依赖
cd frontend
pnpm install
cd ..
```
### 2) 准备配置文件
```bash
cp config.example.json config.json
```
然后至少修改以下关键项(不改会跑不起来):
- `clean.base_url`:你的 CLIProxyAPI 地址(例如 `http://127.0.0.1:8317`
- `clean.token`CLIProxyAPI 管理 token
- `mail.provider` + 对应 provider 的配置(`mail.api_base/api_key/domain/domains``cfmail.api_base/api_key/domains` 等)
### 3) 启动项目
```bash
./dev_services.sh fg
```
启动成功后:
- 前端地址:`http://127.0.0.1:8173`
- 后端 API`http://127.0.0.1:8318`
首次启动后端会生成 `admin_token.txt`,把里面的 token 复制到前端登录框(`X-Admin-Token`)。
---
## 关键配置说明(只讲重要的)
配置文件:`config.json`
### `clean`(账号探测/清理)
- `base_url` / `token`CLIProxyAPI 连接信息(必填)
- `target_type`:目标账号类型(通常为 `codex`
- `sample_size`:随机抽样探测数量,`0` 表示全量探测
- `used_percent_threshold`:超阈值判定
### `maintainer`(补号目标)
- `min_candidates`:目标可用号数量(低于它就补号)
- `loop_interval_seconds`:循环模式下每轮检查间隔
### `run`(补号执行参数)
- `workers`:补号并发
- `failure_threshold_for_cooldown` / `failure_cooldown_seconds`:连续失败冷却策略
- 对支持多域名的邮箱 provider这两个参数作用于“单个域名”的熔断与恢复
### `mail`(邮箱提供方)
- `provider``cfmail / self_hosted_mail_api / duckmail / tempmail_lol / yyds_mail`
- 不同 provider 需要填写对应 section 的鉴权字段
- `otp_timeout_seconds` / `poll_interval_seconds`:验证码等待与轮询间隔
- 支持多域名的 provider`self_hosted_mail_api / duckmail / yyds_mail`
- `domains`:可选数组,配置多个域名时按顺序轮询;如果填写,优先级高于单个 `domain`
- `tempmail_lol` 当前不支持自定义多域名切换
### `cfmail`CF 自建邮箱)
- `api_base`CF Mail Worker 接口地址
- `api_key`CF Mail 管理密钥,请求时会发到 `x-admin-auth`
- `domains`:域名列表,按顺序轮询;填写后优先于单个 `domain`
- `cfmail` 是独立 provider不复用 `self_hosted_mail_api`
- 节点熔断直接复用 `run.failure_threshold_for_cooldown / run.failure_cooldown_seconds`
---
## 常用命令
```bash
# 前台启动(推荐调试)
./dev_services.sh fg
# 后台启动
./dev_services.sh bg
# 查看状态
./dev_services.sh status
# 停止后台服务
./dev_services.sh stop
```
单次执行维护任务(不走前端):
```bash
./.venv/bin/python auto_pool_maintainer.py --config config.json --log-dir logs
```
---
## 日志与产物
- 维护日志:`logs/pool_maintainer_*.log`
- 服务托管日志:`logs/dev-services/`
- 本地 token/账号输出(当 `output.save_local=true` 时):
- `output_fixed/`
- `output_tokens/`
---
## 排错建议(高频)
1. 前端打开但接口 401通常是 `X-Admin-Token` 错误,重新读取 `admin_token.txt`
2. 补号不触发:先看日志里的 `清理后统计: candidates=... 阈值=...`
3. 启动失败:先看 `logs/dev-services/backend.log` / `frontend.log`
4. OAuth 偶发慢:优先看日志中的 `oauth_mail_otp_timeout` 是否增多(邮箱链路波动)
---
## 安全提示
- `config.json``admin_token.txt` 可能包含敏感信息,不要公开上传。
- 对外发布代码时,建议仅保留 `config.example.json`

170
README_v1.md Normal file
View File

@@ -0,0 +1,170 @@
# gpt-auto
一个用于**账号池维护(清理 + 补号)**的本地工具,包含:
- Python 后端(`api_server.py` + `auto_pool_maintainer.py`
- Preact 前端控制台(`frontend/`
- 一键启动脚本(`dev_services.sh`
---
## 新手快速上手(最短路径)
> 目标:第一次就能正确拉起项目并进入前端面板。
### 1) 安装依赖
在项目根目录执行:
```bash
# 1. Python 依赖(推荐使用 uv
uv venv .venv
uv pip install -r requirements.txt
# 如果你不使用 uv也可以继续用 venv + pip
# python3 -m venv .venv
# ./.venv/bin/pip install -r requirements.txt
# 2. 前端依赖
cd frontend
pnpm install
cd ..
```
说明:
- `uv` 这里只用于安装 Python 后端依赖;前端依赖仍然使用 `pnpm`
- 当前仓库没有 `pyproject.toml` / `uv.lock`,因此这里使用 `uv pip install -r requirements.txt`,而不是 `uv sync`
- `uv venv .venv` 创建的虚拟环境与现有启动脚本兼容,无需额外修改脚本
### 2) 准备配置文件
```bash
cp config.example.json config.json
```
然后至少修改以下关键项(不改会跑不起来):
- `clean.base_url`:你的 CLIProxyAPI 地址(例如 `http://127.0.0.1:8317`
- `clean.token`CLIProxyAPI 管理 token
- `mail.provider` + 对应 provider 的配置(`mail.api_base/api_key/domain` 等)
### 3) 启动项目
```bash
# macOS / Linux
./dev_services.sh fg
# Windows PowerShell
.\dev_services.ps1 fg
```
启动成功后:
- 前端地址:`http://127.0.0.1:8173`
- 后端 API`http://127.0.0.1:8318`
首次启动后端会生成 `admin_token.txt`,把里面的 token 复制到前端登录框(`X-Admin-Token`)。
---
## 关键配置说明(只讲重要的)
配置文件:`config.json`
### `clean`(账号探测/清理)
- `base_url` / `token`CLIProxyAPI 连接信息(必填)
- `target_type`:目标账号类型(通常为 `codex`
- `used_percent_threshold`:超阈值判定
### `maintainer`(补号目标)
- `min_candidates`:目标可用号数量(低于它就补号)
- `loop_interval_seconds`:循环模式下每轮检查间隔
### `run`(补号执行参数)
- `workers`:补号并发
- `failure_threshold_for_cooldown` / `failure_cooldown_seconds`:连续失败冷却策略
### `mail`(邮箱提供方)
- `provider``self_hosted_mail_api / duckmail / tempmail_lol / yyds_mail`
- 不同 provider 需要填写对应 section 的鉴权字段
- `otp_timeout_seconds` / `poll_interval_seconds`:验证码等待与轮询间隔
---
## 常用命令
```bash
# win 用户可用 dev_services.ps1 脚本启动
# 下方示例默认使用 bash 写法Windows 对应命令见后
# macOS / Linux
# 前台启动(推荐调试)
./dev_services.sh fg
# 后台启动
./dev_services.sh bg
# 查看状态
./dev_services.sh status
# 停止后台服务
./dev_services.sh stop
```
Windows PowerShell 对应命令:
```powershell
# 前台启动(推荐调试)
.\dev_services.ps1 fg
# 后台启动
.\dev_services.ps1 bg
# 查看状态
.\dev_services.ps1 status
# 停止后台服务
.\dev_services.ps1 stop
```
单次执行维护任务(不走前端):
```bash
# macOS / Linux
./.venv/bin/python auto_pool_maintainer.py --config config.json --log-dir logs
# Windows PowerShell
.\.venv\Scripts\python.exe auto_pool_maintainer.py --config config.json --log-dir logs
```
---
## 日志与产物
- 维护日志:`logs/pool_maintainer_*.log`
- 服务托管日志:`logs/dev-services/`
- 本地 token/账号输出(当 `output.save_local=true` 时):
- `output_fixed/`
- `output_tokens/`
---
## 排错建议(高频)
1. 前端打开但接口 401通常是 `X-Admin-Token` 错误,重新读取 `admin_token.txt`
2. 补号不触发:先看日志里的 `清理后统计: candidates=... 阈值=...`
3. 启动失败:先看 `logs/dev-services/backend.log` / `frontend.log`
4. OAuth 偶发慢:优先看日志中的 `oauth_mail_otp_timeout` 是否增多(邮箱链路波动)
5. 持久化bug 若出现卡住无法stop,把持久化文件`.maintainer_run_state.json`删掉即可
---
## 安全提示
- `config.json``admin_token.txt` 可能包含敏感信息,不要公开上传。
- 对外发布代码时,建议仅保留 `config.example.json`

736
api_server.py Normal file
View File

@@ -0,0 +1,736 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
import json
import math
import os
import re
import secrets
import signal
import shutil
import subprocess
import sys
import threading
import time
from collections import deque
from datetime import datetime
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any, Dict, List, Optional
from auto_pool_maintainer import get_candidates_count
PROJECT_ROOT = Path(__file__).resolve().parent
APP_DATA_DIR = Path(os.environ.get("APP_DATA_DIR", str(PROJECT_ROOT)))
CONFIG_PATH = Path(os.environ.get("APP_CONFIG_PATH", str(APP_DATA_DIR / "config.json")))
LOGS_DIR = Path(os.environ.get("APP_LOG_DIR", str(APP_DATA_DIR / "logs")))
TEMPLATE_CONFIG_PATH = Path(os.environ.get("APP_TEMPLATE_CONFIG_PATH", str(PROJECT_ROOT / "config.example.json")))
API_HOST = os.environ.get("APP_HOST", "127.0.0.1")
API_PORT = int(os.environ.get("APP_PORT", "8318"))
ADMIN_TOKEN_ENV = os.environ.get("APP_ADMIN_TOKEN", "").strip()
ADMIN_TOKEN_FILE = Path(os.environ.get("APP_ADMIN_TOKEN_FILE", str(APP_DATA_DIR / "admin_token.txt")))
RUN_STATE_FILE = Path(os.environ.get("APP_RUN_STATE_FILE", str(APP_DATA_DIR / ".maintainer_run_state.json")))
MASKED_VALUE = "__MASKED__"
RUN_PROCESS: Optional[subprocess.Popen[str]] = None
RUN_MODE: str = ""
RUN_LOG_PATH: str = ""
RUN_PROCESS_LOCK = threading.Lock()
ADMIN_TOKEN_LOCK = threading.Lock()
ADMIN_TOKEN_CACHE: Optional[str] = None
def ensure_runtime_paths() -> None:
APP_DATA_DIR.mkdir(parents=True, exist_ok=True)
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
LOGS_DIR.mkdir(parents=True, exist_ok=True)
ADMIN_TOKEN_FILE.parent.mkdir(parents=True, exist_ok=True)
RUN_STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
def load_run_state() -> Dict[str, Any]:
ensure_runtime_paths()
if not RUN_STATE_FILE.exists():
return {}
try:
data = json.loads(RUN_STATE_FILE.read_text(encoding="utf-8"))
except Exception:
return {}
return data if isinstance(data, dict) else {}
def save_run_state(pid: int, mode: str, log_path: str = "") -> None:
ensure_runtime_paths()
payload = {
"pid": int(pid),
"mode": str(mode or ""),
"log_path": str(log_path or ""),
"updated_at": datetime.now().isoformat(),
}
RUN_STATE_FILE.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
def clear_run_state() -> None:
try:
RUN_STATE_FILE.unlink(missing_ok=True)
except Exception:
pass
def is_pid_running(pid: int) -> bool:
if pid <= 0:
return False
try:
os.kill(pid, 0)
return True
except ProcessLookupError:
return False
except PermissionError:
return True
except Exception:
return False
def read_running_state() -> tuple[Optional[int], str, str]:
state = load_run_state()
raw_pid = state.get("pid")
mode = str(state.get("mode") or "")
log_path = str(state.get("log_path") or "")
try:
pid = int(raw_pid)
except Exception:
return None, mode, log_path
if not is_pid_running(pid):
clear_run_state()
return None, mode, log_path
return pid, mode, log_path
def terminate_pid(pid: int, timeout_seconds: float = 8.0) -> bool:
if not is_pid_running(pid):
return True
try:
os.kill(pid, signal.SIGTERM)
except ProcessLookupError:
return True
except Exception:
return False
deadline = time.time() + max(0.5, timeout_seconds)
while time.time() < deadline:
if not is_pid_running(pid):
return True
time.sleep(0.2)
try:
os.kill(pid, signal.SIGKILL)
except ProcessLookupError:
return True
except Exception:
return False
return not is_pid_running(pid)
def ensure_config_exists() -> None:
ensure_runtime_paths()
if CONFIG_PATH.exists():
return
if TEMPLATE_CONFIG_PATH.exists():
shutil.copyfile(TEMPLATE_CONFIG_PATH, CONFIG_PATH)
return
raise RuntimeError(f"配置文件不存在,且模板不存在: {CONFIG_PATH} | {TEMPLATE_CONFIG_PATH}")
def get_admin_token() -> str:
global ADMIN_TOKEN_CACHE
with ADMIN_TOKEN_LOCK:
if ADMIN_TOKEN_CACHE:
return ADMIN_TOKEN_CACHE
if ADMIN_TOKEN_ENV:
ADMIN_TOKEN_CACHE = ADMIN_TOKEN_ENV
return ADMIN_TOKEN_CACHE
ensure_runtime_paths()
if ADMIN_TOKEN_FILE.exists():
token = ADMIN_TOKEN_FILE.read_text(encoding="utf-8").strip()
if token:
ADMIN_TOKEN_CACHE = token
return ADMIN_TOKEN_CACHE
token = secrets.token_urlsafe(32)
ADMIN_TOKEN_FILE.write_text(f"{token}\n", encoding="utf-8")
try:
os.chmod(ADMIN_TOKEN_FILE, 0o600)
except OSError:
pass
ADMIN_TOKEN_CACHE = token
return ADMIN_TOKEN_CACHE
def load_config() -> Dict[str, Any]:
ensure_config_exists()
with CONFIG_PATH.open("r", encoding="utf-8") as handle:
data = json.load(handle)
if not isinstance(data, dict):
raise RuntimeError("config.json 顶层必须是 JSON 对象")
return data
def save_config(payload: Dict[str, Any]) -> None:
if not isinstance(payload, dict):
raise RuntimeError("配置数据必须是 JSON 对象")
ensure_runtime_paths()
merged = merge_config_with_sensitive_fields(load_config(), payload)
with CONFIG_PATH.open("w", encoding="utf-8") as handle:
json.dump(merged, handle, ensure_ascii=False, indent=2)
handle.write("\n")
def mask_sensitive_config(config: Dict[str, Any]) -> Dict[str, Any]:
masked = json.loads(json.dumps(config))
sensitive_fields = [
("clean", "token"),
("mail", "api_key"),
("cfmail", "api_key"),
("duckmail", "bearer"),
("yyds_mail", "api_key"),
]
for section, key in sensitive_fields:
sec = masked.get(section)
if isinstance(sec, dict) and sec.get(key):
sec[key] = MASKED_VALUE
return masked
def merge_config_with_sensitive_fields(current: Dict[str, Any], incoming: Dict[str, Any]) -> Dict[str, Any]:
merged = json.loads(json.dumps(incoming))
sensitive_fields = [
("clean", "token"),
("mail", "api_key"),
("cfmail", "api_key"),
("duckmail", "bearer"),
("yyds_mail", "api_key"),
]
for section, key in sensitive_fields:
current_section = current.get(section) if isinstance(current.get(section), dict) else {}
merged_section = merged.get(section) if isinstance(merged.get(section), dict) else {}
if merged_section.get(key) == MASKED_VALUE and key in current_section:
merged_section[key] = current_section.get(key)
merged[section] = merged_section
return merged
def is_sensitive_field_masked(value: Any) -> bool:
return isinstance(value, str) and value == MASKED_VALUE
def get_latest_log_path() -> Optional[Path]:
ensure_runtime_paths()
if not LOGS_DIR.exists():
return None
candidates = sorted(LOGS_DIR.glob("pool_maintainer_*.log"), key=lambda item: item.stat().st_mtime, reverse=True)
return candidates[0] if candidates else None
def tail_lines(path: Path, max_lines: int = 120) -> List[str]:
buffer: deque[str] = deque(maxlen=max_lines)
with path.open("r", encoding="utf-8", errors="replace") as handle:
for line in handle:
line = line.rstrip("\n")
if line.strip():
buffer.append(line)
return list(buffer)
def tone_from_log(level: str, message: str) -> str:
normalized_level = level.upper()
normalized_message = message.lower()
if normalized_level in {"ERROR", "CRITICAL"}:
return "danger"
if normalized_level == "WARNING":
return "warning"
if "成功" in message or "完成" in message or "已达标" in message:
return "success"
if "失败" in message or "异常" in message or "错误" in message:
return "danger"
if "等待" in message or "进度" in message:
return "info"
if normalized_level == "INFO":
return "info"
if "warning" in normalized_message:
return "warning"
return "muted"
def parse_log_line(index: int, raw_line: str) -> Dict[str, Any]:
match = re.match(r"^(?P<date>\d{4}-\d{2}-\d{2}) (?P<clock>\d{2}:\d{2}:\d{2}) \| (?P<level>[A-Z]+) \| (?P<message>.*)$", raw_line)
if not match:
return {
"id": f"log-{index}",
"prefix": "[系统]",
"timestamp": "[--:--:--]",
"message": raw_line,
"tone": "muted",
}
level = match.group("level")
message = match.group("message")
prefix = f"[{level}]"
task_match = re.match(r"^(任务\d+)\s*\|\s*(.*)$", message)
if task_match:
prefix = f"[{task_match.group(1)}] [{level}]"
message = task_match.group(2)
return {
"id": f"log-{index}",
"prefix": prefix,
"timestamp": f"[{match.group('clock')}]",
"message": message,
"tone": tone_from_log(level, message),
}
def build_single_account_timing(raw_lines: List[str], window_size: int = 20) -> Dict[str, Any]:
pattern = re.compile(
r"注册\+OAuth 成功: .*?\| 注册 (?P<reg>\d+(?:\.\d+)?)s \+ OAuth (?P<oauth>\d+(?:\.\d+)?)s = (?P<total>\d+(?:\.\d+)?)s"
)
samples: List[Dict[str, float]] = []
for line in raw_lines:
matched = pattern.search(line)
if not matched:
continue
samples.append(
{
"reg": float(matched.group("reg")),
"oauth": float(matched.group("oauth")),
"total": float(matched.group("total")),
}
)
result: Dict[str, Any] = {
"latest_reg_seconds": None,
"latest_oauth_seconds": None,
"latest_total_seconds": None,
"recent_avg_reg_seconds": None,
"recent_avg_oauth_seconds": None,
"recent_avg_total_seconds": None,
"recent_slow_count": 0,
"sample_size": 0,
"window_size": max(1, int(window_size)),
}
if not samples:
return result
latest = samples[-1]
recent = samples[-result["window_size"] :]
result["latest_reg_seconds"] = round(latest["reg"], 1)
result["latest_oauth_seconds"] = round(latest["oauth"], 1)
result["latest_total_seconds"] = round(latest["total"], 1)
result["recent_avg_reg_seconds"] = round(sum(item["reg"] for item in recent) / len(recent), 1)
result["recent_avg_oauth_seconds"] = round(sum(item["oauth"] for item in recent) / len(recent), 1)
result["recent_avg_total_seconds"] = round(sum(item["total"] for item in recent) / len(recent), 1)
result["recent_slow_count"] = sum(1 for item in recent if item["total"] >= 100.0)
result["sample_size"] = len(recent)
return result
def parse_loop_next_check_in_seconds(raw_lines: List[str]) -> Optional[int]:
pattern = re.compile(
r"^(?P<date>\d{4}-\d{2}-\d{2}) (?P<clock>\d{2}:\d{2}:\d{2}) \| [A-Z]+ \| 循环模式休眠 (?P<seconds>\d+(?:\.\d+)?)s 后再次检查号池$"
)
now_ts = time.time()
for line in reversed(raw_lines):
matched = pattern.match(line.strip())
if not matched:
continue
try:
sleep_seconds = float(matched.group("seconds"))
logged_at = datetime.strptime(
f"{matched.group('date')} {matched.group('clock')}",
"%Y-%m-%d %H:%M:%S",
)
next_check_ts = logged_at.timestamp() + sleep_seconds
remaining = int(math.ceil(next_check_ts - now_ts))
return max(0, remaining)
except Exception:
continue
return None
def build_runtime_status() -> Dict[str, Any]:
tracked_log_path = ""
with RUN_PROCESS_LOCK:
process = RUN_PROCESS
running = process is not None and process.poll() is None
run_mode = RUN_MODE if running else ""
if running:
tracked_log_path = RUN_LOG_PATH
if not running:
state_pid, state_mode, state_log_path = read_running_state()
if state_pid is not None:
running = True
run_mode = state_mode
tracked_log_path = state_log_path
status: Dict[str, Any] = {
"running": running,
"run_mode": run_mode,
"loop_running": running and run_mode == "loop",
"loop_next_check_in_seconds": None,
"phase": "idle",
"message": "等待任务启动",
"available_candidates": None,
"available_candidates_error": "",
"completed": 0,
"total": 0,
"percent": 0,
"stats": [
{"label": "成功", "value": 0, "icon": "", "tone": "success"},
{"label": "失败", "value": 0, "icon": "", "tone": "danger"},
{"label": "剩余", "value": 0, "icon": "", "tone": "pending"},
],
"single_account_timing": {
"latest_reg_seconds": None,
"latest_oauth_seconds": None,
"latest_total_seconds": None,
"recent_avg_reg_seconds": None,
"recent_avg_oauth_seconds": None,
"recent_avg_total_seconds": None,
"recent_slow_count": 0,
"sample_size": 0,
"window_size": 20,
},
"logs": [],
"last_log_path": "",
}
latest_log: Optional[Path] = None
if tracked_log_path:
tracked_path = Path(tracked_log_path)
if tracked_path.exists():
latest_log = tracked_path
if latest_log is None:
latest_log = get_latest_log_path()
if latest_log is None:
status["logs"] = [
{
"id": "log-empty",
"prefix": "[系统]",
"timestamp": "[--:--:--]",
"message": "暂无运行日志",
"tone": "muted",
}
]
return status
try:
config = load_config()
base_url = str(((config.get("clean") or {}).get("base_url")) or "").rstrip("/")
token = str(((config.get("clean") or {}).get("token")) or "").strip()
target_type = str(((config.get("clean") or {}).get("target_type")) or "codex")
timeout = int(((config.get("clean") or {}).get("timeout")) or 10)
if base_url and token:
_, available_candidates = get_candidates_count(
base_url=base_url,
token=token,
target_type=target_type,
timeout=timeout,
)
status["available_candidates"] = available_candidates
except Exception as e:
status["available_candidates_error"] = str(e)
status["last_log_path"] = str(latest_log)
raw_lines = tail_lines(latest_log)
status["logs"] = [parse_log_line(index, line) for index, line in enumerate(raw_lines, start=1)]
status["single_account_timing"] = build_single_account_timing(raw_lines, window_size=20)
if status.get("loop_running"):
status["loop_next_check_in_seconds"] = parse_loop_next_check_in_seconds(raw_lines)
round_start_pattern = re.compile(r">>> 循环轮次 #\d+ 开始")
scan_lines = raw_lines
last_round_start_index: Optional[int] = None
for index, line in enumerate(raw_lines):
if round_start_pattern.search(line):
last_round_start_index = index
if last_round_start_index is not None:
scan_lines = raw_lines[last_round_start_index:]
progress_patterns = [
re.compile(r"补号进度: token (?P<success>\d+)/(?P<total>\d+) \| ✅(?P<ok>\d+) ❌(?P<fail>\d+) ⏭️(?P<skip>\d+)"),
re.compile(r"补号完成: token=(?P<success>\d+)/(?P<total>\d+), fail=(?P<fail>\d+), skip=(?P<skip>\d+)"),
]
start_pattern = re.compile(r"开始补号: 目标 token=(?P<total>\d+)")
success = 0
failed = 0
skipped = 0
total = 0
for line in reversed(scan_lines):
for pattern in progress_patterns:
matched = pattern.search(line)
if matched:
success = int(matched.group("success"))
failed = int(matched.group("fail"))
skipped = int(matched.group("skip"))
total = int(matched.group("total"))
break
if total:
break
if total == 0:
for line in reversed(scan_lines):
matched = start_pattern.search(line)
if matched:
total = int(matched.group("total"))
break
completed = success
remaining = max(total - success, 0) if total else 0
percent = int((success / total) * 100) if total else 0
status["completed"] = completed
status["total"] = total
status["percent"] = percent
status["stats"] = [
{"label": "成功", "value": success, "icon": "", "tone": "success"},
{"label": "失败", "value": failed, "icon": "", "tone": "danger"},
{"label": "剩余", "value": remaining, "icon": "", "tone": "pending"},
]
if raw_lines:
last_message = raw_lines[-1]
has_batch_start = "开始补号" in "\n".join(scan_lines)
if status["running"]:
if status.get("loop_running"):
status["phase"] = "looping"
status["message"] = "循环补号运行中"
else:
status["phase"] = "maintaining"
status["message"] = "补号任务运行中" if has_batch_start else "维护任务运行中"
elif "=== 账号池自动维护结束(成功)===" in last_message:
status["phase"] = "completed"
status["message"] = "最近一次维护已完成"
elif "=== 账号池自动维护结束(失败)===" in last_message:
status["phase"] = "failed"
status["message"] = "最近一次维护失败"
elif has_batch_start:
status["message"] = "最近一次维护已停止,日志未写入结束标记"
else:
status["message"] = "已加载最近一次运行日志"
return status
def start_maintainer_process(*, loop_mode: bool = False) -> Dict[str, Any]:
global RUN_PROCESS, RUN_MODE, RUN_LOG_PATH
with RUN_PROCESS_LOCK:
if RUN_PROCESS is not None and RUN_PROCESS.poll() is None:
return {"ok": True, "started": False, "message": "维护任务已在运行中"}
state_pid, state_mode, state_log_path = read_running_state()
if state_pid is not None:
RUN_MODE = state_mode
RUN_LOG_PATH = state_log_path
return {
"ok": True,
"started": False,
"pid": state_pid,
"mode": state_mode,
"message": "维护任务已在运行中",
}
process_env = os.environ.copy()
process_env["APP_DATA_DIR"] = str(APP_DATA_DIR)
process_env["APP_CONFIG_PATH"] = str(CONFIG_PATH)
process_env["APP_LOG_DIR"] = str(LOGS_DIR)
planned_log_path = LOGS_DIR / f"pool_maintainer_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}.log"
process_env["APP_LOG_FILE"] = str(planned_log_path)
command = [sys.executable, str(PROJECT_ROOT / "auto_pool_maintainer.py")]
if loop_mode:
command.append("--loop")
RUN_PROCESS = subprocess.Popen(
command,
cwd=str(APP_DATA_DIR),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
text=True,
env=process_env,
)
time.sleep(0.3)
if RUN_PROCESS.poll() is not None:
exit_code = RUN_PROCESS.returncode
RUN_PROCESS = None
RUN_MODE = ""
RUN_LOG_PATH = ""
clear_run_state()
return {
"ok": False,
"started": False,
"message": f"维护任务启动失败进程已退出code={exit_code}",
}
RUN_MODE = "loop" if loop_mode else "single"
RUN_LOG_PATH = str(planned_log_path)
save_run_state(RUN_PROCESS.pid, RUN_MODE, RUN_LOG_PATH)
return {
"ok": True,
"started": True,
"pid": RUN_PROCESS.pid,
"mode": RUN_MODE,
"message": "已启动循环补号任务" if loop_mode else "已启动维护任务",
}
def stop_maintainer_process() -> Dict[str, Any]:
global RUN_PROCESS, RUN_MODE, RUN_LOG_PATH
with RUN_PROCESS_LOCK:
if RUN_PROCESS is not None and RUN_PROCESS.poll() is None:
target_pid = RUN_PROCESS.pid
try:
RUN_PROCESS.terminate()
try:
RUN_PROCESS.wait(timeout=8)
except subprocess.TimeoutExpired:
RUN_PROCESS.kill()
RUN_PROCESS.wait(timeout=5)
except Exception as e:
return {"ok": False, "stopped": False, "message": f"停止维护任务失败: {e}"}
RUN_PROCESS = None
RUN_MODE = ""
RUN_LOG_PATH = ""
clear_run_state()
return {"ok": True, "stopped": True, "pid": target_pid, "message": "已停止维护任务"}
state_pid, state_mode, state_log_path = read_running_state()
if state_pid is None:
RUN_PROCESS = None
RUN_MODE = ""
RUN_LOG_PATH = ""
clear_run_state()
return {"ok": True, "stopped": False, "message": "当前没有运行中的维护任务"}
target_pid = state_pid
RUN_MODE = state_mode
RUN_LOG_PATH = state_log_path
try:
if not terminate_pid(target_pid, timeout_seconds=8.0):
return {"ok": False, "stopped": False, "message": f"停止维护任务失败: pid={target_pid}"}
except Exception as e:
return {"ok": False, "stopped": False, "message": f"停止维护任务失败: {e}"}
RUN_PROCESS = None
RUN_MODE = ""
RUN_LOG_PATH = ""
clear_run_state()
return {"ok": True, "stopped": True, "pid": target_pid, "message": "已停止维护任务"}
class ApiHandler(BaseHTTPRequestHandler):
server_version = "AutoPoolMaintainerAPI/0.1"
def _send_json(self, payload: Any, status: int = HTTPStatus.OK) -> None:
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.send_header("Cache-Control", "no-store")
origin = self.headers.get("Origin", "")
if origin:
self.send_header("Access-Control-Allow-Origin", origin)
self.send_header("Vary", "Origin")
self.send_header("Access-Control-Allow-Headers", "Content-Type, X-Admin-Token")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.end_headers()
self.wfile.write(data)
def _read_json_body(self) -> Dict[str, Any]:
length = int(self.headers.get("Content-Length", "0") or "0")
raw = self.rfile.read(length) if length > 0 else b"{}"
data = json.loads(raw.decode("utf-8") or "{}")
if not isinstance(data, dict):
raise RuntimeError("请求体必须是 JSON 对象")
return data
def _send_unauthorized(self, message: str = "Unauthorized") -> None:
self._send_json({"error": message}, status=HTTPStatus.UNAUTHORIZED)
def _is_authorized(self) -> bool:
expected = get_admin_token()
incoming = self.headers.get("X-Admin-Token", "").strip()
return incoming == expected
def _require_auth(self) -> bool:
if self.path == "/api/health":
return True
if self._is_authorized():
return True
self._send_unauthorized("Invalid or missing X-Admin-Token")
return False
def do_OPTIONS(self) -> None:
if not self._require_auth():
return
self._send_json({"ok": True})
def do_GET(self) -> None:
if not self._require_auth():
return
if self.path == "/api/config":
self._send_json(mask_sensitive_config(load_config()))
return
if self.path == "/api/runtime/status":
self._send_json(build_runtime_status())
return
if self.path == "/api/health":
self._send_json({"ok": True, "time": datetime.now().isoformat()})
return
self._send_json({"error": "Not Found"}, status=HTTPStatus.NOT_FOUND)
def do_POST(self) -> None:
if not self._require_auth():
return
if self.path == "/api/config":
payload = self._read_json_body()
save_config(payload)
self._send_json(mask_sensitive_config(load_config()))
return
if self.path == "/api/runtime/start":
self._send_json(start_maintainer_process())
return
if self.path == "/api/runtime/start-loop":
self._send_json(start_maintainer_process(loop_mode=True))
return
if self.path == "/api/runtime/stop":
self._send_json(stop_maintainer_process())
return
self._send_json({"error": "Not Found"}, status=HTTPStatus.NOT_FOUND)
def log_message(self, format: str, *args: Any) -> None:
return
def run_server(host: str = API_HOST, port: int = API_PORT) -> None:
ensure_runtime_paths()
admin_token = get_admin_token()
if ADMIN_TOKEN_ENV:
print("Using APP_ADMIN_TOKEN from environment.")
else:
print(f"Generated admin token saved to: {ADMIN_TOKEN_FILE}")
print(f"Generated admin token: {admin_token}")
server = ThreadingHTTPServer((host, port), ApiHandler)
print(f"API server listening on http://{host}:{port}")
server.serve_forever()
if __name__ == "__main__":
run_server()

5413
auto_pool_maintainer.py Normal file

File diff suppressed because it is too large Load Diff

102
config.example.json Normal file
View File

@@ -0,0 +1,102 @@
{
"cfmail": {
"api_base": "",
"api_key": "YOUR_CFMAIL_ADMIN_PASSWORD",
"domain": "",
"domains": [
]
},
"clean": {
"base_url": "https://your-cpa-host.example.com",
"token": "YOUR_CPA_PWD",
"target_type": "codex",
"workers": 20,
"sample_size": 0,
"delete_workers": 20,
"timeout": 10,
"retries": 1,
"user_agent": "codex_cli_rs/0.76.0 (Debian 13.0.0; x86_64) WindowsTerminal",
"used_percent_threshold": 90
},
"mail": {
"provider": "tempmail_lol",
"api_base": "https://your-worker.workers.dev",
"api_key": "YOUR_MAIL_API_KEY",
"domain": "mail.example.com",
"domains": [
"mail.example.com",
"mail-backup.example.com"
],
"otp_timeout_seconds": 120,
"poll_interval_seconds": 3
},
"duckmail": {
"api_base": "https://api.duckmail.sbs",
"bearer": "YOUR_DUCKMAIL_BEARER",
"domain": "duckmail.sbs",
"domains": [
"duckmail.sbs",
"duckmail-backup.example.com"
]
},
"tempmail_lol": {
"api_base": "https://api.tempmail.lol/v2"
},
"yyds_mail": {
"api_base": "https://maliapi.215.im/v1",
"api_key": "YOUR_YYDS_MAIL_API_KEY",
"domain": "",
"domains": [
"mail-a.example.com",
"mail-b.example.com"
]
},
"maintainer": {
"min_candidates": 50,
"loop_interval_seconds": 60
},
"run": {
"workers": 8,
"proxy": "",
"failure_threshold_for_cooldown": 5,
"failure_cooldown_seconds": 45,
"loop_jitter_min_seconds": 2,
"loop_jitter_max_seconds": 6
},
"flow": {
"step_retry_attempts": 2,
"step_retry_delay_base": 0.2,
"step_retry_delay_cap": 0.8,
"outer_retry_attempts": 3,
"oauth_local_retry_attempts": 3,
"transient_markers": "sentinel_,oauth_authorization_code_not_found,headers_failed,timeout,timed out,server disconnected,unexpected_eof_while_reading,transport,remoteprotocolerror,connection reset,temporarily unavailable,network,eof occurred,http_429,http_500,http_502,http_503,http_504",
"register_otp_validate_order": "normal,sentinel",
"oauth_otp_validate_order": "normal,sentinel",
"oauth_password_phone_action": "warn_and_continue",
"oauth_otp_phone_action": "warn_and_continue"
},
"registration": {
"entry_mode": "chatgpt_web",
"entry_mode_fallback": true,
"chatgpt_base": "https://chatgpt.com",
"register_create_account_phone_action": "warn_and_continue",
"phone_verification_markers": "add_phone,/add-phone,phone_verification,phone-verification,phone/verify"
},
"oauth": {
"issuer": "https://auth.openai.com",
"client_id": "app_EMoamEEZ73f0CkXaXp7hrann",
"redirect_uri": "http://localhost:1455/auth/callback",
"retry_attempts": 3,
"retry_backoff_base": 2,
"retry_backoff_max": 15,
"otp_timeout_seconds": 120,
"otp_poll_interval_seconds": 2
},
"output": {
"accounts_file": "accounts.txt",
"csv_file": "registered_accounts.csv",
"ak_file": "ak.txt",
"rk_file": "rk.txt",
"save_local": false
}
}

553
dev_services.ps1 Normal file
View File

@@ -0,0 +1,553 @@
param(
[string]$Action = "",
[string]$Service = "",
[switch]$Background
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$ScriptPath = $MyInvocation.MyCommand.Path
$ProjectRoot = Split-Path -Parent $ScriptPath
$RuntimeDir = Join-Path $ProjectRoot "logs/dev-services"
$PidDir = Join-Path $RuntimeDir "pids"
$Services = @("backend", "frontend")
function Ensure-RuntimeDirectories {
New-Item -ItemType Directory -Force -Path $RuntimeDir | Out-Null
New-Item -ItemType Directory -Force -Path $PidDir | Out-Null
}
function Show-Usage {
@"
:
.\dev_services.ps1 fg Ctrl+C
.\dev_services.ps1 bg
.\dev_services.ps1 stop
.\dev_services.ps1 restart
.\dev_services.ps1 status
:
- : logs/dev-services/
- PID : logs/dev-services/pids/
"@
}
function Get-ServiceTitle {
param([Parameter(Mandatory = $true)][string]$Name)
switch ($Name) {
"backend" { return "backend" }
"frontend" { return "frontend" }
default { return $Name }
}
}
function Get-ServiceLogFile {
param([Parameter(Mandatory = $true)][string]$Name)
Join-Path $RuntimeDir ("{0}.log" -f $Name)
}
function Get-ServicePidFile {
param([Parameter(Mandatory = $true)][string]$Name)
Join-Path $PidDir ("{0}.pid" -f $Name)
}
function Resolve-CommandPath {
param([Parameter(Mandatory = $true)][string[]]$Candidates)
foreach ($candidate in $Candidates) {
if ([System.IO.Path]::IsPathRooted($candidate) -and (Test-Path -LiteralPath $candidate)) {
return $candidate
}
$command = Get-Command -Name $candidate -ErrorAction SilentlyContinue
if ($null -ne $command) {
return $command.Source
}
}
return $null
}
function Get-HostPowerShell {
$currentPath = (Get-Process -Id $PID).Path
if ([string]::IsNullOrWhiteSpace($currentPath)) {
$currentPath = Resolve-CommandPath @("powershell.exe", "pwsh.exe")
}
if ([string]::IsNullOrWhiteSpace($currentPath)) {
throw "找不到当前 PowerShell 可执行文件。"
}
$fileName = [System.IO.Path]::GetFileName($currentPath)
if ($fileName -ieq "pwsh.exe") {
return [pscustomobject]@{
Executable = $currentPath
Arguments = @("-NoProfile", "-File")
}
}
return [pscustomobject]@{
Executable = $currentPath
Arguments = @("-NoProfile", "-ExecutionPolicy", "Bypass", "-File")
}
}
function Get-ServiceDefinition {
param([Parameter(Mandatory = $true)][string]$Name)
$frontendRoot = Join-Path $ProjectRoot "frontend"
switch ($Name) {
"backend" {
$pythonExe = Join-Path $ProjectRoot ".venv\Scripts\python.exe"
return [pscustomobject]@{
Name = $Name
Executable = $pythonExe
Arguments = @("api_server.py")
WorkingDirectory = $ProjectRoot
}
}
"frontend" {
$viteCmd = Join-Path $frontendRoot "node_modules\.bin\vite.cmd"
if (Test-Path -LiteralPath $viteCmd) {
return [pscustomobject]@{
Name = $Name
Executable = $viteCmd
Arguments = @()
WorkingDirectory = $frontendRoot
}
}
$pnpmExe = Resolve-CommandPath @("pnpm.cmd", "pnpm.exe", "pnpm")
if ([string]::IsNullOrWhiteSpace($pnpmExe)) {
throw "缺少前端启动命令: pnpm"
}
return [pscustomobject]@{
Name = $Name
Executable = $pnpmExe
Arguments = @("run", "dev")
WorkingDirectory = $frontendRoot
}
}
default {
throw ("未知服务: {0}" -f $Name)
}
}
}
function Format-ServiceCommand {
param([Parameter(Mandatory = $true)]$Definition)
$parts = @($Definition.Executable) + @($Definition.Arguments)
($parts | ForEach-Object {
if ($_ -match "\s") {
'"{0}"' -f $_
}
else {
$_
}
}) -join " "
}
function Require-Dependencies {
$null = Get-HostPowerShell
$backendPython = Join-Path $ProjectRoot ".venv\Scripts\python.exe"
if (-not (Test-Path -LiteralPath $backendPython)) {
throw ("缺少 Python 解释器: {0}" -f $backendPython)
}
$frontendRoot = Join-Path $ProjectRoot "frontend"
if (-not (Test-Path -LiteralPath $frontendRoot)) {
throw ("缺少前端目录: {0}" -f $frontendRoot)
}
$null = Get-ServiceDefinition "frontend"
}
function Test-PidRunning {
param([Parameter(Mandatory = $true)][int]$ProcessId)
$null -ne (Get-Process -Id $ProcessId -ErrorAction SilentlyContinue)
}
function Get-ServicePid {
param([Parameter(Mandatory = $true)][string]$Name)
$pidFile = Get-ServicePidFile $Name
if (-not (Test-Path -LiteralPath $pidFile)) {
return $null
}
$rawPid = (Get-Content -LiteralPath $pidFile -Raw).Trim()
if ([string]::IsNullOrWhiteSpace($rawPid)) {
return $null
}
try {
return [int]$rawPid
}
catch {
return $null
}
}
function Clear-StalePid {
param([Parameter(Mandatory = $true)][string]$Name)
$pidFile = Get-ServicePidFile $Name
if (-not (Test-Path -LiteralPath $pidFile)) {
return
}
$servicePid = Get-ServicePid $Name
if ($null -eq $servicePid -or -not (Test-PidRunning $servicePid)) {
Remove-Item -LiteralPath $pidFile -Force -ErrorAction SilentlyContinue
}
}
function Test-ServiceRunning {
param([Parameter(Mandatory = $true)][string]$Name)
$servicePid = Get-ServicePid $Name
if ($null -eq $servicePid) {
return $false
}
Test-PidRunning $servicePid
}
function Stop-ProcessTree {
param([Parameter(Mandatory = $true)][int]$ProcessId)
if (-not (Test-PidRunning $ProcessId)) {
return
}
& taskkill /PID $ProcessId /T /F | Out-Null
}
function Stop-ServiceProcess {
param([Parameter(Mandatory = $true)][string]$Name)
Clear-StalePid $Name
$servicePid = Get-ServicePid $Name
if ($null -eq $servicePid) {
return
}
if (-not (Test-PidRunning $servicePid)) {
Remove-Item -LiteralPath (Get-ServicePidFile $Name) -Force -ErrorAction SilentlyContinue
return
}
Write-Host ("停止 {0,-12} pid={1}" -f (Get-ServiceTitle $Name), $servicePid)
Stop-ProcessTree $servicePid
$deadline = (Get-Date).AddSeconds(10)
while ((Get-Date) -lt $deadline) {
if (-not (Test-PidRunning $servicePid)) {
break
}
Start-Sleep -Milliseconds 250
}
Remove-Item -LiteralPath (Get-ServicePidFile $Name) -Force -ErrorAction SilentlyContinue
}
function Ensure-NoManagedServicesRunning {
$busy = $false
foreach ($service in $Services) {
Clear-StalePid $service
if (Test-ServiceRunning $service) {
$servicePid = Get-ServicePid $service
Write-Error ("{0,-12} 已在运行 pid={1},请先执行 .\dev_services.ps1 stop" -f (Get-ServiceTitle $service), $servicePid)
$busy = $true
}
}
if ($busy) {
throw "已有托管服务正在运行。"
}
}
function Write-LogHeader {
param(
[Parameter(Mandatory = $true)][string]$Name,
[Parameter(Mandatory = $true)]$Definition
)
$logFile = Get-ServiceLogFile $Name
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
Add-Content -Path $logFile -Value "" -Encoding UTF8
Add-Content -Path $logFile -Value ("[{0}] starting {1}" -f $timestamp, (Get-ServiceTitle $Name)) -Encoding UTF8
Add-Content -Path $logFile -Value ("[{0}] command: {1}" -f $timestamp, (Format-ServiceCommand $Definition)) -Encoding UTF8
}
function Convert-CommandOutput {
param($Value)
if ($null -eq $Value) {
return $null
}
if ($Value -is [System.Management.Automation.ErrorRecord]) {
return $Value.ToString()
}
return [string]$Value
}
function Run-ServiceProcess {
param(
[Parameter(Mandatory = $true)][string]$Name,
[switch]$BackgroundMode
)
Ensure-RuntimeDirectories
$definition = Get-ServiceDefinition $Name
$logFile = Get-ServiceLogFile $Name
if (-not $BackgroundMode) {
Set-Content -Path $logFile -Value $null -Encoding UTF8
}
Write-LogHeader -Name $Name -Definition $definition
Set-Location -LiteralPath $definition.WorkingDirectory
try {
& $definition.Executable @($definition.Arguments) 2>&1 |
ForEach-Object {
$line = Convert-CommandOutput $_
if ($null -eq $line) {
return
}
Add-Content -Path $logFile -Value $line -Encoding UTF8
if (-not $BackgroundMode) {
Write-Host ("[{0}] {1}" -f (Get-ServiceTitle $Name), $line)
}
}
if ($null -ne $LASTEXITCODE) {
exit ([int]$LASTEXITCODE)
}
exit 0
}
catch {
$message = $_.Exception.Message
Add-Content -Path $logFile -Value $message -Encoding UTF8
if (-not $BackgroundMode) {
Write-Host ("[{0}] {1}" -f (Get-ServiceTitle $Name), $message)
}
exit 1
}
}
function Start-ServiceBackground {
param([Parameter(Mandatory = $true)][string]$Name)
$hostPowerShell = Get-HostPowerShell
$logFile = Get-ServiceLogFile $Name
Set-Content -Path $logFile -Value $null -Encoding UTF8
$arguments = @($hostPowerShell.Arguments + @($ScriptPath, "__runservice", $Name, "-Background"))
$process = Start-Process -FilePath $hostPowerShell.Executable -ArgumentList $arguments -WorkingDirectory $ProjectRoot -WindowStyle Hidden -PassThru
Set-Content -Path (Get-ServicePidFile $Name) -Value $process.Id -Encoding ASCII
Start-Sleep -Seconds 1
if (Test-PidRunning $process.Id) {
Write-Host ("启动 {0,-12} 成功 pid={1} log={2}" -f (Get-ServiceTitle $Name), $process.Id, $logFile)
return
}
Write-Error ("启动 {0} 失败,最近日志:" -f (Get-ServiceTitle $Name))
if (Test-Path -LiteralPath $logFile) {
Get-Content -Path $logFile -Tail 20 | ForEach-Object { Write-Host $_ }
}
Remove-Item -LiteralPath (Get-ServicePidFile $Name) -Force -ErrorAction SilentlyContinue
throw ("启动 {0} 失败。" -f (Get-ServiceTitle $Name))
}
function Start-Background {
Ensure-RuntimeDirectories
Require-Dependencies
Ensure-NoManagedServicesRunning
$started = @()
try {
foreach ($service in $Services) {
Start-ServiceBackground $service
$started += $service
}
}
catch {
foreach ($startedService in $started) {
Stop-ServiceProcess $startedService
}
throw
}
Write-Host ""
Write-Host "后台服务已启动。"
Write-Host "停止命令: .\dev_services.ps1 stop"
Write-Host "状态命令: .\dev_services.ps1 status"
}
function Show-Status {
Ensure-RuntimeDirectories
foreach ($service in $Services) {
Clear-StalePid $service
$title = Get-ServiceTitle $service
$logFile = Get-ServiceLogFile $service
if (Test-ServiceRunning $service) {
$servicePid = Get-ServicePid $service
Write-Host ("{0,-12} running pid={1,-8} log={2}" -f $title, $servicePid, $logFile)
}
else {
Write-Host ("{0,-12} stopped pid={1,-8} log={2}" -f $title, "-", $logFile)
}
}
}
function Stop-Background {
Ensure-RuntimeDirectories
foreach ($service in $Services) {
Stop-ServiceProcess $service
}
}
function Stop-ForegroundProcesses {
param([Parameter(Mandatory = $true)][object[]]$ManagedProcesses)
if ($ManagedProcesses.Count -eq 0) {
return
}
Write-Host ""
Write-Host "正在关闭前台服务..."
foreach ($managed in $ManagedProcesses) {
$process = $managed.Process
if ($null -eq $process) {
continue
}
$process.Refresh()
if ($process.HasExited) {
continue
}
Stop-ProcessTree $process.Id
}
}
function Start-ForegroundService {
param([Parameter(Mandatory = $true)][string]$Name)
$hostPowerShell = Get-HostPowerShell
$arguments = @($hostPowerShell.Arguments + @($ScriptPath, "__runservice", $Name))
Write-Host ("启动 {0,-12} 前台模式" -f (Get-ServiceTitle $Name))
$process = Start-Process -FilePath $hostPowerShell.Executable -ArgumentList $arguments -WorkingDirectory $ProjectRoot -NoNewWindow -PassThru
[pscustomobject]@{
Service = $Name
Process = $process
}
}
function Start-Foreground {
Ensure-RuntimeDirectories
Require-Dependencies
Ensure-NoManagedServicesRunning
$managedProcesses = @()
try {
foreach ($service in $Services) {
$managedProcesses += Start-ForegroundService $service
}
Write-Host ""
Write-Host "两个服务已进入前台托管模式。按 Ctrl+C 可一键关闭。"
while ($true) {
foreach ($managed in $managedProcesses) {
$process = $managed.Process
$process.Refresh()
if (-not $process.HasExited) {
continue
}
$exitCode = $process.ExitCode
Write-Host ""
Write-Host ("{0} 已退出,退出码={1},其余服务也会一并关闭。" -f (Get-ServiceTitle $managed.Service), $exitCode)
return $exitCode
}
Start-Sleep -Seconds 1
}
}
finally {
Stop-ForegroundProcesses $managedProcesses
}
}
Ensure-RuntimeDirectories
switch ($Action) {
"__runservice" {
Run-ServiceProcess -Name $Service -BackgroundMode:$Background
break
}
"fg" {
exit (Start-Foreground)
}
"bg" {
Start-Background
break
}
"stop" {
Stop-Background
break
}
"restart" {
Stop-Background
Start-Background
break
}
"status" {
Show-Status
break
}
"help" {
Show-Usage
break
}
"" {
Show-Usage
break
}
default {
Write-Error ("未知命令: {0}" -f $Action)
Write-Host ""
Show-Usage
exit 1
}
}

398
dev_services.sh Normal file
View File

@@ -0,0 +1,398 @@
#!/usr/bin/env bash
set -u
set -o pipefail
PROJECT_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
RUNTIME_DIR="$PROJECT_ROOT/logs/dev-services"
PID_DIR="$RUNTIME_DIR/pids"
SERVICES=(backend frontend)
FG_PIDS=()
FG_NAMES=()
CLEANED_UP=0
mkdir -p "$PID_DIR"
usage() {
cat <<'EOF'
用法:
./dev_services.sh fg 前台启动两个服务,按 Ctrl+C 一键关闭
./dev_services.sh bg 后台启动两个服务
./dev_services.sh stop 停止由本脚本后台启动的两个服务
./dev_services.sh restart 重启后台服务
./dev_services.sh status 查看后台服务状态
说明:
- 后台模式日志目录: logs/dev-services/
- 后台模式 PID 目录: logs/dev-services/pids/
EOF
}
service_title() {
case "$1" in
backend) printf '%s' "backend" ;;
frontend) printf '%s' "frontend" ;;
*) printf '%s' "$1" ;;
esac
}
service_log_file() {
printf '%s/%s.log' "$RUNTIME_DIR" "$1"
}
service_pid_file() {
printf '%s/%s.pid' "$PID_DIR" "$1"
}
service_command() {
local service="$1"
local cmd=""
case "$service" in
backend)
printf -v cmd 'cd %q && exec %q api_server.py' "$PROJECT_ROOT" "$PROJECT_ROOT/.venv/bin/python"
;;
frontend)
if [[ -x "$PROJECT_ROOT/frontend/node_modules/.bin/vite" ]]; then
printf -v cmd 'cd %q && exec %q' "$PROJECT_ROOT/frontend" "$PROJECT_ROOT/frontend/node_modules/.bin/vite"
else
printf -v cmd 'cd %q && exec pnpm run dev' "$PROJECT_ROOT/frontend"
fi
;;
*)
echo "未知服务: $service" >&2
return 1
;;
esac
printf '%s' "$cmd"
}
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "缺少命令: $1" >&2
exit 1
fi
}
check_dependencies() {
require_command bash
if [[ ! -x "$PROJECT_ROOT/.venv/bin/python" ]]; then
echo "缺少 Python 解释器: $PROJECT_ROOT/.venv/bin/python" >&2
exit 1
fi
if [[ ! -d "$PROJECT_ROOT/frontend" ]]; then
echo "缺少前端目录: $PROJECT_ROOT/frontend" >&2
exit 1
fi
if [[ ! -x "$PROJECT_ROOT/frontend/node_modules/.bin/vite" ]]; then
require_command pnpm
fi
}
is_pid_running() {
local pid="$1"
[[ "$pid" =~ ^[0-9]+$ ]] || return 1
kill -0 "$pid" 2>/dev/null
}
service_pid() {
local pid_file
pid_file="$(service_pid_file "$1")"
[[ -f "$pid_file" ]] || return 1
local pid
pid="$(tr -d '[:space:]' <"$pid_file")"
[[ -n "$pid" ]] || return 1
printf '%s' "$pid"
}
service_running() {
local pid
pid="$(service_pid "$1")" || return 1
is_pid_running "$pid"
}
clear_stale_pid() {
local service="$1"
local pid_file
pid_file="$(service_pid_file "$service")"
if [[ ! -f "$pid_file" ]]; then
return 0
fi
local pid
pid="$(tr -d '[:space:]' <"$pid_file")"
if ! is_pid_running "$pid"; then
rm -f "$pid_file"
fi
}
kill_pid_group() {
local pid="$1"
kill -TERM -- "-$pid" 2>/dev/null || kill -TERM "$pid" 2>/dev/null || true
}
force_kill_pid_group() {
local pid="$1"
kill -KILL -- "-$pid" 2>/dev/null || kill -KILL "$pid" 2>/dev/null || true
}
stop_service() {
local service="$1"
clear_stale_pid "$service"
local pid
pid="$(service_pid "$service")" || return 0
if ! is_pid_running "$pid"; then
rm -f "$(service_pid_file "$service")"
return 0
fi
printf '停止 %-12s pid=%s\n' "$(service_title "$service")" "$pid"
kill_pid_group "$pid"
local i
for i in $(seq 1 20); do
if ! is_pid_running "$pid"; then
rm -f "$(service_pid_file "$service")"
return 0
fi
sleep 0.5
done
force_kill_pid_group "$pid"
rm -f "$(service_pid_file "$service")"
}
ensure_no_managed_services_running() {
local busy=0
local service
for service in "${SERVICES[@]}"; do
clear_stale_pid "$service"
if service_running "$service"; then
local pid
pid="$(service_pid "$service")"
printf '%-12s 已在运行 pid=%s请先执行 ./dev_services.sh stop\n' "$(service_title "$service")" "$pid" >&2
busy=1
fi
done
if (( busy != 0 )); then
exit 1
fi
}
start_service_background() {
local service="$1"
local cmd
cmd="$(service_command "$service")"
local log_file pid_file
log_file="$(service_log_file "$service")"
pid_file="$(service_pid_file "$service")"
{
printf '\n[%s] starting %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$(service_title "$service")"
printf '[%s] command: %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$cmd"
} >>"$log_file"
if command -v setsid >/dev/null 2>&1; then
setsid bash -lc "$cmd" >>"$log_file" 2>&1 < /dev/null &
else
bash -lc "$cmd" >>"$log_file" 2>&1 < /dev/null &
fi
local pid=$!
printf '%s\n' "$pid" >"$pid_file"
sleep 1
if is_pid_running "$pid"; then
printf '启动 %-12s 成功 pid=%s log=%s\n' "$(service_title "$service")" "$pid" "$log_file"
return 0
fi
echo "启动 $(service_title "$service") 失败,最近日志:" >&2
tail -n 20 "$log_file" >&2 || true
rm -f "$pid_file"
return 1
}
start_background() {
check_dependencies
ensure_no_managed_services_running
local started=()
local service
for service in "${SERVICES[@]}"; do
if start_service_background "$service"; then
started+=("$service")
else
local started_service
for started_service in "${started[@]}"; do
stop_service "$started_service"
done
exit 1
fi
done
echo
echo "后台服务已启动。"
echo "停止命令: ./dev_services.sh stop"
echo "状态命令: ./dev_services.sh status"
}
show_status() {
local service
for service in "${SERVICES[@]}"; do
clear_stale_pid "$service"
local title pid_file log_file
title="$(service_title "$service")"
pid_file="$(service_pid_file "$service")"
log_file="$(service_log_file "$service")"
if service_running "$service"; then
local pid
pid="$(service_pid "$service")"
printf '%-12s running pid=%-8s log=%s\n' "$title" "$pid" "$log_file"
else
printf '%-12s stopped pid=%-8s log=%s\n' "$title" "-" "$log_file"
fi
done
}
stop_background() {
local service
for service in "${SERVICES[@]}"; do
stop_service "$service"
done
}
cleanup_foreground() {
if (( CLEANED_UP != 0 )); then
return 0
fi
CLEANED_UP=1
if (( ${#FG_PIDS[@]} == 0 )); then
return 0
fi
echo
echo "正在关闭前台服务..."
local pid
for pid in "${FG_PIDS[@]}"; do
kill -TERM "$pid" 2>/dev/null || true
done
sleep 1
for pid in "${FG_PIDS[@]}"; do
if is_pid_running "$pid"; then
kill -KILL "$pid" 2>/dev/null || true
fi
done
wait "${FG_PIDS[@]}" 2>/dev/null || true
}
on_foreground_interrupt() {
echo
echo "收到中断信号,准备关闭两个服务..."
cleanup_foreground
exit 130
}
start_service_foreground() {
local service="$1"
local cmd
cmd="$(service_command "$service")"
local log_file
log_file="$(service_log_file "$service")"
: >"$log_file"
printf '启动 %-12s 前台模式\n' "$(service_title "$service")"
bash -lc "$cmd" > >(tee -a "$log_file" | sed -u "s/^/[$(service_title "$service")] /") 2>&1 &
FG_PIDS+=("$!")
FG_NAMES+=("$service")
}
monitor_foreground() {
while true; do
local idx
for idx in "${!FG_PIDS[@]}"; do
local pid service
pid="${FG_PIDS[$idx]}"
service="${FG_NAMES[$idx]}"
if ! is_pid_running "$pid"; then
wait "$pid"
local status=$?
echo
echo "$(service_title "$service") 已退出,退出码=$status,其余服务也会一并关闭。"
return "$status"
fi
done
sleep 1
done
}
start_foreground() {
check_dependencies
ensure_no_managed_services_running
trap on_foreground_interrupt INT TERM
trap cleanup_foreground EXIT
local service
for service in "${SERVICES[@]}"; do
start_service_foreground "$service"
done
echo
echo "两个服务已进入前台托管模式。按 Ctrl+C 可一键关闭。"
monitor_foreground
}
main() {
local action="${1:-}"
case "$action" in
fg)
start_foreground
;;
bg)
start_background
;;
stop)
stop_background
;;
restart)
stop_background
start_background
;;
status)
show_status
;;
-h|--help|help|"")
usage
;;
*)
echo "未知命令: $action" >&2
echo >&2
usage >&2
exit 1
;;
esac
}
main "$@"

35
docker-compose.yml Normal file
View File

@@ -0,0 +1,35 @@
services:
backend:
build:
context: .
dockerfile: Dockerfile.backend
container_name: auto-pool-maintainer-backend
restart: unless-stopped
environment:
APP_DATA_DIR: /app/data
APP_CONFIG_PATH: /app/data/config.json
APP_LOG_DIR: /app/data/logs
APP_TEMPLATE_CONFIG_PATH: /app/config.example.json
APP_HOST: 0.0.0.0
APP_PORT: "8318"
APP_ADMIN_TOKEN: ${APP_ADMIN_TOKEN:-}
volumes:
- ./docker-data/backend:/app/data
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8318/api/health', timeout=3)"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: auto-pool-maintainer-frontend
restart: unless-stopped
depends_on:
backend:
condition: service_healthy
ports:
- "8080:80"

BIN
frontend/.DS_Store vendored Normal file

Binary file not shown.

18
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

65
frontend/README.md Normal file
View File

@@ -0,0 +1,65 @@
# Frontend Scaffold
轻量前端脚手架,技术选型:
- `Vite`
- `Preact`
- `TypeScript`
- 手写 `CSS`
## 启动
```bash
cd /home/zhang/workspace/script/auto_pool_maintainer_duckMail
python3 api_server.py
```
另一个终端:
```bash
cd frontend
npm install
npm run dev
```
生产构建:
```bash
cd frontend
npm run build
```
## 一键管理三端
项目根目录提供了统一脚本:
```bash
./dev_services.sh fg
```
前台托管三个服务,按 `Ctrl+C` 会一起关闭。
```bash
./dev_services.sh bg
./dev_services.sh status
./dev_services.sh stop
```
后台启动、查看状态、停止服务都可以直接用上面三条命令。
后台日志和 PID 会写到 `logs/dev-services/`
## 结构
- `src/app.tsx`: 页面入口
- `src/components/`: 配置面板、监控台、日志终端、账号表格
- `src/mock/data.ts`: 当前假数据
- `src/services/`: 后续接后端 API / SSE 的位置
- `src/types/`: 前端状态类型定义
- `src/styles/`: 设计变量和页面样式
## 当前状态
- 已将现有静态 UI 迁为前端组件结构
- `config` 已接到真实后端接口:`GET/POST /api/config`
- `runtime status` 已接到真实后端接口:`GET /api/runtime/status`
- 账号表格仍使用本地假数据

BIN
frontend/dist/.DS_Store vendored Normal file

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

13
frontend/dist/index.html vendored Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Auto Pool Maintainer</title>
<script type="module" crossorigin src="/assets/index-DPSNYdMF.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CraRoSIX.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Auto Pool Maintainer</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

20
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,20 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://backend:8318;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
}

19
frontend/package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "auto-pool-maintainer-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"preact": "^10.26.4"
},
"devDependencies": {
"@preact/preset-vite": "^2.10.2",
"typescript": "^5.8.2",
"vite": "^6.2.0"
}
}

1291
frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
allowBuilds:
esbuild: true

BIN
frontend/src/.DS_Store vendored Normal file

Binary file not shown.

284
frontend/src/app.tsx Normal file
View File

@@ -0,0 +1,284 @@
import { useEffect, useState } from "preact/hooks";
import { ConfigPanel } from "./components/config-panel";
import { LoginGate } from "./components/login-gate";
import { MonitorPanel } from "./components/monitor-panel";
import { initialConfigSections, initialMonitorState } from "./mock/data";
import {
clearAuthToken,
fetchConfig,
fetchMonitorState,
getStoredAuth,
isAuthError,
saveConfig,
startRuntime,
startRuntimeLoop,
stopRuntime,
storeAuthToken,
verifyAuthToken,
} from "./services/api";
import type { ConfigSection, MonitorState } from "./types/runtime";
export function App() {
const [sections, setSections] = useState<ConfigSection[]>(initialConfigSections);
const [monitor, setMonitor] = useState<MonitorState>(initialMonitorState);
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState<string>("");
const [authenticated, setAuthenticated] = useState<boolean>(Boolean(getStoredAuth().token));
const [authError, setAuthError] = useState("");
const [hasStoredToken, setHasStoredToken] = useState<boolean>(Boolean(getStoredAuth().token));
const refreshMonitor = async () => {
const nextMonitor = await fetchMonitorState();
setMonitor(nextMonitor);
};
useEffect(() => {
let active = true;
fetchConfig()
.then((nextSections) => {
if (active) {
setSections(nextSections);
setAuthenticated(true);
}
})
.catch((error) => {
if (active) {
if (isAuthError(error)) {
clearAuthToken();
setHasStoredToken(false);
setAuthError("登录已失效,请重新输入管理令牌");
}
setAuthenticated(false);
}
});
refreshMonitor()
.then(() => {
if (active) {
setNotice("");
}
})
.catch((error) => {
if (active && isAuthError(error)) {
clearAuthToken();
setHasStoredToken(false);
setAuthenticated(false);
setAuthError("登录已失效,请重新输入管理令牌");
}
});
return () => {
active = false;
};
}, []);
useEffect(() => {
if (!authenticated) {
return;
}
let active = true;
const timer = window.setInterval(() => {
refreshMonitor()
.then(() => {
if (active) {
setNotice((current) => current);
}
})
.catch((error) => {
if (active && isAuthError(error)) {
clearAuthToken();
setHasStoredToken(false);
setAuthenticated(false);
setAuthError("登录已失效,请重新输入管理令牌");
}
});
}, 5000);
return () => {
active = false;
window.clearInterval(timer);
};
}, [authenticated]);
const handleLogin = async (token: string) => {
setBusy(true);
setAuthError("");
try {
await verifyAuthToken(token);
storeAuthToken(token);
setAuthenticated(true);
setHasStoredToken(true);
setNotice("登录成功");
const [nextSections, nextMonitor] = await Promise.all([fetchConfig(), fetchMonitorState()]);
setSections(nextSections);
setMonitor(nextMonitor);
} catch (error) {
console.error("登录失败", error);
clearAuthToken();
setAuthenticated(false);
setHasStoredToken(false);
setAuthError("管理令牌无效或服务暂不可用");
} finally {
setBusy(false);
}
};
const handleLogout = () => {
clearAuthToken();
setAuthenticated(false);
setHasStoredToken(false);
setNotice("已退出登录");
setAuthError("");
};
const updateFieldValue = (sectionKey: string, fieldKey: string, nextValue: string | number | boolean) => {
setSections((current) =>
current.map((section) => {
if (section.key !== sectionKey) {
return section;
}
return {
...section,
fields: section.fields.map((field) =>
field.key === fieldKey ? { ...field, value: nextValue } : field,
),
};
}),
);
};
const handleClearLogs = () => {
setMonitor((current) => ({
...current,
logs: [
{
id: "cleared",
prefix: "[系统] [00:00:00]",
timestamp: "[00:00:00]",
message: "日志已清空,等待任务输出...",
tone: "muted",
},
],
}));
};
const handleSaveConfig = async () => {
setBusy(true);
try {
const savedSections = await saveConfig(sections);
setSections(savedSections);
setNotice("配置已保存");
} catch (error) {
console.error("保存配置失败", error);
if (isAuthError(error)) {
clearAuthToken();
setHasStoredToken(false);
setAuthenticated(false);
setAuthError("登录已失效,请重新输入管理令牌");
} else {
setNotice("保存配置失败");
}
} finally {
setBusy(false);
}
};
const handleStartRuntime = async () => {
setBusy(true);
try {
const savedSections = await saveConfig(sections);
setSections(savedSections);
const result = await startRuntime();
setNotice(`配置已保存,${result.message}`);
await refreshMonitor();
} catch (error) {
console.error("保存配置或启动维护任务失败", error);
if (isAuthError(error)) {
clearAuthToken();
setHasStoredToken(false);
setAuthenticated(false);
setAuthError("登录已失效,请重新输入管理令牌");
} else {
setNotice("保存配置或启动维护任务失败");
}
} finally {
setBusy(false);
}
};
const handleStopRuntime = async () => {
setBusy(true);
try {
const result = await stopRuntime();
setNotice(result.message);
await refreshMonitor();
} catch (error) {
console.error("停止维护任务失败", error);
if (isAuthError(error)) {
clearAuthToken();
setHasStoredToken(false);
setAuthenticated(false);
setAuthError("登录已失效,请重新输入管理令牌");
} else {
setNotice("停止维护任务失败");
}
} finally {
setBusy(false);
}
};
const handleStartRuntimeLoop = async () => {
setBusy(true);
try {
const savedSections = await saveConfig(sections);
setSections(savedSections);
const result = await startRuntimeLoop();
setNotice(`配置已保存,${result.message}`);
await refreshMonitor();
} catch (error) {
console.error("保存配置或启动循环补号任务失败", error);
if (isAuthError(error)) {
clearAuthToken();
setHasStoredToken(false);
setAuthenticated(false);
setAuthError("登录已失效,请重新输入管理令牌");
} else {
setNotice("保存配置或启动循环补号任务失败");
}
} finally {
setBusy(false);
}
};
return (
<div class="page-shell">
{!authenticated ? <LoginGate busy={busy} error={authError} onSubmit={handleLogin} /> : null}
{authenticated ? (
<>
{notice ? <div class="page-notice">{notice}</div> : null}
<div class="page-grid">
<ConfigPanel
sections={sections}
onValueChange={updateFieldValue}
onSave={handleSaveConfig}
onStart={handleStartRuntime}
onStartLoop={handleStartRuntimeLoop}
onStop={handleStopRuntime}
onLogout={handleLogout}
busy={busy}
running={monitor.running}
loopRunning={Boolean(monitor.loopRunning)}
hasStoredToken={hasStoredToken}
/>
<div class="main-stack">
<MonitorPanel monitor={monitor} onClearLogs={handleClearLogs} />
</div>
</div>
</>
) : null}
</div>
);
}

View File

@@ -0,0 +1,322 @@
import { useEffect, useState } from "preact/hooks";
import type { ConfigField, ConfigSection } from "../types/runtime";
type ConfigPanelProps = {
sections: ConfigSection[];
onValueChange: (sectionKey: string, fieldKey: string, nextValue: string | number | boolean) => void;
onSave: () => void;
onStart: () => void;
onStartLoop: () => void;
onStop: () => void;
onLogout: () => void;
busy?: boolean;
running?: boolean;
loopRunning?: boolean;
hasStoredToken?: boolean;
};
type ConfigCategory = "common" | "mail" | "advanced";
function FieldControl(props: {
sectionKey: string;
field: ConfigField;
onValueChange: ConfigPanelProps["onValueChange"];
}) {
const { sectionKey, field, onValueChange } = props;
if (field.type === "select") {
return (
<select
value={String(field.value)}
onInput={(event) =>
onValueChange(sectionKey, field.key, (event.currentTarget as HTMLSelectElement).value)
}
>
{(field.options ?? []).map((option) => (
<option value={option.value} key={option.value}>
{option.label}
</option>
))}
</select>
);
}
if (field.type === "checkbox") {
return (
<label class="check-row">
<input
type="checkbox"
checked={Boolean(field.value)}
onInput={(event) =>
onValueChange(sectionKey, field.key, (event.currentTarget as HTMLInputElement).checked)
}
/>
<span>
{field.label} <span class="field-key">{field.key}</span>
</span>
</label>
);
}
if (field.type === "textarea") {
return (
<textarea
value={String(field.value)}
onInput={(event) =>
onValueChange(sectionKey, field.key, (event.currentTarget as HTMLTextAreaElement).value)
}
/>
);
}
return (
<input
type={field.type}
value={String(field.value)}
placeholder={field.sensitive && String(field.value) === "__MASKED__" ? "已保存,留空或保持不变将沿用原值" : ""}
onInput={(event) => {
const target = event.currentTarget as HTMLInputElement;
const nextValue = field.type === "number" ? Number(target.value) : target.value;
onValueChange(sectionKey, field.key, nextValue);
}}
/>
);
}
export function ConfigPanel(props: ConfigPanelProps) {
const {
sections,
onValueChange,
onSave,
onStart,
onStartLoop,
onStop,
onLogout,
busy = false,
running = false,
loopRunning = false,
hasStoredToken = false,
} = props;
const [activeCategory, setActiveCategory] = useState<ConfigCategory>("common");
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
priority: true,
clean: false,
mail: true,
cfmail: false,
self_hosted_mail_api: false,
duckmail: false,
tempmail_lol: false,
yyds_mail: false,
run: false,
registration: false,
flow: false,
oauth: false,
output: false,
});
const sectionCategoryMap: Record<string, ConfigCategory> = {
priority: "common",
clean: "common",
mail: "mail",
cfmail: "mail",
self_hosted_mail_api: "mail",
duckmail: "mail",
tempmail_lol: "mail",
yyds_mail: "mail",
run: "advanced",
registration: "advanced",
flow: "advanced",
oauth: "advanced",
output: "advanced",
};
const categoryLabelMap: Record<ConfigCategory, string> = {
common: "常用",
mail: "邮箱",
advanced: "高级",
};
const selectedProvider =
sections.find((section) => section.key === "mail")?.fields.find((field) => field.key === "provider")?.value ??
"self_hosted_mail_api";
const providerLabelMap: Record<string, string> = {
cfmail: "CF Mail",
self_hosted_mail_api: "自建 Mail API",
duckmail: "DuckMail",
tempmail_lol: "TempMail.lol",
yyds_mail: "YYDS Mail",
};
const visibleSections = sections.filter((section) => {
if (sectionCategoryMap[section.key] !== activeCategory) {
return false;
}
if (section.key === "self_hosted_mail_api") {
return selectedProvider === "self_hosted_mail_api";
}
if (section.key === "cfmail") {
return selectedProvider === "cfmail";
}
if (section.key === "duckmail") {
return selectedProvider === "duckmail";
}
if (section.key === "tempmail_lol") {
return selectedProvider === "tempmail_lol";
}
if (section.key === "yyds_mail") {
return selectedProvider === "yyds_mail";
}
return true;
});
const summaryItems = [
{
label: "当前邮箱",
value: providerLabelMap[String(selectedProvider)] ?? String(selectedProvider),
},
{
label: "维护目标",
value: String(
sections.find((section) => section.key === "priority")?.fields.find((field) => field.key === "min_candidates")
?.value ??
"",
),
},
{
label: "补号并发",
value: String(
sections.find((section) => section.key === "run")?.fields.find((field) => field.key === "workers")?.value ?? "",
),
},
];
const toggleSection = (sectionKey: string) => {
setExpandedSections((current) => ({
...current,
[sectionKey]: !(current[sectionKey] ?? false),
}));
};
useEffect(() => {
if (
selectedProvider === "cfmail" ||
selectedProvider === "self_hosted_mail_api" ||
selectedProvider === "duckmail" ||
selectedProvider === "tempmail_lol" ||
selectedProvider === "yyds_mail"
) {
setExpandedSections((current) => ({
...current,
[String(selectedProvider)]: true,
}));
}
}, [selectedProvider]);
useEffect(() => {
if (activeCategory === "mail") {
return;
}
if (
selectedProvider === "cfmail" ||
selectedProvider === "self_hosted_mail_api" ||
selectedProvider === "duckmail" ||
selectedProvider === "tempmail_lol" ||
selectedProvider === "yyds_mail"
) {
setExpandedSections((current) => ({
...current,
[String(selectedProvider)]: true,
}));
}
}, [activeCategory, selectedProvider]);
return (
<aside class="card settings-card">
<div class="card-head">
<div class="card-title">
<span class="title-icon">📝</span>
<span></span>
</div>
{hasStoredToken ? (
<button class="link-button" type="button" onClick={onLogout}>
退
</button>
) : null}
</div>
<div class="settings-body">
<div class="settings-summary">
{summaryItems.map((item) => (
<div class="summary-chip" key={item.label}>
<span class="summary-label">{item.label}</span>
<span class="summary-value">{item.value}</span>
</div>
))}
</div>
<div class="settings-tabs">
{(Object.keys(categoryLabelMap) as ConfigCategory[]).map((category) => (
<button
key={category}
type="button"
class={`settings-tab${activeCategory === category ? " active" : ""}`}
onClick={() => setActiveCategory(category)}
>
{categoryLabelMap[category]}
</button>
))}
</div>
{visibleSections.map((section) => {
const isExpanded = expandedSections[section.key] ?? false;
return (
<section class={`config-group${isExpanded ? " expanded" : ""}`} key={section.key}>
<button class="group-toggle" type="button" onClick={() => toggleSection(section.key)}>
<span class="group-title">
{section.label}
<span class="group-key">{section.key}</span>
</span>
<span class={`group-caret${isExpanded ? " open" : ""}`}></span>
</button>
{isExpanded ? (
<div class="group-content field-row single-col">
{section.fields.map((field) => (
<label class={`field${field.type === "checkbox" ? " checkbox-group compact" : ""}`} key={field.key}>
{field.type !== "checkbox" ? (
<span class="field-label">
{field.label}
<span class="field-key">{field.key}</span>
</span>
) : null}
<FieldControl sectionKey={section.key} field={field} onValueChange={onValueChange} />
{field.hint ? <span class="field-hint">{field.hint}</span> : null}
</label>
))}
</div>
) : null}
</section>
);
})}
<div class="settings-actions">
<button class="button primary" type="button" onClick={onStart} disabled={busy || running}>
</button>
<button class="button primary" type="button" onClick={onStartLoop} disabled={busy || running}>
{loopRunning ? "循环补号运行中" : "循环补号"}
</button>
<button class="button warning" type="button" onClick={onStop} disabled={busy || !running}>
</button>
<button class="button secondary" type="button" onClick={onSave} disabled={busy}>
</button>
</div>
</div>
</aside>
);
}

View File

@@ -0,0 +1,39 @@
import { useState } from "preact/hooks";
type LoginGateProps = {
busy?: boolean;
error?: string;
onSubmit: (token: string) => Promise<void>;
};
export function LoginGate(props: LoginGateProps) {
const { busy = false, error = "", onSubmit } = props;
const [token, setToken] = useState("");
const handleSubmit = async (event: Event) => {
event.preventDefault();
await onSubmit(token.trim());
};
return (
<div class="login-shell">
<form class="login-card" onSubmit={handleSubmit}>
<div class="login-title"></div>
<div class="login-subtitle">访</div>
<label class="field">
<span class="field-label">Admin Token</span>
<input
type="password"
value={token}
onInput={(event) => setToken((event.currentTarget as HTMLInputElement).value)}
placeholder="请输入 X-Admin-Token"
/>
</label>
{error ? <div class="login-error">{error}</div> : null}
<button class="button primary login-button" type="submit" disabled={busy || !token.trim()}>
{busy ? "验证中..." : "进入控制台"}
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,110 @@
import { TerminalLog } from "./terminal-log";
import type { MonitorState } from "../types/runtime";
type MonitorPanelProps = {
monitor: MonitorState;
onClearLogs: () => void;
};
export function MonitorPanel(props: MonitorPanelProps) {
const { monitor, onClearLogs } = props;
const successCount = monitor.stats.find((item) => item.tone === "success")?.value ?? 0;
const failedCount = monitor.stats.find((item) => item.tone === "danger")?.value ?? 0;
const pendingFromStats = monitor.stats.find((item) => item.tone === "pending")?.value ?? 0;
const pendingCount = Math.max(0, Math.max(pendingFromStats, monitor.total - successCount));
const timing = monitor.singleAccountTiming;
const displaySeconds = (value: number | null): string => (typeof value === "number" ? `${value.toFixed(1)}s` : "--");
const loopRemain = monitor.loopNextCheckInSeconds;
const runtimeModeText = monitor.loopRunning ? "循环补号" : monitor.running ? "单次维护" : "未运行";
const loopRemainText = typeof loopRemain === "number" ? `${Math.max(0, loopRemain)}s` : "--";
return (
<section class="card monitor-card">
<div class="card-head">
<div class="card-title">
<span class="title-icon">💻</span>
<span></span>
</div>
<button class="link-button" type="button" onClick={onClearLogs}>
</button>
</div>
<div class="monitor-body">
<div class={`runtime-banner ${monitor.running ? "active" : ""}`}>
<span class="runtime-dot" />
<span>{monitor.message}</span>
</div>
<div class="runtime-mode-banner">
<span class="runtime-mode-label"></span>
<span class="runtime-mode-value">{runtimeModeText}</span>
{monitor.loopRunning ? (
<span class="runtime-mode-next">: {loopRemainText}</span>
) : null}
</div>
<div class="inventory-banner">
<span class="inventory-label">CPA </span>
<span class="inventory-value">
{monitor.availableCandidates === null ? "--" : monitor.availableCandidates}
</span>
</div>
<div class="progress-head">
<div class="progress-title"></div>
<div class="progress-meta">
<span>
{successCount} / {monitor.total}
</span>
<span>{monitor.percent}%</span>
</div>
</div>
<div class="progress-track">
<div class="progress-value" style={{ width: `${monitor.percent}%` }} />
</div>
<div class="stat-strip">
<div class="mini-stat success">
<span class="mini-stat-label"></span>
<span class="mini-stat-value">{successCount}</span>
</div>
<div class="mini-stat danger">
<span class="mini-stat-label"></span>
<span class="mini-stat-value">{failedCount}</span>
</div>
<div class="mini-stat pending">
<span class="mini-stat-label"></span>
<span class="mini-stat-value">{pendingCount}</span>
</div>
</div>
<div class="timing-strip">
<div class="timing-item">
<span class="timing-label"></span>
<span class="timing-value">{displaySeconds(timing.latestTotalSeconds)}</span>
</div>
<div class="timing-item">
<span class="timing-label">/OAuth</span>
<span class="timing-value">
{displaySeconds(timing.latestRegSeconds)} / {displaySeconds(timing.latestOauthSeconds)}
</span>
</div>
<div class="timing-item">
<span class="timing-label">{timing.windowSize}()</span>
<span class="timing-value">{displaySeconds(timing.recentAvgTotalSeconds)}</span>
</div>
<div class="timing-item">
<span class="timing-label">(100s)</span>
<span class="timing-value">
{timing.recentSlowCount} / {timing.sampleSize}
</span>
</div>
</div>
<TerminalLog lines={monitor.logs} />
</div>
</section>
);
}

View File

@@ -0,0 +1,39 @@
import { useEffect, useRef } from "preact/hooks";
import type { LogLine } from "../types/runtime";
type TerminalLogProps = {
lines: LogLine[];
};
export function TerminalLog(props: TerminalLogProps) {
const { lines } = props;
const terminalRef = useRef<HTMLDivElement>(null);
const shouldStickToBottomRef = useRef(true);
const handleScroll = () => {
const node = terminalRef.current;
if (!node) {
return;
}
const distanceToBottom = node.scrollHeight - node.scrollTop - node.clientHeight;
shouldStickToBottomRef.current = distanceToBottom < 32;
};
useEffect(() => {
if (terminalRef.current && shouldStickToBottomRef.current) {
terminalRef.current.scrollTop = terminalRef.current.scrollHeight;
}
}, [lines]);
return (
<div class="terminal" ref={terminalRef} onScroll={handleScroll}>
{lines.map((line) => (
<div class="log-row" key={line.id}>
<span class="log-dim">{line.prefix}</span>{" "}
<span class={`log-${line.tone}`}>{line.timestamp}</span>{" "}
<span>{line.message}</span>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,580 @@
import type { BackendConfig } from "../types/api";
import type { ConfigSection } from "../types/runtime";
export const defaultBackendConfig: BackendConfig = {
cfmail: {
api_base: "https://mail.example.com",
api_key: "",
domain: "",
domains: [],
},
clean: {
base_url: "CPA地址",
token: "CPA登录密码",
target_type: "codex",
workers: 20,
sample_size: 0,
delete_workers: 20,
timeout: 10,
retries: 1,
user_agent: "codex_cli_rs/0.76.0 (Debian 13.0.0; x86_64) WindowsTerminal",
used_percent_threshold: 95,
},
mail: {
provider: "tempmail_lol",
api_base: "https://your-worker.workers.dev",
api_key: "your-mail-api-key",
domain: "mail.example.com",
domains: [],
otp_timeout_seconds: 120,
poll_interval_seconds: 3,
},
duckmail: {
api_base: "https://api.duckmail.sbs",
bearer: "",
domain: "duckmail.sbs",
domains: [],
},
tempmail_lol: {
api_base: "https://api.tempmail.lol/v2",
},
yyds_mail: {
api_base: "https://maliapi.215.im/v1",
api_key: "",
domain: "",
domains: [],
},
maintainer: {
min_candidates: 50,
loop_interval_seconds: 60,
},
run: {
workers: 8,
proxy: "",
failure_threshold_for_cooldown: 5,
failure_cooldown_seconds: 45,
loop_jitter_min_seconds: 2,
loop_jitter_max_seconds: 6,
},
flow: {
step_retry_attempts: 2,
step_retry_delay_base: 0.2,
step_retry_delay_cap: 0.8,
outer_retry_attempts: 3,
oauth_local_retry_attempts: 3,
transient_markers:
"sentinel_,oauth_authorization_code_not_found,headers_failed,timeout,timed out,server disconnected,unexpected_eof_while_reading,transport,remoteprotocolerror,connection reset,temporarily unavailable,network,eof occurred,http_429,http_500,http_502,http_503,http_504",
register_otp_validate_order: "normal,sentinel",
oauth_otp_validate_order: "normal,sentinel",
oauth_password_phone_action: "warn_and_continue",
oauth_otp_phone_action: "warn_and_continue",
},
registration: {
entry_mode: "chatgpt_web",
entry_mode_fallback: true,
chatgpt_base: "https://chatgpt.com",
register_create_account_phone_action: "warn_and_continue",
phone_verification_markers: "add_phone,/add-phone,phone_verification,phone-verification,phone/verify",
},
oauth: {
issuer: "https://auth.openai.com",
client_id: "app_EMoamEEZ73f0CkXaXp7hrann",
redirect_uri: "http://localhost:1455/auth/callback",
retry_attempts: 3,
retry_backoff_base: 2,
retry_backoff_max: 15,
otp_timeout_seconds: 120,
otp_poll_interval_seconds: 2,
},
output: {
accounts_file: "accounts.txt",
csv_file: "registered_accounts.csv",
ak_file: "ak.txt",
rk_file: "rk.txt",
save_local: false,
},
};
function toNumber(value: unknown, fallback: number): number {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
function toString(value: unknown, fallback: string): string {
return typeof value === "string" ? value : fallback;
}
function toBoolean(value: unknown, fallback: boolean): boolean {
return typeof value === "boolean" ? value : fallback;
}
function toStringArray(value: unknown, fallback: string[] = []): string[] {
if (!Array.isArray(value)) {
return fallback;
}
const normalized = value
.map((item) => (typeof item === "string" ? item.trim() : ""))
.filter(Boolean);
return normalized.length ? normalized : fallback;
}
function linesToArray(value: unknown): string[] {
return String(value ?? "")
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean);
}
function arrayToLines(values: string[]): string {
return values.join("\n");
}
export function normalizeBackendConfig(raw: Partial<BackendConfig> | Record<string, unknown>): BackendConfig {
const source = raw ?? {};
const cfmail = (source.cfmail ?? {}) as Partial<BackendConfig["cfmail"]>;
const clean = (source.clean ?? {}) as Partial<BackendConfig["clean"]>;
const mail = (source.mail ?? {}) as Partial<BackendConfig["mail"]>;
const duckmail = (source.duckmail ?? {}) as Partial<BackendConfig["duckmail"]>;
const tempmailLol = (source.tempmail_lol ?? {}) as Partial<BackendConfig["tempmail_lol"]>;
const yydsMail = (source.yyds_mail ?? {}) as Partial<BackendConfig["yyds_mail"]>;
const maintainer = (source.maintainer ?? {}) as Partial<BackendConfig["maintainer"]>;
const run = (source.run ?? {}) as Partial<BackendConfig["run"]>;
const flow = (source.flow ?? {}) as Partial<BackendConfig["flow"]>;
const registration = (source.registration ?? {}) as Partial<BackendConfig["registration"]>;
const oauth = (source.oauth ?? {}) as Partial<BackendConfig["oauth"]>;
const output = (source.output ?? {}) as Partial<BackendConfig["output"]>;
return {
cfmail: {
api_base: toString(cfmail.api_base, defaultBackendConfig.cfmail.api_base),
api_key: toString(cfmail.api_key, defaultBackendConfig.cfmail.api_key),
domain: toString(cfmail.domain, defaultBackendConfig.cfmail.domain),
domains: toStringArray(cfmail.domains, defaultBackendConfig.cfmail.domains),
},
clean: {
base_url: toString(clean.base_url, defaultBackendConfig.clean.base_url),
token: toString(clean.token, defaultBackendConfig.clean.token),
target_type: toString(clean.target_type, defaultBackendConfig.clean.target_type),
workers: toNumber(clean.workers, defaultBackendConfig.clean.workers),
sample_size: toNumber(clean.sample_size, defaultBackendConfig.clean.sample_size),
delete_workers: toNumber(clean.delete_workers, defaultBackendConfig.clean.delete_workers),
timeout: toNumber(clean.timeout, defaultBackendConfig.clean.timeout),
retries: toNumber(clean.retries, defaultBackendConfig.clean.retries),
user_agent: toString(clean.user_agent, defaultBackendConfig.clean.user_agent ?? ""),
used_percent_threshold: toNumber(clean.used_percent_threshold, defaultBackendConfig.clean.used_percent_threshold),
},
mail: {
provider: toString(mail.provider, defaultBackendConfig.mail.provider),
api_base: toString(mail.api_base, defaultBackendConfig.mail.api_base),
api_key: toString(mail.api_key, defaultBackendConfig.mail.api_key),
domain: toString(mail.domain, defaultBackendConfig.mail.domain),
domains: toStringArray(mail.domains, defaultBackendConfig.mail.domains),
otp_timeout_seconds: toNumber(mail.otp_timeout_seconds, defaultBackendConfig.mail.otp_timeout_seconds),
poll_interval_seconds: toNumber(mail.poll_interval_seconds, defaultBackendConfig.mail.poll_interval_seconds),
},
duckmail: {
api_base: toString(duckmail.api_base, defaultBackendConfig.duckmail.api_base),
bearer: toString(duckmail.bearer, defaultBackendConfig.duckmail.bearer),
domain: toString(duckmail.domain, defaultBackendConfig.duckmail.domain),
domains: toStringArray(duckmail.domains, defaultBackendConfig.duckmail.domains),
},
tempmail_lol: {
api_base: toString(tempmailLol.api_base, defaultBackendConfig.tempmail_lol.api_base),
},
yyds_mail: {
api_base: toString(yydsMail.api_base, defaultBackendConfig.yyds_mail.api_base),
api_key: toString(yydsMail.api_key, defaultBackendConfig.yyds_mail.api_key),
domain: toString(yydsMail.domain, defaultBackendConfig.yyds_mail.domain),
domains: toStringArray(yydsMail.domains, defaultBackendConfig.yyds_mail.domains),
},
maintainer: {
min_candidates: toNumber(maintainer.min_candidates, defaultBackendConfig.maintainer.min_candidates),
loop_interval_seconds: toNumber(
maintainer.loop_interval_seconds,
defaultBackendConfig.maintainer.loop_interval_seconds,
),
},
run: {
workers: toNumber(run.workers, defaultBackendConfig.run.workers),
proxy: toString(run.proxy, defaultBackendConfig.run.proxy),
failure_threshold_for_cooldown: toNumber(
run.failure_threshold_for_cooldown,
defaultBackendConfig.run.failure_threshold_for_cooldown,
),
failure_cooldown_seconds: toNumber(run.failure_cooldown_seconds, defaultBackendConfig.run.failure_cooldown_seconds),
loop_jitter_min_seconds: toNumber(run.loop_jitter_min_seconds, defaultBackendConfig.run.loop_jitter_min_seconds),
loop_jitter_max_seconds: toNumber(run.loop_jitter_max_seconds, defaultBackendConfig.run.loop_jitter_max_seconds),
},
flow: {
step_retry_attempts: toNumber(flow.step_retry_attempts, defaultBackendConfig.flow.step_retry_attempts),
step_retry_delay_base: toNumber(flow.step_retry_delay_base, defaultBackendConfig.flow.step_retry_delay_base),
step_retry_delay_cap: toNumber(flow.step_retry_delay_cap, defaultBackendConfig.flow.step_retry_delay_cap),
outer_retry_attempts: toNumber(flow.outer_retry_attempts, defaultBackendConfig.flow.outer_retry_attempts),
oauth_local_retry_attempts: toNumber(
flow.oauth_local_retry_attempts,
defaultBackendConfig.flow.oauth_local_retry_attempts,
),
transient_markers: toString(flow.transient_markers, defaultBackendConfig.flow.transient_markers),
register_otp_validate_order: toString(
flow.register_otp_validate_order,
defaultBackendConfig.flow.register_otp_validate_order,
),
oauth_otp_validate_order: toString(flow.oauth_otp_validate_order, defaultBackendConfig.flow.oauth_otp_validate_order),
oauth_password_phone_action: toString(
flow.oauth_password_phone_action,
defaultBackendConfig.flow.oauth_password_phone_action,
),
oauth_otp_phone_action: toString(flow.oauth_otp_phone_action, defaultBackendConfig.flow.oauth_otp_phone_action),
},
registration: {
entry_mode: toString(registration.entry_mode, defaultBackendConfig.registration.entry_mode),
entry_mode_fallback: toBoolean(registration.entry_mode_fallback, defaultBackendConfig.registration.entry_mode_fallback),
chatgpt_base: toString(registration.chatgpt_base, defaultBackendConfig.registration.chatgpt_base),
register_create_account_phone_action: toString(
registration.register_create_account_phone_action,
defaultBackendConfig.registration.register_create_account_phone_action,
),
phone_verification_markers: toString(
registration.phone_verification_markers,
defaultBackendConfig.registration.phone_verification_markers,
),
},
oauth: {
issuer: toString(oauth.issuer, defaultBackendConfig.oauth.issuer),
client_id: toString(oauth.client_id, defaultBackendConfig.oauth.client_id),
redirect_uri: toString(oauth.redirect_uri, defaultBackendConfig.oauth.redirect_uri),
retry_attempts: toNumber(oauth.retry_attempts, defaultBackendConfig.oauth.retry_attempts),
retry_backoff_base: toNumber(oauth.retry_backoff_base, defaultBackendConfig.oauth.retry_backoff_base),
retry_backoff_max: toNumber(oauth.retry_backoff_max, defaultBackendConfig.oauth.retry_backoff_max),
otp_timeout_seconds: toNumber(oauth.otp_timeout_seconds, defaultBackendConfig.oauth.otp_timeout_seconds),
otp_poll_interval_seconds: toNumber(
oauth.otp_poll_interval_seconds,
defaultBackendConfig.oauth.otp_poll_interval_seconds,
),
},
output: {
accounts_file: toString(output.accounts_file, defaultBackendConfig.output.accounts_file),
csv_file: toString(output.csv_file, defaultBackendConfig.output.csv_file),
ak_file: toString(output.ak_file, defaultBackendConfig.output.ak_file),
rk_file: toString(output.rk_file, defaultBackendConfig.output.rk_file),
save_local: toBoolean(output.save_local, defaultBackendConfig.output.save_local),
},
};
}
export function configToSections(config: BackendConfig): ConfigSection[] {
return [
{
key: "priority",
label: "核心配置",
fields: [
{ key: "base_url", label: "CPA 接口地址", type: "text", value: config.clean.base_url },
{ key: "token", label: "CPA 访问令牌", type: "password", value: config.clean.token, sensitive: true },
{
key: "min_candidates",
label: "最小候选账号数",
type: "number",
value: config.maintainer.min_candidates,
hint: "表示账号池希望长期保有的最低可用账号数。清理完成后若当前候选账号低于该值,系统会自动补号。",
},
{
key: "loop_interval_seconds",
label: "循环补号间隔(秒)",
type: "number",
value: config.maintainer.loop_interval_seconds,
hint: "点击“循环补号”按钮后,每轮检查完会休眠该秒数再重新检测。",
},
{
key: "proxy",
label: "代理地址",
type: "text",
value: config.run.proxy,
hint: "示例: http://127.0.0.1:7890 或 socks5://127.0.0.1:1080",
},
],
},
{
key: "clean",
label: "清理配置",
columns: 2,
fields: [
{ key: "target_type", label: "目标账号类型", type: "text", value: config.clean.target_type },
{ key: "timeout", label: "请求超时", type: "number", value: config.clean.timeout },
{ key: "workers", label: "探测并发", type: "number", value: config.clean.workers },
{
key: "sample_size",
label: "抽样数量",
type: "number",
value: config.clean.sample_size,
hint: "0 表示全量探测;大于 0 时,每轮仅随机抽取这部分账号做可用性探测。",
},
{ key: "delete_workers", label: "删除并发", type: "number", value: config.clean.delete_workers },
{ key: "retries", label: "重试次数", type: "number", value: config.clean.retries },
{
key: "used_percent_threshold",
label: "用量阈值",
type: "number",
value: config.clean.used_percent_threshold,
hint: "用于识别高消耗账号。若账号的 used_percent 大于等于该值,会在清理阶段优先禁用(不直接删除)。",
},
],
},
{
key: "mail",
label: "邮箱配置",
columns: 2,
fields: [
{
key: "provider",
label: "邮箱提供方",
type: "select",
value: config.mail.provider,
options: [
{ label: "cfmail", value: "cfmail" },
{ label: "self_hosted_mail_api", value: "self_hosted_mail_api" },
{ label: "duckmail", value: "duckmail" },
{ label: "tempmail_lol", value: "tempmail_lol" },
{ label: "yyds_mail", value: "yyds_mail" },
],
},
{ key: "otp_timeout_seconds", label: "验证码超时", type: "number", value: config.mail.otp_timeout_seconds },
{ key: "poll_interval_seconds", label: "轮询间隔", type: "number", value: config.mail.poll_interval_seconds },
],
},
{
key: "cfmail",
label: "CF Mail 配置",
columns: 2,
fields: [
{ key: "api_base", label: "接口地址", type: "text", value: config.cfmail.api_base },
{ key: "api_key", label: "接口密钥", type: "password", value: config.cfmail.api_key, sensitive: true },
{ key: "domain", label: "邮箱域名", type: "text", value: config.cfmail.domain },
{
key: "domains",
label: "邮箱域名列表",
type: "textarea",
value: arrayToLines(config.cfmail.domains),
hint: "每行一个域名;填写后优先于单个 domain。",
},
],
},
{
key: "self_hosted_mail_api",
label: "自建 Mail API 配置",
columns: 2,
fields: [
{ key: "api_base", label: "邮件 API 地址", type: "text", value: config.mail.api_base },
{ key: "domain", label: "邮箱域名", type: "text", value: config.mail.domain },
{
key: "domains",
label: "邮箱域名列表",
type: "textarea",
value: arrayToLines(config.mail.domains),
hint: "每行一个域名;填写后优先于单个 domain。",
},
{ key: "api_key", label: "邮件 API 密钥", type: "password", value: config.mail.api_key, sensitive: true },
],
},
{
key: "duckmail",
label: "DuckMail 配置",
columns: 2,
fields: [
{ key: "api_base", label: "接口地址", type: "text", value: config.duckmail.api_base },
{ key: "domain", label: "邮箱域名", type: "text", value: config.duckmail.domain },
{
key: "domains",
label: "邮箱域名列表",
type: "textarea",
value: arrayToLines(config.duckmail.domains),
hint: "每行一个域名;填写后优先于单个 domain。",
},
{ key: "bearer", label: "访问凭证", type: "password", value: config.duckmail.bearer, sensitive: true },
],
},
{
key: "tempmail_lol",
label: "TempMail.lol 配置",
fields: [{ key: "api_base", label: "接口地址", type: "text", value: config.tempmail_lol.api_base }],
},
{
key: "yyds_mail",
label: "YYDS Mail 配置",
columns: 2,
fields: [
{ key: "api_base", label: "接口地址", type: "text", value: config.yyds_mail.api_base },
{ key: "domain", label: "邮箱域名", type: "text", value: config.yyds_mail.domain },
{
key: "domains",
label: "邮箱域名列表",
type: "textarea",
value: arrayToLines(config.yyds_mail.domains),
hint: "每行一个域名;填写后优先于单个 domain。",
},
{ key: "api_key", label: "访问密钥", type: "password", value: config.yyds_mail.api_key, sensitive: true },
],
},
{
key: "run",
label: "运行参数",
columns: 2,
fields: [
{ key: "workers", label: "补号并发数", type: "number", value: config.run.workers },
{
key: "failure_threshold_for_cooldown",
label: "连续失败阈值",
type: "number",
value: config.run.failure_threshold_for_cooldown,
},
{
key: "failure_cooldown_seconds",
label: "冷却时长",
type: "number",
value: config.run.failure_cooldown_seconds,
},
{ key: "loop_jitter_min_seconds", label: "最小抖动秒数", type: "number", value: config.run.loop_jitter_min_seconds },
{ key: "loop_jitter_max_seconds", label: "最大抖动秒数", type: "number", value: config.run.loop_jitter_max_seconds },
],
},
{
key: "registration",
label: "注册流程策略",
columns: 2,
fields: [
{
key: "entry_mode",
label: "注册入口模式",
type: "select",
value: config.registration.entry_mode,
options: [
{ label: "chatgpt_web", value: "chatgpt_web" },
{ label: "direct_auth", value: "direct_auth" },
],
},
{ key: "entry_mode_fallback", label: "入口失败自动回退", type: "checkbox", value: config.registration.entry_mode_fallback },
{ key: "chatgpt_base", label: "ChatGPT 入口域名", type: "text", value: config.registration.chatgpt_base },
{
key: "register_create_account_phone_action",
label: "注册命中手机验证",
type: "select",
value: config.registration.register_create_account_phone_action,
options: [
{ label: "warn_and_continue", value: "warn_and_continue" },
{ label: "fail_fast", value: "fail_fast" },
],
},
{
key: "phone_verification_markers",
label: "手机验证识别关键词",
type: "text",
value: config.registration.phone_verification_markers,
},
],
},
{
key: "flow",
label: "流程重试策略",
columns: 2,
fields: [
{ key: "step_retry_attempts", label: "注册步骤局部重试", type: "number", value: config.flow.step_retry_attempts },
{ key: "step_retry_delay_base", label: "步骤重试基数", type: "number", value: config.flow.step_retry_delay_base },
{ key: "step_retry_delay_cap", label: "步骤重试上限", type: "number", value: config.flow.step_retry_delay_cap },
{ key: "outer_retry_attempts", label: "OAuth 外层重试", type: "number", value: config.flow.outer_retry_attempts },
{
key: "oauth_local_retry_attempts",
label: "OAuth 局部重试",
type: "number",
value: config.flow.oauth_local_retry_attempts,
},
{ key: "register_otp_validate_order", label: "注册 OTP 校验顺序", type: "text", value: config.flow.register_otp_validate_order },
{ key: "oauth_otp_validate_order", label: "OAuth OTP 校验顺序", type: "text", value: config.flow.oauth_otp_validate_order },
{
key: "oauth_password_phone_action",
label: "OAuth 密码阶段手机验证",
type: "select",
value: config.flow.oauth_password_phone_action,
options: [
{ label: "warn_and_continue", value: "warn_and_continue" },
{ label: "fail_fast", value: "fail_fast" },
],
},
{
key: "oauth_otp_phone_action",
label: "OAuth OTP阶段手机验证",
type: "select",
value: config.flow.oauth_otp_phone_action,
options: [
{ label: "warn_and_continue", value: "warn_and_continue" },
{ label: "fail_fast", value: "fail_fast" },
],
},
{ key: "transient_markers", label: "瞬时错误关键词", type: "text", value: config.flow.transient_markers },
],
},
{
key: "oauth",
label: "OAuth 配置",
columns: 2,
fields: [
{ key: "issuer", label: "认证服务地址", type: "text", value: config.oauth.issuer },
{ key: "client_id", label: "客户端 ID", type: "text", value: config.oauth.client_id },
{ key: "redirect_uri", label: "回调地址", type: "text", value: config.oauth.redirect_uri },
{ key: "retry_attempts", label: "重试次数", type: "number", value: config.oauth.retry_attempts },
{ key: "retry_backoff_base", label: "退避基数", type: "number", value: config.oauth.retry_backoff_base },
{ key: "retry_backoff_max", label: "最大退避", type: "number", value: config.oauth.retry_backoff_max },
{ key: "otp_timeout_seconds", label: "登录验证码超时", type: "number", value: config.oauth.otp_timeout_seconds },
{ key: "otp_poll_interval_seconds", label: "登录轮询间隔", type: "number", value: config.oauth.otp_poll_interval_seconds },
],
},
{
key: "output",
label: "输出配置",
columns: 2,
fields: [
{ key: "accounts_file", label: "账号文件", type: "text", value: config.output.accounts_file },
{ key: "csv_file", label: "CSV 文件", type: "text", value: config.output.csv_file },
{ key: "ak_file", label: "Access Token 文件", type: "text", value: config.output.ak_file },
{ key: "rk_file", label: "Refresh Token 文件", type: "text", value: config.output.rk_file },
{ key: "save_local", label: "本地保存", type: "checkbox", value: config.output.save_local },
],
},
];
}
export function sectionsToConfig(sections: ConfigSection[]): BackendConfig {
const config = structuredClone(defaultBackendConfig);
for (const section of sections) {
const targetKey = section.key === "self_hosted_mail_api" ? "mail" : section.key;
if (section.key === "priority") {
for (const field of section.fields) {
if (field.key === "base_url" || field.key === "token") {
(config.clean as Record<string, string | number | boolean>)[field.key] = field.value;
} else if (field.key === "min_candidates") {
config.maintainer.min_candidates = Number(field.value);
} else if (field.key === "loop_interval_seconds") {
config.maintainer.loop_interval_seconds = Number(field.value);
} else if (field.key === "proxy") {
config.run.proxy = String(field.value);
}
}
continue;
}
const target = config[targetKey as keyof BackendConfig] as Record<string, string | number | boolean | string[]>;
if (!target) {
continue;
}
for (const field of section.fields) {
if (field.key === "domains") {
target[field.key] = linesToArray(field.value);
continue;
}
target[field.key] = field.value;
}
}
return normalizeBackendConfig(config);
}

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

@@ -0,0 +1,7 @@
import { render } from "preact";
import { App } from "./app";
import "./styles/tokens.css";
import "./styles/base.css";
import "./styles/layout.css";
render(<App />, document.getElementById("app") as HTMLElement);

51
frontend/src/mock/data.ts Normal file
View File

@@ -0,0 +1,51 @@
import { configToSections, defaultBackendConfig } from "../lib/config-schema";
import type { MonitorState } from "../types/runtime";
export const initialConfigSections = configToSections(defaultBackendConfig);
export const initialMonitorState: MonitorState = {
running: false,
runMode: "",
loopRunning: false,
loopNextCheckInSeconds: null,
phase: "idle",
message: "等待任务启动",
availableCandidates: null,
availableCandidatesError: "",
completed: 2,
total: 20,
percent: 10,
stats: [
{ label: "成功", value: 2, icon: "☑", tone: "success" },
{ label: "失败", value: 0, icon: "✕", tone: "danger" },
{ label: "剩余", value: 18, icon: "⏳", tone: "pending" },
],
singleAccountTiming: {
latestRegSeconds: 15.4,
latestOauthSeconds: 56.8,
latestTotalSeconds: 72.2,
recentAvgRegSeconds: 16.1,
recentAvgOauthSeconds: 54.3,
recentAvgTotalSeconds: 70.4,
recentSlowCount: 1,
sampleSize: 20,
windowSize: 20,
},
logs: [
{ id: "1", prefix: "[00:28:38] [任务3]", timestamp: "[00:28:38]", message: "提交密码状态: 200", tone: "info" },
{ id: "2", prefix: "[00:28:38] [任务3]", timestamp: "[00:28:38]", message: "9. 发送验证码...", tone: "info" },
{ id: "3", prefix: "[00:28:39] [任务3]", timestamp: "[00:28:39]", message: "验证码发送状态: 200", tone: "info" },
{ id: "4", prefix: "[00:28:39] [任务3]", timestamp: "[00:28:39]", message: "10. 等待验证码...", tone: "info" },
{
id: "5",
prefix: "[00:28:39] [任务3]",
timestamp: "[00:28:39]",
message: "正在等待邮箱 dictman3eb8a4@whf.hush2u.com 的验证码...",
tone: "info",
},
{ id: "6", prefix: "[00:28:39] [任务3]", timestamp: "[00:28:39]", message: "成功获取验证码: 963817", tone: "success" },
{ id: "7", prefix: "[00:28:40] [任务3]", timestamp: "[00:28:40]", message: "生成用户信息: Charlotte生日: 1996-01-27", tone: "info" },
{ id: "8", prefix: "[00:28:41] [任务3]", timestamp: "[00:28:41]", message: "Sentinel token 获取成功", tone: "success" },
{ id: "9", prefix: "[00:28:44] [任务3]", timestamp: "[00:28:44]", message: "OAuth 登录链路进入 consent 阶段", tone: "success" },
],
};

View File

@@ -0,0 +1,127 @@
import { configToSections, normalizeBackendConfig, sectionsToConfig } from "../lib/config-schema";
import type {
AuthState,
BackendConfig,
RuntimeStatusResponse,
StartRuntimeResponse,
StopRuntimeResponse,
} from "../types/api";
import type { ConfigSection, MonitorState } from "../types/runtime";
const AUTH_STORAGE_KEY = "apm_admin_token";
export function getStoredAuth(): AuthState {
return {
token: window.sessionStorage.getItem(AUTH_STORAGE_KEY) ?? "",
};
}
export function storeAuthToken(token: string): void {
window.sessionStorage.setItem(AUTH_STORAGE_KEY, token);
}
export function clearAuthToken(): void {
window.sessionStorage.removeItem(AUTH_STORAGE_KEY);
}
export class ApiRequestError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.name = "ApiRequestError";
this.status = status;
}
}
export function isAuthError(error: unknown): boolean {
return error instanceof ApiRequestError && (error.status === 401 || error.status === 403);
}
async function getJson<T>(path: string, init?: RequestInit, token?: string): Promise<T> {
const adminToken = token ?? getStoredAuth().token;
const response = await fetch(path, {
...init,
headers: {
"Content-Type": "application/json",
...(adminToken ? { "X-Admin-Token": adminToken } : {}),
...(init?.headers ?? {}),
},
});
if (!response.ok) {
throw new ApiRequestError(`${init?.method ?? "GET"} ${path} failed: ${response.status}`, response.status);
}
return (await response.json()) as T;
}
export async function verifyAuthToken(token: string): Promise<void> {
await getJson<{ ok: boolean; time: string }>("/api/health", undefined, token);
await getJson<Partial<BackendConfig>>("/api/config", undefined, token);
}
export async function fetchConfig(): Promise<ConfigSection[]> {
const config = await getJson<Partial<BackendConfig>>("/api/config");
return configToSections(normalizeBackendConfig(config));
}
export async function saveConfig(sections: ConfigSection[]): Promise<ConfigSection[]> {
const saved = await getJson<BackendConfig>("/api/config", {
method: "POST",
body: JSON.stringify(sectionsToConfig(sections)),
});
return configToSections(normalizeBackendConfig(saved));
}
export async function fetchMonitorState(): Promise<MonitorState> {
const status = await getJson<RuntimeStatusResponse>("/api/runtime/status");
const timing = status.single_account_timing;
return {
running: status.running,
runMode: status.run_mode ?? "",
loopRunning: status.loop_running ?? false,
loopNextCheckInSeconds: status.loop_next_check_in_seconds ?? null,
phase: status.phase,
message: status.message,
availableCandidates: status.available_candidates,
availableCandidatesError: status.available_candidates_error,
completed: status.completed,
total: status.total,
percent: status.percent,
stats: status.stats,
singleAccountTiming: {
latestRegSeconds: timing?.latest_reg_seconds ?? null,
latestOauthSeconds: timing?.latest_oauth_seconds ?? null,
latestTotalSeconds: timing?.latest_total_seconds ?? null,
recentAvgRegSeconds: timing?.recent_avg_reg_seconds ?? null,
recentAvgOauthSeconds: timing?.recent_avg_oauth_seconds ?? null,
recentAvgTotalSeconds: timing?.recent_avg_total_seconds ?? null,
recentSlowCount: timing?.recent_slow_count ?? 0,
sampleSize: timing?.sample_size ?? 0,
windowSize: timing?.window_size ?? 20,
},
logs: status.logs,
};
}
export async function startRuntime(): Promise<StartRuntimeResponse> {
return getJson<StartRuntimeResponse>("/api/runtime/start", {
method: "POST",
body: "{}",
});
}
export async function startRuntimeLoop(): Promise<StartRuntimeResponse> {
return getJson<StartRuntimeResponse>("/api/runtime/start-loop", {
method: "POST",
body: "{}",
});
}
export async function stopRuntime(): Promise<StopRuntimeResponse> {
return getJson<StopRuntimeResponse>("/api/runtime/stop", {
method: "POST",
body: "{}",
});
}

View File

@@ -0,0 +1,3 @@
export function connectLogStream(): EventSource {
return new EventSource("/api/logs");
}

View File

@@ -0,0 +1,29 @@
* {
box-sizing: border-box;
}
html,
body,
#app {
min-height: 100%;
}
html,
body {
margin: 0;
}
body {
padding: 28px 24px;
font-family: "IBM Plex Sans", "PingFang SC", "Noto Sans SC", "Microsoft YaHei", sans-serif;
color: var(--text);
background:
radial-gradient(circle at top left, rgba(177, 220, 255, 0.24), transparent 28%),
linear-gradient(180deg, #f9fcff 0%, var(--bg) 100%);
}
button,
input,
select {
font: inherit;
}

View File

@@ -0,0 +1,816 @@
.page-shell {
max-width: 1280px;
margin: 0 auto;
}
.login-shell {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.login-card {
width: min(420px, 100%);
padding: 28px;
border: 1px solid var(--line);
border-radius: 20px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(246, 250, 255, 0.96));
box-shadow: var(--shadow);
}
.login-title {
font-size: 24px;
font-weight: 700;
color: #153150;
}
.login-subtitle {
margin-top: 8px;
margin-bottom: 20px;
color: #6f87a4;
font-size: 14px;
}
.login-error {
margin-top: -4px;
margin-bottom: 14px;
color: #b24b52;
font-size: 13px;
}
.login-button {
width: 100%;
}
.page-notice {
margin-bottom: 16px;
padding: 12px 16px;
border: 1px solid #d7e4f2;
border-radius: 14px;
background: rgba(255, 255, 255, 0.92);
color: #335376;
box-shadow: var(--shadow);
}
.page-grid {
display: grid;
grid-template-columns: 430px minmax(0, 1fr);
gap: 24px;
align-items: start;
}
.main-stack {
display: grid;
align-content: start;
min-width: 0;
}
.card {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(251, 253, 255, 0.95));
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow: hidden;
backdrop-filter: blur(10px);
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px 18px;
border-bottom: 1px solid var(--line);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(247, 250, 255, 0.9));
}
.card-title {
display: inline-flex;
align-items: center;
gap: 10px;
font-size: 16px;
font-weight: 700;
letter-spacing: 0.01em;
}
.title-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
font-size: 14px;
}
.settings-body {
padding: 24px;
max-height: calc(100vh - 160px);
overflow: auto;
}
.settings-card {
min-width: 0;
}
.settings-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin-bottom: 16px;
}
.summary-chip {
padding: 10px 12px;
border: 1px solid #deebf8;
border-radius: 14px;
background: linear-gradient(180deg, #fdfefe, #eef5fb);
}
.summary-label {
display: block;
margin-bottom: 4px;
color: #6f87a4;
font-size: 12px;
}
.summary-value {
display: block;
color: #163151;
font-size: 14px;
font-weight: 700;
}
.settings-tabs {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin-bottom: 16px;
padding: 6px;
border: 1px solid #deebf8;
border-radius: 16px;
background: linear-gradient(180deg, #f9fbfe, #eef4fb);
}
.settings-tab {
height: 38px;
border: 0;
border-radius: 11px;
background: transparent;
color: #6f87a4;
font-size: 14px;
font-weight: 700;
cursor: pointer;
transition: background 140ms ease, color 140ms ease, transform 140ms ease;
}
.settings-tab:hover {
transform: translateY(-1px);
}
.settings-tab.active {
background: #ffffff;
color: #143150;
box-shadow: 0 8px 18px rgba(110, 139, 174, 0.14);
}
.config-group {
margin-bottom: 16px;
padding: 0;
border: 1px solid #e0eaf5;
border-radius: 14px;
background: linear-gradient(180deg, rgba(249, 252, 255, 0.96), rgba(244, 249, 254, 0.9));
}
.config-group:last-of-type {
margin-bottom: 0;
}
.group-title {
font-size: 13px;
font-weight: 700;
letter-spacing: 0.03em;
color: #4f7196;
}
.group-toggle {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 15px 14px;
border: 0;
background: transparent;
cursor: pointer;
}
.group-toggle:hover {
background: rgba(234, 242, 251, 0.5);
}
.group-caret {
color: #6f87a4;
font-size: 18px;
line-height: 1;
transition: transform 160ms ease;
}
.group-caret.open {
transform: rotate(180deg);
}
.config-group.expanded .field-row,
.config-group.expanded .group-content,
.config-group.expanded > .field,
.config-group.expanded > .checkbox-group {
padding-left: 14px;
padding-right: 14px;
}
.config-group.expanded .field-row,
.config-group.expanded .group-content {
padding-bottom: 10px;
}
.group-key,
.field-key {
display: inline-flex;
align-items: center;
justify-content: center;
margin-left: 6px;
padding: 2px 7px;
border-radius: 999px;
background: #eaf2fb;
border: 1px solid #d6e4f2;
color: #6483a8;
font-family: "IBM Plex Mono", "JetBrains Mono", monospace;
font-size: 11px;
font-weight: 600;
}
.field-key {
margin-left: 8px;
transform: translateY(-1px);
}
.field {
display: block;
margin-bottom: 14px;
}
.field-row {
display: grid;
gap: 12px;
}
.field-row.single-col {
grid-template-columns: 1fr;
}
.field-row.two-cols {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.field-row.three-cols {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.field-row .field {
margin-bottom: 8px;
}
.field-label {
display: block;
margin-bottom: 8px;
color: var(--muted-strong);
font-size: 13px;
line-height: 1.35;
}
.field-hint {
display: block;
margin-top: 8px;
color: #8da1bb;
font-size: 12px;
line-height: 1.45;
}
input[type="text"],
input[type="password"],
select,
input[type="number"] {
width: 100%;
height: 40px;
padding: 0 14px;
border: 1px solid #d6e2f0;
border-radius: 11px;
background: #fff;
color: var(--text);
font-size: 14px;
outline: none;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9);
}
input[type="text"]:focus,
input[type="password"]:focus,
select:focus,
input[type="number"]:focus {
border-color: #8eb8ef;
box-shadow: 0 0 0 3px rgba(115, 164, 224, 0.14);
}
.checkbox-group {
margin-top: 4px;
}
.checkbox-group.compact {
margin-bottom: 6px;
}
.check-row {
display: flex;
align-items: center;
gap: 10px;
color: var(--muted-strong);
font-size: 15px;
}
.check-row input {
width: 14px;
height: 14px;
accent-color: var(--accent);
}
.settings-actions {
display: flex;
gap: 10px;
margin-top: 18px;
position: sticky;
bottom: -24px;
padding-top: 12px;
background: linear-gradient(180deg, rgba(244, 248, 252, 0), rgba(244, 248, 252, 0.96) 36%, rgba(244, 248, 252, 1) 100%);
}
.button,
.tool-button,
.link-button {
border: 0;
border-radius: 11px;
font: inherit;
cursor: pointer;
transition: transform 140ms ease, box-shadow 140ms ease, background 140ms ease;
}
.button:hover,
.tool-button:hover,
.link-button:hover {
transform: translateY(-1px);
}
.button:disabled,
.tool-button:disabled,
.link-button:disabled {
cursor: not-allowed;
opacity: 0.65;
transform: none;
}
.button.primary {
padding: 11px 18px;
background: linear-gradient(135deg, #179774, #0f7f61);
color: #fff;
box-shadow: 0 12px 22px rgba(21, 145, 110, 0.25);
}
.button.secondary,
.button.tertiary,
.button.warning {
padding: 11px 18px;
background: #eef4fb;
color: var(--text);
border: 1px solid #d6e3f0;
}
.button.warning {
background: #fff2ee;
color: #a34b2c;
border-color: #f3c8ba;
}
.button.tertiary {
padding: 8px 14px;
font-size: 14px;
font-weight: 600;
}
.link-button {
background: transparent;
color: #4d6c92;
padding: 0;
font-size: 14px;
}
.monitor-body {
padding: 22px 16px 18px;
display: flex;
flex-direction: column;
min-height: 0;
}
.progress-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.progress-title {
color: #5b7391;
font-size: 13px;
font-weight: 700;
}
.runtime-banner {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
padding: 10px 12px;
border-radius: 12px;
background: #eef4fb;
color: #526e90;
font-size: 14px;
}
.runtime-banner.active {
background: #e3f5ee;
color: #1c6b53;
}
.inventory-banner {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid #dfe9f4;
background: linear-gradient(180deg, #ffffff, #f2f7fc);
}
.inventory-label {
color: #637b98;
font-size: 13px;
font-weight: 600;
}
.inventory-value {
color: #153150;
font-size: 18px;
font-weight: 700;
}
.runtime-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #8ca6c7;
box-shadow: 0 0 0 4px rgba(140, 166, 199, 0.14);
}
.runtime-banner.active .runtime-dot {
background: #35be7c;
box-shadow: 0 0 0 4px rgba(53, 190, 124, 0.14);
}
.runtime-mode-banner {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
padding: 9px 12px;
border-radius: 12px;
border: 1px solid #dbe6f2;
background: #f7fafd;
color: #4b6788;
font-size: 13px;
}
.runtime-mode-label {
color: #6f89a7;
font-weight: 600;
}
.runtime-mode-value {
color: #153150;
font-weight: 700;
}
.runtime-mode-next {
margin-left: auto;
color: #5a7290;
font-weight: 600;
}
.progress-meta {
display: flex;
align-items: center;
gap: 12px;
padding: 0 2px;
font-size: 15px;
font-weight: 700;
color: #0f2240;
}
.progress-meta span:last-child {
font-size: 13px;
font-weight: 700;
color: #5b7391;
}
.progress-track {
margin-top: 10px;
width: 100%;
height: 8px;
border-radius: 999px;
background: #deebf8;
overflow: hidden;
}
.progress-value {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, #15916e, #1bb790);
}
.stat-strip {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
padding: 14px 0 14px;
}
.mini-stat {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border: 1px solid #dfebf6;
border-radius: 14px;
background: linear-gradient(180deg, #fdfefe, #eef5fb);
}
.mini-stat-label {
color: #617a98;
font-size: 13px;
font-weight: 600;
}
.mini-stat-value {
font-size: 20px;
font-weight: 700;
color: #0f2140;
}
.mini-stat.success {
border-color: #d2eee3;
background: linear-gradient(180deg, #f9fffc, #eaf7f1);
}
.mini-stat.danger {
border-color: #f4d8df;
background: linear-gradient(180deg, #fffafb, #fcf0f3);
}
.mini-stat.pending {
border-color: #e7e2cf;
background: linear-gradient(180deg, #fffef9, #f6f2e4);
}
.timing-strip {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-bottom: 12px;
}
.timing-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 10px;
border: 1px dashed #d3e2f0;
border-radius: 10px;
background: #f7fbff;
}
.timing-label {
color: #5f7997;
font-size: 12px;
font-weight: 600;
}
.timing-value {
color: #112746;
font-size: 13px;
font-weight: 700;
}
.terminal {
height: clamp(320px, 52vh, 560px);
padding: 14px 16px;
border-radius: 13px;
background: linear-gradient(180deg, #1e2030, var(--terminal));
border: 1px solid #2b3044;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
overflow: auto;
}
.log-row {
font-family: "IBM Plex Mono", "JetBrains Mono", "SFMono-Regular", monospace;
font-size: 15px;
line-height: 1.9;
white-space: nowrap;
color: #d7e3ff;
}
.log-dim {
color: #91a3c8;
}
.log-info {
color: #b6c8ff;
}
.log-success {
color: #9fe870;
}
.log-warning {
color: #ffd575;
}
.log-danger {
color: #ff8ea6;
}
.card-tools {
display: flex;
align-items: center;
gap: 10px;
}
.tool-button {
padding: 6px 10px;
background: transparent;
color: #4d6c92;
font-size: 14px;
}
.table-wrap {
overflow: hidden;
}
table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
thead th {
padding: 10px 16px;
text-align: left;
font-size: 14px;
font-weight: 700;
color: #506b8a;
background: linear-gradient(180deg, #eef4fb, #e7eef7);
border-bottom: 1px solid #dde8f3;
}
tbody td {
padding: 12px 16px;
font-size: 15px;
border-bottom: 1px solid #edf3f8;
vertical-align: middle;
}
tbody tr:hover {
background: rgba(243, 248, 253, 0.88);
}
.col-id {
width: 48px;
}
.col-password {
width: 150px;
}
.col-status {
width: 82px;
}
.email-cell,
.password-cell {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.email-cell span:first-child,
.password-cell span:first-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.masked {
filter: blur(4px);
user-select: none;
}
.mini-icon {
width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid #d9e4ef;
background: #f7faff;
border-radius: 8px;
color: #6f87a4;
cursor: pointer;
flex: 0 0 auto;
}
.status-dot {
display: inline-block;
width: 13px;
height: 13px;
border-radius: 50%;
background: #bbcad9;
box-shadow: 0 0 0 4px rgba(187, 202, 217, 0.18);
}
.status-dot.active {
background: linear-gradient(180deg, #7fe098, #34c36c);
box-shadow: 0 0 0 4px rgba(59, 198, 108, 0.16);
}
@media (max-width: 1120px) {
.page-grid {
grid-template-columns: 1fr;
}
.monitor-body {
padding: 22px 18px 18px;
}
}
@media (max-width: 720px) {
body {
padding: 18px 14px;
}
.card-head,
.settings-body {
padding-left: 16px;
padding-right: 16px;
}
.field-row.two-cols,
.field-row.three-cols {
grid-template-columns: 1fr;
}
.settings-summary {
grid-template-columns: 1fr;
}
.settings-tabs {
grid-template-columns: 1fr;
}
.stat-strip {
grid-template-columns: 1fr;
}
.timing-strip {
grid-template-columns: 1fr;
}
.terminal {
height: 320px;
}
thead th,
tbody td {
padding-left: 12px;
padding-right: 12px;
font-size: 14px;
}
}

View File

@@ -0,0 +1,16 @@
:root {
--bg: #f4f8fc;
--panel: rgba(255, 255, 255, 0.92);
--panel-strong: #ffffff;
--line: #d7e4f2;
--line-soft: #e8f0f8;
--text: #10233f;
--muted: #6f87a4;
--muted-strong: #4f6987;
--accent: #15916e;
--danger: #ef5077;
--pending: #1e3152;
--terminal: #191b29;
--shadow: 0 14px 42px rgba(108, 135, 166, 0.12);
--radius: 18px;
}

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

@@ -0,0 +1,142 @@
import type { LogLine, StatItem } from "./runtime";
export type SingleAccountTimingResponse = {
latest_reg_seconds: number | null;
latest_oauth_seconds: number | null;
latest_total_seconds: number | null;
recent_avg_reg_seconds: number | null;
recent_avg_oauth_seconds: number | null;
recent_avg_total_seconds: number | null;
recent_slow_count: number;
sample_size: number;
window_size: number;
};
export type BackendConfig = {
cfmail: {
api_base: string;
api_key: string;
domain: string;
domains: string[];
};
clean: {
base_url: string;
token: string;
target_type: string;
workers: number;
sample_size: number;
delete_workers: number;
timeout: number;
retries: number;
user_agent?: string;
used_percent_threshold: number;
};
mail: {
provider: string;
api_base: string;
api_key: string;
domain: string;
domains: string[];
otp_timeout_seconds: number;
poll_interval_seconds: number;
};
duckmail: {
api_base: string;
bearer: string;
domain: string;
domains: string[];
};
tempmail_lol: {
api_base: string;
};
yyds_mail: {
api_base: string;
api_key: string;
domain: string;
domains: string[];
};
maintainer: {
min_candidates: number;
loop_interval_seconds: number;
};
run: {
workers: number;
proxy: string;
failure_threshold_for_cooldown: number;
failure_cooldown_seconds: number;
loop_jitter_min_seconds: number;
loop_jitter_max_seconds: number;
};
flow: {
step_retry_attempts: number;
step_retry_delay_base: number;
step_retry_delay_cap: number;
outer_retry_attempts: number;
oauth_local_retry_attempts: number;
transient_markers: string;
register_otp_validate_order: string;
oauth_otp_validate_order: string;
oauth_password_phone_action: string;
oauth_otp_phone_action: string;
};
registration: {
entry_mode: string;
entry_mode_fallback: boolean;
chatgpt_base: string;
register_create_account_phone_action: string;
phone_verification_markers: string;
};
oauth: {
issuer: string;
client_id: string;
redirect_uri: string;
retry_attempts: number;
retry_backoff_base: number;
retry_backoff_max: number;
otp_timeout_seconds: number;
otp_poll_interval_seconds: number;
};
output: {
accounts_file: string;
csv_file: string;
ak_file: string;
rk_file: string;
save_local: boolean;
};
};
export type RuntimeStatusResponse = {
running: boolean;
run_mode?: string;
loop_running?: boolean;
loop_next_check_in_seconds?: number | null;
phase: string;
message: string;
available_candidates: number | null;
available_candidates_error?: string;
completed: number;
total: number;
percent: number;
stats: StatItem[];
single_account_timing?: SingleAccountTimingResponse;
logs: LogLine[];
last_log_path?: string;
};
export type StartRuntimeResponse = {
ok: boolean;
started: boolean;
pid?: number;
mode?: string;
message: string;
};
export type StopRuntimeResponse = {
ok: boolean;
stopped: boolean;
message: string;
};
export type AuthState = {
token: string;
};

View File

@@ -0,0 +1,63 @@
export type SelectOption = {
label: string;
value: string;
};
export type ConfigField = {
key: string;
label: string;
type: "text" | "textarea" | "password" | "number" | "checkbox" | "select";
value: string | number | boolean;
sensitive?: boolean;
options?: SelectOption[];
hint?: string;
};
export type ConfigSection = {
key: string;
label: string;
columns?: 1 | 2 | 3;
fields: ConfigField[];
};
export type StatItem = {
label: string;
value: number;
icon: string;
tone: "success" | "danger" | "pending";
};
export type LogLine = {
id: string;
prefix: string;
timestamp: string;
message: string;
tone: "muted" | "info" | "success" | "warning" | "danger";
};
export type MonitorState = {
running: boolean;
runMode?: string;
loopRunning?: boolean;
loopNextCheckInSeconds?: number | null;
phase: string;
message: string;
availableCandidates: number | null;
availableCandidatesError?: string;
completed: number;
total: number;
percent: number;
stats: StatItem[];
singleAccountTiming: {
latestRegSeconds: number | null;
latestOauthSeconds: number | null;
latestTotalSeconds: number | null;
recentAvgRegSeconds: number | null;
recentAvgOauthSeconds: number | null;
recentAvgTotalSeconds: number | null;
recentSlowCount: number;
sampleSize: number;
windowSize: number;
};
logs: LogLine[];
};

24
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": false,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"jsxImportSource": "preact",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

16
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from "vite";
import preact from "@preact/preset-vite";
export default defineConfig({
plugins: [preact()],
server: {
host: "127.0.0.1",
port: 8173,
proxy: {
"/api": {
target: "http://127.0.0.1:8318",
changeOrigin: true,
},
},
},
});

BIN
logs/.DS_Store vendored Normal file

Binary file not shown.

BIN
output_fixed/.DS_Store vendored Normal file

Binary file not shown.

BIN
output_tokens/.DS_Store vendored Normal file

Binary file not shown.

6
package-lock.json generated Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "gpt-register-oss",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

14
requirements.txt Normal file
View File

@@ -0,0 +1,14 @@
aiohappyeyeballs==2.6.1
aiohttp==3.13.3
aiosignal==1.4.0
attrs==25.4.0
certifi==2026.2.25
charset-normalizer==3.4.4
frozenlist==1.8.0
idna==3.11
multidict==6.7.1
propcache==0.4.1
requests==2.32.5
typing_extensions==4.15.0
urllib3==2.6.3
yarl==1.22.0

BIN
tests/.DS_Store vendored Normal file

Binary file not shown.

File diff suppressed because it is too large Load Diff