diff --git a/apps/web/public/sw-debug.html b/apps/web/public/sw-debug.html index 221c6240..d41f29fe 100644 --- a/apps/web/public/sw-debug.html +++ b/apps/web/public/sw-debug.html @@ -26,7 +26,7 @@ diff --git a/apps/web/public/sw-debug/analysis-mode.js b/apps/web/public/sw-debug/analysis-mode.js new file mode 100644 index 00000000..0c56f062 --- /dev/null +++ b/apps/web/public/sw-debug/analysis-mode.js @@ -0,0 +1,720 @@ +/** + * SW Debug Panel - Analysis Mode + * 分析模式功能模块 + */ + +import { state, elements, updateElements } from './state.js'; +import { escapeHtml, truncateUrl, parseUserAgent, formatBytes } from './common.js'; +import { showToast } from './toast.js'; +import { + enableDebug, + refreshStatus, + loadConsoleLogs, + loadPostMessageLogs, + loadCacheStats, +} from './sw-communication.js'; + +// Forward declarations - will be set by app.js +let renderLogs = null; +let renderConsoleLogs = null; +let renderPostmessageLogs = null; +let renderCrashLogs = null; +let renderLLMApiLogs = null; +let updateConsoleCount = null; +let updatePostmessageCount = null; +let updateCrashCount = null; +let updateErrorDots = null; +let updateMessageTypeOptions = null; +let loadCrashLogs = null; +let loadLLMApiLogs = null; +let updateMemoryDisplay = null; + +/** + * Set render callbacks from app.js + */ +export function setAnalysisModeCallbacks(callbacks) { + renderLogs = callbacks.renderLogs; + renderConsoleLogs = callbacks.renderConsoleLogs; + renderPostmessageLogs = callbacks.renderPostmessageLogs; + renderCrashLogs = callbacks.renderCrashLogs; + renderLLMApiLogs = callbacks.renderLLMApiLogs; + updateConsoleCount = callbacks.updateConsoleCount; + updatePostmessageCount = callbacks.updatePostmessageCount; + updateCrashCount = callbacks.updateCrashCount; + updateErrorDots = callbacks.updateErrorDots; + updateMessageTypeOptions = callbacks.updateMessageTypeOptions; + loadCrashLogs = callbacks.loadCrashLogs; + loadLLMApiLogs = callbacks.loadLLMApiLogs; + updateMemoryDisplay = callbacks.updateMemoryDisplay; +} + +/** + * Toggle analysis mode (for analyzing user-provided logs) + * In analysis mode: + * - SW connection is disabled + * - Only imported log data is displayed + * - Debug-related buttons are hidden + */ +export function toggleAnalysisMode() { + state.isAnalysisMode = !state.isAnalysisMode; + updateAnalysisModeUI(); + + if (state.isAnalysisMode) { + // Clear all existing data when entering analysis mode + clearAllLogsForAnalysisMode(); + } else { + // Restore normal mode - reconnect to SW + exitAnalysisMode(); + } +} + +/** + * Update UI based on analysis mode state + */ +export function updateAnalysisModeUI() { + const isAnalysis = state.isAnalysisMode; + + // Update mode indicator + if (elements.analysisModeIndicator) { + elements.analysisModeIndicator.style.display = isAnalysis ? 'inline-flex' : 'none'; + } + + // Update SW status indicator + if (elements.swStatus) { + elements.swStatus.style.display = isAnalysis ? 'none' : 'inline-flex'; + } + + // Update title + if (elements.panelTitle) { + elements.panelTitle.textContent = isAnalysis ? '日志分析模式' : 'Service Worker 调试面板'; + } + + // Show/hide import button + if (elements.importLogsBtn) { + elements.importLogsBtn.style.display = isAnalysis ? 'inline-flex' : 'none'; + } + + // Update toggle button appearance + if (elements.toggleAnalysisModeBtn) { + if (isAnalysis) { + elements.toggleAnalysisModeBtn.classList.add('active'); + elements.toggleAnalysisModeBtn.title = '退出分析模式,返回调试模式'; + } else { + elements.toggleAnalysisModeBtn.classList.remove('active'); + elements.toggleAnalysisModeBtn.title = '切换到分析模式(导入用户日志)'; + } + } + + // Hide/show debug-mode-only buttons + document.querySelectorAll('.debug-mode-only').forEach(el => { + el.style.display = isAnalysis ? 'none' : ''; + }); + + // In analysis mode, left panel shows user info instead of SW status + // The panel is always visible, but content changes based on mode + const leftPanel = document.querySelector('.left-panel'); + if (leftPanel) { + // Always show left panel, content will be different in analysis mode + leftPanel.style.display = ''; + } + + // Adjust panels grid - always use two-column layout + const panels = document.querySelector('.panels'); + if (panels) { + panels.style.gridTemplateColumns = '280px 1fr'; + } + + // Update left panel content for analysis mode + if (isAnalysis) { + showAnalysisModeLeftPanel(); + } else { + restoreDebugModeLeftPanel(); + } + + // Add/remove body class for analysis mode styling + document.body.classList.toggle('analysis-mode', isAnalysis); +} + +/** + * Clear display logs when entering analysis mode + * Current logs are preserved in liveLogs buffer for restoration later + */ +function clearAllLogsForAnalysisMode() { + // Save current logs to live buffer before clearing display + state.liveLogs.logs = [...state.logs]; + state.liveLogs.consoleLogs = [...state.consoleLogs]; + state.liveLogs.postmessageLogs = [...state.postmessageLogs]; + state.liveLogs.crashLogs = [...state.crashLogs]; + state.liveLogs.llmapiLogs = [...state.llmapiLogs]; + + // Clear display state for imported logs + state.logs = []; + state.consoleLogs = []; + state.postmessageLogs = []; + state.crashLogs = []; + state.llmapiLogs = []; + state.importedLogData = null; + + // Re-render all tabs + if (renderLogs) renderLogs(); + if (renderConsoleLogs) renderConsoleLogs(); + if (renderPostmessageLogs) renderPostmessageLogs(); + if (renderCrashLogs) renderCrashLogs(); + if (renderLLMApiLogs) renderLLMApiLogs(); + + // Show import prompt + showImportPrompt(); +} + +/** + * Show analysis mode left panel (placeholder for user info) + */ +function showAnalysisModeLeftPanel() { + const leftPanel = document.querySelector('.left-panel'); + if (!leftPanel) return; + + leftPanel.innerHTML = ` +
+
+ 🔍 分析模式 +
+
+
+ 📋 +

请导入用户日志文件

+

导入后将显示用户信息

+
+
+
+ `; +} + +/** + * Restore debug mode left panel + */ +function restoreDebugModeLeftPanel() { + const leftPanel = document.querySelector('.left-panel'); + if (!leftPanel) return; + + // Restore original HTML structure + leftPanel.innerHTML = ` +
+
+ SW 状态信息 +
+
+
+
+ 版本 + - +
+
+ 调试模式 + 关闭 +
+
+ Pending 图片请求 + 0 +
+
+ Pending 视频请求 + 0 +
+
+ 视频 Blob 缓存 + 0 +
+
+ 已完成请求缓存 + 0 +
+
+ 工作流处理器 + 未初始化 +
+
+ 调试日志数 + 0 +
+
+ + +
+
+ +
+
+ 内存监控 + +
+
+
+
+ JS 堆已使用 + - +
+
+ JS 堆总大小 + - +
+
+ JS 堆上限 + - +
+
+ 使用率 + - +
+
+ + +
+
+ +
+
+ 缓存统计 + +
+
+ +
+
+ `; + + // Re-cache elements and rebind refresh cache button + const newElements = { + swVersion: document.getElementById('swVersion'), + debugMode: document.getElementById('debugMode'), + pendingImages: document.getElementById('pendingImages'), + pendingVideos: document.getElementById('pendingVideos'), + videoBlobCache: document.getElementById('videoBlobCache'), + completedRequests: document.getElementById('completedRequests'), + workflowHandler: document.getElementById('workflowHandler'), + debugLogsCount: document.getElementById('debugLogsCount'), + failedDomains: document.getElementById('failedDomains'), + memoryUsed: document.getElementById('memoryUsed'), + memoryTotal: document.getElementById('memoryTotal'), + memoryLimit: document.getElementById('memoryLimit'), + memoryPercent: document.getElementById('memoryPercent'), + memoryWarning: document.getElementById('memoryWarning'), + memoryNotSupported: document.getElementById('memoryNotSupported'), + memoryUpdateTime: document.getElementById('memoryUpdateTime'), + cacheList: document.getElementById('cacheList'), + }; + updateElements(newElements); + + const refreshCacheBtn = document.getElementById('refreshCache'); + if (refreshCacheBtn) { + refreshCacheBtn.addEventListener('click', loadCacheStats); + } + + // Refresh data + refreshStatus(); + loadCacheStats(); + if (updateMemoryDisplay) updateMemoryDisplay(); +} + +/** + * Show user info panel in analysis mode after importing logs + * @param {object} data - Imported log data + */ +export function showUserInfoPanel(data) { + const leftPanel = document.querySelector('.left-panel'); + if (!leftPanel) return; + + // Extract user info from imported data + const userAgent = data.userAgent || '未知'; + const url = data.url || '未知'; + const exportTime = data.exportTime ? new Date(data.exportTime).toLocaleString('zh-CN') : '未知'; + const swStatus = data.swStatus || {}; + const memory = data.memory || {}; + const cacheStats = data.cacheStats || {}; + + // Parse UA for display + const uaInfo = parseUserAgent(userAgent); + + // Calculate total logs + const summary = data.summary || {}; + const logCounts = { + fetch: summary.fetchLogs || (data.fetchLogs?.length || 0), + console: summary.consoleLogs || (data.consoleLogs?.length || 0), + postmessage: summary.postmessageLogs || (data.postmessageLogs?.length || 0), + memory: summary.memoryLogs || (data.memoryLogs?.length || 0), + llmapi: summary.llmapiLogs || (data.llmapiLogs?.length || 0), + }; + const totalLogs = Object.values(logCounts).reduce((a, b) => a + b, 0); + + leftPanel.innerHTML = ` +
+
+ 👤 用户信息 +
+
+
+
+ 访问地址 + ${escapeHtml(truncateUrl(url, 40))} +
+
+ 浏览器 + ${escapeHtml(uaInfo.browser)} +
+
+ 系统 + ${escapeHtml(uaInfo.os)} +
+
+ 导出时间 + ${escapeHtml(exportTime)} +
+
+ 日志总数 + ${totalLogs} +
+
+
+
+ 完整 User-Agent +

${escapeHtml(userAgent)}

+
+
+
+
+ +
+
+ 📊 导出时状态 +
+
+
+
+ SW 版本 + ${escapeHtml(swStatus.version || '-')} +
+
+ 调试模式 + ${swStatus.debugModeEnabled ? '开启' : '关闭'} +
+
+ 工作流处理器 + ${swStatus.workflowHandlerInitialized ? '已初始化' : '未初始化'} +
+
+ 调试日志数 + ${swStatus.debugLogsCount || 0} +
+
+
+
+ + ${memory.usedMB ? ` +
+
+ 💾 内存快照 +
+
+
+
+ JS 堆已使用 + ${memory.usedMB} MB +
+
+ JS 堆总大小 + ${memory.totalMB} MB +
+
+ 使用率 + ${memory.usagePercent}% +
+
+
+
+ ` : ''} + + ${Object.keys(cacheStats).length > 0 ? ` +
+
+ 📦 缓存快照 +
+
+ +
+
+ ` : ''} + +
+
+ 📋 日志统计 +
+
+
+ ${logCounts.fetch > 0 ? `
Fetch${logCounts.fetch}
` : ''} + ${logCounts.console > 0 ? `
控制台${logCounts.console}
` : ''} + ${logCounts.postmessage > 0 ? `
PostMessage${logCounts.postmessage}
` : ''} + ${logCounts.memory > 0 ? `
内存${logCounts.memory}
` : ''} + ${logCounts.llmapi > 0 ? `
LLM API${logCounts.llmapi}
` : ''} + ${totalLogs === 0 ? `
无日志数据
` : ''} +
+
+
+ `; +} + +/** + * Show import prompt in logs container + */ +export function showImportPrompt() { + if (elements.logsContainer) { + elements.logsContainer.innerHTML = ` +
+ 📁 +

分析模式

+

在此模式下,您可以导入用户提供的日志文件进行分析

+

+ 支持从"导出日志"功能生成的 JSON 文件 +

+ +

+ 点击右上角搜索图标可退出分析模式 +

+
+ `; + + // Attach event listener to the prompt button + const btn = document.getElementById('importPromptBtn'); + if (btn) { + btn.addEventListener('click', triggerImportDialog); + } + } +} + +/** + * Exit analysis mode and restore normal SW connection + */ +export function exitAnalysisMode() { + state.importedLogData = null; + + // Restore live logs that were collected during analysis mode + state.logs = state.liveLogs.logs; + state.consoleLogs = state.liveLogs.consoleLogs; + state.postmessageLogs = state.liveLogs.postmessageLogs; + state.crashLogs = state.liveLogs.crashLogs; + state.llmapiLogs = state.liveLogs.llmapiLogs; + + // Re-render with restored logs + if (renderLogs) renderLogs(); + if (renderConsoleLogs) renderConsoleLogs(); + if (updateMessageTypeOptions) updateMessageTypeOptions(); + if (renderPostmessageLogs) renderPostmessageLogs(); + if (renderCrashLogs) renderCrashLogs(); + if (renderLLMApiLogs) renderLLMApiLogs(); + + // Update tab counts + if (updateConsoleCount) updateConsoleCount(); + if (updatePostmessageCount) updatePostmessageCount(); + if (updateCrashCount) updateCrashCount(); + if (updateErrorDots) updateErrorDots(); + + // Reconnect to SW and refresh status + if (navigator.serviceWorker?.controller) { + enableDebug(); + refreshStatus(); + // Re-fetch latest logs from SW (will merge with restored logs) + loadConsoleLogs(); + loadPostMessageLogs(); + if (loadCrashLogs) loadCrashLogs(); + if (loadLLMApiLogs) loadLLMApiLogs(); + } +} + +/** + * Trigger file import dialog + */ +export function triggerImportDialog() { + if (elements.importLogsInput) { + elements.importLogsInput.click(); + } +} + +/** + * Handle imported log file + * @param {Event} event - File input change event + */ +export async function handleLogImport(event) { + const file = event.target.files?.[0]; + if (!file) return; + + try { + const text = await file.text(); + const data = JSON.parse(text); + + // Validate the imported data structure + if (!data || typeof data !== 'object') { + throw new Error('无效的日志文件格式'); + } + + // Store imported data + state.importedLogData = data; + + // Parse and load logs from the imported data + loadImportedLogs(data); + + // Show success notification + showImportSuccessMessage(file.name, data); + + } catch (error) { + console.error('Failed to import log file:', error); + showToast(`导入失败: ${error.message}`, 'error', 5000); + } + + // Reset file input so same file can be selected again + event.target.value = ''; +} + +/** + * Load logs from imported data + * @param {object} data - Imported log data + */ +function loadImportedLogs(data) { + // Load fetch logs + if (data.fetchLogs && Array.isArray(data.fetchLogs)) { + state.logs = data.fetchLogs.map(log => ({ + ...log, + // Ensure required fields exist + id: log.id || `imported-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + timestamp: log.timestamp || Date.now(), + })); + if (renderLogs) renderLogs(); + } + + // Load console logs + if (data.consoleLogs && Array.isArray(data.consoleLogs)) { + state.consoleLogs = data.consoleLogs.map(log => ({ + ...log, + id: log.id || `console-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + timestamp: log.timestamp || Date.now(), + })); + if (renderConsoleLogs) renderConsoleLogs(); + } + + // Load postmessage logs + if (data.postmessageLogs && Array.isArray(data.postmessageLogs)) { + state.postmessageLogs = data.postmessageLogs.map(log => ({ + ...log, + id: log.id || `pm-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + timestamp: log.timestamp || Date.now(), + })); + if (updateMessageTypeOptions) updateMessageTypeOptions(); + if (renderPostmessageLogs) renderPostmessageLogs(); + } + + // Load memory/crash logs + if (data.memoryLogs && Array.isArray(data.memoryLogs)) { + state.crashLogs = data.memoryLogs.map(log => ({ + ...log, + id: log.id || `crash-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + timestamp: log.timestamp || Date.now(), + })); + if (renderCrashLogs) renderCrashLogs(); + } + + // Load LLM API logs + if (data.llmapiLogs && Array.isArray(data.llmapiLogs)) { + state.llmapiLogs = data.llmapiLogs.map(log => ({ + ...log, + id: log.id || `llm-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + timestamp: log.timestamp || Date.now(), + })); + if (renderLLMApiLogs) renderLLMApiLogs(); + } + + // Store SW status from imported data for display + if (data.swStatus) { + state.swStatus = data.swStatus; + } + + // Update tab counts + if (updateConsoleCount) updateConsoleCount(); + if (updatePostmessageCount) updatePostmessageCount(); + if (updateCrashCount) updateCrashCount(); + if (updateErrorDots) updateErrorDots(); + + // Show user info panel in left sidebar + showUserInfoPanel(data); +} + +/** + * Show success message after importing logs + * @param {string} filename + * @param {object} data + */ +function showImportSuccessMessage(filename, data) { + const summary = data.summary || {}; + const counts = { + fetch: summary.fetchLogs || (data.fetchLogs?.length || 0), + console: summary.consoleLogs || (data.consoleLogs?.length || 0), + postmessage: summary.postmessageLogs || (data.postmessageLogs?.length || 0), + memory: summary.memoryLogs || (data.memoryLogs?.length || 0), + llmapi: summary.llmapiLogs || (data.llmapiLogs?.length || 0), + }; + + const totalLogs = counts.fetch + counts.console + counts.postmessage + counts.memory + counts.llmapi; + + // Create a temporary notification + const notification = document.createElement('div'); + notification.className = 'import-notification'; + notification.innerHTML = ` +
+ ✅ +
+ 导入成功 +

${filename}

+

+ 共 ${totalLogs} 条日志 + ${counts.fetch > 0 ? `| Fetch: ${counts.fetch}` : ''} + ${counts.console > 0 ? `| 控制台: ${counts.console}` : ''} + ${counts.postmessage > 0 ? `| PostMessage: ${counts.postmessage}` : ''} + ${counts.memory > 0 ? `| 内存: ${counts.memory}` : ''} + ${counts.llmapi > 0 ? `| LLM API: ${counts.llmapi}` : ''} +

+ ${data.exportTime ? `

导出时间: ${new Date(data.exportTime).toLocaleString('zh-CN')}

` : ''} +
+ +
+ `; + + document.body.appendChild(notification); + + // Auto remove after 5 seconds + setTimeout(() => { + notification.classList.add('fade-out'); + setTimeout(() => notification.remove(), 300); + }, 5000); +} diff --git a/apps/web/public/sw-debug/app.js b/apps/web/public/sw-debug/app.js index 183cbb19..7851210e 100644 --- a/apps/web/public/sw-debug/app.js +++ b/apps/web/public/sw-debug/app.js @@ -1,13 +1,23 @@ /** * SW Debug Panel - Main Application + * 主入口文件 - 组织各模块并初始化应用 */ +// Core modules +import { state, elements, cacheElements } from './state.js'; import { downloadJson, formatTime } from './utils.js'; +import { escapeHtml } from './common.js'; + +// Log entry components import { createLogEntry } from './log-entry.js'; import { createConsoleEntry } from './console-entry.js'; import { createPostMessageEntry } from './postmessage-entry.js'; + +// Panel components import { updateSwStatus, updateStatusPanel, updateDebugButton } from './status-panel.js'; import { extractUniqueTypes, updateTypeSelectOptions } from './log-panel.js'; + +// SW Communication import { enableDebug, disableDebug, @@ -17,2524 +27,81 @@ import { loadConsoleLogs, loadPostMessageLogs, clearPostMessageLogs as clearPostMessageLogsInSW, + loadCacheStats, checkSwReady, registerMessageHandlers, onControllerChange, setPostMessageLogCallback, } from './sw-communication.js'; -// Domain blacklist - requests from these domains will be hidden -const DOMAIN_BLACKLIST = [ - 'us.i.posthog.com', - 'us-assets.i.posthog.com', - 'posthog.com', - 'google-analytics.com', - 'googletagmanager.com', -]; - -/** - * Check if URL is in the domain blacklist - * @param {string} url - * @returns {boolean} - */ -function isBlacklistedUrl(url) { - if (!url) return false; - try { - const urlObj = new URL(url); - return DOMAIN_BLACKLIST.some(domain => - urlObj.hostname === domain || urlObj.hostname.endsWith('.' + domain) - ); - } catch { - return false; - } -} - -/** - * Filter logs by time range - * @param {Array} logs - * @param {string} timeRangeMinutes - Minutes as string, or empty for all - * @returns {Array} - */ -function filterByTimeRange(logs, timeRangeMinutes) { - if (!timeRangeMinutes) return logs; - - const minutes = parseInt(timeRangeMinutes); - if (isNaN(minutes)) return logs; - - const cutoffTime = Date.now() - (minutes * 60 * 1000); - return logs.filter(log => log.timestamp >= cutoffTime); -} - -/** - * Check if a request is slow (> 1 second) - * @param {number} duration - Duration in milliseconds - * @returns {'normal'|'slow'|'very-slow'} - */ -function getSpeedClass(duration) { - if (!duration) return 'normal'; - if (duration >= 3000) return 'very-slow'; - if (duration >= 1000) return 'slow'; - return 'normal'; -} - -/** - * Toggle pause state - */ -function togglePause() { - state.isPaused = !state.isPaused; - updatePauseButton(); - - if (!state.isPaused && state.pendingLogs.length > 0) { - // Apply pending logs when resuming - state.pendingLogs.forEach(log => { - addOrUpdateLog(log, true); // true = skip render - }); - state.pendingLogs = []; - renderLogs(); - } -} - -/** - * Toggle slow request filter - */ -function toggleSlowRequestFilter() { - state.filterSlowOnly = !state.filterSlowOnly; - updateSlowRequestsUI(); - renderLogs(); -} - -/** - * Update slow requests UI (highlight when filter is active) - */ -function updateSlowRequestsUI() { - const wrapper = elements.statSlowRequestsWrapper; - if (wrapper) { - if (state.filterSlowOnly) { - wrapper.classList.add('active'); - } else { - wrapper.classList.remove('active'); - } - } -} - -/** - * Update pause button appearance - */ -function updatePauseButton() { - const btn = elements.togglePauseBtn; - if (!btn) return; - - if (state.isPaused) { - btn.textContent = `⏸️ 暂停`; - if (state.pendingLogs.length > 0) { - btn.textContent += ` (${state.pendingLogs.length})`; - } - btn.classList.add('paused'); - } else { - btn.textContent = '▶️ 实时'; - btn.classList.remove('paused'); - } -} - -/** - * Update fetch statistics panel - */ -function updateFetchStats() { - const logs = state.logs.filter(l => !isBlacklistedUrl(l.url)); - - // Total requests - if (elements.statTotalRequests) { - elements.statTotalRequests.textContent = logs.length; - } - - // Success rate - if (elements.statSuccessRate) { - const successCount = logs.filter(l => l.status >= 200 && l.status < 400).length; - const rate = logs.length > 0 ? ((successCount / logs.length) * 100).toFixed(1) : 0; - elements.statSuccessRate.textContent = `${rate}%`; - elements.statSuccessRate.style.color = rate >= 95 ? 'var(--success-color)' : (rate >= 80 ? 'var(--warning-color)' : 'var(--error-color)'); - } - - // Average duration - if (elements.statAvgDuration) { - const durations = logs.filter(l => l.duration > 0).map(l => l.duration); - const avg = durations.length > 0 ? (durations.reduce((a, b) => a + b, 0) / durations.length) : 0; - elements.statAvgDuration.textContent = avg > 0 ? `${Math.round(avg)}ms` : '-'; - elements.statAvgDuration.style.color = avg < 500 ? 'var(--success-color)' : (avg < 1000 ? 'var(--warning-color)' : 'var(--error-color)'); - } - - // Cache hit rate - if (elements.statCacheHit) { - const cachedCount = logs.filter(l => l.cached).length; - const rate = logs.length > 0 ? ((cachedCount / logs.length) * 100).toFixed(1) : 0; - elements.statCacheHit.textContent = `${rate}%`; - } - - // Slow requests count - if (elements.statSlowRequests) { - const slowCount = logs.filter(l => l.duration >= 1000).length; - elements.statSlowRequests.textContent = slowCount; - elements.statSlowRequests.style.color = slowCount === 0 ? 'var(--success-color)' : 'var(--warning-color)'; - } - - // Duration distribution chart - updateDurationChart(logs); -} - -/** - * Update duration distribution chart - */ -function updateDurationChart(logs) { - const logsWithDuration = logs.filter(l => l.duration > 0); - const total = logsWithDuration.length; - - if (total === 0) { - if (elements.chartFast) elements.chartFast.style.width = '0%'; - if (elements.chartMedium) elements.chartMedium.style.width = '0%'; - if (elements.chartSlow) elements.chartSlow.style.width = '0%'; - if (elements.chartVerySlow) elements.chartVerySlow.style.width = '0%'; - return; - } - - // Categorize by duration - const fast = logsWithDuration.filter(l => l.duration < 100).length; - const medium = logsWithDuration.filter(l => l.duration >= 100 && l.duration < 500).length; - const slow = logsWithDuration.filter(l => l.duration >= 500 && l.duration < 1000).length; - const verySlow = logsWithDuration.filter(l => l.duration >= 1000).length; - - // Calculate percentages - const fastPct = (fast / total) * 100; - const mediumPct = (medium / total) * 100; - const slowPct = (slow / total) * 100; - const verySlowPct = (verySlow / total) * 100; - - // Update chart bars - if (elements.chartFast) { - elements.chartFast.style.width = `${fastPct}%`; - elements.chartFast.title = `<100ms: ${fast} (${fastPct.toFixed(1)}%)`; - } - if (elements.chartMedium) { - elements.chartMedium.style.width = `${mediumPct}%`; - elements.chartMedium.title = `100-500ms: ${medium} (${mediumPct.toFixed(1)}%)`; - } - if (elements.chartSlow) { - elements.chartSlow.style.width = `${slowPct}%`; - elements.chartSlow.title = `500ms-1s: ${slow} (${slowPct.toFixed(1)}%)`; - } - if (elements.chartVerySlow) { - elements.chartVerySlow.style.width = `${verySlowPct}%`; - elements.chartVerySlow.title = `>1s: ${verySlow} (${verySlowPct.toFixed(1)}%)`; - } -} - -/** - * Export fetch logs as CSV - */ -function exportFetchCSV() { - const filteredLogs = getFilteredFetchLogs(); - if (filteredLogs.length === 0) { - alert('没有可导出的日志'); - return; - } - - // CSV header - const headers = ['时间', '方法', '状态', 'URL', '耗时(ms)', '类型', '缓存']; - const rows = [headers.join(',')]; - - // CSV rows - filteredLogs.forEach(log => { - const time = new Date(log.timestamp).toLocaleString('zh-CN', { hour12: false }); - const row = [ - `"${time}"`, - log.method || 'GET', - log.status || '-', - `"${(log.url || '').replace(/"/g, '""')}"`, - log.duration || '', - log.requestType || '-', - log.cached ? '是' : '否' - ]; - rows.push(row.join(',')); - }); - - const csvContent = '\uFEFF' + rows.join('\n'); // BOM for Excel - const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `fetch-logs-${new Date().toISOString().slice(0, 10)}.csv`; - a.click(); - URL.revokeObjectURL(url); -} - -/** - * Show shortcuts modal - */ -function showShortcutsModal() { - if (elements.shortcutsModalOverlay) { - elements.shortcutsModalOverlay.style.display = 'flex'; - } -} - -/** - * Close shortcuts modal - */ -function closeShortcutsModal() { - if (elements.shortcutsModalOverlay) { - elements.shortcutsModalOverlay.style.display = 'none'; - } -} - -/** - * Toggle bookmark for a log entry - * @param {string} logId - */ -function toggleBookmark(logId) { - if (state.bookmarkedLogIds.has(logId)) { - state.bookmarkedLogIds.delete(logId); - } else { - state.bookmarkedLogIds.add(logId); - } - // Save to localStorage - saveBookmarks(); - renderLogs(); -} - -/** - * Save bookmarks to localStorage - */ -function saveBookmarks() { - try { - localStorage.setItem('sw-debug-bookmarks', JSON.stringify([...state.bookmarkedLogIds])); - } catch (e) { - console.error('Failed to save bookmarks:', e); - } -} - -/** - * Load bookmarks from localStorage - */ -function loadBookmarks() { - try { - const saved = localStorage.getItem('sw-debug-bookmarks'); - if (saved) { - state.bookmarkedLogIds = new Set(JSON.parse(saved)); - } - } catch (e) { - console.error('Failed to load bookmarks:', e); - } -} - -// ==================== Data Backup ==================== - -/** - * 备份签名和版本 - */ -const BACKUP_SIGNATURE = 'aitu-backup'; -const BACKUP_VERSION = 2; - -/** - * IndexedDB 存储名称常量(与应用层 storage-keys.ts 保持一致) - */ -const IDB_STORES = { - // 工作区数据(文件夹、画板) - WORKSPACE: { - name: 'aitu-workspace', - stores: { - FOLDERS: 'folders', - BOARDS: 'boards', - STATE: 'state', - }, - }, - // 通用键值存储(提示词等) - KV: { - name: 'aitu-storage', - store: 'data', - }, - // 素材库元数据 - ASSETS: { - name: 'aitu-assets', - store: 'assets', - }, - // 统一缓存(AI 生成的媒体元数据) - UNIFIED_CACHE: { - name: 'drawnix-unified-cache', - store: 'media', - }, -}; - -/** - * KV 存储中的关键 key(与应用层 LS_KEYS_TO_MIGRATE 保持一致) - */ -const KV_KEYS = { - PROMPT_HISTORY: 'aitu_prompt_history', - VIDEO_PROMPT_HISTORY: 'aitu_video_prompt_history', - IMAGE_PROMPT_HISTORY: 'aitu_image_prompt_history', - PRESET_SETTINGS: 'aitu-prompt-preset-settings', -}; - -/** - * Cache Storage 名称 - */ -const CACHE_NAMES = { - IMAGES: 'drawnix-images', -}; - -/** - * 任务队列数据库配置 - */ -const SW_TASK_QUEUE_DB = { - name: 'sw-task-queue', - stores: { - TASKS: 'tasks', - }, -}; - -/** - * 任务类型和状态常量(与 SW 中的枚举值保持一致,是小写) - */ -const TaskType = { - IMAGE: 'image', - VIDEO: 'video', -}; - -const TaskStatus = { - COMPLETED: 'completed', -}; - -/** - * 打开 IndexedDB 数据库 - */ -function openIDB(dbName, storeName) { - return new Promise((resolve, reject) => { - const request = indexedDB.open(dbName); - request.onerror = () => reject(request.error); - request.onsuccess = () => { - const db = request.result; - if (!db.objectStoreNames.contains(storeName)) { - db.close(); - resolve(null); - return; - } - resolve(db); - }; - }); -} - -/** - * 从 IndexedDB 读取所有数据 - */ -async function readAllFromIDB(dbName, storeName) { - try { - const db = await openIDB(dbName, storeName); - if (!db) return []; - - return new Promise((resolve, reject) => { - const transaction = db.transaction(storeName, 'readonly'); - const store = transaction.objectStore(storeName); - const request = store.getAll(); - - request.onerror = () => { - db.close(); - reject(request.error); - }; - request.onsuccess = () => { - db.close(); - resolve(request.result || []); - }; - }); - } catch (error) { - return []; - } -} - -/** - * 从 IndexedDB KV 存储读取指定 key - */ -async function readKVItem(key) { - const db = await openIDB(IDB_STORES.KV.name, IDB_STORES.KV.store); - if (!db) return null; - - return new Promise((resolve, reject) => { - const transaction = db.transaction(IDB_STORES.KV.store, 'readonly'); - const store = transaction.objectStore(IDB_STORES.KV.store); - const request = store.get(key); - - request.onerror = () => { - db.close(); - reject(request.error); - }; - request.onsuccess = () => { - db.close(); - const result = request.result; - resolve(result?.value || null); - }; - }); -} - -/** - * 获取文件扩展名 - */ -function getExtensionFromMimeType(mimeType) { - const mimeToExt = { - 'image/jpeg': '.jpg', - 'image/jpg': '.jpg', - 'image/png': '.png', - 'image/gif': '.gif', - 'image/webp': '.webp', - 'image/svg+xml': '.svg', - 'video/mp4': '.mp4', - 'video/webm': '.webm', - 'video/quicktime': '.mov', - }; - return mimeToExt[mimeType] || ''; -} - -/** - * 清理文件/文件夹名称 - */ -function sanitizeFileName(name) { - return ( - name - .replace(/[<>:"/\\|?*]/g, '_') - .replace(/\s+/g, ' ') - .trim() || 'unnamed' - ); -} - -/** - * 等待 JSZip 加载完成 - * @param {number} timeout - 超时时间(毫秒) - * @returns {Promise} - */ -function waitForJSZip(timeout = 5000) { - return new Promise((resolve) => { - if (typeof JSZip !== 'undefined') { - resolve(true); - return; - } - - const startTime = Date.now(); - const checkInterval = setInterval(() => { - if (typeof JSZip !== 'undefined') { - clearInterval(checkInterval); - resolve(true); - } else if (Date.now() - startTime > timeout) { - clearInterval(checkInterval); - resolve(false); - } - }, 100); - }); -} - -/** - * 执行数据备份 - */ -async function performBackup() { - const btn = elements.backupDataBtn; - if (!btn) return; - - const originalText = btn.innerHTML; - - try { - btn.disabled = true; - btn.innerHTML = '⏳ 加载中...'; - - // 等待 JSZip 加载 - const jsZipLoaded = await waitForJSZip(5000); - - if (!jsZipLoaded) { - throw new Error('JSZip 库加载超时,请检查网络连接后重试'); - } - - btn.innerHTML = '⏳ 准备中...'; - - const zip = new JSZip(); - - const manifest = { - signature: BACKUP_SIGNATURE, - version: BACKUP_VERSION, - createdAt: Date.now(), - source: 'sw-debug-panel', - includes: { - prompts: true, - projects: true, - assets: true, - }, - stats: { - promptCount: 0, - videoPromptCount: 0, - imagePromptCount: 0, - folderCount: 0, - boardCount: 0, - assetCount: 0, - taskCount: 0, - }, - }; - - // 显示进度条 - const progressContainer = showBackupProgress(); - const updateProgress = (percent, text) => { - const progressBar = progressContainer.querySelector('.backup-progress-fill'); - const progressText = progressContainer.querySelector('.backup-progress-text'); - if (progressBar) progressBar.style.width = `${percent}%`; - if (progressText) progressText.textContent = text; - }; - - // 0. 先收集任务数据(后面提示词收集需要用到) - updateProgress(5, '正在读取任务数据...'); - const allTasks = await collectTasksData(); - - // 1. 收集提示词数据(会从任务中提取提示词合并) - updateProgress(15, '正在备份提示词...'); - const promptsData = await collectPromptsData(allTasks); - zip.file('prompts.json', JSON.stringify(promptsData, null, 2)); - manifest.stats.promptCount = promptsData.promptHistory?.length || 0; - manifest.stats.videoPromptCount = promptsData.videoPromptHistory?.length || 0; - manifest.stats.imagePromptCount = promptsData.imagePromptHistory?.length || 0; - - // 2. 收集项目数据 - updateProgress(25, '正在备份项目...'); - const projectStats = await collectProjectsData(zip); - manifest.stats.folderCount = projectStats.folders; - manifest.stats.boardCount = projectStats.boards; - - // 3. 收集素材数据(进度回调) - updateProgress(35, '正在备份素材...'); - const assetCount = await collectAssetsData(zip, (current, total) => { - const percent = 35 + Math.round((current / total) * 40); - updateProgress(percent, `正在备份素材 (${current}/${total})...`); - }); - manifest.stats.assetCount = assetCount; - - // 4. 导出已完成的媒体任务数据(素材库展示需要) - updateProgress(80, '正在导出任务数据...'); - const completedMediaTasks = allTasks.filter( - task => task.status === TaskStatus.COMPLETED && - (task.type === TaskType.IMAGE || task.type === TaskType.VIDEO) && - task.result?.url - ); - if (completedMediaTasks.length > 0) { - zip.file('tasks.json', JSON.stringify(completedMediaTasks, null, 2)); - manifest.stats.taskCount = completedMediaTasks.length; - } - - // 5. 写入清单文件 - zip.file('manifest.json', JSON.stringify(manifest, null, 2)); - - // 6. 生成并下载 ZIP 文件 - updateProgress(85, '正在压缩文件...'); - const blob = await zip.generateAsync({ - type: 'blob', - compression: 'DEFLATE', - compressionOptions: { level: 6 }, - }, (metadata) => { - const percent = 85 + Math.round(metadata.percent * 0.14); - updateProgress(percent, '正在压缩文件...'); - }); - - // 下载文件 - updateProgress(100, '备份完成!'); - const date = new Date(); - const dateStr = date.toISOString().split('T')[0]; - const timeStr = date.toTimeString().split(' ')[0].replace(/:/g, ''); - const filename = `aitu_backup_${dateStr}_${timeStr}.zip`; - - 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); - - // 关闭进度条,显示成功信息 - setTimeout(() => { - progressContainer.remove(); - const sizeInMB = (blob.size / 1024 / 1024).toFixed(2); - showBackupSuccessNotification({ - filename, - size: sizeInMB, - stats: manifest.stats, - }); - }, 500); - - btn.innerHTML = originalText; - btn.disabled = false; - - } catch (error) { - // 关闭进度条 - const progressContainer = document.querySelector('.backup-progress-container'); - if (progressContainer) progressContainer.remove(); - - showToast('备份失败: ' + error.message, 'error', 5000); - btn.innerHTML = originalText; - btn.disabled = false; - } -} - -/** - * 收集任务数据 - * 从 sw-task-queue 数据库读取所有任务 - */ -async function collectTasksData() { - try { - const tasks = await readAllFromIDB(SW_TASK_QUEUE_DB.name, SW_TASK_QUEUE_DB.stores.TASKS); - return tasks || []; - } catch (error) { - console.warn('[Backup] Failed to read tasks:', error); - return []; - } -} - -/** - * 收集提示词数据 - * 合并两个来源: - * 1. IndexedDB 中的提示词历史 - * 2. 任务队列中已完成任务的提示词 - */ -async function collectPromptsData(allTasks = []) { - const [promptHistory, videoPromptHistory, imagePromptHistory, presetSettings] = await Promise.all([ - readKVItem(KV_KEYS.PROMPT_HISTORY), - readKVItem(KV_KEYS.VIDEO_PROMPT_HISTORY), - readKVItem(KV_KEYS.IMAGE_PROMPT_HISTORY), - readKVItem(KV_KEYS.PRESET_SETTINGS), - ]); - - let finalPromptHistory = promptHistory || []; - let finalVideoPromptHistory = videoPromptHistory || []; - let finalImagePromptHistory = imagePromptHistory || []; - - // 从已完成的任务中提取提示词 - const completedTasks = allTasks.filter(task => task.status === TaskStatus.COMPLETED); - - // 提取图片任务的提示词 - const imageTaskPrompts = completedTasks - .filter(task => task.type === TaskType.IMAGE && task.params?.prompt) - .map(task => ({ - id: `task_${task.id}`, - content: task.params.prompt.trim(), - timestamp: task.completedAt || task.createdAt, - })) - .filter(item => item.content && item.content.length > 0); - - // 提取视频任务的提示词 - const videoTaskPrompts = completedTasks - .filter(task => task.type === TaskType.VIDEO && task.params?.prompt) - .map(task => ({ - id: `task_${task.id}`, - content: task.params.prompt.trim(), - timestamp: task.completedAt || task.createdAt, - })) - .filter(item => item.content && item.content.length > 0); - - // 合并图片提示词(去重) - const existingImageContents = new Set(finalImagePromptHistory.map(p => p.content)); - const newImagePrompts = imageTaskPrompts.filter(p => !existingImageContents.has(p.content)); - finalImagePromptHistory = [...finalImagePromptHistory, ...newImagePrompts]; - - // 合并视频提示词(去重) - const existingVideoContents = new Set(finalVideoPromptHistory.map(p => p.content)); - const newVideoPrompts = videoTaskPrompts.filter(p => !existingVideoContents.has(p.content)); - finalVideoPromptHistory = [...finalVideoPromptHistory, ...newVideoPrompts]; - - return { - promptHistory: finalPromptHistory, - videoPromptHistory: finalVideoPromptHistory, - imagePromptHistory: finalImagePromptHistory, - presetSettings: presetSettings || { - image: { pinnedPrompts: [], deletedPrompts: [] }, - video: { pinnedPrompts: [], deletedPrompts: [] }, - }, - }; -} - -/** - * 收集项目数据 - * 从 aitu-workspace 数据库的 folders 和 boards store 读取 - */ -async function collectProjectsData(zip) { - const projectsFolder = zip.folder('projects'); - - // 从独立的 store 读取,而不是 KV 存储 - const [folders, boards] = await Promise.all([ - readAllFromIDB(IDB_STORES.WORKSPACE.name, IDB_STORES.WORKSPACE.stores.FOLDERS), - readAllFromIDB(IDB_STORES.WORKSPACE.name, IDB_STORES.WORKSPACE.stores.BOARDS), - ]); - - const folderList = folders || []; - const boardList = boards || []; - - // 构建文件夹路径映射 - const folderPathMap = new Map(); - const folderMap = new Map(); - - for (const folder of folderList) { - folderMap.set(folder.id, folder); - } - - const getPath = (folderId) => { - if (folderPathMap.has(folderId)) { - return folderPathMap.get(folderId); - } - - const folder = folderMap.get(folderId); - if (!folder) return ''; - - const safeName = sanitizeFileName(folder.name); - if (folder.parentId) { - const parentPath = getPath(folder.parentId); - const fullPath = parentPath ? `${parentPath}/${safeName}` : safeName; - folderPathMap.set(folderId, fullPath); - return fullPath; - } - - folderPathMap.set(folderId, safeName); - return safeName; - }; - - for (const folder of folderList) { - getPath(folder.id); - } - - // 创建文件夹结构 - for (const folder of folderList) { - const path = folderPathMap.get(folder.id) || folder.name; - projectsFolder.folder(path); - } - - // 导出画板 - for (const board of boardList) { - const folderPath = board.folderId ? folderPathMap.get(board.folderId) : null; - const safeName = sanitizeFileName(board.name); - const boardPath = folderPath - ? `${folderPath}/${safeName}.drawnix` - : `${safeName}.drawnix`; - - const drawnixData = { - type: 'drawnix', - version: 1, - source: 'backup', - elements: board.elements || [], - viewport: board.viewport || { zoom: 1 }, - theme: board.theme, - boardMeta: { - id: board.id, - name: board.name, - folderId: board.folderId, - order: board.order, - createdAt: board.createdAt, - updatedAt: board.updatedAt, - }, - }; - - projectsFolder.file(boardPath, JSON.stringify(drawnixData, null, 2)); - } - - return { - folders: folderList.length, - boards: boardList.length, - }; -} - -/** - * 从 URL 生成唯一 ID(与应用层保持一致) - */ -function generateIdFromUrl(url) { - let hash = 0; - for (let i = 0; i < url.length; i++) { - const char = url.charCodeAt(i); - hash = ((hash << 5) - hash) + char; - hash = hash & hash; - } - return `cache-${Math.abs(hash).toString(36)}`; -} - -/** - * 收集素材数据 - * 三个数据源: - * 1. aitu-assets 数据库(本地素材库元数据) - * 2. drawnix-unified-cache 数据库(AI 生成媒体元数据) - * 3. drawnix-images Cache Storage(媒体二进制数据) - * @param {JSZip} zip - ZIP 实例 - * @param {Function} onProgress - 进度回调 (current, total) - */ -async function collectAssetsData(zip, onProgress) { - const assetsFolder = zip.folder('assets'); - let exportedCount = 0; - const exportedUrls = new Set(); - - try { - // 打开 Cache Storage - const cache = await caches.open(CACHE_NAMES.IMAGES); - - // 1. 从 aitu-assets 数据库读取本地素材元数据 - const assetMetaList = await readAllFromIDB(IDB_STORES.ASSETS.name, IDB_STORES.ASSETS.store); - - // 2. 从 drawnix-unified-cache 数据库读取 AI 生成媒体元数据 - const unifiedCacheItems = await readAllFromIDB(IDB_STORES.UNIFIED_CACHE.name, IDB_STORES.UNIFIED_CACHE.store); - - // 3. 获取虚拟路径缓存 - const cacheKeys = await cache.keys(); - const virtualRequests = cacheKeys.filter(req => req.url.includes('/__aitu_cache__/')); - - // 计算总数用于进度显示 - const totalItems = assetMetaList.length + unifiedCacheItems.length + virtualRequests.length; - let processedCount = 0; - - // 1. 导出本地素材 - for (const asset of assetMetaList) { - try { - assetsFolder.file(`${asset.id}.meta.json`, JSON.stringify(asset, null, 2)); - - if (asset.url) { - const response = await cache.match(asset.url); - if (response) { - const blob = await response.blob(); - if (blob.size > 0) { - const ext = getExtensionFromMimeType(asset.mimeType || blob.type); - assetsFolder.file(`${asset.id}${ext}`, blob); - exportedUrls.add(asset.url); - exportedCount++; - } - } - } - } catch (err) { - // 静默处理错误 - } - processedCount++; - if (onProgress) onProgress(processedCount, totalItems); - } - - // 2. 导出 unified-cache 中的素材 - const newCacheItems = unifiedCacheItems.filter(item => !exportedUrls.has(item.url)); - - for (const item of newCacheItems) { - try { - const itemId = item.metadata?.taskId || generateIdFromUrl(item.url); - - const metaData = { - id: itemId, - url: item.url, - type: item.type === 'video' ? 'VIDEO' : 'IMAGE', - mimeType: item.mimeType, - size: item.size, - source: 'AI_GENERATED', - createdAt: item.cachedAt, - updatedAt: item.lastUsed, - metadata: item.metadata, - }; - assetsFolder.file(`${itemId}.meta.json`, JSON.stringify(metaData, null, 2)); - - const response = await cache.match(item.url); - if (response) { - const blob = await response.blob(); - if (blob.size > 0) { - const ext = getExtensionFromMimeType(item.mimeType); - assetsFolder.file(`${itemId}${ext}`, blob); - exportedUrls.add(item.url); - exportedCount++; - } - } - } catch (err) { - // 静默处理错误 - } - processedCount++; - if (onProgress) onProgress(processedCount, totalItems); - } - - // 跳过已处理的 unified-cache items - processedCount += (unifiedCacheItems.length - newCacheItems.length); - - // 3. 导出虚拟路径缓存中的媒体(可能有些不在 unified-cache 中) - const pendingVirtualRequests = virtualRequests.filter(req => !exportedUrls.has(req.url)); - - for (const request of pendingVirtualRequests) { - const url = request.url; - - try { - const response = await cache.match(request); - if (response) { - const blob = await response.blob(); - if (blob.size > 0) { - const urlParts = url.split('/'); - const filename = urlParts[urlParts.length - 1]; - const id = filename.split('.')[0] || `cache-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - const contentType = response.headers.get('content-type') || blob.type; - const ext = getExtensionFromMimeType(contentType); - const type = contentType.startsWith('video/') ? 'VIDEO' : 'IMAGE'; - - const metadata = { - id, - url, - type, - mimeType: contentType, - size: blob.size, - source: 'AI_GENERATED', - createdAt: Date.now(), - }; - assetsFolder.file(`${id}.meta.json`, JSON.stringify(metadata, null, 2)); - assetsFolder.file(`${id}${ext}`, blob); - exportedUrls.add(url); - exportedCount++; - } - } - } catch (err) { - // 静默处理错误 - } - processedCount++; - if (onProgress) onProgress(processedCount, totalItems); - } - - } catch (error) { - // 静默处理错误 - } - - return exportedCount; -} - -/** - * 显示备份进度条 - */ -function showBackupProgress() { - const container = document.createElement('div'); - container.className = 'backup-progress-container'; - container.innerHTML = ` -
-
- 📦 - 正在备份数据 -
-
-
-
-
准备中...
-
- `; - document.body.appendChild(container); - return container; -} - -/** - * 显示 Toast 通知 - * @param {string} message - 通知消息 - * @param {string} type - 类型: 'success' | 'error' | 'warning' | 'info' - * @param {number} duration - 持续时间(毫秒),默认 3000 - */ -function showToast(message, type = 'success', duration = 3000) { - const icons = { - success: '✅', - error: '❌', - warning: '⚠️', - info: 'ℹ️' - }; - - const notification = document.createElement('div'); - notification.className = `import-notification toast-notification toast-${type}`; - notification.innerHTML = ` -
- ${icons[type] || icons.info} -
-

${escapeHtml(message)}

-
- -
- `; - - document.body.appendChild(notification); - - // 自动消失 - setTimeout(() => { - notification.classList.add('fade-out'); - setTimeout(() => notification.remove(), 300); - }, duration); -} - -/** - * 显示备份成功通知 - */ -function showBackupSuccessNotification({ filename, size, stats }) { - const notification = document.createElement('div'); - notification.className = 'import-notification backup-notification'; - notification.innerHTML = ` -
- ✅ -
- 备份成功 -

${filename}

-

- 文件大小: ${size} MB - ${stats.boardCount > 0 ? `| 画板: ${stats.boardCount}` : ''} - ${stats.folderCount > 0 ? `| 文件夹: ${stats.folderCount}` : ''} - ${stats.assetCount > 0 ? `| 素材: ${stats.assetCount}` : ''} - ${stats.taskCount > 0 ? `| 任务: ${stats.taskCount}` : ''} - ${stats.imagePromptCount > 0 ? `| 图片提示词: ${stats.imagePromptCount}` : ''} - ${stats.videoPromptCount > 0 ? `| 视频提示词: ${stats.videoPromptCount}` : ''} -

-
- -
- `; - - document.body.appendChild(notification); - - // 5 秒后自动消失 - setTimeout(() => { - notification.classList.add('fade-out'); - setTimeout(() => notification.remove(), 300); - }, 5000); -} - -// ==================== Analysis Mode ==================== - -/** - * Toggle analysis mode (for analyzing user-provided logs) - * In analysis mode: - * - SW connection is disabled - * - Only imported log data is displayed - * - Debug-related buttons are hidden - */ -function toggleAnalysisMode() { - state.isAnalysisMode = !state.isAnalysisMode; - updateAnalysisModeUI(); - - if (state.isAnalysisMode) { - // Clear all existing data when entering analysis mode - clearAllLogsForAnalysisMode(); - } else { - // Restore normal mode - reconnect to SW - exitAnalysisMode(); - } -} - -/** - * Update UI based on analysis mode state - */ -function updateAnalysisModeUI() { - const isAnalysis = state.isAnalysisMode; - - // Update mode indicator - if (elements.analysisModeIndicator) { - elements.analysisModeIndicator.style.display = isAnalysis ? 'inline-flex' : 'none'; - } - - // Update SW status indicator - if (elements.swStatus) { - elements.swStatus.style.display = isAnalysis ? 'none' : 'inline-flex'; - } - - // Update title - if (elements.panelTitle) { - elements.panelTitle.textContent = isAnalysis ? '日志分析模式' : 'Service Worker 调试面板'; - } - - // Show/hide import button - if (elements.importLogsBtn) { - elements.importLogsBtn.style.display = isAnalysis ? 'inline-flex' : 'none'; - } - - // Update toggle button appearance - if (elements.toggleAnalysisModeBtn) { - if (isAnalysis) { - elements.toggleAnalysisModeBtn.classList.add('active'); - elements.toggleAnalysisModeBtn.title = '退出分析模式,返回调试模式'; - } else { - elements.toggleAnalysisModeBtn.classList.remove('active'); - elements.toggleAnalysisModeBtn.title = '切换到分析模式(导入用户日志)'; - } - } - - // Hide/show debug-mode-only buttons - document.querySelectorAll('.debug-mode-only').forEach(el => { - el.style.display = isAnalysis ? 'none' : ''; - }); - - // In analysis mode, left panel shows user info instead of SW status - // The panel is always visible, but content changes based on mode - const leftPanel = document.querySelector('.left-panel'); - if (leftPanel) { - // Always show left panel, content will be different in analysis mode - leftPanel.style.display = ''; - } - - // Adjust panels grid - always use two-column layout - const panels = document.querySelector('.panels'); - if (panels) { - panels.style.gridTemplateColumns = '280px 1fr'; - } - - // Update left panel content for analysis mode - if (isAnalysis) { - showAnalysisModeLeftPanel(); - } else { - restoreDebugModeLeftPanel(); - } - - // Add/remove body class for analysis mode styling - document.body.classList.toggle('analysis-mode', isAnalysis); -} - -/** - * Clear display logs when entering analysis mode - * Current logs are preserved in liveLogs buffer for restoration later - */ -function clearAllLogsForAnalysisMode() { - // Save current logs to live buffer before clearing display - state.liveLogs.logs = [...state.logs]; - state.liveLogs.consoleLogs = [...state.consoleLogs]; - state.liveLogs.postmessageLogs = [...state.postmessageLogs]; - state.liveLogs.crashLogs = [...state.crashLogs]; - state.liveLogs.llmapiLogs = [...state.llmapiLogs]; - - // Clear display state for imported logs - state.logs = []; - state.consoleLogs = []; - state.postmessageLogs = []; - state.crashLogs = []; - state.llmapiLogs = []; - state.importedLogData = null; - - // Re-render all tabs - renderLogs(); - renderConsoleLogs(); - renderPostmessageLogs(); - renderCrashLogs(); - renderLLMApiLogs(); - - // Show import prompt - showImportPrompt(); -} - -/** - * Show analysis mode left panel (placeholder for user info) - */ -function showAnalysisModeLeftPanel() { - const leftPanel = document.querySelector('.left-panel'); - if (!leftPanel) return; - - leftPanel.innerHTML = ` -
-
- 🔍 分析模式 -
-
-
- 📋 -

请导入用户日志文件

-

导入后将显示用户信息

-
-
-
- `; -} - -/** - * Restore debug mode left panel - */ -function restoreDebugModeLeftPanel() { - const leftPanel = document.querySelector('.left-panel'); - if (!leftPanel) return; - - // Restore original HTML structure - leftPanel.innerHTML = ` -
-
- SW 状态信息 -
-
-
-
- 版本 - - -
-
- 调试模式 - 关闭 -
-
- Pending 图片请求 - 0 -
-
- Pending 视频请求 - 0 -
-
- 视频 Blob 缓存 - 0 -
-
- 已完成请求缓存 - 0 -
-
- 工作流处理器 - 未初始化 -
-
- 调试日志数 - 0 -
-
- - -
-
- -
-
- 内存监控 - -
-
-
-
- JS 堆已使用 - - -
-
- JS 堆总大小 - - -
-
- JS 堆上限 - - -
-
- 使用率 - - -
-
- - -
-
- -
-
- 缓存统计 - -
-
-
    -
  • - 加载中... -
  • -
-
-
- `; - - // Re-cache elements and rebind refresh cache button - elements.swVersion = document.getElementById('swVersion'); - elements.debugMode = document.getElementById('debugMode'); - elements.pendingImages = document.getElementById('pendingImages'); - elements.pendingVideos = document.getElementById('pendingVideos'); - elements.videoBlobCache = document.getElementById('videoBlobCache'); - elements.completedRequests = document.getElementById('completedRequests'); - elements.workflowHandler = document.getElementById('workflowHandler'); - elements.debugLogsCount = document.getElementById('debugLogsCount'); - elements.failedDomains = document.getElementById('failedDomains'); - elements.memoryUsed = document.getElementById('memoryUsed'); - elements.memoryTotal = document.getElementById('memoryTotal'); - elements.memoryLimit = document.getElementById('memoryLimit'); - elements.memoryPercent = document.getElementById('memoryPercent'); - elements.cacheList = document.getElementById('cacheList'); - - const refreshCacheBtn = document.getElementById('refreshCache'); - if (refreshCacheBtn) { - refreshCacheBtn.addEventListener('click', loadCacheStats); - } - - // Refresh data - refreshStatus(); - loadCacheStats(); - updateMemoryDisplay(); -} - -/** - * Show user info panel in analysis mode after importing logs - * @param {object} data - Imported log data - */ -function showUserInfoPanel(data) { - const leftPanel = document.querySelector('.left-panel'); - if (!leftPanel) return; - - // Extract user info from imported data - const userAgent = data.userAgent || '未知'; - const url = data.url || '未知'; - const exportTime = data.exportTime ? new Date(data.exportTime).toLocaleString('zh-CN') : '未知'; - const swStatus = data.swStatus || {}; - const memory = data.memory || {}; - const cacheStats = data.cacheStats || {}; - - // Parse UA for display - const uaInfo = parseUserAgent(userAgent); - - // Calculate total logs - const summary = data.summary || {}; - const logCounts = { - fetch: summary.fetchLogs || (data.fetchLogs?.length || 0), - console: summary.consoleLogs || (data.consoleLogs?.length || 0), - postmessage: summary.postmessageLogs || (data.postmessageLogs?.length || 0), - memory: summary.memoryLogs || (data.memoryLogs?.length || 0), - llmapi: summary.llmapiLogs || (data.llmapiLogs?.length || 0), - }; - const totalLogs = Object.values(logCounts).reduce((a, b) => a + b, 0); - - leftPanel.innerHTML = ` -
-
- 👤 用户信息 -
-
-
-
- 访问地址 - ${escapeHtml(truncateUrl(url, 40))} -
-
- 浏览器 - ${escapeHtml(uaInfo.browser)} -
-
- 系统 - ${escapeHtml(uaInfo.os)} -
-
- 导出时间 - ${escapeHtml(exportTime)} -
-
- 日志总数 - ${totalLogs} -
-
-
-
- 完整 User-Agent -

${escapeHtml(userAgent)}

-
-
-
-
- -
-
- 📊 导出时状态 -
-
-
-
- SW 版本 - ${escapeHtml(swStatus.version || '-')} -
-
- 调试模式 - ${swStatus.debugModeEnabled ? '开启' : '关闭'} -
-
- 工作流处理器 - ${swStatus.workflowHandlerInitialized ? '已初始化' : '未初始化'} -
-
- 调试日志数 - ${swStatus.debugLogsCount || 0} -
-
-
-
- - ${memory.usedMB ? ` -
-
- 💾 内存快照 -
-
-
-
- JS 堆已使用 - ${memory.usedMB} MB -
-
- JS 堆总大小 - ${memory.totalMB} MB -
-
- 使用率 - ${memory.usagePercent}% -
-
-
-
- ` : ''} - - ${Object.keys(cacheStats).length > 0 ? ` -
-
- 📦 缓存快照 -
-
-
    - ${Object.entries(cacheStats).map(([name, stats]) => ` -
  • - ${escapeHtml(truncateUrl(name, 25))} - ${stats.count} 项 - ${formatBytes(stats.totalSize)} -
  • - `).join('')} -
-
-
- ` : ''} - -
-
- 📋 日志统计 -
-
-
- ${logCounts.fetch > 0 ? `
Fetch${logCounts.fetch}
` : ''} - ${logCounts.console > 0 ? `
控制台${logCounts.console}
` : ''} - ${logCounts.postmessage > 0 ? `
PostMessage${logCounts.postmessage}
` : ''} - ${logCounts.memory > 0 ? `
内存${logCounts.memory}
` : ''} - ${logCounts.llmapi > 0 ? `
LLM API${logCounts.llmapi}
` : ''} - ${totalLogs === 0 ? `
无日志数据
` : ''} -
-
-
- `; -} - -/** - * Parse User-Agent string to extract browser and OS info - * @param {string} ua - User-Agent string - * @returns {{browser: string, os: string}} - */ -function parseUserAgent(ua) { - let browser = '未知'; - let os = '未知'; - - // Detect browser - if (ua.includes('Chrome') && !ua.includes('Edg')) { - const match = ua.match(/Chrome\/(\d+)/); - browser = match ? `Chrome ${match[1]}` : 'Chrome'; - } else if (ua.includes('Edg')) { - const match = ua.match(/Edg\/(\d+)/); - browser = match ? `Edge ${match[1]}` : 'Edge'; - } else if (ua.includes('Firefox')) { - const match = ua.match(/Firefox\/(\d+)/); - browser = match ? `Firefox ${match[1]}` : 'Firefox'; - } else if (ua.includes('Safari') && !ua.includes('Chrome')) { - const match = ua.match(/Version\/(\d+)/); - browser = match ? `Safari ${match[1]}` : 'Safari'; - } - - // Detect OS - if (ua.includes('Windows NT 10')) { - os = 'Windows 10/11'; - } else if (ua.includes('Windows NT')) { - os = 'Windows'; - } else if (ua.includes('Mac OS X')) { - const match = ua.match(/Mac OS X (\d+[._]\d+)/); - os = match ? `macOS ${match[1].replace('_', '.')}` : 'macOS'; - } else if (ua.includes('Linux')) { - os = 'Linux'; - } else if (ua.includes('Android')) { - const match = ua.match(/Android (\d+)/); - os = match ? `Android ${match[1]}` : 'Android'; - } else if (ua.includes('iPhone') || ua.includes('iPad')) { - const match = ua.match(/OS (\d+)/); - os = match ? `iOS ${match[1]}` : 'iOS'; - } - - return { browser, os }; -} - -/** - * Truncate URL for display - * @param {string} url - * @param {number} maxLen - * @returns {string} - */ -function truncateUrl(url, maxLen) { - if (url.length <= maxLen) return url; - return url.substring(0, maxLen - 3) + '...'; -} - -/** - * Escape HTML special characters - * @param {string} str - * @returns {string} - */ -function escapeHtml(str) { - if (typeof str !== 'string') return String(str); - return str - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} - -/** - * Show import prompt in logs container - */ -function showImportPrompt() { - if (elements.logsContainer) { - elements.logsContainer.innerHTML = ` -
- 📁 -

分析模式

-

在此模式下,您可以导入用户提供的日志文件进行分析

-

- 支持从"导出日志"功能生成的 JSON 文件 -

- -

- 点击右上角搜索图标可退出分析模式 -

-
- `; - - // Attach event listener to the prompt button - const btn = document.getElementById('importPromptBtn'); - if (btn) { - btn.addEventListener('click', triggerImportDialog); - } - } -} - -/** - * Exit analysis mode and restore normal SW connection - */ -function exitAnalysisMode() { - state.importedLogData = null; - - // Restore live logs that were collected during analysis mode - state.logs = state.liveLogs.logs; - state.consoleLogs = state.liveLogs.consoleLogs; - state.postmessageLogs = state.liveLogs.postmessageLogs; - state.crashLogs = state.liveLogs.crashLogs; - state.llmapiLogs = state.liveLogs.llmapiLogs; - - // Re-render with restored logs - renderLogs(); - renderConsoleLogs(); - updateMessageTypeOptions(); - renderPostmessageLogs(); - renderCrashLogs(); - renderLLMApiLogs(); - - // Update tab counts - updateConsoleCount(); - updatePostmessageCount(); - updateCrashCount(); - updateErrorDots(); - - // Reconnect to SW and refresh status - if (navigator.serviceWorker?.controller) { - enableDebug(); - refreshStatus(); - // Re-fetch latest logs from SW (will merge with restored logs) - loadConsoleLogs(); - loadPostMessageLogs(); - loadCrashLogs(); - loadLLMApiLogs(); - } -} - -/** - * Trigger file import dialog - */ -function triggerImportDialog() { - if (elements.importLogsInput) { - elements.importLogsInput.click(); - } -} - -/** - * Handle imported log file - * @param {Event} event - File input change event - */ -async function handleLogImport(event) { - const file = event.target.files?.[0]; - if (!file) return; - - try { - const text = await file.text(); - const data = JSON.parse(text); - - // Validate the imported data structure - if (!data || typeof data !== 'object') { - throw new Error('无效的日志文件格式'); - } - - // Store imported data - state.importedLogData = data; - - // Parse and load logs from the imported data - loadImportedLogs(data); - - // Show success notification - showImportSuccessMessage(file.name, data); - - } catch (error) { - console.error('Failed to import log file:', error); - showToast(`导入失败: ${error.message}`, 'error', 5000); - } - - // Reset file input so same file can be selected again - event.target.value = ''; -} - -/** - * Load logs from imported data - * @param {object} data - Imported log data - */ -function loadImportedLogs(data) { - // Load fetch logs - if (data.fetchLogs && Array.isArray(data.fetchLogs)) { - state.logs = data.fetchLogs.map(log => ({ - ...log, - // Ensure required fields exist - id: log.id || `imported-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, - timestamp: log.timestamp || Date.now(), - })); - renderLogs(); - } - - // Load console logs - if (data.consoleLogs && Array.isArray(data.consoleLogs)) { - state.consoleLogs = data.consoleLogs.map(log => ({ - ...log, - id: log.id || `console-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, - timestamp: log.timestamp || Date.now(), - })); - renderConsoleLogs(); - } - - // Load postmessage logs - if (data.postmessageLogs && Array.isArray(data.postmessageLogs)) { - state.postmessageLogs = data.postmessageLogs.map(log => ({ - ...log, - id: log.id || `pm-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, - timestamp: log.timestamp || Date.now(), - })); - updateMessageTypeOptions(); - renderPostmessageLogs(); - } - - // Load memory/crash logs - if (data.memoryLogs && Array.isArray(data.memoryLogs)) { - state.crashLogs = data.memoryLogs.map(log => ({ - ...log, - id: log.id || `crash-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, - timestamp: log.timestamp || Date.now(), - })); - renderCrashLogs(); - } - - // Load LLM API logs - if (data.llmapiLogs && Array.isArray(data.llmapiLogs)) { - state.llmapiLogs = data.llmapiLogs.map(log => ({ - ...log, - id: log.id || `llm-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, - timestamp: log.timestamp || Date.now(), - })); - renderLLMApiLogs(); - } - - // Store SW status from imported data for display - if (data.swStatus) { - state.swStatus = data.swStatus; - } - - // Update tab counts - updateConsoleCount(); - updatePostmessageCount(); - updateCrashCount(); - updateErrorDots(); - - // Show user info panel in left sidebar - showUserInfoPanel(data); -} - -/** - * Show success message after importing logs - * @param {string} filename - * @param {object} data - */ -function showImportSuccessMessage(filename, data) { - const summary = data.summary || {}; - const counts = { - fetch: summary.fetchLogs || (data.fetchLogs?.length || 0), - console: summary.consoleLogs || (data.consoleLogs?.length || 0), - postmessage: summary.postmessageLogs || (data.postmessageLogs?.length || 0), - memory: summary.memoryLogs || (data.memoryLogs?.length || 0), - llmapi: summary.llmapiLogs || (data.llmapiLogs?.length || 0), - }; - - const totalLogs = counts.fetch + counts.console + counts.postmessage + counts.memory + counts.llmapi; - - // Create a temporary notification - const notification = document.createElement('div'); - notification.className = 'import-notification'; - notification.innerHTML = ` -
- ✅ -
- 导入成功 -

${filename}

-

- 共 ${totalLogs} 条日志 - ${counts.fetch > 0 ? `| Fetch: ${counts.fetch}` : ''} - ${counts.console > 0 ? `| 控制台: ${counts.console}` : ''} - ${counts.postmessage > 0 ? `| PostMessage: ${counts.postmessage}` : ''} - ${counts.memory > 0 ? `| 内存: ${counts.memory}` : ''} - ${counts.llmapi > 0 ? `| LLM API: ${counts.llmapi}` : ''} -

- ${data.exportTime ? `

导出时间: ${new Date(data.exportTime).toLocaleString('zh-CN')}

` : ''} -
- -
- `; - - document.body.appendChild(notification); - - // Auto remove after 5 seconds - setTimeout(() => { - notification.classList.add('fade-out'); - setTimeout(() => notification.remove(), 300); - }, 5000); -} - -/** - * Toggle theme between light and dark - */ -function toggleTheme() { - const currentTheme = document.documentElement.getAttribute('data-theme'); - const newTheme = currentTheme === 'dark' ? 'light' : 'dark'; - document.documentElement.setAttribute('data-theme', newTheme); - updateThemeIcon(newTheme); - saveTheme(newTheme); -} - -/** - * Update theme icon (SVG) - */ -function updateThemeIcon(theme) { - if (elements.themeIcon) { - if (theme === 'dark') { - // Sun icon for dark mode (click to switch to light) - elements.themeIcon.innerHTML = ''; - } else { - // Moon icon for light mode (click to switch to dark) - elements.themeIcon.innerHTML = ''; - } - } -} - -/** - * Save theme preference - */ -function saveTheme(theme) { - try { - localStorage.setItem('sw-debug-theme', theme); - } catch (e) { - console.error('Failed to save theme:', e); - } -} - -/** - * Load saved theme preference - */ -function loadTheme() { - try { - const savedTheme = localStorage.getItem('sw-debug-theme'); - // Also check system preference - const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; - const theme = savedTheme || (prefersDark ? 'dark' : 'light'); - document.documentElement.setAttribute('data-theme', theme); - updateThemeIcon(theme); - } catch (e) { - console.error('Failed to load theme:', e); - } -} - -/** - * Show settings modal - */ -function showSettingsModal() { - if (elements.settingsModalOverlay) { - // Populate current values - if (elements.settingMaxLogs) { - elements.settingMaxLogs.value = state.settings.maxLogs.toString(); - } - if (elements.settingAutoClean) { - elements.settingAutoClean.value = state.settings.autoCleanMinutes.toString(); - } - if (elements.settingKeepBookmarks) { - elements.settingKeepBookmarks.checked = state.settings.keepBookmarks; - } - elements.settingsModalOverlay.style.display = 'flex'; - } -} - -/** - * Close settings modal - */ -function closeSettingsModal() { - if (elements.settingsModalOverlay) { - elements.settingsModalOverlay.style.display = 'none'; - } -} - -/** - * Save settings - */ -function saveSettings() { - const newSettings = { - maxLogs: parseInt(elements.settingMaxLogs?.value || '500'), - autoCleanMinutes: parseInt(elements.settingAutoClean?.value || '0'), - keepBookmarks: elements.settingKeepBookmarks?.checked ?? true, - }; - - state.settings = newSettings; - - // Save to localStorage - try { - localStorage.setItem('sw-debug-settings', JSON.stringify(newSettings)); - } catch (e) { - console.error('Failed to save settings:', e); - } - - // Apply new max logs limit - applyMaxLogsLimit(); - - // Setup auto clean timer - setupAutoCleanTimer(); - - closeSettingsModal(); -} - -/** - * Load saved settings - */ -function loadSettings() { - try { - const saved = localStorage.getItem('sw-debug-settings'); - if (saved) { - state.settings = { ...state.settings, ...JSON.parse(saved) }; - } - } catch (e) { - console.error('Failed to load settings:', e); - } - - // Setup auto clean timer based on saved settings - setupAutoCleanTimer(); -} - -/** - * Apply max logs limit to current logs - */ -function applyMaxLogsLimit() { - const maxLogs = state.settings.maxLogs; - - if (state.logs.length > maxLogs) { - // Keep bookmarked logs if setting is enabled - if (state.settings.keepBookmarks) { - const bookmarked = state.logs.filter(l => state.bookmarkedLogIds.has(l.id)); - const nonBookmarked = state.logs.filter(l => !state.bookmarkedLogIds.has(l.id)); - state.logs = [...bookmarked, ...nonBookmarked.slice(0, maxLogs - bookmarked.length)]; - } else { - state.logs = state.logs.slice(0, maxLogs); - } - renderLogs(); - } -} - -/** - * Setup auto clean timer - */ -function setupAutoCleanTimer() { - // Clear existing timer - if (state.autoCleanTimerId) { - clearInterval(state.autoCleanTimerId); - state.autoCleanTimerId = null; - } - - const minutes = state.settings.autoCleanMinutes; - if (minutes <= 0) return; - - // Run cleanup every minute - state.autoCleanTimerId = setInterval(() => { - autoCleanLogs(); - }, 60000); - - // Also run immediately - autoCleanLogs(); -} - -/** - * Auto clean old logs - */ -function autoCleanLogs() { - const minutes = state.settings.autoCleanMinutes; - if (minutes <= 0) return; - - const cutoffTime = Date.now() - (minutes * 60 * 1000); - const beforeCount = state.logs.length; - - if (state.settings.keepBookmarks) { - state.logs = state.logs.filter(l => - l.timestamp >= cutoffTime || state.bookmarkedLogIds.has(l.id) - ); - } else { - state.logs = state.logs.filter(l => l.timestamp >= cutoffTime); - } - - if (state.logs.length !== beforeCount) { - renderLogs(); - } -} - -/** - * Toggle select mode for batch operations - */ -function toggleSelectMode() { - state.isSelectMode = !state.isSelectMode; - state.selectedLogIds.clear(); - updateSelectModeUI(); - renderLogs(); -} - -/** - * Update select mode UI - */ -function updateSelectModeUI() { - if (elements.toggleSelectModeBtn) { - elements.toggleSelectModeBtn.textContent = state.isSelectMode ? '✅ 取消' : '☑️ 选择'; - elements.toggleSelectModeBtn.style.background = state.isSelectMode ? 'var(--primary-color)' : ''; - elements.toggleSelectModeBtn.style.color = state.isSelectMode ? '#fff' : ''; - } - if (elements.batchActionsEl) { - elements.batchActionsEl.style.display = state.isSelectMode ? 'flex' : 'none'; - } - updateSelectedCount(); -} - -/** - * Update selected count display - */ -function updateSelectedCount() { - if (elements.selectedCountEl) { - elements.selectedCountEl.textContent = `已选 ${state.selectedLogIds.size} 条`; - } -} - -/** - * Toggle selection of a log entry - */ -function toggleLogSelection(logId) { - if (state.selectedLogIds.has(logId)) { - state.selectedLogIds.delete(logId); - } else { - state.selectedLogIds.add(logId); - } - updateSelectedCount(); - // Update checkbox in DOM - const checkbox = document.querySelector(`.log-select-checkbox[data-id="${logId}"]`); - if (checkbox) { - checkbox.checked = state.selectedLogIds.has(logId); - } -} - -/** - * Select all visible logs - */ -function selectAllLogs() { - const filteredLogs = getFilteredFetchLogs(); - const allSelected = filteredLogs.every(l => state.selectedLogIds.has(l.id)); - - if (allSelected) { - // Deselect all - filteredLogs.forEach(l => state.selectedLogIds.delete(l.id)); - } else { - // Select all - filteredLogs.forEach(l => state.selectedLogIds.add(l.id)); - } - - updateSelectedCount(); - renderLogs(); -} - -/** - * Batch bookmark selected logs - */ -function batchBookmarkLogs() { - if (state.selectedLogIds.size === 0) { - alert('请先选择日志'); - return; - } - - state.selectedLogIds.forEach(id => { - state.bookmarkedLogIds.add(id); - }); - - saveBookmarks(); - state.selectedLogIds.clear(); - updateSelectedCount(); - renderLogs(); -} - -/** - * Batch delete selected logs - */ -function batchDeleteLogs() { - if (state.selectedLogIds.size === 0) { - alert('请先选择日志'); - return; - } - - if (!confirm(`确定要删除选中的 ${state.selectedLogIds.size} 条日志吗?`)) { - return; - } - - state.logs = state.logs.filter(l => !state.selectedLogIds.has(l.id)); - - // Also remove from bookmarks - state.selectedLogIds.forEach(id => { - state.bookmarkedLogIds.delete(id); - }); - saveBookmarks(); - - state.selectedLogIds.clear(); - updateSelectedCount(); - renderLogs(); -} - -/** - * Find related requests based on URL pattern or timing - * @param {object} log - The log entry to find related requests for - * @returns {Array} - Array of related log entries - */ -function findRelatedRequests(log) { - if (!log.url) return []; - - try { - const urlObj = new URL(log.url); - const basePath = urlObj.pathname.split('/').slice(0, 3).join('/'); // First 2 path segments - const timestamp = log.timestamp; - const timeWindow = 5000; // 5 second window - - return state.logs.filter(l => { - if (l.id === log.id) return false; - if (!l.url) return false; - - try { - const otherUrl = new URL(l.url); - - // Same host - if (otherUrl.hostname !== urlObj.hostname) return false; - - // Similar path OR within time window - const otherBasePath = otherUrl.pathname.split('/').slice(0, 3).join('/'); - const pathMatch = otherBasePath === basePath; - const timeMatch = Math.abs(l.timestamp - timestamp) <= timeWindow; - - return pathMatch || timeMatch; - } catch { - return false; - } - }).slice(0, 10); // Limit to 10 related requests - } catch { - return []; - } -} - -/** - * Render related requests section for log details - * @param {object} log - The log entry - * @returns {string} - HTML string - */ -function renderRelatedRequests(log) { - const related = findRelatedRequests(log); - if (related.length === 0) return ''; - - const items = related.map(r => { - const time = new Date(r.timestamp).toLocaleTimeString('zh-CN', { hour12: false }); - const status = r.status || '...'; - const statusClass = r.status >= 200 && r.status < 400 ? 'success' : (r.status >= 400 ? 'error' : ''); - const duration = r.duration ? `${r.duration}ms` : '-'; - // Show full URL, let CSS handle wrapping - const displayUrl = r.url || '-'; - - return ` - - `; - }).join(''); - - return ` -
-

🔗 相关请求 (${related.length})

- -
- `; -} - -// Make renderRelatedRequests available globally -window.renderRelatedRequests = renderRelatedRequests; - -/** - * Update error dot indicators - */ -function updateErrorDots() { - // Console errors - if (elements.consoleErrorDot) { - const hasErrors = state.consoleLogs.some(l => l.logLevel === 'error'); - elements.consoleErrorDot.style.display = (hasErrors && state.activeTab !== 'console') ? 'inline-block' : 'none'; - } - - // LLM API errors - if (elements.llmapiErrorDot) { - const hasErrors = state.llmapiLogs.some(l => l.status === 'error'); - elements.llmapiErrorDot.style.display = (hasErrors && state.activeTab !== 'llmapi') ? 'inline-block' : 'none'; - } - - // Crash/memory errors - if (elements.crashErrorDot) { - const hasErrors = state.crashLogs.some(l => l.type === 'error' || l.type === 'freeze' || l.type === 'whitescreen'); - elements.crashErrorDot.style.display = (hasErrors && state.activeTab !== 'crash') ? 'inline-block' : 'none'; - } -} - -// Application State -const state = { - debugEnabled: false, - logs: [], - consoleLogs: [], - postmessageLogs: [], - crashLogs: [], // Crash snapshots - llmapiLogs: [], // LLM API call logs - swStatus: null, // SW status data for export - autoScroll: true, - activeTab: 'fetch', - expandedLogIds: new Set(), // Track expanded fetch log IDs - expandedStackIds: new Set(), // Track expanded console stack IDs - expandedPmIds: new Set(), // Track expanded postmessage log IDs - expandedCrashIds: new Set(), // Track expanded crash log IDs - expandedLLMApiIds: new Set(), // Track expanded LLM API log IDs - // New states for enhanced features - bookmarkedLogIds: new Set(), // Bookmarked/starred log IDs - showBookmarksOnly: false, // Filter to show only bookmarked logs - filterSlowOnly: false, // Filter to show only slow requests (>1s) - isSelectMode: false, // Batch select mode - selectedLogIds: new Set(), // Selected log IDs for batch operations - isPaused: false, // Pause real-time updates - pendingLogs: [], // Logs received while paused - hasNewErrors: false, // Track new errors for tab indicator - hasNewCrashLogs: false, // Track new crash logs - hasNewLLMApiErrors: false, // Track new LLM API errors - // Settings - settings: { - maxLogs: 500, - autoCleanMinutes: 0, - keepBookmarks: true, - }, - autoCleanTimerId: null, - // Analysis mode - for debugging user-provided logs without local SW connection - isAnalysisMode: false, - importedLogData: null, // Imported log data from user - // Live logs buffer - stores incoming logs while in analysis mode - // So they can be restored when exiting analysis mode - liveLogs: { - logs: [], - consoleLogs: [], - postmessageLogs: [], - crashLogs: [], - llmapiLogs: [], - }, -}; - -// Memory monitoring interval -let memoryMonitorInterval = null; - -// DOM Elements cache -let elements = {}; - -/** - * Cache DOM elements - */ -function cacheElements() { - elements = { - swStatus: document.getElementById('swStatus'), - toggleDebugBtn: document.getElementById('toggleDebug'), - exportLogsBtn: document.getElementById('exportLogs'), - clearLogsBtn: document.getElementById('clearLogs'), - refreshStatusBtn: document.getElementById('refreshStatus'), - refreshCacheBtn: document.getElementById('refreshCache'), - enableDebugBtn: document.getElementById('enableDebugBtn'), - logsContainer: document.getElementById('logsContainer'), - consoleLogsContainer: document.getElementById('consoleLogsContainer'), - filterType: document.getElementById('filterType'), - filterStatus: document.getElementById('filterStatus'), - filterTimeRange: document.getElementById('filterTimeRange'), - filterUrl: document.getElementById('filterUrl'), - filterUrlRegex: document.getElementById('filterUrlRegex'), - togglePauseBtn: document.getElementById('togglePause'), - toggleSelectModeBtn: document.getElementById('toggleSelectMode'), - batchActionsEl: document.getElementById('batchActions'), - selectAllBtn: document.getElementById('selectAll'), - batchBookmarkBtn: document.getElementById('batchBookmark'), - batchDeleteBtn: document.getElementById('batchDelete'), - selectedCountEl: document.getElementById('selectedCount'), - fetchCountEl: document.getElementById('fetchCount'), - exportFetchCSVBtn: document.getElementById('exportFetchCSV'), - showShortcutsBtn: document.getElementById('showShortcuts'), - showBookmarksOnly: document.getElementById('showBookmarksOnly'), - shortcutsModalOverlay: document.getElementById('shortcutsModalOverlay'), - closeShortcutsModalBtn: document.getElementById('closeShortcutsModal'), - toggleThemeBtn: document.getElementById('toggleTheme'), - themeIcon: document.getElementById('themeIcon'), - showSettingsBtn: document.getElementById('showSettings'), - settingsModalOverlay: document.getElementById('settingsModalOverlay'), - closeSettingsModalBtn: document.getElementById('closeSettingsModal'), - saveSettingsBtn: document.getElementById('saveSettings'), - settingMaxLogs: document.getElementById('settingMaxLogs'), - settingAutoClean: document.getElementById('settingAutoClean'), - settingKeepBookmarks: document.getElementById('settingKeepBookmarks'), - // Stats elements - statTotalRequests: document.getElementById('statTotalRequests'), - statSuccessRate: document.getElementById('statSuccessRate'), - statAvgDuration: document.getElementById('statAvgDuration'), - statCacheHit: document.getElementById('statCacheHit'), - statSlowRequests: document.getElementById('statSlowRequests'), - statSlowRequestsWrapper: document.getElementById('statSlowRequestsWrapper'), - // Duration chart elements - chartFast: document.getElementById('chartFast'), - chartMedium: document.getElementById('chartMedium'), - chartSlow: document.getElementById('chartSlow'), - chartVerySlow: document.getElementById('chartVerySlow'), - filterConsoleLevel: document.getElementById('filterConsoleLevel'), - filterConsoleText: document.getElementById('filterConsoleText'), - clearConsoleLogsBtn: document.getElementById('clearConsoleLogs'), - copyConsoleLogsBtn: document.getElementById('copyConsoleLogs'), - autoScrollCheckbox: document.getElementById('autoScroll'), - consoleCountEl: document.getElementById('consoleCount'), - postmessageCountEl: document.getElementById('postmessageCount'), - postmessageLogsContainer: document.getElementById('postmessageLogsContainer'), - filterMessageDirection: document.getElementById('filterMessageDirection'), - filterMessageTypeSelect: document.getElementById('filterMessageTypeSelect'), - filterPmTimeRange: document.getElementById('filterPmTimeRange'), - filterMessageType: document.getElementById('filterMessageType'), - // Error dot indicators - consoleErrorDot: document.getElementById('consoleErrorDot'), - llmapiErrorDot: document.getElementById('llmapiErrorDot'), - crashErrorDot: document.getElementById('crashErrorDot'), - clearPostmessageLogsBtn: document.getElementById('clearPostmessageLogs'), - copyPostmessageLogsBtn: document.getElementById('copyPostmessageLogs'), - copyFetchLogsBtn: document.getElementById('copyFetchLogs'), - // Status panel elements - swVersion: document.getElementById('swVersion'), - debugMode: document.getElementById('debugMode'), - pendingImages: document.getElementById('pendingImages'), - pendingVideos: document.getElementById('pendingVideos'), - videoBlobCache: document.getElementById('videoBlobCache'), - completedRequests: document.getElementById('completedRequests'), - workflowHandler: document.getElementById('workflowHandler'), - debugLogsCount: document.getElementById('debugLogsCount'), - failedDomainsSection: document.getElementById('failedDomainsSection'), - failedDomains: document.getElementById('failedDomains'), - cacheList: document.getElementById('cacheList'), - // Export modal elements - exportModalOverlay: document.getElementById('exportModalOverlay'), - closeExportModalBtn: document.getElementById('closeExportModal'), - cancelExportBtn: document.getElementById('cancelExport'), - doExportBtn: document.getElementById('doExport'), - selectAllExport: document.getElementById('selectAllExport'), - // LLM API logs elements - llmapiLogsContainer: document.getElementById('llmapiLogsContainer'), - filterLLMApiType: document.getElementById('filterLLMApiType'), - filterLLMApiStatus: document.getElementById('filterLLMApiStatus'), - refreshLLMApiLogsBtn: document.getElementById('refreshLLMApiLogs'), - copyLLMApiLogsBtn: document.getElementById('copyLLMApiLogs'), - exportLLMApiLogsBtn: document.getElementById('exportLLMApiLogs'), - clearLLMApiLogsBtn: document.getElementById('clearLLMApiLogs'), - // Crash logs elements - crashCountEl: document.getElementById('crashCount'), - crashLogsContainer: document.getElementById('crashLogsContainer'), - filterCrashType: document.getElementById('filterCrashType'), - refreshCrashLogsBtn: document.getElementById('refreshCrashLogs'), - copyCrashLogsBtn: document.getElementById('copyCrashLogs'), - clearCrashLogsBtn: document.getElementById('clearCrashLogs'), - exportCrashLogsBtn: document.getElementById('exportCrashLogs'), - // Memory monitoring elements - memoryUsed: document.getElementById('memoryUsed'), - memoryTotal: document.getElementById('memoryTotal'), - memoryLimit: document.getElementById('memoryLimit'), - memoryPercent: document.getElementById('memoryPercent'), - memoryWarning: document.getElementById('memoryWarning'), - memoryNotSupported: document.getElementById('memoryNotSupported'), - memoryUpdateTime: document.getElementById('memoryUpdateTime'), - // Analysis mode elements - toggleAnalysisModeBtn: document.getElementById('toggleAnalysisMode'), - importLogsBtn: document.getElementById('importLogs'), - importLogsInput: document.getElementById('importLogsInput'), - analysisModeIndicator: document.getElementById('analysisModeIndicator'), - panelTitle: document.getElementById('panelTitle'), - // Backup button - backupDataBtn: document.getElementById('backupData'), - }; -} - -/** - * Render fetch logs - */ -function renderLogs() { - const typeFilter = elements.filterType?.value || ''; - const statusFilter = elements.filterStatus?.value || ''; - const timeRangeFilter = elements.filterTimeRange?.value || ''; - const urlFilter = (elements.filterUrl?.value || '').toLowerCase(); - - let filteredLogs = state.logs; - - // Filter out blacklisted domains - filteredLogs = filteredLogs.filter(l => !isBlacklistedUrl(l.url)); - - if (typeFilter) { - filteredLogs = filteredLogs.filter(l => l.requestType === typeFilter); - } - - if (statusFilter) { - if (statusFilter === 'error') { - // Filter failed requests (4xx, 5xx, or has error) - filteredLogs = filteredLogs.filter(l => l.status >= 400 || l.error); - } else if (statusFilter === '500') { - filteredLogs = filteredLogs.filter(l => l.status >= 500); - } else if (statusFilter === 'slow') { - // Filter slow requests (> 1 second) - filteredLogs = filteredLogs.filter(l => l.duration >= 1000); - } else { - filteredLogs = filteredLogs.filter(l => l.status === parseInt(statusFilter)); - } - } - - // Apply time range filter - filteredLogs = filterByTimeRange(filteredLogs, timeRangeFilter); - - // URL filter with optional regex support - if (urlFilter) { - const useRegex = elements.filterUrlRegex?.checked || false; - if (useRegex) { - try { - const regex = new RegExp(urlFilter, 'i'); - filteredLogs = filteredLogs.filter(l => regex.test(l.url || '')); - } catch (e) { - // Invalid regex, fall back to simple match - filteredLogs = filteredLogs.filter(l => l.url?.toLowerCase().includes(urlFilter)); - } - } else { - filteredLogs = filteredLogs.filter(l => l.url?.toLowerCase().includes(urlFilter)); - } - } - - // Bookmarks filter - if (state.showBookmarksOnly) { - filteredLogs = filteredLogs.filter(l => state.bookmarkedLogIds.has(l.id)); - } - - // Slow requests filter (via stats bar click) - if (state.filterSlowOnly) { - filteredLogs = filteredLogs.filter(l => l.duration >= 1000); - } - - // Update statistics panel - updateFetchStats(); - - // Update fetch count - if (elements.fetchCountEl) { - const slowCount = filteredLogs.filter(l => l.duration >= 1000).length; - const errorCount = filteredLogs.filter(l => l.status >= 400 || l.error).length; - let countText = `(${filteredLogs.length})`; - if (slowCount > 0 || errorCount > 0) { - const parts = []; - if (errorCount > 0) parts.push(`${errorCount} err`); - if (slowCount > 0) parts.push(`${slowCount} slow`); - countText = `(${parts.join(', ')})`; - } - elements.fetchCountEl.innerHTML = countText; - } - - if (filteredLogs.length === 0) { - elements.logsContainer.innerHTML = ` -
- 📋 -

${state.debugEnabled ? '暂无匹配的日志' : '请先启用调试模式'}

- ${!state.debugEnabled ? '' : ''} -
- `; - const btn = document.getElementById('enableDebugBtn2'); - if (btn) { - btn.addEventListener('click', toggleDebug); - } - return; - } - - elements.logsContainer.innerHTML = ''; - filteredLogs.slice(0, 200).forEach(log => { - const isExpanded = state.expandedLogIds.has(log.id); - const isBookmarked = state.bookmarkedLogIds.has(log.id); - const isSelected = state.selectedLogIds.has(log.id); - const entry = createLogEntry( - log, - isExpanded, - (id, expanded) => { - // Update expanded state - if (expanded) { - state.expandedLogIds.add(id); - } else { - state.expandedLogIds.delete(id); - } - }, - isBookmarked, - toggleBookmark, - state.isSelectMode, - isSelected, - toggleLogSelection - ); - - // Add slow request class for highlighting - const speedClass = getSpeedClass(log.duration); - if (speedClass !== 'normal') { - entry.classList.add('slow-request'); - } - - elements.logsContainer.appendChild(entry); - }); -} +// Feature modules +import { performBackup } from './backup.js'; +import { + toggleAnalysisMode, + updateAnalysisModeUI, + showImportPrompt, + triggerImportDialog, + handleLogImport, + setAnalysisModeCallbacks, +} from './analysis-mode.js'; +import { + toggleTheme, + loadTheme, + showSettingsModal, + closeSettingsModal, + saveSettings, + loadSettings, + showShortcutsModal, + closeShortcutsModal, + setThemeSettingsCallbacks, +} from './theme-settings.js'; +import { + loadLLMApiLogs, + handleClearLLMApiLogs, + handleCopyLLMApiLogs, + handleExportLLMApiLogs, + renderLLMApiLogs, +} from './llmapi-logs.js'; +import { + loadCrashLogs, + handleClearCrashLogs, + handleCopyCrashLogs, + handleExportCrashLogs, + renderCrashLogs, + updateCrashCount, + updateMemoryDisplay, + startMemoryMonitoring, + stopMemoryMonitoring, +} from './memory-logs.js'; +import { + openExportModal, + closeExportModal, + setupExportModalCheckboxes, + exportLogs, +} from './export-modal.js'; +import { + renderLogs, + togglePause, + toggleSlowRequestFilter, + updateFetchStats, + getFilteredFetchLogs, + addOrUpdateLog, + toggleBookmark, + saveBookmarks, + loadBookmarks, + toggleSelectMode, + selectAllLogs, + batchBookmarkLogs, + batchDeleteLogs, + exportFetchCSV, + handleCopyFetchLogs, + toggleLogSelection, +} from './fetch-logs.js'; + +import { showToast } from './toast.js'; +import { filterByTimeRange } from './common.js'; + +// ==================== Console Logs ==================== /** * Render console logs @@ -2599,101 +166,6 @@ function updateConsoleCount() { } } -/** - * 判断日志是否为问题请求(错误、慢请求) - */ -function isProblemLog(log) { - // 错误请求:状态码 >= 400 或有错误信息 - if (log.status >= 400 || log.error) return true; - // 慢请求:耗时 >= 1秒 - if (log.duration >= 1000) return true; - return false; -} - -/** - * 裁剪日志,优先保留问题请求和收藏 - */ -function trimLogsWithPriority(maxLogs) { - if (state.logs.length <= maxLogs) return; - - // 分类日志 - const bookmarked = []; - const problems = []; - const normal = []; - - state.logs.forEach(log => { - if (state.bookmarkedLogIds.has(log.id)) { - bookmarked.push(log); - } else if (isProblemLog(log)) { - problems.push(log); - } else { - normal.push(log); - } - }); - - // 计算需要保留的数量 - const mustKeep = bookmarked.length + problems.length; - - if (mustKeep >= maxLogs) { - // 问题请求太多,只保留收藏 + 部分问题请求 - const problemsToKeep = Math.max(0, maxLogs - bookmarked.length); - state.logs = [...bookmarked, ...problems.slice(0, problemsToKeep)]; - } else { - // 保留所有收藏和问题请求,剩余空间给正常请求 - const normalToKeep = maxLogs - mustKeep; - state.logs = [...bookmarked, ...problems, ...normal.slice(0, normalToKeep)]; - } - - // 按时间排序(最新的在前) - state.logs.sort((a, b) => b.timestamp - a.timestamp); -} - -/** - * Add or update a log entry - * @param {object} entry - * @param {boolean} skipRender - Skip rendering (for batch updates) - */ -function addOrUpdateLog(entry, skipRender = false) { - // Skip blacklisted URLs - if (isBlacklistedUrl(entry.url)) { - return; - } - - // In analysis mode, save to liveLogs buffer instead of display state - if (state.isAnalysisMode) { - addOrUpdateLiveLog('logs', entry); - return; - } - - // If paused, add to pending queue - if (state.isPaused && !skipRender) { - state.pendingLogs.push(entry); - updatePauseButton(); - return; - } - - const existingIndex = state.logs.findIndex(l => l.id === entry.id); - if (existingIndex !== -1) { - state.logs[existingIndex] = { ...state.logs[existingIndex], ...entry }; - } else { - state.logs.unshift(entry); - // Use configurable max logs limit - const maxLogs = state.settings?.maxLogs || 500; - if (state.logs.length > maxLogs) { - // 优先保留问题请求(错误、慢请求、收藏) - trimLogsWithPriority(maxLogs); - } - } - - if (!skipRender) { - renderLogs(); - - if (state.autoScroll) { - elements.logsContainer.scrollTop = 0; - } - } -} - /** * Add a console log entry (real-time) * @param {object} entry @@ -2733,6 +205,8 @@ function addConsoleLog(entry) { } } +// ==================== PostMessage Logs ==================== + /** * Update message type select options based on current logs */ @@ -2926,1122 +400,32 @@ async function handleCopyPostmessageLogs() { } } -/** - * Get filtered fetch logs based on current filters - */ -function getFilteredFetchLogs() { - const typeFilter = elements.filterType?.value || ''; - const statusFilter = elements.filterStatus?.value || ''; - const urlFilter = (elements.filterUrl?.value || '').toLowerCase(); - - let filteredLogs = state.logs.filter(l => !isBlacklistedUrl(l.url)); - - if (typeFilter) { - filteredLogs = filteredLogs.filter(l => l.requestType === typeFilter); - } - - if (statusFilter) { - if (statusFilter === '500') { - filteredLogs = filteredLogs.filter(l => l.status >= 500); - } else { - filteredLogs = filteredLogs.filter(l => l.status === parseInt(statusFilter)); - } - } - - if (urlFilter) { - filteredLogs = filteredLogs.filter(l => l.url?.toLowerCase().includes(urlFilter)); - } - - return filteredLogs; -} +// ==================== Error Dots ==================== /** - * Copy filtered fetch logs to clipboard + * Update error dot indicators */ -async function handleCopyFetchLogs() { - const filteredLogs = getFilteredFetchLogs(); - - if (filteredLogs.length === 0) { - alert('没有可复制的日志'); - return; - } - - // Format logs as text - const logText = filteredLogs.map(log => { - const time = new Date(log.timestamp).toLocaleTimeString('zh-CN', { hour12: false }); - const method = log.method || 'GET'; - const status = log.status || '-'; - const duration = log.duration ? `${log.duration}ms` : '-'; - const cached = log.cached ? ' [缓存]' : ''; - const url = log.url || '-'; - return `${time} ${method} ${status} ${url} (${duration})${cached}`; - }).join('\n'); - - try { - await navigator.clipboard.writeText(logText); - const btn = elements.copyFetchLogsBtn; - const originalText = btn.textContent; - btn.textContent = '✅ 已复制'; - setTimeout(() => { - btn.textContent = originalText; - }, 1500); - } catch (err) { - console.error('Failed to copy:', err); - alert('复制失败'); - } -} - -/** - * Get filtered LLM API logs based on current filters - */ -function getFilteredLLMApiLogs() { - const typeFilter = elements.filterLLMApiType?.value || ''; - const statusFilter = elements.filterLLMApiStatus?.value || ''; - - let filteredLogs = state.llmapiLogs; - - if (typeFilter) { - filteredLogs = filteredLogs.filter(l => l.taskType === typeFilter); - } - if (statusFilter) { - filteredLogs = filteredLogs.filter(l => l.status === statusFilter); - } - - return filteredLogs; -} - -/** - * Copy filtered LLM API logs to clipboard - */ -async function handleCopyLLMApiLogs() { - const filteredLogs = getFilteredLLMApiLogs(); - - if (filteredLogs.length === 0) { - alert('没有可复制的日志'); - return; - } - - // Format logs as text - const logText = filteredLogs.map(log => { - const time = new Date(log.timestamp).toLocaleString('zh-CN', { hour12: false }); - const type = log.taskType || 'unknown'; - const status = log.status || '-'; - const model = log.model || '-'; - const duration = log.duration ? `${(log.duration / 1000).toFixed(1)}s` : '-'; - const prompt = log.prompt ? `\n 提示词: ${log.prompt}` : ''; - const error = log.errorMessage ? `\n 错误: ${log.errorMessage}` : ''; - return `${time} [${type}] ${status} | ${model} (${duration})${prompt}${error}`; - }).join('\n\n'); - - try { - await navigator.clipboard.writeText(logText); - const btn = elements.copyLLMApiLogsBtn; - const originalText = btn.textContent; - btn.textContent = '✅ 已复制'; - setTimeout(() => { - btn.textContent = originalText; - }, 1500); - } catch (err) { - console.error('Failed to copy:', err); - alert('复制失败'); - } -} - -/** - * Get filtered crash logs based on current filters - */ -function getFilteredCrashLogs() { - const typeFilter = elements.filterCrashType?.value || ''; - - let filteredLogs = state.crashLogs; - - if (typeFilter) { - filteredLogs = filteredLogs.filter(l => l.type === typeFilter); - } - - return filteredLogs; -} - -/** - * Copy filtered crash logs to clipboard with all details - */ -async function handleCopyCrashLogs() { - const filteredLogs = getFilteredCrashLogs(); - - if (filteredLogs.length === 0) { - alert('没有可复制的日志'); - return; - } - - const typeLabels = { - startup: '启动', - periodic: '定期', - error: '错误', - beforeunload: '关闭', - freeze: '卡死', - whitescreen: '白屏', - longtask: '长任务' - }; - - // Format logs as text with all details - const logText = filteredLogs.map(log => { - const time = new Date(log.timestamp).toLocaleString('zh-CN', { hour12: false }); - const type = log.type || 'unknown'; - const typeLabel = typeLabels[type] || type; - - const lines = []; - lines.push(`═══════════════════════════════════════════════════`); - lines.push(`${time} [${typeLabel}]`); - lines.push(`───────────────────────────────────────────────────`); - - // 基本信息 - lines.push(`【基本信息】`); - lines.push(` ID: ${log.id}`); - lines.push(` 时间: ${time}`); - if (log.url) { - lines.push(` URL: ${log.url}`); - } - - // 内存信息 - if (log.memory) { - const usedMB = (log.memory.usedJSHeapSize / (1024 * 1024)).toFixed(1); - const totalMB = (log.memory.totalJSHeapSize / (1024 * 1024)).toFixed(1); - const limitMB = (log.memory.jsHeapSizeLimit / (1024 * 1024)).toFixed(1); - const percent = ((log.memory.usedJSHeapSize / log.memory.jsHeapSizeLimit) * 100).toFixed(1); - lines.push(``); - lines.push(`【内存信息】`); - lines.push(` 已用: ${usedMB} MB`); - lines.push(` 总计: ${totalMB} MB`); - lines.push(` 限制: ${limitMB} MB`); - lines.push(` 使用率: ${percent}%`); - } - - // 页面统计 - if (log.pageStats) { - const stats = log.pageStats; - lines.push(``); - lines.push(`【页面统计】`); - lines.push(` DOM节点: ${stats.domNodeCount || 0}`); - lines.push(` Canvas: ${stats.canvasCount || 0}`); - lines.push(` 图片: ${stats.imageCount || 0}`); - lines.push(` 视频: ${stats.videoCount || 0}`); - lines.push(` iframe: ${stats.iframeCount || 0}`); - if (stats.plaitElementCount !== undefined) { - lines.push(` Plait元素: ${stats.plaitElementCount}`); - } - } - - // 性能信息 - if (log.performance) { - const perf = log.performance; - const perfParts = []; - if (perf.longTaskDuration) { - perfParts.push(`长任务时长: ${perf.longTaskDuration.toFixed(0)}ms`); - } - if (perf.freezeDuration) { - perfParts.push(`卡死时长: ${(perf.freezeDuration / 1000).toFixed(1)}s`); - } - if (perf.fps !== undefined) { - perfParts.push(`FPS: ${perf.fps}`); - } - if (perfParts.length > 0) { - lines.push(``); - lines.push(`【性能信息】`); - perfParts.forEach(p => lines.push(` ${p}`)); - } - } - - // 错误信息 - if (log.error) { - lines.push(``); - lines.push(`【错误信息】`); - lines.push(` 类型: ${log.error.type || 'Error'}`); - lines.push(` 消息: ${log.error.message}`); - if (log.error.stack) { - lines.push(` 堆栈:`); - log.error.stack.split('\n').forEach(line => { - lines.push(` ${line}`); - }); - } - } - - // 自定义数据 - if (log.customData) { - lines.push(``); - lines.push(`【自定义数据】`); - lines.push(` ${JSON.stringify(log.customData, null, 2).split('\n').join('\n ')}`); - } - - return lines.join('\n'); - }).join('\n\n'); - - try { - await navigator.clipboard.writeText(logText); - const btn = elements.copyCrashLogsBtn; - const originalText = btn.textContent; - btn.textContent = '✅ 已复制'; - setTimeout(() => { - btn.textContent = originalText; - }, 1500); - } catch (err) { - console.error('Failed to copy:', err); - alert('复制失败'); - } -} - -// ==================== LLM API Logs ==================== - -/** - * Render LLM API logs - */ -function renderLLMApiLogs() { - const typeFilter = elements.filterLLMApiType?.value || ''; - const statusFilter = elements.filterLLMApiStatus?.value || ''; - - let filteredLogs = state.llmapiLogs; - - if (typeFilter) { - filteredLogs = filteredLogs.filter(l => l.taskType === typeFilter); - } - if (statusFilter) { - filteredLogs = filteredLogs.filter(l => l.status === statusFilter); - } - - if (!elements.llmapiLogsContainer) return; - - if (filteredLogs.length === 0) { - elements.llmapiLogsContainer.innerHTML = ` -
- 🤖 -

暂无 LLM API 调用记录

-

图片/视频/对话等 AI 接口调用会自动记录

-
- `; - return; - } - - elements.llmapiLogsContainer.innerHTML = ''; - filteredLogs.forEach(log => { - const isExpanded = state.expandedLLMApiIds.has(log.id); - const entry = createLLMApiEntry(log, isExpanded, (id, expanded) => { - if (expanded) { - state.expandedLLMApiIds.add(id); - } else { - state.expandedLLMApiIds.delete(id); - } - }); - elements.llmapiLogsContainer.appendChild(entry); - }); -} - -/** - * Create a LLM API log entry element - * Uses the same styles as Fetch logs for consistency - */ -function createLLMApiEntry(log, isExpanded, onToggle) { - const entry = document.createElement('div'); - entry.className = 'log-entry' + (isExpanded ? ' expanded' : ''); - entry.dataset.id = log.id; - - const time = new Date(log.timestamp).toLocaleTimeString('zh-CN', { - hour12: false, - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - }); - - // Status badge - use log-status class like Fetch logs - let statusClass = ''; - let statusText = ''; - switch (log.status) { - case 'success': - statusClass = 'success'; - statusText = '✓ 成功'; - break; - case 'error': - statusClass = 'error'; - statusText = '✗ 失败'; - break; - case 'pending': - statusClass = 'pending'; - statusText = '⋯ 进行中'; - break; - default: - statusText = log.status; - } - - // Task type badge - use log-type-badge class like Fetch logs - const typeLabel = { - 'image': '图片生成', - 'video': '视频生成', - 'chat': '对话', - 'character': '角色', - 'other': '其他', - }[log.taskType] || log.taskType; - - // Duration format - use log-duration class like Fetch logs - const durationMs = log.duration || 0; - const durationText = log.duration ? `${(log.duration / 1000).toFixed(1)}s` : '-'; - const durationClass = durationMs >= 3000 ? 'very-slow' : (durationMs >= 1000 ? 'slow' : ''); - - // Show full prompt in header (will wrap if needed) - const promptPreview = log.prompt || '-'; - - // Render reference images preview - let referenceImagesHtml = ''; - if (log.referenceImages && log.referenceImages.length > 0) { - const imagesList = log.referenceImages.map(img => { - const sizeText = img.size ? formatBytes(img.size) : '-'; - const dimensions = img.width && img.height ? `${img.width}x${img.height}` : '-'; - return ` -
-
- -
-
- ${sizeText} - ${dimensions} -
-
- `; - }).join(''); - - referenceImagesHtml = ` -
-

参考图详情

-
- ${imagesList} -
-
- `; - } - - entry.innerHTML = ` -
- ▶ - ${time} - ${statusText} - ${typeLabel} - ${log.model} - ${escapeHtml(promptPreview)} - ${durationText} -
-
-
-

基本信息

- - - - - - - - - - - - - - - - - - - - - - - - - - - ${log.hasReferenceImages ? ` - - - - - ` : ''} - ${log.resultType ? ` - - - - - ` : ''} - ${log.taskId ? ` - - - - - ` : ''} - ${log.resultUrl ? ` - - - - - ` : ''} - ${log.errorMessage ? ` - - - - - ` : ''} - -
ID${log.id}
Endpoint${log.endpoint}
模型${log.model}
类型${log.taskType}
HTTP 状态${log.httpStatus || '-'}
耗时${durationText}
参考图${log.referenceImageCount || 0} 张
结果类型${log.resultType}
任务 ID${log.taskId}
结果 URL - - ${log.resultUrl.length > 80 ? log.resultUrl.substring(0, 80) + '...' : log.resultUrl} - - -
错误信息${escapeHtml(log.errorMessage)}
-
- ${referenceImagesHtml} - ${log.prompt ? ` -
-

提示词

-
${escapeHtml(log.prompt)}
-
- ` : ''} - ${log.requestBody ? ` -
-

请求体 (Request Body)

-
${escapeHtml(log.requestBody)}
-
- ` : ''} - ${log.resultText ? ` -
-

响应文本

-
${escapeHtml(log.resultText)}
-
- ` : ''} - ${log.responseBody ? ` -
-

响应体 (Response Body)

-
${escapeHtml(log.responseBody)}
-
- ` : ''} -
- `; - - // Toggle function - same as Fetch logs - const toggleExpand = () => { - const isNowExpanded = entry.classList.toggle('expanded'); - if (onToggle) { - onToggle(log.id, isNowExpanded); - } - }; - - // Toggle expand/collapse on button click - const toggleBtn = entry.querySelector('.log-toggle'); - toggleBtn.addEventListener('click', (e) => { - e.stopPropagation(); - toggleExpand(); - }); - - // Toggle on header click (except toggle button) - const header = entry.querySelector('.log-header'); - header.addEventListener('click', (e) => { - if (e.target.closest('.log-toggle')) return; - toggleExpand(); - }); - - // Copy URL button click handler - const copyUrlBtn = entry.querySelector('.copy-url-btn'); - if (copyUrlBtn) { - copyUrlBtn.addEventListener('click', async (e) => { - e.stopPropagation(); - const url = copyUrlBtn.dataset.url; - try { - await navigator.clipboard.writeText(url); - // Show feedback - const originalHtml = copyUrlBtn.innerHTML; - copyUrlBtn.innerHTML = '✓'; - copyUrlBtn.style.color = 'var(--success-color)'; - setTimeout(() => { - copyUrlBtn.innerHTML = originalHtml; - copyUrlBtn.style.color = ''; - }, 1500); - } catch (err) { - console.error('Failed to copy URL:', err); - } - }); - } - - return entry; -} - - -/** - * Load LLM API logs from SW - */ -function loadLLMApiLogs() { - if (navigator.serviceWorker?.controller) { - navigator.serviceWorker.controller.postMessage({ - type: 'SW_DEBUG_GET_LLM_API_LOGS' - }); - } -} - -/** - * Clear LLM API logs - */ -function handleClearLLMApiLogs() { - if (!confirm('确定要清空所有 LLM API 日志吗?')) return; - - if (navigator.serviceWorker?.controller) { - navigator.serviceWorker.controller.postMessage({ - type: 'SW_DEBUG_CLEAR_LLM_API_LOGS' - }); - } - state.llmapiLogs = []; - renderLLMApiLogs(); -} - -/** - * Export LLM API logs with media files (images/videos) - * Creates a ZIP file containing: - * - llm-api-logs.json: All LLM API logs - * - media/: Directory containing cached images and videos - */ -async function handleExportLLMApiLogs() { - if (state.llmapiLogs.length === 0) { - alert('暂无 LLM API 日志可导出'); - return; +function updateErrorDots() { + // Console errors + if (elements.consoleErrorDot) { + const hasErrors = state.consoleLogs.some(l => l.logLevel === 'error'); + elements.consoleErrorDot.style.display = (hasErrors && state.activeTab !== 'console') ? 'inline-block' : 'none'; } - const exportBtn = elements.exportLLMApiLogsBtn; - const originalText = exportBtn.textContent; + // LLM API errors + if (elements.llmapiErrorDot) { + const hasErrors = state.llmapiLogs.some(l => l.status === 'error'); + elements.llmapiErrorDot.style.display = (hasErrors && state.activeTab !== 'llmapi') ? 'inline-block' : 'none'; + } - try { - exportBtn.disabled = true; - exportBtn.textContent = '⏳ 准备中...'; - - // Check if JSZip is available - if (typeof JSZip === 'undefined') { - // Fallback to JSON-only export - console.warn('JSZip not available, falling back to JSON-only export'); - const filename = `llm-api-logs-${new Date().toISOString().slice(0, 19).replace(/[:-]/g, '')}.json`; - downloadJson({ - exportTime: new Date().toISOString(), - logs: state.llmapiLogs, - mediaNotIncluded: true, - reason: 'JSZip not available' - }, filename); - return; - } - - const zip = new JSZip(); - - // Add logs JSON - const logsData = { - exportTime: new Date().toISOString(), - userAgent: navigator.userAgent, - totalLogs: state.llmapiLogs.length, - summary: { - image: state.llmapiLogs.filter(l => l.taskType === 'image').length, - video: state.llmapiLogs.filter(l => l.taskType === 'video').length, - chat: state.llmapiLogs.filter(l => l.taskType === 'chat').length, - character: state.llmapiLogs.filter(l => l.taskType === 'character').length, - success: state.llmapiLogs.filter(l => l.status === 'success').length, - error: state.llmapiLogs.filter(l => l.status === 'error').length, - }, - logs: state.llmapiLogs - }; - zip.file('llm-api-logs.json', JSON.stringify(logsData, null, 2)); - - // Collect URLs to download - const mediaUrls = []; - for (const log of state.llmapiLogs) { - if (log.resultUrl && log.status === 'success') { - mediaUrls.push({ - url: log.resultUrl, - id: log.id, - type: log.taskType, - timestamp: log.timestamp - }); - } - } - - exportBtn.textContent = `⏳ 下载媒体 0/${mediaUrls.length}...`; - - // Download media files - const mediaFolder = zip.folder('media'); - let downloadedCount = 0; - let failedCount = 0; - const mediaManifest = []; - - for (const item of mediaUrls) { - try { - // Handle both absolute and relative URLs - let fetchUrl = item.url; - if (fetchUrl.startsWith('/')) { - fetchUrl = location.origin + fetchUrl; - } - - const response = await fetch(fetchUrl); - if (response.ok) { - const blob = await response.blob(); - const contentType = response.headers.get('content-type') || blob.type; - - // Determine file extension - let ext = 'bin'; - if (contentType.includes('image/png')) ext = 'png'; - else if (contentType.includes('image/jpeg')) ext = 'jpg'; - else if (contentType.includes('image/gif')) ext = 'gif'; - else if (contentType.includes('image/webp')) ext = 'webp'; - else if (contentType.includes('video/mp4')) ext = 'mp4'; - else if (contentType.includes('video/webm')) ext = 'webm'; - - // Create filename based on log id and timestamp - const date = new Date(item.timestamp); - const dateStr = date.toISOString().slice(0, 10).replace(/-/g, ''); - const filename = `${dateStr}_${item.type}_${item.id.split('-').pop()}.${ext}`; - - mediaFolder.file(filename, blob); - mediaManifest.push({ - logId: item.id, - filename, - originalUrl: item.url, - size: blob.size, - type: contentType - }); - downloadedCount++; - } else { - failedCount++; - mediaManifest.push({ - logId: item.id, - originalUrl: item.url, - error: `HTTP ${response.status}` - }); - } - } catch (err) { - failedCount++; - mediaManifest.push({ - logId: item.id, - originalUrl: item.url, - error: err.message - }); - } - - exportBtn.textContent = `⏳ 下载媒体 ${downloadedCount + failedCount}/${mediaUrls.length}...`; - } - - // Add media manifest - zip.file('media-manifest.json', JSON.stringify({ - totalUrls: mediaUrls.length, - downloaded: downloadedCount, - failed: failedCount, - files: mediaManifest - }, null, 2)); - - exportBtn.textContent = '⏳ 生成 ZIP...'; - - // Generate and download ZIP - const zipBlob = await zip.generateAsync({ - type: 'blob', - compression: 'DEFLATE', - compressionOptions: { level: 6 } - }); - - const filename = `llm-api-export-${new Date().toISOString().slice(0, 19).replace(/[:-]/g, '')}.zip`; - const url = URL.createObjectURL(zipBlob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - - // Show summary - const sizeInMB = (zipBlob.size / 1024 / 1024).toFixed(2); - showToast(`导出完成!\n日志数: ${state.llmapiLogs.length}\n媒体文件: ${downloadedCount} 成功, ${failedCount} 失败\n文件大小: ${sizeInMB} MB`, 'success', 5000); - - } catch (err) { - console.error('Export failed:', err); - showToast('导出失败: ' + err.message, 'error', 5000); - } finally { - exportBtn.disabled = false; - exportBtn.textContent = originalText; + // Crash/memory errors + if (elements.crashErrorDot) { + const hasErrors = state.crashLogs.some(l => l.type === 'error' || l.type === 'freeze' || l.type === 'whitescreen'); + elements.crashErrorDot.style.display = (hasErrors && state.activeTab !== 'crash') ? 'inline-block' : 'none'; } } -// ==================== Memory Logs ==================== - -/** - * Render crash logs - */ -function renderCrashLogs() { - const typeFilter = elements.filterCrashType?.value || ''; - - let filteredLogs = state.crashLogs; - - if (typeFilter) { - filteredLogs = filteredLogs.filter(l => l.type === typeFilter); - } - - // Update count - updateCrashCount(); - - if (filteredLogs.length === 0) { - elements.crashLogsContainer.innerHTML = ` -
- 💥 -

暂无内存日志

-

页面启动、内存超限、错误和关闭时的快照会自动记录

-
- `; - return; - } - - elements.crashLogsContainer.innerHTML = ''; - filteredLogs.forEach(log => { - const isExpanded = state.expandedCrashIds.has(log.id); - const entry = createCrashEntry(log, isExpanded, (id, expanded) => { - if (expanded) { - state.expandedCrashIds.add(id); - } else { - state.expandedCrashIds.delete(id); - } - }); - elements.crashLogsContainer.appendChild(entry); - }); -} - -/** - * Create a crash log entry element - */ -function createCrashEntry(log, isExpanded, onToggle) { - const entry = document.createElement('div'); - // 使用与 Fetch 日志相同的样式 - entry.className = 'log-entry memory-entry' + (isExpanded ? ' expanded' : ''); - entry.dataset.id = log.id; - - const time = formatTime(log.timestamp); - - const typeLabels = { - startup: '启动', - periodic: '定期', - error: '错误', - beforeunload: '关闭', - freeze: '卡死', - whitescreen: '白屏', - longtask: '长任务' - }; - - const typeLabel = typeLabels[log.type] || log.type; - const isError = log.type === 'error'; - const isWarning = log.type === 'freeze' || log.type === 'whitescreen' || log.type === 'longtask'; - - // 类型徽章样式类 - const typeClass = isError ? 'error' : (isWarning ? 'warning' : 'normal'); - - // Memory info - 简化显示 - let memoryBadge = ''; - let memoryPercent = 0; - if (log.memory) { - const usedMB = (log.memory.usedJSHeapSize / (1024 * 1024)).toFixed(0); - memoryPercent = ((log.memory.usedJSHeapSize / log.memory.jsHeapSizeLimit) * 100); - const memoryClass = memoryPercent >= 90 ? 'critical' : (memoryPercent >= 75 ? 'warning' : 'normal'); - memoryBadge = `${usedMB} MB`; - } - - // Page stats - 简化为一行 - let statsText = ''; - if (log.pageStats) { - const stats = log.pageStats; - statsText = `DOM ${stats.domNodeCount || 0} · Img ${stats.imageCount || 0}`; - if (stats.plaitElementCount !== undefined) { - statsText += ` · Plait ${stats.plaitElementCount}`; - } - } - - // Performance info - 完整显示 - let perfText = ''; - if (log.performance) { - const parts = []; - if (log.performance.longTaskDuration) { - parts.push(`任务时长: ${log.performance.longTaskDuration.toFixed(0)}ms`); - } - if (log.performance.freezeDuration) { - parts.push(`卡死时长: ${(log.performance.freezeDuration / 1000).toFixed(1)}s`); - } - if (log.performance.fps !== undefined) { - parts.push(`FPS: ${log.performance.fps}`); - } - if (parts.length > 0) { - perfText = parts.join(' | '); - } - } - - // Error preview - show full message (will wrap if needed) - let errorPreview = ''; - if (log.error) { - errorPreview = `${escapeHtml(log.error.message || '')}`; - } - - // 完整内存显示 - let memoryText = ''; - if (log.memory) { - const usedMB = (log.memory.usedJSHeapSize / (1024 * 1024)).toFixed(1); - const limitMB = (log.memory.jsHeapSizeLimit / (1024 * 1024)).toFixed(1); - memoryText = `${usedMB} MB / ${limitMB} MB (${memoryPercent.toFixed(1)}%)`; - } - - entry.innerHTML = ` -
- ▶ - ${time} - ${typeLabel} - ${perfText ? `⚡ ${perfText}` : ''} - ${memoryText ? `📊 ${memoryText}` : ''} - ${statsText ? `📄 ${statsText}` : ''} - ${errorPreview} -
-
-
-

基本信息

-
ID: ${log.id}
-时间: ${new Date(log.timestamp).toLocaleString('zh-CN')}
-URL: ${log.url || '-'}
-
- ${log.memory ? ` -
-

内存信息

-
已用: ${(log.memory.usedJSHeapSize / (1024 * 1024)).toFixed(1)} MB
-总计: ${(log.memory.totalJSHeapSize / (1024 * 1024)).toFixed(1)} MB
-限制: ${(log.memory.jsHeapSizeLimit / (1024 * 1024)).toFixed(1)} MB
-使用率: ${memoryPercent.toFixed(1)}%
-
- ` : ''} - ${log.pageStats ? ` -
-

页面统计

-
DOM节点: ${log.pageStats.domNodeCount || 0}
-Canvas: ${log.pageStats.canvasCount || 0}
-图片: ${log.pageStats.imageCount || 0}
-视频: ${log.pageStats.videoCount || 0}
-iframe: ${log.pageStats.iframeCount || 0}${log.pageStats.plaitElementCount !== undefined ? `\nPlait元素: ${log.pageStats.plaitElementCount}` : ''}
-
- ` : ''} - ${log.performance ? ` -
-

性能信息

-
${log.performance.longTaskDuration ? `长任务时长: ${log.performance.longTaskDuration.toFixed(0)}ms` : ''}${log.performance.freezeDuration ? `卡死时长: ${(log.performance.freezeDuration / 1000).toFixed(1)}s` : ''}${log.performance.fps !== undefined ? `\nFPS: ${log.performance.fps}` : ''}
-
- ` : ''} - ${log.error ? ` -
-

错误信息

-
${log.error.type}: ${escapeHtml(log.error.message)}
- ${log.error.stack ? `
${escapeHtml(log.error.stack)}
` : ''} -
- ` : ''} - ${log.customData ? ` -
-

自定义数据

-
${JSON.stringify(log.customData, null, 2)}
-
- ${log.type === 'longtask' ? ` -
-

💡 如何定位长任务来源

-
    -
  1. 打开 Chrome DevTools → Performance 面板
  2. -
  3. 点击录制按钮 ⏺,复现长任务操作
  4. -
  5. 停止录制,在 Main 线程中找到黄色/红色的长条(> 50ms)
  6. -
  7. 点击展开查看详细的函数调用栈
  8. -
-
- ` : ''} - ` : ''} -
- `; - - // Toggle expand on header click - const toggleBtn = entry.querySelector('.log-toggle'); - toggleBtn.addEventListener('click', (e) => { - e.stopPropagation(); - const nowExpanded = entry.classList.toggle('expanded'); - onToggle(log.id, nowExpanded); - }); - - return entry; -} - -/** - * Update crash log count indicator - */ -function updateCrashCount() { - const errorCount = state.crashLogs.filter(l => l.type === 'error').length; - - if (errorCount > 0) { - elements.crashCountEl.innerHTML = `(${errorCount} errors)`; - } else { - elements.crashCountEl.textContent = `(${state.crashLogs.length})`; - } -} - -/** - * Load crash logs from SW - */ -function loadCrashLogs() { - if (navigator.serviceWorker?.controller) { - navigator.serviceWorker.controller.postMessage({ - type: 'SW_DEBUG_GET_CRASH_SNAPSHOTS' - }); - } -} - -/** - * Clear crash logs - */ -function handleClearCrashLogs() { - if (navigator.serviceWorker?.controller) { - navigator.serviceWorker.controller.postMessage({ - type: 'SW_DEBUG_CLEAR_CRASH_SNAPSHOTS' - }); - } - state.crashLogs = []; - renderCrashLogs(); -} - -/** - * Export crash logs as JSON - */ -function handleExportCrashLogs() { - if (state.crashLogs.length === 0) { - alert('没有可导出的内存日志'); - return; - } - - const exportData = { - exportTime: new Date().toISOString(), - userAgent: navigator.userAgent, - url: location.href, - memorySnapshots: state.crashLogs, - }; - - const filename = `memory-logs-${new Date().toISOString().slice(0, 19).replace(/[:-]/g, '')}.json`; - downloadJson(exportData, filename); -} - -// ==================== Memory Monitoring ==================== - -/** - * Format bytes to human readable - */ -function formatBytes(bytes) { - if (bytes === 0) return '0 B'; - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; -} - -/** - * Update memory display - */ -function updateMemoryDisplay() { - // Check for performance.memory (Chrome only) - if (typeof performance !== 'undefined' && 'memory' in performance) { - const mem = performance.memory; - const usedMB = (mem.usedJSHeapSize / (1024 * 1024)).toFixed(1); - const totalMB = (mem.totalJSHeapSize / (1024 * 1024)).toFixed(1); - const limitMB = (mem.jsHeapSizeLimit / (1024 * 1024)).toFixed(0); - const percent = ((mem.usedJSHeapSize / mem.jsHeapSizeLimit) * 100).toFixed(1); - - elements.memoryUsed.textContent = `${usedMB} MB`; - elements.memoryTotal.textContent = `${totalMB} MB`; - elements.memoryLimit.textContent = `${limitMB} MB`; - elements.memoryPercent.textContent = `${percent}%`; - - // Warning if usage is high - if (parseFloat(percent) > 70) { - elements.memoryWarning.style.display = 'block'; - elements.memoryPercent.style.color = 'var(--error-color)'; - } else { - elements.memoryWarning.style.display = 'none'; - elements.memoryPercent.style.color = ''; - } - - elements.memoryNotSupported.style.display = 'none'; - } else { - elements.memoryUsed.textContent = '-'; - elements.memoryTotal.textContent = '-'; - elements.memoryLimit.textContent = '-'; - elements.memoryPercent.textContent = '-'; - elements.memoryNotSupported.style.display = 'block'; - } - - // Update timestamp - const now = new Date(); - elements.memoryUpdateTime.textContent = `更新: ${now.toLocaleTimeString('zh-CN', { hour12: false })}`; -} - -/** - * Start memory monitoring - */ -function startMemoryMonitoring() { - // Initial update - updateMemoryDisplay(); - - // Update every 2 seconds - if (memoryMonitorInterval) { - clearInterval(memoryMonitorInterval); - } - memoryMonitorInterval = setInterval(updateMemoryDisplay, 2000); -} - -/** - * Stop memory monitoring - */ -function stopMemoryMonitoring() { - if (memoryMonitorInterval) { - clearInterval(memoryMonitorInterval); - memoryMonitorInterval = null; - } -} - -/** - * Toggle debug mode - */ -function toggleDebug() { - if (state.debugEnabled) { - disableDebug(); - } else { - enableDebug(); - } -} - -/** - * Clear all logs (Fetch, Console, PostMessage, and Memory logs) - * Note: LLM API logs are NOT cleared here as they are important for cost tracking - */ -function handleClearLogs() { - // Clear Fetch logs - clearFetchLogs(); - state.logs = []; - renderLogs(); - - // Clear Console logs - clearConsoleLogs(); - state.consoleLogs = []; - renderConsoleLogs(); - - // Clear PostMessage logs - clearPostMessageLogsInSW(); - state.postmessageLogs = []; - renderPostmessageLogs(); - - // Clear Memory logs (crash snapshots) - if (navigator.serviceWorker?.controller) { - navigator.serviceWorker.controller.postMessage({ - type: 'SW_DEBUG_CLEAR_CRASH_SNAPSHOTS' - }); - } - state.crashLogs = []; - renderCrashLogs(); -} +// ==================== Console Log Handlers ==================== /** * Clear console logs only @@ -4103,238 +487,50 @@ async function handleCopyConsoleLogs() { } } +// ==================== Debug Toggle and Clear ==================== + /** - * Open export modal + * Toggle debug mode */ -function openExportModal() { - elements.exportModalOverlay?.classList.add('show'); +function toggleDebug() { + if (state.debugEnabled) { + disableDebug(); + } else { + enableDebug(); + } } /** - * Close export modal + * Clear all logs (Fetch, Console, PostMessage, and Memory logs) + * Note: LLM API logs are NOT cleared here as they are important for cost tracking */ -function closeExportModal() { - elements.exportModalOverlay?.classList.remove('show'); -} +function handleClearLogs() { + // Clear Fetch logs + clearFetchLogs(); + state.logs = []; + renderLogs(); -/** - * Setup export modal checkbox logic - */ -function setupExportModalCheckboxes() { - const modal = elements.exportModalOverlay; - if (!modal) return; + // Clear Console logs + clearConsoleLogs(); + state.consoleLogs = []; + renderConsoleLogs(); - const selectAllCheckbox = elements.selectAllExport; - const sectionCheckboxes = modal.querySelectorAll('[data-section]'); - const allItemCheckboxes = modal.querySelectorAll('input[name]'); + // Clear PostMessage logs + clearPostMessageLogsInSW(); + state.postmessageLogs = []; + renderPostmessageLogs(); - // Update section checkbox state based on its items - function updateSectionCheckbox(sectionName) { - const sectionCheckbox = modal.querySelector(`[data-section="${sectionName}"]`); - const items = modal.querySelectorAll(`input[name="${sectionName}"]`); - if (!sectionCheckbox || items.length === 0) return; - - const checkedItems = Array.from(items).filter(i => i.checked); - sectionCheckbox.checked = checkedItems.length === items.length; - sectionCheckbox.indeterminate = checkedItems.length > 0 && checkedItems.length < items.length; - } - - // Update select all checkbox state - function updateSelectAllCheckbox() { - const allChecked = Array.from(allItemCheckboxes).every(cb => cb.checked); - const someChecked = Array.from(allItemCheckboxes).some(cb => cb.checked); - selectAllCheckbox.checked = allChecked; - selectAllCheckbox.indeterminate = someChecked && !allChecked; - } - - // Select all handler - selectAllCheckbox?.addEventListener('change', () => { - const checked = selectAllCheckbox.checked; - allItemCheckboxes.forEach(cb => cb.checked = checked); - sectionCheckboxes.forEach(cb => { - cb.checked = checked; - cb.indeterminate = false; + // Clear Memory logs (crash snapshots) + if (navigator.serviceWorker?.controller) { + navigator.serviceWorker.controller.postMessage({ + type: 'SW_DEBUG_CLEAR_CRASH_SNAPSHOTS' }); - }); - - // Section checkbox handlers - sectionCheckboxes.forEach(sectionCb => { - sectionCb.addEventListener('change', () => { - const sectionName = sectionCb.dataset.section; - const items = modal.querySelectorAll(`input[name="${sectionName}"]`); - items.forEach(item => item.checked = sectionCb.checked); - sectionCb.indeterminate = false; - updateSelectAllCheckbox(); - }); - }); - - // Item checkbox handlers - allItemCheckboxes.forEach(cb => { - cb.addEventListener('change', () => { - updateSectionCheckbox(cb.name); - updateSelectAllCheckbox(); - }); - }); + } + state.crashLogs = []; + renderCrashLogs(); } -/** - * Get selected export options - * @returns {object} - */ -function getExportOptions() { - const basicTypes = Array.from( - document.querySelectorAll('input[name="basic"]:checked') - ).map(el => el.value); - - const fetchTypes = Array.from( - document.querySelectorAll('input[name="fetch"]:checked') - ).map(el => el.value); - - const consoleLevels = Array.from( - document.querySelectorAll('input[name="console"]:checked') - ).map(el => el.value); - - const postmessageDirections = Array.from( - document.querySelectorAll('input[name="postmessage"]:checked') - ).map(el => el.value); - - const memoryTypes = Array.from( - document.querySelectorAll('input[name="memory"]:checked') - ).map(el => el.value); - - const llmapiTypes = Array.from( - document.querySelectorAll('input[name="llmapi"]:checked') - ).map(el => el.value); - - return { basicTypes, fetchTypes, consoleLevels, postmessageDirections, memoryTypes, llmapiTypes }; -} - -/** - * Get current memory info from browser - */ -function getCurrentMemoryInfo() { - const memory = performance.memory; - if (!memory) return null; - - return { - usedJSHeapSize: memory.usedJSHeapSize, - totalJSHeapSize: memory.totalJSHeapSize, - jsHeapSizeLimit: memory.jsHeapSizeLimit, - usedMB: Math.round(memory.usedJSHeapSize / 1024 / 1024 * 10) / 10, - totalMB: Math.round(memory.totalJSHeapSize / 1024 / 1024 * 10) / 10, - limitMB: Math.round(memory.jsHeapSizeLimit / 1024 / 1024 * 10) / 10, - usagePercent: Math.round(memory.usedJSHeapSize / memory.jsHeapSizeLimit * 1000) / 10, - }; -} - -/** - * Export logs to JSON file with selected options - */ -function exportLogs() { - const options = getExportOptions(); - - // Build basic info - let basicInfo = {}; - if (options.basicTypes.includes('swStatus') && state.swStatus) { - basicInfo.swStatus = { - version: state.swStatus.version, - debugModeEnabled: state.swStatus.debugModeEnabled, - pendingImageRequests: state.swStatus.pendingImageRequests, - pendingVideoRequests: state.swStatus.pendingVideoRequests, - videoBlobCacheSize: state.swStatus.videoBlobCacheSize, - videoBlobCacheTotalBytes: state.swStatus.videoBlobCacheTotalBytes, - completedImageRequestsSize: state.swStatus.completedImageRequestsSize, - workflowHandlerInitialized: state.swStatus.workflowHandlerInitialized, - debugLogsCount: state.swStatus.debugLogsCount, - failedDomains: state.swStatus.failedDomains, - }; - } - if (options.basicTypes.includes('memory')) { - basicInfo.memory = getCurrentMemoryInfo(); - } - if (options.basicTypes.includes('cache') && state.swStatus?.cacheStats) { - basicInfo.cacheStats = state.swStatus.cacheStats; - } - - // Filter fetch logs - let filteredFetchLogs = []; - if (options.fetchTypes.length > 0) { - filteredFetchLogs = state.logs.filter(l => - options.fetchTypes.includes(l.requestType) - ); - } - - // Filter console logs - let filteredConsoleLogs = []; - if (options.consoleLevels.length > 0) { - filteredConsoleLogs = state.consoleLogs.filter(l => - options.consoleLevels.includes(l.logLevel) - ); - } - - // Filter postmessage logs - let filteredPostmessageLogs = []; - if (options.postmessageDirections.length > 0) { - filteredPostmessageLogs = state.postmessageLogs.filter(l => - options.postmessageDirections.includes(l.direction) - ); - } - - // Filter memory logs - let filteredMemoryLogs = []; - if (options.memoryTypes.length > 0) { - filteredMemoryLogs = state.crashLogs.filter(l => - options.memoryTypes.includes(l.type) - ); - } - - // Filter LLM API logs - let filteredLLMApiLogs = []; - if (options.llmapiTypes.length > 0) { - filteredLLMApiLogs = state.llmapiLogs.filter(l => - options.llmapiTypes.includes(l.taskType) - ); - } - - // Check if anything selected - const hasBasicInfo = Object.keys(basicInfo).length > 0; - const hasLogs = filteredFetchLogs.length > 0 || - filteredConsoleLogs.length > 0 || - filteredPostmessageLogs.length > 0 || - filteredMemoryLogs.length > 0 || - filteredLLMApiLogs.length > 0; - - if (!hasBasicInfo && !hasLogs) { - alert('没有选中任何导出项,或选中的类型没有数据'); - return; - } - - const exportData = { - exportTime: new Date().toISOString(), - userAgent: navigator.userAgent, - url: location.href, - exportOptions: options, - // Basic info at the top level for easy access - ...basicInfo, - summary: { - hasBasicInfo, - fetchLogs: filteredFetchLogs.length, - consoleLogs: filteredConsoleLogs.length, - postmessageLogs: filteredPostmessageLogs.length, - memoryLogs: filteredMemoryLogs.length, - llmapiLogs: filteredLLMApiLogs.length, - }, - fetchLogs: filteredFetchLogs, - consoleLogs: filteredConsoleLogs, - postmessageLogs: filteredPostmessageLogs, - memoryLogs: filteredMemoryLogs, - llmapiLogs: filteredLLMApiLogs, - }; - - const filename = `sw-debug-logs-${new Date().toISOString().slice(0, 19).replace(/[:-]/g, '')}.json`; - downloadJson(exportData, filename); - closeExportModal(); -} +// ==================== Tab Switching ==================== /** * Switch active tab @@ -4365,6 +561,8 @@ function switchTab(tabName) { } } +// ==================== Status Update Handler ==================== + /** * Handle SW status update * @param {object} data @@ -4376,6 +574,8 @@ function handleStatusUpdate(data) { updateDebugButton(elements.toggleDebugBtn, state.debugEnabled); } +// ==================== Event Listeners Setup ==================== + /** * Setup event listeners */ @@ -4530,6 +730,25 @@ function setupEventListeners() { }); } +// ==================== SW Message Handlers ==================== + +/** + * Add or update a log entry in the live logs buffer (used in analysis mode) + * @param {string} logType - The type of log ('logs', 'consoleLogs', etc.) + * @param {object} entry - The log entry + */ +function addOrUpdateLiveLog(logType, entry) { + if (!state.liveLogs[logType]) { + state.liveLogs[logType] = []; + } + const existingIndex = state.liveLogs[logType].findIndex(l => l.id === entry.id); + if (existingIndex >= 0) { + state.liveLogs[logType][existingIndex] = { ...state.liveLogs[logType][existingIndex], ...entry }; + } else { + state.liveLogs[logType].unshift(entry); + } +} + /** * Setup SW message handlers * In analysis mode, live logs are stored separately (state.liveLogs) and not displayed. @@ -4725,22 +944,7 @@ function setupMessageHandlers() { }); } -/** - * Add or update a log entry in the live logs buffer (used in analysis mode) - * @param {string} logType - The type of log ('logs', 'consoleLogs', etc.) - * @param {object} entry - The log entry - */ -function addOrUpdateLiveLog(logType, entry) { - if (!state.liveLogs[logType]) { - state.liveLogs[logType] = []; - } - const existingIndex = state.liveLogs[logType].findIndex(l => l.id === entry.id); - if (existingIndex >= 0) { - state.liveLogs[logType][existingIndex] = { ...state.liveLogs[logType][existingIndex], ...entry }; - } else { - state.liveLogs[logType].unshift(entry); - } -} +// ==================== Initialization ==================== /** * Initialize the application @@ -4753,6 +957,27 @@ async function init() { loadTheme(); loadSettings(); + // Setup callbacks for modules that need render functions + setAnalysisModeCallbacks({ + renderLogs, + renderConsoleLogs, + renderPostmessageLogs, + renderCrashLogs, + renderLLMApiLogs, + updateConsoleCount, + updatePostmessageCount, + updateCrashCount, + updateErrorDots, + updateMessageTypeOptions, + loadCrashLogs, + loadLLMApiLogs, + updateMemoryDisplay, + }); + + setThemeSettingsCallbacks({ + renderLogs, + }); + // Setup event listeners (always needed, even in analysis mode) setupEventListeners(); @@ -4811,6 +1036,8 @@ async function init() { loadPostMessageLogs(); // Load crash logs loadCrashLogs(); + // Load LLM API logs (ensure data is available for export even without visiting the tab) + loadLLMApiLogs(); renderLogs(); // Start memory monitoring diff --git a/apps/web/public/sw-debug/backup.js b/apps/web/public/sw-debug/backup.js new file mode 100644 index 00000000..77f94fe0 --- /dev/null +++ b/apps/web/public/sw-debug/backup.js @@ -0,0 +1,603 @@ +/** + * SW Debug Panel - Data Backup + * 数据备份功能模块 + */ + +import { elements } from './state.js'; +import { + IDB_STORES, + KV_KEYS, + CACHE_NAMES, + SW_TASK_QUEUE_DB, + TaskType, + TaskStatus, + readAllFromIDB, + readKVItem, +} from './indexeddb.js'; +import { showToast } from './toast.js'; + +/** + * 备份签名和版本 + */ +const BACKUP_SIGNATURE = 'aitu-backup'; +const BACKUP_VERSION = 2; + +/** + * 获取文件扩展名 + */ +function getExtensionFromMimeType(mimeType) { + const mimeToExt = { + 'image/jpeg': '.jpg', + 'image/jpg': '.jpg', + 'image/png': '.png', + 'image/gif': '.gif', + 'image/webp': '.webp', + 'image/svg+xml': '.svg', + 'video/mp4': '.mp4', + 'video/webm': '.webm', + 'video/quicktime': '.mov', + }; + return mimeToExt[mimeType] || ''; +} + +/** + * 清理文件/文件夹名称 + */ +function sanitizeFileName(name) { + return ( + name + .replace(/[<>:"/\\|?*]/g, '_') + .replace(/\s+/g, ' ') + .trim() || 'unnamed' + ); +} + +/** + * 等待 JSZip 加载完成 + * @param {number} timeout - 超时时间(毫秒) + * @returns {Promise} + */ +function waitForJSZip(timeout = 5000) { + return new Promise((resolve) => { + if (typeof JSZip !== 'undefined') { + resolve(true); + return; + } + + const startTime = Date.now(); + const checkInterval = setInterval(() => { + if (typeof JSZip !== 'undefined') { + clearInterval(checkInterval); + resolve(true); + } else if (Date.now() - startTime > timeout) { + clearInterval(checkInterval); + resolve(false); + } + }, 100); + }); +} + +/** + * 执行数据备份 + */ +export async function performBackup() { + const btn = elements.backupDataBtn; + if (!btn) return; + + const originalText = btn.innerHTML; + + try { + btn.disabled = true; + btn.innerHTML = '⏳ 加载中...'; + + // 等待 JSZip 加载 + const jsZipLoaded = await waitForJSZip(5000); + + if (!jsZipLoaded) { + throw new Error('JSZip 库加载超时,请检查网络连接后重试'); + } + + btn.innerHTML = '⏳ 准备中...'; + + const zip = new JSZip(); + + const manifest = { + signature: BACKUP_SIGNATURE, + version: BACKUP_VERSION, + createdAt: Date.now(), + source: 'sw-debug-panel', + includes: { + prompts: true, + projects: true, + assets: true, + }, + stats: { + promptCount: 0, + videoPromptCount: 0, + imagePromptCount: 0, + folderCount: 0, + boardCount: 0, + assetCount: 0, + taskCount: 0, + }, + }; + + // 显示进度条 + const progressContainer = showBackupProgress(); + const updateProgress = (percent, text) => { + const progressBar = progressContainer.querySelector('.backup-progress-fill'); + const progressText = progressContainer.querySelector('.backup-progress-text'); + if (progressBar) progressBar.style.width = `${percent}%`; + if (progressText) progressText.textContent = text; + }; + + // 0. 先收集任务数据(后面提示词收集需要用到) + updateProgress(5, '正在读取任务数据...'); + const allTasks = await collectTasksData(); + + // 1. 收集提示词数据(会从任务中提取提示词合并) + updateProgress(15, '正在备份提示词...'); + const promptsData = await collectPromptsData(allTasks); + zip.file('prompts.json', JSON.stringify(promptsData, null, 2)); + manifest.stats.promptCount = promptsData.promptHistory?.length || 0; + manifest.stats.videoPromptCount = promptsData.videoPromptHistory?.length || 0; + manifest.stats.imagePromptCount = promptsData.imagePromptHistory?.length || 0; + + // 2. 收集项目数据 + updateProgress(25, '正在备份项目...'); + const projectStats = await collectProjectsData(zip); + manifest.stats.folderCount = projectStats.folders; + manifest.stats.boardCount = projectStats.boards; + + // 3. 收集素材数据(进度回调) + updateProgress(35, '正在备份素材...'); + const assetCount = await collectAssetsData(zip, (current, total) => { + const percent = 35 + Math.round((current / total) * 40); + updateProgress(percent, `正在备份素材 (${current}/${total})...`); + }); + manifest.stats.assetCount = assetCount; + + // 4. 导出已完成的媒体任务数据(素材库展示需要) + updateProgress(80, '正在导出任务数据...'); + const completedMediaTasks = allTasks.filter( + task => task.status === TaskStatus.COMPLETED && + (task.type === TaskType.IMAGE || task.type === TaskType.VIDEO) && + task.result?.url + ); + if (completedMediaTasks.length > 0) { + zip.file('tasks.json', JSON.stringify(completedMediaTasks, null, 2)); + manifest.stats.taskCount = completedMediaTasks.length; + } + + // 5. 写入清单文件 + zip.file('manifest.json', JSON.stringify(manifest, null, 2)); + + // 6. 生成并下载 ZIP 文件 + updateProgress(85, '正在压缩文件...'); + const blob = await zip.generateAsync({ + type: 'blob', + compression: 'DEFLATE', + compressionOptions: { level: 6 }, + }, (metadata) => { + const percent = 85 + Math.round(metadata.percent * 0.14); + updateProgress(percent, '正在压缩文件...'); + }); + + // 下载文件 + updateProgress(100, '备份完成!'); + const date = new Date(); + const dateStr = date.toISOString().split('T')[0]; + const timeStr = date.toTimeString().split(' ')[0].replace(/:/g, ''); + const filename = `aitu_backup_${dateStr}_${timeStr}.zip`; + + 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); + + // 关闭进度条,显示成功信息 + setTimeout(() => { + progressContainer.remove(); + const sizeInMB = (blob.size / 1024 / 1024).toFixed(2); + showBackupSuccessNotification({ + filename, + size: sizeInMB, + stats: manifest.stats, + }); + }, 500); + + btn.innerHTML = originalText; + btn.disabled = false; + + } catch (error) { + // 关闭进度条 + const progressContainer = document.querySelector('.backup-progress-container'); + if (progressContainer) progressContainer.remove(); + + showToast('备份失败: ' + error.message, 'error', 5000); + btn.innerHTML = originalText; + btn.disabled = false; + } +} + +/** + * 收集任务数据 + * 从 sw-task-queue 数据库读取所有任务 + */ +async function collectTasksData() { + try { + const tasks = await readAllFromIDB(SW_TASK_QUEUE_DB.name, SW_TASK_QUEUE_DB.stores.TASKS); + return tasks || []; + } catch (error) { + console.warn('[Backup] Failed to read tasks:', error); + return []; + } +} + +/** + * 收集提示词数据 + * 合并两个来源: + * 1. IndexedDB 中的提示词历史 + * 2. 任务队列中已完成任务的提示词 + */ +async function collectPromptsData(allTasks = []) { + const [promptHistory, videoPromptHistory, imagePromptHistory, presetSettings] = await Promise.all([ + readKVItem(KV_KEYS.PROMPT_HISTORY), + readKVItem(KV_KEYS.VIDEO_PROMPT_HISTORY), + readKVItem(KV_KEYS.IMAGE_PROMPT_HISTORY), + readKVItem(KV_KEYS.PRESET_SETTINGS), + ]); + + let finalPromptHistory = promptHistory || []; + let finalVideoPromptHistory = videoPromptHistory || []; + let finalImagePromptHistory = imagePromptHistory || []; + + // 从已完成的任务中提取提示词 + const completedTasks = allTasks.filter(task => task.status === TaskStatus.COMPLETED); + + // 提取图片任务的提示词 + const imageTaskPrompts = completedTasks + .filter(task => task.type === TaskType.IMAGE && task.params?.prompt) + .map(task => ({ + id: `task_${task.id}`, + content: task.params.prompt.trim(), + timestamp: task.completedAt || task.createdAt, + })) + .filter(item => item.content && item.content.length > 0); + + // 提取视频任务的提示词 + const videoTaskPrompts = completedTasks + .filter(task => task.type === TaskType.VIDEO && task.params?.prompt) + .map(task => ({ + id: `task_${task.id}`, + content: task.params.prompt.trim(), + timestamp: task.completedAt || task.createdAt, + })) + .filter(item => item.content && item.content.length > 0); + + // 合并图片提示词(去重) + const existingImageContents = new Set(finalImagePromptHistory.map(p => p.content)); + const newImagePrompts = imageTaskPrompts.filter(p => !existingImageContents.has(p.content)); + finalImagePromptHistory = [...finalImagePromptHistory, ...newImagePrompts]; + + // 合并视频提示词(去重) + const existingVideoContents = new Set(finalVideoPromptHistory.map(p => p.content)); + const newVideoPrompts = videoTaskPrompts.filter(p => !existingVideoContents.has(p.content)); + finalVideoPromptHistory = [...finalVideoPromptHistory, ...newVideoPrompts]; + + return { + promptHistory: finalPromptHistory, + videoPromptHistory: finalVideoPromptHistory, + imagePromptHistory: finalImagePromptHistory, + presetSettings: presetSettings || { + image: { pinnedPrompts: [], deletedPrompts: [] }, + video: { pinnedPrompts: [], deletedPrompts: [] }, + }, + }; +} + +/** + * 收集项目数据 + * 从 aitu-workspace 数据库的 folders 和 boards store 读取 + */ +async function collectProjectsData(zip) { + const projectsFolder = zip.folder('projects'); + + // 从独立的 store 读取,而不是 KV 存储 + const [folders, boards] = await Promise.all([ + readAllFromIDB(IDB_STORES.WORKSPACE.name, IDB_STORES.WORKSPACE.stores.FOLDERS), + readAllFromIDB(IDB_STORES.WORKSPACE.name, IDB_STORES.WORKSPACE.stores.BOARDS), + ]); + + const folderList = folders || []; + const boardList = boards || []; + + // 构建文件夹路径映射 + const folderPathMap = new Map(); + const folderMap = new Map(); + + for (const folder of folderList) { + folderMap.set(folder.id, folder); + } + + const getPath = (folderId) => { + if (folderPathMap.has(folderId)) { + return folderPathMap.get(folderId); + } + + const folder = folderMap.get(folderId); + if (!folder) return ''; + + const safeName = sanitizeFileName(folder.name); + if (folder.parentId) { + const parentPath = getPath(folder.parentId); + const fullPath = parentPath ? `${parentPath}/${safeName}` : safeName; + folderPathMap.set(folderId, fullPath); + return fullPath; + } + + folderPathMap.set(folderId, safeName); + return safeName; + }; + + for (const folder of folderList) { + getPath(folder.id); + } + + // 创建文件夹结构 + for (const folder of folderList) { + const path = folderPathMap.get(folder.id) || folder.name; + projectsFolder.folder(path); + } + + // 导出画板 + for (const board of boardList) { + const folderPath = board.folderId ? folderPathMap.get(board.folderId) : null; + const safeName = sanitizeFileName(board.name); + const boardPath = folderPath + ? `${folderPath}/${safeName}.drawnix` + : `${safeName}.drawnix`; + + const drawnixData = { + type: 'drawnix', + version: 1, + source: 'backup', + elements: board.elements || [], + viewport: board.viewport || { zoom: 1 }, + theme: board.theme, + boardMeta: { + id: board.id, + name: board.name, + folderId: board.folderId, + order: board.order, + createdAt: board.createdAt, + updatedAt: board.updatedAt, + }, + }; + + projectsFolder.file(boardPath, JSON.stringify(drawnixData, null, 2)); + } + + return { + folders: folderList.length, + boards: boardList.length, + }; +} + +/** + * 从 URL 生成唯一 ID(与应用层保持一致) + */ +function generateIdFromUrl(url) { + let hash = 0; + for (let i = 0; i < url.length; i++) { + const char = url.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; + } + return `cache-${Math.abs(hash).toString(36)}`; +} + +/** + * 收集素材数据 + * 三个数据源: + * 1. aitu-assets 数据库(本地素材库元数据) + * 2. drawnix-unified-cache 数据库(AI 生成媒体元数据) + * 3. drawnix-images Cache Storage(媒体二进制数据) + * @param {JSZip} zip - ZIP 实例 + * @param {Function} onProgress - 进度回调 (current, total) + */ +async function collectAssetsData(zip, onProgress) { + const assetsFolder = zip.folder('assets'); + let exportedCount = 0; + const exportedUrls = new Set(); + + try { + // 打开 Cache Storage + const cache = await caches.open(CACHE_NAMES.IMAGES); + + // 1. 从 aitu-assets 数据库读取本地素材元数据 + const assetMetaList = await readAllFromIDB(IDB_STORES.ASSETS.name, IDB_STORES.ASSETS.store); + + // 2. 从 drawnix-unified-cache 数据库读取 AI 生成媒体元数据 + const unifiedCacheItems = await readAllFromIDB(IDB_STORES.UNIFIED_CACHE.name, IDB_STORES.UNIFIED_CACHE.store); + + // 3. 获取虚拟路径缓存 + const cacheKeys = await cache.keys(); + const virtualRequests = cacheKeys.filter(req => req.url.includes('/__aitu_cache__/')); + + // 计算总数用于进度显示 + const totalItems = assetMetaList.length + unifiedCacheItems.length + virtualRequests.length; + let processedCount = 0; + + // 1. 导出本地素材 + for (const asset of assetMetaList) { + try { + assetsFolder.file(`${asset.id}.meta.json`, JSON.stringify(asset, null, 2)); + + if (asset.url) { + const response = await cache.match(asset.url); + if (response) { + const blob = await response.blob(); + if (blob.size > 0) { + const ext = getExtensionFromMimeType(asset.mimeType || blob.type); + assetsFolder.file(`${asset.id}${ext}`, blob); + exportedUrls.add(asset.url); + exportedCount++; + } + } + } + } catch (err) { + // 静默处理错误 + } + processedCount++; + if (onProgress) onProgress(processedCount, totalItems); + } + + // 2. 导出 unified-cache 中的素材 + const newCacheItems = unifiedCacheItems.filter(item => !exportedUrls.has(item.url)); + + for (const item of newCacheItems) { + try { + const itemId = item.metadata?.taskId || generateIdFromUrl(item.url); + + const metaData = { + id: itemId, + url: item.url, + type: item.type === 'video' ? 'VIDEO' : 'IMAGE', + mimeType: item.mimeType, + size: item.size, + source: 'AI_GENERATED', + createdAt: item.cachedAt, + updatedAt: item.lastUsed, + metadata: item.metadata, + }; + assetsFolder.file(`${itemId}.meta.json`, JSON.stringify(metaData, null, 2)); + + const response = await cache.match(item.url); + if (response) { + const blob = await response.blob(); + if (blob.size > 0) { + const ext = getExtensionFromMimeType(item.mimeType); + assetsFolder.file(`${itemId}${ext}`, blob); + exportedUrls.add(item.url); + exportedCount++; + } + } + } catch (err) { + // 静默处理错误 + } + processedCount++; + if (onProgress) onProgress(processedCount, totalItems); + } + + // 跳过已处理的 unified-cache items + processedCount += (unifiedCacheItems.length - newCacheItems.length); + + // 3. 导出虚拟路径缓存中的媒体(可能有些不在 unified-cache 中) + const pendingVirtualRequests = virtualRequests.filter(req => !exportedUrls.has(req.url)); + + for (const request of pendingVirtualRequests) { + const url = request.url; + + try { + const response = await cache.match(request); + if (response) { + const blob = await response.blob(); + if (blob.size > 0) { + const urlParts = url.split('/'); + const filename = urlParts[urlParts.length - 1]; + const id = filename.split('.')[0] || `cache-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const contentType = response.headers.get('content-type') || blob.type; + const ext = getExtensionFromMimeType(contentType); + const type = contentType.startsWith('video/') ? 'VIDEO' : 'IMAGE'; + + const metadata = { + id, + url, + type, + mimeType: contentType, + size: blob.size, + source: 'AI_GENERATED', + createdAt: Date.now(), + }; + assetsFolder.file(`${id}.meta.json`, JSON.stringify(metadata, null, 2)); + assetsFolder.file(`${id}${ext}`, blob); + exportedUrls.add(url); + exportedCount++; + } + } + } catch (err) { + // 静默处理错误 + } + processedCount++; + if (onProgress) onProgress(processedCount, totalItems); + } + + } catch (error) { + // 静默处理错误 + } + + return exportedCount; +} + +/** + * 显示备份进度条 + */ +function showBackupProgress() { + const container = document.createElement('div'); + container.className = 'backup-progress-container'; + container.innerHTML = ` +
+
+ 📦 + 正在备份数据 +
+
+
+
+
准备中...
+
+ `; + document.body.appendChild(container); + return container; +} + +/** + * 显示备份成功通知 + */ +function showBackupSuccessNotification({ filename, size, stats }) { + const notification = document.createElement('div'); + notification.className = 'import-notification backup-notification'; + notification.innerHTML = ` +
+ ✅ +
+ 备份成功 +

${filename}

+

+ 文件大小: ${size} MB + ${stats.boardCount > 0 ? `| 画板: ${stats.boardCount}` : ''} + ${stats.folderCount > 0 ? `| 文件夹: ${stats.folderCount}` : ''} + ${stats.assetCount > 0 ? `| 素材: ${stats.assetCount}` : ''} + ${stats.taskCount > 0 ? `| 任务: ${stats.taskCount}` : ''} + ${stats.imagePromptCount > 0 ? `| 图片提示词: ${stats.imagePromptCount}` : ''} + ${stats.videoPromptCount > 0 ? `| 视频提示词: ${stats.videoPromptCount}` : ''} +

+
+ +
+ `; + + document.body.appendChild(notification); + + // 5 秒后自动消失 + setTimeout(() => { + notification.classList.add('fade-out'); + setTimeout(() => notification.remove(), 300); + }, 5000); +} diff --git a/apps/web/public/sw-debug/common.js b/apps/web/public/sw-debug/common.js new file mode 100644 index 00000000..bd5e36db --- /dev/null +++ b/apps/web/public/sw-debug/common.js @@ -0,0 +1,140 @@ +/** + * SW Debug Panel - Common Utilities + * 公共工具函数 + */ + +/** + * Escape HTML special characters + * @param {string} str + * @returns {string} + */ +export function escapeHtml(str) { + if (typeof str !== 'string') return String(str); + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * Truncate URL for display + * @param {string} url + * @param {number} maxLen + * @returns {string} + */ +export function truncateUrl(url, maxLen) { + if (url.length <= maxLen) return url; + return url.substring(0, maxLen - 3) + '...'; +} + +/** + * Format bytes to human readable + */ +export function formatBytes(bytes) { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; +} + +/** + * Parse User-Agent string to extract browser and OS info + * @param {string} ua - User-Agent string + * @returns {{browser: string, os: string}} + */ +export function parseUserAgent(ua) { + let browser = '未知'; + let os = '未知'; + + // Detect browser + if (ua.includes('Chrome') && !ua.includes('Edg')) { + const match = ua.match(/Chrome\/(\d+)/); + browser = match ? `Chrome ${match[1]}` : 'Chrome'; + } else if (ua.includes('Edg')) { + const match = ua.match(/Edg\/(\d+)/); + browser = match ? `Edge ${match[1]}` : 'Edge'; + } else if (ua.includes('Firefox')) { + const match = ua.match(/Firefox\/(\d+)/); + browser = match ? `Firefox ${match[1]}` : 'Firefox'; + } else if (ua.includes('Safari') && !ua.includes('Chrome')) { + const match = ua.match(/Version\/(\d+)/); + browser = match ? `Safari ${match[1]}` : 'Safari'; + } + + // Detect OS + if (ua.includes('Windows NT 10')) { + os = 'Windows 10/11'; + } else if (ua.includes('Windows NT')) { + os = 'Windows'; + } else if (ua.includes('Mac OS X')) { + const match = ua.match(/Mac OS X (\d+[._]\d+)/); + os = match ? `macOS ${match[1].replace('_', '.')}` : 'macOS'; + } else if (ua.includes('Linux')) { + os = 'Linux'; + } else if (ua.includes('Android')) { + const match = ua.match(/Android (\d+)/); + os = match ? `Android ${match[1]}` : 'Android'; + } else if (ua.includes('iPhone') || ua.includes('iPad')) { + const match = ua.match(/OS (\d+)/); + os = match ? `iOS ${match[1]}` : 'iOS'; + } + + return { browser, os }; +} + +// Domain blacklist - requests from these domains will be hidden +export const DOMAIN_BLACKLIST = [ + 'us.i.posthog.com', + 'us-assets.i.posthog.com', + 'posthog.com', + 'google-analytics.com', + 'googletagmanager.com', +]; + +/** + * Check if URL is in the domain blacklist + * @param {string} url + * @returns {boolean} + */ +export function isBlacklistedUrl(url) { + if (!url) return false; + try { + const urlObj = new URL(url); + return DOMAIN_BLACKLIST.some(domain => + urlObj.hostname === domain || urlObj.hostname.endsWith('.' + domain) + ); + } catch { + return false; + } +} + +/** + * Filter logs by time range + * @param {Array} logs + * @param {string} timeRangeMinutes - Minutes as string, or empty for all + * @returns {Array} + */ +export function filterByTimeRange(logs, timeRangeMinutes) { + if (!timeRangeMinutes) return logs; + + const minutes = parseInt(timeRangeMinutes); + if (isNaN(minutes)) return logs; + + const cutoffTime = Date.now() - (minutes * 60 * 1000); + return logs.filter(log => log.timestamp >= cutoffTime); +} + +/** + * Check if a request is slow (> 1 second) + * @param {number} duration - Duration in milliseconds + * @returns {'normal'|'slow'|'very-slow'} + */ +export function getSpeedClass(duration) { + if (!duration) return 'normal'; + if (duration >= 3000) return 'very-slow'; + if (duration >= 1000) return 'slow'; + return 'normal'; +} diff --git a/apps/web/public/sw-debug/export-modal.js b/apps/web/public/sw-debug/export-modal.js new file mode 100644 index 00000000..d0002eb1 --- /dev/null +++ b/apps/web/public/sw-debug/export-modal.js @@ -0,0 +1,249 @@ +/** + * SW Debug Panel - Export Modal + * 导出弹窗功能模块 + */ + +import { state, elements } from './state.js'; +import { downloadJson } from './utils.js'; +import { loadLLMApiLogs } from './llmapi-logs.js'; +import { loadCrashLogs } from './memory-logs.js'; + +/** + * Open export modal + * Ensures all log data is loaded before export + */ +export function openExportModal() { + // Ensure LLM API logs are loaded before showing export modal + // This handles the case where user hasn't visited the LLM API tab yet + loadLLMApiLogs(); + // Also refresh crash logs to ensure completeness + loadCrashLogs(); + + elements.exportModalOverlay?.classList.add('show'); +} + +/** + * Close export modal + */ +export function closeExportModal() { + elements.exportModalOverlay?.classList.remove('show'); +} + +/** + * Setup export modal checkbox logic + */ +export function setupExportModalCheckboxes() { + const modal = elements.exportModalOverlay; + if (!modal) return; + + const selectAllCheckbox = elements.selectAllExport; + const sectionCheckboxes = modal.querySelectorAll('[data-section]'); + const allItemCheckboxes = modal.querySelectorAll('input[name]'); + + // Update section checkbox state based on its items + function updateSectionCheckbox(sectionName) { + const sectionCheckbox = modal.querySelector(`[data-section="${sectionName}"]`); + const items = modal.querySelectorAll(`input[name="${sectionName}"]`); + if (!sectionCheckbox || items.length === 0) return; + + const checkedItems = Array.from(items).filter(i => i.checked); + sectionCheckbox.checked = checkedItems.length === items.length; + sectionCheckbox.indeterminate = checkedItems.length > 0 && checkedItems.length < items.length; + } + + // Update select all checkbox state + function updateSelectAllCheckbox() { + const allChecked = Array.from(allItemCheckboxes).every(cb => cb.checked); + const someChecked = Array.from(allItemCheckboxes).some(cb => cb.checked); + selectAllCheckbox.checked = allChecked; + selectAllCheckbox.indeterminate = someChecked && !allChecked; + } + + // Select all handler + selectAllCheckbox?.addEventListener('change', () => { + const checked = selectAllCheckbox.checked; + allItemCheckboxes.forEach(cb => cb.checked = checked); + sectionCheckboxes.forEach(cb => { + cb.checked = checked; + cb.indeterminate = false; + }); + }); + + // Section checkbox handlers + sectionCheckboxes.forEach(sectionCb => { + sectionCb.addEventListener('change', () => { + const sectionName = sectionCb.dataset.section; + const items = modal.querySelectorAll(`input[name="${sectionName}"]`); + items.forEach(item => item.checked = sectionCb.checked); + sectionCb.indeterminate = false; + updateSelectAllCheckbox(); + }); + }); + + // Item checkbox handlers + allItemCheckboxes.forEach(cb => { + cb.addEventListener('change', () => { + updateSectionCheckbox(cb.name); + updateSelectAllCheckbox(); + }); + }); +} + +/** + * Get selected export options + * @returns {object} + */ +export function getExportOptions() { + const basicTypes = Array.from( + document.querySelectorAll('input[name="basic"]:checked') + ).map(el => el.value); + + const fetchTypes = Array.from( + document.querySelectorAll('input[name="fetch"]:checked') + ).map(el => el.value); + + const consoleLevels = Array.from( + document.querySelectorAll('input[name="console"]:checked') + ).map(el => el.value); + + const postmessageDirections = Array.from( + document.querySelectorAll('input[name="postmessage"]:checked') + ).map(el => el.value); + + const memoryTypes = Array.from( + document.querySelectorAll('input[name="memory"]:checked') + ).map(el => el.value); + + const llmapiTypes = Array.from( + document.querySelectorAll('input[name="llmapi"]:checked') + ).map(el => el.value); + + return { basicTypes, fetchTypes, consoleLevels, postmessageDirections, memoryTypes, llmapiTypes }; +} + +/** + * Get current memory info from browser + */ +function getCurrentMemoryInfo() { + const memory = performance.memory; + if (!memory) return null; + + return { + usedJSHeapSize: memory.usedJSHeapSize, + totalJSHeapSize: memory.totalJSHeapSize, + jsHeapSizeLimit: memory.jsHeapSizeLimit, + usedMB: Math.round(memory.usedJSHeapSize / 1024 / 1024 * 10) / 10, + totalMB: Math.round(memory.totalJSHeapSize / 1024 / 1024 * 10) / 10, + limitMB: Math.round(memory.jsHeapSizeLimit / 1024 / 1024 * 10) / 10, + usagePercent: Math.round(memory.usedJSHeapSize / memory.jsHeapSizeLimit * 1000) / 10, + }; +} + +/** + * Export logs to JSON file with selected options + */ +export function exportLogs() { + const options = getExportOptions(); + + // Build basic info + let basicInfo = {}; + if (options.basicTypes.includes('swStatus') && state.swStatus) { + basicInfo.swStatus = { + version: state.swStatus.version, + debugModeEnabled: state.swStatus.debugModeEnabled, + pendingImageRequests: state.swStatus.pendingImageRequests, + pendingVideoRequests: state.swStatus.pendingVideoRequests, + videoBlobCacheSize: state.swStatus.videoBlobCacheSize, + videoBlobCacheTotalBytes: state.swStatus.videoBlobCacheTotalBytes, + completedImageRequestsSize: state.swStatus.completedImageRequestsSize, + workflowHandlerInitialized: state.swStatus.workflowHandlerInitialized, + debugLogsCount: state.swStatus.debugLogsCount, + failedDomains: state.swStatus.failedDomains, + }; + } + if (options.basicTypes.includes('memory')) { + basicInfo.memory = getCurrentMemoryInfo(); + } + if (options.basicTypes.includes('cache') && state.swStatus?.cacheStats) { + basicInfo.cacheStats = state.swStatus.cacheStats; + } + + // Filter fetch logs + let filteredFetchLogs = []; + if (options.fetchTypes.length > 0) { + filteredFetchLogs = state.logs.filter(l => + options.fetchTypes.includes(l.requestType) + ); + } + + // Filter console logs + let filteredConsoleLogs = []; + if (options.consoleLevels.length > 0) { + filteredConsoleLogs = state.consoleLogs.filter(l => + options.consoleLevels.includes(l.logLevel) + ); + } + + // Filter postmessage logs + let filteredPostmessageLogs = []; + if (options.postmessageDirections.length > 0) { + filteredPostmessageLogs = state.postmessageLogs.filter(l => + options.postmessageDirections.includes(l.direction) + ); + } + + // Filter memory logs + let filteredMemoryLogs = []; + if (options.memoryTypes.length > 0) { + filteredMemoryLogs = state.crashLogs.filter(l => + options.memoryTypes.includes(l.type) + ); + } + + // Filter LLM API logs + let filteredLLMApiLogs = []; + if (options.llmapiTypes.length > 0) { + filteredLLMApiLogs = state.llmapiLogs.filter(l => + options.llmapiTypes.includes(l.taskType) + ); + } + + // Check if anything selected + const hasBasicInfo = Object.keys(basicInfo).length > 0; + const hasLogs = filteredFetchLogs.length > 0 || + filteredConsoleLogs.length > 0 || + filteredPostmessageLogs.length > 0 || + filteredMemoryLogs.length > 0 || + filteredLLMApiLogs.length > 0; + + if (!hasBasicInfo && !hasLogs) { + alert('没有选中任何导出项,或选中的类型没有数据'); + return; + } + + const exportData = { + exportTime: new Date().toISOString(), + userAgent: navigator.userAgent, + url: location.href, + exportOptions: options, + // Basic info at the top level for easy access + ...basicInfo, + summary: { + hasBasicInfo, + fetchLogs: filteredFetchLogs.length, + consoleLogs: filteredConsoleLogs.length, + postmessageLogs: filteredPostmessageLogs.length, + memoryLogs: filteredMemoryLogs.length, + llmapiLogs: filteredLLMApiLogs.length, + }, + fetchLogs: filteredFetchLogs, + consoleLogs: filteredConsoleLogs, + postmessageLogs: filteredPostmessageLogs, + memoryLogs: filteredMemoryLogs, + llmapiLogs: filteredLLMApiLogs, + }; + + const filename = `sw-debug-logs-${new Date().toISOString().slice(0, 19).replace(/[:-]/g, '')}.json`; + downloadJson(exportData, filename); + closeExportModal(); +} diff --git a/apps/web/public/sw-debug/fetch-logs.js b/apps/web/public/sw-debug/fetch-logs.js new file mode 100644 index 00000000..77f3ce79 --- /dev/null +++ b/apps/web/public/sw-debug/fetch-logs.js @@ -0,0 +1,745 @@ +/** + * SW Debug Panel - Fetch Logs + * Fetch 日志渲染和操作功能模块 + */ + +import { state, elements } from './state.js'; +import { isBlacklistedUrl, filterByTimeRange, getSpeedClass } from './common.js'; +import { createLogEntry } from './log-entry.js'; + +/** + * Toggle pause state + */ +export function togglePause() { + state.isPaused = !state.isPaused; + updatePauseButton(); + + if (!state.isPaused && state.pendingLogs.length > 0) { + // Apply pending logs when resuming + state.pendingLogs.forEach(log => { + addOrUpdateLog(log, true); // true = skip render + }); + state.pendingLogs = []; + renderLogs(); + } +} + +/** + * Update pause button appearance + */ +export function updatePauseButton() { + const btn = elements.togglePauseBtn; + if (!btn) return; + + if (state.isPaused) { + btn.textContent = `⏸️ 暂停`; + if (state.pendingLogs.length > 0) { + btn.textContent += ` (${state.pendingLogs.length})`; + } + btn.classList.add('paused'); + } else { + btn.textContent = '▶️ 实时'; + btn.classList.remove('paused'); + } +} + +/** + * Toggle slow request filter + */ +export function toggleSlowRequestFilter() { + state.filterSlowOnly = !state.filterSlowOnly; + updateSlowRequestsUI(); + renderLogs(); +} + +/** + * Update slow requests UI (highlight when filter is active) + */ +function updateSlowRequestsUI() { + const wrapper = elements.statSlowRequestsWrapper; + if (wrapper) { + if (state.filterSlowOnly) { + wrapper.classList.add('active'); + } else { + wrapper.classList.remove('active'); + } + } +} + +/** + * Update fetch statistics panel + */ +export function updateFetchStats() { + const logs = state.logs.filter(l => !isBlacklistedUrl(l.url)); + + // Total requests + if (elements.statTotalRequests) { + elements.statTotalRequests.textContent = logs.length; + } + + // Success rate + if (elements.statSuccessRate) { + const successCount = logs.filter(l => l.status >= 200 && l.status < 400).length; + const rate = logs.length > 0 ? ((successCount / logs.length) * 100).toFixed(1) : 0; + elements.statSuccessRate.textContent = `${rate}%`; + elements.statSuccessRate.style.color = rate >= 95 ? 'var(--success-color)' : (rate >= 80 ? 'var(--warning-color)' : 'var(--error-color)'); + } + + // Average duration + if (elements.statAvgDuration) { + const durations = logs.filter(l => l.duration > 0).map(l => l.duration); + const avg = durations.length > 0 ? (durations.reduce((a, b) => a + b, 0) / durations.length) : 0; + elements.statAvgDuration.textContent = avg > 0 ? `${Math.round(avg)}ms` : '-'; + elements.statAvgDuration.style.color = avg < 500 ? 'var(--success-color)' : (avg < 1000 ? 'var(--warning-color)' : 'var(--error-color)'); + } + + // Cache hit rate + if (elements.statCacheHit) { + const cachedCount = logs.filter(l => l.cached).length; + const rate = logs.length > 0 ? ((cachedCount / logs.length) * 100).toFixed(1) : 0; + elements.statCacheHit.textContent = `${rate}%`; + } + + // Slow requests count + if (elements.statSlowRequests) { + const slowCount = logs.filter(l => l.duration >= 1000).length; + elements.statSlowRequests.textContent = slowCount; + elements.statSlowRequests.style.color = slowCount === 0 ? 'var(--success-color)' : 'var(--warning-color)'; + } + + // Duration distribution chart + updateDurationChart(logs); +} + +/** + * Update duration distribution chart + */ +function updateDurationChart(logs) { + const logsWithDuration = logs.filter(l => l.duration > 0); + const total = logsWithDuration.length; + + if (total === 0) { + if (elements.chartFast) elements.chartFast.style.width = '0%'; + if (elements.chartMedium) elements.chartMedium.style.width = '0%'; + if (elements.chartSlow) elements.chartSlow.style.width = '0%'; + if (elements.chartVerySlow) elements.chartVerySlow.style.width = '0%'; + return; + } + + // Categorize by duration + const fast = logsWithDuration.filter(l => l.duration < 100).length; + const medium = logsWithDuration.filter(l => l.duration >= 100 && l.duration < 500).length; + const slow = logsWithDuration.filter(l => l.duration >= 500 && l.duration < 1000).length; + const verySlow = logsWithDuration.filter(l => l.duration >= 1000).length; + + // Calculate percentages + const fastPct = (fast / total) * 100; + const mediumPct = (medium / total) * 100; + const slowPct = (slow / total) * 100; + const verySlowPct = (verySlow / total) * 100; + + // Update chart bars + if (elements.chartFast) { + elements.chartFast.style.width = `${fastPct}%`; + elements.chartFast.title = `<100ms: ${fast} (${fastPct.toFixed(1)}%)`; + } + if (elements.chartMedium) { + elements.chartMedium.style.width = `${mediumPct}%`; + elements.chartMedium.title = `100-500ms: ${medium} (${mediumPct.toFixed(1)}%)`; + } + if (elements.chartSlow) { + elements.chartSlow.style.width = `${slowPct}%`; + elements.chartSlow.title = `500ms-1s: ${slow} (${slowPct.toFixed(1)}%)`; + } + if (elements.chartVerySlow) { + elements.chartVerySlow.style.width = `${verySlowPct}%`; + elements.chartVerySlow.title = `>1s: ${verySlow} (${verySlowPct.toFixed(1)}%)`; + } +} + +/** + * Get filtered fetch logs based on current filters + */ +export function getFilteredFetchLogs() { + const typeFilter = elements.filterType?.value || ''; + const statusFilter = elements.filterStatus?.value || ''; + const urlFilter = (elements.filterUrl?.value || '').toLowerCase(); + + let filteredLogs = state.logs.filter(l => !isBlacklistedUrl(l.url)); + + if (typeFilter) { + filteredLogs = filteredLogs.filter(l => l.requestType === typeFilter); + } + + if (statusFilter) { + if (statusFilter === '500') { + filteredLogs = filteredLogs.filter(l => l.status >= 500); + } else { + filteredLogs = filteredLogs.filter(l => l.status === parseInt(statusFilter)); + } + } + + if (urlFilter) { + filteredLogs = filteredLogs.filter(l => l.url?.toLowerCase().includes(urlFilter)); + } + + return filteredLogs; +} + +/** + * Render fetch logs + */ +export function renderLogs() { + const typeFilter = elements.filterType?.value || ''; + const statusFilter = elements.filterStatus?.value || ''; + const timeRangeFilter = elements.filterTimeRange?.value || ''; + const urlFilter = (elements.filterUrl?.value || '').toLowerCase(); + + let filteredLogs = state.logs; + + // Filter out blacklisted domains + filteredLogs = filteredLogs.filter(l => !isBlacklistedUrl(l.url)); + + if (typeFilter) { + filteredLogs = filteredLogs.filter(l => l.requestType === typeFilter); + } + + if (statusFilter) { + if (statusFilter === 'error') { + // Filter failed requests (4xx, 5xx, or has error) + filteredLogs = filteredLogs.filter(l => l.status >= 400 || l.error); + } else if (statusFilter === '500') { + filteredLogs = filteredLogs.filter(l => l.status >= 500); + } else if (statusFilter === 'slow') { + // Filter slow requests (> 1 second) + filteredLogs = filteredLogs.filter(l => l.duration >= 1000); + } else { + filteredLogs = filteredLogs.filter(l => l.status === parseInt(statusFilter)); + } + } + + // Apply time range filter + filteredLogs = filterByTimeRange(filteredLogs, timeRangeFilter); + + // URL filter with optional regex support + if (urlFilter) { + const useRegex = elements.filterUrlRegex?.checked || false; + if (useRegex) { + try { + const regex = new RegExp(urlFilter, 'i'); + filteredLogs = filteredLogs.filter(l => regex.test(l.url || '')); + } catch (e) { + // Invalid regex, fall back to simple match + filteredLogs = filteredLogs.filter(l => l.url?.toLowerCase().includes(urlFilter)); + } + } else { + filteredLogs = filteredLogs.filter(l => l.url?.toLowerCase().includes(urlFilter)); + } + } + + // Bookmarks filter + if (state.showBookmarksOnly) { + filteredLogs = filteredLogs.filter(l => state.bookmarkedLogIds.has(l.id)); + } + + // Slow requests filter (via stats bar click) + if (state.filterSlowOnly) { + filteredLogs = filteredLogs.filter(l => l.duration >= 1000); + } + + // Update statistics panel + updateFetchStats(); + + // Update fetch count + if (elements.fetchCountEl) { + const slowCount = filteredLogs.filter(l => l.duration >= 1000).length; + const errorCount = filteredLogs.filter(l => l.status >= 400 || l.error).length; + let countText = `(${filteredLogs.length})`; + if (slowCount > 0 || errorCount > 0) { + const parts = []; + if (errorCount > 0) parts.push(`${errorCount} err`); + if (slowCount > 0) parts.push(`${slowCount} slow`); + countText = `(${parts.join(', ')})`; + } + elements.fetchCountEl.innerHTML = countText; + } + + if (filteredLogs.length === 0) { + elements.logsContainer.innerHTML = ` +
+ 📋 +

${state.debugEnabled ? '暂无匹配的日志' : '请先启用调试模式'}

+ ${!state.debugEnabled ? '' : ''} +
+ `; + const btn = document.getElementById('enableDebugBtn2'); + if (btn) { + btn.addEventListener('click', () => { + // This will be handled by the event listener setup + document.getElementById('toggleDebug')?.click(); + }); + } + return; + } + + elements.logsContainer.innerHTML = ''; + filteredLogs.slice(0, 200).forEach(log => { + const isExpanded = state.expandedLogIds.has(log.id); + const isBookmarked = state.bookmarkedLogIds.has(log.id); + const isSelected = state.selectedLogIds.has(log.id); + const entry = createLogEntry( + log, + isExpanded, + (id, expanded) => { + // Update expanded state + if (expanded) { + state.expandedLogIds.add(id); + } else { + state.expandedLogIds.delete(id); + } + }, + isBookmarked, + toggleBookmark, + state.isSelectMode, + isSelected, + toggleLogSelection + ); + + // Add slow request class for highlighting + const speedClass = getSpeedClass(log.duration); + if (speedClass !== 'normal') { + entry.classList.add('slow-request'); + } + + elements.logsContainer.appendChild(entry); + }); +} + +/** + * 判断日志是否为问题请求(错误、慢请求) + */ +function isProblemLog(log) { + // 错误请求:状态码 >= 400 或有错误信息 + if (log.status >= 400 || log.error) return true; + // 慢请求:耗时 >= 1秒 + if (log.duration >= 1000) return true; + return false; +} + +/** + * 裁剪日志,优先保留问题请求和收藏 + */ +function trimLogsWithPriority(maxLogs) { + if (state.logs.length <= maxLogs) return; + + // 分类日志 + const bookmarked = []; + const problems = []; + const normal = []; + + state.logs.forEach(log => { + if (state.bookmarkedLogIds.has(log.id)) { + bookmarked.push(log); + } else if (isProblemLog(log)) { + problems.push(log); + } else { + normal.push(log); + } + }); + + // 计算需要保留的数量 + const mustKeep = bookmarked.length + problems.length; + + if (mustKeep >= maxLogs) { + // 问题请求太多,只保留收藏 + 部分问题请求 + const problemsToKeep = Math.max(0, maxLogs - bookmarked.length); + state.logs = [...bookmarked, ...problems.slice(0, problemsToKeep)]; + } else { + // 保留所有收藏和问题请求,剩余空间给正常请求 + const normalToKeep = maxLogs - mustKeep; + state.logs = [...bookmarked, ...problems, ...normal.slice(0, normalToKeep)]; + } + + // 按时间排序(最新的在前) + state.logs.sort((a, b) => b.timestamp - a.timestamp); +} + +/** + * Add or update a log entry + * @param {object} entry + * @param {boolean} skipRender - Skip rendering (for batch updates) + */ +export function addOrUpdateLog(entry, skipRender = false) { + // Skip blacklisted URLs + if (isBlacklistedUrl(entry.url)) { + return; + } + + // In analysis mode, save to liveLogs buffer instead of display state + if (state.isAnalysisMode) { + addOrUpdateLiveLog('logs', entry); + return; + } + + // If paused, add to pending queue + if (state.isPaused && !skipRender) { + state.pendingLogs.push(entry); + updatePauseButton(); + return; + } + + const existingIndex = state.logs.findIndex(l => l.id === entry.id); + if (existingIndex !== -1) { + state.logs[existingIndex] = { ...state.logs[existingIndex], ...entry }; + } else { + state.logs.unshift(entry); + // Use configurable max logs limit + const maxLogs = state.settings?.maxLogs || 500; + if (state.logs.length > maxLogs) { + // 优先保留问题请求(错误、慢请求、收藏) + trimLogsWithPriority(maxLogs); + } + } + + if (!skipRender) { + renderLogs(); + + if (state.autoScroll) { + elements.logsContainer.scrollTop = 0; + } + } +} + +/** + * Add or update a log entry in the live logs buffer (used in analysis mode) + * @param {string} logType - The type of log ('logs', 'consoleLogs', etc.) + * @param {object} entry - The log entry + */ +function addOrUpdateLiveLog(logType, entry) { + if (!state.liveLogs[logType]) { + state.liveLogs[logType] = []; + } + const existingIndex = state.liveLogs[logType].findIndex(l => l.id === entry.id); + if (existingIndex >= 0) { + state.liveLogs[logType][existingIndex] = { ...state.liveLogs[logType][existingIndex], ...entry }; + } else { + state.liveLogs[logType].unshift(entry); + } +} + +// ==================== Bookmark Functions ==================== + +/** + * Toggle bookmark for a log entry + * @param {string} logId + */ +export function toggleBookmark(logId) { + if (state.bookmarkedLogIds.has(logId)) { + state.bookmarkedLogIds.delete(logId); + } else { + state.bookmarkedLogIds.add(logId); + } + // Save to localStorage + saveBookmarks(); + renderLogs(); +} + +/** + * Save bookmarks to localStorage + */ +export function saveBookmarks() { + try { + localStorage.setItem('sw-debug-bookmarks', JSON.stringify([...state.bookmarkedLogIds])); + } catch (e) { + console.error('Failed to save bookmarks:', e); + } +} + +/** + * Load bookmarks from localStorage + */ +export function loadBookmarks() { + try { + const saved = localStorage.getItem('sw-debug-bookmarks'); + if (saved) { + state.bookmarkedLogIds = new Set(JSON.parse(saved)); + } + } catch (e) { + console.error('Failed to load bookmarks:', e); + } +} + +// ==================== Selection Functions ==================== + +/** + * Toggle select mode for batch operations + */ +export function toggleSelectMode() { + state.isSelectMode = !state.isSelectMode; + state.selectedLogIds.clear(); + updateSelectModeUI(); + renderLogs(); +} + +/** + * Update select mode UI + */ +function updateSelectModeUI() { + if (elements.toggleSelectModeBtn) { + elements.toggleSelectModeBtn.textContent = state.isSelectMode ? '✅ 取消' : '☑️ 选择'; + elements.toggleSelectModeBtn.style.background = state.isSelectMode ? 'var(--primary-color)' : ''; + elements.toggleSelectModeBtn.style.color = state.isSelectMode ? '#fff' : ''; + } + if (elements.batchActionsEl) { + elements.batchActionsEl.style.display = state.isSelectMode ? 'flex' : 'none'; + } + updateSelectedCount(); +} + +/** + * Update selected count display + */ +function updateSelectedCount() { + if (elements.selectedCountEl) { + elements.selectedCountEl.textContent = `已选 ${state.selectedLogIds.size} 条`; + } +} + +/** + * Toggle selection of a log entry + */ +export function toggleLogSelection(logId) { + if (state.selectedLogIds.has(logId)) { + state.selectedLogIds.delete(logId); + } else { + state.selectedLogIds.add(logId); + } + updateSelectedCount(); + // Update checkbox in DOM + const checkbox = document.querySelector(`.log-select-checkbox[data-id="${logId}"]`); + if (checkbox) { + checkbox.checked = state.selectedLogIds.has(logId); + } +} + +/** + * Select all visible logs + */ +export function selectAllLogs() { + const filteredLogs = getFilteredFetchLogs(); + const allSelected = filteredLogs.every(l => state.selectedLogIds.has(l.id)); + + if (allSelected) { + // Deselect all + filteredLogs.forEach(l => state.selectedLogIds.delete(l.id)); + } else { + // Select all + filteredLogs.forEach(l => state.selectedLogIds.add(l.id)); + } + + updateSelectedCount(); + renderLogs(); +} + +/** + * Batch bookmark selected logs + */ +export function batchBookmarkLogs() { + if (state.selectedLogIds.size === 0) { + alert('请先选择日志'); + return; + } + + state.selectedLogIds.forEach(id => { + state.bookmarkedLogIds.add(id); + }); + + saveBookmarks(); + state.selectedLogIds.clear(); + updateSelectedCount(); + renderLogs(); +} + +/** + * Batch delete selected logs + */ +export function batchDeleteLogs() { + if (state.selectedLogIds.size === 0) { + alert('请先选择日志'); + return; + } + + if (!confirm(`确定要删除选中的 ${state.selectedLogIds.size} 条日志吗?`)) { + return; + } + + state.logs = state.logs.filter(l => !state.selectedLogIds.has(l.id)); + + // Also remove from bookmarks + state.selectedLogIds.forEach(id => { + state.bookmarkedLogIds.delete(id); + }); + saveBookmarks(); + + state.selectedLogIds.clear(); + updateSelectedCount(); + renderLogs(); +} + +// ==================== CSV Export ==================== + +/** + * Export fetch logs as CSV + */ +export function exportFetchCSV() { + const filteredLogs = getFilteredFetchLogs(); + if (filteredLogs.length === 0) { + alert('没有可导出的日志'); + return; + } + + // CSV header + const headers = ['时间', '方法', '状态', 'URL', '耗时(ms)', '类型', '缓存']; + const rows = [headers.join(',')]; + + // CSV rows + filteredLogs.forEach(log => { + const time = new Date(log.timestamp).toLocaleString('zh-CN', { hour12: false }); + const row = [ + `"${time}"`, + log.method || 'GET', + log.status || '-', + `"${(log.url || '').replace(/"/g, '""')}"`, + log.duration || '', + log.requestType || '-', + log.cached ? '是' : '否' + ]; + rows.push(row.join(',')); + }); + + const csvContent = '\uFEFF' + rows.join('\n'); // BOM for Excel + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `fetch-logs-${new Date().toISOString().slice(0, 10)}.csv`; + a.click(); + URL.revokeObjectURL(url); +} + +/** + * Copy filtered fetch logs to clipboard + */ +export async function handleCopyFetchLogs() { + const filteredLogs = getFilteredFetchLogs(); + + if (filteredLogs.length === 0) { + alert('没有可复制的日志'); + return; + } + + // Format logs as text + const logText = filteredLogs.map(log => { + const time = new Date(log.timestamp).toLocaleTimeString('zh-CN', { hour12: false }); + const method = log.method || 'GET'; + const status = log.status || '-'; + const duration = log.duration ? `${log.duration}ms` : '-'; + const cached = log.cached ? ' [缓存]' : ''; + const url = log.url || '-'; + return `${time} ${method} ${status} ${url} (${duration})${cached}`; + }).join('\n'); + + try { + await navigator.clipboard.writeText(logText); + const btn = elements.copyFetchLogsBtn; + const originalText = btn.textContent; + btn.textContent = '✅ 已复制'; + setTimeout(() => { + btn.textContent = originalText; + }, 1500); + } catch (err) { + console.error('Failed to copy:', err); + alert('复制失败'); + } +} + +// ==================== Related Requests ==================== + +/** + * Find related requests based on URL pattern or timing + * @param {object} log - The log entry to find related requests for + * @returns {Array} - Array of related log entries + */ +export function findRelatedRequests(log) { + if (!log.url) return []; + + try { + const urlObj = new URL(log.url); + const basePath = urlObj.pathname.split('/').slice(0, 3).join('/'); // First 2 path segments + const timestamp = log.timestamp; + const timeWindow = 5000; // 5 second window + + return state.logs.filter(l => { + if (l.id === log.id) return false; + if (!l.url) return false; + + try { + const otherUrl = new URL(l.url); + + // Same host + if (otherUrl.hostname !== urlObj.hostname) return false; + + // Similar path OR within time window + const otherBasePath = otherUrl.pathname.split('/').slice(0, 3).join('/'); + const pathMatch = otherBasePath === basePath; + const timeMatch = Math.abs(l.timestamp - timestamp) <= timeWindow; + + return pathMatch || timeMatch; + } catch { + return false; + } + }).slice(0, 10); // Limit to 10 related requests + } catch { + return []; + } +} + +/** + * Render related requests section for log details + * @param {object} log - The log entry + * @returns {string} - HTML string + */ +export function renderRelatedRequests(log) { + const related = findRelatedRequests(log); + if (related.length === 0) return ''; + + const items = related.map(r => { + const time = new Date(r.timestamp).toLocaleTimeString('zh-CN', { hour12: false }); + const status = r.status || '...'; + const statusClass = r.status >= 200 && r.status < 400 ? 'success' : (r.status >= 400 ? 'error' : ''); + const duration = r.duration ? `${r.duration}ms` : '-'; + // Show full URL, let CSS handle wrapping + const displayUrl = r.url || '-'; + + return ` + + `; + }).join(''); + + return ` +
+

🔗 相关请求 (${related.length})

+ +
+ `; +} + +// Make renderRelatedRequests available globally +window.renderRelatedRequests = renderRelatedRequests; diff --git a/apps/web/public/sw-debug/indexeddb.js b/apps/web/public/sw-debug/indexeddb.js new file mode 100644 index 00000000..ca78273b --- /dev/null +++ b/apps/web/public/sw-debug/indexeddb.js @@ -0,0 +1,143 @@ +/** + * SW Debug Panel - IndexedDB Operations + * IndexedDB 数据库操作函数 + */ + +/** + * IndexedDB 存储名称常量(与应用层 storage-keys.ts 保持一致) + */ +export const IDB_STORES = { + // 工作区数据(文件夹、画板) + WORKSPACE: { + name: 'aitu-workspace', + stores: { + FOLDERS: 'folders', + BOARDS: 'boards', + STATE: 'state', + }, + }, + // 通用键值存储(提示词等) + KV: { + name: 'aitu-storage', + store: 'data', + }, + // 素材库元数据 + ASSETS: { + name: 'aitu-assets', + store: 'assets', + }, + // 统一缓存(AI 生成的媒体元数据) + UNIFIED_CACHE: { + name: 'drawnix-unified-cache', + store: 'media', + }, +}; + +/** + * KV 存储中的关键 key(与应用层 LS_KEYS_TO_MIGRATE 保持一致) + */ +export const KV_KEYS = { + PROMPT_HISTORY: 'aitu_prompt_history', + VIDEO_PROMPT_HISTORY: 'aitu_video_prompt_history', + IMAGE_PROMPT_HISTORY: 'aitu_image_prompt_history', + PRESET_SETTINGS: 'aitu-prompt-preset-settings', +}; + +/** + * Cache Storage 名称 + */ +export const CACHE_NAMES = { + IMAGES: 'drawnix-images', +}; + +/** + * 任务队列数据库配置 + */ +export const SW_TASK_QUEUE_DB = { + name: 'sw-task-queue', + stores: { + TASKS: 'tasks', + }, +}; + +/** + * 任务类型和状态常量(与 SW 中的枚举值保持一致,是小写) + */ +export const TaskType = { + IMAGE: 'image', + VIDEO: 'video', +}; + +export const TaskStatus = { + COMPLETED: 'completed', +}; + +/** + * 打开 IndexedDB 数据库 + */ +export function openIDB(dbName, storeName) { + return new Promise((resolve, reject) => { + const request = indexedDB.open(dbName); + request.onerror = () => reject(request.error); + request.onsuccess = () => { + const db = request.result; + if (!db.objectStoreNames.contains(storeName)) { + db.close(); + resolve(null); + return; + } + resolve(db); + }; + }); +} + +/** + * 从 IndexedDB 读取所有数据 + */ +export async function readAllFromIDB(dbName, storeName) { + try { + const db = await openIDB(dbName, storeName); + if (!db) return []; + + return new Promise((resolve, reject) => { + const transaction = db.transaction(storeName, 'readonly'); + const store = transaction.objectStore(storeName); + const request = store.getAll(); + + request.onerror = () => { + db.close(); + reject(request.error); + }; + request.onsuccess = () => { + db.close(); + resolve(request.result || []); + }; + }); + } catch (error) { + return []; + } +} + +/** + * 从 IndexedDB KV 存储读取指定 key + */ +export async function readKVItem(key) { + const db = await openIDB(IDB_STORES.KV.name, IDB_STORES.KV.store); + if (!db) return null; + + return new Promise((resolve, reject) => { + const transaction = db.transaction(IDB_STORES.KV.store, 'readonly'); + const store = transaction.objectStore(IDB_STORES.KV.store); + const request = store.get(key); + + request.onerror = () => { + db.close(); + reject(request.error); + }; + request.onsuccess = () => { + db.close(); + const result = request.result; + resolve(result?.value || null); + }; + }); +} diff --git a/apps/web/public/sw-debug/llmapi-logs.js b/apps/web/public/sw-debug/llmapi-logs.js new file mode 100644 index 00000000..f5bffcf6 --- /dev/null +++ b/apps/web/public/sw-debug/llmapi-logs.js @@ -0,0 +1,529 @@ +/** + * SW Debug Panel - LLM API Logs + * LLM API 日志功能模块 + */ + +import { state, elements } from './state.js'; +import { escapeHtml, formatBytes } from './common.js'; +import { downloadJson } from './utils.js'; +import { showToast } from './toast.js'; + +/** + * Load LLM API logs from SW + */ +export function loadLLMApiLogs() { + if (navigator.serviceWorker?.controller) { + navigator.serviceWorker.controller.postMessage({ + type: 'SW_DEBUG_GET_LLM_API_LOGS' + }); + } +} + +/** + * Clear LLM API logs + */ +export function handleClearLLMApiLogs() { + if (!confirm('确定要清空所有 LLM API 日志吗?')) return; + + if (navigator.serviceWorker?.controller) { + navigator.serviceWorker.controller.postMessage({ + type: 'SW_DEBUG_CLEAR_LLM_API_LOGS' + }); + } + state.llmapiLogs = []; + renderLLMApiLogs(); +} + +/** + * Get filtered LLM API logs based on current filters + */ +export function getFilteredLLMApiLogs() { + const typeFilter = elements.filterLLMApiType?.value || ''; + const statusFilter = elements.filterLLMApiStatus?.value || ''; + + let filteredLogs = state.llmapiLogs; + + if (typeFilter) { + filteredLogs = filteredLogs.filter(l => l.taskType === typeFilter); + } + if (statusFilter) { + filteredLogs = filteredLogs.filter(l => l.status === statusFilter); + } + + return filteredLogs; +} + +/** + * Render LLM API logs + */ +export function renderLLMApiLogs() { + const filteredLogs = getFilteredLLMApiLogs(); + + if (!elements.llmapiLogsContainer) return; + + if (filteredLogs.length === 0) { + elements.llmapiLogsContainer.innerHTML = ` +
+ 🤖 +

暂无 LLM API 调用记录

+

图片/视频/对话等 AI 接口调用会自动记录

+
+ `; + return; + } + + elements.llmapiLogsContainer.innerHTML = ''; + filteredLogs.forEach(log => { + const isExpanded = state.expandedLLMApiIds.has(log.id); + const entry = createLLMApiEntry(log, isExpanded, (id, expanded) => { + if (expanded) { + state.expandedLLMApiIds.add(id); + } else { + state.expandedLLMApiIds.delete(id); + } + }); + elements.llmapiLogsContainer.appendChild(entry); + }); +} + +/** + * Create a LLM API log entry element + * Uses the same styles as Fetch logs for consistency + */ +function createLLMApiEntry(log, isExpanded, onToggle) { + const entry = document.createElement('div'); + entry.className = 'log-entry' + (isExpanded ? ' expanded' : ''); + entry.dataset.id = log.id; + + const time = new Date(log.timestamp).toLocaleTimeString('zh-CN', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + + // Status badge - use log-status class like Fetch logs + let statusClass = ''; + let statusText = ''; + switch (log.status) { + case 'success': + statusClass = 'success'; + statusText = '✓ 成功'; + break; + case 'error': + statusClass = 'error'; + statusText = '✗ 失败'; + break; + case 'pending': + statusClass = 'pending'; + statusText = '⋯ 进行中'; + break; + default: + statusText = log.status; + } + + // Task type badge - use log-type-badge class like Fetch logs + const typeLabel = { + 'image': '图片生成', + 'video': '视频生成', + 'chat': '对话', + 'character': '角色', + 'other': '其他', + }[log.taskType] || log.taskType; + + // Duration format - use log-duration class like Fetch logs + const durationMs = log.duration || 0; + const durationText = log.duration ? `${(log.duration / 1000).toFixed(1)}s` : '-'; + const durationClass = durationMs >= 3000 ? 'very-slow' : (durationMs >= 1000 ? 'slow' : ''); + + // Show full prompt in header (will wrap if needed) + const promptPreview = log.prompt || '-'; + + // Render reference images preview + let referenceImagesHtml = ''; + if (log.referenceImages && log.referenceImages.length > 0) { + const imagesList = log.referenceImages.map(img => { + const sizeText = img.size ? formatBytes(img.size) : '-'; + const dimensions = img.width && img.height ? `${img.width}x${img.height}` : '-'; + return ` +
+
+ +
+
+ ${sizeText} + ${dimensions} +
+
+ `; + }).join(''); + + referenceImagesHtml = ` +
+

参考图详情

+
+ ${imagesList} +
+
+ `; + } + + entry.innerHTML = ` +
+ ▶ + ${time} + ${statusText} + ${typeLabel} + ${log.model} + ${escapeHtml(promptPreview)} + ${durationText} +
+
+
+

基本信息

+ + + + + + + + + + + + + + + + + + + + + + + + + + + ${log.hasReferenceImages ? ` + + + + + ` : ''} + ${log.resultType ? ` + + + + + ` : ''} + ${log.taskId ? ` + + + + + ` : ''} + ${log.resultUrl ? ` + + + + + ` : ''} + ${log.errorMessage ? ` + + + + + ` : ''} + +
ID${log.id}
Endpoint${log.endpoint}
模型${log.model}
类型${log.taskType}
HTTP 状态${log.httpStatus || '-'}
耗时${durationText}
参考图${log.referenceImageCount || 0} 张
结果类型${log.resultType}
任务 ID${log.taskId}
结果 URL + + ${log.resultUrl.length > 80 ? log.resultUrl.substring(0, 80) + '...' : log.resultUrl} + + +
错误信息${escapeHtml(log.errorMessage)}
+
+ ${referenceImagesHtml} + ${log.prompt ? ` +
+

提示词

+
${escapeHtml(log.prompt)}
+
+ ` : ''} + ${log.requestBody ? ` +
+

请求体 (Request Body)

+
${escapeHtml(log.requestBody)}
+
+ ` : ''} + ${log.resultText ? ` +
+

响应文本

+
${escapeHtml(log.resultText)}
+
+ ` : ''} + ${log.responseBody ? ` +
+

响应体 (Response Body)

+
${escapeHtml(log.responseBody)}
+
+ ` : ''} +
+ `; + + // Toggle function - same as Fetch logs + const toggleExpand = () => { + const isNowExpanded = entry.classList.toggle('expanded'); + if (onToggle) { + onToggle(log.id, isNowExpanded); + } + }; + + // Toggle expand/collapse on button click + const toggleBtn = entry.querySelector('.log-toggle'); + toggleBtn.addEventListener('click', (e) => { + e.stopPropagation(); + toggleExpand(); + }); + + // Toggle on header click (except toggle button) + const header = entry.querySelector('.log-header'); + header.addEventListener('click', (e) => { + if (e.target.closest('.log-toggle')) return; + toggleExpand(); + }); + + // Copy URL button click handler + const copyUrlBtn = entry.querySelector('.copy-url-btn'); + if (copyUrlBtn) { + copyUrlBtn.addEventListener('click', async (e) => { + e.stopPropagation(); + const url = copyUrlBtn.dataset.url; + try { + await navigator.clipboard.writeText(url); + // Show feedback + const originalHtml = copyUrlBtn.innerHTML; + copyUrlBtn.innerHTML = '✓'; + copyUrlBtn.style.color = 'var(--success-color)'; + setTimeout(() => { + copyUrlBtn.innerHTML = originalHtml; + copyUrlBtn.style.color = ''; + }, 1500); + } catch (err) { + console.error('Failed to copy URL:', err); + } + }); + } + + return entry; +} + +/** + * Copy filtered LLM API logs to clipboard + */ +export async function handleCopyLLMApiLogs() { + const filteredLogs = getFilteredLLMApiLogs(); + + if (filteredLogs.length === 0) { + alert('没有可复制的日志'); + return; + } + + // Format logs as text + const logText = filteredLogs.map(log => { + const time = new Date(log.timestamp).toLocaleString('zh-CN', { hour12: false }); + const type = log.taskType || 'unknown'; + const status = log.status || '-'; + const model = log.model || '-'; + const duration = log.duration ? `${(log.duration / 1000).toFixed(1)}s` : '-'; + const prompt = log.prompt ? `\n 提示词: ${log.prompt}` : ''; + const error = log.errorMessage ? `\n 错误: ${log.errorMessage}` : ''; + return `${time} [${type}] ${status} | ${model} (${duration})${prompt}${error}`; + }).join('\n\n'); + + try { + await navigator.clipboard.writeText(logText); + const btn = elements.copyLLMApiLogsBtn; + const originalText = btn.textContent; + btn.textContent = '✅ 已复制'; + setTimeout(() => { + btn.textContent = originalText; + }, 1500); + } catch (err) { + console.error('Failed to copy:', err); + alert('复制失败'); + } +} + +/** + * Export LLM API logs with media files (images/videos) + * Creates a ZIP file containing: + * - llm-api-logs.json: All LLM API logs + * - media/: Directory containing cached images and videos + */ +export async function handleExportLLMApiLogs() { + if (state.llmapiLogs.length === 0) { + alert('暂无 LLM API 日志可导出'); + return; + } + + const exportBtn = elements.exportLLMApiLogsBtn; + const originalText = exportBtn.textContent; + + try { + exportBtn.disabled = true; + exportBtn.textContent = '⏳ 准备中...'; + + // Check if JSZip is available + if (typeof JSZip === 'undefined') { + // Fallback to JSON-only export + console.warn('JSZip not available, falling back to JSON-only export'); + const filename = `llm-api-logs-${new Date().toISOString().slice(0, 19).replace(/[:-]/g, '')}.json`; + downloadJson({ + exportTime: new Date().toISOString(), + logs: state.llmapiLogs, + mediaNotIncluded: true, + reason: 'JSZip not available' + }, filename); + return; + } + + const zip = new JSZip(); + + // Add logs JSON + const logsData = { + exportTime: new Date().toISOString(), + userAgent: navigator.userAgent, + totalLogs: state.llmapiLogs.length, + summary: { + image: state.llmapiLogs.filter(l => l.taskType === 'image').length, + video: state.llmapiLogs.filter(l => l.taskType === 'video').length, + chat: state.llmapiLogs.filter(l => l.taskType === 'chat').length, + character: state.llmapiLogs.filter(l => l.taskType === 'character').length, + success: state.llmapiLogs.filter(l => l.status === 'success').length, + error: state.llmapiLogs.filter(l => l.status === 'error').length, + }, + logs: state.llmapiLogs + }; + zip.file('llm-api-logs.json', JSON.stringify(logsData, null, 2)); + + // Collect URLs to download + const mediaUrls = []; + for (const log of state.llmapiLogs) { + if (log.resultUrl && log.status === 'success') { + mediaUrls.push({ + url: log.resultUrl, + id: log.id, + type: log.taskType, + timestamp: log.timestamp + }); + } + } + + exportBtn.textContent = `⏳ 下载媒体 0/${mediaUrls.length}...`; + + // Download media files + const mediaFolder = zip.folder('media'); + let downloadedCount = 0; + let failedCount = 0; + const mediaManifest = []; + + for (const item of mediaUrls) { + try { + // Handle both absolute and relative URLs + let fetchUrl = item.url; + if (fetchUrl.startsWith('/')) { + fetchUrl = location.origin + fetchUrl; + } + + const response = await fetch(fetchUrl); + if (response.ok) { + const blob = await response.blob(); + const contentType = response.headers.get('content-type') || blob.type; + + // Determine file extension + let ext = 'bin'; + if (contentType.includes('image/png')) ext = 'png'; + else if (contentType.includes('image/jpeg')) ext = 'jpg'; + else if (contentType.includes('image/gif')) ext = 'gif'; + else if (contentType.includes('image/webp')) ext = 'webp'; + else if (contentType.includes('video/mp4')) ext = 'mp4'; + else if (contentType.includes('video/webm')) ext = 'webm'; + + // Create filename based on log id and timestamp + const date = new Date(item.timestamp); + const dateStr = date.toISOString().slice(0, 10).replace(/-/g, ''); + const filename = `${dateStr}_${item.type}_${item.id.split('-').pop()}.${ext}`; + + mediaFolder.file(filename, blob); + mediaManifest.push({ + logId: item.id, + filename, + originalUrl: item.url, + size: blob.size, + type: contentType + }); + downloadedCount++; + } else { + failedCount++; + mediaManifest.push({ + logId: item.id, + originalUrl: item.url, + error: `HTTP ${response.status}` + }); + } + } catch (err) { + failedCount++; + mediaManifest.push({ + logId: item.id, + originalUrl: item.url, + error: err.message + }); + } + + exportBtn.textContent = `⏳ 下载媒体 ${downloadedCount + failedCount}/${mediaUrls.length}...`; + } + + // Add media manifest + zip.file('media-manifest.json', JSON.stringify({ + totalUrls: mediaUrls.length, + downloaded: downloadedCount, + failed: failedCount, + files: mediaManifest + }, null, 2)); + + exportBtn.textContent = '⏳ 生成 ZIP...'; + + // Generate and download ZIP + const zipBlob = await zip.generateAsync({ + type: 'blob', + compression: 'DEFLATE', + compressionOptions: { level: 6 } + }); + + const filename = `llm-api-export-${new Date().toISOString().slice(0, 19).replace(/[:-]/g, '')}.zip`; + const url = URL.createObjectURL(zipBlob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + // Show summary + const sizeInMB = (zipBlob.size / 1024 / 1024).toFixed(2); + showToast(`导出完成!\n日志数: ${state.llmapiLogs.length}\n媒体文件: ${downloadedCount} 成功, ${failedCount} 失败\n文件大小: ${sizeInMB} MB`, 'success', 5000); + + } catch (err) { + console.error('Export failed:', err); + showToast('导出失败: ' + err.message, 'error', 5000); + } finally { + exportBtn.disabled = false; + exportBtn.textContent = originalText; + } +} diff --git a/apps/web/public/sw-debug/memory-logs.js b/apps/web/public/sw-debug/memory-logs.js new file mode 100644 index 00000000..d5551d9d --- /dev/null +++ b/apps/web/public/sw-debug/memory-logs.js @@ -0,0 +1,468 @@ +/** + * SW Debug Panel - Memory/Crash Logs + * 内存和崩溃日志功能模块 + */ + +import { state, elements } from './state.js'; +import { escapeHtml, formatBytes } from './common.js'; +import { downloadJson, formatTime } from './utils.js'; + +// Memory monitoring interval +let memoryMonitorInterval = null; + +/** + * Load crash logs from SW + */ +export function loadCrashLogs() { + if (navigator.serviceWorker?.controller) { + navigator.serviceWorker.controller.postMessage({ + type: 'SW_DEBUG_GET_CRASH_SNAPSHOTS' + }); + } +} + +/** + * Clear crash logs + */ +export function handleClearCrashLogs() { + if (navigator.serviceWorker?.controller) { + navigator.serviceWorker.controller.postMessage({ + type: 'SW_DEBUG_CLEAR_CRASH_SNAPSHOTS' + }); + } + state.crashLogs = []; + renderCrashLogs(); +} + +/** + * Get filtered crash logs based on current filters + */ +export function getFilteredCrashLogs() { + const typeFilter = elements.filterCrashType?.value || ''; + + let filteredLogs = state.crashLogs; + + if (typeFilter) { + filteredLogs = filteredLogs.filter(l => l.type === typeFilter); + } + + return filteredLogs; +} + +/** + * Render crash logs + */ +export function renderCrashLogs() { + const filteredLogs = getFilteredCrashLogs(); + + // Update count + updateCrashCount(); + + if (filteredLogs.length === 0) { + elements.crashLogsContainer.innerHTML = ` +
+ 💥 +

暂无内存日志

+

页面启动、内存超限、错误和关闭时的快照会自动记录

+
+ `; + return; + } + + elements.crashLogsContainer.innerHTML = ''; + filteredLogs.forEach(log => { + const isExpanded = state.expandedCrashIds.has(log.id); + const entry = createCrashEntry(log, isExpanded, (id, expanded) => { + if (expanded) { + state.expandedCrashIds.add(id); + } else { + state.expandedCrashIds.delete(id); + } + }); + elements.crashLogsContainer.appendChild(entry); + }); +} + +/** + * Create a crash log entry element + */ +function createCrashEntry(log, isExpanded, onToggle) { + const entry = document.createElement('div'); + // 使用与 Fetch 日志相同的样式 + entry.className = 'log-entry memory-entry' + (isExpanded ? ' expanded' : ''); + entry.dataset.id = log.id; + + const time = formatTime(log.timestamp); + + const typeLabels = { + startup: '启动', + periodic: '定期', + error: '错误', + beforeunload: '关闭', + freeze: '卡死', + whitescreen: '白屏', + longtask: '长任务' + }; + + const typeLabel = typeLabels[log.type] || log.type; + const isError = log.type === 'error'; + const isWarning = log.type === 'freeze' || log.type === 'whitescreen' || log.type === 'longtask'; + + // 类型徽章样式类 + const typeClass = isError ? 'error' : (isWarning ? 'warning' : 'normal'); + + // Memory info - 简化显示 + let memoryPercent = 0; + if (log.memory) { + memoryPercent = ((log.memory.usedJSHeapSize / log.memory.jsHeapSizeLimit) * 100); + } + + // Page stats - 简化为一行 + let statsText = ''; + if (log.pageStats) { + const stats = log.pageStats; + statsText = `DOM ${stats.domNodeCount || 0} · Img ${stats.imageCount || 0}`; + if (stats.plaitElementCount !== undefined) { + statsText += ` · Plait ${stats.plaitElementCount}`; + } + } + + // Performance info - 完整显示 + let perfText = ''; + if (log.performance) { + const parts = []; + if (log.performance.longTaskDuration) { + parts.push(`任务时长: ${log.performance.longTaskDuration.toFixed(0)}ms`); + } + if (log.performance.freezeDuration) { + parts.push(`卡死时长: ${(log.performance.freezeDuration / 1000).toFixed(1)}s`); + } + if (log.performance.fps !== undefined) { + parts.push(`FPS: ${log.performance.fps}`); + } + if (parts.length > 0) { + perfText = parts.join(' | '); + } + } + + // Error preview - show full message (will wrap if needed) + let errorPreview = ''; + if (log.error) { + errorPreview = `${escapeHtml(log.error.message || '')}`; + } + + // 完整内存显示 + let memoryText = ''; + if (log.memory) { + const usedMB = (log.memory.usedJSHeapSize / (1024 * 1024)).toFixed(1); + const limitMB = (log.memory.jsHeapSizeLimit / (1024 * 1024)).toFixed(1); + memoryText = `${usedMB} MB / ${limitMB} MB (${memoryPercent.toFixed(1)}%)`; + } + + entry.innerHTML = ` +
+ ▶ + ${time} + ${typeLabel} + ${perfText ? `⚡ ${perfText}` : ''} + ${memoryText ? `📊 ${memoryText}` : ''} + ${statsText ? `📄 ${statsText}` : ''} + ${errorPreview} +
+
+
+

基本信息

+
ID: ${log.id}
+时间: ${new Date(log.timestamp).toLocaleString('zh-CN')}
+URL: ${log.url || '-'}
+
+ ${log.memory ? ` +
+

内存信息

+
已用: ${(log.memory.usedJSHeapSize / (1024 * 1024)).toFixed(1)} MB
+总计: ${(log.memory.totalJSHeapSize / (1024 * 1024)).toFixed(1)} MB
+限制: ${(log.memory.jsHeapSizeLimit / (1024 * 1024)).toFixed(1)} MB
+使用率: ${memoryPercent.toFixed(1)}%
+
+ ` : ''} + ${log.pageStats ? ` +
+

页面统计

+
DOM节点: ${log.pageStats.domNodeCount || 0}
+Canvas: ${log.pageStats.canvasCount || 0}
+图片: ${log.pageStats.imageCount || 0}
+视频: ${log.pageStats.videoCount || 0}
+iframe: ${log.pageStats.iframeCount || 0}${log.pageStats.plaitElementCount !== undefined ? `\nPlait元素: ${log.pageStats.plaitElementCount}` : ''}
+
+ ` : ''} + ${log.performance ? ` +
+

性能信息

+
${log.performance.longTaskDuration ? `长任务时长: ${log.performance.longTaskDuration.toFixed(0)}ms` : ''}${log.performance.freezeDuration ? `卡死时长: ${(log.performance.freezeDuration / 1000).toFixed(1)}s` : ''}${log.performance.fps !== undefined ? `\nFPS: ${log.performance.fps}` : ''}
+
+ ` : ''} + ${log.error ? ` +
+

错误信息

+
${log.error.type}: ${escapeHtml(log.error.message)}
+ ${log.error.stack ? `
${escapeHtml(log.error.stack)}
` : ''} +
+ ` : ''} + ${log.customData ? ` +
+

自定义数据

+
${JSON.stringify(log.customData, null, 2)}
+
+ ${log.type === 'longtask' ? ` +
+

💡 如何定位长任务来源

+
    +
  1. 打开 Chrome DevTools → Performance 面板
  2. +
  3. 点击录制按钮 ⏺,复现长任务操作
  4. +
  5. 停止录制,在 Main 线程中找到黄色/红色的长条(> 50ms)
  6. +
  7. 点击展开查看详细的函数调用栈
  8. +
+
+ ` : ''} + ` : ''} +
+ `; + + // Toggle expand on header click + const toggleBtn = entry.querySelector('.log-toggle'); + toggleBtn.addEventListener('click', (e) => { + e.stopPropagation(); + const nowExpanded = entry.classList.toggle('expanded'); + onToggle(log.id, nowExpanded); + }); + + return entry; +} + +/** + * Update crash log count indicator + */ +export function updateCrashCount() { + const errorCount = state.crashLogs.filter(l => l.type === 'error').length; + + if (errorCount > 0) { + elements.crashCountEl.innerHTML = `(${errorCount} errors)`; + } else { + elements.crashCountEl.textContent = `(${state.crashLogs.length})`; + } +} + +/** + * Copy filtered crash logs to clipboard with all details + */ +export async function handleCopyCrashLogs() { + const filteredLogs = getFilteredCrashLogs(); + + if (filteredLogs.length === 0) { + alert('没有可复制的日志'); + return; + } + + const typeLabels = { + startup: '启动', + periodic: '定期', + error: '错误', + beforeunload: '关闭', + freeze: '卡死', + whitescreen: '白屏', + longtask: '长任务' + }; + + // Format logs as text with all details + const logText = filteredLogs.map(log => { + const time = new Date(log.timestamp).toLocaleString('zh-CN', { hour12: false }); + const type = log.type || 'unknown'; + const typeLabel = typeLabels[type] || type; + + const lines = []; + lines.push(`═══════════════════════════════════════════════════`); + lines.push(`${time} [${typeLabel}]`); + lines.push(`───────────────────────────────────────────────────`); + + // 基本信息 + lines.push(`【基本信息】`); + lines.push(` ID: ${log.id}`); + lines.push(` 时间: ${time}`); + if (log.url) { + lines.push(` URL: ${log.url}`); + } + + // 内存信息 + if (log.memory) { + const usedMB = (log.memory.usedJSHeapSize / (1024 * 1024)).toFixed(1); + const totalMB = (log.memory.totalJSHeapSize / (1024 * 1024)).toFixed(1); + const limitMB = (log.memory.jsHeapSizeLimit / (1024 * 1024)).toFixed(1); + const percent = ((log.memory.usedJSHeapSize / log.memory.jsHeapSizeLimit) * 100).toFixed(1); + lines.push(``); + lines.push(`【内存信息】`); + lines.push(` 已用: ${usedMB} MB`); + lines.push(` 总计: ${totalMB} MB`); + lines.push(` 限制: ${limitMB} MB`); + lines.push(` 使用率: ${percent}%`); + } + + // 页面统计 + if (log.pageStats) { + const stats = log.pageStats; + lines.push(``); + lines.push(`【页面统计】`); + lines.push(` DOM节点: ${stats.domNodeCount || 0}`); + lines.push(` Canvas: ${stats.canvasCount || 0}`); + lines.push(` 图片: ${stats.imageCount || 0}`); + lines.push(` 视频: ${stats.videoCount || 0}`); + lines.push(` iframe: ${stats.iframeCount || 0}`); + if (stats.plaitElementCount !== undefined) { + lines.push(` Plait元素: ${stats.plaitElementCount}`); + } + } + + // 性能信息 + if (log.performance) { + const perf = log.performance; + const perfParts = []; + if (perf.longTaskDuration) { + perfParts.push(`长任务时长: ${perf.longTaskDuration.toFixed(0)}ms`); + } + if (perf.freezeDuration) { + perfParts.push(`卡死时长: ${(perf.freezeDuration / 1000).toFixed(1)}s`); + } + if (perf.fps !== undefined) { + perfParts.push(`FPS: ${perf.fps}`); + } + if (perfParts.length > 0) { + lines.push(``); + lines.push(`【性能信息】`); + perfParts.forEach(p => lines.push(` ${p}`)); + } + } + + // 错误信息 + if (log.error) { + lines.push(``); + lines.push(`【错误信息】`); + lines.push(` 类型: ${log.error.type || 'Error'}`); + lines.push(` 消息: ${log.error.message}`); + if (log.error.stack) { + lines.push(` 堆栈:`); + log.error.stack.split('\n').forEach(line => { + lines.push(` ${line}`); + }); + } + } + + // 自定义数据 + if (log.customData) { + lines.push(``); + lines.push(`【自定义数据】`); + lines.push(` ${JSON.stringify(log.customData, null, 2).split('\n').join('\n ')}`); + } + + return lines.join('\n'); + }).join('\n\n'); + + try { + await navigator.clipboard.writeText(logText); + const btn = elements.copyCrashLogsBtn; + const originalText = btn.textContent; + btn.textContent = '✅ 已复制'; + setTimeout(() => { + btn.textContent = originalText; + }, 1500); + } catch (err) { + console.error('Failed to copy:', err); + alert('复制失败'); + } +} + +/** + * Export crash logs as JSON + */ +export function handleExportCrashLogs() { + if (state.crashLogs.length === 0) { + alert('没有可导出的内存日志'); + return; + } + + const exportData = { + exportTime: new Date().toISOString(), + userAgent: navigator.userAgent, + url: location.href, + memorySnapshots: state.crashLogs, + }; + + const filename = `memory-logs-${new Date().toISOString().slice(0, 19).replace(/[:-]/g, '')}.json`; + downloadJson(exportData, filename); +} + +// ==================== Memory Monitoring ==================== + +/** + * Update memory display + */ +export function updateMemoryDisplay() { + // Check for performance.memory (Chrome only) + if (typeof performance !== 'undefined' && 'memory' in performance) { + const mem = performance.memory; + const usedMB = (mem.usedJSHeapSize / (1024 * 1024)).toFixed(1); + const totalMB = (mem.totalJSHeapSize / (1024 * 1024)).toFixed(1); + const limitMB = (mem.jsHeapSizeLimit / (1024 * 1024)).toFixed(0); + const percent = ((mem.usedJSHeapSize / mem.jsHeapSizeLimit) * 100).toFixed(1); + + if (elements.memoryUsed) elements.memoryUsed.textContent = `${usedMB} MB`; + if (elements.memoryTotal) elements.memoryTotal.textContent = `${totalMB} MB`; + if (elements.memoryLimit) elements.memoryLimit.textContent = `${limitMB} MB`; + if (elements.memoryPercent) elements.memoryPercent.textContent = `${percent}%`; + + // Warning if usage is high + if (parseFloat(percent) > 70) { + if (elements.memoryWarning) elements.memoryWarning.style.display = 'block'; + if (elements.memoryPercent) elements.memoryPercent.style.color = 'var(--error-color)'; + } else { + if (elements.memoryWarning) elements.memoryWarning.style.display = 'none'; + if (elements.memoryPercent) elements.memoryPercent.style.color = ''; + } + + if (elements.memoryNotSupported) elements.memoryNotSupported.style.display = 'none'; + } else { + if (elements.memoryUsed) elements.memoryUsed.textContent = '-'; + if (elements.memoryTotal) elements.memoryTotal.textContent = '-'; + if (elements.memoryLimit) elements.memoryLimit.textContent = '-'; + if (elements.memoryPercent) elements.memoryPercent.textContent = '-'; + if (elements.memoryNotSupported) elements.memoryNotSupported.style.display = 'block'; + } + + // Update timestamp + const now = new Date(); + if (elements.memoryUpdateTime) { + elements.memoryUpdateTime.textContent = `更新: ${now.toLocaleTimeString('zh-CN', { hour12: false })}`; + } +} + +/** + * Start memory monitoring + */ +export function startMemoryMonitoring() { + // Initial update + updateMemoryDisplay(); + + // Update every 2 seconds + if (memoryMonitorInterval) { + clearInterval(memoryMonitorInterval); + } + memoryMonitorInterval = setInterval(updateMemoryDisplay, 2000); +} + +/** + * Stop memory monitoring + */ +export function stopMemoryMonitoring() { + if (memoryMonitorInterval) { + clearInterval(memoryMonitorInterval); + memoryMonitorInterval = null; + } +} diff --git a/apps/web/public/sw-debug/state.js b/apps/web/public/sw-debug/state.js new file mode 100644 index 00000000..8a76321a --- /dev/null +++ b/apps/web/public/sw-debug/state.js @@ -0,0 +1,188 @@ +/** + * SW Debug Panel - Application State + * 应用状态管理和 DOM 元素缓存 + */ + +// Application State +export const state = { + debugEnabled: false, + logs: [], + consoleLogs: [], + postmessageLogs: [], + crashLogs: [], // Crash snapshots + llmapiLogs: [], // LLM API call logs + swStatus: null, // SW status data for export + autoScroll: true, + activeTab: 'fetch', + expandedLogIds: new Set(), // Track expanded fetch log IDs + expandedStackIds: new Set(), // Track expanded console stack IDs + expandedPmIds: new Set(), // Track expanded postmessage log IDs + expandedCrashIds: new Set(), // Track expanded crash log IDs + expandedLLMApiIds: new Set(), // Track expanded LLM API log IDs + // New states for enhanced features + bookmarkedLogIds: new Set(), // Bookmarked/starred log IDs + showBookmarksOnly: false, // Filter to show only bookmarked logs + filterSlowOnly: false, // Filter to show only slow requests (>1s) + isSelectMode: false, // Batch select mode + selectedLogIds: new Set(), // Selected log IDs for batch operations + isPaused: false, // Pause real-time updates + pendingLogs: [], // Logs received while paused + hasNewErrors: false, // Track new errors for tab indicator + hasNewCrashLogs: false, // Track new crash logs + hasNewLLMApiErrors: false, // Track new LLM API errors + // Settings + settings: { + maxLogs: 500, + autoCleanMinutes: 0, + keepBookmarks: true, + }, + autoCleanTimerId: null, + // Analysis mode - for debugging user-provided logs without local SW connection + isAnalysisMode: false, + importedLogData: null, // Imported log data from user + // Live logs buffer - stores incoming logs while in analysis mode + // So they can be restored when exiting analysis mode + liveLogs: { + logs: [], + consoleLogs: [], + postmessageLogs: [], + crashLogs: [], + llmapiLogs: [], + }, +}; + +// DOM Elements cache +export let elements = {}; + +/** + * Cache DOM elements + */ +export function cacheElements() { + elements = { + swStatus: document.getElementById('swStatus'), + toggleDebugBtn: document.getElementById('toggleDebug'), + exportLogsBtn: document.getElementById('exportLogs'), + clearLogsBtn: document.getElementById('clearLogs'), + refreshStatusBtn: document.getElementById('refreshStatus'), + refreshCacheBtn: document.getElementById('refreshCache'), + enableDebugBtn: document.getElementById('enableDebugBtn'), + logsContainer: document.getElementById('logsContainer'), + consoleLogsContainer: document.getElementById('consoleLogsContainer'), + filterType: document.getElementById('filterType'), + filterStatus: document.getElementById('filterStatus'), + filterTimeRange: document.getElementById('filterTimeRange'), + filterUrl: document.getElementById('filterUrl'), + filterUrlRegex: document.getElementById('filterUrlRegex'), + togglePauseBtn: document.getElementById('togglePause'), + toggleSelectModeBtn: document.getElementById('toggleSelectMode'), + batchActionsEl: document.getElementById('batchActions'), + selectAllBtn: document.getElementById('selectAll'), + batchBookmarkBtn: document.getElementById('batchBookmark'), + batchDeleteBtn: document.getElementById('batchDelete'), + selectedCountEl: document.getElementById('selectedCount'), + fetchCountEl: document.getElementById('fetchCount'), + exportFetchCSVBtn: document.getElementById('exportFetchCSV'), + showShortcutsBtn: document.getElementById('showShortcuts'), + showBookmarksOnly: document.getElementById('showBookmarksOnly'), + shortcutsModalOverlay: document.getElementById('shortcutsModalOverlay'), + closeShortcutsModalBtn: document.getElementById('closeShortcutsModal'), + toggleThemeBtn: document.getElementById('toggleTheme'), + themeIcon: document.getElementById('themeIcon'), + showSettingsBtn: document.getElementById('showSettings'), + settingsModalOverlay: document.getElementById('settingsModalOverlay'), + closeSettingsModalBtn: document.getElementById('closeSettingsModal'), + saveSettingsBtn: document.getElementById('saveSettings'), + settingMaxLogs: document.getElementById('settingMaxLogs'), + settingAutoClean: document.getElementById('settingAutoClean'), + settingKeepBookmarks: document.getElementById('settingKeepBookmarks'), + // Stats elements + statTotalRequests: document.getElementById('statTotalRequests'), + statSuccessRate: document.getElementById('statSuccessRate'), + statAvgDuration: document.getElementById('statAvgDuration'), + statCacheHit: document.getElementById('statCacheHit'), + statSlowRequests: document.getElementById('statSlowRequests'), + statSlowRequestsWrapper: document.getElementById('statSlowRequestsWrapper'), + // Duration chart elements + chartFast: document.getElementById('chartFast'), + chartMedium: document.getElementById('chartMedium'), + chartSlow: document.getElementById('chartSlow'), + chartVerySlow: document.getElementById('chartVerySlow'), + filterConsoleLevel: document.getElementById('filterConsoleLevel'), + filterConsoleText: document.getElementById('filterConsoleText'), + clearConsoleLogsBtn: document.getElementById('clearConsoleLogs'), + copyConsoleLogsBtn: document.getElementById('copyConsoleLogs'), + autoScrollCheckbox: document.getElementById('autoScroll'), + consoleCountEl: document.getElementById('consoleCount'), + postmessageCountEl: document.getElementById('postmessageCount'), + postmessageLogsContainer: document.getElementById('postmessageLogsContainer'), + filterMessageDirection: document.getElementById('filterMessageDirection'), + filterMessageTypeSelect: document.getElementById('filterMessageTypeSelect'), + filterPmTimeRange: document.getElementById('filterPmTimeRange'), + filterMessageType: document.getElementById('filterMessageType'), + // Error dot indicators + consoleErrorDot: document.getElementById('consoleErrorDot'), + llmapiErrorDot: document.getElementById('llmapiErrorDot'), + crashErrorDot: document.getElementById('crashErrorDot'), + clearPostmessageLogsBtn: document.getElementById('clearPostmessageLogs'), + copyPostmessageLogsBtn: document.getElementById('copyPostmessageLogs'), + copyFetchLogsBtn: document.getElementById('copyFetchLogs'), + // Status panel elements + swVersion: document.getElementById('swVersion'), + debugMode: document.getElementById('debugMode'), + pendingImages: document.getElementById('pendingImages'), + pendingVideos: document.getElementById('pendingVideos'), + videoBlobCache: document.getElementById('videoBlobCache'), + completedRequests: document.getElementById('completedRequests'), + workflowHandler: document.getElementById('workflowHandler'), + debugLogsCount: document.getElementById('debugLogsCount'), + failedDomainsSection: document.getElementById('failedDomainsSection'), + failedDomains: document.getElementById('failedDomains'), + cacheList: document.getElementById('cacheList'), + // Export modal elements + exportModalOverlay: document.getElementById('exportModalOverlay'), + closeExportModalBtn: document.getElementById('closeExportModal'), + cancelExportBtn: document.getElementById('cancelExport'), + doExportBtn: document.getElementById('doExport'), + selectAllExport: document.getElementById('selectAllExport'), + // LLM API logs elements + llmapiLogsContainer: document.getElementById('llmapiLogsContainer'), + filterLLMApiType: document.getElementById('filterLLMApiType'), + filterLLMApiStatus: document.getElementById('filterLLMApiStatus'), + refreshLLMApiLogsBtn: document.getElementById('refreshLLMApiLogs'), + copyLLMApiLogsBtn: document.getElementById('copyLLMApiLogs'), + exportLLMApiLogsBtn: document.getElementById('exportLLMApiLogs'), + clearLLMApiLogsBtn: document.getElementById('clearLLMApiLogs'), + // Crash logs elements + crashCountEl: document.getElementById('crashCount'), + crashLogsContainer: document.getElementById('crashLogsContainer'), + filterCrashType: document.getElementById('filterCrashType'), + refreshCrashLogsBtn: document.getElementById('refreshCrashLogs'), + copyCrashLogsBtn: document.getElementById('copyCrashLogs'), + clearCrashLogsBtn: document.getElementById('clearCrashLogs'), + exportCrashLogsBtn: document.getElementById('exportCrashLogs'), + // Memory monitoring elements + memoryUsed: document.getElementById('memoryUsed'), + memoryTotal: document.getElementById('memoryTotal'), + memoryLimit: document.getElementById('memoryLimit'), + memoryPercent: document.getElementById('memoryPercent'), + memoryWarning: document.getElementById('memoryWarning'), + memoryNotSupported: document.getElementById('memoryNotSupported'), + memoryUpdateTime: document.getElementById('memoryUpdateTime'), + // Analysis mode elements + toggleAnalysisModeBtn: document.getElementById('toggleAnalysisMode'), + importLogsBtn: document.getElementById('importLogs'), + importLogsInput: document.getElementById('importLogsInput'), + analysisModeIndicator: document.getElementById('analysisModeIndicator'), + panelTitle: document.getElementById('panelTitle'), + // Backup button + backupDataBtn: document.getElementById('backupData'), + }; +} + +/** + * Update elements reference after DOM manipulation + * Used when restoring debug mode left panel + */ +export function updateElements(newElements) { + Object.assign(elements, newElements); +} diff --git a/apps/web/public/sw-debug/sw-communication.js b/apps/web/public/sw-debug/sw-communication.js index c7290c01..4a3a8225 100644 --- a/apps/web/public/sw-debug/sw-communication.js +++ b/apps/web/public/sw-debug/sw-communication.js @@ -142,6 +142,13 @@ export function clearPostMessageLogs() { sendToSW('SW_DEBUG_CLEAR_POSTMESSAGE_LOGS'); } +/** + * Request cache stats from SW + */ +export function loadCacheStats() { + sendToSW('SW_DEBUG_GET_CACHE_STATS'); +} + /** @type {ServiceWorkerRegistration|null} */ let cachedRegistration = null; diff --git a/apps/web/public/sw-debug/theme-settings.js b/apps/web/public/sw-debug/theme-settings.js new file mode 100644 index 00000000..163d3052 --- /dev/null +++ b/apps/web/public/sw-debug/theme-settings.js @@ -0,0 +1,224 @@ +/** + * SW Debug Panel - Theme and Settings + * 主题切换和设置管理模块 + */ + +import { state, elements } from './state.js'; + +// Forward declarations - will be set by app.js +let renderLogs = null; + +/** + * Set callbacks from app.js + */ +export function setThemeSettingsCallbacks(callbacks) { + renderLogs = callbacks.renderLogs; +} + +/** + * Toggle theme between light and dark + */ +export function toggleTheme() { + const currentTheme = document.documentElement.getAttribute('data-theme'); + const newTheme = currentTheme === 'dark' ? 'light' : 'dark'; + document.documentElement.setAttribute('data-theme', newTheme); + updateThemeIcon(newTheme); + saveTheme(newTheme); +} + +/** + * Update theme icon (SVG) + */ +export function updateThemeIcon(theme) { + if (elements.themeIcon) { + if (theme === 'dark') { + // Sun icon for dark mode (click to switch to light) + elements.themeIcon.innerHTML = ''; + } else { + // Moon icon for light mode (click to switch to dark) + elements.themeIcon.innerHTML = ''; + } + } +} + +/** + * Save theme preference + */ +function saveTheme(theme) { + try { + localStorage.setItem('sw-debug-theme', theme); + } catch (e) { + console.error('Failed to save theme:', e); + } +} + +/** + * Load saved theme preference + */ +export function loadTheme() { + try { + const savedTheme = localStorage.getItem('sw-debug-theme'); + // Also check system preference + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + const theme = savedTheme || (prefersDark ? 'dark' : 'light'); + document.documentElement.setAttribute('data-theme', theme); + updateThemeIcon(theme); + } catch (e) { + console.error('Failed to load theme:', e); + } +} + +/** + * Show settings modal + */ +export function showSettingsModal() { + if (elements.settingsModalOverlay) { + // Populate current values + if (elements.settingMaxLogs) { + elements.settingMaxLogs.value = state.settings.maxLogs.toString(); + } + if (elements.settingAutoClean) { + elements.settingAutoClean.value = state.settings.autoCleanMinutes.toString(); + } + if (elements.settingKeepBookmarks) { + elements.settingKeepBookmarks.checked = state.settings.keepBookmarks; + } + elements.settingsModalOverlay.style.display = 'flex'; + } +} + +/** + * Close settings modal + */ +export function closeSettingsModal() { + if (elements.settingsModalOverlay) { + elements.settingsModalOverlay.style.display = 'none'; + } +} + +/** + * Save settings + */ +export function saveSettings() { + const newSettings = { + maxLogs: parseInt(elements.settingMaxLogs?.value || '500'), + autoCleanMinutes: parseInt(elements.settingAutoClean?.value || '0'), + keepBookmarks: elements.settingKeepBookmarks?.checked ?? true, + }; + + state.settings = newSettings; + + // Save to localStorage + try { + localStorage.setItem('sw-debug-settings', JSON.stringify(newSettings)); + } catch (e) { + console.error('Failed to save settings:', e); + } + + // Apply new max logs limit + applyMaxLogsLimit(); + + // Setup auto clean timer + setupAutoCleanTimer(); + + closeSettingsModal(); +} + +/** + * Load saved settings + */ +export function loadSettings() { + try { + const saved = localStorage.getItem('sw-debug-settings'); + if (saved) { + state.settings = { ...state.settings, ...JSON.parse(saved) }; + } + } catch (e) { + console.error('Failed to load settings:', e); + } + + // Setup auto clean timer based on saved settings + setupAutoCleanTimer(); +} + +/** + * Apply max logs limit to current logs + */ +function applyMaxLogsLimit() { + const maxLogs = state.settings.maxLogs; + + if (state.logs.length > maxLogs) { + // Keep bookmarked logs if setting is enabled + if (state.settings.keepBookmarks) { + const bookmarked = state.logs.filter(l => state.bookmarkedLogIds.has(l.id)); + const nonBookmarked = state.logs.filter(l => !state.bookmarkedLogIds.has(l.id)); + state.logs = [...bookmarked, ...nonBookmarked.slice(0, maxLogs - bookmarked.length)]; + } else { + state.logs = state.logs.slice(0, maxLogs); + } + if (renderLogs) renderLogs(); + } +} + +/** + * Setup auto clean timer + */ +export function setupAutoCleanTimer() { + // Clear existing timer + if (state.autoCleanTimerId) { + clearInterval(state.autoCleanTimerId); + state.autoCleanTimerId = null; + } + + const minutes = state.settings.autoCleanMinutes; + if (minutes <= 0) return; + + // Run cleanup every minute + state.autoCleanTimerId = setInterval(() => { + autoCleanLogs(); + }, 60000); + + // Also run immediately + autoCleanLogs(); +} + +/** + * Auto clean old logs + */ +function autoCleanLogs() { + const minutes = state.settings.autoCleanMinutes; + if (minutes <= 0) return; + + const cutoffTime = Date.now() - (minutes * 60 * 1000); + const beforeCount = state.logs.length; + + if (state.settings.keepBookmarks) { + state.logs = state.logs.filter(l => + l.timestamp >= cutoffTime || state.bookmarkedLogIds.has(l.id) + ); + } else { + state.logs = state.logs.filter(l => l.timestamp >= cutoffTime); + } + + if (state.logs.length !== beforeCount) { + if (renderLogs) renderLogs(); + } +} + +/** + * Show shortcuts modal + */ +export function showShortcutsModal() { + if (elements.shortcutsModalOverlay) { + elements.shortcutsModalOverlay.style.display = 'flex'; + } +} + +/** + * Close shortcuts modal + */ +export function closeShortcutsModal() { + if (elements.shortcutsModalOverlay) { + elements.shortcutsModalOverlay.style.display = 'none'; + } +} diff --git a/apps/web/public/sw-debug/toast.js b/apps/web/public/sw-debug/toast.js new file mode 100644 index 00000000..4dd70db4 --- /dev/null +++ b/apps/web/public/sw-debug/toast.js @@ -0,0 +1,41 @@ +/** + * SW Debug Panel - Toast Notifications + * Toast 通知组件 + */ + +import { escapeHtml } from './common.js'; + +/** + * 显示 Toast 通知 + * @param {string} message - 通知消息 + * @param {string} type - 类型: 'success' | 'error' | 'warning' | 'info' + * @param {number} duration - 持续时间(毫秒),默认 3000 + */ +export function showToast(message, type = 'success', duration = 3000) { + const icons = { + success: '✅', + error: '❌', + warning: '⚠️', + info: 'ℹ️' + }; + + const notification = document.createElement('div'); + notification.className = `import-notification toast-notification toast-${type}`; + notification.innerHTML = ` +
+ ${icons[type] || icons.info} +
+

${escapeHtml(message)}

+
+ +
+ `; + + document.body.appendChild(notification); + + // 自动消失 + setTimeout(() => { + notification.classList.add('fade-out'); + setTimeout(() => notification.remove(), 300); + }, duration); +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 05d95d49..441b0116 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -46,6 +46,7 @@ function precacheManifestPlugin(): Plugin { /\.map$/, // Source maps /precache-manifest\.json$/, // 自身 /sw\.js$/, // Service Worker 本身不需要预缓存 + /sw-debug\.html$/, // 调试面板,仅在访问时加载 ]; // 总是包含的关键文件 const ALWAYS_INCLUDE = [ @@ -66,8 +67,10 @@ function precacheManifestPlugin(): Plugin { const relativePath = path.join(base, entry.name).replace(/\\/g, '/'); if (entry.isDirectory()) { - // 跳过 product_showcase 等大型资源目录 - if (!['product_showcase', 'help_tooltips'].includes(entry.name)) { + // 跳过不需要预缓存的目录 + // - product_showcase, help_tooltips: 大型资源目录 + // - sw-debug: 调试面板,仅在访问 /sw-debug.html 时加载 + if (!['product_showcase', 'help_tooltips', 'sw-debug'].includes(entry.name)) { scanDir(fullPath, relativePath); } } else if (entry.isFile()) { diff --git a/docs/CODING_RULES.md b/docs/CODING_RULES.md index 619340be..4982f4ce 100644 --- a/docs/CODING_RULES.md +++ b/docs/CODING_RULES.md @@ -3205,6 +3205,59 @@ const handleSortClick = () => { - 避免在 render 中创建新对象/函数 - 对长列表考虑使用虚拟化 +#### 预缓存配置规范 + +**场景**: 使用 Service Worker 预缓存静态资源时,需要合理配置排除列表 + +❌ **错误示例**: +```typescript +// vite.config.ts - 错误:没有排除调试工具和大型资源 +function precacheManifestPlugin(): Plugin { + const EXCLUDE_PATTERNS = [ + /stats\.html$/, + /\.map$/, + ]; + // 扫描所有目录,包括 sw-debug/、product_showcase/ 等 + scanDir(outDir); // ❌ 会将所有文件加入预缓存 +} +``` + +✅ **正确示例**: +```typescript +// vite.config.ts - 正确:明确排除非核心资源 +function precacheManifestPlugin(): Plugin { + const EXCLUDE_PATTERNS = [ + /stats\.html$/, + /\.map$/, + /sw-debug\.html$/, // ✅ 排除调试面板入口 + ]; + + // 跳过不需要预缓存的目录 + const SKIP_DIRECTORIES = [ + 'product_showcase', // 大型展示资源 + 'help_tooltips', // 帮助提示图片 + 'sw-debug', // 调试面板(仅在访问时加载) + ]; + + if (entry.isDirectory()) { + if (!SKIP_DIRECTORIES.includes(entry.name)) { + scanDir(fullPath, relativePath); + } + } +} +``` + +**应该排除的资源类型**: +1. **调试工具** - 如 `sw-debug/`,仅开发/排查时访问 +2. **大型展示资源** - 如 `product_showcase/`,非核心功能 +3. **帮助/文档资源** - 如 `help_tooltips/`,按需加载即可 +4. **Source Maps** - 生产环境不需要预缓存 + +**原因**: 预缓存会在主应用启动时由 Service Worker 下载所有列表中的文件。如果包含非核心资源,会: +- 增加首次加载时间和带宽消耗 +- 占用用户设备存储空间 +- 影响主应用的启动性能和用户体验 + ### 安全指南 - 验证和清理所有用户输入 - 永远不要硬编码敏感信息(API keys 等)