forked from zhouruizhe/gmTouringMiniApp
396 lines
9.5 KiB
Vue
396 lines
9.5 KiB
Vue
<script setup lang="ts">
|
||
import type { 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 { 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)
|
||
|
||
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="poi.images" :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>
|