ai-coding
2026-06-30 84d0aee783f49030622f73cf4cd99cccdc1cedcf
Merge origin/feature_v2.0 into feature/liaof-0630
[ai-coding] [廖芳]
1个文件已添加
14个文件已修改
442 ■■■■■ 已修改文件
docs/修改目的地流程梳理.md 218 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/components/css-folder/calling-car.less 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/components/evaluate-popup.tsx 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/hooks/use-map-viewport.ts 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/hooks/use-page-restore.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/hooks/use-route-manager.shared.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/hooks/use-vehicle-tracking.ts 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/hooks/use-view-delegates.tsx 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/hooks/use-web-socket-events.ts 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/hooks/web-socket-handlers/create-order-route-socket-handler.ts 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/index.tsx 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/select-map-site/components/select-map-site-view.tsx 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/select-map-site/hooks/use-select-map-site.ts 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/share-trip/hooks/use-vehicle-tracking.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/site-search/index.tsx 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/修改目的地流程梳理.md
New file
@@ -0,0 +1,218 @@
# 行程中修改目的地 — 完整逻辑梳理
---
## 一、整体流程概览
```
用户点击"修改目的地"
  → 写入 session 缓存
  → 跳转选点页
  → 用户选终点
  → 写入 candidate 缓存
  → reLaunch 回行程页
  → 页面恢复 + 预览态恢复
  → 展示预览确认卡片(新路线 + 新费用)
  → 用户确认 / 取消
    → 确认:调后端 orderChange → 更新终点 → 退出预览态
    → 取消:恢复旧终点 → 退出预览态
```
---
## 二、入口条件
| 条件 | 说明 |
|------|------|
| 按钮可见 | `tripStatus` 为接驾中(4)、车辆已到达(5)、行程中(6) |
| 按钮可点击(非置灰) | 以下条件全部不命中:<br>① socket 推送 status=0(临近终点)<br>② 剩余距离 < `modifyDistanceLimitMeters`<br>③ 剩余时长 < `modifyDurationLimitMinutes` |
**代码位置**:`index.tsx` 第 1139-1177 行 `modifyDestinationDisabledToastText`
---
## 三、阶段一:启动流程(handleStartModifyDestination)
**文件**:`hooks/use-modify-destination-flow.ts`
1. 写入 **session 缓存**(storage key: `modifyDestinationSession`):
   ```json
   {
     "active": true,
     "previousEndSite": "<当前终点>",
     "previousFareEstimate": "<当前费用>",
     "orderId": "<当前订单ID>",
     "tripStatus": "<当前行程状态>",
     "updatedAt": 1234567890
   }
   ```
2. 写入 **路线点缓存**(`modifyDestinationRoutePoints`):当前路线点 → 用于取消后回滚路线
3. 清空 **候选终点缓存**(`modifyDestinationCandidate`)
4. 设置流程状态 → `SELECTING`
5. 调用 `toSelectSite('end')` → `Taro.navigateTo` 到站点搜索页
---
## 四、阶段二:选点页选终点
**文件**:`pages/site-search/index.tsx`、`pages/select-map-site/hooks/use-select-map-site.ts`
用户选择新终点后:
1. 判断新终点是否与原终点相同 → 写入 `modifyDestinationPendingSameSiteToast` 标记
2. 将选中站点写入 **候选终点缓存**(`modifyDestinationCandidate`)
3. 写入跳过空调查询标记(`modifyDestinationSkipAcQueryOnReturn`)
4. 通过 **`Taro.reLaunch`** 返回行程页:
   ```
   /robotaxi/pages/begin-route/index?skipPageFlag=1&fromModifyDestination=1
   ```
   > 注意:是 reLaunch(销毁全部页面栈重新创建),不是 navigateBack
---
## 五、阶段三:行程页恢复 + 预览态建立
### 5.1 页面恢复(use-page-restore.ts → restorePageStateFromStorage)
页面 `useDidShow` 触发后:
1. `ensurePageSocketReady()` 确保 WebSocket 连接
2. 如果 `fromModifyDestination=1` + 有相同终点 toast 标记 → 弹提示
3. 调用 `restorePageStateFromStorage()`:
   - 读取 storage 中的 `startSite/endSite/orderId/tripStatus`
   - 如果 `tripStatus` 有值且不等于 1 → **调用 `getOrderActive()` 接口**
   - 接口返回数据 → 用真实订单数据更新页面状态
   - ⚠️ 接口返回空 → **清缓存并 redirect 到首页**(模拟器环境下无真实订单会踩到此分支)
### 5.2 预览态恢复(use-modify-destination-flow.ts → useEffect)
依赖 `[tripStatus, orderId, endSite]` 变化后执行:
1. 读取缓存快照(session + candidate + override + routePoints)
2. 调用 `resolveFlowState` 推导状态:
   - session.active + orderId 匹配 + candidate 有效坐标 → `PREVIEWING`
   - session.active + 无 candidate → `SELECTING`
   - 其他 → `IDLE`
3. 如果 candidate 与 previousEndSite 相同 → 自动取消(触发 `onCancelReview`)
4. 否则 → `applyPreviewingState`:设置 `previewEndSite`,`isModifyDestinationReviewVisible = true`
### 5.3 预览路线请求(use-modify-destination-preview-route.ts)
`isModifyDestinationReviewVisible` 变为 true 后:
1. 确定起点:
   - 接驾中/车辆已到达 → 使用 `startSite`(上车点)
   - 行程中 → 使用 `vehicleLocation`(车辆实时位置)
2. 调用 `requestRouteAndFare` 请求新路线 + 费用估算
3. 启动 **60 秒重估价倒计时**:超时后用户再点确认需要先刷新报价
---
## 六、阶段四:确认修改(handleConfirmModifyDestinationWithGuard)
**文件**:`index.tsx` 第 1855-1896 行
### 前置校验(任一命中则中断):
1. socket status === 0 → 弹失败弹窗 + 退出预览态
2. 距离/时长阈值校验(`shouldBypassLocalLimit` 为 false 时)→ 弹失败弹窗
3. `pendingConfirmRequote`(60s 超时)→ 重新估价 + 要求二次确认
### 提交流程(handleConfirmModifyDestination):
1. 锁定按钮(`isConfirmingModifyDestination = true`)
2. 全局 loading:「正在修改目的地」
3. 调用 API:`POST /itinerary/api/v1/mini-program/order/orderChange`
   ```json
   {
     "endPointId": "新终点站点ID",
     "orderId": "订单ID",
     "selectedRouteId": "选中路线ID",
     "selectedRoutePlanRule": "路线规划策略",
     "navigationSnapshotId": "导航快照ID"
   }
   ```
4. **成功**:
   - 构建新终点 → 更新 storage + state
   - `exitModifyDestinationReview({ shouldRestorePreviousEndSite: false })`
   - showToast「行程已更新」
   - 触发 `onModifySuccess`:清空路线选择 + 重新拉取后端行程路线
5. **失败**:弹修改失败弹窗
### 确认按钮禁用条件:
- 提交请求中
- 修改次数查询中 / 未就绪
- 路线关键参数未就绪(routeID / routePlanRule / snapshotId)
- 剩余修改次数为 0
---
## 七、阶段五:取消/退出预览态
| 触发场景 | 来源 | 行为 |
|---------|------|------|
| 点击返回按钮 | `handleBack` | 恢复旧终点 + 旧路线 → 退出预览态 |
| socket 推送不可修改 | `effectiveModifyDestinationSocketStatus === 0` | 恢复 + 弹失败弹窗 |
| 新旧终点相同 | 恢复流程中自动检测 | 触发 `onCancelReview` 恢复 |
| 失败弹窗确认 | `handleAcknowledgeModifyDestinationFail` | 恢复旧路线 |
| 页面离开/销毁 | `beforeBackRef` | 清理所有修改目的地缓存 |
| 预览态内点"编辑" | `onEditDestination` | 重新执行 `handleStartModifyDestination`(跳回选点页) |
**`exitModifyDestinationReview` 核心逻辑**:
- 参数 `shouldRestorePreviousEndSite`(默认 true):从 session 取旧终点恢复给页面
- 清空所有内存 state(previewEndSite / previousEndSite / previousFareEstimate / previousRoutePoints)
- 清空所有 storage 缓存(session / candidate / override / routePoints)
- 清空本地终点覆盖(overrideEndSite)
- 流程状态 → `IDLE`
---
## 八、关键 Storage Keys 一览
| Key | 用途 | 生命周期 |
|-----|------|---------|
| `modifyDestinationSession` | 修改会话(active / previousEndSite / orderId) | 进入选点页 → 确认/取消后清除 |
| `modifyDestinationCandidate` | 候选新终点(选点页写入) | 选点后写入 → 预览态读取后消费 |
| `modifyDestinationRoutePoints` | 旧路线点快照 | 启动时写入 → 取消后用于回滚路线 |
| `modifyDestinationOverride` | 本地终点覆盖值 | 修改确认后写入 → 后端推送同步后清除 |
| `modifyDestinationSkipAcQueryOnReturn` | 返回时跳过空调查询 | 选点返回时写入 → 消费后清除 |
| `modifyDestinationSocketStatus` | socket 推送的修改资格状态 | 实时更新 |
| `modifyDestinationPendingSameSiteToast` | 新旧终点相同 toast 标记 | 选点时写入 → 返回后消费 |
---
## 九、涉及的核心文件
| 文件 | 职责 |
|------|------|
| `hooks/use-modify-destination-flow.ts` | 流程状态管理、缓存读写、预览态恢复/退出 |
| `hooks/use-modify-destination-preview-route.ts` | 预览路线请求 + 60s 重估价倒计时 |
| `hooks/use-page-restore.ts` | 页面恢复编排(含 `getOrderActive()` 守卫) |
| `hooks/use-view-delegates.tsx` | 视图层委托(修改目的地确认卡片渲染) |
| `components/modify-destination-review.tsx` | 预览确认 UI 卡片组件 |
| `components/modify-destination-fail-popup.tsx` | 修改失败弹窗 |
| `components/calling-car-modules/trip-action-bar.tsx` | 底部操作栏(修改目的地按钮) |
| `pages/site-search/index.tsx` | 站点搜索页(选终点入口之一) |
| `pages/select-map-site/hooks/use-select-map-site.ts` | 地图选点页(选终点入口之二) |
| `constants/modify-destination.ts` | 所有 storage key 常量 + `isSameModifyDestinationSite` 工具函数 |
| `api/begin-route.ts` | `orderChange` API(修改目的地后端接口) |
---
## 十、模拟器环境已知问题
**问题**:在 DebugOrderSimulatorPanel 模拟器中,订单走到送驾状态后点击「修改目的地」,选完终点返回行程页时会自动退出回首页。
**根因**:
1. 模拟器仅注入 socket 事件驱动前端状态,不会在后端创建真实订单
2. 选点页返回后 reLaunch 重建行程页 → `useDidShow` → `restorePageStateFromStorage`
3. 因为 storage 中 `tripStatus = 6`(行程中),满足查询条件,调用 `getOrderActive()` 接口
4. 接口查不到活跃订单(模拟器无真实订单)→ 返回 null
5. 代码认为「订单已结束」→ 清缓存 + `Taro.redirectTo('/pages/robotaxi/home/index')`
**代码位置**:`hooks/use-page-restore.ts` 第 288-297 行
**修复建议**:在 `syncSitesFromActiveOrder` 中增加对模拟订单 ID(`SIM-` 前缀)的豁免,跳过 `getOrderActive()` 调用。
src/robotaxi/pages/begin-route/components/css-folder/calling-car.less
@@ -498,6 +498,7 @@
    &-item {
      display: flex;
      align-items: center;
      box-sizing: border-box;
      &-disabled {
@@ -511,6 +512,7 @@
      }
      &-destination {
        .trip-action-bar-item-icon {
          width: 44px;
          height: 44px;
@@ -520,7 +522,7 @@
      &-text {
        flex-shrink: 0;
        font-size: 24px;
        line-height: 40px;
        line-height: 24px;
        color: rgba(0, 0, 0, 0.9);
      }
    }
@@ -794,4 +796,4 @@
      height: 17.5px;
    }
  }
}
}
src/robotaxi/pages/begin-route/components/evaluate-popup.tsx
@@ -81,7 +81,11 @@
    };
    const evaluateDimension = evaluateDimensionMap[currentOrderRating] || 3; // 接口维度:1 满意,2 一般,3 不满意
    const shouldRequireFeedbackType = !hideFeedbackType;
    const canSubmit = shouldRequireFeedbackType ? selectedTypeIds.length > 0 && !!feedbackContent.trim() : !!feedbackContent.trim();
    const shouldHideFeedbackTextarea = submitMode === 'comment' && [2, 3].includes(currentOrderRating);
    const hasFeedbackContent = !!feedbackContent.trim();
    const canSubmit = shouldRequireFeedbackType
        ? selectedTypeIds.length > 0 && (shouldHideFeedbackTextarea || hasFeedbackContent)
        : (shouldHideFeedbackTextarea || hasFeedbackContent);
    const normalizeFeedbackTypeId = (typeId: unknown) => {
        if (typeId === null || typeId === undefined || typeId === '') {
            return '';
@@ -120,7 +124,7 @@
            });
            return;
        }
        if (!feedbackContent.trim()) {
        if (!shouldHideFeedbackTextarea && !feedbackContent.trim()) {
            Taro.showToast({
                title: '请输入问题描述',
                icon: 'none'
@@ -221,16 +225,18 @@
                            </View>
                        </View>
                    )}
                    <View className='evaluate-problem-desc-box'>
                        <Textarea
                            className='evaluate-problem-desc'
                            maxlength={500}
                            placeholderClass='placeholder-class'
                            placeholder={popupDisplayConfig.placeholder}
                            value={feedbackContent}
                            onInput={(e) => setFeedbackContent(e.detail.value)} />
                        <View className='evaluate-problem-desc-count'>{feedbackContent.length}/500</View>
                    </View>
                    {!shouldHideFeedbackTextarea && (
                        <View className='evaluate-problem-desc-box'>
                            <Textarea
                                className='evaluate-problem-desc'
                                maxlength={500}
                                placeholderClass='placeholder-class'
                                placeholder={popupDisplayConfig.placeholder}
                                value={feedbackContent}
                                onInput={(e) => setFeedbackContent(e.detail.value)} />
                            <View className='evaluate-problem-desc-count'>{feedbackContent.length}/500</View>
                        </View>
                    )}
                    <View className='evaluate-problem-pic-box'>
                        {pictures.map((p, idx) => (
                            <View key={idx} className='evaluate-problem-pic-box-item'>
src/robotaxi/pages/begin-route/hooks/use-map-viewport.ts
@@ -395,8 +395,10 @@
    return Promise.all([getMapScale(), getMapCenterLocation()])
      .then(([scaleRes, centerRes]) => {
        const actualScale = Number(scaleRes?.scale);
        const roundedScale = Number.isFinite(actualScale)
          ? Math.round(actualScale)
          : null;
        if (Number.isFinite(actualScale)) {
          const roundedScale = Math.round(actualScale);
          setMapScale(prev => (prev === roundedScale ? prev : roundedScale));
        }
        const centerLatitude = Number(centerRes?.latitude);
@@ -409,7 +411,7 @@
        }
      })
      .catch(() => {});
  }, [getMapScale, getMapCenterLocation, setMapScale, deps.setLatitude, deps.setLongitude]);
  }, [getMapScale, getMapCenterLocation, setMapScale, deps.setLatitude, deps.setLongitude, deps.tripStatus]);
  /**
   * 将经纬度映射为当前地图上的屏幕坐标。
@@ -753,10 +755,7 @@
    // order_detail 视口(行程已结束/支付成功)下的上车点 marker 会显示向上突出的 customCallout 气泡,
    // 需要在系统安全区之上再叠加 ≈灵动岛 + 气泡高度的缓冲,避免气泡被灵动岛/状态栏遮挡。
    // 数值与接驾态 ACTIVE_TRIP_CAMERA_TOP_EXTRA_PADDING 保持一致(80),其它视口沿用原有 32。
    const topInsetExtra =
      viewportType === "order_detail"
        ? ROUTE_VIEWPORT_TOP_EXTRA_PADDING + 10
        : ROUTE_VIEWPORT_TOP_EXTRA_PADDING;
    const topInsetExtra = ROUTE_VIEWPORT_TOP_EXTRA_PADDING
    const topPadding = Math.max(
      baseTopPadding,
      resolveSafeTopInset() + topInsetExtra
src/robotaxi/pages/begin-route/hooks/use-page-restore.ts
@@ -358,7 +358,9 @@
    } else if (s && s.latitude && s.longitude) {
      if([TripStatus.VehicleApproaching, TripStatus.InService].includes(cachedTripStatus)) {
        deps.getLocationInfo();
      } else {
      } else if (![TripStatus.TripFinished, TripStatus.PaymentSuccess].includes(cachedTripStatus)) {
        // 完成态(TripFinished/PaymentSuccess)地图中心由 useMapViewport 的 order_detail includePoints 主导,
        // 这里不再用上车点覆盖,否则受控中心会被拉到路线一端导致路线偏离屏幕中央。
        deps.setLatitude(s.latitude);
        deps.setLongitude(s.longitude);
      }
src/robotaxi/pages/begin-route/hooks/use-route-manager.shared.ts
@@ -19,7 +19,7 @@
/** 地图视口最小顶部内边距。 */
export const ROUTE_VIEWPORT_MIN_TOP_PADDING = 32;
/** 地图视口顶部附加安全区内边距。 */
export const ROUTE_VIEWPORT_TOP_EXTRA_PADDING = 50;
export const ROUTE_VIEWPORT_TOP_EXTRA_PADDING = 60;
/** 地图视口底部附加安全区内边距,因为地图的高度是 87vh,到时底部 padding 偏高,要减去一点 */
export const ROUTE_VIEWPORT_BOTTOM_EXTRA_PADDING = 0;
/** 地图视口左右内边距。 */
src/robotaxi/pages/begin-route/hooks/use-vehicle-tracking.ts
@@ -152,7 +152,7 @@
          longitude: task.longitude,
        },
        autoRotate: false,
        moveWithRotate: false,
        moveWithRotate: true,
        rotate: Math.round(task.rotate),
        duration,
        animationEnd: () => {
@@ -191,11 +191,23 @@
      // 降低车辆位置更新频率
      return;
    }
    if (Number.isFinite(lat) && Number.isFinite(lng)) {
      lastVehiclePointRef.current = { latitude: lat, longitude: lng };
      lastVehicleUpdateAtRef.current = now;
      setVehicleLocation({ latitude: lat, longitude: lng });
    if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
      return;
    }
    const lastPoint = lastVehiclePointRef.current;
    // 坐标与上次一致时跳过 setState:避免静止 / 重复点位推送产生无效重渲染
    // (朝向变化由 vehicleDirection 单独驱动,不依赖此处的 vehicleLocation 写入)。
    if (
      lastPoint
      && Math.abs(lastPoint.latitude - lat) <= VEHICLE_LOCATION_EPSILON
      && Math.abs(lastPoint.longitude - lng) <= VEHICLE_LOCATION_EPSILON
    ) {
      lastVehicleUpdateAtRef.current = now;
      return;
    }
    lastVehiclePointRef.current = { latitude: lat, longitude: lng };
    lastVehicleUpdateAtRef.current = now;
    setVehicleLocation({ latitude: lat, longitude: lng });
  };
  useEffect(() => {
src/robotaxi/pages/begin-route/hooks/use-view-delegates.tsx
@@ -213,11 +213,10 @@
    const { hasBeforeUserEndSite, routeDistance, durationDisplay } = props;
    return (
      <View
        className={`custom-callout-content ${
          hasBeforeUserEndSite
        className={`custom-callout-content ${hasBeforeUserEndSite
            ? "car-callout-with-before car-callout-double-line"
            : ""
        }`.trim()}
          }`.trim()}
        style={{ padding: "6px 10px" }}
      >
        {hasBeforeUserEndSite && (
@@ -292,11 +291,9 @@
  const routeSummaryText = useMemo(
    () =>
      deps.routeDistance && deps.routeDuration !== null
        ? `${vehicleMetricDurationDisplay.value}${
            vehicleMetricDurationDisplay.unit
          } ${deps.routeDistance.value}${
            deps.routeDistance.unit === "m" ? "米" : "公里"
          }`
        ? `${vehicleMetricDurationDisplay.value}${vehicleMetricDurationDisplay.unit
        } ${deps.routeDistance.value}${deps.routeDistance.unit === "m" ? "米" : "公里"
        }`
        : "",
    [deps.routeDistance, deps.routeDuration, vehicleMetricDurationDisplay]
  );
@@ -448,8 +445,9 @@
                    }}
                  >
                    {item.name}
                    {!deps.isAndroid && item.name.length > 16 && (
                      <CoverView style={{ color: "transparent",width: "3px" }}>占</CoverView>
                    {/* 包含非中文字符时占位  因为非中文字符渲染时存在额外间距(比如:‘地名123地名’ 会被渲染为:‘地名 123 地名’ ) 但是气泡宽度又没有自动计算上这个间距  导致名称会被截断 */}
                    {!deps.isAndroid && /[^\u4e00-\u9fa5]/.test(item.name) && (
                      <CoverView style={{ color: "transparent", width: "3px" }}>占</CoverView>
                    )}
                  </CoverView>
                  {deps.isAndroid && (
@@ -474,11 +472,10 @@
              !deps.isAndroid &&
              shouldShowVehicleMetricCallout && (
                <CoverView
                  className={`custom-callout-content ${
                    deps.hasBeforeUserEndSite
                  className={`custom-callout-content ${deps.hasBeforeUserEndSite
                      ? "car-callout-with-before car-callout-double-line"
                      : ""
                  }`.trim()}
                    }`.trim()}
                  style={{
                    padding: "6px 10px",
                    minWidth: "40px",
@@ -591,15 +588,15 @@
            {androidCarCalloutContent}
          </View>
        )}
      {deps.isAndroid && deps.arriveCalloutVisible && deps.arrivedTooltipReady && (
        <View className="car-callout-android" style={deps.arriveCalloutStyle}>
          <View className="custom-callout-content">
            已到达
        {deps.isAndroid && deps.arriveCalloutVisible && deps.arrivedTooltipReady && (
          <View className="car-callout-android" style={deps.arriveCalloutStyle}>
            <View className="custom-callout-content">
              已到达
            </View>
          </View>
        </View>
      )}
    </>
  ), [
        )}
      </>
    ), [
    androidCarCalloutContent,
    deps.arriveCalloutStyle,
    deps.arriveCalloutVisible,
src/robotaxi/pages/begin-route/hooks/use-web-socket-events.ts
@@ -65,10 +65,20 @@
    setRouteDistance: (value: any) => void;
    setRouteDuration: (value: number | null) => void;
    setModifyDestinationSocketStatus: (value: number | null) => void;
    setOrderRouteMetrics?: (metrics: {
        distanceMeters: number | null;
        durationSeconds: number | null;
    }) => void;
    setOrderRouteMetrics?: (
        metrics:
            | {
                distanceMeters: number | null;
                durationSeconds: number | null;
            }
            | ((prev: {
                distanceMeters: number | null;
                durationSeconds: number | null;
            }) => {
                distanceMeters: number | null;
                durationSeconds: number | null;
            })
    ) => void;
    setBeforeUserEndSite: (site: { longitude: number; latitude: number } | null) => void;
    setHasPreviousOrder: (value: boolean) => void;
    isModifyDestinationReviewVisible?: boolean;
src/robotaxi/pages/begin-route/hooks/web-socket-handlers/create-order-route-socket-handler.ts
@@ -70,16 +70,31 @@
  const durationSecondsRaw = Number(route?.duration);
  const distanceKm = normalizeDistanceKm(distanceMeters);
  if (distanceKm !== null) {
    deps.setRouteDistance(distanceKm);
    // 行程中后端会持续推送 ORDER_ROUTE(车辆静止时也在上报),normalizeDistanceKm 每次都会返回新对象,
    // 直接 setRouteDistance 会让每条消息都因新引用触发无效重渲染。这里用函数式更新做按值去重:
    // value/unit 未变化时返回旧引用,React 据此跳过本次重渲染。
    deps.setRouteDistance((prev: { value: number; unit: 'km' | 'm' } | null) =>
      prev && prev.value === distanceKm.value && prev.unit === distanceKm.unit ? prev : distanceKm
    );
  }
  const durationSeconds = normalizeDurationSeconds(durationSecondsRaw);
  if (durationSeconds !== null) {
    // number:值相同时 React 自动跳过重渲染,无需额外去重。
    deps.setRouteDuration(durationSeconds);
  }
  deps.setOrderRouteMetrics?.({
    distanceMeters: Number.isFinite(distanceMeters) ? distanceMeters : null,
    durationSeconds: Number.isFinite(durationSecondsRaw) ? durationSecondsRaw : null
  });
  const nextDistanceMeters = Number.isFinite(distanceMeters) ? distanceMeters : null;
  const nextDurationSeconds = Number.isFinite(durationSecondsRaw) ? durationSecondsRaw : null;
  // orderRouteMetrics 同理:函数式更新按值去重,未变化保持旧引用以跳过重渲染。
  deps.setOrderRouteMetrics?.((prev) =>
    prev
      && prev.distanceMeters === nextDistanceMeters
      && prev.durationSeconds === nextDurationSeconds
      ? prev
      : {
        distanceMeters: nextDistanceMeters,
        durationSeconds: nextDurationSeconds
      }
  );
};
/**
src/robotaxi/pages/begin-route/index.tsx
@@ -93,6 +93,19 @@
const COUPON_POPUP_CLOSE_VIEWPORT_REFRESH_DELAY_MS = 320; // 优惠券弹窗退场动画结束后,再刷新完成态视口
const ROUTE_SELECTABLE_TRIP_STATUSES = [TripStatus.PublishRoute, TripStatus.VehicleApproaching] as const; // 允许展示并切换候选路线的行程状态
const PICKUP_ROUTE_CHANGE_ELIGIBLE_ORDER_ID_KEY = 'pickupRouteChangeEligibleOrderId'; // 接驾态允许切路线的订单 ID,仅在当前订单生命周期内有效
const PASSENGER_NOTICE_REGIONCHANGE_COOLDOWN_MS = 1000;
/**
 * 归一化原生地图回传的 causedBy,兼容 update 的异常拼写。
 */
const normalizeRegionChangeCause = (causedBy: unknown) => {
  const normalized = String(causedBy || '').toLowerCase();
  if (['update', 'udpate', 'upudate'].includes(normalized)) {
    return 'update';
  }
  return normalized;
};
type StatusRule = {
  showPickup: boolean;
  showDropoff: boolean;
@@ -238,6 +251,7 @@
    longitudeRef.current = value;
  }, []);
  const globalFitSyncRef = useRef(false); // 标记全局 includePoints 触发,用于在 regionchange end 时同步原生缩放
  const passengerNoticeRegionChangeIgnoreUntilRef = useRef(0); // 乘车须知开关后的地图 regionchange(update) 冷却截止时间
  const markViewportFitSyncing = useCallback(() => {
    globalFitSyncRef.current = true;
  }, []);
@@ -1606,6 +1620,11 @@
    setDoorUnlockedInCurrentPage(false);
    handleDidShow(router.params?.skipPageFlag, router.params?.fromModifyDestination);
    updateMapViewportPadding();
    // 完成态下隐藏小程序再恢复时,受控中心点可能与原生地图实际中心脱钩,主动触发一次
    // order_detail includePoints 纠偏,避免路线被压在底部支付卡片附近。
    if ([TripStatus.TripFinished, TripStatus.PaymentSuccess].includes(tripStatusRef.current)) {
      setOrderDetailViewportRefreshVersion((v) => v + 1);
    }
  });
  /**
@@ -1679,19 +1698,21 @@
    setShowEvaluatePopup(true);
  };
  const handleOpenPassengerNotice = useCallback(async () => {
    if (tripStatus === TripStatus.PublishRoute) {
      await syncNativeViewportToControlledState();
    }
  const handleOpenPassengerNotice = useCallback(() => {
    pendingFitSyncRef.current = false;
    globalFitSyncRef.current = false;
    passengerNoticeRegionChangeIgnoreUntilRef.current =
      Date.now() + PASSENGER_NOTICE_REGIONCHANGE_COOLDOWN_MS;
    rawHandleOpenPassengerNotice();
  }, [tripStatus, syncNativeViewportToControlledState, rawHandleOpenPassengerNotice]);
  }, [rawHandleOpenPassengerNotice, pendingFitSyncRef]);
  const handleClosePassengerNotice = useCallback(async () => {
    if (tripStatus === TripStatus.PublishRoute) {
      await syncNativeViewportToControlledState();
    }
  const handleClosePassengerNotice = useCallback(() => {
    pendingFitSyncRef.current = false;
    globalFitSyncRef.current = false;
    passengerNoticeRegionChangeIgnoreUntilRef.current =
      Date.now() + PASSENGER_NOTICE_REGIONCHANGE_COOLDOWN_MS;
    rawHandleClosePassengerNotice();
  }, [tripStatus, syncNativeViewportToControlledState, rawHandleClosePassengerNotice]);
  }, [rawHandleClosePassengerNotice, pendingFitSyncRef]);
  const handleCloseEvaluatePopup = () => {
    setEvaluateCloseVersion(v => v + 1);
@@ -1709,13 +1730,18 @@
  }
  const handleRegionChange = (e) => {
    const causedBy = normalizeRegionChangeCause(e?.causedBy || e?.detail?.causedBy || '');
    const isPassengerNoticeRegionChangeCoolingDown =
      passengerNoticeRegionChangeIgnoreUntilRef.current > Date.now();
    if (causedBy === 'update' && isPassengerNoticeRegionChangeCoolingDown) {
      return;
    }
    activeTripScale.handleRegionChange(e);
    handleRouteSummaryRegionChange(e);
    handleAndroidOverlayRegionChange(e);
    // 用户手势导致的缩放/拖拽结束后,把原生地图的实际 scale 同步回 React 状态,
    // 避免受控 <Map scale={mapScale}> 与原生实际缩放漂移,影响后续视口编排判断。
    const causedBy = String(e?.causedBy || e?.detail?.causedBy || '').toLowerCase();
    const eventType = String(e?.type || e?.detail?.type || '').toLowerCase();
    if (eventType === 'end' && (causedBy === 'scale' || causedBy === 'drag' || causedBy === 'gesture')) {
        notifyUserMapGesture();
src/robotaxi/pages/select-map-site/components/select-map-site-view.tsx
@@ -71,7 +71,7 @@
  return (
    <View className="home-container">
      <Map
        id="map"
        id="select-site-map"
        longitude={deps.longitude}
        latitude={deps.latitude}
        scale={deps.mapScale}
src/robotaxi/pages/select-map-site/hooks/use-select-map-site.ts
@@ -459,7 +459,7 @@
   * 返回当前位置并重新吸附最近站点。
   */
  const toOrigin = () => {
    const mapCtx = Taro.createMapContext('map');
    const mapCtx = Taro.createMapContext('select-site-map');
    mapCtx.moveToLocation({
      success: () => {
        handleRegionChange({ type: 'end', causedBy: 'gesture' });
@@ -476,7 +476,7 @@
      Taro.showToast({ title: '暂无可选运营区域', icon: 'none' });
      return;
    }
    const mapCtx = Taro.createMapContext('map');
    const mapCtx = Taro.createMapContext('select-site-map');
    mapCtx.includePoints({
      points,
      padding: INCLUDE_POINTS_PADDING
@@ -496,7 +496,7 @@
    if (type !== 'end') return;
    state.setBounce(true);
    const mapCtx = Taro.createMapContext('map');
    const mapCtx = Taro.createMapContext('select-site-map');
    // 获取真实的缩放等级并通过回调传递出去,彻底阻断与 mapScale 状态的绑定,避免触发原生组件抖动
    mapCtx.getScale({
      success: (res) => {
@@ -600,7 +600,7 @@
    state.setLatitude(site.latitude);
    state.setLongitude(site.longitude);
    syncPinLocation(site.latitude, site.longitude);
    const mapCtx = Taro.createMapContext('map');
    const mapCtx = Taro.createMapContext('select-site-map');
    mapCtx.moveToLocation({
      latitude: site.latitude,
      longitude: site.longitude,
@@ -874,7 +874,7 @@
            updateMapData(lat, lng).then(({ isInside, electronicAreaId }) => {
              state.setRangeStatus(getRangeStatus(isInside, {}, electronicAreaId));
            });
            const mapCtx = Taro.createMapContext('map');
            const mapCtx = Taro.createMapContext('select-site-map');
            const points = extractPointsFromPolygons(newPolygons);
            mapCtx.includePoints({
              points,
src/robotaxi/pages/share-trip/hooks/use-vehicle-tracking.ts
@@ -142,7 +142,7 @@
                    longitude: task.longitude,
                },
                autoRotate: false,
                moveWithRotate: false,
                moveWithRotate: true,
                rotate: task.rotate,
                duration: Math.max(task.duration, 100),
                animationEnd: () => {
src/robotaxi/pages/site-search/index.tsx
@@ -79,6 +79,7 @@
  const MAX_SHOW_COUNT = 5 // 默认最多显示5条
  const MAX_EXPANDED_COUNT = 10
  const MAX_SHOW_HISTORY = 3 // 默认历史记录最多3条
  const RECOMMEND_SHOW_COUNT = 15 // 推荐站点默认展示数量
  const router = Taro.useRouter();
@@ -504,7 +505,7 @@
    : recommendSourceList;
  const sortedHistorySite = sortSitesByCollectedFirst(historySite);
  const visibleHistory = showAllHistory ? sortedHistorySite.slice(0, MAX_EXPANDED_COUNT) : sortedHistorySite.slice(0, MAX_SHOW_HISTORY);
  const visibleRecommend = showAllRecommend ? recommendList.slice(0, MAX_EXPANDED_COUNT) : recommendList.slice(0, MAX_SHOW_COUNT);
  const visibleRecommend = showAllRecommend ? recommendList : recommendList.slice(0, RECOMMEND_SHOW_COUNT);
  const canShowSearchFallback = !searchValue || (!isSearching && lastCompletedKeyword === searchValue.trim());
  useEffect(() => {
@@ -614,7 +615,7 @@
              }, true, true)
            }
          </View>
          {recommendList.length > MAX_SHOW_COUNT && (
          {recommendList.length > RECOMMEND_SHOW_COUNT && (
            <View className='show-more' hoverClass='none' onClick={toggleRecommendShow}>
              <Text className='more-text'>{showAllRecommend ? '收起' : '展示更多'}</Text>
              <Image className={`more-icon ${showAllRecommend ? 'rotate-up' : ''}`} src="https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/arrow.png"></Image>