ai-coding
2026-06-28 68ff4ee8e52e928a2f5d2cb2ea10704287bde5e3
docs(pin-location): 添加大头针信息滞留分析报告

Co-Authored-By: ai-coding <ai-coding@devgenie.local>
1个文件已添加
205 ■■■■■ 已修改文件
docs/修改目的地选点页-大头针信息滞留分析报告.md 205 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/修改目的地选点页-大头针信息滞留分析报告.md
New file
@@ -0,0 +1,205 @@
# 修改目的地地图选点页 — 大头针离开点位后信息滞留问题分析报告
> 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 落地修复,请告知。