mirror of
https://github.com/adminlove520/AI-Account-Toolkit.git
synced 2026-05-14 09:17:38 +08:00
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:
23
Code-Patch/.env.example
Normal file
23
Code-Patch/.env.example
Normal 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
1247
Code-Patch/.gitignore
vendored
Normal file
File diff suppressed because it is too large
Load Diff
75
Code-Patch/README.md
Normal file
75
Code-Patch/README.md
Normal 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
|
||||
106
Code-Patch/backend/database.py
Normal file
106
Code-Patch/backend/database.py
Normal 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
1379
Code-Patch/backend/main.py
Normal file
File diff suppressed because it is too large
Load Diff
566
Code-Patch/backend/register.py
Normal file
566
Code-Patch/backend/register.py
Normal 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
|
||||
12
Code-Patch/frontend/index.html
Normal file
12
Code-Patch/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>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
1572
Code-Patch/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
Code-Patch/frontend/package.json
Normal file
21
Code-Patch/frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
95
Code-Patch/frontend/src/App.vue
Normal file
95
Code-Patch/frontend/src/App.vue
Normal 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>
|
||||
104
Code-Patch/frontend/src/api/index.js
Normal file
104
Code-Patch/frontend/src/api/index.js
Normal 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
|
||||
}
|
||||
57
Code-Patch/frontend/src/composables/useCheckState.js
Normal file
57
Code-Patch/frontend/src/composables/useCheckState.js
Normal 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 }
|
||||
}
|
||||
40
Code-Patch/frontend/src/composables/useWebSocket.js
Normal file
40
Code-Patch/frontend/src/composables/useWebSocket.js
Normal 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 }
|
||||
}
|
||||
18
Code-Patch/frontend/src/main.js
Normal file
18
Code-Patch/frontend/src/main.js
Normal 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')
|
||||
18
Code-Patch/frontend/src/router/index.js
Normal file
18
Code-Patch/frontend/src/router/index.js
Normal 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,
|
||||
})
|
||||
35
Code-Patch/frontend/src/styles/variables.css
Normal file
35
Code-Patch/frontend/src/styles/variables.css
Normal 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);
|
||||
}
|
||||
28
Code-Patch/frontend/src/utils/format.js
Normal file
28
Code-Patch/frontend/src/utils/format.js
Normal 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 '未检测'
|
||||
}
|
||||
702
Code-Patch/frontend/src/views/AccountsView.vue
Normal file
702
Code-Patch/frontend/src/views/AccountsView.vue
Normal 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>
|
||||
418
Code-Patch/frontend/src/views/RegisterView.vue
Normal file
418
Code-Patch/frontend/src/views/RegisterView.vue
Normal 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="每行一个代理地址,例如: http://user:pass@host:port 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>
|
||||
{{ 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>
|
||||
483
Code-Patch/frontend/src/views/SchedulesView.vue
Normal file
483
Code-Patch/frontend/src/views/SchedulesView.vue
Normal 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>
|
||||
324
Code-Patch/frontend/src/views/SessionsView.vue
Normal file
324
Code-Patch/frontend/src/views/SessionsView.vue
Normal 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>
|
||||
43
Code-Patch/frontend/vite.config.js
Normal file
43
Code-Patch/frontend/vite.config.js
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user