hejianhao
2026-04-21 7293d2c5c81f7c581e76eac2db3fad5c89ba001c
feat(robotaxi): 新增上一单终点标记及车辆气泡优化

- 在路线管理器中解析并存储上一单乘客下车点坐标
- 在地图标记中新增上一单终点标记及其自定义气泡
- 调整车辆气泡布局,在存在上一单终点时显示“即将完成上一单”提示
- 优化温度控制组件,仅在车辆接近状态显示并展示实际温度值
- 调整样式细节,包括字体大小和气泡定位
8个文件已修改
121 ■■■■■ 已修改文件
src/robotaxi/pages/begin-route/components/calling-car-modules/temperature-control.tsx 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/hooks/use-map-markers.ts 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/hooks/use-route-manager.ts 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/hooks/use-view-delegates.tsx 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/hooks/use-web-socket-events.ts 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/index.less 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/index.tsx 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/websocket.ts 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/components/calling-car-modules/temperature-control.tsx
@@ -100,10 +100,10 @@
        });
    };
    // if (tripStatus !== TripStatus.VehicleApproaching || typeof tempStatus !== 'number') {
    //     return null;
    // }
    if (tripStatus !== TripStatus.VehicleApproaching || typeof tempStatus !== 'number') {
        return null;
    }
    //TODO: 温度控制等待接口开发完成后补全前端功能:远程开关、查车当前空调状态
    return (
        <View>
            <View className="car-card-temp">
@@ -117,7 +117,7 @@
                        }
                    </View>
                    <View className="car-card-temp-num-text">
                        <View className="car-card-temp-num-text-temp color-000-25">20℃</View>
                        <View className="car-card-temp-num-text-temp color-000-25">{tempStatus?.toFixed(1)}°℃</View>
                        <View className="car-card-temp-num-text-desc color-000-25">设定温度</View>
                    </View>
                    <View className="car-card-temp-num-btn">
src/robotaxi/pages/begin-route/hooks/use-map-markers.ts
@@ -9,6 +9,7 @@
  showTrip1StyleEndpoint: boolean;
  startSite: any;
  endSite: any;
  beforeUserEndSite: { longitude: number; latitude: number } | null;
  vehicleLocation: { latitude: number; longitude: number } | null;
  vehicleDirection: number | null;
  vehicleMarkerId: number;
@@ -23,6 +24,7 @@
  showTrip1StyleEndpoint: false,
  startSite: null,
  endSite: null,
  beforeUserEndSite: null,
  vehicleLocation: null,
  vehicleDirection: null,
  vehicleMarkerId: 3,
@@ -104,6 +106,26 @@
    };
  };
  // 构建上一单终点 marker
  const createBeforeUserDropoffMarker = () => {
    const latitude = Number(deps.beforeUserEndSite?.latitude);
    const longitude = Number(deps.beforeUserEndSite?.longitude);
    if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
    return {
      id: 31,
      latitude,
      longitude,
      iconPath: 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/before-user-site.png',
      width: 22,
      height: 36,
      anchor: { x: 0.5, y: 1 },
      zIndex: 1,
      name: '上一单终点',
      isBeforeUserSite: true,
      customCallout: { anchorY: -5, anchorX: 0, display: 'ALWAYS' }
    };
  };
  // 构建车辆 marker
  const createCarMarker = () => {
    if (!deps.vehicleLocation) return null;
@@ -144,6 +166,7 @@
    const useAndroidNativeTrip1Callout = deps.isAndroid && ([TripStatus.PublishRoute, TripStatus.VehicleArrivedPickup, TripStatus.TripFinished, TripStatus.PaymentSuccess, TripStatus.CancelOnlyPickup].includes(deps.tripStatus) || deps.showTrip1StyleEndpoint);
    const pickupMarker = createPickupMarker(pickupSite, useAndroidNativeTrip1Callout);
    const dropoffMarker = createDropoffMarker(dropoffSite, useAndroidNativeTrip1Callout);
    const beforeUserDropoffMarker = createBeforeUserDropoffMarker();
    const shouldWaitArrivedDirection = deps.tripStatus === TripStatus.ArrivedDestination && !deps.arrivedMarkerReady;
    const effectiveShowCar = deps.statusRule.showCar && !shouldWaitArrivedDirection;
    const carMarker = createCarMarker();
@@ -151,6 +174,7 @@
    if (deps.statusRule.showPickup && pickupMarker) nextMarkers.push(pickupMarker);
    if (effectiveShowCar && carMarker) nextMarkers.push(carMarker);
    if (deps.statusRule.showDropoff && dropoffMarker) nextMarkers.push(dropoffMarker);
    if (beforeUserDropoffMarker) nextMarkers.push(beforeUserDropoffMarker);
    const nextMarkersSignature = nextMarkers
      .map((m: any) => [
        m.id,
@@ -165,7 +189,7 @@
    const shouldHoldMarkerByTranslate = [TripStatus.VehicleApproaching, TripStatus.InService].includes(deps.tripStatus) && carMarkerInitialized.current && deps.statusRule.showCar && (!deps.statusRule.showDropoff || hasDropoffMarker);
    const shouldSkipUpdate = shouldHoldMarkerByTranslate && (deps.isAndroid || tripStatus6MarkerRefreshedRef.current);
    applyMarkerState(nextMarkers, nextMarkersSignature, shouldSkipUpdate, carMarker);
  }, [deps.tripStatus, deps.startSite?.name, deps.startSite?.latitude, deps.startSite?.longitude, deps.endSite?.name, deps.endSite?.latitude, deps.endSite?.longitude, deps.vehicleLocation, deps.vehicleDirection, deps.vehicleMarkerId, deps.isAndroid, deps.statusRule.showPickup, deps.statusRule.showCar, deps.statusRule.showDropoff]);
  }, [deps.tripStatus, deps.startSite?.name, deps.startSite?.latitude, deps.startSite?.longitude, deps.endSite?.name, deps.endSite?.latitude, deps.endSite?.longitude, deps.beforeUserEndSite?.latitude, deps.beforeUserEndSite?.longitude, deps.vehicleLocation, deps.vehicleDirection, deps.vehicleMarkerId, deps.isAndroid, deps.statusRule.showPickup, deps.statusRule.showCar, deps.statusRule.showDropoff]);
  return {
    markers,
src/robotaxi/pages/begin-route/hooks/use-route-manager.ts
@@ -17,6 +17,7 @@
  endSite?: any;
  setStartSite?: (site: any) => void;
  setEndSite?: (site: any) => void;
  setBeforeUserEndSite?: (site: { longitude: number; latitude: number } | null) => void;
  resolveSafeTopInset: () => number;
  computeBottomPadding: (points: any[], viewportType: string) => number;
  updateVehicleLocationThrottled: (lat: number, lng: number, force?: boolean) => void;
@@ -35,6 +36,16 @@
  computeBottomPadding: () => 280,
  updateVehicleLocationThrottled: () => { },
  setVehicleDirection: () => { }
};
// 解析“上一单乘客下车点”坐标,任一坐标无效则返回空。
const parseBeforeUserEndSite = (endLongitudeValue: any, endLatitudeValue: any) => {
  const longitude = Number(endLongitudeValue);
  const latitude = Number(endLatitudeValue);
  if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) {
    return null;
  }
  return { longitude, latitude };
};
export default function useRouteManager(rawDeps?: Partial<Deps>) {
@@ -452,6 +463,8 @@
    const requestRoute = [TripStatus.TripFinished, TripStatus.PaymentSuccess].includes(status) ? queryOrderTrajectory : queryOrderRoute;
    requestRoute({ orderId: targetOrderId, routeType })
      .then((res: any) => {
        const beforeUserSite = parseBeforeUserEndSite(res?.endLongitude, res?.endlatitude);
        deps.setBeforeUserEndSite?.(beforeUserSite);
        const rawPoints = Array.isArray(res?.polyline) ? res.polyline : [];
        const points = rawPoints
          .map((item: any) => ({
src/robotaxi/pages/begin-route/hooks/use-view-delegates.tsx
@@ -38,6 +38,8 @@
  routeDistance: { value: number; unit: 'km' | 'm' } | null;
  /** 路线时长 */
  routeDuration: number | null;
  /** 是否存在上一单终点 */
  hasBeforeUserEndSite: boolean;
  /** 自定义气泡图标 */
  customCalloutIconSrc: string;
  /** 是否使用下单前端点气泡样式 */
@@ -145,6 +147,7 @@
  const routeSummaryText = deps.routeDistance && deps.routeDuration !== null
    ? `${durationDisplay.value}${durationDisplay.unit} ${deps.routeDistance.value}${deps.routeDistance.unit === 'm' ? '米' : '公里'}`
    : '';
  const shouldHideAndroidMarkerCallout = deps.isAndroid && [TripStatus.PublishRoute, TripStatus.VehicleArrivedPickup, TripStatus.TripFinished, TripStatus.PaymentSuccess, TripStatus.CancelOnlyPickup].includes(deps.tripStatus); // Android 指定状态下隐藏常规 marker 气泡
  /**
   * 渲染等待态气泡。
@@ -175,6 +178,10 @@
            <CoverView className='route-summary-callout-arrow' />
          </CoverView>
        ) : null
      ) : item.isBeforeUserSite ? (
        <CoverView className='before-user-site-callout'>
          <CoverView className='before-user-site-txt'>上一站终点</CoverView>
        </CoverView>
      ) : (
        <>
          {(item.name !== '车辆位置' && !(item.id === 2 && [TripStatus.ArrivedDestination].includes(deps.tripStatus))) && (
@@ -185,7 +192,10 @@
            </CoverView>
          )}
          {(item.name === '车辆位置' && [TripStatus.VehicleApproaching, TripStatus.InService].includes(deps.tripStatus) && !deps.isAndroid) && (
            <CoverView className='custom-callout-content' style={{ padding: '6px 10px', minWidth: '40px', justifyContent: 'center', zIndex: 2 }}>
            <CoverView className='custom-callout-content car-callout-with-before' style={{ padding: '6px 10px', minWidth: '40px', justifyContent: 'center', zIndex: 2 }}>
              {deps.hasBeforeUserEndSite && (
                <CoverView className='car-time car-before-order-tip'>即将完成上一单</CoverView>
              )}
              <CoverView className='car-time'>
                <CoverView className='car-time-num'>{deps.routeDistance?.value ?? 0}</CoverView>
                <CoverView>{deps.routeDistance?.unit === 'm' ? '米' : '公里'}</CoverView>
@@ -208,6 +218,7 @@
   * 渲染单个 marker 气泡。
   */
  const renderMarkerCallout = (item: any, index: number) => {
    if (shouldHideAndroidMarkerCallout && !item.isBeforeUserSite) return null;
    const calloutKey = `callout-${item.id}-${item.name || ''}`;
    if (item.isRouteSummaryBubble) return renderDefaultCallout(item, calloutKey);
    const isWaiting = [TripStatus.Publishing, TripStatus.CallingCar].includes(deps.tripStatus) && index === 0;
@@ -221,13 +232,16 @@
   */
  const renderMapOverlays = () => (
    <>
      {!(deps.isAndroid && [TripStatus.PublishRoute, TripStatus.VehicleArrivedPickup, TripStatus.TripFinished, TripStatus.PaymentSuccess, TripStatus.CancelOnlyPickup].includes(deps.tripStatus)) && deps.markers.map(renderMarkerCallout)}
      {deps.markers.map(renderMarkerCallout)}
      {deps.shouldShowWaitingRadar && deps.waitingRadarVisible && (
        <Image className="waiting-radar-img" src={deps.waitingRadarSrc} webp={true} style={deps.waitingRadarStyle} />
      )}
      {deps.isAndroid && deps.carCalloutVisible && (
        <View className="car-callout-android" style={deps.carCalloutStyle}>
          <View className="custom-callout-content" style={{ padding: '6px 10px' }}>
          <View className="custom-callout-content car-callout-with-before" style={{ padding: '6px 10px' }}>
            {deps.hasBeforeUserEndSite && (
              <View className="car-time car-before-order-tip">即将完成上一单</View>
            )}
            <View className="car-time" style={{ alignItems: 'flex-end' }}>
              <View className="car-time-num">{deps.routeDistance?.value ?? 0}</View>
              <View style={{ flexShrink: 0 }}>{deps.routeDistance?.unit === 'm' ? '米' : '公里'}</View>
src/robotaxi/pages/begin-route/hooks/use-web-socket-events.ts
@@ -34,6 +34,7 @@
    setVehicleMoveAlongPoints: (points: any[]) => void;
    setRouteDistance: (value: any) => void;
    setRouteDuration: (value: number) => void;
    setBeforeUserEndSite: (site: { longitude: number; latitude: number } | null) => void;
    erasePassedRouteSegments: (points: any[], servedPoints?: any[]) => void;
    applyRoutePoints: (points: any[]) => boolean;
    updateVehicleLocationThrottled: (lat: number, lng: number, force?: boolean) => void;
@@ -41,6 +42,16 @@
const resolveOrderId = (payload: any, fallbackOrderId: string) =>
    payload?.content?.orderId || payload?.orderId || fallbackOrderId || Taro.getStorageSync('orderId');
// 解析“上一单乘客下车点”坐标,任一坐标无效则返回空,供 marker 显隐控制。
const parseBeforeUserEndSite = (payload: any) => {
    const longitude = Number(payload?.content?.endLongitude);
    const latitude = Number(payload?.content?.endlatitude);
    if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) {
        return null;
    }
    return { longitude, latitude };
};
const isSameOrder = (incomingOrderId: string, currentOrderId: string) => {
    if (!incomingOrderId || !currentOrderId) return true;
@@ -146,6 +157,7 @@
    if (!isSameOrder(incomingOrderId, currentOrderId)) {
        return;
    }
    deps.setBeforeUserEndSite(parseBeforeUserEndSite(payload));
    const mappedTripStatus = syncTripStatus(payload, deps);
    const effectiveOrderId = resolveOrderId(payload, currentOrderId);
@@ -263,6 +275,7 @@
};
const handleOrderRoutePayload = (payload: any, deps: UseWebSocketEventsDeps) => {
    deps.setBeforeUserEndSite(parseBeforeUserEndSite(payload));
    const points = payload?.points || [];
    const servedPoints = Array.isArray(payload?.servedPoints) ? payload.servedPoints : [];
    const forceFullRedraw = payload?.forceFullRedraw === true;
src/robotaxi/pages/begin-route/index.less
@@ -160,11 +160,20 @@
}
.car-time {
    font-size: 24px;
    font-size: 20px;
    letter-spacing: normal;
    display: flex;
    align-items: center;
    justify-content: center;
}
.car-callout-with-before {
    flex-direction: column;
    align-items: flex-start;
}
.car-before-order-tip {
    margin-bottom: 4px;
}
.car-time-num {
@@ -186,6 +195,18 @@
    pointer-events: none;
}
.before-user-site-callout {
    transform: translate(-74%, -110%);
    pointer-events: none;
}
.before-user-site-txt {
    font-size: 20px;
    color: rgba(0, 0, 0, 0.9);
    line-height: 28px;
    white-space: nowrap;
}
.waiting-callout {
    position: relative;
    z-index: 999;
src/robotaxi/pages/begin-route/index.tsx
@@ -58,6 +58,10 @@
  viewport: 'route_walking' | 'pickup' | 'route' | 'car_user' | 'order_detail';
  translateCar: boolean;
};
type BeforeUserEndSite = {
  longitude: number;
  latitude: number;
};
export default function BeginRoute() {
  const WAITING_TIMER_START_KEY = 'waitingTimerStartAt'; // 等待计时开始时间
@@ -100,6 +104,7 @@
  const [endSite, setEndSite] = useState<any>({}); // 下车点
  const [tripStatus, setTripStatus] = useState<TripStatus>(toTripStatus(Taro.getStorageSync('tripStatus'))); // 当前行程状态
  const [elapsedSeconds, setElapsedSeconds] = useState(0); // 等待计时秒数
  const [beforeUserEndSite, setBeforeUserEndSite] = useState<BeforeUserEndSite | null>(null); // 上一单乘客下车点
  const requoteTriggeredRef = useRef(false); // 重新报价触发标记,防止重复触发
  const [callSubmitting, setCallSubmitting] = useState(false); // 呼叫车辆提交中状态
  const [orderId, setOrderId] = useState<string>(''); // 订单ID
@@ -238,6 +243,7 @@
    endSite: effectiveEndSite,
    setStartSite,
    setEndSite,
    setBeforeUserEndSite,
    updateVehicleLocationThrottled: vehicleState.updateVehicleLocationThrottled,
    setVehicleDirection
  }); // 路线与费用状态容器
@@ -352,6 +358,7 @@
    vehicleLocation,
    vehicleDirection,
    vehicleMarkerId,
    beforeUserEndSite,
    arrivedMarkerReady,
    arrivedTooltipReady
  }); // 地图标记状态容器
@@ -500,6 +507,7 @@
    setVehicleMoveAlongPoints,
    setRouteDistance,
    setRouteDuration,
    setBeforeUserEndSite,
    erasePassedRouteSegments: routeErasePassedRouteSegments,
    applyRoutePoints: routeApplyRoutePoints,
    updateVehicleLocationThrottled: vehicleState.updateVehicleLocationThrottled
@@ -689,6 +697,7 @@
    arrivedTooltipReady,
    routeDistance,
    routeDuration,
    hasBeforeUserEndSite: !!beforeUserEndSite,
    customCalloutIconSrc,
    showTrip1StyleEndpoint,
    startSite,
src/utils/websocket.ts
@@ -487,7 +487,12 @@
          Taro.setStorageSync('tripStatus', mapped.tripStatus);
          Taro.setStorageSync('orderId', orderId);
        }
        this.notifyListeners('order_status', { status, ...mapped, data, content });
        this.notifyListeners('order_status', {
          status,
          ...mapped,
          data,
          content
        });
      }
    }
    if (data?.type === 'ORDER_ROUTE' || data?.type === 'ORDER_UPDATE_LINE') {