Add check-in feature and update travel planner UI
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
import type {
|
||||
CheckInBadge,
|
||||
CheckInBadgeCode,
|
||||
CheckInBadgeDefinition,
|
||||
CheckInEligibility,
|
||||
CheckInLocationSnapshot,
|
||||
CheckInProfile,
|
||||
CheckInRecord,
|
||||
CheckInResult,
|
||||
} from './types'
|
||||
import type { GeoPoint } from '@/domain/poi'
|
||||
|
||||
export const CHECK_IN_POINTS = 10
|
||||
export const CHECK_IN_RADIUS_METERS = 200
|
||||
export const CHECK_IN_MAX_ACCURACY_METERS = 100
|
||||
|
||||
export const CHECK_IN_BADGES: readonly CheckInBadgeDefinition[] = [
|
||||
{
|
||||
code: 'explorer',
|
||||
name: '光明探索者',
|
||||
description: '完成首个真实定位点位打卡',
|
||||
icon: '01',
|
||||
requiredCheckIns: 1,
|
||||
},
|
||||
{
|
||||
code: 'traveller',
|
||||
name: '光明旅行家',
|
||||
description: '累计打卡 2 个开放点位',
|
||||
icon: '02',
|
||||
requiredCheckIns: 2,
|
||||
},
|
||||
{
|
||||
code: 'check_in_master',
|
||||
name: '全域打卡达人',
|
||||
description: '累计打卡 3 个开放点位',
|
||||
icon: '03',
|
||||
requiredCheckIns: 3,
|
||||
},
|
||||
]
|
||||
|
||||
export class CheckInError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly code: 'already_checked_in' | 'invalid_poi' | 'invalid_location' | 'not_eligible',
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'CheckInError'
|
||||
}
|
||||
}
|
||||
|
||||
export function createEmptyCheckInProfile(): CheckInProfile {
|
||||
return { points: 0, badges: [], checkIns: [] }
|
||||
}
|
||||
|
||||
export function cloneCheckInProfile(profile: CheckInProfile): CheckInProfile {
|
||||
return {
|
||||
points: profile.points,
|
||||
badges: profile.badges.map(badge => ({ ...badge })),
|
||||
checkIns: profile.checkIns.map(record => ({ ...record })),
|
||||
}
|
||||
}
|
||||
|
||||
function toRadians(value: number): number {
|
||||
return value * Math.PI / 180
|
||||
}
|
||||
|
||||
function isValidPoint(point: GeoPoint): boolean {
|
||||
return Number.isFinite(point.longitude)
|
||||
&& Number.isFinite(point.latitude)
|
||||
&& point.longitude >= -180
|
||||
&& point.longitude <= 180
|
||||
&& point.latitude >= -90
|
||||
&& point.latitude <= 90
|
||||
}
|
||||
|
||||
export function distanceBetweenMeters(left: GeoPoint, right: GeoPoint): number {
|
||||
if (!isValidPoint(left) || !isValidPoint(right))
|
||||
return Number.NaN
|
||||
|
||||
const earthRadiusMeters = 6_371_008.8
|
||||
const latitudeDelta = toRadians(right.latitude - left.latitude)
|
||||
const longitudeDelta = toRadians(right.longitude - left.longitude)
|
||||
const leftLatitude = toRadians(left.latitude)
|
||||
const rightLatitude = toRadians(right.latitude)
|
||||
const haversine = Math.sin(latitudeDelta / 2) ** 2
|
||||
+ Math.cos(leftLatitude) * Math.cos(rightLatitude) * Math.sin(longitudeDelta / 2) ** 2
|
||||
return earthRadiusMeters * 2 * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine))
|
||||
}
|
||||
|
||||
export function createCheckInRecordId(poiId: string, checkedInAt: string): string {
|
||||
return `check-in:${poiId}:${checkedInAt}`
|
||||
}
|
||||
|
||||
export function evaluateCheckInEligibility(
|
||||
location: CheckInLocationSnapshot,
|
||||
poi: GeoPoint,
|
||||
allowedDistanceMeters = CHECK_IN_RADIUS_METERS,
|
||||
): CheckInEligibility {
|
||||
if (!Number.isFinite(allowedDistanceMeters) || allowedDistanceMeters <= 0) {
|
||||
return {
|
||||
eligible: false,
|
||||
code: 'invalid_location',
|
||||
distanceMeters: null,
|
||||
allowedDistanceMeters: CHECK_IN_RADIUS_METERS,
|
||||
message: '点位打卡范围配置无效,请稍后重试',
|
||||
}
|
||||
}
|
||||
if (location.coordinateSystem !== 'GCJ02' || !isValidPoint(location)) {
|
||||
return {
|
||||
eligible: false,
|
||||
code: 'invalid_location',
|
||||
distanceMeters: null,
|
||||
allowedDistanceMeters,
|
||||
message: '定位结果无效,请重新获取当前位置',
|
||||
}
|
||||
}
|
||||
|
||||
const accuracy = location.accuracy
|
||||
if (accuracy === null || !Number.isFinite(accuracy) || accuracy <= 0 || accuracy > CHECK_IN_MAX_ACCURACY_METERS) {
|
||||
return {
|
||||
eligible: false,
|
||||
code: 'low_accuracy',
|
||||
distanceMeters: null,
|
||||
allowedDistanceMeters,
|
||||
message: '当前位置精度不足,请到开阔处重新定位',
|
||||
}
|
||||
}
|
||||
|
||||
const distanceMeters = distanceBetweenMeters(location, poi)
|
||||
if (!Number.isFinite(distanceMeters)) {
|
||||
return {
|
||||
eligible: false,
|
||||
code: 'invalid_location',
|
||||
distanceMeters: null,
|
||||
allowedDistanceMeters,
|
||||
message: '定位结果无效,请重新获取当前位置',
|
||||
}
|
||||
}
|
||||
|
||||
if (distanceMeters - accuracy > allowedDistanceMeters) {
|
||||
return {
|
||||
eligible: false,
|
||||
code: 'too_far',
|
||||
distanceMeters,
|
||||
allowedDistanceMeters,
|
||||
message: `请到点位 ${Math.round(allowedDistanceMeters)} 米范围内再打卡`,
|
||||
}
|
||||
}
|
||||
|
||||
if (distanceMeters + accuracy > allowedDistanceMeters) {
|
||||
return {
|
||||
eligible: false,
|
||||
code: 'uncertain',
|
||||
distanceMeters,
|
||||
allowedDistanceMeters,
|
||||
message: '当前位置接近打卡边界,请靠近点位后重新定位',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
eligible: true,
|
||||
code: 'eligible',
|
||||
distanceMeters,
|
||||
allowedDistanceMeters,
|
||||
message: '已进入点位打卡范围',
|
||||
}
|
||||
}
|
||||
|
||||
export function hasCheckedIn(profile: CheckInProfile, poiId: string): boolean {
|
||||
const normalizedPoiId = poiId.trim()
|
||||
return Boolean(normalizedPoiId) && profile.checkIns.some(record => record.poiId === normalizedPoiId)
|
||||
}
|
||||
|
||||
function badgesForCheckInCount(checkInCount: number, obtainedAt: string): CheckInBadge[] {
|
||||
return CHECK_IN_BADGES
|
||||
.filter(definition => checkInCount >= definition.requiredCheckIns)
|
||||
.map(definition => ({ code: definition.code, obtainedAt }))
|
||||
}
|
||||
|
||||
export function applyCheckIn(
|
||||
profile: CheckInProfile,
|
||||
poi: { id: string, name: string } & GeoPoint,
|
||||
location: CheckInLocationSnapshot,
|
||||
now = Date.now(),
|
||||
allowedDistanceMeters = CHECK_IN_RADIUS_METERS,
|
||||
): CheckInResult {
|
||||
const poiId = poi.id.trim()
|
||||
const poiName = poi.name.trim()
|
||||
if (!poiId || !poiName || !isValidPoint(poi))
|
||||
throw new CheckInError('点位信息无效,请返回地图重新选择', 'invalid_poi')
|
||||
if (hasCheckedIn(profile, poiId))
|
||||
throw new CheckInError('该点位已经打卡,本机不会重复计分', 'already_checked_in')
|
||||
|
||||
const eligibility = evaluateCheckInEligibility(location, poi, allowedDistanceMeters)
|
||||
if (!eligibility.eligible || eligibility.distanceMeters === null)
|
||||
throw new CheckInError(eligibility.message, 'not_eligible')
|
||||
|
||||
const checkedInAt = new Date(now).toISOString()
|
||||
const record: CheckInRecord = {
|
||||
id: createCheckInRecordId(poiId, checkedInAt),
|
||||
poiId,
|
||||
poiName,
|
||||
checkedInAt,
|
||||
pointsAwarded: CHECK_IN_POINTS,
|
||||
}
|
||||
const previousBadgeCodes = new Set<CheckInBadgeCode>(profile.badges.map(badge => badge.code))
|
||||
const earnedBadges = badgesForCheckInCount(profile.checkIns.length + 1, checkedInAt)
|
||||
const newBadges = earnedBadges.filter(badge => !previousBadgeCodes.has(badge.code))
|
||||
const nextProfile: CheckInProfile = {
|
||||
points: profile.points + CHECK_IN_POINTS,
|
||||
badges: [
|
||||
...profile.badges.map(badge => ({ ...badge })),
|
||||
...newBadges,
|
||||
],
|
||||
checkIns: [record, ...profile.checkIns.map(item => ({ ...item }))],
|
||||
}
|
||||
return { profile: nextProfile, record: { ...record }, newBadges: newBadges.map(badge => ({ ...badge })) }
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './check-in'
|
||||
export * from './types'
|
||||
export * from './validation'
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { CoordinateSystem, GeoPoint } from '@/domain/poi'
|
||||
|
||||
export type CheckInBadgeCode = 'explorer' | 'traveller' | 'check_in_master'
|
||||
export type CheckInEligibilityCode = 'eligible' | 'too_far' | 'uncertain' | 'low_accuracy' | 'invalid_location'
|
||||
|
||||
export interface CheckInLocationSnapshot extends GeoPoint {
|
||||
accuracy: number | null
|
||||
receivedAt: string
|
||||
coordinateSystem: CoordinateSystem
|
||||
}
|
||||
|
||||
export interface CheckInRecord {
|
||||
id: string
|
||||
poiId: string
|
||||
poiName: string
|
||||
checkedInAt: string
|
||||
pointsAwarded: number
|
||||
}
|
||||
|
||||
export interface CheckInBadge {
|
||||
code: CheckInBadgeCode
|
||||
obtainedAt: string
|
||||
}
|
||||
|
||||
export interface CheckInProfile {
|
||||
points: number
|
||||
badges: CheckInBadge[]
|
||||
checkIns: CheckInRecord[]
|
||||
}
|
||||
|
||||
export interface CheckInEligibility {
|
||||
eligible: boolean
|
||||
code: CheckInEligibilityCode
|
||||
distanceMeters: number | null
|
||||
allowedDistanceMeters: number
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface CheckInResult {
|
||||
profile: CheckInProfile
|
||||
record: CheckInRecord
|
||||
newBadges: CheckInBadge[]
|
||||
}
|
||||
|
||||
export interface CheckInBadgeDefinition {
|
||||
code: CheckInBadgeCode
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
requiredCheckIns: number
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { CheckInBadge, CheckInBadgeCode, CheckInProfile, CheckInRecord } from './types'
|
||||
import {
|
||||
CHECK_IN_BADGES,
|
||||
CHECK_IN_POINTS,
|
||||
cloneCheckInProfile,
|
||||
createCheckInRecordId,
|
||||
} from './check-in'
|
||||
|
||||
const badgeCodes = new Set<CheckInBadgeCode>(CHECK_IN_BADGES.map(badge => badge.code))
|
||||
const profileKeys = new Set(['points', 'badges', 'checkIns'])
|
||||
const recordKeys = new Set(['id', 'poiId', 'poiName', 'checkedInAt', 'pointsAwarded'])
|
||||
const badgeKeys = new Set(['code', 'obtainedAt'])
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function hasOnlyKeys(value: Record<string, unknown>, allowedKeys: ReadonlySet<string>): boolean {
|
||||
return Object.keys(value).every(key => allowedKeys.has(key))
|
||||
}
|
||||
|
||||
function readTimestamp(value: unknown): string | null {
|
||||
if (typeof value !== 'string' || !Number.isFinite(Date.parse(value)))
|
||||
return null
|
||||
return new Date(value).toISOString()
|
||||
}
|
||||
|
||||
function isCheckInRecordId(id: string, poiId: string, checkedInAt: string): boolean {
|
||||
return id === createCheckInRecordId(poiId, checkedInAt)
|
||||
}
|
||||
|
||||
function normalizeCheckInRecord(value: unknown): CheckInRecord | null {
|
||||
if (!isRecord(value) || !hasOnlyKeys(value, recordKeys))
|
||||
return null
|
||||
const id = typeof value.id === 'string' ? value.id.trim() : ''
|
||||
const poiId = typeof value.poiId === 'string' ? value.poiId.trim() : ''
|
||||
const poiName = typeof value.poiName === 'string' ? value.poiName.trim() : ''
|
||||
const checkedInAt = readTimestamp(value.checkedInAt)
|
||||
if (!id
|
||||
|| !poiId
|
||||
|| !poiName
|
||||
|| !checkedInAt
|
||||
|| !isCheckInRecordId(id, poiId, checkedInAt)
|
||||
|| value.pointsAwarded !== CHECK_IN_POINTS) {
|
||||
return null
|
||||
}
|
||||
return { id, poiId, poiName, checkedInAt, pointsAwarded: CHECK_IN_POINTS }
|
||||
}
|
||||
|
||||
function normalizeBadge(value: unknown): CheckInBadge | null {
|
||||
if (!isRecord(value)
|
||||
|| !hasOnlyKeys(value, badgeKeys)
|
||||
|| typeof value.code !== 'string'
|
||||
|| !badgeCodes.has(value.code as CheckInBadgeCode)) {
|
||||
return null
|
||||
}
|
||||
const obtainedAt = readTimestamp(value.obtainedAt)
|
||||
return obtainedAt ? { code: value.code as CheckInBadgeCode, obtainedAt } : null
|
||||
}
|
||||
|
||||
export function normalizeCheckInProfile(value: unknown): CheckInProfile | null {
|
||||
if (!isRecord(value)
|
||||
|| !hasOnlyKeys(value, profileKeys)
|
||||
|| !Array.isArray(value.checkIns)
|
||||
|| !Array.isArray(value.badges)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const checkIns = value.checkIns.map(normalizeCheckInRecord)
|
||||
const badges = value.badges.map(normalizeBadge)
|
||||
if (checkIns.some(record => !record) || badges.some(badge => !badge))
|
||||
return null
|
||||
|
||||
const normalizedCheckIns = checkIns as CheckInRecord[]
|
||||
const normalizedBadges = badges as CheckInBadge[]
|
||||
if (new Set(normalizedCheckIns.map(record => record.poiId)).size !== normalizedCheckIns.length
|
||||
|| new Set(normalizedCheckIns.map(record => record.id)).size !== normalizedCheckIns.length
|
||||
|| new Set(normalizedBadges.map(badge => badge.code)).size !== normalizedBadges.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const expectedBadgeCodes = CHECK_IN_BADGES
|
||||
.filter(badge => normalizedCheckIns.length >= badge.requiredCheckIns)
|
||||
.map(badge => badge.code)
|
||||
const actualBadgeCodes = normalizedBadges.map(badge => badge.code)
|
||||
if (expectedBadgeCodes.length !== actualBadgeCodes.length
|
||||
|| expectedBadgeCodes.some(code => !actualBadgeCodes.includes(code))) {
|
||||
return null
|
||||
}
|
||||
|
||||
const checkInTimes = new Map(normalizedCheckIns.map(record => [record.poiId, record.checkedInAt]))
|
||||
if (CHECK_IN_BADGES.some((definition) => {
|
||||
const badge = normalizedBadges.find(item => item.code === definition.code)
|
||||
const thresholdRecord = normalizedCheckIns[normalizedCheckIns.length - definition.requiredCheckIns]
|
||||
return badge && (!thresholdRecord || badge.obtainedAt !== checkInTimes.get(thresholdRecord.poiId))
|
||||
})) {
|
||||
return null
|
||||
}
|
||||
|
||||
const expectedPoints = normalizedCheckIns.reduce((total, record) => total + record.pointsAwarded, 0)
|
||||
if (value.points !== expectedPoints)
|
||||
return null
|
||||
|
||||
return cloneCheckInProfile({ points: expectedPoints, badges: normalizedBadges, checkIns: normalizedCheckIns })
|
||||
}
|
||||
Reference in New Issue
Block a user