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:
1 parent
2504d9b7bb
commit
33aa2ed122
16 files changed
+4293
-3953
No files matched your search
@@ -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>
|
||||
<!-- 导入日志按钮 (分析模式下显示) -->
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+177
-3950
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* SW Debug Panel - Common Utilities
|
||||
* 公共工具函数
|
||||
*/
|
||||
|
||||
/**
|
||||
* Escape HTML special characters
|
||||
* @param {string} str
|
||||
* @returns {string}
|
||||
*/
|
||||
export function escapeHtml(str) {
|
||||
if (typeof str !== 'string') return String(str);
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate URL for display
|
||||
* @param {string} url
|
||||
* @param {number} maxLen
|
||||
* @returns {string}
|
||||
*/
|
||||
export function truncateUrl(url, maxLen) {
|
||||
if (url.length <= maxLen) return url;
|
||||
return url.substring(0, maxLen - 3) + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes to human readable
|
||||
*/
|
||||
export function formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse User-Agent string to extract browser and OS info
|
||||
* @param {string} ua - User-Agent string
|
||||
* @returns {{browser: string, os: string}}
|
||||
*/
|
||||
export function parseUserAgent(ua) {
|
||||
let browser = '未知';
|
||||
let os = '未知';
|
||||
|
||||
// Detect browser
|
||||
if (ua.includes('Chrome') && !ua.includes('Edg')) {
|
||||
const match = ua.match(/Chrome\/(\d+)/);
|
||||
browser = match ? `Chrome ${match[1]}` : 'Chrome';
|
||||
} else if (ua.includes('Edg')) {
|
||||
const match = ua.match(/Edg\/(\d+)/);
|
||||
browser = match ? `Edge ${match[1]}` : 'Edge';
|
||||
} else if (ua.includes('Firefox')) {
|
||||
const match = ua.match(/Firefox\/(\d+)/);
|
||||
browser = match ? `Firefox ${match[1]}` : 'Firefox';
|
||||
} else if (ua.includes('Safari') && !ua.includes('Chrome')) {
|
||||
const match = ua.match(/Version\/(\d+)/);
|
||||
browser = match ? `Safari ${match[1]}` : 'Safari';
|
||||
}
|
||||
|
||||
// Detect OS
|
||||
if (ua.includes('Windows NT 10')) {
|
||||
os = 'Windows 10/11';
|
||||
} else if (ua.includes('Windows NT')) {
|
||||
os = 'Windows';
|
||||
} else if (ua.includes('Mac OS X')) {
|
||||
const match = ua.match(/Mac OS X (\d+[._]\d+)/);
|
||||
os = match ? `macOS ${match[1].replace('_', '.')}` : 'macOS';
|
||||
} else if (ua.includes('Linux')) {
|
||||
os = 'Linux';
|
||||
} else if (ua.includes('Android')) {
|
||||
const match = ua.match(/Android (\d+)/);
|
||||
os = match ? `Android ${match[1]}` : 'Android';
|
||||
} else if (ua.includes('iPhone') || ua.includes('iPad')) {
|
||||
const match = ua.match(/OS (\d+)/);
|
||||
os = match ? `iOS ${match[1]}` : 'iOS';
|
||||
}
|
||||
|
||||
return { browser, os };
|
||||
}
|
||||
|
||||
// Domain blacklist - requests from these domains will be hidden
|
||||
export const DOMAIN_BLACKLIST = [
|
||||
'us.i.posthog.com',
|
||||
'us-assets.i.posthog.com',
|
||||
'posthog.com',
|
||||
'google-analytics.com',
|
||||
'googletagmanager.com',
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if URL is in the domain blacklist
|
||||
* @param {string} url
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isBlacklistedUrl(url) {
|
||||
if (!url) return false;
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
return DOMAIN_BLACKLIST.some(domain =>
|
||||
urlObj.hostname === domain || urlObj.hostname.endsWith('.' + domain)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter logs by time range
|
||||
* @param {Array} logs
|
||||
* @param {string} timeRangeMinutes - Minutes as string, or empty for all
|
||||
* @returns {Array}
|
||||
*/
|
||||
export function filterByTimeRange(logs, timeRangeMinutes) {
|
||||
if (!timeRangeMinutes) return logs;
|
||||
|
||||
const minutes = parseInt(timeRangeMinutes);
|
||||
if (isNaN(minutes)) return logs;
|
||||
|
||||
const cutoffTime = Date.now() - (minutes * 60 * 1000);
|
||||
return logs.filter(log => log.timestamp >= cutoffTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a request is slow (> 1 second)
|
||||
* @param {number} duration - Duration in milliseconds
|
||||
* @returns {'normal'|'slow'|'very-slow'}
|
||||
*/
|
||||
export function getSpeedClass(duration) {
|
||||
if (!duration) return 'normal';
|
||||
if (duration >= 3000) return 'very-slow';
|
||||
if (duration >= 1000) return 'slow';
|
||||
return 'normal';
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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 等)
|
||||
|
||||
Reference in new issue
Block a user