feat:蓝牙绑定和在线客服
This commit is contained in:
@@ -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
|
||||||
|
|||||||
21
.env.example
21
.env.example
@@ -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=
|
||||||
|
|||||||
21
src/App.vue
21
src/App.vue
@@ -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';
|
||||||
|
|||||||
@@ -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
72
src/api/home.ts
Normal 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
67
src/api/location.ts
Normal 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
53
src/api/message.ts
Normal 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
61
src/api/profile.ts
Normal 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 || '上传失败'))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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 || '网络异常'))
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
48
src/hooks/use-message-inbox.ts
Normal file
48
src/hooks/use-message-inbox.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
},
|
},
|
||||||
|
|||||||
105
src/pages.json
105
src/pages.json
@@ -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": {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
11
src/pages/add-device/complete/complete.api.ts
Normal file
11
src/pages/add-device/complete/complete.api.ts
Normal 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: {},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
11
src/pages/add-device/complete/complete.types.ts
Normal file
11
src/pages/add-device/complete/complete.types.ts
Normal 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[]
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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) {
|
|
||||||
roomName.value = picked.name
|
|
||||||
locationId.value = picked.locationId
|
|
||||||
}
|
}
|
||||||
|
if (!homeId.value) {
|
||||||
|
uni.showToast({ title: '未找到家庭信息', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = [
|
||||||
|
`homeId=${homeId.value}`,
|
||||||
|
...(locationId.value ? [`locationId=${locationId.value}`] : []),
|
||||||
|
`locationName=${encodeURIComponent(roomName.value)}`,
|
||||||
|
]
|
||||||
|
|
||||||
|
uni.navigateTo({
|
||||||
|
url: `/pages/device/settings/room/index?${params.join('&')}`,
|
||||||
|
events: {
|
||||||
|
roomSelected: (data: { locationId: string | number; name: string }) => {
|
||||||
|
locationId.value = String(data.locationId)
|
||||||
|
roomName.value = data.name
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -58,19 +201,41 @@ export function useCompleteDevice() {
|
|||||||
uni.showToast({ title: '缺少设备编号', icon: 'none' })
|
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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,44 +16,101 @@ 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)
|
function updateCheck(id: string, status: PairCheckItem['status'], tag: string) {
|
||||||
}
|
|
||||||
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) =>
|
checkItems.value = checkItems.value.map((item) =>
|
||||||
item.id === 'network'
|
item.id === id ? { ...item, status, tag } : item,
|
||||||
? { ...item, status: 'done', tag: '完成' }
|
|
||||||
: item,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}, 500)
|
|
||||||
|
|
||||||
let navigated = false
|
function markFailedFromStep(stepId: string, message: string) {
|
||||||
|
const order = ['signal', 'credential', 'network']
|
||||||
|
const failedIndex = order.indexOf(stepId)
|
||||||
|
checkItems.value = checkItems.value.map((item) => {
|
||||||
|
const idx = order.indexOf(item.id)
|
||||||
|
if (idx < failedIndex && item.status === 'done') return item
|
||||||
|
if (item.id === stepId) return { ...item, status: 'failed', tag: '失败' }
|
||||||
|
if (idx > failedIndex) return { ...item, status: 'pending', tag: '未开始' }
|
||||||
|
return item
|
||||||
|
})
|
||||||
|
connectStatus.value = 'failed'
|
||||||
|
errorMessage.value = message
|
||||||
|
progress.value = Math.max(progress.value, 30)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startPairing() {
|
||||||
|
if (pairing) return
|
||||||
|
pairing = true
|
||||||
|
connectStatus.value = 'connecting'
|
||||||
|
errorMessage.value = ''
|
||||||
|
progress.value = 5
|
||||||
|
|
||||||
|
checkItems.value = [
|
||||||
|
{ id: 'signal', title: '蓝牙信号强度', status: 'syncing', tag: '检测中' },
|
||||||
|
{ id: 'credential', title: '设备凭证校验', status: 'pending', tag: '待校验' },
|
||||||
|
{ id: 'network', title: '蓝牙连接建立', status: 'pending', tag: '待连接' },
|
||||||
|
]
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!isBluetoothApiSupported()) {
|
||||||
|
throw new Error('当前环境不支持蓝牙,请使用 App 或微信小程序')
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureBluetoothReady()
|
||||||
|
updateCheck('signal', 'done', '良好')
|
||||||
|
progress.value = 30
|
||||||
|
|
||||||
|
const mac = device.value.mac?.trim()
|
||||||
|
const name = device.value.name?.trim()
|
||||||
|
if (!mac && !name) {
|
||||||
|
throw new Error('缺少设备 MAC 或名称,无法建立蓝牙连接')
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCheck('credential', 'syncing', '扫描中')
|
||||||
|
progress.value = 45
|
||||||
|
|
||||||
|
const found = await scanForBleDeviceByMac(mac ?? '', {
|
||||||
|
timeoutMs: SCAN_TIMEOUT_MS,
|
||||||
|
name,
|
||||||
|
})
|
||||||
|
if (!found) {
|
||||||
|
markFailedFromStep('credential', '未找到设备,请确认已开机并靠近手机')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCheck('credential', 'done', '通过')
|
||||||
|
progress.value = 70
|
||||||
|
updateCheck('network', 'syncing', '连接中')
|
||||||
|
|
||||||
|
await ensureBleConnection(found.deviceId)
|
||||||
|
bleDeviceId = found.deviceId
|
||||||
|
|
||||||
|
try {
|
||||||
|
await queryZ2BleState(found.deviceId)
|
||||||
|
} catch {
|
||||||
|
// 连接成功即可继续
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCheck('network', 'done', '已连接')
|
||||||
|
progress.value = 100
|
||||||
|
connectStatus.value = 'success'
|
||||||
|
|
||||||
watch(progress, (value) => {
|
|
||||||
if (value < 100 || navigated) return
|
|
||||||
navigated = true
|
|
||||||
const d = device.value
|
const d = device.value
|
||||||
const q = buildBindDeviceQuery({
|
const q = buildBindDeviceQuery({
|
||||||
deviceNo: d.deviceNo,
|
deviceNo: d.deviceNo,
|
||||||
@@ -55,14 +120,32 @@ export function usePairDevice(initial?: Partial<PairDeviceInfo>) {
|
|||||||
model: d.model,
|
model: d.model,
|
||||||
imei: d.imei,
|
imei: d.imei,
|
||||||
mac: d.mac,
|
mac: d.mac,
|
||||||
})
|
bluetoothConnected: '1',
|
||||||
setTimeout(() => {
|
|
||||||
uni.redirectTo({ url: `/pages/add-device/complete/index?${q}` })
|
|
||||||
}, 600)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
defineProps<{
|
||||||
visible: boolean
|
visible: boolean
|
||||||
}>()
|
title?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
title: '设置房间位置',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:visible': [value: boolean]
|
'update:visible': [value: boolean]
|
||||||
|
|||||||
@@ -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 {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 '差'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (!Object.keys(blePayload).length) {
|
||||||
|
setSyncText('蓝牙已连接,等待设备上报数据…')
|
||||||
|
} else {
|
||||||
setSyncText('刚刚更新 · 数据实时同步中')
|
setSyncText('刚刚更新 · 数据实时同步中')
|
||||||
|
}
|
||||||
await context.onSynced?.()
|
await context.onSynced?.()
|
||||||
return true
|
return true
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setSyncText(getBluetoothErrorMessage(err))
|
setSyncText(getBluetoothErrorMessage(err))
|
||||||
|
if (!connected) {
|
||||||
bleDeviceId.value = undefined
|
bleDeviceId.value = undefined
|
||||||
await reportDisconnected()
|
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
111
src/pages/device/device-presence.ts
Normal file
111
src/pages/device/device-presence.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
62
src/pages/device/home-manage/detail/index.vue
Normal file
62
src/pages/device/home-manage/detail/index.vue
Normal 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>
|
||||||
16
src/pages/device/home-manage/home-manage.api.ts
Normal file
16
src/pages/device/home-manage/home-manage.api.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
85
src/pages/device/home-manage/home-manage.scss
Normal file
85
src/pages/device/home-manage/home-manage.scss
Normal 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;
|
||||||
|
}
|
||||||
10
src/pages/device/home-manage/home-manage.types.ts
Normal file
10
src/pages/device/home-manage/home-manage.types.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export interface HomeManageRouteQuery {
|
||||||
|
homeId?: string
|
||||||
|
homeName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HomeGeoInfo {
|
||||||
|
address: string
|
||||||
|
latitude: number
|
||||||
|
longitude: number
|
||||||
|
}
|
||||||
40
src/pages/device/home-manage/index.vue
Normal file
40
src/pages/device/home-manage/index.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
139
src/pages/device/home-manage/location/home-location.scss
Normal file
139
src/pages/device/home-manage/location/home-location.scss
Normal 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;
|
||||||
|
}
|
||||||
71
src/pages/device/home-manage/location/index.vue
Normal file
71
src/pages/device/home-manage/location/index.vue
Normal 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>
|
||||||
182
src/pages/device/home-manage/location/use-home-location.ts
Normal file
182
src/pages/device/home-manage/location/use-home-location.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src/pages/device/home-manage/use-home-list.ts
Normal file
48
src/pages/device/home-manage/use-home-list.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
111
src/pages/device/home-manage/use-home-manage.ts
Normal file
111
src/pages/device/home-manage/use-home-manage.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
20
src/pages/device/settings/device-settings.api.ts
Normal file
20
src/pages/device/settings/device-settings.api.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
155
src/pages/device/settings/device-settings.scss
Normal file
155
src/pages/device/settings/device-settings.scss
Normal 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;
|
||||||
|
}
|
||||||
20
src/pages/device/settings/device-settings.types.ts
Normal file
20
src/pages/device/settings/device-settings.types.ts
Normal 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'
|
||||||
115
src/pages/device/settings/index.vue
Normal file
115
src/pages/device/settings/index.vue
Normal 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>
|
||||||
66
src/pages/device/settings/room/index.vue
Normal file
66
src/pages/device/settings/room/index.vue
Normal 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>
|
||||||
78
src/pages/device/settings/room/room.scss
Normal file
78
src/pages/device/settings/room/room.scss
Normal 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;
|
||||||
|
}
|
||||||
11
src/pages/device/settings/room/room.types.ts
Normal file
11
src/pages/device/settings/room/room.types.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export interface DeviceRoomRouteQuery {
|
||||||
|
deviceId?: string
|
||||||
|
homeId?: string
|
||||||
|
locationId?: string
|
||||||
|
locationName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoomSelectedPayload {
|
||||||
|
locationId: string | number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
165
src/pages/device/settings/room/use-device-room.ts
Normal file
165
src/pages/device/settings/room/use-device-room.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
348
src/pages/device/settings/use-device-settings.ts
Normal file
348
src/pages/device/settings/use-device-settings.ts
Normal 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,
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
customRooms.value = [...customRooms.value, room]
|
|
||||||
saveCustomRooms(customRooms.value)
|
|
||||||
refreshRoomTabs()
|
|
||||||
activeRoomId.value = room.id
|
|
||||||
showOperationSuccess()
|
showOperationSuccess()
|
||||||
|
} catch (err) {
|
||||||
|
uni.showToast({ title: getErrorMessage(err, '添加房间失败'), icon: 'none' })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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'}`,
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
35
src/pages/mine/change-phone/bind/index.vue
Normal file
35
src/pages/mine/change-phone/bind/index.vue
Normal 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>
|
||||||
67
src/pages/mine/change-phone/bind/use-change-phone-bind.ts
Normal file
67
src/pages/mine/change-phone/bind/use-change-phone-bind.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/pages/mine/change-phone/change-phone.api.ts
Normal file
34
src/pages/mine/change-phone/change-phone.api.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
50
src/pages/mine/change-phone/change-phone.scss
Normal file
50
src/pages/mine/change-phone/change-phone.scss
Normal 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);
|
||||||
|
}
|
||||||
22
src/pages/mine/change-phone/change-phone.types.ts
Normal file
22
src/pages/mine/change-phone/change-phone.types.ts
Normal 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
|
||||||
|
}
|
||||||
37
src/pages/mine/change-phone/index.vue
Normal file
37
src/pages/mine/change-phone/index.vue
Normal 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>
|
||||||
99
src/pages/mine/change-phone/use-change-phone-verify.ts
Normal file
99
src/pages/mine/change-phone/use-change-phone-verify.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
123
src/pages/mine/edit/components/avatar-action-sheet/index.vue
Normal file
123
src/pages/mine/edit/components/avatar-action-sheet/index.vue
Normal 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>
|
||||||
150
src/pages/mine/edit/components/nickname-dialog/index.vue
Normal file
150
src/pages/mine/edit/components/nickname-dialog/index.vue
Normal 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>
|
||||||
151
src/pages/mine/edit/edit.scss
Normal file
151
src/pages/mine/edit/edit.scss
Normal 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;
|
||||||
|
}
|
||||||
13
src/pages/mine/edit/edit.types.ts
Normal file
13
src/pages/mine/edit/edit.types.ts
Normal 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'
|
||||||
184
src/pages/mine/edit/index.vue
Normal file
184
src/pages/mine/edit/index.vue
Normal 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>
|
||||||
232
src/pages/mine/edit/use-edit-profile.ts
Normal file
232
src/pages/mine/edit/use-edit-profile.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
<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>
|
||||||
|
|||||||
13
src/pages/mine/mine.api.ts
Normal file
13
src/pages/mine/mine.api.ts
Normal 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
192
src/pages/mine/mine.scss
Normal 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;
|
||||||
|
}
|
||||||
11
src/pages/mine/mine.types.ts
Normal file
11
src/pages/mine/mine.types.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export interface MineQuickAction {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
icon: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MineMenuItem {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
value?: string
|
||||||
|
}
|
||||||
79
src/pages/mine/notify/index.vue
Normal file
79
src/pages/mine/notify/index.vue
Normal 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>
|
||||||
100
src/pages/mine/notify/notify.scss
Normal file
100
src/pages/mine/notify/notify.scss
Normal 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;
|
||||||
|
}
|
||||||
22
src/pages/mine/notify/notify.utils.ts
Normal file
22
src/pages/mine/notify/notify.utils.ts
Normal 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')}`
|
||||||
|
}
|
||||||
132
src/pages/mine/notify/use-notify.ts
Normal file
132
src/pages/mine/notify/use-notify.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
70
src/pages/mine/privacy/index.vue
Normal file
70
src/pages/mine/privacy/index.vue
Normal 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>
|
||||||
137
src/pages/mine/privacy/privacy.scss
Normal file
137
src/pages/mine/privacy/privacy.scss
Normal 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;
|
||||||
|
}
|
||||||
12
src/pages/mine/privacy/privacy.types.ts
Normal file
12
src/pages/mine/privacy/privacy.types.ts
Normal 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[]
|
||||||
|
}
|
||||||
114
src/pages/mine/privacy/use-privacy.ts
Normal file
114
src/pages/mine/privacy/use-privacy.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/pages/mine/set-password/index.vue
Normal file
37
src/pages/mine/set-password/index.vue
Normal 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>
|
||||||
46
src/pages/mine/set-password/phone/index.vue
Normal file
46
src/pages/mine/set-password/phone/index.vue
Normal 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>
|
||||||
85
src/pages/mine/set-password/phone/use-set-password-phone.ts
Normal file
85
src/pages/mine/set-password/phone/use-set-password-phone.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/pages/mine/set-password/set-password.api.ts
Normal file
34
src/pages/mine/set-password/set-password.api.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
108
src/pages/mine/set-password/set-password.scss
Normal file
108
src/pages/mine/set-password/set-password.scss
Normal 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;
|
||||||
|
}
|
||||||
10
src/pages/mine/set-password/set-password.types.ts
Normal file
10
src/pages/mine/set-password/set-password.types.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export interface SetPasswordPhoneForm {
|
||||||
|
phone: string
|
||||||
|
code: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetPasswordForm {
|
||||||
|
resetToken: string
|
||||||
|
newPassword: string
|
||||||
|
confirmPassword: string
|
||||||
|
}
|
||||||
63
src/pages/mine/set-password/use-set-password.ts
Normal file
63
src/pages/mine/set-password/use-set-password.ts
Normal 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 }
|
||||||
|
}
|
||||||
31
src/pages/mine/set-password/verify/index.vue
Normal file
31
src/pages/mine/set-password/verify/index.vue
Normal 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>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export function useSetPasswordVerify() {
|
||||||
|
function onPhoneVerify() {
|
||||||
|
uni.navigateTo({ url: '/pages/mine/set-password/phone/index' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return { onPhoneVerify }
|
||||||
|
}
|
||||||
68
src/pages/mine/storage/index.vue
Normal file
68
src/pages/mine/storage/index.vue
Normal 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>
|
||||||
130
src/pages/mine/storage/storage.scss
Normal file
130
src/pages/mine/storage/storage.scss
Normal 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;
|
||||||
|
}
|
||||||
6
src/pages/mine/storage/storage.types.ts
Normal file
6
src/pages/mine/storage/storage.types.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export interface StorageCacheItem {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
sizeMb: number
|
||||||
|
percent: number
|
||||||
|
}
|
||||||
52
src/pages/mine/storage/use-storage.ts
Normal file
52
src/pages/mine/storage/use-storage.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
169
src/pages/mine/support/cs-chat-socket.ts
Normal file
169
src/pages/mine/support/cs-chat-socket.ts
Normal 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
|
||||||
|
}
|
||||||
319
src/pages/mine/support/index.vue
Normal file
319
src/pages/mine/support/index.vue
Normal 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
Reference in New Issue
Block a user