From a53b58a007f0911c512918ee4173613415fe820e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=A8=E9=9C=96=E9=93=83?= Date: Sun, 25 Jan 2026 12:36:52 +0800 Subject: [PATCH] feat(app): add rrweb recording and replay functionality - Integrate rrweb library for session recording capabilities - Add RecordeReplayPage component with start/stop recording buttons - Implement useRecorder hook to manage recording state and events - Create bridge service for extension communication - Add storage service with Chrome extension and web compatibility - Implement downloadHtml utility for replay file generation - Update manifest permissions for activeTab, scripting, and tabs - Add Chrome extension type definitions as development dependencies --- package-lock.json | 179 +++++++++++++++++++++++++++++++-- package.json | 8 ++ public/favicon.ico | Bin 3870 -> 15685 bytes public/manifest.json | 9 +- src/App.js | 2 + src/hooks/useRecorder.js | 42 ++++++++ src/pages/RecordeReplayPage.js | 62 ++++++++++++ src/services/bridge.js | 56 +++++++++++ src/services/storage.js | 40 ++++++++ src/utils/env.js | 7 ++ src/utils/recordUtils.js | 53 ++++++++++ 11 files changed, 449 insertions(+), 9 deletions(-) create mode 100644 src/hooks/useRecorder.js create mode 100644 src/pages/RecordeReplayPage.js create mode 100644 src/services/bridge.js create mode 100644 src/services/storage.js create mode 100644 src/utils/env.js create mode 100644 src/utils/recordUtils.js diff --git a/package-lock.json b/package-lock.json index 60b7ff0..0fa5549 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "chrome-extension-router-demo", "version": "0.1.0", "dependencies": { + "@rrweb/all": "^2.0.0-alpha.18", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.1", @@ -21,7 +22,14 @@ "react-router-dom": "^6.30.2", "react-scripts": "5.0.1", "remark-gfm": "^1.0.0", + "rrweb": "^2.0.0-alpha.4", + "rrweb-player": "^1.0.0-alpha.4", "web-vitals": "^2.1.4" + }, + "devDependencies": { + "@types/chrome": "^0.1.36", + "@types/react": "^19.2.9", + "@types/react-dom": "^19.2.3" } }, "node_modules/@adobe/css-tools": { @@ -3066,6 +3074,39 @@ "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==", "license": "MIT" }, + "node_modules/@rrweb/all": { + "version": "2.0.0-alpha.18", + "resolved": "https://registry.npmmirror.com/@rrweb/all/-/all-2.0.0-alpha.18.tgz", + "integrity": "sha512-QJ1WxHfAdkDadu79/zrZt+yzco6ckPOXEVRMW/RIv+8BIFh3q/j89y9rPbg5BpmH662V5J0GB99f7BKIM+RMJg==", + "license": "MIT", + "dependencies": { + "@rrweb/packer": "^2.0.0-alpha.18", + "@rrweb/types": "^2.0.0-alpha.18", + "rrweb": "^2.0.0-alpha.18" + } + }, + "node_modules/@rrweb/packer": { + "version": "2.0.0-alpha.18", + "resolved": "https://registry.npmmirror.com/@rrweb/packer/-/packer-2.0.0-alpha.18.tgz", + "integrity": "sha512-rEXltE/gnflEv/NatVNPIp4/6EtVlPLSwzIbd4WPSMtQGvwZe5OfAAk/Y88HkbAwb1nHfdA3PtFGn2Epik4inQ==", + "license": "MIT", + "dependencies": { + "@rrweb/types": "^2.0.0-alpha.18", + "fflate": "^0.4.4" + } + }, + "node_modules/@rrweb/types": { + "version": "2.0.0-alpha.18", + "resolved": "https://registry.npmmirror.com/@rrweb/types/-/types-2.0.0-alpha.18.tgz", + "integrity": "sha512-iMH3amHthJZ9x3gGmBPmdfim7wLGygC2GciIkw2A6SO8giSn8PHYtRT8OKNH4V+k3SZ6RSnYHcTQxBA7pSWZ3Q==", + "license": "MIT" + }, + "node_modules/@rrweb/utils": { + "version": "2.0.0-alpha.18", + "resolved": "https://registry.npmmirror.com/@rrweb/utils/-/utils-2.0.0-alpha.18.tgz", + "integrity": "sha512-qV8azQYo9RuwW4NGRtOiQfTBdHNL1B0Q//uRLMbCSjbaKqJYd88Js17Bdskj65a0Vgp2dwTLPIZ0gK47dfjfaA==", + "license": "MIT" + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/@rtsao/scc/-/scc-1.1.0.tgz", @@ -3449,6 +3490,12 @@ "node": ">=10.13.0" } }, + "node_modules/@tsconfig/svelte": { + "version": "1.0.13", + "resolved": "https://registry.npmmirror.com/@tsconfig/svelte/-/svelte-1.0.13.tgz", + "integrity": "sha512-5lYJP45Xllo4yE/RUBccBT32eBlRDbqN8r1/MIvQbKxW3aFqaYPCNgm8D5V20X4ShHcwvYWNlKg3liDh1MlBoA==", + "license": "MIT" + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmmirror.com/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -3515,6 +3562,17 @@ "@types/node": "*" } }, + "node_modules/@types/chrome": { + "version": "0.1.36", + "resolved": "https://registry.npmmirror.com/@types/chrome/-/chrome-0.1.36.tgz", + "integrity": "sha512-BvHbuyGttYXnGt5Gpwa4769KIinKHY1iLjlAPrrMBS2GI9m/XNMPtdsq0NgQalyuUdxvlMN/0OyGw0shFVIoUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/filesystem": "*", + "@types/har-format": "*" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmmirror.com/@types/connect/-/connect-3.4.38.tgz", @@ -3534,6 +3592,12 @@ "@types/node": "*" } }, + "node_modules/@types/css-font-loading-module": { + "version": "0.0.7", + "resolved": "https://registry.npmmirror.com/@types/css-font-loading-module/-/css-font-loading-module-0.0.7.tgz", + "integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==", + "license": "MIT" + }, "node_modules/@types/eslint": { "version": "8.56.12", "resolved": "https://registry.npmmirror.com/@types/eslint/-/eslint-8.56.12.tgz", @@ -3596,6 +3660,23 @@ "@types/send": "*" } }, + "node_modules/@types/filesystem": { + "version": "0.0.36", + "resolved": "https://registry.npmmirror.com/@types/filesystem/-/filesystem-0.0.36.tgz", + "integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/filewriter": "*" + } + }, + "node_modules/@types/filewriter": { + "version": "0.0.33", + "resolved": "https://registry.npmmirror.com/@types/filewriter/-/filewriter-0.0.33.tgz", + "integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmmirror.com/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -3605,6 +3686,13 @@ "@types/node": "*" } }, + "node_modules/@types/har-format": { + "version": "1.2.16", + "resolved": "https://registry.npmmirror.com/@types/har-format/-/har-format-1.2.16.tgz", + "integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/hast": { "version": "2.3.10", "resolved": "https://registry.npmmirror.com/@types/hast/-/hast-2.3.10.tgz", @@ -3735,15 +3823,24 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.7", - "resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.7.tgz", - "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", + "version": "19.2.9", + "resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.9.tgz", + "integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmmirror.com/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, "node_modules/@types/resolve": { "version": "1.17.1", "resolved": "https://registry.npmmirror.com/@types/resolve/-/resolve-1.17.1.tgz", @@ -4236,6 +4333,12 @@ "@xtuc/long": "4.2.2" } }, + "node_modules/@xstate/fsm": { + "version": "1.6.5", + "resolved": "https://registry.npmmirror.com/@xstate/fsm/-/fsm-1.6.5.tgz", + "integrity": "sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==", + "license": "MIT" + }, "node_modules/@xtuc/ieee754": { "version": "1.2.0", "resolved": "https://registry.npmmirror.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz", @@ -5127,6 +5230,15 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.9.7", "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.7.tgz", @@ -6390,8 +6502,7 @@ "version": "3.2.3", "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/damerau-levenshtein": { "version": "1.0.8", @@ -8039,6 +8150,12 @@ "bser": "2.1.1" } }, + "node_modules/fflate": { + "version": "0.4.8", + "resolved": "https://registry.npmmirror.com/fflate/-/fflate-0.4.8.tgz", + "integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -11842,6 +11959,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, "node_modules/mkdirp": { "version": "0.5.6", "resolved": "https://registry.npmmirror.com/mkdirp/-/mkdirp-0.5.6.tgz", @@ -14844,6 +14967,50 @@ "randombytes": "^2.1.0" } }, + "node_modules/rrdom": { + "version": "2.0.0-alpha.18", + "resolved": "https://registry.npmmirror.com/rrdom/-/rrdom-2.0.0-alpha.18.tgz", + "integrity": "sha512-fSFzFFxbqAViITyYVA4Z0o5G6p1nEqEr/N8vdgSKie9Rn0FJxDSNJgjV0yiCIzcDs0QR+hpvgFhpbdZ6JIr5Nw==", + "license": "MIT", + "dependencies": { + "rrweb-snapshot": "^2.0.0-alpha.18" + } + }, + "node_modules/rrweb": { + "version": "2.0.0-alpha.18", + "resolved": "https://registry.npmmirror.com/rrweb/-/rrweb-2.0.0-alpha.18.tgz", + "integrity": "sha512-1mjZcB+LVoGSx1+i9E2ZdAP90fS3MghYVix2wvGlZvrgRuLCbTCCOZMztFCkKpgp7/EeCdYM4nIHJkKX5J1Nmg==", + "license": "MIT", + "dependencies": { + "@rrweb/types": "^2.0.0-alpha.18", + "@rrweb/utils": "^2.0.0-alpha.18", + "@types/css-font-loading-module": "0.0.7", + "@xstate/fsm": "^1.4.0", + "base64-arraybuffer": "^1.0.1", + "mitt": "^3.0.0", + "rrdom": "^2.0.0-alpha.18", + "rrweb-snapshot": "^2.0.0-alpha.18" + } + }, + "node_modules/rrweb-player": { + "version": "1.0.0-alpha.4", + "resolved": "https://registry.npmmirror.com/rrweb-player/-/rrweb-player-1.0.0-alpha.4.tgz", + "integrity": "sha512-Wlmn9GZ5Fdqa37vd3TzsYdLl/JWEvXNUrLCrYpnOwEgmY409HwVIvvA5aIo7k582LoKgdRCsB87N+f0oWAR0Kg==", + "license": "MIT", + "dependencies": { + "@tsconfig/svelte": "^1.0.0", + "rrweb": "^2.0.0-alpha.4" + } + }, + "node_modules/rrweb-snapshot": { + "version": "2.0.0-alpha.18", + "resolved": "https://registry.npmmirror.com/rrweb-snapshot/-/rrweb-snapshot-2.0.0-alpha.18.tgz", + "integrity": "sha512-hBHZL/NfgQX6wO1D9mpwqFu1NJPpim+moIcKhFEjVTZVRUfCln+LOugRc4teVTCISYHN8Cw5e2iNTWCSm+SkoA==", + "license": "MIT", + "dependencies": { + "postcss": "^8.4.38" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmmirror.com/run-parallel/-/run-parallel-1.2.0.tgz", diff --git a/package.json b/package.json index 3cc6a3a..22e9fae 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "0.1.0", "private": true, "dependencies": { + "@rrweb/all": "^2.0.0-alpha.18", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.1", @@ -16,6 +17,8 @@ "react-router-dom": "^6.30.2", "react-scripts": "5.0.1", "remark-gfm": "^1.0.0", + "rrweb": "^2.0.0-alpha.4", + "rrweb-player": "^1.0.0-alpha.4", "web-vitals": "^2.1.4" }, "scripts": { @@ -41,5 +44,10 @@ "last 1 firefox version", "last 1 safari version" ] + }, + "devDependencies": { + "@types/chrome": "^0.1.36", + "@types/react": "^19.2.9", + "@types/react-dom": "^19.2.3" } } diff --git a/public/favicon.ico b/public/favicon.ico index a11777cc471a4344702741ab1c8a588998b1311a..4e0c6b014d7d6211e51160f6b30a638f7cd9188a 100644 GIT binary patch literal 15685 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 literal 3870 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 baj7?x%VD!5Cxk2YbYLNVeiXvvpMCWYo=by@ diff --git a/public/manifest.json b/public/manifest.json index 23b5043..7106cf3 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -1,11 +1,14 @@ { "manifest_version": 3, - "name": "React Router Chrome Extension", + "name": "Testing Tools", "version": "1.0", - "description": "A Chrome Extension with React Router: User List + Actions Pages", + "description": "测试工具", "permissions": [ "storage", - "clipboardWrite" + "clipboardWrite", + "activeTab", + "scripting", + "tabs" ], "background": { "service_worker": "background.js" diff --git a/src/App.js b/src/App.js index acbd24c..55aeccd 100644 --- a/src/App.js +++ b/src/App.js @@ -2,6 +2,7 @@ import {HashRouter as Router, Routes, Route, NavLink} from 'react-router-dom'; import {useState, useEffect} from 'react'; import TimestampPage from './pages/TimestampPage'; // import ElectronicWoodenFishPage from "./pages/ElectronicWoodenFishPage"; +import RecordeReplayPage from './pages/RecordeReplayPage'; import './App.css'; // 导航项配置数组,便于后续添加 @@ -9,6 +10,7 @@ import './App.css'; const navItems = [ {path: '/', label: '时间戳', element: }, // {path: '/dzmy', label: '电子木鱼', element: }, + {path: '/recorde-replay', label: '录制与回放', element: }, ]; function App() { diff --git a/src/hooks/useRecorder.js b/src/hooks/useRecorder.js new file mode 100644 index 0000000..1558267 --- /dev/null +++ b/src/hooks/useRecorder.js @@ -0,0 +1,42 @@ +import {useRef, useState} from "react"; +import {record} from "rrweb"; + +export const useRecorder = () => { + // 存储录制事件 + const eventRef = useRef([]); + // 存储停止录制函数 + const stopFnRef = useRef(); + const [isRecording, setIsRecording] = useState(false); + + const startRecord = async () => { + if (isRecording) return; + + setIsRecording(true); + + // 启动录制 + stopFnRef.current = record({ + emit(event) { + // 存储数据 + eventRef.current.push(event); + } + }); + }; + + const stopRecord = () => { + if (!isRecording) return; + + setIsRecording(false); + + // 停止录制 + if (stopFnRef.current) { + stopFnRef.current(); + } + }; + + return { + startRecord, + stopRecord, + isRecording, + getEvents: () => eventRef.current + } +}; \ No newline at end of file diff --git a/src/pages/RecordeReplayPage.js b/src/pages/RecordeReplayPage.js new file mode 100644 index 0000000..80a08ea --- /dev/null +++ b/src/pages/RecordeReplayPage.js @@ -0,0 +1,62 @@ +import React from 'react'; +// import {sendRecordCommand} from '../services/bridge' +// import {storage} from "../services/storage"; +import {useRecorder} from "../hooks/useRecorder"; +import {downloadHtml} from "../utils/recordUtils"; + +const RecordeReplayPage = () => { + // const [isRecording, setIsRecording] = useState(false); + + const {startRecord, stopRecord, getEvents, isRecording} = useRecorder(); + + const handleStart = async () => { + try { + // await sendRecordCommand("START_RECORD"); + if (!isRecording) { + await startRecord(); + } else { + stopRecord(); + } + // await storage.set({isRecording: true, recordingStartTime: Date.now()}); + // console.log('events', events); + } catch (e) { + console.error("Error starting recording:", e); + } + } + + const handleStop = async () => { + try { + // await sendRecordCommand("STOP_RECORD"); + // const events =useRecorder().getEvents(); + // console.log('events', events); + // setIsRecording(false); + // await storage.remove('isRecording'); + stopRecord(); + downloadHtml(getEvents()); + // console.log('events', getEvents()); + } catch (e) { + console.error("Error stopping recording:", e) + } + }; + + return ( +
+

RRWeb Recorder

+ + {/* 3. 选择标签页逻辑:默认为当前页,如果需要跨页,需先列出 chrome.tabs.query */} + +
+ {!isRecording ? ( + + ) : ( + + )} +
+
+ ); +}; + +export default RecordeReplayPage; diff --git a/src/services/bridge.js b/src/services/bridge.js new file mode 100644 index 0000000..ab9c62b --- /dev/null +++ b/src/services/bridge.js @@ -0,0 +1,56 @@ +// src/services/bridge.ts +import {record} from 'rrweb'; +import {isExtension} from '../utils/env'; + +let stopFn = null; +let events = []; + +/** + * 发送指令给录制器(Content Script 或 当前页面逻辑) + * @param action 'START_RECORD' | 'STOP_RECORD' + * @returns {Promise} + */ +export const sendRecordCommand = async (action) => { + if (isExtension()) { + // === 插件环境 === + try { + const [tab] = await chrome.tabs.query({active: true, currentWindow: true}); + if (tab?.id) { + // 使用 Promise 包装 sendMessage 以便统一 async/await 风格 + return new Promise((resolve, reject) => { + chrome.tabs.sendMessage(tab.id, {action}, (response) => { + if (chrome.runtime.lastError) { + reject(chrome.runtime.lastError); + } else { + resolve(response); + } + }); + }); + } + } catch (e) { + console.error("Extension communication error:", e); + } + } else { + // === 网页环境 (Web Demo / Localhost 调试) === + console.log(`[Web Mode] Simulation: Sending ${action}`); + + if (action === 'START_RECORD') { + stopFn = record({ + emit(event) { + if (events.length > 1000) { + console.log('events length > 1000'); + stopFn(); + } + events.push(event); + } + }); + } else if (action === 'STOP_RECORD') { + if (stopFn != null) { + stopFn(); + } + console.log('[Web Mode] Simulation: Stopping recorder'); + console.log('[Web Mode] Simulation: Events:', events); + console.log('[Web Mode] Simulation: Events:', events.length); + } + } +}; \ No newline at end of file diff --git a/src/services/storage.js b/src/services/storage.js new file mode 100644 index 0000000..2a179f9 --- /dev/null +++ b/src/services/storage.js @@ -0,0 +1,40 @@ +// src/services/storage.ts +import {isExtension} from '../utils/env'; + +export const storage = { + set: async (items) => { + if (isExtension()) { + return chrome.storage.local.set(items); + } else { + // Web 兼容:写入 localStorage + Object.keys(items).forEach(key => { + localStorage.setItem(key, JSON.stringify(items[key])); + }); + return Promise.resolve(); + } + }, + + get: async (keys) => { + if (isExtension()) { + return chrome.storage.local.get(keys); + } else { + // Web 兼容:读取 localStorage + const result = {}; + const keyList = Array.isArray(keys) ? keys : [keys]; + keyList.forEach(key => { + const val = localStorage.getItem(key); + if (val) result[key] = JSON.parse(val); + }); + return Promise.resolve(result); + } + }, + + remove: async (key) => { + if (isExtension()) { + return chrome.storage.local.remove(key); + } else { + localStorage.removeItem(key); + return Promise.resolve(); + } + } +}; \ No newline at end of file diff --git a/src/utils/env.js b/src/utils/env.js new file mode 100644 index 0000000..836b527 --- /dev/null +++ b/src/utils/env.js @@ -0,0 +1,7 @@ +/** + * 判断当前是否处于 Chrome 插件环境 + * @returns {false|chrome.storage.LocalStorageArea} + */ +export const isExtension = () => { + return typeof chrome !== 'undefined' && chrome.storage && chrome.storage.local; +} \ No newline at end of file diff --git a/src/utils/recordUtils.js b/src/utils/recordUtils.js new file mode 100644 index 0000000..4f9f5fb --- /dev/null +++ b/src/utils/recordUtils.js @@ -0,0 +1,53 @@ +// import rrwebPlayer from 'rrweb-player'; + +export const downloadHtml = (events) => { + if (events.length === 0) return; + + // 1. 构建 HTML 模板字符串 + // 我们将 events 数据直接注入到 + + + + + `; + + // 2. 创建 Blob 并下载 + const blob = new Blob([htmlContent], {type: 'text/html'}); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `replay-${Date.now()}.html`; // 保存为 .html 文件 + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +}; \ No newline at end of file