hejianhao
2026-06-13 37f205cb33c08b21193b9e801c4469c1e55c97b8
feat(robotaxi/begin-route):

修改目的地预览,优化重复请求判断与状态管理
新增路线摘要气泡锚点适配逻辑,修复安卓地图气泡偏移问题
站点收藏选中的站点id转换
温度与车门控制的loading状态最少动画一秒结束
修复修改目的地预览页面的返回逻辑
3个文件已添加
10个文件已修改
616 ■■■■■ 已修改文件
src/robotaxi/pages/begin-route/components/calling-car-modules/door-trunk-control.tsx 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/components/calling-car-modules/temperature-control.tsx 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/components/calling-car-modules/use-minimum-spin-loading.ts 72 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/components/calling-car.tsx 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/hooks/route-summary-anchor.ts 120 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/hooks/use-android-overlays.ts 65 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/hooks/use-modify-destination-flow.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/hooks/use-modify-destination-preview-route.ts 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/hooks/use-route-summary-anchor-location.ts 157 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/hooks/use-view-delegates.tsx 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/index.less 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/index.tsx 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/site-search/index.tsx 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/components/calling-car-modules/door-trunk-control.tsx
@@ -4,6 +4,7 @@
import { unlockVehicle, queryVehicleTrunkOpenStatus } from '../../../../api/begin-route';
import { getOrderId } from '../../../../utils/order';
import { TripStatus } from '../../../../constants/trip-status';
import useMinimumSpinLoading from './use-minimum-spin-loading';
const LOCK_CLOSE_ICON_URL = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/lock-close.png';
const LOCK_OPEN_ICON_URL = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/lock-open.png';
@@ -30,10 +31,10 @@
}: Props) {
    const [trunkStatus, setTrunkStatus] = useState(orderTrunkStatus); // 后备箱开关状态
    const [lockStatus, setLockStatus] = useState(false); // 车门开关状态
    const [loadingControlType, setLoadingControlType] = useState<'door' | 'trunk' | null>(null); // 当前处于指令执行中的控件
    const trunkPollTimerRef = useRef<any>(null); // 后备箱状态轮询定时器
    const overRoutePopupDebounceTimerRef = useRef<any>(null); // 结束行程弹窗防抖定时器
    const OVER_ROUTE_POPUP_DEBOUNCE_MS = 1000; // 结束行程弹窗点击防抖时长
    const { visibleLoadingKey: loadingControlType, startLoading, stopLoading } = useMinimumSpinLoading<'door' | 'trunk'>();
    // 外部后备箱状态变化时同步本地状态
    useEffect(() => {
@@ -123,7 +124,7 @@
            Taro.showToast({ title: '获取定位失败,请打开定位开关', icon: 'none' });
            return;
        }
        setLoadingControlType(doorType);
        startLoading(doorType);
        const isCloseAction = doorType === 'trunk' ? !!trunkStatus : false;
        const actionLoadingText = doorType === 'trunk'
            ? (isCloseAction ? '正在关闭后备箱' : '正在开启后备箱')
@@ -138,7 +139,7 @@
            if (res?.code !== 200 || !res?.data) {
                // Taro.hideLoading();
                setLoadingControlType(null);
                stopLoading(doorType);
                Taro.showToast({ title: res?.msg || '解锁失败,请重试或联系客服', icon: 'none' });
                return;
            }
@@ -165,7 +166,7 @@
                        trunkPollTimerRef.current = setTimeout(doPoll, pollInterval);
                    } else {
                        stopPolling();
                        setLoadingControlType(null);
                        stopLoading(doorType);
                        // Taro.hideLoading();
                        Taro.showToast({
                            title: failToastText,
@@ -198,7 +199,7 @@
                            stopPolling();
                            const isOpen = !isCloseAction;
                            setTrunkStatus(isOpen);
                            setLoadingControlType(null);
                            stopLoading(doorType);
                            // Taro.hideLoading();
                            Taro.showToast({
                                title: successToastText,
@@ -217,7 +218,7 @@
            if (doorType === 'door') {
                // Taro.hideLoading();
                setLoadingControlType(null);
                stopLoading(doorType);
                Taro.showToast({ title: '右侧车门已解锁', icon: 'none' });
                setLockStatus(true);
                onDoorUnlockSuccess?.();
@@ -225,7 +226,7 @@
            }
        }).catch(() => {
            // Taro.hideLoading();
            setLoadingControlType(null);
            stopLoading(doorType);
            Taro.showToast({ title: '解锁失败,请重试或联系客服', icon: 'none' });
        });
    };
src/robotaxi/pages/begin-route/components/calling-car-modules/temperature-control.tsx
@@ -14,6 +14,7 @@
import { TripStatus } from '../../../../constants/trip-status';
import { MODIFY_DESTINATION_SKIP_AC_QUERY_ON_RETURN_KEY } from '../../../../constants/modify-destination';
import { getAcStorage, saveAcStorage, removeAcStorage } from './ac-storage';
import useMinimumSpinLoading from './use-minimum-spin-loading';
const AC_SWITCH_OPEN_ICON = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/T-switch-open.png';
const AC_SWITCH_CLOSE_ICON = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/T-switch-close.png';
@@ -33,16 +34,17 @@
    const [tempStatus, setTempStatus] = useState<number | null>(null);
    const [acSwitchOn, setAcSwitchOn] = useState(false);
    const [acStatusLoaded, setAcStatusLoaded] = useState(false);
    const [isTogglingAc, setIsTogglingAc] = useState(false);
    const tempTimerRef = useRef<any>(null);
    const lastConfirmedTempRef = useRef<number | null>(null);
    const skipQueryOnThisCycleRef = useRef(false);
    const userActionSeqRef = useRef(0);
    const calibrationSeqRef = useRef(0);
    const { visibleLoadingKey: acLoadingKey, startLoading, stopLoading } = useMinimumSpinLoading<'ac'>();
    const TEMP_MIN = 22;
    const TEMP_MAX = 28;
    const TEMP_UPDATE_DEBOUNCE_MS = 500;
    const DEFAULT_FIRST_TEMP = 23.5;
    const isTogglingAc = acLoadingKey === 'ac';
    const canToggleSwitch = tripStatus === TripStatus.VehicleApproaching && !isTogglingAc;
    const canAdjustTemperature = canToggleSwitch && acStatusLoaded && acSwitchOn && typeof tempStatus === 'number';
@@ -96,7 +98,7 @@
        setTempStatus(DEFAULT_FIRST_TEMP);
        lastConfirmedTempRef.current = DEFAULT_FIRST_TEMP;
        setAcStatusLoaded(true);
        setIsTogglingAc(true);
        startLoading('ac');
        let acSuccess = false;
        let tempSuccess = false;
@@ -113,7 +115,7 @@
            }
        } catch {}
        setIsTogglingAc(false);
        stopLoading('ac');
        if (acSuccess && tempSuccess) {
            Taro.showToast({ title: '已开启空调', icon: 'none' });
@@ -273,7 +275,7 @@
            return;
        }
        const targetOpenState = !acSwitchOn;
        setIsTogglingAc(true);
        startLoading('ac');
        try {
            // 200就认为是指令下发成功了
            await airConditionerUnlock({
@@ -302,7 +304,7 @@
        } catch (error) {
            Taro.showToast({ title: AC_TOGGLE_FAIL_TEXT, icon: 'none' });
        } finally {
            setIsTogglingAc(false);
            stopLoading('ac');
        }
    };
src/robotaxi/pages/begin-route/components/calling-car-modules/use-minimum-spin-loading.ts
New file
@@ -0,0 +1,72 @@
import { useCallback, useEffect, useRef, useState } from 'react';
/** loading 动画至少完成一圈的展示时长(毫秒)。 */
const MINIMUM_SPIN_DURATION_MS = 1000;
/**
 * 控制 loading 动画最短展示时长,避免图标未完成一圈旋转就切回普通态。
 */
export default function useMinimumSpinLoading<T extends string>() {
  /** 当前处于 loading 展示中的控件标识。 */
  const [visibleLoadingKey, setVisibleLoadingKey] = useState<T | null>(null);
  /** 当前 loading 开始时间戳。 */
  const loadingStartedAtRef = useRef(0);
  /** 延迟关闭 loading 的定时器。 */
  const loadingFinishTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  /**
   * 清理延迟关闭定时器。
   */
  const clearLoadingFinishTimer = useCallback(() => {
    if (loadingFinishTimerRef.current) {
      clearTimeout(loadingFinishTimerRef.current);
      loadingFinishTimerRef.current = null;
    }
  }, []);
  useEffect(() => {
    return () => {
      clearLoadingFinishTimer();
    };
  }, [clearLoadingFinishTimer]);
  /**
   * 开始展示 loading 动画。
   */
  const startLoading = useCallback((key: T) => {
    clearLoadingFinishTimer();
    loadingStartedAtRef.current = Date.now();
    setVisibleLoadingKey(key);
  }, [clearLoadingFinishTimer]);
  /**
   * 结束 loading 动画;若未满一圈则延迟到一圈完成后再关闭。
   */
  const stopLoading = useCallback((key?: T | null) => {
    setVisibleLoadingKey((currentKey) => {
      if (!currentKey) {
        return currentKey;
      }
      if (key && currentKey !== key) {
        return currentKey;
      }
      const elapsed = Date.now() - loadingStartedAtRef.current;
      const remaining = Math.max(0, MINIMUM_SPIN_DURATION_MS - elapsed);
      clearLoadingFinishTimer();
      if (remaining === 0) {
        return null;
      }
      loadingFinishTimerRef.current = setTimeout(() => {
        setVisibleLoadingKey((latestKey) => (latestKey === currentKey ? null : latestKey));
        loadingFinishTimerRef.current = null;
      }, remaining);
      return currentKey;
    });
  }, [clearLoadingFinishTimer]);
  return {
    visibleLoadingKey,
    startLoading,
    stopLoading
  };
}
src/robotaxi/pages/begin-route/components/calling-car.tsx
@@ -2,8 +2,6 @@
import './css-folder/calling-car.less'
import { formatRemainingDurationDisplay } from '../../../utils/format';
import { TripStatus } from '../../../constants/trip-status';
import useTripCountdown from './calling-car-modules/use-trip-countdown'; // 行程倒计时
import TripStatusHeader from './calling-car-modules/trip-status-header'; // 行程状态头
import VehicleInfoCard from './calling-car-modules/vehicle-info-card'; // 车辆信息卡片
import TemperatureControl from './calling-car-modules/temperature-control'; // 温度控制
@@ -24,10 +22,6 @@
    handleCancelTimeout?: () => void;
    /** 到达终点后倒计时结束回调 */
    handleFinishTimeout?: () => void;
    /** 重置费用明细展开态的版本号 */
    resetDetailVersion?: number;
    /** 服务端推送价格更新版本号 */
    priceUpdateVersion?: number;
    /** 外部触发“评价已提交”版本号 */
    evaluateSubmitVersion?: number;
    /** 外部触发“评价弹窗关闭”版本号 */
@@ -48,12 +42,22 @@
    showModifyDestination?: boolean;
    /** 分享行程 */
    handleShareTrip?: () => void;
    /** 预估等待分钟数 */
    estimatedWaitMinutes?: number;
    /** 预估行程秒数 */
    routeDurationSeconds?: number | null;
    /** 是否启用到达终点后的离车倒计时 */
    finishCountdownEnabled?: boolean;
    /** 重置费用明细展开态的版本号 */
    resetDetailVersion?: number;
    /** 预计等待分钟数 */
    waitMinutes?: number | null;
    /** 上车倒计时限制分钟数 */
    limitMinutes?: number | null;
    /** 上车倒计时秒数 */
    pickupCountdownSeconds?: number | null;
    /** 下车倒计时秒数 */
    finishCountdownSeconds?: number | null;
    /** 费用明细 */
    fareBreakdown?: any;
    /** 车辆信息 */
    vehicleInfo?: {
        vehiclePictures?: string;
@@ -91,8 +95,6 @@
    handleOpenCancelPopup,
    handleCancelTimeout,
    handleFinishTimeout,
    resetDetailVersion,
    priceUpdateVersion,
    evaluateSubmitVersion,
    evaluateCloseVersion,
    handleOpenOverRoutePopup,
@@ -103,9 +105,14 @@
    handleModifyDestination,
    showModifyDestination = true,
    handleShareTrip,
    estimatedWaitMinutes,
    routeDurationSeconds,
    finishCountdownEnabled = false,
    resetDetailVersion,
    waitMinutes = null,
    limitMinutes = null,
    pickupCountdownSeconds = null,
    finishCountdownSeconds = null,
    fareBreakdown = null,
    vehicleInfo,
    orderId,
    hasBeforeUserEndSite = false,
@@ -129,23 +136,6 @@
    onOpenCouponSelect,
    queuePollingTimeSeconds = 10
}: Props) {
    // 汇总行程中的倒计时与费用数据
    const {
        waitMinutes,
        limitMinutes,
        pickupCountdownSeconds,
        finishCountdownSeconds,
        fareBreakdown
    } = useTripCountdown({
        tripStatus,
        orderId,
        estimatedWaitMinutes,
        finishCountdownEnabled,
        handleCancelTimeout,
        handleFinishTimeout,
        priceUpdateVersion
    });
    /** 预计到达时长展示(秒/分钟) */
    const durationDisplay = formatRemainingDurationDisplay(routeDurationSeconds);
    /** 车辆即将到达上车点(<=60秒) */
src/robotaxi/pages/begin-route/hooks/route-summary-anchor.ts
New file
@@ -0,0 +1,120 @@
/** 路线经纬度点。 */
export type RouteSummaryGeoPoint = {
  latitude: number;
  longitude: number;
};
/** 路线摘要气泡预估宽度。 */
export const ROUTE_SUMMARY_CALLOUT_WIDTH = 190;
/** 路线摘要气泡预估高度。 */
export const ROUTE_SUMMARY_CALLOUT_HEIGHT = 45;
/** 路线摘要气泡左右安全边距。 */
export const ROUTE_SUMMARY_SAFE_SIDE_MARGIN = 12;
/** 路线摘要气泡顶部安全边距。 */
export const ROUTE_SUMMARY_SAFE_TOP_MARGIN = 12;
/** 路线摘要气泡底部安全边距。 */
export const ROUTE_SUMMARY_SAFE_BOTTOM_MARGIN = 12;
/** 屏幕坐标点。 */
export type RouteSummaryScreenPoint = {
  x: number;
  y: number;
};
/** 路线摘要气泡安全边界。 */
export type RouteSummaryBubbleBounds = {
  safeLeft: number;
  safeRight: number;
  safeTop: number;
  safeBottom: number;
  bubbleWidth: number;
  bubbleHeight: number;
};
/** 路线摘要锚点解析入参。 */
export type ResolveRouteSummaryAnchorInput = {
  routePoints: RouteSummaryGeoPoint[];
  fallbackPoint: RouteSummaryGeoPoint | null;
  bounds: RouteSummaryBubbleBounds;
  projectToScreen: (point: RouteSummaryGeoPoint) => RouteSummaryScreenPoint | null;
};
/**
 * 判断候选路线点是否有效。
 */
const isValidRoutePoint = (point: RouteSummaryGeoPoint | null | undefined) => (
  Number.isFinite(Number(point?.latitude)) && Number.isFinite(Number(point?.longitude))
);
/**
 * 判断气泡在当前候选锚点下是否完全位于安全区内。
 */
const isBubbleInsideSafeBounds = (
  point: RouteSummaryScreenPoint,
  bounds: RouteSummaryBubbleBounds
) => (
  point.x >= bounds.safeLeft
  && point.x + bounds.bubbleWidth <= bounds.safeRight
  && point.y >= bounds.safeTop
  && point.y + bounds.bubbleHeight <= bounds.safeBottom
);
/**
 * 以路线中点为中心向两侧扩散,生成候选索引顺序。
 */
const buildCandidateIndices = (pointCount: number) => {
  if (pointCount <= 0) {
    return [];
  }
  const midpointIndex = Math.max(0, Math.floor(pointCount / 2));
  const sampleStep = pointCount > 120 ? 3 : pointCount > 60 ? 2 : 1;
  const visited = new Set<number>();
  const indices: number[] = [];
  for (let offset = 0; offset < pointCount; offset += sampleStep) {
    const rightIndex = midpointIndex + offset;
    if (rightIndex >= 0 && rightIndex < pointCount && !visited.has(rightIndex)) {
      visited.add(rightIndex);
      indices.push(rightIndex);
    }
    if (offset === 0) {
      continue;
    }
    const leftIndex = midpointIndex - offset;
    if (leftIndex >= 0 && leftIndex < pointCount && !visited.has(leftIndex)) {
      visited.add(leftIndex);
      indices.push(leftIndex);
    }
  }
  if (!visited.has(midpointIndex)) {
    indices.unshift(midpointIndex);
  }
  return indices;
};
/**
 * 解析路线摘要气泡应挂载的最佳路线点。
 */
export const resolveRouteSummaryAnchorPoint = (
  input: ResolveRouteSummaryAnchorInput
) => {
  const { routePoints, fallbackPoint, bounds, projectToScreen } = input;
  const candidateIndices = buildCandidateIndices(routePoints.length);
  for (const candidateIndex of candidateIndices) {
    const candidatePoint = routePoints[candidateIndex];
    if (!isValidRoutePoint(candidatePoint)) {
      continue;
    }
    const screenPoint = projectToScreen(candidatePoint);
    if (!screenPoint) {
      continue;
    }
    if (isBubbleInsideSafeBounds(screenPoint, bounds)) {
      return candidatePoint;
    }
  }
  return isValidRoutePoint(fallbackPoint) ? fallbackPoint : null;
};
src/robotaxi/pages/begin-route/hooks/use-android-overlays.ts
@@ -22,6 +22,12 @@
const CAR_CALLOUT_BOTTOM_GAP = 34;
type MapProjectionContext = {
  rect: any;
  southwest: { latitude: number; longitude: number };
  northeast: { latitude: number; longitude: number };
};
export default function useAndroidOverlays(deps: Deps) {
  // 等待雷达样式与显示状态
  const [waitingRadarStyle, setWaitingRadarStyle] = useState<any>({});
@@ -37,30 +43,57 @@
  const [routeSummaryCalloutVisible, setRouteSummaryCalloutVisible] = useState(false);
  const mapRectRef = useRef<any>(null);
  // 经纬度转屏幕坐标
  const computeScreenPosition = (target: { latitude: number; longitude: number }, rect: any, transformTopOffset = 0) => {
    if (!target || !rect) return null;
  // 读取地图投影上下文,供路线点批量转换屏幕坐标使用。
  const resolveMapProjectionContext = (rect: any) => {
    if (!rect) return Promise.resolve<MapProjectionContext | null>(null);
    return new Promise<any>((resolve) => {
      Taro.createMapContext('map').getRegion({
        success: (res) => {
          const sw = res?.southwest;
          const ne = res?.northeast;
          const latRange = ne?.latitude - sw?.latitude;
          const lngRange = ne?.longitude - sw?.longitude;
          if (!latRange || !lngRange) {
          if (!sw || !ne) {
            resolve(null);
            return;
          }
          const x = ((target.longitude - sw.longitude) / lngRange) * rect.width;
          const y = ((ne.latitude - target.latitude) / latRange) * rect.height;
          resolve({
            x: rect.left + x,
            y: rect.top + y + transformTopOffset,
            rect,
            southwest: sw,
            northeast: ne
          });
        },
        fail: () => resolve(null)
      });
    });
  };
  // 经纬度转屏幕坐标。
  const projectScreenPosition = (
    target: { latitude: number; longitude: number },
    projection: MapProjectionContext | null,
    transformTopOffset = 0
  ) => {
    if (!target || !projection) return null;
    const latRange = projection.northeast.latitude - projection.southwest.latitude;
    const lngRange = projection.northeast.longitude - projection.southwest.longitude;
    if (!latRange || !lngRange) {
      return null;
    }
    const x = ((target.longitude - projection.southwest.longitude) / lngRange) * projection.rect.width;
    const y = ((projection.northeast.latitude - target.latitude) / latRange) * projection.rect.height;
    return {
      x: projection.rect.left + x,
      y: projection.rect.top + y + transformTopOffset,
    };
  };
  // 经纬度转屏幕坐标。
  const computeScreenPosition = async (
    target: { latitude: number; longitude: number },
    rect: any,
    transformTopOffset = 0
  ) => {
    const projection = await resolveMapProjectionContext(rect);
    return projectScreenPosition(target, projection, transformTopOffset);
  };
  // 固定层样式:节点锚定在 (0,0),仅通过 transform 位移,减少 left/top 频繁更新导致的重排闪动。
@@ -155,12 +188,10 @@
      setRouteSummaryCalloutVisible(false);
      return;
    }
    setRouteSummaryCalloutStyle({
      position: 'fixed',
      left: `${pos.x}px`,
      top: `${pos.y}px`,
      transform: 'translate(0, 0)'
    });
    setRouteSummaryCalloutStyle(buildTransformOverlayStyle({
      x: pos.x,
      y: pos.y
    }, ''));
    setRouteSummaryCalloutVisible(true);
  };
@@ -205,7 +236,7 @@
  useEffect(() => {
    updateRouteSummaryCalloutPosition();
  }, [deps.isAndroid, deps.routeSummaryLocation?.latitude, deps.routeSummaryLocation?.longitude, deps.mapScale]);
  }, [deps.isAndroid, deps.routeSummaryLocation?.latitude, deps.routeSummaryLocation?.longitude, deps.mapScale, deps.tripStatus]);
  useEffect(() => {
    if (!deps.isAndroid || deps.tripStatus !== TripStatus.ArrivedDestination) return;
src/robotaxi/pages/begin-route/hooks/use-modify-destination-flow.ts
@@ -384,7 +384,7 @@
    try {
      setIsConfirmingModifyDestination(true);
      setFlowState(FLOW_STATES.CONFIRMING);
      Taro.showLoading({ title: '正在向车辆发起修改目的地请求', mask: true }); // 修改目的地请求进行中全局 loading
      Taro.showLoading({ title: '正在修改目的地', mask: true }); // 修改目的地请求进行中全局 loading
      // 如果后端需要字符串格式可以显式转String,如果后端支持number直接传值,保留原始格式防止隐式转换截断
      // 如果原本因为 Number() 导致大整数丢失精度,去掉 Number() 即可
src/robotaxi/pages/begin-route/hooks/use-modify-destination-preview-route.ts
@@ -102,7 +102,8 @@
 * 修改目的地预览路线编排:负责首次预览请求与确认态超时重估价。
 */
export default function useModifyDestinationPreviewRoute(deps: Deps) {
  const previewRouteKeyRef = useRef(''); // 预览路线请求签名,避免重复请求
  const hasRequestedCurrentPreviewRef = useRef(false); // 当前预览态是否已完成首次预览请求
  const wasPreviewVisibleRef = useRef(false); // 记录上一帧是否处于修改目的地预览态
  const [pendingConfirmRequote, setPendingConfirmRequote] = useState(false); // 确认态停留超过 60 秒后,点击确认前需要先重估价
  /**
@@ -117,16 +118,26 @@
  };
  useEffect(() => {
    const routeContext = resolveModifyDestinationRouteContext(deps);
    if (!routeContext) {
      previewRouteKeyRef.current = '';
    const isPreviewVisible = deps.isModifyDestinationReviewVisible;
    const hasEnteredPreview = isPreviewVisible && !wasPreviewVisibleRef.current;
    wasPreviewVisibleRef.current = isPreviewVisible;
    if (!isPreviewVisible) {
      hasRequestedCurrentPreviewRef.current = false;
      setPendingConfirmRequote(false);
      return;
    }
    if (routeContext.requestKey === previewRouteKeyRef.current) {
    if (hasEnteredPreview) {
      hasRequestedCurrentPreviewRef.current = false;
    }
    if (hasRequestedCurrentPreviewRef.current) {
      return;
    }
    previewRouteKeyRef.current = routeContext.requestKey;
    const routeContext = resolveModifyDestinationRouteContext(deps);
    if (!routeContext) {
      return;
    }
    hasRequestedCurrentPreviewRef.current = true;
    setPendingConfirmRequote(false);
    clearSelectedCouponBeforePreview();
    deps.requestRouteAndFare(
@@ -145,7 +156,6 @@
    deps.tripStatus,
    deps.isModifyDestinationReviewVisible,
    deps.hasLocalDestinationOverride,
    deps.pageDidShowVersion,
    deps.effectiveEndSite?.id,
    deps.effectiveEndSite?.latitude,
    deps.effectiveEndSite?.longitude,
src/robotaxi/pages/begin-route/hooks/use-route-summary-anchor-location.ts
New file
@@ -0,0 +1,157 @@
import Taro from '@tarojs/taro';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
  resolveRouteSummaryAnchorPoint,
  ROUTE_SUMMARY_CALLOUT_HEIGHT,
  ROUTE_SUMMARY_SAFE_BOTTOM_MARGIN,
  ROUTE_SUMMARY_SAFE_SIDE_MARGIN,
  ROUTE_SUMMARY_SAFE_TOP_MARGIN,
  ROUTE_SUMMARY_CALLOUT_WIDTH,
  type RouteSummaryGeoPoint
} from './route-summary-anchor';
type Deps = {
  enabled: boolean;
  routeSummaryLocation: RouteSummaryGeoPoint | null;
  routePoints?: RouteSummaryGeoPoint[];
};
type MapProjectionContext = {
  rect: any;
  southwest: { latitude: number; longitude: number };
  northeast: { latitude: number; longitude: number };
};
/**
 * 路线摘要气泡锚点解析:优先选择屏幕内可见且不碰撞底部卡片的路线点。
 */
export default function useRouteSummaryAnchorLocation(deps: Deps) {
  /** 当前解析出的路线摘要锚点。 */
  const [routeSummaryAnchorLocation, setRouteSummaryAnchorLocation] = useState<RouteSummaryGeoPoint | null>(null);
  /** 地图容器矩形缓存。 */
  const mapRectRef = useRef<any>(null);
  /**
   * 同时量取地图区域与底部卡片区域。
   */
  const measureLayout = useCallback(() => new Promise<any>((resolve) => {
    Taro.createSelectorQuery()
      .select('#map')
      .boundingClientRect()
      .select('.container')
      .boundingClientRect()
      .exec((res) => {
        const mapRect = res?.[0];
        const containerRect = res?.[1];
        if (mapRect) {
          mapRectRef.current = mapRect;
        }
        resolve({
          mapRect,
          containerRect
        });
      });
  }), []);
  /**
   * 获取当前地图投影上下文。
   */
  const resolveMapProjectionContext = useCallback((rect: any) => {
    if (!rect) {
      return Promise.resolve<MapProjectionContext | null>(null);
    }
    return new Promise<MapProjectionContext | null>((resolve) => {
      Taro.createMapContext('map').getRegion({
        success: (res) => {
          const southwest = res?.southwest;
          const northeast = res?.northeast;
          if (!southwest || !northeast) {
            resolve(null);
            return;
          }
          resolve({
            rect,
            southwest,
            northeast
          });
        },
        fail: () => resolve(null)
      });
    });
  }, []);
  /**
   * 将路线点投影到屏幕坐标。
   */
  const projectToScreen = useCallback((point: RouteSummaryGeoPoint, projection: MapProjectionContext | null) => {
    if (!point || !projection) {
      return null;
    }
    const latRange = projection.northeast.latitude - projection.southwest.latitude;
    const lngRange = projection.northeast.longitude - projection.southwest.longitude;
    if (!latRange || !lngRange) {
      return null;
    }
    return {
      x: projection.rect.left + ((point.longitude - projection.southwest.longitude) / lngRange) * projection.rect.width,
      y: projection.rect.top + ((projection.northeast.latitude - point.latitude) / latRange) * projection.rect.height
    };
  }, []);
  /**
   * 重新计算路线摘要锚点。
   */
  const updateRouteSummaryAnchorLocation = useCallback(async () => {
    if (!deps.enabled || !deps.routeSummaryLocation) {
      setRouteSummaryAnchorLocation(null);
      return;
    }
    const layout = await measureLayout();
    const rect = layout?.mapRect || mapRectRef.current;
    const projection = await resolveMapProjectionContext(rect);
    if (!rect || !projection) {
      setRouteSummaryAnchorLocation(deps.routeSummaryLocation);
      return;
    }
    const mapRight = rect.left + rect.width;
    const mapBottom = rect.top + rect.height;
    const containerTop = Number(layout?.containerRect?.top);
    const safeBottomBoundary = Number.isFinite(containerTop)
      ? Math.min(mapBottom - ROUTE_SUMMARY_SAFE_BOTTOM_MARGIN, containerTop - ROUTE_SUMMARY_SAFE_BOTTOM_MARGIN)
      : mapBottom - ROUTE_SUMMARY_SAFE_BOTTOM_MARGIN;
    const nextResolvedAnchor = resolveRouteSummaryAnchorPoint({
      routePoints: Array.isArray(deps.routePoints) ? deps.routePoints : [],
      fallbackPoint: deps.routeSummaryLocation,
      bounds: {
        safeLeft: rect.left + ROUTE_SUMMARY_SAFE_SIDE_MARGIN,
        safeRight: mapRight - ROUTE_SUMMARY_SAFE_SIDE_MARGIN,
        safeTop: rect.top + ROUTE_SUMMARY_SAFE_TOP_MARGIN,
        safeBottom: safeBottomBoundary,
        bubbleWidth: ROUTE_SUMMARY_CALLOUT_WIDTH,
        bubbleHeight: ROUTE_SUMMARY_CALLOUT_HEIGHT
      },
      projectToScreen: (point) => projectToScreen(point, projection)
    });
    setRouteSummaryAnchorLocation(nextResolvedAnchor || deps.routeSummaryLocation);
  }, [deps.enabled, deps.routePoints, deps.routeSummaryLocation, measureLayout, projectToScreen, resolveMapProjectionContext]);
  useEffect(() => {
    updateRouteSummaryAnchorLocation();
  }, [updateRouteSummaryAnchorLocation]);
  /**
   * 地图视野变化结束后,重新解析路线摘要锚点。
   */
  const handleRegionChange = useCallback((event: any) => {
    const eventType = String(event?.type || event?.detail?.type || '').toLowerCase();
    if (eventType === 'end') {
      updateRouteSummaryAnchorLocation();
    }
  }, [updateRouteSummaryAnchorLocation]);
  return {
    routeSummaryAnchorLocation,
    updateRouteSummaryAnchorLocation,
    handleRegionChange
  };
}
src/robotaxi/pages/begin-route/hooks/use-view-delegates.tsx
@@ -5,6 +5,7 @@
import ReleaseBeBeing from '../components/release-be-being';
import CallingCar from '../components/calling-car';
import ModifyDestinationReview from '../components/modify-destination-review';
import useTripCountdown from '../components/calling-car-modules/use-trip-countdown';
import { TripStatus } from '../../../constants/trip-status';
import { formatRemainingDurationDisplay } from '../../../utils/format';
@@ -209,6 +210,21 @@
 * 页面展示委托:地图覆盖物与行程卡片渲染。
 */
export default function useViewDelegates(deps: Deps) {
  const {
    waitMinutes,
    limitMinutes,
    pickupCountdownSeconds,
    finishCountdownSeconds,
    fareBreakdown
  } = useTripCountdown({
    tripStatus: deps.tripStatus,
    orderId: deps.orderId,
    estimatedWaitMinutes: deps.fareEstimate?.waitingTime,
    finishCountdownEnabled: deps.finishCountdownEnabled,
    handleCancelTimeout: deps.handlePickupTimeoutAutoCancel,
    handleFinishTimeout: deps.handleFinishCountdownTimeout,
    priceUpdateVersion: deps.priceUpdateVersion
  });
  /** 时长文案 */
  const durationDisplay = useMemo(() => formatRemainingDurationDisplay(deps.routeDuration), [deps.routeDuration]);
  /** 车辆公里/分钟气泡专用时长文案:不足 1 分钟时统一展示 1 分钟。 */
@@ -221,9 +237,9 @@
  const shouldShowVehicleMetricCallout = !deps.isModifyDestinationReviewVisible;
  const routeSummaryText = useMemo(() => (
    deps.routeDistance && deps.routeDuration !== null
      ? `${durationDisplay.value}${durationDisplay.unit} ${deps.routeDistance.value}${deps.routeDistance.unit === 'm' ? '米' : '公里'}`
      ? `${vehicleMetricDurationDisplay.value}${vehicleMetricDurationDisplay.unit} ${deps.routeDistance.value}${deps.routeDistance.unit === 'm' ? '米' : '公里'}`
      : ''
  ), [deps.routeDistance, deps.routeDuration, durationDisplay]);
  ), [deps.routeDistance, deps.routeDuration, vehicleMetricDurationDisplay]);
  const shouldHideAndroidMarkerCallout = deps.isAndroid && [TripStatus.PublishRoute, TripStatus.VehicleArrivedPickup, TripStatus.TripFinished, TripStatus.PaymentSuccess, TripStatus.CancelOnlyPickup].includes(deps.tripStatus); // Android 指定状态下隐藏常规 marker 气泡
  const androidCarCalloutContent = useMemo(() => (
    <AndroidCarCalloutContent
@@ -452,17 +468,18 @@
          handleModifyDestination={deps.handleModifyDestination}
          showModifyDestination={deps.showModifyDestination}
          handleShareTrip={deps.handleShareTrip}
          handleCancelTimeout={deps.handlePickupTimeoutAutoCancel}
          handleFinishTimeout={deps.handleFinishCountdownTimeout}
          resetDetailVersion={deps.resetDetailVersion}
          priceUpdateVersion={deps.priceUpdateVersion}
          waitMinutes={waitMinutes}
          limitMinutes={limitMinutes}
          pickupCountdownSeconds={pickupCountdownSeconds}
          finishCountdownSeconds={finishCountdownSeconds}
          fareBreakdown={fareBreakdown}
          evaluateSubmitVersion={deps.evaluateSubmitVersion}
          evaluateCloseVersion={deps.evaluateCloseVersion}
          handleOpenOverRoutePopup={deps.handleOpenOverRoutePopup}
          handlePay={deps.handlePay}
          onOpenCouponSelect={deps.handleOpenCouponSelect}
          handleOpenEvaluatePopup={deps.handleOpenEvaluatePopup}
          estimatedWaitMinutes={deps.fareEstimate?.waitingTime}
          routeDurationSeconds={deps.routeDuration}
          onToggleDetailFade={deps.handleToggleDetailFade}
          orderTrunkStatus={deps.trunkStatus}
@@ -492,6 +509,7 @@
  return {
    durationDisplay,
    renderMapOverlays,
    renderTripCard
    renderTripCard,
    fareBreakdown
  };
}
src/robotaxi/pages/begin-route/index.less
@@ -291,4 +291,4 @@
.pr-0 {
    padding-right: 0;
}
}
src/robotaxi/pages/begin-route/index.tsx
@@ -1,5 +1,5 @@
import Taro, { useDidShow, useShareAppMessage } from "@tarojs/taro"
import { useEffect, useState, useRef, useMemo,useCallback } from 'react';
import { useEffect, useState, useRef, useMemo, useCallback } from 'react';
import { View, Image } from '@tarojs/components';
import './index.less';
@@ -22,6 +22,7 @@
import useSmoothRouteMotion from './hooks/use-smooth-route-motion';
// Android 覆盖物状态管理
import useAndroidOverlays from './hooks/use-android-overlays';
import useRouteSummaryAnchorLocation from './hooks/use-route-summary-anchor-location';
// 地图视口状态管理
import useMapViewport from './hooks/use-map-viewport';
import useActiveTripScale from './hooks/use-active-trip-scale';
@@ -370,6 +371,12 @@
   * 预估态退出页面时上报埋点。
   */
  const handleBack = () => {
    if (isModifyDestinationReviewVisible || hasLocalDestinationOverride) {
      const modifyDestinationSession = Taro.getStorageSync(MODIFY_DESTINATION_SESSION_KEY);
      const fallbackPreviousEndSite = modifyDestinationSession?.previousEndSite || previousEndSite;
      restoreModifyDestinationDisplay(fallbackPreviousEndSite, previousFareEstimate, previousRoutePoints);
      return;
    }
    if (tripStatus === TripStatus.PublishRoute) {
      reportConfirmRideEvent({
        eventId: CONFIRM_RIDE_EXIT_BUTTON_EVENT_ID,
@@ -573,7 +580,7 @@
    resetRouteRuntimeState();
  };
  const displayPolyline = polyline;
  const routeSummaryMarker = useMemo(() => {
  const routeSummaryBaseLocation = useMemo(() => {
    if (!allowSelectableRoutes || !routeDistance || routeDuration === null || !Array.isArray(routePoints) || routePoints.length === 0) {
      return null;
    }
@@ -582,9 +589,27 @@
      return null;
    }
    return {
      id: ROUTE_SUMMARY_MARKER_ID,
      latitude: midpoint.latitude,
      longitude: midpoint.longitude,
      longitude: midpoint.longitude
    };
  }, [allowSelectableRoutes, routeDistance, routeDuration, routePoints]);
  const {
    routeSummaryAnchorLocation,
    handleRegionChange: handleRouteSummaryRegionChange
  } = useRouteSummaryAnchorLocation({
    enabled: allowSelectableRoutes && !!routeDistance && routeDuration !== null,
    routeSummaryLocation: routeSummaryBaseLocation,
    routePoints
  });
  const routeSummaryMarker = useMemo(() => {
    const summaryLocation = routeSummaryAnchorLocation || routeSummaryBaseLocation;
    if (!summaryLocation?.latitude || !summaryLocation?.longitude) {
      return null;
    }
    return {
      id: ROUTE_SUMMARY_MARKER_ID,
      latitude: summaryLocation.latitude,
      longitude: summaryLocation.longitude,
      iconPath: 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/marker-car.png',
      width: 1,
      height: 1,
@@ -593,7 +618,7 @@
      isRouteSummaryBubble: true,
      customCallout: { anchorY: 22, anchorX: 42, display: 'ALWAYS' }
    };
  }, [allowSelectableRoutes, routeDistance, routeDuration, routePoints]);
  }, [routeSummaryAnchorLocation, routeSummaryBaseLocation]);
  const viewportState = useMapViewport({
    tripStatus,
    statusRule,
@@ -679,10 +704,7 @@
    tripStatus,
    mapScale,
    isModifyDestinationReviewVisible,
    routeSummaryLocation: routeSummaryMarker ? {
      latitude: routeSummaryMarker.latitude,
      longitude: routeSummaryMarker.longitude
    } : null,
    routeSummaryLocation: routeSummaryAnchorLocation || routeSummaryBaseLocation,
    startSite,
    endSite: effectiveEndSite,
    vehicleLocation,
@@ -1210,6 +1232,7 @@
  const handleRegionChange = (e) => {
    activeTripScale.handleRegionChange(e);
    handleRouteSummaryRegionChange(e);
    handleAndroidOverlayRegionChange(e);
    // 用户手势导致的缩放/拖拽结束后,把原生地图的实际 scale 同步回 React 状态,
    // 避免受控 <Map scale={mapScale}> 与原生实际缩放漂移,导致后续速度驱动误判"挡位未变化"。
@@ -1392,7 +1415,6 @@
  const activeCouponId = String( // 优惠券弹窗默认选中券ID
    fareEstimate?.couponId || fareEstimate?.userCouponId || Taro.getStorageSync('selectedCouponId') || ''
  );
  const couponOrderMoney = Number(fareEstimate?.originalFare || fareEstimate?.finalFare || 0); // 优惠券可用性计算订单金额
  const canWalkNavigateToPickup = useMemo(() => {
    if (isModifyDestinationReviewVisible) {
      return false;
@@ -1457,7 +1479,7 @@
    };
  }, [tripStatus, startSite?.latitude, startSite?.longitude, currentUserLocation?.latitude, currentUserLocation?.longitude]);
  const { renderMapOverlays, renderTripCard } = useViewDelegates({
  const { renderMapOverlays, renderTripCard, fareBreakdown } = useViewDelegates({
    isAndroid,
    tripStatus,
    mapScale,
@@ -1538,6 +1560,12 @@
    pageDidShowVersion,
    queuePollingTimeSeconds
  }); // 视图渲染委托
  /**
   * 优惠券可用性计算订单金额
   */
  const couponOrderMoney = tripStatus === TripStatus.TripFinished
    ? Number(fareBreakdown?.orderMoney)
    : 0;
  const mapLongitude = longitude ?? 116.397128;
  const mapLatitude = latitude ?? 39.916527;
  const canToggleRouteSelect = allowSelectableRoutes && routeOptions.length > 1;
src/robotaxi/pages/site-search/index.tsx
@@ -404,9 +404,26 @@
    refreshData(); // 关闭弹窗时刷新列表数据(包括历史记录和附近站点)
  };
  /**
   * 收藏夹返回的是“收藏记录对象”,这里统一转换成下单链路使用的站点对象。
   */
  const normalizeFavoriteSelectedSite = (item: any) => {
    const resolvedPointId = item?.pointId || '';
    const resolvedName = item?.pointName || '';
    const shouldPreserveFavoriteId = item?.pointId && String(item?.id || '') !== String(item?.pointId || '');
    return {
      ...item,
      favoriteId: shouldPreserveFavoriteId ? item?.id : item?.favoriteId,
      id: resolvedPointId,
      pointId: resolvedPointId,
      name: resolvedName,
      pointName: item?.pointName || resolvedName,
    };
  };
  const handleSelectFavorite = (item) => {
    setShowFavoritePopup(false);
    onChangeSelectSite(item);
    onChangeSelectSite(normalizeFavoriteSelectedSite(item));
  };
  const renderHighlightedText = (text: string, className: string) => { // 通用高亮渲染