Add check-in feature and update travel planner UI
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
export { checkInAtPoi, getCheckInProfile } from './service'
|
||||
export {
|
||||
CHECK_IN_STORAGE_KEY,
|
||||
CheckInStorageError,
|
||||
clearCheckInProfile,
|
||||
loadCheckInProfile,
|
||||
saveCheckInProfile,
|
||||
} from './storage'
|
||||
@@ -0,0 +1,27 @@
|
||||
import { getCheckInTask } from '@/data/check-in'
|
||||
import { getPoiRepository } from '@/data/poi'
|
||||
import {
|
||||
applyCheckIn,
|
||||
type CheckInLocationSnapshot,
|
||||
type CheckInProfile,
|
||||
type CheckInResult,
|
||||
} from '@/domain/check-in'
|
||||
import { loadCheckInProfile, saveCheckInProfile } from './storage'
|
||||
|
||||
export function checkInAtPoi(
|
||||
poiId: string,
|
||||
location: CheckInLocationSnapshot,
|
||||
now = Date.now(),
|
||||
): CheckInResult {
|
||||
const poi = getPoiRepository().getPoiById(poiId)
|
||||
const task = poi ? getCheckInTask(poi.id) : null
|
||||
if (!poi || !task)
|
||||
throw new Error('该点位不存在或已下线,暂时无法打卡')
|
||||
const result = applyCheckIn(loadCheckInProfile(), poi, location, now, task.radiusMeters)
|
||||
saveCheckInProfile(result.profile)
|
||||
return result
|
||||
}
|
||||
|
||||
export function getCheckInProfile(): CheckInProfile {
|
||||
return loadCheckInProfile()
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { getCheckInTasks } from '@/data/check-in'
|
||||
import {
|
||||
CHECK_IN_BADGES,
|
||||
CHECK_IN_POINTS,
|
||||
type CheckInBadge,
|
||||
type CheckInProfile,
|
||||
type CheckInRecord,
|
||||
cloneCheckInProfile,
|
||||
createCheckInRecordId,
|
||||
createEmptyCheckInProfile,
|
||||
normalizeCheckInProfile,
|
||||
} from '@/domain/check-in'
|
||||
|
||||
export const CHECK_IN_STORAGE_KEY = 'guangming:check-in-profile'
|
||||
const CHECK_IN_STORAGE_SCHEMA_VERSION = 2
|
||||
const LEGACY_CHECK_IN_STORAGE_SCHEMA_VERSION = 1
|
||||
const envelopeKeys = new Set(['schemaVersion', 'savedAt', 'payload'])
|
||||
|
||||
interface CheckInStorageEnvelope {
|
||||
schemaVersion: typeof CHECK_IN_STORAGE_SCHEMA_VERSION
|
||||
savedAt: string
|
||||
payload: CheckInProfile
|
||||
}
|
||||
|
||||
export class CheckInStorageError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'CheckInStorageError'
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function readLegacyTimestamp(value: unknown): string | null {
|
||||
if (typeof value !== 'string' || !Number.isFinite(Date.parse(value)))
|
||||
return null
|
||||
return new Date(value).toISOString()
|
||||
}
|
||||
|
||||
function migrateLegacyProfile(value: unknown): CheckInProfile | null {
|
||||
if (!isRecord(value) || !Array.isArray(value.checkIns))
|
||||
return null
|
||||
|
||||
const enabledPoiIds = new Set(getCheckInTasks().map(task => task.poiId))
|
||||
const uniquePoiIds = new Set<string>()
|
||||
const checkIns: CheckInRecord[] = []
|
||||
for (const entry of value.checkIns) {
|
||||
if (!isRecord(entry))
|
||||
return null
|
||||
const poiId = typeof entry.poiId === 'string' ? entry.poiId.trim() : ''
|
||||
const poiName = typeof entry.poiName === 'string' ? entry.poiName.trim() : ''
|
||||
const checkedInAt = readLegacyTimestamp(entry.checkedInAt)
|
||||
if (!poiId || !poiName || !checkedInAt)
|
||||
return null
|
||||
if (!enabledPoiIds.has(poiId) || uniquePoiIds.has(poiId))
|
||||
continue
|
||||
uniquePoiIds.add(poiId)
|
||||
checkIns.push({
|
||||
id: createCheckInRecordId(poiId, checkedInAt),
|
||||
poiId,
|
||||
poiName,
|
||||
checkedInAt,
|
||||
pointsAwarded: CHECK_IN_POINTS,
|
||||
})
|
||||
}
|
||||
|
||||
checkIns.sort((left, right) => right.checkedInAt.localeCompare(left.checkedInAt))
|
||||
const badges: CheckInBadge[] = CHECK_IN_BADGES
|
||||
.filter(definition => checkIns.length >= definition.requiredCheckIns)
|
||||
.map((definition) => {
|
||||
const thresholdRecord = checkIns[checkIns.length - definition.requiredCheckIns]
|
||||
return { code: definition.code, obtainedAt: thresholdRecord.checkedInAt }
|
||||
})
|
||||
return normalizeCheckInProfile({
|
||||
points: checkIns.length * CHECK_IN_POINTS,
|
||||
badges,
|
||||
checkIns,
|
||||
})
|
||||
}
|
||||
|
||||
function readValidatedProfile(value: Record<string, unknown>): { profile: CheckInProfile, migrated: boolean } | null {
|
||||
if (value.schemaVersion === CHECK_IN_STORAGE_SCHEMA_VERSION) {
|
||||
const profile = normalizeCheckInProfile(value.payload)
|
||||
return profile ? { profile, migrated: false } : null
|
||||
}
|
||||
if (value.schemaVersion === LEGACY_CHECK_IN_STORAGE_SCHEMA_VERSION) {
|
||||
const profile = migrateLegacyProfile(value.payload)
|
||||
return profile ? { profile, migrated: true } : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function loadCheckInProfile(): CheckInProfile {
|
||||
let value: unknown
|
||||
try {
|
||||
value = uni.getStorageSync(CHECK_IN_STORAGE_KEY)
|
||||
}
|
||||
catch (error) {
|
||||
const reason = error instanceof Error && error.message ? `:${error.message}` : ''
|
||||
throw new CheckInStorageError(`无法读取本机打卡记录${reason}`)
|
||||
}
|
||||
|
||||
if (value === undefined || value === null || value === '')
|
||||
return createEmptyCheckInProfile()
|
||||
|
||||
if (!isRecord(value)
|
||||
|| Object.keys(value).some(key => !envelopeKeys.has(key))
|
||||
|| typeof value.savedAt !== 'string'
|
||||
|| !Number.isFinite(Date.parse(value.savedAt))) {
|
||||
throw new CheckInStorageError('本机打卡记录异常,请先在“我的打卡”中清除后重试')
|
||||
}
|
||||
const result = readValidatedProfile(value)
|
||||
if (!result)
|
||||
throw new CheckInStorageError('本机打卡记录异常,请先在“我的打卡”中清除后重试')
|
||||
if (result.migrated)
|
||||
saveCheckInProfile(result.profile)
|
||||
return result.profile
|
||||
}
|
||||
|
||||
export function saveCheckInProfile(profile: CheckInProfile): void {
|
||||
const normalized = normalizeCheckInProfile(profile)
|
||||
if (!normalized)
|
||||
throw new Error('打卡记录格式无效,未写入本机')
|
||||
|
||||
const envelope: CheckInStorageEnvelope = {
|
||||
schemaVersion: CHECK_IN_STORAGE_SCHEMA_VERSION,
|
||||
savedAt: new Date().toISOString(),
|
||||
payload: cloneCheckInProfile(normalized),
|
||||
}
|
||||
try {
|
||||
uni.setStorageSync(CHECK_IN_STORAGE_KEY, envelope)
|
||||
}
|
||||
catch (error) {
|
||||
const reason = error instanceof Error && error.message ? `:${error.message}` : ''
|
||||
throw new Error(`无法保存本机打卡记录${reason}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function clearCheckInProfile(): void {
|
||||
try {
|
||||
uni.removeStorageSync(CHECK_IN_STORAGE_KEY)
|
||||
}
|
||||
catch (error) {
|
||||
const reason = error instanceof Error && error.message ? `:${error.message}` : ''
|
||||
throw new Error(`无法清除本机打卡记录${reason}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { CheckInLocationSnapshot } from '@/domain/check-in'
|
||||
|
||||
interface CurrentLocationOptions {
|
||||
highAccuracyExpireTime?: number
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
function locationOptionsSupported(): boolean {
|
||||
try {
|
||||
return typeof uni.canIUse !== 'function'
|
||||
|| uni.canIUse('getLocation.object.isHighAccuracy')
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function isLocationPermissionDenied(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object')
|
||||
return false
|
||||
const message = String((error as { errMsg?: unknown }).errMsg ?? '')
|
||||
return !/privacy/i.test(message)
|
||||
&& /auth deny|auth denied|authorize:fail|permission/i.test(message)
|
||||
}
|
||||
|
||||
export function getCurrentGcj02Location(options: CurrentLocationOptions = {}): Promise<CheckInLocationSnapshot> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const highAccuracyExpireTime = Math.max(3000, options.highAccuracyExpireTime ?? 8000)
|
||||
const timeoutMs = Math.max(3000, options.timeoutMs ?? 15000)
|
||||
let settled = false
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
const finish = (callback: () => void) => {
|
||||
if (settled)
|
||||
return
|
||||
settled = true
|
||||
if (timeout)
|
||||
clearTimeout(timeout)
|
||||
callback()
|
||||
}
|
||||
timeout = setTimeout(() => {
|
||||
finish(() => reject(new Error('定位超时,请到开阔处重试')))
|
||||
}, timeoutMs)
|
||||
const commonOptions = {
|
||||
type: 'gcj02' as const,
|
||||
success: (result: UniNamespace.GetLocationSuccess) => {
|
||||
const longitude = Number(result.longitude)
|
||||
const latitude = Number(result.latitude)
|
||||
const accuracy = Number(result.accuracy ?? result.horizontalAccuracy)
|
||||
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) {
|
||||
finish(() => reject(new Error('定位结果无效,请稍后重试')))
|
||||
return
|
||||
}
|
||||
finish(() => resolve({
|
||||
longitude,
|
||||
latitude,
|
||||
accuracy: Number.isFinite(accuracy) && accuracy >= 0 ? accuracy : null,
|
||||
receivedAt: new Date().toISOString(),
|
||||
coordinateSystem: 'GCJ02',
|
||||
}))
|
||||
},
|
||||
fail: (error: unknown) => finish(() => reject(error)),
|
||||
}
|
||||
|
||||
if (locationOptionsSupported()) {
|
||||
uni.getLocation({
|
||||
...commonOptions,
|
||||
isHighAccuracy: true,
|
||||
highAccuracyExpireTime,
|
||||
})
|
||||
return
|
||||
}
|
||||
uni.getLocation(commonOptions)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './current-location'
|
||||
@@ -1,5 +1,4 @@
|
||||
export { isRemoteTravelAssistantEnabled } from './remote'
|
||||
export { adjustPlan, createPlan } from './service'
|
||||
export { adjustPlan, createPlan, getSessionPlanningOrigin } from './service'
|
||||
export {
|
||||
loadPlan,
|
||||
loadPlanningRequest,
|
||||
|
||||
@@ -198,7 +198,13 @@ export async function createRemoteRecommendations(
|
||||
method: 'POST',
|
||||
data: {
|
||||
mode: planningRequest.mode,
|
||||
preferences: planningRequest.preferences,
|
||||
preferences: {
|
||||
destination: planningRequest.preferences.destination,
|
||||
themes: [...planningRequest.preferences.themes],
|
||||
pace: planningRequest.preferences.pace,
|
||||
interests: [...planningRequest.preferences.interests],
|
||||
transport: planningRequest.preferences.transport,
|
||||
},
|
||||
durationMinutes: planningRequest.durationMinutes,
|
||||
selectedPoiIds: [...planningRequest.selectedPoiIds],
|
||||
candidates,
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
clonePlanResponse,
|
||||
cloneTravelPreferences,
|
||||
createLocalPlan,
|
||||
createRecommendedPlan,
|
||||
normalizePlanningRequest,
|
||||
normalizeTravelPreferences,
|
||||
type PlanningOrigin,
|
||||
@@ -13,7 +12,6 @@ import {
|
||||
type TravelPreferences,
|
||||
TravelValidationError,
|
||||
} from '@/domain/travel'
|
||||
import { createRemoteRecommendations, isRemoteTravelAssistantEnabled } from './remote'
|
||||
import { loadPlan, loadPlanningRequest, loadPreferences, savePlanningRequest, savePreferences } from './storage'
|
||||
|
||||
interface LocalSession {
|
||||
@@ -100,28 +98,6 @@ export async function createPlan(
|
||||
selectedPoiIds: [],
|
||||
...(normalizePlanningOrigin(planningOrigin) ? { origin: normalizePlanningOrigin(planningOrigin)! } : {}),
|
||||
}, repository)
|
||||
if (isRemoteTravelAssistantEnabled()) {
|
||||
const candidates = repository.getPublishedPois().map(poi => ({
|
||||
id: poi.id,
|
||||
name: poi.name,
|
||||
categoryCode: poi.categoryCode,
|
||||
tagCodes: poi.tagCodes,
|
||||
summary: poi.summary,
|
||||
recommendationIndex: poi.recommendationIndex,
|
||||
}))
|
||||
const remote = await createRemoteRecommendations(request, candidates)
|
||||
const plan = createRecommendedPlan(
|
||||
request,
|
||||
remote.recommendations,
|
||||
repository,
|
||||
remote.assistantMessage,
|
||||
remote.generationMode === 'real' ? 'remote_ai' : 'remote_mock',
|
||||
)
|
||||
rememberLocalSession(plan, request)
|
||||
savePlanningRequest(request)
|
||||
return clonePlanResponse(plan, repository)
|
||||
}
|
||||
|
||||
const plan = createLocalPlan(request, repository)
|
||||
rememberLocalSession(plan, request)
|
||||
savePlanningRequest(request)
|
||||
@@ -152,10 +128,19 @@ export async function adjustPlan(conversationId: string, message: string): Promi
|
||||
)
|
||||
if (session.plan.source !== 'local_poc') {
|
||||
adjusted.plan.assistantMessage = '已在本机按你的补充要求重新核算路线;如需 AI 重新选点,请返回规划页重新生成。'
|
||||
adjusted.plan.source = session.plan.source
|
||||
adjusted.plan.source = 'local_poc'
|
||||
}
|
||||
rememberLocalSession(adjusted.plan, adjusted.request)
|
||||
savePlanningRequest(adjusted.request)
|
||||
savePreferences(adjusted.preferences)
|
||||
return clonePlanResponse(adjusted.plan, repository)
|
||||
}
|
||||
|
||||
/** Exact planning origins live only in this in-memory session and are never persisted. */
|
||||
export function getSessionPlanningOrigin(conversationId: string): PlanningOrigin | null {
|
||||
const normalizedConversationId = conversationId.trim()
|
||||
if (!normalizedConversationId)
|
||||
return null
|
||||
const origin = localSessions.get(normalizedConversationId)?.planningOrigin
|
||||
return origin ? { ...origin } : null
|
||||
}
|
||||
|
||||
@@ -78,7 +78,8 @@ export function loadPlan(): PlanResponse | null {
|
||||
if (!envelope || envelope.datasetVersion !== repository.getDatasetMeta().datasetVersion)
|
||||
return null
|
||||
try {
|
||||
return normalizePlanResponse(envelope.payload, repository)
|
||||
const plan = normalizePlanResponse(envelope.payload, repository)
|
||||
return plan.source === 'local_poc' ? plan : null
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
|
||||
Reference in New Issue
Block a user