feat:绑定设备和设备详情
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
VITE_API_BASE_URL=http://192.168.0.222:8082
|
||||
VITE_API_PROXY_TARGET=http://192.168.0.222:8082
|
||||
VITE_AGREEMENT_ID_SERVICE=0
|
||||
VITE_AGREEMENT_ID_PRIVACY=0
|
||||
VITE_API_CLIENT_ID=app
|
||||
|
||||
@@ -4,6 +4,9 @@ VITE_API_BASE_URL=http://192.168.0.222:8082
|
||||
# H5 开发:BASE_URL 留空时可走 vite 代理 /app
|
||||
VITE_API_PROXY_TARGET=http://192.168.0.222:8082
|
||||
|
||||
# 协议详情 agreementId(POST /app/auth/agreement/detail)
|
||||
VITE_AGREEMENT_ID_SERVICE=0
|
||||
VITE_AGREEMENT_ID_PRIVACY=0
|
||||
# RuoYi-Vue-Plus App 端 clientId(请求头 clientId)
|
||||
VITE_API_CLIENT_ID=app
|
||||
|
||||
# 可选:配置后详情页会通过 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": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4787,9 +4784,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4810,9 +4804,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4833,9 +4824,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4856,9 +4844,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4879,9 +4864,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4902,9 +4884,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5078,9 +5057,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5094,9 +5070,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5110,9 +5083,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5126,9 +5096,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5142,9 +5109,6 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5158,9 +5122,6 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5174,9 +5135,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5190,9 +5148,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5206,9 +5161,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5222,9 +5174,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5238,9 +5187,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5254,9 +5200,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5270,9 +5213,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"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 type { AuthTokenResult, SendSmsCodeBody, SmsCodeScene } from '@/types/api'
|
||||
import { ApiError } from '@/utils/api-error'
|
||||
import { extractAuthToken, saveStoredToken } from '@/utils/auth-token'
|
||||
|
||||
/** POST /app/auth/sendSmsCode */
|
||||
export function sendSmsCode(phone: string, scene: SmsCodeScene) {
|
||||
@@ -15,11 +16,10 @@ export function sendSmsCode(phone: string, scene: SmsCodeScene) {
|
||||
})
|
||||
}
|
||||
|
||||
export function saveAuthToken(data: AuthTokenResult) {
|
||||
const token = data?.token ?? data?.accessToken
|
||||
export function saveAuthToken(data: AuthTokenResult | unknown) {
|
||||
const token = extractAuthToken(data)
|
||||
if (!token) {
|
||||
throw new ApiError('登录失败,未获取到令牌')
|
||||
}
|
||||
uni.setStorageSync('token', token)
|
||||
return token
|
||||
return saveStoredToken(token)
|
||||
}
|
||||
|
||||
@@ -1,25 +1,32 @@
|
||||
import type { ApiResponse } from '@/types/api'
|
||||
import { extractAuthToken, getStoredToken, saveStoredToken } from '@/utils/auth-token'
|
||||
import { ApiError, isApiSuccess, unwrapApiData } from '@/utils/api-error'
|
||||
|
||||
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'> {
|
||||
url: string
|
||||
}
|
||||
|
||||
export function request<T = unknown>(options: RequestOptions): Promise<T> {
|
||||
const token = uni.getStorageSync('token') as string
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.request({
|
||||
...options,
|
||||
url: `${BASE_URL}${options.url}`,
|
||||
header: {
|
||||
'Content-Type': 'application/json',
|
||||
/** 构建鉴权请求头:clientid + Authorization */
|
||||
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}` } : {}),
|
||||
...options.header,
|
||||
},
|
||||
success: (res) => {
|
||||
...(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(
|
||||
@@ -33,14 +40,40 @@ export function request<T = unknown>(options: RequestOptions): Promise<T> {
|
||||
|
||||
const body = (res.data ?? {}) as ApiResponse<T> & Record<string, unknown>
|
||||
if (!isApiSuccess(body)) {
|
||||
reject(
|
||||
new ApiError(body.msg || body.message || '请求失败', body.code),
|
||||
)
|
||||
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) => {
|
||||
uni.request({
|
||||
...options,
|
||||
url: `${BASE_URL}${options.url}`,
|
||||
header: buildAuthHeaders(options.header),
|
||||
success: (res) => handleSuccess<T>(res, resolve, reject),
|
||||
fail: (err) => {
|
||||
reject(new ApiError(err.errMsg || '网络异常'))
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** 登录类接口:从完整响应体提取并保存 token */
|
||||
export function requestLogin<T = unknown>(options: RequestOptions): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.request({
|
||||
...options,
|
||||
url: `${BASE_URL}${options.url}`,
|
||||
header: buildAuthHeaders(options.header),
|
||||
success: (res) => handleSuccess<T>(res, resolve, reject, true),
|
||||
fail: (err) => {
|
||||
reject(new ApiError(err.errMsg || '网络异常'))
|
||||
},
|
||||
|
||||
@@ -49,7 +49,8 @@ const routes: Record<TabKey, string> = {
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,12 @@
|
||||
/* android打包配置 */
|
||||
"android" : {
|
||||
"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.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
|
||||
@@ -54,7 +60,17 @@
|
||||
"setting" : {
|
||||
"urlCheck" : false
|
||||
},
|
||||
"usingComponents" : true
|
||||
"usingComponents" : true,
|
||||
"permission" : {
|
||||
"scope.bluetooth" : {
|
||||
"desc" : "你的蓝牙将用于发现和连接智能设备"
|
||||
}
|
||||
},
|
||||
"requiredPrivateInfos" : [
|
||||
"getBluetoothDevices",
|
||||
"onBluetoothDeviceFound",
|
||||
"startBluetoothDevicesDiscovery"
|
||||
]
|
||||
},
|
||||
"mp-alipay" : {
|
||||
"usingComponents" : true
|
||||
|
||||
@@ -20,6 +20,13 @@
|
||||
"navigationBarTitleText": "设备"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/device/detail/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "设备详情"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/add-device/index",
|
||||
"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%);
|
||||
box-shadow: 0 8rpx 28rpx rgba(33, 194, 94, 0.28);
|
||||
margin-bottom: 28rpx;
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.65;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.add-device-page__btn-text {
|
||||
|
||||
@@ -9,3 +9,60 @@ export interface BindFeatureItem {
|
||||
desc: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
/** POST /app/device/bind 请求体 */
|
||||
export interface BindDeviceBody {
|
||||
deviceNo: string
|
||||
productId?: number
|
||||
model?: string
|
||||
imei?: string
|
||||
mac?: string
|
||||
name?: string
|
||||
homeId?: number
|
||||
locationId?: number
|
||||
}
|
||||
|
||||
/** 绑定流程页间传递的设备信息 */
|
||||
export interface BindDeviceContext {
|
||||
deviceNo: string
|
||||
name: string
|
||||
serialMasked: string
|
||||
productId?: number
|
||||
model?: string
|
||||
imei?: string
|
||||
mac?: string
|
||||
}
|
||||
|
||||
export function buildBindDeviceQuery(ctx: BindDeviceContext): string {
|
||||
const parts = [
|
||||
`deviceNo=${encodeURIComponent(ctx.deviceNo)}`,
|
||||
`name=${encodeURIComponent(ctx.name)}`,
|
||||
`serial=${encodeURIComponent(ctx.serialMasked)}`,
|
||||
]
|
||||
if (ctx.productId != null) parts.push(`productId=${ctx.productId}`)
|
||||
if (ctx.model) parts.push(`model=${encodeURIComponent(ctx.model)}`)
|
||||
if (ctx.imei) parts.push(`imei=${encodeURIComponent(ctx.imei)}`)
|
||||
if (ctx.mac) parts.push(`mac=${encodeURIComponent(ctx.mac)}`)
|
||||
return parts.join('&')
|
||||
}
|
||||
|
||||
function parseQueryNumber(raw: unknown): number | undefined {
|
||||
if (raw === undefined || raw === null || raw === '') return undefined
|
||||
const n = Number(raw)
|
||||
return Number.isFinite(n) ? n : undefined
|
||||
}
|
||||
|
||||
export function readBindDeviceContext(
|
||||
query: Record<string, string | undefined>,
|
||||
): Partial<BindDeviceContext> {
|
||||
const ctx: Partial<BindDeviceContext> = {}
|
||||
if (query.deviceNo) ctx.deviceNo = decodeURIComponent(String(query.deviceNo))
|
||||
if (query.name) ctx.name = decodeURIComponent(String(query.name))
|
||||
if (query.serial) ctx.serialMasked = decodeURIComponent(String(query.serial))
|
||||
const productId = parseQueryNumber(query.productId)
|
||||
if (productId != null) ctx.productId = productId
|
||||
if (query.model) ctx.model = decodeURIComponent(String(query.model))
|
||||
if (query.imei) ctx.imei = decodeURIComponent(String(query.imei))
|
||||
if (query.mac) ctx.mac = decodeURIComponent(String(query.mac))
|
||||
return ctx
|
||||
}
|
||||
|
||||
@@ -210,6 +210,11 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.65;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.complete-page__submit-text {
|
||||
|
||||
@@ -73,8 +73,14 @@
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view class="complete-page__footer">
|
||||
<view class="complete-page__submit" @tap="onFinishAdd">
|
||||
<text class="complete-page__submit-text">完成添加</text>
|
||||
<view
|
||||
class="complete-page__submit"
|
||||
:class="{ 'complete-page__submit--disabled': loading }"
|
||||
@tap="onFinishAdd"
|
||||
>
|
||||
<text class="complete-page__submit-text">
|
||||
{{ loading ? '提交中...' : '完成添加' }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -93,17 +99,14 @@ const {
|
||||
deviceName,
|
||||
serialMasked,
|
||||
roomName,
|
||||
loading,
|
||||
applyQuery,
|
||||
onPickRoom,
|
||||
onFinishAdd,
|
||||
} = useCompleteDevice()
|
||||
|
||||
onLoad((query) => {
|
||||
if (query?.name) {
|
||||
deviceName.value = decodeURIComponent(String(query.name))
|
||||
}
|
||||
if (query?.serial) {
|
||||
serialMasked.value = decodeURIComponent(String(query.serial))
|
||||
}
|
||||
applyQuery(query ?? {})
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,34 +1,85 @@
|
||||
import { ref } from 'vue'
|
||||
import { getErrorMessage } from '@/utils/api-error'
|
||||
import { bindDevice } from '../add-device.api'
|
||||
import { readBindDeviceContext } from '../add-device.types'
|
||||
|
||||
const ROOM_OPTIONS = ['客厅', '主卧', '卧室', '厨房', '卫生间']
|
||||
const ROOM_OPTIONS = [
|
||||
{ name: '客厅', locationId: 1 },
|
||||
{ name: '主卧', locationId: 2 },
|
||||
{ name: '卧室', locationId: 3 },
|
||||
{ name: '厨房', locationId: 4 },
|
||||
{ name: '卫生间', locationId: 5 },
|
||||
] as const
|
||||
|
||||
export function useCompleteDevice() {
|
||||
const currentStep = 4
|
||||
const deviceNo = ref('')
|
||||
const deviceName = ref('Z2 空气净化器')
|
||||
const serialMasked = ref('****A3B2')
|
||||
const roomName = ref('客厅')
|
||||
const roomOptions = ROOM_OPTIONS
|
||||
const productId = ref<number>()
|
||||
const model = ref<string>()
|
||||
const imei = ref<string>()
|
||||
const mac = ref<string>()
|
||||
const roomName = ref(ROOM_OPTIONS[0].name)
|
||||
const locationId = ref<number | undefined>(ROOM_OPTIONS[0].locationId)
|
||||
const loading = ref(false)
|
||||
|
||||
function applyQuery(query: Record<string, string | undefined>) {
|
||||
const ctx = readBindDeviceContext(query)
|
||||
if (ctx.deviceNo) deviceNo.value = ctx.deviceNo
|
||||
if (ctx.name) deviceName.value = ctx.name
|
||||
if (ctx.serialMasked) serialMasked.value = ctx.serialMasked
|
||||
if (ctx.productId != null) productId.value = ctx.productId
|
||||
if (ctx.model) model.value = ctx.model
|
||||
if (ctx.imei) imei.value = ctx.imei
|
||||
if (ctx.mac) mac.value = ctx.mac
|
||||
}
|
||||
|
||||
function onPickRoom() {
|
||||
uni.showActionSheet({
|
||||
itemList: roomOptions,
|
||||
itemList: ROOM_OPTIONS.map((item) => item.name),
|
||||
success: (res) => {
|
||||
const picked = roomOptions[res.tapIndex]
|
||||
if (picked) roomName.value = picked
|
||||
const picked = ROOM_OPTIONS[res.tapIndex]
|
||||
if (picked) {
|
||||
roomName.value = picked.name
|
||||
locationId.value = picked.locationId
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function onFinishAdd() {
|
||||
async function onFinishAdd() {
|
||||
const name = deviceName.value.trim()
|
||||
if (!name) {
|
||||
uni.showToast({ title: '请输入设备名称', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!deviceNo.value.trim()) {
|
||||
uni.showToast({ title: '缺少设备编号', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (loading.value) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await bindDevice({
|
||||
deviceNo: deviceNo.value.trim(),
|
||||
name,
|
||||
...(productId.value != null ? { productId: productId.value } : {}),
|
||||
...(model.value ? { model: model.value } : {}),
|
||||
...(imei.value ? { imei: imei.value } : {}),
|
||||
...(mac.value ? { mac: mac.value } : {}),
|
||||
...(locationId.value != null ? { locationId: locationId.value } : {}),
|
||||
})
|
||||
uni.showToast({ title: '添加成功', icon: 'success' })
|
||||
setTimeout(() => {
|
||||
uni.reLaunch({ url: '/pages/device/index' })
|
||||
}, 400)
|
||||
} catch (err) {
|
||||
uni.showToast({ title: getErrorMessage(err, '绑定失败,请重试'), icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -36,6 +87,8 @@ export function useCompleteDevice() {
|
||||
deviceName,
|
||||
serialMasked,
|
||||
roomName,
|
||||
loading,
|
||||
applyQuery,
|
||||
onPickRoom,
|
||||
onFinishAdd,
|
||||
}
|
||||
|
||||
@@ -11,8 +11,14 @@
|
||||
</text>
|
||||
<bind-feature-list :items="BIND_FEATURES" />
|
||||
<view class="add-device-page__footer">
|
||||
<view class="add-device-page__btn" @tap="onOpenBluetooth">
|
||||
<text class="add-device-page__btn-text">去开启蓝牙</text>
|
||||
<view
|
||||
class="add-device-page__btn"
|
||||
:class="{ 'add-device-page__btn--disabled': opening }"
|
||||
@tap="onOpenBluetooth"
|
||||
>
|
||||
<text class="add-device-page__btn-text">
|
||||
{{ opening ? '正在开启...' : '去开启蓝牙' }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="add-device-page__later-wrap" @tap="onBindLater">
|
||||
<text class="add-device-page__later">稍后绑定</text>
|
||||
@@ -29,7 +35,7 @@ import BindFeatureList from './components/bind-feature-list/index.vue'
|
||||
import BindSteps from './components/bind-steps/index.vue'
|
||||
import { BIND_FEATURES, BIND_STEPS, useAddDevice } from './use-add-device'
|
||||
|
||||
const { currentStep, onOpenBluetooth, onBindLater } = useAddDevice()
|
||||
const { currentStep, opening, onOpenBluetooth, onBindLater } = useAddDevice()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -45,18 +45,21 @@ import { BIND_STEPS } from '../use-add-device'
|
||||
import PairCheckList from './components/pair-check-list/index.vue'
|
||||
import PairDeviceSummary from './components/pair-device-summary/index.vue'
|
||||
import PairHero from './components/pair-hero/index.vue'
|
||||
import { readBindDeviceContext } from '../add-device.types'
|
||||
import { usePairDevice } from './use-pair-device'
|
||||
|
||||
const { currentStep, device, progress, etaSeconds, checkItems, onCancelPair } =
|
||||
usePairDevice()
|
||||
|
||||
onLoad((query) => {
|
||||
if (query?.name) {
|
||||
device.value.name = decodeURIComponent(String(query.name))
|
||||
}
|
||||
if (query?.serial) {
|
||||
device.value.serialMasked = decodeURIComponent(String(query.serial))
|
||||
}
|
||||
const ctx = readBindDeviceContext(query ?? {})
|
||||
if (ctx.name) device.value.name = ctx.name
|
||||
if (ctx.serialMasked) device.value.serialMasked = ctx.serialMasked
|
||||
if (ctx.deviceNo) device.value.deviceNo = ctx.deviceNo
|
||||
if (ctx.productId != null) device.value.productId = ctx.productId
|
||||
if (ctx.model) device.value.model = ctx.model
|
||||
if (ctx.imei) device.value.imei = ctx.imei
|
||||
if (ctx.mac) device.value.mac = ctx.mac
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -10,4 +10,9 @@ export interface PairCheckItem {
|
||||
export interface PairDeviceInfo {
|
||||
name: string
|
||||
serialMasked: string
|
||||
deviceNo: string
|
||||
productId?: number
|
||||
model?: string
|
||||
imei?: string
|
||||
mac?: string
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { onUnmounted, ref, watch } from 'vue'
|
||||
import { buildBindDeviceQuery } from '../add-device.types'
|
||||
import type { PairCheckItem, PairDeviceInfo } from './pair.types'
|
||||
|
||||
const DEFAULT_DEVICE: PairDeviceInfo = {
|
||||
name: 'Z2 空气净化器',
|
||||
serialMasked: '****A3B2',
|
||||
name: '',
|
||||
serialMasked: '',
|
||||
deviceNo: '',
|
||||
}
|
||||
|
||||
export function usePairDevice(initial?: Partial<PairDeviceInfo>) {
|
||||
@@ -45,10 +47,15 @@ export function usePairDevice(initial?: Partial<PairDeviceInfo>) {
|
||||
if (value < 100 || navigated) return
|
||||
navigated = true
|
||||
const d = device.value
|
||||
const q = [
|
||||
`name=${encodeURIComponent(d.name)}`,
|
||||
`serial=${encodeURIComponent(d.serialMasked)}`,
|
||||
].join('&')
|
||||
const q = buildBindDeviceQuery({
|
||||
deviceNo: d.deviceNo,
|
||||
name: d.name,
|
||||
serialMasked: d.serialMasked,
|
||||
productId: d.productId,
|
||||
model: d.model,
|
||||
imei: d.imei,
|
||||
mac: d.mac,
|
||||
})
|
||||
setTimeout(() => {
|
||||
uni.redirectTo({ url: `/pages/add-device/complete/index?${q}` })
|
||||
}, 600)
|
||||
|
||||
@@ -20,7 +20,15 @@
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="scan-page__list">
|
||||
<view v-if="scanError" class="scan-page__empty">
|
||||
<text class="scan-page__empty-text">{{ scanError }}</text>
|
||||
</view>
|
||||
<view v-else-if="devices.length === 0" class="scan-page__empty">
|
||||
<text class="scan-page__empty-text">
|
||||
{{ scanning ? '正在搜索附近蓝牙设备...' : '未发现附近设备,请确认设备已开机并靠近手机' }}
|
||||
</text>
|
||||
</view>
|
||||
<view v-else class="scan-page__list">
|
||||
<scan-device-item
|
||||
v-for="item in devices"
|
||||
:key="item.id"
|
||||
@@ -52,6 +60,7 @@ const {
|
||||
selectedId,
|
||||
secondsLeft,
|
||||
scanning,
|
||||
scanError,
|
||||
onSelectDevice,
|
||||
onDeviceAction,
|
||||
} = useScanDevice()
|
||||
|
||||
@@ -76,3 +76,19 @@
|
||||
.scan-page__list {
|
||||
padding: 0 32rpx;
|
||||
}
|
||||
|
||||
.scan-page__empty {
|
||||
margin: 0 32rpx;
|
||||
padding: 48rpx 32rpx;
|
||||
background: #fff;
|
||||
border-radius: 20rpx;
|
||||
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.scan-page__empty-text {
|
||||
display: block;
|
||||
font-size: 26rpx;
|
||||
color: #98a2b3;
|
||||
line-height: 1.65;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,11 @@ export interface ScannedDeviceItem {
|
||||
id: string
|
||||
name: string
|
||||
serialMasked: string
|
||||
deviceNo: string
|
||||
productId?: number
|
||||
model?: string
|
||||
imei?: string
|
||||
mac?: string
|
||||
/** 是否可添加(未知设备为 false 时箭头置灰) */
|
||||
canAdd: boolean
|
||||
}
|
||||
|
||||
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'
|
||||
|
||||
const MOCK_DEVICES: ScannedDeviceItem[] = [
|
||||
{
|
||||
id: 'z2-purifier',
|
||||
name: 'Z2 空气净化器',
|
||||
serialMasked: '****A3B2',
|
||||
canAdd: true,
|
||||
},
|
||||
{
|
||||
id: 'unknown',
|
||||
name: '未知设备',
|
||||
serialMasked: '****7C91',
|
||||
canAdd: false,
|
||||
},
|
||||
]
|
||||
import { mapBluetoothDevice } from './scan.utils'
|
||||
|
||||
const SCAN_SECONDS = 20
|
||||
|
||||
export function useScanDevice() {
|
||||
const currentStep = 2
|
||||
const devices = ref<ScannedDeviceItem[]>(MOCK_DEVICES)
|
||||
const selectedId = ref(MOCK_DEVICES[0]?.id ?? '')
|
||||
const devices = ref<ScannedDeviceItem[]>([])
|
||||
const selectedId = ref('')
|
||||
const secondsLeft = ref(SCAN_SECONDS)
|
||||
const scanning = ref(true)
|
||||
const scanError = ref('')
|
||||
|
||||
let unsubscribeFound: (() => void) | undefined
|
||||
|
||||
function upsertDevice(item: ScannedDeviceItem) {
|
||||
const idx = devices.value.findIndex((d) => d.id === item.id)
|
||||
if (idx >= 0) {
|
||||
devices.value[idx] = item
|
||||
return
|
||||
}
|
||||
devices.value.push(item)
|
||||
if (!selectedId.value) selectedId.value = item.id
|
||||
}
|
||||
|
||||
function ingestBluetoothDevices(found: UniApp.BluetoothDeviceInfo[]) {
|
||||
for (const device of found) {
|
||||
upsertDevice(mapBluetoothDevice(device))
|
||||
}
|
||||
}
|
||||
|
||||
async function beginDiscovery() {
|
||||
if (!isBluetoothApiSupported()) {
|
||||
scanning.value = false
|
||||
scanError.value = '当前环境不支持蓝牙扫描,请使用 App 或微信小程序'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureBluetoothReady()
|
||||
|
||||
unsubscribeFound = subscribeBluetoothDevicesFound((found) => {
|
||||
ingestBluetoothDevices(found)
|
||||
})
|
||||
|
||||
const existing = await getDiscoveredBluetoothDevices()
|
||||
ingestBluetoothDevices(existing)
|
||||
|
||||
await startBluetoothDiscovery()
|
||||
} catch (err) {
|
||||
scanning.value = false
|
||||
scanError.value = getBluetoothErrorMessage(err)
|
||||
uni.showToast({ title: scanError.value, icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void beginDiscovery()
|
||||
})
|
||||
|
||||
const timer = setInterval(() => {
|
||||
if (secondsLeft.value <= 0) {
|
||||
@@ -33,11 +77,14 @@ export function useScanDevice() {
|
||||
secondsLeft.value -= 1
|
||||
if (secondsLeft.value === 0) {
|
||||
scanning.value = false
|
||||
void stopBluetoothDiscovery().catch(() => {})
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(timer)
|
||||
unsubscribeFound?.()
|
||||
void stopBluetoothDiscovery().catch(() => {})
|
||||
})
|
||||
|
||||
function onSelectDevice(id: string) {
|
||||
@@ -50,10 +97,15 @@ export function useScanDevice() {
|
||||
uni.showToast({ title: '暂无法添加该设备', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const q = [
|
||||
`name=${encodeURIComponent(device.name)}`,
|
||||
`serial=${encodeURIComponent(device.serialMasked)}`,
|
||||
].join('&')
|
||||
const q = buildBindDeviceQuery({
|
||||
deviceNo: device.deviceNo,
|
||||
name: device.name,
|
||||
serialMasked: device.serialMasked,
|
||||
productId: device.productId,
|
||||
model: device.model,
|
||||
imei: device.imei,
|
||||
mac: device.mac,
|
||||
})
|
||||
uni.navigateTo({ url: `/pages/add-device/pair/index?${q}` })
|
||||
}
|
||||
|
||||
@@ -63,6 +115,7 @@ export function useScanDevice() {
|
||||
selectedId,
|
||||
secondsLeft,
|
||||
scanning,
|
||||
scanError,
|
||||
onSelectDevice,
|
||||
onDeviceAction,
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ref } from 'vue'
|
||||
import { ensureBluetoothReady, getBluetoothErrorMessage } from '@/utils/bluetooth'
|
||||
import { staticAssets } from '@/utils/static-assets'
|
||||
import type { BindFeatureItem, BindStepItem } from './add-device.types'
|
||||
|
||||
@@ -31,9 +33,24 @@ export const BIND_FEATURES: BindFeatureItem[] = [
|
||||
|
||||
export function useAddDevice() {
|
||||
const currentStep = 1
|
||||
const opening = ref(false)
|
||||
|
||||
function onOpenBluetooth() {
|
||||
async function onOpenBluetooth() {
|
||||
if (opening.value) return
|
||||
opening.value = true
|
||||
try {
|
||||
await ensureBluetoothReady()
|
||||
uni.navigateTo({ url: '/pages/add-device/scan/index' })
|
||||
} catch (err) {
|
||||
uni.showModal({
|
||||
title: '无法开启蓝牙',
|
||||
content: getBluetoothErrorMessage(err),
|
||||
showCancel: false,
|
||||
confirmText: '知道了',
|
||||
})
|
||||
} finally {
|
||||
opening.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onBindLater() {
|
||||
@@ -42,6 +59,7 @@ export function useAddDevice() {
|
||||
|
||||
return {
|
||||
currentStep,
|
||||
opening,
|
||||
onOpenBluetooth,
|
||||
onBindLater,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
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) */
|
||||
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 {
|
||||
if (value === undefined || value === '') return undefined
|
||||
const n = Number(value)
|
||||
return Number.isFinite(n) && n >= 0 ? n : undefined
|
||||
let cachedAgreements: AgreementVo[] | null = null
|
||||
|
||||
function matchesAgreementType(apiType: string | undefined, type: AgreementType): boolean {
|
||||
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 {
|
||||
const fromEnv =
|
||||
type === 'service'
|
||||
? parseEnvId(import.meta.env.VITE_AGREEMENT_ID_SERVICE)
|
||||
: parseEnvId(import.meta.env.VITE_AGREEMENT_ID_PRIVACY)
|
||||
/** 拉取并缓存生效协议列表 */
|
||||
export async function loadActiveAgreements(): Promise<AgreementVo[]> {
|
||||
if (cachedAgreements) return cachedAgreements
|
||||
const list = await fetchActiveAgreements()
|
||||
cachedAgreements = list ?? []
|
||||
return cachedAgreements
|
||||
}
|
||||
|
||||
if (fromEnv !== undefined) return fromEnv
|
||||
|
||||
return 0
|
||||
/** 按协议类型从生效列表中解析 agreementId */
|
||||
export async function resolveAgreementId(type: AgreementType): Promise<number | undefined> {
|
||||
const list = await loadActiveAgreements()
|
||||
const match = list.find((item) => matchesAgreementType(item.agreementType, type))
|
||||
return match?.agreementId
|
||||
}
|
||||
|
||||
export function parseAgreementIdFromQuery(raw: unknown): number | undefined {
|
||||
if (raw === undefined || raw === null || raw === '') return undefined
|
||||
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'
|
||||
|
||||
/** POST /app/auth/agreements 返回项 */
|
||||
export interface AgreementVo {
|
||||
agreementId?: number
|
||||
agreementType?: string
|
||||
agreementNo?: string
|
||||
versionName?: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
/** /app/auth/agreement/detail 返回 data 结构 */
|
||||
export interface AgreementDetail {
|
||||
agreementNo?: string
|
||||
|
||||
@@ -26,7 +26,12 @@ export function useAgreement() {
|
||||
loading.value = true
|
||||
title.value = DEFAULT_TITLE[agreementType]
|
||||
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 {
|
||||
const data = await fetchAgreementDetail(id)
|
||||
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,7 +1,8 @@
|
||||
<template>
|
||||
<view class="device-header" :style="{ paddingTop: headerPaddingTop + 'px' }">
|
||||
<view class="device-header__bar">
|
||||
<view class="device-header__home" @tap="$emit('home')">
|
||||
<view class="device-header__home-wrap">
|
||||
<view class="device-header__home" @tap.stop="togglePicker">
|
||||
<image
|
||||
class="device-header__home-icon"
|
||||
:src="staticAssets.iconHomeSelector"
|
||||
@@ -10,10 +11,38 @@
|
||||
<text class="device-header__home-text">{{ homeName }}</text>
|
||||
<image
|
||||
class="device-header__arrow"
|
||||
: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 class="device-header__actions">
|
||||
<view class="device-header__notify-wrap" @tap="$emit('notify')">
|
||||
<image
|
||||
class="device-header__notify-icon"
|
||||
@@ -21,29 +50,87 @@
|
||||
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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { staticAssets } from '@/utils/static-assets'
|
||||
|
||||
defineProps<{
|
||||
homeName: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
home: []
|
||||
const emit = defineEmits<{
|
||||
notify: []
|
||||
'add-device': []
|
||||
scan: []
|
||||
manage: []
|
||||
}>()
|
||||
|
||||
const pickerOpen = ref(false)
|
||||
const addMenuOpen = ref(false)
|
||||
|
||||
const sys = uni.getSystemInfoSync()
|
||||
const statusBarHeight = sys.statusBarHeight ?? 0
|
||||
/** 状态栏下再留一段间距;H5 无状态栏时用固定上内边距 */
|
||||
const headerPaddingTop =
|
||||
statusBarHeight > 0
|
||||
? statusBarHeight + uni.upx2px(24)
|
||||
: 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>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -60,14 +147,19 @@ const headerPaddingTop =
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.device-header__home-wrap {
|
||||
position: relative;
|
||||
z-index: 300;
|
||||
}
|
||||
|
||||
.device-header__home {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
padding: 12rpx 28rpx 12rpx 20rpx;
|
||||
background: #EFFBF4;
|
||||
background: #effbf4;
|
||||
border-radius: 40rpx;
|
||||
border: 1px solid #C9EFD9;
|
||||
border: 1px solid #c9efd9;
|
||||
}
|
||||
|
||||
.device-header__home-icon {
|
||||
@@ -80,12 +172,74 @@ const headerPaddingTop =
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #1fa85c;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.device-header__arrow {
|
||||
width: 18rpx;
|
||||
height: 18rpx;
|
||||
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 {
|
||||
@@ -93,4 +247,69 @@ const headerPaddingTop =
|
||||
height: 72rpx;
|
||||
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>
|
||||
|
||||
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 {
|
||||
id: string
|
||||
name: string
|
||||
locationId?: number
|
||||
}
|
||||
|
||||
export interface HotDeviceItem {
|
||||
@@ -8,3 +9,34 @@ export interface HotDeviceItem {
|
||||
name: 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">
|
||||
<device-header
|
||||
:home-name="homeName"
|
||||
@home="onHomePicker"
|
||||
@manage="onHomeManage"
|
||||
@notify="onNotify"
|
||||
@add-device="onStartBind"
|
||||
@scan="onScan"
|
||||
/>
|
||||
<room-tabs
|
||||
:rooms="rooms"
|
||||
@@ -11,8 +13,25 @@
|
||||
@select="selectRoom"
|
||||
@add="onAddRoom"
|
||||
/>
|
||||
|
||||
<device-list
|
||||
v-if="hasDevices"
|
||||
:devices="devices"
|
||||
:online-count="onlineCount"
|
||||
@manage="onManageDevices"
|
||||
@select="onSelectDevice"
|
||||
/>
|
||||
<template v-else-if="!loading">
|
||||
<device-empty :step-hint="bindStepHint" @bind="onStartBind" />
|
||||
<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" />
|
||||
</view>
|
||||
</template>
|
||||
@@ -21,7 +40,10 @@
|
||||
import { useTabPage } from '@/hooks/use-tab-page'
|
||||
import DeviceEmpty from './components/device-empty/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 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 { useDevice } from './use-device'
|
||||
|
||||
@@ -31,14 +53,24 @@ const {
|
||||
homeName,
|
||||
rooms,
|
||||
activeRoomId,
|
||||
devices,
|
||||
onlineCount,
|
||||
hasDevices,
|
||||
loading,
|
||||
hotDevices,
|
||||
bindStepHint,
|
||||
roomDialogVisible,
|
||||
successToastVisible,
|
||||
selectRoom,
|
||||
onAddRoom,
|
||||
onConfirmAddRoom,
|
||||
onStartBind,
|
||||
onScan,
|
||||
onGoMall,
|
||||
onNotify,
|
||||
onHomePicker,
|
||||
onHomeManage,
|
||||
onManageDevices,
|
||||
onSelectDevice,
|
||||
} = useDevice()
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { ref } from 'vue'
|
||||
import type { HotDeviceItem, RoomTab } from './device.types'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
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[] = [
|
||||
{ id: 'all', name: '全部' },
|
||||
{ id: 'living', name: '客厅' },
|
||||
]
|
||||
const CUSTOM_ROOMS_KEY = 'device_custom_rooms'
|
||||
|
||||
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() {
|
||||
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 devices = ref<DeviceListItem[]>([])
|
||||
const loading = ref(false)
|
||||
const hotDevices = ref<HotDeviceItem[]>(DEFAULT_HOT_DEVICES)
|
||||
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) {
|
||||
activeRoomId.value = id
|
||||
}
|
||||
|
||||
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() {
|
||||
uni.navigateTo({ url: '/pages/add-device/index' })
|
||||
}
|
||||
|
||||
function onScan() {
|
||||
uni.showToast({ title: '扫一扫功能开发中', icon: 'none' })
|
||||
}
|
||||
|
||||
function onGoMall() {
|
||||
uni.showToast({ title: '商城功能开发中', icon: 'none' })
|
||||
}
|
||||
@@ -46,21 +125,53 @@ export function useDevice() {
|
||||
uni.showToast({ title: '消息功能开发中', icon: 'none' })
|
||||
}
|
||||
|
||||
function onHomePicker() {
|
||||
uni.showToast({ title: '切换家庭功能开发中', icon: 'none' })
|
||||
function onHomeManage() {
|
||||
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 {
|
||||
homeName,
|
||||
rooms,
|
||||
activeRoomId,
|
||||
devices: filteredDevices,
|
||||
onlineCount,
|
||||
hasDevices,
|
||||
loading,
|
||||
hotDevices,
|
||||
bindStepHint,
|
||||
roomDialogVisible,
|
||||
successToastVisible,
|
||||
selectRoom,
|
||||
onAddRoom,
|
||||
onConfirmAddRoom,
|
||||
onStartBind,
|
||||
onScan,
|
||||
onGoMall,
|
||||
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 { LoginCodeForm, LoginPasswordForm } from './login.types'
|
||||
|
||||
@@ -8,7 +8,7 @@ export function loginBySms(data: LoginCodeForm) {
|
||||
phonenumber: data.phone.trim(),
|
||||
smsCode: data.code.trim(),
|
||||
}
|
||||
return request<AuthTokenResult>({
|
||||
return requestLogin<AuthTokenResult>({
|
||||
url: '/app/auth/login/sms',
|
||||
method: 'POST',
|
||||
data: body,
|
||||
@@ -21,7 +21,7 @@ export function loginByPassword(data: LoginPasswordForm) {
|
||||
account: data.account.trim(),
|
||||
password: data.password,
|
||||
}
|
||||
return request<AuthTokenResult>({
|
||||
return requestLogin<AuthTokenResult>({
|
||||
url: '/app/auth/login/password',
|
||||
method: 'POST',
|
||||
data: body,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { ref } from 'vue'
|
||||
import { useSmsCode } from '@/hooks/use-sms-code'
|
||||
import { isValidPassword, isValidPhone, isValidSmsCode } from '@/utils/validate'
|
||||
import { saveAuthToken } from '@/api/auth'
|
||||
import { getErrorMessage } from '@/utils/api-error'
|
||||
import { getStoredToken } from '@/utils/auth-token'
|
||||
import { SMS_CODE_SCENE } from '@/types/api'
|
||||
import { loginByPassword, loginBySms } from './login.api'
|
||||
import type { LoginMode } from './login.types'
|
||||
@@ -44,8 +44,7 @@ export function useLogin() {
|
||||
uni.showToast({ title: '请输入验证码', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const smsRes = await loginBySms({ phone: phone.value, code: code.value })
|
||||
saveAuthToken(smsRes)
|
||||
await loginBySms({ phone: phone.value, code: code.value })
|
||||
} else {
|
||||
if (!account.value.trim()) {
|
||||
uni.showToast({ title: '请输入账号', icon: 'none' })
|
||||
@@ -55,11 +54,14 @@ export function useLogin() {
|
||||
uni.showToast({ title: '密码至少 6 位', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const pwdRes = await loginByPassword({
|
||||
await loginByPassword({
|
||||
account: account.value,
|
||||
password: password.value,
|
||||
})
|
||||
saveAuthToken(pwdRes)
|
||||
}
|
||||
if (!getStoredToken()) {
|
||||
uni.showToast({ title: '登录失败,未获取到令牌', icon: 'none' })
|
||||
return
|
||||
}
|
||||
uni.showToast({ title: '登录成功', icon: 'success' })
|
||||
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 {
|
||||
token?: string
|
||||
accessToken?: string
|
||||
/** RuoYi-Vue-Plus 登录响应 */
|
||||
access_token?: string
|
||||
expire_in?: number
|
||||
client_id?: string
|
||||
}
|
||||
|
||||
/** 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 iconChecked from '@/static/icon-checked@2x.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 iconHomeArrow from '@/static/组 9.png'
|
||||
import iconHomeSelected from '@/static/完成@2x.png'
|
||||
import iconNotify from '@/static/组 27.png'
|
||||
import iconDeviceEmpty from '@/static/图标 8.png'
|
||||
import iconDeviceNameEdit from '@/static/图标 9.png'
|
||||
@@ -33,10 +37,18 @@ export const staticAssets = {
|
||||
iconChecked,
|
||||
/** 路径.png — 密码框 */
|
||||
iconPassword,
|
||||
/** 路径1.png — 设备详情右上角设置 */
|
||||
iconDeviceDetailSettings,
|
||||
/** 组 15226@2x.png — 设备详情页上半部分背景 */
|
||||
deviceDetailHeroBg,
|
||||
/** 组 15227@2x.png — 设备详情「运行模式」旁提示图标 */
|
||||
iconRunModeInfo,
|
||||
/** 图标 4.png — 首页「我的家」 */
|
||||
iconHomeSelector,
|
||||
/** 组 9.png — 「我的家」下拉箭头 */
|
||||
iconHomeArrow,
|
||||
/** 完成@2x.png — 「我的家」下拉选中图标 */
|
||||
iconHomeSelected,
|
||||
/** 组 27.png — 设备页右上角通知 */
|
||||
iconNotify,
|
||||
/** 图标 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