Эх сурвалжийг харах

设备管理、设备连接页面

liyuliangjiazai 3 сар өмнө
parent
commit
6a561e6f8f

+ 256 - 0
code/ajyApp/components/l-sliderRange/l-sliderRange.nvue

@@ -0,0 +1,256 @@
+<template>
+	<view class="lView" ref="lViewRef">
+		<view class="angleNumber" :style="angleNumStyle">
+			<text class="angleTxt">{{angleNumber}}°</text>
+		</view>
+		<view class="outbox">
+			<view class="plusicon" @click="clickSub">
+				<text class="plusTxt">-</text>
+			</view>
+			<view class="container" ref="containerRef" @touchstart="onTouchStart" @touchmove="onTouchMove"
+				@touchend="onTouchEnd">
+				<view class="trackBg"></view>
+				<view class="block" :style="blockStyle" @touchstart.stop="onBlockTouchStart"
+					@touchmove.stop="onBlockTouchMove" @touchend.stop="onTouchEnd">
+					<image class="barimg" src="/static/device/barimg.png" mode="aspectFill"></image>
+				</view>
+			</view>
+			<view class="plusicon" @click="clickPlus">
+				<text class="plusTxt">+</text>
+			</view>
+		</view>
+	</view>
+</template>
+
+<script>
+	const dom = weex.requireModule('dom')
+
+	export default {
+		props: {
+			// 当前角度值
+			anglenum: {
+				type: Number,
+				default: 90
+			},
+			// 最小值
+			min: {
+				type: Number,
+				default: 90
+			},
+			// 最大值
+			max: {
+				type: Number,
+				default: 150
+			},
+			// 步长
+			step: {
+				type: Number,
+				default: 15
+			}
+		},
+		data() {
+			return {
+				angleNumber: 90,
+				containerWidth: 0,
+				containerLeft: 0,
+				lViewLeft: 0, // lView左侧偏移
+				containerOffsetX: 0, // container相对于lView的左侧偏移
+				blockWidth: 62, // 滑块宽度 px (约124rpx)
+				currentX: 0, // 当前滑块left值
+				isDragging: false
+			}
+		},
+		computed: {
+			blockStyle() {
+				return 'left:' + this.currentX + 'px;'
+			},
+			angleNumStyle() {
+				// 数字容器与滑块同宽,加上 container 相对 lView 的偏移量
+				const left = this.currentX + this.containerOffsetX
+				return 'left:' + left + 'px;width:' + this.blockWidth + 'px;'
+			}
+		},
+		watch: {
+			anglenum: {
+				immediate: true,
+				handler(newVal) {
+					this.angleNumber = newVal
+					this.$nextTick(() => {
+						this.updatePositionFromValue(newVal)
+					})
+				}
+			}
+		},
+		mounted() {
+			setTimeout(() => {
+				this.getContainerRect()
+			}, 300)
+		},
+		methods: {
+			getContainerRect() {
+				// 先获取 lView 的位置
+				dom.getComponentRect(this.$refs.lViewRef, (lRes) => {
+					if (lRes && lRes.result) {
+						this.lViewLeft = lRes.size.left
+					}
+					// 再获取 container 的位置
+					dom.getComponentRect(this.$refs.containerRef, (res) => {
+						if (res && res.result) {
+							const rect = res.size
+							this.containerWidth = rect.width
+							this.containerLeft = rect.left
+							// container 相对于 lView 的偏移
+							this.containerOffsetX = rect.left - this.lViewLeft
+							// 初始化位置
+							this.updatePositionFromValue(this.angleNumber)
+						}
+					})
+				})
+			},
+			// 根据值计算位置
+			updatePositionFromValue(val) {
+				if (this.containerWidth <= 0) return
+				const trackWidth = this.containerWidth - this.blockWidth
+				const ratio = (val - this.min) / (this.max - this.min)
+				this.currentX = ratio * trackWidth
+			},
+			// 根据位置计算值(不吸附,实时显示)
+			getRawValueFromPosition(x) {
+				const trackWidth = this.containerWidth - this.blockWidth
+				if (trackWidth <= 0) return this.min
+				let ratio = x / trackWidth
+				ratio = Math.max(0, Math.min(1, ratio))
+				const rawValue = this.min + ratio * (this.max - this.min)
+				// 四舍五入到步长
+				const steps = Math.round((rawValue - this.min) / this.step)
+				return Math.min(this.max, Math.max(this.min, this.min + steps * this.step))
+			},
+			// 容器触摸开始(点击轨道跳转)
+			onTouchStart(e) {
+				this.isDragging = true
+				const touch = e.touches[0]
+				const x = touch.screenX - this.containerLeft - this.blockWidth / 2
+				this.smoothMove(x)
+			},
+			onTouchMove(e) {
+				const touch = e.touches[0]
+				const x = touch.screenX - this.containerLeft - this.blockWidth / 2
+				this.smoothMove(x)
+			},
+			// 滑块触摸
+			onBlockTouchStart(e) {
+				this.isDragging = true
+			},
+			onBlockTouchMove(e) {
+				if (!this.isDragging) return
+				const touch = e.touches[0]
+				const x = touch.screenX - this.containerLeft - this.blockWidth / 2
+				this.smoothMove(x)
+			},
+			onTouchEnd(e) {
+				this.isDragging = false
+				// 松手时吸附到步进位置
+				const val = this.getRawValueFromPosition(this.currentX)
+				this.angleNumber = val
+				this.updatePositionFromValue(val)
+				this.$emit('change', this.angleNumber)
+			},
+			// 拖拽时丝滑跟手,不吸附
+			smoothMove(x) {
+				const trackWidth = this.containerWidth - this.blockWidth
+				x = Math.max(0, Math.min(trackWidth, x))
+				this.currentX = x
+				// 实时计算角度显示(吸附到最近步进值)
+				this.angleNumber = this.getRawValueFromPosition(x)
+			},
+			clickSub() {
+				if (this.angleNumber > this.min) {
+					this.angleNumber -= this.step
+					this.updatePositionFromValue(this.angleNumber)
+					this.$emit('change', this.angleNumber)
+				}
+			},
+			clickPlus() {
+				if (this.angleNumber < this.max) {
+					this.angleNumber += this.step
+					this.updatePositionFromValue(this.angleNumber)
+					this.$emit('change', this.angleNumber)
+				}
+			}
+		}
+	}
+</script>
+
+<style scoped>
+	.lView {
+		height: 96rpx;
+		position: relative;
+		padding-top: 40rpx;
+	}
+
+	.angleNumber {
+		position: absolute;
+		top: 0;
+		align-items: center;
+		justify-content: center;
+	}
+
+	.angleTxt {
+		font-size: 36rpx;
+		color: #FFFFFF;
+		font-weight: bold;
+		text-align: center;
+	}
+
+	.outbox {
+		height: 56rpx;
+		background-color: rgba(255, 255, 255, 0.7);
+		border-radius: 40rpx;
+		flex-direction: row;
+		justify-content: space-between;
+		align-items: center;
+		padding-left: 10rpx;
+		padding-right: 10rpx;
+	}
+
+	.plusicon {
+		width: 50rpx;
+		height: 50rpx;
+		justify-content: center;
+		align-items: center;
+	}
+
+	.plusTxt {
+		font-size: 44rpx;
+		color: #389588;
+		font-weight: bold;
+	}
+
+	.container {
+		flex: 1;
+		height: 56rpx;
+		position: relative;
+		justify-content: center;
+	}
+
+	.trackBg {
+		height: 6rpx;
+		background-color: rgba(56, 149, 136, 0.3);
+		border-radius: 3rpx;
+	}
+
+	.block {
+		position: absolute;
+		width: 124rpx;
+		height: 44rpx;
+		background-color: #389588;
+		border-radius: 40rpx;
+		justify-content: center;
+		align-items: center;
+	}
+
+	.barimg {
+		width: 30rpx;
+		height: 36rpx;
+	}
+</style>

+ 31 - 0
code/ajyApp/pages.json

@@ -6,6 +6,16 @@
 		}
 	},
 	"pages": [
+		// {
+		// 	"path": "pages/ble-demo/ble-demo",
+		// 	"style": {
+		// 		"navigationStyle": "custom",
+		// 		"navigationBarTitleText": "",
+		// 		"enablePullDownRefresh": true
+		// 	}
+		// },
+		
+		
 			{
 			"path": "pages/device/list/list",
 			"style": {
@@ -68,6 +78,27 @@
 				"navigationBarTitleText": ""
 			}
 		},
+		{
+			"path": "pages/device/search/search",
+			"style": {
+				"navigationStyle": "custom",
+				"navigationBarTitleText": ""
+			}
+		},
+		{
+			"path": "pages/device/detail/detail",
+			"style": {
+				"navigationStyle": "custom",
+				"navigationBarTitleText": ""
+			}
+		},
+		{
+			"path": "pages/device/deviceInfo/deviceInfo",
+			"style": {
+				"navigationStyle": "custom",
+				"navigationBarTitleText": ""
+			}
+		},
 		{
 			"path": "pages/test-toast/test-toast",
 			"style": {

+ 336 - 0
code/ajyApp/pages/device/detail/detail.nvue

@@ -0,0 +1,336 @@
+<template>
+	<view class="container">
+		<view class="status-bar" :style="{height: statusBarHeight + 'px'}"></view>
+		<view class="customHead">
+			<view class="back-wrap" @click="goBack">
+				<image src="/static/public/backBlack.png" mode="aspectFill" class="backimg"></image>
+			</view>
+			<text class="head-title">设备详情</text>
+			<view class="plus"></view>
+		</view>
+		<scroll-view class="list" scroll-y="true">
+			<!-- 连接状态 -->
+			<view class="connect-status">
+				<view class="status-dot" :style="{backgroundColor: connected ? '#389588' : '#CCCCCC'}"></view>
+				<text class="status-text">{{connected ? '已连接' : '未连接'}}</text>
+				<text class="connect-btn" v-if="!connected && !connecting" @click="connectDevice">连接设备</text>
+				<text class="connect-btn" v-if="connecting">连接中...</text>
+			</view>
+
+			<text class="txt">通用设置</text>
+			<view class="menuitem">
+				<text>设备名称</text>
+				<text class="gray">{{name}}</text>
+			</view>
+			<view class="menuitem">
+				<text>设备ID</text>
+				<text class="gray device-id-text">{{deviceId}}</text>
+			</view>
+			<view class="menuitem">
+				<text>信号强度</text>
+				<text class="gray">{{rssiText}}</text>
+			</view>
+			<view class="menuitem">
+				<text>固件版本</text>
+				<text class="gray">{{firmwareVersion || '未获取'}}</text>
+			</view>
+			<view class="menuitem lastItem">
+				<text>连接状态</text>
+				<text class="gray">{{connected ? '已连接' : '未连接'}}</text>
+			</view>
+
+			<text class="txt">设备操作</text>
+			<view class="menuitem" @click="disconnectDevice" v-if="connected">
+				<text>断开连接</text>
+				<image class="arrowright" src="/static/my/arrowright.png" mode="aspectFill"></image>
+			</view>
+			<view class="menuitem lastItem">
+				<text>帮助与反馈</text>
+				<image class="arrowright" src="/static/my/arrowright.png" mode="aspectFill"></image>
+			</view>
+
+			<view class="delbtn" @click="delDevice">
+				<text class="delbtn-text">删除设备</text>
+			</view>
+		</scroll-view>
+		<ay-toast ref="ayToast" />
+	</view>
+</template>
+
+<script>
+import bleManager, { BLE_STATE } from '@/utils/ble'
+import ayToast from '@/components/ay-toast/ay-toast.nvue'
+
+export default {
+	components: {
+		ayToast
+	},
+	data() {
+		return {
+			statusBarHeight: 44,
+			name: '',
+			deviceId: '',
+			rssi: '',
+			connected: false,
+			connecting: false,
+			firmwareVersion: '',
+			_unbinders: []
+		}
+	},
+	computed: {
+		rssiText() {
+			if (!this.rssi) return '未知'
+			const val = Number(this.rssi)
+			if (val >= -50) return '极强 (' + this.rssi + 'dBm)'
+			if (val >= -65) return '强 (' + this.rssi + 'dBm)'
+			if (val >= -80) return '中 (' + this.rssi + 'dBm)'
+			return '弱 (' + this.rssi + 'dBm)'
+		}
+	},
+	onLoad(options) {
+		const sysInfo = uni.getSystemInfoSync()
+		this.statusBarHeight = sysInfo.statusBarHeight || 44
+		this.name = options.name || ''
+		this.deviceId = options.deviceId || ''
+		this.rssi = options.rssi || ''
+	},
+	onShow() {
+		// 监听BLE状态
+		this._unbinders = [
+			bleManager.on('state', (s) => {
+				this.connected = (s === BLE_STATE.READY_COMM)
+				if (s === BLE_STATE.DISCONNECTED) {
+					this.connecting = false
+				}
+			}),
+			bleManager.on('disconnected', () => {
+				this.connected = false
+				this.connecting = false
+			}),
+			bleManager.on('report:GROUP_2', (data) => {
+				if (data && data.firmwareVersion) {
+					this.firmwareVersion = 'V' + data.firmwareVersion
+				}
+			})
+		]
+		// 检查当前连接状态
+		this.connected = bleManager.isConnected
+	},
+	onHide() {
+		this._cleanListeners()
+	},
+	onUnload() {
+		this._cleanListeners()
+	},
+	methods: {
+		goBack() {
+			uni.navigateBack()
+		},
+		_cleanListeners() {
+			if (this._unbinders && this._unbinders.length) {
+				this._unbinders.forEach(fn => fn && fn())
+				this._unbinders = []
+			}
+		},
+		async connectDevice() {
+			if (this.connecting || this.connected) return
+			this.connecting = true
+			try {
+				await bleManager.init()
+				await bleManager.connect(this.deviceId)
+				this.connected = true
+				this.$refs.ayToast.success('连接成功')
+			} catch (e) {
+				this.$refs.ayToast.error('连接失败: ' + (e.message || ''))
+			} finally {
+				this.connecting = false
+			}
+		},
+		async disconnectDevice() {
+			try {
+				await bleManager.disconnect()
+				this.connected = false
+				this.$refs.ayToast.success('已断开连接')
+			} catch (e) {
+				this.$refs.ayToast.error('断开失败')
+			}
+		},
+		delDevice() {
+			uni.showModal({
+				title: '提示',
+				content: '此操作将删除该设备,是否继续?',
+				cancelText: '取消',
+				confirmText: '继续',
+				success: (res) => {
+					if (res.confirm) {
+						// 断开连接
+						if (this.connected) {
+							bleManager.disconnect().catch(() => {})
+						}
+						// 从本地存储中移除
+						try {
+							let deviceList = uni.getStorageSync('deviceList') || []
+							deviceList = deviceList.filter(item => item.deviceId !== this.deviceId)
+							uni.setStorageSync('deviceList', deviceList)
+						} catch (e) {}
+
+						this.$refs.ayToast.success('设备删除成功')
+						setTimeout(() => {
+							uni.navigateBack()
+						}, 1500)
+					}
+				}
+			})
+		}
+	}
+}
+</script>
+
+<style>
+.container {
+	flex: 1;
+	background-color: #FFFFFF;
+}
+
+.status-bar {
+	background-color: #FFFFFF;
+}
+
+.customHead {
+	width: 750rpx;
+	background-color: #FFFFFF;
+	flex-direction: row;
+	justify-content: space-between;
+	height: 88rpx;
+	align-items: center;
+	padding-left: 54rpx;
+	padding-right: 54rpx;
+}
+
+.back-wrap {
+	width: 50rpx;
+	height: 50rpx;
+	justify-content: center;
+	align-items: center;
+}
+
+.backimg {
+	width: 50rpx;
+	height: 50rpx;
+}
+
+.head-title {
+	font-weight: bold;
+	font-size: 32rpx;
+	color: #545F71;
+}
+
+.plus {
+	width: 50rpx;
+	height: 50rpx;
+}
+
+.list {
+	flex: 1;
+}
+
+.connect-status {
+	flex-direction: row;
+	align-items: center;
+	margin-left: 38rpx;
+	margin-right: 38rpx;
+	margin-top: 30rpx;
+	margin-bottom: 10rpx;
+	padding-top: 24rpx;
+	padding-bottom: 24rpx;
+	padding-left: 30rpx;
+	padding-right: 30rpx;
+	background-color: #F8F9FB;
+	border-radius: 12rpx;
+}
+
+.status-dot {
+	width: 16rpx;
+	height: 16rpx;
+	border-radius: 16rpx;
+	background-color: #CCCCCC;
+	margin-right: 12rpx;
+}
+
+.status-text {
+	font-size: 28rpx;
+	color: #545F71;
+	flex: 1;
+}
+
+.connect-btn {
+	font-size: 26rpx;
+	color: #389588;
+	padding-top: 8rpx;
+	padding-bottom: 8rpx;
+	padding-left: 24rpx;
+	padding-right: 24rpx;
+	border-width: 1px;
+	border-style: solid;
+	border-color: #389588;
+	border-radius: 24rpx;
+}
+
+.txt {
+	margin-top: 50rpx;
+	margin-left: 38rpx;
+	margin-right: 38rpx;
+	font-size: 28rpx;
+	color: #9BA5B7;
+}
+
+.lastItem {
+	border-bottom-width: 0;
+}
+
+.menuitem {
+	margin-left: 38rpx;
+	margin-right: 38rpx;
+	flex-direction: row;
+	align-items: center;
+	justify-content: space-between;
+	height: 104rpx;
+	font-size: 32rpx;
+	color: #545F71;
+	border-bottom-width: 1px;
+	border-bottom-style: solid;
+	border-bottom-color: #EEF1F4;
+}
+
+.gray {
+	font-size: 28rpx;
+	color: #9BA5B7;
+	padding-right: 20rpx;
+}
+
+.device-id-text {
+	font-size: 22rpx;
+}
+
+.arrowright {
+	width: 40rpx;
+	height: 40rpx;
+}
+
+.delbtn {
+	width: 654rpx;
+	height: 96rpx;
+	background-color: #F3F3F3;
+	border-radius: 12rpx;
+	margin-top: 60rpx;
+	margin-left: 48rpx;
+	margin-right: 48rpx;
+	justify-content: center;
+	align-items: center;
+}
+
+.delbtn-text {
+	font-size: 32rpx;
+	color: #F65252;
+}
+</style>

+ 915 - 0
code/ajyApp/pages/device/deviceInfo/deviceInfo.nvue

@@ -0,0 +1,915 @@
+<template>
+	<view class="infoBox">
+		<!-- <image class="pageBg" src="/static/device/devicebg.png" mode="aspectFill"></image> -->
+		<view class="pageContent">
+			<view class="status-bar" :style="{height: statusBarHeight + 'px'}"></view>
+			<view class="customHead">
+				<view class="back-wrap" @click="goBack">
+					<image src="/static/public/back.png" mode="aspectFill" class="backimg"></image>
+				</view>
+				<text class="customTitle">{{picMode===1?'我的艾灸椅':'穴位编辑'}}</text>
+				<navigator :url="'/pages/device/detail/detail?name='+deviceName+'&deviceId='+deviceId+'&rssi='+rssi"
+					class="plus">
+					<image v-if="picMode===1" src="/static/public/view-list.png" mode="aspectFill" class="menuimg"></image>
+				</navigator>
+			</view>
+			<scroll-view class="boxs" scroll-y="true">
+				<view class="proBox" v-if="picMode == 1">
+					<image class="proimg" src="/static/device/aijiuyi2.png" mode="aspectFill"></image>
+					<view class="pointbox" v-if="deviceStatus == 3">
+						<view class="pointContainer">
+							<text class="pointLabel" v-if="acupointList.length > 0 && curCaseIndex < acupointList.length">{{acupointList[curCaseIndex].name}}</text>
+						</view>
+					</view>
+					<text class="prostatus">蓝牙{{linked?'已':'未'}}连接</text>
+				</view>
+
+				<!-- 座椅角度 -->
+				<view class="angleSection" v-if="picMode == 1">
+					<text class="angleLabel">座椅角度(拖动滑块调整)</text>
+					<l-sliderRange ref="slideRange" :anglenum="chairAngle" :min="90" :max="150" :step="15" @change="onSliderAngleChange"></l-sliderRange>
+				</view>
+
+				<!-- 模式选择 -->
+				<view class="controlMode" v-if="picMode == 1">
+					<text class="modetxt">选择模式</text>
+					<view class="modechange" @click="isShowDrawer2 = true">
+						<text class="modeName">{{modelist[modeType]}}</text>
+						<view class="changeBtn">
+							<text class="changetxt">切换</text>
+							<image class="changeinmg" src="/static/device/change.png" mode="aspectFill"></image>
+						</view>
+					</view>
+				</view>
+
+				<!-- 其他功能 -->
+				<view class="otherSetting" v-if="modeType != 0 && picMode == 1">
+					<text class="otherTxt">其他功能</text>
+					<view class="func">
+						<view class="funcitem">
+							<image class="funcimg" src="/static/device/gongneng1.png" mode="aspectFill"></image>
+							<text class="functxt">艾灸余量</text>
+						</view>
+						<view class="funcitem">
+							<image class="funcimg" src="/static/device/gongneng2.png" mode="aspectFill"></image>
+							<text class="functxt">滤芯状态</text>
+						</view>
+						<view class="funcitem">
+							<image class="funcimg" src="/static/device/gongneng2.png" mode="aspectFill"></image>
+							<text class="functxt">回收仓占用</text>
+						</view>
+					</view>
+				</view>
+
+				<!-- 自定义模式切换按钮 -->
+				<view class="modeChange" v-if="modeType == 2">
+					<view class="modeBtn" @click="picModeChange(1)" :class="picMode===1?'curBtn':''">
+						<text class="modeBtnTxt" :style="{color:picMode===1?'#545F71':'#fff'}">设备</text>
+					</view>
+					<view class="modeBtn" @click="picModeChange(2)" :class="picMode===2?'curBtn':''">
+						<text class="modeBtnTxt" :style="{color:picMode===2?'#545F71':'#fff'}">穴位</text>
+					</view>
+				</view>
+
+				<!-- 穴位图 -->
+				<view class="acupointPic" v-if="modeType == 2 && picMode == 2">
+					<image class="acupointBg" src="/static/device/persionbg.png" mode="aspectFill"></image>
+					<view class="acupoint" :class="index == acupointIndex ? 'curAcupoint':''" @click="showTimeDraw(index)"
+						v-for="(item,index) in acupointList" :key="index"
+						:style="{left:item.x+'rpx',top:item.y+'rpx'}">
+					</view>
+					<view class="tipsbox" v-if="acupointIndex != -1"
+						:style="{left:50+acupointList[acupointIndex].x+'rpx',top:-50+acupointList[acupointIndex].y+'rpx'}">
+						<text class="tipsTxt">{{acupointList[acupointIndex].name}}</text>
+					</view>
+				</view>
+
+				<!-- 白噪音 -->
+				<view class="musicCard" v-if="modeType == 0 && picMode == 1">
+					<text class="musicname">白噪音</text>
+					<view class="musliclist">
+						<text class="musictxt" :class="{'current':curAudioInx==inx}" v-for="(itm,inx) in audiolist"
+							:key="inx" @click="changeAudio(itm.url,inx)">{{itm.name}}</text>
+					</view>
+				</view>
+	
+
+				<!-- 开始/暂停/停止 -->
+				<view class="startbox" v-if="modeType != 0">
+					<view class="stop" v-if="deviceStatus==0">
+						<image class="startbtn" @click="startDeviceEvt()" src="/static/device/start.png" mode="aspectFill"></image>
+						<text class="starttxt">开始</text>
+					</view>
+					<view class="hot" v-if="deviceStatus==1">
+						<view class="hotone">
+							<text class="hoting">{{ispreHot == false?'正在预热':'点火中'}}</text>
+							<text class="hotnum">{{hotPercentage}}</text>
+						</view>
+						<view class="stopbox" @click="stopPreHot">
+							<image class="stopimg" src="/static/device/stop.png" mode="aspectFill"></image>
+							<text class="stoptext">结束</text>
+						</view>
+					</view>
+					<view class="hot" v-if="deviceStatus==3 || deviceStatus==5">
+						<view class="hotone" v-if="deviceStatus==3">
+							<text class="hoting">正在艾灸</text>
+							<text class="hotnum">{{subTime}}</text>
+						</view>
+						<view class="hotone" v-if="deviceStatus==5">
+							<text class="hoting">已暂停</text>
+							<text class="hotnum">{{subTime}}</text>
+						</view>
+						<view class="pausebox" @click="stopAijiu">
+							<image class="pauseimg" :src="deviceStatus==5?'/static/device/start.png':'/static/device/pause.png'" mode="aspectFill"></image>
+							<text class="pausetxt">{{deviceStatus==5?'开始':'暂停'}}</text>
+						</view>
+						<view class="stopbox" @click="stopPreHot">
+							<image class="stopimg" src="/static/device/stop.png" mode="aspectFill"></image>
+							<text class="stoptext">结束</text>
+						</view>
+					</view>
+					<view class="hot" v-if="deviceStatus==4">
+						<view class="hotone">
+							<text class="hoting unfire">正在灭火中,请勿操作</text>
+						</view>
+					</view>
+				</view>
+
+				<view class="empty" v-if="modeType == 0"></view>
+			</scroll-view>
+		</view>
+
+		<!-- 音乐播放器 - 固定底部 -->
+		<view class="musicPlayerFixed" v-if="modeType == 0">
+			<view class="musicPlayer">
+				<view class="leftbox">
+					<image class="playerimg" :src="audiolist[curAudioInx].posterimg" mode="aspectFill"></image>
+					<view class="musicnamebox">
+						<text class="mtime">{{formatTime(currentTime)}}</text>
+					</view>
+				</view>
+				<view class="rightbox">
+					<view class="playBtn" @click="playAudio">
+						<image class="playericon"
+							:src="ispause?'/static/device/playerbtn.png':'/static/device/pauseicon.png'"
+							mode="aspectFit"></image>
+					</view>
+					<image @click="isShowDrawer3 = true" class="menuicon" src="/static/device/menubtn.png"
+						mode="aspectFill"></image>
+				</view>
+			</view>
+		</view>
+
+		<!-- 异常提示弹框 -->
+		<customPopup :isShow="excepDrawer" @closePop="excepDrawer=false">
+			<text class="tip1 toptip" v-if="exceTxt == 1">检查到有异物</text>
+			<text class="tip1" v-if="exceTxt == 1">已暂停艾灸,清理后重试</text>
+			<text class="tip1 toptip" v-if="exceTxt == 2">艾条识别错误</text>
+			<text class="tip1" v-if="exceTxt == 2">已暂停艾灸,重新插入艾条后重试</text>
+			<text class="tip1 toptip" v-if="exceTxt == 3">上艾失败</text>
+			<text class="tip1 toptip" v-if="exceTxt == 4">点火失败</text>
+			<view class="confirmBtnWrap">
+				<text class="confirmBtn" @click="excepDrawer = false; exceTxt = 0;">我知道了</text>
+			</view>
+		</customPopup>
+
+		<!-- 确认结束弹框 -->
+		<customPopup :isShow="isShowConfirm" @closePop="isShowConfirm=false">
+			<text class="confirmTxt toptip">确认结束艾灸?</text>
+			<view class="confirmBtns">
+				<view class="cbtn" @click="isShowConfirm=false">
+					<text class="cbtnTxt">取消</text>
+				</view>
+				<view class="cbtn redbtn" @click="confirmStop">
+					<text class="cbtnTxt">结束</text>
+				</view>
+			</view>
+		</customPopup>
+
+		<!-- 模式选择弹框 -->
+		<customPopup :isShow="isShowDrawer2" @closePop="isShowDrawer2=false">
+			<view class="modeList">
+				<view class="modeItem" @click="changeMode(0)">
+					<text class="modeItemTxt">无艾灸模式</text>
+					<view class="checkboxCustom">
+						<view v-if="modeType == 0" class="checkboxCircle"></view>
+					</view>
+				</view>
+				<view class="modeItem" @click="changeMode(1)">
+					<text class="modeItemTxt">专业模式</text>
+					<view class="checkboxCustom">
+						<view v-if="modeType == 1" class="checkboxCircle"></view>
+					</view>
+				</view>
+				<view class="modeItem" @click="changeMode(2)">
+					<text class="modeItemTxt">自定义模式</text>
+					<view class="checkboxCustom">
+						<view v-if="modeType == 2" class="checkboxCircle"></view>
+					</view>
+				</view>
+				<view class="modeItem" @click="changeMode(3)">
+					<text class="modeItemTxt">专家模式</text>
+					<view class="checkboxCustom">
+						<view v-if="modeType == 3" class="checkboxCircle"></view>
+					</view>
+				</view>
+			</view>
+		</customPopup>
+
+		<!-- 音乐弹窗 -->
+		<customPopup :isShow="isShowDrawer3" @closePop="isShowDrawer3=false">
+			<view class="audiolists">
+				<text class="playname">当前播放</text>
+				<view class="audiolist" :class="curAudioInx==inx?'curAudio':''" v-for="(itm,inx) in audiolist"
+					:key="inx" @click="changeAudio(itm.url,inx)">
+					<text class="audioTxt">{{itm.name}}</text>
+					<view class="signal-container" v-if="curAudioInx==inx">
+						<view class="signal-bar bar1"></view>
+						<view class="signal-bar bar2"></view>
+						<view class="signal-bar bar3"></view>
+					</view>
+				</view>
+			</view>
+		</customPopup>
+
+		<!-- 时间配置器 -->
+		<customPopup :isShow="isShowTimeDrawer" @closePop="isShowTimeDrawer=false">
+			<text class="aijiuTime">选择灸疗时间</text>
+			<view class="timePickerRow">
+				<picker mode="multiSelector" :range="timeRange" @change="bindTimeChange">
+					<text class="timePickerTxt">{{selectedHour}}小时 {{selectedMinute}}分钟</text>
+				</picker>
+			</view>
+			<view class="timeBtns">
+				<view class="tbtn" @click="cancelAijiuTime(0)">
+					<text class="tbtnTxt">取消</text>
+				</view>
+				<view class="tbtn confirmBtn2" @click="cancelAijiuTime(1)">
+					<text class="tbtnTxt confirmBtn2Txt">确认</text>
+				</view>
+			</view>
+		</customPopup>
+
+		<!-- 重连弹框 -->
+		<view class="maskbg" v-if="reconnectDrawer">
+			<view class="maskbox">
+				<text class="maskTxt">蓝牙连接断开,第{{reconnectCount}}次重连中...</text>
+			</view>
+		</view>
+
+		<ay-toast ref="ayToast" />
+	</view>
+</template>
+
+<script>
+import customPopup from '@/components/customPopup/customPopup.nvue'
+import ayToast from '@/components/ay-toast/ay-toast.nvue'
+import lSliderRange from '@/components/l-sliderRange/l-sliderRange.nvue'
+import bleMixin from './mixins/ble-mixin.js'
+import audioMixin from './mixins/audio-mixin.js'
+import acupointMixin from './mixins/acupoint-mixin.js'
+import bleManager, { CHAIR_ANGLE } from '@/utils/ble'
+
+export default {
+	components: {
+		customPopup,
+		ayToast,
+		lSliderRange
+	},
+	mixins: [bleMixin, audioMixin, acupointMixin],
+	data() {
+		return {
+			statusBarHeight: 44,
+			deviceId: '',
+			deviceName: '',
+			rssi: '',
+			subTime: '00:00:00',
+			isShowDrawer2: false,
+			modeType: 0, // 0无艾灸 1专业 2自定义 3专家
+			modelist: ['无艾灸模式', '专业模式', '自定义模式', '专家模式'],
+			chairAngle: 90
+		}
+	},
+	onLoad(options) {
+		const sysInfo = uni.getSystemInfoSync()
+		this.statusBarHeight = sysInfo.statusBarHeight || 44
+
+		this.deviceId = options.deviceId || ''
+		this.deviceName = options.name || ''
+		this.rssi = options.rssi || ''
+
+		// TODO: 后续接口确定后实现 - 下载穴位列表
+		// TODO: 后续接口确定后实现 - 下载专业模式推荐方案
+
+		this.initAudio()
+		this.initBle()
+	},
+	onUnload() {
+		this.destroyAudio()
+		this.disconnectBle()
+	},
+	methods: {
+		goBack() {
+			uni.navigateBack()
+		},
+		// 座椅角度控制 - 来自 l-sliderRange 组件
+		onSliderAngleChange(val) {
+			this.chairAngle = val
+			this._sendAngleCommand()
+		},
+		_sendAngleCommand() {
+			const angleMap = {
+				90: CHAIR_ANGLE.LEVEL_1,
+				105: CHAIR_ANGLE.LEVEL_2,
+				120: CHAIR_ANGLE.LEVEL_3,
+				135: CHAIR_ANGLE.LEVEL_4,
+				150: CHAIR_ANGLE.LEVEL_5
+			}
+			const level = angleMap[this.chairAngle] || CHAIR_ANGLE.LEVEL_1
+			if (this.linked) {
+				bleManager.setChairAngle(level).catch(e => console.error('setAngle fail', e))
+			}
+		}
+	}
+}
+</script>
+
+<style>
+.infoBox {
+	flex: 1;
+	position: relative;
+	background-image: linear-gradient(to bottom, rgba(56,149,136,1), rgba(56,149,136,0.5));
+}
+.pageBg {
+	position: absolute;
+	top: 0;
+	left: 0;
+	width: 750rpx;
+	height: 1400rpx;
+}
+.pageContent {
+	flex: 1;
+}
+.status-bar {
+	background-color: rgba(0,0,0,0);
+}
+.customHead {
+	flex-direction: row;
+	justify-content: space-between;
+	align-items: center;
+	height: 88rpx;
+	padding-left: 30rpx;
+	padding-right: 30rpx;
+	
+}
+.back-wrap {
+	width: 50rpx;
+	height: 50rpx;
+	justify-content: center;
+	align-items: center;
+}
+.backimg {
+	width: 50rpx;
+	height: 50rpx;
+}
+.customTitle {
+	font-weight: bold;
+	font-size: 32rpx;
+	color: #FFFFFF;
+}
+.plus {
+	width: 50rpx;
+	height: 50rpx;
+	justify-content: center;
+	align-items: center;
+}
+.menuimg {
+	width: 40rpx;
+	height: 40rpx;
+}
+.boxs {
+	flex: 1;
+}
+.proBox {
+	align-items: center;
+	padding-top: 30rpx;
+	padding-bottom: 20rpx;
+}
+.proimg {
+	width: 600rpx;
+	height: 500rpx;
+}
+.pointbox {
+	margin-top: 20rpx;
+	align-items: center;
+}
+.pointContainer {
+	background-color: #389588;
+	border-radius: 20rpx;
+	padding-left: 24rpx;
+	padding-right: 24rpx;
+	padding-top: 8rpx;
+	padding-bottom: 8rpx;
+}
+.pointLabel {
+	color: #FFFFFF;
+	font-size: 24rpx;
+}
+.prostatus {
+	margin-top: 20rpx;
+	font-size: 26rpx;
+	color: #FFFFFF;
+}
+/* 座椅角度 */
+.angleSection {
+	padding-left: 38rpx;
+	padding-right: 38rpx;
+	padding-top: 20rpx;
+	padding-bottom: 30rpx;
+}
+.angleLabel {
+	font-size: 26rpx;
+	color: #FFFFFF;
+	margin-bottom: 10rpx;
+}
+/* 模式选择 */
+.controlMode {
+	margin-left: 38rpx;
+	margin-right: 38rpx;
+	margin-top: 20rpx;
+	background-color: rgba(255,255,255,0.9);
+	border-radius: 16rpx;
+	padding: 30rpx;
+}
+.modetxt {
+	font-size: 28rpx;
+	color: #9BA5B7;
+	margin-bottom: 20rpx;
+}
+.modechange {
+	flex-direction: row;
+	justify-content: space-between;
+	align-items: center;
+	background-color: #389588;
+	border-radius: 40rpx;
+	padding-left: 30rpx;
+	padding-right: 20rpx;
+	padding-top: 20rpx;
+	padding-bottom: 20rpx;
+}
+.modeName {
+	font-size: 30rpx;
+	font-weight: bold;
+	color: #ffffff;
+}
+.changeBtn {
+	flex-direction: row;
+	align-items: center;
+}
+.changetxt {
+	font-size: 26rpx;
+	color: #389588;
+	margin-right: 8rpx;
+}
+.changeinmg {
+	width: 20px;
+	height: 20px;
+}
+/* 其他功能 */
+.otherSetting {
+	margin-left: 38rpx;
+	margin-right: 38rpx;
+	margin-top: 20rpx;
+}
+.otherTxt {
+	font-size: 28rpx;
+	color: #FFFFFF;
+	margin-bottom: 20rpx;
+}
+.func {
+	flex-direction: row;
+	flex-wrap: wrap;
+}
+.funcitem {
+	width: 200rpx;
+	align-items: center;
+	margin-bottom: 20rpx;
+}
+.funcimg {
+	width: 80rpx;
+	height: 80rpx;
+}
+.functxt {
+	font-size: 24rpx;
+	color: #FFFFFF;
+	margin-top: 10rpx;
+}
+/* 自定义模式切换 */
+.modeChange {
+	flex-direction: row;
+	justify-content: center;
+	margin-top: 30rpx;
+	margin-bottom: 20rpx;
+}
+.modeBtn {
+	width: 160rpx;
+	height: 60rpx;
+	background-color: #545F71;
+	justify-content: center;
+	align-items: center;
+	border-radius: 30rpx;
+	margin-left: 10rpx;
+	margin-right: 10rpx;
+}
+.curBtn {
+	background-color: #FFFFFF;
+}
+.modeBtnTxt {
+	font-size: 26rpx;
+}
+/* 穴位图 */
+.acupointPic {
+	position: relative;
+	margin-left: 38rpx;
+	margin-right: 38rpx;
+	height: 700rpx;
+	align-items: center;
+}
+.acupointBg {
+	width: 300rpx;
+	height: 650rpx;
+}
+.acupoint {
+	position: absolute;
+	width: 20rpx;
+	height: 20rpx;
+	background-color: #389588;
+	border-radius: 20rpx;
+}
+.curAcupoint {
+	background-color: #F65252;
+	width: 28rpx;
+	height: 28rpx;
+	border-radius: 28rpx;
+}
+.tipsbox {
+	position: absolute;
+	background-color: #333;
+	border-radius: 8rpx;
+	padding-left: 12rpx;
+	padding-right: 12rpx;
+	padding-top: 6rpx;
+	padding-bottom: 6rpx;
+}
+.tipsTxt {
+	color: #FFFFFF;
+	font-size: 22rpx;
+}
+/* 白噪音 */
+.musicCard {
+	margin-left: 38rpx;
+	margin-right: 38rpx;
+	margin-top: 20rpx;
+	background-color: rgba(255,255,255,0.9);
+	border-radius: 16rpx;
+	padding: 30rpx;
+	height:100px;
+}
+.musicname {
+	font-size: 28rpx;
+	color: #545F71;
+	font-weight: bold;
+	margin-bottom: 20rpx;
+}
+.musliclist {
+	flex-direction: row;
+	flex-wrap: wrap;
+}
+.musictxt {
+	font-size: 26rpx;
+	color: #9BA5B7;
+	padding-left: 24rpx;
+	padding-right: 24rpx;
+	padding-top: 12rpx;
+	padding-bottom: 12rpx;
+	margin-right: 20rpx;
+	border-radius: 24rpx;
+	background-color: #F5F7FA;
+}
+.current {
+	background-color: #389588;
+	color: #FFFFFF;
+}
+.musicPlayerFixed {
+	position: fixed;
+	left: 0;
+	right: 0;
+	bottom: 0;
+}
+.musicPlayer {
+	flex-direction: row;
+	justify-content: space-between;
+	align-items: center;
+	background-color: rgba(255,255,255,0.5);
+	padding-left: 20rpx;
+	padding-right: 20rpx;
+	padding-top: 50px;
+	padding-bottom: 40px;
+	border-radius:16px 16px 0 0;
+}
+.leftbox {
+	flex-direction: row;
+	align-items: center;
+}
+.playerimg {
+	width: 100rpx;
+	height: 100rpx;
+	border-radius: 12rpx;
+}
+.musicnamebox {
+	margin-left: 16rpx;
+}
+.mtime {
+	font-size: 28rpx;
+	color: #545F71;
+}
+.rightbox {
+	flex-direction: row;
+	align-items: center;
+}
+.playBtn {
+	width: 60rpx;
+	height: 60rpx;
+	justify-content: center;
+	align-items: center;
+}
+.playericon {
+	width: 28rpx;
+	height: 28rpx;
+}
+.menuicon {
+	width: 80rpx;
+	height: 80rpx;
+	margin-left: 20rpx;
+}
+/* 开始/暂停/停止 */
+.startbox {
+	align-items: center;
+	margin-top: 40rpx;
+	padding-bottom: 40rpx;
+}
+.stop {
+	align-items: center;
+}
+.startbtn {
+	width: 120rpx;
+	height: 120rpx;
+}
+.starttxt {
+	font-size: 28rpx;
+	color: #FFFFFF;
+	margin-top: 10rpx;
+}
+.hot {
+	flex-direction: row;
+	align-items: center;
+	justify-content: center;
+}
+.hotone {
+	align-items: center;
+	margin-right: 40rpx;
+}
+.hoting {
+	font-size: 28rpx;
+	color: #FFFFFF;
+}
+.unfire {
+	color: #F65252;
+}
+.hotnum {
+	font-size: 36rpx;
+	color: #E0FFF8;
+	font-weight: bold;
+	margin-top: 8rpx;
+}
+.pausebox {
+	align-items: center;
+	margin-right: 40rpx;
+}
+.pauseimg {
+	width: 80rpx;
+	height: 80rpx;
+}
+.pausetxt {
+	font-size: 24rpx;
+	color: #FFFFFF;
+	margin-top: 6rpx;
+}
+.stopbox {
+	align-items: center;
+}
+.stopimg {
+	width: 80rpx;
+	height: 80rpx;
+}
+.stoptext {
+	font-size: 24rpx;
+	color: #F65252;
+	margin-top: 6rpx;
+}
+.empty {
+	height: 180rpx;
+}
+/* 弹框样式 */
+.tip1 {
+	font-size: 28rpx;
+	color: #545F71;
+	text-align: center;
+	margin-bottom: 10rpx;
+}
+.toptip {
+	margin-top: 20rpx;
+	font-weight: bold;
+}
+.confirmBtnWrap {
+	margin-top: 30rpx;
+	align-items: center;
+}
+.confirmBtn {
+	font-size: 28rpx;
+	color: #FFFFFF;
+	background-color: #389588;
+	border-radius: 40rpx;
+	padding-left: 60rpx;
+	padding-right: 60rpx;
+	padding-top: 16rpx;
+	padding-bottom: 16rpx;
+}
+.confirmTxt {
+	font-size: 32rpx;
+	color: #545F71;
+	font-weight: bold;
+	text-align: center;
+}
+.confirmBtns {
+	flex-direction: row;
+	justify-content: center;
+	margin-top: 40rpx;
+}
+.cbtn {
+	width: 240rpx;
+	height: 80rpx;
+	border-radius: 40rpx;
+	justify-content: center;
+	align-items: center;
+	margin-left: 20rpx;
+	margin-right: 20rpx;
+	background-color: #F3F3F3;
+}
+.redbtn {
+	background-color: #F65252;
+}
+.cbtnTxt {
+	font-size: 28rpx;
+	color: #545F71;
+}
+/* 模式选择弹框 */
+.modeList {
+	margin-top: 10rpx;
+}
+.modeItem {
+	flex-direction: row;
+	justify-content: space-between;
+	align-items: center;
+	height: 100rpx;
+	border-bottom-width: 1px;
+	border-bottom-style: solid;
+	border-bottom-color: #EEF1F4;
+}
+.modeItemTxt {
+	font-size: 30rpx;
+	color: #545F71;
+}
+.checkboxCustom {
+	width: 36rpx;
+	height: 36rpx;
+	border-radius: 36rpx;
+	border-width: 2px;
+	border-style: solid;
+	border-color: #389588;
+	justify-content: center;
+	align-items: center;
+}
+.checkboxCircle {
+	width: 20rpx;
+	height: 20rpx;
+	border-radius: 20rpx;
+	background-color: #389588;
+}
+/* 音乐弹窗 */
+.audiolists {
+	margin-top: 10rpx;
+}
+.playname {
+	font-size: 28rpx;
+	color: #9BA5B7;
+	margin-bottom: 20rpx;
+}
+.audiolist {
+	flex-direction: row;
+	justify-content: space-between;
+	align-items: center;
+	height: 90rpx;
+	padding-left: 10rpx;
+	padding-right: 10rpx;
+	border-radius: 12rpx;
+}
+.curAudio {
+	background-color: #F0F8F7;
+}
+.audioTxt {
+	font-size: 30rpx;
+	color: #545F71;
+}
+.signal-container {
+	flex-direction: row;
+	align-items: flex-end;
+}
+.signal-bar {
+	width: 6rpx;
+	margin-left: 4rpx;
+	border-radius: 4rpx;
+	background-color: #389588;
+}
+.bar1 {
+	height: 16rpx;
+}
+.bar2 {
+	height: 24rpx;
+}
+.bar3 {
+	height: 32rpx;
+}
+/* 时间选择器 */
+.aijiuTime {
+	font-size: 32rpx;
+	color: #545F71;
+	font-weight: bold;
+	text-align: center;
+	margin-bottom: 30rpx;
+}
+.timePickerRow {
+	align-items: center;
+	padding-top: 20rpx;
+	padding-bottom: 20rpx;
+}
+.timePickerTxt {
+	font-size: 32rpx;
+	color: #545F71;
+}
+.timeBtns {
+	flex-direction: row;
+	justify-content: center;
+	margin-top: 30rpx;
+}
+.tbtn {
+	width: 240rpx;
+	height: 80rpx;
+	border-radius: 40rpx;
+	justify-content: center;
+	align-items: center;
+	margin-left: 20rpx;
+	margin-right: 20rpx;
+	background-color: #F3F3F3;
+}
+.confirmBtn2 {
+	background-color: #389588;
+}
+.tbtnTxt {
+	font-size: 28rpx;
+	color: #545F71;
+}
+.confirmBtn2Txt {
+	color: #FFFFFF;
+}
+/* 重连遮罩 */
+.maskbg {
+	position: fixed;
+	left: 0;
+	right: 0;
+	top: 0;
+	bottom: 0;
+	background-color: rgba(0,0,0,0.6);
+	justify-content: center;
+	align-items: center;
+}
+.maskbox {
+	background-color: #FFFFFF;
+	border-radius: 16rpx;
+	padding: 40rpx;
+}
+.maskTxt {
+	font-size: 28rpx;
+	color: #545F71;
+}
+</style>

+ 50 - 0
code/ajyApp/pages/device/deviceInfo/mixins/acupoint-mixin.js

@@ -0,0 +1,50 @@
+/**
+ * 穴位编辑与自定义模式 mixin
+ * 负责:穴位图交互、时间选择、方案管理
+ */
+export default {
+	data() {
+		return {
+			picMode: 1, // 1设备 2穴位
+			acupointIndex: -1,
+			acupointList: [],
+			curCase: [],
+			curCaseIndex: 0,
+			isShowTimeDrawer: false,
+			timeRange: [[0, 1, 2, 3], [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55]],
+			selectedHour: 0,
+			selectedMinute: 0
+		}
+	},
+	methods: {
+		picModeChange(value) {
+			this.picMode = value
+		},
+
+		showTimeDraw(inx) {
+			this.acupointIndex = inx
+			this.isShowTimeDrawer = true
+		},
+
+		bindTimeChange(e) {
+			const val = e.detail.value
+			this.selectedHour = this.timeRange[0][val[0]]
+			this.selectedMinute = this.timeRange[1][val[1]]
+		},
+
+		cancelAijiuTime(val) {
+			this.isShowTimeDrawer = false
+			if (val == 1 && this.acupointIndex >= 0) {
+				const sumMinutes = this.selectedHour * 60 + this.selectedMinute
+				const point = this.acupointList[this.acupointIndex]
+				this.curCase = [{
+					id: point.id,
+					time: sumMinutes,
+					_x: point._x || 0,
+					_y: point._y || 0
+				}]
+				this.curCaseIndex = this.acupointIndex
+			}
+		}
+	}
+}

+ 84 - 0
code/ajyApp/pages/device/deviceInfo/mixins/audio-mixin.js

@@ -0,0 +1,84 @@
+/**
+ * 音频播放 mixin
+ * 负责:白噪音初始化、播放/暂停、切换音源
+ */
+let innerAudioContext = null
+
+export default {
+	data() {
+		return {
+			audiolist: [
+				{ name: '河流', posterimg: '/static/device/heliu.png', url: '/static/audio/audio1.mp3' },
+				{ name: '下雨', posterimg: '/static/device/heliu.png', url: '/static/audio/audio2.mp3' }
+			],
+			curAudioInx: 0,
+			totalTime: 0,
+			currentTime: 0,
+			ispause: true,
+			isShowDrawer3: false
+		}
+	},
+	methods: {
+		initAudio() {
+			innerAudioContext = uni.createInnerAudioContext()
+			innerAudioContext.src = '/static/audio/audio1.mp3'
+			innerAudioContext.loop = true
+			innerAudioContext.onPlay(() => {
+				this.totalTime = innerAudioContext.duration
+			})
+			innerAudioContext.onCanplay(() => {
+				this.totalTime = innerAudioContext.duration
+			})
+			innerAudioContext.onError((res) => {
+				console.log('播放错误', res.errMsg)
+			})
+			innerAudioContext.onTimeUpdate(() => {
+				this.currentTime = innerAudioContext.currentTime
+			})
+		},
+
+		formatTime(totalSeconds) {
+			totalSeconds = Math.floor(totalSeconds)
+			const minutes = Math.floor(totalSeconds / 60)
+			const seconds = totalSeconds % 60
+			return minutes.toString().padStart(2, '0') + ':' + seconds.toString().padStart(2, '0')
+		},
+
+		changeAudio(url, inx) {
+			this.curAudioInx = inx
+			if (innerAudioContext) {
+				innerAudioContext.stop()
+				setTimeout(() => {
+					innerAudioContext.src = url
+					this.ispause = true
+					this.playAudio()
+				}, 500)
+			}
+		},
+
+		playAudio() {
+			if (!innerAudioContext) return
+			if (this.ispause) {
+				innerAudioContext.play()
+			} else {
+				innerAudioContext.pause()
+			}
+			this.ispause = !this.ispause
+		},
+
+		/** 供 ble-mixin 模式切换时调用 */
+		_stopAudioOnModeChange() {
+			if (innerAudioContext) {
+				innerAudioContext.stop()
+			}
+		},
+
+		destroyAudio() {
+			if (innerAudioContext) {
+				innerAudioContext.stop()
+				innerAudioContext.destroy()
+				innerAudioContext = null
+			}
+		}
+	}
+}

+ 280 - 0
code/ajyApp/pages/device/deviceInfo/mixins/ble-mixin.js

@@ -0,0 +1,280 @@
+/**
+ * BLE 蓝牙连接与设备控制 mixin
+ * 负责:权限检查、连接/断连/重连、指令收发、设备状态管理
+ */
+import bleManager, { BLE_STATE, MODE, MOXI_STATE, POWER, MUTE, TEMPERATURE, CHAIR_ANGLE } from '@/utils/ble'
+import { ensureBlePrerequisite } from '@/utils/ble/permission.js'
+
+export default {
+	data() {
+		return {
+			linked: false,
+			deviceStatus: 0, // 0停止 1预热 2点火 3艾灸 4灭火 5暂停
+			hotPercentage: '0%',
+			ispreHot: false,
+			reconnectDrawer: false,
+			reconnectCount: 0,
+			excepDrawer: false,
+			exceTxt: 0,
+			isShowConfirm: false,
+			_unbinders: []
+		}
+	},
+	methods: {
+		// ========== BLE 初始化 ==========
+		async initBle() {
+			// 1. Android权限检查(iOS由系统自动弹窗,无需手动处理)
+			try {
+				await ensureBlePrerequisite()
+			} catch (e) {
+				if (e.message === 'BLE_PERMISSION_DENIED') {
+					this.$refs.ayToast.error('请授予蓝牙权限后重试')
+					return
+				}
+				if (e.message === 'BLE_LOCATION_OFF') {
+					this.$refs.ayToast.error('请开启位置服务后重试')
+					return
+				}
+			}
+
+			// 2. 重置BLE适配器,清除search页面可能残留的扫描状态
+			try { await bleManager.destroy() } catch (_) {}
+
+			// 3. 重新初始化适配器
+			await bleManager.init()
+
+			// 4. 监听BLE事件(必须在destroy之后注册,因为destroy会清除所有监听器)
+			this._unbinders = [
+				bleManager.on('state', (s) => {
+					this.linked = (s === BLE_STATE.READY_COMM)
+					if (s === BLE_STATE.DISCONNECTED) {
+						this.linked = false
+						this._handleBleDisconnect()
+					}
+				}),
+				bleManager.on('disconnected', (info) => {
+					this.linked = false
+					if (!info.manual) {
+						this._handleBleDisconnect()
+					}
+				}),
+				bleManager.on('reconnected', () => {
+					this.reconnectDrawer = false
+					this.reconnectCount = 0
+					this.linked = true
+					this.$refs.ayToast.success('重连成功')
+				}),
+				bleManager.on('report:GROUP_1', (data) => {
+					this._handleGroup1(data)
+				}),
+				bleManager.on('report:GROUP_2', (data) => {
+					this._handleGroup2(data)
+				})
+			]
+
+			// 5. 扫描并连接设备(与ble-demo一致的可靠方式:先扫描发现设备,再连接)
+			try {
+				const scanOpt = { timeout: 8000 }
+				if (this.deviceName) {
+					scanOpt.deviceName = this.deviceName
+				} else {
+					scanOpt.namePrefix = 'BT-'
+				}
+				await bleManager.scanAndConnect(scanOpt)
+				this.linked = true
+				this._sendCurrentState()
+			} catch (e) {
+				console.error('BLE连接失败', e)
+				this.$refs.ayToast.error('连接失败: ' + (e.message || e.code || ''))
+			}
+		},
+
+		_cleanListeners() {
+			if (this._unbinders && this._unbinders.length) {
+				this._unbinders.forEach(fn => fn && fn())
+				this._unbinders = []
+			}
+		},
+
+		_handleBleDisconnect() {
+			this.reconnectCount++
+			if (this.reconnectCount >= 4) {
+				this.reconnectDrawer = false
+				this.$refs.ayToast.error('连接失败,请返回重试')
+				setTimeout(() => {
+					uni.navigateBack()
+				}, 1500)
+			} else {
+				this.reconnectDrawer = true
+			}
+		},
+
+		// ========== 设备上报数据解析 ==========
+		_handleGroup1(data) {
+			// 参数组1: 设备状态
+			switch (data.runtimeState) {
+				case 0x00:
+					this.deviceStatus = 0
+					break
+				case 0x01:
+				case 0x02:
+					this.deviceStatus = 1
+					break
+				case 0x03:
+					this.deviceStatus = 3
+					break
+				case 0x04:
+					this.deviceStatus = 4
+					break
+				case 0x05:
+					this.deviceStatus = 5
+					break
+			}
+			// 模式
+			switch (data.mode) {
+				case MODE.LEISURE:
+					this.modeType = 0; break
+				case MODE.PROFESSIONAL:
+					this.modeType = 1; break
+				case MODE.PERSONAL:
+					this.modeType = 2; break
+				case MODE.EXPERT:
+					this.modeType = 3; break
+			}
+			// 剩余时间
+			const m = data.remainMinute || 0
+			const s = data.remainSecond || 0
+			const h = Math.floor(m / 60)
+			const m1 = m % 60
+			this.subTime = `${h.toString().padStart(2,'0')}:${m1.toString().padStart(2,'0')}:${s.toString().padStart(2,'0')}`
+
+			// 异常检测
+			if (data.foreignDetect === 0x02) {
+				this.excepDrawer = true
+				this.exceTxt = 1
+			}
+		},
+
+		_handleGroup2(data) {
+			// 参数组2: 预热/点火进度
+			if (data.preheatPercent < 100) {
+				this.ispreHot = false
+				this.hotPercentage = data.preheatPercent + '%'
+			} else {
+				this.ispreHot = true
+				this.hotPercentage = data.ignitePercent + '%'
+			}
+		},
+
+		// ========== 发送指令 ==========
+		_sendCurrentState() {
+			const modeMap = [MODE.LEISURE, MODE.PROFESSIONAL, MODE.PERSONAL, MODE.EXPERT]
+			bleManager.sendBasic({
+				power: POWER.ON,
+				moxiState: MOXI_STATE.DONE,
+				preheatState: MOXI_STATE.DONE,
+				mute: MUTE.OFF,
+				mode: modeMap[this.modeType] || MODE.LEISURE,
+				subMode: 0x01,
+				temperature: TEMPERATURE.MID,
+				angle: CHAIR_ANGLE.OFF,
+				duration: 0
+			}).catch(e => console.error('sendBasic fail', e))
+		},
+
+		// ========== 开始艾灸 ==========
+		startDeviceEvt() {
+			this.deviceStatus = 1
+			this.ispreHot = false
+			let totalDuration = 0
+			this.curCase.forEach(item => {
+				totalDuration += item.time
+			})
+			// 发送开始指令
+			const modeMap = [MODE.LEISURE, MODE.PROFESSIONAL, MODE.PERSONAL, MODE.EXPERT]
+			bleManager.sendBasic({
+				power: POWER.ON,
+				moxiState: MOXI_STATE.START,
+				preheatState: MOXI_STATE.START,
+				mute: MUTE.OFF,
+				mode: modeMap[this.modeType] || MODE.LEISURE,
+				subMode: 0x01,
+				temperature: TEMPERATURE.MID,
+				angle: CHAIR_ANGLE.OFF,
+				duration: totalDuration || 30
+			}).catch(e => console.error('startDevice fail', e))
+
+			// 发送穴位坐标
+			if (this.curCase.length > 0) {
+				const points = this.curCase.map(item => ({
+					point: item.id,
+					x: item._x,
+					y: item._y
+				}))
+				bleManager.sendAcupoints(points).catch(e => console.error('sendAcupoints fail', e))
+			}
+		},
+
+		// ========== 暂停/继续 ==========
+		stopAijiu() {
+			if (this.deviceStatus == 5) {
+				// 继续
+				this.deviceStatus = 3
+				bleManager.sendBasic({ moxiState: MOXI_STATE.START }).catch(() => {})
+			} else if (this.deviceStatus == 3) {
+				// 暂停
+				this.deviceStatus = 5
+				bleManager.sendBasic({ moxiState: MOXI_STATE.PAUSE }).catch(() => {})
+			}
+		},
+
+		// ========== 停止 ==========
+		stopPreHot() {
+			this.isShowConfirm = true
+		},
+		confirmStop() {
+			this.deviceStatus = 0
+			this.isShowConfirm = false
+			bleManager.sendBasic({ moxiState: MOXI_STATE.DONE }).catch(() => {})
+		},
+
+		// ========== 模式切换 ==========
+		changeMode(num) {
+			uni.showModal({
+				title: '提示',
+				content: '切换模式会停止当前的艾灸,是否继续',
+				cancelText: '取消',
+				confirmText: '继续',
+				success: (res) => {
+					if (res.confirm) {
+						this.modeType = num
+						this.deviceStatus = 0
+						this.isShowDrawer2 = false
+						if (this._stopAudioOnModeChange) {
+							this._stopAudioOnModeChange()
+						}
+						// 发送模式切换指令
+						const modeMap = [MODE.LEISURE, MODE.PROFESSIONAL, MODE.PERSONAL, MODE.EXPERT]
+						bleManager.sendBasic({
+							power: POWER.ON,
+							moxiState: MOXI_STATE.DONE,
+							preheatState: MOXI_STATE.DONE,
+							mute: MUTE.OFF,
+							mode: modeMap[num] || MODE.LEISURE,
+							subMode: 0x01,
+							temperature: TEMPERATURE.MID,
+							angle: CHAIR_ANGLE.OFF,
+							duration: 0
+						}).catch(e => console.error('changeMode fail', e))
+					}
+				}
+			})
+		},
+
+		// ========== 断开BLE ==========
+		disconnectBle() {
+			this._cleanListeners()
+			bleManager.disconnect().catch(() => {})
+		}
+	}
+}

+ 12 - 10
code/ajyApp/pages/device/list/list.nvue

@@ -67,12 +67,14 @@ export default {
 			}
 		},
 		goAddDevice() {
-			// TODO: 跳转添加设备页面
-			this.$toastRef.text('添加设备')
+			uni.navigateTo({
+				url: '/pages/device/search/search'
+			})
 		},
 		goDeviceInfo(item) {
-			// TODO: 跳转设备详情
-			this.$toastRef.text('设备: ' + item.name)
+			uni.navigateTo({
+				url: '/pages/device/deviceInfo/deviceInfo?name=' + encodeURIComponent(item.name || '') + '&deviceId=' + encodeURIComponent(item.deviceId || '') + '&rssi=' + encodeURIComponent(item.RSSI || '')
+			})
 		}
 	}
 }
@@ -88,7 +90,7 @@ export default {
 	left: 0;
 	top: 0;
 	width: 750rpx;
-	height: 1400rpx;
+	height: 2000px;
 }
 .status-bar {
 	background-color: rgba(0,0,0,0);
@@ -110,15 +112,15 @@ export default {
 	color: #545F71;
 }
 .head-add {
-	width: 36rpx;
-	height: 36rpx;
+	width: 20px;
+	height: 20px;
 	border-width: 2px;
 	border-style: solid;
 	border-color: #545F71;
-	border-radius: 36rpx;
+	border-radius: 20px;
 	text-align: center;
-	line-height: 32rpx;
-	font-size: 26rpx;
+	line-height: 16px;
+	font-size: 20px;
 	font-weight: bold;
 	color: #545F71;
 }

+ 334 - 0
code/ajyApp/pages/device/search/search.nvue

@@ -0,0 +1,334 @@
+<template>
+	<view class="page">
+		<image class="content-bg" src="/static/device/devicebg.png" mode="scaleToFill"></image>
+		<view class="status-bar" :style="{height: statusBarHeight + 'px'}"></view>
+		<!-- 顶部导航 -->
+		<view class="custom-head">
+			<view class="head-back" @click="goBack">
+				<image class="back-icon" src="/static/public/back-arrow.png" mode="aspectFit"></image>
+			</view>
+			<text class="head-title">添加设备</text>
+			<view class="head-placeholder"></view>
+		</view>
+
+		<!-- 扫描状态提示 -->
+		<view class="search-tip">
+			<text class="search-tip-text">{{searching ? '正在搜索附近设备...' : '搜索完成'}}</text>
+			<text class="search-btn" @click="onRefresh">{{searching ? '停止' : '重新搜索'}}</text>
+		</view>
+
+		<!-- 设备列表 -->
+		<scroll-view class="device-scroll" scroll-y="true">
+			<view class="device-item" v-for="(item, index) in deviceList" :key="index" @click="onSelectDevice(item)">
+				<view class="device-info">
+					<text class="device-name">{{item.name || '未知设备'}}</text>
+					<text class="device-id">{{item.deviceId}}</text>
+				</view>
+				<view class="device-right">
+					<!-- 信号强度图标 -->
+					<view class="signal-wrap">
+						<view class="signal-bar signal-bar1" :class="{'signal-active': getSignalLevel(item.RSSI) >= 1}"></view>
+						<view class="signal-bar signal-bar2" :class="{'signal-active': getSignalLevel(item.RSSI) >= 2}"></view>
+						<view class="signal-bar signal-bar3" :class="{'signal-active': getSignalLevel(item.RSSI) >= 3}"></view>
+						<view class="signal-bar signal-bar4" :class="{'signal-active': getSignalLevel(item.RSSI) >= 4}"></view>
+					</view>
+					<text class="connect-btn">添加</text>
+				</view>
+			</view>
+
+			<view class="empty-tip" v-if="!searching && deviceList.length === 0">
+				<text class="empty-text">未发现设备,请确认设备已开启</text>
+			</view>
+		</scroll-view>
+
+		<ay-toast ref="ayToast" />
+	</view>
+</template>
+
+<script>
+import bleManager, {
+	BLE_STATE, ensureBlePrerequisite, openLocationSettings
+} from '@/utils/ble'
+import ayToast from '@/components/ay-toast/ay-toast.nvue'
+
+export default {
+	components: {
+		ayToast
+	},
+	data() {
+		return {
+			statusBarHeight: 44,
+			searching: false,
+			deviceList: [],
+			_unbinders: []
+		}
+	},
+	onLoad() {
+		const sysInfo = uni.getSystemInfoSync()
+		this.statusBarHeight = sysInfo.statusBarHeight || 44
+
+		// 配置并监听事件
+		bleManager.configure({ deviceNamePrefix: 'BT-', debug: true })
+
+		this._unbinders = [
+			bleManager.on('state', s => {
+				this.searching = (s === BLE_STATE.SCANNING)
+			}),
+			bleManager.on('deviceFound', d => {
+				// 避免重复
+				const exists = this.deviceList.find(item => item.deviceId === d.deviceId)
+				if (!exists) {
+					this.deviceList.push({
+						deviceId: d.deviceId,
+						name: d.name || d.localName || '',
+						RSSI: d.RSSI || ''
+					})
+				}
+			})
+		]
+
+		this.startScan()
+	},
+	onUnload() {
+		// 离开页面停止扫描
+		this.stopScan()
+		if (this._unbinders && this._unbinders.length) {
+			this._unbinders.forEach(fn => fn && fn())
+		}
+	},
+	methods: {
+		goBack() {
+			uni.navigateBack()
+		},
+		async startScan() {
+			this.deviceList = []
+			try {
+				await ensureBlePrerequisite()
+				await bleManager.init()
+				// 使用returnAll模式,超时后resolve所有设备
+				bleManager.scan({ namePrefix: 'BT-', timeout: 15000, returnAll: true }).then(devices => {
+					// 扫描结束
+					this.searching = false
+				}).catch(e => {
+					this.searching = false
+					console.error('扫描异常', e)
+				})
+			} catch (e) {
+				this.searching = false
+				if (e.message === 'BLE_LOCATION_OFF') {
+					uni.showModal({
+						title: '提示',
+						content: '请先开启位置服务以搜索蓝牙设备',
+						success: r => {
+							if (r.confirm) openLocationSettings()
+						}
+					})
+				} else if (e.message === 'BLE_ADAPTER_OFF') {
+					this.$refs.ayToast.error('请先开启蓝牙')
+				} else if (e.message === 'BLE_PERMISSION_DENIED') {
+					this.$refs.ayToast.error('请授权蓝牙权限')
+				} else {
+					this.$refs.ayToast.error('扫描失败: ' + (e.message || ''))
+				}
+			}
+		},
+		stopScan() {
+			try {
+				uni.stopBluetoothDevicesDiscovery({
+					success() {},
+					fail() {}
+				})
+			} catch (e) {}
+		},
+		onRefresh() {
+			if (this.searching) {
+				this.stopScan()
+				this.searching = false
+			} else {
+				this.startScan()
+			}
+		},
+		getSignalLevel(rssi) {
+			// RSSI转信号格数: 4格(强) / 3格 / 2格 / 1格(弱)
+			if (!rssi) return 0
+			const val = Number(rssi)
+			if (val >= -50) return 4   // 非常强
+			if (val >= -65) return 3   // 强
+			if (val >= -80) return 2   // 中
+			if (val >= -95) return 1   // 弱
+			return 1
+		},
+		onSelectDevice(device) {
+			// 将设备添加到本地存储的设备列表
+			let deviceList = []
+			try {
+				const data = uni.getStorageSync('deviceList')
+				if (data && data.length > 0) {
+					deviceList = data
+				}
+			} catch (e) {}
+
+			// 检查是否已添加
+			const exists = deviceList.find(item => item.deviceId === device.deviceId)
+			if (exists) {
+				this.$refs.ayToast.show('该设备已添加')
+				return
+			}
+
+			// 添加设备
+			deviceList.push({
+				deviceId: device.deviceId,
+				name: device.name || '艾灸椅',
+				RSSI: device.RSSI
+			})
+			uni.setStorageSync('deviceList', deviceList)
+			this.$refs.ayToast.success('设备添加成功')
+
+			// 延迟返回
+			setTimeout(() => {
+				uni.navigateBack()
+			}, 1000)
+		}
+	}
+}
+</script>
+
+<style>
+.page {
+	flex: 1;
+	position: relative;
+}
+.content-bg {
+	position: absolute;
+	left: 0;
+	top: 0;
+	width: 750rpx;
+	height: 2000px;
+}
+.status-bar {
+	background-color: rgba(0,0,0,0);
+}
+.custom-head {
+	flex-direction: row;
+	justify-content: space-between;
+	align-items: center;
+	height: 88rpx;
+	padding-left: 30rpx;
+	padding-right: 54rpx;
+}
+.head-back {
+	width: 60rpx;
+	height: 60rpx;
+	justify-content: center;
+	align-items: center;
+}
+.back-icon {
+	width: 60rpx;
+	height: 60rpx;
+}
+.head-title {
+	font-weight: bold;
+	font-size: 32rpx;
+	color: #545F71;
+}
+.head-placeholder {
+	width: 60rpx;
+	height: 60rpx;
+}
+.search-tip {
+	flex-direction: row;
+	justify-content: space-between;
+	align-items: center;
+	padding-left: 38rpx;
+	padding-right: 38rpx;
+	padding-top: 20rpx;
+	padding-bottom: 20rpx;
+}
+.search-tip-text {
+	font-size: 26rpx;
+	color: #999;
+}
+.search-btn {
+	font-size: 26rpx;
+	color: #389588;
+	padding: 10rpx 24rpx;
+	border-width: 1px;
+	border-style: solid;
+	border-color: #389588;
+	border-radius: 24rpx;
+}
+.device-scroll {
+	flex: 1;
+	padding-left: 38rpx;
+	padding-right: 38rpx;
+}
+.device-item {
+	flex-direction: row;
+	justify-content: space-between;
+	align-items: center;
+	background-color: #FFFFFF;
+	border-radius: 16rpx;
+	padding: 30rpx;
+	margin-bottom: 20rpx;
+}
+.device-info {
+	flex: 1;
+}
+.device-name {
+	font-size: 30rpx;
+	color: #333;
+	font-weight: bold;
+}
+.device-id {
+	font-size: 22rpx;
+	color: #999;
+	margin-top: 8rpx;
+}
+.device-right {
+	align-items: center;
+}
+.signal-wrap {
+	flex-direction: row;
+	align-items: flex-end;
+	margin-bottom: 12rpx;
+}
+.signal-bar {
+	width: 8rpx;
+	margin-left: 4rpx;
+	border-radius: 4rpx;
+	background-color: #ddd;
+}
+.signal-bar1 {
+	height: 14rpx;
+}
+.signal-bar2 {
+	height: 22rpx;
+}
+.signal-bar3 {
+	height: 30rpx;
+}
+.signal-bar4 {
+	height: 38rpx;
+}
+.signal-active {
+	background-color: #389588;
+}
+.connect-btn {
+	font-size: 26rpx;
+	color: #FFFFFF;
+	background-color: #389588;
+	padding-left: 24rpx;
+	padding-right: 24rpx;
+	padding-top: 12rpx;
+	padding-bottom: 12rpx;
+	border-radius: 20rpx;
+}
+.empty-tip {
+	align-items: center;
+	padding-top: 100rpx;
+}
+.empty-text {
+	font-size: 28rpx;
+	color: #999;
+}
+</style>

BIN
code/ajyApp/static/public/back-arrow.png


+ 3 - 0
code/ajyApp/static/public/back-arrow.svg

@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
+  <path d="M30 36L18 24L30 12" stroke="#545F71" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 15 - 0
code/ajyApp/utils/ble/BleManager.js

@@ -47,6 +47,7 @@ class BleManager extends EventEmitter {
     )
     this._writeLock = Promise.resolve() // 写入串行化
     this._bound = false                 // 是否已绑定系统监听
+    this._cancelScan = null             // 取消残留扫描的回调
   }
 
   // ============== 基础 ==============
@@ -94,6 +95,8 @@ class BleManager extends EventEmitter {
   /** 彻底释放 */
   async destroy() {
     this._clearReconnect()
+    // 取消残留的扫描
+    if (this._cancelScan) { this._cancelScan(); this._cancelScan = null }
     try { await this.disconnect() } catch (_) {}
     try { await this._invoke(uni.closeBluetoothAdapter, {}) } catch (_) {}
     this._unbindSystemListeners()
@@ -144,6 +147,9 @@ class BleManager extends EventEmitter {
    * @returns {Promise<Object|Object[]>}
    */
   async scan(opt = {}) {
+    // 取消上一次残留的扫描(如 search 页面未正确清理)
+    if (this._cancelScan) { this._cancelScan(); this._cancelScan = null }
+
     await this._ensureReady()
     const {
       namePrefix = this.config.deviceNamePrefix,
@@ -157,7 +163,10 @@ class BleManager extends EventEmitter {
     const matched = []
 
     return new Promise(async (resolve, reject) => {
+      let finished = false   // 防重入:确保 finish 只执行一次
+
       const onFound = res => {
+        if (finished) return
         for (const d of res.devices) {
           if (devices.has(d.deviceId)) continue
           devices.set(d.deviceId, d)
@@ -180,6 +189,9 @@ class BleManager extends EventEmitter {
       }, timeout)
 
       const finish = (err, data) => {
+        if (finished) return   // 防重入
+        finished = true
+        this._cancelScan = null
         clearTimeout(timer)
         uni.offBluetoothDeviceFound && uni.offBluetoothDeviceFound(onFound)
         this._invoke(uni.stopBluetoothDevicesDiscovery, {}).catch(() => {})
@@ -187,6 +199,9 @@ class BleManager extends EventEmitter {
         err ? reject(err) : resolve(data)
       }
 
+      // 暴露取消句柄,供 destroy() / 下次 scan() 调用
+      this._cancelScan = () => finish(this._err(BLE_ERROR.SCAN_FAIL, { msg: '扫描已取消' }))
+
       uni.onBluetoothDeviceFound(onFound)
       this._setState(BLE_STATE.SCANNING)