Update AI planner integration, docs, and config

This commit is contained in:
周瑞哲
2026-07-30 16:33:39 +08:00
parent 33c14eec86
commit ac4179086c
13 changed files with 192 additions and 29 deletions
+3 -3
View File
@@ -2,7 +2,7 @@
基于 uni-app、Vue 3、TypeScript 和 Pinia 开发的微信小程序 POC,用于验证深圳市光明区文旅资源地图探索、POI 数字化展示、文旅问答式偏好收集和行程规划。 基于 uni-app、Vue 3、TypeScript 和 Pinia 开发的微信小程序 POC,用于验证深圳市光明区文旅资源地图探索、POI 数字化展示、文旅问答式偏好收集和行程规划。
当前范围包括地图拖动缩放、POI 分类、marker 与摘要联动、用户主动授权后的前台实时定位、POI 详情、AI 文旅助手交互和推荐行程展示。POC 默认使用随包数据与本地确定性规则生成游览顺序;有定位快照时会优先安排较近首站,但不是道路级实时导航,也不包含打卡、登录或个人中心。 当前范围包括地图拖动缩放、POI 分类、marker 与摘要联动、用户主动授权后的前台实时定位、POI 详情、AI 文旅助手交互和推荐行程展示。路线规划支持基于当前位置的快速规划,以及用户自选 2–8 个 POI、2–10 小时时长的自定义规划;本地规划器按时间预算动态排程,配置服务端后可由受控 AI 在审核白名单内推荐点位与顺序。当前不是道路级实时导航,也不包含打卡、登录或个人中心。
## 微信开发者工具 ## 微信开发者工具
@@ -58,9 +58,9 @@ src/
└── static/ # 随包静态资源 └── static/ # 随包静态资源
``` ```
不配置 `VITE_TRAVEL_ASSISTANT_API_BASE_URL` 时,小程序始终使用本地确定性 POC 规划器。若后续接入远程 AI,只允许在 `.env.local` 配置受控后端地址,并在微信公众平台配置合法请求域名;API Key、模型凭证和高德 Web Service Key 均不得进入客户端环境变量或构建产物。 不配置 `VITE_TRAVEL_ASSISTANT_API_BASE_URL` 时,小程序使用本地确定性 POC 规划器。配置受控后端地址后,服务端 AI 只接收偏好和不含坐标的 POI 白名单,返回推荐 ID、理由和顺序;当前位置、交通估算、时长适配及最终路线仍由小程序本机计算。服务端默认 `LLM_MODE=mock`,只有部署时设置 `LLM_MODE=real` 及模型凭据才会标记为真实 AI。微信公众平台还需配置 HTTPS 合法请求域名;API Key、模型凭证和高德 Web Service Key 均不得进入客户端环境变量或构建产物。
实时位置仅用户点击地图中的“定位到我”后启用,并只在地图页处于前台时更新。精确坐标保留在当前内存会话中,不写入匿名偏好或路线缓存,也不会发送给可选远程 AI 服务。iOS 真机使用前,需通过真实 AppID 在微信公众平台开通定位接口并完善《用户隐私保护指引》。 实时位置仅用户主动触发,并只在地图页处于前台时持续更新。精确坐标保留在当前内存会话中,不写入匿名偏好或路线缓存,也不会发送给远程 AI 服务。iOS 真机使用前,需通过真实 AppID 在微信公众平台开通定位接口并完善《用户隐私保护指引》。
路由来自页面 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`
+2 -2
View File
@@ -27,7 +27,7 @@
1. 复制 `.env.example``.env.local` 1. 复制 `.env.example``.env.local`
2. 将项目方确认的微信小程序 AppID 写入 `UNI_MP_WEIXIN_APPID` 2. 将项目方确认的微信小程序 AppID 写入 `UNI_MP_WEIXIN_APPID`
3. 真机预览或上传前,将根目录 `project.config.json``appid``touristappid` 同步为相同的真实 AppID。 3. 真机预览或上传前,将根目录 `project.config.json``appid``touristappid` 同步为相同的真实 AppID。
4. 远程 AI 为可选能力;留空 `VITE_TRAVEL_ASSISTANT_API_BASE_URL` 即使用本地 POC 规划器。 4. 远程 AI 为可选能力;留空 `VITE_TRAVEL_ASSISTANT_API_BASE_URL` 即使用本地 POC 规划器。服务端必须设置 `LLM_MODE=real` 才会作为真实 AI 展示;mock 仅用于联调演示。
5. 不要在任何客户端环境变量中写入高德 Key、AI API Key 或模型凭证,也不要把 `.env.local` 提交或外发。 5. 不要在任何客户端环境变量中写入高德 Key、AI API Key 或模型凭证,也不要把 `.env.local` 提交或外发。
`project.config.json` 中已有的 AppID 属于模板现状,未经项目方确认不能视为本期正式身份。构建以 `manifest.config.ts` 读取的本地环境配置为准。 `project.config.json` 中已有的 AppID 属于模板现状,未经项目方确认不能视为本期正式身份。构建以 `manifest.config.ts` 读取的本地环境配置为准。
@@ -78,7 +78,7 @@ macOS 可在另一个终端执行 `corepack pnpm ide:mp-weixin` 调起已安装
5. 返回地图后恢复分类、中心、缩放和选中点位。 5. 返回地图后恢复分类、中心、缩放和选中点位。
6. 验证空分类、数据错误、地图错误、点位失效和图片失败降级。 6. 验证空分类、数据错误、地图错误、点位失效和图片失败降级。
7. 专项检查原生地图层级、点击穿透、安全区和快速操作稳定性。 7. 专项检查原生地图层级、点击穿透、安全区和快速操作稳定性。
8. 从“AI 助手”和“路线规划”进入偏好表单,生成半日及一日行程,核对点位、顺序、时间、转场提示和来源说明。 8. 从“AI 助手”和“路线规划”进入偏好表单,分别验证当前位置快速规划和自选 2–8 个点位规划;拖动 2–10 小时时长,核对完整点位、顺序、时间、转场提示和来源说明。
9. 关闭网络后验证本地规划仍可生成;如启用远程服务,另测合法域名、超时和服务失败提示,确认客户端产物不含服务凭证。 9. 关闭网络后验证本地规划仍可生成;如启用远程服务,另测合法域名、超时和服务失败提示,确认客户端产物不含服务凭证。
10. 点击“定位到我”,验证首次授权、实时蓝点、拒绝后从设置恢复,以及切换 Tab 后停止前台监听。 10. 点击“定位到我”,验证首次授权、实时蓝点、拒绝后从设置恢复,以及切换 Tab 后停止前台监听。
11. 定位成功后生成路线,确认首站按当前位置优化;检查本机存储和远程请求均不包含精确坐标。 11. 定位成功后生成路线,确认首站按当前位置优化;检查本机存储和远程请求均不包含精确坐标。
-1
View File
@@ -27,4 +27,3 @@ packages = ["app"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
pythonpath = [ "." ] pythonpath = [ "." ]
testpaths = [ "tests" ] testpaths = [ "tests" ]
+8 -1
View File
@@ -254,6 +254,9 @@ function selectQuickPois(
cursor = next cursor = next
} }
if (selected.length === 0 && remaining.length > 0)
selected.push(rankedCandidates(remaining, preferences, [], origin)[0])
return selected return selected
} }
@@ -312,7 +315,9 @@ function selectRecommendedPois(
: recommendedPois : recommendedPois
} }
const remaining = [...recommendedPois] const remaining = origin
? orderPoisFromOrigin(recommendedPois, origin)
: [...recommendedPois]
const selected: PoiResolved[] = [] const selected: PoiResolved[] = []
let usedMinutes = 0 let usedMinutes = 0
let cursor: PlanningOrigin | PoiResolved | null = origin let cursor: PlanningOrigin | PoiResolved | null = origin
@@ -326,6 +331,8 @@ function selectRecommendedPois(
usedMinutes += transfer + visit usedMinutes += transfer + visit
cursor = next cursor = next
} }
if (selected.length === 0 && recommendedPois.length > 0)
selected.push(remaining[0] ?? recommendedPois[0])
return selected return selected
} }
+1 -1
View File
@@ -61,7 +61,7 @@
"startLocationUpdate", "startLocationUpdate",
"onLocationChange" "onLocationChange"
], ],
"projectname": "【dev】光明文旅地图" "projectname": "【prod】光明文旅地图"
}, },
"mp-alipay": { "mp-alipay": {
"usingComponents": true "usingComponents": true
+1 -1
View File
@@ -55,7 +55,7 @@
"type": "page", "type": "page",
"layout": "map", "layout": "map",
"style": { "style": {
"navigationBarTitleText": "AI 路线规划", "navigationBarTitleText": "智能路线规划",
"navigationBarBackgroundColor": "#ffffff", "navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black", "navigationBarTextStyle": "black",
"backgroundColor": "#f4f8f6" "backgroundColor": "#f4f8f6"
+8 -1
View File
@@ -110,6 +110,13 @@ const routeTags = computed(() => {
}) })
const planningModeLabel = computed(() => plan.value?.itinerary.planningMode === 'custom' ? '自选规划' : '快速规划') const planningModeLabel = computed(() => plan.value?.itinerary.planningMode === 'custom' ? '自选规划' : '快速规划')
const planningSourceLabel = computed(() => {
if (plan.value?.source === 'remote_ai')
return '真实 AI 推荐'
if (plan.value?.source === 'remote_mock')
return '远端演示规划'
return '本地智能规划'
})
const durationFitNotice = computed(() => plan.value ? getDurationFitNotice(plan.value.itinerary.durationFitStatus) : null) const durationFitNotice = computed(() => plan.value ? getDurationFitNotice(plan.value.itinerary.durationFitStatus) : null)
function loadStoredPlan() { function loadStoredPlan() {
@@ -294,7 +301,7 @@ onReady(initializeMap)
<view class="route-hero__top"> <view class="route-hero__top">
<view class="route-hero__label"> <view class="route-hero__label">
<text class="route-hero__spark"></text> <text class="route-hero__spark"></text>
{{ planningModeLabel }} {{ planningModeLabel }} · {{ planningSourceLabel }}
</view> </view>
<button @click="openPlanner">重新规划</button> <button @click="openPlanner">重新规划</button>
</view> </view>
+16 -11
View File
@@ -2,7 +2,7 @@
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 { createPlan, isRemoteTravelAssistantEnabled, loadPreferences, savePlan, savePreferences } from '@/services/travel-assistant' import { createPlan, isRemoteTravelAssistantEnabled, loadPlanningRequest, loadPreferences, savePlan, savePreferences } from '@/services/travel-assistant'
import { useLocationStore } from '@/stores' import { useLocationStore } from '@/stores'
interface PlannerPreset { interface PlannerPreset {
@@ -74,8 +74,8 @@ const durationText = computed(() => {
}) })
const usesCurrentLocation = computed(() => locationAvailable.value const usesCurrentLocation = computed(() => locationAvailable.value
&& (planningMode.value === 'quick' || customStartsFromCurrentLocation.value)) && (planningMode.value === 'quick' || customStartsFromCurrentLocation.value))
const planningEngineLabel = computed(() => remoteEnabled.value && planningMode.value === 'quick' const planningEngineLabel = computed(() => remoteEnabled.value
? '在线 AI 服务' ? '在线智能服务'
: '本地智能规划') : '本地智能规划')
const locationActionText = computed(() => { const locationActionText = computed(() => {
if (locationStore.status === 'locating') if (locationStore.status === 'locating')
@@ -151,9 +151,6 @@ function toggleInterest(interest: Interest) {
function setPlanningMode(mode: PlanningRequest['mode']) { function setPlanningMode(mode: PlanningRequest['mode']) {
planningMode.value = mode planningMode.value = mode
if (mode === 'custom' && remoteEnabled.value) {
uni.showToast({ title: '自选点位将使用本地智能规划', icon: 'none' })
}
} }
function togglePoi(poiId: string) { function togglePoi(poiId: string) {
@@ -333,10 +330,18 @@ async function submit() {
} }
onShow(() => { onShow(() => {
const stored = loadPreferences() const storedRequest = loadPlanningRequest()
if (stored) { const storedPreferences = storedRequest?.preferences ?? loadPreferences()
assignPreferences(stored) if (storedPreferences)
durationMinutes.value = stored.duration === 'half_day' ? 4 * 60 : 8 * 60 assignPreferences(storedPreferences)
if (storedRequest) {
planningMode.value = storedRequest.mode
durationMinutes.value = storedRequest.durationMinutes
selectedPoiIds.value = [...storedRequest.selectedPoiIds]
customStartsFromCurrentLocation.value = false
}
else if (storedPreferences) {
durationMinutes.value = storedPreferences.duration === 'half_day' ? 4 * 60 : 8 * 60
} }
applyPreset() applyPreset()
}) })
@@ -390,7 +395,7 @@ onShow(() => {
</button> </button>
</view> </view>
<text v-if="remoteEnabled && planningMode === 'custom'" class="planning-mode-tip"> <text v-if="remoteEnabled && planningMode === 'custom'" class="planning-mode-tip">
自选点位不依赖在线服务将使用本地智能规划 在线 AI 将在你选择的点位内建议顺序本机负责定位交通与时长核算
</text> </text>
</view> </view>
+3
View File
@@ -2,10 +2,13 @@ export { isRemoteTravelAssistantEnabled } from './remote'
export { adjustPlan, createPlan } from './service' export { adjustPlan, createPlan } from './service'
export { export {
loadPlan, loadPlan,
loadPlanningRequest,
loadPreferences, loadPreferences,
PLAN_STORAGE_KEY, PLAN_STORAGE_KEY,
PLANNING_REQUEST_STORAGE_KEY,
PREFERENCES_STORAGE_KEY, PREFERENCES_STORAGE_KEY,
savePlan, savePlan,
savePlanningRequest,
savePreferences, savePreferences,
} from './storage' } from './storage'
+6 -3
View File
@@ -5,7 +5,6 @@ import {
cloneTravelPreferences, cloneTravelPreferences,
createLocalPlan, createLocalPlan,
createRecommendedPlan, createRecommendedPlan,
normalizePlanResponse,
normalizePlanningRequest, normalizePlanningRequest,
normalizeTravelPreferences, normalizeTravelPreferences,
type PlanningOrigin, type PlanningOrigin,
@@ -15,7 +14,7 @@ import {
TravelValidationError, TravelValidationError,
} from '@/domain/travel' } from '@/domain/travel'
import { createRemoteRecommendations, isRemoteTravelAssistantEnabled } from './remote' import { createRemoteRecommendations, isRemoteTravelAssistantEnabled } from './remote'
import { loadPlan, loadPreferences, savePreferences } from './storage' import { loadPlan, loadPlanningRequest, loadPreferences, savePlanningRequest, savePreferences } from './storage'
interface LocalSession { interface LocalSession {
plan: PlanResponse plan: PlanResponse
@@ -55,7 +54,8 @@ function restoreLocalSession(conversationId: string): LocalSession | null {
const preferences = loadPreferences() const preferences = loadPreferences()
if (!plan || !preferences || plan.conversationId !== conversationId) if (!plan || !preferences || plan.conversationId !== conversationId)
return null return null
rememberLocalSession(plan, { const storedRequest = loadPlanningRequest()
rememberLocalSession(plan, storedRequest ?? {
mode: plan.itinerary.planningMode, mode: plan.itinerary.planningMode,
preferences, preferences,
durationMinutes: plan.itinerary.requestedMinutes, durationMinutes: plan.itinerary.requestedMinutes,
@@ -118,11 +118,13 @@ export async function createPlan(
remote.generationMode === 'real' ? 'remote_ai' : 'remote_mock', remote.generationMode === 'real' ? 'remote_ai' : 'remote_mock',
) )
rememberLocalSession(plan, request) rememberLocalSession(plan, request)
savePlanningRequest(request)
return clonePlanResponse(plan, repository) return clonePlanResponse(plan, repository)
} }
const plan = createLocalPlan(request, repository) const plan = createLocalPlan(request, repository)
rememberLocalSession(plan, request) rememberLocalSession(plan, request)
savePlanningRequest(request)
return clonePlanResponse(plan, repository) return clonePlanResponse(plan, repository)
} }
@@ -153,6 +155,7 @@ export async function adjustPlan(conversationId: string, message: string): Promi
adjusted.plan.source = session.plan.source adjusted.plan.source = session.plan.source
} }
rememberLocalSession(adjusted.plan, adjusted.request) rememberLocalSession(adjusted.plan, adjusted.request)
savePlanningRequest(adjusted.request)
savePreferences(adjusted.preferences) savePreferences(adjusted.preferences)
return clonePlanResponse(adjusted.plan, repository) return clonePlanResponse(adjusted.plan, repository)
} }
+67
View File
@@ -4,12 +4,14 @@ import {
cloneTravelPreferences, cloneTravelPreferences,
normalizePlanResponse, normalizePlanResponse,
normalizeTravelPreferences, normalizeTravelPreferences,
type PlanningRequest,
type PlanResponse, type PlanResponse,
type TravelPreferences, type TravelPreferences,
} from '@/domain/travel' } from '@/domain/travel'
export const PLAN_STORAGE_KEY = 'guangming:last-plan' export const PLAN_STORAGE_KEY = 'guangming:last-plan'
export const PREFERENCES_STORAGE_KEY = 'guangming:last-preferences' export const PREFERENCES_STORAGE_KEY = 'guangming:last-preferences'
export const PLANNING_REQUEST_STORAGE_KEY = 'guangming:last-planning-request'
const STORAGE_SCHEMA_VERSION = 1 const STORAGE_SCHEMA_VERSION = 1
@@ -99,3 +101,68 @@ export function loadPreferences(): TravelPreferences | null {
return null return null
} }
} }
export function savePlanningRequest(request: PlanningRequest): void {
const repository = getPoiRepository()
const sanitized: PlanningRequest = {
mode: request.mode,
preferences: normalizeTravelPreferences(request.preferences),
durationMinutes: request.durationMinutes,
selectedPoiIds: [...request.selectedPoiIds],
}
if (sanitized.mode !== 'quick' && sanitized.mode !== 'custom')
throw new Error('无法保存规划条件:规划模式无效')
if (!Number.isInteger(sanitized.durationMinutes)
|| sanitized.durationMinutes < 120
|| sanitized.durationMinutes > 600
|| sanitized.durationMinutes % 30 !== 0) {
throw new Error('无法保存规划条件:规划时长无效')
}
if (sanitized.mode === 'quick' && sanitized.selectedPoiIds.length > 0)
throw new Error('无法保存规划条件:快速规划不能预选点位')
if (sanitized.mode === 'custom'
&& (sanitized.selectedPoiIds.length < 2 || sanitized.selectedPoiIds.length > 8)) {
throw new Error('无法保存规划条件:自选点位数量无效')
}
if (new Set(sanitized.selectedPoiIds).size !== sanitized.selectedPoiIds.length
|| sanitized.selectedPoiIds.some(poiId => !repository.getPoiById(poiId))) {
throw new Error('无法保存规划条件:自选点位无效')
}
writeStorage(PLANNING_REQUEST_STORAGE_KEY, createEnvelope(sanitized))
}
export function loadPlanningRequest(): PlanningRequest | null {
const repository = getPoiRepository()
const envelope = readEnvelope(readStorage(PLANNING_REQUEST_STORAGE_KEY))
if (!envelope || envelope.datasetVersion !== repository.getDatasetMeta().datasetVersion || !isRecord(envelope.payload))
return null
try {
const payload = envelope.payload
const request: PlanningRequest = {
mode: payload.mode as PlanningRequest['mode'],
preferences: normalizeTravelPreferences(payload.preferences),
durationMinutes: Number(payload.durationMinutes),
selectedPoiIds: Array.isArray(payload.selectedPoiIds) ? payload.selectedPoiIds.map(String) : [],
}
if (request.mode !== 'quick' && request.mode !== 'custom')
return null
if (!Number.isInteger(request.durationMinutes)
|| request.durationMinutes < 120
|| request.durationMinutes > 600
|| request.durationMinutes % 30 !== 0) {
return null
}
if (request.mode === 'quick' && request.selectedPoiIds.length > 0)
return null
if (request.mode === 'custom' && (request.selectedPoiIds.length < 2 || request.selectedPoiIds.length > 8))
return null
if (new Set(request.selectedPoiIds).size !== request.selectedPoiIds.length
|| request.selectedPoiIds.some(poiId => !repository.getPoiById(poiId))) {
return null
}
return request
}
catch {
return null
}
}
+72
View File
@@ -4,6 +4,7 @@ import {
adjustLocalPlan, adjustLocalPlan,
classifyDurationFit, classifyDurationFit,
createLocalPlan, createLocalPlan,
createRecommendedPlan,
hasSignificantDurationGap, hasSignificantDurationGap,
LEGACY_PLACE_ID_TO_POI_ID, LEGACY_PLACE_ID_TO_POI_ID,
normalizePlanningRequest, normalizePlanningRequest,
@@ -17,10 +18,13 @@ import {
import { import {
createPlan, createPlan,
loadPlan, loadPlan,
loadPlanningRequest,
loadPreferences, loadPreferences,
PLAN_STORAGE_KEY, PLAN_STORAGE_KEY,
PLANNING_REQUEST_STORAGE_KEY,
PREFERENCES_STORAGE_KEY, PREFERENCES_STORAGE_KEY,
savePlan, savePlan,
savePlanningRequest,
savePreferences, savePreferences,
} from '../src/services/travel-assistant' } from '../src/services/travel-assistant'
@@ -354,6 +358,56 @@ describe('deterministic local POC planner', () => {
}) })
}) })
describe('reviewed AI recommendations', () => {
const repository = getPoiRepository()
const selectedPoiIds = repository.getPublishedPois().slice(0, 3).map(poi => poi.id)
const request: PlanningRequest = {
mode: 'custom',
preferences: preferences(),
durationMinutes: 120,
selectedPoiIds,
origin: { longitude: 113.94, latitude: 22.76, coordinateSystem: 'GCJ02' },
}
const recommendations = selectedPoiIds.map((poiId, index) => ({
poiId,
reason: `AI 白名单推荐理由 ${index + 1}`,
}))
it('keeps all custom POIs, AI reasons and local-only location data', () => {
const plan = createRecommendedPlan(request, recommendations, repository, '真实模型推荐完成', 'remote_ai')
expect(plan.source).toBe('remote_ai')
expect(new Set(plan.itinerary.items.map(item => item.placeId))).toEqual(new Set(selectedPoiIds))
expect(plan.itinerary.items.every(item => item.reason.includes('AI 白名单推荐理由'))).toBe(true)
expect(plan.itinerary.durationFitStatus).toBe('overflow')
expect(JSON.stringify(plan)).not.toContain('113.94')
expect(JSON.stringify(plan)).not.toContain('22.76')
})
it('uses current location to optimize a quick AI recommendation and still returns an overflow route', () => {
const quickRequest: PlanningRequest = {
...request,
mode: 'quick',
selectedPoiIds: [],
durationMinutes: 120,
origin: { longitude: 0, latitude: 0, coordinateSystem: 'GCJ02' },
}
const plan = createRecommendedPlan(quickRequest, recommendations, repository, '推荐完成', 'remote_ai')
expect(plan.itinerary.items).toHaveLength(1)
expect(plan.itinerary.durationFitStatus).toBe('overflow')
expect(plan.itinerary.startsFromCurrentLocation).toBe(true)
})
it('labels mock recommendations honestly and rejects an incomplete custom set', () => {
const mockPlan = createRecommendedPlan(request, recommendations, repository, '演示推荐完成', 'remote_mock')
expect(mockPlan.source).toBe('remote_mock')
expect(mockPlan.itinerary.title).not.toContain('AI')
expect(() => createRecommendedPlan(request, recommendations.slice(0, 2), repository, '', 'remote_ai'))
.toThrow('未完整保留')
})
})
describe('travel storage', () => { describe('travel storage', () => {
const values = new Map<string, unknown>() const values = new Map<string, unknown>()
@@ -382,6 +436,24 @@ describe('travel storage', () => {
expect(values.has(PLAN_STORAGE_KEY)).toBe(true) expect(values.has(PLAN_STORAGE_KEY)).toBe(true)
}) })
it('round-trips custom planning inputs without persisting an exact origin', () => {
const selectedPoiIds = getPoiRepository().getPublishedPois().slice(0, 4).map(poi => poi.id)
const request: PlanningRequest = {
mode: 'custom',
preferences: preferences(),
durationMinutes: 330,
selectedPoiIds,
origin: { longitude: 113.94, latitude: 22.76, coordinateSystem: 'GCJ02' },
}
savePlanningRequest(request)
const loaded = loadPlanningRequest()!
expect(loaded).toEqual({ ...request, origin: undefined })
expect(loaded).not.toHaveProperty('origin')
expect(JSON.stringify(values.get(PLANNING_REQUEST_STORAGE_KEY))).not.toContain('113.94')
expect(JSON.stringify(values.get(PLANNING_REQUEST_STORAGE_KEY))).not.toContain('22.76')
})
it('never persists planning-origin coordinates with a plan', () => { it('never persists planning-origin coordinates with a plan', () => {
const sourcePlan = createLocalPlan( const sourcePlan = createLocalPlan(
preferences(), preferences(),