feat:蓝牙绑定和在线客服

This commit is contained in:
李伊帆
2026-10-13 16:23:43 +08:00
parent d4b02745e1
commit e22ebd6bef
128 changed files with 10429 additions and 385 deletions

View File

@@ -13,13 +13,13 @@ export interface BindFeatureItem {
/** POST /app/device/bind 请求体 */
export interface BindDeviceBody {
deviceNo: string
productId?: number
productId?: string | number
model?: string
imei?: string
mac?: string
name?: string
homeId?: number
locationId?: number
homeId?: string | number
locationId?: string | number
}
/** 绑定流程页间传递的设备信息 */
@@ -27,10 +27,11 @@ export interface BindDeviceContext {
deviceNo: string
name: string
serialMasked: string
productId?: number
productId?: string | number
model?: string
imei?: string
mac?: string
bluetoothConnected?: string
}
export function buildBindDeviceQuery(ctx: BindDeviceContext): string {
@@ -43,6 +44,9 @@ export function buildBindDeviceQuery(ctx: BindDeviceContext): string {
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)}`)
if (ctx.bluetoothConnected) {
parts.push(`bluetoothConnected=${ctx.bluetoothConnected}`)
}
return parts.join('&')
}
@@ -64,5 +68,8 @@ export function readBindDeviceContext(
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))
if (query.bluetoothConnected) {
ctx.bluetoothConnected = String(query.bluetoothConnected)
}
return ctx
}

View File

@@ -0,0 +1,11 @@
import { request } from '@/api/request'
import type { ProductOption } from './complete.types'
/** POST /app/device/productOptions — 获取产品型号下拉列表 */
export function fetchProductOptions() {
return request<ProductOption[]>({
url: '/app/device/productOptions',
method: 'POST',
data: {},
})
}

View File

@@ -95,6 +95,14 @@
font-weight: 500;
}
.complete-page__bt-status--offline .complete-page__bt-dot {
background: #e54d4d;
}
.complete-page__bt-status--offline .complete-page__bt-text {
color: #e54d4d;
}
.complete-page__form {
margin: 0 32rpx 24rpx;
padding: 8rpx 24rpx 24rpx;
@@ -138,11 +146,17 @@
flex-shrink: 0;
}
.complete-page__select-row,
.complete-page__room-row {
display: flex;
align-items: center;
height: 88rpx;
padding: 0 24rpx;
cursor: pointer;
&--hover {
opacity: 0.85;
}
background: #f7f8fa;
border-radius: 16rpx;
gap: 12rpx;
@@ -158,8 +172,13 @@
flex: 1;
font-size: 28rpx;
color: $color-text;
&--placeholder {
color: #c0c4cc;
}
}
.complete-page__select-arrow,
.complete-page__room-arrow {
width: 24rpx;
height: 24rpx;

View File

@@ -0,0 +1,11 @@
/** POST /app/device/productOptions 返回项 */
export interface ProductOption {
productId?: string | number
model?: string
name?: string
category?: string
connectTypes?: string
filterEnabled?: boolean
filterTotalDays?: number
capabilities?: string[]
}

View File

@@ -6,8 +6,12 @@
<bind-steps :steps="BIND_STEPS" :current="currentStep" />
<complete-success-hero />
<view class="complete-page__status">
<text class="complete-page__title">配对成功!</text>
<text class="complete-page__desc">请确认设备信息后完成添加</text>
<text class="complete-page__title">
{{ bluetoothConnected ? '配对成功!' : '配对未完成' }}
</text>
<text class="complete-page__desc">
{{ bluetoothConnected ? '请确认设备信息后完成添加' : '设备蓝牙未连接,请返回重新配对' }}
</text>
</view>
<view class="complete-page__device-card">
@@ -21,9 +25,12 @@
<text class="complete-page__device-serial">
设备编号:{{ serialMasked }}
</text>
<view class="complete-page__bt-status">
<view
class="complete-page__bt-status"
:class="{ 'complete-page__bt-status--offline': !bluetoothConnected }"
>
<view class="complete-page__bt-dot" />
<text class="complete-page__bt-text">蓝牙已连接</text>
<text class="complete-page__bt-text">{{ bluetoothStatusText }}</text>
</view>
</view>
</view>
@@ -46,15 +53,49 @@
/>
</view>
</view>
<view class="complete-page__field">
<text class="complete-page__label">产品型号</text>
<picker
mode="selector"
:range="productOptions"
range-key="model"
:value="productIndex"
:disabled="productOptionsLoading || !productOptions.length"
@change="onProductChange"
>
<view class="complete-page__select-row">
<text
class="complete-page__select-text"
:class="{ 'complete-page__select-text--placeholder': !productSelected }"
>
{{ productDisplayText }}
</text>
<image
class="complete-page__select-arrow"
:src="staticAssets.iconHomeArrow"
mode="aspectFit"
/>
</view>
</picker>
</view>
<view class="complete-page__field">
<text class="complete-page__label">所在房间</text>
<view class="complete-page__room-row" @tap="onPickRoom">
<view
class="complete-page__room-row"
hover-class="complete-page__room-row--hover"
@tap.stop="onPickRoom"
>
<image
class="complete-page__room-icon"
:src="staticAssets.iconHomeSelector"
mode="aspectFit"
/>
<text class="complete-page__room-text">{{ roomName }}</text>
<text
class="complete-page__room-text"
:class="{ 'complete-page__room-text--placeholder': !roomSelected }"
>
{{ roomOptionsLoading ? '加载中...' : roomName }}
</text>
<image
class="complete-page__room-arrow"
:src="staticAssets.iconHomeArrow"
@@ -75,7 +116,7 @@
<view class="complete-page__footer">
<view
class="complete-page__submit"
:class="{ 'complete-page__submit--disabled': loading }"
:class="{ 'complete-page__submit--disabled': loading || !bluetoothConnected }"
@tap="onFinishAdd"
>
<text class="complete-page__submit-text">
@@ -98,18 +139,26 @@ const {
currentStep,
deviceName,
serialMasked,
productOptions,
productIndex,
productDisplayText,
productSelected,
productOptionsLoading,
roomName,
roomSelected,
roomOptionsLoading,
loading,
applyQuery,
bluetoothConnected,
bluetoothStatusText,
initPage,
onProductChange,
onPickRoom,
onFinishAdd,
} = useCompleteDevice()
onLoad((query) => {
applyQuery(query ?? {})
void initPage(query ?? {})
})
</script>
<style lang="scss" scoped>
@import './complete.scss';
</style>
<style lang="scss" scoped src="./complete.scss"></style>

View File

@@ -1,49 +1,192 @@
import { ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { computed, ref } from 'vue'
import { resolveValidHomeId } from '@/api/home'
import { getErrorMessage } from '@/utils/api-error'
import { fetchRoomList } from '@/api/location'
import type { RoomOption } from '@/types/location'
import { fetchDeviceList } from '@/pages/device/device.api'
import { updateDeviceRuntime } from '@/pages/device/detail/device-detail.api'
import { bindDevice } from '../add-device.api'
import { parseId, sameId } from '@/utils/device-id'
import { readBindDeviceContext } from '../add-device.types'
const ROOM_OPTIONS = [
{ name: '客厅', locationId: 1 },
{ name: '主卧', locationId: 2 },
{ name: '卧室', locationId: 3 },
{ name: '厨房', locationId: 4 },
{ name: '卫生间', locationId: 5 },
] as const
import { fetchProductOptions } from './complete.api'
import type { ProductOption } from './complete.types'
export function useCompleteDevice() {
const currentStep = 4
const deviceNo = ref('')
const deviceName = ref('Z2 空气净化器')
const serialMasked = ref('****A3B2')
const productId = ref<number>()
const model = ref<string>()
const productId = ref('')
const model = ref('')
const productOptions = ref<ProductOption[]>([])
const productOptionsLoading = ref(false)
const productIndex = ref(0)
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 homeId = ref('')
const roomOptions = ref<RoomOption[]>([])
const roomOptionsLoading = ref(false)
const roomName = ref('请选择所在房间')
const locationId = ref<string | number>()
const loading = ref(false)
const initialized = ref(false)
const bluetoothConnected = ref(false)
const roomSelected = computed(() => !!locationId.value && roomName.value !== '请选择所在房间')
const productSelected = computed(() => !!productId.value && !!model.value)
const productDisplayText = computed(() => {
if (productOptionsLoading.value) return '加载中...'
if (model.value) return model.value
return '请选择产品型号'
})
const bluetoothStatusText = computed(() =>
bluetoothConnected.value ? '蓝牙已连接' : '蓝牙未连接',
)
function applyProduct(option: ProductOption) {
const parsedProductId = parseId(option.productId)
if (parsedProductId) productId.value = parsedProductId
if (option.model) model.value = option.model
const index = productOptions.value.findIndex((item) => sameId(item.productId, option.productId))
if (index >= 0) productIndex.value = index
}
function syncProductFromOptions() {
const options = productOptions.value
if (!options.length) return
if (productId.value) {
const matched = options.find((item) => sameId(item.productId, productId.value))
if (matched) {
applyProduct(matched)
return
}
}
if (model.value) {
const matched = options.find((item) => item.model === model.value)
if (matched) {
applyProduct(matched)
return
}
}
applyProduct(options[0])
}
async function loadProductOptions() {
if (productOptionsLoading.value) return
productOptionsLoading.value = true
try {
productOptions.value = await fetchProductOptions()
if (productOptions.value.length) {
syncProductFromOptions()
} else {
productId.value = ''
model.value = ''
productIndex.value = 0
}
} catch (err) {
uni.showToast({ title: getErrorMessage(err, '加载产品型号失败'), icon: 'none' })
} finally {
productOptionsLoading.value = false
}
}
function syncRoomFromOptions() {
if (locationId.value) {
const matched = roomOptions.value.find((item) =>
sameId(item.locationId, locationId.value),
)
if (matched) {
roomName.value = matched.name
return
}
}
const first = roomOptions.value[0]
if (first && !locationId.value) {
roomName.value = first.name
locationId.value = String(first.locationId)
}
}
async function loadRoomOptionList() {
if (roomOptionsLoading.value) return
roomOptionsLoading.value = true
try {
homeId.value = await resolveValidHomeId(homeId.value)
if (!homeId.value) {
uni.showToast({ title: '未找到家庭信息', icon: 'none' })
return
}
roomOptions.value = await fetchRoomList(homeId.value)
if (roomOptions.value.length) {
syncRoomFromOptions()
} else {
roomName.value = '请选择所在房间'
locationId.value = ''
}
} catch (err) {
uni.showToast({ title: getErrorMessage(err, '加载房间列表失败'), icon: 'none' })
} finally {
roomOptionsLoading.value = 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
const parsedProductId = parseId(ctx.productId)
if (parsedProductId) productId.value = parsedProductId
if (ctx.model) model.value = ctx.model
if (productOptions.value.length) {
syncProductFromOptions()
}
if (ctx.imei) imei.value = ctx.imei
if (ctx.mac) mac.value = ctx.mac
bluetoothConnected.value = ctx.bluetoothConnected === '1'
if (roomOptions.value.length) {
syncRoomFromOptions()
}
}
async function initPage(query: Record<string, string | undefined>) {
applyQuery(query)
await Promise.all([loadRoomOptionList(), loadProductOptions()])
initialized.value = true
}
function onProductChange(event: { detail: { value: number } }) {
const index = Number(event.detail.value)
const option = productOptions.value[index]
if (!option) return
applyProduct(option)
}
function onPickRoom() {
uni.showActionSheet({
itemList: ROOM_OPTIONS.map((item) => item.name),
success: (res) => {
const picked = ROOM_OPTIONS[res.tapIndex]
if (picked) {
roomName.value = picked.name
locationId.value = picked.locationId
}
if (roomOptionsLoading.value) {
uni.showToast({ title: '房间列表加载中', icon: 'none' })
return
}
if (!homeId.value) {
uni.showToast({ title: '未找到家庭信息', icon: 'none' })
return
}
const params = [
`homeId=${homeId.value}`,
...(locationId.value ? [`locationId=${locationId.value}`] : []),
`locationName=${encodeURIComponent(roomName.value)}`,
]
uni.navigateTo({
url: `/pages/device/settings/room/index?${params.join('&')}`,
events: {
roomSelected: (data: { locationId: string | number; name: string }) => {
locationId.value = String(data.locationId)
roomName.value = data.name
},
},
})
}
@@ -58,19 +201,41 @@ export function useCompleteDevice() {
uni.showToast({ title: '缺少设备编号', icon: 'none' })
return
}
if (!locationId.value) {
uni.showToast({ title: '请选择所在房间', icon: 'none' })
return
}
if (!bluetoothConnected.value) {
uni.showToast({ title: '设备未连接蓝牙,请返回重新配对', icon: 'none' })
return
}
if (loading.value) return
loading.value = true
try {
const validHomeId = await resolveValidHomeId(homeId.value)
homeId.value = validHomeId
await bindDevice({
deviceNo: deviceNo.value.trim(),
name,
...(productId.value != null ? { productId: productId.value } : {}),
...(model.value ? { model: model.value } : {}),
productId: productId.value,
model: model.value,
...(imei.value ? { imei: imei.value } : {}),
...(mac.value ? { mac: mac.value } : {}),
...(locationId.value != null ? { locationId: locationId.value } : {}),
...(validHomeId ? { homeId: validHomeId } : {}),
...(locationId.value ? { locationId: locationId.value } : {}),
})
const list = await fetchDeviceList()
const bound = list?.find((item) => item.deviceNo === deviceNo.value.trim())
if (bound?.deviceId != null) {
await updateDeviceRuntime({
deviceId: bound.deviceId,
status: bluetoothConnected.value ? '1' : '2',
bluetoothConnected: bluetoothConnected.value ? '1' : '0',
})
}
uni.showToast({ title: '添加成功', icon: 'success' })
setTimeout(() => {
uni.reLaunch({ url: '/pages/device/index' })
@@ -82,13 +247,28 @@ export function useCompleteDevice() {
}
}
onShow(() => {
if (!initialized.value || !homeId.value) return
void loadRoomOptionList()
})
return {
currentStep,
deviceName,
serialMasked,
productOptions,
productIndex,
productDisplayText,
productSelected,
productOptionsLoading,
roomName,
roomSelected,
roomOptionsLoading,
loading,
applyQuery,
bluetoothConnected,
bluetoothStatusText,
initPage,
onProductChange,
onPickRoom,
onFinishAdd,
}

View File

@@ -12,6 +12,7 @@
:class="`pair-check-list__status--${item.status}`"
>
<text v-if="item.status === 'done'" class="pair-check-list__check">✓</text>
<text v-else-if="item.status === 'failed'" class="pair-check-list__check">×</text>
</view>
<text class="pair-check-list__label">{{ item.title }}</text>
<view
@@ -82,6 +83,10 @@ defineProps<{
background: #e8eaed;
}
.pair-check-list__status--failed {
background: #e54d4d;
}
.pair-check-list__check {
font-size: 22rpx;
color: #fff;
@@ -113,6 +118,10 @@ defineProps<{
background: #f5f6f8;
}
.pair-check-list__tag--failed {
background: #fff5f5;
}
.pair-check-list__tag-text {
font-size: 24rpx;
font-weight: 500;
@@ -129,4 +138,8 @@ defineProps<{
.pair-check-list__tag--pending .pair-check-list__tag-text {
color: #98a2b3;
}
.pair-check-list__tag--failed .pair-check-list__tag-text {
color: #e54d4d;
}
</style>

View File

@@ -5,24 +5,43 @@
<text class="pair-device-summary__name">{{ device.name }}</text>
<text class="pair-device-summary__serial">设备编号:{{ device.serialMasked }}</text>
</view>
<view class="pair-device-summary__status">
<view
class="pair-device-summary__status"
:class="`pair-device-summary__status--${connectStatus}`"
>
<view class="pair-device-summary__status-dot" />
<text class="pair-device-summary__status-text">连接中</text>
<text class="pair-device-summary__status-text">{{ statusText }}</text>
</view>
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { staticAssets } from '@/utils/static-assets'
import type { PairDeviceInfo } from '../../pair.types'
import type { PairConnectStatus, PairDeviceInfo } from '../../pair.types'
withDefaults(
const props = withDefaults(
defineProps<{
device: PairDeviceInfo
connectStatus?: PairConnectStatus
thumbSrc?: string
}>(),
{ thumbSrc: staticAssets.logo },
{
connectStatus: 'connecting',
thumbSrc: staticAssets.logo,
},
)
const statusText = computed(() => {
switch (props.connectStatus) {
case 'success':
return '已连接'
case 'failed':
return '未连接'
default:
return '连接中'
}
})
</script>
<style lang="scss" scoped>
@@ -69,22 +88,55 @@ withDefaults(
align-items: center;
gap: 8rpx;
padding: 10rpx 16rpx;
background: #effbf4;
border-radius: 999rpx;
flex-shrink: 0;
}
.pair-device-summary__status--connecting {
background: #fff4e8;
}
.pair-device-summary__status--success {
background: #effbf4;
}
.pair-device-summary__status--failed {
background: #fff5f5;
}
.pair-device-summary__status-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
border: 3rpx solid $color-primary;
box-sizing: border-box;
}
.pair-device-summary__status--connecting .pair-device-summary__status-dot {
border: 3rpx solid #e67a2e;
}
.pair-device-summary__status--success .pair-device-summary__status-dot {
background: $color-primary;
}
.pair-device-summary__status--failed .pair-device-summary__status-dot {
background: #e54d4d;
}
.pair-device-summary__status-text {
font-size: 24rpx;
color: $color-primary;
font-weight: 500;
}
.pair-device-summary__status--connecting .pair-device-summary__status-text {
color: #e67a2e;
}
.pair-device-summary__status--success .pair-device-summary__status-text {
color: $color-primary;
}
.pair-device-summary__status--failed .pair-device-summary__status-text {
color: #e54d4d;
}
</style>

View File

@@ -6,13 +6,15 @@
<bind-steps :steps="BIND_STEPS" :current="currentStep" />
<pair-hero />
<view class="pair-page__status">
<text class="pair-page__title">配对中...</text>
<text class="pair-page__desc">正在与设备建立安全连接,请保持手机靠近</text>
<text class="pair-page__title">{{ pageTitle }}</text>
<text class="pair-page__desc">{{ pageDesc }}</text>
</view>
<view class="pair-page__progress-block">
<view v-if="connectStatus !== 'failed'" class="pair-page__progress-block">
<view class="pair-page__progress-head">
<text class="pair-page__progress-label">正在同步设备配置...</text>
<text class="pair-page__progress-label">
{{ connectStatus === 'success' ? '蓝牙连接成功' : '正在建立蓝牙连接...' }}
</text>
<text class="pair-page__progress-percent">{{ progress }}%</text>
</view>
<view class="pair-page__progress-track">
@@ -21,45 +23,101 @@
:style="{ width: `${progress}%` }"
/>
</view>
<text class="pair-page__progress-tip">
{{ progress >= 100 ? '配置同步完成' : `预计还需约 ${etaSeconds} 秒,请勿离开当前页面` }}
</text>
<text class="pair-page__progress-tip">{{ progressTip }}</text>
</view>
<view v-else class="pair-page__error-block">
<text class="pair-page__error-text">{{ errorMessage }}</text>
</view>
<pair-check-list :items="checkItems" />
<pair-device-summary :device="device" />
<pair-device-summary :device="device" :connect-status="connectStatus" />
</view>
</scroll-view>
<view class="pair-page__footer">
<view class="pair-page__cancel" @tap="onCancelPair">
<text class="pair-page__cancel-text">取消配对</text>
<view
v-if="connectStatus === 'failed'"
class="pair-page__retry"
@tap="onRetryPair"
>
<text class="pair-page__retry-text">重新连接</text>
</view>
<view class="pair-page__cancel" @tap="onFooterCancel">
<text class="pair-page__cancel-text">
{{ connectStatus === 'failed' ? '返回' : '取消配对' }}
</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import BindSteps from '../components/bind-steps/index.vue'
import { BIND_STEPS } from '../use-add-device'
import { readBindDeviceContext } from '../add-device.types'
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()
const {
currentStep,
device,
connectStatus,
progress,
errorMessage,
checkItems,
applyDevice,
startPairing,
onCancelPair,
onRetryPair,
} = usePairDevice()
const pageTitle = computed(() => {
if (connectStatus.value === 'failed') return '连接失败'
if (connectStatus.value === 'success') return '连接成功'
return '配对中...'
})
const pageDesc = computed(() => {
if (connectStatus.value === 'failed') {
return '请确认设备已开机、手机蓝牙已开启,并靠近设备后重试'
}
if (connectStatus.value === 'success') {
return '蓝牙连接已建立,即将进入下一步'
}
return '正在与设备建立安全连接,请保持手机靠近'
})
const progressTip = computed(() => {
if (connectStatus.value === 'success') return '连接完成,正在跳转...'
if (progress.value >= 70) return '正在连接设备,请稍候...'
if (progress.value >= 30) return '正在搜索附近设备...'
return '正在初始化蓝牙,请勿离开当前页面'
})
function onFooterCancel() {
if (connectStatus.value === 'failed') {
uni.navigateBack()
return
}
onCancelPair()
}
onLoad((query) => {
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
applyDevice({
name: ctx.name ?? '',
serialMasked: ctx.serialMasked ?? '',
deviceNo: ctx.deviceNo ?? '',
productId: ctx.productId,
model: ctx.model,
imei: ctx.imei,
mac: ctx.mac,
})
void startPairing()
})
</script>

View File

@@ -87,6 +87,36 @@
line-height: 1.5;
}
.pair-page__error-block {
margin: 0 32rpx 24rpx;
padding: 24rpx;
background: #fff5f5;
border-radius: 20rpx;
border: 1rpx solid #ffd6d6;
}
.pair-page__error-text {
font-size: 26rpx;
color: #e54d4d;
line-height: 1.65;
}
.pair-page__retry {
margin-bottom: 16rpx;
height: 88rpx;
border-radius: 44rpx;
background: $color-primary;
display: flex;
align-items: center;
justify-content: center;
}
.pair-page__retry-text {
font-size: 30rpx;
font-weight: 600;
color: #fff;
}
.pair-page__footer {
padding: 16rpx 32rpx calc(24rpx + env(safe-area-inset-bottom));
background: $color-bg-page;

View File

@@ -1,4 +1,6 @@
export type PairCheckStatus = 'done' | 'syncing' | 'pending'
export type PairCheckStatus = 'done' | 'syncing' | 'pending' | 'failed'
export type PairConnectStatus = 'connecting' | 'success' | 'failed'
export interface PairCheckItem {
id: string

View File

@@ -1,6 +1,14 @@
import { onUnmounted, ref, watch } from 'vue'
import { onUnmounted, ref } from 'vue'
import {
ensureBleConnection,
ensureBluetoothReady,
getBluetoothErrorMessage,
isBluetoothApiSupported,
scanForBleDeviceByMac,
} from '@/utils/bluetooth'
import { queryZ2BleState } from '@/utils/z2-ble'
import { buildBindDeviceQuery } from '../add-device.types'
import type { PairCheckItem, PairDeviceInfo } from './pair.types'
import type { PairCheckItem, PairConnectStatus, PairDeviceInfo } from './pair.types'
const DEFAULT_DEVICE: PairDeviceInfo = {
name: '',
@@ -8,61 +16,136 @@ const DEFAULT_DEVICE: PairDeviceInfo = {
deviceNo: '',
}
export function usePairDevice(initial?: Partial<PairDeviceInfo>) {
const currentStep = 3
const device = ref<PairDeviceInfo>({
...DEFAULT_DEVICE,
...initial,
})
const SCAN_TIMEOUT_MS = 15000
const progress = ref(65)
const etaSeconds = ref(15)
export function usePairDevice() {
const currentStep = 3
const device = ref<PairDeviceInfo>({ ...DEFAULT_DEVICE })
const connectStatus = ref<PairConnectStatus>('connecting')
const progress = ref(0)
const errorMessage = ref('')
const checkItems = ref<PairCheckItem[]>([
{ id: 'signal', title: '蓝牙信号强度', status: 'done', tag: '良好' },
{ id: 'credential', title: '设备凭证校验', status: 'done', tag: '通过' },
{ id: 'network', title: '网络配置下发', status: 'syncing', tag: '同步中' },
{ id: 'signal', title: '蓝牙信号强度', status: 'pending', tag: '待检测' },
{ id: 'credential', title: '设备凭证校验', status: 'pending', tag: '待校验' },
{ id: 'network', title: '蓝牙连接建立', status: 'pending', tag: '待连接' },
])
let etaTick = 0
const tick = setInterval(() => {
if (progress.value < 100) {
progress.value = Math.min(100, progress.value + 1)
}
etaTick += 1
if (etaTick % 2 === 0 && etaSeconds.value > 0 && progress.value < 100) {
etaSeconds.value = Math.max(0, etaSeconds.value - 1)
}
if (progress.value >= 100) {
checkItems.value = checkItems.value.map((item) =>
item.id === 'network'
? { ...item, status: 'done', tag: '完成' }
: item,
)
}
}, 500)
let pairing = false
let bleDeviceId: string | undefined
let navigated = false
function updateCheck(id: string, status: PairCheckItem['status'], tag: string) {
checkItems.value = checkItems.value.map((item) =>
item.id === id ? { ...item, status, tag } : item,
)
}
watch(progress, (value) => {
if (value < 100 || navigated) return
navigated = true
const d = device.value
const q = buildBindDeviceQuery({
deviceNo: d.deviceNo,
name: d.name,
serialMasked: d.serialMasked,
productId: d.productId,
model: d.model,
imei: d.imei,
mac: d.mac,
function markFailedFromStep(stepId: string, message: string) {
const order = ['signal', 'credential', 'network']
const failedIndex = order.indexOf(stepId)
checkItems.value = checkItems.value.map((item) => {
const idx = order.indexOf(item.id)
if (idx < failedIndex && item.status === 'done') return item
if (item.id === stepId) return { ...item, status: 'failed', tag: '失败' }
if (idx > failedIndex) return { ...item, status: 'pending', tag: '未开始' }
return item
})
setTimeout(() => {
uni.redirectTo({ url: `/pages/add-device/complete/index?${q}` })
}, 600)
})
connectStatus.value = 'failed'
errorMessage.value = message
progress.value = Math.max(progress.value, 30)
}
async function startPairing() {
if (pairing) return
pairing = true
connectStatus.value = 'connecting'
errorMessage.value = ''
progress.value = 5
checkItems.value = [
{ id: 'signal', title: '蓝牙信号强度', status: 'syncing', tag: '检测中' },
{ id: 'credential', title: '设备凭证校验', status: 'pending', tag: '待校验' },
{ id: 'network', title: '蓝牙连接建立', status: 'pending', tag: '待连接' },
]
try {
if (!isBluetoothApiSupported()) {
throw new Error('当前环境不支持蓝牙,请使用 App 或微信小程序')
}
await ensureBluetoothReady()
updateCheck('signal', 'done', '良好')
progress.value = 30
const mac = device.value.mac?.trim()
const name = device.value.name?.trim()
if (!mac && !name) {
throw new Error('缺少设备 MAC 或名称,无法建立蓝牙连接')
}
updateCheck('credential', 'syncing', '扫描中')
progress.value = 45
const found = await scanForBleDeviceByMac(mac ?? '', {
timeoutMs: SCAN_TIMEOUT_MS,
name,
})
if (!found) {
markFailedFromStep('credential', '未找到设备,请确认已开机并靠近手机')
return
}
updateCheck('credential', 'done', '通过')
progress.value = 70
updateCheck('network', 'syncing', '连接中')
await ensureBleConnection(found.deviceId)
bleDeviceId = found.deviceId
try {
await queryZ2BleState(found.deviceId)
} catch {
// 连接成功即可继续
}
updateCheck('network', 'done', '已连接')
progress.value = 100
connectStatus.value = 'success'
const d = device.value
const q = buildBindDeviceQuery({
deviceNo: d.deviceNo,
name: d.name,
serialMasked: d.serialMasked,
productId: d.productId,
model: d.model,
imei: d.imei,
mac: d.mac,
bluetoothConnected: '1',
})
setTimeout(() => {
uni.redirectTo({ url: `/pages/add-device/complete/index?${q}` })
}, 500)
} catch (err) {
const message = getBluetoothErrorMessage(err)
const failedStep = progress.value < 30
? 'signal'
: progress.value < 70
? 'credential'
: 'network'
markFailedFromStep(failedStep, message)
uni.showToast({ title: message, icon: 'none' })
} finally {
pairing = false
}
}
function applyDevice(partial?: Partial<PairDeviceInfo>) {
device.value = { ...device.value, ...partial }
}
onUnmounted(() => {
clearInterval(tick)
bleDeviceId = undefined
})
function onCancelPair() {
@@ -77,12 +160,20 @@ export function usePairDevice(initial?: Partial<PairDeviceInfo>) {
})
}
function onRetryPair() {
void startPairing()
}
return {
currentStep,
device,
connectStatus,
progress,
etaSeconds,
errorMessage,
checkItems,
applyDevice,
startPairing,
onCancelPair,
onRetryPair,
}
}