Merge branch 'feature_v1.0' of http://gitlab.xiaopeng.local:18080/robotaxi1/xp-robotaxi-miniprogram into feature_v1.0
| | |
| | | if (props.isLocationAuthDenied) { |
| | | return []; |
| | | } |
| | | return props.mergedMapMarkers.map((item) => ({ ...item, title: '', name: '' })); |
| | | return props.mergedMapMarkers; |
| | | }, [props.isLocationAuthDenied, props.mergedMapMarkers]); |
| | | |
| | | return ( |
| | |
| | | import Taro from '@tarojs/taro'; |
| | | import wsManager from '../../../../utils/websocket'; |
| | | import { mapOrderStatusToTripStatus } from '../../../../utils/Robotaxi-public-method'; |
| | | import { TripStatus, toTripStatus } from '../../../../robotaxi/pages/begin-route/constants/trip-status'; |
| | | import { SocketOrderStatus, TripStatus, toTripStatus } from '../../../../robotaxi/pages/begin-route/constants/trip-status'; |
| | | import { |
| | | clearHomeStartSiteDisplayState, |
| | | HOME_START_SITE_SOURCE_ACTIVE_ORDER, |
| | |
| | | hasActiveOrder: boolean; |
| | | /** 当前首页确认上车点。 */ |
| | | preferredStartSite: any; |
| | | /** 当前是否应按用户位置吸附最近站点。 */ |
| | | shouldUseNearestSiteByOrder: boolean; |
| | | }; |
| | | |
| | | type GetLocationInfoOptions = { |
| | | /** 是否更新地图中心。 */ |
| | | updateCenter?: boolean; |
| | | /** 是否强制刷新城市信息。 */ |
| | | forceCityRefresh?: boolean; |
| | | /** 是否强制按用户位置吸附最近站点。 */ |
| | | forceNearestSite?: boolean; |
| | | }; |
| | | |
| | | type Deps = { |
| | |
| | | /** 设置经度 */ |
| | | setLongitude: (value: number) => void; |
| | | /** 读取定位并更新站点 */ |
| | | getLocationInfo: (updateCenter?: boolean, forceCityRefresh?: boolean) => void; |
| | | getLocationInfo: (options?: GetLocationInfoOptions) => void; |
| | | /** 处理切城标记 */ |
| | | consumeCitySwitchFlag: () => { switched: boolean; shouldMoveToCityCenter: boolean }; |
| | | /** 跳过下一次居中 */ |
| | |
| | | }; |
| | | |
| | | /** |
| | | * 判断当前活跃订单是否应按用户位置吸附最近站点。 |
| | | */ |
| | | const shouldUseNearestSiteByActiveOrderStatus = (status: unknown) => ( |
| | | Number(status) >= SocketOrderStatus.PendingDeparture |
| | | ); |
| | | |
| | | /** |
| | | * 将活跃订单起点映射为首页缓存站点。 |
| | | */ |
| | | const buildStartSiteByActiveOrder = (activeOrder: any) => ({ |
| | |
| | | deps.setNeedLogin(!otp); |
| | | if (!otp) { |
| | | deps.setPreferredStartSite(null); |
| | | return { hasLogin: false, hasActiveOrder: false, preferredStartSite: null } as AuthOrderSyncResult; |
| | | return { |
| | | hasLogin: false, |
| | | hasActiveOrder: false, |
| | | preferredStartSite: null, |
| | | shouldUseNearestSiteByOrder: false |
| | | } as AuthOrderSyncResult; |
| | | } |
| | | wsManager.connect(deps.wsHost); |
| | | const agreement = await deps.getAgreement(); |
| | |
| | | deps.setTripStatus(0); |
| | | deps.setOrderData({}); |
| | | deps.setPreferredStartSite(preferredStartSite); |
| | | return { hasLogin: true, hasActiveOrder: false, preferredStartSite } as AuthOrderSyncResult; |
| | | return { |
| | | hasLogin: true, |
| | | hasActiveOrder: false, |
| | | preferredStartSite, |
| | | shouldUseNearestSiteByOrder: false |
| | | } as AuthOrderSyncResult; |
| | | } |
| | | deps.setOrderData(activeOrder || {}); |
| | | const mappedStatus = toHomeTripStatus(mapOrderStatusToTripStatus(activeOrder.status || 0)); |
| | | const startSite = buildStartSiteByActiveOrder(activeOrder); |
| | | const endSite = buildEndSiteByActiveOrder(activeOrder); |
| | | const preferredState = readHomeStartSiteDisplayState(); |
| | | const shouldUseNearestSiteByOrder = shouldUseNearestSiteByActiveOrderStatus(activeOrder.status); |
| | | const manualSelectedStartSite = isManualSelectedHomeStartSiteDisplayState(preferredState) |
| | | ? resolveHomeStartSiteDisplaySite() |
| | | : null; // 手动选择的上车点优先用于首页回显,避免返回首页时被订单起点覆盖 |
| | | const preferredStartSite = shouldUseNearestSiteByOrder ? null : (manualSelectedStartSite || startSite); |
| | | deps.setTripStatus(mappedStatus); |
| | | Taro.setStorageSync('tripStatus', mappedStatus); |
| | | Taro.setStorageSync('orderId', activeOrder.id || ''); |
| | | Taro.setStorageSync('startSite', startSite); |
| | | Taro.setStorageSync('endSite', endSite); |
| | | writeHomeStartSiteDisplayState(startSite, HOME_START_SITE_SOURCE_ACTIVE_ORDER); |
| | | deps.setPreferredStartSite(startSite); |
| | | return { hasLogin: true, hasActiveOrder: true, preferredStartSite: startSite } as AuthOrderSyncResult; |
| | | if (shouldUseNearestSiteByOrder) { |
| | | Taro.removeStorageSync('startSite'); |
| | | } else { |
| | | Taro.setStorageSync('startSite', preferredStartSite); |
| | | } |
| | | if (shouldUseNearestSiteByOrder && preferredState?.source === HOME_START_SITE_SOURCE_ACTIVE_ORDER) { |
| | | clearHomeStartSiteDisplayState(); |
| | | } |
| | | if (!shouldUseNearestSiteByOrder && !manualSelectedStartSite) { |
| | | writeHomeStartSiteDisplayState(startSite, HOME_START_SITE_SOURCE_ACTIVE_ORDER); |
| | | } |
| | | deps.setPreferredStartSite(preferredStartSite); |
| | | return { |
| | | hasLogin: true, |
| | | hasActiveOrder: true, |
| | | preferredStartSite, |
| | | shouldUseNearestSiteByOrder |
| | | } as AuthOrderSyncResult; |
| | | }; |
| | | |
| | | /** |
| | | * 处理定位与首屏地图状态。 |
| | | */ |
| | | const processLocationSetting = async (auth: any, startSite: any) => { |
| | | const processLocationSetting = async (auth: any, startSite: any, shouldUseNearestSiteByOrder = false) => { |
| | | const hasStartSite = startSite && startSite.latitude && startSite.longitude; |
| | | deps.setIsLocationAuthDenied(auth === false); |
| | | const switchState = deps.consumeCitySwitchFlag(); |
| | |
| | | deps.setLongitude(deps.defaultLongitude); |
| | | return; |
| | | } |
| | | if (shouldUseNearestSiteByOrder) { |
| | | deps.getLocationInfo({ |
| | | updateCenter: true, |
| | | forceCityRefresh: true, |
| | | forceNearestSite: true |
| | | }); |
| | | return; |
| | | } |
| | | if (auth === undefined) { |
| | | deps.getLocationInfo(); |
| | | return; |
| | |
| | | deps.skipNextLocationCenterRef.current = false; |
| | | const hasEntered = Taro.getStorageSync(deps.firstEnterKey) === 'true'; |
| | | if (!hasEntered) { |
| | | deps.getLocationInfo(true); |
| | | deps.getLocationInfo({ updateCenter: true }); |
| | | Taro.setStorageSync(deps.firstEnterKey, 'true'); |
| | | return; |
| | | } |
| | | deps.getLocationInfo(shouldUpdateCenter); |
| | | if (!hasStartSite && auth === true) { |
| | | deps.getLocationInfo({ updateCenter: shouldUpdateCenter }); |
| | | if (!hasStartSite && auth === true && !shouldUseNearestSiteByOrder) { |
| | | await deps.toOrigin(); |
| | | } |
| | | }; |
| | |
| | | const auth = res.authSetting['scope.userLocation']; |
| | | Promise.resolve(authSyncTask) |
| | | .then((syncResult) => { |
| | | const startSite = syncResult.hasActiveOrder |
| | | ? Taro.getStorageSync('startSite') |
| | | : syncResult.preferredStartSite; |
| | | processLocationSetting(auth, startSite); |
| | | const startSite = syncResult.preferredStartSite || Taro.getStorageSync('startSite'); |
| | | processLocationSetting(auth, startSite, syncResult.shouldUseNearestSiteByOrder); |
| | | }) |
| | | .catch(() => { |
| | | processLocationSetting(auth, null); |
| | |
| | | onLocationSettled: (lat: number, lng: number, isInside: boolean) => void; |
| | | }; |
| | | |
| | | type GetLocationInfoOptions = { |
| | | /** 是否更新地图中心。 */ |
| | | updateCenter?: boolean; |
| | | /** 是否强制刷新城市信息。 */ |
| | | forceCityRefresh?: boolean; |
| | | /** 是否强制按用户位置吸附最近站点。 */ |
| | | forceNearestSite?: boolean; |
| | | }; |
| | | |
| | | /** |
| | | * 定位流程编排:不持有状态,仅组合 map/site/city 状态 hook 完成定位主链路。 |
| | | */ |
| | |
| | | /** |
| | | * 定位入口:定位 -> 城市就绪 -> 地图数据刷新 -> 吸附判断 -> 地址兜底。 |
| | | */ |
| | | const getLocationInfo = (updateCenter = true, forceCityRefresh = false) => { |
| | | const getLocationInfo = (options: GetLocationInfoOptions = {}) => { |
| | | const { |
| | | updateCenter = true, |
| | | forceCityRefresh = false, |
| | | forceNearestSite = false |
| | | } = options; |
| | | Taro.getLocation({ type: 'gcj02' }) |
| | | .then(async (res) => { |
| | | mapState.setIsLocationAuthDenied(false); |
| | |
| | | ); |
| | | if (cityInfo?.code) { |
| | | const { isInside, newMarkers, sites } = await mapState.updateMapData(res.latitude, res.longitude, cityInfo.code); |
| | | const lockedManualStartSite = resolveLockedManualStartSite(cityInfo.code); |
| | | const lockedManualStartSite = forceNearestSite ? null : resolveLockedManualStartSite(cityInfo.code); |
| | | const nearestCandidates = sites.length > 0 ? sites : newMarkers; |
| | | const { adsorbed, marker } = lockedManualStartSite |
| | | ? { adsorbed: false, marker: null } |
| | |
| | | /** 设置地图缩放 */ |
| | | setMapScale: (v: number) => void; |
| | | /** 主动定位 */ |
| | | getLocationInfo: (updateCenter?: boolean, forceCityRefresh?: boolean) => void; |
| | | getLocationInfo: (options?: { |
| | | /** 是否更新地图中心。 */ |
| | | updateCenter?: boolean; |
| | | /** 是否强制刷新城市信息。 */ |
| | | forceCityRefresh?: boolean; |
| | | /** 是否强制按用户位置吸附最近站点。 */ |
| | | forceNearestSite?: boolean; |
| | | }) => void; |
| | | /** 查询指针吸附 */ |
| | | queryPointerByCenter: (lat: number, lng: number) => Promise<void>; |
| | | }; |
| | |
| | | if (res.authSetting['scope.userLocation']) { |
| | | deps.setIsLocationAuthDenied(false); |
| | | deps.setMapScale(14); |
| | | deps.getLocationInfo(true, true); |
| | | deps.getLocationInfo({ updateCenter: true, forceCityRefresh: true }); |
| | | } |
| | | } |
| | | }); |
| | |
| | | |
| | | type NearbyVehicleItem = { |
| | | id: string; |
| | | markerId: number; |
| | | latitude: number; |
| | | longitude: number; |
| | | direction: number; |
| | |
| | | |
| | | type VehicleTrackItem = { |
| | | id: string; |
| | | markerId: number; |
| | | fromLat: number; |
| | | fromLon: number; |
| | | toLat: number; |
| | |
| | | const POLL_INTERVAL_MS = 5000; // 车辆轮询周期(毫秒) |
| | | const SETTLE_DELAY_MS = 500; // 地图停止后的静止判定时间(毫秒) |
| | | const MOVE_THRESHOLD_METERS = 500; // 触发新搜索的位移阈值(米) |
| | | const ANIMATION_DURATION_MS = 4500; // 车辆位移动画时长(毫秒) |
| | | const VEHICLE_MARKER_ID_BASE = 900000000; // 车辆 marker id 起始段 |
| | | const ANIMATION_DURATION_MS = 2000; // 车辆位移动画时长(毫秒) |
| | | const VEHICLE_MARKER_ICON = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/marker-car.png'; // 车辆 icon 地址 |
| | | |
| | | /** |
| | |
| | | }; |
| | | |
| | | /** |
| | | * 将任意字符串稳定映射到正整数,避免与站点 marker id 冲突。 |
| | | */ |
| | | const hashToMarkerId = (rawId: string) => { |
| | | let hash = 0; |
| | | for (let i = 0; i < rawId.length; i += 1) { |
| | | hash = (hash << 5) - hash + rawId.charCodeAt(i); |
| | | hash |= 0; |
| | | } |
| | | return VEHICLE_MARKER_ID_BASE + (Math.abs(hash) % 9999999); |
| | | }; |
| | | |
| | | /** |
| | | * 将车辆位置数据映射为地图 marker。 |
| | | */ |
| | | const buildVehicleMarkers = (vehicles: NearbyVehicleItem[]) => { |
| | | return vehicles.map((vehicle) => ({ |
| | | id: hashToMarkerId(vehicle.id), |
| | | id: vehicle.markerId, |
| | | markerType: 'vehicle', |
| | | vehicleId: vehicle.id, |
| | | title: '', |
| | | name: '', |
| | | latitude: vehicle.latitude, |
| | | longitude: vehicle.longitude, |
| | | rotate: vehicle.direction, |
| | |
| | | ? response.vehicles |
| | | : []; |
| | | return rawList |
| | | .map((item: any, index: number) => { |
| | | .map((item: any) => { |
| | | const latitude = Number(item?.latitude); |
| | | const longitude = Number(item?.longitude); |
| | | if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null; |
| | | const rawId = item?.id ?? `vehicle-${index}-${latitude}-${longitude}`; |
| | | const rawId = item?.id; |
| | | const markerId = Number(rawId); |
| | | if (!Number.isFinite(latitude) || !Number.isFinite(longitude) || rawId === undefined || rawId === null || String(rawId) === '' || !Number.isFinite(markerId)) return null; |
| | | return { |
| | | id: String(rawId), |
| | | markerId, |
| | | latitude, |
| | | longitude, |
| | | direction: normalizeDirection(Number(item?.direction)) |
| | |
| | | }; |
| | | |
| | | /** |
| | | * 清理动画同步定时器。 |
| | | */ |
| | | const clearAnimationSyncTimer = () => { |
| | | if (animationSyncTimerRef.current) { |
| | | clearTimeout(animationSyncTimerRef.current); |
| | | animationSyncTimerRef.current = null; |
| | | } |
| | | }; |
| | | |
| | | /** |
| | | * 清理轮询定时器。 |
| | | */ |
| | | const clearPollTimer = () => { |
| | |
| | | if (settleTimerRef.current) { |
| | | clearTimeout(settleTimerRef.current); |
| | | settleTimerRef.current = null; |
| | | } |
| | | }; |
| | | |
| | | /** |
| | | * 清理动画结束后的 React 坐标同步定时器。 |
| | | */ |
| | | const clearAnimationSyncTimer = () => { |
| | | if (animationSyncTimerRef.current) { |
| | | clearTimeout(animationSyncTimerRef.current); |
| | | animationSyncTimerRef.current = null; |
| | | } |
| | | }; |
| | | |
| | |
| | | const translateVehicleMarkers = (tracks: VehicleTrackItem[]) => { |
| | | const mapContext = getMapContext(); |
| | | tracks.forEach((track) => { |
| | | const markerId = hashToMarkerId(track.id); |
| | | (mapContext as any).translateMarker?.({ |
| | | markerId, |
| | | markerId: track.markerId, |
| | | destination: { |
| | | latitude: track.toLat, |
| | | longitude: track.toLon |
| | | }, |
| | | duration: ANIMATION_DURATION_MS, |
| | | autoRotate: false, |
| | | moveWithRotate: true, |
| | | rotate: interpolateDirection(track.fromDirection, track.toDirection, 1) |
| | | }); |
| | | }); |
| | |
| | | const currentVehicle = currentVehicleMap.get(vehicle.id); |
| | | return { |
| | | id: vehicle.id, |
| | | markerId: vehicle.markerId, |
| | | fromLat: currentVehicle?.latitude ?? vehicle.latitude, |
| | | fromLon: currentVehicle?.longitude ?? vehicle.longitude, |
| | | toLat: vehicle.latitude, |
| | |
| | | if (seq !== latestSeqRef.current) { |
| | | return; |
| | | } |
| | | setVehicleMarkers(buildVehicleMarkers(Array.from(nextVehicleMap.values()))); |
| | | setVehicleMarkers(buildVehicleMarkers(Array.from(renderedVehicleMapRef.current.values()))); |
| | | }, ANIMATION_DURATION_MS); |
| | | } catch (error) { |
| | | // 请求异常时保持当前车辆状态,避免闪断。 |
| | |
| | | import trafficSignalIcon from '../../../assets/traffic-signal.png'; |
| | | import RoutePreferencePopup, { RoutePreferenceOption } from './route-preference-popup'; |
| | | import { queryRoutePreferences, saveRoutePreference } from '../../../api/begin-route'; |
| | | import { formatDurationDisplay, formatTripDistance } from '../../../../utils/Robotaxi-public-method'; |
| | | import { formatRemainingDurationDisplay, normalizeDistanceKm } from '../../../utils/format'; |
| | | import './css-folder/route-selection-panel.less'; |
| | | |
| | | type Props = { |
| | |
| | | <View className='route-select-content'> |
| | | {routeOptions.map((item, index) => { |
| | | const routeData = item.route || {}; |
| | | const time = formatDurationDisplay(routeData.duration || 0); |
| | | const dist = formatTripDistance(routeData.distance || 0); |
| | | const time = formatRemainingDurationDisplay(routeData.duration || 0); |
| | | const distance = normalizeDistanceKm(routeData.distance || 0); |
| | | const dist = distance ? `${distance.value}${distance.unit === 'm' ? '米' : '公里'}` : '--'; |
| | | const isSelected = index === selectedRouteIndex; |
| | | |
| | | return ( |
| New file |
| | |
| | | import Taro from '@tarojs/taro'; |
| | | |
| | | /** 已走过路线缓存键。 */ |
| | | export const PASSED_ROUTE_CACHE_KEY = 'beginRoutePassedRouteCache'; |
| | | |
| | | /** 已走过路线缓存结构。 */ |
| | | export type PassedRouteCacheState = { |
| | | /** 订单 ID。 */ |
| | | orderId: string; |
| | | /** 已走过路线点。 */ |
| | | points: Array<{ latitude: number; longitude: number }>; |
| | | /** 最近更新时间。 */ |
| | | updatedAt: number; |
| | | }; |
| | | |
| | | /** |
| | | * 归一化已走过路线点,仅保留有效经纬度。 |
| | | */ |
| | | const normalizePassedRoutePoints = (points: any[] = []) => { |
| | | return points |
| | | .map((point) => ({ |
| | | latitude: Number(point?.latitude), |
| | | longitude: Number(point?.longitude) |
| | | })) |
| | | .filter((point) => Number.isFinite(point.latitude) && Number.isFinite(point.longitude)); |
| | | }; |
| | | |
| | | /** |
| | | * 清理已走过路线缓存。 |
| | | */ |
| | | export const clearPassedRouteCache = () => { |
| | | Taro.removeStorageSync(PASSED_ROUTE_CACHE_KEY); |
| | | }; |
| | | |
| | | /** |
| | | * 写入已走过路线缓存。 |
| | | */ |
| | | export const writePassedRouteCache = (orderId: string, points: any[]) => { |
| | | const normalizedOrderId = String(orderId || ''); |
| | | const normalizedPoints = normalizePassedRoutePoints(points); |
| | | if (!normalizedOrderId || normalizedPoints.length <= 1) { |
| | | return; |
| | | } |
| | | const nextState: PassedRouteCacheState = { |
| | | orderId: normalizedOrderId, |
| | | points: normalizedPoints, |
| | | updatedAt: Date.now() |
| | | }; |
| | | Taro.setStorageSync(PASSED_ROUTE_CACHE_KEY, nextState); |
| | | }; |
| | | |
| | | /** |
| | | * 读取当前订单对应的已走过路线缓存。 |
| | | */ |
| | | export const readPassedRouteCache = (orderId?: string) => { |
| | | const cachedState = Taro.getStorageSync(PASSED_ROUTE_CACHE_KEY) as PassedRouteCacheState | null; |
| | | if (!cachedState || !Array.isArray(cachedState.points)) { |
| | | return []; |
| | | } |
| | | const normalizedOrderId = String(orderId || ''); |
| | | if (normalizedOrderId && String(cachedState.orderId || '') !== normalizedOrderId) { |
| | | return []; |
| | | } |
| | | return normalizePassedRoutePoints(cachedState.points); |
| | | }; |
| | |
| | | export function normalizeSocketRouteSnapshot(payload: any): RouteSnapshot { |
| | | const route = safeParse(payload?.content?.route, {}); |
| | | const timestamp = Number(payload?.data?.timestamp); |
| | | const speedMps = Number(route?.speed); |
| | | const speedKmh = Number(route?.speed); |
| | | return { |
| | | points: normalizeRoutePoints(payload?.points), |
| | | distance: normalizeDistanceKm(Number(route?.distance)), |
| | | duration: normalizeDurationSeconds(Number(route?.duration)), |
| | | direction: normalizeDirection(payload?.content?.direction), |
| | | speedKmh: Number.isFinite(speedMps) ? speedMps * 3.6 : null, |
| | | speedKmh: Number.isFinite(speedKmh) ? speedKmh : null, |
| | | timestamp: Number.isFinite(timestamp) ? timestamp : null, |
| | | vehicleMoveAlongPoints: normalizeRoutePoints(payload?.servedPoints), |
| | | forceFullRedraw: payload?.forceFullRedraw === true, |
| | |
| | | |
| | | type Deps = { |
| | | tripStatus: TripStatus; |
| | | routePoints: RoutePoint[]; |
| | | speedKmh: number | null; |
| | | mapScale: number; |
| | | setMapScale: (value: number) => void; |
| | | syncActiveTripViewportToVehicle: () => void; |
| | | computeBottomPadding: (points: any[], viewportType: string) => number; |
| | | resolveSafeTopInset: () => number; |
| | | holdDynamicScale: (durationMs?: number) => void; |
| | |
| | | const USER_GESTURE_HOLD_MS = 3000; // 用户手势后暂停速度缩放时长 |
| | | const ORDER_UPDATE_LINE_HOLD_MS = 2500; // ORDER_UPDATE_LINE 全路线入屏后暂停速度缩放时长 |
| | | const SCALE_RESUME_BUFFER_MS = 80; // 冷却窗口结束后的恢复缓冲时间 |
| | | const LOW_SPEED_KMH = 0; // 低速缩放区间起点 |
| | | const HIGH_SPEED_KMH = 60; // 高速缩放区间终点 |
| | | const MIN_DYNAMIC_SCALE = 14; // 高速时允许的最远视角比例尺 |
| | | const MAX_DYNAMIC_SCALE = 18; // 低速时允许的最近视角比例尺 |
| | | 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 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) => { |
| | | const safeSpeed = clamp(speedKmh, LOW_SPEED_KMH, HIGH_SPEED_KMH); |
| | | const speedRatio = (safeSpeed - LOW_SPEED_KMH) / (HIGH_SPEED_KMH - LOW_SPEED_KMH || 1); |
| | | const nextScale = MAX_DYNAMIC_SCALE - (MAX_DYNAMIC_SCALE - MIN_DYNAMIC_SCALE) * speedRatio; |
| | | return Math.round(nextScale / SCALE_STEP) * SCALE_STEP; |
| | | const resolveScaleBySpeed = (speedKmh: number, previousBandIndex: number | null) => { |
| | | const nextBandIndex = resolveStableScaleBandIndex(speedKmh, previousBandIndex); |
| | | return { |
| | | targetScale: DYNAMIC_SCALE_BANDS[nextBandIndex].scale, |
| | | bandIndex: nextBandIndex |
| | | }; |
| | | }; |
| | | |
| | | /** |
| | |
| | | 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]); |
| | |
| | | } |
| | | deps.holdDynamicScale(USER_GESTURE_HOLD_MS); |
| | | scheduleResume(USER_GESTURE_HOLD_MS + SCALE_RESUME_BUFFER_MS); |
| | | }, [deps, scheduleResume]); |
| | | }, [deps.holdDynamicScale, scheduleResume]); |
| | | |
| | | /** |
| | | * ORDER_UPDATE_LINE 渲染完成后,自动执行一次全路线入屏。 |
| | |
| | | }, 0); |
| | | }); |
| | | scheduleResume(ORDER_UPDATE_LINE_HOLD_MS + SCALE_RESUME_BUFFER_MS); |
| | | }, [deps, isActiveStatus, scheduleResume]); |
| | | }, [deps.computeBottomPadding, deps.holdDynamicScale, deps.resolveSafeTopInset, isActiveStatus, scheduleResume]); |
| | | |
| | | useEffect(() => { |
| | | if (!isActiveStatus) { |
| | | clearResumeTimer(); |
| | | speedSamplesRef.current = []; |
| | | scaleBandIndexRef.current = null; |
| | | return; |
| | | } |
| | | const speedKmh = Number(deps.speedKmh); |
| | |
| | | scheduleResume(holdUntil - now + SCALE_RESUME_BUFFER_MS); |
| | | return; |
| | | } |
| | | const targetScale = resolveScaleBySpeed(speedKmh); |
| | | if (Math.abs(targetScale - deps.mapScale) < MIN_SCALE_CHANGE) { |
| | | 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(targetScale); |
| | | }, [deps, isActiveStatus, resumeVersion, scheduleResume]); |
| | | deps.setMapScale(clamp(nextScale, 14, 18)); |
| | | deps.syncActiveTripViewportToVehicle(); |
| | | }, [deps.getDynamicScaleHoldUntil, deps.mapScale, deps.setMapScale, deps.speedKmh, deps.syncActiveTripViewportToVehicle, isActiveStatus, resumeVersion, scheduleResume]); |
| | | |
| | | useEffect(() => { |
| | | return () => { |
| | |
| | | const x = ((target.longitude - sw.longitude) / lngRange) * rect.width; |
| | | const y = ((ne.latitude - target.latitude) / latRange) * rect.height; |
| | | resolve({ |
| | | left: `${rect.left + x}px`, |
| | | top: `${rect.top + y + transformTopOffset}px`, |
| | | x: rect.left + x, |
| | | y: rect.top + y + transformTopOffset, |
| | | }); |
| | | }, |
| | | fail: () => resolve(null) |
| | | }); |
| | | }); |
| | | }; |
| | | |
| | | // 固定层样式:节点锚定在 (0,0),仅通过 transform 位移,减少 left/top 频繁更新导致的重排闪动。 |
| | | const buildTransformOverlayStyle = (position: { x: number; y: number }, anchorTransform: string) => ({ |
| | | position: 'fixed', |
| | | left: '0px', |
| | | top: '0px', |
| | | transform: `translate3d(${position.x}px, ${position.y}px, 0) ${anchorTransform}` |
| | | }); |
| | | |
| | | // 获取并缓存地图容器矩形 |
| | | const ensureMapRect = async () => { |
| | |
| | | if (!pos) return; |
| | | setWaitingRadarStyle({ |
| | | position: 'fixed', |
| | | left: pos.left, |
| | | top: pos.top, |
| | | left: `${pos.x}px`, |
| | | top: `${pos.y}px`, |
| | | transform: 'translate(-50%, -55%)' |
| | | }); |
| | | }; |
| | |
| | | const rect = await ensureMapRect(); |
| | | const pos = await computeScreenPosition(deps.vehicleLocation, rect, 60); |
| | | if (!pos) return; |
| | | setCarCalloutStyle({ |
| | | position: 'fixed', |
| | | left: pos.left, |
| | | top: pos.top, |
| | | transform: 'translate(-50%, -120%)' |
| | | }); |
| | | setCarCalloutStyle(buildTransformOverlayStyle(pos, 'translate(-50%, -120%)')); |
| | | setCarCalloutVisible(true); |
| | | }; |
| | | |
| | |
| | | if (!pos) return; |
| | | setArriveCalloutStyle({ |
| | | position: 'fixed', |
| | | left: pos.left, |
| | | top: pos.top, |
| | | left: `${pos.x}px`, |
| | | top: `${pos.y}px`, |
| | | transform: 'translate(-50%, -120%)' |
| | | }); |
| | | setArriveCalloutVisible(true); |
| | |
| | | } |
| | | setRouteSummaryCalloutStyle({ |
| | | position: 'fixed', |
| | | left: pos.left, |
| | | top: pos.top, |
| | | transform: 'translate(-50%, -100%)' |
| | | left: `${pos.x}px`, |
| | | top: `${pos.y}px`, |
| | | transform: 'translate(0, 0)' |
| | | }); |
| | | setRouteSummaryCalloutVisible(true); |
| | | }; |
| | |
| | | }; |
| | | |
| | | const BLUETOOTH_AUTH_KEY = 'ROBOTAXI_BLUETOOTH_AUTHORIZED'; |
| | | const BLUETOOTH_SCOPE = 'scope.bluetooth' as any; |
| | | 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 startOpenSettingPollingRef = useRef<() => void>(() => {}); |
| | | const startBluetoothStateWatchRef = useRef<() => void>(() => {}); |
| | | const stopBluetoothStateWatchRef = useRef<() => void>(() => {}); |
| | | |
| | | /** |
| | | * 读取当前小程序蓝牙授权状态。 |
| | | */ |
| | | const getMiniappBluetoothAuthState = useCallback(async () => { |
| | | try { |
| | | const setting = await Taro.getSetting(); |
| | | const authSetting = setting?.authSetting || {}; |
| | | if (Object.prototype.hasOwnProperty.call(authSetting, BLUETOOTH_SCOPE)) { |
| | | return authSetting[BLUETOOTH_SCOPE] === true ? 'authorized' : 'denied'; |
| | | } |
| | | } catch (error) { |
| | | } |
| | | return 'unknown'; |
| | | }, []); |
| | | |
| | | /** |
| | | * 已确认拿到小程序蓝牙权限后,统一收口后续初始化与兜底提示。 |
| | | */ |
| | | const finishBluetoothAuthorization = useCallback(async () => { |
| | | Taro.setStorageSync(BLUETOOTH_AUTH_KEY, true); |
| | | setShowAuthBluetoothPopup(false); |
| | | setStatus('authorized'); |
| | | setStatusReady(true); |
| | | startBluetoothAuth(); |
| | | |
| | | const openResult = await openBluetoothAdapterSafe(); |
| | | if (openResult.opened) { |
| | | await refreshBluetoothState(); |
| | | return; |
| | | } |
| | | |
| | | if (openResult.errorType === 'permission_deny') { |
| | | setStatus('unauthorized'); |
| | | try { |
| | | await Taro.openSetting(); |
| | | } catch (openSettingError) { |
| | | console.error('打开设置失败:', openSettingError); |
| | | } |
| | | Taro.showToast({ title: '请先授权小程序蓝牙权限', icon: 'none' }); |
| | | return; |
| | | } |
| | | |
| | | if (openResult.errorType === 'system_off') { |
| | | setStatus('system_off'); |
| | | Taro.showToast({ title: '请先开启系统蓝牙', icon: 'none' }); |
| | | return; |
| | | } |
| | | |
| | | Taro.showToast({ title: '蓝牙初始化失败,请稍后重试', icon: 'none' }); |
| | | await refreshBluetoothState(); |
| | | }, [refreshBluetoothState, startBluetoothAuth]); |
| | | |
| | | useEffect(() => { |
| | | startBluetoothAuthRef.current = startBluetoothAuth; |
| | |
| | | |
| | | // 执行小程序蓝牙授权,成功后展示成功弹窗 |
| | | const handleAuthorizeBluetooth = useCallback(async () => { |
| | | const miniappBluetoothAuthState = await getMiniappBluetoothAuthState(); |
| | | if (miniappBluetoothAuthState === 'denied') { |
| | | try { |
| | | await Taro.openSetting(); |
| | | } catch (openSettingError) { |
| | | console.error('打开设置失败:', openSettingError); |
| | | Taro.showToast({ title: '请先授权小程序蓝牙权限', icon: 'none' }); |
| | | return; |
| | | } |
| | | const latestAuthState = await getMiniappBluetoothAuthState(); |
| | | if (latestAuthState !== 'authorized') { |
| | | Taro.showToast({ title: '请先授权小程序蓝牙权限', icon: 'none' }); |
| | | return; |
| | | } |
| | | await finishBluetoothAuthorization(); |
| | | return; |
| | | } |
| | | |
| | | try { |
| | | await Taro.authorize({ scope: 'scope.bluetooth' as any }); |
| | | await Taro.authorize({ scope: BLUETOOTH_SCOPE }); |
| | | } catch (authorizeError) { |
| | | console.error('授权蓝牙失败:', authorizeError); |
| | | console.log('授权蓝牙失败:', authorizeError); |
| | | try { |
| | | await Taro.openSetting(); |
| | | } catch (openSettingError) { |
| | | console.error('打开设置失败:', openSettingError); |
| | | console.log('打开设置失败:', openSettingError); |
| | | Taro.showToast({ title: '请先授权小程序蓝牙权限', icon: 'none' }); |
| | | return; |
| | | } |
| | | Taro.showToast({ title: '请先授权小程序蓝牙权限', icon: 'none' }); |
| | | return; |
| | | } |
| | | |
| | | // 用户同意授权后先更新 UI,避免出现“同意后仍显示去授权”的滞后感。 |
| | | Taro.setStorageSync(BLUETOOTH_AUTH_KEY, true); |
| | | setShowAuthBluetoothPopup(false); |
| | | setStatus('authorized'); |
| | | setStatusReady(true); |
| | | startBluetoothAuth(); |
| | | |
| | | const openResult = await openBluetoothAdapterSafe(); |
| | | if (openResult.opened) { |
| | | await refreshBluetoothState(); |
| | | return; |
| | | } |
| | | |
| | | if (openResult.errorType === 'permission_deny') { |
| | | setStatus('unauthorized'); |
| | | try { |
| | | await Taro.openSetting(); |
| | | } catch (openSettingError) { |
| | | console.error('打开设置失败:', openSettingError); |
| | | // 忽略打开设置失败,统一走文案提示 |
| | | const latestAuthState = await getMiniappBluetoothAuthState(); |
| | | if (latestAuthState !== 'authorized') { |
| | | Taro.showToast({ title: '请先授权小程序蓝牙权限', icon: 'none' }); |
| | | return; |
| | | } |
| | | Taro.showToast({ title: '请先授权小程序蓝牙权限', icon: 'none' }); |
| | | await finishBluetoothAuthorization(); |
| | | return; |
| | | } |
| | | |
| | | if (openResult.errorType === 'system_off') { |
| | | setStatus('system_off'); |
| | | Taro.showToast({ title: '请先开启系统蓝牙', icon: 'none' }); |
| | | return; |
| | | } |
| | | |
| | | Taro.showToast({ title: '蓝牙初始化失败,请稍后重试', icon: 'none' }); |
| | | await refreshBluetoothState(); |
| | | }, [refreshBluetoothState, openBluetoothAdapterSafe, startBluetoothAuth]); |
| | | await finishBluetoothAuthorization(); |
| | | }, [finishBluetoothAuthorization, getMiniappBluetoothAuthState]); |
| | | |
| | | // 蓝牙卡片点击分发:未开蓝牙->去开启,未授权->弹授权,已授权->弹成功态说明 |
| | | const handleBluetoothCardClick = useCallback(() => { |
| | |
| | | import Taro from '@tarojs/taro'; |
| | | import { useEffect, useRef, useState } from 'react'; |
| | | import { TripStatus } from '../constants/trip-status'; |
| | | import changeSiteUpIcon from '../../../assets/change-site-up.png'; |
| | | |
| | | const CHANGE_SITE_UP_ICON_URL = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/change-site-up.png'; // 行程中修改目的地时的上车点图标 |
| | | |
| | | type Deps = { |
| | | tripStatus: TripStatus; |
| | |
| | | const createPickupMarker = (pickupSite: any, useAndroidNativeTrip1Callout: boolean) => { |
| | | if (!(pickupSite?.latitude && pickupSite?.longitude)) return null; |
| | | const shouldUseChangePickupIcon = deps.isModifyDestinationReviewVisible && deps.tripStatus >= TripStatus.VehicleArrivedPickup; // 修改目的地确认态且进入行程阶段时使用换点上车图标 |
| | | const shouldHidePickupName = deps.tripStatus === TripStatus.InService && deps.isModifyDestinationReviewVisible; // 行程中修改目的地时只展示换点图标,不展示上车点名称 |
| | | return { |
| | | id: 1, |
| | | latitude: pickupSite.latitude, |
| | | longitude: pickupSite.longitude, |
| | | iconPath: shouldUseChangePickupIcon |
| | | ? changeSiteUpIcon |
| | | ? CHANGE_SITE_UP_ICON_URL |
| | | : 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/maker-up.png', |
| | | width: 22, |
| | | height: 40, |
| | | anchor: { x: 0.5, y: 1 }, |
| | | zIndex: 1, |
| | | name: pickupSite?.name || '上车点', |
| | | hideNameCallout: deps.isModifyDestinationReviewVisible, // 修改目的地确认态隐藏上车点名称气泡 |
| | | ...(useAndroidNativeTrip1Callout |
| | | hideNameCallout: deps.isModifyDestinationReviewVisible || shouldHidePickupName, // 修改目的地确认态隐藏上车点名称气泡 |
| | | ...(!shouldHidePickupName && useAndroidNativeTrip1Callout |
| | | ? { |
| | | callout: { |
| | | content: (deps.tripStatus === TripStatus.PublishRoute || deps.showTrip1StyleEndpoint) ? `${pickupSite?.name || '上车点'} >` : (pickupSite?.name || '上车点'), |
| | |
| | | textAlign: 'center', |
| | | }, |
| | | } |
| | | : { |
| | | : !shouldHidePickupName ? { |
| | | customCallout: { anchorY: [TripStatus.Publishing, TripStatus.CallingCar].includes(deps.tripStatus) ? 150 : -5, anchorX: 0, display: 'ALWAYS' }, |
| | | }) |
| | | } : {}) |
| | | }; |
| | | }; |
| | | |
| | |
| | | ...mergedMarkers[nextCarIndex], |
| | | latitude: prevCar.latitude, |
| | | longitude: prevCar.longitude, |
| | | rotate: prevCar.rotate |
| | | // 平移态仅保留当前位置,朝向始终以最新车辆状态为准,避免旧 marker 角度覆盖最新 socket 方向。 |
| | | rotate: typeof mergedMarkers[nextCarIndex]?.rotate === 'number' |
| | | ? mergedMarkers[nextCarIndex].rotate |
| | | : prevCar.rotate |
| | | }; |
| | | return mergedMarkers; |
| | | }); |
| | |
| | | |
| | | const DROPOFF_MAX_SCALE = 20; |
| | | const DEFAULT_DYNAMIC_SCALE_HOLD_MS = 2500; // 一次性视口调整后,暂停速度缩放的默认冷却时长 |
| | | const ACTIVE_TRIP_VIEWPORT_SYNC_MIN_DELTA = 0.00001; // 车辆中心跟随的最小经纬度变化,约 1 米,避免重复写入中心点状态 |
| | | |
| | | export default function useMapViewport(rawDeps?: Partial<Deps>) { |
| | | const deps = { ...defaultDeps, ...(rawDeps || {}) } as Deps; |
| | |
| | | .catch(() => { }); |
| | | }; |
| | | |
| | | /** |
| | | * 行程动态缩放生效时,将地图中心跟随到车辆位置,确保车辆留在当前视野内。 |
| | | */ |
| | | const syncActiveTripViewportToVehicle = useCallback(() => { |
| | | const vehicleLatitude = Number(deps.vehicleLocation?.latitude); |
| | | const vehicleLongitude = Number(deps.vehicleLocation?.longitude); |
| | | if (!Number.isFinite(vehicleLatitude) || !Number.isFinite(vehicleLongitude)) { |
| | | return; |
| | | } |
| | | const currentLatitude = Number(deps.latitude); |
| | | const currentLongitude = Number(deps.longitude); |
| | | const hasLatitudeDelta = !Number.isFinite(currentLatitude) || Math.abs(currentLatitude - vehicleLatitude) >= ACTIVE_TRIP_VIEWPORT_SYNC_MIN_DELTA; |
| | | const hasLongitudeDelta = !Number.isFinite(currentLongitude) || Math.abs(currentLongitude - vehicleLongitude) >= ACTIVE_TRIP_VIEWPORT_SYNC_MIN_DELTA; |
| | | if (!hasLatitudeDelta && !hasLongitudeDelta) { |
| | | return; |
| | | } |
| | | deps.setLatitude(vehicleLatitude); |
| | | deps.setLongitude(vehicleLongitude); |
| | | }, [deps.latitude, deps.longitude, deps.setLatitude, deps.setLongitude, deps.vehicleLocation?.latitude, deps.vehicleLocation?.longitude]); |
| | | |
| | | useEffect(() => { |
| | | if ([TripStatus.InService, TripStatus.ArrivedDestination].includes(deps.tripStatus)) { |
| | | setShowLocationFlag(false); |
| | |
| | | computeBottomPadding, |
| | | holdDynamicScale, |
| | | getDynamicScaleHoldUntil, |
| | | syncActiveTripViewportToVehicle, |
| | | updateMapViewportPadding, |
| | | toOrigin, |
| | | getLocationInfo |
| | |
| | | setEndSite: (site: any) => void; |
| | | toSelectSite: (pageType: 'start' | 'end', keyword?: string) => void; |
| | | onModifySuccess?: (restoredRoutePoints?: any[]) => void; |
| | | onCancelReview?: (payload: { |
| | | previousEndSite: any; |
| | | previousFareEstimate: any; |
| | | previousRoutePoints: any[]; |
| | | }) => void; |
| | | }; |
| | | |
| | | type ExitModifyDestinationReviewOptions = { |
| | |
| | | |
| | | if (session?.active && isValidSite(candidate)) { |
| | | if (isSameSite(session.previousEndSite, candidate)) { |
| | | deps.onCancelReview?.({ |
| | | previousEndSite: session.previousEndSite || deps.endSite || null, |
| | | previousFareEstimate: session.previousFareEstimate || null, |
| | | previousRoutePoints: Array.isArray(cachedRoutePoints) ? cachedRoutePoints : [] |
| | | }); |
| | | clearModifyDestinationSession(); |
| | | setPreviewEndSite(null); |
| | | setPreviousEndSite(null); |
| | |
| | | setPreviousEndSite(null); |
| | | setPreviousFareEstimate(null); |
| | | setPreviousRoutePoints([]); |
| | | if (isValidSite(cachedOverride)) { |
| | | Taro.removeStorageSync(MODIFY_DESTINATION_OVERRIDE_KEY); |
| | | setOverrideEndSite(null); |
| | | } |
| | | }, [ |
| | | deps.tripStatus, |
| | | deps.orderId, |
| | |
| | | const clearLocalDestinationOverride = () => { |
| | | setOverrideEndSite(null); |
| | | Taro.removeStorageSync(MODIFY_DESTINATION_OVERRIDE_KEY); |
| | | }; |
| | | |
| | | /** |
| | | * 预览态下同步旧目的地路线,供置灰路线与取消预览后的回滚复用。 |
| | | */ |
| | | const updatePreviousRoutePoints = (points: any[]) => { |
| | | const safeRoutePoints = Array.isArray(points) ? points : []; |
| | | setPreviousRoutePoints(safeRoutePoints); |
| | | if (Taro.getStorageSync(MODIFY_DESTINATION_SESSION_KEY)?.active) { |
| | | Taro.setStorageSync(MODIFY_DESTINATION_ROUTE_POINTS_KEY, safeRoutePoints); |
| | | } |
| | | }; |
| | | |
| | | /** |
| | |
| | | previousEndSite, |
| | | previousFareEstimate, |
| | | previousRoutePoints, |
| | | updatePreviousRoutePoints, |
| | | isConfirmingModifyDestination, |
| | | isModifyDestinationReviewVisible, |
| | | hasLocalDestinationOverride, |
| | |
| | | const selectedRoute = deps.routeOptions[deps.selectedRouteIndex]; |
| | | if (selectedRoute?.route?.routeID) { |
| | | payload.selectedRouteId = selectedRoute.route.routeID; |
| | | payload.navigationSnapshotId = selectedRoute.route.routeID; |
| | | payload.navigationSnapshotId = selectedRoute.snapshotId; |
| | | } |
| | | } |
| | | |
| | |
| | | MODIFY_DESTINATION_ROUTE_POINTS_KEY, |
| | | MODIFY_DESTINATION_SESSION_KEY |
| | | } from '../constants/modify-destination'; |
| | | import { clearPassedRouteCache } from './passed-route-cache'; |
| | | |
| | | type Deps = { |
| | | tripStatus: TripStatus; |
| | |
| | | */ |
| | | const toBack = () => { |
| | | clearModifyDestinationStorage(); |
| | | clearPassedRouteCache(); |
| | | if (deps.tripStatus === TripStatus.PaymentSuccess || deps.tripStatus === TripStatus.PublishRoute) { |
| | | clearOrderTripStorage(); |
| | | Taro.removeStorageSync('endSite'); |
| | |
| | | import { queryVehicleByOrderId } from '../../../api/begin-route'; |
| | | import { toTripStatus, TripStatus } from '../constants/trip-status'; |
| | | import { MODIFY_DESTINATION_SESSION_KEY } from '../constants/modify-destination'; |
| | | import { readPassedRouteCache } from './passed-route-cache'; |
| | | |
| | | type Deps = { |
| | | wsHost: string; |
| | |
| | | setTripStatus: (value: TripStatus) => void; |
| | | setStartSite: (value: any) => void; |
| | | setEndSite: (value: any) => void; |
| | | setVehicleMoveAlongPoints: (value: any[]) => void; |
| | | setTrunkStatus: (value: boolean) => void; |
| | | setLatitude: (value: number | null) => void; |
| | | setLongitude: (value: number | null) => void; |
| | |
| | | const cachedTripStatusStorage = Taro.getStorageSync('tripStatus'); |
| | | const cachedTripStatus = toTripStatus(cachedTripStatusStorage, deps.getCurrentTripStatus()); |
| | | deps.setTripStatus(cachedTripStatus); |
| | | if ([TripStatus.VehicleApproaching, TripStatus.InService].includes(cachedTripStatus)) { |
| | | deps.setVehicleMoveAlongPoints(readPassedRouteCache(String(cachedOrderId || ''))); |
| | | } else { |
| | | deps.setVehicleMoveAlongPoints([]); |
| | | } |
| | | syncCachedOrderVehicle(cachedOrderId); |
| | | syncSitesFromActiveOrder(cachedTripStatusStorage, s, e); |
| | | if (cachedOrderId && [TripStatus.VehicleApproaching, TripStatus.VehicleArrivedPickup, TripStatus.InService, TripStatus.ArrivedDestination, TripStatus.TripFinished, TripStatus.PaymentSuccess].includes(cachedTripStatus)) { |
| | |
| | | buildPolylineLines: (displayRoutePoints: any[]) => any[]; |
| | | }; |
| | | |
| | | const PASSED_ROUTE_POLYLINE_ID = 1999; // 已走过路线浅色折线 ID |
| | | |
| | | /** 统一获取路线降采样上限。 */ |
| | | const resolveRoutePointLimit = (isAndroid: boolean) => ( |
| | | isAndroid ? ANDROID_ROUTE_POINT_LIMIT : DEFAULT_ROUTE_POINT_LIMIT |
| | |
| | | }; |
| | | |
| | | /** |
| | | * 获取修改目的地预览态下的已走过路线点。 |
| | | */ |
| | | const getPassedRoutePoints = () => { |
| | | const shouldShowPassedRoute = deps.tripStatus === TripStatus.InService |
| | | && deps.allowSelectableRoutes |
| | | && Array.isArray(deps.vehicleMoveAlongPoints) |
| | | && deps.vehicleMoveAlongPoints.length > 1; |
| | | if (!shouldShowPassedRoute) { |
| | | return []; |
| | | } |
| | | return reducePoints(deps.vehicleMoveAlongPoints || [], resolveRoutePointLimit(deps.isAndroid)); |
| | | }; |
| | | |
| | | /** |
| | | * 构建 polyline 渲染签名,避免重复 setState。 |
| | | */ |
| | | const buildPolylineKey = (displayRoutePoints: any[]) => { |
| | | const passedRoutePoints = getPassedRoutePoints(); |
| | | const lastRoutePoint = displayRoutePoints[displayRoutePoints.length - 1]; |
| | | const lastPassedRoutePoint = passedRoutePoints[passedRoutePoints.length - 1]; |
| | | const lastWalkingPoint = walkingPoints[walkingPoints.length - 1]; |
| | | return [ |
| | | deps.shouldShowRoute ? 1 : 0, |
| | | deps.statusRule.showWalking ? 1 : 0, |
| | | displayRoutePoints.length, |
| | | passedRoutePoints.length, |
| | | walkingPoints.length, |
| | | lastRoutePoint?.latitude ?? '', |
| | | lastRoutePoint?.longitude ?? '', |
| | | lastPassedRoutePoint?.latitude ?? '', |
| | | lastPassedRoutePoint?.longitude ?? '', |
| | | lastWalkingPoint?.latitude ?? '', |
| | | lastWalkingPoint?.longitude ?? '', |
| | | allRoutePoints.length, |
| | |
| | | */ |
| | | const buildPolylineLines = (displayRoutePoints: any[]) => { |
| | | const lines: any[] = []; |
| | | const passedRoutePoints = getPassedRoutePoints(); |
| | | if (passedRoutePoints.length > 1) { |
| | | lines.push({ |
| | | id: PASSED_ROUTE_POLYLINE_ID, |
| | | points: passedRoutePoints, |
| | | color: '#D7DDEF', |
| | | width: 10, |
| | | clickable: false, |
| | | dottedLine: false, |
| | | arrowLine: false, |
| | | }); |
| | | } |
| | | if (deps.shouldShowRoute && deps.allowSelectableRoutes && allRoutePoints.length > 1) { |
| | | allRoutePoints.forEach((points, index) => { |
| | | if (!points?.length) { |
| | |
| | | lastPolylineUpdateAtRef.current = 0; |
| | | }, [ |
| | | allRoutePoints, |
| | | deps.allowSelectableRoutes, |
| | | deps.isAndroid, |
| | | deps.shouldShowRoute, |
| | | deps.statusRule.showWalking, |
| | | deps.tripStatus, |
| | | deps.vehicleMoveAlongPoints, |
| | | routePoints, |
| | | selectedRouteIndex, |
| | | walkingPoints |
| New file |
| | |
| | | import { useEffect, useState } from 'react'; |
| | | import { preloadImageToTemp } from '../../../../utils/Robotaxi-public-method'; |
| | | import { TripStatus } from '../constants/trip-status'; |
| | | |
| | | /** 地图资源在线地址集合。 */ |
| | | type MapAssetUrls = { |
| | | /** 自定义气泡箭头图地址。 */ |
| | | customCalloutIconUrl: string; |
| | | /** 等待态气泡背景图地址。 */ |
| | | waitingCalloutBgUrl: string; |
| | | /** 等待态雷达图地址。 */ |
| | | waitingRadarUrl: string; |
| | | /** 路线概览气泡背景图地址。 */ |
| | | routeSummaryBgUrl: string; |
| | | }; |
| | | |
| | | /** Hook 入参。 */ |
| | | type Params = { |
| | | /** 当前行程状态。 */ |
| | | tripStatus: TripStatus; |
| | | /** 地图资源地址集合。 */ |
| | | assetUrls: MapAssetUrls; |
| | | }; |
| | | |
| | | /** Hook 返回值。 */ |
| | | type UsePreloadedMapAssetsResult = { |
| | | /** 自定义气泡箭头图本地地址。 */ |
| | | customCalloutIconSrc: string; |
| | | /** 等待态气泡背景图本地地址。 */ |
| | | waitingCalloutBgSrc: string; |
| | | /** 等待态雷达图本地地址。 */ |
| | | waitingRadarSrc: string; |
| | | /** 路线概览气泡背景图本地地址。 */ |
| | | routeSummaryBgSrc: string; |
| | | }; |
| | | |
| | | /** |
| | | * 统一预加载 BeginRoute 地图相关图片资源。 |
| | | */ |
| | | export default function usePreloadedMapAssets(params: Params): UsePreloadedMapAssetsResult { |
| | | const [customCalloutIconSrc, setCustomCalloutIconSrc] = useState(params.assetUrls.customCalloutIconUrl); // 自定义气泡箭头图本地地址 |
| | | const [waitingCalloutBgSrc, setWaitingCalloutBgSrc] = useState(params.assetUrls.waitingCalloutBgUrl); // 等待态气泡背景图本地地址 |
| | | const [waitingRadarSrc, setWaitingRadarSrc] = useState(params.assetUrls.waitingRadarUrl); // 等待态雷达图本地地址 |
| | | const [routeSummaryBgSrc, setRouteSummaryBgSrc] = useState(params.assetUrls.routeSummaryBgUrl); // 路线概览气泡背景图本地地址 |
| | | |
| | | /** |
| | | * 预加载单张图片并在组件仍挂载时回写结果。 |
| | | */ |
| | | const preloadSingleAsset = async (url: string, setter: (value: string) => void, cancelledRef: { current: boolean }) => { |
| | | const local = await preloadImageToTemp(url); |
| | | if (!cancelledRef.current) { |
| | | setter(local); |
| | | } |
| | | }; |
| | | |
| | | useEffect(() => { |
| | | const cancelledRef = { current: false }; |
| | | preloadSingleAsset(params.assetUrls.customCalloutIconUrl, setCustomCalloutIconSrc, cancelledRef); |
| | | preloadSingleAsset(params.assetUrls.routeSummaryBgUrl, setRouteSummaryBgSrc, cancelledRef); |
| | | return () => { |
| | | cancelledRef.current = true; |
| | | }; |
| | | }, [params.assetUrls.customCalloutIconUrl, params.assetUrls.routeSummaryBgUrl]); |
| | | |
| | | useEffect(() => { |
| | | if (![TripStatus.Publishing, TripStatus.CallingCar].includes(params.tripStatus)) { |
| | | return; |
| | | } |
| | | const cancelledRef = { current: false }; |
| | | preloadSingleAsset(params.assetUrls.waitingCalloutBgUrl, setWaitingCalloutBgSrc, cancelledRef); |
| | | preloadSingleAsset(params.assetUrls.waitingRadarUrl, setWaitingRadarSrc, cancelledRef); |
| | | return () => { |
| | | cancelledRef.current = true; |
| | | }; |
| | | }, [params.tripStatus, params.assetUrls.waitingCalloutBgUrl, params.assetUrls.waitingRadarUrl]); |
| | | |
| | | return { |
| | | customCalloutIconSrc, |
| | | waitingCalloutBgSrc, |
| | | waitingRadarSrc, |
| | | routeSummaryBgSrc |
| | | }; |
| | | } |
| | |
| | | computeBottomPadding: (points: any[], viewportType: string) => number; |
| | | /** 节流更新车辆位置。 */ |
| | | updateVehicleLocationThrottled: (lat: number, lng: number, force?: boolean) => void; |
| | | /** 已行驶轨迹点(servedPoints)。 */ |
| | | vehicleMoveAlongPoints?: any[]; |
| | | /** 设置车辆朝向。 */ |
| | | setVehicleDirection: (value: number) => void; |
| | | /** 设置订单路线摘要。 */ |
| | |
| | | resolveSafeTopInset: () => 0, |
| | | computeBottomPadding: () => 280, |
| | | updateVehicleLocationThrottled: () => { }, |
| | | vehicleMoveAlongPoints: [], |
| | | setVehicleDirection: () => { } |
| | | }; |
| | |
| | | import useRouteRequest from './use-route-request'; |
| | | import { Deps, defaultDeps, RouteDistanceValue } from './use-route-manager.shared'; |
| | | |
| | | type UseRouteManagerOptions = { |
| | | /** 接口回显主路线后,用于同步初始化平滑擦线基线。 */ |
| | | syncSmoothRouteBaselineRef?: React.MutableRefObject<((snapshot: any) => void) | null>; |
| | | /** 最近一次由 socket 写入车辆朝向的时间。 */ |
| | | lastSocketDirectionAtRef?: React.MutableRefObject<number>; |
| | | }; |
| | | |
| | | /** |
| | | * 聚合路线请求、渲染与交互逻辑,向页面保持稳定接口。 |
| | | */ |
| | | export default function useRouteManager(rawDeps?: Partial<Deps>) { |
| | | export default function useRouteManager(rawDeps?: Partial<Deps>, options?: UseRouteManagerOptions) { |
| | | /** 当前路由依赖。 */ |
| | | const deps = { ...defaultDeps, ...(rawDeps || {}) } as Deps; |
| | | /** 路线选项集合。 */ |
| | |
| | | currentFullRouteRef: renderState.currentFullRouteRef, |
| | | lastErasedIndexRef: renderState.lastErasedIndexRef, |
| | | orderRouteInitializedRef: renderState.orderRouteInitializedRef, |
| | | syncRouteViewport: interactionState.syncRouteViewport |
| | | syncRouteViewport: interactionState.syncRouteViewport, |
| | | syncSmoothRouteBaselineRef: options?.syncSmoothRouteBaselineRef, |
| | | lastSocketDirectionAtRef: options?.lastSocketDirectionAtRef |
| | | }); |
| | | |
| | | return { |
| | |
| | | import { ensureSelectedCityByLocation } from '../../../utils/city'; |
| | | import { normalizeDistanceKm, normalizeDurationSeconds } from '../../../utils/format'; |
| | | import { TripStatus } from '../constants/trip-status'; |
| | | import type { RouteSnapshot } from './runtime-sync'; |
| | | import { |
| | | Deps, |
| | | RequestRouteAndFareOptions, |
| | |
| | | orderRouteInitializedRef: React.MutableRefObject<boolean>; |
| | | /** 同步多路线视口。 */ |
| | | syncRouteViewport: (routePointGroups: any[][], nextWalkingPoints?: any[]) => void; |
| | | /** 接口回显主路线后,同步初始化平滑擦线基线。 */ |
| | | syncSmoothRouteBaselineRef?: React.MutableRefObject<((snapshot: RouteSnapshot) => void) | null>; |
| | | /** 最近一次由 socket 写入车辆朝向的时间。 */ |
| | | lastSocketDirectionAtRef?: React.MutableRefObject<number>; |
| | | }; |
| | | |
| | | /** 路线请求 Hook 返回值。 */ |
| | |
| | | ...(item || {}), |
| | | snapshotId: item?.snapshotId ?? snapshotId, |
| | | generatedAtMillis: item?.generatedAtMillis ?? generatedAtMillis |
| | | }); |
| | | |
| | | /** 当 socket 已提供较新的朝向时,接口方向只作为兜底,不再覆盖实时值。 */ |
| | | const SOCKET_DIRECTION_PRIORITY_WINDOW_MS = 4000; |
| | | |
| | | /** 将订单路线接口响应组装成平滑擦线基线快照。 */ |
| | | const buildOrderRouteSnapshot = ( |
| | | response: any, |
| | | points: Array<{ longitude: number; latitude: number }>, |
| | | direction: number | null |
| | | ): RouteSnapshot => ({ |
| | | points, |
| | | distance: normalizeDistanceKm(Number(response?.distance)), |
| | | duration: normalizeDurationSeconds(Number(response?.duration)), |
| | | direction, |
| | | speedKmh: Number.isFinite(Number(response?.speed)) ? Number(response?.speed) : null, |
| | | timestamp: Date.now(), |
| | | vehicleMoveAlongPoints: [], |
| | | forceFullRedraw: true, |
| | | eraseVehiclePointMode: 'first_then_served_last', |
| | | normalVehiclePointMode: 'served_last_then_first' |
| | | }); |
| | | |
| | | /** |
| | |
| | | currentFullRouteRef, |
| | | lastErasedIndexRef, |
| | | orderRouteInitializedRef, |
| | | syncRouteViewport |
| | | syncRouteViewport, |
| | | syncSmoothRouteBaselineRef, |
| | | lastSocketDirectionAtRef |
| | | } = params; |
| | | |
| | | /** 路线请求签名引用。 */ |
| | |
| | | }); |
| | | |
| | | const normalizedDirection = resolveDirectionValue(response); |
| | | if (typeof normalizedDirection === 'number') { |
| | | deps.setVehicleDirection(normalizedDirection); |
| | | const hasFreshSocketDirection = Date.now() - Number(lastSocketDirectionAtRef?.current || 0) < SOCKET_DIRECTION_PRIORITY_WINDOW_MS; |
| | | const fallbackDirection = hasFreshSocketDirection ? null : normalizedDirection; |
| | | if (typeof fallbackDirection === 'number') { |
| | | deps.setVehicleDirection(fallbackDirection); |
| | | } |
| | | if ([TripStatus.VehicleApproaching, TripStatus.InService].includes(status) && points.length > 0) { |
| | | const routeSnapshot = buildOrderRouteSnapshot(response, points, fallbackDirection); |
| | | syncSmoothRouteBaselineRef?.current?.(routeSnapshot); |
| | | } |
| | | }) |
| | | .catch(() => { }); |
| | |
| | | import Taro, { useDidHide } from '@tarojs/taro'; |
| | | import { useEffect } from 'react'; |
| | | import { preloadImageToTemp } from '../../../../utils/Robotaxi-public-method'; |
| | | import { getOrderDetail } from '../../../api/order'; |
| | | import { TripStatus } from '../constants/trip-status'; |
| | | |
| | |
| | | setShowCustomerService: (value: boolean) => void; |
| | | /** 车辆信息 */ |
| | | vehicleInfo: any; |
| | | /** 等待背景图 url */ |
| | | waitingCalloutBgUrl: string; |
| | | /** 等待雷达图 url */ |
| | | waitingRadarUrl: string; |
| | | /** 自定义气泡图 url */ |
| | | customCalloutIconUrl: string; |
| | | /** 设置等待背景图 */ |
| | | setWaitingCalloutBgSrc: (value: string) => void; |
| | | /** 设置等待雷达图 */ |
| | | setWaitingRadarSrc: (value: string) => void; |
| | | /** 设置气泡图 */ |
| | | setCustomCalloutIconSrc: (value: string) => void; |
| | | /** 费用信息 */ |
| | | fareEstimate: any; |
| | | /** 请求预估路线 */ |
| | |
| | | |
| | | useEffect(() => { |
| | | if (deps.tripStatus === TripStatus.PublishRoute) checkAndShowPassengerNotice(); |
| | | if (![TripStatus.Publishing, TripStatus.CallingCar].includes(deps.tripStatus)) return; |
| | | let cancelled = false; |
| | | const load = async (url: string, setter: (value: string) => void) => { |
| | | const local = await preloadImageToTemp(url); |
| | | if (!cancelled) setter(local); |
| | | }; |
| | | load(deps.waitingCalloutBgUrl, deps.setWaitingCalloutBgSrc); |
| | | load(deps.waitingRadarUrl, deps.setWaitingRadarSrc); |
| | | return () => { |
| | | cancelled = true; |
| | | }; |
| | | }, [deps.tripStatus]); |
| | | |
| | | useEffect(() => { |
| | | let cancelled = false; |
| | | const run = async () => { |
| | | const local = await preloadImageToTemp(deps.customCalloutIconUrl); |
| | | if (!cancelled) deps.setCustomCalloutIconSrc(local); |
| | | }; |
| | | run(); |
| | | return () => { cancelled = true; }; |
| | | }, []); |
| | | |
| | | useEffect(() => { |
| | | Taro.setStorageSync('tripStatus', deps.tripStatus); |
| | |
| | | import Taro from '@tarojs/taro'; |
| | | import { memo, useCallback, useMemo } from 'react'; |
| | | import { CoverImage, CoverView, Image, View } from '@tarojs/components'; |
| | | import ReleaseRoute from '../components/release-route'; |
| | | import ReleaseBeBeing from '../components/release-be-being'; |
| | |
| | | import ModifyDestinationReview from '../components/modify-destination-review'; |
| | | import { TripStatus } from '../constants/trip-status'; |
| | | import { formatRemainingDurationDisplay } from '../../../utils/format'; |
| | | import summaryIcon from '../../../assets/summary-icon.png'; |
| | | |
| | | type Deps = { |
| | | /** 是否安卓 */ |
| | |
| | | hasBeforeUserEndSite: boolean; |
| | | /** 自定义气泡图标 */ |
| | | customCalloutIconSrc: string; |
| | | /** 路线概览气泡背景图 */ |
| | | routeSummaryBgSrc: string; |
| | | /** 是否使用下单前端点气泡样式 */ |
| | | showTrip1StyleEndpoint: boolean; |
| | | /** 上车点 */ |
| | |
| | | queuePollingTimeSeconds: number; |
| | | }; |
| | | |
| | | type DurationDisplay = { |
| | | value: string | number; |
| | | unit: string; |
| | | }; |
| | | |
| | | type AndroidCarCalloutContentProps = { |
| | | hasBeforeUserEndSite: boolean; |
| | | routeDistance: { value: number; unit: 'km' | 'm' } | null; |
| | | durationDisplay: DurationDisplay; |
| | | }; |
| | | |
| | | /** |
| | | * Android 车辆气泡内容独立 memo,位置变化时复用内容节点,降低快速移动时的闪烁感。 |
| | | */ |
| | | const AndroidCarCalloutContent = memo(function AndroidCarCalloutContent(props: AndroidCarCalloutContentProps) { |
| | | const { hasBeforeUserEndSite, routeDistance, durationDisplay } = props; |
| | | return ( |
| | | <View className="custom-callout-content car-callout-with-before" style={{ padding: '6px 10px' }}> |
| | | {hasBeforeUserEndSite && ( |
| | | <View className="car-time car-before-order-tip">即将完成上一单</View> |
| | | )} |
| | | <View className="car-time" style={{ alignItems: 'flex-end' }}> |
| | | <View className="car-time-num">{routeDistance?.value ?? 0}</View> |
| | | <View style={{ flexShrink: 0 }}>{routeDistance?.unit === 'm' ? '米' : '公里'}</View> |
| | | <View style={{ marginLeft: '2.5px' }} className="car-time-num">{durationDisplay.value}</View> |
| | | <View style={{ flexShrink: 0 }}>{durationDisplay.unit}</View> |
| | | </View> |
| | | </View> |
| | | ); |
| | | }, (prevProps, nextProps) => ( |
| | | prevProps.hasBeforeUserEndSite === nextProps.hasBeforeUserEndSite |
| | | && prevProps.routeDistance?.value === nextProps.routeDistance?.value |
| | | && prevProps.routeDistance?.unit === nextProps.routeDistance?.unit |
| | | && prevProps.durationDisplay.value === nextProps.durationDisplay.value |
| | | && prevProps.durationDisplay.unit === nextProps.durationDisplay.unit |
| | | )); |
| | | |
| | | /** |
| | | * 页面展示委托:地图覆盖物与行程卡片渲染。 |
| | | */ |
| | | export default function useViewDelegates(deps: Deps) { |
| | | /** 时长文案 */ |
| | | const durationDisplay = formatRemainingDurationDisplay(deps.routeDuration); |
| | | const routeSummaryText = deps.routeDistance && deps.routeDuration !== null |
| | | ? `${durationDisplay.value}${durationDisplay.unit} ${deps.routeDistance.value}${deps.routeDistance.unit === 'm' ? '米' : '公里'}` |
| | | : ''; |
| | | const durationDisplay = useMemo(() => formatRemainingDurationDisplay(deps.routeDuration), [deps.routeDuration]); |
| | | const routeSummaryText = useMemo(() => ( |
| | | deps.routeDistance && deps.routeDuration !== null |
| | | ? `${durationDisplay.value}${durationDisplay.unit} ${deps.routeDistance.value}${deps.routeDistance.unit === 'm' ? '米' : '公里'}` |
| | | : '' |
| | | ), [deps.routeDistance, deps.routeDuration, durationDisplay]); |
| | | const shouldHideAndroidMarkerCallout = deps.isAndroid && [TripStatus.PublishRoute, TripStatus.VehicleArrivedPickup, TripStatus.TripFinished, TripStatus.PaymentSuccess, TripStatus.CancelOnlyPickup].includes(deps.tripStatus); // Android 指定状态下隐藏常规 marker 气泡 |
| | | const androidCarCalloutContent = useMemo(() => ( |
| | | <AndroidCarCalloutContent |
| | | hasBeforeUserEndSite={deps.hasBeforeUserEndSite} |
| | | routeDistance={deps.routeDistance} |
| | | durationDisplay={durationDisplay} |
| | | /> |
| | | ), [deps.hasBeforeUserEndSite, deps.routeDistance, durationDisplay]); |
| | | |
| | | /** |
| | | * 渲染等待态气泡。 |
| | | */ |
| | | const renderWaitingCallout = (item: any, calloutKey: string) => ( |
| | | const renderWaitingCallout = useCallback((item: any, calloutKey: string) => ( |
| | | <CoverView slot='callout' marker-id={item.id} key={calloutKey} className='waiting-callout'> |
| | | <CoverView className='waiting-callout-content'> |
| | | <CoverView className={`waiting-callout-txt ${!deps.isAndroid && 'pr-6'}`}> |
| | |
| | | </CoverView> |
| | | <CoverImage className='waiting-radar' src={deps.waitingRadarSrc} /> |
| | | </CoverView> |
| | | ); |
| | | ), [deps.isAndroid, deps.waitingRadarSrc]); |
| | | |
| | | /** |
| | | * 渲染默认气泡。 |
| | | */ |
| | | const renderDefaultCallout = (item: any, calloutKey: string) => ( |
| | | const renderDefaultCallout = useCallback((item: any, calloutKey: string) => ( |
| | | <CoverView slot='callout' marker-id={item.id} key={calloutKey} className='custom-callout'> |
| | | {item.isRouteSummaryBubble ? ( |
| | | routeSummaryText ? ( |
| | | <CoverView className='route-summary-callout'> |
| | | <CoverImage className='route-summary-callout-bg' src={summaryIcon} /> |
| | | <CoverImage className='route-summary-callout-bg' src={deps.routeSummaryBgSrc} /> |
| | | <CoverView className='route-summary-callout-text'> |
| | | {routeSummaryText} |
| | | </CoverView> |
| | |
| | | </> |
| | | )} |
| | | </CoverView> |
| | | ); |
| | | ), [ |
| | | deps.arrivedTooltipReady, |
| | | deps.customCalloutIconSrc, |
| | | deps.hasBeforeUserEndSite, |
| | | deps.isAndroid, |
| | | deps.routeDistance, |
| | | deps.routeSummaryBgSrc, |
| | | deps.showTrip1StyleEndpoint, |
| | | deps.tripStatus, |
| | | durationDisplay, |
| | | routeSummaryText |
| | | ]); |
| | | |
| | | /** |
| | | * 渲染单个 marker 气泡。 |
| | | */ |
| | | const renderMarkerCallout = (item: any, index: number) => { |
| | | const renderMarkerCallout = useCallback((item: any, index: number) => { |
| | | const calloutKey = `callout-${item.id}-${item.name || ''}`; |
| | | // 路线摘要气泡需跨平台强制显示,避免被 Android 普通气泡隐藏规则误伤 |
| | | if (item.isRouteSummaryBubble) { |
| | |
| | | if (isWaiting) return renderWaitingCallout(item, calloutKey); |
| | | if (item.name === '车辆位置' && deps.isAndroid && [TripStatus.VehicleApproaching, TripStatus.InService].includes(deps.tripStatus)) return null; |
| | | return renderDefaultCallout(item, calloutKey); |
| | | }; |
| | | }, [deps.isAndroid, deps.tripStatus, renderDefaultCallout, renderWaitingCallout, shouldHideAndroidMarkerCallout]); |
| | | |
| | | /** |
| | | * 渲染地图覆盖层。 |
| | | */ |
| | | const renderMapOverlays = () => ( |
| | | const renderMapOverlays = useCallback(() => ( |
| | | <> |
| | | {deps.markers.map(renderMarkerCallout)} |
| | | {deps.shouldShowWaitingRadar && deps.waitingRadarVisible && ( |
| | |
| | | )} |
| | | {deps.isAndroid && deps.routeSummaryCalloutVisible && routeSummaryText && ( |
| | | <View className="route-summary-callout" style={deps.routeSummaryCalloutStyle}> |
| | | <Image className="route-summary-callout-bg" src={summaryIcon} /> |
| | | <Image className="route-summary-callout-bg" src={deps.routeSummaryBgSrc} /> |
| | | <View className="route-summary-callout-text"> |
| | | {routeSummaryText} |
| | | </View> |
| | |
| | | )} |
| | | {deps.isAndroid && deps.carCalloutVisible && ( |
| | | <View className="car-callout-android" style={deps.carCalloutStyle}> |
| | | <View className="custom-callout-content car-callout-with-before" style={{ padding: '6px 10px' }}> |
| | | {deps.hasBeforeUserEndSite && ( |
| | | <View className="car-time car-before-order-tip">即将完成上一单</View> |
| | | )} |
| | | <View className="car-time" style={{ alignItems: 'flex-end' }}> |
| | | <View className="car-time-num">{deps.routeDistance?.value ?? 0}</View> |
| | | <View style={{ flexShrink: 0 }}>{deps.routeDistance?.unit === 'm' ? '米' : '公里'}</View> |
| | | <View style={{ marginLeft: '2.5px' }} className="car-time-num">{durationDisplay.value}</View> |
| | | <View style={{ flexShrink: 0 }}>{durationDisplay.unit}</View> |
| | | </View> |
| | | </View> |
| | | {androidCarCalloutContent} |
| | | </View> |
| | | )} |
| | | {deps.isAndroid && deps.arriveCalloutVisible && deps.arrivedTooltipReady && ( |
| | |
| | | </View> |
| | | )} |
| | | </> |
| | | ); |
| | | ), [ |
| | | androidCarCalloutContent, |
| | | deps.arriveCalloutStyle, |
| | | deps.arriveCalloutVisible, |
| | | deps.arrivedTooltipReady, |
| | | deps.carCalloutStyle, |
| | | deps.carCalloutVisible, |
| | | deps.isAndroid, |
| | | deps.markers, |
| | | deps.routeSummaryBgSrc, |
| | | deps.routeSummaryCalloutStyle, |
| | | deps.routeSummaryCalloutVisible, |
| | | deps.shouldShowWaitingRadar, |
| | | deps.waitingRadarSrc, |
| | | deps.waitingRadarStyle, |
| | | deps.waitingRadarVisible, |
| | | renderMarkerCallout, |
| | | routeSummaryText |
| | | ]); |
| | | |
| | | /** |
| | | * 渲染底部行程卡片。 |
| | |
| | | import { toTripStatus, TripStatus } from '../constants/trip-status'; |
| | | import { normalizeSocketRouteSnapshot } 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>; |
| | | modifyDestinationPickupSuccessAtRef: React.MutableRefObject<number>; |
| | | modifyDestinationPickupStatusGuardMs: number; |
| | | orderId: string; |
| | | setOrderId: (orderId: string) => void; |
| | | setTripStatus: (status: TripStatus) => void; |
| | |
| | | setShowCancelPopup: (value: boolean) => void; |
| | | checkAndShowPassengerNotice: () => void; |
| | | setVehicleDirection: (value: number) => void; |
| | | lastSocketDirectionAtRef?: React.MutableRefObject<number>; |
| | | setVehicleMoveAlongPoints: (points: any[]) => void; |
| | | setRouteDistance: (value: any) => void; |
| | | setRouteDuration: (value: number) => void; |
| | |
| | | speedKmh: number | null; |
| | | }) => void; |
| | | setBeforeUserEndSite: (site: { longitude: number; latitude: number } | null) => void; |
| | | isModifyDestinationReviewVisible?: boolean; |
| | | hasLocalDestinationOverride?: boolean; |
| | | updatePreviousRoutePoints?: (points: any[]) => void; |
| | | erasePassedRouteSegments: (points: any[], servedPoints?: any[]) => void; |
| | | applyRoutePoints: (points: any[]) => boolean; |
| | | updateVehicleLocationThrottled: (lat: number, lng: number, force?: boolean) => void; |
| | |
| | | deps.setRoutePoints([]); |
| | | deps.setPolyline([]); |
| | | deps.setVehicleMoveAlongPoints([]); |
| | | clearPassedRouteCache(); |
| | | deps.polylineKeyRef.current = ''; |
| | | deps.lastPolylineUpdateAtRef.current = 0; |
| | | deps.lastRoutePointRef.current = null; |
| | |
| | | } |
| | | const currentTripStatus = deps.tripStatusRef.current; |
| | | const mappedTripStatus = toTripStatus(incomingTripStatus, currentTripStatus); |
| | | const modifyDestinationPickupSuccessAt = Number(deps.modifyDestinationPickupSuccessAtRef.current || 0); |
| | | const shouldGuardModifyDestinationPickupStatus = mappedTripStatus === TripStatus.InService |
| | | && [TripStatus.VehicleApproaching, TripStatus.VehicleArrivedPickup].includes(currentTripStatus) |
| | | && modifyDestinationPickupSuccessAt > 0 |
| | | && Date.now() - modifyDestinationPickupSuccessAt <= deps.modifyDestinationPickupStatusGuardMs; |
| | | if (shouldGuardModifyDestinationPickupStatus) { |
| | | deps.modifyDestinationPickupSuccessAtRef.current = 0; |
| | | return currentTripStatus; |
| | | } |
| | | // 行程进行关键态不允许被回退,避免 4/6 被延迟状态覆盖导致路线链路重置 |
| | | if ([TripStatus.VehicleApproaching, TripStatus.InService].includes(currentTripStatus) && mappedTripStatus < currentTripStatus) { |
| | | return currentTripStatus; |
| | |
| | | const updateRouteSummary = (payload: any, deps: UseWebSocketEventsDeps) => { |
| | | const normalizedDirection = resolveDirectionValue(payload); |
| | | if (typeof normalizedDirection === 'number') { |
| | | if (deps.lastSocketDirectionAtRef) { |
| | | deps.lastSocketDirectionAtRef.current = Date.now(); |
| | | } |
| | | deps.setVehicleDirection(normalizedDirection); |
| | | } |
| | | const route = safeParse(payload?.content?.route, {}); |
| | |
| | | }); |
| | | }; |
| | | |
| | | const syncVehiclePointOnly = (routeSnapshot: ReturnType<typeof normalizeSocketRouteSnapshot>, deps: UseWebSocketEventsDeps) => { |
| | | const targetPoint = resolveVehiclePoint( |
| | | routeSnapshot.points, |
| | | routeSnapshot.vehicleMoveAlongPoints, |
| | | routeSnapshot.normalVehiclePointMode |
| | | ); |
| | | if (!targetPoint) { |
| | | return; |
| | | } |
| | | deps.updateVehicleLocationThrottled(targetPoint.latitude, targetPoint.longitude, true); |
| | | }; |
| | | |
| | | const handleOrderRoutePayload = (payload: any, deps: UseWebSocketEventsDeps) => { |
| | | deps.setBeforeUserEndSite(parseBeforeUserEndSite(payload)); |
| | | const currentTripStatus = deps.tripStatusRef.current; |
| | | updateRouteSummary(payload, deps); |
| | | // socket 路线拦截只跟随真实的“修改目的地预览态”,不要被本地终点覆盖态误伤。 |
| | | const isModifyDestinationPreviewActive = Boolean(deps.isModifyDestinationReviewVisible); |
| | | const shouldKeepModifyPreviewSummary = isModifyDestinationPreviewActive; |
| | | if (!shouldKeepModifyPreviewSummary) { |
| | | updateRouteSummary(payload, deps); |
| | | } |
| | | // 行程结束态仅更新概要信息,不再消费路线点,避免视口反复重算造成跳动 |
| | | if ([TripStatus.TripFinished, TripStatus.PaymentSuccess].includes(currentTripStatus)) { |
| | | return; |
| | | } |
| | | const routeSnapshot = normalizeSocketRouteSnapshot(payload); |
| | | const shouldOnlySyncModifyPreviewVehicle = isModifyDestinationPreviewActive && currentTripStatus === TripStatus.InService; |
| | | const shouldFreezeModifyPreviewRoute = isModifyDestinationPreviewActive && currentTripStatus !== TripStatus.InService; |
| | | if (isModifyDestinationPreviewActive) { |
| | | deps.updatePreviousRoutePoints?.(routeSnapshot.points || []); |
| | | } |
| | | if (shouldFreezeModifyPreviewRoute) { |
| | | return; |
| | | } |
| | | if (shouldOnlySyncModifyPreviewVehicle) { |
| | | syncVehiclePointOnly(routeSnapshot, deps); |
| | | return; |
| | | } |
| | | const currentOrderId = String(deps.orderId || Taro.getStorageSync('orderId') || ''); |
| | | if (routeSnapshot.vehicleMoveAlongPoints.length > 1) { |
| | | writePassedRouteCache(currentOrderId, routeSnapshot.vehicleMoveAlongPoints); |
| | | } |
| | | deps.applySmoothRouteSnapshot(routeSnapshot); |
| | | if (routeSnapshot.forceFullRedraw && typeof deps.onOrderUpdateLineRendered === 'function') { |
| | | Taro.nextTick(() => { |
| | |
| | | .car-callout-android { |
| | | z-index: 9; |
| | | pointer-events: none; |
| | | will-change: transform; |
| | | backface-visibility: hidden; |
| | | transition: transform 0.18s linear; |
| | | } |
| | | |
| | | .before-user-site-callout { |
| | |
| | | // 修改目的地流程编排 |
| | | import useModifyDestinationFlow from './hooks/use-modify-destination-flow'; |
| | | import useModifyDestinationPreviewRoute from './hooks/use-modify-destination-preview-route'; |
| | | import usePreloadedMapAssets from './hooks/use-preloaded-map-assets'; |
| | | // 蓝牙状态与弹窗编排 |
| | | import useBluetoothAccess from './hooks/use-bluetooth-access'; |
| | | // 蓝牙车控连接与鉴权编排 |
| | |
| | | import { _ROBOTAXI_WS_HOST } from '../../../constants/api'; |
| | | |
| | | const ROUTE_SUMMARY_MARKER_ID = 999001; |
| | | const MODIFY_DESTINATION_PICKUP_STATUS_GUARD_MS = 5000; |
| | | type StatusRule = { |
| | | showPickup: boolean; |
| | | showDropoff: boolean; |
| | |
| | | const customCalloutIconUrl = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/maker-tooltip-right.png'; // 自定义气泡图标URL |
| | | const waitingCalloutBgUrl = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/maker-tooltip-bg.png'; // 等待气泡背景图URL |
| | | const waitingRadarUrl = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/waiting-radar.webp'; // 等待雷达动画图URL |
| | | const [customCalloutIconSrc, setCustomCalloutIconSrc] = useState(customCalloutIconUrl); // 自定义气泡图标源(本地缓存路径) |
| | | const [waitingCalloutBgSrc, setWaitingCalloutBgSrc] = useState(waitingCalloutBgUrl); // 等待气泡背景图源 |
| | | const [waitingRadarSrc, setWaitingRadarSrc] = useState(waitingRadarUrl); // 等待雷达动画图源 |
| | | const routeSummaryBgUrl = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/summary-icon.png'; // 路线概览气泡背景图URL |
| | | const [isRouteSelectExpanded, setIsRouteSelectExpanded] = useState(false); // 路线选择展开状态 |
| | | const [pageDidShowVersion, setPageDidShowVersion] = useState(0); // 页面展示版本号,用于触发子模块重查 |
| | | const [resetDetailVersion, setResetDetailVersion] = useState(0); // 重置详情版本号,用于触发组件更新 |
| | |
| | | }); |
| | | 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 tripStatusRef = useRef(tripStatus); // 行程状态引用 |
| | | const lastTripStatusRef = useRef(tripStatus); // 上一次行程状态,用于检测状态变化 |
| | | const tripStatusForMapSyncRef = useRef(tripStatus); // 用于地图同步的行程状态引用 |
| | | const modifyDestinationPickupSuccessAtRef = useRef(0); // 接驾态修改目的地成功时间戳,用于短时忽略异常的行程中状态推送 |
| | | useEffect(() => { |
| | | tripStatusRef.current = tripStatus; |
| | | tripStatusForMapSyncRef.current = tripStatus; |
| | |
| | | const shouldShowWaitingRadar = isAndroid && [TripStatus.Publishing, TripStatus.CallingCar].includes(tripStatus); // 是否显示等待雷达动画(Android 且处于发布中/呼叫车中) |
| | | const vehicleMarkerId = !isAndroid && tripStatus === TripStatus.InService ? 30 : 3; // 车辆标记 ID,iOS 行驶中阶段使用不同 ID |
| | | const { |
| | | customCalloutIconSrc, |
| | | waitingRadarSrc, |
| | | routeSummaryBgSrc |
| | | } = usePreloadedMapAssets({ |
| | | tripStatus, |
| | | assetUrls: { |
| | | customCalloutIconUrl, |
| | | waitingCalloutBgUrl, |
| | | waitingRadarUrl, |
| | | routeSummaryBgUrl |
| | | } |
| | | }); // 地图气泡资源预加载 |
| | | const { |
| | | openService, |
| | | toCoupon, |
| | | toPersonalCenter, |
| | |
| | | previousEndSite, |
| | | previousFareEstimate, |
| | | previousRoutePoints, |
| | | updatePreviousRoutePoints, |
| | | isConfirmingModifyDestination, |
| | | isModifyDestinationReviewVisible, |
| | | hasLocalDestinationOverride, |
| | |
| | | endSite, |
| | | setEndSite, |
| | | toSelectSite, |
| | | onModifySuccess: (restoredRoutePoints?: any[]) => { |
| | | onCancelReview: ({ previousEndSite: cancelledPreviousEndSite, previousFareEstimate: cancelledPreviousFareEstimate, previousRoutePoints: cancelledPreviousRoutePoints }) => { |
| | | if ( |
| | | cancelledPreviousEndSite |
| | | && Number.isFinite(Number(cancelledPreviousEndSite?.latitude)) |
| | | && Number.isFinite(Number(cancelledPreviousEndSite?.longitude)) |
| | | ) { |
| | | Taro.setStorageSync('endSite', cancelledPreviousEndSite); |
| | | setEndSite(cancelledPreviousEndSite); |
| | | } |
| | | setIsRouteSelectExpanded(false); |
| | | setRouteOptions([]); |
| | | setRoutePoints(Array.isArray(cancelledPreviousRoutePoints) ? cancelledPreviousRoutePoints : []); |
| | | setFareEstimate(cancelledPreviousFareEstimate || {}); |
| | | if (orderId) { |
| | | routeRequestOrderRoute(orderId, tripStatus); |
| | | } |
| | | }, |
| | | onModifySuccess: () => { |
| | | const shouldGuardPickupStatus = [TripStatus.VehicleApproaching, TripStatus.VehicleArrivedPickup].includes(tripStatus); // 接驾态修改成功后,短时保护页面继续按接驾态渲染 |
| | | modifyDestinationPickupSuccessAtRef.current = shouldGuardPickupStatus ? Date.now() : 0; |
| | | // 成功修改后触发路线重新获取 |
| | | // 下一次渲染时 isModifyDestinationReviewVisible 变为 false |
| | | // 我们需要强制让选路状态退出,同时获取后端的新行程路线 |
| | | setIsRouteSelectExpanded(false); |
| | | setRouteOptions([]); |
| | | setFareEstimate({}); |
| | | const shouldSkipOrderRouteSync = [TripStatus.VehicleApproaching, TripStatus.VehicleArrivedPickup].includes(tripStatus); // 接驾阶段不拉取订单路线,避免误渲染行程中路线 |
| | | if (shouldSkipOrderRouteSync) { |
| | | if (restoredRoutePoints && restoredRoutePoints.length > 0) { |
| | | routeApplyRoutePoints(restoredRoutePoints); |
| | | } else { |
| | | setRoutePoints([]); |
| | | const shouldSyncPickupRoute = [TripStatus.VehicleApproaching, TripStatus.VehicleArrivedPickup].includes(tripStatus); // 接驾态修改成功后重新拉取接驾路线,避免复用预览前缓存路线 |
| | | if (shouldSyncPickupRoute) { |
| | | const cachedStartSite = Taro.getStorageSync('startSite'); // 接驾态上车点与名称始终以缓存站点为准回显 |
| | | if (cachedStartSite?.latitude && cachedStartSite?.longitude) { |
| | | setStartSite(cachedStartSite); |
| | | } |
| | | setRoutePoints([]); |
| | | routeRequestOrderRoute(orderId, tripStatus); |
| | | return; |
| | | } |
| | | setRoutePoints([]); |
| | |
| | | }); // 车辆追踪状态容器 |
| | | const { |
| | | vehicleLocation, |
| | | vehicleMoveAlongPoints, |
| | | vehicleDirection, |
| | | vehicleInfo, |
| | | trunkStatus, |
| | |
| | | setEndSite, |
| | | setBeforeUserEndSite, |
| | | updateVehicleLocationThrottled: vehicleState.updateVehicleLocationThrottled, |
| | | vehicleMoveAlongPoints, |
| | | setVehicleDirection, |
| | | setOrderRouteMetrics, |
| | | setCurrentUserLocation |
| | | }, { |
| | | syncSmoothRouteBaselineRef, |
| | | lastSocketDirectionAtRef |
| | | }); // 路线与费用状态容器 |
| | | const { |
| | | polyline, |
| | |
| | | requestOrderRoute: routeRequestOrderRoute, |
| | | updateWalkingRouteForStart: routeUpdateWalkingRouteForStart |
| | | } = routeState; // 路线状态解构 |
| | | const shouldShowModifyDestinationPreviousRoute = tripStatus === TripStatus.InService; // 仅行程中修改目的地时展示旧目的地浅色对比路线 |
| | | const displayPolyline = useMemo(() => { |
| | | if (!(isModifyDestinationReviewVisible || hasLocalDestinationOverride) || !Array.isArray(previousRoutePoints) || previousRoutePoints.length === 0) { |
| | | if ( |
| | | !shouldShowModifyDestinationPreviousRoute |
| | | || !(isModifyDestinationReviewVisible || hasLocalDestinationOverride) |
| | | || !Array.isArray(previousRoutePoints) |
| | | || previousRoutePoints.length === 0 |
| | | ) { |
| | | return polyline; |
| | | } |
| | | return [ |
| | |
| | | }, |
| | | ...polyline |
| | | ]; |
| | | }, [polyline, previousRoutePoints, isModifyDestinationReviewVisible, hasLocalDestinationOverride]); |
| | | }, [polyline, previousRoutePoints, isModifyDestinationReviewVisible, hasLocalDestinationOverride, shouldShowModifyDestinationPreviousRoute]); |
| | | const routeSummaryMarker = useMemo(() => { |
| | | if (!allowSelectableRoutes || !routeDistance || routeDuration === null || !Array.isArray(routePoints) || routePoints.length === 0) { |
| | | return null; |
| | |
| | | alpha: 0, |
| | | zIndex: 3, |
| | | isRouteSummaryBubble: true, |
| | | customCallout: { anchorY: -0.1, anchorX: 50, display: 'ALWAYS' } |
| | | customCallout: { anchorY: 22, anchorX: 42, display: 'ALWAYS' } |
| | | }; |
| | | }, [allowSelectableRoutes, routeDistance, routeDuration, routePoints]); |
| | | const viewportState = useMapViewport({ |
| | |
| | | toOrigin, |
| | | getLocationInfo, |
| | | computeBottomPadding, |
| | | resolveSafeTopInset |
| | | resolveSafeTopInset, |
| | | syncActiveTripViewportToVehicle |
| | | } = viewportState; // 视口状态解构 |
| | | const activeTripScale = useActiveTripScale({ |
| | | tripStatus, |
| | | routePoints, |
| | | speedKmh: orderRouteMetrics?.speedKmh, |
| | | mapScale, |
| | | setMapScale: viewportState.setMapScale, |
| | | syncActiveTripViewportToVehicle, |
| | | computeBottomPadding, |
| | | resolveSafeTopInset, |
| | | holdDynamicScale, |
| | |
| | | setTripStatus, |
| | | setStartSite, |
| | | setEndSite, |
| | | setVehicleMoveAlongPoints, |
| | | setTrunkStatus, |
| | | setLatitude, |
| | | setLongitude, |
| | |
| | | lastErasedIndexRef, |
| | | setShowCustomerService, |
| | | vehicleInfo, |
| | | waitingCalloutBgUrl, |
| | | waitingRadarUrl, |
| | | customCalloutIconUrl, |
| | | setWaitingCalloutBgSrc, |
| | | setWaitingRadarSrc, |
| | | setCustomCalloutIconSrc, |
| | | fareEstimate, |
| | | routeRequestRouteAndFare, |
| | | routeUpdateWalkingRouteForStart, |
| | |
| | | updateVehicleLocationThrottled: vehicleState.updateVehicleLocationThrottled |
| | | }); |
| | | |
| | | useEffect(() => { |
| | | syncSmoothRouteBaselineRef.current = applySmoothRouteSnapshot; |
| | | return () => { |
| | | syncSmoothRouteBaselineRef.current = null; |
| | | }; |
| | | }, [applySmoothRouteSnapshot]); |
| | | |
| | | // 页面进入后优先拉取一遍下单配置,供轮询频率、步行按钮和重新报价时效复用。 |
| | | useEffect(() => { |
| | | ensureDistanceConfig(); |
| | |
| | | setShowCancelPopup, |
| | | checkAndShowPassengerNotice: tripRuntime.checkAndShowPassengerNotice, |
| | | setVehicleDirection, |
| | | lastSocketDirectionAtRef, |
| | | setVehicleMoveAlongPoints, |
| | | setRouteDistance, |
| | | setRouteDuration, |
| | | setModifyDestinationSocketStatus, |
| | | setOrderRouteMetrics, |
| | | setBeforeUserEndSite, |
| | | isModifyDestinationReviewVisible, |
| | | hasLocalDestinationOverride, |
| | | updatePreviousRoutePoints, |
| | | modifyDestinationPickupSuccessAtRef, |
| | | erasePassedRouteSegments: routeErasePassedRouteSegments, |
| | | applyRoutePoints: routeApplyRoutePoints, |
| | | updateVehicleLocationThrottled: vehicleState.updateVehicleLocationThrottled, |
| | | onOrderUpdateLineRendered: activeTripScale.handleOrderUpdateLineRendered, |
| | | applySmoothRouteSnapshot, |
| | | resetSmoothRouteMotion |
| | | resetSmoothRouteMotion, |
| | | modifyDestinationPickupStatusGuardMs: MODIFY_DESTINATION_PICKUP_STATUS_GUARD_MS |
| | | }); |
| | | |
| | | const orderActions = useOrderActions({ |
| | |
| | | setModifyDestinationFailMessage(message || ''); |
| | | setShowModifyDestinationFailPopup(true); |
| | | }; |
| | | // 失败弹窗确认后退出修改目的地确认态,并恢复修改前路线 |
| | | const handleAcknowledgeModifyDestinationFail = () => { |
| | | setShowModifyDestinationFailPopup(false); |
| | | setModifyDestinationFailMessage(''); |
| | | const modifyDestinationSession = Taro.getStorageSync(MODIFY_DESTINATION_SESSION_KEY); |
| | | const fallbackPreviousEndSite = modifyDestinationSession?.previousEndSite; |
| | | // 修改目的地退出后统一恢复地图路线、上下车点与费用展示,确保“失败”和“取消”表现一致 |
| | | const restoreModifyDestinationDisplay = (fallbackPreviousEndSite: any, fallbackPreviousFareEstimate: any, fallbackPreviousRoutePoints: any[]) => { |
| | | if ( |
| | | fallbackPreviousEndSite |
| | | && Number.isFinite(Number(fallbackPreviousEndSite?.latitude)) |
| | |
| | | exitModifyDestinationReview(); |
| | | setIsRouteSelectExpanded(false); |
| | | setRouteOptions([]); |
| | | setRoutePoints(Array.isArray(previousRoutePoints) ? previousRoutePoints : []); |
| | | setFareEstimate(previousFareEstimate || {}); |
| | | setRoutePoints(Array.isArray(fallbackPreviousRoutePoints) ? fallbackPreviousRoutePoints : []); |
| | | setFareEstimate(fallbackPreviousFareEstimate || {}); |
| | | if (orderId) { |
| | | routeRequestOrderRoute(orderId, tripStatus); |
| | | } |
| | | }; |
| | | // 失败弹窗确认后退出修改目的地确认态,并恢复修改前路线 |
| | | const handleAcknowledgeModifyDestinationFail = () => { |
| | | setShowModifyDestinationFailPopup(false); |
| | | setModifyDestinationFailMessage(''); |
| | | const modifyDestinationSession = Taro.getStorageSync(MODIFY_DESTINATION_SESSION_KEY); |
| | | const fallbackPreviousEndSite = modifyDestinationSession?.previousEndSite; |
| | | restoreModifyDestinationDisplay(fallbackPreviousEndSite, previousFareEstimate, previousRoutePoints); |
| | | }; |
| | | // 确认修改前进行阈值校验,命中限制或接口失败都展示失败弹窗 |
| | | const handleConfirmModifyDestinationWithGuard = async (selectedRoute?: any) => { |
| | |
| | | routeDuration, |
| | | hasBeforeUserEndSite: !!beforeUserEndSite, |
| | | customCalloutIconSrc, |
| | | routeSummaryBgSrc, |
| | | showTrip1StyleEndpoint, |
| | | startSite, |
| | | endSite: effectiveEndSite, |
| | |
| | | const queryCouponCount = async () => { |
| | | try { |
| | | const res = await getUserouponNum(); |
| | | const nextCouponCount = res.data || 0; |
| | | const nextCouponCount = res || 0; |
| | | setCouponCount(nextCouponCount); |
| | | } catch (error) { |
| | | setCouponCount(0); |