feat(robotaxi/begin-route):
修改目的地预览,优化重复请求判断与状态管理
新增路线摘要气泡锚点适配逻辑,修复安卓地图气泡偏移问题
站点收藏选中的站点id转换
温度与车门控制的loading状态最少动画一秒结束
修复修改目的地预览页面的返回逻辑
| | |
| | | import { unlockVehicle, queryVehicleTrunkOpenStatus } from '../../../../api/begin-route'; |
| | | import { getOrderId } from '../../../../utils/order'; |
| | | import { TripStatus } from '../../../../constants/trip-status'; |
| | | import useMinimumSpinLoading from './use-minimum-spin-loading'; |
| | | |
| | | const LOCK_CLOSE_ICON_URL = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/lock-close.png'; |
| | | const LOCK_OPEN_ICON_URL = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/lock-open.png'; |
| | |
| | | }: Props) { |
| | | const [trunkStatus, setTrunkStatus] = useState(orderTrunkStatus); // 后备箱开关状态 |
| | | const [lockStatus, setLockStatus] = useState(false); // 车门开关状态 |
| | | const [loadingControlType, setLoadingControlType] = useState<'door' | 'trunk' | null>(null); // 当前处于指令执行中的控件 |
| | | const trunkPollTimerRef = useRef<any>(null); // 后备箱状态轮询定时器 |
| | | const overRoutePopupDebounceTimerRef = useRef<any>(null); // 结束行程弹窗防抖定时器 |
| | | const OVER_ROUTE_POPUP_DEBOUNCE_MS = 1000; // 结束行程弹窗点击防抖时长 |
| | | const { visibleLoadingKey: loadingControlType, startLoading, stopLoading } = useMinimumSpinLoading<'door' | 'trunk'>(); |
| | | |
| | | // 外部后备箱状态变化时同步本地状态 |
| | | useEffect(() => { |
| | |
| | | Taro.showToast({ title: '获取定位失败,请打开定位开关', icon: 'none' }); |
| | | return; |
| | | } |
| | | setLoadingControlType(doorType); |
| | | startLoading(doorType); |
| | | const isCloseAction = doorType === 'trunk' ? !!trunkStatus : false; |
| | | const actionLoadingText = doorType === 'trunk' |
| | | ? (isCloseAction ? '正在关闭后备箱' : '正在开启后备箱') |
| | |
| | | |
| | | if (res?.code !== 200 || !res?.data) { |
| | | // Taro.hideLoading(); |
| | | setLoadingControlType(null); |
| | | stopLoading(doorType); |
| | | Taro.showToast({ title: res?.msg || '解锁失败,请重试或联系客服', icon: 'none' }); |
| | | return; |
| | | } |
| | |
| | | trunkPollTimerRef.current = setTimeout(doPoll, pollInterval); |
| | | } else { |
| | | stopPolling(); |
| | | setLoadingControlType(null); |
| | | stopLoading(doorType); |
| | | // Taro.hideLoading(); |
| | | Taro.showToast({ |
| | | title: failToastText, |
| | |
| | | stopPolling(); |
| | | const isOpen = !isCloseAction; |
| | | setTrunkStatus(isOpen); |
| | | setLoadingControlType(null); |
| | | stopLoading(doorType); |
| | | // Taro.hideLoading(); |
| | | Taro.showToast({ |
| | | title: successToastText, |
| | |
| | | |
| | | if (doorType === 'door') { |
| | | // Taro.hideLoading(); |
| | | setLoadingControlType(null); |
| | | stopLoading(doorType); |
| | | Taro.showToast({ title: '右侧车门已解锁', icon: 'none' }); |
| | | setLockStatus(true); |
| | | onDoorUnlockSuccess?.(); |
| | |
| | | } |
| | | }).catch(() => { |
| | | // Taro.hideLoading(); |
| | | setLoadingControlType(null); |
| | | stopLoading(doorType); |
| | | Taro.showToast({ title: '解锁失败,请重试或联系客服', icon: 'none' }); |
| | | }); |
| | | }; |
| | |
| | | import { TripStatus } from '../../../../constants/trip-status'; |
| | | import { MODIFY_DESTINATION_SKIP_AC_QUERY_ON_RETURN_KEY } from '../../../../constants/modify-destination'; |
| | | import { getAcStorage, saveAcStorage, removeAcStorage } from './ac-storage'; |
| | | import useMinimumSpinLoading from './use-minimum-spin-loading'; |
| | | |
| | | 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 [tempStatus, setTempStatus] = useState<number | null>(null); |
| | | const [acSwitchOn, setAcSwitchOn] = useState(false); |
| | | const [acStatusLoaded, setAcStatusLoaded] = useState(false); |
| | | const [isTogglingAc, setIsTogglingAc] = useState(false); |
| | | const tempTimerRef = useRef<any>(null); |
| | | const lastConfirmedTempRef = useRef<number | null>(null); |
| | | const skipQueryOnThisCycleRef = useRef(false); |
| | | const userActionSeqRef = useRef(0); |
| | | const calibrationSeqRef = useRef(0); |
| | | const { visibleLoadingKey: acLoadingKey, startLoading, stopLoading } = useMinimumSpinLoading<'ac'>(); |
| | | const TEMP_MIN = 22; |
| | | const TEMP_MAX = 28; |
| | | const TEMP_UPDATE_DEBOUNCE_MS = 500; |
| | | const DEFAULT_FIRST_TEMP = 23.5; |
| | | const isTogglingAc = acLoadingKey === 'ac'; |
| | | |
| | | const canToggleSwitch = tripStatus === TripStatus.VehicleApproaching && !isTogglingAc; |
| | | const canAdjustTemperature = canToggleSwitch && acStatusLoaded && acSwitchOn && typeof tempStatus === 'number'; |
| | |
| | | setTempStatus(DEFAULT_FIRST_TEMP); |
| | | lastConfirmedTempRef.current = DEFAULT_FIRST_TEMP; |
| | | setAcStatusLoaded(true); |
| | | setIsTogglingAc(true); |
| | | startLoading('ac'); |
| | | |
| | | let acSuccess = false; |
| | | let tempSuccess = false; |
| | |
| | | } |
| | | } catch {} |
| | | |
| | | setIsTogglingAc(false); |
| | | stopLoading('ac'); |
| | | |
| | | if (acSuccess && tempSuccess) { |
| | | Taro.showToast({ title: '已开启空调', icon: 'none' }); |
| | |
| | | return; |
| | | } |
| | | const targetOpenState = !acSwitchOn; |
| | | setIsTogglingAc(true); |
| | | startLoading('ac'); |
| | | try { |
| | | // 200就认为是指令下发成功了 |
| | | await airConditionerUnlock({ |
| | |
| | | } catch (error) { |
| | | Taro.showToast({ title: AC_TOGGLE_FAIL_TEXT, icon: 'none' }); |
| | | } finally { |
| | | setIsTogglingAc(false); |
| | | stopLoading('ac'); |
| | | } |
| | | }; |
| | | |
| New file |
| | |
| | | import { useCallback, useEffect, useRef, useState } from 'react'; |
| | | |
| | | /** loading 动画至少完成一圈的展示时长(毫秒)。 */ |
| | | const MINIMUM_SPIN_DURATION_MS = 1000; |
| | | |
| | | /** |
| | | * 控制 loading 动画最短展示时长,避免图标未完成一圈旋转就切回普通态。 |
| | | */ |
| | | export default function useMinimumSpinLoading<T extends string>() { |
| | | /** 当前处于 loading 展示中的控件标识。 */ |
| | | const [visibleLoadingKey, setVisibleLoadingKey] = useState<T | null>(null); |
| | | /** 当前 loading 开始时间戳。 */ |
| | | const loadingStartedAtRef = useRef(0); |
| | | /** 延迟关闭 loading 的定时器。 */ |
| | | const loadingFinishTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
| | | |
| | | /** |
| | | * 清理延迟关闭定时器。 |
| | | */ |
| | | const clearLoadingFinishTimer = useCallback(() => { |
| | | if (loadingFinishTimerRef.current) { |
| | | clearTimeout(loadingFinishTimerRef.current); |
| | | loadingFinishTimerRef.current = null; |
| | | } |
| | | }, []); |
| | | |
| | | useEffect(() => { |
| | | return () => { |
| | | clearLoadingFinishTimer(); |
| | | }; |
| | | }, [clearLoadingFinishTimer]); |
| | | |
| | | /** |
| | | * 开始展示 loading 动画。 |
| | | */ |
| | | const startLoading = useCallback((key: T) => { |
| | | clearLoadingFinishTimer(); |
| | | loadingStartedAtRef.current = Date.now(); |
| | | setVisibleLoadingKey(key); |
| | | }, [clearLoadingFinishTimer]); |
| | | |
| | | /** |
| | | * 结束 loading 动画;若未满一圈则延迟到一圈完成后再关闭。 |
| | | */ |
| | | const stopLoading = useCallback((key?: T | null) => { |
| | | setVisibleLoadingKey((currentKey) => { |
| | | if (!currentKey) { |
| | | return currentKey; |
| | | } |
| | | if (key && currentKey !== key) { |
| | | return currentKey; |
| | | } |
| | | const elapsed = Date.now() - loadingStartedAtRef.current; |
| | | const remaining = Math.max(0, MINIMUM_SPIN_DURATION_MS - elapsed); |
| | | clearLoadingFinishTimer(); |
| | | if (remaining === 0) { |
| | | return null; |
| | | } |
| | | loadingFinishTimerRef.current = setTimeout(() => { |
| | | setVisibleLoadingKey((latestKey) => (latestKey === currentKey ? null : latestKey)); |
| | | loadingFinishTimerRef.current = null; |
| | | }, remaining); |
| | | return currentKey; |
| | | }); |
| | | }, [clearLoadingFinishTimer]); |
| | | |
| | | return { |
| | | visibleLoadingKey, |
| | | startLoading, |
| | | stopLoading |
| | | }; |
| | | } |
| | |
| | | import './css-folder/calling-car.less' |
| | | import { formatRemainingDurationDisplay } from '../../../utils/format'; |
| | | import { TripStatus } from '../../../constants/trip-status'; |
| | | |
| | | import useTripCountdown from './calling-car-modules/use-trip-countdown'; // 行程倒计时 |
| | | import TripStatusHeader from './calling-car-modules/trip-status-header'; // 行程状态头 |
| | | import VehicleInfoCard from './calling-car-modules/vehicle-info-card'; // 车辆信息卡片 |
| | | import TemperatureControl from './calling-car-modules/temperature-control'; // 温度控制 |
| | |
| | | handleCancelTimeout?: () => void; |
| | | /** 到达终点后倒计时结束回调 */ |
| | | handleFinishTimeout?: () => void; |
| | | /** 重置费用明细展开态的版本号 */ |
| | | resetDetailVersion?: number; |
| | | /** 服务端推送价格更新版本号 */ |
| | | priceUpdateVersion?: number; |
| | | /** 外部触发“评价已提交”版本号 */ |
| | | evaluateSubmitVersion?: number; |
| | | /** 外部触发“评价弹窗关闭”版本号 */ |
| | |
| | | showModifyDestination?: boolean; |
| | | /** 分享行程 */ |
| | | handleShareTrip?: () => void; |
| | | /** 预估等待分钟数 */ |
| | | estimatedWaitMinutes?: number; |
| | | /** 预估行程秒数 */ |
| | | routeDurationSeconds?: number | null; |
| | | /** 是否启用到达终点后的离车倒计时 */ |
| | | finishCountdownEnabled?: boolean; |
| | | /** 重置费用明细展开态的版本号 */ |
| | | resetDetailVersion?: number; |
| | | /** 预计等待分钟数 */ |
| | | waitMinutes?: number | null; |
| | | /** 上车倒计时限制分钟数 */ |
| | | limitMinutes?: number | null; |
| | | /** 上车倒计时秒数 */ |
| | | pickupCountdownSeconds?: number | null; |
| | | /** 下车倒计时秒数 */ |
| | | finishCountdownSeconds?: number | null; |
| | | /** 费用明细 */ |
| | | fareBreakdown?: any; |
| | | /** 车辆信息 */ |
| | | vehicleInfo?: { |
| | | vehiclePictures?: string; |
| | |
| | | handleOpenCancelPopup, |
| | | handleCancelTimeout, |
| | | handleFinishTimeout, |
| | | resetDetailVersion, |
| | | priceUpdateVersion, |
| | | evaluateSubmitVersion, |
| | | evaluateCloseVersion, |
| | | handleOpenOverRoutePopup, |
| | |
| | | handleModifyDestination, |
| | | showModifyDestination = true, |
| | | handleShareTrip, |
| | | estimatedWaitMinutes, |
| | | routeDurationSeconds, |
| | | finishCountdownEnabled = false, |
| | | resetDetailVersion, |
| | | waitMinutes = null, |
| | | limitMinutes = null, |
| | | pickupCountdownSeconds = null, |
| | | finishCountdownSeconds = null, |
| | | fareBreakdown = null, |
| | | vehicleInfo, |
| | | orderId, |
| | | hasBeforeUserEndSite = false, |
| | |
| | | onOpenCouponSelect, |
| | | queuePollingTimeSeconds = 10 |
| | | }: Props) { |
| | | // 汇总行程中的倒计时与费用数据 |
| | | const { |
| | | waitMinutes, |
| | | limitMinutes, |
| | | pickupCountdownSeconds, |
| | | finishCountdownSeconds, |
| | | fareBreakdown |
| | | } = useTripCountdown({ |
| | | tripStatus, |
| | | orderId, |
| | | estimatedWaitMinutes, |
| | | finishCountdownEnabled, |
| | | handleCancelTimeout, |
| | | handleFinishTimeout, |
| | | priceUpdateVersion |
| | | }); |
| | | |
| | | /** 预计到达时长展示(秒/分钟) */ |
| | | const durationDisplay = formatRemainingDurationDisplay(routeDurationSeconds); |
| | | /** 车辆即将到达上车点(<=60秒) */ |
| New file |
| | |
| | | /** 路线经纬度点。 */ |
| | | export type RouteSummaryGeoPoint = { |
| | | latitude: number; |
| | | longitude: number; |
| | | }; |
| | | |
| | | /** 路线摘要气泡预估宽度。 */ |
| | | export const ROUTE_SUMMARY_CALLOUT_WIDTH = 190; |
| | | /** 路线摘要气泡预估高度。 */ |
| | | export const ROUTE_SUMMARY_CALLOUT_HEIGHT = 45; |
| | | /** 路线摘要气泡左右安全边距。 */ |
| | | export const ROUTE_SUMMARY_SAFE_SIDE_MARGIN = 12; |
| | | /** 路线摘要气泡顶部安全边距。 */ |
| | | export const ROUTE_SUMMARY_SAFE_TOP_MARGIN = 12; |
| | | /** 路线摘要气泡底部安全边距。 */ |
| | | export const ROUTE_SUMMARY_SAFE_BOTTOM_MARGIN = 12; |
| | | |
| | | /** 屏幕坐标点。 */ |
| | | export type RouteSummaryScreenPoint = { |
| | | x: number; |
| | | y: number; |
| | | }; |
| | | |
| | | /** 路线摘要气泡安全边界。 */ |
| | | export type RouteSummaryBubbleBounds = { |
| | | safeLeft: number; |
| | | safeRight: number; |
| | | safeTop: number; |
| | | safeBottom: number; |
| | | bubbleWidth: number; |
| | | bubbleHeight: number; |
| | | }; |
| | | |
| | | /** 路线摘要锚点解析入参。 */ |
| | | export type ResolveRouteSummaryAnchorInput = { |
| | | routePoints: RouteSummaryGeoPoint[]; |
| | | fallbackPoint: RouteSummaryGeoPoint | null; |
| | | bounds: RouteSummaryBubbleBounds; |
| | | projectToScreen: (point: RouteSummaryGeoPoint) => RouteSummaryScreenPoint | null; |
| | | }; |
| | | |
| | | /** |
| | | * 判断候选路线点是否有效。 |
| | | */ |
| | | const isValidRoutePoint = (point: RouteSummaryGeoPoint | null | undefined) => ( |
| | | Number.isFinite(Number(point?.latitude)) && Number.isFinite(Number(point?.longitude)) |
| | | ); |
| | | |
| | | /** |
| | | * 判断气泡在当前候选锚点下是否完全位于安全区内。 |
| | | */ |
| | | const isBubbleInsideSafeBounds = ( |
| | | point: RouteSummaryScreenPoint, |
| | | bounds: RouteSummaryBubbleBounds |
| | | ) => ( |
| | | point.x >= bounds.safeLeft |
| | | && point.x + bounds.bubbleWidth <= bounds.safeRight |
| | | && point.y >= bounds.safeTop |
| | | && point.y + bounds.bubbleHeight <= bounds.safeBottom |
| | | ); |
| | | |
| | | /** |
| | | * 以路线中点为中心向两侧扩散,生成候选索引顺序。 |
| | | */ |
| | | const buildCandidateIndices = (pointCount: number) => { |
| | | if (pointCount <= 0) { |
| | | return []; |
| | | } |
| | | const midpointIndex = Math.max(0, Math.floor(pointCount / 2)); |
| | | const sampleStep = pointCount > 120 ? 3 : pointCount > 60 ? 2 : 1; |
| | | const visited = new Set<number>(); |
| | | const indices: number[] = []; |
| | | |
| | | for (let offset = 0; offset < pointCount; offset += sampleStep) { |
| | | const rightIndex = midpointIndex + offset; |
| | | if (rightIndex >= 0 && rightIndex < pointCount && !visited.has(rightIndex)) { |
| | | visited.add(rightIndex); |
| | | indices.push(rightIndex); |
| | | } |
| | | if (offset === 0) { |
| | | continue; |
| | | } |
| | | const leftIndex = midpointIndex - offset; |
| | | if (leftIndex >= 0 && leftIndex < pointCount && !visited.has(leftIndex)) { |
| | | visited.add(leftIndex); |
| | | indices.push(leftIndex); |
| | | } |
| | | } |
| | | |
| | | if (!visited.has(midpointIndex)) { |
| | | indices.unshift(midpointIndex); |
| | | } |
| | | return indices; |
| | | }; |
| | | |
| | | /** |
| | | * 解析路线摘要气泡应挂载的最佳路线点。 |
| | | */ |
| | | export const resolveRouteSummaryAnchorPoint = ( |
| | | input: ResolveRouteSummaryAnchorInput |
| | | ) => { |
| | | const { routePoints, fallbackPoint, bounds, projectToScreen } = input; |
| | | const candidateIndices = buildCandidateIndices(routePoints.length); |
| | | |
| | | for (const candidateIndex of candidateIndices) { |
| | | const candidatePoint = routePoints[candidateIndex]; |
| | | if (!isValidRoutePoint(candidatePoint)) { |
| | | continue; |
| | | } |
| | | const screenPoint = projectToScreen(candidatePoint); |
| | | if (!screenPoint) { |
| | | continue; |
| | | } |
| | | if (isBubbleInsideSafeBounds(screenPoint, bounds)) { |
| | | return candidatePoint; |
| | | } |
| | | } |
| | | |
| | | return isValidRoutePoint(fallbackPoint) ? fallbackPoint : null; |
| | | }; |
| | |
| | | |
| | | const CAR_CALLOUT_BOTTOM_GAP = 34; |
| | | |
| | | type MapProjectionContext = { |
| | | rect: any; |
| | | southwest: { latitude: number; longitude: number }; |
| | | northeast: { latitude: number; longitude: number }; |
| | | }; |
| | | |
| | | export default function useAndroidOverlays(deps: Deps) { |
| | | // 等待雷达样式与显示状态 |
| | | const [waitingRadarStyle, setWaitingRadarStyle] = useState<any>({}); |
| | |
| | | const [routeSummaryCalloutVisible, setRouteSummaryCalloutVisible] = useState(false); |
| | | const mapRectRef = useRef<any>(null); |
| | | |
| | | // 经纬度转屏幕坐标 |
| | | const computeScreenPosition = (target: { latitude: number; longitude: number }, rect: any, transformTopOffset = 0) => { |
| | | if (!target || !rect) return null; |
| | | // 读取地图投影上下文,供路线点批量转换屏幕坐标使用。 |
| | | const resolveMapProjectionContext = (rect: any) => { |
| | | if (!rect) return Promise.resolve<MapProjectionContext | null>(null); |
| | | return new Promise<any>((resolve) => { |
| | | Taro.createMapContext('map').getRegion({ |
| | | success: (res) => { |
| | | const sw = res?.southwest; |
| | | const ne = res?.northeast; |
| | | const latRange = ne?.latitude - sw?.latitude; |
| | | const lngRange = ne?.longitude - sw?.longitude; |
| | | if (!latRange || !lngRange) { |
| | | if (!sw || !ne) { |
| | | resolve(null); |
| | | return; |
| | | } |
| | | const x = ((target.longitude - sw.longitude) / lngRange) * rect.width; |
| | | const y = ((ne.latitude - target.latitude) / latRange) * rect.height; |
| | | resolve({ |
| | | x: rect.left + x, |
| | | y: rect.top + y + transformTopOffset, |
| | | rect, |
| | | southwest: sw, |
| | | northeast: ne |
| | | }); |
| | | }, |
| | | fail: () => resolve(null) |
| | | }); |
| | | }); |
| | | }; |
| | | |
| | | // 经纬度转屏幕坐标。 |
| | | const projectScreenPosition = ( |
| | | target: { latitude: number; longitude: number }, |
| | | projection: MapProjectionContext | null, |
| | | transformTopOffset = 0 |
| | | ) => { |
| | | if (!target || !projection) return null; |
| | | const latRange = projection.northeast.latitude - projection.southwest.latitude; |
| | | const lngRange = projection.northeast.longitude - projection.southwest.longitude; |
| | | if (!latRange || !lngRange) { |
| | | return null; |
| | | } |
| | | const x = ((target.longitude - projection.southwest.longitude) / lngRange) * projection.rect.width; |
| | | const y = ((projection.northeast.latitude - target.latitude) / latRange) * projection.rect.height; |
| | | return { |
| | | x: projection.rect.left + x, |
| | | y: projection.rect.top + y + transformTopOffset, |
| | | }; |
| | | }; |
| | | |
| | | // 经纬度转屏幕坐标。 |
| | | const computeScreenPosition = async ( |
| | | target: { latitude: number; longitude: number }, |
| | | rect: any, |
| | | transformTopOffset = 0 |
| | | ) => { |
| | | const projection = await resolveMapProjectionContext(rect); |
| | | return projectScreenPosition(target, projection, transformTopOffset); |
| | | }; |
| | | |
| | | // 固定层样式:节点锚定在 (0,0),仅通过 transform 位移,减少 left/top 频繁更新导致的重排闪动。 |
| | |
| | | setRouteSummaryCalloutVisible(false); |
| | | return; |
| | | } |
| | | setRouteSummaryCalloutStyle({ |
| | | position: 'fixed', |
| | | left: `${pos.x}px`, |
| | | top: `${pos.y}px`, |
| | | transform: 'translate(0, 0)' |
| | | }); |
| | | setRouteSummaryCalloutStyle(buildTransformOverlayStyle({ |
| | | x: pos.x, |
| | | y: pos.y |
| | | }, '')); |
| | | setRouteSummaryCalloutVisible(true); |
| | | }; |
| | | |
| | |
| | | |
| | | useEffect(() => { |
| | | updateRouteSummaryCalloutPosition(); |
| | | }, [deps.isAndroid, deps.routeSummaryLocation?.latitude, deps.routeSummaryLocation?.longitude, deps.mapScale]); |
| | | }, [deps.isAndroid, deps.routeSummaryLocation?.latitude, deps.routeSummaryLocation?.longitude, deps.mapScale, deps.tripStatus]); |
| | | |
| | | useEffect(() => { |
| | | if (!deps.isAndroid || deps.tripStatus !== TripStatus.ArrivedDestination) return; |
| | |
| | | try { |
| | | setIsConfirmingModifyDestination(true); |
| | | setFlowState(FLOW_STATES.CONFIRMING); |
| | | Taro.showLoading({ title: '正在向车辆发起修改目的地请求', mask: true }); // 修改目的地请求进行中全局 loading |
| | | Taro.showLoading({ title: '正在修改目的地', mask: true }); // 修改目的地请求进行中全局 loading |
| | | |
| | | // 如果后端需要字符串格式可以显式转String,如果后端支持number直接传值,保留原始格式防止隐式转换截断 |
| | | // 如果原本因为 Number() 导致大整数丢失精度,去掉 Number() 即可 |
| | |
| | | * 修改目的地预览路线编排:负责首次预览请求与确认态超时重估价。 |
| | | */ |
| | | export default function useModifyDestinationPreviewRoute(deps: Deps) { |
| | | const previewRouteKeyRef = useRef(''); // 预览路线请求签名,避免重复请求 |
| | | const hasRequestedCurrentPreviewRef = useRef(false); // 当前预览态是否已完成首次预览请求 |
| | | const wasPreviewVisibleRef = useRef(false); // 记录上一帧是否处于修改目的地预览态 |
| | | const [pendingConfirmRequote, setPendingConfirmRequote] = useState(false); // 确认态停留超过 60 秒后,点击确认前需要先重估价 |
| | | |
| | | /** |
| | |
| | | }; |
| | | |
| | | useEffect(() => { |
| | | const routeContext = resolveModifyDestinationRouteContext(deps); |
| | | if (!routeContext) { |
| | | previewRouteKeyRef.current = ''; |
| | | const isPreviewVisible = deps.isModifyDestinationReviewVisible; |
| | | const hasEnteredPreview = isPreviewVisible && !wasPreviewVisibleRef.current; |
| | | wasPreviewVisibleRef.current = isPreviewVisible; |
| | | |
| | | if (!isPreviewVisible) { |
| | | hasRequestedCurrentPreviewRef.current = false; |
| | | setPendingConfirmRequote(false); |
| | | return; |
| | | } |
| | | if (routeContext.requestKey === previewRouteKeyRef.current) { |
| | | if (hasEnteredPreview) { |
| | | hasRequestedCurrentPreviewRef.current = false; |
| | | } |
| | | if (hasRequestedCurrentPreviewRef.current) { |
| | | return; |
| | | } |
| | | previewRouteKeyRef.current = routeContext.requestKey; |
| | | const routeContext = resolveModifyDestinationRouteContext(deps); |
| | | if (!routeContext) { |
| | | return; |
| | | } |
| | | hasRequestedCurrentPreviewRef.current = true; |
| | | setPendingConfirmRequote(false); |
| | | clearSelectedCouponBeforePreview(); |
| | | deps.requestRouteAndFare( |
| | |
| | | deps.tripStatus, |
| | | deps.isModifyDestinationReviewVisible, |
| | | deps.hasLocalDestinationOverride, |
| | | deps.pageDidShowVersion, |
| | | deps.effectiveEndSite?.id, |
| | | deps.effectiveEndSite?.latitude, |
| | | deps.effectiveEndSite?.longitude, |
| New file |
| | |
| | | import Taro from '@tarojs/taro'; |
| | | import { useCallback, useEffect, useRef, useState } from 'react'; |
| | | import { |
| | | resolveRouteSummaryAnchorPoint, |
| | | ROUTE_SUMMARY_CALLOUT_HEIGHT, |
| | | ROUTE_SUMMARY_SAFE_BOTTOM_MARGIN, |
| | | ROUTE_SUMMARY_SAFE_SIDE_MARGIN, |
| | | ROUTE_SUMMARY_SAFE_TOP_MARGIN, |
| | | ROUTE_SUMMARY_CALLOUT_WIDTH, |
| | | type RouteSummaryGeoPoint |
| | | } from './route-summary-anchor'; |
| | | |
| | | type Deps = { |
| | | enabled: boolean; |
| | | routeSummaryLocation: RouteSummaryGeoPoint | null; |
| | | routePoints?: RouteSummaryGeoPoint[]; |
| | | }; |
| | | |
| | | type MapProjectionContext = { |
| | | rect: any; |
| | | southwest: { latitude: number; longitude: number }; |
| | | northeast: { latitude: number; longitude: number }; |
| | | }; |
| | | |
| | | /** |
| | | * 路线摘要气泡锚点解析:优先选择屏幕内可见且不碰撞底部卡片的路线点。 |
| | | */ |
| | | export default function useRouteSummaryAnchorLocation(deps: Deps) { |
| | | /** 当前解析出的路线摘要锚点。 */ |
| | | const [routeSummaryAnchorLocation, setRouteSummaryAnchorLocation] = useState<RouteSummaryGeoPoint | null>(null); |
| | | /** 地图容器矩形缓存。 */ |
| | | const mapRectRef = useRef<any>(null); |
| | | |
| | | /** |
| | | * 同时量取地图区域与底部卡片区域。 |
| | | */ |
| | | const measureLayout = useCallback(() => new Promise<any>((resolve) => { |
| | | Taro.createSelectorQuery() |
| | | .select('#map') |
| | | .boundingClientRect() |
| | | .select('.container') |
| | | .boundingClientRect() |
| | | .exec((res) => { |
| | | const mapRect = res?.[0]; |
| | | const containerRect = res?.[1]; |
| | | if (mapRect) { |
| | | mapRectRef.current = mapRect; |
| | | } |
| | | resolve({ |
| | | mapRect, |
| | | containerRect |
| | | }); |
| | | }); |
| | | }), []); |
| | | |
| | | /** |
| | | * 获取当前地图投影上下文。 |
| | | */ |
| | | const resolveMapProjectionContext = useCallback((rect: any) => { |
| | | if (!rect) { |
| | | return Promise.resolve<MapProjectionContext | null>(null); |
| | | } |
| | | return new Promise<MapProjectionContext | null>((resolve) => { |
| | | Taro.createMapContext('map').getRegion({ |
| | | success: (res) => { |
| | | const southwest = res?.southwest; |
| | | const northeast = res?.northeast; |
| | | if (!southwest || !northeast) { |
| | | resolve(null); |
| | | return; |
| | | } |
| | | resolve({ |
| | | rect, |
| | | southwest, |
| | | northeast |
| | | }); |
| | | }, |
| | | fail: () => resolve(null) |
| | | }); |
| | | }); |
| | | }, []); |
| | | |
| | | /** |
| | | * 将路线点投影到屏幕坐标。 |
| | | */ |
| | | const projectToScreen = useCallback((point: RouteSummaryGeoPoint, projection: MapProjectionContext | null) => { |
| | | if (!point || !projection) { |
| | | return null; |
| | | } |
| | | const latRange = projection.northeast.latitude - projection.southwest.latitude; |
| | | const lngRange = projection.northeast.longitude - projection.southwest.longitude; |
| | | if (!latRange || !lngRange) { |
| | | return null; |
| | | } |
| | | return { |
| | | x: projection.rect.left + ((point.longitude - projection.southwest.longitude) / lngRange) * projection.rect.width, |
| | | y: projection.rect.top + ((projection.northeast.latitude - point.latitude) / latRange) * projection.rect.height |
| | | }; |
| | | }, []); |
| | | |
| | | /** |
| | | * 重新计算路线摘要锚点。 |
| | | */ |
| | | const updateRouteSummaryAnchorLocation = useCallback(async () => { |
| | | if (!deps.enabled || !deps.routeSummaryLocation) { |
| | | setRouteSummaryAnchorLocation(null); |
| | | return; |
| | | } |
| | | const layout = await measureLayout(); |
| | | const rect = layout?.mapRect || mapRectRef.current; |
| | | const projection = await resolveMapProjectionContext(rect); |
| | | if (!rect || !projection) { |
| | | setRouteSummaryAnchorLocation(deps.routeSummaryLocation); |
| | | return; |
| | | } |
| | | const mapRight = rect.left + rect.width; |
| | | const mapBottom = rect.top + rect.height; |
| | | const containerTop = Number(layout?.containerRect?.top); |
| | | const safeBottomBoundary = Number.isFinite(containerTop) |
| | | ? Math.min(mapBottom - ROUTE_SUMMARY_SAFE_BOTTOM_MARGIN, containerTop - ROUTE_SUMMARY_SAFE_BOTTOM_MARGIN) |
| | | : mapBottom - ROUTE_SUMMARY_SAFE_BOTTOM_MARGIN; |
| | | const nextResolvedAnchor = resolveRouteSummaryAnchorPoint({ |
| | | routePoints: Array.isArray(deps.routePoints) ? deps.routePoints : [], |
| | | fallbackPoint: deps.routeSummaryLocation, |
| | | bounds: { |
| | | safeLeft: rect.left + ROUTE_SUMMARY_SAFE_SIDE_MARGIN, |
| | | safeRight: mapRight - ROUTE_SUMMARY_SAFE_SIDE_MARGIN, |
| | | safeTop: rect.top + ROUTE_SUMMARY_SAFE_TOP_MARGIN, |
| | | safeBottom: safeBottomBoundary, |
| | | bubbleWidth: ROUTE_SUMMARY_CALLOUT_WIDTH, |
| | | bubbleHeight: ROUTE_SUMMARY_CALLOUT_HEIGHT |
| | | }, |
| | | projectToScreen: (point) => projectToScreen(point, projection) |
| | | }); |
| | | setRouteSummaryAnchorLocation(nextResolvedAnchor || deps.routeSummaryLocation); |
| | | }, [deps.enabled, deps.routePoints, deps.routeSummaryLocation, measureLayout, projectToScreen, resolveMapProjectionContext]); |
| | | |
| | | useEffect(() => { |
| | | updateRouteSummaryAnchorLocation(); |
| | | }, [updateRouteSummaryAnchorLocation]); |
| | | |
| | | /** |
| | | * 地图视野变化结束后,重新解析路线摘要锚点。 |
| | | */ |
| | | const handleRegionChange = useCallback((event: any) => { |
| | | const eventType = String(event?.type || event?.detail?.type || '').toLowerCase(); |
| | | if (eventType === 'end') { |
| | | updateRouteSummaryAnchorLocation(); |
| | | } |
| | | }, [updateRouteSummaryAnchorLocation]); |
| | | |
| | | return { |
| | | routeSummaryAnchorLocation, |
| | | updateRouteSummaryAnchorLocation, |
| | | handleRegionChange |
| | | }; |
| | | } |
| | |
| | | import ReleaseBeBeing from '../components/release-be-being'; |
| | | import CallingCar from '../components/calling-car'; |
| | | import ModifyDestinationReview from '../components/modify-destination-review'; |
| | | import useTripCountdown from '../components/calling-car-modules/use-trip-countdown'; |
| | | import { TripStatus } from '../../../constants/trip-status'; |
| | | import { formatRemainingDurationDisplay } from '../../../utils/format'; |
| | | |
| | |
| | | * 页面展示委托:地图覆盖物与行程卡片渲染。 |
| | | */ |
| | | export default function useViewDelegates(deps: Deps) { |
| | | const { |
| | | waitMinutes, |
| | | limitMinutes, |
| | | pickupCountdownSeconds, |
| | | finishCountdownSeconds, |
| | | fareBreakdown |
| | | } = useTripCountdown({ |
| | | tripStatus: deps.tripStatus, |
| | | orderId: deps.orderId, |
| | | estimatedWaitMinutes: deps.fareEstimate?.waitingTime, |
| | | finishCountdownEnabled: deps.finishCountdownEnabled, |
| | | handleCancelTimeout: deps.handlePickupTimeoutAutoCancel, |
| | | handleFinishTimeout: deps.handleFinishCountdownTimeout, |
| | | priceUpdateVersion: deps.priceUpdateVersion |
| | | }); |
| | | /** 时长文案 */ |
| | | const durationDisplay = useMemo(() => formatRemainingDurationDisplay(deps.routeDuration), [deps.routeDuration]); |
| | | /** 车辆公里/分钟气泡专用时长文案:不足 1 分钟时统一展示 1 分钟。 */ |
| | |
| | | const shouldShowVehicleMetricCallout = !deps.isModifyDestinationReviewVisible; |
| | | const routeSummaryText = useMemo(() => ( |
| | | deps.routeDistance && deps.routeDuration !== null |
| | | ? `${durationDisplay.value}${durationDisplay.unit} ${deps.routeDistance.value}${deps.routeDistance.unit === 'm' ? '米' : '公里'}` |
| | | ? `${vehicleMetricDurationDisplay.value}${vehicleMetricDurationDisplay.unit} ${deps.routeDistance.value}${deps.routeDistance.unit === 'm' ? '米' : '公里'}` |
| | | : '' |
| | | ), [deps.routeDistance, deps.routeDuration, durationDisplay]); |
| | | ), [deps.routeDistance, deps.routeDuration, vehicleMetricDurationDisplay]); |
| | | const shouldHideAndroidMarkerCallout = deps.isAndroid && [TripStatus.PublishRoute, TripStatus.VehicleArrivedPickup, TripStatus.TripFinished, TripStatus.PaymentSuccess, TripStatus.CancelOnlyPickup].includes(deps.tripStatus); // Android 指定状态下隐藏常规 marker 气泡 |
| | | const androidCarCalloutContent = useMemo(() => ( |
| | | <AndroidCarCalloutContent |
| | |
| | | handleModifyDestination={deps.handleModifyDestination} |
| | | showModifyDestination={deps.showModifyDestination} |
| | | handleShareTrip={deps.handleShareTrip} |
| | | handleCancelTimeout={deps.handlePickupTimeoutAutoCancel} |
| | | handleFinishTimeout={deps.handleFinishCountdownTimeout} |
| | | resetDetailVersion={deps.resetDetailVersion} |
| | | priceUpdateVersion={deps.priceUpdateVersion} |
| | | waitMinutes={waitMinutes} |
| | | limitMinutes={limitMinutes} |
| | | pickupCountdownSeconds={pickupCountdownSeconds} |
| | | finishCountdownSeconds={finishCountdownSeconds} |
| | | fareBreakdown={fareBreakdown} |
| | | evaluateSubmitVersion={deps.evaluateSubmitVersion} |
| | | evaluateCloseVersion={deps.evaluateCloseVersion} |
| | | handleOpenOverRoutePopup={deps.handleOpenOverRoutePopup} |
| | | handlePay={deps.handlePay} |
| | | onOpenCouponSelect={deps.handleOpenCouponSelect} |
| | | handleOpenEvaluatePopup={deps.handleOpenEvaluatePopup} |
| | | estimatedWaitMinutes={deps.fareEstimate?.waitingTime} |
| | | routeDurationSeconds={deps.routeDuration} |
| | | onToggleDetailFade={deps.handleToggleDetailFade} |
| | | orderTrunkStatus={deps.trunkStatus} |
| | |
| | | return { |
| | | durationDisplay, |
| | | renderMapOverlays, |
| | | renderTripCard |
| | | renderTripCard, |
| | | fareBreakdown |
| | | }; |
| | | } |
| | |
| | | |
| | | .pr-0 { |
| | | padding-right: 0; |
| | | } |
| | | } |
| | |
| | | import Taro, { useDidShow, useShareAppMessage } from "@tarojs/taro" |
| | | import { useEffect, useState, useRef, useMemo,useCallback } from 'react'; |
| | | import { useEffect, useState, useRef, useMemo, useCallback } from 'react'; |
| | | import { View, Image } from '@tarojs/components'; |
| | | import './index.less'; |
| | | |
| | |
| | | import useSmoothRouteMotion from './hooks/use-smooth-route-motion'; |
| | | // Android 覆盖物状态管理 |
| | | import useAndroidOverlays from './hooks/use-android-overlays'; |
| | | import useRouteSummaryAnchorLocation from './hooks/use-route-summary-anchor-location'; |
| | | // 地图视口状态管理 |
| | | import useMapViewport from './hooks/use-map-viewport'; |
| | | import useActiveTripScale from './hooks/use-active-trip-scale'; |
| | |
| | | * 预估态退出页面时上报埋点。 |
| | | */ |
| | | const handleBack = () => { |
| | | if (isModifyDestinationReviewVisible || hasLocalDestinationOverride) { |
| | | const modifyDestinationSession = Taro.getStorageSync(MODIFY_DESTINATION_SESSION_KEY); |
| | | const fallbackPreviousEndSite = modifyDestinationSession?.previousEndSite || previousEndSite; |
| | | restoreModifyDestinationDisplay(fallbackPreviousEndSite, previousFareEstimate, previousRoutePoints); |
| | | return; |
| | | } |
| | | if (tripStatus === TripStatus.PublishRoute) { |
| | | reportConfirmRideEvent({ |
| | | eventId: CONFIRM_RIDE_EXIT_BUTTON_EVENT_ID, |
| | |
| | | resetRouteRuntimeState(); |
| | | }; |
| | | const displayPolyline = polyline; |
| | | const routeSummaryMarker = useMemo(() => { |
| | | const routeSummaryBaseLocation = useMemo(() => { |
| | | if (!allowSelectableRoutes || !routeDistance || routeDuration === null || !Array.isArray(routePoints) || routePoints.length === 0) { |
| | | return null; |
| | | } |
| | |
| | | return null; |
| | | } |
| | | return { |
| | | id: ROUTE_SUMMARY_MARKER_ID, |
| | | latitude: midpoint.latitude, |
| | | longitude: midpoint.longitude, |
| | | longitude: midpoint.longitude |
| | | }; |
| | | }, [allowSelectableRoutes, routeDistance, routeDuration, routePoints]); |
| | | const { |
| | | routeSummaryAnchorLocation, |
| | | handleRegionChange: handleRouteSummaryRegionChange |
| | | } = useRouteSummaryAnchorLocation({ |
| | | enabled: allowSelectableRoutes && !!routeDistance && routeDuration !== null, |
| | | routeSummaryLocation: routeSummaryBaseLocation, |
| | | routePoints |
| | | }); |
| | | const routeSummaryMarker = useMemo(() => { |
| | | const summaryLocation = routeSummaryAnchorLocation || routeSummaryBaseLocation; |
| | | if (!summaryLocation?.latitude || !summaryLocation?.longitude) { |
| | | return null; |
| | | } |
| | | return { |
| | | id: ROUTE_SUMMARY_MARKER_ID, |
| | | latitude: summaryLocation.latitude, |
| | | longitude: summaryLocation.longitude, |
| | | iconPath: 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/marker-car.png', |
| | | width: 1, |
| | | height: 1, |
| | |
| | | isRouteSummaryBubble: true, |
| | | customCallout: { anchorY: 22, anchorX: 42, display: 'ALWAYS' } |
| | | }; |
| | | }, [allowSelectableRoutes, routeDistance, routeDuration, routePoints]); |
| | | }, [routeSummaryAnchorLocation, routeSummaryBaseLocation]); |
| | | const viewportState = useMapViewport({ |
| | | tripStatus, |
| | | statusRule, |
| | |
| | | tripStatus, |
| | | mapScale, |
| | | isModifyDestinationReviewVisible, |
| | | routeSummaryLocation: routeSummaryMarker ? { |
| | | latitude: routeSummaryMarker.latitude, |
| | | longitude: routeSummaryMarker.longitude |
| | | } : null, |
| | | routeSummaryLocation: routeSummaryAnchorLocation || routeSummaryBaseLocation, |
| | | startSite, |
| | | endSite: effectiveEndSite, |
| | | vehicleLocation, |
| | |
| | | |
| | | const handleRegionChange = (e) => { |
| | | activeTripScale.handleRegionChange(e); |
| | | handleRouteSummaryRegionChange(e); |
| | | handleAndroidOverlayRegionChange(e); |
| | | // 用户手势导致的缩放/拖拽结束后,把原生地图的实际 scale 同步回 React 状态, |
| | | // 避免受控 <Map scale={mapScale}> 与原生实际缩放漂移,导致后续速度驱动误判"挡位未变化"。 |
| | |
| | | const activeCouponId = String( // 优惠券弹窗默认选中券ID |
| | | fareEstimate?.couponId || fareEstimate?.userCouponId || Taro.getStorageSync('selectedCouponId') || '' |
| | | ); |
| | | const couponOrderMoney = Number(fareEstimate?.originalFare || fareEstimate?.finalFare || 0); // 优惠券可用性计算订单金额 |
| | | const canWalkNavigateToPickup = useMemo(() => { |
| | | if (isModifyDestinationReviewVisible) { |
| | | return false; |
| | |
| | | }; |
| | | }, [tripStatus, startSite?.latitude, startSite?.longitude, currentUserLocation?.latitude, currentUserLocation?.longitude]); |
| | | |
| | | const { renderMapOverlays, renderTripCard } = useViewDelegates({ |
| | | const { renderMapOverlays, renderTripCard, fareBreakdown } = useViewDelegates({ |
| | | isAndroid, |
| | | tripStatus, |
| | | mapScale, |
| | |
| | | pageDidShowVersion, |
| | | queuePollingTimeSeconds |
| | | }); // 视图渲染委托 |
| | | /** |
| | | * 优惠券可用性计算订单金额 |
| | | */ |
| | | const couponOrderMoney = tripStatus === TripStatus.TripFinished |
| | | ? Number(fareBreakdown?.orderMoney) |
| | | : 0; |
| | | const mapLongitude = longitude ?? 116.397128; |
| | | const mapLatitude = latitude ?? 39.916527; |
| | | const canToggleRouteSelect = allowSelectableRoutes && routeOptions.length > 1; |
| | |
| | | refreshData(); // 关闭弹窗时刷新列表数据(包括历史记录和附近站点) |
| | | }; |
| | | |
| | | /** |
| | | * 收藏夹返回的是“收藏记录对象”,这里统一转换成下单链路使用的站点对象。 |
| | | */ |
| | | const normalizeFavoriteSelectedSite = (item: any) => { |
| | | const resolvedPointId = item?.pointId || ''; |
| | | const resolvedName = item?.pointName || ''; |
| | | const shouldPreserveFavoriteId = item?.pointId && String(item?.id || '') !== String(item?.pointId || ''); |
| | | return { |
| | | ...item, |
| | | favoriteId: shouldPreserveFavoriteId ? item?.id : item?.favoriteId, |
| | | id: resolvedPointId, |
| | | pointId: resolvedPointId, |
| | | name: resolvedName, |
| | | pointName: item?.pointName || resolvedName, |
| | | }; |
| | | }; |
| | | |
| | | const handleSelectFavorite = (item) => { |
| | | setShowFavoritePopup(false); |
| | | onChangeSelectSite(item); |
| | | onChangeSelectSite(normalizeFavoriteSelectedSite(item)); |
| | | }; |
| | | |
| | | const renderHighlightedText = (text: string, className: string) => { // 通用高亮渲染 |