feat:蓝牙绑定和在线客服

This commit is contained in:
李伊帆
2026-10-13 16:23:43 +08:00
parent d4b02745e1
commit e22ebd6bef
128 changed files with 10429 additions and 385 deletions

View File

@@ -1,3 +1,5 @@
VITE_API_BASE_URL=http://192.168.0.222:8082 VITE_API_BASE_URL=http://192.168.0.222:8082
VITE_API_PROXY_TARGET=http://192.168.0.222:8082 VITE_API_PROXY_TARGET=http://192.168.0.222:8082
VITE_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

View File

@@ -4,9 +4,20 @@ VITE_API_BASE_URL=http://192.168.0.222:8082
# H5 开发:BASE_URL 留空时可走 vite 代理 /app # H5 开发:BASE_URL 留空时可走 vite 代理 /app
VITE_API_PROXY_TARGET=http://192.168.0.222:8082 VITE_API_PROXY_TARGET=http://192.168.0.222:8082
# RuoYi-Vue-Plus App 端 clientId(请求头 clientId) # 消息推送 WebSocket(不含 token,连接时自动拼接 ?token=)
VITE_API_CLIENT_ID=app VITE_MESSAGE_WS_URL=ws://192.168.0.222:8082/app/ws/message
# 可选:配置后详情页会通过 BLE 特征值读取设备运行数据(JSON) # 在线客服 WebSocket(不含 token/sessionId,连接时自动拼接)
# VITE_BLE_SERVICE_UUID= VITE_CS_CHAT_WS_URL=ws://192.168.0.212:8082/app/cs/chat
# VITE_BLE_RUNTIME_CHARACTERISTIC_UUID=
# 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=

View File

@@ -1,14 +1,19 @@
<script setup lang="ts"> <script setup lang="ts">
import { onLaunch, onShow, onHide } from "@dcloudio/uni-app"; import { onLaunch, onShow } from '@dcloudio/uni-app'
import { setupMessageInbox } from '@/hooks/use-message-inbox'
import { getStoredToken } from '@/utils/auth-token'
onLaunch(() => { onLaunch(() => {
console.log("App Launch"); if (getStoredToken()) {
}); setupMessageInbox()
}
})
onShow(() => { onShow(() => {
console.log("App Show"); if (getStoredToken()) {
}); setupMessageInbox()
onHide(() => { }
console.log("App Hide"); })
});
</script> </script>
<style lang="scss"> <style lang="scss">
@import '@/styles/index.scss'; @import '@/styles/index.scss';

View File

@@ -23,3 +23,11 @@ export function saveAuthToken(data: AuthTokenResult | unknown) {
} }
return saveStoredToken(token) return saveStoredToken(token)
} }
/** POST /app/auth/logout — 退出登录 */
export function logout() {
return request<void>({
url: '/app/auth/logout',
method: 'POST',
})
}

72
src/api/home.ts Normal file
View File

@@ -0,0 +1,72 @@
import { request } from '@/api/request'
import type { AppHomeVo } from '@/types/location'
import { parseId, sameId } from '@/utils/device-id'
/** GET /app/home/list — 查询当前用户的家庭列表 */
export function fetchHomeList() {
return request<AppHomeVo[]>({
url: '/app/home/list',
method: 'GET',
})
}
/** POST /app/home/create — 创建家庭(返回 RVoid,需再拉列表取 ID) */
export function createHome(data: { homeName: string }) {
return request<void>({
url: '/app/home/create',
method: 'POST',
data,
})
}
function isDefaultHome(home: AppHomeVo): boolean {
const value = home.isDefault
return value === '1' || value === 1 || value === true
}
export function pickDefaultHome(homes: AppHomeVo[] | undefined): AppHomeVo | undefined {
if (!homes?.length) return undefined
return homes.find(isDefaultHome) ?? homes[0]
}
export function getHomeId(home?: AppHomeVo): string {
return parseId(home?.id)
}
export function findHomeById(homes: AppHomeVo[] | undefined, homeId: string | number) {

67
src/api/location.ts Normal file
View File

@@ -0,0 +1,67 @@
import { request } from '@/api/request'
import { resolveValidHomeId } from '@/api/home'
import { mapAppLocationVo, type AppLocationVo, type RoomOption } from '@/types/location'
import { ApiError } from '@/utils/api-error'
/** GET /app/location/list/{homeId} — 查询指定家庭的所有房间位置 */
export function fetchLocationList(homeId: string | number) {
return request<AppLocationVo[]>({
url: `/app/location/list/${homeId}`,
method: 'GET',
})
}
/** 拉取并映射房间列表(每次直接请求接口,不做本地缓存) */
export async function fetchRoomList(homeId?: string | number): Promise<RoomOption[]> {
const validHomeId = await resolveValidHomeId(homeId)
if (!validHomeId) {
throw new ApiError('未找到家庭信息')
}
const list = await fetchLocationList(validHomeId)
return (list ?? [])
.map(mapAppLocationVo)
.filter((item): item is RoomOption => item != null)
}
/** POST /app/location/create — 创建新的房间位置 */
export async function createLocation(data: {
homeId: string | number
locationName: string
icon?: string
sortOrder?: number
}) {
const validHomeId = await resolveValidHomeId(data.homeId)
if (!validHomeId) {
throw new ApiError('未找到家庭信息')
}
return request<void>({
url: '/app/location/create',
method: 'POST',
data: {
...data,
homeId: validHomeId,
},
})
}
/** POST /app/location/update — 修改房间位置 */
export function updateLocation(data: {
locationId: string | number
locationName: string
icon?: string
sortOrder?: number
}) {
return request<void>({
url: '/app/location/update',
method: 'POST',
data,
})
}
/** DELETE /app/location/{locationId} — 删除房间位置 */
export function deleteLocation(locationId: string | number) {
return request<void>({
url: `/app/location/${locationId}`,
method: 'DELETE',
})
}

53
src/api/message.ts Normal file
View File

@@ -0,0 +1,53 @@
import { request } from './request'
import type { AppMessageVo, MessageListQuery, MessageTableData } from '@/types/message'
export interface MessagePageResult {
rows: AppMessageVo[]
total: number
}
function normalizePageResult(
data: AppMessageVo[] | MessageTableData<AppMessageVo> | undefined,
): MessagePageResult {
if (!data) return { rows: [], total: 0 }
if (Array.isArray(data)) return { rows: data, total: data.length }
const rows = data.rows ?? []
return { rows, total: data.total ?? rows.length }
}
/** GET /app/message/list — 分页查询当前用户的消息列表 */
export function fetchMessagePage(query: MessageListQuery = {}) {
return request<AppMessageVo[] | MessageTableData<AppMessageVo>>({
url: '/app/message/list',
method: 'GET',
data: {
pageNum: 1,
pageSize: 20,
...query,
},
}).then(normalizePageResult)
}
/** GET /app/message/unreadCount — 未读消息数量 */
export function fetchUnreadMessageCount() {
return request<number>({
url: '/app/message/unreadCount',
method: 'GET',
})
}
/** PUT /app/message/read/{messageId} — 标记单条消息为已读 */
export function markMessageRead(messageId: string) {
return request<void>({
url: `/app/message/read/${encodeURIComponent(messageId)}`,
method: 'PUT',
})
}
/** PUT /app/message/readAll — 标记全部消息为已读 */
export function markAllMessagesRead() {
return request<void>({
url: '/app/message/readAll',
method: 'PUT',
})
}

61
src/api/profile.ts Normal file
View File

@@ -0,0 +1,61 @@
import { API_CLIENT_ID, request } from '@/api/request'
import type { ApiResponse } from '@/types/api'
import type { AppUserProfileVo, AvatarUploadVo, UpdateProfileBo } from '@/types/profile'
import { getStoredToken } from '@/utils/auth-token'
import { ApiError, isApiSuccess, unwrapApiData } from '@/utils/api-error'
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? ''
/** GET /app/profile — 获取当前用户资料 */
export function fetchProfile() {
return request<AppUserProfileVo>({
url: '/app/profile',
method: 'GET',
})
}
/** POST /app/profile/update — 修改用户资料 */
export function updateProfile(data: UpdateProfileBo) {
return request<void>({
url: '/app/profile/update',
method: 'POST',
data,
})
}
/** POST /app/profile/avatar — 上传用户头像 */
export function uploadProfileAvatar(filePath: string): Promise<AvatarUploadVo> {
return new Promise((resolve, reject) => {
const token = getStoredToken()
uni.uploadFile({
url: `${BASE_URL}/app/profile/avatar`,
filePath,
name: 'file',
header: {
clientId: API_CLIENT_ID,
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
success: (res) => {
if (res.statusCode < 200 || res.statusCode >= 300) {
reject(new ApiError(`上传失败(${res.statusCode})`, res.statusCode))
return
}
let body: ApiResponse<AvatarUploadVo>
try {
body = JSON.parse(res.data) as ApiResponse<AvatarUploadVo>
} catch {
reject(new ApiError('上传响应解析失败'))
return
}
if (!isApiSuccess(body)) {
reject(new ApiError(body.msg || body.message || '上传失败', body.code))
return
}
resolve(unwrapApiData(body))
},
fail: (err) => {
reject(new ApiError(err.errMsg || '上传失败'))
},
})
})
}

View File

@@ -2,9 +2,40 @@ import type { ApiResponse } from '@/types/api'
import { extractAuthToken, getStoredToken, saveStoredToken } from '@/utils/auth-token' import { extractAuthToken, getStoredToken, saveStoredToken } from '@/utils/auth-token'
import { ApiError, isApiSuccess, unwrapApiData } from '@/utils/api-error' import { ApiError, isApiSuccess, unwrapApiData } from '@/utils/api-error'
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '' /** H5 开发走 Vite 代理;App/小程序使用完整后端地址 */
function resolveBaseUrl(): string {
const envUrl = import.meta.env.VITE_API_BASE_URL ?? ''
// #ifdef H5
if (import.meta.env.DEV) return ''
// #endif
return envUrl
}
const BASE_URL = resolveBaseUrl()
/** RuoYi-Vue-Plus App 端 clientId(请求头 clientid,与 Postman 一致) */ /** RuoYi-Vue-Plus App 端 clientId(请求头 clientid,与 Postman 一致) */
export const API_CLIENT_ID = import.meta.env.VITE_API_CLIENT_ID || 'app' export const API_CLIENT_ID =
import.meta.env.VITE_API_CLIENT_ID || '428a8310cd442757ae699df5d894f051'
/** 将响应 JSON 中的 int64 主键字段转为字符串,避免 JS 精度丢失 */
function parseJsonPreserveIdFields(text: string): unknown {
const safe = text.replace(
/"(deviceId|homeId|locationId|productId|sessionId|messageId|alertId|seq|id)"\s*:\s*(\d+)/g,
'"$1":"$2"',
)
return JSON.parse(safe)
}
function parseResponseBody(data: unknown): unknown {
if (typeof data === 'string') {
if (!data) return {}
try {
return parseJsonPreserveIdFields(data)
} catch {
return {}
}
}
return data ?? {}
}
export interface RequestOptions extends Omit<UniApp.RequestOptions, 'url'> { export interface RequestOptions extends Omit<UniApp.RequestOptions, 'url'> {
url: string url: string
@@ -28,7 +59,7 @@ function handleSuccess<T>(
saveTokenFromBody = false, saveTokenFromBody = false,
) { ) {
if (res.statusCode < 200 || res.statusCode >= 300) { if (res.statusCode < 200 || res.statusCode >= 300) {
const body = res.data as ApiResponse | undefined const body = parseResponseBody(res.data) as ApiResponse | undefined
reject( reject(
new ApiError( new ApiError(
body?.msg || body?.message || `请求失败(${res.statusCode})`, body?.msg || body?.message || `请求失败(${res.statusCode})`,
@@ -38,7 +69,7 @@ function handleSuccess<T>(
return return
} }
const body = (res.data ?? {}) as ApiResponse<T> & Record<string, unknown> const body = parseResponseBody(res.data) as ApiResponse<T> & Record<string, unknown>
if (!isApiSuccess(body)) { if (!isApiSuccess(body)) {
reject(new ApiError(body.msg || body.message || '请求失败', body.code)) reject(new ApiError(body.msg || body.message || '请求失败', body.code))
return return
@@ -58,6 +89,7 @@ export function request<T = unknown>(options: RequestOptions): Promise<T> {
...options, ...options,
url: `${BASE_URL}${options.url}`, url: `${BASE_URL}${options.url}`,
header: buildAuthHeaders(options.header), header: buildAuthHeaders(options.header),
dataType: 'text',
success: (res) => handleSuccess<T>(res, resolve, reject), success: (res) => handleSuccess<T>(res, resolve, reject),
fail: (err) => { fail: (err) => {
reject(new ApiError(err.errMsg || '网络异常')) reject(new ApiError(err.errMsg || '网络异常'))
@@ -73,6 +105,7 @@ export function requestLogin<T = unknown>(options: RequestOptions): Promise<T> {
...options, ...options,
url: `${BASE_URL}${options.url}`, url: `${BASE_URL}${options.url}`,
header: buildAuthHeaders(options.header), header: buildAuthHeaders(options.header),
dataType: 'text',
success: (res) => handleSuccess<T>(res, resolve, reject, true), success: (res) => handleSuccess<T>(res, resolve, reject, true),
fail: (err) => { fail: (err) => {
reject(new ApiError(err.errMsg || '网络异常')) reject(new ApiError(err.errMsg || '网络异常'))

View File

@@ -5,7 +5,9 @@
<text class="app-navbar__back-icon">‹</text> <text class="app-navbar__back-icon">‹</text>
</view> </view>
<text class="app-navbar__title">{{ title }}</text> <text class="app-navbar__title">{{ title }}</text>
<view class="app-navbar__placeholder" /> <view class="app-navbar__right">
<slot name="right" />
</view>
</view> </view>
</view> </view>
</template> </template>
@@ -66,7 +68,13 @@ function onBack() {
color: $color-text; color: $color-text;
} }
.app-navbar__placeholder { .app-navbar__right {
width: 80rpx; min-width: 80rpx;
max-width: 220rpx;
height: 88rpx;
display: flex;
align-items: center;
justify-content: flex-end;
flex-shrink: 0;
} }
</style> </style>

View File

@@ -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<number> {
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
}

View File

@@ -17,7 +17,9 @@
"delay" : 0 "delay" : 0
}, },
/* 模块配置 */ /* 模块配置 */
"modules" : {}, "modules" : {
"Maps" : {}
},
/* 应用发布信息 */ /* 应用发布信息 */
"distribute" : { "distribute" : {
/* android打包配置 */ /* android打包配置 */
@@ -64,12 +66,17 @@
"permission" : { "permission" : {
"scope.bluetooth" : { "scope.bluetooth" : {
"desc" : "你的蓝牙将用于发现和连接智能设备" "desc" : "你的蓝牙将用于发现和连接智能设备"
},
"scope.userLocation" : {
"desc" : "你的位置信息将用于在地图上选择家庭位置"
} }
}, },
"requiredPrivateInfos" : [ "requiredPrivateInfos" : [
"getBluetoothDevices", "getBluetoothDevices",
"onBluetoothDeviceFound", "onBluetoothDeviceFound",
"startBluetoothDevicesDiscovery" "startBluetoothDevicesDiscovery",
"getLocation",
"chooseLocation"
] ]
}, },
"mp-alipay" : { "mp-alipay" : {
@@ -81,6 +88,15 @@
"mp-toutiao" : { "mp-toutiao" : {
"usingComponents" : true "usingComponents" : true
}, },
"h5" : {
"sdkConfigs" : {
"maps" : {
"tencent" : {
"key" : ""
}
}
}
},
"uniStatistics": { "uniStatistics": {
"enable": false "enable": false
}, },

View File

@@ -27,6 +27,41 @@
"navigationBarTitleText": "设备详情" "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", "path": "pages/add-device/index",
"style": { "style": {
@@ -62,6 +97,76 @@
"navigationStyle": "custom" "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", "path": "pages/register/index",
"style": { "style": {

View File

@@ -13,13 +13,13 @@ export interface BindFeatureItem {
/** POST /app/device/bind 请求体 */ /** POST /app/device/bind 请求体 */
export interface BindDeviceBody { export interface BindDeviceBody {
deviceNo: string deviceNo: string
productId?: number productId?: string | number
model?: string model?: string
imei?: string imei?: string
mac?: string mac?: string
name?: string name?: string
homeId?: number homeId?: string | number
locationId?: number locationId?: string | number
} }
/** 绑定流程页间传递的设备信息 */ /** 绑定流程页间传递的设备信息 */
@@ -27,10 +27,11 @@ export interface BindDeviceContext {
deviceNo: string deviceNo: string
name: string name: string
serialMasked: string serialMasked: string
productId?: number productId?: string | number
model?: string model?: string
imei?: string imei?: string
mac?: string mac?: string
bluetoothConnected?: string
} }
export function buildBindDeviceQuery(ctx: BindDeviceContext): 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.model) parts.push(`model=${encodeURIComponent(ctx.model)}`)
if (ctx.imei) parts.push(`imei=${encodeURIComponent(ctx.imei)}`) if (ctx.imei) parts.push(`imei=${encodeURIComponent(ctx.imei)}`)
if (ctx.mac) parts.push(`mac=${encodeURIComponent(ctx.mac)}`) if (ctx.mac) parts.push(`mac=${encodeURIComponent(ctx.mac)}`)
if (ctx.bluetoothConnected) {
parts.push(`bluetoothConnected=${ctx.bluetoothConnected}`)
}
return parts.join('&') return parts.join('&')
} }
@@ -64,5 +68,8 @@ export function readBindDeviceContext(
if (query.model) ctx.model = decodeURIComponent(String(query.model)) if (query.model) ctx.model = decodeURIComponent(String(query.model))
if (query.imei) ctx.imei = decodeURIComponent(String(query.imei)) if (query.imei) ctx.imei = decodeURIComponent(String(query.imei))
if (query.mac) ctx.mac = decodeURIComponent(String(query.mac)) if (query.mac) ctx.mac = decodeURIComponent(String(query.mac))
if (query.bluetoothConnected) {
ctx.bluetoothConnected = String(query.bluetoothConnected)
}
return ctx return ctx
} }

View File

@@ -0,0 +1,11 @@
import { request } from '@/api/request'
import type { ProductOption } from './complete.types'
/** POST /app/device/productOptions — 获取产品型号下拉列表 */
export function fetchProductOptions() {
return request<ProductOption[]>({
url: '/app/device/productOptions',
method: 'POST',
data: {},
})
}

View File

@@ -95,6 +95,14 @@
font-weight: 500; 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 { .complete-page__form {
margin: 0 32rpx 24rpx; margin: 0 32rpx 24rpx;
padding: 8rpx 24rpx 24rpx; padding: 8rpx 24rpx 24rpx;
@@ -138,11 +146,17 @@
flex-shrink: 0; flex-shrink: 0;
} }
.complete-page__select-row,
.complete-page__room-row { .complete-page__room-row {
display: flex; display: flex;
align-items: center; align-items: center;
height: 88rpx; height: 88rpx;
padding: 0 24rpx; padding: 0 24rpx;
cursor: pointer;
&--hover {
opacity: 0.85;
}
background: #f7f8fa; background: #f7f8fa;
border-radius: 16rpx; border-radius: 16rpx;
gap: 12rpx; gap: 12rpx;
@@ -158,8 +172,13 @@
flex: 1; flex: 1;
font-size: 28rpx; font-size: 28rpx;
color: $color-text; color: $color-text;
&--placeholder {
color: #c0c4cc;
}
} }
.complete-page__select-arrow,
.complete-page__room-arrow { .complete-page__room-arrow {
width: 24rpx; width: 24rpx;
height: 24rpx; height: 24rpx;

View File

@@ -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[]
}

View File

@@ -6,8 +6,12 @@
<bind-steps :steps="BIND_STEPS" :current="currentStep" /> <bind-steps :steps="BIND_STEPS" :current="currentStep" />
<complete-success-hero /> <complete-success-hero />
<view class="complete-page__status"> <view class="complete-page__status">
<text class="complete-page__title">配对成功!</text> <text class="complete-page__title">
<text class="complete-page__desc">请确认设备信息后完成添加</text> {{ bluetoothConnected ? '配对成功!' : '配对未完成' }}
</text>
<text class="complete-page__desc">
{{ bluetoothConnected ? '请确认设备信息后完成添加' : '设备蓝牙未连接,请返回重新配对' }}
</text>
</view> </view>
<view class="complete-page__device-card"> <view class="complete-page__device-card">
@@ -21,9 +25,12 @@
<text class="complete-page__device-serial"> <text class="complete-page__device-serial">
设备编号:{{ serialMasked }} 设备编号:{{ serialMasked }}
</text> </text>
<view class="complete-page__bt-status"> <view
class="complete-page__bt-status"
:class="{ 'complete-page__bt-status--offline': !bluetoothConnected }"
>
<view class="complete-page__bt-dot" /> <view class="complete-page__bt-dot" />
<text class="complete-page__bt-text">蓝牙已连接</text> <text class="complete-page__bt-text">{{ bluetoothStatusText }}</text>
</view> </view>
</view> </view>
</view> </view>
@@ -46,15 +53,49 @@
/> />
</view> </view>
</view> </view>
<view class="complete-page__field">
<text class="complete-page__label">产品型号</text>
<picker
mode="selector"
:range="productOptions"
range-key="model"
:value="productIndex"
:disabled="productOptionsLoading || !productOptions.length"
@change="onProductChange"
>
<view class="complete-page__select-row">
<text
class="complete-page__select-text"
:class="{ 'complete-page__select-text--placeholder': !productSelected }"
>
{{ productDisplayText }}
</text>
<image
class="complete-page__select-arrow"
:src="staticAssets.iconHomeArrow"
mode="aspectFit"
/>
</view>
</picker>
</view>
<view class="complete-page__field"> <view class="complete-page__field">
<text class="complete-page__label">所在房间</text> <text class="complete-page__label">所在房间</text>
<view class="complete-page__room-row" @tap="onPickRoom"> <view
class="complete-page__room-row"
hover-class="complete-page__room-row--hover"
@tap.stop="onPickRoom"
>
<image <image
class="complete-page__room-icon" class="complete-page__room-icon"
:src="staticAssets.iconHomeSelector" :src="staticAssets.iconHomeSelector"
mode="aspectFit" mode="aspectFit"
/> />
<text class="complete-page__room-text">{{ roomName }}</text> <text
class="complete-page__room-text"
:class="{ 'complete-page__room-text--placeholder': !roomSelected }"
>
{{ roomOptionsLoading ? '加载中...' : roomName }}
</text>
<image <image
class="complete-page__room-arrow" class="complete-page__room-arrow"
:src="staticAssets.iconHomeArrow" :src="staticAssets.iconHomeArrow"
@@ -75,7 +116,7 @@
<view class="complete-page__footer"> <view class="complete-page__footer">
<view <view
class="complete-page__submit" class="complete-page__submit"
:class="{ 'complete-page__submit--disabled': loading }" :class="{ 'complete-page__submit--disabled': loading || !bluetoothConnected }"
@tap="onFinishAdd" @tap="onFinishAdd"
> >
<text class="complete-page__submit-text"> <text class="complete-page__submit-text">
@@ -98,18 +139,26 @@ const {
currentStep, currentStep,
deviceName, deviceName,
serialMasked, serialMasked,
productOptions,
productIndex,
productDisplayText,
productSelected,
productOptionsLoading,
roomName, roomName,
roomSelected,
roomOptionsLoading,
loading, loading,
applyQuery, bluetoothConnected,
bluetoothStatusText,
initPage,
onProductChange,
onPickRoom, onPickRoom,
onFinishAdd, onFinishAdd,
} = useCompleteDevice() } = useCompleteDevice()
onLoad((query) => { onLoad((query) => {
applyQuery(query ?? {}) void initPage(query ?? {})
}) })
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped src="./complete.scss"></style>
@import './complete.scss';
</style>

View File

@@ -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 { 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 { bindDevice } from '../add-device.api'
import { parseId, sameId } from '@/utils/device-id'
import { readBindDeviceContext } from '../add-device.types' import { readBindDeviceContext } from '../add-device.types'
import { fetchProductOptions } from './complete.api'
const ROOM_OPTIONS = [ import type { ProductOption } from './complete.types'
{ name: '客厅', locationId: 1 },
{ name: '主卧', locationId: 2 },
{ name: '卧室', locationId: 3 },
{ name: '厨房', locationId: 4 },
{ name: '卫生间', locationId: 5 },
] as const
export function useCompleteDevice() { export function useCompleteDevice() {
const currentStep = 4 const currentStep = 4
const deviceNo = ref('') const deviceNo = ref('')
const deviceName = ref('Z2 空气净化器') const deviceName = ref('Z2 空气净化器')
const serialMasked = ref('****A3B2') const serialMasked = ref('****A3B2')
const productId = ref<number>() const productId = ref('')
const model = ref<string>() const model = ref('')
const productOptions = ref<ProductOption[]>([])
const productOptionsLoading = ref(false)
const productIndex = ref(0)
const imei = ref<string>() const imei = ref<string>()
const mac = ref<string>() const mac = ref<string>()
const roomName = ref(ROOM_OPTIONS[0].name) const homeId = ref('')
const locationId = ref<number | undefined>(ROOM_OPTIONS[0].locationId) const roomOptions = ref<RoomOption[]>([])
const roomOptionsLoading = ref(false)
const roomName = ref('请选择所在房间')
const locationId = ref<string | number>()
const loading = ref(false) 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<string, string | undefined>) { function applyQuery(query: Record<string, string | undefined>) {
const ctx = readBindDeviceContext(query) const ctx = readBindDeviceContext(query)
if (ctx.deviceNo) deviceNo.value = ctx.deviceNo if (ctx.deviceNo) deviceNo.value = ctx.deviceNo
if (ctx.name) deviceName.value = ctx.name if (ctx.name) deviceName.value = ctx.name
if (ctx.serialMasked) serialMasked.value = ctx.serialMasked 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 (ctx.model) model.value = ctx.model
if (productOptions.value.length) {
syncProductFromOptions()
}
if (ctx.imei) imei.value = ctx.imei if (ctx.imei) imei.value = ctx.imei
if (ctx.mac) mac.value = ctx.mac if (ctx.mac) mac.value = ctx.mac
bluetoothConnected.value = ctx.bluetoothConnected === '1'
if (roomOptions.value.length) {
syncRoomFromOptions()
}
}
async function initPage(query: Record<string, string | undefined>) {
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() { function onPickRoom() {
uni.showActionSheet({ if (roomOptionsLoading.value) {
itemList: ROOM_OPTIONS.map((item) => item.name), uni.showToast({ title: '房间列表加载中', icon: 'none' })
success: (res) => { return
const picked = ROOM_OPTIONS[res.tapIndex] }
if (picked) { if (!homeId.value) {
roomName.value = picked.name uni.showToast({ title: '未找到家庭信息', icon: 'none' })
locationId.value = picked.locationId 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' }) uni.showToast({ title: '缺少设备编号', icon: 'none' })
return return
} }
if (!locationId.value) {
uni.showToast({ title: '请选择所在房间', icon: 'none' })
return
}
if (!bluetoothConnected.value) {
uni.showToast({ title: '设备未连接蓝牙,请返回重新配对', icon: 'none' })
return
}
if (loading.value) return if (loading.value) return
loading.value = true loading.value = true
try { try {
const validHomeId = await resolveValidHomeId(homeId.value)
homeId.value = validHomeId
await bindDevice({ await bindDevice({
deviceNo: deviceNo.value.trim(), deviceNo: deviceNo.value.trim(),
name, name,
...(productId.value != null ? { productId: productId.value } : {}), productId: productId.value,
...(model.value ? { model: model.value } : {}), model: model.value,
...(imei.value ? { imei: imei.value } : {}), ...(imei.value ? { imei: imei.value } : {}),
...(mac.value ? { mac: mac.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' }) uni.showToast({ title: '添加成功', icon: 'success' })
setTimeout(() => { setTimeout(() => {
uni.reLaunch({ url: '/pages/device/index' }) uni.reLaunch({ url: '/pages/device/index' })
@@ -82,13 +247,28 @@ export function useCompleteDevice() {
} }
} }
onShow(() => {
if (!initialized.value || !homeId.value) return
void loadRoomOptionList()
})
return { return {
currentStep, currentStep,
deviceName, deviceName,
serialMasked, serialMasked,
productOptions,
productIndex,
productDisplayText,
productSelected,
productOptionsLoading,
roomName, roomName,
roomSelected,
roomOptionsLoading,
loading, loading,
applyQuery, bluetoothConnected,
bluetoothStatusText,
initPage,
onProductChange,
onPickRoom, onPickRoom,
onFinishAdd, onFinishAdd,
} }

View File

@@ -12,6 +12,7 @@
:class="`pair-check-list__status--${item.status}`" :class="`pair-check-list__status--${item.status}`"
> >
<text v-if="item.status === 'done'" class="pair-check-list__check">✓</text> <text v-if="item.status === 'done'" class="pair-check-list__check">✓</text>
<text v-else-if="item.status === 'failed'" class="pair-check-list__check">×</text>
</view> </view>
<text class="pair-check-list__label">{{ item.title }}</text> <text class="pair-check-list__label">{{ item.title }}</text>
<view <view
@@ -82,6 +83,10 @@ defineProps<{
background: #e8eaed; background: #e8eaed;
} }
.pair-check-list__status--failed {
background: #e54d4d;
}
.pair-check-list__check { .pair-check-list__check {
font-size: 22rpx; font-size: 22rpx;
color: #fff; color: #fff;
@@ -113,6 +118,10 @@ defineProps<{
background: #f5f6f8; background: #f5f6f8;
} }
.pair-check-list__tag--failed {
background: #fff5f5;
}
.pair-check-list__tag-text { .pair-check-list__tag-text {
font-size: 24rpx; font-size: 24rpx;
font-weight: 500; font-weight: 500;
@@ -129,4 +138,8 @@ defineProps<{
.pair-check-list__tag--pending .pair-check-list__tag-text { .pair-check-list__tag--pending .pair-check-list__tag-text {
color: #98a2b3; color: #98a2b3;
} }
.pair-check-list__tag--failed .pair-check-list__tag-text {
color: #e54d4d;
}
</style> </style>

View File

@@ -5,24 +5,43 @@
<text class="pair-device-summary__name">{{ device.name }}</text> <text class="pair-device-summary__name">{{ device.name }}</text>
<text class="pair-device-summary__serial">设备编号:{{ device.serialMasked }}</text> <text class="pair-device-summary__serial">设备编号:{{ device.serialMasked }}</text>
</view> </view>
<view class="pair-device-summary__status"> <view
class="pair-device-summary__status"
:class="`pair-device-summary__status--${connectStatus}`"
>
<view class="pair-device-summary__status-dot" /> <view class="pair-device-summary__status-dot" />
<text class="pair-device-summary__status-text">连接中</text> <text class="pair-device-summary__status-text">{{ statusText }}</text>
</view> </view>
</view> </view>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'
import { staticAssets } from '@/utils/static-assets' import { staticAssets } from '@/utils/static-assets'
import type { PairDeviceInfo } from '../../pair.types' import type { PairConnectStatus, PairDeviceInfo } from '../../pair.types'
withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
device: PairDeviceInfo device: PairDeviceInfo
connectStatus?: PairConnectStatus
thumbSrc?: string thumbSrc?: string
}>(), }>(),
{ thumbSrc: staticAssets.logo }, {
connectStatus: 'connecting',
thumbSrc: staticAssets.logo,
},
) )
const statusText = computed(() => {
switch (props.connectStatus) {
case 'success':
return '已连接'
case 'failed':
return '未连接'
default:
return '连接中'
}
})
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -69,22 +88,55 @@ withDefaults(
align-items: center; align-items: center;
gap: 8rpx; gap: 8rpx;
padding: 10rpx 16rpx; padding: 10rpx 16rpx;
background: #effbf4;
border-radius: 999rpx; border-radius: 999rpx;
flex-shrink: 0; flex-shrink: 0;
} }
.pair-device-summary__status--connecting {
background: #fff4e8;
}
.pair-device-summary__status--success {
background: #effbf4;
}
.pair-device-summary__status--failed {
background: #fff5f5;
}
.pair-device-summary__status-dot { .pair-device-summary__status-dot {
width: 12rpx; width: 12rpx;
height: 12rpx; height: 12rpx;
border-radius: 50%; border-radius: 50%;
border: 3rpx solid $color-primary;
box-sizing: border-box; box-sizing: border-box;
} }
.pair-device-summary__status--connecting .pair-device-summary__status-dot {
border: 3rpx solid #e67a2e;
}
.pair-device-summary__status--success .pair-device-summary__status-dot {
background: $color-primary;
}
.pair-device-summary__status--failed .pair-device-summary__status-dot {
background: #e54d4d;
}
.pair-device-summary__status-text { .pair-device-summary__status-text {
font-size: 24rpx; font-size: 24rpx;
color: $color-primary;
font-weight: 500; font-weight: 500;
} }
.pair-device-summary__status--connecting .pair-device-summary__status-text {
color: #e67a2e;
}
.pair-device-summary__status--success .pair-device-summary__status-text {
color: $color-primary;
}
.pair-device-summary__status--failed .pair-device-summary__status-text {
color: #e54d4d;
}
</style> </style>

View File

@@ -6,13 +6,15 @@
<bind-steps :steps="BIND_STEPS" :current="currentStep" /> <bind-steps :steps="BIND_STEPS" :current="currentStep" />
<pair-hero /> <pair-hero />
<view class="pair-page__status"> <view class="pair-page__status">
<text class="pair-page__title">配对中...</text> <text class="pair-page__title">{{ pageTitle }}</text>
<text class="pair-page__desc">正在与设备建立安全连接,请保持手机靠近</text> <text class="pair-page__desc">{{ pageDesc }}</text>
</view> </view>
<view class="pair-page__progress-block"> <view v-if="connectStatus !== 'failed'" class="pair-page__progress-block">
<view class="pair-page__progress-head"> <view class="pair-page__progress-head">
<text class="pair-page__progress-label">正在同步设备配置...</text> <text class="pair-page__progress-label">
{{ connectStatus === 'success' ? '蓝牙连接成功' : '正在建立蓝牙连接...' }}
</text>
<text class="pair-page__progress-percent">{{ progress }}%</text> <text class="pair-page__progress-percent">{{ progress }}%</text>
</view> </view>
<view class="pair-page__progress-track"> <view class="pair-page__progress-track">
@@ -21,45 +23,101 @@
:style="{ width: `${progress}%` }" :style="{ width: `${progress}%` }"
/> />
</view> </view>
<text class="pair-page__progress-tip"> <text class="pair-page__progress-tip">{{ progressTip }}</text>
{{ progress >= 100 ? '配置同步完成' : `预计还需约 ${etaSeconds} 秒,请勿离开当前页面` }} </view>
</text>
<view v-else class="pair-page__error-block">
<text class="pair-page__error-text">{{ errorMessage }}</text>
</view> </view>
<pair-check-list :items="checkItems" /> <pair-check-list :items="checkItems" />
<pair-device-summary :device="device" /> <pair-device-summary :device="device" :connect-status="connectStatus" />
</view> </view>
</scroll-view> </scroll-view>
<view class="pair-page__footer"> <view class="pair-page__footer">
<view class="pair-page__cancel" @tap="onCancelPair"> <view
<text class="pair-page__cancel-text">取消配对</text> v-if="connectStatus === 'failed'"
class="pair-page__retry"
@tap="onRetryPair"
>
<text class="pair-page__retry-text">重新连接</text>
</view>
<view class="pair-page__cancel" @tap="onFooterCancel">
<text class="pair-page__cancel-text">
{{ connectStatus === 'failed' ? '返回' : '取消配对' }}
</text>
</view> </view>
</view> </view>
</view> </view>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app' import { onLoad } from '@dcloudio/uni-app'
import BindSteps from '../components/bind-steps/index.vue' import BindSteps from '../components/bind-steps/index.vue'
import { BIND_STEPS } from '../use-add-device' import { BIND_STEPS } from '../use-add-device'
import { readBindDeviceContext } from '../add-device.types'
import PairCheckList from './components/pair-check-list/index.vue' import PairCheckList from './components/pair-check-list/index.vue'
import PairDeviceSummary from './components/pair-device-summary/index.vue' import PairDeviceSummary from './components/pair-device-summary/index.vue'
import PairHero from './components/pair-hero/index.vue' import PairHero from './components/pair-hero/index.vue'
import { readBindDeviceContext } from '../add-device.types'
import { usePairDevice } from './use-pair-device' import { usePairDevice } from './use-pair-device'
const { currentStep, device, progress, etaSeconds, checkItems, onCancelPair } = const {
usePairDevice() currentStep,
device,
connectStatus,
progress,
errorMessage,
checkItems,
applyDevice,
startPairing,
onCancelPair,
onRetryPair,
} = usePairDevice()
const pageTitle = computed(() => {
if (connectStatus.value === 'failed') return '连接失败'
if (connectStatus.value === 'success') return '连接成功'
return '配对中...'
})
const pageDesc = computed(() => {
if (connectStatus.value === 'failed') {
return '请确认设备已开机、手机蓝牙已开启,并靠近设备后重试'
}
if (connectStatus.value === 'success') {
return '蓝牙连接已建立,即将进入下一步'
}
return '正在与设备建立安全连接,请保持手机靠近'
})
const progressTip = computed(() => {
if (connectStatus.value === 'success') return '连接完成,正在跳转...'
if (progress.value >= 70) return '正在连接设备,请稍候...'
if (progress.value >= 30) return '正在搜索附近设备...'
return '正在初始化蓝牙,请勿离开当前页面'
})
function onFooterCancel() {
if (connectStatus.value === 'failed') {
uni.navigateBack()
return
}
onCancelPair()
}
onLoad((query) => { onLoad((query) => {
const ctx = readBindDeviceContext(query ?? {}) const ctx = readBindDeviceContext(query ?? {})
if (ctx.name) device.value.name = ctx.name applyDevice({
if (ctx.serialMasked) device.value.serialMasked = ctx.serialMasked name: ctx.name ?? '',
if (ctx.deviceNo) device.value.deviceNo = ctx.deviceNo serialMasked: ctx.serialMasked ?? '',
if (ctx.productId != null) device.value.productId = ctx.productId deviceNo: ctx.deviceNo ?? '',
if (ctx.model) device.value.model = ctx.model productId: ctx.productId,
if (ctx.imei) device.value.imei = ctx.imei model: ctx.model,
if (ctx.mac) device.value.mac = ctx.mac imei: ctx.imei,
mac: ctx.mac,
})
void startPairing()
}) })
</script> </script>

View File

@@ -87,6 +87,36 @@
line-height: 1.5; 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 { .pair-page__footer {
padding: 16rpx 32rpx calc(24rpx + env(safe-area-inset-bottom)); padding: 16rpx 32rpx calc(24rpx + env(safe-area-inset-bottom));
background: $color-bg-page; background: $color-bg-page;

View File

@@ -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 { export interface PairCheckItem {
id: string id: string

View File

@@ -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 { buildBindDeviceQuery } from '../add-device.types'
import type { PairCheckItem, PairDeviceInfo } from './pair.types' import type { PairCheckItem, PairConnectStatus, PairDeviceInfo } from './pair.types'
const DEFAULT_DEVICE: PairDeviceInfo = { const DEFAULT_DEVICE: PairDeviceInfo = {
name: '', name: '',
@@ -8,61 +16,136 @@ const DEFAULT_DEVICE: PairDeviceInfo = {
deviceNo: '', deviceNo: '',
} }
export function usePairDevice(initial?: Partial<PairDeviceInfo>) { const SCAN_TIMEOUT_MS = 15000
const currentStep = 3
const device = ref<PairDeviceInfo>({
...DEFAULT_DEVICE,
...initial,
})
const progress = ref(65) export function usePairDevice() {
const etaSeconds = ref(15) const currentStep = 3
const device = ref<PairDeviceInfo>({ ...DEFAULT_DEVICE })
const connectStatus = ref<PairConnectStatus>('connecting')
const progress = ref(0)
const errorMessage = ref('')
const checkItems = ref<PairCheckItem[]>([ const checkItems = ref<PairCheckItem[]>([
{ id: 'signal', title: '蓝牙信号强度', status: 'done', tag: '良好' }, { id: 'signal', title: '蓝牙信号强度', status: 'pending', tag: '待检测' },
{ id: 'credential', title: '设备凭证校验', status: 'done', tag: '通过' }, { id: 'credential', title: '设备凭证校验', status: 'pending', tag: '待校验' },
{ id: 'network', title: '网络配置下发', status: 'syncing', tag: '同步中' }, { id: 'network', title: '蓝牙连接建立', status: 'pending', tag: '待连接' },
]) ])
let etaTick = 0 let pairing = false
const tick = setInterval(() => { let bleDeviceId: string | undefined
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 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) => { function markFailedFromStep(stepId: string, message: string) {
if (value < 100 || navigated) return const order = ['signal', 'credential', 'network']
navigated = true const failedIndex = order.indexOf(stepId)
const d = device.value checkItems.value = checkItems.value.map((item) => {
const q = buildBindDeviceQuery({ const idx = order.indexOf(item.id)
deviceNo: d.deviceNo, if (idx < failedIndex && item.status === 'done') return item
name: d.name, if (item.id === stepId) return { ...item, status: 'failed', tag: '失败' }
serialMasked: d.serialMasked, if (idx > failedIndex) return { ...item, status: 'pending', tag: '未开始' }
productId: d.productId, return item
model: d.model,
imei: d.imei,
mac: d.mac,
}) })
setTimeout(() => { connectStatus.value = 'failed'
uni.redirectTo({ url: `/pages/add-device/complete/index?${q}` }) errorMessage.value = message
}, 600) 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<PairDeviceInfo>) {
device.value = { ...device.value, ...partial }
}
onUnmounted(() => { onUnmounted(() => {
clearInterval(tick) bleDeviceId = undefined
}) })
function onCancelPair() { function onCancelPair() {
@@ -77,12 +160,20 @@ export function usePairDevice(initial?: Partial<PairDeviceInfo>) {
}) })
} }
function onRetryPair() {
void startPairing()
}
return { return {
currentStep, currentStep,
device, device,
connectStatus,
progress, progress,
etaSeconds, errorMessage,
checkItems, checkItems,
applyDevice,
startPairing,
onCancelPair, onCancelPair,
onRetryPair,
} }
} }

View File

@@ -2,7 +2,7 @@
<view v-if="visible" class="room-location-dialog"> <view v-if="visible" class="room-location-dialog">
<view class="room-location-dialog__mask" @tap="onCancel" /> <view class="room-location-dialog__mask" @tap="onCancel" />
<view class="room-location-dialog__panel" @tap.stop> <view class="room-location-dialog__panel" @tap.stop>
<text class="room-location-dialog__title">设置房间位置</text> <text class="room-location-dialog__title">{{ title }}</text>
<view class="room-location-dialog__input-wrap"> <view class="room-location-dialog__input-wrap">
<input <input
v-model="roomName" v-model="roomName"
@@ -32,9 +32,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
const props = defineProps<{ const props = withDefaults(
visible: boolean defineProps<{
}>() visible: boolean
title?: string
}>(),
{
title: '设置房间位置',
},
)
const emit = defineEmits<{ const emit = defineEmits<{
'update:visible': [value: boolean] 'update:visible': [value: boolean]

View File

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

View File

@@ -1,6 +1,6 @@
/** POST /app/device/runtime 查询参数 */ /** POST /app/device/runtime 查询参数 */
export interface DeviceRuntimeQueryBody { export interface DeviceRuntimeQueryBody {
deviceId: number deviceId: string | number
} }
/** /**
@@ -25,11 +25,12 @@ export interface AppDeviceRuntimeVo {
filterLife?: number filterLife?: number
filterReplaceTime?: string filterReplaceTime?: string
parkingShutdown?: string parkingShutdown?: string
deviceBattery?: number
} }
/** POST /app/device/updateRuntime */ /** POST /app/device/updateRuntime */
export interface UpdateDeviceRuntimeBody { export interface UpdateDeviceRuntimeBody {
deviceId: number deviceId: string | number
runMode?: string runMode?: string
fanLevel?: string fanLevel?: string
status?: string status?: string
@@ -38,6 +39,7 @@ export interface UpdateDeviceRuntimeBody {
charging?: string charging?: string
tvoc?: number tvoc?: number
powerMode?: string powerMode?: string
deviceBattery?: number
filterTotalDays?: string filterTotalDays?: string
filterRemainDays?: string filterRemainDays?: string
parkingShutdown?: string parkingShutdown?: string
@@ -45,8 +47,12 @@ export interface UpdateDeviceRuntimeBody {
export interface DeviceDetailRouteQuery { export interface DeviceDetailRouteQuery {
deviceId?: string deviceId?: string
deviceNo?: string
name?: string name?: string
locationName?: string locationName?: string
locationId?: string
homeId?: string
model?: string
status?: string status?: string
bluetoothConnected?: string bluetoothConnected?: string
charging?: string charging?: string

View File

@@ -59,9 +59,9 @@ export function formatTvocValue(tvoc?: number): string {
} }
export function formatTvocLevel(tvoc?: number): string { export function formatTvocLevel(tvoc?: number): string {
const value = tvoc ?? 0 if (tvoc == null) return '--'
if (value <= 0.3) return '优' if (tvoc <= 0.3) return '优'
if (value <= 0.6) return '良' if (tvoc <= 0.6) return '良'
return '差' return '差'
} }

View File

@@ -1,9 +1,8 @@
import { onUnload } from '@dcloudio/uni-app' import { onUnload } from '@dcloudio/uni-app'
import { ref } from 'vue' import { ref } from 'vue'
import { import {
BluetoothError,
closeBleConnection, closeBleConnection,
createBleConnection, ensureBleConnection,
ensureBluetoothReady, ensureBluetoothReady,
getBluetoothErrorMessage, getBluetoothErrorMessage,
getDiscoveredBluetoothDevices, getDiscoveredBluetoothDevices,
@@ -12,20 +11,24 @@ import {
scanForBleDeviceByMac, scanForBleDeviceByMac,
subscribeBleConnectionStateChange, subscribeBleConnectionStateChange,
} from '@/utils/bluetooth' } from '@/utils/bluetooth'
import { mapZ2StateToRuntimePatch } from '@/utils/z2-ble-protocol'
import { clearZ2BleHandleCache, sendZ2BleCommand } from '@/utils/z2-ble'
import { updateDeviceRuntime } from './device-detail.api' 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' import type { UpdateDeviceRuntimeBody } from './device-detail.types'
const SYNC_INTERVAL_MS = 15000 const SYNC_INTERVAL_MS = 15000
const SCAN_TIMEOUT_MS = 15000 const SCAN_TIMEOUT_MS = 15000
export interface DeviceBluetoothSyncContext { export interface DeviceBluetoothSyncContext {
getDeviceId: () => number getDeviceId: () => string | number
getMac: () => string | undefined getMac: () => string | undefined
getDeviceName?: () => string
getRuntimePatch: () => Partial<UpdateDeviceRuntimeBody> getRuntimePatch: () => Partial<UpdateDeviceRuntimeBody>
onBluetoothConnectedChange: (connected: boolean) => void onBluetoothConnectedChange: (connected: boolean) => void
onSyncTextChange: (text: string) => void onSyncTextChange: (text: string) => void
onSynced?: () => void | Promise<void> onSynced?: () => void | Promise<void>
onBleRuntimeRead?: (patch: BleRuntimePatch) => void
} }
export function useDeviceBluetoothSync(context: DeviceBluetoothSyncContext) { export function useDeviceBluetoothSync(context: DeviceBluetoothSyncContext) {
@@ -39,7 +42,7 @@ export function useDeviceBluetoothSync(context: DeviceBluetoothSyncContext) {
context.onSyncTextChange(text) context.onSyncTextChange(text)
} }
async function reportDisconnected() { async function reportBleDisconnected() {
const deviceId = context.getDeviceId() const deviceId = context.getDeviceId()
if (!deviceId) return if (!deviceId) return
context.onBluetoothConnectedChange(false) 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<boolean> { async function connectAndSync(): Promise<boolean> {
const deviceId = context.getDeviceId() const deviceId = context.getDeviceId()
const mac = context.getMac() const mac = context.getMac()
@@ -72,18 +91,24 @@ export function useDeviceBluetoothSync(context: DeviceBluetoothSyncContext) {
if (syncing.value) return false if (syncing.value) return false
syncing.value = true syncing.value = true
let connected = false
let targetBleId = bleDeviceId.value
try { try {
setSyncText('正在连接蓝牙…') setSyncText('正在连接蓝牙…')
await ensureBluetoothReady() await ensureBluetoothReady()
let targetBleId = bleDeviceId.value
let foundDevice: UniApp.BluetoothDeviceInfo | null = null let foundDevice: UniApp.BluetoothDeviceInfo | null = null
if (!targetBleId) { 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) { if (!foundDevice) {
setSyncText('未找到设备,请确认已开机并靠近手机') setSyncText('未找到设备,请确认已开机并靠近手机')
await reportDisconnected() await reportDeviceOffline()
return false return false
} }
targetBleId = foundDevice.deviceId targetBleId = foundDevice.deviceId
@@ -93,22 +118,16 @@ export function useDeviceBluetoothSync(context: DeviceBluetoothSyncContext) {
foundDevice = discovered.find((item) => item.deviceId === targetBleId) ?? null foundDevice = discovered.find((item) => item.deviceId === targetBleId) ?? null
} }
try { await ensureBleConnection(targetBleId)
await createBleConnection(targetBleId) connected = true
} catch (err) {
const btErr = err as BluetoothError
if (btErr.errCode !== 10010) throw err
}
if (!targetBleId) {
setSyncText('蓝牙连接异常,请重试')
await reportDisconnected()
return false
}
context.onBluetoothConnectedChange(true) context.onBluetoothConnectedChange(true)
setSyncText('正在同步设备数据…')
const blePayload = await readRuntimeFromBle(targetBleId) const blePayload = await readRuntimeFromBle(targetBleId)
if (Object.keys(blePayload).length) {
context.onBleRuntimeRead?.(blePayload)
}
const runtimePatch = context.getRuntimePatch() const runtimePatch = context.getRuntimePatch()
const signalLevel = rssiToSignalLevel(foundDevice?.RSSI) const signalLevel = rssiToSignalLevel(foundDevice?.RSSI)
@@ -121,28 +140,58 @@ export function useDeviceBluetoothSync(context: DeviceBluetoothSyncContext) {
signalLevel, signalLevel,
}) })
setSyncText('刚刚更新 · 数据实时同步中') if (!Object.keys(blePayload).length) {
setSyncText('蓝牙已连接,等待设备上报数据…')
} else {
setSyncText('刚刚更新 · 数据实时同步中')
}
await context.onSynced?.() await context.onSynced?.()
return true return true
} catch (err) { } catch (err) {
setSyncText(getBluetoothErrorMessage(err)) setSyncText(getBluetoothErrorMessage(err))
bleDeviceId.value = undefined if (!connected) {
await reportDisconnected() bleDeviceId.value = undefined
clearZ2BleHandleCache(targetBleId)
await reportDeviceOffline()
}
return false return false
} finally { } finally {
syncing.value = false syncing.value = false
} }
} }
async function sendBleCommand(command: string): Promise<boolean> {
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() { function startSyncLoop() {
if (active) return if (active) return
if (!isBluetoothApiSupported()) {
setSyncText('云端数据已加载 · 实时同步请使用 App 或微信小程序')
return
}
active = true active = true
unsubscribeConnection = subscribeBleConnectionStateChange((res) => { unsubscribeConnection = subscribeBleConnectionStateChange((res) => {
if (!bleDeviceId.value || res.deviceId !== bleDeviceId.value) return if (!bleDeviceId.value || res.deviceId !== bleDeviceId.value) return
if (!res.connected) { if (!res.connected) {
bleDeviceId.value = undefined bleDeviceId.value = undefined
void reportDisconnected() void reportBleDisconnected()
setSyncText('蓝牙已断开,正在尝试重连…') setSyncText('蓝牙已断开,正在尝试重连…')
} }
}) })
@@ -167,8 +216,9 @@ export function useDeviceBluetoothSync(context: DeviceBluetoothSyncContext) {
bleDeviceId.value = undefined bleDeviceId.value = undefined
if (currentBleId) { if (currentBleId) {
clearZ2BleHandleCache(currentBleId)
await closeBleConnection(currentBleId).catch(() => {}) await closeBleConnection(currentBleId).catch(() => {})
await reportDisconnected() await reportBleDisconnected()
} }
} }
@@ -181,5 +231,6 @@ export function useDeviceBluetoothSync(context: DeviceBluetoothSyncContext) {
startSyncLoop, startSyncLoop,
stopSync, stopSync,
connectAndSync, connectAndSync,
sendBleCommand,
} }
} }

View File

@@ -1,6 +1,8 @@
import { onLoad } from '@dcloudio/uni-app' import { onLoad } from '@dcloudio/uni-app'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { fetchRoomList } from '@/api/location'
import { getErrorMessage } from '@/utils/api-error' import { getErrorMessage } from '@/utils/api-error'
import { parseDeviceId, parseId, sameId } from '@/utils/device-id'
import { fetchDeviceList } from '../device.api' import { fetchDeviceList } from '../device.api'
import { fetchDeviceRuntime, updateDeviceRuntime } from './device-detail.api' import { fetchDeviceRuntime, updateDeviceRuntime } from './device-detail.api'
import type { AppDeviceRuntimeVo, DeviceDetailRouteQuery, FanLevel, RunMode } from './device-detail.types' import type { AppDeviceRuntimeVo, DeviceDetailRouteQuery, FanLevel, RunMode } from './device-detail.types'
@@ -15,13 +17,27 @@ import {
mapRuntimeToView, mapRuntimeToView,
RUN_MODE_OPTIONS, RUN_MODE_OPTIONS,
} from './device-detail.utils' } 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' import { useDeviceBluetoothSync } from './use-device-bluetooth-sync'
export function useDeviceDetail() { export function useDeviceDetail() {
const deviceId = ref(0) const deviceId = ref('')
const deviceNo = ref('')
const deviceMac = ref<string>() const deviceMac = ref<string>()
const deviceName = ref('') const deviceName = ref('')
const locationName = ref('客厅') const locationName = ref('客厅')
const locationId = ref('')
const homeId = ref('')
const productModel = ref('CZ-Z2023')
const deviceStatus = ref<string>() const deviceStatus = ref<string>()
const bluetoothConnected = ref(false) const bluetoothConnected = ref(false)
const charging = ref(false) const charging = ref(false)
@@ -32,6 +48,7 @@ export function useDeviceDetail() {
const runMode = ref<RunMode>('0') const runMode = ref<RunMode>('0')
const fanLevel = ref<FanLevel>('0') const fanLevel = ref<FanLevel>('0')
const tvoc = ref<number | null>(null) const tvoc = ref<number | null>(null)
const airQuality = ref<Z2AirQuality>()
const powerMode = ref('0') const powerMode = ref('0')
const filterLife = ref(0) const filterLife = ref(0)
const filterRemainText = ref('预计还可使用 -- 天') const filterRemainText = ref('预计还可使用 -- 天')
@@ -41,7 +58,10 @@ export function useDeviceDetail() {
const syncText = ref('数据实时同步中') const syncText = ref('数据实时同步中')
const tvocDisplay = computed(() => formatTvocValue(tvoc.value ?? undefined)) 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 powerModeText = computed(() => formatPowerMode(powerMode.value))
const batteryDisplay = computed(() => const batteryDisplay = computed(() =>
batteryLevel.value != null ? `${batteryLevel.value}%` : '--', batteryLevel.value != null ? `${batteryLevel.value}%` : '--',
@@ -58,28 +78,54 @@ export function useDeviceDetail() {
showFanLevelControl.value ? AUTO_FAN_LEVEL_OPTIONS : [], 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) { function applyRuntime(data: AppDeviceRuntimeVo) {
const view = mapRuntimeToView(data, deviceName.value) const view = mapRuntimeToView(data, deviceName.value)
deviceName.value = view.deviceName deviceName.value = view.deviceName
runMode.value = view.runMode runMode.value = view.runMode
fanLevel.value = view.fanLevel fanLevel.value = view.fanLevel
tvoc.value = view.tvoc if (view.tvoc != null) tvoc.value = view.tvoc
powerMode.value = view.powerMode powerMode.value = view.powerMode
filterLife.value = view.filterLife filterLife.value = view.filterLife
filterRemainText.value = view.filterRemainText filterRemainText.value = view.filterRemainText
filterReplaced.value = view.filterReplaced filterReplaced.value = view.filterReplaced
isOnline.value = view.isOnline isOnline.value = view.isOnline
if (data.deviceBattery != null) batteryLevel.value = clampPercent(data.deviceBattery)
if (data.status !== undefined) deviceStatus.value = data.status if (data.status !== undefined) deviceStatus.value = data.status
syncText.value = '刚刚更新 · 数据实时同步中'
} }
async function refreshDeviceMeta() { async function refreshDeviceMeta() {
if (!deviceId.value) return if (!deviceId.value) return
try { try {
const list = await fetchDeviceList() 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) return
if (device.deviceId != null) deviceId.value = String(device.deviceId)
if (device.deviceNo) deviceNo.value = device.deviceNo
if (device.mac) deviceMac.value = device.mac 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) bluetoothConnected.value = isBluetoothConnected(device.bluetoothConnected)
charging.value = device.charging === '1' charging.value = device.charging === '1'
if (device.status !== undefined) deviceStatus.value = device.status if (device.status !== undefined) deviceStatus.value = device.status
@@ -109,6 +155,14 @@ export function useDeviceDetail() {
if (!deviceId.value || updating.value) return if (!deviceId.value || updating.value) return
updating.value = true updating.value = true
try { try {
const bleCmd = patch.runMode != null
? encodeRunModeBleCommand(patch.runMode)
: patch.fanLevel != null
? encodeFanLevelBleCommand(patch.fanLevel)
: undefined
if (bleCmd) {
await sendBleCommand(bleCmd)
}
await updateDeviceRuntime({ await updateDeviceRuntime({
deviceId: deviceId.value, deviceId: deviceId.value,
...patch, ...patch,
@@ -135,7 +189,21 @@ export function useDeviceDetail() {
} }
function onSettings() { 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() { function onFilterReplace() {
@@ -143,18 +211,20 @@ export function useDeviceDetail() {
filterReplaceDialogVisible.value = true filterReplaceDialogVisible.value = true
} }
function onConfirmFilterReplace() { async function onConfirmFilterReplace() {
await sendBleCommand(Z2_BLE_CMD.FILTER_RESET)
filterReplaced.value = true filterReplaced.value = true
uni.showToast({ title: '操作成功', icon: 'success' }) uni.showToast({ title: '操作成功', icon: 'success' })
} }
const { startSyncLoop } = useDeviceBluetoothSync({ const { startSyncLoop, sendBleCommand } = useDeviceBluetoothSync({
getDeviceId: () => deviceId.value, getDeviceId: () => deviceId.value,
getMac: () => deviceMac.value, getMac: () => deviceMac.value,
getDeviceName: () => deviceName.value,
getRuntimePatch: () => ({ getRuntimePatch: () => ({
runMode: runMode.value, runMode: runMode.value,
fanLevel: fanLevel.value, fanLevel: fanLevel.value,
tvoc: tvoc.value ?? undefined, tvoc: tvoc.value ?? (airQuality.value ? airQualityToTvoc(airQuality.value) : undefined),
powerMode: powerMode.value, powerMode: powerMode.value,
charging: charging.value ? '1' : '0', charging: charging.value ? '1' : '0',
}), }),
@@ -168,19 +238,29 @@ export function useDeviceDetail() {
onSyncTextChange: (text) => { onSyncTextChange: (text) => {
syncText.value = text syncText.value = text
}, },
onBleRuntimeRead: (patch) => {
applyBleRuntime(patch)
},
onSynced: async () => { onSynced: async () => {
await refreshDeviceMeta() await refreshDeviceMeta()
const data = await fetchDeviceRuntime({ deviceId: deviceId.value })
applyRuntime(data)
}, },
}) })
onLoad((query?: DeviceDetailRouteQuery) => { 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?.name) deviceName.value = decodeURIComponent(String(query.name))
if (query?.locationName) { if (query?.locationName) {
locationName.value = decodeURIComponent(String(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) { if (query?.status) {
deviceStatus.value = String(query.status) deviceStatus.value = String(query.status)
isOnline.value = query.status === '1' isOnline.value = query.status === '1'

View File

@@ -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<DeviceListItem[]> {
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
}
}

View File

@@ -1,7 +1,7 @@
export interface RoomTab { export interface RoomTab {
id: string id: string
name: string name: string
locationId?: number locationId?: string | number
} }
export interface HotDeviceItem { export interface HotDeviceItem {
@@ -12,8 +12,8 @@ export interface HotDeviceItem {
/** POST /app/device/list 查询参数 */ /** POST /app/device/list 查询参数 */
export interface DeviceQueryBody { export interface DeviceQueryBody {
homeId?: number homeId?: string | number
locationId?: number locationId?: string | number
} }
/** 设备状态:0未激活 1在线 2离线 */ /** 设备状态:0未激活 1在线 2离线 */
@@ -21,13 +21,13 @@ export type DeviceStatus = '0' | '1' | '2'
/** POST /app/device/list 返回项 */ /** POST /app/device/list 返回项 */
export interface AppDeviceVo { export interface AppDeviceVo {
deviceId?: number deviceId?: number | string
deviceNo?: string deviceNo?: string
name?: string name?: string
imei?: string imei?: string
mac?: string mac?: string
homeId?: number homeId?: string | number
locationId?: number locationId?: string | number
status?: DeviceStatus | string status?: DeviceStatus | string
bluetoothConnected?: string bluetoothConnected?: string
signalLevel?: string signalLevel?: string

View File

@@ -1,54 +1,44 @@
import type { AppDeviceVo, DeviceListItem, RoomTab } from './device.types' import type { AppDeviceVo, DeviceListItem, RoomTab } from './device.types'
import type { RoomOption } from '@/types/location'
import { sameId } from '@/utils/device-id'
/** 位置 ID → 房间名(后端暂无 locationName 时的本地映射) */ export type LocationMap = Map<string, RoomOption[]>
const LOCATION_NAME_MAP: Record<number, string> = {
1: '客厅',
2: '主卧',
3: '卧室',
4: '厨房',
5: '卫生间',
6: '书房',
}
export function resolveLocationName(locationId?: number): string { function resolveLocationName(
locationId?: string | number,
locations: RoomOption[] = [],
): string {
if (locationId == null) return '未分配' 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 { return {
...device, ...device,
locationName: resolveLocationName(device.locationId), locationName: resolveLocationName(device.locationId, locations),
isOnline: device.status === '1', isOnline: device.status === '1',
isCharging: device.charging === '1', isCharging: device.charging === '1',
} }
} }
/** 根据家庭房间位置列表构建首页 Tab(与所在房间页数据源一致) */
export function buildRoomTabs( export function buildRoomTabs(
devices: DeviceListItem[], locations: { locationId: string | number; name: string }[],
customRooms: RoomTab[] = [],
): RoomTab[] { ): RoomTab[] {
const tabs: RoomTab[] = [{ id: 'all', name: '全部' }] const tabs: RoomTab[] = [{ id: 'all', name: '全部' }]
const seen = new Set<string>() for (const location of locations) {
for (const device of devices) {
if (device.locationId == null) continue
const id = String(device.locationId)
if (seen.has(id)) continue
seen.add(id)
tabs.push({ tabs.push({
id, id: String(location.locationId),
name: device.locationName, name: location.name,
locationId: device.locationId, locationId: location.locationId,
}) })
} }
for (const room of customRooms) {
if (seen.has(room.id)) continue
seen.add(room.id)
tabs.push(room)
}
return tabs return tabs
} }
@@ -57,9 +47,7 @@ export function filterDevicesByRoom(
activeRoomId: string, activeRoomId: string,
): DeviceListItem[] { ): DeviceListItem[] {
if (activeRoomId === 'all') return devices if (activeRoomId === 'all') return devices
const locationId = Number(activeRoomId) return devices.filter((item) => sameId(item.locationId, activeRoomId))
if (Number.isNaN(locationId)) return devices
return devices.filter((item) => item.locationId === locationId)
} }
export function countOnlineDevices(devices: DeviceListItem[]): number { export function countOnlineDevices(devices: DeviceListItem[]): number {

View File

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

View File

@@ -0,0 +1,62 @@
<template>
<view class="home-manage-page">
<app-navbar title="家庭管理" />
<scroll-view class="home-manage-page__scroll" scroll-y>
<view class="home-manage-page__body">
<text class="home-manage-page__summary">{{ summaryText }}</text>
<view class="home-manage-page__list">
<view class="home-manage-page__item" @tap="onOpenNameDialog">
<text class="home-manage-page__title">家庭名称</text>
<image
class="home-manage-page__arrow"
:src="staticAssets.iconChevronLeft"
mode="aspectFit"
/>
</view>
<view class="home-manage-page__divider" />
<view class="home-manage-page__item" @tap="onOpenLocation">
<view class="home-manage-page__location">
<text class="home-manage-page__title">家庭位置</text>
<text v-if="homeAddress" class="home-manage-page__desc">{{ homeAddress }}</text>
</view>
<image
class="home-manage-page__arrow"
:src="staticAssets.iconChevronLeft"
mode="aspectFit"
/>
</view>
</view>
</view>
</scroll-view>
<home-name-dialog
v-model:visible="nameDialogVisible"
:value="homeName"
@confirm="onConfirmHomeName"
/>
</view>
</template>
<script setup lang="ts">
import { staticAssets } from '@/utils/static-assets'
import HomeNameDialog from '../components/home-name-dialog/index.vue'
import { useHomeManage } from '../use-home-manage'
const {
homeName,
homeAddress,
summaryText,
nameDialogVisible,
onOpenNameDialog,
onConfirmHomeName,
onOpenLocation,
} = useHomeManage()
</script>
<style lang="scss" scoped>
@import '../home-manage.scss';
</style>

View File

@@ -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,
}
}

View File

@@ -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;
}

View File

@@ -0,0 +1,10 @@
export interface HomeManageRouteQuery {
homeId?: string
homeName?: string
}
export interface HomeGeoInfo {
address: string
latitude: number
longitude: number
}

View File

@@ -0,0 +1,40 @@
<template>
<view class="home-manage-page">
<app-navbar title="家庭管理" />
<scroll-view class="home-manage-page__scroll" scroll-y>
<view class="home-manage-page__body">
<view v-if="homes.length" class="home-manage-page__list">
<view
v-for="home in homes"
:key="String(home.id)"
class="home-manage-page__item"
@tap="onSelectHome(home)"
>
<text class="home-manage-page__title">{{ home.homeName || '我的家' }}</text>
<image
class="home-manage-page__arrow"
:src="staticAssets.iconChevronLeft"
mode="aspectFit"
/>
</view>
</view>
<view v-else-if="!loading" class="home-manage-page__empty">
<text class="home-manage-page__empty-text">暂无家庭</text>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { staticAssets } from '@/utils/static-assets'
import { useHomeList } from './use-home-list'
const { homes, loading, onSelectHome } = useHomeList()
</script>
<style lang="scss" scoped>
@import './home-manage.scss';
</style>

View File

@@ -0,0 +1,147 @@
<template>
<!-- #ifdef H5 -->
<view class="location-map">
<view :id="mapDomId" class="location-map__canvas" />
<view v-if="hintText" class="location-map__hint">
<text class="location-map__hint-text">{{ hintText }}</text>
</view>
</view>
<!-- #endif -->
<!-- #ifndef H5 -->
<map
class="location-map"
:latitude="latitude"
:longitude="longitude"
:scale="16"
:show-location="true"
:enable-3d="false"
:show-compass="false"
@regionchange="onRegionChange"
/>
<!-- #endif -->
</template>
<script setup lang="ts">
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { getTencentMapKey, loadTencentMapGl, type TencentMapInstance } from '@/utils/tencent-map-loader'
const props = defineProps<{
latitude: number
longitude: number
}>()
const emit = defineEmits<{
'center-change': [center: { latitude: number; longitude: number }]
regionchange: [event: UniHelper.MapRegionChangeEvent]
}>()
// #ifdef H5
const mapDomId = `location-map-${Math.random().toString(36).slice(2, 10)}`
const hintText = ref('')
let mapInstance: TencentMapInstance | null = null
let syncingFromDrag = false
async function initH5Map() {
const key = getTencentMapKey()
if (!key) {
hintText.value = '请在 .env 中配置 VITE_TENCENT_MAP_KEY(腾讯地图 Web 端 Key)'
return
}
try {
await loadTencentMapGl()
await nextTick()
const container = document.getElementById(mapDomId)
if (!container || !window.TMap) {
hintText.value = '地图容器初始化失败'
return
}
hintText.value = ''
mapInstance = new window.TMap.Map(container, {
center: new window.TMap.LatLng(props.latitude, props.longitude),
zoom: 16,
pitch: 0,
rotation: 0,
showControl: false,
baseMap: {
type: 'vector',
},
})
const emitCenter = () => {
if (!mapInstance) return
const center = mapInstance.getCenter()
syncingFromDrag = true
emit('center-change', {
latitude: center.lat,
longitude: center.lng,
})
setTimeout(() => {
syncingFromDrag = false
}, 0)
}
mapInstance.on('dragend', emitCenter)
mapInstance.on('zoom_changed', emitCenter)
} catch (err) {
hintText.value = err instanceof Error ? err.message : '地图加载失败'
}
}
watch(
() => [props.latitude, props.longitude],
([lat, lng]) => {
if (!mapInstance || syncingFromDrag || !window.TMap) return
mapInstance.setCenter(new window.TMap.LatLng(lat, lng))
},
)
onMounted(() => {
void initH5Map()
})
onUnmounted(() => {
mapInstance?.destroy?.()
mapInstance = null
})
// #endif
// #ifndef H5
function onRegionChange(event: UniHelper.MapRegionChangeEvent) {
emit('regionchange', event)
}
// #endif
</script>
<style lang="scss" scoped>
.location-map {
position: relative;
width: 100%;
height: 100%;
}
.location-map__canvas {
width: 100%;
height: 100%;
}
.location-map__hint {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 48rpx;
background: #eef1f5;
z-index: 1;
}
.location-map__hint-text {
font-size: 28rpx;
color: #667085;
line-height: 1.6;
text-align: center;
}
</style>

View File

@@ -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;
}

View File

@@ -0,0 +1,71 @@
<template>
<view class="home-location-page">
<app-navbar title="家庭位置">
<template #right>
<view class="home-location-page__confirm" @tap="onConfirm">
<text class="home-location-page__confirm-icon">✓</text>
</view>
</template>
</app-navbar>
<view class="home-location-page__search" @tap="onChooseLocation">
<text class="home-location-page__search-icon">⌕</text>
<text class="home-location-page__search-text">
{{ searchKeyword || '搜索地点' }}
</text>
</view>
<view class="home-location-page__map-wrap">
<location-map
class="home-location-page__map"
:latitude="latitude"
:longitude="longitude"
@center-change="onMapCenterChange"
@regionchange="onRegionChange"
/>
<view class="home-location-page__pin" aria-hidden="true">
<view class="home-location-page__pin-head" />
<view class="home-location-page__pin-tail" />
</view>
<view
class="home-location-page__locate"
:class="{ 'home-location-page__locate--loading': locating }"
@tap="onLocate"
>
<text class="home-location-page__locate-icon">◎</text>
</view>
</view>
<view class="home-location-page__footer">
<text class="home-location-page__address-label">当前位置</text>
<text class="home-location-page__address-text">
{{ resolvingAddress ? '正在解析地址…' : (addressText || '拖动地图选择家庭位置') }}
</text>
</view>
</view>
</template>
<script setup lang="ts">
import LocationMap from './components/location-map/index.vue'
import { useHomeLocation } from './use-home-location'
const {
latitude,
longitude,
searchKeyword,
addressText,
locating,
resolvingAddress,
onRegionChange,
onMapCenterChange,
onChooseLocation,
onLocate,
onConfirm,
} = useHomeLocation()
</script>
<style lang="scss" scoped>
@import './home-location.scss';
</style>

View File

@@ -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<UniApp.GetLocationSuccess> {
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<typeof setTimeout> | 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<string, string | undefined>) => {
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,
}
}

View File

@@ -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<AppHomeVo[]>([])
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,
}
}

View File

@@ -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,
}
}

View File

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

View File

@@ -0,0 +1,130 @@
<template>
<view v-if="visible" class="unbind-confirm-dialog">
<view class="unbind-confirm-dialog__mask" @tap="onCancel" />
<view class="unbind-confirm-dialog__panel" @tap.stop>
<text class="unbind-confirm-dialog__title">解绑前请确认</text>
<view class="unbind-confirm-dialog__body">
<text class="unbind-confirm-dialog__item">
· 解绑后设备将从您的账号移除,需重新配对才能控制
</text>
<text class="unbind-confirm-dialog__item">
· 解绑后其他家庭成员也将失去控制权限
</text>
</view>
<view class="unbind-confirm-dialog__footer">
<view class="unbind-confirm-dialog__btn" @tap.stop="onCancel">
<text class="unbind-confirm-dialog__btn-text">取消</text>
</view>
<view class="unbind-confirm-dialog__divider-v" />
<view class="unbind-confirm-dialog__btn" @tap.stop="onConfirm">
<text class="unbind-confirm-dialog__btn-text unbind-confirm-dialog__btn-text--danger">
确认解绑
</text>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
defineProps<{
visible: boolean
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
confirm: []
}>()
function onCancel() {
emit('update:visible', false)
}
function onConfirm() {
emit('confirm')
emit('update:visible', false)
}
</script>
<style lang="scss" scoped>
.unbind-confirm-dialog {
position: fixed;
inset: 0;
z-index: 500;
display: flex;
align-items: center;
justify-content: center;
}
.unbind-confirm-dialog__mask {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.55);
}
.unbind-confirm-dialog__panel {
position: relative;
width: 560rpx;
background: #fff;
border-radius: 24rpx;
overflow: hidden;
z-index: 1;
}
.unbind-confirm-dialog__title {
display: block;
padding: 48rpx 40rpx 28rpx;
font-size: 34rpx;
font-weight: 600;
color: $color-text;
text-align: center;
line-height: 1.3;
}
.unbind-confirm-dialog__body {
padding: 0 40rpx 40rpx;
}
.unbind-confirm-dialog__item {
display: block;
font-size: 28rpx;
color: $color-text-secondary;
line-height: 1.65;
text-align: left;
margin-bottom: 12rpx;
&:last-child {
margin-bottom: 0;
}
}
.unbind-confirm-dialog__footer {
display: flex;
align-items: stretch;
border-top: 1rpx solid #ebedf0;
}
.unbind-confirm-dialog__btn {
flex: 1;
height: 96rpx;
display: flex;
align-items: center;
justify-content: center;
}
.unbind-confirm-dialog__divider-v {
width: 1rpx;
background: #ebedf0;
}
.unbind-confirm-dialog__btn-text {
font-size: 32rpx;
color: $color-text;
line-height: 1;
}
.unbind-confirm-dialog__btn-text--danger {
color: #f56c6c;
font-weight: 500;
}
</style>

View File

@@ -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<void>({
url: '/app/device/updateSettings',
method: 'POST',
data,
})
}
/** POST /app/device/unbind — 解绑设备 */
export function unbindDevice(data: { deviceId: string | number }) {
return request<void>({
url: '/app/device/unbind',
method: 'POST',
data,
})
}

View File

@@ -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;
}

View File

@@ -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'

View File

@@ -0,0 +1,115 @@
<template>
<view class="device-settings-page">
<app-navbar title="设备设置" />
<scroll-view class="device-settings-page__scroll" scroll-y>
<view class="device-settings-page__body">
<view class="device-settings-card">
<view class="device-settings-card__top">
<image
class="device-settings-card__thumb"
:src="staticAssets.logo"
mode="aspectFill"
/>
<view class="device-settings-card__meta">
<text class="device-settings-card__name">{{ deviceName }}</text>
<view class="device-settings-card__status">
<view
class="device-settings-card__status-dot"
:class="{ 'device-settings-card__status-dot--offline': !bluetoothConnected }"
/>
<text class="device-settings-card__status-text">{{ connectionText }}</text>
</view>
</view>
</view>
<view class="device-settings-card__model-row">
<text class="device-settings-card__model-label">产品型号</text>
<text class="device-settings-card__model-value">{{ productModel }}</text>
</view>
</view>
<text class="device-settings-page__section-title">基础设置</text>
<view class="device-settings-list">
<view class="device-settings-list__item" @tap="onOpenNameDialog">
<view class="device-settings-list__content">
<text class="device-settings-list__title">设备名称</text>
<text class="device-settings-list__desc">{{ deviceName }}</text>
</view>
<image
class="device-settings-list__arrow"
:src="staticAssets.iconChevronLeft"
mode="aspectFit"
/>
</view>
<view class="device-settings-list__divider" />
<view class="device-settings-list__item" @tap="onPickRoom">
<view class="device-settings-list__content">
<text class="device-settings-list__title">所在房间</text>
<text class="device-settings-list__desc">{{ locationName }}</text>
</view>
<image
class="device-settings-list__arrow"
:src="staticAssets.iconChevronLeft"
mode="aspectFit"
/>
</view>
<view class="device-settings-list__divider" />
<view class="device-settings-list__item" @tap="onOpenUnbindDialog">
<view class="device-settings-list__content">
<text class="device-settings-list__title device-settings-list__title--danger">
解除设备绑定
</text>
<text class="device-settings-list__desc">解绑后需重新配对才能使用</text>
</view>
<image
class="device-settings-list__arrow"
:src="staticAssets.iconChevronLeft"
mode="aspectFit"
/>
</view>
</view>
</view>
</scroll-view>
<device-name-dialog
v-model:visible="nameDialogVisible"
:value="deviceName"
@confirm="onConfirmName"
/>
<unbind-confirm-dialog
v-model:visible="unbindDialogVisible"
@confirm="onConfirmUnbind"
/>
</view>
</template>
<script setup lang="ts">
import { staticAssets } from '@/utils/static-assets'
import DeviceNameDialog from './components/device-name-dialog/index.vue'
import UnbindConfirmDialog from './components/unbind-confirm-dialog/index.vue'
import { useDeviceSettings } from './use-device-settings'
const {
deviceName,
productModel,
locationName,
connectionText,
bluetoothConnected,
nameDialogVisible,
unbindDialogVisible,
onOpenNameDialog,
onPickRoom,
onOpenUnbindDialog,
onConfirmName,
onConfirmUnbind,
} = useDeviceSettings()
</script>
<style lang="scss" scoped>
@import './device-settings.scss';
</style>

View File

@@ -0,0 +1,66 @@
<template>
<view class="device-room-page">
<app-navbar title="所在房间">
<template #right>
<view class="device-room-page__add-btn" @tap="onOpenAddDialog">
<text class="device-room-page__add-icon">+</text>
</view>
</template>
</app-navbar>
<view v-if="listLoading" class="device-room-page__loading">
<text class="device-room-page__loading-text">加载中...</text>
</view>
<view v-else-if="!roomOptions.length" class="device-room-page__empty">
<text class="device-room-page__empty-text">暂无房间位置,点击右上角添加</text>
</view>
<view v-else class="device-room-page__list">
<view
v-for="room in roomOptions"
:key="room.locationId"
class="device-room-page__item"
:class="{ 'device-room-page__item--disabled': saving }"
@tap="onSelectRoom(room)"
>
<text class="device-room-page__name">{{ room.name }}</text>
<view
class="device-room-page__indicator"
:class="{ 'device-room-page__indicator--selected': isRoomSelected(room.locationId) }"
>
<text
v-if="isRoomSelected(room.locationId)"
class="device-room-page__check-mark"
>
✓
</text>
</view>
</view>
</view>
<room-location-dialog
v-model:visible="addDialogVisible"
title="添加房间位置"
@confirm="onConfirmAddRoom"
/>
</view>
</template>
<script setup lang="ts">
import RoomLocationDialog from '../../components/room-location-dialog/index.vue'
import { useDeviceRoom } from './use-device-room'
const {
roomOptions,
listLoading,
saving,
addDialogVisible,
isRoomSelected,
onOpenAddDialog,
onConfirmAddRoom,
onSelectRoom,
} = useDeviceRoom()
</script>
<style lang="scss" scoped>
@import './room.scss';
</style>

View File

@@ -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;
}

View File

@@ -0,0 +1,11 @@
export interface DeviceRoomRouteQuery {
deviceId?: string
homeId?: string
locationId?: string
locationName?: string
}
export interface RoomSelectedPayload {
locationId: string | number
name: string
}

View File

@@ -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<string>()
const roomOptions = ref<RoomOption[]>([])
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,
}
}

View File

@@ -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,
}
}

View File

@@ -1,8 +1,14 @@
import { onShow } from '@dcloudio/uni-app' import { onShow } from '@dcloudio/uni-app'
import { computed, ref } from 'vue' 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 { getErrorMessage } from '@/utils/api-error'
import { fetchRoomList } from '@/api/location'
import { fetchDeviceList } from './device.api' import { fetchDeviceList } from './device.api'
import type { DeviceListItem, HotDeviceItem, RoomTab } from './device.types' import type { DeviceListItem, HotDeviceItem, RoomTab } from './device.types'
import type { LocationMap } from './device.utils'
import { refreshDevicePresence } from './device-presence'
import { import {
buildRoomTabs, buildRoomTabs,
countOnlineDevices, countOnlineDevices,
@@ -10,8 +16,6 @@ import {
mapDeviceItem, mapDeviceItem,
} from './device.utils' } from './device.utils'
const CUSTOM_ROOMS_KEY = 'device_custom_rooms'
const DEFAULT_HOT_DEVICES: HotDeviceItem[] = [ const DEFAULT_HOT_DEVICES: HotDeviceItem[] = [
{ {
id: 'z2', 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() { export function useDevice() {
const homeName = ref('我的家') const homeName = ref('我的家')
const customRooms = ref<RoomTab[]>(loadCustomRooms()) const homeId = ref('')
const rooms = ref<RoomTab[]>([{ id: 'all', name: '全部' }]) const rooms = ref<RoomTab[]>([{ id: 'all', name: '全部' }])
const activeRoomId = ref('all') const activeRoomId = ref('all')
const devices = ref<DeviceListItem[]>([]) const devices = ref<DeviceListItem[]>([])
@@ -60,8 +49,27 @@ export function useDevice() {
const onlineCount = computed(() => countOnlineDevices(filteredDevices.value)) const onlineCount = computed(() => countOnlineDevices(filteredDevices.value))
function refreshRoomTabs() { async function buildLocationMap(homeIds: string[]): Promise<LocationMap> {
rooms.value = buildRoomTabs(devices.value, customRooms.value) const map: LocationMap = new Map()
const seen = new Set<string>()
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)) { if (!rooms.value.some((room) => room.id === activeRoomId.value)) {
activeRoomId.value = 'all' activeRoomId.value = 'all'
} }
@@ -71,9 +79,27 @@ export function useDevice() {
if (loading.value) return if (loading.value) return
loading.value = true loading.value = true
try { try {
const list = await fetchDeviceList() const list = await fetchDeviceList() ?? []
devices.value = (list ?? []).map(mapDeviceItem) const homeIds = [
refreshRoomTabs() ...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) { } catch (err) {
uni.showToast({ title: getErrorMessage(err, '加载设备失败'), icon: 'none' }) uni.showToast({ title: getErrorMessage(err, '加载设备失败'), icon: 'none' })
} finally { } finally {
@@ -97,16 +123,26 @@ export function useDevice() {
roomDialogVisible.value = true roomDialogVisible.value = true
} }
function onConfirmAddRoom(name: string) { async function onConfirmAddRoom(name: string) {
const room: RoomTab = { const trimmed = name.trim()
id: `custom-${Date.now()}`, if (!trimmed) return
name, 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() { function onStartBind() {
@@ -122,11 +158,11 @@ export function useDevice() {
} }
function onNotify() { function onNotify() {
uni.showToast({ title: '消息功能开发中', icon: 'none' }) uni.navigateTo({ url: '/pages/mine/notify/index' })
} }
function onHomeManage() { function onHomeManage() {
uni.showToast({ title: '家庭管理功能开发中', icon: 'none' }) uni.navigateTo({ url: '/pages/device/home-manage/index' })
} }
function onManageDevices() { function onManageDevices() {
@@ -140,8 +176,11 @@ export function useDevice() {
} }
const q = [ const q = [
`deviceId=${device.deviceId}`, `deviceId=${device.deviceId}`,
...(device.deviceNo ? [`deviceNo=${encodeURIComponent(device.deviceNo)}`] : []),
`name=${encodeURIComponent(device.name || '')}`, `name=${encodeURIComponent(device.name || '')}`,
`locationName=${encodeURIComponent(device.locationName || '')}`, `locationName=${encodeURIComponent(device.locationName || '')}`,
...(device.locationId != null ? [`locationId=${device.locationId}`] : []),
...(device.homeId != null ? [`homeId=${device.homeId}`] : []),
`status=${device.status ?? ''}`, `status=${device.status ?? ''}`,
`bluetoothConnected=${device.bluetoothConnected ?? '0'}`, `bluetoothConnected=${device.bluetoothConnected ?? '0'}`,
`charging=${device.charging ?? '0'}`, `charging=${device.charging ?? '0'}`,

View File

@@ -64,6 +64,4 @@ const {
} = useLogin() } = useLogin()
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped src="./login.scss"></style>
@import './login.scss';
</style>

View File

@@ -2,6 +2,7 @@ import { ref } from 'vue'
import { useSmsCode } from '@/hooks/use-sms-code' import { useSmsCode } from '@/hooks/use-sms-code'
import { isValidPassword, isValidPhone, isValidSmsCode } from '@/utils/validate' import { isValidPassword, isValidPhone, isValidSmsCode } from '@/utils/validate'
import { getErrorMessage } from '@/utils/api-error' import { getErrorMessage } from '@/utils/api-error'
import { setupMessageInbox } from '@/hooks/use-message-inbox'
import { getStoredToken } from '@/utils/auth-token' import { getStoredToken } from '@/utils/auth-token'
import { SMS_CODE_SCENE } from '@/types/api' import { SMS_CODE_SCENE } from '@/types/api'
import { loginByPassword, loginBySms } from './login.api' import { loginByPassword, loginBySms } from './login.api'
@@ -63,6 +64,7 @@ export function useLogin() {
uni.showToast({ title: '登录失败,未获取到令牌', icon: 'none' }) uni.showToast({ title: '登录失败,未获取到令牌', icon: 'none' })
return return
} }
setupMessageInbox()
uni.showToast({ title: '登录成功', icon: 'success' }) uni.showToast({ title: '登录成功', icon: 'success' })
uni.reLaunch({ url: '/pages/device/index' }) uni.reLaunch({ url: '/pages/device/index' })
} catch (err) { } catch (err) {

View File

@@ -0,0 +1,35 @@
<template>
<view class="change-phone-page">
<app-navbar title="手机号换绑" />
<view class="change-phone-page__body">
<text class="change-phone-page__title">新手机号码绑定</text>
<view class="change-phone-page__form change-phone-page__form--bind">
<base-input v-model="phone" icon="phone" type="number" placeholder="请输入手机号" :maxlength="11" />
<base-input v-model="code" icon="shield" type="number" placeholder="请输入验证码" :maxlength="6">
<template #suffix>
<sms-code-btn :counting="counting" :seconds="seconds" @send="onSendCode" />
</template>
</base-input>
</view>
<view class="change-phone-page__btn">
<primary-button text="确定" @click="submit" />
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { useChangePhoneBind } from './use-change-phone-bind'
const { phone, code, counting, seconds, onSendCode, submit } = useChangePhoneBind()
</script>
<style lang="scss" scoped>
@import '../change-phone.scss';
.change-phone-page__form--bind {
margin-top: 64rpx;
}
</style>

View File

@@ -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,
}
}

View File

@@ -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<string>({
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<void>({
url: '/app/auth/changePhone/verifyNew',
method: 'POST',
data: body,
})
}

View File

@@ -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);
}

View File

@@ -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
}

View File

@@ -0,0 +1,37 @@
<template>
<view class="change-phone-page">
<app-navbar title="手机号换绑" />
<view class="change-phone-page__body">
<text class="change-phone-page__title">原手机号验证</text>
<text class="change-phone-page__subtitle">{{ subtitle }}</text>
<view class="change-phone-page__form">
<base-input v-model="phone" icon="phone" type="number" placeholder="请输入手机号" :maxlength="11" />
<base-input v-model="code" icon="shield" type="number" placeholder="请输入验证码" :maxlength="6">
<template #suffix>
<sms-code-btn :counting="counting" :seconds="seconds" @send="onSendCode" />
</template>
</base-input>
</view>
<view class="change-phone-page__btn">
<primary-button text="下一步" @click="nextStep" />
</view>
<view class="change-phone-page__link-row">
<text class="change-phone-page__link" @tap="onUnavailablePhone">原手机号码已不可用?</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { useChangePhoneVerify } from './use-change-phone-verify'
const { phone, code, counting, seconds, subtitle, onSendCode, onUnavailablePhone, nextStep } =
useChangePhoneVerify()
</script>
<style lang="scss" scoped>
@import './change-phone.scss';
</style>

View File

@@ -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,
}
}

View File

@@ -0,0 +1,123 @@
<template>
<view v-if="visible" class="avatar-action-sheet">
<view class="avatar-action-sheet__mask" @tap="onClose" />
<view class="avatar-action-sheet__panel" @tap.stop>
<text class="avatar-action-sheet__title">更换头像</text>
<view class="avatar-action-sheet__option" @tap="onTakePhoto">
<text>拍照</text>
</view>
<view class="avatar-action-sheet__divider" />
<view class="avatar-action-sheet__option" @tap="onChooseAlbum">
<text>从手机相册选择</text>
</view>
<view class="avatar-action-sheet__divider" />
<view class="avatar-action-sheet__option" @tap="onPreview">
<text>查看当前头像</text>
</view>
</view>
<view class="avatar-action-sheet__cancel-wrap" @tap.stop>
<view class="avatar-action-sheet__cancel" @tap="onClose">
<text>取消</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
defineProps<{
visible: boolean
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
takePhoto: []
chooseAlbum: []
preview: []
}>()
function onClose() {
emit('update:visible', false)
}
function onTakePhoto() {
emit('takePhoto')
emit('update:visible', false)
}
function onChooseAlbum() {
emit('chooseAlbum')
emit('update:visible', false)
}
function onPreview() {
emit('preview')
emit('update:visible', false)
}
</script>
<style lang="scss" scoped>
.avatar-action-sheet {
position: fixed;
inset: 0;
z-index: 500;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.avatar-action-sheet__mask {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.55);
}
.avatar-action-sheet__panel {
position: relative;
margin: 0 16rpx;
background: #fff;
border-radius: 24rpx 24rpx 0 0;
overflow: hidden;
z-index: 1;
}
.avatar-action-sheet__title {
display: block;
padding: 28rpx 32rpx 20rpx;
font-size: 26rpx;
color: #999;
text-align: center;
line-height: 1.4;
}
.avatar-action-sheet__option {
height: 108rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
color: $color-text;
}
.avatar-action-sheet__divider {
height: 1rpx;
background: #eee;
margin: 0 24rpx;
}
.avatar-action-sheet__cancel-wrap {
position: relative;
z-index: 1;
padding: 16rpx 16rpx calc(16rpx + env(safe-area-inset-bottom));
}
.avatar-action-sheet__cancel {
height: 108rpx;
background: #fff;
border-radius: 24rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
color: $color-text;
}
</style>

View File

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

View File

@@ -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;
}

View File

@@ -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'

View File

@@ -0,0 +1,184 @@
<template>
<view class="edit-profile-page">
<view class="edit-profile-page__navbar">
<app-navbar title="编辑资料" />
</view>
<scroll-view class="edit-profile-page__scroll" scroll-y>
<view class="edit-profile-page__body">
<view class="edit-profile-page__avatar-section">
<view class="edit-profile-page__avatar-wrap">
<image
v-if="form.avatarUrl"
class="edit-profile-page__avatar"
:src="form.avatarUrl"
mode="aspectFill"
/>
<view v-else class="edit-profile-page__avatar-placeholder" />
</view>
<text class="edit-profile-page__change-avatar" @tap="onOpenAvatarSheet">
更换头像
</text>
<text class="edit-profile-page__avatar-tip">
支持 JPG / PNG,建议使用 200x200 以上正方形图片
</text>
</view>
<view class="edit-profile-page__list">
<view class="edit-profile-page__item" @tap="onOpenNicknameDialog">
<text class="edit-profile-page__label">昵称</text>
<view class="edit-profile-page__right">
<text
class="edit-profile-page__value"
:class="{ 'edit-profile-page__value--placeholder': !form.nickName }"
>
{{ form.nickName || '请输入昵称' }}
</text>
<image
class="edit-profile-page__arrow"
:src="staticAssets.iconChevronLeft"
mode="aspectFit"
/>
</view>
</view>
<view class="edit-profile-page__divider" />
<view class="edit-profile-page__item" @tap="onPickGender">
<text class="edit-profile-page__label">性别</text>
<view class="edit-profile-page__right">
<text class="edit-profile-page__value">{{ genderText }}</text>
<image
class="edit-profile-page__arrow"
:src="staticAssets.iconChevronLeft"
mode="aspectFit"
/>
</view>
</view>
<view class="edit-profile-page__divider" />
<picker
mode="date"
:value="form.birthday"
:end="today"
@change="onBirthdayChange"
>
<view class="edit-profile-page__item">
<text class="edit-profile-page__label">生日</text>
<view class="edit-profile-page__right">
<text
class="edit-profile-page__value"
:class="{ 'edit-profile-page__value--placeholder': !form.birthday }"
>
{{ form.birthday || '请选择生日' }}
</text>
<image
class="edit-profile-page__arrow"
:src="staticAssets.iconChevronLeft"
mode="aspectFit"
/>
</view>
</view>
</picker>
<view class="edit-profile-page__divider" />
<picker mode="region" :value="regionValue" @change="onRegionChange">
<view class="edit-profile-page__item">
<text class="edit-profile-page__label">所在地</text>
<view class="edit-profile-page__right">
<text
class="edit-profile-page__value"
:class="{
'edit-profile-page__value--placeholder': locationText === '未设置',
}"
>
{{ locationText }}
</text>
<image
class="edit-profile-page__arrow"
:src="staticAssets.iconChevronLeft"
mode="aspectFit"
/>
</view>
</view>
</picker>
<view class="edit-profile-page__divider" />
<view class="edit-profile-page__item">
<text class="edit-profile-page__label">手机号</text>
<view class="edit-profile-page__right">
<text class="edit-profile-page__value">
{{ maskedPhone || '未绑定' }}
</text>
<text class="edit-profile-page__change-phone" @tap.stop="onChangePhone">
更换
</text>
</view>
</view>
</view>
</view>
</scroll-view>
<view class="edit-profile-page__footer">
<view
class="edit-profile-page__save-btn"
:class="{ 'edit-profile-page__save-btn--disabled': saving || uploadingAvatar }"
@tap="onSave"
>
保存修改
</view>
</view>
<avatar-action-sheet
v-model:visible="avatarSheetVisible"
@take-photo="onTakePhoto"
@choose-album="onChooseAlbum"
@preview="onPreviewAvatar"
/>
<nickname-dialog
v-model:visible="nicknameDialogVisible"
:value="form.nickName"
@confirm="onConfirmNickname"
/>
</view>
</template>
<script setup lang="ts">
import { staticAssets } from '@/utils/static-assets'
import AvatarActionSheet from './components/avatar-action-sheet/index.vue'
import NicknameDialog from './components/nickname-dialog/index.vue'
import { useEditProfile } from './use-edit-profile'
const today = new Date().toISOString().slice(0, 10)
const {
saving,
uploadingAvatar,
form,
maskedPhone,
genderText,
locationText,
regionValue,
avatarSheetVisible,
nicknameDialogVisible,
onOpenAvatarSheet,
onPreviewAvatar,
onTakePhoto,
onChooseAlbum,
onOpenNicknameDialog,
onConfirmNickname,
onPickGender,
onBirthdayChange,
onRegionChange,
onChangePhone,
onSave,
} = useEditProfile()
</script>
<style lang="scss" scoped>
@import './edit.scss';
</style>

View File

@@ -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<string, string> = {
'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<ProfileFormState>(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<UniApp.ChooseImageSuccessCallbackResult>((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,
}
}

View File

@@ -1,44 +1,107 @@
<template> <template>
<view class="mine-page"> <view class="mine-page">
<view class="mine-page__placeholder"> <view class="mine-page__content" :style="{ paddingTop: statusBarHeight + 16 + 'px' }">
<text class="mine-page__title">我的</text> <view class="mine-page__profile">
<text class="mine-page__hint">个人中心页面开发中</text> <view class="mine-page__avatar-wrap">
<image
v-if="avatarUrl"
class="mine-page__avatar"
:src="avatarUrl"
mode="aspectFill"
/>
<view v-else class="mine-page__avatar-placeholder" />
</view>
<view class="mine-page__profile-meta">
<view class="mine-page__name-row" @tap="onEditNickname">
<text class="mine-page__nickname">{{ nickName }}</text>
<view class="mine-page__edit">
<image
class="mine-page__edit-icon"
:src="staticAssets.iconDeviceNameEdit"
mode="aspectFit"
/>
</view>
</view>
<text class="mine-page__phone">{{ phone || '未绑定手机号' }}</text>
</view>
</view>
<view class="mine-page__stats">
<view
v-for="item in stats"
:key="item.label"
class="mine-page__stat-item"
>
<text class="mine-page__stat-value">{{ item.value }}</text>
<text class="mine-page__stat-label">{{ item.label }}</text>
</view>
</view>
<view class="mine-page__quick">
<view
v-for="action in quickActions"
:key="action.id"
class="mine-page__quick-item"
@tap="onQuickAction(action)"
>
<view class="mine-page__quick-icon">
<image
class="mine-page__quick-icon-img"
:src="action.icon"
mode="aspectFit"
/>
</view>
<text class="mine-page__quick-label">{{ action.label }}</text>
</view>
</view>
<view class="mine-page__menu">
<template v-for="(item, index) in menuList" :key="item.id">
<view
v-if="index > 0"
class="mine-page__menu-divider"
/>
<view class="mine-page__menu-item" @tap="onMenuTap(item)">
<text class="mine-page__menu-title">{{ item.title }}</text>
<view class="mine-page__menu-right">
<text v-if="item.value" class="mine-page__menu-value">{{ item.value }}</text>
<image
class="mine-page__menu-arrow"
:src="staticAssets.iconChevronLeft"
mode="aspectFit"
/>
</view>
</view>
</template>
</view>
</view> </view>
<app-tab-bar :current="tabCurrent" /> <app-tab-bar :current="tabCurrent" />
</view> </view>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useTabPage } from '@/hooks/use-tab-page' import { useTabPage } from '@/hooks/use-tab-page'
import { staticAssets } from '@/utils/static-assets'
import { useMine } from './use-mine'
const { tabCurrent } = useTabPage('mine') const { tabCurrent } = useTabPage('mine')
const {
statusBarHeight,
nickName,
phone,
avatarUrl,
stats,
quickActions,
menuList,
onEditNickname,
onQuickAction,
onMenuTap,
} = useMine()
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.mine-page { @import './mine.scss';
min-height: 100vh;
background: $color-bg-page;
display: flex;
align-items: center;
justify-content: center;
padding-bottom: calc(120rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
}
.mine-page__placeholder {
text-align: center;
}
.mine-page__title {
display: block;
font-size: 40rpx;
font-weight: 600;
color: $color-text;
margin-bottom: 16rpx;
}
.mine-page__hint {
font-size: 28rpx;
color: $color-text-secondary;
}
</style> </style>

View File

@@ -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,
}
}

192
src/pages/mine/mine.scss Normal file
View File

@@ -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;
}

View File

@@ -0,0 +1,11 @@
export interface MineQuickAction {
id: string
label: string
icon: string
}
export interface MineMenuItem {
id: string
title: string
value?: string
}

View File

@@ -0,0 +1,79 @@
<template>
<view class="notify-page">
<app-navbar title="通知">
<template #right>
<view class="notify-page__mark-all" @tap="onMarkAllRead">
<text class="notify-page__mark-all-text">全部已读</text>
</view>
</template>
</app-navbar>
<scroll-view
class="notify-page__scroll"
scroll-y
refresher-enabled
:refresher-triggered="loading"
@refresherrefresh="onRefresh"
@scrolltolower="onLoadMore"
>
<view class="notify-page__body">
<text class="notify-page__section">{{ sectionTitle }}</text>
<view v-if="messages.length" class="notify-page__list">
<view
v-for="item in messages"
:key="normalizeId(item)"
class="notify-page__item"
@tap="onItemTap(item)"
>
<view class="notify-page__item-head">
<view class="notify-page__title-wrap">
<view v-if="isUnreadMessage(item)" class="notify-page__dot" />
<text class="notify-page__title">{{ item.title || '系统通知' }}</text>
</view>
<text class="notify-page__time">{{ formatNotifyTime(item) }}</text>
</view>
<text class="notify-page__content">{{ item.content || '暂无详情' }}</text>
</view>
</view>
<view v-else-if="!loading" class="notify-page__empty">
暂无通知消息
</view>
<view v-if="loading && !messages.length" class="notify-page__loading">
加载中...
</view>
<view v-else-if="messages.length && !hasMore" class="notify-page__footer-tip">
没有更多了
</view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import type { AppMessageVo } from '@/types/message'
import { formatNotifyTime, isUnreadMessage, normalizeMessageId } from './notify.utils'
import { useNotify } from './use-notify'
const {
messages,
loading,
hasMore,
sectionTitle,
onRefresh,
onLoadMore,
onItemTap,
onMarkAllRead,
} = useNotify()
function normalizeId(item: AppMessageVo) {
return normalizeMessageId(item) || `${item.title}-${item.timestamp}`
}
</script>
<style lang="scss" scoped>
@import './notify.scss';
</style>

View File

@@ -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;
}

View File

@@ -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')}`
}

View File

@@ -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<AppMessageVo[]>([])
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<string, AppMessageVo>()
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,
}
}

View File

@@ -0,0 +1,70 @@
<template>
<view class="privacy-page">
<app-navbar title="账号与安全" />
<scroll-view class="privacy-page__scroll" scroll-y>
<view class="privacy-page__body">
<view
v-for="section in sections"
:key="section.id"
class="privacy-page__section"
>
<text class="privacy-page__section-title">{{ section.title }}</text>
<view class="privacy-page__list">
<template v-for="(item, index) in section.items" :key="item.id">
<view
v-if="index > 0"
class="privacy-page__divider"
/>
<view class="privacy-page__item" @tap="onItemTap(item.id)">
<view class="privacy-page__item-main">
<text
class="privacy-page__item-title"
:class="{ 'privacy-page__item-title--danger': item.danger }"
>
{{ item.title }}
</text>
<text v-if="item.desc" class="privacy-page__item-desc">
{{ item.desc }}
</text>
</view>
<image
class="privacy-page__arrow"
:src="staticAssets.iconChevronLeft"
mode="aspectFit"
/>
</view>
</template>
</view>
</view>
<view class="privacy-page__tip">
<text class="privacy-page__tip-icon">💡</text>
<view class="privacy-page__tip-content">
<text class="privacy-page__tip-title">安全小贴士</text>
<text class="privacy-page__tip-text">
为保障您的账号安全,建议定期修改密码,不要将账号密码透露给他人。如发现异常登录,请及时前往登录设备管理下线陌生设备。
</text>
</view>
</view>
<view class="privacy-page__footer">
<view class="privacy-page__logout-btn" @tap="onLogout">
<text class="privacy-page__logout-text">退出登录</text>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { staticAssets } from '@/utils/static-assets'
import { usePrivacy } from './use-privacy'
const { sections, onItemTap, onLogout } = usePrivacy()
</script>
<style lang="scss" scoped>
@import './privacy.scss';
</style>

View File

@@ -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;
}

View File

@@ -0,0 +1,12 @@
export interface PrivacyMenuItem {
id: string
title: string
desc?: string
danger?: boolean
}
export interface PrivacySection {
id: string
title: string
items: PrivacyMenuItem[]
}

View File

@@ -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<PrivacySection[]>(() => [
{
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,
}
}

View File

@@ -0,0 +1,37 @@
<template>
<view class="set-password-page">
<app-navbar title="设置新密码" />
<view class="set-password-page__body">
<text class="set-password-page__title">设置新密码</text>
<view class="set-password-page__form set-password-page__form--password">
<base-input v-model="password" icon="lock" type="password" placeholder="请输入密码" />
<base-input v-model="confirmPassword" icon="lock" type="password" placeholder="确认密码" />
</view>
<text class="set-password-page__hint">8-20位字符,须包含字母、数字、符号中的至少2种。</text>
<view class="set-password-page__btn">
<primary-button text="确认修改" @click="submit" />
</view>
<view class="set-password-page__link-row">
<text class="set-password-page__link" @tap="onForgotPassword">忘记密码</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { useSetPassword } from './use-set-password'
const { password, confirmPassword, onForgotPassword, submit } = useSetPassword()
</script>
<style lang="scss" scoped>
@import './set-password.scss';
.set-password-page__form--password {
margin-top: 64rpx;
}
</style>

View File

@@ -0,0 +1,46 @@
<template>
<view class="set-password-page">
<app-navbar title="验证手机号" />
<view class="set-password-page__body">
<text class="set-password-page__title">验证手机号</text>
<text class="set-password-page__subtitle">短信验证码已发送,请填写6位数字验证码</text>
<view class="set-password-page__phone-row">
<image
class="set-password-page__phone-icon"
:src="staticAssets.iconPhone"
mode="aspectFit"
/>
<text class="set-password-page__phone-text">{{ maskedPhone || '未绑定手机号' }}</text>
<image
class="set-password-page__arrow"
:src="staticAssets.iconChevronLeft"
mode="aspectFit"
/>
</view>
<view class="set-password-page__form">
<base-input v-model="code" icon="shield" type="number" placeholder="请输入验证码" :maxlength="6">
<template #suffix>
<sms-code-btn :counting="counting" :seconds="seconds" @send="onSendCode" />
</template>
</base-input>
</view>
<view class="set-password-page__btn">
<primary-button text="下一步" @click="nextStep" />
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { staticAssets } from '@/utils/static-assets'
import { useSetPasswordPhone } from './use-set-password-phone'
const { code, maskedPhone, counting, seconds, onSendCode, nextStep } = useSetPasswordPhone()
</script>
<style lang="scss" scoped>
@import '../set-password.scss';
</style>

View File

@@ -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,
}
}

View File

@@ -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<string | ForgetPasswordVerifyResult>({
url: '/app/auth/forgetPassword/verifyCode',
method: 'POST',
data: body,
}).then(normalizeResetToken)
}
/** POST /app/auth/forgetPassword/doReset — 凭重置令牌设置新密码 */
export function resetPasswordByToken(data: ForgetPasswordResetBody) {
return request<void>({
url: '/app/auth/forgetPassword/doReset',
method: 'POST',
data,
})
}

View File

@@ -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;
}

View File

@@ -0,0 +1,10 @@
export interface SetPasswordPhoneForm {
phone: string
code: string
}
export interface SetPasswordForm {
resetToken: string
newPassword: string
confirmPassword: string
}

View File

@@ -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 }
}

View File

@@ -0,0 +1,31 @@
<template>
<view class="set-password-page">
<app-navbar title="身份验证" />
<view class="set-password-page__body">
<text class="set-password-page__title">身份验证</text>
<text class="set-password-page__subtitle">请先进行身份验证</text>
<view class="set-password-page__method-list">
<view class="set-password-page__method-item" @tap="onPhoneVerify">
<text class="set-password-page__method-text">手机验证码验证</text>
<image
class="set-password-page__arrow"
:src="staticAssets.iconChevronLeft"
mode="aspectFit"
/>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { staticAssets } from '@/utils/static-assets'
import { useSetPasswordVerify } from './use-set-password-verify'
const { onPhoneVerify } = useSetPasswordVerify()
</script>
<style lang="scss" scoped>
@import '../set-password.scss';
</style>

View File

@@ -0,0 +1,7 @@
export function useSetPasswordVerify() {
function onPhoneVerify() {
uni.navigateTo({ url: '/pages/mine/set-password/phone/index' })
}
return { onPhoneVerify }
}

View File

@@ -0,0 +1,68 @@
<template>
<view class="storage-page">
<app-navbar title="储存空间" />
<scroll-view class="storage-page__scroll" scroll-y>
<view class="storage-page__body">
<text class="storage-page__section-label">储存空间</text>
<view class="storage-page__ring-wrap">
<view class="storage-page__ring" :style="ringStyle">
<view class="storage-page__ring-inner">
<text class="storage-page__ring-value">{{ usedPercent }}%</text>
<text class="storage-page__ring-label">已使用</text>
</view>
</view>
</view>
<view class="storage-page__list">
<view
v-for="item in items"
:key="item.id"
class="storage-page__item"
>
<view class="storage-page__item-head">
<text class="storage-page__item-label">{{ item.label }}</text>
<text class="storage-page__item-size">{{ formatSize(item.sizeMb) }}</text>
</view>
<view class="storage-page__bar">
<view
class="storage-page__bar-fill"
:style="{ width: `${item.percent}%` }"
/>
</view>
</view>
</view>
<view class="storage-page__footer">
<view class="storage-page__clear-btn" @tap="onClearAll">
<text class="storage-page__clear-text">
一键清理全部缓存({{ totalCacheMb }}MB)
</text>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { useStorage } from './use-storage'
const {
items,
usedPercent,
totalCacheMb,
ringStyle,
onClearAll,
} = useStorage()
function formatSize(sizeMb: number): string {
if (sizeMb <= 0) return '0MB'
return `${sizeMb}MB`
}
</script>
<style lang="scss" scoped>
@import './storage.scss';
</style>

View File

@@ -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;
}

View File

@@ -0,0 +1,6 @@
export interface StorageCacheItem {
id: string
label: string
sizeMb: number
percent: number
}

View File

@@ -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<StorageCacheItem[]>(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,
}
}

View File

@@ -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<typeof setInterval> | null = null
let reconnectTimer: ReturnType<typeof setTimeout> | 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<string, unknown>
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
}

View File

@@ -0,0 +1,319 @@
<template>
<view class="support-page">
<app-navbar title="在线客服">
<template #right>
<view v-if="showTransfer" class="support-page__nav-action" @tap="onTransferHuman">
<text class="support-page__nav-action-text">转人工</text>
</view>
<view v-else-if="showEndSession" class="support-page__nav-action" @tap="onEndSession">
<text class="support-page__nav-action-text support-page__nav-action-text--end">结束</text>
</view>
</template>
</app-navbar>
<view class="support-page__header">
<view
class="support-page__header-avatar"
:class="`support-page__header-avatar--${headerInfo.avatarShape}`"
>
{{ headerInfo.avatarText }}
</view>
<view class="support-page__header-main">
<view class="support-page__header-row">
<text class="support-page__header-name">{{ headerInfo.name }}</text>
<view
class="support-page__header-badge"
:class="`support-page__header-badge--${headerInfo.badgeType}`"
>
<view
v-if="headerInfo.badgeType === 'online'"
class="support-page__header-badge-dot"
/>
<text>{{ headerInfo.badgeText }}</text>
</view>
</view>
<text v-if="headerInfo.subtitle" class="support-page__header-subtitle">
<text class="support-page__header-subtitle-dot">●</text>{{ headerInfo.subtitle }}
</text>
</view>
<text v-if="headerInfo.serviceHours" class="support-page__header-hours">
{{ headerInfo.serviceHours }}
</text>
</view>
<scroll-view
class="support-page__chat"
scroll-y
:scroll-into-view="scrollIntoView"
scroll-with-animation
>
<view class="support-page__chat-inner">
<template v-for="item in messages" :key="item.id">
<view v-if="item.type === 'time'" class="support-page__time">
<text class="support-page__time-pill">🕐 {{ item.text }}</text>
</view>
<view
v-else-if="item.type === 'text' && item.role === 'system'"
class="support-page__msg support-page__msg--system"
>
<view class="support-page__bubble support-page__bubble--system">
<text>{{ item.content }}</text>
</view>
</view>
<view
v-else-if="item.type === 'text'"
class="support-page__msg"
:class="`support-page__msg--${item.role}`"
>
<view
v-if="item.role === 'bot' || item.role === 'agent'"
class="support-page__msg-avatar"
:class="`support-page__msg-avatar--${item.role}`"
>
{{ item.role === 'bot' ? '智' : humanAvatarText }}
</view>
<view
class="support-page__msg-body"
:class="{ 'support-page__msg-body--user': item.role === 'user' }"
>
<view
class="support-page__bubble"
:class="`support-page__bubble--${item.role}`"
>
<text>{{ item.content }}</text>
</view>
<text v-if="item.time" class="support-page__bubble-time">
{{ item.time }}
</text>
</view>
<view v-if="item.role === 'user'" class="support-page__msg-avatar support-page__msg-avatar--user">
我
</view>
</view>
<view v-else-if="item.type === 'quick-actions'" class="support-page__quick">
<view
v-for="action in item.actions"
:key="action.id"
class="support-page__quick-item"
@tap="onQuickAction(action)"
>
<text class="support-page__quick-dot">●</text>
<text>{{ action.label }}</text>
</view>
</view>
<view v-else-if="item.type === 'wifi-help'" class="support-page__card">
<text class="support-page__card-title">{{ item.title }}</text>
<view
v-for="(step, index) in item.steps"
:key="`${item.id}-${index}`"
class="support-page__card-step"
>
<text class="support-page__card-step-index">{{ index + 1 }}</text>
<text class="support-page__card-step-text">{{ step }}</text>
</view>
</view>
<view v-else-if="item.type === 'queue'" class="support-page__card">
<view class="support-page__queue-head">
<view class="support-page__queue-title-wrap">
<text class="support-page__queue-icon">🎧</text>
<text class="support-page__queue-title">转换人工客服</text>
</view>
<text class="support-page__queue-tag">分配中</text>
</view>
<view class="support-page__queue-steps">
<view class="support-page__queue-step">
<view class="support-page__queue-step-dot support-page__queue-step-dot--done">✓</view>
<text class="support-page__queue-step-label">已提交</text>
</view>
<view class="support-page__queue-step">
<view class="support-page__queue-step-dot support-page__queue-step-dot--active">Ⅱ</view>
<text class="support-page__queue-step-label">分配中</text>
</view>
<view class="support-page__queue-step">
<view class="support-page__queue-step-dot">○</view>
<text class="support-page__queue-step-label">接入</text>
</view>
</view>
<view class="support-page__queue-info">
<text class="support-page__queue-info-text">
排队第 {{ item.position }} 位 · 已等待 {{ formattedQueueWait }}
</text>
<text class="support-page__queue-estimate">{{ item.estimateText }}</text>
</view>
<view class="support-page__queue-actions">
<text class="support-page__queue-link" @tap="onResolveWithoutHuman">
已解决,无需人工
</text>
<text class="support-page__queue-cancel" @tap="onCancelQueue">取消排队</text>
</view>
</view>
<view v-else-if="item.type === 'diagnosis'" class="support-page__card">
<view class="support-page__diagnosis-head">
<view class="support-page__diagnosis-title-wrap">
<view class="support-page__diagnosis-bar" />
<text class="support-page__card-title">{{ item.title }}</text>
</view>
<text class="support-page__diagnosis-estimate">{{ item.estimateText }}</text>
</view>
<view
v-for="diag in item.items"
:key="diag.id"
class="support-page__diagnosis-item"
>
<text class="support-page__diagnosis-icon">{{ diag.icon }}</text>
<text class="support-page__diagnosis-label">{{ diag.label }}</text>
<text
class="support-page__diagnosis-status"
:class="`support-page__diagnosis-status--${diag.status}`"
>
{{ diagnosisStatusText(diag.status) }}
</text>
</view>
<view class="support-page__progress">
<view
class="support-page__progress-bar"
:style="{ width: `${item.progress}%` }"
/>
</view>
<view class="support-page__diagnosis-suggest">
<text
class="support-page__diagnosis-suggest-item"
@tap="onDiagnosisSuggestion('补充设备信息')"
>
补充设备信息
</text>
<text
class="support-page__diagnosis-suggest-item"
@tap="onDiagnosisSuggestion('大概还要多久?')"
>
大概还要多久?
</text>
</view>
</view>
<view
v-else-if="item.type === 'image'"
class="support-page__msg support-page__msg--user"
>
<view class="support-page__image-wrap">
<image class="support-page__image" :src="item.src" mode="aspectFill" />
<text v-if="item.time" class="support-page__bubble-time">{{ item.time }}</text>
</view>
<view class="support-page__msg-avatar support-page__msg-avatar--user">我</view>
</view>
</template>
<view id="chat-bottom" />
</view>
</scroll-view>
<view class="support-page__footer">
<view class="support-page__footer-shortcuts">
<text
v-for="shortcut in footerShortcuts"
:key="shortcut"
class="support-page__footer-shortcut"
@tap="onFooterShortcut(shortcut)"
>
{{ shortcut }}
</text>
</view>
<view class="support-page__input-row">
<view class="support-page__mic">
<image
class="support-page__mic-icon"
:src="staticAssets.iconSupportMic"
mode="aspectFit"
/>
</view>
<view class="support-page__input-wrap">
<input
v-model="inputValue"
class="support-page__input"
type="text"
placeholder="输入你的问题..."
placeholder-class="support-page__input-placeholder"
confirm-type="send"
@confirm="onSend"
/>
</view>
<view class="support-page__tag">
<image
class="support-page__tag-icon"
:src="staticAssets.iconSupportTag"
mode="aspectFit"
/>
</view>
<view class="support-page__send" @tap="onSend">
<image
class="support-page__send-icon"
:src="staticAssets.iconSupportSend"
mode="aspectFit"
/>
</view>
</view>
<text class="support-page__secure">
本次会话已加密,客服不会向您索要密码或验证码
</text>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { staticAssets } from '@/utils/static-assets'
import { useSupport } from './use-support'
import type { DiagnosisItem } from './support.types'
const {
messages,
inputValue,
headerInfo,
showTransfer,
showEndSession,
formattedQueueWait,
footerShortcuts,
onQuickAction,
onTransferHuman,
onEndSession,
onCancelQueue,
onResolveWithoutHuman,
onFooterShortcut,
onDiagnosisSuggestion,
onSend,
} = useSupport()
const humanAvatarText = computed(() => headerInfo.value.avatarText || '服')
const scrollIntoView = ref('chat-bottom')
watch(
() => messages.value.length,
async () => {
await nextTick()
scrollIntoView.value = 'chat-bottom'
},
)
function diagnosisStatusText(status: DiagnosisItem['status']): string {
if (status === 'done') return '已完成'
if (status === 'running') return '进行中'
return '等待中'
}
</script>
<style lang="scss" scoped>
@import './support.scss';
.support-page__input-placeholder {
color: #c0c4cc;
font-size: 28rpx;
}
</style>

Some files were not shown because too many files have changed in this diff Show More