hejianhao
2026-04-23 e91671d61c45bdf38564566f6a67061706ba2697
feat(begin-route): 新增路线自动巡航视口与蓝牙多地址重试

引入自动路线视口巡航编排,在车辆行进与服务状态时,每8秒自动调整地图视口以包含完整路线。用户手势操作后暂停巡航,2秒空闲后恢复。优化蓝牙车控逻辑,支持从订单接口获取主/从蓝牙地址列表,并在连接失败时自动切换重试,提高车门解锁成功率。
1个文件已添加
3个文件已修改
355 ■■■■■ 已修改文件
src/robotaxi/pages/begin-route/hooks/use-auto-route-viewport.ts 219 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/hooks/use-bluetooth-car-control.ts 105 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/hooks/use-web-socket-events.ts 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/index.tsx 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/hooks/use-auto-route-viewport.ts
New file
@@ -0,0 +1,219 @@
import Taro from '@tarojs/taro';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { TripStatus } from '../constants/trip-status';
type RoutePoint = {
  latitude: number;
  longitude: number;
};
type Deps = {
  tripStatus: TripStatus;
  routePoints: RoutePoint[];
  computeBottomPadding: (points: any[], viewportType: string) => number;
  resolveSafeTopInset: () => number;
};
const CRUISE_INTERVAL_MS = 8000; // 自动巡航调整周期(毫秒)
const IDLE_RESUME_DELAY_MS = 2000; // 用户手势结束后的恢复延迟(毫秒)
const MIN_FIT_INTERVAL_MS = 1200; // 两次视图调整的最小时间间隔(毫秒)
const USER_GESTURE_CAUSED_BY = new Set(['gesture', 'drag', 'scale']); // 视为用户手势的 causedBy 集合
/**
 * 根据路线点构建签名:用于判断路线是否发生实质变化。
 */
const buildRouteSignature = (points: RoutePoint[]) => {
  if (!Array.isArray(points) || points.length === 0) return '';
  const first = points[0];
  const middle = points[Math.floor(points.length / 2)];
  const last = points[points.length - 1];
  const fmt = (value: number) => Number(value || 0).toFixed(6);
  return [
    points.length,
    fmt(first?.latitude), fmt(first?.longitude),
    fmt(middle?.latitude), fmt(middle?.longitude),
    fmt(last?.latitude), fmt(last?.longitude),
  ].join('|');
};
/**
 * 自动路线视口编排:空闲时 8s 巡航调整,用户手势后 2s 恢复,支持 ORDER_UPDATE_LINE 强制重算。
 */
export default function useAutoRouteViewport(deps: Deps) {
  const routePointsRef = useRef<RoutePoint[]>(deps.routePoints);
  const cruiseTimerRef = useRef<any>(null);
  const idleResumeTimerRef = useRef<any>(null);
  const lastFitAtRef = useRef(0);
  const lastFittedRouteSignatureRef = useRef('');
  const pendingOrderUpdateLinePointsRef = useRef<RoutePoint[] | null>(null);
  const isActiveStatus = useMemo(() => (
    [TripStatus.VehicleApproaching, TripStatus.InService].includes(deps.tripStatus)
  ), [deps.tripStatus]);
  useEffect(() => {
    routePointsRef.current = deps.routePoints;
  }, [deps.routePoints]);
  /**
   * 清理巡航定时器。
   */
  const clearCruiseTimer = useCallback(() => {
    if (!cruiseTimerRef.current) return;
    clearInterval(cruiseTimerRef.current);
    cruiseTimerRef.current = null;
  }, []);
  /**
   * 清理空闲恢复定时器。
   */
  const clearIdleResumeTimer = useCallback(() => {
    if (!idleResumeTimerRef.current) return;
    clearTimeout(idleResumeTimerRef.current);
    idleResumeTimerRef.current = null;
  }, []);
  /**
   * 执行一次路线入屏调整。
   */
  const fitRouteIntoView = useCallback((points: RoutePoint[]) => {
    if (!Array.isArray(points) || points.length < 2) return;
    const mapCtx = Taro.createMapContext('map');
    const bottomPadding = deps.computeBottomPadding(points, 'route');
    const topPadding = Math.max(24, deps.resolveSafeTopInset() + 32);
    mapCtx.includePoints({
      points,
      padding: [topPadding, 100, bottomPadding, 100]
    });
    lastFitAtRef.current = Date.now();
    lastFittedRouteSignatureRef.current = buildRouteSignature(points);
  }, [deps.computeBottomPadding, deps.resolveSafeTopInset]);
  /**
   * 按规则尝试执行调整(普通巡航会做签名去重,force 模式会强制执行)。
   */
  const tryFitRoute = useCallback((force: boolean, forcePoints?: RoutePoint[] | null) => {
    if (!isActiveStatus) return false;
    const now = Date.now();
    if (now - lastFitAtRef.current < MIN_FIT_INTERVAL_MS) return false;
    const points = Array.isArray(forcePoints) && forcePoints.length > 1 ? forcePoints : routePointsRef.current;
    if (!Array.isArray(points) || points.length < 2) return false;
    if (!force) {
      const nextSignature = buildRouteSignature(points);
      if (!nextSignature || nextSignature === lastFittedRouteSignatureRef.current) {
        return false;
      }
    }
    fitRouteIntoView(points);
    return true;
  }, [fitRouteIntoView, isActiveStatus]);
  /**
   * 启动巡航定时器。
   */
  const startCruise = useCallback(() => {
    if (!isActiveStatus || cruiseTimerRef.current) return;
    cruiseTimerRef.current = setInterval(() => {
      tryFitRoute(false);
    }, CRUISE_INTERVAL_MS);
  }, [isActiveStatus, tryFitRoute]);
  /**
   * 重置巡航节奏:先停后启,进入新的 8s 周期。
   */
  const resetCruise = useCallback(() => {
    clearCruiseTimer();
    clearIdleResumeTimer();
    if (!isActiveStatus) return;
    startCruise();
  }, [clearCruiseTimer, clearIdleResumeTimer, isActiveStatus, startCruise]);
  /**
   * 用户手势触发:暂停巡航并在 2s 空闲后恢复。
   */
  const onUserGesture = useCallback(() => {
    if (!isActiveStatus) return;
    clearCruiseTimer();
    clearIdleResumeTimer();
    idleResumeTimerRef.current = setTimeout(() => {
      const forcedPoints = pendingOrderUpdateLinePointsRef.current;
      if (forcedPoints && forcedPoints.length > 1) {
        tryFitRoute(true, forcedPoints);
        pendingOrderUpdateLinePointsRef.current = null;
      } else {
        tryFitRoute(false);
      }
      startCruise();
    }, IDLE_RESUME_DELAY_MS);
  }, [clearCruiseTimer, clearIdleResumeTimer, isActiveStatus, startCruise, tryFitRoute]);
  /**
   * 处理地图 regionchange,仅识别用户手势 causedBy。
   */
  const handleRegionChange = useCallback((e: any) => {
    const causedBy = String(e?.causedBy || e?.detail?.causedBy || '').toLowerCase();
    if (!USER_GESTURE_CAUSED_BY.has(causedBy)) return;
    onUserGesture();
  }, [onUserGesture]);
  /**
   * ORDER_UPDATE_LINE 触发:路线完成渲染后强制重算一次视图并重置巡航。
   */
  const handleOrderUpdateLineRendered = useCallback((points: RoutePoint[]) => {
    if (!isActiveStatus) return;
    const safePoints = Array.isArray(points) ? points : [];
    const runForcedFit = () => {
      const nextPoints = safePoints.length > 1 ? safePoints : routePointsRef.current;
      const fitted = tryFitRoute(true, nextPoints);
      if (fitted) {
        pendingOrderUpdateLinePointsRef.current = null;
        resetCruise();
      } else {
        pendingOrderUpdateLinePointsRef.current = nextPoints;
      }
    };
    Taro.nextTick(() => {
      setTimeout(() => {
        runForcedFit();
      }, 0);
    });
  }, [isActiveStatus, resetCruise, tryFitRoute]);
  /**
   * 外部主动释放:页面隐藏/卸载时确保定时器清理。
   */
  const dispose = useCallback(() => {
    pendingOrderUpdateLinePointsRef.current = null;
    clearCruiseTimer();
    clearIdleResumeTimer();
  }, [clearCruiseTimer, clearIdleResumeTimer]);
  /**
   * 页面恢复展示时继续巡航(若当前状态允许)。
   */
  const resume = useCallback(() => {
    if (!isActiveStatus) return;
    startCruise();
  }, [isActiveStatus, startCruise]);
  useEffect(() => {
    if (!isActiveStatus) {
      clearCruiseTimer();
      clearIdleResumeTimer();
      pendingOrderUpdateLinePointsRef.current = null;
      return;
    }
    startCruise();
    return () => {
      clearCruiseTimer();
      clearIdleResumeTimer();
    };
  }, [isActiveStatus, clearCruiseTimer, clearIdleResumeTimer, startCruise]);
  return {
    handleRegionChange,
    handleOrderUpdateLineRendered,
    dispose,
    resume
  };
}
src/robotaxi/pages/begin-route/hooks/use-bluetooth-car-control.ts
@@ -1,5 +1,5 @@
import Taro from '@tarojs/taro';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import RtAuthBluetoothManager from '../../../utils/car-control/RtAuthBluetoothManager';
import { getBluetoothUnlockToken } from '../../../api/car-control';
import { TripStatus } from '../constants/trip-status';
@@ -7,7 +7,12 @@
type Deps = {
  orderId: string;
  tripStatus: TripStatus;
  vehicleInfo: any;
};
type BluetoothTokenPayload = {
  token?: string; // 鉴权凭证
  macAddress?: string; // 主地址(字符串)
  slaveMacList?: string[]; // 从地址列表(字符串数组)
};
type ConnectStatus = 0 | 1 | 2 | 3;
@@ -15,11 +20,33 @@
const UNLOCK_SUCCESS_MESSAGE = '车门已解锁,请上车'; // 解锁成功提示文案
/**
 * 解析车辆信息中的蓝牙 MAC。
 * 归一化 MAC 地址,统一去空格并转大写。
 */
function resolveBluetoothMac(vehicleInfo: any): string {
  const mac = vehicleInfo?.macAddress;
  return typeof mac === 'string' ? mac.trim() : '';
function normalizeMac(value: unknown): string {
  return typeof value === 'string' ? value.trim().toUpperCase() : '';
}
/**
 * 从 token 接口结果构建蓝牙地址候选列表:主地址优先,其次从地址列表,自动去重。
 */
function buildMacCandidates(macAddress: unknown, slaveMacList: unknown): string[] {
  const candidates = [normalizeMac(macAddress)];
  if (Array.isArray(slaveMacList)) {
    slaveMacList.forEach((item) => {
      candidates.push(normalizeMac(item));
    });
  }
  return Array.from(new Set(candidates.filter(Boolean)));
}
/**
 * 判断错误是否属于可切换 MAC 重试的连接链路错误。
 */
function isRetryableConnectError(error: any): boolean {
  const type = String(error?.type || '').toUpperCase();
  if (type === 'CONNECT_ERR' || type === 'ADAPTER_ERR') return true;
  const msg = String(error?.msg || error?.errMsg || '');
  return /(connect|连接|adapter|蓝牙适配器|timeout|超时|discovery)/i.test(msg);
}
/**
@@ -37,9 +64,13 @@
/**
 * 蓝牙车控编排:负责凭证获取、蓝牙连接和 RtAuth 鉴权。
 */
export default function useBluetoothCarControl({ orderId, tripStatus, vehicleInfo }: Deps) {
export default function useBluetoothCarControl({ orderId, tripStatus }: Deps) {
  const managerRef = useRef<any>(null); // 蓝牙管理器实例引用
  const lastStartKeyRef = useRef(''); // 最近一次启动鉴权的参数签名
  const candidateMacsRef = useRef<string[]>([]); // 蓝牙地址候选列表(主 + 从)
  const currentMacIndexRef = useRef(0); // 当前尝试的 MAC 索引
  const certificateRef = useRef(''); // 当前会话证书缓存
  const deviceModelRef = useRef(resolveDeviceModel()); // 当前设备型号缓存
  const [connectStatus, setConnectStatus] = useState<ConnectStatus>(0); // 蓝牙连接状态:0断开/1连接中/2已连接/3已鉴权
  const [authSuccess, setAuthSuccess] = useState(false); // RtAuth 鉴权是否成功
  const [isAuthorizing, setIsAuthorizing] = useState(false); // 鉴权流程进行中
@@ -51,7 +82,28 @@
    });
  }, []); // 统一展示蓝牙解锁结果提示
  const bluetoothMac = useMemo(() => resolveBluetoothMac(vehicleInfo), [vehicleInfo]); // 车辆蓝牙 MAC
  /**
   * 切换到下一个候选 MAC 重试连接。
   */
  const tryFallbackMacConnection = useCallback(() => {
    if (currentMacIndexRef.current >= candidateMacsRef.current.length - 1) {
      return false;
    }
    const nextIndex = currentMacIndexRef.current + 1;
    const nextMac = candidateMacsRef.current[nextIndex];
    if (!nextMac || !managerRef.current) {
      return false;
    }
    currentMacIndexRef.current = nextIndex;
    console.log('[蓝牙] 当前地址连接失败,切换从地址重试:', nextMac);
    managerRef.current.updateCarConfig({
      macAddress: nextMac,
      certificate: certificateRef.current,
      deviceModel: deviceModelRef.current
    });
    managerRef.current.retryConnection();
    return true;
  }, []);
  /**
   * 初始化蓝牙管理器。
@@ -72,6 +124,9 @@
      },
      onError: (error: any) => {
        console.log('[蓝牙] 连接错误:', error);
        if (isRetryableConnectError(error) && tryFallbackMacConnection()) {
          return;
        }
        setAuthError(error?.msg || '蓝牙连接失败');
        setIsAuthorizing(false);
      },
@@ -107,7 +162,7 @@
      }
    });
    return managerRef.current;
  }, [showUnlockToast]);
  }, [showUnlockToast, tryFallbackMacConnection]);
  /**
   * 主动断开蓝牙连接并重置状态。
@@ -126,11 +181,11 @@
   * 触发蓝牙连接与 RtAuth 鉴权。
   */
  const startAuth = useCallback(async () => {
    console.log('[蓝牙] 开始鉴权入参:', { tripStatus, orderId, bluetoothMac, isAuthorizing, authSuccess });
    console.log('[蓝牙] 开始鉴权入参:', { tripStatus, orderId, isAuthorizing, authSuccess });
    if (![TripStatus.VehicleApproaching, TripStatus.VehicleArrivedPickup].includes(tripStatus)) return;
    if (!orderId || !bluetoothMac) return;
    if (!orderId) return;
    const startKey = `${orderId}|${bluetoothMac}`;
    const startKey = `${orderId}`;
    if (isAuthorizing || (authSuccess && lastStartKeyRef.current === startKey)) return;
    lastStartKeyRef.current = startKey;
    setIsAuthorizing(true);
@@ -138,19 +193,29 @@
    try {
      const tokenRes = await getBluetoothUnlockToken(orderId);
      const certificate = typeof tokenRes === 'string' ? tokenRes : String(tokenRes || '');
      console.log('[蓝牙] 获取凭证成功,凭证长度:', certificate?.length || 0);
      const tokenPayload: BluetoothTokenPayload = typeof tokenRes === 'object' && tokenRes !== null ? tokenRes as BluetoothTokenPayload : {};
      const certificate = typeof tokenPayload.token === 'string' ? tokenPayload.token : String(tokenPayload.token || '');
      const candidateMacs = buildMacCandidates(tokenPayload.macAddress, tokenPayload.slaveMacList);
      console.log('[蓝牙] 获取凭证与地址成功:', { tokenLength: certificate?.length || 0, macCount: candidateMacs.length });
      if (!certificate) {
        setAuthError('蓝牙鉴权凭证为空');
        setIsAuthorizing(false);
        return;
      }
      if (candidateMacs.length === 0) {
        setAuthError('蓝牙地址为空');
        setIsAuthorizing(false);
        return;
      }
      certificateRef.current = certificate;
      candidateMacsRef.current = candidateMacs;
      currentMacIndexRef.current = 0;
      const manager = ensureManager();
      console.log('[蓝牙] 更新车辆配置:', { macAddress: bluetoothMac, deviceModel: resolveDeviceModel() });
      console.log('[蓝牙] 更新车辆配置(主地址):', { macAddress: candidateMacs[0], deviceModel: deviceModelRef.current });
      manager.updateCarConfig({
        macAddress: bluetoothMac,
        macAddress: candidateMacs[0],
        certificate,
        deviceModel: resolveDeviceModel()
        deviceModel: deviceModelRef.current
      });
      console.log('[蓝牙] 准备打开蓝牙适配器');
      manager.openBluetoothAdapter();
@@ -159,7 +224,7 @@
      setAuthError(error?.msg || '获取蓝牙鉴权凭证失败');
      setIsAuthorizing(false);
    }
  }, [tripStatus, orderId, bluetoothMac, isAuthorizing, authSuccess, ensureManager]);
  }, [tripStatus, orderId, isAuthorizing, authSuccess, ensureManager]);
  /**
   * 重试蓝牙鉴权流程。
@@ -180,6 +245,9 @@
      console.log('[蓝牙] 当前状态非4/5,断开连接:', tripStatus);
      closeConnection();
      lastStartKeyRef.current = '';
      candidateMacsRef.current = [];
      currentMacIndexRef.current = 0;
      certificateRef.current = '';
      setAuthError('');
    }
  }, [tripStatus, closeConnection]);
@@ -193,7 +261,6 @@
  }, []);
  return {
    bluetoothMac,
    connectStatus,
    authSuccess,
    authError,
src/robotaxi/pages/begin-route/hooks/use-web-socket-events.ts
@@ -38,6 +38,7 @@
    erasePassedRouteSegments: (points: any[], servedPoints?: any[]) => void;
    applyRoutePoints: (points: any[]) => boolean;
    updateVehicleLocationThrottled: (lat: number, lng: number, force?: boolean) => void;
    onOrderUpdateLineRendered?: (points: any[]) => void;
};
const resolveOrderId = (payload: any, fallbackOrderId: string) =>
@@ -274,6 +275,12 @@
        if (points.length > 0) {
            syncVehiclePointWithRoute(points, deps, servedPoints);
        }
        if (typeof deps.onOrderUpdateLineRendered === 'function') {
            // 等路线点与折线提交渲染后再触发视口重算,避免 includePoints 早于渲染导致跳动。
            Taro.nextTick(() => {
                deps.onOrderUpdateLineRendered?.(points);
            });
        }
    });
};
src/robotaxi/pages/begin-route/index.tsx
@@ -1,4 +1,4 @@
import Taro, { useDidShow, useShareAppMessage } from "@tarojs/taro"
import Taro, { useDidHide, useDidShow, useShareAppMessage } from "@tarojs/taro"
import { useEffect, useState, useRef, useMemo } from 'react';
import { View, Image } from '@tarojs/components';
import './index.less';
@@ -18,6 +18,7 @@
import useAndroidOverlays from './hooks/use-android-overlays';
// 地图视口状态管理
import useMapViewport from './hooks/use-map-viewport';
import useAutoRouteViewport from './hooks/use-auto-route-viewport';
// 页面恢复与初始化编排
import usePageRestore from './hooks/use-page-restore';
// 页面动作编排
@@ -345,8 +346,16 @@
    tripStatus6ViewportLockedRef,
    updateMapViewportPadding,
    toOrigin,
    getLocationInfo
    getLocationInfo,
    computeBottomPadding,
    resolveSafeTopInset
  } = viewportState; // 视口状态解构
  const autoRouteViewport = useAutoRouteViewport({
    tripStatus,
    routePoints,
    computeBottomPadding,
    resolveSafeTopInset
  }); // 自动路线视口巡航编排
  const arrivedMarkerReady = tripStatus !== TripStatus.ArrivedDestination || typeof vehicleDirection === 'number';
  const arrivedTooltipReady = tripStatus !== TripStatus.ArrivedDestination || (arrivedMarkerReady && mapScale >= 20);
  const markerState = useMapMarkers({
@@ -511,7 +520,8 @@
    setBeforeUserEndSite,
    erasePassedRouteSegments: routeErasePassedRouteSegments,
    applyRoutePoints: routeApplyRoutePoints,
    updateVehicleLocationThrottled: vehicleState.updateVehicleLocationThrottled
    updateVehicleLocationThrottled: vehicleState.updateVehicleLocationThrottled,
    onOrderUpdateLineRendered: autoRouteViewport.handleOrderUpdateLineRendered
  });
  const orderActions = useOrderActions({
@@ -556,8 +566,7 @@
    startAuth: startBluetoothAuth
  } = useBluetoothCarControl({
    orderId,
    tripStatus,
    vehicleInfo
    tripStatus
  });
  const {
    bluetoothStatusReady,
@@ -637,6 +646,10 @@
    setPageDidShowVersion((v) => v + 1);
    handleDidShow(router.params?.skipPageFlag);
    updateMapViewportPadding();
    autoRouteViewport.resume();
  });
  useDidHide(() => {
    autoRouteViewport.dispose();
  });
  // 打开评价弹窗
@@ -656,6 +669,7 @@
  }
  const handleRegionChange = (e) => {
    autoRouteViewport.handleRegionChange(e);
    handleAndroidOverlayRegionChange(e);
  };