Merge branch 'feature_v1.0' of http://gitlab.xiaopeng.local:18080/robotaxi1/xp-robotaxi-miniprogram into feature_v1.0
94个文件已删除
1个文件已添加
16个文件已修改
| | |
| | | duration: number | null; |
| | | /** 车辆朝向(0-359) */ |
| | | direction: number | null; |
| | | /** 当前速度(km/h,已由原始 m/s 统一换算) */ |
| | | speedKmh: number | null; |
| | | /** 当前速度(m/s)。 */ |
| | | speedMps: number | null; |
| | | /** 消息时间戳(用于乱序保护) */ |
| | | timestamp: number | null; |
| | | /** 已行驶轨迹点(servedPoints) */ |
| | |
| | | } |
| | | |
| | | /** |
| | | * 将后端下发的速度(m/s)统一换算为页面消费的 km/h。 |
| | | * 将后端下发的速度统一规整为 m/s。 |
| | | */ |
| | | export function normalizeSpeedKmh(rawSpeed: any) { |
| | | export function normalizeSpeedMps(rawSpeed: any) { |
| | | const speedMetersPerSecond = Number(rawSpeed); |
| | | if (!Number.isFinite(speedMetersPerSecond)) { |
| | | return null; |
| | | } |
| | | return speedMetersPerSecond * 3.6; |
| | | return Math.max(0, speedMetersPerSecond); |
| | | } |
| | | |
| | | /** |
| | |
| | | export function normalizeSocketRouteSnapshot(payload: any): RouteSnapshot { |
| | | const route = safeParse(payload?.content?.route, {}); |
| | | const timestamp = Number(payload?.data?.timestamp); |
| | | const speedKmh = normalizeSpeedKmh(route?.speed); |
| | | const speedMps = normalizeSpeedMps(route?.speed); |
| | | return { |
| | | points: normalizeRoutePoints(payload?.points), |
| | | distance: normalizeDistanceKm(Number(route?.distance)), |
| | | duration: normalizeDurationSeconds(Number(route?.duration)), |
| | | direction: normalizeDirection(payload?.content?.direction), |
| | | speedKmh, |
| | | speedMps, |
| | | timestamp: Number.isFinite(timestamp) ? timestamp : null, |
| | | vehicleMoveAlongPoints: normalizeRoutePoints(payload?.servedPoints), |
| | | forceFullRedraw: payload?.forceFullRedraw === true, |
| | |
| | | import Taro from '@tarojs/taro'; |
| | | import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; |
| | | import { useCallback, useMemo } from 'react'; |
| | | import { TripStatus } from '../constants/trip-status'; |
| | | import useVehicleSpeedScale from './use-vehicle-speed-scale'; |
| | | |
| | | type RoutePoint = { |
| | | latitude: number; |
| | |
| | | }; |
| | | |
| | | type Deps = { |
| | | /** 当前行程状态。 */ |
| | | tripStatus: TripStatus; |
| | | speedKmh: number | null; |
| | | /** 当前速度,单位 m/s。 */ |
| | | speedMps: number | null; |
| | | /** 当前地图比例尺。 */ |
| | | mapScale: number; |
| | | /** 设置地图比例尺。 */ |
| | | setMapScale: (value: number) => void; |
| | | /** 动态缩放生效后同步车辆视口中心。 */ |
| | | syncActiveTripViewportToVehicle: () => void; |
| | | /** 计算 includePoints 所需底部留白。 */ |
| | | computeBottomPadding: (points: any[], viewportType: string) => number; |
| | | /** 读取顶部安全区高度。 */ |
| | | resolveSafeTopInset: () => number; |
| | | /** 暂停动态缩放。 */ |
| | | holdDynamicScale: (durationMs?: number) => void; |
| | | /** 读取动态缩放暂停截止时间。 */ |
| | | getDynamicScaleHoldUntil: () => number; |
| | | }; |
| | | |
| | |
| | | const USER_GESTURE_CAUSED_BY = new Set(['gesture', 'drag', 'scale']); // 需要暂停速度缩放的用户手势来源 |
| | | const USER_GESTURE_HOLD_MS = 3000; // 用户手势后暂停速度缩放时长 |
| | | const ORDER_UPDATE_LINE_HOLD_MS = 2500; // ORDER_UPDATE_LINE 全路线入屏后暂停速度缩放时长 |
| | | const SCALE_RESUME_BUFFER_MS = 80; // 冷却窗口结束后的恢复缓冲时间 |
| | | const SCALE_STEP = 0.5; // 比例尺离散步进,避免频繁抖动 |
| | | const MIN_SCALE_CHANGE = 0.25; // 触发 setMapScale 的最小比例尺变化 |
| | | const SPEED_SMOOTHING_SAMPLE_SIZE = 3; // 速度平滑采样数量 |
| | | const SCALE_BAND_HYSTERESIS_KMH = 4; // 档位切换滞回,避免边界抖动 |
| | | const MAX_SCALE_STEP_PER_TICK = 1; // 单次缩放最大变化,避免突然大幅跳变 |
| | | const ORDER_UPDATE_LINE_SIDE_PADDING = 100; // ORDER_UPDATE_LINE 路线入屏左右留白 |
| | | const ORDER_UPDATE_LINE_TOP_PADDING = 24; // ORDER_UPDATE_LINE 路线入屏最小顶部留白 |
| | | const ORDER_UPDATE_LINE_TOP_EXTRA_PADDING = 32; // ORDER_UPDATE_LINE 顶部安全区附加留白 |
| | | |
| | | type ScaleBand = { |
| | | minSpeedKmh: number; |
| | | maxSpeedKmh: number; |
| | | scale: number; |
| | | }; |
| | | |
| | | const DYNAMIC_SCALE_BANDS: ScaleBand[] = [ |
| | | { minSpeedKmh: 0, maxSpeedKmh: 10, scale: 18 }, |
| | | { minSpeedKmh: 10, maxSpeedKmh: 20, scale: 17.5 }, |
| | | { minSpeedKmh: 20, maxSpeedKmh: 30, scale: 17 }, |
| | | { minSpeedKmh: 30, maxSpeedKmh: 40, scale: 16 }, |
| | | { minSpeedKmh: 40, maxSpeedKmh: 50, scale: 15.5 }, |
| | | { minSpeedKmh: 50, maxSpeedKmh: 60, scale: 15 }, |
| | | { minSpeedKmh: 60, maxSpeedKmh: Number.POSITIVE_INFINITY, scale: 14 } |
| | | ]; // 基于车速的动态缩放档位,覆盖低速精细、中速平衡、高速宏观三类视角 |
| | | |
| | | /** |
| | | * 将数值限制在给定区间内。 |
| | | */ |
| | | const clamp = (value: number, min: number, max: number) => { |
| | | if (value < min) return min; |
| | | if (value > max) return max; |
| | | return value; |
| | | }; |
| | | |
| | | /** |
| | | * 根据速度命中所属缩放档位。 |
| | | */ |
| | | const resolveScaleBandIndex = (speedKmh: number) => { |
| | | const safeSpeed = Math.max(0, Number(speedKmh) || 0); |
| | | const bandIndex = DYNAMIC_SCALE_BANDS.findIndex((band) => safeSpeed >= band.minSpeedKmh && safeSpeed < band.maxSpeedKmh); |
| | | return bandIndex >= 0 ? bandIndex : DYNAMIC_SCALE_BANDS.length - 1; |
| | | }; |
| | | |
| | | /** |
| | | * 基于上一次档位应用滞回,避免速度在边界附近频繁切换。 |
| | | */ |
| | | const resolveStableScaleBandIndex = (speedKmh: number, previousBandIndex: number | null) => { |
| | | const targetBandIndex = resolveScaleBandIndex(speedKmh); |
| | | if (previousBandIndex === null || previousBandIndex === targetBandIndex) { |
| | | return targetBandIndex; |
| | | } |
| | | let nextBandIndex = previousBandIndex; |
| | | if (targetBandIndex > previousBandIndex) { |
| | | while (nextBandIndex < targetBandIndex) { |
| | | const fasterBand = DYNAMIC_SCALE_BANDS[nextBandIndex + 1]; |
| | | if (speedKmh >= fasterBand.minSpeedKmh + SCALE_BAND_HYSTERESIS_KMH) { |
| | | nextBandIndex += 1; |
| | | continue; |
| | | } |
| | | break; |
| | | } |
| | | return nextBandIndex; |
| | | } |
| | | while (nextBandIndex > targetBandIndex) { |
| | | const currentBand = DYNAMIC_SCALE_BANDS[nextBandIndex]; |
| | | if (speedKmh < currentBand.minSpeedKmh - SCALE_BAND_HYSTERESIS_KMH) { |
| | | nextBandIndex -= 1; |
| | | continue; |
| | | } |
| | | break; |
| | | } |
| | | return nextBandIndex; |
| | | }; |
| | | |
| | | /** |
| | | * 对最近几帧速度做均值平滑,降低单帧抖动对缩放的影响。 |
| | | */ |
| | | const resolveSmoothedSpeed = (samples: number[], nextSpeedKmh: number) => { |
| | | const nextSamples = [...samples, Math.max(0, nextSpeedKmh)].slice(-SPEED_SMOOTHING_SAMPLE_SIZE); |
| | | const total = nextSamples.reduce((sum, item) => sum + item, 0); |
| | | return { |
| | | nextSamples, |
| | | smoothedSpeedKmh: total / nextSamples.length |
| | | }; |
| | | }; |
| | | |
| | | /** |
| | | * 将目标比例尺限制为渐进变化,避免视角一步跳太远。 |
| | | */ |
| | | const resolveNextScale = (currentScale: number, targetScale: number) => { |
| | | const scaleDelta = targetScale - currentScale; |
| | | if (Math.abs(scaleDelta) <= MAX_SCALE_STEP_PER_TICK) { |
| | | return targetScale; |
| | | } |
| | | return currentScale + Math.sign(scaleDelta) * MAX_SCALE_STEP_PER_TICK; |
| | | }; |
| | | |
| | | /** |
| | | * 将速度换算成目标比例尺,速度越高视角越远。 |
| | | */ |
| | | const resolveScaleBySpeed = (speedKmh: number, previousBandIndex: number | null) => { |
| | | const nextBandIndex = resolveStableScaleBandIndex(speedKmh, previousBandIndex); |
| | | return { |
| | | targetScale: DYNAMIC_SCALE_BANDS[nextBandIndex].scale, |
| | | bandIndex: nextBandIndex |
| | | }; |
| | | }; |
| | | |
| | | /** |
| | | * 接驾中/行程中动态缩放:由速度驱动比例尺,并保留 ORDER_UPDATE_LINE 的一次性全路线入屏。 |
| | | * 接驾中/行程中动态缩放入口:速度缩放单独交给专用 Hook,这里仅保留视口编排职责。 |
| | | */ |
| | | export default function useActiveTripScale(deps: Deps) { |
| | | const [resumeVersion, setResumeVersion] = useState(0); // 冷却结束后触发重新评估 |
| | | const resumeTimerRef = useRef<any>(null); |
| | | const speedSamplesRef = useRef<number[]>([]); |
| | | const scaleBandIndexRef = useRef<number | null>(null); |
| | | const isActiveStatus = useMemo(() => { |
| | | return ACTIVE_TRIP_STATUSES.includes(deps.tripStatus); |
| | | }, [deps.tripStatus]); |
| | | |
| | | /** |
| | | * 清理冷却恢复定时器。 |
| | | */ |
| | | const clearResumeTimer = () => { |
| | | if (!resumeTimerRef.current) { |
| | | return; |
| | | } |
| | | clearTimeout(resumeTimerRef.current); |
| | | resumeTimerRef.current = null; |
| | | }; |
| | | |
| | | /** |
| | | * 在冷却结束后触发一次动态缩放重算。 |
| | | */ |
| | | const scheduleResume = useCallback((delayMs: number) => { |
| | | clearResumeTimer(); |
| | | const safeDelayMs = Math.max(0, Math.ceil(delayMs)); |
| | | resumeTimerRef.current = setTimeout(() => { |
| | | setResumeVersion((value) => value + 1); |
| | | resumeTimerRef.current = null; |
| | | }, safeDelayMs); |
| | | }, []); |
| | | useVehicleSpeedScale({ |
| | | tripStatus: deps.tripStatus, |
| | | speedMps: deps.speedMps, |
| | | mapScale: deps.mapScale, |
| | | setMapScale: deps.setMapScale, |
| | | syncActiveTripViewportToVehicle: deps.syncActiveTripViewportToVehicle, |
| | | getDynamicScaleHoldUntil: deps.getDynamicScaleHoldUntil |
| | | }); |
| | | |
| | | /** |
| | | * 处理地图手势,手动缩放/拖拽后短暂暂停速度接管。 |
| | |
| | | return; |
| | | } |
| | | deps.holdDynamicScale(USER_GESTURE_HOLD_MS); |
| | | scheduleResume(USER_GESTURE_HOLD_MS + SCALE_RESUME_BUFFER_MS); |
| | | }, [deps.holdDynamicScale, scheduleResume]); |
| | | }, [deps.holdDynamicScale]); |
| | | |
| | | /** |
| | | * ORDER_UPDATE_LINE 渲染完成后,自动执行一次全路线入屏。 |
| | |
| | | }); |
| | | }, 0); |
| | | }); |
| | | scheduleResume(ORDER_UPDATE_LINE_HOLD_MS + SCALE_RESUME_BUFFER_MS); |
| | | }, [deps.computeBottomPadding, deps.holdDynamicScale, deps.resolveSafeTopInset, isActiveStatus, scheduleResume]); |
| | | |
| | | useEffect(() => { |
| | | if (!isActiveStatus) { |
| | | clearResumeTimer(); |
| | | speedSamplesRef.current = []; |
| | | scaleBandIndexRef.current = null; |
| | | return; |
| | | } |
| | | const speedKmh = Number(deps.speedKmh); |
| | | if (!Number.isFinite(speedKmh)) { |
| | | return; |
| | | } |
| | | const holdUntil = Number(deps.getDynamicScaleHoldUntil()); |
| | | const now = Date.now(); |
| | | if (Number.isFinite(holdUntil) && holdUntil > now) { |
| | | scheduleResume(holdUntil - now + SCALE_RESUME_BUFFER_MS); |
| | | return; |
| | | } |
| | | const { nextSamples, smoothedSpeedKmh } = resolveSmoothedSpeed(speedSamplesRef.current, speedKmh); |
| | | speedSamplesRef.current = nextSamples; |
| | | const { targetScale, bandIndex } = resolveScaleBySpeed(smoothedSpeedKmh, scaleBandIndexRef.current); |
| | | scaleBandIndexRef.current = bandIndex; |
| | | const nextScale = resolveNextScale(deps.mapScale, targetScale); |
| | | if (Math.abs(nextScale - deps.mapScale) < MIN_SCALE_CHANGE) { |
| | | return; |
| | | } |
| | | deps.setMapScale(clamp(nextScale, 14, 18)); |
| | | deps.syncActiveTripViewportToVehicle(); |
| | | }, [deps.getDynamicScaleHoldUntil, deps.mapScale, deps.setMapScale, deps.speedKmh, deps.syncActiveTripViewportToVehicle, isActiveStatus, resumeVersion, scheduleResume]); |
| | | |
| | | useEffect(() => { |
| | | return () => { |
| | | clearResumeTimer(); |
| | | }; |
| | | }, []); |
| | | }, [deps.computeBottomPadding, deps.holdDynamicScale, deps.resolveSafeTopInset, isActiveStatus]); |
| | | |
| | | return { |
| | | handleRegionChange, |
| | |
| | | const SYSTEM_OFF_ERROR_RE = /(not available|10001|未开启|开关未打开|bluetooth adapter is not available)/i; |
| | | const ENV_UNSUPPORTED_ERROR_RE = /(暂不支持|not support|devtools|Mac 以外)/i; |
| | | const PERMISSION_DENY_ERROR_RE = /deny|auth deny|authorize|permission|scope/i; |
| | | const BLUETOOTH_ACCESS_LOG_PREFIX = '[蓝牙权限]'; |
| | | const BLUETOOTH_AUTH_SYNC_DELAY_MS = 300; // 授权完成后首轮刷新延迟,给小程序权限态一点同步时间 |
| | | const BLUETOOTH_AUTH_PROTECTION_WINDOW_MS = 5000; // 授权成功后的保护窗口,避免瞬时 permission_deny 覆盖 UI |
| | | |
| | | /** |
| | | * 输出蓝牙权限链路日志,便于排查授权、系统蓝牙和环境问题。 |
| | | */ |
| | | function logBluetoothAccess(message: string, payload?: Record<string, unknown>) { |
| | | if (payload) { |
| | | console.log(BLUETOOTH_ACCESS_LOG_PREFIX, message, payload); |
| | | return; |
| | | } |
| | | console.log(BLUETOOTH_ACCESS_LOG_PREFIX, message); |
| | | } |
| | | |
| | | /** |
| | | * 统一分类蓝牙打开失败原因。 |
| | |
| | | async function openBluetoothAdapterSafe(): Promise<BluetoothOpenResult> { |
| | | try { |
| | | await Taro.openBluetoothAdapter(); |
| | | logBluetoothAccess('openBluetoothAdapter 成功'); |
| | | return { opened: true, errorType: 'unknown', error: null }; |
| | | } catch (error: any) { |
| | | const errorType = classifyBluetoothError(error); |
| | | logBluetoothAccess('openBluetoothAdapter 失败', { |
| | | errorType, |
| | | errCode: error?.errCode, |
| | | errMsg: error?.errMsg |
| | | }); |
| | | return { |
| | | opened: false, |
| | | errorType: classifyBluetoothError(error), |
| | | errorType, |
| | | error |
| | | }; |
| | | } |
| | |
| | | const openSettingPollingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null); // 去开启后的短轮询定时器 |
| | | const openSettingPollingCountRef = useRef(0); // 去开启后的短轮询计数 |
| | | const startBluetoothAuthRef = useRef(startBluetoothAuth); // 蓝牙鉴权动作引用,避免函数引用变化导致状态 effect 反复触发 |
| | | const recentAuthorizedAtRef = useRef(0); // 最近一次确认蓝牙授权成功的时间戳,用于保护首次授权后的 UI 状态 |
| | | const stopOpenSettingPollingRef = useRef<() => void>(() => {}); |
| | | const refreshBluetoothStateRef = useRef<() => Promise<void>>(async () => {}); |
| | | const startOpenSettingPollingRef = useRef<() => void>(() => {}); |
| | |
| | | try { |
| | | const setting = await Taro.getSetting(); |
| | | const authSetting = setting?.authSetting || {}; |
| | | logBluetoothAccess('读取小程序权限设置', { |
| | | bluetoothScope: authSetting?.[BLUETOOTH_SCOPE], |
| | | authSetting |
| | | }); |
| | | if (Object.prototype.hasOwnProperty.call(authSetting, BLUETOOTH_SCOPE)) { |
| | | return authSetting[BLUETOOTH_SCOPE] === true ? 'authorized' : 'denied'; |
| | | } |
| | | } catch (error) { |
| | | logBluetoothAccess('读取小程序权限设置失败', { |
| | | error |
| | | }); |
| | | } |
| | | logBluetoothAccess('小程序蓝牙权限状态未知'); |
| | | return 'unknown'; |
| | | }, []); |
| | | |
| | |
| | | * 已确认拿到小程序蓝牙权限后,统一收口后续初始化与兜底提示。 |
| | | */ |
| | | const finishBluetoothAuthorization = useCallback(async () => { |
| | | logBluetoothAccess('进入授权完成收口流程', { |
| | | tripStatus |
| | | }); |
| | | recentAuthorizedAtRef.current = Date.now(); |
| | | Taro.setStorageSync(BLUETOOTH_AUTH_KEY, true); |
| | | setShowAuthBluetoothPopup(false); |
| | | setStatus('authorized'); |
| | | setStatusReady(true); |
| | | startBluetoothAuth(); |
| | | startOpenSettingPollingRef.current(); |
| | | |
| | | const openResult = await openBluetoothAdapterSafe(); |
| | | logBluetoothAccess('授权完成后再次初始化蓝牙适配器', { |
| | | opened: openResult.opened, |
| | | errorType: openResult.errorType, |
| | | errCode: openResult.error?.errCode, |
| | | errMsg: openResult.error?.errMsg |
| | | }); |
| | | if (openResult.opened) { |
| | | await refreshBluetoothState(); |
| | | setTimeout(() => { |
| | | refreshBluetoothStateRef.current(); |
| | | }, BLUETOOTH_AUTH_SYNC_DELAY_MS); |
| | | return; |
| | | } |
| | | |
| | | if (openResult.errorType === 'permission_deny') { |
| | | setStatus('unauthorized'); |
| | | try { |
| | | await Taro.openSetting(); |
| | | } catch (openSettingError) { |
| | | console.error('打开设置失败:', openSettingError); |
| | | } |
| | | Taro.showToast({ title: '请先授权小程序蓝牙权限', icon: 'none' }); |
| | | logBluetoothAccess('授权成功后权限态仍在同步,保护窗口内维持已授权态', { |
| | | protectionWindowMs: BLUETOOTH_AUTH_PROTECTION_WINDOW_MS |
| | | }); |
| | | return; |
| | | } |
| | | |
| | | if (openResult.errorType === 'system_off') { |
| | | recentAuthorizedAtRef.current = 0; |
| | | stopOpenSettingPollingRef.current(); |
| | | setStatus('system_off'); |
| | | Taro.showToast({ title: '请先开启系统蓝牙', icon: 'none' }); |
| | | return; |
| | | } |
| | | |
| | | Taro.showToast({ title: '蓝牙初始化失败,请稍后重试', icon: 'none' }); |
| | | await refreshBluetoothState(); |
| | | }, [refreshBluetoothState, startBluetoothAuth]); |
| | | setTimeout(() => { |
| | | refreshBluetoothStateRef.current(); |
| | | }, BLUETOOTH_AUTH_SYNC_DELAY_MS); |
| | | }, [startBluetoothAuth, tripStatus]); |
| | | |
| | | useEffect(() => { |
| | | startBluetoothAuthRef.current = startBluetoothAuth; |
| | |
| | | */ |
| | | const refreshBluetoothStateImpl = async () => { |
| | | let hasMiniappAuth = Boolean(Taro.getStorageSync(BLUETOOTH_AUTH_KEY)); |
| | | const isAuthorizeProtectionActive = (Date.now() - recentAuthorizedAtRef.current) < BLUETOOTH_AUTH_PROTECTION_WINDOW_MS; |
| | | logBluetoothAccess('开始刷新蓝牙状态', { |
| | | cachedMiniappAuth: hasMiniappAuth, |
| | | tripStatus, |
| | | isAuthorizeProtectionActive |
| | | }); |
| | | try { |
| | | const setting = await Taro.getSetting(); |
| | | logBluetoothAccess('刷新蓝牙状态时读取设置', { |
| | | bluetoothScope: setting?.authSetting?.['scope.bluetooth'], |
| | | authSetting: setting?.authSetting || {} |
| | | }); |
| | | if (setting?.authSetting?.['scope.bluetooth']) { |
| | | hasMiniappAuth = true; |
| | | recentAuthorizedAtRef.current = 0; |
| | | } |
| | | } catch (error) { |
| | | logBluetoothAccess('刷新蓝牙状态时读取设置失败', { |
| | | error |
| | | }); |
| | | } |
| | | |
| | | let systemBluetoothEnabled = false; |
| | |
| | | systemBluetoothEnabled = true; |
| | | } else if (openResult.errorType === 'system_off') { |
| | | systemBluetoothEnabled = false; |
| | | recentAuthorizedAtRef.current = 0; |
| | | } else if (openResult.errorType === 'permission_deny') { |
| | | systemBluetoothEnabled = true; |
| | | hasMiniappAuth = false; |
| | | if (!isAuthorizeProtectionActive) { |
| | | hasMiniappAuth = false; |
| | | } else { |
| | | logBluetoothAccess('授权保护窗口内忽略瞬时 permission_deny', { |
| | | errCode: openResult.error?.errCode, |
| | | errMsg: openResult.error?.errMsg |
| | | }); |
| | | } |
| | | } else { |
| | | // 开发工具限制和未知异常按“系统已开启”兜底,避免误判成“去开启”。 |
| | | systemBluetoothEnabled = true; |
| | | } |
| | | logBluetoothAccess('蓝牙状态刷新结果', { |
| | | systemBluetoothEnabled, |
| | | hasMiniappAuth, |
| | | opened: openResult.opened, |
| | | errorType: openResult.errorType, |
| | | errCode: openResult.error?.errCode, |
| | | errMsg: openResult.error?.errMsg |
| | | }); |
| | | |
| | | if (!systemBluetoothEnabled) { |
| | | setStatus('system_off'); |
| | | setStatusReady(true); |
| | | logBluetoothAccess('蓝牙状态判定完成', { |
| | | nextStatus: 'system_off' |
| | | }); |
| | | return; |
| | | } |
| | | const nextStatus = hasMiniappAuth ? 'authorized' : 'unauthorized'; |
| | | setStatus(nextStatus); |
| | | setStatusReady(true); |
| | | stopOpenSettingPollingRef.current(); |
| | | if (nextStatus !== 'authorized') { |
| | | recentAuthorizedAtRef.current = 0; |
| | | } |
| | | logBluetoothAccess('蓝牙状态判定完成', { |
| | | nextStatus |
| | | }); |
| | | if (nextStatus === 'authorized') { |
| | | startBluetoothAuthRef.current?.(); |
| | | } |
| | |
| | | const startOpenSettingPollingImpl = () => { |
| | | stopOpenSettingPollingRef.current(); |
| | | openSettingPollingCountRef.current = 0; |
| | | logBluetoothAccess('启动设置页返回后的短轮询'); |
| | | openSettingPollingTimerRef.current = setInterval(() => { |
| | | openSettingPollingCountRef.current += 1; |
| | | logBluetoothAccess('轮询刷新蓝牙状态', { |
| | | pollingCount: openSettingPollingCountRef.current |
| | | }); |
| | | refreshBluetoothStateRef.current(); |
| | | if (openSettingPollingCountRef.current >= 10) { |
| | | stopOpenSettingPollingRef.current(); |
| | |
| | | const startBluetoothStateWatchImpl = () => { |
| | | if (adapterStateHandlerRef.current) return; |
| | | const onBluetoothStateChange = (res: any) => { |
| | | logBluetoothAccess('收到蓝牙适配器状态变更', { |
| | | available: res?.available, |
| | | discovering: res?.discovering |
| | | }); |
| | | if (res?.available === false) { |
| | | setStatus('system_off'); |
| | | return; |
| | |
| | | // 仅在 4/5 状态维护蓝牙 UI;其他状态强制收起弹窗 |
| | | useEffect(() => { |
| | | if ([TripStatus.VehicleApproaching, TripStatus.VehicleArrivedPickup].includes(tripStatus)) { |
| | | logBluetoothAccess('进入蓝牙 UI 维护态', { |
| | | tripStatus |
| | | }); |
| | | setStatusReady(false); |
| | | refreshBluetoothState(); |
| | | startBluetoothStateWatchRef.current(); |
| | |
| | | stopBluetoothStateWatchRef.current(); |
| | | }; |
| | | } |
| | | logBluetoothAccess('离开蓝牙 UI 维护态,重置弹窗与监听', { |
| | | tripStatus |
| | | }); |
| | | setStatusReady(false); |
| | | stopBluetoothStateWatchRef.current(); |
| | | stopOpenSettingPollingRef.current(); |
| | |
| | | |
| | | // 跳转系统蓝牙设置,并在返回后触发一次状态刷新 |
| | | const handleOpenSystemBluetoothSetting = useCallback(async () => { |
| | | logBluetoothAccess('用户点击去开启系统蓝牙'); |
| | | try { |
| | | const openSystemBluetoothSetting = (Taro as any).openSystemBluetoothSetting; |
| | | if (typeof openSystemBluetoothSetting === 'function') { |
| | |
| | | await Taro.openSetting(); |
| | | } |
| | | } catch (error) { |
| | | logBluetoothAccess('打开系统蓝牙设置失败', { |
| | | error |
| | | }); |
| | | Taro.showToast({ title: '请手动开启系统蓝牙', icon: 'none' }); |
| | | } finally { |
| | | logBluetoothAccess('系统蓝牙设置流程结束,准备刷新状态'); |
| | | startOpenSettingPollingRef.current(); |
| | | setTimeout(() => { |
| | | refreshBluetoothState(); |
| | |
| | | // 执行小程序蓝牙授权,成功后展示成功弹窗 |
| | | const handleAuthorizeBluetooth = useCallback(async () => { |
| | | const miniappBluetoothAuthState = await getMiniappBluetoothAuthState(); |
| | | logBluetoothAccess('用户触发蓝牙授权', { |
| | | miniappBluetoothAuthState |
| | | }); |
| | | if (miniappBluetoothAuthState === 'denied') { |
| | | try { |
| | | await Taro.openSetting(); |
| | | } catch (openSettingError) { |
| | | console.error('打开设置失败:', openSettingError); |
| | | logBluetoothAccess('蓝牙权限已拒绝,打开设置失败', { |
| | | openSettingError |
| | | }); |
| | | Taro.showToast({ title: '请先授权小程序蓝牙权限', icon: 'none' }); |
| | | return; |
| | | } |
| | | const latestAuthState = await getMiniappBluetoothAuthState(); |
| | | logBluetoothAccess('从设置页返回后重新读取蓝牙权限', { |
| | | latestAuthState |
| | | }); |
| | | if (latestAuthState !== 'authorized') { |
| | | Taro.showToast({ title: '请先授权小程序蓝牙权限', icon: 'none' }); |
| | | return; |
| | |
| | | |
| | | try { |
| | | await Taro.authorize({ scope: BLUETOOTH_SCOPE }); |
| | | logBluetoothAccess('调用 authorize(scope.bluetooth) 成功'); |
| | | } catch (authorizeError) { |
| | | console.log('授权蓝牙失败:', authorizeError); |
| | | logBluetoothAccess('调用 authorize(scope.bluetooth) 失败', { |
| | | authorizeError |
| | | }); |
| | | try { |
| | | await Taro.openSetting(); |
| | | } catch (openSettingError) { |
| | | console.log('打开设置失败:', openSettingError); |
| | | logBluetoothAccess('authorize 失败后打开设置失败', { |
| | | openSettingError |
| | | }); |
| | | Taro.showToast({ title: '请先授权小程序蓝牙权限', icon: 'none' }); |
| | | return; |
| | | } |
| | | const latestAuthState = await getMiniappBluetoothAuthState(); |
| | | logBluetoothAccess('authorize 失败后从设置页返回,重新读取蓝牙权限', { |
| | | latestAuthState |
| | | }); |
| | | if (latestAuthState !== 'authorized') { |
| | | Taro.showToast({ title: '请先授权小程序蓝牙权限', icon: 'none' }); |
| | | return; |
| | |
| | | |
| | | // 蓝牙卡片点击分发:未开蓝牙->去开启,未授权->弹授权,已授权->弹成功态说明 |
| | | const handleBluetoothCardClick = useCallback(() => { |
| | | logBluetoothAccess('点击蓝牙卡片', { |
| | | status, |
| | | tripStatus |
| | | }); |
| | | if (status === 'system_off') { |
| | | handleOpenSystemBluetoothSetting(); |
| | | return; |
| | |
| | | import { TripStatus } from '../constants/trip-status'; |
| | | |
| | | const CHANGE_SITE_UP_ICON_URL = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/change-site-up.png'; // 行程中修改目的地时的上车点图标 |
| | | const DYNAMIC_MARKER_POSITION_EPSILON = 0.000001; // 动态 marker 位置变化阈值,避免无意义重复刷新 |
| | | |
| | | /** |
| | | * 生成 marker 签名,供平移态判断结构是否实际变化。 |
| | | */ |
| | |
| | | shouldIgnoreDynamicCarPosition ? 'translate' : Number(marker.latitude).toFixed(6), |
| | | shouldIgnoreDynamicCarPosition ? 'translate' : Number(marker.longitude).toFixed(6), |
| | | marker.name || '', |
| | | shouldIgnoreDynamicCarPosition ? 'translate' : (typeof marker.rotate === 'number' ? marker.rotate : ''), |
| | | typeof marker.rotate === 'number' ? marker.rotate : '', |
| | | marker?.customCallout?.anchorY ?? '', |
| | | ].join('|'); |
| | | }) |
| | |
| | | // 应用 marker 状态并处理“行驶中”阶段的刷新标记 |
| | | const applyMarkerState = (nextMarkers: any[], nextMarkersSignature: string, shouldSkipUpdate: boolean, carMarker: any) => { |
| | | if (shouldSkipUpdate) { |
| | | // 平移态下保留已创建车辆 marker 的当前位置和角度,只同步其他 marker 结构变化 |
| | | // 平移态下车辆位置完全交给 translateMarker 托管,React 仅同步非车辆 marker 的结构变化 |
| | | setMarkers((prevMarkers: any[]) => { |
| | | const prevMarkersSignature = buildMarkersSignature(prevMarkers, deps.vehicleMarkerId, shouldUsePureTranslateCarMarker); |
| | | const targetCarIndex = prevMarkers.findIndex((item: any) => item.id === deps.vehicleMarkerId); |
| | |
| | | return prevMarkers; |
| | | } |
| | | const prevCar = prevMarkers[targetCarIndex] || {}; |
| | | const nextCar = nextMarkers[nextCarIndex] || {}; |
| | | const shouldRefreshCarPosition = ( |
| | | Math.abs(Number(prevCar.latitude) - Number(nextCar.latitude)) > DYNAMIC_MARKER_POSITION_EPSILON |
| | | || Math.abs(Number(prevCar.longitude) - Number(nextCar.longitude)) > DYNAMIC_MARKER_POSITION_EPSILON |
| | | || ( |
| | | typeof nextCar.rotate === 'number' |
| | | && Number(nextCar.rotate) !== Number(prevCar.rotate) |
| | | ) |
| | | ); |
| | | if (prevMarkersSignature === nextMarkersSignature && !shouldRefreshCarPosition) { |
| | | if (prevMarkersSignature === nextMarkersSignature) { |
| | | return prevMarkers; |
| | | } |
| | | markersSignatureRef.current = nextMarkersSignature; |
| | | const mergedMarkers = [...nextMarkers]; |
| | | mergedMarkers[nextCarIndex] = { |
| | | ...mergedMarkers[nextCarIndex], |
| | | latitude: shouldRefreshCarPosition ? mergedMarkers[nextCarIndex].latitude : prevCar.latitude, |
| | | longitude: shouldRefreshCarPosition ? mergedMarkers[nextCarIndex].longitude : prevCar.longitude, |
| | | // 平移态优先使用最新车辆朝向,动画丢帧时仍由 React 坐标兜底刷新当前位置。 |
| | | latitude: prevCar.latitude, |
| | | longitude: prevCar.longitude, |
| | | rotate: typeof mergedMarkers[nextCarIndex]?.rotate === 'number' |
| | | ? mergedMarkers[nextCarIndex].rotate |
| | | : prevCar.rotate |
| | |
| | | effectiveEndSite: any; |
| | | startSite: any; |
| | | vehicleLocation: any; |
| | | pageDidShowVersion: number; |
| | | requestRouteAndFare: ( |
| | | originLat: number, |
| | | originLng: number, |
| | |
| | | } |
| | | |
| | | const startPointId = deps.startSite?.id ?? cachedStartSite?.id ?? ''; |
| | | if (startPointId === '' || startPointId === null || startPointId === undefined) { |
| | | return null; |
| | | } |
| | | return { |
| | | originLat, |
| | | originLng, |
| | |
| | | { |
| | | startPointId: routeContext.startPointId, |
| | | force: true |
| | | } |
| | | }, |
| | | ); |
| | | }, [ |
| | | deps.tripStatus, |
| | | deps.isModifyDestinationReviewVisible, |
| | | deps.hasLocalDestinationOverride, |
| | | deps.pageDidShowVersion, |
| | | deps.effectiveEndSite?.id, |
| | | deps.effectiveEndSite?.latitude, |
| | | deps.effectiveEndSite?.longitude, |
| | |
| | | { |
| | | startPointId: routeContext.startPointId, |
| | | force: true |
| | | } |
| | | }, |
| | | ); |
| | | Taro.showToast({ |
| | | title: '报价已更新,请再次确认修改', |
| | |
| | | export const LOGIN_PAGE_URL = '/pages/user/login?hideBack=1'; |
| | | /** 自动返回首页的延迟时长。 */ |
| | | export const HOME_RELAUNCH_DELAY_MS = 1500; |
| | | /** 待确认取消上下文有效期,单位毫秒。 */ |
| | | export const PENDING_CANCEL_CONTEXT_TTL_MS = 15 * 1000; |
| | | |
| | | /** 待确认取消上下文来源。 */ |
| | | export type PendingCancelSource = 'pickup_timeout'; |
| | | |
| | | /** 待确认取消上下文。 */ |
| | | export type PendingCancelContext = { |
| | | /** 取消关联订单 ID。 */ |
| | | orderId: string; |
| | | /** 取消前页面真实行程状态。 */ |
| | | tripStatus: TripStatus; |
| | | /** 期望展示的取消弹窗类型。 */ |
| | | cancelType: number; |
| | | /** 取消来源。 */ |
| | | source: PendingCancelSource; |
| | | /** 上下文创建时间戳。 */ |
| | | createdAt: number; |
| | | }; |
| | | |
| | | /** 订单动作依赖。 */ |
| | | export type OrderActionsDeps = { |
| | |
| | | callSubmitting: boolean; |
| | | /** 取消类型。 */ |
| | | cancelType: number; |
| | | /** 待确认取消上下文。 */ |
| | | pendingCancelContextRef: React.MutableRefObject<PendingCancelContext | null>; |
| | | /** 设置行程状态。 */ |
| | | setTripStatus: (value: TripStatus) => void; |
| | | /** 设置订单 ID。 */ |
| | |
| | | handleConfirmCancel: cancelActions.handleConfirmCancel, |
| | | handleOpenCancelPopupWithType: cancelActions.handleOpenCancelPopupWithType, |
| | | handleOpenCancelPopupByTripStatus: cancelActions.handleOpenCancelPopupByTripStatus, |
| | | handlePickupTimeoutAutoCancel: cancelActions.handlePickupTimeoutAutoCancel, |
| | | handleConfirmOverRoute: paymentActions.handleConfirmOverRoute, |
| | | handleFinishCountdownTimeout: paymentActions.handleFinishCountdownTimeout, |
| | | handlePay: paymentActions.handlePay, |
| | |
| | | handleOpenCancelPopupWithType: (type: number) => void; |
| | | /** 根据状态打开取消弹窗。 */ |
| | | handleOpenCancelPopupByTripStatus: (status: TripStatus, displayInfo?: any) => void; |
| | | /** 上车倒计时超时后发起自动取消。 */ |
| | | handlePickupTimeoutAutoCancel: () => void; |
| | | /** 确认取消。 */ |
| | | handleConfirmCancel: () => Promise<void>; |
| | | }; |
| | |
| | | clearActiveOrderRuntime(); |
| | | deps.setCancelType(type); |
| | | restartPlanningRoute(); |
| | | }; |
| | | |
| | | /** |
| | | * 上车倒计时超时后发起自动取消,并记录取消前真实状态供 socket 确认时复用。 |
| | | */ |
| | | const handlePickupTimeoutAutoCancel = () => { |
| | | const targetOrderId = String(deps.orderId || Taro.getStorageSync('orderId') || ''); |
| | | deps.pendingCancelContextRef.current = { |
| | | orderId: targetOrderId, |
| | | tripStatus: deps.tripStatus, |
| | | cancelType: 4, |
| | | source: 'pickup_timeout', |
| | | createdAt: Date.now() |
| | | }; |
| | | handleOpenCancelPopupWithType(4); |
| | | }; |
| | | |
| | | /** |
| | |
| | | restartPlanningRoute, |
| | | handleOpenCancelPopupWithType, |
| | | handleOpenCancelPopupByTripStatus, |
| | | handlePickupTimeoutAutoCancel, |
| | | handleConfirmCancel |
| | | }; |
| | | } |
| | |
| | | import { getOrderActive, } from '../../../api/home'; |
| | | import { queryVehicleByOrderId } from '../../../api/begin-route'; |
| | | import { toTripStatus, TripStatus } from '../constants/trip-status'; |
| | | import { MODIFY_DESTINATION_SESSION_KEY } from '../constants/modify-destination'; |
| | | import { MODIFY_DESTINATION_CANDIDATE_KEY, MODIFY_DESTINATION_SESSION_KEY } from '../constants/modify-destination'; |
| | | import { readPassedRouteCache } from './passed-route-cache'; |
| | | |
| | | type RequestRouteOptions = { |
| | | startPointId?: string | number; |
| | | force?: boolean; |
| | | }; |
| | | |
| | | type Deps = { |
| | | wsHost: string; |
| | |
| | | originLng: number, |
| | | destLat: number, |
| | | destLng: number, |
| | | startPointId: string | number, |
| | | force?: boolean |
| | | options: RequestRouteOptions |
| | | ) => void; |
| | | routeRequestOrderRoute: (orderId: string, status: number) => void; |
| | | routeUpdateWalkingRouteForStart: (startSite: any) => void; |
| | | getCurrentTripStatus: () => TripStatus; |
| | | getCurrentLongitude: () => number | null; |
| | | getCurrentLatitude: () => number | null; |
| | | getCurrentVehicleLocation: () => { latitude: number; longitude: number } | null; |
| | | }; |
| | | |
| | | /** |
| | | * 判断当前是否处于可恢复的修改目的地预览态。 |
| | | */ |
| | | const isPreviewRouteRestoreActive = (cachedOrderId: string, cachedTripStatus: TripStatus) => { |
| | | const modifyDestinationSession = Taro.getStorageSync(MODIFY_DESTINATION_SESSION_KEY); |
| | | const modifyDestinationCandidate = Taro.getStorageSync(MODIFY_DESTINATION_CANDIDATE_KEY); |
| | | const isPreviewSessionActive = !!modifyDestinationSession?.active; |
| | | const isSessionOrderMatched = !modifyDestinationSession?.orderId |
| | | || !cachedOrderId |
| | | || String(modifyDestinationSession.orderId) === String(cachedOrderId); |
| | | const hasValidCandidate = Number.isFinite(Number(modifyDestinationCandidate?.latitude)) |
| | | && Number.isFinite(Number(modifyDestinationCandidate?.longitude)); |
| | | if (!isPreviewSessionActive || !isSessionOrderMatched || !hasValidCandidate) { |
| | | return false; |
| | | } |
| | | return [TripStatus.VehicleApproaching, TripStatus.VehicleArrivedPickup, TripStatus.InService].includes(cachedTripStatus); |
| | | }; |
| | | |
| | | /** |
| | |
| | | const originLng = s?.longitude || deps.getCurrentLongitude(); |
| | | const originLat = s?.latitude || deps.getCurrentLatitude(); |
| | | if (cachedTripStatus === TripStatus.PublishRoute && originLng && originLat && e?.longitude && e?.latitude) { |
| | | deps.routeRequestRouteAndFare(originLat, originLng, e.latitude, e.longitude, s?.id, true); |
| | | deps.routeRequestRouteAndFare(originLat, originLng, e.latitude, e.longitude, { |
| | | startPointId: s?.id, |
| | | force: true |
| | | }); |
| | | } |
| | | if (deps.getCurrentTripStatus() === TripStatus.PublishRoute && s?.latitude && s?.longitude) { |
| | | deps.routeUpdateWalkingRouteForStart(s); |
| | |
| | | const restorePageStateFromStorage = () => { |
| | | const s = Taro.getStorageSync('startSite'); |
| | | const e = Taro.getStorageSync('endSite'); |
| | | const cachedOrderId = Taro.getStorageSync('orderId'); |
| | | const cachedOrderId = String(Taro.getStorageSync('orderId') || ''); |
| | | const cachedTripStatusStorage = Taro.getStorageSync('tripStatus'); |
| | | const cachedTripStatus = toTripStatus(cachedTripStatusStorage, deps.getCurrentTripStatus()); |
| | | deps.setTripStatus(cachedTripStatus); |
| | |
| | | } |
| | | syncCachedOrderVehicle(cachedOrderId); |
| | | syncSitesFromActiveOrder(cachedTripStatusStorage, s, e); |
| | | if (cachedOrderId && [TripStatus.VehicleApproaching, TripStatus.VehicleArrivedPickup, TripStatus.InService, TripStatus.ArrivedDestination, TripStatus.TripFinished, TripStatus.PaymentSuccess].includes(cachedTripStatus)) { |
| | | const shouldRestorePreviewRoute = isPreviewRouteRestoreActive(cachedOrderId, cachedTripStatus); |
| | | if (!shouldRestorePreviewRoute && cachedOrderId && [TripStatus.VehicleApproaching, TripStatus.VehicleArrivedPickup, TripStatus.InService, TripStatus.ArrivedDestination, TripStatus.TripFinished, TripStatus.PaymentSuccess].includes(cachedTripStatus)) { |
| | | deps.routeRequestOrderRoute(cachedOrderId, cachedTripStatus); |
| | | } |
| | | syncLocationAndInitRoute(s, e, cachedTripStatus); |
| | |
| | | distanceMeters: number | null; |
| | | /** 时长,单位秒。 */ |
| | | durationSeconds: number | null; |
| | | /** 速度,单位 km/h。 */ |
| | | speedKmh: number | null; |
| | | /** 速度,单位 m/s。 */ |
| | | speedMps: number | null; |
| | | }) => void; |
| | | /** 设置当前用户定位。 */ |
| | | setCurrentUserLocation?: (location: CurrentUserLocation) => void; |
| | |
| | | import { ensureSelectedCityByLocation } from '../../../utils/city'; |
| | | import { normalizeDistanceKm, normalizeDurationSeconds } from '../../../utils/format'; |
| | | import { TripStatus } from '../constants/trip-status'; |
| | | import { normalizeSpeedKmh, type RouteSnapshot } from './runtime-sync'; |
| | | import { normalizeSpeedMps, type RouteSnapshot } from './runtime-sync'; |
| | | import { |
| | | Deps, |
| | | RequestRouteAndFareOptions, |
| | |
| | | distance: normalizeDistanceKm(Number(response?.distance)), |
| | | duration: normalizeDurationSeconds(Number(response?.duration)), |
| | | direction, |
| | | speedKmh: normalizeSpeedKmh(response?.speed), |
| | | speedMps: normalizeSpeedMps(response?.speed), |
| | | timestamp: Date.now(), |
| | | vehicleMoveAlongPoints: [], |
| | | forceFullRedraw: true, |
| | |
| | | response?.endLongitude, |
| | | response?.endLatitude ?? response?.endlatitude |
| | | ); |
| | | if (beforeUserSite) { |
| | | deps.setBeforeUserEndSite?.(beforeUserSite); |
| | | } |
| | | deps.setBeforeUserEndSite?.(beforeUserSite); |
| | | |
| | | const rawPoints = Array.isArray(response?.polyline) ? response.polyline : []; |
| | | const points = rawPoints |
| | |
| | | deps.setOrderRouteMetrics?.({ |
| | | distanceMeters: Number.isFinite(Number(response?.distance)) ? Number(response?.distance) : null, |
| | | durationSeconds: Number.isFinite(Number(response?.duration)) ? Number(response?.duration) : null, |
| | | speedKmh: normalizeSpeedKmh(response?.speed) |
| | | speedMps: normalizeSpeedMps(response?.speed) |
| | | }); |
| | | |
| | | const normalizedDirection = resolveDirectionValue(response); |
| New file |
| | |
| | | import { useEffect, useRef } from 'react'; |
| | | import { TripStatus } from '../constants/trip-status'; |
| | | |
| | | type SpeedScaleBandKey = 'low' | 'medium' | 'high'; |
| | | |
| | | type SpeedScaleBand = { |
| | | /** 档位标识。 */ |
| | | key: SpeedScaleBandKey; |
| | | /** 档位最小速度,单位 m/s。 */ |
| | | minSpeedMps: number; |
| | | /** 档位最大速度,单位 m/s。 */ |
| | | maxSpeedMps: number; |
| | | /** 档位对应比例尺。 */ |
| | | scale: number; |
| | | }; |
| | | |
| | | type Deps = { |
| | | /** 当前行程状态。 */ |
| | | tripStatus: TripStatus; |
| | | /** 当前速度,单位 m/s。 */ |
| | | speedMps: number | null; |
| | | /** 当前地图比例尺。 */ |
| | | mapScale: number; |
| | | /** 设置地图比例尺。 */ |
| | | setMapScale: (value: number) => void; |
| | | /** 动态缩放生效后同步车辆视口中心。 */ |
| | | syncActiveTripViewportToVehicle: () => void; |
| | | /** 读取动态缩放暂停截止时间。 */ |
| | | getDynamicScaleHoldUntil: () => number; |
| | | }; |
| | | |
| | | const ACTIVE_TRIP_STATUSES = [TripStatus.VehicleApproaching, TripStatus.InService]; // 允许速度缩放的行程状态 |
| | | const SCALE_MIN = 15; // 允许的最小比例尺 |
| | | const SCALE_MAX = 18; // 允许的最大比例尺 |
| | | const SCALE_APPLY_THRESHOLD = 0.25; // 触发 setMapScale 的最小变化阈值 |
| | | const SPEED_SCALE_STABLE_DURATION_MS = 10000; // 新档位需要持续命中 10 秒才执行 |
| | | const HOLD_RESUME_BUFFER_MS = 80; // 冷却结束后的恢复缓冲 |
| | | |
| | | const SPEED_SCALE_BANDS: SpeedScaleBand[] = [ |
| | | { key: 'low', minSpeedMps: 0, maxSpeedMps: 5, scale: 18 }, |
| | | { key: 'medium', minSpeedMps: 5, maxSpeedMps: 10, scale: 16.5 }, |
| | | { key: 'high', minSpeedMps: 10, maxSpeedMps: Number.POSITIVE_INFINITY, scale: 15 } |
| | | ]; // 低速更精细,中速平衡,高速拉远 |
| | | |
| | | /** |
| | | * 将比例尺限制在安全区间内。 |
| | | */ |
| | | const clamp = (value: number, min: number, max: number) => { |
| | | if (value < min) { |
| | | return min; |
| | | } |
| | | if (value > max) { |
| | | return max; |
| | | } |
| | | return value; |
| | | }; |
| | | |
| | | /** |
| | | * 根据速度命中对应档位。 |
| | | */ |
| | | const resolveScaleBandBySpeed = (speedMps: number) => { |
| | | const safeSpeedMps = Math.max(0, Number(speedMps) || 0); |
| | | return SPEED_SCALE_BANDS.find((band) => ( |
| | | safeSpeedMps >= band.minSpeedMps && safeSpeedMps < band.maxSpeedMps |
| | | )) || SPEED_SCALE_BANDS[SPEED_SCALE_BANDS.length - 1]; |
| | | }; |
| | | |
| | | /** |
| | | * 根据档位标识获取档位配置。 |
| | | */ |
| | | const getScaleBandByKey = (key: SpeedScaleBandKey | null) => { |
| | | if (!key) { |
| | | return null; |
| | | } |
| | | return SPEED_SCALE_BANDS.find((band) => band.key === key) || null; |
| | | }; |
| | | |
| | | /** |
| | | * 车辆速度驱动缩放:按 m/s 三段划分,并要求候选档位持续 10 秒才切换。 |
| | | */ |
| | | export default function useVehicleSpeedScale(deps: Deps) { |
| | | const applyTimerRef = useRef<any>(null); |
| | | const latestSpeedMpsRef = useRef<number | null>(deps.speedMps); |
| | | const latestMapScaleRef = useRef(deps.mapScale); |
| | | const appliedBandKeyRef = useRef<SpeedScaleBandKey | null>(null); |
| | | const pendingBandKeyRef = useRef<SpeedScaleBandKey | null>(null); |
| | | const pendingBandStartedAtRef = useRef(0); |
| | | |
| | | /** |
| | | * 清理候选档位应用定时器。 |
| | | */ |
| | | const clearApplyTimer = () => { |
| | | if (!applyTimerRef.current) { |
| | | return; |
| | | } |
| | | clearTimeout(applyTimerRef.current); |
| | | applyTimerRef.current = null; |
| | | }; |
| | | |
| | | /** |
| | | * 清空当前候选档位。 |
| | | */ |
| | | const clearPendingBand = () => { |
| | | pendingBandKeyRef.current = null; |
| | | pendingBandStartedAtRef.current = 0; |
| | | clearApplyTimer(); |
| | | }; |
| | | |
| | | /** |
| | | * 安排下一次候选档位检查。 |
| | | */ |
| | | const schedulePendingApply = (delayMs: number) => { |
| | | clearApplyTimer(); |
| | | applyTimerRef.current = setTimeout(() => { |
| | | const latestSpeedMps = Number(latestSpeedMpsRef.current); |
| | | const pendingBandKey = pendingBandKeyRef.current; |
| | | const appliedBandKey = appliedBandKeyRef.current; |
| | | if (!Number.isFinite(latestSpeedMps) || !pendingBandKey) { |
| | | clearPendingBand(); |
| | | return; |
| | | } |
| | | const holdUntil = Number(deps.getDynamicScaleHoldUntil()); |
| | | const now = Date.now(); |
| | | if (Number.isFinite(holdUntil) && holdUntil > now) { |
| | | schedulePendingApply(holdUntil - now + HOLD_RESUME_BUFFER_MS); |
| | | return; |
| | | } |
| | | const nextBand = resolveScaleBandBySpeed(latestSpeedMps); |
| | | if (nextBand.key !== pendingBandKey) { |
| | | clearPendingBand(); |
| | | return; |
| | | } |
| | | if (nextBand.key === appliedBandKey) { |
| | | clearPendingBand(); |
| | | return; |
| | | } |
| | | const stableDurationMs = now - Number(pendingBandStartedAtRef.current || 0); |
| | | if (stableDurationMs < SPEED_SCALE_STABLE_DURATION_MS) { |
| | | schedulePendingApply(SPEED_SCALE_STABLE_DURATION_MS - stableDurationMs); |
| | | return; |
| | | } |
| | | appliedBandKeyRef.current = nextBand.key; |
| | | clearPendingBand(); |
| | | if (Math.abs(nextBand.scale - latestMapScaleRef.current) < SCALE_APPLY_THRESHOLD) { |
| | | return; |
| | | } |
| | | deps.setMapScale(clamp(nextBand.scale, SCALE_MIN, SCALE_MAX)); |
| | | deps.syncActiveTripViewportToVehicle(); |
| | | }, Math.max(0, Math.ceil(delayMs))); |
| | | }; |
| | | |
| | | useEffect(() => { |
| | | latestSpeedMpsRef.current = deps.speedMps; |
| | | }, [deps.speedMps]); |
| | | |
| | | useEffect(() => { |
| | | latestMapScaleRef.current = deps.mapScale; |
| | | }, [deps.mapScale]); |
| | | |
| | | useEffect(() => { |
| | | const isActiveStatus = ACTIVE_TRIP_STATUSES.includes(deps.tripStatus); |
| | | if (!isActiveStatus) { |
| | | clearPendingBand(); |
| | | appliedBandKeyRef.current = null; |
| | | return; |
| | | } |
| | | const speedMps = Number(deps.speedMps); |
| | | if (!Number.isFinite(speedMps)) { |
| | | clearPendingBand(); |
| | | return; |
| | | } |
| | | const nextBand = resolveScaleBandBySpeed(speedMps); |
| | | const appliedBand = getScaleBandByKey(appliedBandKeyRef.current); |
| | | if (appliedBand && nextBand.key === appliedBand.key) { |
| | | clearPendingBand(); |
| | | return; |
| | | } |
| | | if (pendingBandKeyRef.current !== nextBand.key) { |
| | | pendingBandKeyRef.current = nextBand.key; |
| | | pendingBandStartedAtRef.current = Date.now(); |
| | | } |
| | | const now = Date.now(); |
| | | const holdUntil = Number(deps.getDynamicScaleHoldUntil()); |
| | | const stableElapsedMs = now - Number(pendingBandStartedAtRef.current || 0); |
| | | const stableRemainingMs = Math.max(0, SPEED_SCALE_STABLE_DURATION_MS - stableElapsedMs); |
| | | const holdRemainingMs = Number.isFinite(holdUntil) && holdUntil > now |
| | | ? holdUntil - now + HOLD_RESUME_BUFFER_MS |
| | | : 0; |
| | | schedulePendingApply(Math.max(stableRemainingMs, holdRemainingMs)); |
| | | }, [ |
| | | deps.getDynamicScaleHoldUntil, |
| | | deps.speedMps, |
| | | deps.syncActiveTripViewportToVehicle, |
| | | deps.setMapScale, |
| | | deps.tripStatus |
| | | ]); |
| | | |
| | | useEffect(() => { |
| | | return () => { |
| | | clearPendingBand(); |
| | | }; |
| | | }, []); |
| | | } |
| | |
| | | handleOpenCancelPopupByTripStatus: (status: TripStatus, durationDisplay?: any) => void; |
| | | /** 取消超时 */ |
| | | handleOpenCancelPopupWithType: (type: number) => void; |
| | | /** 上车倒计时超时自动取消 */ |
| | | handlePickupTimeoutAutoCancel: () => void; |
| | | /** 行程状态设置 */ |
| | | setTripStatus: (value: TripStatus) => void; |
| | | /** 完成倒计时启用 */ |
| | |
| | | handleModifyDestination={deps.handleModifyDestination} |
| | | showModifyDestination={deps.showModifyDestination} |
| | | handleShareTrip={deps.handleShareTrip} |
| | | handleCancelTimeout={() => deps.handleOpenCancelPopupWithType(4)} |
| | | handleCancelTimeout={deps.handlePickupTimeoutAutoCancel} |
| | | handleFinishTimeout={deps.handleFinishCountdownTimeout} |
| | | resetDetailVersion={deps.resetDetailVersion} |
| | | priceUpdateVersion={deps.priceUpdateVersion} |
| | |
| | | import { clearOrderTripStorage, safeParse } from '../../../../utils/Robotaxi-public-method'; |
| | | import { normalizeDistanceKm, normalizeDurationSeconds } from '../../../../robotaxi/utils/format'; |
| | | import { toTripStatus, TripStatus } from '../constants/trip-status'; |
| | | import { normalizeSocketRouteSnapshot, normalizeSpeedKmh } from './runtime-sync'; |
| | | import { PENDING_CANCEL_CONTEXT_TTL_MS, PendingCancelContext } from './use-order-actions.shared'; |
| | | import { normalizeSocketRouteSnapshot, normalizeSpeedMps } from './runtime-sync'; |
| | | import { MODIFY_DESTINATION_SOCKET_STATUS_KEY } from '../constants/modify-destination'; |
| | | import { clearPassedRouteCache, writePassedRouteCache } from './passed-route-cache'; |
| | | import { resolveVehiclePoint } from './trip-motion-utils'; |
| | | |
| | | type UseWebSocketEventsDeps = { |
| | | tripStatusRef: React.MutableRefObject<TripStatus>; |
| | | pendingCancelContextRef: React.MutableRefObject<PendingCancelContext | null>; |
| | | modifyDestinationPickupSuccessAtRef: React.MutableRefObject<number>; |
| | | modifyDestinationPickupStatusGuardMs: number; |
| | | orderId: string; |
| | |
| | | setOrderRouteMetrics?: (metrics: { |
| | | distanceMeters: number | null; |
| | | durationSeconds: number | null; |
| | | speedKmh: number | null; |
| | | speedMps: number | null; |
| | | }) => void; |
| | | setBeforeUserEndSite: (site: { longitude: number; latitude: number } | null) => void; |
| | | isModifyDestinationReviewVisible?: boolean; |
| | |
| | | if (!incomingOrderId && !currentOrderId) return true; |
| | | if (!incomingOrderId || !currentOrderId) return false; |
| | | return String(incomingOrderId) === String(currentOrderId); |
| | | }; |
| | | |
| | | /** |
| | | * 获取仍在有效期内的待确认取消上下文;过期则立即清空,避免脏上下文影响后续订单。 |
| | | */ |
| | | const getValidPendingCancelContext = (deps: UseWebSocketEventsDeps) => { |
| | | const pendingCancelContext = deps.pendingCancelContextRef.current; |
| | | if (!pendingCancelContext) { |
| | | return null; |
| | | } |
| | | if (Date.now() - pendingCancelContext.createdAt > PENDING_CANCEL_CONTEXT_TTL_MS) { |
| | | deps.pendingCancelContextRef.current = null; |
| | | return null; |
| | | } |
| | | return pendingCancelContext; |
| | | }; |
| | | |
| | | /** |
| | | * 判断当前 socket 取消消息是否可复用本地记录的取消前上下文。 |
| | | */ |
| | | const isPendingCancelContextMatched = (payload: any, pendingCancelContext: PendingCancelContext | null) => { |
| | | if (!pendingCancelContext) { |
| | | return false; |
| | | } |
| | | const socketStatus = Number(payload?.status); |
| | | if (socketStatus !== 0) { |
| | | return false; |
| | | } |
| | | const incomingOrderId = String(payload?.content?.orderId || payload?.orderId || ''); |
| | | if (!incomingOrderId || !pendingCancelContext.orderId) { |
| | | return true; |
| | | } |
| | | return incomingOrderId === pendingCancelContext.orderId; |
| | | }; |
| | | |
| | | const resetRouteRuntimeState = (deps: UseWebSocketEventsDeps) => { |
| | |
| | | .catch(() => { }); |
| | | }; |
| | | |
| | | const handleCancelPopupPush = (payload: any, changeStatusOld: TripStatus, deps: UseWebSocketEventsDeps) => { |
| | | const handleCancelPopupPush = ( |
| | | payload: any, |
| | | changeStatusOld: TripStatus, |
| | | deps: UseWebSocketEventsDeps, |
| | | pendingCancelContext: PendingCancelContext | null |
| | | ) => { |
| | | if (!payload?.showCancelPopup) return; |
| | | if (changeStatusOld === TripStatus.VehicleApproaching || changeStatusOld === TripStatus.VehicleArrivedPickup) { |
| | | const matchedPendingCancelContext = isPendingCancelContextMatched(payload, pendingCancelContext); |
| | | if (matchedPendingCancelContext) { |
| | | deps.setCancelType(pendingCancelContext?.cancelType || 4); |
| | | deps.pendingCancelContextRef.current = null; |
| | | } else if (changeStatusOld === TripStatus.VehicleApproaching || changeStatusOld === TripStatus.VehicleArrivedPickup) { |
| | | deps.setOrderId(''); |
| | | deps.setVehicleInfo({}); |
| | | clearOrderTripStorage(); |
| | |
| | | lastFetchedVehicleOrderIdRef: any |
| | | ) => { |
| | | const changeStatusOld = deps.tripStatusRef.current; |
| | | const currentOrderId = deps.orderId || Taro.getStorageSync('orderId'); |
| | | const pendingCancelContext = getValidPendingCancelContext(deps); |
| | | const currentOrderId = deps.orderId || Taro.getStorageSync('orderId') || pendingCancelContext?.orderId || ''; |
| | | const incomingOrderId = payload?.content?.orderId || payload?.orderId; |
| | | if (!isSameOrder(incomingOrderId, currentOrderId)) { |
| | | return; |
| | |
| | | const effectiveOrderId = resolveOrderId(payload, currentOrderId); |
| | | syncVehicleInfo(effectiveOrderId, deps, lastFetchedVehicleOrderIdRef); |
| | | handleStatusSideEffects(mappedTripStatus, changeStatusOld, deps); |
| | | handleCancelPopupPush(payload, changeStatusOld, deps); |
| | | handleCancelPopupPush(payload, changeStatusOld, deps, pendingCancelContext); |
| | | |
| | | if (payload?.showPassengerNotice) { |
| | | deps.checkAndShowPassengerNotice(); |
| | |
| | | const route = safeParse(payload?.content?.route, {}); |
| | | const distanceMeters = Number(route?.distance); |
| | | const durationSecondsRaw = Number(route?.duration); |
| | | const speedKmh = normalizeSpeedKmh(route?.speed); |
| | | const speedMps = normalizeSpeedMps(route?.speed); |
| | | const distanceKm = normalizeDistanceKm(distanceMeters); |
| | | if (distanceKm !== null) { |
| | | deps.setRouteDistance(distanceKm); |
| | |
| | | deps.setOrderRouteMetrics?.({ |
| | | distanceMeters: Number.isFinite(distanceMeters) ? distanceMeters : null, |
| | | durationSeconds: Number.isFinite(durationSecondsRaw) ? durationSecondsRaw : null, |
| | | speedKmh |
| | | speedMps |
| | | }); |
| | | }; |
| | | |
| | |
| | | |
| | | const handleOrderRoutePayload = (payload: any, deps: UseWebSocketEventsDeps) => { |
| | | const beforeUserSite = parseBeforeUserEndSite(payload); |
| | | if (beforeUserSite) { |
| | | deps.setBeforeUserEndSite(beforeUserSite); |
| | | } |
| | | deps.setBeforeUserEndSite(beforeUserSite); |
| | | const currentTripStatus = deps.tripStatusRef.current; |
| | | // socket 路线拦截只跟随真实的“修改目的地预览态”,不要被本地终点覆盖态误伤。 |
| | | const isModifyDestinationPreviewActive = Boolean(deps.isModifyDestinationReviewVisible); |
| | |
| | | import useTripRuntime from './hooks/use-trip-runtime'; |
| | | // 订单动作编排 |
| | | import useOrderActions from './hooks/use-order-actions'; |
| | | import { PendingCancelContext } from './hooks/use-order-actions.shared'; |
| | | // 视图委托渲染编排 |
| | | import useViewDelegates from './hooks/use-view-delegates'; |
| | | // 修改目的地流程编排 |
| | |
| | | type OrderRouteMetrics = { |
| | | distanceMeters: number | null; |
| | | durationSeconds: number | null; |
| | | speedKmh: number | null; |
| | | speedMps: number | null; |
| | | }; |
| | | |
| | | export default function BeginRoute() { |
| | |
| | | const [orderRouteMetrics, setOrderRouteMetrics] = useState<OrderRouteMetrics>({ |
| | | distanceMeters: null, |
| | | durationSeconds: null, |
| | | speedKmh: null |
| | | speedMps: null |
| | | }); |
| | | const [currentUserLocation, setCurrentUserLocation] = useState<CurrentUserLocation>(null); // 当前用户真实定位,避免被地图中心点覆盖 |
| | | const [modifyDestinationSocketStatus, setModifyDestinationSocketStatus] = useState<number | null>(null); // 后端推送的修改目的地展示状态 |
| | | const syncSmoothRouteBaselineRef = useRef<((snapshot: any) => void) | null>(null); // 订单路线接口回显后,同步初始化平滑擦线基线 |
| | | const lastSocketDirectionAtRef = useRef(0); // 最近一次由 socket 写入车辆朝向的时间戳 |
| | | const beforeBackRef = useRef<() => void>(() => { }); // 左上角返回前的兜底清理,避免预览路线残留到下次进页 |
| | | const pendingCancelContextRef = useRef<PendingCancelContext | null>(null); // 待确认取消上下文,避免本地状态重置污染后续 socket 取消弹窗类型判断 |
| | | |
| | | const tripStatusRef = useRef(tripStatus); // 行程状态引用 |
| | | const lastTripStatusRef = useRef(tripStatus); // 上一次行程状态,用于检测状态变化 |
| | |
| | | setOrderRouteMetrics({ |
| | | distanceMeters: null, |
| | | durationSeconds: null, |
| | | speedKmh: null |
| | | speedMps: null |
| | | }); |
| | | const cachedSocketStatus = Taro.getStorageSync(MODIFY_DESTINATION_SOCKET_STATUS_KEY); |
| | | const cachedOrderId = String(cachedSocketStatus?.orderId || ''); |
| | |
| | | } = viewportState; // 视口状态解构 |
| | | const activeTripScale = useActiveTripScale({ |
| | | tripStatus, |
| | | speedKmh: orderRouteMetrics?.speedKmh, |
| | | speedMps: orderRouteMetrics?.speedMps, |
| | | mapScale, |
| | | setMapScale: viewportState.setMapScale, |
| | | syncActiveTripViewportToVehicle, |
| | |
| | | routeUpdateWalkingRouteForStart, |
| | | getCurrentTripStatus: () => tripStatusRef.current, |
| | | getCurrentLongitude: () => longitude, |
| | | getCurrentLatitude: () => latitude |
| | | getCurrentLatitude: () => latitude, |
| | | getCurrentVehicleLocation: () => vehicleLocation |
| | | }); // 页面恢复与初始化编排 |
| | | const router = Taro.useRouter(); |
| | | const tripRuntime = useTripRuntime({ |
| | |
| | | |
| | | const { bindSocketListeners, ensureSocketListeners } = useWebSocketEvents({ |
| | | tripStatusRef, |
| | | pendingCancelContextRef, |
| | | orderId, |
| | | setOrderId, |
| | | setTripStatus, |
| | |
| | | routeAndFareError, |
| | | callSubmitting, |
| | | cancelType, |
| | | pendingCancelContextRef, |
| | | setTripStatus, |
| | | setOrderId, |
| | | setVehicleInfo, |
| | |
| | | handleConfirmCancel, |
| | | handleOpenCancelPopupWithType, |
| | | handleOpenCancelPopupByTripStatus, |
| | | handlePickupTimeoutAutoCancel, |
| | | handleConfirmOverRoute, |
| | | handleFinishCountdownTimeout, |
| | | handlePay |
| | |
| | | effectiveEndSite, |
| | | startSite, |
| | | vehicleLocation, |
| | | pageDidShowVersion, |
| | | requestRouteAndFare: routeRequestRouteAndFare |
| | | }); |
| | | const shareOrderId = orderId || Taro.getStorageSync('orderId') || ''; |
| | |
| | | isCallDisabled: routeAndFareLoading || routeAndFareError || callSubmitting, |
| | | handleOpenCancelPopupByTripStatus, |
| | | handleOpenCancelPopupWithType, |
| | | handlePickupTimeoutAutoCancel, |
| | | setTripStatus, |
| | | finishCountdownEnabled, |
| | | orderId, |
| | |
| | | {((deps.showToolTip && deps.selectedSite.name) || deps.outOfServiceRange) && ( |
| | | <View className="toolTip"> |
| | | {(deps.outOfServiceRange || deps.selectedSite.time) && ( |
| | | <View className="toolTip-bg" style={{ backgroundImage: `url(${deps.tooltipBgSrc})` }}> |
| | | <View className="toolTip-bg"> |
| | | <Image className="toolTip-bg-img" src={deps.tooltipBgSrc} /> |
| | | <Text className="toolTip-bg-text"> |
| | | {deps.outOfServiceRange ? '不在运营区' : `限停${deps.selectedSite.time}分钟`} |
| | | </Text> |
| | |
| | | z-index: 10; |
| | | |
| | | .toolTip-bg { |
| | | background-image: url('https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/tooltip-bg.png'); |
| | | background-size: cover; |
| | | position: relative; |
| | | height: 70px; |
| | | min-width: 155px; |
| | | text-align: center; |
| | | width: fit-content; |
| | | padding: 0 15px 0 3px; |
| | | |
| | | .toolTip-bg-img { |
| | | position: absolute; |
| | | top: 0; |
| | | left: 0; |
| | | width: 100%; |
| | | height: 100%; |
| | | z-index: -1; |
| | | } |
| | | |
| | | .toolTip-bg-text { |
| | | position: relative; |
| | | z-index: 1; |
| | | font-size: 22px; |
| | | font-weight: 500; |
| | | letter-spacing: normal; |