pzt / 监控录像调看记录表-自动填写

// ==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="张三&#10;李四&#10;王五"></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 });
            }
        });
    }
})();