// ==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 `