2 Commits
Author SHA1 Message Date
admin b1e0cae07a Merge pull request 'Update planner, check-in records, location service and tests' (#10) from zhouruizhe into main
Reviewed-on: #10
2026-08-01 22:01:35 +08:00
周瑞哲 c0ed0048aa Update planner, check-in records, location service and tests
AI Code Review / review (pull_request) Successful in 6m54s
2026-07-31 15:12:21 +08:00
11 changed files with 174 additions and 53 deletions
+1 -1
View File
@@ -73,7 +73,7 @@ src/
打卡为匿名本机 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`
路由来自页面 SFC 的 `<route>`,由 UniPages 在编译时生成 `src/pages.json`;应用清单由 `manifest.config.ts` 生成 `src/manifest.json`。不要直接修改这两个生成文件,也不要让微信开发者工具读取 `src`IDE 只读取 `dist/dev/mp-weixin`仓库中的 `src/manifest.json` 以开发模式快照为准,`【dev】` / `【prod】` 项目名前缀由构建模式自动生成;提交时不应单独保留由最后一次构建造成的前缀漂移。
## POC 数据声明
+1 -1
View File
@@ -24,7 +24,7 @@
"ignore": [],
"include": []
},
"appid": "wx946b42048d117dde",
"appid": "wxda4fb5048b5dc9a1",
"editorSetting": {},
"condition": {}
}
-11
View File
@@ -25,11 +25,6 @@ class PlanningMode(str, Enum):
CUSTOM = "custom"
class Duration(str, Enum):
HALF_DAY = "half_day"
FULL_DAY = "full_day"
class Pace(str, Enum):
RELAXED = "relaxed"
MODERATE = "moderate"
@@ -42,12 +37,6 @@ class Transport(str, Enum):
PUBLIC_TRANSPORT = "public_transport"
class BudgetLevel(str, Enum):
ECONOMY = "economy"
STANDARD = "standard"
QUALITY = "quality"
Theme = Literal["亲子", "情侣", "朋友", "银发", "研学"]
Interest = Literal["自然风光", "文化场馆", "生态科普", "美食", "摄影"]
+7
View File
@@ -294,6 +294,13 @@ function selectQuickPois(
cursor = next
}
if (selected.length === 0 && remaining.length > 0) {
throw new TravelValidationError(
'当前起点、交通方式与所选时长无法容纳首个点位',
'quick_route_unreachable',
)
}
return selected
}
+3 -1
View File
@@ -16,8 +16,10 @@ import {
const DESTINATION = '深圳市光明区' as const
export type TravelValidationErrorCode = 'quick_route_unreachable'
export class TravelValidationError extends Error {
constructor(message: string) {
constructor(message: string, readonly code?: TravelValidationErrorCode) {
super(message)
this.name = 'TravelValidationError'
}
+1 -1
View File
@@ -46,7 +46,7 @@
},
"quickapp": {},
"mp-weixin": {
"appid": "wx946b42048d117dde",
"appid": "wxda4fb5048b5dc9a1",
"setting": {
"urlCheck": true
},
+56 -22
View File
@@ -12,6 +12,7 @@ interface BadgeView extends CheckInBadgeDefinition {
const profile = ref<CheckInProfile>({ points: 0, badges: [], checkIns: [] })
const storageError = ref('')
const poiDirectoryError = ref('')
const availablePois = shallowRef<PoiResolved[]>([])
const badgeViews = computed<BadgeView[]>(() => CHECK_IN_BADGES.map(definition => ({
...definition,
@@ -31,7 +32,20 @@ function formatTime(value: string): string {
}
function openPoi(poiId: string) {
if (!getPoiRepository().getPoiById(poiId)) {
let poi: PoiResolved | null = null
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({
title: '点位已下线',
content: '该点位暂不在当前开放列表中,本机历史打卡记录仍会保留。',
@@ -42,12 +56,35 @@ function openPoi(poiId: string) {
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() {
const repository = getPoiRepository()
availablePois.value = getCheckInTasks().flatMap((task) => {
const poi = repository.getPoiById(task.poiId)
return poi ? [poi] : []
})
try {
const repository = getPoiRepository()
availablePois.value = getCheckInTasks().flatMap((task) => {
const poi = repository.getPoiById(task.poiId)
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 {
@@ -80,16 +117,7 @@ function clearLocalRecords() {
})
}
onShow(() => {
loadAvailablePois()
try {
profile.value = getCheckInProfile()
storageError.value = ''
}
catch (error) {
storageError.value = error instanceof Error ? error.message : '本机打卡记录暂时无法读取'
}
})
onShow(loadRecords)
</script>
<template>
@@ -98,9 +126,9 @@ onShow(() => {
<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><text>{{ storageError ? '--' : profile.points }}</text><text>本机积分</text></view>
<view><text>{{ storageError ? '--' : profile.checkIns.length }}</text><text>打卡点位</text></view>
<view><text>{{ storageError ? '--' : profile.badges.length }}</text><text>已获徽章</text></view>
</view>
</view>
@@ -111,7 +139,13 @@ onShow(() => {
<button @click="clearLocalRecords">清除异常打卡数据</button>
</view>
<view class="records-section">
<view v-if="poiDirectoryError" class="records-error">
<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><text class="records-section__kicker">BADGES</text><text class="records-section__title">光明徽章</text></view>
<text>{{ profile.badges.length }} / {{ badgeViews.length }}</text>
@@ -131,7 +165,7 @@ onShow(() => {
</view>
</view>
<view class="records-section">
<view v-if="!poiDirectoryError && !storageError" 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>
@@ -150,7 +184,7 @@ onShow(() => {
</view>
</view>
<view class="records-section">
<view v-if="!storageError" 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>
+54 -8
View File
@@ -2,6 +2,8 @@
import type { PoiSummary } from '@/domain/poi'
import type { BudgetLevel, Interest, Pace, PlanningRequest, Theme, Transport, TravelPreferences } from '@/domain/travel'
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 { useLocationStore } from '@/stores'
@@ -56,14 +58,18 @@ const selectedPoiIds = ref<string[]>([])
const customStartsFromCurrentLocation = ref(false)
const locationFreshnessCheckedAt = ref(Date.now())
const submitting = ref(false)
let locationFreshnessTimer: ReturnType<typeof setTimeout> | null = null
let pageVisible = false
const locationStore = useLocationStore()
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
&& isLocationSnapshotFresh(
snapshot?.capturedAt ?? '',
locationFreshnessCheckedAt.value,
LOCATION_MAX_AGE_MS,
)
})
const poiSummaries = shallowRef<PoiSummary[]>(getPoiRepository().getPoiSummaries())
const poiGroups = computed(() => {
@@ -217,6 +223,32 @@ function isPermissionDenied(error: unknown): boolean {
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() {
if (locationStore.status === 'locating')
return
@@ -234,6 +266,7 @@ function requestCurrentLocation() {
function locateFromUserAction() {
locationStore.startLocating()
refreshLocationFreshness()
uni.getLocation({
type: 'gcj02',
isHighAccuracy: true,
@@ -249,7 +282,7 @@ function locateFromUserAction() {
uni.showToast({ title: '定位结果无效', icon: 'none' })
return
}
locationFreshnessCheckedAt.value = Date.now()
refreshLocationFreshness()
if (planningMode.value === 'custom')
customStartsFromCurrentLocation.value = true
uni.showToast({ title: '已更新当前位置', icon: 'success' })
@@ -286,7 +319,7 @@ function showDurationFitNotice(status: 'overflow' | 'underfilled'): Promise<void
}
async function submit() {
locationFreshnessCheckedAt.value = Date.now()
refreshLocationFreshness()
if (!form.themes.length && !form.interests.length) {
uni.showToast({ title: '请至少选择一项主题或偏好', icon: 'none' })
return
@@ -343,9 +376,13 @@ async function submit() {
uni.navigateTo({ url: '/pages/itinerary/index' })
}
catch (error) {
const quickRouteUnreachable = error instanceof TravelValidationError
&& error.code === 'quick_route_unreachable'
uni.showModal({
title: '暂时无法生成路线',
content: error instanceof Error ? error.message : '请检查选择后重试',
title: quickRouteUnreachable ? '当前路线无法完成' : '暂时无法生成路线',
content: quickRouteUnreachable
? '当前起点、交通方式与所选时长无法容纳首个点位。请靠近光明区、延长游玩时长,或改用自选点位规划。'
: error instanceof Error ? error.message : '请检查选择后重试',
showCancel: false,
})
}
@@ -355,7 +392,8 @@ async function submit() {
}
onShow(() => {
locationFreshnessCheckedAt.value = Date.now()
pageVisible = true
refreshLocationFreshness()
const storedRequest = loadPlanningRequest()
const storedPreferences = storedRequest?.preferences ?? loadPreferences()
if (storedPreferences)
@@ -371,6 +409,14 @@ onShow(() => {
}
applyPreset()
})
function deactivatePlannerPage() {
pageVisible = false
clearLocationFreshnessTimer()
}
onHide(deactivatePlannerPage)
onUnload(deactivatePlannerPage)
</script>
<template>
+17
View File
@@ -5,6 +5,23 @@ interface CurrentLocationOptions {
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 {
try {
return typeof uni.canIUse !== 'function'
+17 -1
View File
@@ -1,5 +1,9 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getCurrentGcj02Location, isLocationPermissionDenied } from '../src/services/location'
import {
getCurrentGcj02Location,
isLocationPermissionDenied,
isLocationSnapshotFresh,
} from '../src/services/location'
describe('current location adapter', () => {
beforeEach(() => {
@@ -73,4 +77,16 @@ describe('current location adapter', () => {
expect(isLocationPermissionDenied({ errMsg: 'getLocation:fail privacy permission is not authorized' })).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)
})
})
+17 -7
View File
@@ -15,6 +15,7 @@ import {
type PlanResponse,
resolveCanonicalPoiId,
type TravelPreferences,
TravelValidationError,
} from '../src/domain/travel'
import {
createPlan,
@@ -222,13 +223,22 @@ describe('deterministic local POC planner', () => {
})
it('rejects a quick plan when even the first stop cannot fit the budget', () => {
expect(() => createLocalPlan({
mode: 'quick',
preferences: preferences({ pace: 'relaxed', transport: 'walking' }),
durationMinutes: 120,
selectedPoiIds: [],
origin: { longitude: 114.3, latitude: 22.3, coordinateSystem: 'GCJ02' },
}, repository)).toThrow('没有可用于规划的地点')
let thrown: unknown
try {
createLocalPlan({
mode: 'quick',
preferences: preferences({ pace: 'relaxed', transport: 'walking' }),
durationMinutes: 120,
selectedPoiIds: [],
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', () => {