feat:蓝牙绑定和在线客服
This commit is contained in:
21
src/App.vue
21
src/App.vue
@@ -1,14 +1,19 @@
|
||||
<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(() => {
|
||||
console.log("App Launch");
|
||||
});
|
||||
if (getStoredToken()) {
|
||||
setupMessageInbox()
|
||||
}
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
console.log("App Show");
|
||||
});
|
||||
onHide(() => {
|
||||
console.log("App Hide");
|
||||
});
|
||||
if (getStoredToken()) {
|
||||
setupMessageInbox()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style lang="scss">
|
||||
@import '@/styles/index.scss';
|
||||
|
||||
@@ -23,3 +23,11 @@ export function saveAuthToken(data: AuthTokenResult | unknown) {
|
||||
}
|
||||
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 { 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 一致) */
|
||||
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'> {
|
||||
url: string
|
||||
@@ -28,7 +59,7 @@ function handleSuccess<T>(
|
||||
saveTokenFromBody = false,
|
||||
) {
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
const body = res.data as ApiResponse | undefined
|
||||
const body = parseResponseBody(res.data) as ApiResponse | undefined
|
||||
reject(
|
||||
new ApiError(
|
||||
body?.msg || body?.message || `请求失败(${res.statusCode})`,
|
||||
@@ -38,7 +69,7 @@ function handleSuccess<T>(
|
||||
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)) {
|
||||
reject(new ApiError(body.msg || body.message || '请求失败', body.code))
|
||||
return
|
||||
@@ -58,6 +89,7 @@ export function request<T = unknown>(options: RequestOptions): Promise<T> {
|
||||
...options,
|
||||
url: `${BASE_URL}${options.url}`,
|
||||
header: buildAuthHeaders(options.header),
|
||||
dataType: 'text',
|
||||
success: (res) => handleSuccess<T>(res, resolve, reject),
|
||||
fail: (err) => {
|
||||
reject(new ApiError(err.errMsg || '网络异常'))
|
||||
@@ -73,6 +105,7 @@ export function requestLogin<T = unknown>(options: RequestOptions): Promise<T> {
|
||||
...options,
|
||||
url: `${BASE_URL}${options.url}`,
|
||||
header: buildAuthHeaders(options.header),
|
||||
dataType: 'text',
|
||||
success: (res) => handleSuccess<T>(res, resolve, reject, true),
|
||||
fail: (err) => {
|
||||
reject(new ApiError(err.errMsg || '网络异常'))
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
<text class="app-navbar__back-icon">‹</text>
|
||||
</view>
|
||||
<text class="app-navbar__title">{{ title }}</text>
|
||||
<view class="app-navbar__placeholder" />
|
||||
<view class="app-navbar__right">
|
||||
<slot name="right" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
@@ -66,7 +68,13 @@ function onBack() {
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.app-navbar__placeholder {
|
||||
width: 80rpx;
|
||||
.app-navbar__right {
|
||||
min-width: 80rpx;
|
||||
max-width: 220rpx;
|
||||
height: 88rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</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
|
||||
},
|
||||
/* 模块配置 */
|
||||
"modules" : {},
|
||||
"modules" : {
|
||||
"Maps" : {}
|
||||
},
|
||||
/* 应用发布信息 */
|
||||
"distribute" : {
|
||||
/* android打包配置 */
|
||||
@@ -64,12 +66,17 @@
|
||||
"permission" : {
|
||||
"scope.bluetooth" : {
|
||||
"desc" : "你的蓝牙将用于发现和连接智能设备"
|
||||
},
|
||||
"scope.userLocation" : {
|
||||
"desc" : "你的位置信息将用于在地图上选择家庭位置"
|
||||
}
|
||||
},
|
||||
"requiredPrivateInfos" : [
|
||||
"getBluetoothDevices",
|
||||
"onBluetoothDeviceFound",
|
||||
"startBluetoothDevicesDiscovery"
|
||||
"startBluetoothDevicesDiscovery",
|
||||
"getLocation",
|
||||
"chooseLocation"
|
||||
]
|
||||
},
|
||||
"mp-alipay" : {
|
||||
@@ -81,6 +88,15 @@
|
||||
"mp-toutiao" : {
|
||||
"usingComponents" : true
|
||||
},
|
||||
"h5" : {
|
||||
"sdkConfigs" : {
|
||||
"maps" : {
|
||||
"tencent" : {
|
||||
"key" : ""
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"uniStatistics": {
|
||||
"enable": false
|
||||
},
|
||||
|
||||
105
src/pages.json
105
src/pages.json
@@ -27,6 +27,41 @@
|
||||
"navigationBarTitleText": "设备详情"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/device/settings/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "设备设置"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/device/settings/room/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "所在房间"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/device/home-manage/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "家庭管理"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/device/home-manage/detail/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "家庭管理"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/device/home-manage/location/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "家庭位置"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/add-device/index",
|
||||
"style": {
|
||||
@@ -62,6 +97,76 @@
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mine/edit/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "编辑资料"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mine/support/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "在线客服"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mine/notify/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "通知"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mine/storage/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "储存空间"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mine/privacy/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "账号与安全"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mine/change-phone/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "手机号换绑"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mine/change-phone/bind/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "手机号换绑"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mine/set-password/verify/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "身份验证"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mine/set-password/phone/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "验证手机号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mine/set-password/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "设置新密码"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/register/index",
|
||||
"style": {
|
||||
|
||||
@@ -13,13 +13,13 @@ export interface BindFeatureItem {
|
||||
/** POST /app/device/bind 请求体 */
|
||||
export interface BindDeviceBody {
|
||||
deviceNo: string
|
||||
productId?: number
|
||||
productId?: string | number
|
||||
model?: string
|
||||
imei?: string
|
||||
mac?: string
|
||||
name?: string
|
||||
homeId?: number
|
||||
locationId?: number
|
||||
homeId?: string | number
|
||||
locationId?: string | number
|
||||
}
|
||||
|
||||
/** 绑定流程页间传递的设备信息 */
|
||||
@@ -27,10 +27,11 @@ export interface BindDeviceContext {
|
||||
deviceNo: string
|
||||
name: string
|
||||
serialMasked: string
|
||||
productId?: number
|
||||
productId?: string | number
|
||||
model?: string
|
||||
imei?: string
|
||||
mac?: string
|
||||
bluetoothConnected?: string
|
||||
}
|
||||
|
||||
export function buildBindDeviceQuery(ctx: BindDeviceContext): string {
|
||||
@@ -43,6 +44,9 @@ export function buildBindDeviceQuery(ctx: BindDeviceContext): string {
|
||||
if (ctx.model) parts.push(`model=${encodeURIComponent(ctx.model)}`)
|
||||
if (ctx.imei) parts.push(`imei=${encodeURIComponent(ctx.imei)}`)
|
||||
if (ctx.mac) parts.push(`mac=${encodeURIComponent(ctx.mac)}`)
|
||||
if (ctx.bluetoothConnected) {
|
||||
parts.push(`bluetoothConnected=${ctx.bluetoothConnected}`)
|
||||
}
|
||||
return parts.join('&')
|
||||
}
|
||||
|
||||
@@ -64,5 +68,8 @@ export function readBindDeviceContext(
|
||||
if (query.model) ctx.model = decodeURIComponent(String(query.model))
|
||||
if (query.imei) ctx.imei = decodeURIComponent(String(query.imei))
|
||||
if (query.mac) ctx.mac = decodeURIComponent(String(query.mac))
|
||||
if (query.bluetoothConnected) {
|
||||
ctx.bluetoothConnected = String(query.bluetoothConnected)
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
.complete-page__bt-status--offline .complete-page__bt-dot {
|
||||
background: #e54d4d;
|
||||
}
|
||||
|
||||
.complete-page__bt-status--offline .complete-page__bt-text {
|
||||
color: #e54d4d;
|
||||
}
|
||||
|
||||
.complete-page__form {
|
||||
margin: 0 32rpx 24rpx;
|
||||
padding: 8rpx 24rpx 24rpx;
|
||||
@@ -138,11 +146,17 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.complete-page__select-row,
|
||||
.complete-page__room-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 88rpx;
|
||||
padding: 0 24rpx;
|
||||
cursor: pointer;
|
||||
|
||||
&--hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
background: #f7f8fa;
|
||||
border-radius: 16rpx;
|
||||
gap: 12rpx;
|
||||
@@ -158,8 +172,13 @@
|
||||
flex: 1;
|
||||
font-size: 28rpx;
|
||||
color: $color-text;
|
||||
|
||||
&--placeholder {
|
||||
color: #c0c4cc;
|
||||
}
|
||||
}
|
||||
|
||||
.complete-page__select-arrow,
|
||||
.complete-page__room-arrow {
|
||||
width: 24rpx;
|
||||
height: 24rpx;
|
||||
|
||||
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" />
|
||||
<complete-success-hero />
|
||||
<view class="complete-page__status">
|
||||
<text class="complete-page__title">配对成功!</text>
|
||||
<text class="complete-page__desc">请确认设备信息后完成添加</text>
|
||||
<text class="complete-page__title">
|
||||
{{ bluetoothConnected ? '配对成功!' : '配对未完成' }}
|
||||
</text>
|
||||
<text class="complete-page__desc">
|
||||
{{ bluetoothConnected ? '请确认设备信息后完成添加' : '设备蓝牙未连接,请返回重新配对' }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="complete-page__device-card">
|
||||
@@ -21,9 +25,12 @@
|
||||
<text class="complete-page__device-serial">
|
||||
设备编号:{{ serialMasked }}
|
||||
</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" />
|
||||
<text class="complete-page__bt-text">蓝牙已连接</text>
|
||||
<text class="complete-page__bt-text">{{ bluetoothStatusText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -46,15 +53,49 @@
|
||||
/>
|
||||
</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">
|
||||
<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
|
||||
class="complete-page__room-icon"
|
||||
:src="staticAssets.iconHomeSelector"
|
||||
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
|
||||
class="complete-page__room-arrow"
|
||||
:src="staticAssets.iconHomeArrow"
|
||||
@@ -75,7 +116,7 @@
|
||||
<view class="complete-page__footer">
|
||||
<view
|
||||
class="complete-page__submit"
|
||||
:class="{ 'complete-page__submit--disabled': loading }"
|
||||
:class="{ 'complete-page__submit--disabled': loading || !bluetoothConnected }"
|
||||
@tap="onFinishAdd"
|
||||
>
|
||||
<text class="complete-page__submit-text">
|
||||
@@ -98,18 +139,26 @@ const {
|
||||
currentStep,
|
||||
deviceName,
|
||||
serialMasked,
|
||||
productOptions,
|
||||
productIndex,
|
||||
productDisplayText,
|
||||
productSelected,
|
||||
productOptionsLoading,
|
||||
roomName,
|
||||
roomSelected,
|
||||
roomOptionsLoading,
|
||||
loading,
|
||||
applyQuery,
|
||||
bluetoothConnected,
|
||||
bluetoothStatusText,
|
||||
initPage,
|
||||
onProductChange,
|
||||
onPickRoom,
|
||||
onFinishAdd,
|
||||
} = useCompleteDevice()
|
||||
|
||||
onLoad((query) => {
|
||||
applyQuery(query ?? {})
|
||||
void initPage(query ?? {})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import './complete.scss';
|
||||
</style>
|
||||
<style lang="scss" scoped src="./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 { fetchRoomList } from '@/api/location'
|
||||
import type { RoomOption } from '@/types/location'
|
||||
import { fetchDeviceList } from '@/pages/device/device.api'
|
||||
import { updateDeviceRuntime } from '@/pages/device/detail/device-detail.api'
|
||||
import { bindDevice } from '../add-device.api'
|
||||
import { parseId, sameId } from '@/utils/device-id'
|
||||
import { readBindDeviceContext } from '../add-device.types'
|
||||
|
||||
const ROOM_OPTIONS = [
|
||||
{ name: '客厅', locationId: 1 },
|
||||
{ name: '主卧', locationId: 2 },
|
||||
{ name: '卧室', locationId: 3 },
|
||||
{ name: '厨房', locationId: 4 },
|
||||
{ name: '卫生间', locationId: 5 },
|
||||
] as const
|
||||
import { fetchProductOptions } from './complete.api'
|
||||
import type { ProductOption } from './complete.types'
|
||||
|
||||
export function useCompleteDevice() {
|
||||
const currentStep = 4
|
||||
const deviceNo = ref('')
|
||||
const deviceName = ref('Z2 空气净化器')
|
||||
const serialMasked = ref('****A3B2')
|
||||
const productId = ref<number>()
|
||||
const model = ref<string>()
|
||||
const productId = ref('')
|
||||
const model = ref('')
|
||||
const productOptions = ref<ProductOption[]>([])
|
||||
const productOptionsLoading = ref(false)
|
||||
const productIndex = ref(0)
|
||||
const imei = ref<string>()
|
||||
const mac = ref<string>()
|
||||
const roomName = ref(ROOM_OPTIONS[0].name)
|
||||
const locationId = ref<number | undefined>(ROOM_OPTIONS[0].locationId)
|
||||
const homeId = ref('')
|
||||
const roomOptions = ref<RoomOption[]>([])
|
||||
const roomOptionsLoading = ref(false)
|
||||
const roomName = ref('请选择所在房间')
|
||||
const locationId = ref<string | number>()
|
||||
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>) {
|
||||
const ctx = readBindDeviceContext(query)
|
||||
if (ctx.deviceNo) deviceNo.value = ctx.deviceNo
|
||||
if (ctx.name) deviceName.value = ctx.name
|
||||
if (ctx.serialMasked) serialMasked.value = ctx.serialMasked
|
||||
if (ctx.productId != null) productId.value = ctx.productId
|
||||
const parsedProductId = parseId(ctx.productId)
|
||||
if (parsedProductId) productId.value = parsedProductId
|
||||
if (ctx.model) model.value = ctx.model
|
||||
if (productOptions.value.length) {
|
||||
syncProductFromOptions()
|
||||
}
|
||||
if (ctx.imei) imei.value = ctx.imei
|
||||
if (ctx.mac) mac.value = ctx.mac
|
||||
bluetoothConnected.value = ctx.bluetoothConnected === '1'
|
||||
if (roomOptions.value.length) {
|
||||
syncRoomFromOptions()
|
||||
}
|
||||
}
|
||||
|
||||
async function initPage(query: Record<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() {
|
||||
uni.showActionSheet({
|
||||
itemList: ROOM_OPTIONS.map((item) => item.name),
|
||||
success: (res) => {
|
||||
const picked = ROOM_OPTIONS[res.tapIndex]
|
||||
if (picked) {
|
||||
roomName.value = picked.name
|
||||
locationId.value = picked.locationId
|
||||
}
|
||||
if (roomOptionsLoading.value) {
|
||||
uni.showToast({ title: '房间列表加载中', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!homeId.value) {
|
||||
uni.showToast({ title: '未找到家庭信息', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
const params = [
|
||||
`homeId=${homeId.value}`,
|
||||
...(locationId.value ? [`locationId=${locationId.value}`] : []),
|
||||
`locationName=${encodeURIComponent(roomName.value)}`,
|
||||
]
|
||||
|
||||
uni.navigateTo({
|
||||
url: `/pages/device/settings/room/index?${params.join('&')}`,
|
||||
events: {
|
||||
roomSelected: (data: { locationId: string | number; name: string }) => {
|
||||
locationId.value = String(data.locationId)
|
||||
roomName.value = data.name
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -58,19 +201,41 @@ export function useCompleteDevice() {
|
||||
uni.showToast({ title: '缺少设备编号', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!locationId.value) {
|
||||
uni.showToast({ title: '请选择所在房间', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!bluetoothConnected.value) {
|
||||
uni.showToast({ title: '设备未连接蓝牙,请返回重新配对', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (loading.value) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const validHomeId = await resolveValidHomeId(homeId.value)
|
||||
homeId.value = validHomeId
|
||||
await bindDevice({
|
||||
deviceNo: deviceNo.value.trim(),
|
||||
name,
|
||||
...(productId.value != null ? { productId: productId.value } : {}),
|
||||
...(model.value ? { model: model.value } : {}),
|
||||
productId: productId.value,
|
||||
model: model.value,
|
||||
...(imei.value ? { imei: imei.value } : {}),
|
||||
...(mac.value ? { mac: mac.value } : {}),
|
||||
...(locationId.value != null ? { locationId: locationId.value } : {}),
|
||||
...(validHomeId ? { homeId: validHomeId } : {}),
|
||||
...(locationId.value ? { locationId: locationId.value } : {}),
|
||||
})
|
||||
|
||||
const list = await fetchDeviceList()
|
||||
const bound = list?.find((item) => item.deviceNo === deviceNo.value.trim())
|
||||
if (bound?.deviceId != null) {
|
||||
await updateDeviceRuntime({
|
||||
deviceId: bound.deviceId,
|
||||
status: bluetoothConnected.value ? '1' : '2',
|
||||
bluetoothConnected: bluetoothConnected.value ? '1' : '0',
|
||||
})
|
||||
}
|
||||
|
||||
uni.showToast({ title: '添加成功', icon: 'success' })
|
||||
setTimeout(() => {
|
||||
uni.reLaunch({ url: '/pages/device/index' })
|
||||
@@ -82,13 +247,28 @@ export function useCompleteDevice() {
|
||||
}
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
if (!initialized.value || !homeId.value) return
|
||||
void loadRoomOptionList()
|
||||
})
|
||||
|
||||
return {
|
||||
currentStep,
|
||||
deviceName,
|
||||
serialMasked,
|
||||
productOptions,
|
||||
productIndex,
|
||||
productDisplayText,
|
||||
productSelected,
|
||||
productOptionsLoading,
|
||||
roomName,
|
||||
roomSelected,
|
||||
roomOptionsLoading,
|
||||
loading,
|
||||
applyQuery,
|
||||
bluetoothConnected,
|
||||
bluetoothStatusText,
|
||||
initPage,
|
||||
onProductChange,
|
||||
onPickRoom,
|
||||
onFinishAdd,
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
:class="`pair-check-list__status--${item.status}`"
|
||||
>
|
||||
<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>
|
||||
<text class="pair-check-list__label">{{ item.title }}</text>
|
||||
<view
|
||||
@@ -82,6 +83,10 @@ defineProps<{
|
||||
background: #e8eaed;
|
||||
}
|
||||
|
||||
.pair-check-list__status--failed {
|
||||
background: #e54d4d;
|
||||
}
|
||||
|
||||
.pair-check-list__check {
|
||||
font-size: 22rpx;
|
||||
color: #fff;
|
||||
@@ -113,6 +118,10 @@ defineProps<{
|
||||
background: #f5f6f8;
|
||||
}
|
||||
|
||||
.pair-check-list__tag--failed {
|
||||
background: #fff5f5;
|
||||
}
|
||||
|
||||
.pair-check-list__tag-text {
|
||||
font-size: 24rpx;
|
||||
font-weight: 500;
|
||||
@@ -129,4 +138,8 @@ defineProps<{
|
||||
.pair-check-list__tag--pending .pair-check-list__tag-text {
|
||||
color: #98a2b3;
|
||||
}
|
||||
|
||||
.pair-check-list__tag--failed .pair-check-list__tag-text {
|
||||
color: #e54d4d;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,24 +5,43 @@
|
||||
<text class="pair-device-summary__name">{{ device.name }}</text>
|
||||
<text class="pair-device-summary__serial">设备编号:{{ device.serialMasked }}</text>
|
||||
</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" />
|
||||
<text class="pair-device-summary__status-text">连接中</text>
|
||||
<text class="pair-device-summary__status-text">{{ statusText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { staticAssets } from '@/utils/static-assets'
|
||||
import type { PairDeviceInfo } from '../../pair.types'
|
||||
import type { PairConnectStatus, PairDeviceInfo } from '../../pair.types'
|
||||
|
||||
withDefaults(
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
device: PairDeviceInfo
|
||||
connectStatus?: PairConnectStatus
|
||||
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>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -69,22 +88,55 @@ withDefaults(
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
padding: 10rpx 16rpx;
|
||||
background: #effbf4;
|
||||
border-radius: 999rpx;
|
||||
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 {
|
||||
width: 12rpx;
|
||||
height: 12rpx;
|
||||
border-radius: 50%;
|
||||
border: 3rpx solid $color-primary;
|
||||
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 {
|
||||
font-size: 24rpx;
|
||||
color: $color-primary;
|
||||
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>
|
||||
|
||||
@@ -6,13 +6,15 @@
|
||||
<bind-steps :steps="BIND_STEPS" :current="currentStep" />
|
||||
<pair-hero />
|
||||
<view class="pair-page__status">
|
||||
<text class="pair-page__title">配对中...</text>
|
||||
<text class="pair-page__desc">正在与设备建立安全连接,请保持手机靠近</text>
|
||||
<text class="pair-page__title">{{ pageTitle }}</text>
|
||||
<text class="pair-page__desc">{{ pageDesc }}</text>
|
||||
</view>
|
||||
|
||||
<view class="pair-page__progress-block">
|
||||
<view v-if="connectStatus !== 'failed'" class="pair-page__progress-block">
|
||||
<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>
|
||||
</view>
|
||||
<view class="pair-page__progress-track">
|
||||
@@ -21,45 +23,101 @@
|
||||
:style="{ width: `${progress}%` }"
|
||||
/>
|
||||
</view>
|
||||
<text class="pair-page__progress-tip">
|
||||
{{ progress >= 100 ? '配置同步完成' : `预计还需约 ${etaSeconds} 秒,请勿离开当前页面` }}
|
||||
</text>
|
||||
<text class="pair-page__progress-tip">{{ progressTip }}</text>
|
||||
</view>
|
||||
|
||||
<view v-else class="pair-page__error-block">
|
||||
<text class="pair-page__error-text">{{ errorMessage }}</text>
|
||||
</view>
|
||||
|
||||
<pair-check-list :items="checkItems" />
|
||||
<pair-device-summary :device="device" />
|
||||
<pair-device-summary :device="device" :connect-status="connectStatus" />
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view class="pair-page__footer">
|
||||
<view class="pair-page__cancel" @tap="onCancelPair">
|
||||
<text class="pair-page__cancel-text">取消配对</text>
|
||||
<view
|
||||
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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import BindSteps from '../components/bind-steps/index.vue'
|
||||
import { BIND_STEPS } from '../use-add-device'
|
||||
import { readBindDeviceContext } from '../add-device.types'
|
||||
import PairCheckList from './components/pair-check-list/index.vue'
|
||||
import PairDeviceSummary from './components/pair-device-summary/index.vue'
|
||||
import PairHero from './components/pair-hero/index.vue'
|
||||
import { readBindDeviceContext } from '../add-device.types'
|
||||
import { usePairDevice } from './use-pair-device'
|
||||
|
||||
const { currentStep, device, progress, etaSeconds, checkItems, onCancelPair } =
|
||||
usePairDevice()
|
||||
const {
|
||||
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) => {
|
||||
const ctx = readBindDeviceContext(query ?? {})
|
||||
if (ctx.name) device.value.name = ctx.name
|
||||
if (ctx.serialMasked) device.value.serialMasked = ctx.serialMasked
|
||||
if (ctx.deviceNo) device.value.deviceNo = ctx.deviceNo
|
||||
if (ctx.productId != null) device.value.productId = ctx.productId
|
||||
if (ctx.model) device.value.model = ctx.model
|
||||
if (ctx.imei) device.value.imei = ctx.imei
|
||||
if (ctx.mac) device.value.mac = ctx.mac
|
||||
applyDevice({
|
||||
name: ctx.name ?? '',
|
||||
serialMasked: ctx.serialMasked ?? '',
|
||||
deviceNo: ctx.deviceNo ?? '',
|
||||
productId: ctx.productId,
|
||||
model: ctx.model,
|
||||
imei: ctx.imei,
|
||||
mac: ctx.mac,
|
||||
})
|
||||
void startPairing()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -87,6 +87,36 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.pair-page__error-block {
|
||||
margin: 0 32rpx 24rpx;
|
||||
padding: 24rpx;
|
||||
background: #fff5f5;
|
||||
border-radius: 20rpx;
|
||||
border: 1rpx solid #ffd6d6;
|
||||
}
|
||||
|
||||
.pair-page__error-text {
|
||||
font-size: 26rpx;
|
||||
color: #e54d4d;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.pair-page__retry {
|
||||
margin-bottom: 16rpx;
|
||||
height: 88rpx;
|
||||
border-radius: 44rpx;
|
||||
background: $color-primary;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pair-page__retry-text {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.pair-page__footer {
|
||||
padding: 16rpx 32rpx calc(24rpx + env(safe-area-inset-bottom));
|
||||
background: $color-bg-page;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export type PairCheckStatus = 'done' | 'syncing' | 'pending'
|
||||
export type PairCheckStatus = 'done' | 'syncing' | 'pending' | 'failed'
|
||||
|
||||
export type PairConnectStatus = 'connecting' | 'success' | 'failed'
|
||||
|
||||
export interface PairCheckItem {
|
||||
id: string
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { onUnmounted, ref, watch } from 'vue'
|
||||
import { onUnmounted, ref } from 'vue'
|
||||
import {
|
||||
ensureBleConnection,
|
||||
ensureBluetoothReady,
|
||||
getBluetoothErrorMessage,
|
||||
isBluetoothApiSupported,
|
||||
scanForBleDeviceByMac,
|
||||
} from '@/utils/bluetooth'
|
||||
import { queryZ2BleState } from '@/utils/z2-ble'
|
||||
import { buildBindDeviceQuery } from '../add-device.types'
|
||||
import type { PairCheckItem, PairDeviceInfo } from './pair.types'
|
||||
import type { PairCheckItem, PairConnectStatus, PairDeviceInfo } from './pair.types'
|
||||
|
||||
const DEFAULT_DEVICE: PairDeviceInfo = {
|
||||
name: '',
|
||||
@@ -8,61 +16,136 @@ const DEFAULT_DEVICE: PairDeviceInfo = {
|
||||
deviceNo: '',
|
||||
}
|
||||
|
||||
export function usePairDevice(initial?: Partial<PairDeviceInfo>) {
|
||||
const currentStep = 3
|
||||
const device = ref<PairDeviceInfo>({
|
||||
...DEFAULT_DEVICE,
|
||||
...initial,
|
||||
})
|
||||
const SCAN_TIMEOUT_MS = 15000
|
||||
|
||||
const progress = ref(65)
|
||||
const etaSeconds = ref(15)
|
||||
export function usePairDevice() {
|
||||
const currentStep = 3
|
||||
const device = ref<PairDeviceInfo>({ ...DEFAULT_DEVICE })
|
||||
const connectStatus = ref<PairConnectStatus>('connecting')
|
||||
const progress = ref(0)
|
||||
const errorMessage = ref('')
|
||||
const checkItems = ref<PairCheckItem[]>([
|
||||
{ id: 'signal', title: '蓝牙信号强度', status: 'done', tag: '良好' },
|
||||
{ id: 'credential', title: '设备凭证校验', status: 'done', tag: '通过' },
|
||||
{ id: 'network', title: '网络配置下发', status: 'syncing', tag: '同步中' },
|
||||
{ id: 'signal', title: '蓝牙信号强度', status: 'pending', tag: '待检测' },
|
||||
{ id: 'credential', title: '设备凭证校验', status: 'pending', tag: '待校验' },
|
||||
{ id: 'network', title: '蓝牙连接建立', status: 'pending', tag: '待连接' },
|
||||
])
|
||||
|
||||
let etaTick = 0
|
||||
const tick = setInterval(() => {
|
||||
if (progress.value < 100) {
|
||||
progress.value = Math.min(100, progress.value + 1)
|
||||
}
|
||||
etaTick += 1
|
||||
if (etaTick % 2 === 0 && etaSeconds.value > 0 && progress.value < 100) {
|
||||
etaSeconds.value = Math.max(0, etaSeconds.value - 1)
|
||||
}
|
||||
if (progress.value >= 100) {
|
||||
checkItems.value = checkItems.value.map((item) =>
|
||||
item.id === 'network'
|
||||
? { ...item, status: 'done', tag: '完成' }
|
||||
: item,
|
||||
)
|
||||
}
|
||||
}, 500)
|
||||
let pairing = false
|
||||
let bleDeviceId: string | undefined
|
||||
|
||||
let navigated = false
|
||||
function updateCheck(id: string, status: PairCheckItem['status'], tag: string) {
|
||||
checkItems.value = checkItems.value.map((item) =>
|
||||
item.id === id ? { ...item, status, tag } : item,
|
||||
)
|
||||
}
|
||||
|
||||
watch(progress, (value) => {
|
||||
if (value < 100 || navigated) return
|
||||
navigated = true
|
||||
const d = device.value
|
||||
const q = buildBindDeviceQuery({
|
||||
deviceNo: d.deviceNo,
|
||||
name: d.name,
|
||||
serialMasked: d.serialMasked,
|
||||
productId: d.productId,
|
||||
model: d.model,
|
||||
imei: d.imei,
|
||||
mac: d.mac,
|
||||
function markFailedFromStep(stepId: string, message: string) {
|
||||
const order = ['signal', 'credential', 'network']
|
||||
const failedIndex = order.indexOf(stepId)
|
||||
checkItems.value = checkItems.value.map((item) => {
|
||||
const idx = order.indexOf(item.id)
|
||||
if (idx < failedIndex && item.status === 'done') return item
|
||||
if (item.id === stepId) return { ...item, status: 'failed', tag: '失败' }
|
||||
if (idx > failedIndex) return { ...item, status: 'pending', tag: '未开始' }
|
||||
return item
|
||||
})
|
||||
setTimeout(() => {
|
||||
uni.redirectTo({ url: `/pages/add-device/complete/index?${q}` })
|
||||
}, 600)
|
||||
})
|
||||
connectStatus.value = 'failed'
|
||||
errorMessage.value = message
|
||||
progress.value = Math.max(progress.value, 30)
|
||||
}
|
||||
|
||||
async function startPairing() {
|
||||
if (pairing) return
|
||||
pairing = true
|
||||
connectStatus.value = 'connecting'
|
||||
errorMessage.value = ''
|
||||
progress.value = 5
|
||||
|
||||
checkItems.value = [
|
||||
{ id: 'signal', title: '蓝牙信号强度', status: 'syncing', tag: '检测中' },
|
||||
{ id: 'credential', title: '设备凭证校验', status: 'pending', tag: '待校验' },
|
||||
{ id: 'network', title: '蓝牙连接建立', status: 'pending', tag: '待连接' },
|
||||
]
|
||||
|
||||
try {
|
||||
if (!isBluetoothApiSupported()) {
|
||||
throw new Error('当前环境不支持蓝牙,请使用 App 或微信小程序')
|
||||
}
|
||||
|
||||
await ensureBluetoothReady()
|
||||
updateCheck('signal', 'done', '良好')
|
||||
progress.value = 30
|
||||
|
||||
const mac = device.value.mac?.trim()
|
||||
const name = device.value.name?.trim()
|
||||
if (!mac && !name) {
|
||||
throw new Error('缺少设备 MAC 或名称,无法建立蓝牙连接')
|
||||
}
|
||||
|
||||
updateCheck('credential', 'syncing', '扫描中')
|
||||
progress.value = 45
|
||||
|
||||
const found = await scanForBleDeviceByMac(mac ?? '', {
|
||||
timeoutMs: SCAN_TIMEOUT_MS,
|
||||
name,
|
||||
})
|
||||
if (!found) {
|
||||
markFailedFromStep('credential', '未找到设备,请确认已开机并靠近手机')
|
||||
return
|
||||
}
|
||||
|
||||
updateCheck('credential', 'done', '通过')
|
||||
progress.value = 70
|
||||
updateCheck('network', 'syncing', '连接中')
|
||||
|
||||
await ensureBleConnection(found.deviceId)
|
||||
bleDeviceId = found.deviceId
|
||||
|
||||
try {
|
||||
await queryZ2BleState(found.deviceId)
|
||||
} catch {
|
||||
// 连接成功即可继续
|
||||
}
|
||||
|
||||
updateCheck('network', 'done', '已连接')
|
||||
progress.value = 100
|
||||
connectStatus.value = 'success'
|
||||
|
||||
const d = device.value
|
||||
const q = buildBindDeviceQuery({
|
||||
deviceNo: d.deviceNo,
|
||||
name: d.name,
|
||||
serialMasked: d.serialMasked,
|
||||
productId: d.productId,
|
||||
model: d.model,
|
||||
imei: d.imei,
|
||||
mac: d.mac,
|
||||
bluetoothConnected: '1',
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
uni.redirectTo({ url: `/pages/add-device/complete/index?${q}` })
|
||||
}, 500)
|
||||
} catch (err) {
|
||||
const message = getBluetoothErrorMessage(err)
|
||||
const failedStep = progress.value < 30
|
||||
? 'signal'
|
||||
: progress.value < 70
|
||||
? 'credential'
|
||||
: 'network'
|
||||
markFailedFromStep(failedStep, message)
|
||||
uni.showToast({ title: message, icon: 'none' })
|
||||
} finally {
|
||||
pairing = false
|
||||
}
|
||||
}
|
||||
|
||||
function applyDevice(partial?: Partial<PairDeviceInfo>) {
|
||||
device.value = { ...device.value, ...partial }
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(tick)
|
||||
bleDeviceId = undefined
|
||||
})
|
||||
|
||||
function onCancelPair() {
|
||||
@@ -77,12 +160,20 @@ export function usePairDevice(initial?: Partial<PairDeviceInfo>) {
|
||||
})
|
||||
}
|
||||
|
||||
function onRetryPair() {
|
||||
void startPairing()
|
||||
}
|
||||
|
||||
return {
|
||||
currentStep,
|
||||
device,
|
||||
connectStatus,
|
||||
progress,
|
||||
etaSeconds,
|
||||
errorMessage,
|
||||
checkItems,
|
||||
applyDevice,
|
||||
startPairing,
|
||||
onCancelPair,
|
||||
onRetryPair,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<view v-if="visible" class="room-location-dialog">
|
||||
<view class="room-location-dialog__mask" @tap="onCancel" />
|
||||
<view class="room-location-dialog__panel" @tap.stop>
|
||||
<text class="room-location-dialog__title">设置房间位置</text>
|
||||
<text class="room-location-dialog__title">{{ title }}</text>
|
||||
<view class="room-location-dialog__input-wrap">
|
||||
<input
|
||||
v-model="roomName"
|
||||
@@ -32,9 +32,15 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
}>()
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
visible: boolean
|
||||
title?: string
|
||||
}>(),
|
||||
{
|
||||
title: '设置房间位置',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [value: boolean]
|
||||
|
||||
@@ -1,92 +1,22 @@
|
||||
import {
|
||||
mapZ2StateToRuntimePatch,
|
||||
type Z2AirQuality,
|
||||
} from '@/utils/z2-ble-protocol'
|
||||
import { queryZ2BleState } from '@/utils/z2-ble'
|
||||
import type { UpdateDeviceRuntimeBody } from './device-detail.types'
|
||||
|
||||
const SERVICE_UUID = (import.meta.env.VITE_BLE_SERVICE_UUID as string | undefined)?.trim()
|
||||
const RUNTIME_CHAR_UUID = (import.meta.env.VITE_BLE_RUNTIME_CHARACTERISTIC_UUID as string | undefined)?.trim()
|
||||
|
||||
function normalizeUuid(value: string): string {
|
||||
return value.replace(/-/g, '').toUpperCase()
|
||||
export type BleRuntimePatch = Partial<UpdateDeviceRuntimeBody> & {
|
||||
deviceBattery?: number
|
||||
filterLife?: number
|
||||
airQuality?: Z2AirQuality
|
||||
}
|
||||
|
||||
function readBleCharacteristic(
|
||||
deviceId: string,
|
||||
serviceId: string,
|
||||
characteristicId: string,
|
||||
): Promise<ArrayBuffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const targetService = normalizeUuid(serviceId)
|
||||
const targetChar = normalizeUuid(characteristicId)
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
cleanup()
|
||||
reject(new Error('读取特征值超时'))
|
||||
}, 5000)
|
||||
|
||||
const onValue = (res: UniApp.OnBLECharacteristicValueChangeCallbackResult) => {
|
||||
if (res.deviceId !== deviceId) return
|
||||
if (normalizeUuid(res.serviceId) !== targetService) return
|
||||
if (normalizeUuid(res.characteristicId) !== targetChar) return
|
||||
cleanup()
|
||||
resolve(res.value as ArrayBuffer)
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
clearTimeout(timeout)
|
||||
if (typeof uni.offBLECharacteristicValueChange === 'function') {
|
||||
uni.offBLECharacteristicValueChange(onValue)
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof uni.onBLECharacteristicValueChange !== 'function') {
|
||||
reject(new Error('当前环境不支持读取蓝牙特征值'))
|
||||
return
|
||||
}
|
||||
|
||||
uni.onBLECharacteristicValueChange(onValue)
|
||||
uni.readBLECharacteristicValue({
|
||||
deviceId,
|
||||
serviceId,
|
||||
characteristicId,
|
||||
success: () => {},
|
||||
fail: (err) => {
|
||||
cleanup()
|
||||
reject(err)
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function parseRuntimePayload(buffer: ArrayBuffer): Partial<UpdateDeviceRuntimeBody> {
|
||||
try {
|
||||
const bytes = new Uint8Array(buffer)
|
||||
const text = String.fromCharCode(...bytes).trim()
|
||||
if (!text) return {}
|
||||
const data = JSON.parse(text) as Record<string, unknown>
|
||||
const patch: Partial<UpdateDeviceRuntimeBody> = {}
|
||||
if (data.runMode != null) patch.runMode = String(data.runMode)
|
||||
if (data.fanLevel != null) patch.fanLevel = String(data.fanLevel)
|
||||
if (data.tvoc != null) patch.tvoc = Number(data.tvoc)
|
||||
if (data.powerMode != null) patch.powerMode = String(data.powerMode)
|
||||
if (data.charging != null) patch.charging = String(data.charging)
|
||||
if (data.filterTotalDays != null) patch.filterTotalDays = String(data.filterTotalDays)
|
||||
if (data.filterRemainDays != null) patch.filterRemainDays = String(data.filterRemainDays)
|
||||
if (data.parkingShutdown != null) patch.parkingShutdown = String(data.parkingShutdown)
|
||||
return patch
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
/** 若配置了 BLE 特征值 UUID,则尝试读取设备运行数据 */
|
||||
/** 通过 Z2 BLE 协议查询设备运行数据 */
|
||||
export async function readRuntimeFromBle(
|
||||
bleDeviceId: string,
|
||||
): Promise<Partial<UpdateDeviceRuntimeBody>> {
|
||||
if (!SERVICE_UUID || !RUNTIME_CHAR_UUID) return {}
|
||||
): Promise<BleRuntimePatch> {
|
||||
if (typeof uni.readBLECharacteristicValue !== 'function') return {}
|
||||
|
||||
try {
|
||||
const buffer = await readBleCharacteristic(bleDeviceId, SERVICE_UUID, RUNTIME_CHAR_UUID)
|
||||
return parseRuntimePayload(buffer)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
const state = await queryZ2BleState(bleDeviceId)
|
||||
return mapZ2StateToRuntimePatch(state)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** POST /app/device/runtime 查询参数 */
|
||||
export interface DeviceRuntimeQueryBody {
|
||||
deviceId: number
|
||||
deviceId: string | number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -25,11 +25,12 @@ export interface AppDeviceRuntimeVo {
|
||||
filterLife?: number
|
||||
filterReplaceTime?: string
|
||||
parkingShutdown?: string
|
||||
deviceBattery?: number
|
||||
}
|
||||
|
||||
/** POST /app/device/updateRuntime */
|
||||
export interface UpdateDeviceRuntimeBody {
|
||||
deviceId: number
|
||||
deviceId: string | number
|
||||
runMode?: string
|
||||
fanLevel?: string
|
||||
status?: string
|
||||
@@ -38,6 +39,7 @@ export interface UpdateDeviceRuntimeBody {
|
||||
charging?: string
|
||||
tvoc?: number
|
||||
powerMode?: string
|
||||
deviceBattery?: number
|
||||
filterTotalDays?: string
|
||||
filterRemainDays?: string
|
||||
parkingShutdown?: string
|
||||
@@ -45,8 +47,12 @@ export interface UpdateDeviceRuntimeBody {
|
||||
|
||||
export interface DeviceDetailRouteQuery {
|
||||
deviceId?: string
|
||||
deviceNo?: string
|
||||
name?: string
|
||||
locationName?: string
|
||||
locationId?: string
|
||||
homeId?: string
|
||||
model?: string
|
||||
status?: string
|
||||
bluetoothConnected?: string
|
||||
charging?: string
|
||||
|
||||
@@ -59,9 +59,9 @@ export function formatTvocValue(tvoc?: number): string {
|
||||
}
|
||||
|
||||
export function formatTvocLevel(tvoc?: number): string {
|
||||
const value = tvoc ?? 0
|
||||
if (value <= 0.3) return '优'
|
||||
if (value <= 0.6) return '良'
|
||||
if (tvoc == null) return '--'
|
||||
if (tvoc <= 0.3) return '优'
|
||||
if (tvoc <= 0.6) return '良'
|
||||
return '差'
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { onUnload } from '@dcloudio/uni-app'
|
||||
import { ref } from 'vue'
|
||||
import {
|
||||
BluetoothError,
|
||||
closeBleConnection,
|
||||
createBleConnection,
|
||||
ensureBleConnection,
|
||||
ensureBluetoothReady,
|
||||
getBluetoothErrorMessage,
|
||||
getDiscoveredBluetoothDevices,
|
||||
@@ -12,20 +11,24 @@ import {
|
||||
scanForBleDeviceByMac,
|
||||
subscribeBleConnectionStateChange,
|
||||
} from '@/utils/bluetooth'
|
||||
import { mapZ2StateToRuntimePatch } from '@/utils/z2-ble-protocol'
|
||||
import { clearZ2BleHandleCache, sendZ2BleCommand } from '@/utils/z2-ble'
|
||||
import { updateDeviceRuntime } from './device-detail.api'
|
||||
import { readRuntimeFromBle } from './device-bluetooth-read'
|
||||
import { readRuntimeFromBle, type BleRuntimePatch } from './device-bluetooth-read'
|
||||
import type { UpdateDeviceRuntimeBody } from './device-detail.types'
|
||||
|
||||
const SYNC_INTERVAL_MS = 15000
|
||||
const SCAN_TIMEOUT_MS = 15000
|
||||
|
||||
export interface DeviceBluetoothSyncContext {
|
||||
getDeviceId: () => number
|
||||
getDeviceId: () => string | number
|
||||
getMac: () => string | undefined
|
||||
getDeviceName?: () => string
|
||||
getRuntimePatch: () => Partial<UpdateDeviceRuntimeBody>
|
||||
onBluetoothConnectedChange: (connected: boolean) => void
|
||||
onSyncTextChange: (text: string) => void
|
||||
onSynced?: () => void | Promise<void>
|
||||
onBleRuntimeRead?: (patch: BleRuntimePatch) => void
|
||||
}
|
||||
|
||||
export function useDeviceBluetoothSync(context: DeviceBluetoothSyncContext) {
|
||||
@@ -39,7 +42,7 @@ export function useDeviceBluetoothSync(context: DeviceBluetoothSyncContext) {
|
||||
context.onSyncTextChange(text)
|
||||
}
|
||||
|
||||
async function reportDisconnected() {
|
||||
async function reportBleDisconnected() {
|
||||
const deviceId = context.getDeviceId()
|
||||
if (!deviceId) return
|
||||
context.onBluetoothConnectedChange(false)
|
||||
@@ -54,6 +57,22 @@ export function useDeviceBluetoothSync(context: DeviceBluetoothSyncContext) {
|
||||
}
|
||||
}
|
||||
|
||||
async function reportDeviceOffline() {
|
||||
const deviceId = context.getDeviceId()
|
||||
if (!deviceId) return
|
||||
context.onBluetoothConnectedChange(false)
|
||||
try {
|
||||
await updateDeviceRuntime({
|
||||
deviceId,
|
||||
status: '2',
|
||||
bluetoothConnected: '0',
|
||||
signalLevel: '0',
|
||||
})
|
||||
} catch {
|
||||
// 上报失败时保留本地状态
|
||||
}
|
||||
}
|
||||
|
||||
async function connectAndSync(): Promise<boolean> {
|
||||
const deviceId = context.getDeviceId()
|
||||
const mac = context.getMac()
|
||||
@@ -72,18 +91,24 @@ export function useDeviceBluetoothSync(context: DeviceBluetoothSyncContext) {
|
||||
if (syncing.value) return false
|
||||
syncing.value = true
|
||||
|
||||
let connected = false
|
||||
let targetBleId = bleDeviceId.value
|
||||
|
||||
try {
|
||||
setSyncText('正在连接蓝牙…')
|
||||
await ensureBluetoothReady()
|
||||
|
||||
let targetBleId = bleDeviceId.value
|
||||
let foundDevice: UniApp.BluetoothDeviceInfo | null = null
|
||||
|
||||
if (!targetBleId) {
|
||||
foundDevice = await scanForBleDeviceByMac(mac, { timeoutMs: SCAN_TIMEOUT_MS })
|
||||
const deviceName = context.getDeviceName?.()
|
||||
foundDevice = await scanForBleDeviceByMac(mac ?? '', {
|
||||
timeoutMs: SCAN_TIMEOUT_MS,
|
||||
name: deviceName,
|
||||
})
|
||||
if (!foundDevice) {
|
||||
setSyncText('未找到设备,请确认已开机并靠近手机')
|
||||
await reportDisconnected()
|
||||
await reportDeviceOffline()
|
||||
return false
|
||||
}
|
||||
targetBleId = foundDevice.deviceId
|
||||
@@ -93,22 +118,16 @@ export function useDeviceBluetoothSync(context: DeviceBluetoothSyncContext) {
|
||||
foundDevice = discovered.find((item) => item.deviceId === targetBleId) ?? null
|
||||
}
|
||||
|
||||
try {
|
||||
await createBleConnection(targetBleId)
|
||||
} catch (err) {
|
||||
const btErr = err as BluetoothError
|
||||
if (btErr.errCode !== 10010) throw err
|
||||
}
|
||||
|
||||
if (!targetBleId) {
|
||||
setSyncText('蓝牙连接异常,请重试')
|
||||
await reportDisconnected()
|
||||
return false
|
||||
}
|
||||
|
||||
await ensureBleConnection(targetBleId)
|
||||
connected = true
|
||||
context.onBluetoothConnectedChange(true)
|
||||
|
||||
setSyncText('正在同步设备数据…')
|
||||
const blePayload = await readRuntimeFromBle(targetBleId)
|
||||
if (Object.keys(blePayload).length) {
|
||||
context.onBleRuntimeRead?.(blePayload)
|
||||
}
|
||||
|
||||
const runtimePatch = context.getRuntimePatch()
|
||||
const signalLevel = rssiToSignalLevel(foundDevice?.RSSI)
|
||||
|
||||
@@ -121,28 +140,58 @@ export function useDeviceBluetoothSync(context: DeviceBluetoothSyncContext) {
|
||||
signalLevel,
|
||||
})
|
||||
|
||||
setSyncText('刚刚更新 · 数据实时同步中')
|
||||
if (!Object.keys(blePayload).length) {
|
||||
setSyncText('蓝牙已连接,等待设备上报数据…')
|
||||
} else {
|
||||
setSyncText('刚刚更新 · 数据实时同步中')
|
||||
}
|
||||
await context.onSynced?.()
|
||||
return true
|
||||
} catch (err) {
|
||||
setSyncText(getBluetoothErrorMessage(err))
|
||||
bleDeviceId.value = undefined
|
||||
await reportDisconnected()
|
||||
if (!connected) {
|
||||
bleDeviceId.value = undefined
|
||||
clearZ2BleHandleCache(targetBleId)
|
||||
await reportDeviceOffline()
|
||||
}
|
||||
return false
|
||||
} finally {
|
||||
syncing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function sendBleCommand(command: string): Promise<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() {
|
||||
if (active) return
|
||||
|
||||
if (!isBluetoothApiSupported()) {
|
||||
setSyncText('云端数据已加载 · 实时同步请使用 App 或微信小程序')
|
||||
return
|
||||
}
|
||||
|
||||
active = true
|
||||
|
||||
unsubscribeConnection = subscribeBleConnectionStateChange((res) => {
|
||||
if (!bleDeviceId.value || res.deviceId !== bleDeviceId.value) return
|
||||
if (!res.connected) {
|
||||
bleDeviceId.value = undefined
|
||||
void reportDisconnected()
|
||||
void reportBleDisconnected()
|
||||
setSyncText('蓝牙已断开,正在尝试重连…')
|
||||
}
|
||||
})
|
||||
@@ -167,8 +216,9 @@ export function useDeviceBluetoothSync(context: DeviceBluetoothSyncContext) {
|
||||
bleDeviceId.value = undefined
|
||||
|
||||
if (currentBleId) {
|
||||
clearZ2BleHandleCache(currentBleId)
|
||||
await closeBleConnection(currentBleId).catch(() => {})
|
||||
await reportDisconnected()
|
||||
await reportBleDisconnected()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,5 +231,6 @@ export function useDeviceBluetoothSync(context: DeviceBluetoothSyncContext) {
|
||||
startSyncLoop,
|
||||
stopSync,
|
||||
connectAndSync,
|
||||
sendBleCommand,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { computed, ref } from 'vue'
|
||||
import { fetchRoomList } from '@/api/location'
|
||||
import { getErrorMessage } from '@/utils/api-error'
|
||||
import { parseDeviceId, parseId, sameId } from '@/utils/device-id'
|
||||
import { fetchDeviceList } from '../device.api'
|
||||
import { fetchDeviceRuntime, updateDeviceRuntime } from './device-detail.api'
|
||||
import type { AppDeviceRuntimeVo, DeviceDetailRouteQuery, FanLevel, RunMode } from './device-detail.types'
|
||||
@@ -15,13 +17,27 @@ import {
|
||||
mapRuntimeToView,
|
||||
RUN_MODE_OPTIONS,
|
||||
} from './device-detail.utils'
|
||||
import {
|
||||
airQualityToLabel,
|
||||
airQualityToTvoc,
|
||||
clampPercent,
|
||||
encodeFanLevelBleCommand,
|
||||
encodeRunModeBleCommand,
|
||||
type Z2AirQuality,
|
||||
Z2_BLE_CMD,
|
||||
} from '@/utils/z2-ble-protocol'
|
||||
import type { BleRuntimePatch } from './device-bluetooth-read'
|
||||
import { useDeviceBluetoothSync } from './use-device-bluetooth-sync'
|
||||
|
||||
export function useDeviceDetail() {
|
||||
const deviceId = ref(0)
|
||||
const deviceId = ref('')
|
||||
const deviceNo = ref('')
|
||||
const deviceMac = ref<string>()
|
||||
const deviceName = ref('')
|
||||
const locationName = ref('客厅')
|
||||
const locationId = ref('')
|
||||
const homeId = ref('')
|
||||
const productModel = ref('CZ-Z2023')
|
||||
const deviceStatus = ref<string>()
|
||||
const bluetoothConnected = ref(false)
|
||||
const charging = ref(false)
|
||||
@@ -32,6 +48,7 @@ export function useDeviceDetail() {
|
||||
const runMode = ref<RunMode>('0')
|
||||
const fanLevel = ref<FanLevel>('0')
|
||||
const tvoc = ref<number | null>(null)
|
||||
const airQuality = ref<Z2AirQuality>()
|
||||
const powerMode = ref('0')
|
||||
const filterLife = ref(0)
|
||||
const filterRemainText = ref('预计还可使用 -- 天')
|
||||
@@ -41,7 +58,10 @@ export function useDeviceDetail() {
|
||||
const syncText = ref('数据实时同步中')
|
||||
|
||||
const tvocDisplay = computed(() => formatTvocValue(tvoc.value ?? undefined))
|
||||
const tvocLevel = computed(() => formatTvocLevel(tvoc.value ?? undefined))
|
||||
const tvocLevel = computed(() => {
|
||||
if (airQuality.value) return airQualityToLabel(airQuality.value)
|
||||
return formatTvocLevel(tvoc.value ?? undefined)
|
||||
})
|
||||
const powerModeText = computed(() => formatPowerMode(powerMode.value))
|
||||
const batteryDisplay = computed(() =>
|
||||
batteryLevel.value != null ? `${batteryLevel.value}%` : '--',
|
||||
@@ -58,28 +78,54 @@ export function useDeviceDetail() {
|
||||
showFanLevelControl.value ? AUTO_FAN_LEVEL_OPTIONS : [],
|
||||
)
|
||||
|
||||
function applyBleRuntime(patch: BleRuntimePatch) {
|
||||
if (patch.runMode != null) runMode.value = patch.runMode as RunMode
|
||||
if (patch.fanLevel != null) fanLevel.value = patch.fanLevel as FanLevel
|
||||
if (patch.airQuality != null) {
|
||||
airQuality.value = patch.airQuality
|
||||
tvoc.value = airQualityToTvoc(patch.airQuality) ?? tvoc.value
|
||||
} else if (patch.tvoc != null) {
|
||||
tvoc.value = patch.tvoc
|
||||
}
|
||||
if (patch.powerMode != null) powerMode.value = patch.powerMode
|
||||
if (patch.filterLife != null) filterLife.value = patch.filterLife
|
||||
if (patch.deviceBattery != null) batteryLevel.value = clampPercent(patch.deviceBattery)
|
||||
if (patch.charging != null) charging.value = patch.charging === '1'
|
||||
}
|
||||
|
||||
function applyRuntime(data: AppDeviceRuntimeVo) {
|
||||
const view = mapRuntimeToView(data, deviceName.value)
|
||||
deviceName.value = view.deviceName
|
||||
runMode.value = view.runMode
|
||||
fanLevel.value = view.fanLevel
|
||||
tvoc.value = view.tvoc
|
||||
if (view.tvoc != null) tvoc.value = view.tvoc
|
||||
powerMode.value = view.powerMode
|
||||
filterLife.value = view.filterLife
|
||||
filterRemainText.value = view.filterRemainText
|
||||
filterReplaced.value = view.filterReplaced
|
||||
isOnline.value = view.isOnline
|
||||
if (data.deviceBattery != null) batteryLevel.value = clampPercent(data.deviceBattery)
|
||||
if (data.status !== undefined) deviceStatus.value = data.status
|
||||
syncText.value = '刚刚更新 · 数据实时同步中'
|
||||
}
|
||||
|
||||
async function refreshDeviceMeta() {
|
||||
if (!deviceId.value) return
|
||||
try {
|
||||
const list = await fetchDeviceList()
|
||||
const device = list?.find((item) => item.deviceId === deviceId.value)
|
||||
const device = list?.find((item) => String(item.deviceId) === deviceId.value)
|
||||
if (!device) return
|
||||
if (device.deviceId != null) deviceId.value = String(device.deviceId)
|
||||
if (device.deviceNo) deviceNo.value = device.deviceNo
|
||||
if (device.mac) deviceMac.value = device.mac
|
||||
const parsedLocationId = parseId(device.locationId)
|
||||
if (parsedLocationId) locationId.value = parsedLocationId
|
||||
const parsedHomeId = parseId(device.homeId)
|
||||
if (parsedHomeId) homeId.value = parsedHomeId
|
||||
if (parsedHomeId && parsedLocationId) {
|
||||
const locations = await fetchRoomList(parsedHomeId)
|
||||
const matched = locations.find((item) => sameId(item.locationId, parsedLocationId))
|
||||
if (matched) locationName.value = matched.name
|
||||
}
|
||||
bluetoothConnected.value = isBluetoothConnected(device.bluetoothConnected)
|
||||
charging.value = device.charging === '1'
|
||||
if (device.status !== undefined) deviceStatus.value = device.status
|
||||
@@ -109,6 +155,14 @@ export function useDeviceDetail() {
|
||||
if (!deviceId.value || updating.value) return
|
||||
updating.value = true
|
||||
try {
|
||||
const bleCmd = patch.runMode != null
|
||||
? encodeRunModeBleCommand(patch.runMode)
|
||||
: patch.fanLevel != null
|
||||
? encodeFanLevelBleCommand(patch.fanLevel)
|
||||
: undefined
|
||||
if (bleCmd) {
|
||||
await sendBleCommand(bleCmd)
|
||||
}
|
||||
await updateDeviceRuntime({
|
||||
deviceId: deviceId.value,
|
||||
...patch,
|
||||
@@ -135,7 +189,21 @@ export function useDeviceDetail() {
|
||||
}
|
||||
|
||||
function onSettings() {
|
||||
uni.showToast({ title: '设备设置功能开发中', icon: 'none' })
|
||||
if (!deviceId.value) {
|
||||
uni.showToast({ title: '设备信息不完整', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const q = [
|
||||
`deviceId=${deviceId.value}`,
|
||||
...(deviceNo.value ? [`deviceNo=${encodeURIComponent(deviceNo.value)}`] : []),
|
||||
`name=${encodeURIComponent(deviceName.value)}`,
|
||||
`locationName=${encodeURIComponent(locationName.value)}`,
|
||||
...(locationId.value ? [`locationId=${locationId.value}`] : []),
|
||||
...(homeId.value ? [`homeId=${homeId.value}`] : []),
|
||||
`model=${encodeURIComponent(productModel.value)}`,
|
||||
`bluetoothConnected=${bluetoothConnected.value ? '1' : '0'}`,
|
||||
].join('&')
|
||||
uni.navigateTo({ url: `/pages/device/settings/index?${q}` })
|
||||
}
|
||||
|
||||
function onFilterReplace() {
|
||||
@@ -143,18 +211,20 @@ export function useDeviceDetail() {
|
||||
filterReplaceDialogVisible.value = true
|
||||
}
|
||||
|
||||
function onConfirmFilterReplace() {
|
||||
async function onConfirmFilterReplace() {
|
||||
await sendBleCommand(Z2_BLE_CMD.FILTER_RESET)
|
||||
filterReplaced.value = true
|
||||
uni.showToast({ title: '操作成功', icon: 'success' })
|
||||
}
|
||||
|
||||
const { startSyncLoop } = useDeviceBluetoothSync({
|
||||
const { startSyncLoop, sendBleCommand } = useDeviceBluetoothSync({
|
||||
getDeviceId: () => deviceId.value,
|
||||
getMac: () => deviceMac.value,
|
||||
getDeviceName: () => deviceName.value,
|
||||
getRuntimePatch: () => ({
|
||||
runMode: runMode.value,
|
||||
fanLevel: fanLevel.value,
|
||||
tvoc: tvoc.value ?? undefined,
|
||||
tvoc: tvoc.value ?? (airQuality.value ? airQualityToTvoc(airQuality.value) : undefined),
|
||||
powerMode: powerMode.value,
|
||||
charging: charging.value ? '1' : '0',
|
||||
}),
|
||||
@@ -168,19 +238,29 @@ export function useDeviceDetail() {
|
||||
onSyncTextChange: (text) => {
|
||||
syncText.value = text
|
||||
},
|
||||
onBleRuntimeRead: (patch) => {
|
||||
applyBleRuntime(patch)
|
||||
},
|
||||
onSynced: async () => {
|
||||
await refreshDeviceMeta()
|
||||
const data = await fetchDeviceRuntime({ deviceId: deviceId.value })
|
||||
applyRuntime(data)
|
||||
},
|
||||
})
|
||||
|
||||
onLoad((query?: DeviceDetailRouteQuery) => {
|
||||
deviceId.value = Number(query?.deviceId) || 0
|
||||
deviceId.value = parseDeviceId(query?.deviceId)
|
||||
if (query?.deviceNo) {
|
||||
deviceNo.value = decodeURIComponent(String(query.deviceNo))
|
||||
}
|
||||
if (query?.name) deviceName.value = decodeURIComponent(String(query.name))
|
||||
if (query?.locationName) {
|
||||
locationName.value = decodeURIComponent(String(query.locationName))
|
||||
}
|
||||
const parsedLocationId = parseId(query?.locationId)
|
||||
if (parsedLocationId) locationId.value = parsedLocationId
|
||||
|
||||
const parsedHomeId = parseId(query?.homeId)
|
||||
if (parsedHomeId) homeId.value = parsedHomeId
|
||||
if (query?.model) productModel.value = decodeURIComponent(String(query.model))
|
||||
if (query?.status) {
|
||||
deviceStatus.value = String(query.status)
|
||||
isOnline.value = query.status === '1'
|
||||
|
||||
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 {
|
||||
id: string
|
||||
name: string
|
||||
locationId?: number
|
||||
locationId?: string | number
|
||||
}
|
||||
|
||||
export interface HotDeviceItem {
|
||||
@@ -12,8 +12,8 @@ export interface HotDeviceItem {
|
||||
|
||||
/** POST /app/device/list 查询参数 */
|
||||
export interface DeviceQueryBody {
|
||||
homeId?: number
|
||||
locationId?: number
|
||||
homeId?: string | number
|
||||
locationId?: string | number
|
||||
}
|
||||
|
||||
/** 设备状态:0未激活 1在线 2离线 */
|
||||
@@ -21,13 +21,13 @@ export type DeviceStatus = '0' | '1' | '2'
|
||||
|
||||
/** POST /app/device/list 返回项 */
|
||||
export interface AppDeviceVo {
|
||||
deviceId?: number
|
||||
deviceId?: number | string
|
||||
deviceNo?: string
|
||||
name?: string
|
||||
imei?: string
|
||||
mac?: string
|
||||
homeId?: number
|
||||
locationId?: number
|
||||
homeId?: string | number
|
||||
locationId?: string | number
|
||||
status?: DeviceStatus | string
|
||||
bluetoothConnected?: string
|
||||
signalLevel?: string
|
||||
|
||||
@@ -1,54 +1,44 @@
|
||||
import type { AppDeviceVo, DeviceListItem, RoomTab } from './device.types'
|
||||
import type { RoomOption } from '@/types/location'
|
||||
import { sameId } from '@/utils/device-id'
|
||||
|
||||
/** 位置 ID → 房间名(后端暂无 locationName 时的本地映射) */
|
||||
const LOCATION_NAME_MAP: Record<number, string> = {
|
||||
1: '客厅',
|
||||
2: '主卧',
|
||||
3: '卧室',
|
||||
4: '厨房',
|
||||
5: '卫生间',
|
||||
6: '书房',
|
||||
}
|
||||
export type LocationMap = Map<string, RoomOption[]>
|
||||
|
||||
export function resolveLocationName(locationId?: number): string {
|
||||
function resolveLocationName(
|
||||
locationId?: string | number,
|
||||
locations: RoomOption[] = [],
|
||||
): string {
|
||||
if (locationId == null) return '未分配'
|
||||
return LOCATION_NAME_MAP[locationId] ?? `位置${locationId}`
|
||||
const matched = locations.find((item) => sameId(item.locationId, locationId))
|
||||
return matched?.name ?? '未分配'
|
||||
}
|
||||
|
||||
export function mapDeviceItem(device: AppDeviceVo): DeviceListItem {
|
||||
export function mapDeviceItem(
|
||||
device: AppDeviceVo,
|
||||
locationMap: LocationMap,
|
||||
): DeviceListItem {
|
||||
const homeKey = device.homeId != null ? String(device.homeId) : ''
|
||||
const locations = homeKey ? locationMap.get(homeKey) ?? [] : []
|
||||
return {
|
||||
...device,
|
||||
locationName: resolveLocationName(device.locationId),
|
||||
locationName: resolveLocationName(device.locationId, locations),
|
||||
isOnline: device.status === '1',
|
||||
isCharging: device.charging === '1',
|
||||
}
|
||||
}
|
||||
|
||||
/** 根据家庭房间位置列表构建首页 Tab(与所在房间页数据源一致) */
|
||||
export function buildRoomTabs(
|
||||
devices: DeviceListItem[],
|
||||
customRooms: RoomTab[] = [],
|
||||
locations: { locationId: string | number; name: string }[],
|
||||
): RoomTab[] {
|
||||
const tabs: RoomTab[] = [{ id: 'all', name: '全部' }]
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const device of devices) {
|
||||
if (device.locationId == null) continue
|
||||
const id = String(device.locationId)
|
||||
if (seen.has(id)) continue
|
||||
seen.add(id)
|
||||
for (const location of locations) {
|
||||
tabs.push({
|
||||
id,
|
||||
name: device.locationName,
|
||||
locationId: device.locationId,
|
||||
id: String(location.locationId),
|
||||
name: location.name,
|
||||
locationId: location.locationId,
|
||||
})
|
||||
}
|
||||
|
||||
for (const room of customRooms) {
|
||||
if (seen.has(room.id)) continue
|
||||
seen.add(room.id)
|
||||
tabs.push(room)
|
||||
}
|
||||
|
||||
return tabs
|
||||
}
|
||||
|
||||
@@ -57,9 +47,7 @@ export function filterDevicesByRoom(
|
||||
activeRoomId: string,
|
||||
): DeviceListItem[] {
|
||||
if (activeRoomId === 'all') return devices
|
||||
const locationId = Number(activeRoomId)
|
||||
if (Number.isNaN(locationId)) return devices
|
||||
return devices.filter((item) => item.locationId === locationId)
|
||||
return devices.filter((item) => sameId(item.locationId, activeRoomId))
|
||||
}
|
||||
|
||||
export function countOnlineDevices(devices: DeviceListItem[]): number {
|
||||
|
||||
@@ -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 { computed, ref } from 'vue'
|
||||
import { fetchHomeList, resolveValidHomeId } from '@/api/home'
|
||||
import { parseId, sameId } from '@/utils/device-id'
|
||||
import { createLocation } from '@/api/location'
|
||||
import { getErrorMessage } from '@/utils/api-error'
|
||||
import { fetchRoomList } from '@/api/location'
|
||||
import { fetchDeviceList } from './device.api'
|
||||
import type { DeviceListItem, HotDeviceItem, RoomTab } from './device.types'
|
||||
import type { LocationMap } from './device.utils'
|
||||
import { refreshDevicePresence } from './device-presence'
|
||||
import {
|
||||
buildRoomTabs,
|
||||
countOnlineDevices,
|
||||
@@ -10,8 +16,6 @@ import {
|
||||
mapDeviceItem,
|
||||
} from './device.utils'
|
||||
|
||||
const CUSTOM_ROOMS_KEY = 'device_custom_rooms'
|
||||
|
||||
const DEFAULT_HOT_DEVICES: HotDeviceItem[] = [
|
||||
{
|
||||
id: 'z2',
|
||||
@@ -25,24 +29,9 @@ const DEFAULT_HOT_DEVICES: HotDeviceItem[] = [
|
||||
},
|
||||
]
|
||||
|
||||
function loadCustomRooms(): RoomTab[] {
|
||||
try {
|
||||
const raw = uni.getStorageSync(CUSTOM_ROOMS_KEY) as RoomTab[] | string
|
||||
if (Array.isArray(raw)) return raw
|
||||
if (typeof raw === 'string' && raw) return JSON.parse(raw) as RoomTab[]
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function saveCustomRooms(rooms: RoomTab[]) {
|
||||
uni.setStorageSync(CUSTOM_ROOMS_KEY, rooms)
|
||||
}
|
||||
|
||||
export function useDevice() {
|
||||
const homeName = ref('我的家')
|
||||
const customRooms = ref<RoomTab[]>(loadCustomRooms())
|
||||
const homeId = ref('')
|
||||
const rooms = ref<RoomTab[]>([{ id: 'all', name: '全部' }])
|
||||
const activeRoomId = ref('all')
|
||||
const devices = ref<DeviceListItem[]>([])
|
||||
@@ -60,8 +49,27 @@ export function useDevice() {
|
||||
|
||||
const onlineCount = computed(() => countOnlineDevices(filteredDevices.value))
|
||||
|
||||
function refreshRoomTabs() {
|
||||
rooms.value = buildRoomTabs(devices.value, customRooms.value)
|
||||
async function buildLocationMap(homeIds: string[]): Promise<LocationMap> {
|
||||
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)) {
|
||||
activeRoomId.value = 'all'
|
||||
}
|
||||
@@ -71,9 +79,27 @@ export function useDevice() {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const list = await fetchDeviceList()
|
||||
devices.value = (list ?? []).map(mapDeviceItem)
|
||||
refreshRoomTabs()
|
||||
const list = await fetchDeviceList() ?? []
|
||||
const homeIds = [
|
||||
...new Set(
|
||||
list
|
||||
.map((item) => parseId(item.homeId))
|
||||
.filter((id) => id !== ''),
|
||||
),
|
||||
]
|
||||
if (!homeIds.length) {
|
||||
const defaultHomeId = await resolveValidHomeId()
|
||||
if (defaultHomeId) homeIds.push(defaultHomeId)
|
||||
}
|
||||
const locationMap = await buildLocationMap(homeIds)
|
||||
const deviceHomeId = parseId(list.find((item) => item.homeId != null)?.homeId)
|
||||
homeId.value = await resolveValidHomeId(deviceHomeId || homeIds[0])
|
||||
const homes = await fetchHomeList()
|
||||
const currentHome = homes?.find((item) => sameId(item.id, homeId.value))
|
||||
if (currentHome?.homeName) homeName.value = currentHome.homeName
|
||||
applyRoomTabs(locationMap)
|
||||
devices.value = list.map((item) => mapDeviceItem(item, locationMap))
|
||||
devices.value = await refreshDevicePresence(devices.value)
|
||||
} catch (err) {
|
||||
uni.showToast({ title: getErrorMessage(err, '加载设备失败'), icon: 'none' })
|
||||
} finally {
|
||||
@@ -97,16 +123,26 @@ export function useDevice() {
|
||||
roomDialogVisible.value = true
|
||||
}
|
||||
|
||||
function onConfirmAddRoom(name: string) {
|
||||
const room: RoomTab = {
|
||||
id: `custom-${Date.now()}`,
|
||||
name,
|
||||
async function onConfirmAddRoom(name: string) {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) return
|
||||
try {
|
||||
const id = await resolveValidHomeId(homeId.value)
|
||||
if (!id) {
|
||||
uni.showToast({ title: '未找到家庭信息', icon: 'none' })
|
||||
return
|
||||
}
|
||||
homeId.value = id
|
||||
await createLocation({ homeId: id, locationName: trimmed })
|
||||
await loadDevices()
|
||||
const created = rooms.value.find((room) => room.name === trimmed)
|
||||
if (created) {
|
||||
activeRoomId.value = created.id
|
||||
}
|
||||
showOperationSuccess()
|
||||
} catch (err) {
|
||||
uni.showToast({ title: getErrorMessage(err, '添加房间失败'), icon: 'none' })
|
||||
}
|
||||
customRooms.value = [...customRooms.value, room]
|
||||
saveCustomRooms(customRooms.value)
|
||||
refreshRoomTabs()
|
||||
activeRoomId.value = room.id
|
||||
showOperationSuccess()
|
||||
}
|
||||
|
||||
function onStartBind() {
|
||||
@@ -122,11 +158,11 @@ export function useDevice() {
|
||||
}
|
||||
|
||||
function onNotify() {
|
||||
uni.showToast({ title: '消息功能开发中', icon: 'none' })
|
||||
uni.navigateTo({ url: '/pages/mine/notify/index' })
|
||||
}
|
||||
|
||||
function onHomeManage() {
|
||||
uni.showToast({ title: '家庭管理功能开发中', icon: 'none' })
|
||||
uni.navigateTo({ url: '/pages/device/home-manage/index' })
|
||||
}
|
||||
|
||||
function onManageDevices() {
|
||||
@@ -140,8 +176,11 @@ export function useDevice() {
|
||||
}
|
||||
const q = [
|
||||
`deviceId=${device.deviceId}`,
|
||||
...(device.deviceNo ? [`deviceNo=${encodeURIComponent(device.deviceNo)}`] : []),
|
||||
`name=${encodeURIComponent(device.name || '')}`,
|
||||
`locationName=${encodeURIComponent(device.locationName || '')}`,
|
||||
...(device.locationId != null ? [`locationId=${device.locationId}`] : []),
|
||||
...(device.homeId != null ? [`homeId=${device.homeId}`] : []),
|
||||
`status=${device.status ?? ''}`,
|
||||
`bluetoothConnected=${device.bluetoothConnected ?? '0'}`,
|
||||
`charging=${device.charging ?? '0'}`,
|
||||
|
||||
@@ -64,6 +64,4 @@ const {
|
||||
} = useLogin()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import './login.scss';
|
||||
</style>
|
||||
<style lang="scss" scoped src="./login.scss"></style>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ref } from 'vue'
|
||||
import { useSmsCode } from '@/hooks/use-sms-code'
|
||||
import { isValidPassword, isValidPhone, isValidSmsCode } from '@/utils/validate'
|
||||
import { getErrorMessage } from '@/utils/api-error'
|
||||
import { setupMessageInbox } from '@/hooks/use-message-inbox'
|
||||
import { getStoredToken } from '@/utils/auth-token'
|
||||
import { SMS_CODE_SCENE } from '@/types/api'
|
||||
import { loginByPassword, loginBySms } from './login.api'
|
||||
@@ -63,6 +64,7 @@ export function useLogin() {
|
||||
uni.showToast({ title: '登录失败,未获取到令牌', icon: 'none' })
|
||||
return
|
||||
}
|
||||
setupMessageInbox()
|
||||
uni.showToast({ title: '登录成功', icon: 'success' })
|
||||
uni.reLaunch({ url: '/pages/device/index' })
|
||||
} catch (err) {
|
||||
|
||||
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>
|
||||
<view class="mine-page">
|
||||
<view class="mine-page__placeholder">
|
||||
<text class="mine-page__title">我的</text>
|
||||
<text class="mine-page__hint">个人中心页面开发中</text>
|
||||
<view class="mine-page__content" :style="{ paddingTop: statusBarHeight + 16 + 'px' }">
|
||||
<view class="mine-page__profile">
|
||||
<view class="mine-page__avatar-wrap">
|
||||
<image
|
||||
v-if="avatarUrl"
|
||||
class="mine-page__avatar"
|
||||
:src="avatarUrl"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view v-else class="mine-page__avatar-placeholder" />
|
||||
</view>
|
||||
|
||||
<view class="mine-page__profile-meta">
|
||||
<view class="mine-page__name-row" @tap="onEditNickname">
|
||||
<text class="mine-page__nickname">{{ nickName }}</text>
|
||||
<view class="mine-page__edit">
|
||||
<image
|
||||
class="mine-page__edit-icon"
|
||||
:src="staticAssets.iconDeviceNameEdit"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<text class="mine-page__phone">{{ phone || '未绑定手机号' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="mine-page__stats">
|
||||
<view
|
||||
v-for="item in stats"
|
||||
:key="item.label"
|
||||
class="mine-page__stat-item"
|
||||
>
|
||||
<text class="mine-page__stat-value">{{ item.value }}</text>
|
||||
<text class="mine-page__stat-label">{{ item.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="mine-page__quick">
|
||||
<view
|
||||
v-for="action in quickActions"
|
||||
:key="action.id"
|
||||
class="mine-page__quick-item"
|
||||
@tap="onQuickAction(action)"
|
||||
>
|
||||
<view class="mine-page__quick-icon">
|
||||
<image
|
||||
class="mine-page__quick-icon-img"
|
||||
:src="action.icon"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
<text class="mine-page__quick-label">{{ action.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="mine-page__menu">
|
||||
<template v-for="(item, index) in menuList" :key="item.id">
|
||||
<view
|
||||
v-if="index > 0"
|
||||
class="mine-page__menu-divider"
|
||||
/>
|
||||
<view class="mine-page__menu-item" @tap="onMenuTap(item)">
|
||||
<text class="mine-page__menu-title">{{ item.title }}</text>
|
||||
<view class="mine-page__menu-right">
|
||||
<text v-if="item.value" class="mine-page__menu-value">{{ item.value }}</text>
|
||||
<image
|
||||
class="mine-page__menu-arrow"
|
||||
:src="staticAssets.iconChevronLeft"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<app-tab-bar :current="tabCurrent" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTabPage } from '@/hooks/use-tab-page'
|
||||
import { staticAssets } from '@/utils/static-assets'
|
||||
import { useMine } from './use-mine'
|
||||
|
||||
const { tabCurrent } = useTabPage('mine')
|
||||
|
||||
const {
|
||||
statusBarHeight,
|
||||
nickName,
|
||||
phone,
|
||||
avatarUrl,
|
||||
stats,
|
||||
quickActions,
|
||||
menuList,
|
||||
onEditNickname,
|
||||
onQuickAction,
|
||||
onMenuTap,
|
||||
} = useMine()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mine-page {
|
||||
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;
|
||||
}
|
||||
@import './mine.scss';
|
||||
</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>
|
||||
46
src/pages/mine/support/support-runtime.ts
Normal file
46
src/pages/mine/support/support-runtime.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/** 客服页运行时状态(模块级,确保离开页面后定时器可被彻底清理) */
|
||||
let pageActive = false
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
let queueTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const QUEUE_POLL_INTERVAL_MS = 5000
|
||||
|
||||
export function isSupportPageActive(): boolean {
|
||||
return pageActive
|
||||
}
|
||||
|
||||
export function markSupportPageActive(active: boolean) {
|
||||
pageActive = active
|
||||
}
|
||||
|
||||
export function stopSupportPollTimer() {
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
|
||||
export function stopSupportQueueTimer() {
|
||||
if (queueTimer) clearInterval(queueTimer)
|
||||
queueTimer = null
|
||||
}
|
||||
|
||||
export function teardownSupportRuntime() {
|
||||
markSupportPageActive(false)
|
||||
stopSupportPollTimer()
|
||||
stopSupportQueueTimer()
|
||||
}
|
||||
|
||||
export function startSupportQueuePolling(onTick: () => void) {
|
||||
stopSupportPollTimer()
|
||||
pollTimer = setInterval(() => {
|
||||
if (!pageActive) return
|
||||
onTick()
|
||||
}, QUEUE_POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
export function startSupportQueueWaitTimer(onTick: () => void) {
|
||||
stopSupportQueueTimer()
|
||||
queueTimer = setInterval(() => {
|
||||
if (!pageActive) return
|
||||
onTick()
|
||||
}, 1000)
|
||||
}
|
||||
136
src/pages/mine/support/support.api.ts
Normal file
136
src/pages/mine/support/support.api.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { request } from '@/api/request'
|
||||
import { ApiError } from '@/utils/api-error'
|
||||
import {
|
||||
isSessionEnded,
|
||||
normalizeSessionId,
|
||||
normalizeSessionResponse,
|
||||
unwrapCsActionResponse,
|
||||
} from './support.utils'
|
||||
import type {
|
||||
CsAppActionVo,
|
||||
CsClickButtonBody,
|
||||
CsCreateSessionBody,
|
||||
CsMessageVo,
|
||||
CsResolvedSession,
|
||||
CsSendMessageBody,
|
||||
CsSessionListQuery,
|
||||
CsSessionVo,
|
||||
CsTableData,
|
||||
} from './support.types'
|
||||
|
||||
function buildSessionPath(sessionId: string, suffix = '') {
|
||||
return `/app/cs/session/${encodeURIComponent(sessionId)}${suffix}`
|
||||
}
|
||||
|
||||
function unwrapRows<T>(data: T[] | CsTableData<T> | undefined): T[] {
|
||||
if (!data) return []
|
||||
if (Array.isArray(data)) return data
|
||||
return data.rows ?? []
|
||||
}
|
||||
|
||||
/** POST /app/cs/session — 创建客服会话;已有未结束会话时返回原会话 */
|
||||
export function createCsSession(data: CsCreateSessionBody = {}) {
|
||||
return request<CsAppActionVo | CsSessionVo | string | null>({
|
||||
url: '/app/cs/session',
|
||||
method: 'POST',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
async function fetchActiveCsSession(): Promise<CsSessionVo | null> {
|
||||
const list = await fetchCsSessionList({ pageNum: 1, pageSize: 20 })
|
||||
const active = list.find((item) => !isSessionEnded(item)) ?? list[0]
|
||||
if (!active || !normalizeSessionId(active)) return null
|
||||
return normalizeSessionResponse(active)
|
||||
}
|
||||
|
||||
/** 创建或获取当前客服会话(兼容 action 包装、id 字段、需二次查询列表) */
|
||||
export async function resolveCsSession(): Promise<CsResolvedSession> {
|
||||
let resolved = unwrapCsActionResponse(await createCsSession())
|
||||
let session = resolved.session
|
||||
let messages = resolved.messages
|
||||
let sessionId = normalizeSessionId(session)
|
||||
|
||||
if (!sessionId) {
|
||||
const active = await fetchActiveCsSession()
|
||||
if (active) {
|
||||
session = active
|
||||
sessionId = normalizeSessionId(active)
|
||||
messages = undefined
|
||||
}
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
throw new ApiError('未获取客服会话,请确认已登录后重试')
|
||||
}
|
||||
|
||||
if (session.status === undefined && session.sessionStatus === undefined) {
|
||||
try {
|
||||
session = normalizeSessionResponse(await fetchCsSessionDetail(sessionId))
|
||||
} catch {
|
||||
session = { ...session, sessionId }
|
||||
}
|
||||
}
|
||||
|
||||
return { session, messages }
|
||||
}
|
||||
|
||||
/** GET /app/cs/session/list — 查询自己的会话 */
|
||||
export function fetchCsSessionList(query: CsSessionListQuery = {}) {
|
||||
return request<CsSessionVo[] | CsTableData<CsSessionVo>>({
|
||||
url: '/app/cs/session/list',
|
||||
method: 'GET',
|
||||
data: query,
|
||||
}).then(unwrapRows)
|
||||
}
|
||||
|
||||
/** GET /app/cs/session/{sessionId} — 查询会话详情 */
|
||||
export function fetchCsSessionDetail(sessionId: string) {
|
||||
return request<CsSessionVo>({
|
||||
url: buildSessionPath(sessionId),
|
||||
method: 'GET',
|
||||
})
|
||||
}
|
||||
|
||||
/** GET /app/cs/session/{sessionId}/messages — 拉取会话消息 */
|
||||
export function fetchCsMessages(sessionId: string, afterSeq = 0) {
|
||||
return request<CsMessageVo[] | CsTableData<CsMessageVo>>({
|
||||
url: buildSessionPath(sessionId, '/messages'),
|
||||
method: 'GET',
|
||||
data: { afterSeq },
|
||||
}).then(unwrapRows)
|
||||
}
|
||||
|
||||
/** POST /app/cs/session/{sessionId}/message — 发送消息 */
|
||||
export function sendCsMessage(sessionId: string, data: CsSendMessageBody) {
|
||||
return request<CsAppActionVo>({
|
||||
url: buildSessionPath(sessionId, '/message'),
|
||||
method: 'POST',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
/** POST /app/cs/session/{sessionId}/transfer — 转人工 */
|
||||
export function transferCsSession(sessionId: string) {
|
||||
return request<void>({
|
||||
url: buildSessionPath(sessionId, '/transfer'),
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
/** POST /app/cs/session/{sessionId}/end — 结束会话 */
|
||||
export function endCsSession(sessionId: string) {
|
||||
return request<void>({
|
||||
url: buildSessionPath(sessionId, '/end'),
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
/** POST /app/cs/session/{sessionId}/button — 点击机器人按钮 */
|
||||
export function clickCsButton(sessionId: string, data: CsClickButtonBody) {
|
||||
return request<void>({
|
||||
url: buildSessionPath(sessionId, '/button'),
|
||||
method: 'POST',
|
||||
data,
|
||||
})
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user