Files
yml2213 5c4ff8c4a1 feat(web): 靶子显示友好名 + 支持连打次数
- targets.py 增加 label 字段(1ms 签到 / mliev 登录),下拉框、说明、历史表统一用友好名
- POST /api/jobs 支持 count(1~20):一次任务连打 N 发,发间自动按靶子频控节流、可取消
- 每发结果记入 stats.rounds,EVT_VERIFY 带 round,结果区展示「每发结果(通过 x/y)」彩色一览
- 滑块不再生成标注图(红圈+序号只表达点击顺序,对滑块是误导)
- 观测台 README 补齐启动/后台运行/端口占用/自检说明
2026-10-15 11:20:24 +08:00

401 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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();