forked from zhouruizhe/gmTouringMiniApp
Initial commit: gmTouringMiniApp project
This commit is contained in:
@@ -0,0 +1,525 @@
|
||||
<script setup lang="ts">
|
||||
import type { PoiResolved } from '@/domain/poi'
|
||||
import TravelPoiCard from '@/components/travel/TravelPoiCard.vue'
|
||||
import { getPoiRepository } from '@/data/poi'
|
||||
import { loadPlan } from '@/services/travel-assistant'
|
||||
import { useMapStore } from '@/stores'
|
||||
|
||||
interface AssistantPrompt {
|
||||
title: string
|
||||
description: string
|
||||
themes: string[]
|
||||
interests: string[]
|
||||
extra: string
|
||||
}
|
||||
|
||||
const mapStore = useMapStore()
|
||||
const featuredPois = ref<PoiResolved[]>([])
|
||||
const hasSavedPlan = ref(false)
|
||||
|
||||
const prompts: AssistantPrompt[] = [
|
||||
{
|
||||
title: '亲子半日轻松游',
|
||||
description: '公园、田园与科普点位,控制步行强度',
|
||||
themes: ['亲子'],
|
||||
interests: ['自然风光', '生态科普'],
|
||||
extra: '安排适合亲子的轻松半日路线',
|
||||
},
|
||||
{
|
||||
title: '雨天室内研学',
|
||||
description: '优先文化场馆与科普体验,避开户外点位',
|
||||
themes: ['研学'],
|
||||
interests: ['文化场馆', '生态科普'],
|
||||
extra: '下雨,只安排室内项目',
|
||||
},
|
||||
{
|
||||
title: '城市摄影漫游',
|
||||
description: '串联绿道、公园和人文地标',
|
||||
themes: ['朋友'],
|
||||
interests: ['摄影', '自然风光'],
|
||||
extra: '优先推荐适合拍照和慢慢散步的地点',
|
||||
},
|
||||
]
|
||||
|
||||
function loadAssistantHome() {
|
||||
const repository = getPoiRepository()
|
||||
featuredPois.value = repository.getPublishedPois()
|
||||
.slice()
|
||||
.sort((left, right) => right.recommendationIndex - left.recommendationIndex || left.name.localeCompare(right.name, 'zh-CN'))
|
||||
.slice(0, 3)
|
||||
hasSavedPlan.value = Boolean(loadPlan())
|
||||
}
|
||||
|
||||
function openPlanner(prompt?: AssistantPrompt) {
|
||||
if (prompt) {
|
||||
uni.setStorageSync('guangming:planner-preset', {
|
||||
themes: prompt.themes,
|
||||
interests: prompt.interests,
|
||||
extraRequirements: prompt.extra,
|
||||
})
|
||||
}
|
||||
uni.switchTab({ url: '/pages/planner/index' })
|
||||
}
|
||||
|
||||
function openItinerary() {
|
||||
uni.navigateTo({ url: '/pages/itinerary/index' })
|
||||
}
|
||||
|
||||
function openDetail(poiId: string) {
|
||||
uni.navigateTo({ url: `/pages/poi/detail?poiId=${encodeURIComponent(poiId)}` })
|
||||
}
|
||||
|
||||
function focusOnMap(poiId: string) {
|
||||
const poi = getPoiRepository().getPoiById(poiId)
|
||||
if (!poi)
|
||||
return
|
||||
|
||||
mapStore.setCategory(null)
|
||||
mapStore.selectPoi(poi.id)
|
||||
mapStore.updateViewport({ longitude: poi.longitude, latitude: poi.latitude, scale: 14 })
|
||||
uni.switchTab({ url: '/pages/map/index' })
|
||||
}
|
||||
|
||||
onShow(loadAssistantHome)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="assistant-page">
|
||||
<view class="assistant-hero">
|
||||
<view class="assistant-hero__glow assistant-hero__glow--one" />
|
||||
<view class="assistant-hero__glow assistant-hero__glow--two" />
|
||||
<view class="assistant-hero__top">
|
||||
<view>
|
||||
<text class="assistant-hero__eyebrow">GUANGMING AI GUIDE</text>
|
||||
<text class="assistant-hero__title">光明文旅 AI 助手</text>
|
||||
</view>
|
||||
<view class="assistant-hero__status">
|
||||
<view class="assistant-hero__status-dot" />
|
||||
POC 可用
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="assistant-bot" aria-label="AI 助手形象">
|
||||
<view class="assistant-bot__antenna" />
|
||||
<view class="assistant-bot__head">
|
||||
<view class="assistant-bot__face">
|
||||
<view class="assistant-bot__eye" />
|
||||
<view class="assistant-bot__mouth" />
|
||||
<view class="assistant-bot__eye" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="assistant-bot__body">AI</view>
|
||||
</view>
|
||||
|
||||
<view class="assistant-hero__speech">
|
||||
<text class="assistant-hero__hello">你好,我是你的光明向导</text>
|
||||
<text class="assistant-hero__copy">告诉我同行人、兴趣和节奏,我会基于已审核的本地点位编排一条可展示、可调整的游览顺序。</text>
|
||||
</view>
|
||||
|
||||
<button class="assistant-hero__action" @click="openPlanner()">
|
||||
<text>定制我的光明路线</text>
|
||||
<text>→</text>
|
||||
</button>
|
||||
<button v-if="hasSavedPlan" class="assistant-hero__secondary" @click="openItinerary">
|
||||
继续查看上次路线
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view class="assistant-content">
|
||||
<view class="assistant-section">
|
||||
<view class="assistant-section__heading">
|
||||
<view>
|
||||
<text class="assistant-section__kicker">QUICK START</text>
|
||||
<text class="assistant-section__title">试试这样问</text>
|
||||
</view>
|
||||
<text class="assistant-section__count">03</text>
|
||||
</view>
|
||||
<view class="prompt-list">
|
||||
<button v-for="(prompt, index) in prompts" :key="prompt.title" class="prompt-card" @click="openPlanner(prompt)">
|
||||
<text class="prompt-card__index">0{{ index + 1 }}</text>
|
||||
<view class="prompt-card__copy">
|
||||
<text class="prompt-card__title">{{ prompt.title }}</text>
|
||||
<text class="prompt-card__description">{{ prompt.description }}</text>
|
||||
</view>
|
||||
<text class="prompt-card__arrow">›</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="assistant-section">
|
||||
<view class="assistant-section__heading">
|
||||
<view>
|
||||
<text class="assistant-section__kicker">CURATED PLACES</text>
|
||||
<text class="assistant-section__title">从这些点位开始</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="featured-list">
|
||||
<TravelPoiCard
|
||||
v-for="poi in featuredPois"
|
||||
:key="poi.id"
|
||||
:poi="poi"
|
||||
@detail="openDetail"
|
||||
@map="focusOnMap"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="assistant-notice">
|
||||
<text class="assistant-notice__title">POC 能力边界</text>
|
||||
<text>未配置在线 AI 服务时,助手会使用本地 POI 和确定性规则生成路线;当前路线为推荐游览顺序,不提供实时路况或逐段导航。</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<route lang="yaml">
|
||||
layout: map
|
||||
style:
|
||||
navigationBarTitleText: AI 文旅助手
|
||||
navigationBarBackgroundColor: '#ffffff'
|
||||
navigationBarTextStyle: black
|
||||
backgroundColor: '#f4f8f6'
|
||||
</route>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.assistant-page {
|
||||
min-height: 100vh;
|
||||
color: #17221d;
|
||||
background: #f4f8f6;
|
||||
}
|
||||
|
||||
.assistant-hero {
|
||||
position: relative;
|
||||
padding: 48rpx 36rpx 54rpx;
|
||||
overflow: hidden;
|
||||
color: #fff;
|
||||
background: linear-gradient(145deg, #123d35 0%, #08724d 62%, #07573d 100%);
|
||||
}
|
||||
|
||||
.assistant-hero__glow {
|
||||
position: absolute;
|
||||
border: 1rpx solid rgb(220 255 234 / 22%);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.assistant-hero__glow--one {
|
||||
top: -180rpx;
|
||||
right: -200rpx;
|
||||
width: 620rpx;
|
||||
height: 620rpx;
|
||||
}
|
||||
|
||||
.assistant-hero__glow--two {
|
||||
top: 170rpx;
|
||||
right: 38rpx;
|
||||
width: 180rpx;
|
||||
height: 180rpx;
|
||||
background: rgb(184 245 207 / 12%);
|
||||
}
|
||||
|
||||
.assistant-hero__top {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.assistant-hero__eyebrow,
|
||||
.assistant-section__kicker {
|
||||
display: block;
|
||||
font-size: 18rpx;
|
||||
line-height: 28rpx;
|
||||
color: #9fe0bd;
|
||||
letter-spacing: 4rpx;
|
||||
}
|
||||
|
||||
.assistant-hero__title {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
font-size: 44rpx;
|
||||
font-weight: 800;
|
||||
line-height: 60rpx;
|
||||
}
|
||||
|
||||
.assistant-hero__status {
|
||||
display: flex;
|
||||
flex: none;
|
||||
gap: 9rpx;
|
||||
align-items: center;
|
||||
padding: 10rpx 15rpx;
|
||||
font-size: 18rpx;
|
||||
color: #dff9e9;
|
||||
background: rgb(255 255 255 / 11%);
|
||||
border: 1rpx solid rgb(255 255 255 / 24%);
|
||||
border-radius: 999rpx;
|
||||
}
|
||||
|
||||
.assistant-hero__status-dot {
|
||||
width: 12rpx;
|
||||
height: 12rpx;
|
||||
background: #8df0b3;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 5rpx rgb(141 240 179 / 12%);
|
||||
}
|
||||
|
||||
.assistant-bot {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: 150rpx;
|
||||
height: 175rpx;
|
||||
margin: 42rpx auto 0;
|
||||
}
|
||||
|
||||
.assistant-bot__antenna {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 72rpx;
|
||||
width: 6rpx;
|
||||
height: 32rpx;
|
||||
background: #9fe0bd;
|
||||
border-radius: 999rpx;
|
||||
}
|
||||
|
||||
.assistant-bot__antenna::before {
|
||||
position: absolute;
|
||||
top: -8rpx;
|
||||
left: -7rpx;
|
||||
width: 20rpx;
|
||||
height: 20rpx;
|
||||
content: "";
|
||||
background: #dff9e9;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.assistant-bot__head {
|
||||
position: absolute;
|
||||
top: 28rpx;
|
||||
left: 12rpx;
|
||||
box-sizing: border-box;
|
||||
width: 126rpx;
|
||||
height: 96rpx;
|
||||
padding: 16rpx;
|
||||
background: #f1fbf6;
|
||||
border: 4rpx solid #9fe0bd;
|
||||
border-radius: 44rpx;
|
||||
box-shadow: 0 12rpx 28rpx rgb(0 44 30 / 18%);
|
||||
}
|
||||
|
||||
.assistant-bot__face {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #092f2b;
|
||||
border-radius: 26rpx;
|
||||
}
|
||||
|
||||
.assistant-bot__eye {
|
||||
width: 13rpx;
|
||||
height: 29rpx;
|
||||
background: #20e2b7;
|
||||
border-radius: 999rpx;
|
||||
box-shadow: 0 0 10rpx rgb(32 226 183 / 60%);
|
||||
}
|
||||
|
||||
.assistant-bot__mouth {
|
||||
width: 20rpx;
|
||||
height: 5rpx;
|
||||
margin-top: 18rpx;
|
||||
background: #20e2b7;
|
||||
border-radius: 999rpx;
|
||||
}
|
||||
|
||||
.assistant-bot__body {
|
||||
position: absolute;
|
||||
top: 122rpx;
|
||||
left: 40rpx;
|
||||
width: 70rpx;
|
||||
height: 48rpx;
|
||||
font-size: 17rpx;
|
||||
font-weight: 800;
|
||||
line-height: 48rpx;
|
||||
color: #087a4b;
|
||||
text-align: center;
|
||||
background: #e5f7ee;
|
||||
border: 4rpx solid #9fe0bd;
|
||||
border-radius: 26rpx 26rpx 12rpx 12rpx;
|
||||
}
|
||||
|
||||
.assistant-hero__speech {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 28rpx;
|
||||
margin-top: 24rpx;
|
||||
background: rgb(255 255 255 / 11%);
|
||||
border: 1rpx solid rgb(255 255 255 / 22%);
|
||||
border-radius: 24rpx;
|
||||
}
|
||||
|
||||
.assistant-hero__hello {
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
line-height: 42rpx;
|
||||
}
|
||||
|
||||
.assistant-hero__copy {
|
||||
margin-top: 12rpx;
|
||||
font-size: 23rpx;
|
||||
line-height: 38rpx;
|
||||
color: rgb(239 255 246 / 76%);
|
||||
}
|
||||
|
||||
.assistant-hero__action,
|
||||
.assistant-hero__secondary {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: 100%;
|
||||
height: 92rpx;
|
||||
margin: 28rpx 0 0;
|
||||
font-size: 27rpx;
|
||||
font-weight: 700;
|
||||
line-height: 92rpx;
|
||||
border: 0;
|
||||
border-radius: 20rpx;
|
||||
}
|
||||
|
||||
.assistant-hero__action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 30rpx;
|
||||
color: #0d5038;
|
||||
background: #c9f1da;
|
||||
box-shadow: 0 14rpx 32rpx rgb(0 42 28 / 20%);
|
||||
}
|
||||
|
||||
.assistant-hero__secondary {
|
||||
height: 72rpx;
|
||||
margin-top: 16rpx;
|
||||
line-height: 72rpx;
|
||||
color: #e9fff2;
|
||||
background: transparent;
|
||||
border: 1rpx solid rgb(255 255 255 / 30%);
|
||||
}
|
||||
|
||||
.assistant-hero__action::after,
|
||||
.assistant-hero__secondary::after,
|
||||
.prompt-card::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.assistant-content {
|
||||
padding: 8rpx 28rpx calc(48rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.assistant-section {
|
||||
margin-top: 48rpx;
|
||||
}
|
||||
|
||||
.assistant-section__heading {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.assistant-section__kicker {
|
||||
color: #16805c;
|
||||
}
|
||||
|
||||
.assistant-section__title {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
font-size: 36rpx;
|
||||
font-weight: 800;
|
||||
line-height: 50rpx;
|
||||
}
|
||||
|
||||
.assistant-section__count {
|
||||
font-family: Georgia, serif;
|
||||
font-size: 48rpx;
|
||||
font-style: italic;
|
||||
color: rgb(23 56 47 / 18%);
|
||||
}
|
||||
|
||||
.prompt-list,
|
||||
.featured-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18rpx;
|
||||
}
|
||||
|
||||
.prompt-card {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 132rpx;
|
||||
padding: 22rpx 24rpx;
|
||||
margin: 0;
|
||||
text-align: left;
|
||||
background: #fff;
|
||||
border: 1rpx solid #dce6e1;
|
||||
border-radius: 20rpx;
|
||||
box-shadow: 0 10rpx 28rpx rgb(20 78 53 / 5%);
|
||||
}
|
||||
|
||||
.prompt-card__index {
|
||||
flex: none;
|
||||
font-family: Georgia, serif;
|
||||
font-size: 27rpx;
|
||||
color: #16805c;
|
||||
}
|
||||
|
||||
.prompt-card__copy {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.prompt-card__title {
|
||||
font-size: 27rpx;
|
||||
font-weight: 700;
|
||||
line-height: 38rpx;
|
||||
color: #18201d;
|
||||
}
|
||||
|
||||
.prompt-card__description {
|
||||
margin-top: 5rpx;
|
||||
font-size: 21rpx;
|
||||
line-height: 32rpx;
|
||||
color: #718078;
|
||||
}
|
||||
|
||||
.prompt-card__arrow {
|
||||
flex: none;
|
||||
font-size: 42rpx;
|
||||
color: #16805c;
|
||||
}
|
||||
|
||||
.assistant-notice {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 26rpx;
|
||||
margin-top: 46rpx;
|
||||
font-size: 21rpx;
|
||||
line-height: 34rpx;
|
||||
color: #637069;
|
||||
background: #eef6f2;
|
||||
border: 1rpx solid #d6e7df;
|
||||
border-radius: 20rpx;
|
||||
}
|
||||
|
||||
.assistant-notice__title {
|
||||
margin-bottom: 8rpx;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
color: #0e6247;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,967 @@
|
||||
<script setup lang="ts">
|
||||
import type { PoiResolved } from '@/domain/poi'
|
||||
import type { ItineraryItem, PlanResponse, TravelPreferences } from '@/domain/travel'
|
||||
import TravelPoiCard from '@/components/travel/TravelPoiCard.vue'
|
||||
import { getPoiRepository } from '@/data/poi'
|
||||
import { adjustPlan, loadPlan, loadPreferences, savePlan } from '@/services/travel-assistant'
|
||||
import { useMapStore } from '@/stores'
|
||||
|
||||
interface ResolvedItineraryItem {
|
||||
item: ItineraryItem
|
||||
poi: PoiResolved
|
||||
}
|
||||
|
||||
interface RouteMarker {
|
||||
id: number
|
||||
latitude: number
|
||||
longitude: number
|
||||
iconPath: string
|
||||
width: number
|
||||
height: number
|
||||
callout: {
|
||||
content: string
|
||||
color: string
|
||||
fontSize: number
|
||||
borderRadius: number
|
||||
bgColor: string
|
||||
padding: number
|
||||
display: 'ALWAYS'
|
||||
textAlign: 'center'
|
||||
}
|
||||
anchor: { x: number, y: number }
|
||||
}
|
||||
|
||||
type DurationFitStatus = PlanResponse['itinerary']['durationFitStatus']
|
||||
|
||||
interface DurationFitNotice {
|
||||
title: string
|
||||
content: string
|
||||
}
|
||||
|
||||
const MAP_ID = 'guangming-itinerary-map'
|
||||
const mapStore = useMapStore()
|
||||
const plan = ref<PlanResponse | null>(null)
|
||||
const preferences = ref<TravelPreferences | null>(null)
|
||||
const message = ref('')
|
||||
const adjusting = ref(false)
|
||||
const mapReady = ref(false)
|
||||
const mapError = ref(false)
|
||||
const mapContext = shallowRef<ReturnType<typeof uni.createMapContext> | null>(null)
|
||||
const quickPrompts = ['节奏慢一点', '换成室内场馆', '增加亲子体验']
|
||||
|
||||
const resolvedItems = computed<ResolvedItineraryItem[]>(() => {
|
||||
if (!plan.value)
|
||||
return []
|
||||
const repository = getPoiRepository()
|
||||
return plan.value.itinerary.items.flatMap((item) => {
|
||||
const poi = repository.getPoiById(item.placeId)
|
||||
return poi ? [{ item, poi }] : []
|
||||
})
|
||||
})
|
||||
|
||||
const routeMarkers = computed<RouteMarker[]>(() => resolvedItems.value.map(({ poi }, index) => ({
|
||||
id: index + 1,
|
||||
latitude: poi.latitude,
|
||||
longitude: poi.longitude,
|
||||
iconPath: '/static/markers/default-selected.png',
|
||||
width: uni.upx2px(64),
|
||||
height: uni.upx2px(76),
|
||||
anchor: { x: 0.5, y: 1 },
|
||||
callout: {
|
||||
content: `${index + 1} · ${poi.name}`,
|
||||
color: '#18201d',
|
||||
fontSize: 11,
|
||||
borderRadius: 7,
|
||||
bgColor: '#ffffff',
|
||||
padding: 5,
|
||||
display: 'ALWAYS',
|
||||
textAlign: 'center',
|
||||
},
|
||||
})))
|
||||
|
||||
const routePolyline = computed(() => [{
|
||||
points: resolvedItems.value.map(({ poi }) => ({
|
||||
latitude: poi.latitude,
|
||||
longitude: poi.longitude,
|
||||
})),
|
||||
color: '#167a5bcc',
|
||||
width: 6,
|
||||
dottedLine: false,
|
||||
arrowLine: true,
|
||||
borderColor: '#ffffffcc',
|
||||
borderWidth: 2,
|
||||
}])
|
||||
|
||||
const mapCenter = computed(() => {
|
||||
const first = resolvedItems.value[0]?.poi
|
||||
return {
|
||||
longitude: first?.longitude ?? 113.9275,
|
||||
latitude: first?.latitude ?? 22.76,
|
||||
}
|
||||
})
|
||||
|
||||
const routeTags = computed(() => {
|
||||
const current = preferences.value
|
||||
if (!current)
|
||||
return ['光明文旅', 'POC 路线']
|
||||
const duration = current.duration === 'half_day' ? '半日游' : '一日游'
|
||||
const pace = { relaxed: '轻松', moderate: '适中', compact: '紧凑' }[current.pace]
|
||||
return [duration, current.themes[0], pace, current.interests[0]].filter((tag): tag is string => Boolean(tag))
|
||||
})
|
||||
|
||||
const planningModeLabel = computed(() => plan.value?.itinerary.planningMode === 'custom' ? '自选规划' : '快速规划')
|
||||
const durationFitNotice = computed(() => plan.value ? getDurationFitNotice(plan.value.itinerary.durationFitStatus) : null)
|
||||
|
||||
function loadStoredPlan() {
|
||||
plan.value = loadPlan()
|
||||
preferences.value = loadPreferences()
|
||||
}
|
||||
|
||||
function formatMinutes(minutes: number): string {
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const rest = minutes % 60
|
||||
if (!hours)
|
||||
return `${rest} 分钟`
|
||||
return rest ? `${hours} 小时 ${rest} 分` : `${hours} 小时`
|
||||
}
|
||||
|
||||
function formatItemTime(item: ItineraryItem, index: number): string {
|
||||
const startsFromCurrentLocation = index === 0
|
||||
&& plan.value?.itinerary.startsFromCurrentLocation
|
||||
&& item.transferFromPreviousMinutes !== null
|
||||
const transfer = startsFromCurrentLocation
|
||||
? ` · 从当前位置出发约${item.transferFromPreviousMinutes}分钟`
|
||||
: index > 0
|
||||
? item.transferFromPreviousMinutes === null
|
||||
? ' · 交通待确认'
|
||||
: ` · 转场约 ${item.transferFromPreviousMinutes} 分钟`
|
||||
: ''
|
||||
return `${item.startTime}–${item.endTime}${transfer}`
|
||||
}
|
||||
|
||||
function getDurationFitNotice(status: DurationFitStatus): DurationFitNotice | null {
|
||||
if (status === 'overflow') {
|
||||
return {
|
||||
title: '行程可能超出所选时长',
|
||||
content: '当前安排较为充实,建议调整点位、节奏或出行时间后再出发。',
|
||||
}
|
||||
}
|
||||
if (status === 'underfilled') {
|
||||
return {
|
||||
title: '行程安排较为宽松',
|
||||
content: '当前安排少于所选时长,可继续添加点位或保留机动时间。',
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function showDurationFitNotice(status: DurationFitStatus): Promise<void> {
|
||||
const notice = getDurationFitNotice(status)
|
||||
if (!notice)
|
||||
return Promise.resolve()
|
||||
|
||||
return new Promise((resolve) => {
|
||||
uni.showModal({
|
||||
...notice,
|
||||
showCancel: false,
|
||||
confirmText: '我知道了',
|
||||
complete: () => resolve(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
const timestamp = Date.parse(value)
|
||||
if (!Number.isFinite(timestamp))
|
||||
return '待确认'
|
||||
const date = new Date(timestamp)
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function fitRoutePoints() {
|
||||
if (!mapReady.value || !mapContext.value || resolvedItems.value.length === 0)
|
||||
return
|
||||
|
||||
const context = mapContext.value
|
||||
nextTick(() => {
|
||||
if (!mapReady.value || mapContext.value !== context)
|
||||
return
|
||||
try {
|
||||
context.includePoints({
|
||||
points: resolvedItems.value.map(({ poi }) => ({ latitude: poi.latitude, longitude: poi.longitude })),
|
||||
padding: [uni.upx2px(70), uni.upx2px(70), uni.upx2px(70), uni.upx2px(70)],
|
||||
fail: handleMapError,
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
handleMapError(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function initializeMap() {
|
||||
mapError.value = false
|
||||
try {
|
||||
mapContext.value = uni.createMapContext(MAP_ID)
|
||||
mapReady.value = true
|
||||
fitRoutePoints()
|
||||
}
|
||||
catch (error) {
|
||||
handleMapError(error)
|
||||
}
|
||||
}
|
||||
|
||||
function handleMapError(error: unknown) {
|
||||
console.error('Failed to render itinerary map', error)
|
||||
mapReady.value = false
|
||||
mapError.value = true
|
||||
mapContext.value = null
|
||||
}
|
||||
|
||||
function openPlanner() {
|
||||
uni.switchTab({ url: '/pages/planner/index' })
|
||||
}
|
||||
|
||||
function openDetail(poiId: string) {
|
||||
uni.navigateTo({ url: `/pages/poi/detail?poiId=${encodeURIComponent(poiId)}` })
|
||||
}
|
||||
|
||||
function focusOnMap(poiId: string) {
|
||||
const poi = getPoiRepository().getPoiById(poiId)
|
||||
if (!poi)
|
||||
return
|
||||
mapStore.setCategory(null)
|
||||
mapStore.selectPoi(poi.id)
|
||||
mapStore.updateViewport({ longitude: poi.longitude, latitude: poi.latitude, scale: 14 })
|
||||
uni.switchTab({ url: '/pages/map/index' })
|
||||
}
|
||||
|
||||
function focusFirstOnMap() {
|
||||
const poi = resolvedItems.value[0]?.poi
|
||||
if (poi)
|
||||
focusOnMap(poi.id)
|
||||
}
|
||||
|
||||
function saveRoute() {
|
||||
if (!plan.value)
|
||||
return
|
||||
try {
|
||||
savePlan(plan.value)
|
||||
uni.showToast({ title: '已保存到本机', icon: 'success' })
|
||||
}
|
||||
catch (error) {
|
||||
uni.showModal({ title: '保存失败', content: error instanceof Error ? error.message : '请稍后重试', showCancel: false })
|
||||
}
|
||||
}
|
||||
|
||||
async function sendAdjustment(quick?: string) {
|
||||
if (!plan.value || adjusting.value)
|
||||
return
|
||||
const content = (quick ?? message.value).trim()
|
||||
if (!content)
|
||||
return
|
||||
|
||||
adjusting.value = true
|
||||
try {
|
||||
const response = await adjustPlan(plan.value.conversationId, content)
|
||||
savePlan(response)
|
||||
plan.value = response
|
||||
preferences.value = loadPreferences()
|
||||
message.value = ''
|
||||
await showDurationFitNotice(response.itinerary.durationFitStatus)
|
||||
fitRoutePoints()
|
||||
uni.pageScrollTo({ scrollTop: 0, duration: 300 })
|
||||
}
|
||||
catch (error) {
|
||||
uni.showModal({
|
||||
title: '调整未完成',
|
||||
content: error instanceof Error ? error.message : '上一次路线已为你保留',
|
||||
showCancel: false,
|
||||
})
|
||||
}
|
||||
finally {
|
||||
adjusting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad(loadStoredPlan)
|
||||
onReady(initializeMap)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view v-if="plan" class="itinerary-page">
|
||||
<view class="route-hero">
|
||||
<view class="route-hero__top">
|
||||
<view class="route-hero__label">
|
||||
<text class="route-hero__spark">✦</text>
|
||||
{{ planningModeLabel }}
|
||||
</view>
|
||||
<button @click="openPlanner">重新规划</button>
|
||||
</view>
|
||||
<text class="route-hero__title">{{ plan.itinerary.title }}</text>
|
||||
<view class="route-hero__tags">
|
||||
<text v-for="tag in routeTags" :key="tag">{{ tag }}</text>
|
||||
</view>
|
||||
<view class="route-hero__meta">
|
||||
<view><text>用户选择时长</text><text>{{ formatMinutes(plan.itinerary.requestedMinutes) }}</text></view>
|
||||
<view><text>预计总时长</text><text>{{ formatMinutes(plan.itinerary.totalMinutes) }}</text></view>
|
||||
<view><text>行程点位</text><text>{{ resolvedItems.length }} 个</text></view>
|
||||
</view>
|
||||
<text class="route-hero__summary">{{ plan.itinerary.summary }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="plan.changeSummary" class="change-notice">
|
||||
<text>已更新</text>{{ plan.changeSummary }}
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-if="durationFitNotice"
|
||||
class="duration-fit-notice"
|
||||
:class="`duration-fit-notice--${plan.itinerary.durationFitStatus}`"
|
||||
>
|
||||
<text class="duration-fit-notice__title">{{ durationFitNotice.title }}</text>
|
||||
<text class="duration-fit-notice__content">{{ durationFitNotice.content }}</text>
|
||||
</view>
|
||||
|
||||
<view class="itinerary-content">
|
||||
<view class="route-map-card">
|
||||
<view class="route-map-card__heading">
|
||||
<view>
|
||||
<text class="route-map-card__title">路线点位分布</text>
|
||||
<text class="route-map-card__description">按推荐顺序直线连接,并非实际道路导航</text>
|
||||
</view>
|
||||
<button @click="fitRoutePoints">查看全程</button>
|
||||
</view>
|
||||
<view v-if="mapError" class="route-map-card__error">
|
||||
<text>地图暂时无法显示</text>
|
||||
<button @click="initializeMap">重试地图</button>
|
||||
</view>
|
||||
<map
|
||||
v-else
|
||||
:id="MAP_ID"
|
||||
class="route-map-card__map"
|
||||
:longitude="mapCenter.longitude"
|
||||
:latitude="mapCenter.latitude"
|
||||
:scale="11"
|
||||
:markers="routeMarkers"
|
||||
:polyline="routePolyline"
|
||||
:enable-rotate="false"
|
||||
:enable-overlooking="false"
|
||||
:show-compass="false"
|
||||
@error="handleMapError"
|
||||
/>
|
||||
<view class="route-map-card__notice">推荐顺序 · 非实时导航</view>
|
||||
</view>
|
||||
|
||||
<view class="route-section">
|
||||
<view class="route-section__heading">
|
||||
<view>
|
||||
<text class="route-section__kicker">ITINERARY</text>
|
||||
<text class="route-section__title">行程安排</text>
|
||||
</view>
|
||||
<text class="route-section__count">{{ String(resolvedItems.length).padStart(2, '0') }}</text>
|
||||
</view>
|
||||
<view class="route-list">
|
||||
<TravelPoiCard
|
||||
v-for="({ item, poi }, index) in resolvedItems"
|
||||
:key="item.placeId"
|
||||
:poi="poi"
|
||||
:index="index + 1"
|
||||
:time-text="formatItemTime(item, index)"
|
||||
:activity="item.activity"
|
||||
:reason="item.reason"
|
||||
@detail="openDetail"
|
||||
@map="focusOnMap"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="cost-card">
|
||||
<view class="cost-card__icon">¥</view>
|
||||
<view><text>费用提示</text><text>{{ plan.itinerary.estimatedCostText }}</text></view>
|
||||
</view>
|
||||
|
||||
<view class="adjust-card">
|
||||
<text class="route-section__kicker">ROUTE ADJUSTMENT</text>
|
||||
<text class="adjust-card__title">还想怎么调整?</text>
|
||||
<view class="quick-list">
|
||||
<button v-for="prompt in quickPrompts" :key="prompt" :disabled="adjusting" @click="sendAdjustment(prompt)">
|
||||
<text>{{ prompt }}</text><text>›</text>
|
||||
</button>
|
||||
</view>
|
||||
<view class="composer">
|
||||
<input
|
||||
v-model="message"
|
||||
:disabled="adjusting"
|
||||
maxlength="500"
|
||||
placeholder="例如:第二个地点换成室内项目"
|
||||
placeholder-class="composer__placeholder"
|
||||
confirm-type="send"
|
||||
@confirm="sendAdjustment()"
|
||||
>
|
||||
<button :disabled="adjusting || !message.trim()" @click="sendAdjustment()">{{ adjusting ? '调整中' : '发送' }}</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="details-card">
|
||||
<view class="details-card__section">
|
||||
<text class="route-section__kicker">出行提醒</text>
|
||||
<text v-for="note in plan.itinerary.notes" :key="note" class="details-card__note">· {{ note }}</text>
|
||||
</view>
|
||||
<view class="details-card__section">
|
||||
<text class="route-section__kicker">资料来源</text>
|
||||
<view v-for="source in plan.itinerary.sources" :key="source.placeId" class="source-row">
|
||||
<text>{{ source.sourceName }}</text>
|
||||
<text>{{ formatDate(source.updatedAt) }} 更新</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="bottom-actions">
|
||||
<button class="bottom-actions__save" @click="saveRoute">保存路线</button>
|
||||
<button class="bottom-actions__map" @click="focusFirstOnMap">在全域地图查看</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="empty-route">
|
||||
<view class="empty-route__icon">⌁</view>
|
||||
<text class="empty-route__title">还没有生成路线</text>
|
||||
<text class="empty-route__copy">先选择出行主题、时长和兴趣偏好,助手会基于本地点位为你安排。</text>
|
||||
<button @click="openPlanner">开始规划</button>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<route lang="yaml">
|
||||
layout: map
|
||||
style:
|
||||
navigationBarTitleText: 推荐路线
|
||||
navigationBarBackgroundColor: '#ffffff'
|
||||
navigationBarTextStyle: black
|
||||
backgroundColor: '#f4f8f6'
|
||||
</route>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.itinerary-page {
|
||||
min-height: 100vh;
|
||||
padding-bottom: calc(140rpx + env(safe-area-inset-bottom));
|
||||
color: #17221d;
|
||||
background: #f4f8f6;
|
||||
}
|
||||
|
||||
.route-hero {
|
||||
padding: 34rpx 32rpx 38rpx;
|
||||
color: #fff;
|
||||
background: linear-gradient(145deg, #123d35, #08724d);
|
||||
}
|
||||
|
||||
.route-hero__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.route-hero__label {
|
||||
display: flex;
|
||||
gap: 8rpx;
|
||||
align-items: center;
|
||||
font-size: 21rpx;
|
||||
font-weight: 700;
|
||||
color: #b9f0d0;
|
||||
}
|
||||
|
||||
.route-hero__spark {
|
||||
font-size: 30rpx;
|
||||
}
|
||||
|
||||
.route-hero__top button {
|
||||
width: auto;
|
||||
height: 58rpx;
|
||||
padding: 0 20rpx;
|
||||
margin: 0;
|
||||
font-size: 20rpx;
|
||||
line-height: 58rpx;
|
||||
color: #eafff2;
|
||||
background: rgb(255 255 255 / 11%);
|
||||
border: 1rpx solid rgb(255 255 255 / 24%);
|
||||
border-radius: 29rpx;
|
||||
}
|
||||
|
||||
.route-hero__top button::after,
|
||||
.route-map-card button::after,
|
||||
.quick-list button::after,
|
||||
.composer button::after,
|
||||
.bottom-actions button::after,
|
||||
.empty-route button::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.route-hero__title {
|
||||
display: block;
|
||||
margin-top: 24rpx;
|
||||
font-size: 42rpx;
|
||||
font-weight: 800;
|
||||
line-height: 58rpx;
|
||||
}
|
||||
|
||||
.route-hero__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10rpx;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.route-hero__tags text {
|
||||
padding: 7rpx 13rpx;
|
||||
font-size: 18rpx;
|
||||
line-height: 28rpx;
|
||||
color: #eafff2;
|
||||
background: rgb(255 255 255 / 10%);
|
||||
border: 1rpx solid rgb(255 255 255 / 22%);
|
||||
border-radius: 999rpx;
|
||||
}
|
||||
|
||||
.route-hero__meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 20rpx;
|
||||
margin-top: 25rpx;
|
||||
}
|
||||
|
||||
.route-hero__meta view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.route-hero__meta text:first-child {
|
||||
font-size: 18rpx;
|
||||
color: rgb(238 255 245 / 65%);
|
||||
}
|
||||
|
||||
.route-hero__meta text:last-child {
|
||||
margin-top: 5rpx;
|
||||
font-size: 29rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.route-hero__summary {
|
||||
display: block;
|
||||
margin-top: 22rpx;
|
||||
font-size: 21rpx;
|
||||
line-height: 35rpx;
|
||||
color: rgb(238 255 245 / 75%);
|
||||
}
|
||||
|
||||
.change-notice {
|
||||
padding: 18rpx 28rpx;
|
||||
font-size: 21rpx;
|
||||
line-height: 32rpx;
|
||||
color: #486656;
|
||||
background: #e4f5ec;
|
||||
border-bottom: 1rpx solid #cce6d8;
|
||||
}
|
||||
|
||||
.change-notice text {
|
||||
padding: 4rpx 9rpx;
|
||||
margin-right: 10rpx;
|
||||
font-size: 17rpx;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
background: #16805c;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.duration-fit-notice {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6rpx;
|
||||
padding: 20rpx 28rpx;
|
||||
color: #78551f;
|
||||
background: #fff8e8;
|
||||
border-bottom: 1rpx solid #f0dfb8;
|
||||
}
|
||||
|
||||
.duration-fit-notice--overflow {
|
||||
color: #8a432f;
|
||||
background: #fff1ec;
|
||||
border-bottom-color: #f1d1c6;
|
||||
}
|
||||
|
||||
.duration-fit-notice__title {
|
||||
font-size: 22rpx;
|
||||
font-weight: 800;
|
||||
line-height: 32rpx;
|
||||
}
|
||||
|
||||
.duration-fit-notice__content {
|
||||
font-size: 19rpx;
|
||||
line-height: 30rpx;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.itinerary-content {
|
||||
padding: 24rpx 20rpx 48rpx;
|
||||
}
|
||||
|
||||
.route-map-card,
|
||||
.adjust-card,
|
||||
.details-card {
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
border: 1rpx solid #d9e7e0;
|
||||
border-radius: 24rpx;
|
||||
box-shadow: 0 12rpx 34rpx rgb(20 78 53 / 6%);
|
||||
}
|
||||
|
||||
.route-map-card__heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 22rpx 24rpx;
|
||||
}
|
||||
|
||||
.route-map-card__heading > view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.route-map-card__title {
|
||||
font-size: 27rpx;
|
||||
font-weight: 800;
|
||||
line-height: 38rpx;
|
||||
}
|
||||
|
||||
.route-map-card__description {
|
||||
margin-top: 3rpx;
|
||||
font-size: 17rpx;
|
||||
line-height: 28rpx;
|
||||
color: #85928b;
|
||||
}
|
||||
|
||||
.route-map-card__heading button,
|
||||
.route-map-card__error button {
|
||||
width: auto;
|
||||
height: 54rpx;
|
||||
padding: 0 17rpx;
|
||||
margin: 0;
|
||||
font-size: 19rpx;
|
||||
line-height: 54rpx;
|
||||
color: #167a5b;
|
||||
background: #eaf5f0;
|
||||
border: 0;
|
||||
border-radius: 27rpx;
|
||||
}
|
||||
|
||||
.route-map-card__map {
|
||||
width: 100%;
|
||||
height: 430rpx;
|
||||
}
|
||||
|
||||
.route-map-card__error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 430rpx;
|
||||
font-size: 24rpx;
|
||||
color: #637069;
|
||||
background: #eef3f0;
|
||||
}
|
||||
|
||||
.route-map-card__notice {
|
||||
padding: 12rpx 20rpx;
|
||||
font-size: 18rpx;
|
||||
line-height: 28rpx;
|
||||
color: #718078;
|
||||
text-align: center;
|
||||
background: #f8faf9;
|
||||
}
|
||||
|
||||
.route-section {
|
||||
margin-top: 44rpx;
|
||||
}
|
||||
|
||||
.route-section__heading {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 22rpx;
|
||||
}
|
||||
|
||||
.route-section__kicker {
|
||||
display: block;
|
||||
font-size: 17rpx;
|
||||
font-weight: 700;
|
||||
line-height: 27rpx;
|
||||
color: #16805c;
|
||||
letter-spacing: 2rpx;
|
||||
}
|
||||
|
||||
.route-section__title {
|
||||
display: block;
|
||||
margin-top: 6rpx;
|
||||
font-size: 34rpx;
|
||||
font-weight: 800;
|
||||
line-height: 48rpx;
|
||||
}
|
||||
|
||||
.route-section__count {
|
||||
font-family: Georgia, serif;
|
||||
font-size: 50rpx;
|
||||
font-style: italic;
|
||||
color: rgb(23 56 47 / 18%);
|
||||
}
|
||||
|
||||
.route-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18rpx;
|
||||
}
|
||||
|
||||
.cost-card {
|
||||
display: flex;
|
||||
gap: 18rpx;
|
||||
padding: 24rpx;
|
||||
margin-top: 22rpx;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #0a965b, #077647);
|
||||
border-radius: 20rpx;
|
||||
box-shadow: 0 12rpx 28rpx rgb(5 118 69 / 17%);
|
||||
}
|
||||
|
||||
.cost-card__icon {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 52rpx;
|
||||
height: 52rpx;
|
||||
font-size: 23rpx;
|
||||
border: 1rpx solid rgb(255 255 255 / 50%);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.cost-card > view:last-child {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7rpx;
|
||||
font-size: 18rpx;
|
||||
line-height: 30rpx;
|
||||
}
|
||||
|
||||
.cost-card > view:last-child text:first-child {
|
||||
font-size: 22rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.adjust-card,
|
||||
.details-card {
|
||||
padding: 26rpx;
|
||||
margin-top: 22rpx;
|
||||
}
|
||||
|
||||
.adjust-card__title {
|
||||
display: block;
|
||||
margin-top: 7rpx;
|
||||
font-size: 30rpx;
|
||||
font-weight: 800;
|
||||
line-height: 42rpx;
|
||||
}
|
||||
|
||||
.quick-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 18rpx;
|
||||
overflow: hidden;
|
||||
border: 1rpx solid #e1eae5;
|
||||
border-radius: 14rpx;
|
||||
}
|
||||
|
||||
.quick-list button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
height: 76rpx;
|
||||
padding: 0 19rpx;
|
||||
margin: 0;
|
||||
font-size: 21rpx;
|
||||
line-height: 76rpx;
|
||||
text-align: left;
|
||||
background: #fff;
|
||||
border: 0;
|
||||
border-bottom: 1rpx solid #e8eeeb;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.quick-list button:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.quick-list button text:last-child {
|
||||
font-size: 30rpx;
|
||||
color: #078e55;
|
||||
}
|
||||
|
||||
.composer {
|
||||
display: flex;
|
||||
margin-top: 17rpx;
|
||||
overflow: hidden;
|
||||
border: 1rpx solid #dce7e1;
|
||||
border-radius: 14rpx;
|
||||
}
|
||||
|
||||
.composer input {
|
||||
flex: 1;
|
||||
height: 82rpx;
|
||||
padding: 0 18rpx;
|
||||
font-size: 20rpx;
|
||||
}
|
||||
|
||||
.composer__placeholder {
|
||||
color: #a5afaa;
|
||||
}
|
||||
|
||||
.composer button {
|
||||
width: 120rpx;
|
||||
height: 82rpx;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-size: 20rpx;
|
||||
line-height: 82rpx;
|
||||
color: #fff;
|
||||
background: #078e55;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.composer button[disabled],
|
||||
.quick-list button[disabled] {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.details-card__section + .details-card__section {
|
||||
padding-top: 24rpx;
|
||||
margin-top: 25rpx;
|
||||
border-top: 1rpx solid #e5ece8;
|
||||
}
|
||||
|
||||
.details-card__note {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
font-size: 19rpx;
|
||||
line-height: 31rpx;
|
||||
color: #68766f;
|
||||
}
|
||||
|
||||
.source-row {
|
||||
display: flex;
|
||||
gap: 18rpx;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16rpx 0;
|
||||
font-size: 17rpx;
|
||||
line-height: 27rpx;
|
||||
color: #34473d;
|
||||
border-bottom: 1rpx solid #e8eeeb;
|
||||
}
|
||||
|
||||
.source-row text:first-child {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.source-row text:last-child {
|
||||
flex: none;
|
||||
color: #919b96;
|
||||
}
|
||||
|
||||
.bottom-actions {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
padding: 18rpx 22rpx calc(18rpx + env(safe-area-inset-bottom));
|
||||
background: rgb(255 255 255 / 96%);
|
||||
border-top: 1rpx solid #dfe8e3;
|
||||
box-shadow: 0 -10rpx 30rpx rgb(26 70 49 / 7%);
|
||||
}
|
||||
|
||||
.bottom-actions button {
|
||||
height: 82rpx;
|
||||
margin: 0;
|
||||
font-size: 22rpx;
|
||||
font-weight: 800;
|
||||
line-height: 82rpx;
|
||||
border-radius: 18rpx;
|
||||
}
|
||||
|
||||
.bottom-actions__save {
|
||||
width: 220rpx;
|
||||
color: #087d4d;
|
||||
background: #fff;
|
||||
border: 1rpx solid #11915b;
|
||||
}
|
||||
|
||||
.bottom-actions__map {
|
||||
flex: 1;
|
||||
color: #fff;
|
||||
background: linear-gradient(100deg, #079c5c, #067d4a);
|
||||
border: 0;
|
||||
box-shadow: 0 10rpx 22rpx rgb(3 129 75 / 20%);
|
||||
}
|
||||
|
||||
.empty-route {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 48rpx;
|
||||
text-align: center;
|
||||
background: #f4f8f6;
|
||||
}
|
||||
|
||||
.empty-route__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 104rpx;
|
||||
height: 104rpx;
|
||||
font-size: 58rpx;
|
||||
color: #078e55;
|
||||
background: #e4f5ec;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.empty-route__title {
|
||||
margin-top: 26rpx;
|
||||
font-size: 31rpx;
|
||||
font-weight: 800;
|
||||
line-height: 44rpx;
|
||||
}
|
||||
|
||||
.empty-route__copy {
|
||||
max-width: 560rpx;
|
||||
margin-top: 12rpx;
|
||||
font-size: 21rpx;
|
||||
line-height: 34rpx;
|
||||
color: #718078;
|
||||
}
|
||||
|
||||
.empty-route button {
|
||||
width: 300rpx;
|
||||
height: 82rpx;
|
||||
margin-top: 28rpx;
|
||||
font-size: 23rpx;
|
||||
font-weight: 700;
|
||||
line-height: 82rpx;
|
||||
color: #fff;
|
||||
background: #078e55;
|
||||
border: 0;
|
||||
border-radius: 18rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,287 @@
|
||||
<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'
|
||||
|
||||
const state = ref<'loading' | 'ready' | 'invalid' | 'error'>('loading')
|
||||
const poi = ref<PoiResolved | null>(null)
|
||||
|
||||
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
|
||||
state.value = 'ready'
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to load POI detail', error)
|
||||
state.value = 'error'
|
||||
}
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
})
|
||||
</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="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,
|
||||
.description-section,
|
||||
.poc-notice {
|
||||
padding: 28rpx;
|
||||
margin-top: 24rpx;
|
||||
background: #fff;
|
||||
border: 1rpx solid #dde5e1;
|
||||
border-radius: 20rpx;
|
||||
}
|
||||
|
||||
.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>
|
||||
Reference in New Issue
Block a user