// ==UserScript==
// @name 鼠标点击录制助手-比例坐标版-多目标区域+优化+UI提升+调试
// @namespace http://tampermonkey.net/
// @version 1.0
// @description 增强版:支持多目标区域,优化回放,UI和调试,详尽调试信息。
// @author ChatGPT
// @match *://*/*
// @grant none
// ==/UserScript==
// 匿名立即执行函数,防止变量污染全局作用域
(function() {
'use strict'; // 开启严格模式,有助于编写更规范、更少错误的代码
// -------------- 配置参数 --------------
const DEBUG_MODE = false; // 定义一个常量,用于控制是否开启调试模式。如果为 true,则会在控制台输出调试信息。
// 快捷键配置(无需修改,支持自定义)
const defaultHotkeys = { // 定义默认的快捷键配置对象
startRecord: { ctrlKey: true, altKey:false, shiftKey:false, key: 'w' }, // Ctrl+W 开始录制
pauseRecord: { ctrlKey: true, altKey:false, shiftKey:false, key: 'e' }, // Ctrl+E 暂停录制
resumeRecord: { ctrlKey: true, altKey:false, shiftKey:false, key: 'r' }, // Ctrl+R 继续录制
stopRecord: { ctrlKey: true, altKey:false, shiftKey:false, key: 'q' }, // Ctrl+Q 停止录制
startPlayback: { ctrlKey: true, altKey:false, shiftKey:false, key: 'p' }, // Ctrl+P 开始回放
stopPlayback: { ctrlKey: true, altKey:false, shiftKey:false, key: 'o' }, // Ctrl+O 停止回放
};
// -------------- 变量定义 --------------
let hotkeys = JSON.parse(localStorage.getItem('mouseRecHotkeys')) || defaultHotkeys; // 从 localStorage 加载自定义快捷键,如果不存在则使用默认值
let isRecording = false, isPaused = false; // 记录是否正在录制、是否暂停录制的状态
let lastClickTime = 0; // 记录上一次点击的时间戳,用于计算点击间隔
let recordedClicks = []; // 存储录制到的所有点击事件数据
let totalRecordedTime = 0; // 记录所有点击事件的总延迟时间
let playbackTimer = null; // 回放计时器 ID,用于控制回放的暂停和停止
let playbackIndex = 0; // 回放时当前点击事件的索引
let playbackStartTime = 0; // 回放开始的时间戳,用于计算已回放时长
// 目标区域数组
let targetAreas = JSON.parse(localStorage.getItem('mouseRecTargetAreas')) || []; // 从 localStorage 加载用户定义的目标区域,如果不存在则为空数组
// UI位置记忆
let savedPanelPos = JSON.parse(localStorage.getItem('mouseRecPanelPos')) || null; // 从 localStorage 加载面板的保存位置
let savedBtnPos = JSON.parse(localStorage.getItem('mouseRecBtnPos')) || null; // 从 localStorage 加载入口按钮的保存位置
// 录制时是否阻止网页事件
let preventClicksDuringRecord = false; // 记录录制时是否阻止页面默认点击事件的标志
// 调试开关
let debugMode = DEBUG_MODE; // 将配置参数 DEBUG_MODE 赋值给局部变量 debugMode
function debugLog(msg) { if(debugMode) console.log('[录制调试]', msg); } // 调试日志函数,只有在 debugMode 为 true 时才输出信息
// -------------- UI元素创建 --------------
//【入口按钮】
const entryBtn = document.createElement('button'); // 创建一个按钮元素作为脚本的入口
entryBtn.textContent = '寻到大千脚本'; // 设置按钮的文本内容
Object.assign(entryBtn.style, { // 使用 Object.assign 批量设置按钮的 CSS 样式
position: 'fixed', // 固定定位,使其不随页面滚动
top: savedBtnPos?.top ?? '50px', // 从 localStorage 加载顶部位置,否则默认为 50px
right: savedBtnPos?.right ?? '10px', // 从 localStorage 加载右侧位置,否则默认为 10px
zIndex: 999999, // 设置高层级,确保按钮显示在其他元素之上
padding: '6px 12px', // 内边距
fontSize: '14px', // 字体大小
cursor: 'move', // 鼠标悬停时显示为移动光标,提示可拖动
backgroundColor: '#d9534f', // 背景颜色
color: 'white', // 文本颜色
border: 'none', // 无边框
borderRadius: '4px', // 圆角
userSelect: 'none', // 禁止用户选择文本
transition: 'background-color 0.2s', // 鼠标悬停时背景颜色变化的过渡效果
});
document.body.appendChild(entryBtn); // 将入口按钮添加到页面的 body 中
// 拖动入口按钮
(function dragElement(el, saveKey){ // 定义一个用于拖动元素的匿名立即执行函数,并接受元素和保存键作为参数
let isDown = false, offsetX=0, offsetY=0; // 记录鼠标是否按下、鼠标相对于元素左上角的偏移量
el.addEventListener('mousedown', e => { // 监听元素的鼠标按下事件
// 如果不是左键点击 或者 点击目标是按钮本身,则不进行拖动
if(e.button!==0 || e.target.tagName==='BUTTON') return;
isDown = true; // 标记鼠标已按下
const rect = el.getBoundingClientRect(); // 获取元素的尺寸和位置
offsetX = e.clientX - rect.left; // 计算鼠标点击位置与元素左边缘的距离
offsetY = e.clientY - rect.top; // 计算鼠标点击位置与元素上边缘的距离
document.body.style.userSelect = 'none'; // 拖动时禁止页面文本选择
});
window.addEventListener('mousemove', e => { // 监听窗口的鼠标移动事件
if(!isDown) return; // 如果鼠标未按下,则不执行
let left=e.clientX - offsetX, top=e.clientY - offsetY; // 计算元素的新位置
// 限制边界,确保元素不会拖出可视区域
left = Math.min(Math.max(0, left), window.innerWidth - el.offsetWidth);
top = Math.min(Math.max(0, top), window.innerHeight - el.offsetHeight);
el.style.left=left+'px'; el.style.top=top+'px'; // 更新元素的 left 和 top 样式
el.style.right='auto'; el.style.bottom='auto'; // 清除 right 和 bottom 样式,避免冲突
});
window.addEventListener('mouseup', e=>{ // 监听窗口的鼠标松开事件
if(isDown){ isDown=false; document.body.style.userSelect='';} // 标记鼠标已松开,恢复页面文本选择
// 位置存储:将元素当前的位置保存到 localStorage
localStorage.setItem(saveKey, JSON.stringify({top:el.style.top, left:el.style.left, right:el.style.right, bottom:el.style.bottom}));
});
})(entryBtn,'mouseRecBtnPos'); // 立即执行拖动函数,传入入口按钮和其位置的 localStorage 键名
// 目标区域设置提示
function alertMsg(msg) { alert(msg); } // 封装一个简单的弹窗提示函数
// 面板
const panel = document.createElement('div'); // 创建主功能面板元素
Object.assign(panel.style, { // 批量设置面板的 CSS 样式
position: 'fixed', // 固定定位
top: savedPanelPos?.top ?? '50px', // 从 localStorage 加载顶部位置,否则默认为 50px
right: savedPanelPos?.right ?? '10px', // 从 localStorage 加载右侧位置,否则默认为 10px
width: '370px', // 固定宽度
maxHeight:'90vh', // 最大高度为视口高度的 90%
backgroundColor: '#fafafa', // 背景颜色
border: '2px solid #555', // 边框
padding: '12px', // 内边距
boxShadow: '0 0 12px rgba(0,0,0,0.25)', // 阴影效果
borderRadius: '6px', // 圆角
fontFamily: '"Segoe UI","微软雅黑",Tahoma,Arial,sans-serif', // 字体
fontSize: '14px', // 字体大小
color: '#222', // 文本颜色
userSelect: 'none', // 禁止用户选择文本
zIndex: 999999, // 高层级,确保显示在最上层
display: 'none', // 默认隐藏面板
overflowY: 'auto' // 当内容超出高度时显示垂直滚动条
});
document.body.appendChild(panel); // 将功能面板添加到页面的 body 中
// 设置面板的内部 HTML 结构和内容
panel.innerHTML = `
鼠标点击录制助手
状态:空闲
点数:0,时长:0.00秒
回放时长:0秒,已播放:0秒
快捷键设置(点击输入框后按任意快捷键捕获)
说明:按输入框后可按任意快捷键设置,支持 Ctrl、Alt、Shift,区分大小写。
`;
// -------------- 功能封装 --------------
// 位置拖动存储(与入口按钮的拖动逻辑类似,用于面板)
(function dragElement(el, saveKey){ // 定义一个用于拖动元素的匿名立即执行函数
let isDown = false, offsetX=0, offsetY=0; // 记录鼠标是否按下、鼠标相对于元素左上角的偏移量
el.addEventListener('mousedown', e => { // 监听面板的鼠标按下事件
// 如果点击目标是按钮、文本域或输入框,则不进行拖动(允许这些元素内部的操作)
if(e.target.tagName==='BUTTON' || e.target.tagName==='TEXTAREA' || e.target.tagName==='INPUT') return;
if(e.button!==0) return; // 如果不是左键点击,则不进行拖动
isDown=true; // 标记鼠标已按下
const rect=el.getBoundingClientRect(); // 获取面板的尺寸和位置
offsetX=e.clientX-rect.left; // 计算鼠标点击位置与面板左边缘的距离
offsetY=e.clientY-rect.top; // 计算鼠标点击位置与面板上边缘的距离
document.body.style.userSelect='none'; // 拖动时禁止页面文本选择
});
window.addEventListener('mousemove', e=> { // 监听窗口的鼠标移动事件
if(!isDown) return; // 如果鼠标未按下,则不执行
let left = e.clientX - offsetX; // 计算元素的新 left 位置
let top = e.clientY - offsetY; // 计算元素的新 top 位置
// 限制边界,确保元素不会拖出可视区域
left=Math.min(Math.max(0,left), window.innerWidth - el.offsetWidth);
top=Math.min(Math.max(0,top), window.innerHeight - el.offsetHeight);
el.style.left=left+'px'; el.style.top=top+'px'; // 更新元素的 left 和 top 样式
el.style.right='auto'; el.style.bottom='auto'; // 清除 right 和 bottom 样式
});
window.addEventListener('mouseup', e => { // 监听窗口的鼠标松开事件
if(isDown){ isDown=false; document.body.style.userSelect='';} // 标记鼠标已松开,恢复页面文本选择
// 位置存储:将元素当前的位置保存到 localStorage
localStorage.setItem(saveKey, JSON.stringify({top:el.style.top, left:el.style.left, right:el.style.right, bottom:el.style.bottom}));
});
})(panel, 'mouseRecPanelPos'); // 立即执行拖动函数,传入面板和其位置的 localStorage 键名
// -------------- 控件绑定 ----------------
// 通过 ID 获取面板中的各个交互元素
const btnBackEntry = document.querySelector('#btnBackEntry'); // 返回入口按钮
const btnClearRecord = document.querySelector('#btnClearRecord'); // 清空录制按钮
const btnStartRec = document.querySelector('#btnStartRec'); // 开始录制按钮
const btnPauseRec = document.querySelector('#btnPauseRec'); // 暂停录制按钮
const btnResumeRec = document.querySelector('#btnResumeRec'); // 继续录制按钮
const btnStopRec = document.querySelector('#btnStopRec'); // 停止录制按钮
const btnPlay = document.querySelector('#btnPlay'); // 开始回放按钮
const btnStopPlay = document.querySelector('#btnStopPlay'); // 停止回放按钮
const btnSaveFile = document.querySelector('#btnSaveFile'); // 保存文件按钮
const btnLoadFile = document.querySelector('#btnLoadFile'); // 导入文件按钮
const fileInput = document.querySelector('#fileInput'); // 文件输入框(隐藏的)
const recData = document.querySelector('#recData'); // 录制数据显示文本域
const statusArea = document.querySelector('#statusArea'); // 状态显示区域
const btnSetHotkeys = document.querySelector('#btnSetHotkeys'); // 设置快捷键按钮
const hotkeySettingsPanel = document.querySelector('#hotkeySettingsPanel'); // 快捷键设置面板
const hotkeyForm = document.querySelector('#hotkeyForm'); // 快捷键设置表单
const btnSaveHotkeys = document.querySelector('#btnSaveHotkeys'); // 保存快捷键按钮
const btnCancelHotkeys = document.querySelector('#btnCancelHotkeys'); // 取消快捷键设置按钮
const chkPreventClicks = document.querySelector('#chkPreventClicks'); // 阻止点击事件复选框
// 复选框初始化
chkPreventClicks.checked = preventClicksDuringRecord; // 根据当前配置初始化复选框状态
chkPreventClicks.onchange = e => { // 监听复选框的改变事件
preventClicksDuringRecord=e.target.checked; // 更新阻止点击事件的配置
updateStatusAndUI(); // 更新状态和UI显示
debugLog(`阻止点击事件设置:${preventClicksDuringRecord}`); // 输出调试信息
};
// -------------- 快捷键配置 ----------------
// 将快捷键对象转换为字符串,例如 { ctrlKey: true, key: 'w' } -> "Ctrl+W"
function hotkeyObjToStr(hk) {
if(!hk.key) return ''; // 如果没有 key,返回空字符串
const parts = []; // 用于存储快捷键组合的数组
if(hk.ctrlKey) parts.push('Ctrl'); // 如果 Ctrl 键被按下,添加 'Ctrl'
if(hk.altKey) parts.push('Alt'); // 如果 Alt 键被按下,添加 'Alt'
if(hk.shiftKey) parts.push('Shift'); // 如果 Shift 键被按下,添加 'Shift'
parts.push(hk.key.length===1 ? hk.key.toUpperCase() : hk.key); // 添加按键本身,如果是单个字母则转为大写
return parts.join('+'); // 用 '+' 连接所有部分
}
// 将快捷键字符串转换为对象,例如 "Ctrl+W" -> { ctrlKey: true, key: 'w' }
function hotkeyStrToObj(str) {
// 分割字符串,并转换为小写,去除空格
const parts = str.trim().split(/[\+\-\s]+/).map(s=>s.trim());
let hk = { ctrlKey:false, altKey:false, shiftKey:false, key:'' }; // 初始化快捷键对象
for(const p of parts) { // 遍历分割后的部分
const pp=p.toLowerCase(); // 转换为小写进行判断
if(pp==='ctrl') hk.ctrlKey=true; // 如果是 'ctrl',设置 ctrlKey 为 true
else if(pp==='alt') hk.altKey=true; // 如果是 'alt',设置 altKey 为 true
else if(pp==='shift') hk.shiftKey=true; // 如果是 'shift',设置 shiftKey 为 true
else if(p.length>0) hk.key=p.toLowerCase(); // 如果是按键本身,设置 key(转为小写)
}
if(!hk.key) return null; // 如果没有按键,返回 null
return hk; // 返回转换后的快捷键对象
}
// 更新快捷键设置面板中的输入框显示(此函数代码略有冗余,直接赋值即可)
function updateHotkeyInputs() {
// 这段代码是尝试遍历更新,但hotkeyForm不是一个直接的DOM元素,它只是ID。
// hotkeyForm[id] = ... 这行实际上是无效的。
// 正确的做法是直接通过querySelector获取元素并赋值。
['startRecord','pauseRecord','resumeRecord','stopRecord','startPlayback','stopPlayback']
.forEach(id => {
const hk=hotkeys[id];
// 这里的hotkeyForm[id] = ... 实际上是无效的,因为hotkeyForm不是一个map或object,而是一个DOM元素。
// 这导致了后续需要单独赋值。
// hotkeyForm[id]=({}) => {
// return hotkeyObjToStr(hk);
// }
// hotkeyForm[`#${id}`].value= hotkeyObjToStr(hk);
});
//单独赋值:确保每个输入框显示正确的快捷键字符串
document.querySelector('#hkStart').value=hotkeyObjToStr(hotkeys.startRecord);
document.querySelector('#hkPause').value=hotkeyObjToStr(hotkeys.pauseRecord);
document.querySelector('#hkResume').value=hotkeyObjToStr(hotkeys.resumeRecord);
document.querySelector('#hkStop').value=hotkeyObjToStr(hotkeys.stopRecord);
document.querySelector('#hkPlay').value=hotkeyObjToStr(hotkeys.startPlayback);
document.querySelector('#hkStopPlay').value=hotkeyObjToStr(hotkeys.stopPlayback);
}
// 更新状态显示区域的文本内容
function updateStatus(text) {
statusArea.innerHTML = text.replace(/\n/g,'
'); // 将换行符转换为
标签以便在 HTML 中正确显示
}
// 更新所有状态和UI元素显示
function updateStatusAndUI() {
const seconds = (totalRecordedTime/1000).toFixed(2); // 将总录制时间转换为秒并保留两位小数
let recStatus='空闲'; // 初始化录制状态为 '空闲'
if (isRecording) { // 如果正在录制
recStatus=isPaused?'暂停中':'录制中'; // 根据是否暂停显示 '暂停中' 或 '录制中'
if(!isPaused && preventClicksDuringRecord){ recStatus+='(阻止网页事件)';} // 如果未暂停且阻止点击,添加提示
}
updateStatus( // 更新状态区域的显示
`状态:${recStatus}
`+ // 显示当前状态
`点数:${recordedClicks.length},时长:${seconds}秒
`+ // 显示录制点数和总时长
`回放时长:${calcPlaybackTotalTime()}秒,已播放:0秒` // 显示回放总时长和已播放时长
);
// 根据状态启用/禁用按钮
btnStartRec.disabled = isRecording;
btnPauseRec.disabled = !isRecording || isPaused;
btnResumeRec.disabled = !isRecording || !isPaused;
btnStopRec.disabled = !isRecording;
btnPlay.disabled = recordedClicks.length === 0 || isRecording;
btnStopPlay.disabled = !playbackTimer; // 只有在回放进行中才启用停止回放按钮
btnSaveFile.disabled = recordedClicks.length === 0;
btnClearRecord.disabled = recordedClicks.length === 0 && !isRecording;
}
// 计算回放的总时长
function calcPlaybackTotalTime() {
if(recordedClicks.length===0) return '0.00'; // 如果没有录制数据,返回 '0.00'
const totalSeconds = recordedClicks.reduce((acc,c)=>acc+c.delay,0)/1000; // 累加所有点击的延迟时间并转换为秒
return totalSeconds.toFixed(2); // 保留两位小数
}
// -------------- 录制操作 ----------------
function startRecording() {
if(isRecording) return; // 如果已经在录制中,则直接返回
recordedClicks=[]; totalRecordedTime=0; lastClickTime=Date.now(); // 初始化录制数据、总时长和上次点击时间
isRecording=true; isPaused=false; // 设置录制状态为进行中,未暂停
// 启用/禁用相关按钮
btnStartRec.disabled=true; btnPauseRec.disabled=false; btnResumeRec.disabled=true; btnStopRec.disabled=false;
btnPlay.disabled=true; btnStopPlay.disabled=true; btnSaveFile.disabled=true; btnClearRecord.disabled=false;
updateStatusAndUI(); // 更新状态和UI显示
debugLog('开始录制'); // 输出调试信息
}
function pauseRecording() {
if(!isRecording || isPaused) return; // 如果不在录制中或已暂停,则直接返回
isPaused=true; // 设置录制状态为暂停
btnPauseRec.disabled=true; btnResumeRec.disabled=false; // 禁用暂停按钮,启用继续按钮
updateStatusAndUI(); // 更新状态和UI显示
debugLog('暂停录制'); // 输出调试信息
}
function resumeRecording() {
if(!isRecording || !isPaused) return; // 如果不在录制中或未暂停,则直接返回
isPaused=false; lastClickTime=Date.now(); // 取消暂停,更新上次点击时间
btnPauseRec.disabled=false; btnResumeRec.disabled=true; // 启用暂停按钮,禁用继续按钮
updateStatusAndUI(); // 更新状态和UI显示
debugLog('继续录制'); // 输出调试信息
}
function stopRecording() {
if(!isRecording) return; // 如果不在录制中,则直接返回
isRecording=false; isPaused=false; // 设置录制状态为空闲,未暂停
// 启用/禁用相关按钮
btnStartRec.disabled=false; btnPauseRec.disabled=true; btnResumeRec.disabled=true; btnStopRec.disabled=true;
btnPlay.disabled=recordedClicks.length===0; btnSaveFile.disabled=recordedClicks.length===0; btnClearRecord.disabled=recordedClicks.length===0;
totalRecordedTime = recordedClicks.reduce((acc,c)=>acc+c.delay,0); // 重新计算总录制时长
recData.value=JSON.stringify(recordedClicks, null, 2); // 将录制数据格式化后显示在文本域中
updateStatusAndUI(); // 更新状态和UI显示
debugLog('停止录制,点数:'+recordedClicks.length); // 输出调试信息
}
function clearRecordingData() {
if(isRecording) { stopRecording(); } // 如果正在录制,先停止录制
recordedClicks=[]; totalRecordedTime=0; recData.value=''; // 清空录制数据
btnPlay.disabled=true; btnSaveFile.disabled=true; btnClearRecord.disabled=true; // 禁用相关按钮
updateStatusAndUI(); // 更新状态和UI显示
debugLog('清空录制数据'); // 输出调试信息
}
// -------------- 鼠标点击录制 ----------------
// 判断给定的坐标 (x, y) 是否在任何一个目标区域内
function isInTargetAreas(x,y) {
if(targetAreas.length===0) return true; // 如果没有设置目标区域,则认为所有点击都在目标区域内
for(const area of targetAreas) { // 遍历所有目标区域
// 判断点击坐标是否在当前区域的矩形范围内
if(x>=area.x && x<=area.x+area.width && y>=area.y && y<=area.y+area.height){
return true; // 如果在任何一个区域内,返回 true
}
}
return false; // 如果不在任何区域内,返回 false
}
// 处理鼠标点击事件,进行录制
function onClickRecord(e) {
if(!isRecording || isPaused) return; // 如果不在录制中或已暂停,则不处理点击事件
const x=e.clientX, y=e.clientY; // 获取点击的页面坐标
if(targetAreas.length>0 && !isInTargetAreas(x,y)){ // 如果设置了目标区域且点击不在区域内
debugLog(`点击点(${x},${y})未在目标区域,跳过`); // 输出调试信息
return; // 跳过此点击
}
const now=Date.now(); // 获取当前时间戳
const delay= recordedClicks.length===0 ? 0 : (now-lastClickTime); // 计算与上次点击的时间间隔,如果是第一个点击则间隔为0
lastClickTime=now; // 更新上次点击的时间戳
// 将绝对坐标转换为相对坐标,以适应不同屏幕分辨率
const relX= x/window.innerWidth; // 相对于窗口宽度的 X 坐标比例
const relY= y/window.innerHeight; // 相对于窗口高度的 Y 坐标比例
recordedClicks.push({relativeX:relX,relativeY:relY, delay}); // 将相对坐标和延迟时间保存到录制数组中
totalRecordedTime+=delay; // 累加总录制时长
updateStatusAndUI(); // 更新状态和UI显示
if(preventClicksDuringRecord){ // 如果配置为阻止点击事件
e.preventDefault(); // 阻止默认事件(例如链接跳转)
e.stopPropagation(); // 阻止事件冒泡到父元素
}
debugLog(`录制点:${relX.toFixed(4)},${relY.toFixed(4)},间隔:${delay}ms`); // 输出调试信息
}
document.addEventListener('click', onClickRecord,true); // 监听全局的点击事件,使用捕获阶段(true)确保在其他点击事件之前触发
// -------------- 回放逻辑 ----------------
// 突出显示被模拟点击的元素
function highlightElement(el) {
if(!el) return; // 如果元素不存在,则返回
el.style.transition='box-shadow 0.3s ease'; // 添加过渡效果
el.style.boxShadow='0 0 10px #f39c12'; // 设置一个黄色的阴影
setTimeout(()=>{el.style.boxShadow='';},900); // 900ms 后清除阴影效果
}
// 在指定坐标 (x, y) 模拟点击事件
function simulateClickAt(x,y) {
const el= document.elementFromPoint(x,y); // 获取指定坐标下的元素
highlightElement(el); // 突出显示该元素
if(!el) return; // 如果没有找到元素,则返回
// 依次派发 mousedown, mouseup, click 事件,模拟完整的鼠标点击过程
['mousedown','mouseup','click'].forEach(t=>{
el.dispatchEvent(new MouseEvent(t,{bubbles:true, cancelable:true, clientX:x, clientY:y, button:0}));
});
debugLog(`模拟点击:(${x.toFixed(0)},${y.toFixed(0)}) 元素:${el.tagName}`); // 输出调试信息
}
// 播放点击序列的主函数
function playClickSequence() {
if(playbackIndex>=recordedClicks.length){ // 如果所有点击都已播放完毕
// 完成回放
playbackTimer=null; // 清除回放计时器
setTimeout(()=> alert('回放完成!'),50); // 稍作延迟后弹出回放完成提示
// 更新状态
updateStatus(`状态:回放完成
点数:${recordedClicks.length},时长:${(totalRecordedTime/1000).toFixed(2)}秒`);
btnPlay.disabled=false; btnStopPlay.disabled=true; // 启用开始回放按钮,禁用停止回放按钮
debugLog('回放结束'); // 输出调试信息
return;
}
const c=recordedClicks[playbackIndex]; // 获取当前要播放的点击数据
const delay=c.delay; // 获取点击延迟时间
let x= c.relativeX * window.innerWidth; // 将相对 X 坐标转换为当前窗口的绝对 X 坐标
let y= c.relativeY * window.innerHeight; // 将相对 Y 坐标转换为当前窗口的绝对 Y 坐标
simulateClickAt(x,y); // 模拟点击
playbackIndex++; // 移动到下一个点击事件
// 动画平滑(此部分代码是动画的框架,但实际未实现复杂动画效果,只是一个示意)
let startTime= performance.now(); // 记录当前时间
const animate = ()=> { // 定义动画函数
let elapsed= performance.now() - startTime; // 计算从上次点击开始经过的时间
const progress= Math.min(elapsed/delay,1); // 计算动画进度,限制在 0 到 1 之间
// 暂时不做复杂动画(例如鼠标移动轨迹动画),这里只是一个空循环直到 delay 结束
if(progress<1){
// 可以做鼠标动画机场
requestAnimationFrame(animate); // 在下一帧继续执行动画
} else {
// 下一个:动画结束后,更新回放状态并设置下一个点击的计时器
updatePlaybackStatus((performance.now() - playbackStartTime)/1000); // 更新已播放时长
playbackTimer= setTimeout(playClickSequence,0); // 设置一个 0 毫秒的延迟来播放下一个点击(实际上会放到事件队列末尾)
}
};
// 如果没有延迟,直接播放下一个;否则启动动画
if (delay === 0) {
updatePlaybackStatus((performance.now() - playbackStartTime)/1000);
playbackTimer = setTimeout(playClickSequence, 0);
} else {
playbackTimer = setTimeout(animate, delay); // 延迟 delay 毫秒后执行动画
}
}
// 更新回放状态区域的显示
function updatePlaybackStatus(elapsedSeconds=0) {
const totalSec= parseFloat(calcPlaybackTotalTime()); // 获取总回放时长
const playedSecs= elapsedSeconds.toFixed(2); // 格式化已播放时长
updateStatus(`状态:回放中
点数:${recordedClicks.length},时长:${totalSec}秒
已播放:${playedSecs}秒`); // 更新状态信息
}
// 开始回放
function startPlayback() {
if(playbackTimer) return; // 如果回放计时器已存在(正在回放中),则返回
if(recordedClicks.length===0) { // 如果没有录制数据
updateStatus('无录制数据,无法回放'); // 更新状态提示
return;
}
playbackIndex=0; // 重置回放索引
playbackStartTime=performance.now(); // 记录回放开始时间
btnPlay.disabled=true; btnStopPlay.disabled=false; // 禁用开始回放按钮,启用停止回放按钮
updatePlaybackStatus(0); // 初始化回放状态显示
debugLog('开始回放'); // 输出调试信息
playClickSequence(); // 开始播放点击序列
}
// 停止回放
function stopPlayback() {
if(playbackTimer) { // 如果回放计时器存在
clearTimeout(playbackTimer); // 清除计时器,停止回放
playbackTimer=null; // 清空计时器 ID
}
btnPlay.disabled=false; btnStopPlay.disabled=true; // 启用开始回放按钮,禁用停止回放按钮
const elapsedSec= ((performance.now()-playbackStartTime)/1000).toFixed(2); // 计算已播放时长
updateStatus(`状态:回放已停止
点数:${recordedClicks.length},时长:${(totalRecordedTime/1000).toFixed(2)}秒
已播放:${elapsedSec}秒`); // 更新状态信息
debugLog('回放停止'); // 输出调试信息
}
// -------------- 导出导入 ----------------
// 将录制数据保存到文件
function saveToFile() {
if(recordedClicks.length===0) return; // 如果没有录制数据,则返回
// 创建一个 Blob 对象,包含录制数据(JSON 格式)
const blob=new Blob([JSON.stringify(recordedClicks,null,2)], {type:'application/json'});
const url= URL.createObjectURL(blob); // 创建一个 Blob URL
const a=document.createElement('a'); // 创建一个元素用于下载
a.href= url; // 设置下载链接
a.download= `mouse_recording_${(new Date()).toISOString().replace(/[:.]/g,'-')}.json`; // 设置下载文件名(包含时间戳)
a.click(); // 模拟点击下载链接
URL.revokeObjectURL(url); // 释放 Blob URL
debugLog('导出录制数据'); // 输出调试信息
}
// 从文件加载录制数据
function loadFromFile(file) {
const reader= new FileReader(); // 创建一个 FileReader 对象
reader.onload=e => { // 监听文件读取完成事件
try{
const data= JSON.parse(e.target.result); // 解析文件内容为 JSON 对象
// 验证数据格式:确保是数组,且每个元素包含 delay 和 (relativeX/relativeY 或 x/y)
if(Array.isArray(data) && data.every(v => ('delay' in v) && (('relativeX' in v && 'relativeY' in v) || ('x' in v && 'y' in v)))) {
recordedClicks= data; // 更新录制数据
totalRecordedTime= recordedClicks.reduce((a,c)=>a+c.delay, 0); // 重新计算总时长
recData.value= JSON.stringify(recordedClicks,null,2); // 更新文本域显示
btnPlay.disabled= false; // 启用回放按钮
btnSaveFile.disabled= false; // 启用保存按钮
btnClearRecord.disabled= false; // 启用清空按钮
updateStatus(`导入成功,点数:${recordedClicks.length},时长:${(totalRecordedTime/1000).toFixed(2)}秒`); // 更新状态
debugLog('导入录制数据成功'); // 输出调试信息
} else {
updateStatus('文件格式错误'); // 文件格式不正确
}
} catch(e){ updateStatus('读取失败'); } // JSON 解析失败
};
reader.readAsText(file); // 以文本格式读取文件内容
}
// -------------- 快捷键设置 ----------------
// 打开快捷键设置面板
function openHotkeyPanel() {
document.querySelector('#hotkeySettingsPanel').style.display='block'; // 显示设置面板
// 填充输入框,确保显示当前配置的快捷键
document.querySelector('#hkStart').value= hotkeyObjToStr(hotkeys.startRecord);
document.querySelector('#hkPause').value= hotkeyObjToStr(hotkeys.pauseRecord);
document.querySelector('#hkResume').value= hotkeyObjToStr(hotkeys.resumeRecord);
document.querySelector('#hkStop').value= hotkeyObjToStr(hotkeys.stopRecord);
document.querySelector('#hkPlay').value= hotkeyObjToStr(hotkeys.startPlayback);
document.querySelector('#hkStopPlay').value= hotkeyObjToStr(hotkeys.stopPlayback);
}
// 关闭快捷键设置面板
function closeHotkeyPanel() {
document.querySelector('#hotkeySettingsPanel').style.display='none'; // 隐藏设置面板
}
// 绑定快捷键输入框的按键捕获事件
['hkStart','hkPause','hkResume','hkStop','hkPlay','hkStopPlay'].forEach(id => { // 遍历所有快捷键输入框的 ID
const input= document.querySelector('#'+id); // 获取对应的输入框元素
input.onkeydown= e => { // 监听输入框的按键按下事件
e.preventDefault(); // 阻止默认行为(例如输入字符)
const keys= []; // 用于存储按下的修饰键和主键
if(e.ctrlKey) keys.push('Ctrl'); // 如果 Ctrl 键被按下,添加 'Ctrl'
if(e.altKey) keys.push('Alt'); // 如果 Alt 键被按下,添加 'Alt'
if(e.shiftKey) keys.push('Shift'); // 如果 Shift 键被按下,添加 'Shift'
let keyName= e.key; // 获取按键名称
// 过滤掉单独的修饰键(避免输入 Ctrl 或 Shift 等)
if(keyName==='Control'|| keyName==='Shift'|| keyName==='Alt'|| keyName==='Meta') {
// 做跳过
} else {
keyName= keyName.length===1 ? keyName.toUpperCase() : keyName; // 如果是单个字母,转为大写
if(!['Control','Shift','Alt','Meta'].includes(e.key)) { // 再次检查是否是修饰键
keys.push(keyName); // 添加主键名称
}
input.value= keys.join('+'); // 将组合键显示在输入框中
}
};
});
// 保存快捷键设置按钮的点击事件
document.querySelector('#btnSaveHotkeys').onclick= ()=> {
try{
const newHK={}; // 创建新的快捷键配置对象
// 从输入框获取值并转换为快捷键对象
newHK.startRecord= hotkeyStrToObj(document.querySelector('#hkStart').value);
newHK.pauseRecord= hotkeyStrToObj(document.querySelector('#hkPause').value);
newHK.resumeRecord= hotkeyStrToObj(document.querySelector('#hkResume').value);
newHK.stopRecord= hotkeyStrToObj(document.querySelector('#hkStop').value);
newHK.startPlayback= hotkeyStrToObj(document.querySelector('#hkPlay').value);
newHK.stopPlayback= hotkeyStrToObj(document.querySelector('#hkStopPlay').value);
hotkeys= newHK; // 更新全局快捷键配置
localStorage.setItem('mouseRecHotkeys', JSON.stringify(hotkeys)); // 保存到 localStorage
alert('快捷键保存成功'); // 提示保存成功
closeHotkeyPanel(); // 关闭设置面板
} catch(e){ alert('快捷键设置错误'); } // 捕获错误并提示
};
document.querySelector('#btnCancelHotkeys').onclick= ()=> { closeHotkeyPanel(); }; // 取消按钮点击事件,关闭面板
// 自定义快捷键设置按钮的点击事件,用于切换设置面板的显示/隐藏
document.querySelector('#btnSetHotkeys').onclick= ()=> {
if(document.querySelector('#hotkeySettingsPanel').style.display==='block') {
closeHotkeyPanel(); // 如果已显示,则关闭
} else {
openHotkeyPanel(); // 如果已隐藏,则打开
}
};
// -------------- UI按钮事件 ----------------
// 入口按钮点击事件:隐藏入口按钮,显示主面板
entryBtn.onclick= ()=>{
entryBtn.style.display='none';
panel.style.display='block';
};
// 返回入口按钮点击事件:隐藏主面板,显示入口按钮
document.querySelector('#btnBackEntry').onclick= ()=>{
panel.style.display='none';
entryBtn.style.display='block';
};
// 添加目标区域按钮点击事件
document.querySelector('#btnAddTargetArea').onclick= ()=>{
alertMsg('请框选目标区域(按住鼠标拖拽后松开)'); // 提示用户操作
let isDragging=false, startX=0, startY=0, rect=null; // 拖动状态、起始坐标、绘制的矩形元素
const overlay=document.createElement('div'); // 创建一个覆盖层
Object.assign(overlay.style,{ // 设置覆盖层样式
position:'fixed', top:0, left:0,width:'100%', height:'100%',
background:'rgba(0,0,0,0.2)', cursor:'crosshair', zIndex:9999999 // 半透明背景,十字光标,高层级
});
document.body.appendChild(overlay); // 将覆盖层添加到 body 中
overlay.onmousedown= e => { // 覆盖层鼠标按下事件
if(e.button!==0) return; // 只响应左键
isDragging=true; startX=e.clientX; startY=e.clientY; // 标记拖动开始,记录起始坐标
rect=document.createElement('div'); // 创建一个用于显示选择区域的矩形框
Object.assign(rect.style, { // 设置矩形框样式
position:'absolute', border:'2px dashed #333', background:'rgba(0,0,0,0.1)',
left:startX+'px', top:startY+'px', width:'0px', height:'0px'
});
overlay.appendChild(rect); // 将矩形框添加到覆盖层
};
overlay.onmousemove= e => { // 覆盖层鼠标移动事件
if(!isDragging) return; // 如果未拖动,则返回
const currX=e.clientX, currY=e.clientY; // 获取当前鼠标坐标
rect.style.left= Math.min(startX,currX)+'px'; // 更新矩形框的 left 位置
rect.style.top= Math.min(startY,currY)+'px'; // 更新矩形框的 top 位置
rect.style.width= Math.abs(currX-startX)+'px'; // 更新矩形框的宽度
rect.style.height= Math.abs(currY-startY)+'px'; // 更新矩形框的高度
};
overlay.onmouseup= e => { // 覆盖层鼠标松开事件
if(!isDragging) return; // 如果未拖动,则返回
isDragging=false; // 标记拖动结束
const x= parseFloat(rect.style.left); // 获取最终矩形框的 X 坐标
const y= parseFloat(rect.style.top); // 获取最终矩形框的 Y 坐标
const width= parseFloat(rect.style.width); // 获取最终矩形框的宽度
const height= parseFloat(rect.style.height); // 获取最终矩形框的高度
// 存入目标区域
targetAreas.push({x,y,width,height}); // 将新目标区域添加到数组
localStorage.setItem('mouseRecTargetAreas', JSON.stringify(targetAreas)); // 保存到 localStorage
refreshTargetArea(); // 刷新目标区域列表显示
document.body.removeChild(overlay); // 移除覆盖层
debugLog(`新目标区域:x:${x}, y:${y}, w:${width}, h:${height}`); // 输出调试信息
};
};
// 目标区域刷新显示函数
function refreshTargetArea() {
const listDiv= document.querySelector('#targetAreaList'); // 获取目标区域列表容器
listDiv.innerHTML=''; // 清空现有内容
targetAreas.forEach((area, idx) => { // 遍历所有目标区域
const div= document.createElement('div'); // 创建一个 div 显示区域信息
div.textContent= `区域${idx+1}: [${area.x.toFixed(0)}, ${area.y.toFixed(0)}, ${area.width.toFixed(0)}x${area.height.toFixed(0)}]`; // 显示区域坐标和尺寸
//删除按钮
const btn= document.createElement('button'); // 创建删除按钮
btn.textContent='删除';
btn.style.marginLeft='8px';
btn.style.fontSize='12px';
btn.onclick= ()=> { // 删除按钮点击事件
targetAreas.splice(idx,1); // 从数组中删除当前区域
localStorage.setItem('mouseRecTargetAreas', JSON.stringify(targetAreas)); // 更新 localStorage
refreshTargetArea(); // 刷新显示
};
div.appendChild(btn); // 将删除按钮添加到区域信息 div
listDiv.appendChild(div); // 将区域信息 div 添加到列表容器
});
}
// 初始化显示:页面加载时刷新目标区域列表
refreshTargetArea();
// -------------- 按钮事件绑定 ----------------
// 为所有功能按钮绑定对应的事件处理函数
document.querySelector('#btnStartRec').onclick= startRecording; // 开始录制
document.querySelector('#btnPauseRec').onclick= pauseRecording; // 暂停录制
document.querySelector('#btnResumeRec').onclick=resumeRecording; // 继续录制
document.querySelector('#btnStopRec').onclick= stopRecording; // 停止录制
document.querySelector('#btnPlay').onclick= startPlayback; // 开始回放
document.querySelector('#btnStopPlay').onclick= stopPlayback; // 停止回放
document.querySelector('#btnSaveFile').onclick= saveToFile; // 保存文件
document.querySelector('#btnLoadFile').onclick= ()=> fileInput.click(); // 导入文件(触发隐藏的文件输入框点击)
document.querySelector('#btnClearRecord').onclick= clearRecordingData; // 清空录制数据
// 文件输入框的改变事件(当选择文件后触发)
fileInput.onchange= e => {
if(e.target.files.length>0) { // 如果选择了文件
loadFromFile(e.target.files[0]); // 加载第一个文件
e.target.value=''; // 清空文件输入框,以便再次选择相同文件时也能触发 change 事件
}
};
// -------------- 全局快捷键监听 --------------
window.addEventListener('keydown', e => { // 监听窗口的按键按下事件
// 判断当前按键事件是否与某个快捷键配置匹配
function matchHotkey(hk,ev) {
// 比较 Ctrl, Alt, Shift 键的状态,以及主键(不区分大小写)
return (!!hk.ctrlKey===ev.ctrlKey) && (!!hk.altKey===ev.altKey) && (!!hk.shiftKey===ev.shiftKey) && (hk.key.toLowerCase()===ev.key.toLowerCase());
}
// 根据匹配结果调用相应的功能函数,并阻止默认行为
if(matchHotkey(hotkeys.startRecord, e)){ e.preventDefault(); startRecording();}
else if(matchHotkey(hotkeys.pauseRecord, e)){ e.preventDefault(); pauseRecording();}
else if(matchHotkey(hotkeys.resumeRecord, e)){ e.preventDefault(); resumeRecording();}
else if(matchHotkey(hotkeys.stopRecord, e)){ e.preventDefault(); stopRecording();}
else if(matchHotkey(hotkeys.startPlayback, e)){ e.preventDefault(); startPlayback();}
else if(matchHotkey(hotkeys.stopPlayback, e)){ e.preventDefault(); stopPlayback();}
});
// -------------- 面板快捷键设置 ----------------
// 再次绑定快捷键保存按钮的点击事件(与前面重复,这里是确保在面板内操作时也能正确保存)
document.querySelector('#btnSaveHotkeys').onclick= ()=> {
try{
// 从输入框获取值并转换为快捷键对象
hotkeys.startRecord= hotkeyStrToObj(document.querySelector('#hkStart').value);
hotkeys.pauseRecord= hotkeyStrToObj(document.querySelector('#hkPause').value);
hotkeys.resumeRecord= hotkeyStrToObj(document.querySelector('#hkResume').value);
hotkeys.stopRecord= hotkeyStrToObj(document.querySelector('#hkStop').value);
hotkeys.startPlayback= hotkeyStrToObj(document.querySelector('#hkPlay').value);
hotkeys.stopPlayback= hotkeyStrToObj(document.querySelector('#hkStopPlay').value);
localStorage.setItem('mouseRecHotkeys', JSON.stringify(hotkeys)); // 保存到 localStorage
alert('快捷键已保存'); // 提示保存成功
closeHotkeyPanel(); // 关闭设置面板
} catch(e){ alert('快捷键格式有误'); } // 捕获错误并提示
};
document.querySelector('#btnCancelHotkeys').onclick= ()=> { closeHotkeyPanel(); }; // 取消按钮点击事件,关闭面板
document.querySelector('#btnSetHotkeys').onclick= ()=> { // 设置快捷键按钮的点击事件
if(document.querySelector('#hotkeySettingsPanel').style.display==='block') {
closeHotkeyPanel(); // 如果面板已显示,则关闭
} else {
openHotkeyPanel(); // 如果面板已隐藏,则打开
}
};
// -------------- 显示状态 ----------------
updateStatusAndUI(); // 脚本加载时,初始化UI状态显示
})(); // 匿名立即执行函数结束