Initial commit: gmTouringMiniApp project

This commit is contained in:
周瑞哲
2026-07-30 16:04:34 +08:00
commit ebcae02d35
201 changed files with 49545 additions and 0 deletions
+640
View File
@@ -0,0 +1,640 @@
<script setup lang="ts">
import type { PoiCategory, PoiDatasetMeta, PoiSummary } from '@/domain/poi'
import type { PoiMapMarker } from '@/services/map'
import MapFilterHeader from '@/components/map/MapFilterHeader.vue'
import PoiSummaryCard from '@/components/map/PoiSummaryCard.vue'
import PageState from '@/components/poi/PageState.vue'
import { getPoiRepository } from '@/data/poi'
import { buildMarkerIdMap, createPoiMarkers } from '@/services/map'
import { useLocationStore, useMapStore } from '@/stores'
const MAP_ID = 'guangming-cultural-map'
const mapStore = useMapStore()
const locationStore = useLocationStore()
const pageState = ref<'loading' | 'ready' | 'data_error' | 'dataset_empty'>('loading')
const mapReady = ref(false)
const mapError = ref(false)
const categories = ref<PoiCategory[]>([])
const allPoiSummaries = ref<PoiSummary[]>([])
const datasetMeta = ref<PoiDatasetMeta | null>(null)
const mapContext = shallowRef<ReturnType<typeof uni.createMapContext> | null>(null)
const markerIdToPoiId = shallowRef(new Map<number, string>())
const lastMarkerTapAt = ref(0)
const navigating = ref(false)
let includePointsRequestVersion = 0
let locationListenerAttached = false
let locationUpdatesStarted = false
let locationRequestVersion = 0
const activeCategoryCode = computed(() => mapStore.categoryCode)
const selectedPoiId = computed(() => mapStore.selectedPoiId)
const viewport = computed(() => mapStore.viewport)
const locationReady = computed(() => locationStore.status === 'ready' && Boolean(locationStore.snapshot))
const locationButtonText = computed(() => {
if (locationStore.status === 'locating')
return '定位中…'
if (locationReady.value)
return '回到我的位置'
if (locationStore.status === 'denied')
return '开启位置权限'
return '定位到我'
})
const filteredPois = computed(() => {
if (!activeCategoryCode.value)
return allPoiSummaries.value
return allPoiSummaries.value.filter(poi => poi.categoryCode === activeCategoryCode.value)
})
const selectedPoi = computed(() => {
if (!selectedPoiId.value)
return null
return allPoiSummaries.value.find(poi => poi.id === selectedPoiId.value) ?? null
})
const markers = computed<PoiMapMarker[]>(() => createPoiMarkers(
filteredPois.value,
categories.value,
markerIdToPoiId.value,
selectedPoiId.value,
))
const mapPoints = computed<Array<{ latitude: number, longitude: number }>>(() => filteredPois.value.map(poi => ({
latitude: poi.latitude,
longitude: poi.longitude,
})))
function loadDataset() {
pageState.value = 'loading'
mapError.value = false
try {
const repository = getPoiRepository()
const nextMeta = repository.getDatasetMeta()
const nextCategories = repository.getCategories()
const nextPois = repository.getPoiSummaries()
datasetMeta.value = nextMeta
categories.value = nextCategories
allPoiSummaries.value = nextPois
markerIdToPoiId.value = buildMarkerIdMap(nextPois.map(poi => poi.id))
const untouchedDefaultViewport = mapStore.viewport.longitude === 113.935
&& mapStore.viewport.latitude === 22.748
&& mapStore.viewport.scale === 11
if (untouchedDefaultViewport)
mapStore.resetViewport(nextMeta.defaultViewport)
if (mapStore.categoryCode && !nextCategories.some(category => category.code === mapStore.categoryCode))
mapStore.setCategory(null)
if (mapStore.selectedPoiId && !nextPois.some(poi => poi.id === mapStore.selectedPoiId))
mapStore.selectPoi(null)
pageState.value = nextPois.length === 0 ? 'dataset_empty' : 'ready'
}
catch (error) {
console.error('Failed to load POI dataset', error)
categories.value = []
allPoiSummaries.value = []
markerIdToPoiId.value = new Map()
pageState.value = 'data_error'
}
}
function includeFilteredPoints() {
const requestVersion = ++includePointsRequestVersion
const context = mapContext.value
const points = mapPoints.value.map(point => ({ ...point }))
const bottomPadding = selectedPoi.value ? 360 : 80
if (!mapReady.value || !context || points.length === 0)
return
nextTick(() => {
if (requestVersion !== includePointsRequestVersion
|| !mapReady.value
|| mapContext.value !== context) {
return
}
if (points.length === 1) {
mapStore.updateViewport({
...points[0],
scale: 15,
})
return
}
try {
context.includePoints({
points,
padding: [
uni.upx2px(48),
uni.upx2px(48),
uni.upx2px(bottomPadding),
uni.upx2px(48),
],
fail: (error) => {
handleIncludePointsFailure(requestVersion, context, error)
},
})
}
catch (error) {
handleIncludePointsFailure(requestVersion, context, error)
}
})
}
function handleIncludePointsFailure(
requestVersion: number,
context: ReturnType<typeof uni.createMapContext>,
error: unknown,
) {
if (requestVersion !== includePointsRequestVersion || mapContext.value !== context)
return
console.error('Failed to include POI points', error)
includePointsRequestVersion += 1
mapReady.value = false
mapError.value = true
mapContext.value = null
}
function selectCategory(categoryCode: string | null) {
if (mapStore.categoryCode === categoryCode)
return
mapStore.setCategory(categoryCode)
if (selectedPoi.value && categoryCode && selectedPoi.value.categoryCode !== categoryCode)
mapStore.selectPoi(null)
includeFilteredPoints()
}
function selectMarker(event: { detail: { markerId: number } }) {
lastMarkerTapAt.value = Date.now()
const poiId = markerIdToPoiId.value.get(Number(event.detail.markerId))
if (!poiId)
return
// A marker tap is more recent than any pending category fit operation.
includePointsRequestVersion += 1
mapStore.selectPoi(poiId)
const poi = allPoiSummaries.value.find(item => item.id === poiId)
if (poi) {
mapStore.updateViewport({
longitude: poi.longitude,
latitude: poi.latitude,
scale: Math.max(viewport.value.scale, 13),
})
}
}
function clearSelection() {
if (Date.now() - lastMarkerTapAt.value < 250)
return
mapStore.selectPoi(null)
}
function updateViewport(event: { type: string, detail: Record<string, unknown> }) {
const changeType = String(event.detail.type ?? event.type ?? '')
if (changeType !== 'end')
return
const longitude = Number(event.detail.longitude)
const latitude = Number(event.detail.latitude)
const eventScale = Number(event.detail.scale)
const fallbackScale = Number.isFinite(eventScale) ? eventScale : viewport.value.scale
if (Number.isFinite(longitude) && Number.isFinite(latitude)) {
updateViewportWithScale(longitude, latitude, fallbackScale)
return
}
mapContext.value?.getCenterLocation({
success: (location: { longitude: number, latitude: number }) => {
updateViewportWithScale(location.longitude, location.latitude, fallbackScale)
},
})
}
function updateViewportWithScale(longitude: number, latitude: number, fallbackScale: number) {
const context = mapContext.value as (ReturnType<typeof uni.createMapContext> & {
getScale?: (options: {
success: (result: { scale: number }) => void
fail: () => void
}) => void
}) | null
if (!context?.getScale) {
mapStore.updateViewport({ longitude, latitude, scale: fallbackScale })
return
}
context.getScale({
success: (result) => {
const scale = Number(result.scale)
mapStore.updateViewport({
longitude,
latitude,
scale: Number.isFinite(scale) ? scale : fallbackScale,
})
},
fail: () => {
mapStore.updateViewport({ longitude, latitude, scale: fallbackScale })
},
})
}
function resetToAll() {
mapStore.setCategory(null)
mapStore.selectPoi(null)
const target = datasetMeta.value?.defaultViewport
if (target)
mapStore.resetViewport(target)
includeFilteredPoints()
}
function isPermissionDenied(error: unknown): boolean {
if (!error || typeof error !== 'object')
return false
const message = String((error as { errMsg?: unknown }).errMsg ?? '')
return /auth deny|auth denied|authorize:fail|permission/i.test(message)
}
function locationChangeListener(result: UniNamespace.OnLocationChangeCallbackResult) {
const longitude = Number(result.longitude)
const latitude = Number(result.latitude)
const accuracy = Number(result.accuracy ?? result.horizontalAccuracy)
locationStore.updateLocation(longitude, latitude, accuracy)
}
function attachLocationListener() {
if (locationListenerAttached)
return
uni.onLocationChange(locationChangeListener)
locationListenerAttached = true
}
function detachLocationListener() {
if (!locationListenerAttached)
return
uni.offLocationChange(locationChangeListener)
locationListenerAttached = false
}
function startForegroundLocationUpdates() {
if (locationUpdatesStarted)
return
attachLocationListener()
uni.startLocationUpdate({
type: 'gcj02',
success: () => {
locationUpdatesStarted = true
},
fail: (error) => {
detachLocationListener()
console.error('Failed to start foreground location updates', error)
},
})
}
function stopForegroundLocationUpdates() {
locationRequestVersion += 1
detachLocationListener()
if (locationUpdatesStarted) {
uni.stopLocationUpdate()
locationUpdatesStarted = false
}
}
function centerOnUserLocation() {
const snapshot = locationStore.snapshot
if (!snapshot)
return
includePointsRequestVersion += 1
mapStore.selectPoi(null)
mapStore.updateViewport({
longitude: snapshot.longitude,
latitude: snapshot.latitude,
scale: 15,
})
}
function requestUserLocation() {
if (locationStore.status === 'locating')
return
if (locationStore.status === 'denied') {
uni.openSetting({
success: (settings) => {
if (settings.authSetting['scope.userLocation']) {
locationStore.startLocating()
requestCurrentLocation()
}
},
})
return
}
if (locationReady.value) {
centerOnUserLocation()
startForegroundLocationUpdates()
return
}
locationStore.startLocating()
requestCurrentLocation()
}
function requestCurrentLocation() {
const requestVersion = ++locationRequestVersion
uni.getLocation({
type: 'gcj02',
isHighAccuracy: true,
highAccuracyExpireTime: 5000,
success: (result) => {
if (requestVersion !== locationRequestVersion)
return
const updated = locationStore.updateLocation(
Number(result.longitude),
Number(result.latitude),
Number(result.accuracy ?? result.horizontalAccuracy),
)
if (!updated) {
locationStore.failLocation('error', '定位结果无效,请稍后重试')
return
}
centerOnUserLocation()
startForegroundLocationUpdates()
},
fail: (error) => {
if (requestVersion !== locationRequestVersion)
return
if (isPermissionDenied(error)) {
locationStore.failLocation('denied', '位置权限未开启')
uni.showModal({
title: '需要位置权限',
content: '开启位置权限后,可在地图显示实时方位,并按当前位置优化推荐路线。',
confirmText: '去设置',
success: result => result.confirm && requestUserLocation(),
})
return
}
locationStore.failLocation('unavailable', '暂时无法获取位置')
uni.showToast({ title: '定位失败,请检查系统定位服务', icon: 'none' })
},
})
}
function openDetail(poiId: string) {
if (navigating.value)
return
navigating.value = true
uni.navigateTo({
url: `/pages/poi/detail?poiId=${encodeURIComponent(poiId)}`,
complete: () => {
setTimeout(() => {
navigating.value = false
}, 500)
},
})
}
function initializeMap() {
includePointsRequestVersion += 1
mapReady.value = false
mapError.value = false
nextTick(() => {
try {
mapContext.value = uni.createMapContext(MAP_ID)
mapReady.value = true
includeFilteredPoints()
}
catch (error) {
console.error('Failed to initialize map', error)
mapReady.value = false
mapError.value = true
}
})
}
function handleMapError(event: unknown) {
console.error('Map component error', event)
includePointsRequestVersion += 1
mapReady.value = false
mapError.value = true
mapContext.value = null
}
onLoad(() => {
loadDataset()
})
onReady(() => {
initializeMap()
})
onShow(() => {
if (locationReady.value)
startForegroundLocationUpdates()
})
onHide(stopForegroundLocationUpdates)
onUnload(stopForegroundLocationUpdates)
</script>
<template>
<view class="map-page">
<MapFilterHeader
:categories="categories"
:active-category-code="activeCategoryCode"
:result-count="filteredPois.length"
:coverage-label="datasetMeta?.coverageLabel ?? 'POC 示例数据'"
:loading="pageState === 'loading'"
@select="selectCategory"
/>
<view v-if="pageState === 'loading'" class="map-page__state">
<PageState state="loading" title="正在加载光明区文旅资源…" />
</view>
<view v-else-if="pageState === 'data_error'" class="map-page__state">
<PageState
state="error"
title="点位数据加载失败"
description="暂时无法读取点位信息,请稍后重试"
action-label="重新加载"
@action="loadDataset"
/>
</view>
<view v-else-if="pageState === 'dataset_empty'" class="map-page__state">
<PageState state="empty" title="暂无可展示点位" description="本期审核数据中暂时没有已发布点位" />
</view>
<template v-else>
<view v-if="filteredPois.length === 0" class="map-page__empty-strip">
<text>当前分类暂无点位</text>
<button @click="resetToAll">查看全部</button>
</view>
<view class="map-page__viewport">
<view v-if="mapError" class="map-page__map-error">
<PageState
state="error"
title="地图服务暂不可用"
description="请检查网络后重试地图;位置功能仅在您主动点击定位按钮后启用"
action-label="重试地图"
@action="initializeMap"
/>
</view>
<map
v-else
:id="MAP_ID"
class="map-page__map"
:longitude="viewport.longitude"
:latitude="viewport.latitude"
:scale="viewport.scale"
:markers="markers"
:show-location="locationReady"
:enable-rotate="false"
:enable-overlooking="false"
:show-compass="false"
@markertap="selectMarker"
@tap="clearSelection"
@regionchange="updateViewport"
@error="handleMapError"
>
<cover-view class="map-page__reset" @tap="resetToAll">
回到全域
</cover-view>
<cover-view
class="map-page__locate"
:class="{ 'map-page__locate--active': locationReady }"
@tap="requestUserLocation"
>
{{ locationButtonText }}
</cover-view>
</map>
</view>
<PoiSummaryCard
v-if="selectedPoi"
:poi="selectedPoi"
@close="mapStore.selectPoi(null)"
@detail="openDetail"
/>
<view class="map-page__disclaimer">
{{ datasetMeta?.disclaimer }}
</view>
</template>
</view>
</template>
<route lang="yaml" type="home">
layout: map
style:
navigationBarTitleText: 光明文旅地图
navigationBarBackgroundColor: '#ffffff'
navigationBarTextStyle: black
disableScroll: true
</route>
<style scoped lang="scss">
.map-page {
display: flex;
flex-direction: column;
width: 100%;
height: 100vh;
overflow: hidden;
background: #f5f7f6;
}
.map-page__state,
.map-page__map-error {
display: flex;
flex: 1;
align-items: stretch;
justify-content: stretch;
min-height: 0;
}
.map-page__empty-strip {
display: flex;
flex: none;
align-items: center;
justify-content: space-between;
min-height: 72rpx;
padding: 0 32rpx;
font-size: 24rpx;
color: #934b00;
background: #fff3e0;
}
.map-page__empty-strip button {
width: auto;
padding: 0 24rpx;
margin: 0;
font-size: 24rpx;
font-weight: 600;
line-height: 72rpx;
color: #0e6247;
background: transparent;
border: 0;
}
.map-page__empty-strip button::after {
border: 0;
}
.map-page__viewport {
position: relative;
flex: 1;
min-height: 480rpx;
}
.map-page__map {
width: 100%;
height: 100%;
}
.map-page__reset {
position: absolute;
top: 24rpx;
right: 24rpx;
padding: 14rpx 22rpx;
font-size: 24rpx;
font-weight: 600;
line-height: 36rpx;
color: #0e6247;
background: #fff;
border-radius: 28rpx;
box-shadow: 0 8rpx 24rpx rgb(24 32 29 / 16%);
}
.map-page__locate {
position: absolute;
right: 24rpx;
bottom: 28rpx;
padding: 14rpx 22rpx;
font-size: 24rpx;
font-weight: 600;
line-height: 36rpx;
color: #34473d;
background: #fff;
border-radius: 28rpx;
box-shadow: 0 8rpx 24rpx rgb(24 32 29 / 16%);
}
.map-page__locate--active {
color: #fff;
background: #167a5b;
}
.map-page__disclaimer {
flex: none;
padding: 8rpx 24rpx calc(8rpx + env(safe-area-inset-bottom));
font-size: 20rpx;
line-height: 30rpx;
color: #8a9690;
text-align: center;
background: #f5f7f6;
}
</style>