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

@@ -1,4 +1,3 @@
VITE_API_BASE_URL=http://192.168.0.222:8082 VITE_API_BASE_URL=http://192.168.0.222:8082
VITE_API_PROXY_TARGET=http://192.168.0.222:8082 VITE_API_PROXY_TARGET=http://192.168.0.222:8082
VITE_AGREEMENT_ID_SERVICE=0 VITE_API_CLIENT_ID=app
VITE_AGREEMENT_ID_PRIVACY=0

View File

@@ -4,6 +4,9 @@ VITE_API_BASE_URL=http://192.168.0.222:8082
# H5 开发:BASE_URL 留空时可走 vite 代理 /app # H5 开发:BASE_URL 留空时可走 vite 代理 /app
VITE_API_PROXY_TARGET=http://192.168.0.222:8082 VITE_API_PROXY_TARGET=http://192.168.0.222:8082
# 协议详情 agreementId(POST /app/auth/agreement/detail) # RuoYi-Vue-Plus App 端 clientId(请求头 clientId)
VITE_AGREEMENT_ID_SERVICE=0 VITE_API_CLIENT_ID=app
VITE_AGREEMENT_ID_PRIVACY=0
# 可选:配置后详情页会通过 BLE 特征值读取设备运行数据(JSON)
# VITE_BLE_SERVICE_UUID=
# VITE_BLE_RUNTIME_CHARACTERISTIC_UUID=

14
.hbuilderx/launch.json Normal file
View File

@@ -0,0 +1,14 @@
{
"version" : "1.0",
"configurations" : [
{
"customPlaygroundType" : "device",
"playground" : "standard",
"type" : "uni-app:app-ios"
},
{
"playground" : "standard",
"type" : "uni-app:app-android"
}
]
}

1
.openapi-cache.json Normal file

File diff suppressed because one or more lines are too long

0
.tmp-openapi-out.txt Normal file
View File

60
package-lock.json generated
View File

@@ -4618,9 +4618,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4787,9 +4784,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4810,9 +4804,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4833,9 +4824,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4856,9 +4844,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4879,9 +4864,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4902,9 +4884,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5078,9 +5057,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5094,9 +5070,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5110,9 +5083,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5126,9 +5096,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5142,9 +5109,6 @@
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5158,9 +5122,6 @@
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5174,9 +5135,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5190,9 +5148,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5206,9 +5161,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5222,9 +5174,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5238,9 +5187,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5254,9 +5200,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5270,9 +5213,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [

24
project.config.json Normal file
View File

@@ -0,0 +1,24 @@
{
"setting": {
"es6": true,
"postcss": true,
"minified": true,
"uglifyFileName": false,
"enhance": true,
"packNpmRelationList": [],
"babelSetting": {
"ignore": [],
"disablePlugins": [],
"outputPath": ""
},
"useCompilerPlugins": false
},
"compileType": "miniprogram",
"simulatorPluginLibVersion": {},
"packOptions": {
"ignore": [],
"include": []
},
"appid": "wxde0794b85ed16144",
"editorSetting": {}
}

View File

@@ -0,0 +1,15 @@
{
"libVersion": "3.17.4",
"projectname": "czzj-app",
"setting": {
"urlCheck": true,
"coverView": true,
"lazyloadPlaceholderEnable": false,
"skylineRenderEnable": false,
"preloadBackgroundData": false,
"autoAudits": false,
"showShadowRootInWxmlPanel": true,
"compileHotReLoad": true,
"useGlassEaselForWxml": false
}
}

View File

@@ -1,6 +1,7 @@
import { request } from './request' import { request } from './request'
import type { AuthTokenResult, SendSmsCodeBody, SmsCodeScene } from '@/types/api' import type { AuthTokenResult, SendSmsCodeBody, SmsCodeScene } from '@/types/api'
import { ApiError } from '@/utils/api-error' import { ApiError } from '@/utils/api-error'
import { extractAuthToken, saveStoredToken } from '@/utils/auth-token'
/** POST /app/auth/sendSmsCode */ /** POST /app/auth/sendSmsCode */
export function sendSmsCode(phone: string, scene: SmsCodeScene) { export function sendSmsCode(phone: string, scene: SmsCodeScene) {
@@ -15,11 +16,10 @@ export function sendSmsCode(phone: string, scene: SmsCodeScene) {
}) })
} }
export function saveAuthToken(data: AuthTokenResult) { export function saveAuthToken(data: AuthTokenResult | unknown) {
const token = data?.token ?? data?.accessToken const token = extractAuthToken(data)
if (!token) { if (!token) {
throw new ApiError('登录失败,未获取到令牌') throw new ApiError('登录失败,未获取到令牌')
} }
uni.setStorageSync('token', token) return saveStoredToken(token)
return token
} }

View File

@@ -1,25 +1,32 @@
import type { ApiResponse } from '@/types/api' import type { ApiResponse } from '@/types/api'
import { extractAuthToken, getStoredToken, saveStoredToken } from '@/utils/auth-token'
import { ApiError, isApiSuccess, unwrapApiData } from '@/utils/api-error' import { ApiError, isApiSuccess, unwrapApiData } from '@/utils/api-error'
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '' const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? ''
/** RuoYi-Vue-Plus App 端 clientId(请求头 clientid,与 Postman 一致) */
export const API_CLIENT_ID = import.meta.env.VITE_API_CLIENT_ID || 'app'
export interface RequestOptions extends Omit<UniApp.RequestOptions, 'url'> { export interface RequestOptions extends Omit<UniApp.RequestOptions, 'url'> {
url: string url: string
} }
export function request<T = unknown>(options: RequestOptions): Promise<T> { /** 构建鉴权请求头:clientid + Authorization */
const token = uni.getStorageSync('token') as string export function buildAuthHeaders(extra?: Record<string, unknown>): Record<string, string> {
const token = getStoredToken()
return new Promise((resolve, reject) => { return {
uni.request({ 'Content-Type': 'application/json;charset=UTF-8',
...options, clientId: API_CLIENT_ID,
url: `${BASE_URL}${options.url}`,
header: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.header, ...(extra as Record<string, string> | undefined),
}, }
success: (res) => { }
function handleSuccess<T>(
res: UniApp.RequestSuccessCallbackResult,
resolve: (value: T) => void,
reject: (reason: ApiError) => void,
saveTokenFromBody = false,
) {
if (res.statusCode < 200 || res.statusCode >= 300) { if (res.statusCode < 200 || res.statusCode >= 300) {
const body = res.data as ApiResponse | undefined const body = res.data as ApiResponse | undefined
reject( reject(
@@ -33,14 +40,40 @@ export function request<T = unknown>(options: RequestOptions): Promise<T> {
const body = (res.data ?? {}) as ApiResponse<T> & Record<string, unknown> const body = (res.data ?? {}) as ApiResponse<T> & Record<string, unknown>
if (!isApiSuccess(body)) { if (!isApiSuccess(body)) {
reject( reject(new ApiError(body.msg || body.message || '请求失败', body.code))
new ApiError(body.msg || body.message || '请求失败', body.code),
)
return return
} }
if (saveTokenFromBody) {
const token = extractAuthToken(body)
if (token) saveStoredToken(token)
}
resolve(unwrapApiData<T>(body)) resolve(unwrapApiData<T>(body))
}, }
export function request<T = unknown>(options: RequestOptions): Promise<T> {
return new Promise((resolve, reject) => {
uni.request({
...options,
url: `${BASE_URL}${options.url}`,
header: buildAuthHeaders(options.header),
success: (res) => handleSuccess<T>(res, resolve, reject),
fail: (err) => {
reject(new ApiError(err.errMsg || '网络异常'))
},
})
})
}
/** 登录类接口:从完整响应体提取并保存 token */
export function requestLogin<T = unknown>(options: RequestOptions): Promise<T> {
return new Promise((resolve, reject) => {
uni.request({
...options,
url: `${BASE_URL}${options.url}`,
header: buildAuthHeaders(options.header),
success: (res) => handleSuccess<T>(res, resolve, reject, true),
fail: (err) => { fail: (err) => {
reject(new ApiError(err.errMsg || '网络异常')) reject(new ApiError(err.errMsg || '网络异常'))
}, },

View File

@@ -49,7 +49,8 @@ const routes: Record<TabKey, string> = {
} }
function resolveTabFromRoute(): TabKey { function resolveTabFromRoute(): TabKey {
const route = getCurrentPages().at(-1)?.route ?? '' const pages = getCurrentPages()
const route = pages.length > 0 ? pages[pages.length - 1].route ?? '' : ''
return route.includes('mine') ? 'mine' : 'device' return route.includes('mine') ? 'mine' : 'device'
} }

View File

@@ -23,6 +23,12 @@
/* android打包配置 */ /* android打包配置 */
"android" : { "android" : {
"permissions" : [ "permissions" : [
"<uses-permission android:name=\"android.permission.BLUETOOTH\"/>",
"<uses-permission android:name=\"android.permission.BLUETOOTH_ADMIN\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_FINE_LOCATION\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_COARSE_LOCATION\"/>",
"<uses-permission android:name=\"android.permission.BLUETOOTH_SCAN\"/>",
"<uses-permission android:name=\"android.permission.BLUETOOTH_CONNECT\"/>",
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>", "<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>", "<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
"<uses-permission android:name=\"android.permission.VIBRATE\"/>", "<uses-permission android:name=\"android.permission.VIBRATE\"/>",
@@ -54,7 +60,17 @@
"setting" : { "setting" : {
"urlCheck" : false "urlCheck" : false
}, },
"usingComponents" : true "usingComponents" : true,
"permission" : {
"scope.bluetooth" : {
"desc" : "你的蓝牙将用于发现和连接智能设备"
}
},
"requiredPrivateInfos" : [
"getBluetoothDevices",
"onBluetoothDeviceFound",
"startBluetoothDevicesDiscovery"
]
}, },
"mp-alipay" : { "mp-alipay" : {
"usingComponents" : true "usingComponents" : true

View File

@@ -20,6 +20,13 @@
"navigationBarTitleText": "设备" "navigationBarTitleText": "设备"
} }
}, },
{
"path": "pages/device/detail/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "设备详情"
}
},
{ {
"path": "pages/add-device/index", "path": "pages/add-device/index",
"style": { "style": {

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%); background: linear-gradient(98deg, #45d685 0%, #21c25e 100%);
box-shadow: 0 8rpx 28rpx rgba(33, 194, 94, 0.28); box-shadow: 0 8rpx 28rpx rgba(33, 194, 94, 0.28);
margin-bottom: 28rpx; margin-bottom: 28rpx;
&--disabled {
opacity: 0.65;
pointer-events: none;
}
} }
.add-device-page__btn-text { .add-device-page__btn-text {

View File

@@ -9,3 +9,60 @@ export interface BindFeatureItem {
desc: string desc: string
icon: 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; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
&--disabled {
opacity: 0.65;
pointer-events: none;
}
} }
.complete-page__submit-text { .complete-page__submit-text {

View File

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

View File

@@ -1,34 +1,85 @@
import { ref } from 'vue' 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() { export function useCompleteDevice() {
const currentStep = 4 const currentStep = 4
const deviceNo = ref('')
const deviceName = ref('Z2 空气净化器') const deviceName = ref('Z2 空气净化器')
const serialMasked = ref('****A3B2') const serialMasked = ref('****A3B2')
const roomName = ref('客厅') const productId = ref<number>()
const roomOptions = ROOM_OPTIONS 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() { function onPickRoom() {
uni.showActionSheet({ uni.showActionSheet({
itemList: roomOptions, itemList: ROOM_OPTIONS.map((item) => item.name),
success: (res) => { success: (res) => {
const picked = roomOptions[res.tapIndex] const picked = ROOM_OPTIONS[res.tapIndex]
if (picked) roomName.value = picked if (picked) {
roomName.value = picked.name
locationId.value = picked.locationId
}
}, },
}) })
} }
function onFinishAdd() { async function onFinishAdd() {
const name = deviceName.value.trim() const name = deviceName.value.trim()
if (!name) { if (!name) {
uni.showToast({ title: '请输入设备名称', icon: 'none' }) uni.showToast({ title: '请输入设备名称', icon: 'none' })
return return
} }
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' }) uni.showToast({ title: '添加成功', icon: 'success' })
setTimeout(() => { setTimeout(() => {
uni.reLaunch({ url: '/pages/device/index' }) uni.reLaunch({ url: '/pages/device/index' })
}, 400) }, 400)
} catch (err) {
uni.showToast({ title: getErrorMessage(err, '绑定失败,请重试'), icon: 'none' })
} finally {
loading.value = false
}
} }
return { return {
@@ -36,6 +87,8 @@ export function useCompleteDevice() {
deviceName, deviceName,
serialMasked, serialMasked,
roomName, roomName,
loading,
applyQuery,
onPickRoom, onPickRoom,
onFinishAdd, onFinishAdd,
} }

View File

@@ -11,8 +11,14 @@
</text> </text>
<bind-feature-list :items="BIND_FEATURES" /> <bind-feature-list :items="BIND_FEATURES" />
<view class="add-device-page__footer"> <view class="add-device-page__footer">
<view class="add-device-page__btn" @tap="onOpenBluetooth"> <view
<text class="add-device-page__btn-text">去开启蓝牙</text> 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>
<view class="add-device-page__later-wrap" @tap="onBindLater"> <view class="add-device-page__later-wrap" @tap="onBindLater">
<text class="add-device-page__later">稍后绑定</text> <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 BindSteps from './components/bind-steps/index.vue'
import { BIND_FEATURES, BIND_STEPS, useAddDevice } from './use-add-device' import { BIND_FEATURES, BIND_STEPS, useAddDevice } from './use-add-device'
const { currentStep, onOpenBluetooth, onBindLater } = useAddDevice() const { currentStep, opening, onOpenBluetooth, onBindLater } = useAddDevice()
</script> </script>
<style lang="scss" scoped> <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 PairCheckList from './components/pair-check-list/index.vue'
import PairDeviceSummary from './components/pair-device-summary/index.vue' import PairDeviceSummary from './components/pair-device-summary/index.vue'
import PairHero from './components/pair-hero/index.vue' import PairHero from './components/pair-hero/index.vue'
import { readBindDeviceContext } from '../add-device.types'
import { usePairDevice } from './use-pair-device' import { usePairDevice } from './use-pair-device'
const { currentStep, device, progress, etaSeconds, checkItems, onCancelPair } = const { currentStep, device, progress, etaSeconds, checkItems, onCancelPair } =
usePairDevice() usePairDevice()
onLoad((query) => { onLoad((query) => {
if (query?.name) { const ctx = readBindDeviceContext(query ?? {})
device.value.name = decodeURIComponent(String(query.name)) if (ctx.name) device.value.name = ctx.name
} if (ctx.serialMasked) device.value.serialMasked = ctx.serialMasked
if (query?.serial) { if (ctx.deviceNo) device.value.deviceNo = ctx.deviceNo
device.value.serialMasked = decodeURIComponent(String(query.serial)) 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> </script>

View File

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

View File

@@ -20,7 +20,15 @@
</text> </text>
</view> </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 <scan-device-item
v-for="item in devices" v-for="item in devices"
:key="item.id" :key="item.id"
@@ -52,6 +60,7 @@ const {
selectedId, selectedId,
secondsLeft, secondsLeft,
scanning, scanning,
scanError,
onSelectDevice, onSelectDevice,
onDeviceAction, onDeviceAction,
} = useScanDevice() } = useScanDevice()

View File

@@ -76,3 +76,19 @@
.scan-page__list { .scan-page__list {
padding: 0 32rpx; 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 id: string
name: string name: string
serialMasked: string serialMasked: string
deviceNo: string
productId?: number
model?: string
imei?: string
mac?: string
/** 是否可添加(未知设备为 false 时箭头置灰) */ /** 是否可添加(未知设备为 false 时箭头置灰) */
canAdd: boolean 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' import type { ScannedDeviceItem } from './scan.types'
import { mapBluetoothDevice } from './scan.utils'
const MOCK_DEVICES: ScannedDeviceItem[] = [
{
id: 'z2-purifier',
name: 'Z2 空气净化器',
serialMasked: '****A3B2',
canAdd: true,
},
{
id: 'unknown',
name: '未知设备',
serialMasked: '****7C91',
canAdd: false,
},
]
const SCAN_SECONDS = 20 const SCAN_SECONDS = 20
export function useScanDevice() { export function useScanDevice() {
const currentStep = 2 const currentStep = 2
const devices = ref<ScannedDeviceItem[]>(MOCK_DEVICES) const devices = ref<ScannedDeviceItem[]>([])
const selectedId = ref(MOCK_DEVICES[0]?.id ?? '') const selectedId = ref('')
const secondsLeft = ref(SCAN_SECONDS) const secondsLeft = ref(SCAN_SECONDS)
const scanning = ref(true) 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(() => { const timer = setInterval(() => {
if (secondsLeft.value <= 0) { if (secondsLeft.value <= 0) {
@@ -33,11 +77,14 @@ export function useScanDevice() {
secondsLeft.value -= 1 secondsLeft.value -= 1
if (secondsLeft.value === 0) { if (secondsLeft.value === 0) {
scanning.value = false scanning.value = false
void stopBluetoothDiscovery().catch(() => {})
} }
}, 1000) }, 1000)
onUnmounted(() => { onUnmounted(() => {
clearInterval(timer) clearInterval(timer)
unsubscribeFound?.()
void stopBluetoothDiscovery().catch(() => {})
}) })
function onSelectDevice(id: string) { function onSelectDevice(id: string) {
@@ -50,10 +97,15 @@ export function useScanDevice() {
uni.showToast({ title: '暂无法添加该设备', icon: 'none' }) uni.showToast({ title: '暂无法添加该设备', icon: 'none' })
return return
} }
const q = [ const q = buildBindDeviceQuery({
`name=${encodeURIComponent(device.name)}`, deviceNo: device.deviceNo,
`serial=${encodeURIComponent(device.serialMasked)}`, name: device.name,
].join('&') serialMasked: device.serialMasked,
productId: device.productId,
model: device.model,
imei: device.imei,
mac: device.mac,
})
uni.navigateTo({ url: `/pages/add-device/pair/index?${q}` }) uni.navigateTo({ url: `/pages/add-device/pair/index?${q}` })
} }
@@ -63,6 +115,7 @@ export function useScanDevice() {
selectedId, selectedId,
secondsLeft, secondsLeft,
scanning, scanning,
scanError,
onSelectDevice, onSelectDevice,
onDeviceAction, onDeviceAction,
} }

View File

@@ -1,3 +1,5 @@
import { ref } from 'vue'
import { ensureBluetoothReady, getBluetoothErrorMessage } from '@/utils/bluetooth'
import { staticAssets } from '@/utils/static-assets' import { staticAssets } from '@/utils/static-assets'
import type { BindFeatureItem, BindStepItem } from './add-device.types' import type { BindFeatureItem, BindStepItem } from './add-device.types'
@@ -31,9 +33,24 @@ export const BIND_FEATURES: BindFeatureItem[] = [
export function useAddDevice() { export function useAddDevice() {
const currentStep = 1 const currentStep = 1
const opening = ref(false)
function onOpenBluetooth() { async function onOpenBluetooth() {
if (opening.value) return
opening.value = true
try {
await ensureBluetoothReady()
uni.navigateTo({ url: '/pages/add-device/scan/index' }) 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() { function onBindLater() {
@@ -42,6 +59,7 @@ export function useAddDevice() {
return { return {
currentStep, currentStep,
opening,
onOpenBluetooth, onOpenBluetooth,
onBindLater, onBindLater,
} }

View File

@@ -1,5 +1,14 @@
import { request } from '@/api/request' import { request } from '@/api/request'
import type { AgreementDetail } from './agreement.types' import type { AgreementDetail, AgreementVo } from './agreement.types'
/** POST /app/auth/agreements — 获取生效的协议配置列表 */
export function fetchActiveAgreements() {
return request<AgreementVo[]>({
url: '/app/auth/agreements',
method: 'POST',
data: {},
})
}
/** POST /app/auth/agreement/detail — 协议详情(body: agreementId) */ /** POST /app/auth/agreement/detail — 协议详情(body: agreementId) */
export function fetchAgreementDetail(agreementId: number) { export function fetchAgreementDetail(agreementId: number) {

View File

@@ -1,25 +1,32 @@
import type { AgreementType } from './agreement.types' import { fetchActiveAgreements } from './agreement.api'
import type { AgreementType, AgreementVo } from './agreement.types'
function parseEnvId(value: string | undefined): number | undefined { let cachedAgreements: AgreementVo[] | null = null
if (value === undefined || value === '') return undefined
const n = Number(value) function matchesAgreementType(apiType: string | undefined, type: AgreementType): boolean {
return Number.isFinite(n) && n >= 0 ? n : undefined if (!apiType) return false
const normalized = apiType.toLowerCase()
if (type === 'privacy') return normalized === 'privacy'
return normalized === 'service' || normalized === 'user'
} }
/** 与后端协议主键对应,可在 .env 中配置 */ /** 拉取并缓存生效协议列表 */
export function resolveAgreementId(type: AgreementType): number { export async function loadActiveAgreements(): Promise<AgreementVo[]> {
const fromEnv = if (cachedAgreements) return cachedAgreements
type === 'service' const list = await fetchActiveAgreements()
? parseEnvId(import.meta.env.VITE_AGREEMENT_ID_SERVICE) cachedAgreements = list ?? []
: parseEnvId(import.meta.env.VITE_AGREEMENT_ID_PRIVACY) return cachedAgreements
}
if (fromEnv !== undefined) return fromEnv /** 按协议类型从生效列表中解析 agreementId */
export async function resolveAgreementId(type: AgreementType): Promise<number | undefined> {
return 0 const list = await loadActiveAgreements()
const match = list.find((item) => matchesAgreementType(item.agreementType, type))
return match?.agreementId
} }
export function parseAgreementIdFromQuery(raw: unknown): number | undefined { export function parseAgreementIdFromQuery(raw: unknown): number | undefined {
if (raw === undefined || raw === null || raw === '') return undefined if (raw === undefined || raw === null || raw === '') return undefined
const n = Number(raw) const n = Number(raw)
return Number.isFinite(n) && n >= 0 ? n : undefined return Number.isFinite(n) && n > 0 ? n : undefined
} }

View File

@@ -1,5 +1,14 @@
export type AgreementType = 'service' | 'privacy' export type AgreementType = 'service' | 'privacy'
/** POST /app/auth/agreements 返回项 */
export interface AgreementVo {
agreementId?: number
agreementType?: string
agreementNo?: string
versionName?: string
name?: string
}
/** /app/auth/agreement/detail 返回 data 结构 */ /** /app/auth/agreement/detail 返回 data 结构 */
export interface AgreementDetail { export interface AgreementDetail {
agreementNo?: string agreementNo?: string

View File

@@ -26,7 +26,12 @@ export function useAgreement() {
loading.value = true loading.value = true
title.value = DEFAULT_TITLE[agreementType] title.value = DEFAULT_TITLE[agreementType]
content.value = '' content.value = ''
const id = agreementId ?? resolveAgreementId(agreementType) const id = agreementId ?? await resolveAgreementId(agreementType)
if (!id) {
uni.showToast({ title: '未找到生效的协议', icon: 'none' })
loading.value = false
return
}
try { try {
const data = await fetchAgreementDetail(id) const data = await fetchAgreementDetail(id)
const normalized = normalizeDetail(agreementType, data ?? {}) const normalized = normalizeDetail(agreementType, data ?? {})

View File

@@ -0,0 +1,147 @@
<template>
<view class="device-card" @tap="$emit('tap', device)">
<view class="device-card__visual">
<image
class="device-card__img"
:src="staticAssets.logo"
mode="aspectFill"
/>
<view
v-if="device.isCharging"
class="device-card__badge device-card__badge--charge"
>
<text class="device-card__charge-icon">☀</text>
</view>
<view
v-else
class="device-card__badge"
:class="{ 'device-card__badge--offline': !device.isOnline }"
>
<view class="device-card__dot" />
<text class="device-card__badge-text">
{{ device.isOnline ? '在线' : '离线' }}
</text>
</view>
</view>
<view class="device-card__info">
<text class="device-card__name">{{ device.name || '未命名设备' }}</text>
<view class="device-card__room">
<view class="device-card__room-dot" />
<text class="device-card__room-text">{{ device.locationName }}</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { staticAssets } from '@/utils/static-assets'
import type { DeviceListItem } from '../../device.types'
defineProps<{
device: DeviceListItem
}>()
defineEmits<{
tap: [device: DeviceListItem]
}>()
</script>
<style lang="scss" scoped>
.device-card {
background: #fff;
border-radius: 24rpx;
overflow: hidden;
box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.04);
}
.device-card__visual {
position: relative;
height: 220rpx;
background: linear-gradient(145deg, #f5f7fa 0%, #eef1f5 100%);
}
.device-card__img {
width: 100%;
height: 100%;
opacity: 0.85;
}
.device-card__badge {
position: absolute;
top: 16rpx;
left: 16rpx;
display: flex;
align-items: center;
gap: 8rpx;
padding: 8rpx 16rpx;
background: rgba(255, 255, 255, 0.92);
border-radius: 24rpx;
}
.device-card__badge--offline .device-card__dot {
background: #98a2b3;
}
.device-card__badge--offline .device-card__badge-text {
color: #98a2b3;
}
.device-card__badge--charge {
padding: 8rpx 14rpx;
}
.device-card__dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background: $color-primary;
flex-shrink: 0;
}
.device-card__badge-text {
font-size: 22rpx;
color: $color-primary;
line-height: 1;
}
.device-card__charge-icon {
font-size: 24rpx;
color: #f5a623;
line-height: 1;
}
.device-card__info {
padding: 20rpx 20rpx 24rpx;
}
.device-card__name {
display: block;
font-size: 28rpx;
font-weight: 600;
color: $color-text;
margin-bottom: 10rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.device-card__room {
display: flex;
align-items: center;
gap: 8rpx;
}
.device-card__room-dot {
width: 8rpx;
height: 8rpx;
border-radius: 50%;
background: #98a2b3;
flex-shrink: 0;
}
.device-card__room-text {
font-size: 24rpx;
color: $color-text-secondary;
line-height: 1;
}
</style>

View File

@@ -1,7 +1,8 @@
<template> <template>
<view class="device-header" :style="{ paddingTop: headerPaddingTop + 'px' }"> <view class="device-header" :style="{ paddingTop: headerPaddingTop + 'px' }">
<view class="device-header__bar"> <view class="device-header__bar">
<view class="device-header__home" @tap="$emit('home')"> <view class="device-header__home-wrap">
<view class="device-header__home" @tap.stop="togglePicker">
<image <image
class="device-header__home-icon" class="device-header__home-icon"
:src="staticAssets.iconHomeSelector" :src="staticAssets.iconHomeSelector"
@@ -10,10 +11,38 @@
<text class="device-header__home-text">{{ homeName }}</text> <text class="device-header__home-text">{{ homeName }}</text>
<image <image
class="device-header__arrow" class="device-header__arrow"
:class="{ 'device-header__arrow--open': pickerOpen }"
:src="staticAssets.iconHomeArrow" :src="staticAssets.iconHomeArrow"
mode="aspectFit" mode="aspectFit"
/> />
</view> </view>
<view
v-if="pickerOpen"
class="device-header__mask"
@tap="closePicker"
/>
<view v-if="pickerOpen" class="device-header__dropdown">
<view
class="device-header__option device-header__option--active"
@tap.stop="selectHome"
>
<text class="device-header__option-text device-header__option-text--active">
{{ homeName }}
</text>
<image
class="device-header__check-icon"
:src="staticAssets.iconHomeSelected"
mode="aspectFit"
/>
</view>
<view class="device-header__option" @tap.stop="selectManage">
<text class="device-header__option-text">家庭管理</text>
</view>
</view>
</view>
<view class="device-header__actions">
<view class="device-header__notify-wrap" @tap="$emit('notify')"> <view class="device-header__notify-wrap" @tap="$emit('notify')">
<image <image
class="device-header__notify-icon" class="device-header__notify-icon"
@@ -21,29 +50,87 @@
mode="aspectFit" mode="aspectFit"
/> />
</view> </view>
<view class="device-header__add-wrap">
<view class="device-header__add-btn" @tap.stop="toggleAddMenu">
<text class="device-header__add-icon">+</text>
</view>
<view v-if="addMenuOpen" class="device-header__add-mask" @tap="closeAddMenu" />
<view v-if="addMenuOpen" class="device-header__add-menu" @tap.stop>
<view class="device-header__add-menu-arrow" />
<view class="device-header__add-menu-item" @tap="onAddDevice">
<text class="device-header__add-menu-text">添加设备</text>
</view>
<view class="device-header__add-menu-item" @tap="onScan">
<text class="device-header__add-menu-text">扫一扫</text>
</view>
</view>
</view>
</view>
</view> </view>
</view> </view>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'
import { staticAssets } from '@/utils/static-assets' import { staticAssets } from '@/utils/static-assets'
defineProps<{ defineProps<{
homeName: string homeName: string
}>() }>()
defineEmits<{ const emit = defineEmits<{
home: []
notify: [] notify: []
'add-device': []
scan: []
manage: []
}>() }>()
const pickerOpen = ref(false)
const addMenuOpen = ref(false)
const sys = uni.getSystemInfoSync() const sys = uni.getSystemInfoSync()
const statusBarHeight = sys.statusBarHeight ?? 0 const statusBarHeight = sys.statusBarHeight ?? 0
/** 状态栏下再留一段间距;H5 无状态栏时用固定上内边距 */
const headerPaddingTop = const headerPaddingTop =
statusBarHeight > 0 statusBarHeight > 0
? statusBarHeight + uni.upx2px(24) ? statusBarHeight + uni.upx2px(24)
: uni.upx2px(48) : uni.upx2px(48)
function togglePicker() {
closeAddMenu()
pickerOpen.value = !pickerOpen.value
}
function closePicker() {
pickerOpen.value = false
}
function selectHome() {
closePicker()
}
function selectManage() {
closePicker()
emit('manage')
}
function toggleAddMenu() {
closePicker()
addMenuOpen.value = !addMenuOpen.value
}
function closeAddMenu() {
addMenuOpen.value = false
}
function onAddDevice() {
closeAddMenu()
emit('add-device')
}
function onScan() {
closeAddMenu()
emit('scan')
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -60,14 +147,19 @@ const headerPaddingTop =
justify-content: space-between; justify-content: space-between;
} }
.device-header__home-wrap {
position: relative;
z-index: 300;
}
.device-header__home { .device-header__home {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12rpx; gap: 12rpx;
padding: 12rpx 28rpx 12rpx 20rpx; padding: 12rpx 28rpx 12rpx 20rpx;
background: #EFFBF4; background: #effbf4;
border-radius: 40rpx; border-radius: 40rpx;
border: 1px solid #C9EFD9; border: 1px solid #c9efd9;
} }
.device-header__home-icon { .device-header__home-icon {
@@ -80,12 +172,74 @@ const headerPaddingTop =
font-size: 30rpx; font-size: 30rpx;
font-weight: 600; font-weight: 600;
color: #1fa85c; color: #1fa85c;
white-space: nowrap;
} }
.device-header__arrow { .device-header__arrow {
width: 18rpx; width: 18rpx;
height: 18rpx; height: 18rpx;
flex-shrink: 0; flex-shrink: 0;
transition: transform 0.2s ease;
}
.device-header__arrow--open {
transform: rotate(180deg);
}
.device-header__mask {
position: fixed;
inset: 0;
z-index: 250;
background: transparent;
}
.device-header__dropdown {
position: absolute;
top: calc(100% + 12rpx);
left: 0;
min-width: 100%;
width: 280rpx;
border-radius: 24rpx;
overflow: hidden;
background: #fff;
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.1);
z-index: 300;
}
.device-header__option {
display: flex;
align-items: center;
justify-content: space-between;
padding: 28rpx 32rpx;
background: #fff;
}
.device-header__option--active {
background: linear-gradient(98deg, #45d685 0%, #21c25e 100%);
}
.device-header__option-text {
font-size: 30rpx;
font-weight: 500;
color: $color-text;
line-height: 1.2;
}
.device-header__option-text--active {
color: #fff;
font-weight: 600;
}
.device-header__check-icon {
width: 36rpx;
height: 36rpx;
flex-shrink: 0;
}
.device-header__actions {
display: flex;
align-items: center;
gap: 16rpx;
} }
.device-header__notify-icon { .device-header__notify-icon {
@@ -93,4 +247,69 @@ const headerPaddingTop =
height: 72rpx; height: 72rpx;
flex-shrink: 0; flex-shrink: 0;
} }
.device-header__add-wrap {
position: relative;
z-index: 350;
}
.device-header__add-btn {
width: 72rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.device-header__add-icon {
font-size: 52rpx;
font-weight: 300;
color: #667085;
line-height: 1;
}
.device-header__add-mask {
position: fixed;
inset: 0;
z-index: 340;
background: transparent;
}
.device-header__add-menu {
position: absolute;
top: calc(100% + 12rpx);
right: 0;
min-width: 240rpx;
padding: 16rpx 0;
background: #fff;
border-radius: 20rpx;
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.12);
z-index: 360;
}
.device-header__add-menu-arrow {
position: absolute;
top: -10rpx;
right: 24rpx;
width: 20rpx;
height: 20rpx;
background: #fff;
transform: rotate(45deg);
box-shadow: -2rpx -2rpx 4rpx rgba(0, 0, 0, 0.04);
}
.device-header__add-menu-item {
padding: 28rpx 48rpx;
display: flex;
align-items: center;
justify-content: center;
}
.device-header__add-menu-text {
font-size: 30rpx;
color: $color-text;
line-height: 1.2;
white-space: nowrap;
}
</style> </style>

View File

@@ -0,0 +1,85 @@
<template>
<view class="device-list">
<view class="device-list__head">
<view class="device-list__title-row">
<text class="device-list__title">我的设备</text>
<view v-if="onlineCount > 0" class="device-list__badge">
<text class="device-list__badge-text">{{ onlineCount }} 台在线</text>
</view>
</view>
<text class="device-list__manage" @tap="$emit('manage')">管理 &gt;</text>
</view>
<view class="device-list__grid">
<device-card
v-for="item in devices"
:key="item.deviceId ?? item.deviceNo"
:device="item"
@tap="$emit('select', item)"
/>
</view>
</view>
</template>
<script setup lang="ts">
import type { DeviceListItem } from '../../device.types'
import DeviceCard from '../device-card/index.vue'
defineProps<{
devices: DeviceListItem[]
onlineCount: number
}>()
defineEmits<{
manage: []
select: [device: DeviceListItem]
}>()
</script>
<style lang="scss" scoped>
.device-list {
padding: 0 32rpx 32rpx;
}
.device-list__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24rpx;
}
.device-list__title-row {
display: flex;
align-items: center;
gap: 16rpx;
}
.device-list__title {
font-size: 32rpx;
font-weight: 600;
color: $color-text;
}
.device-list__badge {
padding: 6rpx 16rpx;
background: #effbf4;
border-radius: 20rpx;
}
.device-list__badge-text {
font-size: 22rpx;
color: #1fa85c;
line-height: 1;
}
.device-list__manage {
font-size: 26rpx;
color: $color-text-secondary;
}
.device-list__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 24rpx;
}
</style>

View File

@@ -0,0 +1,91 @@
<template>
<view v-if="visible" class="operation-success-toast">
<view class="operation-success-toast__box">
<view class="operation-success-toast__icon-wrap" aria-hidden="true">
<view class="operation-success-toast__icon-frame">
<text class="operation-success-toast__check">✓</text>
</view>
</view>
<text class="operation-success-toast__text">操作成功</text>
</view>
</view>
</template>
<script setup lang="ts">
import { watch } from 'vue'
const props = defineProps<{
visible: boolean
duration?: number
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
}>()
watch(
() => props.visible,
(show) => {
if (!show) return
const ms = props.duration ?? 1500
setTimeout(() => {
emit('update:visible', false)
}, ms)
},
)
</script>
<style lang="scss" scoped>
.operation-success-toast {
position: fixed;
inset: 0;
z-index: 999;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.operation-success-toast__box {
min-width: 240rpx;
padding: 40rpx 48rpx 36rpx;
background: rgba(76, 76, 76, 0.92);
border-radius: 24rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 20rpx;
}
.operation-success-toast__icon-wrap {
width: 72rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
}
.operation-success-toast__icon-frame {
width: 56rpx;
height: 56rpx;
border: 3rpx dashed rgba(255, 255, 255, 0.85);
border-radius: 8rpx;
display: flex;
align-items: center;
justify-content: center;
}
.operation-success-toast__check {
font-size: 32rpx;
font-weight: 700;
color: #fff;
line-height: 1;
margin-top: -2rpx;
}
.operation-success-toast__text {
font-size: 28rpx;
color: #fff;
line-height: 1.2;
}
</style>

View File

@@ -0,0 +1,154 @@
<template>
<view v-if="visible" class="room-location-dialog">
<view class="room-location-dialog__mask" @tap="onCancel" />
<view class="room-location-dialog__panel" @tap.stop>
<text class="room-location-dialog__title">设置房间位置</text>
<view class="room-location-dialog__input-wrap">
<input
v-model="roomName"
class="room-location-dialog__input"
type="text"
maxlength="20"
placeholder="请输入位置名称"
placeholder-class="room-location-dialog__placeholder"
:focus="visible"
/>
</view>
<view class="room-location-dialog__footer">
<view class="room-location-dialog__btn" @tap="onCancel">
<text class="room-location-dialog__btn-text">取消</text>
</view>
<view class="room-location-dialog__divider-v" />
<view class="room-location-dialog__btn" @tap="onConfirm">
<text class="room-location-dialog__btn-text room-location-dialog__btn-text--primary">
确定
</text>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
const props = defineProps<{
visible: boolean
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
confirm: [name: string]
}>()
const roomName = ref('')
watch(
() => props.visible,
(show) => {
if (show) roomName.value = ''
},
)
function onCancel() {
emit('update:visible', false)
}
function onConfirm() {
const name = roomName.value.trim()
if (!name) {
uni.showToast({ title: '请输入位置名称', icon: 'none' })
return
}
emit('confirm', name)
emit('update:visible', false)
}
</script>
<style lang="scss" scoped>
.room-location-dialog {
position: fixed;
inset: 0;
z-index: 500;
display: flex;
align-items: center;
justify-content: center;
}
.room-location-dialog__mask {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.55);
}
.room-location-dialog__panel {
position: relative;
width: 560rpx;
background: #fff;
border-radius: 24rpx;
overflow: hidden;
z-index: 1;
}
.room-location-dialog__title {
display: block;
padding: 48rpx 40rpx 32rpx;
font-size: 34rpx;
font-weight: 600;
color: $color-text;
text-align: center;
line-height: 1.3;
}
.room-location-dialog__input-wrap {
margin: 0 40rpx 40rpx;
padding: 0 28rpx;
height: 88rpx;
background: #f5f5f5;
border-radius: 12rpx;
display: flex;
align-items: center;
}
.room-location-dialog__input {
flex: 1;
height: 88rpx;
font-size: 28rpx;
color: $color-text;
}
.room-location-dialog__placeholder {
color: #c0c4cc;
font-size: 28rpx;
}
.room-location-dialog__footer {
display: flex;
align-items: stretch;
border-top: 1rpx solid #ebedf0;
}
.room-location-dialog__btn {
flex: 1;
height: 96rpx;
display: flex;
align-items: center;
justify-content: center;
}
.room-location-dialog__divider-v {
width: 1rpx;
background: #ebedf0;
}
.room-location-dialog__btn-text {
font-size: 32rpx;
color: $color-text;
line-height: 1;
}
.room-location-dialog__btn-text--primary {
color: #21c25e;
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,115 @@
<template>
<view v-if="visible" class="filter-replace-dialog">
<view class="filter-replace-dialog__mask" @tap="onCancel" />
<view class="filter-replace-dialog__panel" @tap.stop>
<text class="filter-replace-dialog__title">提示</text>
<text class="filter-replace-dialog__message">确认已更换新滤芯?</text>
<view class="filter-replace-dialog__footer">
<view class="filter-replace-dialog__btn" @tap.stop="onCancel">
<text class="filter-replace-dialog__btn-text">取消</text>
</view>
<view class="filter-replace-dialog__divider-v" />
<view class="filter-replace-dialog__btn" @tap.stop="onConfirm">
<text class="filter-replace-dialog__btn-text filter-replace-dialog__btn-text--primary">
确定
</text>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
defineProps<{
visible: boolean
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
confirm: []
}>()
function onCancel() {
emit('update:visible', false)
}
function onConfirm() {
emit('confirm')
emit('update:visible', false)
}
</script>
<style lang="scss" scoped>
.filter-replace-dialog {
position: fixed;
inset: 0;
z-index: 500;
display: flex;
align-items: center;
justify-content: center;
}
.filter-replace-dialog__mask {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.55);
}
.filter-replace-dialog__panel {
position: relative;
width: 560rpx;
background: #fff;
border-radius: 24rpx;
overflow: hidden;
z-index: 1;
}
.filter-replace-dialog__title {
display: block;
padding: 48rpx 40rpx 24rpx;
font-size: 34rpx;
font-weight: 600;
color: $color-text;
text-align: center;
line-height: 1.3;
}
.filter-replace-dialog__message {
display: block;
padding: 0 40rpx 48rpx;
font-size: 30rpx;
color: $color-text-secondary;
text-align: center;
line-height: 1.5;
}
.filter-replace-dialog__footer {
display: flex;
align-items: stretch;
border-top: 1rpx solid #ebedf0;
}
.filter-replace-dialog__btn {
flex: 1;
height: 96rpx;
display: flex;
align-items: center;
justify-content: center;
}
.filter-replace-dialog__divider-v {
width: 1rpx;
background: #ebedf0;
}
.filter-replace-dialog__btn-text {
font-size: 32rpx;
color: $color-text;
line-height: 1;
}
.filter-replace-dialog__btn-text--primary {
color: #21c25e;
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,210 @@
<template>
<view v-if="visible" class="run-mode-explain-dialog">
<view class="run-mode-explain-dialog__mask" @tap="onClose" />
<view class="run-mode-explain-dialog__panel" @tap.stop>
<view class="run-mode-explain-dialog__header">
<text class="run-mode-explain-dialog__title">模式说明</text>
<view class="run-mode-explain-dialog__close" @tap.stop="onClose">
<text class="run-mode-explain-dialog__close-icon">×</text>
</view>
</view>
<scroll-view class="run-mode-explain-dialog__scroll" scroll-y>
<text class="run-mode-explain-dialog__intro">
该模式用于在停车断电场景下,兼顾消杀效果与电池寿命保护,规则如下:
</text>
<view class="run-mode-explain-dialog__card run-mode-explain-dialog__card--car">
<view class="run-mode-explain-dialog__card-head">
<text class="run-mode-explain-dialog__tag run-mode-explain-dialog__tag--car">车载</text>
<text class="run-mode-explain-dialog__card-title run-mode-explain-dialog__card-title--car">
USB 断电后电池消杀
</text>
</view>
<text class="run-mode-explain-dialog__card-desc run-mode-explain-dialog__card-desc--car">
USB 断电后,由电池继续供电消杀 5 分钟,随后自动关机。
</text>
</view>
<view class="run-mode-explain-dialog__card run-mode-explain-dialog__card--desk">
<view class="run-mode-explain-dialog__card-head">
<text class="run-mode-explain-dialog__tag run-mode-explain-dialog__tag--desk">桌面</text>
<text class="run-mode-explain-dialog__card-title run-mode-explain-dialog__card-title--desk">
低电量保护策略
</text>
</view>
<text class="run-mode-explain-dialog__card-desc run-mode-explain-dialog__card-desc--desk">
电量&lt;10%时停止消杀,仅保留基础净化。
</text>
<text
class="run-mode-explain-dialog__card-desc run-mode-explain-dialog__card-desc--desk run-mode-explain-dialog__card-desc--gap"
>
电量&lt;5%时整机锁关机,防止电池过放。
</text>
</view>
</scroll-view>
</view>
</view>
</template>
<script setup lang="ts">
defineProps<{
visible: boolean
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
}>()
function onClose() {
emit('update:visible', false)
}
</script>
<style lang="scss" scoped>
.run-mode-explain-dialog {
position: fixed;
inset: 0;
z-index: 500;
display: flex;
align-items: center;
justify-content: center;
padding: 48rpx 40rpx;
box-sizing: border-box;
}
.run-mode-explain-dialog__mask {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.55);
}
.run-mode-explain-dialog__panel {
position: relative;
z-index: 1;
width: 100%;
max-width: 640rpx;
max-height: 80vh;
background: #fff;
border-radius: 24rpx;
padding: 36rpx 32rpx 32rpx;
box-sizing: border-box;
overflow: hidden;
}
.run-mode-explain-dialog__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24rpx;
}
.run-mode-explain-dialog__title {
font-size: 34rpx;
font-weight: 600;
color: $color-text;
line-height: 1.3;
}
.run-mode-explain-dialog__close {
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
}
.run-mode-explain-dialog__close-icon {
font-size: 44rpx;
line-height: 1;
color: #999;
font-weight: 300;
}
.run-mode-explain-dialog__scroll {
max-height: calc(80vh - 140rpx);
}
.run-mode-explain-dialog__intro {
display: block;
font-size: 28rpx;
color: $color-text-secondary;
line-height: 1.6;
margin-bottom: 28rpx;
}
.run-mode-explain-dialog__card {
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 20rpx;
border-width: 2rpx;
border-style: solid;
}
.run-mode-explain-dialog__card--car {
background: #f3fbf6;
border-color: #b8e8cc;
}
.run-mode-explain-dialog__card--desk {
background: #fffbf2;
border-color: #f0d9a8;
}
.run-mode-explain-dialog__card-head {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 16rpx;
flex-wrap: wrap;
}
.run-mode-explain-dialog__tag {
padding: 4rpx 14rpx;
border-radius: 8rpx;
font-size: 24rpx;
color: #fff;
line-height: 1.4;
font-weight: 500;
}
.run-mode-explain-dialog__tag--car {
background: #21c25e;
}
.run-mode-explain-dialog__tag--desk {
background: #e6a23c;
}
.run-mode-explain-dialog__card-title {
font-size: 28rpx;
font-weight: 600;
line-height: 1.4;
}
.run-mode-explain-dialog__card-title--car {
color: #21c25e;
}
.run-mode-explain-dialog__card-title--desk {
color: #c58a1a;
}
.run-mode-explain-dialog__card-desc {
display: block;
font-size: 26rpx;
line-height: 1.65;
}
.run-mode-explain-dialog__card-desc--car {
color: #2d9f5f;
}
.run-mode-explain-dialog__card-desc--desk {
color: #8a6a2e;
}
.run-mode-explain-dialog__card-desc--gap {
margin-top: 8rpx;
}
</style>

View File

@@ -0,0 +1,92 @@
import type { UpdateDeviceRuntimeBody } from './device-detail.types'
const SERVICE_UUID = (import.meta.env.VITE_BLE_SERVICE_UUID as string | undefined)?.trim()
const RUNTIME_CHAR_UUID = (import.meta.env.VITE_BLE_RUNTIME_CHARACTERISTIC_UUID as string | undefined)?.trim()
function normalizeUuid(value: string): string {
return value.replace(/-/g, '').toUpperCase()
}
function readBleCharacteristic(
deviceId: string,
serviceId: string,
characteristicId: string,
): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
const targetService = normalizeUuid(serviceId)
const targetChar = normalizeUuid(characteristicId)
const timeout = setTimeout(() => {
cleanup()
reject(new Error('读取特征值超时'))
}, 5000)
const onValue = (res: UniApp.OnBLECharacteristicValueChangeCallbackResult) => {
if (res.deviceId !== deviceId) return
if (normalizeUuid(res.serviceId) !== targetService) return
if (normalizeUuid(res.characteristicId) !== targetChar) return
cleanup()
resolve(res.value as ArrayBuffer)
}
function cleanup() {
clearTimeout(timeout)
if (typeof uni.offBLECharacteristicValueChange === 'function') {
uni.offBLECharacteristicValueChange(onValue)
}
}
if (typeof uni.onBLECharacteristicValueChange !== 'function') {
reject(new Error('当前环境不支持读取蓝牙特征值'))
return
}
uni.onBLECharacteristicValueChange(onValue)
uni.readBLECharacteristicValue({
deviceId,
serviceId,
characteristicId,
success: () => {},
fail: (err) => {
cleanup()
reject(err)
},
})
})
}
function parseRuntimePayload(buffer: ArrayBuffer): Partial<UpdateDeviceRuntimeBody> {
try {
const bytes = new Uint8Array(buffer)
const text = String.fromCharCode(...bytes).trim()
if (!text) return {}
const data = JSON.parse(text) as Record<string, unknown>
const patch: Partial<UpdateDeviceRuntimeBody> = {}
if (data.runMode != null) patch.runMode = String(data.runMode)
if (data.fanLevel != null) patch.fanLevel = String(data.fanLevel)
if (data.tvoc != null) patch.tvoc = Number(data.tvoc)
if (data.powerMode != null) patch.powerMode = String(data.powerMode)
if (data.charging != null) patch.charging = String(data.charging)
if (data.filterTotalDays != null) patch.filterTotalDays = String(data.filterTotalDays)
if (data.filterRemainDays != null) patch.filterRemainDays = String(data.filterRemainDays)
if (data.parkingShutdown != null) patch.parkingShutdown = String(data.parkingShutdown)
return patch
} catch {
return {}
}
}
/** 若配置了 BLE 特征值 UUID,则尝试读取设备运行数据 */
export async function readRuntimeFromBle(
bleDeviceId: string,
): Promise<Partial<UpdateDeviceRuntimeBody>> {
if (!SERVICE_UUID || !RUNTIME_CHAR_UUID) return {}
if (typeof uni.readBLECharacteristicValue !== 'function') return {}
try {
const buffer = await readBleCharacteristic(bleDeviceId, SERVICE_UUID, RUNTIME_CHAR_UUID)
return parseRuntimePayload(buffer)
} catch {
return {}
}
}

View File

@@ -0,0 +1,24 @@
import { request } from '@/api/request'
import type {
AppDeviceRuntimeVo,
DeviceRuntimeQueryBody,
UpdateDeviceRuntimeBody,
} from './device-detail.types'
/** POST /app/device/runtime — 查询设备运行数据 */
export function fetchDeviceRuntime(data: DeviceRuntimeQueryBody) {
return request<AppDeviceRuntimeVo>({
url: '/app/device/runtime',
method: 'POST',
data,
})
}
/** POST /app/device/updateRuntime — 更新设备运行数据 */
export function updateDeviceRuntime(data: UpdateDeviceRuntimeBody) {
return request<void>({
url: '/app/device/updateRuntime',
method: 'POST',
data,
})
}

View File

@@ -0,0 +1,400 @@
.device-detail-page {
min-height: 100vh;
background: #f5f6f8;
display: flex;
flex-direction: column;
}
.device-detail-hero {
position: relative;
z-index: 1;
flex-shrink: 0;
padding: 0 32rpx 56rpx;
overflow: hidden;
pointer-events: none;
}
.device-detail-hero__bar,
.device-detail-hero__status,
.device-detail-hero__metric-row,
.device-detail-hero__sync,
.device-detail-hero__settings,
.device-detail-hero__back {
pointer-events: auto;
}
.device-detail-hero__bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
}
.device-detail-hero__bar {
position: relative;
z-index: 1;
height: 88rpx;
display: flex;
align-items: center;
}
.device-detail-hero__back {
width: 72rpx;
height: 88rpx;
display: flex;
align-items: center;
}
.device-detail-hero__back-icon {
font-size: 56rpx;
line-height: 1;
color: #fff;
font-weight: 300;
}
.device-detail-hero__title {
flex: 1;
text-align: center;
font-size: 34rpx;
font-weight: 600;
color: #fff;
}
.device-detail-hero__settings {
width: 72rpx;
height: 88rpx;
display: flex;
align-items: center;
justify-content: flex-end;
}
.device-detail-hero__settings-icon {
width: 40rpx;
height: 40rpx;
}
.device-detail-hero__status {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 10rpx;
margin-top: 4rpx;
padding-left: 8rpx;
}
.device-detail-hero__status-dot {
width: 10rpx;
height: 10rpx;
border-radius: 50%;
background: #fff;
&--offline {
background: rgba(255, 255, 255, 0.55);
}
}
.device-detail-hero__status-text {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.92);
}
.device-detail-hero__metric-row {
position: relative;
z-index: 1;
margin-top: 40rpx;
padding: 0 8rpx;
display: flex;
align-items: center;
justify-content: space-between;
}
.device-detail-hero__metric {
display: flex;
align-items: baseline;
gap: 10rpx;
}
.device-detail-hero__metric-value {
font-size: 96rpx;
font-weight: 700;
color: #fff;
line-height: 1;
letter-spacing: -2rpx;
}
.device-detail-hero__metric-unit {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.92);
padding-bottom: 8rpx;
}
.device-detail-hero__quality {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.device-detail-hero__quality-text {
font-size: 36rpx;
font-weight: 700;
color: #fff;
}
.device-detail-hero__sync {
position: relative;
z-index: 1;
display: block;
margin-top: 24rpx;
padding-left: 8rpx;
text-align: left;
font-size: 22rpx;
color: rgba(255, 255, 255, 0.75);
}
.device-detail-page__scroll {
position: relative;
z-index: 2;
flex: 1;
height: 0;
margin-top: -40rpx;
}
.device-detail-panel {
padding: 0 24rpx calc(40rpx + env(safe-area-inset-bottom));
}
.device-detail-panel__dash {
height: 2rpx;
margin: 0 8rpx 24rpx;
border-top: 2rpx dashed rgba(255, 255, 255, 0.55);
}
.device-detail-card {
background: #fff;
border-radius: 32rpx;
padding: 36rpx 28rpx 32rpx;
box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.04);
margin-bottom: 24rpx;
}
.device-detail-card__section {
margin-bottom: 32rpx;
}
.device-detail-card__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20rpx;
}
.device-detail-card__head-left {
display: flex;
align-items: center;
gap: 10rpx;
}
.device-detail-card__label {
font-size: 30rpx;
font-weight: 600;
color: $color-text;
}
.device-detail-card__head-icon-wrap {
width: 40rpx;
height: 40rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.device-detail-card__head-icon {
width: 28rpx;
height: 28rpx;
}
.device-detail-mode-list {
display: flex;
gap: 16rpx;
}
.device-detail-mode-item {
flex: 1;
height: 80rpx;
border-radius: 16rpx;
background: #f3f4f6;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
}
.device-detail-mode-item--active {
background: linear-gradient(135deg, #3fd47f 0%, #21c25e 100%);
box-shadow: 0 6rpx 20rpx rgba(33, 194, 94, 0.28);
}
.device-detail-mode-item__text {
font-size: 26rpx;
color: #333;
}
.device-detail-mode-item--active .device-detail-mode-item__text {
color: #fff;
font-weight: 500;
}
.device-detail-fan-list {
display: flex;
gap: 16rpx;
}
.device-detail-fan-item {
flex: 1;
height: 80rpx;
border-radius: 16rpx;
background: #f3f4f6;
display: flex;
align-items: center;
justify-content: center;
}
.device-detail-fan-item--active {
background: linear-gradient(135deg, #3fd47f 0%, #21c25e 100%);
box-shadow: 0 6rpx 20rpx rgba(33, 194, 94, 0.28);
}
.device-detail-fan-item__text {
font-size: 28rpx;
color: #333;
font-weight: 500;
}
.device-detail-fan-item--active .device-detail-fan-item__text {
color: #fff;
}
.device-detail-card__power {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 4rpx;
}
.device-detail-card__power-label {
font-size: 28rpx;
color: $color-text;
}
.device-detail-card__power-value {
font-size: 28rpx;
font-weight: 600;
color: $color-primary;
}
.device-detail-stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20rpx;
}
.device-detail-stat-card {
background: #fff;
border-radius: 28rpx;
padding: 28rpx 24rpx 24rpx;
box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.04);
min-height: 300rpx;
display: flex;
flex-direction: column;
}
.device-detail-stat-card__head {
margin-bottom: 12rpx;
}
.device-detail-stat-card__title {
font-size: 28rpx;
font-weight: 600;
color: $color-text;
}
.device-detail-stat-card__value {
font-size: 52rpx;
font-weight: 700;
color: $color-text;
line-height: 1.1;
margin-bottom: 14rpx;
}
.device-detail-stat-card__track {
height: 10rpx;
border-radius: 5rpx;
background: #eef0f3;
overflow: hidden;
margin-bottom: 14rpx;
}
.device-detail-stat-card__fill {
height: 100%;
border-radius: 5rpx;
background: linear-gradient(90deg, #45d685 0%, #21c25e 100%);
}
.device-detail-stat-card__desc {
font-size: 22rpx;
color: $color-text-secondary;
line-height: 1.4;
margin-bottom: 16rpx;
}
.device-detail-stat-card__btn {
margin-top: auto;
height: 68rpx;
border-radius: 34rpx;
border: 2rpx solid #e8eaed;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
}
.device-detail-stat-card__btn--hover {
opacity: 0.85;
}
.device-detail-stat-card__btn--done {
border-color: #e8eaed;
background: #fff;
}
.device-detail-stat-card__btn-text {
font-size: 24rpx;
color: $color-text-secondary;
}
.device-detail-stat-card__btn--done .device-detail-stat-card__btn-text {
color: $color-text-secondary;
}
.device-detail-stat-card__tag {
margin-top: auto;
align-self: flex-start;
padding: 6rpx 18rpx;
border-radius: 20rpx;
background: #edf9f1;
}
.device-detail-stat-card__tag-text {
font-size: 22rpx;
color: $color-primary;
}

View File

@@ -0,0 +1,57 @@
/** POST /app/device/runtime 查询参数 */
export interface DeviceRuntimeQueryBody {
deviceId: number
}
/**
* POST /app/device/runtime 返回 data
* status: 0未激活 1在线 2离线
* runMode: 0自动 1车载 2桌面
* fanLevel: 0标准 1强力 2低档
* powerMode: 0USB 1电池
* parkingShutdown: 0关机 1开机
*/
export interface AppDeviceRuntimeVo {
deviceId?: number
deviceNo?: string
name?: string
status?: string
runMode?: string
fanLevel?: string
tvoc?: number
powerMode?: string
filterTotalDays?: string
filterRemainDays?: string
filterLife?: number
filterReplaceTime?: string
parkingShutdown?: string
}
/** POST /app/device/updateRuntime */
export interface UpdateDeviceRuntimeBody {
deviceId: number
runMode?: string
fanLevel?: string
status?: string
bluetoothConnected?: string
signalLevel?: string
charging?: string
tvoc?: number
powerMode?: string
filterTotalDays?: string
filterRemainDays?: string
parkingShutdown?: string
}
export interface DeviceDetailRouteQuery {
deviceId?: string
name?: string
locationName?: string
status?: string
bluetoothConnected?: string
charging?: string
mac?: string
}
export type RunMode = '0' | '1' | '2'
export type FanLevel = '0' | '1' | '2'

View File

@@ -0,0 +1,113 @@
import type { AppDeviceRuntimeVo, FanLevel, RunMode } from './device-detail.types'
export const RUN_MODE_OPTIONS = [
{ value: '0' as RunMode, label: '自动模式' },
{ value: '1' as RunMode, label: '车载模式' },
{ value: '2' as RunMode, label: '桌面模式' },
] as const
/** 自动模式下可选:标准 / 强力(对应 API fanLevel 0 / 1) */
export const AUTO_FAN_LEVEL_OPTIONS = [
{ value: '0' as FanLevel, label: '标准' },
{ value: '1' as FanLevel, label: '强力' },
] as const
export function isAutoRunMode(runMode?: string): boolean {
return runMode === '0'
}
/** 自动模式下 UI 仅展示标准/强力 */
export function normalizeAutoFanLevel(runMode?: string, fanLevel?: string): FanLevel {
const level = (fanLevel as FanLevel) || '0'
if (!isAutoRunMode(runMode)) return level
return level === '1' ? '1' : '0'
}
/** 状态:0未激活 1在线 2离线 */
export function isDeviceOnline(status?: string): boolean {
return status === '1'
}
/** 蓝牙连接状态:0未连接 1已连接 */
export function isBluetoothConnected(value?: string): boolean {
return value === '1'
}
export function formatConnectionText(options: {
status?: string
bluetoothConnected?: string
locationName: string
}): string {
const { status, bluetoothConnected, locationName } = options
if (status === '0') return `未激活 · ${locationName}`
if (status === '2' || !isDeviceOnline(status)) {
return `设备离线 · ${locationName}`
}
const btText = isBluetoothConnected(bluetoothConnected) ? '蓝牙已连接' : '蓝牙未连接'
return `${btText} · ${locationName}`
}
/** 供电模式:0USB 1电池 */
export function formatPowerMode(mode?: string): string {
if (mode === '1') return '电池供电模式'
return 'USB供电模式'
}
export function formatTvocValue(tvoc?: number): string {
if (tvoc == null) return '--'
return Number(tvoc).toFixed(1)
}
export function formatTvocLevel(tvoc?: number): string {
const value = tvoc ?? 0
if (value <= 0.3) return '优'
if (value <= 0.6) return '良'
return '差'
}
export function formatFilterRemainDays(runtime: AppDeviceRuntimeVo): string {
if (runtime.filterRemainDays) {
return `预计还可使用 ${runtime.filterRemainDays} 天`
}
const total = Number(runtime.filterTotalDays)
const life = runtime.filterLife ?? 0
if (total > 0 && life > 0) {
const remain = Math.round(total * life / 100)
return `预计还可使用 ${remain} 天`
}
return '预计还可使用 -- 天'
}
export function hasFilterReplaced(runtime: AppDeviceRuntimeVo): boolean {
return !!runtime.filterReplaceTime
}
export interface RuntimeViewState {
deviceName: string
runMode: RunMode
fanLevel: FanLevel
tvoc: number | null
powerMode: string
filterLife: number
filterRemainText: string
filterReplaced: boolean
isOnline: boolean
}
/** 将 /app/device/runtime 响应映射为详情页展示状态 */
export function mapRuntimeToView(
data: AppDeviceRuntimeVo,
fallbackName = '未命名设备',
): RuntimeViewState {
return {
deviceName: data.name || fallbackName,
runMode: (data.runMode as RunMode) || '0',
fanLevel: normalizeAutoFanLevel(data.runMode, data.fanLevel),
tvoc: data.tvoc ?? null,
powerMode: data.powerMode || '0',
filterLife: data.filterLife ?? 0,
filterRemainText: formatFilterRemainDays(data),
filterReplaced: hasFilterReplaced(data),
isOnline: isDeviceOnline(data.status),
}
}

View File

@@ -0,0 +1,206 @@
<template>
<view class="device-detail-page">
<view class="device-detail-hero" :style="{ paddingTop: statusBarHeight + 'px' }">
<image
class="device-detail-hero__bg"
:src="staticAssets.deviceDetailHeroBg"
mode="aspectFill"
/>
<view class="device-detail-hero__bar">
<view class="device-detail-hero__back" @tap="onBack">
<text class="device-detail-hero__back-icon">‹</text>
</view>
<text class="device-detail-hero__title">{{ deviceName }}</text>
<view class="device-detail-hero__settings" @tap="onSettings">
<image
class="device-detail-hero__settings-icon"
:src="staticAssets.iconDeviceDetailSettings"
mode="aspectFit"
/>
</view>
</view>
<view class="device-detail-hero__status">
<view
class="device-detail-hero__status-dot"
:class="{ 'device-detail-hero__status-dot--offline': !isOnline }"
/>
<text class="device-detail-hero__status-text">{{ connectionText }}</text>
</view>
<view class="device-detail-hero__metric-row">
<view class="device-detail-hero__metric">
<text class="device-detail-hero__metric-value">{{ tvocDisplay }}</text>
<text class="device-detail-hero__metric-unit">TVOC mg</text>
</view>
<view class="device-detail-hero__quality">
<text class="device-detail-hero__quality-text">{{ tvocLevel }}</text>
</view>
</view>
<text class="device-detail-hero__sync">{{ syncText }}</text>
</view>
<scroll-view class="device-detail-page__scroll" scroll-y>
<view class="device-detail-panel">
<view class="device-detail-panel__dash" aria-hidden="true" />
<view class="device-detail-card">
<view class="device-detail-card__section">
<view class="device-detail-card__head">
<view class="device-detail-card__head-left">
<text class="device-detail-card__label">运行模式</text>
<view class="device-detail-card__head-icon-wrap" @tap.stop="onOpenRunModeExplain">
<image
class="device-detail-card__head-icon"
:src="staticAssets.iconRunModeInfo"
mode="aspectFit"
/>
</view>
</view>
</view>
<view class="device-detail-mode-list">
<view
v-for="item in runModeOptions"
:key="item.value"
class="device-detail-mode-item"
:class="{ 'device-detail-mode-item--active': runMode === item.value }"
@tap="onSelectRunMode(item.value)"
>
<text class="device-detail-mode-item__text">{{ item.label }}</text>
</view>
</view>
</view>
<view v-if="showFanLevelControl" class="device-detail-card__section">
<view class="device-detail-card__head">
<view class="device-detail-card__head-left">
<text class="device-detail-card__label">风速档位</text>
</view>
</view>
<view class="device-detail-fan-list">
<view
v-for="item in fanLevelOptions"
:key="item.value"
class="device-detail-fan-item"
:class="{ 'device-detail-fan-item--active': fanLevel === item.value }"
@tap="onSelectFanLevel(item.value)"
>
<text class="device-detail-fan-item__text">{{ item.label }}</text>
</view>
</view>
</view>
<view class="device-detail-card__power">
<text class="device-detail-card__power-label">当前供电状态</text>
<text class="device-detail-card__power-value">{{ powerModeText }}</text>
</view>
</view>
<view class="device-detail-stats">
<view class="device-detail-stat-card">
<view class="device-detail-stat-card__head">
<text class="device-detail-stat-card__title">滤芯寿命</text>
</view>
<text class="device-detail-stat-card__value">{{ filterLife }}%</text>
<view class="device-detail-stat-card__track">
<view
class="device-detail-stat-card__fill"
:style="{ width: `${filterLife}%` }"
/>
</view>
<text class="device-detail-stat-card__desc">{{ filterRemainText }}</text>
<view
class="device-detail-stat-card__btn"
:class="{ 'device-detail-stat-card__btn--done': filterReplaced }"
hover-class="device-detail-stat-card__btn--hover"
@tap.stop="onFilterReplace"
>
<text class="device-detail-stat-card__btn-text" @tap.stop="onFilterReplace">
{{ filterReplaced ? '已换新滤芯' : '确认换新滤芯' }}
</text>
</view>
</view>
<view class="device-detail-stat-card">
<view class="device-detail-stat-card__head">
<text class="device-detail-stat-card__title">电量</text>
</view>
<text class="device-detail-stat-card__value">{{ batteryDisplay }}</text>
<view class="device-detail-stat-card__track">
<view
v-if="batteryLevel != null"
class="device-detail-stat-card__fill"
:style="{ width: `${batteryLevel}%` }"
/>
</view>
<view v-if="charging" class="device-detail-stat-card__tag">
<text class="device-detail-stat-card__tag-text">充电中</text>
</view>
</view>
</view>
</view>
</scroll-view>
<filter-replace-dialog
v-model:visible="filterReplaceDialogVisible"
@confirm="onConfirmFilterReplace"
/>
<run-mode-explain-dialog v-model:visible="runModeExplainVisible" />
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { staticAssets } from '@/utils/static-assets'
import FilterReplaceDialog from './components/filter-replace-dialog/index.vue'
import RunModeExplainDialog from './components/run-mode-explain-dialog/index.vue'
import { useDeviceDetail } from './use-device-detail'
const statusBarHeight = ref(uni.getSystemInfoSync().statusBarHeight ?? 0)
const runModeExplainVisible = ref(false)
function onOpenRunModeExplain() {
runModeExplainVisible.value = true
}
const {
deviceName,
connectionText,
isOnline,
tvocDisplay,
tvocLevel,
syncText,
runMode,
fanLevel,
showFanLevelControl,
powerModeText,
filterLife,
filterRemainText,
filterReplaced,
filterReplaceDialogVisible,
batteryLevel,
batteryDisplay,
charging,
runModeOptions,
fanLevelOptions,
onSelectRunMode,
onSelectFanLevel,
onSettings,
onFilterReplace,
onConfirmFilterReplace,
} = useDeviceDetail()
function onBack() {
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack()
} else {
uni.reLaunch({ url: '/pages/device/index' })
}
}
</script>
<style lang="scss" scoped>
@import './device-detail.scss';
</style>

View File

@@ -0,0 +1,185 @@
import { onUnload } from '@dcloudio/uni-app'
import { ref } from 'vue'
import {
BluetoothError,
closeBleConnection,
createBleConnection,
ensureBluetoothReady,
getBluetoothErrorMessage,
getDiscoveredBluetoothDevices,
isBluetoothApiSupported,
rssiToSignalLevel,
scanForBleDeviceByMac,
subscribeBleConnectionStateChange,
} from '@/utils/bluetooth'
import { updateDeviceRuntime } from './device-detail.api'
import { readRuntimeFromBle } from './device-bluetooth-read'
import type { UpdateDeviceRuntimeBody } from './device-detail.types'
const SYNC_INTERVAL_MS = 15000
const SCAN_TIMEOUT_MS = 15000
export interface DeviceBluetoothSyncContext {
getDeviceId: () => number
getMac: () => string | undefined
getRuntimePatch: () => Partial<UpdateDeviceRuntimeBody>
onBluetoothConnectedChange: (connected: boolean) => void
onSyncTextChange: (text: string) => void
onSynced?: () => void | Promise<void>
}
export function useDeviceBluetoothSync(context: DeviceBluetoothSyncContext) {
const syncing = ref(false)
const bleDeviceId = ref<string>()
let syncTimer: ReturnType<typeof setInterval> | undefined
let unsubscribeConnection: (() => void) | undefined
let active = false
function setSyncText(text: string) {
context.onSyncTextChange(text)
}
async function reportDisconnected() {
const deviceId = context.getDeviceId()
if (!deviceId) return
context.onBluetoothConnectedChange(false)
try {
await updateDeviceRuntime({
deviceId,
bluetoothConnected: '0',
signalLevel: '0',
})
} catch {
// 上报失败时保留本地状态
}
}
async function connectAndSync(): Promise<boolean> {
const deviceId = context.getDeviceId()
const mac = context.getMac()
if (!deviceId) return false
if (!isBluetoothApiSupported()) {
setSyncText('当前环境不支持蓝牙同步')
return false
}
if (!mac) {
setSyncText('缺少设备 MAC,无法蓝牙同步')
return false
}
if (syncing.value) return false
syncing.value = true
try {
setSyncText('正在连接蓝牙…')
await ensureBluetoothReady()
let targetBleId = bleDeviceId.value
let foundDevice: UniApp.BluetoothDeviceInfo | null = null
if (!targetBleId) {
foundDevice = await scanForBleDeviceByMac(mac, { timeoutMs: SCAN_TIMEOUT_MS })
if (!foundDevice) {
setSyncText('未找到设备,请确认已开机并靠近手机')
await reportDisconnected()
return false
}
targetBleId = foundDevice.deviceId
bleDeviceId.value = targetBleId
} else {
const discovered = await getDiscoveredBluetoothDevices()
foundDevice = discovered.find((item) => item.deviceId === targetBleId) ?? null
}
try {
await createBleConnection(targetBleId)
} catch (err) {
const btErr = err as BluetoothError
if (btErr.errCode !== 10010) throw err
}
if (!targetBleId) {
setSyncText('蓝牙连接异常,请重试')
await reportDisconnected()
return false
}
context.onBluetoothConnectedChange(true)
const blePayload = await readRuntimeFromBle(targetBleId)
const runtimePatch = context.getRuntimePatch()
const signalLevel = rssiToSignalLevel(foundDevice?.RSSI)
await updateDeviceRuntime({
deviceId,
...runtimePatch,
...blePayload,
status: '1',
bluetoothConnected: '1',
signalLevel,
})
setSyncText('刚刚更新 · 数据实时同步中')
await context.onSynced?.()
return true
} catch (err) {
setSyncText(getBluetoothErrorMessage(err))
bleDeviceId.value = undefined
await reportDisconnected()
return false
} finally {
syncing.value = false
}
}
function startSyncLoop() {
if (active) return
active = true
unsubscribeConnection = subscribeBleConnectionStateChange((res) => {
if (!bleDeviceId.value || res.deviceId !== bleDeviceId.value) return
if (!res.connected) {
bleDeviceId.value = undefined
void reportDisconnected()
setSyncText('蓝牙已断开,正在尝试重连…')
}
})
void connectAndSync()
syncTimer = setInterval(() => {
void connectAndSync()
}, SYNC_INTERVAL_MS)
}
async function stopSync() {
active = false
if (syncTimer) {
clearInterval(syncTimer)
syncTimer = undefined
}
unsubscribeConnection?.()
unsubscribeConnection = undefined
const currentBleId = bleDeviceId.value
bleDeviceId.value = undefined
if (currentBleId) {
await closeBleConnection(currentBleId).catch(() => {})
await reportDisconnected()
}
}
onUnload(() => {
void stopSync()
})
return {
syncing,
startSyncLoop,
stopSync,
connectAndSync,
}
}

View File

@@ -0,0 +1,223 @@
import { onLoad } from '@dcloudio/uni-app'
import { computed, ref } from 'vue'
import { getErrorMessage } from '@/utils/api-error'
import { fetchDeviceList } from '../device.api'
import { fetchDeviceRuntime, updateDeviceRuntime } from './device-detail.api'
import type { AppDeviceRuntimeVo, DeviceDetailRouteQuery, FanLevel, RunMode } from './device-detail.types'
import {
AUTO_FAN_LEVEL_OPTIONS,
formatConnectionText,
formatPowerMode,
formatTvocLevel,
formatTvocValue,
isAutoRunMode,
isBluetoothConnected,
mapRuntimeToView,
RUN_MODE_OPTIONS,
} from './device-detail.utils'
import { useDeviceBluetoothSync } from './use-device-bluetooth-sync'
export function useDeviceDetail() {
const deviceId = ref(0)
const deviceMac = ref<string>()
const deviceName = ref('')
const locationName = ref('客厅')
const deviceStatus = ref<string>()
const bluetoothConnected = ref(false)
const charging = ref(false)
const isOnline = ref(false)
const loading = ref(false)
const updating = ref(false)
const runMode = ref<RunMode>('0')
const fanLevel = ref<FanLevel>('0')
const tvoc = ref<number | null>(null)
const powerMode = ref('0')
const filterLife = ref(0)
const filterRemainText = ref('预计还可使用 -- 天')
const filterReplaced = ref(false)
const filterReplaceDialogVisible = ref(false)
const batteryLevel = ref<number | null>(null)
const syncText = ref('数据实时同步中')
const tvocDisplay = computed(() => formatTvocValue(tvoc.value ?? undefined))
const tvocLevel = computed(() => formatTvocLevel(tvoc.value ?? undefined))
const powerModeText = computed(() => formatPowerMode(powerMode.value))
const batteryDisplay = computed(() =>
batteryLevel.value != null ? `${batteryLevel.value}%` : '--',
)
const connectionText = computed(() =>
formatConnectionText({
status: deviceStatus.value,
bluetoothConnected: bluetoothConnected.value ? '1' : '0',
locationName: locationName.value,
}),
)
const showFanLevelControl = computed(() => isAutoRunMode(runMode.value))
const fanLevelOptions = computed(() =>
showFanLevelControl.value ? AUTO_FAN_LEVEL_OPTIONS : [],
)
function applyRuntime(data: AppDeviceRuntimeVo) {
const view = mapRuntimeToView(data, deviceName.value)
deviceName.value = view.deviceName
runMode.value = view.runMode
fanLevel.value = view.fanLevel
tvoc.value = view.tvoc
powerMode.value = view.powerMode
filterLife.value = view.filterLife
filterRemainText.value = view.filterRemainText
filterReplaced.value = view.filterReplaced
isOnline.value = view.isOnline
if (data.status !== undefined) deviceStatus.value = data.status
syncText.value = '刚刚更新 · 数据实时同步中'
}
async function refreshDeviceMeta() {
if (!deviceId.value) return
try {
const list = await fetchDeviceList()
const device = list?.find((item) => item.deviceId === deviceId.value)
if (!device) return
if (device.mac) deviceMac.value = device.mac
bluetoothConnected.value = isBluetoothConnected(device.bluetoothConnected)
charging.value = device.charging === '1'
if (device.status !== undefined) deviceStatus.value = device.status
isOnline.value = device.status === '1'
} catch {
// 列表刷新失败时保留路由参数状态
}
}
async function loadRuntime() {
if (!deviceId.value) return
loading.value = true
try {
const [runtime] = await Promise.all([
fetchDeviceRuntime({ deviceId: deviceId.value }),
refreshDeviceMeta(),
])
applyRuntime(runtime)
} catch (err) {
uni.showToast({ title: getErrorMessage(err, '加载设备数据失败'), icon: 'none' })
} finally {
loading.value = false
}
}
async function patchRuntime(patch: { runMode?: RunMode; fanLevel?: FanLevel }) {
if (!deviceId.value || updating.value) return
updating.value = true
try {
await updateDeviceRuntime({
deviceId: deviceId.value,
...patch,
})
const data = await fetchDeviceRuntime({ deviceId: deviceId.value })
applyRuntime(data)
await refreshDeviceMeta()
} catch (err) {
uni.showToast({ title: getErrorMessage(err, '设置失败'), icon: 'none' })
} finally {
updating.value = false
}
}
function onSelectRunMode(mode: RunMode) {
if (runMode.value === mode) return
void patchRuntime({ runMode: mode })
}
function onSelectFanLevel(level: FanLevel) {
if (!isAutoRunMode(runMode.value)) return
if (fanLevel.value === level) return
void patchRuntime({ fanLevel: level })
}
function onSettings() {
uni.showToast({ title: '设备设置功能开发中', icon: 'none' })
}
function onFilterReplace() {
if (filterReplaced.value) return
filterReplaceDialogVisible.value = true
}
function onConfirmFilterReplace() {
filterReplaced.value = true
uni.showToast({ title: '操作成功', icon: 'success' })
}
const { startSyncLoop } = useDeviceBluetoothSync({
getDeviceId: () => deviceId.value,
getMac: () => deviceMac.value,
getRuntimePatch: () => ({
runMode: runMode.value,
fanLevel: fanLevel.value,
tvoc: tvoc.value ?? undefined,
powerMode: powerMode.value,
charging: charging.value ? '1' : '0',
}),
onBluetoothConnectedChange: (connected) => {
bluetoothConnected.value = connected
if (connected) {
isOnline.value = true
deviceStatus.value = '1'
}
},
onSyncTextChange: (text) => {
syncText.value = text
},
onSynced: async () => {
await refreshDeviceMeta()
const data = await fetchDeviceRuntime({ deviceId: deviceId.value })
applyRuntime(data)
},
})
onLoad((query?: DeviceDetailRouteQuery) => {
deviceId.value = Number(query?.deviceId) || 0
if (query?.name) deviceName.value = decodeURIComponent(String(query.name))
if (query?.locationName) {
locationName.value = decodeURIComponent(String(query.locationName))
}
if (query?.status) {
deviceStatus.value = String(query.status)
isOnline.value = query.status === '1'
}
if (query?.mac) deviceMac.value = decodeURIComponent(String(query.mac))
bluetoothConnected.value = isBluetoothConnected(query?.bluetoothConnected)
charging.value = query?.charging === '1'
void loadRuntime().then(() => {
startSyncLoop()
})
})
return {
deviceName,
connectionText,
isOnline,
tvocDisplay,
tvocLevel,
syncText,
runMode,
fanLevel,
powerModeText,
filterLife,
filterRemainText,
filterReplaced,
filterReplaceDialogVisible,
batteryDisplay,
batteryLevel,
charging,
loading,
showFanLevelControl,
runModeOptions: RUN_MODE_OPTIONS,
fanLevelOptions,
onSelectRunMode,
onSelectFanLevel,
onSettings,
onFilterReplace,
onConfirmFilterReplace,
}
}

View File

@@ -0,0 +1,11 @@
import { request } from '@/api/request'
import type { AppDeviceVo, DeviceQueryBody } from './device.types'
/** POST /app/device/list — 查询当前用户的设备列表 */
export function fetchDeviceList(query: DeviceQueryBody = {}) {
return request<AppDeviceVo[]>({
url: '/app/device/list',
method: 'POST',
data: query,
})
}

View File

@@ -1,6 +1,7 @@
export interface RoomTab { export interface RoomTab {
id: string id: string
name: string name: string
locationId?: number
} }
export interface HotDeviceItem { export interface HotDeviceItem {
@@ -8,3 +9,34 @@ export interface HotDeviceItem {
name: string name: string
desc: string desc: string
} }
/** POST /app/device/list 查询参数 */
export interface DeviceQueryBody {
homeId?: number
locationId?: number
}
/** 设备状态:0未激活 1在线 2离线 */
export type DeviceStatus = '0' | '1' | '2'
/** POST /app/device/list 返回项 */
export interface AppDeviceVo {
deviceId?: number
deviceNo?: string
name?: string
imei?: string
mac?: string
homeId?: number
locationId?: number
status?: DeviceStatus | string
bluetoothConnected?: string
signalLevel?: string
charging?: string
bindTime?: string
}
export interface DeviceListItem extends AppDeviceVo {
locationName: string
isOnline: boolean
isCharging: boolean
}

View File

@@ -0,0 +1,67 @@
import type { AppDeviceVo, DeviceListItem, RoomTab } from './device.types'
/** 位置 ID → 房间名(后端暂无 locationName 时的本地映射) */
const LOCATION_NAME_MAP: Record<number, string> = {
1: '客厅',
2: '主卧',
3: '卧室',
4: '厨房',
5: '卫生间',
6: '书房',
}
export function resolveLocationName(locationId?: number): string {
if (locationId == null) return '未分配'
return LOCATION_NAME_MAP[locationId] ?? `位置${locationId}`
}
export function mapDeviceItem(device: AppDeviceVo): DeviceListItem {
return {
...device,
locationName: resolveLocationName(device.locationId),
isOnline: device.status === '1',
isCharging: device.charging === '1',
}
}
export function buildRoomTabs(
devices: DeviceListItem[],
customRooms: RoomTab[] = [],
): RoomTab[] {
const tabs: RoomTab[] = [{ id: 'all', name: '全部' }]
const seen = new Set<string>()
for (const device of devices) {
if (device.locationId == null) continue
const id = String(device.locationId)
if (seen.has(id)) continue
seen.add(id)
tabs.push({
id,
name: device.locationName,
locationId: device.locationId,
})
}
for (const room of customRooms) {
if (seen.has(room.id)) continue
seen.add(room.id)
tabs.push(room)
}
return tabs
}
export function filterDevicesByRoom(
devices: DeviceListItem[],
activeRoomId: string,
): DeviceListItem[] {
if (activeRoomId === 'all') return devices
const locationId = Number(activeRoomId)
if (Number.isNaN(locationId)) return devices
return devices.filter((item) => item.locationId === locationId)
}
export function countOnlineDevices(devices: DeviceListItem[]): number {
return devices.filter((item) => item.isOnline).length
}

View File

@@ -2,8 +2,10 @@
<view class="device-page"> <view class="device-page">
<device-header <device-header
:home-name="homeName" :home-name="homeName"
@home="onHomePicker" @manage="onHomeManage"
@notify="onNotify" @notify="onNotify"
@add-device="onStartBind"
@scan="onScan"
/> />
<room-tabs <room-tabs
:rooms="rooms" :rooms="rooms"
@@ -11,8 +13,25 @@
@select="selectRoom" @select="selectRoom"
@add="onAddRoom" @add="onAddRoom"
/> />
<device-list
v-if="hasDevices"
:devices="devices"
:online-count="onlineCount"
@manage="onManageDevices"
@select="onSelectDevice"
/>
<template v-else-if="!loading">
<device-empty :step-hint="bindStepHint" @bind="onStartBind" /> <device-empty :step-hint="bindStepHint" @bind="onStartBind" />
<hot-devices :items="hotDevices" @mall="onGoMall" /> <hot-devices :items="hotDevices" @mall="onGoMall" />
</template>
<room-location-dialog
v-model:visible="roomDialogVisible"
@confirm="onConfirmAddRoom"
/>
<operation-success-toast v-model:visible="successToastVisible" />
<app-tab-bar :current="tabCurrent" /> <app-tab-bar :current="tabCurrent" />
</view> </view>
</template> </template>
@@ -21,7 +40,10 @@
import { useTabPage } from '@/hooks/use-tab-page' import { useTabPage } from '@/hooks/use-tab-page'
import DeviceEmpty from './components/device-empty/index.vue' import DeviceEmpty from './components/device-empty/index.vue'
import DeviceHeader from './components/device-header/index.vue' import DeviceHeader from './components/device-header/index.vue'
import DeviceList from './components/device-list/index.vue'
import HotDevices from './components/hot-devices/index.vue' import HotDevices from './components/hot-devices/index.vue'
import OperationSuccessToast from './components/operation-success-toast/index.vue'
import RoomLocationDialog from './components/room-location-dialog/index.vue'
import RoomTabs from './components/room-tabs/index.vue' import RoomTabs from './components/room-tabs/index.vue'
import { useDevice } from './use-device' import { useDevice } from './use-device'
@@ -31,14 +53,24 @@ const {
homeName, homeName,
rooms, rooms,
activeRoomId, activeRoomId,
devices,
onlineCount,
hasDevices,
loading,
hotDevices, hotDevices,
bindStepHint, bindStepHint,
roomDialogVisible,
successToastVisible,
selectRoom, selectRoom,
onAddRoom, onAddRoom,
onConfirmAddRoom,
onStartBind, onStartBind,
onScan,
onGoMall, onGoMall,
onNotify, onNotify,
onHomePicker, onHomeManage,
onManageDevices,
onSelectDevice,
} = useDevice() } = useDevice()
</script> </script>

View File

@@ -1,10 +1,16 @@
import { ref } from 'vue' import { onShow } from '@dcloudio/uni-app'
import type { HotDeviceItem, RoomTab } from './device.types' import { computed, ref } from 'vue'
import { getErrorMessage } from '@/utils/api-error'
import { fetchDeviceList } from './device.api'
import type { DeviceListItem, HotDeviceItem, RoomTab } from './device.types'
import {
buildRoomTabs,
countOnlineDevices,
filterDevicesByRoom,
mapDeviceItem,
} from './device.utils'
const DEFAULT_ROOMS: RoomTab[] = [ const CUSTOM_ROOMS_KEY = 'device_custom_rooms'
{ id: 'all', name: '全部' },
{ id: 'living', name: '客厅' },
]
const DEFAULT_HOT_DEVICES: HotDeviceItem[] = [ const DEFAULT_HOT_DEVICES: HotDeviceItem[] = [
{ {
@@ -19,25 +25,98 @@ const DEFAULT_HOT_DEVICES: HotDeviceItem[] = [
}, },
] ]
function loadCustomRooms(): RoomTab[] {
try {
const raw = uni.getStorageSync(CUSTOM_ROOMS_KEY) as RoomTab[] | string
if (Array.isArray(raw)) return raw
if (typeof raw === 'string' && raw) return JSON.parse(raw) as RoomTab[]
} catch {
// ignore
}
return []
}
function saveCustomRooms(rooms: RoomTab[]) {
uni.setStorageSync(CUSTOM_ROOMS_KEY, rooms)
}
export function useDevice() { export function useDevice() {
const homeName = ref('我的家') const homeName = ref('我的家')
const rooms = ref<RoomTab[]>(DEFAULT_ROOMS) const customRooms = ref<RoomTab[]>(loadCustomRooms())
const rooms = ref<RoomTab[]>([{ id: 'all', name: '全部' }])
const activeRoomId = ref('all') const activeRoomId = ref('all')
const devices = ref<DeviceListItem[]>([])
const loading = ref(false)
const hotDevices = ref<HotDeviceItem[]>(DEFAULT_HOT_DEVICES) const hotDevices = ref<HotDeviceItem[]>(DEFAULT_HOT_DEVICES)
const bindStepHint = ref(3) const bindStepHint = ref(3)
const roomDialogVisible = ref(false)
const successToastVisible = ref(false)
const hasDevices = computed(() => devices.value.length > 0)
const filteredDevices = computed(() =>
filterDevicesByRoom(devices.value, activeRoomId.value),
)
const onlineCount = computed(() => countOnlineDevices(filteredDevices.value))
function refreshRoomTabs() {
rooms.value = buildRoomTabs(devices.value, customRooms.value)
if (!rooms.value.some((room) => room.id === activeRoomId.value)) {
activeRoomId.value = 'all'
}
}
async function loadDevices() {
if (loading.value) return
loading.value = true
try {
const list = await fetchDeviceList()
devices.value = (list ?? []).map(mapDeviceItem)
refreshRoomTabs()
} catch (err) {
uni.showToast({ title: getErrorMessage(err, '加载设备失败'), icon: 'none' })
} finally {
loading.value = false
}
}
onShow(() => {
void loadDevices()
})
function showOperationSuccess() {
successToastVisible.value = true
}
function selectRoom(id: string) { function selectRoom(id: string) {
activeRoomId.value = id activeRoomId.value = id
} }
function onAddRoom() { function onAddRoom() {
uni.showToast({ title: '添加房间功能开发中', icon: 'none' }) roomDialogVisible.value = true
}
function onConfirmAddRoom(name: string) {
const room: RoomTab = {
id: `custom-${Date.now()}`,
name,
}
customRooms.value = [...customRooms.value, room]
saveCustomRooms(customRooms.value)
refreshRoomTabs()
activeRoomId.value = room.id
showOperationSuccess()
} }
function onStartBind() { function onStartBind() {
uni.navigateTo({ url: '/pages/add-device/index' }) uni.navigateTo({ url: '/pages/add-device/index' })
} }
function onScan() {
uni.showToast({ title: '扫一扫功能开发中', icon: 'none' })
}
function onGoMall() { function onGoMall() {
uni.showToast({ title: '商城功能开发中', icon: 'none' }) uni.showToast({ title: '商城功能开发中', icon: 'none' })
} }
@@ -46,21 +125,53 @@ export function useDevice() {
uni.showToast({ title: '消息功能开发中', icon: 'none' }) uni.showToast({ title: '消息功能开发中', icon: 'none' })
} }
function onHomePicker() { function onHomeManage() {
uni.showToast({ title: '切换家庭功能开发中', icon: 'none' }) uni.showToast({ title: '家庭管理功能开发中', icon: 'none' })
}
function onManageDevices() {
uni.showToast({ title: '设备管理功能开发中', icon: 'none' })
}
function onSelectDevice(device: DeviceListItem) {
if (!device.deviceId) {
uni.showToast({ title: '设备信息不完整', icon: 'none' })
return
}
const q = [
`deviceId=${device.deviceId}`,
`name=${encodeURIComponent(device.name || '')}`,
`locationName=${encodeURIComponent(device.locationName || '')}`,
`status=${device.status ?? ''}`,
`bluetoothConnected=${device.bluetoothConnected ?? '0'}`,
`charging=${device.charging ?? '0'}`,
...(device.mac ? [`mac=${encodeURIComponent(device.mac)}`] : []),
].join('&')
uni.navigateTo({ url: `/pages/device/detail/index?${q}` })
} }
return { return {
homeName, homeName,
rooms, rooms,
activeRoomId, activeRoomId,
devices: filteredDevices,
onlineCount,
hasDevices,
loading,
hotDevices, hotDevices,
bindStepHint, bindStepHint,
roomDialogVisible,
successToastVisible,
selectRoom, selectRoom,
onAddRoom, onAddRoom,
onConfirmAddRoom,
onStartBind, onStartBind,
onScan,
onGoMall, onGoMall,
onNotify, onNotify,
onHomePicker, onHomeManage,
onManageDevices,
onSelectDevice,
loadDevices,
} }
} }

View File

@@ -1,4 +1,4 @@
import { request } from '@/api/request' import { requestLogin } from '@/api/request'
import type { AuthTokenResult, LoginPasswordBody, LoginSmsBody } from '@/types/api' import type { AuthTokenResult, LoginPasswordBody, LoginSmsBody } from '@/types/api'
import type { LoginCodeForm, LoginPasswordForm } from './login.types' import type { LoginCodeForm, LoginPasswordForm } from './login.types'
@@ -8,7 +8,7 @@ export function loginBySms(data: LoginCodeForm) {
phonenumber: data.phone.trim(), phonenumber: data.phone.trim(),
smsCode: data.code.trim(), smsCode: data.code.trim(),
} }
return request<AuthTokenResult>({ return requestLogin<AuthTokenResult>({
url: '/app/auth/login/sms', url: '/app/auth/login/sms',
method: 'POST', method: 'POST',
data: body, data: body,
@@ -21,7 +21,7 @@ export function loginByPassword(data: LoginPasswordForm) {
account: data.account.trim(), account: data.account.trim(),
password: data.password, password: data.password,
} }
return request<AuthTokenResult>({ return requestLogin<AuthTokenResult>({
url: '/app/auth/login/password', url: '/app/auth/login/password',
method: 'POST', method: 'POST',
data: body, data: body,

View File

@@ -1,8 +1,8 @@
import { ref } from 'vue' import { ref } from 'vue'
import { useSmsCode } from '@/hooks/use-sms-code' import { useSmsCode } from '@/hooks/use-sms-code'
import { isValidPassword, isValidPhone, isValidSmsCode } from '@/utils/validate' import { isValidPassword, isValidPhone, isValidSmsCode } from '@/utils/validate'
import { saveAuthToken } from '@/api/auth'
import { getErrorMessage } from '@/utils/api-error' import { getErrorMessage } from '@/utils/api-error'
import { getStoredToken } from '@/utils/auth-token'
import { SMS_CODE_SCENE } from '@/types/api' import { SMS_CODE_SCENE } from '@/types/api'
import { loginByPassword, loginBySms } from './login.api' import { loginByPassword, loginBySms } from './login.api'
import type { LoginMode } from './login.types' import type { LoginMode } from './login.types'
@@ -44,8 +44,7 @@ export function useLogin() {
uni.showToast({ title: '请输入验证码', icon: 'none' }) uni.showToast({ title: '请输入验证码', icon: 'none' })
return return
} }
const smsRes = await loginBySms({ phone: phone.value, code: code.value }) await loginBySms({ phone: phone.value, code: code.value })
saveAuthToken(smsRes)
} else { } else {
if (!account.value.trim()) { if (!account.value.trim()) {
uni.showToast({ title: '请输入账号', icon: 'none' }) uni.showToast({ title: '请输入账号', icon: 'none' })
@@ -55,11 +54,14 @@ export function useLogin() {
uni.showToast({ title: '密码至少 6 位', icon: 'none' }) uni.showToast({ title: '密码至少 6 位', icon: 'none' })
return return
} }
const pwdRes = await loginByPassword({ await loginByPassword({
account: account.value, account: account.value,
password: password.value, password: password.value,
}) })
saveAuthToken(pwdRes) }
if (!getStoredToken()) {
uni.showToast({ title: '登录失败,未获取到令牌', icon: 'none' })
return
} }
uni.showToast({ title: '登录成功', icon: 'success' }) uni.showToast({ title: '登录成功', icon: 'success' })
uni.reLaunch({ url: '/pages/device/index' }) uni.reLaunch({ url: '/pages/device/index' })

BIN
src/static/完成@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 538 B

BIN
src/static/组 15226@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

BIN
src/static/组 15227@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1016 B

BIN
src/static/路径1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 702 B

View File

@@ -42,6 +42,10 @@ export interface RegisterBody {
export interface AuthTokenResult { export interface AuthTokenResult {
token?: string token?: string
accessToken?: string accessToken?: string
/** RuoYi-Vue-Plus 登录响应 */
access_token?: string
expire_in?: number
client_id?: string
} }
/** POST /app/auth/forgetPassword/verifyCode */ /** POST /app/auth/forgetPassword/verifyCode */

44
src/utils/auth-token.ts Normal file
View File

@@ -0,0 +1,44 @@
const TOKEN_KEY = 'token'
/** 从登录响应中提取 access_token(兼容 RuoYi-Vue-Plus) */
export function extractAuthToken(payload: unknown): string {
if (payload == null) return ''
if (typeof payload === 'string') {
return payload.replace(/^Bearer\s+/i, '').trim()
}
if (typeof payload === 'object') {
const record = payload as Record<string, unknown>
for (const key of ['access_token', 'accessToken', 'token']) {
const value = record[key]
if (typeof value === 'string' && value.trim()) {
return value.replace(/^Bearer\s+/i, '').trim()
}
}
if (record.data != null) {
const nested = extractAuthToken(record.data)
if (nested) return nested
}
}
return ''
}
export function getStoredToken(): string {
const raw = (uni.getStorageSync(TOKEN_KEY) as string) || ''
return raw.replace(/^Bearer\s+/i, '').trim()
}
export function saveStoredToken(token: string): string {
const normalized = token.replace(/^Bearer\s+/i, '').trim()
if (!normalized) {
throw new Error('empty token')
}
uni.setStorageSync(TOKEN_KEY, normalized)
return normalized
}
export function clearStoredToken(): void {
uni.removeStorageSync(TOKEN_KEY)
}

292
src/utils/bluetooth.ts Normal file
View File

@@ -0,0 +1,292 @@
export class BluetoothError extends Error {
errCode?: number
constructor(message: string, errCode?: number) {
super(message)
this.name = 'BluetoothError'
this.errCode = errCode
}
}
type UniFail = UniApp.GeneralCallbackResult
function toBluetoothError(err: UniFail, fallback: string): BluetoothError {
const msg = err.errMsg || fallback
return new BluetoothError(getBluetoothErrorMessage({ errCode: err.errCode, errMsg: msg }), err.errCode)
}
export function isBluetoothApiSupported(): boolean {
return (
typeof uni.openBluetoothAdapter === 'function'
&& typeof uni.startBluetoothDevicesDiscovery === 'function'
&& typeof uni.onBluetoothDeviceFound === 'function'
)
}
function openAdapter(): Promise<void> {
return new Promise((resolve, reject) => {
uni.openBluetoothAdapter({
success: () => resolve(),
fail: (err) => reject(toBluetoothError(err, '蓝牙初始化失败')),
})
})
}
function getAdapterState(): Promise<UniApp.GetBluetoothAdapterStateSuccess> {
return new Promise((resolve, reject) => {
uni.getBluetoothAdapterState({
success: (res) => resolve(res),
fail: (err) => reject(toBluetoothError(err, '获取蓝牙状态失败')),
})
})
}
/** 初始化蓝牙适配器并确认可用 */
export async function ensureBluetoothReady(): Promise<void> {
if (!isBluetoothApiSupported()) {
throw new BluetoothError('当前环境不支持蓝牙,请使用 App 或微信小程序')
}
try {
await openAdapter()
} catch (err) {
const btErr = err as BluetoothError
if (btErr.errCode !== 10001) {
try {
const state = await getAdapterState()
if (state.available) return
} catch {
// fall through
}
}
throw err
}
const state = await getAdapterState()
if (!state.available) {
throw new BluetoothError('本机不支持蓝牙', 10009)
}
}
export function startBluetoothDiscovery(): Promise<void> {
if (typeof uni.startBluetoothDevicesDiscovery !== 'function') {
return Promise.reject(new BluetoothError('当前环境不支持蓝牙扫描'))
}
return new Promise((resolve, reject) => {
uni.startBluetoothDevicesDiscovery({
allowDuplicatesKey: false,
success: () => resolve(),
fail: (err) => reject(toBluetoothError(err, '开始扫描失败')),
})
})
}
export function stopBluetoothDiscovery(): Promise<void> {
if (typeof uni.stopBluetoothDevicesDiscovery !== 'function') {
return Promise.resolve()
}
return new Promise((resolve, reject) => {
uni.stopBluetoothDevicesDiscovery({
success: () => resolve(),
fail: (err) => reject(toBluetoothError(err, '停止扫描失败')),
})
})
}
export function getDiscoveredBluetoothDevices(): Promise<UniApp.BluetoothDeviceInfo[]> {
if (typeof uni.getBluetoothDevices !== 'function') {
return Promise.resolve([])
}
return new Promise((resolve, reject) => {
uni.getBluetoothDevices({
success: (res) => resolve(res.devices ?? []),
fail: (err) => reject(toBluetoothError(err, '获取已发现设备失败')),
})
})
}
export function subscribeBluetoothDevicesFound(
onFound: (devices: UniApp.BluetoothDeviceInfo[]) => void,
): () => void {
if (typeof uni.onBluetoothDeviceFound !== 'function') {
return () => {}
}
const handler = (res: UniApp.OnBluetoothDeviceFoundCallbackResult) => {
if (res.devices?.length) onFound(res.devices)
}
uni.onBluetoothDeviceFound(handler)
return () => {
if (typeof uni.offBluetoothDeviceFound !== 'function') return
uni.offBluetoothDeviceFound(handler)
}
}
const MAC_PATTERN = /^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$/
/** 规范化为大写 MAC(AA:BB:CC:DD:EE:FF) */
export function normalizeMacAddress(value: string): string | undefined {
const trimmed = value.trim()
if (MAC_PATTERN.test(trimmed)) return trimmed.toUpperCase()
const compact = trimmed.replace(/[^0-9A-Fa-f]/g, '').toUpperCase()
if (compact.length !== 12) return undefined
return compact.replace(/(.{2})(?=.)/g, '$1:')
}
/** 去掉分隔符的大写 MAC */
export function compactMacAddress(value: string): string {
return value.replace(/[^0-9A-Fa-f]/g, '').toUpperCase()
}
export function deviceMatchesMac(
device: UniApp.BluetoothDeviceInfo,
mac: string,
): boolean {
const target = compactMacAddress(mac)
if (!target) return false
const deviceMac = normalizeMacAddress(device.deviceId)
if (deviceMac && compactMacAddress(deviceMac) === target) return true
const compactId = compactMacAddress(device.deviceId)
return compactId === target
}
/** RSSI → signalLevel:0离线 1弱 2一般 3强 */
export function rssiToSignalLevel(rssi?: number): string {
if (rssi == null) return '0'
if (rssi >= -55) return '3'
if (rssi >= -70) return '2'
if (rssi >= -85) return '1'
return '0'
}
export function createBleConnection(bleDeviceId: string): Promise<void> {
if (typeof uni.createBLEConnection !== 'function') {
return Promise.reject(new BluetoothError('当前环境不支持蓝牙连接'))
}
return new Promise((resolve, reject) => {
uni.createBLEConnection({
deviceId: bleDeviceId,
timeout: 10000,
success: () => resolve(),
fail: (err) => reject(toBluetoothError(err, '连接设备失败')),
})
})
}
export function closeBleConnection(bleDeviceId: string): Promise<void> {
if (typeof uni.closeBLEConnection !== 'function') {
return Promise.resolve()
}
return new Promise((resolve, reject) => {
uni.closeBLEConnection({
deviceId: bleDeviceId,
success: () => resolve(),
fail: (err) => reject(toBluetoothError(err, '断开连接失败')),
})
})
}
export function subscribeBleConnectionStateChange(
onChange: (res: { deviceId: string; connected: boolean }) => void,
): () => void {
if (typeof uni.onBLEConnectionStateChange !== 'function') {
return () => {}
}
uni.onBLEConnectionStateChange(onChange)
return () => {
if (typeof uni.offBLEConnectionStateChange !== 'function') return
uni.offBLEConnectionStateChange(onChange)
}
}
/** 扫描并匹配指定 MAC 的 BLE 设备 */
export async function scanForBleDeviceByMac(
mac: string,
options: { timeoutMs?: number } = {},
): Promise<UniApp.BluetoothDeviceInfo | null> {
const timeoutMs = options.timeoutMs ?? 15000
const normalizedMac = normalizeMacAddress(mac)
if (!normalizedMac) return null
await ensureBluetoothReady()
let matched: UniApp.BluetoothDeviceInfo | null = null
const pickMatch = (devices: UniApp.BluetoothDeviceInfo[]) => {
for (const device of devices) {
if (deviceMatchesMac(device, normalizedMac)) {
matched = device
return true
}
}
return false
}
const existing = await getDiscoveredBluetoothDevices()
if (pickMatch(existing)) {
return matched
}
const unsubscribe = subscribeBluetoothDevicesFound((devices) => {
pickMatch(devices)
})
try {
await startBluetoothDiscovery()
} catch (err) {
unsubscribe()
throw err
}
const startedAt = Date.now()
while (!matched && Date.now() - startedAt < timeoutMs) {
await new Promise((resolve) => setTimeout(resolve, 300))
const cached = await getDiscoveredBluetoothDevices()
if (pickMatch(cached)) break
}
unsubscribe()
await stopBluetoothDiscovery().catch(() => {})
return matched
}
export function getBluetoothErrorMessage(err: unknown): string {
if (err instanceof BluetoothError) return err.message
const code = (err as UniFail)?.errCode
const msg = (err as UniFail)?.errMsg
switch (code) {
case 10001:
return '请先打开手机蓝牙后再试'
case 10002:
return '没有找到指定设备'
case 10003:
return '连接失败,请靠近设备后重试'
case 10004:
return '没有找到指定服务'
case 10005:
return '没有找到指定特征值'
case 10006:
return '当前连接已断开'
case 10007:
return '当前特征值不支持此操作'
case 10008:
return '蓝牙操作失败,请重试'
case 10009:
return '本机不支持低功耗蓝牙'
case 10010:
return '设备已连接'
case 10011:
return '配对设备需要配对码'
case 10012:
return '连接超时,请重试'
case 10013:
return '设备标识无效'
default:
return msg || '蓝牙不可用,请检查权限与系统设置'
}
}

View File

@@ -4,8 +4,12 @@ import iconPhone from '@/static/icon-phone@2x.png'
import iconCode from '@/static/icon-code@2x.png' import iconCode from '@/static/icon-code@2x.png'
import iconChecked from '@/static/icon-checked@2x.png' import iconChecked from '@/static/icon-checked@2x.png'
import iconPassword from '@/static/路径.png' import iconPassword from '@/static/路径.png'
import iconDeviceDetailSettings from '@/static/路径1.png'
import deviceDetailHeroBg from '@/static/组 15226@2x.png'
import iconRunModeInfo from '@/static/组 15227@2x.png'
import iconHomeSelector from '@/static/图标 4.png' import iconHomeSelector from '@/static/图标 4.png'
import iconHomeArrow from '@/static/组 9.png' import iconHomeArrow from '@/static/组 9.png'
import iconHomeSelected from '@/static/完成@2x.png'
import iconNotify from '@/static/组 27.png' import iconNotify from '@/static/组 27.png'
import iconDeviceEmpty from '@/static/图标 8.png' import iconDeviceEmpty from '@/static/图标 8.png'
import iconDeviceNameEdit from '@/static/图标 9.png' import iconDeviceNameEdit from '@/static/图标 9.png'
@@ -33,10 +37,18 @@ export const staticAssets = {
iconChecked, iconChecked,
/** 路径.png — 密码框 */ /** 路径.png — 密码框 */
iconPassword, iconPassword,
/** 路径1.png — 设备详情右上角设置 */
iconDeviceDetailSettings,
/** 组 15226@2x.png — 设备详情页上半部分背景 */
deviceDetailHeroBg,
/** 组 15227@2x.png — 设备详情「运行模式」旁提示图标 */
iconRunModeInfo,
/** 图标 4.png — 首页「我的家」 */ /** 图标 4.png — 首页「我的家」 */
iconHomeSelector, iconHomeSelector,
/** 组 9.png — 「我的家」下拉箭头 */ /** 组 9.png — 「我的家」下拉箭头 */
iconHomeArrow, iconHomeArrow,
/** 完成@2x.png — 「我的家」下拉选中图标 */
iconHomeSelected,
/** 组 27.png — 设备页右上角通知 */ /** 组 27.png — 设备页右上角通知 */
iconNotify, iconNotify,
/** 图标 8.png — 设备空状态中心图标 */ /** 图标 8.png — 设备空状态中心图标 */

1
tmp-openapi.json Normal file

File diff suppressed because one or more lines are too long