// ==UserScript== // @name EPC 车型目录导出 // @namespace https://scriptcat.org/users/epc-export // @version 3.3 // @description EPC 车型目录一键导出工具:自动展开全部节点、识别车辆信息、导出完整 HTML 清单。支持弹窗内零件结构单独导出(v3.3 修复parsePartText连字符空格bug + 混合树构建) // @match https://sbom-epc.dflzm.com/* // @match http://sbom-epc.dflzm.com/* // @grant unsafeWindow // @run-at document-start // @license MIT // ==/UserScript== (function () { 'use strict'; const W = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; if (W.__epcV33) return; W.__epcV33 = true; // ============================================================ // ★ API 拦截层:使用 unsafeWindow 拦截页面的真实 fetch/XHR // ============================================================ W.__epcApiStore = []; const API_PATTERNS = [ /tree/i, /bom/i, /part/i, /struct/i, /detail/i, /catalog/i, /vehicle/i, /model/i, /list/i, /query/i, /material/i, /component/i, /assembly/i, /node/i, /child/i ]; const shouldCapture = (url) => { if (!url) return false; const u = url.toLowerCase(); return API_PATTERNS.some(p => p.test(u)); }; // 拦截 fetch(使用 unsafeWindow) try { const origFetch = W.fetch; if (origFetch) { W.fetch = async function (...args) { const res = await origFetch.apply(this, args); const url = typeof args[0] === 'string' ? args[0] : (args[0] && args[0].url) || ''; if (shouldCapture(url)) { try { const clone = res.clone(); const text = await clone.text(); if (text.length > 50) { W.__epcApiStore.push({ url: url.substring(0, 300), text: text.substring(0, 200000), len: text.length, time: Date.now() }); console.log('[EPC-API] fetch:', url.substring(0, 80), 'len:', text.length); } } catch (e) {} } return res; }; } } catch (e) { console.log('[EPC] fetch拦截失败:', e.message); } // 拦截 XHR(使用 unsafeWindow) try { const origOpen = W.XMLHttpRequest.prototype.open; W.XMLHttpRequest.prototype.open = function (method, url, ...rest) { this.addEventListener('load', function () { try { const respUrl = url || ''; if (shouldCapture(respUrl)) { const text = this.responseText || ''; if (text.length > 50) { W.__epcApiStore = W.__epcApiStore || []; W.__epcApiStore.push({ url: respUrl.substring(0, 300), text: text.substring(0, 200000), len: text.length, time: Date.now() }); console.log('[EPC-XHR]:', respUrl.substring(0, 80), 'len:', text.length); } } } catch (e) {} }); return origOpen.call(this, method, url, ...rest); }; } catch (e) { console.log('[EPC] XHR拦截失败:', e.message); } // ============================================================ // ★ 工具函数 // ============================================================ const $ = (s, r = document) => r.querySelector(s); const $$ = (s, r = document) => Array.from(r.querySelectorAll(s)); const wait = (ms) => new Promise(r => setTimeout(r, ms)); const toast = (msg, color = '#409eff', duration = 2000) => { const t = document.createElement('div'); t.textContent = msg; t.style.cssText = `position:fixed;top:20px;right:20px;background:${color};color:#fff;padding:12px 20px;border-radius:8px;z-index:99999;font-size:14px;box-shadow:0 4px 12px rgba(0,0,0,0.15);transition:opacity 0.3s;max-width:400px;`; document.body.appendChild(t); setTimeout(() => { t.style.opacity = '0'; }, duration - 300); setTimeout(() => { t.remove(); }, duration); }; // ============================================================ // ★ 检查弹窗状态 // ============================================================ const getActiveDialog = () => { const selectors = [ '.part-struct-dialog', '.el-dialog[style*="visibility: visible"]', '.el-dialog:not([style*="display: none"])', '.el-overlay-dialog .el-dialog', '.el-dialog__wrapper:not([style*="display: none"])', '.el-drawer:not([style*="display: none"])' ]; for (const sel of selectors) { const el = document.querySelector(sel); if (el && el.offsetHeight > 0) return el; } return null; }; // 检测当前是否在零件结构页面(通过URL hash判断) const isPartStructPage = () => { const hash = location.hash || ''; return /\/part|struct|detail/i.test(hash); }; // 检测当前是否在装车清单页面(通过URL和页面标题判断) const isPackingListPage = () => { const full = (location.href || '').toLowerCase(); const title = (document.title || '').toLowerCase(); return /装车清单|装车配置|装车|packing|loading.*list/i.test(full + title); }; // ============================================================ // ★ 从 API 数据中提取树结构(多API合并方案) // ============================================================ const extractTreeFromAPI = () => { const store = W.__epcApiStore || []; if (store.length === 0) return null; const allNodes = []; const nodeByCode = {}; for (const entry of store) { try { const data = JSON.parse(entry.text); const nodes = extractAllNodes(data); for (const n of nodes) { if (n.code) { nodeByCode[n.code] = n; allNodes.push(n); } } } catch (e) {} } if (allNodes.length === 0) return null; console.log('[EPC] 从', store.length, '个API中提取到', allNodes.length, '个节点'); // 方式1:用 parentCode/parentId 匹配 const roots1 = buildTreeByParentCode(allNodes); if (roots1.length > 0 && countNodes(roots1) > allNodes.length * 0.5) { console.log('[EPC] 通过parentCode构建树成功,根节点:', roots1.length, '总节点:', countNodes(roots1)); return { nodes: roots1, source: 'api-merged' }; } // 方式2:通过零件号层级编号推断父子关系 const roots2 = buildTreeByCodeHierarchy(allNodes); if (roots2.length > 0 && countNodes(roots2) > allNodes.length * 0.3) { console.log('[EPC] 通过code层级构建树成功,根节点:', roots2.length, '总节点:', countNodes(roots2)); return { nodes: roots2, source: 'api-hierarchy' }; } // 方式3:从第一个有children的响应获取主树 const mainTree = findMainTreeResponse(store); if (mainTree && mainTree.length > 0) { console.log('[EPC] 使用主树响应,根节点:', mainTree.length); return { nodes: mainTree, source: 'api-main' }; } // 方式4:扁平列表按层级分组 const flatTree = groupFlatByLevel(allNodes); if (flatTree.length > 0) { console.log('[EPC] 扁平列表按层级分组,根节点:', flatTree.length); return { nodes: flatTree, source: 'api-flat' }; } return { nodes: allNodes.slice(0, 50), source: 'api-fallback' }; }; const extractAllNodes = (data, depth = 0) => { if (depth > 6) return []; if (!data || typeof data !== 'object') return []; const results = []; if (Array.isArray(data)) { for (const item of data) { const nodes = extractAllNodes(item, depth + 1); results.push(...nodes); } } else if (typeof data === 'object') { const code = data.code || data.materialCode || data.partCode || data.materialNo || data.codeNo || data.partNo || ''; const name = data.name || data.materialName || data.partName || data.label || data.materialName || ''; const hasNodeFields = code || name; if (hasNodeFields) { const node = { code: String(code).replace(/\s*[-\-]+\s*$/, '').trim(), name: String(name).replace(/\s*[-\-]+\s*$/, '').trim(), qty: String(data.qty || data.quantity || data.count || data.num || data.amount || ''), spec: String(data.spec || data.specification || data.remark || ''), parentCode: data.parentCode || data.parentId || data.pid || data.parent || '', level: data.level || data.nodeLevel || 0, children: [] }; if (data.children && Array.isArray(data.children)) { const childNodes = extractAllNodes(data.children, depth + 1); node.children = childNodes; } results.push(node); } const keys = Object.keys(data); for (const k of keys) { if (k !== 'children' && k !== 'parentCode' && k !== 'parentId') { const val = data[k]; if (Array.isArray(val) && val.length > 0 && !hasNodeFields) { const subNodes = extractAllNodes(val, depth + 1); results.push(...subNodes); } else if (val && typeof val === 'object' && !hasNodeFields) { const subNodes = extractAllNodes(val, depth + 1); results.push(...subNodes); } } } } return results; }; const buildTreeByParentCode = (nodes) => { const codeMap = {}; nodes.forEach(n => { codeMap[n.code] = { ...n, children: [] }; }); const roots = []; nodes.forEach(n => { const node = codeMap[n.code]; const parentCode = n.parentCode; if (parentCode && codeMap[parentCode] && parentCode !== n.code) { codeMap[parentCode].children.push(node); } else { roots.push(node); } }); const cleanEmpty = (arr) => { arr.forEach(n => { if (n.children && n.children.length === 0) delete n.children; else if (n.children) cleanEmpty(n.children); }); }; cleanEmpty(roots); return roots; }; const buildTreeByCodeHierarchy = (nodes) => { const seenCodes = new Set(); const uniqueNodes = []; for (const n of nodes) { if (!seenCodes.has(n.code)) { seenCodes.add(n.code); uniqueNodes.push(n); } } const codeMap = {}; uniqueNodes.forEach(n => { codeMap[n.code] = { ...n, children: [] }; }); const roots = []; const assigned = new Set(); const sorted = [...uniqueNodes].sort((a, b) => a.code.length - b.code.length); sorted.forEach(node => { const code = node.code; let parentNode = null; const parts = code.split('-'); for (let i = parts.length - 1; i >= 1; i--) { const tryCode = parts.slice(0, i).join('-'); if (codeMap[tryCode]) { parentNode = codeMap[tryCode]; break; } } if (parentNode) { parentNode.children.push(codeMap[code]); assigned.add(code); } else { roots.push(codeMap[code]); } }); const unassigned = uniqueNodes.filter(n => !assigned.has(n.code) && !roots.some(r => r.code === n.code)); if (unassigned.length > 0 && roots.length > 0) { unassigned.forEach(node => { const code = node.code; const matchingRoot = roots.find(r => code.startsWith(r.code)); if (matchingRoot) { matchingRoot.children.push(codeMap[code]); } else { roots.push(codeMap[code]); } }); } const cleanEmpty = (arr) => { arr.forEach(n => { if (n.children && n.children.length === 0) delete n.children; else if (n.children) cleanEmpty(n.children); }); }; cleanEmpty(roots); return roots; }; const findMainTreeResponse = (store) => { for (let i = store.length - 1; i >= 0; i--) { const entry = store[i]; try { const data = JSON.parse(entry.text); const tree = findTreeInData(data); if (tree && tree.length > 0) { return tree; } } catch (e) {} } return null; }; const groupFlatByLevel = (nodes) => { const sorted = [...nodes].sort((a, b) => (a.level || 0) - (b.level || 0)); const roots = []; const stack = []; sorted.forEach(node => { node.children = []; const lvl = node.level || 0; while (stack.length > 0 && stack[stack.length - 1].level >= lvl) { stack.pop(); } if (stack.length === 0) { roots.push(node); } else { stack[stack.length - 1].node.children.push(node); } stack.push({ level: lvl, node }); }); const cleanEmpty = (arr) => { arr.forEach(n => { if (n.children && n.children.length === 0) delete n.children; else if (n.children) cleanEmpty(n.children); }); }; cleanEmpty(roots); return roots; }; const countNodes = (nodes) => { let c = 0; const count = (arr) => { arr.forEach(n => { c++; if (n.children) count(n.children); }); }; count(nodes); return c; }; const findTreeInData = (data, depth = 0) => { if (depth > 5) return null; if (!data || typeof data !== 'object') return null; if (Array.isArray(data) && data.length > 0) { const first = data[0]; if (first && first.children && Array.isArray(first.children)) { return normalizeTree(data); } if (first && (first.code || first.materialCode || first.name) && (first.children || first.hasChildren)) { return normalizeTree(data); } for (const item of data) { const result = findTreeInData(item, depth + 1); if (result && result.length > 0) return result; } } if (typeof data === 'object' && data !== null) { const keys = Object.keys(data); for (const k of keys) { if (k === 'data' || k === 'list' || k === 'result' || k === 'rows' || k === 'tree' || k === 'items') { const val = data[k]; if (Array.isArray(val) && val.length > 0) { const result = findTreeInData(val, depth + 1); if (result && result.length > 0) return result; } else if (val && typeof val === 'object') { const result = findTreeInData(val, depth + 1); if (result && result.length > 0) return result; } } } if (data.children && Array.isArray(data.children) && data.children.length > 0) { return normalizeTree([data]); } } return null; }; const normalizeTree = (nodes) => { const process = (arr) => { return arr.map(n => { const code = n.code || n.materialCode || n.partCode || n.materialNo || n.codeNo || ''; const name = n.name || n.materialName || n.partName || n.label || ''; const qty = n.qty || n.quantity || n.count || n.num || n.amount || ''; const spec = n.spec || n.specification || n.remark || ''; const children = n.children && Array.isArray(n.children) ? process(n.children) : []; return { label: name || code || '', code: String(code).replace(/\s*[-\-]+\s*$/, '').trim(), name: String(name).replace(/\s*[-\-]+\s*$/, '').trim(), spec: String(spec), qty: String(qty), children: children }; }); }; return process(nodes); }; // ============================================================ // ★★★ parsePartText — v3.3 核心修复:正确处理连字符带空格的零件号 // ============================================================ // 旧版 bug: "471Q - 0540010 - 钢球" 被解析为 code="471Q" (错误!) // 新版修复: code="471Q-0540010", name="钢球" const parsePartText = (text) => { const result = { code: '', name: '', qty: '', spec: '' }; // 1. 提取数量 (x1, ×2, x10 等) const qtyMatch = text.match(/[x×X]\s*(\d+)\s*$/); if (qtyMatch) { result.qty = qtyMatch[1]; text = text.substring(0, text.length - qtyMatch[0].length).trim(); } // 2. 提取规格 (括号内的内容,如 (8×9), (5/16), NPTF1/4 等) const specMatch = text.match(/[((]([^))]+)[))]/g); if (specMatch) { result.spec = specMatch.map(s => s.replace(/[((]|[))]/g, '')).join(' '); text = text.replace(/[((][^))]+[))]/g, '').trim(); } // ★ 3. 核心修复:正确匹配零件号(支持连字符周围有空格的情况) // 贪心匹配连续的字母数字段(用 - 分隔,连字符周围可能有空格) // 例如: "471Q - 0540010 - 钢球" → code="471Q-0540010", name="钢球" // 例如: "4J15T - 99-1029 - 气门和气门弹簧" → code="4J15T-99-1029", name="气门和气门弹簧" // 例如: "DAED122907 - 缸盖分总成" → code="DAED122907", name="缸盖分总成" // 例如: "C0102AA-1002 - 缸体系统" → code="C0102AA-1002", name="缸体系统" // 策略1: 字母数字段(空格-空格字母数字段)* 后跟 " - " 分隔符和名称 // 贪心匹配会尽可能多地匹配字母数字段作为 code 的一部分 const codeMatch = text.match(/^([A-Z0-9]+(?:\s*[-–—]\s*[A-Z0-9]+)*)\s*[-–—]\s+(.+)$/); if (codeMatch) { // 规范化 code:移除连字符周围的空格 result.code = codeMatch[1].replace(/\s*[-–—]\s*/g, '-').trim(); result.name = codeMatch[2].trim(); // 清理名称末尾多余的 " - " 后缀 result.name = result.name.replace(/\s*[-–—]\s*[A-Z]{1,8}$/, '').trim(); result.name = result.name.replace(/\s*[-–—]\s*$/, '').trim(); } // 策略2: CODE空格NAME 格式(无连字符分隔,仅空格) if (!result.code) { const m = text.match(/^([A-Z0-9][A-Z0-9\-]*[A-Z0-9])\s+(.+)$/); if (m) { result.code = m[1]; result.name = m[2].trim(); } } // 策略3: 整个文本作为名称 if (!result.code) { result.name = text.trim(); } // 清理名称 result.name = result.name.replace(/\s*[-\-]+\s*$/, '').trim(); return result; }; // ============================================================ // ★★★ getNodeLevel — v3.3 改进层级检测 // ============================================================ const getNodeLevel = (node, treeEl) => { // 策略1: data-level / aria-level 属性 const attrLevel = node.getAttribute('data-level') || node.getAttribute('aria-level'); if (attrLevel) return parseInt(attrLevel) - 1 || 0; // 策略2: padding-left 内联样式(Element Plus 默认每级 16px 或 18px) const content = node.querySelector('.el-tree-node__content'); if (content) { const pad = content.style.paddingLeft || content.style.paddingInlineStart; if (pad && pad.endsWith('px')) { const px = parseFloat(pad); if (px > 0) return Math.round(px / 16); } // 策略3: getComputedStyle try { const cs = getComputedStyle(content); const csPad = cs.paddingLeft || cs.paddingInlineStart; if (csPad && csPad !== '0px' && csPad !== '0') { const px = parseFloat(csPad); if (px > 0) return Math.round(px / 16); } } catch (e) {} } // 策略4: 通过父元素链中的 el-tree-node__children 数量 let parent = node.parentElement; let d = 0; while (parent && parent !== treeEl && d < 50) { // 放宽到 50 层,确保深层节点也能正确识别层级 if (parent.classList && parent.classList.contains('el-tree-node__children')) d++; parent = parent.parentElement; } if (d > 0) return d; // 策略5: 通过零件号层级推断(计算规范化 code 中的 - 分隔符数量) const text = (node.innerText || '').replace(/\s+/g, ' ').trim(); const p = parsePartText(text); if (p.code) { const dashes = (p.code.match(/-/g) || []).length; // 4J15T-99 = 1 dash → root (level 0, 减1) // 4J15T-99-1029 = 2 dashes → level 1 // 471Q-0540010 = 1 dash → 可能是 level 1 或更深(跨前缀 code) // 取 max(0, dashes - 1) 作为最低估计 return Math.max(0, dashes - 1); } return 0; }; // ============================================================ // ★★★ expandAndCollectTree — v3.3 改进展开+滚动收集管道 // ============================================================ const expandAndCollectTree = async (treeEl, skipExpand = false) => { console.log('[EPC] 开始 expandAndCollectTree skipExpand=' + skipExpand); const scrollContainer = treeEl.querySelector('.el-tree-virtual-list') || treeEl.querySelector('[class*="virtual-list"]') || treeEl.querySelector('.el-scrollbar__wrap') || treeEl.querySelector('.el-scrollbar') || treeEl.parentElement; console.log('[EPC] 滚动容器:', scrollContainer ? scrollContainer.className : '无'); // 阶段1: 展开所有可见的未展开节点(skipExpand=true 时跳过) const expandAllVisible = async () => { if (skipExpand) return 0; // 已展开,跳过 let clicked = 0; let rounds = 0; while (rounds < 200) { // 放宽到 200 轮,确保深层级也能完全展开 rounds++; const unexpanded = $$('.el-tree-node:not(.is-expanded):not(.is-leaf)', treeEl); if (unexpanded.length === 0) break; for (const node of unexpanded) { const icon = node.querySelector('.el-tree-node__expand-icon'); const content = node.querySelector('.el-tree-node__content'); const target = icon || content; if (target) { target.dispatchEvent(new MouseEvent('click', { bubbles: true })); clicked++; } } await wait(600); // 等待懒加载 } // 强制展开所有 $$('.el-tree-node', treeEl).forEach(n => { n.classList.add('is-expanded', 'expanded'); }); $$('.el-tree-node__children', treeEl).forEach(el => { el.style.display = 'block'; }); return clicked; }; // 阶段2: 滚动遍历 + 展开循环 const collected = new Map(); const maxPasses = skipExpand ? 3 : 50; // 放宽到 50 轮,确保所有层级都能展开+收集 let totalClicked = 0; for (let pass = 0; pass < maxPasses; pass++) { console.log('[EPC] Pass', pass + 1, '开始'); const clicked = await expandAllVisible(); totalClicked += clicked; // 收集函数 const collectVisible = () => { const visibleNodes = $$('.el-tree-node', treeEl); let collectedThisTime = 0; visibleNodes.forEach((node) => { const content = node.querySelector('.el-tree-node__content'); let text = ''; if (content) { const clone = content.cloneNode(true); // 移除展开图标 clone.querySelectorAll('.el-tree-node__expand-icon, .el-icon, i, svg, .el-checkbox').forEach(el => el.remove()); text = (clone.innerText || clone.textContent || '').replace(/\s+/g, ' ').trim(); } if (!text || text.length < 1) return; // 过滤只有符号的节点 if (/^[\s\-\_·•\u200b]+$/.test(text)) return; const level = getNodeLevel(node, treeEl); const p = parsePartText(text); const key = p.code || text; if (!collected.has(key)) { collected.set(key, { text, level, parsed: p, domNode: node }); collectedThisTime++; } }); return collectedThisTime; }; // 滚动收集 if (scrollContainer && scrollContainer.scrollHeight > scrollContainer.clientHeight) { const prevScroll = scrollContainer.scrollTop; // 先收集顶部节点(可能包含根节点) scrollContainer.scrollTop = 0; await wait(150); let topCount = collectVisible(); console.log('[EPC] 顶部收集:', topCount, '个节点'); let stableCount = 0; let scrollCount = 0; const maxScrolls = 2000; // 放宽到 2000 次,确保超长树也能完全滚动收集 while (scrollCount < maxScrolls) { scrollCount++; const collectedNow = collectVisible(); // 检查是否到底 const scrollHeight = scrollContainer.scrollHeight; const clientHeight = scrollContainer.clientHeight; const atBottom = scrollContainer.scrollTop + clientHeight >= scrollHeight - 2; if (atBottom) { stableCount++; if (stableCount >= 3) break; } else { stableCount = 0; scrollContainer.scrollTop += Math.max(clientHeight * 0.7, 150); await wait(60); // v3.3: 增加等待时间确保虚拟滚动渲染 } } console.log('[EPC] Pass', pass + 1, '滚动', scrollCount, '次, 累计收集', collected.size, '个节点'); // 恢复滚动位置 scrollContainer.scrollTop = prevScroll; } else { // 没有虚拟滚动,直接收集 let count = collectVisible(); console.log('[EPC] 无虚拟滚动, 直接收集', count, '个节点'); break; } // 检查是否还有未展开的节点(需要再一轮) const remainingUnexpanded = $$('.el-tree-node:not(.is-expanded):not(.is-leaf)', treeEl).length; console.log('[EPC] Pass', pass + 1, '剩余未展开:', remainingUnexpanded); if (remainingUnexpanded === 0 && pass > 0) break; } console.log('[EPC] 收集完成: 共', collected.size, '个节点, 展开点击:', totalClicked, '次'); if (collected.size === 0) { return extractTreeFromDOM(treeEl); } // 按收集顺序排列 const items = Array.from(collected.values()); // 打印前10个和后10个用于调试 console.log('[EPC] 前10个节点:'); items.slice(0, 10).forEach((item, i) => { console.log(` [${i}] level=${item.level} code="${item.parsed.code}" name="${item.parsed.name}" text="${item.text.substring(0, 60)}"`); }); console.log('[EPC] 后10个节点:'); items.slice(-10).forEach((item, i) => { console.log(` [${items.length - 10 + i}] level=${item.level} code="${item.parsed.code}" name="${item.parsed.name}" text="${item.text.substring(0, 60)}"`); }); // ★★★ v3.3: 混合树构建算法(code 前缀匹配 + 层级栈) const buildTreeHybrid = (flatItems) => { const roots = []; const stack = []; flatItems.forEach((item, idx) => { const p = item.parsed; const node = { label: item.text, code: p.code, name: p.name, spec: p.spec || '', qty: p.qty, children: [] }; // 策略1: 通过 code 前缀匹配找父节点 // 例如 "4J15T-99-1029" 以 "4J15T-99" + "-" 开头 → 是其子节点 let placed = false; if (p.code) { for (let i = stack.length - 1; i >= 0; i--) { const stackNode = stack[i].node; const stackCode = stackNode.code; if (stackCode && p.code.startsWith(stackCode + '-')) { stackNode.children.push(node); // 截断栈到当前位置 + 1 stack.length = i + 1; stack.push({ level: item.level, node }); placed = true; break; } } } // 策略2: 通过层级栈找父节点 if (!placed) { while (stack.length > 0 && stack[stack.length - 1].level >= item.level) { stack.pop(); } if (stack.length === 0) { roots.push(node); } else { stack[stack.length - 1].node.children.push(node); } stack.push({ level: item.level, node }); } }); return roots; }; const tree = buildTreeHybrid(items); console.log('[EPC] 树构建完成: 根节点', tree.length, '总节点', countNodes(tree)); return tree; }; // ============================================================ // extractTreeFromDOM(DOM 兜底提取) // ============================================================ const extractTreeFromDOM = (treeEl) => { const domNodes = $$('.el-tree-node', treeEl); if (domNodes.length === 0) return []; const parsed = []; const seen = new Set(); domNodes.forEach((node, idx) => { const key = node.getAttribute('data-key') || node.getAttribute('data-id') || idx; if (seen.has(key)) return; seen.add(key); let text = ''; const content = node.querySelector('.el-tree-node__content'); const customNode = node.querySelector('.custom-tree-node, .tree-node-label, [class*="node-label"]'); const labelSpan = node.querySelector('.el-tree-node__content span'); if (customNode) { text = (customNode.innerText || customNode.textContent || '').trim(); } else if (labelSpan) { text = (labelSpan.innerText || labelSpan.textContent || '').trim(); } else if (content) { const clone = content.cloneNode(true); const icons = clone.querySelectorAll('.el-tree-node__expand-icon, .el-icon, i, svg, .el-checkbox, .el-tree-node__expand-icon'); icons.forEach(i => i.remove()); text = (clone.innerText || clone.textContent || '').trim(); } text = text.replace(/\s+/g, ' ').trim(); if (!text || text.length < 1) return; if (/^[\s\-\_·•\u200b]+$/.test(text)) return; let level = 0; if (content) { const stylePad = content.style.paddingLeft || content.style.paddingInlineStart; if (stylePad && stylePad.endsWith('px')) { level = Math.round(parseFloat(stylePad) / 16); } else { const cs = getComputedStyle(content); const padVal = cs.paddingLeft || cs.paddingInlineStart; if (padVal && padVal !== '0px') { level = Math.round(parseFloat(padVal) / 16); } } } if (level === 0) { let parent = node.parentElement; let depth = 0; while (parent && parent !== treeEl && depth < 50) { // 放宽到 50 层 if (parent.classList && parent.classList.contains('el-tree-node__children')) { depth++; } parent = parent.parentElement; } level = depth; } parsed.push({ text, level }); }); const buildFromFlat = (items) => { const roots = []; const stack = []; items.forEach(item => { const p = parsePartText(item.text); const node = { label: item.text, code: p.code, name: p.name, spec: p.spec || '', qty: p.qty, children: [] }; while (stack.length > 0 && stack[stack.length - 1].level >= item.level) { stack.pop(); } if (stack.length === 0) { roots.push(node); } else { stack[stack.length - 1].node.children.push(node); } stack.push({ level: item.level, node }); }); return roots; }; return buildFromFlat(parsed); }; // ============================================================ // ★ 提取零件信息 // ============================================================ const extractPartInfo = (dialog) => { const info = { code: '', name: '', spec: '', material: '', size: '' }; // 辅助:判断是否像零件号(含连字符,通常短且有规律),排除VIN码/车型码(长且无连字符) const looksLikePartCode = (s) => { if (!s) return false; // VIN码通常17位纯字母数字,车型码也较长 if (s.length > 15) return false; // 零件号通常包含连字符 if (/[-\u2013\u2014]/.test(s)) return true; // 短的纯字母数字(<=8位)也可能是零件号 if (/^[A-Za-z0-9]{4,8}$/.test(s)) return true; return false; }; const items = $$('.el-descriptions__item, .el-form-item, .el-descriptions-item', dialog); items.forEach(item => { const label = item.querySelector('.el-descriptions__label, .el-form-item__label'); const value = item.querySelector('.el-descriptions__content, .el-form-item__content'); if (!label || !value) return; const labelText = label.textContent.trim(); const valueText = value.textContent.trim(); if (!valueText) return; // 精确匹配:优先"料号"/"零件号",排除车型相关编号 if (labelText.includes('料号') || labelText.includes('零件号')) { info.code = valueText; } else if (labelText.includes('品名') || labelText.includes('名称')) { info.name = valueText; } else if (labelText.includes('规格')) { info.spec = valueText; } else if (labelText.includes('材料')) { info.material = valueText; } else if (labelText.includes('尺寸')) { info.size = valueText; } else if (labelText === '编号' || labelText === '零件编号') { // 仅在值看起来像零件号时才采用 if (!info.code && looksLikePartCode(valueText)) { info.code = valueText; } } }); // 从弹窗标题提取(例如 "零件结构【B12-1108110A3-油门踏板总成】") if (!info.code) { const title = dialog.querySelector('.el-dialog__title, .el-card__header'); if (title) { const titleText = title.textContent.trim(); const m = titleText.match(/[【\[](.+?)[】\]]/); if (m) { const parts = m[1].split(/[\s_\-]+/); if (parts.length >= 1) info.code = parts[0]; if (parts.length >= 2) info.name = parts.slice(1).join(' '); } } } // 从 URL hash 提取(part-struct-dialog 特有) if (!info.code && dialog.classList && dialog.classList.contains('part-struct-dialog')) { const hash = location.hash || ''; const urlMatch = hash.match(/\/([A-Z0-9]{10,})/); if (urlMatch) info.code = urlMatch[1]; } return info; }; // ============================================================ // ★ 展开树(弹窗内或零件结构页面) // ============================================================ const expandDialogTree = async (dialog) => { // dialog 可能是弹窗元素,也可能是 treeEl 本身(零件结构页面) const treeEl = dialog.querySelector?.('.el-tree') || (dialog.classList?.contains('el-tree') ? dialog : null); if (!treeEl) return; const scopeEl = dialog.querySelector?.('.el-tree') ? dialog : treeEl.parentElement; // 显示等待提示(保持显示直到完成) const waitToast = document.createElement('div'); waitToast.style.cssText = `position:fixed;top:20px;left:50%;transform:translateX(-50%);background:#e6a23c;color:#fff;padding:14px 24px;border-radius:8px;z-index:999999;font-size:15px;font-weight:bold;box-shadow:0 4px 16px rgba(0,0,0,0.2);`; waitToast.innerHTML = '⏳ 请等待展开完成...'; document.body.appendChild(waitToast); toast('⏳ 请等待展开完成...', '#e6a23c', 1500); // 找到滚动容器 const scrollContainer = treeEl.querySelector('.el-tree-virtual-list') || treeEl.querySelector('[class*="virtual-list"]') || treeEl.querySelector('.el-scrollbar__wrap') || treeEl.querySelector('.el-scrollbar') || treeEl.parentElement; let count = 0; const clicked = new Set(); const startTime = Date.now(); let round = 0, noFoundStreak = 0; let scrollTop = 0; while (round < 200 && noFoundStreak < 5) { // 放宽:200 轮 + 连续 5 次无新节点才停止,确保完全展开 round++; const toClick = []; const unexpanded = $$('.el-tree-node:not(.is-expanded):not(.is-leaf)', scopeEl); for (const node of unexpanded) { const icon = node.querySelector('.el-tree-node__expand-icon'); const content = node.querySelector('.el-tree-node__content'); const target = icon || content; if (target && !clicked.has(target)) { toClick.push(target); clicked.add(target); } } if (toClick.length === 0) { noFoundStreak++; // 每轮等待期间模拟滚动(从顶部到底部,加大步进加快速度) if (scrollContainer && scrollContainer.scrollHeight > scrollContainer.clientHeight) { scrollTop = Math.min(scrollTop + scrollContainer.clientHeight, scrollContainer.scrollHeight); scrollContainer.scrollTop = scrollTop; if (scrollTop >= scrollContainer.scrollHeight - 2) { // 到底了,回到顶部重新扫一遍 scrollTop = 0; scrollContainer.scrollTop = 0; } } await wait(150); const still = $$('.el-tree-node:not(.is-expanded):not(.is-leaf)', scopeEl).length; if (still === 0) break; // 不重置 noFoundStreak,让连续多次无新节点才退出(避免深层懒加载节点漏掉) continue; } noFoundStreak = 0; for (const el of toClick) { el.dispatchEvent(new MouseEvent('click', { bubbles: true })); count++; } // 每次展开后也滚动一下(加大步进加快速度) if (scrollContainer && scrollContainer.scrollHeight > scrollContainer.clientHeight) { scrollTop = Math.min(scrollTop + scrollContainer.clientHeight, scrollContainer.scrollHeight); scrollContainer.scrollTop = scrollTop; if (scrollTop >= scrollContainer.scrollHeight - 2) { scrollTop = 0; scrollContainer.scrollTop = 0; } } await wait(200); } // 完成后再从头到尾快速滚一遍,确保所有节点都加载 if (scrollContainer && scrollContainer.scrollHeight > scrollContainer.clientHeight) { scrollContainer.scrollTop = 0; await wait(80); let s = 0; while (s < scrollContainer.scrollHeight) { s += scrollContainer.clientHeight; scrollContainer.scrollTop = s; await wait(40); } scrollContainer.scrollTop = 0; } $$('.el-tree-node', scopeEl).forEach(n => n.classList.add('is-expanded', 'expanded')); $$('.el-tree-node__children', scopeEl).forEach(el => el.style.display = 'block'); $$('.el-tree-node__expand-icon', scopeEl).forEach(i => { i.style.transform = 'rotate(90deg)'; i.classList.add('expanded'); }); const actualExpanded = $$('.el-tree-node.is-expanded, .el-tree-node.expanded', scopeEl).length; waitToast.remove(); toast(`✅ 已展开完成,共 ${Math.max(count, actualExpanded, 1)} 个节点`, '#67c23a', 2500); }; // ============================================================ // ★ 展开树(主页) // ============================================================ const expandMainTree = async () => { // 只在主页区域内查找树,不影响弹窗内的树 const mainArea = $('.el-main, .app-main, .app-container, main, section') || document.body; const treeEl = mainArea.querySelector('.el-tree') || document.querySelector('.el-tree'); if (!treeEl) { toast('未找到车型树', '#f56c6c'); return; } let count = 0; const clicked = new Set(); let round = 0, noFoundStreak = 0; while (round < 200 && noFoundStreak < 5) { // 放宽:200 轮 + 连续 5 次无新节点才停止 round++; const toClick = []; const unexpanded = $$('.el-tree-node:not(.is-expanded):not(.is-leaf)', treeEl); for (const node of unexpanded) { const icon = node.querySelector('.el-tree-node__expand-icon'); const target = icon; if (target && !clicked.has(target)) { toClick.push(target); clicked.add(target); } } if (toClick.length === 0) { noFoundStreak++; await wait(500); continue; } noFoundStreak = 0; for (const el of toClick) { el.dispatchEvent(new MouseEvent('click', { bubbles: true })); count++; } await wait(600); } $$('.el-tree-node', treeEl).forEach(n => n.classList.add('is-expanded', 'expanded')); $$('.el-tree-node__children', treeEl).forEach(el => el.style.display = 'block'); const actualExpanded = $$('.el-tree-node.is-expanded, .el-tree-node.expanded', treeEl).length; toast(`✅ 已展开 ${Math.max(count, actualExpanded, 1)} 个节点`, '#67c23a', 2000); }; // ============================================================ // ★ 构建导出 HTML // ============================================================ const buildExportHTML = (nodes, partInfo, title, isDialog = false) => { const now = new Date(); const timeStr = `${now.getFullYear()}/${String(now.getMonth()+1).padStart(2,'0')}/${String(now.getDate()).padStart(2,'0')} ${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}:${String(now.getSeconds()).padStart(2,'0')}`; const renderNode = (node, depth = 0) => { const indent = ' '.repeat(depth * 2); const childrenHtml = node.children && node.children.length > 0 ? node.children.map(child => renderNode(child, depth + 1)).join('') : ''; const qtyHtml = node.qty ? ` ×${node.qty}` : ''; const codeHtml = node.code ? `${node.code}` : ''; const nameHtml = node.name ? ` ${node.name}` : ''; const labelHtml = !node.code && !node.name ? `${node.label || ''}` : ''; return `
${indent} ${codeHtml}${nameHtml}${labelHtml}${qtyHtml}
${childrenHtml} `; }; const treeHtml = nodes.map(n => renderNode(n, 0)).join(''); let infoHtml = ''; if (isDialog && (partInfo.name || partInfo.spec || partInfo.material || partInfo.size)) { const fields = []; if (partInfo.name) fields.push(`
品名${partInfo.name}
`); if (partInfo.spec) fields.push(`
规格${partInfo.spec}
`); if (partInfo.material) fields.push(`
材料${partInfo.material}
`); if (partInfo.size) fields.push(`
尺寸${partInfo.size}
`); if (fields.length > 0) { infoHtml = `
${fields.join('')}
`; } } const nodesCount = (() => { let c = 0; const count = (arr) => { arr.forEach(n => { c++; if (n.children) count(n.children); }); }; count(nodes); return c; })(); return ` ${title}

📂 ${title}

导出时间:${timeStr} | 共 ${nodesCount} 个节点
${infoHtml}

📋 ${isDialog ? '零件结构明细' : '车型目录明细'}

${treeHtml}
`; }; // ============================================================ // ★ 导出功能 // ============================================================ const downloadHTML = (filename, html) => { 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 = filename; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); }; const exportDialog = async () => { const dialog = getActiveDialog(); const onStructPage = isPartStructPage(); // 容器:弹窗 或 零件结构页面主区域 let container = dialog; let scopeName = ''; if (!container && onStructPage) { // 零件结构页面(无弹窗) const mainArea = $('.el-main, .app-main, .app-container, main, section') || document.body; // 找零件结构相关的容器 container = mainArea.querySelector('[class*="part"], [class*="struct"], [class*="detail"]') || mainArea; scopeName = 'partstruct'; } if (!container) { toast('请先打开零件结构弹窗', '#f56c6c'); return; } // 重置 API 存储 W.__epcApiStore = []; const partInfo = extractPartInfo(container); let treeData = null; // 在容器内查找树,若找不到则全局找(且限定为零件结构相关的树,过滤掉主页车型目录) let treeEl = container.querySelector?.('.el-tree') || (container.classList?.contains('el-tree') ? container : null); if (!treeEl && onStructPage) { // 兜底:全局找零件结构相关的树 const candidates = $$('.el-tree'); for (const t of candidates) { const p = t.closest('[class*="part"], [class*="struct"], [class*="detail"]'); if (p && p.offsetHeight > 0) { treeEl = t; break; } } if (!treeEl && candidates.length === 1) treeEl = candidates[0]; } if (treeEl) { console.log('[EPC] === 开始导出', scopeName || '弹窗', '==='); console.log('[EPC] 零件信息:', partInfo); // 检测树是否已展开(避免重复展开导致导出很慢) const allNodes = $$('.el-tree-node', treeEl); const expandedNodes = $$('.el-tree-node.is-expanded, .el-tree-node.expanded', treeEl); const alreadyExpanded = allNodes.length > 0 && expandedNodes.length >= allNodes.length * 0.8; console.log('[EPC] 树状态: 总节点=' + allNodes.length + ' 已展开=' + expandedNodes.length + ' alreadyExpanded=' + alreadyExpanded); // 调用展开+滚动收集管道:已展开时 skipExpand=true 只做滚动收集(解决虚拟滚动数据丢失) const scrollNodes = await expandAndCollectTree(treeEl, alreadyExpanded); if (scrollNodes.length > 0) { treeData = { nodes: scrollNodes, source: alreadyExpanded ? 'scroll-collect' : 'pipeline-v3.3' }; } // 方案2: DOM 提取(兜底) if (!treeData || treeData.nodes.length === 0) { const domNodes = extractTreeFromDOM(treeEl); if (domNodes.length > 0) { treeData = { nodes: domNodes, source: 'dom' }; } } } // 方案3: API 数据提取(兜底) if (!treeData || treeData.nodes.length === 0) { const apiData = extractTreeFromAPI(); if (apiData && apiData.nodes.length > 0) { treeData = apiData; } } if (!treeData || treeData.nodes.length === 0) { toast('未获取到树数据,请先展开零件结构树', '#f56c6c'); return; } console.log('[EPC] 最终导出: source=' + treeData.source + ' 根节点=' + treeData.nodes.length + ' 总节点=' + countNodes(treeData.nodes)); // 文件名取明细第一行内容,例如:零件结构明细【B12-1108110A3-油门踏板总成】 const firstNode = treeData.nodes[0]; const firstLine = (firstNode && (firstNode.label || [firstNode.code, firstNode.name].filter(Boolean).join(' - '))) || ''; const title = firstLine ? `零件结构明细【${firstLine}】` : '零件结构明细'; const html = buildExportHTML(treeData.nodes, partInfo, title, true); downloadHTML(`${title}.html`, html); toast(`✅ 已导出 ${countNodes(treeData.nodes)} 个节点(${treeData.source})`, '#67c23a', 3000); }; const exportMain = () => { const main = $('.el-main, .app-main, .app-container, main, section') || document.body; const treeEl = main.querySelector('.el-tree') || document.querySelector('.el-tree'); if (!treeEl) { toast('未找到车型树', '#f56c6c'); return; } const nodes = extractTreeFromDOM(treeEl); if (nodes.length === 0) { toast('未获取到树数据', '#f56c6c'); return; } // 文件名取目录明细第一行数据,例如:车型BOM清单【CM5JQ20W65M09SS22 - 2022款 2.0L 手动舒适型】 const firstNode = nodes[0]; const firstLine = (firstNode && (firstNode.label || [firstNode.code, firstNode.name].filter(Boolean).join(' - '))) || ''; const title = firstLine ? `车型BOM清单【${firstLine}】` : '车型BOM清单'; const html = buildExportHTML(nodes, {}, title, false); downloadHTML(`${title}.html`, html); toast(`✅ 已导出 ${nodes.length} 个根节点`, '#67c23a', 3000); }; // ============================================================ // ★ UI 控件 // ============================================================ const createWidget = () => { const widget = document.createElement('div'); widget.id = 'epc-export-widget'; widget.style.cssText = `position:fixed;right:20px;bottom:80px;z-index:99999;`; widget.innerHTML = `
📂 EPC主页
`; document.body.appendChild(widget); const btnWrap = $('#epc-btn-wrap', widget); const menu = $('#epc-menu', widget); const mainBtn = $('#epc-main-btn', widget); const mainInner = $('#epc-main-inner', widget); // 恢复旧版:鼠标悬停自动展开/收起菜单 let hoverTimer = null; const showMenu = () => { if (isDragging) return; clearTimeout(hoverTimer); updateMenuItems(); menu.style.display = 'block'; }; const hideMenu = () => { if (isDragging) return; hoverTimer = setTimeout(() => { menu.style.display = 'none'; }, 200); }; btnWrap.addEventListener('mouseenter', showMenu); btnWrap.addEventListener('mouseleave', hideMenu); const updateButtonText = () => { const dlg = getActiveDialog(); const onStructPage = isPartStructPage(); if (dlg || onStructPage) { mainInner.innerHTML = '📂 零件详情'; } else { mainInner.innerHTML = '📂 EPC主页'; } }; // 弹窗相关选项仅在弹窗出现时显示,主页时隐藏 const updateMenuItems = () => { const dlg = getActiveDialog(); const onStructPage = isPartStructPage(); const dialogItems = $$('.epc-dialog-item', widget); const mainItems = $$('.epc-main-item', widget); if (dlg || onStructPage) { dialogItems.forEach(i => i.style.display = 'block'); mainItems.forEach(i => i.style.display = 'none'); } else { dialogItems.forEach(i => i.style.display = 'none'); mainItems.forEach(i => i.style.display = 'block'); } }; setInterval(() => { updateButtonText(); updateMenuItems(); }, 500); updateButtonText(); updateMenuItems(); // 找到零件结构页面的树(无弹窗情况下) const findPartStructTreeOnPage = () => { // 在零件结构页面,树通常在主内容区域 const mainArea = $('.el-main, .app-main, .app-container, main, section') || document.body; // 优先找"零件结构"相关的树,过滤掉主页车型目录 const candidates = $$('.el-tree', mainArea); for (const t of candidates) { // 如果有父级节点是零件结构/详情相关的,优先返回 const container = t.closest('[class*="part"], [class*="struct"], [class*="detail"]'); if (container && container.offsetHeight > 0) return t; } return candidates[0] || null; }; menu.addEventListener('click', async (e) => { const item = e.target.closest('[data-action]'); if (!item) return; const action = item.dataset.action; menu.style.display = 'none'; if (action === 'expand-dialog') { const dlg = getActiveDialog(); const onStructPage = isPartStructPage(); if (dlg) { await expandDialogTree(dlg); } else if (onStructPage) { const treeEl = findPartStructTreeOnPage(); if (treeEl) { await expandDialogTree(treeEl); } else { toast('未找到零件结构树', '#f56c6c'); } } else { toast('请先打开零件结构弹窗', '#f56c6c'); } } else if (action === 'export-dialog') { await exportDialog(); } else if (action === 'expand-main') { await expandMainTree(); } else if (action === 'export-main') { exportMain(); } }); // ============================================================ // 拖拽功能(彻底重写:稳定版本) // ============================================================ let isDragging = false; let dragMoved = false; let dragStartX = 0; let dragStartY = 0; let dragOrigLeft = 0; let dragOrigTop = 0; const ensureLeftTop = () => { // 确保 widget 用 left/top 定位,而非 right/bottom if (widget.style.left && widget.style.top) return; const rect = widget.getBoundingClientRect(); widget.style.left = rect.left + 'px'; widget.style.top = rect.top + 'px'; widget.style.right = 'auto'; widget.style.bottom = 'auto'; }; const onPointerDown = (e) => { // 只响应主按钮/左键/触摸 if (e.button !== undefined && e.button !== 0) return; // 点击到菜单项不触发 if (e.target.closest('[data-action]')) return; isDragging = true; dragMoved = false; dragStartX = e.clientX; dragStartY = e.clientY; ensureLeftTop(); const rect = widget.getBoundingClientRect(); dragOrigLeft = rect.left; dragOrigTop = rect.top; // 防止拖拽时选中文字/触发 hover 菜单 widget.style.userSelect = 'none'; menu.style.display = 'none'; // 捕获指针,防止离开元素后丢失事件 try { if (mainBtn.setPointerCapture && e.pointerId !== undefined) { mainBtn.setPointerCapture(e.pointerId); } } catch (_) {} e.stopPropagation(); e.preventDefault(); }; const onPointerMove = (e) => { if (!isDragging) return; const dx = e.clientX - dragStartX; const dy = e.clientY - dragStartY; if (Math.abs(dx) > 2 || Math.abs(dy) > 2) { dragMoved = true; } const newLeft = Math.max(0, Math.min(window.innerWidth - 20, dragOrigLeft + dx)); const newTop = Math.max(0, Math.min(window.innerHeight - 20, dragOrigTop + dy)); widget.style.left = newLeft + 'px'; widget.style.top = newTop + 'px'; // 防止 hover 菜单在移动到边缘时出现 menu.style.display = 'none'; e.preventDefault(); }; const onPointerUp = (e) => { if (!isDragging) return; isDragging = false; widget.style.userSelect = ''; try { if (mainBtn.releasePointerCapture && e.pointerId !== undefined) { mainBtn.releasePointerCapture(e.pointerId); } } catch (_) {} // 如果没有移动超过阈值,视为点击 -> 打开菜单 if (!dragMoved) { showMenu(); } }; // 同时支持鼠标和触摸(PointerEvents 最稳定) if (window.PointerEvent) { mainBtn.addEventListener('pointerdown', onPointerDown); document.addEventListener('pointermove', onPointerMove); document.addEventListener('pointerup', onPointerUp); document.addEventListener('pointercancel', onPointerUp); } else { mainBtn.addEventListener('mousedown', onPointerDown); document.addEventListener('mousemove', onPointerMove); document.addEventListener('mouseup', onPointerUp); mainBtn.addEventListener('touchstart', (e) => { const t = e.touches[0]; onPointerDown({ clientX: t.clientX, clientY: t.clientY, button: 0, preventDefault: () => e.preventDefault(), stopPropagation: () => e.stopPropagation() }); }, { passive: false }); document.addEventListener('touchmove', (e) => { const t = e.touches[0]; onPointerMove({ clientX: t.clientX, clientY: t.clientY, preventDefault: () => {} }); }, { passive: true }); document.addEventListener('touchend', () => onPointerUp({})); } }; // ============================================================ // ★ 启动 // ============================================================ const init = () => { // 装车清单页面不创建按钮(屏蔽所有EPC功能) if (isPackingListPage()) { console.log('[EPC] 装车清单页面,已屏蔽EPC按钮功能'); return; } createWidget(); console.log('%c📂⬇ EPC v3.3 已加载:修复parsePartText+混合树构建', 'color:#67c23a;font-size:14px;font-weight:bold'); console.log('%c💡 v3.3 修复: (1) parsePartText正确处理"471Q - 0540010"格式 (2) 混合树构建(code前缀+层级栈) (3) 改进滚动收集', 'color:#909399'); // 调试函数 W.__epcDebug = () => { const store = W.__epcApiStore || []; console.group('=== EPC API 调试 ==='); console.log('已拦截 API 数量:', store.length); console.log('unsafeWindow可用:', typeof unsafeWindow !== 'undefined'); store.forEach((entry, i) => { console.log(`[${i}]`, entry.url.substring(0, 80), '| 数据长度:', entry.len); try { const data = JSON.parse(entry.text); const tree = findTreeInData(data); console.log(' 树数据:', tree ? tree.length + ' 个根节点' : '无树结构'); } catch (e) { console.log(' 非JSON或解析失败'); } }); console.groupEnd(); return store; }; // 导出API数据到文件 W.__epcExportData = () => { const store = W.__epcApiStore || []; const blob = new Blob([JSON.stringify(store, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'epc-api-data.json'; a.click(); console.log('✅ 已导出', store.length, '条API数据'); }; // ============================================================ // ★ 滑块验证自动化(页面上下文注入版,绕过油猴沙箱事件隔离) // 页面结构:.drag(轨道)+.drag_bg(填充条)+.drag_text(文字)+.handler.handler_bg(把手) // ============================================================ const autoSliderVerify = () => { const isLoginPage = () => /\/login|login\?|\/#\/login/i.test(location.href); if (!isLoginPage()) return; // 是否已注入 if (W.__epcSliderInjected) return; W.__epcSliderInjected = true; // ============================================================ // 把核心逻辑注入到 PAGE CONTEXT(不是油猴沙箱) // 只有这样 dispatch 的事件才能触发目标页面 Vue 组件内部绑定的事件处理器 // ============================================================ const pageScript = ` (function(){ if (window.__EPC_SLIDER_RUNNING__) return; window.__EPC_SLIDER_RUNNING__ = true; var TOAST_EL = null; function showToast(msg, color, dur) { try { if (TOAST_EL) try { TOAST_EL.remove(); } catch(e) {} var d = document.createElement('div'); d.style.cssText = 'position:fixed;top:20px;left:50%;transform:translateX(-50%);z-index:9999999;background:'+(color||'#409eff')+';color:#fff;padding:12px 20px;border-radius:8px;font-size:14px;font-weight:bold;box-shadow:0 4px 16px rgba(0,0,0,.2);'; d.textContent = msg; document.body.appendChild(d); TOAST_EL = d; if (dur) setTimeout(function(){ try { d.remove(); } catch(e){} }, dur); } catch(e) {} } function log(m) { try { console.log('[EPC-SLIDER]', m); } catch(e) {} } function wait(ms) { return new Promise(function(r){ setTimeout(r, ms); }); } function findParts() { // 1. sbom-epc 专用 var handler = document.querySelector('.handler.handler_bg, .handler.bg, .handler, .drag-handler'); var track = document.querySelector('.drag, .drag-bar, .slider-track'); if (handler && track && handler.offsetHeight > 0 && track.offsetWidth > 20) { return { track: track, handle: handler, type: 'drag' }; } // 兜底 if (handler && !track) { var t = handler.parentElement; if (t && t.offsetWidth > 20) return { track: t, handle: handler, type: 'drag' }; } if (track && !handler) { var h = track.querySelector('[class*="handler"], [class*="handle"], [class*="btn"], [class*="button"], div'); if (h) return { track: track, handle: h, type: 'drag' }; } return null; } function isVerified(parts) { if (!parts) return false; var bg = parts.track.querySelector('.drag_bg, [class*="drag_bg"]'); var text = parts.track.querySelector('.drag_text, [class*="drag_text"]'); if (text && /验证成功|验证通过|已验证/.test(text.textContent || '')) return true; if (bg) { var tr = parts.track.getBoundingClientRect(); var br = bg.getBoundingClientRect(); if (br.width >= tr.width * 0.95) return true; } return false; } function findLoginBtn() { var list = document.querySelectorAll('.el-button--primary, button[type="submit"], button'); for (var i = 0; i < list.length; i++) { var p = list[i]; if (p.offsetHeight > 0 && /登录|登\\s*录/.test(p.textContent)) return p; } return null; } // ★ 关键:直接在目标页面 window 上创建的 PointerEvent / MouseEvent // 可以被页面 Vue 绑定的原生 addEventListener 捕获到(油猴沙箱中 new 出来的则不行) function fireEvents(kind, x, y, handle, track) { // PointerEvent(优先) try { var peOpts = { bubbles: true, cancelable: true, composed: true, view: window, clientX: x, clientY: y, screenX: x, screenY: y, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: kind === 'up' ? 0 : 1, width: 1, height: 1, pressure: kind === 'up' ? 0 : 0.5 }; var pe = new PointerEvent('pointer' + kind, peOpts); handle.dispatchEvent(pe); track.dispatchEvent(pe); } catch(e) {} // MouseEvent(兜底) var meOpts = { bubbles: true, cancelable: true, composed: true, view: window, clientX: x, clientY: y, screenX: x, screenY: y, button: 0, buttons: kind === 'up' ? 0 : 1 }; var me = new MouseEvent('mouse' + kind, meOpts); handle.dispatchEvent(me); track.dispatchEvent(me); // move/up 还 dispatch 到 document(部分滑块组件在 document 监听) if (kind === 'move' || kind === 'up') { document.dispatchEvent(new MouseEvent('mouse' + kind, meOpts)); try { document.dispatchEvent(new PointerEvent('pointer' + kind, peOpts)); } catch(e2) {} } } async function doVerify(parts) { var track = parts.track, handle = parts.handle; log('开始自动验证...'); showToast('🤖 正在自动完成滑块验证...', '#409eff', 2000); var hRect = handle.getBoundingClientRect(); var tRect = track.getBoundingClientRect(); var startX = hRect.left + hRect.width / 2; var startY = hRect.top + hRect.height / 2; var endX = tRect.right - 4; // 留 4px 余量,实测刚好到最右端 var distance = endX - startX; log('start=' + startX + ' end=' + endX + ' dist=' + distance + ' trackW=' + tRect.width); if (distance <= 0) { log('距离异常,跳过'); return false; } // 按下 + 捕获指针 fireEvents('down', startX, startY, handle, track); try { handle.setPointerCapture && handle.setPointerCapture(1); } catch(e) {} // 40 步分段拖动(先快后慢) var steps = 40; for (var i = 1; i <= steps; i++) { var t = i / steps; var progress = t < 0.7 ? t / 0.7 : 1; var x = startX + distance * progress; fireEvents('move', x, startY, handle, track); await wait(12); } // 确保停到终点 fireEvents('move', endX, startY, handle, track); await wait(60); try { handle.releasePointerCapture && handle.releasePointerCapture(1); } catch(e) {} fireEvents('up', endX, startY, handle, track); log('滑块已滑到终点,等待结果...'); await wait(700); var ok = isVerified(parts); if (ok) { log('✅ 验证成功'); showToast('✅ 滑块验证成功', '#67c23a', 2000); await wait(300); var loginBtn = findLoginBtn(); if (loginBtn) { loginBtn.click(); log('已点击登录按钮'); showToast('🚀 正在登录...', '#409eff', 1500); } } else { log('⚠️ 未通过,准备重试'); showToast('⚠️ 自动验证未通过,准备重试', '#e6a23c', 2000); } return ok; } // 主循环:每 2 秒检测,直到离开登录页 var PROC = false; var LAST = 0; var ticker = setInterval(function run(){ try { if (!/\\/login|login\\?|\\/#\\/login/i.test(location.href)) { clearInterval(ticker); return; } var now = Date.now(); if (PROC || (now - LAST < 1800)) return; var parts = findParts(); if (!parts) return; if (isVerified(parts)) return; PROC = true; LAST = now; doVerify(parts).then(function(){ PROC = false; }) .catch(function(){ PROC = false; }); } catch(e) { PROC = false; } }, 1500); // 初始触发几次(页面刚加载时) [800, 1800, 3000, 5000].forEach(function(ms){ setTimeout(function(){ try { clearInterval._tmp ? null : null; } catch(e){} // 直接触发一轮 var parts = findParts(); if (parts && !isVerified(parts) && !PROC) { PROC = true; LAST = Date.now(); doVerify(parts).then(function(){ PROC=false; }).catch(function(){ PROC=false; }); } }, ms); }); log('滑块自动验证脚本已注入页面上下文'); })(); `; // 注入到页面 const s = document.createElement('script'); s.setAttribute('type', 'text/javascript'); s.setAttribute('data-epc-slider', '1'); // 把脚本内容用 try/catch 包一层,防止注入失败抛错影响主脚本 s.textContent = `try { ${pageScript} } catch(e) { console.error('[EPC] 滑块注入失败:', e); }`; try { (document.head || document.documentElement).appendChild(s); } catch (e) { console.error('[EPC] 注入 script 标签失败,改用 eval 在当前 context 兜底:', e); try { (0, eval)(pageScript); } catch (e2) { console.error('[EPC] eval 兜底也失败:', e2); } } // 油猴端也加一个独立的 MutationObserver,在页面注入前就检测 const tryInPage = () => { if (!isLoginPage()) return; // 检查注入脚本是否已经在运行 if (document.querySelector('script[data-epc-slider="1"]')) return; }; // 再定期检查一次(防止注入时机错过) const recheck = setInterval(() => { if (!isLoginPage()) { clearInterval(recheck); return; } tryInPage(); }, 3000); }; // 启动滑块验证监听 autoSliderVerify(); // ★ v3.3: 测试 parsePartText W.__epcTestParse = () => { const tests = [ '471Q - 0540010 - 钢球(5/16) ×1', '4J15T - 99-1029 - 气门和气门弹簧 - LQ ×6', 'DAED122907 - 缸盖分总成 ×1', 'C0102AA-1002 - 缸体系统、传感器 - LQ ×1', '4J15T-99 发动机总成 ×1', '471Q-1007004 - 气门弹簧保持座 ×16', ]; console.group('=== parsePartText 测试 ==='); tests.forEach(text => { const p = parsePartText(text); console.log(`"${text}"`); console.log(` → code="${p.code}" name="${p.name}" qty="${p.qty}" spec="${p.spec}"`); }); console.groupEnd(); }; }; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();