<template> <view :class="['app-container', theme]...
Creado el: 29 de julio de 2026
Respondido usando GPT-5.6 Thinking por Chat01
Creado el: 29 de julio de 2026
Respondido usando GPT-5.6 Thinking por Chat01
</template> <script setup> import { computed, nextTick, ref, watch } from 'vue' import { onLoad, onShow } from '@dcloudio/uni-app' import { storeToRefs } from 'pinia' import { useMainStore } from '@/store/index.js' import { getStatusBarHeight, getTitleBarHeight } from '@/utils/system.js' import { useLocator } from '@/store/uselocator.js' const mainStore = useMainStore() const { theme } = storeToRefs(mainStore) const locatorStore = useLocator() const { locators: deviceList, currentLocatorId, currentLocator: selectedDevice, placeList, deviceStatus, loading } = storeToRefs(locatorStore) const statusBarHeight = ref(getStatusBarHeight()) const headerBarHeight = ref(getTitleBarHeight()) const routeLocatorId = ref('') const routeMode = ref('home') const addMode = ref(false) const inputMode = ref(false) const deviceNumber = ref('') const editPopupVisible = ref(false) const deletePopupVisible = ref(false) const publicPopupVisible = ref(false) const publicSwitchDisabled = ref(false) const pendingPublicStatus = ref(false) const lastPublicStatus = ref(false) const isPublic = ref(false) const editName = ref('') const actionSwiperIndex = ref(0) const activeAction = ref('') const isDetailMode = computed(() => routeMode.value === 'detail' && !!routeLocatorId.value) const pageTitle = computed(() => { if (isDetailMode.value) return selectedDevice.value?.name || selectedDevice.value?.number || '设备详情' return '电子看护' }) const actions = [{ key: 'track', label: '轨迹', icon: 'paperplane', page: 'Track' }, { key: 'alarm', label: '报警记录', icon: 'notification', page: 'AlarmRecord' }, { key: 'fence', label: '设防', icon: 'flag', page: 'Fence' }, { key: 'command', label: '指令', icon: 'gear', page: 'Command' }, { key: 'navigate', label: '导航', icon: 'map', page: 'Navigation' } ] const productList = [{ id: 1, imageText: '儿童腕带', title: '防走丢实时定位', price: '90.99', desc: '儿童防走丢,智能定位', sold: '已售出200件', right: false }, { id: 2, imageText: '定位器', title: '智能安防定位', price: '90.99', desc: '儿童防走丢,智能定位', sold: '已售出200件', right: true }] const latestPlace = computed(() => deviceStatus.value?.latest || (placeList.value && placeList.value.length ? placeList.value[0] : null)) const hasLocation = computed(() => Number.isFinite(Number(latestPlace.value?.latitude)) && Number.isFinite(Number(latestPlace.value?.longitude))) const currentLat = computed(() => hasLocation.value ? Number(latestPlace.value.latitude) : 0) const currentLng = computed(() => hasLocation.value ? Number(latestPlace.value.longitude) : 0) const latestTime = computed(() => formatApiTime(latestPlace.value?.recordTime || latestPlace.value?.createTime) || '暂无定位时间') const latestAddress = computed(() => latestPlace.value?.address || '北京市北京市朝阳区,建外一道街东方瑞景东方瑞景-22号楼(西北偏西)12米。建国门外大街16号东方瑞景-C号楼(西北)18米。') const onlineText = computed(() => deviceStatus.value?.online ? '在线' : '离线') const mainMarkers = computed(() => [{ id: 1, latitude: currentLat.value, longitude: currentLng.value, width: 28, height: 28, iconPath: '/static/electronic-care/locator.png' }]) function formatApiTime(value) { if (!value) return '' const date = new Date(Number(value)) return Number.isNaN(date.getTime()) ? String(value) : `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}` } function navigateBack() { uni.navigateBack() } function EnteGroupSettings() { if (isDetailMode.value) return openAddDevice() } function openAddDevice() { addMode.value = true inputMode.value = !deviceList.value.length } async function connectDevice() { const number = deviceNumber.value.trim() if (!number) return uni.showToast({ title: '请输入设备编号', icon: 'none' }) const ok = await locatorStore.saveLocator({ number, name: `设备${deviceList.value.length + 1}`, isPublic: 0 }) if (ok) { deviceNumber.value = '' addMode.value = false inputMode.value = false if (locatorStore.currentLocatorId) await locatorStore.fetchPlaces(locatorStore.currentLocatorId) syncPublicStatus() } } function changeDevice(item) { locatorStore.setCurrentLocator(item) Promise.all([locatorStore.fetchPlaces(item.id), locatorStore.fetchDeviceStatus(item.id)]) actionSwiperIndex.value = 0 syncPublicStatus(item) } function showManagePage() { actionSwiperIndex.value = 1 } function onActionSwiperChange(e) { actionSwiperIndex.value = e.detail.current } function openEditPopup() { if (!selectedDevice.value) return editName.value = selectedDevice.value.name || '' editPopupVisible.value = true } async function confirmEditName() { if (!selectedDevice.value) return const name = editName.value.trim() if (!name) return uni.showToast({ title: '请输入设备名称', icon: 'none' }) const ok = await locatorStore.saveLocator({ ...selectedDevice.value, name, isPublic: isPublic.value ? 1 : 0 }) if (ok) { editPopupVisible.value = false actionSwiperIndex.value = 0 } } function openDeletePopup() { if (!selectedDevice.value) return deletePopupVisible.value = true } async function confirmDeleteDevice() { if (!selectedDevice.value) return const id = selectedDevice.value.id const ok = await locatorStore.deleteLocator(id) if (ok) { deletePopupVisible.value = false actionSwiperIndex.value = 0 if (isDetailMode.value) { setTimeout(() => { uni.navigateBack() }, 600) return } if (!deviceList.value.length) { addMode.value = true inputMode.value = false } } } function sharePosition() { uni.showToast({ title: '位置已准备分享', icon: 'none' }) } function goAction(item) { if (!selectedDevice.value) return uni.showToast({ title: '请先添加设备', icon: 'none' }) activeAction.value = item.key const query = `locatorId=${encodeURIComponent(selectedDevice.value.id)}&name=${encodeURIComponent(selectedDevice.value.name || '')}&number=${encodeURIComponent(selectedDevice.value.number || '')}&lat=${currentLat.value}&lng=${currentLng.value}&address=${encodeURIComponent(latestAddress.value)}` uni.navigateTo({ url: `/pages/visualization/ElectronicCarePage/${item.page}?${query}` }) } function getPublicValue(device = selectedDevice.value) { return device?.isPublic === true || device?.isPublic === 1 || String(device?.isPublic) === '1' } function syncPublicStatus(device = selectedDevice.value) { const next = getPublicValue(device) isPublic.value = next lastPublicStatus.value = next pendingPublicStatus.value = next } function onPublicChange(e) { if (!selectedDevice.value) return const next = e.detail.value lastPublicStatus.value = isPublic.value pendingPublicStatus.value = next isPublic.value = next publicPopupVisible.value = true } function cancelPublicChange() { publicPopupVisible.value = false publicSwitchDisabled.value = true isPublic.value = lastPublicStatus.value nextTick(() => { setTimeout(() => { publicSwitchDisabled.value = false }, 80) }) } async function confirmPublicChange() { if (!selectedDevice.value) return publicPopupVisible.value = false publicSwitchDisabled.value = true const ok = await locatorStore.saveLocator({ ...selectedDevice.value, isPublic: pendingPublicStatus.value ? 1 : 0 }) if (ok) { isPublic.value = pendingPublicStatus.value lastPublicStatus.value = pendingPublicStatus.value uni.showToast({ title: '设置成功', icon: 'success' }) } else { isPublic.value = lastPublicStatus.value } setTimeout(() => { publicSwitchDisabled.value = false }, 120) } async function initPage() { if (routeLocatorId.value) { locatorStore.setCurrentLocator({ id: routeLocatorId.value, name: '' }) } await locatorStore.fetchLocators() if (routeLocatorId.value) { const target = deviceList.value.find(item => String(item.id) === String(routeLocatorId.value)) if (target) locatorStore.setCurrentLocator(target) } if (locatorStore.currentLocatorId) { await Promise.all([locatorStore.fetchPlaces(locatorStore.currentLocatorId), locatorStore.fetchDeviceStatus(locatorStore.currentLocatorId)]) addMode.value = false inputMode.value = false } else { addMode.value = true inputMode.value = false } if (routeMode.value === 'add') { addMode.value = true inputMode.value = true } syncPublicStatus() } watch(selectedDevice, (val) => { if (val) syncPublicStatus(val) }, { deep: true }) onLoad((option = {}) => { routeLocatorId.value = option.locatorId || '' routeMode.value = option.mode === 'detail' || option.locatorId ? 'detail' : option.mode === 'add' ? 'add' : 'home' }) onShow(() => { initPage() }) </script> <style lang="scss" scoped> @import '@/assets/styles/global.scss'; .app-container { min-height: 100vh; background: #f7f8fc; display: flex; flex-direction: column; } .content { flex: 1; position: relative; background: linear-gradient(180deg, #fdf7ea 0%, #f7f8fc 38%, #f7f8fc 100%); display: flex; flex-direction: column; } .overlay { position: relative; width: 100%; min-height: 100vh; display: flex; flex-direction: column; } .header-fixed { position: fixed; top: 0; left: 0; width: 100%; background: linear-gradient(180deg, #fdf7ea 0%, #fef9f1 100%); z-index: 999; } .header-inner { position: relative; width: 94%; margin: 0 auto; display: flex; align-items: center; justify-content: center; } .left-box, .right-box { position: absolute; top: 0; bottom: 0; width: 120rpx; display: flex; align-items: center; } .left-box { left: 0; justify-content: flex-start; } .right-box { right: 0; justify-content: flex-end; } .add-text { font-size: 28rpx; color: #111111; font-weight: 500; } .center-box { flex: none; } .title-text { font-size: 34rpx; font-weight: 600; color: #111111; } .page-wrap { position: relative; width: 94%; margin: 0 auto; box-sizing: border-box; display: flex; flex-direction: column; padding-bottom: 80rpx; } .device-tabs-wrap { width: 100%; padding-top: 12rpx; } .device-tabs { width: 100%; white-space: nowrap; height: 62rpx; } .tabs-inner { display: flex; align-items: center; gap: 48rpx; min-width: 100%; } .device-tab { flex-shrink: 0; font-size: 24rpx; line-height: 50rpx; color: #333; padding-bottom: 6rpx; white-space: nowrap; } .device-tab.active { color: #d89833; border-bottom: 4rpx solid #d89833; } .add-area { height: 560rpx; background: rgba(238, 238, 238, .95); border-radius: 12rpx; margin-top: 20rpx; display: flex; align-items: center; justify-content: center; } .add-button { width: 300rpx; height: 76rpx; line-height: 76rpx; text-align: center; background: #fff; border-radius: 10rpx; font-size: 28rpx; color: #222; } .connect-box { width: 100%; display: flex; flex-direction: column; align-items: center; } .connect-title { font-size: 26rpx; color: #333; margin-bottom: 22rpx; } .device-input { width: 292rpx; height: 68rpx; background: #fff; border-radius: 8rpx; text-align: center; font-size: 28rpx; color: #111; } .connect-tip { margin-top: 28rpx; margin-bottom: 112rpx; font-size: 18rpx; color: #777; } .primary-button { width: 330rpx; height: 76rpx; line-height: 76rpx; border-radius: 8rpx; background: #df9c28; color: #222; font-size: 28rpx; } .device-panel { margin-top: 20rpx; border-radius: 18rpx; overflow: hidden; background: #fff; box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, .03); } .map-card { height: 360rpx; background: #e9e9e9; position: relative; } .main-map, .map-placeholder { width: 100%; height: 100%; } .map-placeholder { display: flex; align-items: center; justify-content: center; color: #333; font-size: 22rpx; background: #e9e9e9; } .map-manage { position: absolute; right: 10rpx; top: 10rpx; width: 38rpx; height: 38rpx; line-height: 34rpx; text-align: center; background: rgba(255, 255, 255, .9); border-radius: 4rpx; color: #222; font-size: 34rpx; } .device-card { background: #fff; padding: 8rpx 18rpx 12rpx; background-image: linear-gradient(135deg, rgba(216, 152, 51, .22) 0 3rpx, transparent 3rpx 28rpx); background-size: 38rpx 38rpx; } .device-top { display: flex; justify-content: space-between; align-items: flex-start; background: rgba(255, 255, 255, .96); } .device-number { display: block; font-size: 29rpx; line-height: 36rpx; color: #111; font-weight: 700; } .status-row { display: flex; align-items: center; gap: 12rpx; margin-top: 4rpx; } .offline-pill { height: 28rpx; line-height: 28rpx; padding: 0 15rpx; border-radius: 15rpx; background: #aaa; color: #fff; font-size: 20rpx; } .offline-time { font-size: 24rpx; color: #333; } .signal-box { min-width: 170rpx; display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; gap: 6rpx; } .battery { border: 1rpx solid #11bc70; color: #11bc70; font-size: 20rpx; line-height: 28rpx; padding: 0 10rpx; border-radius: 2rpx; } .signal-bars { height: 34rpx; display: flex; align-items: flex-end; gap: 5rpx; } .bar { width: 8rpx; background: #13c777; border-radius: 4rpx 4rpx 0 0; } .b1 { height: 15rpx; } .b2 { height: 24rpx; } .b3 { height: 32rpx; } .wifi { width: 100%; font-size: 23rpx; color: #222; text-align: right; } .time-row, .address-row { display: flex; align-items: flex-start; gap: 10rpx; background: rgba(255, 255, 255, .96); font-size: 22rpx; color: #222; line-height: 1.42; } .address-text { flex: 1; } .share-text { color: #5d9fe5; white-space: nowrap; } .action-swiper { height: 142rpx; background: #fff; } .action-row { height: 142rpx; display: flex; align-items: center; justify-content: space-between; padding: 0 20rpx; box-sizing: border-box; } .action-item { width: 104rpx; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #333; font-size: 22rpx; } .icon-box, .manage-icon { width: 60rpx; height: 60rpx; border-radius: 12rpx; background: #eee; display: flex; align-items: center; justify-content: center; margin-bottom: 8rpx; } .icon-box.active, .manage-icon.active { background: #df9c28; } .manage-row { height: 142rpx; display: flex; align-items: center; gap: 42rpx; padding-left: 38rpx; box-sizing: border-box; } .manage-item { width: 118rpx; display: flex; flex-direction: column; align-items: center; color: #333; font-size: 20rpx; } .dot-row { background: #fff; display: flex; justify-content: center; align-items: center; gap: 12rpx; padding: 0 0 20rpx; } .dot { width: 22rpx; height: 5rpx; border-radius: 20rpx; background: #d8d8d8; } .dot.active { background: #df9c28; width: 28rpx; } .below-device-area { width: 100%; } .remark-row { width: 100%; padding: 24rpx 4rpx; box-sizing: border-box; display: flex; flex-direction: row; align-items: center; } .remark-label, .remark-value { font-size: 28rpx; color: #333333; font-weight: 600; } .setting-row { width: 100%; min-height: 82rpx; background: #9b9b9b; border-radius: 12rpx; padding: 0 12rpx; box-sizing: border-box; display: flex; align-items: center; justify-content: space-between; } .setting-label { font-size: 26rpx; color: #111111; } .remove-btn { width: 100%; height: 82rpx; margin-top: 24rpx; background: #df9c28; border-radius: 12rpx; display: flex; align-items: center; justify-content: center; } .remove-text { font-size: 28rpx; color: #1e160b; font-weight: 600; } .product-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 34rpx; margin: 28rpx 0 0; } .product-card { background: #fff; border-radius: 10rpx; overflow: hidden; box-shadow: 0 8rpx 18rpx rgba(0, 0, 0, .07); position: relative; } .product-img { height: 156rpx; background: radial-gradient(circle at center, #4b2e25 0%, #1e1715 58%, #111 100%); display: flex; align-items: center; justify-content: center; color: #fff; font-size: 28rpx; font-weight: 600; } .product-img.right { background: radial-gradient(circle at center, #3b342e 0%, #1d1b1a 56%, #0f0f0f 100%); } .product-info { position: relative; padding: 12rpx 14rpx 16rpx; min-height: 118rpx; box-sizing: border-box; } .product-title { display: block; font-size: 25rpx; color: #111; line-height: 34rpx; } .product-price { display: block; font-size: 25rpx; color: #df9c28; line-height: 32rpx; } .product-desc, .sold-text { display: block; font-size: 20rpx; color: #999; line-height: 28rpx; } .buy-btn { position: absolute; right: 10rpx; bottom: 12rpx; width: 116rpx; height: 48rpx; line-height: 48rpx; border-radius: 6rpx; background: #222; color: #fff; font-size: 21rpx; padding: 0; } .modal-mask { position: fixed; inset: 0; z-index: 9999; background: rgba(0, 0, 0, .48); display: flex; align-items: center; justify-content: center; } .edit-modal, .delete-modal { width: 560rpx; background: #fff; border-radius: 12rpx; padding: 42rpx 42rpx 34rpx; box-sizing: border-box; position: relative; display: flex; flex-direction: column; align-items: center; } .modal-close { position: absolute; right: 14rpx; top: 10rpx; font-size: 24rpx; color: #333; } .modal-title { font-size: 28rpx; color: #333; margin-bottom: 30rpx; } .edit-line { width: 100%; display: flex; align-items: center; gap: 12rpx; font-size: 24rpx; color: #333; margin-bottom: 48rpx; } .edit-input { flex: 1; height: 48rpx; background: #eee; border-radius: 8rpx; padding: 0 16rpx; box-sizing: border-box; } .confirm-button { width: 170rpx; height: 54rpx; line-height: 54rpx; background: #eee; color: #333; font-size: 22rpx; border-radius: 10rpx; padding: 0; } .delete-text { font-size: 24rpx; color: #555; line-height: 1.6; text-align: center; margin-bottom: 34rpx; } .delete-actions { display: flex; gap: 24rpx; } .cancel-btn, .delete-btn { width: 170rpx; height: 58rpx; line-height: 58rpx; border-radius: 10rpx; font-size: 22rpx; padding: 0; } .cancel-btn { background: #eee; color: #333; } .delete-btn { background: #df9c28; color: #222; } .bottom-space { height: 120rpx; } </style>text<view class="center-box"> <text class="title-text">{{ pageTitle }}</text> </view> <view class="right-box" @click="EnteGroupSettings"> <text v-if="!isDetailMode" class="add-text">添加</text> </view> </view> </view> <view class="page-wrap" :style="{ paddingTop: (statusBarHeight + headerBarHeight) + 'px' }"> <view v-if="!isDetailMode && deviceList.length && !addMode" class="device-tabs-wrap"> <scroll-view scroll-x class="device-tabs" :show-scrollbar="false"> <view class="tabs-inner"> <view v-for="item in deviceList" :key="item.id" class="device-tab" :class="{ active: String(item.id) === String(currentLocatorId) }" @click="changeDevice(item)"> {{ item.name || item.number || '设备' }} </view> </view> </scroll-view> </view> <view v-if="!deviceList.length || addMode" class="add-panel"> <view class="add-area"> <view v-if="!inputMode" class="add-button" @click="inputMode = true">添加设备</view> <view v-else class="connect-box"> <text class="connect-title">请输入设备编号</text> <input class="device-input" v-model="deviceNumber" maxlength="32" placeholder="" /> <text class="connect-tip">连接示例</text> <button class="primary-button" :loading="loading" @click="connectDevice">开始连接</button> </view> </view> <view class="product-row"> <view v-for="product in productList" :key="product.id" class="product-card"> <view :class="['product-img', product.right ? 'right' : '']"> <text>{{ product.imageText }}</text></view> <view class="product-info"> <text class="product-title">{{ product.title }}</text> <text class="product-price">¥{{ product.price }}</text> <text class="product-desc">{{ product.desc }}</text> <text class="sold-text">{{ product.sold }}</text> <button class="buy-btn">立即购买</button> </view> </view> </view> </view> <view v-else class="device-panel"> <view class="map-card"> <map v-if="hasLocation && !editPopupVisible && !deletePopupVisible && !publicPopupVisible" class="main-map" :latitude="currentLat" :longitude="currentLng" :scale="16" :markers="mainMarkers" :show-location="false" :enable-scroll="false" :enable-zoom="false"> <cover-view class="map-manage" @click="showManagePage">□</cover-view> </map> <view v-else class="map-placeholder"> <text>地图</text> </view> </view> <view class="device-card"> <view class="device-top"> <view class="device-left"> <text class="device-number">{{ selectedDevice && (selectedDevice.number || selectedDevice.name) || '暂无编号' }}</text> <view class="status-row"> <text class="offline-pill">离线</text> <text class="offline-time">15天18时54分</text> </view> </view> <view class="signal-box"> <text class="battery">90%</text> <view class="signal-bars"> <view class="bar b1"></view> <view class="bar b2"></view> <view class="bar b3"></view> </view> <text class="wifi">定位: WIFI</text> </view> </view> <view class="time-row"> <uni-icons type="clock" color="#808080" size="14" /> <text>{{ latestTime }}</text> </view> <view class="address-row"> <uni-icons type="location-filled" color="#9b9b9b" size="18" /> <text class="address-text">{{ latestAddress }}</text> <text class="share-text" @click="sharePosition">分享</text> <uni-icons type="paperplane" color="#67a9e7" size="16" /> </view> </view> <swiper class="action-swiper" :current="actionSwiperIndex" @change="onActionSwiperChange"> <swiper-item> <view class="action-row"> <view v-for="item in actions" :key="item.key" class="action-item" @click="goAction(item)"> <view class="icon-box" :class="{ active: activeAction === item.key }"> <uni-icons :type="item.icon" color="#333333" size="21" /> </view> <text>{{ item.label }}</text> </view> </view> </swiper-item> <swiper-item> <view class="manage-row"> <view class="manage-item" @click="openEditPopup"> <view class="manage-icon active"><uni-icons type="compose" color="#222" size="23" /></view> <text>修改名称</text> </view> <view class="manage-item" @click="openDeletePopup"> <view class="manage-icon"><uni-icons type="trash" color="#222" size="23" /> </view> <text>删除设备</text> </view> </view> </swiper-item> </swiper> <view class="dot-row"> <view class="dot" :class="{ active: actionSwiperIndex === 0 }"></view> <view class="dot" :class="{ active: actionSwiperIndex === 1 }"></view> </view> </view> <view v-if="deviceList.length && !addMode" class="below-device-area"> <view class="remark-row"> <text class="remark-label">备注:</text> <text class="remark-value">{{ selectedDevice && selectedDevice.remark || selectedDevice && selectedDevice.note || '' }}</text> </view> <view class="setting-row"> <text class="setting-label">是否公开</text> <switch color="#dda645" :checked="isPublic" :disabled="publicSwitchDisabled" @change="onPublicChange" style="transform:scale(0.85);" /> </view> <view class="remove-btn" @click="openDeletePopup"> <text class="remove-text">移除设备</text> </view> <!-- <view class="product-row"> <view v-for="product in productList" :key="product.id" class="product-card"> <view :class="['product-img', product.right ? 'right' : '']"> <text>{{ product.imageText }}</text></view> <view class="product-info"> <text class="product-title">{{ product.title }}</text> <text class="product-price">¥{{ product.price }}</text> <text class="product-desc">{{ product.desc }}</text> <text class="sold-text">{{ product.sold }}</text> <button class="buy-btn">立即购买</button> </view> </view> </view> --> </view> <view class="bottom-space"></view> </view> <view v-if="editPopupVisible" class="modal-mask" @click.self="editPopupVisible = false"> <view class="edit-modal"> <text class="modal-close" @click="editPopupVisible = false">X</text> <text class="modal-title">修改设备名称</text> <view class="edit-line"> <text>修改为:</text> <input class="edit-input" v-model="editName" maxlength="16" /> </view> <button class="confirm-button" :loading="loading" @click="confirmEditName">确定修改</button> </view> </view> <view v-if="deletePopupVisible" class="modal-mask" @click.self="deletePopupVisible = false"> <view class="delete-modal"> <text class="modal-close" @click="deletePopupVisible = false">X</text> <text class="modal-title">移除设备</text> <text class="delete-text">确定移除「{{ selectedDevice && (selectedDevice.name || selectedDevice.number) || '该设备' }}」吗?移除后会从当前列表删除。</text> <view class="delete-actions"> <button class="cancel-btn" @click="deletePopupVisible = false">取消</button> <button class="delete-btn" :loading="loading" @click="confirmDeleteDevice">确定</button> </view> </view> </view> <view v-if="publicPopupVisible" class="modal-mask" @click.self="cancelPublicChange"> <view class="delete-modal"> <text class="modal-close" @click="cancelPublicChange">X</text> <text class="modal-title">设置公开</text> <text class="delete-text">确定要{{ pendingPublicStatus ? '公开' : '关闭公开' }}该定位器吗?</text> <view class="delete-actions"> <button class="cancel-btn" @click="cancelPublicChange">取消</button> <button class="delete-btn" :loading="loading" @click="confirmPublicChange">确定</button> </view> </view> </view> </view> </view> </view>
弹窗的问题解决下居中然后这个 页面的样式 在优化下写一个完整的页面
你这个弹窗偏到左上角,主要有两个常见原因:
inset: 0 支持不稳定。map 属于原生组件,层级可能高于普通 view,会影响弹窗显示。下面这版把弹窗统一改成了全屏固定定位,明确设置 top/left/right/bottom,弹窗出现时暂时隐藏地图,同时重构了设备卡片、操作区域、公开设置和删除确认逻辑。可以直接替换当前页面。
vue<template> <view :class="['page-container', theme]"> <!-- 顶部导航 --> <view class="page-header" :style="{ paddingTop: statusBarHeight + 'px', height: statusBarHeight + headerBarHeight + 'px' }" > <view class="header-content" :style="{ height: headerBarHeight + 'px' }" > <view class="header-side header-left" @click="navigateBack"> <uni-icons type="left" color="#1f1f1f" size="24" /> </view> <view class="header-title"> <text>{{ pageTitle }}</text> </view> <view class="header-side header-right" @click="openAddDevice"> <text v-if="!isDetailMode" class="header-action">添加</text> </view> </view> </view> <!-- 页面内容 --> <view class="page-content" :style="{ paddingTop: statusBarHeight + headerBarHeight + 'px' }" > <!-- 设备切换 --> <view v-if="deviceList.length && !addMode && !isDetailMode" class="device-tabs-container" > <scroll-view class="device-tabs" scroll-x :show-scrollbar="false" > <view class="device-tabs-inner"> <view v-for="item in deviceList" :key="item.id" :class="[ 'device-tab-item', { active: String(item.id) === String(currentLocatorId) } ]" @click="changeDevice(item)" > <text>{{ item.name || item.number || '设备' }}</text> </view> </view> </scroll-view> </view> <!-- 添加设备页面 --> <view v-if="!deviceList.length || addMode" class="add-device-page"> <view class="add-device-card"> <view v-if="!inputMode" class="empty-device"> <view class="empty-device-icon"> <uni-icons type="plus-filled" color="#dea02a" size="38" /> </view> <text class="empty-device-title">暂无设备</text> <text class="empty-device-desc"> 添加定位设备后,可查看实时位置和设备状态 </text> <view class="add-device-button" @click="inputMode = true"> <text>添加设备</text> </view> </view> <view v-else class="connect-device"> <text class="connect-title">连接定位设备</text> <text class="connect-desc">请输入设备背面的设备编号</text> <view class="device-input-wrap"> <input v-model="deviceNumber" class="device-input" maxlength="32" placeholder="请输入设备编号" placeholder-class="device-placeholder" /> </view> <view class="connect-button" :class="{ disabled: loading }" @click="connectDevice" > <text>{{ loading ? '连接中...' : '开始连接' }}</text> </view> <view v-if="deviceList.length" class="cancel-connect" @click="cancelAddDevice" > 返回设备列表 </view> </view> </view> <view class="product-section"> <view class="section-heading"> <text class="section-title">推荐设备</text> <text class="section-subtitle">精选智能定位产品</text> </view> <view class="product-grid"> <view v-for="product in productList" :key="product.id" class="product-card" > <view :class="[ 'product-cover', { second: product.right } ]" > <text>{{ product.imageText }}</text> </view> <view class="product-info"> <text class="product-title"> {{ product.title }} </text> <text class="product-desc"> {{ product.desc }} </text> <view class="product-bottom"> <text class="product-price"> ¥{{ product.price }} </text> <view class="buy-button"> <text>立即购买</text> </view> </view> </view> </view> </view> </view> </view> <!-- 设备详情 --> <template v-else> <view class="device-main-card"> <!-- 地图 --> <view class="map-section"> <map v-if="hasLocation && !dialogVisible" class="main-map" :latitude="currentLat" :longitude="currentLng" :scale="16" :markers="mainMarkers" :show-location="false" :enable-scroll="false" :enable-zoom="false" > <cover-view class="map-manage-button" @click="showManagePage" > <cover-image class="map-manage-image" src="/static/electronic-care/manage.png" /> </cover-view> </map> <view v-else class="map-placeholder"> <uni-icons type="map" color="#909090" size="36" /> <text> {{ dialogVisible ? '' : '暂无定位信息' }} </text> </view> </view> <!-- 设备信息 --> <view class="device-info"> <view class="device-info-top"> <view class="device-info-left"> <text class="device-number"> {{ selectedDevice && (selectedDevice.number || selectedDevice.name) || '暂无设备编号' }} </text> <view class="device-status-row"> <text :class="[ 'device-status', { online: isOnline } ]" > {{ onlineText }} </text> <text class="offline-time"> {{ offlineDurationText }} </text> </view> </view> <view class="device-signal"> <view class="battery-box"> <text>{{ batteryText }}</text> </view> <view class="signal-bars"> <view class="signal-bar signal-bar-1" /> <view class="signal-bar signal-bar-2" /> <view class="signal-bar signal-bar-3" /> </view> <text class="location-mode"> 定位:{{ positionMode }} </text> </view> </view> <view class="location-time"> <uni-icons type="clock" color="#8a8a8a" size="15" /> <text>{{ latestTime }}</text> </view> <view class="location-address"> <uni-icons type="location-filled" color="#979797" size="20" /> <text class="address-text"> {{ latestAddress }} </text> <view class="share-position" @click="sharePosition"> <text>分享</text> <uni-icons type="paperplane" color="#5c9fe1" size="17" /> </view> </view> </view> <!-- 操作菜单 --> <swiper class="action-swiper" :current="actionSwiperIndex" @change="onActionSwiperChange" > <swiper-item> <view class="action-list"> <view v-for="item in actions" :key="item.key" class="action-item" @click="goAction(item)" > <view :class="[ 'action-icon', { active: activeAction === item.key } ]" > <uni-icons :type="item.icon" color="#323232" size="22" /> </view> <text class="action-text"> {{ item.label }} </text> </view> </view> </swiper-item> <swiper-item> <view class="manage-list"> <view class="manage-item" @click="openEditDialog" > <view class="action-icon active"> <uni-icons type="compose" color="#272727" size="23" /> </view> <text class="action-text">修改名称</text> </view> <view class="manage-item" @click="openDeleteDialog" > <view class="action-icon"> <uni-icons type="trash" color="#272727" size="23" /> </view> <text class="action-text">删除设备</text> </view> </view> </swiper-item> </swiper> <view class="swiper-dots"> <view :class="[ 'swiper-dot', { active: actionSwiperIndex === 0 } ]" /> <view :class="[ 'swiper-dot', { active: actionSwiperIndex === 1 } ]" /> </view> </view> <!-- 设置区域 --> <view class="device-setting-card"> <view class="remark-section"> <text class="setting-title">备注</text> <text class="remark-content"> {{ selectedDevice && (selectedDevice.remark || selectedDevice.note) || '暂无备注' }} </text> </view> <view class="setting-divider" /> <view class="public-setting"> <view class="public-setting-info"> <text class="setting-title">是否公开</text> <text class="setting-description"> 开启后,其他用户可以查看该设备位置 </text> </view> <switch :key="publicSwitchKey" color="#dfa12a" :checked="isPublic" :disabled="publicSwitchDisabled" @change="onPublicChange" style=" transform: scale(0.82); transform-origin: right center; " /> </view> </view> <view class="remove-device-button" :class="{ disabled: loading }" @click="openDeleteDialog" > <text>移除设备</text> </view> </template> <view class="safe-bottom" /> </view> <!-- 弹窗必须放在页面最外层。 不要放进 scroll-view、swiper 或 map 内部。 --> <view v-if="dialogVisible" class="dialog-mask" @touchmove.stop.prevent @click="closeDialog" > <view class="dialog-card" @click.stop> <view class="dialog-close" @click="closeDialog"> <uni-icons type="closeempty" color="#333333" size="24" /> </view> <text class="dialog-title">{{ dialogTitle }}</text> <!-- 修改名称 --> <view v-if="dialogType === 'edit'" class="edit-dialog-content"> <text class="dialog-field-label">设备名称</text> <input v-model="editName" class="dialog-input" maxlength="16" placeholder="请输入设备名称" placeholder-class="dialog-placeholder" /> </view> <!-- 删除或公开确认 --> <text v-else class="dialog-message"> {{ dialogMessage }} </text> <view class="dialog-actions"> <button class="dialog-button dialog-cancel" @click="closeDialog" > 取消 </button> <button class="dialog-button dialog-confirm" :loading="loading" :disabled="loading" @click="confirmDialog" > {{ dialogConfirmText }} </button> </view> </view> </view> </view> </template> <script setup> import { computed, nextTick, ref, watch } from 'vue' import { onLoad, onPullDownRefresh, onShow } from '@dcloudio/uni-app' import { storeToRefs } from 'pinia' import { useMainStore } from '@/store/index.js' import { useLocator } from '@/store/uselocator.js' import { getStatusBarHeight, getTitleBarHeight } from '@/utils/system.js' const mainStore = useMainStore() const { theme } = storeToRefs(mainStore) const locatorStore = useLocator() const { locators: deviceList, currentLocatorId, currentLocator: selectedDevice, placeList, deviceStatus, loading } = storeToRefs(locatorStore) const statusBarHeight = ref(getStatusBarHeight()) const headerBarHeight = ref(getTitleBarHeight()) const routeLocatorId = ref('') const routeMode = ref('home') const addMode = ref(false) const inputMode = ref(false) const deviceNumber = ref('') const actionSwiperIndex = ref(0) const activeAction = ref('') const dialogType = ref('') const editName = ref('') const isPublic = ref(false) const lastPublicStatus = ref(false) const pendingPublicStatus = ref(false) const publicSwitchDisabled = ref(false) const publicSwitchKey = ref(0) const isDetailMode = computed(() => { return routeMode.value === 'detail' && Boolean(routeLocatorId.value) }) const pageTitle = computed(() => { if (isDetailMode.value) { return ( selectedDevice.value?.name || selectedDevice.value?.number || '设备详情' ) } return '电子看护' }) const dialogVisible = computed(() => Boolean(dialogType.value)) const dialogTitle = computed(() => { const titleMap = { edit: '修改设备名称', delete: '移除设备', public: pendingPublicStatus.value ? '设置公开' : '关闭公开' } return titleMap[dialogType.value] || '' }) const dialogMessage = computed(() => { if (dialogType.value === 'delete') { const deviceName = selectedDevice.value?.name || selectedDevice.value?.number || '该设备' return `确定移除「${deviceName}」吗?移除后,该设备将从当前列表中删除。` } if (dialogType.value === 'public') { return pendingPublicStatus.value ? '确定公开该定位设备吗?公开后,其他用户可能查看到该设备的位置信息。' : '确定关闭公开吗?关闭后,其他用户将无法查看该设备的位置信息。' } return '' }) const dialogConfirmText = computed(() => { if (dialogType.value === 'edit') { return '确定修改' } if (dialogType.value === 'delete') { return '确定移除' } return '确定' }) const actions = [ { key: 'track', label: '轨迹', icon: 'paperplane', page: 'Track' }, { key: 'alarm', label: '报警记录', icon: 'notification', page: 'AlarmRecord' }, { key: 'fence', label: '设防', icon: 'flag', page: 'Fence' }, { key: 'command', label: '指令', icon: 'gear', page: 'Command' }, { key: 'navigate', label: '导航', icon: 'map', page: 'Navigation' } ] const productList = [ { id: 1, imageText: '儿童腕带', title: '防走丢实时定位', price: '90.99', desc: '儿童防走丢,支持实时智能定位', right: false }, { id: 2, imageText: '定位器', title: '智能安防定位', price: '90.99', desc: '老人儿童宠物,多场景定位守护', right: true } ] const latestPlace = computed(() => { if (deviceStatus.value?.latest) { return deviceStatus.value.latest } if (Array.isArray(placeList.value) && placeList.value.length) { return placeList.value[0] } return null }) const hasLocation = computed(() => { const latitude = Number(latestPlace.value?.latitude) const longitude = Number(latestPlace.value?.longitude) return Number.isFinite(latitude) && Number.isFinite(longitude) }) const currentLat = computed(() => { return hasLocation.value ? Number(latestPlace.value.latitude) : 0 }) const currentLng = computed(() => { return hasLocation.value ? Number(latestPlace.value.longitude) : 0 }) const mainMarkers = computed(() => { if (!hasLocation.value) { return [] } return [ { id: 1, latitude: currentLat.value, longitude: currentLng.value, width: 30, height: 30, iconPath: '/static/electronic-care/locator.png' } ] }) const latestTime = computed(() => { const time = latestPlace.value?.recordTime || latestPlace.value?.createTime || latestPlace.value?.locationTime return formatApiTime(time) || '暂无定位时间' }) const latestAddress = computed(() => { return latestPlace.value?.address || '暂无定位地址' }) const isOnline = computed(() => { return Boolean(deviceStatus.value?.online) }) const onlineText = computed(() => { return isOnline.value ? '在线' : '离线' }) const offlineDurationText = computed(() => { if (isOnline.value) { return '设备连接正常' } return ( deviceStatus.value?.offlineDurationText || deviceStatus.value?.offlineText || '离线时长未知' ) }) const batteryText = computed(() => { const battery = deviceStatus.value?.battery ?? selectedDevice.value?.battery ?? 90 const value = Number(battery) if (!Number.isFinite(value)) { return '90%' } return `${Math.max(0, Math.min(100, value))}%` }) const positionMode = computed(() => { return ( deviceStatus.value?.locationType || latestPlace.value?.locationType || latestPlace.value?.positionType || 'WIFI' ) }) function formatApiTime(value) { if (!value) { return '' } let timestamp = Number(value) if (Number.isFinite(timestamp) && timestamp < 1000000000000) { timestamp *= 1000 } const date = Number.isFinite(timestamp) ? new Date(timestamp) : new Date(value) if (Number.isNaN(date.getTime())) { return String(value) } const year = date.getFullYear() const month = String(date.getMonth() + 1).padStart(2, '0') const day = String(date.getDate()).padStart(2, '0') const hour = String(date.getHours()).padStart(2, '0') const minute = String(date.getMinutes()).padStart(2, '0') const second = String(date.getSeconds()).padStart(2, '0') return `${year}-${month}-${day} ${hour}:${minute}:${second}` } function navigateBack() { uni.navigateBack() } function openAddDevice() { if (isDetailMode.value) { return } addMode.value = true inputMode.value = !deviceList.value.length } function cancelAddDevice() { addMode.value = false inputMode.value = false deviceNumber.value = '' } async function connectDevice() { if (loading.value) { return } const number = deviceNumber.value.trim() if (!number) { uni.showToast({ title: '请输入设备编号', icon: 'none' }) return } const result = await locatorStore.saveLocator({ number, name: `设备${deviceList.value.length + 1}`, isPublic: 0 }) if (!result) { return } deviceNumber.value = '' addMode.value = false inputMode.value = false await locatorStore.fetchLocators() if (locatorStore.currentLocatorId) { await Promise.all([ locatorStore.fetchPlaces(locatorStore.currentLocatorId), locatorStore.fetchDeviceStatus(locatorStore.currentLocatorId) ]) } syncPublicStatus() uni.showToast({ title: '设备添加成功', icon: 'success' }) } async function changeDevice(item) { if (!item?.id) { return } locatorStore.setCurrentLocator(item) actionSwiperIndex.value = 0 activeAction.value = '' syncPublicStatus(item) await Promise.all([ locatorStore.fetchPlaces(item.id), locatorStore.fetchDeviceStatus(item.id) ]) } function showManagePage() { actionSwiperIndex.value = 1 } function onActionSwiperChange(event) { actionSwiperIndex.value = Number(event.detail.current || 0) } function openEditDialog() { if (!selectedDevice.value) { return } editName.value = selectedDevice.value.name || '' dialogType.value = 'edit' } function openDeleteDialog() { if (!selectedDevice.value || loading.value) { return } dialogType.value = 'delete' } function onPublicChange(event) { if (!selectedDevice.value || publicSwitchDisabled.value) { return } const nextStatus = Boolean(event.detail.value) lastPublicStatus.value = isPublic.value pendingPublicStatus.value = nextStatus isPublic.value = nextStatus dialogType.value = 'public' } function closeDialog() { if (dialogType.value === 'public') { isPublic.value = lastPublicStatus.value refreshPublicSwitch() } dialogType.value = '' } async function confirmDialog() { if (loading.value) { return } if (dialogType.value === 'edit') { await confirmEditName() return } if (dialogType.value === 'delete') { await confirmDeleteDevice() return } if (dialogType.value === 'public') { await confirmPublicChange() } } async function confirmEditName() { if (!selectedDevice.value) { return } const name = editName.value.trim() if (!name) { uni.showToast({ title: '请输入设备名称', icon: 'none' }) return } const result = await locatorStore.saveLocator({ ...selectedDevice.value, name, isPublic: isPublic.value ? 1 : 0 }) if (!result) { return } dialogType.value = '' actionSwiperIndex.value = 0 uni.showToast({ title: '修改成功', icon: 'success' }) } async function confirmDeleteDevice() { if (!selectedDevice.value) { return } const deviceId = selectedDevice.value.id const result = await locatorStore.deleteLocator(deviceId) if (!result) { return } dialogType.value = '' actionSwiperIndex.value = 0 if (isDetailMode.value) { setTimeout(() => { uni.navigateBack() }, 500) return } await locatorStore.fetchLocators() if (!deviceList.value.length) { addMode.value = true inputMode.value = false return } const nextDevice = deviceList.value.find( item => String(item.id) === String(locatorStore.currentLocatorId) ) || deviceList.value[0] if (nextDevice) { await changeDevice(nextDevice) } uni.showToast({ title: '设备已移除', icon: 'success' }) } async function confirmPublicChange() { if (!selectedDevice.value) { return } publicSwitchDisabled.value = true const nextStatus = pendingPublicStatus.value const result = await locatorStore.saveLocator({ ...selectedDevice.value, isPublic: nextStatus ? 1 : 0 }) if (result) { isPublic.value = nextStatus lastPublicStatus.value = nextStatus dialogType.value = '' uni.showToast({ title: '设置成功', icon: 'success' }) } else { isPublic.value = lastPublicStatus.value dialogType.value = '' } refreshPublicSwitch() setTimeout(() => { publicSwitchDisabled.value = false }, 150) } async function refreshPublicSwitch() { await nextTick() publicSwitchKey.value += 1 } function getPublicValue(device = selectedDevice.value) { const value = device?.isPublic return value === true || value === 1 || String(value) === '1' } function syncPublicStatus(device = selectedDevice.value) { const nextStatus = getPublicValue(device) isPublic.value = nextStatus lastPublicStatus.value = nextStatus pendingPublicStatus.value = nextStatus refreshPublicSwitch() } function sharePosition() { if (!hasLocation.value) { uni.showToast({ title: '暂无可分享的位置', icon: 'none' }) return } uni.showToast({ title: '位置已准备分享', icon: 'none' }) } function goAction(item) { if (!selectedDevice.value) { uni.showToast({ title: '请先添加设备', icon: 'none' }) return } activeAction.value = item.key const query = [ `locatorId=${encodeURIComponent(selectedDevice.value.id)}`, `name=${encodeURIComponent(selectedDevice.value.name || '')}`, `number=${encodeURIComponent(selectedDevice.value.number || '')}`, `lat=${encodeURIComponent(currentLat.value)}`, `lng=${encodeURIComponent(currentLng.value)}`, `address=${encodeURIComponent(latestAddress.value)}` ].join('&') uni.navigateTo({ url: `/pages/visualization/ElectronicCarePage/${item.page}?${query}` }) } async function initPage() { if (routeLocatorId.value) { locatorStore.setCurrentLocator({ id: routeLocatorId.value, name: '' }) } await locatorStore.fetchLocators() if (routeLocatorId.value) { const target = deviceList.value.find(item => { return String(item.id) === String(routeLocatorId.value) }) if (target) { locatorStore.setCurrentLocator(target) } } if (locatorStore.currentLocatorId) { await Promise.all([ locatorStore.fetchPlaces(locatorStore.currentLocatorId), locatorStore.fetchDeviceStatus( locatorStore.currentLocatorId ) ]) addMode.value = false inputMode.value = false } else { addMode.value = true inputMode.value = false } if (routeMode.value === 'add') { addMode.value = true inputMode.value = true } syncPublicStatus() } watch( () => selectedDevice.value, value => { if (value) { syncPublicStatus(value) } }, { deep: true } ) onLoad((options = {}) => { routeLocatorId.value = options.locatorId || '' if (options.locatorId || options.mode === 'detail') { routeMode.value = 'detail' } else if (options.mode === 'add') { routeMode.value = 'add' } else { routeMode.value = 'home' } }) onShow(() => { initPage() }) onPullDownRefresh(async () => { try { await initPage() } finally { uni.stopPullDownRefresh() } }) </script> <style lang="scss" scoped> @import '@/assets/styles/global.scss'; page { background: #f5f6f9; } .page-container { width: 100%; min-height: 100vh; background: linear-gradient( 180deg, #fff8ea 0, #faf8f4 300rpx, #f5f6f9 520rpx ); color: #202020; box-sizing: border-box; } .page-header { position: fixed; top: 0; left: 0; right: 0; width: 100%; z-index: 1000; box-sizing: border-box; background: rgba(255, 249, 237, 0.96); backdrop-filter: blur(16rpx); border-bottom: 1rpx solid rgba(0, 0, 0, 0.03); } .header-content { position: relative; width: 100%; display: flex; align-items: center; justify-content: center; box-sizing: border-box; } .header-side { position: absolute; top: 0; bottom: 0; width: 120rpx; display: flex; align-items: center; z-index: 2; } .header-left { left: 24rpx; justify-content: flex-start; } .header-right { right: 30rpx; justify-content: flex-end; } .header-title { max-width: 430rpx; font-size: 34rpx; font-weight: 600; color: #202020; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .header-action { font-size: 28rpx; color: #252525; font-weight: 500; } .page-content { width: 100%; padding-left: 24rpx; padding-right: 24rpx; box-sizing: border-box; } .device-tabs-container { width: 100%; padding-top: 14rpx; box-sizing: border-box; } .device-tabs { width: 100%; height: 72rpx; white-space: nowrap; } .device-tabs-inner { display: inline-flex; align-items: center; min-width: 100%; height: 72rpx; gap: 46rpx; padding: 0 10rpx; box-sizing: border-box; } .device-tab-item { position: relative; flex-shrink: 0; height: 72rpx; display: flex; align-items: center; font-size: 25rpx; color: #565656; transition: color 0.2s ease; } .device-tab-item::after { content: ''; position: absolute; left: 50%; bottom: 4rpx; width: 0; height: 5rpx; border-radius: 6rpx; background: #dda02d; transform: translateX(-50%); transition: width 0.2s ease; } .device-tab-item.active { color: #d89224; font-weight: 600; } .device-tab-item.active::after { width: 42rpx; } .add-device-page { padding-top: 22rpx; } .add-device-card { width: 100%; min-height: 500rpx; background: rgba(237, 237, 237, 0.92); border-radius: 24rpx; display: flex; align-items: center; justify-content: center; box-sizing: border-box; box-shadow: inset 0 1rpx 0 rgba(255, 255, 255, 0.7); } .empty-device, .connect-device { width: 100%; padding: 50rpx; box-sizing: border-box; display: flex; flex-direction: column; align-items: center; } .empty-device-icon { width: 100rpx; height: 100rpx; border-radius: 50%; background: rgba(255, 255, 255, 0.9); display: flex; align-items: center; justify-content: center; margin-bottom: 26rpx; } .empty-device-title, .connect-title { font-size: 32rpx; line-height: 46rpx; font-weight: 600; color: #252525; } .empty-device-desc, .connect-desc { margin-top: 12rpx; font-size: 23rpx; line-height: 36rpx; color: #858585; text-align: center; } .add-device-button, .connect-button { width: 320rpx; height: 78rpx; margin-top: 44rpx; border-radius: 14rpx; background: linear-gradient(135deg, #e6a52c, #dc951d); display: flex; align-items: center; justify-content: center; font-size: 28rpx; color: #241a0a; font-weight: 600; box-shadow: 0 10rpx 22rpx rgba(219, 150, 32, 0.22); } .connect-button.disabled, .remove-device-button.disabled { opacity: 0.55; } .device-input-wrap { width: 440rpx; height: 82rpx; margin-top: 36rpx; background: #ffffff; border-radius: 14rpx; border: 2rpx solid transparent; display: flex; align-items: center; box-sizing: border-box; box-shadow: 0 8rpx 20rpx rgba(0, 0, 0, 0.04); } .device-input-wrap:focus-within { border-color: #dda02d; } .device-input { width: 100%; height: 82rpx; padding: 0 24rpx; box-sizing: border-box; text-align: center; font-size: 28rpx; color: #222222; } .device-placeholder { font-size: 25rpx; color: #b0b0b0; } .cancel-connect { margin-top: 30rpx; font-size: 24rpx; color: #777777; } .product-section { margin-top: 38rpx; } .section-heading { display: flex; align-items: flex-end; justify-content: space-between; margin-bottom: 20rpx; padding: 0 4rpx; } .section-title { font-size: 30rpx; font-weight: 600; color: #242424; } .section-subtitle { font-size: 22rpx; color: #969696; } .product-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20rpx; } .product-card { min-width: 0; background: #ffffff; border-radius: 18rpx; overflow: hidden; box-shadow: 0 10rpx 28rpx rgba(42, 42, 42, 0.06); } .product-cover { height: 184rpx; background: radial-gradient( circle at 50% 44%, #694536 0, #2e211d 44%, #151211 100% ); display: flex; align-items: center; justify-content: center; color: #ffffff; font-size: 27rpx; font-weight: 600; } .product-cover.second { background: radial-gradient( circle at 50% 44%, #62584e 0, #2d2926 44%, #141313 100% ); } .product-info { padding: 18rpx; box-sizing: border-box; } .product-title { display: block; font-size: 26rpx; font-weight: 600; line-height: 38rpx; color: #252525; } .product-desc { display: block; margin-top: 6rpx; font-size: 21rpx; line-height: 32rpx; color: #919191; } .product-bottom { margin-top: 18rpx; display: flex; align-items: center; justify-content: space-between; } .product-price { font-size: 27rpx; color: #d89423; font-weight: 600; } .buy-button { height: 48rpx; padding: 0 18rpx; border-radius: 8rpx; background: #242424; display: flex; align-items: center; justify-content: center; font-size: 20rpx; color: #ffffff; } .device-main-card { width: 100%; margin-top: 20rpx; background: #ffffff; border-radius: 24rpx; overflow: hidden; box-shadow: 0 12rpx 34rpx rgba(32, 32, 32, 0.06); } .map-section { position: relative; width: 100%; height: 350rpx; background: #ebebeb; } .main-map, .map-placeholder { width: 100%; height: 100%; } .map-placeholder { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14rpx; font-size: 24rpx; color: #8f8f8f; background: linear-gradient(135deg, #eeeeee, #e7e7e7); } .map-manage-button { position: absolute; top: 18rpx; right: 18rpx; width: 58rpx; height: 58rpx; border-radius: 12rpx; background: rgba(255, 255, 255, 0.94); display: flex; align-items: center; justify-content: center; box-shadow: 0 6rpx 16rpx rgba(0, 0, 0, 0.1); } .map-manage-image { width: 34rpx; height: 34rpx; } .device-info { padding: 18rpx 22rpx 20rpx; background: #ffffff; box-sizing: border-box; } .device-info-top { display: flex; align-items: flex-start; justify-content: space-between; } .device-info-left { flex: 1; min-width: 0; padding-right: 16rpx; } .device-number { display: block; font-size: 34rpx; line-height: 44rpx; font-weight: 700; color: #171717; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .device-status-row { margin-top: 6rpx; display: flex; align-items: center; gap: 12rpx; } .device-status { height: 32rpx; padding: 0 16rpx; border-radius: 18rpx; background: #a8a8a8; display: inline-flex; align-items: center; font-size: 20rpx; color: #ffffff; } .device-status.online { background: #22b977; } .offline-time { font-size: 24rpx; color: #464646; } .device-signal { width: 190rpx; display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 8rpx; } .battery-box { height: 34rpx; padding: 0 11rpx; border: 2rpx solid #1abd76; border-radius: 4rpx; display: flex; align-items: center; justify-content: center; box-sizing: border-box; font-size: 20rpx; color: #16af6c; } .signal-bars { height: 36rpx; display: flex; align-items: flex-end; gap: 4rpx; } .signal-bar { width: 8rpx; background: #20c87a; border-radius: 4rpx 4rpx 1rpx 1rpx; } .signal-bar-1 { height: 14rpx; } .signal-bar-2 { height: 23rpx; } .signal-bar-3 { height: 33rpx; } .location-mode { width: 100%; margin-top: 2rpx; font-size: 23rpx; line-height: 32rpx; text-align: right; color: #333333; } .location-time { margin-top: 12rpx; display: flex; align-items: center; gap: 8rpx; font-size: 22rpx; line-height: 32rpx; color: #777777; } .location-address { margin-top: 8rpx; display: flex; align-items: flex-start; gap: 9rpx; } .address-text { flex: 1; min-width: 0; font-size: 23rpx; line-height: 36rpx; color: #343434; } .share-position { flex-shrink: 0; height: 36rpx; display: flex; align-items: center; gap: 4rpx; font-size: 22rpx; color: #5c9fe1; } .action-swiper { width: 100%; height: 150rpx; background: #ffffff; border-top: 1rpx solid #f1f1f1; } .action-list { width: 100%; height: 150rpx; padding: 0 20rpx; box-sizing: border-box; display: flex; align-items: center; justify-content: space-between; } .action-item, .manage-item { width: 108rpx; display: flex; flex-direction: column; align-items: center; justify-content: center; } .action-icon { width: 64rpx; height: 64rpx; border-radius: 14rpx; background: #f0f0f0; display: flex; align-items: center; justify-content: center; margin-bottom: 10rpx; transition: background 0.2s ease; } .action-icon.active { background: #dfa12a; } .action-text { font-size: 22rpx; color: #333333; white-space: nowrap; } .manage-list { width: 100%; height: 150rpx; padding-left: 34rpx; box-sizing: border-box; display: flex; align-items: center; gap: 40rpx; } .swiper-dots { height: 26rpx; display: flex; align-items: flex-start; justify-content: center; gap: 10rpx; background: #ffffff; } .swiper-dot { width: 24rpx; height: 6rpx; border-radius: 8rpx; background: #dddddd; transition: all 0.2s ease; } .swiper-dot.active { width: 38rpx; background: #dfa12a; } .device-setting-card { margin-top: 24rpx; padding: 0 24rpx; background: #ffffff; border-radius: 20rpx; box-shadow: 0 10rpx 26rpx rgba(35, 35, 35, 0.05); } .remark-section { min-height: 104rpx; padding: 22rpx 0; box-sizing: border-box; display: flex; flex-direction: column; justify-content: center; } .setting-title { font-size: 28rpx; line-height: 40rpx; font-weight: 600; color: #252525; } .remark-content { margin-top: 6rpx; font-size: 23rpx; line-height: 34rpx; color: #888888; } .setting-divider { width: 100%; height: 1rpx; background: #eeeeee; } .public-setting { min-height: 120rpx; display: flex; align-items: center; justify-content: space-between; } .public-setting-info { flex: 1; min-width: 0; padding-right: 20rpx; display: flex; flex-direction: column; } .setting-description { margin-top: 5rpx; font-size: 21rpx; line-height: 31rpx; color: #9a9a9a; } .remove-device-button { width: 100%; height: 88rpx; margin-top: 24rpx; border-radius: 16rpx; background: linear-gradient(135deg, #e5a52e, #dd991f); display: flex; align-items: center; justify-content: center; box-shadow: 0 10rpx 22rpx rgba(216, 145, 26, 0.17); font-size: 29rpx; color: #241b0c; font-weight: 600; } .safe-bottom { height: calc(80rpx + env(safe-area-inset-bottom)); } /* 弹窗全屏遮罩 */ .dialog-mask { position: fixed; /* 不要只使用 inset: 0。 部分旧版本 App、小程序环境兼容性不好。 */ top: 0; left: 0; right: 0; bottom: 0; width: 100vw; height: 100vh; padding: 32rpx; box-sizing: border-box; z-index: 99999; background: rgba(0, 0, 0, 0.48); display: flex; align-items: center; justify-content: center; transform: translateZ(0); animation: maskFadeIn 0.2s ease; } .dialog-card { position: relative; width: 620rpx; max-width: 92vw; min-height: 300rpx; padding: 46rpx 42rpx 36rpx; box-sizing: border-box; background: #ffffff; border-radius: 24rpx; display: flex; flex-direction: column; align-items: center; box-shadow: 0 28rpx 70rpx rgba(0, 0, 0, 0.2); animation: dialogScaleIn 0.22s ease; } .dialog-close { position: absolute; top: 18rpx; right: 18rpx; width: 54rpx; height: 54rpx; display: flex; align-items: center; justify-content: center; z-index: 2; } .dialog-title { max-width: 440rpx; margin-top: 2rpx; font-size: 31rpx; line-height: 44rpx; font-weight: 600; color: #292929; text-align: center; } .dialog-message { width: 100%; margin-top: 30rpx; font-size: 25rpx; line-height: 42rpx; color: #5f5f5f; text-align: center; } .edit-dialog-content { width: 100%; margin-top: 32rpx; } .dialog-field-label { display: block; margin-bottom: 14rpx; font-size: 24rpx; color: #505050; } .dialog-input { width: 100%; height: 80rpx; padding: 0 22rpx; box-sizing: border-box; border-radius: 14rpx; background: #f2f2f2; font-size: 27rpx; color: #202020; } .dialog-placeholder { font-size: 24rpx; color: #aaaaaa; } .dialog-actions { width: 100%; margin-top: 38rpx; display: flex; gap: 20rpx; } .dialog-button { flex: 1; height: 76rpx; margin: 0; padding: 0; border-radius: 14rpx; display: flex; align-items: center; justify-content: center; font-size: 27rpx; line-height: 1; } .dialog-button::after { border: none; } .dialog-cancel { background: #eeeeee; color: #363636; } .dialog-confirm { background: linear-gradient(135deg, #e6a52d, #dc971e); color: #281e0d; font-weight: 600; } @keyframes maskFadeIn { from { background: rgba(0, 0, 0, 0); } to { background: rgba(0, 0, 0, 0.48); } } @keyframes dialogScaleIn { from { opacity: 0; transform: scale(0.92); } to { opacity: 1; transform: scale(1); } } </style>
需要特别注意,原代码中的:
scss.modal-mask { position: fixed; inset: 0; }
建议改成:
scss.dialog-mask { position: fixed; top: 0; left: 0; right: 0; bottom: 0; width: 100vw; height: 100vh; display: flex; align-items: center; justify-content: center; z-index: 99999; }
另外,弹窗节点不要放进 scroll-view、swiper 或 map 里面。这版已经把弹窗放到了页面最外层,并通过:
vue<map v-if="hasLocation && !dialogVisible">
解决地图原生层级覆盖弹窗的问题。