This commit is contained in:
nnwang
2026-02-12 14:41:06 +08:00
parent e7b2334421
commit 56f3a88b62
11 changed files with 3053 additions and 9 deletions

31
.gitignore vendored
View File

@@ -1,11 +1,24 @@
# ---> Vue
# gitignore template for Vue.js projects
#
# Recommended template: Node.gitignore
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# TODO: where does this rule come from?
docs/_book
# TODO: where does this rule come from?
test/
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>vue</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1995
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "vue",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"html5-qrcode": "^2.3.8",
"vue": "^3.5.25"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.18",
"@vitejs/plugin-vue": "^6.0.2",
"daisyui": "^5.5.18",
"tailwindcss": "^4.1.18",
"vite": "^7.3.1"
}
}

1
public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

982
src/App.vue Normal file
View File

@@ -0,0 +1,982 @@
<template>
<div class="min-h-screen bg-base-300 text-base-content p-4 flex justify-center items-center font-sans">
<div class="card w-full max-w-lg bg-base-100 shadow-xl">
<div class="card-body p-4">
<h2 class="card-title justify-center text-primary text-2xl mb-4">DG-LAB Socket To V2</h2>
<!-- === 状态指示栏 === -->
<div class="flex justify-between items-center bg-base-200 p-3 rounded-lg mb-4 shadow-inner">
<div class="flex items-center gap-2">
<div class="tooltip" :data-tip="bleDeviceName || '未连接'">
<span class="badge" :class="bleConnected ? 'badge-success' : 'badge-error'">BLE</span>
</div>
</div>
<div class="flex items-center gap-2">
<div class="tooltip" :data-tip="wsStatusText">
<span class="badge" :class="wsConnected ? 'badge-success' : 'badge-error'">WS</span>
</div>
</div>
<!-- 电池电量 -->
<div class="flex items-center gap-1 tooltip tooltip-bottom" :data-tip="'上次刷新: ' + lastBatUpdate">
<div class="text-lg">{{ getBatIcon(batteryLevel) }}</div>
<span class="font-bold text-sm font-mono">{{ batteryLevel }}%</span>
</div>
</div>
<!-- === 蓝牙连接 === -->
<div v-if="!bleConnected" class="form-control mb-4 transition-all">
<button class="btn btn-primary w-full shadow-lg" @click="scanAndConnectBLE" :disabled="isConnectingBLE">
<span v-if="isConnectingBLE" class="loading loading-spinner"></span>
{{ isConnectingBLE ? '正在建立通道...' : '搜索并连接郊狼 V2' }}
</button>
</div>
<!-- === Socket 连接设置 (支持复杂QR码) === -->
<div class="collapse collapse-arrow bg-base-200 mb-4 rounded-lg">
<input type="checkbox" checked />
<div class="collapse-title font-medium">
📡 远程控制 (扫码/粘贴)
</div>
<div class="collapse-content">
<div class="form-control gap-3 mt-2">
<label class="label pb-0"><span class="label-text">WS链接 / DG-LAB 二维码内容</span></label>
<div class="join w-full shadow-sm">
<input type="text" v-model="wsUrlInput" placeholder="#DGLAB-SOCKET#ws://..." class="input input-bordered join-item w-full text-xs font-mono" />
<button class="btn btn-square join-item btn-primary" @click="toggleScanner">
<span v-if="!showScanner">📷</span><span v-else></span>
</button>
</div>
<!-- 扫码区域 -->
<div v-if="showScanner" class="w-full mt-2 rounded-lg overflow-hidden border border-base-300 relative bg-black h-56">
<div id="reader" class="w-full h-full"></div>
<div class="absolute bottom-1 right-2 text-xs text-gray-400 bg-black/50 px-1 rounded pointer-events-none">Scanner Active</div>
</div>
<div class="flex gap-2 mt-1">
<button class="btn btn-secondary flex-1 btn-sm" @click="connectSocket" :disabled="wsConnected">连接 WS</button>
<button class="btn btn-warning flex-1 btn-sm" @click="disconnectSocket" :disabled="!wsConnected">断开</button>
</div>
<div class="text-[10px] mt-2 flex justify-between opacity-60 font-mono">
<span>Client: {{ wsClientId || 'Waiting...' }}</span>
<span>Target: {{ wsTargetId || 'Waiting...' }}</span>
</div>
</div>
</div>
</div>
<!-- === 核心控制台 === -->
<div class="grid grid-cols-2 gap-1 my-1">
<!-- A 通道面板 -->
<div class="flex flex-col items-center gap-2 p-1.5 bg-base-200/50 rounded-xl border border-secondary/20 shadow-sm">
<h3 class="font-bold text-lg text-secondary">Channel A</h3>
<!-- A 通道波形 - 柱状图 -->
<div class="w-full h-20 bg-black rounded border border-secondary/30 overflow-hidden relative shadow-inner">
<canvas ref="canvasA" width="200" height="64" class="w-full h-full block"></canvas>
</div>
<div class="radial-progress text-secondary border-4 border-base-200 bg-base-100 mt-2 hover:scale-105 transition-transform"
:style="`--value:${calcPercent(dglab.powerLevel[0])}; --size:5rem;`"
role="progressbar">
<span class="text-xl font-bold font-mono">{{ dglab.powerLevel[0] }}</span>
</div>
<!-- 上限滑块 A (修改部分) -->
<div class="w-full px-1 mt-1">
<label class="label p-0 justify-between items-center mb-1">
<span class="label-text-alt text-xs">MaxLevel A</span>
<!-- 将原来的 span 替换为 input -->
<input type="number"
min="0" max="200"
v-model.number="maxStrengthA"
class="input input-xs input-bordered w-16 text-center font-bold text-secondary focus:border-secondary" />
</label>
<input type="range" min="0" max="200" v-model.number="maxStrengthA" class="range range-xs range-secondary" />
</div>
</div>
<!-- B 通道面板 -->
<div class="flex flex-col items-center gap-2 p-1.5 bg-base-200/50 rounded-xl border border-accent/20 shadow-sm">
<h3 class="font-bold text-lg text-accent">Channel B</h3>
<!-- B 通道波形 - 柱状图 -->
<div class="w-full h-20 bg-black rounded border border-accent/30 overflow-hidden relative shadow-inner">
<canvas ref="canvasB" width="200" height="64" class="w-full h-full block"></canvas>
</div>
<div class="radial-progress text-accent border-4 border-base-200 bg-base-100 mt-2 hover:scale-105 transition-transform"
:style="`--value:${calcPercent(dglab.powerLevel[1])}; --size:5rem;`"
role="progressbar">
<span class="text-xl font-bold font-mono">{{ dglab.powerLevel[1] }}</span>
</div>
<!-- 上限滑块 B (修改部分) -->
<div class="w-full px-1 mt-1">
<label class="label p-0 justify-between items-center mb-1">
<span class="label-text-alt text-xs">MaxLevel B</span>
<!-- 将原来的 span 替换为 input -->
<input type="number"
min="0" max="200"
v-model.number="maxStrengthB"
class="input input-xs input-bordered w-16 text-center font-bold text-accent focus:border-accent" />
</label>
<input type="range" min="0" max="200" v-model.number="maxStrengthB" class="range range-xs range-accent" />
</div>
</div>
</div>
<!-- === 反馈按钮 === -->
<div class="divider text-xs opacity-50 my-2">App Feedback Simulation</div>
<div class="flex justify-center flex-wrap gap-2">
<div class="flex justify-center flex-wrap gap-2">
<!-- A通道: i 1 5对应 index i-1 (0,1,2,3,4) -->
<button v-for="i in 5" :key="`btn-a-${i}`" class="btn btn-xs btn-outline btn-secondary" @click="sendFeedback(i - 1)">
A-{{i}}
</button>
</div>
<div class="flex justify-center flex-wrap gap-2">
<!-- B通道: i 1 5对应 index i+4 (5,6,7,8,9) -->
<button v-for="i in 5" :key="`btn-b-${i}`" class="btn btn-xs btn-outline btn-accent" @click="sendFeedback(i + 4)">
B-{{i}}
</button>
</div>
</div>
<!-- === 日志 === -->
<div class="collapse collapse-plus bg-black text-green-500 mt-4 rounded-box border border-base-300">
<input type="checkbox" />
<div class="collapse-title text-xs font-mono min-h-[2rem] py-2">
> System Logs
</div>
<div class="collapse-content px-0">
<div class="h-32 overflow-y-auto text-[10px] font-mono p-2">
<div v-for="(log, index) in logs" :key="index" class="border-b border-white/10 py-1 flex">
<span class="opacity-50 mr-2 w-14 shrink-0">{{log.time}}</span>
<span class="break-all">{{ log.msg }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ===安全连接配置提示弹窗 === -->
<dialog class="modal" :class="{ 'modal-open': showInsecureModal }">
<div class="modal-box border border-warning shadow-warning/20 max-w-2xl">
<h3 class="font-bold text-lg text-warning flex items-center gap-2">
允许不安全 WebSocket 连接
</h3>
<div class="py-3 text-sm">
<p class="mb-2">检测到您正在 HTTPS 页面下尝试连接 <b>ws://</b> (非加密) 地址。现代浏览器出于安全策略会默认拦截。</p>
<div class="bg-base-200 p-3 rounded-lg text-xs space-y-3">
<p class="font-bold text-base-content/70">请根据您的浏览器复制地址到地址栏回车</p>
<div class="mt-2 text-warning-content font-mono select-all bg-base-100 border border-base-content/20 p-2 rounded break-all">
{{ 'chrome://flags/#unsafely-treat-insecure-origin-as-secure' }}
</div>
<ol class="list-decimal list-inside space-y-2 mt-2">
<li>将该选项设置为 <b class="text-success">Enabled</b></li>
<li>在出现的输入框中粘贴下方提取的 <b>ws://</b> 目标地址:</li>
</ol>
<!-- 计算出的 WS Origin -->
<div class="bg-warning/20 p-3 rounded border border-warning/30">
<div class="text-[10px] opacity-60 mb-1 uppercase tracking-wider">Target Origin To Allow:</div>
<div class="font-mono text-sm font-bold select-all break-all text-white">
{{ insecureOriginList }}
</div>
</div>
<p class="text-[10px] opacity-70">
* 设置完成后点击浏览器底部的 <span class="badge badge-xs">Relaunch</span> 重启浏览器即可连接
</p>
</div>
</div>
<div class="modal-action">
<form method="dialog" @submit.prevent="showInsecureModal = false">
<button class="btn btn-sm">已了解 / 关闭</button>
</form>
</div>
</div>
<form method="dialog" class="modal-backdrop" @submit.prevent="showInsecureModal = false">
<button>close</button>
</form>
</dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onUnmounted, nextTick, watch } from 'vue';
import { Html5Qrcode } from "html5-qrcode";
// ============================================
// 1. 全局状态与日志 (Thread Safety Simulation)
// ============================================
const logs = ref([{time: 'INIT', msg: 'System Ready.'}]);
const addLog = (msg) => {
console.log(msg);
// 限制日志长度防止内存溢出
if (logs.value.length > 50) logs.value.pop();
logs.value.unshift({ time: new Date().toLocaleTimeString('en-GB'), msg });
};
const getBatIcon = (level) => {
if(level > 90) return '🔋';
if(level > 60) return '🔋';
if(level > 30) return '🔋';
return '🪫';
}
// ============================================
// 2. 蓝牙层 (Producer-Consumer 模式)
// ============================================
// 蓝牙对象
const rawBLE = {
device: null,
chars: { battery: null, ab: null, a: null, b: null }
};
const bleConnected = ref(false);
const isConnectingBLE = ref(false);
const bleDeviceName = ref("");
const batteryLevel = ref(0);
const lastBatUpdate = ref("-");
const dglab = reactive({ powerLevel: [0, 0] });
// 互斥锁 与 指令队列
let isGattBusy = false;
const cmdQueue = []; // 命令队列,充当缓冲区
// ============================================
// 3. 视觉层 (UI Thread decoupled)
// ============================================
// 波形历史数据
const visHistory = reactive({ A: [], B: [] });
const MAX_VIS_POINTS = 32; // 柱子数量
const canvasA = ref(null);
const canvasB = ref(null);
// 通道状态机
const channelState = reactive({
A: { queue: [], index: 0, active: false, nextTick: 0 },
B: { queue: [], index: 0, active: false, nextTick: 0 }
});
const WAVE_INTERVAL = 100; // V2 脉冲间隔标准 100ms
// V2 协议映射 (Data + Strength)
const parseV3Hex = (v3HexStr) => {
if (!v3HexStr || v3HexStr.length < 10) return { payload: new Uint8Array([0,0,0]), strength: 0 };
// 解析频率和强度
const freqVal = parseInt(v3HexStr.substring(0, 2), 16);
const strengthVal = parseInt(v3HexStr.substring(8, 10), 16);
if (strengthVal === 0) return { payload: new Uint8Array([0, 0, 0]), strength: 0 };
// V2 映射算法 (核心)
let z = Math.round(strengthVal / 4);
if (z > 31) z = 31; if (z < 1) z = 1;
let freqHz = freqVal;
if (freqHz < 10) freqHz = 10;
const periodMs = 1000 / freqHz;
let x = Math.round(Math.sqrt(freqHz / 1000) * 15);
if (x < 1) x = 1; if (x > 31) x = 31;
let y = Math.round(periodMs - x);
if (y < 1) y = 1; if (y > 1023) y = 1023;
const value = (z << 15) | (y << 5) | x;
const arr = new Uint8Array([(value & 0xFF), ((value >> 8) & 0xFF), ((value >> 16) & 0xFF)]);
return { payload: arr, strength: strengthVal };
};
// ============================================
// 4. 可视化绘图 (Render "Helper")
// ============================================
const updateVisualizer = (chStr, strength) => {
const list = visHistory[chStr];
list.push(strength);
if (list.length > MAX_VIS_POINTS) list.shift();
const cvs = chStr === 'A' ? canvasA.value : canvasB.value;
if (!cvs) return;
// 简单的 diff 检查,不过 Canvas 重绘代价低,这里每 100ms 一次,不是性能瓶颈
const ctx = cvs.getContext('2d');
const w = cvs.width;
const h = cvs.height;
// 清空
ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, w, h);
// 基准线
ctx.beginPath();
ctx.strokeStyle = '#333';
ctx.moveTo(0, h); ctx.lineTo(w, h);
ctx.stroke();
// 绘制柱状图
const color = chStr === 'A' ? '#f000b8' : '#37cdbe'; // Pink / Teal
ctx.fillStyle = color;
ctx.shadowBlur = 5;
ctx.shadowColor = color;
const slotW = w / MAX_VIS_POINTS;
const barW = slotW - 2; // 缝隙
for (let i = 0; i < list.length; i++) {
const val = Math.min(list[i], 100);
if (val > 0) {
const barH = (val / 100) * (h - 2); // 留一点顶空
ctx.fillRect(i * slotW + 1, h - barH, barW, barH);
}
}
ctx.shadowBlur = 0;
};
// ============================================
// 5. Socket 与 QR 解析 (Network Layer)
// ============================================
const wsConnected = ref(false);
const wsStatusText = ref("离线");
const wsUrlInput = ref("");
const wsClientId = ref("");
const wsTargetId = ref("");
const maxStrengthA = ref(10);
const maxStrengthB = ref(10);
const realMaxA = computed(() => maxStrengthA.value);
const realMaxB = computed(() => maxStrengthB.value);
const calcPercent = (val) => Math.min(100, Math.round((val / 200) * 100));
let socket = null;
let bindMap = {};
// 监听强度上限变化,一旦改变立即向上同步
watch([maxStrengthA, maxStrengthB], () => {
syncStatusToSocket();
});
// 🔥 新增:安全弹窗控制
const showInsecureModal = ref(false);
const insecureOriginList = computed(() => {
if (!wsUrlInput.value) return "等待输入 WS 地址...";
// 提取纯净 URL
let urlToParse = wsUrlInput.value.trim();
// 如果包含 DG-LAB 前缀,先分割
if (urlToParse.includes("#DGLAB-SOCKET#")) {
const parts = urlToParse.split('#');
const found = parts.find(p => p.startsWith('ws://') || p.startsWith('wss://'));
if (found) urlToParse = found;
}
try {
const urlObj = new URL(urlToParse);
// 如果不是 ws:// 协议 (例如 wss://),通常不需要配置这个 flag但如果是 ws:// 则需要
if (urlObj.protocol === 'ws:') {
// 返回 ws://host:port
return urlObj.origin;
}
} catch(e) {}
// 如果无法解析,返回提示
return "请确保输入包含有效的 ws:// 地址";
});
// ★核心解析逻辑:处理 #DGLAB-SOCKET#wss://...
const parseWsUrl = (rawInput) => {
if (!rawInput) return null;
let url = rawInput.trim();
let target = "";
// 格式 1: "https://...#DGLAB-SOCKET#wss://ws.dungeon-lab.cn/UUID"
if (url.includes("#DGLAB-SOCKET#")) {
const parts = url.split('#');
// 寻找以 ws 开头的部分
const wsPart = parts.find(p => p.startsWith('ws://') || p.startsWith('wss://'));
if (wsPart) {
url = wsPart;
} else {
return null; // 无法识别的格式
}
}
// 提取 UUID (TargetId)
// 通常在 URL 的最后一段
const urlParts = url.split('/');
if (urlParts.length > 0) {
// 假设 UUID 是类似 136437dd-5043-49d4-82d8-52324b2eed42长度 36
const lastPart = urlParts[urlParts.length - 1];
if (lastPart.length > 20) { // 简单检查
target = lastPart;
}
}
return { finalUrl: url, targetId: target };
};
const connectSocket = () => {
const parseResult = parseWsUrl(wsUrlInput.value);
if (!parseResult) {
addLog("WS URL 格式错误");
return;
}
const { finalUrl, targetId } = parseResult;
wsTargetId.value = targetId;
addLog(`连接: ${finalUrl.substring(0,25)}...`);
if (socket) socket.close();
try {
socket = new WebSocket(finalUrl);
} catch(e) {
addLog("Socket创建失败");
// Chrome 在混合内容(Mixed Content)安全策略下可能直接在构造函数抛错
if (finalUrl.startsWith("ws://")) {
showInsecureModal.value = true;
}
return;
}
socket.onopen = () => {
wsConnected.value = true;
wsStatusText.value = "已连接等待绑定";
addLog("WS Connected");
resetToZero();
};
socket.onerror = (err) => {
addLog("WS Error");
wsConnected.value = false;
// 如果是异步连接失败,且是 ws:// 开头,大概率被安全策略拦截
if (finalUrl.startsWith("ws://")) {
console.warn("WS connection failed, possibly due to Mixed Content blocker.");
showInsecureModal.value = true;
}
};
socket.onclose = () => {
wsConnected.value = false;
wsStatusText.value = "离线";
// 🔥 WS 意外或被动断开时,触发重置
resetToZero();
};
socket.onmessage = (e) => {
try {
const data = JSON.parse(e.data);
handleWsMessage(data);
} catch(err){ console.error(err); }
};
};
const disconnectSocket = () => {
if(socket) socket.close();
// 手动断开时触发重置
resetToZero();
};
const sendMsg = (type, data = {}) => {
addLog(`WS Send: ${type}, Data: ${JSON.stringify(data)}`);
if(socket && wsConnected.value) socket.send(JSON.stringify({ type, targetId: wsClientId.value, ...data }));
};
// 消息处理器
const handleWsMessage = (data) => {
// 1. 握手与绑定
if (data.type === 'bind') {
if (data.message === 'targetId') {
wsClientId.value = data.clientId;
// 收到 ClientID 后,如果你有 TargetID来自扫码立即发起绑定
if(wsTargetId.value) {
sendMsg('bind', { clientId: wsClientId.value, targetId: wsTargetId.value, message: 'DGLAB' });
}
} else if (data.message === '200') {
bindMap[wsTargetId.value] = true;
wsStatusText.value = "已绑定 (Ready)";
addLog("绑定成功");
syncStatusToSocket();
}
}
// 2. 接收控制指令
if (data.type === 'msg' && data.message) {
const msg = data.message;
// 停止指令
if (msg.startsWith('clear-')) {
clearWaveStatus(msg.split('-')[1]);
}
// 强度指令 (strength-1+2+10) = Channel A, Set/Add/Sub, Value
else if(msg.startsWith('strength-')) {
try {
const parts = msg.substring(9).split('+');
const ch = parseInt(parts[0]);
const mode = parseInt(parts[1]);
const val = parseInt(parts[2]);
let current = (ch === 1) ? dglab.powerLevel[0] : dglab.powerLevel[1];
let target = 0;
if (mode === 0) target = Math.max(0, current - val); // 减
else if (mode === 1) target = current + val; // 加
else if (mode === 2) target = val; // 设
// 将指令推入蓝牙队列
setDevicePower((ch===1)?target:dglab.powerLevel[0], (ch===2)?target:dglab.powerLevel[1]);
} catch(e) {}
}
// 波形指令
else if(msg.startsWith('pulse-')) {
try {
const parts = msg.split(':');
const chStr = parts[0].split('-')[1];
const waveStr = msg.substring(msg.indexOf(':')+1);
const wave = JSON.parse(waveStr);
if (Array.isArray(wave)) updateWaveData(chStr, wave);
} catch(e) {}
}
}
// 3. 心跳
if (data.type === 'heartbeat') sendMsg('heartbeat', { message: "DGLAB" });
};
// ============================================
// 6. QR 扫码逻辑 (Separate Logic)
// ============================================
const showScanner = ref(false);
let html5QrCode = null;
const toggleScanner = () => {
if(showScanner.value) {
// 关闭
if(html5QrCode) {
html5QrCode.stop().then(() => {
html5QrCode.clear();
showScanner.value = false;
}).catch(err => console.error(err));
} else {
showScanner.value = false;
}
return;
}
// 开启
showScanner.value = true;
nextTick(() => {
html5QrCode = new Html5Qrcode("reader");
const config = {
fps: 2, // ★ 设置每秒读取2次节省性能
qrbox: 240,
aspectRatio: 1.0
};
html5QrCode.start(
{ facingMode: "environment" },
config,
(decodedText, decodedResult) => {
// 成功回调
const parsed = parseWsUrl(decodedText);
if (parsed) {
addLog("QR码识别成功");
wsUrlInput.value = decodedText; // 显示原始数据给用户看
// 自动连接
toggleScanner(); // 关闭扫码
connectSocket(); // 发起连接
}
},
(errorMessage) => {
// 每帧识别失败会在这里,可以忽略
}
).catch(err => {
addLog("相机启动失败");
showScanner.value = false;
});
});
};
// ============================================
// 7. 蓝牙核心循环 (The "Main Thread" Loop)
// ============================================
let loopRunning = false;
let statusTimer = null;
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const withTimeout = (p, ms=500) => Promise.race([p, new Promise((_,j)=>setTimeout(()=>j(new Error("Timeout")),ms))]);
// 封装安全的读写
const safeBleWrite = async (char, data) => {
if (isGattBusy) throw new Error("BUSY");
isGattBusy = true;
try { await withTimeout(char.writeValue(data), 300); }
finally { isGattBusy = false; }
};
const safeBleRead = async (char) => {
if (isGattBusy) throw new Error("BUSY");
isGattBusy = true;
try { return await withTimeout(char.readValue(), 300); }
finally { isGattBusy = false; }
};
// ★ 主循环:同时处理 波形 和 强度指令
const processLoop = async () => {
while (loopRunning && bleConnected.value) {
try {
if (isGattBusy) { await sleep(10); continue; }
const now = Date.now();
let actionTaken = false;
// 1. 处理 A 通道波形
if (channelState.A.active && channelState.A.queue.length > 0) {
if (now >= channelState.A.nextTick) {
const item = channelState.A.queue[channelState.A.index];
if (rawBLE.chars.a) {
try {
await safeBleWrite(rawBLE.chars.a, item.payload);
updateVisualizer('A', item.strength);
// 更新时间戳
if (channelState.A.nextTick === 0) channelState.A.nextTick = now + WAVE_INTERVAL;
else {
channelState.A.nextTick += WAVE_INTERVAL;
// 防止时间漂移过大
if (now - channelState.A.nextTick > 300) channelState.A.nextTick = now + WAVE_INTERVAL;
}
channelState.A.index = (channelState.A.index + 1) % channelState.A.queue.length;
actionTaken = true;
} catch (e) { if(e.message!=="BUSY") console.warn("WaveA skip"); }
}
}
} else if (channelState.A.active) {
// 队列为空但激活,画空线
updateVisualizer('A', 0);
}
// 2. 处理 B 通道波形
if (!actionTaken && channelState.B.active && channelState.B.queue.length > 0) {
if (now >= channelState.B.nextTick) {
const item = channelState.B.queue[channelState.B.index];
if (rawBLE.chars.b) {
try {
await safeBleWrite(rawBLE.chars.b, item.payload);
updateVisualizer('B', item.strength);
if (channelState.B.nextTick === 0) channelState.B.nextTick = now + WAVE_INTERVAL;
else {
channelState.B.nextTick += WAVE_INTERVAL;
if (now - channelState.B.nextTick > 300) channelState.B.nextTick = now + WAVE_INTERVAL;
}
channelState.B.index = (channelState.B.index + 1) % channelState.B.queue.length;
actionTaken = true;
} catch (e) { if(e.message!=="BUSY") console.warn("WaveB skip"); }
}
}
} else if (channelState.B.active) {
updateVisualizer('B', 0);
}
// 3. 处理常规指令 (从队列取出一个)
if (!actionTaken && cmdQueue.length > 0) {
const task = cmdQueue[0];
const char = rawBLE.chars[task.charKey];
if (char) {
try {
if (task.type === 'write') {
if(task.tag === 'pwr') addLog(task.desc);
await safeBleWrite(char, task.data);
} else if (task.type === 'read') {
if (task.tag === 'bat') {
const val = await safeBleRead(char);
batteryLevel.value = val.getUint8(0);
lastBatUpdate.value = new Date().toLocaleTimeString();
} else if (task.tag === 'status') {
const val = await safeBleRead(char); // 读取 3 bytes
if (val.byteLength >= 3) {
// 24bit data: A(11) | B(11) | Reserved(2) - usually
// 实际上 V2: 3 bytes mapping
const raw = (val.getUint8(0)) | (val.getUint8(1) << 8) | (val.getUint8(2) << 16);
const newA = Math.round(((raw >> 11) & 0x7FF) / 7);
const newB = Math.round((raw & 0x7FF) / 7);
dglab.powerLevel[0] = newA;
dglab.powerLevel[1] = newB;
syncStatusToSocket();
}
}
}
// 成功执行后移除
cmdQueue.shift();
actionTaken = true;
} catch (e) {
// 一般 BUSY 或 Timeout 重试,但如果是断连则会抛出 loop 外
if (e.message !== "BUSY") {
// 如果是严重错误,移除任务防止堵塞
cmdQueue.shift();
}
}
} else {
cmdQueue.shift(); // 特征值不存在
}
}
// 动态休眠:如果有动作,休息短一点;如果空闲,休息久一点
await sleep(actionTaken ? 15 : 20);
} catch (e) {
isGattBusy = false;
// 如果连接断了,在主逻辑处处理
if (!bleConnected.value) break;
await sleep(100);
}
}
};
// ============================================
// 8. 业务逻辑 (Utils)
// ============================================
const clearWaveStatus = (target) => {
const ch = (target == '1' || target == 'A') ? 'A' : 'B';
channelState[ch].active = false;
channelState[ch].queue = [];
channelState[ch].nextTick = 0;
updateVisualizer(ch, 0);
addLog(`[Stop] Channel ${ch}`);
};
const updateWaveData = (target, hexList) => {
if (!bleConnected.value) return;
const ch = (target == '1' || target == 'A') ? 'A' : 'B';
const parsed = hexList.map(h => parseV3Hex(h));
channelState[ch].queue = parsed;
channelState[ch].index = 0;
channelState[ch].active = true;
channelState[ch].nextTick = 0; // 重置计时器以便立即开始
addLog(`>> [PULSE] ${ch} loaded ${parsed.length} frames`);
};
// 🔥 新增:全局重置状态 (归零强度 + 清空波形)
const resetToZero = () => {
// 1. 逻辑数据归零
dglab.powerLevel = [0, 0];
// 2. 清空波形队列
channelState.A.queue = [];
channelState.A.active = false;
channelState.A.index = 0;
channelState.B.queue = [];
channelState.B.active = false;
channelState.B.index = 0;
// 3. 更新可视化为空
updateVisualizer('A', 0);
updateVisualizer('B', 0);
// 4. 如果蓝牙已连接,发送物理归零指令
if (bleConnected.value) {
// 直接构造归零指令推入队列,不通过 setDevicePower 避免递归逻辑
const arr = new Uint8Array([0, 0, 0]);
// 清理旧的强度指令,确保归零优先
for (let i=cmdQueue.length-1; i>=0; i--) {
if (cmdQueue[i].tag === 'pwr') cmdQueue.splice(i, 1);
}
cmdQueue.push({
type: 'write',
charKey: 'ab',
data: arr,
tag: 'pwr',
desc: `安全重置: 强度归零`
});
}
addLog(">>System Reset (Zero Strength & Clear Waves)<<");
};
// ... existing code (setDevicePower, syncStatusToSocket etc.) ...
const setDevicePower = (a, b) => {
// 限制
const safeA = Math.min(a, realMaxA.value);
const safeB = Math.min(b, realMaxB.value);
// 乐观更新 UI (本地数据源更新)
dglab.powerLevel = [safeA, safeB];
// ============== 新增代码开始 ==============
// 强度发生变化,立即向上方 Socket 同步最新状态
syncStatusToSocket();
// ============== 新增代码结束 ==============
// 构建指令
// A (11bit) | B (11bit)
const rawA = safeA * 7;
const rawB = safeB * 7;
const val = (rawA << 11) | rawB;
const arr = new Uint8Array([(val & 0xFF), ((val >> 8) & 0xFF), ((val >> 16) & 0xFF)]);
// 优化队列:如果有未发送的强度指令,替换之,减少堆积
for (let i=cmdQueue.length-1; i>=0; i--) {
if (cmdQueue[i].tag === 'pwr') {
cmdQueue.splice(i, 1);
}
}
cmdQueue.push({
type: 'write',
charKey: 'ab',
data: arr,
tag: 'pwr',
desc: `强度 A${safeA} B${safeB}`
});
};
// 宽松版同步函数
const syncStatusToSocket = () => {
// 只要 WebSocket 是连着的,并且我们知道要发给谁 (TargetId),就直接发
if(wsConnected.value && wsTargetId.value) {
// 构造消息
const payload = `strength-${dglab.powerLevel[0]}+${dglab.powerLevel[1]}+${realMaxA.value}+${realMaxB.value}`;
// 只有当数据真正发送时才打印日志,避免 console 刷屏
// console.log("发送同步:", payload);
sendMsg("msg", { message: payload });
}
};
// 🔥 修复Feedback 发送逻辑
const sendFeedback = (index) => {
// 必须连接 WS 才能发送反馈
if(!wsConnected.value) {
addLog("WS未连接无法发送反馈");
return;
}
// 格式必须是 "feedback-x" (x 为 0-9)
const msgPayload = `feedback-${index}`;
sendMsg("msg", { message: msgPayload });
addLog(`Feedback sent: ${msgPayload} (Index: ${index})`);
};
// ============================================
// 9. 连接建立
// ============================================
const scanAndConnectBLE = async () => {
isConnectingBLE.value = true;
// 安全清理旧设备对象
if (rawBLE.device) {
if (rawBLE.device.gatt.connected) rawBLE.device.gatt.disconnect();
rawBLE.device = null;
}
try {
const device = await navigator.bluetooth.requestDevice({
filters: [{ namePrefix: "D-LAB" }],
optionalServices: [
"955a180b-0fe2-f5aa-a094-84b8d4f3e8ad", // Service Basic
"955a180a-0fe2-f5aa-a094-84b8d4f3e8ad" // Service Bat
]
});
device.addEventListener('gattserverdisconnected', onDisconnect);
rawBLE.device = device;
bleDeviceName.value = device.name;
addLog("正在连接 GATT...");
const server = await device.gatt.connect();
// 🔥🔥🔥 关键修复:增加 500ms 延时 🔥🔥🔥
// 等待物理连接稳定,防止立即读取服务导致 "GATT Server is disconnected"
await sleep(500);
if (!server.connected) {
throw new Error("连接不稳定,设备已断开,请重试");
}
addLog("获取服务中...");
// Services
const sBasic = await server.getPrimaryService("955a180b-0fe2-f5aa-a094-84b8d4f3e8ad");
const sBat = await server.getPrimaryService("955a180a-0fe2-f5aa-a094-84b8d4f3e8ad");
// Characteristics
rawBLE.chars.battery = await sBat.getCharacteristic("955a1500-0fe2-f5aa-a094-84b8d4f3e8ad");
// 获取 Basic 下的特征值
rawBLE.chars.ab = await sBasic.getCharacteristic("955a1504-0fe2-f5aa-a094-84b8d4f3e8ad");
rawBLE.chars.a = await sBasic.getCharacteristic("955a1505-0fe2-f5aa-a094-84b8d4f3e8ad");
rawBLE.chars.b = await sBasic.getCharacteristic("955a1506-0fe2-f5aa-a094-84b8d4f3e8ad");
bleConnected.value = true;
isGattBusy = false;
loopRunning = true;
// 重置可视化
visHistory.A = []; visHistory.B = [];
for(let i=0;i<MAX_VIS_POINTS;i++) { visHistory.A.push(0); visHistory.B.push(0); }
addLog("Bluetooth Connected (Ready)");
resetToZero();
processLoop();
// 启动后台状态检查 (每2秒同步一次)
if(statusTimer) clearInterval(statusTimer);
statusTimer = setInterval(() => {
if (bleConnected.value) {
// 只有在空闲时才读取,避免冲突
if (!isGattBusy) {
cmdQueue.push({ type:'read', tag:'status', charKey:'ab' });
// 10% 概率读电量
if (Math.random() < 0.1) cmdQueue.push({ type:'read', tag:'bat', charKey:'battery' });
}
}
}, 2000);
} catch (e) {
console.error(e);
addLog("BLE Connect Fail: " + e.message);
// 如果半路失败,清理连接状态
if (rawBLE.device && rawBLE.device.gatt.connected) {
rawBLE.device.gatt.disconnect();
}
} finally {
isConnectingBLE.value = false;
}
};
const onDisconnect = () => {
bleConnected.value = false;
loopRunning = false;
if(statusTimer) clearInterval(statusTimer);
addLog("BLE Disconnected");
};
// 生命周期清理
onUnmounted(() => {
loopRunning = false;
if(socket) socket.close();
if(html5QrCode) html5QrCode.stop();
if(statusTimer) clearInterval(statusTimer);
});
</script>

1
src/assets/vue.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

5
src/main.js Normal file
View File

@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
createApp(App).mount('#app')

2
src/style.css Normal file
View File

@@ -0,0 +1,2 @@
@import "tailwindcss";
@plugin "daisyui";

7
vite.config.js Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from "vite";
import tailwindcss from "@tailwindcss/vite";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [tailwindcss(), vue()],
});