refactor: 移除所有录制功能代码
- 删除录制相关页面组件(RecordReplayPage、ReplayListPage、ReplayPlayerPage) - 删除录制工具文件(recordEventsDb、recordUtils、useRecorder、tabUtils) - 清理 background.ts 和 content.ts 中的录制代码 - 更新 App.tsx 移除录制路由 - 清理 messages.tsx 中的消息定义 - 删除 types.tsx 中的录制状态 - 移除 package.json 中的 rrweb 相关依赖 - 更新 wxt.config.ts 移除 offscreen、downloads 权限 - 删除 offscreen 目录 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
```
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
@@ -10,6 +9,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
## 核心命令
|
||||
|
||||
### 开发相关
|
||||
|
||||
- `npm run dev` - 启动 Chrome 浏览器的开发模式
|
||||
- `npm run dev:firefox` - 启动 Firefox 浏览器的开发模式
|
||||
- `npm run build` - 构建 Chrome 浏览器的生产版本
|
||||
@@ -19,7 +19,27 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
- `npm run compile` - TypeScript 类型检查(不生成文件)
|
||||
- `npm run lint` - 运行 ESLint 检查
|
||||
|
||||
### 测试相关
|
||||
|
||||
- `npm run test` - 运行所有测试(单次执行)
|
||||
- `npm run test:watch` - 运行测试并监听文件变化
|
||||
- `npm run test:coverage` - 运行测试并生成覆盖率报告
|
||||
|
||||
**运行单个测试文件:**
|
||||
|
||||
```bash
|
||||
npx vitest run components/__tests__/CopyButton.test.tsx
|
||||
```
|
||||
|
||||
**测试技术栈:**
|
||||
|
||||
- Vitest - 测试框架
|
||||
- @testing-library/react - React 组件测试
|
||||
- @testing-library/user-event v13 - 用户交互模拟(注意:v13 不支持 setup(),使用 fireEvent)
|
||||
- jsdom - 浏览器环境模拟
|
||||
|
||||
### 依赖与准备
|
||||
|
||||
- `npm install` - 安装依赖
|
||||
- `postinstall` 会自动运行 `wxt prepare` 准备开发环境
|
||||
- `prepare` 钩子会初始化 Husky Git 钩子
|
||||
@@ -27,6 +47,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
## 项目架构
|
||||
|
||||
### 技术栈
|
||||
|
||||
- **框架**: WXT (Web Extension Toolkit) - 浏览器扩展开发框架
|
||||
- **前端**: React 19 + TypeScript
|
||||
- **UI 库**: Material UI (MUI)
|
||||
@@ -36,6 +57,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
- **路由**: React Router DOM
|
||||
|
||||
### 目录结构
|
||||
|
||||
```
|
||||
|
||||
├── components/ # 可复用 UI 组件
|
||||
@@ -74,39 +96,91 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
├── wxt.config.ts # WXT 配置
|
||||
└── web-ext.config.ts # WebExtensions 配置
|
||||
|
||||
````
|
||||
```
|
||||
|
||||
### 核心功能实现
|
||||
|
||||
#### 1. 时间戳转换工具
|
||||
|
||||
- 位置: `components/` 目录下的时间戳相关组件
|
||||
- 依赖: dayjs 库进行日期处理
|
||||
- 功能: 支持日期与时间戳的双向转换,支持多种格式
|
||||
|
||||
#### 2. 录制与回放功能
|
||||
|
||||
- 位置: `utils/useRecorder.tsx` (核心录制逻辑)、`utils/recordUtils.tsx` (工具函数)
|
||||
- 依赖: rrweb 库
|
||||
- 存储: IndexedDB (Dexie.js) - `utils/recordEventsDb.ts`
|
||||
- 特点: 支持分块存储录制事件,优化性能
|
||||
|
||||
**录制架构流程:**
|
||||
|
||||
1. **开始录制** (`popup:start` → `background.ts` → `content.ts`)
|
||||
- Popup 发送开始录制消息
|
||||
- Background 生成 sessionId,初始化 IndexedDB 会话
|
||||
- Content Script 启动 rrweb 录制器
|
||||
|
||||
2. **事件存储** (`content:save-track-events`)
|
||||
- rrweb 捕获事件后通过消息发送给 Background
|
||||
- Background 使用 IndexedDB 分块存储(每块 100 个事件)
|
||||
|
||||
3. **停止录制** (`popup:stop`)
|
||||
- Background 从 IndexedDB 流式读取所有事件
|
||||
- 生成回放 HTML 文件并下载
|
||||
- 保留录制历史(不删除 IndexedDB 数据)
|
||||
|
||||
4. **状态管理**
|
||||
- 录制状态存储在 `chrome.storage.local` (recorder_state)
|
||||
- 支持 Tab 切换检测和 Tab 关闭自动停止
|
||||
|
||||
#### 3. 通信系统
|
||||
|
||||
- 位置: `utils/messages.tsx`
|
||||
- 机制: 使用 `@webext-core/messaging` 库实现
|
||||
- 通信通道: 后台脚本 ↔ 内容脚本 ↔ 弹窗 ↔ 离屏文档
|
||||
|
||||
**核心消息类型:**
|
||||
|
||||
- `popup:start` / `popup:stop` - Popup 控制录制
|
||||
- `popup:started` / `popup:stopped` - 状态变化通知
|
||||
- `popup:check-status` - 查询录制状态
|
||||
- `content:start-recording` / `content:stop-recording` - 控制 Content Script
|
||||
- `content:save-track-events` - 保存录制事件
|
||||
- `popup:get-sessions` / `popup:delete-session` - 录制会话管理
|
||||
|
||||
#### 4. 数据存储
|
||||
|
||||
- Chrome Storage API: `utils/chromeStorage.ts` (用于配置等小数据)
|
||||
- IndexedDB: `utils/recordEventsDb.ts` (用于存储大量录制事件)
|
||||
|
||||
**IndexedDB 数据结构:**
|
||||
|
||||
- **sessions** 表: 录制会话元数据
|
||||
- `id`: sessionId (string)
|
||||
- `startTime`: 录制开始时间 (number)
|
||||
- `tabId`: 录制的标签页 ID (number)
|
||||
- `chunkCount`: 数据块数量 (number)
|
||||
- `totalEvents`: 总事件数 (number)
|
||||
|
||||
- **events** 表: 事件数据块
|
||||
- `id`: 自增 ID (number)
|
||||
- `sessionId`: 关联的会话 ID
|
||||
- `chunkIndex`: 块索引 (number)
|
||||
- `events`: rrweb 事件数组 (unknown[])
|
||||
- `timestamp`: 时间戳 (number)
|
||||
- CHUNK_SIZE: 100 个事件/块
|
||||
|
||||
### 关键配置文件
|
||||
|
||||
#### wxt.config.ts
|
||||
|
||||
- 配置 WXT 框架参数
|
||||
- 启用 React 模块
|
||||
- 配置浏览器扩展权限
|
||||
- Vite 构建配置(使用 Terser 压缩,强制 ASCII 编码)
|
||||
|
||||
#### manifest 权限
|
||||
|
||||
```typescript
|
||||
permissions: [
|
||||
'storage', // 存储权限
|
||||
@@ -120,7 +194,7 @@ permissions: [
|
||||
'debugger', // 调试器
|
||||
],
|
||||
host_permissions: ['<all_urls>'] // 访问所有网站
|
||||
````
|
||||
```
|
||||
|
||||
## 开发注意事项
|
||||
|
||||
@@ -141,7 +215,3 @@ host_permissions: ['<all_urls>'] // 访问所有网站
|
||||
- 使用 ESLint 进行代码检查
|
||||
- Husky 用于 Git 钩子管理
|
||||
- Lint-staged 确保暂存文件符合规范
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
|
||||
@@ -1,54 +1,5 @@
|
||||
import '../.wxt/types/imports.d.ts';
|
||||
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,
|
||||
getAllSessions,
|
||||
getAllEvents,
|
||||
} from '@/utils/recordEventsDb';
|
||||
|
||||
interface RecorderState {
|
||||
isRecording: boolean;
|
||||
recordingTabId: number | undefined;
|
||||
sessionId: string | undefined;
|
||||
startTime: number | undefined;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'recorder_state';
|
||||
|
||||
// 从 storage 恢复录制状态
|
||||
async function loadRecorderState(): Promise<RecorderState> {
|
||||
try {
|
||||
const result = await browser.storage.local.get(STORAGE_KEY);
|
||||
return (result[STORAGE_KEY] as RecorderState) || {
|
||||
isRecording: false,
|
||||
recordingTabId: undefined,
|
||||
sessionId: undefined,
|
||||
startTime: undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('[background] Failed to load recorder state:', err);
|
||||
return { isRecording: false, recordingTabId: undefined, sessionId: undefined, startTime: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
// 保存录制状态到 storage
|
||||
async function saveRecorderState(state: Partial<RecorderState>) {
|
||||
try {
|
||||
const currentState = await loadRecorderState();
|
||||
await browser.storage.local.set({
|
||||
[STORAGE_KEY]: { ...currentState, ...state },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[background] Failed to save recorder state:', err);
|
||||
}
|
||||
}
|
||||
|
||||
export default defineBackground(() => {
|
||||
// 监听扩展安装或更新事件
|
||||
@@ -92,217 +43,9 @@ export default defineBackground(() => {
|
||||
console.log(
|
||||
`Successfully injected content script into ${successCount}/${targetTabs.length} tabs.`,
|
||||
);
|
||||
|
||||
// 扩展安装/更新时重置录制状态
|
||||
await saveRecorderState({ isRecording: false, recordingTabId: undefined, sessionId: undefined, startTime: undefined });
|
||||
});
|
||||
|
||||
// 监听 Tab 切换 - 如果正在录制且切换到其他 Tab,发出警告
|
||||
chrome.tabs.onActivated.addListener(async (activeInfo) => {
|
||||
const state = await loadRecorderState();
|
||||
if (state.isRecording && state.recordingTabId !== activeInfo.tabId) {
|
||||
console.warn('[background] 录制中切换到其他 Tab,当前 Tab:', activeInfo.tabId, '录制 Tab:', state.recordingTabId);
|
||||
// 可选:发送通知给 popup 更新 UI 提示用户
|
||||
await sendMessage('popup:tab-changed', { currentTabId: activeInfo.tabId, recordingTabId: state.recordingTabId });
|
||||
}
|
||||
});
|
||||
|
||||
chrome.tabs.onUpdated.addListener((tabId) => {
|
||||
console.log('加载完成的 Tab ID:', tabId);
|
||||
});
|
||||
|
||||
// 监听 Tab 关闭 - 如果关闭的是录制中的 Tab,自动停止录制
|
||||
chrome.tabs.onRemoved.addListener(async (tabId) => {
|
||||
const state = await loadRecorderState();
|
||||
if (state.isRecording && state.recordingTabId === tabId) {
|
||||
console.warn('[background] 录制中的 Tab 已关闭,自动停止录制');
|
||||
// 这里可以调用停止逻辑或通知 popup
|
||||
await sendMessage('popup:stopped', 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 {
|
||||
const result = await sendMessage('content:check-status', undefined, state.recordingTabId);
|
||||
console.log('[background] content check-status result:', result);
|
||||
// 如果 content script 返回 false,说明状态不同步,需要重置
|
||||
if (!result) {
|
||||
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, sessionId: undefined, startTime: undefined });
|
||||
return { active: false, startTime: -1 };
|
||||
}
|
||||
}
|
||||
|
||||
return { active: state.isRecording, startTime: state.startTime ?? -1 };
|
||||
});
|
||||
|
||||
onMessage('popup:start', async () => {
|
||||
console.log('[bg] startRecording received');
|
||||
try {
|
||||
// 检查是否已经在录制
|
||||
const currentState = await loadRecorderState();
|
||||
if (currentState.isRecording) {
|
||||
console.warn('[bg] 已经在录制中,无法开始新的录制');
|
||||
return { ok: false, error: 'Already recording' };
|
||||
}
|
||||
|
||||
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,
|
||||
sessionId,
|
||||
startTime: Date.now(),
|
||||
});
|
||||
console.log('[bg] 录制状态已保存');
|
||||
await sendMessage('popup:started', undefined);
|
||||
return { ok: true };
|
||||
}
|
||||
return { ok: false, error: response.error };
|
||||
} catch (error) {
|
||||
console.error('Failed to start recording in content script', error);
|
||||
return { ok: false, error: String(error) };
|
||||
}
|
||||
});
|
||||
|
||||
onMessage('popup:stop', async () => {
|
||||
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(
|
||||
`[bg] Tab 不一致:录制 Tab=${state.recordingTabId}, 当前 Tab=${currentTabId}`,
|
||||
);
|
||||
// 仍然尝试在录制 Tab 上停止(如果该 Tab 还存在)
|
||||
// 如果需要在当前 Tab 停止,可以移除这个条件判断
|
||||
}
|
||||
|
||||
const targetTabId = state.recordingTabId ?? currentTabId;
|
||||
const response = await sendMessage('content:stop-recording', undefined, targetTabId);
|
||||
|
||||
if (response.ok) {
|
||||
// 从 IndexedDB 流式读取所有事件并下载回放文件
|
||||
if (state.sessionId) {
|
||||
const allEvents: unknown[] = [];
|
||||
await streamAllEvents(state.sessionId, (events) => {
|
||||
allEvents.push(...events);
|
||||
});
|
||||
downloadHtmlInBackground(allEvents);
|
||||
|
||||
// 不再删除会话数据,保留录制历史
|
||||
}
|
||||
|
||||
// 清空状态
|
||||
await saveRecorderState({
|
||||
isRecording: false,
|
||||
recordingTabId: undefined,
|
||||
sessionId: undefined,
|
||||
startTime: undefined,
|
||||
});
|
||||
|
||||
await sendMessage('popup:stopped', undefined);
|
||||
return { ok: true };
|
||||
}
|
||||
return { ok: false, error: 'Failed to stop recording in content script' };
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to stop recording in content script', error);
|
||||
return { ok: false, error: String(error) };
|
||||
}
|
||||
});
|
||||
|
||||
// 获取所有录制会话
|
||||
onMessage('popup:get-sessions', async () => {
|
||||
try {
|
||||
const sessions = await getAllSessions();
|
||||
return sessions;
|
||||
} catch (error) {
|
||||
console.error('[bg] 获取录制会话失败:', error);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
// 获取会话事件
|
||||
onMessage('popup:get-session-events', async (message) => {
|
||||
try {
|
||||
const sessionId = message.data;
|
||||
const events = await getAllEvents(sessionId);
|
||||
return events;
|
||||
} catch (error) {
|
||||
console.error('[bg] 获取会话事件失败:', error);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
// 删除会话
|
||||
onMessage('popup:delete-session', async (message) => {
|
||||
try {
|
||||
const sessionId = message.data;
|
||||
await deleteRecordingSession(sessionId);
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
console.error('[bg] 删除会话失败:', error);
|
||||
return { ok: false };
|
||||
}
|
||||
});
|
||||
|
||||
// 下载会话回放
|
||||
onMessage('popup:download-session', async (message) => {
|
||||
try {
|
||||
const sessionId = message.data;
|
||||
const events = await getAllEvents(sessionId);
|
||||
downloadHtmlInBackground(events);
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
console.error('[bg] 下载会话失败:', error);
|
||||
return { ok: false };
|
||||
}
|
||||
});
|
||||
|
||||
onMessage('content:save-track-events', async (event) => {
|
||||
const state = await loadRecorderState();
|
||||
console.log('[bg] 收到事件,当前状态:', state);
|
||||
if (!state.isRecording) {
|
||||
console.warn('[bg] 收到事件但当前不在录制状态,丢弃事件');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!state.sessionId) {
|
||||
console.error('[bg] 没有 sessionId,无法存储事件');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 使用 IndexedDB 分块存储事件
|
||||
await appendEvents(state.sessionId, [event]);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
+1
-37
@@ -1,45 +1,9 @@
|
||||
import '../.wxt/types/imports.d.ts';
|
||||
import { createRecorder } from '@/utils/useRecorder';
|
||||
import { onMessage } from '@/utils/messages';
|
||||
|
||||
export default defineContentScript({
|
||||
// matches: ['*://*.google.com/*'],
|
||||
matches: ['<all_urls>'],
|
||||
runAt: 'document_start',
|
||||
main() {
|
||||
const recorder = createRecorder();
|
||||
let isRecording = false;
|
||||
|
||||
onMessage('content:check-status', () => {
|
||||
console.log(`[content]${isRecording}`);
|
||||
return isRecording;
|
||||
});
|
||||
|
||||
onMessage('content:start-recording', async () => {
|
||||
try {
|
||||
const started = await recorder.startRecord();
|
||||
if (started) {
|
||||
isRecording = true;
|
||||
return { ok: true };
|
||||
} else {
|
||||
return { ok: false, error: 'Failed to start recorder' };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error handling start recording:', error);
|
||||
return { ok: false };
|
||||
}
|
||||
});
|
||||
|
||||
onMessage('content:stop-recording', () => {
|
||||
try {
|
||||
console.log('[content] stopRecording received');
|
||||
recorder.stopRecord();
|
||||
isRecording = false;
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
console.error('Error handling stop recording:', error);
|
||||
return { ok: false };
|
||||
}
|
||||
});
|
||||
// Content script placeholder
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<script src="main.tsx" type="module"></script>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
@@ -1,28 +0,0 @@
|
||||
// import { useRef } from 'react';
|
||||
console.log('[offscreen] loaded');
|
||||
|
||||
// const events = [];
|
||||
|
||||
// chrome.runtime.onMessage.addListener((msg) => {
|
||||
// if (msg.type === 'SAVE_EVENT') {
|
||||
// console.log('[offscreen] rrweb event', msg.event);
|
||||
// events.push(msg.event);
|
||||
// // 这里你可以存 IndexedDB / memory / file
|
||||
// }
|
||||
|
||||
// if (msg.type === 'SAVE_EVENTS') {
|
||||
// console.log('[offscreen] SAVE_EVENTS received');
|
||||
// console.log('[offscreen] total events:', events.length);
|
||||
// console.log('[offscreen] events:', JSON.stringify(events));
|
||||
// // const blob = new Blob([JSON.stringify(events)], {
|
||||
// // type: 'application/json',
|
||||
// // });
|
||||
// // reader.onload = () => {
|
||||
// // chrome.runtime.sendMessage({
|
||||
// // type: 'DOWNLOAD',
|
||||
// // dataUrl: reader.result,
|
||||
// // });
|
||||
// // };
|
||||
// // reader.readAsDataURL(blob);
|
||||
// }
|
||||
// });
|
||||
@@ -1,10 +1,7 @@
|
||||
// App.js
|
||||
import { HashRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
import TimestampPage from './pages/TimestampPage';
|
||||
import RecordReplayPage from './pages/RecordReplayPage';
|
||||
import TestPage from './pages/TestPage';
|
||||
import ReplayListPage from './pages/ReplayListPage';
|
||||
import ReplayPlayerPage from './pages/ReplayPlayerPage';
|
||||
import Navbar from '../../components/Navbar';
|
||||
import RoutePersistence from '../../components/RoutePersistence';
|
||||
import './App.css';
|
||||
@@ -13,8 +10,6 @@ import './App.css';
|
||||
const navItems = [
|
||||
{ path: '/test', label: '测试页面', element: <TestPage /> },
|
||||
{ path: '/', label: '时间戳', element: <TimestampPage /> },
|
||||
{ path: '/record-replay', label: '录制', element: <RecordReplayPage /> },
|
||||
{ path: '/replay-list', label: '历史回放', element: <ReplayListPage /> },
|
||||
];
|
||||
|
||||
function App() {
|
||||
@@ -28,7 +23,6 @@ function App() {
|
||||
{navItems.map((item) => (
|
||||
<Route key={item.path} path={item.path} element={item.element} />
|
||||
))}
|
||||
<Route path="/replay/:id" element={<ReplayPlayerPage />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</Router>
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { AppState } from '../types';
|
||||
import { sendMessage, onMessage } from '@/utils/messages';
|
||||
import { Button, Container, Stack, Typography, Alert } from '@mui/material';
|
||||
import { CircularProgress } from '@mui/material';
|
||||
|
||||
const RecordReplayPage = () => {
|
||||
const [status, setStatus] = useState<AppState>(AppState.READ);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const isRecording = useMemo(() => status === AppState.RECORDING, [status]);
|
||||
|
||||
useEffect(() => {
|
||||
const unlistenStarted = onMessage('popup:started', () => {
|
||||
console.log('[popup] Received started message');
|
||||
setStatus(AppState.RECORDING);
|
||||
setIsLoading(false);
|
||||
setError(null);
|
||||
});
|
||||
|
||||
const unlistenStopped = onMessage('popup:stopped', () => {
|
||||
setStatus(AppState.READ);
|
||||
setIsLoading(false);
|
||||
setError(null);
|
||||
});
|
||||
|
||||
const unlistenReady = onMessage('popup:ready', () => {
|
||||
setStatus(AppState.READ);
|
||||
setIsLoading(false);
|
||||
setError(null);
|
||||
});
|
||||
|
||||
sendMessage('popup:check-status', undefined)
|
||||
.then((res) => {
|
||||
if (res?.active) {
|
||||
setStatus(AppState.RECORDING);
|
||||
} else {
|
||||
setStatus(AppState.READ);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
setError('检查录制状态失败');
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlistenStarted();
|
||||
unlistenStopped();
|
||||
unlistenReady();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggleRecording = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (isRecording) {
|
||||
const result = await sendMessage('popup:stop', undefined);
|
||||
if (!result?.ok) {
|
||||
setError(result?.error || '停止录制失败');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const result = await sendMessage('popup:start', undefined);
|
||||
if (!result?.ok) {
|
||||
setError(result?.error || '开始录制失败');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling recording:', error);
|
||||
setError('操作失败,请重试');
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxWidth={false} sx={{ py: 2.5 }}>
|
||||
<Stack direction="column" spacing={2} sx={{ mb: 2.5 }}>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ fontSize: '0.875rem' }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Stack direction="row" spacing={1.25} justifyContent="center">
|
||||
<Button
|
||||
variant="contained"
|
||||
size="medium"
|
||||
color={isRecording ? 'error' : 'primary'}
|
||||
onClick={toggleRecording}
|
||||
disabled={isLoading}
|
||||
sx={{ minWidth: 120 }}
|
||||
>
|
||||
{isLoading ? (
|
||||
<CircularProgress size={20} color="inherit" />
|
||||
) : isRecording ? (
|
||||
'停止录制'
|
||||
) : (
|
||||
'开始录制'
|
||||
)}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
textAlign="center"
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
{isRecording ? '正在录制用户操作...' : '点击开始录制用户操作'}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecordReplayPage;
|
||||
@@ -1,181 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Container,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemText,
|
||||
ListItemSecondaryAction,
|
||||
IconButton,
|
||||
Typography,
|
||||
Box,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Button,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
PlayArrow as PlayIcon,
|
||||
Download as DownloadIcon,
|
||||
Delete as DeleteIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { sendMessage } from '@/utils/messages';
|
||||
|
||||
interface Session {
|
||||
id: string;
|
||||
startTime: number;
|
||||
tabId: number;
|
||||
chunkCount: number;
|
||||
totalEvents: number;
|
||||
}
|
||||
|
||||
const ReplayListPage = () => {
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
fetchSessions();
|
||||
}, []);
|
||||
|
||||
const fetchSessions = async () => {
|
||||
try {
|
||||
const data = await sendMessage('popup:get-sessions');
|
||||
setSessions(data || []);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError('获取录制历史失败');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlay = (sessionId: string) => {
|
||||
navigate(`/replay/${sessionId}`);
|
||||
};
|
||||
|
||||
const handleDownload = async (sessionId: string) => {
|
||||
try {
|
||||
await sendMessage('popup:download-session', sessionId);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError('下载失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (sessionId: string) => {
|
||||
if (window.confirm('确定要删除这个录制吗?')) {
|
||||
try {
|
||||
await sendMessage('popup:delete-session', sessionId);
|
||||
fetchSessions();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError('删除失败');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Container maxWidth={false} sx={{ py: 2.5, display: 'flex', justifyContent: 'center' }}>
|
||||
<CircularProgress />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Container maxWidth={false} sx={{ py: 2.5 }}>
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
<Button variant="contained" onClick={fetchSessions}>
|
||||
重试
|
||||
</Button>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth={false} sx={{ py: 2.5 }}>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
录制历史
|
||||
</Typography>
|
||||
|
||||
{sessions.length === 0 ? (
|
||||
<Box textAlign="center" sx={{ py: 4 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
暂无录制历史
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
||||
点击"录制与回放"页面的开始按钮进行录制
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<List sx={{ width: '100%', maxWidth: 500, margin: '0 auto' }}>
|
||||
{sessions.map((session) => (
|
||||
<ListItem
|
||||
key={session.id}
|
||||
disablePadding
|
||||
sx={{ mb: 1, borderRadius: 1, overflow: 'hidden' }}
|
||||
>
|
||||
<ListItemButton
|
||||
sx={{ borderRadius: 1 }}
|
||||
onClick={() => handlePlay(session.id)}
|
||||
>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<PlayIcon fontSize="small" />
|
||||
<Typography variant="body1">
|
||||
{new Date(session.startTime).toLocaleString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
secondary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
事件数: {session.chunkCount * 100}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Tab: {session.tabId}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<IconButton
|
||||
edge="end"
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDownload(session.id);
|
||||
}}
|
||||
title="下载"
|
||||
>
|
||||
<DownloadIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
edge="end"
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(session.id);
|
||||
}}
|
||||
title="删除"
|
||||
sx={{ color: 'error.main' }}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReplayListPage;
|
||||
@@ -1,159 +0,0 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Container,
|
||||
Typography,
|
||||
Box,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
IconButton,
|
||||
Button,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ArrowBack as ArrowBackIcon,
|
||||
Download as DownloadIcon,
|
||||
Delete as DeleteIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { sendMessage } from '@/utils/messages';
|
||||
|
||||
const ReplayPlayerPage = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [eventsData, setEventsData] = useState<unknown[] | null>(null);
|
||||
|
||||
const fetchSessionEvents = useCallback(async (sessionId: string) => {
|
||||
try {
|
||||
const data = await sendMessage('popup:get-session-events', sessionId);
|
||||
setEventsData(data || []);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError('加载录制内容失败');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
fetchSessionEvents(id);
|
||||
}
|
||||
}, [id, fetchSessionEvents]);
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
await sendMessage('popup:download-session', id);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError('下载失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!id) return;
|
||||
if (window.confirm('确定要删除这个录制吗?')) {
|
||||
try {
|
||||
await sendMessage('popup:delete-session', id);
|
||||
navigate('/record-replay');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError('删除失败');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Container maxWidth={false} sx={{ py: 2.5, display: 'flex', justifyContent: 'center' }}>
|
||||
<CircularProgress />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Container maxWidth={false} sx={{ py: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<IconButton onClick={() => navigate('/record-replay')} sx={{ mr: 1 }}>
|
||||
<ArrowBackIcon />
|
||||
</IconButton>
|
||||
<Typography variant="h6">回放</Typography>
|
||||
</Box>
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
<Button variant="contained" onClick={() => fetchSessionEvents(id!)}>
|
||||
重试
|
||||
</Button>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (!eventsData || eventsData.length === 0) {
|
||||
return (
|
||||
<Container maxWidth={false} sx={{ py: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<IconButton onClick={() => navigate('/record-replay')} sx={{ mr: 1 }}>
|
||||
<ArrowBackIcon />
|
||||
</IconButton>
|
||||
<Typography variant="h6">回放</Typography>
|
||||
</Box>
|
||||
<Alert severity="warning">
|
||||
此会话没有事件数据,请尝试重新录制。
|
||||
</Alert>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth={false} sx={{ py: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<IconButton onClick={() => navigate('/record-replay')} sx={{ mr: 1 }}>
|
||||
<ArrowBackIcon />
|
||||
</IconButton>
|
||||
<Typography variant="h6">回放</Typography>
|
||||
<Box sx={{ ml: 'auto', display: 'flex', gap: 1 }}>
|
||||
<IconButton onClick={handleDownload} title="下载">
|
||||
<DownloadIcon />
|
||||
</IconButton>
|
||||
<IconButton onClick={handleDelete} title="删除" sx={{ color: 'error.main' }}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ borderRadius: 1, overflow: 'hidden', border: 1, borderColor: 'divider' }}>
|
||||
<Box sx={{ p: 2, textAlign: 'center' }}>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
由于浏览器安全策略限制,无法在内联 iframe 中直接回放。
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||
请点击右上角的"下载"按钮,下载完整的回放 HTML 文件到本地查看。
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleDownload}
|
||||
sx={{ mt: 2 }}
|
||||
startIcon={<DownloadIcon />}
|
||||
>
|
||||
下载回放文件
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Alert severity="info">
|
||||
<Typography variant="body2">
|
||||
<strong>安全提示:</strong>浏览器扩展有严格的 Content Security Policy (CSP)
|
||||
限制,禁止内联脚本执行,因此无法在扩展 popup 中直接显示回放内容。
|
||||
下载的 HTML 文件包含了完整的回放代码,可以在任何现代浏览器中直接打开。
|
||||
</Typography>
|
||||
</Alert>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReplayPlayerPage;
|
||||
@@ -1,4 +1,3 @@
|
||||
import { sendMessage } from '@/utils/messages';
|
||||
import { Button } from '@mui/material';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
@@ -17,11 +16,6 @@ const TestPage = () => {
|
||||
})();
|
||||
});
|
||||
|
||||
const handleSeedMessage = async () => {
|
||||
const status = await sendMessage('popup:check-status');
|
||||
console.log(`[popup]status: ${status}`);
|
||||
};
|
||||
|
||||
const handleAttach = () => {
|
||||
try {
|
||||
chrome.debugger.attach({ tabId: tabId }, '1.2', () => {
|
||||
@@ -61,9 +55,6 @@ const TestPage = () => {
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<Button variant="contained" color="primary" onClick={handleSeedMessage}>
|
||||
发送消息
|
||||
</Button>
|
||||
<Button variant="contained" color="primary" onClick={handleAttach}>
|
||||
attach
|
||||
</Button>
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export enum AppState {
|
||||
READ = 'ready',
|
||||
RECORDING = 'recording',
|
||||
}
|
||||
@@ -24,9 +24,6 @@
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-material": "^7.3.8",
|
||||
"@mui/material": "^7.3.8",
|
||||
"@rrweb/all": "^2.0.0-alpha.18",
|
||||
"@rrweb/rrweb-plugin-console-record": "^2.0.0-alpha.18",
|
||||
"@rrweb/rrweb-plugin-console-replay": "^2.0.0-alpha.18",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.1",
|
||||
@@ -34,15 +31,11 @@
|
||||
"@webext-core/messaging": "^2.3.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"dayjs": "^1.11.19",
|
||||
"dexie": "^4.2.1",
|
||||
"dexie-react-hooks": "^4.2.0",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-markdown": "^6.0.3",
|
||||
"react-router-dom": "^6.30.2",
|
||||
"remark-gfm": "^1.0.0",
|
||||
"rrweb": "^2.0.0-alpha.4",
|
||||
"rrweb-player": "^1.0.0-alpha.4",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
+2
-22
@@ -1,28 +1,8 @@
|
||||
import { defineExtensionMessaging } from '@webext-core/messaging';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
interface ProtocolMap {
|
||||
// --- Popup 相关 ---
|
||||
'popup:start': () => { ok: boolean; error?: string };
|
||||
'popup:stop': () => { ok: boolean; error?: string };
|
||||
'popup:started': () => void;
|
||||
'popup:stopped': () => void;
|
||||
'popup:tab-changed': (data: { currentTabId: number; recordingTabId: number | undefined }) => void;
|
||||
'popup:check-status': () => { active: boolean; startTime?: number };
|
||||
'popup:ready': () => void;
|
||||
'popup:get-sessions': () => { id: string; startTime: number; tabId: number; chunkCount: number; totalEvents: number }[];
|
||||
'popup:get-session-events': (sessionId: string) => unknown[];
|
||||
'popup:delete-session': (sessionId: string) => { ok: boolean };
|
||||
'popup:download-session': (sessionId: string) => { ok: boolean };
|
||||
|
||||
// --- Content 相关 ---
|
||||
'content:save-track-events': (event: unknown) => boolean;
|
||||
'content:start-recording': () => { ok: boolean; error?: string };
|
||||
'content:stop-recording': () => { ok: boolean };
|
||||
'content:check-status': () => boolean;
|
||||
|
||||
// --- Offscreen 相关 ---
|
||||
'offscreen:start-recording': (streamId: string) => void;
|
||||
'offscreen:stop-recording': () => void;
|
||||
// Placeholder - 扩展消息协议
|
||||
}
|
||||
|
||||
export const { sendMessage, onMessage } = defineExtensionMessaging<ProtocolMap>();
|
||||
|
||||
@@ -1,367 +0,0 @@
|
||||
/**
|
||||
* 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<IDBDatabase> {
|
||||
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<void> {
|
||||
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<number> {
|
||||
// 参数验证
|
||||
if (!sessionId || typeof sessionId !== 'string') {
|
||||
console.error('[db] Invalid sessionId for getSessionChunkCount:', sessionId);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const database = await openDB();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const tx = database.transaction('sessions', 'readonly');
|
||||
const store = tx.objectStore('sessions');
|
||||
|
||||
try {
|
||||
const request = store.get(sessionId);
|
||||
|
||||
request.onsuccess = () => {
|
||||
const session = request.result as RecordingSession | undefined;
|
||||
resolve(session?.chunkCount || 0);
|
||||
};
|
||||
request.onerror = (e) => {
|
||||
console.error('[db] Error getting session chunk count:', e);
|
||||
resolve(0);
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[db] Exception getting session chunk count:', error);
|
||||
resolve(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 追加事件到存储(自动分块)
|
||||
* 当当前 chunk 满时创建新 chunk
|
||||
*/
|
||||
export async function appendEvents(sessionId: string, newEvents: unknown[]): Promise<void> {
|
||||
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<EventChunk | null> {
|
||||
// 参数验证
|
||||
if (!sessionId || typeof sessionId !== 'string') {
|
||||
console.error('[db] Invalid sessionId for getChunk:', sessionId);
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
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 = (e) => {
|
||||
console.error('[db] Error getting chunk:', e);
|
||||
resolve(null);
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[db] Exception getting chunk:', error);
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存 chunk
|
||||
*/
|
||||
async function saveChunk(database: IDBDatabase, chunk: EventChunk): Promise<void> {
|
||||
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<void> {
|
||||
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<void>,
|
||||
): Promise<void> {
|
||||
// 参数验证
|
||||
if (!sessionId || typeof sessionId !== 'string') {
|
||||
console.error('[db] Invalid sessionId for streamAllEvents:', sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const database = await openDB();
|
||||
const chunkCount = await getSessionChunkCount(sessionId);
|
||||
|
||||
console.log(`[db] Streaming ${chunkCount} chunks for session ${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);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[db] Error streaming events:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有事件(用于下载,一次性加载)
|
||||
* 注意:这会将所有事件加载到内存,仅在需要导出时调用
|
||||
*/
|
||||
export async function getAllEvents(sessionId: string): Promise<unknown[]> {
|
||||
// 参数验证
|
||||
if (!sessionId || typeof sessionId !== 'string') {
|
||||
console.error('[db] Invalid sessionId for getAllEvents:', sessionId);
|
||||
return [];
|
||||
}
|
||||
|
||||
const allEvents: unknown[] = [];
|
||||
|
||||
await streamAllEvents(sessionId, (events) => {
|
||||
allEvents.push(...events);
|
||||
});
|
||||
|
||||
return allEvents;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除录制会话的所有数据
|
||||
*/
|
||||
export async function deleteRecordingSession(sessionId: string): Promise<void> {
|
||||
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<IDBCursorWithValue>).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);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有录制会话列表
|
||||
*/
|
||||
export async function getAllSessions(): Promise<RecordingSession[]> {
|
||||
const database = await openDB();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = database.transaction('sessions', 'readonly');
|
||||
const store = tx.objectStore('sessions');
|
||||
const index = store.index('startTime');
|
||||
const request = index.openCursor(null, 'prev'); // 按时间倒序排列
|
||||
const sessions: RecordingSession[] = [];
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result;
|
||||
if (cursor) {
|
||||
sessions.push(cursor.value);
|
||||
cursor.continue();
|
||||
} else {
|
||||
resolve(sessions);
|
||||
}
|
||||
};
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成唯一的会话 ID
|
||||
*/
|
||||
export function generateSessionId(): string {
|
||||
return `session_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理数据库(用于调试或重置)
|
||||
*/
|
||||
export async function clearDatabase(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.deleteDatabase(DB_NAME);
|
||||
request.onsuccess = () => {
|
||||
db = null;
|
||||
resolve();
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
export const downloadHtmlInBackground = (events: unknown[]) => {
|
||||
if (!events || events.length === 0) return;
|
||||
|
||||
// 安全转义
|
||||
const safeEventsString = JSON.stringify(events)
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/`/g, '\\`')
|
||||
.replace(/<\/script>/g, '<\\/script>');
|
||||
|
||||
// 使用固定版本的 rrweb-player,确保与项目使用的 rrweb 版本兼容
|
||||
const htmlContent = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>RRWeb 回放</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/rrweb-player@1.0.0-alpha.4/dist/style.css" />
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: #f0f2f5;
|
||||
}
|
||||
#player {
|
||||
width: 100%;
|
||||
max-width: 1024px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="player"></div>
|
||||
|
||||
<!-- 使用与项目 rrweb 版本匹配的 rrweb-player -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/rrweb-player@1.0.0-alpha.4/dist/index.js"></script>
|
||||
|
||||
<script>
|
||||
try {
|
||||
// 解析事件数据
|
||||
const events = JSON.parse(\`${safeEventsString}\`);
|
||||
console.log('Loaded events count:', events.length);
|
||||
|
||||
// 创建播放器
|
||||
const player = new rrwebPlayer({
|
||||
target: document.getElementById('player'),
|
||||
props: {
|
||||
events: events,
|
||||
width: 1024,
|
||||
height: 576,
|
||||
autoPlay: true,
|
||||
showController: true,
|
||||
UNSAFE_replayCanvas: true
|
||||
},
|
||||
});
|
||||
|
||||
console.log('Player created successfully');
|
||||
} catch (error) {
|
||||
console.error('Replay error:', error);
|
||||
document.body.innerHTML = \`
|
||||
<div style="padding: 20px; text-align: center; color: #d32f2f;">
|
||||
<h2>回放失败</h2>
|
||||
<p>\${error.message}</p>
|
||||
<p>请检查控制台(F12)获取详细错误信息。</p>
|
||||
</div>
|
||||
\`;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
// 下载逻辑
|
||||
const blob = new Blob([htmlContent], { type: 'text/html' });
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
chrome.downloads
|
||||
.download({
|
||||
url: reader.result as string,
|
||||
filename: `replay-${Date.now()}.html`,
|
||||
saveAs: true,
|
||||
})
|
||||
.then((r) => console.log('Download started:', r));
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
};
|
||||
@@ -1,35 +0,0 @@
|
||||
import { browser } from 'wxt/browser';
|
||||
|
||||
/**
|
||||
* 获取当前活动标签页的 ID
|
||||
* @returns Promise<number | undefined> 返回活动标签页的 ID,如果未找到则返回 undefined
|
||||
*/
|
||||
export async function getActiveTabId(): Promise<number | undefined> {
|
||||
const activeTabs = await browser.tabs.query({
|
||||
active: true,
|
||||
currentWindow: true,
|
||||
});
|
||||
const activeTab = activeTabs[0];
|
||||
|
||||
if (!activeTab) {
|
||||
throw new Error('No active tab found.');
|
||||
}
|
||||
|
||||
// 检查URL是否受限
|
||||
if (activeTab?.url) {
|
||||
const restrictedProtocols = [
|
||||
'chrome:',
|
||||
'chrome-extension:',
|
||||
'about:',
|
||||
'edge:',
|
||||
'view-source:',
|
||||
'data:',
|
||||
'file:',
|
||||
];
|
||||
if (restrictedProtocols.some((protocol) => activeTab.url!.startsWith(protocol))) {
|
||||
throw new Error(`Cannot send message to a restricted URL: ${activeTab.url}`);
|
||||
}
|
||||
}
|
||||
|
||||
return activeTab.id;
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import * as rrweb from 'rrweb';
|
||||
import { listenerHandler } from '@rrweb/types';
|
||||
import { getRecordConsolePlugin } from '@rrweb/rrweb-plugin-console-record';
|
||||
import { sendMessage } from '@/utils/messages';
|
||||
|
||||
export const createRecorder = () => {
|
||||
let stopFn: listenerHandler | null = null;
|
||||
|
||||
const startRecord = async () => {
|
||||
try {
|
||||
const handler = rrweb.record({
|
||||
emit(event) {
|
||||
sendMessage('content:save-track-events', event);
|
||||
},
|
||||
plugins: [getRecordConsolePlugin()],
|
||||
});
|
||||
|
||||
stopFn = handler || null;
|
||||
|
||||
console.log('[content] rrweb started');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to start recording:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const stopRecord = () => {
|
||||
if (stopFn) {
|
||||
stopFn();
|
||||
stopFn = null;
|
||||
console.log('[content] rrweb stopped');
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
return { startRecord, stopRecord };
|
||||
};
|
||||
@@ -14,8 +14,6 @@ export default defineConfig({
|
||||
'activeTab',
|
||||
'scripting',
|
||||
'tabs',
|
||||
'offscreen',
|
||||
'downloads',
|
||||
'debugger',
|
||||
],
|
||||
host_permissions: ['<all_urls>'],
|
||||
|
||||
Reference in New Issue
Block a user