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 @@ + + + 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 @@ + + + \ 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 @@ + + + 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 @@ + + + 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 @@ + + + \ 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 @@ + + + \ 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 @@ + + + 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 @@ + + + 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 @@ + + + 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; + } + } + } + } +});