1252 lines
30 KiB
Vue
1252 lines
30 KiB
Vue
<template>
|
||
<view class="page">
|
||
<scroll-view class="content" scroll-y>
|
||
<view class="section card">
|
||
<view class="section-title-row">
|
||
<text class="section-title">设备信息</text>
|
||
<text class="section-tip">连接后自动同步</text>
|
||
</view>
|
||
<view class="name-edit-row">
|
||
<input class="name-input" type="text" v-model="name" placeholder="设备名称" />
|
||
<button class="save-name-btn" @click="saveName">保存</button>
|
||
</view>
|
||
|
||
<view class="info-grid two-col">
|
||
<view class="info-item">
|
||
<text class="label">电量</text>
|
||
<text class="value">{{ battery.level }}%</text>
|
||
<text class="sub">{{ battery.mv }} mV</text>
|
||
</view>
|
||
<view class="info-item">
|
||
<text class="label">版本</text>
|
||
<text class="value">{{ ver || '--' }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="section card">
|
||
<view class="section-title-row">
|
||
<text class="section-title">治疗参数</text>
|
||
</view>
|
||
|
||
<view class="mode-wrap">
|
||
<button class="mode-btn" :class="Number(mode) === 0 ? 'active' : ''" @click="setMode(0)">舒缓模式</button>
|
||
<button class="mode-btn" :class="Number(mode) === 1 ? 'active' : ''" @click="setMode(1)">强效模式</button>
|
||
</view>
|
||
|
||
<view class="field-row">
|
||
<view class="field-header">
|
||
<text class="field-label">电压</text>
|
||
<view class="quick-actions">
|
||
<button class="mini-btn" @click="decreaseVoltage">-</button>
|
||
<button class="mini-btn" @click="increaseVoltage">+</button>
|
||
</view>
|
||
</view>
|
||
<view class="voltage-pill">{{ voltageText }}</view>
|
||
</view>
|
||
|
||
<view class="field-row">
|
||
<view class="field-header">
|
||
<text class="field-label">档位</text>
|
||
<view class="quick-actions">
|
||
<button class="mini-btn" @click="sendCtrl(0x03)">-</button>
|
||
<button class="mini-btn" @click="sendCtrl(0x02)">+</button>
|
||
</view>
|
||
</view>
|
||
<slider v-model="gear" step="1" :min="1" :max="10" show-value @change="handleGearChange" activeColor="#1f9f9a" backgroundColor="#bfe9e7" />
|
||
</view>
|
||
|
||
<view class="field-row">
|
||
<text class="field-label">缩放</text>
|
||
<slider v-model="scale" :step="0.1" :min="0.1" :max="5.0" show-value @change="setScale" activeColor="#1f9f9a" backgroundColor="#bfe9e7" />
|
||
</view>
|
||
|
||
<view class="field-row">
|
||
<text class="field-label">频率</text>
|
||
<slider v-model="frequency" step="20" :min="20" :max="200" show-value @change="setFrequency" activeColor="#1f9f9a" backgroundColor="#bfe9e7" />
|
||
</view>
|
||
|
||
<view class="field-row">
|
||
<text class="field-label">脉宽</text>
|
||
<slider v-model="pluse" step="100" :min="100" :max="500" show-value @change="setPluse" activeColor="#1f9f9a" backgroundColor="#bfe9e7" />
|
||
</view>
|
||
</view>
|
||
|
||
<view class="section card">
|
||
<view class="section-title-row">
|
||
<text class="section-title">灯光个性化</text>
|
||
</view>
|
||
|
||
<view class="color-row">
|
||
<view class="color-item" @click="handleOpenStandBy">
|
||
<text class="field-label">待机颜色</text>
|
||
<view class="color-dot" :style="{ backgroundColor: standbyColor || '#1e3a8a' }"></view>
|
||
</view>
|
||
<view class="color-item" @click="handleOpenRunning">
|
||
<text class="field-label">运行颜色</text>
|
||
<view class="color-dot" :style="{ backgroundColor: runningColor || '#dc2626' }"></view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="field-row">
|
||
<text class="field-label">亮度</text>
|
||
<uni-data-select v-model="lighting" :localdata="[
|
||
{ value: 0, text: '100%' },
|
||
{ value: 1, text: '50%' },
|
||
{ value: 2, text: '25%' },
|
||
{ value: 3, text: '12.5%' },
|
||
{ value: 4, text: '6.25%' }
|
||
]" @change="setLighting" />
|
||
</view>
|
||
</view>
|
||
</scroll-view>
|
||
|
||
<view class="footer-actions">
|
||
<button class="action-btn ghost" hover-class="btn-hover" @click="sendCMD(0x01, [])">刷新状态</button>
|
||
<button
|
||
v-if="isRunning"
|
||
class="action-btn danger"
|
||
hover-class="btn-hover"
|
||
@click="stop"
|
||
>停止</button>
|
||
<button
|
||
v-else
|
||
class="action-btn primary"
|
||
hover-class="btn-hover"
|
||
@click="start"
|
||
>启动</button>
|
||
</view>
|
||
|
||
<uni-popup ref="standByPopup" type="center">
|
||
<x-color-picker v-model="standbyColor" :show-alpha="false" :show-presets="false" @confirm="setStandByColor" />
|
||
</uni-popup>
|
||
<uni-popup ref="runningPopup" type="center">
|
||
<x-color-picker v-model="runningColor" :show-alpha="false" :show-presets="false" @confirm="setRunningColor" />
|
||
</uni-popup>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
function toHex(n) {
|
||
return n.toString(16).padStart(2, '0');
|
||
}
|
||
|
||
function utf8Encode(str) {
|
||
let codePoints = [];
|
||
let i = 0;
|
||
const len = str.length;
|
||
|
||
while (i < len) {
|
||
let c = str.charCodeAt(i);
|
||
|
||
// 单字节字符 (0x00-0x7F)
|
||
if (c < 0x80) {
|
||
codePoints.push(c);
|
||
i++;
|
||
}
|
||
// 双字节字符 (0x80-0x7FF)
|
||
else if (c < 0x800) {
|
||
codePoints.push(0xC0 | (c >> 6));
|
||
codePoints.push(0x80 | (c & 0x3F));
|
||
i++;
|
||
}
|
||
// 三字节字符 (0x800-0xFFFF)
|
||
else if (c < 0x10000) {
|
||
codePoints.push(0xE0 | (c >> 12));
|
||
codePoints.push(0x80 | ((c >> 6) & 0x3F));
|
||
codePoints.push(0x80 | (c & 0x3F));
|
||
i++;
|
||
}
|
||
// 四字节字符 (0x10000-0x10FFFF)
|
||
else {
|
||
c -= 0x10000;
|
||
codePoints.push(0xF0 | (c >> 18));
|
||
codePoints.push(0x80 | ((c >> 12) & 0x3F));
|
||
codePoints.push(0x80 | ((c >> 6) & 0x3F));
|
||
codePoints.push(0x80 | (c & 0x3F));
|
||
i++;
|
||
}
|
||
}
|
||
|
||
return codePoints; // 直接返回数组,无需再转 Array.from
|
||
}
|
||
|
||
function getErrStr(e) {
|
||
return {
|
||
0: "成功",
|
||
1: "运行中禁止",
|
||
2: "参数越界",
|
||
3: "档位极限",
|
||
0xEE: "校验错误",
|
||
0xFF: "未知命令"
|
||
} [e] || `Err(0x${e.toString(16)})`;
|
||
}
|
||
|
||
function safeDecode(bytes) {
|
||
try {
|
||
// 1. 统一格式:转为Uint8Array(兼容uniapp各端传入的不同字节格式)
|
||
let uint8Bytes = bytes;
|
||
if (!(bytes instanceof Uint8Array)) {
|
||
uint8Bytes = new Uint8Array(bytes);
|
||
}
|
||
|
||
// 2. 找到第一个0终止符,截断无效内容(和原逻辑一致)
|
||
let endIndex = -1;
|
||
for (let i = 0; i < uint8Bytes.length; i++) {
|
||
if (uint8Bytes[i] === 0) {
|
||
endIndex = i;
|
||
break;
|
||
}
|
||
}
|
||
if (endIndex !== -1) {
|
||
uint8Bytes = uint8Bytes.slice(0, endIndex);
|
||
}
|
||
|
||
// 3. 兼容处理:部分小程序端TextDecoder支持性差,增加兜底方案
|
||
let result = '';
|
||
if (typeof TextDecoder !== 'undefined') {
|
||
// 优先使用标准TextDecoder
|
||
result = new TextDecoder("utf-8", {
|
||
fatal: false
|
||
}).decode(uint8Bytes);
|
||
} else {
|
||
// 兜底:手动转换UTF-8字节到字符串(兼容低版本小程序)
|
||
result = decodeURIComponent(escape(String.fromCharCode.apply(null, uint8Bytes)));
|
||
}
|
||
|
||
// 过滤解码后的空字符(避免不可见字符)
|
||
return result.replace(/\0/g, '');
|
||
} catch (e) {
|
||
console.error('safeDecode解码失败:', e);
|
||
return "[Decode Err]";
|
||
}
|
||
}
|
||
|
||
export default {
|
||
data() {
|
||
return {
|
||
isRunning: false,
|
||
deviceId: '',
|
||
logs: [],
|
||
name: '',
|
||
sn: '',
|
||
ver: '',
|
||
mode: '0',
|
||
gear: 1,
|
||
timer: '15',
|
||
frequency: '100',
|
||
pluse: '200',
|
||
scale: '1',
|
||
voltage: '0',
|
||
standbyColor: '',
|
||
runningColor: '',
|
||
lighting: '0',
|
||
SERVICE_UUID: '',
|
||
CHAR_WRITE_UUID: '',
|
||
CHAR_NOTIFY_UUID: '',
|
||
battery: {
|
||
level: 0,
|
||
mv: 1600
|
||
},
|
||
inited: false,
|
||
rxBuffer: [],
|
||
timer1: '',
|
||
timer2: ''
|
||
}
|
||
},
|
||
onLoad(options) {
|
||
uni.hideLoading()
|
||
if (options && options.deviceId) {
|
||
this.deviceId = decodeURIComponent(options.deviceId)
|
||
}
|
||
if (!this.deviceId) {
|
||
uni.showToast({
|
||
title: '缺少设备ID',
|
||
icon: 'none'
|
||
})
|
||
return
|
||
}
|
||
const that = this
|
||
uni.openBluetoothAdapter({
|
||
success() {
|
||
console.log('蓝牙已就绪')
|
||
that.inited = true
|
||
that.relink()
|
||
},
|
||
fail() {
|
||
uni.showToast({
|
||
title: '蓝牙不可用,请开启蓝牙',
|
||
icon: 'none'
|
||
})
|
||
}
|
||
})
|
||
},
|
||
destroyed() {
|
||
// 关闭蓝牙适配器,释放资源
|
||
uni.closeBluetoothAdapter({
|
||
success() {
|
||
console.log('蓝牙适配器已关闭');
|
||
},
|
||
fail(err) {
|
||
// 捕获关闭失败的异常(比如蓝牙未打开时),避免控制台报错
|
||
console.warn('关闭蓝牙适配器失败', err);
|
||
}
|
||
});
|
||
clearTimeout(this.timer1);
|
||
clearInterval(this.timer2)
|
||
},
|
||
computed: {
|
||
stimPl() {
|
||
const pl = [
|
||
parseInt(this.mode),
|
||
parseInt(this.gear) - 1,
|
||
parseInt(this.timer),
|
||
parseInt(this.frequency) >> 8,
|
||
parseInt(this.frequency) & 0xFF,
|
||
parseInt(this.pluse) >> 8,
|
||
parseInt(this.pluse) & 0xFF,
|
||
Math.round(parseFloat(this.scale) * 1000) >> 8,
|
||
Math.round(parseFloat(this.scale) * 1000) & 0xFF,
|
||
parseInt(this.voltage)
|
||
]
|
||
return pl
|
||
},
|
||
voltageText() {
|
||
const map = ['11V', '15V', '26V', '30V', '45V', '48V', '52V', '56V']
|
||
return map[parseInt(this.voltage)] || '--'
|
||
}
|
||
},
|
||
methods: {
|
||
handleGearChange(e) {
|
||
this.gear = e.detail.value;
|
||
this.sendSingle('GEAR');
|
||
},
|
||
setFrequency(e) {
|
||
this.frequency = e.detail.value;
|
||
this.sendStimAll()
|
||
},
|
||
setPluse(e) {
|
||
this.pluse = e.detail.value;
|
||
this.sendStimAll()
|
||
},
|
||
sendStimAll() {
|
||
this.sendCMD(0x10, this.stimPl)
|
||
},
|
||
// 设置设备名称
|
||
saveName() {
|
||
const nextName = (this.name || '').trim()
|
||
if (!nextName) {
|
||
uni.showToast({ title: '请输入设备名称', icon: 'none' })
|
||
return
|
||
}
|
||
const pl = utf8Encode(nextName)
|
||
this.sendCMD(0xC1, Array.from(pl))
|
||
uni.showToast({ title: '已发送保存', icon: 'none' })
|
||
},
|
||
|
||
relink() {
|
||
const that = this;
|
||
if (!this.inited) {
|
||
uni.openBluetoothAdapter({
|
||
success() {
|
||
that.inited = true
|
||
that.relink()
|
||
}
|
||
})
|
||
return;
|
||
}
|
||
|
||
const deviceId = this.deviceId;
|
||
uni.showLoading({ title: '正在准备连接设备' })
|
||
|
||
const bindNotifyAndReady = () => {
|
||
uni.notifyBLECharacteristicValueChange({
|
||
deviceId,
|
||
serviceId: that.SERVICE_UUID,
|
||
characteristicId: that.CHAR_NOTIFY_UUID,
|
||
state: true,
|
||
success() {
|
||
uni.onBLECharacteristicValueChange((res) => {
|
||
const data = new Uint8Array(res.value)
|
||
that.rxBuffer = that.rxBuffer || []
|
||
that.rxBuffer.push(...data)
|
||
while (true) {
|
||
let headIdx = -1
|
||
for (let i = 0; i < that.rxBuffer.length - 1; i++) {
|
||
if (that.rxBuffer[i] === 0xAA && that.rxBuffer[i + 1] === 0x55) {
|
||
headIdx = i
|
||
break
|
||
}
|
||
}
|
||
if (headIdx === -1) {
|
||
if (that.rxBuffer.length > 200) that.rxBuffer = []
|
||
break
|
||
}
|
||
if (headIdx > 0) that.rxBuffer.splice(0, headIdx)
|
||
if (that.rxBuffer.length < 3) break
|
||
const len = that.rxBuffer[2]
|
||
const totalLen = len + 4
|
||
if (that.rxBuffer.length < totalLen) break
|
||
const packet = that.rxBuffer.slice(0, totalLen)
|
||
that.rxBuffer.splice(0, totalLen)
|
||
that.parsePkt(packet)
|
||
}
|
||
})
|
||
that.timer1 = setTimeout(() => that.sendCMD(0xC0, []), 500)
|
||
uni.hideLoading()
|
||
},
|
||
fail(err) {
|
||
uni.hideLoading()
|
||
console.log('订阅失败', err)
|
||
uni.showToast({ icon: 'none', title: '订阅通知失败' })
|
||
}
|
||
})
|
||
}
|
||
|
||
const tryBindService = (services, idx) => {
|
||
if (idx >= services.length) {
|
||
uni.hideLoading()
|
||
console.log('可用服务列表', services)
|
||
uni.showToast({ icon: 'none', title: '未找到可通信特征值' })
|
||
return
|
||
}
|
||
const currService = services[idx]
|
||
uni.getBLEDeviceCharacteristics({
|
||
deviceId,
|
||
serviceId: currService.uuid,
|
||
success(charRes) {
|
||
const characteristics = charRes.characteristics || []
|
||
let writeUUID = ''
|
||
let notifyUUID = ''
|
||
for (let i = 0; i < characteristics.length; i++) {
|
||
const item = characteristics[i]
|
||
const cUuid = (item.uuid || '').toLowerCase()
|
||
const p = item.properties || {}
|
||
if (!writeUUID && (cUuid.includes('ae01') || p.write || p.writeNoResponse)) {
|
||
writeUUID = item.uuid
|
||
}
|
||
if (!notifyUUID && (cUuid.includes('ae02') || p.notify || p.indicate)) {
|
||
notifyUUID = item.uuid
|
||
}
|
||
}
|
||
if (writeUUID && notifyUUID) {
|
||
that.SERVICE_UUID = currService.uuid
|
||
that.CHAR_WRITE_UUID = writeUUID
|
||
that.CHAR_NOTIFY_UUID = notifyUUID
|
||
bindNotifyAndReady()
|
||
return
|
||
}
|
||
tryBindService(services, idx + 1)
|
||
},
|
||
fail() {
|
||
tryBindService(services, idx + 1)
|
||
}
|
||
})
|
||
}
|
||
|
||
const fetchServicesWithRetry = (retry = 0) => {
|
||
uni.getBLEDeviceServices({
|
||
deviceId,
|
||
success(res) {
|
||
const services = (res.services || []).filter(item => item && item.uuid)
|
||
console.log('服务', services)
|
||
if (!services.length) {
|
||
if (retry < 4) {
|
||
setTimeout(() => fetchServicesWithRetry(retry + 1), 700)
|
||
return
|
||
}
|
||
uni.hideLoading()
|
||
uni.showToast({ icon: 'none', title: '未发现可用服务' })
|
||
return
|
||
}
|
||
tryBindService(services, 0)
|
||
},
|
||
fail(error) {
|
||
if (retry < 1) {
|
||
setTimeout(() => fetchServicesWithRetry(retry + 1), 500)
|
||
return
|
||
}
|
||
uni.hideLoading()
|
||
console.log('读取服务失败', error)
|
||
uni.showToast({ icon: 'none', title: '设备连接异常' })
|
||
}
|
||
})
|
||
}
|
||
|
||
const continueWithConnectedLink = () => {
|
||
that.CHAR_WRITE_UUID = ''
|
||
that.CHAR_NOTIFY_UUID = ''
|
||
setTimeout(() => fetchServicesWithRetry(0), 500)
|
||
}
|
||
|
||
uni.getConnectedBluetoothDevices({
|
||
services: [],
|
||
success(res) {
|
||
const connected = (res.devices || []).some(d => d.deviceId === deviceId)
|
||
if (connected) {
|
||
console.log('复用已存在连接', deviceId)
|
||
continueWithConnectedLink()
|
||
return
|
||
}
|
||
uni.createBLEConnection({
|
||
deviceId,
|
||
success() {
|
||
console.log('新建BLE连接', deviceId)
|
||
continueWithConnectedLink()
|
||
},
|
||
fail(error) {
|
||
uni.hideLoading()
|
||
console.log(error)
|
||
uni.showToast({ icon: 'none', title: '设备连接失败' })
|
||
}
|
||
})
|
||
},
|
||
fail() {
|
||
uni.createBLEConnection({
|
||
deviceId,
|
||
success() {
|
||
console.log('新建BLE连接(兜底)', deviceId)
|
||
continueWithConnectedLink()
|
||
},
|
||
fail(error) {
|
||
uni.hideLoading()
|
||
console.log(error)
|
||
uni.showToast({ icon: 'none', title: '设备连接失败' })
|
||
}
|
||
})
|
||
}
|
||
})
|
||
},
|
||
// 构建底层协议
|
||
buildPacket(cmd, payload) {
|
||
const len = payload.length + 1;
|
||
let sum = len + cmd;
|
||
payload.forEach(b => sum += b);
|
||
const pkt = [0xAA, 0x55, len, cmd, ...payload];
|
||
pkt.push(sum & 0xFF);
|
||
return new Uint8Array(pkt);
|
||
},
|
||
// 记录日志 + 发送消息
|
||
sendCMD(cmd, payload, desc) {
|
||
const pkt = this.buildPacket(cmd, payload);
|
||
// 发送消息
|
||
uni.writeBLECharacteristicValue({
|
||
deviceId: this.deviceId,
|
||
serviceId: this.SERVICE_UUID,
|
||
characteristicId: this.CHAR_WRITE_UUID,
|
||
writeType: 'writeNoResponse',
|
||
value: pkt.buffer,
|
||
success() {
|
||
console.log('写入成功', pkt, pkt.buffer)
|
||
},
|
||
fail(error) {
|
||
console.log(error)
|
||
}
|
||
})
|
||
// 记录日志
|
||
},
|
||
log() {
|
||
// 本地日志 + 云端日志记录
|
||
},
|
||
// 单设通用
|
||
sendSingle(t) {
|
||
if (t == 'GEAR') {
|
||
console.log(this.gear)
|
||
this.sendCMD(0x13, [parseInt(this.gear) - 1], '单设档位');
|
||
} else if (t === 'SCALE') {
|
||
const s = Math.round(parseFloat(this.scale) * 1000);
|
||
this.sendCMD(0x14, [s >> 8, s & 0xFF], '单设缩放');
|
||
} else if (t === 'VOLT') {
|
||
this.sendCMD(0x15, [parseInt(this.voltage)], '单设电压')
|
||
} else if (t === 'BRIGHT') {
|
||
this.sendCMD(0x22, [parseInt(this.lighting)], '单设亮度')
|
||
} else if (t === 'COLOR') {
|
||
const [s_r, s_g, s_b] = this.getColorRGB(this.standbyColor);
|
||
const [r_r, r_g, r_b] = this.getColorRGB(this.runningColor);
|
||
this.sendCMD(0x21, [s_r, s_g, s_b, r_r, r_g, r_b], '单设颜色')
|
||
}
|
||
},
|
||
|
||
getColorRGB(hex) {
|
||
const r = parseInt(hex.substr(1, 2), 16)
|
||
const g = parseInt(hex.substr(3, 2), 16)
|
||
const b = parseInt(hex.substr(5, 2), 16)
|
||
return [r, g, b]
|
||
},
|
||
|
||
// 控制
|
||
sendCtrl(a) {
|
||
this.sendCMD(0x30, [a], `控制: ${['Stop','Start','Gear+','Gear-','ModeSw'][a]}`)
|
||
},
|
||
// 获取设备电刺激信息
|
||
|
||
// 设定电刺激
|
||
|
||
// 单设挡位
|
||
|
||
// 单设缩放
|
||
setScale(e) {
|
||
const value = e.detail.value
|
||
this.sendSingle("SCALE")
|
||
},
|
||
// 单设电压
|
||
setVoltage1(e) {
|
||
console.log(e)
|
||
const value = e;
|
||
this.sendSingle('VOLT')
|
||
},
|
||
increaseVoltage() {
|
||
const curr = parseInt(this.voltage)
|
||
if (curr >= 7) return
|
||
this.voltage = curr + 1
|
||
this.sendSingle('VOLT')
|
||
},
|
||
decreaseVoltage() {
|
||
const curr = parseInt(this.voltage)
|
||
if (curr <= 0) return
|
||
this.voltage = curr - 1
|
||
this.sendSingle('VOLT')
|
||
},
|
||
// 获取设备信息
|
||
|
||
// 修改设备名称
|
||
|
||
// 获取个性化信息
|
||
|
||
// 设置个性化信息
|
||
|
||
// 单设亮度
|
||
|
||
// 单设颜色
|
||
|
||
// 启动
|
||
start() {
|
||
if (this.isRunning) return
|
||
this.sendCtrl(0x01)
|
||
this.isRunning = true
|
||
},
|
||
// 停止
|
||
stop() {
|
||
if (!this.isRunning) return
|
||
this.sendCtrl(0x00)
|
||
this.isRunning = false
|
||
},
|
||
// 加档
|
||
|
||
// 减档
|
||
|
||
// 换模
|
||
setMode(m) {
|
||
if (this.mode === m) return
|
||
this.mode = m
|
||
if (m == 0) {
|
||
this.sendCMD(0x11, [this.timer, this.frequency >> 8, this.frequency & 0xFF, this.pluse >> 8, this.pluse &
|
||
0xFF
|
||
])
|
||
} else {
|
||
this.sendCMD(0x12, [this.timer, this.frequency >> 8, this.frequency & 0xFF, this.pluse >> 8, this.pluse &
|
||
0xFF
|
||
])
|
||
}
|
||
this.sendCtrl(0x04)
|
||
},
|
||
handleModeChange(e) {
|
||
const m = Number(e && e.detail ? e.detail.value : 0)
|
||
this.setMode(m)
|
||
},
|
||
// 单设档位
|
||
|
||
handleOpenStandBy() {
|
||
this.$refs.standByPopup.open()
|
||
},
|
||
handleOpenRunning() {
|
||
this.$refs.runningPopup.open()
|
||
},
|
||
setStandByColor(c) {
|
||
this.standbyColor = c.hex
|
||
this.$refs.standByPopup.close()
|
||
this.sendSingle('COLOR')
|
||
},
|
||
setRunningColor(c) {
|
||
this.runningColor = c.hex
|
||
this.$refs.runningPopup.close()
|
||
this.sendSingle('COLOR')
|
||
},
|
||
setLighting(v) {
|
||
this.sendSingle('BRIGHT')
|
||
},
|
||
parsePkt(pkt) {
|
||
const cmd = pkt[3];
|
||
const pl = pkt.slice(4, pkt.length - 1);
|
||
let desc = '',
|
||
style = '';
|
||
console.log('解析pkt', toHex(cmd), pl)
|
||
|
||
try {
|
||
switch (cmd) {
|
||
case 0xFF: // ACK
|
||
if (pl[1] === 0) desc = `ACK成功: 0x${pl[0].toString(16).toUpperCase()}`;
|
||
else {
|
||
desc = `ACK失败: 0x${pl[0].toString(16).toUpperCase()} -> ${getErrStr(pl[1])}`;
|
||
style = "err";
|
||
}
|
||
break;
|
||
|
||
case 0x80: // REPORT ALL
|
||
case 0x81: { // REPORT STIM
|
||
console.log('获取所有参数')
|
||
const m = pl[0],
|
||
g = pl[1],
|
||
t = pl[2];
|
||
const f = (pl[3] << 8) | pl[4],
|
||
w = (pl[5] << 8) | pl[6],
|
||
s = (pl[7] << 8) | pl[8],
|
||
v = pl[9],
|
||
run = pl[10];
|
||
desc =
|
||
`状态: ${m===0?'舒缓':'强效'}, ${g+1}档, ${t}min, ${f}Hz, ${w}us, ${(s/1000).toFixed(1)}x}`;
|
||
|
||
this.mode = m;
|
||
this.gear = g + 1;
|
||
this.timer = t;
|
||
this.frequency = f;
|
||
this.pluse = w;
|
||
this.scale = (s / 1000).toFixed(1);
|
||
this.voltage = v;
|
||
this.isRunning = !!run;
|
||
|
||
style = "sync";
|
||
|
||
if (cmd === 0x80 && pl.length >= 18) {
|
||
const sr = pl[11],
|
||
sg = pl[12],
|
||
sb = pl[13],
|
||
rr = pl[14],
|
||
rg = pl[15],
|
||
rb = pl[16],
|
||
br = pl[17];
|
||
desc +=
|
||
`\n个性: Sys(#${toHex(sr)}${toHex(sg)}${toHex(sb)}) Run(#${toHex(rr)}${toHex(rg)}${toHex(rb)})}`;
|
||
this.standbyColor = '#' + toHex(sr) + toHex(sg) + toHex(sb)
|
||
this.runningColor = '#' + toHex(rr) + toHex(rg) + toHex(rb)
|
||
console.log(this.standbyColor, this.runningColor)
|
||
this.lighting = br;
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 0x82: { // REPORT PERSONAL
|
||
sync = true;
|
||
const sr2 = pl[0],
|
||
sg2 = pl[1],
|
||
sb2 = pl[2],
|
||
rr2 = pl[3],
|
||
rg2 = pl[4],
|
||
rb2 = pl[5],
|
||
br2 = pl[6];
|
||
desc =
|
||
`个性化: Sys(#${toHex(sr2)}${toHex(sg2)}${toHex(sb2)}) Run(#${toHex(rr2)}${toHex(rg2)}${toHex(rb2)}) 亮度${getBrightStr(br2)}`;
|
||
this.standbyColor = '#' + toHex(sr2) + toHex(sg2) + toHex(sb2)
|
||
this.runningColor = '#' + toHex(rr2) + toHex(rg2) + toHex(sb2)
|
||
this.lighting = br2;
|
||
break;
|
||
}
|
||
|
||
case 0xC0: { // Get All Info
|
||
style = "sys";
|
||
let ptr = 0;
|
||
if (pl.length > 3) {
|
||
const snLen = pl[ptr++];
|
||
console.log(snLen, ptr, pl.length)
|
||
if (ptr + snLen < pl.length) {
|
||
console.log('开始解析SN')
|
||
const sn = safeDecode(pl.slice(ptr, ptr + snLen));
|
||
console.log('SN:', sn)
|
||
ptr += snLen;
|
||
this.sn = sn
|
||
desc += `SN:${sn} `;
|
||
}
|
||
if (ptr < pl.length) {
|
||
const nameLen = pl[ptr++];
|
||
if (ptr + nameLen < pl.length) {
|
||
const name = safeDecode(pl.slice(ptr, ptr + nameLen));
|
||
ptr += nameLen;
|
||
this.name = name
|
||
desc += `Name:${name} `;
|
||
}
|
||
}
|
||
if (ptr + 2 < pl.length) {
|
||
const v1 = pl[ptr++],
|
||
v2 = pl[ptr++],
|
||
v3 = pl[ptr++];
|
||
// safeSetVal('sys_ver', `v${v1}.${v2}.${v3}`);
|
||
this.ver = `v${v1}.${v2}.${v3}`
|
||
desc += `Ver:v${v1}.${v2}.${v3}`;
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 0xC1: // Set Name Echo
|
||
case 0xC4: { // Get Name
|
||
style = "sys";
|
||
const nameStr = safeDecode(pl);
|
||
desc = `蓝牙名: ${nameStr}`;
|
||
this.name = nameStr
|
||
break;
|
||
}
|
||
|
||
case 0xC3: { // Get SN
|
||
style = "sys";
|
||
const snStr = safeDecode(pl);
|
||
desc = `SN序列号: ${snStr}`;
|
||
this.sn = snStr
|
||
break;
|
||
}
|
||
|
||
case 0xC5: { // Get Version
|
||
style = "sys";
|
||
desc = `固件版本: v${pl[0]}.${pl[1]}.${pl[2]}`;
|
||
this.ver = `v${pl[0]}.${pl[1]}.${pl[2]}`
|
||
break;
|
||
}
|
||
|
||
case 0xC2:
|
||
desc = `设置SN结果: ${pl[0]===0?'成功':'失败'}`;
|
||
style = "sys";
|
||
break;
|
||
case 0xB0:
|
||
desc = "通知: 蓝牙已连接";
|
||
uni.showToast({
|
||
title: '蓝牙已连接',
|
||
icon: 'none'
|
||
});
|
||
// 连接成功后获取一次设备信息
|
||
this.sendCMD(0xC0, [])
|
||
uni.hideLoading()
|
||
break;
|
||
case 0xB2:
|
||
desc = "通知: [低电量关机请求]";
|
||
uni.showToast({
|
||
icon: 'exception',
|
||
title: '电量不足'
|
||
})
|
||
style = "err";
|
||
break;
|
||
case 0xA1:
|
||
this.battery = {
|
||
level: pl[1],
|
||
mv: (pl[2] << 8 | pl[3])
|
||
}
|
||
desc = `电池上报: ${pl[1]}%, ${(pl[2]<<8|pl[3])}mV`;
|
||
break;
|
||
|
||
default:
|
||
desc = `CMD: 0x${cmd.toString(16).toUpperCase()}`;
|
||
}
|
||
} catch (e) {
|
||
desc = "解析异常: " + e.message;
|
||
style = "err";
|
||
}
|
||
},
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
:root {
|
||
--bg-page: #f8fafc;
|
||
--bg-card: #ffffff;
|
||
--bg-soft: #f8fafc;
|
||
--border-soft: #edf2f7;
|
||
--border-input: #e2e8f0;
|
||
--text-main: #0f172a;
|
||
--text-sub: #64748b;
|
||
--text-muted: #94a3b8;
|
||
--primary: #1f9f9a;
|
||
--primary-soft: #e6f5f4;
|
||
--danger-soft: #fef2f2;
|
||
--danger: #b91c1c;
|
||
}
|
||
|
||
@media (prefers-color-scheme: dark) {
|
||
:root {
|
||
--bg-page: #0f172a;
|
||
--bg-card: #111827;
|
||
--bg-soft: #1f2937;
|
||
--border-soft: #273244;
|
||
--border-input: #334155;
|
||
--text-main: #e5e7eb;
|
||
--text-sub: #cbd5e1;
|
||
--text-muted: #94a3b8;
|
||
--primary: #22b3ad;
|
||
--primary-soft: #113c39;
|
||
--danger-soft: #3b1e24;
|
||
--danger: #f87171;
|
||
}
|
||
}
|
||
|
||
.page {
|
||
background: var(--bg-page);
|
||
min-height: 100vh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.content {
|
||
flex: 1;
|
||
padding: 20rpx 20rpx 140rpx;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.section {
|
||
margin-bottom: 16rpx;
|
||
}
|
||
|
||
.card {
|
||
background: #ffffff;
|
||
border: 1rpx solid #edf2f7;
|
||
border-radius: 16rpx;
|
||
padding: 20rpx;
|
||
}
|
||
|
||
.section-title-row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 14rpx;
|
||
}
|
||
|
||
.section-title {
|
||
font-size: 28rpx;
|
||
font-weight: 600;
|
||
color: #0f172a;
|
||
}
|
||
|
||
.section-tip {
|
||
font-size: 22rpx;
|
||
color: #94a3b8;
|
||
}
|
||
|
||
.name-edit-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12rpx;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
|
||
.name-input {
|
||
flex: 1;
|
||
height: 74rpx;
|
||
line-height: 74rpx;
|
||
background: var(--bg-soft);
|
||
border-radius: 12rpx;
|
||
padding: 0 18rpx;
|
||
font-size: 28rpx;
|
||
color: var(--text-main);
|
||
}
|
||
|
||
.save-name-btn {
|
||
height: 74rpx;
|
||
line-height: 74rpx;
|
||
padding: 0 20rpx;
|
||
border-radius: 12rpx;
|
||
border: 1rpx solid var(--border-input);
|
||
background: var(--bg-soft);
|
||
color: var(--text-sub);
|
||
font-size: 24rpx;
|
||
}
|
||
|
||
.info-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 12rpx;
|
||
}
|
||
|
||
.info-item {
|
||
background: #f8fafc;
|
||
border-radius: 12rpx;
|
||
padding: 14rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4rpx;
|
||
}
|
||
|
||
.label {
|
||
font-size: 22rpx;
|
||
color: #64748b;
|
||
}
|
||
|
||
.value {
|
||
font-size: 30rpx;
|
||
font-weight: 600;
|
||
color: #0f172a;
|
||
}
|
||
|
||
.sub {
|
||
font-size: 22rpx;
|
||
color: #94a3b8;
|
||
}
|
||
|
||
.text-ellipsis {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.mode-wrap {
|
||
margin-bottom: 12rpx;
|
||
display: flex;
|
||
gap: 12rpx;
|
||
}
|
||
|
||
.mode-btn {
|
||
flex: 1;
|
||
height: 68rpx;
|
||
line-height: 68rpx;
|
||
border-radius: 10rpx;
|
||
border: 1rpx solid #bfe9e7;
|
||
background: #f2fbfa;
|
||
color: #0f7f7a;
|
||
font-size: 24rpx;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.mode-btn.active {
|
||
background: #1f9f9a;
|
||
border-color: #1f9f9a;
|
||
color: #ffffff;
|
||
}
|
||
|
||
.field-row {
|
||
margin-top: 12rpx;
|
||
}
|
||
|
||
.field-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 8rpx;
|
||
}
|
||
|
||
.field-label {
|
||
font-size: 24rpx;
|
||
color: #0f7f7a;
|
||
margin-bottom: 6rpx;
|
||
display: block;
|
||
}
|
||
|
||
.quick-actions {
|
||
display: flex;
|
||
gap: 10rpx;
|
||
}
|
||
|
||
.mini-btn {
|
||
min-width: 54rpx;
|
||
height: 44rpx;
|
||
line-height: 44rpx;
|
||
border-radius: 10rpx;
|
||
background: #f2fbfa;
|
||
color: #0f7f7a;
|
||
font-size: 24rpx;
|
||
padding: 0 12rpx;
|
||
border: 1rpx solid #bfe9e7;
|
||
}
|
||
|
||
.voltage-pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-width: 120rpx;
|
||
height: 56rpx;
|
||
padding: 0 16rpx;
|
||
border-radius: 10rpx;
|
||
background: #f2fbfa;
|
||
border: 1rpx solid #bfe9e7;
|
||
font-size: 24rpx;
|
||
color: #0f7f7a;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.color-row {
|
||
display: flex;
|
||
gap: 12rpx;
|
||
margin-bottom: 8rpx;
|
||
}
|
||
|
||
.color-item {
|
||
flex: 1;
|
||
background: #f8fafc;
|
||
border-radius: 12rpx;
|
||
padding: 14rpx;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.color-dot {
|
||
width: 44rpx;
|
||
height: 44rpx;
|
||
border-radius: 10rpx;
|
||
border: 1rpx solid #e2e8f0;
|
||
}
|
||
|
||
.footer-actions {
|
||
position: fixed;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
padding: 16rpx 20rpx 20rpx;
|
||
background: #f8fafc;
|
||
display: flex;
|
||
gap: 12rpx;
|
||
border-top: 1rpx solid #edf2f7;
|
||
}
|
||
|
||
.action-btn {
|
||
flex: 1;
|
||
height: 76rpx;
|
||
line-height: 76rpx;
|
||
border-radius: 12rpx;
|
||
font-size: 26rpx;
|
||
border: none;
|
||
}
|
||
|
||
.action-btn.ghost {
|
||
background: #e2e8f0;
|
||
color: #334155;
|
||
}
|
||
|
||
.action-btn.danger {
|
||
background: #fee2e2;
|
||
color: #b91c1c;
|
||
}
|
||
|
||
.action-btn.primary {
|
||
background: #1f9f9a;
|
||
color: #ffffff;
|
||
}
|
||
|
||
.action-btn.danger {
|
||
background: var(--danger-soft);
|
||
color: var(--danger);
|
||
}
|
||
|
||
.btn-hover {
|
||
transform: scale(0.98);
|
||
opacity: 0.9;
|
||
}
|
||
|
||
.action-btn,
|
||
.mini-btn,
|
||
.save-name-btn,
|
||
.color-item {
|
||
transition: all 0.15s ease;
|
||
}
|
||
|
||
.action-btn:active,
|
||
.mini-btn:active,
|
||
.save-name-btn:active,
|
||
.color-item:active {
|
||
transform: scale(0.98);
|
||
opacity: 0.9;
|
||
}
|
||
|
||
/* 统一 uni 组件视觉 */
|
||
:deep(.uni-data-checklist) {
|
||
width: 100%;
|
||
}
|
||
|
||
:deep(.uni-data-checklist .checklist-group) {
|
||
display: flex;
|
||
gap: 12rpx;
|
||
}
|
||
|
||
:deep(.uni-data-checklist .checklist-box) {
|
||
flex: 1;
|
||
padding: 14rpx 0;
|
||
border-radius: 10rpx;
|
||
border: 1rpx solid #bfe9e7;
|
||
background: #f2fbfa;
|
||
text-align: center;
|
||
}
|
||
|
||
:deep(.uni-data-checklist .checklist-box.is--default.is-checked),
|
||
:deep(.uni-data-checklist .checklist-box.is-checked),
|
||
:deep(.uni-data-checklist .checklist-box.is--tag.is-checked) {
|
||
background: #e6f5f4 !important;
|
||
border-color: #bfe9e7 !important;
|
||
}
|
||
|
||
:deep(.uni-data-checklist .checklist-content) {
|
||
justify-content: center;
|
||
}
|
||
|
||
:deep(.uni-data-checklist .checklist-text) {
|
||
font-size: 24rpx;
|
||
color: #0f7f7a;
|
||
}
|
||
|
||
:deep(.uni-data-checklist .checklist-box.is--default.is-checked .checklist-text),
|
||
:deep(.uni-data-checklist .checklist-box.is-checked .checklist-text),
|
||
:deep(.uni-data-checklist .checklist-box.is--tag.is-checked .checklist-text) {
|
||
color: #0f7f7a !important;
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* 覆盖 uni-data-checkbox 在 tag 模式下内部 uni-tag 的默认蓝色 */
|
||
:deep(.uni-data-checklist .uni-tag),
|
||
:deep(.uni-data-checklist .uni-tag--default) {
|
||
background-color: #f2fbfa !important;
|
||
border-color: #bfe9e7 !important;
|
||
color: #0f7f7a !important;
|
||
}
|
||
|
||
:deep(.uni-data-checklist .uni-tag--primary),
|
||
:deep(.uni-data-checklist .uni-tag--primary.uni-tag--inverted),
|
||
:deep(.uni-data-checklist .is--checked .uni-tag) {
|
||
background-color: #e6f5f4 !important;
|
||
border-color: #7fd3cf !important;
|
||
color: #0f7f7a !important;
|
||
}
|
||
|
||
:deep(.uni-data-checklist .uni-tag-text) {
|
||
color: inherit !important;
|
||
}
|
||
|
||
:deep(.uni-select) {
|
||
border-radius: 10rpx !important;
|
||
border: 1rpx solid #e2e8f0 !important;
|
||
background: #f8fafc !important;
|
||
min-height: 72rpx;
|
||
}
|
||
|
||
:deep(.uni-select__selector) {
|
||
border-radius: 10rpx !important;
|
||
border-color: #e2e8f0 !important;
|
||
}
|
||
|
||
:deep(.uni-select__input-box) {
|
||
padding: 0 12rpx;
|
||
}
|
||
|
||
:deep(.uni-select__input-text) {
|
||
font-size: 24rpx !important;
|
||
color: #0f172a !important;
|
||
}
|
||
|
||
:deep(.uni-icons) {
|
||
color: #64748b !important;
|
||
}
|
||
|
||
:deep(.uni-slider-handle-wrapper) {
|
||
height: 6rpx !important;
|
||
}
|
||
|
||
:deep(.uni-slider-thumb) {
|
||
width: 28rpx !important;
|
||
height: 28rpx !important;
|
||
border: 2rpx solid #ffffff !important;
|
||
box-shadow: 0 0 0 1rpx #bfdbfe !important;
|
||
}
|
||
</style> |