// ==UserScript== // @name 网格视图 · 整包标注物总览 + 类别修正 // @namespace dsh.panorama.gridview // @version 1.1.0 // @description 在标注平台上单独打开一个全屏"网格视图"页面:读取整包所有标注物,按类别分栏、三列网格展示;点击卡片可把标错的类别更正并回写。支持素珍系标注平台,其它平台可通过适配层接入。 // @author you // @match *://*/w/task.html* // @match *://*/w/task-angle.html* // @match *://*/w/task-check.html* // @match *://*/w/*task*.html* // @match *://*/*task*.html* // @run-at document-idle // @grant none // ==/UserScript== /* * ───────────────────────────────────────────────────────────────────────────── * 只读展示 + 定向更正 * ───────────────────────────────────────────────────────────────────────────── * 1) 不改平台自身的任何 js / css / 请求逻辑;全部 UI 放在独立 Shadow DOM 里, * 与平台样式完全隔离,关掉脚本对平台无任何影响。 * 2) 展示(整包标注物、类别统计、缩略图)纯只读。 * 3) 唯一写数据的动作是「保存更正」,且: * · 必须点按钮 + 二次确认; * · 只改你选中的那一个标注物的 class / attrs; * · 保存前逐字段做差异校验,任何计划外的字段变化 → 直接拦下不发请求; * · 该帧正在平台原生编辑器里且有未保存修改时 → 拒绝写入,先让你处理原生修改。 * * 用到的都是平台自己的接口(不新增任何后端依赖): * POST /api/task/get-mark-data 读单帧标注数据(平台自己在用) * POST /v2/tasks/mark-data 平台自己的保存接口(仅更正时调用) * 请求头 Access-Key 取 localStorage.token,与平台 commonAjax 一致。 * ───────────────────────────────────────────────────────────────────────────── */ (function () { 'use strict'; // ========================================================================= // 0. 常量 // ========================================================================= const NS = 'dsh-gridview'; const GRID_COLS = 3; // 三列 // 一页个数。必须是 GRID_COLS 的整数倍,键盘上下移动才能按“整行”对齐(15 = 3 列 × 5 行)。 const PAGE_SIZE = 15; const THUMB_W = 520; // 裁剪图渲染宽度(够宽才能填满网格列,不留黑边) const THUMB_H = 360; // 裁剪图渲染高度上限 const LOAD_CONCURRENCY = 4; const TYPE_LABEL = { rect: '矩形', point: '点', line: '线', polygon: '多边形', outline: '轮廓/分割', cuboid: '日形框', ellipse: '椭圆', curve: '曲线', parallel: '平行四边形', '3drect': '3D矩形', rect3d: '3D矩形', magic: '魔术棒', }; const CLOSED_TYPES = ['rect', 'ellipse', 'outline', 'polygon', 'magic', 'parallel', 'cuboid', '3drect', 'rect3d']; const log = (...a) => console.log('%c[网格视图]', 'color:#3498db;font-weight:bold', ...a); const warn = (...a) => console.warn('[网格视图]', ...a); /** 标注物的唯一键:一帧里的第几个 */ const markKey = (taskId, markIndex) => taskId + ':' + markIndex; // ========================================================================= // 1. 状态 // ========================================================================= const S = { open: false, loading: false, loaded: false, classFilter: '__ALL__', page: 1, keyword: '', selectedIndex: -1, // 当前页内选中的卡片下标(键盘导航用) savedKeys: new Set(), // 本次会话里由我改过并保存成功的标注物("taskId:markIndex") frames: [], // [{ task_id, pic_url, index }] marks: [], // 展平标注物 + __taskId / __frameIndex / __markIndex framePayload: {}, // taskId -> 原始 markData(原样透传) classConfig: [], properties: [], taskKey: '', editing: null, // 正在修正的标注物 drawerView: null, // 抽屉里的缩放查看器 stats: { frames: 0, ok: 0, fail: 0, cached: 0 }, }; // ========================================================================= // 2. 平台适配层 // ========================================================================= // // 换平台 / 换项目时,改动集中在这里: // · 怎么找到标注实例(instance) // · 读一帧标注数据的接口与参数 // · 保存的接口 // · 鉴权头怎么取 // · 类别配置在 params 的哪个字段 // // 检测按数组顺序进行,第一个 detect() 通过的胜出。 // 想加一个平台:照抄一份改 detect / 接口 / 字段名即可,其它代码不用动。 // --------------------------------------------------------------------------- /** 常见「标注实例」挂载点候选:key 路径 */ const INSTANCE_PATHS = [ ['InitCanvasInfo', 'canvasCommon'], // 素珍系:canvas-common 的实例 ['InitCanvasInfo', 'canvasNormal'], ['InitCanvasInfo', 'canvas_normal'], ['canvasCommon'], ['canvasNormal'], ['canvas_normal'], ['canvasObj'], ['annotationApp', 'canvas'], ['labelTool', 'core'], ['app', 'canvas'], ]; /** 从 window 上按路径取实例(带防御) */ function pickInstance() { for (const path of INSTANCE_PATHS) { try { let cur = window; for (const k of path) { cur = cur && cur[k]; if (!cur) break; } if (cur && cur.params && (cur.params.task_id || cur.params.frame || cur.params.task_key)) return cur; } catch (e) { /* ignore */ } } // 兜底:扫全局里「长得像标注实例」的对象(名字带 canvas/label/mark/annot) try { for (const k of Object.keys(window)) { if (!/canvas|label|mark|annot/i.test(k)) continue; const v = window[k]; if (v && typeof v === 'object' && v.params && (v.params.task_id || v.params.frame || v.params.task_key)) return v; } } catch (e) { /* ignore */ } return null; } const ADAPTERS = [ { id: 'suzhen', name: '素珍标注平台(suzhen-admin / task.html 系)', // 这个平台把实例挂在 window.InitCanvasInfo.canvasCommon 上,params 里带 task_key detect(inst) { if (!inst || !inst.params) return false; return !!(inst.params.task_key && (inst.params.frame || inst.params.id || inst.params.task_id)); }, // 类别配置在 params.properties(按 toolType 分组) classesFrom: (params) => params.properties, tokenKey: 'token', tokenHeader: 'Access-Key', hostFrom: (inst) => { try { return (inst.clickCommons && inst.clickCommons.host) || ''; } catch (e) { return ''; } }, markDataUrl: '/api/task/get-mark-data', markDataBody: (taskId, ctx) => ({ time: Date.now(), task_id: taskId, task_key: ctx.taskKey, is_preview: 0, }), saveUrl: '/v2/tasks/mark-data', }, // ↓↓↓ 加新平台就在这里加一条 ↓↓↓ ]; let ADAPTER = null; /** 找出当前页面属于哪个平台;找不到就返回 null */ function detectAdapter() { const inst = pickInstance(); if (!inst) return null; for (const a of ADAPTERS) { try { if (a.detect(inst)) return { adapter: a, inst }; } catch (e) { /* ignore */ } } return null; } /** 取标注实例(按已识别到的平台规则) */ function getInstance() { try { if (ADAPTER) { const inst = pickInstance(); if (inst) return inst; } return pickInstance(); } catch (e) { return null; } } function platformHost() { try { if (ADAPTER && ADAPTER.hostFrom) return ADAPTER.hostFrom(getInstance()) || ''; } catch (e) { /* ignore */ } return ''; } function getToken() { try { const key = (ADAPTER && ADAPTER.tokenKey) || 'token'; const t = localStorage.getItem(key); return t ? t.replace(/"/g, '') : ''; } catch (e) { return ''; } } /** 请求头:不同平台的鉴权头名字不一样 */ function authHeaders() { const h = { 'Content-Type': 'application/json' }; const name = (ADAPTER && ADAPTER.tokenHeader) || 'Access-Key'; const tok = getToken(); if (name && tok) h[name] = tok; return h; } /** 类别配置:不同平台字段名不同 */ function classesOf(params) { if (!params) return []; if (ADAPTER && ADAPTER.classesFrom) { const v = ADAPTER.classesFrom(params); if (Array.isArray(v)) return v; } // 兜底:把几个常见的字段名都试一遍 for (const k of ['properties', 'classes', 'classList', 'labels', 'labelConfig']) { if (Array.isArray(params[k])) return params[k]; } return []; } // ========================================================================= // 3. 接口层 // ========================================================================= /** * 判断接口是否成功 —— 必须和平台自己的写法一致:`data.code == 0`(松散比较)。 * * 平台源码里 20 多处都写 `data.code == 0`,另有 11 处写 `data.code == ''`。 * 关键点:JS 里 `"" == 0` 是 **true**,而服务端保存成功时返回的正是 * { code: "", data: { res: 1 }, msg: "" } * 如果用严格比较 `code === 0`,就会把「保存成功」当成「保存失败」报给用户。 */ function isOk(res) { if (!res) return false; // eslint-disable-next-line eqeqeq return res.code == 0; } async function api(url, data, method = 'POST') { const res = await fetch(platformHost() + url, { method, headers: authHeaders(), body: method === 'GET' ? undefined : JSON.stringify(data || {}), credentials: 'omit', }); const text = await res.text(); let json; try { json = JSON.parse(text); } catch (e) { throw new Error('接口返回不是 JSON(HTTP ' + res.status + '):' + text.slice(0, 160)); } if (json && (json.code === 400 || json.code === 'UserLoginError' || json.code === '400')) { throw new Error('登录态失效,请刷新页面重新登录。' + (json.msg || '')); } return json; } /** 读一帧标注数据(接口与参数按当前平台适配) */ const fetchFrameMarkData = (taskId) => { const url = (ADAPTER && ADAPTER.markDataUrl) || '/api/task/get-mark-data'; const body = ADAPTER && ADAPTER.markDataBody ? ADAPTER.markDataBody(taskId, { taskKey: S.taskKey }) : { time: Date.now(), task_id: taskId, task_key: S.taskKey, is_preview: 0 }; return api(url, body); }; /** 保存接口(按当前平台适配) */ const saveUrl = () => (ADAPTER && ADAPTER.saveUrl) || '/v2/tasks/mark-data'; // ========================================================================= // 4. 数据整形 // ========================================================================= function normalizeFrames(params) { let arr = params && params.frame; if (!Array.isArray(arr) || !arr.length) { const inst = getInstance(); const order = inst && inst.orderAxis; if (order && Array.isArray(order.frame)) arr = order.frame; } if (!Array.isArray(arr)) return []; return arr.map((f, i) => ({ task_id: f.task_id != null ? f.task_id : f.id, pic_url: f.pic_url || f.imgUrl || f.url || f.image || f.path || '', index: i, })).filter(f => f.task_id != null); } function flattenClassConfig(properties) { const out = []; if (!Array.isArray(properties)) return out; const seen = new Set(); for (const group of properties) { const walk = (list) => { if (!Array.isArray(list)) return; for (const c of list) { if (!c || !c.pname) continue; const key = group.toolType + '|' + c.pname; if (!seen.has(key)) { seen.add(key); out.push({ toolType: group.toolType, pname: c.pname, ptitle: c.ptitle || c.pname, pcode: c.pcode, color: c.color, preset_id: c.preset_id || '', is_instance: c.is_instance, attrs: c.attrs || [], }); } if (Array.isArray(c.pvalue)) walk(c.pvalue); } }; walk(group.class); } return out; } /** 某图形类型可选的类别(与平台属性面板同源:params.properties 按 toolType 分组) */ function classesForToolType(toolType) { const groups = S.properties.filter(p => p.toolType === toolType); const flat = []; const seen = new Set(); for (const g of groups) { const walk = (list) => (list || []).forEach(c => { if (!c || !c.pname || seen.has(c.pname)) return; seen.add(c.pname); flat.push({ pname: c.pname, ptitle: c.ptitle || c.pname, pcode: c.pcode, color: c.color, preset_id: c.preset_id || '', attrs: c.attrs || [], }); if (Array.isArray(c.pvalue)) walk(c.pvalue); }); walk(g.class); } if (flat.length) return flat; return S.classConfig.filter(c => c.toolType === toolType); } function classObj(mark) { const c = mark && mark.class; if (Array.isArray(c) && c.length) return c[c.length - 1]; if (c && typeof c === 'object') return c; return {}; } const classKey = (mark) => classObj(mark).pname || classObj(mark).ptitle || '__NONE__'; const classLabel = (mark) => classObj(mark).ptitle || classObj(mark).pname || '(未分类)'; function classColor(mark) { const c = classObj(mark); if (c.color && /^#/.test(c.color)) return c.color; const cfg = S.classConfig.find(x => x.pname === c.pname); if (cfg && cfg.color && /^#/.test(cfg.color)) return cfg.color; return '#3498db'; } /** 属性:value 可能是数组(多选/下拉),用 pvalue 映射成中文 */ function attrEntries(mark) { const a = mark && mark.attrs; const list = []; if (Array.isArray(a)) { for (const x of a) { const vals = Array.isArray(x.value) ? x.value : (x.value == null || x.value === '' ? [] : [x.value]); const titles = vals.map(v => { const hit = (x.pvalue || []).find(p => String(p.pname) === String(v)); return hit ? (hit.ptitle || hit.pname) : String(v); }); list.push({ pname: x.pname, ptitle: x.ptitle || x.pname, value: titles.join('、'), raw: x.value }); } } else if (a && typeof a === 'object') { for (const k of Object.keys(a)) { list.push({ pname: k, ptitle: k, value: String(a[k]), raw: a[k] }); } } return list; } function hydrateMark(raw) { const mk = Object.assign({}, raw); if (!Array.isArray(mk.class) && mk.class && typeof mk.class === 'object') mk.class = [Object.assign({}, mk.class)]; if (Array.isArray(mk.class)) { mk.class = mk.class.map(c => { const cfg = S.classConfig.find(x => x.pname === c.pname) || S.classConfig.find(x => x.ptitle === c.ptitle); return Object.assign({}, cfg ? { ptitle: cfg.ptitle, color: cfg.color, pcode: cfg.pcode } : {}, c); }); } return mk; } // ========================================================================= // 5. 整包加载 // ========================================================================= async function loadPackage() { const inst = getInstance(); if (!inst) throw new Error('没找到平台的标注实例,请确认当前在标注页且已登录。' + '如果这是另一个平台,需要在脚本的「平台适配层」里加一条适配(控制台有诊断信息)。'); if (!ADAPTER) { // 实例在但没匹配到适配器:把诊断打出来,方便接新平台 console.warn('[网格视图] 检测到标注实例但没有匹配的平台适配器,诊断信息:', adapterDiagnostics(inst)); throw new Error('这个平台还没有适配。已把诊断信息打到控制台(F12 → Console),' + '把它发给脚本作者即可加一条适配。'); } const params = inst.params || {}; S.taskKey = params.task_key || S.taskKey; S.properties = classesOf(params); S.classConfig = flattenClassConfig(S.properties); S.frames = normalizeFrames(params); if (!S.frames.length) throw new Error('没有解析到帧列表(params.frame 为空)。'); S.marks = []; S.framePayload = {}; S.savedKeys.clear(); // 「重新加载」会重新拉服务端数据,本次会话的改动标记随之清空 S.stats = { frames: S.frames.length, ok: 0, fail: 0, cached: 0 }; const failed = []; // 平台自带的缓存(批量/多图视图已经拉过的数据)能省掉一批请求 const caches = [inst.markDatas, inst.markDataCache, inst.markData].filter(c => c && typeof c === 'object'); const queue = S.frames.slice(); async function worker() { while (queue.length) { const f = queue.shift(); try { let md = null; for (const c of caches) { const hit = c[f.task_id]; if (hit && hit.markData) { md = hit.markData; break; } if (hit && Array.isArray(hit.marks)) { md = hit; break; } } if (md) S.stats.cached++; else { const res = await fetchFrameMarkData(f.task_id); if (isOk(res) && res.data && res.data.markData) md = res.data.markData; } if (md) { S.framePayload[f.task_id] = md; if (md.imgUrl) f.pic_url = md.imgUrl; (Array.isArray(md.marks) ? md.marks : []).forEach((m, i) => { S.marks.push(Object.assign({}, hydrateMark(m), { __taskId: f.task_id, __frameIndex: f.index, __markIndex: i, })); }); S.stats.ok++; } else { S.stats.fail++; failed.push(f.task_id); } } catch (e) { S.stats.fail++; failed.push(f.task_id); warn('帧加载失败', f.task_id, e); } paintProgress(); } } await Promise.all(Array.from({ length: Math.min(LOAD_CONCURRENCY, S.frames.length) }, worker)); if (failed.length) warn('以下帧加载失败:', failed.join(', ')); log(`加载完成:帧 ${S.stats.ok}/${S.stats.frames}(缓存命中 ${S.stats.cached}),标注物 ${S.marks.length} 个`); return S.marks; } // ========================================================================= // 6. 保存:只改这一个标注物,其余原样透传 // ========================================================================= /** * 按平台自己的规则清空属性值。 * * 平台在用户换类别时(.toolBarNumAddList 的 .class-item 事件)是这么做的: * mark.attrs = 新类别配置里的 attrs ← 整组替换,旧类别的属性直接丢掉 * newAttr.map(item => item.value = []) ← 值清空,等用户重填 * 所以“改了类别还留着旧属性”在平台里根本不会发生,这里完全对齐。 */ function clearedAttrs(attrs) { if (Array.isArray(attrs)) { return attrs.map(a => Object.assign({}, a, { value: [] })); } if (attrs && typeof attrs === 'object') { const o = {}; for (const k of Object.keys(attrs)) o[k] = ''; return o; } return []; } /** 该类别在配置里有没有属性 */ const classHasAttrs = (cfg) => !!(cfg && Array.isArray(cfg.attrs) && cfg.attrs.length); /** * 把 {pname: value} 填进属性集合(数组态或扁平对象态都支持)。 * 只填「本来就存在的属性」,不新增字段 —— 免得写出平台不认识的东西。 */ function fillAttrs(attrs, fill) { if (Array.isArray(attrs)) { return attrs.map(a => (Object.prototype.hasOwnProperty.call(fill, a.pname) ? Object.assign({}, a, { value: fill[a.pname] }) : a)); } const o = Object.assign({}, attrs || {}); for (const k of Object.keys(fill)) { if (Object.prototype.hasOwnProperty.call(o, k)) o[k] = fill[k]; } return o; } const attrValEmpty = (v) => v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0); /** * 把一个标注物转成「保存态」,与平台 getSubClassMarksData 的输出一致: * class: 取类别数组的最后一级 → { pname, preset_id } * attrs: 数组形式 {pname,ptitle,value,...}[] → 扁平 { pname: value } * 已经是保存态(class 是对象、attrs 是对象)的原样返回。 */ function toSavedMark(src) { const c = Array.isArray(src.class) ? (src.class[src.class.length - 1] || {}) : (src.class || {}); return Object.assign({}, src, { class: { pname: c.pname || '', preset_id: c.preset_id || '' }, attrs: Array.isArray(src.attrs) ? src.attrs.reduce((acc, v) => { acc[v.pname] = v.value; return acc; }, {}) : (src.attrs || {}), }); } /** * 复刻平台保存前的「类别属性校验」(submitClick 里那段): * 1) mark.pselect == "请选择" → 未选类别 * 2) 任一属性值为空 → 属性没填完(getResultData 输出的 marks + postils 都要查) * * 平台的校验跑在它自己内存里的整份标注上,所以我们也要查整帧(marks + postils)。 * 注意:本函数只**报告**问题,是否因此阻止保存由调用方决定 —— * 服务端查不查这一项我们没有确证,所以当前策略是「只警告不拦」(见 buildPatchPayload)。 * * @param {Array} marks 该帧的 marks(存档态:class 为对象、attrs 为扁平表) * @param {Array} [postils] 该帧的 postils(同样是存档态) * @returns {string[]} 问题描述,空数组表示全部合格 */ function validateMarksForSave(marks, postils) { const problems = []; const check = (list, head, tail) => { (list || []).forEach((m, i) => { const c = Array.isArray(m.class) ? (m.class[m.class.length - 1] || {}) : (m.class || {}); const clsName = c.ptitle || c.pname || ''; const who = head + (i + 1) + tail + (clsName ? '(' + clsName + ')' : ''); if (!c.pname && !c.ptitle) { problems.push(who + ':没有类别'); return; } if (m.pselect === '请选择') { problems.push(who + ':类别还是「请选择」'); return; } const attrs = Array.isArray(m.attrs) ? m.attrs.map(a => ({ pname: a.pname, ptitle: a.ptitle, value: a.value })) : Object.keys(m.attrs || {}).map(k => ({ pname: k, ptitle: k, value: m.attrs[k] })); const empty = attrs.filter(a => attrValEmpty(a.value)); if (empty.length) { problems.push(who + ':属性未填 —— ' + empty.map(a => (a.ptitle || a.pname)).join('、')); } }); }; check(marks, '第 ', ' 个标注物'); check(postils, '分割结果 ', ''); return problems; } /** * 类别的中文名。存档态(get-mark-data 回包)里 class 往往只有 {pname}, * 没有 ptitle,所以要回到类别配置里查 —— 否则确认弹窗会显示 pname 这种内部代码。 */ function classTitleOf(mark) { const c = classObj(mark); if (c.ptitle) return c.ptitle; const pname = c.pname; if (!pname) return '(未分类)'; const flat = S.classConfig.find(x => x.pname === pname); if (flat && flat.ptitle) return flat.ptitle; for (const g of S.properties) { for (const k of (g.class || [])) { if (k.pname === pname) return k.ptitle || k.pname; for (const sub of (k.pvalue || [])) { if (sub.pname === pname) return sub.ptitle || sub.pname; } } } return pname; } function buildPatchPayload(mark, change) { const taskId = mark.__taskId; const md = S.framePayload[taskId]; if (!md) throw new Error('缺少该帧的原始数据,请点「重新加载」。'); const raw = md.marks[mark.__markIndex]; if (!raw) throw new Error('该帧数据结构已变化,请点「重新加载」。'); const next = JSON.parse(JSON.stringify(raw)); const diff = []; // 6.1 类别(保存格式 {pname, preset_id}) if (change.classPname && change.classPname !== classKey(raw)) { const cfg = classesForToolType(raw.type).find(c => c.pname === change.classPname) || S.classConfig.find(c => c.pname === change.classPname); if (!cfg) throw new Error('目标类别不在当前项目的类别配置里,已阻止保存。'); next.class = { pname: cfg.pname, preset_id: cfg.preset_id || '' }; // 差异文案里用中文标题,方便确认(存档态只有 pname,要去类别配置里查标题) diff.push('类别:' + classTitleOf(raw) + ' → ' + (cfg.ptitle || cfg.pname)); // 与平台一致:属性整组换成新类别的配置,旧类别的属性不再保留 const oldAttrs = attrEntries(raw); next.attrs = clearedAttrs(cfg.attrs); // 若调用方已经知道新属性该填什么(change.attrsFill),就用它填上 —— // 否则留空,而留空的属性会被「属性完整性拦截」挡下(平台自己也一样挡)。 if (change.attrsFill && typeof change.attrsFill === 'object') { next.attrs = fillAttrs(next.attrs, change.attrsFill); } if (oldAttrs.length) { diff.push('属性:删除旧类别的 ' + oldAttrs.length + ' 项(' + oldAttrs.map(a => a.ptitle).join('、') + ')'); } const newNames = (cfg.attrs || []).map(a => a.ptitle || a.pname); if (newNames.length) { diff.push('属性:切换为 ' + (cfg.ptitle || cfg.pname) + ' 的 ' + newNames.length + ' 项(' + newNames.join('、') + '),值留空待填'); } // 注意:不再改写 color / pselect —— 它们都是由 class 推导出来的显示字段, // 平台加载时会自己按新 class 重算。少改一个字段就少一分写坏数据的风险, // 差异校验也只放行 class / attrs。 } // 6.2 属性:本版本不开放编辑,change.attrs 恒为空 if (change.attrs && Object.keys(change.attrs).length) { throw new Error('本版本不支持直接修改属性值。'); } // 6.3 安全校验:除 class / attrs,任何字段都不许变 const rawKeys = Object.keys(raw), nextKeys = Object.keys(next); const keyDiff = rawKeys.filter(k => !nextKeys.includes(k)).concat(nextKeys.filter(k => !rawKeys.includes(k))); if (keyDiff.length) throw new Error('字段集合发生变化(' + keyDiff.join(', ') + '),已阻止保存。'); for (const k of rawKeys) { if (k === 'class' || k === 'attrs') continue; if (JSON.stringify(raw[k]) !== JSON.stringify(next[k])) throw new Error('非预期字段被改动:' + k + ',已阻止保存。'); } if (!diff.length) throw new Error('没有任何改动。'); // 6.4 组装保存体 —— 严格对齐平台 submitClick 里的 inputData: // inputData = Object.assign(submitData, resultData) // inputData.task_id / work_type / access / use_time / status ← 缺一个服务端就报 // “参数错误,xxx为空”(曾经漏了 work_type,保存直接失败) // resultData = { markData: {imgUrl,width,height,marks,postils,totalNums}, task_id, time, mark_status } const marks = md.marks.map((m, i) => toSavedMark(i === mark.__markIndex ? next : m)); const markData = { imgUrl: md.imgUrl, marks, postils: [], totalNums: countTypes(marks), rotateDeg: md.rotateDeg || 0, }; if (md.width != null) markData.width = md.width; if (md.height != null) markData.height = md.height; if (md.imagePath) markData.imagePath = md.imagePath; // global(单题属性/类别)原样带上,否则保存会把单题属性抹掉 if (md.global) markData.global = md.global; const P = instParams(); const payload = { action: 'save', time: Date.now(), markData, // 注意 task_id 必须是「这一帧」的 task_id —— 不能拿 params.task_id 覆盖: // 那是平台当前打开的那一帧,用户切帧后会变。用它去写别的帧,服务端会因为 // 帧与数据对不上而拒绝保存。 task_id: taskId, work_type: P.work_type, // ← 必须,缺了服务端直接拒绝 access: P.access, use_time: platformUseTime(), mark_status: 0, }; // 这几个是平台按需带上、服务端不一定校验,但带上更贴近真实请求 if (P.status != null && P.status !== '') payload.status = P.status; if (P.is_random != null && P.is_random !== '') payload.is_random = P.is_random; if (P.package_id != null && P.package_id !== '') payload.package_id = P.package_id; if (S.taskKey || P.task_key) payload.task_key = P.task_key || S.taskKey; // 必填项自检:宁可在本地报清楚,也不要发出去被服务端打回 const missing = ['work_type', 'access', 'task_id'] .filter(k => !payload[k] && payload[k] !== 0); if (missing.length) { throw new Error('缺少请求参数:' + missing.join('、') + '(请刷新页面后重试;这些参数由平台从网址里读取)'); } // 属性完整性:只**警告**,不拦。 // // 这条规则抄自平台客户端源码(平台保存前会检查「属性是否填完」,没填完就弹 // 「保存失败,请完善属性类别」并且**根本不发请求**)。 // 但服务端到底查不查这一项,我们没有确证 —— 之前那个「保存失败」其实是我把 // 成功应答 (code:"") 判成了失败,跟属性无关。 // 所以不再硬拦(硬拦会挡住本来能成功的操作),改成提示 + 万一真失败时给出归因。 const warnings = validateMarksForSave(marks, md.postils); return { payload, diff, warnings }; } /** 平台 params 的只读快照(保存时要用它的 work_type / access / status 等) */ function instParams() { const inst = getInstance(); return (inst && inst.params) || {}; } /** * 平台自己统计的作答耗时(秒)。submitClick 发的是 this.timeAll, * 由 clickCommon.initTimerAll() 每秒 +1。拿不到就退回 0(不编造数字)。 */ function platformUseTime() { try { const inst = getInstance(); const cands = [ inst && inst.clickCommons && inst.clickCommons.timeAll, inst && inst.canvasComponents && inst.canvasComponents.timeAll, inst && inst.timeAll, ]; for (const v of cands) { const n = Number(v); if (Number.isFinite(n) && n > 0) return Math.round(n); } } catch (e) { /* ignore */ } return 0; } function countTypes(marks) { const t = { line: 0, polygon: 0, point: 0, rect: 0, ellipse: 0, '3drect': 0, magic: 0, parallel: 0, cuboid: 0, curve: 0, outline: 0 }; marks.forEach(m => { t[m.type] = (t[m.type] || 0) + 1; }); return t; } /** 标注物指纹:类别 + 属性 + 类型 + 唯一标识(兼容「界面态」和「存档态」两种格式) */ function markSignature(m) { const c = Array.isArray(m.class) ? (m.class[m.class.length - 1] || {}) : (m.class || {}); const cls = c.pname || c.ptitle || ''; let attrs = ''; if (Array.isArray(m.attrs)) { attrs = m.attrs.map(a => a.pname + '=' + JSON.stringify(a.value)).sort().join(','); } else if (m.attrs && typeof m.attrs === 'object') { attrs = Object.keys(m.attrs).map(k => k + '=' + JSON.stringify(m.attrs[k])).sort().join(','); } return (m.type || '') + '#' + cls + '#' + attrs + '#' + (m.objectId || 0); } function isCurrentPlatformFrame(taskId) { try { const inst = getInstance(); return !!(inst && String(inst.params.task_id) === String(taskId)); } catch (e) { return false; } } /** * 该帧是否正在平台原生编辑器里、且有「原生未保存改动」。 * * 平台内部用自己的 md5 判断是否已修改,这里不依赖它的私有函数,改用逐条比对。 * 关键:**我自己刚保存过的标注物要排除在外**。 * 因为从网格视图保存是直接写服务端,平台画布不会自动重载 —— 它手里还是旧数据, * 天然就和 S.framePayload 不一致。早期版本用「排序后的整体指纹」比对, * 分不清是谁造成的,于是用户刚改完就再也改不回去了(本次修的就是这个)。 */ function nativeFrameIsDirty(taskId) { try { const inst = getInstance(); const cc = inst && inst.canvasComponents; if (!cc || !cc.bg_img || !Array.isArray(cc.bg_img.marks)) return false; if (!isCurrentPlatformFrame(taskId)) return false; // 不是当前正在编辑的那一帧 const md = S.framePayload[taskId]; if (!md || !Array.isArray(md.marks)) return false; const live = cc.bg_img.marks; // 数量对不上 → 平台上新增/删除了标注物,确实有原生改动 if (live.length !== md.marks.length) return true; for (let i = 0; i < live.length; i++) { if (markSignature(live[i]) === markSignature(md.marks[i])) continue; // 差异正好出在「我这次保存过的」标注物上 → 是我造成的,不算原生改动 if (S.savedKeys.has(markKey(taskId, i))) continue; return true; } return false; } catch (e) { return false; } } async function saveCorrection(mark, change) { const { payload, diff } = buildPatchPayload(mark, change); // 出错时要把「到底哪一帧、改了什么、服务端回了什么」留在控制台,方便排查 console.log('%c[网格视图] 保存请求', 'color:#3498db', { url: platformHost() + saveUrl(), task_id: payload.task_id, payload }); let res; try { res = await api(saveUrl(), payload, 'POST'); } catch (e) { console.error('[网格视图] 保存请求异常', e); throw e; } if (isOk(res)) { // 本地缓存同步成刚提交的那份(存档格式:class 为对象、attrs 为扁平表) S.framePayload[mark.__taskId].marks[mark.__markIndex] = payload.markData.marks[mark.__markIndex]; return diff; } // 服务端拒绝:把完整应答打出来,并且不要把 "保存失败: 保存失败" 这种废话丢给用户 console.error('[网格视图] 保存被服务端拒绝', res); const msg = (res && res.msg) ? String(res.msg) : ''; const code = (res && res.code !== undefined) ? String(res.code) : '?'; const detail = (msg && msg !== '保存失败') ? msg : ''; const err = new Error(detail ? detail + '(code=' + code + ',详见控制台)' : '服务端拒绝了这次写入(code=' + code + ')。已把完整应答打到控制台,按 F12 → Console 看「保存被服务端拒绝」。'); err.response = res; throw err; } // ========================================================================= // 7. 缩略图 // ========================================================================= function pointsOf(mark) { const pts = []; const push = p => { if (p && typeof p.x === 'number' && typeof p.y === 'number') pts.push([p.x, p.y]); }; switch (mark.type) { case 'point': push(mark.point); break; case 'outline': case 'polygon': case 'magic': (Array.isArray(mark.point) ? mark.point : (mark.points || [])).forEach(push); break; case 'rect': case 'ellipse': case 'cuboid': case 'line': case 'curve': case 'parallel': case 'rect3d': case '3drect': if (Array.isArray(mark.point)) mark.point.forEach(push); else if (Array.isArray(mark.points)) mark.points.forEach(push); else if (Array.isArray(mark.finshPoints)) mark.finshPoints.forEach(push); break; default: (mark.point || mark.points || []).forEach(push); } if (!pts.length && mark.x != null && mark.width != null) { pts.push([mark.x, mark.y], [mark.x + mark.width, mark.y + mark.height]); } if (!pts.length && mark.rect && typeof mark.rect === 'object' && mark.rect.x != null) { const r = mark.rect; pts.push([r.x, r.y], [r.x + (r.width || 0), r.y + (r.height || 0)]); } return pts; } const holesOf = function (mark) { let h = []; if (Array.isArray(mark.gaps)) h = h.concat(mark.gaps); if (Array.isArray(mark.inside_points) && mark.inside_points.length > 2) h.push(mark.inside_points); return h .map(g => (g || []).map(p => [p.x, p.y]).filter(q => Array.isArray(q) && q.length === 2 && isFinite(q[0]) && isFinite(q[1]))) .filter(g => g.length > 2); }; /** 把 v 夹在 [lo, hi] 之间(hi < lo 时返回 lo,用于宽度不够的窄卡片) */ function clamp3(lo, v, hi) { if (!(hi > lo)) return lo; return Math.max(lo, Math.min(v, hi)); } /** 一个标注物的全部几何点:外轮廓 + 洞。裁剪时全部纳入,避免漏掉洞或 3D 框的另一面。 */ function allPointsOf(mark) { return pointsOf(mark).concat(...holesOf(mark)); } /** * 精确裁剪框:该标注物(含洞)的外接矩形,等比例外扩后吸附到图片范围内的目标长宽比。 * * 两步都在“裁剪框”空间里做,所以裁剪框的长宽比是精确的 → 卡片不会有黑边; * 同时保证裁剪框始终完整包含整个标注物(长条、贴边都不会被切掉),且不会越出图片。 * @returns {{x,y,w,h}|null} 原图像素坐标 */ function cropBoxOf(mark, img) { const pts = allPointsOf(mark); if (!pts.length) return null; let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity; for (const [x, y] of pts) { if (x < x0) x0 = x; if (y < y0) y0 = y; if (x > x1) x1 = x; if (y > y1) y1 = y; } // 标注物自身的外接矩形(保证包含它) const mw = Math.max(x1 - x0, 1), mh = Math.max(y1 - y0, 1); // 目标长宽比:跟随裁剪框自己的长宽比,所以四边留白一致 const pad = Math.max(mw, mh) * 0.18 + 6; const bw = mw + pad * 2, bh = mh + pad * 2; const AR = bw / bh; const iw = (img && img.naturalWidth) || 0; const ih = (img && img.naturalHeight) || 0; if (!iw || !ih) return { x: x0 - pad, y: y0 - pad, w: bw, h: bh }; // 标注物几乎铺满整张图(全景分割常见)→ 直接用整图,否则长宽比会被图片边界挤变形 if (mw >= iw * 0.95 && mh >= ih * 0.95) return { x: 0, y: 0, w: iw, h: ih }; /** * 从“含留白的裁剪框”出发,只在装不下时才等比例缩小到图片能容纳的极限, * 然后以标注物中心居中、贴边时平移回图片内。 * 关键:不能反过来从整图缩到框内,否则一个中等大小的标注物会被扩成整帧,卡片就白做了。 */ let w = bw, h = bh; if (w > iw || h > ih) { const fit = Math.min(iw / w, ih / h); w *= fit; h *= fit; } // 兜底:极端图幅下仍要装住标注物 if (w < mw) { w = mw; h = w / AR; } if (h < mh) { h = mh; w = h * AR; } if (w > iw) { w = iw; h = w / AR; } if (h > ih) { h = ih; w = h * AR; } const cx = (x0 + x1) / 2, cy = (y0 + y1) / 2; const bx = clamp3(0, cx - w / 2, iw - w); const by = clamp3(0, cy - h / 2, ih - h); return { x: bx, y: by, w, h }; } const imgCache = new Map(); // url -> Promise function loadImage(url) { if (!url) return Promise.resolve(null); if (imgCache.has(url)) return imgCache.get(url); const p = new Promise(resolve => { const img = new Image(); // 不设置 crossOrigin:图片常来自另一个域(独立的对象存储), // 加了反而会被 CORS 拦掉。这里只画不读像素,画布被污染没有影响。 img.onload = () => resolve(img); img.onerror = () => resolve(null); img.src = url; }); imgCache.set(url, p); return p; } // ── 图片地址前缀 ──────────────────────────────────────────────────────── // 不同部署 / 不同对象存储下,markData.imgUrl 可能是**相对路径**,需要拼上主机 // (平台自己也会这么做,比如给某个部署拼 "http://xxx:8036")。 // 这里不写死任何域名,而是**从平台自己当前加载的那张图反推前缀**: // 浏览器把 的 src 解析成了绝对地址,而平台记录的原样地址是相对的, // 两者相减就是前缀。推不出来就当作不需要前缀。 let IMG_PREFIX = ''; let imgPrefixTried = false; function learnImagePrefix() { if (imgPrefixTried) return IMG_PREFIX; imgPrefixTried = true; try { const inst = getInstance(); const cc = inst && inst.canvasComponents; const el = (cc && cc.bg_img && cc.bg_img.image) || null; const abs = el && el.src; // 已解析成绝对地址 const raw = (cc && cc.imgName) || ''; // 平台记录的原样地址 if (abs && raw && abs !== raw && abs.slice(-raw.length) === raw) { IMG_PREFIX = abs.slice(0, abs.length - raw.length); log('图片地址前缀(从平台自己的图反推):' + JSON.stringify(IMG_PREFIX)); } } catch (e) { /* 推不出来就不加前缀 */ } return IMG_PREFIX; } /** 一张图可能对应的地址(按优先级),逐个试到能加载为止 */ function imageCandidates(url) { if (!url) return []; const list = [url]; const isAbs = /^(https?:)?\/\//i.test(url) || /^data:/i.test(url) || /^blob:/i.test(url); if (!isAbs) { const pre = learnImagePrefix(); if (pre) { // 前缀末尾与路径开头可能都带 '/',避免拼出双斜杠 const joined = pre.replace(/\/+$/, '') + '/' + url.replace(/^\/+/, ''); if (joined !== url) list.push(joined); } // 再兜一个「相对本站根」的写法(imgUrl 是站点根目录下的路径时) try { const base = (typeof location !== 'undefined' && location.href) || ''; if (base && typeof URL === 'function') { const u = new URL(url, base).href; if (!list.includes(u)) list.push(u); } } catch (e) { /* ignore */ } } return list; } /** 加载某帧的图片:地址是相对路径时会自动试出正确的前缀 */ async function loadFrameImage(url) { const cands = imageCandidates(url); for (const c of cands) { const img = await loadImage(c); if (img && img.naturalWidth) return img; } if (url) warn('图片加载失败,试过的地址:', cands); return null; } /** * 把裁剪框内的图 + 该标注物画到 canvas 上(画布尺寸按裁剪框长宽比自适应)。 * @returns {{src:{x,y,w,h}, scale:number}|null} 用于缩放平移的初始视图 */ function drawCroppedThumb(canvas, img, mark, opts) { const o = opts || {}; const maxW = o.maxW || THUMB_W; const maxH = o.maxH || THUMB_H; const ctx = canvas.getContext('2d'); if (!img) { canvas.width = maxW; canvas.height = Math.round(maxW * 0.66); ctx.fillStyle = '#0b1016'; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = '#4a5a6d'; ctx.font = '13px "Microsoft YaHei",sans-serif'; ctx.fillText('图片未加载', 12, 24); return null; } const iw = img.naturalWidth, ih = img.naturalHeight; if (!iw || !ih) return null; const box = cropBoxOf(mark, img) || { x: 0, y: 0, w: iw, h: ih }; // 画布按裁剪框长宽比定尺寸,所以图片能铺满整个卡片,没有黑边 let cw = maxW; let ch = Math.round(cw * box.h / box.w); if (ch > maxH) { ch = maxH; cw = Math.round(ch * box.w / box.h); } cw = Math.max(80, cw); ch = Math.max(60, ch); canvas.width = cw; canvas.height = ch; const sc = Math.min(cw / box.w, ch / box.h); const c = { x: cw / 2, y: ch / 2 }; // 裁剪框中心画在画布中心 const X = (px) => c.x + (px - (box.x + box.w / 2)) * sc; const Y = (py) => c.y + (py - (box.y + box.h / 2)) * sc; ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; ctx.fillStyle = '#0b1016'; ctx.fillRect(0, 0, cw, ch); ctx.drawImage(img, box.x, box.y, box.w, box.h, X(box.x), Y(box.y), box.w * sc, box.h * sc); const color = classColor(mark); const pts = pointsOf(mark).map(([x, y]) => [X(x), Y(y)]); const holes = holesOf(mark).map(g => g.map(([x, y]) => [X(x), Y(y)])); if (!pts.length) return { src: box, scale: sc, canvas: { w: cw, h: ch } }; const lw = Math.max(1.4, 2.2 * sc); // 线宽随缩放走,避免大图上细得看不见 ctx.fillStyle = hexToRgba(color, 0.32); ctx.strokeStyle = color; ctx.lineWidth = lw; ctx.lineJoin = 'round'; ctx.beginPath(); if (mark.type === 'point') { ctx.arc(pts[0][0], pts[0][1], Math.max(5, cw * 0.035), 0, Math.PI * 2); } else { ctx.moveTo(pts[0][0], pts[0][1]); for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i][0], pts[i][1]); if (CLOSED_TYPES.includes(mark.type)) ctx.closePath(); } try { ctx.fill(); } catch (e) { /* ignore */ } ctx.stroke(); // 洞:挖掉并描边,这样带空洞的分割结果也能看清 holes.forEach(h => { ctx.beginPath(); ctx.moveTo(h[0][0], h[0][1]); for (let i = 1; i < h.length; i++) ctx.lineTo(h[i][0], h[i][1]); ctx.closePath(); ctx.fillStyle = 'rgba(11,16,22,0.72)'; ctx.fill(); ctx.strokeStyle = hexToRgba(color, 0.75); ctx.lineWidth = Math.max(1, lw * 0.7); ctx.stroke(); }); // 类别名(放在标注物上方,越界时回落) const label = classLabel(mark); const fs = Math.max(11, Math.min(16, cw * 0.06)); ctx.font = 'bold ' + fs + 'px "Microsoft YaHei",sans-serif'; const tw = ctx.measureText(label).width; const bh = fs + 7; const bx = clamp3(3, pts[0][0] - 3, cw - tw - 15); const by = Math.max(3, pts[0][1] - bh - 4); ctx.fillStyle = hexToRgba(color, 0.94); ctx.beginPath(); if (ctx.roundRect) ctx.roundRect(bx, by, tw + 12, bh, 4); else ctx.rect(bx, by, tw + 12, bh); ctx.fill(); ctx.fillStyle = '#fff'; ctx.fillText(label, bx + 6, by + bh - 5); return { src: box, scale: sc, canvas: { w: cw, h: ch } }; } const FULL_MAX_W = 1680; const FULL_MAX_H = 1180; /** * 整图模式:把**整张图片**画进 canvas(按图片自身长宽比,contain)。 * 用于放大页和抽屉预览 —— 用户缩小到一定程度就能看到全貌,而不是只有那一小块裁剪。 * 画布里只画标注物轮廓(不压暗背景、不写类别名),读到的是图本身。 * @returns {{canvasW, canvasH, imgRect:{x,y,w,h}, scale:number}|null} * imgRect = 图片在画布里的摆放位置(原图像素 → 画布坐标需要 scale) */ function drawImageFull(canvas, img, mark, opts) { const o = opts || {}; const maxW = o.maxW || FULL_MAX_W; const maxH = o.maxH || FULL_MAX_H; const ctx = canvas.getContext('2d'); const iw = img.naturalWidth, ih = img.naturalHeight; if (!iw || !ih) return null; // 别让小图被放大成糊的;大图按可用空间缩到能看清 const sc = Math.min(1, maxW / iw, maxH / ih); const cw = Math.max(1, Math.round(iw * sc)); const ch = Math.max(1, Math.round(ih * sc)); canvas.width = cw; canvas.height = ch; ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; ctx.fillStyle = '#0b1016'; ctx.fillRect(0, 0, cw, ch); const imgRect = { x: 0, y: 0, w: iw, h: ih }; ctx.drawImage(img, imgRect.x, imgRect.y, iw, ih, 0, 0, cw, ch); const X = (px) => (px - imgRect.x) * sc; const Y = (py) => (py - imgRect.y) * sc; const pts = pointsOf(mark).map(([x, y]) => [X(x), Y(y)]); const holes = holesOf(mark).map(g => g.map(([x, y]) => [X(x), Y(y)])); if (pts.length) { const color = classColor(mark); const lw = Math.max(1.4, 2.2 * sc); ctx.fillStyle = hexToRgba(color, 0.3); ctx.strokeStyle = color; ctx.lineWidth = lw; ctx.lineJoin = 'round'; ctx.beginPath(); if (mark.type === 'point') { ctx.arc(pts[0][0], pts[0][1], Math.max(5, cw * 0.012), 0, Math.PI * 2); } else { ctx.moveTo(pts[0][0], pts[0][1]); for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i][0], pts[i][1]); if (CLOSED_TYPES.includes(mark.type)) ctx.closePath(); } try { ctx.fill(); } catch (e) { /* ignore */ } ctx.stroke(); holes.forEach(h => { ctx.beginPath(); ctx.moveTo(h[0][0], h[0][1]); for (let i = 1; i < h.length; i++) ctx.lineTo(h[i][0], h[i][1]); ctx.closePath(); ctx.fillStyle = 'rgba(11,16,22,0.6)'; ctx.fill(); }); } return { canvasW: cw, canvasH: ch, imgRect, scale: sc }; } /** * 可缩放 / 可拖动的查看器。 * * 两个模式: * focus(默认)—— 画布就是裁剪框那一块,标注物充满视野,看得最清; * full —— 画布是整张图,能看到全貌。 * 缩小到一定程度会自动切到 full(用户想看的“全貌”一缩就有),再放大一点切回 focus。 */ class Viewer { constructor(canvas, stage) { this.canvas = canvas; this.stage = stage || null; this.img = null; this.mark = null; this.opts = { maxW: THUMB_W, maxH: THUMB_H }; this.mode = 'focus'; this.view = null; this.k = 1; this.tx = 0; this.ty = 0; this._bound = false; this._onWheel = this._onWheel.bind(this); this._onDown = this._onDown.bind(this); this._onMove = this._onMove.bind(this); this._onUp = this._onUp.bind(this); this._dbl = this._dbl.bind(this); } /** 载入内容并重置视图 */ load(img, mark, opts) { this.img = img; this.mark = mark; if (opts) this.opts = opts; this.render('focus', true); this.bind(); return this; } /** 按当前模式重绘;resetView=true 时把缩放平移重置为「适应」 */ render(mode, resetView) { if (!this.img || !this.mark) return; if (mode) this.mode = mode; const o = this.opts || {}; if (this.mode === 'full') { const info = drawImageFull(this.canvas, this.img, this.mark, o); this.fullInfo = info; this.view = null; } else { this.view = drawCroppedThumb(this.canvas, this.img, this.mark, { maxW: o.maxW || THUMB_W, maxH: o.maxH || THUMB_H, }); } if (resetView) { this.k = 1; this.tx = 0; this.ty = 0; this.apply(); // 整图模式 = 整张图完整居中;聚焦模式 = 裁剪框铺满、标注物居中 if (this.mode === 'full') this.fitFull(); this.centerInView(); } else { this.apply(); } } /** 整图撑满视野、居中(不放大) */ fitFull() { const info = this.fullInfo; if (!info) return; const cw = this.canvas.width, ch = this.canvas.height; const iw = info.imgRect.w * info.scale, ih = info.imgRect.h * info.scale; this.k = clamp3(0.05, Math.min(cw / iw, ch / ih) * 0.96, 60); this.tx = (cw - iw * this.k) / 2; this.ty = (ch - ih * this.k) / 2; this.apply(); } reset() { this.k = 1; this.tx = 0; this.ty = 0; this.apply(); this.centerInView(); // 画布比可视区小时居中,不然会贴在左上角 } apply() { this.canvas.style.transform = 'translate(' + this.tx + 'px,' + this.ty + 'px) scale(' + this.k + ')'; this.viewState = { k: this.k, tx: this.tx, ty: this.ty, w: this.canvas.width, h: this.canvas.height }; } _cursor(c) { this.canvas.style.cursor = c; if (this.stage) this.stage.style.cursor = c; } bind() { if (this._bound) return; this._bound = true; const cv = this.canvas; cv.style.touchAction = 'none'; this._cursor('grab'); cv.addEventListener('wheel', this._onWheel, { passive: false }); cv.addEventListener('pointerdown', this._onDown); cv.addEventListener('pointermove', this._onMove); cv.addEventListener('pointerup', this._onUp); cv.addEventListener('pointercancel', this._onUp); cv.addEventListener('dblclick', this._dbl); } /** * 缩放:改缩放倍率,然后**把画面摆回可视区正中**。 * * 用户的原话:「我放大缩小它固定在中间,但是我拖动的时候可以随意拖动」。 * 所以这里就两件事 —— 缩放 + 居中,没有锚点换算、没有夹取: * · 缩放后必然居中(这就是「固定在中间」) * · 拖动时完全不动画面(见 _onMove / _onUp) * 少一个环节就少一类「画面顺着一角跑掉」的可能。 */ zoomCenter(factor) { const oldK = this.k; const newK = Math.min(60, Math.max(0.05, oldK * factor)); if (newK === oldK) return; this.k = newK; this.apply(); this.centerInView(); // ← 缩放后固定回中间 this.maybeToggleMode(factor < 1 ? 'out' : 'in'); } /** * 自动切模式 —— 只保留「缩小 → 看整图」这一个方向。 * * 放大方向**不再自动切聚焦**:整图上放大就是一点一点放大整图, * 想聚焦到标注物请点「聚焦标注物」按钮(用户明确要求,自动切会让人莫名其妙)。 */ maybeToggleMode(dir) { const before = this.mode; if (dir === 'out' && this.k < 0.7 && this.mode === 'focus') { this.render('full', true, 'fitFull'); } if (this.mode !== before && typeof this.onModeChange === 'function') this.onModeChange(this.mode); } /** * 可见区(stage)的屏幕矩形;**没有 stage 就返回 null**。 * 拿不到可见区时不做任何夹取 —— 宁可自由平移,也不要按错误的范围把画面钉死。 */ _viewRect() { const st = this.stage; if (st && st.getBoundingClientRect) { const r = st.getBoundingClientRect(); if (r.width && r.height) return r; } return null; } /** * 把画面摆到可视区正中(**只居中,不夹取**)。 * * 只在「打开 / 重置 / 切换模式」时调用 —— 让初始状态是正的。 * 缩放和拖动过程中都不调用它:用户明确要求「想怎么拖就怎么拖」, * 任何自动纠正都会被感受成「拖不动 / 被拽回去」。 * * 位置一律从 getBoundingClientRect() 现读(它里面已经含了 tx/ty/k), * 算出「应该在的位置」和「现在的位置」的差,直接补到 tx/ty 上。 * 不依赖任何缓存状态,所以不存在「算出来和实际不符」。 */ centerInView() { const v = this._viewRect(); if (!v) return; // 没有可见区就不动(自测环境) if (!v.width || !v.height) return; const r = this.canvas.getBoundingClientRect(); const k = this.k || 1; const w = (this.canvas.width || 0) * k; const h = (this.canvas.height || 0) * k; if (!w || !h) return; const wantL = (v.width - w) / 2; const wantT = (v.height - h) / 2; const dx = wantL - (r.left - v.left); const dy = wantT - (r.top - v.top); if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) return; this.tx += dx; this.ty += dy; this.apply(); } /** + / − 按钮:同样以可见区中心为锚点 */ zoomBy(factor) { this.zoomCenter(factor); } _onWheel(e) { e.preventDefault(); e.stopPropagation(); // 滚轮也锚在中心:不管指针在哪,图都从中间长大/缩小 const dy = e.deltaY || 0; this.zoomCenter(Math.exp(-dy * 0.0015)); } _dbl(e) { e.preventDefault(); e.stopPropagation(); this.reset(); } _onDown(e) { e.preventDefault(); e.stopPropagation(); this._drag = { x: e.clientX, y: e.clientY, tx: this.tx, ty: this.ty }; this._cursor('grabbing'); try { this.canvas.setPointerCapture(e.pointerId); } catch (err) { /* ignore */ } } /** * 拖动:**想怎么拖就怎么拖**,全程不做任何自动纠正。 * 位移精确等于鼠标位移,松手停在原地。 * (曾经在拖动中/松手后做「夹回可视区」,用户感受就是「拖不动、被拽回去」。) */ _onMove(e) { if (!this._drag) return; e.stopPropagation(); this.tx = this._drag.tx + (e.clientX - this._drag.x); this.ty = this._drag.ty + (e.clientY - this._drag.y); this.apply(); } /** 松手:什么都不做 —— 停在你放下的地方 */ _onUp(e) { if (!this._drag) return; this._drag = null; this._cursor('grab'); try { this.canvas.releasePointerCapture(e.pointerId); } catch (err) { /* ignore */ } } } function hexToRgba(hex, a) { let h = String(hex || '').trim(); if (h[0] === '#') h = h.slice(1); if (h.length === 3) h = h.split('').map(c => c + c).join(''); if (h.length !== 6) return 'rgba(52,152,219,' + a + ')'; const n = parseInt(h, 16); return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`; } // ========================================================================= // 8. UI // ========================================================================= const CSS = ` :host { all: initial; } * { box-sizing: border-box; font-family: "Microsoft YaHei","PingFang SC",Arial,sans-serif; } .launcher { position: fixed; right: 22px; bottom: 92px; z-index: 999998; display: flex; align-items: center; gap: 8px; padding: 10px 16px; border-radius: 24px; cursor: pointer; background: linear-gradient(135deg,#3498db,#2c80b8); color: #fff; font-size: 14px; font-weight: 600; border: none; box-shadow: 0 6px 20px rgba(52,152,219,.45); transition: transform .18s ease, box-shadow .18s ease; } .launcher:hover { transform: translateY(-2px); box-shadow: 0 10px 26px rgba(52,152,219,.6); } .launcher .badge { background: rgba(255,255,255,.25); border-radius: 10px; padding: 1px 7px; font-size: 12px; } .overlay { position: fixed; inset: 0; z-index: 999999; display: none; background: #0f141b; color: #e8eef5; flex-direction: column; } .overlay.show { display: flex; } .top { display: flex; align-items: center; gap: 14px; padding: 12px 18px; background: #161d26; border-bottom: 1px solid #232c38; flex: 0 0 auto; } .top h2 { margin: 0; font-size: 17px; font-weight: 700; letter-spacing: .5px; } .top .sub { font-size: 12px; color: #8b9bb0; } .spacer { flex: 1; } .btn { border: 1px solid #2f3b4a; background: #1d2733; color: #cfdcea; padding: 7px 13px; border-radius: 7px; cursor: pointer; font-size: 13px; transition: background .15s ease, border-color .15s ease; } .btn:hover { background: #26323f; border-color: #3d4c5f; } .btn.primary { background: #3498db; border-color: #3498db; color: #fff; font-weight: 600; } .btn.primary:hover { background: #2c80b8; } .btn:disabled { opacity: .45; cursor: not-allowed; } .btn.sm { padding: 4px 9px; font-size: 12px; } .cats { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; padding: 10px 18px; background: #131a22; border-bottom: 1px solid #202832; flex: 0 0 auto; max-height: 132px; overflow-y: auto; } .cats .cap { font-size: 12px; color: #7c8ca0; } .chip { display: inline-flex; align-items: center; gap: 6px; padding: 5px 12px; border-radius: 15px; cursor: pointer; background: #1c242e; border: 1px solid #2b3644; color: #c3d2e2; font-size: 13px; transition: all .15s ease; } .chip:hover { border-color: #3d4c5f; background: #222c38; } .chip.active { background: #3498db; border-color: #3498db; color: #fff; font-weight: 600; } .chip .swatch { width: 9px; height: 9px; border-radius: 50%; flex: 0 0 auto; } .chip .cnt { font-size: 11px; opacity: .85; background: rgba(255,255,255,.12); border-radius: 8px; padding: 0 6px; } .search { margin-left: auto; display: flex; align-items: center; gap: 6px; } .search input { background: #1c242e; border: 1px solid #2b3644; color: #e8eef5; padding: 6px 10px; border-radius: 7px; font-size: 13px; width: 200px; outline: none; } .search input:focus { border-color: #3498db; } .body { flex: 1; overflow-y: auto; padding: 16px 18px 90px; } .grid { display: grid; grid-template-columns: repeat(${GRID_COLS}, minmax(0,1fr)); gap: 14px; } .card { background: #18202a; border: 1px solid #242e3a; border-radius: 10px; overflow: hidden; cursor: pointer; transition: all .16s ease; display: flex; flex-direction: column; position: relative; } .card:hover { border-color: #3498db; transform: translateY(-3px); box-shadow: 0 10px 22px rgba(0,0,0,.45); } .card.selected { border-color: #3498db; box-shadow: 0 0 0 2px rgba(52,152,219,.85), 0 10px 22px rgba(0,0,0,.45); } .card.selected::before { content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: #3498db; z-index: 2; } .card canvas { display: block; max-width: 100%; max-height: 300px; width: auto; height: auto; background: #0b1016; margin: 9px auto 2px; border-radius: 6px; } .card .meta { padding: 6px 10px 4px; display: flex; align-items: center; gap: 7px; } .card .dot { width: 10px; height: 10px; border-radius: 50%; flex: 0 0 auto; } .card .nm { font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .card .fr { margin-left: auto; font-size: 11px; color: #7c8ca0; flex: 0 0 auto; } .card .attrs { padding: 0 10px 9px; font-size: 11px; color: #8b9bb0; display: flex; flex-wrap: wrap; gap: 4px; } .card .attrs span { background: #202a35; border-radius: 4px; padding: 1px 6px; } .card .badge-dirty { position: absolute; top: 8px; right: 8px; background: #e67e22; color: #fff; font-size: 11px; padding: 2px 8px; border-radius: 9px; } .card .zoombtn { position: absolute; top: 8px; left: 8px; width: 26px; height: 26px; padding: 0; display: flex; align-items: center; justify-content: center; border-radius: 7px; background: rgba(15,20,27,.78); border: 1px solid #33414f; color: #cfe0ee; cursor: zoom-in; } .card .zoombtn:hover { background: #3498db; border-color: #3498db; color: #fff; } .bottom { display: flex; align-items: center; gap: 12px; padding: 9px 18px; background: #131a22; border-top: 1px solid #202832; font-size: 12px; color: #8b9bb0; flex: 0 0 auto; } .pager { margin-left: auto; display: flex; align-items: center; gap: 7px; } .keys-hint { margin-left: 16px; color: #6c7c90; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .saved-count { margin-left: 12px; color: #e67e22; background: rgba(230,126,34,.14); border: 1px solid rgba(230,126,34,.45); border-radius: 9px; padding: 2px 9px; white-space: nowrap; } .saved-count:empty { display: none; } .progress { position: absolute; left: 0; top: 0; height: 2px; background: #3498db; width: 0; transition: width .2s ease; } .empty { text-align: center; color: #6c7c90; padding: 70px 20px; font-size: 14px; line-height: 2; } .drawer-mask { position: absolute; inset: 0; background: rgba(0,0,0,.55); display: none; } .drawer-mask.show { display: block; } .drawer { position: absolute; right: 0; top: 0; bottom: 0; width: 470px; max-width: 92vw; background: #161d26; border-left: 1px solid #26313d; transform: translateX(102%); transition: transform .22s ease; display: flex; flex-direction: column; } .drawer.show { transform: none; } .drawer .dh { padding: 14px 18px; border-bottom: 1px solid #232c38; display: flex; align-items: center; gap: 10px; } .drawer .dh h3 { margin: 0; font-size: 15px; } .drawer .dc { flex: 1; overflow-y: auto; padding: 16px 18px; } .drawer .df { padding: 13px 18px; border-top: 1px solid #232c38; display: flex; gap: 10px; } .drawer canvas { display: block; margin: 0 auto 4px; max-width: 100%; max-height: 260px; width: auto; height: auto; border-radius: 8px; background: #0b1016; touch-action: none; } /* ── 放大查看(滚轮缩放 / 拖动平移) ── */ .lightbox { position: absolute; inset: 0; background: rgba(5,8,12,.94); display: none; flex-direction: column; z-index: 6; } .lightbox.show { display: flex; } .lightbox .lh { display: flex; align-items: center; gap: 10px; padding: 11px 16px; border-bottom: 1px solid #1e2731; } .lightbox .lh .ttl { font-size: 14px; font-weight: 600; } .lightbox .lh .hint2 { font-size: 11px; color: #6c7c90; margin-left: 4px; } .lightbox .lh .modebadge { font-size: 11px; color: #3498db; background: rgba(52,152,219,.14); border: 1px solid rgba(52,152,219,.45); border-radius: 9px; padding: 2px 9px; white-space: nowrap; } .lb-stage { flex: 1; position: relative; overflow: hidden; cursor: grab; background: repeating-conic-gradient(#131a22 0% 25%, #0f141b 0% 50%) 50% / 22px 22px; } .lb-stage.grabbing { cursor: grabbing; } .lb-inner { position: absolute; left: 0; top: 0; transform-origin: 0 0; will-change: transform; } .lb-inner canvas { display: block; border-radius: 4px; box-shadow: 0 14px 44px rgba(0,0,0,.65); } .field { margin-bottom: 15px; } .field > label { display: block; font-size: 12px; color: #8b9bb0; margin-bottom: 6px; } .field .ro { font-size: 13px; color: #cfdcea; background: #1c242e; border: 1px solid #2b3644; border-radius: 7px; padding: 8px 11px; word-break: break-all; } select, input[type=text] { width: 100%; background: #1c242e; border: 1px solid #2b3644; color: #e8eef5; padding: 9px 11px; border-radius: 7px; font-size: 13px; outline: none; } select:focus, input[type=text]:focus { border-color: #3498db; } .hint { font-size: 11px; color: #6c7c90; margin-top: 5px; line-height: 1.7; } .warnbox { background: #3a2a12; border: 1px solid #8a6420; color: #f0c37a; border-radius: 8px; padding: 10px 12px; font-size: 12px; line-height: 1.75; margin-bottom: 14px; } .warnbox code { background: rgba(0,0,0,.3); padding: 1px 5px; border-radius: 4px; } .attrrow { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; } .attrrow .k { width: 116px; flex: 0 0 auto; font-size: 12px; color: #9fb0c4; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .attrrow .v { flex: 1; font-size: 13px; color: #cfdcea; background: #1c242e; border: 1px solid #2b3644; border-radius: 7px; padding: 8px 11px; word-break: break-all; } .difflist { font-size: 12px; line-height: 1.85; color: #86e0a8; background: #12251a; border: 1px solid #245c39; border-radius: 8px; padding: 9px 12px; } .difflist .row { word-break: break-all; } .difflist .tail { color: #7d9f88; margin-top: 4px; } .difflist .none { color: #7c8ca0; } .difflist .bad { color: #ffb3aa; } .difflist .warnhead { color: #f0c37a; margin-top: 6px; } .difflist .warn { color: #c9a86a; } .toast { position: absolute; left: 50%; bottom: 74px; transform: translateX(-50%); background: #1d2733; border: 1px solid #2f3b4a; color: #e8eef5; padding: 10px 18px; border-radius: 9px; font-size: 13px; opacity: 0; transition: opacity .2s ease; pointer-events: none; max-width: 76vw; text-align: center; white-space: pre-line; line-height: 1.75; } .toast.show { opacity: 1; } .toast.err { background: #3a1a17; border-color: #7d2d24; color: #ffb3aa; } .toast.ok { background: #12251a; border-color: #245c39; color: #86e0a8; } `; let $ = null, toastTimer = null; function buildUI() { const host = document.createElement('div'); host.id = NS + '-host'; document.body.appendChild(host); const shadow = host.attachShadow({ mode: 'open' }); const style = document.createElement('style'); style.textContent = CSS; shadow.appendChild(style); const wrap = document.createElement('div'); wrap.innerHTML = `

▦ 网格视图

整包标注物总览
就绪 ↑↓←→ 移动选择 · A 上一页 · D 下一页 · Ctrl+滚轮 放大 · 点卡片修正 · Esc 退出
1 / 1

修正标注物

`; shadow.appendChild(wrap); $ = (id) => shadow.getElementById(id); $('gv-launch').addEventListener('click', openPanel); $('gv-close').addEventListener('click', closePanel); $('gv-reload').addEventListener('click', () => load(true)); $('gv-mask').addEventListener('click', closeDrawer); $('gv-dclose').addEventListener('click', closeDrawer); $('gv-dcancel').addEventListener('click', closeDrawer); $('gv-save').addEventListener('click', doSave); $('gv-prev').addEventListener('click', () => goPage(-1)); $('gv-next').addEventListener('click', () => goPage(1)); document.addEventListener('keydown', onKeyDown); setupLightbox(); window.addEventListener('resize', () => { if ($('gv-lb').classList.contains('show') && lbViewer) centerLightbox(); }); // 键盘翻页等只对 document 生效;放大页里由 Viewer 自己处理 syncSavedBadge(); log('已注入。点右下角「网格视图」按钮打开独立视图。'); } // ── 键盘导航 ──────────────────────────────────────────────────────────── const isTyping = () => { const el = (document.activeElement && document.activeElement.tagName) || ''; return el === 'INPUT' || el === 'TEXTAREA' || el === 'SELECT'; }; /** 上一页 / 下一页;保留当前列号,跨页后还能接着往下走 */ function goPage(delta) { const total = Math.max(1, Math.ceil(filtered().length / PAGE_SIZE)); const next = Math.min(total, Math.max(1, S.page + delta)); if (next === S.page) { toast(delta > 0 ? '已经是最后一页了' : '已经是第一页了'); return; } const col = S.selectedIndex >= 0 ? S.selectedIndex % GRID_COLS : -1; S.page = next; S.selectedIndex = -1; renderGrid(); if (col >= 0) { const count = pageItems().length; if (count) selectCard(Math.min(col, count - 1), false); } } /** 当前页实际渲染出来的卡片(按 DOM 顺序,已做防御性过滤) */ function pageItems() { const grid = $('gv-body').querySelector('.grid'); return grid ? Array.from(grid.querySelectorAll('.card')) : []; } function applySelectionClass() { pageItems().forEach((c, i) => c.classList.toggle('selected', i === S.selectedIndex)); } function selectCard(idx, scroll) { const items = pageItems(); if (!items.length) { S.selectedIndex = -1; return; } S.selectedIndex = Math.max(0, Math.min(items.length - 1, idx)); applySelectionClass(); if (scroll !== false) items[S.selectedIndex].scrollIntoView({ block: 'nearest' }); } /** * 键盘导航的纯函数核心:给定当前状态和按键方向,算出下一个状态。 * 不碰 DOM,方便离线自测。返回 same:true 表示走到头了、没有移动。 * * @param {{act,index,count,page,totalPages,cols}} st * index = 当前页内下标(-1 表示还没选中);count = 当前页卡片数 * @returns {{same:boolean, index:number, page:number}} */ function navStep(st) { const cols = Math.max(1, st.cols || GRID_COLS); const n = st.count; if (!n) return { same: true, index: -1, page: st.page }; // 还没选中任何卡片 → 第一次按方向键从第一张开始(避免“按了没反应”) let idx = (st.index < 0 || st.index >= n) ? 0 : st.index; if (st.index < 0 || st.index >= n) { return { same: false, index: 0, page: st.page, seeded: true }; } const row = Math.floor(idx / cols); const col = idx % cols; const maxRow = Math.floor((n - 1) / cols); switch (st.act) { // ←/→ 按“阅读顺序”在页内逐格走,走到页首/页尾就翻页。 // 网格是一行 3 个,所以第 1 行最右再按 → 自然落到第 2 行最左,符合直觉。 case 'left': if (idx > 0) return { same: false, index: idx - 1, page: st.page }; if (st.page > 1) return { same: false, index: -1, page: st.page - 1 }; // -1 = 上一页最后一张 return { same: true, index: idx, page: st.page }; case 'right': if (idx + 1 < n) return { same: false, index: idx + 1, page: st.page }; if (st.page < st.totalPages) return { same: false, index: 0, page: st.page + 1 }; return { same: true, index: idx, page: st.page }; // ↑/↓ 按“同一列”上下走,跨页时保持列号 case 'up': if (row > 0) return { same: false, index: idx - cols, page: st.page }; if (st.page > 1) return { same: false, index: col, page: st.page - 1 }; return { same: true, index: idx, page: st.page }; case 'down': if (row < maxRow) return { same: false, index: Math.min(idx + cols, n - 1), page: st.page }; if (st.page < st.totalPages) return { same: false, index: col, page: st.page + 1 }; return { same: true, index: idx, page: st.page }; default: return { same: true, index: idx, page: st.page }; } } /** 方向键移动;返回 false 表示走到头了、没有移动 */ function moveSelection(act) { const items = pageItems(); if (!items.length) return false; const r = navStep({ act, index: S.selectedIndex, count: items.length, page: S.page, totalPages: Math.max(1, Math.ceil(filtered().length / PAGE_SIZE)), cols: GRID_COLS, }); if (r.same) return false; if (r.page !== S.page) { S.page = r.page; S.selectedIndex = -1; renderGrid(); const after = pageItems(); if (!after.length) return true; // index === -1 表示“上一页的最后一张” selectCard(r.index < 0 ? after.length - 1 : Math.min(r.index, after.length - 1)); return true; } selectCard(r.index); return true; } function onKeyDown(e) { if (!S.open) return; const lbOpen = $('gv-lb').classList.contains('show'); const drawerOpen = $('gv-drawer').classList.contains('show'); if (e.key === 'Escape') { if (lbOpen) closeLightbox(); else if (drawerOpen) closeDrawer(); else { if (document.activeElement && document.activeElement.blur) document.activeElement.blur(); closePanel(); } return; } // 放大页 / 抽屉打开时,方向键让给它们(抽屉里是滚轮缩放 + 拖动) if (lbOpen || drawerOpen || isTyping()) return; const key = e.key; // A / D:上一页 / 下一页(也兼容大小写与 PageUp/PageDown) if (key === 'a' || key === 'A' || key === 'PageUp') { e.preventDefault(); goPage(-1); return; } if (key === 'd' || key === 'D' || key === 'PageDown') { e.preventDefault(); goPage(1); return; } const map = { ArrowLeft: 'left', ArrowRight: 'right', ArrowUp: 'up', ArrowDown: 'down' }; const act = map[key]; if (!act) return; e.preventDefault(); // 还没有选中项:第一次按方向键从第一张开始,避免“按了没反应” if (S.selectedIndex < 0 || S.selectedIndex >= pageItems().length) { selectCard(0); return; } moveSelection(act); } function toast(msg, kind) { const t = $('gv-toast'); t.textContent = msg; t.className = 'toast show' + (kind ? ' ' + kind : ''); clearTimeout(toastTimer); toastTimer = setTimeout(() => { t.className = 'toast'; }, kind === 'err' ? 9000 : 2800); } const setStatus = (txt) => { $('gv-status').textContent = txt; }; /** 把「本次已改 N 个」显示在底部状态栏,让卡片上的角标有出处 */ function syncSavedBadge() { const el = $('gv-saved-count'); if (!el) return; const n = S.savedKeys.size; el.textContent = n ? `本次已改 ${n} 个` : ''; el.title = n ? '本次会话中你改过并保存成功的标注物数量(重新加载会清空)' : ''; } function paintProgress() { const done = S.stats.ok + S.stats.fail; $('gv-progress').style.width = (S.stats.frames ? Math.round(done / S.stats.frames * 100) : 0) + '%'; } function openPanel() { S.open = true; $('gv-overlay').classList.add('show'); if (!S.loaded && !S.loading) load(false); } function closePanel() { S.open = false; $('gv-overlay').classList.remove('show'); closeLightbox(); closeDrawer(); } async function load(force) { if (S.loading) return; S.loading = true; S.page = 1; $('gv-progress').style.width = '0%'; $('gv-cats').innerHTML = ''; $('gv-body').innerHTML = '
正在读取整包数据…
逐帧拉取标注数据,请稍候
'; setStatus('加载中…'); try { await loadPackage(); S.loaded = true; S.loading = false; renderAll(); const rate = S.stats.frames ? Math.round(S.stats.ok / S.stats.frames * 100) : 0; setStatus(`帧 ${S.stats.ok}/${S.stats.frames}(${rate}%)· 标注物 ${S.marks.length} 个` + (S.stats.fail ? ` · 失败 ${S.stats.fail} 帧` : '')); $('gv-launch-count').textContent = S.marks.length + ' 个'; syncSavedBadge(); toast(`已加载 ${S.marks.length} 个标注物`, 'ok'); setTimeout(() => { $('gv-progress').style.width = '0%'; }, 700); } catch (e) { S.loading = false; console.error(e); $('gv-body').innerHTML = `
加载失败
${esc(e.message)}

请确认当前是标注页且已登录,然后点「重新加载」。
`; setStatus('加载失败'); toast(e.message, 'err'); } } // ── 渲染 ──────────────────────────────────────────────────────────────── const renderAll = () => { renderCats(); renderGrid(); }; function categoryStats() { const map = new Map(); for (const m of S.marks) { const key = classKey(m); if (!map.has(key)) map.set(key, { key, label: classLabel(m), color: classColor(m), count: 0 }); map.get(key).count++; } return Array.from(map.values()).sort((a, b) => b.count - a.count); } function renderCats() { const box = $('gv-cats'); const live = box.querySelector('.search input'); const kw = live ? live.value : S.keyword; box.innerHTML = ''; const cap = document.createElement('span'); cap.className = 'cap'; cap.textContent = '类别:'; box.appendChild(cap); const mk = (key, label, color, count) => { const c = document.createElement('span'); c.className = 'chip' + (S.classFilter === key ? ' active' : ''); c.innerHTML = `${esc(label)}${count}`; c.addEventListener('click', () => { S.classFilter = key; S.page = 1; S.selectedIndex = -1; renderCats(); renderGrid(); }); return c; }; box.appendChild(mk('__ALL__', '全部', '#7f8c8d', S.marks.length)); categoryStats().forEach(c => box.appendChild(mk(c.key, c.label, c.color, c.count))); const search = document.createElement('div'); search.className = 'search'; search.innerHTML = ``; const inp = search.querySelector('input'); let timer = null; inp.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(() => { S.keyword = inp.value.trim(); S.page = 1; S.selectedIndex = -1; renderGrid(); }, 220); }); box.appendChild(search); } function filtered() { let list = S.marks; if (S.classFilter !== '__ALL__') list = list.filter(m => classKey(m) === S.classFilter); if (S.keyword) { const kw = S.keyword.toLowerCase(); list = list.filter(m => classLabel(m).toLowerCase().includes(kw) || String(m.__frameIndex + 1) === kw || String(m.objectId || '').includes(kw) || (TYPE_LABEL[m.type] || '').includes(kw)); } return list; } function renderGrid() { const list = filtered(); const totalPages = Math.max(1, Math.ceil(list.length / PAGE_SIZE)); if (S.page > totalPages) S.page = totalPages; const start = (S.page - 1) * PAGE_SIZE; const pageMarks = list.slice(start, start + PAGE_SIZE); // 键盘选中项越界就清掉(换筛选、换页后卡片数量会变) if (S.selectedIndex >= pageMarks.length) S.selectedIndex = -1; $('gv-pageinfo').textContent = `${S.page} / ${totalPages}`; $('gv-prev').disabled = S.page <= 1; $('gv-next').disabled = S.page >= totalPages; const catName = S.classFilter === '__ALL__' ? '整包全部标注物' : '类别 ' + ((categoryStats().find(c => c.key === S.classFilter) || {}).label || ''); // 带上当前部署(你有多套网址时,一眼知道自己在哪一套上) $('gv-sub').textContent = `${catName} · ${list.length} 个 · ${location.host}`; const body = $('gv-body'); body.innerHTML = ''; if (!list.length) { body.innerHTML = `
${S.marks.length ? '当前筛选没有匹配的标注物。' : '整包没有读取到标注物。'}
`; return; } const grid = document.createElement('div'); grid.className = 'grid'; body.appendChild(grid); for (let i = 0; i < pageMarks.length; i++) { const m = pageMarks[i]; const frame = S.frames.find(f => f.task_id === m.__taskId) || {}; const color = classColor(m); const attrs = attrEntries(m).filter(a => a.value !== '').slice(0, 3); // 标记到「这一个标注物」,不是整帧。 // 早期版本标在整帧上,于是一帧里几十个标注物的卡片全都挂上「已修改」—— // 用户只改了一个却看到满屏标记(已修)。 const dirty = S.savedKeys.has(markKey(m.__taskId, m.__markIndex)); const card = document.createElement('div'); card.className = 'card' + (i === S.selectedIndex ? ' selected' : ''); card.dataset.idx = String(i); card.innerHTML = ` ${dirty ? '本次已改' : ''}
${esc(classLabel(m))} 帧 ${m.__frameIndex + 1}${m.objectId ? ' · ID ' + esc(m.objectId) : ''}
${esc(TYPE_LABEL[m.type] || m.type || '未知')} ${attrs.map(a => `${esc(a.ptitle)}: ${esc(a.value)}`).join('')}
`; grid.appendChild(card); const cv = card.querySelector('canvas'); // 精确裁剪:只画该标注物外接矩形那一小块,画布长宽比跟着裁剪框走 loadFrameImage(frame.pic_url).then(img => { if (!cv.isConnected) return; drawCroppedThumb(cv, img, m, { maxW: THUMB_W, maxH: THUMB_H }); }); card.querySelector('.zoombtn').addEventListener('click', (e) => { e.stopPropagation(); openLightbox(m, frame); }); // 鼠标移到卡片上、按 Ctrl(Mac 上 ⌘)再滚轮 → 直接放大这张图。 // 不按修饰键时**放行**,让列表正常上下滚动 —— 否则浏览网格时会被缩放抢走滚轮。 card.addEventListener('wheel', (e) => { if (!(e.ctrlKey || e.metaKey)) return; // 普通滚轮 = 滚动列表 e.preventDefault(); e.stopPropagation(); openLightbox(m, frame, e); }, { passive: false }); card.addEventListener('click', () => { S.selectedIndex = i; applySelectionClass(); openDrawer(m, frame); }); } } // ── 编辑抽屉 ──────────────────────────────────────────────────────────── function openDrawer(mark, frame) { S.editing = mark; const color = classColor(mark); const classes = classesForToolType(mark.type); const attrs = attrEntries(mark); const cur = classKey(mark); // 当前选中的目标类别(用于预览“换了以后属性会变成什么”) let targetCfg = classes.find(c => c.pname === cur) || null; const renderAttrHint = () => { const el = $('gv-attr-hint'); if (!el) return; if (!targetCfg || targetCfg.pname === cur) { el.textContent = '选择新类别后,属性会跟着新类别整组切换 —— 旧类别的属性不会留下(与平台自身行为一致)。'; } else if (classHasAttrs(targetCfg)) { const names = targetCfg.attrs.map(a => a.ptitle || a.pname).join('、'); el.textContent = '保存后属性会整组换成「' + targetCfg.ptitle + '」的 ' + targetCfg.attrs.length + ' 项(' + names + '),值留空待填;旧类别的属性不保留。'; } else { el.textContent = '「' + targetCfg.ptitle + '」没有配置属性,换过去后属性会被清空。'; } renderDiffPreview(); }; /** 把「点保存后会写什么」实时列在抽屉里 —— 因为保存不再弹确认框了 */ const renderDiffPreview = () => { const el = $('gv-diff'); if (!el) return; if (!targetCfg || targetCfg.pname === cur) { el.innerHTML = '未选择新类别,点「保存更正」不会写入任何东西。'; return; } let plan = null; try { plan = buildPatchPayload(mark, { classPname: targetCfg.pname, attrs: {} }); } catch (e) { el.innerHTML = '' + esc(e.message) + ''; return; } el.innerHTML = plan.diff.map(d => '
· ' + esc(d) + '
').join('') + '
只回写这一个标注物;同帧其它标注物原样保留。
' + renderWarnBlock(plan.warnings); }; /** * 属性没填完的提示(**不拦保存**)。 * 平台客户端自己会在这种情况下拒绝保存,所以值得提前说一声; * 但服务端查不查这一项我们没确证,所以只提示、不阻止 —— * 万一真被拒,错误信息里会把这份清单带上作为归因。 */ const renderWarnBlock = (warnings) => { if (!warnings || !warnings.length) return ''; return '
⚠ 这一帧有 ' + warnings.length + ' 个标注物的属性是空的。
' + warnings.slice(0, 4).map(w => '
· ' + esc(w) + '
').join('') + (warnings.length > 4 ? '
· …还有 ' + (warnings.length - 4) + ' 项
' : '') + '
平台自己的编辑器在这种情况下是不让保存的。这里不拦你,' + '照常可以存;如果服务端也查这一项而被拒,会告诉你原因。
'; }; const dc = $('gv-dc'); dc.innerHTML = `
滚轮缩放 · 按住拖动平移 · 双击复位
第 ${mark.__frameIndex + 1} 帧 · task_id ${esc(mark.__taskId)}
${esc(TYPE_LABEL[mark.type] || mark.type || '未知')}${mark.objectId ? ' · 唯一标识 ' + esc(mark.objectId) : ''}
${esc(classLabel(mark))}
${classes.length ? '选错类别时在这里改。例:把「建筑房屋」改成「小汽车」。' : '⚠ 没读到该图形类型的类别配置,无法修改类别(请点「重新加载」)。'}
${attrs.length ? attrs.map(a => `
${esc(a.ptitle)}${esc(a.value || '—')}
`).join('') : '
该类别没有属性。
'}
保存时的改动范围(务必看一下):
· 只有你正在看的这一个标注物会被写回,同帧其它标注物一个都不动;
· 类别改成你选的那个;
· 属性按平台自己的规则整组替换为新类别的属性(值留空),旧类别的属性不保留;
· 图形坐标、唯一标识、归组等字段原样透传;
· 保存前逐字段做差异校验,发现计划外的改动会直接拦下、不发送请求。
接口:平台自己的 /v2/tasks/mark-data。
选择新类别后在这里列出
`; // 选中新类别时,实时预览“换过去以后属性会变成什么” $('gv-class-sel').addEventListener('change', (e) => { targetCfg = classes.find(c => c.pname === e.target.value) || null; renderAttrHint(); }); renderAttrHint(); S.drawerView = new Viewer(dc.querySelector('#gv-dcanvas')); loadFrameImage(frame.pic_url).then(img => { S.drawerView.load(img, mark); }); $('gv-dh-title').textContent = '修正标注物 · ' + classLabel(mark); $('gv-drawer').classList.add('show'); $('gv-mask').classList.add('show'); } // ── 放大查看 ──────────────────────────────────────────────────────────── let lbViewer = null; let lbSeq = 0; // 连续滚动时只认最后一次,避免并发打开互相覆盖 function setupLightbox() { const stage = $('gv-lb-stage'); lbViewer = new Viewer($('gv-lb-canvas'), stage); lbViewer.onModeChange = () => updateLbHint(); $('gv-lb-close').addEventListener('click', closeLightbox); // 两个明确的模式按钮:想聚焦才聚焦,不做任何自动切换 $('gv-lb-full').addEventListener('click', () => { lbViewer.render('full', true, 'fitFull'); updateLbHint(); }); $('gv-lb-focus').addEventListener('click', () => { lbViewer.render('focus', true); updateLbHint(); }); $('gv-lb-in').addEventListener('click', () => lbViewer.zoomBy(1.3)); $('gv-lb-out').addEventListener('click', () => lbViewer.zoomBy(1 / 1.3)); // 放大页是模态看图,滚轮直接缩放(画布上的滚轮由 Viewer 自己处理并已 stopPropagation, // 所以这里只会收到「滚在画布外」的那些) stage.addEventListener('wheel', (e) => { e.preventDefault(); e.stopPropagation(); lbViewer.zoomCenter(Math.exp(-(e.deltaY || 0) * 0.0015)); }, { passive: false }); } async function openLightbox(mark, frame, wheelEvent) { if (!lbViewer) return; const seq = ++lbSeq; $('gv-lb-title').textContent = classLabel(mark) + ' · 第 ' + (mark.__frameIndex + 1) + ' 帧' + (mark.objectId ? ' · ID ' + mark.objectId : ''); $('gv-lb').classList.add('show'); const stage = $('gv-lb-stage'); const availW = Math.max(240, (stage.clientWidth || window.innerWidth) - 56); const availH = Math.max(180, (stage.clientHeight || window.innerHeight) - 56); const img = await loadFrameImage(frame.pic_url); if (seq !== lbSeq) return; // 用户又滚了一下,这次的结果作废 // 裁剪聚焦 + 整图都按窗口可用空间绘制,画得更大更清楚 lbViewer.load(img, mark, { maxW: Math.min(availW, FULL_MAX_W), maxH: Math.min(availH, FULL_MAX_H) }); centerLightbox(); // 在卡片上直接滚轮 → 打开后立刻把这一滚的缩放补上,手感连贯 if (wheelEvent && !wheelEvent.__gvApplied) { wheelEvent.__gvApplied = true; lbViewer.zoomCenter(Math.exp(-(wheelEvent.deltaY || 0) * 0.0015)); } updateLbHint(); } function updateLbHint() { const el = $('gv-lb-mode'); if (!el || !lbViewer) return; el.textContent = lbViewer.mode === 'full' ? '当前:整图' : '当前:聚焦标注物'; } function centerLightbox() { if (!lbViewer.canvas.width) return; // 位置只由 Viewer.centerInView 一处决定(它从 getBoundingClientRect 现读, // 不依赖任何缓存)。早期这里自己算 tx/ty,既容易偏、又和 Viewer 打架。 lbViewer.centerInView(); } function closeLightbox() { $('gv-lb').classList.remove('show'); } function closeDrawer() { S.editing = null; $('gv-drawer').classList.remove('show'); $('gv-mask').classList.remove('show'); } async function doSave() { const mark = S.editing; if (!mark) return; const classPname = $('gv-class-sel').value; const change = { classPname, attrs: {} }; if (!classPname) { toast('没有做任何修改。请在「修正为」里选择新的类别。'); return; } // 该帧正在原生编辑器里且有未保存改动 → 先让用户处理,避免互相覆盖 if (nativeFrameIsDirty(mark.__taskId)) { toast('这一帧正在平台原生编辑器里编辑,请先在平台上保存或撤销,再回来修正。', 'err'); return; } let plan; try { plan = buildPatchPayload(mark, change); } catch (e) { toast(e.message, 'err'); return; } // 不再弹浏览器确认框:改动清单在点之前就已经在抽屉里显示过了(底部橙色框 + 属性行提示), // 保存成功后还会用 toast 再报一次实际写入了什么。 const btn = $('gv-save'); btn.disabled = true; btn.textContent = '保存中…'; try { const diff = await saveCorrection(mark, change); const updated = hydrateMark(S.framePayload[mark.__taskId].marks[mark.__markIndex]); const idx = S.marks.findIndex(x => x.__taskId === mark.__taskId && x.__markIndex === mark.__markIndex); if (idx >= 0) S.marks[idx] = Object.assign({}, updated, { __taskId: mark.__taskId, __frameIndex: mark.__frameIndex, __markIndex: mark.__markIndex, }); // 只标记「这一个标注物」,不要整帧(整帧会导致同帧其它卡片全被标上) S.savedKeys.add(markKey(mark.__taskId, mark.__markIndex)); syncSavedBadge(); // 如果改的正好是平台上当前打开的那一帧,平台画布手里还是旧数据。 // 提醒用户:要么刷新,要么别在平台上再保存(平台会把旧数据写回去)。 if (isCurrentPlatformFrame(mark.__taskId)) { toast('已保存:' + diff.join(';') + '\n注意:这一帧正是平台上当前打开的帧,平台里显示的还是旧数据。' + '若还要在平台上继续编辑,请先刷新页面,否则平台保存时会把旧类别写回去。', 'ok'); } else { toast('已保存:' + diff.join(';'), 'ok'); } closeDrawer(); if ($('gv-lb').classList.contains('show')) closeLightbox(); renderAll(); } catch (e) { console.error('[网格视图] 保存失败:本次请求体如下(可直接对照平台自己的请求)', plan.payload); // 如果这一帧本来就有属性没填完,服务端很可能就是因此拒收的 —— 直接说出来, // 否则用户只看到一句「保存失败」,无从下手。 const w = plan.warnings || []; const hint = w.length ? '\n\n可能的原因为:这一帧有 ' + w.length + ' 个标注物的属性是空的,' + '平台自己的编辑器在这种情况下也不让保存。\n' + w.slice(0, 3).map(x => '· ' + x).join('\n') + (w.length > 3 ? '\n· …还有 ' + (w.length - 3) + ' 项' : '') + '\n\n可先到平台上把这一帧的属性填完再回来改。' : ''; toast('保存失败:' + e.message + hint, 'err'); } finally { btn.disabled = false; btn.textContent = '保存更正'; } } function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } // ========================================================================= // 9. 启动 // ========================================================================= /** * 等平台加载并识别它属于哪个适配器。 * 标注实例是异步建好的,所以轮询一段时间;识别成功后把启动按钮显示出来, * 识别不到就一直隐藏(避免在不相关的页面上乱挂一个按钮)。 */ function boot() { if (document.getElementById(NS + '-host')) return; try { buildUI(); } catch (e) { console.error('[网格视图] 注入失败', e); return; } showLauncher(false); installDiagnostics(); let tries = 0; const timer = setInterval(() => { tries++; const found = detectAdapter(); if (found) { ADAPTER = found.adapter; const p = found.inst.params || {}; log('已识别平台:' + ADAPTER.name, '|task_key =', p.task_key, '|帧数 =', Array.isArray(p.frame) ? p.frame.length : '未知', '|类别配置 =', classesOf(p).length, '组'); showLauncher(true); clearInterval(timer); return; } // 实例在、但没有匹配的适配器 —— 这是「换平台要接适配器」的信号 const anyInst = pickInstance(); if (anyInst && tries > 6) { clearInterval(timer); showLauncher(true); warn('检测到标注实例,但没有匹配的平台适配器。' + '请把下面这份诊断信息发给脚本作者,用来加一条适配:', adapterDiagnostics(anyInst)); return; } if (tries > 14) { clearInterval(timer); log('这个页面上没找到标注实例,网格视图按钮保持隐藏。' + '(如果你确实在标注页,刷新一次即可)'); } }, 700); } /** 给「接新平台」用的诊断快照:只读,不改任何东西 */ function adapterDiagnostics(inst) { try { const p = (inst && inst.params) || {}; return { 页面: location.href, 实例上的字段: Object.keys(inst).slice(0, 40), params字段: Object.keys(p).slice(0, 80), task_key: p.task_key, task_id: p.task_id, frame条数: Array.isArray(p.frame) ? p.frame.length : null, 帧对象字段: (Array.isArray(p.frame) && p.frame[0]) ? Object.keys(p.frame[0]) : null, 类别配置字段: ['properties', 'classes', 'classList', 'labels'].filter(k => Array.isArray(p[k])), localStorage里的token键: Object.keys(localStorage).filter(k => /token|access|auth/i.test(k)), window上的候选挂载点: Object.keys(window).filter(k => /canvas|label|mark|annot/i.test(k)).slice(0, 30), }; } catch (e) { return { 诊断失败: String(e) }; } } function showLauncher(on) { try { const b = document.getElementById(NS + '-host'); const btn = b && b.shadowRoot && b.shadowRoot.getElementById('gv-launch'); if (btn) btn.style.display = on ? '' : 'none'; } catch (e) { /* ignore */ } } /** * 诊断用:在控制台执行 `__网格视图诊断()` 会打印当前视图的真实数字。 * 缩放/拖动如果还有异常,把这行输出发过来就能直接定位,不用猜。 */ function installDiagnostics() { try { window.__网格视图诊断 = function () { const out = { 平台: ADAPTER ? ADAPTER.name : '(未识别)', 网址: location.href }; const st = $('gv-lb-stage'); if (st && lbViewer) { const r = lbViewer.canvas.getBoundingClientRect(); const v = st.getBoundingClientRect(); out.放大页 = { 模式: lbViewer.mode, k: +lbViewer.k.toFixed(4), 画布尺寸: lbViewer.canvas.width + 'x' + lbViewer.canvas.height, 画布屏幕位置: [Math.round(r.left), Math.round(r.top)], 可见区: [Math.round(v.left), Math.round(v.top), Math.round(v.width), Math.round(v.height)], 画布相对可见区: [Math.round(r.left - v.left), Math.round(r.top - v.top)], tx_ty: [Math.round(lbViewer.tx), Math.round(lbViewer.ty)], 展开: !!(lbViewer._drag), }; } else { out.放大页 = '(未打开)'; } console.log('%c[网格视图] 诊断', 'color:#3498db;font-weight:bold', out); return out; }; } catch (e) { /* ignore */ } } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot(); })();