| New file |
| | |
| | | # 修改目的地地图选点页 — 大头针离开点位后信息滞留问题分析报告 |
| | | |
| | | > Feature: `pin-location-stale-fix` |
| | | > Bug 链接:<https://xpd.x-peng.com/task-manage/dicpro/project/detail/X202606270009260003?parentUrl=%2Fdicpro%2Fproject%2Fview%2Fdetail%2FCV20260424090103000001%3Fmode%3Dtable&tabuncode=GZXL20260427111251000002> |
| | | > 涉及文件: |
| | | > - `src/robotaxi/pages/select-map-site/index.tsx`(页面入口) |
| | | > - `src/robotaxi/pages/select-map-site/hooks/use-select-map-site.ts`(业务编排,核心) |
| | | > - `src/robotaxi/pages/select-map-site/hooks/use-select-map-site-state.ts`(状态容器) |
| | | > - `src/robotaxi/pages/select-map-site/components/select-map-site-view.tsx`(视图) |
| | | > - `src/utils/Robotaxi-public-method.ts`(`fetchMapData` / `findNearestMarker` / `getDistance`) |
| | | |
| | | --- |
| | | |
| | | ## 一、问题现象 |
| | | |
| | | 测试反馈,在「行程中 - 修改目的地」地图选点页(`pageType = 'end'`): |
| | | |
| | | - 用户把大头针(地图中心固定指针)拖离原目的地点位**一定距离**后,大头针**没有自动吸附**回点位(距离太远,这是**正确**的); |
| | | - 但**大头针顶部气泡**仍显示原点位信息(如「限停 3 分钟 / 天河智慧城希尔顿欢朋酒店」),**底部确认表单**也仍回显该点位(见 bug 截图); |
| | | - **期望表现**:大头针离开所有可选点位后,气泡应消失、底部表单应显示「**附近无可选站点**」、「确认目的地站点」按钮置灰禁用(见正确截图)。 |
| | | |
| | | --- |
| | | |
| | | ## 二、地图选点逻辑梳理 |
| | | |
| | | ### 2.0 进入路径(行程中修改目的地) |
| | | |
| | | 本问题页就是「**确认目的地**」选点页(`select-map-site`),即 bug 截图中带大头针的页面,**并非地图首页**。行程中修改目的地的完整链路为: |
| | | |
| | | ``` |
| | | begin-route(行程中)点「修改目的地」 |
| | | → handleStartModifyDestination 写入 MODIFY_DESTINATION_SESSION_KEY 会话 |
| | | → toSelectSite('end') → navigateTo site-search(搜索目的地页, pageType=end) |
| | | → site-search 点地图选点入口 toSelectSite |
| | | → navigateTo select-map-site(确认目的地选点页, pageType=end&fromSiteSearch=1) ← 本问题页 |
| | | ``` |
| | | |
| | | > 全项目只有**一个** `select-map-site` 页与**一个** `use-select-map-site.ts` hook,被起点(`start`)/终点(`end`)、首页入口、行程中入口**共用**。行程中入口的差异仅在于:携带 `fromSiteSearch=1`(隐藏搜索框)、`MODIFY_DESTINATION_SESSION_KEY` 处于 active、初始 `selectedSite` 取自缓存的原目的地 `endSite`。**出问题的拖动逻辑 `handleRegionChange` 与入口无关,对所有入口表现一致**,故本报告结论对「行程中修改目的地」链路直接适用。 |
| | | |
| | | ### 2.1 页面结构与数据来源 |
| | | |
| | | | 层级 | 职责 | |
| | | |------|------| |
| | | | `index.tsx` | hook 编排,装配 `useSelectMapSite` 与 `useStationScaleFocus`,透传给视图 | |
| | | | `use-select-map-site.ts` | **业务核心**:地图区域变化、吸附、站点选择、运营区校验、确认下单 | |
| | | | `use-select-map-site-state.ts` | 纯状态容器(`selectedSite`、`rangeStatus`、`showToolTip`、`nearbySites`、各 ref 等) | |
| | | | `select-map-site-view.tsx` | 纯渲染:根据 `selectedSite` / `rangeStatus` / `selectionMode` 决定气泡与底部表单 | |
| | | |
| | | ### 2.2 关键状态 |
| | | |
| | | - `selectedSite`:当前选中的站点对象(`{}` 表示未选中)。**气泡文案与底部表单都依赖它的 `name`**。 |
| | | - `rangeStatus`:`normal` / `out_of_operating_area`(不在运营区)/ `cross_operating_area`(跨运营区)。 |
| | | - `showToolTip`:是否展示大头针顶部气泡。 |
| | | - `nearbySites` / `markers`:当前中心点附近的可选点位。 |
| | | - `distanceConfig.adsorptionDistance`:吸附阈值(接口下发,异步加载)。 |
| | | |
| | | ### 2.3 拖动 → 吸附主流程(`handleRegionChange`) |
| | | |
| | | ``` |
| | | 地图区域变化事件 |
| | | ├─ type === 'begin' → setShowToolTip(false) // 仅隐藏气泡,【不清空 selectedSite】 |
| | | │ |
| | | └─ type === 'end' |
| | | ├─ if (causedBy ∈ {gesture, drag, scale}) ← 【关键闸门】 |
| | | │ getCenterLocation(异步) → updateMapData(异步, 2 次网络请求) |
| | | │ → findNearestMarker(中心, 可选 markers) |
| | | │ ├─ 命中且 minDistance < adsorptionDistance: |
| | | │ │ moveToLocation(吸附到点位) + applySelectionBySite(站点) |
| | | │ │ → setSelectedSite(站点)、rangeStatus 重算、showToolTip(true) |
| | | │ └─ 未命中 / 距离过远(else 分支): |
| | | │ resetStationSelection() |
| | | │ setSelectedSite({}) ← 【清空选中点的唯一位置】 |
| | | │ rangeStatus = 依据是否在运营区/跨区重算(区内同区 → normal) |
| | | │ |
| | | └─ setTimeout(600ms) { setBounce(false); setShowToolTip(true); } ← 【无条件强制显示气泡】 |
| | | ``` |
| | | |
| | | ### 2.4 视图渲染条件(`select-map-site-view.tsx`) |
| | | |
| | | ```ts |
| | | const showRangeWarning = isOutOfOperatingArea || isCrossOperatingArea; |
| | | const shouldShowSelectedSiteEcho = !showRangeWarning && Boolean(deps.selectedSite.name); |
| | | |
| | | // 气泡:showToolTip 且有选中点回显,或处于范围警告 |
| | | {((showToolTip && shouldShowSelectedSiteEcho) || showRangeWarning) && <气泡 .../>} |
| | | |
| | | // 底部表单优先级:场站列表 > 选中点回显 > 范围警告 > 「附近无可选站点」 |
| | | shouldShowStationSelection ? 场站列表 |
| | | : shouldShowSelectedSiteEcho ? selectedSite.name |
| | | : showRangeWarning ? 警告文案 |
| | | : 「附近无可选站点」 |
| | | ``` |
| | | |
| | | **结论**:只要 `selectedSite.name` 还在、且 `rangeStatus === normal`,气泡和底部表单就会**回显旧点位**——这正是 bug 的表现条件。 |
| | | |
| | | --- |
| | | |
| | | ## 三、根因分析:为什么拖远后点位信息没更新 |
| | | |
| | | 正常的「单次、干净」拖动其实能走进 `else` 分支执行 `setSelectedSite({})`,逻辑本身是对的。问题在于**「清空」这一步并不可靠**,存在以下设计缺陷,导致 `selectedSite` 被滞留为旧值: |
| | | |
| | | ### 3.1 主因:清空动作被「单一异步闸门」把守,且开始拖动时不清空 |
| | | |
| | | 1. **拖动开始(`begin`)只隐藏气泡,从不清空 `selectedSite`。** 旧点位对象一直驻留在状态里。 |
| | | 2. **清空 `selectedSite` 的唯一时机,是 `end` 事件中 `causedBy ∈ {gesture, drag, scale}` 的 `else` 分支。** 一旦这个闸门「没进去」,旧点位就不会被清掉: |
| | | - 微信小程序不同机型/基础库版本下,`regionchange` 的 `end` 事件 `causedBy` 取值并不稳定(手势拖动期望 `drag`,但惯性回弹、程序化 `moveToLocation`/`includePoints` 触发的 end 往往是 `update`,部分场景甚至为空)。`causedBy` 不在白名单内时,**整段「重新取中心点 → 拉取附近站点 → 重算选中点」逻辑被整体跳过**。 |
| | | 3. **末尾 `setTimeout(600ms)` 无条件把 `showToolTip` 置回 `true`。** 即使数据没更新,气泡也会被强制重新弹出,把滞留的旧 `selectedSite.name` 直接显示出来。 |
| | | |
| | | > 即「**拖动开始不清、拖动结束条件性才清、气泡末尾无条件显示**」三者叠加:当条件性清空未触发时,UI 就回到了旧点位 + 强制显示气泡的状态。 |
| | | |
| | | ### 3.2 诱因:异步请求无「最新生效」序号守卫(乱序覆盖) |
| | | |
| | | `handleRegionChange` 把更新逻辑包在 `getCenterLocation` 回调 + `updateMapData`(内部 `getFenceCheck` + `getNearestSite` 两次网络请求)里。连续/快速拖动时,每个 `end` 事件都会**并发**派生一条独立异步链,而代码**没有任何请求序号 / 取消 / latest-wins 守卫**: |
| | | |
| | | - 早一步「靠近原点位」的链(`adsorb` 分支 → `setSelectedSite(旧点位)`)若**晚于**最后一步「拖远」的链返回,就会把已清空的 `selectedSite` **覆盖回旧点位**; |
| | | - `nearbyLoading` 用 `pendingNearbyRequestCountRef` 计数,只能控制 loading 显隐,**不能保证状态以最后一次中心点为准**。 |
| | | |
| | | ### 3.3 诱因:场站模式下的副作用会「回填」selectedSite |
| | | |
| | | 若原目的地属于某个**场站(stationId)**,初始化 `applySelectionBySite` 会进入场站模式(`selectionMode = 'station'`)。此时有副作用: |
| | | |
| | | ```ts |
| | | useEffect(() => { |
| | | if (state.selectionMode !== 'station') return; |
| | | if (!state.selectedStation?.stationId) return; |
| | | const nextStationSites = filterSelectableSites(state.nearbySites).filter(...); |
| | | if (nextStationSites.length > 0) state.setSelectedSite(nextStationSites[0]); // 回填 |
| | | }, [state.nearbySites, state.selectionMode, state.selectedStation?.stationId]); |
| | | ``` |
| | | |
| | | 拖远时 `updateMapData` 先 `setNearbySites([])`,再由 `else` 分支 `resetStationSelection()`。这些 `setState` 的批处理与上面依赖 `nearbySites` 的副作用相互交织,存在「场站态尚未被重置、副作用先用旧场站回填 `selectedSite`」的时序窗口,进一步把旧点位写回。 |
| | | |
| | | ### 3.4 小结 |
| | | |
| | | > **根因**:选中点(`selectedSite`)与「大头针当前地理位置」**没有解耦**——清空选中点这件事既不在拖动开始时同步执行,又被「`causedBy` 白名单 + 单条异步链 + 无序号守卫」三重不确定性把守;而气泡显示又由独立的 600ms 定时器**无条件**触发。三者叠加,使得「拖远 → 应清空」在 `causedBy` 不匹配、异步乱序、或场站副作用回填等情况下失效,旧点位信息滞留在气泡与底部表单上。 |
| | | |
| | | --- |
| | | |
| | | ## 四、解决办法 |
| | | |
| | | 核心思路:**让「选中点」严格跟随「大头针最新中心点」,清空动作前置且不依赖不稳定条件,异步结果加最新序号守卫。** |
| | | |
| | | ### 方案 A(推荐,改动可控):前置清空 + 拓宽闸门 + 最新请求守卫 |
| | | |
| | | 1. **拖动一开始就清空回显(前置清空)** |
| | | 在 `begin`(或检测到 `end` 时立即)同步将选中点回显失效,避免旧值驻留: |
| | | ```ts |
| | | if (type === 'begin') { |
| | | state.setShowToolTip(false); |
| | | state.setSelectedSite({}); // 拖动即清空,气泡/表单先进入“无点位”态 |
| | | resetStationSelection(); |
| | | return; |
| | | } |
| | | ``` |
| | | 这样无论后续异步是否成功,都不会显示「上一站点」的陈旧信息;待最新结果回来再回填。 |
| | | |
| | | 2. **拓宽 / 去除 `causedBy` 白名单** |
| | | `end` 事件统一处理(至少额外纳入 `'update'`,或干脆不校验 `causedBy`,仅按 `type === 'end'` 处理),消除「闸门没进去导致不清空」的根本不确定性。 |
| | | |
| | | 3. **加入「最新请求」序号守卫(latest-wins),杜绝乱序覆盖** |
| | | ```ts |
| | | // state 中新增 ref |
| | | const regionSeqRef = useRef(0); |
| | | // handleRegionChange end 分支: |
| | | const seq = ++state.regionSeqRef.current; |
| | | mapCtx.getCenterLocation({ success: async (res) => { |
| | | const result = await updateMapData(res.latitude, res.longitude); |
| | | if (seq !== state.regionSeqRef.current) return; // 非最新请求,丢弃 |
| | | // ...再根据 result 做吸附/清空 |
| | | }}); |
| | | ``` |
| | | 仅允许最后一次拖动的结果落到状态上,旧链返回直接丢弃。 |
| | | |
| | | 4. **气泡显示与数据一致** |
| | | 去掉末尾「无条件 `setShowToolTip(true)`」,改为**仅在确实命中可选点位(adsorb 成功)或范围警告时**才显示气泡;未命中则保持隐藏。让 `showToolTip` 由「是否有有效选中点」驱动,而非定时器强行打开。 |
| | | |
| | | ### 方案 B(更彻底):单一数据源派生 |
| | | |
| | | 引入唯一可信状态 `pinCenter`(大头针当前经纬度)与派生 `selectedSite`: |
| | | |
| | | - 每次 `regionchange end` 仅更新 `pinCenter` + 最新一次 `nearbySites`; |
| | | - `selectedSite` / `rangeStatus` 全部由 `pinCenter` + `nearbySites` + `adsorptionDistance` **纯函数派生**(`useMemo`),不再散落多处 `setSelectedSite`; |
| | | - 场站模式同样基于派生结果,移除「依赖 `nearbySites` 回填 `selectedSite`」的副作用。 |
| | | |
| | | > 方案 B 从根上消除「多处写 `selectedSite` + 异步乱序 + 副作用回填」的全部隐患,但改动面较大,建议作为后续重构方向;当前缺陷修复优先采用方案 A。 |
| | | |
| | | ### 验证要点 |
| | | |
| | | - 拖远到无点位区域(仍在运营区内):气泡消失、底部显示「附近无可选站点」、确认按钮置灰。 |
| | | - 拖出运营区:显示「请拖动至目的地 / 超出服务范围」。 |
| | | - 快速来回拖动多次后松手:最终展示**与大头针最终落点**一致,不出现旧点位回显。 |
| | | - 原目的地为场站点位时,拖远后场站卡片不残留。 |
| | | - Android / iOS 双端、不同基础库版本回归(重点验证 `causedBy` 差异场景)。 |
| | | |
| | | --- |
| | | |
| | | ## 五、改动范围建议(仅修复,不含重构) |
| | | |
| | | | 文件 | 改动 | |
| | | |------|------| |
| | | | `use-select-map-site.ts` | `handleRegionChange`:`begin` 前置清空选中点;`end` 拓宽 `causedBy`;新增请求序号守卫;气泡显示改为「命中才显示」 | |
| | | | `use-select-map-site-state.ts` | 新增 `regionSeqRef`(最新请求序号) | |
| | | | `select-map-site-view.tsx` | 一般无需改动(回显逻辑已正确依赖 `selectedSite.name`);如方案 B 则改为消费派生值 | |
| | | |
| | | > 注:以上为分析与方案,**未改动任何代码**。如需我按方案 A 落地修复,请告知。 |