// ==UserScript==
// @name 手势归组校验工具 - 分组检查
// @namespace http://113.207.49.90/
// @version 2.6.0
// @description 全题包实时校验:归组规则=每组「1行人框+1半身框(+可选1手势框)」;框尺寸小于20×40px报错;列出全题包有问题的帧并可点击跳转;自动统计半身框总数并在超限时报错;题包仍有问题时阻止“整题通过”,且面板不可关闭
// @author 粟镇
// @match *://*/w/task.html*
// @grant none
// @run-at document-start
// ==/UserScript==
(function () {
'use strict';
// ============================================================
// 配置区(可在面板“设置”里改,改完自动记住)
// ============================================================
const SETTINGS_KEY = 'sz-gesture-settings-v3';
const STORAGE_KEY = 'sz-gesture-package-stats-v3';
const DEFAULT_SETTINGS = {
clsFull: 'full_bbox',
clsHalf: 'half_bbox_xywh',
clsHand: 'handbboxxywh',
tolPx: 0, // 已废弃(越界校验已按需求移除),仅保留以兼容旧配置
totalOverride: '', // 读不到总帧数时手动填
halfLimit: 33 // 整个题包半身框总数上限,超过即报错;0 表示不限制
};
// 标签兜底:没有 class.pname 时按显示名判断
const LABEL_FULL = ['行人主框', '行人框', '全身框', '行人'];
const LABEL_HALF = ['半身框', '半身'];
const LABEL_HAND = ['手势框', '手势'];
const CAPTURE_MAX = 300; // 最多保留的接口响应数
const CAPTURE_MAX_BYTES = 8 * 1024 * 1024; // 单条超过 8MB 不解析
const SCAN_DEPTH_MAX = 16;
const SCAN_CONCURRENCY = 6; // 题包统计并发数
// ---- 规则开关 ----
const SUBMIT_BLOCK = true; // 题包存在问题时阻止整题通过
const BLOCK_ON_HALF_LIMIT = true; // 半身框总数超限也视为阻塞问题
const BLOCK_NET_SUBMIT = true; // 同时拦截“整题通过”的底层网络请求(防绕过)
const LOCK_PANEL = true; // 题包有阻塞级问题时,面板不可关闭/隐藏
const AUTO_RESCAN_MS = 0; // >0 时定时重新扫描整个题包(毫秒),0=关闭
const REFRESH_CUR_MS = 2500; // 实时刷新当前帧校验的间隔(毫秒)
const CHECK_PKG_ON_LOAD = true; // 打开页面即对整个题包做一次校验
// 框尺寸下限(像素):宽 < 20 或 高 < 40 即报错
const MIN_BOX_W = 20;
const MIN_BOX_H = 40;
// 每个对象/组的分框规则:1 个行人框 + 1 个半身框 + (可选的 1 个手势框)
const GROUP_RULE_TEXT = '1 个行人框 + 1 个半身框(手势框可选,最多 1 个)';
// “整题通过”可能的接口地址特征(用于底层拦截,防止绕过点击拦截)
const SUBMIT_URL_PATTERNS = [
/pass[_-]?all/i, /passall/i, /all[_-]?pass/i,
/batch[_-]?(pass|accept|audit)/i, /task[_-]?pass/i, /mark[_-]?pass/i,
/reject[_-]?sub[_-]?all/i
];
// ============================================================
// 状态
// ============================================================
let cachedData = null;
let panel = null;
let panelReady = false;
let lastRequest = null; // 最近一次 get-mark-data 请求
let scanning = false; // 是否正在接口批量统计
let currentImage = { w: null, h: null };
let settings = Object.assign({}, DEFAULT_SETTINGS);
let cachedFrameKey = null; // 当前 cachedData 对应的帧(图片路径)
let cachedFrameNo = null; // 当前 cachedData 对应的帧号
let lastWatchedFrame = null; // 帧号监听:上次看到的帧号
let autoBatchRanSig = null; // 已自动统计过的题包,避免重复
let autoBatchRunning = false;
// 提交拦截
let scanPromise = null; // 正在进行的题包扫描
let allowPassThrough = false; // 放行标志(校验通过后重新触发点击)
let pendingPass = false; // 点过“整题通过”,等待确认
let blocking = false; // 正在做提交前校验
let pendingBtn = null; // 被拦下的那个“整题通过”按钮
// 捕获到的所有接口响应:{ url, method, json, frames:[...] }
let captured = [];
// 全题包统计:frames 以“帧唯一键”索引,避免翻页与接口统计重复计数
// { taskSig, totalFrames, frames: { key: {half, marks, status, no, src} } }
let pkgStats = { taskSig: '', totalFrames: null, frames: {} };
// ============================================================
// 拦截 XHR / fetch
// ============================================================
function hookXHR() {
const origOpen = XMLHttpRequest.prototype.open;
const origSend = XMLHttpRequest.prototype.send;
const origSetHeader = XMLHttpRequest.prototype.setRequestHeader;
XMLHttpRequest.prototype.open = function (method, url) {
this._szUrl = url;
this._szMethod = method;
this._szHeaders = {};
return origOpen.apply(this, arguments);
};
XMLHttpRequest.prototype.setRequestHeader = function (name, value) {
if (this._szHeaders) this._szHeaders[name] = value;
return origSetHeader.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function (body) {
const self = this;
const isMarkData = self._szUrl && self._szUrl.indexOf('get-mark-data') !== -1;
// 底层拦截:题包还有问题时,不让“整题通过”的请求发出去(防绕过点击拦截)
if (guardSubmitRequest(self._szUrl)) {
try { self.abort(); } catch (e) {}
return;
}
if (isMarkData) {
lastRequest = {
url: self._szUrl,
method: self._szMethod || 'GET',
body: body,
headers: self._szHeaders || {},
frameNoAtSend: resolveFrameNo(self._szUrl, body)
};
}
let handled = false;
const onDone = function () {
if (handled || self.readyState !== 4) return;
handled = true;
if (self.status !== 200) return;
const resp = readJsonLoose(self.responseText, self.response);
if (!resp) return;
captureResponse(self._szUrl, self._szMethod, resp);
if (isMarkData && resp.code === 0 && resp.data) {
const md = resp.data.markData || {};
if (md.width) currentImage.w = md.width;
if (md.height) currentImage.h = md.height;
let frameNo = lastRequest.frameNoAtSend;
if (frameNo === null || frameNo === undefined) frameNo = extractFrameNo(resp.data);
if (frameNo === null || frameNo === undefined) {
const ind = readFrameIndicator();
if (ind) frameNo = ind.cur;
}
setCachedData(resp.data, frameNo);
recordFrame(resp.data.markData ? resp.data : { markData: md, mark_status: resp.data.mark_status }, frameNo, 'walk', extractTaskId(resp.data));
if (panelReady) autoRunCheck();
// 首次拿到数据后,自动统计整个题包(只跑一次)
maybeAutoBatch();
}
};
try { self.addEventListener('load', onDone); } catch (e) {}
try { self.addEventListener('readystatechange', onDone); } catch (e) {}
return origSend.apply(this, arguments);
};
}
function hookFetch() {
if (typeof window === 'undefined' || !window.fetch) return;
const origFetch = window.fetch;
window.fetch = function () {
const args = arguments;
const reqUrl = (typeof args[0] === 'string') ? args[0] : (args[0] && args[0].url);
if (guardSubmitRequest(reqUrl)) {
return Promise.reject(new Error('题包仍存在问题,已阻止整题通过'));
}
return origFetch.apply(this, args).then(function (resp) {
try {
const url = (typeof args[0] === 'string') ? args[0] : (args[0] && args[0].url);
const method = (args[1] && args[1].method) || (args[0] && args[0].method) || 'GET';
resp.clone().text().then(function (txt) {
const json = readJsonLoose(txt, null);
if (json) captureResponse(url, method, json);
}).catch(function () {});
} catch (e) {}
return resp;
});
};
}
function readJsonLoose(text, fallbackObj) {
if (typeof text === 'string' && text) {
const t = text.trim();
if (t.length > CAPTURE_MAX_BYTES) return null;
if (t.charAt(0) === '{' || t.charAt(0) === '[') {
try { return JSON.parse(t); } catch (e) {}
}
}
if (fallbackObj && typeof fallbackObj === 'object') return fallbackObj;
return null;
}
// 捕获响应并就地解析出其中的“帧”数据
function captureResponse(url, method, json) {
let frames = [];
try { frames = collectFrames(json); } catch (e) { frames = []; }
captured.push({ url: url || '', method: method || '', json: json, frames: frames, at: Date.now() });
while (captured.length > CAPTURE_MAX) captured.shift();
// 标注保存类请求成功后,延迟刷新当前帧,做到校验实时更新
if (url && isSaveUrl(url)) scheduleRefresh(700);
return frames;
}
// 深度遍历任意 JSON,抓出所有“含 marks 的帧对象”
function collectFrames(root) {
const out = [];
const seen = new Set(); // 防止循环引用
const consumed = new Set(); // 已作为帧容器使用过的节点,不再向下挖,避免重复计数
const visited = [];
(function walk(node, depth) {
if (depth > SCAN_DEPTH_MAX || !node || typeof node !== 'object') return;
if (seen.has(node) || consumed.has(node)) return;
seen.add(node);
if (Array.isArray(node)) {
for (let i = 0; i < node.length; i++) walk(node[i], depth + 1);
return;
}
let marks = null, status, imagePath = null, container = null;
if (Array.isArray(node.marks) && node.marks.length) {
marks = node.marks;
status = (node.mark_status !== undefined) ? node.mark_status : undefined;
container = node;
} else if (node.markData && Array.isArray(node.markData.marks) && node.markData.marks.length) {
marks = node.markData.marks;
status = (node.mark_status !== undefined) ? node.mark_status : node.markData.mark_status;
imagePath = node.markData.imagePath || node.markData.imgUrl || null;
container = node.markData;
} else if (Array.isArray(node.mark_data) && node.mark_data.length) {
marks = node.mark_data;
status = node.mark_status;
container = node;
}
if (marks && marks.some(isMarkLike)) {
if (!imagePath) imagePath = node.imagePath || node.imgUrl || node.img || node.url || null;
visited.push({ marks: marks, status: status, imagePath: imagePath });
consumed.add(container);
}
for (const k in node) {
if (!Object.prototype.hasOwnProperty.call(node, k)) continue;
if (k === 'marks' || k === 'mark_data') continue;
const v = node[k];
if (v && typeof v === 'object') walk(v, depth + 1);
}
})(root, 0);
// 同一响应内去重:先按图片路径,再用 markId 集合兜底
const bySig = {}, byMarks = {};
for (let i = 0; i < visited.length; i++) {
const f = visited[i];
const sig = frameSignature(f);
const mk = 'k:' + (f.marks || []).map(function (m) { return m.markId; }).filter(Boolean).join(',');
if (bySig[sig] || (mk !== 'k:' && byMarks[mk])) continue;
bySig[sig] = true;
if (mk !== 'k:') byMarks[mk] = true;
out.push(f);
}
return out;
}
function isMarkLike(o) {
if (!o || typeof o !== 'object' || Array.isArray(o)) return false;
const hasClass = (o.class && (o.class.pname || o.class.name)) || o.pselect;
if (!hasClass) return false;
return !!extractBox(o);
}
// 帧唯一键:优先图片路径(同一帧在任何接口里都一致),否则用 markId 组合
function frameSignature(f) {
if (f.imagePath) return 'p:' + f.imagePath;
const ids = (f.marks || []).map(function (m) { return m.markId; }).filter(Boolean).join(',');
if (ids) return 'm:' + ids;
return 'j:' + JSON.stringify((f.marks || []).slice(0, 6)).slice(0, 400);
}
// ============================================================
// 帧号 / 总帧数读取
// ============================================================
function readFrameIndicator() {
if (!document.body) return null;
const re = /^\s*(\d+)\s*[\/|·]\s*(\d+)\s*$/;
let best = null;
const consider = function (raw) {
if (raw === null || raw === undefined) return;
const t = String(raw).trim();
if (!t || t.length > 16) return;
const m = t.match(re);
if (!m) return;
const cur = parseInt(m[1], 10), tot = parseInt(m[2], 10);
if (cur >= 1 && tot >= cur && tot <= 200000) {
if (!best || tot > best.tot) best = { cur: cur, tot: tot };
}
};
const all = document.body.querySelectorAll('*');
for (let i = 0; i < all.length; i++) {
const el = all[i];
if (panel && panel.contains(el)) continue;
if (el.children && el.children.length > 2) continue;
consider(el.textContent);
}
const inputs = document.body.querySelectorAll('input, textarea');
for (let i = 0; i < inputs.length; i++) {
if (panel && panel.contains(inputs[i])) continue;
consider(inputs[i].value);
}
return best;
}
function getTotalFrames() {
const ind = readFrameIndicator();
if (ind) return ind.tot;
const n = toNum(settings.totalOverride);
return (n !== null && n > 0) ? n : null;
}
function resolveFrameNo(url, body) {
const fromReq = pickFrameFromRequest(url, body);
if (fromReq !== null) return fromReq;
const ind = readFrameIndicator();
return ind ? ind.cur : null;
}
function pickFrameFromRequest(url, body) {
const names = ['frame_id', 'frameId', 'frame_index', 'frameIndex', 'frame_no', 'frameNo',
'current_frame', 'currentFrame', 'frame', 'index', 'idx'];
for (let i = 0; i < names.length; i++) {
const n = toNum(pickParamValue(url, body, [names[i]]));
if (n !== null) return n;
}
return null;
}
function extractFrameNo(data) {
if (!data || typeof data !== 'object') return null;
const keys = ['frame_id', 'frameId', 'frame_index', 'frameIndex', 'frame',
'frame_no', 'frameNo', 'current_frame', 'currentFrame', 'index', 'idx', 'seq'];
for (let i = 0; i < keys.length; i++) {
const n = toNum(data[keys[i]]);
if (n !== null) return n;
}
return null;
}
// 从接口返回里取 task_id(本平台 task_id 即帧标识)
function extractTaskId(data) {
if (!data || typeof data !== 'object') return null;
const keys = ['task_id', 'taskId', 'taskID'];
for (let i = 0; i < keys.length; i++) {
const v = data[keys[i]];
if (v !== undefined && v !== null && v !== '') return v;
}
return null;
}
function extractTotalFrames(data) {
if (!data || typeof data !== 'object') return null;
const keys = ['total_frame', 'totalFrame', 'total_frames', 'totalFrames', 'frame_count', 'frameCount', 'frames_total'];
for (let i = 0; i < keys.length; i++) {
const n = toNum(data[keys[i]]);
if (n !== null && n > 0 && n <= 200000) return n;
}
return null;
}
// ============================================================
// 参数工具
// ============================================================
function toNum(v) {
if (typeof v === 'number') return isNaN(v) ? null : v;
if (typeof v === 'string' && v.trim() !== '') {
const n = parseFloat(v);
return isNaN(n) ? null : n;
}
return null;
}
function parseBody(body) {
if (body === null || body === undefined || body === '') return null;
if (typeof body === 'object') return body;
if (typeof body !== 'string') return null;
try { return JSON.parse(body); } catch (e) {}
try {
const sp = new URLSearchParams(body);
const o = {};
sp.forEach(function (v, k) { o[k] = v; });
return o;
} catch (e) {}
return null;
}
function pickParamValue(url, body, names) {
for (let i = 0; i < names.length; i++) {
const name = names[i];
try {
const u = new URL(url, location.href);
if (u.searchParams.has(name)) return u.searchParams.get(name);
} catch (e) {}
const obj = parseBody(body);
if (obj && obj[name] !== undefined && obj[name] !== null) return obj[name];
}
return null;
}
function getTaskSignature() {
let taskId = null;
if (lastRequest) taskId = pickParamValue(lastRequest.url, lastRequest.body, ['task_id', 'taskId']);
if (taskId === null) {
try {
const u = new URL(location.href);
taskId = u.searchParams.get('task_id') || u.searchParams.get('taskId');
} catch (e) {}
}
if (taskId !== null && taskId !== undefined && taskId !== '') return 'task:' + taskId;
if (cachedData && cachedData.markData && cachedData.markData.imagePath) {
return 'img:' + cachedData.markData.imagePath.replace(/\/[^/]*$/, '');
}
return 'path:' + location.pathname;
}
// ============================================================
// 标记分类 / 坐标提取
// ============================================================
function getClassNames(mark) {
const cls = mark.class || {};
const names = [];
if (cls.pname) names.push(String(cls.pname));
if (cls.name) names.push(String(cls.name));
if (mark.pselect) names.push(String(mark.pselect));
return names;
}
function classifyMark(mark) {
const names = getClassNames(mark);
const low = names.map(function (n) { return n.toLowerCase(); });
const eqAny = function (target) { return target && low.indexOf(String(target).toLowerCase()) !== -1; };
if (eqAny(settings.clsFull)) return 'full';
if (eqAny(settings.clsHalf)) return 'half';
if (eqAny(settings.clsHand)) return 'hand';
const hit = function (arr) { return names.some(function (n) { return arr.indexOf(n) !== -1; }); };
if (hit(LABEL_FULL)) return 'full';
if (hit(LABEL_HALF)) return 'half';
if (hit(LABEL_HAND)) return 'hand';
return null;
}
function extractBox(mark) {
if (!mark) return null;
const sources = [
mark.point, mark.points, mark,
mark.bbox, mark.box, mark.rect, mark.position, mark.pos,
mark.coords, mark.coord, mark.data
].filter(function (s) { return s && typeof s === 'object'; });
for (let i = 0; i < sources.length; i++) {
const s = sources[i];
if (Array.isArray(s)) {
if (s.length >= 2 && s.every(function (p) { return p && typeof p === 'object' && toNum(p.x) !== null; })) {
const xs = [], ys = [];
s.forEach(function (p) { xs.push(toNum(p.x)); ys.push(toNum(p.y)); });
return { x1: Math.min.apply(null, xs), y1: Math.min.apply(null, ys), x2: Math.max.apply(null, xs), y2: Math.max.apply(null, ys) };
}
if (s.length >= 4) {
const a = s.map(toNum);
if (a[0] !== null && a[1] !== null && a[2] !== null && a[3] !== null) {
if (a[2] > a[0] && a[3] > a[1]) return { x1: a[0], y1: a[1], x2: a[2], y2: a[3] };
return { x1: a[0], y1: a[1], x2: a[0] + a[2], y2: a[1] + a[3] };
}
}
continue;
}
const l = toNum(s.left !== undefined ? s.left : s.x1);
const t = toNum(s.top !== undefined ? s.top : s.y1);
const r = toNum(s.right !== undefined ? s.right : s.x2);
const b = toNum(s.bottom !== undefined ? s.bottom : s.y2);
if (l !== null && t !== null && r !== null && b !== null) return { x1: l, y1: t, x2: r, y2: b };
const x = toNum(s.x !== undefined ? s.x : s.left);
const y = toNum(s.y !== undefined ? s.y : s.top);
const w = toNum(s.w !== undefined ? s.w : s.width);
const h = toNum(s.h !== undefined ? s.h : s.height);
if (x !== null && y !== null && w !== null && h !== null) return { x1: x, y1: y, x2: x + w, y2: y + h };
const x1 = toNum(s.xmin), y1 = toNum(s.ymin), x2 = toNum(s.xmax), y2 = toNum(s.ymax);
if (x1 !== null && y1 !== null && x2 !== null && y2 !== null) return { x1: x1, y1: y1, x2: x2, y2: y2 };
const pts = s.points || s.pts || s.xy || s.polygon;
if (Array.isArray(pts) && pts.length) {
const xs = [], ys = [];
pts.forEach(function (p) {
const px = Array.isArray(p) ? toNum(p[0]) : toNum(p.x);
const py = Array.isArray(p) ? toNum(p[1]) : toNum(p.y);
if (px !== null && py !== null) { xs.push(px); ys.push(py); }
});
if (xs.length) return { x1: Math.min.apply(null, xs), y1: Math.min.apply(null, ys), x2: Math.max.apply(null, xs), y2: Math.max.apply(null, ys) };
}
}
return null;
}
function isNormalized(box) {
const maxV = Math.max(Math.abs(box.x1), Math.abs(box.y1), Math.abs(box.x2), Math.abs(box.y2));
return maxV <= 1.5;
}
// 归组标识:本平台用 groupId(配合 groupL),objectId 是“对象/轨迹”,不是归组键
function getGroupId(mark) {
const v = mark.groupId !== undefined ? mark.groupId
: (mark.group_id !== undefined ? mark.group_id : mark.group);
if (v === undefined || v === null || v === '') return null;
if (v === 0 || v === '0') return null; // 0 表示未归组
return String(v);
}
// ============================================================
// 全题包统计
// ============================================================
function countClass(marks, kind) {
let n = 0;
for (let i = 0; i < (marks || []).length; i++) {
if (classifyMark(marks[i]) === kind) n++;
}
return n;
}
// 帧唯一键:优先图片路径,其次帧号
function getFrameKey(data, frameNo) {
const md = (data && data.markData) || {};
if (md.imagePath) return 'p:' + md.imagePath;
if (md.imgUrl) return 'u:' + String(md.imgUrl).split('?')[0];
if (frameNo !== null && frameNo !== undefined) return 'n:' + frameNo;
return null;
}
function recordFrame(data, frameNo, src, taskId) {
const md = (data && data.markData) || {};
const marks = md.marks || [];
if (!marks.length && data && data.mark_status === undefined) return;
const key = getFrameKey(data, frameNo);
if (!key) return;
// 全包校验:按本帧自己的原图尺寸换算框的像素大小
const img = {
w: md.width || md.imgWidth || md.imageWidth || currentImage.w,
h: md.height || md.imgHeight || md.imageHeight || currentImage.h
};
const probs = frameProblems(md, data.mark_status, img);
const sig = getTaskSignature();
if (pkgStats.taskSig !== sig) {
pkgStats = { taskSig: sig, totalFrames: pkgStats.totalFrames || null, frames: {} };
}
const tot = getTotalFrames() || extractTotalFrames(data);
if (tot) pkgStats.totalFrames = tot;
const prev = pkgStats.frames[key];
pkgStats.frames[key] = {
half: countClass(marks, 'half'),
marks: marks.length,
status: data.mark_status,
no: (frameNo !== null && frameNo !== undefined) ? frameNo : null,
src: src || 'walk',
bad: probs.length,
block: probs.some(function (p) { return p.block !== false; }) ? 1 : 0,
probs: probs.slice(0, 6).map(function (p) { return String(p.msg).slice(0, 160); }),
badMsg: probs.length ? probs.slice(0, 3).map(function (p) { return p.msg; }).join(';').slice(0, 300) : '',
at: Date.now(),
tid: (taskId !== undefined && taskId !== null) ? String(taskId)
: (prev && prev.tid) || null
};
saveStats();
if (panelReady) updatePackageView();
}
// 记录当前帧数据对应的帧标识(图片路径 + 帧号)
function setCachedData(data, frameNo) {
cachedData = data;
const md = (data && data.markData) || {};
const path = md.imagePath || md.imgUrl || null;
cachedFrameKey = path ? String(path).split('?')[0] : null;
cachedFrameNo = (frameNo !== null && frameNo !== undefined) ? frameNo : extractFrameNo(data);
// 若帧号与页面指示器一致,同步监听基线,避免重复拉取
if (cachedFrameNo !== null && cachedFrameNo !== undefined) lastWatchedFrame = cachedFrameNo;
}
// 帧切换监听:靠帧号变化驱动自动校验。
// 平台对已看过的帧会走缓存、不发请求,只有轮询帧号才能保证切帧必校验。
function startFrameWatcher() {
setInterval(frameWatcherTick, 700);
}
// 一次帧切换检查:返回 'ok'(已校验当前缓存帧)/ 'load'(需拉取)/ 'idle'
function frameWatcherTick() {
if (scanning || autoBatchRunning) return 'idle';
const ind = readFrameIndicator();
if (!ind) return 'idle';
if (ind.cur === lastWatchedFrame) return 'idle';
lastWatchedFrame = ind.cur;
// 帧号变了:若当前缓存的数据就是这一帧,直接校验
if (cachedFrameNo === ind.cur) {
if (panelReady) autoRunCheck();
return 'ok';
}
autoLoadFrame(ind.cur);
return 'load';
}
let autoLoadBusy = false;
async function autoLoadFrame(frameNo) {
if (autoLoadBusy) return;
if (!lastRequest || !cachedData) return; // 等页面自身首次加载完成再自动拉取
autoLoadBusy = true;
try {
// 优先从 DOM 帧导航条拿这一帧的 task_id
const domList = readFrameListFromDom();
let taskId = null;
if (domList && domList.list.length) {
const hit = domList.list.filter(function (e) { return e.order === frameNo - 1 || e.order === frameNo; });
if (hit.length) taskId = hit[0].id;
else if (domList.list[frameNo - 1]) taskId = domList.list[frameNo - 1].id;
}
if (taskId === null) return; // 拿不到 task_id 就不乱请求
const data = await requestMarkData(taskId);
setCachedData(data, frameNo);
recordFrame(data, frameNo, 'walk', taskId);
if (panelReady) autoRunCheck();
} catch (e) {
// 自动拉取失败保持静默,不打扰标注
} finally {
autoLoadBusy = false;
}
}
function frameNoFromPath(p) {
if (!p) return null;
const m = String(p).match(/(\d+)(?=\.\w+$)/);
return m ? parseInt(m[1], 10) : null;
}
// ============================================================
// 实时刷新当前帧:标注保存后 / 定时轮询,重新拉当前帧数据并校验
// ============================================================
let refreshTimer = null;
function isSaveUrl(url) {
if (!url) return false;
return /(save|update|submit)[-_]?(mark|marks|bbox|box|label)/i.test(url)
|| /(mark|marks|bbox|box|label)[-_]?(save|update|submit)/i.test(url)
|| /mark[-_]?save/i.test(url);
}
// 保存类请求完成后,稍等片刻再刷新,避免拿到旧数据
function scheduleRefresh(delay) {
if (refreshTimer) clearTimeout(refreshTimer);
refreshTimer = setTimeout(function () { refreshTimer = null; refreshCurrentFrame(); }, delay || 700);
}
function frameTaskIdFor(no) {
const domList = readFrameListFromDom();
if (domList && domList.list.length) {
const hit = domList.list.filter(function (e) { return e.order === no - 1 || e.order === no; });
if (hit.length) return hit[0].id;
if (domList.list[no - 1]) return domList.list[no - 1].id;
}
const rec = pkgStats.frames['n:' + no];
if (rec && rec.tid) return rec.tid;
if (cachedFrameNo === no && cachedData && cachedData.markData) {
const md = cachedData.markData;
if (md.task_id || md.taskId) return md.task_id || md.taskId;
}
return null;
}
// 重新拉取当前帧的标注数据并刷新校验面板
async function refreshCurrentFrame(force) {
if (scanning || autoBatchRunning || autoLoadBusy) return false;
if (!lastRequest) return false;
const ind = readFrameIndicator();
const no = ind ? ind.cur : cachedFrameNo;
if (no === null || no === undefined) return false;
if (!force && !cachedData) return false;
const taskId = frameTaskIdFor(no);
if (taskId === null || taskId === undefined) return false;
autoLoadBusy = true;
try {
const data = await requestMarkData(taskId);
setCachedData(data, no);
recordFrame(data, no, 'refresh', taskId);
if (panelReady) { updatePackageView(); autoRunCheck(); }
return true;
} catch (e) {
return false;
} finally {
autoLoadBusy = false;
}
}
// ============================================================
// 题包任务列表 → 逐帧请求 get-mark-data
// ============================================================
function walkJson(node, visit, depth, key) {
depth = depth || 0;
if (depth > SCAN_DEPTH_MAX || !node || typeof node !== 'object') return;
visit(node, key);
if (Array.isArray(node)) {
for (let i = 0; i < node.length; i++) walkJson(node[i], visit, depth + 1, null);
return;
}
for (const k in node) {
if (!Object.prototype.hasOwnProperty.call(node, k)) continue;
const v = node[k];
if (v && typeof v === 'object') walkJson(v, visit, depth + 1, k);
}
}
const TASK_ID_KEYS = ['task_id', 'taskId', 'taskID', 'tid'];
const TASK_UUID_KEYS = ['task_uuid', 'taskUuid', 'uuid'];
const TASK_ORDER_KEYS = ['order', 'sort', 'index', 'idx', 'frame', 'frame_no', 'frameNo', 'seq', 'no'];
function firstKey(obj, keys) {
for (let i = 0; i < keys.length; i++) {
if (obj[keys[i]] !== undefined && obj[keys[i]] !== null && obj[keys[i]] !== '') return obj[keys[i]];
}
return null;
}
// 判断一个对象是否像“帧/任务”条目(本平台:data.frame[] 只带 task_id 和状态位)
function taskEntryOf(obj) {
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return null;
const id = firstKey(obj, TASK_ID_KEYS);
if (id === null) return null;
if (Array.isArray(id) || typeof id === 'object') return null;
return {
id: id,
uuid: firstKey(obj, TASK_UUID_KEYS),
image: null, // 帧列表不含图片,图片从 get-mark-data 里取
order: toNum(firstKey(obj, TASK_ORDER_KEYS))
};
}
// 帧条目特征字段,用于给候选数组加分
const FRAME_FLAG_KEYS = ['done_status', 'mark_flag', 'is_save', 'is_acceptance', 'has_issues', 'mark_status'];
// 从所有已捕获响应里找出最像“题包帧列表”的数组
function findTaskList() {
const pageTotal = getTotalFrames();
let best = null;
for (let i = 0; i < captured.length; i++) {
const url = String(captured[i].url || '');
walkJson(captured[i].json, function (node, key) {
if (!Array.isArray(node) || node.length < 2 || node.length > 20000) return;
const entries = [];
let flagHits = 0;
for (let j = 0; j < node.length; j++) {
const e = taskEntryOf(node[j]);
if (e) entries.push(e);
if (node[j] && typeof node[j] === 'object') {
for (let f = 0; f < FRAME_FLAG_KEYS.length; f++) {
if (node[j][FRAME_FLAG_KEYS[f]] !== undefined) { flagHits++; break; }
}
}
}
if (entries.length < 2) return;
if (entries.length < node.length * 0.5) return;
let score = entries.length + flagHits * 10;
if (key === 'frame') score += 20000; // mark-conf 的 data.frame 最可信
if (pageTotal && entries.length === pageTotal) score += 100000; // 与页面总帧数一致,几乎可确定是全量
if (/packages?\/[^/]*\/tasks|mark-conf|package|batch|frame/i.test(url)) score += 5000;
else if (/task/i.test(url)) score += 1000;
if (!best || score > best.score) best = { score: score, entries: entries, url: url };
});
}
if (!best) return null;
const seen = {};
const list = [];
best.entries.forEach(function (e) {
const k = String(e.id);
if (seen[k]) return;
seen[k] = true;
list.push(e);
});
const hasOrder = list.some(function (e) { return e.order !== null; });
if (hasOrder) {
list.sort(function (a, b) {
const ao = a.order === null ? 1e9 : a.order, bo = b.order === null ? 1e9 : b.order;
return ao - bo;
});
}
return { list: list, url: best.url };
}
function buildMarkBody(taskId) {
let base = parseBody(lastRequest ? lastRequest.body : null) || {};
if (typeof base !== 'object' || Array.isArray(base)) base = {};
const body = {};
for (const k in base) { if (Object.prototype.hasOwnProperty.call(base, k)) body[k] = base[k]; }
body.task_id = taskId;
body.time = Date.now();
return body;
}
// 从页面 URL 读题包参数(id/task_id、package_id、status、work_type、access)
function readPageParams() {
const q = {};
try {
const u = new URL(location.href);
u.searchParams.forEach(function (v, k) { q[k] = v; });
} catch (e) {}
return q;
}
// 复用平台请求头(含 Access-Key 等鉴权头)
function authHeaders(extra) {
const h = Object.assign({}, (lastRequest && lastRequest.headers) || {});
// 采集不到时给常见默认值,保证基本可用
if (!h['X-Requested-With']) h['X-Requested-With'] = 'XMLHttpRequest';
h['Accept'] = h['Accept'] || 'application/json, text/javascript, */*; q=0.01';
return Object.assign(h, extra || {});
}
function getTaskKey() {
let tk = lastRequest ? pickParamValue(lastRequest.url, lastRequest.body, ['task_key', 'taskKey']) : null;
if (tk === null) tk = readPageParams().task_key || null;
return tk;
}
async function getJson(url, method) {
try {
const resp = await fetch(url, { method: method || 'GET', credentials: 'include', headers: authHeaders() });
if (!resp.ok) return { error: 'HTTP ' + resp.status };
const json = await resp.json();
captured.push({ url: url, method: method || 'GET', json: json, frames: [], at: Date.now() });
return { json: json };
} catch (e) {
return { error: String(e && e.message || e) };
}
}
// 从 DOM 的帧导航条读取帧列表:平台渲染
function readFrameListFromDom() {
if (!document.body) return null;
const nodes = document.body.querySelectorAll('[data-task]');
const list = [];
const seen = {};
for (let i = 0; i < nodes.length; i++) {
const el = nodes[i];
if (panel && panel.contains(el)) continue;
const id = el.getAttribute('data-task');
if (id === null || id === undefined || id === '') continue;
if (seen[id]) continue;
seen[id] = true;
const dl = el.getAttribute('data-list');
list.push({ id: id, uuid: null, image: null, order: toNum(dl) });
}
if (list.length < 2) return null;
list.sort(function (a, b) {
const ao = a.order === null ? 1e9 : a.order, bo = b.order === null ? 1e9 : b.order;
return ao - bo;
});
return { list: list, url: 'DOM: [data-task]' };
}
// 主动获取整个题包的帧列表(多路尝试)
async function fetchFrameList() {
// 1) 已捕获的数据里直接找
let tl = findTaskList();
if (tl && tl.list.length) return tl;
// 2) DOM 帧导航条
tl = readFrameListFromDom();
if (tl && tl.list.length) return tl;
const q = readPageParams();
const taskId = q.task_id || q.id || (lastRequest ? pickParamValue(lastRequest.url, lastRequest.body, ['task_id', 'taskId']) : null);
const taskKey = getTaskKey();
const packageId = q.package_id || q.packageId || null;
const status = q.status !== undefined ? q.status : 0;
const workType = q.work_type !== undefined ? q.work_type : 3;
const access = q.access !== undefined ? q.access : 3;
// 2) 题包全量帧列表:/v2/task-batches/{task_key}/packages/{package_id}/tasks
// 注意:平台源码里 URL 带一个多余的 } (packages/44841}/tasks),两种都试
if (taskKey && packageId) {
const urls = [
'/v2/task-batches/' + taskKey + '/packages/' + packageId + '}/tasks',
'/v2/task-batches/' + taskKey + '/packages/' + packageId + '/tasks'
];
for (let i = 0; i < urls.length; i++) {
const r = await getJson(urls[i]);
if (r.json) {
tl = findTaskList();
if (tl && tl.list.length) return tl;
}
}
}
// 3) mark-conf 的 data.frame
if (taskId !== null && taskId !== undefined) {
const url = '/v2/tasks/mark-conf?status=' + status + '&task_id=' + taskId + '&work_type=' + workType + '&access=' + access;
const r = await getJson(url);
if (r.json) {
tl = findTaskList();
if (tl && tl.list.length) return tl;
}
}
return tl;
}
async function requestMarkData(taskId) {
const url = lastRequest ? lastRequest.url : '/api/task/get-mark-data';
const headers = authHeaders({ 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' });
const resp = await fetch(url, {
method: (lastRequest && lastRequest.method) || 'POST',
credentials: 'include',
headers: headers,
body: JSON.stringify(buildMarkBody(taskId))
});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const json = await resp.json();
if (!json || json.code !== 0 || !json.data) throw new Error('接口返回异常');
return json.data;
}
async function batchScanFrames() {
if (scanning) return false;
scanning = true;
if (!lastRequest) {
scanning = false;
updatePackageView('尚未捕获到 get-mark-data 请求,请先切换一帧');
return false;
}
const tl = await fetchFrameList();
if (!tl || !tl.list.length) {
scanning = false;
updatePackageView('自动统计:暂未拿到题包帧列表,稍后自动重试…');
return false;
}
const total = tl.list.length;
let ok = 0, fail = 0, done = 0;
// 并发请求,加快整个题包统计
let cursor = 0;
const worker = async function () {
while (true) {
const i = cursor++;
if (i >= total) return;
const t = tl.list[i];
try {
const data = await requestMarkData(t.id);
const md = data.markData || {};
recordFrame({ markData: md, mark_status: data.mark_status }, (t.order !== null ? t.order : (i + 1)), 'api', t.id);
ok++;
} catch (e) {
fail++;
}
done++;
updatePackageView('整个题包统计中 ' + done + ' / ' + total + (fail ? '(失败 ' + fail + ')' : ''));
if (done % 10 === 0) saveStats();
}
};
const ws = [];
for (let w = 0; w < Math.min(SCAN_CONCURRENCY, total); w++) ws.push(worker());
await Promise.all(ws);
saveStats();
scanning = false;
updatePackageView('整个题包统计完成:成功 ' + ok + ' 帧' + (fail ? ',失败 ' + fail + ' 帧' : '') + ',半身框共 ' + pkgHalfTotal() + ' 个');
return true;
}
// 共用一次扫描,避免重复请求
function runScanOnce() {
if (!scanPromise) {
scanPromise = Promise.resolve()
.then(function () { return batchScanFrames(); })
.finally(function () { scanPromise = null; });
}
return scanPromise;
}
function sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }
// 首次拿到标注数据后,自动统计整个题包(成功前会持续重试,最多若干次)
function maybeAutoBatch() {
if (autoBatchRunning || scanning) return;
const sig = getTaskSignature();
if (autoBatchRanSig === sig) return;
autoBatchRunning = true;
let attempts = 0;
const attempt = function () {
attempts++;
updatePackageView('正在自动统计整个题包…(第 ' + attempts + ' 次)');
Promise.resolve()
.then(function () { return runScanOnce(); })
.then(function (done) {
if (done) {
autoBatchRanSig = sig;
autoBatchRunning = false;
} else if (attempts < 5) {
setTimeout(attempt, 2500);
} else {
autoBatchRunning = false;
updatePackageView('自动统计暂未成功,可刷新题包页面后重试');
}
})
.catch(function () {
if (attempts < 5) setTimeout(attempt, 2500);
else autoBatchRunning = false;
});
};
setTimeout(attempt, 800);
}
// ============================================================
// 提交拦截:有分组/包含问题的帧存在时,不让整题通过
// ============================================================
function findSubmitButtons() {
const out = [];
const tryPush = function (el) {
if (el && (!panel || !panel.contains(el))) out.push(el);
};
tryPush(document.querySelector('.toolBarEditorRight #admin_pass_all'));
tryPush(document.getElementById('admin_pass_all'));
if (!out.length && document.body) {
const nodes = document.body.querySelectorAll('[id*="pass_all"], [id*="pass-all"]');
for (let i = 0; i < nodes.length; i++) if (panel && panel.contains(nodes[i])) continue; else out.push(nodes[i]);
}
// 确认弹窗里的最终按钮(点它才真正提交)
tryPush(document.getElementById('reject-sub-all'));
return out;
}
// “整题通过”的入口按钮 + 确认弹窗里的最终按钮(点它才真正提交)
function isSubmitTrigger(el) {
if (!el || !el.closest) return false;
if (el.closest('#admin_pass_all')) return true;
if (el.closest('#reject-sub-all')) return true;
if (el.closest('[id*="pass_all"]') || el.closest('[id*="pass-all"]')) return true;
return false;
}
function isSubmitUrl(url) {
const s = String(url || '');
for (let i = 0; i < SUBMIT_URL_PATTERNS.length; i++) {
if (SUBMIT_URL_PATTERNS[i].test(s)) return true;
}
return false;
}
// 捕获阶段拦截点击。入口按钮和确认按钮都拦,避免从确认弹窗绕过。
function onSubmitClickCapture(e) {
if (!SUBMIT_BLOCK) return;
const t = e.target;
if (!t || !t.closest) return;
if (panel && panel.contains(t)) return;
if (!isSubmitTrigger(t)) return;
if (allowPassThrough) { allowPassThrough = false; pendingPass = false; return; }
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
if (blocking) return;
pendingPass = true;
blocking = true;
// 记住被点的那个按钮:放行时重新点它,避免靠 id 再查一次查不到而吞掉点击
pendingBtn = t.closest('#admin_pass_all') || t.closest('#reject-sub-all')
|| t.closest('[id*="pass_all"]') || t.closest('[id*="pass-all"]') || t;
updatePackageView('正在校验整个题包…');
Promise.resolve()
.then(function () { return runScanOnce(); })
.then(function (scanOk) {
blocking = false;
// 只要还有任何问题证据就阻止提交(即使本次扫描不完整)
const bad = pkgBlockingEntries();
const overLimit = halfLimitBlocking();
if (bad.length || overLimit) {
if (scanOk === false) {
renderSubmitBlock(bad, overLimit, '(注意:本次题包校验未完整完成,以下问题来自已获取的数据)');
} else {
renderSubmitBlock(bad, overLimit, '');
}
// 直接跳转到第一处有问题的帧,方便马上修正
try { awaitJumpToBadFrame(); } catch (e) {}
return;
}
// 没有任何问题证据:扫描不完整时放行并提示,否则正常放行
if (scanOk === false) {
updatePackageView('未能完整校验题包(帧列表或接口异常),未发现问题,已放行提交');
}
pendingPass = false;
passThrough();
})
.catch(function () {
blocking = false;
pendingPass = false;
updatePackageView('提交前校验出错,已放行');
passThrough();
});
}
// 底层拦截:即使绕过点击,也不让“整题通过”的请求发出去
function guardSubmitRequest(url) {
if (!SUBMIT_BLOCK || !BLOCK_NET_SUBMIT) return false;
if (!isSubmitUrl(url)) return false;
if (!hasBlockingProblems()) return false;
const bad = pkgBlockingEntries();
renderSubmitBlock(bad, halfLimitBlocking(), '(已从网络请求层拦截,请先修正问题帧)');
try { awaitJumpToBadFrame(); } catch (e) {}
return true;
}
// 放行:重新触发一次“整题通过”
function passThrough() {
let btn = (pendingBtn && document.body && document.body.contains(pendingBtn)) ? pendingBtn : null;
if (!btn) btn = document.querySelector('.toolBarEditorRight #admin_pass_all') || document.getElementById('admin_pass_all');
if (!btn) btn = pendingBtn;
pendingBtn = null;
if (!btn) return;
allowPassThrough = true;
try { btn.classList.remove('sz-blocked'); } catch (e) {}
btn.click();
}
// 触发跳转(不阻塞提交拦截流程,也不抛出)
function awaitJumpToBadFrame() {
Promise.resolve()
.then(function () { return jumpToBadFrame(); })
.then(function (ok) {
if (ok) setScanStatus('⛔ 提交被阻止:已跳转到第一处有问题的帧');
})
.catch(function () {});
}
// 提交被拦时的提示
function renderSubmitBlock(bad, overLimit, note) {
const nums = bad.map(function (e) { return e.no === null || e.no === undefined ? '?' : e.no; });
const shown = nums.slice(0, 20).join('、');
const more = nums.length > 20 ? (' 等 ' + nums.length + ' 帧') : '';
const limitText = overLimit ? (',半身框总数 ' + pkgHalfTotal() + ' 超过上限 ' + toNum(settings.halfLimit)) : '';
// 面板内提示
const alerts = document.getElementById('sz-pkg-alerts');
if (alerts) {
alerts.innerHTML = '⛔ 题包仍存在问题,已阻止整题通过:'
+ (nums.length ? (nums.length + ' 帧(' + shown + more + ')') : '')
+ limitText + '
' + alerts.innerHTML;
}
setScanStatus('⛔ 提交被阻止:' + (blockingSummary() || '题包存在问题'));
// 弹窗提示
showSubmitBlockDialog(bad, overLimit, note);
if (panel && panelReady) {
panel.classList.remove('sz-shake'); void panel.offsetWidth; panel.classList.add('sz-shake');
}
// 按钮标红
findSubmitButtons().forEach(function (b) { try { b.classList.add('sz-blocked'); } catch (e) {} });
}
function setScanStatus(t) {
const sEl = document.getElementById('sz-scan-status');
if (sEl) sEl.textContent = t;
}
function escHtml(s) {
return String(s === null || s === undefined ? '' : s).replace(/[&<>"]/g, function (c) {
return ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c];
});
}
// 问题帧列表 HTML(面板与弹窗共用):每一项都可点击跳转
function probListHtml(entries, limit) {
if (!entries || !entries.length) return '暂无问题帧
';
const max = limit || 150;
let html = '';
for (let i = 0; i < entries.length && i < max; i++) {
const e = entries[i];
const no = (e.no === null || e.no === undefined) ? '?' : e.no;
const msgs = (e.probs && e.probs.length) ? e.probs.slice() : (e.badMsg ? [e.badMsg] : ['存在问题']);
const tip = msgs.join('\n');
const cls = e.block ? 'sz-prob-item sz-prob-block' : 'sz-prob-item';
html += ''
+ '第 ' + escHtml(no) + ' 帧'
+ '' + (e.block ? '⛔ ' : '⚠ ') + escHtml(msgs[0]).slice(0, 70)
+ (msgs.length > 1 ? (' …共 ' + msgs.length + ' 条') : '') + ''
+ '
';
}
if (entries.length > max) html += '…还有 ' + (entries.length - max) + ' 帧
';
return html;
}
// 提交被拦时的弹窗
function showSubmitBlockDialog(entries, overLimit, note) {
if (!panel) return;
let mask = document.getElementById('sz-block-mask');
if (!mask || !panel.contains(mask)) {
if (mask && mask.parentNode) mask.parentNode.removeChild(mask);
mask = document.createElement('div');
mask.id = 'sz-block-mask';
mask.innerHTML = `
`;
panel.appendChild(mask);
document.getElementById('sz-block-close').addEventListener('click', function () {
mask.classList.remove('open');
});
document.getElementById('sz-block-jump').addEventListener('click', function () {
mask.classList.remove('open');
jumpToBadFrame();
});
const dl = document.getElementById('sz-block-detail');
if (dl) dl.addEventListener('click', function (e) {
const it = (e.target && e.target.closest) ? e.target.closest('[data-no]') : null;
if (!it) return;
mask.classList.remove('open');
jumpByAttrs(it);
});
mask.addEventListener('click', function (e) {
if (e.target === mask) mask.classList.remove('open');
});
}
const sum = document.getElementById('sz-block-sum');
if (sum) {
const parts = [];
if (entries && entries.length) parts.push('问题帧共 ' + entries.length + ' 帧(点击可跳转)');
if (overLimit) parts.push('半身框总数 ' + pkgHalfTotal() + ' 超过上限 ' + toNum(settings.halfLimit) + '');
sum.innerHTML = parts.join(' | ') || '题包存在问题';
}
const d = document.getElementById('sz-block-detail');
if (d) {
d.innerHTML = probListHtml(entries || [], 40);
d.style.maxHeight = '210px';
d.style.overflow = 'auto';
}
const nEl = document.getElementById('sz-block-note');
if (nEl) nEl.textContent = note || '';
mask.classList.add('open');
}
// 定位并跳转到指定 task_id 的帧
async function jumpToTaskId(taskId) {
if (taskId === null || taskId === undefined || taskId === '') return false;
// 只从帧导航条里找(排除面板),不要误匹配到自定义属性
let nodes = [];
if (document.body) {
nodes = Array.prototype.slice.call(document.body.querySelectorAll('li[data-task]'));
}
const node = nodes.filter(function (el) {
return (!panel || !panel.contains(el)) && String(el.getAttribute('data-task')) === String(taskId);
})[0];
if (node) {
node.click();
return true;
}
// 节点没渲染时,用平台自身的切帧方法(用完整帧列表,最可靠)
try {
const cc = window.InitCanvasInfo && (window.InitCanvasInfo.canvasCommon || window.InitCanvasInfo.canvasInstance);
if (cc && cc.Header && cc.Header.nextFrame && cc.params && cc.params.frame) {
cc.Header.nextFrame({ task_id: taskId }, { is_point: true, object: cc });
return true;
}
} catch (e) {}
return false;
}
// 跳到指定问题帧(优先用记录下来的 task_id,其次按帧号在帧列表里定位)
async function jumpToEntry(entry) {
if (!entry) return false;
if (entry.tid && await jumpToTaskId(entry.tid)) return true;
const no = entry.no;
let list = null;
const domList = readFrameListFromDom();
if (domList && domList.list.length) list = domList.list;
else {
const tl = await fetchFrameList();
if (tl && tl.list.length) list = tl.list;
}
if (!list) return false;
let hit = null;
for (let i = 0; i < list.length; i++) {
if (list[i].order === no - 1 || list[i].order === no) { hit = list[i]; break; }
}
if (!hit && no >= 1 && no <= list.length) hit = list[no - 1];
if (!hit) return false;
return await jumpToTaskId(hit.id);
}
// 面板/弹窗里点击问题帧条目时跳转
function jumpByAttrs(el) {
if (!el) return;
jumpToEntry({
tid: el.getAttribute('data-tid') || null,
no: toNum(el.getAttribute('data-no')),
key: el.getAttribute('data-key') || null
});
}
// 跳到第一处有问题的帧(优先跳不是当前帧的那个)
async function jumpToBadFrame() {
const blockBad = pkgBlockingEntries();
const list = blockBad.length ? blockBad : pkgProblemEntries();
if (!list.length) return false;
const cur = cachedFrameNo;
let target = list[0];
for (let i = 0; i < list.length; i++) {
if (cur === null || cur === undefined || list[i].no !== cur) { target = list[i]; break; }
}
return await jumpToEntry(target);
}
function pkgEntries() {
const out = [];
const f = pkgStats.frames;
for (const k in f) {
if (!Object.prototype.hasOwnProperty.call(f, k)) continue;
const v = f[k];
const rec = (typeof v === 'number') ? { half: v, marks: null, status: null, no: null } : v;
let no = (rec.no === undefined) ? null : rec.no;
if (no === null || no === undefined) no = frameNoFromKey(k);
out.push({
key: k, half: rec.half || 0, marks: rec.marks, status: rec.status, no: no,
bad: rec.bad || 0, block: rec.block || 0,
probs: Array.isArray(rec.probs) ? rec.probs : [],
badMsg: rec.badMsg || '', tid: rec.tid || null
});
}
out.sort(function (a, b) {
const na = a.no === null ? 1e9 : a.no, nb = b.no === null ? 1e9 : b.no;
return na - nb;
});
return out;
}
// 从帧唯一键里尽力还原帧号:'n:12' 或纯数字键,以及图片路径里的数字
function frameNoFromKey(k) {
if (k === null || k === undefined) return null;
const s = String(k);
let m = s.match(/^n:(\d+)$/);
if (m) return parseInt(m[1], 10);
m = s.match(/^(\d+)$/);
if (m) return parseInt(m[1], 10);
return frameNoFromPath(s);
}
function pkgHalfTotal() {
return pkgEntries().reduce(function (s, e) { return s + e.half; }, 0);
}
// 半身框总数是否超限(上限可配置,0 表示不限制)
function isHalfOverLimit(total) {
const limit = toNum(settings.halfLimit);
return (limit !== null && limit > 0 && total > limit);
}
function pkgFrameCount() { return Object.keys(pkgStats.frames).length; }
function frameNoLabel(e) { return (e.no === null || e.no === undefined) ? '?' : e.no; }
function saveStats() { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(pkgStats)); } catch (e) {} }
function loadStats() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return;
const obj = JSON.parse(raw);
if (obj && obj.frames && obj.taskSig === getTaskSignature()) pkgStats = obj;
} catch (e) {}
}
// ============================================================
// 校验逻辑
// ============================================================
function validateMarks(respData, imgOverride) {
const results = [];
const markStatus = respData.mark_status;
const md = respData.markData || {};
const marks = (md.marks) || [];
const hasMarks = marks.length > 0;
const img = imgOverride || { w: md.width || currentImage.w, h: md.height || currentImage.h };
if (markStatus === 1 && !hasMarks) { results.push({ type: 'info', msg: '无需标注,跳过检查' }); return results; }
if (markStatus === 1 && hasMarks) {
results.push({ type: 'warning', block: true, msg: `[状态异常] 本帧点了"无需标注",但里面标注了 ${marks.length} 个框,可能误操作` });
}
if (markStatus === 0 && !hasMarks) {
results.push({ type: 'warning', block: true, msg: '[可能漏标] 标注状态为正常标注,但没有任何标注数据' });
return results;
}
if (!hasMarks) { results.push({ type: 'info', msg: '当前帧无标注数据' }); return results; }
checkGroups(marks, results);
checkBoxSizes(marks, img, results);
return results;
}
const KIND_NAME = { full: '行人框', half: '半身框', hand: '手势框' };
const KIND_KEY = { full: 'fulls', half: 'halves', hand: 'hands' };
const KIND_ORDER = ['full', 'half', 'hand'];
// 把一帧里的框按类别收集,并带上归组信息
function collectMarkItems(marks) {
const out = { fulls: [], halves: [], hands: [] };
for (let i = 0; i < marks.length; i++) {
const m = marks[i];
const kind = classifyMark(m);
if (!kind) continue;
out[KIND_KEY[kind]].push({
kind: kind,
id: m.markId || m.boxId || ('index_' + i),
label: (m.pselect || (m.class && m.class.pname) || ''),
box: extractBox(m),
group: getGroupId(m)
});
}
return out;
}
function markTag(it) { return KIND_NAME[it.kind] + '(' + it.id + ')'; }
function markList(arr) { return arr.map(markTag).join('、'); }
// 归组规则:每个组必须是「1 行人框 + 1 半身框(+ 可选 1 手势框)」。
// 同一对象的多余框、缺框、以及没有归组(groupId 为空/0)都算问题。
function checkGroups(marks, results) {
const c = collectMarkItems(marks);
// 1) 未归组的框
KIND_ORDER.forEach(function (k) {
const arr = c[KIND_KEY[k]].filter(function (it) { return it.group === null; });
if (!arr.length) return;
results.push({
type: 'warning', block: true,
msg: '[未归组] ' + arr.length + ' 个' + KIND_NAME[k] + '没有归组:' + markList(arr)
+ ';同一对象应归为「' + GROUP_RULE_TEXT + '」'
});
});
// 2) 按 groupId 分组校验组成
const groups = {};
const gids = [];
KIND_ORDER.forEach(function (k) {
c[KIND_KEY[k]].forEach(function (it) {
if (it.group === null) return;
if (!groups[it.group]) { groups[it.group] = { full: [], half: [], hand: [] }; gids.push(it.group); }
groups[it.group][k].push(it);
});
});
gids.forEach(function (gid) {
const g = groups[gid];
if (g.full.length === 1 && g.half.length === 1 && g.hand.length <= 1) return;
const detail = [];
if (g.full.length) detail.push('行人框 ' + g.full.length + ' 个(' + markList(g.full) + ')');
if (g.half.length) detail.push('半身框 ' + g.half.length + ' 个(' + markList(g.half) + ')');
if (g.hand.length) detail.push('手势框 ' + g.hand.length + ' 个(' + markList(g.hand) + ')');
results.push({
type: 'warning', block: true,
msg: '[归组错误] 组 ' + gid + ' 应为「' + GROUP_RULE_TEXT + '」,实际:' + (detail.join('、') || '无')
});
});
}
// 框尺寸:归一化坐标按原图尺寸换算成像素
function boxPixelSize(box, img) {
if (!box) return null;
const w = Math.abs(box.x2 - box.x1);
const h = Math.abs(box.y2 - box.y1);
if (!isNormalized(box)) return { w: w, h: h };
const iw = (img && img.w) || currentImage.w || 0;
const ih = (img && img.h) || currentImage.h || 0;
if (!iw || !ih) return null;
return { w: w * iw, h: h * ih };
}
// 框尺寸校验:小于 20×40px 报错
function checkBoxSizes(marks, img, results) {
const c = collectMarkItems(marks);
const all = c.fulls.concat(c.halves, c.hands);
for (let i = 0; i < all.length; i++) {
const it = all[i];
const tag = markTag(it);
if (!it.box) {
results.push({ type: 'warning', block: true, msg: '[无法校验] ' + tag + ' 没有取到坐标' });
continue;
}
const s = boxPixelSize(it.box, img);
if (!s) {
results.push({ type: 'warning', block: false, msg: '[无法校验尺寸] ' + tag + ' 坐标为归一化值且拿不到原图尺寸' });
continue;
}
if (s.w < MIN_BOX_W || s.h < MIN_BOX_H) {
results.push({
type: 'warning', block: true,
msg: '[框过小] ' + tag + ' 尺寸 ' + s.w.toFixed(1) + '×' + s.h.toFixed(1)
+ 'px,小于 ' + MIN_BOX_W + '×' + MIN_BOX_H + 'px'
});
}
}
}
function autoRunCheck() {
if (!cachedData) return;
const results = validateMarks(cachedData);
renderFrameView(results, cachedData);
updatePackageView();
}
// ============================================================
// 全题包问题帧(面板展示 / 点击跳转 / 阻止整题提交)
// ============================================================
// 一帧里的全部校验问题(warning 级),每项带 block 标记(是否阻塞提交)
function frameProblems(markData, markStatus, img) {
const results = validateMarks({ mark_status: markStatus, markData: markData || {} }, img);
return results.filter(function (r) { return r.type === 'warning'; });
}
// 整个题包里有问题的帧(bad>0 即至少有一条 warning)
function pkgProblemEntries() {
return pkgEntries().filter(function (e) { return (e.bad || 0) > 0; });
}
// 阻塞提交的帧:存在 block 级问题
function pkgBlockingEntries() {
return pkgEntries().filter(function (e) { return (e.block || 0) > 0; });
}
// 半身框总数超限也算阻塞问题
function halfLimitBlocking() {
return BLOCK_ON_HALF_LIMIT && isHalfOverLimit(pkgHalfTotal());
}
// 当前题包是否还存在阻塞级问题(只要还有问题,就不允许提交)
function hasBlockingProblems() {
return pkgBlockingEntries().length > 0 || halfLimitBlocking();
}
// 阻塞原因简述(用于弹窗/面板)
function blockingSummary() {
const frames = pkgBlockingEntries();
const parts = [];
if (frames.length) parts.push(frames.length + ' 帧存在问题');
if (halfLimitBlocking()) parts.push('半身框总数 ' + pkgHalfTotal() + ' 超过上限 ' + toNum(settings.halfLimit));
return parts.join(',');
}
function manualCheck() {
if (!cachedData) {
const slot = document.getElementById('sz-frame-slot');
if (slot) slot.innerHTML = '暂无标注数据,请先切换到一帧
';
return;
}
const btn = document.getElementById('sz-btn-check');
if (btn) { btn.textContent = '校验中...'; btn.disabled = true; }
setTimeout(function () {
const results = validateMarks(cachedData);
renderFrameView(results, cachedData);
if (btn) { btn.textContent = '校验当前帧'; btn.disabled = false; }
}, 80);
}
// ============================================================
// 样式
// ============================================================
function injectStyles() {
if (document.getElementById('sz-check-styles')) return;
const style = document.createElement('style');
style.id = 'sz-check-styles';
style.textContent = `
#sz-check-panel { position: fixed; top: 80px; left: 320px; width: 372px; max-height: 78vh;
background: linear-gradient(145deg, #1a1a2e 0%, #16213e 100%); color: #e0e0e0;
border: 1px solid rgba(233,69,96,0.3); border-radius: 12px; z-index: 999999;
font-family: "Microsoft YaHei", "PingFang SC", sans-serif; font-size: 13px;
box-shadow: 0 8px 32px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.05); overflow: hidden; }
#sz-check-header { background: linear-gradient(135deg, #0f3460 0%, #1a1a40 100%);
padding: 10px 14px; display: flex; justify-content: space-between; align-items: center;
cursor: move; user-select: none; border-bottom: 1px solid rgba(233,69,96,0.2); }
#sz-check-header:active { cursor: grabbing; }
.sz-title { font-weight: bold; font-size: 14px; color: #00d2ff; display: flex; align-items: center; gap: 6px; }
.sz-title-dot { width: 8px; height: 8px; background: #00d2ff; border-radius: 50%; box-shadow: 0 0 8px #00d2ff; animation: sz-pulse 2s ease-in-out infinite; }
@keyframes sz-pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
.sz-btn-check { background: linear-gradient(135deg, #00d2ff 0%, #0288d1 100%); color: #fff; border: none;
padding: 5px 12px; border-radius: 6px; cursor: pointer; font-size: 12px; font-weight: bold; }
.sz-btn-check:hover { transform: translateY(-1px); }
.sz-btn-check:disabled { opacity: .6; cursor: not-allowed; }
.sz-btn-toggle { background: rgba(255,255,255,0.08); color: #aaa; border: 1px solid rgba(255,255,255,0.1);
width: 26px; height: 26px; border-radius: 6px; cursor: pointer; font-size: 14px;
display: flex; align-items: center; justify-content: center; margin-left: 6px; }
.sz-btn-toggle:hover { background: rgba(255,255,255,0.15); color: #fff; }
#sz-check-content { padding: 12px 14px; max-height: calc(78vh - 50px); overflow-y: auto; }
#sz-check-content::-webkit-scrollbar { width: 6px; }
#sz-check-content::-webkit-scrollbar-thumb { background: rgba(233,69,96,0.3); border-radius: 3px; }
.sz-pkg { margin-bottom: 10px; padding: 10px 12px; background: linear-gradient(135deg, rgba(0,210,255,.10), rgba(15,52,96,.5)); border: 1px solid rgba(0,210,255,.35); border-radius: 8px; }
.sz-pkg-title { color: #00d2ff; font-weight: bold; font-size: 12px; letter-spacing: 1px; margin-bottom: 6px; display: flex; justify-content: space-between; align-items: center; }
.sz-pkg-main { display: flex; align-items: baseline; gap: 8px; }
.sz-pkg-num { color: #00d2ff; font-size: 30px; font-weight: bold; line-height: 1.1; text-shadow: 0 0 12px rgba(0,210,255,.5); }
.sz-pkg-unit { color: #8892b0; font-size: 12px; }
.sz-pkg-sub { color: #8892b0; font-size: 11px; margin-top: 4px; line-height: 1.6; }
.sz-scan-status { color: #7fddc4; font-size: 11px; margin-top: 6px; word-break: break-all; line-height: 1.5; }
.sz-alert { margin-top: 7px; padding: 6px 8px; border-radius: 5px; font-size: 11px; line-height: 1.5; }
.sz-alert-err { background: rgba(233,69,96,.12); border: 1px solid rgba(233,69,96,.35); color: #ff9db0; }
.sz-alert-warn { background: rgba(255,165,0,.10); border: 1px solid rgba(255,165,0,.30); color: #ffc76b; }
.sz-alert-limit { background: rgba(233,69,96,.22); border: 1px solid rgba(233,69,96,.9); color: #ffd0da; font-weight: bold; font-size: 12px; }
.sz-stats { margin-bottom: 10px; padding: 10px 12px; background: rgba(15,52,96,.4); border-radius: 8px; border: 1px solid rgba(255,255,255,.05); }
.sz-stats-title { color: #e94560; font-weight: bold; margin-bottom: 6px; font-size: 12px; letter-spacing: 1px; display: flex; justify-content: space-between; align-items: center; }
.sz-stats-body { color: #8892b0; font-size: 12px; line-height: 1.9; }
.sz-halfline { margin-top: 8px; padding: 7px 10px; background: rgba(0,210,255,.08); border: 1px solid rgba(0,210,255,.25); border-radius: 6px; display: flex; align-items: baseline; gap: 8px; }
.sz-halfline .lbl { color: #8892b0; font-size: 12px; }
.sz-halfline .num { color: #00d2ff; font-size: 20px; font-weight: bold; line-height: 1; }
.sz-badge { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 11px; font-weight: bold; }
.sz-badge-ok { background: rgba(78,204,163,.15); color: #4ecca3; border: 1px solid rgba(78,204,163,.3); }
.sz-badge-warn { background: rgba(255,165,0,.15); color: #ffa500; border: 1px solid rgba(255,165,0,.3); }
.sz-badge-err { background: rgba(233,69,96,.15); color: #e94560; border: 1px solid rgba(233,69,96,.3); }
.sz-pass { color: #4ecca3; text-align: center; padding: 20px 0; font-size: 15px; font-weight: bold; }
.sz-warn-header { color: #e94560; font-weight: bold; margin: 10px 0 6px; font-size: 13px; }
.sz-warn-item { background: rgba(233,69,96,.08); border-left: 3px solid #e94560; padding: 8px 10px; margin-bottom: 5px; border-radius: 0 6px 6px 0; font-size: 13px; line-height: 1.6; color: #ccc; }
.sz-warn-item:hover { background: rgba(233,69,96,.15); }
.sz-info { color: #8892b0; padding: 8px 0; text-align: center; }
.sz-loading { color: #8892b0; text-align: center; padding: 24px 0; }
@keyframes sz-shake { 0%,100%{transform:translateX(0)} 10%,30%,50%,70%,90%{transform:translateX(-5px)} 20%,40%,60%,80%{transform:translateX(5px)} }
.sz-shake { animation: sz-shake .5s ease-in-out; }
#sz-block-mask { position: fixed; inset: 0; background: rgba(8,12,24,.72); z-index: 1000000; display: none; align-items: center; justify-content: center; }
#sz-block-mask.open { display: flex; }
#sz-block-box { width: 460px; max-width: 92vw; max-height: 76vh; display: flex; flex-direction: column;
background: linear-gradient(145deg, #241a1e 0%, #16213e 100%); color: #e0e0e0;
border: 1px solid rgba(233,69,96,.75); border-radius: 12px; box-shadow: 0 12px 48px rgba(0,0,0,.7);
font-family: "Microsoft YaHei","PingFang SC",sans-serif; overflow: hidden; }
#sz-block-head { padding: 12px 16px; background: rgba(233,69,96,.16); border-bottom: 1px solid rgba(233,69,96,.4);
color: #ff8fa3; font-weight: bold; font-size: 15px; }
#sz-block-body { padding: 14px 16px; font-size: 13px; line-height: 1.7; color: #cfd8e8; overflow-y: auto; }
#sz-block-body b { color: #ff9db0; }
#sz-block-detail { margin-top: 10px; padding: 8px 10px; background: #0b1020; border: 1px solid rgba(255,255,255,.1);
border-radius: 5px; font-family: Consolas,monospace; font-size: 11px; color: #9db8d6; white-space: pre-wrap; max-height: 200px; overflow: auto; }
#sz-block-foot { padding: 10px 16px; display: flex; justify-content: flex-end; gap: 8px; border-top: 1px solid rgba(255,255,255,.08); }
#sz-block-foot button { border: none; border-radius: 6px; padding: 7px 16px; font-size: 13px; font-weight: bold; cursor: pointer; }
#sz-block-close { background: rgba(255,255,255,.1); color: #ccc; border: 1px solid rgba(255,255,255,.18); }
#sz-block-jump { background: linear-gradient(135deg,#00d2ff,#0288d1); color: #fff; }
.sz-blocked { outline: 2px solid #e94560 !important; outline-offset: 1px; box-shadow: 0 0 10px rgba(233,69,96,.8) !important; }
.sz-prob-block { margin-bottom: 10px; padding: 10px 12px; background: rgba(15,52,96,.4); border: 1px solid rgba(255,255,255,.08); border-radius: 8px; }
.sz-prob-head { display: flex; align-items: center; gap: 6px; margin-bottom: 7px; }
.sz-prob-title { color: #e94560; font-weight: bold; font-size: 12px; letter-spacing: 1px; flex: 1; }
.sz-btn-mini { background: rgba(255,255,255,.08); color: #9db8d6; border: 1px solid rgba(255,255,255,.15); border-radius: 5px; padding: 2px 8px; font-size: 11px; cursor: pointer; font-family: inherit; }
.sz-btn-mini:hover { background: rgba(0,210,255,.18); color: #fff; }
.sz-prob-list { max-height: 190px; overflow-y: auto; }
.sz-prob-list::-webkit-scrollbar { width: 6px; }
.sz-prob-list::-webkit-scrollbar-thumb { background: rgba(233,69,96,.35); border-radius: 3px; }
.sz-prob-item { position: relative; z-index: 2; pointer-events: auto; display: flex; gap: 6px; align-items: baseline; padding: 5px 7px; margin-bottom: 4px; border-radius: 5px; background: rgba(255,165,0,.08); border-left: 3px solid #ffa500; cursor: pointer; font-size: 11px; line-height: 1.45; }
.sz-prob-item:hover { background: rgba(255,165,0,.2); }
.sz-prob-item.sz-prob-block { background: rgba(233,69,96,.12); border-left-color: #e94560; }
.sz-prob-item.sz-prob-block:hover { background: rgba(233,69,96,.26); }
.sz-prob-no { color: #ffd0da; font-weight: bold; white-space: nowrap; }
.sz-prob-msg { color: #b9c6da; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sz-prob-empty { color: #6b7899; font-size: 11px; padding: 4px 0; }
.sz-prob-tip { color: #556080; font-size: 10px; margin-top: 5px; }
.sz-btn-toggle.sz-locked { opacity: .6; cursor: not-allowed; color: #ff8fa3; border-color: rgba(233,69,96,.6); }
#sz-check-panel.sz-panel-locked #sz-check-header { box-shadow: inset 0 0 0 1px rgba(233,69,96,.5); }
`;
document.head.appendChild(style);
}
// ============================================================
// 面板
// ============================================================
function createPanel() {
if (panel && document.body.contains(panel)) return panel;
injectStyles();
panel = document.createElement('div');
panel.id = 'sz-check-panel';
panel.innerHTML = `
`;
document.body.appendChild(panel);
document.getElementById('sz-btn-check').addEventListener('click', manualCheck);
document.getElementById('sz-btn-toggle').addEventListener('click', togglePanel);
makeDraggable();
panelReady = true;
renderPackageBlock();
if (cachedData) autoRunCheck();
return panel;
}
function makeDraggable() {
const header = document.getElementById('sz-check-header');
let dragging = false, sx = 0, sy = 0, sl = 0, st = 0;
header.addEventListener('mousedown', function (e) {
if (e.target.tagName === 'BUTTON') return;
dragging = true; sx = e.clientX; sy = e.clientY;
const r = panel.getBoundingClientRect(); sl = r.left; st = r.top;
e.preventDefault();
});
document.addEventListener('mousemove', function (e) {
if (!dragging) return;
let nl = sl + (e.clientX - sx), nt = st + (e.clientY - sy);
nl = Math.max(-260, Math.min(nl, window.innerWidth - 80));
nt = Math.max(0, Math.min(nt, window.innerHeight - 50));
panel.style.left = nl + 'px'; panel.style.top = nt + 'px'; panel.style.right = 'auto';
});
document.addEventListener('mouseup', function () { dragging = false; });
}
let lockedRef = false;
// 题包仍有问题时:锁住面板,禁止收起/关闭
function applyPanelLock() {
if (!LOCK_PANEL || !panel || !panelReady) return;
const locked = hasBlockingProblems();
lockedRef = locked;
const btn = document.getElementById('sz-btn-toggle');
if (btn) {
btn.disabled = false;
if (locked) btn.classList.add('sz-locked'); else btn.classList.remove('sz-locked');
btn.title = locked ? '题包仍有问题,解决前无法关闭校验面板' : '';
if (locked) {
const content = document.getElementById('sz-check-content');
if (content) content.style.display = '';
btn.innerHTML = '🔒';
} else if (btn.innerHTML === '🔒') {
btn.innerHTML = '−';
}
}
if (locked) panel.classList.add('sz-panel-locked');
else panel.classList.remove('sz-panel-locked');
}
function flashLockedTip() {
setScanStatus('🔒 当前题包仍有问题(' + (blockingSummary() || '') + '),解决前无法关闭校验面板');
if (panel && panelReady) {
panel.classList.remove('sz-shake'); void panel.offsetWidth; panel.classList.add('sz-shake');
}
}
function togglePanel() {
if (LOCK_PANEL && hasBlockingProblems()) { flashLockedTip(); return; }
const content = document.getElementById('sz-check-content');
const btn = document.getElementById('sz-btn-toggle');
if (content.style.display === 'none') { content.style.display = ''; btn.innerHTML = '−'; }
else { content.style.display = 'none'; btn.innerHTML = '+'; }
}
// 看门狗:面板被移除/隐藏/样式被删时自动恢复(尽量做到关不掉)
function ensurePanelAlive() {
if (!LOCK_PANEL) return;
if (!document.body) return;
if (!panel || !document.body.contains(panel)) {
panel = null; panelReady = false;
createPanel();
}
if (panel) {
if (panel.style.display === 'none') panel.style.display = '';
if (panel.style.visibility === 'hidden') panel.style.visibility = '';
if (panel.style.opacity === '0') panel.style.opacity = '';
if (hasBlockingProblems()) {
const content = document.getElementById('sz-check-content');
if (content && content.style.display === 'none') content.style.display = '';
}
}
if (!document.getElementById('sz-check-styles')) injectStyles();
}
function watchPanel() {
if (!document.body || typeof MutationObserver === 'undefined') return;
const mo = new MutationObserver(function () {
if (!LOCK_PANEL) return;
if (!document.getElementById('sz-check-styles')) { try { injectStyles(); } catch (e) {} }
if (panel && !document.body.contains(panel)) ensurePanelAlive();
});
try { mo.observe(document.body, { childList: true, subtree: false, attributes: true, attributeFilter: ['style', 'class'] }); } catch (e) {}
}
function renderPackageBlock() {
const slot = document.getElementById('sz-pkg-slot');
if (!slot) return;
if (document.getElementById('sz-pkg-num')) { updatePackageView(); return; }
slot.innerHTML = `
全题包问题帧
0
点击问题帧可跳转到该帧(实时更新)
`;
const listEl = document.getElementById('sz-prob-list');
if (listEl) {
listEl.addEventListener('click', function (e) {
const it = (e.target && e.target.closest) ? e.target.closest('[data-no]') : null;
if (it) jumpByAttrs(it);
});
}
const rsBtn = document.getElementById('sz-btn-rescan');
if (rsBtn) rsBtn.addEventListener('click', function () { manualRescan(); });
updatePackageView();
}
// 手动重新扫描整个题包
function manualRescan() {
if (scanning) { setScanStatus('⏳ 正在扫描中,请稍候…'); return; }
setScanStatus('⏳ 正在重新扫描全题包…');
Promise.resolve().then(function () { return runScanOnce(); }).catch(function () {});
}
function updatePackageView(statusText) {
const numEl = document.getElementById('sz-pkg-num');
if (!numEl) return;
const knownTotal = pkgStats.totalFrames || getTotalFrames();
const total = pkgHalfTotal();
const limit = toNum(settings.halfLimit);
const overLimit = isHalfOverLimit(total);
numEl.textContent = String(total);
numEl.style.color = overLimit ? '#ff5c7a' : '#00d2ff';
numEl.style.textShadow = overLimit ? '0 0 12px rgba(233,69,96,.7)' : '0 0 12px rgba(0,210,255,.5)';
const boxEl = document.getElementById('sz-pkg');
if (boxEl) boxEl.style.borderColor = overLimit ? 'rgba(233,69,96,.8)' : 'rgba(0,210,255,.35)';
const fEl = document.getElementById('sz-pkg-frames');
if (fEl) fEl.textContent = knownTotal ? (knownTotal + ' 帧') : '帧数未知';
const pEl = document.getElementById('sz-pkg-progress');
if (pEl) {
pEl.textContent = knownTotal
? ('已统计 ' + pkgFrameCount() + ' / ' + knownTotal + ' 帧')
: ('已统计 ' + pkgFrameCount() + ' 帧');
}
if (statusText !== undefined) {
const sEl = document.getElementById('sz-scan-status');
if (sEl) sEl.textContent = statusText;
}
const alerts = document.getElementById('sz-pkg-alerts');
const probs = pkgProblemEntries();
const blockN = pkgBlockingEntries().length;
if (alerts) {
let html = '';
if (blockN || overLimit) {
html += '⛔ 当前题包仍有问题:' + (blockingSummary() || '')
+ ';已阻止整题通过,面板在问题解决前不可关闭
';
}
alerts.innerHTML = html;
}
// 全题包问题帧列表(实时更新,点击可跳转)
const cntEl = document.getElementById('sz-prob-count');
if (cntEl) {
cntEl.textContent = String(probs.length);
cntEl.className = 'sz-badge ' + (blockN ? 'sz-badge-err' : (probs.length ? 'sz-badge-warn' : 'sz-badge-ok'));
}
const listEl = document.getElementById('sz-prob-list');
if (listEl) {
const html2 = probListHtml(probs, 200);
if (listEl._szHtml !== html2) { listEl.innerHTML = html2; listEl._szHtml = html2; }
}
// 还有阻塞问题时锁定面板
applyPanelLock();
// 超限时面板震动提醒
if (overLimit && panel && panelReady) {
panel.classList.remove('sz-shake'); void panel.offsetWidth; panel.classList.add('sz-shake');
}
}
function renderFrameView(results, respData) {
const slot = document.getElementById('sz-frame-slot');
if (!slot) return;
const markStatus = respData.mark_status;
const marks = (respData.markData && respData.markData.marks) || [];
const ind = readFrameIndicator();
const warnings = results.filter(function (r) { return r.type === 'warning'; });
const infos = results.filter(function (r) { return r.type === 'info'; });
const hasProblem = warnings.length > 0;
let statusText, statusClass, border, titleColor;
if (markStatus === 1) { statusText = '无需标注'; statusClass = 'sz-badge-warn'; border = 'rgba(255,165,0,.3)'; titleColor = '#ffa500'; }
else if (hasProblem) { statusText = '正常标注'; statusClass = 'sz-badge-err'; border = 'rgba(233,69,96,.3)'; titleColor = '#e94560'; }
else { statusText = '正常标注'; statusClass = 'sz-badge-ok'; border = 'rgba(78,204,163,.3)'; titleColor = '#4ecca3'; }
const fullCount = countClass(marks, 'full');
const halfCount = countClass(marks, 'half');
const handCount = countClass(marks, 'hand');
const unknown = marks.filter(function (m) { return !classifyMark(m); });
let html = `
当前帧统计
${ind ? '第 ' + ind.cur + ' / ' + ind.tot + ' 帧' : '帧号未知'}
状态: ${statusText}
标注总数: ${marks.length} | 行人框: ${fullCount} | 手势框: ${handCount}
本帧半身框${halfCount}个
`;
if (unknown.length) {
const names = unknown.map(function (m) { return (m.pselect || (m.class && m.class.pname) || '?'); });
html += `未识别类别 ${unknown.length} 个:${names.join('、')}(可在设置里加类名)
`;
}
for (let i = 0; i < infos.length; i++) html += `${infos[i].msg}
`;
if (warnings.length === 0 && infos.length === 0) {
html += `✓ 校验通过,无问题
`;
} else if (warnings.length > 0) {
html += ``;
for (let i = 0; i < warnings.length; i++) html += `${warnings[i].msg}
`;
}
slot.innerHTML = html;
if (warnings.length > 0) {
panel.classList.remove('sz-shake'); void panel.offsetWidth; panel.classList.add('sz-shake');
}
}
function saveSettings() { try { localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); } catch (e) {} }
function loadSettings() {
try {
const raw = localStorage.getItem(SETTINGS_KEY);
if (raw) settings = Object.assign({}, DEFAULT_SETTINGS, JSON.parse(raw));
} catch (e) {}
}
// ============================================================
// 初始化
// ============================================================
function init() {
loadSettings();
hookXHR();
hookFetch();
document.addEventListener('click', onSubmitClickCapture, true);
const start = function () {
if (document.body) {
createPanel();
loadStats();
updatePackageView();
startFrameWatcher();
watchPanel();
setInterval(ensurePanelAlive, 1500);
setInterval(function () { if (!scanning) refreshCurrentFrame(); }, REFRESH_CUR_MS > 0 ? REFRESH_CUR_MS : 2500);
if (AUTO_RESCAN_MS > 0) setInterval(function () { if (!scanning) runScanOnce(); }, AUTO_RESCAN_MS);
if (cachedData) { autoRunCheck(); maybeAutoBatch(); }
else if (CHECK_PKG_ON_LOAD) maybeAutoBatch();
} else setTimeout(start, 200);
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
else setTimeout(start, 300);
}
init();
})();