// ==UserScript== // @name 红绿灯 · 整批次标注物总览 // @namespace dsh.light.batchgrid // @version 1.9.0 // @description 在项目任务页读取整批次的标注物(批次 → 题包 → 每一题),按类别 / 属性值分栏、三列五行网格展示;点开可以改类别和属性并保存。不改动除你选中的那一个标注物之外的任何东西。 // @author sx // @match *://*/v/project/* // @run-at document-idle // @grant none // ==/UserScript== /* * ───────────────────────────────────────────────────────────────────────────── * 这个脚本做三件事 * ───────────────────────────────────────────────────────────────────────────── * 1. 用平台自己的接口把整批次的标注物读出来 * 2. 按「类别」和「属性值」(比如灯态、颜色)分栏 * 3. 三列五行网格展示,点开看大图 * 4. 放大页右侧可以改类别 / 属性,点「保存更正」才写回去 * * 会写什么、不会写什么: * ✅ POST /v2/tasks/mark-data —— 只有你点「保存更正」才调用 * ✅ 只改你选中的那**一个**标注物的 class / attrs * ✅ 同一题其它标注物原样发回;别的题完全不碰 * ❌ 不改几何、不改平台 DOM、不写 localStorage、不弹原生确认框 * ❌ 除 class / attrs 外任何字段被改动 → 本地直接拒绝保存 * * 用到的接口(全部是平台前端自己在用的): * GET /v2/task-batches/{task_key}/packages 题包列表 * GET /v2/task-batches/{task_key}/packages/{package_id}/tasks 题包里的题 * POST /api/task/get-mark-data 某一题的标注数据(读) * POST /v2/tasks/mark-data 保存(写) * * 请求头与平台前端完全一致:access-key = localStorage.token,另有 time。 * ───────────────────────────────────────────────────────────────────────────── */ (function () { 'use strict'; // ========================================================================= // 0. 常量 // ========================================================================= const NS = 'dsh-light-grid'; /** 界面标题上会显示这个版本号 —— 用来确认浏览器里跑的到底是不是新版 */ const VERSION = '1.9.0'; const GRID_COLS = 3; // 三列 const PAGE_SIZE = 15; // 三列 × 五行 const THUMB_W = 520; // 卡片里画布的渲染宽度 const THUMB_H = 360; const FULL_MAX_W = 1680; const FULL_MAX_H = 1180; // 框线宽度(画布像素)。**恒定值**,不随裁剪放大而变粗 —— // 之前写成 max(1, 1.15 × 缩放倍率),小目标被放大到 500px 宽时线宽会变成 5.6px, // 就是用户说的「图片中的框的线条太宽了」。 const LINE_W = 1.25; const CONCURRENCY = 5; // 拉取并发 // 「颜色」合成维度:红/黄/绿 是红绿灯最核心的分类方式,单独拉出来做一组筛选 const COLOR_DIM = '__color__'; /** 「压根没有这个属性」—— 和「有这个属性但值是空的」分开,用来找回丢属性的标注物 */ const ABSENT = '__absent__'; const COLOR_BUCKETS = [ { key: '红', label: '红', color: '#e74c3c' }, { key: '黄', label: '黄', color: '#f1c40f' }, { key: '绿', label: '绿', color: '#2ecc71' }, { key: '其它', label: '其它', color: '#7f8c9b' }, ]; /** * 平台自己的「工序」和「状态」页签(抄自前端模块 e5/b 的 btnList / workStage, * 只是把 status / work_type 统一成 value,方便和页面上读到的结构一致)。 */ const FALLBACK_STAGE = [ { name: '标注', value: 1 }, { name: '审核', value: 2 }, { name: '质检', value: 3 }, { name: '验收', value: 4 }, ]; const FALLBACK_STATUS = [ { name: '全部', value: 99 }, { name: '待处理', value: 0 }, { name: '进行中', value: 1 }, { name: '已通过', value: 3 }, { name: '已驳回', value: 2 }, { name: '作废', value: 4 }, { name: '抽查列表', value: 100 }, ]; /** * 只有「待处理」这一种状态下的题才改得动。 * 其它状态点保存,服务端会回一句:该题工序已经流转。 * * 而「待处理 → 进行中」是靠**领题**完成的,领题有上限: * 一个人同时只能领几个题包(用户反馈是 3 个),没提交就领不了新的。 * 超了服务端会回 GetTaskExceedMaximumLimit —— 那个错误码是权威,这里的数字只是提前提醒。 */ /** * 哪些状态下能改。 * * 待处理(0) —— 还没人领,点保存会先「领题」再写 * 进行中(1) —— **已经是你的了**(领过了),直接写就行 * * 平台自己的「编辑」按钮在这两个页签下都是可点的:点的时候传的就是当前页签的 status。 * 别的状态(已通过/已驳回/作废)题已经流转走了,服务端会回「该题工序已经流转」。 */ const EDITABLE_STATUS = 0; const IN_PROGRESS_STATUS = 1; const EDITABLE_STATUSES = [EDITABLE_STATUS, IN_PROGRESS_STATUS]; /** 读不到平台配置时的兜底值(用户反馈是 3;平台里这个值是可配的,也可能「无限制」) */ const CLAIM_LIMIT = 3; /** * 「领题限制」是**每个工序一个、可以按项目配**的: * GET /v2/reports/{task_key}/check-info → data.task_conf.{...}_package_limit * 平台自己的说明(配置界面上的 tooltip): * 「单个用户领题数量限制,达到限制后需完成进行中的题后才可领取新题」 * 空字符串 = 无限制。 */ const CLAIM_LIMIT_FIELD = { 1: 'label_package_limit', // 标注 2: 'check_package_limit', // 审核 3: 'inspect_package_limit', // 质检 4: 'accept_package_limit', // 验收 }; const log = (...a) => console.log('%c[红绿灯总览]', 'color:#e74c3c;font-weight:bold', ...a); const warn = (...a) => console.warn('[红绿灯总览]', ...a); // ========================================================================= // 1. 状态 // ========================================================================= const S = { open: false, loading: false, loaded: false, projectKey: '', taskKey: '', taskName: '', packages: [], // [{package_id, __workType, ...}] marks: [], // 展平的标注物 + __taskId / __packageId / __frameIndex taskData: {}, // taskId -> 该题完整的 markData(保存时要把整题的标注都发回去) taskMeta: {}, // taskId -> { imgUrl, width, height, packageId, workType, status } catalog: [], // 从整批标注物推导出的「类别 → 属性 → 可选值」 edits: {}, // "taskId:markIndex" -> true(本次会话改过的,界面上标出来) received: {}, // taskId -> 'task' | 'package'(领过的方式,不重复领) claimed: {}, // workType -> { packageId: true }(领题限制是**按工序**算的) claimLimits: null, // workType -> 上限(题包数);null=无限制;整体为 null=还没读到 pixelCheck: { mismatch: {}, blocked: false, done: 0, total: 0, running: false }, edit: null, // 放大页右侧编辑面板的当前状态 declaredPackages: null, // 服务器报告的题包总数(用来核对有没有漏) packageTried: [], // 题包参数探测记录 platformState: null, // 从页面 Vue 组件读到的状态 lastRaw: null, // 最后一个原始返回(读不到数据时直接显示出来) query: null, // {workType, status} 本次查询用的工序/状态 stageOptions: [], // 工序页签(问页面要,要不到用兜底) statusOptions: [], // 状态页签 filters: { cls: '', attrs: {}, pkg: '', issue: '' }, // attrs: attrPname -> valuePname page: 1, keyword: '', selectedIndex: -1, stats: { packages: 0, tasks: 0, ok: 0, fail: 0, dupTasks: 0 }, }; const imgCache = new Map(); // ========================================================================= // 2. 平台接口(与 /v/ 前端同一套写法) // ========================================================================= const token = () => { try { return (localStorage.getItem('token') || '').replace(/"/g, ''); } catch (e) { return ''; } }; function headers() { return { 'Content-Type': 'application/json;charset=UTF-8', time: Date.now(), 'access-key': token(), }; } /** 平台自己的成功判定:松散比较("" == 0 也是成功) */ const isOk = (res) => !!res && res.code == 0; async function apiGet(path, params) { const u = new URL(path, location.origin); Object.keys(params || {}).forEach(k => { if (params[k] !== undefined && params[k] !== null && params[k] !== '') { u.searchParams.set(k, params[k]); } }); const r = await fetch(u.href, { method: 'GET', headers: headers() }); const t = await r.text(); try { return JSON.parse(t); } catch (e) { throw new Error('返回不是 JSON:' + t.slice(0, 160)); } } async function apiPost(path, data) { const r = await fetch(path, { method: 'POST', headers: headers(), body: JSON.stringify(data || {}), }); const t = await r.text(); try { return JSON.parse(t); } catch (e) { throw new Error('返回不是 JSON:' + t.slice(0, 160)); } } async function apiPut(path, data) { const r = await fetch(path, { method: 'PUT', headers: headers(), body: JSON.stringify(data || {}), }); const t = await r.text(); try { return JSON.parse(t); } catch (e) { throw new Error('返回不是 JSON:' + t.slice(0, 160)); } } /** 把各种可能的返回形态统一成数组 */ function itemsOf(res) { if (!res) return []; const d = res.data; if (Array.isArray(d)) return d; if (d && Array.isArray(d.items)) return d.items; if (d && Array.isArray(d.data_list)) return d.data_list; if (Array.isArray(res.items)) return res.items; return []; } function totalOf(res) { const d = res && res.data; return (d && d.meta && d.meta.total_num) || itemsOf(res).length; } /** 服务器**明确**报告的总数;没报告就返回 null(不要用「这一页有几条」冒充总数) */ function metaTotalOf(res) { const d = res && res.data; const n = d && d.meta && d.meta.total_num; return typeof n === 'number' ? n : null; } const taskIdOf = (t) => t && (t.task_id != null ? t.task_id : (t.id != null ? t.id : t.sub_task_id)); const pkgIdOf = (p) => p && (p.package_id != null ? p.package_id : p.id); // ========================================================================= // 3. 数据加载:批次 → 题包 → 题 → 标注物 // ========================================================================= function readUrl() { const q = new URLSearchParams(location.search); S.taskKey = q.get('task_key') || ''; S.taskName = q.get('task_name') || ''; const m = location.pathname.match(/\/v\/project\/([^/]+)\//); S.projectKey = m ? m[1] : ''; } /** * 一次要一页。服务器**明确拒绝**超过 50(实测报错:「参数错误,分页数据量暂不支持超过50」), * 所以这里要 50;拿不全靠翻页补齐(见 pagedGet),不要靠「一页要 500 条」。 */ const PAGE_NUM = 50; /** 平台自己的默认每页条数,任何部署都一定支持 —— 降级用 */ const PAGE_NUM_SAFE = 20; /** * 带翻页的 GET(一次尝试)。 * 关键点:只有当服务器**明确**报告 total_num 且比已拿到的多时才继续翻页, * 绝不用「这一页的条数」当总数,否则一页拿全的情况会被误判成还有下一页。 */ async function pagedGetOnce(path, params, pageSize) { const all = []; let res = null; for (let page = 1; page <= 200; page++) { res = await apiGet(path, Object.assign({}, params, { page, page_num: pageSize })); if (!isOk(res)) { if (page === 1) return { ok: false, res, items: [] }; break; } const items = itemsOf(res); all.push(...items); const total = metaTotalOf(res); if (!items.length) break; if (total == null || all.length >= total) break; // 服务器没分页 / 已经拿完 } return { ok: true, res, items: all }; } /** 服务器嫌一页要太多时的错误特征 */ const isPageSizeComplaint = (res) => /分页|数据量|page_?num/i.test(String((res && res.msg) || '')); /** * 带翻页的 GET。 * 不同部署对 page_num 的上限不一样(这套是 50)。所以被拒绝时**自动往下调**再试, * 而不是直接把「读不到数据」丢给用户。 */ async function pagedGet(path, params, pageSize) { let size = pageSize || PAGE_NUM; let r = null; for (let attempt = 0; attempt < 4; attempt++) { r = await pagedGetOnce(path, params, size); if (r.ok) return r; if (!isPageSizeComplaint(r.res) || size <= PAGE_NUM_SAFE) return r; const next = Math.max(PAGE_NUM_SAFE, Math.floor(size / 2)); if (next === size) return r; warn('服务器不接受 page_num=' + size + '(' + r.res.msg + '),降到 ' + next + ' 重试'); size = next; } return r || { ok: false, res: null, items: [] }; } /** * 问页面自己要参数。 * * 这个页面(/v/ 的 project-taskIndex)自己已经把题包列表拉下来了,参数就存在 Vue 组件的 * data 里(btnWorkType = 当前阶段,btnStatus = 当前状态页签)。 * 直接读它,比猜参数可靠得多 —— 而且读到的就是你眼前看到的那一批。 * * 读不到(换版本 / 结构变了)就返回空对象,外层会退回按矩阵探测。 */ function readPlatformState() { const out = { workType: null, status: null, packageCount: null, via: '', stageOptions: null, statusOptions: null }; try { const root = document.getElementById('app') || document.body; const startVm = root && (root.__vue__ || (root.firstElementChild && root.firstElementChild.__vue__)); if (!startVm) return out; const seen = new Set(); let best = null; const walk = (vm, depth) => { if (!vm || depth > 14 || seen.has(vm)) return; seen.add(vm); const d = vm._data || vm.$data; if (d && typeof d === 'object') { if (d.btnWorkType !== undefined && d.btnWorkType !== '' && out.workType == null) out.workType = d.btnWorkType; if (d.btnStatus !== undefined && d.btnStatus !== '' && out.status == null) out.status = d.btnStatus; // 工序 / 状态页签也从页面上拿,这样标签和平台完全一致 if (!out.stageOptions && Array.isArray(d.workStage) && d.workStage.length) { out.stageOptions = d.workStage.map(x => ({ name: x.name, value: x.work_type })); } if (!out.statusOptions && Array.isArray(d.btnList) && d.btnList.length && d.btnList[0].status !== undefined) { out.statusOptions = d.btnList.map(x => ({ name: x.name, value: x.status })); } // 这个组件手里有题包列表,说明它就是那个表格 const rows = (vm.tableOptions && vm.tableOptions.data) || d.dataList; if (!best && Array.isArray(rows) && rows.length && rows[0] && rows[0].package_id != null) { best = rows; } } const kids = vm.$children || []; for (let i = 0; i < kids.length; i++) walk(kids[i], depth + 1); }; walk(startVm, 0); if (best) { out.packageCount = best.length; // 只取叶子字段,不搬整个组件对象 out.packages = best.map(r => ({ package_id: r.package_id, status: r.status, seg_dir_name: r.seg_dir_name, task_statistics: r.task_statistics, has_issue: r.has_issue, acceptance_num: r.acceptance_num, })).filter(r => r.package_id != null); } if (out.workType != null || out.packages) out.via = '页面上的 Vue 组件'; } catch (e) { warn('读取页面状态失败(不影响功能,会退回参数探测)', e); } return out; } /** 平台自己在用的题包列表参数(从页面组件的 init() 里抄的,一个字段都不少) */ function packageParams(workType, status) { return { status: status, work_type: workType, task_id: '', package_id: '', file_name: '', seg_dir_name: '', operate_work_type: '', seg_dir_name_like: '', operate_users: '', issue_status: '', split_class: '=', get_result_package: 1, }; } /** * 题包列表。 * * 平台的真实调用(从 module e5/b 的 init() 里扒出来的)必带三个参数: * status=<状态页签> work_type=<阶段> get_result_package=1 split_class='=' * 少了 status 服务器就返回空 items —— 这就是上一版读不到题包的原因。 * * 顺序: * 1. 先问页面自己要(最准,就是你眼前那一批); * 2. 问不到就按 (work_type × status) 矩阵探测,谁有数据用谁; * 3. 多个 work_type 都有数据时取并集(审核/质检包装的是同一批题, * 后面会按 task_id 去重,所以并集不会让标注物翻倍)。 */ async function fetchPackages() { const tried = []; const byId = new Map(); let declared = null; const addAll = (items, workType, status) => { let added = 0; for (const it of items) { const id = pkgIdOf(it); if (id == null) continue; const key = String(id); if (byId.has(key)) continue; // __status 记的是「哪个状态页签把这条查出来的」—— // 保存时必须带这个值,不能拿题包行自己的 status 顶替。 byId.set(key, Object.assign({}, it, { __workType: workType, __status: status })); added++; } return added; }; const attempt = async (tag, workType, status) => { // 界面上要显示「实际发出去的参数」,所以把分页参数一起记下来 const shown = Object.assign({}, packageParams(workType, status), { page: 1, page_num: PAGE_NUM }); let r; try { r = await pagedGet(`/v2/task-batches/${S.taskKey}/packages`, packageParams(workType, status)); } catch (e) { tried.push(tag + '→请求失败 ' + e.message); return null; } if (!r.ok) { const msg = (r.res && (r.res.msg || r.res.code)) || '失败'; tried.push(tag + '→' + JSON.stringify(msg)); // 把原始返回留着,读不到数据时直接显示出来,不用开 F12 猜 if (!S.lastRaw) S.lastRaw = { url: `/v2/task-batches/${S.taskKey}/packages`, params: shown, res: r.res }; return null; } tried.push(tag + '→返回 ' + r.items.length + ' 条'); if (!r.items.length && !S.lastRaw) { S.lastRaw = { url: `/v2/task-batches/${S.taskKey}/packages`, params: shown, res: r.res }; } const t = metaTotalOf(r.res); if (t != null && (declared == null || t > declared)) declared = t; return r; }; // ── 1. 问页面 ────────────────────────────────────────────────────────── const st = readPlatformState(); if (st.via) log('从页面读到的状态:' + st.via, st.workType, st.status, '表格里有 ' + (st.packageCount || 0) + ' 个题包'); S.platformState = st; S.stageOptions = st.stageOptions && st.stageOptions.length ? st.stageOptions : FALLBACK_STAGE; S.statusOptions = st.statusOptions && st.statusOptions.length ? st.statusOptions : FALLBACK_STATUS; // 平台自己会把 100 归一成 99 const norm = (s) => (Number(s) === 100 ? 99 : s); /** * 工序/状态从哪来: * user —— 用户在插件顶栏选的(最权威) * page —— 从平台页面读到的(同样权威) * default —— 都没拿到,只能猜(这时候才需要「矩阵探测」兜底) */ const q = S.query || {}; let wt0, st0, from; if (q.from === 'user') { wt0 = q.workType; st0 = norm(q.status); from = 'user'; } else if (st.workType != null && st.workType !== '') { wt0 = st.workType; st0 = norm(st.status != null && st.status !== '' ? st.status : 99); from = 'page'; } else { wt0 = 1; st0 = 99; from = 'default'; } S.query = { workType: wt0, status: st0, from }; // 「权威」= 这个工序/状态不是猜的 const explicit = from !== 'default'; const first = await attempt('work_type=' + wt0 + ', status=' + st0, wt0, st0); const answered = !!first; // 服务器**正常应答**了(哪怕回的是 0 条) if (first) addAll(first.items, wt0, st0); // ── 2. 什么时候才该去试别的参数 ──────────────────────────────────────── // · 第一次请求就报错 → 参数可能不对,值得试 // · 参数是猜的(没从页面/用户那里拿到)→ 值得试 // · ⚠ 参数是权威的、服务器也正常应答了 0 条 → **那就是真的 0 条**, // 绝不能再偷偷去别的工序/状态里翻数据。 // (用户选了「审核 · 待处理」,那里本来就是空的,翻出「标注」的数据只会让人困惑) const complete = () => byId.size > 0 && (declared == null || byId.size >= declared); const shouldProbe = !answered || !explicit; if (shouldProbe) { const combos = []; const push = (wt, s) => { const key = wt + '|' + norm(s); if (combos.indexOf(key) < 0) combos.push(key); }; for (const wt of [1, 2, 3, 4]) { for (const s of [99, 100, 0, '']) push(wt, s); } // 已经试过的组合(第一次那条)不再重复请求 const tried0 = String(wt0) + '|' + st0; for (const key of combos) { if (complete()) break; if (key === tried0) continue; // work_type 要转成数字,否则后面写回去的类型不一致(字符串 '2' vs 数字 2) const wt = Number(key.split('|')[0]); const s = key.split('|')[1]; const sv = s === '' ? '' : Number(s); const r = await attempt('work_type=' + wt + ', status=' + s, wt, sv); if (r) addAll(r.items, wt, sv); } } else if (!first.items.length) { tried.push('(服务器正常应答:' + stageNameOf(wt0) + ' · ' + statusNameOf(st0) + ' 下就是 0 条,不再去别的参数里翻)'); } else { tried.push('(已经拿全,跳过参数探测)'); } S.declaredPackages = declared; S.packageTried = tried; log('题包列表探测结果:', tried, '服务器报告总数 =', declared, '实际入表 =', byId.size, '本次工序/状态 =', S.query); return Array.from(byId.values()); } /** 当前查询是不是「待处理」—— 只有这一种状态下的题改得动 */ /** 某个工序的领题上限(题包数)。null = 无限制,undefined = 还没读到 */ function claimLimitFor(workType) { const wt = Number(workType == null ? (S.query && S.query.workType) : workType); if (!S.claimLimits) return undefined; return S.claimLimits[wt]; // 可能是 null(无限制) } /** 这个工序已经领了几个题包 */ const claimedCountFor = (workType) => { const wt = Number(workType == null ? (S.query && S.query.workType) : workType); const m = (S.claimed && S.claimed[wt]) || {}; return Object.keys(m); }; /** * 读平台配的「领题限制」。读不到就不显示数字(绝不编一个)。 * 这个值只是提前提醒;真正的裁判是服务端的 GetTaskExceedMaximumLimit。 */ async function fetchClaimLimits() { try { const res = await apiGet(`/v2/reports/${S.taskKey}/check-info`, {}); if (!isOk(res) || !res.data) return null; const conf = res.data.task_conf || res.data; const out = {}; Object.keys(CLAIM_LIMIT_FIELD).forEach(wt => { const raw = conf[CLAIM_LIMIT_FIELD[wt]]; const n = Number(raw); out[wt] = (raw === '' || raw == null || !isFinite(n) || n <= 0) ? null : n; }); log('平台配的领题限制(题包数,null=无限制):', out); return out; } catch (e) { warn('读取领题限制失败(不影响其它功能,界面上就不显示上限了)', e); return null; } } const isEditableQuery = () => !!S.query && EDITABLE_STATUSES.indexOf(Number(S.query.status)) >= 0; /** 当前是「进行中」吗 —— 是的话题已经是你的了,不用再领 */ const isInProgressQuery = () => !!S.query && Number(S.query.status) === IN_PROGRESS_STATUS; const statusNameOf = (v) => { const opts = (S.statusOptions && S.statusOptions.length) ? S.statusOptions : FALLBACK_STATUS; const hit = opts.find(x => String(Number(x.value)) === String(Number(v))); return hit ? hit.name : String(v); }; const stageNameOf = (v) => { const opts = (S.stageOptions && S.stageOptions.length) ? S.stageOptions : FALLBACK_STAGE; const hit = opts.find(x => String(Number(x.value)) === String(Number(v))); return hit ? hit.name : String(v); }; /** 某个题包里的题(每一题就是一张图) */ async function fetchTasks(packageId, workType) { const base = { split_class: '=', get_result_package: 1 }; const variants = workType != null ? [Object.assign({ work_type: workType }, base), base] : [base, Object.assign({ work_type: 1 }, base)]; let last = []; for (const v of variants) { let r; try { r = await pagedGet(`/v2/task-batches/${S.taskKey}/packages/${packageId}/tasks`, v); } catch (e) { continue; } if (r.ok && r.items.length) return r.items; if (r.ok) last = r.items; } return last; } async function fetchMarkData(taskId) { // 与前端 getMarkDataFn 完全一致的入参:{task_id, task_key, time} const res = await apiPost('/api/task/get-mark-data', { time: Date.now(), task_id: taskId, task_key: S.taskKey, }); if (!isOk(res) || !res.data) return null; return res.data.markData || res.data; } /** * 图片地址。 * 同一套代码的不同部署把图片放在不同字段上,实测: * · /w/ 那套是 markData.imgUrl * · /v/ 这套是 markData.file.url(前端 initImg 里就是 new Image + markData.file.url) * 所以这里按候选顺序挑第一个非空字符串。 */ function pickImgUrl(md, raw) { if (!md) md = {}; const f = md.file || {}; const cands = [ md.imgUrl, md.img_url, md.imageUrl, md.image_url, f.url, f.path, f.src, md.url, md.imagePath, md.image_path, md.source_url, raw && raw.img_url, raw && raw.imgUrl, raw && raw.image_url, raw && raw.source_url, raw && raw.file_url, raw && raw.url, ]; for (const c of cands) if (typeof c === 'string' && c.trim()) return c.trim(); return ''; } /** 宽高同理,多个部署放在不同地方 */ function pickSize(md, key) { if (!md) return 0; const f = md.file || {}; const cands = [md[key], f[key], f['natural_' + key], f['natural' + key[0].toUpperCase() + key.slice(1)]]; for (const v of cands) if (typeof v === 'number' && v > 0) return v; return 0; } // ========================================================================= // 4. 标注物整形 // ========================================================================= const classObj = (m) => { const c = m && m.class; if (Array.isArray(c) && c.length) return c[c.length - 1]; if (c && typeof c === 'object') return c; return {}; }; const classKey = (m) => pickPname(classObj(m)) || ''; const classLabel = (m) => { const c = classObj(m); const pn = pickPname(c); // 有代号但没中文时,宁可显示代号,也不要显示成「(未分类)」—— 那会让人以为没标注 if (!pn) return pickTitle(c, '(未分类)') || '(未分类)'; return pickTitle(c, zhOf(pn, pn)) || pn; }; /** * 中文名解析 * ──────────────────────────────────────────────────────────────────────── * 这套平台的标注数据里,英文代号和中文名是分开存的: * pname = Circle ← 代号(接口里一定有) * ptitle = 圆形 ← 中文名(**不一定有**) * 有的批次 / 有的部署回包里根本不带 ptitle,界面上就会显示成一堆 Circle / LeftTurn。 * * 所以这里做两件事: * 1. 名称字段**不写死** —— ptitle / title / name / label … 挨个试; * 2. 边读边学 —— 把回包里**任何地方**出现过的「代号 + 中文」配对记进字典, * 哪怕中文只在某一处出现过一次,别处也能补回来。 */ const HAS_CJK = /[\u4e00-\u9fa5]/; const TITLE_KEYS = ['ptitle', 'title', 'label', 'cn_name', 'chinese_name', 'display_name', 'zh', 'desc', 'text', 'name']; const PNAME_KEYS = ['pname', 'key', 'code', 'value', 'id', 'name']; const isText = (v) => typeof v === 'string' && v.trim().length > 0; function pickPname(o) { if (!o || typeof o !== 'object') return ''; for (const k of PNAME_KEYS) if (isText(o[k])) return o[k].trim(); return ''; } /** 取中文名;挑不到就返回 fallback */ function pickTitle(o, fallback) { if (!o || typeof o !== 'object') return fallback; const pn = pickPname(o); for (const k of TITLE_KEYS) { const v = o[k]; if (isText(v) && v.trim() !== pn) return v.trim(); } return fallback; } /** * 内置中文对照表 —— **最后一道兜底**。 * * 为什么需要它:这套平台的标注数据里,英文代号和中文名是分开存的 * (pname = Circle,ptitle = 圆形)。有的批次回包里压根不带 ptitle, * 界面上就会显示成一片 Circle / LeftTurn / TFL_Panel。 * * 优先级(从高到低): * 1. 数据自带的标题字段(ptitle / title / name …) * 2. 从整个回包任意位置学到的中文别名表 * 3. 下面这张内置表 * 4. 都没有 → 显示英文代号(至少不会显示成空白) * * 表里的内容来自**本平台自己的类别配置界面**,不是猜的。 * 想加类别/属性/取值,直接往这里加就行;接口一旦给了中文,会自动盖过这张表。 */ const BUILTIN_ZH = { // 类别 TrafficLight: '红绿灯', TFL_Panel: '灯板', TFL_Panel_1: '灯板', Countdown: '倒计时', // 属性 LightState: '灯态', Color: '颜色', Number: '数量', // 灯态取值 Circle: '圆形', LeftTurn: '左转', RightTurn: '右转', Forward: '直行', Uturn: '掉头', UTurn: '掉头', L_Uturn: '左转掉头', SideWalk: '行人', Nonmotor: '非机动车', LT_nonmotor: '左转非机动车', Nonstandard_1: '横置可变长条灯', Nonstandard_2: '竖置可变长条灯', other: '其他', Other: '其他', // 颜色取值 Red: '红色', Yellow: '黄色', Green: '绿色', }; /** 学到的字典:代号 → 中文 */ const zhDict = new Map(); /** 查中文:学到的优先,其次内置表,都没有就原样返回代号 */ const zhOf = (pname, fallback) => { const s = String(pname == null ? '' : pname); if (zhDict.has(s)) return zhDict.get(s); if (Object.prototype.hasOwnProperty.call(BUILTIN_ZH, s)) return BUILTIN_ZH[s]; return fallback == null ? s : fallback; }; /** * 扫一遍任意回包,把「代号 + 中文」的配对记下来。 * 顺带会解析字符串形式的 JSON(平台的 pselect 就是塞了个 JSON 字符串)。 */ function learnZh(node, depth) { if (!node || (depth || 0) > 7) return; if (Array.isArray(node)) { for (const n of node) learnZh(n, (depth || 0) + 1); return; } if (typeof node === 'string') { const s = node.trim(); if (s.length > 1 && (s[0] === '{' || s[0] === '[')) { try { learnZh(JSON.parse(s), (depth || 0) + 1); } catch (e) { /* 不是 JSON,忽略 */ } } return; } if (typeof node !== 'object') return; const pn = pickPname(node); if (pn && !zhDict.has(pn) && !HAS_CJK.test(pn)) { for (const k of TITLE_KEYS) { const t = node[k]; if (isText(t) && HAS_CJK.test(t) && t.trim() !== pn) { zhDict.set(pn, t.trim()); break; } } } for (const k of Object.keys(node)) { const v = node[k]; if (v && typeof v === 'object') learnZh(v, (depth || 0) + 1); // 平台的 pselect 这类字段塞的是 JSON **字符串**,也得钻进去看看 else if (typeof v === 'string' && v.length > 1 && (v[0] === '{' || v[0] === '[')) { learnZh(v, (depth || 0) + 1); } } } /** * 属性 → 显示用:{pname, ptitle, values:[{pname, ptitle}]} * * 中文名来源按优先级: * 1. 属性/取值自己带的标题字段(ptitle / title / …) * 2. pvalue 里同一代号对应的标题 * 3. 全局学到的中文别名表 zhDict * 4. 都没有 —— 显示英文代号(至少不会显示成空白) */ function attrsOf(mark) { const a = mark && mark.attrs; const out = []; if (Array.isArray(a)) { for (const x of a) { if (!x || typeof x !== 'object') continue; const raw = Array.isArray(x.value) ? x.value : (x.value == null || x.value === '' ? [] : [x.value]); const vals = raw.map(v => { // value 里可能是代号字符串,也可能直接是 {pname, ptitle} 对象 const obj = (v && typeof v === 'object') ? v : null; const pname = obj ? (pickPname(obj) || '') : String(v); const hit = (x.pvalue || []).find(p => p && String(pickPname(p)) === pname); const own = obj ? pickTitle(obj, '') : ''; return { pname, ptitle: own || zhOf(pname, pickTitle(hit, pname)) }; }); out.push({ pname: x.pname, ptitle: pickTitle(x, zhOf(x.pname, x.pname)), values: vals, pvalue: x.pvalue || [], }); } } else if (a && typeof a === 'object') { for (const k of Object.keys(a)) { const raw = Array.isArray(a[k]) ? a[k] : (a[k] == null || a[k] === '' ? [] : [a[k]]); out.push({ pname: k, ptitle: zhOf(k, k), values: raw.map(v => { const pname = String((v && typeof v === 'object') ? (pickPname(v) || '') : v); return { pname, ptitle: zhOf(pname, pname) }; }), pvalue: [], }); } } return out; } /** 某个标注物是否满足筛选(skip 用来算「分面计数」时忽略某一维) */ function matches(mark, skip) { if (skip !== 'cls' && S.filters.cls && classKey(mark) !== S.filters.cls) return false; if (skip !== 'pkg' && S.filters.pkg && String(mark.__packageId) !== String(S.filters.pkg)) return false; if (skip !== 'issue' && S.filters.issue) { const d = issueDef(S.filters.issue); if (d && !d.test(mark)) return false; } const attrs = attrsOf(mark); for (const ap of Object.keys(S.filters.attrs)) { if (skip === 'attr:' + ap) continue; const want = S.filters.attrs[ap]; if (!want) continue; // 颜色是「合成维度」:不管平台把红黄绿放在颜色属性、灯态属性还是类别里, // 都统一按红/黄/绿/其它分一组,保证「按颜色分出来」这一条永远成立。 if (ap === COLOR_DIM) { if (colorName(mark) !== want) return false; continue; } const row = attrs.find(a => a.pname === ap); // 「类别本来就该有这个属性、这一条却没有」= 真丢了属性,才筛得出来。 // 灯板这种压根没配这个属性的类别不算 —— 否则满屏都是「(没有这个属性)」。 if (want === ABSENT) { if (row || !classHasAttr(classKey(mark), ap)) return false; continue; } if (!row) return false; if (!row.values.some(v => v.pname === want)) return false; } if (S.keyword) { const kw = S.keyword.toLowerCase(); // 中文标题和英文 pname 都进搜索范围(既能搜「左转」,也能搜 LeftTurn); // 题id 和文件名也放进去 —— 拿题id 直接定位到某一题最常用 const fr = frameNoOf(mark); const hay = [classLabel(mark), classKey(mark), String(mark.__taskId == null ? '' : mark.__taskId), fr ? String(fr.n) : '', mark.__fileName || '', ...attrs.map(a => a.ptitle + ' ' + a.pname + ' ' + a.values.map(v => v.ptitle + ' ' + v.pname).join(' '))] .join(' ').toLowerCase(); if (hay.indexOf(kw) < 0) return false; } return true; } /** 当前筛选下可见的标注物;skip 传 'cls' 或 'attr:属性名' 可忽略那一维(算分面计数用) */ const filtered = (skip) => S.marks.filter(m => matches(m, skip)); /** * 「自检」——**只查数据自不自洽,不判断标注内容对不对**。 * * 说清楚它管什么、不管什么: * 管: 单选属性存了多个值 / 属性没填 / 没有类别 / 抠不出坐标 / * 框跑到图片外面 / 框特别小 / 同题里两个框几乎重合 * 不管:**这个框里画的到底是不是红绿灯、灯态颜色和画面对不对得上** —— * 那要看图,脚本看不懂图。 * (唯一能沾边的「颜色 vs 画面像素」要单独点按钮跑,而且受浏览器跨域限制, * 跑不了会明说,不会假装通过。) * * 所以「自检通过」只代表**数据没毛病**,不代表标注是对的。 */ const ISSUES = [ { key: 'multiAttr', label: '属性有多个值', hint: '单选属性却存了多个值(比如颜色同时是红和绿)', test: (m) => attrsOf(m).some(a => a.values.length > 1) }, { key: 'emptyAttr', label: '属性没填', hint: '有这条属性,但一个值都没选', test: (m) => attrsOf(m).some(a => !a.values.length) }, { key: 'noClass', label: '没有类别', hint: '这个标注物没有类别', test: (m) => !pickPname(classObj(m)) }, { key: 'noGeom', label: '抠不出坐标', hint: '读不到几何坐标,只能看整图', test: (m) => !hasGeometry(m) }, { key: 'outOfImage', label: '框跑到图片外', hint: '标注框有一部分在图片范围之外', test: (m) => { const box = boxOfMark(m); const meta = S.taskMeta[m.__taskId] || {}; if (!box || !meta.width || !meta.height) return false; return box.x < -1 || box.y < -1 || box.x + box.w > meta.width + 1 || box.y + box.h > meta.height + 1; } }, { key: 'tinyBox', label: '框特别小', hint: '窄边不到图片短边的 0.2%,多半是误点出来的', test: (m) => { const box = boxOfMark(m); const meta = S.taskMeta[m.__taskId] || {}; if (!box || !meta.width || !meta.height) return false; return Math.min(box.w, box.h) < Math.min(meta.width, meta.height) * 0.002; } }, { key: 'dupBox', label: '同题里框几乎重合', hint: '同一题里有两个同类别的框几乎完全重叠,可能是重复标注', test: (m) => dupBoxSet().has(markKeyOf(m)) }, // 这一项要靠「用图片核对颜色」按钮跑出来,没跑过时永远是 0 { key: 'colorMismatch', label: '颜色和画面对不上', hint: '框内的主要颜色和「颜色」属性不一致(参考值,不是判定)', test: (m) => !!S.pixelCheck.mismatch[markKeyOf(m)] }, ]; const issueDef = (key) => ISSUES.find(x => x.key === key); const markKeyOf = (m) => m.__taskId + ':' + m.__markIndex; /** * 用图片像素核对「颜色」属性 —— 界面上那个「用图片核对颜色」按钮跑的就是这个。 * * 老实说清楚它能干什么: * ✅ 框里的主色调是红/黄/绿,和「颜色」属性写的是不是一致 * ❌ 它看不懂图,判断不了「这个框里画的到底是不是红绿灯」 * * 跨域读不到像素时会直接报 blocked,并把按钮标成不可用 —— 不会假装查过了。 */ async function runPixelColorCheck(scope, onProgress) { const PC = S.pixelCheck; if (PC.running) return PC; PC.running = true; PC.mismatch = {}; const list = scope.filter(m => attrsOf(m).some(a => /颜色|color/i.test(a.pname + a.ptitle))); PC.total = list.length; PC.done = 0; let blocked = false; for (const m of list) { const meta = S.taskMeta[m.__taskId] || {}; const box = boxOfMark(m); if (meta.imgUrl && box && box.w > 0 && box.h > 0) { const img = await loadImage(meta.imgUrl); if (img) { const d = dominantLampHue(img, box); if (d.blocked) { blocked = true; break; } const want = colorName(m); // 属性里写的颜色:红/黄/绿/其它 const got = hueName(d.hue); // 画面上的颜色 if (want !== '其它' && got && got !== want && d.ratio > 0.02) { PC.mismatch[markKeyOf(m)] = { want, got, ratio: d.ratio }; } } } PC.done++; if (onProgress && PC.done % 5 === 0) onProgress(PC.done, PC.total); } PC.blocked = blocked; PC.running = false; return PC; } /** 当前范围内的各类问题数量(算某一类时忽略这一类自己的筛选,否则永远是全 0) */ function issueStats() { const scope = filtered('issue'); return ISSUES.map(d => ({ key: d.key, label: d.label, hint: d.hint, count: scope.filter(d.test).length })); } /** * 把多个题包里的题收集起来,并按 task_id 去重。 * 审核包 / 质检包 / 验收包里装的是同一批题,不去重的话同一张图的标注物会被统计很多遍。 * 去重后按「题包顺序 → 题在题包内的顺序」重排编号,保证每次刷新看到的「题 N」都一样 * (并发完成顺序是不确定的,不能拿它当编号)。 */ async function collectTasks(pkgs, fetchFn, onProgress) { const pkgOrder = new Map(); pkgs.forEach((p, i) => { const id = pkgIdOf(p); if (id != null) pkgOrder.set(String(id), i); }); const taskMap = new Map(); let dupTasks = 0, pkgDone = 0; const queue = pkgs.slice(); async function worker() { while (queue.length) { const p = queue.shift(); const id = pkgIdOf(p); if (id == null) continue; let items = []; try { items = await fetchFn(id, p.__workType); } catch (e) { warn('题包 ' + id + ' 读取失败', e); } learnZh(items); // 题目行里也可能带着中文名 items.forEach((t, inPkg) => { const tid = taskIdOf(t); if (tid == null) return; const key = String(tid); if (taskMap.has(key)) { dupTasks++; return; } taskMap.set(key, { taskId: tid, packageId: id, raw: t, __pkgOrder: pkgOrder.get(String(id)) || 0, __inPkg: inPkg }); }); pkgDone++; if (onProgress) onProgress(pkgDone, pkgs.length, taskMap.size); } } await Promise.all(Array.from({ length: Math.max(1, Math.min(CONCURRENCY, pkgs.length)) }, worker)); const tasks = Array.from(taskMap.values()) .sort((a, b) => (a.__pkgOrder - b.__pkgOrder) || (a.__inPkg - b.__inPkg)); tasks.forEach((t, i) => { const raw = t.raw || {}; // 平台自己给了顺序就用平台的,没给就用我们排好的 const n = raw.frame_index != null ? raw.frame_index : (raw.order != null ? raw.order : i + 1); t.frameIndex = n; }); return { tasks, dupTasks }; } async function loadBatch() { readUrl(); if (!S.taskKey) throw new Error('网址里没有 task_key,请在项目任务页打开。'); S.packages = []; S.marks = []; S.taskMeta = {}; S.taskData = {}; S.catalog = []; S.stats = { packages: 0, tasks: 0, ok: 0, fail: 0, dupTasks: 0 }; S.edits = {}; S.claimed = {}; // 平台给这个批次配的领题上限(读不到就为 null,界面上不显示数字) S.claimLimits = await fetchClaimLimits(); if ($('lg-editstate')) renderQueryBar(); paintProgress(0, '正在读取题包列表…'); const pkgs = await fetchPackages(); S.packages = pkgs; S.stats.packages = pkgs.length; if (!pkgs.length) { // 参数是权威的(用户选的 / 页面上读到的),服务器也正常应答了 —— 那就是真的没有 const qn = S.query ? (stageNameOf(S.query.workType) + ' · ' + statusNameOf(S.query.status)) : ''; if (S.query && S.query.from !== 'default') { throw new Error('「' + qn + '」下面没有题包 —— 服务器正常应答,就是 0 条。\n' + '换个工序或状态再试(要改东西的话,状态选「待处理」)。'); } throw new Error('没有读到题包(接口返回为空)。可能是这个批次还没分配题包,或接口参数不同。'); } learnZh(pkgs); // 题包行里可能带着类别/属性的中文名 // 先把所有题包里的题收集起来(按 task_id 去重,见 collectTasks) const collected = await collectTasks(pkgs, fetchTasks, (d, total, found) => { paintProgress(d / (total + 1) * 0.4, `读取题包 ${d}/${total},已找到 ${found} 题`); }); const tasks = collected.tasks; S.stats.tasks = tasks.length; S.stats.dupTasks = collected.dupTasks; if (!tasks.length) throw new Error('题包里没有读到题(接口返回为空)。'); if (S.stats.dupTasks) log('有 ' + S.stats.dupTasks + ' 条题记录重复(同一题出现在多个题包里),已按 task_id 去重'); // 再逐题拉标注数据 let done = 0, noImg = 0; const queue = tasks.slice(); async function taskWorker() { while (queue.length) { const t = queue.shift(); try { const md = await fetchMarkData(t.taskId); if (md) { learnZh(md); // ← 中文名主要是在这里学到的 const imgUrl = pickImgUrl(md, t.raw); if (!imgUrl) noImg++; const pkg = S.packages.find(p => String(pkgIdOf(p)) === String(t.packageId)) || {}; S.taskMeta[t.taskId] = { imgUrl, width: pickSize(md, 'width'), height: pickSize(md, 'height'), // 保存时要用的参数,逐题存好 packageId: t.packageId, workType: pkg.__workType, status: pkg.status, }; // 整份原始标注留着:平台的保存接口要的是**整题**的 marks, // 只发改动的那一个会把同一题其它标注物全抹掉。 S.taskData[t.taskId] = md; const marks = Array.isArray(md.marks) ? md.marks : []; marks.forEach((m, i) => { S.marks.push(Object.assign({}, m, { __taskId: t.taskId, __packageId: t.packageId, __markIndex: i, __frameIndex: t.frameIndex, __fileName: (t.raw && (t.raw.file_name || t.raw.name)) || '', })); }); S.stats.ok++; } else S.stats.fail++; } catch (e) { S.stats.fail++; warn('题 ' + t.taskId + ' 读取失败', e); } done++; paintProgress(0.4 + done / tasks.length * 0.6, `读取标注 ${done}/${tasks.length},已收集 ${S.marks.length} 个标注物`); } } await Promise.all(Array.from({ length: Math.min(CONCURRENCY, tasks.length) }, taskWorker)); log(`加载完成:题包 ${S.stats.packages},题 ${S.stats.tasks}(成功 ${S.stats.ok} / 失败 ${S.stats.fail}),标注物 ${S.marks.length}` + (noImg ? `,其中 ${noImg} 题没找到图片地址` : '')); if (noImg) warn('有 ' + noImg + ' 题拿到了标注数据但没解析出图片地址。' + '请把 console 里第一条「标注数据」的字段名发出来,好补上这个部署的字段。'); S.stats.noImg = noImg; S.catalog = buildCatalog(); log('从整批标注物推导出的类别:', S.catalog.map(c => c.ptitle + '(' + c.attrs.length + ' 个属性)')); if (!S.catalog.length && S.marks.length) { warn('读到了标注物但推导不出类别 —— 修改功能会不可用。请在控制台跑 __红绿灯诊断()。'); } return S.marks; } // ========================================================================= // 5. 编辑:类别 / 属性 // ========================================================================= /** * 从整批标注物推导出「可选类别 → 该类别有哪些属性 → 每个属性有哪些可选值」。 * * 为什么不去调接口要类别配置:这样最稳,而且拿到的**一定**是这个批次里真实用过的类别。 * 每个属性的可选值优先用 mark 上的 pvalue(平台把「这个属性的全部可选项」都塞在里面了), * 所以不是只能选「已经出现过的值」,而是能选这个类别配的全部值。 */ function buildCatalog() { const byClass = new Map(); for (const m of S.marks) { const c = classObj(m); const key = pickPname(c) || '(未分类)'; if (!byClass.has(key)) { byClass.set(key, { pname: c.pname || key, ptitle: pickTitle(c, zhOf(key, key)), preset_id: c.preset_id || '', color: colorOf(m), attrs: new Map(), }); } const entry = byClass.get(key); for (const a of attrsOf(m)) { if (!a.pname) continue; if (!entry.attrs.has(a.pname)) { entry.attrs.set(a.pname, { pname: a.pname, ptitle: a.ptitle || a.pname, values: new Map() }); } const av = entry.attrs.get(a.pname); // pvalue = 这个属性配的全部可选项(不只是这一条选中的) for (const o of (a.pvalue || [])) { if (o && o.pname != null) av.values.set(String(o.pname), o.ptitle || String(o.pname)); } for (const v of a.values) { if (!av.values.has(v.pname)) av.values.set(v.pname, v.ptitle); } } } return Array.from(byClass.values()).map(c => ({ pname: c.pname, ptitle: c.ptitle, preset_id: c.preset_id, color: c.color, attrs: Array.from(c.attrs.values()).map(a => ({ pname: a.pname, ptitle: a.ptitle, values: Array.from(a.values.entries()).map(([pname, ptitle]) => ({ pname, ptitle })), })), })).sort((a, b) => String(a.ptitle).localeCompare(String(b.ptitle))); } /** 标注物当前的属性选择,形状:{属性pname: [值pname, ...]} */ function currentAttrSel(mark) { const o = {}; for (const a of attrsOf(mark)) o[a.pname] = a.values.map(v => v.pname); return o; } /** * 把标注物转成「保存态」,与平台一致: * class: 取类别数组最后一级 → { pname, preset_id } * attrs: 数组形式 {pname,ptitle,value,...}[] → 扁平 { pname: [值] } */ 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 || {}), }); } const sameSel = (a, b) => { const ka = Object.keys(a || {}).sort(), kb = Object.keys(b || {}).sort(); if (ka.join('|') !== kb.join('|')) return false; return ka.every(k => (a[k] || []).slice().sort().join(',') === (b[k] || []).slice().sort().join(',')); }; /** * 「点下去到底会改成什么」——把每一处改动写成人话,点一下就实时更新。 * 这是保存前唯一的事实来源:不靠记忆、不靠猜。 */ function previewChanges(mark, newClsPname, attrSel) { const cur = classKey(mark); const inCatalog = (S.catalog || []).find(c => c.pname === newClsPname); const out = []; if (newClsPname && newClsPname !== cur) { // 类别不在本批次的目录里就显示代号 —— 不要拿当前标注物的中文名顶上(那是错的) out.push({ label: '类别', from: classLabel(mark), to: inCatalog ? inCatalog.ptitle : newClsPname }); const oldNames = Object.keys(currentAttrSel(mark)); if (oldNames.length) out.push({ label: '属性', from: '旧类别的 ' + oldNames.length + ' 项', to: '全部删除' }); if (inCatalog && inCatalog.attrs.length) { out.push({ label: '属性', from: '', to: '换成「' + inCatalog.ptitle + '」的 ' + inCatalog.attrs.length + ' 项(值待选)' }); } return out; } const target = inCatalog || editorTarget(newClsPname, mark); const before = currentAttrSel(mark); for (const a of target.attrs) { const b = (before[a.pname] || []).map(p => titleOfValue(target, a.pname, p)); const f = ((attrSel || {})[a.pname] || []).map(p => titleOfValue(target, a.pname, p)); if (b.slice().sort().join(',') === f.slice().sort().join(',')) continue; out.push({ label: a.ptitle, from: b.join('、') || '(空)', to: f.join('、') || '(空)' }); } return out; } /** * 组保存体。严格对齐平台 submitClick 的 inputData: * {action, time, markData:{imgUrl,marks,postils,totalNums,rotateDeg,...}, * task_id, work_type, access, use_time, mark_status, status, is_random, package_id, task_key} * * 只改 class / attrs 两个字段,其它字段一律保持原样 —— 改多了就是写坏数据。 */ function buildSavePayload(mark, newClsPname, attrSel) { const taskId = mark.__taskId; const md = S.taskData[taskId]; if (!md) throw new Error('缺少这一题的原始数据,请点「重新加载」再试。'); const raw = md.marks[mark.__markIndex]; if (!raw) throw new Error('这一题的标注物数量变了,请点「重新加载」再试。'); const target = (S.catalog || []).find(c => c.pname === newClsPname); if (!target) throw new Error('目标类别不在本批次的类别里,已阻止保存。'); const next = JSON.parse(JSON.stringify(raw)); const diff = []; const wasCls = classKey(raw); const clsChanged = newClsPname && newClsPname !== wasCls; const isArrayAttrs = Array.isArray(next.attrs); const before = currentAttrSel(raw); // 原来的属性选择 // 类别:保存格式是 { pname, preset_id } if (clsChanged) { const rawCls = Array.isArray(raw.class) ? (raw.class[raw.class.length - 1] || {}) : (raw.class || {}); // preset_id 一定要沿用原文的 —— 丢了它服务端可能就认不出类别对应的属性配置 next.class = { pname: target.pname, preset_id: target.preset_id || rawCls.preset_id || '', }; diff.push('类别:' + classLabel(raw) + ' → ' + target.ptitle); } // ── 属性 ──────────────────────────────────────────────────────────────── // 换类别:整组换成目标类别的属性,旧类别的一条都不留(平台自己就是这么做的)。 // 不换类别:**只动用户真的改过的属性**。 // —— 这一点很关键:如果某个属性在原始标注里根本不存在, // 用户也没给它选值,那就绝不能凭空写一个空属性进去(那是在改人家的数据)。 const after = {}; if (clsChanged) { target.attrs.forEach(a => { after[a.pname] = ((attrSel || {})[a.pname] || []).slice(); }); } else { Object.keys(before).forEach(k => { after[k] = before[k].slice(); }); target.attrs.forEach(a => { if (!Object.prototype.hasOwnProperty.call(attrSel || {}, a.pname)) return; const sel = (attrSel || {})[a.pname] || []; const had = Object.prototype.hasOwnProperty.call(before, a.pname); if (sel.length) after[a.pname] = sel.slice(); // 用户选了值 → 写进去 else if (had) after[a.pname] = []; // 原来有、用户清空了 → 清空 // 原来就没有、用户也没选 → 什么都不做 }); } if (isArrayAttrs) { const names = Object.keys(after); const known = new Map(target.attrs.map(a => [a.pname, a])); next.attrs = names.map(pname => { const cfg = known.get(pname); const old = (raw.attrs || []).find(x => x && x.pname === pname); // 用 Object.assign 保留原文里这个属性的其它字段(color / pcode …),只改该改的 return Object.assign({}, old || {}, { pname, ptitle: (cfg && cfg.ptitle) || (old && old.ptitle) || pname, value: after[pname].slice(), pvalue: cfg ? cfg.values.map(v => ({ pname: v.pname, ptitle: v.ptitle })) : ((old && old.pvalue) || []), }); }); } else { const o = {}; Object.keys(after).forEach(k => { o[k] = after[k].slice(); }); next.attrs = o; } if (clsChanged) { const oldNames = Object.keys(before); if (oldNames.length) diff.push('属性:删掉旧类别的 ' + oldNames.length + ' 项'); if (target.attrs.length) diff.push('属性:换成「' + target.ptitle + '」的 ' + target.attrs.length + ' 项'); } else if (!sameSel(before, after)) { diff.push('属性:' + describeAttrChange(before, after, target)); } if (!diff.length) throw new Error('没有任何改动。'); // 安全校验:字段集合不许变,除了 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 + ',已阻止保存。'); } } // ⚠ 只转「要改的那一个」。 // // 之前这里对所有标注物都跑了一遍 toSavedMark(),等于把整题**重写**成我以为的格式: // · class 被换成 {pname, preset_id} —— 原文里的 preset_id 一丢,服务端可能就 // 认不出这个类别对应的属性配置,同一题里其它标注物的属性/颜色就全没了 // (用户看到的「60 个绿灯变成 40 个」就是这么来的) // · attrs 被拍平,pvalue / color 之类的字段一并丢掉 // // 而且 S.taskData 里的数据本来就**是服务端返回的原样**(get-mark-data 的响应), // 原样发回去才是最安全的 —— 这是一次往返,不是一次重建。 const marks = md.marks.map((m, i) => (i === mark.__markIndex ? toSavedMark(next) : m)); // 自检:没动过的标注物必须一个字节都没变 for (let i = 0; i < marks.length; i++) { if (i === mark.__markIndex) continue; if (JSON.stringify(marks[i]) !== JSON.stringify(md.marks[i])) { throw new Error('内部错误:第 ' + (i + 1) + ' 个标注物被改动了,已阻止保存。'); } } const markData = { imgUrl: md.imgUrl, marks, postils: md.postils || [], totalNums: md.totalNums != null ? md.totalNums : marks.length, 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; if (md.global) markData.global = md.global; const meta = S.taskMeta[taskId] || {}; // status / work_type / access 必须是「这个题包是在哪个状态页签下查出来的」。 // // ⚠ 平台这三个值都是从网址 query 里读的,也就是**字符串**; // 服务端对类型可能是敏感的,所以这里也一律发字符串,跟平台保持一致。 const qStatus = S.query && S.query.status != null ? S.query.status : (meta.status != null ? meta.status : EDITABLE_STATUS); const qWorkType = meta.workType != null && meta.workType !== '' ? meta.workType : (S.query && S.query.workType != null ? S.query.workType : 1); const payload = { action: 'save', time: Date.now(), markData, task_id: taskId, work_type: String(qWorkType), access: '3', use_time: 0, mark_status: 0, is_random: 0, package_id: meta.packageId != null ? meta.packageId : mark.__packageId, task_key: S.taskKey, status: String(qStatus), }; const missing = ['task_id', 'work_type', 'access', 'task_key'] .filter(k => payload[k] === undefined || payload[k] === null || payload[k] === ''); if (missing.length) throw new Error('缺少请求参数:' + missing.join('、')); return { payload, diff, nextMark: next }; } /** 把属性改动写成人话,保存前给用户看 */ function describeAttrChange(before, after, target) { const out = []; for (const a of target.attrs) { const b = (before[a.pname] || []).map(p => titleOfValue(target, a.pname, p)); const f = (after[a.pname] || []).map(p => titleOfValue(target, a.pname, p)); if (b.slice().sort().join(',') === f.slice().sort().join(',')) continue; out.push(a.ptitle + ':' + (b.join('、') || '空') + ' → ' + (f.join('、') || '空')); } return out.join(';') || '(无变化)'; } function titleOfValue(target, attrPname, valuePname) { const a = (target.attrs || []).find(x => x.pname === attrPname); const v = a && a.values.find(x => x.pname === valuePname); return (v && v.ptitle) || valuePname; } /** 把服务端的报错翻译成「接下来该怎么办」 */ function explainSaveError(msg) { const s = String(msg || ''); if (/GetTaskExceedMaximumLimit/.test(s) || /领题数量/.test(s) || /领题.*上限/.test(s)) { const wt = S.query ? S.query.workType : 1; const list = claimedCountFor(wt); const lim = claimLimitFor(wt); const limTxt = lim == null ? '(平台没配上限或没读到)' : ('上限 ' + lim + ' 个'); return (s || 'GetTaskExceedMaximumLimit') + '\n\n领题限制:**单个用户在这个工序下能同时领的题包数**,' + limTxt + '。' + '\n达到限制后必须**完成/提交进行中的题**才能领新的 —— 光保存不算完成。' + '\n\n本次会话在「' + stageNameOf(wt) + '」已经领了 ' + list.length + ' 个题包:' + (list.join('、') || '(无)') + '\n\n该做的:把已经领到的题包**提交**掉,再回来改新的。' + '\n最稳的干法是**一个题包一个题包地改** —— 用「题包」那一行筛出单个题包,' + '改完去平台提交,再换下一个。'; } if (/工序/.test(s) && /流转/.test(s)) { const cur = S.query ? (stageNameOf(S.query.workType) + ' · ' + statusNameOf(S.query.status)) : '未知'; return s + '\n\n这一题已经不在「待处理」了,所以改不了。\n' + '当前看的是:' + cur + '\n' + '把上面的「状态」切到「待处理」(工序选对)→ 重新加载 → 再改。'; } if (/权限|没有权限|无权限/.test(s)) return s + '\n\n这个账号在这一道工序下没有编辑权限。'; if (/参数错误/.test(s)) return s + '\n\n参数不对,请在控制台看「保存更正」打出来的 payload。'; return s; } /** * 「领题」。 * * 平台的「编辑」按钮是这么走的(模块 e5/b 的 getReceiveStatus): * 1. PUT /v2/task-batches/{task_key}/packages/{package_id}/receive-status * body = {task_id, access, work_type, status, is_random, filter_*} * 2. 服务端把这一题记到你名下(状态从「待处理」变「进行中」),返回 task_ids * 3. 再打开 /w/task.html 去编辑 * * 平台有两种领法,取决于点的是哪一行: * · 行内「编辑」(展开行里的那个)→ body 带 task_id → **只领这一题** * · 题包级「编辑」 → body 不带 task_id → **整个题包全领** * 我们先按第一种(影响最小);如果服务端不认,再退回第二种(平台自己也是这么干的)。 * * ⚠ 领过之后这一题就从「待处理」变成「进行中」了 —— * 所以在「待处理」列表里它就消失了,要再看到它得把状态切到「进行中」或「全部」。 */ async function receiveTask(mark) { const taskId = mark.__taskId; if (S.received[taskId]) return { skipped: true, mode: S.received[taskId] }; const meta = S.taskMeta[taskId] || {}; const q = S.query || {}; const base = { // 这三个平台是从页签拿的,而且都是**字符串** access: '3', work_type: String(meta.workType != null ? meta.workType : (q.workType != null ? q.workType : 1)), status: String(q.status != null ? q.status : EDITABLE_STATUS), is_random: 0, // 平台会把当前筛选条件一起带上(默认都是空串),照抄 filter_package_id: '', filter_task_id: '', filter_file_name: '', filter_seg_dir_name: '', filter_operate_work_type: '', filter_operate_users: '', filter_mark_status: '', filter_operate_type: '', is_issues: '', }; const pkgId = meta.packageId != null ? meta.packageId : mark.__packageId; const url = `/v2/task-batches/${S.taskKey}/packages/${pkgId}/receive-status`; // 领一个新的题包之前,先把「这个工序已经领了几个 / 上限多少」摆出来 const wt = String(base.work_type); const claimedList = claimedCountFor(wt); if (claimedList.indexOf(String(pkgId)) < 0) { const lim = claimLimitFor(wt); if (lim != null && claimedList.length >= lim) { warn('这个工序已经领了 ' + claimedList.length + ' 个题包(上限 ' + lim + '):' + claimedList.join('、') + '。再领 (' + pkgId + ') 大概率会被服务端拒绝。'); } else if (lim == null && claimedList.length) { log('这个工序已经领了 ' + claimedList.length + ' 个题包(平台配的是「无限制」或还没读到上限)'); } } const markClaimed = () => { if (!S.claimed[wt]) S.claimed[wt] = {}; S.claimed[wt][String(pkgId)] = true; }; // ① 只领这一题 const one = Object.assign({ task_id: taskId }, base); log('先领题(只领这一题):', { url, body: one }); let res = await apiPut(url, one); if (isOk(res)) { S.received[taskId] = 'task'; markClaimed(); return { res, mode: 'task' }; } const firstMsg = (res && res.msg) || ''; // 领题上限:再试整包领也没意义,直接把该说的话说清楚 if (isClaimLimitError(res)) { throw new Error(explainSaveError(firstMsg || 'GetTaskExceedMaximumLimit')); } // ② 服务端不认单题领法 → 退回整包领(平台题包级「编辑」就是这么发的) log('单题领法被拒(' + firstMsg + '),改用整包领法重试'); const whole = Object.assign({}, base); log('先领题(整包):', { url, body: whole }); res = await apiPut(url, whole); if (isOk(res)) { S.received[taskId] = 'package'; markClaimed(); warn('整包已领取(题包 ' + pkgId + ' 下的题都会变成「进行中」并记到你名下)'); return { res, mode: 'package' }; } throw new Error(explainSaveError((res && res.msg) ? res.msg : ('领题失败(code=' + JSON.stringify(res && res.code) + ')'))); } /** 是不是「领题数量超限」 */ const isClaimLimitError = (res) => { const s = String((res && (res.code || '')) + ' ' + (res && (res.msg || ''))); return /GetTaskExceedMaximumLimit/i.test(s) || /领题|领取.*上限|超过.*题/.test(s); }; /** * 保存一处改动。 * 失败时原样抛出服务端的话,绝不动本地数据 —— 免得界面显示的和平台里的不一致。 */ let saving = false; async function saveMarkChange(mark, newClsPname, attrSel) { if (saving) throw new Error('正在保存,请稍候…'); if (!isEditableQuery()) { throw new Error('当前看的是「' + statusNameOf(S.query && S.query.status) + '」,只有「待处理」和「进行中」的题才改得动。\n请把上面的「状态」切过去再重新加载。'); } const plan = buildSavePayload(mark, newClsPname, attrSel); saving = true; try { log('保存更正:', { taskId: mark.__taskId, markIndex: mark.__markIndex, diff: plan.diff, payload: plan.payload }); // 「待处理」的题还没人领 → 必须先领(平台点「编辑」也是这么做的)。 // 「进行中」的题**已经是你的了** → 直接存;万一服务端说不行,再补领一次重试。 // (这样能少领题包 —— 领题是有名额的) if (!isInProgressQuery()) await receiveTask(mark); let res = await apiPost('/v2/tasks/mark-data', plan.payload); if (!isOk(res) && isInProgressQuery() && res && /工序|流转|领取|未领/.test(String(res.msg || ''))) { log('直接存被拒(' + res.msg + '),补领一次再重试'); await receiveTask(mark); res = await apiPost('/v2/tasks/mark-data', plan.payload); } if (!isOk(res)) { throw new Error(explainSaveError((res && res.msg) ? res.msg : ('保存失败(code=' + JSON.stringify(res && res.code) + ')'))); } // 只有服务端确认成功才动本地数据 S.taskData[mark.__taskId].marks[mark.__markIndex] = plan.nextMark; // 界面用「数组形态」(带中文标题),保存态留在 S.taskData 里 —— 两者不要混 const target = (S.catalog || []).find(c => c.pname === newClsPname); const live = S.marks.find(m => m.__taskId === mark.__taskId && m.__markIndex === mark.__markIndex); if (live && target) { live.class = [{ pname: target.pname, ptitle: target.ptitle, preset_id: target.preset_id || '', color: target.color, }]; live.attrs = target.attrs.map(a => ({ pname: a.pname, ptitle: a.ptitle, value: ((attrSel || {})[a.pname] || []).slice(), pvalue: a.values.map(v => ({ pname: v.pname, ptitle: v.ptitle })), })); } S.edits[mark.__taskId + ':' + mark.__markIndex] = true; return { diff: plan.diff, res }; } finally { saving = false; } } // ========================================================================= // 5. 绘图:裁剪 + 细线框 // ========================================================================= /** 取一个数值字段(不同版本可能用不同的键名) */ function numOf(o, keys) { for (const k of keys) { const v = o[k]; if (typeof v === 'number' && isFinite(v)) return v; if (typeof v === 'string' && v.trim() !== '' && isFinite(Number(v))) return Number(v); } return null; } const PT_X = ['x', 'X', 'left', 'lx', 'x1']; const PT_Y = ['y', 'Y', 'top', 'ty', 'y1']; const PT_X2 = ['x2', 'right', 'rx']; const PT_Y2 = ['y2', 'bottom', 'by']; const PT_W = ['width', 'w']; const PT_H = ['height', 'h']; /** * 几何可能挂在这些字段上 —— 不同标注类型、不同版本叫法不一样, * 所以不能只认 `point`。少认一个字段的后果就是「只能看整图」。 */ const GEOM_FIELDS = ['point', 'points', 'finshPoints', 'finishPoints', 'finish_points', 'inside_points', 'insidePoints', 'pointsArray', 'cuboid', 'rect', 'bbox', 'box', 'polygon', 'shape', 'geometry', 'data', 'annotation']; /** * 从任意形状里把坐标点抠出来。支持: * {x,y} 单点 * [{x,y}, …] 点列 * {x,y,width,height} 矩形(左上角 + 宽高) * {x,y,x2,y2} / {left,top,right,bottom} 对角 * "12,34 56,78" 坐标字符串 * 以及上面任意一种再套 {data:…} / {annotation:{data:…}} */ function extractPoints(node, out, depth) { if (node == null || (depth || 0) > 6 || out.length > 20000) return; if (Array.isArray(node)) { for (const it of node) extractPoints(it, out, (depth || 0) + 1); return; } if (typeof node === 'string') { const nums = node.match(/-?\d+(?:\.\d+)?/g); if (nums && nums.length >= 4 && nums.length % 2 === 0) { for (let i = 0; i < nums.length; i += 2) out.push([Number(nums[i]), Number(nums[i + 1])]); } return; } if (typeof node !== 'object') return; const x = numOf(node, PT_X), y = numOf(node, PT_Y); const x2 = numOf(node, PT_X2), y2 = numOf(node, PT_Y2); const w = numOf(node, PT_W), h = numOf(node, PT_H); // 先判「矩形描述」—— 否则 {x,y,width,height} 会被当成一个孤零零的点。 // ⚠ 必须**补成四个角**,不能只给两个对角点: // 绘图是按点连线的,两个点连起来就是一条对角线,框会画成一根斜线。 if (x != null && y != null && x2 != null && y2 != null) { out.push([x, y], [x2, y], [x2, y2], [x, y2]); return; } if (x != null && y != null && w != null && h != null && w > 0 && h > 0) { out.push([x, y], [x + w, y], [x + w, y + h], [x, y + h]); return; } if (x != null && y != null) { out.push([x, y]); return; } for (const k of GEOM_FIELDS) { if (node[k] != null) extractPoints(node[k], out, (depth || 0) + 1); } } function pointsOf(mark) { if (!mark || typeof mark !== 'object') return []; if (mark.type === 'point') { const one = []; extractPoints(mark.point != null ? mark.point : { x: mark.x, y: mark.y }, one, 0); if (one.length) return one; } for (const k of GEOM_FIELDS) { if (mark[k] == null) continue; const got = []; extractPoints(mark[k], got, 0); if (got.length) return got; } // 最后兜底:几何可能直接写在 mark 自己身上(x/y/width/height、x1/y1/x2/y2、 // left/top/right/bottom、w/h …)。 // 注意这里要传 **mark 本身** —— 手动挑字段很容易漏掉别名(w/h、x1/y1), // 漏掉的后果就是「抠不出坐标 → 只能看整图」。 const out = []; extractPoints(mark, out, 0); return out; } /** 这个标注物有没有可用坐标(没有的话只能看整图) */ const hasGeometry = (mark) => pointsOf(mark).length > 0; /** * 「这一条是哪个题、题里的第几帧」。 * * 题id 用 task_id —— 平台上「子题ID」那一列就是它,比批次内的流水号有用得多 * (流水号换个筛选条件就变了,题id 是稳定的,可以拿去平台上直接查)。 * * 帧号:标注物自己带 frame 字段就用它(视频/序列题); * 没有的话退回「这一题里的第几个标注物」—— 措辞也相应改成「第 N 个」, * 不把「第几个标注物」硬说成「第几帧」。 */ const FRAME_KEYS = ['frame_index', 'frameIndex', 'frame', 'frame_id', 'frameId', 'frame_num', 'frameNo', 'frame_no', 'frame_number']; function frameNoOf(mark) { if (!mark) return null; for (const k of FRAME_KEYS) { const v = mark[k]; if (typeof v === 'number' && isFinite(v)) return { n: v, real: true }; if (typeof v === 'string' && v.trim() !== '' && isFinite(Number(v))) return { n: Number(v), real: true }; } return mark.__markIndex != null ? { n: mark.__markIndex + 1, real: false } : null; } /** 卡片 / 放大页上那行「题 xxx · 第 N 帧 · 文件名」 */ function whereLabel(mark, withFile) { const parts = ['题 ' + (mark && mark.__taskId != null ? mark.__taskId : '?')]; const fr = frameNoOf(mark); if (fr) parts.push(fr.real ? ('第 ' + fr.n + ' 帧') : ('该题第 ' + fr.n + ' 个')); if (withFile !== false && mark && mark.__fileName) parts.push(String(mark.__fileName)); return parts.join(' · '); } const holesOf = (mark) => Array.isArray(mark.gaps) ? mark.gaps.map(g => (g || []).map(p => [p.x, p.y]).filter(q => q && q.length === 2)).filter(h => h.length > 2) : []; const allPointsOf = (mark) => pointsOf(mark).concat(...holesOf(mark)); const CLOSED = ['rect', 'ellipse', 'outline', 'polygon', 'magic', 'parallel', 'cuboid', 'rect3d', '3drect']; /** 这些类型本来就是「线」,两个点就该画成一条线,不能补成矩形 */ const OPEN_TYPES = ['line', 'polyline', 'curve', 'arrow', 'skeleton', 'lane', 'link']; const clamp3 = (lo, v, hi) => (!(hi > lo) ? lo : Math.max(lo, Math.min(v, hi))); 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; let w = mw + pad * 2, h = mh + pad * 2; const iw = (img && img.naturalWidth) || 0, ih = (img && img.naturalHeight) || 0; if (!iw || !ih) return { x: x0 - pad, y: y0 - pad, w, h }; // 装不下就等比例缩小;再以标注物中心居中,推回图片范围内 if (w > iw || h > ih) { const fit = Math.min(iw / w, ih / h); w *= fit; h *= fit; } 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 imgFails = []; /** * 加载图片。 * 关键:**失败不进缓存**,并且会自动带个随机参数重试一次。 * 之前失败也会被缓存成 null,一次网络抖动能让人以为「这张图永远加载不出来」。 */ function loadImage(url, isRetry) { if (!url) return Promise.resolve(null); if (imgCache.has(url)) return imgCache.get(url); const p = new Promise(resolve => { const img = new Image(); const fail = () => { imgCache.delete(url); // 失败不缓存 if (!isRetry) { const sep = url.indexOf('?') >= 0 ? '&' : '?'; resolve(loadImage(url + sep + '__dshtry=' + Date.now(), true)); return; } if (imgFails.length < 20) imgFails.push(url); warn('图片加载失败:' + url); resolve(null); }; img.onload = () => { // 个别浏览器「解码失败」也会走 onload,这里补一刀 if (!img.naturalWidth || !img.naturalHeight) { fail(); return; } resolve(img); }; img.onerror = fail; img.src = url; }); imgCache.set(url, p); return p; } 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(231,76,60,' + a + ')'; const n = parseInt(h, 16); return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`; } /** 交通灯按颜色上色:红/黄/绿一眼分得清 */ function colorOf(mark) { const n = colorName(mark); if (n === '红') return '#e74c3c'; if (n === '黄') return '#f1c40f'; if (n === '绿') return '#2ecc71'; const c = classObj(mark).color; if (c && /^#/.test(c)) return c; return '#3498db'; } /** * 推断一个标注物的颜色(红 / 黄 / 绿 / 其它)。 * 依次看:颜色属性 → 灯态属性 → 类别名 → 题包/文件名。只要任一处出现「红/黄/绿」就归到那一组。 */ function colorName(mark) { const hit = (s) => { const t = String(s == null ? '' : s).toLowerCase(); if (!t) return ''; // 注意顺序:先判「红」,再「黄」,最后「绿」,避免中英混写时误判 if (/红|red/.test(t)) return '红'; if (/黄|yellow|amber/.test(t)) return '黄'; if (/绿|green/.test(t)) return '绿'; return ''; }; const attrs = attrsOf(mark); // 1) 名字里带「颜色/color」的属性,最可信 for (const a of attrs) { if (!/颜色|color/i.test(a.pname + a.ptitle)) continue; for (const v of a.values) { const h = hit(v.ptitle) || hit(v.pname); if (h) return h; } } // 2) 其它属性的取值(有的部署把红黄绿放在「灯态」里,或者自定义属性里) for (const a of attrs) { for (const v of a.values) { const h = hit(v.ptitle) || hit(v.pname); if (h) return h; } } // 3) 类别名本身 const h3 = hit(classLabel(mark)) || hit(classKey(mark)); if (h3) return h3; // 4) 兜底:文件名 / 题名(有的批次按 r_/y_/g_ 命名) return hit(mark.__fileName) || '其它'; } // 颜色名 → 色值(用于属性筛选条上的小圆点) function dotColor(name) { const t = String(name || '').toLowerCase(); if (/红|red/.test(t)) return '#e74c3c'; if (/黄|yellow|amber/.test(t)) return '#f1c40f'; if (/绿|green/.test(t)) return '#2ecc71'; return null; } function drawMark(ctx, mark, X, Y, cw) { const color = colorOf(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; const lw = LINE_W; // ← 细线框:恒定 1.25 画布像素 ctx.fillStyle = hexToRgba(color, 0.22); ctx.strokeStyle = color; ctx.lineWidth = lw; ctx.lineJoin = 'round'; ctx.beginPath(); if (mark.type === 'point' || pts.length === 1) { ctx.arc(pts[0][0], pts[0][1], Math.max(4, cw * 0.02), 0, Math.PI * 2); } else if (pts.length === 2 && !OPEN_TYPES.includes(mark.type)) { // ⚠ 只有两个点:这是一对对角,**必须补成矩形**。 // 按点连线的话两个点就是一条对角线 —— 框会画成一根斜线。 const x0 = Math.min(pts[0][0], pts[1][0]), x1 = Math.max(pts[0][0], pts[1][0]); const y0 = Math.min(pts[0][1], pts[1][1]), y1 = Math.max(pts[0][1], pts[1][1]); ctx.moveTo(x0, y0); ctx.lineTo(x1, y0); ctx.lineTo(x1, y1); ctx.lineTo(x0, y1); ctx.closePath(); } 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.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(); ctx.strokeStyle = hexToRgba(color, 0.7); ctx.lineWidth = Math.max(0.8, lw * 0.6); ctx.stroke(); }); } /** * 把标注物所在的那一块画到画布上。 * box 传进来就用它当取景框(「看整图」时传整张图的框),否则按标注框裁剪。 */ function drawCropped(canvas, img, mark, maxW, maxH, label, boxOverride) { 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 = '#5a6a7d'; ctx.font = '13px "Microsoft YaHei",sans-serif'; ctx.fillText('图片未加载', 12, 24); return null; } const auto = boxOverride ? null : cropBoxOf(mark, img); // 抠不出坐标时退回整图 —— 但要把这件事**标出来**,不能让人以为「聚焦就是这个样子」 const box = boxOverride || auto || { x: 0, y: 0, w: img.naturalWidth, h: img.naturalHeight }; let cw = maxW, 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 cx = cw / 2, cy = ch / 2; const X = (px) => cx + (px - (box.x + box.w / 2)) * sc; const Y = (py) => cy + (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); drawMark(ctx, mark, X, Y, cw); if (label !== false) { const text = classLabel(mark); const color = colorOf(mark); const fs = Math.max(11, Math.min(15, cw * 0.05)); ctx.font = 'bold ' + fs + 'px "Microsoft YaHei",sans-serif'; const tw = ctx.measureText(text).width; const pts = pointsOf(mark).map(([x, y]) => [X(x), Y(y)]); const bh = fs + 6; const bx = clamp3(3, (pts[0] ? pts[0][0] : 4) - 3, cw - tw - 14); const by = Math.max(3, (pts[0] ? pts[0][1] : 20) - bh - 4); ctx.fillStyle = hexToRgba(color, 0.92); 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(text, bx + 6, by + bh - 4); } // 把这一帧的取景信息交回去,界面上要显示「原图多大、裁了多大、放大了几倍」 return { box, cw, ch, sc, iw: img.naturalWidth, ih: img.naturalHeight, noGeometry: !boxOverride && !auto }; } /** 整张原图的取景框 */ const fullBoxOf = (img) => ({ x: 0, y: 0, w: (img && img.naturalWidth) || 1, h: (img && img.naturalHeight) || 1, }); // ========================================================================= // 6. 查看器:缩放固定居中,拖动完全自由 // ========================================================================= 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.k = 1; this.tx = 0; this.ty = 0; this._bound = false; ['_onWheel', '_onDown', '_onMove', '_onUp', '_dbl'].forEach(n => { this[n] = this[n].bind(this); }); } load(img, mark, opts) { this.img = img; this.mark = mark; if (opts) this.opts = opts; this.mode = 'focus'; this.lastFrame = null; this.render(true); this.bind(); return this; } /** 当前取景框:整图 or 标注框裁剪 */ currentBox() { if (this.mode === 'full' && this.img) return fullBoxOf(this.img); return cropBoxOf(this.mark, this.img); } setMode(m) { if (this.mode === m) return; this.mode = m; this.render(true); if (typeof this.onModeChange === 'function') this.onModeChange(m, this.lastFrame); } render(resetView) { if (!this.mark) return; const o = this.opts || {}; // 图片没加载出来也照画:drawCropped 会画一个「图片未加载」的占位, // 比留一片空白更容易看出问题出在哪。 this.lastFrame = drawCropped(this.canvas, this.img, this.mark, o.maxW || THUMB_W, o.maxH || THUMB_H, true, this.currentBox()); if (resetView) { this.k = 1; this.tx = 0; this.ty = 0; this.apply(); this.centerInView(); } else this.apply(); } _viewRect() { const st = this.stage; if (st && st.getBoundingClientRect) { const r = st.getBoundingClientRect(); if (r.width && r.height) return r; } return null; } centerInView() { const v = this._viewRect(); if (!v) return; const r = this.canvas.getBoundingClientRect(); if (!this.canvas.width || !this.canvas.height) return; const k = this.k || 1; const w = (this.canvas.width || 0) * k, h = (this.canvas.height || 0) * k; if (!w || !h) return; const dx = (v.width - w) / 2 - (r.left - v.left); const dy = (v.height - h) / 2 - (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) { const oldK = this.k; const newK = Math.min(40, Math.max(0.05, oldK * factor)); if (newK === oldK) return; this.k = newK; this.apply(); this.centerInView(); } 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 }; } _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); } _onWheel(e) { e.preventDefault(); e.stopPropagation(); this.zoomBy(Math.exp(-(e.deltaY || 0) * 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 */ } } } // ========================================================================= // 7. 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,#e74c3c,#c0392b); color: #fff; font-size: 14px; font-weight: 600; border: none; box-shadow: 0 6px 20px rgba(231,76,60,.45); transition: transform .18s ease; } .launcher:hover { transform: translateY(-2px); } .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: 11px 18px; background: #161d26; border-bottom: 1px solid #232c38; flex: 0 0 auto; } .top h2 { margin: 0; font-size: 17px; font-weight: 700; } .top .sub { font-size: 12px; color: #8b9bb0; } .top .pick { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: #8b9bb0; } .top .pick select { background: #1c242e; border: 1px solid #2b3644; color: #e8eef5; padding: 5px 8px; border-radius: 6px; font-size: 12px; outline: none; } .top .pick select:focus { border-color: #e74c3c; } .top .editstate { font-size: 12px; padding: 3px 9px; border-radius: 10px; white-space: nowrap; } .top .editstate.ok { background: #12251a; color: #86e0a8; border: 1px solid #245c39; } .top .editstate.no { background: #3a2a17; color: #f1c40f; border: 1px solid #6b5518; cursor: pointer; } .top .editstate.no:hover { background: #4a3619; } .spacer { flex: 1; } .btn { border: 1px solid #2f3b4a; background: #1d2733; color: #cfdcea; padding: 7px 13px; border-radius: 7px; cursor: pointer; font-size: 13px; } .btn:hover { background: #26323f; } .btn.sm { padding: 4px 9px; font-size: 12px; } .btn:disabled { opacity: .45; cursor: not-allowed; } .filters { background: #131a22; border-bottom: 1px solid #202832; flex: 0 0 auto; max-height: 190px; overflow-y: auto; } .frow { display: flex; flex-wrap: wrap; gap: 7px; align-items: center; padding: 7px 18px; } .frow + .frow { border-top: 1px solid #1b232d; } .frow .cap { font-size: 12px; color: #7c8ca0; min-width: 46px; flex: 0 0 auto; } .issuehint { font-size: 11px; color: #f1c40f; margin-left: 6px; } .chip { display: inline-flex; align-items: center; gap: 6px; padding: 4px 11px; border-radius: 14px; cursor: pointer; background: #1c242e; border: 1px solid #2b3644; color: #c3d2e2; font-size: 13px; } .chip:hover { border-color: #3d4c5f; background: #222c38; } .chip.active { background: #e74c3c; border-color: #e74c3c; color: #fff; font-weight: 600; } .chip .swatch { width: 9px; height: 9px; border-radius: 50%; } .chip .cnt { font-size: 11px; opacity: .85; background: rgba(255,255,255,.12); border-radius: 8px; padding: 0 6px; } .search { margin-left: auto; } .search input { background: #1c242e; border: 1px solid #2b3644; color: #e8eef5; padding: 6px 10px; border-radius: 7px; font-size: 13px; width: 240px; outline: none; } .search input:focus { border-color: #e74c3c; } .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; display: flex; flex-direction: column; position: relative; transition: all .16s ease; } .card:hover { border-color: #e74c3c; transform: translateY(-3px); box-shadow: 0 10px 22px rgba(0,0,0,.45); } .card.selected { box-shadow: 0 0 0 2px rgba(231,76,60,.85); border-color: #e74c3c; } .card .editbadge { position: absolute; right: 8px; top: 8px; z-index: 2; font-size: 11px; padding: 1px 7px; border-radius: 9px; background: #245c39; color: #86e0a8; } .card .editbadge.warn { top: auto; bottom: 46px; background: #3a2a17; color: #f1c40f; } .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; max-width: 62%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .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 .attrs span.many { background: #3a2a17; color: #f1c40f; } .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 { color: #6c7c90; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .progress { position: absolute; left: 0; top: 0; height: 2px; background: #e74c3c; width: 0; transition: width .2s ease; } .empty { text-align: center; color: #6c7c90; padding: 70px 20px; font-size: 14px; line-height: 2; } .rawbox { margin: 22px auto 0; max-width: 1000px; text-align: left; background: #10161d; border: 1px solid #26313d; border-radius: 8px; padding: 12px 14px; font-size: 12px; line-height: 1.9; color: #93a4b8; word-break: break-all; font-family: Consolas,Menlo,monospace; } .lightbox { position: absolute; inset: 0; background: rgba(5,8,12,.95); 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; } .lb-body { flex: 1; display: flex; min-height: 0; } .lb-stage { flex: 1; position: relative; overflow: hidden; cursor: grab; background: repeating-conic-gradient(#131a22 0% 25%, #0f141b 0% 50%) 50% / 22px 22px; } .lb-inner { position: absolute; left: 0; top: 0; transform-origin: 0 0; } .lb-inner canvas { display: block; transform-origin: 0 0; } /* 右侧编辑面板 */ .lb-side { width: 342px; flex: 0 0 auto; overflow-y: auto; background: #121922; border-left: 1px solid #1e2731; padding: 12px 14px 40px; font-size: 13px; } .lb-side h3 { margin: 0 0 10px; font-size: 14px; color: #e8eef5; font-weight: 700; } .lb-side .sec { margin-bottom: 16px; } .lb-side .sec > .lbl { display: block; font-size: 12px; color: #7c8ca0; margin-bottom: 6px; } .lb-side select { width: 100%; background: #1c242e; border: 1px solid #2b3644; color: #e8eef5; padding: 7px 9px; border-radius: 7px; font-size: 13px; outline: none; } .lb-side select:focus { border-color: #e74c3c; } .vchips { display: flex; flex-wrap: wrap; gap: 6px; } .vchip { padding: 4px 10px; border-radius: 13px; cursor: pointer; background: #1c242e; border: 1px solid #2b3644; color: #c3d2e2; font-size: 12px; user-select: none; } .vchip:hover { border-color: #3d4c5f; } .vchip.on { background: #e74c3c; border-color: #e74c3c; color: #fff; font-weight: 600; } .modetog { font-style: normal; font-size: 11px; padding: 1px 7px; border-radius: 9px; background: #1c242e; border: 1px solid #2b3644; color: #8b9bb0; cursor: pointer; margin-left: 6px; } .modetog:hover { border-color: #e74c3c; color: #e8eef5; } .lb-side .warnline { font-size: 11px; line-height: 1.7; color: #f1c40f; background: #3a2a17; border: 1px solid #6b5518; border-radius: 6px; padding: 5px 8px; margin-bottom: 6px; } .lb-side .ver { font-style: normal; font-size: 11px; font-weight: 400; color: #5a6a7d; margin-left: 4px; } .lb-side .pending, .top .ver { font-family: inherit; } .pending { background: #10161d; border: 1px solid #2b3644; border-radius: 7px; padding: 8px 10px; font-size: 12px; line-height: 2; color: #93a4b8; } .pending b { color: #7c8ca0; font-weight: 600; margin-right: 2px; } .pending s { color: #ff9c8f; text-decoration: line-through; } .pending em { color: #86e0a8; font-style: normal; font-weight: 600; } .pending i { color: #5a6a7d; font-style: normal; } .pending.none { color: #5a6a7d; } .top .ver { font-size: 11px; font-weight: 400; color: #5a6a7d; margin-left: 4px; } .lb-side .was { font-size: 11px; color: #6c7c90; margin-top: 4px; } .btn.wide { width: 100%; padding: 9px; font-size: 13px; font-weight: 600; } .btn.primary { background: #e74c3c; border-color: #e74c3c; color: #fff; } .btn.primary:hover { background: #d4402f; } .btn.primary:disabled { background: #3a2a28; border-color: #4a3431; color: #8a7570; } .lb-side .diffbox { margin-top: 10px; background: #10161d; border: 1px solid #26313d; border-radius: 7px; padding: 9px 11px; font-size: 12px; line-height: 1.8; color: #93a4b8; } .lb-side .diffbox.ok { border-color: #245c39; color: #86e0a8; } .lb-side .diffbox.err { border-color: #7d2d24; color: #ffb3aa; } .lb-side .badge2 { display: inline-block; font-size: 11px; padding: 1px 7px; border-radius: 9px; background: #245c39; color: #86e0a8; margin-left: 6px; } .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.7; } .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, lbViewer = 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 = `

🚦 红绿灯 · 整批次总览 v${VERSION}

就绪 ← → 移动 A/D 翻页 Ctrl+滚轮 放大 Esc 退出
1 / 1
`; shadow.appendChild(wrap); $ = (id) => shadow.getElementById(id); $('lg-launch').addEventListener('click', openPanel); $('lg-close').addEventListener('click', closePanel); $('lg-reload').addEventListener('click', () => load(true)); $('lg-wt').addEventListener('change', () => { S.query = { workType: Number($('lg-wt').value), status: S.query ? S.query.status : 0, from: 'user' }; load(true); }); $('lg-st').addEventListener('change', () => { S.query = { workType: S.query ? S.query.workType : 1, status: Number($('lg-st').value), from: 'user' }; load(true); }); // 「不可改」那个黄标签点一下 = 一键切到待处理 $('lg-editstate').addEventListener('click', () => { if (isEditableQuery()) return; S.query = { workType: S.query ? S.query.workType : 1, status: EDITABLE_STATUS, from: 'user' }; load(true); }); $('lg-prev').addEventListener('click', () => { if (S.page > 1) { S.page--; renderGrid(); } }); $('lg-next').addEventListener('click', () => { const total = Math.max(1, Math.ceil(filtered().length / PAGE_SIZE)); if (S.page < total) { S.page++; renderGrid(); } }); $('lg-lb-close').addEventListener('click', closeLightbox); $('lg-lb-mode').addEventListener('click', () => { if (lbViewer) lbViewer.setMode(lbViewer.mode === 'full' ? 'focus' : 'full'); }); $('lg-lb-in').addEventListener('click', () => lbViewer && lbViewer.zoomBy(1.3)); $('lg-lb-out').addEventListener('click', () => lbViewer && lbViewer.zoomBy(1 / 1.3)); lbViewer = new Viewer($('lg-lb-canvas'), $('lg-lb-stage')); $('lg-lb-stage').addEventListener('wheel', (e) => { e.preventDefault(); e.stopPropagation(); lbViewer.zoomBy(Math.exp(-(e.deltaY || 0) * 0.0015)); }, { passive: false }); document.addEventListener('keydown', onKeyDown); } function toast(msg, kind) { const t = $('lg-toast'); t.textContent = msg; t.className = 'toast show' + (kind ? ' ' + kind : ''); clearTimeout(toastTimer); toastTimer = setTimeout(() => { t.className = 'toast'; }, kind === 'err' ? 9000 : 2800); } const setStatus = (s) => { $('lg-status').textContent = s; }; /** * 工序 / 状态下拉框 + 「能不能改」的提示。 * 只有「待处理」的题改得动 —— 这是平台自己的规则(其它状态服务端会回「该题工序已经流转」), * 所以这里必须让用户看得见、一键能切。 */ function renderQueryBar() { const stages = S.stageOptions && S.stageOptions.length ? S.stageOptions : FALLBACK_STAGE; const stats = S.statusOptions && S.statusOptions.length ? S.statusOptions : FALLBACK_STATUS; const q = S.query || {}; $('lg-wt').innerHTML = stages.map(o => ``).join(''); $('lg-st').innerHTML = stats.map(o => ``).join(''); const box = $('lg-editstate'); if (isEditableQuery()) { box.className = 'editstate ok'; box.textContent = isInProgressQuery() ? '可修改(进行中·已经是你的)' : '可修改(待处理)'; box.title = isInProgressQuery() ? '「进行中」的题已经领到你名下了,直接改就行,不用再领一次' : '「待处理」的题还没人领,保存时会先替你领这一题'; } else { box.className = 'editstate no'; box.textContent = '不可改(点这里切到待处理)'; box.title = '只有「待处理」和「进行中」的题改得动;' + '其它状态题已经流转走了,保存时服务端会回「该题工序已经流转」'; } // 领了哪几个题包 —— 领题限制是**按工序**算的,所以要一直看得见 const claimed = claimedCountFor(S.query && S.query.workType); const lim = claimLimitFor(S.query && S.query.workType); const cw = $('lg-claimed'); if (cw) { if (!claimed.length) { cw.style.display = 'none'; } else { cw.style.display = ''; const over = lim != null && claimed.length >= lim; cw.className = 'editstate ' + (over ? 'no' : 'ok'); cw.textContent = lim == null ? ('已领题包 ' + claimed.length) : ('已领题包 ' + claimed.length + '/' + lim); cw.title = '领题限制:单个用户在这个工序下能同时领的题包数' + (lim == null ? '(平台没配上限,或还没读到)' : ('(平台配的是 ' + lim + ' 个)')) + '\n已达上限后必须**完成/提交进行中的题**才能领新的。' + '\n本工序已领:' + claimed.join('、'); } } } function paintProgress(p, txt) { $('lg-progress').style.width = Math.round(Math.min(1, Math.max(0, p)) * 100) + '%'; if (txt) { const el = $('lg-body'); if (el && S.loading) el.innerHTML = `
${esc(txt)}
`; setStatus(txt); } } function openPanel() { S.open = true; $('lg-overlay').classList.add('show'); if (!S.loaded && !S.loading) load(false); } function closePanel() { S.open = false; $('lg-overlay').classList.remove('show'); closeLightbox(); } async function load(force) { if (S.loading) return; S.loading = true; S.loaded = false; S.page = 1; S.filters = { cls: '', attrs: {} }; S.lastRaw = null; $('lg-progress').style.width = '0%'; $('lg-filters').innerHTML = ''; $('lg-body').innerHTML = '
正在读取整批次数据…
'; setStatus('加载中…'); try { await loadBatch(); S.loading = false; S.loaded = true; renderAll(); renderQueryBar(); $('lg-count').textContent = S.marks.length + ' 个'; const dl = S.declaredPackages; const pkgTxt = '题包 ' + S.stats.packages + (dl != null && dl !== S.stats.packages ? '(服务器报告 ' + dl + ')' : ''); const dupTxt = S.stats.dupTasks ? ',重复 ' + S.stats.dupTasks + ' 条已去重' : ''; setStatus(`${pkgTxt} · 题 ${S.stats.tasks}${dupTxt}(成功 ${S.stats.ok}/失败 ${S.stats.fail})· 标注物 ${S.marks.length}`); if (dl != null && dl > S.stats.packages) { toast('只读到 ' + S.stats.packages + ' 个题包,服务器说有 ' + dl + ' 个。\n请看控制台的「题包列表探测结果」。', 'err'); } // 读到了标注物但一条类别都认不出来 —— 说明这个部署的字段名不一样, // 与其默默显示一堆「(未分类)」,不如直接说清楚。 if (S.marks.length && !classStats().length) { toast('读到了 ' + S.marks.length + ' 个标注物,但一个类别都没解析出来。\n' + '请在控制台执行 __红绿灯诊断(),把结果发出来。', 'err'); } if (S.stats.noImg) { toast('有 ' + S.stats.noImg + ' 题没解析出图片地址,卡片会显示「图片未加载」。\n' + '请在控制台执行 __红绿灯诊断()。', 'err'); } setTimeout(() => { $('lg-progress').style.width = '0%'; }, 700); } catch (e) { S.loading = false; console.error(e); $('lg-body').innerHTML = `
加载失败
${esc(e.message)}

确认已登录、且当前页面是项目任务页(网址里有 task_key)。 ${rawBlock()}
`; setStatus('加载失败'); toast(e.message, 'err'); } } /** * 把「接口到底返回了什么」直接画在界面上。 * 读不到数据的时候,与其让人开 F12 翻网络面板,不如把原始返回摆出来。 */ function rawBlock() { const parts = []; if (S.platformState && S.platformState.via) { parts.push('从页面读到:阶段 work_type=' + S.platformState.workType + ',状态 status=' + S.platformState.status + ',表格里有 ' + (S.platformState.packageCount || 0) + ' 个题包'); } else { parts.push('没能从页面上读到状态(会退回参数探测)'); } if (S.packageTried && S.packageTried.length) { parts.push('参数探测:' + S.packageTried.join(' ')); } if (S.lastRaw) { let body; try { body = JSON.stringify(S.lastRaw.res); } catch (err) { body = String(S.lastRaw.res); } if (body && body.length > 1200) body = body.slice(0, 1200) + ' …(已截断)'; let qs; try { qs = new URLSearchParams( Object.keys(S.lastRaw.params || {}) .filter(k => S.lastRaw.params[k] !== undefined && S.lastRaw.params[k] !== '') .map(k => [k, S.lastRaw.params[k]]) ).toString(); } catch (err) { qs = ''; } parts.push('最后一次请求:GET ' + esc(S.lastRaw.url) + (qs ? '?' + esc(qs) : '')); parts.push('服务器返回:' + esc(body)); } if (!parts.length) return ''; return `
${parts.map(p => esc(p)).join('
')}
`; } // ── 筛选条 ──────────────────────────────────────────────────────────────── /** 出现过的类别(按数量降序)—— 忽略类别自身的筛选,这样各类别的数字不会互相吃掉 */ function classStats() { const map = new Map(); for (const m of filtered('cls')) { const k = classKey(m) || '__NONE__'; if (!map.has(k)) map.set(k, { key: k, label: classLabel(m), color: colorOf(m), count: 0 }); map.get(k).count++; } return Array.from(map.values()).sort((a, b) => b.count - a.count); } /** 所有出现过的属性名(按「颜色 / 灯态 优先」排序) */ function attrNames() { const set = new Map(); for (const m of S.marks) { for (const a of attrsOf(m)) { if (!a.pname) continue; if (!set.has(a.pname)) set.set(a.pname, a.ptitle || a.pname); } } const weight = (pname, ptitle) => { const t = pname + ptitle; if (/颜色|color/i.test(t)) return 0; if (/灯态|状态|state|形状|shape|类型|type/i.test(t)) return 1; return 2; }; return Array.from(set.entries()) .map(([pname, ptitle]) => ({ pname, ptitle })) .sort((a, b) => weight(a.pname, a.ptitle) - weight(b.pname, b.ptitle) || a.pname.localeCompare(b.pname)); } /** 平台自己有没有「颜色 / color」这个属性(有的话就不需要合成颜色行) */ const isColorName = (a) => /颜色|color/i.test(a.pname + a.ptitle); const hasColorAttr = (names) => (names || attrNames()).some(isColorName); /** * 某个属性的取值统计。 * 刻意**忽略这个属性自己的筛选条件**(分面计数): * 否则点了「红色」之后,颜色那一行就只剩红色可选,用户没法改选黄色。 */ /** * 这个类别**本来就该有**这个属性吗? * * 用来区分两种「没有」: * · 灯板这个类别压根没配「颜色」属性 → 它「没有颜色」是天经地义的,不该报出来; * · 红绿灯这个类别配了「颜色」,某一条却没有 → 这才叫**丢了属性**,必须报出来。 * * 没有这个区分的话,属性行里会冒出一大堆没意义的「(没有这个属性)」, * 把真正丢属性的那几条淹掉。 */ function classHasAttr(clsPname, attrPname) { const cat = catalogNow(); const c = cat.find(x => x.pname === clsPname); // 目录里压根没这个类别 → 判断不了,就当成「可能该有」—— // 宁可多显示一项,也不要因为判断不了就把真问题藏掉 if (!c) return true; return c.attrs.some(a => a.pname === attrPname); } /** 类别目录:优先用加载时建好的;没有(或还没建)就按当前 S.marks 现算一份并缓存 */ let _catCache = null, _catFor = null; function catalogNow() { if (S.catalog && S.catalog.length) return S.catalog; if (_catFor !== S.marks) { _catCache = buildCatalog(); _catFor = S.marks; } return _catCache; } /** 某个属性「说得上话」的标注物:所在类别确实配了这个属性的那些 */ const attrScope = (pname, skip) => filtered(skip || ('attr:' + pname)).filter(m => classHasAttr(classKey(m), pname)); // ── 几何类自检要用的两个小工具(都不需要读图片像素)────────────────────── /** 标注物的包围盒(原图像素) */ function boxOfMark(mark) { const pts = pointsOf(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; } return { x: x0, y: y0, w: Math.max(x1 - x0, 0), h: Math.max(y1 - y0, 0) }; } /** 「同一题里同类别、几乎完全重叠」的标注物集合 —— 用来找重复标注 */ function dupBoxSet() { if (_dupFor === S.marks) return _dupSet; _dupFor = S.marks; _dupSet = new Set(); const byTask = new Map(); for (const m of S.marks) { const k = String(m.__taskId); if (!byTask.has(k)) byTask.set(k, []); byTask.get(k).push(m); } byTask.forEach(list => { for (let i = 0; i < list.length; i++) { const a = boxOfMark(list[i]); if (!a) continue; for (let j = i + 1; j < list.length; j++) { if (classKey(list[i]) !== classKey(list[j])) continue; // 不同类别叠着是正常的 const b = boxOfMark(list[j]); if (!b) continue; const inter = Math.max(0, Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x)) * Math.max(0, Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y)); const uni = a.w * a.h + b.w * b.h - inter; if (uni > 0 && inter / uni > 0.9) { _dupSet.add(markKeyOf(list[i])); _dupSet.add(markKeyOf(list[j])); } } } }); return _dupSet; } let _dupFor = null, _dupSet = new Set(); // ── 唯一一个「看图」的检查:颜色 vs 画面像素 ───────────────────────────── // 这不是「看懂图片」,只是把框内的像素取出来算个主色相,和「红色/黄色/绿色」对一下。 // ⚠ 图片在另一个端口(18088),跟页面不同源 —— 浏览器可能禁止读像素。 // 真的读不了会**明说**并且把结果标成不可用,绝不假装通过。 let pixelAccess = null; // null = 还没试过;true/false = 浏览器允不允许 const hueName = (deg) => { if (deg == null) return null; if (deg < 20 || deg >= 330) return '红'; if (deg >= 20 && deg < 45) return '橙'; // 交通灯的黄偏橙,归到黄 if (deg >= 40 && deg < 75) return '黄'; if (deg >= 75 && deg < 170) return '绿'; return null; // 蓝/紫这些不是灯的颜色,不算 }; /** 框内的主色相(只看够鲜艳、够亮的像素,避开灰背景) */ function dominantLampHue(img, box) { const c = document.createElement('canvas'); c.width = 48; c.height = 48; const x = c.getContext('2d'); x.drawImage(img, box.x, box.y, Math.max(box.w, 1), Math.max(box.h, 1), 0, 0, 48, 48); let data; try { data = x.getImageData(0, 0, 48, 48).data; } catch (e) { if (pixelAccess !== false) { pixelAccess = false; warn('浏览器不允许读这张图的像素(跨域限制),颜色核对用不了:' + e.message); } return { blocked: true }; } pixelAccess = true; let sx = 0, sy = 0, n = 0; for (let i = 0; i < data.length; i += 4) { const r = data[i] / 255, g = data[i + 1] / 255, b = data[i + 2] / 255; const mx = Math.max(r, g, b), mn = Math.min(r, g, b); const sat = mx === 0 ? 0 : (mx - mn) / mx; if (sat < 0.35 || mx < 0.25) continue; // 灰的、暗的都不要 let h; const d = mx - mn; if (d === 0) h = 0; else if (mx === r) h = 60 * (((g - b) / d) % 6); else if (mx === g) h = 60 * ((b - r) / d + 2); else h = 60 * ((r - g) / d + 4); if (h < 0) h += 360; sx += Math.cos(h * Math.PI / 180); sy += Math.sin(h * Math.PI / 180); n++; } if (!n) return { hue: null, ratio: 0 }; let h = Math.atan2(sy / n, sx / n) * 180 / Math.PI; if (h < 0) h += 360; return { hue: h, ratio: n / (48 * 48) }; } /** 某个属性的取值统计。 * 刻意**忽略这个属性自己的筛选条件**(分面计数): * 否则点了「红色」之后,颜色那一行就只剩红色可选,用户没法改选黄色。 */ function valuesOf(pname) { const map = new Map(); const bump = (key, ptitle) => { if (!map.has(key)) map.set(key, { pname: key, ptitle, count: 0 }); map.get(key).count++; }; for (const m of attrScope(pname)) { const row = attrsOf(m).find(a => a.pname === pname); // 类别配了这个属性、这一条却没有 —— 这才是真丢了属性,单独归一类 if (!row) { bump(ABSENT, '(没有这个属性)'); continue; } if (!row.values.length) { bump('__EMPTY__', '(未填)'); continue; } for (const v of row.values) { if (!map.has(v.pname)) map.set(v.pname, { pname: v.pname, ptitle: v.ptitle, count: 0 }); map.get(v.pname).count++; } } // 两个「异常项」永远沉到最后,正常取值按数量排前面 const rank = (x) => (x.pname === ABSENT ? 2 : (x.pname === '__EMPTY__' ? 1 : 0)); return Array.from(map.values()).sort((a, b) => rank(a) - rank(b) || b.count - a.count); } const renderAll = () => { renderFilters(); renderGrid(); }; function renderFilters() { const box = $('lg-filters'); const kw = (box.querySelector('.search input') || {}).value || S.keyword; box.innerHTML = ''; const row = (caption, nodes) => { const r = document.createElement('div'); r.className = 'frow'; const cap = document.createElement('span'); cap.className = 'cap'; cap.textContent = caption; r.appendChild(cap); nodes.forEach(n => r.appendChild(n)); box.appendChild(r); return r; }; const chip = (label, count, color, active, onClick) => { const c = document.createElement('span'); c.className = 'chip' + (active ? ' active' : ''); c.innerHTML = (color ? `` : '') + esc(label) + (count != null ? `${count}` : ''); c.addEventListener('click', onClick); return c; }; // 第一行:类别 + 搜索框 const clsNodes = [chip('全部', filtered('cls').length, null, !S.filters.cls, () => { S.filters.cls = ''; S.page = 1; renderAll(); })]; for (const c of classStats()) { clsNodes.push(chip(c.label, c.count, c.color, S.filters.cls === c.key, () => { S.filters.cls = S.filters.cls === c.key ? '' : c.key; S.page = 1; renderAll(); })); } const r1 = row('类别', clsNodes); 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; renderGrid(); }, 220); }); r1.appendChild(search); // 题包行:领题有上限(一个人大概 3 个题包),所以最稳的干法是**一个题包一个题包地改**, // 改完提交掉再换下一个。这一行就是给这个用法准备的。 if (S.packages && S.packages.length > 1) { const pkgCount = new Map(); for (const m of filtered('pkg')) { const k = String(m.__packageId == null ? '' : m.__packageId); pkgCount.set(k, (pkgCount.get(k) || 0) + 1); } if (pkgCount.size > 1) { const pkgName = (id) => { const p = (S.packages || []).find(x => String(pkgIdOf(x)) === String(id)); const seg = p && (p.seg_dir_name || p.package_name); return seg ? String(seg) : ('题包 ' + id); }; const nodes = [chip('全部', filtered('pkg').length, null, !S.filters.pkg, () => { S.filters.pkg = ''; S.page = 1; renderAll(); })]; Array.from(pkgCount.entries()).sort((a, b) => b[1] - a[1]).forEach(([id, n]) => { const claimed = !!(S.claimedPackages || {})[id]; nodes.push(chip(pkgName(id) + (claimed ? ' ✓已领' : ''), n, claimed ? '#f1c40f' : null, String(S.filters.pkg) === String(id), () => { S.filters.pkg = String(S.filters.pkg) === String(id) ? '' : id; S.page = 1; renderAll(); })); }); row('题包', nodes); } } // 自检行:**只查数据自不自洽**,不判断标注内容对不对(那要看图) { const stats = issueStats(); const total = stats.reduce((s, x) => s + x.count, 0); const cur = S.filters.issue || ''; const PC = S.pixelCheck; let nodes; if (!total) { nodes = [chip('✓ 数据自检没发现问题(不代表标注内容是对的)', null, '#2ecc71', false, () => {})]; } else { nodes = [chip('全部', filtered('issue').length, null, !cur, () => { S.filters.issue = ''; S.page = 1; renderAll(); })]; stats.filter(x => x.count).forEach(x => { nodes.push(chip(x.label, x.count, '#f1c40f', cur === x.key, () => { S.filters.issue = cur === x.key ? '' : x.key; S.page = 1; renderAll(); })); }); } // 唯一沾「看图」的一项,单独一个按钮 —— 跑得慢,而且可能被跨域挡住 const imgBtn = document.createElement('span'); imgBtn.className = 'modetog'; if (PC.running) { imgBtn.textContent = '正在核对画面… ' + PC.done + '/' + PC.total; } else if (PC.blocked) { imgBtn.textContent = '画面核对不可用(跨域限制)'; imgBtn.title = '浏览器不允许读这些图片的像素(图片在另一个源上)。' + '这不是脚本出错,是浏览器的安全限制 —— 只能靠人眼看。'; imgBtn.style.color = '#7c8ca0'; imgBtn.style.cursor = 'not-allowed'; } else { imgBtn.textContent = PC.total ? '重新核对画面颜色' : '用图片核对颜色'; imgBtn.title = '取出每个框里的像素算主色,和「颜色」属性对一下。\n' + '⚠ 它看不懂图,只能对颜色;判断不了「框里到底是不是红绿灯」。'; imgBtn.addEventListener('click', () => { const scope = filtered().slice(); imgBtn.textContent = '正在核对画面… 0/' + scope.filter(m => attrsOf(m).some(a => /颜色|color/i.test(a.pname + a.ptitle))).length; runPixelColorCheck(scope).then(() => { if (S.pixelCheck.blocked) { toast('浏览器不允许读这些图片的像素(跨域限制),颜色核对用不了。\n这不是脚本出错,是浏览器的安全限制。', 'err'); } else if (Object.keys(S.pixelCheck.mismatch).length) { toast('颜色对不上的有 ' + Object.keys(S.pixelCheck.mismatch).length + ' 个,已列到「检查」那一行', 'ok'); } else { toast('画面颜色和「颜色」属性都对得上(仅供参考,不代表标注正确)', 'ok'); } renderFilters(); }); }); } const r = row('自检', nodes); r.appendChild(imgBtn); const tip = document.createElement('span'); tip.className = 'issuehint'; tip.textContent = cur ? ('只看:' + ((issueDef(cur) || {}).hint || '') + ' (点同一个标签取消)') : '只查数据自不自洽;「框里画的到底对不对」得靠人眼看。'; r.appendChild(tip); } // 属性名只统计一次,避免每渲染一行就重扫一遍全部标注物 const names = attrNames(); // 颜色行:红 / 黄 / 绿 —— 红绿灯最先要看的就是这个。 // 如果平台自己已经有「颜色 / Color」属性,那一行才是正主,这里就不重复放一行了; // 只有颜色没被单独建成属性时(红黄绿藏在灯态里、或干脆是另一个类别),才用这个汇总行兜底。 const needColorDim = !hasColorAttr(names); if (needColorDim) { const colorCount = new Map(); for (const m of filtered('attr:' + COLOR_DIM)) { const k = colorName(m); colorCount.set(k, (colorCount.get(k) || 0) + 1); } const cur = S.filters.attrs[COLOR_DIM] || ''; const nodes = [chip('全部', filtered('attr:' + COLOR_DIM).length, null, !cur, () => { delete S.filters.attrs[COLOR_DIM]; S.page = 1; renderAll(); })]; for (const b of COLOR_BUCKETS) { const n = colorCount.get(b.key) || 0; if (!n) continue; nodes.push(chip(b.label, n, b.color, cur === b.key, () => { S.filters.attrs[COLOR_DIM] = cur === b.key ? '' : b.key; S.page = 1; renderAll(); })); } if (nodes.length > 1) row('颜色', nodes); } // 每个属性一行:灯态 / 颜色 各自一组,点哪个就是哪个 for (const a of names) { const vals = valuesOf(a.pname); if (!vals.length) continue; const cur = S.filters.attrs[a.pname] || ''; // 「全部」的数 = 这个属性说得上话的标注物数(所在类别确实配了这个属性的), // 这样一行里的数字是自洽的,也不会把不相关的类别算进来 const scopeN = attrScope(a.pname).length; const nodes = [chip('全部', scopeN, null, !cur, () => { delete S.filters.attrs[a.pname]; S.page = 1; renderAll(); })]; for (const v of vals) { const warn = (v.pname === ABSENT || v.pname === '__EMPTY__'); nodes.push(chip(v.ptitle, v.count, warn ? '#f1c40f' : dotColor(v.ptitle + v.pname), cur === v.pname, () => { S.filters.attrs[a.pname] = cur === v.pname ? '' : v.pname; S.page = 1; renderAll(); })); } row(a.ptitle || a.pname, nodes); } } // ── 网格 ────────────────────────────────────────────────────────────────── 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; $('lg-pageinfo').textContent = `${S.page} / ${totalPages}`; $('lg-prev').disabled = S.page <= 1; $('lg-next').disabled = S.page >= totalPages; const clsName = S.filters.cls ? '类别 ' + ((classStats().find(c => c.key === S.filters.cls) || {}).label || '') : '整批次'; const attrConds = Object.keys(S.filters.attrs).filter(k => S.filters.attrs[k]).length; const pkgTxt = S.filters.pkg ? ('【题包 ' + S.filters.pkg + '】') : ''; const conds = []; if (attrConds) conds.push(attrConds + ' 个属性条件'); if (S.filters.issue) { const d = issueDef(S.filters.issue); conds.push('检查:' + (d ? d.label : S.filters.issue)); } if (S.keyword) conds.push('搜索「' + S.keyword + '」'); $('lg-sub').textContent = `${pkgTxt}${clsName}${conds.length ? ' · ' + conds.join(' · ') : ''} · ${list.length} 个 · ${location.host}`; const body = $('lg-body'); body.innerHTML = ''; if (!list.length) { body.innerHTML = `
${S.marks.length ? '当前筛选没有匹配的标注物。' : '整批次没有读到标注物。'} ${S.marks.length ? '' : rawBlock()}
`; 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 color = colorOf(m); const attrs = attrsOf(m).filter(a => a.values.length).slice(0, 3); const card = document.createElement('div'); card.className = 'card' + (i === S.selectedIndex ? ' selected' : ''); card.dataset.idx = String(i); const edited = !!S.edits[m.__taskId + ':' + m.__markIndex]; const noGeom = !hasGeometry(m); card.innerHTML = ` ${edited ? '已改' : ''} ${noGeom ? '无坐标' : ''}
${esc(classLabel(m))} ${esc(whereLabel(m))}
${attrs.map(a => ` 1 ? ' class="many"' : ''}>${esc(a.ptitle)}: ${esc(a.values.map(v => v.ptitle).join('、'))}`).join('') || '' + esc(m.type || '') + ''}
`; grid.appendChild(card); const meta = S.taskMeta[m.__taskId] || {}; const cv = card.querySelector('canvas'); loadImage(meta.imgUrl).then(img => { if (!cv.isConnected) return; drawCropped(cv, img, m, THUMB_W, THUMB_H, true); }); card.addEventListener('click', () => { S.selectedIndex = i; renderGrid(); openLightbox(m); }); card.addEventListener('wheel', (e) => { if (!(e.ctrlKey || e.metaKey)) return; // 普通滚轮留给列表滚动 e.preventDefault(); e.stopPropagation(); openLightbox(m, e); }, { passive: false }); } } // ── 放大页 ──────────────────────────────────────────────────────────────── let lbSeq = 0; async function openLightbox(mark, wheelEvent) { const seq = ++lbSeq; const meta = S.taskMeta[mark.__taskId] || {}; $('lg-lb-title').textContent = lightboxTitle(mark); $('lg-lb-info').textContent = ''; $('lg-lb').classList.add('show'); openEditor(mark); const img = await loadImage(meta.imgUrl); if (seq !== lbSeq) return; if (!img) { $('lg-lb-info').innerHTML = '图片加载失败' + (meta.imgUrl ? ':' + esc(String(meta.imgUrl).slice(0, 120)) : '(这一题没有解析出图片地址)'); } const stage = $('lg-lb-stage'); const availW = Math.max(240, (stage.clientWidth || innerWidth) - 56); const availH = Math.max(180, (stage.clientHeight || innerHeight) - 56); lbViewer.onModeChange = () => syncLightboxInfo(); lbViewer.load(img, mark, { maxW: Math.min(availW, FULL_MAX_W), maxH: Math.min(availH, FULL_MAX_H) }); if (wheelEvent) lbViewer.zoomBy(Math.exp(-(wheelEvent.deltaY || 0) * 0.0015)); syncLightboxInfo(); } /** * 把「原图多大 / 当前看到的是哪一块 / 放大了几倍」写清楚。 * 小目标放大几十倍当然会糊 —— 写出来就不会被当成「图加载不出来」。 */ function syncLightboxInfo() { const el = $('lg-lb-info'); if (!el) return; const f = lbViewer && lbViewer.lastFrame; const full = lbViewer && lbViewer.mode === 'full'; const btn = $('lg-lb-mode'); if (btn) btn.textContent = full ? '看局部' : '看整图'; if (!f) return; const mag = (f.cw / f.box.w); el.innerHTML = '原图 ' + f.iw + '×' + f.ih + (full ? '(整图)' : ' · 裁剪区 ' + Math.round(f.box.w) + '×' + Math.round(f.box.h) + ',放大约 ' + esc(mag.toFixed(mag >= 10 ? 0 : 1)) + '×') + (f.noGeometry ? ' · 这个标注物抠不出坐标,只能看整图' : ''); } function closeLightbox() { $('lg-lb').classList.remove('show'); S.edit = null; } // ── 右侧编辑面板 ────────────────────────────────────────────────────────── function openEditor(mark) { S.edit = { taskId: mark.__taskId, markIndex: mark.__markIndex, cls: classKey(mark), attrSel: currentAttrSel(mark), // 每个属性是单选还是多选。默认**单选**(颜色、灯态这类属性同时选两个是错的), // 只有原数据本身就是多个值时才默认多选 —— 免得把已有数据悄悄改掉。 mode: {}, msg: null, // { kind:'ok'|'err', text:'' } busy: false, }; renderEditor(mark); } /** * 某个属性该按单选还是多选渲染。 * 平台把「最多能选几个」存在标注编辑器的配置里,管理端这边拿不到, * 所以:默认单选;原数据本来就是多个值 → 多选;用户手动切过 → 听用户的。 */ function attrModeOf(mark, attrPname) { const E = S.edit; if (E && E.mode && E.mode[attrPname]) return E.mode[attrPname]; const cur = currentAttrSel(mark)[attrPname] || []; return cur.length > 1 ? 'multi' : 'single'; } /** * 点一个值之后,这个属性应该选中哪些值。 * 单选:直接**替换**(颜色不可能同时是红色和绿色);再点已选的那个就是取消 * 多选:切换 */ function nextSelection(cur, valuePname, mode) { const c = cur || []; if (mode === 'multi') { const arr = c.slice(); const i = arr.indexOf(valuePname); if (i >= 0) arr.splice(i, 1); else arr.push(valuePname); return arr; } return (c.length === 1 && c[0] === valuePname) ? [] : [valuePname]; } /** 目标类别的定义(找不到就用标注物自己带的属性兜底) */ function editorTarget(clsPname, mark) { const hit = (S.catalog || []).find(c => c.pname === clsPname); if (hit) return hit; return { pname: clsPname, ptitle: classLabel(mark), preset_id: '', color: colorOf(mark), attrs: attrsOf(mark).map(a => ({ pname: a.pname, ptitle: a.ptitle, values: a.values })), }; } function renderEditor(mark) { const side = $('lg-lb-side'); const E = S.edit; if (!E) { side.innerHTML = ''; return; } const cur = classKey(mark); const target = editorTarget(E.cls, mark); const changes = previewChanges(mark, E.cls, E.attrSel); const changed = changes.length > 0; const edited = !!S.edits[E.taskId + ':' + E.markIndex]; const opts = (S.catalog || []).map(c => ``).join(''); const attrBlocks = target.attrs.map(a => { const sel = E.attrSel[a.pname] || []; const mode = attrModeOf(mark, a.pname); const chips = a.values.map(v => `${esc(v.ptitle)}` ).join(''); const wasArr = currentAttrSel(mark)[a.pname] || []; const was = wasArr.map(p => titleOfValue(target, a.pname, p)).join('、'); const many = wasArr.length > 1; return `
${esc(a.ptitle)} ${mode === 'multi' ? '多选' : '单选'} ${was ? ' 原:' + esc(was) + '' : ''} ${many ? `
原数据这个属性有 ${wasArr.length} 个值(${esc(was)})。` + (mode === 'single' ? '现在是单选,点一个值就会把它改成单个。' : '点一下已选的值可以取消它。') + '
' : ''}
${chips || '(这个属性没有可选值)'}
`; }).join(''); side.innerHTML = `

修改标注物${edited ? '本次已改' : ''}

${isEditableQuery() ? '' : `
当前看的是「${esc(statusNameOf(S.query && S.query.status))} · ${esc(stageNameOf(S.query && S.query.workType))}」, 只有「待处理」和「进行中」的题才改得动。
点上面黄色的「不可改」标签可以一键切到待处理。
`}
类别
属性${E.cls !== cur ? ' 换类别会清空旧类别的属性' : ''}
${attrBlocks || '
这个类别没有属性
'}
点「保存更正」会改成 ${changes.length ? `
${changes.map(c => `
${esc(c.label)}:${esc(c.from)} → ${esc(c.to)}
`).join('')}
` : '
还没有改动 —— 点上面的值试试
'}
${E.msg ? `
${esc(E.msg.text)}
` : ''}
只会改动这一个标注物;同一题里其它标注物、以及别的题都不会被动。 保存前会先核对「除类别和属性外没有字段被改」。
第一次改某一题时,会先按平台的规矩**领这一题**(平台点「编辑」也是这么做的), 之后同一题再改就不会重复领了。
`; const sel = $('lg-ed-cls'); if (sel) sel.addEventListener('change', () => { E.cls = sel.value; // 与平台一致:换类别 → 属性整组换成新类别的,值先留空 E.attrSel = {}; E.msg = null; renderEditor(mark); }); side.querySelectorAll('.vchip').forEach(el => { el.addEventListener('click', () => { const ap = el.dataset.attr, vp = el.dataset.val; E.attrSel[ap] = nextSelection(E.attrSel[ap], vp, attrModeOf(mark, ap)); E.msg = null; renderEditor(mark); }); }); // 单选 / 多选 切换 side.querySelectorAll('.modetog').forEach(el => { el.addEventListener('click', () => { const ap = el.dataset.attr; E.mode[ap] = attrModeOf(mark, ap) === 'multi' ? 'single' : 'multi'; E.msg = null; renderEditor(mark); }); }); const btn = $('lg-ed-save'); if (btn) btn.addEventListener('click', () => doSave(mark)); } /** * 重新读某一题的标注数据,用它替换本地的那一份。 * * 保存之后**一定要**跑这个:以服务端真正存下来的为准,而不是以「我以为发出去了什么」为准。 * 这样界面上的数字永远是服务端的真实状态 —— 存错了也当场看得见,不用等刷新。 */ async function reloadTaskMarks(taskId) { const fresh = await fetchMarkData(taskId); if (!fresh) return false; learnZh(fresh); S.taskData[taskId] = fresh; const firstAt = S.marks.findIndex(m => String(m.__taskId) === String(taskId)); const old = firstAt >= 0 ? S.marks[firstAt] : null; const meta = S.taskMeta[taskId] || {}; const imgUrl = pickImgUrl(fresh, null); if (imgUrl) meta.imgUrl = imgUrl || (old ? (S.taskMeta[taskId] || {}).imgUrl : ''); S.taskMeta[taskId] = meta; const rest = S.marks.filter(m => String(m.__taskId) !== String(taskId)); const insertAt = firstAt < 0 ? rest.length : S.marks.slice(0, firstAt).filter(m => String(m.__taskId) !== String(taskId)).length; const rebuilt = (Array.isArray(fresh.marks) ? fresh.marks : []).map((m, i) => Object.assign({}, m, { __taskId: taskId, __packageId: old ? old.__packageId : meta.packageId, __markIndex: i, __frameIndex: old ? old.__frameIndex : 0, __fileName: old ? old.__fileName : '', })); S.marks = rest.slice(0, insertAt).concat(rebuilt, rest.slice(insertAt)); // 改完之后类别目录可能变了(比如某一类的最后一个属性被去掉),重建一份 S.catalog = buildCatalog(); return true; } async function doSave(mark) { const E = S.edit; if (!E) return; E.busy = true; E.msg = null; renderEditor(mark); try { const r = await saveMarkChange(mark, E.cls, E.attrSel); E.busy = false; // 以服务端为准重建这一题 —— 顺便验证「存进去的」和「界面上的」一致 let recheck = ''; try { const okReload = await reloadTaskMarks(mark.__taskId); recheck = okReload ? '' : '(没能重新读回这一题,界面数字可能不准,建议刷新)'; } catch (e2) { recheck = '(重新读取失败:' + e2.message + ')'; } const fresh = S.marks.find(m => String(m.__taskId) === String(mark.__taskId) && m.__markIndex === mark.__markIndex); const nowCls = fresh ? classLabel(fresh) : ''; const nowAttrs = fresh ? attrsOf(fresh).filter(a => a.values.length) .map(a => a.ptitle + ':' + a.values.map(v => v.ptitle).join('、')).join(' ') : ''; const rec = S.received[mark.__taskId]; const inProg = isInProgressQuery(); const stageNow = stageNameOf((S.query || {}).workType) + (inProg ? ' · 进行中' : ' · 进行中(刚领的)'); E.msg = { kind: 'ok', text: '已保存。\n' + r.diff.join('\n') + (fresh ? '\n\n服务端现在存的是:' + nowCls + (nowAttrs ? ' ' + nowAttrs : '') : '') + '\n\n这一题现在在:' + stageNow + (rec ? '(' + (rec === 'package' ? '整包领取' : '单题领取') + ')' : '') + '\n⚠ 保存**只写标注数据**,不会把它推到下一个工序。' + '\n 要推进得靠「提交/通过」—— 脚本没做这一步,请到平台上提交。' + (inProg ? '\n 你现在就在「进行中」页签,改完接着改下一条就行。' : '\n 所以在「待处理」列表里它不会再出现了,把「状态」切到「进行中」就能继续改。') + recheck, }; const live = S.marks.find(m => String(m.__taskId) === String(mark.__taskId) && m.__markIndex === mark.__markIndex) || mark; $('lg-lb-title').textContent = lightboxTitle(live); renderAll(); // ← 必须连筛选条一起重画,否则旁边那些数字不更新 renderEditor(live); toast('已保存:' + classLabel(live), 'ok'); } catch (e) { E.busy = false; E.msg = { kind: 'err', text: '没有保存:' + e.message + '\n(平台里的数据没有被动过)' }; renderEditor(mark); toast('保存失败:' + e.message, 'err'); } } function lightboxTitle(mark) { return classLabel(mark) + ' · 题 ' + (mark.__taskId != null ? mark.__taskId : '?') + (mark.__packageId != null ? '(题包 ' + mark.__packageId + ')' : '') + (function () { const fr = frameNoOf(mark); return fr ? ' · ' + (fr.real ? ('第 ' + fr.n + ' 帧') : ('该题第 ' + fr.n + ' 个')) : ''; })() + (attrsOf(mark).filter(a => a.values.length) .map(a => ' · ' + a.ptitle + ':' + a.values.map(v => v.ptitle).join('/')).join('')); } // ── 键盘 ────────────────────────────────────────────────────────────────── const isTyping = () => { // 界面在 Shadow DOM 里:document.activeElement 只会返回宿主
, // 必须再问一次 shadowRoot.activeElement,否则在搜索框里打字会被当成方向键。 let el = null; try { const host = document.getElementById(NS + '-host'); el = (host && host.shadowRoot && host.shadowRoot.activeElement) || document.activeElement; } catch (e) { el = document.activeElement; } const t = (el && el.tagName) || ''; return t === 'INPUT' || t === 'TEXTAREA' || t === 'SELECT' || (el && el.isContentEditable === true); }; function pageItems() { const g = $('lg-body').querySelector('.grid'); return g ? Array.from(g.querySelectorAll('.card')) : []; } function selectCard(idx) { const items = pageItems(); if (!items.length) { S.selectedIndex = -1; return; } S.selectedIndex = Math.max(0, Math.min(items.length - 1, idx)); items.forEach((c, i) => c.classList.toggle('selected', i === S.selectedIndex)); if (items[S.selectedIndex]) items[S.selectedIndex].scrollIntoView({ block: 'nearest' }); } 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; } S.page = next; S.selectedIndex = -1; renderGrid(); } function onKeyDown(e) { if (!S.open) return; const lbOpen = $('lg-lb').classList.contains('show'); if (e.key === 'Escape') { if (lbOpen) closeLightbox(); else closePanel(); return; } if (lbOpen || isTyping()) return; const k = e.key; if (k === 'a' || k === 'A' || k === 'PageUp') { e.preventDefault(); goPage(-1); return; } if (k === 'd' || k === 'D' || k === 'PageDown') { e.preventDefault(); goPage(1); return; } if (k === 'ArrowLeft' || k === 'ArrowRight' || k === 'ArrowUp' || k === 'ArrowDown') { e.preventDefault(); const n = pageItems().length; if (!n) return; if (S.selectedIndex < 0) { selectCard(0); return; } const step = (k === 'ArrowLeft') ? -1 : (k === 'ArrowRight') ? 1 : (k === 'ArrowUp') ? -GRID_COLS : GRID_COLS; const next = S.selectedIndex + step; if (next < 0) { goPage(-1); return; } if (next >= n) { goPage(1); return; } selectCard(next); } } function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } // ========================================================================= // 8. 启动 // ========================================================================= /** * 诊断钩子。如果界面显示的数字/分类不对,在控制台跑一次 __红绿灯诊断() * 就能看到「接口到底返回了什么形状」,把结果发出来即可定位。 */ function installDiagnostics() { try { window.__红绿灯诊断 = function () { const m = S.marks[0]; const out = { '批次': S.taskKey, '题包数': S.stats.packages, '服务器报告题包数': S.declaredPackages, '题数': S.stats.tasks, '重复题已去重': S.stats.dupTasks, '读成功的题': S.stats.ok, '读失败的题': S.stats.fail, '标注物总数': S.marks.length, '没解析出图片地址的题': S.stats.noImg, '第一条标注物的字段名': m ? Object.keys(m) : [], '★ 第一条标注物的定位': m ? whereLabel(m) : '', '第一条标注物的class': m ? m.class : null, '★ 第一条标注物的原始数据(完整)': m ? (function () { try { const raw = (S.taskData[m.__taskId] || {}).marks; const one = raw && raw[m.__markIndex]; return one ? JSON.parse(JSON.stringify(one)) : m; } catch (e) { return '(序列化失败:' + e.message + ')'; } })() : null, '★ 抠不出坐标的标注物有几个': S.marks.filter(x => !hasGeometry(x)).length, '★ 抠不出坐标的标注物长什么样(前 3 条)': S.marks.filter(x => !hasGeometry(x)) .slice(0, 3).map(x => ({ type: x.type, 字段名: Object.keys(x), 几何相关字段的值: ['point', 'points', 'finshPoints', 'data', 'annotation', 'x', 'y', 'width', 'height'] .reduce((acc, k) => { if (x[k] !== undefined) acc[k] = x[k]; return acc; }, {}), })), '★ 学到的中文别名表': (function () { const o = {}; zhDict.forEach((v, k) => { o[k] = v; }); return o; })(), '第一条标注物的attrs形态': m ? (Array.isArray(m.attrs) ? '数组(界面态)' : (m.attrs ? '对象(保存态)' : '没有')) : '没有标注物', '第一条标注物解析出的属性': m ? attrsOf(m) : [], '第一条标注物的图片地址': m ? (S.taskMeta[m.__taskId] || {}).imgUrl : '', '★ 图片加载失败过几次': imgFails.length, '★ 加载失败的图片地址(前 20 条)': imgFails.slice(), '★ 已缓存的图片数': imgCache.size, '识别到的类别': classStats().map(c => c.label + ' × ' + c.count), '识别到的属性': attrNames().map(a => a.ptitle + '(' + a.pname + ')'), '可修改的类别目录': (S.catalog || []).map(c => c.ptitle + ':' + c.attrs.map(a => a.ptitle + '(' + a.values.length + '个可选值)').join('、')), '保存时会带的参数': m ? (function () { const meta = S.taskMeta[m.__taskId] || {}; return { task_id: m.__taskId, work_type: meta.workType, status: meta.status, package_id: meta.packageId, access: 3, task_key: S.taskKey }; })() : null, '本次会话改过几个': Object.keys(S.edits || {}).length, '当前筛选': JSON.parse(JSON.stringify(S.filters)), }; console.log('%c[红绿灯总览] 诊断', 'color:#e74c3c;font-weight:bold', out); return out; }; log('诊断钩子已装好:控制台执行 __红绿灯诊断() 可以看到接口返回的真实字段。'); } catch (e) { /* 无关紧要 */ } } function boot() { if (document.getElementById(NS + '-host')) return; try { buildUI(); } catch (e) { console.error('[红绿灯总览] 注入失败', e); return; } installDiagnostics(); // 只在项目任务页显示按钮 const here = () => /\/v\/project\/[^/]+\/project-taskIndex/.test(location.pathname); const sync = () => { const on = here() && !!new URLSearchParams(location.search).get('task_key'); const b = document.getElementById(NS + '-host'); const btn = b && b.shadowRoot && b.shadowRoot.getElementById('lg-launch'); if (btn) btn.style.display = on ? '' : 'none'; }; sync(); // 这是 SPA,路由切换不会刷新页面 —— 监听地址变化 let last = location.href; setInterval(() => { if (location.href === last) return; last = location.href; sync(); if (here()) { S.loaded = false; S.marks = []; if (S.open) load(false); } else if (S.open) closePanel(); }, 800); log('已注入。项目任务页右下角会出现「🚦 批次总览」按钮。'); } // ── 离线自测钩子:浏览器里没有 __EXPORT_FOR_TEST__,所以这段什么也不做 ────── if (typeof globalThis.__EXPORT_FOR_TEST__ === 'function') { globalThis.__EXPORT_FOR_TEST__({ S, Viewer, CSS, pointsOf, holesOf, allPointsOf, cropBoxOf, drawCropped, drawMark, fullBoxOf, loadImage, imgFails: () => imgFails, imgCache, numOf, extractPoints, hasGeometry, frameNoOf, whereLabel, attrsOf, classObj, classKey, classLabel, colorOf, colorName, dotColor, hexToRgba, pickPname, pickTitle, zhOf, learnZh, zhDict: () => zhDict, BUILTIN_ZH, matches, filtered, classStats, attrNames, valuesOf, hasColorAttr, isOk, itemsOf, totalOf, metaTotalOf, taskIdOf, pkgIdOf, token, headers, apiGet, apiPost, pagedGet, pagedGetOnce, isPageSizeComplaint, apiPut, receiveTask, isClaimLimitError, CLAIM_LIMIT, CLAIM_LIMIT_FIELD, fetchClaimLimits, claimLimitFor, claimedCountFor, fetchPackages, fetchTasks, collectTasks, PAGE_NUM, PAGE_NUM_SAFE, fetchMarkData, pickImgUrl, pickSize, readPlatformState, packageParams, rawBlock, ISSUES, issueStats, issueDef, classHasAttr, attrScope, boxOfMark, dupBoxSet, markKeyOf, runPixelColorCheck, dominantLampHue, hueName, isEditableQuery, isInProgressQuery, statusNameOf, stageNameOf, explainSaveError, FALLBACK_STATUS, FALLBACK_STAGE, EDITABLE_STATUS, EDITABLE_STATUSES, IN_PROGRESS_STATUS, renderQueryBar, currentAttrSel, buildCatalog, currentAttrSel, toSavedMark, sameSel, buildSavePayload, attrModeOf, nextSelection, previewChanges, VERSION, describeAttrChange, titleOfValue, saveMarkChange, editorTarget, saving: () => saving, COLOR_DIM, COLOR_BUCKETS, GRID_COLS, PAGE_SIZE, LINE_W, NS, }); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot(); })();