- 修复蓝牙调试页 ESLint style/max-statements-per-line 错误 - 用户页布局调整 - 字典枚举新增 OPS 模块注释 - gitignore 新增 *.pen 忽略规则 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1470 lines
37 KiB
Vue
1470 lines
37 KiB
Vue
<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>
|
||
|
||
<!-- 手动输入 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="从信标配置工具或安卓手机获取"
|
||
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>
|