diff --git a/README.md b/README.md new file mode 100644 index 0000000..e00130e --- /dev/null +++ b/README.md @@ -0,0 +1,288 @@ +# OpenAI/Codex CLI 自动注册系统 + +一个功能完整的 OpenAI/Codex CLI 自动注册系统,支持多种邮箱服务、Web UI 管理界面和命令行操作。 + +## 功能特性 + +### 核心功能 +- **自动注册流程** - 完整的 OpenAI 账号自动注册,支持 OAuth 2.0 + PKCE 认证 +- **多种邮箱服务** - 支持 Tempmail.lol、Outlook IMAP、自定义域名邮箱 +- **Web UI 界面** - 现代化的 Web 管理界面,包含控制台日志输出 +- **账号管理** - 批量删除、导出(JSON/CSV)、刷新 Token、查看详情 +- **后台任务** - 异步执行注册任务,实时查看任务状态和日志 +- **SQLite 存储** - 轻量级数据库存储账号和配置信息 + +### 邮箱服务 +| 服务类型 | 描述 | 适用场景 | +|---------|------|---------| +| Tempmail.lol | 临时邮箱服务 | 快速测试、一次性注册 | +| Outlook IMAP | Outlook 邮箱 IMAP 接收 | 长期使用的邮箱 | +| 自定义域名 | 支持自定义域名邮箱 API | 企业级邮箱服务 | + +### Web UI 页面 +- **注册页面** - 嵌入式控制台,实时显示注册日志 +- **账号管理** - 表格展示、批量操作、搜索过滤 +- **系统设置** - 代理配置、邮箱服务管理、注册参数、数据库管理 + +## 安装 + +### 环境要求 +- Python >= 3.10 +- pip 或 uv 包管理器 + +### 安装步骤 + +```bash +# 克隆项目 +git clone +cd codex-register-v2 + +# 创建虚拟环境 +python -m venv .venv + +# 激活虚拟环境 +# Windows +.venv\Scripts\activate +# Linux/macOS +source .venv/bin/activate + +# 安装依赖 +pip install -e . + +# 或使用 uv(推荐) +uv sync +``` + +## 使用方法 + +### 方式一:Web UI(推荐) + +```bash +# 启动 Web UI +python webui.py + +# 或指定参数 +python webui.py --host 0.0.0.0 --port 8000 --debug + +# 使用已安装的命令 +codex-webui +``` + +访问 `http://localhost:8000` 即可使用 Web 界面。 + +### 方式二:命令行 + +```bash +# 单次注册 +python cli.py --once + +# 循环注册 +python cli.py --sleep-min 5 --sleep-max 30 + +# 使用代理 +python cli.py --proxy http://127.0.0.1:7890 + +# 使用已安装的命令 +codex-register --once +``` + +### 命令行参数 + +| 参数 | 说明 | 默认值 | +|------|------|--------| +| `--proxy` | 代理地址 | 无 | +| `--once` | 只运行一次 | False | +| `--sleep-min` | 循环模式最短等待秒数 | 5 | +| `--sleep-max` | 循环模式最长等待秒数 | 30 | +| `--log-level` | 日志级别 | INFO | +| `--log-file` | 日志文件路径 | 无 | + +## 配置 + +### 配置方式 + +配置优先级:环境变量 > `.env` 文件 > 默认值 + +创建 `.env` 文件: + +```env +# 应用配置 +DEBUG=false +LOG_LEVEL=INFO + +# 数据库 +DATABASE_URL=sqlite:///data/database.db + +# Web UI +WEBUI_HOST=0.0.0.0 +WEBUI_PORT=8000 +WEBUI_SECRET_KEY=your-secret-key + +# 代理配置 +PROXY_ENABLED=false +PROXY_TYPE=http +PROXY_HOST=127.0.0.1 +PROXY_PORT=7890 + +# OpenAI OAuth +OPENAI_CLIENT_ID=app_EMoamEEZ73f0CkXaXp7hrann + +# Tempmail 配置 +TEMPMAIL_BASE_URL=https://api.tempmail.lol/v2 +TEMPMAIL_TIMEOUT=30 + +# 自定义域名邮箱 +CUSTOM_DOMAIN_BASE_URL=https://mail.example.com/api +CUSTOM_DOMAIN_API_KEY=your-api-key +``` + +### 主要配置项 + +| 配置项 | 说明 | 默认值 | +|--------|------|--------| +| `database_url` | 数据库连接 URL | `data/database.db` | +| `webui_host` | Web UI 监听地址 | `0.0.0.0` | +| `webui_port` | Web UI 监听端口 | `8000` | +| `proxy_enabled` | 是否启用代理 | `false` | +| `proxy_url` | 代理地址 | - | +| `registration_max_retries` | 注册最大重试次数 | `3` | +| `registration_timeout` | 注册超时时间(秒) | `120` | + +## 项目结构 + +``` +codex-register-v2/ +├── cli.py # 命令行入口 +├── webui.py # Web UI 入口 +├── pyproject.toml # 项目配置 +├── requirements.txt # 依赖列表 +├── src/ +│ ├── __init__.py +│ ├── config/ # 配置模块 +│ │ ├── settings.py # Pydantic 设置模型 +│ │ └── constants.py # 常量定义 +│ ├── core/ # 核心功能 +│ │ ├── oauth.py # OAuth 认证 +│ │ ├── register.py # 注册引擎 +│ │ ├── http_client.py # HTTP 客户端 +│ │ └── utils.py # 工具函数 +│ ├── database/ # 数据库模块 +│ │ ├── models.py # 数据模型 +│ │ ├── crud.py # CRUD 操作 +│ │ ├── session.py # 会话管理 +│ │ └── init_db.py # 数据库初始化 +│ ├── services/ # 邮箱服务 +│ │ ├── base.py # 基类和工厂 +│ │ ├── tempmail.py # Tempmail.lol 服务 +│ │ ├── outlook.py # Outlook IMAP 服务 +│ │ └── custom_domain.py # 自定义域名服务 +│ └── web/ # Web 模块 +│ ├── app.py # FastAPI 应用 +│ └── routes/ # API 路由 +│ ├── accounts.py # 账号管理 +│ ├── registration.py # 注册任务 +│ ├── settings.py # 系统设置 +│ └── email_services.py # 邮箱服务 +├── templates/ # Jinja2 模板 +│ ├── index.html # 注册页面 +│ ├── accounts.html # 账号管理 +│ └── settings.html # 系统设置 +├── static/ # 静态文件 +│ ├── css/style.css +│ └── js/ +│ ├── app.js +│ ├── accounts.js +│ └── settings.js +└── data/ # 数据目录 + └── database.db # SQLite 数据库 +``` + +## API 文档 + +### 账号管理 API + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/accounts` | 获取账号列表 | +| GET | `/api/accounts/{id}` | 获取单个账号 | +| DELETE | `/api/accounts/{id}` | 删除账号 | +| POST | `/api/accounts/batch-delete` | 批量删除 | +| GET | `/api/accounts/export` | 导出账号 | +| POST | `/api/accounts/{id}/refresh` | 刷新 Token | + +### 注册任务 API + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/api/registration/start` | 开始注册 | +| GET | `/api/registration/status/{uuid}` | 任务状态 | +| GET | `/api/registration/logs/{uuid}` | 任务日志 | +| DELETE | `/api/registration/cancel/{uuid}` | 取消任务 | + +### 设置 API + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/settings` | 获取设置 | +| PUT | `/api/settings` | 更新设置 | +| GET | `/api/settings/email-services` | 邮箱服务列表 | +| POST | `/api/settings/email-services` | 创建邮箱服务 | + +## 开发 + +### 运行测试 + +```bash +# 安装开发依赖 +pip install -e ".[dev]" + +# 运行测试 +pytest +``` + +### 代码风格 + +项目使用 Python 3.10+ 特性,包括类型注解、dataclass、Pydantic 模型等。 + +### 添加新的邮箱服务 + +1. 继承 `BaseEmailService` 类 +2. 实现必需的方法:`get_email()`, `wait_for_code()`, `get_status()` +3. 在 `EmailServiceFactory` 中注册服务 + +```python +from src.services import BaseEmailService, EmailServiceFactory, EmailServiceType + +class MyEmailService(BaseEmailService): + async def get_email(self) -> str: + # 实现获取邮箱地址 + pass + + async def wait_for_code(self, email: str, timeout: int = 300) -> str: + # 实现等待验证码 + pass + +# 注册服务 +EmailServiceFactory.register(EmailServiceType.CUSTOM, MyEmailService) +``` + +## 技术栈 + +- **Web 框架**: FastAPI + Uvicorn +- **模板引擎**: Jinja2 +- **数据库**: SQLAlchemy + SQLite +- **HTTP 客户端**: curl_cffi(支持浏览器指纹模拟) +- **数据验证**: Pydantic +- **认证**: OAuth 2.0 + PKCE + +## 许可证 + +MIT License + +## 作者 + +Yasal + +--- + +**注意**: 本工具仅供学习和研究目的,请勿用于违反 OpenAI 服务条款的活动。 diff --git a/cli.py b/cli.py new file mode 100644 index 0000000..106950a --- /dev/null +++ b/cli.py @@ -0,0 +1,170 @@ +""" +命令行入口 - 保持向后兼容性 +""" + +import argparse +import json +import random +import time +import logging +from datetime import datetime +from typing import Optional + +from src.core.utils import setup_logging, get_data_dir +from src.core.register import RegistrationEngine +from src.services import EmailServiceFactory, EmailServiceType +from src.database.init_db import initialize_database +from src.config.settings import get_settings + + +def setup_database(): + """初始化数据库""" + try: + initialize_database() + print("[Info] 数据库初始化完成") + return True + except Exception as e: + print(f"[Error] 数据库初始化失败: {e}") + return False + + +def create_tempmail_service(proxy_url: Optional[str] = None): + """创建 Tempmail 服务""" + config = { + "base_url": "https://api.tempmail.lol/v2", + "timeout": 30, + "max_retries": 3, + "proxy_url": proxy_url, + } + + try: + service = EmailServiceFactory.create( + EmailServiceType.TEMPMAIL, + config, + name="tempmail_cli" + ) + print("[Info] Tempmail 服务创建成功") + return service + except Exception as e: + print(f"[Error] 创建 Tempmail 服务失败: {e}") + return None + + +def run_registration(proxy: Optional[str] = None) -> Optional[dict]: + """ + 执行一次注册流程 + + Args: + proxy: 代理地址 + + Returns: + 注册结果字典,如果失败返回 None + """ + # 创建邮箱服务 + email_service = create_tempmail_service(proxy) + if not email_service: + return None + + # 创建注册引擎 + engine = RegistrationEngine( + email_service=email_service, + proxy_url=proxy, + callback_logger=lambda msg: print(msg) + ) + + # 执行注册 + result = engine.run() + + if result.success: + # 保存到数据库 + engine.save_to_database(result) + + # 保存到文件(保持向后兼容) + try: + t_data = { + "id_token": result.id_token, + "access_token": result.access_token, + "refresh_token": result.refresh_token, + "account_id": result.account_id, + "last_refresh": datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ"), + "email": result.email, + "type": "codex", + "expired": datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ") # 简化处理 + } + + fname_email = result.email.replace("@", "_") + file_name = f"token_{fname_email}_{int(time.time())}.json" + + with open(file_name, "w", encoding="utf-8") as f: + json.dump(t_data, f, ensure_ascii=False, separators=(",", ":")) + + print(f"[*] 成功! Token 已保存至: {file_name}") + + except Exception as e: + print(f"[Warning] 保存 Token 文件失败: {e}") + + return result.to_dict() + else: + print(f"[-] 本次注册失败: {result.error_message}") + return None + + +def main() -> None: + """主函数""" + parser = argparse.ArgumentParser(description="OpenAI 自动注册脚本 (重构版本)") + parser.add_argument( + "--proxy", default=None, help="代理地址,如 http://127.0.0.1:7890" + ) + parser.add_argument("--once", action="store_true", help="只运行一次") + parser.add_argument("--sleep-min", type=int, default=5, help="循环模式最短等待秒数") + parser.add_argument( + "--sleep-max", type=int, default=30, help="循环模式最长等待秒数" + ) + parser.add_argument("--log-level", default="INFO", help="日志级别") + parser.add_argument("--log-file", help="日志文件路径") + args = parser.parse_args() + + # 配置日志 + setup_logging( + log_level=args.log_level, + log_file=args.log_file + ) + + # 初始化数据库 + if not setup_database(): + return + + # 参数验证 + sleep_min = max(1, args.sleep_min) + sleep_max = max(sleep_min, args.sleep_max) + + count = 0 + print("[Info] Yasal's Seamless OpenAI Auto-Registrar Started for ZJH (重构版本)") + + while True: + count += 1 + print( + f"\n[{datetime.now().strftime('%H:%M:%S')}] >>> 开始第 {count} 次注册流程 <<<" + ) + + try: + result = run_registration(args.proxy) + + if result: + print(f"[*] 注册成功! 邮箱: {result.get('email')}") + else: + print("[-] 本次注册失败。") + + except Exception as e: + print(f"[Error] 发生未捕获异常: {e}") + + if args.once: + break + + wait_time = random.randint(sleep_min, sleep_max) + print(f"[*] 休息 {wait_time} 秒...") + time.sleep(wait_time) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..84c70b4 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,33 @@ +[project] +name = "codex-register-v2" +version = "0.1.0" +description = "OpenAI/Codex CLI 自动注册系统" +requires-python = ">=3.10" +dependencies = [ + "curl-cffi>=0.14.0", + "fastapi>=0.100.0", + "uvicorn>=0.23.0", + "jinja2>=3.1.0", + "python-multipart>=0.0.6", + "pydantic>=2.0.0", + "pydantic-settings>=2.0.0", + "sqlalchemy>=2.0.0", + "aiosqlite>=0.19.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", + "httpx>=0.24.0", +] + +[project.scripts] +codex-register = "cli:main" +codex-webui = "webui:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src"] diff --git a/src/core/register.py b/src/core/register.py index 086483d..15bcd6f 100644 --- a/src/core/register.py +++ b/src/core/register.py @@ -40,11 +40,13 @@ class RegistrationResult: """注册结果""" success: bool email: str = "" + password: str = "" # 注册密码 account_id: str = "" workspace_id: str = "" access_token: str = "" refresh_token: str = "" id_token: str = "" + session_token: str = "" # 会话令牌 error_message: str = "" logs: list = None metadata: dict = None @@ -54,11 +56,13 @@ class RegistrationResult: return { "success": self.success, "email": self.email, + "password": self.password, "account_id": self.account_id, "workspace_id": self.workspace_id, "access_token": self.access_token[:20] + "..." if self.access_token else "", "refresh_token": self.refresh_token[:20] + "..." if self.refresh_token else "", "id_token": self.id_token[:20] + "..." if self.id_token else "", + "session_token": self.session_token[:20] + "..." if self.session_token else "", "error_message": self.error_message, "logs": self.logs or [], "metadata": self.metadata or {}, @@ -107,9 +111,11 @@ class RegistrationEngine: # 状态变量 self.email: Optional[str] = None + self.password: Optional[str] = None # 注册密码 self.email_info: Optional[Dict[str, Any]] = None self.oauth_start: Optional[OAuthStart] = None self.session: Optional[cffi_requests.Session] = None + self.session_token: Optional[str] = None # 会话令牌 self.logs: list = [] def _log(self, message: str, level: str = "info"): @@ -268,6 +274,7 @@ class RegistrationEngine: try: # 生成密码 password = self._generate_password() + self.password = password # 保存密码到实例变量 self._log(f"生成密码: {password}") # 提交密码注册 @@ -662,6 +669,14 @@ class RegistrationEngine: result.access_token = token_info.get("access_token", "") result.refresh_token = token_info.get("refresh_token", "") result.id_token = token_info.get("id_token", "") + result.password = self.password or "" # 保存密码 + + # 尝试获取 session_token 从 cookie + session_cookie = self.session.cookies.get("__Secure-next-auth.session-token") + if session_cookie: + self.session_token = session_cookie + result.session_token = session_cookie + self._log(f"获取到 Session Token") # 17. 完成 self._log("=" * 60) @@ -699,11 +714,17 @@ class RegistrationEngine: return False try: + # 获取默认 client_id + settings = get_settings() + with get_db() as db: # 保存账户信息 account = crud.create_account( db, email=result.email, + password=result.password, + client_id=settings.openai_client_id, + session_token=result.session_token, email_service=self.email_service.service_type.value, email_service_id=self.email_info.get("service_id") if self.email_info else None, account_id=result.account_id, @@ -712,7 +733,7 @@ class RegistrationEngine: refresh_token=result.refresh_token, id_token=result.id_token, proxy_used=self.proxy_url, - metadata=result.metadata + extra_data=result.metadata ) self._log(f"账户已保存到数据库,ID: {account.id}") diff --git a/src/core/token_refresh.py b/src/core/token_refresh.py new file mode 100644 index 0000000..9475a71 --- /dev/null +++ b/src/core/token_refresh.py @@ -0,0 +1,332 @@ +""" +Token 刷新模块 +支持 Session Token 和 OAuth Refresh Token 两种刷新方式 +""" + +import logging +import json +import time +from typing import Optional, Dict, Any, Tuple +from dataclasses import dataclass +from datetime import datetime, timedelta + +from curl_cffi import requests as cffi_requests + +from ..config.settings import get_settings +from ..database.session import get_db +from ..database import crud +from ..database.models import Account + +logger = logging.getLogger(__name__) + + +@dataclass +class TokenRefreshResult: + """Token 刷新结果""" + success: bool + access_token: str = "" + refresh_token: str = "" + expires_at: Optional[datetime] = None + error_message: str = "" + + +class TokenRefreshManager: + """ + Token 刷新管理器 + 支持两种刷新方式: + 1. Session Token 刷新(优先) + 2. OAuth Refresh Token 刷新 + """ + + # OpenAI OAuth 端点 + SESSION_URL = "https://chatgpt.com/api/auth/session" + TOKEN_URL = "https://auth.openai.com/oauth/token" + + def __init__(self, proxy_url: Optional[str] = None): + """ + 初始化 Token 刷新管理器 + + Args: + proxy_url: 代理 URL + """ + self.proxy_url = proxy_url + self.settings = get_settings() + + def _create_session(self) -> cffi_requests.Session: + """创建 HTTP 会话""" + session = cffi_requests.Session(impersonate="chrome120", proxy=self.proxy_url) + return session + + def refresh_by_session_token(self, session_token: str) -> TokenRefreshResult: + """ + 使用 Session Token 刷新 + + Args: + session_token: 会话令牌 + + Returns: + TokenRefreshResult: 刷新结果 + """ + result = TokenRefreshResult(success=False) + + try: + session = self._create_session() + + # 设置会话 Cookie + session.cookies.set( + "__Secure-next-auth.session-token", + session_token, + domain=".chatgpt.com", + path="/" + ) + + # 请求会话端点 + response = session.get( + self.SESSION_URL, + headers={ + "accept": "application/json", + "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + }, + timeout=30 + ) + + if response.status_code != 200: + result.error_message = f"Session token 刷新失败: HTTP {response.status_code}" + logger.warning(result.error_message) + return result + + data = response.json() + + # 提取 access_token + access_token = data.get("accessToken") + if not access_token: + result.error_message = "Session token 刷新失败: 未找到 accessToken" + logger.warning(result.error_message) + return result + + # 提取过期时间 + expires_at = None + expires_str = data.get("expires") + if expires_str: + try: + expires_at = datetime.fromisoformat(expires_str.replace("Z", "+00:00")) + except: + pass + + result.success = True + result.access_token = access_token + result.expires_at = expires_at + + logger.info(f"Session token 刷新成功,过期时间: {expires_at}") + return result + + except Exception as e: + result.error_message = f"Session token 刷新异常: {str(e)}" + logger.error(result.error_message) + return result + + def refresh_by_oauth_token( + self, + refresh_token: str, + client_id: Optional[str] = None + ) -> TokenRefreshResult: + """ + 使用 OAuth Refresh Token 刷新 + + Args: + refresh_token: OAuth 刷新令牌 + client_id: OAuth Client ID + + Returns: + TokenRefreshResult: 刷新结果 + """ + result = TokenRefreshResult(success=False) + + try: + session = self._create_session() + + # 使用配置的 client_id 或默认值 + client_id = client_id or self.settings.openai_client_id + + # 构建请求体 + token_data = { + "client_id": client_id, + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "redirect_uri": self.settings.openai_redirect_uri + } + + response = session.post( + self.TOKEN_URL, + headers={ + "content-type": "application/x-www-form-urlencoded", + "accept": "application/json" + }, + data=token_data, + timeout=30 + ) + + if response.status_code != 200: + result.error_message = f"OAuth token 刷新失败: HTTP {response.status_code}" + logger.warning(f"{result.error_message}, 响应: {response.text[:200]}") + return result + + data = response.json() + + # 提取令牌 + access_token = data.get("access_token") + new_refresh_token = data.get("refresh_token", refresh_token) + expires_in = data.get("expires_in", 3600) + + if not access_token: + result.error_message = "OAuth token 刷新失败: 未找到 access_token" + logger.warning(result.error_message) + return result + + # 计算过期时间 + expires_at = datetime.utcnow() + timedelta(seconds=expires_in) + + result.success = True + result.access_token = access_token + result.refresh_token = new_refresh_token + result.expires_at = expires_at + + logger.info(f"OAuth token 刷新成功,过期时间: {expires_at}") + return result + + except Exception as e: + result.error_message = f"OAuth token 刷新异常: {str(e)}" + logger.error(result.error_message) + return result + + def refresh_account(self, account: Account) -> TokenRefreshResult: + """ + 刷新账号的 Token + + 优先级: + 1. Session Token 刷新 + 2. OAuth Refresh Token 刷新 + + Args: + account: 账号对象 + + Returns: + TokenRefreshResult: 刷新结果 + """ + # 优先尝试 Session Token + if account.session_token: + logger.info(f"尝试使用 Session Token 刷新账号 {account.email}") + result = self.refresh_by_session_token(account.session_token) + if result.success: + return result + logger.warning(f"Session Token 刷新失败,尝试 OAuth 刷新") + + # 尝试 OAuth Refresh Token + if account.refresh_token: + logger.info(f"尝试使用 OAuth Refresh Token 刷新账号 {account.email}") + result = self.refresh_by_oauth_token( + refresh_token=account.refresh_token, + client_id=account.client_id + ) + return result + + # 无可用刷新方式 + return TokenRefreshResult( + success=False, + error_message="账号没有可用的刷新方式(缺少 session_token 和 refresh_token)" + ) + + def validate_token(self, access_token: str) -> Tuple[bool, Optional[str]]: + """ + 验证 Access Token 是否有效 + + Args: + access_token: 访问令牌 + + Returns: + Tuple[bool, Optional[str]]: (是否有效, 错误信息) + """ + try: + session = self._create_session() + + # 调用 OpenAI API 验证 token + response = session.get( + "https://chatgpt.com/backend-api/me", + headers={ + "authorization": f"Bearer {access_token}", + "accept": "application/json" + }, + timeout=30 + ) + + if response.status_code == 200: + return True, None + elif response.status_code == 401: + return False, "Token 无效或已过期" + elif response.status_code == 403: + return False, "账号可能被封禁" + else: + return False, f"验证失败: HTTP {response.status_code}" + + except Exception as e: + return False, f"验证异常: {str(e)}" + + +def refresh_account_token(account_id: int, proxy_url: Optional[str] = None) -> TokenRefreshResult: + """ + 刷新指定账号的 Token 并更新数据库 + + Args: + account_id: 账号 ID + proxy_url: 代理 URL + + Returns: + TokenRefreshResult: 刷新结果 + """ + with get_db() as db: + account = crud.get_account_by_id(db, account_id) + if not account: + return TokenRefreshResult(success=False, error_message="账号不存在") + + manager = TokenRefreshManager(proxy_url=proxy_url) + result = manager.refresh_account(account) + + if result.success: + # 更新数据库 + update_data = { + "access_token": result.access_token, + "last_refresh": datetime.utcnow() + } + + if result.refresh_token: + update_data["refresh_token"] = result.refresh_token + + if result.expires_at: + update_data["expires_at"] = result.expires_at + + crud.update_account(db, account_id, **update_data) + + return result + + +def validate_account_token(account_id: int, proxy_url: Optional[str] = None) -> Tuple[bool, Optional[str]]: + """ + 验证指定账号的 Token 是否有效 + + Args: + account_id: 账号 ID + proxy_url: 代理 URL + + Returns: + Tuple[bool, Optional[str]]: (是否有效, 错误信息) + """ + with get_db() as db: + account = crud.get_account_by_id(db, account_id) + if not account: + return False, "账号不存在" + + if not account.access_token: + return False, "账号没有 access_token" + + manager = TokenRefreshManager(proxy_url=proxy_url) + return manager.validate_token(account.access_token) diff --git a/src/database/crud.py b/src/database/crud.py index e147e0f..1af726b 100644 --- a/src/database/crud.py +++ b/src/database/crud.py @@ -7,7 +7,7 @@ from datetime import datetime, timedelta from sqlalchemy.orm import Session from sqlalchemy import and_, or_, desc, asc, func -from .models import Account, EmailService, RegistrationTask, Setting +from .models import Account, EmailService, RegistrationTask, Setting, Proxy # ============================================================================ @@ -18,6 +18,9 @@ def create_account( db: Session, email: str, email_service: str, + password: Optional[str] = None, + client_id: Optional[str] = None, + session_token: Optional[str] = None, email_service_id: Optional[str] = None, account_id: Optional[str] = None, workspace_id: Optional[str] = None, @@ -25,11 +28,15 @@ def create_account( refresh_token: Optional[str] = None, id_token: Optional[str] = None, proxy_used: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None + expires_at: Optional['datetime'] = None, + extra_data: Optional[Dict[str, Any]] = None ) -> Account: """创建新账户""" db_account = Account( email=email, + password=password, + client_id=client_id, + session_token=session_token, email_service=email_service, email_service_id=email_service_id, account_id=account_id, @@ -38,7 +45,8 @@ def create_account( refresh_token=refresh_token, id_token=id_token, proxy_used=proxy_used, - metadata=metadata or {}, + expires_at=expires_at, + extra_data=extra_data or {}, registered_at=datetime.utcnow() ) db.add(db_account) @@ -369,4 +377,120 @@ def delete_setting(db: Session, key: str) -> bool: db.delete(db_setting) db.commit() - return True \ No newline at end of file + return True + + +# ============================================================================ +# 代理 CRUD +# ============================================================================ + +def create_proxy( + db: Session, + name: str, + type: str, + host: str, + port: int, + username: Optional[str] = None, + password: Optional[str] = None, + enabled: bool = True, + priority: int = 0 +) -> Proxy: + """创建代理配置""" + db_proxy = Proxy( + name=name, + type=type, + host=host, + port=port, + username=username, + password=password, + enabled=enabled, + priority=priority + ) + db.add(db_proxy) + db.commit() + db.refresh(db_proxy) + return db_proxy + + +def get_proxy_by_id(db: Session, proxy_id: int) -> Optional[Proxy]: + """根据 ID 获取代理""" + return db.query(Proxy).filter(Proxy.id == proxy_id).first() + + +def get_proxies( + db: Session, + enabled: Optional[bool] = None, + skip: int = 0, + limit: int = 100 +) -> List[Proxy]: + """获取代理列表""" + query = db.query(Proxy) + + if enabled is not None: + query = query.filter(Proxy.enabled == enabled) + + query = query.order_by(desc(Proxy.created_at)).offset(skip).limit(limit) + return query.all() + + +def get_enabled_proxies(db: Session) -> List[Proxy]: + """获取所有启用的代理""" + return db.query(Proxy).filter(Proxy.enabled == True).all() + + +def update_proxy( + db: Session, + proxy_id: int, + **kwargs +) -> Optional[Proxy]: + """更新代理配置""" + db_proxy = get_proxy_by_id(db, proxy_id) + if not db_proxy: + return None + + for key, value in kwargs.items(): + if hasattr(db_proxy, key): + setattr(db_proxy, key, value) + + db.commit() + db.refresh(db_proxy) + return db_proxy + + +def delete_proxy(db: Session, proxy_id: int) -> bool: + """删除代理配置""" + db_proxy = get_proxy_by_id(db, proxy_id) + if not db_proxy: + return False + + db.delete(db_proxy) + db.commit() + return True + + +def update_proxy_last_used(db: Session, proxy_id: int) -> bool: + """更新代理最后使用时间""" + db_proxy = get_proxy_by_id(db, proxy_id) + if not db_proxy: + return False + + db_proxy.last_used = datetime.utcnow() + db.commit() + return True + + +def get_random_proxy(db: Session) -> Optional[Proxy]: + """随机获取一个启用的代理""" + import random + proxies = get_enabled_proxies(db) + if not proxies: + return None + return random.choice(proxies) + + +def get_proxies_count(db: Session, enabled: Optional[bool] = None) -> int: + """获取代理数量""" + query = db.query(func.count(Proxy.id)) + if enabled is not None: + query = query.filter(Proxy.enabled == enabled) + return query.scalar() \ No newline at end of file diff --git a/src/database/models.py b/src/database/models.py index af6c041..0c43843 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -34,18 +34,20 @@ class Account(Base): id = Column(Integer, primary_key=True, autoincrement=True) email = Column(String(255), nullable=False, unique=True, index=True) - password_hash = Column(String(255)) + password = Column(String(255)) # 注册密码(明文存储) access_token = Column(Text) refresh_token = Column(Text) id_token = Column(Text) + session_token = Column(Text) # 会话令牌(优先刷新方式) + client_id = Column(String(255)) # OAuth Client ID account_id = Column(String(255)) workspace_id = Column(String(255)) email_service = Column(String(50), nullable=False) # 'tempmail', 'outlook', 'custom_domain' email_service_id = Column(String(255)) # 邮箱服务中的ID proxy_used = Column(String(255)) registered_at = Column(DateTime, default=datetime.utcnow) - last_refresh = Column(DateTime) - expires_at = Column(DateTime) + last_refresh = Column(DateTime) # 最后刷新时间 + expires_at = Column(DateTime) # Token 过期时间 status = Column(String(20), default='active') # 'active', 'expired', 'banned', 'failed' extra_data = Column(JSONEncodedDict) # 额外信息存储 created_at = Column(DateTime, default=datetime.utcnow) @@ -56,10 +58,14 @@ class Account(Base): return { 'id': self.id, 'email': self.email, + 'password': self.password, + 'client_id': self.client_id, 'email_service': self.email_service, 'account_id': self.account_id, 'workspace_id': self.workspace_id, 'registered_at': self.registered_at.isoformat() if self.registered_at else None, + 'last_refresh': self.last_refresh.isoformat() if self.last_refresh else None, + 'expires_at': self.expires_at.isoformat() if self.expires_at else None, 'status': self.status, 'proxy_used': self.proxy_used, 'created_at': self.created_at.isoformat() if self.created_at else None, @@ -110,4 +116,59 @@ class Setting(Base): value = Column(Text) description = Column(Text) category = Column(String(50), default='general') # 'general', 'email', 'proxy', 'openai' - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) \ No newline at end of file + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + +class Proxy(Base): + """代理列表表""" + __tablename__ = 'proxies' + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(100), nullable=False) # 代理名称 + type = Column(String(20), nullable=False, default='http') # http, socks5 + host = Column(String(255), nullable=False) + port = Column(Integer, nullable=False) + username = Column(String(100)) + password = Column(String(255)) + enabled = Column(Boolean, default=True) + priority = Column(Integer, default=0) # 优先级(保留字段) + last_used = Column(DateTime) # 最后使用时间 + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + def to_dict(self, include_password: bool = False) -> Dict[str, Any]: + """转换为字典""" + result = { + 'id': self.id, + 'name': self.name, + 'type': self.type, + 'host': self.host, + 'port': self.port, + 'username': self.username, + 'enabled': self.enabled, + 'priority': self.priority, + 'last_used': self.last_used.isoformat() if self.last_used else None, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'updated_at': self.updated_at.isoformat() if self.updated_at else None, + } + if include_password: + result['password'] = self.password + else: + result['has_password'] = bool(self.password) + return result + + @property + def proxy_url(self) -> str: + """获取完整的代理 URL""" + if self.type == "http": + scheme = "http" + elif self.type == "socks5": + scheme = "socks5" + else: + scheme = self.type + + auth = "" + if self.username and self.password: + auth = f"{self.username}:{self.password}@" + + return f"{scheme}://{auth}{self.host}:{self.port}" \ No newline at end of file diff --git a/src/web/app.py b/src/web/app.py index fb5056e..b99e6bd 100644 --- a/src/web/app.py +++ b/src/web/app.py @@ -78,6 +78,11 @@ def create_app() -> FastAPI: """账号管理页面""" return templates.TemplateResponse("accounts.html", {"request": request}) + @app.get("/email-services", response_class=HTMLResponse) + async def email_services_page(request: Request): + """邮箱服务管理页面""" + return templates.TemplateResponse("email_services.html", {"request": request}) + @app.get("/settings", response_class=HTMLResponse) async def settings_page(request: Request): """设置页面""" diff --git a/src/web/routes/accounts.py b/src/web/routes/accounts.py index d192827..a666819 100644 --- a/src/web/routes/accounts.py +++ b/src/web/routes/accounts.py @@ -26,10 +26,14 @@ class AccountResponse(BaseModel): """账号响应模型""" id: int email: str + password: Optional[str] = None + client_id: Optional[str] = None email_service: str account_id: Optional[str] = None workspace_id: Optional[str] = None registered_at: Optional[str] = None + last_refresh: Optional[str] = None + expires_at: Optional[str] = None status: str proxy_used: Optional[str] = None created_at: Optional[str] = None @@ -69,10 +73,14 @@ def account_to_response(account: Account) -> AccountResponse: return AccountResponse( id=account.id, email=account.email, + password=account.password, + client_id=account.client_id, email_service=account.email_service, account_id=account.account_id, workspace_id=account.workspace_id, registered_at=account.registered_at.isoformat() if account.registered_at else None, + last_refresh=account.last_refresh.isoformat() if account.last_refresh else None, + expires_at=account.expires_at.isoformat() if account.expires_at else None, status=account.status, proxy_used=account.proxy_used, created_at=account.created_at.isoformat() if account.created_at else None, @@ -260,13 +268,18 @@ async def export_accounts_json( for acc in accounts: export_data.append({ "email": acc.email, + "password": acc.password, + "client_id": acc.client_id, "account_id": acc.account_id, "workspace_id": acc.workspace_id, "access_token": acc.access_token, "refresh_token": acc.refresh_token, "id_token": acc.id_token, + "session_token": acc.session_token, "email_service": acc.email_service, "registered_at": acc.registered_at.isoformat() if acc.registered_at else None, + "last_refresh": acc.last_refresh.isoformat() if acc.last_refresh else None, + "expires_at": acc.expires_at.isoformat() if acc.expires_at else None, "status": acc.status, }) @@ -310,9 +323,10 @@ async def export_accounts_csv( # 写入表头 writer.writerow([ - "ID", "Email", "Account ID", "Workspace ID", - "Access Token", "Refresh Token", "ID Token", - "Email Service", "Status", "Registered At" + "ID", "Email", "Password", "Client ID", + "Account ID", "Workspace ID", + "Access Token", "Refresh Token", "ID Token", "Session Token", + "Email Service", "Status", "Registered At", "Last Refresh", "Expires At" ]) # 写入数据 @@ -320,14 +334,19 @@ async def export_accounts_csv( writer.writerow([ acc.id, acc.email, + acc.password or "", + acc.client_id or "", acc.account_id or "", acc.workspace_id or "", acc.access_token or "", acc.refresh_token or "", acc.id_token or "", + acc.session_token or "", acc.email_service, acc.status, - acc.registered_at.isoformat() if acc.registered_at else "" + acc.registered_at.isoformat() if acc.registered_at else "", + acc.last_refresh.isoformat() if acc.last_refresh else "", + acc.expires_at.isoformat() if acc.expires_at else "" ]) # 生成文件名 @@ -367,3 +386,123 @@ async def get_accounts_stats(): "by_status": {status: count for status, count in status_stats}, "by_email_service": {service: count for service, count in service_stats} } + + +# ============== Token 刷新相关 ============== + +class TokenRefreshRequest(BaseModel): + """Token 刷新请求""" + proxy: Optional[str] = None + + +class BatchRefreshRequest(BaseModel): + """批量刷新请求""" + ids: List[int] + proxy: Optional[str] = None + + +class TokenValidateRequest(BaseModel): + """Token 验证请求""" + proxy: Optional[str] = None + + +class BatchValidateRequest(BaseModel): + """批量验证请求""" + ids: List[int] + proxy: Optional[str] = None + + +@router.post("/{account_id}/refresh") +async def refresh_account_token(account_id: int, request: TokenRefreshRequest = None): + """刷新单个账号的 Token""" + from ...core.token_refresh import refresh_account_token as do_refresh + + proxy = request.proxy if request else None + result = do_refresh(account_id, proxy) + + if result.success: + return { + "success": True, + "message": "Token 刷新成功", + "expires_at": result.expires_at.isoformat() if result.expires_at else None + } + else: + return { + "success": False, + "error": result.error_message + } + + +@router.post("/batch-refresh") +async def batch_refresh_tokens(request: BatchRefreshRequest, background_tasks: BackgroundTasks): + """批量刷新账号 Token""" + from ...core.token_refresh import refresh_account_token as do_refresh + + results = { + "success_count": 0, + "failed_count": 0, + "errors": [] + } + + for account_id in request.ids: + try: + result = do_refresh(account_id, request.proxy) + if result.success: + results["success_count"] += 1 + else: + results["failed_count"] += 1 + results["errors"].append({"id": account_id, "error": result.error_message}) + except Exception as e: + results["failed_count"] += 1 + results["errors"].append({"id": account_id, "error": str(e)}) + + return results + + +@router.post("/{account_id}/validate") +async def validate_account_token(account_id: int, request: TokenValidateRequest = None): + """验证单个账号的 Token 有效性""" + from ...core.token_refresh import validate_account_token as do_validate + + proxy = request.proxy if request else None + is_valid, error = do_validate(account_id, proxy) + + return { + "id": account_id, + "valid": is_valid, + "error": error + } + + +@router.post("/batch-validate") +async def batch_validate_tokens(request: BatchValidateRequest): + """批量验证账号 Token 有效性""" + from ...core.token_refresh import validate_account_token as do_validate + + results = { + "valid_count": 0, + "invalid_count": 0, + "details": [] + } + + for account_id in request.ids: + try: + is_valid, error = do_validate(account_id, request.proxy) + results["details"].append({ + "id": account_id, + "valid": is_valid, + "error": error + }) + if is_valid: + results["valid_count"] += 1 + else: + results["invalid_count"] += 1 + except Exception as e: + results["invalid_count"] += 1 + results["details"].append({ + "id": account_id, + "valid": False, + "error": str(e) + }) + + return results diff --git a/src/web/routes/email_services.py b/src/web/routes/email_services.py index d8454ed..e49a70f 100644 --- a/src/web/routes/email_services.py +++ b/src/web/routes/email_services.py @@ -43,6 +43,7 @@ class EmailServiceResponse(BaseModel): name: str enabled: bool priority: int + config: Optional[Dict[str, Any]] = None # 过滤敏感信息后的配置 last_used: Optional[str] = None created_at: Optional[str] = None updated_at: Optional[str] = None @@ -82,6 +83,29 @@ class OutlookBatchImportResponse(BaseModel): # ============== Helper Functions ============== +# 敏感字段列表,返回响应时需要过滤 +SENSITIVE_FIELDS = {'password', 'api_key', 'refresh_token', 'access_token'} + +def filter_sensitive_config(config: Dict[str, Any]) -> Dict[str, Any]: + """过滤敏感配置信息""" + if not config: + return {} + + filtered = {} + for key, value in config.items(): + if key in SENSITIVE_FIELDS: + # 敏感字段不返回,但标记是否存在 + filtered[f"has_{key}"] = bool(value) + else: + filtered[key] = value + + # 为 Outlook 计算是否有 OAuth + if config.get('client_id') and config.get('refresh_token'): + filtered['has_oauth'] = True + + return filtered + + def service_to_response(service: EmailServiceModel) -> EmailServiceResponse: """转换服务模型为响应""" return EmailServiceResponse( @@ -90,6 +114,7 @@ def service_to_response(service: EmailServiceModel) -> EmailServiceResponse: name=service.name, enabled=service.enabled, priority=service.priority, + config=filter_sensitive_config(service.config), last_used=service.last_used.isoformat() if service.last_used else None, created_at=service.created_at.isoformat() if service.created_at else None, updated_at=service.updated_at.isoformat() if service.updated_at else None, @@ -98,6 +123,39 @@ def service_to_response(service: EmailServiceModel) -> EmailServiceResponse: # ============== API Endpoints ============== +@router.get("/stats") +async def get_email_services_stats(): + """获取邮箱服务统计信息""" + with get_db() as db: + from sqlalchemy import func + + # 按类型统计 + type_stats = db.query( + EmailServiceModel.service_type, + func.count(EmailServiceModel.id) + ).group_by(EmailServiceModel.service_type).all() + + # 启用数量 + enabled_count = db.query(func.count(EmailServiceModel.id)).filter( + EmailServiceModel.enabled == True + ).scalar() + + stats = { + 'outlook_count': 0, + 'custom_count': 0, + 'tempmail_available': True, # 临时邮箱始终可用 + 'enabled_count': enabled_count + } + + for service_type, count in type_stats: + if service_type == 'outlook': + stats['outlook_count'] = count + elif service_type == 'custom_domain': + stats['custom_count'] = count + + return stats + + @router.get("/types") async def get_service_types(): """获取支持的邮箱服务类型""" @@ -435,3 +493,36 @@ async def batch_delete_outlook(service_ids: List[int]): db.commit() return {"success": True, "deleted": deleted, "message": f"已删除 {deleted} 个服务"} + + +# ============== 临时邮箱测试 ============== + +class TempmailTestRequest(BaseModel): + """临时邮箱测试请求""" + api_url: Optional[str] = None + + +@router.post("/test-tempmail") +async def test_tempmail_service(request: TempmailTestRequest): + """测试临时邮箱服务是否可用""" + try: + from ...services import EmailServiceFactory, EmailServiceType + from ...config.settings import get_settings + + settings = get_settings() + base_url = request.api_url or settings.tempmail_base_url + + config = {"base_url": base_url} + tempmail = EmailServiceFactory.create(EmailServiceType.TEMPMAIL, config) + + # 检查服务健康状态 + health = tempmail.check_health() + + if health: + return {"success": True, "message": "临时邮箱连接正常"} + else: + return {"success": False, "message": "临时邮箱连接失败"} + + except Exception as e: + logger.error(f"测试临时邮箱失败: {e}") + return {"success": False, "message": f"测试失败: {str(e)}"} diff --git a/src/web/routes/registration.py b/src/web/routes/registration.py index da7b1c2..71d14d0 100644 --- a/src/web/routes/registration.py +++ b/src/web/routes/registration.py @@ -7,14 +7,14 @@ import logging import uuid import random from datetime import datetime -from typing import List, Optional, Dict +from typing import List, Optional, Dict, Tuple from fastapi import APIRouter, HTTPException, Query, BackgroundTasks from pydantic import BaseModel, Field from ...database import crud from ...database.session import get_db -from ...database.models import RegistrationTask +from ...database.models import RegistrationTask, Proxy from ...core.register import RegistrationEngine, RegistrationResult from ...services import EmailServiceFactory, EmailServiceType from ...config.settings import get_settings @@ -28,6 +28,38 @@ running_tasks: dict = {} batch_tasks: Dict[str, dict] = {} +# ============== Proxy Helper Functions ============== + +def get_proxy_for_registration(db) -> Tuple[Optional[str], Optional[int]]: + """ + 获取用于注册的代理 + + 策略: + 1. 优先从代理列表中随机选择一个启用的代理 + 2. 如果代理列表为空,使用系统设置中的默认代理 + + Returns: + Tuple[proxy_url, proxy_id]: 代理 URL 和代理 ID(如果来自代理列表) + """ + # 先尝试从代理列表中获取 + proxy = crud.get_random_proxy(db) + if proxy: + return proxy.proxy_url, proxy.id + + # 代理列表为空,使用系统设置中的默认代理 + settings = get_settings() + if settings.proxy_enabled and settings.proxy_url: + return settings.proxy_url, None + + return None, None + + +def update_proxy_usage(db, proxy_id: Optional[int]): + """更新代理的使用时间""" + if proxy_id: + crud.update_proxy_last_used(db, proxy_id) + + # ============== Pydantic Models ============== class RegistrationTaskCreate(BaseModel): @@ -114,6 +146,20 @@ async def run_registration_task(task_uuid: str, email_service_type: str, proxy: logger.error(f"任务不存在: {task_uuid}") return + # 确定使用的代理 + # 如果前端传入了代理参数,使用传入的 + # 否则从代理列表或系统设置中获取 + actual_proxy_url = proxy + proxy_id = None + + if not actual_proxy_url: + actual_proxy_url, proxy_id = get_proxy_for_registration(db) + if actual_proxy_url: + logger.info(f"任务 {task_uuid} 使用代理: {actual_proxy_url[:50]}...") + + # 更新任务的代理记录 + crud.update_registration_task(db, task_uuid, proxy=actual_proxy_url) + # 创建邮箱服务 service_type = EmailServiceType(email_service_type) settings = get_settings() @@ -140,7 +186,7 @@ async def run_registration_task(task_uuid: str, email_service_type: str, proxy: "base_url": settings.tempmail_base_url, "timeout": settings.tempmail_timeout, "max_retries": settings.tempmail_max_retries, - "proxy_url": proxy, + "proxy_url": actual_proxy_url, } elif service_type == EmailServiceType.CUSTOM_DOMAIN: # 检查数据库中是否有可用的自定义域名服务 @@ -158,7 +204,7 @@ async def run_registration_task(task_uuid: str, email_service_type: str, proxy: config = { "base_url": settings.custom_domain_base_url, "api_key": settings.custom_domain_api_key.get_secret_value() if settings.custom_domain_api_key else "", - "proxy_url": proxy, + "proxy_url": actual_proxy_url, } else: raise ValueError("没有可用的自定义域名邮箱服务,请先在设置中配置") @@ -188,7 +234,7 @@ async def run_registration_task(task_uuid: str, email_service_type: str, proxy: engine = RegistrationEngine( email_service=email_service, - proxy_url=proxy, + proxy_url=actual_proxy_url, callback_logger=log_callback, task_uuid=task_uuid ) @@ -197,6 +243,9 @@ async def run_registration_task(task_uuid: str, email_service_type: str, proxy: result = engine.run() if result.success: + # 更新代理使用时间 + update_proxy_usage(db, proxy_id) + # 保存到数据库 engine.save_to_database(result) diff --git a/src/web/routes/settings.py b/src/web/routes/settings.py index 0b2777d..b3b9242 100644 --- a/src/web/routes/settings.py +++ b/src/web/routes/settings.py @@ -135,6 +135,66 @@ async def update_proxy_settings(request: ProxySettings): return {"success": True, "message": "代理设置已更新"} +@router.post("/proxy/test") +async def test_proxy_settings(request: ProxySettings): + """测试代理连接""" + import time + from curl_cffi import requests as cffi_requests + + # 构建代理 URL + if request.type == "http": + scheme = "http" + elif request.type == "socks5": + scheme = "socks5" + else: + raise HTTPException(status_code=400, detail="不支持的代理类型") + + auth = "" + if request.username and request.password: + auth = f"{request.username}:{request.password}@" + + proxy_url = f"{scheme}://{auth}{request.host}:{request.port}" + + # 测试连接 + test_url = "https://api.ipify.org?format=json" + start_time = time.time() + + try: + proxies = { + "http": proxy_url, + "https": proxy_url + } + + response = cffi_requests.get( + test_url, + proxies=proxies, + timeout=3, + impersonate="chrome110" + ) + + elapsed_time = time.time() - start_time + + if response.status_code == 200: + ip_info = response.json() + return { + "success": True, + "ip": ip_info.get("ip", ""), + "response_time": round(elapsed_time * 1000), # 毫秒 + "message": f"代理连接成功,出口 IP: {ip_info.get('ip', 'unknown')}" + } + else: + return { + "success": False, + "message": f"代理返回错误状态码: {response.status_code}" + } + + except Exception as e: + return { + "success": False, + "message": f"代理连接失败: {str(e)}" + } + + @router.get("/registration") async def get_registration_settings(): """获取注册设置""" @@ -292,3 +352,275 @@ async def get_recent_logs( } except Exception as e: return {"logs": [], "error": str(e)} + + +# ============== 临时邮箱设置 ============== + +class TempmailSettings(BaseModel): + """临时邮箱设置""" + api_url: Optional[str] = None + enabled: bool = True + + +@router.get("/tempmail") +async def get_tempmail_settings(): + """获取临时邮箱设置""" + settings = get_settings() + + return { + "api_url": settings.tempmail_base_url, + "timeout": settings.tempmail_timeout, + "max_retries": settings.tempmail_max_retries, + "enabled": True # 临时邮箱默认可用 + } + + +@router.post("/tempmail") +async def update_tempmail_settings(request: TempmailSettings): + """更新临时邮箱设置""" + update_dict = {} + + if request.api_url: + update_dict["tempmail_base_url"] = request.api_url + + update_settings(**update_dict) + + return {"success": True, "message": "临时邮箱设置已更新"} + + +# ============== 代理列表 CRUD ============== + +class ProxyCreateRequest(BaseModel): + """创建代理请求""" + name: str + type: str = "http" # http, socks5 + host: str + port: int + username: Optional[str] = None + password: Optional[str] = None + enabled: bool = True + priority: int = 0 + + +class ProxyUpdateRequest(BaseModel): + """更新代理请求""" + name: Optional[str] = None + type: Optional[str] = None + host: Optional[str] = None + port: Optional[int] = None + username: Optional[str] = None + password: Optional[str] = None + enabled: Optional[bool] = None + priority: Optional[int] = None + + +@router.get("/proxies") +async def get_proxies_list(enabled: Optional[bool] = None): + """获取代理列表""" + with get_db() as db: + proxies = crud.get_proxies(db, enabled=enabled) + return { + "proxies": [p.to_dict() for p in proxies], + "total": len(proxies) + } + + +@router.post("/proxies") +async def create_proxy_item(request: ProxyCreateRequest): + """创建代理""" + with get_db() as db: + proxy = crud.create_proxy( + db, + name=request.name, + type=request.type, + host=request.host, + port=request.port, + username=request.username, + password=request.password, + enabled=request.enabled, + priority=request.priority + ) + return {"success": True, "proxy": proxy.to_dict()} + + +@router.get("/proxies/{proxy_id}") +async def get_proxy_item(proxy_id: int): + """获取单个代理""" + with get_db() as db: + proxy = crud.get_proxy_by_id(db, proxy_id) + if not proxy: + raise HTTPException(status_code=404, detail="代理不存在") + return proxy.to_dict(include_password=True) + + +@router.patch("/proxies/{proxy_id}") +async def update_proxy_item(proxy_id: int, request: ProxyUpdateRequest): + """更新代理""" + with get_db() as db: + update_data = {} + if request.name is not None: + update_data["name"] = request.name + if request.type is not None: + update_data["type"] = request.type + if request.host is not None: + update_data["host"] = request.host + if request.port is not None: + update_data["port"] = request.port + if request.username is not None: + update_data["username"] = request.username + if request.password is not None: + update_data["password"] = request.password + if request.enabled is not None: + update_data["enabled"] = request.enabled + if request.priority is not None: + update_data["priority"] = request.priority + + proxy = crud.update_proxy(db, proxy_id, **update_data) + if not proxy: + raise HTTPException(status_code=404, detail="代理不存在") + return {"success": True, "proxy": proxy.to_dict()} + + +@router.delete("/proxies/{proxy_id}") +async def delete_proxy_item(proxy_id: int): + """删除代理""" + with get_db() as db: + success = crud.delete_proxy(db, proxy_id) + if not success: + raise HTTPException(status_code=404, detail="代理不存在") + return {"success": True, "message": "代理已删除"} + + +@router.post("/proxies/{proxy_id}/test") +async def test_proxy_item(proxy_id: int): + """测试单个代理""" + import time + from curl_cffi import requests as cffi_requests + + with get_db() as db: + proxy = crud.get_proxy_by_id(db, proxy_id) + if not proxy: + raise HTTPException(status_code=404, detail="代理不存在") + + proxy_url = proxy.proxy_url + test_url = "https://api.ipify.org?format=json" + start_time = time.time() + + try: + proxies = { + "http": proxy_url, + "https": proxy_url + } + + response = cffi_requests.get( + test_url, + proxies=proxies, + timeout=3, + impersonate="chrome110" + ) + + elapsed_time = time.time() - start_time + + if response.status_code == 200: + ip_info = response.json() + return { + "success": True, + "ip": ip_info.get("ip", ""), + "response_time": round(elapsed_time * 1000), + "message": f"代理连接成功,出口 IP: {ip_info.get('ip', 'unknown')}" + } + else: + return { + "success": False, + "message": f"代理返回错误状态码: {response.status_code}" + } + + except Exception as e: + return { + "success": False, + "message": f"代理连接失败: {str(e)}" + } + + +@router.post("/proxies/test-all") +async def test_all_proxies(): + """测试所有启用的代理""" + import time + from curl_cffi import requests as cffi_requests + + with get_db() as db: + proxies = crud.get_enabled_proxies(db) + + results = [] + for proxy in proxies: + proxy_url = proxy.proxy_url + test_url = "https://api.ipify.org?format=json" + start_time = time.time() + + try: + proxies_dict = { + "http": proxy_url, + "https": proxy_url + } + + response = cffi_requests.get( + test_url, + proxies=proxies_dict, + timeout=3, + impersonate="chrome110" + ) + + elapsed_time = time.time() - start_time + + if response.status_code == 200: + ip_info = response.json() + results.append({ + "id": proxy.id, + "name": proxy.name, + "success": True, + "ip": ip_info.get("ip", ""), + "response_time": round(elapsed_time * 1000) + }) + else: + results.append({ + "id": proxy.id, + "name": proxy.name, + "success": False, + "message": f"状态码: {response.status_code}" + }) + + except Exception as e: + results.append({ + "id": proxy.id, + "name": proxy.name, + "success": False, + "message": str(e) + }) + + success_count = sum(1 for r in results if r["success"]) + return { + "total": len(proxies), + "success": success_count, + "failed": len(proxies) - success_count, + "results": results + } + + +@router.post("/proxies/{proxy_id}/enable") +async def enable_proxy(proxy_id: int): + """启用代理""" + with get_db() as db: + proxy = crud.update_proxy(db, proxy_id, enabled=True) + if not proxy: + raise HTTPException(status_code=404, detail="代理不存在") + return {"success": True, "message": "代理已启用"} + + +@router.post("/proxies/{proxy_id}/disable") +async def disable_proxy(proxy_id: int): + """禁用代理""" + with get_db() as db: + proxy = crud.update_proxy(db, proxy_id, enabled=False) + if not proxy: + raise HTTPException(status_code=404, detail="代理不存在") + return {"success": True, "message": "代理已禁用"} diff --git a/static/js/accounts.js b/static/js/accounts.js index bccbeb3..2373b14 100644 --- a/static/js/accounts.js +++ b/static/js/accounts.js @@ -21,6 +21,8 @@ const elements = { filterService: document.getElementById('filter-service'), searchInput: document.getElementById('search-input'), refreshBtn: document.getElementById('refresh-btn'), + batchRefreshBtn: document.getElementById('batch-refresh-btn'), + batchValidateBtn: document.getElementById('batch-validate-btn'), batchDeleteBtn: document.getElementById('batch-delete-btn'), exportBtn: document.getElementById('export-btn'), exportMenu: document.getElementById('export-menu'), @@ -75,6 +77,12 @@ function initEventListeners() { toast.info('已刷新'); }); + // 批量刷新Token + elements.batchRefreshBtn.addEventListener('click', handleBatchRefresh); + + // 批量验证Token + elements.batchValidateBtn.addEventListener('click', handleBatchValidate); + // 批量删除 elements.batchDeleteBtn.addEventListener('click', handleBatchDelete); @@ -173,7 +181,7 @@ async function loadAccounts() { // 显示加载状态 elements.table.innerHTML = ` - +
@@ -209,7 +217,7 @@ async function loadAccounts() { console.error('加载账号列表失败:', error); elements.table.innerHTML = ` - +
❌
加载失败
@@ -228,7 +236,7 @@ function renderAccounts(accounts) { if (accounts.length === 0) { elements.table.innerHTML = ` - +
📭
暂无数据
@@ -252,21 +260,30 @@ function renderAccounts(accounts) { ${escapeHtml(account.email)} + + ${account.password + ? `${escapeHtml(account.password.substring(0, 4) + '****')}` + : '-'} + ${getServiceTypeText(account.email_service)} ${getStatusText('account', account.status)} - ${format.date(account.registered_at)} + ${format.date(account.last_refresh) || '-'}
+ + ${account.password ? `` : ''} @@ -289,6 +306,19 @@ function renderAccounts(accounts) { }); } +// 切换密码显示 +function togglePassword(element, password) { + if (element.dataset.revealed === 'true') { + element.textContent = password.substring(0, 4) + '****'; + element.classList.add('password-hidden'); + element.dataset.revealed = 'false'; + } else { + element.textContent = password; + element.classList.remove('password-hidden'); + element.dataset.revealed = 'true'; + } +} + // 更新分页 function updatePagination() { const totalPages = Math.max(1, Math.ceil(totalAccounts / pageSize)); @@ -303,7 +333,74 @@ function updatePagination() { function updateBatchButtons() { const count = selectedAccounts.size; elements.batchDeleteBtn.disabled = count === 0; - elements.batchDeleteBtn.textContent = count > 0 ? `🗑️ 删除选中 (${count})` : '🗑️ 批量删除'; + elements.batchRefreshBtn.disabled = count === 0; + elements.batchValidateBtn.disabled = count === 0; + + elements.batchDeleteBtn.textContent = count > 0 ? `🗑️ 删除 (${count})` : '🗑️ 批量删除'; + elements.batchRefreshBtn.textContent = count > 0 ? `🔄 刷新 (${count})` : '🔄 刷新Token'; + elements.batchValidateBtn.textContent = count > 0 ? `✅ 验证 (${count})` : '✅ 验证Token'; +} + +// 刷新单个账号Token +async function refreshToken(id) { + try { + toast.info('正在刷新Token...'); + const result = await api.post(`/accounts/${id}/refresh`); + + if (result.success) { + toast.success('Token刷新成功'); + loadAccounts(); + } else { + toast.error('刷新失败: ' + (result.error || '未知错误')); + } + } catch (error) { + toast.error('刷新失败: ' + error.message); + } +} + +// 批量刷新Token +async function handleBatchRefresh() { + if (selectedAccounts.size === 0) return; + + const confirmed = await confirm(`确定要刷新选中的 ${selectedAccounts.size} 个账号的Token吗?`); + if (!confirmed) return; + + elements.batchRefreshBtn.disabled = true; + elements.batchRefreshBtn.textContent = '刷新中...'; + + try { + const result = await api.post('/accounts/batch-refresh', { + ids: Array.from(selectedAccounts) + }); + + toast.success(`成功刷新 ${result.success_count} 个,失败 ${result.failed_count} 个`); + loadAccounts(); + } catch (error) { + toast.error('批量刷新失败: ' + error.message); + } finally { + updateBatchButtons(); + } +} + +// 批量验证Token +async function handleBatchValidate() { + if (selectedAccounts.size === 0) return; + + elements.batchValidateBtn.disabled = true; + elements.batchValidateBtn.textContent = '验证中...'; + + try { + const result = await api.post('/accounts/batch-validate', { + ids: Array.from(selectedAccounts) + }); + + toast.info(`有效: ${result.valid_count},无效: ${result.invalid_count}`); + loadAccounts(); + } catch (error) { + toast.error('批量验证失败: ' + error.message); + } finally { + updateBatchButtons(); + } } // 查看账号详情 @@ -323,6 +420,15 @@ async function viewAccount(id) {
+
+ 密码 + + ${account.password + ? `${escapeHtml(account.password)} + ` + : '-'} + +
邮箱服务 ${getServiceTypeText(account.email_service)} @@ -339,6 +445,10 @@ async function viewAccount(id) { 注册时间 ${format.date(account.registered_at)}
+
+ 最后刷新 + ${format.date(account.last_refresh) || '-'} +
Account ID @@ -351,6 +461,12 @@ async function viewAccount(id) { ${escapeHtml(account.workspace_id || '-')}
+
+ Client ID + + ${escapeHtml(account.client_id || '-')} + +
Access Token
@@ -366,6 +482,11 @@ async function viewAccount(id) {
+
+ +
`; elements.detailModal.classList.add('active'); diff --git a/static/js/app.js b/static/js/app.js index 5e59770..538ea29 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -8,6 +8,7 @@ let currentTask = null; let currentBatch = null; let logPollingInterval = null; let batchPollingInterval = null; +let accountsPollingInterval = null; let isBatchMode = false; let availableServices = { tempmail: { available: true, services: [] }, @@ -19,7 +20,6 @@ let availableServices = { const elements = { form: document.getElementById('registration-form'), emailService: document.getElementById('email-service'), - proxy: document.getElementById('proxy'), regMode: document.getElementById('reg-mode'), batchCountGroup: document.getElementById('batch-count-group'), batchCount: document.getElementById('batch-count'), @@ -28,8 +28,8 @@ const elements = { intervalMax: document.getElementById('interval-max'), startBtn: document.getElementById('start-btn'), cancelBtn: document.getElementById('cancel-btn'), - taskStatusCard: document.getElementById('task-status-card'), - batchStatusCard: document.getElementById('batch-status-card'), + taskStatusRow: document.getElementById('task-status-row'), + batchProgressSection: document.getElementById('batch-progress-section'), consoleLog: document.getElementById('console-log'), clearLogBtn: document.getElementById('clear-log-btn'), // 任务状态 @@ -39,18 +39,23 @@ const elements = { taskService: document.getElementById('task-service'), taskStatusBadge: document.getElementById('task-status-badge'), // 批量状态 - batchProgress: document.getElementById('batch-progress'), + batchProgressText: document.getElementById('batch-progress-text'), + batchProgressPercent: document.getElementById('batch-progress-percent'), progressBar: document.getElementById('progress-bar'), batchSuccess: document.getElementById('batch-success'), batchFailed: document.getElementById('batch-failed'), - batchRemaining: document.getElementById('batch-remaining') + batchRemaining: document.getElementById('batch-remaining'), + // 已注册账号 + recentAccountsTable: document.getElementById('recent-accounts-table'), + refreshAccountsBtn: document.getElementById('refresh-accounts-btn') }; // 初始化 document.addEventListener('DOMContentLoaded', () => { initEventListeners(); - loadSavedProxy(); loadAvailableServices(); + loadRecentAccounts(); + startAccountsPolling(); }); // 事件监听 @@ -71,18 +76,12 @@ function initEventListeners() { elements.clearLogBtn.addEventListener('click', () => { elements.consoleLog.innerHTML = '
[系统] 日志已清空
'; }); -} -// 加载保存的代理设置 -async function loadSavedProxy() { - try { - const settings = await api.get('/settings'); - if (settings.proxy?.host) { - elements.proxy.value = `${settings.proxy.type}://${settings.proxy.host}:${settings.proxy.port}`; - } - } catch (error) { - // 忽略错误 - } + // 刷新账号列表 + elements.refreshAccountsBtn.addEventListener('click', () => { + loadRecentAccounts(); + toast.info('已刷新'); + }); } // 加载可用的邮箱服务 @@ -143,7 +142,7 @@ function updateEmailServiceOptions() { const option = document.createElement('option'); option.value = ''; - option.textContent = '请先在设置中导入账户'; + option.textContent = '请先在邮箱服务页面导入账户'; option.disabled = true; optgroup.appendChild(option); @@ -173,7 +172,7 @@ function updateEmailServiceOptions() { const option = document.createElement('option'); option.value = ''; - option.textContent = '请先在设置中添加服务'; + option.textContent = '请先在邮箱服务页面添加服务'; option.disabled = true; optgroup.appendChild(option); @@ -223,7 +222,6 @@ async function handleStartRegistration(e) { } const [emailServiceType, serviceId] = selectedValue.split(':'); - const proxy = elements.proxy.value.trim() || null; // 禁用开始按钮 elements.startBtn.disabled = true; @@ -232,10 +230,9 @@ async function handleStartRegistration(e) { // 清空日志 elements.consoleLog.innerHTML = ''; - // 构建请求数据 + // 构建请求数据(代理从设置中自动获取) const requestData = { - email_service_type: emailServiceType, - proxy: proxy + email_service_type: emailServiceType }; // 如果选择了数据库中的服务,传递 service_id @@ -365,6 +362,8 @@ function startLogPolling(taskUuid) { if (data.status === 'completed') { addLog('success', '[成功] 注册成功!'); toast.success('注册成功!'); + // 刷新账号列表 + loadRecentAccounts(); } else if (data.status === 'failed') { addLog('error', '[错误] 注册失败'); toast.error('注册失败'); @@ -401,6 +400,8 @@ function startBatchPolling(batchId) { addLog('info', `[完成] 批量任务完成!成功: ${data.success}, 失败: ${data.failed}`); if (data.success > 0) { toast.success(`批量注册完成,成功 ${data.success} 个`); + // 刷新账号列表 + loadRecentAccounts(); } else { toast.warning('批量注册完成,但没有成功注册任何账号'); } @@ -421,9 +422,10 @@ function stopBatchPolling() { // 显示任务状态 function showTaskStatus(task) { - elements.taskStatusCard.style.display = 'block'; - elements.batchStatusCard.style.display = 'none'; - elements.taskId.textContent = task.task_uuid; + elements.taskStatusRow.style.display = 'grid'; + elements.batchProgressSection.style.display = 'none'; + elements.taskStatusBadge.style.display = 'inline-flex'; + elements.taskId.textContent = task.task_uuid.substring(0, 8) + '...'; elements.taskEmail.textContent = '-'; elements.taskService.textContent = '-'; } @@ -446,9 +448,11 @@ function updateTaskStatus(status) { // 显示批量状态 function showBatchStatus(batch) { - elements.batchStatusCard.style.display = 'block'; - elements.taskStatusCard.style.display = 'none'; - elements.batchProgress.textContent = `0/${batch.count}`; + elements.batchProgressSection.style.display = 'block'; + elements.taskStatusRow.style.display = 'none'; + elements.taskStatusBadge.style.display = 'none'; + elements.batchProgressText.textContent = `0/${batch.count}`; + elements.batchProgressPercent.textContent = '0%'; elements.progressBar.style.width = '0%'; elements.batchSuccess.textContent = '0'; elements.batchFailed.textContent = '0'; @@ -461,8 +465,9 @@ function showBatchStatus(batch) { // 更新批量进度 function updateBatchProgress(data) { - const progress = (data.completed / data.total * 100).toFixed(0); - elements.batchProgress.textContent = data.progress || `${data.completed}/${data.total}`; + const progress = ((data.completed / data.total) * 100).toFixed(0); + elements.batchProgressText.textContent = `${data.completed}/${data.total}`; + elements.batchProgressPercent.textContent = `${progress}%`; elements.progressBar.style.width = `${progress}%`; elements.batchSuccess.textContent = data.success; elements.batchFailed.textContent = data.failed; @@ -485,6 +490,63 @@ function updateBatchProgress(data) { } } +// 加载最近注册的账号 +async function loadRecentAccounts() { + try { + const data = await api.get('/accounts?page=1&page_size=10'); + + if (data.accounts.length === 0) { + elements.recentAccountsTable.innerHTML = ` + + +
+
📭
+
暂无已注册账号
+
+ + + `; + return; + } + + elements.recentAccountsTable.innerHTML = data.accounts.map(account => ` + + ${account.id} + + ${escapeHtml(account.email)} + + + ${account.password ? `${escapeHtml(account.password.substring(0, 8))}...` : '-'} + + + + ${getStatusText('account', account.status)} + + + +
+ + ${account.password ? `` : ''} +
+ + + `).join(''); + + } catch (error) { + console.error('加载账号列表失败:', error); + } +} + +// 开始账号列表轮询 +function startAccountsPolling() { + // 每30秒刷新一次账号列表 + accountsPollingInterval = setInterval(() => { + loadRecentAccounts(); + }, 30000); +} + // 添加日志 function addLog(type, message) { const line = document.createElement('div'); diff --git a/static/js/email_services.js b/static/js/email_services.js new file mode 100644 index 0000000..271b745 --- /dev/null +++ b/static/js/email_services.js @@ -0,0 +1,503 @@ +/** + * 邮箱服务页面 JavaScript + */ + +// 状态 +let outlookServices = []; +let customServices = []; +let selectedOutlook = new Set(); +let selectedCustom = new Set(); + +// DOM 元素 +const elements = { + // 统计 + outlookCount: document.getElementById('outlook-count'), + customCount: document.getElementById('custom-count'), + tempmailStatus: document.getElementById('tempmail-status'), + totalEnabled: document.getElementById('total-enabled'), + + // Outlook 导入 + toggleOutlookImport: document.getElementById('toggle-outlook-import'), + outlookImportBody: document.getElementById('outlook-import-body'), + outlookImportData: document.getElementById('outlook-import-data'), + outlookImportEnabled: document.getElementById('outlook-import-enabled'), + outlookImportPriority: document.getElementById('outlook-import-priority'), + outlookImportBtn: document.getElementById('outlook-import-btn'), + clearImportBtn: document.getElementById('clear-import-btn'), + importResult: document.getElementById('import-result'), + + // Outlook 列表 + outlookTable: document.getElementById('outlook-accounts-table'), + selectAllOutlook: document.getElementById('select-all-outlook'), + batchDeleteOutlookBtn: document.getElementById('batch-delete-outlook-btn'), + + // 自定义域名 + customTable: document.getElementById('custom-services-table'), + addCustomBtn: document.getElementById('add-custom-btn'), + selectAllCustom: document.getElementById('select-all-custom'), + + // 临时邮箱 + tempmailForm: document.getElementById('tempmail-form'), + tempmailApi: document.getElementById('tempmail-api'), + tempmailEnabled: document.getElementById('tempmail-enabled'), + testTempmailBtn: document.getElementById('test-tempmail-btn'), + + // 模态框 + addCustomModal: document.getElementById('add-custom-modal'), + addCustomForm: document.getElementById('add-custom-form'), + closeCustomModal: document.getElementById('close-custom-modal'), + cancelAddCustom: document.getElementById('cancel-add-custom') +}; + +// 初始化 +document.addEventListener('DOMContentLoaded', () => { + loadStats(); + loadOutlookServices(); + loadCustomServices(); + loadTempmailConfig(); + initEventListeners(); +}); + +// 事件监听 +function initEventListeners() { + // Outlook 导入展开/收起 + elements.toggleOutlookImport.addEventListener('click', () => { + const isHidden = elements.outlookImportBody.style.display === 'none'; + elements.outlookImportBody.style.display = isHidden ? 'block' : 'none'; + elements.toggleOutlookImport.textContent = isHidden ? '收起' : '展开'; + }); + + // Outlook 导入 + elements.outlookImportBtn.addEventListener('click', handleOutlookImport); + elements.clearImportBtn.addEventListener('click', () => { + elements.outlookImportData.value = ''; + elements.importResult.style.display = 'none'; + }); + + // Outlook 全选 + elements.selectAllOutlook.addEventListener('change', (e) => { + const checkboxes = elements.outlookTable.querySelectorAll('input[type="checkbox"][data-id]'); + checkboxes.forEach(cb => { + cb.checked = e.target.checked; + const id = parseInt(cb.dataset.id); + if (e.target.checked) { + selectedOutlook.add(id); + } else { + selectedOutlook.delete(id); + } + }); + updateBatchButtons(); + }); + + // Outlook 批量删除 + elements.batchDeleteOutlookBtn.addEventListener('click', handleBatchDeleteOutlook); + + // 添加自定义域名 + elements.addCustomBtn.addEventListener('click', () => { + elements.addCustomModal.classList.add('active'); + }); + + elements.closeCustomModal.addEventListener('click', () => { + elements.addCustomModal.classList.remove('active'); + }); + + elements.cancelAddCustom.addEventListener('click', () => { + elements.addCustomModal.classList.remove('active'); + }); + + elements.addCustomForm.addEventListener('submit', handleAddCustom); + + // 自定义域名全选 + elements.selectAllCustom.addEventListener('change', (e) => { + const checkboxes = elements.customTable.querySelectorAll('input[type="checkbox"][data-id]'); + checkboxes.forEach(cb => { + cb.checked = e.target.checked; + const id = parseInt(cb.dataset.id); + if (e.target.checked) { + selectedCustom.add(id); + } else { + selectedCustom.delete(id); + } + }); + }); + + // 临时邮箱配置 + elements.tempmailForm.addEventListener('submit', handleSaveTempmail); + elements.testTempmailBtn.addEventListener('click', handleTestTempmail); +} + +// 加载统计信息 +async function loadStats() { + try { + const data = await api.get('/email-services/stats'); + elements.outlookCount.textContent = data.outlook_count || 0; + elements.customCount.textContent = data.custom_count || 0; + elements.tempmailStatus.textContent = data.tempmail_available ? '可用' : '不可用'; + elements.totalEnabled.textContent = data.enabled_count || 0; + } catch (error) { + console.error('加载统计信息失败:', error); + } +} + +// 加载 Outlook 服务 +async function loadOutlookServices() { + try { + const data = await api.get('/email-services?service_type=outlook'); + outlookServices = data.services || []; + + if (outlookServices.length === 0) { + elements.outlookTable.innerHTML = ` + + +
+
📭
+
暂无 Outlook 账户
+
请使用上方导入功能添加账户
+
+ + + `; + return; + } + + elements.outlookTable.innerHTML = outlookServices.map(service => ` + + + + + ${escapeHtml(service.config?.email || service.name)} + + + ${service.config?.has_oauth ? 'OAuth' : '密码'} + + + + + ${service.enabled ? '启用' : '禁用'} + + + ${service.priority} + ${format.date(service.last_used)} + +
+ + + +
+ + + `).join(''); + + // 绑定复选框事件 + elements.outlookTable.querySelectorAll('input[type="checkbox"][data-id]').forEach(cb => { + cb.addEventListener('change', (e) => { + const id = parseInt(e.target.dataset.id); + if (e.target.checked) { + selectedOutlook.add(id); + } else { + selectedOutlook.delete(id); + } + updateBatchButtons(); + }); + }); + + } catch (error) { + console.error('加载 Outlook 服务失败:', error); + elements.outlookTable.innerHTML = ` + + +
+
❌
+
加载失败
+
+ + + `; + } +} + +// 加载自定义域名服务 +async function loadCustomServices() { + try { + const data = await api.get('/email-services?service_type=custom_domain'); + customServices = data.services || []; + + if (customServices.length === 0) { + elements.customTable.innerHTML = ` + + +
+
📭
+
暂无自定义域名服务
+
点击"添加服务"按钮创建新服务
+
+ + + `; + return; + } + + elements.customTable.innerHTML = customServices.map(service => ` + + + + + ${escapeHtml(service.name)} + ${escapeHtml(service.config?.api_url || '-')} + + + ${service.enabled ? '启用' : '禁用'} + + + ${service.priority} + ${format.date(service.last_used)} + +
+ + + +
+ + + `).join(''); + + // 绑定复选框事件 + elements.customTable.querySelectorAll('input[type="checkbox"][data-id]').forEach(cb => { + cb.addEventListener('change', (e) => { + const id = parseInt(e.target.dataset.id); + if (e.target.checked) { + selectedCustom.add(id); + } else { + selectedCustom.delete(id); + } + }); + }); + + } catch (error) { + console.error('加载自定义域名服务失败:', error); + } +} + +// 加载临时邮箱配置 +async function loadTempmailConfig() { + try { + const settings = await api.get('/settings'); + if (settings.tempmail) { + elements.tempmailApi.value = settings.tempmail.api_url || ''; + elements.tempmailEnabled.checked = settings.tempmail.enabled !== false; + } + } catch (error) { + // 忽略错误 + } +} + +// Outlook 导入 +async function handleOutlookImport() { + const data = elements.outlookImportData.value.trim(); + if (!data) { + toast.error('请输入导入数据'); + return; + } + + elements.outlookImportBtn.disabled = true; + elements.outlookImportBtn.textContent = '导入中...'; + + try { + const result = await api.post('/email-services/outlook/batch-import', { + data: data, + enabled: elements.outlookImportEnabled.checked, + priority: parseInt(elements.outlookImportPriority.value) || 0 + }); + + elements.importResult.style.display = 'block'; + elements.importResult.innerHTML = ` +
+ ✅ 成功导入: ${result.success_count || 0} + ❌ 失败: ${result.failed_count || 0} +
+ ${result.errors?.length ? ` +
+ 错误详情: +
    + ${result.errors.map(e => `
  • ${escapeHtml(e)}
  • `).join('')} +
+
+ ` : ''} + `; + + if (result.success_count > 0) { + toast.success(`成功导入 ${result.success_count} 个账户`); + loadOutlookServices(); + loadStats(); + elements.outlookImportData.value = ''; + } + + } catch (error) { + toast.error('导入失败: ' + error.message); + } finally { + elements.outlookImportBtn.disabled = false; + elements.outlookImportBtn.textContent = '📥 开始导入'; + } +} + +// 添加自定义域名服务 +async function handleAddCustom(e) { + e.preventDefault(); + + const formData = new FormData(e.target); + const data = { + service_type: 'custom_domain', + name: formData.get('name'), + config: { + api_url: formData.get('api_url'), + api_key: formData.get('api_key'), + domain: formData.get('domain') + }, + enabled: formData.get('enabled') === 'on', + priority: parseInt(formData.get('priority')) || 0 + }; + + try { + await api.post('/email-services', data); + toast.success('服务添加成功'); + elements.addCustomModal.classList.remove('active'); + e.target.reset(); + loadCustomServices(); + loadStats(); + } catch (error) { + toast.error('添加失败: ' + error.message); + } +} + +// 切换服务状态 +async function toggleService(id, enabled) { + try { + await api.patch(`/email-services/${id}`, { enabled }); + toast.success(enabled ? '已启用' : '已禁用'); + loadOutlookServices(); + loadCustomServices(); + loadStats(); + } catch (error) { + toast.error('操作失败: ' + error.message); + } +} + +// 测试服务 +async function testService(id) { + try { + const result = await api.post(`/email-services/${id}/test`); + if (result.success) { + toast.success('测试成功'); + } else { + toast.error('测试失败: ' + (result.error || '未知错误')); + } + } catch (error) { + toast.error('测试失败: ' + error.message); + } +} + +// 删除服务 +async function deleteService(id, name) { + const confirmed = await confirm(`确定要删除 "${name}" 吗?`); + if (!confirmed) return; + + try { + await api.delete(`/email-services/${id}`); + toast.success('已删除'); + selectedOutlook.delete(id); + selectedCustom.delete(id); + loadOutlookServices(); + loadCustomServices(); + loadStats(); + } catch (error) { + toast.error('删除失败: ' + error.message); + } +} + +// 批量删除 Outlook +async function handleBatchDeleteOutlook() { + if (selectedOutlook.size === 0) return; + + const confirmed = await confirm(`确定要删除选中的 ${selectedOutlook.size} 个账户吗?`); + if (!confirmed) return; + + try { + const result = await api.request('/email-services/outlook/batch', { + method: 'DELETE', + body: Array.from(selectedOutlook) + }); + toast.success(`成功删除 ${result.deleted || selectedOutlook.size} 个账户`); + selectedOutlook.clear(); + loadOutlookServices(); + loadStats(); + } catch (error) { + toast.error('删除失败: ' + error.message); + } +} + +// 保存临时邮箱配置 +async function handleSaveTempmail(e) { + e.preventDefault(); + + try { + await api.post('/settings/tempmail', { + api_url: elements.tempmailApi.value, + enabled: elements.tempmailEnabled.checked + }); + toast.success('配置已保存'); + } catch (error) { + toast.error('保存失败: ' + error.message); + } +} + +// 测试临时邮箱 +async function handleTestTempmail() { + elements.testTempmailBtn.disabled = true; + elements.testTempmailBtn.textContent = '测试中...'; + + try { + const result = await api.post('/email-services/test-tempmail', { + api_url: elements.tempmailApi.value + }); + + if (result.success) { + toast.success('临时邮箱连接正常'); + } else { + toast.error('连接失败: ' + (result.error || '未知错误')); + } + } catch (error) { + toast.error('测试失败: ' + error.message); + } finally { + elements.testTempmailBtn.disabled = false; + elements.testTempmailBtn.textContent = '🔌 测试连接'; + } +} + +// 更新批量按钮 +function updateBatchButtons() { + const count = selectedOutlook.size; + elements.batchDeleteOutlookBtn.disabled = count === 0; + elements.batchDeleteOutlookBtn.textContent = count > 0 ? `🗑️ 删除选中 (${count})` : '🗑️ 批量删除'; +} + +// HTML 转义 +function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} diff --git a/static/js/settings.js b/static/js/settings.js index 10ba4fd..338395c 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -28,7 +28,16 @@ const elements = { outlookImportData: document.getElementById('outlook-import-data'), importResult: document.getElementById('import-result'), // 批量操作 - selectAllServices: document.getElementById('select-all-services') + selectAllServices: document.getElementById('select-all-services'), + // 代理列表 + proxiesTable: document.getElementById('proxies-table'), + addProxyBtn: document.getElementById('add-proxy-btn'), + testAllProxiesBtn: document.getElementById('test-all-proxies-btn'), + addProxyModal: document.getElementById('add-proxy-modal'), + proxyItemForm: document.getElementById('proxy-item-form'), + closeProxyModal: document.getElementById('close-proxy-modal'), + cancelProxyBtn: document.getElementById('cancel-proxy-btn'), + proxyModalTitle: document.getElementById('proxy-modal-title') }; // 选中的服务 ID @@ -40,6 +49,7 @@ document.addEventListener('DOMContentLoaded', () => { loadSettings(); loadEmailServices(); loadDatabaseInfo(); + loadProxies(); initEventListeners(); }); @@ -61,47 +71,69 @@ function initTabs() { // 事件监听 function initEventListeners() { // 代理表单 - elements.proxyForm.addEventListener('submit', handleSaveProxy); + if (elements.proxyForm) { + elements.proxyForm.addEventListener('submit', handleSaveProxy); + } // 测试代理 - elements.testProxyBtn.addEventListener('click', handleTestProxy); + if (elements.testProxyBtn) { + elements.testProxyBtn.addEventListener('click', handleTestProxy); + } // 注册配置表单 - elements.registrationForm.addEventListener('submit', handleSaveRegistration); + if (elements.registrationForm) { + elements.registrationForm.addEventListener('submit', handleSaveRegistration); + } // 备份数据库 - elements.backupBtn.addEventListener('click', handleBackup); + if (elements.backupBtn) { + elements.backupBtn.addEventListener('click', handleBackup); + } // 清理数据 - elements.cleanupBtn.addEventListener('click', handleCleanup); + if (elements.cleanupBtn) { + elements.cleanupBtn.addEventListener('click', handleCleanup); + } // 添加邮箱服务 - elements.addEmailServiceBtn.addEventListener('click', () => { - elements.addServiceModal.classList.add('active'); - loadServiceConfigFields(elements.serviceType.value); - }); + if (elements.addEmailServiceBtn) { + elements.addEmailServiceBtn.addEventListener('click', () => { + elements.addServiceModal.classList.add('active'); + loadServiceConfigFields(elements.serviceType.value); + }); + } - elements.closeServiceModal.addEventListener('click', () => { - elements.addServiceModal.classList.remove('active'); - }); - - elements.cancelAddService.addEventListener('click', () => { - elements.addServiceModal.classList.remove('active'); - }); - - elements.addServiceModal.addEventListener('click', (e) => { - if (e.target === elements.addServiceModal) { + if (elements.closeServiceModal) { + elements.closeServiceModal.addEventListener('click', () => { elements.addServiceModal.classList.remove('active'); - } - }); + }); + } + + if (elements.cancelAddService) { + elements.cancelAddService.addEventListener('click', () => { + elements.addServiceModal.classList.remove('active'); + }); + } + + if (elements.addServiceModal) { + elements.addServiceModal.addEventListener('click', (e) => { + if (e.target === elements.addServiceModal) { + elements.addServiceModal.classList.remove('active'); + } + }); + } // 服务类型切换 - elements.serviceType.addEventListener('change', (e) => { - loadServiceConfigFields(e.target.value); - }); + if (elements.serviceType) { + elements.serviceType.addEventListener('change', (e) => { + loadServiceConfigFields(e.target.value); + }); + } // 添加服务表单 - elements.addServiceForm.addEventListener('submit', handleAddService); + if (elements.addServiceForm) { + elements.addServiceForm.addEventListener('submit', handleAddService); + } // Outlook 批量导入展开/折叠 if (elements.toggleImportBtn) { @@ -133,6 +165,35 @@ function initEventListeners() { updateSelectedServices(); }); } + + // 代理列表相关 + if (elements.addProxyBtn) { + elements.addProxyBtn.addEventListener('click', () => openProxyModal()); + } + + if (elements.testAllProxiesBtn) { + elements.testAllProxiesBtn.addEventListener('click', handleTestAllProxies); + } + + if (elements.closeProxyModal) { + elements.closeProxyModal.addEventListener('click', closeProxyModal); + } + + if (elements.cancelProxyBtn) { + elements.cancelProxyBtn.addEventListener('click', closeProxyModal); + } + + if (elements.addProxyModal) { + elements.addProxyModal.addEventListener('click', (e) => { + if (e.target === elements.addProxyModal) { + closeProxyModal(); + } + }); + } + + if (elements.proxyItemForm) { + elements.proxyItemForm.addEventListener('submit', handleSaveProxyItem); + } } // 加载设置 @@ -162,26 +223,34 @@ async function loadSettings() { // 加载邮箱服务 async function loadEmailServices() { + // 检查元素是否存在 + if (!elements.emailServicesTable) return; + try { const data = await api.get('/email-services'); renderEmailServices(data.services); } catch (error) { console.error('加载邮箱服务失败:', error); - elements.emailServicesTable.innerHTML = ` - - -
-
❌
-
加载失败
-
- - - `; + if (elements.emailServicesTable) { + elements.emailServicesTable.innerHTML = ` + + +
+
❌
+
加载失败
+
+ + + `; + } } } // 渲染邮箱服务 function renderEmailServices(services) { + // 检查元素是否存在 + if (!elements.emailServicesTable) return; + if (services.length === 0) { elements.emailServicesTable.innerHTML = ` @@ -271,9 +340,24 @@ async function handleTestProxy() { elements.testProxyBtn.innerHTML = ' 测试中...'; try { - // TODO: 实现代理测试 API - await new Promise(resolve => setTimeout(resolve, 1500)); - toast.info('代理测试功能待实现'); + const data = { + enabled: document.getElementById('proxy-enabled').checked, + type: document.getElementById('proxy-type').value, + host: document.getElementById('proxy-host').value, + port: parseInt(document.getElementById('proxy-port').value), + username: document.getElementById('proxy-username').value || null, + password: document.getElementById('proxy-password').value || null, + }; + + const result = await api.post('/settings/proxy/test', data); + + if (result.success) { + toast.success(result.message); + } else { + toast.error(result.message); + } + } catch (error) { + toast.error('测试失败: ' + error.message); } finally { elements.testProxyBtn.disabled = false; elements.testProxyBtn.textContent = '🔌 测试连接'; @@ -543,3 +627,199 @@ function escapeHtml(text) { div.textContent = text; return div.innerHTML; } + + +// ============================================================================ +// 代理列表管理 +// ============================================================================ + +// 加载代理列表 +async function loadProxies() { + try { + const data = await api.get('/settings/proxies'); + renderProxies(data.proxies); + } catch (error) { + console.error('加载代理列表失败:', error); + elements.proxiesTable.innerHTML = ` + + +
+
❌
+
加载失败
+
+ + + `; + } +} + +// 渲染代理列表 +function renderProxies(proxies) { + if (!proxies || proxies.length === 0) { + elements.proxiesTable.innerHTML = ` + + +
+
🌐
+
暂无代理
+
点击"添加代理"按钮添加代理服务器
+
+ + + `; + return; + } + + elements.proxiesTable.innerHTML = proxies.map(proxy => ` + + ${proxy.id} + ${escapeHtml(proxy.name)} + ${proxy.type.toUpperCase()} + ${escapeHtml(proxy.host)}:${proxy.port} + + + ${proxy.enabled ? '已启用' : '已禁用'} + + + ${format.date(proxy.last_used)} + +
+ + + + +
+ + + `).join(''); +} + +// 打开代理模态框 +function openProxyModal(proxy = null) { + elements.proxyModalTitle.textContent = proxy ? '编辑代理' : '添加代理'; + elements.proxyItemForm.reset(); + + document.getElementById('proxy-item-id').value = proxy ? proxy.id : ''; + + if (proxy) { + document.getElementById('proxy-item-name').value = proxy.name || ''; + document.getElementById('proxy-item-type').value = proxy.type || 'http'; + document.getElementById('proxy-item-host').value = proxy.host || ''; + document.getElementById('proxy-item-port').value = proxy.port || ''; + document.getElementById('proxy-item-username').value = proxy.username || ''; + document.getElementById('proxy-item-password').value = ''; + } + + elements.addProxyModal.classList.add('active'); +} + +// 关闭代理模态框 +function closeProxyModal() { + elements.addProxyModal.classList.remove('active'); + elements.proxyItemForm.reset(); +} + +// 保存代理 +async function handleSaveProxyItem(e) { + e.preventDefault(); + + const proxyId = document.getElementById('proxy-item-id').value; + const data = { + name: document.getElementById('proxy-item-name').value, + type: document.getElementById('proxy-item-type').value, + host: document.getElementById('proxy-item-host').value, + port: parseInt(document.getElementById('proxy-item-port').value), + username: document.getElementById('proxy-item-username').value || null, + password: document.getElementById('proxy-item-password').value || null, + enabled: true + }; + + try { + if (proxyId) { + await api.patch(`/settings/proxies/${proxyId}`, data); + toast.success('代理已更新'); + } else { + await api.post('/settings/proxies', data); + toast.success('代理已添加'); + } + closeProxyModal(); + loadProxies(); + } catch (error) { + toast.error('保存失败: ' + error.message); + } +} + +// 编辑代理 +async function editProxyItem(id) { + try { + const proxy = await api.get(`/settings/proxies/${id}`); + openProxyModal(proxy); + } catch (error) { + toast.error('获取代理信息失败'); + } +} + +// 测试单个代理 +async function testProxyItem(id) { + try { + const result = await api.post(`/settings/proxies/${id}/test`); + if (result.success) { + toast.success(result.message); + } else { + toast.error(result.message); + } + } catch (error) { + toast.error('测试失败: ' + error.message); + } +} + +// 切换代理状态 +async function toggleProxyItem(id, enabled) { + try { + const endpoint = enabled ? 'enable' : 'disable'; + await api.post(`/settings/proxies/${id}/${endpoint}`); + toast.success(enabled ? '代理已启用' : '代理已禁用'); + loadProxies(); + } catch (error) { + toast.error('操作失败: ' + error.message); + } +} + +// 删除代理 +async function deleteProxyItem(id) { + const confirmed = await confirm('确定要删除此代理吗?'); + if (!confirmed) return; + + try { + await api.delete(`/settings/proxies/${id}`); + toast.success('代理已删除'); + loadProxies(); + } catch (error) { + toast.error('删除失败: ' + error.message); + } +} + +// 测试所有代理 +async function handleTestAllProxies() { + elements.testAllProxiesBtn.disabled = true; + elements.testAllProxiesBtn.innerHTML = ' 测试中...'; + + try { + const result = await api.post('/settings/proxies/test-all'); + toast.info(`测试完成: 成功 ${result.success}, 失败 ${result.failed}`); + loadProxies(); + } catch (error) { + toast.error('测试失败: ' + error.message); + } finally { + elements.testAllProxiesBtn.disabled = false; + elements.testAllProxiesBtn.textContent = '🔌 测试全部'; + } +} diff --git a/static/js/utils.js b/static/js/utils.js new file mode 100644 index 0000000..4a1e14b --- /dev/null +++ b/static/js/utils.js @@ -0,0 +1,495 @@ +/** + * 通用工具库 + * 包含 Toast 通知、主题切换、工具函数等 + */ + +// ============================================ +// Toast 通知系统 +// ============================================ + +class ToastManager { + constructor() { + this.container = null; + this.init(); + } + + init() { + this.container = document.createElement('div'); + this.container.className = 'toast-container'; + document.body.appendChild(this.container); + } + + show(message, type = 'info', duration = 4000) { + const toast = document.createElement('div'); + toast.className = `toast ${type}`; + + const icon = this.getIcon(type); + toast.innerHTML = ` + ${icon} + ${this.escapeHtml(message)} + + `; + + this.container.appendChild(toast); + + // 自动移除 + setTimeout(() => { + toast.style.animation = 'slideOut 0.3s ease forwards'; + setTimeout(() => toast.remove(), 300); + }, duration); + + return toast; + } + + getIcon(type) { + const icons = { + success: '✓', + error: '✕', + warning: '⚠', + info: 'ℹ' + }; + return icons[type] || icons.info; + } + + escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + success(message, duration) { + return this.show(message, 'success', duration); + } + + error(message, duration) { + return this.show(message, 'error', duration); + } + + warning(message, duration) { + return this.show(message, 'warning', duration); + } + + info(message, duration) { + return this.show(message, 'info', duration); + } +} + +// 全局 Toast 实例 +const toast = new ToastManager(); + +// ============================================ +// 主题管理 +// ============================================ + +class ThemeManager { + constructor() { + this.theme = this.loadTheme(); + this.applyTheme(); + } + + loadTheme() { + return localStorage.getItem('theme') || 'light'; + } + + saveTheme(theme) { + localStorage.setItem('theme', theme); + } + + applyTheme() { + document.documentElement.setAttribute('data-theme', this.theme); + this.updateToggleButtons(); + } + + toggle() { + this.theme = this.theme === 'light' ? 'dark' : 'light'; + this.saveTheme(this.theme); + this.applyTheme(); + } + + setTheme(theme) { + this.theme = theme; + this.saveTheme(theme); + this.applyTheme(); + } + + updateToggleButtons() { + const buttons = document.querySelectorAll('.theme-toggle'); + buttons.forEach(btn => { + btn.innerHTML = this.theme === 'light' ? '🌙' : '☀️'; + btn.title = this.theme === 'light' ? '切换到暗色模式' : '切换到亮色模式'; + }); + } +} + +// 全局主题实例 +const theme = new ThemeManager(); + +// ============================================ +// 加载状态管理 +// ============================================ + +class LoadingManager { + constructor() { + this.activeLoaders = new Set(); + } + + show(element, text = '加载中...') { + if (typeof element === 'string') { + element = document.getElementById(element); + } + if (!element) return; + + element.classList.add('loading'); + element.dataset.originalText = element.innerHTML; + element.innerHTML = ` ${text}`; + element.disabled = true; + this.activeLoaders.add(element); + } + + hide(element) { + if (typeof element === 'string') { + element = document.getElementById(element); + } + if (!element) return; + + element.classList.remove('loading'); + if (element.dataset.originalText) { + element.innerHTML = element.dataset.originalText; + delete element.dataset.originalText; + } + element.disabled = false; + this.activeLoaders.delete(element); + } + + hideAll() { + this.activeLoaders.forEach(element => this.hide(element)); + } +} + +const loading = new LoadingManager(); + +// ============================================ +// API 请求封装 +// ============================================ + +class ApiClient { + constructor(baseUrl = '/api') { + this.baseUrl = baseUrl; + } + + async request(path, options = {}) { + const url = `${this.baseUrl}${path}`; + + const defaultOptions = { + headers: { + 'Content-Type': 'application/json', + }, + }; + + const finalOptions = { ...defaultOptions, ...options }; + + if (finalOptions.body && typeof finalOptions.body === 'object') { + finalOptions.body = JSON.stringify(finalOptions.body); + } + + try { + const response = await fetch(url, finalOptions); + const data = await response.json().catch(() => ({})); + + if (!response.ok) { + const error = new Error(data.detail || `HTTP ${response.status}`); + error.response = response; + error.data = data; + throw error; + } + + return data; + } catch (error) { + // 网络错误处理 + if (!error.response) { + toast.error('网络连接失败,请检查网络'); + } + throw error; + } + } + + get(path, options = {}) { + return this.request(path, { ...options, method: 'GET' }); + } + + post(path, body, options = {}) { + return this.request(path, { ...options, method: 'POST', body }); + } + + put(path, body, options = {}) { + return this.request(path, { ...options, method: 'PUT', body }); + } + + delete(path, options = {}) { + return this.request(path, { ...options, method: 'DELETE' }); + } +} + +const api = new ApiClient(); + +// ============================================ +// 事件委托助手 +// ============================================ + +function delegate(element, eventType, selector, handler) { + element.addEventListener(eventType, (e) => { + const target = e.target.closest(selector); + if (target && element.contains(target)) { + handler.call(target, e, target); + } + }); +} + +// ============================================ +// 防抖和节流 +// ============================================ + +function debounce(func, wait) { + let timeout; + return function executedFunction(...args) { + const later = () => { + clearTimeout(timeout); + func(...args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; +} + +function throttle(func, limit) { + let inThrottle; + return function executedFunction(...args) { + if (!inThrottle) { + func(...args); + inThrottle = true; + setTimeout(() => inThrottle = false, limit); + } + }; +} + +// ============================================ +// 格式化工具 +// ============================================ + +const format = { + date(dateStr) { + if (!dateStr) return '-'; + const date = new Date(dateStr); + return date.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); + }, + + dateShort(dateStr) { + if (!dateStr) return '-'; + const date = new Date(dateStr); + return date.toLocaleDateString('zh-CN'); + }, + + relativeTime(dateStr) { + if (!dateStr) return '-'; + const date = new Date(dateStr); + const now = new Date(); + const diff = now - date; + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (seconds < 60) return '刚刚'; + if (minutes < 60) return `${minutes} 分钟前`; + if (hours < 24) return `${hours} 小时前`; + if (days < 7) return `${days} 天前`; + return this.dateShort(dateStr); + }, + + bytes(bytes) { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + }, + + number(num) { + if (num === null || num === undefined) return '-'; + return num.toLocaleString('zh-CN'); + } +}; + +// ============================================ +// 状态映射 +// ============================================ + +const statusMap = { + account: { + active: { text: '活跃', class: 'active' }, + expired: { text: '过期', class: 'expired' }, + banned: { text: '封禁', class: 'banned' }, + failed: { text: '失败', class: 'failed' } + }, + task: { + pending: { text: '等待中', class: 'pending' }, + running: { text: '运行中', class: 'running' }, + completed: { text: '已完成', class: 'completed' }, + failed: { text: '失败', class: 'failed' }, + cancelled: { text: '已取消', class: 'disabled' } + }, + service: { + tempmail: 'Tempmail.lol', + outlook: 'Outlook', + custom_domain: '自定义域名' + } +}; + +function getStatusText(type, status) { + return statusMap[type]?.[status]?.text || status; +} + +function getStatusClass(type, status) { + return statusMap[type]?.[status]?.class || ''; +} + +function getServiceTypeText(type) { + return statusMap.service[type] || type; +} + +// ============================================ +// 确认对话框 +// ============================================ + +function confirm(message, title = '确认操作') { + return new Promise((resolve) => { + const modal = document.createElement('div'); + modal.className = 'modal active'; + modal.innerHTML = ` + + `; + + document.body.appendChild(modal); + + const cancelBtn = modal.querySelector('#confirm-cancel'); + const okBtn = modal.querySelector('#confirm-ok'); + + cancelBtn.onclick = () => { + modal.remove(); + resolve(false); + }; + + okBtn.onclick = () => { + modal.remove(); + resolve(true); + }; + + modal.onclick = (e) => { + if (e.target === modal) { + modal.remove(); + resolve(false); + } + }; + }); +} + +// ============================================ +// 复制到剪贴板 +// ============================================ + +async function copyToClipboard(text) { + try { + await navigator.clipboard.writeText(text); + toast.success('已复制到剪贴板'); + return true; + } catch (err) { + toast.error('复制失败'); + return false; + } +} + +// ============================================ +// 本地存储助手 +// ============================================ + +const storage = { + get(key, defaultValue = null) { + try { + const value = localStorage.getItem(key); + return value ? JSON.parse(value) : defaultValue; + } catch { + return defaultValue; + } + }, + + set(key, value) { + try { + localStorage.setItem(key, JSON.stringify(value)); + return true; + } catch { + return false; + } + }, + + remove(key) { + localStorage.removeItem(key); + } +}; + +// ============================================ +// 页面初始化 +// ============================================ + +document.addEventListener('DOMContentLoaded', () => { + // 初始化主题 + theme.applyTheme(); + + // 全局键盘快捷键 + document.addEventListener('keydown', (e) => { + // Ctrl/Cmd + K: 聚焦搜索 + if ((e.ctrlKey || e.metaKey) && e.key === 'k') { + e.preventDefault(); + const searchInput = document.querySelector('#search-input, [type="search"]'); + if (searchInput) searchInput.focus(); + } + + // Escape: 关闭模态框 + if (e.key === 'Escape') { + const activeModal = document.querySelector('.modal.active'); + if (activeModal) activeModal.classList.remove('active'); + } + }); +}); + +// 导出全局对象 +window.toast = toast; +window.theme = theme; +window.loading = loading; +window.api = api; +window.format = format; +window.confirm = confirm; +window.copyToClipboard = copyToClipboard; +window.storage = storage; +window.delegate = delegate; +window.debounce = debounce; +window.throttle = throttle; +window.getStatusText = getStatusText; +window.getStatusClass = getStatusClass; +window.getServiceTypeText = getServiceTypeText; diff --git a/templates/accounts.html b/templates/accounts.html index 928e7c2..683bc7b 100644 --- a/templates/accounts.html +++ b/templates/accounts.html @@ -6,6 +6,33 @@ 账号管理 - OpenAI 注册系统 +
@@ -17,6 +44,7 @@ + + @@ -103,15 +137,16 @@ ID 邮箱 + 密码 邮箱服务 - 状态 - 注册时间 + 状态 + 最后刷新 操作 - +
diff --git a/templates/email_services.html b/templates/email_services.html new file mode 100644 index 0000000..bbee35b --- /dev/null +++ b/templates/email_services.html @@ -0,0 +1,240 @@ + + + + + + 邮箱服务 - OpenAI 注册系统 + + + + +
+ + + + +
+ + + +
+
+
0
+
Outlook 账户
+
+
+
0
+
自定义域名
+
+
+
可用
+
临时邮箱
+
+
+
0
+
已启用服务
+
+
+ + +
+
+

📥 Outlook 批量导入

+ +
+ +
+ + +
+
+

🔗 自定义域名服务

+ +
+
+
+ + + + + + + + + + + + + + + + + +
名称API 地址状态优先级最后使用操作
+
+
+
+
+
+
+
+
+ + +
+
+

📧 Outlook 账户列表

+ +
+
+
+ + + + + + + + + + + + + + + + + +
邮箱认证方式状态优先级最后使用操作
+
+
+
+
+
+
+
+
+ + +
+
+

🌐 临时邮箱配置

+
+
+
+
+ + +
+
+ +
+
+ + +
+
+
+
+
+
+ + + + + + + + diff --git a/templates/index.html b/templates/index.html index c573ec7..b0309e1 100644 --- a/templates/index.html +++ b/templates/index.html @@ -6,6 +6,94 @@ 注册控制台 - OpenAI 注册系统 +
@@ -17,6 +105,7 @@ - +
- - - -
-
-

📝 新建注册任务

-
-
-
-
-
- - -
- -
- - -
+
+ +
+
+
+

📝 注册设置

- -
-
- - -
- - -
- - + + +
+ +
+
+

💻 监控台

+
+ +
+
+ + -
- - -
- -
-
+ + - - - - - - -
-
-

💻 控制台日志

- -
-
-
-
[系统] 准备就绪,等待开始注册...
+ +
+
+

📋 已注册账号

+
+ + 查看全部 +
+
+
+
+ + + + + + + + + + + + + + + +
ID邮箱密码状态操作
+
+
📭
+
暂无已注册账号
+
+
+
+
diff --git a/templates/settings.html b/templates/settings.html index 88ce47d..feccbfe 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -17,6 +17,7 @@ -
+
-

代理配置

+

默认代理配置

+ 当代理列表为空时使用此配置
@@ -93,74 +95,37 @@
-
- -
- -
+ +
-

📥 Outlook 批量导入

- -
- - -
-
-

邮箱服务配置

-
- + - - - - - + + + + + - + @@ -171,6 +136,56 @@ + + +
@@ -252,42 +267,6 @@
- - - diff --git a/webui.py b/webui.py new file mode 100644 index 0000000..452a991 --- /dev/null +++ b/webui.py @@ -0,0 +1,113 @@ +""" +Web UI 启动入口 +""" + +import uvicorn +import logging +import sys +from pathlib import Path + +# 添加项目根目录到 Python 路径 +project_root = Path(__file__).parent +sys.path.insert(0, str(project_root)) + +from src.core.utils import setup_logging +from src.database.init_db import initialize_database +from src.config.settings import get_settings + + +def setup_application(): + """设置应用程序""" + # 获取配置 + settings = get_settings() + + # 配置日志 + setup_logging( + log_level=settings.log_level, + log_file=settings.log_file + ) + + logger = logging.getLogger(__name__) + + # 初始化数据库 + try: + initialize_database() + logger.info("数据库初始化完成") + except Exception as e: + logger.error(f"数据库初始化失败: {e}") + raise + + # 检查数据目录 + data_dir = project_root / "data" + data_dir.mkdir(exist_ok=True) + logger.info(f"数据目录: {data_dir}") + + # 检查日志目录 + logs_dir = project_root / "logs" + logs_dir.mkdir(exist_ok=True) + logger.info(f"日志目录: {logs_dir}") + + logger.info("应用程序设置完成") + return settings + + +def start_webui(): + """启动 Web UI""" + # 设置应用程序 + settings = setup_application() + + # 导入 FastAPI 应用(延迟导入以避免循环依赖) + from src.web.app import app + + # 配置 uvicorn + uvicorn_config = { + "app": "src.web.app:app", + "host": settings.webui_host, + "port": settings.webui_port, + "reload": settings.debug, + "log_level": "info" if settings.debug else "warning", + "access_log": settings.debug, + } + + logger = logging.getLogger(__name__) + logger.info(f"启动 Web UI 在 http://{settings.webui_host}:{settings.webui_port}") + logger.info(f"调试模式: {settings.debug}") + + # 启动服务器 + uvicorn.run(**uvicorn_config) + + +def main(): + """主函数""" + import argparse + + parser = argparse.ArgumentParser(description="OpenAI/Codex CLI 自动注册系统 Web UI") + parser.add_argument("--host", help="监听主机") + parser.add_argument("--port", type=int, help="监听端口") + parser.add_argument("--debug", action="store_true", help="启用调试模式") + parser.add_argument("--reload", action="store_true", help="启用热重载") + parser.add_argument("--log-level", help="日志级别") + args = parser.parse_args() + + # 更新配置 + from src.config.settings import update_settings + + updates = {} + if args.host: + updates["webui_host"] = args.host + if args.port: + updates["webui_port"] = args.port + if args.debug: + updates["debug"] = args.debug + if args.log_level: + updates["log_level"] = args.log_level + + if updates: + update_settings(**updates) + + # 启动 Web UI + start_webui() + + +if __name__ == "__main__": + main() \ No newline at end of file
ID 名称类型状态优先级最后使用操作类型地址状态最后使用操作
-
-
+
🌐
+
暂无代理
+
点击"添加代理"按钮添加代理服务器