feat:蓝牙绑定和在线客服
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
11
src/pages/add-device/complete/complete.api.ts
Normal file
11
src/pages/add-device/complete/complete.api.ts
Normal 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: {},
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
11
src/pages/add-device/complete/complete.types.ts
Normal file
11
src/pages/add-device/complete/complete.types.ts
Normal 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[]
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user