feat(web): 靶子显示友好名 + 支持连打次数
- targets.py 增加 label 字段(1ms 签到 / mliev 登录),下拉框、说明、历史表统一用友好名 - POST /api/jobs 支持 count(1~20):一次任务连打 N 发,发间自动按靶子频控节流、可取消 - 每发结果记入 stats.rounds,EVT_VERIFY 带 round,结果区展示「每发结果(通过 x/y)」彩色一览 - 滑块不再生成标注图(红圈+序号只表达点击顺序,对滑块是误导) - 观测台 README 补齐启动/后台运行/端口占用/自检说明
This commit is contained in:
+11
-8
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
+51
-4
@@ -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
|
||||
```
|
||||
|
||||
然后浏览器打开 <http://127.0.0.1:8770/>。前台运行,`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 拉进来(有回归断言)。
|
||||
|
||||
## 架构
|
||||
|
||||
|
||||
+33
-4
@@ -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
|
||||
|
||||
|
||||
|
||||
+59
-26
@@ -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)
|
||||
|
||||
+202
-96
@@ -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) => `<option value="${t.key}">${t.key} aid=${t.aid}(最小间隔 ${t.min_interval}s)</option>`)
|
||||
.map((t) => `<option value="${t.key}">${t.label || t.key}(最小间隔 ${t.min_interval}s)</option>`)
|
||||
.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 = `<span class="t">${hhmmss()}</span><span class="m"></span>`;
|
||||
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(`<img src="${d.bg_url}" alt="底图">`);
|
||||
if (d.prompt_url) imgs.push(`<img src="${d.prompt_url}" alt="提示图">`);
|
||||
card.innerHTML =
|
||||
`<div class="cap"><span>第 ${d.index} 题 · ${d.kind}</span>
|
||||
<span class="muted">${d.instruction || ''}</span></div>
|
||||
<div class="pair">${imgs.join('')}</div>
|
||||
<div class="pts" data-pts>求解中…</div>`;
|
||||
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 = `
|
||||
<div class="card">
|
||||
<div class="kv"><div class="k">errorCode</div>
|
||||
<div class="v ${info.cls}">${d.errorCode ?? '—'} <span style="font-size:12px">${info.text}</span></div></div>
|
||||
<div class="kv"><div class="k">耗时</div><div class="v">${fmtMs(d.elapsed_ms)}</div></div>
|
||||
<div class="kv"><div class="k">randstr</div><div class="v">${d.randstr || '—'}</div></div>
|
||||
<div class="kv"><div class="k">ticket</div><div class="v">${d.ticket || '—'}</div></div>
|
||||
</div>`;
|
||||
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', `
|
||||
<div class="card">
|
||||
<div class="kv"><div class="k">结果</div><div class="v ${info.cls}">${d.success ? '成功' : (d.errorCode ?? '失败')}</div></div>
|
||||
<div class="kv"><div class="k">抽题次数</div><div class="v">${st.challenges ?? '—'}</div></div>
|
||||
<div class="kv"><div class="k">点位总数</div><div class="v">${st.points ?? '—'}</div></div>
|
||||
<div class="kv"><div class="k">平均偏离中心</div><div class="v">${st.mean_offset_px ?? '—'}${st.mean_offset_px ? 'px' : ''}</div></div>
|
||||
</div>`);
|
||||
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 `<span class="round-ec ${ic.cls}">#${r.round}:${r.errorCode ?? '—'}</span>`;
|
||||
}).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 = `
|
||||
<td class="mono muted">${hhmmss(j.created_at)}</td>
|
||||
<td>${j.target}</td>
|
||||
<td class="mono">${j.params.kind}</td>
|
||||
<td class="mono ${info.cls}">${ec ?? '—'} <span class="muted" style="font-size:11px">${info.text}</span></td>
|
||||
<td class="mono">${fmtMs(j.result ? j.result.elapsed_ms : null)}</td>
|
||||
<td class="mono">${(j.result && j.result.success) ? '✓' : '—'}</td>
|
||||
<td class="mono muted">${(j.result && j.result.ticket) ? j.result.ticket.slice(0, 14) + '…' : '—'}</td>`;
|
||||
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 = '<p class="muted">等待事件…</p>';
|
||||
$('log').replaceChildren(el('div', 'empty', '等待事件…'));
|
||||
$('challenges').replaceChildren(el('p', 'empty-hint', '抽到题目后,底图与标注图会出现在这里。'));
|
||||
$('result').replaceChildren(el('p', 'empty-hint', '等待事件…'));
|
||||
state.cards = {};
|
||||
}
|
||||
|
||||
|
||||
@@ -13,18 +13,20 @@
|
||||
<span class="sub">腾讯天御验证码 · 纯协议求解 · 实时观测</span>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<label>靶子
|
||||
<select id="target"></select>
|
||||
</label>
|
||||
<label>靶子 <select id="target"></select></label>
|
||||
<label>题型
|
||||
<select id="kind">
|
||||
<option value="any">any 遇到啥过啥(推荐)</option>
|
||||
<option value="icon">icon 图标点选 2408</option>
|
||||
<option value="text">text 文字点选 2404</option>
|
||||
<option value="slider">slider 滑块 2401/2402</option>
|
||||
<option value="any">any · 遇到啥过啥</option>
|
||||
<option value="icon">icon · 图标点选 2408</option>
|
||||
<option value="text">text · 文字点选 2404</option>
|
||||
<option value="slider">slider · 滑块 2401/2402</option>
|
||||
</select>
|
||||
</label>
|
||||
<button id="start" class="primary">开始一发</button>
|
||||
<label>次数
|
||||
<input id="count" type="number" min="1" max="20" step="1" value="1"
|
||||
title="连打次数;发与发之间按靶子频控自动节流,可随时取消">
|
||||
</label>
|
||||
<button id="start" class="primary">开始</button>
|
||||
<button id="cancel" disabled>取消</button>
|
||||
<span id="cooldown" class="cooldown"></span>
|
||||
</div>
|
||||
@@ -32,30 +34,44 @@
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="panel">
|
||||
<h2>执行轨迹 <span id="state" class="badge">空闲</span>
|
||||
<span id="jobId" class="mono muted"></span>
|
||||
</h2>
|
||||
<div id="log" class="log"></div>
|
||||
</section>
|
||||
<!-- 左栏:执行过程 -->
|
||||
<div class="col">
|
||||
<section class="panel">
|
||||
<h2>执行轨迹 <span id="state" class="badge">空闲</span>
|
||||
<span class="right mono muted" id="jobId"></span>
|
||||
</h2>
|
||||
<div id="log" class="log"><div class="empty">还没有任务。</div></div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>题目 / 点位 / 结果</h2>
|
||||
<div id="result" class="result"><p class="muted">还没有任务。选好靶子与题型,点「开始一发」。</p></div>
|
||||
<div id="images" class="images"></div>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>结果</h2>
|
||||
<div id="result"><p class="empty-hint">选好靶子 / 题型 / 次数,点「开始」。</p></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- 右栏:题目与点位 -->
|
||||
<div class="col">
|
||||
<section class="panel">
|
||||
<h2>题目 / 点位 <span class="right muted" style="font-weight:400">点图片可放大</span></h2>
|
||||
<div id="challenges"><p class="empty-hint">抽到题目后,底图与标注图会出现在这里(标注图上的 1/2/3 即点击顺序)。</p></div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>历史任务</h2>
|
||||
<table id="history">
|
||||
<colgroup>
|
||||
<col class="c-time"><col class="c-target"><col class="c-kind">
|
||||
<col class="c-ec"><col class="c-ms"><col class="c-ok"><col>
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr><th>时间</th><th>靶子</th><th>题型</th><th>errorCode</th><th>耗时</th><th>结果</th><th>ticket</th></tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<section class="panel wide">
|
||||
<h2>历史任务</h2>
|
||||
<table id="history">
|
||||
<thead>
|
||||
<tr><th>时间</th><th>靶子</th><th>题型</th><th>errorCode</th><th>耗时</th><th>点位</th><th>ticket</th></tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<footer class="legend">
|
||||
<span><b class="ec0">0</b> 通过</span>
|
||||
<span><b class="ec50">50 / 9</b> 答案被否(点位或目标错)</span>
|
||||
@@ -63,6 +79,11 @@
|
||||
<span class="muted">靶子带实测最小间隔,后端会强制节流</span>
|
||||
</footer>
|
||||
|
||||
<div id="lightbox" class="lightbox">
|
||||
<span class="tip">点击任意处 / Esc 关闭</span>
|
||||
<img id="lightboxImg" alt="放大查看">
|
||||
</div>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+125
-55
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user