- tcaptcha/web/:FastAPI + SSE + 零构建单页。把同步阻塞的 solve() 包成可观测 后台任务(工作线程 + 全局串行,因 onnxruntime session 非线程安全),用 logging.Handler 零侵入把 client 日志变成事件流;按靶子实测 min_interval 强制节流。 - tcaptcha/render.py:标注图(点击顺序编号 + 质心偏差)与点位量尺,CLI 与 web 共用。 - tcaptcha/targets.py:靶子表提为单一来源(含实测频控间隔 1ms=60s / mliev=3s)。 - fastapi/uvicorn 走 optional extra `web`,核心包零 web 依赖(带回归断言)。 - 修两个真 bug:hook 求解器时 args[0] 是实例自身(真入参从 args[1] 起); client 默认每次新建 solver,预热实例未被复用(每发重载 56MB ONNX)。 - 复验:icon/text/slider 三题型各拿到 errorCode=0;selftest 全绿;依赖隔离通过。 用法:uv run --extra web python -m tcaptcha.web.app
295 lines
10 KiB
JavaScript
295 lines
10 KiB
JavaScript
/* tcaptcha 观测台 —— 零构建单页。只用原生 API,不引框架/打包器。
|
||
*
|
||
* 数据来源只有两个:
|
||
* POST /api/jobs 建任务,拿 job_id
|
||
* GET /api/jobs/{id}/events SSE 事件流(服务端推 event.to_dict())
|
||
* 事件类型由后端 tcaptcha/web/events.py 的 EVT_* 定义,这里按 data.type 分派。
|
||
*/
|
||
|
||
'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' },
|
||
};
|
||
|
||
function ecInfo(ec) {
|
||
if (ec === null || ec === undefined || ec === '') return { text: '—', cls: 'ecother' };
|
||
return EC_MEANING[Number(ec)] || { text: '未知', cls: 'ecother' };
|
||
}
|
||
|
||
function hhmmss(ts) {
|
||
const d = new Date((ts || Date.now() / 1000) * 1000);
|
||
return d.toTimeString().slice(0, 8);
|
||
}
|
||
|
||
let state = {
|
||
targets: [], kinds: [], currentJob: null, es: null,
|
||
cooldownUntil: 0, cards: {}, lastResult: null,
|
||
};
|
||
|
||
/* ------------------------------------------------------------------ 启动 */
|
||
async function boot() {
|
||
await loadTargets();
|
||
await loadHistory();
|
||
setInterval(tickCooldown, 1000);
|
||
$('target').onchange = () => renderNote();
|
||
$('start').onclick = startJob;
|
||
$('cancel').onclick = cancelJob;
|
||
}
|
||
|
||
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>`)
|
||
.join('');
|
||
renderNote();
|
||
applyCooldown(r.cooldown);
|
||
}
|
||
|
||
function renderNote() {
|
||
const t = state.targets.find((x) => x.key === $('target').value);
|
||
$('targetNote').textContent = t ? `【${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 el = $('cooldown');
|
||
if (left > 0) {
|
||
el.textContent = `频控冷却 ${left}s`;
|
||
el.className = 'cooldown';
|
||
$('start').disabled = true;
|
||
} else {
|
||
el.textContent = '可发';
|
||
el.className = 'cooldown ready';
|
||
$('start').disabled = false;
|
||
}
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ 任务 */
|
||
async function startJob() {
|
||
const body = {
|
||
target: $('target').value,
|
||
kind: $('kind').value,
|
||
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;
|
||
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 = () => { /* 断线时 EventSource 自动重连并带 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.target} aid=${data.aid} 题型=${data.kind} 模式=${data.mode}`);
|
||
return;
|
||
case 'log':
|
||
logRow(data.level, data.message);
|
||
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}` : ''));
|
||
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 el = $('state');
|
||
el.textContent = text;
|
||
el.className = 'badge ' + cls;
|
||
}
|
||
|
||
function logRow(level, message) {
|
||
const box = $('log');
|
||
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;
|
||
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); // 最新的题放最上面
|
||
state.cards[d.index] = card;
|
||
}
|
||
|
||
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);
|
||
}
|
||
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>`;
|
||
}
|
||
|
||
function finishJob(d) {
|
||
const info = ecInfo(d.errorCode);
|
||
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>`);
|
||
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';
|
||
}
|
||
|
||
/* ------------------------------------------------------------ 历史任务 */
|
||
async function loadHistory() {
|
||
const r = await fetch('/api/jobs').then((x) => x.json());
|
||
const tb = $('history').querySelector('tbody');
|
||
tb.innerHTML = '';
|
||
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>`;
|
||
tr.onclick = () => replay(j.id);
|
||
tb.appendChild(tr);
|
||
}
|
||
}
|
||
|
||
/** 点历史行 → 重新订阅该任务的事件流,把整个过程重放一遍。 */
|
||
function replay(jobId) {
|
||
resetView();
|
||
logRow('meta', `重放任务 ${jobId} …`);
|
||
subscribe(jobId);
|
||
$('cancel').disabled = true;
|
||
}
|
||
|
||
function resetView() {
|
||
$('log').innerHTML = '';
|
||
$('images').innerHTML = '';
|
||
$('result').innerHTML = '<p class="muted">等待事件…</p>';
|
||
state.cards = {};
|
||
}
|
||
|
||
boot();
|