first commit
This commit is contained in:
2
.env.example
Normal file
2
.env.example
Normal 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
144
README.md
Normal 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
170
README_v1.md
Normal 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
736
api_server.py
Normal 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
5413
auto_pool_maintainer.py
Normal file
File diff suppressed because it is too large
Load Diff
102
config.example.json
Normal file
102
config.example.json
Normal 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
553
dev_services.ps1
Normal 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
398
dev_services.sh
Normal 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
35
docker-compose.yml
Normal 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
BIN
frontend/.DS_Store
vendored
Normal file
Binary file not shown.
18
frontend/Dockerfile
Normal file
18
frontend/Dockerfile
Normal 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
65
frontend/README.md
Normal 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
BIN
frontend/dist/.DS_Store
vendored
Normal file
Binary file not shown.
1
frontend/dist/assets/index-CraRoSIX.css
vendored
Normal file
1
frontend/dist/assets/index-CraRoSIX.css
vendored
Normal file
File diff suppressed because one or more lines are too long
2
frontend/dist/assets/index-DPSNYdMF.js
vendored
Normal file
2
frontend/dist/assets/index-DPSNYdMF.js
vendored
Normal file
File diff suppressed because one or more lines are too long
13
frontend/dist/index.html
vendored
Normal file
13
frontend/dist/index.html
vendored
Normal 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
12
frontend/index.html
Normal 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
20
frontend/nginx.conf
Normal 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
19
frontend/package.json
Normal 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
1291
frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
2
frontend/pnpm-workspace.yaml
Normal file
2
frontend/pnpm-workspace.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
BIN
frontend/src/.DS_Store
vendored
Normal file
BIN
frontend/src/.DS_Store
vendored
Normal file
Binary file not shown.
284
frontend/src/app.tsx
Normal file
284
frontend/src/app.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
322
frontend/src/components/config-panel.tsx
Normal file
322
frontend/src/components/config-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
frontend/src/components/login-gate.tsx
Normal file
39
frontend/src/components/login-gate.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
frontend/src/components/monitor-panel.tsx
Normal file
110
frontend/src/components/monitor-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
frontend/src/components/terminal-log.tsx
Normal file
39
frontend/src/components/terminal-log.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
580
frontend/src/lib/config-schema.ts
Normal file
580
frontend/src/lib/config-schema.ts
Normal 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
7
frontend/src/main.tsx
Normal 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
51
frontend/src/mock/data.ts
Normal 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" },
|
||||
],
|
||||
};
|
||||
127
frontend/src/services/api.ts
Normal file
127
frontend/src/services/api.ts
Normal 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: "{}",
|
||||
});
|
||||
}
|
||||
3
frontend/src/services/events.ts
Normal file
3
frontend/src/services/events.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function connectLogStream(): EventSource {
|
||||
return new EventSource("/api/logs");
|
||||
}
|
||||
29
frontend/src/styles/base.css
Normal file
29
frontend/src/styles/base.css
Normal 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;
|
||||
}
|
||||
816
frontend/src/styles/layout.css
Normal file
816
frontend/src/styles/layout.css
Normal 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;
|
||||
}
|
||||
}
|
||||
16
frontend/src/styles/tokens.css
Normal file
16
frontend/src/styles/tokens.css
Normal 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
142
frontend/src/types/api.ts
Normal 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;
|
||||
};
|
||||
63
frontend/src/types/runtime.ts
Normal file
63
frontend/src/types/runtime.ts
Normal 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
24
frontend/tsconfig.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
9
frontend/tsconfig.node.json
Normal file
9
frontend/tsconfig.node.json
Normal 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
16
frontend/vite.config.ts
Normal 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
BIN
logs/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
output_fixed/.DS_Store
vendored
Normal file
BIN
output_fixed/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
output_tokens/.DS_Store
vendored
Normal file
BIN
output_tokens/.DS_Store
vendored
Normal file
Binary file not shown.
6
package-lock.json
generated
Normal file
6
package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "gpt-register-oss",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
14
requirements.txt
Normal file
14
requirements.txt
Normal 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
BIN
tests/.DS_Store
vendored
Normal file
Binary file not shown.
1250
tests/test_flow_minimal_refactor.py
Normal file
1250
tests/test_flow_minimal_refactor.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user