From 5c4ff8c4a13136367d74419340041b949ebb2dd2 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 15 Oct 2026 11:20:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E9=9D=B6=E5=AD=90=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E5=8F=8B=E5=A5=BD=E5=90=8D=20+=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E8=BF=9E=E6=89=93=E6=AC=A1=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - targets.py 增加 label 字段(1ms 签到 / mliev 登录),下拉框、说明、历史表统一用友好名 - POST /api/jobs 支持 count(1~20):一次任务连打 N 发,发间自动按靶子频控节流、可取消 - 每发结果记入 stats.rounds,EVT_VERIFY 带 round,结果区展示「每发结果(通过 x/y)」彩色一览 - 滑块不再生成标注图(红圈+序号只表达点击顺序,对滑块是误导) - 观测台 README 补齐启动/后台运行/端口占用/自检说明 --- tcaptcha/render.py | 19 ++- tcaptcha/targets.py | 3 + tcaptcha/web/README.md | 55 +++++- tcaptcha/web/app.py | 37 +++- tcaptcha/web/runner.py | 85 +++++++--- tcaptcha/web/static/app.js | 298 ++++++++++++++++++++++----------- tcaptcha/web/static/index.html | 79 +++++---- tcaptcha/web/static/style.css | 180 ++++++++++++++------ 8 files changed, 534 insertions(+), 222 deletions(-) diff --git a/tcaptcha/render.py b/tcaptcha/render.py index d72f9c1..94e1f65 100644 --- a/tcaptcha/render.py +++ b/tcaptcha/render.py @@ -74,16 +74,19 @@ def annotate_points(bg: Any, prompt: Any = None, offsets: Optional[Sequence[Optional[float]]] = None, centroids: Optional[Sequence[Optional[Sequence[float]]]] = None, - title: str = "") -> bytes: + title: str = "", + embed_prompt: bool = True) -> bytes: """在底图上按**点击顺序**画编号红圈,可选贴提示图、标出质心与偏差。 参数: - points 点击点序列(背景图原始像素),**顺序即答案顺序** - prompt 提示图(图标/文字点选题),会缩放到右上角 - offsets 每个点离图标笔画质心的距离(px),标在圈上 - centroids 每个点的质心坐标,画一个蓝色十字(一眼看出偏没偏) - title 左上角标题(如 "icon_click ec=0") - """ + points 点击点序列(背景图原始像素),**顺序即答案顺序** + prompt 提示图(图标/文字点选题) + offsets 每个点离图标笔画质心的距离(px),标在圈上 + centroids 每个点的质心坐标,画一个蓝色十字(一眼看出偏没偏) + title 左上角标题(如 "第 1 题") + embed_prompt 是否把提示图贴进右上角。命令行/落盘单图查看时贴(默认 True); + **Web 界面传 False** —— 那边提示图是独立一张缩略图, + 贴进去只会挡住右上角的图标(实测会遮住一个目标)。""" bgr = to_bgr(bg) for i, pt in enumerate(points): x, y = int(pt[0]), int(pt[1]) @@ -98,7 +101,7 @@ def annotate_points(bg: Any, if offsets and i < len(offsets) and offsets[i] is not None: cv2.putText(bgr, "%.0fpx" % offsets[i], (x - 20, y + _RING_RADIUS + 16), cv2.FONT_HERSHEY_SIMPLEX, 0.5, _CENTROID_COLOR, 1) - if prompt is not None: + if prompt is not None and embed_prompt: try: pr = to_bgr(prompt) ph = max(1, bgr.shape[0] // 4) diff --git a/tcaptcha/targets.py b/tcaptcha/targets.py index 0f84a90..399a936 100644 --- a/tcaptcha/targets.py +++ b/tcaptcha/targets.py @@ -20,6 +20,7 @@ from typing import Dict, TypedDict class Target(TypedDict): aid: str + label: str # 界面上显示的友好名(下拉框/历史表都用它) entry_url: str referer: str wb: str @@ -31,6 +32,7 @@ TARGETS: Dict[str, Target] = { # 用户主靶:1ms 签到页。频控很紧,必须低频。 "1ms": { "aid": "195672344", + "label": "1ms 签到", "entry_url": "https://1ms.run/console/personal/benefits/check-in", "referer": "https://1ms.run/", "wb": "1", @@ -40,6 +42,7 @@ TARGETS: Dict[str, Target] = { # 可复现正样本靶:mliev 短信登录。题率低但宽松,适合回归。 "mliev": { "aid": "190758559", + "label": "mliev 登录", "entry_url": "https://mliev.login.wang/auth/#/oidc/authorize", "referer": "https://mliev.login.wang/", "wb": "1", diff --git a/tcaptcha/web/README.md b/tcaptcha/web/README.md index d1c6586..c6efefa 100644 --- a/tcaptcha/web/README.md +++ b/tcaptcha/web/README.md @@ -9,13 +9,60 @@ ## 启动 ```bash -uv run --extra web python -m tcaptcha.web.app # 默认 127.0.0.1:8770 -uv run --extra web python -m tcaptcha.web.app --port 9000 --host 0.0.0.0 -uv run --extra web python -m tcaptcha.web.app --no-warmup # 不预热 AI 引擎 +# 首次(或依赖变动后):把 web extra 同步进 .venv +uv sync --extra web + +# 启动(默认 127.0.0.1:8770 —— 只有本机能访问) +uv run --extra web python -m tcaptcha.web.app ``` +然后浏览器打开 。前台运行,`Ctrl+C` 停止。 + +启动成功时终端会打印: + +``` + tcaptcha 观测台已启动 → http://127.0.0.1:8770 + + (Ctrl+C 停止;靶子带实测最小间隔,1ms 为 60s,后端会自动节流) +``` + +常用变体: + +```bash +uv run --extra web python -m tcaptcha.web.app --port 8899 # 换端口 +uv run --extra web python -m tcaptcha.web.app --host 0.0.0.0 # 局域网访问(⚠️ 界面无鉴权) +uv run --extra web python -m tcaptcha.web.app --no-warmup # 跳过 AI 引擎预热,启动更快 +``` + +不想用 uv 也可以直接调项目 venv(两条路径等价,`uv run` 用的就是 `.venv`): + +```bash +.venv/bin/python -m tcaptcha.web.app +``` + +**后台运行 + 停止**(`&` 直接后台会随终端退出而挂掉,所以用 `nohup` 并记下 PID): + +```bash +nohup .venv/bin/python -m tcaptcha.web.app > /tmp/tcaptcha-web.log 2>&1 & +echo $! > /tmp/tcaptcha-web.pid +# 停止 +kill "$(cat /tmp/tcaptcha-web.pid)" +``` + +**端口被占用**:启动时会直接给中文提示并以退出码 2 结束(不会打一堆 errno): + +``` + ✗ 端口 8770 已被占用,服务没起来。 + 查占用进程:lsof -nP -iTCP:8770 -sTCP:LISTEN + 停掉它 :kill <上面的 PID> + 或换端口 :--port 8771 +``` + +**自检**:`curl -s http://127.0.0.1:8770/api/health` 应返回 +`{"status":"online","targets":["1ms","mliev"],"cooldown":{...}}`。 + > `fastapi` / `uvicorn` 装在 **optional extra `web`** 里,核心包(协议 / 遥测 / 求解器 / CLI) -> 完全不依赖它们 —— `import tcaptcha` 不会把 fastapi 拉进来(有回归断言:见本文件末)。 +> 完全不依赖它们 —— `import tcaptcha` 不会把 fastapi 拉进来(有回归断言)。 ## 架构 diff --git a/tcaptcha/web/app.py b/tcaptcha/web/app.py index b2e5206..ed3409f 100644 --- a/tcaptcha/web/app.py +++ b/tcaptcha/web/app.py @@ -57,6 +57,7 @@ app = FastAPI(title="tcaptcha 观测台", version="0.1.0") class JobRequest(BaseModel): target: str = Field(default="mliev", description="靶子 key") kind: str = Field(default="icon", description="text | icon | slider | any") + count: int = Field(default=1, ge=1, le=20, description="连打次数(发间自动按靶子频控节流)") max_retries: int = Field(default=2, ge=1, le=10) max_refreshes: int = Field(default=20, ge=1, le=60) @@ -87,7 +88,8 @@ def create_job(req: JobRequest) -> Dict[str, Any]: raise HTTPException(400, "未知靶子 %r(可选:%s)" % (req.target, ", ".join(TARGETS))) if req.kind not in KIND_MAP: raise HTTPException(400, "未知题型 %r(可选:%s)" % (req.kind, ", ".join(sorted(KIND_MAP)))) - job = MANAGER.submit(req.target, {"kind": req.kind, "max_retries": req.max_retries, + job = MANAGER.submit(req.target, {"kind": req.kind, "count": req.count, + "max_retries": req.max_retries, "max_refreshes": req.max_refreshes}) return job.snapshot() @@ -174,9 +176,21 @@ if STATIC_DIR.is_dir(): # --------------------------------------------------------------- 启动入口 +def port_in_use(host: str, port: int) -> bool: + """端口是否已被占用(用来给中文提示,而不是把 uvicorn 的 errno 48 直接甩给用户)。""" + import socket + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + try: + sock.bind((host, port)) + return False + except OSError: + return True + + def main() -> int: parser = argparse.ArgumentParser(description="tcaptcha Web 观测台") - parser.add_argument("--host", default="127.0.0.1", help="监听地址(默认本机)") + parser.add_argument("--host", default="127.0.0.1", + help="监听地址(默认 127.0.0.1 仅本机;0.0.0.0 供局域网访问,注意无鉴权)") parser.add_argument("--port", type=int, default=8770, help="监听端口(默认 8770)") parser.add_argument("--no-warmup", action="store_true", help="不预热 AI 推理引擎") args = parser.parse_args() @@ -185,6 +199,16 @@ def main() -> int: format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", datefmt="%H:%M:%S") + if port_in_use(args.host, args.port): + print("\n ✗ 端口 %d 已被占用,服务没起来。\n" + " 查占用进程:lsof -nP -iTCP:%d -sTCP:LISTEN\n" + " 停掉它 :kill <上面的 PID>\n" + " 或换端口 :--port 8771\n" % (args.port, args.port), flush=True) + return 2 + + if args.host not in ("127.0.0.1", "localhost"): + print("\n ⚠️ 监听地址是 %s —— 界面**没有任何鉴权**,别暴露到公网。\n" % args.host, flush=True) + if not args.no_warmup: def _warm() -> None: try: @@ -198,8 +222,13 @@ def main() -> int: threading.Thread(target=_warm, name="warmup", daemon=True).start() import uvicorn - print("\n tcaptcha 观测台: http://%s:%d\n" % (args.host, args.port)) - uvicorn.run(app, host=args.host, port=args.port, log_level="info") + print("\n tcaptcha 观测台已启动 → http://%s:%d\n" + " (Ctrl+C 停止;靶子带实测最小间隔,1ms 为 60s,后端会自动节流)\n" + % (args.host, args.port), flush=True) + try: + uvicorn.run(app, host=args.host, port=args.port, log_level="info") + except KeyboardInterrupt: + pass return 0 diff --git a/tcaptcha/web/runner.py b/tcaptcha/web/runner.py index 478e019..1d24186 100644 --- a/tcaptcha/web/runner.py +++ b/tcaptcha/web/runner.py @@ -158,6 +158,8 @@ class SolveJob: self._cancel = threading.Event() self._challenge_no = 0 self._solves: List[Dict[str, Any]] = [] + self._rounds: List[Dict[str, Any]] = [] # 每一发的结果(count>1 时) + self._count = 1 self._images: Dict[str, bytes] = {} self._img_lock = threading.Lock() @@ -188,11 +190,17 @@ class SolveJob: def stats(self) -> Dict[str, Any]: offs = [v for s in self._solves for v in (s.get("off_px") or []) if isinstance(v, (int, float))] + ews = [r["errorCode"] for r in self._rounds if r.get("errorCode") is not None] + ok_n = sum(1 for r in self._rounds if r.get("success")) return { "challenges": self._challenge_no, "solves": len(self._solves), "points": sum(len(s.get("points") or []) for s in self._solves), "mean_offset_px": round(sum(offs) / len(offs), 1) if offs else None, + "count": self._count, + "rounds": list(self._rounds), + "ok": ok_n, + "errorCodes": ews, } def snapshot(self) -> Dict[str, Any]: @@ -227,21 +235,12 @@ class SolveJob: def _execute(self) -> None: tgt = get_target(self.target_key) kind = str(self.params.get("kind") or "icon") + self._count = max(1, min(20, int(self.params.get("count") or 1))) self.events.append(EVT_STARTED, target=self.target_key, aid=tgt["aid"], - kind=kind, mode="python_values", + label=tgt.get("label", self.target_key), + kind=kind, mode="python_values", count=self._count, note=tgt["note"], params={k: v for k, v in self.params.items()}) - if self._throttle: - waited = self._throttle(self) - if waited: - self.events.append(EVT_LOG, level="INFO", logger="web.runner", - message="频控节流结束(等待 %.1fs)" % waited) - if self.cancelled: - self.state = "cancelled" - self.events.append(EVT_FINISHED, success=False, errorCode=None, ticket="", - elapsed_ms=0, error="任务已取消", stats=self.stats()) - return - self.state = "running" bridge = _LogBridge(self._emit_log) loggers = [logging.getLogger("tcaptcha"), logging.getLogger("tcaptcha_client")] @@ -250,12 +249,35 @@ class SolveJob: lg.setLevel(logging.INFO) lg.addHandler(bridge) try: - with _Hooks(self): - res = self._call_client(tgt, kind) + for i in range(1, self._count + 1): + if self.cancelled: + break + if i > 1: + self.events.append(EVT_LOG, level="INFO", logger="web.runner", + message="—— 第 %d/%d 发 ——" % (i, self._count)) + if self._throttle: + waited = self._throttle(self) + if waited: + self.events.append(EVT_LOG, level="INFO", logger="web.runner", + message="频控节流结束(等待 %.1fs)" % waited) + if self.cancelled: + break + with _Hooks(self): + res = self._call_client(tgt, kind) + self._finalize(res, round_no=i) finally: for lg in loggers: lg.removeHandler(bridge) - self._finalize(res) + + if self.cancelled: + self.state = "cancelled" + self.events.append(EVT_FINISHED, success=any(r.get("success") for r in self._rounds), + errorCode=self._rounds[-1]["errorCode"] if self._rounds else None, + ticket=self._rounds[-1].get("ticket") or "", + elapsed_ms=self._rounds[-1].get("elapsed_ms"), + error=self.error or ("已取消(完成 %d/%d 发)" % (len(self._rounds), self._count) + if self.cancelled else ""), + stats=self.stats()) def _emit_log(self, level: str, name: str, message: str) -> None: self.events.append(EVT_LOG, level=level, logger=name, message=message) @@ -278,7 +300,7 @@ class SolveJob: except Exception: # noqa: BLE001 pass - def _finalize(self, res: Dict[str, Any]) -> None: + def _finalize(self, res: Dict[str, Any], round_no: int = 1) -> None: raw = res.get("raw_response") or {} ec = raw.get("errorCode") self.result = { @@ -293,13 +315,19 @@ class SolveJob: } self.error = res.get("error") or "" self.state = "done" if res.get("success") else ("error" if self.error else "done") - self.events.append(EVT_VERIFY, success=bool(res.get("success")), errorCode=ec, - ticket=res.get("ticket") or "", randstr=res.get("randstr") or "", + self._rounds.append({"round": round_no, "success": bool(res.get("success")), + "errorCode": ec, "kind": res.get("kind") or "", + "ticket": res.get("ticket") or "", + "elapsed_ms": res.get("elapsed_ms"), + "error": res.get("error") or ""}) + self.events.append(EVT_VERIFY, round=round_no, success=bool(res.get("success")), + errorCode=ec, ticket=res.get("ticket") or "", + randstr=res.get("randstr") or "", elapsed_ms=res.get("elapsed_ms")) - self.events.append(EVT_FINISHED, success=bool(res.get("success")), errorCode=ec, - ticket=res.get("ticket") or "", - elapsed_ms=res.get("elapsed_ms"), - error=self.error, stats=self.stats()) + self.events.append(EVT_LOG, level="INFO", logger="web.runner", + message="第 %d/%d 发结束:errorCode=%s %s" + % (round_no, self._count, ec, + "✅" if res.get("success") else (self.error or "未通过"))) # --------------------------------------------------------- 求解器 hook def _wrap_solver(self, key: str, original: Callable) -> Callable: @@ -369,10 +397,15 @@ class SolveJob: data: Dict[str, Any] = {"index": n, "kind": kind, "points": [list(p) for p in points], "off_px": offsets, "centroids": centroids, "note": note} try: - data["annotated_url"] = self.add_image( - "c%d_annotated.jpg" % n, - annotate_points(bg, points, prompt=prompt, offsets=offsets, - centroids=centroids, title="第 %d 题" % n)) + # title 不画(卡片标题里已有),提示图不贴(界面上是独立缩略图, + # 贴进去会遮挡右上角的目标图标)—— 详见 render.annotate_points。 + # 标注图只给点选题画:红圈+序号表达的是"点击顺序",滑块答案是 + # 缺口位置,画上去只会误导(界面上底图+滑块图已足够核对)。 + if measurable: + data["annotated_url"] = self.add_image( + "c%d_annotated.jpg" % n, + annotate_points(bg, points, offsets=offsets, centroids=centroids, + embed_prompt=False)) except Exception: # noqa: BLE001 pass self._solves.append(data) diff --git a/tcaptcha/web/static/app.js b/tcaptcha/web/static/app.js index 7b19ad4..2340f10 100644 --- a/tcaptcha/web/static/app.js +++ b/tcaptcha/web/static/app.js @@ -4,6 +4,9 @@ * POST /api/jobs 建任务,拿 job_id * GET /api/jobs/{id}/events SSE 事件流(服务端推 event.to_dict()) * 事件类型由后端 tcaptcha/web/events.py 的 EVT_* 定义,这里按 data.type 分派。 + * + * 布局要点:图片**不铺满容器**——底图/标注图限高缩略、提示图小缩略, + * 点任意图进 lightbox 看原始尺寸。否则 672x480 的图会把右栏撑成长条。 */ 'use strict'; @@ -11,15 +14,22 @@ const $ = (id) => document.getElementById(id); const EC_MEANING = { - 0: { text: '通过', cls: 'ec0' }, - 50: { text: '答案被否', cls: 'ec50' }, - 9: { text: '答案被否', cls: 'ec50' }, - 12: { text: '包级拒答', cls: 'ec12' }, + 0: { text: '通过', cls: 'ec0' }, + 50: { text: '答案被否', cls: 'ec50' }, + 9: { text: '答案被否', cls: 'ec50' }, + 12: { text: '包级拒答', cls: 'ec12' }, +}; + +const KIND_LABEL = { + icon_click: '图标点选', + text_click: '文字点选', + slider: '滑块', + unknown: '未知', }; function ecInfo(ec) { - if (ec === null || ec === undefined || ec === '') return { text: '—', cls: 'ecother' }; - return EC_MEANING[Number(ec)] || { text: '未知', cls: 'ecother' }; + if (ec === null || ec === undefined || ec === '') return { text: '—', cls: 'other' }; + return EC_MEANING[Number(ec)] || { text: '未知', cls: 'other' }; } function hhmmss(ts) { @@ -27,9 +37,21 @@ function hhmmss(ts) { return d.toTimeString().slice(0, 8); } +function fmtMs(ms) { + if (ms === null || ms === undefined) return '—'; + return ms >= 1000 ? (ms / 1000).toFixed(1) + 's' : ms + 'ms'; +} + +function el(tag, cls, text) { + const e = document.createElement(tag); + if (cls) e.className = cls; + if (text !== undefined) e.textContent = text; + return e; +} + let state = { - targets: [], kinds: [], currentJob: null, es: null, - cooldownUntil: 0, cards: {}, lastResult: null, + targets: [], currentJob: null, es: null, + cooldownUntil: 0, cards: {}, }; /* ------------------------------------------------------------------ 启动 */ @@ -37,17 +59,17 @@ async function boot() { await loadTargets(); await loadHistory(); setInterval(tickCooldown, 1000); - $('target').onchange = () => renderNote(); + $('target').onchange = renderNote; $('start').onclick = startJob; $('cancel').onclick = cancelJob; + setupLightbox(); } async function loadTargets() { const r = await fetch('/api/targets').then((x) => x.json()); state.targets = r.targets; - state.kinds = r.kinds; $('target').innerHTML = r.targets - .map((t) => ``) + .map((t) => ``) .join(''); renderNote(); applyCooldown(r.cooldown); @@ -55,7 +77,7 @@ async function loadTargets() { function renderNote() { const t = state.targets.find((x) => x.key === $('target').value); - $('targetNote').textContent = t ? `【${t.key}】${t.note}` : ''; + $('targetNote').textContent = t ? `【${t.label || t.key}】${t.note}` : ''; } function applyCooldown(cd) { @@ -65,30 +87,27 @@ function applyCooldown(cd) { function tickCooldown() { const left = Math.ceil((state.cooldownUntil - Date.now()) / 1000); - const el = $('cooldown'); + const elCD = $('cooldown'); if (left > 0) { - el.textContent = `频控冷却 ${left}s`; - el.className = 'cooldown'; + elCD.textContent = `频控冷却 ${left}s`; + elCD.className = 'cooldown'; $('start').disabled = true; } else { - el.textContent = '可发'; - el.className = 'cooldown ready'; + elCD.textContent = '可发'; + elCD.className = 'cooldown ready'; $('start').disabled = false; } } /* ------------------------------------------------------------------ 任务 */ async function startJob() { - const body = { - target: $('target').value, - kind: $('kind').value, - max_retries: 2, - max_refreshes: 20, - }; + const count = Math.max(1, Math.min(20, parseInt($('count').value, 10) || 1)); + $('count').value = count; // 越界输入拉回合法范围 + const body = { target: $('target').value, kind: $('kind').value, count, + max_retries: 2, max_refreshes: 20 }; resetView(); const job = await fetch('/api/jobs', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }).then((r) => r.json()); subscribe(job.id); @@ -100,17 +119,19 @@ function subscribe(jobId) { $('jobId').textContent = jobId; setState('pending'); $('cancel').disabled = false; + markActiveRow(jobId); + const es = new EventSource(`/api/jobs/${jobId}/events?since=0`); es.onmessage = (e) => { let evt; try { evt = JSON.parse(e.data); } catch (err) { return; } onEvent(evt); if (evt.type === 'job.finished' || evt.type === 'job.error') { - es.close(); // 关键:必须主动关闭,否则 EventSource 会自动重连 + es.close(); // 必须主动关,否则 EventSource 会自动重连 if (state.es === es) state.es = null; } }; - es.onerror = () => { /* 断线时 EventSource 自动重连并带 Last-Event-ID 续传 */ }; + es.onerror = () => { /* 断线时自动重连并带 Last-Event-ID 续传 */ }; state.es = es; } @@ -126,23 +147,23 @@ function onEvent(evt) { switch (type) { case 'job.started': setState('running'); - logRow('meta', `任务启动:靶子=${data.target} aid=${data.aid} 题型=${data.kind} 模式=${data.mode}`); + logRow('meta', `任务启动:靶子=${data.label || data.target} aid=${data.aid} 题型=${data.kind} 模式=${data.mode}` + + (data.count > 1 ? ` 连打=${data.count} 发` : '')); return; case 'log': logRow(data.level, data.message); + if (data.message && data.message.includes('频控节流')) setState('throttled'); return; case 'challenge': addChallengeCard(data); - logRow('meta', `抽到第 ${data.index} 题:${data.kind} ${data.instruction}`); return; case 'solve': fillSolve(data); return; case 'telemetry': - logRow('meta', `collect:来源=${data.source} 长度=${data.collect_len} ` + - `段几何=${data.segments ? JSON.stringify(data.segments) : '—'}` + - (data.traj_points ? ` 轨迹点=${data.traj_points}` : '') + - (data.segments_err ? ` ⚠ ${data.segments_err}` : '')); + logRow('meta', `collect:来源=${data.source} 长度=${data.collect_len} 段几何=${data.segments ? JSON.stringify(data.segments) : '—'}` + + (data.traj_points ? ` 轨迹点=${data.traj_points}` : '') + + (data.segments_err ? ` ⚠ ${data.segments_err}` : '')); return; case 'verify': renderVerify(data); @@ -166,67 +187,94 @@ function setState(s) { done: ['已完成', 'ok'], error: ['失败', 'err'], cancelled: ['已取消', 'warn'], }; const [text, cls] = map[s] || [s, '']; - const el = $('state'); - el.textContent = text; - el.className = 'badge ' + cls; + const b = $('state'); + b.textContent = text; + b.className = 'badge ' + cls; } function logRow(level, message) { const box = $('log'); + const empty = box.querySelector('.empty'); + if (empty) empty.remove(); // 首条事件时清掉占位 const atBottom = box.scrollTop + box.clientHeight >= box.scrollHeight - 30; - const row = document.createElement('div'); - row.className = 'row ' + level; - row.innerHTML = `${hhmmss()}`; - row.querySelector('.m').textContent = message; + const row = el('div', 'row ' + level); + row.appendChild(el('span', 't', hhmmss())); + row.appendChild(el('span', 'm', message)); box.appendChild(row); if (atBottom) box.scrollTop = box.scrollHeight; } -/* ------------------------------------------------------------ 图片卡片 */ +/* ------------------------------------------------------------ 题目卡片 */ function addChallengeCard(d) { - const wrap = $('images'); - const card = document.createElement('div'); - card.className = 'shot'; - const imgs = []; - if (d.bg_url) imgs.push(`底图`); - if (d.prompt_url) imgs.push(`提示图`); - card.innerHTML = - `
第 ${d.index} 题 · ${d.kind} - ${d.instruction || ''}
-
${imgs.join('')}
-
求解中…
`; - wrap.prepend(card); // 最新的题放最上面 + const wrap = $('challenges'); + const hint = wrap.querySelector('.empty-hint'); + if (hint) hint.remove(); + + const card = el('div', 'challenge'); + const head = el('div', 'ch-head'); + head.appendChild(el('span', 'pill', '#' + d.index)); + head.appendChild(el('span', 'kind', KIND_LABEL[d.kind] || d.kind)); + if (d.instruction) head.appendChild(el('span', 'inst', d.instruction)); + card.appendChild(head); + + const media = el('div', 'ch-media'); + if (d.bg_url) media.appendChild(figure(d.bg_url, '底图', '')); + if (d.annotated_url) media.appendChild(figure(d.annotated_url, '标注图(1/2/3 = 点击顺序)', '')); + if (d.prompt_url) media.appendChild(figure(d.prompt_url, '提示图', 'prompt')); + card.appendChild(media); + + card.appendChild(el('div', 'ch-meta', '求解中…')); + wrap.prepend(card); // 最新的题放最上面 state.cards[d.index] = card; } +/** 一张可点放的缩略图。CSS 限高,原始尺寸靠 lightbox 看。 */ +function figure(src, caption, extraCls) { + const fig = el('figure', 'thumb' + (extraCls ? ' ' + extraCls : '')); + const img = el('img'); + img.src = src; + img.alt = caption; + img.onclick = () => openLightbox(src); + fig.appendChild(img); + fig.appendChild(el('figcaption', '', caption)); + return fig; +} + function fillSolve(d) { const card = state.cards[d.index]; if (!card) return; - const offs = (d.off_px || []).map((v) => (v === null ? '—' : v + 'px')).join(' / '); - card.querySelector('[data-pts]').textContent = - `点位(按点击顺序): ${JSON.stringify(d.points)} 离图标中心: ${offs}`; - if (d.annotated_url) { - const box = card.querySelector('.pair'); - const img = document.createElement('img'); - img.src = d.annotated_url; - img.alt = '标注图'; - box.appendChild(img); + // 标注图是 solve 时才生成的,补插到「底图之后、提示图之前」。 + // 判重用 data 标记(不能用 nth-child —— 提示图也排第 2,会误判已存在)。 + const media = card.querySelector('.ch-media'); + if (d.annotated_url && media && !media.querySelector('img[data-annotated]')) { + const fig = figure(d.annotated_url, '标注图(1/2/3 = 点击顺序)', ''); + fig.querySelector('img').dataset.annotated = '1'; + const promptFig = media.querySelector('.thumb.prompt'); + media.insertBefore(fig, promptFig || null); } - if (d.note) logRow('meta', `求解结论: ${d.note}`); + const meta = card.querySelector('.ch-meta'); + if (!meta) return; + const offs = (d.off_px || []).map((v) => (v === null ? '—' : v + 'px')); + const measurable = offs.length && offs.some((v) => v !== '—'); + meta.replaceChildren( + document.createTextNode(`点位(按点击顺序):${JSON.stringify(d.points)}`), + measurable + ? el('span', '', ` 离图标中心:${offs.join(' / ')}`) + : el('span', 'warn', ' (滑块的答案是缺口位置,不适用“离图标中心”量尺)'), + ); + if (d.note) logRow('meta', `求解结论:${d.note}`); } /* ------------------------------------------------------------ 结果渲染 */ function renderVerify(d) { const info = ecInfo(d.errorCode); - state.lastResult = d; - $('result').innerHTML = ` -
-
errorCode
-
${d.errorCode ?? '—'} ${info.text}
-
耗时
${fmtMs(d.elapsed_ms)}
-
randstr
${d.randstr || '—'}
-
ticket
${d.ticket || '—'}
-
`; + const grid = el('div', 'kv-grid'); + grid.appendChild(kv(d.round > 1 ? `第${d.round}发 errorCode` : 'errorCode', + String(d.errorCode ?? '—'), info.cls, info.text)); + grid.appendChild(kv('耗时', fmtMs(d.elapsed_ms), '', '')); + grid.appendChild(kv('randstr', d.randstr || '—', '', '')); + grid.appendChild(kvWide('ticket', d.ticket || '—')); + $('result').replaceChildren(grid); } function finishJob(d) { @@ -234,46 +282,93 @@ function finishJob(d) { setState(d.success ? 'done' : (info.cls === 'ec12' ? 'error' : 'warn')); $('cancel').disabled = true; const st = d.stats || {}; - $('result').insertAdjacentHTML('beforeend', ` -
-
结果
${d.success ? '成功' : (d.errorCode ?? '失败')}
-
抽题次数
${st.challenges ?? '—'}
-
点位总数
${st.points ?? '—'}
-
平均偏离中心
${st.mean_offset_px ?? '—'}${st.mean_offset_px ? 'px' : ''}
-
`); + const grid = document.querySelector('#result .kv-grid') || el('div', 'kv-grid'); + if (!grid.parentNode) $('result').replaceChildren(grid); + grid.appendChild(kv('结果', d.success ? '成功' : (d.errorCode ?? '失败'), info.cls, '')); + // 连打时展示每发的 errorCode 一览(0 绿 / 50/9 黄 / 12 红 / 其余灰) + if (st.count > 1 && Array.isArray(st.rounds) && st.rounds.length) { + const seq = st.rounds.map((r) => { + const ic = ecInfo(r.errorCode); + return `#${r.round}:${r.errorCode ?? '—'}`; + }).join(' '); + const box = el('div', 'kv wide'); + box.appendChild(el('div', 'k', `每发结果(通过 ${st.ok}/${st.rounds.length})`)); + const val = el('div', 'v'); + val.innerHTML = seq; + box.appendChild(val); + grid.appendChild(box); + } + grid.appendChild(kv('抽题次数', String(st.challenges ?? '—'), '', '')); + grid.appendChild(kv('点位总数', String(st.points ?? '—'), '', '')); + grid.appendChild(kv('平均偏离中心', + st.mean_offset_px ? st.mean_offset_px + 'px' : '—', '', '')); if (d.error) logRow('WARNING', d.error); - // 本地起冷却(服务端也会节流,这里只是让按钮状态立刻正确) const t = state.targets.find((x) => x.key === $('target').value); if (t) state.cooldownUntil = Date.now() + t.min_interval * 1000; tickCooldown(); loadHistory(); } -function fmtMs(ms) { - if (ms === null || ms === undefined) return '—'; - return ms >= 1000 ? (ms / 1000).toFixed(1) + 's' : ms + 'ms'; +function kv(k, v, cls, sub) { + const box = el('div', 'kv'); + box.appendChild(el('div', 'k', k)); + const val = el('div', 'v' + (cls ? ' ' + cls : ''), v); + if (sub) val.appendChild(el('small', '', ' ' + sub)); + box.appendChild(val); + return box; +} + +/** 长文本卡(ticket 等):占整行 + 单行截断,点击复制全文。 */ +function kvWide(k, v) { + const box = el('div', 'kv wide'); + box.title = v === '—' ? '' : '点击复制'; + box.appendChild(el('div', 'k', k)); + const val = el('div', 'v trunc' + (v === '—' ? ' other' : ''), v); + box.appendChild(val); + if (v !== '—') { + box.style.cursor = 'copy'; + box.onclick = async () => { + try { await navigator.clipboard.writeText(v); } catch (err) { /* 无剪贴板权限时静默 */ } + box.title = '已复制 ✓'; + setTimeout(() => { box.title = '点击复制'; }, 1200); + }; + } + return box; } /* ------------------------------------------------------------ 历史任务 */ async function loadHistory() { const r = await fetch('/api/jobs').then((x) => x.json()); const tb = $('history').querySelector('tbody'); - tb.innerHTML = ''; + tb.replaceChildren(); for (const j of r.jobs) { const ec = j.result ? j.result.errorCode : null; const info = ecInfo(ec); - const tr = document.createElement('tr'); - tr.innerHTML = ` - ${hhmmss(j.created_at)} - ${j.target} - ${j.params.kind} - ${ec ?? '—'} ${info.text} - ${fmtMs(j.result ? j.result.elapsed_ms : null)} - ${(j.result && j.result.success) ? '✓' : '—'} - ${(j.result && j.result.ticket) ? j.result.ticket.slice(0, 14) + '…' : '—'}`; + const t = state.targets.find((x) => x.key === j.target); + const tr = el('tr'); + tr.dataset.job = j.id; + tr.appendChild(td(hhmmss(j.created_at), 'mono muted')); + tr.appendChild(td(t ? (t.label || t.key) : j.target, '')); + tr.appendChild(td(j.params.kind, 'mono')); + tr.appendChild(td(`${ec ?? '—'} ${info.text}`, 'mono ' + info.cls)); + tr.appendChild(td(fmtMs(j.result ? j.result.elapsed_ms : null), 'mono')); + tr.appendChild(td((j.result && j.result.success) ? '✓' : '—', 'mono')); + tr.appendChild(td((j.result && j.result.ticket) ? j.result.ticket : '—', 'mono muted')); tr.onclick = () => replay(j.id); tb.appendChild(tr); } + if (state.currentJob) markActiveRow(state.currentJob); +} + +function td(text, cls) { + const c = el('td', cls, text); + return c; +} + +function markActiveRow(jobId) { + document.querySelectorAll('#history tbody tr').forEach((tr) => { + tr.classList.toggle('active', tr.dataset.job === jobId); + }); } /** 点历史行 → 重新订阅该任务的事件流,把整个过程重放一遍。 */ @@ -284,10 +379,21 @@ function replay(jobId) { $('cancel').disabled = true; } +/* ------------------------------------------------------------- lightbox */ +function setupLightbox() { + const lb = $('lightbox'); + lb.onclick = closeLightbox; + document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeLightbox(); }); +} + +function openLightbox(src) { $('lightboxImg').src = src; $('lightbox').classList.add('show'); } +function closeLightbox() { $('lightbox').classList.remove('show'); } + +/* ------------------------------------------------------------------ 复位 */ function resetView() { - $('log').innerHTML = ''; - $('images').innerHTML = ''; - $('result').innerHTML = '

等待事件…

'; + $('log').replaceChildren(el('div', 'empty', '等待事件…')); + $('challenges').replaceChildren(el('p', 'empty-hint', '抽到题目后,底图与标注图会出现在这里。')); + $('result').replaceChildren(el('p', 'empty-hint', '等待事件…')); state.cards = {}; } diff --git a/tcaptcha/web/static/index.html b/tcaptcha/web/static/index.html index afe7340..f46dadf 100644 --- a/tcaptcha/web/static/index.html +++ b/tcaptcha/web/static/index.html @@ -13,18 +13,20 @@ 腾讯天御验证码 · 纯协议求解 · 实时观测
- + - + +
@@ -32,30 +34,44 @@
-
-

执行轨迹 空闲 - -

-
-
+ +
+
+

执行轨迹 空闲 + +

+
还没有任务。
+
-
-

题目 / 点位 / 结果

-

还没有任务。选好靶子与题型,点「开始一发」。

-
-
+
+

结果

+

选好靶子 / 题型 / 次数,点「开始」。

+
+
+ + +
+
+

题目 / 点位 点图片可放大

+

抽到题目后,底图与标注图会出现在这里(标注图上的 1/2/3 即点击顺序)。

+
+ +
+

历史任务

+ + + + + + + + + +
时间靶子题型errorCode耗时结果ticket
+
+
-
-

历史任务

- - - - - -
时间靶子题型errorCode耗时点位ticket
-
-
0 通过 50 / 9 答案被否(点位或目标错) @@ -63,6 +79,11 @@ 靶子带实测最小间隔,后端会强制节流
+ + diff --git a/tcaptcha/web/static/style.css b/tcaptcha/web/static/style.css index 94df273..5d7ff70 100644 --- a/tcaptcha/web/static/style.css +++ b/tcaptcha/web/static/style.css @@ -1,7 +1,7 @@ :root { - --bg: #0f1115; - --panel: #171a21; - --panel-2: #1e222b; + --bg: #0e1014; + --panel: #161920; + --panel-2: #1d212a; --line: #2a2f3a; --fg: #e6e9ef; --muted: #8b93a3; @@ -10,105 +10,175 @@ --err: #e05a5a; --accent: #4c8dff; --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + --radius: 10px; } * { box-sizing: border-box; } +html, body { height: 100%; } + body { margin: 0; background: var(--bg); color: var(--fg); - font: 14px/1.55 -apple-system, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; - padding: 16px 18px 40px; + font: 13px/1.5 -apple-system, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; + padding: 12px 14px 18px; } -header { margin-bottom: 14px; } -.brand { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; } -h1 { font-size: 18px; margin: 0; letter-spacing: .3px; } -h2 { font-size: 13px; margin: 0 0 10px; color: var(--muted); font-weight: 600; - text-transform: uppercase; letter-spacing: .6px; - display: flex; align-items: center; gap: 10px; } -.sub { color: var(--muted); font-size: 12px; } +/* ---------------------------------------------------------------- 顶栏 */ +header { margin-bottom: 10px; } +.brand { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; } +h1 { font-size: 15px; margin: 0; letter-spacing: .2px; } +.sub { color: var(--muted); font-size: 11.5px; } -.controls { display: flex; align-items: center; gap: 10px; margin-top: 12px; flex-wrap: wrap; } -.controls label { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: 13px; } +.controls { display: flex; align-items: center; gap: 8px; margin-top: 9px; flex-wrap: wrap; } +.controls label { display: flex; align-items: center; gap: 5px; color: var(--muted); font-size: 12px; } select, button { background: var(--panel-2); color: var(--fg); border: 1px solid var(--line); border-radius: 7px; - padding: 7px 11px; font-size: 13px; font-family: inherit; + padding: 6px 10px; font-size: 12.5px; font-family: inherit; } +input[type="number"] { + background: var(--panel-2); color: var(--fg); + border: 1px solid var(--line); border-radius: 7px; + padding: 6px 8px; font-size: 12.5px; font-family: var(--mono); + width: 64px; +} +select { padding-right: 6px; } button { cursor: pointer; } button:hover:not(:disabled) { border-color: var(--accent); } button:disabled { opacity: .45; cursor: not-allowed; } button.primary { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; } button.primary:disabled { background: var(--panel-2); color: var(--fg); border-color: var(--line); } - -.note { margin-top: 8px; font-size: 12.5px; color: var(--muted); } -.cooldown { font-family: var(--mono); font-size: 12.5px; color: var(--warn); } +.cooldown { font-family: var(--mono); font-size: 12px; color: var(--warn); } .cooldown.ready { color: var(--ok); } +.note { margin-top: 6px; font-size: 12px; color: var(--muted); } -main { display: grid; grid-template-columns: minmax(380px, 1fr) minmax(420px, 1.2fr); gap: 14px; } -@media (max-width: 1080px) { main { grid-template-columns: 1fr; } } +/* ---------------------------------------------------------------- 布局 */ +main { + display: grid; + grid-template-columns: minmax(330px, 0.82fr) minmax(430px, 1.18fr); + gap: 12px; + align-items: start; +} +@media (max-width: 1000px) { main { grid-template-columns: 1fr; } } + +.col { display: flex; flex-direction: column; gap: 12px; min-width: 0; } .panel { background: var(--panel); border: 1px solid var(--line); - border-radius: 11px; padding: 13px 14px; margin-bottom: 14px; + border-radius: var(--radius); padding: 11px 12px; min-width: 0; } -.panel.wide { margin-bottom: 14px; } + +h2 { + font-size: 11.5px; margin: 0 0 9px; color: var(--muted); font-weight: 600; + text-transform: uppercase; letter-spacing: .6px; + display: flex; align-items: center; gap: 9px; flex-wrap: wrap; +} +h2 .right { margin-left: auto; text-transform: none; letter-spacing: 0; font-weight: 400; } .badge { - font-size: 11px; padding: 2px 9px; border-radius: 999px; + font-size: 10.5px; padding: 1px 8px; border-radius: 999px; background: var(--panel-2); color: var(--muted); border: 1px solid var(--line); - font-weight: 500; text-transform: none; letter-spacing: 0; } .badge.running { color: var(--accent); border-color: var(--accent); } .badge.ok { color: var(--ok); border-color: var(--ok); } .badge.warn { color: var(--warn); border-color: var(--warn); } .badge.err { color: var(--err); border-color: var(--err); } +/* ---------------------------------------------------------------- 日志 */ .log { - font-family: var(--mono); font-size: 12px; - height: 460px; overflow-y: auto; - background: #0c0e12; border: 1px solid var(--line); border-radius: 8px; - padding: 9px 11px; + font-family: var(--mono); font-size: 11.5px; + height: clamp(220px, 40vh, 420px); overflow-y: auto; + background: #0a0c10; border: 1px solid var(--line); border-radius: 8px; + padding: 8px 10px; overscroll-behavior: contain; } -.log .row { display: grid; grid-template-columns: 62px 1fr; gap: 9px; padding: 1px 0; } -.log .t { color: #5a6376; } -.log .m { white-space: pre-wrap; word-break: break-word; } -.log .row.INFO .m { color: #cdd4e0; } +.log .row { display: grid; grid-template-columns: 56px 1fr; gap: 8px; padding: .5px 0; } +.log .t { color: #4f5766; } +.log .m { white-space: pre-wrap; word-break: break-word; color: #cdd4e0; } .log .row.WARNING .m { color: var(--warn); } .log .row.ERROR .m { color: var(--err); } .log .row.meta .m { color: var(--muted); font-style: italic; } +.log .empty { color: #4f5766; font-style: italic; } -.result { margin-bottom: 12px; } -.result .card { - display: grid; grid-template-columns: repeat(auto-fit, minmax(155px, 1fr)); - gap: 9px; margin-top: 9px; +/* ---------------------------------------------------------------- 结果 */ +.kv-grid { + display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); + gap: 8px; align-items: start; /* 关键:不让少数卡片被拉满整列高 */ } -.result .kv { - background: var(--panel-2); border: 1px solid var(--line); - border-radius: 8px; padding: 8px 10px; -} -.result .kv .k { color: var(--muted); font-size: 11px; letter-spacing: .4px; } -.result .kv .v { font-family: var(--mono); font-size: 14px; margin-top: 3px; word-break: break-all; } +.kv { background: var(--panel-2); border: 1px solid var(--line); border-radius: 8px; padding: 7px 9px; min-width: 0; } +.kv .k { color: var(--muted); font-size: 10.5px; letter-spacing: .3px; } +.kv .v { font-family: var(--mono); font-size: 13px; margin-top: 2px; word-break: break-all; } +.kv.wide { grid-column: 1 / -1; } /* ticket 这类长文本占整行,避免撑高别的卡 */ +.kv .v.trunc { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .v.ec0 { color: var(--ok); } .v.ec50 { color: var(--warn); } -.v.ec12 { color: var(--err); } .v.ecother { color: var(--muted); } +.v.ec12 { color: var(--err); } .v.other { color: var(--muted); } +.v small { font-size: 11px; font-family: inherit; } -.images { display: grid; gap: 11px; } -.shot { background: var(--panel-2); border: 1px solid var(--line); border-radius: 9px; padding: 9px; } -.shot .cap { display: flex; justify-content: space-between; align-items: center; - font-size: 12px; color: var(--muted); margin-bottom: 7px; } -.shot img { width: 100%; border-radius: 6px; display: block; background: #000; } -.shot .pair { display: grid; grid-template-columns: 1fr; gap: 7px; } -.shot .pts { font-family: var(--mono); font-size: 11.5px; color: #9fb0cc; margin-top: 6px; } +/* ----------------------------------------------------------- 题目卡片 */ +.challenge { + border: 1px solid var(--line); border-radius: 9px; + background: var(--panel-2); padding: 9px 10px; margin-bottom: 10px; +} +.challenge:last-child { margin-bottom: 0; } +.ch-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; } +.pill { + font-family: var(--mono); font-size: 11px; padding: 1px 7px; border-radius: 5px; + background: #263043; color: #9db8e8; +} +.kind { font-family: var(--mono); font-size: 11px; color: var(--muted); } +.inst { font-size: 12px; color: #cdd4e0; letter-spacing: .5px; } -table { width: 100%; border-collapse: collapse; font-size: 12.5px; } -th, td { text-align: left; padding: 7px 9px; border-bottom: 1px solid var(--line); } -th { color: var(--muted); font-weight: 600; font-size: 11px; letter-spacing: .4px; } +.ch-media { display: flex; gap: 12px; align-items: flex-start; flex-wrap: wrap; } +.thumb { margin: 0; min-width: 0; } +.thumb img { + display: block; max-width: 100%; max-height: 250px; width: auto; height: auto; + border-radius: 6px; background: #000; cursor: zoom-in; border: 1px solid #000; +} +.thumb.prompt img { max-height: 56px; } +.thumb figcaption { font-size: 10.5px; color: var(--muted); margin-top: 4px; } +.ch-meta { font-family: var(--mono); font-size: 11px; color: #9fb0cc; margin-top: 8px; word-break: break-word; } +.ch-meta .warn { color: var(--warn); } + +.empty-hint { color: var(--muted); font-size: 12.5px; } + +/* ---------------------------------------------------------------- 表格 */ +table { width: 100%; border-collapse: collapse; font-size: 12px; table-layout: fixed; } +th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--line); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +th { color: var(--muted); font-weight: 600; font-size: 10.5px; letter-spacing: .3px; } td.mono, .mono { font-family: var(--mono); } tbody tr:hover { background: var(--panel-2); cursor: pointer; } +tbody tr.active { background: #1b2436; } +col.c-time { width: 76px; } col.c-target { width: 62px; } col.c-kind { width: 64px; } +col.c-ec { width: 108px; } col.c-ms { width: 64px; } col.c-ok { width: 54px; } -.legend { display: flex; gap: 20px; flex-wrap: wrap; color: var(--muted); font-size: 12px; margin-top: 6px; } -.legend b { font-family: var(--mono); padding: 1px 6px; border-radius: 5px; } +/* ------------------------------------------------------------- lightbox */ +.lightbox { + position: fixed; inset: 0; z-index: 50; display: none; + background: rgba(0, 0, 0, .86); align-items: center; justify-content: center; + padding: 24px; cursor: zoom-out; +} +.lightbox.show { display: flex; } +.lightbox img { + max-width: 96vw; max-height: 92vh; border-radius: 8px; + box-shadow: 0 12px 40px rgba(0, 0, 0, .6); +} +.lightbox .tip { + position: fixed; top: 12px; left: 50%; transform: translateX(-50%); + font-size: 12px; color: #cfd6e4; background: rgba(0, 0, 0, .5); + padding: 4px 12px; border-radius: 999px; +} + +/* ---------------------------------------------------------------- 图例 */ +.legend { display: flex; gap: 16px; flex-wrap: wrap; color: var(--muted); font-size: 11.5px; margin-top: 10px; } +.legend b { font-family: var(--mono); padding: 0 5px; border-radius: 4px; } .ec0 { color: var(--ok); } .ec50 { color: var(--warn); } .ec12 { color: var(--err); } .muted { color: var(--muted); } + +/* 连打时「每发结果」一览里的彩色编号 */ +.round-ec { padding: 0 4px; border-radius: 4px; background: rgba(255,255,255,.05); } +.round-ec.ec0 { color: var(--ok); } +.round-ec.ec50 { color: var(--warn); } +.round-ec.ec12 { color: var(--err); } +.round-ec.other { color: var(--muted); }