feat: 添加多个新项目及更新文档

- 新增 GPT_register+duckmail+CPA+autouploadsub2api (DuckMail + OAuth + Sub2Api 注册工具)
- 新增 team_all-in-one (ChatGPT Team 一键注册工具)
- 新增 Code-Patch 项目
- 新增 ABCard 子模块 (ChatGPT Business/Plus 自动开通)
- 新增 cloudflare_temp_email 子模块 (Cloudflare 临时邮箱服务)
- 添加 .gitignore 文件
- 更新 README.md (新增项目导航、子模块说明)
- 添加 CHANGELOG.md
This commit is contained in:
adminlove520
2026-03-19 23:25:34 +08:00
parent 69ba5ab3f5
commit cc691b9fca
43 changed files with 19047 additions and 10 deletions

23
Code-Patch/.env.example Normal file
View File

@@ -0,0 +1,23 @@
# Ports
# If 5173 is occupied, change this (e.g. 5174)
FRONTEND_PORT=5174
# Backend listen address/port
BACKEND_HOST=127.0.0.1
BACKEND_PORT=8008
# Optional: override backend targets used by the frontend dev proxy
# BACKEND_ORIGIN=http://127.0.0.1:8000
# BACKEND_WS_ORIGIN=ws://127.0.0.1:8000
# Optional: override CORS allowlist (comma-separated)
# FRONTEND_ORIGINS=http://localhost:5174,http://127.0.0.1:5174
# Proxy pool (comma-separated, used as default for register/check/import)
# Falls back to system proxy (HTTPS_PROXY/HTTP_PROXY) if not set
# PROXY_POOL=http://127.0.0.1:10808,socks5://127.0.0.1:10810
# Mail worker (required for register-related features)
# WORKER_URL=
# EMAIL_DOMAIN=
# ADMIN_AUTH=

1247
Code-Patch/.gitignore vendored Normal file

File diff suppressed because it is too large Load Diff

75
Code-Patch/README.md Normal file
View File

@@ -0,0 +1,75 @@
# Code-Patch
## 配置端口(.env
`.env` 在项目根目录。推荐先复制一份示例:
```bash
cp .env.example .env
```
常用配置项:
- `FRONTEND_PORT`:前端端口(默认 `5173`,如果被占用就改成 `5174/5175/...`
- `BACKEND_HOST` / `APP_HOST`:后端监听地址(推荐 `127.0.0.1`
- `BACKEND_PORT` / `APP_PORT`:后端端口(默认 `8000`
修改端口后,需要把前后端都重启一次(后端的 CORS 白名单依赖 `FRONTEND_PORT`)。
## 启动
### 后端
```bash
python3 -m venv backend/.venv
backend/.venv/bin/pip install fastapi "uvicorn[standard]" pydantic python-dotenv curl-cffi
cd backend
./.venv/bin/python main.py
```
后端接口文档:`http://127.0.0.1:8000/docs`(端口以 `.env` 为准)
### 前端
```bash
cd frontend
npm install
npm run dev
```
## 重启命令(分别)
### 重启后端
```bash
# 停止占用端口的进程(把 8000 换成你的 BACKEND_PORT
PID=$(lsof -tiTCP:8000 -sTCP:LISTEN) && kill $PID
# 重新启动
cd backend
./.venv/bin/python main.py
```
### 重启前端
```bash
# 停止占用端口的进程(把 5174 换成你的 FRONTEND_PORT默认 5173
PID=$(lsof -tiTCP:5174 -sTCP:LISTEN) && kill $PID
cd frontend
npm run dev
```
### 核心功能
- 批量注册 — 代理池 + 可调并发,实时 WebSocket 进度推送,支持暂停 / 继续
- 账号管理 — 多维度筛选(关键词 / 状态 / 存活),支持 CSV 导入导出
- 存活检测 — 一键批量检活,自动标记存活 / 死亡状态
- Token 自动刷新 — 后台定时刷新 refresh_token保持账号活跃
- 任务中心 — 支持单次 / 每日定时任务,覆盖注册、检活、清理三种任务类型
- 代理检测 — 注册前可预先检测代理可用性及出口 IP

View File

@@ -0,0 +1,106 @@
import os
import sqlite3
from contextlib import contextmanager
# accounts.db 放在根目录backend/ 的上一级)
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DB_PATH = os.path.join(ROOT_DIR, "accounts.db")
SCHEMA = """
PRAGMA journal_mode=WAL;
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL,
proxies TEXT NOT NULL,
proxy_count INTEGER NOT NULL,
requested INTEGER NOT NULL,
concurrency INTEGER NOT NULL DEFAULT 1,
success INTEGER NOT NULL DEFAULT 0,
failed INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'running'
);
CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL REFERENCES sessions(id),
created_at TEXT NOT NULL,
email TEXT,
account_id TEXT,
refresh_token TEXT,
id_token TEXT,
access_token TEXT,
expired TEXT,
last_refresh TEXT,
proxy_used TEXT,
error TEXT
);
CREATE INDEX IF NOT EXISTS idx_accounts_session ON accounts(session_id);
CREATE TABLE IF NOT EXISTS schedules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
task_type TEXT NOT NULL DEFAULT 'register',
proxies TEXT NOT NULL,
target INTEGER NOT NULL DEFAULT 0,
concurrency INTEGER NOT NULL DEFAULT 3,
check_filter TEXT NOT NULL DEFAULT 'all',
schedule_type TEXT NOT NULL DEFAULT 'once',
run_time TEXT NOT NULL,
next_run TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
last_run_at TEXT,
last_session_id INTEGER
);
CREATE TABLE IF NOT EXISTS schedule_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
schedule_id INTEGER NOT NULL REFERENCES schedules(id),
started_at TEXT NOT NULL,
finished_at TEXT,
task_type TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'running',
detail TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_schedule_runs_sid ON schedule_runs(schedule_id);
"""
def init_db():
with get_conn() as conn:
conn.executescript(SCHEMA)
# 迁移:为旧数据库添加新字段
for col in ["alive TEXT", "checked_at TEXT", "plan_type TEXT",
"auto_refresh INTEGER DEFAULT 1", "last_auto_refresh TEXT",
"exit_ip TEXT", "usage_json TEXT"]:
try:
conn.execute(f"ALTER TABLE accounts ADD COLUMN {col}")
except Exception:
pass # 列已存在
# 确保所有账号都开启自动刷新
conn.execute("UPDATE accounts SET auto_refresh=1 WHERE auto_refresh=0 OR auto_refresh IS NULL")
# 迁移 schedules 新字段
for col in ["task_type TEXT DEFAULT 'register'", "check_filter TEXT DEFAULT 'all'",
"check_limit INTEGER DEFAULT 0", "auto_clean INTEGER DEFAULT 0"]:
try:
conn.execute(f"ALTER TABLE schedules ADD COLUMN {col}")
except Exception:
pass
@contextmanager
def get_conn():
conn = sqlite3.connect(DB_PATH, check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()

1379
Code-Patch/backend/main.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,566 @@
import base64
import hashlib
import json
import logging
import os
import random
import re
import secrets
import string
import time
import urllib
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from typing import Any, Dict
from curl_cffi import requests
from dotenv import load_dotenv
logger = logging.getLogger("uvicorn.error")
# .env 在根目录backend/ 的上一级)
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
load_dotenv(os.path.join(ROOT_DIR, ".env"))
WORKER_URL = os.getenv("WORKER_URL", "").strip()
EMAIL_DOMAIN = os.getenv("EMAIL_DOMAIN", "").strip()
ADMIN_AUTH = os.getenv("ADMIN_AUTH", "").strip()
# ---------------------------------------------------------------------------
# 临时邮箱
# ---------------------------------------------------------------------------
def _random_profile() -> str:
"""生成随机姓名 + 成年出生日期18-55岁的 JSON 字符串。"""
first = ''.join(random.choices(string.ascii_lowercase, k=random.randint(4, 8))).capitalize()
last = ''.join(random.choices(string.ascii_lowercase, k=random.randint(4, 8))).capitalize()
name = f"{first} {last}"
today = time.gmtime()
age = random.randint(18, 55)
year = today.tm_year - age
month = random.randint(1, 12)
# 确保出生日期不超过今天
max_day = [31,28,31,30,31,30,31,31,30,31,30,31][month - 1]
if year % 4 == 0 and (year % 100 != 0 or year % 400 == 0):
if month == 2:
max_day = 29
day = random.randint(1, max_day)
birthdate = f"{year}-{month:02d}-{day:02d}"
return json.dumps({"name": name, "birthdate": birthdate}, separators=(",", ":"))
def generate_random_name() -> str:
letters1 = ''.join(random.choices(string.ascii_lowercase, k=5))
numbers = ''.join(random.choices(string.digits, k=random.randint(1, 3)))
letters2 = ''.join(random.choices(string.ascii_lowercase, k=random.randint(1, 3)))
return letters1 + numbers + letters2
def get_email() -> tuple[str, str]:
if not WORKER_URL or not EMAIL_DOMAIN or not ADMIN_AUTH:
raise RuntimeError("Missing env: WORKER_URL / EMAIL_DOMAIN / ADMIN_AUTH (set them in project root .env)")
name = generate_random_name()
res = requests.post(
f"{WORKER_URL}/admin/new_address",
json={"enablePrefix": True, "name": name, "domain": EMAIL_DOMAIN},
headers={"x-admin-auth": ADMIN_AUTH, "Content-Type": "application/json"}
)
logger.info("get_email status=%s body=%s", res.status_code, res.text[:200])
if res.status_code != 200:
raise RuntimeError(f"邮件服务返回 {res.status_code}: {res.text[:200]}")
data = res.json()
return data["address"], data["jwt"]
def get_oai_code(email: str, jwt: str) -> str:
if not WORKER_URL or not ADMIN_AUTH:
raise RuntimeError("Missing env: WORKER_URL / ADMIN_AUTH (set them in project root .env)")
regex = r"(?<!\d)(\d{6})(?!\d)"
for _ in range(20):
res = requests.get(
f"{WORKER_URL}/admin/mails",
headers={"x-admin-auth": ADMIN_AUTH},
params={"limit": "20", "offset": "0", "address": email}
)
mails = res.json().get("results", [])
for mail in mails:
if "openai" in mail.get("source", ""):
m = re.search(regex, mail.get("subject", ""))
if m:
return m.group(1)
m = re.search(regex, mail.get("raw", ""))
if m:
return m.group(1)
time.sleep(3)
# ---------------------------------------------------------------------------
# OAuth / PKCE 工具
# ---------------------------------------------------------------------------
AUTH_URL = "https://auth.openai.com/oauth/authorize"
TOKEN_URL = "https://auth.openai.com/oauth/token"
CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
DEFAULT_REDIRECT_URI = "http://localhost:1455/auth/callback"
DEFAULT_SCOPE = "openid email profile offline_access"
def _b64url_no_pad(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
def _sha256_b64url_no_pad(s: str) -> str:
return _b64url_no_pad(hashlib.sha256(s.encode("ascii")).digest())
def _random_state(nbytes: int = 16) -> str:
return secrets.token_urlsafe(nbytes)
def _pkce_verifier() -> str:
return secrets.token_urlsafe(64)
def _parse_callback_url(callback_url: str) -> Dict[str, str]:
candidate = callback_url.strip()
if not candidate:
return {"code": "", "state": "", "error": "", "error_description": ""}
if "://" not in candidate:
if candidate.startswith("?"):
candidate = f"http://localhost{candidate}"
elif any(ch in candidate for ch in "/?#") or ":" in candidate:
candidate = f"http://{candidate}"
elif "=" in candidate:
candidate = f"http://localhost/?{candidate}"
parsed = urllib.parse.urlparse(candidate)
query = urllib.parse.parse_qs(parsed.query, keep_blank_values=True)
fragment = urllib.parse.parse_qs(parsed.fragment, keep_blank_values=True)
for key, values in fragment.items():
if key not in query or not query[key] or not (query[key][0] or "").strip():
query[key] = values
def get1(k: str) -> str:
v = query.get(k, [""])
return (v[0] or "").strip()
code = get1("code")
state = get1("state")
error = get1("error")
error_description = get1("error_description")
if code and not state and "#" in code:
code, state = code.split("#", 1)
if not error and error_description:
error, error_description = error_description, ""
return {"code": code, "state": state, "error": error, "error_description": error_description}
def _jwt_claims_no_verify(id_token: str) -> Dict[str, Any]:
if not id_token or id_token.count(".") < 2:
return {}
payload_b64 = id_token.split(".")[1]
pad = "=" * ((4 - (len(payload_b64) % 4)) % 4)
try:
payload = base64.urlsafe_b64decode((payload_b64 + pad).encode("ascii"))
return json.loads(payload.decode("utf-8"))
except Exception:
return {}
def _to_int(v: Any) -> int:
try:
return int(v)
except (TypeError, ValueError):
return 0
def _post_form(url: str, data: Dict[str, str], timeout: int = 30) -> Dict[str, Any]:
body = urllib.parse.urlencode(data).encode("utf-8")
req = urllib.request.Request(
url, data=body, method="POST",
headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read()
if resp.status != 200:
raise RuntimeError(f"token exchange failed: {resp.status}: {raw.decode('utf-8', 'replace')}")
return json.loads(raw.decode("utf-8"))
except urllib.error.HTTPError as exc:
raw = exc.read()
raise RuntimeError(f"token exchange failed: {exc.code}: {raw.decode('utf-8', 'replace')}") from exc
@dataclass(frozen=True)
class OAuthStart:
auth_url: str
state: str
code_verifier: str
redirect_uri: str
def generate_oauth_url(
*, redirect_uri: str = DEFAULT_REDIRECT_URI, scope: str = DEFAULT_SCOPE
) -> OAuthStart:
state = _random_state()
code_verifier = _pkce_verifier()
code_challenge = _sha256_b64url_no_pad(code_verifier)
params = {
"client_id": CLIENT_ID, "response_type": "code",
"redirect_uri": redirect_uri, "scope": scope,
"state": state, "code_challenge": code_challenge,
"code_challenge_method": "S256", "prompt": "login",
"id_token_add_organizations": "true", "codex_cli_simplified_flow": "true",
}
auth_url = f"{AUTH_URL}?{urllib.parse.urlencode(params)}"
return OAuthStart(auth_url=auth_url, state=state, code_verifier=code_verifier, redirect_uri=redirect_uri)
def submit_callback_url(
*, callback_url: str, expected_state: str, code_verifier: str,
redirect_uri: str = DEFAULT_REDIRECT_URI
) -> str:
cb = _parse_callback_url(callback_url)
if cb["error"]:
raise RuntimeError(f"oauth error: {cb['error']}: {cb['error_description']}".strip())
if not cb["code"]:
raise ValueError("callback url missing ?code=")
if not cb["state"]:
raise ValueError("callback url missing ?state=")
if cb["state"] != expected_state:
raise ValueError("state mismatch")
token_resp = _post_form(TOKEN_URL, {
"grant_type": "authorization_code", "client_id": CLIENT_ID,
"code": cb["code"], "redirect_uri": redirect_uri, "code_verifier": code_verifier,
})
access_token = (token_resp.get("access_token") or "").strip()
refresh_token = (token_resp.get("refresh_token") or "").strip()
id_token = (token_resp.get("id_token") or "").strip()
expires_in = _to_int(token_resp.get("expires_in"))
claims = _jwt_claims_no_verify(id_token)
email = str(claims.get("email") or "").strip()
auth_claims = claims.get("https://api.openai.com/auth") or {}
account_id = str(auth_claims.get("chatgpt_account_id") or "").strip()
now = int(time.time())
expired_rfc3339 = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now + max(expires_in, 0)))
now_rfc3339 = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now))
return json.dumps({
"id_token": id_token, "access_token": access_token,
"refresh_token": refresh_token, "account_id": account_id,
"last_refresh": now_rfc3339, "email": email,
"type": "codex", "expired": expired_rfc3339,
}, ensure_ascii=False, separators=(",", ":"))
# ---------------------------------------------------------------------------
# 反检测工具
# ---------------------------------------------------------------------------
_IMPERSONATE_POOL = [
"chrome110", "chrome116", "chrome120", "chrome123", "chrome124",
"chrome131", "edge101",
"safari15_5", "safari17_0",
]
# Chrome/Edge Client Hints — 缺少这些头是很大的指纹特征
_CLIENT_HINTS = {
"chrome110": ('"Chromium";v="110", "Google Chrome";v="110", "Not_A Brand";v="24"', "Windows"),
"chrome116": ('"Chromium";v="116", "Google Chrome";v="116", "Not_A Brand";v="24"', "Windows"),
"chrome120": ('"Chromium";v="120", "Google Chrome";v="120", "Not_A Brand";v="24"', "macOS"),
"chrome123": ('"Chromium";v="123", "Google Chrome";v="123", "Not_A Brand";v="24"', "Windows"),
"chrome124": ('"Chromium";v="124", "Google Chrome";v="124", "Not_A Brand";v="24"', "macOS"),
"chrome131": ('"Chromium";v="131", "Google Chrome";v="131", "Not_A Brand";v="24"', "Windows"),
"edge101": ('"Chromium";v="101", "Microsoft Edge";v="101", "Not_A Brand";v="99"', "Windows"),
# Safari 不发送 Client Hints
}
_ACCEPT_LANGUAGES = [
"en-US,en;q=0.9",
"en-GB,en;q=0.9",
"en-US,en;q=0.9,ja;q=0.8",
"en,en-US;q=0.9",
"en-US,en;q=0.8,zh-CN;q=0.7",
]
def _human_delay(lo: float = 0.5, hi: float = 2.5):
"""模拟人类操作间隔,带轻微随机抖动。"""
base = random.uniform(lo, hi)
# 5% 概率额外停顿,模拟真人偶尔走神
if random.random() < 0.05:
base += random.uniform(0.5, 1.5)
time.sleep(base)
def _make_session(proxy: str) -> requests.Session:
"""创建带随机浏览器指纹的 session包含 Client Hints。"""
fp = random.choice(_IMPERSONATE_POOL)
s = requests.Session(
proxies={"http": proxy, "https": proxy},
impersonate=fp,
)
headers = {"accept-language": random.choice(_ACCEPT_LANGUAGES)}
# Chrome/Edge 需要 Client Hints
if fp in _CLIENT_HINTS:
ua_str, platform = _CLIENT_HINTS[fp]
headers["sec-ch-ua"] = ua_str
headers["sec-ch-ua-mobile"] = "?0"
headers["sec-ch-ua-platform"] = f'"{platform}"'
s.headers.update(headers)
return s
def check_proxy(proxy: str, timeout: int = 8) -> tuple[bool, str]:
"""
检测代理是否可用(仅验证连通性)。
地区检查由注册流程中的 _check_loc 负责,因为代理池每次出口 IP 可能不同。
返回: (可用, 信息)
"""
try:
resp = requests.get(
"https://cloudflare.com/cdn-cgi/trace",
proxies={"http": proxy, "https": proxy},
timeout=timeout,
impersonate=random.choice(_IMPERSONATE_POOL),
)
if resp.status_code != 200:
return False, f"HTTP {resp.status_code}"
loc_m = re.search(r"^loc=(.+)$", resp.text, re.MULTILINE)
loc = loc_m.group(1).strip() if loc_m else "unknown"
return True, loc
except Exception as e:
return False, str(e)[:80]
# ---------------------------------------------------------------------------
# 主入口
# ---------------------------------------------------------------------------
def run(proxy: str) -> str:
s = _make_session(proxy)
trace_resp = s.get("https://cloudflare.com/cdn-cgi/trace", timeout=10)
logger.info("trace status=%s len=%s", trace_resp.status_code, len(trace_resp.text))
trace = trace_resp.text
ip_re = re.search(r"^ip=(.+)$", trace, re.MULTILINE)
loc_re = re.search(r"^loc=(.+)$", trace, re.MULTILINE)
loc = loc_re.group(1) if loc_re else None
exit_ip = ip_re.group(1) if ip_re else None
logger.info("proxy=%s exit_ip=%s loc=%s", proxy, exit_ip, loc)
if loc in ("CN", "HK"):
raise RuntimeError(f"检查代理哦w (loc={loc}, ip={exit_ip})")
email, jwt = get_email()
logger.info("email=%s", email)
oauth = generate_oauth_url()
_human_delay(0.3, 1.0)
s.get(oauth.auth_url)
did = s.cookies.get("oai-did")
_human_delay(0.5, 1.5)
signup_body = f'{{"username":{{"value":"{email}","kind":"email"}},"screen_hint":"signup"}}'
sen_req_body = f'{{"p":"","id":"{did}","flow":"authorize_continue"}}'
sen_resp = s.post(
"https://sentinel.openai.com/backend-api/sentinel/req",
headers={
"origin": "https://sentinel.openai.com",
"referer": "https://sentinel.openai.com/backend-api/sentinel/frame.html?sv=20260219f9f6",
"content-type": "text/plain;charset=UTF-8",
},
data=sen_req_body,
)
logger.info("sentinel status=%s body=%s", sen_resp.status_code, sen_resp.text[:200])
sentinel_token = sen_resp.json()["token"]
sentinel = f'{{"p": "", "t": "", "c": "{sentinel_token}", "id": "{did}", "flow": "authorize_continue"}}'
_human_delay(0.8, 2.0)
signup_resp = s.post(
"https://auth.openai.com/api/accounts/authorize/continue",
headers={"referer": "https://auth.openai.com/create-account", "accept": "application/json",
"content-type": "application/json", "openai-sentinel-token": sentinel},
data=signup_body,
)
logger.info("signup status=%s body=%s", signup_resp.status_code, signup_resp.text[:200])
_human_delay(0.5, 1.5)
otp_resp = s.post(
"https://auth.openai.com/api/accounts/passwordless/send-otp",
headers={"referer": "https://auth.openai.com/create-account/password",
"accept": "application/json", "content-type": "application/json"},
)
logger.info("otp status=%s", otp_resp.status_code)
code = get_oai_code(email, jwt)
logger.info("otp code=%s", code)
_human_delay(1.0, 3.0)
code_resp = s.post(
"https://auth.openai.com/api/accounts/email-otp/validate",
headers={"referer": "https://auth.openai.com/email-verification", "accept": "application/json",
"content-type": "application/json"},
data=f'{{"code":"{code}"}}',
)
logger.info("validate status=%s", code_resp.status_code)
_human_delay(1.0, 3.0)
create_resp = s.post(
"https://auth.openai.com/api/accounts/create_account",
headers={"referer": "https://auth.openai.com/about-you", "accept": "application/json",
"content-type": "application/json"},
data=_random_profile(),
)
logger.info("create status=%s", create_resp.status_code)
if create_resp.status_code != 200:
logger.warning("create failed: %s", create_resp.text[:200])
return None
auth = s.cookies.get("oai-client-auth-session")
auth = json.loads(base64.b64decode(auth.split(".")[0]))
workspace_id = auth["workspaces"][0]["id"]
_human_delay(0.5, 1.5)
select_resp = s.post(
"https://auth.openai.com/api/accounts/workspace/select",
headers={"referer": "https://auth.openai.com/sign-in-with-chatgpt/codex/consent",
"content-type": "application/json"},
data=f'{{"workspace_id":"{workspace_id}"}}',
)
logger.info("select status=%s", select_resp.status_code)
continue_url = select_resp.json()["continue_url"]
r = s.get(continue_url, allow_redirects=False)
r = s.get(r.headers.get("Location"), allow_redirects=False)
r = s.get(r.headers.get("Location"), allow_redirects=False)
cbk = r.headers.get("Location")
result_str = submit_callback_url(
callback_url=cbk,
code_verifier=oauth.code_verifier,
redirect_uri=oauth.redirect_uri,
expected_state=oauth.state,
)
# 注入出口 IP 到返回数据
result = json.loads(result_str)
result["exit_ip"] = exit_ip
return json.dumps(result, ensure_ascii=False, separators=(",", ":"))
# ---------------------------------------------------------------------------
# 存活检测
# ---------------------------------------------------------------------------
CHATGPT_API_BASE = "https://chatgpt.com/backend-api"
CODEX_USAGE_URL = f"{CHATGPT_API_BASE}/wham/usage"
_CODEX_VERSIONS = ["0.74.0", "0.75.0", "0.76.0", "0.77.0", "0.78.0"]
_CODEX_PLATFORMS = [
"(Debian 13.0.0; x86_64) WindowsTerminal",
"(Ubuntu 22.04; x86_64) WindowsTerminal",
"(macOS 14.5; arm64) Terminal",
"(Windows 11; x86_64) WindowsTerminal",
"(Debian 12.0.0; x86_64) tmux",
]
def _codex_ua() -> str:
ver = random.choice(_CODEX_VERSIONS)
plat = random.choice(_CODEX_PLATFORMS)
return f"codex_cli_rs/{ver} {plat}"
def check_alive(refresh_token: str, proxy: str) -> tuple:
"""
三步验证账号存活:
1. 用 refresh_token 换新的 access_token
2. 从 id_token 中解析 account_id
3. 用 access_token + account_id 调用 /backend-api/wham/usage 检查配额
返回: (status, access_token, refresh_token, id_token, plan_type, expires_at, usage_json)
status: 'alive' | 'dead' | 'error'
"""
s = _make_session(proxy)
# Step 1: 刷新 token
try:
resp = s.post(
TOKEN_URL,
data={
"grant_type": "refresh_token",
"client_id": CLIENT_ID,
"refresh_token": refresh_token,
},
headers={"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json"},
timeout=20,
)
if resp.status_code in (400, 401):
return "dead", None, None, None, None, None, None
if resp.status_code != 200:
return "error", None, None, None, None, None, None
token_data = resp.json()
new_access = token_data.get("access_token")
new_refresh = token_data.get("refresh_token")
new_id = token_data.get("id_token")
if not new_access:
return "dead", None, None, None, None, None, None
except Exception:
return "error", None, None, None, None, None, None
# Step 2: 从 id_token 解析 account_id
account_id = ""
if new_id:
claims = _jwt_claims_no_verify(new_id)
auth_claims = claims.get("https://api.openai.com/auth") or {}
account_id = str(auth_claims.get("chatgpt_account_id") or "").strip()
_human_delay(0.3, 1.0)
# Step 3: 用 wham/usage 接口检查配额状态
try:
usage_headers = {
"Content-Type": "application/json",
"User-Agent": _codex_ua(),
"Authorization": f"Bearer {new_access}",
}
if account_id:
usage_headers["Chatgpt-Account-Id"] = account_id
usage_resp = s.get(
CODEX_USAGE_URL,
headers=usage_headers,
timeout=20,
)
if usage_resp.status_code in (401, 403):
return "dead", None, None, None, None, None, None
if usage_resp.status_code != 200:
return "error", new_access, new_refresh, new_id, None, None, None
data = usage_resp.json()
plan_type = data.get("plan_type")
usage_json = json.dumps(data, ensure_ascii=False, separators=(",", ":"))
# 从 token expires_in 计算过期时间
expires_in = _to_int(token_data.get("expires_in"))
now = int(time.time())
expires_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now + max(expires_in, 0)))
return "alive", new_access, new_refresh, new_id, plan_type, expires_at, usage_json
except Exception:
# wham/usage 失败但 token 刷新成功,保守标记为 error
return "error", new_access, new_refresh, new_id, None, None, None

View File

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

1572
Code-Patch/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
{
"name": "code-patch",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.3.0",
"element-plus": "^2.7.0",
"@element-plus/icons-vue": "^2.3.0",
"axios": "^1.7.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"vite": "^5.2.0"
}
}

View File

@@ -0,0 +1,95 @@
<template>
<el-container class="app-layout">
<el-aside class="sidebar" width="220px">
<div class="sidebar-logo">
<span class="logo-icon"></span>
<span class="logo-text">Code Patch</span>
</div>
<el-menu
:router="true"
:default-active="route.path"
class="sidebar-menu"
>
<el-menu-item index="/register">
<el-icon><UserFilled /></el-icon>
<span>批量注册</span>
</el-menu-item>
<el-menu-item index="/sessions">
<el-icon><List /></el-icon>
<span>注册记录</span>
</el-menu-item>
<el-menu-item index="/accounts">
<el-icon><DataAnalysis /></el-icon>
<span>账号查询</span>
</el-menu-item>
<el-menu-item index="/schedules">
<el-icon><AlarmClock /></el-icon>
<span>任务中心</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-main class="page-main">
<router-view />
</el-main>
</el-container>
</template>
<script setup>
import { useRoute } from 'vue-router'
const route = useRoute()
</script>
<style>
* { box-sizing: border-box; }
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', sans-serif; }
</style>
<style scoped>
.app-layout {
height: 100vh;
overflow: hidden;
}
.sidebar {
background: var(--color-sidebar-bg);
height: 100vh;
display: flex;
flex-direction: column;
}
.sidebar-logo {
padding: 20px 16px 16px;
display: flex;
align-items: center;
gap: 8px;
border-bottom: 1px solid rgba(255, 255, 255, .06);
margin-bottom: 4px;
}
.logo-icon {
font-size: 20px;
}
.logo-text {
color: #fff;
font-size: 15px;
font-weight: 600;
letter-spacing: .5px;
}
.sidebar-menu {
background-color: var(--color-sidebar-bg) !important;
border-right: none !important;
--el-menu-text-color: var(--color-sidebar-text);
--el-menu-active-color: var(--color-sidebar-active);
--el-menu-hover-bg-color: var(--color-sidebar-hover);
--el-menu-bg-color: var(--color-sidebar-bg);
}
.page-main {
background: var(--color-page-bg);
padding: var(--space-xl);
overflow-y: auto;
}
</style>

View File

@@ -0,0 +1,104 @@
import axios from 'axios'
const http = axios.create({ baseURL: '/api', timeout: 30000 })
// Normalise error messages so callers can use err.message uniformly
http.interceptors.response.use(
r => r,
err => {
const msg = err.response?.data?.detail || err.message || '请求失败'
return Promise.reject(new Error(msg))
}
)
export const getSystemProxy = () => http.get('/system-proxy')
export const startSession = (data) => http.post('/sessions', data)
export const getSessions = () => http.get('/sessions')
export const getActiveSession = () => http.get('/sessions/active')
export const getAccounts = (params) => http.get('/accounts', { params })
export const getAccount = (id) => http.get(`/accounts/${id}`)
export function exportSessionUrl(sessionId) {
return `/api/sessions/${sessionId}/export`
}
export function exportAccountsUrl(params = {}) {
const qs = new URLSearchParams()
if (params.search) qs.set('search', params.search)
if (params.status) qs.set('status', params.status)
if (params.session_id) qs.set('session_id', params.session_id)
if (params.alive) qs.set('alive', params.alive)
const q = qs.toString()
return `/api/accounts/export${q ? '?' + q : ''}`
}
export const pauseSession = (id) => http.post(`/sessions/${id}/pause`)
export const resumeSession = (id) => http.post(`/sessions/${id}/resume`)
export const getSchedules = () => http.get('/schedules')
export const createSchedule = (data) => http.post('/schedules', data)
export const updateSchedule = (id, data) => http.put(`/schedules/${id}`, data)
export const toggleSchedule = (id) => http.put(`/schedules/${id}/toggle`)
export const deleteSchedule = (id) => http.delete(`/schedules/${id}`)
export const getScheduleRuns = (id) => http.get(`/schedules/${id}/runs`)
export const getAllRuns = (limit = 50) => http.get('/schedule-runs', { params: { limit } })
export const startCheckSession = (data) => http.post('/check-sessions', data)
export const importAccounts = (data) => http.post('/accounts/import', data)
export const deleteDeadAccounts = () => http.delete('/accounts/dead')
export const setAutoRefresh = (id, enabled) => http.put(`/accounts/${id}/auto-refresh`, null, { params: { enabled } })
/**
* Open a WebSocket for a registration session.
*/
export function openSessionWS(sessionId, { onSuccess, onFailed, onDone, onError } = {}) {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
const ws = new WebSocket(`${proto}//${location.host}/ws/sessions/${sessionId}`)
ws.onmessage = (e) => {
const msg = JSON.parse(e.data)
if (msg.type === 'success') onSuccess?.(msg)
else if (msg.type === 'failed') onFailed?.(msg)
else if (msg.type === 'done') onDone?.(msg)
// ignore 'ping'
}
ws.onerror = (e) => onError?.(e)
return ws
}
/**
* Open a WebSocket for a liveness check session.
*/
export function openCheckWS(checkId, { onResult, onDone, onError } = {}) {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
const ws = new WebSocket(`${proto}//${location.host}/ws/check/${checkId}`)
ws.onmessage = (e) => {
const msg = JSON.parse(e.data)
if (msg.type === 'result') onResult?.(msg)
else if (msg.type === 'done') onDone?.(msg)
// ignore 'ping'
}
ws.onerror = (e) => onError?.(e)
return ws
}
/**
* Open a WebSocket for an import session.
*/
export function openImportWS(importId, { onResult, onDone, onError } = {}) {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
const ws = new WebSocket(`${proto}//${location.host}/ws/sessions/${importId}`)
ws.onmessage = (e) => {
const msg = JSON.parse(e.data)
if (msg.type === 'success') onResult?.({ ...msg, alive: 'alive' })
else if (msg.type === 'failed') onResult?.({ ...msg, alive: 'dead' })
else if (msg.type === 'done') onDone?.(msg)
// ignore 'ping'
}
ws.onerror = (e) => onError?.(e)
return ws
}

View File

@@ -0,0 +1,57 @@
import { ref, reactive, computed } from 'vue'
import { openCheckWS } from '../api/index.js'
// 全局状态 — 模块级别,切换页面不会丢失
const checking = ref(false)
const checkProgress = reactive({ total: 0, done: 0, alive: 0, dead: 0, error: 0 })
let checkWs = null
let onDoneCallback = null
const checkPct = computed(() => {
if (!checkProgress.total) return 0
return Math.round((checkProgress.done / checkProgress.total) * 100)
})
function startCheck(checkId, total, { onResult, onDone } = {}) {
// 关闭之前的连接
checkWs?.close()
checking.value = true
checkProgress.total = total
checkProgress.done = 0
checkProgress.alive = 0
checkProgress.dead = 0
checkProgress.error = 0
onDoneCallback = onDone || null
checkWs = openCheckWS(checkId, {
onResult(msg) {
checkProgress.done++
if (msg.alive === 'alive') checkProgress.alive++
else if (msg.alive === 'dead') checkProgress.dead++
else checkProgress.error++
onResult?.(msg)
},
onDone(msg) {
checking.value = false
checkWs?.close()
checkWs = null
onDoneCallback?.(msg)
},
onError() {
checking.value = false
checkWs?.close()
checkWs = null
},
})
}
function stopCheck() {
checkWs?.close()
checkWs = null
checking.value = false
}
export function useCheckState() {
return { checking, checkProgress, checkPct, startCheck, stopCheck }
}

View File

@@ -0,0 +1,40 @@
import { onUnmounted } from 'vue'
/**
* Composable for managing a WebSocket connection.
* Automatically closes the socket when the component is unmounted.
*
* @param {string} path - Path template, e.g. '/ws/sessions/{id}'
* @param {object} handlers - { onMessage(msg), onError(e) }
* @returns {{ open(id: string|number): void, close(): void }}
*/
export function useWebSocket(path, handlers = {}) {
let ws = null
function open(id) {
close()
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
const url = `${proto}//${location.host}${path.replace('{id}', id)}`
ws = new WebSocket(url)
ws.onmessage = (e) => {
const msg = JSON.parse(e.data)
if (msg.type !== 'ping') {
handlers.onMessage?.(msg)
}
}
ws.onerror = (e) => handlers.onError?.(e)
}
function close() {
if (ws) {
ws.close()
ws = null
}
}
onUnmounted(close)
return { open, close }
}

View File

@@ -0,0 +1,18 @@
import './styles/variables.css'
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router/index.js'
const app = createApp(App)
app.use(ElementPlus)
app.use(router)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.mount('#app')

View File

@@ -0,0 +1,18 @@
import { createRouter, createWebHistory } from 'vue-router'
import RegisterView from '../views/RegisterView.vue'
import SessionsView from '../views/SessionsView.vue'
import AccountsView from '../views/AccountsView.vue'
import SchedulesView from '../views/SchedulesView.vue'
const routes = [
{ path: '/', redirect: '/register' },
{ path: '/register', component: RegisterView, name: 'Register' },
{ path: '/sessions', component: SessionsView, name: 'Sessions' },
{ path: '/accounts', component: AccountsView, name: 'Accounts' },
{ path: '/schedules', component: SchedulesView, name: 'Schedules' },
]
export default createRouter({
history: createWebHistory(),
routes,
})

View File

@@ -0,0 +1,35 @@
:root {
/* Sidebar */
--color-sidebar-bg: #1a2332;
--color-sidebar-text: #8b9ab4;
--color-sidebar-active: #ffffff;
--color-sidebar-hover: #2d3f52;
/* Page */
--color-page-bg: #f0f2f5;
/* Log / Terminal */
--color-log-bg: #0d1117;
--color-log-success: #3fb950;
--color-log-failed: #f85149;
--color-log-meta: #8b949e;
/* Typography */
--font-mono: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
/* Spacing */
--space-xs: 4px;
--space-sm: 8px;
--space-md: 12px;
--space-lg: 16px;
--space-xl: 24px;
--space-2xl: 32px;
/* Border radius */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
/* Shadows */
--shadow-card: 0 1px 3px rgba(0, 0, 0, .08), 0 1px 2px rgba(0, 0, 0, .06);
}

View File

@@ -0,0 +1,28 @@
/**
* Format an ISO timestamp to a human-readable local string.
* Returns '—' for null/undefined values.
*/
export function formatTime(iso) {
if (!iso) return '—'
return new Date(iso).toLocaleString('zh-CN', { hour12: false })
}
/**
* Map alive status value to an Element Plus tag type.
*/
export function aliveTagType(v) {
if (v === 'alive') return 'success'
if (v === 'dead') return 'danger'
if (v === 'error') return 'warning'
return 'info'
}
/**
* Map alive status value to a display label.
*/
export function aliveLabel(v) {
if (v === 'alive') return '存活'
if (v === 'dead') return '已失效'
if (v === 'error') return '检测异常'
return '未检测'
}

View File

@@ -0,0 +1,702 @@
<template>
<div>
<div class="page-header">
<span class="page-title">账号查询</span>
<div class="header-actions">
<el-button :icon="Download" :disabled="total === 0" @click="exportResults">导出账号</el-button>
<el-button type="success" @click="importVisible = true">
<el-icon><Upload /></el-icon>
导入账号
</el-button>
</div>
</div>
<!-- Search + Check tabs -->
<el-card class="panel-card">
<el-tabs v-model="activeTab">
<el-tab-pane label="搜索筛选" name="search">
<div class="filter-row">
<div class="filter-item flex-1">
<div class="field-label">关键词Email / Account ID</div>
<el-input
v-model="searchForm.keyword"
placeholder="输入邮箱或 Account ID..."
clearable
@keyup.enter="doSearch"
@clear="doSearch"
>
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
</div>
<div class="filter-item">
<div class="field-label">状态</div>
<el-select v-model="searchForm.status" placeholder="全部" clearable style="width:110px;">
<el-option label="成功" value="success" />
<el-option label="失败" value="failed" />
</el-select>
</div>
<div class="filter-item">
<div class="field-label">存活</div>
<el-select v-model="searchForm.alive" placeholder="全部" clearable style="width:110px;">
<el-option label="存活" value="alive" />
<el-option label="已死" value="dead" />
<el-option label="未检测" value="unchecked" />
<el-option label="检测异常" value="error" />
</el-select>
</div>
<div class="filter-item" style="align-self:flex-end;">
<el-button type="primary" :icon="Search" :loading="loading" @click="doSearch">查询</el-button>
</div>
</div>
<el-alert
v-if="searchForm.status === 'success'"
title="当前仅显示注册成功的账号"
type="info"
:closable="false"
show-icon
style="margin-top: 10px;"
/>
</el-tab-pane>
<el-tab-pane name="check">
<template #label>
检测存活
<el-badge
v-if="selectedIds.length > 0"
:value="selectedIds.length"
style="margin-left: 4px;"
/>
</template>
<div class="filter-row">
<div class="filter-item flex-1">
<div class="field-label">检测代理池每行一个</div>
<el-input
v-model="checkForm.proxies"
type="textarea"
:rows="2"
placeholder="http://127.0.0.1:10809"
class="mono-input"
/>
</div>
<div class="filter-item">
<div class="field-label">检测数量</div>
<el-input-number v-model="checkForm.limit" :min="0" :max="1000000" style="width:130px;" />
<div class="field-hint">0 = 全部</div>
</div>
<div class="filter-item">
<div class="field-label">检测范围</div>
<el-select v-model="checkForm.filter" style="width:150px;">
<el-option value="all" label="全部成功账号" />
<el-option value="alive" label="仅存活账号" />
<el-option value="unchecked" label="仅未检测账号" />
</el-select>
</div>
<div class="filter-item">
<div class="field-label">并发数</div>
<el-input-number v-model="checkForm.concurrency" :min="1" :max="1000" style="width:110px;" />
</div>
<div class="filter-item" style="align-self:flex-end; display:flex; flex-direction:column; gap:6px;">
<el-checkbox v-model="checkForm.autoClean">检测后清理失效账号</el-checkbox>
<el-button
type="warning"
:disabled="selectedIds.length === 0 || checking"
:loading="checking"
@click="startCheck(selectedIds)"
>
检测选中 {{ selectedIds.length > 0 ? `(${selectedIds.length})` : '' }}
</el-button>
<el-button
type="danger"
:disabled="checkTotal === 0 || checking"
:loading="checking"
@click="startCheckAll"
>
检测 {{ checkForm.limit > 0 ? checkForm.limit : '全部' }} ({{ checkTotal }})
</el-button>
</div>
</div>
<div v-if="checkProgress.total > 0" style="margin-top: 12px;">
<div class="progress-stats">
<span style="color:#67c23a;">存活 {{ checkProgress.alive }}</span>
<span style="color:#f56c6c;">已死 {{ checkProgress.dead }}</span>
<span style="color:#e6a23c;">异常 {{ checkProgress.error }}</span>
<span> {{ checkProgress.total }}</span>
</div>
<el-progress :percentage="checkPct" :status="checking ? '' : 'success'" :stroke-width="8" />
</div>
</el-tab-pane>
</el-tabs>
</el-card>
<!-- Results table -->
<el-card>
<div class="table-meta">
<b>{{ total }}</b> 条记录
<el-text v-if="selectedIds.length > 0" type="primary" style="margin-left:12px;">
已选 {{ selectedIds.length }}
</el-text>
</div>
<el-table
ref="tableRef"
:data="rows"
v-loading="loading"
border
size="small"
style="width:100%;"
@row-click="openDetail"
@selection-change="onSelectionChange"
:row-style="{ cursor: 'pointer' }"
>
<el-table-column type="selection" width="42" @click.stop />
<el-table-column prop="id" label="ID" width="65" />
<el-table-column prop="session_id" label="批次" width="60" align="center" />
<el-table-column prop="email" label="Email" min-width="200" show-overflow-tooltip />
<el-table-column prop="account_id" label="Account ID" min-width="150" show-overflow-tooltip />
<el-table-column label="存活" width="85" align="center">
<template #default="{ row }">
<el-tag :type="aliveTagType(row.alive)" size="small">{{ aliveLabel(row.alive) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="套餐" width="80" align="center">
<template #default="{ row }">
<el-tag v-if="row.plan_type" :type="planTagType(row.plan_type)" size="small">{{ row.plan_type }}</el-tag>
<span v-else style="color:#c0c4cc;"></span>
</template>
</el-table-column>
<el-table-column label="配额" width="90" align="center">
<template #default="{ row }">
<span v-if="row.usage_json" style="font-size:12px;">{{ usageSummary(row.usage_json) }}</span>
<span v-else style="color:#c0c4cc;"></span>
</template>
</el-table-column>
<el-table-column label="检测时间" width="160" show-overflow-tooltip>
<template #default="{ row }">{{ formatTime(row.checked_at) }}</template>
</el-table-column>
<el-table-column prop="expired" label="过期时间" width="170" show-overflow-tooltip />
<el-table-column label="注册状态" width="75" align="center">
<template #default="{ row }">
<el-tag :type="row.error ? 'danger' : 'success'" size="small">
{{ row.error ? '失败' : '成功' }}
</el-tag>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无账号数据">
<el-button @click="doSearch">刷新</el-button>
</el-empty>
</template>
</el-table>
<div style="display:flex; justify-content:flex-end; margin-top:12px;">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:page-sizes="[20, 50, 100, 200]"
:total="total"
layout="total, sizes, prev, pager, next"
@change="doSearch"
/>
</div>
</el-card>
<!-- Detail drawer -->
<el-drawer
v-model="drawerVisible"
title="账号详情"
direction="rtl"
size="520px"
:destroy-on-close="true"
>
<el-skeleton :loading="!detail" :rows="8" animated>
<template #default>
<div v-if="detail" style="font-size:13px;">
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="ID">{{ detail.id }}</el-descriptions-item>
<el-descriptions-item label="批次">{{ detail.session_id }}</el-descriptions-item>
<el-descriptions-item label="注册状态">
<el-tag :type="detail.error ? 'danger' : 'success'" size="small">
{{ detail.error ? '失败' : '成功' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="存活状态">
<el-tag :type="aliveTagType(detail.alive)" size="small">{{ aliveLabel(detail.alive) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="上次自动刷新">{{ formatTime(detail.last_auto_refresh) }}</el-descriptions-item>
<el-descriptions-item label="检测时间">{{ formatTime(detail.checked_at) }}</el-descriptions-item>
<el-descriptions-item label="Email">{{ detail.email || '—' }}</el-descriptions-item>
<el-descriptions-item label="Account ID">{{ detail.account_id || '—' }}</el-descriptions-item>
<el-descriptions-item label="过期时间">{{ detail.expired || '—' }}</el-descriptions-item>
<el-descriptions-item label="套餐类型">
<el-tag v-if="detail.plan_type" :type="planTagType(detail.plan_type)" size="small">{{ detail.plan_type }}</el-tag>
<span v-else></span>
</el-descriptions-item>
<el-descriptions-item v-if="parsedUsage" label="Rate Limit">
<span :style="{ color: parsedUsage.rate_limit?.limit_reached ? '#f56c6c' : '#67c23a' }">
{{ parsedUsage.rate_limit?.allowed ? '可用' : '不可用' }}
</span>
<span v-if="parsedUsage.rate_limit?.primary_window" style="margin-left:8px; color:#909399;">
已用 {{ parsedUsage.rate_limit.primary_window.used_percent }}%
</span>
</el-descriptions-item>
<el-descriptions-item v-if="parsedUsage?.promo" label="推广">
{{ parsedUsage.promo.message || parsedUsage.promo.campaign_id }}
</el-descriptions-item>
<el-descriptions-item label="代理">{{ detail.proxy_used || '—' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTime(detail.created_at) }}</el-descriptions-item>
<el-descriptions-item v-if="detail.error" label="错误信息">
<span style="color:#f56c6c; word-break:break-all;">{{ detail.error }}</span>
</el-descriptions-item>
</el-descriptions>
<template v-if="!detail.error">
<div class="token-section-title">Token 信息</div>
<div v-for="field in tokenFields" :key="field.key" class="token-block-wrap">
<div class="token-block-header">
<span class="token-label">{{ field.label }}</span>
<el-button size="small" text @click="copyText(detail[field.key])">
<el-icon><CopyDocument /></el-icon>
复制
</el-button>
</div>
<div class="token-block">{{ detail[field.key] || '—' }}</div>
</div>
</template>
</div>
</template>
</el-skeleton>
</el-drawer>
<!-- Import dialog -->
<el-dialog v-model="importVisible" title="导入账号" width="520px" :close-on-click-modal="!importing">
<el-alert
title="每行一个 refresh_token或完整 JSON 对象(需含 refresh_token 字段)。导入时自动验证存活并开启自动刷新保活。"
type="info"
:closable="false"
show-icon
style="margin-bottom: 10px;"
/>
<el-upload
:before-upload="handleCsvUpload"
:show-file-list="false"
accept=".csv,.txt"
:disabled="importing"
style="margin-bottom: 10px;"
>
<el-button :icon="Upload" :disabled="importing">选择 CSV / TXT 文件</el-button>
</el-upload>
<el-input
v-model="importForm.tokens"
type="textarea"
:rows="8"
placeholder="粘贴 refresh_token / JSON / CSV 内容,每行一个;或点击上方按钮上传文件"
class="mono-input"
:disabled="importing"
/>
<div class="filter-row" style="margin-top:12px;">
<div class="filter-item flex-1">
<div class="field-label">代理留空使用系统代理</div>
<el-input v-model="importForm.proxy" placeholder="http://127.0.0.1:10809" :disabled="importing" />
</div>
<div class="filter-item">
<div class="field-label">并发</div>
<el-input-number v-model="importForm.concurrency" :min="1" :max="5" style="width:100px;" :disabled="importing" />
</div>
</div>
<div v-if="importProgress.total > 0" style="margin-top:16px;">
<div class="progress-stats">
<span style="color:#67c23a;">有效 {{ importProgress.alive }}</span>
<span style="color:#f56c6c;">失效 {{ importProgress.dead + importProgress.error }}</span>
<span> {{ importProgress.total }}</span>
</div>
<el-progress :percentage="importPct" :status="importing ? '' : 'success'" :stroke-width="8" />
</div>
<template #footer>
<el-button @click="importVisible = false" :disabled="importing">取消</el-button>
<el-button type="primary" :loading="importing" @click="startImport">开始导入</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Search, Download, Upload, CopyDocument } from '@element-plus/icons-vue'
import {
getAccounts, getAccount, exportAccountsUrl, getSystemProxy,
startCheckSession, importAccounts, deleteDeadAccounts,
openImportWS,
} from '../api/index.js'
import { formatTime, aliveTagType, aliveLabel } from '../utils/format.js'
import { useCheckState } from '../composables/useCheckState.js'
const { checking, checkProgress, checkPct, startCheck: globalStartCheck, stopCheck } = useCheckState()
const searchForm = reactive({ keyword: '', status: 'success', alive: '' })
const rows = ref([])
const total = ref(0)
const checkTotal = ref(0)
const page = ref(1)
const pageSize = ref(50)
const loading = ref(false)
const activeTab = ref('search')
const drawerVisible = ref(false)
const detail = ref(null)
const tableRef = ref(null)
const selectedIds = ref([])
const checkForm = reactive({ proxies: '', concurrency: 5, limit: 0, filter: 'all', autoClean: true })
const importVisible = ref(false)
const importing = ref(false)
const importForm = reactive({ tokens: '', proxy: '', concurrency: 3 })
const importProgress = reactive({ total: 0, done: 0, alive: 0, dead: 0, error: 0 })
let importWs = null
const importPct = computed(() => {
if (!importProgress.total) return 0
return Math.round((importProgress.done / importProgress.total) * 100)
})
const tokenFields = [
{ key: 'refresh_token', label: 'Refresh Token' },
{ key: 'access_token', label: 'Access Token' },
{ key: 'id_token', label: 'ID Token' },
]
function planTagType(plan) {
const map = { team: 'success', plus: 'warning', pro: '', enterprise: 'danger', free: 'info' }
return map[plan] || 'info'
}
function usageSummary(usageJsonStr) {
if (!usageJsonStr) return ''
try {
const data = JSON.parse(usageJsonStr)
const rl = data.rate_limit
if (!rl) return ''
const pct = rl.primary_window?.used_percent ?? '?'
return rl.limit_reached ? `${pct}% 限制` : `${pct}%`
} catch { return '' }
}
const parsedUsage = computed(() => {
if (!detail.value?.usage_json) return null
try { return JSON.parse(detail.value.usage_json) } catch { return null }
})
async function doSearch() {
loading.value = true
try {
const resp = await getAccounts({
search: searchForm.keyword || undefined,
status: searchForm.status || undefined,
alive: searchForm.alive || undefined,
page: page.value,
page_size: pageSize.value,
})
rows.value = resp.data.items
total.value = resp.data.total
} catch (err) {
ElMessage.error(err.message)
} finally {
loading.value = false
}
}
function onSelectionChange(selection) {
selectedIds.value = selection.map((r) => r.id)
}
async function loadCheckTotal() {
try {
let aliveFilter = undefined
if (checkForm.filter === 'alive') aliveFilter = 'alive'
else if (checkForm.filter === 'unchecked') aliveFilter = 'unchecked'
const resp = await getAccounts({ status: 'success', alive: aliveFilter, page: 1, page_size: 1 })
checkTotal.value = resp.data.total
} catch { checkTotal.value = 0 }
}
watch(() => checkForm.filter, () => loadCheckTotal(), { immediate: true })
async function startCheckAll() {
loading.value = true
try {
const ids = []
const limit = checkForm.limit > 0 ? checkForm.limit : Infinity
// 根据检测范围设置 alive 过滤
let aliveFilter = undefined
if (checkForm.filter === 'alive') aliveFilter = 'alive'
else if (checkForm.filter === 'unchecked') aliveFilter = 'unchecked'
let p = 1
while (ids.length < limit) {
const resp = await getAccounts({
status: 'success',
alive: aliveFilter,
page: p,
page_size: 200,
})
for (const r of resp.data.items) {
ids.push(r.id)
if (ids.length >= limit) break
}
if (ids.length >= resp.data.total) break
p++
}
await startCheck(ids)
} catch (err) {
ElMessage.error(err.message)
} finally {
loading.value = false
}
}
async function startCheck(ids) {
if (!checkForm.proxies.trim()) {
ElMessage.warning('请填写检测代理')
return
}
const rowMap = {}
rows.value.forEach((r) => { rowMap[r.id] = r })
try {
const resp = await startCheckSession({
account_ids: ids,
proxies: checkForm.proxies,
concurrency: checkForm.concurrency,
})
globalStartCheck(resp.data.check_id, ids.length, {
onResult(msg) {
if (rowMap[msg.account_id]) rowMap[msg.account_id].alive = msg.alive
},
onDone(msg) {
ElMessage.success(`检测完成:存活 ${msg.alive},已死 ${msg.dead},异常 ${msg.error}`)
if (checkForm.autoClean && msg.dead > 0) {
deleteDeadAccounts().then((resp) => {
ElMessage.success(`已清理 ${resp.data.deleted} 个失效账号`)
doSearch()
loadCheckTotal()
}).catch(() => { doSearch(); loadCheckTotal() })
} else {
doSearch()
loadCheckTotal()
}
},
})
} catch (err) {
ElMessage.error(err.message)
}
}
function handleCsvUpload(file) {
const reader = new FileReader()
reader.onload = (e) => {
importForm.tokens = e.target.result
ElMessage.success(`已加载文件:${file.name}`)
}
reader.readAsText(file)
return false // 阻止 el-upload 自动上传
}
async function startImport() {
const lines = importForm.tokens.split('\n').filter(l => l.trim())
if (!lines.length) { ElMessage.warning('请输入至少一个 token'); return }
importing.value = true
importProgress.total = lines.length
importProgress.done = 0
importProgress.alive = 0
importProgress.dead = 0
importProgress.error = 0
try {
const resp = await importAccounts({
tokens: importForm.tokens,
proxy: importForm.proxy,
concurrency: importForm.concurrency,
})
importWs = openImportWS(resp.data.import_id, {
onResult(msg) {
importProgress.done++
if (msg.alive === 'alive') importProgress.alive++
else if (msg.alive === 'dead') importProgress.dead++
else importProgress.error++
},
onDone(msg) {
importing.value = false
importWs?.close()
ElMessage.success(`导入完成:有效 ${msg.alive},失效 ${msg.dead + msg.error}`)
doSearch()
},
onError() {
ElMessage.error('连接异常')
importing.value = false
},
})
} catch (err) {
ElMessage.error(err.message)
importing.value = false
}
}
async function openDetail(row) {
drawerVisible.value = true
detail.value = null
try {
const resp = await getAccount(row.id)
detail.value = resp.data
} catch (err) {
ElMessage.error(err.message)
drawerVisible.value = false
}
}
function exportResults() {
window.open(exportAccountsUrl({
search: searchForm.keyword || undefined,
status: searchForm.status || undefined,
alive: searchForm.alive || undefined,
}))
}
async function copyText(text) {
if (!text) return
try {
await navigator.clipboard.writeText(text)
ElMessage.success('已复制')
} catch {
ElMessage.error('复制失败')
}
}
onMounted(async () => {
try {
const resp = await getSystemProxy()
if (resp.data.proxy) {
checkForm.proxies = resp.data.proxy
importForm.proxy = resp.data.proxy
}
} catch {}
doSearch()
})
onUnmounted(() => {
importWs?.close()
})
</script>
<style scoped>
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.page-title {
font-size: 18px;
font-weight: 600;
}
.header-actions {
display: flex;
gap: 8px;
}
.panel-card {
margin-bottom: 12px;
}
.filter-row {
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: flex-start;
}
.filter-item {
display: flex;
flex-direction: column;
}
.flex-1 { flex: 1; min-width: 200px; }
.field-label {
font-size: 12px;
color: #909399;
margin-bottom: 4px;
}
.field-hint {
font-size: 11px;
color: #c0c4cc;
margin-top: 2px;
}
.mono-input :deep(textarea),
.mono-input :deep(input) {
font-family: var(--font-mono);
font-size: 12px;
}
.progress-stats {
font-size: 12px;
color: #606266;
margin-bottom: 6px;
display: flex;
gap: 12px;
}
.table-meta {
margin-bottom: 8px;
color: #606266;
font-size: 13px;
}
.token-section-title {
margin-top: 20px;
margin-bottom: 10px;
font-weight: 600;
color: #303133;
}
.token-block-wrap {
margin-bottom: 14px;
}
.token-block-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.token-label {
color: #606266;
font-size: 12px;
font-weight: 500;
}
.token-block {
background: var(--color-log-bg);
color: #c9d1d9;
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.6;
padding: 10px 12px;
border-radius: var(--radius-sm);
word-break: break-all;
white-space: pre-wrap;
max-height: 120px;
overflow-y: auto;
}
</style>

View File

@@ -0,0 +1,418 @@
<template>
<div>
<el-card class="form-card">
<template #header>
<span class="card-title">批量注册</span>
</template>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px" :disabled="status === 'running' || status === 'paused'">
<el-form-item label="代理池" prop="proxies">
<el-input
v-model="form.proxies"
type="textarea"
:rows="5"
placeholder="每行一个代理地址,例如:&#10;http://user:pass@host:port&#10;http://host2:port"
class="proxy-textarea"
/>
<div class="field-hint">
注册时随机选取代理多代理可提高成功率
<el-text v-if="proxyCount > 0" type="primary" size="small" style="margin-left:8px;">已输入 {{ proxyCount }} 个代理</el-text>
</div>
</el-form-item>
<el-form-item label="目标数量" prop="count">
<el-input-number v-model="form.count" :min="1" :max="1000000" />
</el-form-item>
<el-form-item label="并发数">
<el-input-number v-model="form.concurrency" :min="1" :max="1000" />
<el-tooltip content="并发越高速度越快,但失败率可能上升" placement="right">
<el-icon class="tip-icon"><QuestionFilled /></el-icon>
</el-tooltip>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="status === 'running'"
@click="startRegistration"
>
<el-icon v-if="status !== 'running'"><UserFilled /></el-icon>
{{ status === 'running' ? '注册中...' : '开始注册' }}
</el-button>
</el-form-item>
</el-form>
<div v-if="status === 'running' || status === 'paused' || (status === 'done' && sessionId)" class="action-bar">
<el-button
v-if="status === 'running'"
type="warning"
@click="togglePause"
>
<el-icon><VideoPause /></el-icon>
暂停
</el-button>
<el-button
v-if="status === 'paused'"
type="success"
@click="togglePause"
>
<el-icon><VideoPlay /></el-icon>
继续
</el-button>
<el-button
v-if="status === 'done' && sessionId"
type="success"
@click="exportCsv"
>
<el-icon><Download /></el-icon>
导出本次 CSV
</el-button>
</div>
</el-card>
<!-- Progress -->
<el-card v-if="status !== 'idle'" style="margin-top: 16px;">
<template #header>
<div class="progress-header">
<div class="progress-title">
<span class="card-title">进度</span>
<el-tag v-if="status === 'running'" type="warning" size="small">运行中</el-tag>
<el-tag v-else-if="status === 'paused'" type="info" size="small">已暂停</el-tag>
<el-tag v-else type="success" size="small">已完成</el-tag>
</div>
</div>
</template>
<div class="progress-info">
<span class="progress-text">
<span class="progress-current">{{ progress.success }}</span>
<span class="progress-sep"> / </span>
<span class="progress-target">{{ targetCount }}</span>
</span>
<span class="progress-detail">
失败 {{ progress.failed }}
<template v-if="progress.success + progress.failed > 0">
· 成功率 {{ successRate }}%
</template>
</span>
</div>
<el-progress
:percentage="progressPct"
:status="status === 'done' ? 'success' : ''"
:stroke-width="12"
:show-text="false"
style="margin-bottom: 16px;"
/>
<!-- Log terminal -->
<div class="log-header">
<span class="log-title">实时日志</span>
<div class="log-controls">
<el-switch
v-model="autoScroll"
size="small"
active-text="自动滚动"
inactive-text=""
style="margin-right: 12px;"
/>
<el-button size="small" text @click="logs = []">
<el-icon><Delete /></el-icon>
清空
</el-button>
</div>
</div>
<div ref="logEl" class="log-terminal">
<div
v-for="(line, i) in logs"
:key="i"
:class="['log-line', line.type === 'success' ? 'log-success' : 'log-failed']"
>
<span class="log-time">{{ line.time }} </span>
<span class="log-level">{{ line.type === 'success' ? 'SUCCESS' : 'FAILED ' }}</span>
&nbsp;{{ line.text }}
</div>
<div v-if="logs.length === 0" class="log-empty">等待任务开始...</div>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import { startSession, pauseSession, resumeSession, openSessionWS, exportSessionUrl, getSystemProxy, getActiveSession } from '../api/index.js'
const formRef = ref(null)
const savedProxies = localStorage.getItem('register_proxies') || ''
const form = ref({ proxies: savedProxies, count: 5, concurrency: 3 })
const autoScroll = ref(true)
const rules = {
proxies: [{ required: true, message: '请填写至少一个代理地址', trigger: 'blur' }],
count: [{ type: 'number', min: 1, message: '注册数量需 ≥ 1', trigger: 'change' }],
}
const proxyCount = computed(() =>
form.value.proxies.split('\n').filter(l => l.trim()).length
)
watch(() => form.value.proxies, (v) => {
localStorage.setItem('register_proxies', v)
})
onMounted(async () => {
try {
const resp = await getSystemProxy()
if (resp.data.proxy && !form.value.proxies) form.value.proxies = resp.data.proxy
} catch {}
// 恢复运行中/暂停的任务
try {
const resp = await getActiveSession()
const s = resp.data.session
if (s) {
sessionId.value = s.id
targetCount.value = s.requested
progress.value = { success: s.success, failed: s.failed }
status.value = s.status === 'paused' ? 'paused' : 'running'
connectWS(s.id)
}
} catch {}
})
const status = ref('idle') // idle | running | paused | done
const sessionId = ref(null)
const targetCount = ref(0)
const progress = ref({ success: 0, failed: 0 })
const logs = ref([])
const logEl = ref(null)
let ws = null
const progressPct = computed(() => {
if (!targetCount.value) return 0
return Math.min(100, Math.round((progress.value.success / targetCount.value) * 100))
})
const successRate = computed(() => {
const total = progress.value.success + progress.value.failed
if (total === 0) return 0
return Math.round((progress.value.success / total) * 100)
})
function timestamp() {
return new Date().toLocaleTimeString('zh-CN', { hour12: false })
}
function scrollLog() {
if (!autoScroll.value) return
nextTick(() => {
if (logEl.value) logEl.value.scrollTop = logEl.value.scrollHeight
})
}
function connectWS(sid) {
ws?.close()
ws = openSessionWS(sid, {
onSuccess(msg) {
progress.value.success++
logs.value.push({
type: 'success',
time: timestamp(),
text: `${msg.email} [${msg.proxy}] (${msg.elapsed}s)`,
})
scrollLog()
},
onFailed(msg) {
progress.value.failed++
logs.value.push({
type: 'failed',
time: timestamp(),
text: `${msg.error} [${msg.proxy}] (${msg.elapsed}s)`,
})
scrollLog()
},
onDone(msg) {
status.value = 'done'
progress.value.success = msg.success
progress.value.failed = msg.failed
ElMessage.success(`注册完成:成功 ${msg.success},失败 ${msg.failed}`)
ws?.close()
},
onError() {
ElMessage.error('WebSocket 连接异常')
status.value = 'done'
},
})
}
async function startRegistration() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
status.value = 'running'
logs.value = []
progress.value = { success: 0, failed: 0 }
sessionId.value = null
targetCount.value = form.value.count
try {
const resp = await startSession({
proxies: form.value.proxies,
count: form.value.count,
concurrency: form.value.concurrency,
})
sessionId.value = resp.data.session_id
connectWS(sessionId.value)
} catch (err) {
status.value = 'idle'
ElMessage.error(err.message)
}
}
async function togglePause() {
if (!sessionId.value) return
try {
if (status.value === 'running') {
await pauseSession(sessionId.value)
status.value = 'paused'
ElMessage.info('已暂停')
} else if (status.value === 'paused') {
await resumeSession(sessionId.value)
status.value = 'running'
ElMessage.success('已恢复')
}
} catch (err) {
ElMessage.error(err.message)
}
}
function exportCsv() {
window.open(exportSessionUrl(sessionId.value))
}
onUnmounted(() => {
// 只关闭 WS 连接,不影响后端任务继续运行
ws?.close()
})
</script>
<style scoped>
.form-card { max-width: 800px; }
.action-bar {
display: flex;
gap: 8px;
padding: 0 0 0 100px;
}
.card-title { font-weight: 600; }
.proxy-textarea :deep(textarea) {
font-family: var(--font-mono);
font-size: 13px;
}
.field-hint {
color: #909399;
font-size: 12px;
margin-top: 4px;
line-height: 1.5;
}
.tip-icon {
margin-left: 8px;
color: #909399;
cursor: help;
font-size: 16px;
}
.progress-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.progress-title {
display: flex;
align-items: center;
gap: 8px;
}
.log-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.log-title {
font-size: 13px;
font-weight: 600;
color: #606266;
}
.log-controls {
display: flex;
align-items: center;
}
.log-terminal {
background: var(--color-log-bg);
border-radius: var(--radius-md);
padding: var(--space-md);
height: 320px;
overflow-y: auto;
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.7;
}
.log-line { margin: 0; }
.log-success { color: var(--color-log-success); }
.log-failed { color: var(--color-log-failed); }
.log-time {
color: var(--color-log-meta);
user-select: none;
}
.log-level {
font-weight: 600;
}
.log-empty {
color: #484f58;
}
.progress-info {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 8px;
}
.progress-text {
font-size: 24px;
font-weight: 700;
}
.progress-current {
color: #67c23a;
}
.progress-sep {
color: #909399;
margin: 0 2px;
}
.progress-target {
color: #303133;
}
.progress-detail {
font-size: 13px;
color: #909399;
}
</style>

View File

@@ -0,0 +1,483 @@
<template>
<div>
<div class="page-header">
<span class="page-title">任务中心</span>
<el-button type="primary" @click="openDialog(null)">
<el-icon><Plus /></el-icon>
新建任务
</el-button>
</div>
<el-card>
<el-table :data="schedules" v-loading="loading" stripe>
<el-table-column prop="id" label="ID" width="55" />
<el-table-column prop="name" label="任务名称" min-width="120" show-overflow-tooltip>
<template #default="{ row }">{{ row.name || '未命名' }}</template>
</el-table-column>
<el-table-column label="类型" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.schedule_type === 'daily' ? 'primary' : 'info'" size="small">
{{ row.schedule_type === 'daily' ? '每天' : '单次' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="任务类型" width="100" align="center">
<template #default="{ row }">
<el-tag :type="taskTypeTag(row.task_type || 'register')" size="small">
{{ taskTypeLabel(row.task_type || 'register') }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="执行时间" width="170">
<template #default="{ row }">
<span v-if="row.schedule_type === 'daily'">每天 {{ row.run_time }}</span>
<span v-else>{{ formatTime(row.run_time) }}</span>
</template>
</el-table-column>
<el-table-column label="目标/范围" width="130" align="center">
<template #default="{ row }">
<span v-if="(row.task_type || 'register') === 'register'">{{ row.target }}</span>
<span v-else-if="row.task_type === 'clean'" style="color:#909399;">自动</span>
<template v-else>
<el-tag size="small" type="info">
{{ {all:'全部', alive:'存活', unchecked:'未检测'}[row.check_filter] || '全部' }}
</el-tag>
<span v-if="row.check_limit > 0" style="margin-left:4px; font-size:12px; color:#909399;">
×{{ row.check_limit }}
</span>
</template>
</template>
</el-table-column>
<el-table-column prop="concurrency" label="并发" width="60" align="center" />
<el-table-column label="下次执行" width="170" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.enabled && row.next_run">{{ formatTime(row.next_run) }}</span>
<span v-else style="color:#c0c4cc;">--</span>
</template>
</el-table-column>
<el-table-column label="上次执行" width="170" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.last_run_at">{{ formatTime(row.last_run_at) }}</span>
<span v-else style="color:#c0c4cc;">--</span>
</template>
</el-table-column>
<el-table-column label="上次批次" width="80" align="center">
<template #default="{ row }">
<router-link
v-if="row.last_session_id"
:to="'/sessions'"
class="session-link"
>#{{ row.last_session_id }}</router-link>
<span v-else style="color:#c0c4cc;">--</span>
</template>
</el-table-column>
<el-table-column label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.enabled ? 'success' : 'info'" size="small">
{{ row.enabled ? '启用' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" align="center">
<template #default="{ row }">
<el-button
:type="row.enabled ? 'warning' : 'success'"
size="small"
@click="toggle(row)"
>
{{ row.enabled ? '停用' : '启用' }}
</el-button>
<el-button size="small" @click="openDialog(row)">编辑</el-button>
<el-button type="danger" size="small" @click="remove(row)">删除</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无定时任务">
<el-button type="primary" @click="openDialog(null)">创建第一个</el-button>
</el-empty>
</template>
</el-table>
</el-card>
<!-- 执行记录 -->
<el-card style="margin-top:16px;">
<template #header>
<div style="display:flex; align-items:center; justify-content:space-between;">
<span style="font-weight:600;">执行记录</span>
<el-button size="small" @click="loadRuns">刷新</el-button>
</div>
</template>
<el-table :data="runs" v-loading="runsLoading" stripe size="small">
<el-table-column prop="id" label="ID" width="55" />
<el-table-column label="任务" min-width="120" show-overflow-tooltip>
<template #default="{ row }">
{{ row.schedule_name || `#${row.schedule_id}` }}
</template>
</el-table-column>
<el-table-column label="类型" width="100" align="center">
<template #default="{ row }">
<el-tag :type="taskTypeTag(row.task_type)" size="small">{{ taskTypeLabel(row.task_type) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="开始时间" width="170">
<template #default="{ row }">{{ formatTime(row.started_at) }}</template>
</el-table-column>
<el-table-column label="结束时间" width="170">
<template #default="{ row }">
<span v-if="row.finished_at">{{ formatTime(row.finished_at) }}</span>
<span v-else style="color:#e6a23c;">运行中...</span>
</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="runStatusTag(row.status)" size="small">{{ runStatusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="详情" min-width="250">
<template #default="{ row }">
<div v-if="row.status === 'running' && parseProgress(row.detail)">
<div style="display:flex; align-items:center; gap:8px;">
<el-progress
:percentage="parseProgress(row.detail).pct"
:stroke-width="14"
:text-inside="true"
style="flex:1;"
/>
</div>
<div style="font-size:12px; color:#909399; margin-top:2px;">{{ row.detail }}</div>
</div>
<span v-else>{{ row.detail || '' }}</span>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无执行记录" :image-size="60" />
</template>
</el-table>
</el-card>
<!-- 新建/编辑对话框 -->
<el-dialog
v-model="dialogVisible"
:title="editingId ? '编辑定时任务' : '新建定时任务'"
width="560px"
:close-on-click-modal="false"
>
<el-form :model="formData" label-width="90px">
<el-form-item label="任务名称">
<el-input v-model="formData.name" placeholder="可选,便于识别" />
</el-form-item>
<el-form-item label="任务类型">
<el-radio-group v-model="formData.task_type">
<el-radio value="register">注册账号</el-radio>
<el-radio value="check">检测存活</el-radio>
<el-radio value="refresh">刷新Token</el-radio>
<el-radio value="clean">清理失效</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="执行方式">
<el-radio-group v-model="formData.schedule_type">
<el-radio value="once">单次</el-radio>
<el-radio value="daily">每天</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="执行时间">
<el-date-picker
v-if="formData.schedule_type === 'once'"
v-model="formData.run_time_once"
type="datetime"
placeholder="选择日期时间"
format="YYYY-MM-DD HH:mm"
value-format="YYYY-MM-DDTHH:mm:ss"
style="width:100%;"
/>
<el-time-picker
v-else
v-model="formData.run_time_daily"
placeholder="选择时间"
format="HH:mm"
value-format="HH:mm"
style="width:220px;"
/>
</el-form-item>
<el-form-item v-if="formData.task_type !== 'clean'" label="代理池">
<el-input
v-model="formData.proxies"
type="textarea"
:rows="3"
placeholder="每行一个代理地址"
class="mono-input"
/>
</el-form-item>
<el-form-item v-if="formData.task_type === 'register'" label="目标数量">
<el-input-number v-model="formData.target" :min="1" :max="1000000" />
</el-form-item>
<el-form-item v-if="formData.task_type === 'check'" label="检测范围">
<el-select v-model="formData.check_filter" style="width:220px;">
<el-option value="all" label="全部成功账号" />
<el-option value="alive" label="仅存活账号" />
<el-option value="unchecked" label="仅未检测账号" />
</el-select>
</el-form-item>
<el-form-item v-if="formData.task_type === 'check' || formData.task_type === 'refresh'" label="数量限制">
<el-input-number v-model="formData.check_limit" :min="0" :max="1000000" />
<span style="margin-left:8px; font-size:12px; color:#909399;">0 = 全部</span>
</el-form-item>
<el-form-item v-if="formData.task_type === 'check'" label="自动清理">
<el-checkbox v-model="formData.auto_clean">检测后删除失效账号</el-checkbox>
</el-form-item>
<el-form-item v-if="formData.task_type !== 'clean'" label="并发数">
<el-input-number v-model="formData.concurrency" :min="1" :max="1000" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
getSchedules, createSchedule, updateSchedule, toggleSchedule, deleteSchedule,
getSystemProxy, getScheduleRuns, getAllRuns,
} from '../api/index.js'
import { formatTime } from '../utils/format.js'
const schedules = ref([])
const loading = ref(false)
const dialogVisible = ref(false)
const saving = ref(false)
const editingId = ref(null)
const formData = reactive({
name: '',
task_type: 'register',
schedule_type: 'daily',
run_time_once: '',
run_time_daily: '08:00',
proxies: '',
target: 10,
concurrency: 3,
check_filter: 'all',
check_limit: 0,
auto_clean: false,
})
let defaultProxies = ''
async function loadSchedules() {
loading.value = true
try {
const resp = await getSchedules()
schedules.value = resp.data
} finally {
loading.value = false
}
}
function openDialog(row) {
if (row) {
editingId.value = row.id
formData.name = row.name || ''
formData.task_type = row.task_type || 'register'
formData.schedule_type = row.schedule_type
formData.proxies = row.proxies || ''
formData.target = row.target
formData.concurrency = row.concurrency
formData.check_filter = row.check_filter || 'all'
formData.check_limit = row.check_limit || 0
formData.auto_clean = !!row.auto_clean
if (row.schedule_type === 'daily') {
formData.run_time_daily = row.run_time
formData.run_time_once = ''
} else {
formData.run_time_once = row.run_time
formData.run_time_daily = '08:00'
}
} else {
editingId.value = null
formData.name = ''
formData.task_type = 'register'
formData.schedule_type = 'daily'
formData.run_time_once = ''
formData.run_time_daily = '08:00'
formData.proxies = defaultProxies
formData.target = 10
formData.concurrency = 3
formData.check_filter = 'all'
formData.check_limit = 0
formData.auto_clean = false
}
dialogVisible.value = true
}
async function save() {
const run_time = formData.schedule_type === 'daily'
? formData.run_time_daily
: formData.run_time_once
if (!run_time) {
ElMessage.warning('请选择执行时间')
return
}
if (formData.task_type !== 'clean' && !formData.proxies.trim()) {
ElMessage.warning('请填写代理池')
return
}
saving.value = true
const payload = {
name: formData.name,
task_type: formData.task_type,
proxies: formData.proxies,
target: formData.target,
concurrency: formData.concurrency,
check_filter: formData.check_filter,
check_limit: formData.check_limit,
auto_clean: formData.auto_clean,
schedule_type: formData.schedule_type,
run_time,
}
try {
if (editingId.value) {
await updateSchedule(editingId.value, payload)
ElMessage.success('已更新')
} else {
await createSchedule(payload)
ElMessage.success('已创建')
}
dialogVisible.value = false
await loadSchedules()
} catch (err) {
ElMessage.error(err.message)
} finally {
saving.value = false
}
}
async function toggle(row) {
try {
const resp = await toggleSchedule(row.id)
ElMessage.success(resp.data.enabled ? '已启用' : '已停用')
await loadSchedules()
} catch (err) {
ElMessage.error(err.message)
}
}
async function remove(row) {
try {
await ElMessageBox.confirm(
`确定删除任务「${row.name || '#' + row.id}」?`,
'删除确认',
{ type: 'warning' },
)
await deleteSchedule(row.id)
ElMessage.success('已删除')
await loadSchedules()
} catch {}
}
const TASK_TYPE_MAP = {
register: { label: '注册', tag: 'success' },
check: { label: '检测存活', tag: 'warning' },
refresh: { label: '刷新Token', tag: '' },
clean: { label: '清理失效', tag: 'danger' },
}
function taskTypeLabel(t) { return TASK_TYPE_MAP[t]?.label || t }
function taskTypeTag(t) { return TASK_TYPE_MAP[t]?.tag || 'info' }
function runStatusLabel(s) {
return { running: '运行中', done: '完成', failed: '失败' }[s] || s
}
function runStatusTag(s) {
return { running: 'warning', done: 'success', failed: 'danger' }[s] || 'info'
}
const runs = ref([])
const runsLoading = ref(false)
async function loadRuns() {
runsLoading.value = true
try {
const resp = await getAllRuns(50)
runs.value = resp.data
} finally {
runsLoading.value = false
}
}
function parseProgress(detail) {
if (!detail) return null
// 匹配 "成功 3 / 目标 10" 或 "已检测 5 / 20" 或 "已刷新 5 / 20"
const m = detail.match(/(\d+)\s*\/\s*(?:目标\s*)?(\d+)/)
if (!m) return null
const current = parseInt(m[1])
const total = parseInt(m[2])
if (total <= 0) return null
return { current, total, pct: Math.min(Math.round(current / total * 100), 100) }
}
let runsTimer = null
function startRunsPolling() {
stopRunsPolling()
runsTimer = setInterval(() => {
if (runs.value.some(r => r.status === 'running')) {
loadRuns()
}
}, 5000)
}
function stopRunsPolling() {
if (runsTimer) {
clearInterval(runsTimer)
runsTimer = null
}
}
onMounted(async () => {
try {
const resp = await getSystemProxy()
if (resp.data.proxy) defaultProxies = resp.data.proxy
} catch {}
loadSchedules()
loadRuns().then(() => startRunsPolling())
})
onUnmounted(() => {
stopRunsPolling()
})
</script>
<style scoped>
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.page-title {
font-size: 18px;
font-weight: 600;
}
.mono-input :deep(textarea) {
font-family: var(--font-mono);
font-size: 12px;
}
.session-link {
color: #409eff;
text-decoration: none;
}
.session-link:hover {
text-decoration: underline;
}
</style>

View File

@@ -0,0 +1,324 @@
<template>
<div>
<!-- Page header -->
<div class="page-header">
<div>
<span class="page-title">注册记录</span>
<el-text type="info" size="small" style="margin-left: 8px;"> {{ sessions.length }} 条记录</el-text>
</div>
<el-button :icon="Refresh" circle @click="loadSessions" :loading="loadingSessions" />
</div>
<!-- Filter bar -->
<el-card class="filter-card">
<el-row :gutter="12" align="middle">
<el-col :span="10">
<el-input
v-model="filterKeyword"
placeholder="搜索 ID 或日期..."
:prefix-icon="Search"
clearable
size="small"
/>
</el-col>
<el-col :span="8">
<el-select v-model="filterStatus" placeholder="全部状态" clearable size="small" style="width: 100%;">
<el-option label="全部状态" value="" />
<el-option label="运行中" value="running" />
<el-option label="已暂停" value="paused" />
<el-option label="导入中" value="importing" />
<el-option label="已完成" value="done" />
</el-select>
</el-col>
</el-row>
</el-card>
<!-- Sessions table -->
<el-card>
<el-table
:data="filteredSessions"
v-loading="loadingSessions"
row-key="id"
stripe
@expand-change="onExpand"
>
<el-table-column type="expand">
<template #default="{ row }">
<div style="padding: 12px 24px;">
<el-table
:data="accountsMap[row.id] || []"
v-loading="loadingAccounts[row.id]"
size="small"
:max-height="300"
>
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="email" label="Email" min-width="180" />
<el-table-column prop="account_id" label="Account ID" min-width="160" show-overflow-tooltip />
<el-table-column label="过期时间" width="180">
<template #default="{ row: acct }">{{ formatTime(acct.expired) }}</template>
</el-table-column>
<el-table-column prop="exit_ip" label="出口 IP" min-width="140" show-overflow-tooltip />
<el-table-column prop="proxy_used" label="代理" min-width="140" show-overflow-tooltip />
<el-table-column label="存活" width="90">
<template #default="{ row: acct }">
<el-tag :type="aliveTagType(acct.alive)" size="small">{{ aliveLabel(acct.alive) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="注册状态" width="80">
<template #default="{ row: acct }">
<el-tag :type="acct.error ? 'danger' : 'success'" size="small">
{{ acct.error ? '失败' : '成功' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="error" label="错误" min-width="160" show-overflow-tooltip />
</el-table>
</div>
</template>
</el-table-column>
<el-table-column prop="id" label="ID" width="60" />
<el-table-column label="创建时间" width="180">
<template #default="{ row }">{{ formatTime(row.created_at) }}</template>
</el-table-column>
<el-table-column prop="proxy_count" label="代理数" width="70" align="center" />
<el-table-column prop="requested" label="目标" width="60" align="center" />
<el-table-column prop="concurrency" label="并发" width="60" align="center" />
<el-table-column label="IP 统计" width="140" align="center">
<template #default="{ row }">
<span v-if="row.unique_ips > 0">
<span class="ip-unique">{{ row.unique_ips }}</span>
<span v-if="row.reused_ips > 0" class="ip-reused">重复 {{ row.reused_ips }}</span>
</span>
<span v-else style="color: #c0c4cc;"></span>
</template>
</el-table-column>
<el-table-column label="成功" width="60" align="center">
<template #default="{ row }">
<span class="count-success">{{ row.success }}</span>
</template>
</el-table-column>
<el-table-column label="失败" width="60" align="center">
<template #default="{ row }">
<span class="count-failed">{{ row.failed }}</span>
</template>
</el-table-column>
<el-table-column label="进度" width="100" align="center">
<template #default="{ row }">
<span v-if="row.requested > 0">{{ row.success }} / {{ row.requested }}</span>
<span v-else style="color: #c0c4cc;"></span>
</template>
</el-table-column>
<el-table-column label="成功率" width="80" align="center">
<template #default="{ row }">
<span v-if="row.success + row.failed > 0" :style="{ color: successRateColor(row) }">
{{ Math.round((row.success / (row.success + row.failed)) * 100) }}%
</span>
<span v-else style="color: #c0c4cc;"></span>
</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag
:type="statusTagType(row.status)"
size="small"
>
{{ statusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="160" align="center">
<template #default="{ row }">
<div style="display:flex; gap:4px; justify-content:center;">
<el-button
v-if="row.status === 'running'"
type="warning"
size="small"
@click.stop="doPause(row.id)"
>
暂停
</el-button>
<el-button
v-if="row.status === 'paused'"
type="success"
size="small"
@click.stop="doResume(row.id)"
>
继续
</el-button>
<el-button
type="primary"
size="small"
:icon="Download"
@click.stop="exportSession(row.id)"
:disabled="row.success === 0"
>
导出
</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无注册记录">
<el-button @click="loadSessions">刷新</el-button>
</el-empty>
</template>
</el-table>
</el-card>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { Refresh, Search, Download } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { getSessions, getAccounts, exportSessionUrl, pauseSession, resumeSession } from '../api/index.js'
import { formatTime, aliveTagType, aliveLabel } from '../utils/format.js'
const sessions = ref([])
const loadingSessions = ref(false)
const accountsMap = reactive({})
const loadingAccounts = reactive({})
const filterKeyword = ref('')
const filterStatus = ref('')
const filteredSessions = computed(() => {
return sessions.value.filter(s => {
const matchesStatus = !filterStatus.value || s.status === filterStatus.value
const matchesKeyword = !filterKeyword.value ||
String(s.id).includes(filterKeyword.value) ||
(s.created_at && s.created_at.includes(filterKeyword.value))
return matchesStatus && matchesKeyword
})
})
function statusTagType(s) {
if (s === 'done') return 'success'
if (s === 'paused') return 'info'
if (s === 'importing') return 'primary'
return 'warning'
}
function statusLabel(s) {
const map = { done: '已完成', paused: '已暂停', importing: '导入中', running: '运行中' }
return map[s] || s
}
function successRateColor(row) {
const total = row.success + row.failed
if (total === 0) return '#c0c4cc'
const rate = row.success / total
if (rate >= 0.8) return '#67c23a'
if (rate >= 0.5) return '#e6a23c'
return '#f56c6c'
}
let pollTimer = null
async function loadSessions() {
loadingSessions.value = true
try {
const resp = await getSessions()
sessions.value = resp.data
} finally {
loadingSessions.value = false
}
}
async function onExpand(row, expandedRows) {
const isExpanded = expandedRows.some((r) => r.id === row.id)
if (!isExpanded || accountsMap[row.id]) return
loadingAccounts[row.id] = true
try {
const resp = await getAccounts({ session_id: row.id, page_size: 200 })
accountsMap[row.id] = resp.data.items
} finally {
loadingAccounts[row.id] = false
}
}
function exportSession(id) {
window.open(exportSessionUrl(id))
}
async function doPause(id) {
try {
await pauseSession(id)
ElMessage.info('已暂停')
} catch (err) {
ElMessage.warning(err.message)
}
await loadSessions()
}
async function doResume(id) {
try {
await resumeSession(id)
ElMessage.success('已恢复')
} catch (err) {
ElMessage.warning(err.message)
}
await loadSessions()
}
function startPolling() {
pollTimer = setInterval(async () => {
if (document.hidden) return
const hasRunning = sessions.value.some((s) => s.status === 'running' || s.status === 'importing' || s.status === 'paused')
if (hasRunning) await loadSessions()
}, 3000)
}
onMounted(() => {
loadSessions()
startPolling()
})
onUnmounted(() => {
clearInterval(pollTimer)
})
</script>
<style scoped>
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.page-title {
font-size: 18px;
font-weight: 600;
}
.filter-card {
margin-bottom: 12px;
}
.filter-card :deep(.el-card__body) {
padding: 12px 16px;
}
.count-success {
color: #67c23a;
font-weight: 600;
}
.count-failed {
color: #f56c6c;
font-weight: 600;
}
.ip-unique {
color: #409eff;
font-weight: 600;
}
.ip-reused {
color: #e6a23c;
font-size: 12px;
}
</style>

View File

@@ -0,0 +1,43 @@
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const repoRoot = path.resolve(__dirname, '..')
function toWsOrigin(httpOrigin) {
if (httpOrigin.startsWith('https://')) return httpOrigin.replace('https://', 'wss://')
if (httpOrigin.startsWith('http://')) return httpOrigin.replace('http://', 'ws://')
return httpOrigin
}
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, repoRoot, '')
const frontendPort = Number(env.FRONTEND_PORT || 5173)
const backendHost = (env.BACKEND_HOST || env.APP_HOST || '127.0.0.1').trim() || '127.0.0.1'
const backendPort = Number(env.BACKEND_PORT || env.APP_PORT || 8000)
const backendOrigin = (env.BACKEND_ORIGIN || `http://${backendHost}:${backendPort}`).trim()
const backendWsOrigin = (env.BACKEND_WS_ORIGIN || toWsOrigin(backendOrigin)).trim()
return {
envDir: repoRoot,
plugins: [vue()],
server: {
port: frontendPort,
proxy: {
'/api': {
target: backendOrigin,
changeOrigin: true,
},
'/ws': {
target: backendWsOrigin,
ws: true,
changeOrigin: true,
},
},
},
}
})