/* 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) => ``)
.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 = `${hhmmss()}`;
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(``);
if (d.prompt_url) imgs.push(`
`);
card.innerHTML =
`
等待事件…
'; state.cards = {}; } boot();