feat:绑定设备和设备详情

This commit is contained in:
李伊帆
2026-10-10 11:11:44 +08:00
parent 179bd3b66c
commit d4b02745e1
64 changed files with 3526 additions and 221 deletions

View File

@@ -0,0 +1,11 @@
import { request } from '@/api/request'
import type { BindDeviceBody } from './add-device.types'
/** POST /app/device/bind — 绑定设备(设备编号及蓝牙配对信息) */
export function bindDevice(data: BindDeviceBody) {
return request<void>({
url: '/app/device/bind',
method: 'POST',
data,
})
}

View File

@@ -51,6 +51,11 @@
background: linear-gradient(98deg, #45d685 0%, #21c25e 100%);
box-shadow: 0 8rpx 28rpx rgba(33, 194, 94, 0.28);
margin-bottom: 28rpx;
&--disabled {
opacity: 0.65;
pointer-events: none;
}
}
.add-device-page__btn-text {

View File

@@ -9,3 +9,60 @@ export interface BindFeatureItem {
desc: string
icon: string
}
/** POST /app/device/bind 请求体 */
export interface BindDeviceBody {
deviceNo: string
productId?: number
model?: string
imei?: string
mac?: string
name?: string
homeId?: number
locationId?: number
}
/** 绑定流程页间传递的设备信息 */
export interface BindDeviceContext {
deviceNo: string
name: string
serialMasked: string
productId?: number
model?: string
imei?: string
mac?: string
}
export function buildBindDeviceQuery(ctx: BindDeviceContext): string {
const parts = [
`deviceNo=${encodeURIComponent(ctx.deviceNo)}`,
`name=${encodeURIComponent(ctx.name)}`,
`serial=${encodeURIComponent(ctx.serialMasked)}`,
]
if (ctx.productId != null) parts.push(`productId=${ctx.productId}`)
if (ctx.model) parts.push(`model=${encodeURIComponent(ctx.model)}`)
if (ctx.imei) parts.push(`imei=${encodeURIComponent(ctx.imei)}`)
if (ctx.mac) parts.push(`mac=${encodeURIComponent(ctx.mac)}`)
return parts.join('&')
}
function parseQueryNumber(raw: unknown): number | undefined {
if (raw === undefined || raw === null || raw === '') return undefined
const n = Number(raw)
return Number.isFinite(n) ? n : undefined
}
export function readBindDeviceContext(
query: Record<string, string | undefined>,
): Partial<BindDeviceContext> {
const ctx: Partial<BindDeviceContext> = {}
if (query.deviceNo) ctx.deviceNo = decodeURIComponent(String(query.deviceNo))
if (query.name) ctx.name = decodeURIComponent(String(query.name))
if (query.serial) ctx.serialMasked = decodeURIComponent(String(query.serial))
const productId = parseQueryNumber(query.productId)
if (productId != null) ctx.productId = productId
if (query.model) ctx.model = decodeURIComponent(String(query.model))
if (query.imei) ctx.imei = decodeURIComponent(String(query.imei))
if (query.mac) ctx.mac = decodeURIComponent(String(query.mac))
return ctx
}

View File

@@ -210,6 +210,11 @@
display: flex;
align-items: center;
justify-content: center;
&--disabled {
opacity: 0.65;
pointer-events: none;
}
}
.complete-page__submit-text {

View File

@@ -73,8 +73,14 @@
</view>
</scroll-view>
<view class="complete-page__footer">
<view class="complete-page__submit" @tap="onFinishAdd">
<text class="complete-page__submit-text">完成添加</text>
<view
class="complete-page__submit"
:class="{ 'complete-page__submit--disabled': loading }"
@tap="onFinishAdd"
>
<text class="complete-page__submit-text">
{{ loading ? '提交中...' : '完成添加' }}
</text>
</view>
</view>
</view>
@@ -93,17 +99,14 @@ const {
deviceName,
serialMasked,
roomName,
loading,
applyQuery,
onPickRoom,
onFinishAdd,
} = useCompleteDevice()
onLoad((query) => {
if (query?.name) {
deviceName.value = decodeURIComponent(String(query.name))
}
if (query?.serial) {
serialMasked.value = decodeURIComponent(String(query.serial))
}
applyQuery(query ?? {})
})
</script>

View File

@@ -1,34 +1,85 @@
import { ref } from 'vue'
import { getErrorMessage } from '@/utils/api-error'
import { bindDevice } from '../add-device.api'
import { readBindDeviceContext } from '../add-device.types'
const ROOM_OPTIONS = ['客厅', '主卧', '卧室', '厨房', '卫生间']
const ROOM_OPTIONS = [
{ name: '客厅', locationId: 1 },
{ name: '主卧', locationId: 2 },
{ name: '卧室', locationId: 3 },
{ name: '厨房', locationId: 4 },
{ name: '卫生间', locationId: 5 },
] as const
export function useCompleteDevice() {
const currentStep = 4
const deviceNo = ref('')
const deviceName = ref('Z2 空气净化器')
const serialMasked = ref('****A3B2')
const roomName = ref('客厅')
const roomOptions = ROOM_OPTIONS
const productId = ref<number>()
const model = ref<string>()
const imei = ref<string>()
const mac = ref<string>()
const roomName = ref(ROOM_OPTIONS[0].name)
const locationId = ref<number | undefined>(ROOM_OPTIONS[0].locationId)
const loading = ref(false)
function applyQuery(query: Record<string, string | undefined>) {
const ctx = readBindDeviceContext(query)
if (ctx.deviceNo) deviceNo.value = ctx.deviceNo
if (ctx.name) deviceName.value = ctx.name
if (ctx.serialMasked) serialMasked.value = ctx.serialMasked
if (ctx.productId != null) productId.value = ctx.productId
if (ctx.model) model.value = ctx.model
if (ctx.imei) imei.value = ctx.imei
if (ctx.mac) mac.value = ctx.mac
}
function onPickRoom() {
uni.showActionSheet({
itemList: roomOptions,
itemList: ROOM_OPTIONS.map((item) => item.name),
success: (res) => {
const picked = roomOptions[res.tapIndex]
if (picked) roomName.value = picked
const picked = ROOM_OPTIONS[res.tapIndex]
if (picked) {
roomName.value = picked.name
locationId.value = picked.locationId
}
},
})
}
function onFinishAdd() {
async function onFinishAdd() {
const name = deviceName.value.trim()
if (!name) {
uni.showToast({ title: '请输入设备名称', icon: 'none' })
return
}
uni.showToast({ title: '添加成功', icon: 'success' })
setTimeout(() => {
uni.reLaunch({ url: '/pages/device/index' })
}, 400)
if (!deviceNo.value.trim()) {
uni.showToast({ title: '缺少设备编号', icon: 'none' })
return
}
if (loading.value) return
loading.value = true
try {
await bindDevice({
deviceNo: deviceNo.value.trim(),
name,
...(productId.value != null ? { productId: productId.value } : {}),
...(model.value ? { model: model.value } : {}),
...(imei.value ? { imei: imei.value } : {}),
...(mac.value ? { mac: mac.value } : {}),
...(locationId.value != null ? { locationId: locationId.value } : {}),
})
uni.showToast({ title: '添加成功', icon: 'success' })
setTimeout(() => {
uni.reLaunch({ url: '/pages/device/index' })
}, 400)
} catch (err) {
uni.showToast({ title: getErrorMessage(err, '绑定失败,请重试'), icon: 'none' })
} finally {
loading.value = false
}
}
return {
@@ -36,6 +87,8 @@ export function useCompleteDevice() {
deviceName,
serialMasked,
roomName,
loading,
applyQuery,
onPickRoom,
onFinishAdd,
}

View File

@@ -11,8 +11,14 @@
</text>
<bind-feature-list :items="BIND_FEATURES" />
<view class="add-device-page__footer">
<view class="add-device-page__btn" @tap="onOpenBluetooth">
<text class="add-device-page__btn-text">去开启蓝牙</text>
<view
class="add-device-page__btn"
:class="{ 'add-device-page__btn--disabled': opening }"
@tap="onOpenBluetooth"
>
<text class="add-device-page__btn-text">
{{ opening ? '正在开启...' : '去开启蓝牙' }}
</text>
</view>
<view class="add-device-page__later-wrap" @tap="onBindLater">
<text class="add-device-page__later">稍后绑定</text>
@@ -29,7 +35,7 @@ import BindFeatureList from './components/bind-feature-list/index.vue'
import BindSteps from './components/bind-steps/index.vue'
import { BIND_FEATURES, BIND_STEPS, useAddDevice } from './use-add-device'
const { currentStep, onOpenBluetooth, onBindLater } = useAddDevice()
const { currentStep, opening, onOpenBluetooth, onBindLater } = useAddDevice()
</script>
<style lang="scss" scoped>

View File

@@ -45,18 +45,21 @@ import { BIND_STEPS } from '../use-add-device'
import PairCheckList from './components/pair-check-list/index.vue'
import PairDeviceSummary from './components/pair-device-summary/index.vue'
import PairHero from './components/pair-hero/index.vue'
import { readBindDeviceContext } from '../add-device.types'
import { usePairDevice } from './use-pair-device'
const { currentStep, device, progress, etaSeconds, checkItems, onCancelPair } =
usePairDevice()
onLoad((query) => {
if (query?.name) {
device.value.name = decodeURIComponent(String(query.name))
}
if (query?.serial) {
device.value.serialMasked = decodeURIComponent(String(query.serial))
}
const ctx = readBindDeviceContext(query ?? {})
if (ctx.name) device.value.name = ctx.name
if (ctx.serialMasked) device.value.serialMasked = ctx.serialMasked
if (ctx.deviceNo) device.value.deviceNo = ctx.deviceNo
if (ctx.productId != null) device.value.productId = ctx.productId
if (ctx.model) device.value.model = ctx.model
if (ctx.imei) device.value.imei = ctx.imei
if (ctx.mac) device.value.mac = ctx.mac
})
</script>

View File

@@ -10,4 +10,9 @@ export interface PairCheckItem {
export interface PairDeviceInfo {
name: string
serialMasked: string
deviceNo: string
productId?: number
model?: string
imei?: string
mac?: string
}

View File

@@ -1,9 +1,11 @@
import { onUnmounted, ref, watch } from 'vue'
import { buildBindDeviceQuery } from '../add-device.types'
import type { PairCheckItem, PairDeviceInfo } from './pair.types'
const DEFAULT_DEVICE: PairDeviceInfo = {
name: 'Z2 空气净化器',
serialMasked: '****A3B2',
name: '',
serialMasked: '',
deviceNo: '',
}
export function usePairDevice(initial?: Partial<PairDeviceInfo>) {
@@ -45,10 +47,15 @@ export function usePairDevice(initial?: Partial<PairDeviceInfo>) {
if (value < 100 || navigated) return
navigated = true
const d = device.value
const q = [
`name=${encodeURIComponent(d.name)}`,
`serial=${encodeURIComponent(d.serialMasked)}`,
].join('&')
const q = buildBindDeviceQuery({
deviceNo: d.deviceNo,
name: d.name,
serialMasked: d.serialMasked,
productId: d.productId,
model: d.model,
imei: d.imei,
mac: d.mac,
})
setTimeout(() => {
uni.redirectTo({ url: `/pages/add-device/complete/index?${q}` })
}, 600)

View File

@@ -20,7 +20,15 @@
</text>
</view>
<view class="scan-page__list">
<view v-if="scanError" class="scan-page__empty">
<text class="scan-page__empty-text">{{ scanError }}</text>
</view>
<view v-else-if="devices.length === 0" class="scan-page__empty">
<text class="scan-page__empty-text">
{{ scanning ? '正在搜索附近蓝牙设备...' : '未发现附近设备,请确认设备已开机并靠近手机' }}
</text>
</view>
<view v-else class="scan-page__list">
<scan-device-item
v-for="item in devices"
:key="item.id"
@@ -52,6 +60,7 @@ const {
selectedId,
secondsLeft,
scanning,
scanError,
onSelectDevice,
onDeviceAction,
} = useScanDevice()

View File

@@ -76,3 +76,19 @@
.scan-page__list {
padding: 0 32rpx;
}
.scan-page__empty {
margin: 0 32rpx;
padding: 48rpx 32rpx;
background: #fff;
border-radius: 20rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.04);
}
.scan-page__empty-text {
display: block;
font-size: 26rpx;
color: #98a2b3;
line-height: 1.65;
text-align: center;
}

View File

@@ -2,6 +2,11 @@ export interface ScannedDeviceItem {
id: string
name: string
serialMasked: string
deviceNo: string
productId?: number
model?: string
imei?: string
mac?: string
/** 是否可添加(未知设备为 false 时箭头置灰) */
canAdd: boolean
}

View File

@@ -0,0 +1,37 @@
import type { ScannedDeviceItem } from './scan.types'
function normalizeMac(deviceId: string): string | undefined {
const macPattern = /^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$/
if (macPattern.test(deviceId)) return deviceId.toUpperCase()
return undefined
}
/** 扫描列表展示用:优先 MAC,否则完整 deviceId */
function formatDeviceSerial(deviceId: string): string {
return normalizeMac(deviceId) ?? deviceId
}
function buildDeviceNo(deviceId: string): string {
const mac = normalizeMac(deviceId)
if (mac) return mac.replace(/:/g, '')
const compact = deviceId.replace(/[^a-zA-Z0-9]/g, '').toUpperCase()
return compact || deviceId
}
function resolveDisplayName(device: UniApp.BluetoothDeviceInfo): string {
return (device.name || device.localName || '').trim() || '未命名设备'
}
/** 将蓝牙广播设备映射为扫描列表项(使用真实广播数据) */
export function mapBluetoothDevice(device: UniApp.BluetoothDeviceInfo): ScannedDeviceItem {
const serial = formatDeviceSerial(device.deviceId)
return {
id: device.deviceId,
name: resolveDisplayName(device),
serialMasked: serial,
deviceNo: buildDeviceNo(device.deviceId),
mac: normalizeMac(device.deviceId),
canAdd: true,
}
}

View File

@@ -1,29 +1,73 @@
import { onUnmounted, ref } from 'vue'
import { onMounted, onUnmounted, ref } from 'vue'
import {
ensureBluetoothReady,
getBluetoothErrorMessage,
getDiscoveredBluetoothDevices,
isBluetoothApiSupported,
startBluetoothDiscovery,
stopBluetoothDiscovery,
subscribeBluetoothDevicesFound,
} from '@/utils/bluetooth'
import { buildBindDeviceQuery } from '../add-device.types'
import type { ScannedDeviceItem } from './scan.types'
const MOCK_DEVICES: ScannedDeviceItem[] = [
{
id: 'z2-purifier',
name: 'Z2 空气净化器',
serialMasked: '****A3B2',
canAdd: true,
},
{
id: 'unknown',
name: '未知设备',
serialMasked: '****7C91',
canAdd: false,
},
]
import { mapBluetoothDevice } from './scan.utils'
const SCAN_SECONDS = 20
export function useScanDevice() {
const currentStep = 2
const devices = ref<ScannedDeviceItem[]>(MOCK_DEVICES)
const selectedId = ref(MOCK_DEVICES[0]?.id ?? '')
const devices = ref<ScannedDeviceItem[]>([])
const selectedId = ref('')
const secondsLeft = ref(SCAN_SECONDS)
const scanning = ref(true)
const scanError = ref('')
let unsubscribeFound: (() => void) | undefined
function upsertDevice(item: ScannedDeviceItem) {
const idx = devices.value.findIndex((d) => d.id === item.id)
if (idx >= 0) {
devices.value[idx] = item
return
}
devices.value.push(item)
if (!selectedId.value) selectedId.value = item.id
}
function ingestBluetoothDevices(found: UniApp.BluetoothDeviceInfo[]) {
for (const device of found) {
upsertDevice(mapBluetoothDevice(device))
}
}
async function beginDiscovery() {
if (!isBluetoothApiSupported()) {
scanning.value = false
scanError.value = '当前环境不支持蓝牙扫描,请使用 App 或微信小程序'
return
}
try {
await ensureBluetoothReady()
unsubscribeFound = subscribeBluetoothDevicesFound((found) => {
ingestBluetoothDevices(found)
})
const existing = await getDiscoveredBluetoothDevices()
ingestBluetoothDevices(existing)
await startBluetoothDiscovery()
} catch (err) {
scanning.value = false
scanError.value = getBluetoothErrorMessage(err)
uni.showToast({ title: scanError.value, icon: 'none' })
}
}
onMounted(() => {
void beginDiscovery()
})
const timer = setInterval(() => {
if (secondsLeft.value <= 0) {
@@ -33,11 +77,14 @@ export function useScanDevice() {
secondsLeft.value -= 1
if (secondsLeft.value === 0) {
scanning.value = false
void stopBluetoothDiscovery().catch(() => {})
}
}, 1000)
onUnmounted(() => {
clearInterval(timer)
unsubscribeFound?.()
void stopBluetoothDiscovery().catch(() => {})
})
function onSelectDevice(id: string) {
@@ -50,10 +97,15 @@ export function useScanDevice() {
uni.showToast({ title: '暂无法添加该设备', icon: 'none' })
return
}
const q = [
`name=${encodeURIComponent(device.name)}`,
`serial=${encodeURIComponent(device.serialMasked)}`,
].join('&')
const q = buildBindDeviceQuery({
deviceNo: device.deviceNo,
name: device.name,
serialMasked: device.serialMasked,
productId: device.productId,
model: device.model,
imei: device.imei,
mac: device.mac,
})
uni.navigateTo({ url: `/pages/add-device/pair/index?${q}` })
}
@@ -63,6 +115,7 @@ export function useScanDevice() {
selectedId,
secondsLeft,
scanning,
scanError,
onSelectDevice,
onDeviceAction,
}

View File

@@ -1,3 +1,5 @@
import { ref } from 'vue'
import { ensureBluetoothReady, getBluetoothErrorMessage } from '@/utils/bluetooth'
import { staticAssets } from '@/utils/static-assets'
import type { BindFeatureItem, BindStepItem } from './add-device.types'
@@ -31,9 +33,24 @@ export const BIND_FEATURES: BindFeatureItem[] = [
export function useAddDevice() {
const currentStep = 1
const opening = ref(false)
function onOpenBluetooth() {
uni.navigateTo({ url: '/pages/add-device/scan/index' })
async function onOpenBluetooth() {
if (opening.value) return
opening.value = true
try {
await ensureBluetoothReady()
uni.navigateTo({ url: '/pages/add-device/scan/index' })
} catch (err) {
uni.showModal({
title: '无法开启蓝牙',
content: getBluetoothErrorMessage(err),
showCancel: false,
confirmText: '知道了',
})
} finally {
opening.value = false
}
}
function onBindLater() {
@@ -42,6 +59,7 @@ export function useAddDevice() {
return {
currentStep,
opening,
onOpenBluetooth,
onBindLater,
}