- targets.py 增加 label 字段(1ms 签到 / mliev 登录),下拉框、说明、历史表统一用友好名 - POST /api/jobs 支持 count(1~20):一次任务连打 N 发,发间自动按靶子频控节流、可取消 - 每发结果记入 stats.rounds,EVT_VERIFY 带 round,结果区展示「每发结果(通过 x/y)」彩色一览 - 滑块不再生成标注图(红圈+序号只表达点击顺序,对滑块是误导) - 观测台 README 补齐启动/后台运行/端口占用/自检说明
401 lines
14 KiB
JavaScript
401 lines
14 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 分派。
|
||
*
|
||
* 布局要点:图片**不铺满容器**——底图/标注图限高缩略、提示图小缩略,
|
||
* 点任意图进 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) => `<option value="${t.key}">${t.label || t.key}(最小间隔 ${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.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 `<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 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();
|