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 }} - + - - 取消配对 + + 重新连接 + + + + {{ connectStatus === 'failed' ? '返回' : '取消配对' }} + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 0000000..56164e7 Binary files /dev/null and b/src/static/chevron.left.png differ diff --git a/src/static/优惠券@2x.png b/src/static/优惠券@2x.png new file mode 100644 index 0000000..d4c039f Binary files /dev/null and b/src/static/优惠券@2x.png differ diff --git a/src/static/图标 13.png b/src/static/图标 13.png new file mode 100644 index 0000000..f5c0c00 Binary files /dev/null and b/src/static/图标 13.png differ diff --git a/src/static/图标 14.png b/src/static/图标 14.png new file mode 100644 index 0000000..0960338 Binary files /dev/null and b/src/static/图标 14.png differ diff --git a/src/static/组 134.png b/src/static/组 134.png new file mode 100644 index 0000000..f63b33f Binary files /dev/null and b/src/static/组 134.png differ diff --git a/src/static/组 14919@2x.png b/src/static/组 14919@2x.png new file mode 100644 index 0000000..476deaa Binary files /dev/null and b/src/static/组 14919@2x.png differ diff --git a/src/static/组 14923@2x.png b/src/static/组 14923@2x.png new file mode 100644 index 0000000..a4e0107 Binary files /dev/null and b/src/static/组 14923@2x.png differ diff --git a/src/static/订单@2x.png b/src/static/订单@2x.png new file mode 100644 index 0000000..8d176c2 Binary files /dev/null and b/src/static/订单@2x.png differ 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 *;`, }, }, },