diff --git a/components/RoutePersistence.tsx b/components/RoutePersistence.tsx index c6e2c4a..aa146d4 100644 --- a/components/RoutePersistence.tsx +++ b/components/RoutePersistence.tsx @@ -26,7 +26,7 @@ const RoutePersistence = () => { } }; - restoreRoute(); + restoreRoute().then(() => console.info('恢复路由成功')); }, [location, navigate]); useEffect(() => { @@ -36,7 +36,7 @@ const RoutePersistence = () => { console.log('保存路由', location.pathname); }; - saveRoute(); + saveRoute().then(() => console.info('保存路由成功')); }, [location]); return null; diff --git a/entrypoints/background.ts b/entrypoints/background.ts index cd2dcbd..8dbc96b 100644 --- a/entrypoints/background.ts +++ b/entrypoints/background.ts @@ -3,11 +3,18 @@ import { browser } from 'wxt/browser'; import { downloadHtmlInBackground } from '@/utils/recordUtils.tsx'; import { sendMessage, onMessage } from '@/utils/messages'; import { getActiveTabId } from '@/utils/tabUtils'; +import { + initRecordingSession, + appendEvents, + streamAllEvents, + deleteRecordingSession, + generateSessionId, +} from '@/utils/recordEventsDb'; interface RecorderState { isRecording: boolean; recordingTabId: number | undefined; - events: unknown[]; + sessionId: string | undefined; startTime: number | undefined; } @@ -20,12 +27,12 @@ async function loadRecorderState(): Promise { return (result[STORAGE_KEY] as RecorderState) || { isRecording: false, recordingTabId: undefined, - events: [], + sessionId: undefined, startTime: undefined, }; } catch (err) { console.error('[background] Failed to load recorder state:', err); - return { isRecording: false, recordingTabId: undefined, events: [], startTime: undefined }; + return { isRecording: false, recordingTabId: undefined, sessionId: undefined, startTime: undefined }; } } @@ -85,7 +92,7 @@ export default defineBackground(() => { ); // 扩展安装/更新时重置录制状态 - await saveRecorderState({ isRecording: false, recordingTabId: undefined, events: [], startTime: undefined }); + await saveRecorderState({ isRecording: false, recordingTabId: undefined, sessionId: undefined, startTime: undefined }); }); // 监听 Tab 切换 - 如果正在录制且切换到其他 Tab,发出警告 @@ -109,14 +116,14 @@ export default defineBackground(() => { console.warn('[background] 录制中的 Tab 已关闭,自动停止录制'); // 这里可以调用停止逻辑或通知 popup await sendMessage('popup:stopped', undefined); - await saveRecorderState({ isRecording: false, recordingTabId: undefined, startTime: undefined }); + await saveRecorderState({ isRecording: false, recordingTabId: undefined, sessionId: undefined, startTime: undefined }); } }); onMessage('popup:check-status', async () => { console.log('[background] popup:check-status'); const state = await loadRecorderState(); - + // 如果正在录制,额外检查 content script 的实际状态 if (state.isRecording && state.recordingTabId) { try { @@ -124,17 +131,17 @@ export default defineBackground(() => { console.log('[background] content check-status result:', result); // 如果 content script 返回 false,说明状态不同步,需要重置 if (!result) { - await saveRecorderState({ isRecording: false, recordingTabId: undefined, startTime: undefined }); + await saveRecorderState({ isRecording: false, recordingTabId: undefined, sessionId: undefined, startTime: undefined }); return { active: false, startTime: -1 }; } } catch (err) { console.error('[background-err] check-status failed:', err); // Content script 可能已被移除(如页面刷新),重置状态 - await saveRecorderState({ isRecording: false, recordingTabId: undefined, startTime: undefined }); + await saveRecorderState({ isRecording: false, recordingTabId: undefined, sessionId: undefined, startTime: undefined }); return { active: false, startTime: -1 }; } } - + return { active: state.isRecording, startTime: state.startTime ?? -1 }; }); @@ -149,15 +156,24 @@ export default defineBackground(() => { } const tabId = await getActiveTabId(); + if (!tabId) { + return { ok: false, error: 'No active tab' }; + } const response = await sendMessage('content:start-recording', undefined, tabId); if (response.ok) { + // 生成新的会话 ID 并初始化 IndexedDB 会话 + const sessionId = generateSessionId(); + console.log('[bg] 开始录制,sessionId:', sessionId, 'tabId:', tabId); + await initRecordingSession(sessionId, tabId); + // 保存录制状态到 storage await saveRecorderState({ isRecording: true, recordingTabId: tabId, - events: [], // 清空旧事件 + sessionId, startTime: Date.now(), }); + console.log('[bg] 录制状态已保存'); await sendMessage('popup:started', undefined); return { ok: true }; } @@ -172,14 +188,14 @@ export default defineBackground(() => { console.log('[bg] stopRecording received'); try { const state = await loadRecorderState(); - + if (!state.isRecording) { console.warn('[bg] 当前不在录制状态'); return { ok: false, error: 'Not recording' }; } const currentTabId = await getActiveTabId(); - + // Tab 一致性检查 - 允许一定程度的灵活性 if (state.recordingTabId !== currentTabId) { console.warn( @@ -191,19 +207,27 @@ export default defineBackground(() => { const targetTabId = state.recordingTabId ?? currentTabId; const response = await sendMessage('content:stop-recording', undefined, targetTabId); - + if (response.ok) { - // 下载回放文件 - downloadHtmlInBackground(state.events); - + // 从 IndexedDB 流式读取所有事件并下载回放文件 + if (state.sessionId) {const allEvents: unknown[] = []; + await streamAllEvents(state.sessionId, (events) => { + allEvents.push(...events); + }); + downloadHtmlInBackground(allEvents); + + // 删除会话数据 + await deleteRecordingSession(state.sessionId); + } + // 清空状态 await saveRecorderState({ isRecording: false, recordingTabId: undefined, - events: [], + sessionId: undefined, startTime: undefined, }); - + await sendMessage('popup:stopped', undefined); return { ok: true }; } @@ -214,16 +238,21 @@ export default defineBackground(() => { } }); - onMessage('content:save-tracke-events', async (event) => { + onMessage('content:save-track-events', async (event) => { const state = await loadRecorderState(); + console.log('[bg] 收到事件,当前状态:', state); if (!state.isRecording) { console.warn('[bg] 收到事件但当前不在录制状态,丢弃事件'); return false; } - - // 将新事件追加到现有事件列表 - const updatedEvents = [...state.events, event]; - await saveRecorderState({ events: updatedEvents }); + + if (!state.sessionId) { + console.error('[bg] 没有 sessionId,无法存储事件'); + return false; + } + + // 使用 IndexedDB 分块存储事件 + await appendEvents(state.sessionId, [event]); return true; }); }); diff --git a/entrypoints/popup/App.css b/entrypoints/popup/App.css index 96d0316..873141c 100644 --- a/entrypoints/popup/App.css +++ b/entrypoints/popup/App.css @@ -38,913 +38,6 @@ body { box-sizing: border-box; } -.nav { - height: 56px; - background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%); - border-bottom: 1px solid #e9ecef; - display: flex; - align-items: center; - padding: 0 20px; - margin-bottom: 10px; - position: relative; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); -} - -.nav-content { - width: 100%; - display: flex; - align-items: center; -} - -.nav-list { - list-style: none; - display: flex; - gap: 16px; - padding: 0; - margin: 0; - align-items: center; - flex-wrap: wrap; - transition: all 0.3s ease; -} - -.nav-link { - text-decoration: none; - color: #495057; - font-size: 14px; - font-weight: 500; - padding: 8px 16px; - border-radius: 8px; - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - display: inline-block; - white-space: nowrap; - position: relative; - border: 1px solid transparent; -} - -.nav-link:hover { - background-color: #f8f9fa; - color: #212529; - border-color: #dee2e6; - transform: translateY(-1px); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); -} - -.nav-link.active { - color: #4361ee; - font-weight: 600; - background: linear-gradient(135deg, rgba(67, 97, 238, 0.1) 0%, rgba(67, 97, 238, 0.05) 100%); - border: 1px solid rgba(67, 97, 238, 0.2); - box-shadow: 0 2px 8px rgba(67, 97, 238, 0.15); -} - -.nav-link.active::before { - content: ''; - position: absolute; - bottom: -2px; - left: 16px; - right: 16px; - height: 3px; - background: linear-gradient(90deg, #4361ee, #3a56d4); - border-radius: 3px; -} - -/* 折叠菜单相关样式 */ -.nav-collapse-item { - position: relative; - display: flex; - align-items: center; -} - -.nav-collapse-content { - position: absolute; - top: 100%; - right: 0; - background: white; - border: 1px solid transparent; - border-radius: 12px; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); - min-width: 140px; - margin-top: 12px; - flex-direction: column; - gap: 0; - opacity: 0; - visibility: hidden; - transform: translateY(-12px) scale(0.95); - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - display: none; - z-index: 1000; - overflow: hidden; -} - -.nav-collapse-item:hover .nav-collapse-content, -.nav-collapse-content.show { - display: flex; - opacity: 1; - visibility: visible; - transform: translateY(0) scale(1); - border-color: #e9ecef; -} - -.nav-collapse-content .nav-link { - padding: 12px 20px; - width: 100%; - text-align: left; - box-sizing: border-box; - border-radius: 0; - border: none; - font-size: 14px; - font-weight: 500; - color: #495057; - transition: all 0.2s ease; -} - -.nav-collapse-content .nav-link:last-child { - border-bottom: none; -} - -.nav-collapse-content .nav-link:hover { - background: linear-gradient(90deg, rgba(67, 97, 238, 0.08), rgba(67, 97, 238, 0.04)); - color: #4361ee; - padding-left: 24px; -} - -.nav-collapse-content .nav-link.active { - background: linear-gradient(90deg, rgba(67, 97, 238, 0.15), rgba(67, 97, 238, 0.08)); - color: #4361ee; - font-weight: 600; - border: none; - box-shadow: none; -} - -.nav-collapse-content .nav-link.active::before { - display: none; -} - -.nav-toggle { - background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%); - border: 1px solid #dee2e6; - border-radius: 10px; - padding: 8px 16px; - cursor: pointer; - font-size: 14px; - font-weight: 500; - color: #495057; - display: flex; - align-items: center; - gap: 6px; - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - margin-left: 12px; - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05); - position: relative; - overflow: hidden; -} - -.nav-toggle::before { - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: linear-gradient(135deg, rgba(67, 97, 238, 0.1), rgba(67, 97, 238, 0.05)); - opacity: 0; - transition: opacity 0.3s ease; - z-index: 1; -} - -.nav-toggle:hover { - background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); - border-color: #4361ee; - color: #4361ee; - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(67, 97, 238, 0.15); -} - -.nav-toggle:hover::before { - opacity: 1; -} - -.nav-toggle-icon { - font-size: 18px; - line-height: 1; - position: relative; - z-index: 2; - transition: transform 0.3s ease; -} - -.nav-toggle:hover .nav-toggle-icon { - transform: scale(1.1); -} - -.nav-collapse-count { - font-size: 12px; - font-weight: 600; - color: #4361ee; - background: rgba(67, 97, 238, 0.1); - padding: 2px 6px; - border-radius: 10px; - min-width: 20px; - text-align: center; - position: relative; - z-index: 2; -} - -/* 响应式设计 */ -@media (max-width: 767px) { - .nav-list { - gap: 12px; - flex-wrap: nowrap; - } - - /* 当菜单展开时,允许换行并显示所有可见项 */ - .nav-list.open { - flex-wrap: wrap; - overflow: visible; - } - - .nav-link { - padding: 6px 10px; - font-size: 13px; - } - - .nav-collapse-content { - position: fixed; - top: 48px; - left: 0; - right: 0; - margin-top: 0; - border-radius: 8px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); - max-height: 0; - overflow: hidden; - transition: max-height 0.3s ease; - opacity: 1; /* 覆盖默认的opacity: 0 */ - visibility: visible; /* 覆盖默认的visibility: hidden */ - transform: none; /* 覆盖默认的transform */ - display: block; /* 覆盖默认的display: none,使用block而不是flex */ - /* 移除边框,避免横线显示 */ - border-style: none; - } - - .nav-collapse-content.show { - max-height: 300px; - } - - .nav-collapse-content .nav-link { - padding: 12px 16px; - font-size: 14px; - } - - .nav-toggle { - padding: 8px 16px; - margin-left: auto; - } -} - -@media (max-width: 479px) { - .nav { - padding: 0 12px; - z-index: 1000; /* 确保导航栏在展开菜单上方 */ - height: 52px; - } - - .nav-list { - width: 100%; - align-items: center; - } - - /* 当菜单展开时,允许换行并显示所有可见项 */ - .nav-list.open { - flex-wrap: wrap; - overflow: visible; - } - - /* 可见的导航项应该始终显示 */ - .nav-list > li:not(.nav-collapse-item) { - display: flex; - align-items: center; - } - - .nav-list > li:not(.nav-collapse-item) .nav-link { - display: inline-block; - padding: 8px 12px; - font-size: 13px; - white-space: nowrap; - max-width: 80px; - overflow: hidden; - text-overflow: ellipsis; - border: 1px solid transparent; - } - - /* 折叠菜单内容 - 只在打开时显示 */ - .nav-collapse-content { - position: fixed; - top: 52px; - left: 0; - right: 0; - background: white; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); - z-index: 999; - max-height: 0; - overflow: hidden; - transition: max-height 0.3s ease; - border: 0; - border-radius: 0 0 12px 12px; - } - - .nav-collapse-content.show { - max-height: calc(100% - 52px); - overflow-y: auto; - } - - .nav-collapse-content .nav-link { - display: block; - width: 100%; - text-align: center; - border-bottom: 1px solid #f8f9fa; - padding: 14px 16px; - font-size: 14px; - font-weight: 500; - color: #495057; - transition: all 0.2s ease; - } - - .nav-collapse-content .nav-link:hover { - background: linear-gradient(90deg, rgba(67, 97, 238, 0.08), rgba(67, 97, 238, 0.04)); - color: #4361ee; - } - - .nav-collapse-content .nav-link.active { - background: linear-gradient(90deg, rgba(67, 97, 238, 0.15), rgba(67, 97, 238, 0.08)); - color: #4361ee; - font-weight: 600; - } - - .nav-collapse-content .nav-link:last-child { - border-bottom: none; - } - - .nav-toggle { - padding: 8px 12px; - font-size: 13px; - min-width: 44px; - height: 32px; - display: flex; - align-items: center; - justify-content: center; - background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%); - border: 1px solid #dee2e6; - border-radius: 8px; - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05); - } - - .nav-toggle:hover { - background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); - border-color: #4361ee; - color: #4361ee; - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(67, 97, 238, 0.15); - } - - .nav-collapse-count { - display: none; - } -} - -/* 隐藏滚动条样式 */ -/* WebKit浏览器 (Chrome, Safari, Edge) */ -body::-webkit-scrollbar, -.app::-webkit-scrollbar, -.nav-collapse-content.show::-webkit-scrollbar, -.timestamp-display::-webkit-scrollbar { - display: none; -} - -/* Firefox */ -body, -.app, -.nav-collapse-content.show, -.timestamp-display { - scrollbar-width: none; -} - -/* IE/Edge 10+ */ -body, -.app, -.nav-collapse-content.show, -.timestamp-display { - -ms-overflow-style: none; -} - -button { - background: #4caf50; - display: flex; - align-items: center; - justify-content: center; - box-sizing: border-box; - transition: - transform 0.08s ease, - box-shadow 0.2s ease, - background-color 0.25s ease; -} - -button:hover { - background: #3d8b40; - transform: translateY(-2px); - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); -} - -button:active { - background: #4caf50; - transform: scale(0.96) translateY(0); - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); -} - -/* 超小屏幕适配 - 修复边框遮挡问题 */ -@media (max-width: 399px) { - .nav { - position: relative; - border-bottom: none; /* 移除导航栏底部边框 */ - height: 48px; - padding: 0 8px; - } - - .nav::after { - content: ''; - position: absolute; - bottom: 0; - left: 0; - right: 0; - height: 1px; - background: linear-gradient(90deg, transparent, #e9ecef, transparent); - } - - /* 当菜单展开时,允许换行并显示所有可见项 */ - .nav-list.open { - flex-wrap: wrap; - overflow: visible; - } - - /* 调整可见导航项的宽度,适应更小的屏幕 */ - .nav-list > li:not(.nav-collapse-item) .nav-link { - max-width: 60px; - padding: 6px 8px; - font-size: 12px; - border: 1px solid transparent; - } - - .nav-list > li:not(.nav-collapse-item) .nav-link.active { - background: linear-gradient(135deg, rgba(67, 97, 238, 0.1) 0%, rgba(67, 97, 238, 0.05) 100%); - border: 1px solid rgba(67, 97, 238, 0.2); - box-shadow: 0 1px 4px rgba(67, 97, 238, 0.15); - } - - .nav-collapse-content { - top: 48px; - } - - .nav-collapse-content.show { - max-height: calc(100% - 48px); - } - - .nav-toggle { - padding: 6px 10px; - font-size: 12px; - min-width: 36px; - height: 28px; - background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%); - border: 1px solid #dee2e6; - border-radius: 6px; - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05); - } - - .nav-toggle:hover { - background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); - border-color: #4361ee; - color: #4361ee; - transform: translateY(-1px); - box-shadow: 0 2px 8px rgba(67, 97, 238, 0.15); - } - - .nav-toggle-icon { - font-size: 12px; - } -} - -/* 中等屏幕适配 */ -@media (min-width: 480px) and (max-width: 767px) { - .nav-list { - flex-wrap: wrap; - justify-content: flex-start; - } - - .nav-link { - flex: 0 0 auto; - } - - .nav-collapse-item { - margin-left: auto; - } - - /* 确保.nav-list.open不会影响中等屏幕的布局 */ - .nav-list.open { - flex-wrap: wrap; - position: static; - background: none; - border: none; - box-shadow: none; - max-height: none; - overflow: visible; - } -} - -.page { - padding: 20px; - border: 1px solid #ddd; - border-radius: 8px; -} - -.user-list { - list-style: none; - padding: 0; -} - -.user-list li { - padding: 8px; - border-bottom: 1px solid #eee; -} - -.btn { - display: inline-block; - margin-top: 20px; - padding: 10px 20px; - background: #2196f3; - color: white; - text-decoration: none; - border-radius: 8px; -} - -.btn:hover { - background: #0b7dda; -} - -.actions { - margin: 20px 0; -} - -.action-btn { - margin-right: 10px; - padding: 8px 16px; - background: #4caf50; - color: white; - border-radius: 8px; - cursor: pointer; - border: 1px solid #d9d9d9; - box-shadow: 0 2px #00000004; -} - -.stop-btn { - margin-right: 10px; - padding: 8px 16px; - background: #f32152; - color: white; - border: none; - border-radius: 8px; - cursor: pointer; -} - -.stop-btn:hover { - background: #ff0000; -} - -input { - width: 100%; - padding: 10px; - border: 1px solid #ddd; - border-radius: 8px; - margin-bottom: 20px; - box-sizing: border-box; - font-size: 14px; -} - -select { - width: 100%; - padding: 10px; - border: 1px solid #ddd; - border-radius: 8px; - margin-bottom: 20px; - box-sizing: border-box; - font-size: 14px; -} - -/* 时间戳组件样式 */ -.timestamp-container { - padding: 20px; - border: var(--border); - border-radius: var(--radius); - background: var(--bg-color); - margin: 20px 0; -} - -.timestamp-container:hover { - box-shadow: 4px 4px 12px 8px rgba(0, 0, 0, 0.1); - transition: box-shadow 0.3s ease; -} - -/* 时间戳显示区域 */ -.timestamp-display { - display: flex; - align-items: center; /* 垂直居中 */ - justify-content: center; /* 横向居中 */ - gap: 10px; - border-radius: var(--radius); - overflow-x: auto; - overflow-y: hidden; - white-space: nowrap; -} - -.timestamp-value { - font-size: 2.5rem; - font-weight: 600; - color: var(--text-color); - font-family: 'Courier New', monospace; - text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.1); - white-space: nowrap; - flex-shrink: 0; -} - -.timestamp-unit { - font-size: 1rem; - color: var(--text-color); - font-weight: 400; -} - -.timestamp-controls { - display: flex; - gap: 12px; - justify-content: center; - flex-wrap: wrap; -} - -/* 确保 CopyButton 也使用相同的样式 */ -.timestamp-controls .action-btn, -.timestamp-controls .stop-btn { - min-width: 120px; - height: 44px; - padding: 10px 20px; - border-radius: 8px; - font-weight: 500; - font-size: 0.95rem; -} - -.timestamp-status { - display: flex; - justify-content: space-between; - align-items: center; - padding: 12px 16px; - background: rgba(255, 255, 255, 0.8); - border-radius: 8px; - font-size: 0.9rem; - border: 1px solid #e0e0e0; -} - -.status-indicator { - display: flex; - align-items: center; - gap: 6px; - font-weight: 500; -} - -.status-indicator.running { - color: #27ae60; -} - -.status-indicator.stopped { - color: #e74c3c; -} - -.unit-indicator { - color: #7f8c8d; - font-weight: 500; -} - -/* 响应式设计 */ -@media (max-width: 480px) { - .timestamp-container { - padding: 15px; - } - - .timestamp-display { - padding: 12px; - align-items: center; /* 垂直居中 */ - justify-content: center; /* 横向居中 */ - } - - .timestamp-value { - font-size: 1.8rem; - line-height: 1.1; - } - - .timestamp-unit { - font-size: 1rem; - flex-shrink: 0; - } - - .timestamp-controls { - flex-direction: row; - flex-wrap: nowrap; - justify-content: space-between; - gap: 8px; - } - - .timestamp-controls .action-btn, - .timestamp-controls .stop-btn { - width: auto; - flex: 1; - min-width: auto; - max-width: none; - height: 44px; - padding: 8px 12px; - font-size: 0.85rem; - } - - .timestamp-status { - flex-direction: column; - gap: 8px; - text-align: center; - } -} - -/* 400px以下屏幕适配 - 防止按钮文本换行 */ -@media (max-width: 400px) { - .timestamp-controls { - gap: 4px; - } - - .timestamp-controls .action-btn, - .timestamp-controls .stop-btn { - padding: 6px 4px; - font-size: 0.75rem; - height: 38px; - min-width: auto; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } -} - -/* 超小屏幕适配 */ -@media (max-width: 360px) { - .timestamp-value { - font-size: 1.5rem; - } - - .timestamp-display { - padding: 10px; - align-items: center; /* 垂直居中 */ - justify-content: center; /* 横向居中 */ - } - - .timestamp-controls { - gap: 3px; - } - - .timestamp-controls .action-btn, - .timestamp-controls .stop-btn { - padding: 5px 3px; - font-size: 0.7rem; - height: 36px; - } -} - -/* 日期时间转换器样式 */ -.datetime-converter { - padding: 20px; - border: var(--border); - border-radius: 8px; - background: var(--bg-color); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); - margin: 20px 0; -} - -.datetime-converter:hover { - box-shadow: 4px 4px 12px 8px rgba(0, 0, 0, 0.1); - transition: box-shadow 0.3s ease; -} - -.converter-title { - color: #2c3e50; - font-size: 1.5rem; - margin-bottom: 20px; - text-align: center; - font-weight: 600; -} - -.converter-form { - display: flex; - flex-direction: column; - gap: 16px; -} - -.input-group, -.result-group { - display: flex; - gap: 12px; - align-items: center; -} - -.datetime-input, -.result-input { - flex: 1; - padding: 10px 14px; - border: 1px solid #ddd; - border-radius: 8px; - font-size: 0.95rem; - font-weight: 400; - background: white; - transition: border-color 0.2s ease; - width: auto !important; - margin-bottom: 0 !important; -} - -.datetime-input:focus, -.result-input:focus { - outline: none; - border-color: #3498db; - box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.2); -} - -.timezone-select, -.unit-select { - padding: 10px 14px; - border: 1px solid #ddd; - border-radius: 8px; - font-size: 0.95rem; - background: white; - min-width: 180px; - cursor: pointer; - transition: border-color 0.2s ease; - width: auto !important; - margin-bottom: 0 !important; -} - -.timezone-select:focus, -.unit-select:focus { - outline: none; - border-color: #3498db; - box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.2); -} - -.action-group { - display: flex; - justify-content: center; -} - -.converter-btn { - padding: 10px 24px; - border-radius: 8px; - font-weight: 500; - font-size: 1rem; - min-width: 120px; -} - -.error-message { - padding: 10px 14px; - background: #ffeaea; - border: 1px solid #ffcccc; - border-radius: 8px; - color: #d32f2f; - font-size: 0.9rem; - text-align: center; - animation: fadeIn 0.3s ease; -} - -.result-info { - padding: 12px 16px; - background: rgba(255, 255, 255, 0.9); - border-radius: 8px; - border: 1px solid #e0e0e0; - font-size: 0.95rem; - text-align: center; -} - -.result-label { - font-weight: 600; - color: #2c3e50; -} - -.result-value { - font-family: 'Courier New', monospace; - font-weight: 700; - color: #3498db; - margin: 0 4px; -} - -.result-unit { - color: #7f8c8d; - font-weight: 500; -} - @keyframes fadeIn { from { opacity: 0; @@ -955,41 +48,3 @@ select { transform: translateY(0); } } - -/* 响应式设计 */ -@media (max-width: 768px) { - .input-group, - .result-group { - flex-direction: column; - align-items: stretch; - } - - .timezone-select, - .unit-select { - min-width: auto; - width: 100% !important; - } - - .datetime-converter { - padding: 16px; - } -} - -@media (max-width: 480px) { - .converter-title { - font-size: 1.3rem; - } - - .datetime-input, - .result-input, - .timezone-select, - .unit-select { - padding: 8px 12px; - font-size: 0.9rem; - } - - .converter-btn { - padding: 10px 20px; - font-size: 0.95rem; - } -} diff --git a/utils/messages.tsx b/utils/messages.tsx index f8cb5f8..65dc66a 100644 --- a/utils/messages.tsx +++ b/utils/messages.tsx @@ -16,7 +16,7 @@ export const messages = { }, content: { from: { - saveTrackeEvents: 'content:save-tracke-events', + saveTrackEvents: 'content:save-track-events', }, to: { startRecording: 'content:start-recording', @@ -43,7 +43,7 @@ interface ProtocolMap { 'popup:ready': () => void; // --- Content 相关 --- - 'content:save-tracke-events': (event: unknown) => boolean; + 'content:save-track-events': (event: unknown) => boolean; 'content:start-recording': () => { ok: boolean; error?: string }; 'content:stop-recording': () => { ok: boolean }; 'content:check-status': () => boolean; diff --git a/utils/recordEventsDb.ts b/utils/recordEventsDb.ts new file mode 100644 index 0000000..24c2e70 --- /dev/null +++ b/utils/recordEventsDb.ts @@ -0,0 +1,293 @@ +/** + * IndexedDB 工具模块 - 用于分块存储录制事件 + * 避免长时间录制导致内存溢出 + */ + +const DB_NAME = 'recording-events-db'; +const DB_VERSION = 1; +const STORE_NAME = 'events'; +const CHUNK_SIZE = 100; // 每个 chunk 存储的事件数量 + +export interface EventChunk { + id: number; + sessionId: string; + chunkIndex: number; + events: unknown[]; + timestamp: number; +} + +interface RecordingSession { + id: string; + startTime: number; + tabId: number; + chunkCount: number; + totalEvents: number; +} + +let db: IDBDatabase | null = null; + +/** + * 打开数据库连接 + */ +export async function openDB(): Promise { + if (db) return db; + + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onerror = () => reject(request.error); + request.onsuccess = () => { + db = request.result; + resolve(db); + }; + + request.onupgradeneeded = (event) => { + const database = (event.target as IDBOpenDBRequest).result; + + // 创建事件 chunk 存储 + if (!database.objectStoreNames.contains(STORE_NAME)) { + const store = database.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true }); + store.createIndex('sessionId', 'sessionId', { unique: false }); + store.createIndex('chunkIndex', 'chunkIndex', { unique: false }); + store.createIndex('sessionId_chunkIndex', ['sessionId', 'chunkIndex'], { unique: true }); + } + + // 创建录制会话存储 + if (!database.objectStoreNames.contains('sessions')) { + const sessionStore = database.createObjectStore('sessions', { keyPath: 'id' }); + sessionStore.createIndex('startTime', 'startTime', { unique: false }); + } + }; + }); +} + +/** + * 初始化新的录制会话 + */ +export async function initRecordingSession(sessionId: string, tabId: number): Promise { + const database = await openDB(); + + return new Promise((resolve, reject) => { + const tx = database.transaction('sessions', 'readwrite'); + const store = tx.objectStore('sessions'); + + const session: RecordingSession = { + id: sessionId, + startTime: Date.now(), + tabId, + chunkCount: 0, + totalEvents: 0, + }; + + const request = store.put(session); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); +} + +/** + * 获取当前会话的 chunk 数量 + */ +export async function getSessionChunkCount(sessionId: string): Promise { + const database = await openDB(); + + return new Promise((resolve, reject) => { + const tx = database.transaction('sessions', 'readonly'); + const store = tx.objectStore('sessions'); + const request = store.get(sessionId); + + request.onsuccess = () => { + const session = request.result as RecordingSession | undefined; + resolve(session?.chunkCount || 0); + }; + request.onerror = () => reject(request.error); + }); +} + +/** + * 追加事件到存储(自动分块) + * 当当前 chunk 满时创建新 chunk + */ +export async function appendEvents(sessionId: string, newEvents: unknown[]): Promise { + const database = await openDB(); + const chunkCount = await getSessionChunkCount(sessionId); + + // 获取或创建当前 chunk + let currentChunk: EventChunk | null = null; + let currentChunkIndex = chunkCount > 0 ? chunkCount - 1 : 0; + + if (chunkCount > 0) { + currentChunk = await getChunk(database, sessionId, currentChunkIndex); + } + + // 如果当前 chunk 不存在或已满,创建新 chunk + if (!currentChunk || currentChunk.events.length >= CHUNK_SIZE) { + currentChunkIndex = chunkCount; + currentChunk = { + id: 0, // 将由 IndexedDB 自动分配 + sessionId, + chunkIndex: currentChunkIndex, + events: [], + timestamp: Date.now(), + }; + + // 更新会话的 chunk 计数 + await updateSessionChunkCount(database, sessionId, chunkCount + 1); + } + + // 将新事件添加到当前 chunk + currentChunk.events.push(...newEvents); + + // 保存 chunk + await saveChunk(database, currentChunk); +} + +/** + * 获取指定 chunk + */ +async function getChunk( + database: IDBDatabase, + sessionId: string, + chunkIndex: number, +): Promise { + return new Promise((resolve, reject) => { + const tx = database.transaction(STORE_NAME, 'readonly'); + const store = tx.objectStore(STORE_NAME); + const index = store.index('sessionId_chunkIndex'); + const request = index.get([sessionId, chunkIndex]); + + request.onsuccess = () => resolve(request.result || null); + request.onerror = () => reject(request.error); + }); +} + +/** + * 保存 chunk + */ +async function saveChunk(database: IDBDatabase, chunk: EventChunk): Promise { + return new Promise((resolve, reject) => { + const tx = database.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + const request = store.put(chunk); + + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); +} + +/** + * 更新会话的 chunk 计数 + */ +async function updateSessionChunkCount( + database: IDBDatabase, + sessionId: string, + chunkCount: number, +): Promise { + return new Promise((resolve, reject) => { + const tx = database.transaction('sessions', 'readwrite'); + const store = tx.objectStore('sessions'); + const getRequest = store.get(sessionId); + + getRequest.onsuccess = () => { + const session = getRequest.result as RecordingSession | undefined; + if (session) { + session.chunkCount = chunkCount; + const putRequest = store.put(session); + putRequest.onsuccess = () => resolve(); + putRequest.onerror = () => reject(putRequest.error); + } else { + resolve(); + } + }; + getRequest.onerror = () => reject(getRequest.error); + }); +} + +/** + * 流式读取所有事件(按 chunk 顺序) + * 使用回调方式避免一次性加载所有事件到内存 + */ +export async function streamAllEvents( + sessionId: string, + onChunk: (events: unknown[]) => void | Promise, +): Promise { + const database = await openDB(); + const chunkCount = await getSessionChunkCount(sessionId); + + for (let i = 0; i < chunkCount; i++) { + const chunk = await getChunk(database, sessionId, i); + if (chunk && chunk.events.length > 0) { + await onChunk(chunk.events); + } + } +} + +/** + * 获取所有事件(用于下载,一次性加载) + * 注意:这会将所有事件加载到内存,仅在需要导出时调用 + */ +export async function getAllEvents(sessionId: string): Promise { + const allEvents: unknown[] = []; + + await streamAllEvents(sessionId, (events) => { + allEvents.push(...events); + }); + + return allEvents; +} + +/** + * 删除录制会话的所有数据 + */ +export async function deleteRecordingSession(sessionId: string): Promise { + const database = await openDB(); + + return new Promise((resolve, reject) => { + // 删除所有相关的 chunk + const chunkTx = database.transaction(STORE_NAME, 'readwrite'); + const chunkStore = chunkTx.objectStore(STORE_NAME); + const index = chunkStore.index('sessionId'); + const request = index.openCursor(IDBKeyRange.only(sessionId)); + + request.onsuccess = (event) => { + const cursor = (event.target as IDBRequest).result; + if (cursor) { + cursor.delete(); + cursor.continue(); + } + }; + + chunkTx.oncomplete = () => { + // 删除会话记录 + const sessionTx = database.transaction('sessions', 'readwrite'); + const sessionStore = sessionTx.objectStore('sessions'); + const deleteRequest = sessionStore.delete(sessionId); + + deleteRequest.onsuccess = () => resolve(); + deleteRequest.onerror = () => reject(deleteRequest.error); + }; + + chunkTx.onerror = () => reject(chunkTx.error); + }); +} + +/** + * 生成唯一的会话 ID + */ +export function generateSessionId(): string { + return `session_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; +} + +/** + * 清理数据库(用于调试或重置) + */ +export async function clearDatabase(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.deleteDatabase(DB_NAME); + request.onsuccess = () => { + db = null; + resolve(); + }; + request.onerror = () => reject(request.error); + }); +} diff --git a/utils/useRecorder.tsx b/utils/useRecorder.tsx index 87b4e7f..001d4df 100644 --- a/utils/useRecorder.tsx +++ b/utils/useRecorder.tsx @@ -10,7 +10,7 @@ export const createRecorder = () => { try { const handler = rrweb.record({ emit(event) { - sendMessage('content:save-tracke-events', event); + sendMessage('content:save-track-events', event); }, plugins: [getRecordConsolePlugin()], });