// ==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 = `
`; 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 = `