// ==UserScript== // @name 标注助手 - 框数统计 // @namespace https://dawnchan.online // @version 2.1.0 // @description 直接读取平台标注数据,按类别统计当前帧/全部帧框数;支持定时保存与指定帧范围的框尺寸同步。 // @author cc // @match *://*/*pointcloud* // @match *://*/w/pointcloud/* // @grant none // @noframes // @run-at document-idle // ==/UserScript== (function () { 'use strict'; const LS_KEY = 'bch_widget_v2'; const MIN_SAVE_MS = 10000; const MAX_SAVE_MS = 24 * 60 * 60 * 1000; const DEFAULTS = { autoSave: true, interval: 5, unit: 'minutes', scope: 'all', collapsed: false, pos: null }; let settings = loadSettings(); // ========== 设置 ========== function loadSettings() { try { const raw = localStorage.getItem(LS_KEY); const parsed = raw ? JSON.parse(raw) : {}; const s = Object.assign({}, DEFAULTS, parsed || {}); s.interval = Math.round(Number(s.interval)); if (!Number.isFinite(s.interval) || s.interval < 1) s.interval = DEFAULTS.interval; s.interval = Math.min(999, Math.max(1, s.interval)); if (!['seconds', 'minutes', 'hours'].includes(s.unit)) s.unit = DEFAULTS.unit; if (!['frame', 'all'].includes(s.scope)) s.scope = DEFAULTS.scope; s.autoSave = !!s.autoSave; s.collapsed = !!s.collapsed; if (!s.pos || typeof s.pos.x !== 'number' || typeof s.pos.y !== 'number') s.pos = null; return s; } catch (e) { return Object.assign({}, DEFAULTS); } } function persist() { try { localStorage.setItem(LS_KEY, JSON.stringify(settings)); } catch (e) { // localStorage 写入失败不影响统计 } } function intervalMs() { const mult = settings.unit === 'seconds' ? 1000 : settings.unit === 'minutes' ? 60000 : 3600000; return Math.min(MAX_SAVE_MS, Math.max(MIN_SAVE_MS, Math.round(settings.interval * mult))); } // ========== 平台数据访问 ========== function getInstance() { try { return window.instance || null; } catch (e) { return null; } } function getSM() { const inst = getInstance(); try { return inst && inst.StoreManager ? inst.StoreManager : null; } catch (e) { return null; } } function canEdit() { const inst = getInstance(); try { return !!(inst && inst.canEdit); } catch (e) { return false; } } function getStatusState() { const sm = getSM(); if (!sm) return null; try { return sm.statusState(); } catch (e) { return null; } } function getSnapshot() { const sm = getSM(); if (!sm) return null; try { const cfgState = sm.configState(); const markState = sm.marksState(); const cfg = cfgState.config || {}; const frames = Array.isArray(cfgState.frames) ? cfgState.frames : []; const frameIds = cfgState.frameIds || {}; const curId = sm.getCurFrameId(); const idx = frameIds[curId]; return { sm, marks: markState.marks || {}, versions: `${markState.curVer}|${markState.curMetaVer}`, config: cfg, frames, frameIds, curId, curIndex: typeof idx === 'number' ? idx : -1, taskKey: cfg.task_key || cfg.id || '', frameSetKey: frames.length ? String(frames[0] && frames[0].task_id) + '|' + String(frames[frames.length - 1] && frames[frames.length - 1].task_id) : '' }; } catch (e) { return null; } } // ========== 统计 ========== const TOOL_2D_MAP = { rect: 'volume', cuboid_2d: 'volume', line: 'line_3d', polygon: 'polygon_3d' }; function frameHas2D(cams) { if (!cams) return false; if (Array.isArray(cams)) return cams.length > 0; if (typeof cams !== 'object') return false; for (const key in cams) { const arr = cams[key]; if (Array.isArray(arr) && arr.length) return true; } return false; } function trackHasMark(track, frameId) { if (!track || !frameId) return false; const d3 = track['3d']; if (d3 && d3[frameId]) return true; const d2 = track['2d']; return !!(d2 && frameHas2D(d2[frameId])); } function first2DType(arr) { if (!arr) return null; if (Array.isArray(arr)) { return arr.length && arr[0] && arr[0].type ? arr[0].type : null; } for (const key in arr) { const sub = arr[key]; if (Array.isArray(sub) && sub.length && sub[0] && sub[0].type) return sub[0].type; } return null; } function trackToolType(track, frameId) { if (!track) return null; const d3 = track['3d']; if (frameId && d3 && d3[frameId] && d3[frameId].type) return d3[frameId].type; if (d3) { for (const fid in d3) { const mark = d3[fid]; if (mark && mark.type) return mark.type; } } const d2 = track['2d']; if (d2) { if (frameId) { const type = first2DType(d2[frameId]); if (type) return TOOL_2D_MAP[type] || type; } for (const fid in d2) { const type = first2DType(d2[fid]); if (type) return TOOL_2D_MAP[type] || type; } } return null; } function countFrame(snapshot) { const map = new Map(); let total = 0; const frameId = snapshot.curId; if (!frameId) return { map, total }; for (const trackId in snapshot.marks) { const track = snapshot.marks[trackId]; if (!trackHasMark(track, frameId)) continue; const tool = trackToolType(track, frameId); if (!tool) continue; const key = tool + '|' + (track.class != null ? track.class : '?'); map.set(key, (map.get(key) || 0) + 1); total++; } return { map, total }; } function countAll(snapshot) { const map = new Map(); let total = 0; const validFrames = new Set( snapshot.frames .map(function (f) { return f && f.task_id != null ? String(f.task_id) : null; }) .filter(function (id) { return id != null; }) ); if (!validFrames.size) return { map, total }; for (const trackId in snapshot.marks) { const track = snapshot.marks[trackId]; if (!track) continue; const tool = trackToolType(track, null); if (!tool) continue; const counted = new Set(); const d3 = track['3d']; if (d3) { for (const fid in d3) { if (validFrames.has(String(fid)) && d3[fid]) counted.add(fid); } } const d2 = track['2d']; if (d2) { for (const fid in d2) { if (!validFrames.has(String(fid)) || counted.has(fid)) continue; if (frameHas2D(d2[fid])) counted.add(fid); } } if (!counted.size) continue; const key = tool + '|' + (track.class != null ? track.class : '?'); map.set(key, (map.get(key) || 0) + counted.size); total += counted.size; } return { map, total }; } function classInfo(snapshot, toolType, classId) { let cls = null; const classes = snapshot.config && snapshot.config.classes; const group = classes && classes[toolType]; if (group && group.listClass) cls = group.listClass[classId]; if (!cls) { const props = snapshot.config && snapshot.config.properties; if (Array.isArray(props)) { const g = props.find(function (p) { return p && p.toolType === toolType; }); if (g && g.listClass) cls = g.listClass[classId]; } } let name = ''; let color = ''; if (cls) { name = cls.ptitle || cls.pname || ''; color = cls.color || ''; } if (!name) name = '类别 ' + classId; if (!/^#[0-9a-f]{3,8}$/i.test(color) && !/^rgba?\(/i.test(color)) color = '#7a7a7a'; return { name: name, color: color, tool: toolType, id: classId }; } // ========== 尺寸同步 ========== function toDims(value) { if (Array.isArray(value) && value.length >= 3) { const out = value.slice(0, 3).map(Number); return out.every(function (v) { return Number.isFinite(v) && v > 0; }) ? out : null; } if (value && typeof value === 'object') { const candidates = [ [value.l, value.w, value.h], [value.length, value.width, value.height], [value.x, value.y, value.z] ]; for (const candidate of candidates) { if (candidate.every(function (v) { return typeof v === 'number' && Number.isFinite(v) && v > 0; })) return candidate.slice(); } } return null; } function markDims(mark) { if (!mark) return null; for (const key of ['scale', 'size', 'dimension', 'dimensions']) { const dims = toDims(mark[key]); if (dims) return dims; } return null; } function toVec3(value) { if (Array.isArray(value) && value.length >= 3) { const out = value.slice(0, 3).map(Number); return out.every(Number.isFinite) ? out : null; } if (value && typeof value === 'object') { const out = [Number(value.x), Number(value.y), Number(value.z)]; return out.every(Number.isFinite) ? out : null; } return null; } function toRot(value) { if (Array.isArray(value) && value.length >= 2) { return [Number(value[0]) || 0, Number(value[1]) || 0, Number(value[2]) || 0]; } if (value && typeof value === 'object') { return [ Number(value.x != null ? value.x : value._x) || 0, Number(value.y != null ? value.y : value._y) || 0, Number(value.z != null ? value.z : value._z) || 0 ]; } return [0, 0, 0]; } function zAxisOfEuler(rotation) { const rx = rotation[0] || 0; const ry = rotation[1] || 0; const cx = Math.cos(rx), sx = Math.sin(rx); const cy = Math.cos(ry), sy = Math.sin(ry); return [sy, -sx * cy, cx * cy]; } function fixedBottomPosition(mark, oldDims, targetDims) { const position = toVec3(mark && mark.position); if (!position) return null; const axis = zAxisOfEuler(toRot(mark && mark.rotation)); const k = (targetDims[2] - oldDims[2]) / 2; return [position[0] + axis[0] * k, position[1] + axis[1] * k, position[2] + axis[2] * k]; } function fmtSize(dims) { return dims.map(function (v) { return String(Number(v.toFixed(3))); }).join('×'); } function getSizeTarget(snapshot) { if (!snapshot) return { error: '等待平台数据…' }; const sm = snapshot.sm; let selected = null; try { selected = sm.statusState().selectMarkInfo; } catch (e) { selected = null; } const trackId = selected && selected.trackId != null ? selected.trackId : null; if (trackId == null) return { error: '先在平台里选中一个 3D 立体框' }; const trackKey = String(trackId); const track = snapshot.marks[trackKey] || snapshot.marks[trackId]; if (!track || !track['3d']) return { error: '选中的目标物没有 3D 标注' }; const mark = track['3d'][snapshot.curId]; if (!mark) return { error: '当前帧没有该目标物的 3D 框' }; const dims = markDims(mark); if (!dims) return { error: '当前帧的框没有可用尺寸' }; const tool = mark.type || trackToolType(track, snapshot.curId); const info = classInfo(snapshot, tool, track.class); const classId = track.class != null ? track.class : (selected.classId != null ? selected.classId : 0); return { trackId: trackId, trackKey: trackKey, track: track, mark: mark, dims: dims, classId: classId, className: info.name, curFrameNo: snapshot.curIndex >= 0 ? snapshot.curIndex + 1 : 0 }; } function buildSizePlan(snapshot, range) { const target = getSizeTarget(snapshot); if (target.error) return { error: target.error }; if (!canEdit()) return { error: '只读账号,无法修改尺寸' }; let status = null; try { status = snapshot.sm.statusState(); } catch (e) { status = null; } if (status && status.multiFrame) return { error: '多帧合并中,暂停尺寸同步' }; if (status && status.disabledShortKey) return { error: '平台弹窗打开中,请先关闭弹窗' }; const d3 = target.track['3d']; const changes = [], same = [], locked = [], missing = [], noSize = []; for (let frameNo = range.start; frameNo <= range.end; frameNo++) { const frame = snapshot.frames[frameNo - 1]; const frameId = frame && frame.task_id; if (frameId == null) { missing.push(frameNo); continue; } const mark = d3[frameId]; if (!mark) { missing.push(frameNo); continue; } const dims = markDims(mark); if (!dims) { noSize.push(frameNo); continue; } let allowed = true; if (typeof snapshot.sm.isAllowOpera === 'function') { try { allowed = !!snapshot.sm.isAllowOpera(mark, 'size'); } catch (e) { allowed = true; } } if (!allowed) { locked.push(frameNo); continue; } const isSame = dims.every(function (v, i) { return Math.abs(v - target.dims[i]) <= 1e-9; }); if (isSame) { same.push(frameNo); continue; } changes.push({ frameNo: frameNo, frameId: frameId, oldDims: dims.slice(), oldPosition: toVec3(mark.position) }); } return { target: target, range: range, changes: changes, same: same, locked: locked, missing: missing, noSize: noSize, taskKey: snapshot.taskKey + '|' + snapshot.frames.length + '|' + snapshot.frameSetKey, trackId: target.trackId, trackKey: target.trackKey, classId: target.classId }; } function applySizePlan(plan) { const sm = getSM(); if (!sm || typeof sm.markCommit !== 'function') return { error: '平台 markCommit 不可用' }; const current = getSnapshot(); if (!current) return { error: '平台数据已断开' }; const currentTaskKey = current.taskKey + '|' + current.frames.length + '|' + current.frameSetKey; if (currentTaskKey !== plan.taskKey) return { error: '任务已切换,已取消' }; const track = current.marks[plan.trackKey] || current.marks[plan.trackId]; if (!track || !track['3d']) return { error: '目标物已不存在' }; const d3 = track['3d']; const target = plan.target.dims; const undoEntries = []; let changed = 0, locked = 0, missing = 0; for (const change of plan.changes) { const mark = d3[change.frameId]; if (!mark) { missing++; continue; } if (typeof sm.isAllowOpera === 'function') { let allowed = true; try { allowed = !!sm.isAllowOpera(mark, 'size'); } catch (e) { allowed = true; } if (!allowed) { locked++; continue; } } const dims = markDims(mark) || change.oldDims; const oldPosition = toVec3(mark.position); const newPosition = fixedBottomPosition(mark, dims, target); undoEntries.push({ frameId: change.frameId, scale: dims.slice(), position: oldPosition ? oldPosition.slice() : null }); if (Array.isArray(mark.scale)) { mark.scale[0] = target[0]; mark.scale[1] = target[1]; mark.scale[2] = target[2]; } else { mark.scale = target.slice(); } if (newPosition) mark.position = newPosition; mark.is_computed = 0; mark.point_num = 0; sm.markCommit('addOrReplaceMark3D', [1, mark, plan.trackId, plan.classId, change.frameId, null, true]); changed++; } if (changed) { lastSizeUndo = { taskKey: plan.taskKey, trackId: plan.trackId, trackKey: plan.trackKey, classId: plan.classId, entries: undoEntries, at: Date.now() }; } return { changed: changed, locked: locked, missing: missing }; } // ========== 格式化 ========== const NUM_FMT = (function () { try { return new Intl.NumberFormat('zh-CN'); } catch (e) { return null; } })(); function formatNum(n) { n = Number(n) || 0; return NUM_FMT ? NUM_FMT.format(n) : String(n); } function compactNum(n) { n = Number(n) || 0; if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'm'; if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'k'; return String(n); } function formatDuration(sec) { sec = Math.max(0, Math.ceil(sec)); const h = Math.floor(sec / 3600); const m = Math.floor((sec % 3600) / 60); const s = sec % 60; if (h > 0) return h + ':' + String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0'); return m + ':' + String(s).padStart(2, '0'); } function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); } // ========== 自动保存 ========== let autoTimer = null; let autoGeneration = 0; let nextSaveAt = 0; let savingUntil = 0; let pendingSave = null; let messageObserverStarted = false; let flash = { text: '', kind: '', until: 0 }; function setStatus(text, kind, ms) { flash = { text: text, kind: kind || '', until: Date.now() + (ms || 2500) }; updateFooter(); } function isDirty() { const sm = getSM(); if (!sm) return false; try { const marks = sm.marksState(); if (marks.curVer !== marks.updatedVer || marks.curMetaVer !== marks.updatedMetaVer) return true; const seg = sm.segState(); if (seg && (seg.curSegVer !== seg.updateSegVer || seg.segProjectionVer !== seg.updateSegProjectionVer)) return true; } catch (e) { return true; } return false; } function triggerSave() { // 优先点击平台真实的保存按钮,让平台自己的“保存成功/失败”提示出现 const button = findSaveButton(); if (button) { try { button.click(); return true; } catch (e) { console.error('[框数助手] 点击保存按钮失败', e); } } // 找不到按钮时退回平台保存快捷键 Ctrl+S const isMac = /Mac/.test(navigator.platform || ''); try { document.dispatchEvent(new KeyboardEvent('keydown', { key: 's', code: 'KeyS', ctrlKey: !isMac, metaKey: isMac, bubbles: true, cancelable: true })); return true; } catch (e) { console.error('[框数助手] 保存快捷键派发失败', e); return false; } } function findSaveButton() { try { const top = document.getElementById('top'); if (!top) return null; const groups = []; for (const child of top.children) { if (child.classList && child.classList.contains('top-item')) groups.push(child); } const group = groups[groups.length - 1]; // 平台把保存按钮固定放在最后一个 top-item 组的最后一位 if (!group || top.lastElementChild !== group) return null; const buttons = group.querySelectorAll('button'); // 编辑权限下通常是 [通过, 保存];超过 4 个说明结构变了,退回快捷键更安全 if (buttons.length < 1 || buttons.length > 4) return null; const button = buttons[buttons.length - 1]; if (!button || button.disabled) return null; return button; } catch (e) { return null; } } function tryAutoSave(manual) { const inst = getInstance(); if (!inst) { if (manual) setStatus('平台未就绪', 'warn'); return; } if (!inst.canEdit) { setStatus('只读账号,无法保存', 'warn'); return; } const sm = getSM(); if (!sm) { if (manual) setStatus('平台未就绪', 'warn'); return; } let status = null; try { status = sm.statusState(); } catch (e) { status = null; } if (status && status.isMarksObtained === false) { if (manual) setStatus('标注数据加载中…', 'warn'); return; } if (status && status.multiFrame) { setStatus('多帧合并中,已跳过', 'warn'); return; } if (status && status.disabledShortKey) { setStatus('弹窗打开中,已跳过', 'warn'); return; } if (Date.now() < savingUntil) { if (manual) setStatus('正在保存…', 'warn'); return; } const dirty = isDirty(); // 自动保存只在有改动时触发,避免每隔几分钟弹一次“无需保存” // 手动保存即使没改动也触发,让平台给出“已同步至最新”的明确反馈 if (!manual && !dirty) return; if (!triggerSave()) { setStatus('保存请求失败', 'error'); return; } pendingSave = { at: Date.now(), baselineDirty: dirty }; savingUntil = Date.now() + 4000; setStatus('正在保存…', 'info', 5000); } function saveAfterSizeSync() { if (Date.now() < savingUntil) { const delay = Math.max(500, savingUntil - Date.now() + 200); setTimeout(function () { if (isDirty()) tryAutoSave(true); }, delay); return; } tryAutoSave(true); } function scheduleAutoSave() { autoGeneration++; if (autoTimer) { clearTimeout(autoTimer); autoTimer = null; } if (!settings.autoSave) { nextSaveAt = 0; updateFooter(); return; } const ms = intervalMs(); nextSaveAt = Date.now() + ms; const generation = autoGeneration; const tick = function () { if (generation !== autoGeneration) return; try { if (!document.hidden) tryAutoSave(false); } catch (e) { console.error('[框数助手] 自动保存失败', e); } nextSaveAt = Date.now() + ms; autoTimer = setTimeout(tick, ms); updateFooter(); }; autoTimer = setTimeout(tick, ms); updateFooter(); } function checkPendingSave() { if (!pendingSave) return; const elapsed = Date.now() - pendingSave.at; if (!pendingSave.baselineDirty) { // 手动保存但本来就没改动:平台通常会提示“已同步至最新”,没捕获到就在这里兜底 if (elapsed > 1200) { setStatus('已同步至最新', 'success', 5000); pendingSave = null; savingUntil = 0; } return; } if (!isDirty()) { setStatus('保存成功', 'success', 6000); pendingSave = null; savingUntil = 0; return; } if (elapsed > 12000) { setStatus('保存状态未确认,请检查网络或平台提示', 'warn', 7000); pendingSave = null; savingUntil = 0; } } function setupMessageObserver() { if (messageObserverStarted || !document.body) return; messageObserverStarted = true; const observer = new MutationObserver(function (mutations) { for (const mutation of mutations) { for (const node of mutation.addedNodes) { if (node.nodeType !== 1) continue; const messages = []; if (node.matches && node.matches('.el-message')) messages.push(node); if (node.querySelectorAll) { const nested = node.querySelectorAll('.el-message'); for (const el of nested) messages.push(el); } for (const el of messages) handlePlatformMessage(el); } } }); observer.observe(document.body, { childList: true, subtree: true }); } function handlePlatformMessage(el) { const content = el.querySelector('.el-message__content') || el; const text = (content.textContent || '').trim(); if (!text) return; const isError = el.classList.contains('el-message--error'); const isSaveText = /保存成功|保存失败|无需保存|已同步至最新|保存出错/.test(text); const withinSave = pendingSave && Date.now() - pendingSave.at < 15000; if (!isSaveText && !(isError && withinSave)) return; pendingSave = null; savingUntil = 0; if (isError || /保存失败|保存出错/.test(text)) { setStatus(text, 'error', 8000); } else { setStatus(text, 'success', 6000); } } // ========== 界面 ========== let els = null; let refreshKey = ''; let booted = false; let lastSnapshot = null; let sizeRangeKey = ''; let sizeFlash = null; let lastSizeUndo = null; function buildUI() { if (document.getElementById('bch-panel')) return false; if (!document.getElementById('bch-style')) { const style = document.createElement('style'); style.id = 'bch-style'; style.textContent = ` #bch-panel, #bch-ball, #bch-panel * { box-sizing: border-box; } #bch-panel { position: fixed; top: 12px; right: 12px; width: 256px; display: flex; flex-direction: column; background: #232323; color: #e8e8e8; border: 1px solid #3a3a3a; border-radius: 10px; box-shadow: 0 8px 28px rgba(0,0,0,.38); font: 12px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; z-index: 999999; overflow: hidden; user-select: none; max-height: calc(100vh - 8px); } #bch-panel .bch-head { display:flex; align-items:center; gap:4px; padding:7px 8px 7px 10px; border-bottom:1px solid #343434; cursor:move; } #bch-panel .bch-title { flex:1; font-size:12.5px; font-weight:600; color:#f2f2f2; white-space:nowrap; letter-spacing:.2px; } #bch-panel .bch-icon { width:24px; height:24px; padding:0; border:0; border-radius:5px; background:transparent; color:#a9a9a9; font-size:14px; line-height:24px; text-align:center; cursor:pointer; } #bch-panel .bch-icon:hover { background:#343434; color:#fff; } #bch-panel .bch-switch { position:relative; display:inline-block; width:34px; height:18px; flex:none; cursor:pointer; } #bch-panel .bch-switch input { position:absolute; opacity:0; width:0; height:0; } #bch-panel .bch-slider { position:absolute; inset:0; background:#5a5a5a; border-radius:10px; transition:background .2s; } #bch-panel .bch-slider::before { content:""; position:absolute; width:14px; height:14px; left:2px; top:2px; border-radius:50%; background:#fff; transition:transform .2s; box-shadow:0 1px 3px rgba(0,0,0,.35); } #bch-panel .bch-switch input:checked + .bch-slider { background:#2ecc71; } #bch-panel .bch-switch input:checked + .bch-slider::before { transform:translateX(16px); } #bch-panel .bch-switch input:disabled + .bch-slider { opacity:.45; cursor:not-allowed; } #bch-panel .bch-settings { padding:8px 10px; background:#262626; border-bottom:1px solid #343434; } #bch-panel .bch-set-row { display:flex; align-items:center; gap:6px; color:#bdbdbd; font-size:11px; } #bch-panel .bch-set-row input, #bch-panel .bch-set-row select { background:#1a1a1a; color:#e8e8e8; border:1px solid #404040; border-radius:4px; padding:2px 4px; font-size:11px; outline:none; } #bch-panel .bch-set-row input { width:52px; } #bch-panel .bch-set-row select { flex:1; min-width:0; } #bch-panel .bch-hint { margin-top:6px; font-size:10px; color:#777; line-height:1.4; } #bch-panel .bch-tabs { display:flex; gap:2px; margin:8px 10px 0; padding:2px; background:#1b1b1b; border-radius:6px; } #bch-panel .bch-tab { flex:1; text-align:center; padding:3px 0; border-radius:4px; color:#9a9a9a; font-size:11px; cursor:pointer; } #bch-panel .bch-tab:hover { color:#ddd; } #bch-panel .bch-tab.active { background:#383838; color:#fff; box-shadow:0 1px 2px rgba(0,0,0,.3); } #bch-panel .bch-total { display:flex; align-items:flex-end; justify-content:space-between; gap:8px; padding:8px 12px 2px; } #bch-panel .bch-num { font-size:26px; font-weight:700; line-height:1; color:#fff; font-variant-numeric:tabular-nums; letter-spacing:-.5px; } #bch-panel .bch-unit { margin-left:3px; font-size:11px; color:#8d8d8d; font-weight:400; } #bch-panel .bch-sub { font-size:10px; color:#7d7d7d; white-space:nowrap; padding-bottom:2px; } #bch-panel .bch-list { flex:0 1 auto; min-height:0; margin:4px 10px 8px; max-height:188px; overflow-y:auto; } #bch-panel .bch-row { display:flex; align-items:center; gap:6px; padding:3px 2px; min-height:20px; } #bch-panel .bch-row:hover { background:#2b2b2b; border-radius:4px; } #bch-panel .bch-dot { width:7px; height:7px; border-radius:50%; flex:none; } #bch-panel .bch-name { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#cfcfcf; font-size:11.5px; } #bch-panel .bch-count { color:#fff; font-weight:600; font-size:11.5px; font-variant-numeric:tabular-nums; } #bch-panel .bch-empty { padding:12px 0; text-align:center; color:#777; font-size:11px; } #bch-panel .bch-size { border-top:1px solid #2f2f2f; } #bch-panel .bch-size-head { display:flex; align-items:center; gap:6px; padding:6px 10px; cursor:pointer; } #bch-panel .bch-size-head:hover { background:#2a2a2a; } #bch-panel .bch-size-title { flex:none; color:#d6d6d6; font-size:11px; font-weight:600; } #bch-panel .bch-size-sub { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#7f7f7f; font-size:10px; text-align:right; } #bch-panel .bch-chev { flex:none; color:#999; font-size:11px; transition:transform .15s; } #bch-panel .bch-size-head.open .bch-chev { transform:rotate(90deg); } #bch-panel .bch-size-body { padding:0 10px 8px; } #bch-panel .bch-size-row { display:flex; align-items:center; gap:4px; color:#9a9a9a; font-size:10px; } #bch-panel .bch-size-row input { width:48px; padding:2px 4px; border:1px solid #404040; border-radius:4px; background:#1a1a1a; color:#e8e8e8; font-size:11px; outline:none; } #bch-panel .bch-size-mini { flex:none; padding:2px 6px; border:1px solid #404040; border-radius:4px; background:#2a2a2a; color:#aaa; font-size:10px; cursor:pointer; } #bch-panel .bch-size-mini:hover { background:#333; color:#fff; } #bch-panel .bch-size-tip { min-height:14px; margin-top:5px; overflow:hidden; color:#8a8a8a; font-size:10px; line-height:1.35; text-overflow:ellipsis; white-space:nowrap; } #bch-panel .bch-size-btn { width:100%; margin-top:6px; padding:4px 0; border:1px solid #3f6188; border-radius:4px; background:#2f4a6b; color:#bcd7f2; font-size:11px; cursor:pointer; } #bch-panel .bch-size-btn:hover { background:#35567d; } #bch-panel .bch-size-btn:disabled { opacity:.4; cursor:not-allowed; } #bch-panel .bch-size-undo { width:100%; margin-top:5px; padding:3px 0; border:1px solid #4a4a4a; border-radius:4px; background:transparent; color:#aaa; font-size:10px; cursor:pointer; } #bch-panel .bch-size-undo:hover { border-color:#666; color:#fff; } #bch-panel .bch-foot { display:flex; align-items:center; gap:6px; padding:6px 10px; background:#202020; border-top:1px solid #343434; } #bch-panel .bch-status-dot { width:6px; height:6px; border-radius:50%; background:#2ecc71; flex:none; } #bch-panel .bch-foot-text { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#8f8f8f; font-size:10px; } #bch-panel .bch-save { border:1px solid #3c5a45; background:#28382d; color:#9fd6ae; border-radius:4px; padding:2px 7px; font-size:10px; cursor:pointer; } #bch-panel .bch-save:hover { background:#324737; } #bch-panel .bch-save:disabled { opacity:.4; cursor:not-allowed; } #bch-panel .bch-list::-webkit-scrollbar { width:4px; } #bch-panel .bch-list::-webkit-scrollbar-thumb { background:#4a4a4a; border-radius:2px; } #bch-ball { position: fixed; width:46px; height:46px; display:none; flex-direction:column; align-items:center; justify-content:center; background:rgba(35,35,35,.94); border:1px solid #444; border-radius:50%; box-shadow:0 4px 14px rgba(0,0,0,.35); color:#fff; cursor:grab; z-index:999998; user-select:none; backdrop-filter:blur(4px); } #bch-ball:active { cursor:grabbing; } #bch-ball .bch-ball-num { font-size:12px; font-weight:700; line-height:1.1; max-width:40px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } #bch-ball .bch-ball-label { font-size:8px; color:#8f8f8f; line-height:1.1; } #bch-ball .bch-ball-dot { position:absolute; top:3px; right:4px; width:6px; height:6px; border-radius:50%; background:#2ecc71; } #bch-panel [hidden] { display:none !important; } `; document.head.appendChild(style); } const panel = document.createElement('div'); panel.id = 'bch-panel'; panel.innerHTML = `