Files
aiot-uniapp/src/pages/scan/bluetooth-debug/index.vue
lzh ee8e8732cc chore: 杂项修复(蓝牙调试代码风格、用户页、字典枚举、gitignore)
- 修复蓝牙调试页 ESLint style/max-statements-per-line 错误
- 用户页布局调整
- 字典枚举新增 OPS 模块注释
- gitignore 新增 *.pen 忽略规则

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 22:07:11 +08:00

1470 lines
37 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="page">
<wd-navbar
title="蓝牙调试"
left-arrow
placeholder
safe-area-inset-top
fixed
@click-left="uni.navigateBack()"
/>
<!-- ===== 步骤指示 ===== -->
<view class="section">
<view class="steps">
<view
v-for="(s, i) in stepList"
:key="i"
class="step"
:class="{ 'step--active': step === i, 'step--done': step > i }"
@click="gotoStep(i)"
>
<view class="step-num">
{{ step > i ? '✓' : i + 1 }}
</view>
<text class="step-label">{{ s }}</text>
</view>
</view>
</view>
<!-- ===================== Step 0: BLE 通用扫描 ===================== -->
<template v-if="step === 0">
<!-- iOS 提示 -->
<view v-if="isIOS" class="section">
<view class="ios-tip">
<view class="ios-tip-title">
iOS 限制说明
</view>
<view class="ios-tip-desc">
苹果系统会屏蔽 BLE 扫描中的 iBeacon 广播包因此 BLE 扫描无法发现 iBeacon 设备
</view>
<view class="ios-tip-desc" style="margin-top: 8rpx;">
请直接跳到第 2 手动输入信标的 iBeacon UUID 进行监听
可先用安卓手机扫描获取 UUID
</view>
<view class="action-btn action-btn--primary mt-16" @click="gotoStep(1)">
跳到识别信标输入 UUID
</view>
</view>
</view>
<!-- 控制 -->
<view class="section">
<view class="card control-card">
<view class="control-main">
<view class="title">
BLE 通用扫描
</view>
<view class="desc">
扫描所有 BLE 设备{{ isIOS ? '' : ',自动解析 iBeacon 广播帧' }}
</view>
<view class="desc">
设备 {{ devices.length }} {{ isIOS ? '' : `,其中 iBeacon ${ibeaconDevices.length}` }}
</view>
</view>
<wd-button
:type="scanning ? 'error' : 'primary'"
size="small"
round
@click="scanning ? stopBleScan() : startBleScan()"
>
{{ scanning ? '停止' : '开始' }}
</wd-button>
</view>
</view>
<view v-if="errorMsg" class="section">
<view class="error-box">
{{ errorMsg }}
</view>
</view>
<!-- 筛选工具 -->
<view v-if="devices.length" class="section tools">
<view class="chip" :class="{ 'chip--active': bleFilter === 'all' }" @click="bleFilter = 'all'">
全部 ({{ devices.length }})
</view>
<view class="chip" :class="{ 'chip--active': bleFilter === 'ibeacon' }" @click="bleFilter = 'ibeacon'">
iBeacon ({{ ibeaconDevices.length }})
</view>
<view class="chip" :class="{ 'chip--active': bleFilter === 'named' }" @click="bleFilter = 'named'">
有名称
</view>
<view class="chip" @click="devices = []">
清空
</view>
</view>
<!-- MAC 搜索 -->
<view v-if="devices.length" class="section">
<view class="input-wrap">
<input
v-model="targetMac"
class="input"
placeholder="搜索 MAC / 设备名 / 广播数据"
confirm-type="done"
>
<view v-if="targetMac" class="clear" @click="targetMac = ''">
清除
</view>
</view>
</view>
<!-- 设备列表 -->
<view class="list-section">
<view v-if="filteredBleDevices.length === 0" class="empty">
<view class="empty-title">
{{ scanning ? '正在搜索 BLE 设备...' : '点击「开始」扫描附近设备' }}
</view>
<view v-if="!scanning" class="empty-desc">
会自动解析 iBeacon 广播帧提取 UUID / Major / Minor
</view>
</view>
<view
v-for="device in filteredBleDevices"
:key="device.deviceId"
class="device-card"
:class="{ 'device-card--beacon': !!device.ibeacon }"
@click="handleDeviceTap(device)"
>
<view class="device-header">
<view class="device-name">
{{ device.name || device.localName || '未知设备' }}
<text v-if="device.ibeacon" class="beacon-tag">iBeacon</text>
</view>
<view class="rssi">
{{ device.RSSI }} dBm
</view>
</view>
<view class="info-row">
<text class="info-label">设备 ID</text>
<text class="info-value mono break">{{ device.deviceId }}</text>
</view>
<view v-if="device.mac" class="info-row">
<text class="info-label">解析 MAC</text>
<text class="info-value mono break">{{ device.mac }}</text>
</view>
<view v-if="device.ibeacon" class="info-row">
<text class="info-label">UUID</text>
<text class="info-value mono break text-xs">{{ device.ibeacon.uuid }}</text>
</view>
<view v-if="device.ibeacon" class="info-row">
<text class="info-label">Major/Minor</text>
<text class="info-value mono">{{ device.ibeacon.major }} / {{ device.ibeacon.minor }}</text>
</view>
<view v-if="device.advertisData" class="info-row">
<text class="info-label">广播数据</text>
<text class="info-value mono break text-xs">{{ device.advertisData }}</text>
</view>
<!-- 操作按钮 -->
<view class="device-actions">
<view v-if="device.ibeacon" class="action-btn action-btn--primary" @click.stop="useAsBeacon(device)">
用此 UUID 进入 iBeacon 监听
</view>
<view class="action-btn" @click.stop="connectAndRead(device)">
{{ connectingId === device.deviceId ? '连接中...' : '连接读取设备信息' }}
</view>
</view>
<!-- 连接后读到的服务/特征值 -->
<view v-if="deviceServices[device.deviceId]" class="services-panel">
<view class="services-title">
设备信息0x180A
</view>
<view
v-for="char in deviceServices[device.deviceId]"
:key="char.name"
class="info-row"
>
<text class="info-label">{{ char.name }}</text>
<text class="info-value mono break">{{ char.value }}</text>
</view>
<view v-if="deviceServices[device.deviceId].length === 0" class="hint">
该设备不支持 Device Information Service
</view>
</view>
</view>
</view>
</template>
<!-- ===================== Step 1: 识别确认 ===================== -->
<template v-if="step === 1">
<view class="section">
<view class="card">
<view class="title">
确认目标信标
</view>
<view class="desc mt-12">
{{ isIOS
? 'iOS 无法通过 BLE 扫描发现 iBeacon请在下方手动输入 UUID'
: '以下是从 BLE 扫描中发现的 iBeacon 信标,选择你的目标信标'
}}
</view>
</view>
</view>
<!-- 手动输入 UUIDiOS 优先显示 -->
<view v-if="isIOS" class="section">
<view class="card">
<view class="label">
输入 iBeacon UUID
</view>
<view class="input-wrap">
<input
v-model="manualUuid"
class="input"
placeholder="从信标配置工具或安卓手机获取"
confirm-type="done"
>
</view>
<view class="hint">
UUID 通常为 36 位格式 FDA50693-A4E2-4FB1-AFCB-194215961234
</view>
<view v-if="manualUuid" class="mt-16">
<view class="action-btn action-btn--primary action-btn--block" @click="manualConfirmAndGo">
使用此 UUID 进入 iBeacon 监听
</view>
</view>
</view>
</view>
<view v-if="ibeaconDevices.length === 0 && !isIOS" class="section">
<view class="empty">
<view class="empty-title">
未发现 iBeacon 设备
</view>
<view class="empty-desc">
请返回第一步先进行 BLE 扫描
</view>
<view class="action-btn action-btn--primary mt-24" @click="gotoStep(0)">
返回扫描
</view>
</view>
</view>
<view class="list-section">
<view
v-for="device in ibeaconDevices"
:key="device.deviceId"
class="device-card device-card--selectable"
:class="{ 'device-card--selected': selectedDevice?.deviceId === device.deviceId }"
@click="selectedDevice = device"
>
<view class="device-header">
<view class="device-name">
{{ device.name || '未知设备' }}
<text class="beacon-tag">iBeacon</text>
</view>
<view class="rssi">
{{ device.RSSI }} dBm
</view>
</view>
<view class="info-row">
<text class="info-label">UUID</text>
<text class="info-value mono break text-xs">{{ device.ibeacon!.uuid }}</text>
</view>
<view class="info-row">
<text class="info-label">Major/Minor</text>
<text class="info-value mono">{{ device.ibeacon!.major }} / {{ device.ibeacon!.minor }}</text>
</view>
<view v-if="device.mac" class="info-row">
<text class="info-label">解析 MAC</text>
<text class="info-value mono">{{ device.mac }}</text>
</view>
</view>
</view>
<view v-if="selectedDevice" class="section">
<view class="action-btn action-btn--primary action-btn--block" @click="confirmAndGo">
确认进入 iBeacon 精确监听
</view>
</view>
<!-- 手动输入 UUID iOS -->
<view v-if="!isIOS" class="section">
<view class="card">
<view class="label">
或手动输入 iBeacon UUID
</view>
<view class="input-wrap">
<input
v-model="manualUuid"
class="input"
placeholder="输入 iBeacon UUID"
confirm-type="done"
>
</view>
<view v-if="manualUuid" class="mt-16">
<view class="action-btn action-btn--primary" @click="manualConfirmAndGo">
使用此 UUID 进入监听
</view>
</view>
</view>
</view>
</template>
<!-- ===================== Step 2: iBeacon 精确监听 ===================== -->
<template v-if="step === 2">
<!-- 目标信息 -->
<view class="section">
<view class="card highlight-card">
<view class="title">
目标 iBeacon
</view>
<view class="info-row mt-12">
<text class="info-label">UUID</text>
<text class="info-value mono break text-xs">{{ activeBeaconUuid }}</text>
</view>
<view v-if="selectedDevice" class="info-row">
<text class="info-label">设备名</text>
<text class="info-value">{{ selectedDevice.name || '未知' }}</text>
</view>
</view>
</view>
<!-- 控制 -->
<view class="section">
<view class="card control-card">
<view class="control-main">
<view class="title">
iBeacon 监听
</view>
<view class="desc">
信标 {{ beacons.length }}
<text v-if="scanning" class="scanning-dot" />
</view>
</view>
<wd-button
:type="scanning ? 'error' : 'primary'"
size="small"
round
@click="scanning ? stopBeaconScan() : startBeaconScan()"
>
{{ scanning ? '停止' : '开始' }}
</wd-button>
</view>
</view>
<view v-if="errorMsg" class="section">
<view class="error-box">
{{ errorMsg }}
</view>
</view>
<!-- 信标列表 -->
<view class="list-section">
<view v-if="sortedBeacons.length === 0" class="empty">
<view class="empty-title">
{{ scanning ? '正在监听 iBeacon...' : '点击「开始」监听信标' }}
</view>
</view>
<view
v-for="b in sortedBeacons"
:key="b.key"
class="device-card device-card--beacon"
>
<view class="device-header">
<view class="device-name">
iBeacon
<text
class="proximity-tag"
:class="{
'proximity-tag--near': b.proximity === 1,
'proximity-tag--mid': b.proximity === 2,
'proximity-tag--far': b.proximity === 3,
}"
>
{{ proximityText(b.proximity) }}
</text>
</view>
<view class="rssi">
{{ b.rssi }} dBm
</view>
</view>
<view class="info-row">
<text class="info-label">UUID</text>
<text class="info-value mono break text-xs">{{ b.uuid }}</text>
</view>
<view class="info-row">
<text class="info-label">Major</text>
<text class="info-value mono">{{ b.major }}</text>
</view>
<view class="info-row">
<text class="info-label">Minor</text>
<text class="info-value mono">{{ b.minor }}</text>
</view>
<view class="info-row">
<text class="info-label">精度</text>
<text class="info-value">{{ b.accuracy.toFixed(2) }} m</text>
</view>
<view class="info-row">
<text class="info-label">RSSI</text>
<view class="rssi-bar-wrap">
<view class="rssi-bar" :style="{ width: `${rssiPercent(b.rssi)}%` }" />
</view>
<text class="info-value mono" style="width: auto;">{{ b.rssi }}</text>
</view>
</view>
</view>
</template>
<!-- ===== 日志面板 ===== -->
<view v-if="logs.length" class="log-panel">
<view class="log-head">
<text class="log-title">日志 ({{ logs.length }})</text>
<view class="log-actions">
<text class="log-action" @click="showLog = !showLog">{{ showLog ? '收起' : '展开' }}</text>
<text class="log-action" @click="logs = []">清空</text>
</view>
</view>
<view v-if="showLog" class="log-body">
<view
v-for="(log, i) in logs"
:key="i"
class="log-item"
:class="{
'log-item--error': log.type === 'error',
'log-item--success': log.type === 'success',
}"
>
[{{ log.time }}] {{ log.msg }}
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, onUnmounted, reactive, ref } from 'vue'
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
// ==================== 类型 ====================
interface IBeaconParsed {
uuid: string
major: number
minor: number
}
interface BleDevice {
name: string
localName?: string
deviceId: string
mac?: string
RSSI: number
advertisData?: string
ibeacon?: IBeaconParsed
}
interface BeaconItem {
key: string
uuid: string
major: number
minor: number
rssi: number
accuracy: number
proximity: number
}
interface LogItem {
time: string
msg: string
type: 'info' | 'error' | 'success'
}
interface CharInfo {
name: string
value: string
}
// ==================== 平台检测 ====================
const systemInfo = uni.getSystemInfoSync()
const isIOS = systemInfo.platform === 'ios'
// ==================== 状态 ====================
const stepList = ['BLE 扫描', '识别信标', 'iBeacon 监听']
const step = ref(isIOS ? 1 : 0) // iOS 默认跳到第 2 步
const scanning = ref(false)
const errorMsg = ref('')
const logs = ref<LogItem[]>([])
const showLog = ref(false)
// Step 0: BLE 扫描
const devices = ref<BleDevice[]>([])
const targetMac = ref('')
const bleFilter = ref<'all' | 'ibeacon' | 'named'>('all')
const connectingId = ref('')
const deviceServices = reactive<Record<string, CharInfo[]>>({})
let bleFoundHandler: ((res: any) => void) | null = null
// Step 1: 识别
const selectedDevice = ref<BleDevice | null>(null)
const manualUuid = ref('')
// Step 2: iBeacon
const activeBeaconUuid = ref('')
const beacons = ref<BeaconItem[]>([])
// ==================== 工具函数 ====================
function normalizeMac(mac: string): string {
return mac.replace(/[:\-\s]/g, '').toUpperCase()
}
function ab2hex(buffer: ArrayBuffer): string {
if (!buffer || buffer.byteLength === 0)
return ''
return Array.from(new Uint8Array(buffer))
.map(b => b.toString(16).padStart(2, '0'))
.join(' ')
}
function parseIBeaconFromAdv(buffer: ArrayBuffer): IBeaconParsed | null {
if (!buffer || buffer.byteLength < 25)
return null
const bytes = new Uint8Array(buffer)
for (let i = 0; i < bytes.length - 24; i++) {
if (bytes[i] === 0x4C && bytes[i + 1] === 0x00
&& bytes[i + 2] === 0x02 && bytes[i + 3] === 0x15) {
const uuidBytes = Array.from(bytes.slice(i + 4, i + 20))
const uuid = [
uuidBytes.slice(0, 4),
uuidBytes.slice(4, 6),
uuidBytes.slice(6, 8),
uuidBytes.slice(8, 10),
uuidBytes.slice(10, 16),
].map(g => g.map(b => b.toString(16).padStart(2, '0')).join('')).join('-').toUpperCase()
return {
uuid,
major: (bytes[i + 20] << 8) | bytes[i + 21],
minor: (bytes[i + 22] << 8) | bytes[i + 23],
}
}
}
return null
}
function parseMacFromAdv(buffer: ArrayBuffer): string | null {
if (!buffer || buffer.byteLength === 0)
return null
const bytes = new Uint8Array(buffer)
let i = 0
while (i < bytes.length) {
const len = bytes[i]
if (len === 0 || i + len >= bytes.length)
break
if (bytes[i + 1] === 0xFF && len >= 8) {
const dataEnd = i + 1 + len
const tail = Array.from(bytes.slice(dataEnd - 6, dataEnd))
const forward = tail.map(b => b.toString(16).padStart(2, '0').toUpperCase()).join(':')
const reversed = [...tail].reverse().map(b => b.toString(16).padStart(2, '0').toUpperCase()).join(':')
return `${forward} / ${reversed}`
}
i += len + 1
}
return null
}
function isMatchTarget(device: BleDevice): boolean {
const kw = normalizeMac(targetMac.value)
if (!kw)
return true
// deviceId
if (normalizeMac(device.deviceId).includes(kw))
return true
// MAC
if (device.mac && normalizeMac(device.mac).includes(kw))
return true
// 名称
if (device.name && device.name.toUpperCase().includes(kw))
return true
if (device.localName && device.localName.toUpperCase().includes(kw))
return true
// 广播数据
if (device.advertisData) {
const adv = device.advertisData.replace(/\s/g, '').toUpperCase()
if (adv.includes(kw))
return true
const reversed = kw.match(/.{2}/g)?.reverse().join('') || ''
if (reversed && adv.includes(reversed))
return true
}
return false
}
function proximityText(p: number): string {
if (p === 1)
return '极近'
if (p === 2)
return '较近'
if (p === 3)
return '较远'
return '未知'
}
function rssiPercent(rssi: number): number {
// -100 → 0%, -30 → 100%
return Math.max(0, Math.min(100, ((rssi + 100) / 70) * 100))
}
function addLog(msg: string, type: LogItem['type'] = 'info') {
const now = new Date()
const time = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`
logs.value.push({ time, msg, type })
if (logs.value.length > 300)
logs.value.splice(0, logs.value.length - 300)
}
function promisify(fn: any, opts: any = {}): Promise<any> {
return new Promise((resolve, reject) => {
fn({ ...opts, success: resolve, fail: reject })
})
}
// ==================== 计算属性 ====================
const ibeaconDevices = computed(() => devices.value.filter(d => d.ibeacon))
const filteredBleDevices = computed(() => {
let list = [...devices.value]
if (bleFilter.value === 'ibeacon')
list = list.filter(d => d.ibeacon)
if (bleFilter.value === 'named')
list = list.filter(d => !!(d.name || d.localName))
list = list.filter(d => isMatchTarget(d))
list.sort((a, b) => b.RSSI - a.RSSI)
return list
})
const sortedBeacons = computed(() => [...beacons.value].sort((a, b) => b.rssi - a.rssi))
// ==================== 步骤导航 ====================
async function gotoStep(target: number) {
if (scanning.value) {
if (step.value === 0)
await stopBleScan()
if (step.value === 2)
await stopBeaconScan()
}
errorMsg.value = ''
step.value = target
}
// ==================== Step 0: BLE 扫描 ====================
async function startBleScan() {
errorMsg.value = ''
try {
await promisify(uni.openBluetoothAdapter)
addLog('蓝牙适配器已打开', 'success')
} catch (err: any) {
errorMsg.value = err?.errMsg || '蓝牙初始化失败'
addLog(errorMsg.value, 'error')
return
}
if (bleFoundHandler) {
try {
uni.offBluetoothDeviceFound?.(bleFoundHandler)
} catch {}
}
bleFoundHandler = (res: any) => {
const found = Array.isArray(res?.devices) ? res.devices : []
for (const raw of found) {
const item: BleDevice = {
name: raw.name || raw.localName || '',
localName: raw.localName || '',
deviceId: raw.deviceId,
mac: raw.advertisData ? parseMacFromAdv(raw.advertisData) || undefined : undefined,
RSSI: raw.RSSI,
advertisData: raw.advertisData ? ab2hex(raw.advertisData) || undefined : undefined,
ibeacon: raw.advertisData ? parseIBeaconFromAdv(raw.advertisData) || undefined : undefined,
}
const idx = devices.value.findIndex(d => d.deviceId === item.deviceId)
if (idx >= 0) {
devices.value.splice(idx, 1, item)
} else {
devices.value.unshift(item)
const tag = item.ibeacon ? ' [iBeacon]' : ''
addLog(`发现: ${item.name || '未知'} RSSI=${item.RSSI}${tag}`, item.ibeacon ? 'success' : 'info')
}
}
}
uni.onBluetoothDeviceFound(bleFoundHandler)
try {
await promisify(uni.startBluetoothDevicesDiscovery, { allowDuplicatesKey: true })
scanning.value = true
addLog('BLE 扫描已启动', 'success')
} catch (err: any) {
errorMsg.value = err?.errMsg || '启动扫描失败'
addLog(errorMsg.value, 'error')
}
}
async function stopBleScan() {
try {
await promisify(uni.stopBluetoothDevicesDiscovery)
} catch {}
if (bleFoundHandler) {
try {
uni.offBluetoothDeviceFound?.(bleFoundHandler)
} catch {}
bleFoundHandler = null
}
scanning.value = false
addLog('BLE 扫描已停止')
}
// ==================== 连接读取设备信息 ====================
/** Device Information Service 标准特征值 */
const DIS_CHARS: Record<string, string> = {
'00002A23': 'System ID',
'00002A24': 'Model Number',
'00002A25': 'Serial Number',
'00002A26': 'Firmware Rev',
'00002A27': 'Hardware Rev',
'00002A28': 'Software Rev',
'00002A29': 'Manufacturer',
}
async function connectAndRead(device: BleDevice) {
if (connectingId.value)
return
connectingId.value = device.deviceId
addLog(`正在连接 ${device.name || device.deviceId}...`)
try {
// 先停止扫描(扫描和连接不能同时进行)
const wasScan = scanning.value
if (wasScan) {
await promisify(uni.stopBluetoothDevicesDiscovery)
scanning.value = false
}
// 连接
await promisify(uni.createBLEConnection, { deviceId: device.deviceId })
addLog('已连接', 'success')
// 发现服务
const svcRes = await promisify(uni.getBLEDeviceServices, { deviceId: device.deviceId })
const services: any[] = svcRes.services || []
addLog(`发现 ${services.length} 个服务`)
const chars: CharInfo[] = []
// 查找 Device Information Service (0x180A)
const disService = services.find((s: any) =>
s.uuid.toUpperCase().includes('180A'),
)
if (disService) {
addLog(`找到设备信息服务: ${disService.uuid}`, 'success')
const charRes = await promisify(uni.getBLEDeviceCharacteristics, {
deviceId: device.deviceId,
serviceId: disService.uuid,
})
for (const ch of (charRes.characteristics || [])) {
if (!ch.properties?.read)
continue
try {
const readRes = await promisify(uni.readBLECharacteristicValue, {
deviceId: device.deviceId,
serviceId: disService.uuid,
characteristicId: ch.uuid,
})
// readBLECharacteristicValue 通过回调返回
const value = await new Promise<string>((resolve) => {
const handler = (result: any) => {
if (result.characteristicId === ch.uuid) {
;(uni.offBLECharacteristicValueChange as any)?.(handler)
const bytes = new Uint8Array(result.value)
// 尝试解析为字符串
const text = Array.from(bytes).map(b => String.fromCharCode(b)).join('')
const hex = Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(':')
resolve(isPrintable(text) ? text : hex)
}
}
uni.onBLECharacteristicValueChange(handler as any)
// 超时
setTimeout(() => resolve('(read timeout)'), 3000)
})
const charKey = ch.uuid.slice(4, 8).toUpperCase()
const name = DIS_CHARS[`0000${charKey}`] || ch.uuid
chars.push({ name, value })
addLog(` ${name}: ${value}`)
} catch {
// 读取失败,跳过
}
}
} else {
addLog('该设备不支持 Device Information Service (0x180A)')
// 列出所有服务 UUID 供参考
for (const svc of services) {
addLog(` 服务: ${svc.uuid}`)
}
}
deviceServices[device.deviceId] = chars
// 断开
try {
await promisify(uni.closeBLEConnection, { deviceId: device.deviceId })
} catch {}
addLog('已断开连接')
// 恢复扫描
if (wasScan) {
await startBleScan()
}
} catch (err: any) {
addLog(`连接失败: ${err?.errMsg || err?.message || '未知错误'}`, 'error')
try {
await promisify(uni.closeBLEConnection, { deviceId: device.deviceId })
} catch {}
} finally {
connectingId.value = ''
}
}
function isPrintable(str: string): boolean {
return /^[\x20-\x7E]+$/.test(str) && str.length > 0
}
// ==================== Step 0 → Step 1/2 快捷操作 ====================
function handleDeviceTap(device: BleDevice) {
// 展开/收起详情(当前靠模板展示,无需额外逻辑)
}
async function useAsBeacon(device: BleDevice) {
if (!device.ibeacon)
return
if (scanning.value)
await stopBleScan()
selectedDevice.value = device
activeBeaconUuid.value = device.ibeacon.uuid
beacons.value = []
step.value = 2
addLog(`已选择信标 UUID: ${device.ibeacon.uuid}`, 'success')
}
// ==================== Step 1: 识别确认 ====================
async function confirmAndGo() {
if (!selectedDevice.value?.ibeacon)
return
activeBeaconUuid.value = selectedDevice.value.ibeacon.uuid
beacons.value = []
step.value = 2
}
async function manualConfirmAndGo() {
if (!manualUuid.value.trim())
return
activeBeaconUuid.value = manualUuid.value.trim()
selectedDevice.value = null
beacons.value = []
step.value = 2
}
// ==================== Step 2: iBeacon 精确监听 ====================
function handleBeaconUpdate(res: { beacons: Array<{ uuid: string, major: number, minor: number, rssi: number, accuracy: number, proximity: number }> }) {
for (const b of res.beacons) {
const item: BeaconItem = {
key: `${b.uuid}-${b.major}-${b.minor}`,
uuid: b.uuid,
major: b.major,
minor: b.minor,
rssi: b.rssi,
accuracy: b.accuracy,
proximity: b.proximity,
}
const idx = beacons.value.findIndex(x => x.key === item.key)
if (idx >= 0) {
beacons.value.splice(idx, 1, item)
} else {
beacons.value.unshift(item)
addLog(`iBeacon: Major=${b.major} Minor=${b.minor} RSSI=${b.rssi} 距离=${b.accuracy.toFixed(1)}m`, 'success')
}
}
}
async function startBeaconScan() {
errorMsg.value = ''
if (!activeBeaconUuid.value) {
errorMsg.value = '请先选择 iBeacon UUID'
return
}
try {
await promisify(uni.openBluetoothAdapter)
addLog('蓝牙适配器已打开', 'success')
} catch (err: any) {
errorMsg.value = err?.errMsg || '蓝牙初始化失败'
addLog(errorMsg.value, 'error')
return
}
uni.onBeaconUpdate(handleBeaconUpdate as any)
try {
await new Promise<void>((resolve, reject) => {
uni.startBeaconDiscovery({
uuids: [activeBeaconUuid.value],
success: () => resolve(),
fail: (err: any) => reject(new Error(err?.errMsg || 'iBeacon 启动失败')),
})
})
scanning.value = true
addLog(`iBeacon 监听已启动: ${activeBeaconUuid.value}`, 'success')
} catch (err: any) {
errorMsg.value = err.message
addLog(err.message, 'error')
}
}
async function stopBeaconScan() {
try {
uni.stopBeaconDiscovery({})
} catch {}
try {
(uni.offBeaconUpdate as any)?.(handleBeaconUpdate)
} catch {}
scanning.value = false
addLog('iBeacon 监听已停止')
}
// ==================== 清理 ====================
onUnmounted(() => {
if (step.value === 0 && scanning.value)
stopBleScan()
if (step.value === 2 && scanning.value)
stopBeaconScan()
try {
uni.closeBluetoothAdapter({})
} catch {}
})
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: #f5f7fb;
padding-bottom: 220rpx;
}
.section {
padding: 16rpx 32rpx 0;
}
.list-section {
padding: 16rpx 32rpx 0;
}
.card {
border-radius: 24rpx;
background: #fff;
padding: 28rpx;
box-shadow: 0 8rpx 24rpx rgba(15, 23, 42, 0.05);
}
.mt-12 {
margin-top: 12rpx;
}
.mt-16 {
margin-top: 16rpx;
}
.mt-24 {
margin-top: 24rpx;
}
.text-xs {
font-size: 20rpx;
}
// ===== 步骤条 =====
.steps {
display: flex;
align-items: center;
gap: 4rpx;
padding: 8rpx;
border-radius: 24rpx;
background: rgba(249, 115, 22, 0.06);
}
.step {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
padding: 18rpx 0;
border-radius: 18rpx;
transition: all 0.25s;
}
.step-num {
width: 36rpx;
height: 36rpx;
border-radius: 50%;
background: #e5e7eb;
color: #9ca3af;
font-size: 20rpx;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.step-label {
font-size: 22rpx;
font-weight: 600;
color: #9ca3af;
}
.step--active {
background: #fff;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
.step-num {
background: #f97316;
color: #fff;
}
.step-label {
color: #f97316;
}
}
.step--done {
.step-num {
background: #10b981;
color: #fff;
}
.step-label {
color: #374151;
}
}
// ===== 控制卡 =====
.control-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24rpx;
}
.control-main {
flex: 1;
}
.title {
font-size: 30rpx;
font-weight: 700;
color: #111827;
}
.desc {
margin-top: 6rpx;
font-size: 22rpx;
color: #9ca3af;
}
.highlight-card {
background: linear-gradient(135deg, #fef3c7, #fff7ed);
border: 2rpx solid #f59e0b;
}
// ===== 输入 =====
.label {
margin-bottom: 12rpx;
font-size: 26rpx;
font-weight: 600;
color: #374151;
}
.input-wrap {
display: flex;
align-items: center;
gap: 16rpx;
border-radius: 16rpx;
background: #f3f4f6;
padding: 18rpx 20rpx;
}
.input {
flex: 1;
font-size: 26rpx;
color: #111827;
}
.clear {
font-size: 22rpx;
color: #9ca3af;
}
.hint {
margin-top: 12rpx;
font-size: 22rpx;
color: #9ca3af;
}
// ===== iOS 提示 =====
.ios-tip {
border-radius: 20rpx;
background: linear-gradient(135deg, #eff6ff, #f0f9ff);
border: 2rpx solid #93c5fd;
padding: 28rpx;
}
.ios-tip-title {
font-size: 28rpx;
font-weight: 700;
color: #1d4ed8;
margin-bottom: 12rpx;
}
.ios-tip-desc {
font-size: 24rpx;
line-height: 1.6;
color: #3b82f6;
}
.error-box {
border-radius: 16rpx;
background: #fef2f2;
padding: 20rpx 24rpx;
font-size: 24rpx;
color: #ef4444;
}
// ===== 筛选 =====
.tools {
display: flex;
gap: 12rpx;
flex-wrap: wrap;
}
.chip {
border-radius: 999rpx;
background: #fff;
padding: 10rpx 20rpx;
font-size: 22rpx;
font-weight: 600;
color: #6b7280;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
}
.chip--active {
background: #f97316;
color: #fff;
}
// ===== 空状态 =====
.empty {
border-radius: 24rpx;
background: #fff;
padding: 60rpx 32rpx;
text-align: center;
box-shadow: 0 8rpx 24rpx rgba(15, 23, 42, 0.05);
}
.empty-title {
font-size: 28rpx;
font-weight: 600;
color: #374151;
}
.empty-desc {
margin-top: 12rpx;
font-size: 22rpx;
color: #9ca3af;
}
// ===== 设备卡 =====
.device-card {
margin-bottom: 16rpx;
border-radius: 24rpx;
background: #fff;
padding: 24rpx;
box-shadow: 0 8rpx 24rpx rgba(15, 23, 42, 0.05);
transition: all 0.2s;
}
.device-card--beacon {
border-left: 6rpx solid #f97316;
}
.device-card--selectable {
border: 2rpx solid transparent;
&:active {
background: #fefce8;
}
}
.device-card--selected {
border-color: #f97316;
background: #fff7ed;
}
.device-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16rpx;
margin-bottom: 12rpx;
}
.device-name {
flex: 1;
font-size: 28rpx;
font-weight: 700;
color: #111827;
word-break: break-all;
}
.beacon-tag {
display: inline-block;
margin-left: 8rpx;
padding: 2rpx 12rpx;
border-radius: 8rpx;
background: #fff7ed;
font-size: 20rpx;
font-weight: 600;
color: #f97316;
vertical-align: middle;
}
.proximity-tag {
display: inline-block;
margin-left: 8rpx;
padding: 4rpx 16rpx;
border-radius: 12rpx;
font-size: 22rpx;
font-weight: 700;
vertical-align: middle;
}
.proximity-tag--near {
background: #dcfce7;
color: #16a34a;
}
.proximity-tag--mid {
background: #fff7ed;
color: #f97316;
}
.proximity-tag--far {
background: #f3f4f6;
color: #9ca3af;
}
.rssi {
flex-shrink: 0;
font-size: 24rpx;
font-weight: 600;
color: #f97316;
}
.info-row {
display: flex;
align-items: flex-start;
gap: 12rpx;
margin-top: 8rpx;
}
.info-label {
width: 128rpx;
flex-shrink: 0;
font-size: 22rpx;
font-weight: 600;
color: #6b7280;
}
.info-value {
flex: 1;
font-size: 22rpx;
color: #4b5563;
}
.mono {
font-family: 'Courier New', Courier, monospace;
}
.break {
word-break: break-all;
}
// ===== 操作按钮 =====
.device-actions {
margin-top: 16rpx;
padding-top: 16rpx;
border-top: 2rpx solid #f3f4f6;
display: flex;
flex-wrap: wrap;
gap: 12rpx;
}
.action-btn {
padding: 12rpx 24rpx;
border-radius: 16rpx;
background: #f3f4f6;
font-size: 22rpx;
font-weight: 600;
color: #374151;
text-align: center;
&:active {
opacity: 0.7;
}
}
.action-btn--primary {
background: #f97316;
color: #fff;
}
.action-btn--block {
display: block;
width: 100%;
}
// ===== 服务信息面板 =====
.services-panel {
margin-top: 16rpx;
padding: 16rpx;
border-radius: 16rpx;
background: #f0fdf4;
}
.services-title {
margin-bottom: 8rpx;
font-size: 22rpx;
font-weight: 700;
color: #16a34a;
}
// ===== RSSI 进度条 =====
.rssi-bar-wrap {
flex: 1;
height: 16rpx;
border-radius: 8rpx;
background: #f3f4f6;
margin-top: 6rpx;
}
.rssi-bar {
height: 100%;
border-radius: 8rpx;
background: linear-gradient(90deg, #f97316, #10b981);
transition: width 0.3s;
}
// ===== 扫描动画 =====
.scanning-dot {
display: inline-block;
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background: #10b981;
margin-left: 8rpx;
vertical-align: middle;
animation: pulse 1s infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
}
// ===== 日志 =====
.log-panel {
margin-top: 32rpx;
border-top: 2rpx solid #e5e7eb;
background: #fafafa;
}
.log-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 24rpx;
}
.log-title {
font-size: 24rpx;
font-weight: 700;
color: #6b7280;
}
.log-actions {
display: flex;
gap: 20rpx;
}
.log-action {
font-size: 22rpx;
color: #f97316;
}
.log-body {
max-height: 400rpx;
overflow: auto;
padding: 0 24rpx 20rpx;
}
.log-item {
padding-top: 8rpx;
font-family: 'Courier New', Courier, monospace;
font-size: 20rpx;
color: #6b7280;
word-break: break-all;
}
.log-item--error {
color: #ef4444;
}
.log-item--success {
color: #16a34a;
}
</style>