// ==UserScript== // @name 网页填写识别回填操作台 // @namespace http://tampermonkey.net/ // @version 12.9 // @description 记忆网页表单;悬浮可拖拽缩放控制台;下拉回填;全网页保存识别,回填数据,减少繁琐操作 // @author MoXingcheng(2464479805@qq.com) // @match *://*/* // @grant none // @license All Rights Reserved // ==/UserScript== (function () { 'use strict'; /* * 'use strict' 严格模式:开启JS严格语法校验,禁止未声明变量,不要删除 */ /* ================================================================ * 【一、可调参数区】单位:毫秒(ms),1000ms = 1秒 * 想调手感只动这一段,下面代码不用碰 * ================================================================ */ // 自动保存防抖延时:表单控件触发 change 事件后,等多久才真正写入 localStorage // 连续输入/选择时不会每一下都写盘,停手后等 SAVE_DELAY 才存 // 推荐 1500-3000,太小会频繁写盘,太大感觉"存得慢" const SAVE_DELAY = 2000; // 下拉框回填时,等待选项面板出现并找到目标项的最大超时 // 网页卡、接口慢就把它调大,否则可能报"未选中" const DROPDOWN_WAIT = 2000; // 弹窗(Modal/Dialog)打开后,等页面 DOM 稳定下来再自动回填的延时 // 弹窗里的异步表单渲染慢就调大,否则可能找不到刚渲染出来的输入框 const STABLE_WAIT = 350; // 按 ESC 关闭下拉浮层后,等待关闭动画结束的时间 // 太短会在动画途中立刻点下一个下拉,导致浮层错位;推荐 100-200 const CLOSE_WAIT = 120; // 点击下拉后,等待下拉浮层(popup)挂载到 DOM 的最大超时 const PANEL_WAIT = 800; // 点击下拉选项后,等待框架(React/Vue)把选中值写回控件的时间 // 立刻读值会读到旧值,所以要等一下再校验是否真的选中 const OPTION_CHECK = 220; // 轮询 DOM 的间隔:等浮层、等选项出现时,每隔多少毫秒检查一次 // 越小越灵敏但越吃 CPU;一般 50-150 够用 const POLL_INTERVAL = 70; // 两个输入框依次回填之间的停顿 // 太快连续触发 input/change 事件,某些前端框架会丢事件;留 20-40ms 比较稳 const INPUT_GAP = 25; // 悬浮面板距离浏览器视口边缘的最小留白(px) // 拖拽/窗口缩放时,面板不会贴边或越出屏幕 const PANEL_MARGIN = 8; // 递增列表最大高度占视口高度的比例 // 0.55 = 内容行多时,列表最多占屏幕 55%,超出部分在列表内部滚动 // 内容行少时(没到这个比例)全部直接铺开,不出现滚动条也不留空白 const LIST_MAX_RATIO = 0.55; /* ================================================================ * 【二、内部状态变量】——一般不用改 * ================================================================ */ // localStorage 里存"字段值"用的 key // 用当前网址(去掉#后面的哈希)作为前缀,保证不同页面互不串数据 const pageKey = 'fmAuto_' + location.href.split('#')[0]; // localStorage 里存"递增/递减配置"用的 key(每个字段的步长、模式) const incKey = pageKey + '_inc'; // 回填锁:true 表示正在脚本自动写值,此时忽略 change 事件、不再触发保存 // 避免"回填→触发change→又保存→覆盖刚填的值"的循环 let suppressSave = false; // 当前弹窗是否已经回填过一次 // 防止同一个弹窗每次 Mutation 触发都重复填 let formFilled = false; // 上一帧检测到的弹窗开关状态:用来判断"弹窗刚打开"这个边沿 let hadModal = false; // 悬浮面板的 DOM 引用,展开后所有操作都靠它 let panelRef = null; // 递增列表当前是否展开(用于 ResizeObserver 判断要不要同步列表高度) let listExpanded = false; // 小工具:返回一个 Promise,等待 ms 毫秒 // 配合 await 使用,比 setTimeout 嵌套好读 function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } /* ================================================================ * 【三、字段唯一标识生成】 * 给页面上每个表单控件算一个"身份证",用来在 localStorage 里对应它存的值 * 优先级:name 属性 > id 属性 > 逐级向上的 CSS 路径 * ================================================================ */ function getElementPath(el) { // 优先用 name(表单字段最标准的标识) if (el.name) return el.tagName.toLowerCase() + '@' + el.name; // 其次用 id if (el.id) return el.tagName.toLowerCase() + '#' + el.id; // 都没有就向上爬 DOM,用"标签名+在兄弟节点中的序号"拼一条路径 const path = []; let node = el; while (node && node.nodeType === 1) { const tag = node.tagName.toLowerCase(); if (node.id) { path.unshift(tag + '#' + node.id); break; } // 找同标签兄弟,确定自己是第几个,避免位置变动后认不出 const sib = Array.from(node.parentNode.children).filter(e => e.tagName === node.tagName); path.unshift(tag + '[' + sib.indexOf(node) + ']'); node = node.parentNode; } return path.join('>'); } /* ================================================================ * scanFields:扫描页面全部表单控件并分类 * 返回数组,每项 { el: DOM节点, key: 唯一标识, category: 类型 } * category 三种: * native —— 原生 input/textarea/select * antd —— Ant Design 的 .ant-select 下拉 * element —— ElementUI 的 .el-select 下拉 * ================================================================ */ function scanFields() { const fields = []; const keys = new Set(); // 去重用,同一个控件不重复加 const add = (el, category) => { const key = getElementPath(el); if (!key || keys.has(key)) return; keys.add(key); fields.push({ el, key, category }); }; // 原生控件:跳过按钮/隐藏域/文件/密码框等不该被记忆的类型 document.querySelectorAll('input, textarea, select').forEach(el => { const t = (el.type || '').toLowerCase(); if (['button','submit','reset','hidden','file','password','image'].includes(t)) return; // 虚拟下拉框内部那个藏起来的 input 跳过,外层 .ant-select/.el-select 会单独加 if (el.closest('.ant-select, .el-select')) return; add(el, 'native'); }); // AntD / ElementUI 下拉整体作为一个"字段" document.querySelectorAll('.ant-select').forEach(el => add(el, 'antd')); document.querySelectorAll('.el-select').forEach(el => add(el, 'element')); return fields; } /* ================================================================ * getFieldLabel:猜输入框旁边的中文标签 * 用途:在递增面板里给每行起个能看懂的名字(比如"设备名称") * 识别失败返回 null,该行不显示在列表里 * ================================================================ */ function getFieldLabel(field) { const el = field.el; // 1) 优先用无障碍标签 aria-label if (el.getAttribute('aria-label')) return el.getAttribute('aria-label'); // 2) AntD / ElementUI 表单的 .ant-form-item-label / .el-form-item__label const formItem = el.closest('.ant-form-item, .el-form-item'); if (formItem) { const label = formItem.querySelector('.ant-form-item-label label, .ant-form-item-label, .el-form-item__label'); if (label && label.innerText.trim()) return label.innerText.trim().replace(/[::*]/g, ''); } // 3) 向上找几层父级,看兄弟节点里有没有像标签的纯文字 let p = el.parentElement; for (let i = 0; i < 6 && p; i++) { for (const child of p.children) { if (child === el || child.contains(el)) continue; if (child.querySelector('input, select, textarea')) continue; const txt = child.innerText.trim(); // 标签一般短(<20字)、不带按钮链接 if (txt && txt.length < 20 && !child.querySelector('button, a')) { return txt.replace(/[::*]/g, ''); } } p = p.parentElement; } // 4) 实在认不出来就用 placeholder if (el.placeholder) return el.placeholder; return null; } /* ================================================================ * readField:读取控件当前真实显示的值 * 原生控件读 .value;AntD 下拉读选中项文字;ElementUI 读内部 input * ================================================================ */ function readField(field) { const el = field.el; if (field.category === 'antd') { // AntD 选中项可能在好几种节点里,挨个试 const selectors = ['.ant-select-selection-item', '.ant-select-selection-selected-value', '.ant-select-selection__rendered']; for (const sel of selectors) { const nodes = el.querySelectorAll(sel); const texts = Array.from(nodes).map(n => n.innerText.trim()).filter(Boolean); if (texts.length) return texts.join(','); // 多选用逗号拼 } const inp = el.querySelector('input'); return inp ? inp.value.trim() : ''; } if (field.category === 'element') { const inp = el.querySelector('input'); return inp ? inp.value.trim() : ''; } const t = (el.type || '').toLowerCase(); if (t === 'checkbox' || t === 'radio') return el.checked; // 勾选框读布尔 return el.value.trim(); } /* ================================================================ * saveAll:把当前页面所有非空表单值存进 localStorage * 旧记忆里已有的字段会被新值合并覆盖;空值不覆盖旧值 * ================================================================ */ function saveAll() { const oldRaw = localStorage.getItem(pageKey); const oldData = oldRaw ? JSON.parse(oldRaw) : {}; scanFields().forEach(f => { const v = readField(f); if (v !== '' && v !== false) oldData[f.key] = v; }); localStorage.setItem(pageKey, JSON.stringify(oldData)); showToast('✅已保存'); // 列表展开时顺便刷新一下右侧的"已存值"输入框 const list = document.getElementById('fmIncList'); if (list && list.style.display === 'block') renderIncList(); } // change 事件的防抖计时器 let saveTimer = null; // 监听整个 document 的 change 事件(捕获阶段),任何表单控件变化都触发 document.addEventListener('change', onFormChange, true); function onFormChange(e) { if (suppressSave) return; // 脚本回填中,不存 const el = e.target; if (!el) return; if (el.matches('input, textarea, select') || el.closest('.ant-select, .el-select')) { clearTimeout(saveTimer); saveTimer = setTimeout(saveAll, SAVE_DELAY); // 防抖:停手 SAVE_DELAY 后才存 } } // 读/写递增配置(哪个字段要+1/-1、步长多少) function getIncSettings() { try { return JSON.parse(localStorage.getItem(incKey) || '{}'); } catch { return {}; } } function setIncSettings(s) { localStorage.setItem(incKey, JSON.stringify(s)); } /* ================================================================ * fullMouseClick:派发完整的鼠标事件序列 * 直接 el.click() 在 Vue/React 某些版本上不触发框架的事件监听, * 所以手动派发 pointerdown/mousedown/pointerup/mouseup/click 全套 * ================================================================ */ async function fullMouseClick(el) { el.scrollIntoView({ block: 'nearest' }); await sleep(30); const rect = el.getBoundingClientRect(); const common = { bubbles: true, cancelable: true, view: window, button: 0, buttons: 1, clientX: rect.x + rect.width / 2, clientY: rect.y + rect.height / 2 }; el.dispatchEvent(new PointerEvent('pointerdown', common)); el.dispatchEvent(new MouseEvent('mousedown', common)); el.dispatchEvent(new PointerEvent('pointerup', common)); el.dispatchEvent(new MouseEvent('mouseup', common)); el.dispatchEvent(new MouseEvent('click', common)); } // 在 document 里找"当前可见"的浮层(排除 display:none / visibility:hidden 的) function findVisiblePanel(selector) { for (const p of document.querySelectorAll(selector)) { const rect = p.getBoundingClientRect(); const style = window.getComputedStyle(p); if (rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none') return p; } return null; } // 按 ESC 关掉当前打开的下拉浮层,然后等动画结束 async function closeDropdown() { document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); document.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape', bubbles: true })); await sleep(CLOSE_WAIT); } // 在浮层里按文字精确匹配(找不到就退而求其次包含匹配)找选项 function findOption(panel, optionSelector, targetText) { const options = panel.querySelectorAll(optionSelector); return Array.from(options).find(o => o.innerText.trim() === targetText) || Array.from(options).find(o => o.innerText.trim().includes(targetText)); } // 轮询等待浮层出现,超时返回 null async function waitVisiblePanel(selector, timeout) { const start = Date.now(); while (Date.now() - start < timeout) { const p = findVisiblePanel(selector); if (p) return p; await sleep(POLL_INTERVAL); } return null; } /* ================================================================ * pickAntd:给 Ant Design 下拉选值 * 流程:关旧浮层 → 点开下拉 → 等浮层 → 找目标项 → 点 → 校验 → 关浮层 * ================================================================ */ async function pickAntd(el, targetText) { if (readField({ el, category: 'antd' }) === targetText) return; // 已经是目标值就跳过 await closeDropdown(); const selector = el.querySelector('.ant-select-selector') || el; await fullMouseClick(selector); let panel = await waitVisiblePanel('.ant-select-dropdown', PANEL_WAIT); // 第一次没点开就换个位置再点一次 if (!panel) { await fullMouseClick(el); panel = await waitVisiblePanel('.ant-select-dropdown', PANEL_WAIT); } if (!panel) { const inp = el.querySelector('input'); if (inp) { await fullMouseClick(inp); panel = await waitVisiblePanel('.ant-select-dropdown', PANEL_WAIT); } } if (!panel) { console.warn('[表单记忆] 面板未出现:', targetText); return; } const start = Date.now(); let picked = false; while (Date.now() - start < DROPDOWN_WAIT && !picked) { panel = findVisiblePanel('.ant-select-dropdown') || panel; const target = findOption(panel, '.ant-select-item-option, .ant-select-dropdown-menu-item', targetText); if (target) { await fullMouseClick(target); await sleep(OPTION_CHECK); if (readField({ el, category: 'antd' }) === targetText) picked = true; } await sleep(POLL_INTERVAL); } if (!picked) console.warn('[表单记忆] 未选中:', targetText); await closeDropdown(); } /* ================================================================ * pickElement:给 ElementUI 下拉选值,流程同上 * ================================================================ */ async function pickElement(el, targetText) { if (readField({ el, category: 'element' }) === targetText) return; await closeDropdown(); const inner = el.querySelector('.el-input__inner') || el; await fullMouseClick(inner); let panel = await waitVisiblePanel('.el-select-dropdown', PANEL_WAIT); if (!panel) { await fullMouseClick(el); panel = await waitVisiblePanel('.el-select-dropdown', PANEL_WAIT); } if (!panel) { console.warn('[表单记忆] ElementUI面板未出现:', targetText); return; } const start = Date.now(); let picked = false; while (Date.now() - start < DROPDOWN_WAIT && !picked) { panel = findVisiblePanel('.el-select-dropdown') || panel; const target = findOption(panel, '.el-select-dropdown__item', targetText); if (target) { await fullMouseClick(target); await sleep(OPTION_CHECK); if (readField({ el, category: 'element' }) === targetText) picked = true; } await sleep(POLL_INTERVAL); } if (!picked) console.warn('[表单记忆] ElementUI未选中:', targetText); await closeDropdown(); } /* ================================================================ * writeField:给单个字段写值 * 数字字段如果开了 +1/-1 模式,会在原值上加减步长后再写 * ================================================================ */ async function writeField(field, value) { if (value === '' || value === undefined || value === null) return; const el = field.el; // 下拉框走专门的点选流程 if (field.category === 'antd') { await pickAntd(el, String(value)); return; } if (field.category === 'element') { await pickElement(el, String(value)); return; } let fillValue = value; const inc = getIncSettings(); // 如果该字段开了递增/递减模式,且值是数字:在旧值上加减步长 if (inc[field.key] && inc[field.key].mode && inc[field.key].mode !== 0 && !isNaN(parseFloat(value))) { const step = parseFloat(inc[field.key].step) || 0; const delta = inc[field.key].mode === 1 ? step : -step; // mode=1 加, mode=2 减 fillValue = String(parseFloat(value) + delta); // 把递增后的新值写回 localStorage,下次从新值继续 setTimeout(() => { try { const data = JSON.parse(localStorage.getItem(pageKey) || '{}'); data[field.key] = fillValue; localStorage.setItem(pageKey, JSON.stringify(data)); } catch {} }, 600); } const t = (el.type || '').toLowerCase(); if (t === 'checkbox' || t === 'radio') { el.checked = !!value; } else { const old = el.value; el.value = fillValue; // React 用 _valueTracker 追踪值变化,不手动同步它,React 以为值没变不触发更新 if (el._valueTracker) el._valueTracker.setValue(old); } // 派发 focus/input/change 事件,让 Vue/React 监听到这次写入 el.dispatchEvent(new Event('focus', { bubbles: true })); el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); await sleep(INPUT_GAP); } /* ================================================================ * fillForm:批量回填:遍历所有字段,把 localStorage 里存的值写回去 * ================================================================ */ async function fillForm() { const raw = localStorage.getItem(pageKey); if (!raw) { showToast('⚠️无记忆数据', true); return; } const data = JSON.parse(raw); const fields = scanFields(); if (!fields.length) { showToast('⚠️未找到表单', true); return; } suppressSave = true; // 进入回填锁,期间 change 事件不触发保存 try { for (const f of fields) { if (data[f.key] !== undefined) await writeField(f, data[f.key]); } formFilled = true; showToast('✅回填完成'); } catch (err) { console.error('[表单记忆] 回填异常:', err); showToast('❌回填失败', true); formFilled = true; } finally { suppressSave = false; } } /* ================================================================ * 自动检测弹窗打开:AntD Modal/Drawer、ElementUI Dialog * 弹窗刚打开那一帧,延迟 STABLE_WAIT 后自动回填一次 * ================================================================ */ function isModalOpen() { return !!document.querySelector('.ant-modal, .ant-modal-root, .el-dialog, .el-dialog__wrapper, .ant-drawer'); } function tryAutoFill() { const modal = isModalOpen(); if (modal && !hadModal && !formFilled && localStorage.getItem(pageKey)) { setTimeout(fillForm, STABLE_WAIT); } if (!modal) formFilled = false; // 弹窗关了重置标记,下次打开重新填 hadModal = modal; } let observerTimer = null; const observer = new MutationObserver(() => { clearTimeout(observerTimer); observerTimer = setTimeout(tryAutoFill, 200); // DOM 变动后防抖 200ms 再检测 }); observer.observe(document.body, { childList: true, subtree: true }); // 页面刚加载时,如果首屏就有表单且为空,也自动回填一次 setTimeout(() => { hadModal = isModalOpen(); const raw = localStorage.getItem(pageKey); if (raw) { const fields = scanFields(); const empty = fields.some(f => { const t = (f.el.type || '').toLowerCase(); if (t === 'checkbox' || t === 'radio') return false; return readField(f) === ''; }); if (fields.length && empty) setTimeout(fillForm, STABLE_WAIT); } }, 600); // 清空当前页所有记忆(字段值 + 递增配置) function clearMemory() { localStorage.removeItem(pageKey); localStorage.removeItem(incKey); formFilled = false; showToast('🗑️已清空'); } /* ================================================================ * showToast:右上角黑色小气泡提示 * isError=true 显示红色,否则绿色 * ================================================================ */ function showToast(text, isError) { let toast = document.getElementById('fmToast'); if (!toast) { toast = document.createElement('div'); toast.id = 'fmToast'; toast.style.cssText = ` position:fixed; top:20px; right:20px; z-index:9999999; background:#1f2937; color:#fff; padding:6px 14px; border-radius:16px; font-size:12px; font-family:system-ui; box-shadow:0 2px 10px #0006; border:1px solid #4b5563; opacity:0; transition:opacity .3s; pointer-events:none; white-space:nowrap; `; document.body.appendChild(toast); } toast.textContent = text; toast.style.color = isError ? '#ff6b6b' : '#4ade80'; toast.style.opacity = '1'; clearTimeout(toast._timer); toast._timer = setTimeout(() => { toast.style.opacity = '0'; }, 1800); } /* ================================================================ * renderIncList:渲染"增减"面板里的字段表格 * 四列 grid 均分:字段名 | 已存值(可编辑) | 模式(0/+/-循环) | 步长 * ================================================================ */ function renderIncList() { const container = document.getElementById('fmIncList'); if (!container) return; const settings = getIncSettings(); const savedData = JSON.parse(localStorage.getItem(pageKey) || '{}'); container.innerHTML = ''; // 四列:minmax(最小px, 1fr) —— 最小宽度保底,剩余空间按比例均分 // 第一列字段名(最宽弹性)、第二列已存值、第三列模式按钮、第四列步长 const COLS = 'minmax(60px,1fr) minmax(50px,1fr) minmax(28px,1fr) minmax(42px,1fr)'; // 表头 const thead = document.createElement('div'); thead.style.cssText = ` display:grid; grid-template-columns:${COLS}; gap:4px; font-size:10px; color:#999; margin-bottom:4px; padding:0 4px; `; thead.innerHTML = ` 字段 已存值 模式 增减 `; container.appendChild(thead); const allFields = scanFields().filter(f => f.category === 'native'); let shown = 0; allFields.forEach((f) => { const label = getFieldLabel(f); if (!label) return; // 认不出标签的字段不显示 const key = f.key; const s = settings[key] || { mode: 0, step: 1 }; // 没配置就默认 mode=0, step=1 const savedVal = savedData[key] !== undefined ? savedData[key] : ''; const row = document.createElement('div'); row.style.cssText = ` display:grid; grid-template-columns:${COLS}; gap:4px; align-items:center; margin:2px 0; font-size:11px; padding:0 4px; `; // 第1列:字段名,过长省略号截断;min-width:0 是 ellipsis 生效的关键 const nameSpan = document.createElement('span'); nameSpan.style.cssText = ` overflow:hidden; text-overflow:ellipsis; white-space:nowrap; min-width:0; `; nameSpan.textContent = label; // 第2列:已存值(可直接改) const valInput = document.createElement('input'); valInput.type = 'text'; valInput.value = savedVal; valInput.style.cssText = ` width:100%; padding:2px; border:1px solid #555; border-radius:3px; background:#111; color:#93c5fd; font-size:11px; text-align:center; box-sizing:border-box; min-width:0; `; valInput.onchange = () => { const data = JSON.parse(localStorage.getItem(pageKey) || '{}'); if (valInput.value === '') delete data[key]; else data[key] = valInput.value; localStorage.setItem(pageKey, JSON.stringify(data)); showToast('✅已改'); }; // 第3列:模式按钮,循环切换 0(不变) → +(加步长) → -(减步长) const modeBtn = document.createElement('button'); modeBtn.style.cssText = ` width:100%; padding:2px 0; border:none; border-radius:3px; cursor:pointer; font-size:11px; color:#fff; box-sizing:border-box; min-width:0; `; const modes = [ { text: '0', bg: '#666' }, { text: '+', bg: '#10b981' }, { text: '-', bg: '#ef4444' } ]; modeBtn.textContent = modes[s.mode].text; modeBtn.style.background = modes[s.mode].bg; modeBtn.onclick = () => { const st = getIncSettings(); if (!st[key]) st[key] = { mode: 0, step: 1 }; st[key].mode = (st[key].mode + 1) % 3; // 0→1→2→0 循环 setIncSettings(st); modeBtn.textContent = modes[st[key].mode].text; modeBtn.style.background = modes[st[key].mode].bg; }; // 第4列:步长(数字) const stepInput = document.createElement('input'); stepInput.type = 'number'; stepInput.value = s.step; stepInput.style.cssText = ` width:100%; padding:2px; border:1px solid #555; border-radius:3px; background:#111; color:#fff; font-size:11px; text-align:center; box-sizing:border-box; min-width:0; `; stepInput.onchange = () => { const st = getIncSettings(); if (!st[key]) st[key] = { mode: 0, step: 1 }; st[key].step = parseFloat(stepInput.value) || 0; setIncSettings(st); }; row.appendChild(nameSpan); row.appendChild(valInput); row.appendChild(modeBtn); row.appendChild(stepInput); container.appendChild(row); shown++; }); if (shown === 0) { const empty = document.createElement('div'); empty.style.cssText = `color:#666;font-size:11px;text-align:center;padding:8px;`; empty.textContent = '(未识别到带标签的输入框)'; container.appendChild(empty); } } /* ================================================================ * clampPanelToViewport:把面板约束在视口内 * 拖拽/缩放/窗口变化时调用,防止面板跑出屏幕 * ================================================================ */ function clampPanelToViewport() { if (!panelRef) return; const margin = PANEL_MARGIN; const rect = panelRef.getBoundingClientRect(); // 高度不能超过视口高度减去上下边距 const maxAllowedHeight = window.innerHeight - margin * 2; if (rect.height > maxAllowedHeight) { panelRef.style.height = maxAllowedHeight + 'px'; } const r = panelRef.getBoundingClientRect(); let newLeft = r.left; let newTop = r.top; if (newLeft < margin) newLeft = margin; if (newLeft + r.width > window.innerWidth - margin) newLeft = window.innerWidth - margin - r.width; if (newTop < margin) newTop = margin; if (newTop + r.height > window.innerHeight - margin) newTop = window.innerHeight - margin - r.height; if (newLeft < margin) newLeft = margin; if (newTop < margin) newTop = margin; panelRef.style.left = newLeft + 'px'; panelRef.style.top = newTop + 'px'; panelRef.style.right = 'auto'; // 从 right 定位切到 left 定位,避免拖拽后错位 } /* ================================================================ * addPanel:创建右下角悬浮控制台 * 结构:外层 panel(可拖拽/可resize) → 顶部 header(按钮栏) + 下方 list(递增表) * ================================================================ */ function addPanel() { if (document.getElementById('fmPanel')) return; const panel = document.createElement('div'); panel.id = 'fmPanel'; // 关键 CSS: // width:fit-content 收起时由 header 内容决定宽度 // resize:both 右下角出现拖拽手柄,可手动拉大小 // overflow:hidden 内容超出不溢出 // flex column header 在上,list 在下 // min-width:180px 初始最小宽度(后面会被脚本量出的 header 实际宽度覆盖) panel.style.cssText = ` position:fixed; top:60px; right:10px; z-index:999999; background:#1f2937; color:#fff; border-radius:8px; border:1px solid #4b5563; box-shadow:0 2px 12px #0008; font-size:13px; font-family:system-ui; user-select:none; width:fit-content; height:auto; min-width:180px; min-height:36px; max-width:90vw; resize:both; overflow:hidden; display:flex; flex-direction:column; `; panel.innerHTML = `
↯ ▼增减
`; document.body.appendChild(panel); panelRef = panel; // 三个按钮的事件 document.getElementById('fmSave').addEventListener('click', saveAll); document.getElementById('fmFill').addEventListener('click', () => { formFilled = false; fillForm(); }); document.getElementById('fmClear').addEventListener('click', clearMemory); /* ================================================================ * 展开/收起逻辑(v12.9) * * ★v12.9 修复重点:宽度被某些页面撑大 * 之前的顺序是:display:block → renderIncList() → 再设 panel.width=headerW。 * 在 renderIncList() 把字段行塞进 list 的那一瞬间,panel 还是 * width:fit-content,浏览器会按 list 内容的"最大内容宽度"重新布局 * (label 长的页面尤其明显),把 panel 撑到最宽; * 虽然之后我们又设了 width=headerW,但部分 grid/input 的 * intrinsic 宽度会顶住,视觉上就宽了。 * * v12.9 改成:先把 panel.width 钉死成 header 宽度, * 再 display:block + render。list 从一开始就在固定宽度里排版, * fit-content 根本没机会被内容撑大。 * * 宽度策略: * 展开时宽度 = 收起时 header 自然宽度,不随 list 内容走; * 字段名过长在固定宽度内 ellipsis。 * * 高度策略: * 按 list 内容自然高度撑开,所有行直接铺开、无底部空白; * 内容超过视口 LIST_MAX_RATIO 才在 list 内部滚动。 * * 最小宽度保护: * minWidth 在面板创建时就锁成 header 自然宽度, * 展开/收起都不放开——否则收起时拖窄会裁掉按钮。 * * 最大高度: * 展开时锁到内容高度,往上拖不会出底部空白;收起时取消。 * ================================================================ */ document.getElementById('fmIncToggle').onclick = function() { const list = document.getElementById('fmIncList'); const header = document.getElementById('fmHeader'); if (list.style.display === 'none') { // —— 展开 —— // 此时 list 还是 display:none,panel 是 fit-content, // offsetWidth 就是 header 一排按钮的自然宽度。先记下。 const headerW = panel.offsetWidth; const headerH = header.offsetHeight; // ★关键修复:先钉死宽度,再显示列表。 // 必须在 list.display='block' 之前就把 panel.width 写死, // 否则 renderIncList() 塞内容时 panel 还是 fit-content, // 会被长 label 的内容瞬间撑宽。 panel.style.width = headerW + 'px'; // 现在 list 才可见、才渲染——它从一开始就活在 headerW 这个固定宽度里 list.style.display = 'block'; list.style.maxHeight = 'none'; renderIncList(); listExpanded = true; this.textContent = '▲增减'; // 双保险:渲染完再读一次宽度,如果被内容偷偷顶大了,强制压回 headerW // (某些 input 的 intrinsic size 可能撑一帧,这里兜住) if (panel.offsetWidth !== headerW) { panel.style.width = headerW + 'px'; } // 量 list 在固定宽度下的自然高度(表头+所有行) const naturalListH = list.scrollHeight; const viewportListH = window.innerHeight * LIST_MAX_RATIO; const targetListH = Math.min(naturalListH, viewportListH); // 高度钉死:刚好包裹内容,无底部空白 panel.style.height = (headerH + targetListH + 20) + 'px'; list.style.maxHeight = targetListH + 'px'; // 高度上限锁到内容高度,往上拖不会出空白 panel.style.maxHeight = (headerH + targetListH + 20) + 'px'; clampPanelToViewport(); } else { // —— 收起 —— list.style.display = 'none'; this.textContent = '▼增减'; listExpanded = false; // 宽度回 fit-content(由 header 内容决定) panel.style.height = 'auto'; panel.style.width = 'fit-content'; // 注意:minWidth 不在这里改回 180! // 创建时已经锁成 header 自然宽度,收起时保持这个值, // 否则拖窄面板会把"清空/▼增减"按钮裁掉。 // maxHeight 取消,收起状态不限制高度 panel.style.maxHeight = 'none'; list.style.maxHeight = 'none'; } }; // —— header 拖拽移动面板 —— const header = document.getElementById('fmHeader'); let pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0; header.onmousedown = function(e) { // 点在按钮/输入框上不触发拖拽 if (e.target.tagName === 'BUTTON' || e.target.tagName === 'INPUT') return; e.preventDefault(); pos3 = e.clientX; pos4 = e.clientY; document.onmouseup = closeDrag; document.onmousemove = doDrag; }; function doDrag(e) { e.preventDefault(); pos1 = pos3 - e.clientX; pos2 = pos4 - e.clientY; pos3 = e.clientX; pos4 = e.clientY; panel.style.top = (panel.offsetTop - pos2) + 'px'; panel.style.left = (panel.offsetLeft - pos1) + 'px'; panel.style.right = 'auto'; clampPanelToViewport(); } function closeDrag() { document.onmouseup = null; document.onmousemove = null; } // —— 用户手动 resize 面板时,同步 list 高度 —— // 拖小面板高度后,list.maxHeight 跟着变小,list 内部出现滚动条 let resizeCheckTimer = null; new ResizeObserver(() => { clearTimeout(resizeCheckTimer); resizeCheckTimer = setTimeout(() => { if (listExpanded) { const headerH = document.getElementById('fmHeader').offsetHeight; const maxListH = Math.max(40, panel.offsetHeight - headerH - 20); const list = document.getElementById('fmIncList'); if (list) list.style.maxHeight = maxListH + 'px'; } clampPanelToViewport(); }, 100); }).observe(panel); window.addEventListener('resize', clampPanelToViewport); /* ================================================================ * 初始 minWidth 锁定(关键): * 面板刚插入 DOM 100ms 后,此时是 fit-content 状态, * panel.offsetWidth 就是 header 一排按钮的自然宽度。 * 把 min-width 锁成这个值——无论展开还是收起, * 用户拖 resize 手柄往左拖都拖不过这一档, * "清空"和"▲增减"永远不会被挤出可视区。 * ================================================================ */ setTimeout(() => { panel.style.minWidth = panel.offsetWidth + 'px'; clampPanelToViewport(); }, 100); } // 页面加载 800ms 后再插入面板,等页面主要 DOM 渲染完 setTimeout(addPanel, 800); })();