卢春龙
2026-06-13 f3b892ef70ef3e6557114cad25133089c489d1d4
Merge branch 'feature_v1.0_fix_613_hc' into 'feature_v1.0'

fix(行程开始页): 优化车辆标记、费用详情与弹窗行为

See merge request robotaxi1/xp-robotaxi-miniprogram!49
6个文件已修改
239 ■■■■■ 已修改文件
src/robotaxi/pages/begin-route/components/calling-car-modules/door-trunk-control.tsx 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/components/calling-car-modules/fare-detail-card.tsx 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/hooks/use-android-overlays.ts 144 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/hooks/use-map-markers.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/hooks/use-view-delegates.tsx 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/index.tsx 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/components/calling-car-modules/door-trunk-control.tsx
@@ -7,6 +7,9 @@
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';
const TRUNK_CLOSE_ICON_URL = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/trunk-close.png';
const TRUNK_OPEN_ICON_URL = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/trunk-open.png';
const SWITCH_LOADING_ICON_URL = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/T-switch-loading-v2.png';
type Props = {
    tripStatus: TripStatus;
@@ -27,6 +30,7 @@
}: 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; // 结束行程弹窗点击防抖时长
@@ -106,6 +110,9 @@
    const handleUnlock = async (doorType: 'door' | 'trunk', onSuccess?: () => void) => {
        const targetOrderId = getOrderId(orderId);
        if (!targetOrderId) return;
        if (loadingControlType) {
            return;
        }
        if (doorType === 'door' && lockStatus) {
            Taro.showToast({ title: '车门已解锁,请开门上车', icon: 'none' });
            return;
@@ -116,6 +123,7 @@
            Taro.showToast({ title: '获取定位失败,请打开定位开关', icon: 'none' });
            return;
        }
        setLoadingControlType(doorType);
        const isCloseAction = doorType === 'trunk' ? !!trunkStatus : false;
        const actionLoadingText = doorType === 'trunk'
            ? (isCloseAction ? '正在关闭后备箱' : '正在开启后备箱')
@@ -130,6 +138,7 @@
            if (res?.code !== 200 || !res?.data) {
                // Taro.hideLoading();
                setLoadingControlType(null);
                Taro.showToast({ title: res?.msg || '解锁失败,请重试或联系客服', icon: 'none' });
                return;
            }
@@ -156,6 +165,7 @@
                        trunkPollTimerRef.current = setTimeout(doPoll, pollInterval);
                    } else {
                        stopPolling();
                        setLoadingControlType(null);
                        // Taro.hideLoading();
                        Taro.showToast({
                            title: failToastText,
@@ -188,6 +198,7 @@
                            stopPolling();
                            const isOpen = !isCloseAction;
                            setTrunkStatus(isOpen);
                            setLoadingControlType(null);
                            // Taro.hideLoading();
                            Taro.showToast({
                                title: successToastText,
@@ -206,6 +217,7 @@
            if (doorType === 'door') {
                // Taro.hideLoading();
                setLoadingControlType(null);
                Taro.showToast({ title: '右侧车门已解锁', icon: 'none' });
                setLockStatus(true);
                onDoorUnlockSuccess?.();
@@ -213,20 +225,38 @@
            }
        }).catch(() => {
            // Taro.hideLoading();
            setLoadingControlType(null);
            Taro.showToast({ title: '解锁失败,请重试或联系客服', icon: 'none' });
        });
    };
    /** 渲染带 loading 效果的控制按钮图标。 */
    const renderControlIcon = (controlType: 'door' | 'trunk', defaultIconSrc: string, className: string) => {
        const isLoading = loadingControlType === controlType;
        return (
            <View style={{ position: 'relative' }}>
                <Image className={className} style={isLoading ? { visibility: 'hidden' } : undefined} src={defaultIconSrc} />
                {isLoading && (
                    <Image
                        className={`${className} car-card-temp-switch-img-spinning`}
                        style={{ position: 'absolute', top: 0, left: 0 }}
                        src={SWITCH_LOADING_ICON_URL}
                    />
                )}
            </View>
        );
    };
    if (tripStatus === TripStatus.VehicleArrivedPickup) {
        return (
            <View className="car-card-arrive">
                <View className={`car-card-arrive-item ${trunkStatus && 'close-btn-item'}`} onClick={() => handleUnlock('trunk', () => setTrunkStatus(!trunkStatus))}>
                    <Image className="car-card-arrive-item-img" src={trunkStatus ? 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/trunk-open.png' : 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/trunk-close.png'} />
                    <View className="car-card-arrive-item-text">{trunkStatus ? '后备箱' : '后备箱'}</View>
                <View className={`car-card-arrive-item ${trunkStatus && 'close-btn-item'} ${loadingControlType ? 'trip-action-bar-item-disabled' : ''}`.trim()} onClick={() => handleUnlock('trunk', () => setTrunkStatus(!trunkStatus))}>
                    {renderControlIcon('trunk', trunkStatus ? TRUNK_OPEN_ICON_URL : TRUNK_CLOSE_ICON_URL, 'car-card-arrive-item-img')}
                    <View className="car-card-arrive-item-text" style={loadingControlType === 'trunk' ? { visibility: 'hidden' } : undefined}>{trunkStatus ? '后备箱' : '后备箱'}</View>
                </View>
                <View className={`car-card-arrive-item ${lockStatus && 'close-btn-item'}`} onClick={() => handleUnlock('door')}>
                    <Image className="car-card-arrive-item-img" src={lockStatus ? LOCK_OPEN_ICON_URL : LOCK_CLOSE_ICON_URL} />
                    <View className="car-card-arrive-item-text">车锁</View>
                <View className={`car-card-arrive-item ${lockStatus && 'close-btn-item'} ${loadingControlType ? 'trip-action-bar-item-disabled' : ''}`.trim()} onClick={() => handleUnlock('door')}>
                    {renderControlIcon('door', lockStatus ? LOCK_OPEN_ICON_URL : LOCK_CLOSE_ICON_URL, 'car-card-arrive-item-img')}
                    <View className="car-card-arrive-item-text" style={loadingControlType === 'door' ? { visibility: 'hidden' } : undefined}>车锁</View>
                </View>
            </View>
        );
@@ -238,9 +268,9 @@
                <View className="car-card-reach-text">点击按钮开车门</View>
                <Image className='car-card-reach-img' src='https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/door-anim.webp' />
                <View className="car-card-reach-btn">
                    <View className={`car-card-reach-btn-item ${trunkStatus && 'close-btn-item'}`} onClick={() => handleUnlock('trunk', () => setTrunkStatus(!trunkStatus))}>
                        <Image className="car-card-reach-btn-item-img" src={trunkStatus ? 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/trunk-open.png' : 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/trunk-close.png'} />
                        <View className="car-card-reach-btn-item-text">{trunkStatus ? '后备箱' : '后备箱'}</View>
                    <View className={`car-card-reach-btn-item ${trunkStatus && 'close-btn-item'} ${loadingControlType ? 'trip-action-bar-item-disabled' : ''}`.trim()} onClick={() => handleUnlock('trunk', () => setTrunkStatus(!trunkStatus))}>
                        {renderControlIcon('trunk', trunkStatus ? TRUNK_OPEN_ICON_URL : TRUNK_CLOSE_ICON_URL, 'car-card-reach-btn-item-img')}
                        <View className="car-card-reach-btn-item-text" style={loadingControlType === 'trunk' ? { visibility: 'hidden' } : undefined}>{trunkStatus ? '后备箱' : '后备箱'}</View>
                    </View>
                    <View className="car-card-reach-btn-item" onClick={handleOpenOverRoutePopupWithDebounce}>
                        <Image className="car-card-reach-btn-item-img" src="https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/over-gps-icon.png" />
src/robotaxi/pages/begin-route/components/calling-car-modules/fare-detail-card.tsx
@@ -24,6 +24,7 @@
    onOpenCouponSelect
}: Props) {
    const [showDetail, setShowDetail] = useState(false); // 费用明细展开状态
    const canOpenCouponSelect = tripStatus === TripStatus.TripFinished && typeof onOpenCouponSelect === 'function'; // 仅待支付支持切换优惠券
    // 外部重置版本变化时收起明细
    useEffect(() => {
@@ -77,8 +78,8 @@
                            :
                            <View className="car-card-end-particulars-money" onClick={handleToggleDetail}>
                                <View className="car-card-end-particulars-money-amount">{formatMoney(
                                    (fareBreakdown?.orderMoney - fareBreakdown?.redPacketMoney - fareBreakdown?.couponMoney) <= 0
                                        ? 0 : (fareBreakdown?.orderMoney - fareBreakdown?.redPacketMoney - fareBreakdown?.couponMoney))}</View>
                                    fareBreakdown?.payMoney <= 0
                                        ? 0 : fareBreakdown?.payMoney)}</View>
                                <View>元</View>
                                {
                                    fareBreakdown?.redPacketMoney + fareBreakdown?.couponMoney > 0 &&
@@ -197,10 +198,12 @@
                    (fareBreakdown?.operateFree != 1 && [TripStatus.TripFinished, TripStatus.PaymentSuccess].includes(tripStatus)) && Number(fareBreakdown?.redPacketMoney + fareBreakdown?.couponMoney) > 0 &&
                    <View className="car-card-end-through">
                        <View className="car-card-end-through-label">优惠活动</View>
                        <View className="car-card-end-through-content" onClick={onOpenCouponSelect}>
                        <View className="car-card-end-through-content" onClick={canOpenCouponSelect ? onOpenCouponSelect : undefined}>
                            <View className="car-card-end-through-content-title">最佳优惠</View>
                            <View className="car-card-end-through-content-money">-{formatMoney(fareBreakdown?.redPacketMoney + fareBreakdown?.couponMoney)}元</View>
                            <Image src="https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/ccc-icon-right.png" />
                            {canOpenCouponSelect && (
                                <Image src="https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/ccc-icon-right.png" />
                            )}
                        </View>
                    </View>
                }
@@ -211,11 +214,11 @@
                    {
                        fareBreakdown?.operateFree != 1 ?
                            <View className="pay-box-money"><View>{formatMoney(
                                (fareBreakdown?.orderMoney - fareBreakdown?.redPacketMoney - fareBreakdown?.couponMoney) <= 0
                                    ? 0 : (fareBreakdown?.orderMoney - fareBreakdown?.redPacketMoney - fareBreakdown?.couponMoney))}</View><View className="unit">元</View></View>
                               fareBreakdown?.payMoney <= 0
                                    ? 0 : fareBreakdown?.payMoney)}</View><View className="unit">元</View></View>
                            :
                            <View className="pay-box-free">载客测试免费</View>
                    }
                    }{/*  */}
                    <View onClick={handlePay} className="pay-box-btn">立即支付</View>
                </View>
            }
src/robotaxi/pages/begin-route/hooks/use-android-overlays.ts
@@ -20,138 +20,7 @@
  latitude: number | null;
};
const CAR_CALLOUT_EDGE_GAP = 12;
const CAR_CALLOUT_SIDE_GAP = 18;
const CAR_CALLOUT_TOP_GAP = 20;
const CAR_CALLOUT_BOTTOM_GAP = 34;
const CAR_CALLOUT_DEFAULT_SIZE = { width: 166, height: 56 };
const CAR_CALLOUT_WITH_BEFORE_SIZE = { width: 188, height: 84 };
type CarCalloutPlacement = 'top' | 'bottom' | 'left' | 'right';
/**
 * 计算车辆附近路线切线方向,供气泡选边使用。
 */
const resolveRouteVector = (
  routePoints: Array<{ latitude: number; longitude: number }> = [],
  vehicleLocation: { latitude: number; longitude: number } | null
) => {
  if (!vehicleLocation || routePoints.length < 2) {
    return null;
  }
  let closestIndex = 0;
  let closestDistance = Infinity;
  routePoints.forEach((point, index) => {
    const lat = Number(point?.latitude);
    const lng = Number(point?.longitude);
    if (!Number.isFinite(lat) || !Number.isFinite(lng)) return;
    const distance = ((lat - vehicleLocation.latitude) ** 2) + ((lng - vehicleLocation.longitude) ** 2);
    if (distance < closestDistance) {
      closestDistance = distance;
      closestIndex = index;
    }
  });
  const prevIndex = closestIndex <= 0 ? 0 : closestIndex - 1;
  const nextIndex = closestIndex >= routePoints.length - 1 ? routePoints.length - 1 : closestIndex + 1;
  if (prevIndex === nextIndex) {
    return null;
  }
  const prevPoint = routePoints[prevIndex];
  const nextPoint = routePoints[nextIndex];
  const deltaX = Number(nextPoint?.longitude) - Number(prevPoint?.longitude);
  const deltaY = Number(nextPoint?.latitude) - Number(prevPoint?.latitude);
  if (!Number.isFinite(deltaX) || !Number.isFinite(deltaY)) {
    return null;
  }
  return { deltaX, deltaY };
};
/**
 * 基于路线走向与当前屏幕空间,选择车辆气泡最稳妥的停靠方向。
 */
const resolveCarCalloutPlacement = (
  pos: { x: number; y: number },
  rect: any,
  routeVector: { deltaX: number; deltaY: number } | null,
  calloutSize: { width: number; height: number },
  verticalExtraOffset: number
) => {
  const routeIsMostlyHorizontal = !!routeVector && Math.abs(routeVector.deltaX) >= Math.abs(routeVector.deltaY);
  const topPreferred = rect ? (pos.y - rect.top) >= (rect.top + rect.height - pos.y) : false;
  const leftPreferred = rect ? (pos.x - rect.left) >= (rect.left + rect.width - pos.x) : false;
  const preferredOrder: CarCalloutPlacement[] = routeIsMostlyHorizontal
    ? (topPreferred ? ['top', 'bottom', leftPreferred ? 'left' : 'right', leftPreferred ? 'right' : 'left'] : ['bottom', 'top', leftPreferred ? 'left' : 'right', leftPreferred ? 'right' : 'left'])
    : (leftPreferred ? ['left', 'right', topPreferred ? 'top' : 'bottom', topPreferred ? 'bottom' : 'top'] : ['right', 'left', topPreferred ? 'top' : 'bottom', topPreferred ? 'bottom' : 'top']);
  const computeCandidate = (placement: CarCalloutPlacement) => {
    if (placement === 'top') {
      return {
        placement,
        left: pos.x - (calloutSize.width / 2),
        top: pos.y - calloutSize.height - CAR_CALLOUT_TOP_GAP,
      };
    }
    if (placement === 'bottom') {
      return {
        placement,
        left: pos.x - (calloutSize.width / 2),
        top: pos.y + CAR_CALLOUT_BOTTOM_GAP + verticalExtraOffset,
      };
    }
    if (placement === 'left') {
      return {
        placement,
        left: pos.x - calloutSize.width - CAR_CALLOUT_SIDE_GAP,
        top: pos.y - (calloutSize.height / 2),
      };
    }
    return {
      placement,
      left: pos.x + CAR_CALLOUT_SIDE_GAP,
      top: pos.y - (calloutSize.height / 2),
    };
  };
  const scoreCandidate = (candidate: { placement: CarCalloutPlacement; left: number; top: number }) => {
    const right = candidate.left + calloutSize.width;
    const bottom = candidate.top + calloutSize.height;
    const minLeft = rect.left + CAR_CALLOUT_EDGE_GAP;
    const minTop = rect.top + CAR_CALLOUT_EDGE_GAP;
    const maxRight = rect.left + rect.width - CAR_CALLOUT_EDGE_GAP;
    const maxBottom = rect.top + rect.height - CAR_CALLOUT_EDGE_GAP;
    const overflowPenalty = (
      Math.max(0, minLeft - candidate.left)
      + Math.max(0, minTop - candidate.top)
      + Math.max(0, right - maxRight)
      + Math.max(0, bottom - maxBottom)
    ) * 100;
    const routePenalty = routeIsMostlyHorizontal
      ? (candidate.placement === 'left' || candidate.placement === 'right' ? 24 : 0)
      : (candidate.placement === 'top' || candidate.placement === 'bottom' ? 24 : 0);
    const freeSpaceScore = Math.min(
      candidate.left - minLeft,
      candidate.top - minTop,
      maxRight - right,
      maxBottom - bottom
    );
    const orderPenalty = preferredOrder.indexOf(candidate.placement) * 4;
    return freeSpaceScore - overflowPenalty - routePenalty - orderPenalty;
  };
  const bestCandidate = preferredOrder
    .map(computeCandidate)
    .sort((first, second) => scoreCandidate(second) - scoreCandidate(first))[0];
  const minLeft = rect.left + CAR_CALLOUT_EDGE_GAP;
  const minTop = rect.top + CAR_CALLOUT_EDGE_GAP;
  const maxLeft = rect.left + rect.width - calloutSize.width - CAR_CALLOUT_EDGE_GAP;
  const maxTop = rect.top + rect.height - calloutSize.height - CAR_CALLOUT_EDGE_GAP;
  return {
    left: Math.min(Math.max(bestCandidate.left, minLeft), Math.max(minLeft, maxLeft)),
    top: Math.min(Math.max(bestCandidate.top, minTop), Math.max(minTop, maxTop))
  };
};
export default function useAndroidOverlays(deps: Deps) {
  // 等待雷达样式与显示状态
@@ -244,20 +113,9 @@
    const pos = await computeScreenPosition(deps.vehicleLocation, rect, 0);
    if (!pos) return;
    const verticalExtraOffset = deps.hasBeforeUserEndSite ? 18 : 0;
    if (deps.tripStatus === TripStatus.InService) {
      const routeVector = resolveRouteVector(deps.routePoints || [], deps.vehicleLocation);
      const calloutSize = deps.hasBeforeUserEndSite ? CAR_CALLOUT_WITH_BEFORE_SIZE : CAR_CALLOUT_DEFAULT_SIZE;
      const placement = resolveCarCalloutPlacement(pos, rect, routeVector, calloutSize, verticalExtraOffset);
      setCarCalloutStyle(buildTransformOverlayStyle({
        x: placement.left,
        y: placement.top
      }, ''));
      setCarCalloutVisible(true);
      return;
    }
    setCarCalloutStyle(buildTransformOverlayStyle({
      x: pos.x,
      y: pos.y + 34 + verticalExtraOffset
      y: pos.y + CAR_CALLOUT_BOTTOM_GAP + verticalExtraOffset
    }, 'translate(-50%, 6px)'));
    setCarCalloutVisible(true);
  };
src/robotaxi/pages/begin-route/hooks/use-map-markers.ts
@@ -203,9 +203,9 @@
      anchor: { x: 0.5, y: 0.5 },
      zIndex: 2,
      name: '车辆位置',
      // ...(!shouldUsePureTranslateCarMarker ? {
      //   rotate: typeof deps.vehicleDirection === 'number' ? deps.vehicleDirection : 0,
      // } : {}),
      ...(deps.isAndroid ? {
        rotate: typeof deps.vehicleDirection === 'number' ? deps.vehicleDirection : 0,
      } : {}),
      customCallout: { anchorY, anchorX, display: 'ALWAYS' }
    };
  };
src/robotaxi/pages/begin-route/hooks/use-view-delegates.tsx
@@ -211,6 +211,12 @@
export default function useViewDelegates(deps: Deps) {
  /** 时长文案 */
  const durationDisplay = useMemo(() => formatRemainingDurationDisplay(deps.routeDuration), [deps.routeDuration]);
  /** 车辆公里/分钟气泡专用时长文案:不足 1 分钟时统一展示 1 分钟。 */
  const vehicleMetricDurationDisplay = useMemo(() => (
    durationDisplay.unit === '秒'
      ? { value: 1, unit: '分钟' as const }
      : durationDisplay
  ), [durationDisplay]);
  /** 修改目的地预览态下隐藏车辆公里/分钟气泡。 */
  const shouldShowVehicleMetricCallout = !deps.isModifyDestinationReviewVisible;
  const routeSummaryText = useMemo(() => (
@@ -223,9 +229,9 @@
    <AndroidCarCalloutContent
      hasBeforeUserEndSite={deps.hasBeforeUserEndSite}
      routeDistance={deps.routeDistance}
      durationDisplay={durationDisplay}
      durationDisplay={vehicleMetricDurationDisplay}
    />
  ), [deps.hasBeforeUserEndSite, deps.routeDistance, durationDisplay]);
  ), [deps.hasBeforeUserEndSite, deps.routeDistance, vehicleMetricDurationDisplay]);
  /**
   * 渲染等待态气泡。
@@ -282,8 +288,8 @@
              <CoverView className='car-time'>
                <CoverView className='car-time-num'>{deps.routeDistance?.value ?? 0}</CoverView>
                <CoverView>{deps.routeDistance?.unit === 'm' ? '米' : '公里'}</CoverView>
                <CoverView style={{ marginLeft: '2.5px' }} className='car-time-num'>{durationDisplay.value}</CoverView>
                <CoverView>{durationDisplay.unit}</CoverView>
                <CoverView style={{ marginLeft: '2.5px' }} className='car-time-num'>{vehicleMetricDurationDisplay.value}</CoverView>
                <CoverView>{vehicleMetricDurationDisplay.unit}</CoverView>
              </CoverView>
            </CoverView>
          )}
@@ -305,8 +311,8 @@
    deps.routeSummaryBgSrc,
    deps.showTrip1StyleEndpoint,
    deps.tripStatus,
    durationDisplay,
    routeSummaryText,
    vehicleMetricDurationDisplay,
    shouldShowVehicleMetricCallout
  ]);
src/robotaxi/pages/begin-route/index.tsx
@@ -1347,7 +1347,7 @@
  };
  // 打开优惠券选择弹窗
  const handleOpenCouponSelect = () => {
    if (![TripStatus.PublishRoute, TripStatus.TripFinished, TripStatus.PaymentSuccess].includes(tripStatus)) {
    if (![TripStatus.PublishRoute, TripStatus.TripFinished].includes(tripStatus)) {
      return;
    }
    setShowCouponSelectPopup(true);
@@ -1356,6 +1356,12 @@
  const handleCloseCouponSelect = () => {
    setShowCouponSelectPopup(false);
  };
  // 支付成功后关闭优惠券选择弹窗
  useEffect(() => {
    if (tripStatus === TripStatus.PaymentSuccess) {
      setShowCouponSelectPopup(false);
    }
  }, [tripStatus]);
  // 选择优惠券后仅刷新价格,不刷新地图路线
  const handleSelectCoupon = (item: any) => {
    const selectedCouponId = String(item?.operationCouponId || item?.couponId || '');