// ==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);
})();