// ==UserScript== // @name EPC 车型目录导出 // @namespace https://scriptcat.org/users/epc-export // @version 3.8 // @description EPC 车型目录导出工具:自动展开全部节点、识别车辆信息、导出完整 HTML 清单。支持弹窗内零件结构单独导出(v3.7 优化"当前选中的明细"导出速度,使用原始图片URL保证清晰度) // @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' }; }; // ============================================================ // ★ 从 API 数据中提取热点序号(备选方案) // ============================================================ const extractHotNumsFromAPI = (expectedCount) => { const store = W.__epcApiStore || []; if (store.length === 0) return null; // 查找包含热点序号的 API 响应 const hotNumFields = ['hotNum', 'hotspotNum', 'hotNo', 'serialNo', 'sn', 'no', 'index', 'num', 'seq', 'hotspot', 'pointNum']; for (const entry of store) { try { const data = JSON.parse(entry.text); // 尝试在数据中查找热点序号字段 const result = findHotNumsInData(data, hotNumFields, expectedCount); if (result && result.length >= expectedCount * 0.5) { console.log('[EPC] 从API获取热点序号成功,数量:', result.length, 'URL:', entry.url.substring(0, 60)); return result; } } catch (e) {} } console.log('[EPC] 未从API获取到热点序号'); return null; }; // 递归查找数据中的热点序号字段 const findHotNumsInData = (data, hotNumFields, expectedCount, depth = 0) => { if (depth > 5) return null; if (!data || typeof data !== 'object') return null; // 检查数据本身是否包含热点序号字段 if (typeof data === 'object' && !Array.isArray(data)) { for (const field of hotNumFields) { if (data[field] !== undefined && data[field] !== null && data[field] !== '') { // 找到包含热点序号字段的对象 // 检查是否是数组结构的一部分 const parentArray = findParentArray(data, hotNumFields, expectedCount, depth); if (parentArray) return parentArray; // 否则返回单个值的数组 return [String(data[field])]; } } } // 如果是数组,检查每个元素 if (Array.isArray(data)) { // 检查数组元素是否包含热点序号字段 const hasHotNum = data.length > 0 && typeof data[0] === 'object' && hotNumFields.some(f => data[0][f] !== undefined); if (hasHotNum) { return data.map(item => { for (const field of hotNumFields) { if (item[field] !== undefined && item[field] !== null && item[field] !== '') { return String(item[field]); } } return ''; }); } // 递归检查每个元素 for (const item of data) { const result = findHotNumsInData(item, hotNumFields, expectedCount, depth + 1); if (result && result.length >= expectedCount * 0.5) { return result; } } } else if (typeof data === 'object') { // 递归检查对象的每个属性 for (const key of Object.keys(data)) { if (key === 'children' || key === 'parentCode' || key === 'parentId') continue; const val = data[key]; if (val && (Array.isArray(val) || typeof val === 'object')) { const result = findHotNumsInData(val, hotNumFields, expectedCount, depth + 1); if (result && result.length >= expectedCount * 0.5) { return result; } } } } return null; }; // 查找包含热点序号字段的父数组 const findParentArray = (obj, hotNumFields, expectedCount, depth) => { // 这是一个简化的实现,在实际使用中可能需要更复杂的逻辑 return null; }; 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; // ★ 关键修复:不用 clicked Set 去重 + 不用 :not(.is-leaf) while (rounds < 200) { rounds++; const allNodes = $$('.el-tree-node', treeEl); let found = false; for (const node of allNodes) { if (node.classList.contains('is-expanded') || node.classList.contains('expanded')) continue; const icon = node.querySelector('.el-tree-node__expand-icon'); if (!icon) continue; icon.dispatchEvent(new MouseEvent('click', { bubbles: true })); clicked++; found = true; } if (!found) break; await wait(300); // 等待懒加载 } // 强制展开所有 $$('.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 : 15; // 平衡速度与完整性 let totalClicked = 0; let prevSize = 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 = 500; // 平衡滚动收集速度与完整性 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(40); // 等待虚拟滚动渲染 } } console.log('[EPC] Pass', pass + 1, '滚动', scrollCount, '次, 累计收集', collected.size, '个节点'); // 恢复滚动位置 scrollContainer.scrollTop = prevScroll; } else { // 没有虚拟滚动,直接收集 let count = collectVisible(); console.log('[EPC] 无虚拟滚动, 直接收集', count, '个节点'); break; } // ★ 本轮无新增节点则提前退出 if (collected.size === prevSize && pass > 0) { console.log('[EPC] 本轮无新增节点,提前退出'); break; } prevSize = collected.size; // 检查是否还有未展开的节点(需要再一轮) 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; if (W.__epcBusy) { toast('正在执行其他操作,请稍候...', '#e6a23c'); return; } W.__epcBusy = true; 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); // ★ 安全兜底:60秒后强制移除提示,防止永久卡住 let safetyTimer = null; safetyTimer = setTimeout(() => { try { waitToast.remove(); } catch(e) {} toast('⚠️ 展开超时,已强制结束', '#e6a23c', 3000); console.warn('[EPC] expandDialogTree 超时强制结束'); }, 60000); try { // 找到滚动容器 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 startTime = Date.now(); let round = 0, noFoundStreak = 0; let scrollTop = 0; let maxExpandedCount = 0; let noProgressStreak = 0; // 120 轮上限;连续6轮未出现更高展开数则认为已完成 while (round < 120 && noFoundStreak < 8 && noProgressStreak < 6) { round++; // ★ 关键修复1:不用 clicked Set 去重(虚拟滚动下元素会复用,导致误判) // ★ 关键修复2:不用 :not(.is-leaf)(懒加载未完成时会被误判为叶子) // 改为:查找所有"有展开图标 且 未展开"的节点 const allNodes = $$('.el-tree-node', scopeEl); const toClick = []; for (const node of allNodes) { // 跳过已展开的 if (node.classList.contains('is-expanded') || node.classList.contains('expanded')) continue; // 必须有展开图标(说明可展开,避免误点叶子) const icon = node.querySelector('.el-tree-node__expand-icon'); if (!icon) continue; toClick.push({ node, icon }); } 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(200); // 再次检查是否真的没有未展开节点 const still = $$('.el-tree-node:not(.is-expanded):not(.is-leaf)', scopeEl).length; if (still === 0) break; // 还有未展开节点但本轮没找到 → 可能是虚拟滚动未渲染,继续滚动 continue; } // 找到可点击节点,重置计数 noFoundStreak = 0; // ★ 关键修复3:先把每个节点滚动到可见再点击(虚拟滚动下不可见的节点点击无效) for (const item of toClick) { // 滚动到该节点可见 try { const nodeRect = item.node.getBoundingClientRect(); const containerRect = scrollContainer ? scrollContainer.getBoundingClientRect() : null; if (containerRect && (nodeRect.top < containerRect.top || nodeRect.bottom > containerRect.bottom)) { // 节点不可见,滚动到它 if (scrollContainer) { const offset = nodeRect.top - containerRect.top + scrollContainer.scrollTop - 50; scrollContainer.scrollTop = Math.max(0, Math.min(offset, scrollContainer.scrollHeight - scrollContainer.clientHeight)); await wait(30); } } } catch (e) {} // 点击展开 item.icon.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); // ★ 进度检测:用最大展开数追踪,避免虚拟滚动计数波动误判 const currentExpanded = $$('.el-tree-node.is-expanded, .el-tree-node.expanded', scopeEl).length; if (currentExpanded > maxExpandedCount) { maxExpandedCount = currentExpanded; noProgressStreak = 0; } else { noProgressStreak++; } } console.log('[EPC] 展开循环结束: round=' + round + ' count=' + count + ' noFoundStreak=' + noFoundStreak); // 完成后再从头到尾快速滚一遍,确保所有节点都加载 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; toast(`✅ 已展开完成,共 ${Math.max(count, actualExpanded, 1)} 个节点`, '#67c23a', 2500); } catch (e) { console.error('[EPC] expandDialogTree 失败:', e); toast('展开失败: ' + (e.message || e), '#f56c6c', 3000); } finally { W.__epcBusy = false; if (safetyTimer) clearTimeout(safetyTimer); try { waitToast.remove(); } catch(e) {} } }; // ============================================================ // ★ 展开树(主页) // ============================================================ 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; } if (W.__epcBusy) { toast('正在执行其他操作,请稍候...', '#e6a23c'); return; } W.__epcBusy = true; // 显示等待提示(保持显示直到完成) 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); // ★ 安全兜底:60秒后强制移除提示,防止永久卡住 let safetyTimer = null; safetyTimer = setTimeout(() => { try { waitToast.remove(); } catch(e) {} toast('⚠️ 展开超时,已强制结束(部分节点可能未展开)', '#e6a23c', 3000); console.warn('[EPC] expandMainTree 超时强制结束'); }, 60000); try { let count = 0; // ★ 找到滚动容器(支持虚拟滚动树) 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 round = 0, noFoundStreak = 0; let scrollTop = 0; let maxExpandedCount = 0; let noProgressStreak = 0; // 60轮上限;连续6轮未出现更高展开数则认为已完成 while (round < 60 && noFoundStreak < 8 && noProgressStreak < 6) { round++; const allNodes = $$('.el-tree-node', treeEl); const toClick = []; for (const node of allNodes) { if (node.classList.contains('is-expanded') || node.classList.contains('expanded')) continue; const icon = node.querySelector('.el-tree-node__expand-icon'); if (!icon) continue; toClick.push(icon); } 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(300); const still = $$('.el-tree-node:not(.is-expanded):not(.is-leaf)', treeEl).length; if (still === 0) break; 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(250); // ★ 进度检测:用最大展开数追踪,避免虚拟滚动计数波动误判 const currentExpanded = $$('.el-tree-node.is-expanded, .el-tree-node.expanded', treeEl).length; if (currentExpanded > maxExpandedCount) { maxExpandedCount = currentExpanded; noProgressStreak = 0; } else { noProgressStreak++; } } $$('.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', 2500); } catch (e) { console.error('[EPC] expandMainTree 失败:', e); toast('展开失败: ' + (e.message || e), '#f56c6c', 3000); } finally { W.__epcBusy = false; if (safetyTimer) clearTimeout(safetyTimer); try { waitToast.remove(); } catch(e) {} } }; // ============================================================ // ★ 构建导出 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; } if (W.__epcBusy) { toast('正在执行其他操作,请稍候...', '#e6a23c'); return; } W.__epcBusy = true; // 显示正在导出提示(保持显示直到完成) const exportToast = document.createElement('div'); exportToast.style.cssText = `position:fixed;top:20px;left:50%;transform:translateX(-50%);background:#409eff;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);`; exportToast.innerHTML = '⏳ 正在导出,请稍候...'; document.body.appendChild(exportToast); try { // 重置 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); } catch (e) { console.error('[EPC] 导出失败:', e); toast('导出失败: ' + (e.message || e), '#f56c6c', 3000); } finally { W.__epcBusy = false; try { exportToast.remove(); } catch(e) {} } }; 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); }; // ============================================================ // ★ 导出当前选中节点的明细(图片+明细表格) // 布局:左侧明细列表(可滚动),右侧图片(固定) // 文件名:选中节点 "编码 - 名称"(去掉用量后缀) // ============================================================ const exportCurrentSelection = async () => { if (W.__epcBusy) { toast('正在执行其他操作,请稍候...', '#e6a23c'); return; } W.__epcBusy = true; const exportToast = document.createElement('div'); exportToast.style.cssText = `position:fixed;top:20px;left:50%;transform:translateX(-50%);background:#409eff;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);`; exportToast.innerHTML = '⏳ 正在导出当前选中明细...'; document.body.appendChild(exportToast); try { // 1. 获取当前选中的树节点文本 const selectedNode = document.querySelector('.el-tree-node.is-current, .el-tree-node.is-selected'); if (!selectedNode) { toast('请先在左侧树中选中一个节点', '#f56c6c'); return; } const content = selectedNode.querySelector('.el-tree-node__content'); let selectedText = ''; if (content) { const clone = content.cloneNode(true); clone.querySelectorAll('.el-tree-node__expand-icon, .el-icon, i, svg, .el-checkbox').forEach(el => el.remove()); selectedText = (clone.innerText || '').replace(/\s+/g, ' ').trim(); } if (!selectedText) { toast('无法获取选中节点名称', '#f56c6c'); return; } // 用 parsePartText 提取 code 和 name(去掉用量后缀) const p = parsePartText(selectedText); const displayName = (p.code && p.name) ? `${p.code} - ${p.name}` : (p.name || p.code || selectedText); // 2. 找到 splitter 的三个 panel(树 | 图片 | 明细) const splitter = document.querySelector('.el-splitter__horizontal'); if (!splitter) { toast('未找到内容区域(请确认在车型Bom查询页面)', '#f56c6c'); return; } const panels = splitter.querySelectorAll(':scope > .el-splitter-panel'); if (panels.length < 3) { toast('页面结构不完整,需要树/图片/明细三栏布局', '#f56c6c'); return; } const canvasPanel = panels[1]; const tablePanel = panels[2]; // 3. 提取原始图片(从 Konva.Image 获取原始 HTMLImageElement) // 同时获取 URL 和 base64:URL 用于清晰显示,base64 作为 URL 过期时的兜底 let imgOrigUrl = ''; // 原始图片 URL(最清晰,但会过期) let imgFallbackData = ''; // base64 兜底数据(URL 过期时使用) let imgNaturalW = 0, imgNaturalH = 0; try { const KonvaRef = W.Konva || (typeof Konva !== 'undefined' ? Konva : null); if (KonvaRef && KonvaRef.stages && KonvaRef.stages.length > 0) { const findOrigImage = (node) => { if (!node) return null; if (node.className === 'Image' && typeof node.image === 'function') { const im = node.image(); if (im && im.complete && im.naturalWidth > 0) return im; } if (node.children && node.children.length > 0) { for (const child of node.children) { const found = findOrigImage(child); if (found) return found; } } return null; }; const origImg = findOrigImage(KonvaRef.stages[0]); if (origImg) { imgNaturalW = origImg.naturalWidth; imgNaturalH = origImg.naturalHeight; imgOrigUrl = origImg.src || ''; // 同时生成 base64 兜底数据 try { const tmpCanvas = document.createElement('canvas'); tmpCanvas.width = origImg.naturalWidth; tmpCanvas.height = origImg.naturalHeight; const ctx = tmpCanvas.getContext('2d'); ctx.drawImage(origImg, 0, 0); imgFallbackData = tmpCanvas.toDataURL('image/png'); } catch (e2) { console.error('[EPC] base64兜底生成失败:', e2); } console.log('[EPC] 原始图片: ' + imgNaturalW + 'x' + imgNaturalH + ' URL长度=' + imgOrigUrl.length + ' base64长度=' + imgFallbackData.length); } } } catch (e) { console.error('[EPC] 从 Konva 获取原始图片失败:', e); } // 回退:直接用 canvas.toDataURL if (!imgOrigUrl && !imgFallbackData) { const canvas = canvasPanel.querySelector('canvas'); if (canvas) { try { imgFallbackData = canvas.toDataURL('image/png'); imgNaturalW = canvas.width; imgNaturalH = canvas.height; console.log('[EPC] 回退使用canvas截图: ' + imgNaturalW + 'x' + imgNaturalH); } catch (e) { console.error('[EPC] canvas转图片失败:', e); } } } // 4. 提取 vxe-table 明细数据(右侧面板) const vxe = tablePanel.querySelector('.vxe-table'); const detailRows = []; const getCellText = (el) => { if (!el) return ''; const t = el.innerText || el.textContent || ''; return t.replace(/\s+/g, ' ').trim(); }; if (vxe) { // 强制 vxe-table 渲染 fixed-left 列(热点序号列懒加载) // F12 打开 DevTools 改变视口宽度 → vxe-table 重新布局 → 渲染固定列 // 采用激进方案:多次循环触发 resize + 大幅度改变容器宽度 const wrapper = vxe.closest('.vxe-table--wrapper') || vxe; const viewport = vxe.querySelector('.vxe-table--viewport-wrapper'); const origWrapperW = wrapper.style.width; const origViewportScroll = viewport ? viewport.scrollLeft : 0; // 方案A: 多次循环触发 resize(模拟 F12 打开时的视口变化) const forceResize = async () => { // 1. 大幅度改变容器宽度(模拟视口变化) const origWidth = wrapper.offsetWidth; const shrinkSteps = [origWidth - 50, origWidth - 20, origWidth - 5, origWidth]; for (const newW of shrinkSteps) { wrapper.style.width = newW + 'px'; wrapper.offsetHeight; // 强制同步布局 window.dispatchEvent(new Event('resize')); await new Promise(r => setTimeout(r, 15)); } if (origWrapperW) wrapper.style.width = origWrapperW; else wrapper.style.width = ''; // 2. 横向滚动触发 if (viewport) { const maxScroll = viewport.scrollWidth - viewport.clientWidth; if (maxScroll > 0) { viewport.scrollLeft = maxScroll; viewport.dispatchEvent(new Event('scroll')); await new Promise(r => setTimeout(r, 10)); viewport.scrollLeft = 0; viewport.dispatchEvent(new Event('scroll')); await new Promise(r => setTimeout(r, 10)); viewport.scrollLeft = origViewportScroll; } } // 3. 连续触发 window resize(模拟真实的 resize 过程) for (let i = 0; i < 5; i++) { window.dispatchEvent(new Event('resize')); document.body.offsetHeight; // 强制重排 await new Promise(r => setTimeout(r, 20)); } }; // 方案D: 直接强制显示 fixed-left 列(如果存在但被隐藏) const forceShowFixedLeft = () => { const flWrapper = vxe.querySelector('.vxe-table--fixed-left-wrapper'); if (flWrapper) { // 强制显示 fixed-left wrapper flWrapper.style.display = ''; flWrapper.style.visibility = 'visible'; flWrapper.style.opacity = '1'; flWrapper.style.pointerEvents = 'auto'; // 强制显示其中的 body const flBodies = flWrapper.querySelectorAll('.vxe-table--body'); flBodies.forEach(body => { body.style.display = ''; body.style.visibility = 'visible'; }); // 强制显示所有 td const flCells = flWrapper.querySelectorAll('td'); flCells.forEach(td => { td.style.display = ''; td.style.visibility = 'visible'; td.style.width = td.style.width || 'auto'; }); console.log('[EPC] 强制显示 fixed-left 列,td数量: ' + flCells.length); } return flWrapper; }; // 方案F: 通过 CSS transform 强制浏览器重新计算布局 const forceReflow = async () => { // 临时添加 transform 触发硬件加速和重新布局 const origTransform = vxe.style.transform; vxe.style.transform = 'translateZ(0)'; vxe.offsetHeight; // 强制同步布局 // 触发多次 reflow for (let i = 0; i < 3; i++) { vxe.style.transform = 'translateX(0.1px)'; vxe.offsetHeight; vxe.style.transform = 'translateX(0)'; vxe.offsetHeight; await new Promise(r => setTimeout(r, 30)); } if (origTransform) vxe.style.transform = origTransform; else vxe.style.transform = ''; // 最后一次强制重排 vxe.offsetHeight; window.dispatchEvent(new Event('resize')); }; await forceResize(); await forceReflow(); forceShowFixedLeft(); // 方案B: 等待 fixed-left 列渲染完成(更长的等待时间) let allBodies = vxe.querySelectorAll('.vxe-table--body'); let retries = 0; // 如果 body 数量不够,继续尝试触发 while (allBodies.length < 2 && retries < 30) { // 再次触发 resize window.dispatchEvent(new Event('resize')); if (viewport) { viewport.scrollLeft = (viewport.scrollLeft + 1) % 100; viewport.dispatchEvent(new Event('scroll')); } forceShowFixedLeft(); await new Promise(r => setTimeout(r, 80)); allBodies = vxe.querySelectorAll('.vxe-table--body'); retries++; } console.log('[EPC] body数量: ' + allBodies.length + ' 重试: ' + retries); // 诊断:打印 vxe-table 结构 const fixedLeft = vxe.querySelector('.vxe-table--fixed-left-wrapper'); const fixedRight = vxe.querySelector('.vxe-table--fixed-right-wrapper'); console.log('[EPC] vxe结构: fixedLeft=' + (fixedLeft ? '存在' : '不存在') + ' fixedRight=' + (fixedRight ? '存在' : '不存在')); if (fixedLeft) { const flBodies = fixedLeft.querySelectorAll('.vxe-table--body'); console.log('[EPC] fixed-left bodies: ' + flBodies.length); if (flBodies.length > 0) { const flRows = flBodies[0].querySelectorAll('tr'); console.log('[EPC] fixed-left 行数: ' + flRows.length + ' 首行td数: ' + flRows[0]?.querySelectorAll('td').length); } } // 按 body 索引分别收集每行的每个单元格文本(保留空单元格以保持索引对齐) const bodyRowCells = []; for (let bi = 0; bi < allBodies.length; bi++) { const rows = allBodies[bi].querySelectorAll('tr'); bodyRowCells[bi] = []; for (let ri = 0; ri < rows.length; ri++) { const cells = rows[ri].querySelectorAll('td'); const texts = []; for (let ci = 0; ci < cells.length; ci++) { texts.push(getCellText(cells[ci])); } bodyRowCells[bi][ri] = texts; } if (bodyRowCells[bi].length > 0) { console.log('[EPC] body[' + bi + '] 行数=' + bodyRowCells[bi].length + ' 首行=' + JSON.stringify(bodyRowCells[bi][0])); } } // 方案C: 如果只有一个body,尝试从主body的第一列获取热点序号 // 有时热点序号列可能是主表格的第一列,而不是fixed-left列 if (allBodies.length === 1 && bodyRowCells[0]) { const firstColTexts = bodyRowCells[0].map(row => row[0] || ''); const hasHotNums = firstColTexts.some(t => t && t.match(/^\d+$/)); if (hasHotNums) { console.log('[EPC] 检测到热点序号在主body第一列'); bodyRowCells[1] = bodyRowCells[0].map(row => [row[0] || '']); } } // 方案E: 如果仍然没有热点序号,尝试克隆第一列作为最终兜底 // 注意:只有当第一列看起来是数字时才使用,否则留空 if ((allBodies.length < 2 || !bodyRowCells[1]) && bodyRowCells[0]) { const firstColTexts = bodyRowCells[0].map(row => row[0] || ''); // 检查第一列是否全是数字(热点序号通常是数字) const allNumeric = firstColTexts.length > 0 && firstColTexts.every(t => !t || t.match(/^\d+$/)); if (allNumeric) { console.log('[EPC] 使用克隆第一列作为热点序号兜底方案(检测到第一列全是数字)'); bodyRowCells[1] = bodyRowCells[0].map(row => [row[0] || '']); } else { console.log('[EPC] 第一列不是全数字,不使用克隆兜底,热点序号将留空'); } } // 组装明细:body[0] 取编码/名称/用量,body[1] 取热点序号 const mainRows = bodyRowCells[0]; if (mainRows) { // 先尝试从DOM获取热点序号 let hotNumFromDOM = null; if (bodyRowCells[1] && bodyRowCells[1].length > 0) { hotNumFromDOM = bodyRowCells[1]; } // 如果DOM没有热点序号,尝试从API数据获取 let hotNumFromAPI = null; if (!hotNumFromDOM) { hotNumFromAPI = extractHotNumsFromAPI(mainRows.length); } for (let ri = 0; ri < mainRows.length; ri++) { const mainCells = mainRows[ri]; const code = mainCells[1] || ''; const name = mainCells[2] || ''; const qty = mainCells[3] || ''; let hotNum = ''; // 优先使用DOM获取的热点序号 if (hotNumFromDOM && hotNumFromDOM[ri] && hotNumFromDOM[ri][0]) { hotNum = hotNumFromDOM[ri][0]; } // 兜底:使用API数据获取的热点序号 if (!hotNum && hotNumFromAPI && hotNumFromAPI[ri]) { hotNum = hotNumFromAPI[ri]; } if (code || name) { detailRows.push({ hotNum, code, name, qty }); } } } } console.log('[EPC] 明细提取: ' + detailRows.length + '条, 热点序号示例: ' + (detailRows.slice(0,3).map(r=>r.hotNum).join(',') || '(空)')); if (detailRows.length === 0 && !imgOrigUrl && !imgFallbackData) { toast('未获取到明细数据和图片', '#f56c6c'); return; } // 5. 构建 HTML(左明细可滚动,右图片固定) 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 tableBodyHtml = detailRows.length > 0 ? detailRows.map(r => `${r.hotNum||''}${r.code||''}${r.name||''}${r.qty||''}`).join('') : '无明细数据'; // 图片策略:URL 为主(最清晰),base64 为兜底(URL 过期时自动切换) let imgHtml = '
无图片
'; if (imgOrigUrl && imgFallbackData) { // 有 URL 和 base64:URL 优先,加载失败时自动切换到 base64 imgHtml = `${displayName}`; } else if (imgOrigUrl) { imgHtml = `${displayName}`; } else if (imgFallbackData) { imgHtml = `${displayName}`; } const html = ` ${displayName}

📋 ${displayName}

导出时间:${timeStr} | 共 ${detailRows.length} 条明细${imgNaturalW ? ' | 原图 ' + imgNaturalW + '×' + imgNaturalH : ''}

📋 零件明细

${tableBodyHtml}
热点序号 编码 名称 用量
🖼️ 零件图 滚轮缩放·拖动·双击复位
${imgHtml}
100%
`; // 6. 文件名(清理非法字符) const safeName = displayName.replace(/[\\/:*?"<>|]/g, '_').substring(0, 120); const filename = `${safeName}.html`; downloadHTML(filename, html); console.log('[EPC] 当前选中明细导出: 名称="' + displayName + '" 明细=' + detailRows.length + '条 图片=' + ((imgOrigUrl || imgFallbackData) ? '有' : '无')); toast(`✅ 已导出:${safeName}(${detailRows.length} 条明细)`, '#67c23a', 3000); } catch (e) { console.error('[EPC] 导出当前选中明细失败:', e); toast('导出失败: ' + (e.message || e), '#f56c6c', 3000); } finally { W.__epcBusy = false; try { exportToast.remove(); } catch(e) {} } }; // ============================================================ // ★ 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(); } else if (action === 'export-selection') { await exportCurrentSelection(); } }); // ============================================================ // 拖拽功能(彻底重写:稳定版本) // ============================================================ 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); // ★ 关键修复:等待密码框真正同步有值后再点击登录 // 滑块验证过程中 dispatch 的事件可能干扰 Vue 表单状态, // 立即点击登录会提示"密码未输入",需要轮询等待密码框有值 var tryCount = 0; var pwdReady = false; while (tryCount < 20) { tryCount++; await wait(150); var pwdInputs = document.querySelectorAll('input[type="password"], input[placeholder*="密码"]'); var userInputs = document.querySelectorAll('input[type="text"], input[placeholder*="用户"], input[placeholder*="账号"]'); var pwdOk = false, userOk = false; for (var pi = 0; pi < pwdInputs.length; pi++) { if (pwdInputs[pi].value && pwdInputs[pi].value.length > 0) { pwdOk = true; break; } } for (var ui = 0; ui < userInputs.length; ui++) { if (userInputs[ui].value && userInputs[ui].value.length > 0) { userOk = true; break; } } if (pwdOk && userOk) { pwdReady = true; break; } log('等待账号密码就绪... try=' + tryCount + ' pwdOk=' + pwdOk + ' userOk=' + userOk); } if (pwdReady) { await wait(200); var loginBtn = findLoginBtn(); if (loginBtn) { loginBtn.click(); log('已点击登录按钮(账号密码已就绪)'); showToast('🚀 正在登录...', '#409eff', 1500); } } else { log('⚠️ 账号或密码未就绪,请手动点击登录'); showToast('⚠️ 账号或密码未就绪,请手动点击登录', '#e6a23c', 3000); } } 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(); } })();