refactor(sw-debug): 模块化重构调试面板

- 将 app.js (4865行) 拆分为 21 个独立模块
- 新增模块: state, common, toast, backup, analysis-mode, theme-settings,
  llmapi-logs, memory-logs, export-modal, fetch-logs, indexeddb
- 排除 sw-debug 目录从预缓存清单,避免影响主应用性能
- 更换分析模式图标为文档图标(原为搜索图标易混淆)
- 添加预缓存配置规范到编码规则文档
This commit is contained in:
liquidliang committed 2026-01-23 10:44:58 +08:00
1 parent 2504d9b7bb
commit 33aa2ed122
16 files changed
+4293 -3953

No files matched your search

+1 -1
View File
@@ -26,7 +26,7 @@
<!-- 模式切换按钮 -->
<button id="toggleAnalysisMode" class="icon-btn" title="切换到分析模式(导入用户日志)">
<svg class="icon-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>
</svg>
</button>
<!-- 导入日志按钮 (分析模式下显示) -->
+720
View File
@@ -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 = `
<div class="panel">
<div class="panel-header">
🔍 分析模式
</div>
<div class="panel-content">
<div class="empty-state" style="padding: 20px;">
<span class="icon" style="font-size: 32px;">📋</span>
<p style="margin-top: 12px; color: var(--text-secondary);">请导入用户日志文件</p>
<p style="font-size: 11px; opacity: 0.6; margin-top: 8px;">导入后将显示用户信息</p>
</div>
</div>
</div>
`;
}
/**
* Restore debug mode left panel
*/
function restoreDebugModeLeftPanel() {
const leftPanel = document.querySelector('.left-panel');
if (!leftPanel) return;
// Restore original HTML structure
leftPanel.innerHTML = `
<div class="panel">
<div class="panel-header">
SW 状态信息
</div>
<div class="panel-content">
<div class="stat-grid" id="statusGrid">
<div class="stat-item">
<span class="label">版本</span>
<span class="value" id="swVersion">-</span>
</div>
<div class="stat-item">
<span class="label">调试模式</span>
<span class="value" id="debugMode">关闭</span>
</div>
<div class="stat-item">
<span class="label">Pending 图片请求</span>
<span class="value" id="pendingImages">0</span>
</div>
<div class="stat-item">
<span class="label">Pending 视频请求</span>
<span class="value" id="pendingVideos">0</span>
</div>
<div class="stat-item">
<span class="label">视频 Blob 缓存</span>
<span class="value" id="videoBlobCache">0</span>
</div>
<div class="stat-item">
<span class="label">已完成请求缓存</span>
<span class="value" id="completedRequests">0</span>
</div>
<div class="stat-item">
<span class="label">工作流处理器</span>
<span class="value" id="workflowHandler">未初始化</span>
</div>
<div class="stat-item">
<span class="label">调试日志数</span>
<span class="value" id="debugLogsCount">0</span>
</div>
</div>
<div id="failedDomainsSection" style="margin-top: 16px; display: none;">
<div class="stat-item" style="flex-direction: column; align-items: flex-start;">
<span class="label">失败域名</span>
<div class="failed-domains" id="failedDomains"></div>
</div>
</div>
</div>
</div>
<div class="panel">
<div class="panel-header">
内存监控
<span id="memoryUpdateTime" style="font-size: 11px; opacity: 0.6; margin-left: 10px;"></span>
</div>
<div class="panel-content">
<div class="stat-grid" id="memoryGrid">
<div class="stat-item">
<span class="label">JS 堆已使用</span>
<span class="value" id="memoryUsed">-</span>
</div>
<div class="stat-item">
<span class="label">JS 堆总大小</span>
<span class="value" id="memoryTotal">-</span>
</div>
<div class="stat-item">
<span class="label">JS 堆上限</span>
<span class="value" id="memoryLimit">-</span>
</div>
<div class="stat-item">
<span class="label">使用率</span>
<span class="value" id="memoryPercent">-</span>
</div>
</div>
<p id="memoryWarning" style="display: none; margin-top: 12px; padding: 8px; background: #fff3cd; border-radius: 4px; font-size: 12px; color: #856404;">
⚠️ 内存使用率较高,可能导致页面崩溃
</p>
<p id="memoryNotSupported" style="display: none; margin-top: 8px; font-size: 11px; opacity: 0.6;">
注:performance.memory 仅 Chrome 支持
</p>
</div>
</div>
<div class="panel">
<div class="panel-header">
缓存统计
<button id="refreshCache">刷新</button>
</div>
<div class="panel-content">
<ul class="cache-list" id="cacheList">
<li class="cache-item">
<span class="name">加载中...</span>
</li>
</ul>
</div>
</div>
`;
// 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 = `
<div class="panel">
<div class="panel-header">
👤 用户信息
</div>
<div class="panel-content">
<div class="stat-grid">
<div class="stat-item" style="grid-column: 1 / -1;">
<span class="label">访问地址</span>
<span class="value" style="font-size: 11px; word-break: break-all;" title="${escapeHtml(url)}">${escapeHtml(truncateUrl(url, 40))}</span>
</div>
<div class="stat-item">
<span class="label">浏览器</span>
<span class="value">${escapeHtml(uaInfo.browser)}</span>
</div>
<div class="stat-item">
<span class="label">系统</span>
<span class="value">${escapeHtml(uaInfo.os)}</span>
</div>
<div class="stat-item">
<span class="label">导出时间</span>
<span class="value" style="font-size: 11px;">${escapeHtml(exportTime)}</span>
</div>
<div class="stat-item">
<span class="label">日志总数</span>
<span class="value">${totalLogs}</span>
</div>
</div>
<div style="margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border-color);">
<details style="font-size: 11px;">
<summary style="cursor: pointer; color: var(--text-secondary);">完整 User-Agent</summary>
<p style="margin-top: 8px; word-break: break-all; color: var(--text-secondary); line-height: 1.4;">${escapeHtml(userAgent)}</p>
</details>
</div>
</div>
</div>
<div class="panel">
<div class="panel-header">
📊 导出时状态
</div>
<div class="panel-content">
<div class="stat-grid">
<div class="stat-item">
<span class="label">SW 版本</span>
<span class="value">${escapeHtml(swStatus.version || '-')}</span>
</div>
<div class="stat-item">
<span class="label">调试模式</span>
<span class="value">${swStatus.debugModeEnabled ? '开启' : '关闭'}</span>
</div>
<div class="stat-item">
<span class="label">工作流处理器</span>
<span class="value">${swStatus.workflowHandlerInitialized ? '已初始化' : '未初始化'}</span>
</div>
<div class="stat-item">
<span class="label">调试日志数</span>
<span class="value">${swStatus.debugLogsCount || 0}</span>
</div>
</div>
</div>
</div>
${memory.usedMB ? `
<div class="panel">
<div class="panel-header">
💾 内存快照
</div>
<div class="panel-content">
<div class="stat-grid">
<div class="stat-item">
<span class="label">JS 堆已使用</span>
<span class="value">${memory.usedMB} MB</span>
</div>
<div class="stat-item">
<span class="label">JS 堆总大小</span>
<span class="value">${memory.totalMB} MB</span>
</div>
<div class="stat-item">
<span class="label">使用率</span>
<span class="value ${memory.usagePercent > 80 ? 'warning' : ''}">${memory.usagePercent}%</span>
</div>
</div>
</div>
</div>
` : ''}
${Object.keys(cacheStats).length > 0 ? `
<div class="panel">
<div class="panel-header">
📦 缓存快照
</div>
<div class="panel-content">
<ul class="cache-list">
${Object.entries(cacheStats).map(([name, stats]) => `
<li class="cache-item">
<span class="name" title="${escapeHtml(name)}">${escapeHtml(truncateUrl(name, 25))}</span>
<span class="count">${stats.count} 项</span>
<span class="size">${formatBytes(stats.totalSize)}</span>
</li>
`).join('')}
</ul>
</div>
</div>
` : ''}
<div class="panel">
<div class="panel-header">
📋 日志统计
</div>
<div class="panel-content">
<div class="stat-grid">
${logCounts.fetch > 0 ? `<div class="stat-item"><span class="label">Fetch</span><span class="value">${logCounts.fetch}</span></div>` : ''}
${logCounts.console > 0 ? `<div class="stat-item"><span class="label">控制台</span><span class="value">${logCounts.console}</span></div>` : ''}
${logCounts.postmessage > 0 ? `<div class="stat-item"><span class="label">PostMessage</span><span class="value">${logCounts.postmessage}</span></div>` : ''}
${logCounts.memory > 0 ? `<div class="stat-item"><span class="label">内存</span><span class="value">${logCounts.memory}</span></div>` : ''}
${logCounts.llmapi > 0 ? `<div class="stat-item"><span class="label">LLM API</span><span class="value">${logCounts.llmapi}</span></div>` : ''}
${totalLogs === 0 ? `<div class="stat-item" style="grid-column: 1 / -1;"><span class="label">无日志数据</span></div>` : ''}
</div>
</div>
</div>
`;
}
/**
* Show import prompt in logs container
*/
export function showImportPrompt() {
if (elements.logsContainer) {
elements.logsContainer.innerHTML = `
<div class="empty-state analysis-mode-prompt">
<span class="icon">📁</span>
<h3>分析模式</h3>
<p>在此模式下,您可以导入用户提供的日志文件进行分析</p>
<p style="font-size: 12px; opacity: 0.7; margin-bottom: 20px;">
支持从"导出日志"功能生成的 JSON 文件
</p>
<button id="importPromptBtn" class="primary" style="margin-top: 10px;">
<svg class="icon-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 16px; height: 16px; margin-right: 6px;">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12"/>
</svg>
导入日志文件
</button>
<p style="font-size: 11px; opacity: 0.5; margin-top: 16px;">
点击右上角搜索图标可退出分析模式
</p>
</div>
`;
// 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 = `
<div class="import-notification-content">
<span class="icon">✅</span>
<div class="info">
<strong>导入成功</strong>
<p>${filename}</p>
<p class="counts">
共 ${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}` : ''}
</p>
${data.exportTime ? `<p class="export-time">导出时间: ${new Date(data.exportTime).toLocaleString('zh-CN')}</p>` : ''}
</div>
<button class="close" onclick="this.parentElement.parentElement.remove()">×</button>
</div>
`;
document.body.appendChild(notification);
// Auto remove after 5 seconds
setTimeout(() => {
notification.classList.add('fade-out');
setTimeout(() => notification.remove(), 300);
}, 5000);
}
File diff suppressed because it is too large. Load diff
+603
View File
@@ -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<boolean>}
*/
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 = `
<div class="backup-progress-content">
<div class="backup-progress-header">
<span class="backup-progress-icon">📦</span>
<span class="backup-progress-title">正在备份数据</span>
</div>
<div class="backup-progress-bar">
<div class="backup-progress-fill" style="width: 0%"></div>
</div>
<div class="backup-progress-text">准备中...</div>
</div>
`;
document.body.appendChild(container);
return container;
}
/**
* 显示备份成功通知
*/
function showBackupSuccessNotification({ filename, size, stats }) {
const notification = document.createElement('div');
notification.className = 'import-notification backup-notification';
notification.innerHTML = `
<div class="import-notification-content">
<span class="icon">✅</span>
<div class="info">
<strong>备份成功</strong>
<p>${filename}</p>
<p class="counts">
文件大小: ${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}` : ''}
</p>
</div>
<button class="close" onclick="this.parentElement.parentElement.remove()">×</button>
</div>
`;
document.body.appendChild(notification);
// 5 秒后自动消失
setTimeout(() => {
notification.classList.add('fade-out');
setTimeout(() => notification.remove(), 300);
}, 5000);
}
+140
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
/**
* 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';
}
+249
View File
@@ -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();
}
+745
View File
@@ -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(`<span style="color:var(--error-color)">${errorCount} err</span>`);
if (slowCount > 0) parts.push(`<span style="color:var(--warning-color)">${slowCount} slow</span>`);
countText = `(${parts.join(', ')})`;
}
elements.fetchCountEl.innerHTML = countText;
}
if (filteredLogs.length === 0) {
elements.logsContainer.innerHTML = `
<div class="empty-state">
<span class="icon">📋</span>
<p>${state.debugEnabled ? '暂无匹配的日志' : '请先启用调试模式'}</p>
${!state.debugEnabled ? '<button id="enableDebugBtn2" class="primary">启用调试</button>' : ''}
</div>
`;
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 `
<div class="related-request" data-id="${r.id}" style="padding: 4px 8px; cursor: pointer; border-radius: 4px; margin-bottom: 4px; background: var(--bg-tertiary); word-break: break-word;">
<span style="color: var(--text-muted); font-size: 11px;">${time}</span>
<span class="log-status ${statusClass}" style="font-size: 11px; margin-left: 8px;">${status}</span>
<span style="margin-left: 8px; font-size: 12px;">${displayUrl}</span>
<span style="color: var(--text-muted); font-size: 11px; margin-left: 8px;">${duration}</span>
</div>
`;
}).join('');
return `
<div class="detail-section" style="margin-top: 12px;">
<h4>🔗 相关请求 (${related.length})</h4>
<div class="related-requests-list" style="margin-top: 8px; max-height: 200px; overflow-y: auto;">
${items}
</div>
</div>
`;
}
// Make renderRelatedRequests available globally
window.renderRelatedRequests = renderRelatedRequests;
+143
View File
@@ -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);
};
});
}
+529
View File
@@ -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 = `
<div class="empty-state">
<span class="icon">🤖</span>
<p>暂无 LLM API 调用记录</p>
<p style="font-size: 12px; opacity: 0.7;">图片/视频/对话等 AI 接口调用会自动记录</p>
</div>
`;
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 `
<div class="reference-image-item" style="display: inline-flex; flex-direction: column; gap: 4px; border: 1px solid var(--border-color); border-radius: 4px; padding: 4px; background: var(--bg-secondary);">
<div style="width: 120px; height: 120px; display: flex; align-items: center; justify-content: center; overflow: hidden; background: #000; border-radius: 2px;">
<img src="${img.url}" style="max-width: 100%; max-height: 100%; object-fit: contain; cursor: pointer;" onclick="window.open('${img.url}')" title="点击查看原图">
</div>
<div style="font-size: 10px; color: var(--text-muted); display: flex; justify-content: space-between; padding: 0 2px;">
<span>${sizeText}</span>
<span>${dimensions}</span>
</div>
</div>
`;
}).join('');
referenceImagesHtml = `
<div class="detail-section">
<h4>参考图详情</h4>
<div class="reference-images-preview" style="display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px;">
${imagesList}
</div>
</div>
`;
}
entry.innerHTML = `
<div class="log-header">
<span class="log-toggle" title="展开/收起详情"><span class="arrow">▶</span></span>
<span class="log-time">${time}</span>
<span class="log-status ${statusClass}">${statusText}</span>
<span class="log-type-badge">${typeLabel}</span>
<span class="log-type-badge sw-internal">${log.model}</span>
<span class="log-url" title="${escapeHtml(log.prompt || '')}">${escapeHtml(promptPreview)}</span>
<span class="log-duration ${durationClass}">${durationText}</span>
</div>
<div class="log-details">
<div class="detail-section">
<h4>基本信息</h4>
<table class="form-data-table">
<tbody>
<tr>
<td class="form-data-name">ID</td>
<td><span class="form-data-value" style="font-family: monospace; font-size: 11px;">${log.id}</span></td>
</tr>
<tr>
<td class="form-data-name">Endpoint</td>
<td><span class="form-data-value" style="font-family: monospace; font-size: 11px;">${log.endpoint}</span></td>
</tr>
<tr>
<td class="form-data-name">模型</td>
<td><span class="form-data-value">${log.model}</span></td>
</tr>
<tr>
<td class="form-data-name">类型</td>
<td><span class="form-data-value">${log.taskType}</span></td>
</tr>
<tr>
<td class="form-data-name">HTTP 状态</td>
<td><span class="form-data-value">${log.httpStatus || '-'}</span></td>
</tr>
<tr>
<td class="form-data-name">耗时</td>
<td><span class="form-data-value">${durationText}</span></td>
</tr>
${log.hasReferenceImages ? `
<tr>
<td class="form-data-name">参考图</td>
<td><span class="form-data-value">${log.referenceImageCount || 0} 张</span></td>
</tr>
` : ''}
${log.resultType ? `
<tr>
<td class="form-data-name">结果类型</td>
<td><span class="form-data-value">${log.resultType}</span></td>
</tr>
` : ''}
${log.taskId ? `
<tr>
<td class="form-data-name">任务 ID</td>
<td><span class="form-data-value" style="font-family: monospace; font-size: 11px;">${log.taskId}</span></td>
</tr>
` : ''}
${log.resultUrl ? `
<tr>
<td class="form-data-name">结果 URL</td>
<td>
<span class="form-data-value" style="display: flex; align-items: center; gap: 8px;">
<a href="${log.resultUrl}" target="_blank" class="llm-result-url" style="font-family: monospace; font-size: 11px; word-break: break-all; color: var(--primary-color); cursor: pointer;">${log.resultUrl.length > 80 ? log.resultUrl.substring(0, 80) + '...' : log.resultUrl}</a>
<button class="copy-url-btn" data-url="${escapeHtml(log.resultUrl)}" title="复制 URL" style="padding: 2px 6px; font-size: 10px; cursor: pointer; flex-shrink: 0;">
<svg class="icon-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 12px; height: 12px;"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
</button>
</span>
</td>
</tr>
` : ''}
${log.errorMessage ? `
<tr>
<td class="form-data-name">错误信息</td>
<td><span class="form-data-value" style="color: var(--error-color); word-break: break-word;">${escapeHtml(log.errorMessage)}</span></td>
</tr>
` : ''}
</tbody>
</table>
</div>
${referenceImagesHtml}
${log.prompt ? `
<div class="detail-section">
<h4>提示词</h4>
<pre>${escapeHtml(log.prompt)}</pre>
</div>
` : ''}
${log.requestBody ? `
<div class="detail-section">
<h4>请求体 (Request Body)</h4>
<pre>${escapeHtml(log.requestBody)}</pre>
</div>
` : ''}
${log.resultText ? `
<div class="detail-section">
<h4>响应文本</h4>
<pre>${escapeHtml(log.resultText)}</pre>
</div>
` : ''}
${log.responseBody ? `
<div class="detail-section">
<h4>响应体 (Response Body)</h4>
<pre>${escapeHtml(log.responseBody)}</pre>
</div>
` : ''}
</div>
`;
// 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;
}
}
+468
View File
@@ -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 = `
<div class="empty-state">
<span class="icon">💥</span>
<p>暂无内存日志</p>
<p style="font-size: 12px; opacity: 0.7;">页面启动、内存超限、错误和关闭时的快照会自动记录</p>
</div>
`;
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 = `<span class="log-url" style="color: var(--error-color);">${escapeHtml(log.error.message || '')}</span>`;
}
// 完整内存显示
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 = `
<div class="log-header">
<span class="log-toggle"><span class="arrow">▶</span></span>
<span class="log-time">${time}</span>
<span class="log-type-badge ${typeClass}">${typeLabel}</span>
${perfText ? `<span class="log-perf">⚡ ${perfText}</span>` : ''}
${memoryText ? `<span class="log-memory-info">📊 ${memoryText}</span>` : ''}
${statsText ? `<span class="log-stats-info">📄 ${statsText}</span>` : ''}
${errorPreview}
</div>
<div class="log-details">
<div class="detail-section">
<h4>基本信息</h4>
<pre>ID: ${log.id}
时间: ${new Date(log.timestamp).toLocaleString('zh-CN')}
URL: ${log.url || '-'}</pre>
</div>
${log.memory ? `
<div class="detail-section">
<h4>内存信息</h4>
<pre>已用: ${(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)}%</pre>
</div>
` : ''}
${log.pageStats ? `
<div class="detail-section">
<h4>页面统计</h4>
<pre>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}` : ''}</pre>
</div>
` : ''}
${log.performance ? `
<div class="detail-section">
<h4>性能信息</h4>
<pre>${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}` : ''}</pre>
</div>
` : ''}
${log.error ? `
<div class="detail-section">
<h4>错误信息</h4>
<pre style="color: var(--error-color);">${log.error.type}: ${escapeHtml(log.error.message)}</pre>
${log.error.stack ? `<pre style="margin-top: 8px; font-size: 11px; opacity: 0.8;">${escapeHtml(log.error.stack)}</pre>` : ''}
</div>
` : ''}
${log.customData ? `
<div class="detail-section">
<h4>自定义数据</h4>
<pre>${JSON.stringify(log.customData, null, 2)}</pre>
</div>
${log.type === 'longtask' ? `
<div class="detail-section" style="background: var(--warning-light); padding: 12px; border-radius: 6px; border-left: 3px solid var(--warning-color);">
<h4 style="color: var(--warning-color);">💡 如何定位长任务来源</h4>
<ol style="margin: 8px 0 0 0; padding-left: 20px; font-size: 12px; line-height: 1.8;">
<li>打开 Chrome DevTools → Performance 面板</li>
<li>点击录制按钮 ⏺,复现长任务操作</li>
<li>停止录制,在 Main 线程中找到黄色/红色的长条(> 50ms)</li>
<li>点击展开查看详细的函数调用栈</li>
</ol>
</div>
` : ''}
` : ''}
</div>
`;
// 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 = `(<span style="color:var(--error-color)">${errorCount} errors</span>)`;
} 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;
}
}
+188
View File
@@ -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);
}
@@ -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;
+224
View File
@@ -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 = '<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>';
} else {
// Moon icon for light mode (click to switch to dark)
elements.themeIcon.innerHTML = '<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>';
}
}
}
/**
* 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';
}
}
+41
View File
@@ -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 = `
<div class="import-notification-content">
<span class="icon">${icons[type] || icons.info}</span>
<div class="info">
<p style="margin: 0; white-space: pre-line;">${escapeHtml(message)}</p>
</div>
<button class="close" onclick="this.parentElement.parentElement.remove()">×</button>
</div>
`;
document.body.appendChild(notification);
// 自动消失
setTimeout(() => {
notification.classList.add('fade-out');
setTimeout(() => notification.remove(), 300);
}, duration);
}
+5 -2
View File
@@ -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()) {
+53
View File
@@ -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 等)