/* tcaptcha 观测台 —— 零构建单页。只用原生 API,不引框架/打包器。 * * 数据来源只有两个: * 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'; const $ = (id) => document.getElementById(id); const EC_MEANING = { 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: 'other' }; return EC_MEANING[Number(ec)] || { text: '未知', cls: 'other' }; } function hhmmss(ts) { const d = new Date((ts || Date.now() / 1000) * 1000); 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: [], currentJob: null, es: null, cooldownUntil: 0, cards: {}, }; /* ------------------------------------------------------------------ 启动 */ async function boot() { await loadTargets(); await loadHistory(); setInterval(tickCooldown, 1000); $('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; $('target').innerHTML = r.targets .map((t) => ``) .join(''); renderNote(); applyCooldown(r.cooldown); } function renderNote() { const t = state.targets.find((x) => x.key === $('target').value); $('targetNote').textContent = t ? `【${t.label || t.key}】${t.note}` : ''; } function applyCooldown(cd) { const left = cd && cd[$('target').value]; if (typeof left === 'number' && left > 0) state.cooldownUntil = Date.now() + left * 1000; } function tickCooldown() { const left = Math.ceil((state.cooldownUntil - Date.now()) / 1000); const elCD = $('cooldown'); if (left > 0) { elCD.textContent = `频控冷却 ${left}s`; elCD.className = 'cooldown'; $('start').disabled = true; } else { elCD.textContent = '可发'; elCD.className = 'cooldown ready'; $('start').disabled = false; } } /* ------------------------------------------------------------------ 任务 */ async function startJob() { 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' }, body: JSON.stringify(body), }).then((r) => r.json()); subscribe(job.id); } function subscribe(jobId) { if (state.es) { state.es.close(); state.es = null; } state.currentJob = 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 会自动重连 if (state.es === es) state.es = null; } }; es.onerror = () => { /* 断线时自动重连并带 Last-Event-ID 续传 */ }; state.es = es; } async function cancelJob() { if (!state.currentJob) return; await fetch(`/api/jobs/${state.currentJob}/cancel`, { method: 'POST' }); $('cancel').disabled = true; } /* ------------------------------------------------------------ 事件分派 */ function onEvent(evt) { const { type, data } = evt; switch (type) { case 'job.started': setState('running'); 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); 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}` : '')); return; case 'verify': renderVerify(data); return; case 'job.finished': finishJob(data); return; case 'job.error': setState('error'); logRow('ERROR', data.message || '任务异常'); $('cancel').disabled = true; return; default: return; } } function setState(s) { const map = { pending: ['排队中', ''], running: ['执行中', 'running'], throttled: ['频控等待', 'warn'], done: ['已完成', 'ok'], error: ['失败', 'err'], cancelled: ['已取消', 'warn'], }; const [text, cls] = map[s] || [s, '']; 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 = 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 = $('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; // 标注图是 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); } 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); 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) { const info = ecInfo(d.errorCode); setState(d.success ? 'done' : (info.cls === 'ec12' ? 'error' : 'warn')); $('cancel').disabled = true; const st = d.stats || {}; 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 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.replaceChildren(); for (const j of r.jobs) { const ec = j.result ? j.result.errorCode : null; const info = ecInfo(ec); 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); }); } /** 点历史行 → 重新订阅该任务的事件流,把整个过程重放一遍。 */ function replay(jobId) { resetView(); logRow('meta', `重放任务 ${jobId} …`); subscribe(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').replaceChildren(el('div', 'empty', '等待事件…')); $('challenges').replaceChildren(el('p', 'empty-hint', '抽到题目后,底图与标注图会出现在这里。')); $('result').replaceChildren(el('p', 'empty-hint', '等待事件…')); state.cards = {}; } boot();