// ==UserScript== // @name 无需标注批量勾选 // @namespace http://113.207.49.90/ // @version 1.2.1 // @description 批量把指定帧范围标记为“无需标注”(mark_status=1)并保存(并发加速),保存后可选回读校验,完成后自动跳到范围内最后一帧的下一帧 // @author 粟镇 // @match *://*/w/task.html* // @grant none // @run-at document-start // ==/UserScript== (function () { 'use strict'; // ============================================================ // 配置 // ============================================================ const SETTINGS_KEY = 'sz-nomark-settings-v2'; const CONCURRENCY = 6; // 并发帧数(越大越快,过高可能被限流) const MAX_FRAMES = 5000; const MARK_DATA_URL = '/api/task/get-mark-data'; const SAVE_URL = '/v2/tasks/mark-data'; // 一律正式保存(有面板内确认框兜底) let settings = { range: '', jump: true, verify: true }; let panel = null; let running = false; let stopFlag = false; let hookToken = ''; // 拦截到的 Access-Key,作为 localStorage 取不到时的兜底 let hookTaskKey = ''; // 拦截到的 task_key let lastMarkBody = null; // 最近一次 get-mark-data 的请求体,作为模板 // ============================================================ // 工具 // ============================================================ function sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); } let logCount = 0; function log(msg) { const el = document.getElementById('nm-log'); if (!el) return; // 只逐帧输出关键信息,避免日志节点过大拖慢页面 const t = new Date().toLocaleTimeString(); el.textContent += '[' + t + '] ' + msg + '\n'; logCount++; if (logCount > 400) { const lines = el.textContent.split('\n'); el.textContent = lines.slice(-250).join('\n'); logCount = 250; } el.scrollTop = el.scrollHeight; } function setStatus(msg) { const el = document.getElementById('nm-status'); if (el) el.textContent = msg; } function getHost() { return location.origin; } // Access-Key 来自 localStorage.token(平台 commonAjax 即如此) function getToken() { const keys = ['token', 'Token', 'access_key', 'accessKey', 'Access-Key']; for (let i = 0; i < keys.length; i++) { try { let v = localStorage.getItem(keys[i]); if (v) { v = String(v).replace(/^"+|"+$/g, '').trim(); if (v) return v; } } catch (e) {} } return hookToken || ''; } function readParams() { const q = {}; try { const u = new URL(location.href); u.searchParams.forEach(function (v, k) { q[k] = v; }); } catch (e) {} return q; } // task_key 优先取拦截到的(URL 里常常没有) function getTaskKey() { return readParams().task_key || hookTaskKey || ''; } // ============================================================ // 请求(JSON body + Access-Key,与平台 commonAjax 一致) // ============================================================ async function apiPost(path, data) { const headers = { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'Accept': 'application/json, text/javascript, */*; q=0.01', 'X-Requested-With': 'XMLHttpRequest' }; const token = getToken(); if (token) headers['Access-Key'] = token; const resp = await fetch(getHost() + path, { method: 'POST', credentials: 'include', headers: headers, body: JSON.stringify(data) }); if (!resp.ok) throw new Error('HTTP ' + resp.status); return resp.json(); } async function apiGet(path) { const headers = { 'Accept': 'application/json, text/javascript, */*; q=0.01', 'X-Requested-With': 'XMLHttpRequest' }; const token = getToken(); if (token) headers['Access-Key'] = token; const resp = await fetch(getHost() + path, { method: 'GET', credentials: 'include', headers: headers }); if (!resp.ok) throw new Error('HTTP ' + resp.status); return resp.json(); } function isOk(res) { return res && (res.code === 0 || res.code === '' || res.code === '0'); } // ============================================================ // 帧列表 // ============================================================ // 1) DOM 帧导航条:
  • function frameListFromDom() { if (!document.body) return null; const nodes = document.body.querySelectorAll('[data-task]'); const out = []; 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'); out.push({ id: id, order: dl === null ? null : parseInt(dl, 10) }); } if (out.length < 2) return null; const hasOrder = out.some(function (e) { return e.order !== null && !isNaN(e.order); }); if (hasOrder) out.sort(function (a, b) { return (a.order === null ? 1e9 : a.order) - (b.order === null ? 1e9 : b.order); }); return out; } // 2) 接口:packages/{id}/tasks → data.items[],或 mark-conf → data.frame[] function collectTaskIds(root) { const out = []; const seen = new Set(); const flags = ['done_status', 'mark_flag', 'is_save', 'is_acceptance', 'has_issues', 'mark_status']; (function walk(node, depth, key) { if (depth > 14 || !node || typeof node !== 'object') return; if (seen.has(node)) return; seen.add(node); if (Array.isArray(node)) { const ids = []; node.forEach(function (item) { if (item && typeof item === 'object' && item.task_id !== undefined && item.task_id !== null && item.task_id !== '') ids.push(String(item.task_id)); }); if (ids.length >= 2 && ids.length >= node.length * 0.5) { const keyed = key === 'frame' || key === 'items'; out.push({ ids: ids, keyed: keyed, key: key }); } node.forEach(function (item) { walk(item, 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') walk(v, depth + 1, k); } })(root, 0, null); if (!out.length) return null; out.sort(function (a, b) { return (b.keyed ? 1 : 0) - (a.keyed ? 1 : 0) || b.ids.length - a.ids.length; }); return out[0].ids; } async function getFrameList() { const dom = frameListFromDom(); if (dom && dom.length) return { ids: dom.map(function (e) { return e.id; }), src: 'DOM' }; const q = readParams(); const taskKey = getTaskKey(); const packageId = q.package_id || q.packageId || null; if (taskKey && packageId) { try { const r = await apiGet('/v2/task-batches/' + taskKey + '/packages/' + packageId + '}/tasks'); const ids = collectTaskIds(r); if (ids) return { ids: ids, src: 'packages/tasks' }; } catch (e) {} try { const r = await apiGet('/v2/task-batches/' + taskKey + '/packages/' + packageId + '/tasks'); const ids = collectTaskIds(r); if (ids) return { ids: ids, src: 'packages/tasks(无})' }; } catch (e) {} } const taskId = q.task_id || q.id || null; if (taskId) { 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; try { const r = await apiGet('/v2/tasks/mark-conf?status=' + status + '&task_id=' + taskId + '&work_type=' + workType + '&access=' + access); const ids = collectTaskIds(r); if (ids) return { ids: ids, src: 'mark-conf' }; } catch (e) {} } return null; } // ============================================================ // 范围解析:"1-33" / "1-5,8,10-12" // ============================================================ function parseRange(str) { const out = []; const seen = {}; String(str || '').split(/[,,\s]+/).forEach(function (part) { if (!part) return; const m = part.match(/^(\d+)\s*[-~]\s*(\d+)$/); if (m) { let a = parseInt(m[1], 10), b = parseInt(m[2], 10); if (a > b) { const t = a; a = b; b = t; } for (let n = a; n <= b; n++) if (!seen[n]) { seen[n] = 1; out.push(n); } return; } const one = part.match(/^(\d+)$/); if (one) { const n = parseInt(one[1], 10); if (!seen[n]) { seen[n] = 1; out.push(n); } } }); out.sort(function (a, b) { return a - b; }); return out; } // ============================================================ // 保存 // ============================================================ function buildSavePayload(md, taskId, q) { const marks = (md && md.marks) ? md.marks : []; const totalNums = { line: 0, polygon: 0, rect: 0, point: 0, cuboid: 0, curve: 0, parallel: 0, ellipse: 0, '3drect': 0, magic: 0 }; marks.forEach(function (m) { const t = m && m.type; if (t && totalNums[t] !== undefined) totalNums[t]++; }); const markData = { imgUrl: md.imgUrl, width: md.width, height: md.height, imagePath: md.imagePath, rotateDeg: md.rotateDeg || 0, marks: marks, totalNums: totalNums }; // 平台在 access=3(审核/编辑)时会删除 postils,这里保持一致 if (String(q.access) !== '3') markData.postils = md.postils || []; if (md.global) markData.global = md.global; if (md.is_minio !== undefined) markData.is_minio = md.is_minio; const payload = { action: 'save', time: Date.now(), markData: markData, task_id: taskId, work_type: q.work_type !== undefined ? q.work_type : 3, access: q.access !== undefined ? q.access : 3, status: q.status !== undefined ? q.status : 0, use_time: 0, mark_status: 1 // 1 = 无需标注 }; return payload; } async function fetchMarkData(taskId, q) { let body; if (lastMarkBody && typeof lastMarkBody === 'object') { body = Object.assign({}, lastMarkBody); // 复用平台真实请求体(含 task_key / is_preview 等) body.task_id = taskId; body.time = Date.now(); } else { body = { time: Date.now(), task_id: taskId, task_key: getTaskKey(), is_preview: q.is_preview || 0 }; } const res = await apiPost(MARK_DATA_URL, body); if (!isOk(res) || !res.data) throw new Error('get-mark-data 失败:' + (res && res.msg ? res.msg : JSON.stringify(res && res.code))); return res.data; } async function markOneFrame(frameNo, taskId, q, verify) { const data = await fetchMarkData(taskId, q); const already = data.mark_status === 1 || data.mark_status === '1'; const md = data.markData || {}; const markCount = (md.marks || []).length; if (already) return { frame: frameNo, status: 'skip', markCount: markCount, msg: '已是无需标注' }; if (!md.imgUrl) return { frame: frameNo, status: 'fail', markCount: markCount, msg: '缺少 imgUrl,无法保存' }; const payload = buildSavePayload(md, taskId, q); const res = await apiPost(SAVE_URL, payload); if (!isOk(res)) return { frame: frameNo, status: 'fail', markCount: markCount, msg: (res && res.msg) || ('code=' + (res && res.code)) }; const kept = markCount ? ('(原有 ' + markCount + ' 个标注已保留)') : ''; if (!verify) return { frame: frameNo, status: 'ok', markCount: markCount, msg: '已保存' + kept }; // 回读校验:确认 mark_status 真的写进去了 try { const check = await fetchMarkData(taskId, q); const now = check.mark_status; if (now === 1 || now === '1') { return { frame: frameNo, status: 'ok', markCount: markCount, msg: '已保存并校验' + kept }; } return { frame: frameNo, status: 'fail', markCount: markCount, msg: '接口返回成功但回读 mark_status=' + now + ',未生效' }; } catch (e) { return { frame: frameNo, status: 'ok', markCount: markCount, msg: '已保存(未能回读校验)' }; } } // ============================================================ // 跳转:优先用平台自身的导航(不依赖 DOM 帧导航条是否渲染了目标帧) // ============================================================ function findFrameNode(taskId) { if (!document.body) return null; const nodes = document.body.querySelectorAll('[data-task]'); for (let i = 0; i < nodes.length; i++) { const el = nodes[i]; if (panel && panel.contains(el)) continue; if (String(el.getAttribute('data-task')) === String(taskId)) return el; } return null; } function currentFrameNo() { const el = document.getElementById('state'); if (el) { const m = String(el.textContent || '').trim().match(/^(\d+)\s*[\/|]\s*(\d+)/); if (m) return parseInt(m[1], 10); } if (!document.body) return null; const all = document.body.querySelectorAll('span,div'); for (let i = 0; i < all.length; i++) { if (panel && panel.contains(all[i])) continue; const m = String(all[i].textContent || '').trim().match(/^(\d+)\s*\/\s*(\d+)$/); if (m) return parseInt(m[1], 10); } return null; } // 平台 canvas 实例:service-mark.js 里 window.InitCanvasInfo.canvasCommon function getCanvasInstance() { try { const info = window.InitCanvasInfo; return info && (info.canvasCommon || info.canvasInstance) || null; } catch (e) { return null; } } async function waitFrame(frameNo, timeoutMs) { const t0 = Date.now(); while (Date.now() - t0 < timeoutMs) { if (currentFrameNo() === frameNo) return true; await sleep(200); } return false; } // 方式一:平台自己的按 task_id 切帧(使用完整 params.frame,最可靠) async function jumpViaPlatform(taskId, frameNo) { const cc = getCanvasInstance(); if (!cc || !cc.Header || typeof cc.Header.nextFrame !== 'function' || !cc.params || !cc.params.frame) return false; try { cc.Header.nextFrame({ task_id: taskId }, { is_point: true, object: cc }); } catch (e) { return false; } return await waitFrame(frameNo, 5000); } // 方式二:点击 DOM 帧导航条(若目标帧已渲染) async function jumpViaDom(taskId, frameNo) { const node = findFrameNode(taskId); if (!node) return false; node.click(); return await waitFrame(frameNo, 4000); } // 平台离开当前帧时会校验/可能自动保存当前帧的 mark_status(取自“无需标注”勾选状态)。 // 我们在后台改了数据但平台内存没变,若不把勾选同步上,切帧时可能把最后一帧又写回 0。 function syncNoMarkCheckbox(markedSet) { try { const el = document.querySelector('.toolBarEditorRight #markTag_input'); if (!el) return; const cur = currentFrameNo(); if (cur !== null && markedSet && markedSet[cur]) { el.checked = true; el.classList.add('notAllowTag'); } } catch (e) {} } async function jumpToFrame(frameNo, taskId, markedSet) { syncNoMarkCheckbox(markedSet); if (await jumpViaPlatform(taskId, frameNo)) return true; if (await jumpViaDom(taskId, frameNo)) return true; return false; } // ============================================================ // 主流程 // ============================================================ async function run() { if (running) return; const q = readParams(); const frames = parseRange(document.getElementById('nm-range').value); if (!frames.length) { setStatus('请输入帧范围,例如 1-33'); return; } if (frames.length > MAX_FRAMES) { setStatus('帧数过多(' + frames.length + '),已中止'); return; } if (!getToken()) { setStatus('取不到 Access-Key(localStorage.token),请先正常打开一次题包页面'); return; } const jump = !!document.getElementById('nm-jump').checked; const verify = !!document.getElementById('nm-verify').checked; setStatus('正在获取帧列表...'); let fl; try { fl = await getFrameList(); } catch (e) { fl = null; } if (!fl) { setStatus('获取帧列表失败:请刷新题包页面后重试'); return; } const ids = fl.ids; const total = ids.length; const bad = frames.filter(function (n) { return n < 1 || n > total; }); const valid = frames.filter(function (n) { return n >= 1 && n <= total; }); if (!valid.length) { setStatus('范围超出题包帧数(共 ' + total + ' 帧)'); return; } if (bad.length) log('警告:第 ' + bad.join('、') + ' 帧超出范围(共 ' + total + ' 帧),已忽略'); if (String(q.access) !== '3') { log('提示:当前 access=' + q.access + ',平台通常只在 access=3(编辑/审核)时才允许保存,可能保存失败'); } const confirmed = await confirmInPanel( '标记为“无需标注”并保存', '第 ' + valid[0] + ' ~ ' + valid[valid.length - 1] + ' 帧(共 ' + valid.length + ' 帧)\n' + '此操作会写入平台数据。' ); if (!confirmed) { setStatus('已取消'); return; } running = true; stopFlag = false; document.getElementById('nm-run').disabled = true; document.getElementById('nm-stop').disabled = false; log('帧列表来源:' + fl.src + ',共 ' + total + ' 帧'); log('开始处理 ' + valid.length + ' 帧(并发 ' + CONCURRENCY + ')'); let ok = 0, skip = 0, fail = 0, done = 0; const tick = function () { done++; document.getElementById('nm-bar').style.width = Math.round(done / valid.length * 100) + '%'; setStatus('处理中 ' + done + ' / ' + valid.length); }; // 并发处理:每个 worker 依次取下一帧 let cursor = 0; const worker = async function () { while (!stopFlag) { const i = cursor++; if (i >= valid.length) return; const frameNo = valid[i]; const taskId = ids[frameNo - 1]; try { const r = await markOneFrame(frameNo, taskId, q, verify); if (r.status === 'ok') { ok++; log('第 ' + frameNo + ' 帧 ✓ ' + r.msg); } else if (r.status === 'skip') { skip++; log('第 ' + frameNo + ' 帧 - 跳过(' + r.msg + ')'); } else { fail++; log('第 ' + frameNo + ' 帧 ✗ ' + r.msg); } } catch (e) { fail++; log('第 ' + frameNo + ' 帧 ✗ ' + (e && e.message ? e.message : e)); } tick(); } }; const workers = []; for (let w = 0; w < Math.min(CONCURRENCY, valid.length); w++) workers.push(worker()); await Promise.all(workers); if (stopFlag) log('已被手动停止'); running = false; document.getElementById('nm-run').disabled = false; document.getElementById('nm-stop').disabled = true; const summary = '处理完成:成功 ' + ok + ',跳过 ' + skip + ',失败 ' + fail; setStatus(summary); log(summary); // 完成后跳转到下一帧(范围末尾的下一帧) if (jump && !stopFlag) { const nextFrame = valid[valid.length - 1] + 1; if (nextFrame > total) { log('已是最后一帧,无下一帧可跳转'); } else { const nextId = ids[nextFrame - 1]; const markedSet = {}; valid.forEach(function (n) { markedSet[n] = true; }); setStatus('正在跳转到第 ' + nextFrame + ' 帧...'); let jumped = false; try { jumped = await jumpToFrame(nextFrame, nextId, markedSet); } catch (e) { jumped = false; } if (jumped) log('已跳转到第 ' + nextFrame + ' 帧'); else log('未能自动跳转到第 ' + nextFrame + ' 帧,请手动切到第 ' + nextFrame + ' 帧'); } } } // ============================================================ // Access-Key 兜底捕获 // ============================================================ function hookXHR() { try { const origOpen = XMLHttpRequest.prototype.open; const origSend = XMLHttpRequest.prototype.send; const origSetHeader = XMLHttpRequest.prototype.setRequestHeader; XMLHttpRequest.prototype.open = function (method, url) { this._nmUrl = url; this._nmHeaders = {}; return origOpen.apply(this, arguments); }; XMLHttpRequest.prototype.setRequestHeader = function (k, v) { if (this._nmHeaders && String(k).toLowerCase() === 'access-key' && v) hookToken = String(v).replace(/^"+|"+$/g, ''); return origSetHeader.apply(this, arguments); }; XMLHttpRequest.prototype.send = function (body) { try { if (this._nmUrl && String(this._nmUrl).indexOf('get-mark-data') !== -1 && body) { const obj = JSON.parse(body); if (obj && obj.task_key) hookTaskKey = String(obj.task_key); if (obj && typeof obj === 'object') lastMarkBody = obj; } } catch (e) {} return origSend.apply(this, arguments); }; } catch (e) {} } // ============================================================ // 面板 // ============================================================ function injectStyles() { if (document.getElementById('nm-styles')) return; const s = document.createElement('style'); s.id = 'nm-styles'; s.textContent = ` #nm-panel { position: fixed; top: 80px; left: 700px; width: 330px; max-height: 70vh; background: linear-gradient(145deg, #1a1a2e 0%, #16213e 100%); color: #e0e0e0; border: 1px solid rgba(255,140,0,.4); 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,.6); overflow: hidden; } #nm-header { background: linear-gradient(135deg, #7a4a00 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(255,140,0,.25); } #nm-header:active { cursor: grabbing; } .nm-title { font-weight: bold; font-size: 14px; color: #ffb04d; display: flex; align-items: center; gap: 6px; } .nm-title-dot { width: 8px; height: 8px; border-radius: 50%; background: #ffb04d; box-shadow: 0 0 8px #ffb04d; } .nm-btn-toggle { background: rgba(255,255,255,.08); color: #aaa; border: 1px solid rgba(255,255,255,.1); width: 26px; height: 26px; border-radius: 6px; cursor: pointer; font-size: 14px; } #nm-content { padding: 12px 14px; max-height: calc(70vh - 50px); overflow-y: auto; } .nm-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; } .nm-row label { color: #8892b0; font-size: 12px; flex: none; } .nm-row input[type=text] { flex: 1; background: rgba(0,0,0,.3); border: 1px solid rgba(255,255,255,.15); color: #ffd9a8; border-radius: 5px; padding: 5px 8px; font-size: 13px; } .nm-row input[type=checkbox] { transform: scale(1.1); } .nm-hint { color: #667; font-size: 11px; margin: -4px 0 8px; } .nm-actions { display: flex; gap: 8px; margin: 6px 0 10px; } .nm-actions button { flex: 1; border: none; border-radius: 6px; padding: 7px 10px; font-size: 13px; font-weight: bold; cursor: pointer; } #nm-run { background: linear-gradient(135deg,#ffa500,#e07000); color: #fff; } #nm-run:hover { filter: brightness(1.1); } #nm-run:disabled { opacity: .5; cursor: not-allowed; } #nm-stop { background: rgba(255,255,255,.08); color: #ccc; border: 1px solid rgba(255,255,255,.15); } #nm-stop:disabled { opacity: .4; cursor: not-allowed; } #nm-status { color: #7fddc4; font-size: 12px; line-height: 1.5; margin-bottom: 6px; word-break: break-all; } .nm-progress { height: 6px; background: rgba(255,255,255,.08); border-radius: 3px; overflow: hidden; margin-bottom: 8px; } #nm-bar { height: 100%; width: 0; background: linear-gradient(90deg,#ffa500,#ffd280); transition: width .2s; } #nm-log { background: #0b1020; border: 1px solid rgba(255,255,255,.1); border-radius: 5px; padding: 8px; font-family: Consolas,monospace; font-size: 11px; color: #9db8d6; max-height: 220px; overflow: auto; white-space: pre-wrap; } .nm-warn { background: rgba(233,69,96,.12); border: 1px solid rgba(233,69,96,.4); color: #ff9db0; padding: 6px 8px; border-radius: 5px; font-size: 11px; line-height: 1.5; margin-bottom: 8px; } #nm-confirm { position: absolute; left: 0; top: 0; right: 0; bottom: 0; background: rgba(8,12,24,.86); display: none; flex-direction: column; align-items: center; justify-content: center; padding: 16px; text-align: center; z-index: 5; } #nm-confirm.open { display: flex; } #nm-confirm-title { color: #ffb04d; font-weight: bold; font-size: 14px; margin-bottom: 8px; } #nm-confirm-body { color: #cfd8e8; font-size: 12px; line-height: 1.7; margin-bottom: 14px; white-space: pre-line; } #nm-confirm-btns { display: flex; gap: 10px; } #nm-confirm-btns button { border: none; border-radius: 6px; padding: 7px 18px; font-size: 13px; font-weight: bold; cursor: pointer; } #nm-confirm-ok { background: linear-gradient(135deg,#e94560,#b81f3a); color: #fff; } #nm-confirm-cancel { background: rgba(255,255,255,.1); color: #ccc; border: 1px solid rgba(255,255,255,.18); } `; document.head.appendChild(s); } // 面板内确认框(替代 window.confirm,避免原生弹窗打断且样式统一) function confirmInPanel(title, body) { return new Promise(function (resolve) { const box = document.getElementById('nm-confirm'); const cur = document.getElementById('nm-content'); if (!box) { resolve(window.confirm(title + '\n' + body)); return; } document.getElementById('nm-confirm-title').textContent = title; document.getElementById('nm-confirm-body').textContent = body; if (cur) cur.style.minHeight = '150px'; box.classList.add('open'); const cleanup = function (val) { box.classList.remove('open'); okBtn.removeEventListener('click', onOk); cancelBtn.removeEventListener('click', onCancel); resolve(val); }; const onOk = function () { cleanup(true); }; const onCancel = function () { cleanup(false); }; const okBtn = document.getElementById('nm-confirm-ok'); const cancelBtn = document.getElementById('nm-confirm-cancel'); okBtn.addEventListener('click', onOk); cancelBtn.addEventListener('click', onCancel); }); } function createPanel() { if (panel && document.body.contains(panel)) return; injectStyles(); panel = document.createElement('div'); panel.id = 'nm-panel'; panel.innerHTML = `
    无需标注批量勾选
    填写的帧将被标记为“无需标注”并保存
    保存会写入平台数据,建议先用较小的范围确认效果。
    就绪
    `; document.body.appendChild(panel); document.getElementById('nm-run').addEventListener('click', run); document.getElementById('nm-stop').addEventListener('click', function () { stopFlag = true; setStatus('正在停止...'); }); document.getElementById('nm-toggle').addEventListener('click', function () { const c = document.getElementById('nm-content'); const b = document.getElementById('nm-toggle'); if (c.style.display === 'none') { c.style.display = ''; b.innerHTML = '−'; } else { c.style.display = 'none'; b.innerHTML = '+'; } }); // 记忆设置 try { const raw = localStorage.getItem(SETTINGS_KEY); if (raw) { settings = Object.assign(settings, JSON.parse(raw)); document.getElementById('nm-range').value = settings.range || ''; document.getElementById('nm-jump').checked = settings.jump !== false; document.getElementById('nm-verify').checked = settings.verify !== false; } } catch (e) {} ['nm-range', 'nm-jump', 'nm-verify'].forEach(function (id) { document.getElementById(id).addEventListener('change', function () { settings.range = document.getElementById('nm-range').value; settings.jump = document.getElementById('nm-jump').checked; settings.verify = document.getElementById('nm-verify').checked; try { localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); } catch (e) {} }); }); makeDraggable(); } function makeDraggable() { const header = document.getElementById('nm-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; }); } // ============================================================ // 初始化 // ============================================================ function init() { hookXHR(); const start = function () { if (document.body) createPanel(); else setTimeout(start, 200); }; if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start); else setTimeout(start, 300); } init(); })();