Files
gmTouringMiniApp/src/pages/assistant/index.vue
T

526 lines
12 KiB
Vue
Raw Normal View History

2026-07-30 16:04:34 +08:00
<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>