refactor(robotaxi): 多模块优化与bug修复
本次提交涵盖机器人出租车项目多个模块的优化与修复:
1. 新增导航快照ID字段,完善修改目的地流程的订单参数传递
2. 适配Android设备地图渲染,修正车辆标记点旋转角度,禁用平台特有动画行为
3. 重构地图标记管理逻辑,新增重复渲染校验,支持延迟提交标记点避让车辆动画,调整上一单终点标记点尺寸
4. 修复经纬度解析兼容问题,兼容多字段层级与大小写差异的接口返回
5. 修复个人中心菜单、车费详情卡片的图标路径错误
6. 完善页面返回前的状态清理逻辑,新增修改目的地相关缓存的自动清理
7. 优化空调控制组件,拆分常量配置,优化交互加载状态与提示文案,修复开关操作逻辑
8. 新增车辆动画剩余时长查询接口,支持地图吸附逻辑避让车辆动画周期
| | |
| | | import { useRef, useState } from 'react'; |
| | | import { useEffect, useRef, useState } from 'react'; |
| | | import Taro from '@tarojs/taro'; |
| | | import { fetchMapData, getAutoScaleBySites } from '../../../../utils/Robotaxi-public-method'; |
| | | |
| | | const AUTO_SCALE_MAX = 17; // 自动缩放最大等级 |
| | | const AUTO_SCALE_MIN = 11; // 自动缩放最小等级 |
| | | |
| | | /** |
| | | * 比较两批站点 marker 的关键展示字段是否一致。 |
| | | */ |
| | | const isSameMarkerList = (currentMarkers: any[], nextMarkers: any[]) => { |
| | | if (currentMarkers.length !== nextMarkers.length) { |
| | | return false; |
| | | } |
| | | return currentMarkers.every((marker, index) => { |
| | | const nextMarker = nextMarkers[index]; |
| | | return marker?.id === nextMarker?.id |
| | | && marker?.latitude === nextMarker?.latitude |
| | | && marker?.longitude === nextMarker?.longitude |
| | | && marker?.iconPath === nextMarker?.iconPath |
| | | && marker?.width === nextMarker?.width |
| | | && marker?.height === nextMarker?.height |
| | | && marker?.zIndex === nextMarker?.zIndex |
| | | && marker?.markerType === nextMarker?.markerType; |
| | | }); |
| | | }; |
| | | |
| | | /** |
| | | * 地图状态管理:拥有地图坐标、围栏、标记点及地图业务数据刷新能力。 |
| | |
| | | const resetTimer = useRef<any>(null); |
| | | const pendingNearbyRequestCountRef = useRef(0); |
| | | const autoScaleAppliedCityCodeRef = useRef(''); // 已执行自动缩放的城市 code |
| | | const markersRef = useRef<any[]>([]); // 当前站点 marker 快照 |
| | | const markerCommitTimerRef = useRef<any>(null); // 延迟提交站点 marker 的定时器 |
| | | |
| | | /** |
| | | * 清理延迟提交站点 marker 的定时器。 |
| | | */ |
| | | const clearMarkerCommitTimer = () => { |
| | | if (markerCommitTimerRef.current) { |
| | | clearTimeout(markerCommitTimerRef.current); |
| | | markerCommitTimerRef.current = null; |
| | | } |
| | | }; |
| | | |
| | | /** |
| | | * 立即提交站点 marker 到地图。 |
| | | */ |
| | | const commitMarkers = (nextMarkers: any[]) => { |
| | | if (!isSameMarkerList(markersRef.current, nextMarkers)) { |
| | | markersRef.current = nextMarkers; |
| | | setMarkers(nextMarkers); |
| | | } |
| | | }; |
| | | |
| | | /** |
| | | * 按指定延迟提交站点 marker,拖图查询可先拿结果、后避让车辆动画提交。 |
| | | */ |
| | | const scheduleMarkerCommit = (nextMarkers: any[], delayMs = 0) => { |
| | | clearMarkerCommitTimer(); |
| | | if (delayMs <= 0) { |
| | | commitMarkers(nextMarkers); |
| | | return; |
| | | } |
| | | markerCommitTimerRef.current = setTimeout(() => { |
| | | markerCommitTimerRef.current = null; |
| | | commitMarkers(nextMarkers); |
| | | }, delayMs); |
| | | }; |
| | | |
| | | /** |
| | | * 根据经纬度和城市编码刷新地图业务数据(围栏内判断、电子围栏 ID、站点标记)。 |
| | | */ |
| | | const updateMapData = async (lat: number, lng: number, cityCode?: string) => { |
| | | const updateMapData = async ( |
| | | lat: number, |
| | | lng: number, |
| | | cityCode?: string, |
| | | options?: { |
| | | commitMarkers?: boolean; |
| | | markerCommitDelayMs?: number; |
| | | } |
| | | ) => { |
| | | const code = cityCode || Taro.getStorageSync('selectedCity')?.code; |
| | | if (!code) { |
| | | return { isInside: false, electronicAreaId: '', newMarkers: [], sites: [] }; |
| | |
| | | const result = await fetchMapData(lat, lng, code); |
| | | setIsOperatingArea(result.isInside); |
| | | setElectronicAreaId(result.electronicAreaId); |
| | | setMarkers(result.newMarkers); |
| | | if (options?.commitMarkers !== false) { |
| | | scheduleMarkerCommit(result.newMarkers, options?.markerCommitDelayMs || 0); |
| | | } |
| | | if (autoScaleAppliedCityCodeRef.current !== code) { |
| | | const autoScale = getAutoScaleBySites(result?.sites || result?.newMarkers || [], lat, lng); |
| | | if (typeof autoScale === 'number') { |
| | |
| | | } |
| | | } |
| | | }; |
| | | |
| | | useEffect(() => { |
| | | return () => { |
| | | clearMarkerCommitTimer(); |
| | | }; |
| | | }, []); |
| | | |
| | | return { |
| | | longitude, |
| | |
| | | shouldIgnoreRegionChange, |
| | | isReturningToOrigin, |
| | | resetTimer, |
| | | commitMarkers, |
| | | scheduleMarkerCommit, |
| | | updateMapData, |
| | | }; |
| | | } |
| | |
| | | toDirection: number; |
| | | }; |
| | | |
| | | type VehicleAnimationState = { |
| | | startedAt: number; |
| | | durationMs: number; |
| | | trackMap: Map<string, VehicleTrackItem>; |
| | | }; |
| | | |
| | | const POLL_INTERVAL_MS = 5000; // 车辆轮询周期(毫秒) |
| | | const SETTLE_DELAY_MS = 500; // 地图停止后的静止判定时间(毫秒) |
| | | const MOVE_THRESHOLD_METERS = 500; // 触发新搜索的位移阈值(米) |
| | | const ANIMATION_DURATION_MS = 2000; // 车辆位移动画时长(毫秒) |
| | | const VEHICLE_MARKER_ICON = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/marker-car.png'; // 车辆 icon 地址 |
| | | |
| | | /** |
| | | * 识别当前是否为 Android 设备,避免原生地图对车辆旋转做额外处理。 |
| | | */ |
| | | const detectIsAndroid = () => { |
| | | try { |
| | | const systemInfo = Taro.getSystemInfoSync(); |
| | | return systemInfo?.platform === 'android' || /android/i.test(systemInfo?.system || ''); |
| | | } catch (error) { |
| | | return false; |
| | | } |
| | | }; |
| | | |
| | | /** |
| | | * 归一化角度到 [0, 360) 区间。 |
| | |
| | | const safe = Number(value); |
| | | if (!Number.isFinite(safe)) return 0; |
| | | return ((safe % 360) + 360) % 360; |
| | | }; |
| | | |
| | | /** |
| | | * 统一将业务方向值转换为地图 marker 需要的展示角度。 |
| | | * Android 地图会在相同 rotate 值下比 iOS 顺时针多偏约 90 度,这里做平台补偿。 |
| | | */ |
| | | const resolveMarkerRotate = (direction: number, isAndroid: boolean) => { |
| | | const normalizedDirection = normalizeDirection(direction); |
| | | return isAndroid |
| | | ? normalizeDirection(normalizedDirection - 90) |
| | | : normalizedDirection; |
| | | }; |
| | | |
| | | /** |
| | |
| | | /** |
| | | * 将车辆位置数据映射为地图 marker。 |
| | | */ |
| | | const buildVehicleMarkers = (vehicles: NearbyVehicleItem[]) => { |
| | | const buildVehicleMarkers = (vehicles: NearbyVehicleItem[], isAndroid: boolean) => { |
| | | return vehicles.map((vehicle) => ({ |
| | | id: vehicle.markerId, |
| | | markerType: 'vehicle', |
| | |
| | | name: '', |
| | | latitude: vehicle.latitude, |
| | | longitude: vehicle.longitude, |
| | | rotate: vehicle.direction, |
| | | rotate: resolveMarkerRotate(vehicle.direction, isAndroid), |
| | | width: 20, |
| | | height: 40, |
| | | zIndex: 6, |
| | |
| | | * 附近车辆编排:处理停稳判定、500 米阈值、5 秒轮询与平滑动画。 |
| | | */ |
| | | export default function useNearbyVehicles({ isOperatingArea }: Deps) { |
| | | const [vehicleMarkers, setVehicleMarkers] = useState<any[]>([]); // 车辆 marker 列表 |
| | | const [vehicleMarkerVersion, setVehicleMarkerVersion] = useState(0); // 车辆 marker 结构版本号,仅在结构变化时触发重渲染 |
| | | const pollTimerRef = useRef<any>(null); // 轮询定时器句柄 |
| | | const settleTimerRef = useRef<any>(null); // 停稳判定定时器句柄 |
| | | const animationSyncTimerRef = useRef<any>(null); // 动画结束后同步 React marker 的定时器 |
| | | const activeAnimationRef = useRef<VehicleAnimationState | null>(null); // 当前进行中的车辆动画状态 |
| | | const isAndroidRef = useRef(detectIsAndroid()); // 当前设备是否为 Android,决定是否禁用原生随移动旋转 |
| | | const requestLockRef = useRef(false); // 请求并发锁,防止重入 |
| | | const latestSeqRef = useRef(0); // 请求序号,防止旧响应覆盖新状态 |
| | | const activeSearchCenterRef = useRef<NearbyCenter | null>(null); // 当前轮询中心点 |
| | | const lastSearchCenterRef = useRef<NearbyCenter | null>(null); // 上一次发请求的中心点 |
| | | const renderedVehicleMapRef = useRef<Map<string, NearbyVehicleItem>>(new Map()); // 当前渲染中的车辆 |
| | | const vehicleMarkersRef = useRef<any[]>([]); // 首页传给地图的车辆 marker 快照 |
| | | const mapContextRef = useRef<any>(null); // 首页地图上下文,负责原生 marker 平移 |
| | | const fetchAndAnimateVehiclesRef = useRef<(center: NearbyCenter) => Promise<void>>(async () => {}); |
| | | const ensurePollingRef = useRef<() => void>(() => {}); |
| | |
| | | }; |
| | | |
| | | /** |
| | | * 替换车辆 marker 结构并通知 React 重新渲染地图。 |
| | | */ |
| | | const replaceVehicleMarkers = (nextMarkers: any[]) => { |
| | | vehicleMarkersRef.current = nextMarkers; |
| | | setVehicleMarkerVersion((prev) => prev + 1); |
| | | }; |
| | | |
| | | /** |
| | | * 在不触发 React 重渲染的前提下,同步车辆 marker 的最终位置与朝向快照。 |
| | | */ |
| | | const syncVehicleMarkerSnapshot = (vehicles: NearbyVehicleItem[]) => { |
| | | if (vehicleMarkersRef.current.length === 0) { |
| | | return; |
| | | } |
| | | const vehicleMap = new Map<string, NearbyVehicleItem>( |
| | | vehicles.map((vehicle) => [vehicle.id, vehicle]) |
| | | ); |
| | | vehicleMarkersRef.current = vehicleMarkersRef.current.map((marker) => { |
| | | const vehicleId = String(marker?.vehicleId ?? marker?.id ?? ''); |
| | | const nextVehicle = vehicleMap.get(vehicleId); |
| | | if (!nextVehicle) { |
| | | return marker; |
| | | } |
| | | return { |
| | | ...marker, |
| | | latitude: nextVehicle.latitude, |
| | | longitude: nextVehicle.longitude, |
| | | rotate: resolveMarkerRotate(nextVehicle.direction, isAndroidRef.current) |
| | | }; |
| | | }); |
| | | }; |
| | | |
| | | /** |
| | | * 读取车辆动画剩余时长,供其他模块避让 marker 整包提交。 |
| | | */ |
| | | const getVehicleAnimationRemainingMs = useCallback(() => { |
| | | const animationState = activeAnimationRef.current; |
| | | if (!animationState) { |
| | | return 0; |
| | | } |
| | | const remainingMs = animationState.durationMs - (Date.now() - animationState.startedAt); |
| | | if (remainingMs <= 0) { |
| | | activeAnimationRef.current = null; |
| | | return 0; |
| | | } |
| | | return remainingMs; |
| | | }, []); |
| | | |
| | | /** |
| | | * 清空车辆渲染状态。 |
| | | */ |
| | | const clearVehicles = () => { |
| | | renderedVehicleMapRef.current = new Map(); |
| | | setVehicleMarkers([]); |
| | | replaceVehicleMarkers([]); |
| | | clearAnimationSyncTimer(); |
| | | activeAnimationRef.current = null; |
| | | }; |
| | | |
| | | /** |
| | |
| | | }, |
| | | duration: ANIMATION_DURATION_MS, |
| | | autoRotate: false, |
| | | moveWithRotate: true, |
| | | rotate: interpolateDirection(track.fromDirection, track.toDirection, 1) |
| | | moveWithRotate: !isAndroidRef.current, |
| | | rotate: resolveMarkerRotate( |
| | | interpolateDirection(track.fromDirection, track.toDirection, 1), |
| | | isAndroidRef.current |
| | | ) |
| | | }); |
| | | }); |
| | | }; |
| | |
| | | ); |
| | | if (currentVehicleMap.size === 0 || collectionChanged) { |
| | | clearAnimationSyncTimer(); |
| | | activeAnimationRef.current = null; |
| | | renderedVehicleMapRef.current = nextVehicleMap; |
| | | setVehicleMarkers(buildVehicleMarkers(normalizedVehicles)); |
| | | replaceVehicleMarkers(buildVehicleMarkers(normalizedVehicles, isAndroidRef.current)); |
| | | return; |
| | | } |
| | | const tracks: VehicleTrackItem[] = normalizedVehicles.map((vehicle) => { |
| | |
| | | }; |
| | | }); |
| | | renderedVehicleMapRef.current = nextVehicleMap; |
| | | activeAnimationRef.current = { |
| | | startedAt: Date.now(), |
| | | durationMs: ANIMATION_DURATION_MS, |
| | | trackMap: new Map(tracks.map((track) => [track.id, track])) |
| | | }; |
| | | translateVehicleMarkers(tracks); |
| | | clearAnimationSyncTimer(); |
| | | animationSyncTimerRef.current = setTimeout(() => { |
| | | if (seq !== latestSeqRef.current) { |
| | | return; |
| | | } |
| | | setVehicleMarkers(buildVehicleMarkers(Array.from(renderedVehicleMapRef.current.values()))); |
| | | activeAnimationRef.current = null; |
| | | syncVehicleMarkerSnapshot(Array.from(renderedVehicleMapRef.current.values())); |
| | | }, ANIMATION_DURATION_MS); |
| | | } catch (error) { |
| | | // 请求异常时保持当前车辆状态,避免闪断。 |
| | |
| | | }, [disposeVehicles]); |
| | | |
| | | return { |
| | | vehicleMarkers, |
| | | vehicleMarkers: vehicleMarkerVersion >= 0 ? vehicleMarkersRef.current : [], |
| | | handleRegionBegin, |
| | | handleRegionSettled, |
| | | tryStartPollingOnInit, |
| | | disposeVehicles |
| | | disposeVehicles, |
| | | getVehicleAnimationRemainingMs |
| | | }; |
| | | } |
| | |
| | | mapState: any; |
| | | siteState: any; |
| | | toOrigin: () => void; |
| | | getMarkerCommitDelayMs?: () => number; |
| | | onRegionBegin?: () => void; |
| | | onRegionSettled?: (payload: { lat: number; lon: number; isInside: boolean }) => void; |
| | | }; |
| | |
| | | /** |
| | | * 地图 region-change 编排:处理地图拖拽/缩放后的吸附、回填和自动归位。 |
| | | */ |
| | | export default function useRegionChange({ mapState, siteState, toOrigin, onRegionBegin, onRegionSettled }: Deps) { |
| | | export default function useRegionChange({ mapState, siteState, toOrigin, getMarkerCommitDelayMs, onRegionBegin, onRegionSettled }: Deps) { |
| | | /** |
| | | * 查询完成后提交站点 marker:命中吸附时立即提交,未吸附时避让车辆动画窗口再提交。 |
| | | */ |
| | | const commitQueriedMarkers = (newMarkers: any[], isInside: boolean, adsorbed: boolean) => { |
| | | if (!isInside || adsorbed) { |
| | | mapState.commitMarkers(newMarkers); |
| | | return; |
| | | } |
| | | const delayMs = typeof getMarkerCommitDelayMs === 'function' ? getMarkerCommitDelayMs() : 0; |
| | | mapState.scheduleMarkerCommit(newMarkers, delayMs); |
| | | }; |
| | | |
| | | /** |
| | | * 将用户显式换点后的吸附结果同步为首页确认上车点。 |
| | | */ |
| | |
| | | siteState.setBounce(true); |
| | | siteState.setShowToolTip(false); |
| | | try { |
| | | const { isInside, newMarkers, sites } = await mapState.updateMapData(lat, lng); |
| | | const { isInside, newMarkers, sites } = await mapState.updateMapData(lat, lng, undefined, { commitMarkers: false }); |
| | | if (!isInside) { |
| | | commitQueriedMarkers(newMarkers, false, false); |
| | | settleRegionResult({ lat, lon: lng, isInside: false }); |
| | | siteState.setBounce(false); |
| | | return; |
| | | } |
| | | const nearestCandidates = sites.length > 0 ? sites : newMarkers; |
| | | const { adsorbed, marker } = siteState.tryAdsorb(lat, lng, nearestCandidates); |
| | | commitQueriedMarkers(newMarkers, true, adsorbed); |
| | | const finalLat = adsorbed && marker ? marker.latitude : lat; // 最终纬度 |
| | | const finalLng = adsorbed && marker ? marker.longitude : lng; // 最终经度 |
| | | settleRegionResult({ lat: finalLat, lon: finalLng, isInside: true }); |
| | |
| | | return; |
| | | } |
| | | try { |
| | | const { isInside, newMarkers, sites } = await mapState.updateMapData(lat, lng); |
| | | const { isInside, newMarkers, sites } = await mapState.updateMapData(lat, lng, undefined, { commitMarkers: false }); |
| | | if (!isInside) { |
| | | commitQueriedMarkers(newMarkers, false, false); |
| | | settleRegionResult({ lat, lon: lng, isInside: false }); |
| | | siteState.setBounce(false); |
| | | return; |
| | | } |
| | | const nearestCandidates = sites.length > 0 ? sites : newMarkers; |
| | | const { adsorbed, marker } = siteState.tryAdsorb(lat, lng, nearestCandidates); |
| | | commitQueriedMarkers(newMarkers, true, adsorbed); |
| | | const finalLat = adsorbed && marker ? marker.latitude : lat; // 最终纬度 |
| | | const finalLng = adsorbed && marker ? marker.longitude : lng; // 最终经度 |
| | | if (adsorbed && marker && (causedBy === 'gesture' || causedBy === 'drag' || wasReturningToOrigin)) { |
| | |
| | | vehicleMarkers, |
| | | handleRegionBegin: handleVehicleRegionBegin, |
| | | handleRegionSettled: handleVehicleRegionSettled, |
| | | getVehicleAnimationRemainingMs, |
| | | tryStartPollingOnInit, |
| | | disposeVehicles |
| | | } = useNearbyVehicles({ isOperatingArea }); |
| | |
| | | mapState, |
| | | siteState, |
| | | toOrigin, |
| | | getMarkerCommitDelayMs: getVehicleAnimationRemainingMs, |
| | | onRegionBegin: handleVehicleRegionBegin, |
| | | onRegionSettled: (payload) => { |
| | | handleVehicleRegionSettled(payload); |
| | |
| | | selectedRouteId?: OrderId | ''; |
| | | /** 选中的路线规则。 */ |
| | | selectedRoutePlanRule?: NumericLike | ''; |
| | | /** 导航快照 ID。 */ |
| | | navigationSnapshotId?: OrderId | ''; |
| | | } |
| | | |
| | | /** 费用明细查询请求。 */ |
| | |
| | | fareBreakdown?.redPacketMoney + fareBreakdown?.couponMoney > 0 && |
| | | <View className="car-card-end-particulars-money-through">{formatMoney(fareBreakdown?.orderMoney)}元</View> |
| | | } |
| | | <Image style={{ transform: showDetail ? 'rotate(90deg)' : 'rotate(0deg)', transition: 'transform 0.3s' }} src="https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/ccc-right.png" /> |
| | | <Image style={{ transform: showDetail ? 'rotate(90deg)' : 'rotate(0deg)', transition: 'transform 0.3s' }} src="https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/ccc-icon-right.png" /> |
| | | </View> |
| | | } |
| | | </View> |
| | |
| | | <View className="car-card-end-cost-money"> |
| | | 全程{formatTripDistance(fareBreakdown?.kilometer)}|{formatTripDurationFromSeconds(fareBreakdown?.time)} |
| | | </View> |
| | | <Image src="https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/ccc-right.png" /> |
| | | <Image src="https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/ccc-icon-right.png" /> |
| | | </View> |
| | | { |
| | | (fareBreakdown?.operateFree != 1 && [TripStatus.TripFinished, TripStatus.PaymentSuccess].includes(tripStatus)) && Number(fareBreakdown?.redPacketMoney + fareBreakdown?.couponMoney) > 0 && |
| | |
| | | <View className="car-card-end-through-content" onClick={onOpenCouponSelect}> |
| | | <View className="car-card-end-through-content-title">最佳优惠</View> |
| | | <View className="car-card-end-through-content-money">-{formatMoney(fareBreakdown?.redPacketMoney + fareBreakdown?.couponMoney)}元</View> |
| | | <Image src="https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/ccc-right.png" /> |
| | | <Image src="https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/ccc-icon-right.png" /> |
| | | </View> |
| | | </View> |
| | | } |
| | |
| | | import { TripStatus } from '../../constants/trip-status'; |
| | | import { MODIFY_DESTINATION_SKIP_AC_QUERY_ON_RETURN_KEY } from '../../constants/modify-destination'; |
| | | |
| | | const AC_SWITCH_OPEN_ICON = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/T-switch-open.png'; // 空调关闭态展示的开启动作图标 |
| | | const AC_SWITCH_CLOSE_ICON = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/T-switch-close.png'; // 空调开启态展示的关闭动作图标 |
| | | const AC_OPENING_TEXT = '开启中...'; // 开启空调时的 loading 文案 |
| | | const AC_CLOSING_TEXT = '关闭中...'; // 关闭空调时的 loading 文案 |
| | | const AC_QUERY_FAIL_TEXT = '空调状态查询失败,请稍后重试'; // 空调状态查询失败提示 |
| | | const AC_TOGGLE_FAIL_TEXT = '空调开关操作失败,请重试'; // 空调开关操作失败提示 |
| | | |
| | | type Props = { |
| | | tripStatus: TripStatus; |
| | | orderId?: string; |
| | |
| | | const TEMP_MAX = 28; // 空调最高温度 |
| | | const canToggleSwitch = tripStatus === TripStatus.VehicleApproaching && !isQueryingAc && !isTogglingAc; // 开关是否可点击 |
| | | const canAdjustTemperature = canToggleSwitch && acStatusLoaded && acSwitchOn && typeof tempStatus === 'number'; // 温度是否可调整 |
| | | const tempButtonIconSuffix = acSwitchOn ? '' : '-no'; // 空调关闭时展示置灰的温度调节图标 |
| | | const tempTextDisabledClassName = acSwitchOn ? '' : 'color-000-25'; // 空调关闭时温度和文案置灰 |
| | | useEffect(() => { |
| | | const skipOnce = Number(Taro.getStorageSync(MODIFY_DESTINATION_SKIP_AC_QUERY_ON_RETURN_KEY)) === 1; // 修改目的地返回场景只跳过一次查询 |
| | | skipQueryOnThisCycleRef.current = skipOnce; |
| | |
| | | } |
| | | setAcSwitchOn(false); |
| | | setAcStatusLoaded(false); |
| | | Taro.showToast({ title: '空调状态查询失败,请稍后重试', icon: 'none' }); |
| | | Taro.showToast({ title: AC_QUERY_FAIL_TEXT, icon: 'none' }); |
| | | } finally { |
| | | if (nextSeq === acQuerySeqRef.current) { |
| | | setIsQueryingAc(false); |
| | |
| | | }); |
| | | }; |
| | | |
| | | // 处理空调开关点击:先调开关,再强制回查状态 |
| | | // 处理空调开关点击:以操作接口返回结果作为当前空调状态 |
| | | const handleToggleAcSwitch = async () => { |
| | | if (!canToggleSwitch) { |
| | | return; |
| | |
| | | if (!targetOrderId) { |
| | | return; |
| | | } |
| | | const targetOpenState = !acSwitchOn; |
| | | setIsTogglingAc(true); |
| | | Taro.showLoading({ |
| | | title: targetOpenState ? AC_OPENING_TEXT : AC_CLOSING_TEXT, |
| | | mask: true |
| | | }); |
| | | try { |
| | | await airConditionerUnlock({ |
| | | const res = await airConditionerUnlock({ |
| | | orderId: targetOrderId, |
| | | open: !acSwitchOn |
| | | open: targetOpenState |
| | | }); |
| | | await queryAcSwitchStatus(targetOrderId); |
| | | const nextOpenState = parseAcSwitchState(typeof res === 'undefined' ? targetOpenState : res); |
| | | setAcSwitchOn(nextOpenState); |
| | | setAcStatusLoaded(true); |
| | | } catch (error) { |
| | | Taro.showToast({ title: '空调开关操作失败,请重试', icon: 'none' }); |
| | | Taro.hideLoading(); |
| | | Taro.showToast({ title: AC_TOGGLE_FAIL_TEXT, icon: 'none' }); |
| | | } finally { |
| | | Taro.hideLoading(); |
| | | setIsTogglingAc(false); |
| | | } |
| | | }; |
| | |
| | | <View className="car-card-temp-num-btn"> |
| | | { |
| | | canAdjustTemperature ? |
| | | <Image className="car-card-temp-num-btn-img" src="https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/T-down.png" onClick={() => handleTempChange(-0.5)} /> |
| | | <Image className="car-card-temp-num-btn-img" src={`https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/T-down${tempButtonIconSuffix}.png`} onClick={() => handleTempChange(-0.5)} /> |
| | | : |
| | | <Image className="car-card-temp-num-btn-img" src="https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/T-down-no.png" /> |
| | | <Image className="car-card-temp-num-btn-img" src={`https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/T-down${tempButtonIconSuffix}.png`} /> |
| | | } |
| | | </View> |
| | | <View className="car-card-temp-num-text"> |
| | | <View className="car-card-temp-num-text-temp color-000-25">{typeof tempStatus === 'number' ? `${tempStatus.toFixed(1)}℃` : '--'}</View> |
| | | <View className="car-card-temp-num-text-desc color-000-25">设定温度</View> |
| | | <View className={`car-card-temp-num-text-temp ${tempTextDisabledClassName}`.trim()}>{typeof tempStatus === 'number' ? `${tempStatus.toFixed(1)}℃` : '--'}</View> |
| | | <View className={`car-card-temp-num-text-desc ${tempTextDisabledClassName}`.trim()}>设定温度</View> |
| | | </View> |
| | | <View className="car-card-temp-num-btn"> |
| | | { |
| | | canAdjustTemperature ? |
| | | <Image className="car-card-temp-num-btn-img" src="https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/T-up.png" onClick={() => handleTempChange(0.5)} /> |
| | | <Image className="car-card-temp-num-btn-img" src={`https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/T-up${tempButtonIconSuffix}.png`} onClick={() => handleTempChange(0.5)} /> |
| | | : |
| | | <Image className="car-card-temp-num-btn-img" src="https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/T-up-no.png" /> |
| | | <Image className="car-card-temp-num-btn-img" src={`https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/T-up${tempButtonIconSuffix}.png`} /> |
| | | } |
| | | </View> |
| | | </View> |
| | | <View className="car-card-temp-switch" onClick={handleToggleAcSwitch}> |
| | | <View className={`car-card-temp-switch ${acSwitchOn ? 'car-card-temp-switch-open' : ''}`.trim()} onClick={handleToggleAcSwitch}> |
| | | { |
| | | acSwitchOn ? |
| | | <Image className="car-card-temp-switch-img" src="https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/T-switch-open.png" /> |
| | | <Image className="car-card-temp-switch-img" src={AC_SWITCH_CLOSE_ICON} /> |
| | | : |
| | | <Image className="car-card-temp-switch-img" src="https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/T-switch-close.png" /> |
| | | <Image className="car-card-temp-switch-img" src={AC_SWITCH_OPEN_ICON} /> |
| | | } |
| | | <View className="car-card-temp-switch-text">空调开关</View> |
| | | </View> |
| | |
| | | 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 签名,供平移态判断结构是否实际变化。 |
| | | */ |
| | | const buildMarkersSignature = (markers: any[], vehicleMarkerId: number, shouldUsePureTranslateCarMarker: boolean) => markers |
| | | .map((marker: any) => { |
| | | const shouldIgnoreDynamicCarPosition = shouldUsePureTranslateCarMarker && marker.id === vehicleMarkerId; |
| | | return [ |
| | | marker.id, |
| | | shouldIgnoreDynamicCarPosition ? 'translate' : Number(marker.latitude).toFixed(6), |
| | | shouldIgnoreDynamicCarPosition ? 'translate' : Number(marker.longitude).toFixed(6), |
| | | marker.name || '', |
| | | shouldIgnoreDynamicCarPosition ? 'translate' : (typeof marker.rotate === 'number' ? marker.rotate : ''), |
| | | marker?.customCallout?.anchorY ?? '', |
| | | ].join('|'); |
| | | }) |
| | | .join(';'); |
| | | |
| | | type Deps = { |
| | | tripStatus: TripStatus; |
| | | statusRule: any; |
| | |
| | | latitude, |
| | | longitude, |
| | | iconPath: 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/before-user-site.png', |
| | | width: 22, |
| | | height: 36, |
| | | width: 18, |
| | | height: 18, |
| | | anchor: { x: 0.5, y: 1 }, |
| | | zIndex: 1, |
| | | name: '上一单终点', |
| | |
| | | if (shouldSkipUpdate) { |
| | | // 平移态下保留已创建车辆 marker 的当前位置和角度,只同步其他 marker 结构变化 |
| | | setMarkers((prevMarkers: any[]) => { |
| | | const prevMarkersSignature = buildMarkersSignature(prevMarkers, deps.vehicleMarkerId, shouldUsePureTranslateCarMarker); |
| | | const targetCarIndex = prevMarkers.findIndex((item: any) => item.id === deps.vehicleMarkerId); |
| | | const nextCarIndex = nextMarkers.findIndex((item: any) => item.id === deps.vehicleMarkerId); |
| | | if (nextCarIndex === -1) { |
| | | if (markersSignatureRef.current !== nextMarkersSignature) { |
| | | if (prevMarkersSignature !== nextMarkersSignature) { |
| | | markersSignatureRef.current = nextMarkersSignature; |
| | | return nextMarkers; |
| | | } |
| | | return prevMarkers; |
| | | } |
| | | if (targetCarIndex === -1 || !shouldUsePureTranslateCarMarker) { |
| | | if (markersSignatureRef.current !== nextMarkersSignature) { |
| | | if (prevMarkersSignature !== nextMarkersSignature) { |
| | | markersSignatureRef.current = nextMarkersSignature; |
| | | return nextMarkers; |
| | | } |
| | |
| | | && Number(nextCar.rotate) !== Number(prevCar.rotate) |
| | | ) |
| | | ); |
| | | if (markersSignatureRef.current === nextMarkersSignature && !shouldRefreshCarPosition) { |
| | | if (prevMarkersSignature === nextMarkersSignature && !shouldRefreshCarPosition) { |
| | | return prevMarkers; |
| | | } |
| | | markersSignatureRef.current = nextMarkersSignature; |
| | |
| | | if (effectiveShowCar && carMarker) nextMarkers.push(carMarker); |
| | | if (deps.statusRule.showDropoff && dropoffMarker) nextMarkers.push(dropoffMarker); |
| | | if (beforeUserDropoffMarker) nextMarkers.push(beforeUserDropoffMarker); |
| | | const nextMarkersSignature = nextMarkers |
| | | .map((m: any) => { |
| | | const shouldIgnoreDynamicCarPosition = shouldUsePureTranslateCarMarker && m.id === deps.vehicleMarkerId; |
| | | return [ |
| | | m.id, |
| | | shouldIgnoreDynamicCarPosition ? 'translate' : Number(m.latitude).toFixed(6), |
| | | shouldIgnoreDynamicCarPosition ? 'translate' : Number(m.longitude).toFixed(6), |
| | | m.name || '', |
| | | shouldIgnoreDynamicCarPosition ? 'translate' : (typeof m.rotate === 'number' ? m.rotate : ''), |
| | | m?.customCallout?.anchorY ?? '', |
| | | ].join('|'); |
| | | }) |
| | | .join(';'); |
| | | const nextMarkersSignature = buildMarkersSignature(nextMarkers, deps.vehicleMarkerId, shouldUsePureTranslateCarMarker); |
| | | const hasDropoffMarker = markersRef.current.some((m: any) => m.id === 2); |
| | | const shouldHoldMarkerByTranslate = [TripStatus.VehicleApproaching, TripStatus.InService].includes(deps.tripStatus) && carMarkerInitialized.current && deps.statusRule.showCar && (!deps.statusRule.showDropoff || hasDropoffMarker); |
| | | const shouldSkipUpdate = shouldHoldMarkerByTranslate && (deps.isAndroid || tripStatus6MarkerRefreshedRef.current); |
| | |
| | | endPointId: previewEndSite.id || 0, |
| | | orderId: Taro.getStorageSync('orderId') || '', |
| | | selectedRouteId: selectedRoute?.route?.routeID || selectedRoute?.pathFingerprint || '', |
| | | selectedRoutePlanRule: selectedRoute?.primaryRoutePlanRule || '' |
| | | selectedRoutePlanRule: selectedRoute?.primaryRoutePlanRule || '', |
| | | navigationSnapshotId: selectedRoute?.snapshotId || '' |
| | | }); |
| | | |
| | | // 清除本地修改状态,不再作为“本地覆盖”存在,因为订单已真正变更 |
| | |
| | | MODIFY_DESTINATION_CANDIDATE_KEY, |
| | | MODIFY_DESTINATION_OVERRIDE_KEY, |
| | | MODIFY_DESTINATION_ROUTE_POINTS_KEY, |
| | | MODIFY_DESTINATION_SKIP_AC_QUERY_ON_RETURN_KEY, |
| | | MODIFY_DESTINATION_SOCKET_STATUS_KEY, |
| | | MODIFY_DESTINATION_SESSION_KEY |
| | | } from '../constants/modify-destination'; |
| | | import { clearPassedRouteCache } from './passed-route-cache'; |
| | |
| | | startSite: any; |
| | | getCheckedEnter: () => boolean; |
| | | setCheckedEnter: (checked: boolean) => void; |
| | | onBeforeBack?: () => void; |
| | | }; |
| | | |
| | | /** |
| | |
| | | Taro.removeStorageSync(MODIFY_DESTINATION_CANDIDATE_KEY); |
| | | Taro.removeStorageSync(MODIFY_DESTINATION_OVERRIDE_KEY); |
| | | Taro.removeStorageSync(MODIFY_DESTINATION_ROUTE_POINTS_KEY); |
| | | Taro.removeStorageSync(MODIFY_DESTINATION_SKIP_AC_QUERY_ON_RETURN_KEY); |
| | | Taro.removeStorageSync(MODIFY_DESTINATION_SOCKET_STATUS_KEY); |
| | | }; |
| | | |
| | | /** |
| | |
| | | * 返回首页并清理必要缓存。 |
| | | */ |
| | | const toBack = () => { |
| | | deps.onBeforeBack?.(); |
| | | clearModifyDestinationStorage(); |
| | | clearPassedRouteCache(); |
| | | if (deps.tripStatus === TripStatus.PaymentSuccess || deps.tripStatus === TripStatus.PublishRoute) { |
| | |
| | | |
| | | requestHandler({ orderId: targetOrderId, routeType }) |
| | | .then((response: any) => { |
| | | const beforeUserSite = parseBeforeUserEndSite(response?.endLongitude, response?.endlatitude); |
| | | deps.setBeforeUserEndSite?.(beforeUserSite); |
| | | const beforeUserSite = parseBeforeUserEndSite( |
| | | response?.endLongitude, |
| | | response?.endLatitude ?? response?.endlatitude |
| | | ); |
| | | if (beforeUserSite) { |
| | | deps.setBeforeUserEndSite?.(beforeUserSite); |
| | | } |
| | | |
| | | const rawPoints = Array.isArray(response?.polyline) ? response.polyline : []; |
| | | const points = rawPoints |
| | |
| | | type Deps = { |
| | | statusRule: any; |
| | | vehicleMarkerId: number; |
| | | isAndroid: boolean; |
| | | }; |
| | | |
| | | export default function useVehicleTracking({ |
| | | statusRule, |
| | | vehicleMarkerId |
| | | vehicleMarkerId, |
| | | isAndroid |
| | | }: Deps) { |
| | | // 车辆当前位置 |
| | | const [vehicleLocation, setVehicleLocation] = useState<{ latitude: number; longitude: number } | null>(null); |
| | |
| | | longitude: task.longitude, |
| | | }, |
| | | autoRotate: false, |
| | | moveWithRotate: true, |
| | | moveWithRotate: !isAndroid, |
| | | rotate: task.rotate, |
| | | duration: TRANSLATE_DURATION_MS, |
| | | fail: () => { |
| | |
| | | const resolveOrderId = (payload: any, fallbackOrderId: string) => |
| | | payload?.content?.orderId || payload?.orderId || fallbackOrderId || Taro.getStorageSync('orderId'); |
| | | |
| | | // 解析“上一单乘客下车点”坐标,任一坐标无效则返回空,供 marker 显隐控制。 |
| | | // 解析“上一单乘客下车点”坐标,兼容顶层字段、content 字段与历史大小写差异。 |
| | | const parseBeforeUserEndSite = (payload: any) => { |
| | | const longitude = Number(payload?.content?.endLongitude); |
| | | const latitude = Number(payload?.content?.endlatitude); |
| | | const longitude = Number( |
| | | payload?.endLongitude |
| | | ?? payload?.data?.endLongitude |
| | | ?? payload?.content?.endLongitude |
| | | ); |
| | | const latitude = Number( |
| | | payload?.endLatitude |
| | | ?? payload?.endlatitude |
| | | ?? payload?.data?.endLatitude |
| | | ?? payload?.data?.endlatitude |
| | | ?? payload?.content?.endLatitude |
| | | ?? payload?.content?.endlatitude |
| | | ); |
| | | if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) { |
| | | return null; |
| | | } |
| | |
| | | if (!isSameOrder(incomingOrderId, currentOrderId)) { |
| | | return; |
| | | } |
| | | deps.setBeforeUserEndSite(parseBeforeUserEndSite(payload)); |
| | | |
| | | const mappedTripStatus = syncTripStatus(payload, deps); |
| | | const effectiveOrderId = resolveOrderId(payload, currentOrderId); |
| | |
| | | }; |
| | | |
| | | const handleOrderRoutePayload = (payload: any, deps: UseWebSocketEventsDeps) => { |
| | | deps.setBeforeUserEndSite(parseBeforeUserEndSite(payload)); |
| | | const beforeUserSite = parseBeforeUserEndSite(payload); |
| | | if (beforeUserSite) { |
| | | deps.setBeforeUserEndSite(beforeUserSite); |
| | | } |
| | | const currentTripStatus = deps.tripStatusRef.current; |
| | | // socket 路线拦截只跟随真实的“修改目的地预览态”,不要被本地终点覆盖态误伤。 |
| | | const isModifyDestinationPreviewActive = Boolean(deps.isModifyDestinationReviewVisible); |
| | |
| | | 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 tripStatusRef = useRef(tripStatus); // 行程状态引用 |
| | | const lastTripStatusRef = useRef(tripStatus); // 上一次行程状态,用于检测状态变化 |
| | |
| | | getCheckedEnter: () => isCheckedEnter.current, |
| | | setCheckedEnter: (checked) => { |
| | | isCheckedEnter.current = checked; |
| | | }, |
| | | onBeforeBack: () => { |
| | | beforeBackRef.current(); |
| | | } |
| | | }); // 页面动作编排 |
| | | const { |
| | |
| | | const showTrip1StyleEndpoint = isModifyDestinationReviewVisible || hasLocalDestinationOverride; |
| | | const vehicleState = useVehicleTracking({ |
| | | statusRule, |
| | | vehicleMarkerId |
| | | vehicleMarkerId, |
| | | isAndroid |
| | | }); // 车辆追踪状态容器 |
| | | const { |
| | | vehicleLocation, |
| | |
| | | lastPolylineUpdateAtRef, |
| | | currentFullRouteRef, |
| | | lastErasedIndexRef, |
| | | resetRouteRuntimeState, |
| | | applyRoutePoints: routeApplyRoutePoints, |
| | | erasePassedRouteSegments: routeErasePassedRouteSegments, |
| | | requestRouteAndFare: routeRequestRouteAndFare, |
| | | requestOrderRoute: routeRequestOrderRoute, |
| | | updateWalkingRouteForStart: routeUpdateWalkingRouteForStart |
| | | } = routeState; // 路线状态解构 |
| | | beforeBackRef.current = () => { |
| | | if (isModifyDestinationReviewVisible || hasLocalDestinationOverride || previousEndSite) { |
| | | exitModifyDestinationReview(); |
| | | } else { |
| | | clearLocalDestinationOverride(); |
| | | } |
| | | setIsRouteSelectExpanded(false); |
| | | setRouteOptions([]); |
| | | setFareEstimate({}); |
| | | setShowModifyDestinationFailPopup(false); |
| | | setModifyDestinationFailMessage(''); |
| | | setRouteDistance(null); |
| | | setRouteDuration(null); |
| | | setWalkingPoints([]); |
| | | setWalkingTime(0); |
| | | resetRouteRuntimeState(); |
| | | }; |
| | | const displayPolyline = polyline; |
| | | const routeSummaryMarker = useMemo(() => { |
| | | if (!allowSelectableRoutes || !routeDistance || routeDuration === null || !Array.isArray(routePoints) || routePoints.length === 0) { |
| | |
| | | if (!routeSummaryMarker) return markers; |
| | | return [...markers, routeSummaryMarker]; |
| | | }, [markers, routeSummaryMarker]); |
| | | const hasValidBeforeUserEndSite = useMemo(() => ( |
| | | Number.isFinite(Number(beforeUserEndSite?.latitude)) |
| | | && Number.isFinite(Number(beforeUserEndSite?.longitude)) |
| | | ), [beforeUserEndSite?.latitude, beforeUserEndSite?.longitude]); |
| | | const { distanceConfig, ensureDistanceConfig, checkDistanceAndCityReady } = useOrderDistanceConfig({ |
| | | endSite, |
| | | setTipType, |
| | |
| | | arrivedTooltipReady, |
| | | routeDistance, |
| | | routeDuration, |
| | | hasBeforeUserEndSite: !!beforeUserEndSite, |
| | | hasBeforeUserEndSite: hasValidBeforeUserEndSite, |
| | | customCalloutIconSrc, |
| | | routeSummaryBgSrc, |
| | | showTrip1StyleEndpoint, |
| | |
| | | |
| | | const tripMenuList: TripMenuItem[] = [ |
| | | { key: "pendingPay", label: "待支付", icon: 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/mine-pay.png' }, |
| | | { key: "completed", label: "已完成", icon: 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/finished.png' }, |
| | | { key: "cancelled", label: "已取消", icon: 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/cancelled.png' }, |
| | | { key: "completed", label: "已完成", icon: 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/mine-finished.png' }, |
| | | { key: "cancelled", label: "已取消", icon: 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/mine-cancelled.png' }, |
| | | { key: "all", label: "全部订单", icon: 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/mine-all.png' } |
| | | ]; |
| | | |