feat(begin-route): 新增路线自动巡航视口与蓝牙多地址重试
引入自动路线视口巡航编排,在车辆行进与服务状态时,每8秒自动调整地图视口以包含完整路线。用户手势操作后暂停巡航,2秒空闲后恢复。优化蓝牙车控逻辑,支持从订单接口获取主/从蓝牙地址列表,并在连接失败时自动切换重试,提高车门解锁成功率。
| New file |
| | |
| | | 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 |
| | | }; |
| | | } |
| | |
| | | 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'; |
| | |
| | | type Deps = { |
| | | orderId: string; |
| | | tripStatus: TripStatus; |
| | | vehicleInfo: any; |
| | | }; |
| | | |
| | | type BluetoothTokenPayload = { |
| | | token?: string; // 鉴权凭证 |
| | | macAddress?: string; // 主地址(字符串) |
| | | slaveMacList?: string[]; // 从地址列表(字符串数组) |
| | | }; |
| | | |
| | | type ConnectStatus = 0 | 1 | 2 | 3; |
| | |
| | | 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); |
| | | } |
| | | |
| | | /** |
| | |
| | | /** |
| | | * 蓝牙车控编排:负责凭证获取、蓝牙连接和 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); // 鉴权流程进行中 |
| | |
| | | }); |
| | | }, []); // 统一展示蓝牙解锁结果提示 |
| | | |
| | | 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; |
| | | }, []); |
| | | |
| | | /** |
| | | * 初始化蓝牙管理器。 |
| | |
| | | }, |
| | | onError: (error: any) => { |
| | | console.log('[蓝牙] 连接错误:', error); |
| | | if (isRetryableConnectError(error) && tryFallbackMacConnection()) { |
| | | return; |
| | | } |
| | | setAuthError(error?.msg || '蓝牙连接失败'); |
| | | setIsAuthorizing(false); |
| | | }, |
| | |
| | | } |
| | | }); |
| | | return managerRef.current; |
| | | }, [showUnlockToast]); |
| | | }, [showUnlockToast, tryFallbackMacConnection]); |
| | | |
| | | /** |
| | | * 主动断开蓝牙连接并重置状态。 |
| | |
| | | * 触发蓝牙连接与 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); |
| | |
| | | |
| | | 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(); |
| | |
| | | setAuthError(error?.msg || '获取蓝牙鉴权凭证失败'); |
| | | setIsAuthorizing(false); |
| | | } |
| | | }, [tripStatus, orderId, bluetoothMac, isAuthorizing, authSuccess, ensureManager]); |
| | | }, [tripStatus, orderId, isAuthorizing, authSuccess, ensureManager]); |
| | | |
| | | /** |
| | | * 重试蓝牙鉴权流程。 |
| | |
| | | console.log('[蓝牙] 当前状态非4/5,断开连接:', tripStatus); |
| | | closeConnection(); |
| | | lastStartKeyRef.current = ''; |
| | | candidateMacsRef.current = []; |
| | | currentMacIndexRef.current = 0; |
| | | certificateRef.current = ''; |
| | | setAuthError(''); |
| | | } |
| | | }, [tripStatus, closeConnection]); |
| | |
| | | }, []); |
| | | |
| | | return { |
| | | bluetoothMac, |
| | | connectStatus, |
| | | authSuccess, |
| | | authError, |
| | |
| | | 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) => |
| | |
| | | if (points.length > 0) { |
| | | syncVehiclePointWithRoute(points, deps, servedPoints); |
| | | } |
| | | if (typeof deps.onOrderUpdateLineRendered === 'function') { |
| | | // 等路线点与折线提交渲染后再触发视口重算,避免 includePoints 早于渲染导致跳动。 |
| | | Taro.nextTick(() => { |
| | | deps.onOrderUpdateLineRendered?.(points); |
| | | }); |
| | | } |
| | | }); |
| | | }; |
| | | |
| | |
| | | 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'; |
| | |
| | | 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'; |
| | | // 页面动作编排 |
| | |
| | | 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({ |
| | |
| | | setBeforeUserEndSite, |
| | | erasePassedRouteSegments: routeErasePassedRouteSegments, |
| | | applyRoutePoints: routeApplyRoutePoints, |
| | | updateVehicleLocationThrottled: vehicleState.updateVehicleLocationThrottled |
| | | updateVehicleLocationThrottled: vehicleState.updateVehicleLocationThrottled, |
| | | onOrderUpdateLineRendered: autoRouteViewport.handleOrderUpdateLineRendered |
| | | }); |
| | | |
| | | const orderActions = useOrderActions({ |
| | |
| | | startAuth: startBluetoothAuth |
| | | } = useBluetoothCarControl({ |
| | | orderId, |
| | | tripStatus, |
| | | vehicleInfo |
| | | tripStatus |
| | | }); |
| | | const { |
| | | bluetoothStatusReady, |
| | |
| | | setPageDidShowVersion((v) => v + 1); |
| | | handleDidShow(router.params?.skipPageFlag); |
| | | updateMapViewportPadding(); |
| | | autoRouteViewport.resume(); |
| | | }); |
| | | useDidHide(() => { |
| | | autoRouteViewport.dispose(); |
| | | }); |
| | | |
| | | // 打开评价弹窗 |
| | |
| | | } |
| | | |
| | | const handleRegionChange = (e) => { |
| | | autoRouteViewport.handleRegionChange(e); |
| | | handleAndroidOverlayRegionChange(e); |
| | | }; |
| | | |