// ==UserScript== // @name 镂空拖挂头标注 // @namespace lh6v.trailer-head.mark-guard // @version 1.0.1 // @description 2D 图像标注页实时校验:nut / pillar / base_nut / base / lockring_center / lockring 六个类别各限出现一次,总数不超过 6 个,可少不可多;不合规时拦截保存与提交。 // @author CC // @match *://*/w/task.html* // @match *://*/w/task-angle.html* // @match *://*/w/task-check.html* // @run-at document-start // @grant none // @noframes // ==/UserScript== /* eslint-disable no-empty */ (function () { 'use strict'; // ──────────────────────────────────────────────────────────────────────────── /* ==RULES-BEGIN== */ /** 允许出现的六个类别(顺序即面板展示顺序),pname 与服务端 mark-conf 返回的 class.pname 逐字一致 */ var CLASSES = [ { pname: 'nut', label: 'nut(插梢上面的螺帽)' }, { pname: 'pillar', label: 'pillar(插销)' }, { pname: 'base_nut', label: 'base_nut(基座上的螺帽)' }, { pname: 'base', label: 'base(基座)' }, { pname: 'lockring_center', label: 'lockring_center(锁环中心)' }, { pname: 'lockring', label: 'lockring(锁环)' } ]; var MAX_TOTAL = 6; var ALLOWED = Object.create(null); var LABEL = Object.create(null); CLASSES.forEach(function (c) { ALLOWED[c.pname] = true; LABEL[c.pname] = c.label; }); function labelOf(pname) { return LABEL[pname] || pname; } /** * 取一个标注物的类别名。 * 标注器「实时态」把 class 存成数组(canvas-common.js: mark.class = [classes[i]]), * 「保存/接口态」是对象 {pname, preset_id}(submitClick 里做了归一化)。两种都要兼容。 */ function pnameOf(mark) { if (!mark) return ''; var c = mark.class; if (Array.isArray(c)) { if (!c.length) return ''; var last = c[c.length - 1]; if (!last) return ''; if (typeof last === 'string') return last; return last.pname || ''; } if (typeof c === 'string') return c; if (c && typeof c === 'object') return c.pname || ''; return ''; } /** * 校验一组 marks。 * 规则:① 每个允许的类别最多出现一次;② 总数不超过 6;③ 不允许出现六类之外的类别(含未选择类别)。 * 可以少。 */ function validate(marks) { var arr = Array.isArray(marks) ? marks : []; var counts = Object.create(null); CLASSES.forEach(function (c) { counts[c.pname] = 0; }); var items = arr.map(function (m, i) { return { index: i + 1, pname: pnameOf(m), markId: m && m.markId, type: m && m.type }; }); var unknown = []; items.forEach(function (it) { if (ALLOWED[it.pname]) counts[it.pname]++; else unknown.push(it); }); var issues = []; CLASSES.forEach(function (c) { if (counts[c.pname] > 1) { var idx = items .filter(function (it) { return it.pname === c.pname; }) .map(function (it) { return it.index; }); issues.push({ kind: 'duplicate', pname: c.pname, indexes: idx, text: '「' + labelOf(c.pname) + '」重复出现 ' + idx.length + ' 次(第 ' + idx.join('、') + ' 个标注物)' }); } }); unknown.forEach(function (it) { issues.push({ kind: 'unknown', pname: it.pname, indexes: [it.index], text: it.pname ? '第 ' + it.index + ' 个标注物的类别「' + it.pname + '」不属于允许的 6 个类别' : '第 ' + it.index + ' 个标注物还没有选择类别' }); }); if (items.length > MAX_TOTAL) { issues.push({ kind: 'overflow', pname: '', indexes: items.map(function (it) { return it.index; }), text: '标注物共 ' + items.length + ' 个,超过上限 ' + MAX_TOTAL + ' 个' }); } return { ok: issues.length === 0, total: items.length, counts: counts, items: items, issues: issues, badIndexes: (function () { var s = Object.create(null); issues.forEach(function (is) { is.indexes.forEach(function (i) { s[i] = true; }); }); return Object.keys(s).map(Number).sort(function (a, b) { return a - b; }); })() }; } /* ==RULES-END== */ // ──────────────────────────────────────────────────────────────────────────── // 2. 平台适配层(唯一与本站点耦合的部分) // ──────────────────────────────────────────────────────────────────────────── var MARK_DATA_URL_RE = /\/v2\/tasks\/mark-data(\?|$)/; function jq() { return window.jQuery || window.$ || null; } /** 标注器实时数据:window.InitCanvasInfo.canvasCommon.bg_img.marks(service-mark.js 里挂到 window 的) */ function readLiveMarks() { try { var ci = window.InitCanvasInfo; var cc = ci && ci.canvasCommon; var bg = cc && cc.bg_img; if (bg && Array.isArray(bg.marks)) return bg.marks; } catch (e) {} try { var ci2 = window.InitCanvasInfo; var cc2 = ci2 && ci2.canvasNormal; var bg2 = cc2 && cc2.bg_img; if (bg2 && Array.isArray(bg2.marks)) return bg2.marks; } catch (e) {} return null; } function liveConnected() { return readLiveMarks() !== null; } /** 高亮右侧标注物列表里出问题的条目 */ function locateBadMarks(badIndexes) { var $ = jq(); if (!$ || !badIndexes || !badIndexes.length) return; $('#toolBarList li').removeClass('lh6v-bad'); var first = null; badIndexes.forEach(function (i) { var $li = $('#toolBarList li[data-index="' + i + '"]'); if ($li.length) { $li.addClass('lh6v-bad'); if (!first) first = $li; } }); if (first && first[0] && first[0].scrollIntoView) { try { first[0].scrollIntoView({ block: 'nearest' }); } catch (e) {} } } function clearBadMarks() { var $ = jq(); if ($) $('#toolBarList li').removeClass('lh6v-bad'); } function hideLoaders() { var $ = jq(); if (!$) return; try { $('#load').hide(); } catch (e) {} try { $('#loadup').hide(); } catch (e) {} } // ──────────────────────────────────────────────────────────────────────────── // 3. 配置 // ──────────────────────────────────────────────────────────────────────────── var CFG_KEY = 'lh6v.cfg.v1'; var CFG = { blockSave: true, // 拦截「暂时保存」等手动保存 blockSubmit: true, // 拦截「提交 / 继续提交」 blockAuto: false, // 拦截自动保存(默认关闭,避免丢数据) locate: true, // 拦截后自动定位并高亮问题标注物 allowForce: true, // 是否在拦截弹窗里保留「强制保存」入口 x: null, y: null, collapsed: false }; function loadCfg() { try { var raw = localStorage.getItem(CFG_KEY); if (!raw) return; var o = JSON.parse(raw); Object.keys(CFG).forEach(function (k) { if (o[k] !== undefined) CFG[k] = o[k]; }); } catch (e) {} } function saveCfg() { try { localStorage.setItem(CFG_KEY, JSON.stringify(CFG)); } catch (e) {} } loadCfg(); // ──────────────────────────────────────────────────────────────────────────── // 4. 界面(Shadow DOM,避免与本页 CSS 互相污染) // ──────────────────────────────────────────────────────────────────────────── var host = null, root = null; var el = {}; var toastTimer = null; var PANEL_CSS = [ ':host{all:initial}', '*{box-sizing:border-box;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",sans-serif}', '.wrap{position:fixed;z-index:2147483000;width:268px;background:#fff;border:1px solid #d8dee6;border-radius:10px;', 'box-shadow:0 8px 28px rgba(15,23,42,.18);font-size:12px;color:#1f2937;overflow:hidden;user-select:none}', '.hd{display:flex;align-items:center;gap:6px;padding:8px 10px;background:#1f2937;color:#fff;cursor:move}', '.hd .t{flex:1;font-size:12px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}', '.hd .b{border:0;background:transparent;color:#cbd5e1;cursor:pointer;font-size:13px;line-height:1;padding:2px 3px;border-radius:4px}', '.hd .b:hover{background:rgba(255,255,255,.15);color:#fff}', '.st{padding:7px 10px;font-weight:600;display:flex;align-items:center;gap:6px;border-bottom:1px solid #eef2f7}', '.st.ok{background:#ecfdf5;color:#047857}', '.st.bad{background:#fef2f2;color:#b91c1c}', '.st.warn{background:#fffbeb;color:#b45309}', '.st .cnt{margin-left:auto;font-weight:500;font-size:11px;opacity:.85}', '.rows{padding:4px 0}', '.row{display:flex;align-items:center;gap:6px;padding:4px 10px;line-height:1.35}', '.row .nm{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}', '.row .pin{width:16px;text-align:center;font-weight:700}', '.row.v1{color:#047857}', '.row.v1 .pin{color:#059669}', '.row.v0{color:#9ca3af}', '.row.v0 .pin{color:#d1d5db}', '.row.vbad{background:#fef2f2;color:#b91c1c;font-weight:600}', '.row.vbad .pin{color:#dc2626}', '.row .idx{font-size:10px;color:#9ca3af;font-weight:400}', '.row.vbad .idx{color:#ef4444}', '.msg{margin:2px 8px 6px;padding:6px 8px;background:#fef2f2;border:1px solid #fecaca;border-radius:6px;color:#b91c1c;font-size:11px;line-height:1.5;max-height:132px;overflow:auto;user-select:text}', '.msg div+div{margin-top:3px}', '.ft{display:flex;align-items:center;gap:8px;padding:6px 10px;border-top:1px solid #eef2f7;background:#f9fafb;font-size:11px;color:#6b7280}', '.ft a{color:#2563eb;cursor:pointer;text-decoration:none}', '.ft a:hover{text-decoration:underline}', '.set{padding:8px 10px;border-top:1px solid #eef2f7;background:#f9fafb;display:none}', '.set.on{display:block}', '.set label{display:flex;align-items:center;gap:6px;padding:3px 0;cursor:pointer;color:#374151}', '.set input{accent-color:#2563eb}', '.toast{position:fixed;z-index:2147483001;left:50%;bottom:48px;transform:translateX(-50%);background:rgba(17,24,39,.94);', 'color:#fff;padding:8px 14px;border-radius:8px;font-size:12px;max-width:70vw;line-height:1.5;box-shadow:0 6px 20px rgba(0,0,0,.3)}', '.mask{position:fixed;z-index:2147483002;inset:0;background:rgba(15,23,42,.45);display:flex;align-items:center;justify-content:center}', '.dlg{width:460px;max-width:92vw;max-height:82vh;background:#fff;border-radius:12px;box-shadow:0 20px 60px rgba(0,0,0,.35);overflow:hidden;display:flex;flex-direction:column}', '.dlg .dh{padding:12px 16px;background:#fef2f2;border-bottom:1px solid #fecaca;color:#b91c1c;font-weight:700;font-size:14px;display:flex;align-items:center;gap:8px}', '.dlg .db{padding:14px 16px;overflow:auto;font-size:13px;line-height:1.65;color:#374151;user-select:text}', '.dlg .db ul{margin:6px 0 0;padding-left:20px}', '.dlg .db li{margin:4px 0}', '.dlg .db .hint{margin-top:10px;color:#6b7280;font-size:12px}', '.dlg .df{padding:10px 16px;border-top:1px solid #eef2f7;display:flex;gap:8px;align-items:center;justify-content:flex-end;background:#f9fafb}', '.dlg button{border:1px solid #d1d5db;background:#fff;color:#374151;padding:6px 14px;border-radius:6px;cursor:pointer;font-size:13px}', '.dlg button:hover{background:#f3f4f6}', '.dlg button.pri{background:#2563eb;border-color:#2563eb;color:#fff}', '.dlg button.pri:hover{background:#1d4ed8}', '.dlg .force{margin-right:auto;font-size:12px;color:#9ca3af;text-decoration:underline;cursor:pointer;background:none;border:0;padding:0}', '.dlg .force:hover{color:#b91c1c}' ].join(''); function buildPanel() { host = document.createElement('div'); host.id = 'lh6v-host'; host.style.cssText = 'all:initial;display:block'; root = host.attachShadow({ mode: 'open' }); var style = document.createElement('style'); style.textContent = PANEL_CSS; root.appendChild(style); var wrap = document.createElement('div'); wrap.className = 'wrap'; wrap.innerHTML = [ '
', ' 六类唯一性校验', ' ', ' ', '
', '
', '
·正在连接标注器…
', '
', ' ', '
', ' ', ' ', ' ', ' ', ' ', '
', '
', '
实时校验重新校验
', '
' ].join(''); root.appendChild(wrap); // 初次定位 wrap.style.left = (CFG.x != null ? CFG.x : Math.max(8, window.innerWidth - 288)) + 'px'; wrap.style.top = (CFG.y != null ? CFG.y : 96) + 'px'; el.wrap = wrap; el.st = wrap.querySelector('[data-st]'); el.stIcon = wrap.querySelector('[data-st-icon]'); el.stText = wrap.querySelector('[data-st-text]'); el.stCnt = wrap.querySelector('[data-st-cnt]'); el.rows = wrap.querySelector('[data-rows]'); el.msg = wrap.querySelector('[data-msg]'); el.set = wrap.querySelector('[data-set]'); el.diag = wrap.querySelector('[data-diag]'); el.mode = wrap.querySelector('[data-mode]'); el.body = wrap.querySelector('[data-body]'); // 六行固定结构,后续只改 class / 文本 CLASSES.forEach(function (c) { var row = document.createElement('div'); row.className = 'row v0'; row.setAttribute('data-pname', c.pname); row.innerHTML = '○' + '' + c.label + '' + ''; el.rows.appendChild(row); }); // 拖动 var dragging = null; wrap.querySelector('[data-drag]').addEventListener('mousedown', function (ev) { if (ev.target.closest('button')) return; dragging = { dx: ev.clientX - wrap.offsetLeft, dy: ev.clientY - wrap.offsetTop }; ev.preventDefault(); }); window.addEventListener('mousemove', function (ev) { if (!dragging) return; var x = Math.min(Math.max(0, ev.clientX - dragging.dx), window.innerWidth - 60); var y = Math.min(Math.max(0, ev.clientY - dragging.dy), window.innerHeight - 30); wrap.style.left = x + 'px'; wrap.style.top = y + 'px'; }); window.addEventListener('mouseup', function () { if (!dragging) return; dragging = null; CFG.x = parseInt(wrap.style.left, 10); CFG.y = parseInt(wrap.style.top, 10); saveCfg(); }); // 按钮 wrap.addEventListener('click', function (ev) { var t = ev.target.closest('[data-act]'); if (t) { var act = t.getAttribute('data-act'); if (act === 'fold') { CFG.collapsed = !CFG.collapsed; saveCfg(); applyCollapsed(); } else if (act === 'set') { el.set.classList.toggle('on'); } else if (act === 'recheck') { tick(true); } return; } var cb = ev.target.closest('input[data-cfg]'); if (cb) { CFG[cb.getAttribute('data-cfg')] = cb.checked; saveCfg(); } }); // 同步 checkbox wrap.querySelectorAll('input[data-cfg]').forEach(function (inp) { inp.checked = !!CFG[inp.getAttribute('data-cfg')]; }); applyCollapsed(); (document.body || document.documentElement).appendChild(host); } function applyCollapsed() { if (!el.body) return; el.body.style.display = CFG.collapsed ? 'none' : ''; } function toast(text, ms) { if (!root) return; var old = root.querySelector('.toast'); if (old) old.remove(); var d = document.createElement('div'); d.className = 'toast'; d.textContent = text; root.appendChild(d); clearTimeout(toastTimer); toastTimer = setTimeout(function () { d.remove(); }, ms || 2600); } function highlightCss() { if (document.getElementById('lh6v-page-css')) return; var s = document.createElement('style'); s.id = 'lh6v-page-css'; s.textContent = '#toolBarList li.lh6v-bad{outline:2px solid #dc2626 !important;outline-offset:-2px;' + 'background:rgba(220,38,38,.12) !important;animation:lh6v-blink 1s ease-in-out 3}' + '@keyframes lh6v-blink{0%,100%{background:rgba(220,38,38,.12)}50%{background:rgba(220,38,38,.4)}}'; (document.head || document.documentElement).appendChild(s); } // 拦截弹窗 var dlgNode = null; function closeDialog() { if (dlgNode) { dlgNode.remove(); dlgNode = null; } } function showBlockDialog(res, onForce) { closeDialog(); var mask = document.createElement('div'); mask.className = 'mask'; var items = res.issues.map(function (i) { return '
  • ' + escapeHtml(i.text) + '
  • '; }).join(''); var dlg = document.createElement('div'); dlg.className = 'dlg'; dlg.innerHTML = '
    ⚠ 标注数据不合规,已阻止保存
    ' + '
    ' + '
    本图的标注物必须满足:六个类别(' + CLASSES.map(function (c) { return c.pname; }).join(' / ') + ')各最多一个,总数不超过 ' + MAX_TOTAL + ' 个,可少不可多。
    ' + ' ' + '
    当前共 ' + res.total + ' 个标注物。请删除或修正后再保存。
    ' + '
    ' + '
    ' + (CFG.allowForce ? '' : '') + '' + '' + '
    '; mask.appendChild(dlg); root.appendChild(mask); dlgNode = mask; dlg.addEventListener('click', function (ev) { if (ev.target.closest('[data-ok]')) { closeDialog(); return; } if (ev.target.closest('[data-locate]')) { locateBadMarks(res.badIndexes); return; } if (ev.target.closest('[data-force]')) { closeDialog(); onForce(); return; } }); } function escapeHtml(s) { return String(s).replace(/[&<>"']/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; }); } // ──────────────────────────────────────────────────────────────────────────── // 5. 面板刷新 // ──────────────────────────────────────────────────────────────────────────── var lastRes = null; function render(res, connected) { lastRes = res; // 状态条 var cls, icon, text; if (!connected) { cls = 'warn'; icon = '?'; text = '未读到标注器数据'; } else if (res.ok) { cls = 'ok'; icon = '✓'; text = res.total === 0 ? '暂无标注物(合规)' : '合规'; } else { cls = 'bad'; icon = '✕'; text = '不合规 · ' + res.issues.length + ' 项问题'; } el.st.className = 'st ' + cls; el.stIcon.textContent = icon; el.stText.textContent = text; el.stCnt.textContent = connected ? res.total + ' / ' + MAX_TOTAL : ''; // 六行 CLASSES.forEach(function (c) { var row = el.rows.querySelector('.row[data-pname="' + c.pname + '"]'); if (!row) return; var n = res.counts[c.pname] || 0; var idx = res.items .filter(function (it) { return it.pname === c.pname; }) .map(function (it) { return it.index; }); var v; if (!connected) v = 'v0'; else if (n === 0) v = 'v0'; else if (n === 1) v = 'v1'; else v = 'vbad'; row.className = 'row ' + v; row.querySelector('.pin').textContent = n === 0 ? '○' : (n === 1 ? '●' : '✕'); row.querySelector('.idx').textContent = n === 0 ? '未标注' : ('×' + n + ' 第' + idx.join('/') + '个'); }); // 问题清单 if (connected && !res.ok) { el.msg.style.display = ''; el.msg.innerHTML = res.issues.map(function (i) { return '
    · ' + escapeHtml(i.text) + '
    '; }).join(''); } else { el.msg.style.display = 'none'; el.msg.innerHTML = ''; } // 诊断 el.diag.textContent = '数据源 ' + (connected ? 'window.InitCanvasInfo.canvasCommon.bg_img.marks' : '未连接') + ' | 保存钩子 ' + (ajaxPatched ? '已安装' : '未安装'); el.mode.textContent = '实时校验'; } var rafPending = false; function tick(force) { if (!el.rows) return; var marks = readLiveMarks(); var connected = marks !== null; var res = validate(marks || []); render(res, connected); if (force && !connected) toast('没读到标注器数据,请确认当前页是 /w/task.html 标注页'); return res; } // ──────────────────────────────────────────────────────────────────────────── // 6. AJAX 拦截 // ──────────────────────────────────────────────────────────────────────────── var ajaxPatched = false; var lastGesture = 0; ['mousedown', 'pointerdown', 'touchstart', 'keydown'].forEach(function (ev) { window.addEventListener(ev, function () { lastGesture = Date.now(); }, true); }); function isManual() { return Date.now() - lastGesture < 3000; } function parseBody(data) { if (!data) return null; if (typeof data === 'string') { try { return JSON.parse(data); } catch (e) { return null; } } if (typeof data === 'object') return data; return null; } /** * 返回 null 表示放行;返回 {res, mode:'block'|'warn'} 表示校验不通过。 */ function inspect(settings) { var type = String(settings.type || settings.method || 'GET').toUpperCase(); if (type !== 'POST') return null; var url = String(settings.url || ''); if (!MARK_DATA_URL_RE.test(url)) return null; var body = parseBody(settings.data); if (!body || !body.markData || !Array.isArray(body.markData.marks)) return null; var res = validate(body.markData.marks); if (res.ok) { if (CFG.locate) clearBadMarks(); return { res: res, mode: 'ok' }; } var action = String(body.action || ''); var manual = isManual(); var auto = !manual; var block; if (action === 'submit') block = CFG.blockSubmit; else if (auto) block = CFG.blockAuto; else block = CFG.blockSave; return { res: res, mode: block ? 'block' : 'warn', action: action, auto: auto }; } function makeStub(settings, verdict) { var stub = { readyState: 0, status: 0, statusText: 'blocked-by-lh6v', abort: function () {}, done: function () { return stub; }, fail: function (cb) { return stub; }, always: function (cb) { return stub; }, then: function () { return stub; } }; // 走一遍 error 回调,让调用方(commonAjax)自己收尾隐藏 loading setTimeout(function () { try { if (typeof settings.error === 'function') { settings.error({ status: 0, statusText: 'blocked', responseText: '' }, 'blocked', ''); } } catch (e) {} hideLoaders(); var $ = jq(); if ($) { try { $('#load').hide(); } catch (e) {} } }, 0); return stub; } function installAjaxHook() { if (ajaxPatched) return true; var $ = jq(); if (!$ || typeof $.ajax !== 'function') return false; var orig = $.ajax; var wrapped = function (url, options) { var self = this; var args = arguments; var settings; if (typeof url === 'string') settings = Object.assign({}, options, { url: url }); else settings = Object.assign({}, url); var v = inspect(settings); if (v && v.mode === 'block') { var res = v.res; hideLoaders(); if (CFG.locate) locateBadMarks(res.badIndexes); showBlockDialog(res, function () { // 强制保存:直接走原始 $.ajax,绕开本脚本的校验 orig.apply(self, args); }); return makeStub(settings, v); } if (v && v.mode === 'warn') { if (CFG.locate) locateBadMarks(v.res.badIndexes); toast('⚠ 自动保存:' + v.res.issues[0].text, 5000); } return orig.apply(this, arguments); }; $.ajax = wrapped; ajaxPatched = true; return true; } // 等 jQuery 出现(本页 jQuery 在 body 末尾、bundle 之前加载) var hookTimer = setInterval(function () { if (installAjaxHook()) { clearInterval(hookTimer); hookTimer = null; tick(true); } }, 50); setTimeout(function () { if (hookTimer) { clearInterval(hookTimer); hookTimer = null; } installAjaxHook(); }, 30000); // ──────────────────────────────────────────────────────────────────────────── // 7. 启动 // ──────────────────────────────────────────────────────────────────────────── function boot() { if (host) return; if (!document.body) return; // 等 body 出现 highlightCss(); buildPanel(); tick(true); setInterval(function () { if (rafPending) return; rafPending = true; requestAnimationFrame(function () { rafPending = false; tick(false); }); }, 450); } var bootTimer = setInterval(function () { boot(); if (host) { clearInterval(bootTimer); bootTimer = null; } }, 60); setTimeout(function () { if (bootTimer) { clearInterval(bootTimer); bootTimer = null; } boot(); }, 20000); // 供人工排查 window.__lh6v = { version: '1.0.1', cfg: CFG, classes: CLASSES.slice(), validate: validate, pnameOf: pnameOf, readLiveMarks: readLiveMarks, tick: function () { return tick(true); } }; })();