NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript==
// @name 监控录像调看记录表-自动填写
// @namespace https://oa.hengdianfilm.com/
// @version 2.4.0
// @description 在监控录像调看记录表页面添加可拖动的浮动按钮,一键填写影城编号、收银员、5 行起止时间段及四项是/否(行数由用户手动准备好)。
// @author you
// @license MIT
// @match *://oa.hengdianfilm.com/seeyon/*
// @match *://oa.hengdianfilm.com/seeyon/common/cap4/template/display/pc/form/dist/index.html*
// @run-at document-idle
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// ==/UserScript==
(function () {
'use strict';
// ---------- 目标识别 ----------
function isTargetFormDocument() {
const titles = document.querySelectorAll('.field-title');
for (const t of titles) if ((t.textContent || '').trim() === '影城编号') return true;
return false;
}
// 等待 CAP4 表单异步渲染完成
const READY_TIMEOUT_MS = 30000;
const readyStart = Date.now();
const readyTimer = setInterval(() => {
if (isTargetFormDocument()) {
clearInterval(readyTimer);
init();
} else if (Date.now() - readyStart > READY_TIMEOUT_MS) {
clearInterval(readyTimer);
}
}, 500);
// ---------- 持久化(Tampermonkey 永久存储 + localStorage 双写) ----------
const STORAGE_KEY = 'hengdian_monitor_autofill_cfg_v2';
const FAB_POS_KEY = 'hengdian_monitor_autofill_fab_pos_v2';
function loadCfg() {
try {
if (typeof GM_getValue === 'function') {
const v = GM_getValue(STORAGE_KEY, null);
if (v) return typeof v === 'string' ? JSON.parse(v) : v;
}
} catch (e) {}
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : null;
} catch (e) { return null; }
}
function saveCfg(cfg) {
const s = JSON.stringify(cfg);
try { if (typeof GM_setValue === 'function') GM_setValue(STORAGE_KEY, s); } catch (e) {}
try { localStorage.setItem(STORAGE_KEY, s); } catch (e) {}
}
function loadFabPos() {
try {
if (typeof GM_getValue === 'function') {
const v = GM_getValue(FAB_POS_KEY, null);
if (v) return typeof v === 'string' ? JSON.parse(v) : v;
}
} catch (e) {}
try { return JSON.parse(localStorage.getItem(FAB_POS_KEY) || 'null'); }
catch (e) { return null; }
}
function saveFabPos(pos) {
const s = JSON.stringify(pos);
try { if (typeof GM_setValue === 'function') GM_setValue(FAB_POS_KEY, s); } catch (e) {}
try { localStorage.setItem(FAB_POS_KEY, s); } catch (e) {}
}
// ---------- 通用工具 ----------
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
// 用原生 setter + 一组事件触发,让 Vue/React 等都能识别值变化
function setNativeValue(input, value) {
const proto = input.tagName === 'TEXTAREA'
? window.HTMLTextAreaElement.prototype
: window.HTMLInputElement.prototype;
const setter = Object.getOwnPropertyDescriptor(proto, 'value').set;
setter.call(input, value);
}
function fireFullSequence(input, value) {
input.focus();
try { input.click(); } catch (e) {}
// 触发 keydown 让有些框架进入 "用户输入" 模式
try { input.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true })); } catch (e) {}
setNativeValue(input, value);
try {
input.dispatchEvent(new InputEvent('input', {
bubbles: true, cancelable: true, data: value, inputType: 'insertText',
}));
} catch (e) {
input.dispatchEvent(new Event('input', { bubbles: true }));
}
input.dispatchEvent(new Event('change', { bubbles: true }));
try { input.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true })); } catch (e) {}
input.dispatchEvent(new Event('blur', { bubbles: true }));
input.blur();
}
// ---------- 字段定位 ----------
function getFieldSection(node) {
let p = node;
while (p && p !== document.body) {
if (p.matches && p.matches('section')) return p;
p = p.parentElement;
}
return null;
}
// 返回所有具有该标题的字段 section(多行子表时有多个)
function getAllSectionsByTitle(titleText) {
const out = [];
document.querySelectorAll('.field-title').forEach(n => {
if ((n.textContent || '').trim() === titleText) {
const sec = getFieldSection(n);
if (sec) out.push(sec);
}
});
return out;
}
// 第一个匹配(用于"影城编号"这种外层单值字段)
function getFirstSectionByTitle(titleText) {
const all = getAllSectionsByTitle(titleText);
return all.length ? all[0] : null;
}
// ---------- 各类型字段填写 ----------
function fillNumberInSection(section, value) {
if (!section) return false;
const input = section.querySelector('.cap4-number__cntinput input.is-activeInput')
|| section.querySelector('.cap4-number__cntinput input:not([readonly])')
|| section.querySelector('input:not([readonly])');
if (!input) return false;
fireFullSequence(input, String(value));
return true;
}
function fillTextInSection(section, value) {
if (!section) return false;
// 先点击外层容器尝试进入编辑态
const cnt = section.querySelector('.cap4-text__cnt')
|| section.querySelector('.field-content');
if (cnt) {
try { cnt.dispatchEvent(new MouseEvent('click', { bubbles: true })); } catch (e) {}
}
// 找可写 input
let input = null;
const inputs = section.querySelectorAll('input');
for (const inp of inputs) {
if (inp.type !== 'hidden' && !inp.readOnly && !inp.disabled) { input = inp; break; }
}
if (!input) input = section.querySelector('input[type="text"]') || section.querySelector('input');
if (!input) return false;
fireFullSequence(input, String(value));
return true;
}
function fillDateTimeInSection(section, value) {
if (!section) return false;
const input = section.querySelector('input[type="text"]')
|| section.querySelector('input');
if (!input) return false;
fireFullSequence(input, String(value));
return true;
}
function fillSelectInSection(section, value) {
if (!section) return false;
// 1) 直接设内层 input(多数 CAP4 select 这样就生效)
const innerInput = section.querySelector('.cap4-select__box input')
|| section.querySelector('input[id$="_inner"]')
|| section.querySelector('input');
if (innerInput) fireFullSequence(innerInput, value);
// 2) 兜底:模拟点击展开 → 找弹出层匹配项 → 点击
try {
const trigger = section.querySelector('.cap4-select__cntdefault')
|| section.querySelector('.cap4-select__box')
|| innerInput;
if (trigger) {
trigger.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
trigger.dispatchEvent(new MouseEvent('click', { bubbles: true }));
setTimeout(() => {
const popups = document.querySelectorAll(
'.cap4-select__dropdown li, .cap4-select__option, .ui-select-option, li[class*="option"], .dropdown li'
);
for (const li of popups) {
if ((li.textContent || '').trim() === value) {
li.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
li.dispatchEvent(new MouseEvent('click', { bubbles: true }));
break;
}
}
}, 100);
}
} catch (e) {}
return true;
}
// ---------- 子表行 ----------
function getRowCount() {
// 以"日期时间1"作为每行存在的标识
return getAllSectionsByTitle('日期时间1').length;
}
// ---------- 时间段 ----------
const TIME_GROUPS = {
'group1': {
label: '第一组(12:00 - 16:10)',
slots: [
{ start: '12:00', end: '12:10' },
{ start: '13:00', end: '13:10' },
{ start: '14:00', end: '14:10' },
{ start: '15:00', end: '15:10' },
{ start: '16:00', end: '16:10' },
],
},
'group2': {
label: '第二组(17:00 - 21:10)',
slots: [
{ start: '17:00', end: '17:10' },
{ start: '18:00', end: '18:10' },
{ start: '19:00', end: '19:10' },
{ start: '20:00', end: '20:10' },
{ start: '21:00', end: '21:10' },
],
},
};
function todayStr() {
const d = new Date();
const p = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
}
// ---------- 主流程 ----------
function init() {
if (document.getElementById('hd-autofill-fab')) return;
injectStyle();
const fab = createFab();
const panel = createPanel();
document.body.appendChild(fab);
document.body.appendChild(panel);
fab.addEventListener('click', () => {
if (fab.dataset.dragged === '1') { fab.dataset.dragged = ''; return; }
panel.style.display = panel.style.display === 'block' ? 'none' : 'block';
});
enableDrag(fab);
bindPanel(panel);
}
function injectStyle() {
const css = `
#hd-autofill-fab {
position: fixed; right: 24px; bottom: 80px; z-index: 2147483600;
width: 56px; height: 56px; border-radius: 50%; cursor: grab;
background: linear-gradient(135deg, #4f8cff, #1f5fe0); color: #fff;
font-size: 13px; font-weight: 600; line-height: 1.1;
display: flex; align-items: center; justify-content: center; text-align: center;
box-shadow: 0 6px 18px rgba(31,95,224,0.45); user-select: none;
}
#hd-autofill-fab:active { cursor: grabbing; }
#hd-autofill-panel {
position: fixed; right: 24px; bottom: 150px; z-index: 2147483600;
width: 400px; max-height: 80vh; overflow: auto;
background: #fff; border-radius: 10px;
box-shadow: 0 12px 32px rgba(0,0,0,0.18);
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
font-size: 13px; color: #222; padding: 14px 16px; display: none;
border: 1px solid #e6e8eb;
}
#hd-autofill-panel h3 {
margin: 0 0 6px; font-size: 14px;
display: flex; justify-content: space-between; align-items: center;
}
#hd-autofill-panel .hd-persist-tip {
color: #2a9b5d; font-size: 12px; background: #eaf7ef;
border: 1px solid #cbe9d6; border-radius: 5px; padding: 4px 8px; margin-bottom: 8px;
}
#hd-autofill-panel h3 .hd-close { cursor: pointer; color: #999; font-weight: 400; }
#hd-autofill-panel label.hd-label { display: block; margin: 10px 0 4px; color: #555; font-weight: 600; }
#hd-autofill-panel input[type=text], #hd-autofill-panel textarea {
width: 100%; box-sizing: border-box; padding: 6px 8px;
border: 1px solid #d6d9dd; border-radius: 5px; outline: none; font-size: 13px;
font-family: inherit;
}
#hd-autofill-panel textarea { min-height: 60px; resize: vertical; }
#hd-autofill-panel .hd-cashier-list { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
#hd-autofill-panel .hd-cashier-list .hd-chip {
border: 1px solid #d6d9dd; border-radius: 14px; padding: 3px 10px; cursor: pointer; background: #f7f8fa;
}
#hd-autofill-panel .hd-cashier-list .hd-chip.active {
background: #1f5fe0; color: #fff; border-color: #1f5fe0;
}
#hd-autofill-panel .hd-group-cards { display: flex; gap: 8px; margin-top: 4px; }
#hd-autofill-panel .hd-group-card {
flex: 1; border: 1px solid #d6d9dd; border-radius: 8px; padding: 8px 10px;
cursor: pointer; background: #f7f8fa; transition: all .15s;
}
#hd-autofill-panel .hd-group-card:hover { border-color: #1f5fe0; }
#hd-autofill-panel .hd-group-card.active {
background: #1f5fe0; color: #fff; border-color: #1f5fe0;
}
#hd-autofill-panel .hd-group-card .hd-group-name { font-weight: 600; margin-bottom: 4px; }
#hd-autofill-panel .hd-group-card .hd-group-slots { font-size: 12px; opacity: .9; line-height: 1.5; }
#hd-autofill-panel .hd-row { display: flex; gap: 8px; align-items: center; }
#hd-autofill-panel .hd-row select { flex: 1; padding: 5px; border: 1px solid #d6d9dd; border-radius: 4px; }
#hd-autofill-panel .hd-actions { margin-top: 14px; display: flex; gap: 8px; justify-content: flex-end; }
#hd-autofill-panel button.hd-btn {
border: 0; border-radius: 5px; padding: 6px 14px; cursor: pointer; font-weight: 600; font-size: 13px;
}
#hd-autofill-panel button.hd-primary { background: #1f5fe0; color: #fff; }
#hd-autofill-panel button.hd-secondary { background: #eef1f5; color: #333; }
#hd-autofill-panel .hd-hint { color: #999; font-size: 12px; margin-top: 6px; }
`;
const style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
}
function createFab() {
const fab = document.createElement('div');
fab.id = 'hd-autofill-fab';
fab.textContent = '一键填写';
const pos = loadFabPos();
if (pos && pos.left && pos.top) {
fab.style.left = pos.left;
fab.style.top = pos.top;
fab.style.right = 'auto';
fab.style.bottom = 'auto';
}
return fab;
}
function createPanel() {
const panel = document.createElement('div');
panel.id = 'hd-autofill-panel';
panel.innerHTML = `
<h3>监控记录 · 自动填写 <span class="hd-close">×</span></h3>
<label class="hd-label">影城编号</label>
<input type="text" id="hd-cinema" placeholder="例如:001" />
<label class="hd-label">收银员姓名(每行一个,可保存多个;下方点选一个)</label>
<textarea id="hd-cashier-input" placeholder="张三 李四 王五"></textarea>
<div class="hd-cashier-list" id="hd-cashier-chips"></div>
<label class="hd-label">时间段(点击选择整组,按页面已有行数依次填入起止时间,使用今日日期)</label>
<div class="hd-group-cards" id="hd-group-cards"></div>
<label class="hd-label">每行的四项审核(默认值已按你的要求设置)</label>
<div class="hd-row"><span style="width:170px;">收银画面是否清晰</span>
<select id="hd-q1"><option value="是">是</option><option value="否">否</option></select>
</div>
<div class="hd-row" style="margin-top:6px"><span style="width:170px;">是否带手机或其他私人物品</span>
<select id="hd-q2"><option value="否">否</option><option value="是">是</option></select>
</div>
<div class="hd-row" style="margin-top:6px"><span style="width:170px;">收款后是否及时出单</span>
<select id="hd-q3"><option value="是">是</option><option value="否">否</option></select>
</div>
<div class="hd-row" style="margin-top:6px"><span style="width:170px;">是否存在私自收款行为</span>
<select id="hd-q4"><option value="否">否</option><option value="是">是</option></select>
</div>
<label class="hd-label">其他情况(每行同填)</label>
<input type="text" id="hd-other" value="无" />
<div class="hd-hint">说明:脚本不会自动加行,请在页面里手动准备好至少 5 行子表再点"一键填写"。</div>
<div class="hd-actions">
<button class="hd-btn hd-secondary" id="hd-reset">清空配置</button>
<button class="hd-btn hd-primary" id="hd-apply">一键填写</button>
</div>
`;
return panel;
}
function renderCashierChips(panel, cfg) {
const wrap = panel.querySelector('#hd-cashier-chips');
wrap.innerHTML = '';
const names = (cfg.cashierList || []).filter(Boolean);
// 若只有一个名字且没选过,自动选中它
if (names.length === 1 && !cfg.cashierSelected) {
cfg.cashierSelected = names[0];
saveCfg(cfg);
}
// 若选中已经不在列表里,清掉
if (cfg.cashierSelected && !names.includes(cfg.cashierSelected)) {
cfg.cashierSelected = '';
saveCfg(cfg);
}
names.forEach((name) => {
const chip = document.createElement('span');
chip.className = 'hd-chip' + (cfg.cashierSelected === name ? ' active' : '');
chip.textContent = name;
chip.addEventListener('click', () => {
cfg.cashierSelected = name;
saveCfg(cfg);
renderCashierChips(panel, cfg);
});
wrap.appendChild(chip);
});
if (!names.length) {
const tip = document.createElement('span');
tip.style.cssText = 'color:#aaa;font-size:12px;';
tip.textContent = '(先在上方文本框输入姓名,每行一个)';
wrap.appendChild(tip);
}
}
function renderGroupCards(panel, cfg) {
const wrap = panel.querySelector('#hd-group-cards');
wrap.innerHTML = '';
Object.keys(TIME_GROUPS).forEach((key) => {
const g = TIME_GROUPS[key];
const card = document.createElement('div');
card.className = 'hd-group-card' + (cfg.timeGroup === key ? ' active' : '');
card.innerHTML = `
<div class="hd-group-name">${g.label}</div>
<div class="hd-group-slots">${g.slots.map(s => `${s.start}-${s.end}`).join('<br>')}</div>
`;
card.addEventListener('click', () => {
cfg.timeGroup = key;
saveCfg(cfg);
renderGroupCards(panel, cfg);
});
wrap.appendChild(card);
});
}
function bindPanel(panel) {
const cfg = loadCfg() || {
cinema: '',
cashierList: [],
cashierSelected: '',
timeGroup: '',
q1: '是', q2: '否', q3: '是', q4: '否',
other: '无',
};
const cinemaEl = panel.querySelector('#hd-cinema');
const cashierInputEl = panel.querySelector('#hd-cashier-input');
const otherEl = panel.querySelector('#hd-other');
const q1 = panel.querySelector('#hd-q1');
const q2 = panel.querySelector('#hd-q2');
const q3 = panel.querySelector('#hd-q3');
const q4 = panel.querySelector('#hd-q4');
cinemaEl.value = cfg.cinema || '';
cashierInputEl.value = (cfg.cashierList || []).join('\n');
otherEl.value = cfg.other || '无';
if (cfg.q1) q1.value = cfg.q1;
if (cfg.q2) q2.value = cfg.q2;
if (cfg.q3) q3.value = cfg.q3;
if (cfg.q4) q4.value = cfg.q4;
renderCashierChips(panel, cfg);
renderGroupCards(panel, cfg);
cinemaEl.addEventListener('input', () => { cfg.cinema = cinemaEl.value.trim(); saveCfg(cfg); });
cashierInputEl.addEventListener('input', () => {
cfg.cashierList = cashierInputEl.value.split('\n').map(s => s.trim()).filter(Boolean);
saveCfg(cfg);
renderCashierChips(panel, cfg);
});
otherEl.addEventListener('input', () => { cfg.other = otherEl.value; saveCfg(cfg); });
q1.addEventListener('change', () => { cfg.q1 = q1.value; saveCfg(cfg); });
q2.addEventListener('change', () => { cfg.q2 = q2.value; saveCfg(cfg); });
q3.addEventListener('change', () => { cfg.q3 = q3.value; saveCfg(cfg); });
q4.addEventListener('change', () => { cfg.q4 = q4.value; saveCfg(cfg); });
panel.querySelector('.hd-close').addEventListener('click', () => { panel.style.display = 'none'; });
panel.querySelector('#hd-reset').addEventListener('click', () => {
if (!confirm('确认清空全部已保存配置?')) return;
try { localStorage.removeItem(STORAGE_KEY); } catch (e) {}
try { localStorage.removeItem(FAB_POS_KEY); } catch (e) {}
try { if (typeof GM_deleteValue === 'function') { GM_deleteValue(STORAGE_KEY); GM_deleteValue(FAB_POS_KEY); } } catch (e) {}
try { if (typeof GM_setValue === 'function') { GM_setValue(STORAGE_KEY, ''); GM_setValue(FAB_POS_KEY, ''); } } catch (e) {}
location.reload();
});
panel.querySelector('#hd-apply').addEventListener('click', async () => {
const applyBtn = panel.querySelector('#hd-apply');
applyBtn.disabled = true; applyBtn.textContent = '填写中…';
try { await applyToForm(cfg); }
finally { applyBtn.disabled = false; applyBtn.textContent = '一键填写'; }
});
}
// ---------- 应用配置到表单 ----------
async function applyToForm(cfg) {
const errs = [];
// 1) 外层字段:影城编号
if (cfg.cinema) {
const sec = getFirstSectionByTitle('影城编号');
if (!fillNumberInSection(sec, cfg.cinema)) errs.push('影城编号');
} else {
errs.push('影城编号(未配置)');
}
// 2) 校验时间组 & 收银员
if (!cfg.timeGroup || !TIME_GROUPS[cfg.timeGroup]) {
errs.push('时间段(未选择)');
}
if (!cfg.cashierSelected) {
errs.push('收银员姓名(未选择)');
}
// 3) 第一步:直接以页面当前已有的行数为准(不再自动"插入行",因为页面默认就是 5 行)
if (cfg.timeGroup && TIME_GROUPS[cfg.timeGroup]) {
const slots = TIME_GROUPS[cfg.timeGroup].slots;
const rowCount = getRowCount();
console.log(`[横店OA自动填写] 当前子表行数:${rowCount},本组时间段数:${slots.length}`);
const fillCount = Math.min(rowCount, slots.length);
if (rowCount < slots.length) {
errs.push(`子表当前只有 ${rowCount} 行,少于 5 行:请先在页面里手动加到 5 行后再点"一键填写"`);
}
await sleep(150);
// 4) 取每行的字段 section(按行顺序)
const startSecs = getAllSectionsByTitle('日期时间1');
const endSecs = getAllSectionsByTitle('日期时间2');
const q1Secs = getAllSectionsByTitle('收银画面是否清晰');
const cashierSecs= getAllSectionsByTitle('收银员');
const q2Secs = getAllSectionsByTitle('是否带手机或 其他私人物品');
const q2SecsAlt = q2Secs.length ? q2Secs : getAllSectionsByTitle('是否带手机或其他私人物品');
const q3Secs = getAllSectionsByTitle('收款后是否及时出单');
const q4Secs = getAllSectionsByTitle('是否存在私自收款行为');
const otherSecs = getAllSectionsByTitle('其他情况');
const date = todayStr();
for (let i = 0; i < fillCount; i++) {
// 起 / 止
if (!fillDateTimeInSection(startSecs[i], `${date} ${slots[i].start}`)) errs.push(`第 ${i+1} 行 · 起`);
if (!fillDateTimeInSection(endSecs[i], `${date} ${slots[i].end}`)) errs.push(`第 ${i+1} 行 · 止`);
// 收银员
if (cfg.cashierSelected) {
if (!fillTextInSection(cashierSecs[i], cfg.cashierSelected)) errs.push(`第 ${i+1} 行 · 收银员`);
}
// 四项是/否
fillSelectInSection(q1Secs[i], cfg.q1 || '是');
fillSelectInSection(q2SecsAlt[i], cfg.q2 || '否');
fillSelectInSection(q3Secs[i], cfg.q3 || '是');
fillSelectInSection(q4Secs[i], cfg.q4 || '否');
// 其他情况
fillTextInSection(otherSecs[i], cfg.other || '无');
await sleep(60); // 给框架一点节流时间
}
}
if (errs.length) {
console.warn('[横店OA自动填写] 未完成项:', errs);
toast('已尽力填写,部分项目可能需要手动确认(控制台有详细列表)', false);
} else {
toast('已填写完成 ✔', true);
}
}
function toast(text, ok) {
const tip = document.createElement('div');
tip.textContent = text;
tip.style.cssText = `position:fixed;left:50%;top:24px;transform:translateX(-50%);
background:${ok ? '#1f5fe0' : '#e08a1f'};color:#fff;padding:8px 14px;border-radius:6px;
z-index:2147483647;box-shadow:0 6px 16px rgba(0,0,0,.2);
font-family:-apple-system,"PingFang SC",sans-serif;font-size:13px;`;
document.body.appendChild(tip);
setTimeout(() => tip.remove(), 1800);
}
// ---------- 拖动 ----------
function enableDrag(el) {
let startX = 0, startY = 0, origLeft = 0, origTop = 0, dragging = false, moved = false;
el.addEventListener('mousedown', (e) => {
dragging = true; moved = false;
const rect = el.getBoundingClientRect();
origLeft = rect.left; origTop = rect.top;
startX = e.clientX; startY = e.clientY;
el.style.left = origLeft + 'px';
el.style.top = origTop + 'px';
el.style.right = 'auto'; el.style.bottom = 'auto';
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (!dragging) return;
const dx = e.clientX - startX, dy = e.clientY - startY;
if (Math.abs(dx) + Math.abs(dy) > 3) moved = true;
const nx = Math.max(0, Math.min(window.innerWidth - el.offsetWidth, origLeft + dx));
const ny = Math.max(0, Math.min(window.innerHeight - el.offsetHeight, origTop + dy));
el.style.left = nx + 'px';
el.style.top = ny + 'px';
});
document.addEventListener('mouseup', () => {
if (!dragging) return;
dragging = false;
if (moved) {
el.dataset.dragged = '1';
saveFabPos({ left: el.style.left, top: el.style.top });
}
});
}
})();