first commit and delete token

This commit is contained in:
2025-07-11 22:03:31 +08:00
commit 51c1218a93
15 changed files with 1160 additions and 0 deletions

558
obs_plugin.html Normal file
View File

@ -0,0 +1,558 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>心率监控 - OBS插件</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: transparent;
overflow: hidden;
user-select: none;
}
.heart-rate-container {
background: linear-gradient(135deg,
rgba(0, 0, 0, 0.4),
rgba(50, 50, 50, 0.4),
rgba(255, 0, 0, 0.1),
rgba(255, 100, 100, 0.1));
border: 2px solid rgba(255, 0, 0, 0.8);
border-radius: 15px;
padding: 15px;
width: 400px;
height: 280px;
position: relative;
backdrop-filter: blur(10px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.heart-icon {
font-size: 24px;
color: #ff4757;
animation: heartbeat 1s infinite;
}
@keyframes heartbeat {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.2); }
}
.current-bpm {
font-size: 36px;
font-weight: bold;
color: #ff4757;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
}
.bpm-label {
font-size: 12px;
color: #ffffff;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.7);
}
.device-info {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
color: #ffffff;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.7);
margin-bottom: 5px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #ff4757;
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.device-name {
font-weight: bold;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.last-update {
font-size: 9px;
opacity: 0.8;
}
.chart-container {
position: relative;
height: 120px;
margin-top: 8px;
background: rgba(0, 0, 0, 0.7);
border-radius: 8px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.chart-canvas {
width: 100%;
height: 100%;
display: block;
}
.stats {
display: flex;
justify-content: space-around;
margin-top: 8px;
font-size: 11px;
color: #ffffff;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.9);
background: rgba(0, 0, 0, 0.3);
padding: 5px;
border-radius: 5px;
}
.stat-item {
text-align: center;
}
.stat-value {
font-weight: bold;
font-size: 14px;
}
.stat-label {
opacity: 0.8;
}
.disconnected {
filter: grayscale(100%);
opacity: 0.6;
}
.disconnected .status-dot {
background: #666;
animation: none;
}
.zone-indicator {
position: absolute;
top: 10px;
right: 10px;
padding: 4px 8px;
border-radius: 12px;
font-size: 10px;
font-weight: bold;
text-shadow: none;
}
.zone-rest { background: #74b9ff; color: white; }
.zone-fat-burn { background: #00b894; color: white; }
.zone-cardio { background: #fdcb6e; color: black; }
.zone-peak { background: #e17055; color: white; }
.zone-max { background: #d63031; color: white; }
</style>
</head>
<body>
<div class="heart-rate-container" id="heartRateContainer">
<div class="header">
<div class="heart-icon">❤️</div>
<div class="current-bpm" id="currentBPM">--</div>
<div class="bpm-label">BPM</div>
</div>
<div class="device-info">
<div class="status-dot" id="statusDot"></div>
<span class="device-name" id="deviceName">等待连接...</span>
<span class="last-update" id="lastUpdate"></span>
</div>
<div class="chart-container">
<canvas class="chart-canvas" id="chartCanvas"></canvas>
</div>
<div class="stats">
<div class="stat-item">
<div class="stat-value" id="avgBPM">--</div>
<div class="stat-label">平均</div>
</div>
<div class="stat-item">
<div class="stat-value" id="maxBPM">--</div>
<div class="stat-label">最高</div>
</div>
<div class="stat-item">
<div class="stat-value" id="minBPM">--</div>
<div class="stat-label">最低</div>
</div>
<div class="stat-item">
<div class="stat-value" id="duration">00:00</div>
<div class="stat-label">时长</div>
</div>
</div>
</div>
<script>
class HeartRateMonitor {
constructor() {
this.heartRateData = [];
this.maxDataPoints = 100;
this.isConnected = false;
this.startTime = null;
this.lastUpdateTime = 0;
this.currentDeviceName = '';
this.canvas = document.getElementById('chartCanvas');
this.ctx = this.canvas.getContext('2d');
this.currentBPMElement = document.getElementById('currentBPM');
this.deviceNameElement = document.getElementById('deviceName');
this.lastUpdateElement = document.getElementById('lastUpdate');
this.statusDotElement = document.getElementById('statusDot');
this.avgBPMElement = document.getElementById('avgBPM');
this.maxBPMElement = document.getElementById('maxBPM');
this.minBPMElement = document.getElementById('minBPM');
this.durationElement = document.getElementById('duration');
this.containerElement = document.getElementById('heartRateContainer');
this.setupCanvas();
this.setupWebSocket();
this.startUpdateLoop();
}
setupCanvas() {
const rect = this.canvas.getBoundingClientRect();
this.canvas.width = rect.width * devicePixelRatio;
this.canvas.height = rect.height * devicePixelRatio;
this.ctx.scale(devicePixelRatio, devicePixelRatio);
this.canvas.style.width = rect.width + 'px';
this.canvas.style.height = rect.height + 'px';
}
setupWebSocket() {
// WebSocket连接用于接收心率数据
try {
this.ws = new WebSocket('wss://ws.nuanr-mxi.com/ws');
this.ws.onopen = () => {
this.setConnected(true);
console.log('WebSocket连接已建立');
};
this.ws.onmessage = (event) => {
try {
const heartRateResponse = JSON.parse(event.data);
this.processHeartRateResponse(heartRateResponse);
} catch (error) {
console.error('解析心率数据失败:', error);
}
};
this.ws.onclose = () => {
this.setConnected(false);
console.log('WebSocket连接已关闭');
// 5秒后重连
setTimeout(() => this.setupWebSocket(), 5000);
};
this.ws.onerror = (error) => {
console.error('WebSocket错误:', error);
this.setConnected(false);
};
} catch (error) {
console.error('WebSocket连接失败:', error);
this.setConnected(false);
}
}
processHeartRateResponse(heartRateResponse) {
// 处理C#发送的HeartRateResponse数据结构
if (!heartRateResponse || !heartRateResponse.HeartRate) {
console.warn('无效的心率数据:', heartRateResponse);
return;
}
const bpm = heartRateResponse.HeartRate;
let timestamp;
// 处理C#的DateTime格式
if (heartRateResponse.Timestamp) {
if (typeof heartRateResponse.Timestamp === 'string') {
// 如果是ISO字符串格式
timestamp = new Date(heartRateResponse.Timestamp).getTime();
} else {
// 如果是其他格式,尝试解析
timestamp = new Date(heartRateResponse.Timestamp).getTime();
}
} else {
timestamp = Date.now();
}
// 更新设备名称
if (heartRateResponse.DeviceName && heartRateResponse.DeviceName !== this.currentDeviceName) {
this.currentDeviceName = heartRateResponse.DeviceName;
this.deviceNameElement.textContent = this.currentDeviceName;
}
// 添加心率数据
this.addHeartRateData(bpm, timestamp);
// 更新最后更新时间显示
this.updateLastUpdateTime(timestamp);
}
addHeartRateData(bpm, timestamp = Date.now()) {
if (bpm <= 0 || bpm > 250) return; // 过滤无效数据
const dataPoint = {
bpm: bpm,
timestamp: timestamp,
time: new Date(timestamp)
};
this.heartRateData.push(dataPoint);
// 保持最多100个数据点
if (this.heartRateData.length > this.maxDataPoints) {
this.heartRateData.shift();
}
// 设置开始时间
if (!this.startTime) {
this.startTime = timestamp;
}
this.lastUpdateTime = timestamp;
this.updateDisplay();
}
updateLastUpdateTime(timestamp) {
const updateTime = new Date(timestamp);
const timeString = updateTime.toLocaleTimeString('zh-CN', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
this.lastUpdateElement.textContent = timeString;
}
setConnected(connected) {
this.isConnected = connected;
if (connected) {
this.containerElement.classList.remove('disconnected');
if (!this.currentDeviceName) {
this.deviceNameElement.textContent = '已连接';
}
} else {
this.containerElement.classList.add('disconnected');
this.deviceNameElement.textContent = '连接断开';
this.lastUpdateElement.textContent = '';
}
}
updateDisplay() {
if (this.heartRateData.length === 0) return;
const currentBPM = this.heartRateData[this.heartRateData.length - 1].bpm;
const avgBPM = this.calculateAverage();
const maxBPM = this.calculateMax();
const minBPM = this.calculateMin();
const duration = this.calculateDuration();
this.currentBPMElement.textContent = currentBPM;
this.avgBPMElement.textContent = avgBPM;
this.maxBPMElement.textContent = maxBPM;
this.minBPMElement.textContent = minBPM;
this.durationElement.textContent = duration;
this.updateZoneIndicator(currentBPM);
this.drawChart();
}
calculateAverage() {
if (this.heartRateData.length === 0) return '--';
const sum = this.heartRateData.reduce((acc, data) => acc + data.bpm, 0);
return Math.round(sum / this.heartRateData.length);
}
calculateMax() {
if (this.heartRateData.length === 0) return '--';
return Math.max(...this.heartRateData.map(data => data.bpm));
}
calculateMin() {
if (this.heartRateData.length === 0) return '--';
return Math.min(...this.heartRateData.map(data => data.bpm));
}
calculateDuration() {
if (!this.startTime) return '00:00';
const duration = Math.floor((this.lastUpdateTime - this.startTime) / 1000);
const minutes = Math.floor(duration / 60);
const seconds = duration % 60;
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
}
drawChart() {
const canvas = this.canvas;
const ctx = this.ctx;
const rect = canvas.getBoundingClientRect();
const width = rect.width;
const height = rect.height;
// 清空画布
ctx.clearRect(0, 0, width, height);
if (this.heartRateData.length < 2) return;
// 计算绘图区域
const padding = 20;
const chartWidth = width - padding * 2;
const chartHeight = height - padding * 2;
// 计算数据范围
const minBPM = Math.min(...this.heartRateData.map(d => d.bpm));
const maxBPM = Math.max(...this.heartRateData.map(d => d.bpm));
const range = Math.max(maxBPM - minBPM, 20); // 最小范围20
const adjustedMin = Math.max(minBPM - 10, 0);
const adjustedMax = adjustedMin + range + 20;
// 绘制网格线
ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
ctx.lineWidth = 1;
// 横向网格线
for (let i = 0; i <= 4; i++) {
const y = padding + (chartHeight / 4) * i;
ctx.beginPath();
ctx.moveTo(padding, y);
ctx.lineTo(width - padding, y);
ctx.stroke();
}
// 纵向网格线
for (let i = 0; i <= 5; i++) {
const x = padding + (chartWidth / 5) * i;
ctx.beginPath();
ctx.moveTo(x, padding);
ctx.lineTo(x, height - padding);
ctx.stroke();
}
// 绘制心率曲线
ctx.strokeStyle = '#ff4757';
ctx.lineWidth = 2;
ctx.beginPath();
this.heartRateData.forEach((data, index) => {
const x = padding + (index / (this.maxDataPoints - 1)) * chartWidth;
const y = height - padding - ((data.bpm - adjustedMin) / (adjustedMax - adjustedMin)) * chartHeight;
if (index === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
});
ctx.stroke();
// 绘制填充区域
ctx.fillStyle = 'rgba(255, 71, 87, 0.1)';
ctx.lineTo(width - padding, height - padding);
ctx.lineTo(padding, height - padding);
ctx.closePath();
ctx.fill();
// 绘制当前值点
if (this.heartRateData.length > 0) {
const lastData = this.heartRateData[this.heartRateData.length - 1];
const lastIndex = this.heartRateData.length - 1;
const x = padding + (lastIndex / (this.maxDataPoints - 1)) * chartWidth;
const y = height - padding - ((lastData.bpm - adjustedMin) / (adjustedMax - adjustedMin)) * chartHeight;
ctx.fillStyle = '#ff4757';
ctx.beginPath();
ctx.arc(x, y, 4, 0, 2 * Math.PI);
ctx.fill();
// 绘制外圈动画
ctx.strokeStyle = 'rgba(255, 71, 87, 0.5)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(x, y, 6 + Math.sin(Date.now() / 200) * 2, 0, 2 * Math.PI);
ctx.stroke();
}
// 绘制Y轴标签
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
ctx.font = '10px Arial';
ctx.textAlign = 'right';
for (let i = 0; i <= 4; i++) {
const value = Math.round(adjustedMax - (adjustedMax - adjustedMin) * (i / 4));
const y = padding + (chartHeight / 4) * i + 3;
ctx.fillText(value.toString(), padding - 5, y);
}
}
startUpdateLoop() {
const update = () => {
this.drawChart();
requestAnimationFrame(update);
};
update();
}
}
// 窗口大小调整时重新设置画布
window.addEventListener('resize', () => {
if (window.heartRateMonitor) {
window.heartRateMonitor.setupCanvas();
}
});
// 初始化监控器
window.heartRateMonitor = new HeartRateMonitor();
// 暴露全局接口,用于外部调用 - 兼容HeartRateResponse数据结构
window.addHeartRateResponse = (heartRateResponse) => {
window.heartRateMonitor.processHeartRateResponse(heartRateResponse);
};
// 暴露传统接口,保持向后兼容
window.addHeartRateData = (bpm, timestamp) => {
const heartRateResponse = {
HeartRate: bpm,
Timestamp: timestamp ? new Date(timestamp).toISOString() : new Date().toISOString(),
DeviceName: "外部设备"
};
window.heartRateMonitor.processHeartRateResponse(heartRateResponse);
};
// 暴露连接状态设置接口
window.setHeartRateConnected = (connected) => {
window.heartRateMonitor.setConnected(connected);
};
</script>
</body>
</html>