feat:绑定设备和设备详情
This commit is contained in:
@@ -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
|
|
||||||
|
|||||||
@@ -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
14
.hbuilderx/launch.json
Normal 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
1
.openapi-cache.json
Normal file
File diff suppressed because one or more lines are too long
0
.tmp-openapi-out.txt
Normal file
0
.tmp-openapi-out.txt
Normal file
60
package-lock.json
generated
60
package-lock.json
generated
@@ -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
24
project.config.json
Normal 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": {}
|
||||||
|
}
|
||||||
15
project.private.config.json
Normal file
15
project.private.config.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +1,79 @@
|
|||||||
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 {
|
||||||
|
'Content-Type': 'application/json;charset=UTF-8',
|
||||||
|
clientId: API_CLIENT_ID,
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
...(extra as Record<string, string> | undefined),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSuccess<T>(
|
||||||
|
res: UniApp.RequestSuccessCallbackResult,
|
||||||
|
resolve: (value: T) => void,
|
||||||
|
reject: (reason: ApiError) => void,
|
||||||
|
saveTokenFromBody = false,
|
||||||
|
) {
|
||||||
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||||
|
const body = res.data as ApiResponse | undefined
|
||||||
|
reject(
|
||||||
|
new ApiError(
|
||||||
|
body?.msg || body?.message || `请求失败(${res.statusCode})`,
|
||||||
|
body?.code ?? res.statusCode,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (res.data ?? {}) as ApiResponse<T> & Record<string, unknown>
|
||||||
|
if (!isApiSuccess(body)) {
|
||||||
|
reject(new ApiError(body.msg || body.message || '请求失败', body.code))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (saveTokenFromBody) {
|
||||||
|
const token = extractAuthToken(body)
|
||||||
|
if (token) saveStoredToken(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(unwrapApiData<T>(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function request<T = unknown>(options: RequestOptions): Promise<T> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
uni.request({
|
uni.request({
|
||||||
...options,
|
...options,
|
||||||
url: `${BASE_URL}${options.url}`,
|
url: `${BASE_URL}${options.url}`,
|
||||||
header: {
|
header: buildAuthHeaders(options.header),
|
||||||
'Content-Type': 'application/json',
|
success: (res) => handleSuccess<T>(res, resolve, reject),
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
fail: (err) => {
|
||||||
...options.header,
|
reject(new ApiError(err.errMsg || '网络异常'))
|
||||||
},
|
},
|
||||||
success: (res) => {
|
})
|
||||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
})
|
||||||
const body = res.data as ApiResponse | undefined
|
}
|
||||||
reject(
|
|
||||||
new ApiError(
|
/** 登录类接口:从完整响应体提取并保存 token */
|
||||||
body?.msg || body?.message || `请求失败(${res.statusCode})`,
|
export function requestLogin<T = unknown>(options: RequestOptions): Promise<T> {
|
||||||
body?.code ?? res.statusCode,
|
return new Promise((resolve, reject) => {
|
||||||
),
|
uni.request({
|
||||||
)
|
...options,
|
||||||
return
|
url: `${BASE_URL}${options.url}`,
|
||||||
}
|
header: buildAuthHeaders(options.header),
|
||||||
|
success: (res) => handleSuccess<T>(res, resolve, reject, true),
|
||||||
const body = (res.data ?? {}) as ApiResponse<T> & Record<string, unknown>
|
|
||||||
if (!isApiSuccess(body)) {
|
|
||||||
reject(
|
|
||||||
new ApiError(body.msg || body.message || '请求失败', body.code),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve(unwrapApiData<T>(body))
|
|
||||||
},
|
|
||||||
fail: (err) => {
|
fail: (err) => {
|
||||||
reject(new ApiError(err.errMsg || '网络异常'))
|
reject(new ApiError(err.errMsg || '网络异常'))
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
11
src/pages/add-device/add-device.api.ts
Normal file
11
src/pages/add-device/add-device.api.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
uni.showToast({ title: '添加成功', icon: 'success' })
|
if (!deviceNo.value.trim()) {
|
||||||
setTimeout(() => {
|
uni.showToast({ title: '缺少设备编号', icon: 'none' })
|
||||||
uni.reLaunch({ url: '/pages/device/index' })
|
return
|
||||||
}, 400)
|
}
|
||||||
|
if (loading.value) return
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await bindDevice({
|
||||||
|
deviceNo: deviceNo.value.trim(),
|
||||||
|
name,
|
||||||
|
...(productId.value != null ? { productId: productId.value } : {}),
|
||||||
|
...(model.value ? { model: model.value } : {}),
|
||||||
|
...(imei.value ? { imei: imei.value } : {}),
|
||||||
|
...(mac.value ? { mac: mac.value } : {}),
|
||||||
|
...(locationId.value != null ? { locationId: locationId.value } : {}),
|
||||||
|
})
|
||||||
|
uni.showToast({ title: '添加成功', icon: 'success' })
|
||||||
|
setTimeout(() => {
|
||||||
|
uni.reLaunch({ url: '/pages/device/index' })
|
||||||
|
}, 400)
|
||||||
|
} catch (err) {
|
||||||
|
uni.showToast({ title: getErrorMessage(err, '绑定失败,请重试'), icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -36,6 +87,8 @@ export function useCompleteDevice() {
|
|||||||
deviceName,
|
deviceName,
|
||||||
serialMasked,
|
serialMasked,
|
||||||
roomName,
|
roomName,
|
||||||
|
loading,
|
||||||
|
applyQuery,
|
||||||
onPickRoom,
|
onPickRoom,
|
||||||
onFinishAdd,
|
onFinishAdd,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
37
src/pages/add-device/scan/scan.utils.ts
Normal file
37
src/pages/add-device/scan/scan.utils.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
uni.navigateTo({ url: '/pages/add-device/scan/index' })
|
if (opening.value) return
|
||||||
|
opening.value = true
|
||||||
|
try {
|
||||||
|
await ensureBluetoothReady()
|
||||||
|
uni.navigateTo({ url: '/pages/add-device/scan/index' })
|
||||||
|
} catch (err) {
|
||||||
|
uni.showModal({
|
||||||
|
title: '无法开启蓝牙',
|
||||||
|
content: getBluetoothErrorMessage(err),
|
||||||
|
showCancel: false,
|
||||||
|
confirmText: '知道了',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
opening.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onBindLater() {
|
function onBindLater() {
|
||||||
@@ -42,6 +59,7 @@ export function useAddDevice() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
currentStep,
|
currentStep,
|
||||||
|
opening,
|
||||||
onOpenBluetooth,
|
onOpenBluetooth,
|
||||||
onBindLater,
|
onBindLater,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ?? {})
|
||||||
|
|||||||
147
src/pages/device/components/device-card/index.vue
Normal file
147
src/pages/device/components/device-card/index.vue
Normal 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>
|
||||||
@@ -1,49 +1,136 @@
|
|||||||
<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">
|
||||||
<image
|
<view class="device-header__home" @tap.stop="togglePicker">
|
||||||
class="device-header__home-icon"
|
<image
|
||||||
:src="staticAssets.iconHomeSelector"
|
class="device-header__home-icon"
|
||||||
mode="aspectFit"
|
:src="staticAssets.iconHomeSelector"
|
||||||
/>
|
mode="aspectFit"
|
||||||
<text class="device-header__home-text">{{ homeName }}</text>
|
/>
|
||||||
<image
|
<text class="device-header__home-text">{{ homeName }}</text>
|
||||||
class="device-header__arrow"
|
<image
|
||||||
:src="staticAssets.iconHomeArrow"
|
class="device-header__arrow"
|
||||||
mode="aspectFit"
|
:class="{ 'device-header__arrow--open': pickerOpen }"
|
||||||
|
:src="staticAssets.iconHomeArrow"
|
||||||
|
mode="aspectFit"
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
<view class="device-header__notify-wrap" @tap="$emit('notify')">
|
|
||||||
<image
|
<view class="device-header__actions">
|
||||||
class="device-header__notify-icon"
|
<view class="device-header__notify-wrap" @tap="$emit('notify')">
|
||||||
:src="staticAssets.iconNotify"
|
<image
|
||||||
mode="aspectFit"
|
class="device-header__notify-icon"
|
||||||
/>
|
:src="staticAssets.iconNotify"
|
||||||
|
mode="aspectFit"
|
||||||
|
/>
|
||||||
|
</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>
|
</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>
|
||||||
|
|||||||
85
src/pages/device/components/device-list/index.vue
Normal file
85
src/pages/device/components/device-list/index.vue
Normal 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')">管理 ></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>
|
||||||
@@ -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>
|
||||||
154
src/pages/device/components/room-location-dialog/index.vue
Normal file
154
src/pages/device/components/room-location-dialog/index.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
@@ -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">
|
||||||
|
电量<10%时停止消杀,仅保留基础净化。
|
||||||
|
</text>
|
||||||
|
<text
|
||||||
|
class="run-mode-explain-dialog__card-desc run-mode-explain-dialog__card-desc--desk run-mode-explain-dialog__card-desc--gap"
|
||||||
|
>
|
||||||
|
电量<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>
|
||||||
92
src/pages/device/detail/device-bluetooth-read.ts
Normal file
92
src/pages/device/detail/device-bluetooth-read.ts
Normal 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 {}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/pages/device/detail/device-detail.api.ts
Normal file
24
src/pages/device/detail/device-detail.api.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
400
src/pages/device/detail/device-detail.scss
Normal file
400
src/pages/device/detail/device-detail.scss
Normal 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;
|
||||||
|
}
|
||||||
57
src/pages/device/detail/device-detail.types.ts
Normal file
57
src/pages/device/detail/device-detail.types.ts
Normal 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'
|
||||||
113
src/pages/device/detail/device-detail.utils.ts
Normal file
113
src/pages/device/detail/device-detail.utils.ts
Normal 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
206
src/pages/device/detail/index.vue
Normal file
206
src/pages/device/detail/index.vue
Normal 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>
|
||||||
185
src/pages/device/detail/use-device-bluetooth-sync.ts
Normal file
185
src/pages/device/detail/use-device-bluetooth-sync.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
223
src/pages/device/detail/use-device-detail.ts
Normal file
223
src/pages/device/detail/use-device-detail.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/pages/device/device.api.ts
Normal file
11
src/pages/device/device.api.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
67
src/pages/device/device.utils.ts
Normal file
67
src/pages/device/device.utils.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -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-empty :step-hint="bindStepHint" @bind="onStartBind" />
|
|
||||||
<hot-devices :items="hotDevices" @mall="onGoMall" />
|
<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" />
|
||||||
|
<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>
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
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
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
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
BIN
src/static/路径1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 702 B |
@@ -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
44
src/utils/auth-token.ts
Normal 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
292
src/utils/bluetooth.ts
Normal 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 || '蓝牙不可用,请检查权限与系统设置'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
1
tmp-openapi.json
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user