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

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

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

526 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import type { 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/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 LOCAL GUIDE</text>
<text class="assistant-hero__title">光明文旅助手</text>
</view>
<view class="assistant-hero__status">
<view class="assistant-hero__status-dot" />
POC 可用
</view>
</view>
<view class="assistant-bot" aria-label="文旅助手形象">
<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">POC</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: 文旅助手
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>