// ==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();
})();