Files
rrweb/index.html
xugp 2a7084db5b feat: add export functionality and fix button bindings
- Add export button and exportRecording() function
- Fix button initialization timing with loop check
- Change events to global window.events for export access
- Update all references to use window.events
- Fix stopRecording to enable controls even if replay fails
- Enable all control buttons (play, speed, export) after recording

🎯 Key improvements:
- Users can now export recorded sessions as JSON files
- All buttons now work correctly after recording stops
- Proper error handling for replay initialization
- User-selectable save paths for exported files

📁 Modified: index.html
2026-04-16 10:44:50 +08:00

626 lines
18 KiB
HTML
Raw Permalink 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: #f8f9fa;
}
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;
}
.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;
}
@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>
</ul>
</div>
</div>
<div class="panel">
<h2>▶️ 回放区域</h2>
<div id="replayer" style="width:100%; height:400px; border:2px solid #007bff; border-radius:8px; background:#f8f9fa;"></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 style="margin-top: 10px;">
<button onclick="exportRecording()" style="
background: #28a745;
color: white;
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
" onmouseover="this.style.background='#1e7e34'"
onmouseout="this.style.background='#28a745'">
💾 导出录制文件
</button>
</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>
</ul>
</div>
</div>
</div>
<!-- rrweb 浏览器版本 -->
<link rel="stylesheet" href="./packages/rrweb/dist/style.css">
<script src="./packages/rrweb/dist/rrweb.umd.cjs"></script>
<script>
// 全局变量
window.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 (!window.window.events.length) {
return;
}
destroyReplay();
const target = document.getElementById('replayer');
replayer = new rrweb.Replayer(window.events, {
root: target,
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();
} else {
console.log('等待 rrweb 加载...');
// 确保 rrweb 加载完成后再初始化按钮
function initCheck() {
if (
typeof rrweb !== 'undefined' &&
typeof rrweb.record === 'function' &&
typeof rrweb.Replayer === 'function'
) {
console.log('rrweb 已加载,准备就绪');
setupButtons();
resetReplayControls();
} else {
console.log('等待 rrweb 加载...');
setTimeout(initCheck, 100);
}
}
initCheck();
}
}
function startRecording() {
if (typeof stopRecordingFn === 'function') {
stopRecordingFn();
}
stopRecordingFn = null;
window.events = [];
destroyReplay();
try {
stopRecordingFn = rrweb.record({
emit(event) {
window.events.push(event);
},
recordCanvas: true,
recordCrossOriginIframes: true,
recordAfter: 'DOMContentLoaded',
ignoreSelector: '.status-bar, .info-box, #replayer, #replay-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(`✅ 已录制 ${window.events.length} 个事件`, 'idle');
document.getElementById('start-btn').disabled = false;
document.getElementById('stop-btn').disabled = true;
try {
renderReplay();
console.log('回放已初始化');
} catch (error) {
console.error('初始化回放失败:', error);
// 即使回放失败,也要启用控制按钮
setReplayControlsEnabled(true);
alert('初始化回放失败,但控制条已启用。');
}
console.log('录制完成,事件数量:', window.events.length);
console.log('事件列表:', events);
alert(`录制完成!\n共记录了 ${window.events.length} 个事件。\n请在右侧查看回放与控制条。`);
}
function changeColor() {
const colors = ['#f8f9fa', '#e3f2fd', '#fff3cd', '#d1ecf1d', '#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 会记录并回放这个弹窗操作。');
}
function clearAll() {
if (typeof stopRecordingFn === 'function') {
stopRecordingFn();
stopRecordingFn = null;
}
window.events = [];
document.getElementById('start-btn').disabled = false;
document.getElementById('stop-btn').disabled = true;
destroyReplay();
updateStatus('⚪ 等待录制', 'idle');
}
// 🆕 导出录制功能
function exportRecording() {
if (window.events.length === 0) {
alert('没有录制数据可以导出');
return;
}
// 准备导出数据
const exportData = {
version: '1.0',
timestamp: new Date().toISOString(),
events: window.events
};
// 转换为JSON字符串
const jsonStr = JSON.stringify(exportData, null, 2);
// 创建Blob对象
const blob = new Blob([jsonStr], { type: 'application/json' });
// 创建下载链接
const url = URL.createObjectURL(blob);
// 创建临时链接并触发下载
const a = document.createElement('a');
a.href = url;
// 默认文件名recording-日期时间.json
const now = new Date();
const timestamp = now.toISOString().slice(0, 19).replace(/:/g, '-');
a.download = `recording-${timestamp}.json`;
// 用户点击选择保存位置
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// 清理URL对象
URL.revokeObjectURL(url);
alert(`成功导出 ${window.events.length} 个事件!`);
}
// 确保 rrweb 加载完成后再初始化按钮
function initCheck() {
if (
typeof rrweb !== 'undefined' &&
typeof rrweb.record === 'function' &&
typeof rrweb.Replayer === 'function'
) {
console.log('rrweb 已加载,准备就绪');
setupButtons();
resetReplayControls();
} else {
console.log('等待 rrweb 加载...');
setTimeout(initCheck, 100);
}
}
initCheck();
</script>
</body>
</html>