Files
gmTouringMiniApp/src/pages-poi/poi-detail.vue
T
周瑞哲 7ef4094788
AI Code Review / review (pull_request) Successful in 2m11s
fix(map): 收敛 regionchange 回写回路,修复选中标点后地图抽动
选中标点后地图在左右/上下反复抽动,根因是视口回写形成了自激回路:
map 的 longitude/latitude/scale 绑定到 store,而 regionchange 又无条件把
地图当前中心点写回 store —— 写 store 触发地图移动,移动结束回传一个
「差一点点」的中心点,再写回,再移动。上一个提交把中心点改成从
detail.centerLocation 同步读取后,回路里原本靠 getCenterLocation 异步跳变
掩盖住的这一点噪声就直接闭合了。

两道闸:
- 只接受手势造成的变化。causedBy 为 update(我们自己改绑定值或调
  includePoints 触发)时 store 已是权威值,不回写。老基础库拿不到
  causedBy 时退化到下一道闸。
- 所有回写走 commitViewport,经 isSignificantViewportChange 过滤掉小于
  1e-4 度 / 0.01 级的变化。远小于任何一次真实拖动,足以吸收量化噪声。

顺带修一个 H5 构建回归(1bcdd40 引入):uni 的 H5 路由生成器按路径推导
组件标识,pages/poi/detail 与 pages-poi/detail 都归一成 PagesPoiDetail,
重复声明导致 build:h5 失败。兼容跳板必须留在旧路径上,所以改名分包路由
pages-poi/detail -> pages-poi/poi-detail。
2026-08-03 11:08:39 +08:00

415 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import type { PoiImageAsset, PoiResolved } from '@/domain/poi'
import PageState from '@/components/poi/PageState.vue'
import PoiHeroGallery from '@/components/poi/PoiHeroGallery.vue'
import PoiTagList from '@/components/poi/PoiTagList.vue'
import { getPoiRepository } from '@/data/poi'
import { coverPhotoFullUrlFor } from '@/data/poi/photos'
import { formatRecommendationLabel } from '@/domain/poi'
import { getCheckInProfile } from '@/services/check-in'
const state = ref<'loading' | 'ready' | 'invalid' | 'error'>('loading')
const poi = ref<PoiResolved | null>(null)
const hasCheckedIn = ref(false)
const checkInStorageError = ref(false)
/**
* hero 用分包大图(800×600)替换主包缩略图(400×300)。
*
* 本页在 pages-poi 分包内,能引用分包 static 资源;主包页面不行。
* 占位图仍指向主包,不做替换。
*/
const heroImages = computed<PoiImageAsset[]>(() => {
const current = poi.value
if (!current)
return []
return current.images.map(image => (
image.id === current.coverImageId && !image.isPlaceholder
? { ...image, url: coverPhotoFullUrlFor(current.id) }
: image
))
})
function loadPoi(poiId: string) {
state.value = 'loading'
poi.value = null
try {
if (!poiId.trim()) {
state.value = 'invalid'
return
}
const result = getPoiRepository().getPoiById(poiId)
if (!result) {
state.value = 'invalid'
return
}
poi.value = result
try {
hasCheckedIn.value = getCheckInProfile().checkIns.some(record => record.poiId === result.id)
checkInStorageError.value = false
}
catch (error) {
console.error('Failed to load POI check-in state', error)
hasCheckedIn.value = false
checkInStorageError.value = true
}
state.value = 'ready'
}
catch (error) {
console.error('Failed to load POI detail', error)
state.value = 'error'
}
}
function openCheckIn() {
if (!poi.value)
return
uni.navigateTo({ url: `/pages/check-in/index?poiId=${encodeURIComponent(poi.value.id)}` })
}
function openCheckInRecords() {
uni.navigateTo({ url: '/pages/check-in/records' })
}
function returnToMap() {
uni.navigateBack({
fail: () => {
uni.reLaunch({ url: '/pages/map/index' })
},
})
}
function formatCoordinate(value: number): string {
return value.toFixed(6)
}
function formatUpdatedAt(value: string): string {
const date = new Date(value)
if (Number.isNaN(date.getTime()))
return '待确认'
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
onLoad((query) => {
const rawPoiId = typeof query?.poiId === 'string' ? query.poiId : ''
try {
loadPoi(decodeURIComponent(rawPoiId))
}
catch {
state.value = 'invalid'
}
})
onShow(() => {
if (!poi.value)
return
try {
hasCheckedIn.value = getCheckInProfile().checkIns.some(record => record.poiId === poi.value?.id)
checkInStorageError.value = false
}
catch (error) {
console.error('Failed to refresh POI check-in state', error)
hasCheckedIn.value = false
checkInStorageError.value = true
}
})
</script>
<template>
<view class="poi-detail-page">
<PageState v-if="state === 'loading'" state="loading" title="正在加载点位信息…" />
<PageState
v-else-if="state === 'invalid'"
state="empty"
title="点位不存在或已下线"
description="该点位可能已更新,请返回地图重新选择"
action-label="返回地图"
@action="returnToMap"
/>
<PageState
v-else-if="state === 'error'"
state="error"
title="点位信息加载失败"
description="暂时无法读取该点位,请稍后返回重试"
action-label="返回地图"
@action="returnToMap"
/>
<template v-else-if="poi">
<PoiHeroGallery :images="heroImages" :name="poi.name" />
<view class="poi-detail-page__content">
<view class="poi-detail-page__header">
<text class="poi-detail-page__category">{{ poi.category.name }}</text>
<text class="poi-detail-page__name">{{ poi.name }}</text>
<view class="poi-detail-page__recommendation">
{{ formatRecommendationLabel(poi.recommendationSource, poi.recommendationIndex) }}
</view>
<PoiTagList class="poi-detail-page__tags" :tags="poi.tags" />
</view>
<view class="check-in-entry" :class="{ 'check-in-entry--done': hasCheckedIn }">
<view class="check-in-entry__mark">{{ hasCheckedIn ? '✓' : '◎' }}</view>
<view class="check-in-entry__copy">
<text class="check-in-entry__title">{{ checkInStorageError ? '打卡数据需要处理' : hasCheckedIn ? '已留下光明足迹' : '到点打卡' }}</text>
<text>{{ checkInStorageError ? '本机打卡数据异常,请先处理' : hasCheckedIn ? '该点位已在本机完成打卡' : '到达点位附近后,使用本次真实定位打卡' }}</text>
</view>
<button @click="checkInStorageError ? openCheckInRecords() : openCheckIn()">{{ checkInStorageError ? '去处理' : hasCheckedIn ? '查看' : '去打卡' }}</button>
</view>
<view class="info-card">
<view class="info-row">
<text class="info-row__label">开放时间</text>
<text class="info-row__value">{{ poi.openingHoursText }}</text>
</view>
<view class="info-row">
<text class="info-row__label">地址</text>
<text class="info-row__value">{{ poi.address }}</text>
</view>
<view class="info-row info-row--coordinates">
<text class="info-row__label">经纬度</text>
<view class="info-row__value info-row__coordinates">
<text>经度 {{ formatCoordinate(poi.longitude) }}</text>
<text>纬度 {{ formatCoordinate(poi.latitude) }}</text>
<text class="info-row__coordinate-system">坐标系 {{ poi.coordinateSystem }}</text>
</view>
</view>
<view class="info-row">
<text class="info-row__label">数据更新</text>
<text class="info-row__value">{{ formatUpdatedAt(poi.updatedAt) }}</text>
</view>
</view>
<view class="description-section">
<text class="description-section__title">点位简介</text>
<text class="description-section__body">{{ poi.description }}</text>
</view>
<view class="poc-notice">
<text class="poc-notice__title">POC 数据说明</text>
<text class="poc-notice__body">当前内容用于产品和技术可行性验证正式开放信息请以后续审核版本为准</text>
</view>
</view>
</template>
</view>
</template>
<route lang="yaml">
layout: map
style:
navigationBarTitleText: 点位详情
navigationBarBackgroundColor: '#ffffff'
navigationBarTextStyle: black
backgroundColor: '#f5f7f6'
</route>
<style scoped lang="scss">
.poi-detail-page {
min-height: 100vh;
background: #f5f7f6;
}
.poi-detail-page__content {
padding: 32rpx 32rpx calc(32rpx + env(safe-area-inset-bottom));
}
.poi-detail-page__header {
display: flex;
flex-direction: column;
}
.poi-detail-page__category {
align-self: flex-start;
padding: 8rpx 18rpx;
font-size: 24rpx;
font-weight: 600;
line-height: 34rpx;
color: #0e6247;
background: #e8f4ee;
border-radius: 24rpx;
}
.poi-detail-page__name {
margin-top: 16rpx;
font-size: 40rpx;
font-weight: 700;
line-height: 56rpx;
color: #18201d;
}
.poi-detail-page__recommendation {
align-self: flex-start;
padding: 8rpx 16rpx;
margin-top: 16rpx;
font-size: 26rpx;
font-weight: 600;
line-height: 38rpx;
color: #934b00;
background: #fff3e0;
border-radius: 10rpx;
}
.poi-detail-page__tags {
margin-top: 18rpx;
}
.info-card,
.check-in-entry,
.description-section,
.poc-notice {
padding: 28rpx;
margin-top: 24rpx;
background: #fff;
border: 1rpx solid #dde5e1;
border-radius: 20rpx;
}
.check-in-entry {
display: flex;
gap: 18rpx;
align-items: center;
padding: 24rpx;
}
.check-in-entry--done {
background: #e8f4ee;
border-color: #cfe4d9;
}
.check-in-entry__mark {
display: flex;
flex: none;
align-items: center;
justify-content: center;
width: 62rpx;
height: 62rpx;
font-size: 28rpx;
font-weight: 800;
color: #fff;
background: #167a5b;
border-radius: 50%;
}
.check-in-entry__copy {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
font-size: 21rpx;
line-height: 32rpx;
color: #5e6b65;
}
.check-in-entry__title {
margin-bottom: 3rpx;
font-size: 27rpx;
font-weight: 750;
color: #18201d;
}
.check-in-entry button {
flex: none;
width: auto;
height: 60rpx;
padding: 0 22rpx;
margin: 0;
font-size: 23rpx;
font-weight: 700;
line-height: 60rpx;
color: #fff;
background: #167a5b;
border: 0;
border-radius: 30rpx;
}
.check-in-entry button::after {
border: 0;
}
.info-row {
display: flex;
gap: 24rpx;
align-items: flex-start;
padding: 20rpx 0;
border-bottom: 1rpx solid #edf1ef;
}
.info-row:first-child {
padding-top: 0;
}
.info-row:last-child {
padding-bottom: 0;
border-bottom: 0;
}
.info-row__label {
flex: none;
width: 128rpx;
font-size: 26rpx;
line-height: 40rpx;
color: #5e6b65;
}
.info-row__value {
flex: 1;
font-size: 28rpx;
line-height: 40rpx;
color: #18201d;
}
.info-row__coordinates {
display: flex;
flex-direction: column;
min-width: 0;
}
.info-row__coordinate-system {
margin-top: 6rpx;
font-size: 24rpx;
color: #8a9690;
}
.description-section__title,
.poc-notice__title {
display: block;
font-size: 30rpx;
font-weight: 700;
line-height: 42rpx;
color: #18201d;
}
.description-section__body,
.poc-notice__body {
display: block;
margin-top: 16rpx;
font-size: 28rpx;
line-height: 44rpx;
color: #425049;
white-space: pre-wrap;
}
.poc-notice {
background: #e8f4ee;
border-color: #cfe4d9;
}
.poc-notice__title {
font-size: 26rpx;
color: #0e6247;
}
.poc-notice__body {
font-size: 24rpx;
line-height: 38rpx;
color: #3d5a4d;
}
</style>