hejianhao
2026-05-11 7bb5a94e9b6f6d73be3971500a0649d76343f520
feat(robotaxi): 新增修改目的地权限控制及相关配套功能

- 新增发票URL常量并在订单页面引用
- 新增行程距离、时长、车速的WebSocket上报处理
- 追踪修改目的地次数与低速持续时长状态
- 基于剩余里程、剩余时间、修改次数及低速时长控制修改目的地按钮显示
- 新增后端配置的距离相关参数支持
8个文件已修改
152 ■■■■■ 已修改文件
src/constants/api.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/components/calling-car-modules/trip-action-bar.tsx 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/components/calling-car.tsx 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/hooks/use-order-distance-config.ts 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/hooks/use-view-delegates.tsx 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/hooks/use-web-socket-events.ts 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/index.tsx 102 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/order/index.tsx 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/constants/api.ts
@@ -45,6 +45,10 @@
//@ts-ignore
export const _AMAP_KEY = AMAP_KEY
// 发票URL
//@ts-ignore
export const _INVOICE_URL = INVOICE_URL
// pic CDN
export const CDN = 'https://s.xiaopeng.com/xp-fe/mini'
// 官渠通用 oss 资源前缀
src/robotaxi/pages/begin-route/components/calling-car-modules/trip-action-bar.tsx
@@ -9,13 +9,15 @@
    handleCancelTrip?: () => void;
    handleModifyDestination?: () => void;
    handleShareTrip?: () => void;
    showModifyDestination?: boolean;
};
export default function TripActionBar({
    tripStatus,
    handleCancelTrip,
    handleModifyDestination,
    handleShareTrip
    handleShareTrip,
    showModifyDestination = true
}: Props) {
    // 按行程状态控制按钮集合,避免在 JSX 中堆叠过多条件判断
    const actionList = [
@@ -30,7 +32,7 @@
            key: "destination",
            label: "修改目的地",
            icon: changeDestinationIcon,
            visible: [TripStatus.VehicleApproaching, TripStatus.VehicleArrivedPickup, TripStatus.InService].includes(tripStatus),
            visible: [TripStatus.VehicleApproaching, TripStatus.VehicleArrivedPickup, TripStatus.InService].includes(tripStatus) && showModifyDestination,
            onClick: handleModifyDestination
        },
        {
src/robotaxi/pages/begin-route/components/calling-car.tsx
@@ -44,6 +44,8 @@
    handleCancelTrip?: () => void;
    /** 修改目的地 */
    handleModifyDestination?: () => void;
    /** 是否展示修改目的地 */
    showModifyDestination?: boolean;
    /** 分享行程 */
    handleShareTrip?: () => void;
    /** 预估等待分钟数 */
@@ -90,6 +92,7 @@
    handleOpenEvaluatePopup,
    handleCancelTrip,
    handleModifyDestination,
    showModifyDestination = true,
    handleShareTrip,
    estimatedWaitMinutes,
    routeDurationSeconds,
@@ -208,6 +211,7 @@
                        tripStatus={tripStatus}
                        handleCancelTrip={handleCancelTrip}
                        handleModifyDestination={handleModifyDestination}
                        showModifyDestination={showModifyDestination}
                        handleShareTrip={handleShareTrip}
                    />
                </View>
src/robotaxi/pages/begin-route/hooks/use-order-distance-config.ts
@@ -11,6 +11,11 @@
  queuePollingTime?: number;
  walkingNavigationDistance?: number;
  maxStandingTiming?: number;
  xczSyDistance?: number;
  xczSyTime?: number;
  xczCaCount?: number;
  xczHmSpeedPerHour?: number;
  xczHmDuration?: number;
};
type Deps = {
@@ -43,7 +48,12 @@
          adsorptionDistance: Number(res.adsorptionDistance),
          queuePollingTime: Number(res.queuePollingTime),
          walkingNavigationDistance: Number(res.walkingNavigationDistance),
          maxStandingTiming: Number(res.maxStandingTiming)
          maxStandingTiming: Number(res.maxStandingTiming),
          xczSyDistance: Number(res.xczSyDistance),
          xczSyTime: Number(res.xczSyTime),
          xczCaCount: Number(res.xczCaCount),
          xczHmSpeedPerHour: Number(res.xczHmSpeedPerHour),
          xczHmDuration: Number(res.xczHmDuration)
        };
        setDistanceConfig(nextConfig);
        setDistanceConfigLoaded(true);
src/robotaxi/pages/begin-route/hooks/use-view-delegates.tsx
@@ -93,6 +93,8 @@
  handleToggleDetailFade: (expanded: boolean, hasFare: boolean) => void;
  /** 修改目的地 */
  handleModifyDestination: () => void;
  /** 是否展示修改目的地 */
  showModifyDestination: boolean;
  /** 分享行程 */
  handleShareTrip: () => void;
  /** 路线选择展开状态 */
@@ -332,6 +334,7 @@
          handleOpenCancelPopup={(displayInfo) => deps.handleOpenCancelPopupByTripStatus(deps.tripStatus, displayInfo)}
          handleCancelTrip={() => deps.handleOpenCancelPopupByTripStatus(deps.tripStatus, true)}
          handleModifyDestination={deps.handleModifyDestination}
          showModifyDestination={deps.showModifyDestination}
          handleShareTrip={deps.handleShareTrip}
          handleCancelTimeout={() => deps.handleOpenCancelPopupWithType(4)}
          handleFinishTimeout={deps.handleFinishCountdownTimeout}
src/robotaxi/pages/begin-route/hooks/use-web-socket-events.ts
@@ -34,6 +34,11 @@
    setVehicleMoveAlongPoints: (points: any[]) => void;
    setRouteDistance: (value: any) => void;
    setRouteDuration: (value: number) => void;
    setOrderRouteMetrics?: (metrics: {
        distanceMeters: number | null;
        durationSeconds: number | null;
        speedKmh: number | null;
    }) => void;
    setBeforeUserEndSite: (site: { longitude: number; latitude: number } | null) => void;
    erasePassedRouteSegments: (points: any[], servedPoints?: any[]) => void;
    applyRoutePoints: (points: any[]) => boolean;
@@ -190,14 +195,22 @@
        deps.setVehicleDirection(normalizedDirection);
    }
    const route = safeParse(payload?.content?.route, {});
    const distanceKm = normalizeDistanceKm(Number(route?.distance));
    const distanceMeters = Number(route?.distance);
    const durationSecondsRaw = Number(route?.duration);
    const speedKmh = Number(route?.speed);
    const distanceKm = normalizeDistanceKm(distanceMeters);
    if (distanceKm !== null) {
        deps.setRouteDistance(distanceKm);
    }
    const durationSeconds = normalizeDurationSeconds(Number(route?.duration));
    const durationSeconds = normalizeDurationSeconds(durationSecondsRaw);
    if (durationSeconds !== null) {
        deps.setRouteDuration(durationSeconds);
    }
    deps.setOrderRouteMetrics?.({
        distanceMeters: Number.isFinite(distanceMeters) ? distanceMeters : null,
        durationSeconds: Number.isFinite(durationSecondsRaw) ? durationSecondsRaw : null,
        speedKmh: Number.isFinite(speedKmh) ? speedKmh : null
    });
};
const resolveVehiclePoint = (points: any[], servedPoints: any[] = []) => {
src/robotaxi/pages/begin-route/index.tsx
@@ -64,6 +64,12 @@
  latitude: number;
};
type OrderRouteMetrics = {
  distanceMeters: number | null;
  durationSeconds: number | null;
  speedKmh: number | null;
};
export default function BeginRoute() {
  const WAITING_TIMER_START_KEY = 'waitingTimerStartAt'; // 等待计时开始时间
  const WAITING_TIMER_ELAPSED_KEY = 'waitingTimerElapsed'; // 等待计时已过秒数
@@ -129,6 +135,14 @@
  const [evaluateSubmitVersion, setEvaluateSubmitVersion] = useState(0); // 评价提交版本号
  const [evaluateCloseVersion, setEvaluateCloseVersion] = useState(0); // 评价关闭版本号
  const [showCouponSelectPopup, setShowCouponSelectPopup] = useState(false); // 优惠券选择弹窗展示状态
  const [orderRouteMetrics, setOrderRouteMetrics] = useState<OrderRouteMetrics>({
    distanceMeters: null,
    durationSeconds: null,
    speedKmh: null
  });
  const [modifyDestinationUsedCount, setModifyDestinationUsedCount] = useState(0); // 本次行程已成功修改目的地次数
  const [lowSpeedDurationMs, setLowSpeedDurationMs] = useState(0); // 连续低速持续时长(毫秒)
  const lowSpeedStartAtRef = useRef<number | null>(null); // 连续低速开始时间戳
  const tripStatusRef = useRef(tripStatus); // 行程状态引用
  const lastTripStatusRef = useRef(tripStatus); // 上一次行程状态,用于检测状态变化
@@ -138,6 +152,16 @@
    tripStatusForMapSyncRef.current = tripStatus;
    Taro.setStorageSync('tripStatus', tripStatus);
  }, [tripStatus]);
  useEffect(() => {
    setModifyDestinationUsedCount(0);
    setOrderRouteMetrics({
      distanceMeters: null,
      durationSeconds: null,
      speedKmh: null
    });
    lowSpeedStartAtRef.current = null;
    setLowSpeedDurationMs(0);
  }, [orderId]);
  // 行程状态显示规则配置:showPickup-显示上车点,showDropoff-显示下车点,showCar-显示车辆,showRoute-显示路线,showWalking-显示步行路线,viewport-视口类型,translateCar-车辆是否平移
  const STATUS_RULES: Record<TripStatus, StatusRule> = {
    [TripStatus.PublishRoute]: { showPickup: true, showDropoff: true, showCar: false, showRoute: true, showWalking: true, viewport: 'route_walking', translateCar: false },
@@ -198,6 +222,7 @@
    setEndSite,
    toSelectSite,
    onModifySuccess: () => {
      setModifyDestinationUsedCount((prev) => prev + 1);
      // 成功修改后触发路线重新获取
      // 下一次渲染时 isModifyDestinationReviewVisible 变为 false
      // 我们需要强制让选路状态退出,同时获取后端的新行程路线
@@ -428,6 +453,81 @@
  const requoteExpireSeconds = Number.isFinite(Number(distanceConfig?.maxStandingTiming)) && Number(distanceConfig?.maxStandingTiming) > 0
    ? Number(distanceConfig?.maxStandingTiming)
    : DEFAULT_REQUOTE_EXPIRE_SECONDS; // 重新报价有效期(秒)
  const modifyDistanceLimitMeters = Number(distanceConfig?.xczSyDistance); // 修改目的地剩余里程限制(米)
  const modifyDurationLimitMinutes = Number(distanceConfig?.xczSyTime); // 修改目的地剩余时间限制(分钟)
  const modifyCountLimit = Number(distanceConfig?.xczCaCount); // 单次行程允许修改目的地次数上限
  const lowSpeedLimitKmh = Number(distanceConfig?.xczHmSpeedPerHour); // 低速豁免阈值(km/h)
  const lowSpeedLimitMinutes = Number(distanceConfig?.xczHmDuration); // 低速豁免持续时间(分钟)
  const isTripStatusAllowModifyEntry = [TripStatus.VehicleApproaching, TripStatus.VehicleArrivedPickup, TripStatus.InService].includes(tripStatus);
  useEffect(() => {
    if (!isTripStatusAllowModifyEntry) {
      lowSpeedStartAtRef.current = null;
      setLowSpeedDurationMs(0);
      return;
    }
    if (!Number.isFinite(lowSpeedLimitKmh) || lowSpeedLimitKmh <= 0) {
      lowSpeedStartAtRef.current = null;
      setLowSpeedDurationMs(0);
      return;
    }
    const currentSpeed = Number(orderRouteMetrics?.speedKmh);
    if (!Number.isFinite(currentSpeed) || currentSpeed >= lowSpeedLimitKmh) {
      lowSpeedStartAtRef.current = null;
      setLowSpeedDurationMs(0);
      return;
    }
    const now = Date.now();
    if (!lowSpeedStartAtRef.current) {
      lowSpeedStartAtRef.current = now;
      setLowSpeedDurationMs(0);
      return;
    }
    setLowSpeedDurationMs(now - lowSpeedStartAtRef.current);
  }, [isTripStatusAllowModifyEntry, orderRouteMetrics?.speedKmh, lowSpeedLimitKmh]);
  const showModifyDestination = useMemo(() => {
    if (!isTripStatusAllowModifyEntry) {
      return false;
    }
    const hasDistanceLimit = Number.isFinite(modifyDistanceLimitMeters) && modifyDistanceLimitMeters > 0;
    const hasDurationLimit = Number.isFinite(modifyDurationLimitMinutes) && modifyDurationLimitMinutes > 0;
    const hasCountLimit = Number.isFinite(modifyCountLimit) && modifyCountLimit > 0;
    const hasLowSpeedLimit = Number.isFinite(lowSpeedLimitKmh) && lowSpeedLimitKmh > 0;
    const hasLowSpeedDurationLimit = Number.isFinite(lowSpeedLimitMinutes) && lowSpeedLimitMinutes > 0;
    const currentDistance = Number(orderRouteMetrics?.distanceMeters);
    const currentDurationSeconds = Number(orderRouteMetrics?.durationSeconds);
    const currentSpeed = Number(orderRouteMetrics?.speedKmh);
    const hitDistanceLimit = hasDistanceLimit && Number.isFinite(currentDistance) && currentDistance < modifyDistanceLimitMeters;
    const hitDurationLimit = hasDurationLimit && Number.isFinite(currentDurationSeconds) && (currentDurationSeconds / 60) < modifyDurationLimitMinutes;
    const hitCountLimit = hasCountLimit && modifyDestinationUsedCount >= modifyCountLimit;
    const hitAnyLimit = hitDistanceLimit || hitDurationLimit || hitCountLimit;
    const hitLowSpeedExemption = hasLowSpeedLimit
      && hasLowSpeedDurationLimit
      && Number.isFinite(currentSpeed)
      && currentSpeed < lowSpeedLimitKmh
      && lowSpeedDurationMs >= lowSpeedLimitMinutes * 60 * 1000;
    if (hitLowSpeedExemption) {
      return true;
    }
    return !hitAnyLimit;
  }, [
    isTripStatusAllowModifyEntry,
    modifyDistanceLimitMeters,
    modifyDurationLimitMinutes,
    modifyCountLimit,
    lowSpeedLimitKmh,
    lowSpeedLimitMinutes,
    orderRouteMetrics?.distanceMeters,
    orderRouteMetrics?.durationSeconds,
    orderRouteMetrics?.speedKmh,
    modifyDestinationUsedCount,
    lowSpeedDurationMs
  ]);
  const {
    handleDidShow
  } = usePageRestore({
@@ -546,6 +646,7 @@
    setVehicleMoveAlongPoints,
    setRouteDistance,
    setRouteDuration,
    setOrderRouteMetrics,
    setBeforeUserEndSite,
    erasePassedRouteSegments: routeErasePassedRouteSegments,
    applyRoutePoints: routeApplyRoutePoints,
@@ -842,6 +943,7 @@
    handleOpenEvaluatePopup,
    handleToggleDetailFade,
    handleModifyDestination,
    showModifyDestination,
    handleShareTrip,
    trunkStatus,
    routeOptions,
src/robotaxi/pages/order/index.tsx
@@ -3,7 +3,7 @@
import { View, Image } from '@tarojs/components';
import './index.less';
import { getOrderHistoryList, OrderHistoryGroup } from '../../../robotaxi/api/order';
import { INVOICE_URL } from '../../../constants/api';
import { _INVOICE_URL } from '../../../constants/api';
type OrderFilterKey = 'all' | 'pendingPay' | 'completed' | 'cancelled';
@@ -107,7 +107,7 @@
  const toInvoicePage = () => {
    Taro.navigateTo({
      url: `/pages/webpage/index?pageUrl=${encodeURIComponent(INVOICE_URL)}`
      url: `/pages/webpage/index?pageUrl=${encodeURIComponent(_INVOICE_URL)}`
    })
  }