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