Files
rrweb/index-with-export.html
xugp 71438691b3
Some checks failed
Tests / Tests (push) Has been cancelled
ESLint Check / ESLint Check and Report Upload (push) Has been cancelled
Prettier Check / Format Check (push) Has been cancelled
Prettier Check / Format Code (push) Has been cancelled
ESLint Check / Build Base for Bundle Size Comparison (push) Has been cancelled
feat: enhance web extension with export functionality and utility improvements
- Add export functionality to SessionList and Player pages
- Add new utility modules: dataOperations, format, path, settings
- Update manifest with export and download permissions
- Enhance storage utility with new data operations
- Add various test scripts and documentation files
2026-04-16 10:44:50 +08:00

839 lines
24 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>rrweb 录制与回放 - 增强版</title>
<style>
body {
font-family: 'Segoe UI', Arial, sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background: #f5f5f5;
}
h1 {
color: #333;
border-bottom: 3px solid #007bff;
padding-bottom: 10px;
}
.container {
display: flex;
gap: 20px;
}
.panel {
flex: 1;
border: 1px solid #ddd;
padding: 20px;
border-radius: 8px;
background: white;
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
.test-area {
min-height: 300px;
border: 2px dashed #007bff;
padding: 20px;
margin: 15px 0;
border-radius: 8px;
background: #ffffff;
}
button {
padding: 12px 24px;
margin: 8px;
cursor: pointer;
background: #007bff;
color: white;
border: none;
border-radius: 6px;
font-size: 15px;
font-weight: 600;
}
button:hover {
background: #0056b3;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,123,255,0.4);
}
button:disabled {
background: #6c757d;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.record-btn {
background: #28a745;
min-width: 120px;
}
.record-btn:hover {
background: #1e7e34;
}
.stop-btn {
background: #dc3545;
min-width: 120px;
}
.stop-btn:hover {
background: #b02a37;
}
.status-bar {
padding: 15px;
margin: 15px 0;
border-radius: 6px;
font-size: 16px;
font-weight: 600;
text-align: center;
}
.status-bar.idle {
background: #e2e3e5;
color: #383d41;
}
.status-bar.recording {
background: #d4edda;
color: #155724;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.info-box {
padding: 15px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 8px;
color: white;
margin-top: 15px;
}
.info-box h3 {
margin-top: 0;
}
.info-box ul {
margin: 10px 0;
padding-left: 20px;
}
.replayer-container {
background: #ffffff;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
#replayer {
background: #ffffff;
}
.replay-controls {
margin-top: 15px;
padding: 15px;
border: 1px solid #d8e2ef;
border-radius: 8px;
background: #fff;
}
.replay-controls.disabled {
opacity: 0.6;
}
.replay-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.timeline {
flex: 1 1 240px;
margin: 0;
accent-color: #007bff;
}
.time-label {
min-width: 48px;
text-align: center;
color: #495057;
font-variant-numeric: tabular-nums;
}
.speed-controls {
display: flex;
gap: 8px;
margin-top: 12px;
flex-wrap: wrap;
}
.speed-controls button,
#play-toggle-btn {
margin: 0;
}
.speed-controls button.active {
background: #17a2b8;
}
.export-controls {
margin-top: 15px;
padding: 15px;
border: 1px solid #d8e2ef;
border-radius: 8px;
background: #f8f9fa;
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.export-controls button {
background: #28a745;
color: white;
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.export-controls button:hover {
background: #1e7e34;
}
.import-label {
background: #17a2b8;
color: white;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
display: inline-block;
font-size: 14px;
}
.import-label:hover {
background: #138496;
}
#import-file {
display: none;
}
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.5);
}
.modal-content {
background-color: #fefefe;
margin: 15% auto;
padding: 20px;
border: 1px solid #888;
width: 500px;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
}
.close {
color: #aaa;
float: right;
font-size: 28px;
font-weight: bold;
cursor: pointer;
}
.close:hover {
color: black;
}
.form-group {
margin: 15px 0;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: 600;
}
.form-group input,
.form-group select {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.modal-buttons {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 20px;
}
.modal-buttons button {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
}
.modal-buttons button.confirm {
background: #007bff;
color: white;
}
.modal-buttons button.cancel {
background: #6c757d;
color: white;
}
.export-info {
background: #e9ecef;
padding: 10px;
border-radius: 4px;
margin-top: 10px;
font-size: 14px;
color: #495057;
}
@media (max-width: 900px) {
.container {
flex-direction: column;
}
#replayer {
height: 320px !important;
}
}
</style>
</head>
<body>
<h1>🎥 rrweb 录制与回放 - 增强版</h1>
<div class="container">
<div class="panel">
<h2>📹 录制区域</h2>
<div class="test-area">
<h3 style="margin-top:0;">在此区域进行操作</h3>
<p>点击按钮、输入文字,所有操作都会被记录:</p>
<button onclick="changeColor()">🎨 随机变色</button>
<button onclick="addCounter()">🔢 添加计数器</button>
<button onclick="showAlert()">💬 测试弹窗</button>
<div id="counters" style="display:flex; gap:10px; flex-wrap:wrap; margin-top:15px;"></div>
</div>
<div id="status-bar" class="status-bar idle">
⚪ 等待录制
</div>
<div style="margin: 15px 0;">
<button id="start-btn" class="record-btn" onclick="startRecording()">▶ 开始录制</button>
<button id="stop-btn" class="stop-btn" onclick="stopRecording()" disabled>⏹ 停止录制</button>
<button onclick="clearAll()">🗑️ 清空</button>
</div>
<div class="info-box">
<h3>✨ 功能说明</h3>
<ul>
<li>点击"开始录制"开始记录</li>
<li>点击"停止录制"结束记录</li>
<li>右侧会自动回放录制的操作</li>
<li>支持播放速度控制</li>
<li>🆕 新增:支持录制数据导出/导入</li>
</ul>
</div>
</div>
<div class="panel">
<h2>▶️ 回放区域</h2>
<div id="replayer" class="replayer-container" style="width:100%; height:400px; border:2px solid #007bff;"></div>
<div id="replay-controls" class="replay-controls disabled">
<div class="replay-row">
<button id="play-toggle-btn" type="button" disabled>▶ 播放</button>
<span id="current-time" class="time-label">00:00</span>
<input id="timeline" class="timeline" type="range" min="0" max="0" step="1" value="0" disabled>
<span id="total-time" class="time-label">00:00</span>
</div>
<div class="speed-controls">
<button type="button" data-speed="0.5" disabled>0.5x</button>
<button type="button" data-speed="1" class="active" disabled>1x</button>
<button type="button" data-speed="2" disabled>2x</button>
<button type="button" data-speed="4" disabled>4x</button>
</div>
</div>
<!-- 🆕 导出控制区域 -->
<div class="export-controls">
<button id="export-btn" onclick="openExportModal()">
💾 导出录制
</button>
<label for="import-file" class="import-label">
📁 导入录制
</label>
<input type="file" id="import-file" accept=".json,.gz" onchange="handleImport(event)" />
<div class="export-info">
💡 提示:导出录制数据可以保存为文件,用于后续回放或分享
</div>
</div>
<div class="info-box" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);">
<h3>🎬 回放控制</h3>
<p>播放器提供完整控制:</p>
<ul>
<li>时间轴拖动:点击任意位置跳转</li>
<li>播放/暂停:控制播放状态</li>
<li>速度控制0.5x、1x、2x、4x</li>
<li>🆕 导出/导入:保存和加载录制数据</li>
</ul>
</div>
</div>
</div>
<!-- 导出选项模态框 -->
<div id="export-modal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeExportModal()">&times;</span>
<h3>导出选项</h3>
<div class="form-group">
<label for="export-format">文件格式:</label>
<select id="export-format">
<option value="json">JSON (推荐)</option>
<option value="json.gz">JSON 压缩</option>
</select>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="include-metadata" checked>
包含元数据
</label>
</div>
<div class="form-group">
<label for="export-filename">文件名:</label>
<input type="text" id="export-filename"
placeholder="recording-2024-01-01T12:00:00">
</div>
<div class="export-info">
📊 <span id="export-stats">当前录制0 个事件</span>
</div>
<div class="modal-buttons">
<button class="confirm" onclick="confirmExport()">确认导出</button>
<button class="cancel" onclick="closeExportModal()">取消</button>
</div>
</div>
</div>
<!-- rrweb 浏览器版本 -->
<link rel="stylesheet" href="./packages/rrweb/dist/style.css">
<script src="./packages/rrweb/dist/rrweb.umd.cjs"></script>
<script>
let events = [];
let stopRecordingFn = null;
let replayer = null;
let replayMeta = null;
let replayFrame = null;
let isReplaying = false;
let currentSpeed = 1;
function getReplayElements() {
return {
controls: document.getElementById('replay-controls'),
playToggle: document.getElementById('play-toggle-btn'),
timeline: document.getElementById('timeline'),
currentTime: document.getElementById('current-time'),
totalTime: document.getElementById('total-time'),
speedButtons: Array.from(document.querySelectorAll('#replay-controls [data-speed]')),
};
}
function formatTime(ms) {
const safeMs = Math.max(0, Math.floor(ms));
const totalSeconds = Math.floor(safeMs / 1000);
const minutes = String(Math.floor(totalSeconds / 60)).padStart(2, '0');
const seconds = String(totalSeconds % 60).padStart(2, '0');
if (safeMs < 60000) {
const tenths = Math.floor((safeMs % 1000) / 100);
return `${minutes}:${seconds}.${tenths}`;
}
return `${minutes}:${seconds}`;
}
function updateStatus(text, type) {
const statusEl = document.getElementById('status-bar');
statusEl.className = `status-bar ${type}`;
statusEl.textContent = text;
}
function updatePlayToggle() {
const { playToggle } = getReplayElements();
playToggle.textContent = isReplaying ? '⏸ 暂停' : '▶ 播放';
}
function setActiveSpeed(speed) {
currentSpeed = speed;
const { speedButtons } = getReplayElements();
speedButtons.forEach((button) => {
button.classList.toggle('active', Number(button.dataset.speed) === speed);
});
}
function setReplayControlsEnabled(enabled) {
const { controls, playToggle, timeline, speedButtons } = getReplayElements();
controls.classList.toggle('disabled', !enabled);
playToggle.disabled = !enabled;
timeline.disabled = !enabled;
speedButtons.forEach((button) => {
button.disabled = !enabled;
});
}
function syncReplayUI() {
if (!replayer || !replayMeta) {
return;
}
const { timeline, currentTime, totalTime } = getReplayElements();
const timeOffset = Math.min(replayer.getCurrentTime(), replayMeta.totalTime);
timeline.max = String(replayMeta.totalTime);
timeline.value = String(Math.max(0, Math.floor(timeOffset)));
currentTime.textContent = formatTime(timeOffset);
totalTime.textContent = formatTime(replayMeta.totalTime);
if (isReplaying && timeOffset >= replayMeta.totalTime) {
isReplaying = false;
updatePlayToggle();
stopReplayLoop();
}
}
function stopReplayLoop() {
if (replayFrame !== null) {
cancelAnimationFrame(replayFrame);
replayFrame = null;
}
}
function startReplayLoop() {
stopReplayLoop();
const tick = () => {
syncReplayUI();
if (isReplaying && replayer) {
replayFrame = requestAnimationFrame(tick);
} else {
replayFrame = null;
}
};
replayFrame = requestAnimationFrame(tick);
}
function resetReplayControls() {
replayMeta = null;
isReplaying = false;
stopReplayLoop();
setReplayControlsEnabled(false);
setActiveSpeed(1);
updatePlayToggle();
const { timeline, currentTime, totalTime } = getReplayElements();
timeline.max = '0';
timeline.value = '0';
currentTime.textContent = '00:00';
totalTime.textContent = '00:00';
}
function destroyReplay() {
stopReplayLoop();
if (replayer && typeof replayer.destroy === 'function') {
replayer.destroy();
}
replayer = null;
document.getElementById('replayer').innerHTML = '';
resetReplayControls();
}
function renderReplay() {
if (!events.length) {
return;
}
destroyReplay();
replayer = new rrweb.Replayer(events, {
root: document.getElementById('replayer'),
speed: currentSpeed,
});
replayMeta = replayer.getMetaData();
setReplayControlsEnabled(true);
syncReplayUI();
replayer.play();
isReplaying = true;
updatePlayToggle();
startReplayLoop();
}
function toggleReplay() {
if (!replayer || !replayMeta) {
return;
}
const { timeline } = getReplayElements();
const timeOffset = Number(timeline.value);
if (isReplaying) {
replayer.pause();
isReplaying = false;
stopReplayLoop();
} else {
replayer.play(timeOffset);
isReplaying = true;
startReplayLoop();
}
updatePlayToggle();
syncReplayUI();
}
function seekReplay(timeOffset) {
if (!replayer || !replayMeta) {
return;
}
const safeOffset = Math.max(0, Math.min(timeOffset, replayMeta.totalTime));
if (isReplaying) {
replayer.play(safeOffset);
startReplayLoop();
} else {
replayer.pause(safeOffset);
}
syncReplayUI();
}
function changeReplaySpeed(speed) {
setActiveSpeed(speed);
if (replayer) {
replayer.setConfig({ speed });
}
}
function setupButtons() {
document.getElementById('start-btn').onclick = startRecording;
document.getElementById('stop-btn').onclick = stopRecording;
document.getElementById('play-toggle-btn').onclick = toggleReplay;
document.getElementById('timeline').addEventListener('input', (event) => {
const value = Number(event.target.value);
getReplayElements().currentTime.textContent = formatTime(value);
});
document.getElementById('timeline').addEventListener('change', (event) => {
seekReplay(Number(event.target.value));
});
getReplayElements().speedButtons.forEach((button) => {
button.addEventListener('click', () => {
changeReplaySpeed(Number(button.dataset.speed));
});
});
}
function initWhenReady() {
if (
typeof rrweb !== 'undefined' &&
typeof rrweb.record === 'function' &&
typeof rrweb.Replayer === 'function'
) {
console.log('rrweb 已加载,准备就绪');
setupButtons();
resetReplayControls();
updateExportStats();
} else {
console.log('等待 rrweb 加载...');
setTimeout(initWhenReady, 100);
}
}
function startRecording() {
if (typeof stopRecordingFn === 'function') {
stopRecordingFn();
}
stopRecordingFn = null;
events = [];
destroyReplay();
try {
stopRecordingFn = rrweb.record({
emit(event) {
events.push(event);
},
recordCanvas: true,
recordCrossOriginIframes: true,
recordAfter: 'DOMContentLoaded',
ignoreSelector: '.status-bar, .info-box, #replayer, #replay-controls, .export-controls',
});
updateStatus('🔴 正在录制...', 'recording');
document.getElementById('start-btn').disabled = true;
document.getElementById('stop-btn').disabled = false;
console.log('录制已启动');
} catch (error) {
updateStatus('⚪ 等待录制', 'idle');
console.error('启动录制失败:', error);
alert('启动录制失败: ' + error.message);
}
}
function stopRecording() {
if (typeof stopRecordingFn !== 'function') {
return;
}
stopRecordingFn();
stopRecordingFn = null;
updateStatus(`✅ 已录制 ${events.length} 个事件`, 'idle');
document.getElementById('start-btn').disabled = false;
document.getElementById('stop-btn').disabled = true;
try {
renderReplay();
console.log('回放已初始化');
} catch (error) {
console.error('初始化回放失败:', error);
alert('初始化回放失败: ' + error.message);
return;
}
console.log('录制完成,事件数量:', events.length);
console.log('事件列表:', events);
alert(`录制完成!\n共记录了 ${events.length} 个事件。\n请在右侧查看回放与控制条。`);
updateExportStats();
}
function clearAll() {
if (typeof stopRecordingFn === 'function') {
stopRecordingFn();
stopRecordingFn = null;
}
events = [];
document.getElementById('start-btn').disabled = false;
document.getElementById('stop-btn').disabled = true;
destroyReplay();
updateStatus('⚪ 等待录制', 'idle');
updateExportStats();
}
// 🆕 导出功能
function openExportModal() {
document.getElementById('export-modal').style.display = 'block';
updateExportStats();
// 设置默认文件名
const now = new Date();
const timestamp = now.toISOString().slice(0, 19).replace(/:/g, '-');
document.getElementById('export-filename').value = `recording-${timestamp}`;
}
function closeExportModal() {
document.getElementById('export-modal').style.display = 'none';
}
function confirmExport() {
const format = document.getElementById('export-format').value;
const includeMetadata = document.getElementById('include-metadata').checked;
const filename = document.getElementById('export-filename').value ||
`recording-${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}`;
// 准备导出数据
const exportData = {
version: '1.0',
timestamp: new Date().toISOString(),
metadata: includeMetadata ? {
eventCount: events.length,
duration: events.length > 0 ?
Math.max(...events.map(e => e.timestamp)) - Math.min(...events.map(e => e.timestamp)) : 0,
browserInfo: navigator.userAgent
} : undefined,
events: events
};
// 序列化数据
const jsonStr = JSON.stringify(exportData, null, 2);
const dataStr = format === 'json.gz' ? compress(jsonStr) : jsonStr;
// 创建并下载文件
const blob = new Blob([dataStr], {
type: format === 'json.gz' ? 'application/gzip' : 'application/json'
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${filename}.${format}`;
a.click();
URL.revokeObjectURL(url);
closeExportModal();
alert(`成功导出 ${events.length} 个事件!`);
}
// 🆕 导入功能
function handleImport(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function(e) {
try {
const data = JSON.parse(e.target.result);
// 验证数据格式
if (!validateRecording(data.events)) {
throw new Error('录制文件格式无效');
}
// 替换当前录制
events = data.events;
replayMeta = null;
// 重新初始化回放
if (events.length > 0) {
renderReplay();
}
alert(`成功导入 ${data.events.length} 个事件`);
updateExportStats();
} catch (error) {
alert('导入失败: ' + error.message);
}
};
reader.onerror = function() {
alert('文件读取失败');
};
reader.readAsText(file);
// 清空文件输入,允许重复选择同一文件
event.target.value = '';
}
// 🆕 验证录制数据
function validateRecording(events) {
if (!Array.isArray(events)) {
return false;
}
return events.every(event => {
return (
event &&
typeof event === 'object' &&
typeof event.timestamp === 'number' &&
typeof event.type === 'string' &&
event.timestamp > 0
);
});
}
// 🆕 更新导出统计信息
function updateExportStats() {
const stats = document.getElementById('export-stats');
stats.textContent = `当前录制:${events.length} 个事件`;
}
// 模拟压缩函数(简化版)
function compress(str) {
// 这里应该使用真正的压缩算法,如 pako
// 为了演示,我们使用简单的 Base64 编码
return btoa(unescape(encodeURIComponent(str)));
}
function changeColor() {
const colors = ['#f8f9fa', '#e3f2fd', '#fff3cd', '#d1ecf1', '#f8d7da', '#d6d8db', '#cce5ff', '#e2d9f7'];
document.querySelector('.test-area').style.background = colors[Math.floor(Math.random() * colors.length)];
}
function addCounter() {
const counters = document.getElementById('counters');
const count = counters.children.length;
const div = document.createElement('div');
div.style.cssText = 'padding:10px 20px; background:white; border:2px solid #007bff; border-radius:8px; font-size:24px; font-weight:bold; cursor:pointer;';
div.innerHTML = `<span>#${count + 1}</span>`;
div.onclick = function() {
const span = this.querySelector('span');
span.innerText = parseInt(span.innerText) + 1;
};
counters.appendChild(div);
}
function showAlert() {
alert('这是一个测试弹窗!\\nrrweb 会记录并回放这个弹窗操作。');
}
// 点击模态框外部关闭
window.onclick = function(event) {
const modal = document.getElementById('export-modal');
if (event.target === modal) {
closeExportModal();
}
}
setTimeout(initWhenReady, 100);
</script>
</body>
</html>