/** * MicroLink Web Serial Terminal * 使用 Web Serial API 实现浏览器直接访问串口 */ class MicroLinkTerminal { constructor() { this.port = null; this.reader = null; this.writer = null; this.isConnected = false; this.customCommands = []; // 数据缓冲相关 this.dataBuffer = ''; this.lineTimeoutId = null; this.bufferStartTime = null; // 记录缓冲区开始接收数据的时间 // 终端输入相关 this.commandHistory = []; this.historyIndex = -1; this.logMessages = []; // 虚拟终端配置 this.virtualTerminalMode = true; // 启用虚拟终端模式,实时发送按键 this.virtualTerminalModeOriginalValue = true; // 保存用户原始设置值 this.currentLine = ''; // 当前行缓冲 this.initializeElements(); this.bindEvents(); this.updateSendOptions(); // 初始化发送选项状态 this.loadParameters(); this.checkWebSerialSupport(); } initializeElements() { // 串口配置元素 this.baudRateSelect = document.getElementById('baudRate'); this.customBaudRateInput = document.getElementById('customBaudRate'); this.dataBitsSelect = document.getElementById('dataBits'); this.paritySelect = document.getElementById('parity'); this.stopBitsSelect = document.getElementById('stopBits'); // 连接控制元素 this.connectBtn = document.getElementById('connectBtn'); this.disconnectBtn = document.getElementById('disconnectBtn'); this.clearBtn = document.getElementById('clearBtn'); this.saveLogBtn = document.getElementById('saveLogBtn'); this.connectionStatus = document.getElementById('connectionStatus'); // 显示选项元素 this.hexModeCheckbox = document.getElementById('hexMode'); this.showTimestampCheckbox = document.getElementById('showTimestamp'); this.autoScrollCheckbox = document.getElementById('autoScroll'); this.enableBufferCheckbox = document.getElementById('enableBuffer'); this.virtualTerminalModeCheckbox = document.getElementById('virtualTerminalMode'); this.processAnsiSequencesCheckbox = document.getElementById('processAnsiSequences'); this.debugModeCheckbox = document.getElementById('debugMode'); this.lineTimeoutInput = document.getElementById('lineTimeout'); // 终端元素 this.terminal = document.getElementById('terminal'); this.terminalOutput = document.getElementById('terminalOutput'); this.terminalInputField = document.getElementById('terminalInputField'); // API 控制元素 this.rttAddrInput = document.getElementById('rttAddr'); this.rttSizeInput = document.getElementById('rttSize'); this.rttChannelInput = document.getElementById('rttChannel'); this.startRTTBtn = document.getElementById('startRTT'); this.stopRTTBtn = document.getElementById('stopRTT'); this.svAddrInput = document.getElementById('svAddr'); this.svSizeInput = document.getElementById('svSize'); this.svChannelInput = document.getElementById('svChannel'); this.startSystemViewBtn = document.getElementById('startSystemView'); this.flmPathInput = document.getElementById('flmPath'); this.baseAddrInput = document.getElementById('baseAddr'); this.ramAddrInput = document.getElementById('ramAddr'); this.loadFLMBtn = document.getElementById('loadFLM'); this.binPathInput = document.getElementById('binPath'); this.binAddrInput = document.getElementById('binAddr'); this.loadBinBtn = document.getElementById('loadBin'); this.offlineDownloadBtn = document.getElementById('offlineDownload'); this.ymodemFileInput = document.getElementById('ymodemFile'); this.ymodemSendBtn = document.getElementById('ymodemSend'); this.customCommandInput = document.getElementById('customCommand'); this.sendCustomBtn = document.getElementById('sendCustom'); this.addCustomBtn = document.getElementById('addCustom'); this.customCommandsList = document.getElementById('customCommandsList'); this.saveParamsBtn = document.getElementById('saveParams'); this.saveToFileBtn = document.getElementById('saveToFile'); this.loadParamsBtn = document.getElementById('loadParams'); this.resetParamsBtn = document.getElementById('resetParams'); this.loadConfigFileBtn = document.getElementById('loadConfigFile'); this.configFileInput = document.getElementById('configFileInput'); } bindEvents() { // 波特率选择事件 this.baudRateSelect.addEventListener('change', () => { if (this.baudRateSelect.value === 'custom') { this.customBaudRateInput.style.display = 'block'; } else { this.customBaudRateInput.style.display = 'none'; } }); // 连接控制事件 this.connectBtn.addEventListener('click', () => this.connectSerial()); this.disconnectBtn.addEventListener('click', () => this.disconnectSerial()); this.clearBtn.addEventListener('click', () => this.clearTerminal()); this.saveLogBtn.addEventListener('click', () => this.saveLog()); // 终端输入事件 this.terminalInputField.addEventListener('keydown', (e) => this.handleTerminalInput(e)); this.terminalInputField.addEventListener('focus', () => this.scrollToBottom()); // 终端发送按钮事件 this.terminalSendBtn = document.getElementById('terminalSendBtn'); if (this.terminalSendBtn) { this.terminalSendBtn.addEventListener('click', () => this.executeTerminalCommand()); } // 虚拟终端配置事件 this.virtualTerminalModeCheckbox.addEventListener('change', () => { this.virtualTerminalMode = this.virtualTerminalModeCheckbox.checked; // 只有在非HEX模式下才保存用户的原始设置 if (!this.hexModeCheckbox.checked) { this.virtualTerminalModeOriginalValue = this.virtualTerminalModeCheckbox.checked; } this.updateTerminalPlaceholder(); }); // HEX模式切换事件 this.hexModeCheckbox.addEventListener('change', () => { this.updateSendOptions(); }); // API 控制事件 this.startRTTBtn.addEventListener('click', () => this.startRTT()); this.stopRTTBtn.addEventListener('click', () => this.stopRTT()); this.startSystemViewBtn.addEventListener('click', () => this.startSystemView()); this.loadFLMBtn.addEventListener('click', () => this.loadFLM()); this.loadBinBtn.addEventListener('click', () => this.loadBin()); this.offlineDownloadBtn.addEventListener('click', () => this.offlineDownload()); this.ymodemSendBtn.addEventListener('click', () => this.ymodemSend()); this.sendCustomBtn.addEventListener('click', () => this.sendCustomCommand()); this.addCustomBtn.addEventListener('click', () => this.addCustomCommand()); // 参数管理事件 this.saveParamsBtn.addEventListener('click', () => this.saveParameters()); this.saveToFileBtn.addEventListener('click', () => this.saveParametersToFile()); this.loadParamsBtn.addEventListener('click', () => this.loadParameters()); this.resetParamsBtn.addEventListener('click', () => this.resetParameters()); this.loadConfigFileBtn.addEventListener('click', () => this.loadConfigFile()); this.configFileInput.addEventListener('change', (e) => this.handleConfigFileSelect(e)); // 统一YMODEM相关按钮事件绑定 const flmYmodemSendBtn = document.getElementById('flmYmodemSendBtn'); if (flmYmodemSendBtn) { flmYmodemSendBtn.addEventListener('click', () => this.handleFlmYmodemSend()); } // Python发送按钮事件绑定在setupPythonScriptPanel中处理 } checkWebSerialSupport() { if (!('serial' in navigator)) { this.addMessage('错误: 您的浏览器不支持 Web Serial API。请使用 Chrome 89+ 或 Edge 89+', 'error'); this.connectBtn.disabled = true; this.connectBtn.innerHTML = ' 不支持'; } } async connectSerial() { try { // 请求串口权限 this.port = await navigator.serial.requestPort(); // 获取串口配置 const baudRate = this.baudRateSelect.value === 'custom' ? parseInt(this.customBaudRateInput.value) : parseInt(this.baudRateSelect.value); const dataBits = parseInt(this.dataBitsSelect.value); const stopBits = parseInt(this.stopBitsSelect.value); const parity = this.paritySelect.value; // 打开串口 await this.port.open({ baudRate: baudRate, dataBits: dataBits, stopBits: stopBits, parity: parity }); this.isConnected = true; this.updateConnectionStatus(true); // 格式化校验位显示 const parityDisplay = parity === 'none' ? 'N' : parity === 'even' ? 'E' : 'O'; this.addMessage(`串口连接成功 - 波特率: ${baudRate}, 数据位: ${dataBits}${parityDisplay}${stopBits}`, 'info'); // 开始读取数据 this.startReading(); } catch (error) { this.addMessage(`连接失败: ${error.message}`, 'error'); } } async disconnectSerial() { try { // 先设置断开标志,停止读取循环 this.isConnected = false; // 释放reader锁 if (this.reader) { try { await this.reader.cancel(); } catch (e) { // 忽略cancel错误,继续释放锁 console.log('Reader cancel error:', e); } try { this.reader.releaseLock(); } catch (e) { // 忽略releaseLock错误 console.log('Reader releaseLock error:', e); } this.reader = null; } // 释放writer锁 if (this.writer) { try { this.writer.releaseLock(); } catch (e) { console.log('Writer releaseLock error:', e); } this.writer = null; } // 关闭串口 if (this.port) { await this.port.close(); this.port = null; } this.updateConnectionStatus(false); // 清理数据缓冲区 this.flushBuffer(); this.addMessage('串口已断开', 'info'); } catch (error) { this.addMessage(`断开连接失败: ${error.message}`, 'error'); // 强制重置状态 this.isConnected = false; this.reader = null; this.writer = null; this.port = null; this.updateConnectionStatus(false); } } async startReading() { if (!this.port) return; try { this.reader = this.port.readable.getReader(); while (this.isConnected) { const { value, done } = await this.reader.read(); if (done) break; this.handleReceivedData(value); } } catch (error) { if (this.isConnected) { this.addMessage(`读取数据错误: ${error.message}`, 'error'); } } finally { if (this.reader) { try { this.reader.releaseLock(); } catch (e) { console.log('Reader releaseLock in finally:', e); } this.reader = null; } } } handleReceivedData(data) { // 记录数据接收时间 const receiveTime = new Date(); // 检查是否处于曲线绘制模式 if (chartDrawEnabled && window.chartData) { // 曲线绘制模式:直接处理串口数据,不显示在主监控 // console.log(`[曲线模式] 接收到 ${data.length} 字节数据:`, Array.from(data).map(b => b.toString(16).padStart(2, '0').toUpperCase()).join(' ')); // 处理分片数据,确保4字节对齐 handleRealtimeHexDataChunkedSimple(data); // 不显示在主监控,直接返回 return; } if (this.hexModeCheckbox.checked) { // HEX 模式显示 const hexText = Array.from(data) .map(byte => byte.toString(16).padStart(2, '0').toUpperCase()) .join(' '); if (this.enableBufferCheckbox.checked) { // HEX模式也使用缓冲和超时机制 this.bufferHexData(hexText); } else { // 直接显示,不缓冲 this.addMessage(hexText, 'received', receiveTime); } } else { // 文本模式显示 const text = new TextDecoder().decode(data); if (this.enableBufferCheckbox.checked) { // 使用缓冲和超时机制 this.bufferData(text); } else { // 直接显示,不缓冲,使用接收时间作为时间戳 this.addMessage(text, 'received', receiveTime); } } } bufferData(text) { // 调试模式:显示接收到的原始数据 if (this.debugModeCheckbox.checked) { const debugInfo = `[DEBUG] 接收数据 (${text.length}字节): ${JSON.stringify(text)}`; this.addMessage(debugInfo, 'info'); } // 记录缓冲区开始时间(只在缓冲区为空时记录) if (this.dataBuffer.length === 0) { this.bufferStartTime = new Date(); } // 将新数据添加到缓冲区 this.dataBuffer += text; // 清除之前的超时(这是关键!每次新数据都重置超时) if (this.lineTimeoutId) { clearTimeout(this.lineTimeoutId); this.lineTimeoutId = null; if (this.debugModeCheckbox.checked) { this.addMessage('[DEBUG] 重置超时计时器', 'info'); } } // 检查是否包含明确的结束标志 const hasLineEnding = this.dataBuffer.includes('\n') || this.dataBuffer.includes('\r'); // 检查是否是单独的shell提示符(应该保持在同一行) const isStandalonePrompt = this.dataBuffer.match(/^sh:\/\$\s*$/) || this.dataBuffer.match(/^>>>\s*$/) || this.dataBuffer.match(/^>\s*$/); // 检查是否包含完整的命令输出(以换行符+提示符结尾) const hasCompleteOutput = this.dataBuffer.match(/\n\s*sh:\/\$\s*$/) || this.dataBuffer.match(/\r\n\s*sh:\/\$\s*$/) || this.dataBuffer.match(/\n\s*>>>\s*$/) || this.dataBuffer.match(/\r\n\s*>>>\s*$/); // 检查是否包含完整的命令列表结束 const hasCommandListEnd = this.dataBuffer.includes('Command List:') && (this.dataBuffer.includes('\nsh:/$ ') || this.dataBuffer.includes('\r\nsh:/$ ')); // 检查缓冲区大小,如果太大就强制刷新 const bufferTooLarge = this.dataBuffer.length > 2000; // 立即刷新的条件 if (hasCompleteOutput || hasCommandListEnd || bufferTooLarge) { // 立即刷新,不等待超时 this.flushBuffer(); return; } // 如果是单独的提示符,不立即刷新,等待后续内容 if (isStandalonePrompt) { // 使用较长的超时等待后续内容 const timeout = parseInt(this.lineTimeoutInput.value) || 50; this.lineTimeoutId = setTimeout(() => { this.flushBuffer(); }, timeout * 2); // 使用双倍超时时间 return; } // 如果包含换行符,检查是否是完整的行 if (hasLineEnding) { // 检查是否以换行符结尾(完整的行) const endsWithNewline = this.dataBuffer.endsWith('\n') || this.dataBuffer.endsWith('\r\n') || this.dataBuffer.endsWith('\r'); if (endsWithNewline) { // 完整的行,使用较短的超时 if (this.debugModeCheckbox.checked) { this.addMessage(`[DEBUG] 检测到完整行,使用短超时(10ms)`, 'info'); } this.lineTimeoutId = setTimeout(() => { this.flushBuffer(); }, 10); } else { // 包含换行符但不以换行符结尾,可能还有更多数据,使用配置的超时 const timeout = parseInt(this.lineTimeoutInput.value) || 50; if (this.debugModeCheckbox.checked) { this.addMessage(`[DEBUG] 包含换行符但不完整,使用配置超时(${timeout}ms)`, 'info'); } this.lineTimeoutId = setTimeout(() => { this.flushBuffer(); }, timeout); } return; } // 默认情况下,设置超时等待更多数据 const baseTimeout = parseInt(this.lineTimeoutInput.value) || 50; // 根据数据特征调整超时时间 let timeout = baseTimeout; // 如果包含ANSI序列,使用更短的超时 if (this.dataBuffer.includes('\x1b[')) { timeout = Math.min(baseTimeout, 20); if (this.debugModeCheckbox.checked) { this.addMessage(`[DEBUG] 检测到ANSI序列,调整超时为${timeout}ms`, 'info'); } } // 如果数据看起来像是命令输出的一部分,使用更短的超时 if (this.dataBuffer.includes('CMD') || this.dataBuffer.includes('--------')) { timeout = Math.min(baseTimeout, 15); if (this.debugModeCheckbox.checked) { this.addMessage(`[DEBUG] 检测到命令输出,调整超时为${timeout}ms`, 'info'); } } if (this.debugModeCheckbox.checked) { this.addMessage(`[DEBUG] 设置默认超时: ${timeout}ms (基础: ${baseTimeout}ms)`, 'info'); } this.lineTimeoutId = setTimeout(() => { this.flushBuffer(); }, timeout); } bufferHexData(hexText) { // 调试模式:显示接收到的原始HEX数据 if (this.debugModeCheckbox.checked) { const debugInfo = `[DEBUG] 接收HEX数据: ${hexText}`; this.addMessage(debugInfo, 'info'); } // 记录缓冲区开始时间(只在缓冲区为空时记录) if (this.dataBuffer.length === 0) { this.bufferStartTime = new Date(); } // 将新的HEX数据添加到缓冲区(用空格分隔) if (this.dataBuffer.length > 0) { this.dataBuffer += ' ' + hexText; } else { this.dataBuffer = hexText; } // 清除之前的超时(每次新数据都重置超时) if (this.lineTimeoutId) { clearTimeout(this.lineTimeoutId); this.lineTimeoutId = null; if (this.debugModeCheckbox.checked) { this.addMessage('[DEBUG] 重置HEX超时计时器', 'info'); } } // HEX模式下的缓冲逻辑相对简单,主要基于超时 const timeout = parseInt(this.lineTimeoutInput.value) || 50; // 检查缓冲区大小,如果太大就强制刷新 const bufferTooLarge = this.dataBuffer.length > 1000; // HEX数据较长,适当增加限制 if (bufferTooLarge) { if (this.debugModeCheckbox.checked) { this.addMessage('[DEBUG] HEX缓冲区过大,强制刷新', 'info'); } this.flushBuffer(); return; } if (this.debugModeCheckbox.checked) { this.addMessage(`[DEBUG] 设置HEX超时: ${timeout}ms`, 'info'); } this.lineTimeoutId = setTimeout(() => { this.flushBuffer(); }, timeout); } flushBuffer() { if (this.dataBuffer.length > 0) { if (this.debugModeCheckbox.checked) { this.addMessage(`[DEBUG] 刷新缓冲区 (${this.dataBuffer.length}字节)`, 'info'); } // 根据配置决定是否处理ANSI序列 let processedData = this.processAnsiSequencesCheckbox.checked ? this.processAnsiSequences(this.dataBuffer) : this.dataBuffer; // 显示处理后的数据,使用缓冲区开始时间作为时间戳 this.addMessage(processedData, 'received', this.bufferStartTime); // 清空缓冲区和时间戳 this.dataBuffer = ''; this.bufferStartTime = null; } // 清除超时 if (this.lineTimeoutId) { clearTimeout(this.lineTimeoutId); this.lineTimeoutId = null; } } processAnsiSequences(text) { // 处理ANSI转义序列,使其在HTML中正确显示 let processed = text; // 首先处理特殊的控制序列 // 处理 [2K (清除整行) processed = processed.replace(/\x1b\[2K/g, ''); // 处理光标移动序列 processed = processed.replace(/\x1b\[[0-9]+;[0-9]+H/g, ''); processed = processed.replace(/\x1b\[[0-9]+;[0-9]+f/g, ''); // 处理回车符覆盖行为(这是关键!) // 当遇到 \r 但没有 \n 时,表示要覆盖当前行 processed = this.processCarriageReturn(processed); // 处理ANSI颜色和格式代码 processed = this.processAnsiColors(processed); // 移除其他未处理的ANSI转义序列 processed = processed.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, ''); // 移除一些控制字符,但保留换行符和制表符 processed = processed.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); // 清理多余的空行(连续的换行符) processed = processed.replace(/\n{3,}/g, '\n\n'); return processed; } processCarriageReturn(text) { // 处理回车符的覆盖行为 let processed = text; // 检查是否包含需要处理的覆盖模式 if (processed.includes('\r') && !processed.includes('\r\n')) { // 只处理特定的shell提示符覆盖模式 // 模式1: 调试信息被提示符覆盖 (如: "2025-05-30 11:21:01 DEBUG: MQTT...\rsh:/$ ") processed = processed.replace(/(.*DEBUG[^\r]*)\r(sh:\/\$\s*)/g, (match, content, prompt) => { // 如果调试信息很长,保留它并添加换行,然后显示提示符 if (content.length > 10) { return content + '\n' + prompt; } else { return prompt; // 短内容直接被覆盖 } }); // 模式2: 其他内容被提示符覆盖,但保留有意义的内容 processed = processed.replace(/([^\r\n]{10,})\r(sh:\/\$\s*|>>>\s*|>\s*)$/gm, '$1\n$2'); // 模式3: 短内容被提示符覆盖(真正的覆盖行为) processed = processed.replace(/([^\r\n]{1,9})\r(sh:\/\$\s*|>>>\s*|>\s*)$/gm, '$2'); } return processed; } processAnsiColors(text) { let processed = text; // 定义ANSI颜色映射(使用终端友好的颜色) const ansiColors = { // 重置 '0': { action: 'reset' }, // 文本样式 '1': { style: 'font-weight: bold;' }, // 粗体 '2': { style: 'opacity: 0.6;' }, // 暗淡 '3': { style: 'font-style: italic;' }, // 斜体 '4': { style: 'text-decoration: underline;' }, // 下划线 '5': { style: 'animation: blink 1s infinite;' }, // 闪烁 '7': { style: 'filter: invert(1);' }, // 反色 '9': { style: 'text-decoration: line-through;' }, // 删除线 // 前景色(标准颜色) '30': { color: '#2e3436' }, // 黑色 '31': { color: '#cc0000' }, // 红色 '32': { color: '#4e9a06' }, // 绿色 '33': { color: '#c4a000' }, // 黄色 '34': { color: '#3465a4' }, // 蓝色 '35': { color: '#75507b' }, // 洋红 '36': { color: '#06989a' }, // 青色 '37': { color: '#d3d7cf' }, // 白色 // 前景色(高亮颜色) '90': { color: '#555753' }, // 亮黑色(灰色) '91': { color: '#ef2929' }, // 亮红色 '92': { color: '#8ae234' }, // 亮绿色 '93': { color: '#fce94f' }, // 亮黄色 '94': { color: '#729fcf' }, // 亮蓝色 '95': { color: '#ad7fa8' }, // 亮洋红 '96': { color: '#34e2e2' }, // 亮青色 '97': { color: '#eeeeec' }, // 亮白色 // 背景色(标准颜色) '40': { backgroundColor: '#2e3436' }, // 黑色背景 '41': { backgroundColor: '#cc0000' }, // 红色背景 '42': { backgroundColor: '#4e9a06' }, // 绿色背景 '43': { backgroundColor: '#c4a000' }, // 黄色背景 '44': { backgroundColor: '#3465a4' }, // 蓝色背景 '45': { backgroundColor: '#75507b' }, // 洋红背景 '46': { backgroundColor: '#06989a' }, // 青色背景 '47': { backgroundColor: '#d3d7cf' }, // 白色背景 // 背景色(高亮颜色) '100': { backgroundColor: '#555753' }, // 亮黑色背景 '101': { backgroundColor: '#ef2929' }, // 亮红色背景 '102': { backgroundColor: '#8ae234' }, // 亮绿色背景 '103': { backgroundColor: '#fce94f' }, // 亮黄色背景 '104': { backgroundColor: '#729fcf' }, // 亮蓝色背景 '105': { backgroundColor: '#ad7fa8' }, // 亮洋红背景 '106': { backgroundColor: '#34e2e2' }, // 亮青色背景 '107': { backgroundColor: '#eeeeec' }, // 亮白色背景 }; // 处理ANSI转义序列 processed = processed.replace(/\x1b\[([0-9;]*)m/g, (match, codes) => { if (!codes) codes = '0'; // 空代码默认为重置 const codeList = codes.split(';'); let styles = []; let hasReset = false; let i = 0; while (i < codeList.length) { const code = codeList[i]; // 处理256色和RGB颜色 if (code === '38' || code === '48') { // 前景色或背景色 const isBackground = code === '48'; i++; if (i < codeList.length && codeList[i] === '5') { // 256色模式: ESC[38;5;n m 或 ESC[48;5;n m i++; if (i < codeList.length) { const colorIndex = parseInt(codeList[i]); const color = this.get256Color(colorIndex); if (isBackground) { styles.push(`background-color: ${color}`); } else { styles.push(`color: ${color}`); } } } else if (i < codeList.length && codeList[i] === '2') { // RGB模式: ESC[38;2;r;g;b m 或 ESC[48;2;r;g;b m i++; if (i + 2 < codeList.length) { const r = parseInt(codeList[i]); const g = parseInt(codeList[i + 1]); const b = parseInt(codeList[i + 2]); const color = `rgb(${r}, ${g}, ${b})`; if (isBackground) { styles.push(`background-color: ${color}`); } else { styles.push(`color: ${color}`); } i += 2; } } } else { // 处理标准ANSI代码 const ansiCode = ansiColors[code]; if (ansiCode) { if (ansiCode.action === 'reset') { hasReset = true; break; } else { if (ansiCode.color) styles.push(`color: ${ansiCode.color}`); if (ansiCode.backgroundColor) styles.push(`background-color: ${ansiCode.backgroundColor}`); if (ansiCode.style) styles.push(ansiCode.style); } } } i++; } if (hasReset) { return ''; } else if (styles.length > 0) { return ``; } else { return ''; // 未知代码,移除 } }); return processed; } get256Color(index) { // 256色调色板 if (index < 16) { // 标准16色 const colors = [ '#000000', '#800000', '#008000', '#808000', '#000080', '#800080', '#008080', '#c0c0c0', '#808080', '#ff0000', '#00ff00', '#ffff00', '#0000ff', '#ff00ff', '#00ffff', '#ffffff' ]; return colors[index]; } else if (index < 232) { // 216色立方体 (6x6x6) const i = index - 16; const r = Math.floor(i / 36); const g = Math.floor((i % 36) / 6); const b = i % 6; const toHex = (n) => { const values = [0, 95, 135, 175, 215, 255]; return values[n].toString(16).padStart(2, '0'); }; return `#${toHex(r)}${toHex(g)}${toHex(b)}`; } else { // 24级灰度 const gray = 8 + (index - 232) * 10; const hex = gray.toString(16).padStart(2, '0'); return `#${hex}${hex}${hex}`; } } escapeRegExp(string) { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } // MicroLink API 方法 async startRTT() { const addr = this.rttAddrInput.value; const size = this.rttSizeInput.value; const channel = this.rttChannelInput.value; const command = `RTTView.start(${addr}, ${size}, ${channel})`; await this.sendCommand(command); } async stopRTT() { const command = 'RTTView.stop()'; await this.sendCommand(command); } async startSystemView() { const addr = this.svAddrInput.value; const size = this.svSizeInput.value; const channel = this.svChannelInput.value; const command = `SystemView.start(${addr}, ${size}, ${channel})`; await this.sendCommand(command); } async loadFLM() { const flmPath = this.flmPathInput.value; const baseAddr = this.baseAddrInput.value; const ramAddr = this.ramAddrInput.value; const command = `ReadFlm.load("${flmPath}", ${baseAddr}, ${ramAddr})`; await this.sendCommand(command); } async loadBin() { const binPath = this.binPathInput.value; const binAddr = this.binAddrInput.value; const command = `load.bin("${binPath}", ${binAddr})`; await this.sendCommand(command); } async offlineDownload() { const command = 'load.offline()'; await this.sendCommand(command); } async ymodemSend() { const filePath = this.ymodemFileInput.value; const command = `ym.send("${filePath}")`; await this.sendCommand(command); } async sendCustomCommand() { const command = this.customCommandInput.value.trim(); if (command) { if (this.hexModeCheckbox.checked) { // HEX模式:发送十六进制数据 await this.sendHexData(command); } else { // 文本模式:发送普通命令 await this.sendCommand(command); } } } async sendCommand(command) { if (!this.isConnected) { this.addMessage('请先连接串口', 'error'); return; } try { const dataToSend = new TextEncoder().encode(command + '\r\n'); if (!this.writer) { this.writer = this.port.writable.getWriter(); } await this.writer.write(dataToSend); this.addMessage(`命令: ${command}`, 'sent'); } catch (error) { this.addMessage(`发送命令失败: ${error.message}`, 'error'); } } addCustomCommand() { const command = this.customCommandInput.value.trim(); if (command && !this.customCommands.includes(command)) { this.customCommands.push(command); this.updateCustomCommandsList(); this.customCommandInput.value = ''; } } updateCustomCommandsList() { this.customCommandsList.innerHTML = ''; this.customCommands.forEach((command, index) => { const item = document.createElement('div'); item.className = 'custom-command-item'; item.textContent = command; item.addEventListener('click', () => { this.customCommandInput.value = command; }); this.customCommandsList.appendChild(item); }); } addMessage(text, type = 'info', customTimestamp = null) { const message = document.createElement('div'); message.className = `message message-${type}`; // 使用自定义时间戳或当前时间 const messageTime = customTimestamp || new Date(); let content = ''; if (this.showTimestampCheckbox.checked) { const hours = messageTime.getHours().toString().padStart(2, '0'); const minutes = messageTime.getMinutes().toString().padStart(2, '0'); const seconds = messageTime.getSeconds().toString().padStart(2, '0'); const milliseconds = messageTime.getMilliseconds().toString().padStart(3, '0'); const timestamp = `${hours}:${minutes}:${seconds}.${milliseconds}`; content += `[${timestamp}]`; } content += text; message.innerHTML = content; // 保存到日志记录中,使用消息的实际时间戳 this.logMessages.push({ timestamp: messageTime, type: type, content: text }); this.terminalOutput.appendChild(message); if (this.autoScrollCheckbox.checked) { this.scrollToBottom(); } } scrollToBottom() { this.terminalOutput.scrollTop = this.terminalOutput.scrollHeight; } updateTerminalPlaceholder() { if (this.isConnected) { if (this.hexModeCheckbox.checked) { this.terminalInputField.placeholder = 'HEX模式:输入十六进制数据(如:41 42 43 或 414243)后按Enter发送...'; } else { this.terminalInputField.placeholder = '输入命令后点击发送或按Enter键发送...'; } } else { this.terminalInputField.placeholder = '请先连接串口...'; } } updateSendOptions() { const isHexMode = this.hexModeCheckbox.checked; if (isHexMode) { // HEX模式下:保存当前设置并禁用虚拟终端模式 this.virtualTerminalModeOriginalValue = this.virtualTerminalModeCheckbox.checked; this.virtualTerminalModeCheckbox.disabled = true; this.virtualTerminalMode = false; // 功能上禁用,但保持复选框原始状态 // 添加视觉提示 const label = this.virtualTerminalModeCheckbox.parentElement; label.style.opacity = '0.5'; label.title = 'HEX模式下虚拟终端功能不可用'; } else { // 文本模式下:恢复虚拟终端模式 this.virtualTerminalModeCheckbox.disabled = false; this.virtualTerminalModeCheckbox.checked = this.virtualTerminalModeOriginalValue; this.virtualTerminalMode = this.virtualTerminalModeOriginalValue; // 移除视觉提示 const label = this.virtualTerminalModeCheckbox.parentElement; label.style.opacity = '1'; label.title = ''; } // 更新终端占位符 this.updateTerminalPlaceholder(); } clearTerminal() { if (confirm('确定要清空终端内容吗?')) { this.terminalOutput.innerHTML = ''; this.logMessages = []; this.addMessage('终端已清空', 'info'); } } updateConnectionStatus(connected) { if (connected) { this.connectionStatus.className = 'status-connected'; this.connectionStatus.innerHTML = ' 已连接'; this.connectBtn.disabled = true; this.disconnectBtn.disabled = false; this.terminalInputField.disabled = false; if (this.terminalSendBtn) this.terminalSendBtn.disabled = false; this.updateTerminalPlaceholder(); } else { this.connectionStatus.className = 'status-disconnected'; this.connectionStatus.innerHTML = ' 未连接'; this.connectBtn.disabled = false; this.disconnectBtn.disabled = true; this.terminalInputField.disabled = true; if (this.terminalSendBtn) this.terminalSendBtn.disabled = true; this.updateTerminalPlaceholder(); } } // 参数管理方法 saveParameters() { const params = { baudRate: this.baudRateSelect.value, customBaudRate: this.customBaudRateInput.value, dataBits: this.dataBitsSelect.value, parity: this.paritySelect.value, stopBits: this.stopBitsSelect.value, rttAddr: this.rttAddrInput.value, rttSize: this.rttSizeInput.value, rttChannel: this.rttChannelInput.value, svAddr: this.svAddrInput.value, svSize: this.svSizeInput.value, svChannel: this.svChannelInput.value, flmPath: this.flmPathInput.value, baseAddr: this.baseAddrInput.value, ramAddr: this.ramAddrInput.value, binPath: this.binPathInput.value, binAddr: this.binAddrInput.value, ymodemFile: this.ymodemFileInput.value, customCommands: this.customCommands, virtualTerminalMode: this.virtualTerminalModeOriginalValue, processAnsiSequences: this.processAnsiSequencesCheckbox.checked }; localStorage.setItem('microlinkParams', JSON.stringify(params)); this.addMessage('✅ 参数已保存到本地存储', 'info'); } async saveParametersToFile() { try { // 收集当前参数 const params = this.getCurrentParameters(); // 生成配置文件内容 const configContent = this.generateConfigFileContent(params); // 检查是否支持File System Access API if ('showSaveFilePicker' in window) { await this.saveWithFilePicker(configContent); } else { // 降级到传统下载方式 this.saveWithDownload(configContent); } } catch (error) { if (error.name === 'AbortError') { this.addMessage('💡 用户取消了文件保存', 'info'); } else { this.addMessage(`❌ 保存参数到文件失败: ${error.message}`, 'error'); } } } async saveWithFilePicker(configContent) { try { // 使用File System Access API让用户选择保存位置 const fileHandle = await window.showSaveFilePicker({ suggestedName: 'microlink_web_scp.txt', types: [ { description: 'MicroLink配置文件', accept: { 'text/plain': ['.txt'], }, }, ], }); // 创建可写流并写入内容 const writable = await fileHandle.createWritable(); await writable.write(configContent); await writable.close(); this.addMessage('✅ 参数已保存到指定位置', 'info'); } catch (error) { if (error.name === 'AbortError') { throw error; // 重新抛出取消错误 } else { // 如果File System Access API失败,降级到下载方式 this.addMessage('⚠️ 文件选择器不可用,使用下载方式保存', 'info'); this.saveWithDownload(configContent); } } } saveWithDownload(configContent) { // 传统的下载方式 const blob = new Blob([configContent], { type: 'text/plain;charset=utf-8' }); const url = URL.createObjectURL(blob); // 创建下载链接并触发下载 const a = document.createElement('a'); a.href = url; a.download = 'microlink_web_scp.txt'; document.body.appendChild(a); a.click(); document.body.removeChild(a); // 清理URL对象 URL.revokeObjectURL(url); this.addMessage('✅ 参数已保存到下载文件夹 microlink_web_scp.txt', 'info'); } getCurrentParameters() { return { baudRate: this.baudRateSelect.value, customBaudRate: this.customBaudRateInput.value, dataBits: this.dataBitsSelect.value, parity: this.paritySelect.value, stopBits: this.stopBitsSelect.value, rttAddr: this.rttAddrInput.value, rttSize: this.rttSizeInput.value, rttChannel: this.rttChannelInput.value, svAddr: this.svAddrInput.value, svSize: this.svSizeInput.value, svChannel: this.svChannelInput.value, flmPath: this.flmPathInput.value, baseAddr: this.baseAddrInput.value, ramAddr: this.ramAddrInput.value, binPath: this.binPathInput.value, binAddr: this.binAddrInput.value, ymodemFile: this.ymodemFileInput.value, customCommands: this.customCommands, virtualTerminalMode: this.virtualTerminalModeOriginalValue, processAnsiSequences: this.processAnsiSequencesCheckbox.checked }; } generateConfigFileContent(params) { // 生成配置文件格式的内容 const lines = [ '# MicroLink Web Serial Configuration Parameters', '# 串口配置', 'port=COM3', `baudrate=${params.baudRate}`, `databits=${params.dataBits}`, `parity=${params.parity === 'none' ? 'N' : params.parity === 'even' ? 'E' : 'O'}`, `stopbits=${params.stopBits}`, '', '# RTT配置', `rtt_addr=${params.rttAddr}`, `rtt_size=${params.rttSize}`, `rtt_channel=${params.rttChannel}`, '', '# SystemView配置', `systemview_addr=${params.svAddr}`, `systemview_size=${params.svSize}`, `systemview_channel=${params.svChannel}`, '', '# FLM配置', `flm_path=${params.flmPath}`, `base_addr=${params.baseAddr}`, `ram_addr=${params.ramAddr}`, '', '# 下载配置', `bin_file_path=${params.binPath}`, `bin_addr=${params.binAddr}`, '', '# 自定义命令', `custom_commands=${params.customCommands.join(';')}`, '' ]; return lines.join('\n'); } async loadParameters() { // 直接从HTML配置加载 this.loadConfigFromHTML(); // 然后检查是否有用户保存的参数覆盖 const saved = localStorage.getItem('microlinkParams'); if (saved) { try { const params = JSON.parse(saved); this.applyParameters(params); this.addMessage('✅ 用户保存的参数已加载并覆盖HTML配置', 'info'); } catch (error) { this.addMessage('❌ 用户参数格式错误,使用HTML配置参数', 'error'); } } } async loadConfigFile() { // 手动重新加载HTML配置(按钮触发) this.addMessage('🔄 手动重新加载HTML配置...', 'info'); try { const configElement = document.getElementById('embedded-config'); if (configElement) { const configText = configElement.textContent; const params = this.parseConfigFile(configText); this.applyParameters(params); this.addMessage('✅ 参数已从HTML配置重新加载', 'info'); return; } else { this.addMessage('❌ HTML配置元素未找到', 'error'); } } catch (error) { this.addMessage(`从HTML配置加载失败: ${error.message}`, 'error'); } // 如果HTML配置失败,使用备用配置 this.addMessage('⚠️ HTML配置加载失败,使用备用配置', 'info'); this.loadEmbeddedConfig(); } loadConfigFromHTML() { try { // 从HTML中的script标签读取配置 const configElement = document.getElementById('embedded-config'); if (configElement) { const configText = configElement.textContent; const params = this.parseConfigFile(configText); this.applyParameters(params); this.addMessage('✅ 参数已从HTML配置加载', 'info'); } else { // 如果HTML中没有配置,使用硬编码的备用配置 this.loadEmbeddedConfig(); } } catch (error) { this.addMessage(`从HTML配置加载失败: ${error.message}`, 'error'); this.loadEmbeddedConfig(); } } loadEmbeddedConfig() { // 备用的硬编码配置 const embeddedConfig = `# MicroLink Web Serial Configuration Parameters # 串口配置 port=COM3 baudrate=115200 databits=8 parity=N stopbits=1 # RTT配置 rtt_addr=0x20000000 rtt_size=0x4000 rtt_channel=0 # SystemView配置 systemview_addr=0x20000000 systemview_size=0x4000 systemview_channel=1 # FLM配置 flm_path=STM32/STM32F4xx_1024.FLM.o base_addr=0x08000000 ram_addr=0x20000000 # 下载配置 bin_file_path=firmware.bin bin_addr=0x08000000 # 自定义命令 custom_commands=RTTView.start(0x20000000,1024,0);SystemView.start(0x20000000,1024,1);load.offline()`; const params = this.parseConfigFile(embeddedConfig); this.applyParameters(params); this.addMessage('参数已从备用配置加载', 'info'); } handleConfigFileSelect(event) { const file = event.target.files[0]; if (file) { const reader = new FileReader(); reader.onload = (e) => { try { const configText = e.target.result; const params = this.parseConfigFile(configText); this.applyParameters(params); this.addMessage(`参数已从文件 "${file.name}" 加载`, 'info'); } catch (error) { this.addMessage(`解析配置文件失败: ${error.message}`, 'error'); } }; reader.readAsText(file); } } parseConfigFile(configText) { const params = {}; const lines = configText.split('\n'); for (const line of lines) { const trimmedLine = line.trim(); if (trimmedLine && !trimmedLine.startsWith('#')) { const [key, value] = trimmedLine.split('='); if (key && value) { const trimmedKey = key.trim(); const trimmedValue = value.trim(); // 映射配置文件的键到内部参数名 switch (trimmedKey) { case 'baudrate': params.baudRate = trimmedValue; break; case 'databits': params.dataBits = trimmedValue; break; case 'parity': params.parity = trimmedValue.toLowerCase() === 'n' ? 'none' : trimmedValue.toLowerCase() === 'e' ? 'even' : 'odd'; break; case 'stopbits': params.stopBits = trimmedValue; break; case 'rtt_addr': params.rttAddr = trimmedValue; break; case 'rtt_size': params.rttSize = trimmedValue; break; case 'rtt_channel': params.rttChannel = trimmedValue; break; case 'systemview_addr': params.svAddr = trimmedValue; break; case 'systemview_size': params.svSize = trimmedValue; break; case 'systemview_channel': params.svChannel = trimmedValue; break; case 'flm_path': params.flmPath = trimmedValue; break; case 'base_addr': params.baseAddr = trimmedValue; break; case 'ram_addr': params.ramAddr = trimmedValue; break; case 'bin_file_path': params.binPath = trimmedValue; break; case 'bin_addr': params.binAddr = trimmedValue; break; case 'custom_commands': params.customCommands = trimmedValue.split(';').filter(cmd => cmd.trim()); break; } } } } return params; } resetParameters() { // 重置为默认值(与HTML配置保持一致) const defaultParams = { baudRate: '115200', customBaudRate: '', dataBits: '8', parity: 'none', stopBits: '1', rttAddr: '0x20000000', rttSize: '0x4000', rttChannel: '0', svAddr: '0x20000000', svSize: '0x4000', svChannel: '1', flmPath: 'STM32/STM32F4xx_1024.FLM.o', baseAddr: '0x08000000', ramAddr: '0x20000000', binPath: 'firmware.bin', binAddr: '0x08000000', ymodemFile: 'update.bin', customCommands: [], virtualTerminalMode: true, processAnsiSequences: true }; this.applyParameters(defaultParams); this.addMessage('参数已重置为默认值', 'info'); } applyParameters(params) { this.baudRateSelect.value = params.baudRate || '115200'; this.customBaudRateInput.value = params.customBaudRate || ''; this.dataBitsSelect.value = params.dataBits || '8'; this.paritySelect.value = params.parity || 'none'; this.stopBitsSelect.value = params.stopBits || '1'; this.rttAddrInput.value = params.rttAddr || '0x20000000'; this.rttSizeInput.value = params.rttSize || '0x4000'; this.rttChannelInput.value = params.rttChannel || '0'; this.svAddrInput.value = params.svAddr || '0x20000000'; this.svSizeInput.value = params.svSize || '0x4000'; this.svChannelInput.value = params.svChannel || '1'; this.flmPathInput.value = params.flmPath || 'STM32/STM32F4xx_1024.FLM.o'; this.baseAddrInput.value = params.baseAddr || '0x08000000'; this.ramAddrInput.value = params.ramAddr || '0x20000000'; this.binPathInput.value = params.binPath || 'firmware.bin'; this.binAddrInput.value = params.binAddr || '0x08000000'; this.ymodemFileInput.value = params.ymodemFile || 'update.bin'; this.customCommands = params.customCommands || []; this.updateCustomCommandsList(); // 应用虚拟终端配置 if (params.virtualTerminalMode !== undefined) { this.virtualTerminalModeCheckbox.checked = params.virtualTerminalMode; this.virtualTerminalMode = params.virtualTerminalMode; this.virtualTerminalModeOriginalValue = params.virtualTerminalMode; } if (params.processAnsiSequences !== undefined) { this.processAnsiSequencesCheckbox.checked = params.processAnsiSequences; } // 处理自定义波特率显示 if (this.baudRateSelect.value === 'custom') { this.customBaudRateInput.style.display = 'block'; } else { this.customBaudRateInput.style.display = 'none'; } } // 终端输入处理方法 handleTerminalInput(event) { if (!this.isConnected) { return; // 未连接时不处理按键 } // 现在统一使用传统模式,不再实时发送 this.handleLocalTerminalKey(event); } // 虚拟终端按键处理 async handleVirtualTerminalKey(event) { event.preventDefault(); let keySequence = null; let shouldClearInput = false; switch (event.key) { case 'Enter': keySequence = '\r\n'; this.currentLine = ''; shouldClearInput = true; break; case 'Tab': keySequence = '\t'; shouldClearInput = true; break; case 'ArrowUp': keySequence = '\x1b[A'; // ANSI escape sequence for up arrow shouldClearInput = true; break; case 'ArrowDown': keySequence = '\x1b[B'; // ANSI escape sequence for down arrow shouldClearInput = true; break; case 'ArrowLeft': keySequence = '\x1b[D'; // ANSI escape sequence for left arrow shouldClearInput = true; break; case 'ArrowRight': keySequence = '\x1b[C'; // ANSI escape sequence for right arrow shouldClearInput = true; break; case 'Backspace': keySequence = '\x08'; // Backspace character if (this.currentLine.length > 0) { this.currentLine = this.currentLine.slice(0, -1); } shouldClearInput = true; break; case 'Delete': keySequence = '\x7f'; // DEL character shouldClearInput = true; break; case 'Home': keySequence = '\x1b[H'; shouldClearInput = true; break; case 'End': keySequence = '\x1b[F'; shouldClearInput = true; break; case 'PageUp': keySequence = '\x1b[5~'; shouldClearInput = true; break; case 'PageDown': keySequence = '\x1b[6~'; shouldClearInput = true; break; case 'Escape': keySequence = '\x1b'; shouldClearInput = true; break; default: // 普通字符 if (event.key.length === 1 && !event.ctrlKey && !event.altKey && !event.metaKey) { keySequence = event.key; this.currentLine += event.key; shouldClearInput = true; } // Ctrl组合键 else if (event.ctrlKey && event.key.length === 1) { const char = event.key.toLowerCase(); if (char >= 'a' && char <= 'z') { keySequence = String.fromCharCode(char.charCodeAt(0) - 96); // Ctrl+A = 0x01, etc. shouldClearInput = true; } } break; } if (keySequence) { // 发送按键序列到串口 await this.sendRawData(keySequence); // 在虚拟终端模式下,清除输入框内容,让远程设备控制显示 if (shouldClearInput) { this.terminalInputField.value = ''; this.currentLine = ''; } } } // 传统终端按键处理 handleLocalTerminalKey(event) { switch (event.key) { case 'Enter': event.preventDefault(); this.executeTerminalCommand(); break; case 'ArrowUp': event.preventDefault(); this.navigateHistory(-1); break; case 'ArrowDown': event.preventDefault(); this.navigateHistory(1); break; case 'Tab': event.preventDefault(); // Tab键插入Tab字符 this.insertTabCharacter(); break; } } // 发送原始数据到串口 async sendRawData(data) { if (!this.isConnected || !this.port) { return; } try { const dataToSend = new TextEncoder().encode(data); if (!this.writer) { this.writer = this.port.writable.getWriter(); } await this.writer.write(dataToSend); } catch (error) { this.addMessage(`发送数据失败: ${error.message}`, 'error'); } } // 发送HEX格式数据 async sendHexData(hexString) { if (!this.isConnected) { this.addMessage('请先连接串口', 'error'); return; } try { // 清理输入:移除空格、换行符等 const cleanHex = hexString.replace(/[\s\r\n]/g, ''); // 验证是否为有效的十六进制字符串 if (!/^[0-9A-Fa-f]*$/.test(cleanHex)) { this.addMessage('❌ 无效的十六进制数据,只能包含0-9和A-F字符', 'error'); return; } // 确保是偶数长度(每个字节需要2个十六进制字符) const paddedHex = cleanHex.length % 2 === 0 ? cleanHex : '0' + cleanHex; if (paddedHex.length === 0) { this.addMessage('❌ 请输入有效的十六进制数据', 'error'); return; } // 转换为字节数组 const bytes = []; for (let i = 0; i < paddedHex.length; i += 2) { const byteValue = parseInt(paddedHex.substr(i, 2), 16); bytes.push(byteValue); } // 创建Uint8Array并发送 const dataToSend = new Uint8Array(bytes); if (!this.writer) { this.writer = this.port.writable.getWriter(); } await this.writer.write(dataToSend); // 显示发送的数据(格式化为易读的HEX格式) const formattedHex = paddedHex.toUpperCase().replace(/(.{2})/g, '$1 ').trim(); this.addMessage(`HEX发送 (${bytes.length}字节): ${formattedHex}`, 'sent'); } catch (error) { this.addMessage(`发送HEX数据失败: ${error.message}`, 'error'); } } // 插入Tab字符(传统模式使用) insertTabCharacter() { const input = this.terminalInputField; const start = input.selectionStart; const end = input.selectionEnd; const value = input.value; const tabChar = '\t'; // 使用真实Tab字符 const newValue = value.substring(0, start) + tabChar + value.substring(end); input.value = newValue; // 将光标移动到Tab字符之后 const newCursorPos = start + tabChar.length; input.setSelectionRange(newCursorPos, newCursorPos); // 触发input事件以确保任何监听器都能收到通知 input.dispatchEvent(new Event('input', { bubbles: true })); } executeTerminalCommand() { const command = this.terminalInputField.value.trim(); if (!command) return; // 添加到命令历史 if (this.commandHistory[this.commandHistory.length - 1] !== command) { this.commandHistory.push(command); // 限制历史记录数量 if (this.commandHistory.length > 100) { this.commandHistory.shift(); } } this.historyIndex = -1; if (this.hexModeCheckbox.checked) { // HEX模式:发送十六进制数据 this.sendHexData(command); } else { // 文本模式:发送普通命令 this.addMessage(`$ ${command}`, 'sent'); this.sendCommand(command); } // 清空输入框 this.terminalInputField.value = ''; } navigateHistory(direction) { if (this.commandHistory.length === 0) return; if (direction === -1) { // 向上箭头 - 显示更早的命令 if (this.historyIndex === -1) { this.historyIndex = this.commandHistory.length - 1; } else if (this.historyIndex > 0) { this.historyIndex--; } } else if (direction === 1) { // 向下箭头 - 显示更新的命令 if (this.historyIndex === -1) { return; } else if (this.historyIndex < this.commandHistory.length - 1) { this.historyIndex++; } else { this.historyIndex = -1; this.terminalInputField.value = ''; return; } } if (this.historyIndex >= 0 && this.historyIndex < this.commandHistory.length) { this.terminalInputField.value = this.commandHistory[this.historyIndex]; // 将光标移到末尾 setTimeout(() => { this.terminalInputField.setSelectionRange( this.terminalInputField.value.length, this.terminalInputField.value.length ); }, 0); } } // 日志保存方法 async saveLog() { try { if (this.logMessages.length === 0) { this.addMessage('没有日志内容可保存', 'info'); return; } const logContent = this.generateLogContent(); // 检查是否支持File System Access API if ('showSaveFilePicker' in window) { await this.saveLogWithFilePicker(logContent); } else { // 降级到传统下载方式 this.saveLogWithDownload(logContent); } } catch (error) { if (error.name === 'AbortError') { this.addMessage('💡 用户取消了日志保存', 'info'); } else { this.addMessage(`❌ 保存日志失败: ${error.message}`, 'error'); } } } generateLogContent() { const lines = []; lines.push('# MicroLink Web Serial Terminal 日志'); lines.push(`# 生成时间: ${new Date().toLocaleString()}`); lines.push(`# 总计消息数: ${this.logMessages.length}`); lines.push('# ========================================'); lines.push(''); for (const msg of this.logMessages) { const timestamp = msg.timestamp.toLocaleString(); const typeLabel = this.getTypeLabel(msg.type); lines.push(`[${timestamp}] ${typeLabel} ${msg.content}`); } lines.push(''); lines.push('# ========================================'); lines.push('# 日志结束'); return lines.join('\n'); } getTypeLabel(type) { const labels = { 'sent': '[发送]', 'received': '[接收]', 'error': '[错误]', 'info': '[信息]' }; return labels[type] || '[未知]'; } async saveLogWithFilePicker(logContent) { try { const now = new Date(); const dateStr = now.toISOString().slice(0, 19).replace(/[T:]/g, '-'); const suggestedName = `microlink-log-${dateStr}.txt`; const fileHandle = await window.showSaveFilePicker({ suggestedName: suggestedName, types: [ { description: 'MicroLink日志文件', accept: { 'text/plain': ['.txt'], }, }, ], }); const writable = await fileHandle.createWritable(); await writable.write(logContent); await writable.close(); this.addMessage('✅ 日志已保存到指定位置', 'info'); } catch (error) { if (error.name === 'AbortError') { throw error; } else { this.addMessage('⚠️ 文件选择器不可用,使用下载方式保存', 'info'); this.saveLogWithDownload(logContent); } } } saveLogWithDownload(logContent) { const now = new Date(); const dateStr = now.toISOString().slice(0, 19).replace(/[T:]/g, '-'); const filename = `microlink-log-${dateStr}.txt`; const blob = new Blob([logContent], { type: 'text/plain;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(); document.body.removeChild(a); URL.revokeObjectURL(url); this.addMessage(`✅ 日志已保存到下载文件夹 ${filename}`, 'info'); } } // ========== YMODEM协议实现(移植自add.html,适配window.microLinkTerminal.port) ========== // CRC16校验计算 (用于YMODEM) function calculateCRC16(data) { let crc = 0x0000; const polynomial = 0x1021; // CRC-16-CCITT for (let i = 0; i < data.length; i++) { crc ^= (data[i] << 8); for (let j = 0; j < 8; j++) { if (crc & 0x8000) { crc = (crc << 1) ^ polynomial; } else { crc = crc << 1; } } crc &= 0xFFFF; } return crc; } // 调试函数:验证YMODEM数据包格式 function debugYMODEMPacket(packet, packetType, blockNumber = 0) { const packetInfo = { type: packetType, blockNumber: blockNumber, totalLength: packet.length, header: { SOH: packet[0], blockNumber: packet[1], blockNumberComplement: packet[2] }, dataArea: Array.from(packet.slice(3, 131)), // 128字节数据区(第4字节到第131字节) crc: { value: (packet[131] << 8) | packet[132] } }; console.log(`=== YMODEM ${packetType} 包调试信息 ===`); console.log(`包类型: ${packetType}`); console.log(`包号: ${blockNumber}`); console.log(`总长度: ${packet.length} 字节`); console.log(`帧头: SOH=${packet[0].toString(16)}, 包号=${packet[1].toString(16)}, 反码=${packet[2].toString(16)}`); console.log(`数据区: 128字节(第4-131字节)`); console.log(`数据区前16字节: ${packetInfo.dataArea.slice(0, 16).map(b => b.toString(16).padStart(2, '0')).join(' ')}`); console.log(`数据区后16字节: ${packetInfo.dataArea.slice(-16).map(b => b.toString(16).padStart(2, '0')).join(' ')}`); console.log(`CRC值: ${packetInfo.crc.value.toString(16).padStart(4, '0')}`); // 验证协议规范 const validations = []; // 验证帧头 if (packet[0] === 0x01) validations.push('✓ SOH正确'); else validations.push('✗ SOH错误'); if (packet[1] === blockNumber) validations.push('✓ 包号正确'); else validations.push('✗ 包号错误'); if (packet[2] === (255 - blockNumber)) validations.push('✓ 包号反码正确'); else validations.push('✗ 包号反码错误'); // 验证数据区长度 if (packetInfo.dataArea.length === 128) validations.push('✓ 数据区长度正确(128字节)'); else validations.push('✗ 数据区长度错误'); // 验证数据区内容 if (packetType === '起始帧') { // 起始帧:文件名 + 0x00 + 文件大小 + 0x00 + 0x00填充 const hasNullTerminators = packetInfo.dataArea.includes(0x00); if (hasNullTerminators) validations.push('✓ 包含NULL终止符'); else validations.push('✗ 缺少NULL终止符'); } else if (packetType === '数据帧') { // 数据帧:有效数据 + 0x1A填充 const hasSubPadding = packetInfo.dataArea.slice(-10).some(b => b === 0x1A); if (hasSubPadding) validations.push('✓ 包含SUB填充(0x1A)'); else validations.push('✗ 缺少SUB填充'); } else if (packetType === '结束帧') { // 结束帧:全0x00 const allZeros = packetInfo.dataArea.every(b => b === 0x00); if (allZeros) validations.push('✓ 数据区全为0x00'); else validations.push('✗ 数据区不全为0x00'); } // 验证包长度 if (packet.length === 133) validations.push('✓ 包长度正确(133字节)'); else validations.push('✗ 包长度错误'); console.log('协议验证:', validations.join(', ')); console.log('====================================='); return packetInfo; } // 修改后的数据包创建函数,全部使用CRC校验 function createYMODEMHeaderPacket(fileName, fileSize) { const headerSize = 128; const packetSize = headerSize + 5; // 3字节帧头 + 128字节数据区 + 2字节CRC const header = new Uint8Array(packetSize); // 严格按照YMODEM协议规范 header[0] = 0x01; // SOH - 起始帧固定使用SOH header[1] = 0x00; // 包号固定为0x00 header[2] = 0xFF; // 包号反码 0xFF // 构建文件头信息:文件名 + 0x00 + 文件大小 + 0x00 const headerInfo = `${fileName}\x00${fileSize}\x00`; const headerBytes = new TextEncoder().encode(headerInfo); // 复制文件头信息到数据区(从第4字节开始) header.set(headerBytes, 3); // 填充剩余字节为0x00(从文件信息结束到128字节数据区结束) // 数据区范围:第4字节到第131字节(共128字节) for (let i = headerBytes.length + 3; i < headerSize + 3; i++) { header[i] = 0x00; } // CRC模式:计算CRC16 // 数据区:第4字节到第131字节(128字节) const dataForCRC = header.slice(3, headerSize + 3); const crc = calculateCRC16(dataForCRC); header[headerSize + 3] = (crc >> 8) & 0xFF; // CRC高字节 header[headerSize + 4] = crc & 0xFF; // CRC低字节 // 调试输出 if (typeof console !== 'undefined' && console.log) { debugYMODEMPacket(header, '起始帧', 0); } return header; } function createYMODEMDataPacket(data, blockNumber) { const SOH_BLOCK_SIZE = 128; // SOH固定128字节 const packetSize = SOH_BLOCK_SIZE + 5; // 3字节帧头 + 128字节数据区 + 2字节CRC const packet = new Uint8Array(packetSize); // 严格按照YMODEM协议规范 packet[0] = 0x01; // SOH - 数据帧使用SOH(128字节块) packet[1] = blockNumber; // 包号 packet[2] = 255 - blockNumber; // 包号反码 // 复制有效数据到数据区(从第4字节开始) packet.set(data, 3); // 关键修正:SOH帧数据区严格为128字节,有效数据不足时用0x1A填充 // 数据区范围:第4字节到第131字节(共128字节) for (let i = data.length + 3; i < SOH_BLOCK_SIZE + 3; i++) { packet[i] = 0x1A; // 用0x1A填充剩余字节 } // CRC模式:计算CRC16 // 数据区:第4字节到第131字节(128字节) const dataForCRC = packet.slice(3, SOH_BLOCK_SIZE + 3); const crc = calculateCRC16(dataForCRC); packet[SOH_BLOCK_SIZE + 3] = (crc >> 8) & 0xFF; // CRC高字节 packet[SOH_BLOCK_SIZE + 4] = crc & 0xFF; // CRC低字节 // 调试输出 if (typeof console !== 'undefined' && console.log) { debugYMODEMPacket(packet, '数据帧', blockNumber); } return packet; } function createYMODEMEndPacket() { const headerSize = 128; const packetSize = headerSize + 5; // 3字节帧头 + 128字节数据区 + 2字节CRC const header = new Uint8Array(packetSize); // 严格按照YMODEM协议规范 - 结束帧 header[0] = 0x01; // SOH - 结束帧固定使用SOH header[1] = 0x00; // 包号固定为0x00 header[2] = 0xFF; // 包号反码 0xFF // 数据区全部填充0x00(空包) // 数据区范围:第4字节到第131字节(共128字节) for (let i = 3; i < headerSize + 3; i++) { header[i] = 0x00; } // CRC模式:计算CRC16 // 数据区:第4字节到第131字节(128字节) const dataForCRC = header.slice(3, headerSize + 3); const crc = calculateCRC16(dataForCRC); header[headerSize + 3] = (crc >> 8) & 0xFF; // CRC高字节 header[headerSize + 4] = crc & 0xFF; // CRC低字节 // 调试输出 if (typeof console !== 'undefined' && console.log) { debugYMODEMPacket(header, '结束帧', 0); } return header; } async function waitForStartSignal(reader, writer, onLog) { const timeout = 30000; const startTime = Date.now(); let lastLogTime = 0; let consecutiveChars = 0; onLog && onLog('正在等待接收方启动信号...'); while (Date.now() - startTime < timeout) { try { const { value, done } = await reader.read(); if (done) break; if (value && value.length > 0) { for (let i = 0; i < value.length; i++) { const byte = value[i]; if (byte === 0x15) return 'NAK'; if (byte === 0x18) throw new Error('接收方取消了传输'); if (byte === 0x43) return 'C'; if (byte === 0x06) return 'ACK'; if (byte === 0x2b) { consecutiveChars++; if (consecutiveChars >= 3) { await writer.write(new Uint8Array([0x15])); return 'PLUS_TRIGGER'; } } else if (byte >= 0x20 && byte <= 0x7e) { consecutiveChars++; if (consecutiveChars >= 5) { await writer.write(new Uint8Array([0x15])); return 'CHAR_TRIGGER'; } } else { consecutiveChars = 0; } } } } catch (error) { onLog && onLog('等待启动信号时出错: ' + error.message); } const currentTime = Date.now(); if (currentTime - lastLogTime > 5000) { const elapsed = Math.round((currentTime - startTime) / 1000); onLog && onLog(`等待中... (${elapsed}s/${timeout/1000}s)`); lastLogTime = currentTime; } await new Promise(resolve => setTimeout(resolve, 100)); } throw new Error('等待启动信号超时,请确保接收方已准备就绪'); } async function sendYMODEMPacketWithACK(writer, reader, packet, blockNumber, onLog, maxRetries = 10, isHeaderPacket = false) { let retries = 0; let consecutiveErrors = 0; // 连续错误计数 while (retries < maxRetries) { // 详细的包信息日志 const packetType = blockNumber === 0 ? (packet.slice(3).every(b => b === 0) ? '结束帧' : '起始帧') : '数据帧'; onLog && onLog(`准备发送${packetType} (包号: ${blockNumber}, 长度: ${packet.length}字节)`); // 验证包格式 if (packet[0] !== 0x01) { onLog && onLog(`❌ 包格式错误: SOH应为0x01,实际为0x${packet[0].toString(16)}`); throw new Error('包格式错误: SOH不正确'); } if (packet[1] !== blockNumber) { onLog && onLog(`❌ 包格式错误: 包号应为${blockNumber},实际为${packet[1]}`); throw new Error('包格式错误: 包号不正确'); } if (packet[2] !== (255 - blockNumber)) { onLog && onLog(`❌ 包格式错误: 包号反码应为${255 - blockNumber},实际为${packet[2]}`); throw new Error('包格式错误: 包号反码不正确'); } // 验证包长度 const expectedLength = 133; // CRC模式固定133字节 if (packet.length !== expectedLength) { onLog && onLog(`❌ 包长度错误: 应为${expectedLength}字节,实际为${packet.length}字节`); throw new Error('包长度错误'); } try { await writer.write(packet); onLog && onLog(`✅ ${packetType}已发送,等待ACK...`); // 等待ACK await waitForACK(reader, onLog, isHeaderPacket); onLog && onLog(`✅ ${packetType}确认成功`); consecutiveErrors = 0; // 重置连续错误计数 return; } catch (error) { consecutiveErrors++; if (error.message.includes('C字符')) { onLog && onLog('检测到C字符,设备端请求重启传输'); throw new Error('RESTART_HEADER'); } else if (error.message.includes('CAN') || error.message.includes('取消')) { onLog && onLog('检测到传输取消信号'); throw new Error('TRANSMISSION_CANCELLED'); } else if (error.message.includes('NAK')) { onLog && onLog('收到NAK,数据包校验失败'); // NAK错误,继续重试 } else if (error.message.includes('超时')) { onLog && onLog('等待ACK超时,设备端可能处理缓慢'); // 超时错误,可能是设备端处理缓慢 } retries++; onLog && onLog(`❌ ${packetType}确认失败,重试 ${retries}/${maxRetries} (连续错误: ${consecutiveErrors})`); if (retries >= maxRetries) { throw new Error(`${packetType}发送失败,超过最大重试次数`); } // 根据连续错误次数调整重试延时 let retryDelay = 1000; // 基础延时1秒 if (consecutiveErrors >= 3) { retryDelay = 3000; // 连续错误较多时,增加延时到3秒 onLog && onLog(`连续错误较多,增加重试延时到${retryDelay}ms`); } else if (consecutiveErrors >= 5) { retryDelay = 5000; // 连续错误很多时,增加延时到5秒 onLog && onLog(`连续错误很多,增加重试延时到${retryDelay}ms`); } onLog && onLog(`等待${retryDelay}ms后重试...`); await new Promise(resolve => setTimeout(resolve, retryDelay)); } } } async function waitForACK(reader, onLog, isHeaderPacket = false) { const timeout = 15000; // 增加超时时间到15秒 const startTime = Date.now(); let buffer = ''; while (Date.now() - startTime < timeout) { try { const { value, done } = await reader.read(); if (done) { onLog && onLog('waitForACK: 串口流已关闭 (done=true)'); break; } if (value && value.length > 0) { const text = new TextDecoder().decode(value); buffer += text; for (let i = 0; i < value.length; i++) { const byte = value[i]; if (byte === 0x06) { onLog && onLog('收到ACK确认'); return; // 成功收到ACK } if (byte === 0x15) { onLog && onLog('收到NAK,传输失败'); throw new Error('收到NAK,传输失败'); } if (byte === 0x18) { onLog && onLog('收到CAN,传输被取消'); throw new Error('收到CAN,传输被取消'); } if (byte === 0x43) { if (isHeaderPacket) { onLog && onLog('头包后收到C,协议正常,进入数据包1发送'); return; // 头包后收到C,视为正常 } else { onLog && onLog('收到C字符,需要切换到CRC模式或重启传输'); throw new Error('收到C字符,需要切换到CRC模式'); } } } if (onLog && value.length < 50) { const hexBytes = Array.from(value).map(b => b.toString(16).padStart(2, '0')).join(' '); onLog(`waitForACK: 收到内容: ${hexBytes}`); } } } catch (error) { if (error.message.includes('NAK') || error.message.includes('CAN') || error.message.includes('C字符')) { throw error; } onLog && onLog(`waitForACK读取错误: ${error.message}`); } await new Promise(resolve => setTimeout(resolve, 100)); } throw new Error('等待ACK超时'); } async function waitForACKOrNAK(reader, onLog) { const timeout = 10000; const startTime = Date.now(); while (Date.now() - startTime < timeout) { try { const { value, done } = await reader.read(); if (done) break; if (value && value.length > 0) { for (let i = 0; i < value.length; i++) { const byte = value[i]; if (byte === 0x06) return 'ACK'; if (byte === 0x15) return 'NAK'; if (byte === 0x18) throw new Error('收到CAN,传输被取消'); if (byte === 0x43) throw new Error('收到C字符,需要切换到CRC模式'); } } } catch (error) { if (error.message.includes('CAN') || error.message.includes('C字符')) { throw error; } } await new Promise(resolve => setTimeout(resolve, 50)); } throw new Error('等待ACK或NAK超时'); } // 发送EOT并等待ACK/NAK/C/CAN,支持重试 async function sendEOTWithACKRetry(writer, reader, config, onLog) { const eotTimeout = 1000; // EOT等待ACK最大1秒 const maxEOTRetries = 5; for (let retry = 0; retry < maxEOTRetries; retry++) { onLog && onLog(`[DEBUG] 第${retry+1}次发送EOT信号...`); await writer.write(new Uint8Array([0x04])); onLog && onLog('发送EOT信号,等待ACK...'); try { const resp = await waitForACKorC(reader, onLog, eotTimeout); if (resp === 'ACK') { onLog && onLog('EOT确认成功'); return true; } if (resp === 'C') throw new Error('RESTART_HEADER'); } catch (e) { if (e.message === 'RESTART_HEADER') throw e; onLog && onLog(`EOT未确认,重试${retry + 1}/${maxEOTRetries}`); await new Promise(r => setTimeout(r, config.retryDelay + retry * 200)); } } onLog && onLog('❌ EOT多次重试失败,设备端未响应ACK,传输中止。'); throw new Error('EOT多次重试失败'); } // EOT发送函数:发送EOT后等待ACK,支持重试 async function sendEOT(writer, reader, onLog, maxRetries = 5) { for (let retry = 0; retry < maxRetries; retry++) { onLog && onLog(`[DEBUG] 第${retry+1}次发送EOT信号...`); await writer.write(new Uint8Array([0x04])); onLog && onLog('发送EOT信号,等待ACK...'); try { const resp = await waitForACKorC(reader, onLog, 1000); // 1秒超时 if (resp === 'ACK') { onLog && onLog('EOT确认成功'); return true; } if (resp === 'C') throw new Error('RESTART_HEADER'); } catch (e) { if (e.message === 'RESTART_HEADER') throw e; onLog && onLog(`EOT未确认,重试${retry + 1}/${maxRetries}`); await new Promise(r => setTimeout(r, 200 + retry * 100)); } } onLog && onLog('❌ EOT多次重试失败,设备端未响应ACK,传输中止。'); throw new Error('EOT多次重试失败'); } // waitForACKorC函数,支持自定义超时 async function waitForACKorC(reader, onLog, timeout = 1000) { const startTime = Date.now(); while (Date.now() - startTime < timeout) { try { const { value, done } = await reader.read(); if (done) break; if (value && value.length > 0) { for (let i = 0; i < value.length; i++) { const byte = value[i]; if (byte === 0x06) return 'ACK'; if (byte === 0x15) return 'NAK'; if (byte === 0x18) throw new Error('收到CAN,传输被取消'); if (byte === 0x43) return 'C'; } } } catch (error) { if (error.message.includes('CAN') || error.message.includes('C字符')) { throw error; } } await new Promise(resolve => setTimeout(resolve, 50)); } throw new Error('等待ACK或C超时'); } // 主入口:window.ymodemSendFileViaSerial window.ymodemSendFileViaSerial = async function(uint8Array, fileName, timeout, onProgress, onLog, options = {}) { const port = window.microLinkTerminal && window.microLinkTerminal.port; if (!port) throw new Error('串口未连接'); const config = { retryDelay: options.retryDelay || 1000, maxRetries: options.maxRetries || 20, packetTimeout: options.packetTimeout || 15000, restartDelay: options.restartDelay || 2000, packetInterval: options.packetInterval || 1000, ...options }; const SOH_BLOCK_SIZE = 128; let writer = null, reader = null; let restartCount = 0; const maxRestarts = 3; // === 头包构造函数优先用options.buildHeaderPacket === const buildHeaderPacket = options.buildHeaderPacket || buildHeaderPacketYmodem; try { writer = port.writable.getWriter(); reader = port.readable.getReader(); onLog && onLog(`准备发送文件: ${fileName}, 大小: ${uint8Array.length} 字节`); onLog && onLog(`数据包大小: ${SOH_BLOCK_SIZE} 字节 (SOH)`); onLog && onLog(`预计数据包数量: ${Math.ceil(uint8Array.length / SOH_BLOCK_SIZE)}`); onLog && onLog(`包间延时: ${config.packetInterval}ms`); onLog && onLog(`包超时: ${config.packetTimeout}ms`); onLog && onLog(`校验方式: CRC16 (固定)`); await new Promise(r => setTimeout(r, 1000)); await writer.write(new Uint8Array([0x00])); await new Promise(r => setTimeout(r, 200)); await writer.write(new Uint8Array([0x43])); await new Promise(r => setTimeout(r, 200)); onLog && onLog('已发送触发字符序列,等待接收方C信号...'); while (restartCount <= maxRestarts) { try { await window.waitForCSignal(reader, onLog); await performYMODEMTransfer(); onLog && onLog('YMODEM传输成功完成!'); return; } catch (error) { // === 修正:EOT后收到C时直接发空头包,不再整体重启 === if (error.message === 'RESTART_HEADER' && restartCount < maxRestarts) { onLog && onLog('EOT后收到C,直接发送空头包...'); let endPacket = createYMODEMEndPacket(); const endAck = await sendEndPacketWithRetry(writer, reader, endPacket, onLog, 10); if (!endAck) throw new Error('空头包多次重试失败,传输中止'); return; } else if (error.message === 'RESTART_HEADER') { throw new Error('EOT后收到C,结束帧多次失败,传输中止'); } else if (error.message === 'TRANSMISSION_CANCELLED') { throw new Error('传输被取消'); } else { throw error; } } } throw new Error(`传输失败,已尝试${maxRestarts}次重启`); // 内部传输函数 async function performYMODEMTransfer() { onLog && onLog('=== 开始YMODEM传输流程 ==='); // 1. 发送起始帧 let headerPacket = buildHeaderPacket(fileName, uint8Array.length); await sendPacketWithACKRetry(writer, reader, headerPacket, 0, config, onLog, '起始帧'); onLog && onLog(`[DEBUG] 头包ACK后,准备延时${config.packetInterval}ms`); await new Promise(r => setTimeout(r, config.packetInterval)); // 头包和数据包1之间加延时 onLog && onLog(`[DEBUG] 延时结束,准备发送数据包1`); // 2. 发送数据帧 const totalBlocks = Math.ceil(uint8Array.length / SOH_BLOCK_SIZE); let transferred = 0; for (let blockNum = 1; blockNum <= totalBlocks; blockNum++) { const startIndex = (blockNum - 1) * SOH_BLOCK_SIZE; const endIndex = Math.min(startIndex + SOH_BLOCK_SIZE, uint8Array.length); const blockData = uint8Array.slice(startIndex, endIndex); // === 强制用buildYmodemPacketYmodem构造数据包 === let dataPacket = buildYmodemPacketYmodem(blockNum, blockData); await sendPacketWithACKRetry(writer, reader, dataPacket, blockNum, config, onLog, `数据帧${blockNum}`); transferred += blockData.length; const progress = Math.round((transferred / uint8Array.length) * 100); onProgress && onProgress(progress); onLog && onLog(`✅ 数据帧${blockNum}/${totalBlocks} 传输完成 (${progress}%)`); onLog && onLog(`[DEBUG] 数据包${blockNum} ACK后,准备延时${config.packetInterval}ms`); await new Promise(r => setTimeout(r, config.packetInterval)); // 包间延时 onLog && onLog(`[DEBUG] 延时结束,准备发送下一个数据包`); } // 3. 发送EOT,等待ACK await sendEOTWithACKRetry(writer, reader, config, onLog); await new Promise(r => setTimeout(r, config.packetInterval)); // 包间延时 // === 关键:EOT和空头包之间增加延时 === await new Promise(r => setTimeout(r, 500)); // 4. 发送结束帧(空头包) let endPacket = createYMODEMEndPacket(); const endAck = await sendEndPacketWithRetry(writer, reader, endPacket, onLog, 10); if (!endAck) throw new Error('空头包多次重试失败,传输中止'); await new Promise(r => setTimeout(r, config.packetInterval)); // 包间延时 // === 关键:结束包后严格等待ACK === onLog && onLog('等待设备端ACK确认结束...'); let gotAck = false; const ackStart = Date.now(); while (Date.now() - ackStart < 5000) { // 最多等5秒 const { value, done } = await Promise.race([ reader.read(), new Promise(resolve => setTimeout(() => resolve({value: null, done: false}), 200)) ]); if (value) { for (let i = 0; i < value.length; i++) { if (value[i] === 0x06) { // ACK gotAck = true; onLog('✅ 设备端ACK确认,YMODEM流程完成'); break; } } if (gotAck) break; const text = new TextDecoder().decode(value); onLog('结束后收到内容: ' + text); } await new Promise(r => setTimeout(r, 100)); } if (!gotAck) onLog('⚠️ 结束后未收到ACK,可能已完成也可能异常'); onLog && onLog('✅ YMODEM传输流程完成'); } } finally { if (writer) try { writer.releaseLock(); } catch (e) {} if (reader) try { reader.releaseLock(); } catch (e) {} } }; // 简化的YMODEM发送函数,专门处理设备端问题 window.ymodemSendFileViaSerialSimple = async function(uint8Array, fileName, timeout, onProgress, onLog, options = {}) { const port = window.microLinkTerminal && window.microLinkTerminal.port; if (!port) throw new Error('串口未连接'); let writer = null, reader = null; // === 1. 先彻底暂停主终端监听 === let wasConnected = false; window.isYmodemActive = true; // YMODEM流程期间屏蔽主终端 if (window.microLinkTerminal) { // --- YMODEM前清空主终端缓冲 --- if (typeof window.microLinkTerminal.flushBuffer === 'function') { window.microLinkTerminal.flushBuffer(); } wasConnected = window.microLinkTerminal.isConnected; window.microLinkTerminal.isConnected = false; if (window.microLinkTerminal.reader) { try { await window.microLinkTerminal.reader.cancel(); } catch (e) {} try { window.microLinkTerminal.reader.releaseLock(); } catch (e) {} window.microLinkTerminal.reader = null; } // 等待主终端读取循环彻底退出 await new Promise(r => setTimeout(r, 300)); } // === 头包构造函数优先用options.buildHeaderPacket === const buildHeaderPacket = options.buildHeaderPacket || buildHeaderPacketYmodem; try { // === 2. 再获取writer/reader并发送ym.receive()指令 === writer = port.writable.getWriter(); reader = port.readable.getReader(); onLog && onLog('发送 ym.receive() 指令...'); await writer.write(new TextEncoder().encode('ym.receive()\n')); // 2. 等待接收端发送 'C' onLog && onLog('等待接收端发送 "C"...'); if (!(await waitForByteYmodem(reader, 0x43, 10000, onLog))) { onLog && onLog('未收到接收端 "C",发送中止'); throw new Error('未收到接收端 "C"'); } // 3. 发送头包 onLog && onLog('发送 Ymodem 文件头包...'); if (!(await sendAndWaitAckYmodem(writer, reader, buildHeaderPacket(fileName, uint8Array.length), onLog))) { onLog && onLog('头包发送失败,发送中止'); throw new Error('头包发送失败'); } // 4. 等待接收端再次发送 'C' onLog && onLog('等待接收端再次发送 "C"...'); if (!(await waitForByteYmodem(reader, 0x43, 10000, onLog))) { onLog && onLog('未收到接收端第二个 "C",发送中止'); throw new Error('未收到接收端第二个 "C"'); } // 5. 发送数据包 let seq = 1; for (let offset = 0; offset < uint8Array.length; offset += 128) { let chunk = uint8Array.slice(offset, offset + 128); // === 强制用buildYmodemPacketYmodem构造数据包 === if (!(await sendAndWaitAckYmodem(writer, reader, buildYmodemPacketYmodem(seq, chunk), onLog))) { onLog && onLog(`数据包 #${seq} 发送失败,发送中止`); throw new Error(`数据包 #${seq} 发送失败`); } seq++; if (onProgress) onProgress(Math.round((offset + chunk.length) / uint8Array.length * 100)); } // 6. 发送EOT,等待ACK onLog && onLog('发送 EOT...'); for (let i = 0; i < 10; i++) { await writer.write(new Uint8Array([0x04])); let b = await readByteYmodem(reader, 3000, onLog); if (b === 0x06) break; } // 7. 等待接收端发送 'C' onLog && onLog('等待接收端发送 "C"...'); if (!(await waitForByteYmodem(reader, 0x43, 10000, onLog))) { onLog && onLog('未收到接收端最后一个 "C",发送中止'); throw new Error('未收到接收端最后一个 "C"'); } // 8. 发送空头包 onLog && onLog('发送空头包...'); if (!(await sendEndPacketWithRetry(writer, reader, buildEndPacketYmodem(), onLog, 10))) { onLog && onLog('空头包发送失败'); throw new Error('空头包发送失败'); } onLog && onLog('文件发送完成!'); } finally { if (writer) try { writer.releaseLock(); } catch (e) {} if (reader) try { reader.releaseLock(); } catch (e) {} // === 恢复主终端监听 === if (window.microLinkTerminal) { window.microLinkTerminal.isConnected = wasConnected; if (wasConnected) window.microLinkTerminal.startReading(); } window.isYmodemActive = false; // 恢复主终端 } }; function crc16_ccitt(buf) { let crc = 0x0000; for (let i = 0; i < buf.length; i++) { crc ^= (buf[i] << 8); for (let j = 0; j < 8; j++) { if (crc & 0x8000) { crc = (crc << 1) ^ 0x1021; } else { crc = crc << 1; } crc &= 0xFFFF; } } return crc; } function buildYmodemPacketYmodem(seq, data) { let packet = new Uint8Array(133); packet[0] = 0x01; // SOH packet[1] = seq & 0xFF; packet[2] = (~seq) & 0xFF; for (let i = 0; i < 128; i++) { packet[3 + i] = data[i] !== undefined ? data[i] : 0x1A; } let crc = crc16_ccitt(packet.slice(3, 131)); packet[131] = (crc >> 8) & 0xFF; packet[132] = crc & 0xFF; return packet; } // === 极简YMODEM头包构造(前缀逻辑保持不变,外部传入name) === function buildHeaderPacketYmodem(name, size) { // 检查是否为FLM文件,如果是则自动加前缀 if (name && !name.startsWith('FLM/') && name.endsWith('.FLM.o')) { name = 'FLM/' + name; } let data = new Uint8Array(128); let nameBytes = new TextEncoder().encode(name); data.set(nameBytes, 0); let sizeBytes = new TextEncoder().encode(size.toString()); data.set(sizeBytes, nameBytes.length + 1); return buildYmodemPacketYmodem(0, data); } // === 极简YMODEM结束包构造 === function buildEndPacketYmodem() { let data = new Uint8Array(128); return buildYmodemPacketYmodem(0, data); } // === 极简YMODEM主流程(可被各tab直接调用) === window.sendFileViaYmodem = async function(port, fileBuffer, fileName, fileSize, onLog) { let writer = port.writable.getWriter(); let reader = port.readable.getReader(); function log(msg) { onLog && onLog(msg); } // 1. 发送ym.receive() log('发送 ym.receive() 指令...'); await writer.write(new TextEncoder().encode('ym.receive()\n')); // 2. 等待C log('等待接收端发送 "C"...'); if (!(await waitForByteYmodem(reader, 0x43, 10000, log))) { log('未收到接收端 "C",发送中止'); return false; } // 3. 发送头包 log('发送 Ymodem 文件头包...'); if (!(await sendAndWaitAckYmodem(writer, reader, buildHeaderPacketYmodem(fileName, fileSize), log))) { log('头包发送失败,发送中止'); return false; } // 4. 等待C log('等待接收端再次发送 "C"...'); if (!(await waitForByteYmodem(reader, 0x43, 10000, log))) { log('未收到接收端第二个 "C",发送中止'); return false; } // 5. 发送数据包 let seq = 1; for (let offset = 0; offset < fileBuffer.length; offset += 128) { let chunk = fileBuffer.slice(offset, offset + 128); log(`发送数据包 #${seq}...`); if (!(await sendAndWaitAckYmodem(writer, reader, buildYmodemPacketYmodem(seq, chunk), log))) { log(`数据包 #${seq} 发送失败,发送中止`); return false; } seq++; } // 6. 发送EOT log('发送 EOT...'); for (let i = 0; i < 10; i++) { await writer.write(new Uint8Array([0x04])); let b = await readByteYmodem(reader, 3000, log); if (b === 0x06) break; } // 7. 等待C log('等待接收端发送 "C"...'); if (!(await waitForByteYmodem(reader, 0x43, 10000, log))) { log('未收到接收端最后一个 "C",发送中止'); return false; } // 8. 发送空头包 log('发送空头包...'); if (!(await sendEndPacketWithRetry(writer, reader, buildEndPacketYmodem(), log))) { log('空头包发送失败'); return false; } log('文件发送完成!'); writer.releaseLock(); reader.releaseLock(); return true; } async function readByteYmodem(reader, timeout = 3000, onLog) { const timer = setTimeout(() => reader.cancel(), timeout); try { const { value } = await reader.read(); clearTimeout(timer); return value ? value[0] : null; } catch { clearTimeout(timer); return null; } } async function waitForByteYmodem(reader, target, timeout = 10000, onLog) { let start = Date.now(); while (Date.now() - start < timeout) { let b = await readByteYmodem(reader, timeout, onLog); if (b === target) return true; } return false; } async function sendAndWaitAckYmodem(writer, reader, packet, onLog, retry = 10) { for (let i = 0; i < retry; i++) { await writer.write(packet); let b = await readByteYmodem(reader, 3000, onLog); if (b === 0x06) return true; // ACK if (b === 0x15) continue; // NAK } return false; } // 测试函数:验证YMODEM数据包格式 window.testYMODEMPacketFormat = function() { console.log('=== YMODEM数据包格式测试(CRC模式) ==='); // 测试起始帧 console.log('\n1. 测试起始帧格式'); const headerPacket = createYMODEMHeaderPacket('test.bin', 1024); debugYMODEMPacket(headerPacket, '起始帧', 0); // 验证起始帧长度 console.log(`起始帧长度验证: ${headerPacket.length}字节 (期望: 133字节)`); console.log(`数据区长度验证: ${headerPacket.slice(3, 131).length}字节 (期望: 128字节)`); // 测试数据帧 - 完整数据 console.log('\n2. 测试数据帧格式(完整数据)'); const fullData = new Uint8Array(128); for (let i = 0; i < 128; i++) { fullData[i] = i; } const fullDataPacket = createYMODEMDataPacket(fullData, 1); debugYMODEMPacket(fullDataPacket, '数据帧(完整)', 1); // 测试数据帧 - 部分数据(需要填充) console.log('\n3. 测试数据帧格式(部分数据,需要0x1A填充)'); const partialData = new Uint8Array(64); for (let i = 0; i < 64; i++) { partialData[i] = i; } const partialDataPacket = createYMODEMDataPacket(partialData, 2); debugYMODEMPacket(partialDataPacket, '数据帧(部分)', 2); // 验证填充逻辑 const dataArea = partialDataPacket.slice(3, 131); const originalData = dataArea.slice(0, 64); const paddingData = dataArea.slice(64); console.log(`原始数据长度: ${originalData.length}字节`); console.log(`填充数据长度: ${paddingData.length}字节`); console.log(`填充数据是否全为0x1A: ${paddingData.every(b => b === 0x1A)}`); // 测试结束帧 console.log('\n4. 测试结束帧格式'); const endPacket = createYMODEMEndPacket(); debugYMODEMPacket(endPacket, '结束帧', 0); // 验证结束帧数据区 const endDataArea = endPacket.slice(3, 131); console.log(`结束帧数据区是否全为0x00: ${endDataArea.every(b => b === 0x00)}`); console.log('\n=== 测试完成 ==='); console.log('关键验证点:'); console.log('- 所有SOH帧数据区严格为128字节'); console.log('- 数据帧不足128字节时用0x1A填充'); console.log('- 所有包使用CRC16校验'); console.log('- 所有包长度133字节'); return true; }; // 页面加载完成后初始化 document.addEventListener('DOMContentLoaded', () => { window.microLinkTerminal = new MicroLinkTerminal(); // 在开发模式下自动运行测试 if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') { console.log('开发模式:运行YMODEM数据包格式测试'); setTimeout(() => { window.testYMODEMPacketFormat(); }, 1000); } }); // --- handleReceivedData 屏蔽逻辑 --- const origHandleReceivedData = MicroLinkTerminal.prototype.handleReceivedData; MicroLinkTerminal.prototype.handleReceivedData = function(data) { if (window.isYmodemActive) return; // 自动接入变量分析曲线 // 旧的handleRealtimeHexData已删除,现在使用Worker方式 return origHandleReceivedData.call(this, data); }; // --- handleReceivedData 屏蔽逻辑 --- // --- flushBuffer 屏蔽逻辑 --- const origFlushBuffer = MicroLinkTerminal.prototype.flushBuffer; MicroLinkTerminal.prototype.flushBuffer = function() { if (window.isYmodemActive) return; return origFlushBuffer.call(this); }; // --- flushBuffer 屏蔽逻辑 --- // --- startReading 屏蔽逻辑 --- const origStartReading = MicroLinkTerminal.prototype.startReading; MicroLinkTerminal.prototype.startReading = function() { if (window.isYmodemActive) return; // YMODEM期间禁止重启监听 return origStartReading.call(this); }; // --- startReading 屏蔽逻辑 --- function waitForCSignal(reader, onLog) { // ...原有实现... } window.waitForCSignal = waitForCSignal; function sendPacketWithACKRetry(writer, reader, packet, blockNumber, config, onLog, packetType, isHeaderPacket) { // ...原有实现... } window.sendPacketWithACKRetry = sendPacketWithACKRetry; // === 关键:结束包后严格等待ACK,超时重发,最多10次 === async function sendEndPacketWithRetry(writer, reader, endPacket, onLog, maxRetry = 10) { for (let i = 0; i < maxRetry; i++) { await writer.write(endPacket); onLog && onLog(`发送空头包(第${i+1}次),等待ACK...`); let gotAck = false; const ackStart = Date.now(); while (Date.now() - ackStart < 5000) { // 最多等5秒 const { value, done } = await Promise.race([ reader.read(), new Promise(resolve => setTimeout(() => resolve({value: null, done: false}), 200)) ]); if (value) { for (let j = 0; j < value.length; j++) { if (value[j] === 0x06) { // ACK gotAck = true; onLog && onLog('✅ 设备端ACK确认,YMODEM流程完成'); return true; } } } await new Promise(r => setTimeout(r, 100)); } onLog && onLog(`⚠️ 空头包第${i+1}次未收到ACK,准备重发...`); } onLog && onLog('❌ 空头包多次重试失败,设备端未响应ACK,传输中止。'); return false; } // 多文件配置功能 let fileTableBody, addFileBtn, clearFilesBtn; // 确保DOM加载完成后再初始化 function initMultiFileConfig() { fileTableBody = document.getElementById('fileTableBody'); addFileBtn = document.getElementById('addFileBtn'); clearFilesBtn = document.getElementById('clearFilesBtn'); if (!fileTableBody || !addFileBtn || !clearFilesBtn) { console.error('多文件配置元素未找到,延迟初始化...'); setTimeout(initMultiFileConfig, 100); return; } // 初始化表格 initFileTable(); // 事件监听器 addFileBtn.addEventListener('click', function() { console.log('添加文件按钮被点击'); addFileRow(); if (window.updateCodePreview) { window.updateCodePreview(); } }); // 测试按钮是否正常工作 console.log('多文件配置初始化完成', { fileTableBody: !!fileTableBody, addFileBtn: !!addFileBtn, clearFilesBtn: !!clearFilesBtn }); clearFilesBtn.addEventListener('click', function() { if (confirm('确定要清空所有文件配置吗?')) { clearAllFiles(); } }); } // 初始化文件表格 function initFileTable() { if (!fileTableBody) return; fileTableBody.innerHTML = ''; if (window.config && window.config.files) { window.config.files.forEach((file, index) => { addFileRow(file, index); }); } } // 添加文件行 function addFileRow(file = null, index = null) { console.log('添加文件行被调用', { file, index }); if (!fileTableBody) { console.error('fileTableBody 未找到'); return; } const row = document.createElement('div'); row.className = 'file-table-row'; row.dataset.index = index !== null ? index : (window.config && window.config.files ? window.config.files.length : 0); const fileNameInput = document.createElement('input'); fileNameInput.type = 'text'; fileNameInput.placeholder = '例如: boot.bin'; fileNameInput.value = file ? file.fileName : ''; const addressInput = document.createElement('input'); addressInput.type = 'text'; addressInput.placeholder = '例如: 0x08000000'; addressInput.value = file ? file.address : ''; const algorithmInput = document.createElement('input'); algorithmInput.type = 'text'; algorithmInput.placeholder = '例如: STM32F7x_1024.FLM.o'; algorithmInput.value = file ? file.algorithm : ''; const deleteBtn = document.createElement('button'); deleteBtn.className = 'delete-file-btn'; deleteBtn.innerHTML = ''; deleteBtn.title = '删除此行'; row.appendChild(fileNameInput); row.appendChild(addressInput); row.appendChild(algorithmInput); row.appendChild(deleteBtn); fileTableBody.appendChild(row); // 添加事件监听器 fileNameInput.addEventListener('input', function() { updateFileConfig(); }); addressInput.addEventListener('input', function() { updateFileConfig(); }); algorithmInput.addEventListener('input', function() { updateFileConfig(); }); deleteBtn.addEventListener('click', function() { deleteFileRow(row); }); // 如果是新行,添加到配置中 if (!file && window.config && window.config.files) { window.config.files.push({ fileName: '', address: '', algorithm: '' }); } } // 删除文件行 function deleteFileRow(row) { if (!fileTableBody || !row) return; const index = parseInt(row.dataset.index); if (window.config && window.config.files && index >= 0 && index < window.config.files.length) { window.config.files.splice(index, 1); } fileTableBody.removeChild(row); updateRowIndices(); if (window.updateCodePreview) { window.updateCodePreview(); } } // 更新行索引 function updateRowIndices() { if (!fileTableBody) return; const rows = fileTableBody.querySelectorAll('.file-table-row'); rows.forEach((row, index) => { row.dataset.index = index; }); } // 更新文件配置 function updateFileConfig() { if (!fileTableBody || !window.config || !window.config.files) return; const rows = fileTableBody.querySelectorAll('.file-table-row'); window.config.files = []; rows.forEach(row => { const inputs = row.querySelectorAll('input'); if (inputs.length >= 3) { window.config.files.push({ fileName: inputs[0].value, address: inputs[1].value, algorithm: inputs[2].value }); } }); if (window.updateCodePreview) { window.updateCodePreview(); } } // 清空所有文件 function clearAllFiles() { if (!fileTableBody) return; fileTableBody.innerHTML = ''; if (window.config && window.config.files) { window.config.files.length = 0; } if (window.updateCodePreview) { window.updateCodePreview(); } } // 三栏布局:sidebar切换逻辑 function setupSidebarPanelSwitch() { const sidebarBtns = document.querySelectorAll('.sidebar-btn'); const panels = ['serialPanel', 'flmPanel', 'scriptPanel', 'varPanel']; const monitorPanel = document.querySelector('.monitor-panel'); sidebarBtns.forEach(btn => { btn.addEventListener('click', function() { // 切换按钮active sidebarBtns.forEach(b => b.classList.remove('active')); this.classList.add('active'); // 切换内容区 panels.forEach(pid => { const panel = document.getElementById(pid); if (panel) panel.style.display = (this.dataset.panel === pid) ? 'block' : 'none'; }); // 控制右侧监控面板的显示/隐藏 if (monitorPanel) { if (this.dataset.panel === 'varPanel') { // 变量分析页面:隐藏监控面板 monitorPanel.style.display = 'none'; } else { // 其他页面:显示监控面板 monitorPanel.style.display = ''; } } // 切换到Python脚本配置时初始化 if (this.dataset.panel === 'scriptPanel') { setupPythonScriptPanel(); // 初始化多文件配置 initMultiFileConfig(); } // 切换到变量分析时初始化 if (this.dataset.panel === 'varPanel') { setupVarAnalysisPanel(); } }); }); } document.addEventListener('DOMContentLoaded', function() { setupSidebarPanelSwitch(); }); // ... 现有代码 ... function setupPythonScriptPanel() { // 防止重复绑定 if (window._pythonPanelInited) return; window._pythonPanelInited = true; const swdClockSpeedMap = { '10M': '10000000', '5M': '5000000', '2M': '2000000', '1M': '1000000', '500K': '500000', '200K': '200000', '100K': '100000', '50K': '50000', '20K': '20000', '10K': '10000', '5K': '5000' }; const customFlmInput = document.getElementById('customFlm'); const address1Input = document.getElementById('address1'); const address2Input = document.getElementById('address2'); const binFileNameInput = document.getElementById('binFileName'); const swdClockSpeedSelect = document.getElementById('swdClockSpeed'); const codePreview = document.getElementById('codePreview'); const dragCodePreview = document.getElementById('dragCodePreview'); // 删除下载按钮相关代码 const pyYmodemSendBtn = document.getElementById('pyYmodemSendBtn'); const pyYmodemProgress = document.getElementById('pyYmodemProgress'); const pyYmodemLog = document.getElementById('pyYmodemLog'); // 使用全局config对象 if (typeof window.config === 'undefined') { window.config = { flmFile: (customFlmInput && customFlmInput.value) || 'custom_flm.FLM.o', address1: (address1Input && address1Input.value) || '0X08000000', address2: (address2Input && address2Input.value) || '0x20000000', binFileName: (binFileNameInput && binFileNameInput.value) || 'ILI9341_HAL.bin', swdClockSpeed: (swdClockSpeedSelect && swdClockSpeedSelect.value) || '10M', files: [ { fileName: 'boot.bin', address: '0x08000000', algorithm: 'STM32F7x_1024.FLM.o' }, { fileName: 'rtthread.bin', address: '0x08020000', algorithm: 'STM32F7x_1024.FLM.o' }, { fileName: 'HZK.bin', address: '0x90000000', algorithm: 'STM32F767_W25QXX.FLM.o' } ] }; } const config = window.config; // 确保config对象有files数组 if (!config.files) { config.files = [ { fileName: 'boot.bin', address: '0x08000000', algorithm: 'STM32F7x_1024.FLM.o' }, { fileName: 'rtthread.bin', address: '0x08020000', algorithm: 'STM32F7x_1024.FLM.o' }, { fileName: 'HZK.bin', address: '0x90000000', algorithm: 'STM32F767_W25QXX.FLM.o' } ]; } // 全局updateCodePreview函数 window.updateCodePreview = function() { const flmFile = config.flmFile; const pythonSwdSpeed = swdClockSpeedMap[config.swdClockSpeed] || '10000000'; // 生成多文件烧录代码 let offlineCode = `import FLMConfig\nimport PikaStdLib\nimport PikaStdDevice\nimport time\n\ntime = PikaStdDevice.Time()\nbuzzer = PikaStdDevice.GPIO()\nbuzzer.setPin('PA4')\nbuzzer.setMode('out')\n\n# 设置SWD下载速度\ncmd.set_swd_clock(${pythonSwdSpeed})\n\nReadFlm = FLMConfig.ReadFlm()`; // 按算法分组文件 const algorithmGroups = {}; if (config.files && config.files.length > 0) { config.files.forEach(file => { if (file.algorithm && file.fileName && file.address) { if (!algorithmGroups[file.algorithm]) { algorithmGroups[file.algorithm] = []; } algorithmGroups[file.algorithm].push(file); } }); // 为每个算法生成加载和烧录代码 Object.keys(algorithmGroups).forEach((algorithm, index) => { const files = algorithmGroups[algorithm]; if (files.length > 0) { // 加载算法 offlineCode += `\n# 加载 ${algorithm} 下载算法文件\nresult = ReadFlm.load("FLM/${algorithm}", ${config.address1}, ${config.address2})\nif result != 0:\n return`; // 烧录该算法下的所有文件 files.forEach(file => { offlineCode += `\n\n# 烧写 ${file.fileName}\nresult = load.bin("${file.fileName}", ${file.address})\nif result != 0:\n return`; }); } }); } else { // 如果没有多文件配置,使用默认的单文件配置 offlineCode += `\n# 加载 FLM 文件\nresult = ReadFlm.load("FLM/${flmFile}", ${config.address1}, ${config.address2})\nif result != 0:\n return \n\n# 烧写固件文件\nresult = load.bin("${config.binFileName || 'firmware.bin'}", ${config.address1})\nif result != 0:\n return`; } offlineCode += `\n\n# 蜂鸣器响一声,表示烧写完成\nbuzzer.enable()\nbuzzer.high()\ntime.sleep_ms(500)\nbuzzer.low()\ntime.sleep_ms(500)`; const dragCode = `import FLMConfig\ncmd.set_swd_clock(${pythonSwdSpeed})\nReadFlm = FLMConfig.ReadFlm()\nres1 = ReadFlm.load("FLM/${flmFile}",${config.address1},${config.address2})`; if (codePreview) { codePreview.textContent = offlineCode; } if (dragCodePreview) { dragCodePreview.textContent = dragCode; } // 高亮显示 let highlightedCode = offlineCode; if (config.files && config.files.length > 0) { config.files.forEach(file => { if (file.fileName) { highlightedCode = highlightedCode.replace( new RegExp(`"${file.fileName}"`, 'g'), `"${file.fileName}"` ); } if (file.address) { highlightedCode = highlightedCode.replace( new RegExp(file.address.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&'), 'g'), `${file.address}` ); } if (file.algorithm) { highlightedCode = highlightedCode.replace( new RegExp(`"FLM/${file.algorithm}"`, 'g'), `"FLM/${file.algorithm}"` ); } }); } // 高亮其他配置项 highlightedCode = highlightedCode.replace(`"FLM/${flmFile}"`, `"FLM/${flmFile}"`) .replace(new RegExp(config.address1.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&'), 'g'), `${config.address1}`) .replace(new RegExp(config.address2.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&'), 'g'), `${config.address2}`) .replace(new RegExp(pythonSwdSpeed.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&'), 'g'), `${pythonSwdSpeed}`); if (codePreview) { codePreview.innerHTML = highlightedCode; } if (dragCodePreview) { dragCodePreview.innerHTML = dragCode.replace(`"FLM/${flmFile}"`, `"FLM/${flmFile}"`) .replace(new RegExp(config.address1.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&'), 'g'), `${config.address1}`) .replace(new RegExp(config.address2.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&'), 'g'), `${config.address2}`) .replace(new RegExp(pythonSwdSpeed.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&'), 'g'), `${pythonSwdSpeed}`); } } if (customFlmInput) { customFlmInput.addEventListener('input', function() { config.flmFile = this.value || 'custom_flm.FLM.o'; window.updateCodePreview(); }); } if (address1Input) { address1Input.addEventListener('input', function() { config.address1 = this.value || '0X08000000'; window.updateCodePreview(); }); } if (address2Input) { address2Input.addEventListener('input', function() { config.address2 = this.value || '0x20000000'; window.updateCodePreview(); }); } if (binFileNameInput) { binFileNameInput.addEventListener('input', function() { config.binFileName = this.value || 'firmware.bin'; window.updateCodePreview(); }); } if (swdClockSpeedSelect) { swdClockSpeedSelect.addEventListener('change', function() { config.swdClockSpeed = this.value; window.updateCodePreview(); }); } // 删除下载按钮事件监听器 // YMODEM发送按钮状态和事件 function updatePyYmodemSendBtnState() { const tab = document.querySelector('.script-tab.active'); if (tab && tab.textContent.includes('离线')) { pyYmodemSendBtn.disabled = false; pyYmodemSendBtn.setAttribute('data-pytype', 'offline'); } else if (tab && tab.textContent.includes('拖拽')) { pyYmodemSendBtn.disabled = false; pyYmodemSendBtn.setAttribute('data-pytype', 'drag'); } else { pyYmodemSendBtn.disabled = true; pyYmodemSendBtn.removeAttribute('data-pytype'); } } const scriptTabs = document.querySelectorAll('.script-tab'); if (scriptTabs.length > 0) { scriptTabs.forEach(tab => { tab.addEventListener('click', updatePyYmodemSendBtnState); }); updatePyYmodemSendBtnState(); } // Python日志输出到主终端 // Python日志输出到主终端 function pyYlog(msg, color) { appendToTerminalOutput(`
[PYTHON] ${msg}
`); } function pyYlogClear() { // 不清空主终端 } // 统一的Python YMODEM发送函数 async function sendPythonYmodem(code, fileName) { if (!window.microLinkTerminal || !window.microLinkTerminal.isConnected || !window.microLinkTerminal.port) { pyYlog('请先连接串口', '#f66'); return; } pyYmodemSendOfflineBtn && (pyYmodemSendOfflineBtn.disabled = true); pyYmodemSendDragBtn && (pyYmodemSendDragBtn.disabled = true); pyYlogClear(); pyYlog('准备发送...', '#0ff'); let wasConnected = false; if (window.microLinkTerminal) { if (window.microLinkTerminal.reader) { try { window.microLinkTerminal.reader.cancel(); } catch(e){} try { window.microLinkTerminal.reader.releaseLock(); } catch(e){} window.microLinkTerminal.reader = null; } wasConnected = window.microLinkTerminal.isConnected; window.microLinkTerminal.isConnected = false; await new Promise(r => setTimeout(r, 300)); } try { const uint8Array = new TextEncoder().encode(code); const port = window.microLinkTerminal && window.microLinkTerminal.port; if (!port) throw new Error('串口未连接'); let ok = await window.sendFileViaYmodem( port, uint8Array, fileName, uint8Array.length, msg => pyYlog(msg) ); if (ok) { pyYlog('✅ 发送完成', '#0f0'); } else { pyYlog('❌ 发送失败', '#f66'); } } catch (e) { pyYlog('❌ 发送失败: ' + e.message, '#f66'); if (e && e.stack) pyYlog('错误堆栈: ' + e.stack, '#f66'); } finally { if (window.microLinkTerminal) { window.microLinkTerminal.isConnected = wasConnected; if (wasConnected && typeof window.microLinkTerminal.startReading === 'function') { window.microLinkTerminal.startReading(); } } pyYmodemSendOfflineBtn && (pyYmodemSendOfflineBtn.disabled = false); pyYmodemSendDragBtn && (pyYmodemSendDragBtn.disabled = false); } } function getOfflineCode() { const flmFile = config.flmFile; const pythonSwdSpeed = swdClockSpeedMap[config.swdClockSpeed] || '10000000'; // 生成多文件烧录代码 let offlineCode = `import FLMConfig\nimport PikaStdLib\nimport PikaStdDevice\nimport time\n\ntime = PikaStdDevice.Time()\nbuzzer = PikaStdDevice.GPIO()\nbuzzer.setPin('PA4')\nbuzzer.setMode('out')\n\n# 设置SWD下载速度\ncmd.set_swd_clock(${pythonSwdSpeed})\n\nReadFlm = FLMConfig.ReadFlm()`; // 按算法分组文件 const algorithmGroups = {}; config.files.forEach(file => { if (file.algorithm && file.fileName && file.address) { if (!algorithmGroups[file.algorithm]) { algorithmGroups[file.algorithm] = []; } algorithmGroups[file.algorithm].push(file); } }); // 为每个算法生成加载和烧录代码 Object.keys(algorithmGroups).forEach((algorithm, index) => { const files = algorithmGroups[algorithm]; if (files.length > 0) { // 加载算法 offlineCode += `\n# 加载 ${algorithm} 下载算法文件\nresult = ReadFlm.load("FLM/${algorithm}", ${config.address1}, ${config.address2})\nif result != 0:\n return`; // 烧录该算法下的所有文件 files.forEach(file => { offlineCode += `\n\n# 烧写 ${file.fileName}\nresult = load.bin("${file.fileName}", ${file.address})\nif result != 0:\n return`; }); } }); offlineCode += `\n\n# 蜂鸣器响一声,表示烧写完成\nbuzzer.enable()\nbuzzer.high()\ntime.sleep_ms(500)\nbuzzer.low()\ntime.sleep_ms(500)`; return offlineCode; } function getDragCode() { const flmFile = config.flmFile; const pythonSwdSpeed = swdClockSpeedMap[config.swdClockSpeed] || '10000000'; return `import FLMConfig\ncmd.set_swd_clock(${pythonSwdSpeed})\nReadFlm = FLMConfig.ReadFlm()\nres1 = ReadFlm.load(\"FLM/${flmFile}\",${config.address1},${config.address2})`; } // 删除旧的pyYmodemSendBtn事件绑定 // 统一事件绑定,确保使用正确的脚本内容 const pyYmodemSendOfflineBtn = document.getElementById('pyYmodemSendOfflineBtn'); const pyYmodemSendDragBtn = document.getElementById('pyYmodemSendDragBtn'); if (pyYmodemSendOfflineBtn) { pyYmodemSendOfflineBtn.addEventListener('click', async () => { await sendPythonYmodem(getOfflineCode(), 'Python/offline_download.py'); }); } if (pyYmodemSendDragBtn) { pyYmodemSendDragBtn.addEventListener('click', async () => { await sendPythonYmodem(getDragCode(), 'Python/drag_download.py'); }); } window.updateCodePreview(); } // ... 现有代码 ... // ... 只展示相关修改 ... // 事件绑定移到函数定义之后 // 删除重复的全局getOfflineCode函数定义 function getDragCode() { const customFlmInput = document.getElementById('customFlm'); const address1Input = document.getElementById('address1'); const address2Input = document.getElementById('address2'); const swdClockSpeedSelect = document.getElementById('swdClockSpeed'); const flmFile = customFlmInput ? customFlmInput.value || 'custom_flm.FLM.o' : 'custom_flm.FLM.o'; const address1 = address1Input ? address1Input.value || '0X08000000' : '0X08000000'; const address2 = address2Input ? address2Input.value || '0x20000000' : '0x20000000'; const swdClockSpeedMap = { '10M': '10000000', '5M': '5000000', '2M': '2000000', '1M': '1000000', '500K': '500000', '200K': '200000', '100K': '100000', '50K': '50000', '20K': '20000', '10K': '10000', '5K': '5000' }; const pythonSwdSpeed = swdClockSpeedMap[swdClockSpeedSelect ? swdClockSpeedSelect.value : '10M'] || '10000000'; return `import FLMConfig\ncmd.set_swd_clock(${pythonSwdSpeed})\nReadFlm = FLMConfig.ReadFlm()\nres1 = ReadFlm.load(\"FLM/${flmFile}\",${address1},${address2})`; } // 删除重复的sendPythonYmodem函数定义 // 删除重复的事件绑定,统一在setupPythonScriptPanel中处理 // FLM .o文件 YMODEM发送 async function handleFlmYmodemSend() { const flmYmodemSendBtn = document.getElementById('flmYmodemSendBtn'); const flmYmodemProgress = document.getElementById('flmYmodemProgress'); const log = document.getElementById('log'); // 依赖 convertedBlob, flmFileName, isSerialConnected if (!window.convertedBlob) { if (log) log.textContent += '\n请先生成.o文件'; return; } if (!window.microLinkTerminal || !window.microLinkTerminal.isConnected || !window.microLinkTerminal.port) { if (log) log.textContent += '\n请先连接串口'; return; } flmYmodemSendBtn.disabled = true; flmYmodemProgress.style.display = ''; flmYmodemProgress.value = 0; if (log) log.textContent += '\n准备发送...'; let wasConnected = false; if (window.microLinkTerminal) { if (window.microLinkTerminal.reader) { try { window.microLinkTerminal.reader.cancel(); } catch(e){} try { window.microLinkTerminal.reader.releaseLock(); } catch(e){} window.microLinkTerminal.reader = null; } wasConnected = window.microLinkTerminal.isConnected; window.microLinkTerminal.isConnected = false; await new Promise(r => setTimeout(r, 300)); } try { const arrayBuffer = await window.convertedBlob.arrayBuffer(); const uint8Array = new Uint8Array(arrayBuffer); // 修改点:加上 FLM/ 前缀 const fileName = 'FLM/' + (window.flmFileName ? (window.flmFileName + '.FLM.o') : 'firmware.FLM.o'); const port = window.microLinkTerminal && window.microLinkTerminal.port; let ok = await window.sendFileViaYmodem( port, uint8Array, fileName, uint8Array.length, progress => { flmYmodemProgress.value = progress; }, msg => { if (log) log.textContent += '\n' + msg; } ); if (ok) { if (log) log.textContent += '\n✅ 文件发送完成'; } else { if (log) log.textContent += '\n❌ 发送失败'; } } catch (e) { if (log) log.textContent += '\n❌ 发送失败: ' + e.message; } finally { if (window.microLinkTerminal) { window.microLinkTerminal.isConnected = wasConnected; if (wasConnected && typeof window.microLinkTerminal.startReading === 'function') { window.microLinkTerminal.startReading(); } } flmYmodemSendBtn.disabled = false; flmYmodemProgress.style.display = 'none'; } } // Python脚本 YMODEM发送 async function handlePyYmodemSend(type) { const pyYmodemSendOfflineBtn = document.getElementById('pyYmodemSendOfflineBtn'); const pyYmodemSendDragBtn = document.getElementById('pyYmodemSendDragBtn'); const pyYmodemProgress = document.getElementById('pyYmodemProgress'); const pyYmodemLog = document.getElementById('pyYmodemLog'); function pyYlog(msg, color) { appendToTerminalOutput(`
[PYTHON] ${msg}
`); } function pyYlogClear() { // 不清空主终端 } if (!window.microLinkTerminal || !window.microLinkTerminal.isConnected || !window.microLinkTerminal.port) { pyYlog('请先连接串口', '#f66'); return; } pyYmodemSendOfflineBtn && (pyYmodemSendOfflineBtn.disabled = true); pyYmodemSendDragBtn && (pyYmodemSendDragBtn.disabled = true); pyYmodemProgress && (pyYmodemProgress.value = 0); pyYmodemProgress && (pyYmodemProgress.style.display = ''); pyYlogClear(); pyYlog('准备发送...', '#0ff'); let wasConnected = false; if (window.microLinkTerminal) { if (window.microLinkTerminal.reader) { try { window.microLinkTerminal.reader.cancel(); } catch(e){} try { window.microLinkTerminal.reader.releaseLock(); } catch(e){} window.microLinkTerminal.reader = null; } wasConnected = window.microLinkTerminal.isConnected; window.microLinkTerminal.isConnected = false; await new Promise(r => setTimeout(r, 300)); } try { let code = ''; let fileName = ''; if (type === 'offline') { code = window.getOfflineCode ? window.getOfflineCode() : ''; fileName = 'Python/offline_download.py'; } else if (type === 'drag') { code = window.getDragCode ? window.getDragCode() : ''; fileName = 'Python/drag_download.py'; } else { pyYlog('只允许发送离线下载脚本或拖拽下载脚本', '#f66'); return; } const uint8Array = new TextEncoder().encode(code); const port = window.microLinkTerminal && window.microLinkTerminal.port; let ok = await window.sendFileViaYmodem( port, uint8Array, fileName, uint8Array.length, msg => pyYlog(msg) ); if (ok) { pyYlog('✅ 发送完成', '#0f0'); } else { pyYlog('❌ 发送失败', '#f66'); } } catch (e) { pyYlog('❌ 发送失败: ' + e.message, '#f66'); if (e && e.stack) pyYlog('错误堆栈: ' + e.stack, '#f66'); } finally { if (window.microLinkTerminal) { window.microLinkTerminal.isConnected = wasConnected; if (wasConnected && typeof window.microLinkTerminal.startReading === 'function') { window.microLinkTerminal.startReading(); } } pyYmodemSendOfflineBtn && (pyYmodemSendOfflineBtn.disabled = false); pyYmodemSendDragBtn && (pyYmodemSendDragBtn.disabled = false); pyYmodemProgress && (pyYmodemProgress.style.display = 'none'); } } // ... existing code ... function setupVarAnalysisPanel() { // 只绑定一次 if (window._varPanelInited) return; window._varPanelInited = true; const fileInput = document.getElementById('axfFile'); const analyzeBtn = document.getElementById('analyzeBtn'); if (!fileInput || !analyzeBtn) return; fileInput.addEventListener('change', function(e) { if (this.files.length > 0) { analyzeBtn.disabled = false; const fileInfo = document.getElementById('fileInfo'); if (fileInfo) fileInfo.classList.add('d-none'); } else { analyzeBtn.disabled = true; } }); } // ... existing code ... // ========== 变量分析tab实时曲线功能 ========== let chartData = []; let chartInstance = null; let chartDrawEnabled = false; // 将chartDrawEnabled挂载到全局,供数据处理函数使用 window.chartDrawEnabled = chartDrawEnabled; function setupRealtimeChart() { const chartDom = document.getElementById('realtimeChart'); if (!chartDom) return; if (!window.echarts) return; // 初始化图表数据 if (!window.chartData) { window.chartData = []; } chartInstance = echarts.init(chartDom); // 延迟初始化多变量图表管理器,确保DOM元素已创建 setTimeout(() => { window.multiChartManager = new MultiChartManager(); console.log('多变量图表管理器初始化完成'); }, 200); // 初始化后强制resize,确保图表尺寸正确 setTimeout(() => { if (chartInstance) { chartInstance.resize(); } }, 100); // 采用test.html的ECharts配置 chartInstance.setOption({ xAxis: { type: 'value', name: '时间 (秒)', nameLocation: 'middle', nameGap: 30 }, yAxis: { type: 'value', scale: true, name: '数值', nameLocation: 'middle', nameGap: 40 }, series: [{ type: 'line', data: [], smooth: true, symbol: 'none', lineStyle: { width: 2, color: '#3498db' }, name: '实时数据' }], grid: { left: 80, right: 40, top: 50, bottom: 80, containLabel: true }, animation: false, tooltip: { trigger: 'axis', formatter: function(params) { const data = params[0]; return `时间: ${data.value[0].toFixed(2)}s
数值: ${data.value[1]}`; } } }); document.getElementById('clearChartBtn').onclick = function() { window.chartData = []; chartTimeData = []; // 清除时间数据 chartDataBuffer = []; // 清除缓冲区 dataIntegrityErrors = 0; // 重置错误计数 expectedDataPattern = null; // 重置数据模式 lastProcessedTime = 0; // 重置时间戳 // 停止Worker if (chartDataWorker) { chartDataWorker.postMessage({ type: 'stop' }); } if (chartRenderWorker) { chartRenderWorker.postMessage({ type: 'stop' }); } // 清空多变量图表 if (window.multiChartManager) { window.multiChartManager.clearAllCharts(); } // 清空图表数据,不调用updateRealtimeChart避免覆盖时间轴逻辑 if (chartInstance) { chartInstance.setOption({ series: [{ data: [] }] }); } // 重置开始绘制按钮状态 const startBtn = document.getElementById('startBtn'); if (startBtn) { startBtn.textContent = '开始绘制'; startBtn.className = 'btn btn-success'; } chartDrawEnabled = false; window.chartDrawEnabled = chartDrawEnabled; console.log('[曲线模式] 已清除数据并停止绘制'); console.log('[曲线缓冲] 已清除缓冲区'); console.log('[数据完整性] 已重置错误计数器和数据模式'); console.log('[Worker] 已停止数据处理Worker'); console.log('[数据帧解析] 已清空数据帧缓冲区'); }; // 开始绘制按钮 const startBtn = document.getElementById('startChartBtn'); if (startBtn) { startBtn.onclick = function() { if (!chartDrawEnabled) { // 开始绘制 chartDrawEnabled = true; window.chartDrawEnabled = chartDrawEnabled; startBtn.textContent = '暂停绘制'; startBtn.className = 'btn btn-warning'; // 自动开启HEX模式 const hexModeCheckbox = document.getElementById('hexMode'); if (hexModeCheckbox && !hexModeCheckbox.checked) { hexModeCheckbox.checked = true; hexModeCheckbox.dispatchEvent(new Event('change')); } // 在控制台显示曲线模式已启动 console.log('[曲线模式] 已启动 - 串口数据将直接用于曲线绘制,不显示在主监控'); console.log('[曲线模式] 请确保设备发送4字节对齐的HEX数据'); // 清除缓冲区,准备接收新数据 chartDataBuffer = []; chartTimeData = []; // 清除时间数据 dataIntegrityErrors = 0; // 重置错误计数 expectedDataPattern = null; // 重置数据模式 lastProcessedTime = 0; // 重置时间戳 // 初始化Worker initChartWorkers(); // 启动Worker if (chartDataWorker) { chartDataWorker.postMessage({ type: 'start' }); } if (chartRenderWorker) { chartRenderWorker.postMessage({ type: 'start' }); } console.log('[曲线缓冲] 已清除缓冲区,准备接收新数据'); console.log('[数据完整性] 已重置错误计数器和数据模式'); console.log('[Worker] 已启动数据处理Worker'); } else { // 暂停绘制 chartDrawEnabled = false; window.chartDrawEnabled = chartDrawEnabled; startBtn.textContent = '继续绘制'; startBtn.className = 'btn btn-success'; // 停止Worker if (chartDataWorker) { chartDataWorker.postMessage({ type: 'stop' }); } if (chartRenderWorker) { chartRenderWorker.postMessage({ type: 'stop' }); } console.log('[曲线模式] 已暂停 - 恢复正常监控显示'); console.log('[Worker] 已停止数据处理Worker'); } }; } // 终止绘制按钮逻辑 const stopChartBtn = document.getElementById('stopChartBtn'); if (stopChartBtn) { stopChartBtn.onclick = function() { console.log('[终止绘制] 按钮被点击'); // 1. 清空曲线数据 window.chartData = []; chartTimeData = []; // 清除时间数据 chartDataBuffer = []; // 清除缓冲区 dataIntegrityErrors = 0; // 重置错误计数 expectedDataPattern = null; // 重置数据模式 lastProcessedTime = 0; // 重置时间戳 // 2. 停止曲线绘制 if (window.chartDrawEnabled) { window.chartDrawEnabled = false; chartDrawEnabled = false; // 更新按钮状态 const startBtn = document.getElementById('startChartBtn'); if (startBtn) { startBtn.textContent = '开始绘制'; startBtn.className = 'btn btn-success'; } // 停止Worker if (window.chartDataWorker) { window.chartDataWorker.postMessage({ type: 'stop' }); } if (window.chartRenderWorker) { window.chartRenderWorker.postMessage({ type: 'stop' }); } console.log('[终止绘制] 曲线绘制已停止'); } // 3. 清空多变量图表 if (window.multiChartManager) { window.multiChartManager.clearAllCharts(); } // 4. 清空图表显示 if (chartInstance) { chartInstance.setOption({ series: [{ data: [] }] }); } // 5. 发送终止绘制命令(如果串口已连接) const stopCommand = 'cmd.read_ram(0x20000000,1,0)'; if (window.microLinkTerminal && window.microLinkTerminal.isConnected) { window.microLinkTerminal.sendCommand(stopCommand); console.log('[终止绘制] 已发送停止命令:', stopCommand); } else { console.log('[终止绘制] 串口未连接,跳过发送命令'); } // 6. 关闭监听框的HEX模式 const hexModeCheckbox = document.getElementById('hexMode'); if (hexModeCheckbox && hexModeCheckbox.checked) { hexModeCheckbox.checked = false; hexModeCheckbox.dispatchEvent(new Event('change')); console.log('[终止绘制] 已关闭HEX模式'); } console.log('[终止绘制] 操作完成 - 已清空曲线并停止绘制'); }; } // 保证全局可用(每次都强制挂载,防止tab切换后失效) window.chartData = window.chartData || []; window.updateRealtimeChart = updateRealtimeChart; // 旧的handleRealtimeHexData已删除,现在使用Worker方式 window.chartInstance = chartInstance; // 再次初始化终端区监听,防止tab切换后丢失 if (!window._terminalHexChartSyncInited) { setupTerminalHexChartSync(); window._terminalHexChartSyncInited = true; } } // 全局时间轴数据 let chartTimeData = []; // 将chartTimeData挂载到全局,供多变量图表使用 window.chartTimeData = chartTimeData; function updateRealtimeChart() { if (!chartInstance) return; // 确保有足够的数据点来显示X轴 const data = window.chartData || []; const timeData = chartTimeData || []; // 如果时间数据不足,补充时间数据 while (timeData.length < data.length) { const currentTime = new Date(); timeData.push(currentTime.toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit', fractionalSecondDigits: 3 })); } // 动态计算标签间隔,避免重叠 const maxLabels = 15; // 最多显示15个时间标签 const interval = Math.max(0, Math.floor(timeData.length / maxLabels)); chartInstance.setOption({ xAxis: { type: 'category', data: timeData, axisLabel: { show: true, interval: interval, rotate: 45, // 旋转标签避免重叠 fontSize: 10, margin: 12, formatter: function(value) { // 只显示时:分:秒,不显示毫秒 return value.split('.')[0]; } }, axisTick: { show: true, interval: interval }, name: '时间', nameLocation: 'middle', nameGap: 30 }, yAxis: { type: 'value', scale: true, axisLabel: { show: true, fontSize: 10 }, name: '数值', nameLocation: 'middle', nameGap: 40 }, series: [{ type: 'line', data: data, smooth: true, symbol: 'none', lineStyle: { width: 2, color: '#3498db' }, name: '实时数据' }], tooltip: { trigger: 'axis', formatter: function(params) { const data = params[0]; const timeStr = timeData[data.dataIndex] || '未知时间'; return `时间: ${timeStr}
数值: ${data.value}`; } } }); // 强制重新计算布局 setTimeout(() => { if (chartInstance) { chartInstance.resize(); // 非全屏时也强制重新计算,模拟全屏的效果 setTimeout(() => { chartInstance.resize(); }, 50); } }, 100); } // 全局缓冲区用于处理分片数据 let chartDataBuffer = []; let lastProcessedTime = 0; let dataIntegrityErrors = 0; let expectedDataPattern = null; // 用于检测数据模式 let chartDataWorker = null; // 数据处理Worker let chartRenderWorker = null; // 渲染Worker // 初始化Worker function initChartWorkers() { if (chartDataWorker || chartRenderWorker) return; // 数据Worker脚本 - 采用test.html的简单策略 const dataWorkerScript = ` let dataBuffer = []; let isRunning = false; let lastReportTime = 0; onmessage = function(e) { switch (e.data.type) { case 'start': isRunning = true; dataBuffer = []; lastReportTime = performance.now(); break; case 'stop': isRunning = false; break; case 'serial_data': if (isRunning) { // 使用传入的时间戳,完全采用test.html的方式 e.data.values.forEach(val => { dataBuffer.push({ timestamp: e.data.timestamp, value: val }); }); // 每100个数据点或100ms发送一次(参考test.html) if (dataBuffer.length >= 100 || performance.now() - lastReportTime > 100) { postMessage({ type: 'data_batch', data: dataBuffer.splice(0, dataBuffer.length) }); lastReportTime = performance.now(); } } break; } }; `; // 渲染Worker脚本 - 完全采用test.html的逻辑 const renderWorkerScript = ` let isRunning = false; let renderRate = 60; let maxDataPoints = 10000; let dataBuffer = []; let lastRenderTime = 0; let renderCount = 0; let startTime = 0; let totalDataPoints = 0; let timeWindow = 5; let historyPosition = 1.0; function processData() { if (!isRunning) return; const now = performance.now(); if (now - lastRenderTime >= 1000 / renderRate) { const chartData = prepareChartData(); postMessage({ type: 'render', data: chartData, stats: { renderRate: renderCount / ((now - startTime) / 1000), totalDataPoints: totalDataPoints, dataBufferSize: dataBuffer.length } }); lastRenderTime = now; renderCount++; } if (isRunning) { setTimeout(processData, 1000 / renderRate); } } function prepareChartData() { if (dataBuffer.length === 0) return []; // 根据历史位置计算要显示的数据范围 const totalPoints = dataBuffer.length; const startIndex = Math.floor(totalPoints * (1 - historyPosition)); const endIndex = totalPoints; // 获取指定范围的数据 const filteredData = dataBuffer.slice(startIndex, endIndex); // 返回数据,时间轴显示实际时间(秒)- 完全采用test.html的方式 const result = filteredData.map(item => ({ time: (item.timestamp - dataBuffer[0].timestamp) / 1000, value: item.value })); // 移除调试信息,时间轴已经正常工作 return result; } function compressData() { // 限制数据点数量,避免内存占用过大 if (dataBuffer.length > maxDataPoints) { const step = Math.ceil(dataBuffer.length / maxDataPoints); dataBuffer = dataBuffer.filter((_, index) => index % step === 0); } } function cleanupOldData() { const now = performance.now(); // 保留最近60秒的数据,避免内存无限增长 const cutoffTime = now - 60000; dataBuffer = dataBuffer.filter(item => item.timestamp >= cutoffTime); } onmessage = function(e) { switch (e.data.type) { case 'start': isRunning = true; renderRate = e.data.renderRate || 60; maxDataPoints = e.data.maxDataPoints || 10000; timeWindow = e.data.timeWindow || 5; historyPosition = e.data.historyPosition || 1.0; startTime = performance.now(); renderCount = 0; totalDataPoints = 0; dataBuffer = []; lastRenderTime = 0; processData(); break; case 'stop': isRunning = false; break; case 'data_batch': dataBuffer.push(...e.data.data); totalDataPoints += e.data.data.length; compressData(); cleanupOldData(); break; case 'config': historyPosition = e.data.historyPosition || historyPosition; break; case 'clear': dataBuffer = []; totalDataPoints = 0; break; case 'get_stats': postMessage({ type: 'stats', stats: { renderRate: renderCount / ((performance.now() - startTime) / 1000), totalDataPoints: totalDataPoints, dataBufferSize: dataBuffer.length } }); break; } }; `; const dataWorkerBlob = new Blob([dataWorkerScript], { type: 'application/javascript' }); const renderWorkerBlob = new Blob([renderWorkerScript], { type: 'application/javascript' }); chartDataWorker = new Worker(URL.createObjectURL(dataWorkerBlob)); chartRenderWorker = new Worker(URL.createObjectURL(renderWorkerBlob)); // 设置Worker消息处理 chartDataWorker.onmessage = (e) => { if (e.data.type === 'data_batch') { chartRenderWorker.postMessage({ type: 'data_batch', data: e.data.data }); } }; chartRenderWorker.onmessage = (e) => { if (e.data.type === 'render') { updateChartFromWorker(e.data.data); } }; console.log('[Worker] 图表Worker已初始化'); } // 停止图表绘制 function stopChartDrawing() { chartDrawEnabled = false; window.chartDrawEnabled = chartDrawEnabled; // 停止Worker if (chartDataWorker) { chartDataWorker.postMessage({ type: 'stop' }); } if (chartRenderWorker) { chartRenderWorker.postMessage({ type: 'stop' }); } // 重置性能统计 performanceStats = { dataPointsReceived: 0, renderCount: 0, lastRenderTime: 0, averageRenderTime: 0 }; // 重置数据质量统计 dataQualityStats = { totalBytesReceived: 0, validDataPoints: 0, errorCount: 0, recoveryCount: 0, lastReportTime: 0 }; // 更新按钮状态 const startBtn = document.getElementById('startChartBtn'); if (startBtn) { startBtn.textContent = '开始绘制'; startBtn.className = 'btn btn-success'; } console.log('[性能保护] 已自动停止图表绘制'); } // 性能监控变量 let performanceStats = { dataPointsReceived: 0, renderCount: 0, lastRenderTime: 0, averageRenderTime: 0 }; // 数据质量统计 let dataQualityStats = { totalBytesReceived: 0, validDataPoints: 0, errorCount: 0, recoveryCount: 0, lastReportTime: 0 }; // 从Worker更新图表 // 采用test.html的updateChart逻辑 function updateChartFromWorker(data) { if (!chartInstance || !chartDrawEnabled) return; const startTime = performance.now(); // 采用test.html的数据格式:直接使用time和value const chartData = data.map(d => [d.time, d.value]); // 更新性能统计 performanceStats.dataPointsReceived += data.length; performanceStats.renderCount++; const renderTime = performance.now() - startTime; performanceStats.averageRenderTime = (performanceStats.averageRenderTime * (performanceStats.renderCount - 1) + renderTime) / performanceStats.renderCount; // 每10次渲染输出一次性能统计 //if (performanceStats.renderCount % 10 === 0) { //console.log(`[性能统计] 数据点: ${performanceStats.dataPointsReceived}, 渲染次数: ${performanceStats.renderCount}, 平均渲染时间: ${performanceStats.averageRenderTime.toFixed(2)}ms`); //} // 性能保护:如果渲染时间过长,自动停止 if (renderTime > 100) { console.warn(`[性能警告] 渲染时间过长: ${renderTime.toFixed(2)}ms,自动停止绘制`); stopChartDrawing(); return; } // 自适应性能调节 if (renderTime > 50 && performanceStats.renderCount > 20) { console.warn(`[性能调节] 渲染时间较长: ${renderTime.toFixed(2)}ms,建议降低采样率或关闭完整性检查`); } // 完全采用test.html的updateChart逻辑 const chartDataArray = data.map(d => [d.time, d.value]); const option = { series: [{ data: chartDataArray }] }; // 采用新工程的x轴配置方式,不设置min/max,让ECharts自动计算合适的范围 if (data.length > 0) { option.xAxis = { type: 'value', name: '时间 (秒)', nameLocation: 'middle', nameGap: 30 }; } chartInstance.setOption(option); } function handleRealtimeHexDataChunked(data) { if (!chartDrawEnabled) return; if (!(data instanceof Uint8Array)) return; const currentTime = Date.now(); // 更新数据质量统计 dataQualityStats.totalBytesReceived += data.length; // 采用test.html的数组方式处理数据 // 将Uint8Array转换为数组,便于使用push和splice if (!Array.isArray(chartDataBuffer)) { chartDataBuffer = Array.from(chartDataBuffer); } // 采用test.html的数据转换方式:逐个字节转换 const values = []; for (let i = 0; i < data.length; i++) { values.push(data[i]); } // 将新数据添加到缓冲区 chartDataBuffer.push(...values); // 调试输出:显示拼接后的数据 if (chartDataBuffer.length > 0) { const firstBytes = chartDataBuffer.slice(0, Math.min(8, chartDataBuffer.length)) .map(b => b.toString(16).padStart(2, '0')).join(' '); console.log(`[数据拼接] 缓冲区大小: ${chartDataBuffer.length}, 前8字节: ${firstBytes}`); // 简化调试输出,只在调试模式下显示详细信息 const debugCheckbox = document.getElementById('debugMode'); const enableDebug = debugCheckbox && debugCheckbox.checked; if (enableDebug) { // 如果剩余字节不是4的倍数,记录警告 if (chartDataBuffer.length % 4 !== 0) { console.warn(`[边界警告] 缓冲区大小 ${chartDataBuffer.length} 不是4的倍数`); } } } console.log(`[曲线缓冲] 缓冲区大小: ${chartDataBuffer.length} 字节`); // 采用test.html的即时处理策略:每次接收到数据就立即处理 // 处理完整的4字节组 while (chartDataBuffer.length >= 4) { // 采用test.html的简单策略:不做任何数据验证,直接处理所有数据 const value = (chartDataBuffer[0] | (chartDataBuffer[1]<<8) | (chartDataBuffer[2]<<16) | (chartDataBuffer[3]<<24)) >>> 0; const seg = [chartDataBuffer[0], chartDataBuffer[1], chartDataBuffer[2], chartDataBuffer[3]]; // 发送数据到Worker if (chartDataWorker) { chartDataWorker.postMessage({ type: 'serial_data', values: [value] }); } // 在控制台打印详细信息 console.log(`[曲线解析] ✅ 4字节: ${seg.map(x=>x.toString(16).padStart(2,'0')).join(' ')} -> 小端解析: 0x${value.toString(16).padStart(8,'0')} (${value})`); // 更新最后处理时间 lastProcessedTime = currentTime; // 重置错误计数 dataIntegrityErrors = 0; // 更新统计 dataQualityStats.validDataPoints++; // 移除已处理的4字节(参考test.html的splice方法) chartDataBuffer.splice(0, 4); } // 移除已处理的4字节(参考test.html的splice方法) chartDataBuffer.splice(0, 4); } // 检查缓冲区是否积压过多(可能数据丢失) if (chartDataBuffer.length > 20) { console.warn(`[曲线警告] 缓冲区积压过多 (${chartDataBuffer.length} 字节),可能数据丢失`); chartDataBuffer = []; dataIntegrityErrors++; } // 定期输出数据质量统计 const now = performance.now(); if (now - dataQualityStats.lastReportTime > 5000) { // 每5秒输出一次 const errorRate = dataQualityStats.totalBytesReceived > 0 ? (dataQualityStats.errorCount / dataQualityStats.totalBytesReceived * 100).toFixed(2) : '0.00'; const recoveryRate = dataQualityStats.errorCount > 0 ? (dataQualityStats.recoveryCount / dataQualityStats.errorCount * 100).toFixed(2) : '0.00'; console.log(`[数据质量统计] 总字节: ${dataQualityStats.totalBytesReceived}, 有效数据点: ${dataQualityStats.validDataPoints}, 错误: ${dataQualityStats.errorCount}, 恢复: ${dataQualityStats.recoveryCount}, 错误率: ${errorRate}%, 恢复率: ${recoveryRate}%`); dataQualityStats.lastReportTime = now; } console.log(`[曲线处理] 完成处理,剩余缓冲区: ${chartDataBuffer.length} 字节,错误次数: ${dataIntegrityErrors}`); // 智能数据恢复函数 function attemptSmartRecovery() { if (chartDataBuffer.length < 8) { return false; // 缓冲区数据不足 } console.log(`[数据恢复] 尝试智能恢复,缓冲区大小: ${chartDataBuffer.length}`); // 尝试不同的偏移量 for (let offset = 1; offset <= 4 && chartDataBuffer.length >= 4 + offset; offset++) { const testBytes = [ chartDataBuffer[offset], chartDataBuffer[offset+1], chartDataBuffer[offset+2], chartDataBuffer[offset+3] ]; const testValue = (testBytes[0] | (testBytes[1]<<8) | (testBytes[2]<<16) | (testBytes[3]<<24)) >>> 0; // 检查这个偏移量是否产生合理的数据 if (testValue > 0 && testValue <= 1000000) { console.log(`[数据恢复] 找到有效偏移量: ${offset},新值: ${testValue}`); chartDataBuffer = chartDataBuffer.slice(offset); dataQualityStats.recoveryCount++; return true; } } // 如果找不到有效偏移量,丢弃前4字节 console.log(`[数据恢复] 未找到有效偏移量,丢弃前4字节`); chartDataBuffer = chartDataBuffer.slice(4); return false; } // 数据完整性检查函数 function checkDataIntegrity(bytes, value) { // 检查1: 数值合理性(不能为0或过大) if (value === 0) { console.warn(`[完整性检查] 数值为0,可能数据丢失`); return false; } if (value > 0x7FFFFFFF) { console.warn(`[完整性检查] 数值过大 (${value}),可能数据错位`); return false; } // 检查2: 时间间隔合理性(放宽限制) const currentTime = Date.now(); if (lastProcessedTime > 0 && (currentTime - lastProcessedTime) > 30000) { // 改为30秒 console.warn(`[完整性检查] 数据间隔过长 (${currentTime - lastProcessedTime}ms),可能数据丢失`); return false; } // 检查3: 数据模式一致性(只在有足够数据且模式稳定时检查) if (expectedDataPattern !== null && window.chartData && window.chartData.length > 10) { const patternMatch = checkDataPattern(bytes); if (!patternMatch) { console.warn(`[完整性检查] 数据模式不匹配,可能数据错位`); return false; } } // 建立数据模式(前几个数据点) if (window.chartData && window.chartData.length < 3) { establishDataPattern(bytes); } return true; } // 建立数据模式 function establishDataPattern(bytes) { if (expectedDataPattern === null) { expectedDataPattern = { firstByte: bytes[0], secondByte: bytes[1], pattern: [] }; console.log(`[模式建立] 建立数据模式: 首字节=${bytes[0].toString(16).padStart(2,'0')}, 次字节=${bytes[1].toString(16).padStart(2,'0')}`); } } // 检查数据模式 function checkDataPattern(bytes) { if (expectedDataPattern === null) return true; // 放宽模式检查:只要前两个字节不是完全相同的固定值就认为有效 // 这样可以适应数据变化的情况 if (bytes[0] !== bytes[1]) { return true; } // 如果前两个字节相同,可能是固定模式,需要进一步检查 if (bytes[0] === expectedDataPattern.firstByte && bytes[1] === expectedDataPattern.secondByte) { return true; } return false; } // 尝试数据恢复 function attemptDataRecovery() { console.log(`[数据恢复] 尝试在缓冲区中寻找有效数据边界...`); // 寻找可能的4字节边界 for (let i = 1; i < chartDataBuffer.length - 3; i++) { const testBytes = [chartDataBuffer[i], chartDataBuffer[i+1], chartDataBuffer[i+2], chartDataBuffer[i+3]]; const testValue = (testBytes[0] | (testBytes[1]<<8) | (testBytes[2]<<16) | (testBytes[3]<<24)) >>> 0; // 检查这个位置是否可能是有效数据 if (testValue > 0 && testValue <= 0x7FFFFFFF) { console.log(`[数据恢复] 在位置 ${i} 找到可能的有效数据边界`); chartDataBuffer = chartDataBuffer.slice(i); return true; } } return false; } // 旧的handleRealtimeHexData函数已删除,现在使用Worker方式处理数据 // ... existing code ... function setupTerminalHexChartSync() { // 这个函数现在不再需要,因为数据直接在handleReceivedData中处理 // 保留函数以避免调用错误,但不执行任何操作 console.log('[曲线同步] 已禁用终端监控同步,数据直接通过串口处理'); } // ... existing code ... // ... existing code ... // 全屏样式 (function(){ const style = document.createElement('style'); style.innerHTML += `\n.realtime-chart-fullscreen {\n position: fixed !important;\n top: 0; left: 0; right: 0; bottom: 0;\n z-index: 9999;\n background: #fff;\n margin: 0 !important;\n padding: 20px !important;\n border-radius: 0 !important;\n width: 100vw !important;\n height: 100vh !important;\n box-shadow: 0 0 0 9999px rgba(0,0,0,0.15);\n display: flex;\n flex-direction: column;\n align-items: stretch;\n justify-content: flex-start;\n}\n.realtime-chart-fullscreen #realtimeChart {\n flex: 1;\n height: auto !important;\n min-height: 0 !important;\n}`; document.head.appendChild(style); })(); // ... existing code ... // ... existing code ... // ... existing code ... // ... existing code ... // 页面加载时初始化曲线图 if (document.readyState === 'complete' || document.readyState === 'interactive') { setTimeout(setupRealtimeChart, 300); } else { document.addEventListener('DOMContentLoaded', setupRealtimeChart); } // 立即绑定终止绘制按钮事件,确保按钮可用 function bindStopChartButton() { const stopChartBtn = document.getElementById('stopChartBtn'); if (stopChartBtn) { stopChartBtn.onclick = function() { console.log('[终止绘制] 按钮被点击'); // 1. 清空曲线数据 window.chartData = []; window.chartTimeData = []; // 清除时间数据 window.chartDataBuffer = []; // 清除缓冲区 // 2. 停止曲线绘制 if (window.chartDrawEnabled) { window.chartDrawEnabled = false; // 更新按钮状态 const startBtn = document.getElementById('startChartBtn'); if (startBtn) { startBtn.textContent = '开始绘制'; startBtn.className = 'btn btn-success'; } // 停止Worker if (window.chartDataWorker) { window.chartDataWorker.postMessage({ type: 'stop' }); } if (window.chartRenderWorker) { window.chartRenderWorker.postMessage({ type: 'stop' }); } console.log('[终止绘制] 曲线绘制已停止'); } // 3. 清空多变量图表 if (window.multiChartManager) { window.multiChartManager.clearAllCharts(); } // 4. 清空图表显示 if (window.chartInstance) { window.chartInstance.setOption({ series: [{ data: [] }] }); } // 5. 发送终止绘制命令(如果串口已连接) const stopCommand = 'cmd.read_ram(0x20000000,1,0)'; if (window.microLinkTerminal && window.microLinkTerminal.isConnected) { window.microLinkTerminal.sendCommand(stopCommand); console.log('[终止绘制] 已发送停止命令:', stopCommand); } else { console.log('[终止绘制] 串口未连接,跳过发送命令'); } // 6. 关闭监听框的HEX模式 const hexModeCheckbox = document.getElementById('hexMode'); if (hexModeCheckbox && hexModeCheckbox.checked) { hexModeCheckbox.checked = false; hexModeCheckbox.dispatchEvent(new Event('change')); console.log('[终止绘制] 已关闭HEX模式'); } console.log('[终止绘制] 操作完成 - 已清空曲线并停止绘制'); }; console.log('[终止绘制] 按钮事件已绑定'); } else { console.log('[终止绘制] 按钮未找到,将在setupRealtimeChart中绑定'); } } // 立即尝试绑定按钮 bindStopChartButton(); // 如果DOM还没准备好,等待后再次尝试 if (document.readyState !== 'complete') { document.addEventListener('DOMContentLoaded', bindStopChartButton); } // 切换到变量分析tab时也初始化曲线图,防止切换后全局变量丢失 function setupVarPanelRealtimeChartInit() { setTimeout(setupRealtimeChart, 200); } const varPanelBtn = document.querySelector('.sidebar-btn[data-panel="varPanel"]'); if (varPanelBtn) { varPanelBtn.addEventListener('click', setupVarPanelRealtimeChartInit); } // ... existing code ... // ... existing code ... // 删除旧的串口发送区逻辑,因为HTML结构已经改变 // ... existing code ... // ... existing code ... // 删除重复的开始绘制按钮逻辑,已在setupRealtimeChart中处理 // ... existing code ... // 获取主终端输出区域 function appendToTerminalOutput(html) { const terminalOutput = document.getElementById('terminalOutput'); if (terminalOutput) { terminalOutput.insertAdjacentHTML('beforeend', html); terminalOutput.scrollTop = terminalOutput.scrollHeight; } } // 删除重复的全局日志函数定义 // test.html的简化数据处理逻辑 function parseSerialData(buffer) { const view = new Uint8Array(buffer); const values = []; // 每个字节作为一个独立的十进制值 for (let i = 0; i < view.length; i++) { values.push(view[i]); } return values; } function processSerialBuffer() { // 如果缓冲区有足够的数据,按4字节一组处理 while (chartDataBuffer.length >= 4) { const group = chartDataBuffer.splice(0, 4); console.log('处理4字节组:', group); // 将4字节组合成32位整数 (小端序 - 从后往前读) const value = group[0] | (group[1] << 8) | (group[2] << 16) | (group[3] << 24); console.log('32位整数值 (小端序):', value); // 发送数据到Worker - 添加时间戳,完全采用test.html的方式 if (chartDataWorker) { const timestamp = performance.now(); chartDataWorker.postMessage({ type: 'serial_data', values: [value], timestamp: timestamp }); } } } // 新的简化版本,支持AA和55数据帧头帧尾解析 function handleRealtimeHexDataChunkedSimple(data) { if (!chartDrawEnabled) return; if (!(data instanceof Uint8Array)) return; // 将新数据添加到缓冲区 if (!Array.isArray(chartDataBuffer)) { chartDataBuffer = []; } chartDataBuffer.push(...Array.from(data)); // console.log('接收到串口数据块:', data.length, '字节, 缓冲区大小:', chartDataBuffer.length); // 查找并解析AA和55之间的数据帧 parseDataFrames(); } // 解析AA和55之间的数据帧 function parseDataFrames() { const frameStart = 0xAA; // 帧头 const frameEnd = 0x55; // 帧尾 while (chartDataBuffer.length >= 2) { // 查找帧头AA const startIndex = chartDataBuffer.indexOf(frameStart); if (startIndex === -1) { // 没有找到帧头,清空缓冲区 chartDataBuffer.splice(0, chartDataBuffer.length); return; } // 查找帧尾55(从帧头之后开始查找) const endIndex = chartDataBuffer.indexOf(frameEnd, startIndex + 1); if (endIndex === -1) { // 没有找到帧尾,保留从帧头开始的数据,等待更多数据 chartDataBuffer.splice(0, startIndex); return; } // 提取帧头帧尾之间的数据 const frameData = chartDataBuffer.slice(startIndex + 1, endIndex); // console.log('找到数据帧:', { // start: startIndex, // end: endIndex, // frameLength: frameData.length, // frameData: Array.from(frameData).map(b => '0x' + b.toString(16).padStart(2, '0').toUpperCase()) // }); // 处理提取的数据帧 processDataFrame(frameData); // 移除已处理的数据(包括帧头、帧尾和中间数据) chartDataBuffer.splice(0, endIndex + 1); } } // 处理提取的数据帧 function processDataFrame(frameData) { if (frameData.length === 0) return; // console.log('处理数据帧:', frameData.length, '字节:', Array.from(frameData).map(b => '0x' + b.toString(16).padStart(2, '0').toUpperCase())); // 检查是否有选中的变量 if (!window.selectedVariables || window.selectedVariables.size === 0) { console.log('没有选中的变量,跳过数据处理'); return; } // 获取选中的变量列表 const selectedVars = Array.from(window.selectedVariables); const maxVars = Math.min(selectedVars.length, 9); // 最多9个变量 console.log(`[数据处理] 开始处理数据帧:`, { '帧长度': frameData.length, '选中变量数量': selectedVars.length, '最大处理数量': maxVars, '选中变量': selectedVars, '数据内容': Array.from(frameData).map(b => '0x' + b.toString(16).padStart(2, '0').toUpperCase()) }); // 根据变量大小动态解析数据 let dataIndex = 0; let frameIndex = 0; // 特殊处理:如果数据帧很小,调整处理策略 if (frameData.length === 1) { console.log(`[数据处理] 检测到1字节数据帧,启用特殊处理模式`); // 对于1字节数据帧,强制第一个变量为1字节 if (selectedVars.length > 0) { const varName = selectedVars[0]; console.log(`[数据处理] 1字节模式:将变量 ${varName} 设置为1字节`); } } while (frameIndex < frameData.length && dataIndex < maxVars) { const varName = selectedVars[dataIndex]; // 获取变量的字节大小 - 优先使用选择的变量实际大小 let varSize = 4; // 默认4字节 // 优先从变量信息中获取实际大小 if (window.variableInfo && window.variableInfo[varName]) { varSize = window.variableInfo[varName].size || 4; console.log(`[数据处理] 使用变量 ${varName} 的实际大小: ${varSize}字节`); } else { // 如果无法获取变量大小,尝试从命令中推断 const remainingBytes = frameData.length - frameIndex; // 特殊处理:如果数据帧只有1字节且是第一个变量,强制使用1字节 if (frameData.length === 1 && dataIndex === 0) { varSize = 1; console.log(`[数据处理] 1字节数据帧模式:强制变量 ${varName} 为1字节`); } else if (remainingBytes >= 4) { // 数据充足,可以使用4字节 varSize = 4; } else if (remainingBytes >= 2) { // 数据足够2字节 varSize = 2; } else if (remainingBytes >= 1) { // 只有1字节数据,强制使用1字节 varSize = 1; console.log(`[数据处理] 数据不足,将变量 ${varName} 调整为1字节大小`); } else { // 没有数据了 console.log(`[数据处理] 没有剩余数据,跳过变量 ${varName}`); break; } console.log(`[数据处理] 智能推断变量 ${varName} 大小: ${varSize}字节 (剩余数据: ${remainingBytes}字节)`); } // 检查剩余数据是否足够(这个检查现在由智能推断逻辑处理) // 如果智能推断后仍然不足,记录警告但继续处理 if (frameData.length - frameIndex < varSize) { console.warn(`[数据处理] 警告:变量 ${varName} 需要 ${varSize} 字节,但剩余 ${frameData.length - frameIndex} 字节,尝试调整大小`); // 强制调整为可用大小 varSize = Math.min(varSize, frameData.length - frameIndex); if (varSize <= 0) { console.log(`[数据处理] 无法调整大小,跳过变量 ${varName}`); break; } console.log(`[数据处理] 已将变量 ${varName} 大小调整为 ${varSize} 字节`); } // 提取指定大小的数据 const group = frameData.slice(frameIndex, frameIndex + varSize); frameIndex += varSize; // 根据大小解析数据 - 使用无符号整数(uint)处理 let value; if (varSize === 1) { value = group[0]; // 直接使用无符号8位整数 (0-255) } else if (varSize === 2) { value = group[0] | (group[1] << 8); // 小端序16位无符号整数 (0-65535) } else if (varSize === 4) { value = group[0] | (group[1] << 8) | (group[2] << 16) | (group[3] << 24); // 小端序32位无符号整数 (0-4294967295) } else { // 对于其他大小,转换为无符号数值 value = parseInt(Array.from(group).map(b => b.toString(16).padStart(2, '0')).join(''), 16); } console.log(`[数据处理] 变量 ${varName} (${varSize}字节):`, value, '原始字节:', group.map(b => '0x' + b.toString(16).padStart(2, '0').toUpperCase())); // 特殊显示1字节变量的信息 if (varSize === 1) { console.log(`[数据处理] 1字节变量 ${varName} 处理完成:`, { '原始值': group[0], '无符号值': value, '范围': `${value >= 0 && value <= 255 ? '有效' : '超出范围'}`, '二进制': '0b' + group[0].toString(2).padStart(8, '0') }); } // 发送数据到多变量图表管理器 if (window.multiChartManager && window.chartDrawEnabled) { // 检查图表是否存在,如果不存在则跳过 if (window.multiChartManager.hasChart(varName)) { console.log(`[数据处理] 更新图表 - 变量: ${varName}, 值: ${value}`); window.multiChartManager.updateChartData(varName, [value]); } else { console.log(`[数据处理] 跳过图表更新 - 变量 ${varName} 的图表不存在,尝试创建图表`); // 尝试创建图表 window.multiChartManager.createChart(varName); // 延迟更新数据 setTimeout(() => { if (window.multiChartManager.hasChart(varName)) { window.multiChartManager.updateChartData(varName, [value]); } }, 100); } } dataIndex++; } // 如果剩余数据不足4字节,记录日志 if (frameData.length > 0) { // console.log('数据帧剩余不足4字节的数据:', frameData.length, '字节:', Array.from(frameData).map(b => '0x' + b.toString(16).padStart(2, '0').toUpperCase())); } } // 多变量图表管理器 class MultiChartManager { constructor() { this.container = null; this.charts = new Map(); // 存储变量名到图表实例的映射 this.chartData = new Map(); // 存储变量名到数据的映射 this.chartTimeData = new Map(); // 存储变量名到时间数据的映射 this.varTimestamps = new Map(); // 存储每个变量的时间戳序列 this.defaultChart = null; this.samplingRate = 50; // 默认采样率50Hz this.timeDisplayRange = 10; // 默认时间显示范围10秒 this.debugMode = false; // 调试模式开关,默认关闭 this.lastUpdateTime = new Map(); // 每个变量的上次更新时间,用于节流绘制 console.log(`[MultiChartManager] 初始化完成 - 默认采样率: ${this.samplingRate}Hz, 显示范围: ${this.timeDisplayRange}秒`); // 延迟初始化,等待DOM元素创建完成 this.init(); } init() { this.container = document.getElementById('multiChartContainer'); this.defaultChart = document.getElementById('realtimeChart'); if (!this.container) { console.error('多变量图表管理器容器未找到,将在100ms后重试'); setTimeout(() => this.init(), 100); return; } console.log('多变量图表管理器已初始化'); } /** * 为指定变量创建图表 */ createChart(varName) { console.log(`[MultiChartManager] 尝试创建图表: ${varName}`); if (this.charts.has(varName)) { console.log(`[MultiChartManager] 变量 ${varName} 的图表已存在,跳过创建`); return; } // 检查容器是否准备好 if (!this.container) { console.log(`[MultiChartManager] 容器未准备好,延迟创建图表: ${varName}`); setTimeout(() => this.createChart(varName), 100); return; } console.log(`[MultiChartManager] 开始创建图表: ${varName}, 容器状态:`, !!this.container); // 隐藏默认图表 if (this.defaultChart) { this.defaultChart.style.display = 'none'; } // 创建图表容器 const chartDiv = document.createElement('div'); chartDiv.className = 'variable-chart'; chartDiv.setAttribute('data-var', varName); chartDiv.innerHTML = `
${varName}
`; // 调试:显示图表容器信息 console.log(`[MultiChartManager] 创建图表容器 - 变量 ${varName}:`, { '容器类名': chartDiv.className, '容器属性': chartDiv.getAttribute('data-var'), '容器HTML': chartDiv.innerHTML.substring(0, 100) + '...', '容器高度': chartDiv.querySelector('.variable-chart-content').style.height }); // 确保容器存在且可访问 if (!this.container || !this.container.appendChild) { console.error(`[MultiChartManager] 容器不可用,无法创建图表 - 变量 ${varName}`); return; } this.container.appendChild(chartDiv); // 初始化ECharts实例 const chartDom = chartDiv.querySelector('.variable-chart-content'); let chartInstance; try { chartInstance = echarts.init(chartDom); console.log(`[MultiChartManager] ECharts实例创建成功 - 变量 ${varName}`); } catch (error) { console.error(`[MultiChartManager] ECharts实例创建失败 - 变量 ${varName}:`, error); // 移除失败的DOM元素 chartDiv.remove(); return; } // 设置图表配置,与默认图表保持一致 try { chartInstance.setOption({ xAxis: { type: 'value', name: '时间 (秒)', nameLocation: 'middle', nameGap: 30, min: 0, max: this.timeDisplayRange, // 使用动态监测时间 axisLabel: { formatter: function(value) { return value.toFixed(1) + 's'; } } }, yAxis: { type: 'value', scale: true, name: '数值', nameLocation: 'middle', nameGap: 40, min: -130, max: 130, axisLabel: { formatter: function(value) { // 如果是1字节范围,显示整数 if (value >= -128 && value <= 127) { return Math.round(value); } return value.toFixed(1); } } }, series: [{ type: 'line', data: [], smooth: true, symbol: 'none', lineStyle: { width: 2, color: '#3498db' }, name: '实时数据' }], grid: { left: 80, right: 40, top: 50, bottom: 80, containLabel: true }, animation: false, tooltip: { trigger: 'axis', formatter: function(params) { const data = params[0]; const timeStr = params[0].axisValue || '未知时间'; // 确保数值正确显示 let valueStr; if (Array.isArray(data.value)) { // 如果data.value是数组[time, value],取第二个元素作为数值 valueStr = data.value[1]; } else { // 如果data.value是单个数值 valueStr = data.value; } return `时间: ${timeStr}
数值: ${valueStr}`; } } }); } catch (error) { console.error(`[MultiChartManager] 图表配置设置失败 - 变量 ${varName}:`, error); // 销毁失败的实例并移除DOM chartInstance.dispose(); chartDiv.remove(); return; } // 存储图表实例和数据 this.charts.set(varName, chartInstance); this.chartData.set(varName, []); // 初始化时间戳数组 this.varTimestamps.set(varName, []); // 初始化该变量的更新时间戳 this.lastUpdateTime.set(varName, 0); // 为Y轴范围计算提供chartData引用 chartInstance.chartData = this.chartData.get(varName); // 强制resize setTimeout(() => { chartInstance.resize(); }, 100); console.log(`[MultiChartManager] 为变量 ${varName} 创建了图表:`, { '当前图表数量': this.charts.size, '图表实例': !!chartInstance, '图表DOM': !!chartDom, '数据数组': this.chartData.get(varName), '时间戳数组': this.varTimestamps.get(varName), '容器子元素数量': this.container.children.length, '图表容器可见性': chartDiv.style.display, '图表内容高度': chartDom.style.height, '图表容器位置': chartDiv.offsetTop + 'px' }); } /** * 检查指定变量是否有图表 */ hasChart(varName) { const hasChart = this.charts.has(varName); const chartInstance = this.charts.get(varName); const chartData = this.chartData.get(varName); console.log(`[MultiChartManager] 检查图表 ${varName}:`, { 'hasChart': hasChart, 'chartInstance': !!chartInstance, 'chartData': !!chartData, '数据长度': chartData ? chartData.length : 0 }); return hasChart; } /** * 更新指定变量的图表数据 */ updateChartData(varName, newData) { // 检查容器是否准备好 if (!this.container) { console.log(`[MultiChartManager] 容器未准备好,延迟更新数据: ${varName}`); setTimeout(() => this.updateChartData(varName, newData, timestamp), 100); return; } const chartInstance = this.charts.get(varName); const chartData = this.chartData.get(varName); if (!chartInstance || !chartData) { // console.log(`[MultiChartManager] 变量 ${varName} 的图表或数据未找到 - chartInstance:`, !!chartInstance, 'chartData:', !!chartData); return; } // console.log(`[MultiChartManager] 更新变量 ${varName} 的数据:`, newData, '当前数据长度:', chartData.length); // 环形缓冲区:固定大小,自动覆盖旧数据 const bufferSize = 6000; // 添加新数据到数组 chartData.push(...newData); // 如果数据超过缓冲区大小,自动移除最旧的数据 if (chartData.length > bufferSize) { const removeCount = chartData.length - bufferSize; chartData.splice(0, removeCount); } // 更新图表实例的chartData引用,确保Y轴范围计算能正常工作 chartInstance.chartData = chartData; // 调试:显示数据累积状态 console.log(`[MultiChartManager] 数据累积 - 变量 ${varName}:`, { '新数据': newData, '累积后长度': chartData.length, '缓冲区大小': bufferSize, '数据示例': chartData.slice(-3) // 显示最后3个数据点 }); // 计算时间数据,使用真实的时间戳 let timeData; // 为每个数据点生成时间戳(如果没有时间戳数组,则创建) if (!this.varTimestamps.has(varName)) { this.varTimestamps.set(varName, []); } const timestamps = this.varTimestamps.get(varName); // 为每个新数据点添加时间戳 for (let i = 0; i < newData.length; i++) { const currentTime = performance.now(); timestamps.push(currentTime); } // 限制时间戳数量,与数据保持一致 if (timestamps.length > bufferSize) { const removeCount = timestamps.length - bufferSize; timestamps.splice(0, removeCount); } // 计算相对时间(只显示可视区间内的数据) if (timestamps.length > 0) { const currentTime = performance.now(); const visibleStartTime = currentTime - (this.timeDisplayRange * 1000); // 可视区间的开始时间 // 只保留可视区间内的数据点 const visibleTimestamps = timestamps.filter(ts => ts >= visibleStartTime); const visibleChartData = chartData.slice(-visibleTimestamps.length); // 计算相对时间(从可视区间开始时间开始) timeData = visibleTimestamps.map(ts => (ts - visibleStartTime) / 1000); // 更新图表数据为可视区间内的数据 chartData.length = 0; chartData.push(...visibleChartData); // 更新时间戳为可视区间内的时间戳 timestamps.length = 0; timestamps.push(...visibleTimestamps); } else { timeData = []; } // 调试信息:显示时间数据(限制频率) // if (timeData.length > 1 && timeData.length % 50 === 0) { // const lastTwoTime = timeData.slice(-2); // console.log(`[MultiChartManager] 变量 ${varName} 时间数据:`, // `前一个: ${lastTwoTime[0].toFixed(3)}s, 当前: ${lastTwoTime[1].toFixed(3)}s, 间隔: ${(lastTwoTime[1] - lastTwoTime[0]).toFixed(3)}s`); // } // 调试信息:显示滑动窗口信息(限制频率) // if (timestamps.length > 1000 && timestamps.length % 100 === 0) { // const windowStart = timestamps[0]; // const windowEnd = timestamps[timestamps.length - 1]; // const windowDuration = (windowEnd - windowStart) / 1000; // console.log(`[MultiChartManager] 变量 ${varName} 滑动窗口:`, // `起始: ${(windowStart / 1000).toFixed(3)}s, 结束: ${(windowEnd / 1000).toFixed(3)}s, 窗口长度: ${windowDuration.toFixed(3)}s`); // } // 更新图表 - 使用批量更新和节流绘制 // 确保数据格式正确,避免白屏 if (!timeData || timeData.length === 0 || !chartData || chartData.length === 0) { console.warn(`[MultiChartManager] 数据不完整,跳过图表更新 - 变量 ${varName}:`, { 'timeData长度': timeData ? timeData.length : 0, 'chartData长度': chartData ? chartData.length : 0 }); return; } const chartDataArray = chartData.map((value, index) => [timeData[index], value]); // 调试:显示数据格式 console.log(`[MultiChartManager] 图表数据准备 - 变量 ${varName}:`, { '数据点数量': timeData.length, '数据数组长度': chartData.length, '时间数据长度': timeData.length, '图表数据数组': chartDataArray.slice(-3), // 显示最后3个数据点 '时间范围': timeData.length > 0 ? `${Math.min(...timeData).toFixed(3)}s - ${Math.max(...timeData).toFixed(3)}s` : '无' }); // 节流绘制:限制更新频率,避免1000Hz时卡死 const now = performance.now(); const lastUpdateTime = this.lastUpdateTime.get(varName) || 0; const updateInterval = Math.max(50, 1000 / Math.min(this.samplingRate, 60)); // 最少50ms更新一次,最多60Hz console.log(`[MultiChartManager] 节流检查 - 变量 ${varName}:`, { '当前时间': now.toFixed(1), '上次更新时间': lastUpdateTime.toFixed(1), '时间差': (now - lastUpdateTime).toFixed(1) + 'ms', '更新间隔': updateInterval.toFixed(1) + 'ms', '是否允许更新': (now - lastUpdateTime >= updateInterval) }); if (now - lastUpdateTime >= updateInterval) { // 使用增量更新,只更新数据,不重新配置整个图表 const option = { series: [{ data: chartDataArray, type: 'line', smooth: true, symbol: 'none', lineStyle: { width: 2, color: '#3498db' }, name: '实时数据' }], // 确保包含所有必要的配置,避免白屏 animation: false, // 确保网格配置存在 grid: { left: 80, right: 40, top: 50, bottom: 80, containLabel: true }, // 保持X轴配置,但更新数据范围 xAxis: { type: 'value', name: '时间 (秒)', nameLocation: 'middle', nameGap: 30, // 设置X轴范围,固定显示0到timeDisplayRange min: 0, max: this.timeDisplayRange, // 启用时间轴滚动 axisLabel: { formatter: function(value) { return value.toFixed(1) + 's'; } } }, // 保持Y轴配置 yAxis: { type: 'value', scale: true, name: '数值', nameLocation: 'middle', nameGap: 40, // 动态计算Y轴范围 min: (() => { if (chartData.length > 0) { const minVal = Math.min(...chartData); const maxVal = Math.max(...chartData); const range = maxVal - minVal; // 如果是1字节数据,设置合适的范围 if (minVal >= -128 && maxVal <= 127) { return Math.max(-130, minVal - 2); } // 对于大数值,使用更合理的余量计算 if (range > 0) { // 使用数据范围的5%作为余量,但最小1,最大1000 const margin = Math.max(1, Math.min(1000, range * 0.05)); return minVal - margin; } else { // 如果数据没有变化,使用固定余量 return minVal - Math.abs(minVal) * 0.01; } } return 0; })(), max: (() => { if (chartData.length > 0) { const minVal = Math.min(...chartData); const maxVal = Math.max(...chartData); const range = maxVal - minVal; // 如果是1字节数据,设置合适的范围 if (minVal >= -128 && maxVal <= 127) { return Math.min(130, maxVal + 2); } // 对于大数值,使用更合理的余量计算 if (range > 0) { // 使用数据范围的5%作为余量,但最小1,最大1000 const margin = Math.max(1, Math.min(1000, range * 0.05)); return maxVal + margin; } else { // 如果数据没有变化,使用固定余量 return maxVal + Math.abs(maxVal) * 0.01; } } return 100; })(), // 动态设置Y轴标签格式 axisLabel: { formatter: function(value) { // 如果是1字节范围,显示整数 if (value >= -128 && value <= 127) { return Math.round(value); } // 对于大数值,使用更简洁的格式 if (Math.abs(value) >= 1000) { return value.toFixed(0); } // 对于中等数值,保留1位小数 if (Math.abs(value) >= 10) { return value.toFixed(1); } // 对于小数值,保留2位小数 return value.toFixed(2); } } }, // 保持网格配置 grid: { left: 80, right: 40, top: 50, bottom: 80, containLabel: true }, // 保持工具提示配置 tooltip: { trigger: 'axis', formatter: function(params) { const data = params[0]; const timeStr = params[0].axisValue || '未知时间'; // 确保数值正确显示 let valueStr; if (Array.isArray(data.value)) { // 如果data.value是数组[time, value],取第二个元素作为数值 valueStr = data.value[1]; } else { // 如果data.value是单个数值 valueStr = data.value; } return `时间: ${timeStr}
数值: ${valueStr}`; } }, // 确保图表类型配置存在 type: 'line' }; // 使用增量更新,提高性能 try { chartInstance.setOption(option, true); console.log(`[MultiChartManager] 图表更新成功 - 变量 ${varName}:`, { '选项配置': option, '图表实例状态': !!chartInstance, '图表DOM状态': !!chartInstance.getDom() }); } catch (error) { console.error(`[MultiChartManager] 图表更新失败 - 变量 ${varName}:`, error); // 如果增量更新失败,尝试完全重新设置 try { chartInstance.setOption(option, false); console.log(`[MultiChartManager] 图表重新设置成功 - 变量 ${varName}`); } catch (retryError) { console.error(`[MultiChartManager] 图表重新设置也失败 - 变量 ${varName}:`, retryError); } } // 记录更新时间(为每个变量单独记录) this.lastUpdateTime.set(varName, now); // 调试:显示绘制结果 console.log(`[MultiChartManager] 图表更新完成 - 变量 ${varName}:`, { '数据点数量': timeData.length, '更新间隔': updateInterval.toFixed(1) + 'ms', '采样率': this.samplingRate + 'Hz', '绘制频率': (1000 / updateInterval).toFixed(1) + 'Hz', '图表实例状态': !!chartInstance, '图表数据长度': chartInstance.getOption().series[0].data.length, '容器子元素数量': this.container.children.length, '图表DOM元素': !!this.container.querySelector(`[data-var="${varName}"]`) }); } // 调试信息:显示X轴范围(限制频率,避免过多日志) // if (timeData.length > 0 && timeData.length % 100 === 0) { // 每100个数据点输出一次 // const xMin = Math.min(...timeData); // const xMax = Math.max(...timeData); // console.log(`[MultiChartManager] 变量 ${varName} 图表更新完成,数据点: ${timeData.length}, X轴范围: ${xMin.toFixed(3)}s - ${xMax.toFixed(3)}s`); // // 显示实际时间范围变化 // if (timestamps.length > 1) { // const firstTime = (timestamps[0] / 1000).toFixed(3); // const lastTime = (timestamps[timestamps.length - 1] / 1000).toFixed(3); // console.log(`[MultiChartManager] 变量 ${varName} 图表更新完成,数据点: ${timeData.length}, X轴范围: ${xMin.toFixed(3)}s - ${xMax.toFixed(3)}s`); // } // } } /** * 删除指定变量的图表 */ removeChart(varName) { const chartInstance = this.charts.get(varName); if (chartInstance) { chartInstance.dispose(); } this.charts.delete(varName); this.chartData.delete(varName); this.chartTimeData.delete(varName); this.varTimestamps.delete(varName); // 移除DOM元素 const chartElement = this.container.querySelector(`[data-var="${varName}"]`); if (chartElement) { chartElement.remove(); } // 如果没有其他图表,显示默认图表 if (this.charts.size === 0 && this.defaultChart) { this.defaultChart.style.display = 'block'; } // console.log(`删除了变量 ${varName} 的图表`); } /** * 清空所有图表 */ clearAllCharts() { // 销毁所有图表实例 this.charts.forEach((chartInstance, varName) => { chartInstance.dispose(); }); this.charts.clear(); this.chartData.clear(); this.chartTimeData.clear(); this.varTimestamps.clear(); // 清空容器中的所有图表DOM元素 const chartElements = this.container.querySelectorAll('.variable-chart'); chartElements.forEach(element => { element.remove(); }); // 确保默认图表可见 if (this.defaultChart) { this.defaultChart.style.display = 'block'; } // console.log('已清空所有多变量图表'); } /** * 设置采样率 */ setSamplingRate(rate) { this.samplingRate = rate; console.log(`[MultiChartManager] 采样率设置为 ${rate}Hz`); } /** * 设置时间显示范围 */ setTimeDisplayRange(range) { this.timeDisplayRange = range; console.log(`[MultiChartManager] 时间显示范围设置为 ${range}秒`); // 立即更新所有图表的X轴范围 this.charts.forEach((chartInstance, varName) => { if (chartInstance && chartInstance.getOption) { const option = chartInstance.getOption(); const chartData = this.chartData.get(varName); const timestamps = this.varTimestamps.get(varName); if (chartData && timestamps && timestamps.length > 0) { const startTime = timestamps[0]; const timeData = timestamps.map(ts => (ts - startTime) / 1000); const maxTime = Math.max(...timeData); // 更新X轴范围 option.xAxis = { ...option.xAxis, min: 0, max: this.timeDisplayRange }; chartInstance.setOption(option); } } }); } /** * 全屏显示指定变量的图表 */ fullscreenChart(varName) { const chartInstance = this.charts.get(varName); if (!chartInstance) { console.warn(`[MultiChartManager] 变量 ${varName} 的图表不存在`); return; } // 获取图表DOM元素 const chartElement = this.container.querySelector(`[data-var="${varName}"]`); if (!chartElement) { console.warn(`[MultiChartManager] 变量 ${varName} 的图表DOM元素不存在`); return; } // 创建全屏容器 const fullscreenContainer = document.createElement('div'); fullscreenContainer.id = 'fullscreenChartContainer'; fullscreenContainer.style.cssText = ` position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0, 0, 0, 0.9); z-index: 9999; display: flex; flex-direction: column; align-items: center; justify-content: center; `; // 创建全屏内容 fullscreenContainer.innerHTML = `
`; // 添加到页面 document.body.appendChild(fullscreenContainer); // 创建新的ECharts实例 const fullscreenChartDom = document.getElementById('fullscreenChartContent'); const fullscreenChartInstance = echarts.init(fullscreenChartDom); // 复制原图表的配置和数据 const originalOption = chartInstance.getOption(); fullscreenChartInstance.setOption(originalOption); // 监听窗口大小变化,自动调整图表大小 const resizeHandler = () => { fullscreenChartInstance.resize(); }; window.addEventListener('resize', resizeHandler); // 监听全屏容器移除事件,清理事件监听器 const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (mutation.type === 'childList') { mutation.removedNodes.forEach((node) => { if (node.id === 'fullscreenChartContainer') { window.removeEventListener('resize', resizeHandler); observer.disconnect(); } }); } }); }); observer.observe(document.body, { childList: true }); console.log(`[MultiChartManager] 变量 ${varName} 图表已全屏显示`); } }