Initial commit: gmTouringMiniApp project

This commit is contained in:
周瑞哲
2026-07-30 16:04:34 +08:00
commit ebcae02d35
201 changed files with 49545 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
export * from './marker'
+111
View File
@@ -0,0 +1,111 @@
import type { PoiCategory, PoiSummary } from '@/domain/poi'
export interface PoiMapMarker {
id: number
latitude: number
longitude: number
iconPath: string
width: number
height: number
anchor: {
x: number
y: number
}
callout: {
content: string
color: string
fontSize: number
borderRadius: number
bgColor: string
padding: number
display: 'ALWAYS' | 'BYCLICK'
textAlign: 'center'
}
zIndex: number
}
function stableHash(input: string): number {
let hash = 2166136261
for (let index = 0; index < input.length; index += 1) {
hash ^= input.charCodeAt(index)
hash = Math.imul(hash, 16777619)
}
return hash >>> 0
}
/** Builds collision-free numeric marker IDs without coupling IDs to filter order. */
export function buildMarkerIdMap(poiIds: readonly string[]): Map<number, string> {
const result = new Map<number, string>()
const sortedIds = Array.from(new Set(poiIds)).sort()
sortedIds.forEach((poiId) => {
let markerId = stableHash(poiId) & 0x7FFFFFFF
if (markerId === 0)
markerId = 1
while (result.has(markerId))
markerId = markerId === 0x7FFFFFFF ? 1 : markerId + 1
result.set(markerId, poiId)
})
return result
}
function rpxToPx(rpx: number): number {
return typeof uni !== 'undefined' && typeof uni.upx2px === 'function'
? uni.upx2px(rpx)
: Math.round(rpx / 2)
}
function toMarkerDimensions(selected: boolean): { width: number, height: number } {
const size = selected
? { width: 80, height: 94 }
: { width: 64, height: 78 }
return {
width: rpxToPx(size.width),
height: rpxToPx(size.height),
}
}
export function createPoiMarkers(
pois: readonly PoiSummary[],
categories: readonly PoiCategory[],
markerIdMap: ReadonlyMap<number, string>,
selectedPoiId: string | null,
): PoiMapMarker[] {
const categoryMap = new Map(categories.map(category => [category.code, category]))
const idByPoi = new Map(Array.from(markerIdMap.entries()).map(([markerId, poiId]) => [poiId, markerId]))
return pois.flatMap((poi) => {
const category = categoryMap.get(poi.categoryCode)
const markerId = idByPoi.get(poi.id)
if (!category || markerId === undefined)
return []
const selected = poi.id === selectedPoiId
const size = toMarkerDimensions(selected)
return [{
id: markerId,
latitude: poi.latitude,
longitude: poi.longitude,
iconPath: selected ? category.selectedMarkerIcon : category.markerIcon,
width: size.width,
height: size.height,
anchor: { x: 0.5, y: 1 },
callout: {
content: selected ? `${category.name} · ${poi.name}` : category.name,
color: selected ? '#FFFFFF' : '#18201D',
fontSize: 12,
borderRadius: 8,
bgColor: selected ? '#0E6247' : '#FFFFFF',
padding: 6,
display: selected ? 'ALWAYS' : 'BYCLICK',
textAlign: 'center',
},
zIndex: selected ? 10 : 1,
}]
})
}
+26
View File
@@ -0,0 +1,26 @@
export { isRemoteTravelAssistantEnabled } from './remote'
export { adjustPlan, createPlan } from './service'
export {
loadPlan,
loadPreferences,
PLAN_STORAGE_KEY,
PREFERENCES_STORAGE_KEY,
savePlan,
savePreferences,
} from './storage'
export type {
BudgetLevel,
Duration,
Interest,
Itinerary,
ItineraryItem,
ItinerarySource,
Pace,
PlanningOrigin,
PlanResponse,
Theme,
Transport,
TravelAssistantSource,
TravelPreferences,
} from '@/domain/travel'
+210
View File
@@ -0,0 +1,210 @@
import type { PlanningRequest } from '@/domain/travel'
interface RemoteAPIError {
detail?: unknown
}
export interface RemotePoiCandidate {
id: string
name: string
categoryCode: string
tagCodes: string[]
summary: string
recommendationIndex: number
}
export interface RemotePoiCandidateInput {
id: string
name: string
categoryCode: string
tagCodes: readonly string[]
summary: string
recommendationIndex: number
}
export interface RemoteRecommendationItem {
poiId: string
reason: string
order: number
}
export interface RemoteRecommendationResponse {
requestId: string
mode: PlanningRequest['mode']
assistantMessage: string
recommendations: RemoteRecommendationItem[]
generationMode: 'mock' | 'real'
}
function apiBaseUrl(): string {
const value = import.meta.env.VITE_TRAVEL_ASSISTANT_API_BASE_URL
return typeof value === 'string' ? value.trim().replace(/\/+$/, '') : ''
}
export function isRemoteTravelAssistantEnabled(): boolean {
return apiBaseUrl().length > 0
}
function remoteUrl(path: string): string {
const baseUrl = apiBaseUrl()
if (!/^https?:\/\//i.test(baseUrl))
throw new Error('VITE_TRAVEL_ASSISTANT_API_BASE_URL 必须是有效的 HTTP(S) 地址')
return `${baseUrl}${path}`
}
function errorDetail(data: unknown, statusCode: number): string {
if (typeof data === 'object' && data !== null) {
const detail = (data as RemoteAPIError).detail
if (typeof detail === 'string' && detail.trim())
return detail.trim()
}
return `行程服务请求失败(${statusCode}`
}
function request<T>(options: UniApp.RequestOptions): Promise<T> {
return new Promise((resolve, reject) => {
uni.request({
...options,
success(response) {
if (response.statusCode >= 200 && response.statusCode < 300) {
resolve(response.data as T)
return
}
reject(new Error(errorDetail(response.data, response.statusCode)))
},
fail() {
reject(new Error('无法连接行程服务,请检查网络、服务地址和微信合法域名配置'))
},
})
})
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function readNonEmptyString(value: unknown, fieldName: string, maxLength: number): string {
if (typeof value !== 'string' || !value.trim() || value.trim().length > maxLength)
throw new Error(`AI 推荐响应中的${fieldName}格式无效`)
return value.trim()
}
function safeCandidates(candidates: readonly RemotePoiCandidateInput[]): RemotePoiCandidate[] {
if (candidates.length < 1 || candidates.length > 100)
throw new Error('发送给 AI 的 POI 候选数量必须为 1 至 100 个')
const ids = new Set<string>()
return candidates.map((candidate) => {
const id = candidate.id.trim()
if (!id || ids.has(id))
throw new Error(`发送给 AI 的 POI ID 无效或重复:${id || '空值'}`)
const name = candidate.name.trim()
const categoryCode = candidate.categoryCode.trim()
const summary = candidate.summary.trim()
if (!name || name.length > 100)
throw new Error(`发送给 AI 的 POI 名称无效:${id}`)
if (!categoryCode || categoryCode.length > 64)
throw new Error(`发送给 AI 的 POI 分类无效:${id}`)
if (summary.length > 300)
throw new Error(`发送给 AI 的 POI 摘要过长:${id}`)
if (!Number.isInteger(candidate.recommendationIndex)
|| candidate.recommendationIndex < 1
|| candidate.recommendationIndex > 5) {
throw new Error(`发送给 AI 的 POI 推荐指数无效:${id}`)
}
const tagCodes = [...new Set(candidate.tagCodes.map(tagCode => tagCode.trim()).filter(Boolean))]
if (tagCodes.length > 20 || tagCodes.some(tagCode => tagCode.length > 64))
throw new Error(`发送给 AI 的 POI 标签无效:${id}`)
ids.add(id)
return {
id,
name,
categoryCode,
tagCodes,
summary,
recommendationIndex: candidate.recommendationIndex,
}
})
}
function normalizeRecommendations(
value: unknown,
planningRequest: PlanningRequest,
candidates: readonly RemotePoiCandidate[],
): RemoteRecommendationResponse {
if (!isRecord(value))
throw new Error('AI 推荐响应格式无效')
if (value.mode !== planningRequest.mode)
throw new Error('AI 推荐响应的规划模式不匹配')
if (value.generationMode !== 'mock' && value.generationMode !== 'real')
throw new Error('AI 推荐响应的生成模式无效')
if (!Array.isArray(value.recommendations) || value.recommendations.length < 1 || value.recommendations.length > 8)
throw new Error('AI 推荐响应必须包含 1 至 8 个 POI')
const allowedIds = new Set(candidates.map(candidate => candidate.id))
const seenIds = new Set<string>()
const seenOrders = new Set<number>()
const recommendations = value.recommendations.map((item): RemoteRecommendationItem => {
if (!isRecord(item))
throw new Error('AI 推荐项格式无效')
const poiId = readNonEmptyString(item.poiId, 'POI ID', 100)
if (!allowedIds.has(poiId))
throw new Error(`AI 返回了候选白名单外的 POI${poiId}`)
if (seenIds.has(poiId))
throw new Error(`AI 返回了重复 POI${poiId}`)
if (!Number.isInteger(item.order) || (item.order as number) < 1 || (item.order as number) > 8)
throw new Error('AI 推荐响应中的顺序格式无效')
if (seenOrders.has(item.order as number))
throw new Error('AI 推荐响应中存在重复顺序')
seenIds.add(poiId)
seenOrders.add(item.order as number)
return {
poiId,
reason: readNonEmptyString(item.reason, '推荐理由', 300),
order: item.order as number,
}
}).sort((left, right) => left.order - right.order)
if (recommendations.some((item, index) => item.order !== index + 1))
throw new Error('AI 推荐响应中的顺序必须从 1 连续递增')
if (planningRequest.mode === 'custom') {
const selectedIds = new Set(planningRequest.selectedPoiIds)
if (recommendations.length !== selectedIds.size
|| recommendations.some(item => !selectedIds.has(item.poiId))) {
throw new Error('AI 自选规划未完整保留用户选择的 POI')
}
}
return {
requestId: readNonEmptyString(value.requestId, '请求 ID', 200),
mode: planningRequest.mode,
assistantMessage: readNonEmptyString(value.assistantMessage, '助手消息', 1000),
recommendations,
generationMode: value.generationMode,
}
}
/**
* Requests coordinate-free POI recommendations. The caller must calculate the
* final route and duration locally; planningRequest.origin is never uploaded.
*/
export async function createRemoteRecommendations(
planningRequest: PlanningRequest,
candidateInputs: readonly RemotePoiCandidateInput[],
): Promise<RemoteRecommendationResponse> {
const candidates = safeCandidates(candidateInputs)
const response = await request<unknown>({
url: remoteUrl('/api/v1/plans'),
method: 'POST',
data: {
mode: planningRequest.mode,
preferences: planningRequest.preferences,
durationMinutes: planningRequest.durationMinutes,
selectedPoiIds: [...planningRequest.selectedPoiIds],
candidates,
},
header: { 'content-type': 'application/json' },
timeout: 50000,
})
return normalizeRecommendations(response, planningRequest, candidates)
}
+158
View File
@@ -0,0 +1,158 @@
import { getPoiRepository } from '@/data/poi'
import {
adjustLocalPlan,
clonePlanResponse,
cloneTravelPreferences,
createLocalPlan,
createRecommendedPlan,
normalizePlanResponse,
normalizePlanningRequest,
normalizeTravelPreferences,
type PlanningOrigin,
type PlanningRequest,
type PlanResponse,
type TravelPreferences,
TravelValidationError,
} from '@/domain/travel'
import { createRemoteRecommendations, isRemoteTravelAssistantEnabled } from './remote'
import { loadPlan, loadPreferences, savePreferences } from './storage'
interface LocalSession {
plan: PlanResponse
preferences: TravelPreferences
request: PlanningRequest
planningOrigin: PlanningOrigin | null
}
const localSessions = new Map<string, LocalSession>()
function clonePlanningRequest(request: PlanningRequest, includeOrigin = true): PlanningRequest {
return {
mode: request.mode,
preferences: cloneTravelPreferences(request.preferences),
durationMinutes: request.durationMinutes,
selectedPoiIds: [...request.selectedPoiIds],
...(includeOrigin && request.origin ? { origin: { ...request.origin } } : {}),
}
}
function rememberLocalSession(plan: PlanResponse, request: PlanningRequest): void {
const repository = getPoiRepository()
localSessions.set(plan.conversationId, {
plan: clonePlanResponse(plan, repository),
preferences: cloneTravelPreferences(request.preferences),
request: clonePlanningRequest(request),
planningOrigin: request.origin ? { ...request.origin } : null,
})
}
function restoreLocalSession(conversationId: string): LocalSession | null {
const remembered = localSessions.get(conversationId)
if (remembered)
return remembered
const plan = loadPlan()
const preferences = loadPreferences()
if (!plan || !preferences || plan.conversationId !== conversationId)
return null
rememberLocalSession(plan, {
mode: plan.itinerary.planningMode,
preferences,
durationMinutes: plan.itinerary.requestedMinutes,
selectedPoiIds: plan.itinerary.planningMode === 'custom'
? plan.itinerary.items.map(item => item.placeId)
: [],
})
return localSessions.get(conversationId) ?? null
}
function normalizePlanningOrigin(origin?: PlanningOrigin): PlanningOrigin | null {
if (!origin
|| origin.coordinateSystem !== 'GCJ02'
|| !Number.isFinite(origin.longitude)
|| !Number.isFinite(origin.latitude)
|| origin.longitude < -180
|| origin.longitude > 180
|| origin.latitude < -90
|| origin.latitude > 90) {
return null
}
return { ...origin }
}
function isPlanningRequest(input: TravelPreferences | PlanningRequest): input is PlanningRequest {
return typeof input === 'object' && input !== null && 'mode' in input && 'preferences' in input
}
export function createPlan(input: PlanningRequest): Promise<PlanResponse>
export function createPlan(input: TravelPreferences, planningOrigin?: PlanningOrigin): Promise<PlanResponse>
export async function createPlan(
input: TravelPreferences | PlanningRequest,
planningOrigin?: PlanningOrigin,
): Promise<PlanResponse> {
const repository = getPoiRepository()
const request = normalizePlanningRequest(isPlanningRequest(input)
? input
: {
mode: 'quick',
preferences: normalizeTravelPreferences(input),
durationMinutes: input.duration === 'half_day' ? 240 : 480,
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)
return clonePlanResponse(plan, repository)
}
const plan = createLocalPlan(request, repository)
rememberLocalSession(plan, request)
return clonePlanResponse(plan, repository)
}
export async function adjustPlan(conversationId: string, message: string): Promise<PlanResponse> {
const normalizedConversationId = conversationId.trim()
const normalizedMessage = message.trim()
if (!normalizedConversationId)
throw new TravelValidationError('会话 ID 不能为空')
if (!normalizedMessage)
throw new TravelValidationError('请填写需要调整的内容')
if (normalizedMessage.length > 500)
throw new TravelValidationError('调整内容不能超过 500 字')
const repository = getPoiRepository()
const session = restoreLocalSession(normalizedConversationId)
if (!session)
throw new Error('本机行程会话不存在,请重新生成行程')
const adjusted = adjustLocalPlan(
session.plan,
session.preferences,
normalizedMessage,
repository,
session.planningOrigin ?? undefined,
session.request,
)
if (session.plan.source !== 'local_poc') {
adjusted.plan.assistantMessage = '已在本机按你的补充要求重新核算路线;如需 AI 重新选点,请返回规划页重新生成。'
adjusted.plan.source = session.plan.source
}
rememberLocalSession(adjusted.plan, adjusted.request)
savePreferences(adjusted.preferences)
return clonePlanResponse(adjusted.plan, repository)
}
+101
View File
@@ -0,0 +1,101 @@
import { getPoiRepository } from '@/data/poi'
import {
clonePlanResponse,
cloneTravelPreferences,
normalizePlanResponse,
normalizeTravelPreferences,
type PlanResponse,
type TravelPreferences,
} from '@/domain/travel'
export const PLAN_STORAGE_KEY = 'guangming:last-plan'
export const PREFERENCES_STORAGE_KEY = 'guangming:last-preferences'
const STORAGE_SCHEMA_VERSION = 1
interface StorageEnvelope<T> {
schemaVersion: typeof STORAGE_SCHEMA_VERSION
datasetVersion: string
savedAt: string
payload: T
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function createEnvelope<T>(payload: T): StorageEnvelope<T> {
return {
schemaVersion: STORAGE_SCHEMA_VERSION,
datasetVersion: getPoiRepository().getDatasetMeta().datasetVersion,
savedAt: new Date().toISOString(),
payload,
}
}
function readEnvelope(value: unknown): StorageEnvelope<unknown> | null {
if (!isRecord(value) || value.schemaVersion !== STORAGE_SCHEMA_VERSION)
return null
if (typeof value.datasetVersion !== 'string' || !value.datasetVersion.trim())
return null
if (typeof value.savedAt !== 'string' || !Number.isFinite(Date.parse(value.savedAt)))
return null
if (!('payload' in value))
return null
return value as unknown as StorageEnvelope<unknown>
}
function readStorage(key: string): unknown {
try {
return uni.getStorageSync(key)
}
catch {
return null
}
}
function writeStorage(key: string, value: unknown): void {
try {
uni.setStorageSync(key, value)
}
catch (error) {
const reason = error instanceof Error && error.message ? `${error.message}` : ''
throw new Error(`无法保存本机行程数据${reason}`)
}
}
export function savePlan(plan: PlanResponse): void {
const repository = getPoiRepository()
const normalized = normalizePlanResponse(plan, repository)
writeStorage(PLAN_STORAGE_KEY, createEnvelope(clonePlanResponse(normalized, repository)))
}
export function loadPlan(): PlanResponse | null {
const repository = getPoiRepository()
const envelope = readEnvelope(readStorage(PLAN_STORAGE_KEY))
if (!envelope || envelope.datasetVersion !== repository.getDatasetMeta().datasetVersion)
return null
try {
return normalizePlanResponse(envelope.payload, repository)
}
catch {
return null
}
}
export function savePreferences(preferences: TravelPreferences): void {
const normalized = normalizeTravelPreferences(preferences)
writeStorage(PREFERENCES_STORAGE_KEY, createEnvelope(cloneTravelPreferences(normalized)))
}
export function loadPreferences(): TravelPreferences | null {
const envelope = readEnvelope(readStorage(PREFERENCES_STORAGE_KEY))
if (!envelope)
return null
try {
return normalizeTravelPreferences(envelope.payload)
}
catch {
return null
}
}