feat:蓝牙绑定和在线客服
This commit is contained in:
@@ -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,
|
||||
})
|
||||
}
|
||||
695
src/pages/mine/support/support.scss
Normal file
695
src/pages/mine/support/support.scss
Normal file
@@ -0,0 +1,695 @@
|
||||
$support-green: #1ec671;
|
||||
$support-green-dark: #18b863;
|
||||
$support-page-bg: #f7f8fa;
|
||||
$support-card-border: #eef0f3;
|
||||
$support-text: #1a1a1a;
|
||||
$support-text-secondary: #98a2b3;
|
||||
$support-text-muted: #667085;
|
||||
$support-orange: #f5a623;
|
||||
$support-orange-bg: #fff7e6;
|
||||
$support-danger: #f04438;
|
||||
|
||||
.support-page {
|
||||
height: 100vh;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.support-page :deep(.app-navbar) {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.support-page :deep(.app-navbar__right) {
|
||||
width: auto;
|
||||
min-width: 80rpx;
|
||||
padding-left: 16rpx;
|
||||
}
|
||||
|
||||
.support-page__nav-action {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.support-page__nav-action-text {
|
||||
font-size: 30rpx;
|
||||
color: $support-green;
|
||||
line-height: 1;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.support-page__nav-action-text--end {
|
||||
color: $support-text-muted;
|
||||
}
|
||||
|
||||
.support-page__header {
|
||||
flex-shrink: 0;
|
||||
margin: 0;
|
||||
padding: 16rpx 32rpx 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.support-page__header-avatar {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
background: linear-gradient(145deg, #45d685 0%, #21c25e 100%);
|
||||
color: #fff;
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
|
||||
&--square {
|
||||
border-radius: 18rpx;
|
||||
}
|
||||
|
||||
&--circle {
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.support-page__header-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.support-page__header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.support-page__header-name {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: $support-text;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.support-page__header-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6rpx;
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: 20rpx;
|
||||
font-size: 20rpx;
|
||||
line-height: 1.2;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
|
||||
&--online {
|
||||
color: $support-green;
|
||||
background: rgba(30, 198, 113, 0.1);
|
||||
}
|
||||
|
||||
&--serving {
|
||||
color: $support-green;
|
||||
background: rgba(30, 198, 113, 0.1);
|
||||
border: 1rpx solid rgba(30, 198, 113, 0.18);
|
||||
}
|
||||
}
|
||||
|
||||
.support-page__header-badge-dot {
|
||||
width: 10rpx;
|
||||
height: 10rpx;
|
||||
border-radius: 50%;
|
||||
background: $support-green;
|
||||
}
|
||||
|
||||
.support-page__header-subtitle {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
font-size: 24rpx;
|
||||
color: $support-text-secondary;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.support-page__header-subtitle-dot {
|
||||
color: $support-green;
|
||||
margin-right: 4rpx;
|
||||
}
|
||||
|
||||
.support-page__header-hours {
|
||||
flex-shrink: 0;
|
||||
font-size: 24rpx;
|
||||
color: $support-green;
|
||||
line-height: 1.3;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.support-page__chat {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 0 24rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.support-page__chat-inner {
|
||||
padding: 8rpx 0 24rpx;
|
||||
}
|
||||
|
||||
.support-page__time {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 8rpx 0 24rpx;
|
||||
}
|
||||
|
||||
.support-page__time-pill {
|
||||
padding: 8rpx 20rpx;
|
||||
border-radius: 20rpx;
|
||||
background: #eef1f5;
|
||||
font-size: 22rpx;
|
||||
color: $support-text-secondary;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.support-page__msg {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 28rpx;
|
||||
|
||||
&--user {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
&--system {
|
||||
justify-content: center;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.support-page__msg-avatar {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
color: #fff;
|
||||
|
||||
&--bot {
|
||||
border-radius: 16rpx;
|
||||
background: linear-gradient(145deg, #45d685 0%, #21c25e 100%);
|
||||
}
|
||||
|
||||
&--agent {
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(145deg, #45d685 0%, #21c25e 100%);
|
||||
}
|
||||
|
||||
&--user {
|
||||
border-radius: 16rpx;
|
||||
background: #d0d5dd;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.support-page__msg-body {
|
||||
max-width: calc(100% - 96rpx);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&--user {
|
||||
align-items: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.support-page__bubble {
|
||||
max-width: 100%;
|
||||
padding: 20rpx 24rpx;
|
||||
font-size: 28rpx;
|
||||
line-height: 1.55;
|
||||
word-break: break-word;
|
||||
|
||||
&--bot,
|
||||
&--agent {
|
||||
background: #fff;
|
||||
color: $support-text;
|
||||
border: 1rpx solid $support-card-border;
|
||||
border-radius: 4rpx 20rpx 20rpx 20rpx;
|
||||
box-shadow: 0 2rpx 12rpx rgba(16, 24, 40, 0.04);
|
||||
}
|
||||
|
||||
&--user {
|
||||
background: $support-green;
|
||||
color: $support-text;
|
||||
border-radius: 20rpx 4rpx 20rpx 20rpx;
|
||||
}
|
||||
|
||||
&--system {
|
||||
max-width: 92%;
|
||||
padding: 12rpx 20rpx;
|
||||
background: #eef1f5;
|
||||
color: $support-text-muted;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
border-radius: 20rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.support-page__bubble-time {
|
||||
margin-top: 8rpx;
|
||||
font-size: 22rpx;
|
||||
color: $support-text-secondary;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.support-page__quick {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16rpx;
|
||||
margin: 0 0 28rpx 88rpx;
|
||||
}
|
||||
|
||||
.support-page__quick-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
padding: 14rpx 22rpx;
|
||||
background: #fff;
|
||||
border: 1rpx solid #e4e7ec;
|
||||
border-radius: 32rpx;
|
||||
font-size: 26rpx;
|
||||
color: #3b82f6;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.support-page__quick-dot {
|
||||
color: #3b82f6;
|
||||
font-size: 20rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.support-page__card {
|
||||
margin: 0 0 28rpx 88rpx;
|
||||
background: #fff;
|
||||
border: 1rpx solid $support-card-border;
|
||||
border-radius: 20rpx;
|
||||
padding: 28rpx;
|
||||
box-shadow: 0 2rpx 16rpx rgba(16, 24, 40, 0.04);
|
||||
}
|
||||
|
||||
.support-page__card-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: $support-text;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.support-page__card-step {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 16rpx;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.support-page__card-step-index {
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(30, 198, 113, 0.12);
|
||||
color: $support-green;
|
||||
font-size: 22rpx;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.support-page__card-step-text {
|
||||
flex: 1;
|
||||
font-size: 26rpx;
|
||||
color: $support-text-muted;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.support-page__queue-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
|
||||
.support-page__queue-title-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.support-page__queue-icon {
|
||||
font-size: 30rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.support-page__queue-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: $support-text;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.support-page__queue-tag {
|
||||
padding: 6rpx 14rpx;
|
||||
border-radius: 8rpx;
|
||||
background: $support-orange-bg;
|
||||
color: $support-orange;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.2;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.support-page__queue-steps {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 28rpx;
|
||||
padding: 0 8rpx;
|
||||
}
|
||||
|
||||
.support-page__queue-step {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
position: relative;
|
||||
|
||||
&:not(:last-child)::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 20rpx;
|
||||
left: calc(50% + 24rpx);
|
||||
width: calc(100% - 48rpx);
|
||||
height: 2rpx;
|
||||
background: #e4e7ec;
|
||||
}
|
||||
}
|
||||
|
||||
.support-page__queue-step-dot {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20rpx;
|
||||
line-height: 1;
|
||||
background: #f2f4f7;
|
||||
color: $support-text-secondary;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
&--done {
|
||||
background: $support-green;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&--active {
|
||||
background: $support-orange-bg;
|
||||
color: $support-orange;
|
||||
border: 2rpx solid $support-orange;
|
||||
}
|
||||
}
|
||||
|
||||
.support-page__queue-step-label {
|
||||
font-size: 22rpx;
|
||||
color: $support-text-muted;
|
||||
line-height: 1.3;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.support-page__queue-info {
|
||||
padding: 20rpx 0;
|
||||
border-top: 1rpx solid #f0f0f0;
|
||||
border-bottom: 1rpx solid #f0f0f0;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.support-page__queue-info-text {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: $support-text;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.support-page__queue-estimate {
|
||||
font-size: 24rpx;
|
||||
color: $support-text-secondary;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.support-page__queue-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.support-page__queue-link {
|
||||
font-size: 26rpx;
|
||||
color: $support-text-muted;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.support-page__queue-cancel {
|
||||
font-size: 26rpx;
|
||||
color: $support-danger;
|
||||
line-height: 1.4;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.support-page__diagnosis-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.support-page__diagnosis-title-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.support-page__diagnosis-bar {
|
||||
width: 6rpx;
|
||||
height: 28rpx;
|
||||
border-radius: 3rpx;
|
||||
background: $support-green;
|
||||
}
|
||||
|
||||
.support-page__diagnosis-estimate {
|
||||
padding: 6rpx 14rpx;
|
||||
border-radius: 8rpx;
|
||||
background: #f2f4f7;
|
||||
font-size: 22rpx;
|
||||
color: $support-text-secondary;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.support-page__diagnosis-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
padding: 14rpx 0;
|
||||
}
|
||||
|
||||
.support-page__diagnosis-icon {
|
||||
width: 36rpx;
|
||||
font-size: 24rpx;
|
||||
color: $support-text-secondary;
|
||||
text-align: center;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.support-page__diagnosis-label {
|
||||
flex: 1;
|
||||
font-size: 26rpx;
|
||||
color: $support-text;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.support-page__diagnosis-status {
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: 8rpx;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.2;
|
||||
flex-shrink: 0;
|
||||
|
||||
&--done {
|
||||
color: $support-green;
|
||||
background: rgba(30, 198, 113, 0.12);
|
||||
}
|
||||
|
||||
&--running {
|
||||
color: $support-orange;
|
||||
background: $support-orange-bg;
|
||||
}
|
||||
|
||||
&--pending {
|
||||
color: $support-text-secondary;
|
||||
background: #f2f4f7;
|
||||
}
|
||||
}
|
||||
|
||||
.support-page__progress {
|
||||
height: 8rpx;
|
||||
background: #eef1f5;
|
||||
border-radius: 4rpx;
|
||||
overflow: hidden;
|
||||
margin: 12rpx 0 20rpx;
|
||||
}
|
||||
|
||||
.support-page__progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #45d685 0%, #21c25e 100%);
|
||||
border-radius: 4rpx;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.support-page__diagnosis-suggest {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.support-page__diagnosis-suggest-item {
|
||||
padding: 12rpx 20rpx;
|
||||
border-radius: 28rpx;
|
||||
background: #f5f6f8;
|
||||
font-size: 24rpx;
|
||||
color: $support-text-muted;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.support-page__image-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.support-page__image {
|
||||
width: 280rpx;
|
||||
height: 200rpx;
|
||||
border-radius: 16rpx;
|
||||
background: #eef1f5;
|
||||
}
|
||||
|
||||
.support-page__footer {
|
||||
flex-shrink: 0;
|
||||
background: #fff;
|
||||
padding: 12rpx 24rpx calc(12rpx + env(safe-area-inset-bottom));
|
||||
border-top: 1rpx solid #f0f0f0;
|
||||
}
|
||||
|
||||
.support-page__footer-shortcuts {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.support-page__footer-shortcut {
|
||||
padding: 10rpx 22rpx;
|
||||
border-radius: 28rpx;
|
||||
background: #f5f6f8;
|
||||
font-size: 24rpx;
|
||||
color: $support-text-muted;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.support-page__input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.support-page__mic {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 16rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.support-page__mic-icon {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
}
|
||||
|
||||
.support-page__input-wrap {
|
||||
flex: 1;
|
||||
height: 72rpx;
|
||||
padding: 0 28rpx;
|
||||
background: #f5f6f8;
|
||||
border-radius: 36rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.support-page__input {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 72rpx;
|
||||
font-size: 28rpx;
|
||||
color: $support-text;
|
||||
}
|
||||
|
||||
.support-page__tag {
|
||||
width: 56rpx;
|
||||
height: 72rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.support-page__tag-icon {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
}
|
||||
|
||||
.support-page__send {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 18rpx;
|
||||
background: linear-gradient(135deg, #07b57a 0%, #a4e04b 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 6rpx 16rpx rgba(7, 181, 122, 0.28);
|
||||
}
|
||||
|
||||
.support-page__send-icon {
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
}
|
||||
|
||||
.support-page__secure {
|
||||
margin-top: 12rpx;
|
||||
text-align: center;
|
||||
font-size: 22rpx;
|
||||
color: $support-text-secondary;
|
||||
line-height: 1.4;
|
||||
}
|
||||
179
src/pages/mine/support/support.types.ts
Normal file
179
src/pages/mine/support/support.types.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
/** 会话状态:0 机器人 1 排队中 2 人工 3 已结束 */
|
||||
export const CS_SESSION_STATUS = {
|
||||
bot: '0',
|
||||
queue: '1',
|
||||
human: '2',
|
||||
closed: '3',
|
||||
} as const
|
||||
|
||||
export type CsSessionStatus = (typeof CS_SESSION_STATUS)[keyof typeof CS_SESSION_STATUS]
|
||||
|
||||
/** POST /app/cs/session、GET /app/cs/session/{sessionId} */
|
||||
export interface CsSessionVo {
|
||||
sessionId?: number | string
|
||||
id?: number | string
|
||||
status?: string | number
|
||||
sessionStatus?: string | number
|
||||
ended?: boolean | string
|
||||
agentId?: number | string
|
||||
agentName?: string
|
||||
agentNo?: string
|
||||
queuePosition?: number
|
||||
queueNo?: number
|
||||
estimateWaitTime?: string
|
||||
createTime?: string
|
||||
updateTime?: string
|
||||
}
|
||||
|
||||
export interface CsSessionListQuery {
|
||||
status?: string
|
||||
agentId?: number | string
|
||||
keyword?: string
|
||||
pageNum?: number
|
||||
pageSize?: number
|
||||
orderByColumn?: string
|
||||
isAsc?: string
|
||||
}
|
||||
|
||||
export interface CsTableData<T> {
|
||||
rows?: T[]
|
||||
total?: number
|
||||
}
|
||||
|
||||
/** GET /app/cs/session/{sessionId}/messages */
|
||||
export interface CsMessageVo {
|
||||
messageId?: number | string
|
||||
seq?: number
|
||||
sessionId?: number | string
|
||||
senderType?: string | number
|
||||
messageType?: string
|
||||
content?: string
|
||||
createTime?: string
|
||||
extra?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** WebSocket /app/cs/chat 推送 */
|
||||
export interface CsChatWsPayload {
|
||||
type?: string
|
||||
message?: CsMessageVo
|
||||
session?: CsSessionVo
|
||||
raw?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** POST /app/cs/session 请求体 */
|
||||
export interface CsCreateSessionBody {
|
||||
deviceId?: number | string
|
||||
summary?: string
|
||||
accessType?: 'bot' | 'human'
|
||||
}
|
||||
|
||||
/** POST /app/cs/session 及发消息等操作返回 data */
|
||||
export interface CsAppActionVo {
|
||||
session?: CsSessionVo
|
||||
messages?: CsMessageVo[]
|
||||
robot?: {
|
||||
kbId?: number | string
|
||||
title?: string
|
||||
content?: string
|
||||
matched?: boolean
|
||||
buttons?: Array<{ buttonCode?: string; label?: string }>
|
||||
}
|
||||
}
|
||||
|
||||
export interface CsResolvedSession {
|
||||
session: CsSessionVo
|
||||
messages?: CsMessageVo[]
|
||||
}
|
||||
|
||||
/** POST /app/cs/session/{sessionId}/message */
|
||||
export interface CsSendMessageBody {
|
||||
content: string
|
||||
msgType?: 'text' | 'image' | string
|
||||
clientMsgId: string
|
||||
}
|
||||
|
||||
/** POST /app/cs/session/{sessionId}/button */
|
||||
export type CsButtonCode = 'resolved' | 'transfer_human'
|
||||
|
||||
export interface CsClickButtonBody {
|
||||
buttonCode: CsButtonCode
|
||||
}
|
||||
|
||||
export type SupportPhase = 'bot' | 'queue' | 'human'
|
||||
|
||||
export type ChatRole = 'user' | 'bot' | 'agent' | 'system'
|
||||
|
||||
export interface QuickActionItem {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface DiagnosisItem {
|
||||
id: string
|
||||
icon: string
|
||||
label: string
|
||||
status: 'done' | 'running' | 'pending'
|
||||
}
|
||||
|
||||
export type SupportMessage =
|
||||
| {
|
||||
id: string
|
||||
seq?: number
|
||||
type: 'time'
|
||||
text: string
|
||||
}
|
||||
| {
|
||||
id: string
|
||||
seq?: number
|
||||
type: 'text'
|
||||
role: ChatRole
|
||||
content: string
|
||||
time?: string
|
||||
}
|
||||
| {
|
||||
id: string
|
||||
seq?: number
|
||||
type: 'quick-actions'
|
||||
actions: QuickActionItem[]
|
||||
}
|
||||
| {
|
||||
id: string
|
||||
seq?: number
|
||||
type: 'wifi-help'
|
||||
title: string
|
||||
steps: string[]
|
||||
}
|
||||
| {
|
||||
id: string
|
||||
seq?: number
|
||||
type: 'queue'
|
||||
position: number
|
||||
estimateText: string
|
||||
}
|
||||
| {
|
||||
id: string
|
||||
seq?: number
|
||||
type: 'diagnosis'
|
||||
title: string
|
||||
estimateText: string
|
||||
items: DiagnosisItem[]
|
||||
progress: number
|
||||
}
|
||||
| {
|
||||
id: string
|
||||
seq?: number
|
||||
type: 'image'
|
||||
role: 'user'
|
||||
src: string
|
||||
time?: string
|
||||
}
|
||||
|
||||
export interface SupportHeaderInfo {
|
||||
name: string
|
||||
subtitle?: string
|
||||
badgeText: string
|
||||
badgeType: 'online' | 'serving'
|
||||
avatarText: string
|
||||
avatarShape: 'square' | 'circle'
|
||||
serviceHours?: string
|
||||
}
|
||||
253
src/pages/mine/support/support.utils.ts
Normal file
253
src/pages/mine/support/support.utils.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import type {
|
||||
ChatRole,
|
||||
CsAppActionVo,
|
||||
CsMessageVo,
|
||||
CsSessionVo,
|
||||
QuickActionItem,
|
||||
SupportMessage,
|
||||
SupportPhase,
|
||||
} from './support.types'
|
||||
import { CS_SESSION_STATUS } from './support.types'
|
||||
|
||||
const QUICK_ACTIONS: QuickActionItem[] = [
|
||||
{ id: 'wifi', label: '设备连不上 Wi-Fi' },
|
||||
{ id: 'order', label: '订单怎么修改' },
|
||||
{ id: 'invoice', label: '申请电子发票' },
|
||||
{ id: 'device', label: '设备使用问题' },
|
||||
]
|
||||
|
||||
export function createClientMsgId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
|
||||
export function unwrapCsActionResponse(
|
||||
data: CsAppActionVo | CsSessionVo | string | null | undefined,
|
||||
): { session: CsSessionVo; messages?: CsMessageVo[] } {
|
||||
if (!data) return { session: {} }
|
||||
if (typeof data === 'string') {
|
||||
return { session: normalizeSessionResponse(data) }
|
||||
}
|
||||
if (data.session) {
|
||||
return {
|
||||
session: normalizeSessionResponse(data.session),
|
||||
messages: data.messages,
|
||||
}
|
||||
}
|
||||
const record = data as CsSessionVo & { messages?: CsMessageVo[] }
|
||||
const { messages, ...session } = record
|
||||
return {
|
||||
session: normalizeSessionResponse(session),
|
||||
messages,
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeSessionResponse(
|
||||
data: CsSessionVo | string | null | undefined,
|
||||
): CsSessionVo {
|
||||
if (!data) return {}
|
||||
if (typeof data === 'string') {
|
||||
const value = data.trim()
|
||||
return value ? { sessionId: value } : {}
|
||||
}
|
||||
const sessionId = data.sessionId ?? data.id
|
||||
if (sessionId === undefined || sessionId === null || sessionId === '') {
|
||||
return data
|
||||
}
|
||||
return { ...data, sessionId: String(sessionId) }
|
||||
}
|
||||
|
||||
export function normalizeSessionId(session: CsSessionVo | string | null | undefined): string {
|
||||
const normalized = normalizeSessionResponse(session)
|
||||
const raw = normalized.sessionId
|
||||
if (raw === undefined || raw === null || raw === '') return ''
|
||||
return String(raw)
|
||||
}
|
||||
|
||||
export function getSessionStatus(session: CsSessionVo): string {
|
||||
return String(session.status ?? session.sessionStatus ?? '')
|
||||
}
|
||||
|
||||
export function isSessionEnded(session: CsSessionVo): boolean {
|
||||
if (session.ended === true || session.ended === 'true' || session.ended === '1') return true
|
||||
const status = getSessionStatus(session)
|
||||
return status === CS_SESSION_STATUS.closed || status === 'ended' || status === 'closed'
|
||||
}
|
||||
|
||||
export function resolvePhaseFromSession(session: CsSessionVo): SupportPhase {
|
||||
if (isSessionEnded(session)) return 'bot'
|
||||
const status = getSessionStatus(session)
|
||||
if (status === CS_SESSION_STATUS.queue || status === '1') return 'queue'
|
||||
if (status === CS_SESSION_STATUS.human || status === '2') return 'human'
|
||||
return 'bot'
|
||||
}
|
||||
|
||||
export function getQueuePosition(session: CsSessionVo): number {
|
||||
const value = session.queuePosition ?? session.queueNo
|
||||
if (typeof value === 'number' && value > 0) return value
|
||||
return 1
|
||||
}
|
||||
|
||||
export function getQueueEstimate(session: CsSessionVo): string {
|
||||
return session.estimateWaitTime?.trim() || '正在为您分配人工客服'
|
||||
}
|
||||
|
||||
function formatMessageTime(value?: string): string | undefined {
|
||||
if (!value) return undefined
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return undefined
|
||||
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function resolveSenderRole(senderType?: string | number): ChatRole | null {
|
||||
const value = String(senderType ?? '').toLowerCase()
|
||||
if (!value) return 'bot'
|
||||
if (value === '0' || value.includes('user') || value.includes('app')) return 'user'
|
||||
if (value === '1' || value.includes('bot') || value.includes('robot')) return 'bot'
|
||||
if (value === '2' || value.includes('agent') || value.includes('human')) return 'agent'
|
||||
if (value === '3' || value.includes('system')) return 'system'
|
||||
return 'bot'
|
||||
}
|
||||
|
||||
function createMessageId(message: CsMessageVo): string {
|
||||
if (message.messageId !== undefined && message.messageId !== null && message.messageId !== '') {
|
||||
return String(message.messageId)
|
||||
}
|
||||
if (message.seq !== undefined && message.seq !== null) {
|
||||
return `seq-${message.seq}`
|
||||
}
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
|
||||
function mapStructuredMessage(message: CsMessageVo, payload: Record<string, unknown>): SupportMessage | null {
|
||||
const type = String(payload.type ?? message.messageType ?? '').toLowerCase()
|
||||
const id = createMessageId(message)
|
||||
const seq = message.seq
|
||||
|
||||
if (type === 'quick-actions' || type === 'quick_actions') {
|
||||
const actions = Array.isArray(payload.actions) ? payload.actions : QUICK_ACTIONS
|
||||
return {
|
||||
id,
|
||||
seq,
|
||||
type: 'quick-actions',
|
||||
actions: actions.map((item, index) => {
|
||||
if (typeof item === 'string') return { id: `action-${index}`, label: item }
|
||||
const record = item as { id?: string; label?: string }
|
||||
return { id: record.id || `action-${index}`, label: record.label || '' }
|
||||
}).filter((item) => item.label),
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'wifi-help' || type === 'wifi_help') {
|
||||
const steps = Array.isArray(payload.steps) ? payload.steps.map(String) : []
|
||||
return {
|
||||
id,
|
||||
seq,
|
||||
type: 'wifi-help',
|
||||
title: String(payload.title ?? 'Wi-Fi 连接失败 · 常见原因'),
|
||||
steps,
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'diagnosis') {
|
||||
const items = Array.isArray(payload.items) ? payload.items : []
|
||||
return {
|
||||
id,
|
||||
seq,
|
||||
type: 'diagnosis',
|
||||
title: String(payload.title ?? '远程诊断中'),
|
||||
estimateText: String(payload.estimateText ?? ''),
|
||||
progress: Number(payload.progress ?? 0),
|
||||
items: items.map((item, index) => {
|
||||
const record = item as {
|
||||
id?: string
|
||||
icon?: string
|
||||
label?: string
|
||||
status?: 'done' | 'running' | 'pending'
|
||||
}
|
||||
return {
|
||||
id: record.id || `diag-${index}`,
|
||||
icon: record.icon || '•',
|
||||
label: record.label || '',
|
||||
status: record.status || 'pending',
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'image') {
|
||||
const src = String(payload.src ?? payload.url ?? '')
|
||||
if (!src) return null
|
||||
return {
|
||||
id,
|
||||
seq,
|
||||
type: 'image',
|
||||
role: 'user',
|
||||
src,
|
||||
time: formatMessageTime(message.createTime),
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function mapCsMessage(message: CsMessageVo): SupportMessage | null {
|
||||
const content = message.content?.trim() ?? ''
|
||||
const messageType = String(message.messageType ?? '').toLowerCase()
|
||||
|
||||
if (messageType === 'image') {
|
||||
if (!content) return null
|
||||
return {
|
||||
id: createMessageId(message),
|
||||
seq: message.seq,
|
||||
type: 'image',
|
||||
role: 'user',
|
||||
src: content,
|
||||
time: formatMessageTime(message.createTime),
|
||||
}
|
||||
}
|
||||
|
||||
if (content.startsWith('{') && content.endsWith('}')) {
|
||||
try {
|
||||
const payload = JSON.parse(content) as Record<string, unknown>
|
||||
const structured = mapStructuredMessage(message, payload)
|
||||
if (structured) return structured
|
||||
} catch {
|
||||
// ignore invalid json content
|
||||
}
|
||||
}
|
||||
|
||||
const role = resolveSenderRole(message.senderType)
|
||||
if (!role || !content) return null
|
||||
|
||||
return {
|
||||
id: createMessageId(message),
|
||||
seq: message.seq,
|
||||
type: 'text',
|
||||
role,
|
||||
content,
|
||||
time: formatMessageTime(message.createTime),
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeSupportMessages(
|
||||
current: SupportMessage[],
|
||||
incoming: SupportMessage[],
|
||||
): SupportMessage[] {
|
||||
if (!incoming.length) return current
|
||||
const map = new Map<string, SupportMessage>()
|
||||
for (const item of current) map.set(item.id, item)
|
||||
for (const item of incoming) map.set(item.id, item)
|
||||
return Array.from(map.values()).sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0))
|
||||
}
|
||||
|
||||
export function getMaxSeq(messages: SupportMessage[]): number {
|
||||
return messages.reduce((max, item) => Math.max(max, item.seq ?? 0), 0)
|
||||
}
|
||||
|
||||
export function buildQuickActionsMessage(): SupportMessage {
|
||||
return {
|
||||
id: 'local-quick-actions',
|
||||
type: 'quick-actions',
|
||||
actions: QUICK_ACTIONS,
|
||||
}
|
||||
}
|
||||
489
src/pages/mine/support/use-support.ts
Normal file
489
src/pages/mine/support/use-support.ts
Normal file
@@ -0,0 +1,489 @@
|
||||
import { onBeforeUnmount } from 'vue'
|
||||
import { onHide, onShow, onUnload } from '@dcloudio/uni-app'
|
||||
import { computed, ref } from 'vue'
|
||||
import { getErrorMessage } from '@/utils/api-error'
|
||||
import { getStoredToken } from '@/utils/auth-token'
|
||||
import {
|
||||
clickCsButton,
|
||||
endCsSession,
|
||||
fetchCsMessages,
|
||||
fetchCsSessionDetail,
|
||||
resolveCsSession,
|
||||
sendCsMessage,
|
||||
transferCsSession,
|
||||
} from './support.api'
|
||||
import {
|
||||
connectCsChatSocket,
|
||||
disconnectCsChatSocket,
|
||||
isCsChatSocketConnected,
|
||||
onCsChatPush,
|
||||
} from './cs-chat-socket'
|
||||
import {
|
||||
isSupportPageActive,
|
||||
markSupportPageActive,
|
||||
startSupportQueuePolling,
|
||||
startSupportQueueWaitTimer,
|
||||
stopSupportPollTimer,
|
||||
stopSupportQueueTimer,
|
||||
} from './support-runtime'
|
||||
import type {
|
||||
CsSessionVo,
|
||||
QuickActionItem,
|
||||
SupportHeaderInfo,
|
||||
SupportMessage,
|
||||
SupportPhase,
|
||||
} from './support.types'
|
||||
import {
|
||||
buildQuickActionsMessage,
|
||||
createClientMsgId,
|
||||
getMaxSeq,
|
||||
getQueueEstimate,
|
||||
getQueuePosition,
|
||||
mapCsMessage,
|
||||
mergeSupportMessages,
|
||||
normalizeSessionId,
|
||||
resolvePhaseFromSession,
|
||||
unwrapCsActionResponse,
|
||||
} from './support.utils'
|
||||
|
||||
const BOT_HEADER: SupportHeaderInfo = {
|
||||
name: '智能客服 · 小智',
|
||||
badgeText: '在线',
|
||||
badgeType: 'online',
|
||||
avatarText: '智',
|
||||
avatarShape: 'square',
|
||||
serviceHours: '7×24 小时',
|
||||
}
|
||||
|
||||
const HUMAN_HEADER: SupportHeaderInfo = {
|
||||
name: '人工客服',
|
||||
subtitle: '在线',
|
||||
badgeText: '服务中',
|
||||
badgeType: 'serving',
|
||||
avatarText: '服',
|
||||
avatarShape: 'square',
|
||||
}
|
||||
|
||||
const FOOTER_SHORTCUTS = ['常见问题', '退改政策']
|
||||
|
||||
const MESSAGE_SYNC_MIN_GAP_MS = 3000
|
||||
|
||||
function formatWait(seconds: number): string {
|
||||
return `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function buildHumanHeader(session: CsSessionVo): SupportHeaderInfo {
|
||||
const name = session.agentName?.trim() || HUMAN_HEADER.name
|
||||
const agentNo = session.agentNo?.trim()
|
||||
return {
|
||||
...HUMAN_HEADER,
|
||||
name,
|
||||
subtitle: agentNo ? `工号 ${agentNo} · 在线` : HUMAN_HEADER.subtitle,
|
||||
avatarText: name.slice(0, 1) || HUMAN_HEADER.avatarText,
|
||||
}
|
||||
}
|
||||
|
||||
export function useSupport() {
|
||||
const phase = ref<SupportPhase>('bot')
|
||||
const apiMessages = ref<SupportMessage[]>([])
|
||||
const inputValue = ref('')
|
||||
const queueWaitSeconds = ref(0)
|
||||
const queuePosition = ref(1)
|
||||
const queueEstimate = ref('正在为您分配人工客服')
|
||||
const sessionId = ref('')
|
||||
const humanHeader = ref<SupportHeaderInfo>(HUMAN_HEADER)
|
||||
const sessionInitializing = ref(false)
|
||||
const sending = ref(false)
|
||||
const lastSeq = ref(0)
|
||||
|
||||
let unsubscribeChatPush: (() => void) | null = null
|
||||
let ensureSessionPromise: Promise<void> | null = null
|
||||
let syncingMessages = false
|
||||
let lastMessageSyncAt = 0
|
||||
|
||||
const headerInfo = computed<SupportHeaderInfo>(() =>
|
||||
phase.value === 'human' ? humanHeader.value : BOT_HEADER,
|
||||
)
|
||||
|
||||
const formattedQueueWait = computed(() => formatWait(queueWaitSeconds.value))
|
||||
|
||||
const showTransfer = computed(() => phase.value === 'bot')
|
||||
const showEndSession = computed(() => phase.value === 'queue' || phase.value === 'human')
|
||||
|
||||
const messages = computed<SupportMessage[]>(() => {
|
||||
const list = [...apiMessages.value]
|
||||
const hasQuickActions = list.some((item) => item.type === 'quick-actions')
|
||||
if (phase.value === 'bot' && !hasQuickActions) {
|
||||
list.push(buildQuickActionsMessage())
|
||||
}
|
||||
if (phase.value === 'queue') {
|
||||
list.push({
|
||||
id: 'queue-card',
|
||||
type: 'queue',
|
||||
position: queuePosition.value,
|
||||
estimateText: queueEstimate.value,
|
||||
})
|
||||
}
|
||||
return list
|
||||
})
|
||||
|
||||
function clearQueueTimer() {
|
||||
stopSupportQueueTimer()
|
||||
queueWaitSeconds.value = 0
|
||||
}
|
||||
|
||||
function startQueueTimer() {
|
||||
clearQueueTimer()
|
||||
startSupportQueueWaitTimer(() => {
|
||||
queueWaitSeconds.value += 1
|
||||
})
|
||||
}
|
||||
|
||||
function updateQueuePolling() {
|
||||
if (!isSupportPageActive()) return
|
||||
stopSupportPollTimer()
|
||||
if (phase.value === 'queue') {
|
||||
startSupportQueuePolling(() => {
|
||||
void refreshSession()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function handleChatPush(payload: {
|
||||
message?: Parameters<typeof mapCsMessage>[0]
|
||||
session?: CsSessionVo
|
||||
}) {
|
||||
if (!isSupportPageActive()) return
|
||||
if (payload.message) {
|
||||
const mapped = mapCsMessage(payload.message)
|
||||
if (mapped) {
|
||||
apiMessages.value = mergeSupportMessages(apiMessages.value, [mapped])
|
||||
lastSeq.value = getMaxSeq(apiMessages.value)
|
||||
}
|
||||
}
|
||||
if (payload.session) {
|
||||
applySessionState(payload.session)
|
||||
}
|
||||
}
|
||||
|
||||
function bindChatSocket(session: string) {
|
||||
unsubscribeChatPush?.()
|
||||
unsubscribeChatPush = onCsChatPush((payload) => {
|
||||
handleChatPush(payload)
|
||||
})
|
||||
connectCsChatSocket(session)
|
||||
}
|
||||
|
||||
function unbindChatSocket() {
|
||||
unsubscribeChatPush?.()
|
||||
unsubscribeChatPush = null
|
||||
disconnectCsChatSocket()
|
||||
}
|
||||
|
||||
function applyActionMessages(action: ReturnType<typeof unwrapCsActionResponse>) {
|
||||
if (!action.messages?.length) return
|
||||
const mapped = action.messages
|
||||
.map(mapCsMessage)
|
||||
.filter((item): item is SupportMessage => !!item)
|
||||
if (!mapped.length) return
|
||||
apiMessages.value = mergeSupportMessages(apiMessages.value, mapped)
|
||||
lastSeq.value = getMaxSeq(apiMessages.value)
|
||||
}
|
||||
|
||||
function applySessionState(session: CsSessionVo) {
|
||||
const nextPhase = resolvePhaseFromSession(session)
|
||||
queuePosition.value = getQueuePosition(session)
|
||||
queueEstimate.value = getQueueEstimate(session)
|
||||
|
||||
if (nextPhase === 'human') {
|
||||
humanHeader.value = buildHumanHeader(session)
|
||||
phase.value = 'human'
|
||||
clearQueueTimer()
|
||||
updateQueuePolling()
|
||||
return
|
||||
}
|
||||
|
||||
if (nextPhase === 'queue') {
|
||||
phase.value = 'queue'
|
||||
if (queueWaitSeconds.value === 0) startQueueTimer()
|
||||
updateQueuePolling()
|
||||
return
|
||||
}
|
||||
|
||||
phase.value = 'bot'
|
||||
humanHeader.value = HUMAN_HEADER
|
||||
clearQueueTimer()
|
||||
updateQueuePolling()
|
||||
}
|
||||
|
||||
async function syncMessages(reset = false) {
|
||||
if (!isSupportPageActive() || !sessionId.value || syncingMessages) return
|
||||
if (isCsChatSocketConnected() && !reset) return
|
||||
|
||||
const now = Date.now()
|
||||
if (!reset && now - lastMessageSyncAt < MESSAGE_SYNC_MIN_GAP_MS) return
|
||||
|
||||
syncingMessages = true
|
||||
try {
|
||||
const afterSeq = reset ? 0 : lastSeq.value
|
||||
const rows = await fetchCsMessages(sessionId.value, afterSeq)
|
||||
const mapped = rows.map(mapCsMessage).filter((item): item is SupportMessage => !!item)
|
||||
if (reset) {
|
||||
apiMessages.value = mapped
|
||||
} else {
|
||||
apiMessages.value = mergeSupportMessages(apiMessages.value, mapped)
|
||||
}
|
||||
lastSeq.value = getMaxSeq(apiMessages.value)
|
||||
lastMessageSyncAt = Date.now()
|
||||
} catch (err) {
|
||||
uni.showToast({ title: getErrorMessage(err, '消息同步失败'), icon: 'none' })
|
||||
} finally {
|
||||
syncingMessages = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshSession() {
|
||||
if (!isSupportPageActive() || !sessionId.value) return
|
||||
try {
|
||||
const session = await fetchCsSessionDetail(sessionId.value)
|
||||
applySessionState(session)
|
||||
} catch (err) {
|
||||
uni.showToast({ title: getErrorMessage(err, '会话状态更新失败'), icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
function teardownPage() {
|
||||
markSupportPageActive(false)
|
||||
stopSupportPollTimer()
|
||||
stopSupportQueueTimer()
|
||||
unbindChatSocket()
|
||||
clearQueueTimer()
|
||||
}
|
||||
|
||||
async function ensureSession() {
|
||||
if (ensureSessionPromise) return ensureSessionPromise
|
||||
|
||||
ensureSessionPromise = (async () => {
|
||||
if (!getStoredToken()) {
|
||||
uni.showToast({ title: '请先登录', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
sessionInitializing.value = true
|
||||
try {
|
||||
const resolved = await resolveCsSession()
|
||||
const session = resolved.session
|
||||
const nextSessionId = normalizeSessionId(session)
|
||||
|
||||
const sessionChanged = sessionId.value !== nextSessionId
|
||||
sessionId.value = nextSessionId
|
||||
applySessionState(session)
|
||||
|
||||
if (sessionChanged) {
|
||||
lastSeq.value = 0
|
||||
if (resolved.messages?.length) {
|
||||
applyActionMessages(resolved)
|
||||
} else {
|
||||
await syncMessages(true)
|
||||
}
|
||||
} else if (resolved.messages?.length) {
|
||||
applyActionMessages(resolved)
|
||||
} else {
|
||||
await syncMessages()
|
||||
}
|
||||
|
||||
bindChatSocket(nextSessionId)
|
||||
updateQueuePolling()
|
||||
} catch (err) {
|
||||
uni.showToast({ title: getErrorMessage(err, '客服会话创建失败'), icon: 'none' })
|
||||
} finally {
|
||||
sessionInitializing.value = false
|
||||
}
|
||||
})()
|
||||
|
||||
try {
|
||||
await ensureSessionPromise
|
||||
} finally {
|
||||
ensureSessionPromise = null
|
||||
}
|
||||
}
|
||||
|
||||
async function sendUserMessage(content: string) {
|
||||
const text = content.trim()
|
||||
if (!text || sending.value) return
|
||||
|
||||
if (!sessionId.value) {
|
||||
await ensureSession()
|
||||
if (!sessionId.value) {
|
||||
uni.showToast({ title: '会话未就绪,请稍后重试', icon: 'none' })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
sending.value = true
|
||||
try {
|
||||
const action = unwrapCsActionResponse(await sendCsMessage(sessionId.value, {
|
||||
content: text,
|
||||
msgType: 'text',
|
||||
clientMsgId: createClientMsgId(),
|
||||
}))
|
||||
if (action.session) {
|
||||
applySessionState(action.session)
|
||||
}
|
||||
if (action.messages?.length) {
|
||||
applyActionMessages(action)
|
||||
} else if (!isCsChatSocketConnected()) {
|
||||
await syncMessages()
|
||||
}
|
||||
await refreshSession()
|
||||
} catch (err) {
|
||||
uni.showToast({ title: getErrorMessage(err, '发送失败'), icon: 'none' })
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onQuickAction(action: QuickActionItem) {
|
||||
if (phase.value !== 'bot') return
|
||||
void sendUserMessage(action.label)
|
||||
}
|
||||
|
||||
async function onTransferHuman() {
|
||||
if (!sessionId.value || phase.value !== 'bot' || sending.value) return
|
||||
|
||||
sending.value = true
|
||||
try {
|
||||
await transferCsSession(sessionId.value)
|
||||
await refreshSession()
|
||||
await syncMessages()
|
||||
} catch (err) {
|
||||
uni.showToast({ title: getErrorMessage(err, '转人工失败'), icon: 'none' })
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function restartSession() {
|
||||
teardownPage()
|
||||
sessionId.value = ''
|
||||
apiMessages.value = []
|
||||
lastSeq.value = 0
|
||||
phase.value = 'bot'
|
||||
markSupportPageActive(true)
|
||||
await ensureSession()
|
||||
}
|
||||
|
||||
async function onEndSession() {
|
||||
if (!sessionId.value || sending.value) return
|
||||
if (phase.value !== 'queue' && phase.value !== 'human') return
|
||||
|
||||
const confirmed = await new Promise<boolean>((resolve) => {
|
||||
uni.showModal({
|
||||
title: '结束会话',
|
||||
content: '确定结束本次客服会话?',
|
||||
confirmText: '结束',
|
||||
cancelText: '取消',
|
||||
success: (res) => resolve(!!res.confirm),
|
||||
fail: () => resolve(false),
|
||||
})
|
||||
})
|
||||
if (!confirmed) return
|
||||
|
||||
sending.value = true
|
||||
try {
|
||||
await endCsSession(sessionId.value)
|
||||
await restartSession()
|
||||
} catch (err) {
|
||||
uni.showToast({ title: getErrorMessage(err, '结束会话失败'), icon: 'none' })
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onCancelQueue() {
|
||||
if (!sessionId.value) return
|
||||
sending.value = true
|
||||
try {
|
||||
await endCsSession(sessionId.value)
|
||||
await restartSession()
|
||||
} catch (err) {
|
||||
uni.showToast({ title: getErrorMessage(err, '取消排队失败'), icon: 'none' })
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onResolveWithoutHuman() {
|
||||
if (!sessionId.value) return
|
||||
sending.value = true
|
||||
try {
|
||||
await clickCsButton(sessionId.value, { buttonCode: 'resolved' })
|
||||
clearQueueTimer()
|
||||
await refreshSession()
|
||||
await syncMessages()
|
||||
} catch (err) {
|
||||
uni.showToast({ title: getErrorMessage(err, '操作失败'), icon: 'none' })
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onFooterShortcut(label: string) {
|
||||
void sendUserMessage(label)
|
||||
}
|
||||
|
||||
function onDiagnosisSuggestion(label: string) {
|
||||
void sendUserMessage(label)
|
||||
}
|
||||
|
||||
function onSend() {
|
||||
const text = inputValue.value.trim()
|
||||
if (!text) return
|
||||
inputValue.value = ''
|
||||
|
||||
if (/人工|转人工/.test(text) && phase.value === 'bot') {
|
||||
void onTransferHuman()
|
||||
return
|
||||
}
|
||||
|
||||
void sendUserMessage(text)
|
||||
}
|
||||
|
||||
function activatePage() {
|
||||
markSupportPageActive(true)
|
||||
if (!sessionId.value) {
|
||||
void ensureSession()
|
||||
return
|
||||
}
|
||||
bindChatSocket(sessionId.value)
|
||||
updateQueuePolling()
|
||||
if (!isCsChatSocketConnected()) {
|
||||
void syncMessages()
|
||||
}
|
||||
}
|
||||
|
||||
onShow(activatePage)
|
||||
onHide(teardownPage)
|
||||
onUnload(teardownPage)
|
||||
onBeforeUnmount(teardownPage)
|
||||
|
||||
return {
|
||||
phase,
|
||||
messages,
|
||||
inputValue,
|
||||
sessionId,
|
||||
headerInfo,
|
||||
showTransfer,
|
||||
showEndSession,
|
||||
formattedQueueWait,
|
||||
footerShortcuts: FOOTER_SHORTCUTS,
|
||||
onQuickAction,
|
||||
onTransferHuman,
|
||||
onEndSession,
|
||||
onCancelQueue,
|
||||
onResolveWithoutHuman,
|
||||
onFooterShortcut,
|
||||
onDiagnosisSuggestion,
|
||||
onSend,
|
||||
}
|
||||
}
|
||||
132
src/pages/mine/use-mine.ts
Normal file
132
src/pages/mine/use-mine.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { computed, ref } from 'vue'
|
||||
import { getErrorMessage } from '@/utils/api-error'
|
||||
import { staticAssets } from '@/utils/static-assets'
|
||||
import { fetchMineOverview } from './mine.api'
|
||||
import type { MineMenuItem, MineQuickAction } from './mine.types'
|
||||
|
||||
const APP_VERSION = 'v3.2.0'
|
||||
|
||||
const QUICK_ACTIONS: MineQuickAction[] = [
|
||||
{ id: 'notify', label: '消息通知', icon: staticAssets.iconMineNotify },
|
||||
{ id: 'order', label: '我的订单', icon: staticAssets.iconMineOrder },
|
||||
{ id: 'coupon', label: '优惠券', icon: staticAssets.iconMineCoupon },
|
||||
{ id: 'address', label: '收货地址', icon: staticAssets.iconMineAddress },
|
||||
]
|
||||
|
||||
const MENU_ITEMS: MineMenuItem[] = [
|
||||
{ id: 'devices', title: '我的设备' },
|
||||
{ id: 'members', title: '家庭成员' },
|
||||
{ id: 'scene', title: '场景与自动化' },
|
||||
{ id: 'service', title: '售后服务' },
|
||||
{ id: 'support', title: '在线客服' },
|
||||
{ id: 'feedback', title: '帮助与反馈' },
|
||||
{ id: 'settings', title: '通用设置' },
|
||||
{ id: 'privacy', title: '隐私与安全' },
|
||||
{ id: 'about', title: '关于我们', value: APP_VERSION },
|
||||
]
|
||||
|
||||
export function useMine() {
|
||||
const loading = ref(false)
|
||||
const nickName = ref('这里是昵称')
|
||||
const phone = ref('')
|
||||
const avatarUrl = ref('')
|
||||
const deviceCount = ref(0)
|
||||
const memberCount = ref(0)
|
||||
const warrantyCount = ref(0)
|
||||
const sceneCount = ref(0)
|
||||
|
||||
const statusBarHeight = ref(0)
|
||||
|
||||
const stats = computed(() => [
|
||||
{ label: '我的设备', value: deviceCount.value },
|
||||
{ label: '家庭成员', value: memberCount.value },
|
||||
{ label: '保修中', value: warrantyCount.value },
|
||||
])
|
||||
|
||||
const menuList = computed(() =>
|
||||
MENU_ITEMS.map((item) => {
|
||||
if (item.id === 'devices') {
|
||||
return { ...item, value: `已绑定 ${deviceCount.value} 台` }
|
||||
}
|
||||
if (item.id === 'members') {
|
||||
return { ...item, value: `${memberCount.value} 位成员` }
|
||||
}
|
||||
if (item.id === 'scene') {
|
||||
return { ...item, value: `${sceneCount.value} 个场景` }
|
||||
}
|
||||
if (item.id === 'service') {
|
||||
return { ...item, value: '报修 / 退换' }
|
||||
}
|
||||
return item
|
||||
}),
|
||||
)
|
||||
|
||||
async function loadOverview() {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const { profile, deviceCount: count } = await fetchMineOverview()
|
||||
deviceCount.value = count
|
||||
nickName.value = profile?.nickName || profile?.userName || '这里是昵称'
|
||||
phone.value = profile?.phonenumber || profile?.userName || ''
|
||||
avatarUrl.value = profile?.avatarUrl || ''
|
||||
} catch (err) {
|
||||
uni.showToast({ title: getErrorMessage(err, '加载资料失败'), icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onEditNickname() {
|
||||
uni.navigateTo({ url: '/pages/mine/edit/index' })
|
||||
}
|
||||
|
||||
function onQuickAction(action: MineQuickAction) {
|
||||
if (action.id === 'notify') {
|
||||
uni.navigateTo({ url: '/pages/mine/notify/index' })
|
||||
return
|
||||
}
|
||||
uni.showToast({ title: `${action.label}功能开发中`, icon: 'none' })
|
||||
}
|
||||
|
||||
function onMenuTap(item: MineMenuItem) {
|
||||
if (item.id === 'devices') {
|
||||
uni.reLaunch({ url: '/pages/device/index' })
|
||||
return
|
||||
}
|
||||
if (item.id === 'support') {
|
||||
uni.navigateTo({ url: '/pages/mine/support/index' })
|
||||
return
|
||||
}
|
||||
if (item.id === 'settings') {
|
||||
uni.navigateTo({ url: '/pages/mine/storage/index' })
|
||||
return
|
||||
}
|
||||
if (item.id === 'privacy') {
|
||||
uni.navigateTo({ url: '/pages/mine/privacy/index' })
|
||||
return
|
||||
}
|
||||
if (item.id === 'about') return
|
||||
uni.showToast({ title: `${item.title}功能开发中`, icon: 'none' })
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
const sys = uni.getSystemInfoSync()
|
||||
statusBarHeight.value = sys.statusBarHeight ?? 0
|
||||
void loadOverview()
|
||||
})
|
||||
|
||||
return {
|
||||
statusBarHeight,
|
||||
nickName,
|
||||
phone,
|
||||
avatarUrl,
|
||||
stats,
|
||||
quickActions: QUICK_ACTIONS,
|
||||
menuList,
|
||||
onEditNickname,
|
||||
onQuickAction,
|
||||
onMenuTap,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user