mirror of
https://wget.la/https://github.com/432539/gpt
synced 2026-08-17 03:26:59 +08:00
Initial commit: GPT 账号注册机 (open source)
只开源「GPT 账号注册机 / 管理」(account-manager):账号列表/导入导出、检测/批量检测、 拿 RT、协议/浏览器注册等注册机能力。批量直开 / 协议支付 / 提取链接 / 直卡直开 等其它功能 不含实现代码,仅通过 iframe 内嵌外链访问。真实机密均由 .gitignore 忽略,仅提供脱敏示例配置。
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
# 复制为 .env 后按需填写(.env 已被 .gitignore 忽略)
|
||||||
|
# 监听地址与端口
|
||||||
|
HOST=127.0.0.1
|
||||||
|
PORT=5090
|
||||||
|
# Node 可执行文件路径(默认走 PATH 上的 node)
|
||||||
|
# ACCOUNT_MGR_NODE=C:\Program Files\nodejs\node.exe
|
||||||
|
# 注册子进程回读验证码用的内部地址(默认指向本应用端口)
|
||||||
|
# ACCOUNT_MGR_BASE=http://127.0.0.1:5090
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
# ===== 机密 / 真实数据(切勿提交)=====
|
||||||
|
# account-manager 真实配置(代理 / 接码 / 邮箱 / license key 等)
|
||||||
|
account-manager/config/settings.json
|
||||||
|
account-manager/config/proxies.json
|
||||||
|
account-manager/config/mailbox.json
|
||||||
|
account-manager/config/*.local.json
|
||||||
|
|
||||||
|
# 运行期数据(账号、收件箱、探测缓存等)
|
||||||
|
data/
|
||||||
|
*.jsonl
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# 环境变量
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Node 依赖与诊断产物
|
||||||
|
node_modules/
|
||||||
|
account-manager/node/_*.log
|
||||||
|
account-manager/node/_*.png
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# 日志
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
# GPT 账号注册机 / 管理(开源版)
|
||||||
|
|
||||||
|
> 本仓库**只开源「GPT 账号注册机 / 管理」(GPT管理,account-manager)**这一个功能。
|
||||||
|
> 批量直开、协议支付、提取链接、直卡直开等其它功能**不在本仓库内、不含任何实现代码**,
|
||||||
|
> 仅通过右侧 iframe 内嵌外链的方式访问(它们是独立 / 闭源的线上服务)。
|
||||||
|
|
||||||
|
An open-source **ChatGPT account registration & management tool**. Only the account
|
||||||
|
manager (`account-manager`) is open-sourced here. Other capabilities (card-flow /
|
||||||
|
protocol-pay / link-extract / direct-card) are **not** part of this repo and are only
|
||||||
|
reachable as embedded external links.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、开源范围
|
||||||
|
|
||||||
|
### ✅ 本仓库包含(且仅包含):GPT管理 / 注册机
|
||||||
|
|
||||||
|
页面 `/account-manager/`,能力包括:
|
||||||
|
|
||||||
|
- **账号列表 / 分页 / 搜索 / 筛选**(只看已开通、按状态筛选等)
|
||||||
|
- **导入账号**:支持 `sub2api.json`、`email----password----clientId----refreshToken`、纯 access token 三种格式
|
||||||
|
- **导出**:批量导出 AT / SUB 格式 / 账密
|
||||||
|
- **检测 / 批量检测**:用 refresh_token 续期换 access_token,实时校验套餐(plan)与有效期
|
||||||
|
- **自动刷新 TOKEN**:对未知 / 过期 / 临近过期账号自动续期
|
||||||
|
- **拿 RT / 重拿 RT**:用 codex 客户端登录换取 refresh_token(触发手机验证时自动接码)
|
||||||
|
- **账号注册(注册机核心)**:
|
||||||
|
- 协议注册(HTTP 直连)
|
||||||
|
- 浏览器注册(CloakBrowser 隐身 Chromium)
|
||||||
|
- 批量补绑手机并拿 RT
|
||||||
|
- **设置**:代理池 / 接码(smscode·smsbower·herosms)/ 邮箱池 / 浏览器(CloakBrowser license、注册口令)
|
||||||
|
|
||||||
|
### ❌ 本仓库不包含(仅 iframe 外链)
|
||||||
|
|
||||||
|
| 菜单 | 形态 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 直卡直开 | iframe 外链 | 独立线上服务,无源码 |
|
||||||
|
| 批量直开(试用3天) | iframe 外链 | 独立线上服务,无源码 |
|
||||||
|
| 协议支付(试用3天) | iframe 外链 | 独立线上服务,无源码 |
|
||||||
|
| 提取链接(试用3天) | iframe 外链 | 独立线上服务,无源码 |
|
||||||
|
|
||||||
|
这些菜单点击后只在右侧内容区加载一个指向外部 URL 的 `<iframe>`(见 `templates/embed.html`),
|
||||||
|
**本项目不含它们的任何业务实现**(没有 card-flow / protocol / pay / adobe 的后端、模板或静态资源)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
opensource-gpt/
|
||||||
|
├─ app.py # 精简入口:只注册 account_manager 蓝图 + /embed/<target> 外链壳
|
||||||
|
├─ account_manager.py # 注册机 / GPT管理 后端(唯一开源真实功能)
|
||||||
|
├─ plan_check_session.py # 实时套餐校验(curl_cffi 子进程,account_manager 依赖)
|
||||||
|
├─ templates/
|
||||||
|
│ ├─ _sidebar.html # 共享侧边栏(一级平铺菜单)
|
||||||
|
│ ├─ account-manager.html # GPT管理页
|
||||||
|
│ └─ embed.html # 外链内嵌壳(shell + 一个铺满高度的 iframe,无业务代码)
|
||||||
|
├─ static/
|
||||||
|
│ ├─ app-shell.css # 统一设计系统(企业蓝)
|
||||||
|
│ ├─ account-manager.css # GPT管理页样式
|
||||||
|
│ └─ account-manager.js # GPT管理页前端逻辑
|
||||||
|
├─ account-manager/
|
||||||
|
│ ├─ node/ # 注册机所需 Node 脚本(见下)
|
||||||
|
│ └─ config/
|
||||||
|
│ ├─ settings.example.json # 脱敏示例配置(git 跟踪)
|
||||||
|
│ └─ proxies.example.json # 脱敏示例代理(git 跟踪)
|
||||||
|
├─ requirements.txt
|
||||||
|
├─ .gitignore # 忽略真实机密与运行期数据
|
||||||
|
├─ .env.example
|
||||||
|
└─ README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
`account-manager/node/`(全部为注册机运行时依赖):
|
||||||
|
|
||||||
|
| 文件 | 作用 |
|
||||||
|
| --- | --- |
|
||||||
|
| `reg_runner.mjs` | 协议注册(HTTP 直连)入口 |
|
||||||
|
| `cloak_reg_runner.mjs` | 浏览器注册(CloakBrowser)入口 |
|
||||||
|
| `rt_runner.mjs` / `refetch_runner.mjs` | 拿 RT / 重拿 RT |
|
||||||
|
| `inbox_runner.mjs` | 查收邮箱验证码 |
|
||||||
|
| `get-rt.js` | 核心:登录换 refresh_token / access_token |
|
||||||
|
| `plus-mailbox.js` | 邮箱收件与验证码提取 |
|
||||||
|
| `smscode.js` / `smsbower.js` / `hero-sms.js` | 接码平台适配 |
|
||||||
|
| `fingerprint.js` | 指纹 / 随机资料生成 |
|
||||||
|
| `reg_password.mjs` | 统一注册口令解析(默认仅占位,请自行配置) |
|
||||||
|
| `package.json` / `package-lock.json` | Node 依赖清单 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、快速开始
|
||||||
|
|
||||||
|
依赖:**Python 3.11+**;如需「拿 RT / 查收 / 注册」还需 **Node.js 18+**。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1) Python 依赖
|
||||||
|
python -m venv .venv
|
||||||
|
# Windows
|
||||||
|
.venv\Scripts\activate
|
||||||
|
# macOS / Linux
|
||||||
|
# source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# 2) Node 依赖(注册机相关功能需要)
|
||||||
|
cd account-manager/node
|
||||||
|
npm install
|
||||||
|
cd ../..
|
||||||
|
|
||||||
|
# 3) 配置(示例 -> 真实;真实配置已被 .gitignore 忽略)
|
||||||
|
copy account-manager\config\settings.example.json account-manager\config\settings.json
|
||||||
|
copy account-manager\config\proxies.example.json account-manager\config\proxies.json
|
||||||
|
# 按需填入你自己的:代理池 / 接码 apiKey / 邮箱池 / CloakBrowser license / 注册口令 等
|
||||||
|
# (邮箱池在页面「设置 → 邮箱池」里维护,会写入 config/mailbox.json,同样被忽略)
|
||||||
|
|
||||||
|
# 4) 启动(默认端口 5090)
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
打开 http://127.0.0.1:5090/ (自动跳转到 `/account-manager/`)。
|
||||||
|
|
||||||
|
可用环境变量(见 `.env.example`):`HOST`、`PORT`、`ACCOUNT_MGR_NODE`(node 可执行路径)、`ACCOUNT_MGR_BASE`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、注意事项 / 安全
|
||||||
|
|
||||||
|
- **切勿提交真实机密。** 以下均已在 `.gitignore` 中忽略,不会进入 git:
|
||||||
|
- `account-manager/config/settings.json`、`proxies.json`、`mailbox.json`
|
||||||
|
- `.env`、`data/`、`*.jsonl`、`*.db`、`node_modules/`、`__pycache__/`、`*.log`
|
||||||
|
- 仓库仅提供 `*.example.json` **脱敏示例**(不含真实代理 / 接码 / 邮箱 / token / license / 注册口令)。
|
||||||
|
首次使用请复制示例为真实配置后再填写。
|
||||||
|
- `reg_password.mjs` 内的默认口令仅为占位(`ChangeMe#2026Pw!`),请在「设置 → 浏览器 → 注册密码」
|
||||||
|
或真实 `settings.json` 的 `regPassword` 中配置你自己的强口令。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、关于 iframe 内嵌
|
||||||
|
|
||||||
|
内嵌能否成功取决于目标站点的证书是否可信,以及是否设置了 `X-Frame-Options` /
|
||||||
|
`Content-Security-Policy: frame-ancestors`。若目标站禁止被内嵌,`embed.html` 会提供
|
||||||
|
「在新标签页打开」的兜底入口。**本项目不修改这些外部服务,也不含其实现代码。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、开源地址 / 许可
|
||||||
|
|
||||||
|
- 开源地址:https://github.com/432539/gpt
|
||||||
|
- 许可(License):暂未指定。建议后续按需补充(如 MIT / Apache-2.0)。
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"raw": "host:port:username:password",
|
||||||
|
"host": "proxy.example.com",
|
||||||
|
"port": 3010,
|
||||||
|
"username": "your-user",
|
||||||
|
"password": "your-pass"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"concurrency": 20,
|
||||||
|
"headless": true,
|
||||||
|
"launchTimeout": 60000,
|
||||||
|
"cloakLicenseKey": "",
|
||||||
|
"regPassword": "",
|
||||||
|
"captcha": {
|
||||||
|
"provider": "yescaptcha",
|
||||||
|
"apiKey": "",
|
||||||
|
"endpoint": ""
|
||||||
|
},
|
||||||
|
"sms": {
|
||||||
|
"provider": "smsbower",
|
||||||
|
"pollTimeout": 180000,
|
||||||
|
"smscode": {
|
||||||
|
"apiKey": "",
|
||||||
|
"baseUrl": "https://api.smscode.gg/v1",
|
||||||
|
"countryId": 0,
|
||||||
|
"serviceId": 0,
|
||||||
|
"productId": 0,
|
||||||
|
"catalogProductId": 0,
|
||||||
|
"maxPrice": ""
|
||||||
|
},
|
||||||
|
"smsbower": {
|
||||||
|
"apiKey": "",
|
||||||
|
"baseUrl": "https://smsbower.page/stubs/handler_api.php",
|
||||||
|
"country": 10,
|
||||||
|
"service": "dr",
|
||||||
|
"maxPrice": "0.2"
|
||||||
|
},
|
||||||
|
"herosms": {
|
||||||
|
"apiKey": "",
|
||||||
|
"baseUrl": "https://hero-sms.com/stubs/handler_api.php",
|
||||||
|
"country": 22,
|
||||||
|
"service": "dr",
|
||||||
|
"maxPrice": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"probeTokenAutoRefresh": true,
|
||||||
|
"probeTokenAutoRefreshMin": 15
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,118 @@
|
|||||||
|
// 反检测:为每个注册账号合成一套「内部自洽」的浏览器/设备指纹 + 随机姓名 + 成年生日。
|
||||||
|
// 目的:以前所有账号都写死同一 UA(Chrome/145 Windows)、同一屏幕/语言、姓名只在 6×6 里选、
|
||||||
|
// 年龄挤在 1995~2002,风控极易把这些注册归成同一批机器人。这里让「每账号一套、全程一致、
|
||||||
|
// 账号间不同」——UA / sec-ch-ua / platform / screen / navigator 全部彼此对齐(否则 sec-ch-ua
|
||||||
|
// 与 UA 不符本身就是强特征)。
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
|
function pick(arr) { return arr[crypto.randomInt(0, arr.length)]; }
|
||||||
|
|
||||||
|
// 每个 profile 固定「Chrome 大版本 ↔ 真实的 sec-ch-ua 品牌串」配对:sec-ch-ua 的 GREASE 品牌
|
||||||
|
// ("Not?A_Brand" 之类)与版本号是浏览器构建时生成的,随便拼会露馅,所以直接采样真实组合。
|
||||||
|
const CHROME_PROFILES = [
|
||||||
|
{ major: 140, secChUa: '"Chromium";v="140", "Not=A?Brand";v="24", "Google Chrome";v="140"' },
|
||||||
|
{ major: 141, secChUa: '"Google Chrome";v="141", "Not?A_Brand";v="8", "Chromium";v="141"' },
|
||||||
|
{ major: 142, secChUa: '"Chromium";v="142", "Google Chrome";v="142", "Not_A Brand";v="99"' },
|
||||||
|
{ major: 143, secChUa: '"Not;A=Brand";v="99", "Google Chrome";v="143", "Chromium";v="143"' },
|
||||||
|
{ major: 144, secChUa: '"Google Chrome";v="144", "Chromium";v="144", "Not.A/Brand";v="24"' },
|
||||||
|
{ major: 145, secChUa: '"Chromium";v="145", "Not)A;Brand";v="8", "Google Chrome";v="145"' },
|
||||||
|
{ major: 138, secChUa: '"Not)A;Brand";v="8", "Chromium";v="138", "Google Chrome";v="138"' },
|
||||||
|
{ major: 139, secChUa: '"Chromium";v="139", "Not_A Brand";v="24", "Google Chrome";v="139"' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// OS 决定 UA 平台段、navigator.platform、sec-ch-ua-platform 三者必须一致。
|
||||||
|
const OS_PROFILES = [
|
||||||
|
{ uaToken: 'Windows NT 10.0; Win64; x64', platform: 'Win32', chPlatform: '"Windows"' },
|
||||||
|
{ uaToken: 'Macintosh; Intel Mac OS X 10_15_7', platform: 'MacIntel', chPlatform: '"macOS"' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 常见桌面分辨率(宽x高);taskbar/书签栏占用后 availHeight 略小于 height,贴近真机。
|
||||||
|
const SCREENS = [
|
||||||
|
{ width: 1920, height: 1080 },
|
||||||
|
{ width: 2560, height: 1440 },
|
||||||
|
{ width: 1536, height: 864 },
|
||||||
|
{ width: 1440, height: 900 },
|
||||||
|
{ width: 1366, height: 768 },
|
||||||
|
{ width: 1680, height: 1050 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 英文母语区:语言/locale 保持在英语圈内,避免 UA 是美式 Chrome 却报 zh-CN 这种矛盾。
|
||||||
|
const LOCALES = [
|
||||||
|
{ language: 'en-US', accept: 'en-US,en;q=0.9', tz: 'America/New_York' },
|
||||||
|
{ language: 'en-US', accept: 'en-US,en;q=0.9', tz: 'America/Chicago' },
|
||||||
|
{ language: 'en-US', accept: 'en-US,en;q=0.9', tz: 'America/Los_Angeles' },
|
||||||
|
{ language: 'en-GB', accept: 'en-GB,en;q=0.9', tz: 'Europe/London' },
|
||||||
|
{ language: 'en-CA', accept: 'en-CA,en;q=0.9', tz: 'America/Toronto' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 生成一套彼此自洽的指纹。同一账号整条注册流程复用同一个对象;不同账号各自独立随机。
|
||||||
|
export function generateFingerprint() {
|
||||||
|
const chrome = pick(CHROME_PROFILES);
|
||||||
|
const osp = pick(OS_PROFILES);
|
||||||
|
const screen = pick(SCREENS);
|
||||||
|
const loc = pick(LOCALES);
|
||||||
|
const hardwareConcurrency = pick([4, 8, 12, 16]);
|
||||||
|
const deviceMemory = pick([8, 16]); // Chrome 只暴露 8/16 这类粗粒度值
|
||||||
|
|
||||||
|
const userAgent =
|
||||||
|
`Mozilla/5.0 (${osp.uaToken}) AppleWebKit/537.36 (KHTML, like Gecko) ` +
|
||||||
|
`Chrome/${chrome.major}.0.0.0 Safari/537.36`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
userAgent,
|
||||||
|
secChUa: chrome.secChUa,
|
||||||
|
secChUaMobile: '?0', // 桌面固定 ?0,与 UA 无移动标记一致
|
||||||
|
secChUaPlatform: osp.chPlatform,
|
||||||
|
platform: osp.platform,
|
||||||
|
language: loc.language,
|
||||||
|
acceptLanguage: loc.accept,
|
||||||
|
languages: `${loc.language},${loc.language.split('-')[0]}`,
|
||||||
|
timezone: loc.tz,
|
||||||
|
screen: {
|
||||||
|
width: screen.width,
|
||||||
|
height: screen.height,
|
||||||
|
// availHeight 减去任务栏/系统栏的典型高度,availWidth 通常等于 width。
|
||||||
|
availWidth: screen.width,
|
||||||
|
availHeight: screen.height - 40,
|
||||||
|
colorDepth: 24,
|
||||||
|
pixelDepth: 24,
|
||||||
|
},
|
||||||
|
screenStr: `${screen.width}x${screen.height}`,
|
||||||
|
hardwareConcurrency,
|
||||||
|
deviceMemory,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 扩充姓名池:各数十个真实常见英文名,随机组合,避免总是那几对被风控聚类。
|
||||||
|
const FIRST_NAMES = [
|
||||||
|
'James', 'Michael', 'Robert', 'John', 'David', 'William', 'Richard', 'Joseph',
|
||||||
|
'Thomas', 'Christopher', 'Daniel', 'Matthew', 'Anthony', 'Andrew', 'Joshua',
|
||||||
|
'Ryan', 'Brandon', 'Justin', 'Benjamin', 'Samuel', 'Nathan', 'Aaron', 'Adam',
|
||||||
|
'Mary', 'Patricia', 'Jennifer', 'Linda', 'Elizabeth', 'Barbara', 'Susan',
|
||||||
|
'Jessica', 'Sarah', 'Karen', 'Emily', 'Emma', 'Olivia', 'Sophia', 'Ava',
|
||||||
|
'Isabella', 'Mia', 'Charlotte', 'Amelia', 'Hannah', 'Grace', 'Rachel',
|
||||||
|
'Laura', 'Megan', 'Lauren', 'Victoria',
|
||||||
|
];
|
||||||
|
const LAST_NAMES = [
|
||||||
|
'Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Garcia', 'Miller', 'Davis',
|
||||||
|
'Rodriguez', 'Martinez', 'Hernandez', 'Lopez', 'Gonzalez', 'Wilson', 'Anderson',
|
||||||
|
'Thomas', 'Taylor', 'Moore', 'Jackson', 'Martin', 'Lee', 'Perez', 'Thompson',
|
||||||
|
'White', 'Harris', 'Sanchez', 'Clark', 'Ramirez', 'Lewis', 'Robinson', 'Walker',
|
||||||
|
'Young', 'Allen', 'King', 'Wright', 'Scott', 'Torres', 'Hill', 'Green',
|
||||||
|
'Adams', 'Baker', 'Nelson', 'Carter', 'Mitchell', 'Roberts', 'Turner',
|
||||||
|
'Phillips', 'Campbell', 'Parker', 'Evans',
|
||||||
|
];
|
||||||
|
|
||||||
|
export function randomFullName() {
|
||||||
|
return `${pick(FIRST_NAMES)} ${pick(LAST_NAMES)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 成年生日:约 18~48 岁均匀取样,月/日在安全范围(1~28)避免非法日期,格式 YYYY-MM-DD 与旧逻辑一致。
|
||||||
|
export function randomAdultBirthdate() {
|
||||||
|
const nowYear = new Date().getUTCFullYear();
|
||||||
|
const age = 18 + crypto.randomInt(0, 31); // 18..48
|
||||||
|
const year = nowYear - age;
|
||||||
|
const month = 1 + crypto.randomInt(0, 12);
|
||||||
|
const day = 1 + crypto.randomInt(0, 28);
|
||||||
|
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,205 @@
|
|||||||
|
// 接码 / SMS 验证码服务 —— hero-sms.com API 客户端(零依赖,使用全局 fetch)
|
||||||
|
// 协议:经典 sms-activate 风格,所有请求为 GET https://hero-sms.com/stubs/handler_api.php?api_key=..&action=..
|
||||||
|
// 响应为纯文本(冒号分隔),如 ACCESS_NUMBER:<activationId>:<phone>、STATUS_OK:<code>、ACCESS_BALANCE:<n>
|
||||||
|
//
|
||||||
|
// 与 smsbower.js / smscode.js 接口对齐(可作为调度器的替换供应商):
|
||||||
|
// acquireNumber(cfg, { log }) → { orderId, phone, order }
|
||||||
|
// pollOtp(cfg, id, { timeoutMs, intervalMs, log }) → { code, message, order }
|
||||||
|
// finishOrder(cfg, id) → setStatus status=6(完成)
|
||||||
|
// cancelOrder(cfg, id, { log }) → setStatus status=8(取消,2 分钟内不可取消时静默处理)
|
||||||
|
// getBalance(cfg) → 余额数字
|
||||||
|
//
|
||||||
|
// 已实测(2026-07):base=https://hero-sms.com/stubs/handler_api.php,
|
||||||
|
// India country=22,OpenAI service="dr"(getServicesList 返回 {code:"dr",name:"OpenAI"})。
|
||||||
|
// 配置字段:apiKey、baseUrl(默认 hero-sms 地址)、country(默认 22=印度)、
|
||||||
|
// service(默认 "dr"=OpenAI)、maxPrice、minPrice
|
||||||
|
|
||||||
|
const DEFAULT_BASE = 'https://hero-sms.com/stubs/handler_api.php';
|
||||||
|
const DEFAULT_COUNTRY = 22; // 印度
|
||||||
|
const DEFAULT_SERVICE = 'dr'; // OpenAI
|
||||||
|
|
||||||
|
function normBase(baseUrl) {
|
||||||
|
return String(baseUrl || DEFAULT_BASE).trim().replace(/\/+$/, '') || DEFAULT_BASE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统一请求:拼接 api_key + action + 参数,返回纯文本响应(已 trim)
|
||||||
|
async function request(cfg, action, params = {}, { timeoutMs = 30000 } = {}) {
|
||||||
|
const apiKey = String((cfg && cfg.apiKey) || '').trim();
|
||||||
|
if (!apiKey) {
|
||||||
|
const e = new Error('未配置 hero-sms API Key(系统设置 → 接码配置)');
|
||||||
|
e.code = 'NO_API_KEY';
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
const q = new URLSearchParams({ api_key: apiKey, action });
|
||||||
|
for (const [k, v] of Object.entries(params)) {
|
||||||
|
if (v !== undefined && v !== null && v !== '') q.set(k, String(v));
|
||||||
|
}
|
||||||
|
const url = `${normBase(cfg && cfg.baseUrl)}?${q.toString()}`;
|
||||||
|
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await fetch(url, { method: 'GET', signal: ctrl.signal });
|
||||||
|
} catch (err) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
const e = new Error(err.name === 'AbortError' ? `接码请求超时(action=${action})` : `接码请求失败:${err.message}`);
|
||||||
|
e.code = 'NETWORK';
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
clearTimeout(timer);
|
||||||
|
|
||||||
|
const text = String(await res.text().catch(() => '')).trim();
|
||||||
|
if (!res.ok) {
|
||||||
|
const e = new Error(`接码接口错误:HTTP_${res.status}${text ? ' · ' + text.slice(0, 200) : ''}`);
|
||||||
|
e.code = `HTTP_${res.status}`;
|
||||||
|
e.status = res.status;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通用错误码 → 带 code 的 Error(BAD_KEY / BAD_ACTION / BAD_SERVICE / NO_ACTIVATION 等)
|
||||||
|
function apiError(token, action) {
|
||||||
|
const map = {
|
||||||
|
BAD_KEY: 'API Key 无效',
|
||||||
|
BAD_ACTION: '接口 action 无效',
|
||||||
|
BAD_SERVICE: '服务代码无效',
|
||||||
|
NO_ACTIVATION: '激活订单不存在',
|
||||||
|
NO_BALANCE: '余额不足',
|
||||||
|
NO_NUMBERS: '当前无可用号码',
|
||||||
|
};
|
||||||
|
const e = new Error(`接码接口错误:${token}${map[token] ? ' · ' + map[token] : ''}(action=${action})`);
|
||||||
|
e.code = token;
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((r) => setTimeout(r, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 余额 ----------
|
||||||
|
// 返回余额数字。响应:ACCESS_BALANCE:<balance>
|
||||||
|
export async function getBalance(cfg) {
|
||||||
|
const text = await request(cfg, 'getBalance');
|
||||||
|
const m = /^ACCESS_BALANCE:(.+)$/.exec(text);
|
||||||
|
if (m) {
|
||||||
|
const n = Number(m[1]);
|
||||||
|
if (Number.isFinite(n)) return n;
|
||||||
|
}
|
||||||
|
throw apiError(text || 'EMPTY_RESPONSE', 'getBalance');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 拿号 ----------
|
||||||
|
// 返回 { orderId, phone, order },与 smsbower.acquireNumber 一致(orderId = activationId)。
|
||||||
|
// NO_NUMBERS(无库存,可回退重试)→ e.code='NO_OFFER_AVAILABLE'(与其它平台的库存错误标记一致),
|
||||||
|
// 并附 e.providerCode='NO_NUMBERS';NO_BALANCE / BAD_KEY / BAD_SERVICE 为硬错误,直接抛出。
|
||||||
|
export async function acquireNumber(cfg, { log } = {}) {
|
||||||
|
const say = (level, msg) => { if (typeof log === 'function') log(level, msg); };
|
||||||
|
const country = cfg.country !== undefined && cfg.country !== null && cfg.country !== '' ? cfg.country : DEFAULT_COUNTRY;
|
||||||
|
const service = String(cfg.service || DEFAULT_SERVICE);
|
||||||
|
|
||||||
|
const params = { service, country };
|
||||||
|
if (cfg.maxPrice) params.maxPrice = cfg.maxPrice;
|
||||||
|
if (cfg.minPrice) params.minPrice = cfg.minPrice;
|
||||||
|
if (cfg.operator) params.operator = cfg.operator; // 指定运营商(如 tim/claro/vivo),换号段规避风控
|
||||||
|
|
||||||
|
say('info', `向 hero-sms 请求号码(service=${service} country=${country}${cfg.operator ? ' operator=' + cfg.operator : ''}${cfg.maxPrice ? ' maxPrice=' + cfg.maxPrice : ''})…`);
|
||||||
|
const text = await request(cfg, 'getNumber', params);
|
||||||
|
|
||||||
|
const m = /^ACCESS_NUMBER:([^:]+):(.+)$/.exec(text);
|
||||||
|
if (m) {
|
||||||
|
const orderId = m[1];
|
||||||
|
const phone = m[2];
|
||||||
|
const order = { id: orderId, phone_number: phone, status: 'PENDING', raw: text };
|
||||||
|
say('ok', `已获取号码:${phone}(订单 ${orderId})`);
|
||||||
|
return { orderId, phone, order };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text === 'NO_NUMBERS' || text.startsWith('NO_NUMBERS')) {
|
||||||
|
const e = new Error(`当前无可用号码(service=${service} country=${country})`);
|
||||||
|
e.code = 'NO_OFFER_AVAILABLE';
|
||||||
|
e.providerCode = 'NO_NUMBERS';
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
throw apiError(text || 'EMPTY_RESPONSE', 'getNumber');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 查询状态 ----------
|
||||||
|
// 返回 { status, code }。status ∈ WAIT_CODE / WAIT_RETRY / CANCEL / OK
|
||||||
|
export async function getStatus(cfg, id) {
|
||||||
|
const text = await request(cfg, 'getStatus', { id });
|
||||||
|
if (text === 'STATUS_WAIT_CODE') return { status: 'WAIT_CODE', code: '' };
|
||||||
|
let m = /^STATUS_WAIT_RETRY:(.*)$/.exec(text);
|
||||||
|
if (m) return { status: 'WAIT_RETRY', code: m[1] };
|
||||||
|
if (text === 'STATUS_CANCEL') return { status: 'CANCEL', code: '' };
|
||||||
|
m = /^STATUS_OK:(.+)$/.exec(text);
|
||||||
|
if (m) return { status: 'OK', code: m[1] };
|
||||||
|
throw apiError(text || 'EMPTY_RESPONSE', 'getStatus');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 轮询等待验证码 ----------
|
||||||
|
// 直到 STATUS_OK 拿到 code,或订单取消/超时抛错。返回 { code, message, order },与其它平台一致。
|
||||||
|
export async function pollOtp(cfg, id, { timeoutMs = 180000, intervalMs = 4000, log } = {}) {
|
||||||
|
const start = Date.now();
|
||||||
|
const say = (level, msg) => { if (typeof log === 'function') log(level, msg); };
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
let st;
|
||||||
|
try {
|
||||||
|
st = await getStatus(cfg, id);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code === 'NO_ACTIVATION' || e.code === 'BAD_KEY') throw e; // 不可恢复
|
||||||
|
say('warn', `查询订单失败(将重试):${e.message}`);
|
||||||
|
await sleep(intervalMs);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (st.status === 'OK' && st.code) {
|
||||||
|
say('ok', `已收到验证码:${st.code}`);
|
||||||
|
const order = { id, status: 'OTP_RECEIVED', otp_code: st.code };
|
||||||
|
return { code: st.code, message: '', order };
|
||||||
|
}
|
||||||
|
if (st.status === 'CANCEL') {
|
||||||
|
const e = new Error('订单已取消,未收到验证码');
|
||||||
|
e.code = 'CANCELED';
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
say('info', `等待验证码…(订单 ${id} 状态 ${st.status}${st.status === 'WAIT_RETRY' && st.code ? ',上一条验证码 ' + st.code : ''})`);
|
||||||
|
await sleep(intervalMs);
|
||||||
|
}
|
||||||
|
const e = new Error(`等待验证码超时(${Math.round(timeoutMs / 1000)}s)`);
|
||||||
|
e.code = 'OTP_TIMEOUT';
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 状态变更 ----------
|
||||||
|
// setStatus:1=已就绪 3=请求下一条验证码 6=完成 8=取消
|
||||||
|
async function setStatus(cfg, id, status) {
|
||||||
|
return request(cfg, 'setStatus', { id, status });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 完成订单(收到验证码后调用,setStatus=6)。响应:ACCESS_ACTIVATION
|
||||||
|
export async function finishOrder(cfg, id) {
|
||||||
|
const text = await setStatus(cfg, id, 6);
|
||||||
|
if (text === 'ACCESS_ACTIVATION') return { id, status: 'FINISHED' };
|
||||||
|
throw apiError(text || 'EMPTY_RESPONSE', 'setStatus(6)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取消订单(setStatus=8)。响应:ACCESS_CANCEL。
|
||||||
|
// EARLY_CANCEL_DENIED(购买 2 分钟内不可取消)按非致命处理:记 warn 后正常返回,号码到期自动退款。
|
||||||
|
export async function cancelOrder(cfg, id, { log } = {}) {
|
||||||
|
const say = (level, msg) => { if (typeof log === 'function') log(level, msg); };
|
||||||
|
const text = await setStatus(cfg, id, 8);
|
||||||
|
if (text === 'ACCESS_CANCEL') return { id, status: 'CANCELED' };
|
||||||
|
if (text === 'EARLY_CANCEL_DENIED') {
|
||||||
|
say('warn', `订单 ${id} 购买未满 2 分钟,暂不可取消(到期后将自动退款)`);
|
||||||
|
return { id, status: 'CANCEL_PENDING', earlyCancelDenied: true };
|
||||||
|
}
|
||||||
|
throw apiError(text || 'EMPTY_RESPONSE', 'setStatus(8)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 请求下一条验证码(setStatus=3)。响应:ACCESS_RETRY_GET
|
||||||
|
export async function resendOrder(cfg, id) {
|
||||||
|
const text = await setStatus(cfg, id, 3);
|
||||||
|
if (text === 'ACCESS_RETRY_GET') return { id, status: 'WAIT_RETRY' };
|
||||||
|
throw apiError(text || 'EMPTY_RESPONSE', 'setStatus(3)');
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// 查收 wrapper —— 由 Flask /api/plus/inbox 通过 `node inbox_runner.mjs` 调用。
|
||||||
|
// 输入:stdin 一段 JSON { email, clientId, refreshToken, limit? }
|
||||||
|
// 输出:stdout 一段 JSON
|
||||||
|
// 成功 { ok:true, address, via, messages:[...] }
|
||||||
|
// 失败 { ok:false, status, error }
|
||||||
|
// 复用 auto 项目原封不动的 plus-mailbox.js(fetchPlusInbox)。
|
||||||
|
import { fetchPlusInbox } from './plus-mailbox.js';
|
||||||
|
|
||||||
|
// 复用的脚本会用 console.log 打印诊断信息(如 plus-mailbox 的“令牌换取成功”),
|
||||||
|
// 这些默认写 stdout 会污染我们的 JSON 结果。统一改写到 stderr,保证 stdout 只有结果 JSON。
|
||||||
|
const _errWrite = (...a) => { try { process.stderr.write(a.map(String).join(' ') + '\n'); } catch { /* ignore */ } };
|
||||||
|
console.log = _errWrite;
|
||||||
|
console.info = _errWrite;
|
||||||
|
console.warn = _errWrite;
|
||||||
|
|
||||||
|
function readStdin() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let buf = '';
|
||||||
|
process.stdin.setEncoding('utf8');
|
||||||
|
process.stdin.on('data', (c) => { buf += c; });
|
||||||
|
process.stdin.on('end', () => resolve(buf));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const raw = await readStdin();
|
||||||
|
const input = JSON.parse(raw || '{}');
|
||||||
|
const { email, clientId, refreshToken } = input;
|
||||||
|
const limit = Number(input.limit) || 15;
|
||||||
|
const r = await fetchPlusInbox({ email, clientId, refreshToken }, limit);
|
||||||
|
process.stdout.write(JSON.stringify({ ok: true, address: r.address, via: r.via, messages: r.messages || [] }));
|
||||||
|
} catch (e) {
|
||||||
|
process.stdout.write(JSON.stringify({ ok: false, status: e && e.status ? e.status : 502, error: (e && e.message) || String(e) }));
|
||||||
|
}
|
||||||
|
})();
|
||||||
Generated
+137
@@ -0,0 +1,137 @@
|
|||||||
|
{
|
||||||
|
"name": "account-manager-node",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "account-manager-node",
|
||||||
|
"dependencies": {
|
||||||
|
"cloakbrowser": "^0.5.3",
|
||||||
|
"playwright-core": "^1.62.1",
|
||||||
|
"undici-legacy": "npm:undici@=7.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@isaacs/fs-minipass": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"minipass": "^7.0.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/chownr": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/chownr/-/chownr-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cloakbrowser": {
|
||||||
|
"version": "0.5.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/cloakbrowser/-/cloakbrowser-0.5.3.tgz",
|
||||||
|
"integrity": "sha512-awxjoA3y+id0H9m9278GXftCkgcRcJSWu9D1Oc5E76L063GyMkNbTii8ZCrEQ4zA7b3H3zjRWF+HCtwpHwrsrw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tar": "^7.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"cloakbrowser": "dist/cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"mmdb-lib": ">=2.0.0",
|
||||||
|
"playwright-core": ">=1.53.0",
|
||||||
|
"puppeteer-core": ">=21.0.0",
|
||||||
|
"socks-proxy-agent": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"mmdb-lib": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"playwright-core": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"puppeteer-core": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"socks-proxy-agent": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/minipass": {
|
||||||
|
"version": "7.1.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/minipass/-/minipass-7.1.3.tgz",
|
||||||
|
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16 || 14 >=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/minizlib": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/minizlib/-/minizlib-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"minipass": "^7.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.62.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||||
|
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tar": {
|
||||||
|
"version": "7.5.22",
|
||||||
|
"resolved": "https://registry.npmmirror.com/tar/-/tar-7.5.22.tgz",
|
||||||
|
"integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==",
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@isaacs/fs-minipass": "^4.0.0",
|
||||||
|
"chownr": "^3.0.0",
|
||||||
|
"minipass": "^7.1.2",
|
||||||
|
"minizlib": "^3.1.0",
|
||||||
|
"yallist": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici-legacy": {
|
||||||
|
"name": "undici",
|
||||||
|
"version": "7.21.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.18.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yallist": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/yallist/-/yallist-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "account-manager-node",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "账号管理 Node 引擎:协议注册(get-rt.js) + 浏览器注册(cloakbrowser)。",
|
||||||
|
"dependencies": {
|
||||||
|
"cloakbrowser": "^0.5.3",
|
||||||
|
"playwright-core": "^1.62.1",
|
||||||
|
"undici-legacy": "npm:undici@=7.21.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
// PLUS 邮箱收信 —— 微软 Outlook/Hotmail OAuth(refresh_token + client_id)
|
||||||
|
// 导入格式:email----password----clientId----refreshToken
|
||||||
|
// 收信流程:refresh_token 换 access_token → 优先 Microsoft Graph 读收件箱,失败回退 IMAP(XOAUTH2)
|
||||||
|
import tls from 'node:tls';
|
||||||
|
|
||||||
|
const TOKEN_URL = 'https://login.microsoftonline.com/consumers/oauth2/v2.0/token';
|
||||||
|
// 这些批量 Outlook 账号的 refresh_token 是按「资源级同意」(.default) 颁发的,
|
||||||
|
// 直接请求细粒度 scope(如 https://graph.microsoft.com/Mail.Read)会被拒:
|
||||||
|
// AADSTS70000 invalid_grant: one or more scopes requested are unauthorized or expired
|
||||||
|
// 因此每种传输都按候选 scope 依次尝试,取第一个成功者(.default 优先,细粒度兜底)。
|
||||||
|
const GRAPH_SCOPES = [
|
||||||
|
'https://graph.microsoft.com/.default',
|
||||||
|
'https://graph.microsoft.com/Mail.Read offline_access',
|
||||||
|
];
|
||||||
|
const IMAP_SCOPES = [
|
||||||
|
'https://outlook.office.com/IMAP.AccessAsUser.All offline_access',
|
||||||
|
'https://outlook.office.com/.default',
|
||||||
|
];
|
||||||
|
const IMAP_HOST = 'outlook.office365.com';
|
||||||
|
const IMAP_PORT = 993;
|
||||||
|
|
||||||
|
const tokenCache = new Map(); // key: clientId|refreshToken|scope -> { token, ts }
|
||||||
|
const TOKEN_TTL = 45 * 60 * 1000;
|
||||||
|
|
||||||
|
// ---------- 解析导入格式 ----------
|
||||||
|
// 每行:email----password----clientId----refreshToken(也兼容用 | 或制表符分隔的多字段)
|
||||||
|
export function parsePlus(text) {
|
||||||
|
const out = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const raw of String(text || '').split(/\r?\n/)) {
|
||||||
|
const line = raw.trim();
|
||||||
|
if (!line) continue;
|
||||||
|
let parts;
|
||||||
|
if (line.includes('----')) parts = line.split('----');
|
||||||
|
else if (line.includes('\t')) parts = line.split('\t');
|
||||||
|
else if (line.includes('|')) parts = line.split('|');
|
||||||
|
else parts = line.split(/\s+/);
|
||||||
|
parts = parts.map((s) => s.trim()).filter(Boolean);
|
||||||
|
if (parts.length < 4) continue;
|
||||||
|
const [email, password, clientId, refreshToken] = parts;
|
||||||
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) continue;
|
||||||
|
const key = email.toLowerCase();
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
out.push({ email, password, clientId, refreshToken });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 验证码提取(与 buyer 收信口径一致:4-8 位数字优先)----------
|
||||||
|
export function extractCode(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
const s = String(text);
|
||||||
|
const labeled = s.match(/(?:code|验证码|verification|otp|passcode)[^\d]{0,12}(\d{4,8})/i);
|
||||||
|
if (labeled) return labeled[1];
|
||||||
|
const m = s.match(/\b(\d{4,8})\b/);
|
||||||
|
return m ? m[1] : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 换取 access_token(单个 scope)----------
|
||||||
|
async function exchangeToken(clientId, refreshToken, scope) {
|
||||||
|
const ck = `${clientId}|${refreshToken}|${scope}`;
|
||||||
|
const cached = tokenCache.get(ck);
|
||||||
|
if (cached && Date.now() - cached.ts < TOKEN_TTL) return cached.token;
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('client_id', clientId);
|
||||||
|
params.set('grant_type', 'refresh_token');
|
||||||
|
params.set('refresh_token', refreshToken);
|
||||||
|
params.set('scope', scope);
|
||||||
|
const res = await fetch(TOKEN_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: params.toString(),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok || !data.access_token) {
|
||||||
|
const e = new Error(`换取令牌失败:${data.error || res.status} ${data.error_description ? '· ' + String(data.error_description).slice(0, 120) : ''}`);
|
||||||
|
e.status = res.status;
|
||||||
|
e.oauthError = data.error || '';
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
tokenCache.set(ck, { token: data.access_token, ts: Date.now() });
|
||||||
|
return data.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 换取 access_token(按候选 scope 回退)----------
|
||||||
|
// scopes 为候选 scope 数组,依次尝试,返回第一个成功者的 token。
|
||||||
|
// 遇到 invalid_grant/scope 类错误继续尝试下一个 scope;全部失败则抛出最后错误。
|
||||||
|
// 成功时打印命中的 scope,便于观察是哪种同意口径生效。
|
||||||
|
async function getAccessToken(clientId, refreshToken, scopes) {
|
||||||
|
const candidates = Array.isArray(scopes) ? scopes : [scopes];
|
||||||
|
let lastErr = null;
|
||||||
|
for (const scope of candidates) {
|
||||||
|
try {
|
||||||
|
const token = await exchangeToken(clientId, refreshToken, scope);
|
||||||
|
if (candidates.length > 1) {
|
||||||
|
console.log(`[plus-mailbox] 令牌换取成功 scope=${scope}`);
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
} catch (e) {
|
||||||
|
lastErr = e;
|
||||||
|
// scope 未授权/过期 → 尝试下一个候选;其它错误(RT 失效等)同样继续,
|
||||||
|
// 但若全部候选都失败则把最后的错误抛出(通常即真正原因)。
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastErr || new Error('换取令牌失败:无可用 scope');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- MIME 解码:编码字(RFC2047)/ quoted-printable / base64 ----------
|
||||||
|
function decodeQP(str, isWord = false) {
|
||||||
|
let s = String(str || '');
|
||||||
|
if (isWord) s = s.replace(/_/g, ' ');
|
||||||
|
return s
|
||||||
|
.replace(/=\r?\n/g, '')
|
||||||
|
.replace(/=([0-9A-Fa-f]{2})/g, (_, h) => String.fromCharCode(parseInt(h, 16)));
|
||||||
|
}
|
||||||
|
function utf8(bytesStr) {
|
||||||
|
// bytesStr 为 latin1 字节串,按 UTF-8 还原
|
||||||
|
try { return Buffer.from(bytesStr, 'latin1').toString('utf8'); } catch { return bytesStr; }
|
||||||
|
}
|
||||||
|
function decodeEncodedWords(str) {
|
||||||
|
return String(str || '').replace(/=\?([^?]+)\?([BbQq])\?([^?]*)\?=/g, (_, charset, enc, data) => {
|
||||||
|
try {
|
||||||
|
let bytes;
|
||||||
|
if (enc.toUpperCase() === 'B') bytes = Buffer.from(data, 'base64').toString('latin1');
|
||||||
|
else bytes = decodeQP(data, true);
|
||||||
|
return /utf-?8/i.test(charset) ? utf8(bytes) : bytes;
|
||||||
|
} catch { return data; }
|
||||||
|
}).replace(/\?=\s*=\?/g, ''); // 相邻编码字之间的折行分隔
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- HTML → 纯文本 ----------
|
||||||
|
function htmlToText(html) {
|
||||||
|
return String(html || '')
|
||||||
|
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
||||||
|
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
||||||
|
.replace(/<[^>]+>/g, ' ')
|
||||||
|
.replace(/ /g, ' ')
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/&#(\d+);/g, (_, d) => String.fromCharCode(Number(d)))
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Graph 读收件箱 ----------
|
||||||
|
async function fetchViaGraph(clientId, refreshToken, limit) {
|
||||||
|
const token = await getAccessToken(clientId, refreshToken, GRAPH_SCOPES);
|
||||||
|
const url = `https://graph.microsoft.com/v1.0/me/mailFolders/inbox/messages?$top=${limit}` +
|
||||||
|
`&$select=subject,from,receivedDateTime,bodyPreview,body&$orderby=receivedDateTime desc`;
|
||||||
|
const res = await fetch(url, { headers: { authorization: `Bearer ${token}` } });
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
const e = new Error(`Graph 读信失败:${(data.error && data.error.message) || res.status}`);
|
||||||
|
e.status = res.status;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return (data.value || []).map((m) => {
|
||||||
|
const bodyText = m.body && m.body.contentType === 'html' ? htmlToText(m.body.content) : (m.body && m.body.content) || m.bodyPreview || '';
|
||||||
|
return {
|
||||||
|
from_addr: (m.from && m.from.emailAddress && m.from.emailAddress.address) || '',
|
||||||
|
subject: m.subject || '',
|
||||||
|
received_at: m.receivedDateTime || '',
|
||||||
|
body: bodyText.slice(0, 4000),
|
||||||
|
snippet: m.bodyPreview || '',
|
||||||
|
extracted_code: extractCode(`${m.subject || ''} ${m.bodyPreview || ''} ${bodyText}`),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- IMAP(XOAUTH2) 读收件箱(Graph 失败时回退)----------
|
||||||
|
function imapRead(email, token, limit) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const sock = tls.connect(IMAP_PORT, IMAP_HOST, { servername: IMAP_HOST }, () => {});
|
||||||
|
let buf = '';
|
||||||
|
let stage = 'greeting';
|
||||||
|
let tag = 0;
|
||||||
|
let exists = 0;
|
||||||
|
let raw = '';
|
||||||
|
let settled = false;
|
||||||
|
const timer = setTimeout(() => finish(new Error('IMAP 超时')), 20000);
|
||||||
|
function finish(err, val) {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
try { sock.end(); sock.destroy(); } catch {}
|
||||||
|
err ? reject(err) : resolve(val);
|
||||||
|
}
|
||||||
|
const send = (cmd) => { tag++; sock.write(`a${tag} ${cmd}\r\n`); return `a${tag}`; };
|
||||||
|
sock.setEncoding('utf8');
|
||||||
|
sock.on('error', (e) => finish(new Error('IMAP ' + (e.code || e.message))));
|
||||||
|
sock.on('data', (chunk) => {
|
||||||
|
buf += chunk;
|
||||||
|
if (stage === 'greeting') {
|
||||||
|
if (!/\r\n/.test(buf)) return;
|
||||||
|
buf = '';
|
||||||
|
stage = 'auth';
|
||||||
|
const b64 = Buffer.from(`user=${email}\x01auth=Bearer ${token}\x01\x01`).toString('base64');
|
||||||
|
sock.write(`a1 AUTHENTICATE XOAUTH2 ${b64}\r\n`);
|
||||||
|
tag = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (stage === 'auth') {
|
||||||
|
if (/^\+/m.test(buf)) { sock.write('\r\n'); } // 服务器要求继续 → 发空行以取回错误
|
||||||
|
if (/^a1 OK/mi.test(buf)) { buf = ''; stage = 'select'; send('SELECT INBOX'); return; }
|
||||||
|
if (/^a1 (NO|BAD)/mi.test(buf)) return finish(new Error('IMAP 认证失败(XOAUTH2)'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (stage === 'select') {
|
||||||
|
const m = buf.match(/\*\s+(\d+)\s+EXISTS/i);
|
||||||
|
if (m) exists = Number(m[1]);
|
||||||
|
if (/^a2 OK/mi.test(buf)) {
|
||||||
|
buf = '';
|
||||||
|
if (!exists) return finish(null, []);
|
||||||
|
const from = Math.max(1, exists - limit + 1);
|
||||||
|
stage = 'fetch';
|
||||||
|
send(`FETCH ${from}:${exists} (BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE)] BODY.PEEK[TEXT])`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (/^a2 (NO|BAD)/mi.test(buf)) return finish(new Error('IMAP SELECT 失败'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (stage === 'fetch') {
|
||||||
|
raw += chunk;
|
||||||
|
if (/^a3 OK/mi.test(raw)) return finish(null, parseImapFetch(raw));
|
||||||
|
if (/^a3 (NO|BAD)/mi.test(raw)) return finish(new Error('IMAP FETCH 失败'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// buf 只在非 fetch 阶段用;fetch 阶段用 raw
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 BODY[TEXT] 原文中挑出可读正文段:multipart 优先 text/plain,其次 text/html
|
||||||
|
function pickBodyPart(rawBody) {
|
||||||
|
let segment = rawBody;
|
||||||
|
const boundaryM = rawBody.match(/boundary="?([^"\r\n;]+)"?/i);
|
||||||
|
if (boundaryM) {
|
||||||
|
const bnd = boundaryM[1].replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
const parts = rawBody.split(new RegExp('--' + bnd));
|
||||||
|
let plain = null, html = null;
|
||||||
|
for (const p of parts) {
|
||||||
|
if (!plain && /Content-Type:\s*text\/plain/i.test(p)) plain = p;
|
||||||
|
else if (!html && /Content-Type:\s*text\/html/i.test(p)) html = p;
|
||||||
|
}
|
||||||
|
segment = plain || html || rawBody;
|
||||||
|
}
|
||||||
|
const cte = (segment.match(/Content-Transfer-Encoding:\s*([\w-]+)/i) || [])[1] || '';
|
||||||
|
const charset = (segment.match(/charset=?["']?\s*([\w-]+)/i) || [])[1] || '';
|
||||||
|
const headEnd = segment.search(/\r?\n\r?\n/);
|
||||||
|
const content = headEnd >= 0 ? segment.slice(headEnd).replace(/^\s+/, '') : segment;
|
||||||
|
return { content, cte, isUtf8: /utf-?8/i.test(charset) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 极简解析:按邮件块切分,抓取 From/Subject/Date 和正文文本,并做 MIME 解码
|
||||||
|
function parseImapFetch(raw) {
|
||||||
|
const out = [];
|
||||||
|
const blocks = raw.split(/\*\s+\d+\s+FETCH/i).slice(1);
|
||||||
|
for (const b of blocks) {
|
||||||
|
const from = decodeEncodedWords((b.match(/From:\s*(.*)/i) || [])[1] || '');
|
||||||
|
const subject = decodeEncodedWords((b.match(/Subject:\s*(.*)/i) || [])[1] || '').trim();
|
||||||
|
const date = ((b.match(/Date:\s*(.*)/i) || [])[1] || '').trim();
|
||||||
|
const textIdx = b.search(/BODY\[TEXT\]/i);
|
||||||
|
let body = '';
|
||||||
|
if (textIdx >= 0) {
|
||||||
|
const after = b.slice(textIdx);
|
||||||
|
const lit = after.match(/\{(\d+)\}\r?\n/);
|
||||||
|
if (lit) {
|
||||||
|
const start = after.indexOf(lit[0]) + lit[0].length;
|
||||||
|
body = after.slice(start, start + Number(lit[1]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 挑选正文段(multipart 优先 text/plain),按其自身编码解码
|
||||||
|
const part = pickBodyPart(body);
|
||||||
|
let content = part.content;
|
||||||
|
if (/base64/i.test(part.cte)) {
|
||||||
|
try { content = Buffer.from(content.replace(/\s+/g, ''), 'base64').toString(part.isUtf8 ? 'utf8' : 'latin1'); } catch {}
|
||||||
|
} else if (/quoted-printable/i.test(part.cte)) {
|
||||||
|
content = decodeQP(content);
|
||||||
|
if (part.isUtf8) content = utf8(content);
|
||||||
|
} else {
|
||||||
|
content = decodeQP(content); // 兜底:常见 =XX 也一并还原
|
||||||
|
if (part.isUtf8) content = utf8(content);
|
||||||
|
}
|
||||||
|
body = htmlToText(content).slice(0, 4000);
|
||||||
|
out.push({
|
||||||
|
from_addr: from.trim().replace(/^.*<|>.*$/g, '').trim() || from.trim(),
|
||||||
|
subject,
|
||||||
|
received_at: date,
|
||||||
|
body,
|
||||||
|
snippet: body.slice(0, 160),
|
||||||
|
extracted_code: extractCode(`${subject} ${body}`),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 对外统一入口 ----------
|
||||||
|
export async function fetchPlusInbox({ email, clientId, refreshToken }, limit = 15) {
|
||||||
|
if (!clientId || !refreshToken) {
|
||||||
|
const e = new Error('缺少 clientId / refreshToken');
|
||||||
|
e.status = 400;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const messages = await fetchViaGraph(clientId, refreshToken, limit);
|
||||||
|
return { address: email, via: 'graph', messages };
|
||||||
|
} catch (graphErr) {
|
||||||
|
// Graph 不通(scope 未授权等)→ 回退 IMAP
|
||||||
|
try {
|
||||||
|
const token = await getAccessToken(clientId, refreshToken, IMAP_SCOPES);
|
||||||
|
const messages = await imapRead(email, token, limit);
|
||||||
|
return { address: email, via: 'imap', messages };
|
||||||
|
} catch (imapErr) {
|
||||||
|
const e = new Error(`收信失败(Graph: ${graphErr.message};IMAP: ${imapErr.message})`);
|
||||||
|
e.status = graphErr.status || 502;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// 「重拿RT」wrapper —— 对【已存在账号】用 codex 客户端跑一次全新登录(register:false),
|
||||||
|
// 换取新的 codex refreshToken / accessToken(全程用全新 device_id)。
|
||||||
|
// 输入(stdin JSON):{ email, password, settings, proxy, otpBase }
|
||||||
|
// 输出:
|
||||||
|
// - 过程日志:逐行写 stderr,每行 JSON { level, msg, account }
|
||||||
|
// - 最终结果:stdout 一段 JSON(getRefreshToken 返回结构:{ ok, refreshToken, accessToken, clientId, deviceId, ... })
|
||||||
|
import { getRefreshToken } from './get-rt.js';
|
||||||
|
|
||||||
|
const _errWrite = (...a) => { try { process.stderr.write(a.map(String).join(' ') + '\n'); } catch { /* ignore */ } };
|
||||||
|
console.log = _errWrite;
|
||||||
|
console.info = _errWrite;
|
||||||
|
console.warn = _errWrite;
|
||||||
|
|
||||||
|
function readStdin() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let buf = '';
|
||||||
|
process.stdin.setEncoding('utf8');
|
||||||
|
process.stdin.on('data', (c) => { buf += c; });
|
||||||
|
process.stdin.on('end', () => resolve(buf));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitLog(level, msg, account) {
|
||||||
|
try { process.stderr.write(JSON.stringify({ level, msg: String(msg), account: account || '' }) + '\n'); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
let email = '';
|
||||||
|
try {
|
||||||
|
const input = JSON.parse((await readStdin()) || '{}');
|
||||||
|
email = input.email || '';
|
||||||
|
const settings = input.settings || {};
|
||||||
|
const proxy = input.proxy || null;
|
||||||
|
const password = input.password || '';
|
||||||
|
const otpBase = String(input.otpBase || 'http://127.0.0.1:5088').replace(/\/$/, '');
|
||||||
|
if (!email) throw new Error('缺少邮箱');
|
||||||
|
|
||||||
|
// 邮箱验证码来源:邮箱池(经 Flask /api/reg/inbox)。已注册账号的邮箱在池中,可读到新码。
|
||||||
|
const fetchInbox = async (account /* , limit */) => {
|
||||||
|
const addr = (account && account.email) || email;
|
||||||
|
const url = `${otpBase}/api/reg/inbox?email=${encodeURIComponent(addr)}`;
|
||||||
|
const res = await fetch(url, { method: 'GET' });
|
||||||
|
if (!res.ok) throw new Error('读取邮箱失败 HTTP ' + res.status);
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
return { messages: (data && data.messages) || [] };
|
||||||
|
};
|
||||||
|
|
||||||
|
emitLog('info', '重拿RT中', email);
|
||||||
|
const result = await getRefreshToken({ email, password }, {
|
||||||
|
settings,
|
||||||
|
proxy,
|
||||||
|
register: false,
|
||||||
|
password,
|
||||||
|
log: (level, msg, acc) => emitLog(level, msg, acc || email),
|
||||||
|
fetchInbox,
|
||||||
|
});
|
||||||
|
if (result && result.refreshToken) result.ok = true;
|
||||||
|
process.stdout.write(JSON.stringify(result || { ok: false, error: '无结果' }));
|
||||||
|
} catch (e) {
|
||||||
|
emitLog('error', (e && e.message) || String(e), email);
|
||||||
|
process.stdout.write(JSON.stringify({ ok: false, error: (e && e.message) || String(e) }));
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// 注册密码统一来源 —— 协议模式(reg_runner.mjs) 与 浏览器模式(cloak_reg_runner.mjs) 共用。
|
||||||
|
//
|
||||||
|
// 为什么改成「固定/确定性密码」而不是每次随机:
|
||||||
|
// 随机密码会导致「同一邮箱之前已建号、这次又进来」时用新的随机密码去登录 → OpenAI 报
|
||||||
|
// "Incorrect email address or password",账号再也拿不到登录态。固定密码让「建号」和
|
||||||
|
// 「已注册邮箱重进时的登录」始终用同一个口令,也方便导出账密后人工登录。
|
||||||
|
//
|
||||||
|
// 优先级:显式传入 input.password > settings.regPassword(用户可在设置里改)> 内置默认强密码。
|
||||||
|
// 开源版这里仅放一个占位默认口令;请在「设置 → 浏览器 → 注册密码」中填写你自己的强口令,
|
||||||
|
// 或在真实(被 .gitignore 忽略的)settings.json 里配置 regPassword。
|
||||||
|
export const DEFAULT_REG_PASSWORD = 'ChangeMe#2026Pw!';
|
||||||
|
|
||||||
|
export function resolveRegPassword(input = {}, settings = {}) {
|
||||||
|
const fromInput = input && typeof input.password === 'string' ? input.password.trim() : '';
|
||||||
|
const fromSettings = settings && typeof settings.regPassword === 'string' ? settings.regPassword.trim() : '';
|
||||||
|
return fromInput || fromSettings || DEFAULT_REG_PASSWORD;
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// 协议注册 wrapper —— 复用 get-rt.js 的无密码 OAuth 协议流程(步骤1~6),
|
||||||
|
// 但 OTP 不再从 Outlook 读,而是经 Flask /api/reg/inbox 从「邮箱池」读取(IMAP / 取件API)。
|
||||||
|
// 输入(stdin JSON):{ email, settings, proxy, otpBase }
|
||||||
|
// 输出:
|
||||||
|
// - 过程日志:逐行写 stderr,每行 JSON { level, msg, account }
|
||||||
|
// - 最终结果:stdout 一段 JSON(getRefreshToken 返回结构)
|
||||||
|
// 成功 { ok:true, refreshToken, clientId, accessToken, idToken }
|
||||||
|
// 失败 { ok:false, banned?, reason?, error }
|
||||||
|
import { getRefreshToken } from './get-rt.js';
|
||||||
|
import { resolveRegPassword } from './reg_password.mjs';
|
||||||
|
|
||||||
|
// get-rt.js / plus-mailbox.js 里的 console.* 默认写 stdout,会污染结果 JSON,统一改到 stderr。
|
||||||
|
const _errWrite = (...a) => { try { process.stderr.write(a.map(String).join(' ') + '\n'); } catch { /* ignore */ } };
|
||||||
|
console.log = _errWrite;
|
||||||
|
console.info = _errWrite;
|
||||||
|
console.warn = _errWrite;
|
||||||
|
|
||||||
|
function readStdin() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let buf = '';
|
||||||
|
process.stdin.setEncoding('utf8');
|
||||||
|
process.stdin.on('data', (c) => { buf += c; });
|
||||||
|
process.stdin.on('end', () => resolve(buf));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitLog(level, msg, account) {
|
||||||
|
try { process.stderr.write(JSON.stringify({ level, msg: String(msg), account: account || '' }) + '\n'); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
let email = '';
|
||||||
|
try {
|
||||||
|
const input = JSON.parse((await readStdin()) || '{}');
|
||||||
|
email = input.email || '';
|
||||||
|
const settings = input.settings || {};
|
||||||
|
const proxy = input.proxy || null;
|
||||||
|
const otpBase = String(input.otpBase || 'http://127.0.0.1:5088').replace(/\/$/, '');
|
||||||
|
if (!email) throw new Error('缺少邮箱');
|
||||||
|
|
||||||
|
// 从邮箱池(经 Flask)读取收件箱,形状与 plus-mailbox 一致:
|
||||||
|
// { messages: [{ from_addr, subject, body, received_at, extracted_code }] }
|
||||||
|
const fetchInbox = async (account /* , limit */) => {
|
||||||
|
const addr = (account && account.email) || email;
|
||||||
|
const url = `${otpBase}/api/reg/inbox?email=${encodeURIComponent(addr)}`;
|
||||||
|
const res = await fetch(url, { method: 'GET' });
|
||||||
|
if (!res.ok) throw new Error('读取邮箱失败 HTTP ' + res.status);
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
return { messages: (data && data.messages) || [] };
|
||||||
|
};
|
||||||
|
|
||||||
|
const password = resolveRegPassword(input, settings);
|
||||||
|
const result = await getRefreshToken({ email }, {
|
||||||
|
settings,
|
||||||
|
proxy,
|
||||||
|
register: true,
|
||||||
|
password,
|
||||||
|
log: (level, msg, acc) => emitLog(level, msg, acc || email),
|
||||||
|
fetchInbox,
|
||||||
|
});
|
||||||
|
// getRefreshToken 成功仅返回 {refreshToken,...},补 ok:true 供上游判断,并回传密码用于落库/导出账密
|
||||||
|
if (result && result.refreshToken) { result.ok = true; result.password = password; }
|
||||||
|
process.stdout.write(JSON.stringify(result));
|
||||||
|
} catch (e) {
|
||||||
|
emitLog('error', (e && e.message) || String(e), email);
|
||||||
|
process.stdout.write(JSON.stringify({ ok: false, banned: false, error: (e && e.message) || String(e) }));
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// 拿 RT wrapper —— 由 Flask /api/plus/get-rt 编排逐个账号通过 `node rt_runner.mjs` 调用。
|
||||||
|
// 输入:stdin 一段 JSON { account:{email,password,clientId,refreshToken}, settings, proxy }
|
||||||
|
// 输出:
|
||||||
|
// - 过程日志:逐行写 stderr,每行是 JSON { level, msg, account }(供 Flask 汇集/落日志)
|
||||||
|
// - 最终结果:stdout 一段 JSON(getRefreshToken 的返回结构)
|
||||||
|
// 成功 { ok:true, refreshToken, clientId, accessToken, idToken }
|
||||||
|
// 封号 { ok:false, banned:true, reason, error }
|
||||||
|
// 失败 { ok:false, banned:false, error }
|
||||||
|
// 复用 auto 项目原封不动的 get-rt.js(getRefreshToken)。
|
||||||
|
import { getRefreshToken } from './get-rt.js';
|
||||||
|
|
||||||
|
// 复用脚本内部的 console.log/info/warn(如 plus-mailbox 的诊断行)默认写 stdout,
|
||||||
|
// 会污染我们最终的结果 JSON。统一改写到 stderr,保证 stdout 只有结果 JSON。
|
||||||
|
const _errWrite = (...a) => { try { process.stderr.write(a.map(String).join(' ') + '\n'); } catch { /* ignore */ } };
|
||||||
|
console.log = _errWrite;
|
||||||
|
console.info = _errWrite;
|
||||||
|
console.warn = _errWrite;
|
||||||
|
|
||||||
|
function readStdin() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let buf = '';
|
||||||
|
process.stdin.setEncoding('utf8');
|
||||||
|
process.stdin.on('data', (c) => { buf += c; });
|
||||||
|
process.stdin.on('end', () => resolve(buf));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitLog(level, msg, account) {
|
||||||
|
try { process.stderr.write(JSON.stringify({ level, msg: String(msg), account: account || '' }) + '\n'); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
let account = {};
|
||||||
|
try {
|
||||||
|
const raw = await readStdin();
|
||||||
|
const input = JSON.parse(raw || '{}');
|
||||||
|
account = input.account || {};
|
||||||
|
const settings = input.settings || {};
|
||||||
|
const proxy = input.proxy || null;
|
||||||
|
const result = await getRefreshToken(account, {
|
||||||
|
settings,
|
||||||
|
proxy,
|
||||||
|
log: (level, msg, acc) => emitLog(level, msg, acc || account.email),
|
||||||
|
});
|
||||||
|
process.stdout.write(JSON.stringify(result));
|
||||||
|
} catch (e) {
|
||||||
|
emitLog('error', (e && e.message) || String(e), account.email);
|
||||||
|
process.stdout.write(JSON.stringify({ ok: false, banned: false, error: (e && e.message) || String(e) }));
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
// 接码 / SMS 验证码服务 —— smsbower.app API 客户端(零依赖,使用全局 fetch)
|
||||||
|
// 协议:经典 sms-activate 风格,所有请求为 GET https://smsbower.page/stubs/handler_api.php?api_key=..&action=..
|
||||||
|
// 响应为纯文本(冒号分隔),如 ACCESS_NUMBER:<activationId>:<phone>、STATUS_OK:<code>
|
||||||
|
//
|
||||||
|
// 与 smscode.js 接口对齐(可作为调度器的替换供应商):
|
||||||
|
// acquireNumber(cfg, { log }) → { orderId, phone, order }
|
||||||
|
// pollOtp(cfg, id, { timeoutMs, intervalMs, log }) → { code, message, order }
|
||||||
|
// finishOrder(cfg, id) → setStatus status=6(完成)
|
||||||
|
// cancelOrder(cfg, id) → setStatus status=8(取消,2 分钟内不可取消时静默处理)
|
||||||
|
// getBalance(cfg) → 余额数字
|
||||||
|
//
|
||||||
|
// 配置字段:apiKey、baseUrl(默认 .page 地址)、country(默认 22=印度)、
|
||||||
|
// service(默认 "dr"=OpenAI/ChatGPT)、maxPrice、pollTimeout
|
||||||
|
|
||||||
|
const DEFAULT_BASE = 'https://smsbower.page/stubs/handler_api.php';
|
||||||
|
const DEFAULT_COUNTRY = 22; // 印度
|
||||||
|
const DEFAULT_SERVICE = 'dr'; // OpenAI / ChatGPT
|
||||||
|
|
||||||
|
function normBase(baseUrl) {
|
||||||
|
return String(baseUrl || DEFAULT_BASE).trim().replace(/\/+$/, '') || DEFAULT_BASE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统一请求:拼接 api_key + action + 参数,返回纯文本响应(已 trim)
|
||||||
|
async function request(cfg, action, params = {}, { timeoutMs = 30000 } = {}) {
|
||||||
|
const apiKey = String((cfg && cfg.apiKey) || '').trim();
|
||||||
|
if (!apiKey) {
|
||||||
|
const e = new Error('未配置 smsbower API Key(系统设置 → 接码配置)');
|
||||||
|
e.code = 'NO_API_KEY';
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
const q = new URLSearchParams({ api_key: apiKey, action });
|
||||||
|
for (const [k, v] of Object.entries(params)) {
|
||||||
|
if (v !== undefined && v !== null && v !== '') q.set(k, String(v));
|
||||||
|
}
|
||||||
|
const url = `${normBase(cfg && cfg.baseUrl)}?${q.toString()}`;
|
||||||
|
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await fetch(url, { method: 'GET', signal: ctrl.signal });
|
||||||
|
} catch (err) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
const e = new Error(err.name === 'AbortError' ? `接码请求超时(action=${action})` : `接码请求失败:${err.message}`);
|
||||||
|
e.code = 'NETWORK';
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
clearTimeout(timer);
|
||||||
|
|
||||||
|
const text = String(await res.text().catch(() => '')).trim();
|
||||||
|
if (!res.ok) {
|
||||||
|
const e = new Error(`接码接口错误:HTTP_${res.status}${text ? ' · ' + text.slice(0, 200) : ''}`);
|
||||||
|
e.code = `HTTP_${res.status}`;
|
||||||
|
e.status = res.status;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通用错误码 → 带 code 的 Error(BAD_KEY / BAD_ACTION / BAD_SERVICE / NO_ACTIVATION 等)
|
||||||
|
function apiError(token, action) {
|
||||||
|
const map = {
|
||||||
|
BAD_KEY: 'API Key 无效',
|
||||||
|
BAD_ACTION: '接口 action 无效',
|
||||||
|
BAD_SERVICE: '服务代码无效',
|
||||||
|
NO_ACTIVATION: '激活订单不存在',
|
||||||
|
NO_BALANCE: '余额不足',
|
||||||
|
NO_NUMBERS: '当前无可用号码',
|
||||||
|
};
|
||||||
|
const e = new Error(`接码接口错误:${token}${map[token] ? ' · ' + map[token] : ''}(action=${action})`);
|
||||||
|
e.code = token;
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((r) => setTimeout(r, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 余额 ----------
|
||||||
|
// 返回余额数字。响应:ACCESS_BALANCE:<balance>
|
||||||
|
export async function getBalance(cfg) {
|
||||||
|
const text = await request(cfg, 'getBalance');
|
||||||
|
const m = /^ACCESS_BALANCE:(.+)$/.exec(text);
|
||||||
|
if (m) {
|
||||||
|
const n = Number(m[1]);
|
||||||
|
if (Number.isFinite(n)) return n;
|
||||||
|
}
|
||||||
|
throw apiError(text || 'EMPTY_RESPONSE', 'getBalance');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 拿号 ----------
|
||||||
|
// 返回 { orderId, phone, order },与 smscode.acquireNumber 一致(orderId = activationId)。
|
||||||
|
// NO_NUMBERS(无库存,可回退重试)→ e.code='NO_OFFER_AVAILABLE'(与 smscode 的库存错误标记一致),
|
||||||
|
// 并附 e.providerCode='NO_NUMBERS';NO_BALANCE / BAD_KEY / BAD_SERVICE 为硬错误,直接抛出。
|
||||||
|
export async function acquireNumber(cfg, { log } = {}) {
|
||||||
|
const say = (level, msg) => { if (typeof log === 'function') log(level, msg); };
|
||||||
|
const country = cfg.country !== undefined && cfg.country !== null && cfg.country !== '' ? cfg.country : DEFAULT_COUNTRY;
|
||||||
|
const service = String(cfg.service || DEFAULT_SERVICE);
|
||||||
|
|
||||||
|
const params = { service, country };
|
||||||
|
if (cfg.maxPrice) params.maxPrice = cfg.maxPrice;
|
||||||
|
if (cfg.minPrice) params.minPrice = cfg.minPrice;
|
||||||
|
|
||||||
|
say('info', `向 smsbower 请求号码(service=${service} country=${country}${cfg.maxPrice ? ' maxPrice=' + cfg.maxPrice : ''})…`);
|
||||||
|
const text = await request(cfg, 'getNumber', params);
|
||||||
|
|
||||||
|
const m = /^ACCESS_NUMBER:([^:]+):(.+)$/.exec(text);
|
||||||
|
if (m) {
|
||||||
|
const orderId = m[1];
|
||||||
|
const phone = m[2];
|
||||||
|
const order = { id: orderId, phone_number: phone, status: 'PENDING', raw: text };
|
||||||
|
say('ok', `已获取号码:${phone}(订单 ${orderId})`);
|
||||||
|
return { orderId, phone, order };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text === 'NO_NUMBERS' || text.startsWith('NO_NUMBERS')) {
|
||||||
|
// 无库存:标记为可回退的库存错误(与 smscode 的 isStockError 判定一致)
|
||||||
|
const e = new Error(`当前无可用号码(service=${service} country=${country})`);
|
||||||
|
e.code = 'NO_OFFER_AVAILABLE';
|
||||||
|
e.providerCode = 'NO_NUMBERS';
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
throw apiError(text || 'EMPTY_RESPONSE', 'getNumber');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 查询状态 ----------
|
||||||
|
// 返回 { status, code }。status ∈ WAIT_CODE / WAIT_RETRY / CANCEL / OK
|
||||||
|
export async function getStatus(cfg, id) {
|
||||||
|
const text = await request(cfg, 'getStatus', { id });
|
||||||
|
if (text === 'STATUS_WAIT_CODE') return { status: 'WAIT_CODE', code: '' };
|
||||||
|
let m = /^STATUS_WAIT_RETRY:(.*)$/.exec(text);
|
||||||
|
if (m) return { status: 'WAIT_RETRY', code: m[1] };
|
||||||
|
if (text === 'STATUS_CANCEL') return { status: 'CANCEL', code: '' };
|
||||||
|
m = /^STATUS_OK:(.+)$/.exec(text);
|
||||||
|
if (m) return { status: 'OK', code: m[1] };
|
||||||
|
throw apiError(text || 'EMPTY_RESPONSE', 'getStatus');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 轮询等待验证码 ----------
|
||||||
|
// 直到 STATUS_OK 拿到 code,或订单取消/超时抛错。返回 { code, message, order },与 smscode.pollOtp 一致。
|
||||||
|
export async function pollOtp(cfg, id, { timeoutMs = 180000, intervalMs = 4000, log } = {}) {
|
||||||
|
const start = Date.now();
|
||||||
|
const say = (level, msg) => { if (typeof log === 'function') log(level, msg); };
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
let st;
|
||||||
|
try {
|
||||||
|
st = await getStatus(cfg, id);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code === 'NO_ACTIVATION' || e.code === 'BAD_KEY') throw e; // 不可恢复
|
||||||
|
say('warn', `查询订单失败(将重试):${e.message}`);
|
||||||
|
await sleep(intervalMs);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (st.status === 'OK' && st.code) {
|
||||||
|
say('ok', `已收到验证码:${st.code}`);
|
||||||
|
const order = { id, status: 'OTP_RECEIVED', otp_code: st.code };
|
||||||
|
return { code: st.code, message: '', order };
|
||||||
|
}
|
||||||
|
if (st.status === 'CANCEL') {
|
||||||
|
const e = new Error('订单已取消,未收到验证码');
|
||||||
|
e.code = 'CANCELED';
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
// WAIT_CODE / WAIT_RETRY:继续等待
|
||||||
|
say('info', `等待验证码…(订单 ${id} 状态 ${st.status}${st.status === 'WAIT_RETRY' && st.code ? ',上一条验证码 ' + st.code : ''})`);
|
||||||
|
await sleep(intervalMs);
|
||||||
|
}
|
||||||
|
const e = new Error(`等待验证码超时(${Math.round(timeoutMs / 1000)}s)`);
|
||||||
|
e.code = 'OTP_TIMEOUT';
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 状态变更 ----------
|
||||||
|
// setStatus:1=已就绪 3=请求下一条验证码 6=完成 8=取消
|
||||||
|
async function setStatus(cfg, id, status) {
|
||||||
|
return request(cfg, 'setStatus', { id, status });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 完成订单(收到验证码后调用,setStatus=6)。响应:ACCESS_ACTIVATION
|
||||||
|
export async function finishOrder(cfg, id) {
|
||||||
|
const text = await setStatus(cfg, id, 6);
|
||||||
|
if (text === 'ACCESS_ACTIVATION') return { id, status: 'FINISHED' };
|
||||||
|
throw apiError(text || 'EMPTY_RESPONSE', 'setStatus(6)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取消订单(setStatus=8)。响应:ACCESS_CANCEL。
|
||||||
|
// EARLY_CANCEL_DENIED(购买 2 分钟内不可取消)按非致命处理:记 warn 后正常返回,号码到期自动退款。
|
||||||
|
export async function cancelOrder(cfg, id, { log } = {}) {
|
||||||
|
const say = (level, msg) => { if (typeof log === 'function') log(level, msg); };
|
||||||
|
const text = await setStatus(cfg, id, 8);
|
||||||
|
if (text === 'ACCESS_CANCEL') return { id, status: 'CANCELED' };
|
||||||
|
if (text === 'EARLY_CANCEL_DENIED') {
|
||||||
|
say('warn', `订单 ${id} 购买未满 2 分钟,暂不可取消(到期后将自动退款)`);
|
||||||
|
return { id, status: 'CANCEL_PENDING', earlyCancelDenied: true };
|
||||||
|
}
|
||||||
|
throw apiError(text || 'EMPTY_RESPONSE', 'setStatus(8)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 请求下一条验证码(setStatus=3),对应 smscode.resendOrder。响应:ACCESS_RETRY_GET
|
||||||
|
export async function resendOrder(cfg, id) {
|
||||||
|
const text = await setStatus(cfg, id, 3);
|
||||||
|
if (text === 'ACCESS_RETRY_GET') return { id, status: 'WAIT_RETRY' };
|
||||||
|
throw apiError(text || 'EMPTY_RESPONSE', 'setStatus(3)');
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
// 接码 / SMS 验证码服务 —— smscode.gg API 客户端(零依赖,使用全局 fetch)
|
||||||
|
// 文档:https://smscode.gg/docs Base URL: https://api.smscode.gg/v1
|
||||||
|
// 鉴权:Authorization: Bearer <apiKey>
|
||||||
|
// 统一响应包络:{ success:true, data:{...} } / { success:false, error:{ code, message } }
|
||||||
|
//
|
||||||
|
// 订单生命周期:
|
||||||
|
// 1. createOrder → 下单拿号(product_id 精确档位 或 catalog_product_id 路由)
|
||||||
|
// 2. pollOtp → 轮询 GET /orders/{id} 直到 status=OTP_RECEIVED 拿到 otp_code
|
||||||
|
// 3. finishOrder → 收到验证码后标记完成(立即释放号码)
|
||||||
|
// 或 cancelOrder → 未收到验证码时取消(退款)
|
||||||
|
|
||||||
|
const DEFAULT_BASE = 'https://api.smscode.gg/v1';
|
||||||
|
|
||||||
|
function normBase(baseUrl) {
|
||||||
|
return String(baseUrl || DEFAULT_BASE).trim().replace(/\/+$/, '') || DEFAULT_BASE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统一请求:注入 Bearer、解析包络、处理 429/超时/HTTP 错误
|
||||||
|
async function request(cfg, method, pathAndQuery, { body, idempotencyKey, timeoutMs = 30000 } = {}) {
|
||||||
|
const base = normBase(cfg && cfg.baseUrl);
|
||||||
|
const apiKey = String((cfg && cfg.apiKey) || '').trim();
|
||||||
|
if (!apiKey) {
|
||||||
|
const e = new Error('未配置接码 API Key(系统设置 → 接码配置)');
|
||||||
|
e.code = 'NO_API_KEY';
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
const url = `${base}${pathAndQuery}`;
|
||||||
|
const headers = {
|
||||||
|
authorization: `Bearer ${apiKey}`,
|
||||||
|
accept: 'application/json',
|
||||||
|
};
|
||||||
|
if (body !== undefined) headers['content-type'] = 'application/json';
|
||||||
|
if (idempotencyKey) headers['idempotency-key'] = idempotencyKey;
|
||||||
|
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
signal: ctrl.signal,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
const e = new Error(err.name === 'AbortError' ? `接码请求超时(${method} ${pathAndQuery})` : `接码请求失败:${err.message}`);
|
||||||
|
e.code = 'NETWORK';
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
clearTimeout(timer);
|
||||||
|
|
||||||
|
if (res.status === 429) {
|
||||||
|
const retry = Number(res.headers.get('retry-after')) || 5;
|
||||||
|
const e = new Error(`接码接口限流(429),请 ${retry}s 后重试`);
|
||||||
|
e.code = 'RATE_LIMIT_EXCEEDED';
|
||||||
|
e.retryAfter = retry;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok || data.success === false) {
|
||||||
|
const err = (data && data.error) || {};
|
||||||
|
const e = new Error(`接码接口错误:${err.code || res.status}${err.message ? ' · ' + err.message : ''}`);
|
||||||
|
e.code = err.code || `HTTP_${res.status}`;
|
||||||
|
e.status = res.status;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 目录(catalog) ----------
|
||||||
|
export async function listCountries(cfg) {
|
||||||
|
return request(cfg, 'GET', '/catalog/countries');
|
||||||
|
}
|
||||||
|
export async function listServices(cfg, countryId) {
|
||||||
|
const q = countryId ? `?country_id=${encodeURIComponent(countryId)}` : '';
|
||||||
|
return request(cfg, 'GET', `/catalog/services${q}`);
|
||||||
|
}
|
||||||
|
export async function listProducts(cfg, { countryId, platformId, operatorId, limit = 50, page = 1 } = {}) {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
if (countryId) p.set('country_id', countryId);
|
||||||
|
if (platformId) p.set('platform_id', platformId);
|
||||||
|
if (operatorId) p.set('operator_id', operatorId);
|
||||||
|
p.set('limit', limit);
|
||||||
|
p.set('page', page);
|
||||||
|
return request(cfg, 'GET', `/catalog/products?${p.toString()}`);
|
||||||
|
}
|
||||||
|
export async function getBalance(cfg) {
|
||||||
|
return request(cfg, 'GET', '/balance');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 订单 ----------
|
||||||
|
// 下单拿号。优先使用 productId(精确档位),否则用 catalogProductId(路由下单)。
|
||||||
|
export async function createOrder(cfg, { productId, catalogProductId, operatorId, maxPrice, quantity = 1 } = {}) {
|
||||||
|
const body = { quantity };
|
||||||
|
if (productId) body.product_id = Number(productId);
|
||||||
|
else if (catalogProductId) {
|
||||||
|
body.catalog_product_id = Number(catalogProductId);
|
||||||
|
if (operatorId) body.operator_id = Number(operatorId);
|
||||||
|
if (maxPrice) body.max_price = /^\d+$/.test(String(maxPrice)) ? Number(maxPrice) : String(maxPrice);
|
||||||
|
} else {
|
||||||
|
const e = new Error('下单需提供 productId 或 catalogProductId');
|
||||||
|
e.code = 'VALIDATION_ERROR';
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
const idempotencyKey = `getrt-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
const data = await request(cfg, 'POST', '/orders/create', { body, idempotencyKey });
|
||||||
|
const order = (data.orders && data.orders[0]) || data;
|
||||||
|
if (!order || !order.id) {
|
||||||
|
const e = new Error('下单未返回有效订单');
|
||||||
|
e.code = 'NO_ORDER';
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return order; // { id, status, phone_number, ... }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOrder(cfg, id) {
|
||||||
|
return request(cfg, 'GET', `/orders/${encodeURIComponent(id)}`);
|
||||||
|
}
|
||||||
|
export async function finishOrder(cfg, id) {
|
||||||
|
return request(cfg, 'POST', '/orders/finish', { body: { id: Number(id) } });
|
||||||
|
}
|
||||||
|
export async function cancelOrder(cfg, id) {
|
||||||
|
return request(cfg, 'POST', '/orders/cancel', { body: { id: Number(id) } });
|
||||||
|
}
|
||||||
|
export async function resendOrder(cfg, id) {
|
||||||
|
return request(cfg, 'POST', '/orders/resend', { body: { id: Number(id) } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 轮询等待验证码:直到 status=OTP_RECEIVED 且拿到 otp_code,或超时。
|
||||||
|
// log(level,msg) 可选,用于向上层推送进度。
|
||||||
|
export async function pollOtp(cfg, id, { timeoutMs = 180000, intervalMs = 4000, log } = {}) {
|
||||||
|
const start = Date.now();
|
||||||
|
const say = (level, msg) => { if (typeof log === 'function') log(level, msg); };
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
let order;
|
||||||
|
try {
|
||||||
|
order = await getOrder(cfg, id);
|
||||||
|
} catch (e) {
|
||||||
|
say('warn', `查询订单失败(将重试):${e.message}`);
|
||||||
|
await sleep(intervalMs);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const status = String(order.status || '').toUpperCase();
|
||||||
|
if (order.otp_code) {
|
||||||
|
say('ok', `已收到验证码:${order.otp_code}`);
|
||||||
|
return { code: order.otp_code, message: order.otp_message || '', order };
|
||||||
|
}
|
||||||
|
if (status === 'CANCELED' || status === 'EXPIRED') {
|
||||||
|
const e = new Error(`订单已${status === 'CANCELED' ? '取消' : '过期'},未收到验证码`);
|
||||||
|
e.code = status;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
say('info', `等待验证码…(订单 ${id} 状态 ${status || '未知'})`);
|
||||||
|
await sleep(intervalMs);
|
||||||
|
}
|
||||||
|
const e = new Error(`等待验证码超时(${Math.round(timeoutMs / 1000)}s)`);
|
||||||
|
e.code = 'OTP_TIMEOUT';
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((r) => setTimeout(r, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 一站式:拿号 → 轮询验证码。返回 { orderId, phone, code }。
|
||||||
|
// 判断下单失败是否属于“无库存/无号码”类(可回退下一档位),而非鉴权/余额类(应立即抛出)。
|
||||||
|
function isStockError(e) {
|
||||||
|
const code = String((e && e.code) || '').toUpperCase();
|
||||||
|
if (['NO_OFFER_AVAILABLE', 'PROVIDER_ERROR', 'NO_ORDER'].includes(code)) return true;
|
||||||
|
const msg = String((e && e.message) || '').toLowerCase();
|
||||||
|
if ((e && e.status === 422) && /(no[_\s-]?number|no[_\s-]?offer|out.?of.?stock|no.?stock|stock|available|sold.?out)/.test(msg)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拉取 country+service 的产品档位,构造“有货 + 按价格升序”的候选下单顺序。
|
||||||
|
// 若配置了 productId 且该档位有货,则置于队首。返回 [{ productId, price, available }]。
|
||||||
|
export async function buildCandidates(cfg, { log } = {}) {
|
||||||
|
const say = (level, msg) => { if (typeof log === 'function') log(level, msg); };
|
||||||
|
const products = await listProducts(cfg, { countryId: cfg.countryId, platformId: cfg.serviceId, limit: 10000, page: 1 });
|
||||||
|
const list = Array.isArray(products) ? products : [];
|
||||||
|
const inStock = list
|
||||||
|
.filter((p) => p && p.active !== false && (Number(p.available) || 0) > 0)
|
||||||
|
.map((p) => ({ productId: p.id, price: Number(p.price) || 0, available: Number(p.available) || 0 }))
|
||||||
|
.sort((a, b) => a.price - b.price);
|
||||||
|
const configured = Number(cfg.productId) || 0;
|
||||||
|
let ordered = inStock;
|
||||||
|
if (configured) {
|
||||||
|
const hit = inStock.find((c) => Number(c.productId) === configured);
|
||||||
|
if (hit) ordered = [hit, ...inStock.filter((c) => Number(c.productId) !== configured)];
|
||||||
|
else say('warn', `配置的 productId=${configured} 当前无货,改用有货档位(按价格升序)`);
|
||||||
|
}
|
||||||
|
say('info', `候选档位(有货,共 ${ordered.length}):${ordered.slice(0, 8).map((c) => `#${c.productId}@${c.price}(${c.available})`).join(' → ')}${ordered.length > 8 ? ' …' : ''}`);
|
||||||
|
return ordered;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拿号(带无货自动回退)。返回 { orderId, phone, order },与旧签名一致。
|
||||||
|
// 调用方应在使用完验证码后调用 finishOrder;失败时调用 cancelOrder(若 can_cancel)。
|
||||||
|
export async function acquireNumber(cfg, { log } = {}) {
|
||||||
|
const say = (level, msg) => { if (typeof log === 'function') log(level, msg); };
|
||||||
|
|
||||||
|
// 未配置 country+service:维持旧行为(单一 productId / catalogProductId)。
|
||||||
|
if (!cfg.countryId || !cfg.serviceId) {
|
||||||
|
const order = await createOrder(cfg, {
|
||||||
|
productId: cfg.productId,
|
||||||
|
catalogProductId: cfg.catalogProductId,
|
||||||
|
operatorId: cfg.operatorId,
|
||||||
|
maxPrice: cfg.maxPrice,
|
||||||
|
});
|
||||||
|
say('ok', `已获取号码:${order.phone_number}(订单 ${order.id})`);
|
||||||
|
return { orderId: order.id, phone: order.phone_number, order };
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = await buildCandidates(cfg, { log });
|
||||||
|
if (!candidates.length) {
|
||||||
|
const e = new Error(`所有档位均无库存/下单失败(country=${cfg.countryId} service=${cfg.serviceId})`);
|
||||||
|
e.code = 'NO_OFFER_AVAILABLE';
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < candidates.length; i++) {
|
||||||
|
const c = candidates[i];
|
||||||
|
try {
|
||||||
|
say('info', `尝试下单档位 #${c.productId}(价格 ${c.price} IDR,库存 ${c.available})`);
|
||||||
|
const order = await createOrder(cfg, { productId: c.productId });
|
||||||
|
say('ok', `已获取号码:${order.phone_number}(订单 ${order.id},档位 #${c.productId} 价格 ${c.price} IDR)`);
|
||||||
|
return { orderId: order.id, phone: order.phone_number, order };
|
||||||
|
} catch (e) {
|
||||||
|
if (isStockError(e)) {
|
||||||
|
say('warn', `档位 #${c.productId} 无库存/下单失败(${e.code || e.message}),尝试下一档位…`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw e; // 鉴权/余额等非库存错误:立即抛出
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const e = new Error(`所有档位均无库存/下单失败(country=${cfg.countryId} service=${cfg.serviceId})`);
|
||||||
|
e.code = 'NO_OFFER_AVAILABLE';
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
+2683
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,62 @@
|
|||||||
|
"""开源版 —— GPT 管理(account-manager)精简 Flask 应用。
|
||||||
|
|
||||||
|
本应用只真实运行「GPT管理」这一开源功能(account_manager 蓝图,页面 /account-manager/、
|
||||||
|
API 前缀 /api/plus 与 /api/probe)。其余功能(批量直开 / 协议支付 / 提取链接 / 直卡直开)
|
||||||
|
不放任何代码,改为在右侧内容区用 iframe 内嵌对应线上服务(见 /embed/<target>)。
|
||||||
|
|
||||||
|
本地预览默认端口 5090,避免和可能在跑的 5088 冲突。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
try:
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
from flask import Flask, redirect, render_template, abort
|
||||||
|
|
||||||
|
app = Flask(__name__, template_folder="templates", static_folder="static")
|
||||||
|
|
||||||
|
# 注册子进程回读验证码用的内部地址默认指向本应用端口(仅注册功能相关)。
|
||||||
|
os.environ.setdefault("ACCOUNT_MGR_BASE", "http://127.0.0.1:5090")
|
||||||
|
|
||||||
|
# ---- 外链内嵌配置:菜单 key -> (标题, 目标 URL) ----
|
||||||
|
# 服务器不改动;这里只是让本地 shell 用 iframe 指向这些线上服务。
|
||||||
|
EMBEDS = {
|
||||||
|
"card-link": ("直卡直开", "https://card.153.ink/card-link/"),
|
||||||
|
"card-flow": ("批量直开(试用3天)", "https://reg.gpt2api.com/card-flow/?embed=1"),
|
||||||
|
"protocol": ("协议支付(试用3天)", "https://reg.gpt2api.com/protocol/?embed=1"),
|
||||||
|
"pay": ("提取链接(试用3天)", "https://reg.gpt2api.com/pay/?embed=1"),
|
||||||
|
}
|
||||||
|
|
||||||
|
GITHUB_URL = "https://github.com/432539/gpt"
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
def home():
|
||||||
|
return redirect("/account-manager/")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/embed/<target>")
|
||||||
|
def embed(target: str):
|
||||||
|
item = EMBEDS.get(target)
|
||||||
|
if not item:
|
||||||
|
abort(404)
|
||||||
|
title, url = item
|
||||||
|
return render_template("embed.html", active=target, embed_title=title, embed_url=url)
|
||||||
|
|
||||||
|
|
||||||
|
# --- 注册「GPT管理」(account-manager) 蓝图(唯一开源的真实功能)---
|
||||||
|
try:
|
||||||
|
from account_manager import bp as account_manager_bp
|
||||||
|
app.register_blueprint(account_manager_bp)
|
||||||
|
app.logger.info("account_manager blueprint registered (/account-manager/, /api/plus*, /api/probe*)")
|
||||||
|
except Exception as exc: # 保持主应用可用,即便该蓝图导入失败
|
||||||
|
app.logger.warning("account_manager blueprint registration failed: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(host=os.getenv("HOST", "127.0.0.1"), port=int(os.getenv("PORT", "5090")), debug=False, threaded=True)
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"""通过 access_token 实时调用 ChatGPT 后端 accounts/check 查询真实套餐级别。
|
||||||
|
|
||||||
|
用 curl_cffi(firefox144 指纹)绕过 Cloudflare,读取 plan_type / has_active_subscription。
|
||||||
|
作为独立脚本,由主程序用 .venv 的 python 以子进程方式调用(stdin 传 JSON,stdout 回 JSON),
|
||||||
|
与 card_bind_session.py 的调用方式一致(主程序 C:\\Python311 的 curl_cffi 版本过旧,不支持 firefox144)。
|
||||||
|
|
||||||
|
stdin : {"access_token": "...", "account_id": "(可选)", "proxy": "(可选, DIRECT 或 http/socks URL)"}
|
||||||
|
stdout : {"ok": bool, "httpStatus": int, "plan": "free|plus|pro|...", "active": bool,
|
||||||
|
"subPlan": "", "accountId": "", "error": ""}
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
import base64
|
||||||
|
|
||||||
|
from curl_cffi.requests import Session
|
||||||
|
|
||||||
|
CHECK_URL = "https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27"
|
||||||
|
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:144.0) Gecko/20100101 Firefox/144.0"
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_jwt(token: str) -> dict:
|
||||||
|
try:
|
||||||
|
seg = token.split(".")[1].replace("-", "+").replace("_", "/")
|
||||||
|
seg += "=" * ((4 - len(seg) % 4) % 4)
|
||||||
|
data = json.loads(base64.b64decode(seg).decode("utf-8", "replace"))
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _account_id_from_token(token: str) -> str:
|
||||||
|
auth = (_decode_jwt(token) or {}).get("https://api.openai.com/auth") or {}
|
||||||
|
return str(auth.get("chatgpt_account_id") or "")
|
||||||
|
|
||||||
|
|
||||||
|
def _walk_plan(data):
|
||||||
|
"""深扫响应,取 plan_type / has_active_subscription / subscription_plan(对齐 probe.js)。"""
|
||||||
|
plan = ""
|
||||||
|
active = False
|
||||||
|
sub = ""
|
||||||
|
stack = [data]
|
||||||
|
while stack:
|
||||||
|
cur = stack.pop()
|
||||||
|
if isinstance(cur, dict):
|
||||||
|
for k, v in cur.items():
|
||||||
|
kl = str(k).lower()
|
||||||
|
if kl == "plan_type" and isinstance(v, str) and not plan:
|
||||||
|
plan = v
|
||||||
|
if kl == "has_active_subscription" and v is True:
|
||||||
|
active = True
|
||||||
|
if kl == "subscription_plan" and isinstance(v, str) and not sub:
|
||||||
|
sub = v
|
||||||
|
stack.append(v)
|
||||||
|
elif isinstance(cur, list):
|
||||||
|
stack.extend(cur)
|
||||||
|
return plan, active, sub
|
||||||
|
|
||||||
|
|
||||||
|
def _account_id_from_payload(data) -> str:
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return ""
|
||||||
|
ordering = data.get("account_ordering")
|
||||||
|
if isinstance(ordering, list):
|
||||||
|
for x in ordering:
|
||||||
|
if x and x != "default":
|
||||||
|
return str(x)
|
||||||
|
accounts = data.get("accounts")
|
||||||
|
if isinstance(accounts, dict):
|
||||||
|
for k in accounts.keys():
|
||||||
|
if k and k != "default":
|
||||||
|
return str(k)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
try:
|
||||||
|
payload = json.loads(sys.stdin.read() or "{}")
|
||||||
|
except Exception:
|
||||||
|
payload = {}
|
||||||
|
token = str(payload.get("access_token") or "").strip()
|
||||||
|
account_id = str(payload.get("account_id") or "").strip() or _account_id_from_token(token)
|
||||||
|
proxy = str(payload.get("proxy") or "").strip()
|
||||||
|
if not token:
|
||||||
|
print(json.dumps({"ok": False, "httpStatus": 0, "error": "missing_access_token"}))
|
||||||
|
return
|
||||||
|
|
||||||
|
http = Session(impersonate="firefox144")
|
||||||
|
http.trust_env = False
|
||||||
|
if proxy and proxy.upper() != "DIRECT":
|
||||||
|
http.proxies = {"http": proxy, "https": proxy}
|
||||||
|
|
||||||
|
device_id = str(uuid.uuid4())
|
||||||
|
headers = {
|
||||||
|
"Authorization": "Bearer " + token,
|
||||||
|
"Accept": "application/json",
|
||||||
|
"User-Agent": UA,
|
||||||
|
"Origin": "https://chatgpt.com",
|
||||||
|
"Referer": "https://chatgpt.com/",
|
||||||
|
"OAI-Device-Id": device_id,
|
||||||
|
"oai-device-id": device_id,
|
||||||
|
}
|
||||||
|
if account_id:
|
||||||
|
headers["ChatGPT-Account-Id"] = account_id
|
||||||
|
headers["ChatGPT-Account-ID"] = account_id
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 暖身,拿 Cloudflare cookie
|
||||||
|
try:
|
||||||
|
http.get(
|
||||||
|
"https://chatgpt.com/",
|
||||||
|
headers={"User-Agent": UA, "Accept-Language": "en-US,en;q=0.9"},
|
||||||
|
timeout=25,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
r = http.get(CHECK_URL, headers=headers, timeout=30)
|
||||||
|
status = r.status_code
|
||||||
|
if status == 200:
|
||||||
|
data = r.json()
|
||||||
|
plan, active, sub = _walk_plan(data)
|
||||||
|
print(json.dumps({
|
||||||
|
"ok": True,
|
||||||
|
"httpStatus": status,
|
||||||
|
"plan": plan or "",
|
||||||
|
"active": bool(active),
|
||||||
|
"subPlan": sub or "",
|
||||||
|
"accountId": account_id or _account_id_from_payload(data),
|
||||||
|
}))
|
||||||
|
else:
|
||||||
|
body = (r.text or "")[:300]
|
||||||
|
print(json.dumps({"ok": False, "httpStatus": status, "error": body or ("HTTP " + str(status))}))
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
print(json.dumps({"ok": False, "httpStatus": 0, "error": str(exc)}))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
Flask>=3.0,<4
|
||||||
|
httpx>=0.27,<1
|
||||||
|
curl_cffi>=0.11,<1
|
||||||
|
python-dotenv>=1.0,<2
|
||||||
@@ -0,0 +1,458 @@
|
|||||||
|
@charset "UTF-8";
|
||||||
|
|
||||||
|
/* 账号管理 —— 复用 app-shell.css 的蓝色企业风令牌,仅补充本页所需组件 */
|
||||||
|
|
||||||
|
/* ---------- 统计卡片 ---------- */
|
||||||
|
.am-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.am-stat-card {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 18px 20px;
|
||||||
|
}
|
||||||
|
.am-stat-num { font-size: 28px; color: var(--blue-700); line-height: 1.1; }
|
||||||
|
.am-stat-label { margin-top: 6px; color: var(--ink-500); font-size: 13px; }
|
||||||
|
|
||||||
|
/* ---------- 工具栏 ---------- */
|
||||||
|
.am-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.am-search {
|
||||||
|
position: relative;
|
||||||
|
flex: 0 0 260px;
|
||||||
|
max-width: 260px;
|
||||||
|
}
|
||||||
|
.am-search-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 10px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: .6;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.am-search input { padding-left: 30px; }
|
||||||
|
.am-filter { width: 130px; flex: 0 0 130px; }
|
||||||
|
.am-toolbar-spacer { flex: 1 1 auto; }
|
||||||
|
.am-toolbar button { flex: 0 0 auto; }
|
||||||
|
|
||||||
|
/* 协议注册(占位·绿色按钮) */
|
||||||
|
.am-btn-green { background: #16a34a; border-color: #16a34a; color: #fff; }
|
||||||
|
.am-btn-green:hover:not(:disabled) { background: #15803d; border-color: #15803d; color: #fff; }
|
||||||
|
|
||||||
|
/* 工具栏下拉菜单(批量操作 / 删除) */
|
||||||
|
.am-dropdown { position: relative; display: inline-flex; flex: 0 0 auto; }
|
||||||
|
.am-dropdown-toggle .am-caret { font-size: 11px; opacity: .85; }
|
||||||
|
.am-dropdown-menu {
|
||||||
|
position: absolute; top: calc(100% + 6px); right: 0; z-index: 45;
|
||||||
|
min-width: 168px; padding: 6px;
|
||||||
|
background: var(--card); border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-sm); box-shadow: var(--shadow-pop);
|
||||||
|
display: none; flex-direction: column; gap: 2px;
|
||||||
|
}
|
||||||
|
.am-dropdown.open .am-dropdown-menu { display: flex; }
|
||||||
|
.am-dropdown-menu button {
|
||||||
|
width: 100%; height: 34px; justify-content: flex-start;
|
||||||
|
background: #fff; color: var(--ink-700); border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius-sm); font-size: 13px;
|
||||||
|
}
|
||||||
|
.am-dropdown-menu button:hover:not(:disabled) { background: var(--blue-50); color: var(--blue-700); border-color: var(--blue-100); }
|
||||||
|
.am-dropdown-menu button.danger-item { color: var(--danger); }
|
||||||
|
.am-dropdown-menu button.danger-item:hover:not(:disabled) { background: var(--danger-soft); color: var(--danger); border-color: #fecaca; }
|
||||||
|
|
||||||
|
/* 批量检测进度文案 */
|
||||||
|
.am-detect-status { font-size: 13px; color: var(--blue-700); background: var(--blue-50); border: 1px solid var(--blue-100); padding: 4px 10px; border-radius: var(--radius-sm); }
|
||||||
|
.am-detect-status[hidden] { display: none; }
|
||||||
|
|
||||||
|
/* ---------- 表格 ---------- */
|
||||||
|
.am-table-wrap { border: 1px solid var(--line); border-radius: var(--radius-sm); overflow: hidden; }
|
||||||
|
.am-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||||
|
.am-table thead th {
|
||||||
|
background: var(--blue-50);
|
||||||
|
color: var(--ink-700);
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 400;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.am-table tbody td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--line-soft);
|
||||||
|
color: var(--ink-900);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.am-table tbody tr:hover { background: var(--blue-50); }
|
||||||
|
.am-table .col-check { width: 40px; text-align: center; }
|
||||||
|
.am-table .col-idx { width: 52px; color: var(--ink-500); }
|
||||||
|
.am-table .col-ops { width: 150px; }
|
||||||
|
|
||||||
|
.email-cell { display: flex; align-items: center; gap: 6px; }
|
||||||
|
.email-cell .text-mono { font-family: var(--mono); }
|
||||||
|
.pw-cell { display: flex; align-items: center; gap: 6px; }
|
||||||
|
.pw-mask { letter-spacing: 2px; color: var(--ink-500); }
|
||||||
|
.pw-text { font-family: var(--mono); }
|
||||||
|
.icon-btn {
|
||||||
|
height: 24px; width: 24px; padding: 0;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: #fff; color: var(--ink-500);
|
||||||
|
cursor: pointer; font-size: 13px; line-height: 1;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
/* 悬停对比修复:全局 button:hover:not(:disabled) 会把背景变成深蓝(blue-700),
|
||||||
|
其特异度(0,2,1)高于原来的 .icon-btn:hover(0,2,0),导致深底+蓝字不可读。
|
||||||
|
这里用 button.<class>:hover:not(:disabled)(0,3,1) 提高特异度,并同时固定浅底+深字。 */
|
||||||
|
button.icon-btn:hover:not(:disabled) { background: var(--blue-50); color: var(--blue-700); border-color: var(--blue-100); }
|
||||||
|
|
||||||
|
.row-ops { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.op-link {
|
||||||
|
height: 28px; padding: 0 10px;
|
||||||
|
border: 1px solid var(--line); background: #fff; color: var(--ink-700);
|
||||||
|
border-radius: var(--radius-sm); cursor: pointer; font-size: 13px;
|
||||||
|
}
|
||||||
|
button.op-link:hover:not(:disabled) { background: var(--blue-50); color: var(--blue-700); border-color: var(--blue-100); }
|
||||||
|
.op-link.danger { color: var(--danger); border-color: #fecaca; }
|
||||||
|
button.op-link.danger:hover:not(:disabled) { background: var(--danger-soft); color: var(--danger); border-color: #fecaca; }
|
||||||
|
.op-link.detect { color: var(--blue-700); border-color: var(--blue-100); }
|
||||||
|
button.op-link.detect:hover:not(:disabled) { background: var(--blue-50); color: var(--blue-700); border-color: var(--blue-100); }
|
||||||
|
|
||||||
|
/* 状态药丸 */
|
||||||
|
.sk-tag {
|
||||||
|
display: inline-flex; align-items: center; gap: 4px;
|
||||||
|
padding: 2px 9px; border-radius: 4px; font-size: 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.sk-tag.has { background: var(--ok-soft); color: #047857; border-color: #a7f3d0; cursor: pointer; }
|
||||||
|
.sk-tag.none { background: #f1f5f9; color: var(--ink-500); }
|
||||||
|
.sk-tag.banned { background: var(--danger-soft); color: #991b1b; border-color: #fecaca; }
|
||||||
|
.code-chip {
|
||||||
|
display: inline-flex; align-items: center; gap: 4px;
|
||||||
|
padding: 2px 8px; border-radius: 4px; font-size: 12px;
|
||||||
|
background: var(--blue-50); color: var(--blue-700); border: 1px solid var(--blue-100);
|
||||||
|
cursor: pointer; font-family: var(--mono);
|
||||||
|
}
|
||||||
|
.muted { color: var(--ink-400); }
|
||||||
|
|
||||||
|
/* ---------- 分页 ---------- */
|
||||||
|
.am-pager {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
margin-top: 14px; gap: 12px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.am-pager-left, .am-pager-right { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.am-pager-label { color: var(--ink-500); font-size: 13px; }
|
||||||
|
.am-pagesize { width: 84px; height: 32px; }
|
||||||
|
.am-pager-info { color: var(--ink-500); font-size: 13px; }
|
||||||
|
|
||||||
|
/* ---------- 导入弹窗 ---------- */
|
||||||
|
.am-modal {
|
||||||
|
position: fixed; inset: 0; z-index: 60;
|
||||||
|
background: rgba(15, 23, 42, .45);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.am-modal[hidden] { display: none; }
|
||||||
|
.am-modal-dialog {
|
||||||
|
width: 560px; max-width: 100%;
|
||||||
|
background: var(--card); border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow-pop); padding: 20px;
|
||||||
|
}
|
||||||
|
.am-modal-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
|
||||||
|
.am-modal-head b { font-size: 15px; }
|
||||||
|
button.am-modal-close {
|
||||||
|
height: 28px; width: 28px; padding: 0; font-size: 18px; line-height: 1;
|
||||||
|
background: #fff; color: var(--ink-600); border: 1px solid var(--line); border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
button.am-modal-close:hover:not(:disabled) { background: var(--blue-50); color: var(--blue-700); border-color: var(--blue-100); }
|
||||||
|
.am-modal-dialog textarea { min-height: 200px; margin: 8px 0; }
|
||||||
|
.am-modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 8px; }
|
||||||
|
|
||||||
|
/* 通用确认弹窗:紧凑尺寸 + 危险(删除)确定按钮红色 */
|
||||||
|
.am-modal-confirm { width: 420px; }
|
||||||
|
.am-modal-confirm .hint { margin: 4px 0 16px; color: var(--ink-700); font-size: 14px; line-height: 1.6; }
|
||||||
|
.am-prompt-input {
|
||||||
|
width: 100%; height: 38px; padding: 0 12px; margin: 0 0 16px; box-sizing: border-box;
|
||||||
|
border: 1px solid var(--line); border-radius: var(--radius-sm);
|
||||||
|
font-size: 14px; color: var(--ink-900); font-family: var(--mono);
|
||||||
|
}
|
||||||
|
.am-prompt-input:focus { outline: none; border-color: var(--blue-500, var(--blue-600)); box-shadow: 0 0 0 3px var(--blue-50); }
|
||||||
|
.am-prompt-input[hidden] { display: none; }
|
||||||
|
|
||||||
|
/* 邮箱编辑弹窗 */
|
||||||
|
.am-mb-edit-field { margin: 0 0 14px; }
|
||||||
|
.am-mb-edit-lbl { display: block; margin: 0 0 6px; font-size: 13px; color: var(--ink-700); }
|
||||||
|
.am-mb-edit-email { font-family: var(--mono); font-size: 14px; color: var(--ink-900); word-break: break-all; }
|
||||||
|
.am-mb-edit-field .am-prompt-input { margin: 0; }
|
||||||
|
.am-mb-edit-used { margin: 0 0 4px; }
|
||||||
|
#confirmOk.danger { background: var(--danger); border-color: var(--danger); color: #fff; }
|
||||||
|
#confirmOk.danger:hover:not(:disabled) { background: #b91c1c; border-color: #b91c1c; color: #fff; }
|
||||||
|
|
||||||
|
/* ---------- 查收抽屉 ---------- */
|
||||||
|
.am-drawer { position: fixed; inset: 0; z-index: 70; }
|
||||||
|
.am-drawer[hidden] { display: none; }
|
||||||
|
.am-drawer-mask { position: absolute; inset: 0; background: rgba(15, 23, 42, .35); }
|
||||||
|
.am-drawer-panel {
|
||||||
|
position: absolute; top: 0; right: 0; height: 100%; width: 460px; max-width: 90vw;
|
||||||
|
background: var(--card); box-shadow: var(--shadow-pop);
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
}
|
||||||
|
.am-drawer-head {
|
||||||
|
display: flex; align-items: flex-start; justify-content: space-between;
|
||||||
|
padding: 16px 20px; border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.am-drawer-head b { font-size: 15px; }
|
||||||
|
.am-drawer-addr { color: var(--ink-500); font-size: 12px; margin-top: 4px; font-family: var(--mono); }
|
||||||
|
.am-drawer-head-actions { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.am-drawer-body { flex: 1; overflow-y: auto; padding: 16px 20px; }
|
||||||
|
.msg-item { border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 12px; margin-bottom: 10px; }
|
||||||
|
.msg-from { color: var(--ink-500); font-size: 12px; }
|
||||||
|
.msg-subject { color: var(--ink-900); margin: 3px 0; }
|
||||||
|
.msg-time { color: var(--ink-400); font-size: 12px; }
|
||||||
|
.msg-code { margin-top: 8px; }
|
||||||
|
.msg-body { margin-top: 8px; color: var(--ink-700); font-size: 12px; white-space: pre-wrap; max-height: 160px; overflow: auto; }
|
||||||
|
.drawer-loading, .drawer-empty { color: var(--ink-400); text-align: center; padding: 40px 0; }
|
||||||
|
|
||||||
|
/* ---------- 拿 RT 进度面板 ---------- */
|
||||||
|
.am-rt-panel {
|
||||||
|
position: fixed; right: 20px; bottom: 20px; z-index: 55;
|
||||||
|
width: 420px; max-width: calc(100vw - 40px);
|
||||||
|
background: var(--card); border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius); box-shadow: var(--shadow-pop);
|
||||||
|
display: flex; flex-direction: column; max-height: 60vh;
|
||||||
|
}
|
||||||
|
.am-rt-panel[hidden] { display: none; }
|
||||||
|
.am-rt-head {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 12px 14px; border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.am-rt-head b { font-size: 14px; }
|
||||||
|
.am-rt-summary { flex: 1; color: var(--ink-500); font-size: 12px; }
|
||||||
|
.am-rt-logs { flex: 1; overflow-y: auto; padding: 10px 14px; font-family: var(--mono); font-size: 12px; }
|
||||||
|
.am-rt-line { padding: 2px 0; color: var(--ink-700); white-space: pre-wrap; word-break: break-all; }
|
||||||
|
.am-rt-line.ok { color: #047857; }
|
||||||
|
.am-rt-line.warn { color: #92400e; }
|
||||||
|
.am-rt-line.error { color: #991b1b; }
|
||||||
|
|
||||||
|
/* ---------- Toast ---------- */
|
||||||
|
.am-toast {
|
||||||
|
position: fixed; left: 50%; bottom: 32px; transform: translateX(-50%) translateY(20px);
|
||||||
|
background: var(--ink-900); color: #fff; padding: 10px 18px; border-radius: var(--radius-sm);
|
||||||
|
font-size: 13px; opacity: 0; pointer-events: none; transition: opacity .2s, transform .2s; z-index: 90;
|
||||||
|
box-shadow: var(--shadow-pop);
|
||||||
|
}
|
||||||
|
.am-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||||
|
.am-toast.ok { background: #047857; }
|
||||||
|
.am-toast.err { background: #b91c1c; }
|
||||||
|
|
||||||
|
@media (max-width: 1100px) { .am-stats { grid-template-columns: repeat(2, 1fr); } }
|
||||||
|
|
||||||
|
/* ---------- 自动刷新开关 ---------- */
|
||||||
|
.am-auto-refresh {
|
||||||
|
display: inline-flex; align-items: center; gap: 8px; margin: 0;
|
||||||
|
color: var(--ink-700); font-size: 13px; cursor: pointer; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.am-auto-refresh input { width: 15px; height: 15px; }
|
||||||
|
.am-auto-refresh .muted { color: var(--ink-400); font-size: 12px; }
|
||||||
|
|
||||||
|
/* ---------- Plan / 状态 药丸 ---------- */
|
||||||
|
.pb-plan-tag {
|
||||||
|
display: inline-flex; align-items: center; padding: 2px 9px; border-radius: 4px;
|
||||||
|
font-size: 12px; background: #f1f5f9; color: var(--ink-600, #475569); border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.pb-plan-tag.plus {
|
||||||
|
background: linear-gradient(135deg, #fbbf24, #d97706);
|
||||||
|
color: #fff; border-color: #d97706; font-weight: 600; letter-spacing: .3px;
|
||||||
|
box-shadow: 0 1px 4px rgba(217, 119, 6, .35);
|
||||||
|
}
|
||||||
|
.pb-plan-tag.pro { background: #f5f3ff; color: #6d28d9; border-color: #ddd6fe; font-weight: 600; }
|
||||||
|
.pb-plan-tag.free { background: #f1f5f9; color: var(--ink-500); }
|
||||||
|
|
||||||
|
/* 批量直开「已开通」徽标:绿色系,与 pb-status.ok / at-chip.has 同一语义色 */
|
||||||
|
.pb-opened-tag {
|
||||||
|
display: inline-flex; align-items: center; padding: 2px 9px; border-radius: 4px;
|
||||||
|
font-size: 12px; font-weight: 600;
|
||||||
|
background: var(--ok-soft); color: #047857; border: 1px solid #a7f3d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pb-status { display: inline-flex; align-items: center; gap: 4px; padding: 2px 9px; border-radius: 4px; font-size: 12px; border: 1px solid var(--line); }
|
||||||
|
.pb-status.ok { background: var(--ok-soft); color: #047857; border-color: #a7f3d0; }
|
||||||
|
.pb-status.expiring { background: var(--warn-soft); color: #92400e; border-color: #fde68a; }
|
||||||
|
.pb-status.invalid { background: var(--danger-soft); color: #991b1b; border-color: #fecaca; }
|
||||||
|
.pb-status.unknown { background: #f1f5f9; color: var(--ink-500); }
|
||||||
|
|
||||||
|
.pb-exp-main { color: var(--ink-900); }
|
||||||
|
.pb-exp-sub { color: var(--ink-400); font-size: 12px; }
|
||||||
|
.pb-exp-cell.expired .pb-exp-sub { color: var(--danger); }
|
||||||
|
|
||||||
|
.at-cell { display: flex; align-items: center; gap: 6px; }
|
||||||
|
.at-chip { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 4px; font-size: 12px; }
|
||||||
|
.at-chip.has { background: var(--ok-soft); color: #047857; border: 1px solid #a7f3d0; cursor: pointer; }
|
||||||
|
.at-chip.none { background: #f1f5f9; color: var(--ink-500); border: 1px solid var(--line); }
|
||||||
|
.rt-chip { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 4px; font-size: 12px; }
|
||||||
|
.rt-chip.has { background: var(--blue-50); color: var(--blue-700); border: 1px solid var(--blue-100); cursor: pointer; }
|
||||||
|
.rt-chip.none { background: #f1f5f9; color: var(--ink-500); border: 1px solid var(--line); }
|
||||||
|
.sub-chip { display: inline-flex; align-items: center; justify-content: center; min-width: 30px; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 600; }
|
||||||
|
.sub-chip.yes { background: var(--blue-50); color: var(--blue-700); border: 1px solid var(--blue-100); }
|
||||||
|
.sub-chip.no { background: #f1f5f9; color: var(--ink-500); border: 1px solid var(--line); }
|
||||||
|
|
||||||
|
/* ---------- 编辑 / 设置 弹窗控件 ---------- */
|
||||||
|
.am-mono-area { min-height: 72px; font-family: var(--mono); font-size: 12px; }
|
||||||
|
/* AT / Refresh Token 框调矮:约 3 行高(用更高优先级覆盖上面 .am-modal-dialog textarea 的 min-height:200px) */
|
||||||
|
.am-modal-dialog textarea.am-mono-area-sm { min-height: 52px; max-height: 72px; resize: vertical; }
|
||||||
|
/* 邮箱池「清空」按钮:次要按钮上加红色语义 */
|
||||||
|
button.am-danger { color: var(--danger); border-color: #fecaca; }
|
||||||
|
button.am-danger:hover:not(:disabled) { background: var(--danger-soft); color: var(--danger); border-color: #fecaca; }
|
||||||
|
.am-modal-dialog label { display: block; margin-bottom: 10px; color: var(--ink-700); }
|
||||||
|
.am-modal-dialog label > input,
|
||||||
|
.am-modal-dialog label > textarea,
|
||||||
|
.am-modal-dialog label > select { margin-top: 4px; }
|
||||||
|
.am-modal-wide { width: 720px; max-width: 100%; }
|
||||||
|
.am-set-block { margin-bottom: 18px; }
|
||||||
|
.am-set-proxies { min-height: 140px; }
|
||||||
|
.am-set-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||||
|
.am-set-grid label { margin-bottom: 0; }
|
||||||
|
/* 协议注册弹窗 */
|
||||||
|
.reg-avail { margin: 12px 0 4px; font-size: 13px; color: var(--ink-700); }
|
||||||
|
.reg-avail b { color: var(--blue-700); font-size: 15px; }
|
||||||
|
|
||||||
|
/* 协议注册:右下角浮层 */
|
||||||
|
.reg-panel {
|
||||||
|
position: fixed; right: 20px; bottom: 20px; z-index: 3000;
|
||||||
|
width: 380px; background: #fff;
|
||||||
|
border: 1px solid var(--line); border-radius: var(--radius); box-shadow: 0 12px 40px rgba(15, 23, 42, .22);
|
||||||
|
display: flex; flex-direction: column; overflow: hidden;
|
||||||
|
}
|
||||||
|
.reg-panel[hidden] { display: none; }
|
||||||
|
.reg-panel-head {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 10px 12px; background: var(--blue-600); color: #fff;
|
||||||
|
}
|
||||||
|
.reg-panel-title { flex: 0 0 auto; font-size: 14px; }
|
||||||
|
.reg-panel-stat { flex: 1 1 auto; font-size: 12px; color: rgba(255, 255, 255, .9); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.reg-panel-tools { flex: 0 0 auto; display: flex; gap: 6px; }
|
||||||
|
.reg-panel-btn {
|
||||||
|
height: 24px; min-width: 24px; padding: 0 8px;
|
||||||
|
background: rgba(255, 255, 255, .18); color: #fff;
|
||||||
|
border: 0; border-radius: var(--radius-sm); cursor: pointer; font-size: 13px; line-height: 24px;
|
||||||
|
}
|
||||||
|
button.reg-panel-btn:hover:not(:disabled) { background: rgba(255, 255, 255, .32); }
|
||||||
|
.reg-panel-body { padding: 12px; }
|
||||||
|
.reg-panel.min .reg-panel-body { display: none; }
|
||||||
|
.reg-progress { height: 8px; background: var(--blue-50); border-radius: 999px; overflow: hidden; margin-bottom: 10px; }
|
||||||
|
.reg-progress-bar { height: 100%; width: 0; background: var(--blue-600); transition: width .3s ease; }
|
||||||
|
.reg-logs {
|
||||||
|
height: 220px; overflow-y: auto; overflow-x: hidden; scroll-behavior: smooth;
|
||||||
|
font-family: var(--mono); font-size: 12px; line-height: 1.6;
|
||||||
|
background: #0f172a; color: #cbd5e1; border-radius: var(--radius-sm); padding: 8px 10px;
|
||||||
|
}
|
||||||
|
.reg-log-line {
|
||||||
|
display: block; white-space: pre-wrap; word-break: break-word;
|
||||||
|
padding: 1px 0; border-left: 2px solid transparent; padding-left: 6px;
|
||||||
|
}
|
||||||
|
.reg-log-ts { color: #64748b; margin-right: 6px; }
|
||||||
|
.reg-log-acct { color: #60a5fa; margin-right: 6px; }
|
||||||
|
.reg-log-msg { color: #cbd5e1; }
|
||||||
|
.reg-log-line.lv-ok { border-left-color: #4ade80; }
|
||||||
|
.reg-log-line.lv-ok .reg-log-msg { color: #4ade80; }
|
||||||
|
.reg-log-line.lv-error { border-left-color: #f87171; }
|
||||||
|
.reg-log-line.lv-error .reg-log-msg { color: #f87171; }
|
||||||
|
.reg-log-line.lv-warn { border-left-color: #fbbf24; }
|
||||||
|
.reg-log-line.lv-warn .reg-log-msg { color: #fbbf24; }
|
||||||
|
.reg-log-line.lv-info { border-left-color: #334155; }
|
||||||
|
.reg-log-line.lv-info .reg-log-msg { color: #cbd5e1; }
|
||||||
|
/* 兼容旧结构(span.lv-*) */
|
||||||
|
.reg-log-line .lv-ok { color: #4ade80; }
|
||||||
|
.reg-log-line .lv-error { color: #f87171; }
|
||||||
|
.reg-log-line .lv-warn { color: #fbbf24; }
|
||||||
|
.reg-log-line .lv-info { color: #94a3b8; }
|
||||||
|
|
||||||
|
.am-sms-testbar { display: flex; gap: 10px; align-items: center; margin-top: 12px; }
|
||||||
|
.am-sms-bal { font-size: 13px; }
|
||||||
|
.am-sms-bal.ok { color: var(--blue-700); }
|
||||||
|
.am-sms-bal.err { color: var(--danger); }
|
||||||
|
|
||||||
|
/* 设置弹窗内的 Tab */
|
||||||
|
.am-set-tabs { display: flex; gap: 8px; margin: 4px 0 16px; border-bottom: 1px solid var(--line); padding-bottom: 12px; }
|
||||||
|
.am-set-tab {
|
||||||
|
height: 34px; padding: 0 16px;
|
||||||
|
background: #fff; color: var(--ink-700);
|
||||||
|
border: 1px solid var(--line); border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer; font-size: 14px;
|
||||||
|
}
|
||||||
|
button.am-set-tab:hover:not(:disabled) { background: var(--blue-50); color: var(--blue-700); border-color: var(--blue-100); }
|
||||||
|
.am-set-tab.active, button.am-set-tab.active:hover:not(:disabled) {
|
||||||
|
background: var(--blue-600); color: #fff; border-color: var(--blue-600);
|
||||||
|
}
|
||||||
|
.am-set-panel[hidden] { display: none; }
|
||||||
|
|
||||||
|
/* 邮箱池 */
|
||||||
|
.am-set-check { display: flex; align-items: center; gap: 8px; margin: 4px 0 8px; font-size: 13px; color: var(--ink-700); cursor: pointer; }
|
||||||
|
.am-set-check input { width: auto; margin: 0; }
|
||||||
|
.am-set-check code { background: var(--blue-50); color: var(--blue-700); padding: 1px 5px; border-radius: 4px; font-family: var(--mono); }
|
||||||
|
.am-mb-splitbar { display: flex; gap: 8px; align-items: center; margin: 2px 0 8px; font-size: 13px; color: var(--ink-700); }
|
||||||
|
.am-mb-splitbar[hidden] { display: none; }
|
||||||
|
.am-mb-splitnum { width: 72px; }
|
||||||
|
.am-mb-importbar { display: flex; gap: 10px; align-items: center; margin: 8px 0; }
|
||||||
|
.am-mb-importbar .hint { margin: 0; }
|
||||||
|
.am-mb-splitbar { flex-wrap: wrap; }
|
||||||
|
.am-mb-splitbar .am-mb-count { margin: 0 0 0 auto; }
|
||||||
|
#mbImportBox[hidden] { display: none; }
|
||||||
|
#mbImportBox { margin: 8px 0; }
|
||||||
|
.am-mb-list { border: 1px solid var(--line); border-radius: var(--radius-sm); max-height: 260px; overflow: auto; }
|
||||||
|
.am-mb-row { display: flex; align-items: center; gap: 10px; padding: 6px 10px; border-bottom: 1px solid var(--line-soft); font-size: 13px; }
|
||||||
|
.am-mb-row:last-child { border-bottom: 0; }
|
||||||
|
.am-mb-addr { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink-900); font-family: var(--mono); }
|
||||||
|
.am-mb-badge { flex: 0 0 auto; padding: 1px 8px; border-radius: 999px; font-size: 12px; }
|
||||||
|
.am-mb-badge.free { background: var(--blue-50); color: var(--blue-700); border: 1px solid var(--blue-100); }
|
||||||
|
.am-mb-badge.used { background: #f1f5f9; color: var(--ink-500); border: 1px solid var(--line); }
|
||||||
|
.am-mb-tag { flex: 0 0 auto; color: var(--ink-400); font-size: 12px; }
|
||||||
|
.am-mb-ops { flex: 0 0 auto; display: flex; gap: 6px; }
|
||||||
|
.am-mb-testbar { display: flex; gap: 10px; align-items: center; margin: 6px 0 10px; }
|
||||||
|
.am-mb-select { flex: 1; min-width: 0; }
|
||||||
|
.am-mb-result { border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 10px 12px; max-height: 220px; overflow: auto; background: #fff; }
|
||||||
|
.am-mb-result[hidden] { display: none; }
|
||||||
|
.am-mb-loading, .am-mb-empty { color: var(--ink-400); text-align: center; padding: 16px 0; font-size: 13px; }
|
||||||
|
.am-mb-head { color: var(--blue-700); font-size: 13px; margin-bottom: 8px; }
|
||||||
|
.am-mb-item { border-bottom: 1px solid var(--line-soft); padding: 8px 0; }
|
||||||
|
.am-mb-item:last-child { border-bottom: 0; }
|
||||||
|
.am-mb-subj { color: var(--ink-900); font-size: 13px; }
|
||||||
|
.am-mb-meta { color: var(--ink-400); font-size: 12px; margin-top: 2px; word-break: break-all; }
|
||||||
|
.am-mb-code { margin-top: 4px; font-size: 13px; color: var(--ink-700); }
|
||||||
|
.am-mb-code b { color: var(--blue-700); font-family: var(--mono); font-size: 15px; letter-spacing: 1px; }
|
||||||
|
.topbar-actions button { margin-right: 4px; }
|
||||||
|
|
||||||
|
/* ---------- GPT 账号表:固定列宽(table-layout: fixed)---------- */
|
||||||
|
.am-table-gpt { table-layout: fixed; width: 100%; }
|
||||||
|
.am-table-gpt .col-check { width: 40px; }
|
||||||
|
.am-table-gpt .col-idx { width: 48px; }
|
||||||
|
.am-table-gpt .col-email { width: 240px; }
|
||||||
|
.am-table-gpt .col-at { width: 190px; }
|
||||||
|
.am-table-gpt .col-rt { width: 96px; }
|
||||||
|
.am-table-gpt .col-sub { width: 64px; text-align: center; }
|
||||||
|
.am-table-gpt .col-status { width: 84px; }
|
||||||
|
.am-table-gpt .col-ops { width: 300px; }
|
||||||
|
/* 邮箱过长时省略号 + title 悬浮提示 */
|
||||||
|
.am-table-gpt .email-cell { min-width: 0; }
|
||||||
|
.am-table-gpt .email-cell .am-email {
|
||||||
|
flex: 0 1 auto; min-width: 0;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
/* FREE / PLUS 徽标统一靠右、等宽居中,保证跨行竖向对齐 */
|
||||||
|
.am-table-gpt .email-cell .pb-plan-tag {
|
||||||
|
margin-left: auto; flex: none;
|
||||||
|
min-width: 48px; justify-content: center; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.am-table-gpt .email-cell .pb-opened-tag { flex: none; white-space: nowrap; }
|
||||||
|
/* 操作列按钮保持单行不换行 */
|
||||||
|
.am-table-gpt .row-ops { flex-wrap: nowrap; white-space: nowrap; }
|
||||||
|
.am-table-gpt tbody td { overflow: hidden; }
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,360 @@
|
|||||||
|
@charset "UTF-8";
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
App Shell — 统一设计系统(参考 auto 项目 · 蓝色企业风 · 全局非加粗)
|
||||||
|
左右布局:蓝色渐变侧栏 + 右侧功能区
|
||||||
|
所有功能页共用;功能样式在各自 css 里补充(复用下方令牌)
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
/* 品牌蓝 */
|
||||||
|
--blue-700: #1e40af;
|
||||||
|
--blue-600: #2563eb;
|
||||||
|
--blue-500: #3b82f6;
|
||||||
|
--blue-100: #dbeafe;
|
||||||
|
--blue-50: #eff6ff;
|
||||||
|
/* 墨色文字 */
|
||||||
|
--ink-900: #0f172a;
|
||||||
|
--ink-700: #334155;
|
||||||
|
--ink-500: #64748b;
|
||||||
|
--ink-400: #94a3b8;
|
||||||
|
/* 线与底 */
|
||||||
|
--line: #e2e8f0;
|
||||||
|
--line-soft: #eef2f7;
|
||||||
|
--bg: #f4f7fb;
|
||||||
|
--card: #ffffff;
|
||||||
|
/* 语义色 */
|
||||||
|
--danger: #dc2626;
|
||||||
|
--danger-soft: #fef2f2;
|
||||||
|
--ok: #059669;
|
||||||
|
--ok-soft: #ecfdf5;
|
||||||
|
--warn: #b45309;
|
||||||
|
--warn-soft: #fffbeb;
|
||||||
|
/* 圆角/阴影/字体 */
|
||||||
|
--radius: 6px;
|
||||||
|
--radius-sm: 4px;
|
||||||
|
--shadow: 0 1px 2px rgba(15, 23, 42, .04), 0 8px 24px rgba(15, 23, 42, .06);
|
||||||
|
--shadow-pop: 0 12px 40px rgba(15, 23, 42, .18);
|
||||||
|
--font: "Inter", -apple-system, "Segoe UI", "Microsoft YaHei", "PingFang SC", "Noto Sans SC", sans-serif;
|
||||||
|
--mono: "SFMono-Regular", "Cascadia Mono", Consolas, "Liberation Mono", monospace;
|
||||||
|
|
||||||
|
/* 兼容旧令牌名(功能页 css 复用) */
|
||||||
|
--primary: #2563eb;
|
||||||
|
--primary-dark: #1e40af;
|
||||||
|
--primary-light: #3b82f6;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--panel2: #f8fafc;
|
||||||
|
--line-strong: #cbd5e1;
|
||||||
|
--text: #0f172a;
|
||||||
|
--muted: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 全局:一律不加粗,盒模型统一 */
|
||||||
|
* { box-sizing: border-box; font-weight: 400 !important; }
|
||||||
|
html, body { margin: 0; padding: 0; height: 100%; }
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink-900);
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.55;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
h1, h2, h3, h4 { margin: 0; }
|
||||||
|
code, .text-mono { font-family: var(--mono); }
|
||||||
|
input, textarea, button, select { font-family: inherit; font-size: 14px; color: inherit; }
|
||||||
|
|
||||||
|
/* 统一滚动条 */
|
||||||
|
* { scrollbar-width: thin; scrollbar-color: #cbd5e1 transparent; }
|
||||||
|
*::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||||
|
*::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
*::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; border: 2px solid transparent; background-clip: padding-box; }
|
||||||
|
*::-webkit-scrollbar-thumb:hover { background: #94a3b8; background-clip: padding-box; }
|
||||||
|
|
||||||
|
/* ---------- 布局骨架 ---------- */
|
||||||
|
.layout { display: flex; min-height: 100vh; }
|
||||||
|
|
||||||
|
/* ---------- 侧栏(蓝色渐变) ---------- */
|
||||||
|
.sidebar {
|
||||||
|
width: 224px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: linear-gradient(180deg, var(--blue-700), var(--blue-600));
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
.sidebar-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 18px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, .12);
|
||||||
|
}
|
||||||
|
.sidebar-brand .logo-box {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: rgba(255, 255, 255, .18);
|
||||||
|
border: 1px solid rgba(255, 255, 255, .3);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 18px;
|
||||||
|
letter-spacing: .5px;
|
||||||
|
}
|
||||||
|
.sidebar-brand .brand-text { display: flex; flex-direction: column; line-height: 1.3; }
|
||||||
|
.sidebar-brand .brand-text b { color: #fff; font-size: 16px; letter-spacing: 1px; }
|
||||||
|
.sidebar-brand .brand-text small { color: rgba(255, 255, 255, .78); font-size: 12px; }
|
||||||
|
|
||||||
|
.sidebar-menu { flex: 1 1 auto; padding: 14px 12px; display: flex; flex-direction: column; gap: 6px; overflow-y: auto; }
|
||||||
|
/* 底部固定链接组:被主菜单的 flex:1 顶到侧栏底部,并用分隔线与主导航区分 */
|
||||||
|
.menu-foot {
|
||||||
|
margin-top: auto;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, .12);
|
||||||
|
}
|
||||||
|
.menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 11px 14px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: rgba(255, 255, 255, .82);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: background .15s, color .15s;
|
||||||
|
}
|
||||||
|
.menu-item:hover { background: rgba(255, 255, 255, .12); color: #fff; }
|
||||||
|
.menu-item.active { background: rgba(255, 255, 255, .18); color: #fff; }
|
||||||
|
.menu-item .icon { font-size: 18px; width: 20px; text-align: center; }
|
||||||
|
/* 导航图标:CSS mask + currentColor(白色),替代 emoji */
|
||||||
|
.menu-item .nav-ic {
|
||||||
|
width: 18px; height: 18px; text-align: initial; flex: 0 0 auto;
|
||||||
|
background-color: currentColor;
|
||||||
|
-webkit-mask: var(--icon) center / contain no-repeat;
|
||||||
|
mask: var(--icon) center / contain no-repeat;
|
||||||
|
opacity: .9;
|
||||||
|
}
|
||||||
|
.menu-item:hover .nav-ic, .menu-item.active .nav-ic { opacity: 1; }
|
||||||
|
.nav-ic-accounts {
|
||||||
|
--icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
.nav-ic-cardflow {
|
||||||
|
--icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M20 4H4c-1.11 0-1.99.89-1.99 2L2 18c0 1.11.89 2 2 2h16c1.11 0 2-.89 2-2V6c0-1.11-.89-2-2-2zm0 14H4v-6h16v6zm0-10H4V6h16v2z'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
.nav-ic-protocol {
|
||||||
|
--icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M7 2v11h3v9l7-12h-4l4-8z'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
.nav-ic-pay {
|
||||||
|
--icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
.nav-ic-cardlink {
|
||||||
|
--icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
.nav-ic-github {
|
||||||
|
--icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 .5C5.73.5.5 5.73.5 12c0 5.08 3.29 9.39 7.86 10.91.58.11.79-.25.79-.56 0-.27-.01-1.16-.02-2.1-3.2.7-3.87-1.36-3.87-1.36-.53-1.34-1.29-1.7-1.29-1.7-1.05-.72.08-.71.08-.71 1.16.08 1.77 1.2 1.77 1.2 1.03 1.77 2.7 1.26 3.36.96.1-.75.4-1.26.73-1.55-2.55-.29-5.24-1.28-5.24-5.68 0-1.26.45-2.28 1.19-3.09-.12-.29-.52-1.46.11-3.04 0 0 .97-.31 3.18 1.18a11.1 11.1 0 0 1 5.79 0c2.2-1.49 3.17-1.18 3.17-1.18.63 1.58.23 2.75.11 3.04.74.81 1.19 1.83 1.19 3.09 0 4.41-2.69 5.38-5.25 5.67.41.35.78 1.05.78 2.12 0 1.53-.01 2.76-.01 3.14 0 .31.21.68.8.56A11.51 11.51 0 0 0 23.5 12C23.5 5.73 18.27.5 12 .5z'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
.nav-ic-qq {
|
||||||
|
--icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 12H6v-2h12v2zm0-3H6V9h12v2zm0-3H6V6h12v2z'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
.menu-item .count {
|
||||||
|
margin-left: auto;
|
||||||
|
min-width: 18px; height: 18px; padding: 0 5px;
|
||||||
|
border-radius: 4px; background: #f87171; color: #fff;
|
||||||
|
font-size: 11px; display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 主区域 ---------- */
|
||||||
|
.main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||||
|
.topbar {
|
||||||
|
height: 60px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--card);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 28px;
|
||||||
|
}
|
||||||
|
.topbar-title { font-size: 18px; color: var(--ink-900); letter-spacing: .3px; }
|
||||||
|
.topbar-actions { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--ink-500); }
|
||||||
|
.topbar-actions .dot { width: 8px; height: 8px; border-radius: 50%; background: #34d399; box-shadow: 0 0 0 3px rgba(52, 211, 153, .16); }
|
||||||
|
|
||||||
|
.content { flex: 1; padding: 22px 28px 60px; min-width: 0; width: 100%; }
|
||||||
|
|
||||||
|
/* ---------- 面板/卡片 ---------- */
|
||||||
|
.card, .panel {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 18px 20px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.section-title b { font-size: 14px; color: var(--ink-900); }
|
||||||
|
.section-title small { color: var(--ink-500); font-size: 12px; }
|
||||||
|
|
||||||
|
/* 药丸标签 */
|
||||||
|
.tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 9px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
background: var(--blue-50);
|
||||||
|
color: var(--blue-700);
|
||||||
|
border: 1px solid var(--blue-100);
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 9px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
background: var(--blue-50);
|
||||||
|
color: var(--blue-700);
|
||||||
|
border: 1px solid var(--blue-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 表单控件 ---------- */
|
||||||
|
label { display: block; margin-bottom: 6px; font-size: 13px; color: var(--ink-500); }
|
||||||
|
label > span { color: var(--ink-500); }
|
||||||
|
|
||||||
|
input, textarea {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: #fff;
|
||||||
|
color: var(--ink-900);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 10px 12px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color .15s, box-shadow .15s;
|
||||||
|
}
|
||||||
|
input:not([type="checkbox"]):not([type="radio"]) { height: 38px; padding: 0 12px; }
|
||||||
|
textarea { resize: vertical; font-family: var(--mono); line-height: 1.6; }
|
||||||
|
input:focus, textarea:focus { border-color: var(--blue-500); box-shadow: 0 0 0 3px rgba(59, 130, 246, .12); }
|
||||||
|
|
||||||
|
/* 下拉框:统一箭头样式 */
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 34px 0 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: #fff url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E") no-repeat right 10px center;
|
||||||
|
color: var(--ink-900);
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
transition: border-color .15s, box-shadow .15s;
|
||||||
|
}
|
||||||
|
select:focus { border-color: var(--blue-500); box-shadow: 0 0 0 3px rgba(59, 130, 246, .12); }
|
||||||
|
select option { background: #fff; color: var(--ink-900); }
|
||||||
|
input[type="checkbox"], input[type="radio"] { width: 15px; height: 15px; accent-color: var(--blue-600); cursor: pointer; }
|
||||||
|
|
||||||
|
/* ---------- 统一按钮 ---------- */
|
||||||
|
button, .btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border: 1px solid var(--blue-600);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--blue-600);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background .15s, border-color .15s, color .15s;
|
||||||
|
}
|
||||||
|
button:hover:not(:disabled), .btn:hover:not(:disabled), .btn-primary:hover:not(:disabled) { background: var(--blue-700); border-color: var(--blue-700); }
|
||||||
|
button:disabled, .btn:disabled { opacity: .55; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* 次要 / 幽灵按钮 */
|
||||||
|
button.secondary, button.ghost, .btn-ghost, .btn-secondary,
|
||||||
|
.load-card-button, .copy-one-button, .remove-pair, .add-at-button {
|
||||||
|
background: #fff;
|
||||||
|
color: var(--ink-700);
|
||||||
|
border-color: var(--line);
|
||||||
|
}
|
||||||
|
button.secondary:hover:not(:disabled), button.ghost:hover:not(:disabled),
|
||||||
|
.btn-ghost:hover:not(:disabled), .btn-secondary:hover:not(:disabled),
|
||||||
|
.load-card-button:hover:not(:disabled), .copy-one-button:hover:not(:disabled) {
|
||||||
|
background: var(--blue-50);
|
||||||
|
color: var(--blue-700);
|
||||||
|
border-color: var(--blue-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 危险按钮 */
|
||||||
|
.btn-danger, button.danger {
|
||||||
|
background: #fff; color: var(--danger); border-color: #fecaca;
|
||||||
|
}
|
||||||
|
.btn-danger:hover:not(:disabled), button.danger:hover:not(:disabled) { background: var(--danger-soft); }
|
||||||
|
|
||||||
|
/* 小尺寸 */
|
||||||
|
.small, .btn-sm { height: 32px; padding: 0 12px; font-size: 13px; }
|
||||||
|
|
||||||
|
/* ---------- 提示框(status/hint) ---------- */
|
||||||
|
.status {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 11px 14px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-left: 3px solid var(--blue-600);
|
||||||
|
color: var(--ink-700);
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.status.ok { border-left-color: var(--ok); background: var(--ok-soft); color: #047857; }
|
||||||
|
.status.error { border-left-color: var(--danger); background: var(--danger-soft); color: #991b1b; }
|
||||||
|
.status.warn { border-left-color: var(--warn); background: var(--warn-soft); color: #92400e; }
|
||||||
|
|
||||||
|
.hint { color: var(--ink-500); font-size: 13px; margin: 0 0 12px; }
|
||||||
|
.hint code { background: var(--blue-50); color: var(--blue-700); padding: 1px 6px; border-radius: 5px; font-size: 12px; }
|
||||||
|
|
||||||
|
/* ---------- 空态 ---------- */
|
||||||
|
.empty {
|
||||||
|
min-height: 300px;
|
||||||
|
border: 1px dashed var(--line-strong);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
text-align: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--ink-400);
|
||||||
|
background: #fbfcfd;
|
||||||
|
}
|
||||||
|
.empty b { font-size: 15px; color: var(--ink-700); }
|
||||||
|
.empty[hidden] { display: none; }
|
||||||
|
|
||||||
|
/* ---------- 响应式:窄屏侧栏收窄为图标 ---------- */
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.sidebar { width: 64px; }
|
||||||
|
.sidebar-brand { justify-content: center; padding: 16px 0; }
|
||||||
|
.sidebar-brand .brand-text { display: none; }
|
||||||
|
.menu-item { justify-content: center; padding: 11px 0; }
|
||||||
|
.menu-item span:not(.icon):not(.count) { display: none; }
|
||||||
|
}
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
.content, .topbar { padding-left: 16px; padding-right: 16px; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{# 共享侧边栏:一级平铺菜单(企业蓝)。active 取值:gpt / card-flow / protocol / pay / card-link #}
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar-brand">
|
||||||
|
<span class="logo-box">C</span>
|
||||||
|
<span class="brand-text"><b>ChatGpt</b><small>神奇的发烧友</small></span>
|
||||||
|
</div>
|
||||||
|
<nav class="sidebar-menu">
|
||||||
|
<a class="menu-item {{ 'active' if active == 'gpt' else '' }}" href="/account-manager/"><span class="icon nav-ic nav-ic-accounts"></span><span>GPT管理</span></a>
|
||||||
|
<a class="menu-item {{ 'active' if active == 'card-link' else '' }}" href="/embed/card-link"><span class="icon nav-ic nav-ic-cardlink"></span><span>直卡直开</span></a>
|
||||||
|
<a class="menu-item {{ 'active' if active == 'card-flow' else '' }}" href="/embed/card-flow"><span class="icon nav-ic nav-ic-cardflow"></span><span>批量直开(试用3天)</span></a>
|
||||||
|
<a class="menu-item {{ 'active' if active == 'protocol' else '' }}" href="/embed/protocol"><span class="icon nav-ic nav-ic-protocol"></span><span>协议支付(试用3天)</span></a>
|
||||||
|
<a class="menu-item {{ 'active' if active == 'pay' else '' }}" href="/embed/pay"><span class="icon nav-ic nav-ic-pay"></span><span>提取链接(试用3天)</span></a>
|
||||||
|
</nav>
|
||||||
|
<div class="menu-foot">
|
||||||
|
<a class="menu-item" href="https://github.com/432539/gpt" target="_blank" rel="noopener"><span class="nav-ic nav-ic-github"></span><span>开源地址</span></a>
|
||||||
|
<a class="menu-item" href="#" title="点击复制 QQ 群号" onclick="navigator.clipboard&&navigator.clipboard.writeText('19302577');return false;"><span class="nav-ic nav-ic-qq"></span><span>QQ群 19302577</span></a>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
@@ -0,0 +1,413 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN" data-api-base="{{ request.script_root }}">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>账号管理</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='app-shell.css', v='13') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='account-manager.css', v='24') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="layout">
|
||||||
|
{% set active = 'gpt' %}
|
||||||
|
{% include '_sidebar.html' %}
|
||||||
|
|
||||||
|
<div class="main">
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="topbar-title">账号管理</div>
|
||||||
|
<div class="topbar-actions">
|
||||||
|
<button id="btnSettings" class="secondary small" type="button">⚙ 设置</button>
|
||||||
|
<span class="dot"></span>服务运行中
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<!-- 统计卡片 -->
|
||||||
|
<section class="am-stats">
|
||||||
|
<div class="am-stat-card"><div class="am-stat-num" id="statA">0</div><div class="am-stat-label" id="statAL">账号总数</div></div>
|
||||||
|
<div class="am-stat-card"><div class="am-stat-num" id="statB">0</div><div class="am-stat-label" id="statBL">已选中</div></div>
|
||||||
|
<div class="am-stat-card"><div class="am-stat-num" id="statC">0</div><div class="am-stat-label" id="statCL">有效 AT</div></div>
|
||||||
|
<div class="am-stat-card"><div class="am-stat-num" id="statD">0</div><div class="am-stat-label" id="statDL">续期数量</div></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ GPT 账号管理(primary) ============ -->
|
||||||
|
<section class="card am-view" id="view-gpt">
|
||||||
|
<div class="am-toolbar">
|
||||||
|
<label class="am-auto-refresh" title="对未知状态、已过期或临近过期(<1天)的账号自动用 refresh_token 续期/校验">
|
||||||
|
<input type="checkbox" id="pbAutoRefresh">
|
||||||
|
<span>自动刷新 TOKEN</span>
|
||||||
|
<span class="muted" id="pbAutoRefreshHint">—</span>
|
||||||
|
</label>
|
||||||
|
<label class="am-auto-refresh" title="只显示「批量直开」已开通成功的账号">
|
||||||
|
<input type="checkbox" id="pbOpenedOnly">
|
||||||
|
<span>只看已开通</span>
|
||||||
|
</label>
|
||||||
|
<span id="pbDetectStatus" class="am-detect-status" hidden></span>
|
||||||
|
<div class="am-toolbar-spacer"></div>
|
||||||
|
<div class="am-dropdown" id="ddBatch">
|
||||||
|
<button id="ddBatchToggle" class="am-dropdown-toggle secondary" type="button" aria-haspopup="true" aria-expanded="false">批量操作 <span class="am-caret">▾</span></button>
|
||||||
|
<div class="am-dropdown-menu" role="menu">
|
||||||
|
<button id="btnPbDetectBatch" type="button" role="menuitem">批量检测</button>
|
||||||
|
<button id="btnPbExportAt" type="button" role="menuitem">批量导出 AT</button>
|
||||||
|
<button id="btnPbExportSub" type="button" role="menuitem">批量导出 SUB 格式</button>
|
||||||
|
<button id="btnPbExportPw" type="button" role="menuitem">导出账密</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="am-dropdown" id="ddDelete">
|
||||||
|
<button class="am-dropdown-toggle danger" type="button" aria-haspopup="true" aria-expanded="false">删除 <span class="am-caret">▾</span></button>
|
||||||
|
<div class="am-dropdown-menu" role="menu">
|
||||||
|
<button id="btnPbDelSel" class="danger-item" type="button" role="menuitem">删除所选</button>
|
||||||
|
<button id="btnPbDelAll" class="danger-item" type="button" role="menuitem">删除全部</button>
|
||||||
|
<button id="btnPbDelInvalid" class="danger-item" type="button" role="menuitem">删除失效</button>
|
||||||
|
<button id="btnPbDelExpired" class="danger-item" type="button" role="menuitem">删除过期</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button id="btnPbRefetch" class="secondary" type="button" title="对勾选账号用 codex 客户端重新登录拿 RT(需要手机验证会自动接码)">重拿RT</button>
|
||||||
|
<button id="btnPbBindPhone" class="secondary" type="button" title="对「跳过手机注册」的待绑手机账号(勾选或全部)补做手机验证并拿 RT">批量绑手机拿RT</button>
|
||||||
|
<button id="btnProtocolReg" class="am-btn-green" type="button">账号注册</button>
|
||||||
|
<button id="btnPbImport" type="button">导入账号</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="am-table-wrap">
|
||||||
|
<table class="am-table am-table-gpt" id="pbTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="col-check"><input type="checkbox" id="pbCheckAll"></th>
|
||||||
|
<th class="col-idx">#</th>
|
||||||
|
<th class="col-email">邮箱</th>
|
||||||
|
<th class="col-at">AT + 有效期</th>
|
||||||
|
<th class="col-rt">RefreshToken</th>
|
||||||
|
<th class="col-sub">SUB</th>
|
||||||
|
<th class="col-status">状态</th>
|
||||||
|
<th class="col-ops">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="pbRows"></tbody>
|
||||||
|
</table>
|
||||||
|
<div class="empty" id="pbEmpty" hidden></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="am-pager">
|
||||||
|
<div class="am-pager-left">
|
||||||
|
<span class="am-pager-label">每页</span>
|
||||||
|
<select id="pbPageSize" class="am-pagesize">
|
||||||
|
<option value="10">10</option>
|
||||||
|
<option value="50">50</option>
|
||||||
|
<option value="100">100</option>
|
||||||
|
<option value="500">500</option>
|
||||||
|
<option value="1000">1000</option>
|
||||||
|
<option value="5000">5000</option>
|
||||||
|
</select>
|
||||||
|
<span class="am-pager-label">条</span>
|
||||||
|
</div>
|
||||||
|
<div class="am-pager-right">
|
||||||
|
<span class="am-pager-info" id="pbPageInfo">第 1 / 1 页,共 0 条</span>
|
||||||
|
<button id="pbPagePrev" class="secondary small" type="button">上一页</button>
|
||||||
|
<button id="pbPageNext" class="secondary small" type="button">下一页</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ PLUS 邮箱(secondary,保留原有查收 / 拿RT) ============ -->
|
||||||
|
<section class="card am-view" id="view-plus" hidden>
|
||||||
|
<div class="am-toolbar">
|
||||||
|
<div class="am-search">
|
||||||
|
<span class="am-search-icon">🔍</span>
|
||||||
|
<input type="text" id="plSearch" placeholder="搜索邮箱 / 域名 / 备注" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<select id="plFilter" class="am-filter" title="按 RT 状态筛选">
|
||||||
|
<option value="all">全部</option>
|
||||||
|
<option value="rt">已拿到RT</option>
|
||||||
|
<option value="nort">未拿到RT</option>
|
||||||
|
<option value="banned">已封号</option>
|
||||||
|
</select>
|
||||||
|
<div class="am-toolbar-spacer"></div>
|
||||||
|
<button id="btnPlImport" type="button">批量导入</button>
|
||||||
|
<button id="btnPlGetRT" type="button">批量拿RT</button>
|
||||||
|
<button id="btnPlExport" class="secondary" type="button">批量导出</button>
|
||||||
|
<button id="btnPlDeleteBanned" class="danger" type="button">批量删除封号</button>
|
||||||
|
<button id="btnPlDelete" class="danger" type="button">删除选中</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="am-table-wrap">
|
||||||
|
<table class="am-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="col-check"><input type="checkbox" id="plCheckAll"></th>
|
||||||
|
<th class="col-idx">#</th>
|
||||||
|
<th>邮箱</th>
|
||||||
|
<th>密码</th>
|
||||||
|
<th>是否是PLUS</th>
|
||||||
|
<th>是否有RT</th>
|
||||||
|
<th class="col-ops">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="plRows"></tbody>
|
||||||
|
</table>
|
||||||
|
<div class="empty" id="plEmpty" hidden>
|
||||||
|
<b>暂无 PLUS 账号</b>
|
||||||
|
<span>点击「批量导入」开始添加。</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="am-pager">
|
||||||
|
<div class="am-pager-left">
|
||||||
|
<span class="am-pager-label">每页</span>
|
||||||
|
<select id="plPageSize" class="am-pagesize">
|
||||||
|
<option value="10">10</option>
|
||||||
|
<option value="100">100</option>
|
||||||
|
<option value="500">500</option>
|
||||||
|
<option value="1000">1000</option>
|
||||||
|
<option value="5000">5000</option>
|
||||||
|
</select>
|
||||||
|
<span class="am-pager-label">条</span>
|
||||||
|
</div>
|
||||||
|
<div class="am-pager-right">
|
||||||
|
<span class="am-pager-info" id="plPageInfo">第 1 / 1 页,共 0 条</span>
|
||||||
|
<button id="plPagePrev" class="secondary small" type="button">上一页</button>
|
||||||
|
<button id="plPageNext" class="secondary small" type="button">下一页</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- sub2api 导入弹窗 -->
|
||||||
|
<div id="pbImportModal" class="am-modal" hidden>
|
||||||
|
<div class="am-modal-dialog">
|
||||||
|
<div class="am-modal-head"><b>导入 GPT 账号 (sub2api)</b><button id="pbImportClose" class="am-modal-close" type="button" aria-label="关闭">×</button></div>
|
||||||
|
<p class="hint">支持三种格式:① 粘贴 <code>sub2api.json</code> 导出内容(含 accounts 数组);② 每行 <code>email----password----clientId----refreshToken</code>;③ 每行一个 <code>access token</code>。</p>
|
||||||
|
<textarea id="pbImportText" spellcheck="false" autocomplete="off" placeholder='粘贴 sub2api JSON、每行 email----password----clientId----refreshToken,或每行一个 access token'></textarea>
|
||||||
|
<div id="pbParsePreview" class="hint">识别到 0 个有效账号</div>
|
||||||
|
<div class="am-modal-actions">
|
||||||
|
<button id="btnDoPbImport" type="button">导入</button>
|
||||||
|
<button id="pbImportCancel" class="secondary" type="button">取消</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- GPT 账号编辑弹窗 -->
|
||||||
|
<div id="pbEditModal" class="am-modal" hidden>
|
||||||
|
<div class="am-modal-dialog">
|
||||||
|
<div class="am-modal-head"><b>编辑 GPT 账号</b><button id="pbEditClose" class="am-modal-close" type="button" aria-label="关闭">×</button></div>
|
||||||
|
<input type="hidden" id="pbEditId">
|
||||||
|
<label>邮箱<input type="text" id="pbEditEmail" autocomplete="off"></label>
|
||||||
|
<label>密码<input type="text" id="pbEditPassword" autocomplete="off"></label>
|
||||||
|
<label>2FA 密钥<input type="text" id="pbEditTwoFactor" spellcheck="false" autocomplete="off" placeholder="base32 密钥"></label>
|
||||||
|
<label>Access Token (AT)<textarea id="pbEditAccessToken" class="am-mono-area am-mono-area-sm" rows="3" spellcheck="false" autocomplete="off"></textarea></label>
|
||||||
|
<label>Refresh Token<textarea id="pbEditRefreshToken" class="am-mono-area am-mono-area-sm" rows="3" spellcheck="false" autocomplete="off"></textarea></label>
|
||||||
|
<label>ChatGPT Account ID<input type="text" id="pbEditAccountId" autocomplete="off"></label>
|
||||||
|
<label>备注<input type="text" id="pbEditNote" autocomplete="off"></label>
|
||||||
|
<div class="am-modal-actions">
|
||||||
|
<button id="btnSavePbEdit" type="button">保存</button>
|
||||||
|
<button id="pbEditCancel" class="secondary" type="button">取消</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 设置弹窗:代理池 / 接码设置 / 邮箱池 -->
|
||||||
|
<div id="settingsModal" class="am-modal" hidden>
|
||||||
|
<div class="am-modal-dialog am-modal-wide">
|
||||||
|
<div class="am-modal-head"><b>设置</b><button id="settingsClose" class="am-modal-close" type="button" aria-label="关闭">×</button></div>
|
||||||
|
<div class="am-set-tabs">
|
||||||
|
<button class="am-set-tab active" data-set-tab="proxy" type="button">代理池</button>
|
||||||
|
<button class="am-set-tab" data-set-tab="sms" type="button">接码设置</button>
|
||||||
|
<button class="am-set-tab" data-set-tab="mailbox" type="button">邮箱池</button>
|
||||||
|
<button class="am-set-tab" data-set-tab="browser" type="button">浏览器</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="am-set-panel" data-set-panel="proxy">
|
||||||
|
<div class="am-set-block">
|
||||||
|
<div class="section-title"><b>代理池</b><small>一行一个,支持 <code>host:port</code>、<code>host:port:user:pass</code> 或 <code>scheme://user:pass@host:port</code></small></div>
|
||||||
|
<textarea id="setProxies" class="am-mono-area am-set-proxies" spellcheck="false" autocomplete="off" placeholder="host:port:user:pass"></textarea>
|
||||||
|
<div class="hint" id="setProxyCount">共 0 条</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="am-set-panel" data-set-panel="sms" hidden>
|
||||||
|
<div class="am-set-block">
|
||||||
|
<div class="section-title"><b>接码 / SMS 配置</b><small>拿 RT 若触发手机验证时使用</small></div>
|
||||||
|
<div class="am-set-grid">
|
||||||
|
<label>Provider
|
||||||
|
<select id="setSmsProvider">
|
||||||
|
<option value="smscode">smscode</option>
|
||||||
|
<option value="smsbower">smsbower</option>
|
||||||
|
<option value="herosms">herosms</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>API Key<input type="text" id="setSmsApiKey" autocomplete="off" placeholder="接码平台 API Key"></label>
|
||||||
|
<label>Base URL<input type="text" id="setSmsBaseUrl" autocomplete="off" placeholder="https://..."></label>
|
||||||
|
<label>国家 (country)<input type="text" id="setSmsCountry" autocomplete="off" placeholder="国家代码,如 22"></label>
|
||||||
|
<label>最高金额 (maxPrice)<input type="text" id="setSmsMaxPrice" autocomplete="off" placeholder="如 5"></label>
|
||||||
|
<label>Poll Timeout (ms)<input type="number" id="setSmsPollTimeout" autocomplete="off" placeholder="180000"></label>
|
||||||
|
</div>
|
||||||
|
<div class="am-sms-testbar">
|
||||||
|
<button id="smsBalanceBtn" class="secondary small" type="button">测试 / 查询余额</button>
|
||||||
|
<span id="smsBalanceResult" class="am-sms-bal"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="am-set-panel" data-set-panel="mailbox" hidden>
|
||||||
|
<div class="am-set-block">
|
||||||
|
<div class="section-title"><b>邮箱池</b><small>一行一个,格式 <code>邮箱----专用密码</code> 或 <code>邮箱----取件API链接</code></small></div>
|
||||||
|
<label class="am-set-check"><input type="checkbox" id="setMbSplit"> 分裂邮箱(为主邮箱生成 <code>local+XXXX@domain</code> 别名,共用同一收件箱)</label>
|
||||||
|
<div class="am-mb-splitbar" id="mbSplitBar">
|
||||||
|
每个主邮箱生成 <input type="number" id="mbSplitCount" class="am-mb-splitnum" min="1" max="200" value="10"> 个别名
|
||||||
|
<button id="mbSplitGen" class="secondary small" type="button">生成到列表</button>
|
||||||
|
<button id="mbToggleImport" class="secondary small" type="button">批量导入</button>
|
||||||
|
<button id="mbClearAll" class="secondary small am-danger" type="button">清空</button>
|
||||||
|
<span class="hint am-mb-count" id="setMbCount">共 0 个(未使用 0)</span>
|
||||||
|
</div>
|
||||||
|
<div id="mbImportBox" hidden>
|
||||||
|
<textarea id="setMailbox" class="am-mono-area am-set-proxies" spellcheck="false" autocomplete="off" placeholder="name@icloud.com----abcd-efgh-ijkl-mnop(粘贴后点「导入到列表」)"></textarea>
|
||||||
|
<div class="am-mb-importbar">
|
||||||
|
<button id="mbImportBtn" class="secondary small" type="button">导入到列表</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="mbList" class="am-mb-list"></div>
|
||||||
|
<div id="mbTestResult" class="am-mb-result" hidden></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="am-set-panel" data-set-panel="browser" hidden>
|
||||||
|
<div class="am-set-block">
|
||||||
|
<div class="section-title"><b>浏览器注册(CloakBrowser)</b><small>「账号注册」选择浏览器注册时使用,隐身 Chromium 自带指纹随机</small></div>
|
||||||
|
<div class="am-set-grid">
|
||||||
|
<label>CloakBrowser KEY<input type="text" id="setCloakKey" autocomplete="off" placeholder="cb_xxxxxxxx(license key)"></label>
|
||||||
|
<label>注册密码<input type="text" id="setRegPassword" autocomplete="off" placeholder="留空用内置默认强口令"></label>
|
||||||
|
</div>
|
||||||
|
<div class="hint">留空则以免费额度启动(可能受限)。首次运行会自动下载隐身 Chromium(需联网,约 200MB)。</div>
|
||||||
|
<div class="hint">注册密码:两种注册方式共用,所有账号统一用此口令;改后新注册生效(旧账号仍是旧口令)。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="am-modal-actions">
|
||||||
|
<button id="btnSaveSettings" type="button">保存</button>
|
||||||
|
<button id="settingsCancel" class="secondary" type="button">取消</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PLUS 批量导入弹窗 -->
|
||||||
|
<div id="plImportModal" class="am-modal" hidden>
|
||||||
|
<div class="am-modal-dialog">
|
||||||
|
<div class="am-modal-head"><b>批量导入 PLUS 账号</b><button id="plImportClose" class="am-modal-close" type="button" aria-label="关闭">×</button></div>
|
||||||
|
<p class="hint">每行一个账号,格式:<code>email----password----clientId----refreshToken</code>(也兼容 <code>|</code> / 制表符 / 空白分隔)。</p>
|
||||||
|
<textarea id="plImportText" spellcheck="false" autocomplete="off" placeholder="email----password----clientId----refreshToken"></textarea>
|
||||||
|
<div id="plParsePreview" class="hint">识别到 0 个有效账号</div>
|
||||||
|
<div class="am-modal-actions">
|
||||||
|
<button id="btnDoPlImport" type="button">导入</button>
|
||||||
|
<button id="plImportCancel" class="secondary" type="button">取消</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 查收抽屉 -->
|
||||||
|
<div id="inboxDrawer" class="am-drawer" hidden>
|
||||||
|
<div class="am-drawer-mask" id="inboxMask"></div>
|
||||||
|
<div class="am-drawer-panel">
|
||||||
|
<div class="am-drawer-head">
|
||||||
|
<div><b>收件箱</b><div class="am-drawer-addr" id="inboxAddr"></div></div>
|
||||||
|
<div class="am-drawer-head-actions">
|
||||||
|
<button id="inboxRefresh" class="secondary small" type="button">刷新</button>
|
||||||
|
<button id="inboxClose" class="am-modal-close" type="button" aria-label="关闭">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="am-drawer-body" id="inboxBody"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 拿 RT 进度面板 -->
|
||||||
|
<div id="rtPanel" class="am-rt-panel" hidden>
|
||||||
|
<div class="am-rt-head">
|
||||||
|
<b>批量拿 RT</b>
|
||||||
|
<span id="rtSummary" class="am-rt-summary"></span>
|
||||||
|
<button id="rtPanelClose" class="am-modal-close" type="button" aria-label="关闭">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="am-rt-logs" id="rtLogs"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 通用确认弹窗(替代原生 confirm/alert,主题化) -->
|
||||||
|
<div id="confirmModal" class="am-modal" hidden>
|
||||||
|
<div class="am-modal-dialog am-modal-confirm">
|
||||||
|
<div class="am-modal-head"><b id="confirmTitle">确认操作</b><button id="confirmCloseX" class="am-modal-close" type="button" aria-label="关闭">×</button></div>
|
||||||
|
<p class="hint" id="confirmMsg"></p>
|
||||||
|
<input id="confirmInput" class="am-prompt-input" type="text" autocomplete="off" spellcheck="false" hidden>
|
||||||
|
<div class="am-modal-actions">
|
||||||
|
<button id="confirmOk" type="button">确定</button>
|
||||||
|
<button id="confirmCancel" class="secondary" type="button">取消</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 邮箱编辑弹窗 -->
|
||||||
|
<div id="mbEditModal" class="am-modal" hidden>
|
||||||
|
<div class="am-modal-dialog am-modal-confirm">
|
||||||
|
<div class="am-modal-head"><b>编辑邮箱</b><button id="mbEditCloseX" class="am-modal-close" type="button" aria-label="关闭">×</button></div>
|
||||||
|
<div class="am-mb-edit-field"><span class="am-mb-edit-lbl">邮箱</span><div id="mbEditEmail" class="am-mb-edit-email"></div></div>
|
||||||
|
<div class="am-mb-edit-field"><span class="am-mb-edit-lbl">凭证(专用密码 / 取件 API 链接)</span>
|
||||||
|
<input id="mbEditCred" class="am-prompt-input" type="text" autocomplete="off" spellcheck="false" placeholder="专用密码 或 https://取件API…"></div>
|
||||||
|
<label class="am-set-check am-mb-edit-used"><input type="checkbox" id="mbEditUsed"> 已使用</label>
|
||||||
|
<div class="am-modal-actions">
|
||||||
|
<button id="mbEditSave" type="button">保存</button>
|
||||||
|
<button id="mbEditCancel" class="secondary" type="button">取消</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 协议注册:设置弹窗 -->
|
||||||
|
<div id="regModal" class="am-modal" hidden>
|
||||||
|
<div class="am-modal-dialog">
|
||||||
|
<div class="am-modal-head"><b>账号注册</b><button id="regCloseX" class="am-modal-close" type="button" aria-label="关闭">×</button></div>
|
||||||
|
<p class="hint">使用「设置」中已配置的代理池、接码、邮箱池,对<b>未使用</b>邮箱注册。任务后台运行,切换页面不会中断。</p>
|
||||||
|
<div class="am-set-grid">
|
||||||
|
<label>注册方式
|
||||||
|
<select id="regMode">
|
||||||
|
<option value="protocol">协议注册(HTTP 直连)</option>
|
||||||
|
<option value="browser">浏览器注册(CloakBrowser)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label id="regHeadlessWrap" hidden>浏览器模式
|
||||||
|
<select id="regHeadless">
|
||||||
|
<option value="1">无头(后台运行)</option>
|
||||||
|
<option value="0">有头(显示窗口)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>本次注册数量<input type="number" id="regCount" min="1" value="10"></label>
|
||||||
|
<label>线程数<input type="number" id="regThreads" min="1" max="20" value="3"></label>
|
||||||
|
</div>
|
||||||
|
<div class="reg-avail">未使用邮箱剩余:<b id="regUnused">…</b> 个</div>
|
||||||
|
<div class="am-modal-actions">
|
||||||
|
<button id="regStart" type="button">开始注册</button>
|
||||||
|
<button id="regCancel" class="secondary" type="button">取消</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 协议注册:右下角进度浮层 -->
|
||||||
|
<div id="regPanel" class="reg-panel" hidden>
|
||||||
|
<div class="reg-panel-head" id="regPanelHead">
|
||||||
|
<b class="reg-panel-title">账号注册</b>
|
||||||
|
<span class="reg-panel-stat" id="regPanelStat"></span>
|
||||||
|
<span class="reg-panel-tools">
|
||||||
|
<button id="regPanelClear" class="reg-panel-btn" type="button" title="清空日志">清空</button>
|
||||||
|
<button id="regPanelMin" class="reg-panel-btn" type="button" title="最小化">—</button>
|
||||||
|
<button id="regPanelStop" class="reg-panel-btn" type="button" title="停止任务">停止</button>
|
||||||
|
<button id="regPanelClose" class="reg-panel-btn" type="button" title="关闭" hidden>×</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="reg-panel-body">
|
||||||
|
<div class="reg-progress"><div class="reg-progress-bar" id="regProgressBar"></div></div>
|
||||||
|
<div class="reg-logs" id="regLogs"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="amToast" class="am-toast" role="status" aria-live="polite"></div>
|
||||||
|
|
||||||
|
<script src="{{ url_for('static', filename='account-manager.js', v='24') }}"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>{{ embed_title }}</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='app-shell.css', v='13') }}">
|
||||||
|
<style>
|
||||||
|
/* 右侧内容区占满高度,内嵌 iframe 铺满 */
|
||||||
|
.main { height: 100vh; overflow: hidden; }
|
||||||
|
.embed-content { flex: 1; min-height: 0; padding: 0; position: relative; background: var(--bg); }
|
||||||
|
.embed-frame { width: 100%; height: 100%; border: 0; display: block; background: #fff; }
|
||||||
|
.embed-fallback {
|
||||||
|
position: absolute; inset: 0; display: none;
|
||||||
|
flex-direction: column; align-items: center; justify-content: center; gap: 12px;
|
||||||
|
text-align: center; color: var(--ink-500); padding: 24px;
|
||||||
|
}
|
||||||
|
.embed-fallback b { color: var(--ink-900); font-size: 16px; }
|
||||||
|
.embed-fallback .btn { text-decoration: none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="layout">
|
||||||
|
{% include '_sidebar.html' %}
|
||||||
|
|
||||||
|
<div class="main">
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="topbar-title">{{ embed_title }}</div>
|
||||||
|
<div class="topbar-actions">
|
||||||
|
<a class="secondary small btn" href="{{ embed_url }}" target="_blank" rel="noopener">新标签打开 ↗</a>
|
||||||
|
<span class="dot"></span>线上服务
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="embed-content">
|
||||||
|
<iframe class="embed-frame" src="{{ embed_url }}" title="{{ embed_title }}"
|
||||||
|
referrerpolicy="no-referrer-when-downgrade"
|
||||||
|
allow="clipboard-read; clipboard-write"></iframe>
|
||||||
|
<div class="embed-fallback" id="embedFallback">
|
||||||
|
<b>该服务暂时无法在此页内嵌显示</b>
|
||||||
|
<span>可能因目标站点的安全策略(X-Frame-Options / CSP)或自签名证书导致。</span>
|
||||||
|
<a class="btn" href="{{ embed_url }}" target="_blank" rel="noopener">在新标签页打开 {{ embed_title }} ↗</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user