forked from zhouruizhe/gmTouringMiniApp
Add check-in feature and update travel planner UI
This commit is contained in:
@@ -90,8 +90,8 @@ onShow(loadAssistantHome)
|
||||
<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>
|
||||
<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" />
|
||||
@@ -99,7 +99,7 @@ onShow(loadAssistantHome)
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="assistant-bot" aria-label="AI 助手形象">
|
||||
<view class="assistant-bot" aria-label="文旅助手形象">
|
||||
<view class="assistant-bot__antenna" />
|
||||
<view class="assistant-bot__head">
|
||||
<view class="assistant-bot__face">
|
||||
@@ -108,7 +108,7 @@ onShow(loadAssistantHome)
|
||||
<view class="assistant-bot__eye" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="assistant-bot__body">AI</view>
|
||||
<view class="assistant-bot__body">POC</view>
|
||||
</view>
|
||||
|
||||
<view class="assistant-hero__speech">
|
||||
@@ -166,7 +166,7 @@ onShow(loadAssistantHome)
|
||||
|
||||
<view class="assistant-notice">
|
||||
<text class="assistant-notice__title">POC 能力边界</text>
|
||||
<text>未配置在线 AI 服务时,助手会使用本地 POI 和确定性规则生成路线;当前路线为推荐游览顺序,不提供实时路况或逐段导航。</text>
|
||||
<text>本期不调用 AI,助手使用本地 POI 和确定性规则生成路线;当前路线为推荐游览顺序,不提供实时路况或逐段导航。</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -175,7 +175,7 @@ onShow(loadAssistantHome)
|
||||
<route lang="yaml">
|
||||
layout: map
|
||||
style:
|
||||
navigationBarTitleText: AI 文旅助手
|
||||
navigationBarTitleText: 文旅助手
|
||||
navigationBarBackgroundColor: '#ffffff'
|
||||
navigationBarTextStyle: black
|
||||
backgroundColor: '#f4f8f6'
|
||||
|
||||
@@ -0,0 +1,593 @@
|
||||
<script setup lang="ts">
|
||||
import type { CheckInBadge, CheckInBadgeDefinition, CheckInProfile, CheckInResult } from '@/domain/check-in'
|
||||
import type { PoiResolved } from '@/domain/poi'
|
||||
import PageState from '@/components/poi/PageState.vue'
|
||||
import PoiImage from '@/components/poi/PoiImage.vue'
|
||||
import { getCheckInTask, getCheckInTasks } from '@/data/check-in'
|
||||
import { getPoiRepository } from '@/data/poi'
|
||||
import {
|
||||
CHECK_IN_BADGES,
|
||||
CHECK_IN_POINTS,
|
||||
CheckInError,
|
||||
} from '@/domain/check-in'
|
||||
import { checkInAtPoi, getCheckInProfile } from '@/services/check-in'
|
||||
import { getCurrentGcj02Location, isLocationPermissionDenied } from '@/services/location'
|
||||
|
||||
const pageState = ref<'loading' | 'ready' | 'invalid' | 'error'>('loading')
|
||||
const poi = shallowRef<PoiResolved | null>(null)
|
||||
const profile = ref<CheckInProfile>({ points: 0, badges: [], checkIns: [] })
|
||||
const locating = ref(false)
|
||||
const requestVersion = ref(0)
|
||||
const routePoiId = ref('')
|
||||
const privacyAuthorizationRequired = ref(false)
|
||||
|
||||
const checkedRecord = computed(() => poi.value
|
||||
? profile.value.checkIns.find(record => record.poiId === poi.value?.id) ?? null
|
||||
: null)
|
||||
const progressText = computed(() => {
|
||||
const availablePoiIds = new Set(getCheckInTasks().map(task => task.poiId))
|
||||
const completedCount = profile.value.checkIns.filter(record => availablePoiIds.has(record.poiId)).length
|
||||
return `${completedCount} / ${availablePoiIds.size}`
|
||||
})
|
||||
const checkInRadius = computed(() => poi.value ? getCheckInTask(poi.value.id)?.radiusMeters ?? 0 : 0)
|
||||
|
||||
function loadPage(poiId: string) {
|
||||
pageState.value = 'loading'
|
||||
try {
|
||||
const result = getPoiRepository().getPoiById(poiId)
|
||||
if (!result || !getCheckInTask(result.id)) {
|
||||
pageState.value = 'invalid'
|
||||
return
|
||||
}
|
||||
poi.value = result
|
||||
profile.value = getCheckInProfile()
|
||||
pageState.value = 'ready'
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to load check-in page', error)
|
||||
pageState.value = 'error'
|
||||
}
|
||||
}
|
||||
|
||||
function returnToPoi() {
|
||||
uni.navigateBack({
|
||||
fail: () => uni.reLaunch({ url: '/pages/map/index' }),
|
||||
})
|
||||
}
|
||||
|
||||
function openDataRecovery() {
|
||||
uni.navigateTo({ url: '/pages/check-in/records' })
|
||||
}
|
||||
|
||||
function openRecords() {
|
||||
cancelPendingLocation()
|
||||
uni.navigateTo({ url: '/pages/check-in/records' })
|
||||
}
|
||||
|
||||
function cancelPendingLocation() {
|
||||
if (!locating.value)
|
||||
return
|
||||
requestVersion.value += 1
|
||||
locating.value = false
|
||||
}
|
||||
|
||||
function formatTime(value: string): string {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime()))
|
||||
return '时间待确认'
|
||||
const pad = (number: number) => String(number).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
}
|
||||
|
||||
function badgeDefinition(badge: CheckInBadge): CheckInBadgeDefinition | null {
|
||||
return CHECK_IN_BADGES.find(definition => definition.code === badge.code) ?? null
|
||||
}
|
||||
|
||||
function checkInSuccessContent(result: CheckInResult): string {
|
||||
const badges = result.newBadges
|
||||
.map(badgeDefinition)
|
||||
.filter((badge): badge is CheckInBadgeDefinition => Boolean(badge))
|
||||
return badges.length > 0
|
||||
? `已记录本次到访并获得 ${CHECK_IN_POINTS} 积分,同时解锁“${badges.map(badge => badge.name).join('、')}”。`
|
||||
: `已记录本次到访并获得 ${CHECK_IN_POINTS} 积分。`
|
||||
}
|
||||
|
||||
function showLocationPermissionGuide() {
|
||||
uni.showModal({
|
||||
title: '需要位置权限',
|
||||
content: '打卡必须使用本次真实定位判断是否到达点位附近。授权仅在你点击打卡时触发。',
|
||||
confirmText: '去设置',
|
||||
success: result => result.confirm && uni.openSetting({}),
|
||||
})
|
||||
}
|
||||
|
||||
function privacyAuthorizationSupported(): boolean {
|
||||
try {
|
||||
return typeof uni.requirePrivacyAuthorize === 'function'
|
||||
&& (typeof uni.canIUse !== 'function' || uni.canIUse('requirePrivacyAuthorize'))
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function requirePrivacyAuthorization(): Promise<void> {
|
||||
if (!privacyAuthorizationSupported())
|
||||
return Promise.resolve()
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.requirePrivacyAuthorize({
|
||||
success: () => resolve(),
|
||||
fail: error => reject(error),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function isPrivacyAuthorizationDenied(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object')
|
||||
return false
|
||||
const message = String((error as { errMsg?: unknown }).errMsg ?? '')
|
||||
return /privacy/i.test(message)
|
||||
}
|
||||
|
||||
function acceptPrivacyAuthorization() {
|
||||
privacyAuthorizationRequired.value = false
|
||||
requestCheckIn()
|
||||
}
|
||||
|
||||
async function requestCheckIn() {
|
||||
if (!poi.value || locating.value || checkedRecord.value)
|
||||
return
|
||||
|
||||
const currentRequest = ++requestVersion.value
|
||||
locating.value = true
|
||||
try {
|
||||
await requirePrivacyAuthorization()
|
||||
if (currentRequest !== requestVersion.value)
|
||||
return
|
||||
const location = await getCurrentGcj02Location({ highAccuracyExpireTime: 10000, timeoutMs: 15000 })
|
||||
if (currentRequest !== requestVersion.value)
|
||||
return
|
||||
const result = checkInAtPoi(poi.value.id, location)
|
||||
profile.value = result.profile
|
||||
uni.showModal({
|
||||
title: '打卡成功',
|
||||
content: checkInSuccessContent(result),
|
||||
showCancel: false,
|
||||
confirmText: '查看记录',
|
||||
success: modalResult => modalResult.confirm && openRecords(),
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
if (currentRequest !== requestVersion.value)
|
||||
return
|
||||
if (isPrivacyAuthorizationDenied(error)) {
|
||||
privacyAuthorizationRequired.value = true
|
||||
return
|
||||
}
|
||||
if (isLocationPermissionDenied(error)) {
|
||||
showLocationPermissionGuide()
|
||||
return
|
||||
}
|
||||
const message = error instanceof Error ? error.message : '暂时无法完成打卡,请稍后重试'
|
||||
uni.showModal({
|
||||
title: error instanceof CheckInError && error.code === 'not_eligible' ? '尚不能打卡' : '打卡未完成',
|
||||
content: message,
|
||||
showCancel: false,
|
||||
})
|
||||
}
|
||||
finally {
|
||||
if (currentRequest === requestVersion.value)
|
||||
locating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((query) => {
|
||||
const rawPoiId = typeof query?.poiId === 'string' ? query.poiId : ''
|
||||
try {
|
||||
routePoiId.value = decodeURIComponent(rawPoiId)
|
||||
loadPage(routePoiId.value)
|
||||
}
|
||||
catch {
|
||||
pageState.value = 'invalid'
|
||||
}
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
if (pageState.value === 'error' && routePoiId.value) {
|
||||
loadPage(routePoiId.value)
|
||||
return
|
||||
}
|
||||
try {
|
||||
profile.value = getCheckInProfile()
|
||||
if (poi.value && getCheckInTask(poi.value.id))
|
||||
pageState.value = 'ready'
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to refresh check-in profile', error)
|
||||
pageState.value = 'error'
|
||||
}
|
||||
})
|
||||
|
||||
onUnload(() => {
|
||||
cancelPendingLocation()
|
||||
})
|
||||
|
||||
onHide(cancelPendingLocation)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="check-in-page">
|
||||
<PageState v-if="pageState === 'loading'" state="loading" title="正在准备打卡…" />
|
||||
<PageState
|
||||
v-else-if="pageState === 'invalid'"
|
||||
state="empty"
|
||||
title="该点位暂不可打卡"
|
||||
description="点位可能已更新,请返回地图重新选择"
|
||||
action-label="返回地图"
|
||||
@action="returnToPoi"
|
||||
/>
|
||||
<PageState
|
||||
v-else-if="pageState === 'error'"
|
||||
state="error"
|
||||
title="打卡数据暂不可用"
|
||||
description="请前往“我的打卡”检查并按提示处理本机数据"
|
||||
action-label="去处理"
|
||||
@action="openDataRecovery"
|
||||
/>
|
||||
|
||||
<template v-else-if="poi">
|
||||
<view class="check-in-hero">
|
||||
<text class="check-in-hero__eyebrow">ON-SITE CHECK-IN</text>
|
||||
<text class="check-in-hero__title">抵达光明,留下足迹</text>
|
||||
<text class="check-in-hero__description">每次打卡都使用本次真实定位核验;位置只用于当场判断,不写入本机记录。</text>
|
||||
<button class="check-in-summary" @click="openRecords">
|
||||
<view>
|
||||
<text class="check-in-summary__label">本机积分</text>
|
||||
<text class="check-in-summary__value">{{ profile.points }}</text>
|
||||
</view>
|
||||
<view>
|
||||
<text class="check-in-summary__label">已打卡</text>
|
||||
<text class="check-in-summary__value">{{ progressText }}</text>
|
||||
</view>
|
||||
<view>
|
||||
<text class="check-in-summary__label">徽章</text>
|
||||
<text class="check-in-summary__value">{{ profile.badges.length }}</text>
|
||||
</view>
|
||||
<text class="check-in-summary__arrow">›</text>
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view class="check-in-content">
|
||||
<view class="poi-check-card">
|
||||
<PoiImage
|
||||
:src="poi.coverImage.url"
|
||||
:alt="poi.coverImage.alt"
|
||||
width="152rpx"
|
||||
height="152rpx"
|
||||
radius="18rpx"
|
||||
/>
|
||||
<view class="poi-check-card__copy">
|
||||
<text class="poi-check-card__category">{{ poi.category.name }}</text>
|
||||
<text class="poi-check-card__name">{{ poi.name }}</text>
|
||||
<text class="poi-check-card__address">{{ poi.address }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="checkedRecord" class="check-in-status check-in-status--done">
|
||||
<view class="check-in-status__mark">✓</view>
|
||||
<view class="check-in-status__copy">
|
||||
<text class="check-in-status__title">该点位已完成打卡</text>
|
||||
<text>{{ formatTime(checkedRecord.checkedInAt) }} · 获得 {{ checkedRecord.pointsAwarded }} 积分</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="check-in-status">
|
||||
<view class="check-in-status__mark">◎</view>
|
||||
<view class="check-in-status__copy">
|
||||
<text class="check-in-status__title">请在点位附近发起打卡</text>
|
||||
<text>需在 {{ checkInRadius }} 米范围内,且定位精度满足要求</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<button
|
||||
class="check-in-action"
|
||||
:class="{ 'check-in-action--done': checkedRecord }"
|
||||
:disabled="locating || Boolean(checkedRecord)"
|
||||
@click="requestCheckIn"
|
||||
>
|
||||
{{ checkedRecord ? '已完成打卡' : locating ? '正在获取本次位置…' : `验证位置并打卡 +${CHECK_IN_POINTS}` }}
|
||||
</button>
|
||||
|
||||
<view v-if="privacyAuthorizationRequired" class="check-in-privacy-consent">
|
||||
<text class="check-in-privacy-consent__title">打卡需要位置隐私授权</text>
|
||||
<text>同意后才会请求本次位置;位置仅用于到点判断,不保存精确经纬度。</text>
|
||||
<button
|
||||
id="check-in-privacy-agree"
|
||||
open-type="agreePrivacyAuthorization"
|
||||
@agreeprivacyauthorization="acceptPrivacyAuthorization"
|
||||
>
|
||||
同意并继续打卡
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view class="check-in-rules">
|
||||
<text class="check-in-rules__title">打卡规则</text>
|
||||
<view><text>01</text><text>每个开放打卡点位在本机仅可打卡一次</text></view>
|
||||
<view><text>02</text><text>每次打卡重新请求 GCJ-02 位置,不复用历史定位</text></view>
|
||||
<view><text>03</text><text>成功后增加积分,并按累计点位数解锁徽章</text></view>
|
||||
<view><text>04</text><text>系统级虚拟定位无法由本 POC 完全识别,正式版需增加风控</text></view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<route lang="yaml">
|
||||
layout: map
|
||||
style:
|
||||
navigationBarTitleText: 点位打卡
|
||||
navigationBarBackgroundColor: '#ffffff'
|
||||
navigationBarTextStyle: black
|
||||
backgroundColor: '#f4f8f6'
|
||||
</route>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.check-in-page {
|
||||
min-height: 100vh;
|
||||
color: #18201d;
|
||||
background: #f4f8f6;
|
||||
}
|
||||
|
||||
.check-in-hero {
|
||||
padding: 46rpx 32rpx 34rpx;
|
||||
color: #fff;
|
||||
background: linear-gradient(145deg, #123d35, #08724d 62%, #07573d);
|
||||
}
|
||||
|
||||
.check-in-hero__eyebrow {
|
||||
display: block;
|
||||
font-size: 18rpx;
|
||||
line-height: 28rpx;
|
||||
color: #9fe0bd;
|
||||
letter-spacing: 4rpx;
|
||||
}
|
||||
|
||||
.check-in-hero__title {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
font-size: 42rpx;
|
||||
font-weight: 800;
|
||||
line-height: 58rpx;
|
||||
}
|
||||
|
||||
.check-in-hero__description {
|
||||
display: block;
|
||||
margin-top: 14rpx;
|
||||
font-size: 23rpx;
|
||||
line-height: 38rpx;
|
||||
color: rgb(239 255 246 / 78%);
|
||||
}
|
||||
|
||||
.check-in-summary {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 22rpx 24rpx;
|
||||
margin: 30rpx 0 0;
|
||||
color: #fff;
|
||||
text-align: left;
|
||||
background: rgb(255 255 255 / 11%);
|
||||
border: 1rpx solid rgb(255 255 255 / 22%);
|
||||
border-radius: 20rpx;
|
||||
}
|
||||
|
||||
.check-in-summary::after,
|
||||
.check-in-action::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.check-in-summary view {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.check-in-summary__label {
|
||||
font-size: 18rpx;
|
||||
color: rgb(239 255 246 / 68%);
|
||||
}
|
||||
|
||||
.check-in-summary__value {
|
||||
margin-top: 5rpx;
|
||||
font-size: 29rpx;
|
||||
font-weight: 800;
|
||||
line-height: 40rpx;
|
||||
}
|
||||
|
||||
.check-in-summary__arrow {
|
||||
font-size: 40rpx;
|
||||
color: #9fe0bd;
|
||||
}
|
||||
|
||||
.check-in-content {
|
||||
padding: 28rpx 28rpx calc(48rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.poi-check-card,
|
||||
.check-in-status,
|
||||
.check-in-rules,
|
||||
.check-in-privacy-consent {
|
||||
background: #fff;
|
||||
border: 1rpx solid #dce6e1;
|
||||
border-radius: 22rpx;
|
||||
box-shadow: 0 10rpx 28rpx rgb(20 78 53 / 5%);
|
||||
}
|
||||
|
||||
.check-in-privacy-consent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 24rpx;
|
||||
margin-top: 20rpx;
|
||||
font-size: 21rpx;
|
||||
line-height: 34rpx;
|
||||
color: #5e6b65;
|
||||
}
|
||||
|
||||
.check-in-privacy-consent__title {
|
||||
margin-bottom: 6rpx;
|
||||
font-size: 26rpx;
|
||||
font-weight: 800;
|
||||
color: #18201d;
|
||||
}
|
||||
|
||||
.check-in-privacy-consent button {
|
||||
width: 100%;
|
||||
height: 70rpx;
|
||||
margin: 18rpx 0 0;
|
||||
font-size: 23rpx;
|
||||
line-height: 70rpx;
|
||||
color: #fff;
|
||||
background: #167a5b;
|
||||
border: 0;
|
||||
border-radius: 14rpx;
|
||||
}
|
||||
|
||||
.check-in-privacy-consent button::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.poi-check-card {
|
||||
display: flex;
|
||||
gap: 22rpx;
|
||||
align-items: center;
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
.poi-check-card__copy {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.poi-check-card__category {
|
||||
font-size: 20rpx;
|
||||
color: #16805c;
|
||||
}
|
||||
|
||||
.poi-check-card__name {
|
||||
margin-top: 5rpx;
|
||||
font-size: 30rpx;
|
||||
font-weight: 800;
|
||||
line-height: 42rpx;
|
||||
}
|
||||
|
||||
.poi-check-card__address {
|
||||
display: -webkit-box;
|
||||
margin-top: 7rpx;
|
||||
overflow: hidden;
|
||||
font-size: 21rpx;
|
||||
line-height: 32rpx;
|
||||
color: #718078;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.check-in-status {
|
||||
display: flex;
|
||||
gap: 18rpx;
|
||||
align-items: center;
|
||||
padding: 24rpx;
|
||||
margin-top: 20rpx;
|
||||
color: #5e6b65;
|
||||
}
|
||||
|
||||
.check-in-status--done {
|
||||
color: #315d4a;
|
||||
background: #e8f4ee;
|
||||
border-color: #cfe4d9;
|
||||
}
|
||||
|
||||
.check-in-status__mark {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
font-size: 30rpx;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
background: #167a5b;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.check-in-status__copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 21rpx;
|
||||
line-height: 32rpx;
|
||||
}
|
||||
|
||||
.check-in-status__title {
|
||||
margin-bottom: 4rpx;
|
||||
font-size: 26rpx;
|
||||
font-weight: 750;
|
||||
line-height: 38rpx;
|
||||
color: #18201d;
|
||||
}
|
||||
|
||||
.check-in-action {
|
||||
width: 100%;
|
||||
height: 92rpx;
|
||||
margin: 24rpx 0 0;
|
||||
font-size: 27rpx;
|
||||
font-weight: 750;
|
||||
line-height: 92rpx;
|
||||
color: #fff;
|
||||
background: #167a5b;
|
||||
border: 0;
|
||||
border-radius: 20rpx;
|
||||
box-shadow: 0 14rpx 30rpx rgb(14 98 71 / 16%);
|
||||
}
|
||||
|
||||
.check-in-action--done,
|
||||
.check-in-action[disabled] {
|
||||
color: #718078;
|
||||
background: #e3e9e6;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.check-in-rules {
|
||||
padding: 26rpx;
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
|
||||
.check-in-rules__title {
|
||||
display: block;
|
||||
margin-bottom: 12rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: 800;
|
||||
line-height: 40rpx;
|
||||
}
|
||||
|
||||
.check-in-rules view {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
padding: 12rpx 0;
|
||||
font-size: 22rpx;
|
||||
line-height: 34rpx;
|
||||
color: #5e6b65;
|
||||
border-bottom: 1rpx solid #edf1ef;
|
||||
}
|
||||
|
||||
.check-in-rules view:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.check-in-rules view text:first-child {
|
||||
flex: none;
|
||||
font-family: Georgia, serif;
|
||||
color: #16805c;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,574 @@
|
||||
<script setup lang="ts">
|
||||
import type { CheckInBadgeDefinition, CheckInProfile } from '@/domain/check-in'
|
||||
import type { PoiResolved } from '@/domain/poi'
|
||||
import { getCheckInTasks } from '@/data/check-in'
|
||||
import { getPoiRepository } from '@/data/poi'
|
||||
import { CHECK_IN_BADGES } from '@/domain/check-in'
|
||||
import { clearCheckInProfile, getCheckInProfile } from '@/services/check-in'
|
||||
|
||||
interface BadgeView extends CheckInBadgeDefinition {
|
||||
obtainedAt: string | null
|
||||
}
|
||||
|
||||
const profile = ref<CheckInProfile>({ points: 0, badges: [], checkIns: [] })
|
||||
const storageError = ref('')
|
||||
const availablePois = shallowRef<PoiResolved[]>([])
|
||||
const badgeViews = computed<BadgeView[]>(() => CHECK_IN_BADGES.map(definition => ({
|
||||
...definition,
|
||||
obtainedAt: profile.value.badges.find(badge => badge.code === definition.code)?.obtainedAt ?? null,
|
||||
})))
|
||||
const availableCheckInCount = computed(() => {
|
||||
const availablePoiIds = new Set(availablePois.value.map(poi => poi.id))
|
||||
return profile.value.checkIns.filter(record => availablePoiIds.has(record.poiId)).length
|
||||
})
|
||||
|
||||
function formatTime(value: string): string {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime()))
|
||||
return '时间待确认'
|
||||
const pad = (number: number) => String(number).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
}
|
||||
|
||||
function openPoi(poiId: string) {
|
||||
if (!getPoiRepository().getPoiById(poiId)) {
|
||||
uni.showModal({
|
||||
title: '点位已下线',
|
||||
content: '该点位暂不在当前开放列表中,本机历史打卡记录仍会保留。',
|
||||
showCancel: false,
|
||||
})
|
||||
return
|
||||
}
|
||||
uni.navigateTo({ url: `/pages/poi/detail?poiId=${encodeURIComponent(poiId)}` })
|
||||
}
|
||||
|
||||
function loadAvailablePois() {
|
||||
const repository = getPoiRepository()
|
||||
availablePois.value = getCheckInTasks().flatMap((task) => {
|
||||
const poi = repository.getPoiById(task.poiId)
|
||||
return poi ? [poi] : []
|
||||
})
|
||||
}
|
||||
|
||||
function poiCheckedIn(poiId: string): boolean {
|
||||
return profile.value.checkIns.some(record => record.poiId === poiId)
|
||||
}
|
||||
|
||||
function clearLocalRecords() {
|
||||
uni.showModal({
|
||||
title: '清除本机打卡数据',
|
||||
content: '积分、徽章和打卡记录将从本机删除且无法恢复,不影响路线、偏好或其他数据。',
|
||||
confirmText: '确认清除',
|
||||
confirmColor: '#b53a32',
|
||||
success: (result) => {
|
||||
if (!result.confirm)
|
||||
return
|
||||
try {
|
||||
clearCheckInProfile()
|
||||
profile.value = getCheckInProfile()
|
||||
storageError.value = ''
|
||||
uni.showToast({ title: '打卡数据已清除', icon: 'success' })
|
||||
}
|
||||
catch (error) {
|
||||
uni.showModal({
|
||||
title: '清除失败',
|
||||
content: error instanceof Error ? error.message : '请稍后重试',
|
||||
showCancel: false,
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
loadAvailablePois()
|
||||
try {
|
||||
profile.value = getCheckInProfile()
|
||||
storageError.value = ''
|
||||
}
|
||||
catch (error) {
|
||||
storageError.value = error instanceof Error ? error.message : '本机打卡记录暂时无法读取'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="records-page">
|
||||
<view class="records-hero">
|
||||
<text class="records-hero__eyebrow">LOCAL JOURNEY</text>
|
||||
<text class="records-hero__title">我的光明足迹</text>
|
||||
<view class="records-stats">
|
||||
<view><text>{{ profile.points }}</text><text>本机积分</text></view>
|
||||
<view><text>{{ profile.checkIns.length }}</text><text>打卡点位</text></view>
|
||||
<view><text>{{ profile.badges.length }}</text><text>已获徽章</text></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="records-content">
|
||||
<view v-if="storageError" class="records-error">
|
||||
<text class="records-error__title">打卡数据需要处理</text>
|
||||
<text>{{ storageError }}</text>
|
||||
<button @click="clearLocalRecords">清除异常打卡数据</button>
|
||||
</view>
|
||||
|
||||
<view class="records-section">
|
||||
<view class="records-section__heading">
|
||||
<view><text class="records-section__kicker">BADGES</text><text class="records-section__title">光明徽章</text></view>
|
||||
<text>{{ profile.badges.length }} / {{ badgeViews.length }}</text>
|
||||
</view>
|
||||
<view class="badge-grid">
|
||||
<view
|
||||
v-for="badge in badgeViews"
|
||||
:key="badge.code"
|
||||
class="badge-card"
|
||||
:class="{ 'badge-card--locked': !badge.obtainedAt }"
|
||||
>
|
||||
<view class="badge-card__icon">{{ badge.icon }}</view>
|
||||
<text class="badge-card__name">{{ badge.name }}</text>
|
||||
<text class="badge-card__description">{{ badge.description }}</text>
|
||||
<text class="badge-card__time">{{ badge.obtainedAt ? formatTime(badge.obtainedAt) : '尚未解锁' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="records-section">
|
||||
<view class="records-section__heading">
|
||||
<view><text class="records-section__kicker">PLACES</text><text class="records-section__title">开放打卡点位</text></view>
|
||||
<text>{{ availableCheckInCount }} / {{ availablePois.length }}</text>
|
||||
</view>
|
||||
<view class="check-in-poi-list">
|
||||
<button v-for="poi in availablePois" :key="poi.id" class="check-in-poi-card" @click="openPoi(poi.id)">
|
||||
<view class="check-in-poi-card__mark" :class="{ 'check-in-poi-card__mark--done': poiCheckedIn(poi.id) }">
|
||||
{{ poiCheckedIn(poi.id) ? '✓' : '◎' }}
|
||||
</view>
|
||||
<view class="check-in-poi-card__copy">
|
||||
<text>{{ poi.name }}</text>
|
||||
<text>{{ poi.category.name }} · {{ poiCheckedIn(poi.id) ? '已打卡' : '可前往打卡' }}</text>
|
||||
</view>
|
||||
<text class="check-in-poi-card__arrow">›</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="records-section">
|
||||
<view class="records-section__heading">
|
||||
<view><text class="records-section__kicker">HISTORY</text><text class="records-section__title">打卡记录</text></view>
|
||||
<text>{{ profile.checkIns.length }} 条</text>
|
||||
</view>
|
||||
<view v-if="profile.checkIns.length" class="record-list">
|
||||
<button v-for="record in profile.checkIns" :key="record.id" class="record-card" @click="openPoi(record.poiId)">
|
||||
<view class="record-card__index">✓</view>
|
||||
<view class="record-card__copy">
|
||||
<text class="record-card__name">{{ record.poiName }}</text>
|
||||
<text>{{ formatTime(record.checkedInAt) }} · +{{ record.pointsAwarded }} 积分</text>
|
||||
</view>
|
||||
<text class="record-card__arrow">›</text>
|
||||
</button>
|
||||
</view>
|
||||
<view v-else class="record-empty">
|
||||
<view class="record-empty__mark">◎</view>
|
||||
<text class="record-empty__title">还没有光明足迹</text>
|
||||
<text>从地图选择一个点位,到达现场后即可开始第一次打卡。</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="records-privacy">
|
||||
<text class="records-privacy__title">匿名本机数据</text>
|
||||
<text>打卡记录、积分和徽章仅保存在当前设备;不登录、不上传,也不保存打卡时的精确经纬度。</text>
|
||||
<button v-if="profile.checkIns.length" @click="clearLocalRecords">清除本机打卡数据</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<route lang="yaml">
|
||||
layout: map
|
||||
style:
|
||||
navigationBarTitleText: 我的打卡
|
||||
navigationBarBackgroundColor: '#ffffff'
|
||||
navigationBarTextStyle: black
|
||||
backgroundColor: '#f4f8f6'
|
||||
</route>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.records-page {
|
||||
min-height: 100vh;
|
||||
color: #18201d;
|
||||
background: #f4f8f6;
|
||||
}
|
||||
|
||||
.records-hero {
|
||||
padding: 46rpx 32rpx 36rpx;
|
||||
color: #fff;
|
||||
background: linear-gradient(145deg, #123d35, #08724d 62%, #07573d);
|
||||
}
|
||||
|
||||
.records-hero__eyebrow,
|
||||
.records-section__kicker {
|
||||
display: block;
|
||||
font-size: 18rpx;
|
||||
line-height: 28rpx;
|
||||
color: #9fe0bd;
|
||||
letter-spacing: 4rpx;
|
||||
}
|
||||
|
||||
.records-hero__title {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
font-size: 42rpx;
|
||||
font-weight: 800;
|
||||
line-height: 58rpx;
|
||||
}
|
||||
|
||||
.records-stats {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
margin-top: 30rpx;
|
||||
}
|
||||
|
||||
.records-stats view {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
padding: 18rpx;
|
||||
background: rgb(255 255 255 / 11%);
|
||||
border: 1rpx solid rgb(255 255 255 / 20%);
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.records-stats view text:first-child {
|
||||
font-size: 32rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.records-stats view text:last-child {
|
||||
margin-top: 4rpx;
|
||||
font-size: 18rpx;
|
||||
color: rgb(239 255 246 / 68%);
|
||||
}
|
||||
|
||||
.records-content {
|
||||
padding: 8rpx 28rpx calc(48rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.records-section {
|
||||
margin-top: 40rpx;
|
||||
}
|
||||
|
||||
.records-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 24rpx;
|
||||
margin-top: 28rpx;
|
||||
font-size: 21rpx;
|
||||
line-height: 34rpx;
|
||||
color: #6d3a36;
|
||||
background: #fff5f3;
|
||||
border: 1rpx solid #ead6d2;
|
||||
border-radius: 18rpx;
|
||||
}
|
||||
|
||||
.records-error__title {
|
||||
margin-bottom: 6rpx;
|
||||
font-size: 26rpx;
|
||||
font-weight: 800;
|
||||
color: #873e38;
|
||||
}
|
||||
|
||||
.records-error button {
|
||||
width: 100%;
|
||||
height: 64rpx;
|
||||
margin: 16rpx 0 0;
|
||||
font-size: 22rpx;
|
||||
line-height: 64rpx;
|
||||
color: #fff;
|
||||
background: #873e38;
|
||||
border: 0;
|
||||
border-radius: 12rpx;
|
||||
}
|
||||
|
||||
.records-error button::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.records-section__heading {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20rpx;
|
||||
font-size: 21rpx;
|
||||
color: #718078;
|
||||
}
|
||||
|
||||
.records-section__kicker {
|
||||
color: #16805c;
|
||||
}
|
||||
|
||||
.records-section__title {
|
||||
display: block;
|
||||
margin-top: 7rpx;
|
||||
font-size: 34rpx;
|
||||
font-weight: 800;
|
||||
line-height: 48rpx;
|
||||
color: #18201d;
|
||||
}
|
||||
|
||||
.badge-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 14rpx;
|
||||
}
|
||||
|
||||
.badge-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
padding: 22rpx 12rpx;
|
||||
text-align: center;
|
||||
background: #e8f4ee;
|
||||
border: 1rpx solid #cfe4d9;
|
||||
border-radius: 20rpx;
|
||||
}
|
||||
|
||||
.badge-card--locked {
|
||||
color: #718078;
|
||||
background: #f0f3f1;
|
||||
border-color: #e0e6e3;
|
||||
}
|
||||
|
||||
.badge-card__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
font-family: Georgia, serif;
|
||||
font-size: 25rpx;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
background: #167a5b;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.badge-card--locked .badge-card__icon {
|
||||
background: #a8b3ad;
|
||||
}
|
||||
|
||||
.badge-card__name {
|
||||
margin-top: 14rpx;
|
||||
font-size: 22rpx;
|
||||
font-weight: 800;
|
||||
line-height: 32rpx;
|
||||
}
|
||||
|
||||
.badge-card__description,
|
||||
.badge-card__time {
|
||||
margin-top: 7rpx;
|
||||
font-size: 17rpx;
|
||||
line-height: 27rpx;
|
||||
color: #637069;
|
||||
}
|
||||
|
||||
.badge-card__time {
|
||||
color: #16805c;
|
||||
}
|
||||
|
||||
.record-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14rpx;
|
||||
}
|
||||
|
||||
.check-in-poi-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14rpx;
|
||||
}
|
||||
|
||||
.check-in-poi-card {
|
||||
display: flex;
|
||||
gap: 18rpx;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 100rpx;
|
||||
padding: 18rpx 22rpx;
|
||||
margin: 0;
|
||||
text-align: left;
|
||||
background: #fff;
|
||||
border: 1rpx solid #dce6e1;
|
||||
border-radius: 18rpx;
|
||||
}
|
||||
|
||||
.check-in-poi-card::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.check-in-poi-card__mark {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 54rpx;
|
||||
height: 54rpx;
|
||||
font-size: 25rpx;
|
||||
color: #16805c;
|
||||
background: #e8f4ee;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.check-in-poi-card__mark--done {
|
||||
color: #fff;
|
||||
background: #167a5b;
|
||||
}
|
||||
|
||||
.check-in-poi-card__copy {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.check-in-poi-card__copy text:first-child {
|
||||
overflow: hidden;
|
||||
font-size: 26rpx;
|
||||
font-weight: 750;
|
||||
line-height: 38rpx;
|
||||
color: #18201d;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.check-in-poi-card__copy text:last-child {
|
||||
margin-top: 3rpx;
|
||||
font-size: 20rpx;
|
||||
line-height: 30rpx;
|
||||
color: #718078;
|
||||
}
|
||||
|
||||
.check-in-poi-card__arrow {
|
||||
font-size: 38rpx;
|
||||
color: #16805c;
|
||||
}
|
||||
|
||||
.record-card {
|
||||
display: flex;
|
||||
gap: 18rpx;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 22rpx;
|
||||
margin: 0;
|
||||
text-align: left;
|
||||
background: #fff;
|
||||
border: 1rpx solid #dce6e1;
|
||||
border-radius: 18rpx;
|
||||
}
|
||||
|
||||
.record-card::after,
|
||||
.records-privacy button::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.record-card__index {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 52rpx;
|
||||
height: 52rpx;
|
||||
font-size: 24rpx;
|
||||
color: #fff;
|
||||
background: #167a5b;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.record-card__copy {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
font-size: 20rpx;
|
||||
line-height: 31rpx;
|
||||
color: #718078;
|
||||
}
|
||||
|
||||
.record-card__name {
|
||||
overflow: hidden;
|
||||
font-size: 27rpx;
|
||||
font-weight: 750;
|
||||
line-height: 38rpx;
|
||||
color: #18201d;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.record-card__arrow {
|
||||
font-size: 38rpx;
|
||||
color: #16805c;
|
||||
}
|
||||
|
||||
.record-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 52rpx 30rpx;
|
||||
font-size: 22rpx;
|
||||
line-height: 36rpx;
|
||||
color: #718078;
|
||||
text-align: center;
|
||||
background: #fff;
|
||||
border: 1rpx solid #dce6e1;
|
||||
border-radius: 20rpx;
|
||||
}
|
||||
|
||||
.record-empty__mark {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 88rpx;
|
||||
height: 88rpx;
|
||||
font-size: 40rpx;
|
||||
color: #16805c;
|
||||
background: #e8f4ee;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.record-empty__title {
|
||||
margin: 18rpx 0 7rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: 800;
|
||||
color: #18201d;
|
||||
}
|
||||
|
||||
.records-privacy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 24rpx;
|
||||
margin-top: 36rpx;
|
||||
font-size: 21rpx;
|
||||
line-height: 34rpx;
|
||||
color: #637069;
|
||||
background: #eef6f2;
|
||||
border: 1rpx solid #d6e7df;
|
||||
border-radius: 20rpx;
|
||||
}
|
||||
|
||||
.records-privacy__title {
|
||||
margin-bottom: 7rpx;
|
||||
font-size: 25rpx;
|
||||
font-weight: 800;
|
||||
color: #0e6247;
|
||||
}
|
||||
|
||||
.records-privacy button {
|
||||
width: 100%;
|
||||
height: 70rpx;
|
||||
margin: 18rpx 0 0;
|
||||
font-size: 23rpx;
|
||||
line-height: 70rpx;
|
||||
color: #8d3732;
|
||||
background: #fff;
|
||||
border: 1rpx solid #e8d6d4;
|
||||
border-radius: 14rpx;
|
||||
}
|
||||
</style>
|
||||
+132
-18
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import type { PoiResolved } from '@/domain/poi'
|
||||
import type { ItineraryItem, PlanResponse, TravelPreferences } from '@/domain/travel'
|
||||
import type { ItineraryItem, PlanningOrigin, 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 { adjustPlan, getSessionPlanningOrigin, loadPlan, loadPreferences, savePlan } from '@/services/travel-assistant'
|
||||
import { useMapStore } from '@/stores'
|
||||
|
||||
interface ResolvedItineraryItem {
|
||||
@@ -31,6 +31,11 @@ interface RouteMarker {
|
||||
anchor: { x: number, y: number }
|
||||
}
|
||||
|
||||
interface RoutePoint {
|
||||
latitude: number
|
||||
longitude: number
|
||||
}
|
||||
|
||||
type DurationFitStatus = PlanResponse['itinerary']['durationFitStatus']
|
||||
|
||||
interface DurationFitNotice {
|
||||
@@ -47,7 +52,10 @@ const adjusting = ref(false)
|
||||
const mapReady = ref(false)
|
||||
const mapError = ref(false)
|
||||
const mapContext = shallowRef<ReturnType<typeof uni.createMapContext> | null>(null)
|
||||
const quickPrompts = ['节奏慢一点', '换成室内场馆', '增加亲子体验']
|
||||
const planningOrigin = shallowRef<PlanningOrigin | null>(null)
|
||||
const quickPrompts = computed(() => plan.value?.itinerary.planningMode === 'custom'
|
||||
? ['节奏慢一点', '改为步行', '改成半日游']
|
||||
: ['节奏慢一点', '换成室内场馆', '增加亲子体验'])
|
||||
|
||||
const resolvedItems = computed<ResolvedItineraryItem[]>(() => {
|
||||
if (!plan.value)
|
||||
@@ -59,7 +67,7 @@ const resolvedItems = computed<ResolvedItineraryItem[]>(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const routeMarkers = computed<RouteMarker[]>(() => resolvedItems.value.map(({ poi }, index) => ({
|
||||
const poiRouteMarkers = computed<RouteMarker[]>(() => resolvedItems.value.map(({ poi }, index) => ({
|
||||
id: index + 1,
|
||||
latitude: poi.latitude,
|
||||
longitude: poi.longitude,
|
||||
@@ -68,7 +76,7 @@ const routeMarkers = computed<RouteMarker[]>(() => resolvedItems.value.map(({ po
|
||||
height: uni.upx2px(76),
|
||||
anchor: { x: 0.5, y: 1 },
|
||||
callout: {
|
||||
content: `${index + 1} · ${poi.name}`,
|
||||
content: String(index + 1),
|
||||
color: '#18201d',
|
||||
fontSize: 11,
|
||||
borderRadius: 7,
|
||||
@@ -79,11 +87,47 @@ const routeMarkers = computed<RouteMarker[]>(() => resolvedItems.value.map(({ po
|
||||
},
|
||||
})))
|
||||
|
||||
const routePolyline = computed(() => [{
|
||||
points: resolvedItems.value.map(({ poi }) => ({
|
||||
const originMarker = computed<RouteMarker | null>(() => {
|
||||
if (!plan.value?.itinerary.startsFromCurrentLocation || !planningOrigin.value)
|
||||
return null
|
||||
return {
|
||||
id: 900000001,
|
||||
latitude: planningOrigin.value.latitude,
|
||||
longitude: planningOrigin.value.longitude,
|
||||
iconPath: '/static/markers/default-selected.png',
|
||||
width: uni.upx2px(56),
|
||||
height: uni.upx2px(66),
|
||||
anchor: { x: 0.5, y: 1 },
|
||||
callout: {
|
||||
content: '起点',
|
||||
color: '#ffffff',
|
||||
fontSize: 11,
|
||||
borderRadius: 7,
|
||||
bgColor: '#b45a1b',
|
||||
padding: 5,
|
||||
display: 'ALWAYS',
|
||||
textAlign: 'center',
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const routeMarkers = computed<RouteMarker[]>(() => [
|
||||
...(originMarker.value ? [originMarker.value] : []),
|
||||
...poiRouteMarkers.value,
|
||||
])
|
||||
|
||||
const routePoints = computed<RoutePoint[]>(() => [
|
||||
...(originMarker.value
|
||||
? [{ latitude: originMarker.value.latitude, longitude: originMarker.value.longitude }]
|
||||
: []),
|
||||
...resolvedItems.value.map(({ poi }) => ({
|
||||
latitude: poi.latitude,
|
||||
longitude: poi.longitude,
|
||||
})),
|
||||
])
|
||||
|
||||
const routePolyline = computed(() => [{
|
||||
points: routePoints.value,
|
||||
color: '#167a5bcc',
|
||||
width: 6,
|
||||
dottedLine: false,
|
||||
@@ -115,13 +159,23 @@ const planningSourceLabel = computed(() => {
|
||||
return '真实 AI 推荐'
|
||||
if (plan.value?.source === 'remote_mock')
|
||||
return '远端演示规划'
|
||||
return '本地智能规划'
|
||||
return '本地 POC 规划'
|
||||
})
|
||||
const planningBasisLabel = computed(() => {
|
||||
const location = plan.value?.itinerary.startsFromCurrentLocation
|
||||
? planningOrigin.value ? '已结合本次当前位置' : '已结合定位(起点坐标未保留)'
|
||||
: '光明区全域'
|
||||
const selection = plan.value?.itinerary.planningMode === 'custom'
|
||||
? '完整保留自选点位并优化顺序'
|
||||
: '按偏好、时长和推荐指数自动选点'
|
||||
return `${location} · ${selection}`
|
||||
})
|
||||
const durationFitNotice = computed(() => plan.value ? getDurationFitNotice(plan.value.itinerary.durationFitStatus) : null)
|
||||
|
||||
function loadStoredPlan() {
|
||||
plan.value = loadPlan()
|
||||
preferences.value = loadPreferences()
|
||||
planningOrigin.value = plan.value ? getSessionPlanningOrigin(plan.value.conversationId) : null
|
||||
}
|
||||
|
||||
function formatMinutes(minutes: number): string {
|
||||
@@ -186,7 +240,7 @@ function formatDate(value: string): string {
|
||||
}
|
||||
|
||||
function fitRoutePoints() {
|
||||
if (!mapReady.value || !mapContext.value || resolvedItems.value.length === 0)
|
||||
if (!mapReady.value || !mapContext.value || routePoints.value.length === 0)
|
||||
return
|
||||
|
||||
const context = mapContext.value
|
||||
@@ -195,7 +249,7 @@ function fitRoutePoints() {
|
||||
return
|
||||
try {
|
||||
context.includePoints({
|
||||
points: resolvedItems.value.map(({ poi }) => ({ latitude: poi.latitude, longitude: poi.longitude })),
|
||||
points: routePoints.value,
|
||||
padding: [uni.upx2px(70), uni.upx2px(70), uni.upx2px(70), uni.upx2px(70)],
|
||||
fail: handleMapError,
|
||||
})
|
||||
@@ -243,10 +297,11 @@ function focusOnMap(poiId: string) {
|
||||
uni.switchTab({ url: '/pages/map/index' })
|
||||
}
|
||||
|
||||
function focusFirstOnMap() {
|
||||
const poi = resolvedItems.value[0]?.poi
|
||||
if (poi)
|
||||
focusOnMap(poi.id)
|
||||
function openFullRouteOnMap() {
|
||||
mapStore.setCategory(null)
|
||||
mapStore.selectPoi(null)
|
||||
mapStore.showPlannedRoute()
|
||||
uni.switchTab({ url: '/pages/map/index' })
|
||||
}
|
||||
|
||||
function saveRoute() {
|
||||
@@ -273,6 +328,7 @@ async function sendAdjustment(quick?: string) {
|
||||
const response = await adjustPlan(plan.value.conversationId, content)
|
||||
savePlan(response)
|
||||
plan.value = response
|
||||
planningOrigin.value = getSessionPlanningOrigin(response.conversationId)
|
||||
preferences.value = loadPreferences()
|
||||
message.value = ''
|
||||
await showDurationFitNotice(response.itinerary.durationFitStatus)
|
||||
@@ -315,6 +371,11 @@ onReady(initializeMap)
|
||||
<view><text>行程点位</text><text>{{ resolvedItems.length }} 个</text></view>
|
||||
</view>
|
||||
<text class="route-hero__summary">{{ plan.itinerary.summary }}</text>
|
||||
<view class="route-hero__basis">
|
||||
<text>本次规划依据</text>
|
||||
<text>{{ planningBasisLabel }}</text>
|
||||
<text>{{ plan.assistantMessage }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="plan.changeSummary" class="change-notice">
|
||||
@@ -335,7 +396,9 @@ onReady(initializeMap)
|
||||
<view class="route-map-card__heading">
|
||||
<view>
|
||||
<text class="route-map-card__title">路线点位分布</text>
|
||||
<text class="route-map-card__description">按推荐顺序直线连接,并非实际道路导航</text>
|
||||
<text class="route-map-card__description">
|
||||
{{ originMarker ? '含当前位置起点及全部游览节点' : '展示全部游览节点' }},按规划顺序直线连接
|
||||
</text>
|
||||
</view>
|
||||
<button @click="fitRoutePoints">查看全程</button>
|
||||
</view>
|
||||
@@ -357,7 +420,11 @@ onReady(initializeMap)
|
||||
:show-compass="false"
|
||||
@error="handleMapError"
|
||||
/>
|
||||
<view class="route-map-card__notice">推荐顺序 · 非实时导航</view>
|
||||
<view class="route-node-sequence">
|
||||
<text v-if="originMarker" class="route-node-sequence__origin">起点 · 当前位置</text>
|
||||
<text v-for="({ poi }, index) in resolvedItems" :key="poi.id">{{ index + 1 }} · {{ poi.name }}</text>
|
||||
</view>
|
||||
<view class="route-map-card__notice">完整规划顺序 · 直线示意,非道路级实时导航</view>
|
||||
</view>
|
||||
|
||||
<view class="route-section">
|
||||
@@ -401,7 +468,7 @@ onReady(initializeMap)
|
||||
v-model="message"
|
||||
:disabled="adjusting"
|
||||
maxlength="500"
|
||||
placeholder="例如:第二个地点换成室内项目"
|
||||
:placeholder="plan.itinerary.planningMode === 'custom' ? '例如:节奏慢一点,或改为步行' : '例如:换成室内场馆'"
|
||||
placeholder-class="composer__placeholder"
|
||||
confirm-type="send"
|
||||
@confirm="sendAdjustment()"
|
||||
@@ -427,7 +494,7 @@ onReady(initializeMap)
|
||||
|
||||
<view class="bottom-actions">
|
||||
<button class="bottom-actions__save" @click="saveRoute">保存路线</button>
|
||||
<button class="bottom-actions__map" @click="focusFirstOnMap">在全域地图查看</button>
|
||||
<button class="bottom-actions__map" @click="openFullRouteOnMap">在全域地图查看完整路线</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -559,6 +626,29 @@ style:
|
||||
color: rgb(238 255 245 / 75%);
|
||||
}
|
||||
|
||||
.route-hero__basis {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5rpx;
|
||||
padding: 16rpx 18rpx;
|
||||
margin-top: 18rpx;
|
||||
background: rgb(255 255 255 / 9%);
|
||||
border: 1rpx solid rgb(255 255 255 / 15%);
|
||||
border-radius: 14rpx;
|
||||
}
|
||||
|
||||
.route-hero__basis text {
|
||||
font-size: 19rpx;
|
||||
line-height: 30rpx;
|
||||
color: rgb(238 255 245 / 78%);
|
||||
}
|
||||
|
||||
.route-hero__basis text:first-child {
|
||||
font-size: 18rpx;
|
||||
font-weight: 800;
|
||||
color: #b9f0d0;
|
||||
}
|
||||
|
||||
.change-notice {
|
||||
padding: 18rpx 28rpx;
|
||||
font-size: 21rpx;
|
||||
@@ -676,6 +766,30 @@ style:
|
||||
background: #eef3f0;
|
||||
}
|
||||
|
||||
.route-node-sequence {
|
||||
display: flex;
|
||||
gap: 10rpx;
|
||||
padding: 16rpx 20rpx 4rpx;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
background: #f8faf9;
|
||||
}
|
||||
|
||||
.route-node-sequence text {
|
||||
flex: none;
|
||||
padding: 7rpx 13rpx;
|
||||
font-size: 18rpx;
|
||||
line-height: 28rpx;
|
||||
color: #285542;
|
||||
background: #e8f4ee;
|
||||
border-radius: 999rpx;
|
||||
}
|
||||
|
||||
.route-node-sequence .route-node-sequence__origin {
|
||||
color: #8a431d;
|
||||
background: #fff0e5;
|
||||
}
|
||||
|
||||
.route-map-card__notice {
|
||||
padding: 12rpx 20rpx;
|
||||
font-size: 18rpx;
|
||||
|
||||
+127
-9
@@ -1,11 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import type { PoiCategory, PoiDatasetMeta, PoiSummary } from '@/domain/poi'
|
||||
import type { PlanResponse } from '@/domain/travel'
|
||||
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 { getSessionPlanningOrigin, loadPlan } from '@/services/travel-assistant'
|
||||
import { useLocationStore, useMapStore } from '@/stores'
|
||||
|
||||
const MAP_ID = 'guangming-cultural-map'
|
||||
@@ -18,6 +20,7 @@ const mapError = ref(false)
|
||||
const categories = ref<PoiCategory[]>([])
|
||||
const allPoiSummaries = ref<PoiSummary[]>([])
|
||||
const datasetMeta = ref<PoiDatasetMeta | null>(null)
|
||||
const activePlan = shallowRef<PlanResponse | null>(null)
|
||||
const mapContext = shallowRef<ReturnType<typeof uni.createMapContext> | null>(null)
|
||||
const markerIdToPoiId = shallowRef(new Map<number, string>())
|
||||
const lastMarkerTapAt = ref(0)
|
||||
@@ -25,6 +28,8 @@ const navigating = ref(false)
|
||||
let includePointsRequestVersion = 0
|
||||
let locationListenerAttached = false
|
||||
let locationUpdatesStarted = false
|
||||
let locationUpdatesStarting = false
|
||||
let locationUpdatesRequested = false
|
||||
let locationRequestVersion = 0
|
||||
|
||||
const activeCategoryCode = computed(() => mapStore.categoryCode)
|
||||
@@ -50,16 +55,84 @@ const selectedPoi = computed(() => {
|
||||
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 markers = computed<PoiMapMarker[]>(() => {
|
||||
const baseMarkers = createPoiMarkers(
|
||||
filteredPois.value,
|
||||
categories.value,
|
||||
markerIdToPoiId.value,
|
||||
selectedPoiId.value,
|
||||
)
|
||||
if (!activePlan.value)
|
||||
return baseMarkers
|
||||
|
||||
const routeOrder = new Map(activePlan.value.itinerary.items.map((item, index) => [item.placeId, index + 1]))
|
||||
return baseMarkers.map((marker) => {
|
||||
const poiId = markerIdToPoiId.value.get(marker.id)
|
||||
const order = poiId ? routeOrder.get(poiId) : undefined
|
||||
if (!order)
|
||||
return marker
|
||||
return {
|
||||
...marker,
|
||||
callout: {
|
||||
...marker.callout,
|
||||
content: selectedPoiId.value === poiId ? `${order} · ${marker.callout.content}` : `路线 ${order}`,
|
||||
color: '#ffffff',
|
||||
bgColor: '#167a5b',
|
||||
display: 'ALWAYS',
|
||||
},
|
||||
zIndex: Math.max(marker.zIndex, 8),
|
||||
}
|
||||
})
|
||||
})
|
||||
const mapPoints = computed<Array<{ latitude: number, longitude: number }>>(() => filteredPois.value.map(poi => ({
|
||||
latitude: poi.latitude,
|
||||
longitude: poi.longitude,
|
||||
})))
|
||||
const plannedPois = computed(() => {
|
||||
if (!activePlan.value)
|
||||
return []
|
||||
const repository = getPoiRepository()
|
||||
return activePlan.value.itinerary.items.flatMap((item) => {
|
||||
const poi = repository.getPoiById(item.placeId)
|
||||
return poi ? [poi] : []
|
||||
})
|
||||
})
|
||||
const plannedOrigin = computed(() => activePlan.value
|
||||
? getSessionPlanningOrigin(activePlan.value.conversationId)
|
||||
: null)
|
||||
const plannedRoutePoints = computed(() => [
|
||||
...(activePlan.value?.itinerary.startsFromCurrentLocation && plannedOrigin.value
|
||||
? [{ latitude: plannedOrigin.value.latitude, longitude: plannedOrigin.value.longitude }]
|
||||
: []),
|
||||
...plannedPois.value.map(poi => ({ latitude: poi.latitude, longitude: poi.longitude })),
|
||||
])
|
||||
const plannedRoutePolyline = computed(() => plannedRoutePoints.value.length > 1
|
||||
? [{
|
||||
points: plannedRoutePoints.value,
|
||||
color: '#167a5bcc',
|
||||
width: 6,
|
||||
dottedLine: false,
|
||||
arrowLine: true,
|
||||
borderColor: '#ffffffcc',
|
||||
borderWidth: 2,
|
||||
}]
|
||||
: [])
|
||||
|
||||
function loadActivePlan() {
|
||||
activePlan.value = mapStore.plannedRouteVisible ? loadPlan() : null
|
||||
}
|
||||
|
||||
function includePlannedRoute() {
|
||||
const context = mapContext.value
|
||||
if (!context || !mapReady.value || plannedRoutePoints.value.length === 0)
|
||||
return
|
||||
nextTick(() => {
|
||||
context.includePoints({
|
||||
points: plannedRoutePoints.value,
|
||||
padding: [uni.upx2px(70), uni.upx2px(70), uni.upx2px(150), uni.upx2px(70)],
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function loadDataset() {
|
||||
pageState.value = 'loading'
|
||||
@@ -161,6 +234,8 @@ function selectCategory(categoryCode: string | null) {
|
||||
return
|
||||
|
||||
mapStore.setCategory(categoryCode)
|
||||
mapStore.hidePlannedRoute()
|
||||
activePlan.value = null
|
||||
if (selectedPoi.value && categoryCode && selectedPoi.value.categoryCode !== categoryCode)
|
||||
mapStore.selectPoi(null)
|
||||
|
||||
@@ -244,6 +319,8 @@ function updateViewportWithScale(longitude: number, latitude: number, fallbackSc
|
||||
function resetToAll() {
|
||||
mapStore.setCategory(null)
|
||||
mapStore.selectPoi(null)
|
||||
mapStore.hidePlannedRoute()
|
||||
activePlan.value = null
|
||||
const target = datasetMeta.value?.defaultViewport
|
||||
if (target)
|
||||
mapStore.resetViewport(target)
|
||||
@@ -279,16 +356,24 @@ function detachLocationListener() {
|
||||
}
|
||||
|
||||
function startForegroundLocationUpdates() {
|
||||
if (locationUpdatesStarted)
|
||||
if (locationUpdatesStarted || locationUpdatesStarting || !locationUpdatesRequested)
|
||||
return
|
||||
|
||||
locationUpdatesStarting = true
|
||||
attachLocationListener()
|
||||
uni.startLocationUpdate({
|
||||
type: 'gcj02',
|
||||
success: () => {
|
||||
locationUpdatesStarting = false
|
||||
if (!locationUpdatesRequested) {
|
||||
uni.stopLocationUpdate()
|
||||
detachLocationListener()
|
||||
return
|
||||
}
|
||||
locationUpdatesStarted = true
|
||||
},
|
||||
fail: (error) => {
|
||||
locationUpdatesStarting = false
|
||||
detachLocationListener()
|
||||
console.error('Failed to start foreground location updates', error)
|
||||
},
|
||||
@@ -296,6 +381,7 @@ function startForegroundLocationUpdates() {
|
||||
}
|
||||
|
||||
function stopForegroundLocationUpdates() {
|
||||
locationUpdatesRequested = false
|
||||
locationRequestVersion += 1
|
||||
detachLocationListener()
|
||||
if (locationUpdatesStarted) {
|
||||
@@ -324,6 +410,7 @@ function requestUserLocation() {
|
||||
uni.openSetting({
|
||||
success: (settings) => {
|
||||
if (settings.authSetting['scope.userLocation']) {
|
||||
locationUpdatesRequested = true
|
||||
locationStore.startLocating()
|
||||
requestCurrentLocation()
|
||||
}
|
||||
@@ -332,11 +419,13 @@ function requestUserLocation() {
|
||||
return
|
||||
}
|
||||
if (locationReady.value) {
|
||||
locationUpdatesRequested = true
|
||||
centerOnUserLocation()
|
||||
startForegroundLocationUpdates()
|
||||
return
|
||||
}
|
||||
|
||||
locationUpdatesRequested = true
|
||||
locationStore.startLocating()
|
||||
requestCurrentLocation()
|
||||
}
|
||||
@@ -395,6 +484,10 @@ function openDetail(poiId: string) {
|
||||
})
|
||||
}
|
||||
|
||||
function openCheckInRecords() {
|
||||
uni.navigateTo({ url: '/pages/check-in/records' })
|
||||
}
|
||||
|
||||
function initializeMap() {
|
||||
includePointsRequestVersion += 1
|
||||
mapReady.value = false
|
||||
@@ -404,7 +497,10 @@ function initializeMap() {
|
||||
try {
|
||||
mapContext.value = uni.createMapContext(MAP_ID)
|
||||
mapReady.value = true
|
||||
includeFilteredPoints()
|
||||
if (activePlan.value)
|
||||
includePlannedRoute()
|
||||
else
|
||||
includeFilteredPoints()
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to initialize map', error)
|
||||
@@ -431,7 +527,10 @@ onReady(() => {
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
if (locationReady.value)
|
||||
loadActivePlan()
|
||||
if (activePlan.value)
|
||||
includePlannedRoute()
|
||||
if (locationReady.value && locationUpdatesRequested)
|
||||
startForegroundLocationUpdates()
|
||||
})
|
||||
|
||||
@@ -447,6 +546,7 @@ onUnload(stopForegroundLocationUpdates)
|
||||
:result-count="filteredPois.length"
|
||||
:coverage-label="datasetMeta?.coverageLabel ?? 'POC 示例数据'"
|
||||
:loading="pageState === 'loading'"
|
||||
@check-ins="openCheckInRecords"
|
||||
@select="selectCategory"
|
||||
/>
|
||||
|
||||
@@ -492,6 +592,7 @@ onUnload(stopForegroundLocationUpdates)
|
||||
:latitude="viewport.latitude"
|
||||
:scale="viewport.scale"
|
||||
:markers="markers"
|
||||
:polyline="plannedRoutePolyline"
|
||||
:show-location="locationReady"
|
||||
:enable-rotate="false"
|
||||
:enable-overlooking="false"
|
||||
@@ -511,6 +612,9 @@ onUnload(stopForegroundLocationUpdates)
|
||||
>
|
||||
{{ locationButtonText }}
|
||||
</cover-view>
|
||||
<cover-view v-if="activePlan" class="map-page__route" @tap="includePlannedRoute">
|
||||
完整路线 · {{ plannedPois.length }} 站
|
||||
</cover-view>
|
||||
</map>
|
||||
</view>
|
||||
|
||||
@@ -628,6 +732,20 @@ style:
|
||||
background: #167a5b;
|
||||
}
|
||||
|
||||
.map-page__route {
|
||||
position: absolute;
|
||||
bottom: 28rpx;
|
||||
left: 24rpx;
|
||||
padding: 14rpx 22rpx;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
line-height: 36rpx;
|
||||
color: #fff;
|
||||
background: #167a5b;
|
||||
border-radius: 28rpx;
|
||||
box-shadow: 0 8rpx 24rpx rgb(24 32 29 / 16%);
|
||||
}
|
||||
|
||||
.map-page__disclaimer {
|
||||
flex: none;
|
||||
padding: 8rpx 24rpx calc(8rpx + env(safe-area-inset-bottom));
|
||||
|
||||
+38
-12
@@ -2,7 +2,7 @@
|
||||
import type { PoiSummary } from '@/domain/poi'
|
||||
import type { BudgetLevel, Interest, Pace, PlanningRequest, Theme, Transport, TravelPreferences } from '@/domain/travel'
|
||||
import { getPoiRepository } from '@/data/poi'
|
||||
import { createPlan, isRemoteTravelAssistantEnabled, loadPlanningRequest, loadPreferences, savePlan, savePreferences } from '@/services/travel-assistant'
|
||||
import { createPlan, loadPlanningRequest, loadPreferences, savePlan, savePreferences } from '@/services/travel-assistant'
|
||||
import { useLocationStore } from '@/stores'
|
||||
|
||||
interface PlannerPreset {
|
||||
@@ -17,6 +17,7 @@ const MAX_DURATION_MINUTES = 10 * 60
|
||||
const DURATION_STEP_MINUTES = 30
|
||||
const MIN_CUSTOM_POIS = 2
|
||||
const MAX_CUSTOM_POIS = 8
|
||||
const LOCATION_MAX_AGE_MS = 5 * 60 * 1000
|
||||
const themeOptions: Theme[] = ['亲子', '情侣', '朋友', '银发', '研学']
|
||||
const interestOptions: Interest[] = ['自然风光', '文化场馆', '生态科普', '美食', '摄影']
|
||||
const paceOptions: Array<{ label: string, description: string, value: Pace }> = [
|
||||
@@ -53,10 +54,17 @@ const planningMode = ref<PlanningRequest['mode']>('quick')
|
||||
const durationMinutes = ref(4 * 60)
|
||||
const selectedPoiIds = ref<string[]>([])
|
||||
const customStartsFromCurrentLocation = ref(false)
|
||||
const locationFreshnessCheckedAt = ref(Date.now())
|
||||
const submitting = ref(false)
|
||||
const locationStore = useLocationStore()
|
||||
const remoteEnabled = computed(() => isRemoteTravelAssistantEnabled())
|
||||
const locationAvailable = computed(() => locationStore.status === 'ready' && Boolean(locationStore.snapshot))
|
||||
const locationAvailable = computed(() => {
|
||||
const checkedAt = locationFreshnessCheckedAt.value
|
||||
const snapshot = locationStore.snapshot
|
||||
return locationStore.status === 'ready'
|
||||
&& Boolean(snapshot)
|
||||
&& Number.isFinite(Date.parse(snapshot?.capturedAt ?? ''))
|
||||
&& checkedAt - Date.parse(snapshot?.capturedAt ?? '') <= LOCATION_MAX_AGE_MS
|
||||
})
|
||||
const poiSummaries = shallowRef<PoiSummary[]>(getPoiRepository().getPoiSummaries())
|
||||
const poiGroups = computed(() => {
|
||||
const groups = new Map<string, PoiSummary[]>()
|
||||
@@ -74,23 +82,28 @@ const durationText = computed(() => {
|
||||
})
|
||||
const usesCurrentLocation = computed(() => locationAvailable.value
|
||||
&& (planningMode.value === 'quick' || customStartsFromCurrentLocation.value))
|
||||
const planningEngineLabel = computed(() => remoteEnabled.value
|
||||
? '在线智能服务'
|
||||
: '本地智能规划')
|
||||
const planningEngineLabel = '本地 POC 规划'
|
||||
const locationActionText = computed(() => {
|
||||
if (locationStore.status === 'locating')
|
||||
return '定位中…'
|
||||
if (locationStore.status === 'denied')
|
||||
return '开启位置权限'
|
||||
return locationAvailable.value ? '刷新当前位置' : '获取当前位置'
|
||||
return locationStore.snapshot ? '刷新当前位置' : '获取当前位置'
|
||||
})
|
||||
const locationDescription = computed(() => {
|
||||
if (locationStore.status === 'locating')
|
||||
return '正在获取位置,请稍候。'
|
||||
if (locationStore.status === 'denied') {
|
||||
return planningMode.value === 'quick'
|
||||
? '位置权限未开启;请前往设置授权,或改用自选点位规划。'
|
||||
: '位置权限未开启;自选点位规划仍可继续使用。'
|
||||
}
|
||||
if (planningMode.value === 'quick') {
|
||||
return locationAvailable.value
|
||||
? '将从当前位置出发并优先安排较近点位;可随时刷新。'
|
||||
: '尚未获取位置,提交后会按光明区全域生成路线。'
|
||||
: locationStore.snapshot
|
||||
? '上次定位已超过 5 分钟,请刷新后再开始快速规划。'
|
||||
: '快速规划需要先主动获取当前位置。'
|
||||
}
|
||||
if (!locationAvailable.value)
|
||||
return '可选获取位置,用于优化自选点位的起点和顺序。'
|
||||
@@ -236,6 +249,7 @@ function locateFromUserAction() {
|
||||
uni.showToast({ title: '定位结果无效', icon: 'none' })
|
||||
return
|
||||
}
|
||||
locationFreshnessCheckedAt.value = Date.now()
|
||||
if (planningMode.value === 'custom')
|
||||
customStartsFromCurrentLocation.value = true
|
||||
uni.showToast({ title: '已更新当前位置', icon: 'success' })
|
||||
@@ -272,6 +286,7 @@ function showDurationFitNotice(status: 'overflow' | 'underfilled'): Promise<void
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
locationFreshnessCheckedAt.value = Date.now()
|
||||
if (!form.themes.length && !form.interests.length) {
|
||||
uni.showToast({ title: '请至少选择一项主题或偏好', icon: 'none' })
|
||||
return
|
||||
@@ -285,6 +300,16 @@ async function submit() {
|
||||
uni.showToast({ title: `请选择 ${MIN_CUSTOM_POIS}-${MAX_CUSTOM_POIS} 个点位`, icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (planningMode.value === 'quick' && !locationAvailable.value) {
|
||||
uni.showModal({
|
||||
title: '请先获取当前位置',
|
||||
content: '当前位置快速规划需要一次近期定位;如暂不授权,可改用自选点位规划。',
|
||||
confirmText: locationStore.status === 'denied' ? '去设置' : '立即定位',
|
||||
cancelText: '暂不使用',
|
||||
success: result => result.confirm && requestCurrentLocation(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
@@ -330,6 +355,7 @@ async function submit() {
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
locationFreshnessCheckedAt.value = Date.now()
|
||||
const storedRequest = loadPlanningRequest()
|
||||
const storedPreferences = storedRequest?.preferences ?? loadPreferences()
|
||||
if (storedPreferences)
|
||||
@@ -394,9 +420,7 @@ onShow(() => {
|
||||
<text class="planning-mode-card__copy">从全部 POI 中选 2-8 个编排</text>
|
||||
</button>
|
||||
</view>
|
||||
<text v-if="remoteEnabled && planningMode === 'custom'" class="planning-mode-tip">
|
||||
在线 AI 将在你选择的点位内建议顺序,本机负责定位、交通与时长核算。
|
||||
</text>
|
||||
<text class="planning-mode-tip">本期由本机完成选点、顺序、交通估算与时长核算。</text>
|
||||
</view>
|
||||
|
||||
<view class="planning-origin" :class="{ 'planning-origin--ready': locationAvailable }">
|
||||
@@ -624,7 +648,9 @@ onShow(() => {
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<text class="planner-footer">匿名使用 · 偏好和路线仅保存在本机</text>
|
||||
<text class="planner-footer">
|
||||
匿名使用 · 本期不调用 AI · 路线与偏好仅保存在本机
|
||||
</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -5,9 +5,12 @@ 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'
|
||||
@@ -26,6 +29,15 @@ function loadPoi(poiId: string) {
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -34,6 +46,16 @@ function loadPoi(poiId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
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: () => {
|
||||
@@ -65,6 +87,20 @@ onLoad((query) => {
|
||||
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>
|
||||
@@ -100,6 +136,15 @@ onLoad((query) => {
|
||||
<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>
|
||||
@@ -197,6 +242,7 @@ style:
|
||||
}
|
||||
|
||||
.info-card,
|
||||
.check-in-entry,
|
||||
.description-section,
|
||||
.poc-notice {
|
||||
padding: 28rpx;
|
||||
@@ -206,6 +252,68 @@ style:
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user