From e22ebd6bef7b29dd870508a1fdb02a82aae68947 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E6=9D=8E=E4=BC=8A=E5=B8=86?=
<15818603+Osborn-lyf@user.noreply.gitee.com>
Date: Tue, 13 Oct 2026 16:23:43 +0800
Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E8=93=9D=E7=89=99=E7=BB=91?=
=?UTF-8?q?=E5=AE=9A=E5=92=8C=E5=9C=A8=E7=BA=BF=E5=AE=A2=E6=9C=8D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.env.development | 4 +-
.env.example | 21 +-
src/App.vue | 21 +-
src/api/auth.ts | 8 +
src/api/home.ts | 72 ++
src/api/location.ts | 67 ++
src/api/message.ts | 53 ++
src/api/profile.ts | 61 ++
src/api/request.ts | 41 +-
src/components/app-navbar/index.vue | 14 +-
src/hooks/use-message-inbox.ts | 48 ++
src/manifest.json | 20 +-
src/pages.json | 105 +++
src/pages/add-device/add-device.types.ts | 15 +-
src/pages/add-device/complete/complete.api.ts | 11 +
src/pages/add-device/complete/complete.scss | 19 +
.../add-device/complete/complete.types.ts | 11 +
src/pages/add-device/complete/index.vue | 73 +-
.../complete/use-complete-device.ts | 232 +++++-
.../pair/components/pair-check-list/index.vue | 13 +
.../components/pair-device-summary/index.vue | 68 +-
src/pages/add-device/pair/index.vue | 98 ++-
src/pages/add-device/pair/pair.scss | 30 +
src/pages/add-device/pair/pair.types.ts | 4 +-
src/pages/add-device/pair/use-pair-device.ts | 189 +++--
.../components/room-location-dialog/index.vue | 14 +-
.../device/detail/device-bluetooth-read.ts | 96 +--
.../device/detail/device-detail.types.ts | 10 +-
.../device/detail/device-detail.utils.ts | 6 +-
.../detail/use-device-bluetooth-sync.ts | 103 ++-
src/pages/device/detail/use-device-detail.ts | 104 ++-
src/pages/device/device-presence.ts | 111 +++
src/pages/device/device.types.ts | 12 +-
src/pages/device/device.utils.ts | 58 +-
.../components/home-name-dialog/index.vue | 150 ++++
src/pages/device/home-manage/detail/index.vue | 62 ++
.../device/home-manage/home-manage.api.ts | 16 +
src/pages/device/home-manage/home-manage.scss | 85 +++
.../device/home-manage/home-manage.types.ts | 10 +
src/pages/device/home-manage/index.vue | 40 +
.../components/location-map/index.vue | 147 ++++
.../home-manage/location/home-location.scss | 139 ++++
.../device/home-manage/location/index.vue | 71 ++
.../home-manage/location/use-home-location.ts | 182 +++++
src/pages/device/home-manage/use-home-list.ts | 48 ++
.../device/home-manage/use-home-manage.ts | 111 +++
.../components/device-name-dialog/index.vue | 150 ++++
.../unbind-confirm-dialog/index.vue | 130 ++++
.../device/settings/device-settings.api.ts | 20 +
.../device/settings/device-settings.scss | 155 ++++
.../device/settings/device-settings.types.ts | 20 +
src/pages/device/settings/index.vue | 115 +++
src/pages/device/settings/room/index.vue | 66 ++
src/pages/device/settings/room/room.scss | 78 ++
src/pages/device/settings/room/room.types.ts | 11 +
.../device/settings/room/use-device-room.ts | 165 +++++
.../device/settings/use-device-settings.ts | 348 +++++++++
src/pages/device/use-device.ts | 107 ++-
src/pages/login/index.vue | 4 +-
src/pages/login/use-login.ts | 2 +
src/pages/mine/change-phone/bind/index.vue | 35 +
.../bind/use-change-phone-bind.ts | 67 ++
.../mine/change-phone/change-phone.api.ts | 34 +
src/pages/mine/change-phone/change-phone.scss | 50 ++
.../mine/change-phone/change-phone.types.ts | 22 +
src/pages/mine/change-phone/index.vue | 37 +
.../change-phone/use-change-phone-verify.ts | 99 +++
.../components/avatar-action-sheet/index.vue | 123 ++++
.../edit/components/nickname-dialog/index.vue | 150 ++++
src/pages/mine/edit/edit.scss | 151 ++++
src/pages/mine/edit/edit.types.ts | 13 +
src/pages/mine/edit/index.vue | 184 +++++
src/pages/mine/edit/use-edit-profile.ts | 232 ++++++
src/pages/mine/index.vue | 121 ++-
src/pages/mine/mine.api.ts | 13 +
src/pages/mine/mine.scss | 192 +++++
src/pages/mine/mine.types.ts | 11 +
src/pages/mine/notify/index.vue | 79 ++
src/pages/mine/notify/notify.scss | 100 +++
src/pages/mine/notify/notify.utils.ts | 22 +
src/pages/mine/notify/use-notify.ts | 132 ++++
src/pages/mine/privacy/index.vue | 70 ++
src/pages/mine/privacy/privacy.scss | 137 ++++
src/pages/mine/privacy/privacy.types.ts | 12 +
src/pages/mine/privacy/use-privacy.ts | 114 +++
src/pages/mine/set-password/index.vue | 37 +
src/pages/mine/set-password/phone/index.vue | 46 ++
.../phone/use-set-password-phone.ts | 85 +++
.../mine/set-password/set-password.api.ts | 34 +
src/pages/mine/set-password/set-password.scss | 108 +++
.../mine/set-password/set-password.types.ts | 10 +
.../mine/set-password/use-set-password.ts | 63 ++
src/pages/mine/set-password/verify/index.vue | 31 +
.../verify/use-set-password-verify.ts | 7 +
src/pages/mine/storage/index.vue | 68 ++
src/pages/mine/storage/storage.scss | 130 ++++
src/pages/mine/storage/storage.types.ts | 6 +
src/pages/mine/storage/use-storage.ts | 52 ++
src/pages/mine/support/cs-chat-socket.ts | 169 +++++
src/pages/mine/support/index.vue | 319 ++++++++
src/pages/mine/support/support-runtime.ts | 46 ++
src/pages/mine/support/support.api.ts | 136 ++++
src/pages/mine/support/support.scss | 695 ++++++++++++++++++
src/pages/mine/support/support.types.ts | 179 +++++
src/pages/mine/support/support.utils.ts | 253 +++++++
src/pages/mine/support/use-support.ts | 489 ++++++++++++
src/pages/mine/use-mine.ts | 132 ++++
src/static/chevron.left.png | Bin 0 -> 509 bytes
src/static/优惠券@2x.png | Bin 0 -> 821 bytes
src/static/图标 13.png | Bin 0 -> 642 bytes
src/static/图标 14.png | Bin 0 -> 395 bytes
src/static/组 134.png | Bin 0 -> 617 bytes
src/static/组 14919@2x.png | Bin 0 -> 1113 bytes
src/static/组 14923@2x.png | Bin 0 -> 865 bytes
src/static/订单@2x.png | Bin 0 -> 445 bytes
src/types/location.ts | 38 +
src/types/message.ts | 38 +
src/types/profile.ts | 36 +
src/utils/bluetooth.ts | 129 +++-
src/utils/device-id.ts | 20 +
src/utils/map-geocode.ts | 42 ++
src/utils/message-socket.ts | 128 ++++
src/utils/static-assets.ts | 24 +
src/utils/tencent-map-loader.ts | 59 ++
src/utils/validate.ts | 11 +
src/utils/z2-ble-protocol.ts | 331 +++++++++
src/utils/z2-ble.ts | 416 +++++++++++
vite.config.ts | 3 +-
128 files changed, 10429 insertions(+), 385 deletions(-)
create mode 100644 src/api/home.ts
create mode 100644 src/api/location.ts
create mode 100644 src/api/message.ts
create mode 100644 src/api/profile.ts
create mode 100644 src/hooks/use-message-inbox.ts
create mode 100644 src/pages/add-device/complete/complete.api.ts
create mode 100644 src/pages/add-device/complete/complete.types.ts
create mode 100644 src/pages/device/device-presence.ts
create mode 100644 src/pages/device/home-manage/components/home-name-dialog/index.vue
create mode 100644 src/pages/device/home-manage/detail/index.vue
create mode 100644 src/pages/device/home-manage/home-manage.api.ts
create mode 100644 src/pages/device/home-manage/home-manage.scss
create mode 100644 src/pages/device/home-manage/home-manage.types.ts
create mode 100644 src/pages/device/home-manage/index.vue
create mode 100644 src/pages/device/home-manage/location/components/location-map/index.vue
create mode 100644 src/pages/device/home-manage/location/home-location.scss
create mode 100644 src/pages/device/home-manage/location/index.vue
create mode 100644 src/pages/device/home-manage/location/use-home-location.ts
create mode 100644 src/pages/device/home-manage/use-home-list.ts
create mode 100644 src/pages/device/home-manage/use-home-manage.ts
create mode 100644 src/pages/device/settings/components/device-name-dialog/index.vue
create mode 100644 src/pages/device/settings/components/unbind-confirm-dialog/index.vue
create mode 100644 src/pages/device/settings/device-settings.api.ts
create mode 100644 src/pages/device/settings/device-settings.scss
create mode 100644 src/pages/device/settings/device-settings.types.ts
create mode 100644 src/pages/device/settings/index.vue
create mode 100644 src/pages/device/settings/room/index.vue
create mode 100644 src/pages/device/settings/room/room.scss
create mode 100644 src/pages/device/settings/room/room.types.ts
create mode 100644 src/pages/device/settings/room/use-device-room.ts
create mode 100644 src/pages/device/settings/use-device-settings.ts
create mode 100644 src/pages/mine/change-phone/bind/index.vue
create mode 100644 src/pages/mine/change-phone/bind/use-change-phone-bind.ts
create mode 100644 src/pages/mine/change-phone/change-phone.api.ts
create mode 100644 src/pages/mine/change-phone/change-phone.scss
create mode 100644 src/pages/mine/change-phone/change-phone.types.ts
create mode 100644 src/pages/mine/change-phone/index.vue
create mode 100644 src/pages/mine/change-phone/use-change-phone-verify.ts
create mode 100644 src/pages/mine/edit/components/avatar-action-sheet/index.vue
create mode 100644 src/pages/mine/edit/components/nickname-dialog/index.vue
create mode 100644 src/pages/mine/edit/edit.scss
create mode 100644 src/pages/mine/edit/edit.types.ts
create mode 100644 src/pages/mine/edit/index.vue
create mode 100644 src/pages/mine/edit/use-edit-profile.ts
create mode 100644 src/pages/mine/mine.api.ts
create mode 100644 src/pages/mine/mine.scss
create mode 100644 src/pages/mine/mine.types.ts
create mode 100644 src/pages/mine/notify/index.vue
create mode 100644 src/pages/mine/notify/notify.scss
create mode 100644 src/pages/mine/notify/notify.utils.ts
create mode 100644 src/pages/mine/notify/use-notify.ts
create mode 100644 src/pages/mine/privacy/index.vue
create mode 100644 src/pages/mine/privacy/privacy.scss
create mode 100644 src/pages/mine/privacy/privacy.types.ts
create mode 100644 src/pages/mine/privacy/use-privacy.ts
create mode 100644 src/pages/mine/set-password/index.vue
create mode 100644 src/pages/mine/set-password/phone/index.vue
create mode 100644 src/pages/mine/set-password/phone/use-set-password-phone.ts
create mode 100644 src/pages/mine/set-password/set-password.api.ts
create mode 100644 src/pages/mine/set-password/set-password.scss
create mode 100644 src/pages/mine/set-password/set-password.types.ts
create mode 100644 src/pages/mine/set-password/use-set-password.ts
create mode 100644 src/pages/mine/set-password/verify/index.vue
create mode 100644 src/pages/mine/set-password/verify/use-set-password-verify.ts
create mode 100644 src/pages/mine/storage/index.vue
create mode 100644 src/pages/mine/storage/storage.scss
create mode 100644 src/pages/mine/storage/storage.types.ts
create mode 100644 src/pages/mine/storage/use-storage.ts
create mode 100644 src/pages/mine/support/cs-chat-socket.ts
create mode 100644 src/pages/mine/support/index.vue
create mode 100644 src/pages/mine/support/support-runtime.ts
create mode 100644 src/pages/mine/support/support.api.ts
create mode 100644 src/pages/mine/support/support.scss
create mode 100644 src/pages/mine/support/support.types.ts
create mode 100644 src/pages/mine/support/support.utils.ts
create mode 100644 src/pages/mine/support/use-support.ts
create mode 100644 src/pages/mine/use-mine.ts
create mode 100644 src/static/chevron.left.png
create mode 100644 src/static/优惠券@2x.png
create mode 100644 src/static/图标 13.png
create mode 100644 src/static/图标 14.png
create mode 100644 src/static/组 134.png
create mode 100644 src/static/组 14919@2x.png
create mode 100644 src/static/组 14923@2x.png
create mode 100644 src/static/订单@2x.png
create mode 100644 src/types/location.ts
create mode 100644 src/types/message.ts
create mode 100644 src/types/profile.ts
create mode 100644 src/utils/device-id.ts
create mode 100644 src/utils/map-geocode.ts
create mode 100644 src/utils/message-socket.ts
create mode 100644 src/utils/tencent-map-loader.ts
create mode 100644 src/utils/z2-ble-protocol.ts
create mode 100644 src/utils/z2-ble.ts
diff --git a/.env.development b/.env.development
index 2e63792..dc1c220 100644
--- a/.env.development
+++ b/.env.development
@@ -1,3 +1,5 @@
VITE_API_BASE_URL=http://192.168.0.222:8082
VITE_API_PROXY_TARGET=http://192.168.0.222:8082
-VITE_API_CLIENT_ID=app
+VITE_MESSAGE_WS_URL=ws://192.168.0.222:8082/app/ws/message
+VITE_CS_CHAT_WS_URL=ws://192.168.0.212:8082/app/cs/chat
+VITE_API_CLIENT_ID=428a8310cd442757ae699df5d894f051
diff --git a/.env.example b/.env.example
index 6b3e71f..af7a762 100644
--- a/.env.example
+++ b/.env.example
@@ -4,9 +4,20 @@ 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
-# RuoYi-Vue-Plus App 端 clientId(请求头 clientId)
-VITE_API_CLIENT_ID=app
+# 消息推送 WebSocket(不含 token,连接时自动拼接 ?token=)
+VITE_MESSAGE_WS_URL=ws://192.168.0.222:8082/app/ws/message
-# 可选:配置后详情页会通过 BLE 特征值读取设备运行数据(JSON)
-# VITE_BLE_SERVICE_UUID=
-# VITE_BLE_RUNTIME_CHARACTERISTIC_UUID=
+# 在线客服 WebSocket(不含 token/sessionId,连接时自动拼接)
+VITE_CS_CHAT_WS_URL=ws://192.168.0.212:8082/app/cs/chat
+
+# RuoYi-Vue-Plus App 端 clientId(请求头 clientId)
+VITE_API_CLIENT_ID=428a8310cd442757ae699df5d894f051
+
+# Z2 BLE UUID(嵌入式文档默认 FFF2 写 / FFF1 通知,已内置,一般无需配置)
+# VITE_BLE_SERVICE_UUID=0000ffe0-0000-1000-8000-00805f9b34fb
+# VITE_BLE_WRITE_CHAR_UUID=0000fff2-0000-1000-8000-00805f9b34fb
+# VITE_BLE_NOTIFY_CHAR_UUID=0000fff1-0000-1000-8000-00805f9b34fb
+# VITE_BLE_DEBUG=true
+
+# 腾讯地图 Key(可选,用于拖动地图后逆地理编码显示详细地址)
+# VITE_TENCENT_MAP_KEY=
diff --git a/src/App.vue b/src/App.vue
index c366687..7e1ae94 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -1,14 +1,19 @@
diff --git a/src/hooks/use-message-inbox.ts b/src/hooks/use-message-inbox.ts
new file mode 100644
index 0000000..431878a
--- /dev/null
+++ b/src/hooks/use-message-inbox.ts
@@ -0,0 +1,48 @@
+import { ref } from 'vue'
+import { fetchUnreadMessageCount } from '@/api/message'
+import { connectMessageSocket, disconnectMessageSocket, onMessagePush } from '@/utils/message-socket'
+
+export const unreadMessageCount = ref(0)
+
+let inboxInitialized = false
+let refreshingUnread = false
+let lastUnreadRefreshAt = 0
+
+const UNREAD_REFRESH_MIN_GAP_MS = 5000
+
+export async function refreshUnreadMessageCount(force = false): Promise {
+ const now = Date.now()
+ if (!force && (refreshingUnread || now - lastUnreadRefreshAt < UNREAD_REFRESH_MIN_GAP_MS)) {
+ return unreadMessageCount.value
+ }
+
+ refreshingUnread = true
+ try {
+ const count = await fetchUnreadMessageCount()
+ unreadMessageCount.value = typeof count === 'number' ? count : Number(count) || 0
+ lastUnreadRefreshAt = Date.now()
+ } catch {
+ unreadMessageCount.value = 0
+ } finally {
+ refreshingUnread = false
+ }
+ return unreadMessageCount.value
+}
+
+export function setupMessageInbox() {
+ connectMessageSocket()
+ if (!inboxInitialized) {
+ inboxInitialized = true
+ onMessagePush(() => {
+ void refreshUnreadMessageCount()
+ })
+ void refreshUnreadMessageCount(true)
+ return
+ }
+}
+
+export function teardownMessageInbox() {
+ disconnectMessageSocket()
+ unreadMessageCount.value = 0
+ inboxInitialized = false
+}
diff --git a/src/manifest.json b/src/manifest.json
index fd83415..36cb0f4 100644
--- a/src/manifest.json
+++ b/src/manifest.json
@@ -17,7 +17,9 @@
"delay" : 0
},
/* 模块配置 */
- "modules" : {},
+ "modules" : {
+ "Maps" : {}
+ },
/* 应用发布信息 */
"distribute" : {
/* android打包配置 */
@@ -64,12 +66,17 @@
"permission" : {
"scope.bluetooth" : {
"desc" : "你的蓝牙将用于发现和连接智能设备"
+ },
+ "scope.userLocation" : {
+ "desc" : "你的位置信息将用于在地图上选择家庭位置"
}
},
"requiredPrivateInfos" : [
"getBluetoothDevices",
"onBluetoothDeviceFound",
- "startBluetoothDevicesDiscovery"
+ "startBluetoothDevicesDiscovery",
+ "getLocation",
+ "chooseLocation"
]
},
"mp-alipay" : {
@@ -81,6 +88,15 @@
"mp-toutiao" : {
"usingComponents" : true
},
+ "h5" : {
+ "sdkConfigs" : {
+ "maps" : {
+ "tencent" : {
+ "key" : ""
+ }
+ }
+ }
+ },
"uniStatistics": {
"enable": false
},
diff --git a/src/pages.json b/src/pages.json
index 0eb73f8..84e4cfc 100644
--- a/src/pages.json
+++ b/src/pages.json
@@ -27,6 +27,41 @@
"navigationBarTitleText": "设备详情"
}
},
+ {
+ "path": "pages/device/settings/index",
+ "style": {
+ "navigationStyle": "custom",
+ "navigationBarTitleText": "设备设置"
+ }
+ },
+ {
+ "path": "pages/device/settings/room/index",
+ "style": {
+ "navigationStyle": "custom",
+ "navigationBarTitleText": "所在房间"
+ }
+ },
+ {
+ "path": "pages/device/home-manage/index",
+ "style": {
+ "navigationStyle": "custom",
+ "navigationBarTitleText": "家庭管理"
+ }
+ },
+ {
+ "path": "pages/device/home-manage/detail/index",
+ "style": {
+ "navigationStyle": "custom",
+ "navigationBarTitleText": "家庭管理"
+ }
+ },
+ {
+ "path": "pages/device/home-manage/location/index",
+ "style": {
+ "navigationStyle": "custom",
+ "navigationBarTitleText": "家庭位置"
+ }
+ },
{
"path": "pages/add-device/index",
"style": {
@@ -62,6 +97,76 @@
"navigationStyle": "custom"
}
},
+ {
+ "path": "pages/mine/edit/index",
+ "style": {
+ "navigationStyle": "custom",
+ "navigationBarTitleText": "编辑资料"
+ }
+ },
+ {
+ "path": "pages/mine/support/index",
+ "style": {
+ "navigationStyle": "custom",
+ "navigationBarTitleText": "在线客服"
+ }
+ },
+ {
+ "path": "pages/mine/notify/index",
+ "style": {
+ "navigationStyle": "custom",
+ "navigationBarTitleText": "通知"
+ }
+ },
+ {
+ "path": "pages/mine/storage/index",
+ "style": {
+ "navigationStyle": "custom",
+ "navigationBarTitleText": "储存空间"
+ }
+ },
+ {
+ "path": "pages/mine/privacy/index",
+ "style": {
+ "navigationStyle": "custom",
+ "navigationBarTitleText": "账号与安全"
+ }
+ },
+ {
+ "path": "pages/mine/change-phone/index",
+ "style": {
+ "navigationStyle": "custom",
+ "navigationBarTitleText": "手机号换绑"
+ }
+ },
+ {
+ "path": "pages/mine/change-phone/bind/index",
+ "style": {
+ "navigationStyle": "custom",
+ "navigationBarTitleText": "手机号换绑"
+ }
+ },
+ {
+ "path": "pages/mine/set-password/verify/index",
+ "style": {
+ "navigationStyle": "custom",
+ "navigationBarTitleText": "身份验证"
+ }
+ },
+ {
+ "path": "pages/mine/set-password/phone/index",
+ "style": {
+ "navigationStyle": "custom",
+ "navigationBarTitleText": "验证手机号"
+ }
+ },
+ {
+ "path": "pages/mine/set-password/index",
+ "style": {
+ "navigationStyle": "custom",
+ "navigationBarTitleText": "设置新密码"
+ }
+ },
{
"path": "pages/register/index",
"style": {
diff --git a/src/pages/add-device/add-device.types.ts b/src/pages/add-device/add-device.types.ts
index 6495e79..1b43ca2 100644
--- a/src/pages/add-device/add-device.types.ts
+++ b/src/pages/add-device/add-device.types.ts
@@ -13,13 +13,13 @@ export interface BindFeatureItem {
/** POST /app/device/bind 请求体 */
export interface BindDeviceBody {
deviceNo: string
- productId?: number
+ productId?: string | number
model?: string
imei?: string
mac?: string
name?: string
- homeId?: number
- locationId?: number
+ homeId?: string | number
+ locationId?: string | number
}
/** 绑定流程页间传递的设备信息 */
@@ -27,10 +27,11 @@ export interface BindDeviceContext {
deviceNo: string
name: string
serialMasked: string
- productId?: number
+ productId?: string | number
model?: string
imei?: string
mac?: string
+ bluetoothConnected?: string
}
export function buildBindDeviceQuery(ctx: BindDeviceContext): string {
@@ -43,6 +44,9 @@ export function buildBindDeviceQuery(ctx: BindDeviceContext): string {
if (ctx.model) parts.push(`model=${encodeURIComponent(ctx.model)}`)
if (ctx.imei) parts.push(`imei=${encodeURIComponent(ctx.imei)}`)
if (ctx.mac) parts.push(`mac=${encodeURIComponent(ctx.mac)}`)
+ if (ctx.bluetoothConnected) {
+ parts.push(`bluetoothConnected=${ctx.bluetoothConnected}`)
+ }
return parts.join('&')
}
@@ -64,5 +68,8 @@ export function readBindDeviceContext(
if (query.model) ctx.model = decodeURIComponent(String(query.model))
if (query.imei) ctx.imei = decodeURIComponent(String(query.imei))
if (query.mac) ctx.mac = decodeURIComponent(String(query.mac))
+ if (query.bluetoothConnected) {
+ ctx.bluetoothConnected = String(query.bluetoothConnected)
+ }
return ctx
}
diff --git a/src/pages/add-device/complete/complete.api.ts b/src/pages/add-device/complete/complete.api.ts
new file mode 100644
index 0000000..06f34b3
--- /dev/null
+++ b/src/pages/add-device/complete/complete.api.ts
@@ -0,0 +1,11 @@
+import { request } from '@/api/request'
+import type { ProductOption } from './complete.types'
+
+/** POST /app/device/productOptions — 获取产品型号下拉列表 */
+export function fetchProductOptions() {
+ return request({
+ url: '/app/device/productOptions',
+ method: 'POST',
+ data: {},
+ })
+}
diff --git a/src/pages/add-device/complete/complete.scss b/src/pages/add-device/complete/complete.scss
index b82f332..c563743 100644
--- a/src/pages/add-device/complete/complete.scss
+++ b/src/pages/add-device/complete/complete.scss
@@ -95,6 +95,14 @@
font-weight: 500;
}
+.complete-page__bt-status--offline .complete-page__bt-dot {
+ background: #e54d4d;
+}
+
+.complete-page__bt-status--offline .complete-page__bt-text {
+ color: #e54d4d;
+}
+
.complete-page__form {
margin: 0 32rpx 24rpx;
padding: 8rpx 24rpx 24rpx;
@@ -138,11 +146,17 @@
flex-shrink: 0;
}
+.complete-page__select-row,
.complete-page__room-row {
display: flex;
align-items: center;
height: 88rpx;
padding: 0 24rpx;
+ cursor: pointer;
+
+ &--hover {
+ opacity: 0.85;
+ }
background: #f7f8fa;
border-radius: 16rpx;
gap: 12rpx;
@@ -158,8 +172,13 @@
flex: 1;
font-size: 28rpx;
color: $color-text;
+
+ &--placeholder {
+ color: #c0c4cc;
+ }
}
+.complete-page__select-arrow,
.complete-page__room-arrow {
width: 24rpx;
height: 24rpx;
diff --git a/src/pages/add-device/complete/complete.types.ts b/src/pages/add-device/complete/complete.types.ts
new file mode 100644
index 0000000..b2c8603
--- /dev/null
+++ b/src/pages/add-device/complete/complete.types.ts
@@ -0,0 +1,11 @@
+/** POST /app/device/productOptions 返回项 */
+export interface ProductOption {
+ productId?: string | number
+ model?: string
+ name?: string
+ category?: string
+ connectTypes?: string
+ filterEnabled?: boolean
+ filterTotalDays?: number
+ capabilities?: string[]
+}
diff --git a/src/pages/add-device/complete/index.vue b/src/pages/add-device/complete/index.vue
index 508e3b0..dc91cb5 100644
--- a/src/pages/add-device/complete/index.vue
+++ b/src/pages/add-device/complete/index.vue
@@ -6,8 +6,12 @@
- 配对成功!
- 请确认设备信息后完成添加
+
+ {{ bluetoothConnected ? '配对成功!' : '配对未完成' }}
+
+
+ {{ bluetoothConnected ? '请确认设备信息后完成添加' : '设备蓝牙未连接,请返回重新配对' }}
+
@@ -21,9 +25,12 @@
设备编号:{{ serialMasked }}
-
+
- 蓝牙已连接
+ {{ bluetoothStatusText }}
@@ -46,15 +53,49 @@
/>
+
+ 产品型号
+
+
+
+ {{ productDisplayText }}
+
+
+
+
+
所在房间
-
+
- {{ roomName }}
+
+ {{ roomOptionsLoading ? '加载中...' : roomName }}
+
@@ -98,18 +139,26 @@ const {
currentStep,
deviceName,
serialMasked,
+ productOptions,
+ productIndex,
+ productDisplayText,
+ productSelected,
+ productOptionsLoading,
roomName,
+ roomSelected,
+ roomOptionsLoading,
loading,
- applyQuery,
+ bluetoothConnected,
+ bluetoothStatusText,
+ initPage,
+ onProductChange,
onPickRoom,
onFinishAdd,
} = useCompleteDevice()
onLoad((query) => {
- applyQuery(query ?? {})
+ void initPage(query ?? {})
})
-
+
diff --git a/src/pages/add-device/complete/use-complete-device.ts b/src/pages/add-device/complete/use-complete-device.ts
index 43d1deb..d1d7c09 100644
--- a/src/pages/add-device/complete/use-complete-device.ts
+++ b/src/pages/add-device/complete/use-complete-device.ts
@@ -1,49 +1,192 @@
-import { ref } from 'vue'
+import { onShow } from '@dcloudio/uni-app'
+import { computed, ref } from 'vue'
+import { resolveValidHomeId } from '@/api/home'
import { getErrorMessage } from '@/utils/api-error'
+import { fetchRoomList } from '@/api/location'
+import type { RoomOption } from '@/types/location'
+import { fetchDeviceList } from '@/pages/device/device.api'
+import { updateDeviceRuntime } from '@/pages/device/detail/device-detail.api'
import { bindDevice } from '../add-device.api'
+import { parseId, sameId } from '@/utils/device-id'
import { readBindDeviceContext } from '../add-device.types'
-
-const ROOM_OPTIONS = [
- { name: '客厅', locationId: 1 },
- { name: '主卧', locationId: 2 },
- { name: '卧室', locationId: 3 },
- { name: '厨房', locationId: 4 },
- { name: '卫生间', locationId: 5 },
-] as const
+import { fetchProductOptions } from './complete.api'
+import type { ProductOption } from './complete.types'
export function useCompleteDevice() {
const currentStep = 4
const deviceNo = ref('')
const deviceName = ref('Z2 空气净化器')
const serialMasked = ref('****A3B2')
- const productId = ref()
- const model = ref()
+ const productId = ref('')
+ const model = ref('')
+ const productOptions = ref([])
+ const productOptionsLoading = ref(false)
+ const productIndex = ref(0)
const imei = ref()
const mac = ref()
- const roomName = ref(ROOM_OPTIONS[0].name)
- const locationId = ref(ROOM_OPTIONS[0].locationId)
+ const homeId = ref('')
+ const roomOptions = ref([])
+ const roomOptionsLoading = ref(false)
+ const roomName = ref('请选择所在房间')
+ const locationId = ref()
const loading = ref(false)
+ const initialized = ref(false)
+ const bluetoothConnected = ref(false)
+
+ const roomSelected = computed(() => !!locationId.value && roomName.value !== '请选择所在房间')
+ const productSelected = computed(() => !!productId.value && !!model.value)
+ const productDisplayText = computed(() => {
+ if (productOptionsLoading.value) return '加载中...'
+ if (model.value) return model.value
+ return '请选择产品型号'
+ })
+ const bluetoothStatusText = computed(() =>
+ bluetoothConnected.value ? '蓝牙已连接' : '蓝牙未连接',
+ )
+
+ function applyProduct(option: ProductOption) {
+ const parsedProductId = parseId(option.productId)
+ if (parsedProductId) productId.value = parsedProductId
+ if (option.model) model.value = option.model
+ const index = productOptions.value.findIndex((item) => sameId(item.productId, option.productId))
+ if (index >= 0) productIndex.value = index
+ }
+
+ function syncProductFromOptions() {
+ const options = productOptions.value
+ if (!options.length) return
+
+ if (productId.value) {
+ const matched = options.find((item) => sameId(item.productId, productId.value))
+ if (matched) {
+ applyProduct(matched)
+ return
+ }
+ }
+ if (model.value) {
+ const matched = options.find((item) => item.model === model.value)
+ if (matched) {
+ applyProduct(matched)
+ return
+ }
+ }
+ applyProduct(options[0])
+ }
+
+ async function loadProductOptions() {
+ if (productOptionsLoading.value) return
+ productOptionsLoading.value = true
+ try {
+ productOptions.value = await fetchProductOptions()
+ if (productOptions.value.length) {
+ syncProductFromOptions()
+ } else {
+ productId.value = ''
+ model.value = ''
+ productIndex.value = 0
+ }
+ } catch (err) {
+ uni.showToast({ title: getErrorMessage(err, '加载产品型号失败'), icon: 'none' })
+ } finally {
+ productOptionsLoading.value = false
+ }
+ }
+
+ function syncRoomFromOptions() {
+ if (locationId.value) {
+ const matched = roomOptions.value.find((item) =>
+ sameId(item.locationId, locationId.value),
+ )
+ if (matched) {
+ roomName.value = matched.name
+ return
+ }
+ }
+ const first = roomOptions.value[0]
+ if (first && !locationId.value) {
+ roomName.value = first.name
+ locationId.value = String(first.locationId)
+ }
+ }
+
+ async function loadRoomOptionList() {
+ if (roomOptionsLoading.value) return
+ roomOptionsLoading.value = true
+ try {
+ homeId.value = await resolveValidHomeId(homeId.value)
+ if (!homeId.value) {
+ uni.showToast({ title: '未找到家庭信息', icon: 'none' })
+ return
+ }
+ roomOptions.value = await fetchRoomList(homeId.value)
+ if (roomOptions.value.length) {
+ syncRoomFromOptions()
+ } else {
+ roomName.value = '请选择所在房间'
+ locationId.value = ''
+ }
+ } catch (err) {
+ uni.showToast({ title: getErrorMessage(err, '加载房间列表失败'), icon: 'none' })
+ } finally {
+ roomOptionsLoading.value = false
+ }
+ }
function applyQuery(query: Record) {
const ctx = readBindDeviceContext(query)
if (ctx.deviceNo) deviceNo.value = ctx.deviceNo
if (ctx.name) deviceName.value = ctx.name
if (ctx.serialMasked) serialMasked.value = ctx.serialMasked
- if (ctx.productId != null) productId.value = ctx.productId
+ const parsedProductId = parseId(ctx.productId)
+ if (parsedProductId) productId.value = parsedProductId
if (ctx.model) model.value = ctx.model
+ if (productOptions.value.length) {
+ syncProductFromOptions()
+ }
if (ctx.imei) imei.value = ctx.imei
if (ctx.mac) mac.value = ctx.mac
+ bluetoothConnected.value = ctx.bluetoothConnected === '1'
+ if (roomOptions.value.length) {
+ syncRoomFromOptions()
+ }
+ }
+
+ async function initPage(query: Record) {
+ applyQuery(query)
+ await Promise.all([loadRoomOptionList(), loadProductOptions()])
+ initialized.value = true
+ }
+
+ function onProductChange(event: { detail: { value: number } }) {
+ const index = Number(event.detail.value)
+ const option = productOptions.value[index]
+ if (!option) return
+ applyProduct(option)
}
function onPickRoom() {
- uni.showActionSheet({
- itemList: ROOM_OPTIONS.map((item) => item.name),
- success: (res) => {
- const picked = ROOM_OPTIONS[res.tapIndex]
- if (picked) {
- roomName.value = picked.name
- locationId.value = picked.locationId
- }
+ if (roomOptionsLoading.value) {
+ uni.showToast({ title: '房间列表加载中', icon: 'none' })
+ return
+ }
+ if (!homeId.value) {
+ uni.showToast({ title: '未找到家庭信息', icon: 'none' })
+ return
+ }
+
+ const params = [
+ `homeId=${homeId.value}`,
+ ...(locationId.value ? [`locationId=${locationId.value}`] : []),
+ `locationName=${encodeURIComponent(roomName.value)}`,
+ ]
+
+ uni.navigateTo({
+ url: `/pages/device/settings/room/index?${params.join('&')}`,
+ events: {
+ roomSelected: (data: { locationId: string | number; name: string }) => {
+ locationId.value = String(data.locationId)
+ roomName.value = data.name
+ },
},
})
}
@@ -58,19 +201,41 @@ export function useCompleteDevice() {
uni.showToast({ title: '缺少设备编号', icon: 'none' })
return
}
+ if (!locationId.value) {
+ uni.showToast({ title: '请选择所在房间', icon: 'none' })
+ return
+ }
+ if (!bluetoothConnected.value) {
+ uni.showToast({ title: '设备未连接蓝牙,请返回重新配对', icon: 'none' })
+ return
+ }
if (loading.value) return
loading.value = true
try {
+ const validHomeId = await resolveValidHomeId(homeId.value)
+ homeId.value = validHomeId
await bindDevice({
deviceNo: deviceNo.value.trim(),
name,
- ...(productId.value != null ? { productId: productId.value } : {}),
- ...(model.value ? { model: model.value } : {}),
+ productId: productId.value,
+ model: model.value,
...(imei.value ? { imei: imei.value } : {}),
...(mac.value ? { mac: mac.value } : {}),
- ...(locationId.value != null ? { locationId: locationId.value } : {}),
+ ...(validHomeId ? { homeId: validHomeId } : {}),
+ ...(locationId.value ? { locationId: locationId.value } : {}),
})
+
+ const list = await fetchDeviceList()
+ const bound = list?.find((item) => item.deviceNo === deviceNo.value.trim())
+ if (bound?.deviceId != null) {
+ await updateDeviceRuntime({
+ deviceId: bound.deviceId,
+ status: bluetoothConnected.value ? '1' : '2',
+ bluetoothConnected: bluetoothConnected.value ? '1' : '0',
+ })
+ }
+
uni.showToast({ title: '添加成功', icon: 'success' })
setTimeout(() => {
uni.reLaunch({ url: '/pages/device/index' })
@@ -82,13 +247,28 @@ export function useCompleteDevice() {
}
}
+ onShow(() => {
+ if (!initialized.value || !homeId.value) return
+ void loadRoomOptionList()
+ })
+
return {
currentStep,
deviceName,
serialMasked,
+ productOptions,
+ productIndex,
+ productDisplayText,
+ productSelected,
+ productOptionsLoading,
roomName,
+ roomSelected,
+ roomOptionsLoading,
loading,
- applyQuery,
+ bluetoothConnected,
+ bluetoothStatusText,
+ initPage,
+ onProductChange,
onPickRoom,
onFinishAdd,
}
diff --git a/src/pages/add-device/pair/components/pair-check-list/index.vue b/src/pages/add-device/pair/components/pair-check-list/index.vue
index 0b5826a..b611d7b 100644
--- a/src/pages/add-device/pair/components/pair-check-list/index.vue
+++ b/src/pages/add-device/pair/components/pair-check-list/index.vue
@@ -12,6 +12,7 @@
:class="`pair-check-list__status--${item.status}`"
>
✓
+ ×
{{ item.title }}
diff --git a/src/pages/add-device/pair/components/pair-device-summary/index.vue b/src/pages/add-device/pair/components/pair-device-summary/index.vue
index de8ebcf..20951c0 100644
--- a/src/pages/add-device/pair/components/pair-device-summary/index.vue
+++ b/src/pages/add-device/pair/components/pair-device-summary/index.vue
@@ -5,24 +5,43 @@
{{ device.name }}
设备编号:{{ device.serialMasked }}
-
+
- 连接中
+ {{ statusText }}
diff --git a/src/pages/add-device/pair/index.vue b/src/pages/add-device/pair/index.vue
index 0c625d5..e0ca983 100644
--- a/src/pages/add-device/pair/index.vue
+++ b/src/pages/add-device/pair/index.vue
@@ -6,13 +6,15 @@
- 配对中...
- 正在与设备建立安全连接,请保持手机靠近
+ {{ pageTitle }}
+ {{ pageDesc }}
-
+
- 正在同步设备配置...
+
+ {{ connectStatus === 'success' ? '蓝牙连接成功' : '正在建立蓝牙连接...' }}
+
{{ progress }}%
@@ -21,45 +23,101 @@
:style="{ width: `${progress}%` }"
/>
-
- {{ progress >= 100 ? '配置同步完成' : `预计还需约 ${etaSeconds} 秒,请勿离开当前页面` }}
-
+ {{ progressTip }}
+
+
+
+ {{ errorMessage }}
-
+
diff --git a/src/pages/add-device/pair/pair.scss b/src/pages/add-device/pair/pair.scss
index 2088b85..3b1a33f 100644
--- a/src/pages/add-device/pair/pair.scss
+++ b/src/pages/add-device/pair/pair.scss
@@ -87,6 +87,36 @@
line-height: 1.5;
}
+.pair-page__error-block {
+ margin: 0 32rpx 24rpx;
+ padding: 24rpx;
+ background: #fff5f5;
+ border-radius: 20rpx;
+ border: 1rpx solid #ffd6d6;
+}
+
+.pair-page__error-text {
+ font-size: 26rpx;
+ color: #e54d4d;
+ line-height: 1.65;
+}
+
+.pair-page__retry {
+ margin-bottom: 16rpx;
+ height: 88rpx;
+ border-radius: 44rpx;
+ background: $color-primary;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.pair-page__retry-text {
+ font-size: 30rpx;
+ font-weight: 600;
+ color: #fff;
+}
+
.pair-page__footer {
padding: 16rpx 32rpx calc(24rpx + env(safe-area-inset-bottom));
background: $color-bg-page;
diff --git a/src/pages/add-device/pair/pair.types.ts b/src/pages/add-device/pair/pair.types.ts
index 6238291..7ad1ecb 100644
--- a/src/pages/add-device/pair/pair.types.ts
+++ b/src/pages/add-device/pair/pair.types.ts
@@ -1,4 +1,6 @@
-export type PairCheckStatus = 'done' | 'syncing' | 'pending'
+export type PairCheckStatus = 'done' | 'syncing' | 'pending' | 'failed'
+
+export type PairConnectStatus = 'connecting' | 'success' | 'failed'
export interface PairCheckItem {
id: string
diff --git a/src/pages/add-device/pair/use-pair-device.ts b/src/pages/add-device/pair/use-pair-device.ts
index 91f40a6..872186a 100644
--- a/src/pages/add-device/pair/use-pair-device.ts
+++ b/src/pages/add-device/pair/use-pair-device.ts
@@ -1,6 +1,14 @@
-import { onUnmounted, ref, watch } from 'vue'
+import { onUnmounted, ref } from 'vue'
+import {
+ ensureBleConnection,
+ ensureBluetoothReady,
+ getBluetoothErrorMessage,
+ isBluetoothApiSupported,
+ scanForBleDeviceByMac,
+} from '@/utils/bluetooth'
+import { queryZ2BleState } from '@/utils/z2-ble'
import { buildBindDeviceQuery } from '../add-device.types'
-import type { PairCheckItem, PairDeviceInfo } from './pair.types'
+import type { PairCheckItem, PairConnectStatus, PairDeviceInfo } from './pair.types'
const DEFAULT_DEVICE: PairDeviceInfo = {
name: '',
@@ -8,61 +16,136 @@ const DEFAULT_DEVICE: PairDeviceInfo = {
deviceNo: '',
}
-export function usePairDevice(initial?: Partial) {
- const currentStep = 3
- const device = ref({
- ...DEFAULT_DEVICE,
- ...initial,
- })
+const SCAN_TIMEOUT_MS = 15000
- const progress = ref(65)
- const etaSeconds = ref(15)
+export function usePairDevice() {
+ const currentStep = 3
+ const device = ref({ ...DEFAULT_DEVICE })
+ const connectStatus = ref('connecting')
+ const progress = ref(0)
+ const errorMessage = ref('')
const checkItems = ref([
- { id: 'signal', title: '蓝牙信号强度', status: 'done', tag: '良好' },
- { id: 'credential', title: '设备凭证校验', status: 'done', tag: '通过' },
- { id: 'network', title: '网络配置下发', status: 'syncing', tag: '同步中' },
+ { id: 'signal', title: '蓝牙信号强度', status: 'pending', tag: '待检测' },
+ { id: 'credential', title: '设备凭证校验', status: 'pending', tag: '待校验' },
+ { id: 'network', title: '蓝牙连接建立', status: 'pending', tag: '待连接' },
])
- let etaTick = 0
- const tick = setInterval(() => {
- if (progress.value < 100) {
- progress.value = Math.min(100, progress.value + 1)
- }
- etaTick += 1
- if (etaTick % 2 === 0 && etaSeconds.value > 0 && progress.value < 100) {
- etaSeconds.value = Math.max(0, etaSeconds.value - 1)
- }
- if (progress.value >= 100) {
- checkItems.value = checkItems.value.map((item) =>
- item.id === 'network'
- ? { ...item, status: 'done', tag: '完成' }
- : item,
- )
- }
- }, 500)
+ let pairing = false
+ let bleDeviceId: string | undefined
- let navigated = false
+ function updateCheck(id: string, status: PairCheckItem['status'], tag: string) {
+ checkItems.value = checkItems.value.map((item) =>
+ item.id === id ? { ...item, status, tag } : item,
+ )
+ }
- watch(progress, (value) => {
- if (value < 100 || navigated) return
- navigated = true
- const d = device.value
- const q = buildBindDeviceQuery({
- deviceNo: d.deviceNo,
- name: d.name,
- serialMasked: d.serialMasked,
- productId: d.productId,
- model: d.model,
- imei: d.imei,
- mac: d.mac,
+ function markFailedFromStep(stepId: string, message: string) {
+ const order = ['signal', 'credential', 'network']
+ const failedIndex = order.indexOf(stepId)
+ checkItems.value = checkItems.value.map((item) => {
+ const idx = order.indexOf(item.id)
+ if (idx < failedIndex && item.status === 'done') return item
+ if (item.id === stepId) return { ...item, status: 'failed', tag: '失败' }
+ if (idx > failedIndex) return { ...item, status: 'pending', tag: '未开始' }
+ return item
})
- setTimeout(() => {
- uni.redirectTo({ url: `/pages/add-device/complete/index?${q}` })
- }, 600)
- })
+ connectStatus.value = 'failed'
+ errorMessage.value = message
+ progress.value = Math.max(progress.value, 30)
+ }
+
+ async function startPairing() {
+ if (pairing) return
+ pairing = true
+ connectStatus.value = 'connecting'
+ errorMessage.value = ''
+ progress.value = 5
+
+ checkItems.value = [
+ { id: 'signal', title: '蓝牙信号强度', status: 'syncing', tag: '检测中' },
+ { id: 'credential', title: '设备凭证校验', status: 'pending', tag: '待校验' },
+ { id: 'network', title: '蓝牙连接建立', status: 'pending', tag: '待连接' },
+ ]
+
+ try {
+ if (!isBluetoothApiSupported()) {
+ throw new Error('当前环境不支持蓝牙,请使用 App 或微信小程序')
+ }
+
+ await ensureBluetoothReady()
+ updateCheck('signal', 'done', '良好')
+ progress.value = 30
+
+ const mac = device.value.mac?.trim()
+ const name = device.value.name?.trim()
+ if (!mac && !name) {
+ throw new Error('缺少设备 MAC 或名称,无法建立蓝牙连接')
+ }
+
+ updateCheck('credential', 'syncing', '扫描中')
+ progress.value = 45
+
+ const found = await scanForBleDeviceByMac(mac ?? '', {
+ timeoutMs: SCAN_TIMEOUT_MS,
+ name,
+ })
+ if (!found) {
+ markFailedFromStep('credential', '未找到设备,请确认已开机并靠近手机')
+ return
+ }
+
+ updateCheck('credential', 'done', '通过')
+ progress.value = 70
+ updateCheck('network', 'syncing', '连接中')
+
+ await ensureBleConnection(found.deviceId)
+ bleDeviceId = found.deviceId
+
+ try {
+ await queryZ2BleState(found.deviceId)
+ } catch {
+ // 连接成功即可继续
+ }
+
+ updateCheck('network', 'done', '已连接')
+ progress.value = 100
+ connectStatus.value = 'success'
+
+ const d = device.value
+ const q = buildBindDeviceQuery({
+ deviceNo: d.deviceNo,
+ name: d.name,
+ serialMasked: d.serialMasked,
+ productId: d.productId,
+ model: d.model,
+ imei: d.imei,
+ mac: d.mac,
+ bluetoothConnected: '1',
+ })
+
+ setTimeout(() => {
+ uni.redirectTo({ url: `/pages/add-device/complete/index?${q}` })
+ }, 500)
+ } catch (err) {
+ const message = getBluetoothErrorMessage(err)
+ const failedStep = progress.value < 30
+ ? 'signal'
+ : progress.value < 70
+ ? 'credential'
+ : 'network'
+ markFailedFromStep(failedStep, message)
+ uni.showToast({ title: message, icon: 'none' })
+ } finally {
+ pairing = false
+ }
+ }
+
+ function applyDevice(partial?: Partial) {
+ device.value = { ...device.value, ...partial }
+ }
onUnmounted(() => {
- clearInterval(tick)
+ bleDeviceId = undefined
})
function onCancelPair() {
@@ -77,12 +160,20 @@ export function usePairDevice(initial?: Partial) {
})
}
+ function onRetryPair() {
+ void startPairing()
+ }
+
return {
currentStep,
device,
+ connectStatus,
progress,
- etaSeconds,
+ errorMessage,
checkItems,
+ applyDevice,
+ startPairing,
onCancelPair,
+ onRetryPair,
}
}
diff --git a/src/pages/device/components/room-location-dialog/index.vue b/src/pages/device/components/room-location-dialog/index.vue
index 45270c4..649f77b 100644
--- a/src/pages/device/components/room-location-dialog/index.vue
+++ b/src/pages/device/components/room-location-dialog/index.vue
@@ -2,7 +2,7 @@
- 设置房间位置
+ {{ title }}
import { ref, watch } from 'vue'
-const props = defineProps<{
- visible: boolean
-}>()
+const props = withDefaults(
+ defineProps<{
+ visible: boolean
+ title?: string
+ }>(),
+ {
+ title: '设置房间位置',
+ },
+)
const emit = defineEmits<{
'update:visible': [value: boolean]
diff --git a/src/pages/device/detail/device-bluetooth-read.ts b/src/pages/device/detail/device-bluetooth-read.ts
index 56b48b4..6fb6e91 100644
--- a/src/pages/device/detail/device-bluetooth-read.ts
+++ b/src/pages/device/detail/device-bluetooth-read.ts
@@ -1,92 +1,22 @@
+import {
+ mapZ2StateToRuntimePatch,
+ type Z2AirQuality,
+} from '@/utils/z2-ble-protocol'
+import { queryZ2BleState } from '@/utils/z2-ble'
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()
+export type BleRuntimePatch = Partial & {
+ deviceBattery?: number
+ filterLife?: number
+ airQuality?: Z2AirQuality
}
-function readBleCharacteristic(
- deviceId: string,
- serviceId: string,
- characteristicId: string,
-): Promise {
- 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 {
- try {
- const bytes = new Uint8Array(buffer)
- const text = String.fromCharCode(...bytes).trim()
- if (!text) return {}
- const data = JSON.parse(text) as Record
- const patch: Partial = {}
- 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,则尝试读取设备运行数据 */
+/** 通过 Z2 BLE 协议查询设备运行数据 */
export async function readRuntimeFromBle(
bleDeviceId: string,
-): Promise> {
- if (!SERVICE_UUID || !RUNTIME_CHAR_UUID) return {}
+): Promise {
if (typeof uni.readBLECharacteristicValue !== 'function') return {}
- try {
- const buffer = await readBleCharacteristic(bleDeviceId, SERVICE_UUID, RUNTIME_CHAR_UUID)
- return parseRuntimePayload(buffer)
- } catch {
- return {}
- }
+ const state = await queryZ2BleState(bleDeviceId)
+ return mapZ2StateToRuntimePatch(state)
}
diff --git a/src/pages/device/detail/device-detail.types.ts b/src/pages/device/detail/device-detail.types.ts
index 80137bf..ba12e73 100644
--- a/src/pages/device/detail/device-detail.types.ts
+++ b/src/pages/device/detail/device-detail.types.ts
@@ -1,6 +1,6 @@
/** POST /app/device/runtime 查询参数 */
export interface DeviceRuntimeQueryBody {
- deviceId: number
+ deviceId: string | number
}
/**
@@ -25,11 +25,12 @@ export interface AppDeviceRuntimeVo {
filterLife?: number
filterReplaceTime?: string
parkingShutdown?: string
+ deviceBattery?: number
}
/** POST /app/device/updateRuntime */
export interface UpdateDeviceRuntimeBody {
- deviceId: number
+ deviceId: string | number
runMode?: string
fanLevel?: string
status?: string
@@ -38,6 +39,7 @@ export interface UpdateDeviceRuntimeBody {
charging?: string
tvoc?: number
powerMode?: string
+ deviceBattery?: number
filterTotalDays?: string
filterRemainDays?: string
parkingShutdown?: string
@@ -45,8 +47,12 @@ export interface UpdateDeviceRuntimeBody {
export interface DeviceDetailRouteQuery {
deviceId?: string
+ deviceNo?: string
name?: string
locationName?: string
+ locationId?: string
+ homeId?: string
+ model?: string
status?: string
bluetoothConnected?: string
charging?: string
diff --git a/src/pages/device/detail/device-detail.utils.ts b/src/pages/device/detail/device-detail.utils.ts
index d5ce388..f8eb6d5 100644
--- a/src/pages/device/detail/device-detail.utils.ts
+++ b/src/pages/device/detail/device-detail.utils.ts
@@ -59,9 +59,9 @@ export function formatTvocValue(tvoc?: number): string {
}
export function formatTvocLevel(tvoc?: number): string {
- const value = tvoc ?? 0
- if (value <= 0.3) return '优'
- if (value <= 0.6) return '良'
+ if (tvoc == null) return '--'
+ if (tvoc <= 0.3) return '优'
+ if (tvoc <= 0.6) return '良'
return '差'
}
diff --git a/src/pages/device/detail/use-device-bluetooth-sync.ts b/src/pages/device/detail/use-device-bluetooth-sync.ts
index eebb8f0..46d7701 100644
--- a/src/pages/device/detail/use-device-bluetooth-sync.ts
+++ b/src/pages/device/detail/use-device-bluetooth-sync.ts
@@ -1,9 +1,8 @@
import { onUnload } from '@dcloudio/uni-app'
import { ref } from 'vue'
import {
- BluetoothError,
closeBleConnection,
- createBleConnection,
+ ensureBleConnection,
ensureBluetoothReady,
getBluetoothErrorMessage,
getDiscoveredBluetoothDevices,
@@ -12,20 +11,24 @@ import {
scanForBleDeviceByMac,
subscribeBleConnectionStateChange,
} from '@/utils/bluetooth'
+import { mapZ2StateToRuntimePatch } from '@/utils/z2-ble-protocol'
+import { clearZ2BleHandleCache, sendZ2BleCommand } from '@/utils/z2-ble'
import { updateDeviceRuntime } from './device-detail.api'
-import { readRuntimeFromBle } from './device-bluetooth-read'
+import { readRuntimeFromBle, type BleRuntimePatch } 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
+ getDeviceId: () => string | number
getMac: () => string | undefined
+ getDeviceName?: () => string
getRuntimePatch: () => Partial
onBluetoothConnectedChange: (connected: boolean) => void
onSyncTextChange: (text: string) => void
onSynced?: () => void | Promise
+ onBleRuntimeRead?: (patch: BleRuntimePatch) => void
}
export function useDeviceBluetoothSync(context: DeviceBluetoothSyncContext) {
@@ -39,7 +42,7 @@ export function useDeviceBluetoothSync(context: DeviceBluetoothSyncContext) {
context.onSyncTextChange(text)
}
- async function reportDisconnected() {
+ async function reportBleDisconnected() {
const deviceId = context.getDeviceId()
if (!deviceId) return
context.onBluetoothConnectedChange(false)
@@ -54,6 +57,22 @@ export function useDeviceBluetoothSync(context: DeviceBluetoothSyncContext) {
}
}
+ async function reportDeviceOffline() {
+ const deviceId = context.getDeviceId()
+ if (!deviceId) return
+ context.onBluetoothConnectedChange(false)
+ try {
+ await updateDeviceRuntime({
+ deviceId,
+ status: '2',
+ bluetoothConnected: '0',
+ signalLevel: '0',
+ })
+ } catch {
+ // 上报失败时保留本地状态
+ }
+ }
+
async function connectAndSync(): Promise {
const deviceId = context.getDeviceId()
const mac = context.getMac()
@@ -72,18 +91,24 @@ export function useDeviceBluetoothSync(context: DeviceBluetoothSyncContext) {
if (syncing.value) return false
syncing.value = true
+ let connected = false
+ let targetBleId = bleDeviceId.value
+
try {
setSyncText('正在连接蓝牙…')
await ensureBluetoothReady()
- let targetBleId = bleDeviceId.value
let foundDevice: UniApp.BluetoothDeviceInfo | null = null
if (!targetBleId) {
- foundDevice = await scanForBleDeviceByMac(mac, { timeoutMs: SCAN_TIMEOUT_MS })
+ const deviceName = context.getDeviceName?.()
+ foundDevice = await scanForBleDeviceByMac(mac ?? '', {
+ timeoutMs: SCAN_TIMEOUT_MS,
+ name: deviceName,
+ })
if (!foundDevice) {
setSyncText('未找到设备,请确认已开机并靠近手机')
- await reportDisconnected()
+ await reportDeviceOffline()
return false
}
targetBleId = foundDevice.deviceId
@@ -93,22 +118,16 @@ export function useDeviceBluetoothSync(context: DeviceBluetoothSyncContext) {
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
- }
-
+ await ensureBleConnection(targetBleId)
+ connected = true
context.onBluetoothConnectedChange(true)
+ setSyncText('正在同步设备数据…')
const blePayload = await readRuntimeFromBle(targetBleId)
+ if (Object.keys(blePayload).length) {
+ context.onBleRuntimeRead?.(blePayload)
+ }
+
const runtimePatch = context.getRuntimePatch()
const signalLevel = rssiToSignalLevel(foundDevice?.RSSI)
@@ -121,28 +140,58 @@ export function useDeviceBluetoothSync(context: DeviceBluetoothSyncContext) {
signalLevel,
})
- setSyncText('刚刚更新 · 数据实时同步中')
+ if (!Object.keys(blePayload).length) {
+ setSyncText('蓝牙已连接,等待设备上报数据…')
+ } else {
+ setSyncText('刚刚更新 · 数据实时同步中')
+ }
await context.onSynced?.()
return true
} catch (err) {
setSyncText(getBluetoothErrorMessage(err))
- bleDeviceId.value = undefined
- await reportDisconnected()
+ if (!connected) {
+ bleDeviceId.value = undefined
+ clearZ2BleHandleCache(targetBleId)
+ await reportDeviceOffline()
+ }
return false
} finally {
syncing.value = false
}
}
+ async function sendBleCommand(command: string): Promise {
+ if (!bleDeviceId.value) {
+ const ok = await connectAndSync()
+ if (!ok || !bleDeviceId.value) return false
+ }
+ try {
+ const state = await sendZ2BleCommand(bleDeviceId.value!, command)
+ const runtimePatch = mapZ2StateToRuntimePatch(state)
+ if (Object.keys(runtimePatch).length) {
+ context.onBleRuntimeRead?.(runtimePatch)
+ }
+ return true
+ } catch {
+ return false
+ }
+ }
+
function startSyncLoop() {
if (active) return
+
+ if (!isBluetoothApiSupported()) {
+ setSyncText('云端数据已加载 · 实时同步请使用 App 或微信小程序')
+ return
+ }
+
active = true
unsubscribeConnection = subscribeBleConnectionStateChange((res) => {
if (!bleDeviceId.value || res.deviceId !== bleDeviceId.value) return
if (!res.connected) {
bleDeviceId.value = undefined
- void reportDisconnected()
+ void reportBleDisconnected()
setSyncText('蓝牙已断开,正在尝试重连…')
}
})
@@ -167,8 +216,9 @@ export function useDeviceBluetoothSync(context: DeviceBluetoothSyncContext) {
bleDeviceId.value = undefined
if (currentBleId) {
+ clearZ2BleHandleCache(currentBleId)
await closeBleConnection(currentBleId).catch(() => {})
- await reportDisconnected()
+ await reportBleDisconnected()
}
}
@@ -181,5 +231,6 @@ export function useDeviceBluetoothSync(context: DeviceBluetoothSyncContext) {
startSyncLoop,
stopSync,
connectAndSync,
+ sendBleCommand,
}
}
diff --git a/src/pages/device/detail/use-device-detail.ts b/src/pages/device/detail/use-device-detail.ts
index 75a8845..736146f 100644
--- a/src/pages/device/detail/use-device-detail.ts
+++ b/src/pages/device/detail/use-device-detail.ts
@@ -1,6 +1,8 @@
import { onLoad } from '@dcloudio/uni-app'
import { computed, ref } from 'vue'
+import { fetchRoomList } from '@/api/location'
import { getErrorMessage } from '@/utils/api-error'
+import { parseDeviceId, parseId, sameId } from '@/utils/device-id'
import { fetchDeviceList } from '../device.api'
import { fetchDeviceRuntime, updateDeviceRuntime } from './device-detail.api'
import type { AppDeviceRuntimeVo, DeviceDetailRouteQuery, FanLevel, RunMode } from './device-detail.types'
@@ -15,13 +17,27 @@ import {
mapRuntimeToView,
RUN_MODE_OPTIONS,
} from './device-detail.utils'
+import {
+ airQualityToLabel,
+ airQualityToTvoc,
+ clampPercent,
+ encodeFanLevelBleCommand,
+ encodeRunModeBleCommand,
+ type Z2AirQuality,
+ Z2_BLE_CMD,
+} from '@/utils/z2-ble-protocol'
+import type { BleRuntimePatch } from './device-bluetooth-read'
import { useDeviceBluetoothSync } from './use-device-bluetooth-sync'
export function useDeviceDetail() {
- const deviceId = ref(0)
+ const deviceId = ref('')
+ const deviceNo = ref('')
const deviceMac = ref()
const deviceName = ref('')
const locationName = ref('客厅')
+ const locationId = ref('')
+ const homeId = ref('')
+ const productModel = ref('CZ-Z2023')
const deviceStatus = ref()
const bluetoothConnected = ref(false)
const charging = ref(false)
@@ -32,6 +48,7 @@ export function useDeviceDetail() {
const runMode = ref('0')
const fanLevel = ref('0')
const tvoc = ref(null)
+ const airQuality = ref()
const powerMode = ref('0')
const filterLife = ref(0)
const filterRemainText = ref('预计还可使用 -- 天')
@@ -41,7 +58,10 @@ export function useDeviceDetail() {
const syncText = ref('数据实时同步中')
const tvocDisplay = computed(() => formatTvocValue(tvoc.value ?? undefined))
- const tvocLevel = computed(() => formatTvocLevel(tvoc.value ?? undefined))
+ const tvocLevel = computed(() => {
+ if (airQuality.value) return airQualityToLabel(airQuality.value)
+ return formatTvocLevel(tvoc.value ?? undefined)
+ })
const powerModeText = computed(() => formatPowerMode(powerMode.value))
const batteryDisplay = computed(() =>
batteryLevel.value != null ? `${batteryLevel.value}%` : '--',
@@ -58,28 +78,54 @@ export function useDeviceDetail() {
showFanLevelControl.value ? AUTO_FAN_LEVEL_OPTIONS : [],
)
+ function applyBleRuntime(patch: BleRuntimePatch) {
+ if (patch.runMode != null) runMode.value = patch.runMode as RunMode
+ if (patch.fanLevel != null) fanLevel.value = patch.fanLevel as FanLevel
+ if (patch.airQuality != null) {
+ airQuality.value = patch.airQuality
+ tvoc.value = airQualityToTvoc(patch.airQuality) ?? tvoc.value
+ } else if (patch.tvoc != null) {
+ tvoc.value = patch.tvoc
+ }
+ if (patch.powerMode != null) powerMode.value = patch.powerMode
+ if (patch.filterLife != null) filterLife.value = patch.filterLife
+ if (patch.deviceBattery != null) batteryLevel.value = clampPercent(patch.deviceBattery)
+ if (patch.charging != null) charging.value = patch.charging === '1'
+ }
+
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
+ if (view.tvoc != null) 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.deviceBattery != null) batteryLevel.value = clampPercent(data.deviceBattery)
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)
+ const device = list?.find((item) => String(item.deviceId) === deviceId.value)
if (!device) return
+ if (device.deviceId != null) deviceId.value = String(device.deviceId)
+ if (device.deviceNo) deviceNo.value = device.deviceNo
if (device.mac) deviceMac.value = device.mac
+ const parsedLocationId = parseId(device.locationId)
+ if (parsedLocationId) locationId.value = parsedLocationId
+ const parsedHomeId = parseId(device.homeId)
+ if (parsedHomeId) homeId.value = parsedHomeId
+ if (parsedHomeId && parsedLocationId) {
+ const locations = await fetchRoomList(parsedHomeId)
+ const matched = locations.find((item) => sameId(item.locationId, parsedLocationId))
+ if (matched) locationName.value = matched.name
+ }
bluetoothConnected.value = isBluetoothConnected(device.bluetoothConnected)
charging.value = device.charging === '1'
if (device.status !== undefined) deviceStatus.value = device.status
@@ -109,6 +155,14 @@ export function useDeviceDetail() {
if (!deviceId.value || updating.value) return
updating.value = true
try {
+ const bleCmd = patch.runMode != null
+ ? encodeRunModeBleCommand(patch.runMode)
+ : patch.fanLevel != null
+ ? encodeFanLevelBleCommand(patch.fanLevel)
+ : undefined
+ if (bleCmd) {
+ await sendBleCommand(bleCmd)
+ }
await updateDeviceRuntime({
deviceId: deviceId.value,
...patch,
@@ -135,7 +189,21 @@ export function useDeviceDetail() {
}
function onSettings() {
- uni.showToast({ title: '设备设置功能开发中', icon: 'none' })
+ if (!deviceId.value) {
+ uni.showToast({ title: '设备信息不完整', icon: 'none' })
+ return
+ }
+ const q = [
+ `deviceId=${deviceId.value}`,
+ ...(deviceNo.value ? [`deviceNo=${encodeURIComponent(deviceNo.value)}`] : []),
+ `name=${encodeURIComponent(deviceName.value)}`,
+ `locationName=${encodeURIComponent(locationName.value)}`,
+ ...(locationId.value ? [`locationId=${locationId.value}`] : []),
+ ...(homeId.value ? [`homeId=${homeId.value}`] : []),
+ `model=${encodeURIComponent(productModel.value)}`,
+ `bluetoothConnected=${bluetoothConnected.value ? '1' : '0'}`,
+ ].join('&')
+ uni.navigateTo({ url: `/pages/device/settings/index?${q}` })
}
function onFilterReplace() {
@@ -143,18 +211,20 @@ export function useDeviceDetail() {
filterReplaceDialogVisible.value = true
}
- function onConfirmFilterReplace() {
+ async function onConfirmFilterReplace() {
+ await sendBleCommand(Z2_BLE_CMD.FILTER_RESET)
filterReplaced.value = true
uni.showToast({ title: '操作成功', icon: 'success' })
}
- const { startSyncLoop } = useDeviceBluetoothSync({
+ const { startSyncLoop, sendBleCommand } = useDeviceBluetoothSync({
getDeviceId: () => deviceId.value,
getMac: () => deviceMac.value,
+ getDeviceName: () => deviceName.value,
getRuntimePatch: () => ({
runMode: runMode.value,
fanLevel: fanLevel.value,
- tvoc: tvoc.value ?? undefined,
+ tvoc: tvoc.value ?? (airQuality.value ? airQualityToTvoc(airQuality.value) : undefined),
powerMode: powerMode.value,
charging: charging.value ? '1' : '0',
}),
@@ -168,19 +238,29 @@ export function useDeviceDetail() {
onSyncTextChange: (text) => {
syncText.value = text
},
+ onBleRuntimeRead: (patch) => {
+ applyBleRuntime(patch)
+ },
onSynced: async () => {
await refreshDeviceMeta()
- const data = await fetchDeviceRuntime({ deviceId: deviceId.value })
- applyRuntime(data)
},
})
onLoad((query?: DeviceDetailRouteQuery) => {
- deviceId.value = Number(query?.deviceId) || 0
+ deviceId.value = parseDeviceId(query?.deviceId)
+ if (query?.deviceNo) {
+ deviceNo.value = decodeURIComponent(String(query.deviceNo))
+ }
if (query?.name) deviceName.value = decodeURIComponent(String(query.name))
if (query?.locationName) {
locationName.value = decodeURIComponent(String(query.locationName))
}
+ const parsedLocationId = parseId(query?.locationId)
+ if (parsedLocationId) locationId.value = parsedLocationId
+
+ const parsedHomeId = parseId(query?.homeId)
+ if (parsedHomeId) homeId.value = parsedHomeId
+ if (query?.model) productModel.value = decodeURIComponent(String(query.model))
if (query?.status) {
deviceStatus.value = String(query.status)
isOnline.value = query.status === '1'
diff --git a/src/pages/device/device-presence.ts b/src/pages/device/device-presence.ts
new file mode 100644
index 0000000..c9dd6b8
--- /dev/null
+++ b/src/pages/device/device-presence.ts
@@ -0,0 +1,111 @@
+import {
+ findDeviceInBleScan,
+ isBluetoothApiSupported,
+ rssiToSignalLevel,
+ scanBluetoothDevices,
+} from '@/utils/bluetooth'
+import { sameId } from '@/utils/device-id'
+import { updateDeviceRuntime } from './detail/device-detail.api'
+import type { DeviceListItem } from './device.types'
+
+let syncing = false
+
+const PRESENCE_DEBUG = import.meta.env.DEV
+ || import.meta.env.VITE_BLE_DEBUG === 'true'
+
+function logPresence(message: string, payload?: unknown) {
+ if (!PRESENCE_DEBUG) return
+ if (payload !== undefined) {
+ console.log(`[Device Presence] ${message}`, payload)
+ return
+ }
+ console.log(`[Device Presence] ${message}`)
+}
+
+function applyPresence(
+ device: DeviceListItem,
+ present: boolean,
+): DeviceListItem {
+ const nextStatus = present ? '1' : '2'
+ const nextBluetooth = present ? '1' : '0'
+ return {
+ ...device,
+ status: nextStatus,
+ bluetoothConnected: nextBluetooth,
+ isOnline: present,
+ }
+}
+
+/** 通过 BLE 扫描刷新设备在线状态(status: 1在线 / 2离线) */
+export async function refreshDevicePresence(
+ devices: DeviceListItem[],
+): Promise {
+ if (!isBluetoothApiSupported()) return devices
+
+ const targets = devices.filter((item) => item.deviceId && (item.mac || item.name))
+ if (!targets.length) {
+ logPresence('skip: no mac/name on devices')
+ return devices
+ }
+
+ if (syncing) return devices
+ syncing = true
+
+ try {
+ const scanned = await scanBluetoothDevices({ timeoutMs: 5000 })
+ logPresence('scan result', {
+ count: scanned.length,
+ names: scanned.map((item) => item.name || item.localName || item.deviceId),
+ })
+
+ const nextDevices = devices.map((device) => {
+ if (!device.deviceId) return device
+ const isTarget = targets.some((item) => sameId(item.deviceId, device.deviceId))
+ if (!isTarget) return device
+
+ const found = findDeviceInBleScan(
+ { mac: device.mac, name: device.name },
+ scanned,
+ { strictName: true },
+ )
+ const present = !!found
+ logPresence('device presence', {
+ name: device.name,
+ mac: device.mac,
+ present,
+ foundId: found?.deviceId,
+ })
+ return applyPresence(device, present)
+ })
+
+ void Promise.allSettled(
+ nextDevices
+ .filter((device) => device.deviceId && targets.some((item) => sameId(item.deviceId, device.deviceId)))
+ .map(async (device) => {
+ const found = findDeviceInBleScan(
+ { mac: device.mac, name: device.name },
+ scanned,
+ { strictName: true },
+ )
+ const present = !!found
+ try {
+ await updateDeviceRuntime({
+ deviceId: device.deviceId!,
+ status: present ? '1' : '2',
+ bluetoothConnected: present ? '1' : '0',
+ signalLevel: present ? rssiToSignalLevel(found?.RSSI) : '0',
+ })
+ } catch (err) {
+ logPresence('updateRuntime failed', err)
+ }
+ }),
+ )
+
+ return nextDevices
+ } catch (err) {
+ logPresence('scan failed', err)
+ return devices
+ } finally {
+ syncing = false
+ }
+}
diff --git a/src/pages/device/device.types.ts b/src/pages/device/device.types.ts
index 06df613..a6eec61 100644
--- a/src/pages/device/device.types.ts
+++ b/src/pages/device/device.types.ts
@@ -1,7 +1,7 @@
export interface RoomTab {
id: string
name: string
- locationId?: number
+ locationId?: string | number
}
export interface HotDeviceItem {
@@ -12,8 +12,8 @@ export interface HotDeviceItem {
/** POST /app/device/list 查询参数 */
export interface DeviceQueryBody {
- homeId?: number
- locationId?: number
+ homeId?: string | number
+ locationId?: string | number
}
/** 设备状态:0未激活 1在线 2离线 */
@@ -21,13 +21,13 @@ export type DeviceStatus = '0' | '1' | '2'
/** POST /app/device/list 返回项 */
export interface AppDeviceVo {
- deviceId?: number
+ deviceId?: number | string
deviceNo?: string
name?: string
imei?: string
mac?: string
- homeId?: number
- locationId?: number
+ homeId?: string | number
+ locationId?: string | number
status?: DeviceStatus | string
bluetoothConnected?: string
signalLevel?: string
diff --git a/src/pages/device/device.utils.ts b/src/pages/device/device.utils.ts
index 53ab39a..b680176 100644
--- a/src/pages/device/device.utils.ts
+++ b/src/pages/device/device.utils.ts
@@ -1,54 +1,44 @@
import type { AppDeviceVo, DeviceListItem, RoomTab } from './device.types'
+import type { RoomOption } from '@/types/location'
+import { sameId } from '@/utils/device-id'
-/** 位置 ID → 房间名(后端暂无 locationName 时的本地映射) */
-const LOCATION_NAME_MAP: Record = {
- 1: '客厅',
- 2: '主卧',
- 3: '卧室',
- 4: '厨房',
- 5: '卫生间',
- 6: '书房',
-}
+export type LocationMap = Map
-export function resolveLocationName(locationId?: number): string {
+function resolveLocationName(
+ locationId?: string | number,
+ locations: RoomOption[] = [],
+): string {
if (locationId == null) return '未分配'
- return LOCATION_NAME_MAP[locationId] ?? `位置${locationId}`
+ const matched = locations.find((item) => sameId(item.locationId, locationId))
+ return matched?.name ?? '未分配'
}
-export function mapDeviceItem(device: AppDeviceVo): DeviceListItem {
+export function mapDeviceItem(
+ device: AppDeviceVo,
+ locationMap: LocationMap,
+): DeviceListItem {
+ const homeKey = device.homeId != null ? String(device.homeId) : ''
+ const locations = homeKey ? locationMap.get(homeKey) ?? [] : []
return {
...device,
- locationName: resolveLocationName(device.locationId),
+ locationName: resolveLocationName(device.locationId, locations),
isOnline: device.status === '1',
isCharging: device.charging === '1',
}
}
+/** 根据家庭房间位置列表构建首页 Tab(与所在房间页数据源一致) */
export function buildRoomTabs(
- devices: DeviceListItem[],
- customRooms: RoomTab[] = [],
+ locations: { locationId: string | number; name: string }[],
): RoomTab[] {
const tabs: RoomTab[] = [{ id: 'all', name: '全部' }]
- const seen = new Set()
-
- for (const device of devices) {
- if (device.locationId == null) continue
- const id = String(device.locationId)
- if (seen.has(id)) continue
- seen.add(id)
+ for (const location of locations) {
tabs.push({
- id,
- name: device.locationName,
- locationId: device.locationId,
+ id: String(location.locationId),
+ name: location.name,
+ locationId: location.locationId,
})
}
-
- for (const room of customRooms) {
- if (seen.has(room.id)) continue
- seen.add(room.id)
- tabs.push(room)
- }
-
return tabs
}
@@ -57,9 +47,7 @@ export function filterDevicesByRoom(
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)
+ return devices.filter((item) => sameId(item.locationId, activeRoomId))
}
export function countOnlineDevices(devices: DeviceListItem[]): number {
diff --git a/src/pages/device/home-manage/components/home-name-dialog/index.vue b/src/pages/device/home-manage/components/home-name-dialog/index.vue
new file mode 100644
index 0000000..d6319e8
--- /dev/null
+++ b/src/pages/device/home-manage/components/home-name-dialog/index.vue
@@ -0,0 +1,150 @@
+
+
+
+
+ 家庭名称
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/device/home-manage/detail/index.vue b/src/pages/device/home-manage/detail/index.vue
new file mode 100644
index 0000000..42d9f4e
--- /dev/null
+++ b/src/pages/device/home-manage/detail/index.vue
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+ {{ summaryText }}
+
+
+
+ 家庭名称
+
+
+
+
+
+
+
+ 家庭位置
+ {{ homeAddress }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/device/home-manage/home-manage.api.ts b/src/pages/device/home-manage/home-manage.api.ts
new file mode 100644
index 0000000..3bcabe2
--- /dev/null
+++ b/src/pages/device/home-manage/home-manage.api.ts
@@ -0,0 +1,16 @@
+import { fetchHomeList, findHomeById } from '@/api/home'
+import { fetchDeviceList } from '../device.api'
+
+export async function fetchHomeManageStats(homeId: string | number) {
+ const [homes, devices] = await Promise.all([
+ fetchHomeList(),
+ fetchDeviceList({ homeId }),
+ ])
+ const home = findHomeById(homes, homeId)
+ const deviceCount = devices?.length ?? 0
+ return {
+ home,
+ roomCount: home?.locationCount ?? 0,
+ deviceCount,
+ }
+}
diff --git a/src/pages/device/home-manage/home-manage.scss b/src/pages/device/home-manage/home-manage.scss
new file mode 100644
index 0000000..ba4b986
--- /dev/null
+++ b/src/pages/device/home-manage/home-manage.scss
@@ -0,0 +1,85 @@
+.home-manage-page {
+ min-height: 100vh;
+ background: #f5f6f8;
+ display: flex;
+ flex-direction: column;
+}
+
+.home-manage-page__scroll {
+ flex: 1;
+ height: 0;
+}
+
+.home-manage-page__body {
+ padding: 24rpx 24rpx calc(40rpx + env(safe-area-inset-bottom));
+}
+
+.home-manage-page__summary {
+ display: block;
+ font-size: 28rpx;
+ color: #999;
+ line-height: 1.5;
+ margin-bottom: 24rpx;
+ padding: 0 8rpx;
+}
+
+.home-manage-page__list {
+ background: #fff;
+ border-radius: 24rpx;
+ overflow: hidden;
+ box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.04);
+}
+
+.home-manage-page__item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ min-height: 112rpx;
+ padding: 28rpx 24rpx 28rpx 32rpx;
+ box-sizing: border-box;
+}
+
+.home-manage-page__location {
+ flex: 1;
+ min-width: 0;
+ padding-right: 20rpx;
+}
+
+.home-manage-page__title {
+ font-size: 32rpx;
+ font-weight: 600;
+ color: $color-text;
+ line-height: 1.35;
+}
+
+.home-manage-page__desc {
+ display: block;
+ margin-top: 8rpx;
+ font-size: 26rpx;
+ color: #999;
+ line-height: 1.4;
+}
+
+.home-manage-page__arrow {
+ width: 16rpx;
+ height: 28rpx;
+ flex-shrink: 0;
+ opacity: 0.45;
+}
+
+.home-manage-page__divider {
+ height: 1rpx;
+ background: #eee;
+ margin-left: 32rpx;
+}
+
+.home-manage-page__empty {
+ padding: 120rpx 32rpx;
+ text-align: center;
+}
+
+.home-manage-page__empty-text {
+ font-size: 28rpx;
+ color: #999;
+ line-height: 1.5;
+}
diff --git a/src/pages/device/home-manage/home-manage.types.ts b/src/pages/device/home-manage/home-manage.types.ts
new file mode 100644
index 0000000..b04b444
--- /dev/null
+++ b/src/pages/device/home-manage/home-manage.types.ts
@@ -0,0 +1,10 @@
+export interface HomeManageRouteQuery {
+ homeId?: string
+ homeName?: string
+}
+
+export interface HomeGeoInfo {
+ address: string
+ latitude: number
+ longitude: number
+}
diff --git a/src/pages/device/home-manage/index.vue b/src/pages/device/home-manage/index.vue
new file mode 100644
index 0000000..3dd86a9
--- /dev/null
+++ b/src/pages/device/home-manage/index.vue
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+ {{ home.homeName || '我的家' }}
+
+
+
+
+
+ 暂无家庭
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/device/home-manage/location/components/location-map/index.vue b/src/pages/device/home-manage/location/components/location-map/index.vue
new file mode 100644
index 0000000..901216b
--- /dev/null
+++ b/src/pages/device/home-manage/location/components/location-map/index.vue
@@ -0,0 +1,147 @@
+
+
+
+
+
+ {{ hintText }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/device/home-manage/location/home-location.scss b/src/pages/device/home-manage/location/home-location.scss
new file mode 100644
index 0000000..63046d3
--- /dev/null
+++ b/src/pages/device/home-manage/location/home-location.scss
@@ -0,0 +1,139 @@
+.home-location-page {
+ height: 100vh;
+ background: #f5f6f8;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.home-location-page__confirm {
+ width: 56rpx;
+ height: 56rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.home-location-page__confirm-icon {
+ font-size: 40rpx;
+ line-height: 1;
+ color: $color-primary;
+ font-weight: 600;
+}
+
+.home-location-page__search {
+ margin: 16rpx 24rpx 0;
+ height: 80rpx;
+ padding: 0 28rpx;
+ background: #fff;
+ border-radius: 16rpx;
+ display: flex;
+ align-items: center;
+ gap: 16rpx;
+ box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
+ flex-shrink: 0;
+}
+
+.home-location-page__search-icon {
+ font-size: 32rpx;
+ color: #999;
+ line-height: 1;
+}
+
+.home-location-page__search-text {
+ flex: 1;
+ font-size: 28rpx;
+ color: #999;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.home-location-page__map-wrap {
+ position: relative;
+ flex: 1;
+ min-height: 0;
+ margin-top: 16rpx;
+}
+
+.home-location-page__map {
+ width: 100%;
+ height: 100%;
+}
+
+.home-location-page__pin {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ width: 40rpx;
+ height: 72rpx;
+ transform: translate(-50%, -100%);
+ pointer-events: none;
+ z-index: 2;
+}
+
+.home-location-page__pin-head {
+ width: 40rpx;
+ height: 40rpx;
+ margin: 0 auto;
+ border-radius: 50% 50% 50% 0;
+ background: $color-primary;
+ transform: rotate(-45deg);
+ box-shadow: 0 4rpx 12rpx rgba(31, 168, 92, 0.35);
+}
+
+.home-location-page__pin-tail {
+ width: 16rpx;
+ height: 16rpx;
+ margin: 8rpx auto 0;
+ border-radius: 50%;
+ background: rgba(31, 168, 92, 0.25);
+}
+
+.home-location-page__locate {
+ position: absolute;
+ right: 24rpx;
+ bottom: 32rpx;
+ width: 80rpx;
+ height: 80rpx;
+ border-radius: 50%;
+ background: #fff;
+ box-shadow: 0 6rpx 20rpx rgba(0, 0, 0, 0.12);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 2;
+}
+
+.home-location-page__locate--loading {
+ opacity: 0.6;
+}
+
+.home-location-page__locate-icon {
+ font-size: 40rpx;
+ line-height: 1;
+ color: $color-primary;
+}
+
+.home-location-page__footer {
+ flex-shrink: 0;
+ padding: 24rpx 32rpx calc(24rpx + env(safe-area-inset-bottom));
+ background: #fff;
+ box-shadow: 0 -4rpx 24rpx rgba(0, 0, 0, 0.06);
+}
+
+.home-location-page__address-label {
+ display: block;
+ font-size: 24rpx;
+ color: #999;
+ line-height: 1.4;
+ margin-bottom: 8rpx;
+}
+
+.home-location-page__address-text {
+ display: block;
+ font-size: 30rpx;
+ color: $color-text;
+ line-height: 1.5;
+ word-break: break-all;
+}
diff --git a/src/pages/device/home-manage/location/index.vue b/src/pages/device/home-manage/location/index.vue
new file mode 100644
index 0000000..1d2db0b
--- /dev/null
+++ b/src/pages/device/home-manage/location/index.vue
@@ -0,0 +1,71 @@
+
+
+
+
+
+ ✓
+
+
+
+
+
+ ⌕
+
+ {{ searchKeyword || '搜索地点' }}
+
+
+
+
+
+
+
+
+
+
+
+
+ ◎
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/device/home-manage/location/use-home-location.ts b/src/pages/device/home-manage/location/use-home-location.ts
new file mode 100644
index 0000000..32f97d5
--- /dev/null
+++ b/src/pages/device/home-manage/location/use-home-location.ts
@@ -0,0 +1,182 @@
+import { onLoad } from '@dcloudio/uni-app'
+import { ref } from 'vue'
+import { parseId } from '@/utils/device-id'
+import { formatCoordinateAddress, reverseGeocode } from '@/utils/map-geocode'
+import { loadHomeGeo, saveHomeGeo } from '../use-home-manage'
+import type { HomeGeoInfo } from '../home-manage.types'
+
+const DEFAULT_LATITUDE = 39.9042
+const DEFAULT_LONGITUDE = 116.4074
+
+function requestUserLocation(): Promise {
+ return new Promise((resolve, reject) => {
+ uni.getLocation({
+ type: 'gcj02',
+ isHighAccuracy: true,
+ success: resolve,
+ fail: reject,
+ })
+ })
+}
+
+export function useHomeLocation() {
+ const homeId = ref('')
+ const latitude = ref(DEFAULT_LATITUDE)
+ const longitude = ref(DEFAULT_LONGITUDE)
+ const searchKeyword = ref('')
+ const addressText = ref('')
+ const locating = ref(false)
+ const resolvingAddress = ref(false)
+ let geocodeTimer: ReturnType | null = null
+
+ function setMapCenter(lat: number, lng: number) {
+ latitude.value = lat
+ longitude.value = lng
+ }
+
+ function scheduleAddressResolve() {
+ if (geocodeTimer) clearTimeout(geocodeTimer)
+ geocodeTimer = setTimeout(() => {
+ void resolveAddressFromCoords()
+ }, 400)
+ }
+
+ async function resolveAddressFromCoords() {
+ resolvingAddress.value = true
+ try {
+ const address = await reverseGeocode(latitude.value, longitude.value)
+ addressText.value = address
+ || formatCoordinateAddress(latitude.value, longitude.value)
+ } finally {
+ resolvingAddress.value = false
+ }
+ }
+
+ async function initMapCenter(saved?: HomeGeoInfo) {
+ if (saved?.latitude != null && saved.longitude != null) {
+ latitude.value = saved.latitude
+ longitude.value = saved.longitude
+ addressText.value = saved.address
+ return
+ }
+
+ locating.value = true
+ try {
+ const location = await requestUserLocation()
+ latitude.value = location.latitude
+ longitude.value = location.longitude
+ if (location.address) {
+ addressText.value = location.address
+ } else {
+ await resolveAddressFromCoords()
+ }
+ } catch {
+ latitude.value = DEFAULT_LATITUDE
+ longitude.value = DEFAULT_LONGITUDE
+ addressText.value = formatCoordinateAddress(latitude.value, longitude.value)
+ } finally {
+ locating.value = false
+ }
+ }
+
+ function applyMapCenter(center: { latitude: number; longitude: number }) {
+ latitude.value = center.latitude
+ longitude.value = center.longitude
+ scheduleAddressResolve()
+ }
+
+ function onRegionChange(e: UniHelper.MapRegionChangeEvent) {
+ if (e.type !== 'end') return
+ const center = e.detail?.centerLocation
+ if (!center) return
+ applyMapCenter(center)
+ }
+
+ function onMapCenterChange(center: { latitude: number; longitude: number }) {
+ applyMapCenter(center)
+ }
+
+ function onChooseLocation() {
+ uni.chooseLocation({
+ latitude: latitude.value,
+ longitude: longitude.value,
+ success: (res) => {
+ latitude.value = res.latitude
+ longitude.value = res.longitude
+ addressText.value = res.name
+ ? `${res.name}${res.address ? ` · ${res.address}` : ''}`
+ : (res.address || formatCoordinateAddress(res.latitude, res.longitude))
+ searchKeyword.value = res.name || res.address || ''
+ setMapCenter(res.latitude, res.longitude)
+ },
+ fail: (err) => {
+ const msg = String(err?.errMsg || '')
+ if (/cancel/i.test(msg) || /取消/.test(msg)) return
+ uni.showToast({ title: '无法打开地图选点,请检查定位权限', icon: 'none' })
+ },
+ })
+ }
+
+ async function onLocate() {
+ locating.value = true
+ try {
+ const location = await requestUserLocation()
+ latitude.value = location.latitude
+ longitude.value = location.longitude
+ setMapCenter(location.latitude, location.longitude)
+ if (location.address) {
+ addressText.value = location.address
+ } else {
+ await resolveAddressFromCoords()
+ }
+ } catch {
+ uni.showToast({ title: '定位失败,请检查定位权限', icon: 'none' })
+ } finally {
+ locating.value = false
+ }
+ }
+
+ function onConfirm() {
+ if (!homeId.value) {
+ uni.navigateBack()
+ return
+ }
+ const address = addressText.value.trim()
+ || formatCoordinateAddress(latitude.value, longitude.value)
+ const geo: HomeGeoInfo = {
+ address,
+ latitude: latitude.value,
+ longitude: longitude.value,
+ }
+ saveHomeGeo(homeId.value, geo)
+ uni.showToast({ title: '保存成功', icon: 'success' })
+ setTimeout(() => {
+ uni.navigateBack()
+ }, 300)
+ }
+
+ onLoad((query?: Record) => {
+ homeId.value = parseId(query?.homeId)
+ const saved = homeId.value ? loadHomeGeo(homeId.value) : undefined
+ if (query?.address) {
+ addressText.value = decodeURIComponent(String(query.address))
+ } else if (saved?.address) {
+ addressText.value = saved.address
+ }
+ void initMapCenter(saved)
+ })
+
+ return {
+ latitude,
+ longitude,
+ searchKeyword,
+ addressText,
+ locating,
+ resolvingAddress,
+ onRegionChange,
+ onMapCenterChange,
+ onChooseLocation,
+ onLocate,
+ onConfirm,
+ }
+}
diff --git a/src/pages/device/home-manage/use-home-list.ts b/src/pages/device/home-manage/use-home-list.ts
new file mode 100644
index 0000000..133169a
--- /dev/null
+++ b/src/pages/device/home-manage/use-home-list.ts
@@ -0,0 +1,48 @@
+import { onShow } from '@dcloudio/uni-app'
+import { ref } from 'vue'
+import { fetchHomeList } from '@/api/home'
+import { getErrorMessage } from '@/utils/api-error'
+import { parseId } from '@/utils/device-id'
+import type { AppHomeVo } from '@/types/location'
+
+export function useHomeList() {
+ const homes = ref([])
+ const loading = ref(false)
+
+ async function loadHomes() {
+ loading.value = true
+ try {
+ homes.value = await fetchHomeList() ?? []
+ } catch (err) {
+ uni.showToast({ title: getErrorMessage(err, '加载家庭列表失败'), icon: 'none' })
+ } finally {
+ loading.value = false
+ }
+ }
+
+ function onSelectHome(home: AppHomeVo) {
+ const homeId = parseId(home.id)
+ if (!homeId) {
+ uni.showToast({ title: '家庭信息不完整', icon: 'none' })
+ return
+ }
+ const params = [
+ `homeId=${homeId}`,
+ `homeName=${encodeURIComponent(home.homeName || '我的家')}`,
+ ]
+ uni.navigateTo({
+ url: `/pages/device/home-manage/detail/index?${params.join('&')}`,
+ })
+ }
+
+ onShow(() => {
+ void loadHomes()
+ })
+
+ return {
+ homes,
+ loading,
+ onSelectHome,
+ loadHomes,
+ }
+}
diff --git a/src/pages/device/home-manage/use-home-manage.ts b/src/pages/device/home-manage/use-home-manage.ts
new file mode 100644
index 0000000..549e530
--- /dev/null
+++ b/src/pages/device/home-manage/use-home-manage.ts
@@ -0,0 +1,111 @@
+import { onLoad, onShow } from '@dcloudio/uni-app'
+import { computed, ref } from 'vue'
+import { resolveValidHomeId } from '@/api/home'
+import { fetchRoomList } from '@/api/location'
+import { getErrorMessage } from '@/utils/api-error'
+import { fetchHomeManageStats } from './home-manage.api'
+import type { HomeGeoInfo, HomeManageRouteQuery } from './home-manage.types'
+
+const HOME_GEO_KEY_PREFIX = 'home_geo_'
+
+export function loadHomeGeo(homeId: string | number): HomeGeoInfo | undefined {
+ try {
+ const raw = uni.getStorageSync(`${HOME_GEO_KEY_PREFIX}${homeId}`) as HomeGeoInfo | string
+ if (raw && typeof raw === 'object' && raw.address) return raw
+ if (typeof raw === 'string' && raw) return JSON.parse(raw) as HomeGeoInfo
+ } catch {
+ // ignore
+ }
+ return undefined
+}
+
+export function saveHomeGeo(homeId: string | number, geo: HomeGeoInfo) {
+ uni.setStorageSync(`${HOME_GEO_KEY_PREFIX}${homeId}`, geo)
+}
+
+export function useHomeManage() {
+ const homeId = ref('')
+ const homeName = ref('我的家')
+ const roomCount = ref(0)
+ const deviceCount = ref(0)
+ const homeAddress = ref('')
+ const loading = ref(false)
+ const nameDialogVisible = ref(false)
+
+ const summaryText = computed(
+ () => `共${roomCount.value}个房间,${deviceCount.value}个设备`,
+ )
+
+ async function loadPageData() {
+ if (!homeId.value) return
+ loading.value = true
+ try {
+ const [stats, rooms] = await Promise.all([
+ fetchHomeManageStats(homeId.value),
+ fetchRoomList(homeId.value),
+ ])
+ if (stats.home?.homeName) homeName.value = stats.home.homeName
+ roomCount.value = rooms.length || stats.roomCount
+ deviceCount.value = stats.deviceCount
+ const geo = loadHomeGeo(homeId.value)
+ homeAddress.value = geo?.address ?? ''
+ } catch (err) {
+ uni.showToast({ title: getErrorMessage(err, '加载家庭信息失败'), icon: 'none' })
+ } finally {
+ loading.value = false
+ }
+ }
+
+ async function initPage(query?: HomeManageRouteQuery) {
+ homeId.value = await resolveValidHomeId(query?.homeId)
+ if (query?.homeName) {
+ homeName.value = decodeURIComponent(String(query.homeName))
+ }
+ await loadPageData()
+ }
+
+ function onOpenNameDialog() {
+ nameDialogVisible.value = true
+ }
+
+ function onConfirmHomeName(name: string) {
+ const trimmed = name.trim()
+ if (!trimmed) {
+ uni.showToast({ title: '请输入家庭名称', icon: 'none' })
+ return
+ }
+ homeName.value = trimmed
+ uni.showToast({ title: '家庭名称修改接口待对接', icon: 'none' })
+ }
+
+ function onOpenLocation() {
+ if (!homeId.value) return
+ const params = [
+ `homeId=${homeId.value}`,
+ `homeName=${encodeURIComponent(homeName.value)}`,
+ ...(homeAddress.value ? [`address=${encodeURIComponent(homeAddress.value)}`] : []),
+ ]
+ uni.navigateTo({
+ url: `/pages/device/home-manage/location/index?${params.join('&')}`,
+ })
+ }
+
+ onLoad((query?: HomeManageRouteQuery) => {
+ void initPage(query)
+ })
+
+ onShow(() => {
+ if (homeId.value) void loadPageData()
+ })
+
+ return {
+ homeName,
+ homeAddress,
+ summaryText,
+ loading,
+ nameDialogVisible,
+ onOpenNameDialog,
+ onConfirmHomeName,
+ onOpenLocation,
+ }
+}
diff --git a/src/pages/device/settings/components/device-name-dialog/index.vue b/src/pages/device/settings/components/device-name-dialog/index.vue
new file mode 100644
index 0000000..25b2031
--- /dev/null
+++ b/src/pages/device/settings/components/device-name-dialog/index.vue
@@ -0,0 +1,150 @@
+
+
+
+
+ 设备名称
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/device/settings/components/unbind-confirm-dialog/index.vue b/src/pages/device/settings/components/unbind-confirm-dialog/index.vue
new file mode 100644
index 0000000..72ba51c
--- /dev/null
+++ b/src/pages/device/settings/components/unbind-confirm-dialog/index.vue
@@ -0,0 +1,130 @@
+
+
+
+
+ 解绑前请确认
+
+
+ · 解绑后设备将从您的账号移除,需重新配对才能控制
+
+
+ · 解绑后其他家庭成员也将失去控制权限
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/device/settings/device-settings.api.ts b/src/pages/device/settings/device-settings.api.ts
new file mode 100644
index 0000000..a965e26
--- /dev/null
+++ b/src/pages/device/settings/device-settings.api.ts
@@ -0,0 +1,20 @@
+import { request } from '@/api/request'
+import type { UpdateDeviceSettingsBody } from './device-settings.types'
+
+/** POST /app/device/updateSettings — 更新设备设置(名称、房间等) */
+export function updateDeviceSettings(data: UpdateDeviceSettingsBody) {
+ return request({
+ url: '/app/device/updateSettings',
+ method: 'POST',
+ data,
+ })
+}
+
+/** POST /app/device/unbind — 解绑设备 */
+export function unbindDevice(data: { deviceId: string | number }) {
+ return request({
+ url: '/app/device/unbind',
+ method: 'POST',
+ data,
+ })
+}
diff --git a/src/pages/device/settings/device-settings.scss b/src/pages/device/settings/device-settings.scss
new file mode 100644
index 0000000..7a83558
--- /dev/null
+++ b/src/pages/device/settings/device-settings.scss
@@ -0,0 +1,155 @@
+.device-settings-page {
+ min-height: 100vh;
+ background: #f5f6f8;
+ display: flex;
+ flex-direction: column;
+}
+
+.device-settings-page__scroll {
+ flex: 1;
+ height: 0;
+}
+
+.device-settings-page__body {
+ padding: 24rpx 24rpx calc(40rpx + env(safe-area-inset-bottom));
+}
+
+.device-settings-card {
+ background: #fff;
+ border-radius: 24rpx;
+ padding: 28rpx 28rpx 24rpx;
+ box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.04);
+ margin-bottom: 32rpx;
+}
+
+.device-settings-card__top {
+ display: flex;
+ align-items: center;
+ gap: 20rpx;
+ margin-bottom: 28rpx;
+}
+
+.device-settings-card__thumb {
+ width: 96rpx;
+ height: 96rpx;
+ border-radius: 16rpx;
+ background: #f5f5f5;
+ flex-shrink: 0;
+}
+
+.device-settings-card__meta {
+ flex: 1;
+ min-width: 0;
+}
+
+.device-settings-card__name {
+ display: block;
+ font-size: 32rpx;
+ font-weight: 600;
+ color: $color-text;
+ line-height: 1.3;
+ margin-bottom: 10rpx;
+}
+
+.device-settings-card__status {
+ display: flex;
+ align-items: center;
+ gap: 8rpx;
+}
+
+.device-settings-card__status-dot {
+ width: 10rpx;
+ height: 10rpx;
+ border-radius: 50%;
+ background: #21c25e;
+
+ &--offline {
+ background: #c0c4cc;
+ }
+}
+
+.device-settings-card__status-text {
+ font-size: 24rpx;
+ color: #6b8cae;
+}
+
+.device-settings-card__model-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding-top: 24rpx;
+ border-top: 1rpx solid #f0f1f3;
+}
+
+.device-settings-card__model-label {
+ font-size: 26rpx;
+ color: $color-text-secondary;
+}
+
+.device-settings-card__model-value {
+ font-size: 26rpx;
+ color: $color-text;
+}
+
+.device-settings-page__section-title {
+ display: block;
+ font-size: 26rpx;
+ color: $color-text-secondary;
+ margin-bottom: 16rpx;
+ padding-left: 8rpx;
+}
+
+.device-settings-list {
+ background: #fff;
+ border-radius: 24rpx;
+ overflow: hidden;
+ box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.04);
+}
+
+.device-settings-list__item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 32rpx 24rpx 32rpx 32rpx;
+ min-height: 128rpx;
+ box-sizing: border-box;
+}
+
+.device-settings-list__content {
+ flex: 1;
+ min-width: 0;
+ padding-right: 20rpx;
+}
+
+.device-settings-list__title {
+ display: block;
+ font-size: 32rpx;
+ font-weight: 600;
+ color: $color-text;
+ line-height: 1.35;
+ margin-bottom: 10rpx;
+}
+
+.device-settings-list__title--danger {
+ color: #f56c6c;
+}
+
+.device-settings-list__desc {
+ display: block;
+ font-size: 26rpx;
+ color: #999;
+ line-height: 1.45;
+}
+
+.device-settings-list__arrow {
+ width: 16rpx;
+ height: 28rpx;
+ flex-shrink: 0;
+ opacity: 0.45;
+}
+
+.device-settings-list__divider {
+ height: 1rpx;
+ background: #eee;
+ margin-left: 32rpx;
+}
diff --git a/src/pages/device/settings/device-settings.types.ts b/src/pages/device/settings/device-settings.types.ts
new file mode 100644
index 0000000..99fc885
--- /dev/null
+++ b/src/pages/device/settings/device-settings.types.ts
@@ -0,0 +1,20 @@
+export interface DeviceSettingsRouteQuery {
+ deviceId?: string
+ deviceNo?: string
+ homeId?: string
+ name?: string
+ locationName?: string
+ locationId?: string
+ model?: string
+ bluetoothConnected?: string
+}
+
+/** POST /app/device/updateSettings */
+export interface UpdateDeviceSettingsBody {
+ deviceId: string | number
+ name?: string
+ homeId?: string | number
+ locationId?: string | number
+}
+
+export type { RoomOption } from '@/types/location'
diff --git a/src/pages/device/settings/index.vue b/src/pages/device/settings/index.vue
new file mode 100644
index 0000000..feaa869
--- /dev/null
+++ b/src/pages/device/settings/index.vue
@@ -0,0 +1,115 @@
+
+
+
+
+
+
+
+
+
+
+ {{ deviceName }}
+
+
+ {{ connectionText }}
+
+
+
+
+ 产品型号
+ {{ productModel }}
+
+
+
+ 基础设置
+
+
+
+
+ 设备名称
+ {{ deviceName }}
+
+
+
+
+
+
+
+
+ 所在房间
+ {{ locationName }}
+
+
+
+
+
+
+
+
+
+ 解除设备绑定
+
+ 解绑后需重新配对才能使用
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/device/settings/room/index.vue b/src/pages/device/settings/room/index.vue
new file mode 100644
index 0000000..30cdb40
--- /dev/null
+++ b/src/pages/device/settings/room/index.vue
@@ -0,0 +1,66 @@
+
+
+
+
+
+ +
+
+
+
+
+
+ 加载中...
+
+
+ 暂无房间位置,点击右上角添加
+
+
+
+ {{ room.name }}
+
+
+ ✓
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/device/settings/room/room.scss b/src/pages/device/settings/room/room.scss
new file mode 100644
index 0000000..0a930f7
--- /dev/null
+++ b/src/pages/device/settings/room/room.scss
@@ -0,0 +1,78 @@
+.device-room-page {
+ min-height: 100vh;
+ background: #fff;
+}
+
+.device-room-page__add-btn {
+ width: 56rpx;
+ height: 56rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.device-room-page__add-icon {
+ font-size: 48rpx;
+ line-height: 1;
+ color: $color-text;
+ font-weight: 300;
+}
+
+.device-room-page__loading,
+.device-room-page__empty {
+ padding: 80rpx 32rpx;
+ text-align: center;
+}
+
+.device-room-page__loading-text,
+.device-room-page__empty-text {
+ font-size: 28rpx;
+ color: $color-text-secondary;
+}
+
+.device-room-page__list {
+ padding: 8rpx 32rpx calc(40rpx + env(safe-area-inset-bottom));
+}
+
+.device-room-page__item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ min-height: 112rpx;
+ padding: 24rpx 0;
+ box-sizing: border-box;
+
+ &--disabled {
+ opacity: 0.6;
+ }
+}
+
+.device-room-page__name {
+ font-size: 32rpx;
+ color: $color-text;
+ line-height: 1.4;
+}
+
+.device-room-page__indicator {
+ width: 44rpx;
+ height: 44rpx;
+ border: 2rpx solid #dcdfe6;
+ border-radius: 50%;
+ box-sizing: border-box;
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ &--selected {
+ border-color: $color-primary;
+ background: $color-primary;
+ }
+}
+
+.device-room-page__check-mark {
+ font-size: 24rpx;
+ color: #fff;
+ line-height: 1;
+ font-weight: 600;
+}
diff --git a/src/pages/device/settings/room/room.types.ts b/src/pages/device/settings/room/room.types.ts
new file mode 100644
index 0000000..de0f6bd
--- /dev/null
+++ b/src/pages/device/settings/room/room.types.ts
@@ -0,0 +1,11 @@
+export interface DeviceRoomRouteQuery {
+ deviceId?: string
+ homeId?: string
+ locationId?: string
+ locationName?: string
+}
+
+export interface RoomSelectedPayload {
+ locationId: string | number
+ name: string
+}
diff --git a/src/pages/device/settings/room/use-device-room.ts b/src/pages/device/settings/room/use-device-room.ts
new file mode 100644
index 0000000..c76a559
--- /dev/null
+++ b/src/pages/device/settings/room/use-device-room.ts
@@ -0,0 +1,165 @@
+import { onLoad, onShow } from '@dcloudio/uni-app'
+import { ref } from 'vue'
+import { resolveValidHomeId } from '@/api/home'
+import { createLocation, fetchRoomList } from '@/api/location'
+import { getErrorMessage } from '@/utils/api-error'
+import { parseId, sameId } from '@/utils/device-id'
+import type { RoomOption } from '@/types/location'
+import type { DeviceRoomRouteQuery } from './room.types'
+
+let openerEventChannel: UniApp.EventChannel | undefined
+
+export function useDeviceRoom() {
+ const homeId = ref('')
+ const selectedLocationId = ref('')
+ const pendingLocationName = ref()
+ const roomOptions = ref([])
+ const listLoading = ref(false)
+ const saving = ref(false)
+ const addDialogVisible = ref(false)
+ const initialized = ref(false)
+
+ function applyQuery(query?: DeviceRoomRouteQuery) {
+ const locationId = parseId(query?.locationId)
+ if (locationId) {
+ selectedLocationId.value = locationId
+ }
+ if (query?.locationName) {
+ pendingLocationName.value = decodeURIComponent(String(query.locationName))
+ }
+ }
+
+ function syncSelectedLocation() {
+ const options = roomOptions.value
+ if (!options.length) return
+
+ if (selectedLocationId.value) {
+ const matchedById = options.find((item) =>
+ sameId(item.locationId, selectedLocationId.value),
+ )
+ if (matchedById) return
+ }
+
+ if (pendingLocationName.value) {
+ const matchedByName = options.find((item) => item.name === pendingLocationName.value)
+ if (matchedByName) {
+ selectedLocationId.value = String(matchedByName.locationId)
+ }
+ }
+ }
+
+ async function resolveHomeId(query?: DeviceRoomRouteQuery) {
+ homeId.value = await resolveValidHomeId(query?.homeId)
+ }
+
+ async function loadLocations() {
+ if (!homeId.value) return
+ listLoading.value = true
+ try {
+ roomOptions.value = await fetchRoomList(homeId.value)
+ syncSelectedLocation()
+ } catch (err) {
+ uni.showToast({ title: getErrorMessage(err, '加载房间列表失败'), icon: 'none' })
+ } finally {
+ listLoading.value = false
+ }
+ }
+
+ async function initPage(query?: DeviceRoomRouteQuery) {
+ applyQuery(query)
+ await resolveHomeId(query)
+ if (!homeId.value) {
+ uni.showToast({ title: '未找到家庭信息', icon: 'none' })
+ return
+ }
+ await loadLocations()
+ initialized.value = true
+ }
+
+ function onOpenAddDialog() {
+ addDialogVisible.value = true
+ }
+
+ async function onConfirmAddRoom(name: string) {
+ const trimmed = name.trim()
+ if (!trimmed) {
+ uni.showToast({ title: '请输入位置名称', icon: 'none' })
+ return
+ }
+ if (!homeId.value) {
+ uni.showToast({ title: '未找到家庭信息', icon: 'none' })
+ return
+ }
+ if (roomOptions.value.some((item) => item.name === trimmed)) {
+ uni.showToast({ title: '位置名称已存在', icon: 'none' })
+ return
+ }
+ if (saving.value) return
+
+ saving.value = true
+ try {
+ await createLocation({
+ homeId: homeId.value,
+ locationName: trimmed,
+ })
+ await loadLocations()
+ const created = roomOptions.value.find((item) => item.name === trimmed)
+ uni.showToast({ title: '添加成功', icon: 'success' })
+ if (created) {
+ void onSelectRoom(created)
+ }
+ } catch (err) {
+ uni.showToast({ title: getErrorMessage(err, '添加失败'), icon: 'none' })
+ } finally {
+ saving.value = false
+ }
+ }
+
+ async function onSelectRoom(room: RoomOption) {
+ if (saving.value || listLoading.value) return
+ if (sameId(selectedLocationId.value, room.locationId)) {
+ uni.navigateBack()
+ return
+ }
+
+ selectedLocationId.value = String(room.locationId)
+ pendingLocationName.value = room.name
+ openerEventChannel?.emit('roomSelected', {
+ locationId: room.locationId,
+ name: room.name,
+ })
+ uni.navigateBack()
+ }
+
+ onLoad((query?: DeviceRoomRouteQuery) => {
+ const pages = getCurrentPages()
+ const currentPage = pages[pages.length - 1] as {
+ getOpenerEventChannel?: () => UniApp.EventChannel
+ }
+ if (typeof currentPage.getOpenerEventChannel === 'function') {
+ openerEventChannel = currentPage.getOpenerEventChannel()
+ }
+ void initPage(query)
+ })
+
+ onShow(() => {
+ if (!initialized.value || !homeId.value) return
+ void loadLocations()
+ })
+
+ function isRoomSelected(locationId: string | number) {
+ return sameId(locationId, selectedLocationId.value)
+ }
+
+ return {
+ roomOptions,
+ selectedLocationId,
+ listLoading,
+ saving,
+ addDialogVisible,
+ isRoomSelected,
+ onOpenAddDialog,
+ onConfirmAddRoom,
+ onSelectRoom,
+ }
+}
diff --git a/src/pages/device/settings/use-device-settings.ts b/src/pages/device/settings/use-device-settings.ts
new file mode 100644
index 0000000..6b708ce
--- /dev/null
+++ b/src/pages/device/settings/use-device-settings.ts
@@ -0,0 +1,348 @@
+import { onLoad } from '@dcloudio/uni-app'
+
+import { computed, ref } from 'vue'
+
+import { getErrorMessage } from '@/utils/api-error'
+
+import { resolveValidHomeId } from '@/api/home'
+import { isValidDeviceId, parseDeviceId, parseId } from '@/utils/device-id'
+
+import { fetchDeviceList } from '../device.api'
+
+import { isBluetoothConnected } from '../detail/device-detail.utils'
+
+import { unbindDevice, updateDeviceSettings } from './device-settings.api'
+
+import type { DeviceSettingsRouteQuery } from './device-settings.types'
+
+
+
+export function useDeviceSettings() {
+
+ const deviceId = ref('')
+
+ const deviceNo = ref('')
+
+ const deviceName = ref('Z2 空气净化器')
+
+ const productModel = ref('CZ-Z2023')
+
+ const locationName = ref('客厅')
+
+ const locationId = ref('')
+
+ const homeId = ref('')
+
+ const bluetoothConnected = ref(false)
+
+
+
+ const nameDialogVisible = ref(false)
+
+ const unbindDialogVisible = ref(false)
+
+ const saving = ref(false)
+
+
+
+ const connectionText = computed(() =>
+
+ bluetoothConnected.value ? '蓝牙已连接' : '蓝牙未连接',
+
+ )
+
+
+
+ function applyQuery(query?: DeviceSettingsRouteQuery) {
+
+ deviceId.value = parseDeviceId(query?.deviceId)
+
+ if (query?.deviceNo) {
+
+ deviceNo.value = decodeURIComponent(String(query.deviceNo))
+
+ }
+
+ if (query?.name) deviceName.value = decodeURIComponent(String(query.name))
+
+ if (query?.locationName) {
+
+ locationName.value = decodeURIComponent(String(query.locationName))
+
+ }
+
+ const parsedLocationId = parseId(query?.locationId)
+ if (parsedLocationId) locationId.value = parsedLocationId
+
+ const parsedHomeId = parseId(query?.homeId)
+ if (parsedHomeId) homeId.value = parsedHomeId
+
+ if (query?.model) productModel.value = decodeURIComponent(String(query.model))
+
+ bluetoothConnected.value = isBluetoothConnected(query?.bluetoothConnected)
+
+ }
+
+
+
+ async function syncDeviceFromList() {
+
+ const list = await fetchDeviceList()
+
+ const device = list?.find((item) => {
+
+ if (deviceId.value && item.deviceId != null) {
+
+ return String(item.deviceId) === deviceId.value
+
+ }
+
+ if (deviceNo.value && item.deviceNo) {
+
+ return item.deviceNo === deviceNo.value
+
+ }
+
+ return false
+
+ })
+
+ if (!device) return false
+
+ if (device.deviceId != null) deviceId.value = String(device.deviceId)
+
+ if (device.deviceNo) deviceNo.value = device.deviceNo
+
+ if (device.name) deviceName.value = device.name
+
+ const parsedHomeId = parseId(device.homeId)
+ if (parsedHomeId) homeId.value = parsedHomeId
+
+ const parsedLocationId = parseId(device.locationId)
+ if (parsedLocationId) locationId.value = parsedLocationId
+
+ return true
+
+ }
+
+
+
+ function onOpenNameDialog() {
+
+ nameDialogVisible.value = true
+
+ }
+
+
+
+ function onPickRoom() {
+
+ const params = [
+
+ ...(deviceId.value ? [`deviceId=${deviceId.value}`] : []),
+
+ ...(deviceNo.value ? [`deviceNo=${encodeURIComponent(deviceNo.value)}`] : []),
+
+ ...(homeId.value != null ? [`homeId=${homeId.value}`] : []),
+
+ ...(locationId.value != null ? [`locationId=${locationId.value}`] : []),
+
+ `locationName=${encodeURIComponent(locationName.value)}`,
+
+ ]
+
+ uni.navigateTo({
+
+ url: `/pages/device/settings/room/index?${params.join('&')}`,
+
+ events: {
+
+ roomSelected: (data: { locationId: string | number; name: string }) => {
+ void onConfirmRoom(data)
+ },
+
+ },
+
+ })
+
+ }
+
+
+
+ function onOpenUnbindDialog() {
+
+ unbindDialogVisible.value = true
+
+ }
+
+
+
+ async function onConfirmRoom(data: { locationId: string | number; name: string }) {
+ if (!isValidDeviceId(deviceId.value)) {
+ locationId.value = String(data.locationId)
+ locationName.value = data.name
+ return
+ }
+ if (saving.value) return
+
+ saving.value = true
+ try {
+ const validHomeId = await resolveValidHomeId(homeId.value)
+ homeId.value = validHomeId
+ await updateDeviceSettings({
+ deviceId: deviceId.value,
+ homeId: validHomeId,
+ locationId: data.locationId,
+ })
+ locationId.value = String(data.locationId)
+ locationName.value = data.name
+ uni.showToast({ title: '修改成功', icon: 'success' })
+ } catch (err) {
+ uni.showToast({ title: getErrorMessage(err, '修改房间失败'), icon: 'none' })
+ } finally {
+ saving.value = false
+ }
+ }
+
+ async function onConfirmName(name: string) {
+
+ const trimmed = name.trim()
+
+ if (!trimmed) {
+
+ uni.showToast({ title: '请输入设备名称', icon: 'none' })
+
+ return
+
+ }
+
+ if (!isValidDeviceId(deviceId.value)) {
+
+ deviceName.value = trimmed
+
+ return
+
+ }
+
+ if (saving.value) return
+
+ saving.value = true
+
+ try {
+
+ await updateDeviceSettings({ deviceId: deviceId.value, name: trimmed })
+
+ deviceName.value = trimmed
+
+ uni.showToast({ title: '修改成功', icon: 'success' })
+
+ } catch (err) {
+
+ uni.showToast({ title: getErrorMessage(err, '修改失败'), icon: 'none' })
+
+ } finally {
+
+ saving.value = false
+
+ }
+
+ }
+
+
+
+ async function onConfirmUnbind() {
+
+ if (!isValidDeviceId(deviceId.value)) {
+
+ uni.showToast({ title: '设备信息不完整', icon: 'none' })
+
+ return
+
+ }
+
+ if (saving.value) return
+
+ saving.value = true
+
+ try {
+
+ await syncDeviceFromList()
+
+ if (!isValidDeviceId(deviceId.value)) {
+
+ uni.showToast({ title: '未找到设备信息', icon: 'none' })
+
+ return
+
+ }
+
+ await unbindDevice({ deviceId: deviceId.value })
+
+ unbindDialogVisible.value = false
+
+ uni.showToast({ title: '解绑成功', icon: 'success' })
+
+ setTimeout(() => {
+
+ uni.reLaunch({ url: '/pages/device/index' })
+
+ }, 400)
+
+ } catch (err) {
+
+ uni.showToast({ title: getErrorMessage(err, '解绑失败'), icon: 'none' })
+
+ } finally {
+
+ saving.value = false
+
+ }
+
+ }
+
+
+
+ onLoad((query?: DeviceSettingsRouteQuery) => {
+
+ applyQuery(query)
+
+ void (async () => {
+ await syncDeviceFromList()
+ homeId.value = await resolveValidHomeId(homeId.value)
+ })()
+
+ })
+
+
+
+ return {
+
+ deviceName,
+
+ productModel,
+
+ locationName,
+
+ connectionText,
+
+ bluetoothConnected,
+
+ nameDialogVisible,
+
+ unbindDialogVisible,
+
+ onOpenNameDialog,
+
+ onPickRoom,
+
+ onOpenUnbindDialog,
+
+ onConfirmName,
+
+ onConfirmUnbind,
+
+ }
+
+}
+
+
diff --git a/src/pages/device/use-device.ts b/src/pages/device/use-device.ts
index 00c222f..52dd6ad 100644
--- a/src/pages/device/use-device.ts
+++ b/src/pages/device/use-device.ts
@@ -1,8 +1,14 @@
import { onShow } from '@dcloudio/uni-app'
import { computed, ref } from 'vue'
+import { fetchHomeList, resolveValidHomeId } from '@/api/home'
+import { parseId, sameId } from '@/utils/device-id'
+import { createLocation } from '@/api/location'
import { getErrorMessage } from '@/utils/api-error'
+import { fetchRoomList } from '@/api/location'
import { fetchDeviceList } from './device.api'
import type { DeviceListItem, HotDeviceItem, RoomTab } from './device.types'
+import type { LocationMap } from './device.utils'
+import { refreshDevicePresence } from './device-presence'
import {
buildRoomTabs,
countOnlineDevices,
@@ -10,8 +16,6 @@ import {
mapDeviceItem,
} from './device.utils'
-const CUSTOM_ROOMS_KEY = 'device_custom_rooms'
-
const DEFAULT_HOT_DEVICES: HotDeviceItem[] = [
{
id: 'z2',
@@ -25,24 +29,9 @@ 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 customRooms = ref(loadCustomRooms())
+ const homeId = ref('')
const rooms = ref([{ id: 'all', name: '全部' }])
const activeRoomId = ref('all')
const devices = ref([])
@@ -60,8 +49,27 @@ export function useDevice() {
const onlineCount = computed(() => countOnlineDevices(filteredDevices.value))
- function refreshRoomTabs() {
- rooms.value = buildRoomTabs(devices.value, customRooms.value)
+ async function buildLocationMap(homeIds: string[]): Promise {
+ const map: LocationMap = new Map()
+ const seen = new Set()
+ for (const rawId of homeIds) {
+ const validId = await resolveValidHomeId(rawId)
+ if (!validId || seen.has(validId)) continue
+ seen.add(validId)
+ map.set(validId, await fetchRoomList(validId))
+ }
+ if (!seen.size) {
+ const validId = await resolveValidHomeId()
+ if (validId) {
+ map.set(validId, await fetchRoomList(validId))
+ }
+ }
+ return map
+ }
+
+ function applyRoomTabs(locationMap: LocationMap) {
+ const locations = homeId.value ? locationMap.get(homeId.value) ?? [] : []
+ rooms.value = buildRoomTabs(locations)
if (!rooms.value.some((room) => room.id === activeRoomId.value)) {
activeRoomId.value = 'all'
}
@@ -71,9 +79,27 @@ export function useDevice() {
if (loading.value) return
loading.value = true
try {
- const list = await fetchDeviceList()
- devices.value = (list ?? []).map(mapDeviceItem)
- refreshRoomTabs()
+ const list = await fetchDeviceList() ?? []
+ const homeIds = [
+ ...new Set(
+ list
+ .map((item) => parseId(item.homeId))
+ .filter((id) => id !== ''),
+ ),
+ ]
+ if (!homeIds.length) {
+ const defaultHomeId = await resolveValidHomeId()
+ if (defaultHomeId) homeIds.push(defaultHomeId)
+ }
+ const locationMap = await buildLocationMap(homeIds)
+ const deviceHomeId = parseId(list.find((item) => item.homeId != null)?.homeId)
+ homeId.value = await resolveValidHomeId(deviceHomeId || homeIds[0])
+ const homes = await fetchHomeList()
+ const currentHome = homes?.find((item) => sameId(item.id, homeId.value))
+ if (currentHome?.homeName) homeName.value = currentHome.homeName
+ applyRoomTabs(locationMap)
+ devices.value = list.map((item) => mapDeviceItem(item, locationMap))
+ devices.value = await refreshDevicePresence(devices.value)
} catch (err) {
uni.showToast({ title: getErrorMessage(err, '加载设备失败'), icon: 'none' })
} finally {
@@ -97,16 +123,26 @@ export function useDevice() {
roomDialogVisible.value = true
}
- function onConfirmAddRoom(name: string) {
- const room: RoomTab = {
- id: `custom-${Date.now()}`,
- name,
+ async function onConfirmAddRoom(name: string) {
+ const trimmed = name.trim()
+ if (!trimmed) return
+ try {
+ const id = await resolveValidHomeId(homeId.value)
+ if (!id) {
+ uni.showToast({ title: '未找到家庭信息', icon: 'none' })
+ return
+ }
+ homeId.value = id
+ await createLocation({ homeId: id, locationName: trimmed })
+ await loadDevices()
+ const created = rooms.value.find((room) => room.name === trimmed)
+ if (created) {
+ activeRoomId.value = created.id
+ }
+ showOperationSuccess()
+ } catch (err) {
+ uni.showToast({ title: getErrorMessage(err, '添加房间失败'), icon: 'none' })
}
- customRooms.value = [...customRooms.value, room]
- saveCustomRooms(customRooms.value)
- refreshRoomTabs()
- activeRoomId.value = room.id
- showOperationSuccess()
}
function onStartBind() {
@@ -122,11 +158,11 @@ export function useDevice() {
}
function onNotify() {
- uni.showToast({ title: '消息功能开发中', icon: 'none' })
+ uni.navigateTo({ url: '/pages/mine/notify/index' })
}
function onHomeManage() {
- uni.showToast({ title: '家庭管理功能开发中', icon: 'none' })
+ uni.navigateTo({ url: '/pages/device/home-manage/index' })
}
function onManageDevices() {
@@ -140,8 +176,11 @@ export function useDevice() {
}
const q = [
`deviceId=${device.deviceId}`,
+ ...(device.deviceNo ? [`deviceNo=${encodeURIComponent(device.deviceNo)}`] : []),
`name=${encodeURIComponent(device.name || '')}`,
`locationName=${encodeURIComponent(device.locationName || '')}`,
+ ...(device.locationId != null ? [`locationId=${device.locationId}`] : []),
+ ...(device.homeId != null ? [`homeId=${device.homeId}`] : []),
`status=${device.status ?? ''}`,
`bluetoothConnected=${device.bluetoothConnected ?? '0'}`,
`charging=${device.charging ?? '0'}`,
diff --git a/src/pages/login/index.vue b/src/pages/login/index.vue
index 26673fd..f5b91d9 100644
--- a/src/pages/login/index.vue
+++ b/src/pages/login/index.vue
@@ -64,6 +64,4 @@ const {
} = useLogin()
-
+
diff --git a/src/pages/login/use-login.ts b/src/pages/login/use-login.ts
index 8b111d4..3cad6fe 100644
--- a/src/pages/login/use-login.ts
+++ b/src/pages/login/use-login.ts
@@ -2,6 +2,7 @@ import { ref } from 'vue'
import { useSmsCode } from '@/hooks/use-sms-code'
import { isValidPassword, isValidPhone, isValidSmsCode } from '@/utils/validate'
import { getErrorMessage } from '@/utils/api-error'
+import { setupMessageInbox } from '@/hooks/use-message-inbox'
import { getStoredToken } from '@/utils/auth-token'
import { SMS_CODE_SCENE } from '@/types/api'
import { loginByPassword, loginBySms } from './login.api'
@@ -63,6 +64,7 @@ export function useLogin() {
uni.showToast({ title: '登录失败,未获取到令牌', icon: 'none' })
return
}
+ setupMessageInbox()
uni.showToast({ title: '登录成功', icon: 'success' })
uni.reLaunch({ url: '/pages/device/index' })
} catch (err) {
diff --git a/src/pages/mine/change-phone/bind/index.vue b/src/pages/mine/change-phone/bind/index.vue
new file mode 100644
index 0000000..c12f563
--- /dev/null
+++ b/src/pages/mine/change-phone/bind/index.vue
@@ -0,0 +1,35 @@
+
+
+
+
+ 新手机号码绑定
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/mine/change-phone/bind/use-change-phone-bind.ts b/src/pages/mine/change-phone/bind/use-change-phone-bind.ts
new file mode 100644
index 0000000..9c2b26e
--- /dev/null
+++ b/src/pages/mine/change-phone/bind/use-change-phone-bind.ts
@@ -0,0 +1,67 @@
+import { onLoad } from '@dcloudio/uni-app'
+import { ref } from 'vue'
+import { useSmsCode } from '@/hooks/use-sms-code'
+import { SMS_CODE_SCENE } from '@/types/api'
+import { getErrorMessage } from '@/utils/api-error'
+import { isValidPhone, isValidSmsCode } from '@/utils/validate'
+import { verifyNewPhone } from '../change-phone.api'
+
+export function useChangePhoneBind() {
+ const phone = ref('')
+ const code = ref('')
+ const changeToken = ref('')
+ const loading = ref(false)
+
+ const { counting, seconds, sendCode } = useSmsCode(SMS_CODE_SCENE.changePhoneNew)
+
+ onLoad((query) => {
+ const raw = (query?.changeToken as string) || ''
+ changeToken.value = raw ? decodeURIComponent(raw) : ''
+ if (!changeToken.value) {
+ uni.showToast({ title: '请先完成原手机号验证', icon: 'none' })
+ setTimeout(() => uni.navigateBack(), 1500)
+ }
+ })
+
+ function onSendCode() {
+ sendCode(phone.value)
+ }
+
+ async function submit() {
+ if (!changeToken.value) {
+ uni.showToast({ title: '换绑令牌无效,请重新验证', icon: 'none' })
+ return
+ }
+ if (!isValidPhone(phone.value)) {
+ uni.showToast({ title: '请输入正确手机号', icon: 'none' })
+ return
+ }
+ if (!isValidSmsCode(code.value)) {
+ uni.showToast({ title: '请输入验证码', icon: 'none' })
+ return
+ }
+ if (loading.value) return
+
+ loading.value = true
+ try {
+ await verifyNewPhone(changeToken.value, { phone: phone.value, code: code.value })
+ uni.showToast({ title: '手机号换绑成功', icon: 'success' })
+ setTimeout(() => {
+ uni.navigateBack({ delta: 2 })
+ }, 1200)
+ } catch (err) {
+ uni.showToast({ title: getErrorMessage(err, '换绑失败,请重试'), icon: 'none' })
+ } finally {
+ loading.value = false
+ }
+ }
+
+ return {
+ phone,
+ code,
+ counting,
+ seconds,
+ onSendCode,
+ submit,
+ }
+}
diff --git a/src/pages/mine/change-phone/change-phone.api.ts b/src/pages/mine/change-phone/change-phone.api.ts
new file mode 100644
index 0000000..1acae07
--- /dev/null
+++ b/src/pages/mine/change-phone/change-phone.api.ts
@@ -0,0 +1,34 @@
+import { request } from '@/api/request'
+import type {
+ ChangePhoneBindForm,
+ ChangePhoneVerifyForm,
+ VerifyNewPhoneBody,
+ VerifyOldPhoneBody,
+} from './change-phone.types'
+
+/** POST /app/auth/changePhone/verifyOld — 校验原手机号验证码,返回换绑令牌 */
+export function verifyOldPhone(data: ChangePhoneVerifyForm) {
+ const body: VerifyOldPhoneBody = {
+ oldPhonenumber: data.phone.trim(),
+ oldSmsCode: data.code.trim(),
+ }
+ return request({
+ url: '/app/auth/changePhone/verifyOld',
+ method: 'POST',
+ data: body,
+ })
+}
+
+/** POST /app/auth/changePhone/verifyNew — 校验新手机号验证码并完成换绑 */
+export function verifyNewPhone(changeToken: string, data: ChangePhoneBindForm) {
+ const body: VerifyNewPhoneBody = {
+ changeToken,
+ newPhonenumber: data.phone.trim(),
+ newSmsCode: data.code.trim(),
+ }
+ return request({
+ url: '/app/auth/changePhone/verifyNew',
+ method: 'POST',
+ data: body,
+ })
+}
diff --git a/src/pages/mine/change-phone/change-phone.scss b/src/pages/mine/change-phone/change-phone.scss
new file mode 100644
index 0000000..344fe97
--- /dev/null
+++ b/src/pages/mine/change-phone/change-phone.scss
@@ -0,0 +1,50 @@
+.change-phone-page {
+ min-height: 100vh;
+ background: #fff;
+}
+
+.change-phone-page__body {
+ padding: 48rpx $page-padding-x 48rpx;
+}
+
+.change-phone-page__title {
+ display: block;
+ text-align: center;
+ font-size: 44rpx;
+ font-weight: 700;
+ color: $color-text;
+ margin-bottom: 24rpx;
+}
+
+.change-phone-page__subtitle {
+ display: block;
+ text-align: center;
+ font-size: 26rpx;
+ color: $color-text-secondary;
+ line-height: 1.6;
+ margin-bottom: 64rpx;
+}
+
+.change-phone-page__form {
+ margin-bottom: 16rpx;
+}
+
+.change-phone-page__btn {
+ margin-top: 16rpx;
+}
+
+.change-phone-page__link-row {
+ display: flex;
+ justify-content: flex-end;
+ margin-top: 24rpx;
+}
+
+.change-phone-page__link {
+ font-size: 26rpx;
+ color: $color-primary;
+}
+
+.change-phone-page__form :deep(.base-input) {
+ border: none;
+ box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
+}
diff --git a/src/pages/mine/change-phone/change-phone.types.ts b/src/pages/mine/change-phone/change-phone.types.ts
new file mode 100644
index 0000000..3e29b71
--- /dev/null
+++ b/src/pages/mine/change-phone/change-phone.types.ts
@@ -0,0 +1,22 @@
+/** POST /app/auth/changePhone/verifyOld */
+export interface VerifyOldPhoneBody {
+ oldPhonenumber: string
+ oldSmsCode: string
+}
+
+/** POST /app/auth/changePhone/verifyNew */
+export interface VerifyNewPhoneBody {
+ changeToken: string
+ newPhonenumber: string
+ newSmsCode: string
+}
+
+export interface ChangePhoneVerifyForm {
+ phone: string
+ code: string
+}
+
+export interface ChangePhoneBindForm {
+ phone: string
+ code: string
+}
diff --git a/src/pages/mine/change-phone/index.vue b/src/pages/mine/change-phone/index.vue
new file mode 100644
index 0000000..16a3f41
--- /dev/null
+++ b/src/pages/mine/change-phone/index.vue
@@ -0,0 +1,37 @@
+
+
+
+
+ 原手机号验证
+ {{ subtitle }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 原手机号码已不可用?
+
+
+
+
+
+
+
+
diff --git a/src/pages/mine/change-phone/use-change-phone-verify.ts b/src/pages/mine/change-phone/use-change-phone-verify.ts
new file mode 100644
index 0000000..54a14ee
--- /dev/null
+++ b/src/pages/mine/change-phone/use-change-phone-verify.ts
@@ -0,0 +1,99 @@
+import { onShow } from '@dcloudio/uni-app'
+import { computed, ref } from 'vue'
+import { fetchProfile } from '@/api/profile'
+import { useSmsCode } from '@/hooks/use-sms-code'
+import { SMS_CODE_SCENE } from '@/types/api'
+import { getErrorMessage } from '@/utils/api-error'
+import { isValidPhone, isValidSmsCode } from '@/utils/validate'
+import { verifyOldPhone } from './change-phone.api'
+
+function maskPhone(phone: string): string {
+ const digits = phone.replace(/\D/g, '')
+ if (digits.length < 7) return phone
+ return digits.replace(/(\d{3})\d{4}(\d+)/, '$1****$2')
+}
+
+export function useChangePhoneVerify() {
+ const phone = ref('')
+ const code = ref('')
+ const boundPhone = ref('')
+ const loading = ref(false)
+
+ const { counting, seconds, sendCode } = useSmsCode(SMS_CODE_SCENE.changePhoneOld)
+
+ const maskedBoundPhone = computed(() => maskPhone(boundPhone.value))
+
+ const subtitle = computed(() => {
+ const masked = maskedBoundPhone.value
+ if (!masked) return '请输入该账号绑定的原手机号,完成手机验证'
+ return `请输入该账号绑定的原手机号${masked},完成手机验证`
+ })
+
+ async function loadBoundPhone() {
+ try {
+ const profile = await fetchProfile()
+ boundPhone.value = profile?.phonenumber || profile?.userName || ''
+ } catch (err) {
+ uni.showToast({ title: getErrorMessage(err, '加载账号信息失败'), icon: 'none' })
+ }
+ }
+
+ function onSendCode() {
+ sendCode(phone.value)
+ }
+
+ function onUnavailablePhone() {
+ uni.showModal({
+ title: '原手机号不可用',
+ content: '如原手机号已停用,请联系在线客服协助处理。',
+ confirmText: '联系客服',
+ success: (res) => {
+ if (!res.confirm) return
+ uni.navigateTo({ url: '/pages/mine/support/index' })
+ },
+ })
+ }
+
+ async function nextStep() {
+ if (!isValidPhone(phone.value)) {
+ uni.showToast({ title: '请输入正确手机号', icon: 'none' })
+ return
+ }
+ if (!isValidSmsCode(code.value)) {
+ uni.showToast({ title: '请输入验证码', icon: 'none' })
+ return
+ }
+ if (loading.value) return
+
+ loading.value = true
+ try {
+ const changeToken = await verifyOldPhone({ phone: phone.value, code: code.value })
+ if (!changeToken) {
+ uni.showToast({ title: '验证失败,未获取换绑令牌', icon: 'none' })
+ return
+ }
+ uni.navigateTo({
+ url: `/pages/mine/change-phone/bind/index?changeToken=${encodeURIComponent(changeToken)}`,
+ })
+ } catch (err) {
+ uni.showToast({ title: getErrorMessage(err, '验证失败,请重试'), icon: 'none' })
+ } finally {
+ loading.value = false
+ }
+ }
+
+ onShow(() => {
+ void loadBoundPhone()
+ })
+
+ return {
+ phone,
+ code,
+ counting,
+ seconds,
+ subtitle,
+ onSendCode,
+ onUnavailablePhone,
+ nextStep,
+ }
+}
diff --git a/src/pages/mine/edit/components/avatar-action-sheet/index.vue b/src/pages/mine/edit/components/avatar-action-sheet/index.vue
new file mode 100644
index 0000000..770372e
--- /dev/null
+++ b/src/pages/mine/edit/components/avatar-action-sheet/index.vue
@@ -0,0 +1,123 @@
+
+
+
+
+ 更换头像
+
+ 拍照
+
+
+
+ 从手机相册选择
+
+
+
+ 查看当前头像
+
+
+
+
+ 取消
+
+
+
+
+
+
+
+
diff --git a/src/pages/mine/edit/components/nickname-dialog/index.vue b/src/pages/mine/edit/components/nickname-dialog/index.vue
new file mode 100644
index 0000000..fdfb137
--- /dev/null
+++ b/src/pages/mine/edit/components/nickname-dialog/index.vue
@@ -0,0 +1,150 @@
+
+
+
+
+ 编辑昵称
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/mine/edit/edit.scss b/src/pages/mine/edit/edit.scss
new file mode 100644
index 0000000..7120651
--- /dev/null
+++ b/src/pages/mine/edit/edit.scss
@@ -0,0 +1,151 @@
+.edit-profile-page {
+ min-height: 100vh;
+ background: #fff;
+ display: flex;
+ flex-direction: column;
+}
+
+.edit-profile-page__navbar {
+ background: #fff;
+}
+
+.edit-profile-page__scroll {
+ flex: 1;
+ height: 0;
+}
+
+.edit-profile-page__body {
+ padding-bottom: calc(180rpx + env(safe-area-inset-bottom));
+}
+
+.edit-profile-page__avatar-section {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 48rpx 32rpx 40rpx;
+}
+
+.edit-profile-page__avatar-wrap {
+ width: 176rpx;
+ height: 176rpx;
+ border-radius: 50%;
+ overflow: hidden;
+ background: #f0f0f0;
+}
+
+.edit-profile-page__avatar {
+ width: 100%;
+ height: 100%;
+}
+
+.edit-profile-page__avatar-placeholder {
+ width: 100%;
+ height: 100%;
+ background: #ececec;
+}
+
+.edit-profile-page__change-avatar {
+ margin-top: 24rpx;
+ font-size: 28rpx;
+ color: $color-primary;
+ line-height: 1.4;
+}
+
+.edit-profile-page__avatar-tip {
+ margin-top: 16rpx;
+ font-size: 22rpx;
+ color: #b0b0b0;
+ line-height: 1.5;
+ text-align: center;
+}
+
+.edit-profile-page__list {
+ margin: 0 24rpx;
+ background: #fff;
+ border-radius: 24rpx;
+ overflow: hidden;
+}
+
+.edit-profile-page__item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ min-height: 112rpx;
+ padding: 28rpx 24rpx 28rpx 32rpx;
+ box-sizing: border-box;
+}
+
+.edit-profile-page__label {
+ font-size: 32rpx;
+ color: $color-text;
+ flex-shrink: 0;
+}
+
+.edit-profile-page__right {
+ display: flex;
+ align-items: center;
+ gap: 12rpx;
+ min-width: 0;
+ flex: 1;
+ justify-content: flex-end;
+}
+
+.edit-profile-page__value {
+ font-size: 30rpx;
+ color: $color-text;
+ text-align: right;
+ max-width: 420rpx;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.edit-profile-page__value--placeholder {
+ color: #c0c4cc;
+}
+
+.edit-profile-page__arrow {
+ width: 16rpx;
+ height: 28rpx;
+ flex-shrink: 0;
+ opacity: 0.45;
+}
+
+.edit-profile-page__change-phone {
+ font-size: 28rpx;
+ color: $color-primary;
+ flex-shrink: 0;
+ margin-left: 16rpx;
+}
+
+.edit-profile-page__divider {
+ height: 1rpx;
+ background: #eee;
+ margin-left: 32rpx;
+}
+
+.edit-profile-page__footer {
+ position: fixed;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ padding: 24rpx 48rpx calc(24rpx + env(safe-area-inset-bottom));
+ background: #fff;
+ box-sizing: border-box;
+}
+
+.edit-profile-page__save-btn {
+ height: 96rpx;
+ border-radius: 48rpx;
+ background: $color-primary;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 32rpx;
+ font-weight: 500;
+ color: #fff;
+}
+
+.edit-profile-page__save-btn--disabled {
+ opacity: 0.6;
+}
diff --git a/src/pages/mine/edit/edit.types.ts b/src/pages/mine/edit/edit.types.ts
new file mode 100644
index 0000000..3dfc2d0
--- /dev/null
+++ b/src/pages/mine/edit/edit.types.ts
@@ -0,0 +1,13 @@
+export interface ProfileFormState {
+ nickName: string
+ gender: string
+ birthday: string
+ province: string
+ city: string
+ district: string
+ phonenumber: string
+ avatarUrl: string
+ avatarOssId: string
+}
+
+export type ProfileFieldKey = 'nickName' | 'gender' | 'birthday' | 'location' | 'phone'
diff --git a/src/pages/mine/edit/index.vue b/src/pages/mine/edit/index.vue
new file mode 100644
index 0000000..0b9c23e
--- /dev/null
+++ b/src/pages/mine/edit/index.vue
@@ -0,0 +1,184 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 更换头像
+
+
+ 支持 JPG / PNG,建议使用 200x200 以上正方形图片
+
+
+
+
+
+ 昵称
+
+
+ {{ form.nickName || '请输入昵称' }}
+
+
+
+
+
+
+
+
+ 性别
+
+ {{ genderText }}
+
+
+
+
+
+
+
+
+ 生日
+
+
+ {{ form.birthday || '请选择生日' }}
+
+
+
+
+
+
+
+
+
+
+ 所在地
+
+
+ {{ locationText }}
+
+
+
+
+
+
+
+
+
+ 手机号
+
+
+ {{ maskedPhone || '未绑定' }}
+
+
+ 更换
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/mine/edit/use-edit-profile.ts b/src/pages/mine/edit/use-edit-profile.ts
new file mode 100644
index 0000000..0d9a527
--- /dev/null
+++ b/src/pages/mine/edit/use-edit-profile.ts
@@ -0,0 +1,232 @@
+import { onShow } from '@dcloudio/uni-app'
+import { computed, ref } from 'vue'
+import { fetchProfile, updateProfile, uploadProfileAvatar } from '@/api/profile'
+import { getErrorMessage } from '@/utils/api-error'
+import type { ProfileFormState } from './edit.types'
+
+const GENDER_LABELS: Record = {
+ '0': '未知',
+ '1': '男',
+ '2': '女',
+}
+
+const GENDER_OPTIONS = [
+ { value: '1', label: '男' },
+ { value: '2', label: '女' },
+ { value: '0', label: '未知' },
+]
+
+function maskPhone(phone: string): string {
+ const digits = phone.replace(/\D/g, '')
+ if (digits.length < 7) return phone
+ return digits.replace(/(\d{3})\d{4}(\d+)/, '$1****$2')
+}
+
+function formatLocation(province: string, city: string, district: string): string {
+ const primary = province || city
+ if (primary && district) return `${primary} · ${district}`
+ return primary || district || '未设置'
+}
+
+function createEmptyForm(): ProfileFormState {
+ return {
+ nickName: '',
+ gender: '0',
+ birthday: '',
+ province: '',
+ city: '',
+ district: '',
+ phonenumber: '',
+ avatarUrl: '',
+ avatarOssId: '',
+ }
+}
+
+export function useEditProfile() {
+ const loading = ref(false)
+ const saving = ref(false)
+ const uploadingAvatar = ref(false)
+ const form = ref(createEmptyForm())
+ const avatarSheetVisible = ref(false)
+ const nicknameDialogVisible = ref(false)
+
+ const maskedPhone = computed(() => maskPhone(form.value.phonenumber))
+ const genderText = computed(() => GENDER_LABELS[form.value.gender] || '未知')
+ const locationText = computed(() =>
+ formatLocation(form.value.province, form.value.city, form.value.district),
+ )
+ const regionValue = computed(() => {
+ const { province, city, district } = form.value
+ if (province && city && district) return [province, city, district]
+ return []
+ })
+
+ async function loadProfile() {
+ if (loading.value) return
+ loading.value = true
+ try {
+ const profile = await fetchProfile()
+ form.value = {
+ nickName: profile.nickName || profile.userName || '',
+ gender: profile.gender || '0',
+ birthday: profile.birthday || '',
+ province: profile.province || '',
+ city: profile.city || '',
+ district: profile.district || '',
+ phonenumber: profile.phonenumber || profile.userName || '',
+ avatarUrl: profile.avatarUrl || '',
+ avatarOssId: profile.avatarOssId ? String(profile.avatarOssId) : '',
+ }
+ } catch (err) {
+ uni.showToast({ title: getErrorMessage(err, '加载资料失败'), icon: 'none' })
+ } finally {
+ loading.value = false
+ }
+ }
+
+ function onOpenAvatarSheet() {
+ avatarSheetVisible.value = true
+ }
+
+ function onPreviewAvatar() {
+ if (!form.value.avatarUrl) {
+ uni.showToast({ title: '暂无头像', icon: 'none' })
+ return
+ }
+ uni.previewImage({ urls: [form.value.avatarUrl] })
+ }
+
+ async function onChooseAvatar(sourceType: Array<'album' | 'camera'>) {
+ try {
+ const res = await new Promise((resolve, reject) => {
+ uni.chooseImage({
+ count: 1,
+ sizeType: ['compressed'],
+ sourceType,
+ success: resolve,
+ fail: reject,
+ })
+ })
+ const filePath = res.tempFilePaths[0]
+ if (!filePath) return
+
+ uploadingAvatar.value = true
+ uni.showLoading({ title: '上传中...', mask: true })
+ const uploaded = await uploadProfileAvatar(filePath)
+ form.value.avatarUrl = uploaded.url || filePath
+ form.value.avatarOssId = uploaded.ossId ? String(uploaded.ossId) : ''
+ uni.showToast({ title: '头像已更新', icon: 'success' })
+ } catch (err) {
+ uni.showToast({ title: getErrorMessage(err, '上传头像失败'), icon: 'none' })
+ } finally {
+ uploadingAvatar.value = false
+ uni.hideLoading()
+ }
+ }
+
+ function onTakePhoto() {
+ void onChooseAvatar(['camera'])
+ }
+
+ function onChooseAlbum() {
+ void onChooseAvatar(['album'])
+ }
+
+ function onOpenNicknameDialog() {
+ nicknameDialogVisible.value = true
+ }
+
+ function onConfirmNickname(name: string) {
+ if (!name) {
+ uni.showToast({ title: '请输入昵称', icon: 'none' })
+ return
+ }
+ form.value.nickName = name
+ }
+
+ function onPickGender() {
+ uni.showActionSheet({
+ itemList: GENDER_OPTIONS.map((item) => item.label),
+ success: (res) => {
+ const option = GENDER_OPTIONS[res.tapIndex]
+ if (option) form.value.gender = option.value
+ },
+ })
+ }
+
+ function onBirthdayChange(e: { detail: { value: string } }) {
+ form.value.birthday = e.detail.value
+ }
+
+ function onRegionChange(e: { detail: { value: string[] } }) {
+ const [province, city, district] = e.detail.value
+ form.value.province = province || ''
+ form.value.city = city || ''
+ form.value.district = district || ''
+ }
+
+ function onChangePhone() {
+ uni.navigateTo({ url: '/pages/mine/change-phone/index' })
+ }
+
+ async function onSave() {
+ const nickName = form.value.nickName.trim()
+ if (!nickName) {
+ uni.showToast({ title: '请输入昵称', icon: 'none' })
+ return
+ }
+ if (saving.value) return
+
+ saving.value = true
+ uni.showLoading({ title: '保存中...', mask: true })
+ try {
+ await updateProfile({
+ nickName,
+ gender: form.value.gender,
+ birthday: form.value.birthday || undefined,
+ province: form.value.province || undefined,
+ city: form.value.city || undefined,
+ district: form.value.district || undefined,
+ avatarUrl: form.value.avatarUrl || undefined,
+ avatarOssId: form.value.avatarOssId || undefined,
+ })
+ uni.showToast({ title: '保存成功', icon: 'success' })
+ setTimeout(() => {
+ uni.navigateBack()
+ }, 400)
+ } catch (err) {
+ uni.showToast({ title: getErrorMessage(err, '保存失败'), icon: 'none' })
+ } finally {
+ saving.value = false
+ uni.hideLoading()
+ }
+ }
+
+ onShow(() => {
+ void loadProfile()
+ })
+
+ return {
+ loading,
+ saving,
+ uploadingAvatar,
+ form,
+ maskedPhone,
+ genderText,
+ locationText,
+ regionValue,
+ avatarSheetVisible,
+ nicknameDialogVisible,
+ onOpenAvatarSheet,
+ onPreviewAvatar,
+ onTakePhoto,
+ onChooseAlbum,
+ onOpenNicknameDialog,
+ onConfirmNickname,
+ onPickGender,
+ onBirthdayChange,
+ onRegionChange,
+ onChangePhone,
+ onSave,
+ }
+}
diff --git a/src/pages/mine/index.vue b/src/pages/mine/index.vue
index 4df17d3..825116b 100644
--- a/src/pages/mine/index.vue
+++ b/src/pages/mine/index.vue
@@ -1,44 +1,107 @@
-
- 我的
- 个人中心页面开发中
+
+
+
+
+
+
+
+
+
+ {{ nickName }}
+
+
+
+
+ {{ phone || '未绑定手机号' }}
+
+
+
+
+
+ {{ item.value }}
+ {{ item.label }}
+
+
+
+
+
+
+
+
+ {{ action.label }}
+
+
+
+
+
diff --git a/src/pages/mine/mine.api.ts b/src/pages/mine/mine.api.ts
new file mode 100644
index 0000000..1662696
--- /dev/null
+++ b/src/pages/mine/mine.api.ts
@@ -0,0 +1,13 @@
+import { fetchProfile } from '@/api/profile'
+import { fetchDeviceList } from '../device/device.api'
+
+export async function fetchMineOverview() {
+ const [profile, devices] = await Promise.all([
+ fetchProfile(),
+ fetchDeviceList(),
+ ])
+ return {
+ profile,
+ deviceCount: devices?.length ?? 0,
+ }
+}
diff --git a/src/pages/mine/mine.scss b/src/pages/mine/mine.scss
new file mode 100644
index 0000000..1722e45
--- /dev/null
+++ b/src/pages/mine/mine.scss
@@ -0,0 +1,192 @@
+.mine-page {
+ min-height: 100vh;
+ background: #f5f5f5;
+ padding-bottom: calc(120rpx + env(safe-area-inset-bottom));
+ box-sizing: border-box;
+}
+
+.mine-page__content {
+ padding: 0 24rpx 24rpx;
+}
+
+.mine-page__profile {
+ display: flex;
+ align-items: center;
+ gap: 24rpx;
+ padding: 16rpx 8rpx 32rpx;
+}
+
+.mine-page__avatar-wrap {
+ width: 128rpx;
+ height: 128rpx;
+ border: 2rpx dashed #d9d9d9;
+ border-radius: 20rpx;
+ padding: 8rpx;
+ box-sizing: border-box;
+ flex-shrink: 0;
+ background: #fff;
+}
+
+.mine-page__avatar {
+ width: 100%;
+ height: 100%;
+ border-radius: 12rpx;
+ background: #f0f0f0;
+}
+
+.mine-page__avatar-placeholder {
+ width: 100%;
+ height: 100%;
+ border-radius: 12rpx;
+ background: #ececec;
+}
+
+.mine-page__profile-meta {
+ flex: 1;
+ min-width: 0;
+ padding-top: 8rpx;
+}
+
+.mine-page__name-row {
+ display: flex;
+ align-items: center;
+ gap: 10rpx;
+ margin-bottom: 12rpx;
+}
+
+.mine-page__nickname {
+ font-size: 34rpx;
+ font-weight: 600;
+ color: $color-text;
+ line-height: 1.3;
+}
+
+.mine-page__edit {
+ width: 32rpx;
+ height: 32rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.mine-page__edit-icon {
+ width: 28rpx;
+ height: 28rpx;
+ opacity: 0.55;
+}
+
+.mine-page__phone {
+ font-size: 28rpx;
+ color: #999;
+ line-height: 1.4;
+}
+
+.mine-page__stats {
+ display: flex;
+ background: #fff;
+ border-radius: 20rpx;
+ padding: 36rpx 0 32rpx;
+ margin-bottom: 20rpx;
+}
+
+.mine-page__stat-item {
+ flex: 1;
+ text-align: center;
+}
+
+.mine-page__stat-value {
+ display: block;
+ font-size: 44rpx;
+ font-weight: 600;
+ color: $color-text;
+ line-height: 1.1;
+ margin-bottom: 12rpx;
+}
+
+.mine-page__stat-label {
+ display: block;
+ font-size: 24rpx;
+ color: #999;
+ line-height: 1.3;
+}
+
+.mine-page__quick {
+ display: flex;
+ background: #fff;
+ border-radius: 20rpx;
+ padding: 32rpx 8rpx 28rpx;
+ margin-bottom: 20rpx;
+}
+
+.mine-page__quick-item {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 16rpx;
+}
+
+.mine-page__quick-icon {
+ width: 48rpx;
+ height: 48rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.mine-page__quick-icon-img {
+ width: 48rpx;
+ height: 48rpx;
+}
+
+.mine-page__quick-label {
+ font-size: 24rpx;
+ color: $color-text;
+ line-height: 1.3;
+}
+
+.mine-page__menu {
+ background: #fff;
+ border-radius: 20rpx;
+ overflow: hidden;
+}
+
+.mine-page__menu-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ min-height: 108rpx;
+ padding: 0 28rpx 0 32rpx;
+ box-sizing: border-box;
+}
+
+.mine-page__menu-title {
+ font-size: 30rpx;
+ color: $color-text;
+ line-height: 1.4;
+}
+
+.mine-page__menu-right {
+ display: flex;
+ align-items: center;
+ gap: 12rpx;
+ flex-shrink: 0;
+}
+
+.mine-page__menu-value {
+ font-size: 26rpx;
+ color: #999;
+ line-height: 1.4;
+}
+
+.mine-page__menu-arrow {
+ width: 16rpx;
+ height: 28rpx;
+ opacity: 0.45;
+}
+
+.mine-page__menu-divider {
+ height: 1rpx;
+ background: #f0f0f0;
+ margin-left: 32rpx;
+}
diff --git a/src/pages/mine/mine.types.ts b/src/pages/mine/mine.types.ts
new file mode 100644
index 0000000..01083c5
--- /dev/null
+++ b/src/pages/mine/mine.types.ts
@@ -0,0 +1,11 @@
+export interface MineQuickAction {
+ id: string
+ label: string
+ icon: string
+}
+
+export interface MineMenuItem {
+ id: string
+ title: string
+ value?: string
+}
diff --git a/src/pages/mine/notify/index.vue b/src/pages/mine/notify/index.vue
new file mode 100644
index 0000000..d93ca32
--- /dev/null
+++ b/src/pages/mine/notify/index.vue
@@ -0,0 +1,79 @@
+
+
+
+
+
+ 全部已读
+
+
+
+
+
+
+ {{ sectionTitle }}
+
+
+
+
+
+
+ {{ item.title || '系统通知' }}
+
+ {{ formatNotifyTime(item) }}
+
+ {{ item.content || '暂无详情' }}
+
+
+
+
+ 暂无通知消息
+
+
+
+ 加载中...
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/mine/notify/notify.scss b/src/pages/mine/notify/notify.scss
new file mode 100644
index 0000000..75a3f9c
--- /dev/null
+++ b/src/pages/mine/notify/notify.scss
@@ -0,0 +1,100 @@
+.notify-page {
+ min-height: 100vh;
+ background: #fff;
+ display: flex;
+ flex-direction: column;
+}
+
+.notify-page__scroll {
+ flex: 1;
+ height: 0;
+}
+
+.notify-page__body {
+ min-height: 100%;
+}
+
+.notify-page__section {
+ display: block;
+ padding: 16rpx 32rpx 8rpx;
+ font-size: 26rpx;
+ color: #98a2b3;
+ line-height: 1.4;
+}
+
+.notify-page__list {
+ background: #fff;
+}
+
+.notify-page__item {
+ padding: 28rpx 32rpx;
+ border-bottom: 1rpx solid #f2f4f7;
+}
+
+.notify-page__item-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 24rpx;
+}
+
+.notify-page__title-wrap {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ align-items: center;
+ gap: 12rpx;
+}
+
+.notify-page__dot {
+ width: 12rpx;
+ height: 12rpx;
+ border-radius: 50%;
+ background: #1ec671;
+ flex-shrink: 0;
+}
+
+.notify-page__title {
+ font-size: 30rpx;
+ font-weight: 600;
+ color: #1a1a1a;
+ line-height: 1.4;
+}
+
+.notify-page__time {
+ flex-shrink: 0;
+ font-size: 24rpx;
+ color: #98a2b3;
+ line-height: 1.4;
+}
+
+.notify-page__content {
+ display: block;
+ margin-top: 12rpx;
+ font-size: 26rpx;
+ color: #98a2b3;
+ line-height: 1.55;
+}
+
+.notify-page__empty,
+.notify-page__loading,
+.notify-page__footer-tip {
+ padding: 80rpx 32rpx;
+ text-align: center;
+ font-size: 26rpx;
+ color: #98a2b3;
+}
+
+.notify-page__mark-all {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ padding: 8rpx 0;
+}
+
+.notify-page__mark-all-text {
+ font-size: 26rpx;
+ color: #1ec671;
+ white-space: nowrap;
+ line-height: 1;
+}
diff --git a/src/pages/mine/notify/notify.utils.ts b/src/pages/mine/notify/notify.utils.ts
new file mode 100644
index 0000000..72d112a
--- /dev/null
+++ b/src/pages/mine/notify/notify.utils.ts
@@ -0,0 +1,22 @@
+import type { AppMessageVo } from '@/types/message'
+
+export function normalizeMessageId(message: AppMessageVo): string {
+ const raw = message.messageId
+ if (raw === undefined || raw === null || raw === '') return ''
+ return String(raw)
+}
+
+export function isUnreadMessage(message: AppMessageVo): boolean {
+ const flag = message.readFlag
+ return flag === 0 || flag === '0' || flag === false
+}
+
+export function formatNotifyTime(message: AppMessageVo): string {
+ const raw = message.timestamp || message.createTime
+ if (!raw) return ''
+ const text = String(raw).trim()
+ if (/^\d{2}:\d{2}$/.test(text)) return text
+ const date = new Date(text)
+ if (Number.isNaN(date.getTime())) return text
+ return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
+}
diff --git a/src/pages/mine/notify/use-notify.ts b/src/pages/mine/notify/use-notify.ts
new file mode 100644
index 0000000..22828c2
--- /dev/null
+++ b/src/pages/mine/notify/use-notify.ts
@@ -0,0 +1,132 @@
+import { onShow, onUnload } from '@dcloudio/uni-app'
+import { computed, ref } from 'vue'
+import { fetchMessagePage, markAllMessagesRead, markMessageRead } from '@/api/message'
+import { refreshUnreadMessageCount } from '@/hooks/use-message-inbox'
+import { getErrorMessage } from '@/utils/api-error'
+import { onMessagePush } from '@/utils/message-socket'
+import type { AppMessageVo } from '@/types/message'
+import { formatNotifyTime, isUnreadMessage, normalizeMessageId } from './notify.utils'
+
+const PAGE_SIZE = 20
+
+export function useNotify() {
+ const messages = ref([])
+ const loading = ref(false)
+ const loadingMore = ref(false)
+ const pageNum = ref(1)
+ const total = ref(0)
+ const sectionTitle = ref('全部家庭')
+
+ const hasMore = computed(() => messages.value.length < total.value)
+
+ let unsubscribePush: (() => void) | null = null
+
+ async function loadMessages(reset = false) {
+ if (loading.value || loadingMore.value) return
+ if (reset) {
+ pageNum.value = 1
+ loading.value = true
+ } else {
+ if (!hasMore.value && messages.value.length > 0) return
+ loadingMore.value = true
+ }
+
+ try {
+ const { rows, total: nextTotal } = await fetchMessagePage({
+ pageNum: pageNum.value,
+ pageSize: PAGE_SIZE,
+ })
+ total.value = nextTotal
+ if (reset) {
+ messages.value = rows
+ } else {
+ const map = new Map()
+ for (const item of [...messages.value, ...rows]) {
+ const id = normalizeMessageId(item)
+ if (id) map.set(id, item)
+ }
+ messages.value = Array.from(map.values())
+ }
+ const homeName = rows.find((item) => item.homeName)?.homeName
+ if (homeName) sectionTitle.value = homeName
+ if (rows.length > 0) {
+ pageNum.value += 1
+ }
+ } catch (err) {
+ uni.showToast({ title: getErrorMessage(err, '加载通知失败'), icon: 'none' })
+ } finally {
+ loading.value = false
+ loadingMore.value = false
+ }
+ }
+
+ async function onRefresh() {
+ await loadMessages(true)
+ await refreshUnreadMessageCount()
+ }
+
+ async function onLoadMore() {
+ await loadMessages(false)
+ }
+
+ async function onItemTap(message: AppMessageVo) {
+ const messageId = normalizeMessageId(message)
+ if (!messageId) return
+
+ if (isUnreadMessage(message)) {
+ try {
+ await markMessageRead(messageId)
+ messages.value = messages.value.map((item) =>
+ normalizeMessageId(item) === messageId ? { ...item, readFlag: '1' } : item,
+ )
+ await refreshUnreadMessageCount()
+ } catch (err) {
+ uni.showToast({ title: getErrorMessage(err, '标记已读失败'), icon: 'none' })
+ }
+ }
+
+ const deviceId = message.deviceId
+ if (deviceId !== undefined && deviceId !== null && deviceId !== '') {
+ uni.navigateTo({
+ url: `/pages/device/detail/index?deviceId=${encodeURIComponent(String(deviceId))}`,
+ })
+ }
+ }
+
+ async function onMarkAllRead() {
+ try {
+ await markAllMessagesRead()
+ messages.value = messages.value.map((item) => ({ ...item, readFlag: '1' }))
+ await refreshUnreadMessageCount()
+ uni.showToast({ title: '已全部标为已读', icon: 'none' })
+ } catch (err) {
+ uni.showToast({ title: getErrorMessage(err, '操作失败'), icon: 'none' })
+ }
+ }
+
+ onShow(() => {
+ void onRefresh()
+ unsubscribePush = onMessagePush(() => {
+ void onRefresh()
+ })
+ })
+
+ onUnload(() => {
+ unsubscribePush?.()
+ unsubscribePush = null
+ })
+
+ return {
+ messages,
+ loading,
+ loadingMore,
+ hasMore,
+ sectionTitle,
+ formatNotifyTime,
+ isUnreadMessage,
+ onRefresh,
+ onLoadMore,
+ onItemTap,
+ onMarkAllRead,
+ }
+}
diff --git a/src/pages/mine/privacy/index.vue b/src/pages/mine/privacy/index.vue
new file mode 100644
index 0000000..a174a90
--- /dev/null
+++ b/src/pages/mine/privacy/index.vue
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+ {{ section.title }}
+
+
+
+
+
+
+ {{ item.title }}
+
+
+ {{ item.desc }}
+
+
+
+
+
+
+
+
+
+ 💡
+
+ 安全小贴士
+
+ 为保障您的账号安全,建议定期修改密码,不要将账号密码透露给他人。如发现异常登录,请及时前往登录设备管理下线陌生设备。
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/mine/privacy/privacy.scss b/src/pages/mine/privacy/privacy.scss
new file mode 100644
index 0000000..2423b98
--- /dev/null
+++ b/src/pages/mine/privacy/privacy.scss
@@ -0,0 +1,137 @@
+.privacy-page {
+ min-height: 100vh;
+ background: #f5f6f8;
+ display: flex;
+ flex-direction: column;
+}
+
+.privacy-page__scroll {
+ flex: 1;
+ height: 0;
+}
+
+.privacy-page__body {
+ padding: 8rpx 24rpx calc(32rpx + env(safe-area-inset-bottom));
+}
+
+.privacy-page__section {
+ margin-bottom: 24rpx;
+}
+
+.privacy-page__section-title {
+ display: block;
+ padding: 8rpx 8rpx 16rpx;
+ font-size: 26rpx;
+ color: #98a2b3;
+ line-height: 1.4;
+}
+
+.privacy-page__list {
+ background: #fff;
+ border-radius: 20rpx;
+ overflow: hidden;
+}
+
+.privacy-page__item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ min-height: 120rpx;
+ padding: 24rpx 28rpx;
+ box-sizing: border-box;
+}
+
+.privacy-page__item-main {
+ flex: 1;
+ min-width: 0;
+ padding-right: 16rpx;
+}
+
+.privacy-page__item-title {
+ display: block;
+ font-size: 30rpx;
+ font-weight: 600;
+ color: #1a1a1a;
+ line-height: 1.35;
+
+ &--danger {
+ color: #f04438;
+ }
+}
+
+.privacy-page__item-desc {
+ display: block;
+ margin-top: 8rpx;
+ font-size: 24rpx;
+ color: #98a2b3;
+ line-height: 1.4;
+}
+
+.privacy-page__arrow {
+ width: 16rpx;
+ height: 28rpx;
+ opacity: 0.45;
+ flex-shrink: 0;
+}
+
+.privacy-page__divider {
+ height: 1rpx;
+ background: #f0f0f0;
+ margin-left: 28rpx;
+}
+
+.privacy-page__tip {
+ display: flex;
+ gap: 16rpx;
+ padding: 24rpx 28rpx;
+ margin-bottom: 32rpx;
+ background: #fff9e8;
+ border-radius: 20rpx;
+}
+
+.privacy-page__tip-icon {
+ font-size: 32rpx;
+ line-height: 1.2;
+ flex-shrink: 0;
+}
+
+.privacy-page__tip-content {
+ flex: 1;
+ min-width: 0;
+}
+
+.privacy-page__tip-title {
+ display: block;
+ margin-bottom: 8rpx;
+ font-size: 28rpx;
+ font-weight: 600;
+ color: #b7791f;
+ line-height: 1.35;
+}
+
+.privacy-page__tip-text {
+ display: block;
+ font-size: 24rpx;
+ color: #8a6a2d;
+ line-height: 1.6;
+}
+
+.privacy-page__footer {
+ padding: 0 8rpx;
+}
+
+.privacy-page__logout-btn {
+ height: 96rpx;
+ border-radius: 20rpx;
+ border: 1rpx solid #d0d5dd;
+ background: #fff;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.privacy-page__logout-text {
+ font-size: 30rpx;
+ color: #1a1a1a;
+ line-height: 1.3;
+}
diff --git a/src/pages/mine/privacy/privacy.types.ts b/src/pages/mine/privacy/privacy.types.ts
new file mode 100644
index 0000000..c4cdbf2
--- /dev/null
+++ b/src/pages/mine/privacy/privacy.types.ts
@@ -0,0 +1,12 @@
+export interface PrivacyMenuItem {
+ id: string
+ title: string
+ desc?: string
+ danger?: boolean
+}
+
+export interface PrivacySection {
+ id: string
+ title: string
+ items: PrivacyMenuItem[]
+}
diff --git a/src/pages/mine/privacy/use-privacy.ts b/src/pages/mine/privacy/use-privacy.ts
new file mode 100644
index 0000000..c67197f
--- /dev/null
+++ b/src/pages/mine/privacy/use-privacy.ts
@@ -0,0 +1,114 @@
+import { onShow } from '@dcloudio/uni-app'
+import { computed, ref } from 'vue'
+import { logout } from '@/api/auth'
+import { fetchProfile } from '@/api/profile'
+import { getErrorMessage } from '@/utils/api-error'
+import { teardownMessageInbox } from '@/hooks/use-message-inbox'
+import { clearStoredToken } from '@/utils/auth-token'
+import type { PrivacySection } from './privacy.types'
+
+function maskPhone(phone?: string): string {
+ const value = phone?.trim() || ''
+ if (value.length < 7) return value || '未绑定'
+ return `${value.slice(0, 3)}****${value.slice(-4)}`
+}
+
+export function usePrivacy() {
+ const phone = ref('')
+
+ const sections = computed(() => [
+ {
+ id: 'login',
+ title: '登录安全',
+ items: [
+ {
+ id: 'password',
+ title: '登录密码',
+ desc: '上次修改:30天前',
+ },
+ {
+ id: 'phone',
+ title: '绑定手机号',
+ desc: maskPhone(phone.value),
+ },
+ ],
+ },
+ {
+ id: 'device',
+ title: '设备安全',
+ items: [
+ {
+ id: 'delete-account',
+ title: '注销账号',
+ desc: '永久删除账号和所有数据',
+ danger: true,
+ },
+ ],
+ },
+ ])
+
+ async function loadProfile() {
+ try {
+ const profile = await fetchProfile()
+ phone.value = profile?.phonenumber || profile?.userName || ''
+ } catch (err) {
+ uni.showToast({ title: getErrorMessage(err, '加载账号信息失败'), icon: 'none' })
+ }
+ }
+
+ function onItemTap(itemId: string) {
+ if (itemId === 'password') {
+ uni.navigateTo({ url: '/pages/mine/set-password/verify/index' })
+ return
+ }
+ if (itemId === 'phone') {
+ uni.navigateTo({ url: '/pages/mine/change-phone/index' })
+ return
+ }
+ if (itemId === 'delete-account') {
+ uni.showModal({
+ title: '注销账号',
+ content: '注销后账号及所有数据将被永久删除,且无法恢复。确定继续吗?',
+ confirmColor: '#f04438',
+ success: (res) => {
+ if (!res.confirm) return
+ uni.showToast({ title: '注销功能待对接', icon: 'none' })
+ },
+ })
+ }
+ }
+
+ function onLogout() {
+ uni.showModal({
+ title: '退出登录',
+ content: '确定要退出当前账号吗?',
+ success: (res) => {
+ if (!res.confirm) return
+ void (async () => {
+ try {
+ await logout()
+ } catch (err) {
+ uni.showToast({
+ title: getErrorMessage(err, '退出登录失败'),
+ icon: 'none',
+ })
+ } finally {
+ teardownMessageInbox()
+ clearStoredToken()
+ uni.reLaunch({ url: '/pages/login/index' })
+ }
+ })()
+ },
+ })
+ }
+
+ onShow(() => {
+ void loadProfile()
+ })
+
+ return {
+ sections,
+ onItemTap,
+ onLogout,
+ }
+}
diff --git a/src/pages/mine/set-password/index.vue b/src/pages/mine/set-password/index.vue
new file mode 100644
index 0000000..8547f8e
--- /dev/null
+++ b/src/pages/mine/set-password/index.vue
@@ -0,0 +1,37 @@
+
+
+
+
+ 设置新密码
+
+
+
+
+
+
+ 8-20位字符,须包含字母、数字、符号中的至少2种。
+
+
+
+
+
+
+ 忘记密码
+
+
+
+
+
+
+
+
diff --git a/src/pages/mine/set-password/phone/index.vue b/src/pages/mine/set-password/phone/index.vue
new file mode 100644
index 0000000..722c289
--- /dev/null
+++ b/src/pages/mine/set-password/phone/index.vue
@@ -0,0 +1,46 @@
+
+
+
+
+ 验证手机号
+ 短信验证码已发送,请填写6位数字验证码
+
+
+
+ {{ maskedPhone || '未绑定手机号' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/mine/set-password/phone/use-set-password-phone.ts b/src/pages/mine/set-password/phone/use-set-password-phone.ts
new file mode 100644
index 0000000..3562742
--- /dev/null
+++ b/src/pages/mine/set-password/phone/use-set-password-phone.ts
@@ -0,0 +1,85 @@
+import { onShow } from '@dcloudio/uni-app'
+import { computed, ref } from 'vue'
+import { fetchProfile } from '@/api/profile'
+import { useSmsCode } from '@/hooks/use-sms-code'
+import { SMS_CODE_SCENE } from '@/types/api'
+import { getErrorMessage } from '@/utils/api-error'
+import { isValidSmsCode } from '@/utils/validate'
+import { verifySetPasswordCode } from '../set-password.api'
+
+function maskPhone(phone: string): string {
+ const digits = phone.replace(/\D/g, '')
+ if (digits.length < 7) return phone
+ return digits.replace(/(\d{3})\d{4}(\d+)/, '$1****$2')
+}
+
+export function useSetPasswordPhone() {
+ const boundPhone = ref('')
+ const code = ref('')
+ const loading = ref(false)
+
+ const { counting, seconds, sendCode } = useSmsCode(SMS_CODE_SCENE.reset)
+
+ const maskedPhone = computed(() => maskPhone(boundPhone.value))
+
+ async function loadBoundPhone() {
+ try {
+ const profile = await fetchProfile()
+ boundPhone.value = profile?.phonenumber || profile?.userName || ''
+ if (!boundPhone.value) {
+ uni.showToast({ title: '未绑定手机号', icon: 'none' })
+ }
+ } catch (err) {
+ uni.showToast({ title: getErrorMessage(err, '加载账号信息失败'), icon: 'none' })
+ }
+ }
+
+ function onSendCode() {
+ if (!boundPhone.value) {
+ uni.showToast({ title: '未绑定手机号', icon: 'none' })
+ return
+ }
+ sendCode(boundPhone.value)
+ }
+
+ async function nextStep() {
+ if (!boundPhone.value) {
+ uni.showToast({ title: '未绑定手机号', icon: 'none' })
+ return
+ }
+ if (!isValidSmsCode(code.value)) {
+ uni.showToast({ title: '请输入验证码', icon: 'none' })
+ return
+ }
+ if (loading.value) return
+
+ loading.value = true
+ try {
+ const res = await verifySetPasswordCode({ phone: boundPhone.value, code: code.value })
+ if (!res.resetToken) {
+ uni.showToast({ title: '验证失败,未获取重置令牌', icon: 'none' })
+ return
+ }
+ uni.navigateTo({
+ url: `/pages/mine/set-password/index?resetToken=${encodeURIComponent(res.resetToken)}`,
+ })
+ } catch (err) {
+ uni.showToast({ title: getErrorMessage(err, '验证失败,请重试'), icon: 'none' })
+ } finally {
+ loading.value = false
+ }
+ }
+
+ onShow(() => {
+ void loadBoundPhone()
+ })
+
+ return {
+ code,
+ maskedPhone,
+ counting,
+ seconds,
+ onSendCode,
+ nextStep,
+ }
+}
diff --git a/src/pages/mine/set-password/set-password.api.ts b/src/pages/mine/set-password/set-password.api.ts
new file mode 100644
index 0000000..f1fde1d
--- /dev/null
+++ b/src/pages/mine/set-password/set-password.api.ts
@@ -0,0 +1,34 @@
+import { request } from '@/api/request'
+import type { ForgetPasswordResetBody, ForgetPasswordVerifyBody, ForgetPasswordVerifyResult } from '@/types/api'
+import type { SetPasswordPhoneForm } from './set-password.types'
+
+function normalizeResetToken(
+ data: string | ForgetPasswordVerifyResult,
+): ForgetPasswordVerifyResult {
+ if (typeof data === 'string') {
+ return { resetToken: data }
+ }
+ return data
+}
+
+/** POST /app/auth/forgetPassword/verifyCode — 校验绑定手机号验证码 */
+export function verifySetPasswordCode(data: SetPasswordPhoneForm) {
+ const body: ForgetPasswordVerifyBody = {
+ phonenumber: data.phone.trim(),
+ smsCode: data.code.trim(),
+ }
+ return request({
+ url: '/app/auth/forgetPassword/verifyCode',
+ method: 'POST',
+ data: body,
+ }).then(normalizeResetToken)
+}
+
+/** POST /app/auth/forgetPassword/doReset — 凭重置令牌设置新密码 */
+export function resetPasswordByToken(data: ForgetPasswordResetBody) {
+ return request({
+ url: '/app/auth/forgetPassword/doReset',
+ method: 'POST',
+ data,
+ })
+}
diff --git a/src/pages/mine/set-password/set-password.scss b/src/pages/mine/set-password/set-password.scss
new file mode 100644
index 0000000..47857f7
--- /dev/null
+++ b/src/pages/mine/set-password/set-password.scss
@@ -0,0 +1,108 @@
+.set-password-page {
+ min-height: 100vh;
+ background: #fff;
+}
+
+.set-password-page__body {
+ padding: 48rpx $page-padding-x 48rpx;
+}
+
+.set-password-page__title {
+ display: block;
+ text-align: center;
+ font-size: 44rpx;
+ font-weight: 700;
+ color: $color-text;
+ margin-bottom: 24rpx;
+}
+
+.set-password-page__subtitle {
+ display: block;
+ text-align: center;
+ font-size: 26rpx;
+ color: $color-text-secondary;
+ line-height: 1.6;
+ margin-bottom: 64rpx;
+}
+
+.set-password-page__method-list {
+ background: #f7f8fa;
+ border-radius: 20rpx;
+ overflow: hidden;
+}
+
+.set-password-page__method-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ min-height: 112rpx;
+ padding: 0 32rpx;
+ box-sizing: border-box;
+}
+
+.set-password-page__method-text {
+ font-size: 30rpx;
+ color: $color-text;
+}
+
+.set-password-page__arrow {
+ width: 16rpx;
+ height: 28rpx;
+ opacity: 0.45;
+ flex-shrink: 0;
+}
+
+.set-password-page__phone-row {
+ display: flex;
+ align-items: center;
+ height: 96rpx;
+ padding: 0 32rpx;
+ margin-bottom: 24rpx;
+ background: #f7f8fa;
+ border-radius: $radius-input;
+}
+
+.set-password-page__phone-icon {
+ width: 40rpx;
+ height: 40rpx;
+ margin-right: 20rpx;
+ flex-shrink: 0;
+}
+
+.set-password-page__phone-text {
+ flex: 1;
+ font-size: 28rpx;
+ color: $color-text;
+}
+
+.set-password-page__form {
+ margin-bottom: 16rpx;
+}
+
+.set-password-page__form :deep(.base-input) {
+ border: none;
+ box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
+}
+
+.set-password-page__hint {
+ display: block;
+ margin: -8rpx 0 32rpx;
+ font-size: 24rpx;
+ color: $color-text-secondary;
+ line-height: 1.5;
+}
+
+.set-password-page__btn {
+ margin-top: 16rpx;
+}
+
+.set-password-page__link-row {
+ display: flex;
+ justify-content: center;
+ margin-top: 32rpx;
+}
+
+.set-password-page__link {
+ font-size: 26rpx;
+ color: $color-primary;
+}
diff --git a/src/pages/mine/set-password/set-password.types.ts b/src/pages/mine/set-password/set-password.types.ts
new file mode 100644
index 0000000..68fb60e
--- /dev/null
+++ b/src/pages/mine/set-password/set-password.types.ts
@@ -0,0 +1,10 @@
+export interface SetPasswordPhoneForm {
+ phone: string
+ code: string
+}
+
+export interface SetPasswordForm {
+ resetToken: string
+ newPassword: string
+ confirmPassword: string
+}
diff --git a/src/pages/mine/set-password/use-set-password.ts b/src/pages/mine/set-password/use-set-password.ts
new file mode 100644
index 0000000..4b65409
--- /dev/null
+++ b/src/pages/mine/set-password/use-set-password.ts
@@ -0,0 +1,63 @@
+import { onLoad } from '@dcloudio/uni-app'
+import { ref } from 'vue'
+import { getErrorMessage } from '@/utils/api-error'
+import { isValidStrongPassword } from '@/utils/validate'
+import { resetPasswordByToken } from './set-password.api'
+
+export function useSetPassword() {
+ const password = ref('')
+ const confirmPassword = ref('')
+ const resetToken = ref('')
+ const loading = ref(false)
+
+ onLoad((query) => {
+ const raw = (query?.resetToken as string) || ''
+ resetToken.value = raw ? decodeURIComponent(raw) : ''
+ if (!resetToken.value) {
+ uni.showToast({ title: '请先完成身份验证', icon: 'none' })
+ setTimeout(() => uni.navigateBack(), 1500)
+ }
+ })
+
+ function onForgotPassword() {
+ uni.navigateTo({ url: '/pages/forgot-password/index' })
+ }
+
+ async function submit() {
+ if (!resetToken.value) {
+ uni.showToast({ title: '重置令牌无效', icon: 'none' })
+ return
+ }
+ if (!isValidStrongPassword(password.value)) {
+ uni.showToast({
+ title: '密码需8-20位,且包含字母、数字、符号中至少2种',
+ icon: 'none',
+ })
+ return
+ }
+ if (password.value !== confirmPassword.value) {
+ uni.showToast({ title: '两次密码不一致', icon: 'none' })
+ return
+ }
+ if (loading.value) return
+
+ loading.value = true
+ try {
+ await resetPasswordByToken({
+ resetToken: resetToken.value,
+ newPassword: password.value,
+ confirmPassword: confirmPassword.value,
+ })
+ uni.showToast({ title: '密码修改成功', icon: 'success' })
+ setTimeout(() => {
+ uni.navigateBack({ delta: 3 })
+ }, 1200)
+ } catch (err) {
+ uni.showToast({ title: getErrorMessage(err, '修改失败,请重试'), icon: 'none' })
+ } finally {
+ loading.value = false
+ }
+ }
+
+ return { password, confirmPassword, onForgotPassword, submit }
+}
diff --git a/src/pages/mine/set-password/verify/index.vue b/src/pages/mine/set-password/verify/index.vue
new file mode 100644
index 0000000..f2f32ee
--- /dev/null
+++ b/src/pages/mine/set-password/verify/index.vue
@@ -0,0 +1,31 @@
+
+
+
+
+ 身份验证
+ 请先进行身份验证
+
+
+
+ 手机验证码验证
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/mine/set-password/verify/use-set-password-verify.ts b/src/pages/mine/set-password/verify/use-set-password-verify.ts
new file mode 100644
index 0000000..a6195e5
--- /dev/null
+++ b/src/pages/mine/set-password/verify/use-set-password-verify.ts
@@ -0,0 +1,7 @@
+export function useSetPasswordVerify() {
+ function onPhoneVerify() {
+ uni.navigateTo({ url: '/pages/mine/set-password/phone/index' })
+ }
+
+ return { onPhoneVerify }
+}
diff --git a/src/pages/mine/storage/index.vue b/src/pages/mine/storage/index.vue
new file mode 100644
index 0000000..83090e7
--- /dev/null
+++ b/src/pages/mine/storage/index.vue
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+ 储存空间
+
+
+
+
+ {{ usedPercent }}%
+ 已使用
+
+
+
+
+
+
+
+ {{ item.label }}
+ {{ formatSize(item.sizeMb) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/mine/storage/storage.scss b/src/pages/mine/storage/storage.scss
new file mode 100644
index 0000000..ce9602f
--- /dev/null
+++ b/src/pages/mine/storage/storage.scss
@@ -0,0 +1,130 @@
+.storage-page {
+ min-height: 100vh;
+ background: #f5f6f8;
+ display: flex;
+ flex-direction: column;
+}
+
+.storage-page__scroll {
+ flex: 1;
+ height: 0;
+}
+
+.storage-page__body {
+ padding: 8rpx 24rpx calc(32rpx + env(safe-area-inset-bottom));
+}
+
+.storage-page__section-label {
+ display: block;
+ padding: 8rpx 8rpx 24rpx;
+ font-size: 26rpx;
+ color: #98a2b3;
+ line-height: 1.4;
+}
+
+.storage-page__ring-wrap {
+ display: flex;
+ justify-content: center;
+ margin-bottom: 40rpx;
+}
+
+.storage-page__ring {
+ position: relative;
+ width: 320rpx;
+ height: 320rpx;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.storage-page__ring-inner {
+ width: 252rpx;
+ height: 252rpx;
+ border-radius: 50%;
+ background: #f5f6f8;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+}
+
+.storage-page__ring-value {
+ font-size: 64rpx;
+ font-weight: 700;
+ color: #1a1a1a;
+ line-height: 1.1;
+}
+
+.storage-page__ring-label {
+ margin-top: 8rpx;
+ font-size: 26rpx;
+ color: #98a2b3;
+ line-height: 1.4;
+}
+
+.storage-page__list {
+ background: #fff;
+ border-radius: 20rpx;
+ padding: 8rpx 28rpx 12rpx;
+ margin-bottom: 32rpx;
+}
+
+.storage-page__item {
+ padding: 24rpx 0;
+}
+
+.storage-page__item-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 16rpx;
+}
+
+.storage-page__item-label {
+ font-size: 30rpx;
+ font-weight: 600;
+ color: #1a1a1a;
+ line-height: 1.35;
+}
+
+.storage-page__item-size {
+ font-size: 28rpx;
+ color: $color-primary;
+ line-height: 1.3;
+ font-weight: 500;
+}
+
+.storage-page__bar {
+ height: 12rpx;
+ border-radius: 6rpx;
+ background: #eef1f5;
+ overflow: hidden;
+}
+
+.storage-page__bar-fill {
+ height: 100%;
+ border-radius: 6rpx;
+ background: linear-gradient(90deg, #a4e04b 0%, #07b57a 100%);
+ transition: width 0.3s ease;
+}
+
+.storage-page__footer {
+ padding: 0 8rpx;
+}
+
+.storage-page__clear-btn {
+ height: 96rpx;
+ border-radius: 20rpx;
+ border: 1rpx solid #d0d5dd;
+ background: #fff;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.storage-page__clear-text {
+ font-size: 30rpx;
+ color: #1a1a1a;
+ line-height: 1.3;
+}
diff --git a/src/pages/mine/storage/storage.types.ts b/src/pages/mine/storage/storage.types.ts
new file mode 100644
index 0000000..749b6e6
--- /dev/null
+++ b/src/pages/mine/storage/storage.types.ts
@@ -0,0 +1,6 @@
+export interface StorageCacheItem {
+ id: string
+ label: string
+ sizeMb: number
+ percent: number
+}
diff --git a/src/pages/mine/storage/use-storage.ts b/src/pages/mine/storage/use-storage.ts
new file mode 100644
index 0000000..45454fd
--- /dev/null
+++ b/src/pages/mine/storage/use-storage.ts
@@ -0,0 +1,52 @@
+import { computed, ref } from 'vue'
+import type { StorageCacheItem } from './storage.types'
+
+const DEFAULT_ITEMS: StorageCacheItem[] = [
+ { id: 'image', label: '图片缓存', sizeMb: 12.8, percent: 72 },
+ { id: 'doc', label: '文档数据', sizeMb: 12.8, percent: 58 },
+ { id: 'health', label: '健康数据', sizeMb: 12.8, percent: 45 },
+ { id: 'video', label: '视频缓存', sizeMb: 12.8, percent: 36 },
+ { id: 'other', label: '其他数据', sizeMb: 12.8, percent: 28 },
+]
+
+const USAGE_PERCENT = 25
+
+export function useStorage() {
+ const items = ref(DEFAULT_ITEMS.map((item) => ({ ...item })))
+
+ const usedPercent = ref(USAGE_PERCENT)
+
+ const totalCacheMb = computed(() => {
+ const sum = items.value.reduce((acc, item) => acc + item.sizeMb, 0)
+ return Math.round(sum * 10) / 10
+ })
+
+ const ringStyle = computed(() => ({
+ background: `conic-gradient(#6ea8ff 0% ${usedPercent.value}%, #e8ecf3 ${usedPercent.value}% 100%)`,
+ }))
+
+ function onClearAll() {
+ uni.showModal({
+ title: '清理缓存',
+ content: `确定清理全部缓存(${totalCacheMb.value}MB)吗?`,
+ success: (res) => {
+ if (!res.confirm) return
+ items.value = items.value.map((item) => ({
+ ...item,
+ sizeMb: 0,
+ percent: 0,
+ }))
+ usedPercent.value = 0
+ uni.showToast({ title: '清理完成', icon: 'success' })
+ },
+ })
+ }
+
+ return {
+ items,
+ usedPercent,
+ totalCacheMb,
+ ringStyle,
+ onClearAll,
+ }
+}
diff --git a/src/pages/mine/support/cs-chat-socket.ts b/src/pages/mine/support/cs-chat-socket.ts
new file mode 100644
index 0000000..10b1c80
--- /dev/null
+++ b/src/pages/mine/support/cs-chat-socket.ts
@@ -0,0 +1,169 @@
+import { getStoredToken } from '@/utils/auth-token'
+import type { CsChatWsPayload, CsMessageVo, CsSessionVo } from './support.types'
+
+const HEARTBEAT_INTERVAL_MS = 25000
+const RECONNECT_DELAY_MS = 25000
+
+let socketTask: UniApp.SocketTask | null = null
+let heartbeatTimer: ReturnType | null = null
+let reconnectTimer: ReturnType | null = null
+let connecting = false
+let manualClosed = false
+let activeSessionId = ''
+let socketOpened = false
+
+const listeners = new Set<(payload: CsChatWsPayload) => void>()
+
+function resolveCsChatWsBase(): string {
+ const configured = import.meta.env.VITE_CS_CHAT_WS_URL?.trim()
+ if (configured) {
+ return configured.replace(/[?#].*$/, '').replace(/\/$/, '')
+ }
+
+ const envBase = import.meta.env.VITE_API_BASE_URL?.trim() || ''
+ const fallback = import.meta.env.VITE_API_PROXY_TARGET?.trim() || 'http://192.168.0.212:8082'
+ const httpBase = envBase || fallback
+ const wsBase = httpBase.replace(/^https/i, 'wss').replace(/^http/i, 'ws')
+ return `${wsBase}/app/cs/chat`
+}
+
+function resolveCsChatWsUrl(sessionId: string, token: string): string {
+ const base = resolveCsChatWsBase()
+ return `${base}?token=${encodeURIComponent(token)}&sessionId=${encodeURIComponent(sessionId)}`
+}
+
+function stopHeartbeat() {
+ if (heartbeatTimer) clearInterval(heartbeatTimer)
+ heartbeatTimer = null
+}
+
+function startHeartbeat() {
+ stopHeartbeat()
+ heartbeatTimer = setInterval(() => {
+ socketTask?.send({ data: 'ping' })
+ }, HEARTBEAT_INTERVAL_MS)
+}
+
+function clearReconnectTimer() {
+ if (reconnectTimer) clearTimeout(reconnectTimer)
+ reconnectTimer = null
+}
+
+function scheduleReconnect() {
+ if (manualClosed || !getStoredToken() || !activeSessionId) return
+ clearReconnectTimer()
+ reconnectTimer = setTimeout(() => {
+ connectCsChatSocket(activeSessionId)
+ }, RECONNECT_DELAY_MS)
+}
+
+function cleanupSocketTask() {
+ stopHeartbeat()
+ socketTask = null
+ socketOpened = false
+ connecting = false
+}
+
+export function parseCsChatPayload(raw: string): CsChatWsPayload | null {
+ if (!raw || raw === 'ping' || raw === 'pong') return null
+ try {
+ const parsed = JSON.parse(raw) as Record
+ const type = String(parsed.type ?? '').toLowerCase()
+
+ if (type === 'message' && parsed.data && typeof parsed.data === 'object') {
+ return { type: 'message', message: parsed.data as CsMessageVo }
+ }
+ if (type === 'session' && parsed.data && typeof parsed.data === 'object') {
+ return { type: 'session', session: parsed.data as CsSessionVo }
+ }
+ if (parsed.message && typeof parsed.message === 'object') {
+ return { type: 'message', message: parsed.message as CsMessageVo }
+ }
+ if (parsed.session && typeof parsed.session === 'object') {
+ return { type: 'session', session: parsed.session as CsSessionVo }
+ }
+ if (parsed.content !== undefined || parsed.seq !== undefined || parsed.messageId !== undefined) {
+ return { type: 'message', message: parsed as CsMessageVo }
+ }
+ if (parsed.status !== undefined || parsed.sessionStatus !== undefined) {
+ return { type: 'session', session: parsed as CsSessionVo }
+ }
+ return { type: type || 'unknown', raw: parsed }
+ } catch {
+ return null
+ }
+}
+
+function handleSocketMessage(raw: string) {
+ const payload = parseCsChatPayload(raw)
+ if (!payload) return
+ listeners.forEach((listener) => listener(payload))
+}
+
+export function onCsChatPush(listener: (payload: CsChatWsPayload) => void) {
+ listeners.add(listener)
+ return () => listeners.delete(listener)
+}
+
+export function isCsChatSocketConnected(): boolean {
+ return socketOpened
+}
+
+export function connectCsChatSocket(sessionId: string) {
+ const token = getStoredToken()
+ if (!token || !sessionId) return
+ if (connecting) return
+ if (socketOpened && socketTask && activeSessionId === sessionId) return
+
+ if (socketTask) {
+ manualClosed = true
+ socketTask.close({})
+ cleanupSocketTask()
+ }
+
+ activeSessionId = sessionId
+ manualClosed = false
+ connecting = true
+ clearReconnectTimer()
+
+ const task = uni.connectSocket({
+ url: resolveCsChatWsUrl(sessionId, token),
+ complete: () => {
+ connecting = false
+ },
+ })
+
+ socketTask = task
+
+ task.onOpen(() => {
+ socketOpened = true
+ startHeartbeat()
+ })
+
+ task.onMessage((res) => {
+ const raw = typeof res.data === 'string' ? res.data : ''
+ handleSocketMessage(raw)
+ })
+
+ task.onClose(() => {
+ cleanupSocketTask()
+ scheduleReconnect()
+ })
+
+ task.onError(() => {
+ cleanupSocketTask()
+ scheduleReconnect()
+ })
+}
+
+export function disconnectCsChatSocket() {
+ manualClosed = true
+ activeSessionId = ''
+ clearReconnectTimer()
+ stopHeartbeat()
+ if (socketTask) {
+ socketTask.close({})
+ }
+ socketTask = null
+ connecting = false
+}
diff --git a/src/pages/mine/support/index.vue b/src/pages/mine/support/index.vue
new file mode 100644
index 0000000..badbb89
--- /dev/null
+++ b/src/pages/mine/support/index.vue
@@ -0,0 +1,319 @@
+
+
+
+
+
+ 转人工
+
+
+ 结束
+
+
+
+
+
+
+
+
+
+
+ 🕐 {{ item.text }}
+
+
+
+
+ {{ item.content }}
+
+
+
+
+
+ {{ item.role === 'bot' ? '智' : humanAvatarText }}
+
+
+
+
+ {{ item.content }}
+
+
+ {{ item.time }}
+
+
+
+
+ 我
+
+
+
+
+
+ ●
+ {{ action.label }}
+
+
+
+
+ {{ item.title }}
+
+ {{ index + 1 }}
+ {{ step }}
+
+
+
+
+
+
+ 🎧
+ 转换人工客服
+
+ 分配中
+
+
+
+ ✓
+ 已提交
+
+
+ Ⅱ
+ 分配中
+
+
+ ○
+ 接入
+
+
+
+
+ 排队第 {{ item.position }} 位 · 已等待 {{ formattedQueueWait }}
+
+ {{ item.estimateText }}
+
+
+
+ 已解决,无需人工
+
+ 取消排队
+
+
+
+
+
+
+
+ {{ item.title }}
+
+ {{ item.estimateText }}
+
+
+ {{ diag.icon }}
+ {{ diag.label }}
+
+ {{ diagnosisStatusText(diag.status) }}
+
+
+
+
+
+
+
+ 补充设备信息
+
+
+ 大概还要多久?
+
+
+
+
+
+
+
+ {{ item.time }}
+
+ 我
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/mine/support/support-runtime.ts b/src/pages/mine/support/support-runtime.ts
new file mode 100644
index 0000000..f239f8b
--- /dev/null
+++ b/src/pages/mine/support/support-runtime.ts
@@ -0,0 +1,46 @@
+/** 客服页运行时状态(模块级,确保离开页面后定时器可被彻底清理) */
+let pageActive = false
+let pollTimer: ReturnType | null = null
+let queueTimer: ReturnType | null = null
+
+const QUEUE_POLL_INTERVAL_MS = 5000
+
+export function isSupportPageActive(): boolean {
+ return pageActive
+}
+
+export function markSupportPageActive(active: boolean) {
+ pageActive = active
+}
+
+export function stopSupportPollTimer() {
+ if (pollTimer) clearInterval(pollTimer)
+ pollTimer = null
+}
+
+export function stopSupportQueueTimer() {
+ if (queueTimer) clearInterval(queueTimer)
+ queueTimer = null
+}
+
+export function teardownSupportRuntime() {
+ markSupportPageActive(false)
+ stopSupportPollTimer()
+ stopSupportQueueTimer()
+}
+
+export function startSupportQueuePolling(onTick: () => void) {
+ stopSupportPollTimer()
+ pollTimer = setInterval(() => {
+ if (!pageActive) return
+ onTick()
+ }, QUEUE_POLL_INTERVAL_MS)
+}
+
+export function startSupportQueueWaitTimer(onTick: () => void) {
+ stopSupportQueueTimer()
+ queueTimer = setInterval(() => {
+ if (!pageActive) return
+ onTick()
+ }, 1000)
+}
diff --git a/src/pages/mine/support/support.api.ts b/src/pages/mine/support/support.api.ts
new file mode 100644
index 0000000..ffbfd39
--- /dev/null
+++ b/src/pages/mine/support/support.api.ts
@@ -0,0 +1,136 @@
+import { request } from '@/api/request'
+import { ApiError } from '@/utils/api-error'
+import {
+ isSessionEnded,
+ normalizeSessionId,
+ normalizeSessionResponse,
+ unwrapCsActionResponse,
+} from './support.utils'
+import type {
+ CsAppActionVo,
+ CsClickButtonBody,
+ CsCreateSessionBody,
+ CsMessageVo,
+ CsResolvedSession,
+ CsSendMessageBody,
+ CsSessionListQuery,
+ CsSessionVo,
+ CsTableData,
+} from './support.types'
+
+function buildSessionPath(sessionId: string, suffix = '') {
+ return `/app/cs/session/${encodeURIComponent(sessionId)}${suffix}`
+}
+
+function unwrapRows(data: T[] | CsTableData | undefined): T[] {
+ if (!data) return []
+ if (Array.isArray(data)) return data
+ return data.rows ?? []
+}
+
+/** POST /app/cs/session — 创建客服会话;已有未结束会话时返回原会话 */
+export function createCsSession(data: CsCreateSessionBody = {}) {
+ return request({
+ url: '/app/cs/session',
+ method: 'POST',
+ data,
+ })
+}
+
+async function fetchActiveCsSession(): Promise {
+ const list = await fetchCsSessionList({ pageNum: 1, pageSize: 20 })
+ const active = list.find((item) => !isSessionEnded(item)) ?? list[0]
+ if (!active || !normalizeSessionId(active)) return null
+ return normalizeSessionResponse(active)
+}
+
+/** 创建或获取当前客服会话(兼容 action 包装、id 字段、需二次查询列表) */
+export async function resolveCsSession(): Promise {
+ let resolved = unwrapCsActionResponse(await createCsSession())
+ let session = resolved.session
+ let messages = resolved.messages
+ let sessionId = normalizeSessionId(session)
+
+ if (!sessionId) {
+ const active = await fetchActiveCsSession()
+ if (active) {
+ session = active
+ sessionId = normalizeSessionId(active)
+ messages = undefined
+ }
+ }
+
+ if (!sessionId) {
+ throw new ApiError('未获取客服会话,请确认已登录后重试')
+ }
+
+ if (session.status === undefined && session.sessionStatus === undefined) {
+ try {
+ session = normalizeSessionResponse(await fetchCsSessionDetail(sessionId))
+ } catch {
+ session = { ...session, sessionId }
+ }
+ }
+
+ return { session, messages }
+}
+
+/** GET /app/cs/session/list — 查询自己的会话 */
+export function fetchCsSessionList(query: CsSessionListQuery = {}) {
+ return request>({
+ url: '/app/cs/session/list',
+ method: 'GET',
+ data: query,
+ }).then(unwrapRows)
+}
+
+/** GET /app/cs/session/{sessionId} — 查询会话详情 */
+export function fetchCsSessionDetail(sessionId: string) {
+ return request({
+ url: buildSessionPath(sessionId),
+ method: 'GET',
+ })
+}
+
+/** GET /app/cs/session/{sessionId}/messages — 拉取会话消息 */
+export function fetchCsMessages(sessionId: string, afterSeq = 0) {
+ return request>({
+ url: buildSessionPath(sessionId, '/messages'),
+ method: 'GET',
+ data: { afterSeq },
+ }).then(unwrapRows)
+}
+
+/** POST /app/cs/session/{sessionId}/message — 发送消息 */
+export function sendCsMessage(sessionId: string, data: CsSendMessageBody) {
+ return request({
+ url: buildSessionPath(sessionId, '/message'),
+ method: 'POST',
+ data,
+ })
+}
+
+/** POST /app/cs/session/{sessionId}/transfer — 转人工 */
+export function transferCsSession(sessionId: string) {
+ return request({
+ url: buildSessionPath(sessionId, '/transfer'),
+ method: 'POST',
+ })
+}
+
+/** POST /app/cs/session/{sessionId}/end — 结束会话 */
+export function endCsSession(sessionId: string) {
+ return request({
+ url: buildSessionPath(sessionId, '/end'),
+ method: 'POST',
+ })
+}
+
+/** POST /app/cs/session/{sessionId}/button — 点击机器人按钮 */
+export function clickCsButton(sessionId: string, data: CsClickButtonBody) {
+ return request({
+ url: buildSessionPath(sessionId, '/button'),
+ method: 'POST',
+ data,
+ })
+}
diff --git a/src/pages/mine/support/support.scss b/src/pages/mine/support/support.scss
new file mode 100644
index 0000000..f6d6cda
--- /dev/null
+++ b/src/pages/mine/support/support.scss
@@ -0,0 +1,695 @@
+$support-green: #1ec671;
+$support-green-dark: #18b863;
+$support-page-bg: #f7f8fa;
+$support-card-border: #eef0f3;
+$support-text: #1a1a1a;
+$support-text-secondary: #98a2b3;
+$support-text-muted: #667085;
+$support-orange: #f5a623;
+$support-orange-bg: #fff7e6;
+$support-danger: #f04438;
+
+.support-page {
+ height: 100vh;
+ background: #fff;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.support-page :deep(.app-navbar) {
+ background: #fff;
+}
+
+.support-page :deep(.app-navbar__right) {
+ width: auto;
+ min-width: 80rpx;
+ padding-left: 16rpx;
+}
+
+.support-page__nav-action {
+ padding: 0;
+ background: transparent;
+}
+
+.support-page__nav-action-text {
+ font-size: 30rpx;
+ color: $support-green;
+ line-height: 1;
+ font-weight: 400;
+ white-space: nowrap;
+}
+
+.support-page__nav-action-text--end {
+ color: $support-text-muted;
+}
+
+.support-page__header {
+ flex-shrink: 0;
+ margin: 0;
+ padding: 16rpx 32rpx 20rpx;
+ display: flex;
+ align-items: center;
+ gap: 20rpx;
+}
+
+.support-page__header-avatar {
+ width: 80rpx;
+ height: 80rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ background: linear-gradient(145deg, #45d685 0%, #21c25e 100%);
+ color: #fff;
+ font-size: 34rpx;
+ font-weight: 600;
+ line-height: 1;
+
+ &--square {
+ border-radius: 18rpx;
+ }
+
+ &--circle {
+ border-radius: 50%;
+ }
+}
+
+.support-page__header-main {
+ flex: 1;
+ min-width: 0;
+}
+
+.support-page__header-row {
+ display: flex;
+ align-items: center;
+ gap: 12rpx;
+}
+
+.support-page__header-name {
+ font-size: 30rpx;
+ font-weight: 600;
+ color: $support-text;
+ line-height: 1.35;
+}
+
+.support-page__header-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 6rpx;
+ padding: 4rpx 12rpx;
+ border-radius: 20rpx;
+ font-size: 20rpx;
+ line-height: 1.2;
+ font-weight: 500;
+ flex-shrink: 0;
+
+ &--online {
+ color: $support-green;
+ background: rgba(30, 198, 113, 0.1);
+ }
+
+ &--serving {
+ color: $support-green;
+ background: rgba(30, 198, 113, 0.1);
+ border: 1rpx solid rgba(30, 198, 113, 0.18);
+ }
+}
+
+.support-page__header-badge-dot {
+ width: 10rpx;
+ height: 10rpx;
+ border-radius: 50%;
+ background: $support-green;
+}
+
+.support-page__header-subtitle {
+ display: block;
+ margin-top: 8rpx;
+ font-size: 24rpx;
+ color: $support-text-secondary;
+ line-height: 1.4;
+}
+
+.support-page__header-subtitle-dot {
+ color: $support-green;
+ margin-right: 4rpx;
+}
+
+.support-page__header-hours {
+ flex-shrink: 0;
+ font-size: 24rpx;
+ color: $support-green;
+ line-height: 1.3;
+ font-weight: 500;
+}
+
+.support-page__chat {
+ flex: 1;
+ min-height: 0;
+ padding: 0 24rpx;
+ box-sizing: border-box;
+}
+
+.support-page__chat-inner {
+ padding: 8rpx 0 24rpx;
+}
+
+.support-page__time {
+ display: flex;
+ justify-content: center;
+ margin: 8rpx 0 24rpx;
+}
+
+.support-page__time-pill {
+ padding: 8rpx 20rpx;
+ border-radius: 20rpx;
+ background: #eef1f5;
+ font-size: 22rpx;
+ color: $support-text-secondary;
+ line-height: 1.4;
+}
+
+.support-page__msg {
+ display: flex;
+ align-items: flex-start;
+ gap: 16rpx;
+ margin-bottom: 28rpx;
+
+ &--user {
+ justify-content: flex-end;
+ }
+
+ &--system {
+ justify-content: center;
+ margin-bottom: 20rpx;
+ }
+}
+
+.support-page__msg-avatar {
+ width: 72rpx;
+ height: 72rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ font-size: 28rpx;
+ font-weight: 600;
+ line-height: 1;
+ color: #fff;
+
+ &--bot {
+ border-radius: 16rpx;
+ background: linear-gradient(145deg, #45d685 0%, #21c25e 100%);
+ }
+
+ &--agent {
+ border-radius: 50%;
+ background: linear-gradient(145deg, #45d685 0%, #21c25e 100%);
+ }
+
+ &--user {
+ border-radius: 16rpx;
+ background: #d0d5dd;
+ color: #fff;
+ }
+}
+
+.support-page__msg-body {
+ max-width: calc(100% - 96rpx);
+ display: flex;
+ flex-direction: column;
+
+ &--user {
+ align-items: flex-end;
+ }
+}
+
+.support-page__bubble {
+ max-width: 100%;
+ padding: 20rpx 24rpx;
+ font-size: 28rpx;
+ line-height: 1.55;
+ word-break: break-word;
+
+ &--bot,
+ &--agent {
+ background: #fff;
+ color: $support-text;
+ border: 1rpx solid $support-card-border;
+ border-radius: 4rpx 20rpx 20rpx 20rpx;
+ box-shadow: 0 2rpx 12rpx rgba(16, 24, 40, 0.04);
+ }
+
+ &--user {
+ background: $support-green;
+ color: $support-text;
+ border-radius: 20rpx 4rpx 20rpx 20rpx;
+ }
+
+ &--system {
+ max-width: 92%;
+ padding: 12rpx 20rpx;
+ background: #eef1f5;
+ color: $support-text-muted;
+ font-size: 24rpx;
+ line-height: 1.5;
+ text-align: center;
+ border-radius: 20rpx;
+ }
+}
+
+.support-page__bubble-time {
+ margin-top: 8rpx;
+ font-size: 22rpx;
+ color: $support-text-secondary;
+ line-height: 1.2;
+}
+
+.support-page__quick {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 16rpx;
+ margin: 0 0 28rpx 88rpx;
+}
+
+.support-page__quick-item {
+ display: inline-flex;
+ align-items: center;
+ gap: 8rpx;
+ padding: 14rpx 22rpx;
+ background: #fff;
+ border: 1rpx solid #e4e7ec;
+ border-radius: 32rpx;
+ font-size: 26rpx;
+ color: #3b82f6;
+ line-height: 1.3;
+}
+
+.support-page__quick-dot {
+ color: #3b82f6;
+ font-size: 20rpx;
+ line-height: 1;
+}
+
+.support-page__card {
+ margin: 0 0 28rpx 88rpx;
+ background: #fff;
+ border: 1rpx solid $support-card-border;
+ border-radius: 20rpx;
+ padding: 28rpx;
+ box-shadow: 0 2rpx 16rpx rgba(16, 24, 40, 0.04);
+}
+
+.support-page__card-title {
+ font-size: 30rpx;
+ font-weight: 600;
+ color: $support-text;
+ line-height: 1.4;
+ margin-bottom: 20rpx;
+}
+
+.support-page__card-step {
+ display: flex;
+ gap: 16rpx;
+ margin-bottom: 16rpx;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+}
+
+.support-page__card-step-index {
+ width: 36rpx;
+ height: 36rpx;
+ border-radius: 50%;
+ background: rgba(30, 198, 113, 0.12);
+ color: $support-green;
+ font-size: 22rpx;
+ font-weight: 600;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.support-page__card-step-text {
+ flex: 1;
+ font-size: 26rpx;
+ color: $support-text-muted;
+ line-height: 1.55;
+}
+
+.support-page__queue-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 28rpx;
+}
+
+.support-page__queue-title-wrap {
+ display: flex;
+ align-items: center;
+ gap: 10rpx;
+}
+
+.support-page__queue-icon {
+ font-size: 30rpx;
+ line-height: 1;
+}
+
+.support-page__queue-title {
+ font-size: 30rpx;
+ font-weight: 600;
+ color: $support-text;
+ line-height: 1.3;
+}
+
+.support-page__queue-tag {
+ padding: 6rpx 14rpx;
+ border-radius: 8rpx;
+ background: $support-orange-bg;
+ color: $support-orange;
+ font-size: 22rpx;
+ line-height: 1.2;
+ font-weight: 500;
+}
+
+.support-page__queue-steps {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ margin-bottom: 28rpx;
+ padding: 0 8rpx;
+}
+
+.support-page__queue-step {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 12rpx;
+ position: relative;
+
+ &:not(:last-child)::after {
+ content: '';
+ position: absolute;
+ top: 20rpx;
+ left: calc(50% + 24rpx);
+ width: calc(100% - 48rpx);
+ height: 2rpx;
+ background: #e4e7ec;
+ }
+}
+
+.support-page__queue-step-dot {
+ width: 40rpx;
+ height: 40rpx;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 20rpx;
+ line-height: 1;
+ background: #f2f4f7;
+ color: $support-text-secondary;
+ position: relative;
+ z-index: 1;
+
+ &--done {
+ background: $support-green;
+ color: #fff;
+ }
+
+ &--active {
+ background: $support-orange-bg;
+ color: $support-orange;
+ border: 2rpx solid $support-orange;
+ }
+}
+
+.support-page__queue-step-label {
+ font-size: 22rpx;
+ color: $support-text-muted;
+ line-height: 1.3;
+ text-align: center;
+}
+
+.support-page__queue-info {
+ padding: 20rpx 0;
+ border-top: 1rpx solid #f0f0f0;
+ border-bottom: 1rpx solid #f0f0f0;
+ margin-bottom: 20rpx;
+}
+
+.support-page__queue-info-text {
+ display: block;
+ font-size: 28rpx;
+ font-weight: 600;
+ color: $support-text;
+ line-height: 1.5;
+ margin-bottom: 8rpx;
+}
+
+.support-page__queue-estimate {
+ font-size: 24rpx;
+ color: $support-text-secondary;
+ line-height: 1.4;
+}
+
+.support-page__queue-actions {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.support-page__queue-link {
+ font-size: 26rpx;
+ color: $support-text-muted;
+ line-height: 1.4;
+}
+
+.support-page__queue-cancel {
+ font-size: 26rpx;
+ color: $support-danger;
+ line-height: 1.4;
+ font-weight: 500;
+}
+
+.support-page__diagnosis-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 24rpx;
+}
+
+.support-page__diagnosis-title-wrap {
+ display: flex;
+ align-items: center;
+ gap: 12rpx;
+}
+
+.support-page__diagnosis-bar {
+ width: 6rpx;
+ height: 28rpx;
+ border-radius: 3rpx;
+ background: $support-green;
+}
+
+.support-page__diagnosis-estimate {
+ padding: 6rpx 14rpx;
+ border-radius: 8rpx;
+ background: #f2f4f7;
+ font-size: 22rpx;
+ color: $support-text-secondary;
+ line-height: 1.2;
+}
+
+.support-page__diagnosis-item {
+ display: flex;
+ align-items: center;
+ gap: 12rpx;
+ padding: 14rpx 0;
+}
+
+.support-page__diagnosis-icon {
+ width: 36rpx;
+ font-size: 24rpx;
+ color: $support-text-secondary;
+ text-align: center;
+ line-height: 1;
+ flex-shrink: 0;
+}
+
+.support-page__diagnosis-label {
+ flex: 1;
+ font-size: 26rpx;
+ color: $support-text;
+ line-height: 1.4;
+}
+
+.support-page__diagnosis-status {
+ padding: 4rpx 12rpx;
+ border-radius: 8rpx;
+ font-size: 22rpx;
+ line-height: 1.2;
+ flex-shrink: 0;
+
+ &--done {
+ color: $support-green;
+ background: rgba(30, 198, 113, 0.12);
+ }
+
+ &--running {
+ color: $support-orange;
+ background: $support-orange-bg;
+ }
+
+ &--pending {
+ color: $support-text-secondary;
+ background: #f2f4f7;
+ }
+}
+
+.support-page__progress {
+ height: 8rpx;
+ background: #eef1f5;
+ border-radius: 4rpx;
+ overflow: hidden;
+ margin: 12rpx 0 20rpx;
+}
+
+.support-page__progress-bar {
+ height: 100%;
+ background: linear-gradient(90deg, #45d685 0%, #21c25e 100%);
+ border-radius: 4rpx;
+ transition: width 0.3s ease;
+}
+
+.support-page__diagnosis-suggest {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 16rpx;
+}
+
+.support-page__diagnosis-suggest-item {
+ padding: 12rpx 20rpx;
+ border-radius: 28rpx;
+ background: #f5f6f8;
+ font-size: 24rpx;
+ color: $support-text-muted;
+ line-height: 1.3;
+}
+
+.support-page__image-wrap {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+}
+
+.support-page__image {
+ width: 280rpx;
+ height: 200rpx;
+ border-radius: 16rpx;
+ background: #eef1f5;
+}
+
+.support-page__footer {
+ flex-shrink: 0;
+ background: #fff;
+ padding: 12rpx 24rpx calc(12rpx + env(safe-area-inset-bottom));
+ border-top: 1rpx solid #f0f0f0;
+}
+
+.support-page__footer-shortcuts {
+ display: flex;
+ gap: 16rpx;
+ margin-bottom: 16rpx;
+}
+
+.support-page__footer-shortcut {
+ padding: 10rpx 22rpx;
+ border-radius: 28rpx;
+ background: #f5f6f8;
+ font-size: 24rpx;
+ color: $support-text-muted;
+ line-height: 1.3;
+}
+
+.support-page__input-row {
+ display: flex;
+ align-items: center;
+ gap: 16rpx;
+}
+
+.support-page__mic {
+ width: 80rpx;
+ height: 80rpx;
+ border-radius: 16rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.support-page__mic-icon {
+ width: 56rpx;
+ height: 56rpx;
+}
+
+.support-page__input-wrap {
+ flex: 1;
+ height: 72rpx;
+ padding: 0 28rpx;
+ background: #f5f6f8;
+ border-radius: 36rpx;
+ display: flex;
+ align-items: center;
+}
+
+.support-page__input {
+ flex: 1;
+ width: 100%;
+ height: 72rpx;
+ font-size: 28rpx;
+ color: $support-text;
+}
+
+.support-page__tag {
+ width: 56rpx;
+ height: 72rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.support-page__tag-icon {
+ width: 48rpx;
+ height: 48rpx;
+}
+
+.support-page__send {
+ width: 72rpx;
+ height: 72rpx;
+ border-radius: 18rpx;
+ background: linear-gradient(135deg, #07b57a 0%, #a4e04b 100%);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ box-shadow: 0 6rpx 16rpx rgba(7, 181, 122, 0.28);
+}
+
+.support-page__send-icon {
+ width: 36rpx;
+ height: 36rpx;
+}
+
+.support-page__secure {
+ margin-top: 12rpx;
+ text-align: center;
+ font-size: 22rpx;
+ color: $support-text-secondary;
+ line-height: 1.4;
+}
diff --git a/src/pages/mine/support/support.types.ts b/src/pages/mine/support/support.types.ts
new file mode 100644
index 0000000..cbaab06
--- /dev/null
+++ b/src/pages/mine/support/support.types.ts
@@ -0,0 +1,179 @@
+/** 会话状态:0 机器人 1 排队中 2 人工 3 已结束 */
+export const CS_SESSION_STATUS = {
+ bot: '0',
+ queue: '1',
+ human: '2',
+ closed: '3',
+} as const
+
+export type CsSessionStatus = (typeof CS_SESSION_STATUS)[keyof typeof CS_SESSION_STATUS]
+
+/** POST /app/cs/session、GET /app/cs/session/{sessionId} */
+export interface CsSessionVo {
+ sessionId?: number | string
+ id?: number | string
+ status?: string | number
+ sessionStatus?: string | number
+ ended?: boolean | string
+ agentId?: number | string
+ agentName?: string
+ agentNo?: string
+ queuePosition?: number
+ queueNo?: number
+ estimateWaitTime?: string
+ createTime?: string
+ updateTime?: string
+}
+
+export interface CsSessionListQuery {
+ status?: string
+ agentId?: number | string
+ keyword?: string
+ pageNum?: number
+ pageSize?: number
+ orderByColumn?: string
+ isAsc?: string
+}
+
+export interface CsTableData {
+ rows?: T[]
+ total?: number
+}
+
+/** GET /app/cs/session/{sessionId}/messages */
+export interface CsMessageVo {
+ messageId?: number | string
+ seq?: number
+ sessionId?: number | string
+ senderType?: string | number
+ messageType?: string
+ content?: string
+ createTime?: string
+ extra?: Record
+}
+
+/** WebSocket /app/cs/chat 推送 */
+export interface CsChatWsPayload {
+ type?: string
+ message?: CsMessageVo
+ session?: CsSessionVo
+ raw?: Record
+}
+
+/** POST /app/cs/session 请求体 */
+export interface CsCreateSessionBody {
+ deviceId?: number | string
+ summary?: string
+ accessType?: 'bot' | 'human'
+}
+
+/** POST /app/cs/session 及发消息等操作返回 data */
+export interface CsAppActionVo {
+ session?: CsSessionVo
+ messages?: CsMessageVo[]
+ robot?: {
+ kbId?: number | string
+ title?: string
+ content?: string
+ matched?: boolean
+ buttons?: Array<{ buttonCode?: string; label?: string }>
+ }
+}
+
+export interface CsResolvedSession {
+ session: CsSessionVo
+ messages?: CsMessageVo[]
+}
+
+/** POST /app/cs/session/{sessionId}/message */
+export interface CsSendMessageBody {
+ content: string
+ msgType?: 'text' | 'image' | string
+ clientMsgId: string
+}
+
+/** POST /app/cs/session/{sessionId}/button */
+export type CsButtonCode = 'resolved' | 'transfer_human'
+
+export interface CsClickButtonBody {
+ buttonCode: CsButtonCode
+}
+
+export type SupportPhase = 'bot' | 'queue' | 'human'
+
+export type ChatRole = 'user' | 'bot' | 'agent' | 'system'
+
+export interface QuickActionItem {
+ id: string
+ label: string
+}
+
+export interface DiagnosisItem {
+ id: string
+ icon: string
+ label: string
+ status: 'done' | 'running' | 'pending'
+}
+
+export type SupportMessage =
+ | {
+ id: string
+ seq?: number
+ type: 'time'
+ text: string
+ }
+ | {
+ id: string
+ seq?: number
+ type: 'text'
+ role: ChatRole
+ content: string
+ time?: string
+ }
+ | {
+ id: string
+ seq?: number
+ type: 'quick-actions'
+ actions: QuickActionItem[]
+ }
+ | {
+ id: string
+ seq?: number
+ type: 'wifi-help'
+ title: string
+ steps: string[]
+ }
+ | {
+ id: string
+ seq?: number
+ type: 'queue'
+ position: number
+ estimateText: string
+ }
+ | {
+ id: string
+ seq?: number
+ type: 'diagnosis'
+ title: string
+ estimateText: string
+ items: DiagnosisItem[]
+ progress: number
+ }
+ | {
+ id: string
+ seq?: number
+ type: 'image'
+ role: 'user'
+ src: string
+ time?: string
+ }
+
+export interface SupportHeaderInfo {
+ name: string
+ subtitle?: string
+ badgeText: string
+ badgeType: 'online' | 'serving'
+ avatarText: string
+ avatarShape: 'square' | 'circle'
+ serviceHours?: string
+}
diff --git a/src/pages/mine/support/support.utils.ts b/src/pages/mine/support/support.utils.ts
new file mode 100644
index 0000000..2c48a0d
--- /dev/null
+++ b/src/pages/mine/support/support.utils.ts
@@ -0,0 +1,253 @@
+import type {
+ ChatRole,
+ CsAppActionVo,
+ CsMessageVo,
+ CsSessionVo,
+ QuickActionItem,
+ SupportMessage,
+ SupportPhase,
+} from './support.types'
+import { CS_SESSION_STATUS } from './support.types'
+
+const QUICK_ACTIONS: QuickActionItem[] = [
+ { id: 'wifi', label: '设备连不上 Wi-Fi' },
+ { id: 'order', label: '订单怎么修改' },
+ { id: 'invoice', label: '申请电子发票' },
+ { id: 'device', label: '设备使用问题' },
+]
+
+export function createClientMsgId(): string {
+ return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
+}
+
+export function unwrapCsActionResponse(
+ data: CsAppActionVo | CsSessionVo | string | null | undefined,
+): { session: CsSessionVo; messages?: CsMessageVo[] } {
+ if (!data) return { session: {} }
+ if (typeof data === 'string') {
+ return { session: normalizeSessionResponse(data) }
+ }
+ if (data.session) {
+ return {
+ session: normalizeSessionResponse(data.session),
+ messages: data.messages,
+ }
+ }
+ const record = data as CsSessionVo & { messages?: CsMessageVo[] }
+ const { messages, ...session } = record
+ return {
+ session: normalizeSessionResponse(session),
+ messages,
+ }
+}
+
+export function normalizeSessionResponse(
+ data: CsSessionVo | string | null | undefined,
+): CsSessionVo {
+ if (!data) return {}
+ if (typeof data === 'string') {
+ const value = data.trim()
+ return value ? { sessionId: value } : {}
+ }
+ const sessionId = data.sessionId ?? data.id
+ if (sessionId === undefined || sessionId === null || sessionId === '') {
+ return data
+ }
+ return { ...data, sessionId: String(sessionId) }
+}
+
+export function normalizeSessionId(session: CsSessionVo | string | null | undefined): string {
+ const normalized = normalizeSessionResponse(session)
+ const raw = normalized.sessionId
+ if (raw === undefined || raw === null || raw === '') return ''
+ return String(raw)
+}
+
+export function getSessionStatus(session: CsSessionVo): string {
+ return String(session.status ?? session.sessionStatus ?? '')
+}
+
+export function isSessionEnded(session: CsSessionVo): boolean {
+ if (session.ended === true || session.ended === 'true' || session.ended === '1') return true
+ const status = getSessionStatus(session)
+ return status === CS_SESSION_STATUS.closed || status === 'ended' || status === 'closed'
+}
+
+export function resolvePhaseFromSession(session: CsSessionVo): SupportPhase {
+ if (isSessionEnded(session)) return 'bot'
+ const status = getSessionStatus(session)
+ if (status === CS_SESSION_STATUS.queue || status === '1') return 'queue'
+ if (status === CS_SESSION_STATUS.human || status === '2') return 'human'
+ return 'bot'
+}
+
+export function getQueuePosition(session: CsSessionVo): number {
+ const value = session.queuePosition ?? session.queueNo
+ if (typeof value === 'number' && value > 0) return value
+ return 1
+}
+
+export function getQueueEstimate(session: CsSessionVo): string {
+ return session.estimateWaitTime?.trim() || '正在为您分配人工客服'
+}
+
+function formatMessageTime(value?: string): string | undefined {
+ if (!value) return undefined
+ const date = new Date(value)
+ if (Number.isNaN(date.getTime())) return undefined
+ return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
+}
+
+function resolveSenderRole(senderType?: string | number): ChatRole | null {
+ const value = String(senderType ?? '').toLowerCase()
+ if (!value) return 'bot'
+ if (value === '0' || value.includes('user') || value.includes('app')) return 'user'
+ if (value === '1' || value.includes('bot') || value.includes('robot')) return 'bot'
+ if (value === '2' || value.includes('agent') || value.includes('human')) return 'agent'
+ if (value === '3' || value.includes('system')) return 'system'
+ return 'bot'
+}
+
+function createMessageId(message: CsMessageVo): string {
+ if (message.messageId !== undefined && message.messageId !== null && message.messageId !== '') {
+ return String(message.messageId)
+ }
+ if (message.seq !== undefined && message.seq !== null) {
+ return `seq-${message.seq}`
+ }
+ return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
+}
+
+function mapStructuredMessage(message: CsMessageVo, payload: Record): SupportMessage | null {
+ const type = String(payload.type ?? message.messageType ?? '').toLowerCase()
+ const id = createMessageId(message)
+ const seq = message.seq
+
+ if (type === 'quick-actions' || type === 'quick_actions') {
+ const actions = Array.isArray(payload.actions) ? payload.actions : QUICK_ACTIONS
+ return {
+ id,
+ seq,
+ type: 'quick-actions',
+ actions: actions.map((item, index) => {
+ if (typeof item === 'string') return { id: `action-${index}`, label: item }
+ const record = item as { id?: string; label?: string }
+ return { id: record.id || `action-${index}`, label: record.label || '' }
+ }).filter((item) => item.label),
+ }
+ }
+
+ if (type === 'wifi-help' || type === 'wifi_help') {
+ const steps = Array.isArray(payload.steps) ? payload.steps.map(String) : []
+ return {
+ id,
+ seq,
+ type: 'wifi-help',
+ title: String(payload.title ?? 'Wi-Fi 连接失败 · 常见原因'),
+ steps,
+ }
+ }
+
+ if (type === 'diagnosis') {
+ const items = Array.isArray(payload.items) ? payload.items : []
+ return {
+ id,
+ seq,
+ type: 'diagnosis',
+ title: String(payload.title ?? '远程诊断中'),
+ estimateText: String(payload.estimateText ?? ''),
+ progress: Number(payload.progress ?? 0),
+ items: items.map((item, index) => {
+ const record = item as {
+ id?: string
+ icon?: string
+ label?: string
+ status?: 'done' | 'running' | 'pending'
+ }
+ return {
+ id: record.id || `diag-${index}`,
+ icon: record.icon || '•',
+ label: record.label || '',
+ status: record.status || 'pending',
+ }
+ }),
+ }
+ }
+
+ if (type === 'image') {
+ const src = String(payload.src ?? payload.url ?? '')
+ if (!src) return null
+ return {
+ id,
+ seq,
+ type: 'image',
+ role: 'user',
+ src,
+ time: formatMessageTime(message.createTime),
+ }
+ }
+
+ return null
+}
+
+export function mapCsMessage(message: CsMessageVo): SupportMessage | null {
+ const content = message.content?.trim() ?? ''
+ const messageType = String(message.messageType ?? '').toLowerCase()
+
+ if (messageType === 'image') {
+ if (!content) return null
+ return {
+ id: createMessageId(message),
+ seq: message.seq,
+ type: 'image',
+ role: 'user',
+ src: content,
+ time: formatMessageTime(message.createTime),
+ }
+ }
+
+ if (content.startsWith('{') && content.endsWith('}')) {
+ try {
+ const payload = JSON.parse(content) as Record
+ const structured = mapStructuredMessage(message, payload)
+ if (structured) return structured
+ } catch {
+ // ignore invalid json content
+ }
+ }
+
+ const role = resolveSenderRole(message.senderType)
+ if (!role || !content) return null
+
+ return {
+ id: createMessageId(message),
+ seq: message.seq,
+ type: 'text',
+ role,
+ content,
+ time: formatMessageTime(message.createTime),
+ }
+}
+
+export function mergeSupportMessages(
+ current: SupportMessage[],
+ incoming: SupportMessage[],
+): SupportMessage[] {
+ if (!incoming.length) return current
+ const map = new Map()
+ for (const item of current) map.set(item.id, item)
+ for (const item of incoming) map.set(item.id, item)
+ return Array.from(map.values()).sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0))
+}
+
+export function getMaxSeq(messages: SupportMessage[]): number {
+ return messages.reduce((max, item) => Math.max(max, item.seq ?? 0), 0)
+}
+
+export function buildQuickActionsMessage(): SupportMessage {
+ return {
+ id: 'local-quick-actions',
+ type: 'quick-actions',
+ actions: QUICK_ACTIONS,
+ }
+}
diff --git a/src/pages/mine/support/use-support.ts b/src/pages/mine/support/use-support.ts
new file mode 100644
index 0000000..afb5040
--- /dev/null
+++ b/src/pages/mine/support/use-support.ts
@@ -0,0 +1,489 @@
+import { onBeforeUnmount } from 'vue'
+import { onHide, onShow, onUnload } from '@dcloudio/uni-app'
+import { computed, ref } from 'vue'
+import { getErrorMessage } from '@/utils/api-error'
+import { getStoredToken } from '@/utils/auth-token'
+import {
+ clickCsButton,
+ endCsSession,
+ fetchCsMessages,
+ fetchCsSessionDetail,
+ resolveCsSession,
+ sendCsMessage,
+ transferCsSession,
+} from './support.api'
+import {
+ connectCsChatSocket,
+ disconnectCsChatSocket,
+ isCsChatSocketConnected,
+ onCsChatPush,
+} from './cs-chat-socket'
+import {
+ isSupportPageActive,
+ markSupportPageActive,
+ startSupportQueuePolling,
+ startSupportQueueWaitTimer,
+ stopSupportPollTimer,
+ stopSupportQueueTimer,
+} from './support-runtime'
+import type {
+ CsSessionVo,
+ QuickActionItem,
+ SupportHeaderInfo,
+ SupportMessage,
+ SupportPhase,
+} from './support.types'
+import {
+ buildQuickActionsMessage,
+ createClientMsgId,
+ getMaxSeq,
+ getQueueEstimate,
+ getQueuePosition,
+ mapCsMessage,
+ mergeSupportMessages,
+ normalizeSessionId,
+ resolvePhaseFromSession,
+ unwrapCsActionResponse,
+} from './support.utils'
+
+const BOT_HEADER: SupportHeaderInfo = {
+ name: '智能客服 · 小智',
+ badgeText: '在线',
+ badgeType: 'online',
+ avatarText: '智',
+ avatarShape: 'square',
+ serviceHours: '7×24 小时',
+}
+
+const HUMAN_HEADER: SupportHeaderInfo = {
+ name: '人工客服',
+ subtitle: '在线',
+ badgeText: '服务中',
+ badgeType: 'serving',
+ avatarText: '服',
+ avatarShape: 'square',
+}
+
+const FOOTER_SHORTCUTS = ['常见问题', '退改政策']
+
+const MESSAGE_SYNC_MIN_GAP_MS = 3000
+
+function formatWait(seconds: number): string {
+ return `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`
+}
+
+function buildHumanHeader(session: CsSessionVo): SupportHeaderInfo {
+ const name = session.agentName?.trim() || HUMAN_HEADER.name
+ const agentNo = session.agentNo?.trim()
+ return {
+ ...HUMAN_HEADER,
+ name,
+ subtitle: agentNo ? `工号 ${agentNo} · 在线` : HUMAN_HEADER.subtitle,
+ avatarText: name.slice(0, 1) || HUMAN_HEADER.avatarText,
+ }
+}
+
+export function useSupport() {
+ const phase = ref('bot')
+ const apiMessages = ref([])
+ const inputValue = ref('')
+ const queueWaitSeconds = ref(0)
+ const queuePosition = ref(1)
+ const queueEstimate = ref('正在为您分配人工客服')
+ const sessionId = ref('')
+ const humanHeader = ref(HUMAN_HEADER)
+ const sessionInitializing = ref(false)
+ const sending = ref(false)
+ const lastSeq = ref(0)
+
+ let unsubscribeChatPush: (() => void) | null = null
+ let ensureSessionPromise: Promise | null = null
+ let syncingMessages = false
+ let lastMessageSyncAt = 0
+
+ const headerInfo = computed(() =>
+ phase.value === 'human' ? humanHeader.value : BOT_HEADER,
+ )
+
+ const formattedQueueWait = computed(() => formatWait(queueWaitSeconds.value))
+
+ const showTransfer = computed(() => phase.value === 'bot')
+ const showEndSession = computed(() => phase.value === 'queue' || phase.value === 'human')
+
+ const messages = computed(() => {
+ const list = [...apiMessages.value]
+ const hasQuickActions = list.some((item) => item.type === 'quick-actions')
+ if (phase.value === 'bot' && !hasQuickActions) {
+ list.push(buildQuickActionsMessage())
+ }
+ if (phase.value === 'queue') {
+ list.push({
+ id: 'queue-card',
+ type: 'queue',
+ position: queuePosition.value,
+ estimateText: queueEstimate.value,
+ })
+ }
+ return list
+ })
+
+ function clearQueueTimer() {
+ stopSupportQueueTimer()
+ queueWaitSeconds.value = 0
+ }
+
+ function startQueueTimer() {
+ clearQueueTimer()
+ startSupportQueueWaitTimer(() => {
+ queueWaitSeconds.value += 1
+ })
+ }
+
+ function updateQueuePolling() {
+ if (!isSupportPageActive()) return
+ stopSupportPollTimer()
+ if (phase.value === 'queue') {
+ startSupportQueuePolling(() => {
+ void refreshSession()
+ })
+ }
+ }
+
+ function handleChatPush(payload: {
+ message?: Parameters[0]
+ session?: CsSessionVo
+ }) {
+ if (!isSupportPageActive()) return
+ if (payload.message) {
+ const mapped = mapCsMessage(payload.message)
+ if (mapped) {
+ apiMessages.value = mergeSupportMessages(apiMessages.value, [mapped])
+ lastSeq.value = getMaxSeq(apiMessages.value)
+ }
+ }
+ if (payload.session) {
+ applySessionState(payload.session)
+ }
+ }
+
+ function bindChatSocket(session: string) {
+ unsubscribeChatPush?.()
+ unsubscribeChatPush = onCsChatPush((payload) => {
+ handleChatPush(payload)
+ })
+ connectCsChatSocket(session)
+ }
+
+ function unbindChatSocket() {
+ unsubscribeChatPush?.()
+ unsubscribeChatPush = null
+ disconnectCsChatSocket()
+ }
+
+ function applyActionMessages(action: ReturnType) {
+ if (!action.messages?.length) return
+ const mapped = action.messages
+ .map(mapCsMessage)
+ .filter((item): item is SupportMessage => !!item)
+ if (!mapped.length) return
+ apiMessages.value = mergeSupportMessages(apiMessages.value, mapped)
+ lastSeq.value = getMaxSeq(apiMessages.value)
+ }
+
+ function applySessionState(session: CsSessionVo) {
+ const nextPhase = resolvePhaseFromSession(session)
+ queuePosition.value = getQueuePosition(session)
+ queueEstimate.value = getQueueEstimate(session)
+
+ if (nextPhase === 'human') {
+ humanHeader.value = buildHumanHeader(session)
+ phase.value = 'human'
+ clearQueueTimer()
+ updateQueuePolling()
+ return
+ }
+
+ if (nextPhase === 'queue') {
+ phase.value = 'queue'
+ if (queueWaitSeconds.value === 0) startQueueTimer()
+ updateQueuePolling()
+ return
+ }
+
+ phase.value = 'bot'
+ humanHeader.value = HUMAN_HEADER
+ clearQueueTimer()
+ updateQueuePolling()
+ }
+
+ async function syncMessages(reset = false) {
+ if (!isSupportPageActive() || !sessionId.value || syncingMessages) return
+ if (isCsChatSocketConnected() && !reset) return
+
+ const now = Date.now()
+ if (!reset && now - lastMessageSyncAt < MESSAGE_SYNC_MIN_GAP_MS) return
+
+ syncingMessages = true
+ try {
+ const afterSeq = reset ? 0 : lastSeq.value
+ const rows = await fetchCsMessages(sessionId.value, afterSeq)
+ const mapped = rows.map(mapCsMessage).filter((item): item is SupportMessage => !!item)
+ if (reset) {
+ apiMessages.value = mapped
+ } else {
+ apiMessages.value = mergeSupportMessages(apiMessages.value, mapped)
+ }
+ lastSeq.value = getMaxSeq(apiMessages.value)
+ lastMessageSyncAt = Date.now()
+ } catch (err) {
+ uni.showToast({ title: getErrorMessage(err, '消息同步失败'), icon: 'none' })
+ } finally {
+ syncingMessages = false
+ }
+ }
+
+ async function refreshSession() {
+ if (!isSupportPageActive() || !sessionId.value) return
+ try {
+ const session = await fetchCsSessionDetail(sessionId.value)
+ applySessionState(session)
+ } catch (err) {
+ uni.showToast({ title: getErrorMessage(err, '会话状态更新失败'), icon: 'none' })
+ }
+ }
+
+ function teardownPage() {
+ markSupportPageActive(false)
+ stopSupportPollTimer()
+ stopSupportQueueTimer()
+ unbindChatSocket()
+ clearQueueTimer()
+ }
+
+ async function ensureSession() {
+ if (ensureSessionPromise) return ensureSessionPromise
+
+ ensureSessionPromise = (async () => {
+ if (!getStoredToken()) {
+ uni.showToast({ title: '请先登录', icon: 'none' })
+ return
+ }
+
+ sessionInitializing.value = true
+ try {
+ const resolved = await resolveCsSession()
+ const session = resolved.session
+ const nextSessionId = normalizeSessionId(session)
+
+ const sessionChanged = sessionId.value !== nextSessionId
+ sessionId.value = nextSessionId
+ applySessionState(session)
+
+ if (sessionChanged) {
+ lastSeq.value = 0
+ if (resolved.messages?.length) {
+ applyActionMessages(resolved)
+ } else {
+ await syncMessages(true)
+ }
+ } else if (resolved.messages?.length) {
+ applyActionMessages(resolved)
+ } else {
+ await syncMessages()
+ }
+
+ bindChatSocket(nextSessionId)
+ updateQueuePolling()
+ } catch (err) {
+ uni.showToast({ title: getErrorMessage(err, '客服会话创建失败'), icon: 'none' })
+ } finally {
+ sessionInitializing.value = false
+ }
+ })()
+
+ try {
+ await ensureSessionPromise
+ } finally {
+ ensureSessionPromise = null
+ }
+ }
+
+ async function sendUserMessage(content: string) {
+ const text = content.trim()
+ if (!text || sending.value) return
+
+ if (!sessionId.value) {
+ await ensureSession()
+ if (!sessionId.value) {
+ uni.showToast({ title: '会话未就绪,请稍后重试', icon: 'none' })
+ return
+ }
+ }
+
+ sending.value = true
+ try {
+ const action = unwrapCsActionResponse(await sendCsMessage(sessionId.value, {
+ content: text,
+ msgType: 'text',
+ clientMsgId: createClientMsgId(),
+ }))
+ if (action.session) {
+ applySessionState(action.session)
+ }
+ if (action.messages?.length) {
+ applyActionMessages(action)
+ } else if (!isCsChatSocketConnected()) {
+ await syncMessages()
+ }
+ await refreshSession()
+ } catch (err) {
+ uni.showToast({ title: getErrorMessage(err, '发送失败'), icon: 'none' })
+ } finally {
+ sending.value = false
+ }
+ }
+
+ function onQuickAction(action: QuickActionItem) {
+ if (phase.value !== 'bot') return
+ void sendUserMessage(action.label)
+ }
+
+ async function onTransferHuman() {
+ if (!sessionId.value || phase.value !== 'bot' || sending.value) return
+
+ sending.value = true
+ try {
+ await transferCsSession(sessionId.value)
+ await refreshSession()
+ await syncMessages()
+ } catch (err) {
+ uni.showToast({ title: getErrorMessage(err, '转人工失败'), icon: 'none' })
+ } finally {
+ sending.value = false
+ }
+ }
+
+ async function restartSession() {
+ teardownPage()
+ sessionId.value = ''
+ apiMessages.value = []
+ lastSeq.value = 0
+ phase.value = 'bot'
+ markSupportPageActive(true)
+ await ensureSession()
+ }
+
+ async function onEndSession() {
+ if (!sessionId.value || sending.value) return
+ if (phase.value !== 'queue' && phase.value !== 'human') return
+
+ const confirmed = await new Promise((resolve) => {
+ uni.showModal({
+ title: '结束会话',
+ content: '确定结束本次客服会话?',
+ confirmText: '结束',
+ cancelText: '取消',
+ success: (res) => resolve(!!res.confirm),
+ fail: () => resolve(false),
+ })
+ })
+ if (!confirmed) return
+
+ sending.value = true
+ try {
+ await endCsSession(sessionId.value)
+ await restartSession()
+ } catch (err) {
+ uni.showToast({ title: getErrorMessage(err, '结束会话失败'), icon: 'none' })
+ } finally {
+ sending.value = false
+ }
+ }
+
+ async function onCancelQueue() {
+ if (!sessionId.value) return
+ sending.value = true
+ try {
+ await endCsSession(sessionId.value)
+ await restartSession()
+ } catch (err) {
+ uni.showToast({ title: getErrorMessage(err, '取消排队失败'), icon: 'none' })
+ } finally {
+ sending.value = false
+ }
+ }
+
+ async function onResolveWithoutHuman() {
+ if (!sessionId.value) return
+ sending.value = true
+ try {
+ await clickCsButton(sessionId.value, { buttonCode: 'resolved' })
+ clearQueueTimer()
+ await refreshSession()
+ await syncMessages()
+ } catch (err) {
+ uni.showToast({ title: getErrorMessage(err, '操作失败'), icon: 'none' })
+ } finally {
+ sending.value = false
+ }
+ }
+
+ function onFooterShortcut(label: string) {
+ void sendUserMessage(label)
+ }
+
+ function onDiagnosisSuggestion(label: string) {
+ void sendUserMessage(label)
+ }
+
+ function onSend() {
+ const text = inputValue.value.trim()
+ if (!text) return
+ inputValue.value = ''
+
+ if (/人工|转人工/.test(text) && phase.value === 'bot') {
+ void onTransferHuman()
+ return
+ }
+
+ void sendUserMessage(text)
+ }
+
+ function activatePage() {
+ markSupportPageActive(true)
+ if (!sessionId.value) {
+ void ensureSession()
+ return
+ }
+ bindChatSocket(sessionId.value)
+ updateQueuePolling()
+ if (!isCsChatSocketConnected()) {
+ void syncMessages()
+ }
+ }
+
+ onShow(activatePage)
+ onHide(teardownPage)
+ onUnload(teardownPage)
+ onBeforeUnmount(teardownPage)
+
+ return {
+ phase,
+ messages,
+ inputValue,
+ sessionId,
+ headerInfo,
+ showTransfer,
+ showEndSession,
+ formattedQueueWait,
+ footerShortcuts: FOOTER_SHORTCUTS,
+ onQuickAction,
+ onTransferHuman,
+ onEndSession,
+ onCancelQueue,
+ onResolveWithoutHuman,
+ onFooterShortcut,
+ onDiagnosisSuggestion,
+ onSend,
+ }
+}
diff --git a/src/pages/mine/use-mine.ts b/src/pages/mine/use-mine.ts
new file mode 100644
index 0000000..a8e32e2
--- /dev/null
+++ b/src/pages/mine/use-mine.ts
@@ -0,0 +1,132 @@
+import { onShow } from '@dcloudio/uni-app'
+import { computed, ref } from 'vue'
+import { getErrorMessage } from '@/utils/api-error'
+import { staticAssets } from '@/utils/static-assets'
+import { fetchMineOverview } from './mine.api'
+import type { MineMenuItem, MineQuickAction } from './mine.types'
+
+const APP_VERSION = 'v3.2.0'
+
+const QUICK_ACTIONS: MineQuickAction[] = [
+ { id: 'notify', label: '消息通知', icon: staticAssets.iconMineNotify },
+ { id: 'order', label: '我的订单', icon: staticAssets.iconMineOrder },
+ { id: 'coupon', label: '优惠券', icon: staticAssets.iconMineCoupon },
+ { id: 'address', label: '收货地址', icon: staticAssets.iconMineAddress },
+]
+
+const MENU_ITEMS: MineMenuItem[] = [
+ { id: 'devices', title: '我的设备' },
+ { id: 'members', title: '家庭成员' },
+ { id: 'scene', title: '场景与自动化' },
+ { id: 'service', title: '售后服务' },
+ { id: 'support', title: '在线客服' },
+ { id: 'feedback', title: '帮助与反馈' },
+ { id: 'settings', title: '通用设置' },
+ { id: 'privacy', title: '隐私与安全' },
+ { id: 'about', title: '关于我们', value: APP_VERSION },
+]
+
+export function useMine() {
+ const loading = ref(false)
+ const nickName = ref('这里是昵称')
+ const phone = ref('')
+ const avatarUrl = ref('')
+ const deviceCount = ref(0)
+ const memberCount = ref(0)
+ const warrantyCount = ref(0)
+ const sceneCount = ref(0)
+
+ const statusBarHeight = ref(0)
+
+ const stats = computed(() => [
+ { label: '我的设备', value: deviceCount.value },
+ { label: '家庭成员', value: memberCount.value },
+ { label: '保修中', value: warrantyCount.value },
+ ])
+
+ const menuList = computed(() =>
+ MENU_ITEMS.map((item) => {
+ if (item.id === 'devices') {
+ return { ...item, value: `已绑定 ${deviceCount.value} 台` }
+ }
+ if (item.id === 'members') {
+ return { ...item, value: `${memberCount.value} 位成员` }
+ }
+ if (item.id === 'scene') {
+ return { ...item, value: `${sceneCount.value} 个场景` }
+ }
+ if (item.id === 'service') {
+ return { ...item, value: '报修 / 退换' }
+ }
+ return item
+ }),
+ )
+
+ async function loadOverview() {
+ if (loading.value) return
+ loading.value = true
+ try {
+ const { profile, deviceCount: count } = await fetchMineOverview()
+ deviceCount.value = count
+ nickName.value = profile?.nickName || profile?.userName || '这里是昵称'
+ phone.value = profile?.phonenumber || profile?.userName || ''
+ avatarUrl.value = profile?.avatarUrl || ''
+ } catch (err) {
+ uni.showToast({ title: getErrorMessage(err, '加载资料失败'), icon: 'none' })
+ } finally {
+ loading.value = false
+ }
+ }
+
+ function onEditNickname() {
+ uni.navigateTo({ url: '/pages/mine/edit/index' })
+ }
+
+ function onQuickAction(action: MineQuickAction) {
+ if (action.id === 'notify') {
+ uni.navigateTo({ url: '/pages/mine/notify/index' })
+ return
+ }
+ uni.showToast({ title: `${action.label}功能开发中`, icon: 'none' })
+ }
+
+ function onMenuTap(item: MineMenuItem) {
+ if (item.id === 'devices') {
+ uni.reLaunch({ url: '/pages/device/index' })
+ return
+ }
+ if (item.id === 'support') {
+ uni.navigateTo({ url: '/pages/mine/support/index' })
+ return
+ }
+ if (item.id === 'settings') {
+ uni.navigateTo({ url: '/pages/mine/storage/index' })
+ return
+ }
+ if (item.id === 'privacy') {
+ uni.navigateTo({ url: '/pages/mine/privacy/index' })
+ return
+ }
+ if (item.id === 'about') return
+ uni.showToast({ title: `${item.title}功能开发中`, icon: 'none' })
+ }
+
+ onShow(() => {
+ const sys = uni.getSystemInfoSync()
+ statusBarHeight.value = sys.statusBarHeight ?? 0
+ void loadOverview()
+ })
+
+ return {
+ statusBarHeight,
+ nickName,
+ phone,
+ avatarUrl,
+ stats,
+ quickActions: QUICK_ACTIONS,
+ menuList,
+ onEditNickname,
+ onQuickAction,
+ onMenuTap,
+ }
+}
diff --git a/src/static/chevron.left.png b/src/static/chevron.left.png
new file mode 100644
index 0000000000000000000000000000000000000000..56164e72796d3db87f62e696fe576d064fe44897
GIT binary patch
literal 509
zcmVPx#1am@3R0s$N2z&@+hyVZqr%6OXR49>!lCf)3Q51*2^Ik>blv4)q_B-CPvJ#oe~CEt##G7R4ppBoG4Pa3hE&LEmxkMM8avce?lE
z_npJN_r%V*r>go~RYxI&xBO?t<#PF%h%5s)RP}TSp|wy@)q#lI0wUm=s%i+~^;}^%
z93HFcM_>~;1+1!S5<=JmvXg>LH))!#nb|AgOpcn9$>dJI-~Ul4066Eam|0Uq&gSTF
zG#cF=3+pv5n0XA`)H9+DwTeU$m}ac@w{-QQn?@^`@mKHT>ev*
zWt&SyVdq=|8~_({bPTNbdcDv82uYG$0$RZNyz-HmZFIZcA;8j!%9qV-A6U-O&UifD
zD*U)KO|P5T&U7@)>_w;3*#>^i7L5REwc3`M?as{6lcF#{^8@ew{i5Js!OWV#w=B!Px#1am@3R0s$N2z&@+hyVZr-$_J4RA_C_x3Zfw>x`Jc{A4EkD
zO7QsysK-?d-o%?H7g00^qaKVN6+A>i4TxwW2!eu-paevMf+1oQ(TIWvAz38mwI1w|
zvUg^>W*^yA=7T0RQ}tC>&rDZWWg$k47%|F;84i6T&!;&;z`SO0EIg
zfk_}+<}m@BiShuefqNAYxIManDmz%ePv`(}QCvR&o<@CzMM5*MLtO6xI$fk*vFcN$
zjZcMk5zl=M;fEm1E`5AX)Qfn&+e*JrDC^Ld1A#H+Y>Iyk@d6BzzyL4^tPZhjGKW$>
zu((Kb=RoFOEz*MX5oqwSX)rp^?og>>lE!P56C{C*;PnD^LSJX4=M1o1>3dxv3&;Ww
zm6PoPrUdW0&~=LYlfX`ezZJk^pLm>j>q8m_HY&Jji+2P#YTY}m@Uz8wquvRzPdMyR*l0&8z}HJ*v)dJ%<{Y>q!#2FAtH7z*hRT8g-d=Kx=X-A|E5muMF7
z-e?(f8z!Cc^;Jzsqza!tvQH>mDOinF~v=Nx$$2vAS2{?(-=z-<_mem
zR{AYK9^0Dksnj?o!ZQBE^u!dHF=E7sQGxgcWgC=}w(Zg900000NkvXXu0mjfIksx-
literal 0
HcmV?d00001
diff --git a/src/static/图标 13.png b/src/static/图标 13.png
new file mode 100644
index 0000000000000000000000000000000000000000..f5c0c00f14f57e41aa9556a4c3763072d0e44aba
GIT binary patch
literal 642
zcmV-|0)737P)Px#1am@3R0s$N2z&@+hyVZrEJ;K`R5*>bls{}zQ543%?*f6Tm7&9%T-wFeiF9&h
z=!hoPZsk5rApQx1g96&bNo^qb-i6WB#Fc?T6Lst6Xf#eNg``6T(}WkoJr45lX`|AH
zp6Q)?-u>Nszk9y}oI`M)pNuifi9}*)JRT3V)`K%cecxYGN)<$81Hi>dsY|kLt*yJR
z`*mI@jWJabxleK=A{~a#=
zmjjRk@WER99l(14xqiQYwNxq{PZzI>NXlAUpKUNI%)%WN<_q_JIJW36HH$^}(-U#p
c&VMfP176oInNvnR8vpPx#1am@3R0s$N2z&@+hyVZqHAzH4R5*>z(!WbpVHgK+1yhHXhD;C~95nv}g@$kw
z{1ep8rNu?Ja4Ix%lJW#b-I^eSUn-
z`@Wo(mWL2%!x+}F*eEiL5BTMOY~2SKz&w6r_%EsjZ1gsu8`C(*z!i4!3gzUgI6+%I
z;4vof1s55pU>3dUd7FG0XIQR9hVTi$)A&8ddS27FlzIu1c^|IDRttBrjE*|Hepi56
z@fkns9j`rKs$}38b9kEi`{Y*wb>$isZoBvqY-jikdqpTc)5#6qh!xPm02lcB4@zwx
zM|Xm1&=UUOS=N2o)l4H;KQ8em>(}|`dhU${*u*J1@eMl-LBU?;Xdm;OY8VT680Z{N
pn#IP0Z8QZ1dxSN-Xd1gqTmvb2+m2eIE4Tmv002ovPDHLkV1fv3rdR*~
literal 0
HcmV?d00001
diff --git a/src/static/组 134.png b/src/static/组 134.png
new file mode 100644
index 0000000000000000000000000000000000000000..f63b33fa69ae146bbe0881eb85a53281b9fb997e
GIT binary patch
literal 617
zcmV-v0+#)WP)Px#1am@3R0s$N2z&@+hyVZr6G=otR9J=Wl|N_`Q5?rVzjv2G&?pK;=pt?sS~n?z
zIH`y_ISDqQm4Y1-6x6gXPSPeF8j^-iwnA3fM-{0qscfW&(h=_>ZBL5mNHGlVj@9Zf6+puesS4ZCTOekNdoX0au
zzzzUKzo9am%_LX0n`ySYadvibk9cMQ;i=N>S7QAF&&0?=vA7)Xx=_>jLke-ko6M#!
z)wAgfb;L}fy8Al}bzKOd55P6;r>$Ggx{ct(e=(hlqs!L@1$|Vl9)KTRQQ#_4#7u
zWWH2QcT7lyF>#tA7+{+KCg%2Tt;Y=ef>aY6ZZRp4u`oUSFvTeEfPtX9u4DO1%bU7A
zyEN+i4YA0SfiF#G*MII91m1>e5$X+z(MJdaxpM8$_3`8fkQBo4n6Gsq_FmwaQ&KlG
zqaUZ|DhVI~@~~qm|}qGQuVY!R#AQF#2cANUwrlJ`b?oZsGcke92PipEuCEJs1U)|b`D`#Ar8Dy
zmFJ*FgrmR_L_RyG!xNe0YS`>;|LteyYkkgg1vo2k5CsgcRe4~13c(rc5&2^ENxoEj
z+>WgOZc>MvL%&5@fVLy4EkaJ^mLRSjSrHKt5xwRIFAK4(WL{}700000NkvXXu0mjf
DQ1KGL
literal 0
HcmV?d00001
diff --git a/src/static/组 14919@2x.png b/src/static/组 14919@2x.png
new file mode 100644
index 0000000000000000000000000000000000000000..476deaaa9fd8b15faca164d83a6e03ec4717f951
GIT binary patch
literal 1113
zcmV-f1g86mP)Px#1am@3R0s$N2z&@+hyVZt14%?dRA_Wf;eQbJIE9=3I*z*_a+==86$P
zW(_=OT00RL<`9q4z-Y<@JE*9L4n`1tAPN;8>49Ppc%UHYnww6vxgwQBjQnV>oYRfk
z;$7R*$^V7Rhv$8tulId6aDRAs@9n;B)*ecku<@xTmb5DNu4(o+Sz2s{te0FMI;
zfpYba0QZ1Dfa}1IKo4*?NApL*a^Pd27nlT`kk2G=9{2!QmK5m%;C0|!1j2sL8DL!$
zLP4<-*aJN0(bo@j0e!%)!0*7Hz>xYYfoh-@cuG1NfX6&^odw)w^I7HmnDXI5-=oyYlJ
zLhVNASn?dRq5wE$`0cPA0JDJsc}(Of`+Ssc1h!~w9E=0s0*8I%Rw@7lxC%UJI~&dF
zx&lr-=(?}tJOcsev=>Z0z$|q%8KFY~m3{;;SqI!B#e)v9sjjm)V1-ubeS&Q)xj&>#=C;(nlHfUek9bk1_
zr*OxL?+m`7tzND`@ZVto1`*K{q$Yu7m
zSS9gI`g=kV>bQcp5})y-z+UMX3_59$1^C7HWx%_t>e=hIP+@j9j4RJQsKkCG0G|gC
zX9tRaH-V4R>76j9r_v>#Qto|S`D~9IWoXiF5>fCf@VV-iX-oq9f%jBpC{*@TD3brg
zAsPp}%+0V@p4ok4bcXSgN#mU?so_;%{Zte5Lz@Tjdpfjni&}}m*5_0DVmXx`JtCnB
f*pP5Q_i*5UtOa7eA%*(&00000NkvXXu0mjf0_p{7
literal 0
HcmV?d00001
diff --git a/src/static/组 14923@2x.png b/src/static/组 14923@2x.png
new file mode 100644
index 0000000000000000000000000000000000000000..a4e01077a912c7ce1ea2bd34e417256bd60eabc8
GIT binary patch
literal 865
zcmV-n1D^beP)Px#1am@3R0s$N2z&@+hyVZs3rR#lRA_s9PS``r#Bm~u>auKeIFoFt%2#LNdA;ZebG^@>-I9~62
zbML&F_lo8R7w+79=FI=hy>q_?Qc_Z)Py?I+UI4!ekiG#!KzkhGR{^615^hVEfd%;l
zFjPeGLHg4YzL7Kl57l)K&<}ji=Z!e94CnB=KyMev%suz!yvG>aC+wv8bAi<0LBf&*Fb9)rx~D6
z)JJZKz%ZBwP6CT_IBiz;SCi8YE+4V38~|23INhS|xgEdeIaphu#9}xMY)@EB
zY}h8U*&^tOjI9N1lmkr``+Al4nq1<-HGn~2Lx{~}40^nASqqgq4v|TZLRMT_U71SWF$odR0&4dA%r
zd1be!_$uIygWm~hrAPtP%3ILKi49WMB(_1_2cXg;fWwBMW3uNi0}je+IH>E38bF6C
ze=l!Mvs`9i-3nVBlMcSuqNm%~!j=O+<@$>)fZ%7vbyar4=;t=Vz7LnQ+2Xg(u&EY3
zpMkExNDreez)s+zDw|gMY#W@~gEj
rPrn&ijtoc_os^(DJ?E8_l>d_7`}dSzzRRjK00000NkvXXu0mjfNaKWv
literal 0
HcmV?d00001
diff --git a/src/static/订单@2x.png b/src/static/订单@2x.png
new file mode 100644
index 0000000000000000000000000000000000000000..8d176c26f6820c2e97961e0ccd6291da973f1ff0
GIT binary patch
literal 445
zcmV;u0Yd(XP)Px#1am@3R0s$N2z&@+hyVZqXGugsRA_R@@xH1qOuS2PU(*}rtl-|HvROkp;l%9kI2Z8t-YcVNWnPCXsQP^hp99vo0c+fVHEzI~sWvbS;+XuMXjkGwpl>mV4(*Ur(9K00000NkvXXu0mjfeAT=p
literal 0
HcmV?d00001
diff --git a/src/types/location.ts b/src/types/location.ts
new file mode 100644
index 0000000..8106fc3
--- /dev/null
+++ b/src/types/location.ts
@@ -0,0 +1,38 @@
+/** GET /app/location/list/{homeId} 返回项 */
+export interface AppLocationVo {
+ id?: string | number
+ /** 部分接口返回 locationId 而非 id */
+ locationId?: string | number
+ homeId?: string | number
+ locationName?: string
+ sortOrder?: number
+ icon?: string
+ status?: string
+ createTime?: string
+}
+
+/** GET /app/home/list 返回项 */
+export interface AppHomeVo {
+ id?: string | number
+ homeName?: string
+ isDefault?: string | number | boolean
+ locationCount?: number
+ createTime?: string
+}
+
+/** 房间位置(由接口数据映射) */
+export interface RoomOption {
+ locationId: string | number
+ name: string
+ homeId?: string | number
+}
+
+export function mapAppLocationVo(item: AppLocationVo): RoomOption | null {
+ const locationId = item.id ?? item.locationId
+ if (locationId == null || !item.locationName) return null
+ return {
+ locationId,
+ name: item.locationName,
+ homeId: item.homeId,
+ }
+}
diff --git a/src/types/message.ts b/src/types/message.ts
new file mode 100644
index 0000000..a7608cc
--- /dev/null
+++ b/src/types/message.ts
@@ -0,0 +1,38 @@
+/** WebSocket 推送消息体 */
+export interface MessagePushPayload {
+ messageId?: number | string
+ alertId?: number | string
+ alertType?: string
+ title?: string
+ content?: string
+ deviceId?: number | string
+ timestamp?: string
+}
+
+/** GET /app/message/list 列表项 */
+export interface AppMessageVo {
+ messageId?: number | string
+ alertId?: number | string
+ alertType?: string
+ title?: string
+ content?: string
+ deviceId?: number | string
+ timestamp?: string
+ createTime?: string
+ readFlag?: string | number
+ homeName?: string
+}
+
+export interface MessageListQuery {
+ pageNum?: number
+ pageSize?: number
+ orderByColumn?: string
+ isAsc?: string
+ /** 不传-全部,0-未读 */
+ readFlag?: string
+}
+
+export interface MessageTableData {
+ rows?: T[]
+ total?: number
+}
diff --git a/src/types/profile.ts b/src/types/profile.ts
new file mode 100644
index 0000000..2d4a319
--- /dev/null
+++ b/src/types/profile.ts
@@ -0,0 +1,36 @@
+/** POST /app/profile/update 请求体 */
+export interface UpdateProfileBo {
+ nickName?: string
+ avatarOssId?: number | string
+ avatarUrl?: string
+ gender?: string
+ birthday?: string
+ province?: string
+ city?: string
+ district?: string
+}
+
+/** POST /app/profile/avatar 返回 data */
+export interface AvatarUploadVo {
+ url?: string
+ fileName?: string
+ ossId?: number | string
+}
+
+/** GET /app/profile 返回 data */
+export interface AppUserProfileVo {
+ userId?: number | string
+ userName?: string
+ userNo?: string
+ phonenumber?: string
+ nickName?: string
+ avatarOssId?: number | string
+ avatarUrl?: string
+ gender?: string
+ birthday?: string
+ province?: string
+ city?: string
+ district?: string
+ remark?: string
+ createTime?: string
+}
diff --git a/src/utils/bluetooth.ts b/src/utils/bluetooth.ts
index b90d1ad..014225f 100644
--- a/src/utils/bluetooth.ts
+++ b/src/utils/bluetooth.ts
@@ -162,6 +162,34 @@ export function rssiToSignalLevel(rssi?: number): string {
return '0'
}
+export function isBleAlreadyConnectedError(err: unknown): boolean {
+ if (err instanceof BluetoothError) {
+ if (err.errCode === 10010) return true
+ const msg = err.message.toLowerCase()
+ return msg.includes('already connect') || msg.includes('已连接')
+ }
+
+ const fail = err as UniFail
+ if (fail?.errCode === 10010) return true
+ const msg = String(fail?.errMsg || '').toLowerCase()
+ return msg.includes('already connect') || msg.includes('已连接')
+}
+
+function delay(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms))
+}
+
+/** 建立 BLE 连接;若设备已连接则视为成功 */
+export async function ensureBleConnection(bleDeviceId: string): Promise {
+ try {
+ await createBleConnection(bleDeviceId)
+ } catch (err) {
+ if (!isBleAlreadyConnectedError(err)) throw err
+ }
+ // Android / 微信小程序连接后 GATT 表需短暂就绪
+ await delay(400)
+}
+
export function createBleConnection(bleDeviceId: string): Promise {
if (typeof uni.createBLEConnection !== 'function') {
return Promise.reject(new BluetoothError('当前环境不支持蓝牙连接'))
@@ -202,14 +230,28 @@ export function subscribeBleConnectionStateChange(
}
}
-/** 扫描并匹配指定 MAC 的 BLE 设备 */
+function deviceMatchesName(
+ device: UniApp.BluetoothDeviceInfo,
+ name: string,
+): boolean {
+ const target = name.trim().toLowerCase()
+ if (!target) return false
+ const candidates = [device.name, device.localName]
+ .filter(Boolean)
+ .map((item) => String(item).trim().toLowerCase())
+ return candidates.some((item) => item === target || item.includes(target) || target.includes(item))
+}
+
+/** 扫描并匹配 MAC 或广播名的 BLE 设备(iOS 需依赖名称) */
export async function scanForBleDeviceByMac(
mac: string,
- options: { timeoutMs?: number } = {},
+ options: { timeoutMs?: number; name?: string } = {},
): Promise {
const timeoutMs = options.timeoutMs ?? 15000
const normalizedMac = normalizeMacAddress(mac)
- if (!normalizedMac) return null
+ const targetName = options.name?.trim()
+
+ if (!normalizedMac && !targetName) return null
await ensureBluetoothReady()
@@ -217,7 +259,11 @@ export async function scanForBleDeviceByMac(
const pickMatch = (devices: UniApp.BluetoothDeviceInfo[]) => {
for (const device of devices) {
- if (deviceMatchesMac(device, normalizedMac)) {
+ if (normalizedMac && deviceMatchesMac(device, normalizedMac)) {
+ matched = device
+ return true
+ }
+ if (targetName && deviceMatchesName(device, targetName)) {
matched = device
return true
}
@@ -253,6 +299,78 @@ export async function scanForBleDeviceByMac(
return matched
}
+function deviceMatchesNameStrict(
+ device: UniApp.BluetoothDeviceInfo,
+ name: string,
+): boolean {
+ const target = name.trim().toLowerCase()
+ if (!target) return false
+ const candidates = [device.name, device.localName]
+ .filter(Boolean)
+ .map((item) => String(item).trim().toLowerCase())
+ return candidates.some((item) => item === target)
+}
+
+/** 短时扫描附近全部 BLE 设备(默认仅收集本次扫描窗口内发现的设备,避免系统缓存误判在线) */
+export async function scanBluetoothDevices(
+ options: { timeoutMs?: number; includeCached?: boolean } = {},
+): Promise {
+ const timeoutMs = options.timeoutMs ?? 5000
+ const collected = new Map()
+
+ const merge = (devices: UniApp.BluetoothDeviceInfo[]) => {
+ for (const device of devices) {
+ collected.set(device.deviceId, device)
+ }
+ }
+
+ await ensureBluetoothReady()
+ await stopBluetoothDiscovery().catch(() => {})
+
+ if (options.includeCached) {
+ merge(await getDiscoveredBluetoothDevices())
+ }
+
+ const unsubscribe = subscribeBluetoothDevicesFound(merge)
+ try {
+ await startBluetoothDiscovery()
+ await delay(timeoutMs)
+ } finally {
+ unsubscribe()
+ await stopBluetoothDiscovery().catch(() => {})
+ }
+
+ return [...collected.values()]
+}
+
+/** 在扫描结果中匹配设备(有 MAC 时仅按 MAC 匹配) */
+export function findDeviceInBleScan(
+ target: { mac?: string; name?: string },
+ scanned: UniApp.BluetoothDeviceInfo[],
+ options: { strictName?: boolean } = { strictName: true },
+): UniApp.BluetoothDeviceInfo | null {
+ const normalizedMac = target.mac ? normalizeMacAddress(target.mac) : undefined
+ const targetName = target.name?.trim()
+
+ if (normalizedMac) {
+ for (const device of scanned) {
+ if (deviceMatchesMac(device, normalizedMac)) return device
+ }
+ return null
+ }
+
+ if (!targetName) return null
+
+ for (const device of scanned) {
+ if (options.strictName) {
+ if (deviceMatchesNameStrict(device, targetName)) return device
+ } else if (deviceMatchesName(device, targetName)) {
+ return device
+ }
+ }
+ return null
+}
+
export function getBluetoothErrorMessage(err: unknown): string {
if (err instanceof BluetoothError) return err.message
@@ -275,6 +393,9 @@ export function getBluetoothErrorMessage(err: unknown): string {
case 10007:
return '当前特征值不支持此操作'
case 10008:
+ if (msg && /no descriptor/i.test(msg)) {
+ return '蓝牙通知特征值不可用,请确认设备 Notify 已配置'
+ }
return '蓝牙操作失败,请重试'
case 10009:
return '本机不支持低功耗蓝牙'
diff --git a/src/utils/device-id.ts b/src/utils/device-id.ts
new file mode 100644
index 0000000..184fb0c
--- /dev/null
+++ b/src/utils/device-id.ts
@@ -0,0 +1,20 @@
+/** 从路由参数等解析 ID,避免 Number() 导致 int64 精度丢失 */
+export function parseId(raw: unknown): string {
+ if (raw === undefined || raw === null || raw === '') return ''
+ const id = String(raw).trim()
+ return id === '0' ? '' : id
+}
+
+/** 从路由参数等解析设备 ID */
+export function parseDeviceId(raw: unknown): string {
+ return parseId(raw)
+}
+
+export function isValidDeviceId(id: string): boolean {
+ return /^\d+$/.test(id)
+}
+
+export function sameId(a: unknown, b: unknown): boolean {
+ if (a == null || b == null || a === '' || b === '') return false
+ return String(a) === String(b)
+}
diff --git a/src/utils/map-geocode.ts b/src/utils/map-geocode.ts
new file mode 100644
index 0000000..de772f6
--- /dev/null
+++ b/src/utils/map-geocode.ts
@@ -0,0 +1,42 @@
+const TENCENT_MAP_KEY = import.meta.env.VITE_TENCENT_MAP_KEY as string | undefined
+
+interface TencentGeocodeResponse {
+ status?: number
+ message?: string
+ result?: {
+ address?: string
+ formatted_addresses?: {
+ recommend?: string
+ }
+ }
+}
+
+/** 逆地理编码(需配置 VITE_TENCENT_MAP_KEY) */
+export async function reverseGeocode(
+ latitude: number,
+ longitude: number,
+): Promise {
+ if (!TENCENT_MAP_KEY) return undefined
+
+ try {
+ const res = await uni.request({
+ url: 'https://apis.map.qq.com/ws/geocoder/v1/',
+ method: 'GET',
+ data: {
+ location: `${latitude},${longitude}`,
+ key: TENCENT_MAP_KEY,
+ get_poi: 0,
+ },
+ })
+ const data = res.data as TencentGeocodeResponse
+ if (data.status !== 0) return undefined
+ return data.result?.formatted_addresses?.recommend
+ || data.result?.address
+ } catch {
+ return undefined
+ }
+}
+
+export function formatCoordinateAddress(latitude: number, longitude: number): string {
+ return `纬度 ${latitude.toFixed(4)},经度 ${longitude.toFixed(4)}`
+}
diff --git a/src/utils/message-socket.ts b/src/utils/message-socket.ts
new file mode 100644
index 0000000..ee4f977
--- /dev/null
+++ b/src/utils/message-socket.ts
@@ -0,0 +1,128 @@
+import { getStoredToken } from '@/utils/auth-token'
+import type { MessagePushPayload } from '@/types/message'
+
+const HEARTBEAT_INTERVAL_MS = 25000
+const RECONNECT_DELAY_MS = 25000
+
+let socketTask: UniApp.SocketTask | null = null
+let heartbeatTimer: ReturnType | null = null
+let reconnectTimer: ReturnType | null = null
+let connecting = false
+let manualClosed = false
+
+const listeners = new Set<(payload: MessagePushPayload) => void>()
+
+function resolveMessageWsBase(): string {
+ const configured = import.meta.env.VITE_MESSAGE_WS_URL?.trim()
+ if (configured) {
+ return configured.replace(/[?#].*$/, '').replace(/\/$/, '')
+ }
+
+ const envBase = import.meta.env.VITE_API_BASE_URL?.trim() || ''
+ const fallback = import.meta.env.VITE_API_PROXY_TARGET?.trim() || 'http://192.168.0.222:8082'
+ const httpBase = envBase || fallback
+ const wsBase = httpBase.replace(/^https/i, 'wss').replace(/^http/i, 'ws')
+ return `${wsBase}/app/ws/message`
+}
+
+function resolveMessageWsUrl(token: string): string {
+ const base = resolveMessageWsBase()
+ return `${base}?token=${encodeURIComponent(token)}`
+}
+
+function stopHeartbeat() {
+ if (heartbeatTimer) clearInterval(heartbeatTimer)
+ heartbeatTimer = null
+}
+
+function startHeartbeat() {
+ stopHeartbeat()
+ heartbeatTimer = setInterval(() => {
+ socketTask?.send({ data: 'ping' })
+ }, HEARTBEAT_INTERVAL_MS)
+}
+
+function clearReconnectTimer() {
+ if (reconnectTimer) clearTimeout(reconnectTimer)
+ reconnectTimer = null
+}
+
+function scheduleReconnect() {
+ if (manualClosed || !getStoredToken()) return
+ clearReconnectTimer()
+ reconnectTimer = setTimeout(() => {
+ connectMessageSocket()
+ }, RECONNECT_DELAY_MS)
+}
+
+function cleanupSocketTask() {
+ stopHeartbeat()
+ socketTask = null
+ connecting = false
+}
+
+function handleSocketMessage(raw: string) {
+ if (!raw || raw === 'ping' || raw === 'pong') return
+ try {
+ const data = JSON.parse(raw) as MessagePushPayload
+ listeners.forEach((listener) => listener(data))
+ if (data.title) {
+ uni.showToast({ title: data.title, icon: 'none' })
+ }
+ } catch {
+ // ignore invalid payload
+ }
+}
+
+export function onMessagePush(listener: (payload: MessagePushPayload) => void) {
+ listeners.add(listener)
+ return () => listeners.delete(listener)
+}
+
+export function connectMessageSocket() {
+ const token = getStoredToken()
+ if (!token || connecting || socketTask) return
+
+ manualClosed = false
+ connecting = true
+ clearReconnectTimer()
+
+ const task = uni.connectSocket({
+ url: resolveMessageWsUrl(token),
+ complete: () => {
+ connecting = false
+ },
+ })
+
+ socketTask = task
+
+ task.onOpen(() => {
+ startHeartbeat()
+ })
+
+ task.onMessage((res) => {
+ const raw = typeof res.data === 'string' ? res.data : ''
+ handleSocketMessage(raw)
+ })
+
+ task.onClose(() => {
+ cleanupSocketTask()
+ scheduleReconnect()
+ })
+
+ task.onError(() => {
+ cleanupSocketTask()
+ scheduleReconnect()
+ })
+}
+
+export function disconnectMessageSocket() {
+ manualClosed = true
+ clearReconnectTimer()
+ stopHeartbeat()
+ if (socketTask) {
+ socketTask.close({})
+ }
+ socketTask = null
+ connecting = false
+}
diff --git a/src/utils/static-assets.ts b/src/utils/static-assets.ts
index a8f1bda..cec7f53 100644
--- a/src/utils/static-assets.ts
+++ b/src/utils/static-assets.ts
@@ -25,6 +25,14 @@ import tabDevice from '@/static/tab-device-active.png'
import tabDeviceActive from '@/static/tab-device.png'
import tabMine from '@/static/tab-mine-active.png'
import tabMineActive from '@/static/tab-mine.png'
+import iconChevronLeft from '@/static/chevron.left.png'
+import iconSupportMic from '@/static/组 134.png'
+import iconSupportTag from '@/static/图标 13.png'
+import iconSupportSend from '@/static/图标 14.png'
+import iconMineNotify from '@/static/组 14919@2x.png'
+import iconMineOrder from '@/static/订单@2x.png'
+import iconMineCoupon from '@/static/优惠券@2x.png'
+import iconMineAddress from '@/static/组 14923@2x.png'
export const staticAssets = {
/** 组 15106@2x.png — 登录区中心大图标 */
@@ -75,4 +83,20 @@ export const staticAssets = {
tabDeviceActive,
tabMine,
tabMineActive,
+ /** chevron.left.png — 列表右箭头(旋转 180° 使用) */
+ iconChevronLeft,
+ /** 组 134.png — 在线客服页麦克风 */
+ iconSupportMic,
+ /** 图标 13.png — 在线客服页输入框旁标签 */
+ iconSupportTag,
+ /** 图标 14.png — 在线客服页发送 */
+ iconSupportSend,
+ /** 组 14919@2x.png — 我的页消息通知 */
+ iconMineNotify,
+ /** 订单@2x.png — 我的页我的订单 */
+ iconMineOrder,
+ /** 优惠券@2x.png — 我的页优惠券 */
+ iconMineCoupon,
+ /** 组 14923@2x.png — 我的页收货地址 */
+ iconMineAddress,
} as const
diff --git a/src/utils/tencent-map-loader.ts b/src/utils/tencent-map-loader.ts
new file mode 100644
index 0000000..d24be7b
--- /dev/null
+++ b/src/utils/tencent-map-loader.ts
@@ -0,0 +1,59 @@
+const TENCENT_MAP_KEY = (import.meta.env.VITE_TENCENT_MAP_KEY as string | undefined)?.trim()
+
+let loading: Promise | null = null
+
+declare global {
+ interface Window {
+ TMap?: {
+ Map: new (
+ container: HTMLElement,
+ options: Record,
+ ) => TencentMapInstance
+ LatLng: new (lat: number, lng: number) => TencentLatLng
+ }
+ }
+}
+
+interface TencentLatLng {
+ lat: number
+ lng: number
+}
+
+interface TencentMapInstance {
+ setCenter: (center: TencentLatLng) => void
+ getCenter: () => TencentLatLng
+ on: (event: string, handler: () => void) => void
+ destroy?: () => void
+}
+
+export type { TencentMapInstance, TencentLatLng }
+
+export function getTencentMapKey(): string | undefined {
+ return TENCENT_MAP_KEY || undefined
+}
+
+export function loadTencentMapGl(): Promise {
+ const key = getTencentMapKey()
+ if (!key) {
+ return Promise.reject(new Error('未配置 VITE_TENCENT_MAP_KEY'))
+ }
+ if (window.TMap) return Promise.resolve()
+ if (loading) return loading
+
+ loading = new Promise((resolve, reject) => {
+ const script = document.createElement('script')
+ script.src = `https://map.qq.com/api/gljs?v=1.exp&key=${encodeURIComponent(key)}`
+ script.async = true
+ script.onload = () => {
+ if (window.TMap) {
+ resolve()
+ return
+ }
+ reject(new Error('腾讯地图 SDK 初始化失败'))
+ }
+ script.onerror = () => reject(new Error('腾讯地图 SDK 加载失败'))
+ document.head.appendChild(script)
+ })
+
+ return loading
+}
diff --git a/src/utils/validate.ts b/src/utils/validate.ts
index 3e82020..88d0f28 100644
--- a/src/utils/validate.ts
+++ b/src/utils/validate.ts
@@ -9,3 +9,14 @@ export function isValidSmsCode(code: string): boolean {
export function isValidPassword(password: string): boolean {
return password.length >= 6 && password.length <= 32
}
+
+/** 设置/修改登录密码:8-20 位,且字母、数字、符号至少包含 2 种 */
+export function isValidStrongPassword(password: string): boolean {
+ const value = password.trim()
+ if (value.length < 8 || value.length > 20) return false
+ let types = 0
+ if (/[a-zA-Z]/.test(value)) types += 1
+ if (/\d/.test(value)) types += 1
+ if (/[^a-zA-Z0-9]/.test(value)) types += 1
+ return types >= 2
+}
diff --git a/src/utils/z2-ble-protocol.ts b/src/utils/z2-ble-protocol.ts
new file mode 100644
index 0000000..917c3f0
--- /dev/null
+++ b/src/utils/z2-ble-protocol.ts
@@ -0,0 +1,331 @@
+/** Z2 空气净化器 BLE 协议(ASCII,嵌入式文档) */
+
+export const Z2_BLE_SERVICE_UUID =
+ (import.meta.env.VITE_BLE_SERVICE_UUID as string | undefined)?.trim()
+ || '0000ffe0-0000-1000-8000-00805f9b34fb'
+
+/** Write 特征(嵌入式文档短 UUID: FFF2) */
+export const Z2_BLE_WRITE_CHAR_UUID =
+ (import.meta.env.VITE_BLE_WRITE_CHAR_UUID as string | undefined)?.trim()
+ || '0000fff2-0000-1000-8000-00805f9b34fb'
+
+/** Notify 特征(嵌入式文档短 UUID: FFF1) */
+export const Z2_BLE_NOTIFY_CHAR_UUID =
+ (import.meta.env.VITE_BLE_NOTIFY_CHAR_UUID as string | undefined)?.trim()
+ || '0000fff1-0000-1000-8000-00805f9b34fb'
+
+/** 实机 / 透传模块可能使用的其它短 UUID */
+export const Z2_BLE_WRITE_CHAR_ALIASES = ['FFF2', 'FFC1', 'FFE2']
+export const Z2_BLE_NOTIFY_CHAR_ALIASES = ['FFF1', 'FFC2', 'FFE1']
+
+export type Z2Gear = 'auto' | 'low' | 'high' | 'stop'
+export type Z2WorkMode = 'car' | 'desk'
+export type Z2AirQuality = 'good' | 'medium' | 'poor'
+export type Z2Power = 'usb' | 'battery'
+export type Z2Fan = 'stop' | 'low' | 'middle' | 'high'
+
+export interface Z2DeviceState {
+ gear?: Z2Gear
+ workMode?: Z2WorkMode
+ airQuality?: Z2AirQuality
+ battery?: number
+ power?: Z2Power
+ tempState?: number
+ fan?: Z2Fan
+ plasma?: boolean
+ filterLife?: number
+}
+
+export const Z2_BLE_CMD = {
+ GEAR_AUTO: 'A',
+ GEAR_LOW: 'L',
+ GEAR_HIGH: 'H',
+ WORK_MODE_CAR: 'C',
+ WORK_MODE_DESK: 'D',
+ QUERY_ALL: 'Q',
+ QUERY_BATTERY: 'B',
+ QUERY_THERMAL: 'T',
+ QUERY_FILTER: 'I',
+ FILTER_RESET: 'F',
+} as const
+
+export function normalizeBleUuid(value: string): string {
+ return value.replace(/-/g, '').toUpperCase()
+}
+
+export function bleUuidMatches(
+ actual: string,
+ expected: string,
+ aliases: string[] = [],
+): boolean {
+ const a = normalizeBleUuid(actual)
+ const e = normalizeBleUuid(expected)
+ if (a === e) return true
+
+ const aShort = a.slice(-4)
+ const eShort = e.slice(-4)
+ if (aShort === eShort) return true
+
+ const aliasSet = new Set(
+ [eShort, ...aliases.map((item) => item.toUpperCase())].filter(Boolean),
+ )
+ return aliasSet.has(aShort)
+}
+
+export function clampPercent(value: number): number {
+ if (!Number.isFinite(value)) return 0
+ return Math.min(100, Math.max(0, Math.round(value)))
+}
+
+/** 修复多包 Notify 粘连,如 `A=GB=34;P=B` → `A=G` + `B=34;P=B` */
+export function normalizeZ2NotifyText(raw: string): string {
+ return raw
+ .replace(/([GMP])(B=\d)/g, '$1;$2')
+ .replace(/(P=[UB])(T=\d)/g, '$1;$2')
+ .replace(/(;\d)(B=\d)/g, '$1;$2')
+ .replace(/(E=\d)(FL=)/g, '$1;$2')
+ .replace(/(E=\d)(FILTER=)/g, '$1;$2')
+}
+
+/** 将 Notify 原始文本拆成逻辑行(Q 查询标准格式会返回 4 条) */
+export function splitZ2NotifyRecords(raw: string): string[] {
+ const text = normalizeZ2NotifyText(raw.trim())
+ if (!text) return []
+
+ const byNewline = text.split(/[\r\n]+/).map((item) => item.trim()).filter(Boolean)
+ if (byNewline.length > 1) return byNewline
+
+ // 实机单包合并格式:M=A;B=731;T=0;F=L(勿按 B=/T= 误拆)
+ if (/^M=[^;]+/i.test(text) && !text.startsWith('Q;')) {
+ return [text]
+ }
+
+ // 仅标准多包应答(Q; 开头或独立 FL=/FILTER= 行)才拆分
+ const isMultiRecord = text.startsWith('Q;')
+ || /(?:^|;)FL=|(?:^|;)FILTER=/.test(text)
+ if (!isMultiRecord) {
+ return [text]
+ }
+
+ const records = text
+ .split(/(?=(?:^|(?<=;))(?:Q;|B=\d|T=\d|FL=|FILTER=))/)
+ .map((item) => item.replace(/^;/, '').trim())
+ .filter(Boolean)
+
+ return records.length ? records : [text]
+}
+
+function mapGearCode(code: string): Z2Gear | undefined {
+ switch (normalizeToken(code)) {
+ case 'A': return 'auto'
+ case 'L': return 'low'
+ case 'H': return 'high'
+ case 'S': return 'stop'
+ default: return undefined
+ }
+}
+
+function mapWorkModeCode(code: string): Z2WorkMode | undefined {
+ switch (normalizeToken(code)) {
+ case 'C': return 'car'
+ case 'D': return 'desk'
+ default: return undefined
+ }
+}
+
+function normalizeToken(value: string): string {
+ return value.trim().replace(/\0/g, '').toUpperCase()
+}
+
+function mapAirQualityCode(code: string): Z2AirQuality | undefined {
+ const token = normalizeToken(code)
+ switch (token) {
+ case 'G':
+ case 'GOOD':
+ return 'good'
+ case 'M':
+ case 'MEDIUM':
+ return 'medium'
+ case 'P':
+ case 'POOR':
+ return 'poor'
+ default:
+ return undefined
+ }
+}
+
+export function airQualityToTvoc(quality?: Z2AirQuality): number | undefined {
+ switch (quality) {
+ case 'good': return 0.2
+ case 'medium': return 0.5
+ case 'poor': return 0.8
+ default: return undefined
+ }
+}
+
+export function airQualityToLabel(quality?: Z2AirQuality): string {
+ switch (quality) {
+ case 'good': return '优'
+ case 'medium': return '良'
+ case 'poor': return '差'
+ default: return '--'
+ }
+}
+
+function mapPowerCode(code: string): Z2Power | undefined {
+ switch (normalizeToken(code)) {
+ case 'U': return 'usb'
+ case 'B': return 'battery'
+ default: return undefined
+ }
+}
+
+function mapFanCode(code: string): Z2Fan | undefined {
+ switch (normalizeToken(code)) {
+ case 'S': return 'stop'
+ case 'L': return 'low'
+ case 'M': return 'middle'
+ case 'H': return 'high'
+ default: return undefined
+ }
+}
+
+function parseBatteryValue(value: string): number | undefined {
+ const matched = value.trim().match(/^(\d{1,4})$/)
+ if (!matched) return undefined
+ const raw = Number(matched[1])
+ if (!Number.isFinite(raw)) return undefined
+ if (raw <= 100) return clampPercent(raw)
+ // 实机可能上报 731 表示 73.1%
+ const scaled = Math.round(raw / 10)
+ if (scaled <= 100) return clampPercent(scaled)
+ return clampPercent(raw % 100)
+}
+
+/** 解析单行 Notify,如 `Q;M=L;W=C;A=G` 或 `B=34;P=B` */
+export function parseZ2NotifyLine(line: string): Partial {
+ const patch: Partial = {}
+ const segments = line.split(';').filter(Boolean)
+
+ for (const segment of segments) {
+ if (segment === 'Q') continue
+
+ const eqIndex = segment.indexOf('=')
+ if (eqIndex <= 0) continue
+
+ const key = normalizeToken(segment.slice(0, eqIndex))
+ const value = segment.slice(eqIndex + 1).trim().replace(/\0/g, '')
+ if (!value) continue
+
+ switch (key) {
+ case 'M':
+ patch.gear = mapGearCode(value)
+ break
+ case 'W':
+ patch.workMode = mapWorkModeCode(value)
+ break
+ case 'A':
+ patch.airQuality = mapAirQualityCode(value)
+ break
+ case 'B': {
+ const battery = parseBatteryValue(value)
+ if (battery != null) patch.battery = battery
+ break
+ }
+ case 'P':
+ patch.power = mapPowerCode(value)
+ break
+ case 'T': {
+ const tempState = Number(value)
+ if (Number.isFinite(tempState)) patch.tempState = tempState
+ break
+ }
+ case 'F':
+ patch.fan = mapFanCode(value)
+ break
+ case 'E':
+ patch.plasma = value === '1'
+ break
+ case 'FL':
+ case 'FILTER': {
+ const filterLife = parseBatteryValue(value)
+ if (filterLife != null) patch.filterLife = filterLife
+ break
+ }
+ default:
+ break
+ }
+ }
+
+ return patch
+}
+
+export function mergeZ2DeviceState(
+ base: Z2DeviceState,
+ patch: Partial,
+): Z2DeviceState {
+ return { ...base, ...patch }
+}
+
+export function parseZ2NotifyPayload(lines: string[]): Z2DeviceState {
+ let state: Z2DeviceState = {}
+ for (const line of lines) {
+ for (const record of splitZ2NotifyRecords(line)) {
+ state = mergeZ2DeviceState(state, parseZ2NotifyLine(record))
+ }
+ }
+ return state
+}
+
+function mapGearToFanLevel(gear?: Z2Gear): string | undefined {
+ switch (gear) {
+ case 'low': return '2'
+ case 'high': return '1'
+ case 'auto': return '0'
+ default: return undefined
+ }
+}
+
+function mapGearAndWorkModeToRunMode(
+ gear?: Z2Gear,
+ workMode?: Z2WorkMode,
+): string | undefined {
+ if (workMode === 'car') return '1'
+ if (workMode === 'desk') return '2'
+ if (gear === 'auto' || gear === 'low' || gear === 'high') return '0'
+ return undefined
+}
+
+/** 将 Z2 状态映射为详情页 / updateRuntime 补丁 */
+export function mapZ2StateToRuntimePatch(state: Z2DeviceState) {
+ const runMode = mapGearAndWorkModeToRunMode(state.gear, state.workMode)
+ const fanLevel = mapGearToFanLevel(state.gear)
+ const tvoc = airQualityToTvoc(state.airQuality)
+ const powerMode = state.power === 'battery' ? '1' : state.power === 'usb' ? '0' : undefined
+
+ return {
+ ...(runMode != null ? { runMode } : {}),
+ ...(fanLevel != null ? { fanLevel } : {}),
+ ...(tvoc != null ? { tvoc } : {}),
+ ...(state.airQuality != null ? { airQuality: state.airQuality } : {}),
+ ...(powerMode != null ? { powerMode } : {}),
+ ...(state.filterLife != null ? { filterLife: clampPercent(state.filterLife) } : {}),
+ ...(state.battery != null ? { deviceBattery: clampPercent(state.battery) } : {}),
+ }
+}
+
+export function encodeRunModeBleCommand(runMode: string): string {
+ switch (runMode) {
+ case '1': return Z2_BLE_CMD.WORK_MODE_CAR
+ case '2': return Z2_BLE_CMD.WORK_MODE_DESK
+ default: return Z2_BLE_CMD.GEAR_AUTO
+ }
+}
+
+export function encodeFanLevelBleCommand(fanLevel: string): string {
+ switch (fanLevel) {
+ case '1': return Z2_BLE_CMD.GEAR_HIGH
+ case '2': return Z2_BLE_CMD.GEAR_LOW
+ default: return Z2_BLE_CMD.GEAR_AUTO
+ }
+}
diff --git a/src/utils/z2-ble.ts b/src/utils/z2-ble.ts
new file mode 100644
index 0000000..31e8c80
--- /dev/null
+++ b/src/utils/z2-ble.ts
@@ -0,0 +1,416 @@
+import { BluetoothError } from '@/utils/bluetooth'
+import {
+ bleUuidMatches,
+ normalizeZ2NotifyText,
+ parseZ2NotifyPayload,
+ splitZ2NotifyRecords,
+ Z2_BLE_NOTIFY_CHAR_ALIASES,
+ Z2_BLE_NOTIFY_CHAR_UUID,
+ Z2_BLE_SERVICE_UUID,
+ Z2_BLE_WRITE_CHAR_ALIASES,
+ Z2_BLE_WRITE_CHAR_UUID,
+ type Z2DeviceState,
+} from '@/utils/z2-ble-protocol'
+
+type BleCharacteristic = UniApp.GetBLEDeviceCharacteristicsSuccess['characteristics'][number]
+
+function toBluetoothError(err: UniApp.GeneralCallbackResult, fallback: string): BluetoothError {
+ const fail = err as UniApp.GeneralCallbackResult & { errCode?: number }
+ return new BluetoothError(fail.errMsg || fallback, fail.errCode)
+}
+
+function sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms))
+}
+
+function asciiToArrayBuffer(text: string): ArrayBuffer {
+ const buffer = new ArrayBuffer(text.length)
+ const view = new Uint8Array(buffer)
+ for (let i = 0; i < text.length; i++) {
+ view[i] = text.charCodeAt(i)
+ }
+ return buffer
+}
+
+function arrayBufferToAscii(buffer: ArrayBuffer): string {
+ const bytes = new Uint8Array(buffer)
+ return String.fromCharCode(...bytes).trim()
+}
+
+interface Z2BleHandles {
+ serviceId: string
+ writeCharId: string
+ notifyCharId: string
+ notifyHandle?: number
+}
+
+const handleCache = new Map()
+
+const Z2_BLE_DEBUG = import.meta.env.DEV
+ || import.meta.env.VITE_BLE_DEBUG === 'true'
+
+function logZ2BleDebug(message: string, payload?: unknown) {
+ if (!Z2_BLE_DEBUG) return
+ if (payload !== undefined) {
+ console.log(`[Z2 BLE] ${message}`, payload)
+ return
+ }
+ console.log(`[Z2 BLE] ${message}`)
+}
+
+function getBleServices(deviceId: string): Promise {
+ return new Promise((resolve, reject) => {
+ uni.getBLEDeviceServices({
+ deviceId,
+ success: (res) => resolve(res.services ?? []),
+ fail: (err) => reject(toBluetoothError(err, '获取蓝牙服务失败')),
+ })
+ })
+}
+
+function getBleCharacteristics(
+ deviceId: string,
+ serviceId: string,
+): Promise {
+ return new Promise((resolve, reject) => {
+ uni.getBLEDeviceCharacteristics({
+ deviceId,
+ serviceId,
+ success: (res) => resolve(res.characteristics ?? []),
+ fail: (err) => reject(toBluetoothError(err, '获取蓝牙特征值失败')),
+ })
+ })
+}
+
+async function withGattRetry(
+ load: () => Promise,
+ isReady: (value: T) => boolean,
+ retries = 6,
+ delayMs = 300,
+): Promise {
+ let last!: T
+ for (let i = 0; i < retries; i++) {
+ last = await load()
+ if (isReady(last)) return last
+ if (i < retries - 1) await sleep(delayMs)
+ }
+ return last
+}
+
+function charHasWrite(item: BleCharacteristic): boolean {
+ const props = item.properties
+ return Boolean(props?.write || props?.writeNoResponse)
+}
+
+/** 仅 notify / indicate 才具备 CCCD,read 不能用于订阅 */
+function charSupportsNotify(item: BleCharacteristic): boolean {
+ const props = item.properties
+ return Boolean(props?.notify || props?.indicate)
+}
+
+function pickWriteCharacteristic(
+ characteristics: BleCharacteristic[],
+): BleCharacteristic | undefined {
+ return characteristics.find((item) =>
+ charHasWrite(item)
+ && bleUuidMatches(item.uuid, Z2_BLE_WRITE_CHAR_UUID, Z2_BLE_WRITE_CHAR_ALIASES),
+ ) ?? characteristics.find((item) => charHasWrite(item))
+}
+
+function listNotifyCandidates(
+ characteristics: BleCharacteristic[],
+): BleCharacteristic[] {
+ const notifyable = characteristics.filter(charSupportsNotify)
+ const uuidMatched = notifyable.filter((item) =>
+ bleUuidMatches(item.uuid, Z2_BLE_NOTIFY_CHAR_UUID, Z2_BLE_NOTIFY_CHAR_ALIASES),
+ )
+ return uuidMatched.length ? uuidMatched : notifyable
+}
+
+function pickHandlesFromCharacteristics(
+ characteristics: BleCharacteristic[],
+): { writeChar: BleCharacteristic; notifyCandidates: BleCharacteristic[] } | null {
+ if (!characteristics.length) return null
+
+ const writeChar = pickWriteCharacteristic(characteristics)
+ const notifyCandidates = listNotifyCandidates(characteristics)
+
+ if (writeChar && notifyCandidates.length) {
+ return { writeChar, notifyCandidates }
+ }
+
+ if (characteristics.length === 1) {
+ const only = characteristics[0]
+ if (charHasWrite(only) && charSupportsNotify(only)) {
+ return { writeChar: only, notifyCandidates: [only] }
+ }
+ }
+
+ return null
+}
+
+async function resolveZ2BleHandles(deviceId: string): Promise<{
+ serviceId: string
+ writeChar: BleCharacteristic
+ notifyCandidates: BleCharacteristic[]
+}> {
+ const cached = handleCache.get(deviceId)
+ if (cached) {
+ return {
+ serviceId: cached.serviceId,
+ writeChar: { uuid: cached.writeCharId, properties: {} },
+ notifyCandidates: [{
+ uuid: cached.notifyCharId,
+ properties: { notify: true },
+ ...(cached.notifyHandle != null ? { handle: cached.notifyHandle } : {}),
+ }],
+ }
+ }
+
+ const services = await withGattRetry(
+ () => getBleServices(deviceId),
+ (list) => list.length > 0,
+ )
+ if (!services.length) {
+ throw new BluetoothError('未找到 Z2 蓝牙服务', 10004)
+ }
+
+ const orderedServices = [
+ ...services.filter((item) => bleUuidMatches(item.uuid, Z2_BLE_SERVICE_UUID)),
+ ...services.filter((item) => !bleUuidMatches(item.uuid, Z2_BLE_SERVICE_UUID)),
+ ]
+
+ for (const service of orderedServices) {
+ const characteristics = await withGattRetry(
+ () => getBleCharacteristics(deviceId, service.uuid),
+ (list) => list.length > 0,
+ )
+ const picked = pickHandlesFromCharacteristics(characteristics)
+ if (!picked) continue
+
+ return {
+ serviceId: service.uuid,
+ writeChar: picked.writeChar,
+ notifyCandidates: picked.notifyCandidates,
+ }
+ }
+
+ const serviceSummary = orderedServices.map((item) => item.uuid).join(', ')
+ throw new BluetoothError(
+ `未找到可订阅的蓝牙通知特征值(已发现服务: ${serviceSummary || '无'})`,
+ 10005,
+ )
+}
+
+function isNotifyDescriptorError(err: unknown): boolean {
+ if (err instanceof BluetoothError) {
+ if (err.errCode === 10007 || err.errCode === 10008) return true
+ const msg = err.message.toLowerCase()
+ return msg.includes('no descriptor') || msg.includes('property not support')
+ }
+ const msg = String((err as UniApp.GeneralCallbackResult)?.errMsg || '').toLowerCase()
+ return msg.includes('no descriptor') || msg.includes('property not support')
+}
+
+function notifyOnce(
+ deviceId: string,
+ serviceId: string,
+ characteristic: BleCharacteristic,
+ type: 'notification' | 'indication',
+): Promise {
+ return new Promise((resolve, reject) => {
+ const options: UniApp.NotifyBLECharacteristicValueChangeOptions & {
+ type?: 'notification' | 'indication'
+ handle?: number
+ } = {
+ deviceId,
+ serviceId,
+ characteristicId: characteristic.uuid,
+ state: true,
+ type,
+ success: () => resolve(),
+ fail: (err) => reject(toBluetoothError(err, '开启蓝牙通知失败')),
+ }
+
+ const handle = (characteristic as BleCharacteristic & { handle?: number }).handle
+ if (handle != null) {
+ options.handle = handle
+ }
+
+ uni.notifyBLECharacteristicValueChange(options)
+ })
+}
+
+async function enableBleNotify(
+ deviceId: string,
+ serviceId: string,
+ candidates: BleCharacteristic[],
+): Promise<{ charId: string; handle?: number }> {
+ for (const candidate of candidates) {
+ const types: Array<'notification' | 'indication'> = candidate.properties?.indicate
+ ? ['indication', 'notification']
+ : ['notification', 'indication']
+
+ for (const type of types) {
+ try {
+ await notifyOnce(deviceId, serviceId, candidate, type)
+ const handle = (candidate as BleCharacteristic & { handle?: number }).handle
+ return {
+ charId: candidate.uuid,
+ ...(handle != null ? { handle } : {}),
+ }
+ } catch (err) {
+ if (!isNotifyDescriptorError(err)) throw err
+ }
+ }
+ }
+
+ throw new BluetoothError(
+ '无法订阅蓝牙通知(特征值缺少 Notify 描述符),请确认设备固件已开启 Notify',
+ 10008,
+ )
+}
+
+function writeBleAscii(
+ deviceId: string,
+ serviceId: string,
+ characteristicId: string,
+ command: string,
+): Promise {
+ return new Promise((resolve, reject) => {
+ uni.writeBLECharacteristicValue({
+ deviceId,
+ serviceId,
+ characteristicId,
+ value: asciiToArrayBuffer(command) as unknown as number[],
+ success: () => resolve(),
+ fail: (err) => reject(toBluetoothError(err, '蓝牙写入失败')),
+ })
+ })
+}
+
+function appendNotifyText(buffer: string, chunk: string): string {
+ if (!chunk) return buffer
+ if (!buffer) return chunk
+ const needsSeparator = !/[;\r\n]$/.test(buffer) && !/^(B=\d|T=\d|FL=|FILTER=|Q;)/.test(chunk)
+ return needsSeparator ? `${buffer}${chunk}` : `${buffer}${chunk}`
+}
+
+function resolveNotifyRecords(buffer: string): string[] {
+ const normalized = normalizeZ2NotifyText(buffer)
+ const records = splitZ2NotifyRecords(normalized)
+ return records.length ? records : normalized ? [normalized] : []
+}
+
+async function collectNotifyLines(
+ deviceId: string,
+ options: { maxWaitMs?: number; idleMs?: number } = {},
+): Promise {
+ const maxWaitMs = options.maxWaitMs ?? 6000
+ const idleMs = options.idleMgs ?? 400
+ let buffer = ''
+ let settled = false
+
+ return new Promise((resolve) => {
+ let idleTimer: ReturnType | undefined
+ let maxTimer: ReturnType | undefined
+
+ const finish = () => {
+ if (settled) return
+ settled = true
+ cleanup()
+ const records = resolveNotifyRecords(buffer)
+ logZ2BleDebug('notify finished', { raw: buffer, records })
+ resolve(records)
+ }
+
+ const scheduleIdleFinish = () => {
+ if (idleTimer) clearTimeout(idleTimer)
+ idleTimer = setTimeout(finish, idleMs)
+ }
+
+ const onValue = (res: { deviceId: string; value: ArrayBuffer }) => {
+ if (res.deviceId !== deviceId) return
+ const text = arrayBufferToAscii(res.value)
+ if (!text) return
+ logZ2BleDebug('notify chunk', text)
+ buffer = appendNotifyText(buffer, text)
+ scheduleIdleFinish()
+ }
+
+ function cleanup() {
+ if (idleTimer) clearTimeout(idleTimer)
+ if (maxTimer) clearTimeout(maxTimer)
+ if (typeof uni.offBLECharacteristicValueChange === 'function') {
+ uni.offBLECharacteristicValueChange()
+ }
+ }
+
+ if (typeof uni.onBLECharacteristicValueChange !== 'function') {
+ finish()
+ return
+ }
+
+ uni.onBLECharacteristicValueChange(onValue as never)
+ maxTimer = setTimeout(finish, maxWaitMs)
+ })
+}
+
+/** 发送 ASCII 命令并收集 Notify 回报 */
+export async function sendZ2BleCommand(
+ deviceId: string,
+ command: string,
+ options: { timeoutMs?: number } = {},
+): Promise {
+ const handles = await resolveZ2BleHandles(deviceId)
+
+ const maxWaitMs = options.timeoutMs ?? 6000
+ const notifyPromise = collectNotifyLines(deviceId, { maxWaitMs, idleMs: 400 })
+ const notifyTarget = await enableBleNotify(
+ deviceId,
+ handles.serviceId,
+ handles.notifyCandidates,
+ )
+
+ handleCache.set(deviceId, {
+ serviceId: handles.serviceId,
+ writeCharId: handles.writeChar.uuid,
+ notifyCharId: notifyTarget.charId,
+ notifyHandle: notifyTarget.handle,
+ })
+
+ // 微信小程序 Android:notify 成功后立即 write 可能触发 10008
+ await sleep(200)
+
+ logZ2BleDebug('write command', command)
+ await writeBleAscii(deviceId, handles.serviceId, handles.writeChar.uuid, command)
+ const lines = await notifyPromise
+ const state = parseZ2NotifyPayload(lines)
+ logZ2BleDebug('parsed state', {
+ command,
+ lines,
+ airQuality: state.airQuality,
+ battery: state.battery,
+ filterLife: state.filterLife,
+ state,
+ })
+ if (command === 'Q' && !state.airQuality) {
+ logZ2BleDebug(
+ 'notify 中未包含 A=G/M/P 空气质量字段,请确认固件是否按协议上报',
+ )
+ }
+ return state
+}
+
+/** 查询设备全部状态(发送 Q) */
+export async function queryZ2BleState(deviceId: string): Promise {
+ return sendZ2BleCommand(deviceId, 'Q', { timeoutMs: 6000 })
+}
+
+export function clearZ2BleHandleCache(deviceId?: string) {
+ if (deviceId) {
+ handleCache.delete(deviceId)
+ return
+ }
+ handleCache.clear()
+}
diff --git a/vite.config.ts b/vite.config.ts
index 85cabba..8201948 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -14,13 +14,14 @@ export default defineConfig(({ mode }) => {
"/app": {
target: proxyTarget,
changeOrigin: true,
+ ws: true,
},
},
},
css: {
preprocessorOptions: {
scss: {
- additionalData: `@import "@/styles/variables.scss";`,
+ additionalData: `@use "@/styles/variables.scss" as *;`,
},
},
},