forked from zhouruizhe/gmTouringMiniApp
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33b9c53be6 | ||
|
|
09f8ec7ecf | ||
|
|
12380cd8cf | ||
|
|
14ca296d7d |
@@ -73,7 +73,7 @@ src/
|
|||||||
|
|
||||||
打卡为匿名本机 POC:`src/data/check-in/tasks.ts` 会为当前 POI 仓库中的全部已发布点位生成打卡任务;点位下线后会自动停止新打卡,重新发布后仍保留本机历史记录以防重复领奖。所有点位的游客入口坐标与安全到达范围仍需 iOS 现场复核后才能用于正式验收。每个开放点位首次成功打卡增加 10 积分,累计完成 1、2、3 个开放点位时依次解锁徽章。积分、徽章与足迹使用独立版本化存储键;v1 旧格式首次升级时只迁移当前开放点位记录,进入 v2 后则保留合法历史足迹。清除操作只删除打卡数据,不影响路线和偏好。打卡记录不保存经纬度、定位精度或轨迹。微信客户端无法可靠识别系统级缓存位置或虚拟定位,因此本期只承诺每次点击重新调用定位接口并校验微信返回的精度,不提供虚拟定位测试入口或生产级反作弊承诺。
|
打卡为匿名本机 POC:`src/data/check-in/tasks.ts` 会为当前 POI 仓库中的全部已发布点位生成打卡任务;点位下线后会自动停止新打卡,重新发布后仍保留本机历史记录以防重复领奖。所有点位的游客入口坐标与安全到达范围仍需 iOS 现场复核后才能用于正式验收。每个开放点位首次成功打卡增加 10 积分,累计完成 1、2、3 个开放点位时依次解锁徽章。积分、徽章与足迹使用独立版本化存储键;v1 旧格式首次升级时只迁移当前开放点位记录,进入 v2 后则保留合法历史足迹。清除操作只删除打卡数据,不影响路线和偏好。打卡记录不保存经纬度、定位精度或轨迹。微信客户端无法可靠识别系统级缓存位置或虚拟定位,因此本期只承诺每次点击重新调用定位接口并校验微信返回的精度,不提供虚拟定位测试入口或生产级反作弊承诺。
|
||||||
|
|
||||||
路由来自页面 SFC 的 `<route>`,由 UniPages 在编译时生成 `src/pages.json`;应用清单由 `manifest.config.ts` 生成 `src/manifest.json`。不要直接修改这两个生成文件,也不要让微信开发者工具读取 `src`;IDE 只读取 `dist/dev/mp-weixin`。仓库中的 `src/manifest.json` 以开发模式快照为准,`【dev】` / `【prod】` 项目名前缀由构建模式自动生成;提交时不应单独保留由最后一次构建造成的前缀漂移。
|
路由来自页面 SFC 的 `<route>`,由 UniPages 在编译时生成 `src/pages.json`;应用清单由 `manifest.config.ts` 生成 `src/manifest.json`。不要直接修改这两个生成文件,也不要让微信开发者工具读取 `src`;IDE 只读取 `dist/dev/mp-weixin`。
|
||||||
|
|
||||||
## POC 数据声明
|
## POC 数据声明
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -24,7 +24,7 @@
|
|||||||
"ignore": [],
|
"ignore": [],
|
||||||
"include": []
|
"include": []
|
||||||
},
|
},
|
||||||
"appid": "wxda4fb5048b5dc9a1",
|
"appid": "wx946b42048d117dde",
|
||||||
"editorSetting": {},
|
"editorSetting": {},
|
||||||
"condition": {}
|
"condition": {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ class PlanningMode(str, Enum):
|
|||||||
CUSTOM = "custom"
|
CUSTOM = "custom"
|
||||||
|
|
||||||
|
|
||||||
|
class Duration(str, Enum):
|
||||||
|
HALF_DAY = "half_day"
|
||||||
|
FULL_DAY = "full_day"
|
||||||
|
|
||||||
|
|
||||||
class Pace(str, Enum):
|
class Pace(str, Enum):
|
||||||
RELAXED = "relaxed"
|
RELAXED = "relaxed"
|
||||||
MODERATE = "moderate"
|
MODERATE = "moderate"
|
||||||
@@ -37,6 +42,12 @@ class Transport(str, Enum):
|
|||||||
PUBLIC_TRANSPORT = "public_transport"
|
PUBLIC_TRANSPORT = "public_transport"
|
||||||
|
|
||||||
|
|
||||||
|
class BudgetLevel(str, Enum):
|
||||||
|
ECONOMY = "economy"
|
||||||
|
STANDARD = "standard"
|
||||||
|
QUALITY = "quality"
|
||||||
|
|
||||||
|
|
||||||
Theme = Literal["亲子", "情侣", "朋友", "银发", "研学"]
|
Theme = Literal["亲子", "情侣", "朋友", "银发", "研学"]
|
||||||
Interest = Literal["自然风光", "文化场馆", "生态科普", "美食", "摄影"]
|
Interest = Literal["自然风光", "文化场馆", "生态科普", "美食", "摄影"]
|
||||||
|
|
||||||
|
|||||||
@@ -18,11 +18,8 @@ export function formatOpeningHours(openingHours: PoiOpeningHours): string {
|
|||||||
return openingHours.displayText?.trim() || '开放时间待确认'
|
return openingHours.displayText?.trim() || '开放时间待确认'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatRecommendationLabel(source: RecommendationSource, index: number): string {
|
export function formatRecommendationLabel(_source: RecommendationSource, index: number): string {
|
||||||
const label = source === 'official_editorial'
|
return `推荐指数 ${index}/5`
|
||||||
? '官方推荐指数'
|
|
||||||
: 'POC 推荐指数(待审核)'
|
|
||||||
return `${label} ${index}/5`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function filterPois(pois: readonly Poi[], filter: PoiFilter = {}): Poi[] {
|
export function filterPois(pois: readonly Poi[], filter: PoiFilter = {}): Poi[] {
|
||||||
|
|||||||
@@ -294,13 +294,6 @@ function selectQuickPois(
|
|||||||
cursor = next
|
cursor = next
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selected.length === 0 && remaining.length > 0) {
|
|
||||||
throw new TravelValidationError(
|
|
||||||
'当前起点、交通方式与所选时长无法容纳首个点位',
|
|
||||||
'quick_route_unreachable',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return selected
|
return selected
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,8 @@ import {
|
|||||||
|
|
||||||
const DESTINATION = '深圳市光明区' as const
|
const DESTINATION = '深圳市光明区' as const
|
||||||
|
|
||||||
export type TravelValidationErrorCode = 'quick_route_unreachable'
|
|
||||||
|
|
||||||
export class TravelValidationError extends Error {
|
export class TravelValidationError extends Error {
|
||||||
constructor(message: string, readonly code?: TravelValidationErrorCode) {
|
constructor(message: string) {
|
||||||
super(message)
|
super(message)
|
||||||
this.name = 'TravelValidationError'
|
this.name = 'TravelValidationError'
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -46,7 +46,7 @@
|
|||||||
},
|
},
|
||||||
"quickapp": {},
|
"quickapp": {},
|
||||||
"mp-weixin": {
|
"mp-weixin": {
|
||||||
"appid": "wxda4fb5048b5dc9a1",
|
"appid": "wx946b42048d117dde",
|
||||||
"setting": {
|
"setting": {
|
||||||
"urlCheck": true
|
"urlCheck": true
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ interface BadgeView extends CheckInBadgeDefinition {
|
|||||||
|
|
||||||
const profile = ref<CheckInProfile>({ points: 0, badges: [], checkIns: [] })
|
const profile = ref<CheckInProfile>({ points: 0, badges: [], checkIns: [] })
|
||||||
const storageError = ref('')
|
const storageError = ref('')
|
||||||
const poiDirectoryError = ref('')
|
|
||||||
const availablePois = shallowRef<PoiResolved[]>([])
|
const availablePois = shallowRef<PoiResolved[]>([])
|
||||||
const badgeViews = computed<BadgeView[]>(() => CHECK_IN_BADGES.map(definition => ({
|
const badgeViews = computed<BadgeView[]>(() => CHECK_IN_BADGES.map(definition => ({
|
||||||
...definition,
|
...definition,
|
||||||
@@ -32,20 +31,7 @@ function formatTime(value: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openPoi(poiId: string) {
|
function openPoi(poiId: string) {
|
||||||
let poi: PoiResolved | null = null
|
if (!getPoiRepository().getPoiById(poiId)) {
|
||||||
try {
|
|
||||||
poi = getPoiRepository().getPoiById(poiId)
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
poiDirectoryError.value = error instanceof Error ? error.message : '开放打卡点位暂时无法读取'
|
|
||||||
uni.showModal({
|
|
||||||
title: '暂时无法打开点位',
|
|
||||||
content: '开放点位目录暂时无法读取,请稍后重新加载。',
|
|
||||||
showCancel: false,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!poi) {
|
|
||||||
uni.showModal({
|
uni.showModal({
|
||||||
title: '点位已下线',
|
title: '点位已下线',
|
||||||
content: '该点位暂不在当前开放列表中,本机历史打卡记录仍会保留。',
|
content: '该点位暂不在当前开放列表中,本机历史打卡记录仍会保留。',
|
||||||
@@ -56,35 +42,12 @@ function openPoi(poiId: string) {
|
|||||||
uni.navigateTo({ url: `/pages/poi/detail?poiId=${encodeURIComponent(poiId)}` })
|
uni.navigateTo({ url: `/pages/poi/detail?poiId=${encodeURIComponent(poiId)}` })
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadProfile() {
|
|
||||||
try {
|
|
||||||
profile.value = getCheckInProfile()
|
|
||||||
storageError.value = ''
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
profile.value = { points: 0, badges: [], checkIns: [] }
|
|
||||||
storageError.value = error instanceof Error ? error.message : '本机打卡记录暂时无法读取'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadAvailablePois() {
|
function loadAvailablePois() {
|
||||||
try {
|
const repository = getPoiRepository()
|
||||||
const repository = getPoiRepository()
|
availablePois.value = getCheckInTasks().flatMap((task) => {
|
||||||
availablePois.value = getCheckInTasks().flatMap((task) => {
|
const poi = repository.getPoiById(task.poiId)
|
||||||
const poi = repository.getPoiById(task.poiId)
|
return poi ? [poi] : []
|
||||||
return poi ? [poi] : []
|
})
|
||||||
})
|
|
||||||
poiDirectoryError.value = ''
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
availablePois.value = []
|
|
||||||
poiDirectoryError.value = error instanceof Error ? error.message : '开放打卡点位暂时无法读取'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadRecords() {
|
|
||||||
loadProfile()
|
|
||||||
loadAvailablePois()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function poiCheckedIn(poiId: string): boolean {
|
function poiCheckedIn(poiId: string): boolean {
|
||||||
@@ -117,7 +80,16 @@ function clearLocalRecords() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
onShow(loadRecords)
|
onShow(() => {
|
||||||
|
loadAvailablePois()
|
||||||
|
try {
|
||||||
|
profile.value = getCheckInProfile()
|
||||||
|
storageError.value = ''
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
storageError.value = error instanceof Error ? error.message : '本机打卡记录暂时无法读取'
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -126,9 +98,9 @@ onShow(loadRecords)
|
|||||||
<text class="records-hero__eyebrow">LOCAL JOURNEY</text>
|
<text class="records-hero__eyebrow">LOCAL JOURNEY</text>
|
||||||
<text class="records-hero__title">我的光明足迹</text>
|
<text class="records-hero__title">我的光明足迹</text>
|
||||||
<view class="records-stats">
|
<view class="records-stats">
|
||||||
<view><text>{{ storageError ? '--' : profile.points }}</text><text>本机积分</text></view>
|
<view><text>{{ profile.points }}</text><text>本机积分</text></view>
|
||||||
<view><text>{{ storageError ? '--' : profile.checkIns.length }}</text><text>打卡点位</text></view>
|
<view><text>{{ profile.checkIns.length }}</text><text>打卡点位</text></view>
|
||||||
<view><text>{{ storageError ? '--' : profile.badges.length }}</text><text>已获徽章</text></view>
|
<view><text>{{ profile.badges.length }}</text><text>已获徽章</text></view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -139,13 +111,7 @@ onShow(loadRecords)
|
|||||||
<button @click="clearLocalRecords">清除异常打卡数据</button>
|
<button @click="clearLocalRecords">清除异常打卡数据</button>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-if="poiDirectoryError" class="records-error">
|
<view class="records-section">
|
||||||
<text class="records-error__title">开放点位暂时无法读取</text>
|
|
||||||
<text>{{ poiDirectoryError }}</text>
|
|
||||||
<button @click="loadAvailablePois">重新加载开放点位</button>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view v-if="!storageError" class="records-section">
|
|
||||||
<view class="records-section__heading">
|
<view class="records-section__heading">
|
||||||
<view><text class="records-section__kicker">BADGES</text><text class="records-section__title">光明徽章</text></view>
|
<view><text class="records-section__kicker">BADGES</text><text class="records-section__title">光明徽章</text></view>
|
||||||
<text>{{ profile.badges.length }} / {{ badgeViews.length }}</text>
|
<text>{{ profile.badges.length }} / {{ badgeViews.length }}</text>
|
||||||
@@ -165,7 +131,7 @@ onShow(loadRecords)
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-if="!poiDirectoryError && !storageError" class="records-section">
|
<view class="records-section">
|
||||||
<view class="records-section__heading">
|
<view class="records-section__heading">
|
||||||
<view><text class="records-section__kicker">PLACES</text><text class="records-section__title">开放打卡点位</text></view>
|
<view><text class="records-section__kicker">PLACES</text><text class="records-section__title">开放打卡点位</text></view>
|
||||||
<text>{{ availableCheckInCount }} / {{ availablePois.length }}</text>
|
<text>{{ availableCheckInCount }} / {{ availablePois.length }}</text>
|
||||||
@@ -184,7 +150,7 @@ onShow(loadRecords)
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-if="!storageError" class="records-section">
|
<view class="records-section">
|
||||||
<view class="records-section__heading">
|
<view class="records-section__heading">
|
||||||
<view><text class="records-section__kicker">HISTORY</text><text class="records-section__title">打卡记录</text></view>
|
<view><text class="records-section__kicker">HISTORY</text><text class="records-section__title">打卡记录</text></view>
|
||||||
<text>{{ profile.checkIns.length }} 条</text>
|
<text>{{ profile.checkIns.length }} 条</text>
|
||||||
|
|||||||
@@ -624,10 +624,6 @@ onUnload(stopForegroundLocationUpdates)
|
|||||||
@close="mapStore.selectPoi(null)"
|
@close="mapStore.selectPoi(null)"
|
||||||
@detail="openDetail"
|
@detail="openDetail"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<view class="map-page__disclaimer">
|
|
||||||
{{ datasetMeta?.disclaimer }}
|
|
||||||
</view>
|
|
||||||
</template>
|
</template>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
@@ -745,14 +741,4 @@ style:
|
|||||||
border-radius: 28rpx;
|
border-radius: 28rpx;
|
||||||
box-shadow: 0 8rpx 24rpx rgb(24 32 29 / 16%);
|
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));
|
|
||||||
font-size: 20rpx;
|
|
||||||
line-height: 30rpx;
|
|
||||||
color: #8a9690;
|
|
||||||
text-align: center;
|
|
||||||
background: #f5f7f6;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
import type { PoiSummary } from '@/domain/poi'
|
import type { PoiSummary } from '@/domain/poi'
|
||||||
import type { BudgetLevel, Interest, Pace, PlanningRequest, Theme, Transport, TravelPreferences } from '@/domain/travel'
|
import type { BudgetLevel, Interest, Pace, PlanningRequest, Theme, Transport, TravelPreferences } from '@/domain/travel'
|
||||||
import { getPoiRepository } from '@/data/poi'
|
import { getPoiRepository } from '@/data/poi'
|
||||||
import { TravelValidationError } from '@/domain/travel'
|
|
||||||
import { isLocationSnapshotFresh } from '@/services/location'
|
|
||||||
import { createPlan, loadPlanningRequest, loadPreferences, savePlan, savePreferences } from '@/services/travel-assistant'
|
import { createPlan, loadPlanningRequest, loadPreferences, savePlan, savePreferences } from '@/services/travel-assistant'
|
||||||
import { useLocationStore } from '@/stores'
|
import { useLocationStore } from '@/stores'
|
||||||
|
|
||||||
@@ -58,18 +56,14 @@ const selectedPoiIds = ref<string[]>([])
|
|||||||
const customStartsFromCurrentLocation = ref(false)
|
const customStartsFromCurrentLocation = ref(false)
|
||||||
const locationFreshnessCheckedAt = ref(Date.now())
|
const locationFreshnessCheckedAt = ref(Date.now())
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
let locationFreshnessTimer: ReturnType<typeof setTimeout> | null = null
|
|
||||||
let pageVisible = false
|
|
||||||
const locationStore = useLocationStore()
|
const locationStore = useLocationStore()
|
||||||
const locationAvailable = computed(() => {
|
const locationAvailable = computed(() => {
|
||||||
|
const checkedAt = locationFreshnessCheckedAt.value
|
||||||
const snapshot = locationStore.snapshot
|
const snapshot = locationStore.snapshot
|
||||||
return locationStore.status === 'ready'
|
return locationStore.status === 'ready'
|
||||||
&& Boolean(snapshot)
|
&& Boolean(snapshot)
|
||||||
&& isLocationSnapshotFresh(
|
&& Number.isFinite(Date.parse(snapshot?.capturedAt ?? ''))
|
||||||
snapshot?.capturedAt ?? '',
|
&& checkedAt - Date.parse(snapshot?.capturedAt ?? '') <= LOCATION_MAX_AGE_MS
|
||||||
locationFreshnessCheckedAt.value,
|
|
||||||
LOCATION_MAX_AGE_MS,
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
const poiSummaries = shallowRef<PoiSummary[]>(getPoiRepository().getPoiSummaries())
|
const poiSummaries = shallowRef<PoiSummary[]>(getPoiRepository().getPoiSummaries())
|
||||||
const poiGroups = computed(() => {
|
const poiGroups = computed(() => {
|
||||||
@@ -223,32 +217,6 @@ function isPermissionDenied(error: unknown): boolean {
|
|||||||
return /auth deny|auth denied|authorize:fail|permission/i.test(message)
|
return /auth deny|auth denied|authorize:fail|permission/i.test(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearLocationFreshnessTimer() {
|
|
||||||
if (locationFreshnessTimer === null)
|
|
||||||
return
|
|
||||||
clearTimeout(locationFreshnessTimer)
|
|
||||||
locationFreshnessTimer = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function refreshLocationFreshness() {
|
|
||||||
clearLocationFreshnessTimer()
|
|
||||||
const checkedAt = Date.now()
|
|
||||||
locationFreshnessCheckedAt.value = checkedAt
|
|
||||||
const snapshot = locationStore.snapshot
|
|
||||||
if (locationStore.status !== 'ready' || !snapshot)
|
|
||||||
return
|
|
||||||
if (!pageVisible)
|
|
||||||
return
|
|
||||||
|
|
||||||
const capturedAt = Date.parse(snapshot.capturedAt)
|
|
||||||
if (!Number.isFinite(capturedAt) || capturedAt > checkedAt)
|
|
||||||
return
|
|
||||||
const expiresInMs = capturedAt + LOCATION_MAX_AGE_MS - checkedAt
|
|
||||||
if (expiresInMs < 0)
|
|
||||||
return
|
|
||||||
locationFreshnessTimer = setTimeout(refreshLocationFreshness, expiresInMs + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
function requestCurrentLocation() {
|
function requestCurrentLocation() {
|
||||||
if (locationStore.status === 'locating')
|
if (locationStore.status === 'locating')
|
||||||
return
|
return
|
||||||
@@ -266,7 +234,6 @@ function requestCurrentLocation() {
|
|||||||
|
|
||||||
function locateFromUserAction() {
|
function locateFromUserAction() {
|
||||||
locationStore.startLocating()
|
locationStore.startLocating()
|
||||||
refreshLocationFreshness()
|
|
||||||
uni.getLocation({
|
uni.getLocation({
|
||||||
type: 'gcj02',
|
type: 'gcj02',
|
||||||
isHighAccuracy: true,
|
isHighAccuracy: true,
|
||||||
@@ -282,7 +249,7 @@ function locateFromUserAction() {
|
|||||||
uni.showToast({ title: '定位结果无效', icon: 'none' })
|
uni.showToast({ title: '定位结果无效', icon: 'none' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
refreshLocationFreshness()
|
locationFreshnessCheckedAt.value = Date.now()
|
||||||
if (planningMode.value === 'custom')
|
if (planningMode.value === 'custom')
|
||||||
customStartsFromCurrentLocation.value = true
|
customStartsFromCurrentLocation.value = true
|
||||||
uni.showToast({ title: '已更新当前位置', icon: 'success' })
|
uni.showToast({ title: '已更新当前位置', icon: 'success' })
|
||||||
@@ -319,7 +286,7 @@ function showDurationFitNotice(status: 'overflow' | 'underfilled'): Promise<void
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
refreshLocationFreshness()
|
locationFreshnessCheckedAt.value = Date.now()
|
||||||
if (!form.themes.length && !form.interests.length) {
|
if (!form.themes.length && !form.interests.length) {
|
||||||
uni.showToast({ title: '请至少选择一项主题或偏好', icon: 'none' })
|
uni.showToast({ title: '请至少选择一项主题或偏好', icon: 'none' })
|
||||||
return
|
return
|
||||||
@@ -376,13 +343,9 @@ async function submit() {
|
|||||||
uni.navigateTo({ url: '/pages/itinerary/index' })
|
uni.navigateTo({ url: '/pages/itinerary/index' })
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
const quickRouteUnreachable = error instanceof TravelValidationError
|
|
||||||
&& error.code === 'quick_route_unreachable'
|
|
||||||
uni.showModal({
|
uni.showModal({
|
||||||
title: quickRouteUnreachable ? '当前路线无法完成' : '暂时无法生成路线',
|
title: '暂时无法生成路线',
|
||||||
content: quickRouteUnreachable
|
content: error instanceof Error ? error.message : '请检查选择后重试',
|
||||||
? '当前起点、交通方式与所选时长无法容纳首个点位。请靠近光明区、延长游玩时长,或改用自选点位规划。'
|
|
||||||
: error instanceof Error ? error.message : '请检查选择后重试',
|
|
||||||
showCancel: false,
|
showCancel: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -392,8 +355,7 @@ async function submit() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onShow(() => {
|
onShow(() => {
|
||||||
pageVisible = true
|
locationFreshnessCheckedAt.value = Date.now()
|
||||||
refreshLocationFreshness()
|
|
||||||
const storedRequest = loadPlanningRequest()
|
const storedRequest = loadPlanningRequest()
|
||||||
const storedPreferences = storedRequest?.preferences ?? loadPreferences()
|
const storedPreferences = storedRequest?.preferences ?? loadPreferences()
|
||||||
if (storedPreferences)
|
if (storedPreferences)
|
||||||
@@ -409,14 +371,6 @@ onShow(() => {
|
|||||||
}
|
}
|
||||||
applyPreset()
|
applyPreset()
|
||||||
})
|
})
|
||||||
|
|
||||||
function deactivatePlannerPage() {
|
|
||||||
pageVisible = false
|
|
||||||
clearLocationFreshnessTimer()
|
|
||||||
}
|
|
||||||
|
|
||||||
onHide(deactivatePlannerPage)
|
|
||||||
onUnload(deactivatePlannerPage)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -5,23 +5,6 @@ interface CurrentLocationOptions {
|
|||||||
timeoutMs?: number
|
timeoutMs?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isLocationSnapshotFresh(
|
|
||||||
capturedAt: string,
|
|
||||||
checkedAt = Date.now(),
|
|
||||||
maxAgeMs = 5 * 60 * 1000,
|
|
||||||
): boolean {
|
|
||||||
const capturedTimestamp = Date.parse(capturedAt)
|
|
||||||
if (!Number.isFinite(capturedTimestamp)
|
|
||||||
|| !Number.isFinite(checkedAt)
|
|
||||||
|| !Number.isFinite(maxAgeMs)
|
|
||||||
|| maxAgeMs < 0) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const ageMs = checkedAt - capturedTimestamp
|
|
||||||
return ageMs >= 0 && ageMs <= maxAgeMs
|
|
||||||
}
|
|
||||||
|
|
||||||
function locationOptionsSupported(): boolean {
|
function locationOptionsSupported(): boolean {
|
||||||
try {
|
try {
|
||||||
return typeof uni.canIUse !== 'function'
|
return typeof uni.canIUse !== 'function'
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import {
|
import { getCurrentGcj02Location, isLocationPermissionDenied } from '../src/services/location'
|
||||||
getCurrentGcj02Location,
|
|
||||||
isLocationPermissionDenied,
|
|
||||||
isLocationSnapshotFresh,
|
|
||||||
} from '../src/services/location'
|
|
||||||
|
|
||||||
describe('current location adapter', () => {
|
describe('current location adapter', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -77,16 +73,4 @@ describe('current location adapter', () => {
|
|||||||
expect(isLocationPermissionDenied({ errMsg: 'getLocation:fail privacy permission is not authorized' })).toBe(false)
|
expect(isLocationPermissionDenied({ errMsg: 'getLocation:fail privacy permission is not authorized' })).toBe(false)
|
||||||
expect(isLocationPermissionDenied({ errMsg: 'getLocation:fail system error' })).toBe(false)
|
expect(isLocationPermissionDenied({ errMsg: 'getLocation:fail system error' })).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('evaluates location freshness at exact boundaries and rejects invalid clocks', () => {
|
|
||||||
const capturedAt = '2026-07-31T02:00:00.000Z'
|
|
||||||
const capturedTimestamp = Date.parse(capturedAt)
|
|
||||||
const maxAgeMs = 5 * 60 * 1000
|
|
||||||
|
|
||||||
expect(isLocationSnapshotFresh(capturedAt, capturedTimestamp, maxAgeMs)).toBe(true)
|
|
||||||
expect(isLocationSnapshotFresh(capturedAt, capturedTimestamp + maxAgeMs, maxAgeMs)).toBe(true)
|
|
||||||
expect(isLocationSnapshotFresh(capturedAt, capturedTimestamp + maxAgeMs + 1, maxAgeMs)).toBe(false)
|
|
||||||
expect(isLocationSnapshotFresh(capturedAt, capturedTimestamp - 1, maxAgeMs)).toBe(false)
|
|
||||||
expect(isLocationSnapshotFresh('invalid', capturedTimestamp, maxAgeMs)).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
type PlanResponse,
|
type PlanResponse,
|
||||||
resolveCanonicalPoiId,
|
resolveCanonicalPoiId,
|
||||||
type TravelPreferences,
|
type TravelPreferences,
|
||||||
TravelValidationError,
|
|
||||||
} from '../src/domain/travel'
|
} from '../src/domain/travel'
|
||||||
import {
|
import {
|
||||||
createPlan,
|
createPlan,
|
||||||
@@ -223,22 +222,13 @@ describe('deterministic local POC planner', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('rejects a quick plan when even the first stop cannot fit the budget', () => {
|
it('rejects a quick plan when even the first stop cannot fit the budget', () => {
|
||||||
let thrown: unknown
|
expect(() => createLocalPlan({
|
||||||
try {
|
mode: 'quick',
|
||||||
createLocalPlan({
|
preferences: preferences({ pace: 'relaxed', transport: 'walking' }),
|
||||||
mode: 'quick',
|
durationMinutes: 120,
|
||||||
preferences: preferences({ pace: 'relaxed', transport: 'walking' }),
|
selectedPoiIds: [],
|
||||||
durationMinutes: 120,
|
origin: { longitude: 114.3, latitude: 22.3, coordinateSystem: 'GCJ02' },
|
||||||
selectedPoiIds: [],
|
}, repository)).toThrow('没有可用于规划的地点')
|
||||||
origin: { longitude: 114.3, latitude: 22.3, coordinateSystem: 'GCJ02' },
|
|
||||||
}, repository)
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
thrown = error
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(thrown).toBeInstanceOf(TravelValidationError)
|
|
||||||
expect(thrown).toMatchObject({ code: 'quick_route_unreachable' })
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns the same route and canonical IDs for equivalent input', () => {
|
it('returns the same route and canonical IDs for equivalent input', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user