// ==UserScript== // @name EPC 车型目录导出 // @namespace https://scriptcat.org/users/epc-export // @version 10.6 // @description EPC 车型目录一键导出工具:自动展开全部节点、识别车辆信息、导出完整 HTML 清单 // @match https://sbom-epc.dflzm.com/* // @match http://sbom-epc.dflzm.com/* // @grant none // @run-at document-idle // @license MIT // ==/UserScript== (function () { 'use strict'; if (window.__epcV8) return; window.__epcV8 = true; // ★ 触发条件:仅在这些路由下启用脚本 // https://sbom-epc.dflzm.com/#/vehicleBom // https://sbom-epc.dflzm.com/#/model/bom/ const isTargetPage = () => { const h = location.hash || ''; return h.indexOf('#/vehicleBom') !== -1 || h.indexOf('#/model/bom') !== -1; }; const $ = (s, r) => (r || document).querySelector(s); const $$ = (s, r) => Array.from((r || document).querySelectorAll(s)); // 智能等待:页面后台时(标签切换走)跳过延迟,避免 setTimeout 被浏览器节流导致卡死 const wait = ms => new Promise(r => { if (document.hidden) { r(); return; } let done = false; const finish = () => { if (done) return; done = true; r(); }; const t1 = setTimeout(finish, ms); // 同时用 requestAnimationFrame 作为在后台也能较快响应的兜底通道 const check = () => { if (done) return; if (document.hidden) { clearTimeout(t1); finish(); return; } requestAnimationFrame(check); }; requestAnimationFrame(check); }); const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const fireClick = (el) => { el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); el.dispatchEvent(new MouseEvent('click', { bubbles: true })); }; // ★ 智能点击:用 MutationObserver 监听子节点渲染完成,替代固定 wait(300) // 渲染完成立即继续,无渲染变化走超时兜底。页面后台时立即点击不等(避免被节流) const fireClickAndWait = (el, timeout = 300) => { if (document.hidden) { fireClick(el); return Promise.resolve(); } const parent = el.closest('[class*="tree-node"], [class*="node"]') || el.parentElement; return new Promise(resolve => { if (!parent || typeof MutationObserver === 'undefined') { fireClick(el); setTimeout(resolve, timeout); return; } let done = false; const finish = () => { if (done) return; done = true; observer.disconnect(); clearTimeout(timer); resolve(); }; const observer = new MutationObserver(finish); observer.observe(parent, { childList: true, subtree: true }); fireClick(el); const timer = setTimeout(finish, timeout); }); }; // ★ 等待整棵树渲染稳定:批量点击后用,150ms 内无 DOM 变化则认为渲染完成 // 比逐个节点等待快 10-20 倍(30 个节点:原来 30×300ms=9s,现在 ≈300-500ms) const waitTreeStable = (timeout = 1500) => new Promise(resolve => { if (document.hidden || typeof MutationObserver === 'undefined') { resolve(); return; } const tree = $('.el-tree, [class*="tree-container"], [class*="sidebar"] [class*="tree"], [class*="menu"]') || document.body; let done = false; let stableTimer = null; const finish = () => { if (done) return; done = true; observer.disconnect(); clearTimeout(stableTimer); clearTimeout(maxTimer); resolve(); }; const observer = new MutationObserver(() => { clearTimeout(stableTimer); stableTimer = setTimeout(finish, 150); // 150ms 内无新变化 = 稳定 }); observer.observe(tree, { childList: true, subtree: true }); const maxTimer = setTimeout(finish, timeout); // 兜底超时 stableTimer = setTimeout(finish, 150); }); // ★ 搜索框快速展开:在搜索框输入"0",EPC 会自动展开所有包含"0"的节点(约99%) // 比逐个点击快几十倍。搜索后清空,再用批量点击兜底处理剩余1% const expandBySearch = async (t) => { // 在侧边栏/菜单区域找搜索框 const sidebar = $('.sidebar, [class*="sidebar"], [class*="menu-wrapper"], [class*="left-panel"], [class*="tree-container"], [class*="catalog"], [class*="catalogue"]') || document; let searchInput = $('input[placeholder*="名称"], input[placeholder*="搜索"], input[placeholder*="关键字"], input[placeholder*="过滤"], input[placeholder*="查找"]', sidebar); if (!searchInput) { // 退而求其次:侧边栏内任意可见 input const inputs = $$('input[type="text"], input:not([type])', sidebar); for (const inp of inputs) { if (inp.offsetParent !== null) { searchInput = inp; break; } } } if (!searchInput) { t.setText('📂 未找到搜索框,改用批量点击...'); return 0; } // 用原生 setter 设值(Vue/React 兼容,触发 v-model 更新) const nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; if (!nativeSetter) return 0; // ★ 设值工具:多种事件确保 Vue 响应 const setInputVal = (input, val) => { nativeSetter.call(input, val); input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); input.dispatchEvent(new Event('blur', { bubbles: true })); }; // 1. 搜索"0" t.setText('📂 搜索"0"触发自动展开...'); searchInput.focus(); setInputVal(searchInput, '0'); // 等待搜索结果渲染稳定 await waitTreeStable(3000); let expandedAfterSearch = $$('.el-tree-node__expand-icon.expanded, .el-tree-node__expand-icon.is-expanded, [class*="tree-node"].expanded, [class*="tree-node"].is-expanded').length; t.setText(`📂 搜索展开 ${expandedAfterSearch} 个,正在清空搜索恢复完整树...`); // 2. 清空搜索框,恢复完整树(★ 关键:必须清空,否则会屏蔽不含"0"的编码) // 多种方式确保清空成功 const clearSearch = async () => { // 方式1:原生 setter + 事件 setInputVal(searchInput, ''); await wait(100); // 验证是否清空成功 if (searchInput.value !== '' && searchInput.value !== '0') { // 已经被改成别的值,不处理 return true; } if (searchInput.value === '') return true; // 方式2:模拟键盘逐个删除(Backspace) searchInput.focus(); searchInput.select(); document.execCommand('delete'); await wait(100); if (searchInput.value === '') return true; // 方式3:直接操作 Vue 实例(如果有) try { const vueInst = searchInput.__vue__ || (searchInput.parentElement && searchInput.parentElement.__vue__); if (vueInst && vueInst.$parent) { vueInst.$parent.$emit('input', ''); vueInst.$parent.currentValue = ''; } } catch (e) {} return searchInput.value === ''; }; await clearSearch(); await waitTreeStable(3000); // ★ 二次验证:如果还没清空,再试一次 if (searchInput.value !== '') { t.setText('📂 首次清空未成功,重试中...'); searchInput.value = ''; searchInput.dispatchEvent(new Event('input', { bubbles: true })); await wait(500); } let expandedAfterClear = $$('.el-tree-node__expand-icon.expanded, .el-tree-node__expand-icon.is-expanded, [class*="tree-node"].expanded, [class*="tree-node"].is-expanded').length; // 取较大的值作为已展开数(清空后可能保持或折叠,取最大值更准确) return Math.max(expandedAfterSearch, expandedAfterClear); }; // ★ 功能1:展开树形菜单(搜索"0"快速展开 + 批量点击兜底) const expandAllMenus = async () => { const t = toast('📂 正在展开菜单...', '#409eff'); let count = 0; const clicked = new Set(); // 记录已点击的节点,避免重复点击 const startTime = Date.now(); // ★ 第一步:用搜索框搜索"0"快速展开(约99%节点,几秒内完成) const searchExpanded = await expandBySearch(t); count = searchExpanded; // 估算总数(搜索后重新统计) let estTotal = 0; const allExpandIcons = $$('.el-tree-node__expand-icon, [class*="tree-node__expand-icon"], [class*="tree-expand"], [class*="tree-toggle"], [class*="node-expand"], [class*="node-toggle"]'); estTotal = allExpandIcons.length; estTotal = Math.max(estTotal, count, 1); const updateToast = () => { const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); const remaining = Math.max(0, estTotal - count); const eta = count > 0 ? Math.ceil(remaining * ((Date.now() - startTime) / 1000 / count)) : '?'; t.setText(`📂 展开中:已展开 ${count} / 预估 ${estTotal} 剩余 ${remaining} 用时 ${elapsed}s 预计 ${eta}s`); }; updateToast(); let lastTick = Date.now(); // ★ 批量并行点击:每轮收集所有折叠节点,一次性全部点击,再统一等树渲染稳定 // 比逐个点击+等待快 10-20 倍(30 个节点:原来 30×300ms=9s,现在 ≈300-500ms) let round = 0, noFoundStreak = 0; while (round < 12 && noFoundStreak < 2) { round++; const toClick = []; // 本轮待点击的元素 // 收集所有可能的展开图标 const allIcons = []; $$('.el-tree-node__expand-icon').forEach(el => allIcons.push(el)); const nodeSelectors = [ '[class*="tree-node__expand-icon"]', '[class*="tree-node"] [class*="expand-icon"]', '[class*="tree-node"] [class*="toggle-icon"]', '[class*="tree-expand"]', '[class*="tree-toggle"]', '[class*="node-expand"]', '[class*="node-toggle"]', '[class*="tree"] [class*="arrow-right"]', '[class*="tree"] [class*="caret-right"]', '[class*="tree"] [class*="chevron-right"]', '[class*="node"] [class*="icon-arrow"]', '[class*="node"] [class*="icon-caret"]', '[class*="node"] [class*="icon-chevron"]' ]; for (const sel of nodeSelectors) { try { $$(sel).forEach(el => allIcons.push(el)); } catch (e) {} } // 去重 + 筛选折叠状态的图标节点 const uniqueIcons = new Set(allIcons); for (const el of uniqueIcons) { if (clicked.has(el)) continue; if (el.classList && el.classList.contains('is-leaf')) continue; const parentNode = el.closest('[class*="tree-node"], [class*="node"]'); if (el.classList && (el.classList.contains('expanded') || el.classList.contains('is-expanded'))) continue; if (parentNode && parentNode.classList && (parentNode.classList.contains('expanded') || parentNode.classList.contains('is-expanded'))) continue; toClick.push(el); clicked.add(el); } // 收集无图标但子节点被隐藏的节点 const treeNodes = $$('[class*="tree-node"], [class*="treenode"]'); for (const node of treeNodes) { if (clicked.has(node)) continue; if (node.classList && (node.classList.contains('expanded') || node.classList.contains('is-expanded') || node.classList.contains('is-leaf'))) continue; const hiddenChild = Array.from(node.children).some(child => { return child.offsetParent === null && child.style.position !== 'fixed'; }); if (hiddenChild) { const clickArea = node.querySelector('[class*="title"], [class*="label"], [class*="content"], [class*="text"]') || node; toClick.push(clickArea); clicked.add(node); } } // ★ 批量点击:一次性触发所有点击事件,不等待 for (const el of toClick) { fireClick(el); count++; } if (toClick.length === 0) { noFoundStreak++; } else { noFoundStreak = 0; if (Date.now() - lastTick > 250) { updateToast(); lastTick = Date.now(); } // 统一等待整棵树渲染稳定(150ms 内无 DOM 变化即继续) await waitTreeStable(1500); updateToast(); lastTick = Date.now(); } } // 兜底:强制显示所有树内的隐藏子节点 const hiddenSubs = $$('[class*="tree"] [class*="tree-node"] [style*="display: none"], [class*="tree"] [class*="tree-node"] [style*="display:none"]'); let forced = 0; hiddenSubs.forEach(el => { el.style.display = ''; forced++; }); if (forced > 0) count += forced; // ★ 最终兜底:确保搜索框已清空,避免屏蔽不含"0"的编码 const sidebar = $('.sidebar, [class*="sidebar"], [class*="menu-wrapper"], [class*="left-panel"], [class*="tree-container"], [class*="catalog"], [class*="catalogue"]') || document; const finalInputs = $$('input[type="text"], input:not([type])', sidebar); finalInputs.forEach(inp => { if (inp.value === '0') { const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; if (setter) setter.call(inp, ''); inp.value = ''; inp.dispatchEvent(new Event('input', { bubbles: true })); inp.dispatchEvent(new Event('change', { bubbles: true })); } }); t.remove(); toast(`✅ 已展开 ${count} 个节点,用时 ${((Date.now() - startTime) / 1000).toFixed(1)}s`, '#67c23a', 3000); }; // ★ 功能2:导出当前页(带进度/剩余时间提示) const doExport = () => { const startTime = Date.now(); const steps = ['写入表单值', '克隆DOM', '清理内容', '收集CSS', '识别车辆信息', '拼接HTML', '生成下载']; const total = steps.length; const toastEl = toast(`⏳ 导出中 1/${total} 步骤:${steps[0]} 剩余 ${total - 1} 步 预计 ...s`, '#409eff'); let stepIdx = 0; const step = (i) => { stepIdx = i; const elapsed = (Date.now() - startTime) / 1000; const eta = stepIdx > 0 ? Math.max(0, Math.ceil(((total - stepIdx) * elapsed) / stepIdx)) : '?'; toastEl.setText(`⏳ 导出中 ${stepIdx + 1}/${total} 步骤:${steps[stepIdx]} 剩余 ${total - stepIdx - 1} 步 已用 ${elapsed.toFixed(1)}s 预计 ${eta}s`); }; try { // 1. 写入表单值 fixFormValues(document); step(0); // 2. 克隆 DOM const main = $('.el-main, .app-main, .app-container, main, section') || document.body; const clone = main.cloneNode(true); const ourBtn = clone.querySelector('#__epc_oneclick_btn'); if (ourBtn) ourBtn.remove(); step(1); // 3. 清理内容 fixFormValues(clone); cleanTables(clone); cleanExport(clone); fixTreeArrows(clone); step(2); // 4. 收集 CSS const css = getCSS(); step(3); // 5. 识别车辆信息 const vehInfo = findVehicleInfo(); const now = new Date().toLocaleString(); step(4); // 6. 拼接 HTML const html = ` 车型BOM清单${vehInfo.full ? '-' + esc(vehInfo.full) : ''}

📦 车型BOM清单${vehInfo.full ? ' 【' + esc(vehInfo.full) + '】' : ''}

导出时间:${now}
${clone.innerHTML}
`; step(5); // 7. 生成下载 const blob = new Blob([html], { type: 'text/html;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `车型BOM清单【${vehInfo.full}】.html`; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(url), 2000); step(6); toastEl.remove(); const totalSec = ((Date.now() - startTime) / 1000).toFixed(1); toast(`✅ 导出完成!共 ${total} 步,用时 ${totalSec}s`, '#67c23a', 2000); } catch (e) { toastEl.remove(); toast('❌ ' + e.message, '#f56c6c', 4000); console.error(e); } }; // === 工具函数 === const fixFormValues = (root) => { try { $$('input', root).forEach(inp => { if (inp.value) inp.setAttribute('value', inp.value); }); $$('textarea', root).forEach(ta => { if (ta.value) ta.textContent = ta.value; }); $$('select', root).forEach(sel => { const val = sel.value; $$('option', sel).forEach(o => { if (o.value === val) o.setAttribute('selected', 'selected'); else o.removeAttribute('selected'); }); }); } catch (e) {} }; const getCSS = () => { let css = ''; let count = 0; try { for (const ss of document.styleSheets) { if (count++ > 3) break; try { for (const r of ss.cssRules) css += r.cssText + '\n'; } catch (e) {} } } catch (e) {} return css; }; const findVehicleInfo = () => { // 返回 { code, name, full } // full = "代码 - 名称" 用于文件名 let code = ''; let name = ''; // ★ 1. 优先从树形结构顶级节点获取车辆信息(最准确) // 顶级节点格式:"CMxxxx - 2022款 纯电动 CVT自动客运版-标准型" // 比从页面文本猜测可靠得多,避免把零件名当成车型名 const treeEl = $('.el-tree, [class*="tree-container"], [class*="tree-wrapper"]'); if (treeEl) { // 获取直接子节点(顶级节点) const topNodes = Array.from(treeEl.children).filter(el => el.classList && (el.classList.contains('el-tree-node') || el.classList.contains('tree-node')) ); for (const node of topNodes) { // 只取节点自身的 label 文本(不含子节点内容) const label = node.querySelector('.el-tree-node__content, .el-tree-node__label, [class*="node-label"], [class*="node-content"]'); const text = (label ? label.innerText : node.innerText).trim(); // 匹配 "CMxxxx - 车型名称" 格式:代码字母开头10-20位,名称含中文 const m = text.match(/^([A-Z][A-Z0-9]{10,20})\s*[-–—]\s*(.+)$/); if (m) { const c = m[1]; const n = m[2].trim(); // 验证:名称必须含中文且长度合理(排除模块名等) if (/[\u4e00-\u9fa5]/.test(n) && n.length > 3 && n.length < 100) { code = c; name = n; break; } } } } // 2. 从 URL hash 找代码(兜底) if (!code) { const hash = location.hash || ''; let m = hash.match(/vin[=/]([A-Za-z0-9]{6,20})/i); if (m) code = m[1]; m = hash.match(/(?:model|bom)[=/]([A-Za-z0-9]{6,20})/i); if (m) code = m[1]; } // 3. 从页面表格第一行找代码和名称 if (!code || !name) { const tables = $$('table'); for (const table of tables) { const firstRow = $('tbody tr', table); if (firstRow) { const cells = $$('td', firstRow); for (const cell of cells) { const text = (cell.innerText || '').trim(); // 匹配车型代码:字母开头的长代码 if (!code) { const codeMatch = text.match(/^[A-Z][A-Z0-9]{10,20}$/); if (codeMatch) code = codeMatch[0]; } // 匹配车型描述:含中文 + 可能含数字/字母 if (!name && /[\u4e00-\u9fa5]/.test(text) && text.length > 3 && text.length < 100) { name = text; } } } } } // 4. 从页面文本找 const txt = document.body.innerText; if (!code) { m = txt.match(/L[A-HJ-NPR-Z0-9]{16}/); if (m) code = m[0]; // 也尝试匹配 CM 开头的代码 m = txt.match(/CM[A-Z0-9]{10,18}/); if (m) code = m[0]; } // 5. 从描述列表/表单找车型名称 if (!name) { // 查找包含"款"字的文本(如 2022款) m = txt.match(/(\d{4}款[\u4e00-\u9fa5\w\s]+(?:座|型|版))/); if (m) name = m[1]; // 查找包含"纯电动/混动/燃油"的文本 if (!name) { m = txt.match(/(纯电动|混动|燃油|汽油|柴油)[\u4e00-\u9fa5\w\s]*/); if (m) name = m[0].substring(0, 50); } } const full = code && name ? `${code} - ${name}` : (code || name || 'export'); return { code, name, full }; }; const cleanTables = (root) => { try { $$('table', root).forEach(t => { const rows = $$('tbody tr', t).filter(tr => $$('td', tr).some(td => td.innerText.trim().length > 0)); const text = t.innerText || ''; if (rows.length === 0 || (text.includes('暂无数据') && rows.length === 0)) t.remove(); }); $$('.el-table__body-wrapper, .el-table__footer-wrapper', root).forEach(w => { if (!w.innerText.trim()) w.remove(); }); $$('.el-card', root).forEach(c => { const b = $('.el-card__body', c); if (b && !b.innerText.trim()) c.remove(); }); } catch (e) {} }; // 移除导出中不需要的元素(搜索栏、操作按钮等) const cleanExport = (root) => { try { // 1. 移除搜索栏(包含"VIN码"输入框和"查询"按钮的区域) const searchBar = root.querySelector('.search-bar, .filter-bar, [class*="search-bar"], [class*="filter-bar"]'); if (searchBar) searchBar.remove(); // 2. 移除包含"查询"、"重置"、"搜索"按钮的容器 $$('.el-form, .el-form-item, [class*="filter"], [class*="search"]', root).forEach(el => { const text = (el.innerText || '').trim(); if ((text.includes('查询') || text.includes('重置') || text.includes('搜索')) && text.length < 50) { // 只移除短的搜索表单,不移除车辆信息表单 const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : { width: 0 }; if (rect.width < 600 || el.tagName === 'FORM') { // 检查是否包含车辆信息关键词 const hasVehicleInfo = /VIN|车架|发动机|底盘|电机|电池|车型编码/.test(text); if (!hasVehicleInfo) el.remove(); } } }); // 3. 移除"装车物料清单"等操作按钮区 $$('.el-button, button, [role="button"]', root).forEach(btn => { const text = (btn.innerText || btn.textContent || '').trim(); if (/装车物料清单|导出|下载|新增|编辑|删除|操作/.test(text) && text.length < 20) { // 检查按钮所在容器,如果是操作栏就移除整个容器 const parent = btn.closest('.el-form-item, .el-card, .el-col, [class*="toolbar"], [class*="action"], [class*="operation"]'); if (parent) { const allBtns = $$('.el-button, button', parent); const hasTable = parent.querySelector('table'); if (!hasTable && allBtns.length >= 1) { // 只包含按钮的工具栏,移除 if (parent.innerText.length < 100) parent.remove(); else btn.remove(); } } } }); // 4. 移除"操作"列(表格最后一列的操作按钮) $$('table', root).forEach(table => { const headers = $$('thead th', table); const colIndex = headers.findIndex(th => { const txt = (th.innerText || '').trim(); return /操作|序号/.test(txt); }); if (colIndex >= 0) { // 移除该列的所有单元格 $$('thead th:nth-child(' + (colIndex + 1) + ')', table).forEach(th => th.remove()); $$('tbody tr', table).forEach(tr => { const cells = $$('td', tr); if (cells[colIndex]) cells[colIndex].remove(); }); } }); // 5. 移除我们自己注入的按钮 ['__epc_container', '__epc_epc_btn', '__epc_capsule_bar', '__epc_expand_btn', '__epc_export_btn'].forEach(id => { const el = root.querySelector('#' + id); if (el) el.remove(); }); } catch (e) {} }; // 把树形箭头从向右(▶)改成向下(▼),表示已完全展开 const fixTreeArrows = (root) => { try { // 1. 修改 Element UI Tree 的展开图标 class // 向右 = 折叠状态,向下 = 展开状态 $$('.el-tree-node__expand-icon', root).forEach(el => { // 移除折叠状态,添加展开状态 el.classList.remove('expanded'); el.classList.add('expanded'); // 移除 is-leaf(如果不是叶子节点) // 强制旋转 90 度(向右→向下) el.style.transform = 'rotate(90deg)'; }); // 2. 处理其他树形组件的箭头 const arrowSelectors = [ '[class*="tree-node__expand-icon"]', '[class*="tree-node"] [class*="expand-icon"]', '[class*="tree-node"] [class*="toggle-icon"]', '[class*="tree-expand"]', '[class*="tree-toggle"]', '[class*="node-expand"]', '[class*="node-toggle"]', '[class*="tree"] [class*="arrow-right"]', '[class*="tree"] [class*="caret-right"]', '[class*="tree"] [class*="chevron-right"]', '[class*="node"] [class*="icon-arrow"]', '[class*="node"] [class*="icon-caret"]', '[class*="node"] [class*="icon-chevron"]' ]; for (const sel of arrowSelectors) { try { $$(sel, root).forEach(el => { // 强制旋转 90 度表示展开 el.style.transform = 'rotate(90deg)'; // 添加 expanded class el.classList.add('expanded'); el.classList.remove('collapsed'); el.classList.remove('is-collapsed'); }); } catch (e) {} } // 3. 处理包含 ▶ 字符的文本节点(用 CSS 伪元素的情况) // 替换 ::before 伪元素内容 const style = document.createElement('style'); style.textContent = ` /* 强制所有树箭头向下 */ .el-tree-node__expand-icon, [class*="tree-node"] [class*="expand-icon"], [class*="tree-node"] [class*="toggle-icon"], [class*="tree"] [class*="arrow-right"], [class*="tree"] [class*="caret-right"], [class*="tree"] [class*="chevron-right"], [class*="node"] [class*="icon-arrow"], [class*="node"] [class*="icon-caret"] { transform: rotate(90deg) !important; } /* 树节点展开状态样式 */ .el-tree-node.expanded > .el-tree-node__children, .el-tree-node.is-expanded > .el-tree-node__children { display: block !important; } `; root.appendChild(style); // 4. 确保所有树的子节点都显示出来 $$('.el-tree-node__children, [class*="tree-children"], [class*="node-children"], [class*="submenu"], [class*="sub-menu"]', root).forEach(el => { el.style.display = 'block'; el.style.overflow = 'visible'; }); // 5. 确保所有树节点都是展开状态 $$('.el-tree-node, [class*="tree-node"], [class*="treenode"], [class*="node"]', root).forEach(node => { node.classList.add('expanded'); node.classList.add('is-expanded'); node.classList.remove('collapsed'); node.classList.remove('is-collapsed'); }); } catch (e) {} }; const toast = (msg, color, duration) => { const t = document.createElement('div'); t.textContent = msg; t.style.cssText = 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:' + (color || '#409eff') + ';color:#fff;padding:16px 32px;border-radius:8px;z-index:2147483647;font-size:14px;box-shadow:0 4px 20px rgba(0,0,0,.2);font-family:Microsoft YaHei,Arial;white-space:nowrap;'; t.setText = (s) => { t.textContent = s; }; document.body.appendChild(t); if (duration) setTimeout(() => { if (t.parentNode) t.remove(); }, duration); return t; }; // ★ 注入按钮(悬浮展开 + 可拖动) const injectBtns = () => { if (!isTargetPage()) return; // 仅在目标路由注入 if ($('#__epc_container')) return; if (!document.body) { setTimeout(injectBtns, 300); return; } let isDragging = false; let hasMoved = false; let startX = 0, startY = 0, startLeft = 0, startTop = 0; let hideTimer = null; // === 主容器(可拖动,padding 即透明悬浮区域) === const container = document.createElement('div'); container.id = '__epc_container'; container.style.cssText = 'position:fixed;top:180px;right:24px;z-index:2147483647;padding:12px;user-select:none;font-family:Microsoft YaHei,Arial;'; // === EPC 小按钮(默认显示,绿色小胶囊) === const epcBtn = document.createElement('div'); epcBtn.id = '__epc_epc_btn'; epcBtn.textContent = 'EPC'; epcBtn.style.cssText = 'width:36px;height:17px;background:linear-gradient(135deg,#67c23a,#4e9e2e);color:#fff;border-radius:9px;font-size:10px;font-weight:bold;cursor:grab;display:flex;align-items:center;justify-content:center;box-shadow:0 4px 14px rgba(103,194,58,.45);transition:transform .2s,box-shadow .2s;letter-spacing:1px;'; // === 胶囊栏(默认隐藏) === const capsuleBar = document.createElement('div'); capsuleBar.id = '__epc_capsule_bar'; capsuleBar.style.cssText = 'display:none;flex-direction:column;gap:14px;align-items:center;'; // 展开胶囊(蓝色) const expandBtn = document.createElement('div'); expandBtn.id = '__epc_expand_btn'; expandBtn.textContent = ' 展开菜单'; expandBtn.style.cssText = 'background:linear-gradient(135deg,#409eff,#1e88e5);color:#fff;padding:10px 16px;border-radius:24px;font-size:13px;font-weight:bold;cursor:pointer;box-shadow:0 4px 14px rgba(64,158,255,.45);white-space:nowrap;transition:transform .15s,box-shadow .15s;'; // 导出胶囊(红色) const exportBtn = document.createElement('div'); exportBtn.id = '__epc_export_btn'; exportBtn.textContent = '⬇ 导出当前页'; exportBtn.style.cssText = 'background:linear-gradient(135deg,#f56c6c,#e53935);color:#fff;padding:10px 16px;border-radius:24px;font-size:13px;font-weight:bold;cursor:pointer;box-shadow:0 4px 14px rgba(245,108,108,.45);white-space:nowrap;transition:transform .15s,box-shadow .15s;'; capsuleBar.appendChild(expandBtn); capsuleBar.appendChild(exportBtn); container.appendChild(epcBtn); container.appendChild(capsuleBar); document.body.appendChild(container); // === 悬浮展开 / 移开收起 === const showCapsules = () => { if (isDragging) return; if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; } epcBtn.style.display = 'none'; capsuleBar.style.display = 'flex'; }; const showEpcButton = () => { if (isDragging) return; hideTimer = setTimeout(() => { capsuleBar.style.display = 'none'; epcBtn.style.display = 'flex'; hideTimer = null; }, 150); }; container.addEventListener('mouseenter', showCapsules); container.addEventListener('mouseleave', showEpcButton); // === 按钮悬浮动效 === const addHover = (btn, shadow) => { btn.addEventListener('mouseenter', () => { btn.style.transform = 'translateY(-2px) scale(1.04)'; btn.style.boxShadow = '0 6px 20px ' + shadow; }); btn.addEventListener('mouseleave', () => { btn.style.transform = ''; btn.style.boxShadow = ''; }); }; addHover(epcBtn, 'rgba(103,194,58,.55)'); addHover(expandBtn, 'rgba(64,158,255,.6)'); addHover(exportBtn, 'rgba(245,108,108,.6)'); // === 点击事件(拖动后不触发) === expandBtn.addEventListener('click', () => { if (!hasMoved) expandAllMenus(); }); exportBtn.addEventListener('click', () => { if (!hasMoved) doExport(); }); // === 拖动功能(全局监听 mousemove/mouseup,流畅不卡顿) === container.addEventListener('mousedown', (e) => { isDragging = true; hasMoved = false; startX = e.clientX; startY = e.clientY; const rect = container.getBoundingClientRect(); startLeft = rect.left; startTop = rect.top; }); document.addEventListener('mousemove', (e) => { if (!isDragging) return; const dx = e.clientX - startX; const dy = e.clientY - startY; if (Math.abs(dx) > 4 || Math.abs(dy) > 4) { hasMoved = true; const w = container.offsetWidth; let newX = startLeft + dx; let newY = startTop + dy; // 边界保护:至少保留 50px 可见 newX = Math.max(-w + 50, Math.min(window.innerWidth - 50, newX)); newY = Math.max(0, Math.min(window.innerHeight - 50, newY)); container.style.left = newX + 'px'; container.style.top = newY + 'px'; container.style.right = 'auto'; container.style.bottom = 'auto'; } }); document.addEventListener('mouseup', () => { if (isDragging) { isDragging = false; setTimeout(() => { hasMoved = false; }, 50); } }); console.log('%c📂⬇ EPC v9 已加载:悬浮展开 + 可拖动', 'color:#67c23a;font-size:14px;font-weight:bold'); }; // ★ SPA 路由变化时按需注入 / 移除按钮 const syncUI = () => { if (isTargetPage()) { if (!$('#__epc_container') && document.body) injectBtns(); } else { const c = $('#__epc_container'); if (c) c.remove(); } }; if (document.body) injectBtns(); else window.addEventListener('load', injectBtns); setInterval(() => { if (isTargetPage() && !$('#__epc_container') && document.body) injectBtns(); }, 2000); window.addEventListener('hashchange', syncUI); })();