diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..04f2572
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,27 @@
+.git/
+.venv/
+.env
+.env.*
+!.env.example
+!.env.docker.example
+.claude/
+.codex/
+.codex_tmp/
+
+__pycache__/
+*.py[cod]
+*.pyd
+.pytest_cache/
+.mypy_cache/
+.ruff_cache/
+
+data/
+logs/
+
+node_modules/
+web/node_modules/
+web/dist/
+web/.vite/
+
+Dockerfile
+docker-compose*.yml
diff --git a/.env.docker.example b/.env.docker.example
new file mode 100644
index 0000000..0813fa2
--- /dev/null
+++ b/.env.docker.example
@@ -0,0 +1,20 @@
+APP_PORT=8787
+BIGMODEL_API_BASE=https://www.bigmodel.cn/api
+BIGMODEL_ORIGIN=https://www.bigmodel.cn
+BIGMODEL_REFERER=https://www.bigmodel.cn/glm-coding
+BROWSER_IMPERSONATE=chrome124
+BOOTSTRAP_FINGERPRINT_MAX_RETRIES=99
+REQUEST_TIMEOUT_SECONDS=20
+DEFAULT_LANGUAGE=zh-CN
+TENCENT_CAPTCHA_DOMAIN=https://turing.captcha.qcloud.com
+TENCENT_CAPTCHA_AID=196026326
+TENCENT_CAPTCHA_ENTRY_URL=https://www.bigmodel.cn/glm-coding
+TENCENT_CAPTCHA_MAX_RETRIES=3
+TENCENT_CAPTCHA_MIN_CONFIDENCE=0.55
+TENCENT_OCR_ENABLED=1
+TENCENT_OCR_INCLUDE_DEBUG=0
+TENCENT_OCR_WORKERS=4
+TENCENT_OCR_TIMEOUT_SECONDS=6
+TENCENT_OCR_IDLE_SHRINK_SECONDS=60
+RUNTIME_LOG_LEVEL=INFO
+RUNTIME_LOG_RETENTION_DAYS=7
diff --git a/.env.example b/.env.example
index b1c37d5..9e7717a 100644
--- a/.env.example
+++ b/.env.example
@@ -4,7 +4,8 @@ DATA_DIR=data
BIGMODEL_API_BASE=https://www.bigmodel.cn/api
BIGMODEL_ORIGIN=https://www.bigmodel.cn
BIGMODEL_REFERER=https://www.bigmodel.cn/glm-coding
-BROWSER_IMPERSONATE=chrome124
+BROWSER_IMPERSONATE=chrome146
+BOOTSTRAP_FINGERPRINT_MAX_RETRIES=99
REQUEST_TIMEOUT_SECONDS=20
DEFAULT_LANGUAGE=zh-CN
TENCENT_CAPTCHA_DOMAIN=https://turing.captcha.qcloud.com
@@ -17,5 +18,6 @@ TENCENT_OCR_ENABLED=1
TENCENT_OCR_INCLUDE_DEBUG=0
TENCENT_OCR_WORKERS=4
TENCENT_OCR_TIMEOUT_SECONDS=6
+TENCENT_OCR_IDLE_SHRINK_SECONDS=60
RUNTIME_LOG_LEVEL=INFO
RUNTIME_LOG_RETENTION_DAYS=7
diff --git a/.gitignore b/.gitignore
index 0cb1150..c64ad37 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,4 +14,4 @@ data/sessions/*.json
data/tdc_cache/
node_modules/
-
+web/dist/
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..1c1193b
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,44 @@
+FROM node:20-bookworm-slim AS web-builder
+
+WORKDIR /build/web
+
+COPY web/package.json web/package-lock.json ./
+RUN npm ci
+
+COPY web/ ./
+RUN npm run build
+
+
+FROM python:3.12-slim-bookworm AS runtime
+
+ENV PYTHONDONTWRITEBYTECODE=1 \
+ PYTHONUNBUFFERED=1 \
+ PIP_NO_CACHE_DIR=1 \
+ DATA_DIR=/app/data \
+ XDG_CACHE_HOME=/app/data/cache \
+ TENCENT_CAPTCHA_NODE=node
+
+WORKDIR /app
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends \
+ ca-certificates \
+ libglib2.0-0 \
+ libgl1 \
+ libgomp1 \
+ nodejs \
+ npm \
+ && rm -rf /var/lib/apt/lists/*
+
+COPY requirements.txt ./
+RUN python -m pip install --upgrade pip \
+ && python -m pip install -r requirements.txt
+
+COPY app/ ./app/
+COPY --from=web-builder /build/web/dist ./web/dist
+
+RUN mkdir -p /app/data
+
+EXPOSE 8787
+
+CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${APP_PORT:-8787}"]
diff --git a/README.md b/README.md
index b7988c1..2d519cd 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# GLM Desk
+# GLM Desk
`GLM Desk` 是一个本地运行的 `GLM Coding` 支付运营后台,用来管理多账号导入、套餐同步、自动验证码链路、预览下单、签单出二维码,以及定时启动任务。
@@ -62,6 +62,87 @@
- `APP_HOST=127.0.0.1`
- `APP_PORT=8787`
+## Docker / 1Panel 部署
+
+项目已提供 Docker 单容器部署配置,适合在 `1Panel` 的 Compose 编排里直接使用。
+
+### 1Panel 推荐方式
+
+1. 在服务器上准备项目目录,例如 `/opt/glm-desk`
+2. 上传或拉取本项目代码到该目录
+3. 在 `1Panel` 中进入 `容器` / `Compose`,选择项目目录里的 `docker-compose.yml`
+4. 首次启动选择构建镜像
+5. 启动后访问 `http://服务器IP:8787`
+
+默认 Compose 会做这些事:
+
+- 构建 Vue 前端并复制到镜像内的 `web/dist`
+- 启动 FastAPI 服务并监听 `0.0.0.0:8787`
+- 安装运行 TDC VM 所需的 `node`
+- 安装 OCR 所需的 Linux 系统库
+- 挂载 `./data:/app/data` 保存账号、会话、日志、二维码任务和 TDC 缓存
+
+### Docker 环境变量
+
+Docker 部署可以参考 `.env.docker.example`。
+
+如果使用 `1Panel` 的 Compose 页面,常用配置直接在环境变量区域填写即可:
+
+```env
+APP_PORT=8787
+TENCENT_OCR_WORKERS=4
+TENCENT_OCR_IDLE_SHRINK_SECONDS=60
+BOOTSTRAP_FINGERPRINT_MAX_RETRIES=99
+RUNTIME_LOG_RETENTION_DAYS=7
+```
+
+说明:
+
+- Docker 内部固定使用 `DATA_DIR=/app/data`
+- Docker 内部固定使用 `APP_HOST=0.0.0.0`
+- Docker 内部固定使用 `TENCENT_CAPTCHA_NODE=node`
+- `TENCENT_OCR_WORKERS` 是 OCR 最大并发上限,4 核 24G 机器可以设为 `4`
+- `TENCENT_OCR_IDLE_SHRINK_SECONDS` 控制 OCR 空闲多久后回收多余 worker,默认 `60` 秒,最少保留 `1` 个热 worker
+
+### 命令行启动
+
+如果不用 1Panel,也可以直接运行:
+
+```bash
+docker compose up -d --build
+```
+
+查看日志:
+
+```bash
+docker compose logs -f glm-desk
+```
+
+停止服务:
+
+```bash
+docker compose down
+```
+
+### Docker 数据持久化
+
+Compose 默认挂载:
+
+```text
+./data:/app/data
+```
+
+这个目录必须保留,里面包含:
+
+- `accounts.json`
+- `tasks.json`
+- `sessions/`
+- `logs/runtime/`
+- `tdc_cache/`
+- `cache/`
+
+别把 `data` 当临时目录删了,不然账号和运行记录就没了。
+
## 启动进程与 OCR Worker 说明
本项目本地默认通过 [start.bat](E:/开源项目/glmDesk/start.bat) 启动:
@@ -79,18 +160,17 @@
- 项目没有配置 `uvicorn --workers`,所以 Web 服务本身不是多 worker 部署
- OCR 并发是独立进程池,不是 `uvicorn` worker
- 多账号调度是单应用进程里多线程拉任务,真正重 CPU 的 OCR 再交给 OCR 进程池
-- 启动预热阶段现在只主动预热 `1` 个 OCR worker,避免首次下载 RapidOCR 模型时多个进程同时抢同一个 `.onnx` 文件
-- 真正运行时 OCR 并发上限仍然由 `TENCENT_OCR_WORKERS` 控制
+- 启动预热阶段会先预热基础 OCR worker,避免首次下载 RapidOCR 模型时多个进程同时抢同一个 `.onnx` 文件`n- 支付链路启动前会按前端 `Preview 并发` 配置和当前活跃任务需求继续预热`n- 真正运行时 OCR 并发上限由 `TENCENT_OCR_WORKERS` 控制`n- 当多个账号或同账号并发子任务同时进入验证码 OCR 阶段时,进程池会按活跃需求扩到上限`n- 当 OCR 空闲超过 `TENCENT_OCR_IDLE_SHRINK_SECONDS` 后,会自动回收多余 OCR 进程并重新保留 `1` 个热 worker
-`TENCENT_OCR_WORKERS` 默认值不是写死 `4`,而是按 CPU 自动算:
+`TENCENT_OCR_WORKERS` 是系统 OCR worker 最大数量,不配置时默认 `4`:
-- `max(1, min(4, os.cpu_count() or 1))`
+- `TENCENT_OCR_WORKERS=4`
举例:
-- `1` 核机器默认 `1`
-- `2` 核机器默认 `2`
-- `8` 核机器默认 `4`
+- `Preview 并发=4` 且 `TENCENT_OCR_WORKERS=4`,最多预热 4 个 OCR worker
+- 两个账号同时运行,账号 A `Preview 并发=3`,账号 B `Preview 并发=1`,活跃 OCR 需求为 `4`,最多预热 4 个 OCR worker
+- 如果活跃 OCR 需求为 `8`,但 `TENCENT_OCR_WORKERS=4`,仍然最多只跑 4 个 OCR worker,其余 OCR 请求排队
如果你想强制单路 OCR,直接把:
@@ -121,7 +201,7 @@
- 账号备注
- 购买模式:`新购 / 升级`
-- 当前账号指纹伪装:`chrome / edge / firefox`
+- 当前账号指纹 profile:例如 `chrome146 / chrome145 / edge146 / firefox149`
- 套餐下拉选择器
- 定时启动配置
- 账号状态
@@ -158,7 +238,13 @@
### 6. 同步并换指纹
-点击 `同步并换指纹` 后,会先给该账号分配一个新的账号级伪装指纹,再重新同步账号上下文和套餐。
+点击 `同步并换指纹` 后,会按“换指纹 -> 同步账号上下文 -> 同步套餐”的顺序执行。
+
+如果同步失败,后端会继续换下一个指纹并重试,直到同步成功或达到最大重试次数。
+
+默认最大重试次数:
+
+- `BOOTSTRAP_FINGERPRINT_MAX_RETRIES=99`
这适合在上游风控、链路异常、套餐状态异常时主动切换一套新的网络指纹继续尝试。
@@ -258,14 +344,23 @@
`GLM Desk` 当前不是完整浏览器驱动,而是后端 HTTP 请求链路,所以做的是账号级稳定伪装:
-- 每个账号首次导入时随机分配一个 `browser_impersonate`
-- 候选值为:`chrome / edge / firefox`
-- 后续这个账号的 BigModel、腾讯验证码、TDC 请求都复用同一个伪装
+- 每个账号首次导入时随机分配一个具体版本的 `browser_impersonate`
+- 当前随机候选值为:`chrome146 / chrome145 / edge146 / firefox149`
+- 随机分配带权重,默认更偏向 Chrome,少量分配 Edge / Firefox,贴近真实桌面浏览器分布
+- 每个 profile 同时绑定 `curl-cffi` TLS/HTTP 指纹和匹配的 Windows 桌面 `User-Agent`
+- 后续这个账号的 BigModel、腾讯验证码、TDC 请求都复用同一个 profile
这样做的目的:
- 保持同一账号整条链路的指纹一致
- 避免“每次请求随机换脸”导致风控更容易命中
+- 避免新版 UA 搭配旧版 TLS 指纹这种很假的组合
+
+浏览器版本说明:
+
+- 2026-04-26 查询桌面浏览器版本占有率后,Chrome 侧优先使用高占比的 `145/146`
+- Edge 侧使用 `edge146` 对应的 Windows UA;由于 `curl-cffi 0.15.0` 缺少新版 Edge TLS profile,transport 暂时复用同代 Chrome 146 指纹
+- Firefox 侧使用 `firefox149` 对应的 Windows UA;由于 `curl-cffi 0.15.0` 支持的最新 Firefox transport 为 `firefox147`,底层 TLS 暂时落到 `firefox147`
## 本地数据目录
@@ -379,7 +474,8 @@ DATA_DIR=data
BIGMODEL_API_BASE=https://www.bigmodel.cn/api
BIGMODEL_ORIGIN=https://www.bigmodel.cn
BIGMODEL_REFERER=https://www.bigmodel.cn/glm-coding
-BROWSER_IMPERSONATE=chrome124
+BROWSER_IMPERSONATE=chrome146
+BOOTSTRAP_FINGERPRINT_MAX_RETRIES=99
REQUEST_TIMEOUT_SECONDS=20
DEFAULT_LANGUAGE=zh-CN
TENCENT_CAPTCHA_DOMAIN=https://turing.captcha.qcloud.com
@@ -392,6 +488,7 @@ TENCENT_OCR_ENABLED=1
TENCENT_OCR_INCLUDE_DEBUG=0
TENCENT_OCR_WORKERS=4
TENCENT_OCR_TIMEOUT_SECONDS=6
+TENCENT_OCR_IDLE_SHRINK_SECONDS=60
RUNTIME_LOG_LEVEL=INFO
RUNTIME_LOG_RETENTION_DAYS=7
```
@@ -411,7 +508,8 @@ RUNTIME_LOG_RETENTION_DAYS=7
| `BIGMODEL_API_BASE` | `https://www.bigmodel.cn/api` | BigModel API 根地址 |
| `BIGMODEL_ORIGIN` | `https://www.bigmodel.cn` | BigModel 请求头 `Origin` 默认值 |
| `BIGMODEL_REFERER` | `https://www.bigmodel.cn/glm-coding` | BigModel 请求头 `Referer` 默认值 |
-| `BROWSER_IMPERSONATE` | `chrome124` | `curl-cffi` 的全局兜底 `impersonate` 值;账号实际请求优先用账号自己的随机 `browser_impersonate` |
+| `BROWSER_IMPERSONATE` | `chrome146` | 全局兜底浏览器指纹 profile;账号实际请求优先用账号自己的随机 `browser_impersonate` |
+| `BOOTSTRAP_FINGERPRINT_MAX_RETRIES` | `99` | 点击“同步并换指纹”时的最大尝试次数;每轮先换一个账号级指纹,再完整同步上下文和套餐,失败才进入下一轮 |
| `REQUEST_TIMEOUT_SECONDS` | `20` | 上游 HTTP 请求超时时间,单位秒 |
| `DEFAULT_LANGUAGE` | `zh-CN` | 默认请求语言,会写入 `Accept-Language` 和 `Set-Language` |
| `TENCENT_CAPTCHA_DOMAIN` | `https://turing.captcha.qcloud.com` | 腾讯验证码域名 |
@@ -422,17 +520,20 @@ RUNTIME_LOG_RETENTION_DAYS=7
| `TENCENT_CAPTCHA_NODE` | `node` | 跑腾讯 TDC VM 时使用的 Node.js 命令 |
| `TENCENT_OCR_ENABLED` | `1` | 是否启用本地 OCR;关闭后自动识别不可用 |
| `TENCENT_OCR_INCLUDE_DEBUG` | `0` | 是否在 OCR 结果中附带调试图像 base64,开启后日志和响应会更重 |
-| `TENCENT_OCR_WORKERS` | 自动计算,最大不超过 `4` | OCR 进程池并发上限;建议普通机器配 `1-2`,高配机器可配 `3-4` |
+| `TENCENT_OCR_WORKERS` | `4` | 系统 OCR worker 最大数量;支付链路启动前会按活跃 `Preview 并发` 需求预热,多账号和同账号并发子任务都会计入,但不会超过这个上限 |
| `TENCENT_OCR_TIMEOUT_SECONDS` | `6` | 单次 OCR worker 超时秒数 |
+| `TENCENT_OCR_IDLE_SHRINK_SECONDS` | `60` | OCR 空闲多少秒后回收多余 worker,最少保留 `1` 个热 worker |
| `RUNTIME_LOG_LEVEL` | `INFO` | 正式运行日志级别 |
| `RUNTIME_LOG_RETENTION_DAYS` | `7` | `app.log` 按天轮转保留天数 |
补充说明:
-- `BROWSER_IMPERSONATE` 现在主要是全局兜底值和 transport 展示值
+- `BROWSER_IMPERSONATE` 现在主要是全局兜底 profile 和 transport 展示值
- 真正运行时优先用账号自己的 `browser_impersonate`
-- 账号级 `browser_impersonate` 在首次导入账号时随机分配为 `chrome / edge / firefox`
-- 如果你把 `TENCENT_OCR_WORKERS` 配得太高,OCR 并发会更猛,但内存占用也会跟着往上窜,别一上来就梭哈
+- 账号级 `browser_impersonate` 在首次导入账号时随机分配为 `chrome146 / chrome145 / edge146 / firefox149`
+- 历史账号里的 `chrome / edge / firefox / chrome124 / chrome136 / firefox137 / firefox147` 会自动映射到当前支持的具体 profile
+- `BOOTSTRAP_FINGERPRINT_MAX_RETRIES` 小于 `1` 时会自动按 `1` 处理,避免配置错误导致完全不尝试
+- 如果你把 `TENCENT_OCR_WORKERS` 配得太高,OCR 并发峰值会更猛,但空闲后会按 `TENCENT_OCR_IDLE_SHRINK_SECONDS` 回收到 `1` 个热 worker
## 已知说明
@@ -445,3 +546,4 @@ RUNTIME_LOG_RETENTION_DAYS=7
- `glm-coding-new-purchase-field-map.md`
- `glm-coding-new-purchase-detailed-spec.md`
- `glm-coding-tencent-captcha-verify.md`
+
diff --git a/app/browser_profiles.py b/app/browser_profiles.py
index 16e14d4..0ce4c88 100644
--- a/app/browser_profiles.py
+++ b/app/browser_profiles.py
@@ -1,54 +1,128 @@
-"""Stable per-account browser impersonation helpers."""
+"""Stable per-account browser fingerprint profile helpers."""
from __future__ import annotations
+from dataclasses import dataclass
import secrets
-AVAILABLE_BROWSER_IMPERSONATIONS: tuple[str, ...] = (
- "chrome",
- "edge",
- "firefox",
-)
-DEFAULT_BROWSER_IMPERSONATE = "chrome"
+@dataclass(frozen=True)
+class BrowserProfile:
+ """A coherent browser profile: stored id, curl-cffi TLS profile and UA."""
-DEFAULT_USER_AGENTS: dict[str, str] = {
- "chrome": (
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
- "AppleWebKit/537.36 (KHTML, like Gecko) "
- "Chrome/136.0.0.0 Safari/537.36"
+ profile_id: str
+ family: str
+ version: str
+ impersonate: str
+ user_agent: str
+ random_weight: int = 1
+ enabled_for_random: bool = True
+
+
+# Version choices were aligned with desktop browser-version share checked on
+# 2026-04-26 and limited by curl-cffi 0.15.0 supported impersonation targets.
+WINDOWS_CHROME_146_UA = (
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
+ "Chrome/146.0.0.0 Safari/537.36"
+)
+WINDOWS_CHROME_145_UA = (
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
+ "Chrome/145.0.0.0 Safari/537.36"
+)
+WINDOWS_FIREFOX_149_UA = (
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:149.0) "
+ "Gecko/20100101 Firefox/149.0"
+)
+
+AVAILABLE_BROWSER_PROFILES: dict[str, BrowserProfile] = {
+ "chrome146": BrowserProfile(
+ profile_id="chrome146",
+ family="chrome",
+ version="146",
+ impersonate="chrome146",
+ user_agent=WINDOWS_CHROME_146_UA,
+ random_weight=36,
+ ),
+ "chrome145": BrowserProfile(
+ profile_id="chrome145",
+ family="chrome",
+ version="145",
+ impersonate="chrome145",
+ user_agent=WINDOWS_CHROME_145_UA,
+ random_weight=2,
),
- "edge": (
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
- "AppleWebKit/537.36 (KHTML, like Gecko) "
- "Chrome/136.0.0.0 Safari/537.36 Edg/136.0.0.0"
+ "firefox149": BrowserProfile(
+ profile_id="firefox149",
+ family="firefox",
+ version="149",
+ impersonate="firefox147",
+ user_agent=WINDOWS_FIREFOX_149_UA,
+ random_weight=5,
),
- "firefox": (
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:137.0) "
- "Gecko/20100101 Firefox/137.0"
+ # curl-cffi 0.15.0 only exposes edge101. Keep the UI-facing Edge profile
+ # explicit, but route transport to chrome146 to avoid pairing modern Edge UA
+ # with a very old Edge TLS fingerprint.
+ "edge146": BrowserProfile(
+ profile_id="edge146",
+ family="edge",
+ version="146",
+ impersonate="chrome146",
+ user_agent=(WINDOWS_CHROME_146_UA + " Edg/146.0.0.0"),
+ random_weight=4,
),
}
+DEFAULT_BROWSER_PROFILE_ID = "chrome146"
+DEFAULT_BROWSER_IMPERSONATE = DEFAULT_BROWSER_PROFILE_ID
+AVAILABLE_BROWSER_IMPERSONATIONS: tuple[str, ...] = tuple(AVAILABLE_BROWSER_PROFILES)
+
+LEGACY_BROWSER_PROFILE_ALIASES: dict[str, str] = {
+ "chrome": "chrome146",
+ "chrome124": "chrome146",
+ "chrome136": "chrome146",
+ "edge": "edge146",
+ "edge101": "edge146",
+ "firefox": "firefox149",
+ "firefox137": "firefox149",
+ "firefox147": "firefox149",
+}
+
+DEFAULT_USER_AGENTS: dict[str, str] = {
+ profile_id: profile.user_agent for profile_id, profile in AVAILABLE_BROWSER_PROFILES.items()
+}
+
def random_browser_impersonate() -> str:
- """Pick a browser impersonation for a new account."""
- return secrets.choice(AVAILABLE_BROWSER_IMPERSONATIONS)
+ """Pick a realistic browser fingerprint profile for a new account."""
+ candidates = [
+ profile.profile_id
+ for profile in AVAILABLE_BROWSER_PROFILES.values()
+ if profile.enabled_for_random
+ for _ in range(max(1, profile.random_weight))
+ ]
+ return secrets.choice(candidates)
def resolve_browser_impersonate(raw: str | None) -> str:
- """Normalize browser impersonation to a supported value."""
- normalized = (raw or "").strip().lower()
- if normalized in AVAILABLE_BROWSER_IMPERSONATIONS:
+ """Normalize stored profile id to a supported browser fingerprint profile."""
+ normalized = (raw or "").strip().lower().replace("_", "").replace("-", "")
+ if normalized in AVAILABLE_BROWSER_PROFILES:
return normalized
- return DEFAULT_BROWSER_IMPERSONATE
+ return LEGACY_BROWSER_PROFILE_ALIASES.get(normalized, DEFAULT_BROWSER_PROFILE_ID)
+
+
+def resolve_transport_impersonate(browser_impersonate: str | None) -> str:
+ """Return the curl-cffi impersonate value for a stored profile id."""
+ profile_id = resolve_browser_impersonate(browser_impersonate)
+ return AVAILABLE_BROWSER_PROFILES[profile_id].impersonate
def resolve_user_agent(user_agent: str | None, browser_impersonate: str | None) -> str:
- """Return explicit UA or a stable default matching the impersonation."""
+ """Return explicit UA or a stable default matching the fingerprint profile."""
explicit = (user_agent or "").strip()
if explicit:
return explicit
- return DEFAULT_USER_AGENTS.get(
- resolve_browser_impersonate(browser_impersonate),
- DEFAULT_USER_AGENTS[DEFAULT_BROWSER_IMPERSONATE],
- )
+ profile_id = resolve_browser_impersonate(browser_impersonate)
+ return AVAILABLE_BROWSER_PROFILES[profile_id].user_agent
diff --git a/app/clients/fingerprint_http.py b/app/clients/fingerprint_http.py
index 6bc0727..90f9f5a 100644
--- a/app/clients/fingerprint_http.py
+++ b/app/clients/fingerprint_http.py
@@ -6,6 +6,7 @@
import httpx
+from app.browser_profiles import resolve_transport_impersonate
from app.config import Settings, get_settings
from app.errors import UpstreamRequestError
@@ -160,7 +161,9 @@ def _dispatch(
"params": params,
"timeout": self.settings.request_timeout_seconds,
"allow_redirects": True,
- "impersonate": (browser_impersonate or self.settings.browser_impersonate),
+ "impersonate": resolve_transport_impersonate(
+ browser_impersonate or self.settings.browser_impersonate,
+ ),
}
if json_body is not None:
kwargs["json"] = json_body
diff --git a/app/config.py b/app/config.py
index 30b3c40..1a2bf74 100644
--- a/app/config.py
+++ b/app/config.py
@@ -28,6 +28,7 @@ class Settings:
bigmodel_origin: str
bigmodel_referer: str
browser_impersonate: str
+ bootstrap_fingerprint_max_retries: int
request_timeout_seconds: float
default_language: str
tencent_captcha_domain: str
@@ -40,6 +41,7 @@ class Settings:
tencent_ocr_include_debug: bool
tencent_ocr_workers: int
tencent_ocr_timeout_seconds: int
+ tencent_ocr_idle_shrink_seconds: int
runtime_log_level: str
runtime_log_retention_days: int
@@ -65,7 +67,14 @@ def get_settings() -> Settings:
bigmodel_api_base=os.getenv("BIGMODEL_API_BASE", "https://www.bigmodel.cn/api").rstrip("/"),
bigmodel_origin=os.getenv("BIGMODEL_ORIGIN", "https://www.bigmodel.cn").rstrip("/"),
bigmodel_referer=os.getenv("BIGMODEL_REFERER", "https://www.bigmodel.cn/glm-coding").strip(),
- browser_impersonate=os.getenv("BROWSER_IMPERSONATE", "chrome124").strip() or "chrome124",
+ browser_impersonate=os.getenv("BROWSER_IMPERSONATE", "chrome146").strip() or "chrome146",
+ bootstrap_fingerprint_max_retries=max(
+ 1,
+ _parse_int(
+ os.getenv("BOOTSTRAP_FINGERPRINT_MAX_RETRIES", "99"),
+ field_name="BOOTSTRAP_FINGERPRINT_MAX_RETRIES",
+ ),
+ ),
request_timeout_seconds=_parse_float(
os.getenv("REQUEST_TIMEOUT_SECONDS", "20"),
field_name="REQUEST_TIMEOUT_SECONDS",
@@ -93,13 +102,20 @@ def get_settings() -> Settings:
tencent_ocr_enabled=_parse_bool(os.getenv("TENCENT_OCR_ENABLED", "1")),
tencent_ocr_include_debug=_parse_bool(os.getenv("TENCENT_OCR_INCLUDE_DEBUG", "0")),
tencent_ocr_workers=_parse_int(
- os.getenv("TENCENT_OCR_WORKERS", str(max(1, min(4, os.cpu_count() or 1)))),
+ os.getenv("TENCENT_OCR_WORKERS", "4"),
field_name="TENCENT_OCR_WORKERS",
),
tencent_ocr_timeout_seconds=_parse_int(
os.getenv("TENCENT_OCR_TIMEOUT_SECONDS", "6"),
field_name="TENCENT_OCR_TIMEOUT_SECONDS",
),
+ tencent_ocr_idle_shrink_seconds=max(
+ 1,
+ _parse_int(
+ os.getenv("TENCENT_OCR_IDLE_SHRINK_SECONDS", "60"),
+ field_name="TENCENT_OCR_IDLE_SHRINK_SECONDS",
+ ),
+ ),
runtime_log_level=os.getenv("RUNTIME_LOG_LEVEL", "INFO").strip() or "INFO",
runtime_log_retention_days=_parse_int(
os.getenv("RUNTIME_LOG_RETENTION_DAYS", "7"),
diff --git a/app/main.py b/app/main.py
index 21dcd39..e0a35ee 100644
--- a/app/main.py
+++ b/app/main.py
@@ -3,8 +3,10 @@
from __future__ import annotations
import logging
+from pathlib import Path
from fastapi import FastAPI
+from fastapi.staticfiles import StaticFiles
from app.errors import install_exception_handlers
from app.runtime_logging import configure_logging, get_runtime_log_service
@@ -26,6 +28,9 @@ def create_app() -> FastAPI:
redoc_url="/redoc",
)
install_exception_handlers(app)
+ dist_assets = Path(__file__).resolve().parents[1] / "web" / "dist" / "assets"
+ if dist_assets.exists():
+ app.mount("/assets", StaticFiles(directory=str(dist_assets)), name="spa-assets")
app.include_router(router)
@app.on_event("startup")
diff --git a/app/models.py b/app/models.py
index 3d39e70..d882944 100644
--- a/app/models.py
+++ b/app/models.py
@@ -69,9 +69,12 @@ class AccountRecord(BaseModel):
proxy_url: str = ""
user_agent: str = ""
browser_impersonate: str = ""
+ preview_concurrency: int = 1
schedule_enabled: bool = False
scheduled_start_time: str = ""
last_scheduled_run_at: str | None = None
+ last_scheduled_run_key: str = ""
+ last_manual_run_at: str | None = None
last_schedule_status: str = ""
last_schedule_message: str = ""
account_status: str = "unchecked"
@@ -95,9 +98,12 @@ class PublicAccountRecord(BaseModel):
proxy_url: str = ""
user_agent: str = ""
browser_impersonate: str = ""
+ preview_concurrency: int = 1
schedule_enabled: bool = False
scheduled_start_time: str = ""
last_scheduled_run_at: str | None = None
+ last_scheduled_run_key: str = ""
+ last_manual_run_at: str | None = None
last_schedule_status: str = ""
last_schedule_message: str = ""
account_status: str = "unchecked"
@@ -242,9 +248,20 @@ class AccountPreferencesRequest(BaseModel):
invitation_code: str | None = None
selected_product_id: str | None = None
+ preview_concurrency: int | None = None
schedule_enabled: bool | None = None
scheduled_start_time: str | None = None
+ @field_validator("preview_concurrency")
+ @classmethod
+ def validate_preview_concurrency(cls, value: int | None) -> int | None:
+ if value is None:
+ return None
+ normalized = int(value)
+ if normalized < 1 or normalized > 4:
+ raise ValueError("preview_concurrency 必须在 1 到 4 之间")
+ return normalized
+
@field_validator("scheduled_start_time")
@classmethod
def validate_scheduled_start_time(cls, value: str | None) -> str | None:
diff --git a/app/services/account_state.py b/app/services/account_state.py
index c66f884..eb787c7 100644
--- a/app/services/account_state.py
+++ b/app/services/account_state.py
@@ -10,6 +10,7 @@
from http.cookies import SimpleCookie
from pathlib import Path
from typing import Any
+from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from app.browser_profiles import random_browser_impersonate, resolve_browser_impersonate
from app.config import get_settings
@@ -29,6 +30,13 @@
TOKEN_COOKIE_KEY = "bigmodel_token_production"
DEFAULT_INVITATION_CODE = "XOJGYOGNLN"
DEFAULT_SCHEDULED_START_TIME = "09:59:58"
+DEFAULT_PREVIEW_CONCURRENCY = 1
+MAX_PREVIEW_CONCURRENCY = 4
+
+try:
+ SCHEDULE_TZ = ZoneInfo("Asia/Shanghai")
+except ZoneInfoNotFoundError: # pragma: no cover - Windows without tzdata
+ SCHEDULE_TZ = None
logger = logging.getLogger(__name__)
@@ -107,9 +115,12 @@ def updater(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
proxy_url=request.proxy_url.strip(),
user_agent=request.user_agent.strip(),
browser_impersonate=browser_impersonate,
+ preview_concurrency=_clamp_preview_concurrency(existing.get("preview_concurrency") if existing else 1),
schedule_enabled=bool(existing.get("schedule_enabled")) if existing else False,
scheduled_start_time=str(existing.get("scheduled_start_time") or DEFAULT_SCHEDULED_START_TIME) if existing else DEFAULT_SCHEDULED_START_TIME,
last_scheduled_run_at=existing.get("last_scheduled_run_at") if existing else None,
+ last_scheduled_run_key=str(existing.get("last_scheduled_run_key") or "") if existing else "",
+ last_manual_run_at=existing.get("last_manual_run_at") if existing else None,
last_schedule_status=str(existing.get("last_schedule_status") or "") if existing else "",
last_schedule_message=str(existing.get("last_schedule_message") or "") if existing else "",
account_status=str(existing.get("account_status") or "unchecked") if existing else "unchecked",
@@ -153,9 +164,9 @@ def updater(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
)
return self.to_public_account(account)
- def update_account(self, account: AccountRecord) -> AccountRecord:
- now = utc_now_iso()
- account.updated_at = now
+ def update_account(self, account: AccountRecord, *, touch_updated_at: bool = False) -> AccountRecord:
+ if touch_updated_at:
+ account.updated_at = utc_now_iso()
def updater(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
for index, item in enumerate(records):
@@ -167,6 +178,25 @@ def updater(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
self.accounts_store.update(updater)
return account
+ def touch_account_updated_at(self, account_id: str) -> AccountRecord:
+ updated_account: AccountRecord | None = None
+ now = utc_now_iso()
+
+ def updater(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ nonlocal updated_account
+ for index, item in enumerate(records):
+ if item.get("id") != account_id:
+ continue
+ account = AccountRecord.model_validate(item)
+ account.updated_at = now
+ records[index] = account.model_dump()
+ updated_account = account
+ return records
+ raise NotFoundError("账号不存在", details={"account_id": account_id})
+
+ self.accounts_store.update(updater)
+ return updated_account or self.get_account(account_id)
+
def update_runtime_progress(
self,
account_id: str,
@@ -212,6 +242,8 @@ def rotate_browser_impersonate(self, account_id: str) -> AccountRecord:
def update_preferences(self, account_id: str, request: AccountPreferencesRequest) -> AccountDetailResponse:
account = self.get_account(account_id)
session = self.load_session(account_id)
+ previous_schedule_enabled = account.schedule_enabled
+ previous_scheduled_start_time = account.scheduled_start_time
if request.invitation_code is not None:
account.invitation_code = request.invitation_code.strip() or DEFAULT_INVITATION_CODE
@@ -221,11 +253,51 @@ def update_preferences(self, account_id: str, request: AccountPreferencesRequest
account.scheduled_start_time = request.scheduled_start_time.strip() or DEFAULT_SCHEDULED_START_TIME
if request.selected_product_id is not None:
session.selected_product_id = request.selected_product_id.strip()
-
- self.update_account(account)
+ if request.preview_concurrency is not None:
+ account.preview_concurrency = _clamp_preview_concurrency(request.preview_concurrency)
+ if self._should_skip_today_after_schedule_update(
+ account=account,
+ previous_schedule_enabled=previous_schedule_enabled,
+ previous_scheduled_start_time=previous_scheduled_start_time,
+ request=request,
+ ):
+ current_date, _ = self._current_schedule_date_time()
+ account.last_scheduled_run_key = self._scheduled_run_key(current_date, account.scheduled_start_time)
+
+ self.update_account(account, touch_updated_at=False)
self.save_session(session)
return self.get_account_detail(account_id)
+ def _should_skip_today_after_schedule_update(
+ self,
+ *,
+ account: AccountRecord,
+ previous_schedule_enabled: bool,
+ previous_scheduled_start_time: str,
+ request: AccountPreferencesRequest,
+ ) -> bool:
+ if not account.schedule_enabled or not account.scheduled_start_time:
+ return False
+ schedule_touched = request.schedule_enabled is not None or request.scheduled_start_time is not None
+ if not schedule_touched:
+ return False
+ enabled_now = request.schedule_enabled is True and not previous_schedule_enabled
+ time_changed = (
+ request.scheduled_start_time is not None
+ and account.scheduled_start_time != (previous_scheduled_start_time or "")
+ )
+ if not enabled_now and not time_changed:
+ return False
+ _, current_hms = self._current_schedule_date_time()
+ return account.scheduled_start_time <= current_hms
+
+ def _current_schedule_date_time(self) -> tuple[str, str]:
+ now = datetime.now(SCHEDULE_TZ) if SCHEDULE_TZ is not None else datetime.now().astimezone()
+ return now.strftime("%Y-%m-%d"), now.strftime("%H:%M:%S")
+
+ def _scheduled_run_key(self, current_date: str, scheduled_start_time: str) -> str:
+ return f"{current_date}|{(scheduled_start_time or '').strip()}"
+
def load_session(self, account_id: str) -> AccountSessionState:
path = self._session_path(account_id)
store = JsonFileStore(
@@ -325,10 +397,13 @@ def to_public_account(self, account: AccountRecord) -> PublicAccountRecord:
invitation_code=account.invitation_code,
proxy_url=account.proxy_url,
user_agent=account.user_agent,
- browser_impersonate=account.browser_impersonate,
+ browser_impersonate=resolve_browser_impersonate(account.browser_impersonate),
+ preview_concurrency=account.preview_concurrency,
schedule_enabled=account.schedule_enabled,
scheduled_start_time=account.scheduled_start_time,
last_scheduled_run_at=account.last_scheduled_run_at,
+ last_scheduled_run_key=account.last_scheduled_run_key,
+ last_manual_run_at=account.last_manual_run_at,
last_schedule_status=account.last_schedule_status,
last_schedule_message=account.last_schedule_message,
account_status=account.account_status,
@@ -455,3 +530,11 @@ def mask_secret(value: str) -> str:
if len(normalized) <= 8:
return f"{normalized[:2]}***{normalized[-2:]}"
return f"{normalized[:4]}***{normalized[-4:]}"
+
+
+def _clamp_preview_concurrency(value: Any) -> int:
+ try:
+ normalized = int(value)
+ except (TypeError, ValueError):
+ normalized = DEFAULT_PREVIEW_CONCURRENCY
+ return max(1, min(MAX_PREVIEW_CONCURRENCY, normalized))
diff --git a/app/services/ocr_service.py b/app/services/ocr_service.py
index 5e118d7..55dbd84 100644
--- a/app/services/ocr_service.py
+++ b/app/services/ocr_service.py
@@ -1,4 +1,4 @@
-"""Local OCR wrapper for Tencent click captcha images."""
+"""Local OCR wrapper for Tencent click captcha images."""
from __future__ import annotations
@@ -7,6 +7,7 @@
import logging
import multiprocessing
import os
+import secrets
import threading
import time
from concurrent.futures import ProcessPoolExecutor, TimeoutError as FuturesTimeoutError
@@ -50,7 +51,9 @@ def _worker_initializer() -> None:
def _warmup_worker(index: int) -> dict[str, Any]:
_clear_proxy_env()
_load_adapter_module().get_engine()
- time.sleep(0.1)
+ # Keep warmup tasks alive briefly so ProcessPoolExecutor has a reason to
+ # spawn up to the requested worker count instead of reusing one hot process.
+ time.sleep(1.2)
return {"index": index, "pid": os.getpid()}
@@ -76,10 +79,16 @@ def __init__(self, settings: Settings | None = None) -> None:
self._executor: ProcessPoolExecutor | None = None
self._executor_lock = threading.Lock()
self._bootstrap_lock = threading.Lock()
+ self._capacity_lock = threading.RLock()
+ self._active_jobs = 0
+ self._idle_shrink_timer: threading.Timer | None = None
self._engine_bootstrapped = False
+ self._active_demands: dict[str, int] = {}
+ self._warmed_worker_pids: set[int] = set()
def status_payload(self) -> dict[str, Any]:
missing = self._missing_dependencies()
+ active_demand, warmed_worker_pids = self._capacity_snapshot()
return {
"enabled": self.settings.tencent_ocr_enabled,
"adapter": "local-tenvision-process-pool",
@@ -87,34 +96,89 @@ def status_payload(self) -> dict[str, Any]:
"missing_dependencies": missing,
"include_debug": self.settings.tencent_ocr_include_debug,
"workers": self.settings.tencent_ocr_workers,
+ "max_workers": self.settings.tencent_ocr_workers,
+ "active_jobs": self._active_jobs,
+ "active_demand": active_demand,
+ "idle_shrink_seconds": self.settings.tencent_ocr_idle_shrink_seconds,
+ "processes": self._executor_process_count(),
+ "warmed_workers": len(warmed_worker_pids),
+ "warmed_worker_pids": sorted(warmed_worker_pids),
"timeout_seconds": self.settings.tencent_ocr_timeout_seconds,
"executor_ready": self._executor is not None,
"engine_bootstrapped": self._engine_bootstrapped,
}
- def warmup(self) -> None:
+ def warmup(self, target_workers: int | None = None) -> None:
+ self.ensure_capacity(target_workers or 1)
+
+ def reserve_capacity(self, demand: int) -> dict[str, Any]:
+ normalized_demand = max(1, int(demand or 1))
+ lease_id = secrets.token_hex(8)
+ with self._capacity_lock:
+ self._active_demands[lease_id] = normalized_demand
+ active_demand = sum(self._active_demands.values())
+ capacity = self.ensure_capacity(active_demand)
+ return {
+ **capacity,
+ "lease_id": lease_id,
+ "reserved_demand": normalized_demand,
+ "active_demand": active_demand,
+ }
+
+ def release_capacity(self, lease_id: str) -> dict[str, Any]:
+ if not lease_id:
+ return self.status_payload()
+ with self._capacity_lock:
+ self._active_demands.pop(lease_id, None)
+ active_demand = sum(self._active_demands.values())
+ if active_demand <= 0:
+ with self._executor_lock:
+ if self._active_jobs == 0:
+ self._schedule_idle_shrink_locked()
+ return self.status_payload()
+
+ def ensure_capacity(self, requested_workers: int) -> dict[str, Any]:
if not self.settings.tencent_ocr_enabled:
- return
+ return self.status_payload()
missing = self._missing_dependencies()
if missing:
raise BadRequestError(
"本地 OCR 依赖没装全,先运行 pip install -r requirements.txt,别让发动机缺缸还硬跑。",
details={"missing_dependencies": missing},
)
- self._bootstrap_engine_once()
- executor = self._ensure_executor()
- timeout = max(self.settings.tencent_ocr_timeout_seconds, 1)
- # 首次启动先保证模型在主进程完整落地,再做单 worker 预热,
- # 避免 Windows 下多个进程同时下载/加载同一个 onnx 文件导致文件占用和损坏竞争。
- future = executor.submit(_warmup_worker, 0)
- future.result(timeout=timeout)
+ target_workers = max(1, min(int(requested_workers or 1), max(1, self.settings.tencent_ocr_workers)))
+ self._cancel_idle_shrink_timer()
+ with self._capacity_lock:
+ if len(self._warmed_worker_pids) >= target_workers:
+ return self.status_payload()
+ self._bootstrap_engine_once()
+ executor = self._ensure_executor()
+ timeout = max(self.settings.tencent_ocr_timeout_seconds, 1) + 3
+ try:
+ # Submit a full wave to force ProcessPoolExecutor to spawn the
+ # requested process count instead of reusing one hot worker.
+ futures = [executor.submit(_warmup_worker, index) for index in range(target_workers)]
+ for future in futures:
+ result = future.result(timeout=timeout)
+ pid = int(result.get("pid") or 0)
+ if pid:
+ self._warmed_worker_pids.add(pid)
+ except BrokenProcessPool:
+ self.shutdown()
+ raise
+ return self.status_payload()
def shutdown(self) -> None:
with self._executor_lock:
+ self._cancel_idle_shrink_timer_locked()
if self._executor is None:
return
self._executor.shutdown(wait=False, cancel_futures=True)
self._executor = None
+ self._active_jobs = 0
+ self._warmed_worker_pids.clear()
+ with self._capacity_lock:
+ self._active_demands.clear()
def analyze_captcha_image(self, image_bytes: bytes, *, prompt_text: str) -> dict[str, Any]:
if not self.settings.tencent_ocr_enabled:
@@ -144,6 +208,7 @@ def _run_worker(self, image_bytes: bytes, prompt_text: str) -> dict[str, Any]:
payload_prompt = prompt_text or ""
self._bootstrap_engine_once()
for attempt in range(1, 3):
+ self._begin_ocr_job()
executor = self._ensure_executor()
future = executor.submit(
_worker_analyze,
@@ -152,7 +217,12 @@ def _run_worker(self, image_bytes: bytes, prompt_text: str) -> dict[str, Any]:
self.settings.tencent_ocr_include_debug,
)
try:
- return future.result(timeout=timeout)
+ result = future.result(timeout=timeout)
+ pid = int(result.get("_worker_pid") or 0)
+ if pid:
+ with self._capacity_lock:
+ self._warmed_worker_pids.add(pid)
+ return result
except FuturesTimeoutError:
future.cancel()
raise
@@ -162,6 +232,8 @@ def _run_worker(self, image_bytes: bytes, prompt_text: str) -> dict[str, Any]:
raise
except Exception:
raise
+ finally:
+ self._finish_ocr_job()
raise RuntimeError("OCR worker 未返回结果")
def _bootstrap_engine_once(self) -> None:
@@ -191,6 +263,88 @@ def _ensure_executor(self) -> ProcessPoolExecutor:
)
return self._executor
+ def _begin_ocr_job(self) -> None:
+ with self._executor_lock:
+ self._active_jobs += 1
+ self._cancel_idle_shrink_timer_locked()
+
+ def _finish_ocr_job(self) -> None:
+ with self._executor_lock:
+ self._active_jobs = max(0, self._active_jobs - 1)
+ with self._capacity_lock:
+ active_demand = sum(self._active_demands.values())
+ if self._active_jobs == 0 and active_demand <= 0:
+ self._schedule_idle_shrink_locked()
+
+ def _schedule_idle_shrink_locked(self) -> None:
+ if self._executor is None or self.settings.tencent_ocr_workers <= 1:
+ return
+ self._cancel_idle_shrink_timer_locked()
+ timer = threading.Timer(
+ max(1, self.settings.tencent_ocr_idle_shrink_seconds),
+ self._shrink_idle_executor,
+ )
+ timer.daemon = True
+ self._idle_shrink_timer = timer
+ timer.start()
+
+ def _cancel_idle_shrink_timer(self) -> None:
+ with self._executor_lock:
+ self._cancel_idle_shrink_timer_locked()
+
+ def _cancel_idle_shrink_timer_locked(self) -> None:
+ if self._idle_shrink_timer is None:
+ return
+ self._idle_shrink_timer.cancel()
+ self._idle_shrink_timer = None
+
+ def _shrink_idle_executor(self) -> None:
+ with self._executor_lock:
+ self._idle_shrink_timer = None
+ with self._capacity_lock:
+ active_demand = sum(self._active_demands.values())
+ if self._executor is None or self._active_jobs > 0 or active_demand > 0:
+ return
+ process_count = self._executor_process_count_locked()
+ if process_count <= 1:
+ return
+ logger.info("OCR worker pool idle; shrinking from %s processes to 1 warm worker", process_count)
+ old_executor = self._executor
+ old_executor.shutdown(wait=False, cancel_futures=True)
+ mp_context = multiprocessing.get_context("spawn")
+ self._executor = ProcessPoolExecutor(
+ max_workers=max(1, self.settings.tencent_ocr_workers),
+ mp_context=mp_context,
+ initializer=_worker_initializer,
+ )
+ self._warmed_worker_pids.clear()
+ executor = self._executor
+ try:
+ timeout = max(self.settings.tencent_ocr_timeout_seconds, 1) + 3
+ result = executor.submit(_warmup_worker, 0).result(timeout=timeout)
+ pid = int(result.get("pid") or 0)
+ if pid:
+ with self._capacity_lock:
+ self._warmed_worker_pids.add(pid)
+ except Exception as exc: # pragma: no cover - best effort idle warmup
+ logger.warning("OCR idle warm worker restart failed: %s", exc)
+
+ def _executor_process_count(self) -> int:
+ with self._executor_lock:
+ return self._executor_process_count_locked()
+
+ def _executor_process_count_locked(self) -> int:
+ if self._executor is None:
+ return 0
+ processes = getattr(self._executor, "_processes", None)
+ if not processes:
+ return 0
+ return len(processes)
+
+ def _capacity_snapshot(self) -> tuple[int, set[int]]:
+ with self._capacity_lock:
+ return sum(self._active_demands.values()), set(self._warmed_worker_pids)
+
def _missing_dependencies(self) -> list[str]:
return [name for name in OCR_DEPENDENCIES if importlib.util.find_spec(name) is None]
diff --git a/app/services/payment_service.py b/app/services/payment_service.py
index b72b58f..b57374d 100644
--- a/app/services/payment_service.py
+++ b/app/services/payment_service.py
@@ -3,7 +3,9 @@
from __future__ import annotations
import base64
+import concurrent.futures
import logging
+import threading
from dataclasses import asdict, dataclass
from functools import lru_cache
from io import BytesIO
@@ -13,6 +15,7 @@
from app.clients.bigmodel_client import BigModelClient, get_bigmodel_client
from app.clients.tencent_captcha_client import TencentCaptchaClient, get_tencent_captcha_client
+from app.config import get_settings
from app.errors import BadRequestError, GlmDeskError, UpstreamRequestError
from app.models import (
AccountDetailResponse,
@@ -51,6 +54,20 @@ class StaticProduct:
version: str = "v2"
+@dataclass(frozen=True)
+class PreviewRaceWinner:
+ """Winning isolated preview attempt result."""
+
+ preview: PreviewResult
+ ticket: str
+ randstr: str
+ round: int
+ lane: int
+
+
+PREVIEW_RACE_MAX_ROUNDS = 999
+
+
STATIC_PRODUCTS: tuple[StaticProduct, ...] = (
StaticProduct("product-02434c", "Lite", "month", "49", "lite"),
StaticProduct("product-1df3e1", "Pro", "month", "149", "pro"),
@@ -92,6 +109,7 @@ def __init__(
self.ocr_service = ocr_service or get_ocr_service()
self.tdc_service = tdc_service or get_tdc_service()
self.runtime_logs = runtime_log_service or get_runtime_log_service()
+ self.settings = get_settings()
def health_payload(self) -> dict[str, Any]:
return {
@@ -131,111 +149,160 @@ def bootstrap_account(
action="bootstrap_account",
source="refresh_fingerprint" if refresh_fingerprint else "manual",
)
- try:
- if refresh_fingerprint:
- rotated = self.state_service.rotate_browser_impersonate(account_id)
- self.runtime_logs.log_event(
- flow,
- stage="fingerprint",
- status="rotated",
- message="账号指纹已刷新",
- details={"browser_impersonate": rotated.browser_impersonate},
+ assert flow is not None
+ max_attempts = self.settings.bootstrap_fingerprint_max_retries if refresh_fingerprint else 1
+ for attempt in range(1, max_attempts + 1):
+ try:
+ detail = self._bootstrap_account_once(
+ account_id,
+ refresh_fingerprint=refresh_fingerprint,
+ flow=flow,
+ attempt=attempt,
+ max_attempts=max_attempts,
)
- account = self.state_service.get_account(account_id)
- session = self.state_service.load_session(account_id)
+ if own_flow:
+ self.runtime_logs.finish_run(
+ flow,
+ status="success",
+ message="账号同步完成",
+ details={
+ "attempt": attempt,
+ "max_attempts": max_attempts,
+ "product_count": len(detail.session.products),
+ "selected_product_id": detail.session.selected_product_id,
+ "purchase_mode": detail.session.purchase_mode,
+ },
+ )
+ return detail
+ except Exception as exc:
+ if attempt < max_attempts:
+ self.runtime_logs.log_event(
+ flow,
+ stage="bootstrap_retry",
+ status="retry",
+ message=f"账号同步失败,准备更换指纹重试({attempt}/{max_attempts}):{exc}",
+ details={
+ "attempt": attempt,
+ "max_attempts": max_attempts,
+ "error": exc.__class__.__name__,
+ },
+ level=logging.WARNING,
+ )
+ continue
+ account = self.state_service.get_account(account_id)
+ account.account_status = "error"
+ account.account_status_message = str(exc)
+ account.account_checked_at = utc_now_iso()
+ self.state_service.update_account(account)
+ if own_flow:
+ self.runtime_logs.finish_run(
+ flow,
+ status="failed",
+ message=f"账号同步失败:{exc}",
+ details={
+ "attempt": attempt,
+ "max_attempts": max_attempts,
+ "error": exc.__class__.__name__,
+ },
+ level=logging.ERROR,
+ )
+ raise
+ raise RuntimeError("bootstrap retry loop exited unexpectedly")
+ def _bootstrap_account_once(
+ self,
+ account_id: str,
+ *,
+ refresh_fingerprint: bool,
+ flow: FlowRun,
+ attempt: int,
+ max_attempts: int,
+ ) -> AccountDetailResponse:
+ if refresh_fingerprint:
+ rotated = self.state_service.rotate_browser_impersonate(account_id)
self.runtime_logs.log_event(
flow,
- stage="get_customer_info",
- status="started",
- message="开始同步账号上下文",
- details={"browser_impersonate": account.browser_impersonate},
- )
- user_info_result = self.bigmodel_client.get_customer_info(account, session)
- user_info = self._ensure_dict(user_info_result.data, label="getCustomerInfo.data")
- organizations = self._extract_organizations(user_info)
- org_id, project_id = self._choose_org_and_project(
- organizations=organizations,
- preferred_org_id=account.org_id or session.org_id,
- preferred_project_id=account.project_id or session.project_id,
+ stage="fingerprint",
+ status="rotated",
+ message="账号指纹已刷新",
+ details={
+ "attempt": attempt,
+ "max_attempts": max_attempts,
+ "browser_impersonate": rotated.browser_impersonate,
+ },
)
+ account = self.state_service.get_account(account_id)
+ session = self.state_service.load_session(account_id)
- session.org_id = org_id
- session.project_id = project_id
- session.customer_number = str(user_info.get("customerNumber") or "")
- session.customer_name = str(user_info.get("customerName") or "")
- session.user_info = user_info
- session.organizations = organizations
+ self.runtime_logs.log_event(
+ flow,
+ stage="get_customer_info",
+ status="started",
+ message="开始同步账号上下文",
+ details={
+ "attempt": attempt,
+ "max_attempts": max_attempts,
+ "browser_impersonate": account.browser_impersonate,
+ },
+ )
+ user_info_result = self.bigmodel_client.get_customer_info(account, session)
+ user_info = self._ensure_dict(user_info_result.data, label="getCustomerInfo.data")
+ organizations = self._extract_organizations(user_info)
+ org_id, project_id = self._choose_org_and_project(
+ organizations=organizations,
+ preferred_org_id=account.org_id or session.org_id,
+ preferred_project_id=account.project_id or session.project_id,
+ )
- if not session.customer_number:
- raise UpstreamRequestError(
- "getCustomerInfo 没返回 customerNumber,后面 create-sign 根本没法拼。",
- details={"payload": user_info_result.raw},
- )
+ session.org_id = org_id
+ session.project_id = project_id
+ session.customer_number = str(user_info.get("customerNumber") or "")
+ session.customer_name = str(user_info.get("customerName") or "")
+ session.user_info = user_info
+ session.organizations = organizations
- self.runtime_logs.log_event(
- flow,
- stage="get_customer_info",
- status="success",
- message="账号上下文同步成功",
- details={
- "customer_number": session.customer_number,
- "customer_name": session.customer_name,
- "org_id": org_id,
- "project_id": project_id,
- "organization_count": len(organizations),
- },
+ if not session.customer_number:
+ raise UpstreamRequestError(
+ "getCustomerInfo 没返回 customerNumber,后面 create-sign 根本没法拼。",
+ details={"payload": user_info_result.raw},
)
- account.org_id = org_id
- account.project_id = project_id
- account.last_bootstrap_at = utc_now_iso()
- self.state_service.update_account(account)
+ self.runtime_logs.log_event(
+ flow,
+ stage="get_customer_info",
+ status="success",
+ message="账号上下文同步成功",
+ details={
+ "customer_number": session.customer_number,
+ "customer_name": session.customer_name,
+ "org_id": org_id,
+ "project_id": project_id,
+ "organization_count": len(organizations),
+ },
+ )
- products = self.load_products(
- account_id,
- invitation_code=account.invitation_code,
- account=account,
- session=session,
- flow=flow,
- )
- session.products = products
- if not session.selected_product_id:
- session.selected_product_id = DEFAULT_PRODUCT_ID
- self.state_service.save_session(session)
- account = self.state_service.get_account(account_id)
- account.account_status = "valid"
- account.account_status_message = "同步上下文和套餐成功"
- account.account_checked_at = utc_now_iso()
- self.state_service.update_account(account)
- detail = self.state_service.get_account_detail(account_id)
- if own_flow:
- self.runtime_logs.finish_run(
- flow,
- status="success",
- message="账号同步完成",
- details={
- "product_count": len(products),
- "selected_product_id": detail.session.selected_product_id,
- "purchase_mode": detail.session.purchase_mode,
- },
- )
- return detail
- except Exception as exc:
- account = self.state_service.get_account(account_id)
- account.account_status = "error"
- account.account_status_message = str(exc)
- account.account_checked_at = utc_now_iso()
- self.state_service.update_account(account)
- if own_flow:
- self.runtime_logs.finish_run(
- flow,
- status="failed",
- message=f"账号同步失败:{exc}",
- details={"error": exc.__class__.__name__},
- level=logging.ERROR,
- )
- raise
+ account.org_id = org_id
+ account.project_id = project_id
+ account.last_bootstrap_at = utc_now_iso()
+ self.state_service.update_account(account)
+
+ products = self.load_products(
+ account_id,
+ invitation_code=account.invitation_code,
+ account=account,
+ session=session,
+ flow=flow,
+ )
+ session.products = products
+ if not session.selected_product_id:
+ session.selected_product_id = DEFAULT_PRODUCT_ID
+ self.state_service.save_session(session)
+ account = self.state_service.get_account(account_id)
+ account.account_status = "valid"
+ account.account_status_message = "同步上下文和套餐成功"
+ account.account_checked_at = utc_now_iso()
+ self.state_service.update_account(account)
+ return self.state_service.get_account_detail(account_id)
def load_products(
self,
@@ -385,6 +452,58 @@ def fetch_captcha_challenge(
)
raise
+ def _fetch_captcha_challenge_for_session(
+ self,
+ account: AccountRecord,
+ session: AccountSessionState,
+ *,
+ analyze: bool = True,
+ flow: FlowRun | None = None,
+ details: dict[str, Any] | None = None,
+ ) -> dict[str, Any]:
+ challenge = self.tencent_captcha_client.prehandle(account)
+ image_bytes = self.tencent_captcha_client.fetch_image_bytes(account, challenge)
+ payload: dict[str, Any] = {
+ "instruction": challenge.instruction,
+ "sess": challenge.sess,
+ "sid": challenge.sid,
+ "image_url": challenge.image_url,
+ "image_path": challenge.image_path,
+ "image_size": len(image_bytes),
+ "image_base64": "data:image/png;base64," + base64.b64encode(image_bytes).decode("ascii"),
+ "raw": challenge.raw,
+ }
+ if analyze:
+ payload["ocr"] = self.ocr_service.analyze_captcha_image(
+ image_bytes,
+ prompt_text=challenge.instruction,
+ )
+ self.captcha_service.store_challenge_snapshot(
+ session,
+ sess=challenge.sess,
+ sid=challenge.sid,
+ instruction=challenge.instruction,
+ raw=challenge.raw,
+ ocr=payload.get("ocr") if isinstance(payload.get("ocr"), dict) else None,
+ )
+ ocr = payload.get("ocr") if isinstance(payload.get("ocr"), dict) else {}
+ self.runtime_logs.log_event(
+ flow,
+ stage="captcha_challenge",
+ status="success",
+ message="验证码图片获取成功",
+ details={
+ **(details or {}),
+ "instruction": challenge.instruction,
+ "image_size": len(image_bytes),
+ "ocr_points": len(ocr.get("points") or []) if isinstance(ocr.get("points"), list) else 0,
+ "ocr_confidence": ocr.get("confidence"),
+ "worker_pid": ocr.get("_worker_pid"),
+ "worker_elapsed_ms": ocr.get("_worker_elapsed_ms"),
+ },
+ )
+ return payload
+
def build_captcha_verify_payload(
self,
account_id: str,
@@ -415,9 +534,31 @@ def submit_captcha_verify(
own_flow = flow is None
if own_flow:
flow = self.runtime_logs.start_run(account_id=account_id, action="captcha_verify")
+ account = self.state_service.get_account(account_id)
+ session = self.state_service.load_session(account_id)
+ return self._submit_captcha_verify_for_session(
+ account_id,
+ account,
+ session,
+ request,
+ flow=flow,
+ persist_session=True,
+ finish_own_flow=own_flow,
+ )
+
+ def _submit_captcha_verify_for_session(
+ self,
+ account_id: str,
+ account: AccountRecord,
+ session: AccountSessionState,
+ request: CaptchaVerifyPayloadRequest,
+ *,
+ flow: FlowRun | None = None,
+ persist_session: bool = False,
+ finish_own_flow: bool = False,
+ details: dict[str, Any] | None = None,
+ ) -> dict[str, Any]:
try:
- account = self.state_service.get_account(account_id)
- session = self.state_service.load_session(account_id)
request, tdc_result = self._hydrate_tdc_if_needed(account, session, request)
bundle = self.captcha_service.build_verify_payload(session, request)
bundle["challenge"] = {
@@ -441,7 +582,8 @@ def submit_captcha_verify(
session.captcha_ticket = ""
session.captcha_randstr = ""
session.captcha_updated_at = None
- self.state_service.save_session(session)
+ if persist_session:
+ self.state_service.save_session(session)
response = {
"request": bundle,
@@ -459,6 +601,7 @@ def submit_captcha_verify(
status="success" if verify_ok else "failed",
message="验证码 verify 完成" if verify_ok else "验证码 verify 未通过",
details={
+ **(details or {}),
"ret": verify_result.ret,
"error_code": verify_result.error_code,
"error_message": verify_result.error_message,
@@ -467,7 +610,7 @@ def submit_captcha_verify(
},
level=logging.INFO if verify_ok else logging.WARNING,
)
- if own_flow:
+ if finish_own_flow:
self.runtime_logs.finish_run(
flow,
status="success" if verify_ok else "failed",
@@ -477,7 +620,7 @@ def submit_captcha_verify(
)
return response
except Exception as exc:
- if own_flow:
+ if finish_own_flow:
self.runtime_logs.finish_run(
flow,
status="failed",
@@ -491,26 +634,59 @@ def solve_captcha(self, account_id: str, *, flow: FlowRun | None = None) -> dict
own_flow = flow is None
if own_flow:
flow = self.runtime_logs.start_run(account_id=account_id, action="solve_captcha")
+ account = self.state_service.get_account(account_id)
+ session = self.state_service.load_session(account_id)
+ return self._solve_captcha_for_session(
+ account_id,
+ account,
+ session,
+ flow=flow,
+ persist_session=True,
+ finish_own_flow=own_flow,
+ )
+
+ def _solve_captcha_for_session(
+ self,
+ account_id: str,
+ account: AccountRecord,
+ session: AccountSessionState,
+ *,
+ flow: FlowRun | None = None,
+ persist_session: bool = False,
+ finish_own_flow: bool = False,
+ stop_event: threading.Event | None = None,
+ details: dict[str, Any] | None = None,
+ push_progress: bool = True,
+ ) -> dict[str, Any]:
attempts: list[dict[str, Any]] = []
last_result: dict[str, Any] = {}
attempt = 0
try:
while True:
self._ensure_not_paused(account_id)
+ if stop_event is not None and stop_event.is_set():
+ raise RunPausedError("preview race 已有其他任务胜出")
attempt += 1
- self._push_runtime_message(
- account_id,
- f"正在识别验证码,第 {attempt} 轮",
- )
+ if push_progress:
+ self._push_runtime_message(
+ account_id,
+ f"正在识别验证码,第 {attempt} 轮",
+ )
self.runtime_logs.log_event(
flow,
stage="captcha_attempt",
status="started",
message=f"开始第 {attempt} 轮验证码识别",
- details={"attempt": attempt},
+ details={"attempt": attempt, **(details or {})},
)
try:
- challenge = self.fetch_captcha_challenge(account_id, analyze=True, flow=flow)
+ challenge = self._fetch_captcha_challenge_for_session(
+ account,
+ session,
+ analyze=True,
+ flow=flow,
+ details={"attempt": attempt, **(details or {})},
+ )
except GlmDeskError as exc:
result = {
"attempt": attempt,
@@ -535,10 +711,11 @@ def solve_captcha(self, account_id: str, *, flow: FlowRun | None = None) -> dict
details={"attempt": attempt, "error": exc.message, "details": exc.details},
level=logging.WARNING,
)
- self._push_runtime_message(
- account_id,
- f"验证码 OCR 异常,第 {attempt} 轮重试中",
- )
+ if push_progress:
+ self._push_runtime_message(
+ account_id,
+ f"验证码 OCR 异常,第 {attempt} 轮重试中",
+ )
continue
ocr_gate = self._captcha_ocr_gate(challenge)
if not ocr_gate["usable"]:
@@ -565,13 +742,27 @@ def solve_captcha(self, account_id: str, *, flow: FlowRun | None = None) -> dict
details={"attempt": attempt, **ocr_gate},
level=logging.WARNING,
)
- self._push_runtime_message(
- account_id,
- f"验证码点位不足或不匹配,第 {attempt} 轮重试中",
- )
+ if push_progress:
+ self._push_runtime_message(
+ account_id,
+ f"验证码点位不足或不匹配,第 {attempt} 轮重试中",
+ )
continue
- verify = self.submit_captcha_verify(account_id, CaptchaVerifyPayloadRequest(), flow=flow)
+ if stop_event is not None and stop_event.is_set():
+ raise RunPausedError("preview race 已有其他任务胜出")
+
+ verify = self._submit_captcha_verify_for_session(
+ account_id,
+ account,
+ session,
+ CaptchaVerifyPayloadRequest(),
+ flow=flow,
+ persist_session=persist_session,
+ details={"attempt": attempt, **(details or {})},
+ )
+ if stop_event is not None and stop_event.is_set():
+ raise RunPausedError("preview race 已有其他任务胜出")
error_code = str(verify.get("error_code") or "")
result = {
"attempt": attempt,
@@ -595,10 +786,11 @@ def solve_captcha(self, account_id: str, *, flow: FlowRun | None = None) -> dict
details={"attempt": attempt, "error_code": error_code},
level=logging.WARNING,
)
- self._push_runtime_message(
- account_id,
- f"验证码 verify 返回 error=50,第 {attempt} 轮重试中",
- )
+ if push_progress:
+ self._push_runtime_message(
+ account_id,
+ f"验证码 verify 返回 error=50,第 {attempt} 轮重试中",
+ )
continue
if (error_code and error_code != "0") or not result["ticket"] or not result["randstr"]:
result["ticket"] = ""
@@ -618,10 +810,11 @@ def solve_captcha(self, account_id: str, *, flow: FlowRun | None = None) -> dict
},
level=logging.WARNING,
)
- self._push_runtime_message(
- account_id,
- f"验证码 verify 未通过,第 {attempt} 轮重试中",
- )
+ if push_progress:
+ self._push_runtime_message(
+ account_id,
+ f"验证码 verify 未通过,第 {attempt} 轮重试中",
+ )
continue
attempts.append(result)
last_result = result
@@ -632,15 +825,16 @@ def solve_captcha(self, account_id: str, *, flow: FlowRun | None = None) -> dict
message="验证码识别成功",
details={"attempt": attempt, "ticket_ready": True, "randstr_ready": True},
)
- self._push_runtime_message(
- account_id,
- f"验证码识别成功,共尝试 {attempt} 轮",
- )
+ if push_progress:
+ self._push_runtime_message(
+ account_id,
+ f"验证码识别成功,共尝试 {attempt} 轮",
+ )
solved = {
**result,
"attempts": attempts,
}
- if own_flow:
+ if finish_own_flow:
self.runtime_logs.finish_run(
flow,
status="success",
@@ -649,7 +843,7 @@ def solve_captcha(self, account_id: str, *, flow: FlowRun | None = None) -> dict
)
return solved
except Exception as exc:
- if own_flow:
+ if finish_own_flow:
self.runtime_logs.finish_run(
flow,
status="failed",
@@ -696,8 +890,8 @@ def preview_payment(
flow,
stage="preview",
status="started",
- message=f"开始第 {preview_round} 轮 preview",
- details={"round": preview_round, "product_id": request.product_id},
+ message=f"开始第 {preview_round} 轮 preview,将先获取验证码票据",
+ details={"round": preview_round, "product_id": request.product_id, "captcha_required": True},
)
captcha_request = request if preview_round == 1 else request.model_copy(update={"ticket": None, "randstr": None})
ticket, randstr = self._resolve_or_solve_preview_captcha(account_id, session, captcha_request, flow=flow)
@@ -728,7 +922,7 @@ def preview_payment(
flow,
stage="preview",
status="retry",
- message="preview 请求失败,重新开始整条链路",
+ message="preview 请求失败,下一轮将重新获取验证码票据并再次请求 preview",
details={"round": preview_round, "error": exc.message, "details": exc.details},
level=logging.WARNING,
)
@@ -808,7 +1002,7 @@ def preview_payment(
flow,
stage="preview",
status="retry",
- message="preview 未拿到 bizId,重跑整条链路",
+ message="preview 未拿到 bizId,下一轮将重新获取验证码票据并再次请求 preview",
details={
"round": preview_round,
"code": code,
@@ -834,6 +1028,269 @@ def preview_payment(
)
raise
+ def race_preview_payment(
+ self,
+ account_id: str,
+ request: PreviewPaymentRequest,
+ *,
+ concurrency: int = 1,
+ flow: FlowRun | None = None,
+ ) -> PreviewResult:
+ normalized_concurrency = max(1, min(4, int(concurrency or 1)))
+ own_flow = flow is None
+ if own_flow:
+ flow = self.runtime_logs.start_run(
+ account_id=account_id,
+ action="preview_payment_race",
+ product_id=request.product_id,
+ details={"concurrency": normalized_concurrency},
+ )
+ capacity_lease_id = ""
+ capacity = self.ocr_service.reserve_capacity(normalized_concurrency)
+ capacity_lease_id = str(capacity.get("lease_id") or "")
+ self.runtime_logs.log_event(
+ flow,
+ stage="ocr_capacity",
+ status="ready",
+ message="OCR worker 已按 preview 并发需求预热",
+ details={
+ "preview_concurrency": normalized_concurrency,
+ "reserved_demand": capacity.get("reserved_demand"),
+ "active_demand": capacity.get("active_demand"),
+ "workers_limit": capacity.get("workers"),
+ "warmed_workers": capacity.get("warmed_workers"),
+ "warmed_worker_pids": capacity.get("warmed_worker_pids"),
+ },
+ )
+ if normalized_concurrency <= 1:
+ try:
+ return self.preview_payment(account_id, request, flow=flow)
+ finally:
+ self.ocr_service.release_capacity(capacity_lease_id)
+
+ try:
+ account, base_session = self._ensure_context(account_id)
+ except Exception:
+ self.ocr_service.release_capacity(capacity_lease_id)
+ raise
+ invitation = (request.invitation_code or account.invitation_code).strip()
+ stop_event = threading.Event()
+ errors: list[str] = []
+ self.runtime_logs.log_event(
+ flow,
+ stage="preview_race",
+ status="started",
+ message=f"preview 竞速启动,并发 {normalized_concurrency} 路",
+ details={"concurrency": normalized_concurrency, "product_id": request.product_id},
+ )
+ self._push_runtime_message(
+ account_id,
+ f"正在并发获取 bizId,preview 并发 {normalized_concurrency} 路",
+ )
+ try:
+ executor = concurrent.futures.ThreadPoolExecutor(
+ max_workers=normalized_concurrency,
+ thread_name_prefix=f"preview-race-{account_id}",
+ )
+ try:
+ futures = [
+ executor.submit(
+ self._preview_race_lane,
+ account_id,
+ account,
+ base_session,
+ request,
+ invitation,
+ lane,
+ stop_event,
+ flow,
+ )
+ for lane in range(1, normalized_concurrency + 1)
+ ]
+ self.runtime_logs.log_event(
+ flow,
+ stage="preview_race",
+ status="started",
+ message=f"preview 竞速已提交 {len(futures)} 个并发 lane",
+ details={
+ "concurrency": normalized_concurrency,
+ "max_rounds_per_lane": PREVIEW_RACE_MAX_ROUNDS,
+ },
+ )
+ winner: PreviewRaceWinner | None = None
+ paused_error: RunPausedError | None = None
+ for future in concurrent.futures.as_completed(futures):
+ try:
+ winner = future.result()
+ except RunPausedError as exc:
+ if not stop_event.is_set():
+ paused_error = exc
+ stop_event.set()
+ for item in futures:
+ if item is not future:
+ item.cancel()
+ continue
+ except Exception as exc:
+ errors.append(str(exc))
+ continue
+ stop_event.set()
+ for item in futures:
+ if item is not future:
+ item.cancel()
+ break
+ finally:
+ stop_event.set()
+ executor.shutdown(wait=False, cancel_futures=True)
+
+ if winner is None:
+ if paused_error is not None:
+ raise paused_error
+ message = errors[-1] if errors else "preview 竞速没有拿到 bizId"
+ raise UpstreamRequestError(
+ "preview 竞速失败,所有并发任务都未拿到 bizId",
+ details={"concurrency": normalized_concurrency, "last_error": message, "errors": errors[-6:]},
+ )
+
+ session = self.state_service.load_session(account_id)
+ session.selected_product_id = request.product_id
+ session.preview = winner.preview
+ session.captcha_ticket = winner.ticket
+ session.captcha_randstr = winner.randstr
+ self.state_service.save_session(session)
+ self.runtime_logs.log_event(
+ flow,
+ stage="preview_race",
+ status="success",
+ message="preview 竞速成功,已选用最先返回 bizId 的结果",
+ details={
+ "concurrency": normalized_concurrency,
+ "lane": winner.lane,
+ "round": winner.round,
+ "biz_id": winner.preview.biz_id,
+ },
+ )
+ self._push_runtime_message(
+ account_id,
+ f"preview 竞速成功,lane {winner.lane} 获取 bizId:{winner.preview.biz_id}",
+ )
+ if own_flow:
+ self.runtime_logs.finish_run(
+ flow,
+ status="success",
+ message="preview 竞速成功",
+ details={"biz_id": winner.preview.biz_id, "lane": winner.lane, "round": winner.round},
+ )
+ return winner.preview
+ except Exception as exc:
+ stop_event.set()
+ if own_flow:
+ self.runtime_logs.finish_run(
+ flow,
+ status="failed",
+ message=f"preview 竞速失败:{exc}",
+ details={"error": exc.__class__.__name__},
+ level=logging.ERROR,
+ )
+ raise
+ finally:
+ self.ocr_service.release_capacity(capacity_lease_id)
+
+ def _preview_race_lane(
+ self,
+ account_id: str,
+ account: AccountRecord,
+ base_session: AccountSessionState,
+ request: PreviewPaymentRequest,
+ invitation: str,
+ lane: int,
+ stop_event: threading.Event,
+ flow: FlowRun | None,
+ ) -> PreviewRaceWinner:
+ session = base_session.model_copy(deep=True)
+ round_no = 0
+ while not stop_event.is_set() and round_no < PREVIEW_RACE_MAX_ROUNDS:
+ self._ensure_not_paused(account_id)
+ round_no += 1
+ details = {"lane": lane, "round": round_no, "race": True}
+ self.runtime_logs.log_event(
+ flow,
+ stage="preview_race",
+ status="started",
+ message=f"preview 竞速 lane {lane} 开始第 {round_no} 轮",
+ details=details,
+ )
+ solved = self._solve_captcha_for_session(
+ account_id,
+ account,
+ session,
+ flow=flow,
+ persist_session=False,
+ stop_event=stop_event,
+ details=details,
+ push_progress=False,
+ )
+ if stop_event.is_set():
+ raise RunPausedError("preview race 已有其他任务胜出")
+ ticket = str(solved.get("ticket") or "")
+ randstr = str(solved.get("randstr") or "")
+ if not ticket or not randstr:
+ continue
+ result = self.bigmodel_client.preview_payment(
+ account,
+ session,
+ request,
+ invitation_code=invitation,
+ ticket=ticket,
+ randstr=randstr,
+ )
+ if stop_event.is_set():
+ raise RunPausedError("preview race 已有其他任务胜出")
+ raw = result.raw
+ code = raw.get("code")
+ data = result.data if isinstance(result.data, dict) else {}
+ biz_id = str(data.get("bizId") or "").strip()
+ if code == 200 and biz_id:
+ stop_event.set()
+ preview = PreviewResult(
+ biz_id=biz_id,
+ third_party_amount=self._string_value(data, "thirdPartyAmount"),
+ sold_out=bool(data.get("soldOut")),
+ original_amount=self._string_value(data, "originalAmount"),
+ pay_amount=self._string_value(data, "payAmount"),
+ residual_amount=self._string_value(data, "residualAmount"),
+ give_amount=self._string_value(data, "giveAmount"),
+ cash_amount=self._string_value(data, "cashAmount"),
+ renew_amount=self._string_value(data, "renewAmount"),
+ campaign_discount_details=list(data.get("campaignDiscountDetails") or []),
+ last_subscription_summary=self._ensure_dict(
+ data.get("lastSubscriptionSummary") or {},
+ label="preview.lastSubscriptionSummary",
+ ),
+ raw=raw,
+ )
+ return PreviewRaceWinner(preview=preview, ticket=ticket, randstr=randstr, round=round_no, lane=lane)
+
+ self.runtime_logs.log_event(
+ flow,
+ stage="preview_race",
+ status="retry",
+ message=f"preview 竞速 lane {lane} 未拿到 bizId,继续重试",
+ details={
+ **details,
+ "code": code,
+ "biz_id": biz_id,
+ "sold_out": bool(data.get("soldOut")),
+ "msg": raw.get("msg") or "",
+ },
+ level=logging.WARNING,
+ )
+ if round_no >= PREVIEW_RACE_MAX_ROUNDS:
+ raise UpstreamRequestError(
+ f"preview 竞速 lane {lane} 已达到最大重试 {PREVIEW_RACE_MAX_ROUNDS} 轮",
+ details={"lane": lane, "rounds": round_no, "max_rounds": PREVIEW_RACE_MAX_ROUNDS},
+ )
+ raise RunPausedError("preview race 已有其他任务胜出")
+
def seed_preview(self, account_id: str, request: PreviewSeedRequest) -> PreviewResult:
session = self.state_service.load_session(account_id)
preview = self._preview_from_upstream_payload(request.preview)
@@ -887,12 +1344,14 @@ def create_qr(
details={"cycle": cycle, "product_id": product_id},
level=logging.WARNING,
)
- self.preview_payment(
+ current_account = self.state_service.get_account(account_id)
+ self.race_preview_payment(
account_id,
PreviewPaymentRequest(
product_id=product_id,
invitation_code=invitation,
),
+ concurrency=current_account.preview_concurrency,
flow=flow,
)
session = self.state_service.load_session(account_id)
@@ -983,6 +1442,7 @@ def create_qr(
session.selected_product_id = product_id
self.state_service.save_session(session)
saved_task = self.state_service.save_task(task)
+ self.state_service.touch_account_updated_at(account_id)
self.runtime_logs.log_event(
flow,
stage="sign",
@@ -1101,9 +1561,10 @@ def run_payment_flow(
account_id,
"任务已启动,正在准备支付链路",
)
- preview = self.preview_payment(
+ preview = self.race_preview_payment(
account_id,
PreviewPaymentRequest(product_id=selected_product_id),
+ concurrency=self.state_service.get_account(account_id).preview_concurrency,
flow=flow,
)
task = self.create_qr(
diff --git a/app/services/scheduler_service.py b/app/services/scheduler_service.py
index 10fa03c..645eb0d 100644
--- a/app/services/scheduler_service.py
+++ b/app/services/scheduler_service.py
@@ -29,12 +29,15 @@ def __init__(self) -> None:
self._stop_event = threading.Event()
self._thread: threading.Thread | None = None
self._running_accounts: set[str] = set()
+ self._flow_accounts: set[str] = set()
self._pause_requested: set[str] = set()
+ self._pending_scheduled_runs: dict[str, str] = {}
self._lock = threading.Lock()
def start(self) -> None:
if self._thread and self._thread.is_alive():
return
+ self._clear_stale_schedule_statuses()
self._stop_event.clear()
self._thread = threading.Thread(target=self._run_loop, name="glm-desk-scheduler", daemon=True)
self._thread.start()
@@ -55,6 +58,27 @@ def stop(self) -> None:
message="调度器已停止",
)
+ def _clear_stale_schedule_statuses(self) -> None:
+ stale_statuses = {"running", "pause_requested"}
+ with self._lock:
+ active_flow_accounts = set(self._flow_accounts)
+ for public_account in self.state_service.list_accounts():
+ if public_account.id in active_flow_accounts:
+ continue
+ account = self.state_service.get_account(public_account.id)
+ if str(account.last_schedule_status or "").lower() not in stale_statuses:
+ continue
+ account.last_schedule_status = "paused"
+ account.last_schedule_message = "服务重启后任务已停止,请重新启动"
+ self.state_service.update_account(account)
+ self.runtime_logs.log_account_event(
+ account_id=account.id,
+ action="run_payment_flow",
+ stage="scheduler",
+ status="stale_cleared",
+ message="已清理服务重启遗留的运行状态",
+ )
+
def _run_loop(self) -> None:
while not self._stop_event.is_set():
try:
@@ -80,9 +104,12 @@ def poll_once(self) -> None:
if public_account.scheduled_start_time > current_hms:
continue
account = self.state_service.get_account(public_account.id)
- if self._already_ran_today(account, current_date):
+ run_key = self._scheduled_run_key(current_date, account.scheduled_start_time)
+ if self._already_ran_schedule(account, run_key):
+ continue
+ if self._queue_scheduled_run_if_busy(account.id, run_key):
continue
- self.start_account_flow(account.id, source="scheduled")
+ self.start_account_flow(account.id, source="scheduled", scheduled_run_key=run_key)
def check_cached_accounts_once(self) -> None:
for public_account in self.state_service.list_accounts():
@@ -140,9 +167,28 @@ def check_cached_accounts_once(self) -> None:
with self._lock:
self._running_accounts.discard(account_id)
- def start_account_flow(self, account_id: str, *, source: str = "manual") -> dict[str, object]:
+ def start_account_flow(
+ self,
+ account_id: str,
+ *,
+ source: str = "manual",
+ scheduled_run_key: str = "",
+ ) -> dict[str, object]:
with self._lock:
if account_id in self._running_accounts:
+ if source == "scheduled" and scheduled_run_key:
+ already_pending = self._pending_scheduled_runs.get(account_id) == scheduled_run_key
+ self._pending_scheduled_runs[account_id] = scheduled_run_key
+ if not already_pending:
+ self.runtime_logs.log_account_event(
+ account_id=account_id,
+ action="run_payment_flow",
+ stage="scheduler",
+ status="pending",
+ message="定时任务到点时账号正在运行,已挂起等待当前任务结束",
+ details={"scheduled_run_key": scheduled_run_key},
+ )
+ return {"started": False, "status": "pending"}
self.runtime_logs.log_account_event(
account_id=account_id,
action="run_payment_flow",
@@ -154,11 +200,26 @@ def start_account_flow(self, account_id: str, *, source: str = "manual") -> dict
)
return {"started": False, "status": "running"}
self._pause_requested.discard(account_id)
+ if source == "scheduled":
+ self._pending_scheduled_runs.pop(account_id, None)
self._running_accounts.add(account_id)
+ self._flow_accounts.add(account_id)
account = self.state_service.get_account(account_id)
- account.last_scheduled_run_at = utc_now_iso()
+ if source == "scheduled":
+ account.last_scheduled_run_at = utc_now_iso()
+ account.last_scheduled_run_key = scheduled_run_key or self._scheduled_run_key(
+ datetime.now(SCHEDULER_TZ).strftime("%Y-%m-%d") if SCHEDULER_TZ is not None else datetime.now().astimezone().strftime("%Y-%m-%d"),
+ account.scheduled_start_time,
+ )
+ else:
+ account.last_manual_run_at = utc_now_iso()
account.last_schedule_status = "running"
- account.last_schedule_message = "定时任务运行中" if source == "scheduled" else "手动任务运行中"
+ if source == "scheduled":
+ account.last_schedule_message = "定时任务运行中"
+ elif source == "probe":
+ account.last_schedule_message = "测活任务运行中"
+ else:
+ account.last_schedule_message = "手动任务运行中"
self.state_service.update_account(account)
self.runtime_logs.log_account_event(
account_id=account_id,
@@ -178,9 +239,31 @@ def start_account_flow(self, account_id: str, *, source: str = "manual") -> dict
def request_pause(self, account_id: str) -> dict[str, object]:
with self._lock:
- if account_id not in self._running_accounts:
- return {"paused": False, "status": "idle"}
- self._pause_requested.add(account_id)
+ is_flow_running = account_id in self._flow_accounts
+ if is_flow_running:
+ self._pause_requested.add(account_id)
+ if not is_flow_running:
+ account = self.state_service.get_account(account_id)
+ stale_statuses = {"running", "pause_requested"}
+ if str(account.last_schedule_status or "").lower() in stale_statuses:
+ latest_task = next(iter(self.state_service.list_tasks(account_id)), None)
+ if latest_task and latest_task.qr_base64:
+ account.last_schedule_status = "success"
+ account.last_schedule_message = f"生成二维码成功:{latest_task.biz_id}"
+ self.state_service.update_account(account)
+ return {"paused": False, "status": "success", "stale_cleared": True}
+ account.last_schedule_status = "paused"
+ account.last_schedule_message = "任务不在当前进程运行,已清理陈旧运行状态"
+ self.state_service.update_account(account)
+ self.runtime_logs.log_account_event(
+ account_id=account_id,
+ action="run_payment_flow",
+ stage="pause",
+ status="stale_cleared",
+ message="暂停时发现任务不在当前进程运行,已清理陈旧状态",
+ )
+ return {"paused": False, "status": "paused", "stale_cleared": True}
+ return {"paused": False, "status": "idle"}
account = self.state_service.get_account(account_id)
account.last_schedule_status = "pause_requested"
account.last_schedule_message = "暂停请求已提交"
@@ -198,18 +281,54 @@ def is_pause_requested(self, account_id: str) -> bool:
with self._lock:
return account_id in self._pause_requested
- def _already_ran_today(self, account, current_date: str) -> bool:
- raw = (account.last_scheduled_run_at or "").strip()
- return raw.startswith(current_date)
+ def _scheduled_run_key(self, current_date: str, scheduled_start_time: str) -> str:
+ return f"{current_date}|{(scheduled_start_time or '').strip()}"
+
+ def _already_ran_schedule(self, account, run_key: str) -> bool:
+ return bool(run_key) and (account.last_scheduled_run_key or "").strip() == run_key
+
+ def _queue_scheduled_run_if_busy(self, account_id: str, run_key: str) -> bool:
+ with self._lock:
+ if account_id not in self._running_accounts:
+ return False
+ if self._pending_scheduled_runs.get(account_id) == run_key:
+ return True
+ self._pending_scheduled_runs[account_id] = run_key
+ self.runtime_logs.log_account_event(
+ account_id=account_id,
+ action="run_payment_flow",
+ stage="scheduler",
+ status="pending",
+ message="定时任务到点时账号正在运行,已挂起等待当前任务结束",
+ details={"scheduled_run_key": run_key},
+ )
+ return True
+
+ def _pop_pending_scheduled_run(self, account_id: str) -> str:
+ with self._lock:
+ return self._pending_scheduled_runs.pop(account_id, "")
+
+ def _start_pending_scheduled_run(self, account_id: str, run_key: str) -> None:
+ if not run_key:
+ return
+ account = self.state_service.get_account(account_id)
+ if not account.schedule_enabled:
+ return
+ scheduled_date, _, _ = run_key.partition("|")
+ if run_key != self._scheduled_run_key(scheduled_date, account.scheduled_start_time):
+ return
+ if self._already_ran_schedule(account, run_key):
+ return
+ self.start_account_flow(account_id, source="scheduled", scheduled_run_key=run_key)
def _run_account_flow(self, account_id: str, source: str) -> None:
try:
task = self.payment_service.run_payment_flow(account_id, source=source)
account = self.state_service.get_account(account_id)
account.last_schedule_status = "success"
- account.last_schedule_message = f"生成二维码成功:{task.biz_id}"
+ account.last_schedule_message = f"账号链路正常:{task.biz_id}" if source == "probe" else f"生成二维码成功:{task.biz_id}"
account.account_status = "valid"
- account.account_status_message = "最近一次执行成功"
+ account.account_status_message = "账号链路正常" if source == "probe" else "最近一次执行成功"
account.account_checked_at = utc_now_iso()
self.state_service.update_account(account)
except RunPausedError as exc:
@@ -227,9 +346,9 @@ def _run_account_flow(self, account_id: str, source: str) -> None:
except Exception as exc:
account = self.state_service.get_account(account_id)
account.last_schedule_status = "failed"
- account.last_schedule_message = str(exc)
+ account.last_schedule_message = f"账号链路异常:{exc}" if source == "probe" else str(exc)
account.account_status = "error"
- account.account_status_message = str(exc)
+ account.account_status_message = f"账号链路异常:{exc}" if source == "probe" else str(exc)
account.account_checked_at = utc_now_iso()
self.state_service.update_account(account)
logger.exception("scheduled account flow failed for %s: %s", account_id, exc)
@@ -245,7 +364,9 @@ def _run_account_flow(self, account_id: str, source: str) -> None:
finally:
with self._lock:
self._running_accounts.discard(account_id)
+ self._flow_accounts.discard(account_id)
self._pause_requested.discard(account_id)
+ self._start_pending_scheduled_run(account_id, self._pop_pending_scheduled_run(account_id))
_scheduler_service: SchedulerService | None = None
diff --git a/app/web/routes.py b/app/web/routes.py
index 256fa06..359c973 100644
--- a/app/web/routes.py
+++ b/app/web/routes.py
@@ -2,13 +2,18 @@
from __future__ import annotations
+import json
+import base64
+from datetime import datetime
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Query, Request
-from fastapi.responses import HTMLResponse
+from fastapi.responses import FileResponse, HTMLResponse, Response
from fastapi.templating import Jinja2Templates
+from app.config import get_settings
+from app.errors import NotFoundError
from app.models import (
AccountPreferencesRequest,
AccountImportRequest,
@@ -23,10 +28,22 @@
router = APIRouter()
payment_service = get_payment_service()
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates"))
+SPA_INDEX = Path(__file__).resolve().parents[2] / "web" / "dist" / "index.html"
@router.get("/", response_class=HTMLResponse)
def index(request: Request, ic: str = Query(default="")):
+ if SPA_INDEX.exists():
+ return FileResponse(SPA_INDEX)
+ return render_legacy_index(request, ic)
+
+
+@router.get("/legacy", response_class=HTMLResponse)
+def legacy_index(request: Request, ic: str = Query(default="")):
+ return render_legacy_index(request, ic)
+
+
+def render_legacy_index(request: Request, ic: str = ""):
return templates.TemplateResponse(
request=request,
name="index.html",
@@ -43,6 +60,29 @@ def healthz():
return success(payment_service.health_payload())
+@router.get("/api/logs/today")
+def get_today_logs(limit: int = Query(default=500, ge=1, le=2000)):
+ settings = get_settings()
+ date_part = datetime.now().astimezone().strftime("%Y-%m-%d")
+ log_path = settings.runtime_logs_dir / f"events-{date_part}.jsonl"
+ if not log_path.exists():
+ return success({"date": date_part, "path": str(log_path), "lines": [], "text": "", "truncated": False})
+
+ raw_lines = log_path.read_text(encoding="utf-8").splitlines()
+ selected_lines = raw_lines[-limit:]
+ formatted_lines = [_format_runtime_log_line(line) for line in selected_lines]
+ return success(
+ {
+ "date": date_part,
+ "path": str(log_path),
+ "lines": formatted_lines,
+ "text": "\n".join(formatted_lines),
+ "truncated": len(raw_lines) > len(selected_lines),
+ "total": len(raw_lines),
+ }
+ )
+
+
@router.get("/api/accounts")
def list_accounts():
return success(payment_service.list_accounts())
@@ -130,6 +170,13 @@ def run_payment_flow(account_id: str):
return success(get_scheduler_service().start_account_flow(account_id, source="manual"))
+@router.post("/api/accounts/{account_id}/probe")
+def probe_account_flow(account_id: str):
+ from app.services.scheduler_service import get_scheduler_service
+
+ return success(get_scheduler_service().start_account_flow(account_id, source="probe"))
+
+
@router.post("/api/accounts/{account_id}/pause")
def pause_account_flow(account_id: str):
from app.services.scheduler_service import get_scheduler_service
@@ -147,6 +194,43 @@ def list_tasks(account_id: str):
return success(payment_service.list_tasks(account_id))
+@router.get("/api/accounts/{account_id}/tasks/{task_id}/qr.png")
+def get_task_qr_image(account_id: str, task_id: str):
+ task = next((item for item in payment_service.list_tasks(account_id) if item.id == task_id), None)
+ if task is None or not task.qr_base64:
+ raise NotFoundError("二维码不存在", details={"account_id": account_id, "task_id": task_id})
+ return Response(
+ content=_decode_qr_base64(task.qr_base64),
+ media_type="image/png",
+ headers={"Cache-Control": "no-store"},
+ )
+
+
def success(data: Any) -> dict[str, Any]:
"""Wrap success payloads consistently."""
return {"ok": True, "data": data}
+
+
+def _decode_qr_base64(value: str) -> bytes:
+ payload = (value or "").strip()
+ if "," in payload:
+ payload = payload.split(",", 1)[1]
+ return base64.b64decode(payload)
+
+
+def _format_runtime_log_line(raw_line: str) -> str:
+ try:
+ entry = json.loads(raw_line)
+ except json.JSONDecodeError:
+ return raw_line
+ timestamp = str(entry.get("timestamp") or "")
+ status = str(entry.get("status") or "")
+ account_id = str(entry.get("account_id") or "")
+ action = str(entry.get("action") or "")
+ stage = str(entry.get("stage") or "")
+ message = str(entry.get("message") or "")
+ details = entry.get("details") if isinstance(entry.get("details"), dict) else {}
+ details_text = ""
+ if details:
+ details_text = " | " + json.dumps(details, ensure_ascii=False, separators=(",", ":"))
+ return f"{timestamp} | {status} | {account_id} | {action}/{stage} | {message}{details_text}"
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..c52dc68
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,47 @@
+services:
+ glm-desk:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ image: glm-desk:main-docker
+ container_name: glm-desk
+ restart: unless-stopped
+ ports:
+ - "${APP_PORT:-8787}:${APP_PORT:-8787}"
+ environment:
+ APP_HOST: "0.0.0.0"
+ APP_PORT: "${APP_PORT:-8787}"
+ DATA_DIR: "/app/data"
+ BIGMODEL_API_BASE: "${BIGMODEL_API_BASE:-https://www.bigmodel.cn/api}"
+ BIGMODEL_ORIGIN: "${BIGMODEL_ORIGIN:-https://www.bigmodel.cn}"
+ BIGMODEL_REFERER: "${BIGMODEL_REFERER:-https://www.bigmodel.cn/glm-coding}"
+ BROWSER_IMPERSONATE: "${BROWSER_IMPERSONATE:-chrome124}"
+ BOOTSTRAP_FINGERPRINT_MAX_RETRIES: "${BOOTSTRAP_FINGERPRINT_MAX_RETRIES:-99}"
+ REQUEST_TIMEOUT_SECONDS: "${REQUEST_TIMEOUT_SECONDS:-20}"
+ DEFAULT_LANGUAGE: "${DEFAULT_LANGUAGE:-zh-CN}"
+ TENCENT_CAPTCHA_DOMAIN: "${TENCENT_CAPTCHA_DOMAIN:-https://turing.captcha.qcloud.com}"
+ TENCENT_CAPTCHA_AID: "${TENCENT_CAPTCHA_AID:-196026326}"
+ TENCENT_CAPTCHA_ENTRY_URL: "${TENCENT_CAPTCHA_ENTRY_URL:-https://www.bigmodel.cn/glm-coding}"
+ TENCENT_CAPTCHA_MAX_RETRIES: "${TENCENT_CAPTCHA_MAX_RETRIES:-3}"
+ TENCENT_CAPTCHA_MIN_CONFIDENCE: "${TENCENT_CAPTCHA_MIN_CONFIDENCE:-0.55}"
+ TENCENT_CAPTCHA_NODE: "node"
+ TENCENT_OCR_ENABLED: "${TENCENT_OCR_ENABLED:-1}"
+ TENCENT_OCR_INCLUDE_DEBUG: "${TENCENT_OCR_INCLUDE_DEBUG:-0}"
+ TENCENT_OCR_WORKERS: "${TENCENT_OCR_WORKERS:-4}"
+ TENCENT_OCR_TIMEOUT_SECONDS: "${TENCENT_OCR_TIMEOUT_SECONDS:-6}"
+ TENCENT_OCR_IDLE_SHRINK_SECONDS: "${TENCENT_OCR_IDLE_SHRINK_SECONDS:-60}"
+ RUNTIME_LOG_LEVEL: "${RUNTIME_LOG_LEVEL:-INFO}"
+ RUNTIME_LOG_RETENTION_DAYS: "${RUNTIME_LOG_RETENTION_DAYS:-7}"
+ XDG_CACHE_HOME: "/app/data/cache"
+ volumes:
+ - ./data:/app/data
+ healthcheck:
+ test:
+ [
+ "CMD-SHELL",
+ "python -c \"import os, urllib.request; urllib.request.urlopen('http://127.0.0.1:' + os.getenv('APP_PORT', '8787') + '/healthz', timeout=5).read()\""
+ ]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+ start_period: 60s
diff --git a/start.bat b/start.bat
index 242757e..3de5078 100644
--- a/start.bat
+++ b/start.bat
@@ -29,6 +29,23 @@ if not exist .venv\Scripts\python.exe (
echo [glmDesk] Syncing Python dependencies...
.venv\Scripts\python.exe -m pip install -r requirements.txt
+if exist web\package.json (
+ where npm >nul 2>&1
+ if errorlevel 1 (
+ echo [glmDesk] npm not found; skipping Vue frontend build and using legacy page fallback.
+ ) else (
+ if not exist web\node_modules (
+ echo [glmDesk] Installing Vue frontend dependencies...
+ pushd web
+ call npm install
+ popd
+ )
+ echo [glmDesk] Building Vue frontend...
+ pushd web
+ call npm run build
+ popd
+ )
+)
echo [glmDesk] Starting FastAPI server...
.venv\Scripts\python.exe -m uvicorn app.main:app --host %APP_HOST% --port %APP_PORT% --reload
diff --git a/web/ACCEPTANCE.md b/web/ACCEPTANCE.md
new file mode 100644
index 0000000..7eac85f
--- /dev/null
+++ b/web/ACCEPTANCE.md
@@ -0,0 +1,41 @@
+# Frontend Migration Acceptance Checklist
+
+## Functional Regression
+
+- [ ] Dashboard loads `/healthz`, `/api/accounts`, and per-account details.
+- [ ] Import account submits label, token, and invitation code.
+- [ ] Refresh button reloads dashboard state.
+- [ ] Account name opens context modal with account, session, and latest task JSON.
+- [ ] Product selector persists `selected_product_id` through `PATCH /api/accounts/{id}`.
+- [ ] Schedule switch and time input persist `schedule_enabled` and `scheduled_start_time`.
+- [ ] Sync fingerprint calls `POST /api/accounts/{id}/bootstrap?refresh_fingerprint=true`.
+- [ ] Run and pause call their existing flow-control endpoints.
+- [ ] Delete requires confirmation and calls the existing delete endpoint.
+- [ ] Latest QR image displays when `tasks[0].qr_base64` exists.
+- [ ] Empty state appears when there are no accounts.
+
+## Build And Runtime
+
+- [x] `npm install` succeeds in `web/`.
+- [x] `npm run typecheck` succeeds.
+- [x] `npm run build` succeeds.
+- [x] `python -m py_compile app/web/routes.py app/main.py` succeeds.
+- [x] FastAPI TestClient returns 200 for `/` and `/legacy`.
+- [x] FastAPI mounts `/assets` when `web/dist/assets` exists.
+
+## UI/UX
+
+- [x] Uses a data-dense dashboard layout rather than copied legacy CSS.
+- [x] Uses Naive UI components for cards, buttons, forms, modal, table shell, switch, select, tags, alerts, spinner, and popconfirm.
+- [x] Has visible focus states for keyboard users.
+- [x] Uses loading state for table and per-action buttons.
+- [x] Uses horizontal overflow containment for narrow tables.
+- [x] Respects `prefers-reduced-motion`.
+
+## Risks
+
+- Current refresh keeps the legacy N+1 request pattern. Add `GET /api/dashboard` if account counts grow.
+- `start.bat` now runs npm install/build when `web/package.json` exists; first startup will be slower.
+- UI 文案已集中到 `web/src/locales/zhCN.ts`,Naive UI 通过 `zhCN/dateZhCN` 完成本地化配置。后续新增页面应优先复用该文案模块,避免继续散落硬编码字符串。
+- `web/dist` is ignored because it is generated. Build before production-like serving or let `start.bat` build it.
+- Legacy page should remain until real account flows are manually compared.
\ No newline at end of file
diff --git a/web/MIGRATION.md b/web/MIGRATION.md
new file mode 100644
index 0000000..2c0af7d
--- /dev/null
+++ b/web/MIGRATION.md
@@ -0,0 +1,32 @@
+# Frontend Migration Rollout
+
+## Current Rollout State
+
+The Vue SPA is implemented in parallel with the legacy Jinja page.
+
+- `/` serves `web/dist/index.html` when the frontend has been built.
+- `/legacy` always serves the previous Jinja template.
+- If `web/dist/index.html` is missing, `/` falls back to the legacy template automatically.
+- `/api/*` routes remain unchanged.
+
+## Local Development
+
+1. Start FastAPI as usual on `127.0.0.1:8787`.
+2. In a second terminal, run `cd web && npm run dev`.
+3. Open `http://127.0.0.1:5173` for the Vue SPA.
+4. Use `http://127.0.0.1:8787/legacy` to compare the old page.
+
+## Production-like Local Startup
+
+`start.bat` now installs frontend dependencies if needed, builds `web/dist`, and starts FastAPI. This keeps the normal one-click Windows startup path while allowing automatic legacy fallback if npm is unavailable.
+
+## Cutover Strategy
+
+1. Keep the legacy template for at least one validation cycle.
+2. Compare key flows in `/` and `/legacy`: import, refresh, product select, schedule update, sync, run, pause, delete, context modal, QR display.
+3. If the SPA fails, remove `web/dist` or open `/legacy` to continue using the old page.
+4. After validation, remove the legacy Jinja template and route in a separate cleanup change.
+
+## Deferred Backend Optimization
+
+The SPA intentionally preserves the old refresh behavior: `GET /api/accounts` followed by concurrent `GET /api/accounts/{id}` calls. Once the UI is stable, add a backend aggregate endpoint such as `GET /api/dashboard` to reduce N+1 polling pressure.
\ No newline at end of file
diff --git a/web/README.md b/web/README.md
new file mode 100644
index 0000000..a1e19d0
--- /dev/null
+++ b/web/README.md
@@ -0,0 +1,29 @@
+# GLM Desk Web
+
+Vue 3 + Vite + TypeScript frontend for the FastAPI GLM Desk service.
+
+## Development
+
+```powershell
+cd web
+npm install
+npm run dev
+```
+
+Vite proxies `/api` and `/healthz` to `http://127.0.0.1:8787`.
+
+## Production Build
+
+```powershell
+cd web
+npm run build
+```
+
+The FastAPI app serves `web/dist/index.html` at `/` when the build exists. The legacy Jinja page remains available at `/legacy`.
+
+## Design Notes
+
+- UI pattern: data-dense operations dashboard.
+- Component library: Naive UI, registered on demand to keep the bundle smaller.
+- Accessibility: visible focus states, semantic buttons, loading states, and a mobile-safe horizontal table region.
+- Fallback: if `web/dist/index.html` does not exist, FastAPI automatically renders the legacy Jinja template.
\ No newline at end of file
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..3512256
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ GLM Desk
+
+
+
+
+
+
\ No newline at end of file
diff --git a/web/package-lock.json b/web/package-lock.json
new file mode 100644
index 0000000..c357bb4
--- /dev/null
+++ b/web/package-lock.json
@@ -0,0 +1,1707 @@
+{
+ "name": "glm-desk-web",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "glm-desk-web",
+ "version": "0.1.0",
+ "dependencies": {
+ "@vicons/ionicons5": "^0.13.0",
+ "naive-ui": "^2.42.0",
+ "vue": "^3.5.13"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-vue": "^5.2.1",
+ "typescript": "^5.7.2",
+ "vite": "^6.0.7",
+ "vue-tsc": "^2.2.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.2.tgz",
+ "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.0"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz",
+ "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@css-render/plugin-bem": {
+ "version": "0.15.14",
+ "resolved": "https://registry.npmmirror.com/@css-render/plugin-bem/-/plugin-bem-0.15.14.tgz",
+ "integrity": "sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "css-render": "~0.15.14"
+ }
+ },
+ "node_modules/@css-render/vue3-ssr": {
+ "version": "0.15.14",
+ "resolved": "https://registry.npmmirror.com/@css-render/vue3-ssr/-/vue3-ssr-0.15.14.tgz",
+ "integrity": "sha512-//8027GSbxE9n3QlD73xFY6z4ZbHbvrOVB7AO6hsmrEzGbg+h2A09HboUyDgu+xsmj7JnvJD39Irt+2D0+iV8g==",
+ "license": "MIT",
+ "peerDependencies": {
+ "vue": "^3.0.11"
+ }
+ },
+ "node_modules/@emotion/hash": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmmirror.com/@emotion/hash/-/hash-0.8.0.tgz",
+ "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==",
+ "license": "MIT"
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "license": "MIT"
+ },
+ "node_modules/@juggle/resize-observer": {
+ "version": "3.4.0",
+ "resolved": "https://registry.npmmirror.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz",
+ "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz",
+ "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz",
+ "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz",
+ "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz",
+ "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz",
+ "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz",
+ "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz",
+ "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz",
+ "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz",
+ "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz",
+ "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz",
+ "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz",
+ "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz",
+ "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz",
+ "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz",
+ "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz",
+ "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz",
+ "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz",
+ "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz",
+ "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz",
+ "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz",
+ "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz",
+ "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz",
+ "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz",
+ "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz",
+ "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/lodash": {
+ "version": "4.17.24",
+ "resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.24.tgz",
+ "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==",
+ "license": "MIT"
+ },
+ "node_modules/@types/lodash-es": {
+ "version": "4.17.12",
+ "resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz",
+ "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/lodash": "*"
+ }
+ },
+ "node_modules/@vicons/ionicons5": {
+ "version": "0.13.0",
+ "resolved": "https://registry.npmmirror.com/@vicons/ionicons5/-/ionicons5-0.13.0.tgz",
+ "integrity": "sha512-zvZKBPjEXKN7AXNo2Na2uy+nvuv6SP4KAMQxpKL2vfHMj0fSvuw7JZcOPCjQC3e7ayssKnaoFVAhbYcW6v41qQ==",
+ "license": "MIT"
+ },
+ "node_modules/@vitejs/plugin-vue": {
+ "version": "5.2.4",
+ "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
+ "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^5.0.0 || ^6.0.0",
+ "vue": "^3.2.25"
+ }
+ },
+ "node_modules/@volar/language-core": {
+ "version": "2.4.15",
+ "resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.15.tgz",
+ "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/source-map": "2.4.15"
+ }
+ },
+ "node_modules/@volar/source-map": {
+ "version": "2.4.15",
+ "resolved": "https://registry.npmmirror.com/@volar/source-map/-/source-map-2.4.15.tgz",
+ "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@volar/typescript": {
+ "version": "2.4.15",
+ "resolved": "https://registry.npmmirror.com/@volar/typescript/-/typescript-2.4.15.tgz",
+ "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/language-core": "2.4.15",
+ "path-browserify": "^1.0.1",
+ "vscode-uri": "^3.0.8"
+ }
+ },
+ "node_modules/@vue/compiler-core": {
+ "version": "3.5.33",
+ "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.33.tgz",
+ "integrity": "sha512-3PZLQwFw4Za3TC8t0FvTy3wI16Kt+pmwcgNZca4Pj9iWL2E72a/gZlpBtAJvEdDMdCxdG/qq0C7PN0bsJuv0Rw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.2",
+ "@vue/shared": "3.5.33",
+ "entities": "^7.0.1",
+ "estree-walker": "^2.0.2",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/@vue/compiler-dom": {
+ "version": "3.5.33",
+ "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.33.tgz",
+ "integrity": "sha512-PXq0yrfCLzzL07rbXO4awtXY1Z06LG2eu6Adg3RJFa/j3Cii217XxxLXG22N330gw7GmALCY0Z8RgXEviwgpjA==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-core": "3.5.33",
+ "@vue/shared": "3.5.33"
+ }
+ },
+ "node_modules/@vue/compiler-sfc": {
+ "version": "3.5.33",
+ "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.33.tgz",
+ "integrity": "sha512-UTUvRO9cY+rROrx/pvN9P5Z7FgA6QGfokUCfhQE4EnmUj3rVnK+CHI0LsEO1pg+I7//iRYMUfcNcCPe7tg0CoA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.2",
+ "@vue/compiler-core": "3.5.33",
+ "@vue/compiler-dom": "3.5.33",
+ "@vue/compiler-ssr": "3.5.33",
+ "@vue/shared": "3.5.33",
+ "estree-walker": "^2.0.2",
+ "magic-string": "^0.30.21",
+ "postcss": "^8.5.10",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/@vue/compiler-ssr": {
+ "version": "3.5.33",
+ "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.33.tgz",
+ "integrity": "sha512-IErjYdnj1qIupG5xxiVIYiiRvDhGWV4zuh/RCrwfYpuL+HWQzeU6lCk/nF9r7olWMnjKxCAkOctT2qFWFkzb1A==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-dom": "3.5.33",
+ "@vue/shared": "3.5.33"
+ }
+ },
+ "node_modules/@vue/compiler-vue2": {
+ "version": "2.7.16",
+ "resolved": "https://registry.npmmirror.com/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz",
+ "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "de-indent": "^1.0.2",
+ "he": "^1.2.0"
+ }
+ },
+ "node_modules/@vue/language-core": {
+ "version": "2.2.12",
+ "resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-2.2.12.tgz",
+ "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/language-core": "2.4.15",
+ "@vue/compiler-dom": "^3.5.0",
+ "@vue/compiler-vue2": "^2.7.16",
+ "@vue/shared": "^3.5.0",
+ "alien-signals": "^1.0.3",
+ "minimatch": "^9.0.3",
+ "muggle-string": "^0.4.1",
+ "path-browserify": "^1.0.1"
+ },
+ "peerDependencies": {
+ "typescript": "*"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vue/reactivity": {
+ "version": "3.5.33",
+ "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.33.tgz",
+ "integrity": "sha512-p8UfIqyIhb0rYGlSgSBV+lPhF2iUSBcRy7enhTmPqKWadHy9kcOFYF1AejYBP9P+avnd3OBbD49DU4pLWX/94A==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/shared": "3.5.33"
+ }
+ },
+ "node_modules/@vue/runtime-core": {
+ "version": "3.5.33",
+ "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.33.tgz",
+ "integrity": "sha512-UpFF45RI9//a7rvq7RdOQblb4tup7hHG9QsmIrxkFQLzQ7R8/iNQ5LE15NhLZ1/WcHMU2b47u6P33CPUelHyIQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/reactivity": "3.5.33",
+ "@vue/shared": "3.5.33"
+ }
+ },
+ "node_modules/@vue/runtime-dom": {
+ "version": "3.5.33",
+ "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.33.tgz",
+ "integrity": "sha512-IOxMsAOwquhfITgmOgaPYl7/j8gKUxUFoflRc+u4LxyD3+783xne8vNta1PONVCvCV9A0w7hkyEepINDqfO0tw==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/reactivity": "3.5.33",
+ "@vue/runtime-core": "3.5.33",
+ "@vue/shared": "3.5.33",
+ "csstype": "^3.2.3"
+ }
+ },
+ "node_modules/@vue/server-renderer": {
+ "version": "3.5.33",
+ "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.33.tgz",
+ "integrity": "sha512-0xylq/8/h44lVG0pZFknv1XIdEgymq2E9n59uTWJBG+dIgiT0TMCSsxrN7nO16Z0MU0MPjFcguBbZV8Itk52Hw==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-ssr": "3.5.33",
+ "@vue/shared": "3.5.33"
+ },
+ "peerDependencies": {
+ "vue": "3.5.33"
+ }
+ },
+ "node_modules/@vue/shared": {
+ "version": "3.5.33",
+ "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.33.tgz",
+ "integrity": "sha512-5vR2QIlmaLG77Ygd4pMP6+SGQ5yox9VhtnbDWTy9DzMzdmeLxZ1QqxrywEZ9sa1AVubfIJyaCG3ytyWU81ufcQ==",
+ "license": "MIT"
+ },
+ "node_modules/alien-signals": {
+ "version": "1.0.13",
+ "resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-1.0.13.tgz",
+ "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/async-validator": {
+ "version": "4.2.5",
+ "resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz",
+ "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==",
+ "license": "MIT"
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/brace-expansion": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.0.tgz",
+ "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/css-render": {
+ "version": "0.15.14",
+ "resolved": "https://registry.npmmirror.com/css-render/-/css-render-0.15.14.tgz",
+ "integrity": "sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==",
+ "license": "MIT",
+ "dependencies": {
+ "@emotion/hash": "~0.8.0",
+ "csstype": "~3.0.5"
+ }
+ },
+ "node_modules/css-render/node_modules/csstype": {
+ "version": "3.0.11",
+ "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.0.11.tgz",
+ "integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==",
+ "license": "MIT"
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "license": "MIT"
+ },
+ "node_modules/date-fns": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmmirror.com/date-fns/-/date-fns-4.1.0.tgz",
+ "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/kossnocorp"
+ }
+ },
+ "node_modules/date-fns-tz": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmmirror.com/date-fns-tz/-/date-fns-tz-3.2.0.tgz",
+ "integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "date-fns": "^3.0.0 || ^4.0.0"
+ }
+ },
+ "node_modules/de-indent": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz",
+ "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz",
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz",
+ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+ "license": "MIT"
+ },
+ "node_modules/evtd": {
+ "version": "0.2.4",
+ "resolved": "https://registry.npmmirror.com/evtd/-/evtd-0.2.4.tgz",
+ "integrity": "sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==",
+ "license": "MIT"
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/he": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz",
+ "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "he": "bin/he"
+ }
+ },
+ "node_modules/highlight.js": {
+ "version": "11.11.1",
+ "resolved": "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.11.1.tgz",
+ "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/lodash": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz",
+ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
+ "license": "MIT"
+ },
+ "node_modules/lodash-es": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz",
+ "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
+ "license": "MIT"
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "9.0.9",
+ "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz",
+ "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/muggle-string": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz",
+ "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/naive-ui": {
+ "version": "2.44.1",
+ "resolved": "https://registry.npmmirror.com/naive-ui/-/naive-ui-2.44.1.tgz",
+ "integrity": "sha512-reo8Esw0p58liZwbUutC7meW24Xbn3EwNv91zReWKm2W4JPu+zfgJRn/F7aO0BFmvN+h2brA2M5lRvYqLq4kuA==",
+ "license": "MIT",
+ "dependencies": {
+ "@css-render/plugin-bem": "^0.15.14",
+ "@css-render/vue3-ssr": "^0.15.14",
+ "@types/lodash": "^4.17.20",
+ "@types/lodash-es": "^4.17.12",
+ "async-validator": "^4.2.5",
+ "css-render": "^0.15.14",
+ "csstype": "^3.1.3",
+ "date-fns": "^4.1.0",
+ "date-fns-tz": "^3.2.0",
+ "evtd": "^0.2.4",
+ "highlight.js": "^11.8.0",
+ "lodash": "^4.17.21",
+ "lodash-es": "^4.17.21",
+ "seemly": "^0.3.10",
+ "treemate": "^0.3.11",
+ "vdirs": "^0.1.8",
+ "vooks": "^0.2.12",
+ "vueuc": "^0.4.65"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "peerDependencies": {
+ "vue": "^3.0.0"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/path-browserify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",
+ "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.10",
+ "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.10.tgz",
+ "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.60.2.tgz",
+ "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.60.2",
+ "@rollup/rollup-android-arm64": "4.60.2",
+ "@rollup/rollup-darwin-arm64": "4.60.2",
+ "@rollup/rollup-darwin-x64": "4.60.2",
+ "@rollup/rollup-freebsd-arm64": "4.60.2",
+ "@rollup/rollup-freebsd-x64": "4.60.2",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.60.2",
+ "@rollup/rollup-linux-arm-musleabihf": "4.60.2",
+ "@rollup/rollup-linux-arm64-gnu": "4.60.2",
+ "@rollup/rollup-linux-arm64-musl": "4.60.2",
+ "@rollup/rollup-linux-loong64-gnu": "4.60.2",
+ "@rollup/rollup-linux-loong64-musl": "4.60.2",
+ "@rollup/rollup-linux-ppc64-gnu": "4.60.2",
+ "@rollup/rollup-linux-ppc64-musl": "4.60.2",
+ "@rollup/rollup-linux-riscv64-gnu": "4.60.2",
+ "@rollup/rollup-linux-riscv64-musl": "4.60.2",
+ "@rollup/rollup-linux-s390x-gnu": "4.60.2",
+ "@rollup/rollup-linux-x64-gnu": "4.60.2",
+ "@rollup/rollup-linux-x64-musl": "4.60.2",
+ "@rollup/rollup-openbsd-x64": "4.60.2",
+ "@rollup/rollup-openharmony-arm64": "4.60.2",
+ "@rollup/rollup-win32-arm64-msvc": "4.60.2",
+ "@rollup/rollup-win32-ia32-msvc": "4.60.2",
+ "@rollup/rollup-win32-x64-gnu": "4.60.2",
+ "@rollup/rollup-win32-x64-msvc": "4.60.2",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/seemly": {
+ "version": "0.3.10",
+ "resolved": "https://registry.npmmirror.com/seemly/-/seemly-0.3.10.tgz",
+ "integrity": "sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q==",
+ "license": "MIT"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.16",
+ "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.16.tgz",
+ "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/treemate": {
+ "version": "0.3.11",
+ "resolved": "https://registry.npmmirror.com/treemate/-/treemate-0.3.11.tgz",
+ "integrity": "sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg==",
+ "license": "MIT"
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/vdirs": {
+ "version": "0.1.8",
+ "resolved": "https://registry.npmmirror.com/vdirs/-/vdirs-0.1.8.tgz",
+ "integrity": "sha512-H9V1zGRLQZg9b+GdMk8MXDN2Lva0zx72MPahDKc30v+DtwKjfyOSXWRIX4t2mhDubM1H09gPhWeth/BJWPHGUw==",
+ "license": "MIT",
+ "dependencies": {
+ "evtd": "^0.2.2"
+ },
+ "peerDependencies": {
+ "vue": "^3.0.11"
+ }
+ },
+ "node_modules/vite": {
+ "version": "6.4.2",
+ "resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.2.tgz",
+ "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.25.0",
+ "fdir": "^6.4.4",
+ "picomatch": "^4.0.2",
+ "postcss": "^8.5.3",
+ "rollup": "^4.34.9",
+ "tinyglobby": "^0.2.13"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "jiti": ">=1.21.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vooks": {
+ "version": "0.2.12",
+ "resolved": "https://registry.npmmirror.com/vooks/-/vooks-0.2.12.tgz",
+ "integrity": "sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q==",
+ "license": "MIT",
+ "dependencies": {
+ "evtd": "^0.2.2"
+ },
+ "peerDependencies": {
+ "vue": "^3.0.0"
+ }
+ },
+ "node_modules/vscode-uri": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz",
+ "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vue": {
+ "version": "3.5.33",
+ "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.33.tgz",
+ "integrity": "sha512-1AgChhx5w3ALgT4oK3acm2Es/7jyZhWSVUfs3rOBlGQC0rjEDkS7G4lWlJJGGNQD+BV3reCwbQrOe1mPNwKHBQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-dom": "3.5.33",
+ "@vue/compiler-sfc": "3.5.33",
+ "@vue/runtime-dom": "3.5.33",
+ "@vue/server-renderer": "3.5.33",
+ "@vue/shared": "3.5.33"
+ },
+ "peerDependencies": {
+ "typescript": "*"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vue-tsc": {
+ "version": "2.2.12",
+ "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-2.2.12.tgz",
+ "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/typescript": "2.4.15",
+ "@vue/language-core": "2.2.12"
+ },
+ "bin": {
+ "vue-tsc": "bin/vue-tsc.js"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.0.0"
+ }
+ },
+ "node_modules/vueuc": {
+ "version": "0.4.65",
+ "resolved": "https://registry.npmmirror.com/vueuc/-/vueuc-0.4.65.tgz",
+ "integrity": "sha512-lXuMl+8gsBmruudfxnMF9HW4be8rFziylXFu1VHVNbLVhRTXXV4njvpRuJapD/8q+oFEMSfQMH16E/85VoWRyQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@css-render/vue3-ssr": "^0.15.10",
+ "@juggle/resize-observer": "^3.3.1",
+ "css-render": "^0.15.10",
+ "evtd": "^0.2.4",
+ "seemly": "^0.3.6",
+ "vdirs": "^0.1.4",
+ "vooks": "^0.2.4"
+ },
+ "peerDependencies": {
+ "vue": "^3.0.11"
+ }
+ }
+ }
+}
diff --git a/web/package.json b/web/package.json
new file mode 100644
index 0000000..c5f5ca0
--- /dev/null
+++ b/web/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "glm-desk-web",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite --host 127.0.0.1 --port 5173",
+ "build": "vue-tsc --noEmit && vite build",
+ "preview": "vite preview --host 127.0.0.1 --port 4173",
+ "typecheck": "vue-tsc --noEmit"
+ },
+ "dependencies": {
+ "@vicons/ionicons5": "^0.13.0",
+ "naive-ui": "^2.42.0",
+ "vue": "^3.5.13"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-vue": "^5.2.1",
+ "typescript": "^5.7.2",
+ "vite": "^6.0.7",
+ "vue-tsc": "^2.2.0"
+ }
+}
\ No newline at end of file
diff --git a/web/src/App.vue b/web/src/App.vue
new file mode 100644
index 0000000..ae28707
--- /dev/null
+++ b/web/src/App.vue
@@ -0,0 +1,140 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ logMeta || copy.app.logsToday }}
+ {{ copy.app.refreshLogs }}
+
+
+
+
+
+
diff --git a/web/src/DESIGN_SYSTEM.md b/web/src/DESIGN_SYSTEM.md
new file mode 100644
index 0000000..140180c
--- /dev/null
+++ b/web/src/DESIGN_SYSTEM.md
@@ -0,0 +1,41 @@
+# GLM Desk Design System
+
+Source: `ui-ux-pro-max` data-dense operations dashboard recommendation.
+
+## Product Pattern
+
+GLM Desk is an operations dashboard, not a marketing page. The interface prioritizes fast scanning, dense account rows, clear action states, and QR visibility.
+
+## Tokens
+
+- Background: layered warm/cool radial gradients over `#f8fafc`.
+- Panel: `rgba(255, 255, 255, 0.88)` with `0 24px 70px rgba(15, 23, 42, 0.1)`.
+- Ink: `#172033` for primary text.
+- Muted: `#64748b` for secondary text.
+- Accent: `#c99724`, keeping the old gold direction.
+- Support blue: `#3b82f6` for operational status surfaces.
+- Warm: `#f97316` for QR/payment attention.
+- Radius: 14px controls, 20-28px panels.
+
+## Layout
+
+- `AppShell` owns the hero command bar and primary actions.
+- `DashboardStats` creates three quick KPI cards: accounts, running flows, QR-ready rows.
+- `AccountTable` keeps dense row operations on desktop.
+- Mobile and narrow windows use a focusable horizontal table region to avoid viewport breakage.
+- `ImportAccountModal` and `AccountContextModal` keep destructive/long-form content out of the main table.
+
+## States
+
+- Loading: table content is wrapped in `NSpin`.
+- Empty: `NEmpty` is rendered when no accounts exist.
+- Error/success: `StatusBanner` uses `NAlert` and receives all async errors from `useDashboard`.
+- Pending actions: per-action loading keys disable visual ambiguity during import, sync, delete, run, and pause.
+
+## Accessibility
+
+- Primary interactive elements are real buttons, not clickable divs.
+- Focus rings are visible for keyboard users.
+- Table overflow container has `role="region"`, an accessible label, and `tabindex="0"`.
+- QR image uses descriptive alt text and lazy loading.
+- Motion is wrapped in `prefers-reduced-motion: no-preference`.
\ No newline at end of file
diff --git a/web/src/components/AccountContextModal.vue b/web/src/components/AccountContextModal.vue
new file mode 100644
index 0000000..936f342
--- /dev/null
+++ b/web/src/components/AccountContextModal.vue
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+ {{ copy.contextModal.label }} {{ detail.account.label }}
+ {{ copy.contextModal.customer }} {{ detail.session.customer_number || "-" }}
+ {{ copy.contextModal.name }} {{ detail.session.customer_name || "-" }}
+ {{ copy.contextModal.status }} {{ detail.account.account_status || copy.contextModal.unchecked }}
+ {{ copy.contextModal.schedule }} {{ detail.account.schedule_enabled ? detail.account.scheduled_start_time : copy.contextModal.disabled }}
+ {{ copy.contextModal.browser }} {{ detail.account.browser_impersonate || "-" }}
+
+
+
+
+
\ No newline at end of file
diff --git a/web/src/components/AccountTable.vue b/web/src/components/AccountTable.vue
new file mode 100644
index 0000000..7109d6e
--- /dev/null
+++ b/web/src/components/AccountTable.vue
@@ -0,0 +1,204 @@
+
+
+
+
+
+
+
+ {{ copy.table.title }}
+ {{ copy.table.subtitle(details.length) }}
+
+
+
+
+
+
+
+ {{ copy.table.columns.account }}
+ {{ copy.table.columns.product }}
+ {{ copy.table.columns.schedule }}
+ {{ copy.table.columns.status }}
+ {{ copy.table.columns.latestQr }}
+ {{ copy.table.columns.actions }}
+
+
+
+
+
+
+
+
+
+ {{ detail.account.label }}
+
+ {{ displayMode(detail) }} / {{ detail.account.browser_impersonate || copy.table.browserPending }}
+
+
+
+
+
+
+ emit('updateSchedule', id, enabled, time)"
+ />
+
+ {{ copy.table.previewConcurrency }}
+
+ 1
+ 2
+ 3
+ 4
+
+
+
+
+
+
+
+ {{ detail.account.account_status || copy.table.unchecked }}
+
+ {{ detail.account.last_schedule_status || detail.account.account_status_message || copy.table.noRecentEvent }}
+
+ {{ detail.account.account_status_message }}
+
+ {{ copy.table.scheduleState }}: {{ scheduleStateText(detail) }}
+
+ {{ copy.table.bizId }}: {{ latestTask(detail)?.biz_id }}
+
+
+
+
+
+
+
+ {{ isRunning(detail) ? copy.table.pause : copy.table.run }}
+
+
+ {{ copy.table.probe }}
+
+
+ {{ copy.table.syncFingerprint }}
+
+
+
+
+ {{ copy.table.delete }}
+
+
+ {{ copy.table.deleteConfirm }}
+
+
+
+
+
+
+
diff --git a/web/src/components/AppShell.vue b/web/src/components/AppShell.vue
new file mode 100644
index 0000000..166d8a9
--- /dev/null
+++ b/web/src/components/AppShell.vue
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+ {{ copy.app.title }}
+ {{ copy.app.eyebrow }}
+
+
+ {{ health?.transport || copy.app.transportPending }}
+ {{ copy.app.viewLogs }}
+ {{ copy.app.refresh }}
+ {{ copy.app.importAccount }}
+
+
+
+
+
+
diff --git a/web/src/components/DashboardStats.vue b/web/src/components/DashboardStats.vue
new file mode 100644
index 0000000..ceee40f
--- /dev/null
+++ b/web/src/components/DashboardStats.vue
@@ -0,0 +1,29 @@
+
+
+
+
+
+ {{ copy.stats.accounts }}
+ {{ accountsTotal }}
+ {{ copy.stats.accountsHint }}
+
+
+ {{ copy.stats.running }}
+ {{ runningTotal }}
+ {{ copy.stats.runningHint }}
+
+
+ {{ copy.stats.qrReady }}
+ {{ qrTotal }}
+ {{ copy.stats.qrReadyHint }}
+
+
+
\ No newline at end of file
diff --git a/web/src/components/ImportAccountModal.vue b/web/src/components/ImportAccountModal.vue
new file mode 100644
index 0000000..0f41a98
--- /dev/null
+++ b/web/src/components/ImportAccountModal.vue
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ copy.importModal.cancel }}
+
+ {{ copy.importModal.submit }}
+
+
+
+
+
\ No newline at end of file
diff --git a/web/src/components/QrPreview.vue b/web/src/components/QrPreview.vue
new file mode 100644
index 0000000..7118970
--- /dev/null
+++ b/web/src/components/QrPreview.vue
@@ -0,0 +1,36 @@
+
+
+
+
+
+
{{ copy.qr.empty }}
+
{{ paymentSummary() }}
+
+
diff --git a/web/src/components/ScheduleEditor.vue b/web/src/components/ScheduleEditor.vue
new file mode 100644
index 0000000..80a9398
--- /dev/null
+++ b/web/src/components/ScheduleEditor.vue
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+ {{ normalizedTime }}
+
+
diff --git a/web/src/components/StatusBanner.vue b/web/src/components/StatusBanner.vue
new file mode 100644
index 0000000..51b4c5f
--- /dev/null
+++ b/web/src/components/StatusBanner.vue
@@ -0,0 +1,33 @@
+
+
+
+
+ {{ banner.text }}
+
+ {{ banner.linkText || "打开" }}
+
+
+
diff --git a/web/src/composables/useDashboard.ts b/web/src/composables/useDashboard.ts
new file mode 100644
index 0000000..bb1c4bd
--- /dev/null
+++ b/web/src/composables/useDashboard.ts
@@ -0,0 +1,198 @@
+import { computed, onBeforeUnmount, ref } from "vue";
+import { zhCN as copy } from "../locales/zhCN";
+import { api } from "../services/api";
+import type {
+ AccountDetailResponse,
+ AccountImportPayload,
+ AccountPreferencesPayload,
+ HealthPayload
+} from "../types/api";
+
+export type BannerTone = "success" | "warning" | "error" | "info";
+
+export interface StatusBanner {
+ tone: BannerTone;
+ text: string;
+ linkText?: string;
+ linkHref?: string;
+}
+
+const POLL_INTERVAL_MS = 5000;
+
+export function useDashboard() {
+ const details = ref([]);
+ const health = ref(null);
+ const loading = ref(false);
+ const actionKey = ref("");
+ const banner = ref(null);
+ let pollTimer: number | undefined;
+ let qrReminderReady = false;
+ let knownQrTaskKeys = new Set();
+
+ const accountsTotal = computed(() => details.value.length);
+ const runningTotal = computed(
+ () =>
+ details.value.filter(({ account }) =>
+ ["running", "pause_requested"].includes(String(account.last_schedule_status || "").toLowerCase())
+ ).length
+ );
+ const qrTotal = computed(() => details.value.filter(({ tasks }) => Boolean(tasks?.[0]?.qr_base64)).length);
+
+ function setBanner(text: string, tone: BannerTone = "success", link?: { text: string; href: string }) {
+ banner.value = { text, tone, linkText: link?.text, linkHref: link?.href };
+ }
+
+ function clearBanner() {
+ banner.value = null;
+ }
+
+ function qrTaskKey(detail: AccountDetailResponse) {
+ const task = detail.tasks?.[0];
+ if (!task?.qr_base64) {
+ return "";
+ }
+ return `${detail.account.id}:${task.id || task.biz_id || task.updated_at || ""}`;
+ }
+
+ function qrImageUrl(detail: AccountDetailResponse) {
+ const task = detail.tasks?.[0];
+ if (!task?.id || !task.qr_base64) {
+ return "";
+ }
+ return `/api/accounts/${encodeURIComponent(detail.account.id)}/tasks/${encodeURIComponent(task.id)}/qr.png`;
+ }
+
+ function applyQrGeneratedReminder(nextDetails: AccountDetailResponse[]) {
+ const currentKeys = new Set();
+ const newQrDetails: AccountDetailResponse[] = [];
+ const shouldNotify = qrReminderReady && nextDetails.length > 1;
+
+ for (const detail of nextDetails) {
+ const key = qrTaskKey(detail);
+ if (!key) {
+ continue;
+ }
+ currentKeys.add(key);
+ if (shouldNotify && !knownQrTaskKeys.has(key)) {
+ newQrDetails.push(detail);
+ }
+ }
+
+ knownQrTaskKeys = currentKeys;
+ if (!qrReminderReady) {
+ qrReminderReady = true;
+ return false;
+ }
+ if (newQrDetails.length === 0) {
+ return false;
+ }
+
+ const labels = newQrDetails.map((detail) => detail.account.label || detail.account.id);
+ const firstTask = newQrDetails[0]?.tasks?.[0];
+ const text =
+ newQrDetails.length === 1
+ ? copy.feedback.qrGenerated(labels[0], firstTask?.biz_id || "")
+ : copy.feedback.qrGeneratedBatch(newQrDetails.length, labels.slice(0, 3).join("、"));
+ const qrUrl = newQrDetails.length === 1 ? qrImageUrl(newQrDetails[0]) : "";
+ setBanner(text, "warning", qrUrl ? { text: copy.feedback.openQr, href: qrUrl } : undefined);
+ return true;
+ }
+
+ async function refreshDashboard(silent = false) {
+ if (!silent) {
+ loading.value = true;
+ }
+ try {
+ const [healthPayload, accounts] = await Promise.all([api.health(), api.listAccounts()]);
+ const detailPayloads = await Promise.all(accounts.map((account) => api.getAccount(account.id)));
+ health.value = healthPayload;
+ details.value = detailPayloads;
+ const qrReminderShown = applyQrGeneratedReminder(detailPayloads);
+ if (!silent && !qrReminderShown) {
+ setBanner(copy.feedback.dashboardRefreshed, "success");
+ }
+ } catch (error) {
+ setBanner(error instanceof Error ? error.message : copy.feedback.dashboardRefreshFailed, "error");
+ } finally {
+ loading.value = false;
+ }
+ }
+
+ function startPolling() {
+ stopPolling();
+ pollTimer = window.setInterval(() => {
+ void refreshDashboard(true);
+ }, POLL_INTERVAL_MS);
+ }
+
+ function stopPolling() {
+ if (pollTimer) {
+ window.clearInterval(pollTimer);
+ pollTimer = undefined;
+ }
+ }
+
+ async function runAction(key: string, successText: string, action: () => Promise) {
+ actionKey.value = key;
+ try {
+ await action();
+ setBanner(successText, "success");
+ await refreshDashboard(true);
+ } catch (error) {
+ setBanner(error instanceof Error ? error.message : copy.feedback.operationFailed, "error");
+ } finally {
+ actionKey.value = "";
+ }
+ }
+
+ async function importAccount(payload: AccountImportPayload) {
+ await runAction("import", copy.feedback.accountImported, () => api.importAccount(payload));
+ }
+
+ async function updatePreferences(accountId: string, payload: AccountPreferencesPayload) {
+ await runAction(`prefs:${accountId}`, copy.feedback.preferencesSaved, () => api.updateAccount(accountId, payload));
+ }
+
+ async function syncAccount(accountId: string) {
+ await runAction(`sync:${accountId}`, copy.feedback.accountSynced, () => api.bootstrapAccount(accountId, true));
+ }
+
+ async function deleteAccount(accountId: string) {
+ await runAction(`delete:${accountId}`, copy.feedback.accountDeleted, () => api.deleteAccount(accountId));
+ }
+
+ async function runAccount(accountId: string) {
+ await runAction(`run:${accountId}`, copy.feedback.paymentStarted, () => api.runAccount(accountId));
+ }
+
+ async function probeAccount(accountId: string) {
+ await runAction(`probe:${accountId}`, copy.feedback.probeStarted, () => api.probeAccount(accountId));
+ }
+
+ async function pauseAccount(accountId: string) {
+ await runAction(`pause:${accountId}`, copy.feedback.pauseRequested, () => api.pauseAccount(accountId));
+ }
+
+ onBeforeUnmount(stopPolling);
+
+ return {
+ actionKey,
+ accountsTotal,
+ banner,
+ clearBanner,
+ deleteAccount,
+ details,
+ health,
+ importAccount,
+ loading,
+ pauseAccount,
+ probeAccount,
+ qrTotal,
+ refreshDashboard,
+ runningTotal,
+ runAccount,
+ startPolling,
+ syncAccount,
+ updatePreferences
+ };
+}
diff --git a/web/src/env.d.ts b/web/src/env.d.ts
new file mode 100644
index 0000000..98a5402
--- /dev/null
+++ b/web/src/env.d.ts
@@ -0,0 +1,5 @@
+declare module "*.vue" {
+ import type { DefineComponent } from "vue";
+ const component: DefineComponent;
+ export default component;
+}
\ No newline at end of file
diff --git a/web/src/locales/zhCN.ts b/web/src/locales/zhCN.ts
new file mode 100644
index 0000000..ad9c36f
--- /dev/null
+++ b/web/src/locales/zhCN.ts
@@ -0,0 +1,104 @@
+export const zhCN = {
+ app: {
+ title: "GLM Desk",
+ eyebrow: "本地运营控制台",
+ description: "集中管理账号编排、支付二维码、定时任务和上下文排查。",
+ transportPending: "传输状态待确认",
+ primaryActionsLabel: "主要操作",
+ commandCenterLabel: "GLM Desk 控制中心",
+ viewLogs: "查看日志",
+ logsTitle: "当日运行日志",
+ logsToday: "今日日志",
+ refreshLogs: "刷新日志",
+ noLogs: "今天暂无运行日志",
+ refresh: "刷新列表",
+ importAccount: "导入账号"
+ },
+ stats: {
+ ariaLabel: "控制台概览",
+ accounts: "账号总数",
+ accountsHint: "当前已导入账号",
+ running: "运行中",
+ runningHint: "正在运行或请求暂停的任务",
+ qrReady: "二维码就绪",
+ qrReadyHint: "已有最新二维码的账号"
+ },
+ table: {
+ title: "账号工作台",
+ subtitle: (count: number) => `${count} 个账号,每 5 秒自动刷新`,
+ regionLabel: "账号列表表格",
+ columns: {
+ account: "账号",
+ product: "套餐",
+ schedule: "定时",
+ status: "状态",
+ latestQr: "最新二维码",
+ actions: "操作"
+ },
+ empty: "还没有导入账号",
+ browserPending: "浏览器指纹待同步",
+ selectProduct: "选择套餐",
+ unchecked: "未检查",
+ noRecentEvent: "暂无最近事件",
+ run: "启动",
+ pause: "暂停",
+ probe: "测活",
+ bizId: "bizId",
+ previewConcurrency: "Preview 并发",
+ scheduleState: "定时",
+ scheduleEnabled: "已启用",
+ scheduleDisabled: "未启用",
+ syncFingerprint: "同步并换指纹",
+ delete: "删除",
+ deleteConfirm: "确定删除这个账号及其本地缓存吗?",
+ modes: {
+ newPurchase: "新购",
+ upgrade: "升级"
+ }
+ },
+ importModal: {
+ title: "导入账号",
+ label: "账号备注",
+ labelPlaceholder: "例如 year-max-upgrade",
+ invitationCode: "邀请码",
+ token: "bigmodel_token_production",
+ tokenPlaceholder: "粘贴 token 值",
+ cancel: "取消",
+ submit: "确认导入并同步"
+ },
+ contextModal: {
+ title: "账号上下文",
+ label: "备注",
+ customer: "客户编号",
+ name: "客户名称",
+ status: "账号状态",
+ schedule: "定时任务",
+ browser: "浏览器指纹",
+ disabled: "未启用",
+ unchecked: "未检查"
+ },
+ schedule: {
+ enableLabel: "启用定时任务",
+ timeLabel: "定时启动时间"
+ },
+ qr: {
+ alt: "最新支付二维码",
+ empty: "暂无二维码"
+ },
+ feedback: {
+ dashboardRefreshed: "列表已刷新",
+ dashboardRefreshFailed: "列表刷新失败",
+ operationFailed: "操作失败",
+ accountImported: "账号已导入并同步",
+ preferencesSaved: "偏好设置已保存",
+ accountSynced: "账号已同步并更换指纹",
+ accountDeleted: "账号已删除",
+ paymentStarted: "支付流程已启动",
+ probeStarted: "测活任务已启动",
+ pauseRequested: "已请求暂停",
+ openQr: "打开二维码",
+ qrGenerated: (label: string, bizId: string) =>
+ `账号 ${label} 已生成支付二维码${bizId ? `,bizId: ${bizId}` : ""}`,
+ qrGeneratedBatch: (count: number, labels: string) => `${count} 个账号已生成支付二维码:${labels}`
+ }
+} as const;
diff --git a/web/src/main.ts b/web/src/main.ts
new file mode 100644
index 0000000..33d5009
--- /dev/null
+++ b/web/src/main.ts
@@ -0,0 +1,49 @@
+import { createApp } from "vue";
+import {
+ create,
+ NAlert,
+ NButton,
+ NCard,
+ NConfigProvider,
+ NDrawer,
+ NDrawerContent,
+ NEmpty,
+ NForm,
+ NFormItem,
+ NImage,
+ NInput,
+ NModal,
+ NPopconfirm,
+ NSelect,
+ NSpin,
+ NSwitch,
+ NTable,
+ NTag
+} from "naive-ui";
+import App from "./App.vue";
+import "./styles.css";
+
+const naive = create({
+ components: [
+ NAlert,
+ NButton,
+ NCard,
+ NConfigProvider,
+ NDrawer,
+ NDrawerContent,
+ NEmpty,
+ NForm,
+ NFormItem,
+ NImage,
+ NInput,
+ NModal,
+ NPopconfirm,
+ NSelect,
+ NSpin,
+ NSwitch,
+ NTable,
+ NTag
+ ]
+});
+
+createApp(App).use(naive).mount("#app");
diff --git a/web/src/services/API_CONTRACT.md b/web/src/services/API_CONTRACT.md
new file mode 100644
index 0000000..4c3d6dd
--- /dev/null
+++ b/web/src/services/API_CONTRACT.md
@@ -0,0 +1,35 @@
+# Frontend API Contract
+
+The Vue frontend keeps the existing FastAPI contract unchanged. All endpoints return the backend wrapper shape:
+
+```ts
+interface ApiResponse {
+ ok: boolean;
+ data?: T;
+ error?: { message?: string; details?: unknown };
+}
+```
+
+## Dashboard bootstrap
+
+- `GET /healthz` -> `HealthPayload`, used for transport/status metadata.
+- `GET /api/accounts` -> `PublicAccountRecord[]`, used as the dashboard index.
+- `GET /api/accounts/{account_id}` -> `AccountDetailResponse`, used for row details, products, latest task, QR, and context modal.
+
+Current dashboard refresh intentionally preserves the legacy N+1 pattern: list accounts first, then fetch details concurrently. A future `GET /api/dashboard` aggregate endpoint should replace this once the SPA is stable.
+
+## Account management
+
+- `POST /api/accounts/import` with `AccountImportPayload` -> imports and syncs an account.
+- `PATCH /api/accounts/{account_id}` with `AccountPreferencesPayload` -> updates selected product and schedule preferences.
+- `DELETE /api/accounts/{account_id}` -> deletes account and local cache.
+- `POST /api/accounts/{account_id}/bootstrap?refresh_fingerprint=true` -> syncs account context and rotates fingerprint.
+
+## Flow controls
+
+- `POST /api/accounts/{account_id}/run` -> starts the payment flow.
+- `POST /api/accounts/{account_id}/pause` -> requests pause for the active flow.
+
+## Error handling
+
+`apiClient` unwraps `data` on success and throws `ApiClientError` on failed HTTP status or `{ ok: false }`. The dashboard composable converts thrown errors into a visible status banner and clears per-action loading state in `finally`.
\ No newline at end of file
diff --git a/web/src/services/api.ts b/web/src/services/api.ts
new file mode 100644
index 0000000..f5b39c7
--- /dev/null
+++ b/web/src/services/api.ts
@@ -0,0 +1,72 @@
+import type {
+ AccountDetailResponse,
+ AccountImportPayload,
+ AccountPreferencesPayload,
+ ApiResponse,
+ HealthPayload,
+ RuntimeLogsPayload,
+ PublicAccountRecord
+} from "../types/api";
+
+export class ApiClientError extends Error {
+ details?: unknown;
+
+ constructor(message: string, details?: unknown) {
+ super(message);
+ this.name = "ApiClientError";
+ this.details = details;
+ }
+}
+
+async function request(path: string, options: RequestInit = {}): Promise {
+ const response = await fetch(path, {
+ ...options,
+ headers: {
+ "Content-Type": "application/json",
+ ...(options.headers || {})
+ }
+ });
+ const payload = (await response.json().catch(() => null)) as ApiResponse | null;
+ if (!response.ok || !payload?.ok) {
+ throw new ApiClientError(payload?.error?.message || `HTTP ${response.status}`, payload?.error?.details);
+ }
+ return payload.data as T;
+}
+
+export const api = {
+ health: () => request("/healthz"),
+ todayLogs: () => request("/api/logs/today"),
+ listAccounts: () => request("/api/accounts"),
+ getAccount: (accountId: string) => request(`/api/accounts/${encodeURIComponent(accountId)}`),
+ importAccount: (payload: AccountImportPayload) =>
+ request("/api/accounts/import", {
+ method: "POST",
+ body: JSON.stringify(payload)
+ }),
+ deleteAccount: (accountId: string) =>
+ request(`/api/accounts/${encodeURIComponent(accountId)}`, {
+ method: "DELETE"
+ }),
+ updateAccount: (accountId: string, payload: AccountPreferencesPayload) =>
+ request(`/api/accounts/${encodeURIComponent(accountId)}`, {
+ method: "PATCH",
+ body: JSON.stringify(payload)
+ }),
+ bootstrapAccount: (accountId: string, refreshFingerprint = true) =>
+ request(
+ `/api/accounts/${encodeURIComponent(accountId)}/bootstrap?refresh_fingerprint=${String(refreshFingerprint)}`,
+ { method: "POST" }
+ ),
+ runAccount: (accountId: string) =>
+ request(`/api/accounts/${encodeURIComponent(accountId)}/run`, {
+ method: "POST"
+ }),
+ probeAccount: (accountId: string) =>
+ request(`/api/accounts/${encodeURIComponent(accountId)}/probe`, {
+ method: "POST"
+ }),
+ pauseAccount: (accountId: string) =>
+ request(`/api/accounts/${encodeURIComponent(accountId)}/pause`, {
+ method: "POST"
+ })
+};
diff --git a/web/src/styles.css b/web/src/styles.css
new file mode 100644
index 0000000..839c39b
--- /dev/null
+++ b/web/src/styles.css
@@ -0,0 +1,612 @@
+@import url("https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;600&family=Fira+Sans:wght@300;400;500;600;700&display=swap");
+
+:root {
+ color: #172033;
+ background: #f3f6fb;
+ font-family: "Fira Sans", "Segoe UI", sans-serif;
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ --desk-bg: #f3f6fb;
+ --desk-panel: rgba(255, 255, 255, 0.96);
+ --desk-ink: #172033;
+ --desk-muted: #64748b;
+ --desk-line: #dce5f1;
+ --desk-soft: #f8fafc;
+ --desk-accent: #c99724;
+ --desk-accent-strong: #8f6a16;
+ --desk-blue: #3b82f6;
+ --desk-warm: #f97316;
+ --desk-shadow: 0 18px 48px rgba(15, 23, 42, 0.08);
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body,
+#app {
+ height: 100%;
+ min-height: 100%;
+ margin: 0;
+}
+
+body {
+ background:
+ radial-gradient(circle at 16% 0%, rgba(201, 151, 36, 0.1), transparent 20rem),
+ linear-gradient(180deg, #fbfbf8 0%, var(--desk-bg) 42%, #eef3f9 100%);
+ overflow: hidden;
+}
+
+button,
+input,
+textarea,
+select {
+ font: inherit;
+}
+
+button,
+[role="button"] {
+ cursor: pointer;
+}
+
+button:focus-visible,
+input:focus-visible,
+textarea:focus-visible,
+.ops-table:focus-visible {
+ outline: 3px solid rgba(201, 151, 36, 0.45);
+ outline-offset: 3px;
+}
+
+.desk-shell {
+ width: min(1840px, calc(100% - 24px));
+ height: 100dvh;
+ max-height: 100dvh;
+ min-height: 0;
+ margin: 0 auto;
+ padding: 10px 0 12px;
+ display: grid;
+ grid-template-rows: auto auto auto minmax(0, 1fr);
+ gap: 10px;
+ overflow: hidden;
+}
+
+.command-bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ min-height: 56px;
+ padding: 9px 14px;
+ border: 1px solid rgba(221, 228, 239, 0.95);
+ border-radius: 16px;
+ background: rgba(255, 255, 255, 0.92);
+ box-shadow: 0 10px 26px rgba(15, 23, 42, 0.05);
+}
+
+.command-title {
+ display: flex;
+ align-items: baseline;
+ gap: 12px;
+ min-width: 0;
+}
+
+.command-title strong {
+ color: var(--desk-ink);
+ font-size: 24px;
+ line-height: 1;
+ letter-spacing: -0.04em;
+ white-space: nowrap;
+}
+
+.command-title span {
+ color: var(--desk-muted);
+ font-size: 13px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.command-actions {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.status-banner {
+ box-shadow: 0 10px 28px rgba(15, 23, 42, 0.07);
+}
+
+.status-banner-link {
+ display: inline-flex;
+ align-items: center;
+ margin-left: 10px;
+ color: #7c4a03;
+ font-weight: 800;
+ text-decoration: underline;
+ text-underline-offset: 3px;
+}
+
+.status-banner-link:hover {
+ color: #4a2c00;
+}
+
+.stats-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 8px;
+}
+
+.stat-card {
+ border: 1px solid rgba(221, 228, 239, 0.9);
+ background: var(--desk-panel);
+ box-shadow: 0 10px 24px rgba(15, 23, 42, 0.045);
+}
+
+.stat-card .n-card__content {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ align-items: baseline;
+ gap: 4px 10px;
+ padding: 10px 14px !important;
+}
+
+.stat-label {
+ color: var(--desk-accent-strong);
+ font-size: 12px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.stat-card strong {
+ color: var(--desk-ink);
+ font-size: 24px;
+ line-height: 1;
+}
+
+.stat-card small {
+ grid-column: 1 / -1;
+ color: var(--desk-muted);
+ font-size: 12px;
+}
+
+.stat-card.accent {
+ background: linear-gradient(135deg, rgba(59, 130, 246, 0.09), rgba(255, 255, 255, 0.96));
+}
+
+.stat-card.warm {
+ background: linear-gradient(135deg, rgba(249, 115, 22, 0.1), rgba(255, 255, 255, 0.96));
+}
+
+.table-panel,
+.desk-modal {
+ box-shadow: var(--desk-shadow);
+}
+
+.table-panel {
+ border: 1px solid rgba(221, 228, 239, 0.9);
+ height: 100%;
+ max-height: 100%;
+ min-height: 0;
+ overflow: hidden;
+}
+
+.table-panel.n-card {
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr);
+}
+
+.table-panel .n-card-header {
+ padding: 14px 16px 8px !important;
+}
+
+.table-panel .n-card__content {
+ padding: 0 14px 16px !important;
+ height: 100%;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.panel-title {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+
+.panel-title > div {
+ display: flex;
+ align-items: baseline;
+ gap: 12px;
+}
+
+.panel-title span {
+ color: var(--desk-ink);
+ font-size: 19px;
+ font-weight: 800;
+}
+
+.panel-title small {
+ color: var(--desk-muted);
+}
+
+.ops-table {
+ width: 100%;
+ height: 100%;
+ max-height: 100%;
+ min-height: 0;
+ flex: 1;
+ overflow: auto;
+ overscroll-behavior: contain;
+ scrollbar-gutter: stable;
+ border-radius: 18px;
+}
+
+.table-spin,
+.table-spin .n-spin-content,
+.table-panel .n-spin-container,
+.table-panel .n-spin-content {
+ height: 100%;
+ max-height: 100%;
+ min-height: 0;
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.ops-table-head,
+.ops-row {
+ display: grid;
+ grid-template-columns: minmax(150px, 0.75fr) minmax(120px, 0.55fr) minmax(170px, 0.75fr) minmax(220px, 1fr) 320px minmax(170px, 0.75fr);
+ gap: 12px;
+ min-width: 1240px;
+}
+
+.ops-table-head {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ padding: 10px 14px;
+ color: #64748b;
+ font-size: 12px;
+ font-weight: 800;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
+ border: 1px solid var(--desk-line);
+ border-radius: 16px;
+}
+
+.ops-row {
+ align-items: stretch;
+ margin-top: 10px;
+ padding: 12px;
+ border: 1px solid #dbe5f1;
+ border-radius: 20px;
+ background:
+ linear-gradient(90deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.96)),
+ #fff;
+ box-shadow: 0 14px 32px rgba(15, 23, 42, 0.06);
+ transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease;
+}
+
+.ops-row:hover {
+ border-color: rgba(201, 151, 36, 0.48);
+ box-shadow: 0 20px 46px rgba(15, 23, 42, 0.1);
+ transform: translateY(-1px);
+}
+
+.ops-cell {
+ min-width: 0;
+ align-self: stretch;
+ display: grid;
+ align-content: center;
+ gap: 8px;
+ padding: 6px 0;
+}
+
+.account-cell,
+.status-cell,
+.qr-cell,
+.actions-cell,
+.schedule-cell {
+ display: grid;
+ gap: 8px;
+}
+
+.account-cell span,
+.status-cell small,
+.qr-cell small {
+ color: var(--desk-muted);
+ font-size: 12px;
+ line-height: 1.45;
+}
+
+.mono-line {
+ font-family: "Fira Code", ui-monospace, SFMono-Regular, Menlo, monospace;
+ overflow-wrap: anywhere;
+}
+
+.schedule-state-line {
+ color: #475569 !important;
+ font-weight: 600;
+}
+
+.account-state-line {
+ color: var(--desk-ink) !important;
+ font-weight: 700;
+}
+
+.link-button {
+ width: fit-content;
+ max-width: 100%;
+ border: 0;
+ padding: 0;
+ color: var(--desk-ink);
+ background: transparent;
+ font-size: 15px;
+ font-weight: 800;
+ text-align: left;
+ overflow-wrap: anywhere;
+}
+
+.link-button:hover {
+ color: var(--desk-accent-strong);
+}
+
+.product-cell {
+ align-content: center;
+}
+
+.schedule-cell {
+ grid-template-columns: auto 1fr;
+ align-items: center;
+ align-content: center;
+}
+
+.schedule-config {
+ display: grid;
+ gap: 8px;
+}
+
+.preview-race-control {
+ display: grid;
+ grid-template-columns: 1fr 64px;
+ align-items: center;
+ gap: 8px;
+ color: var(--desk-muted);
+ font-size: 12px;
+ font-weight: 700;
+}
+
+.preview-race-control select {
+ min-height: 34px;
+ border: 1px solid var(--desk-line);
+ border-radius: 12px;
+ padding: 4px 8px;
+ color: var(--desk-ink);
+ background: #fff;
+}
+
+.time-input {
+ min-height: 36px;
+ border: 1px solid var(--desk-line);
+ border-radius: 12px;
+ padding: 6px 10px;
+ color: var(--desk-ink);
+ background: #fff;
+}
+
+.schedule-time-readonly {
+ min-height: 36px;
+ display: inline-grid;
+ align-items: center;
+ border: 1px solid #bbf7d0;
+ border-radius: 12px;
+ padding: 6px 10px;
+ color: #166534;
+ background: #f0fdf4;
+ font-family: "Fira Code", ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 13px;
+ font-weight: 800;
+}
+
+.status-cell {
+ align-content: center;
+}
+
+.qr-column {
+ align-content: center;
+}
+
+.qr-cell {
+ justify-items: center;
+ align-content: center;
+ width: 320px;
+ padding: 0;
+ border: 0;
+ border-radius: 0;
+ background: transparent;
+}
+
+.qr-cell .n-image,
+.qr-cell .n-image img {
+ display: block;
+ width: 280px;
+ height: 280px;
+ max-width: none;
+ max-height: none;
+ border: 0;
+ border-radius: 0;
+ background: transparent;
+}
+
+.qr-cell .n-image {
+ cursor: zoom-in;
+}
+
+.qr-empty {
+ display: grid;
+ place-items: center;
+ width: 280px;
+ height: 280px;
+ border: 1px dashed #cbd5e1;
+ border-radius: 0;
+ color: var(--desk-muted);
+ background: #ffffff;
+ font-size: 14px;
+}
+
+.actions-cell {
+ align-content: center;
+}
+
+.actions-cell .n-button {
+ width: 100%;
+ justify-content: center;
+}
+
+.ops-empty {
+ display: grid;
+ place-items: center;
+ min-height: 260px;
+ margin-top: 10px;
+ border: 1px dashed #cbd5e1;
+ border-radius: 20px;
+ background: #fff;
+}
+
+.desk-modal {
+ width: min(620px, calc(100vw - 32px));
+}
+
+.desk-modal.wide {
+ width: min(980px, calc(100vw - 32px));
+}
+
+.modal-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 10px;
+ margin-top: 10px;
+}
+
+.context-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 12px;
+ margin-bottom: 14px;
+}
+
+.context-grid span {
+ display: block;
+ color: var(--desk-muted);
+ font-size: 12px;
+ margin-bottom: 4px;
+}
+
+.context-grid strong {
+ color: var(--desk-ink);
+ word-break: break-word;
+}
+
+.logs-toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 12px;
+ color: var(--desk-muted);
+ font-size: 13px;
+}
+
+.runtime-log-viewer textarea {
+ min-height: calc(100vh - 150px) !important;
+ resize: none !important;
+ white-space: pre;
+ overflow-wrap: normal;
+}
+
+.json-view textarea,
+.n-input[type="textarea"] textarea {
+ font-family: "Fira Code", ui-monospace, SFMono-Regular, Menlo, monospace;
+}
+
+@media (max-width: 900px) {
+ html,
+ body,
+ #app {
+ height: auto;
+ }
+
+ body {
+ overflow: auto;
+ }
+
+ .desk-shell {
+ width: min(100% - 16px, 1840px);
+ height: auto;
+ min-height: 100dvh;
+ padding-top: 8px;
+ overflow: visible;
+ }
+
+ .table-panel,
+ .table-panel .n-card__content,
+ .table-spin,
+ .table-spin .n-spin-content,
+ .ops-table {
+ overflow: visible;
+ }
+
+ .ops-table {
+ flex: none;
+ overflow-x: auto;
+ }
+
+ .command-bar {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ .command-title {
+ align-items: flex-start;
+ flex-direction: column;
+ gap: 4px;
+ }
+
+ .command-actions {
+ justify-content: flex-start;
+ }
+
+ .stats-grid,
+ .context-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ .command-bar,
+ .stat-card,
+ .table-panel {
+ animation: rise-in 220ms ease both;
+ }
+}
+
+@keyframes rise-in {
+ from {
+ opacity: 0;
+ transform: translateY(8px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
diff --git a/web/src/types/api.ts b/web/src/types/api.ts
new file mode 100644
index 0000000..594fc99
--- /dev/null
+++ b/web/src/types/api.ts
@@ -0,0 +1,135 @@
+export type PurchaseMode = "new_purchase" | "upgrade";
+export type PayType = "ALI" | "WE_CHAT";
+
+export interface ApiErrorPayload {
+ message?: string;
+ details?: unknown;
+}
+
+export interface ApiResponse {
+ ok: boolean;
+ data?: T;
+ error?: ApiErrorPayload;
+}
+
+export interface HealthPayload {
+ transport: string;
+ [key: string]: unknown;
+}
+
+export interface RuntimeLogsPayload {
+ date: string;
+ path: string;
+ lines: string[];
+ text: string;
+ truncated?: boolean;
+ total?: number;
+}
+
+export interface ProductOffer {
+ product_id: string;
+ product_name: string;
+ unit: string;
+ sale_price: string;
+ plan_type: string;
+ purchase_mode: PurchaseMode;
+ version?: string;
+ sold_out?: boolean;
+ forbidden?: boolean;
+ last_valid?: boolean;
+ can_repurchase?: boolean;
+ delay?: boolean;
+ effective_time?: string;
+ monthly_renew_amount?: string;
+ monthly_original_amount?: string;
+ campaign_discount_details?: Record[];
+ raw?: Record;
+}
+
+export interface PublicAccountRecord {
+ id: string;
+ label: string;
+ org_id?: string;
+ project_id?: string;
+ invitation_code?: string;
+ proxy_url?: string;
+ user_agent?: string;
+ browser_impersonate?: string;
+ preview_concurrency?: number;
+ schedule_enabled?: boolean;
+ scheduled_start_time?: string;
+ last_scheduled_run_at?: string | null;
+ last_scheduled_run_key?: string;
+ last_manual_run_at?: string | null;
+ last_schedule_status?: string;
+ last_schedule_message?: string;
+ account_status?: string;
+ account_status_message?: string;
+ account_checked_at?: string | null;
+ has_token?: boolean;
+ token_preview?: string;
+ has_cookie_header?: boolean;
+ last_bootstrap_at?: string | null;
+ created_at?: string;
+ updated_at?: string;
+}
+
+export interface AccountSessionState {
+ account_id: string;
+ org_id?: string;
+ project_id?: string;
+ customer_number?: string;
+ customer_name?: string;
+ organizations?: Record[];
+ user_info?: Record;
+ products?: ProductOffer[];
+ is_subscribed?: boolean;
+ purchase_mode?: PurchaseMode;
+ selected_product_id?: string;
+ captcha_ticket?: string;
+ captcha_randstr?: string;
+ captcha_updated_at?: string | null;
+ preview?: Record | null;
+ last_sign?: string;
+ last_order_id?: string;
+ updated_at?: string;
+ [key: string]: unknown;
+}
+
+export interface PaymentTaskRecord {
+ id: string;
+ account_id: string;
+ product_id: string;
+ product_name?: string;
+ pay_type: PayType;
+ biz_id: string;
+ amount?: string;
+ sign?: string;
+ qr_base64?: string;
+ status?: string;
+ raw_preview?: Record;
+ raw_sign?: Record;
+ last_check?: Record;
+ created_at?: string;
+ updated_at?: string;
+}
+
+export interface AccountDetailResponse {
+ account: PublicAccountRecord;
+ session: AccountSessionState;
+ tasks: PaymentTaskRecord[];
+}
+
+export interface AccountImportPayload {
+ label: string;
+ token: string;
+ invitation_code?: string;
+}
+
+export interface AccountPreferencesPayload {
+ invitation_code?: string | null;
+ selected_product_id?: string | null;
+ preview_concurrency?: number | null;
+ schedule_enabled?: boolean | null;
+ scheduled_start_time?: string | null;
+}
diff --git a/web/tsconfig.json b/web/tsconfig.json
new file mode 100644
index 0000000..0b23e6a
--- /dev/null
+++ b/web/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "module": "ESNext",
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "skipLibCheck": true,
+ "moduleResolution": "Bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "preserve",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true
+ },
+ "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
+ "references": [{ "path": "./tsconfig.node.json" }]
+}
\ No newline at end of file
diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json
new file mode 100644
index 0000000..380c44a
--- /dev/null
+++ b/web/tsconfig.node.json
@@ -0,0 +1,11 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "allowSyntheticDefaultImports": true,
+ "strict": true
+ },
+ "include": ["vite.config.ts"]
+}
\ No newline at end of file
diff --git a/web/vite.config.ts b/web/vite.config.ts
new file mode 100644
index 0000000..58b89db
--- /dev/null
+++ b/web/vite.config.ts
@@ -0,0 +1,23 @@
+import { defineConfig } from "vite";
+import vue from "@vitejs/plugin-vue";
+
+export default defineConfig({
+ plugins: [vue()],
+ server: {
+ proxy: {
+ "/api": "http://127.0.0.1:8787",
+ "/healthz": "http://127.0.0.1:8787"
+ }
+ },
+ build: {
+ outDir: "dist",
+ emptyOutDir: true,
+ rollupOptions: {
+ output: {
+ manualChunks(id) {
+ return id.includes("node_modules") ? "vendor" : undefined;
+ }
+ }
+ }
+ }
+});