Merge branch 'feature_v1.0' into feature/cashier-page
| | |
| | | import { useRef, useState } from 'react'; |
| | | import Taro from '@tarojs/taro'; |
| | | import { fetchMapData } from '../../../../utils/Robotaxi-public-method'; |
| | | import { fetchMapData, getAutoScaleBySites } from '../../../../utils/Robotaxi-public-method'; |
| | | |
| | | const AUTO_SCALE_MAX = 17; // 自动缩放最大等级 |
| | | const AUTO_SCALE_MIN = 11; // 自动缩放最小等级 |
| | | |
| | | /** |
| | | * 地图状态管理:拥有地图坐标、围栏、标记点及地图业务数据刷新能力。 |
| | |
| | | const isReturningToOrigin = useRef(false); |
| | | const resetTimer = useRef<any>(null); |
| | | const pendingNearbyRequestCountRef = useRef(0); |
| | | const autoScaleAppliedCityCodeRef = useRef(''); // 已执行自动缩放的城市 code |
| | | |
| | | /** |
| | | * 根据经纬度和城市编码刷新地图业务数据(围栏内判断、电子围栏 ID、站点标记)。 |
| | |
| | | setIsOperatingArea(result.isInside); |
| | | setElectronicAreaId(result.electronicAreaId); |
| | | setMarkers(result.newMarkers); |
| | | if (autoScaleAppliedCityCodeRef.current !== code) { |
| | | const autoScale = getAutoScaleBySites(result?.sites || result?.newMarkers || []); |
| | | if (typeof autoScale === 'number') { |
| | | setMapScale((prev) => { |
| | | const nextScale = Math.max(AUTO_SCALE_MIN, Math.min(AUTO_SCALE_MAX, autoScale)); |
| | | if (Math.abs(prev - nextScale) < 0.01) return prev; |
| | | return nextScale; |
| | | }); |
| | | } |
| | | autoScaleAppliedCityCodeRef.current = code; |
| | | } |
| | | return result; |
| | | } finally { |
| | | pendingNearbyRequestCountRef.current = Math.max(0, pendingNearbyRequestCountRef.current - 1); |
| New file |
| | |
| | | import { useCallback, useEffect, useRef, useState } from 'react'; |
| | | import { getNearbyVehicles } from '../../../../robotaxi/api/home'; |
| | | import { getDistance } from '../../../../utils/Robotaxi-public-method'; |
| | | |
| | | type Deps = { |
| | | isOperatingArea: boolean; |
| | | }; |
| | | |
| | | type NearbyCenter = { |
| | | lat: number; |
| | | lon: number; |
| | | }; |
| | | |
| | | type NearbyVehicleItem = { |
| | | id: string; |
| | | latitude: number; |
| | | longitude: number; |
| | | direction: number; |
| | | }; |
| | | |
| | | type VehicleTrackItem = { |
| | | id: string; |
| | | fromLat: number; |
| | | fromLon: number; |
| | | toLat: number; |
| | | toLon: number; |
| | | fromDirection: number; |
| | | toDirection: number; |
| | | }; |
| | | |
| | | const POLL_INTERVAL_MS = 5000; // 车辆轮询周期(毫秒) |
| | | const SETTLE_DELAY_MS = 500; // 地图停止后的静止判定时间(毫秒) |
| | | const MOVE_THRESHOLD_METERS = 500; // 触发新搜索的位移阈值(米) |
| | | const ANIMATION_DURATION_MS = 4500; // 车辆位移动画时长(毫秒) |
| | | const ANIMATION_FRAME_MS = 120; // 动画帧间隔(毫秒) |
| | | const VEHICLE_MARKER_ID_BASE = 900000000; // 车辆 marker id 起始段 |
| | | const VEHICLE_MARKER_ICON = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/marker-car.png'; // 车辆 icon 地址 |
| | | |
| | | /** |
| | | * 归一化角度到 [0, 360) 区间。 |
| | | */ |
| | | const normalizeDirection = (value: number) => { |
| | | const safe = Number(value); |
| | | if (!Number.isFinite(safe)) return 0; |
| | | return ((safe % 360) + 360) % 360; |
| | | }; |
| | | |
| | | /** |
| | | * 插值航向角,选择最短旋转路径避免大角度跳变。 |
| | | */ |
| | | const interpolateDirection = (from: number, to: number, progress: number) => { |
| | | const fromSafe = normalizeDirection(from); |
| | | const toSafe = normalizeDirection(to); |
| | | const delta = ((((toSafe - fromSafe) % 360) + 540) % 360) - 180; |
| | | return normalizeDirection(fromSafe + delta * progress); |
| | | }; |
| | | |
| | | /** |
| | | * 将任意字符串稳定映射到正整数,避免与站点 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); |
| | | }; |
| | | |
| | | /** |
| | | * 附近车辆编排:处理停稳判定、500 米阈值、5 秒轮询与平滑动画。 |
| | | */ |
| | | export default function useNearbyVehicles({ isOperatingArea }: Deps) { |
| | | const [vehicleMarkers, setVehicleMarkers] = useState<any[]>([]); // 车辆 marker 列表 |
| | | const pollTimerRef = useRef<any>(null); // 轮询定时器句柄 |
| | | const settleTimerRef = useRef<any>(null); // 停稳判定定时器句柄 |
| | | const animationTimerRef = useRef<any>(null); // 动画帧定时器句柄 |
| | | const requestLockRef = useRef(false); // 请求并发锁,防止重入 |
| | | const latestSeqRef = useRef(0); // 请求序号,防止旧响应覆盖新状态 |
| | | const activeSearchCenterRef = useRef<NearbyCenter | null>(null); // 当前轮询中心点 |
| | | const lastSearchCenterRef = useRef<NearbyCenter | null>(null); // 上一次发请求的中心点 |
| | | const renderedVehicleMapRef = useRef<Map<string, NearbyVehicleItem>>(new Map()); // 当前渲染中的车辆 |
| | | const animationStateRef = useRef<{ startAt: number; endAt: number; tracks: VehicleTrackItem[] } | null>(null); // 当前动画轨迹状态 |
| | | |
| | | /** |
| | | * 清理动画定时器。 |
| | | */ |
| | | const clearAnimationTimer = useCallback(() => { |
| | | if (animationTimerRef.current) { |
| | | clearTimeout(animationTimerRef.current); |
| | | animationTimerRef.current = null; |
| | | } |
| | | animationStateRef.current = null; |
| | | }, []); |
| | | |
| | | /** |
| | | * 清理轮询定时器。 |
| | | */ |
| | | const clearPollTimer = useCallback(() => { |
| | | if (pollTimerRef.current) { |
| | | clearInterval(pollTimerRef.current); |
| | | pollTimerRef.current = null; |
| | | } |
| | | }, []); |
| | | |
| | | /** |
| | | * 清理停稳判定定时器。 |
| | | */ |
| | | const clearSettleTimer = useCallback(() => { |
| | | if (settleTimerRef.current) { |
| | | clearTimeout(settleTimerRef.current); |
| | | settleTimerRef.current = null; |
| | | } |
| | | }, []); |
| | | |
| | | /** |
| | | * 清空车辆渲染状态。 |
| | | */ |
| | | const clearVehicles = useCallback(() => { |
| | | renderedVehicleMapRef.current = new Map(); |
| | | setVehicleMarkers([]); |
| | | clearAnimationTimer(); |
| | | }, [clearAnimationTimer]); |
| | | |
| | | /** |
| | | * 停止车辆相关任务并清空车辆。 |
| | | */ |
| | | const disposeVehicles = useCallback(() => { |
| | | clearSettleTimer(); |
| | | clearPollTimer(); |
| | | clearVehicles(); |
| | | activeSearchCenterRef.current = null; |
| | | lastSearchCenterRef.current = null; |
| | | }, [clearPollTimer, clearSettleTimer, clearVehicles]); |
| | | |
| | | /** |
| | | * 将车辆位置数据映射为地图 marker。 |
| | | */ |
| | | const buildVehicleMarkers = useCallback((vehicles: NearbyVehicleItem[]) => { |
| | | return vehicles.map((vehicle) => ({ |
| | | id: hashToMarkerId(vehicle.id), |
| | | markerType: 'vehicle', |
| | | vehicleId: vehicle.id, |
| | | latitude: vehicle.latitude, |
| | | longitude: vehicle.longitude, |
| | | rotate: vehicle.direction, |
| | | width: 20, |
| | | height: 20, |
| | | zIndex: 6, |
| | | anchor: { x: 0.5, y: 0.5 }, |
| | | iconPath: VEHICLE_MARKER_ICON |
| | | })); |
| | | }, []); |
| | | |
| | | /** |
| | | * 基于插值轨迹推进车辆动画帧。 |
| | | */ |
| | | const playAnimationFrame = useCallback(() => { |
| | | const animationState = animationStateRef.current; |
| | | if (!animationState) return; |
| | | const now = Date.now(); |
| | | const progress = Math.max(0, Math.min(1, (now - animationState.startAt) / Math.max(1, animationState.endAt - animationState.startAt))); |
| | | const nextVehicleMap = new Map<string, NearbyVehicleItem>(); |
| | | animationState.tracks.forEach((track) => { |
| | | const lat = track.fromLat + (track.toLat - track.fromLat) * progress; |
| | | const lon = track.fromLon + (track.toLon - track.fromLon) * progress; |
| | | const direction = interpolateDirection(track.fromDirection, track.toDirection, progress); |
| | | nextVehicleMap.set(track.id, { |
| | | id: track.id, |
| | | latitude: lat, |
| | | longitude: lon, |
| | | direction |
| | | }); |
| | | }); |
| | | renderedVehicleMapRef.current = nextVehicleMap; |
| | | setVehicleMarkers(buildVehicleMarkers(Array.from(nextVehicleMap.values()))); |
| | | if (progress >= 1) { |
| | | animationStateRef.current = null; |
| | | animationTimerRef.current = null; |
| | | return; |
| | | } |
| | | animationTimerRef.current = setTimeout(() => { |
| | | playAnimationFrame(); |
| | | }, ANIMATION_FRAME_MS); |
| | | }, [buildVehicleMarkers]); |
| | | |
| | | /** |
| | | * 规范化接口返回,过滤非法经纬度。 |
| | | */ |
| | | const normalizeVehicleList = useCallback((response: any): NearbyVehicleItem[] => { |
| | | const rawList = Array.isArray(response) |
| | | ? response |
| | | : Array.isArray(response?.list) |
| | | ? response.list |
| | | : Array.isArray(response?.vehicles) |
| | | ? response.vehicles |
| | | : []; |
| | | return rawList |
| | | .map((item: any, index: number) => { |
| | | 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}`; |
| | | return { |
| | | id: String(rawId), |
| | | latitude, |
| | | longitude, |
| | | direction: normalizeDirection(Number(item?.direction)) |
| | | }; |
| | | }) |
| | | .filter(Boolean) as NearbyVehicleItem[]; |
| | | }, []); |
| | | |
| | | /** |
| | | * 请求车辆并衔接平滑位移动画。 |
| | | */ |
| | | const fetchAndAnimateVehicles = useCallback(async (center: NearbyCenter) => { |
| | | if (requestLockRef.current) return; |
| | | requestLockRef.current = true; |
| | | const seq = latestSeqRef.current + 1; |
| | | latestSeqRef.current = seq; |
| | | try { |
| | | const response = await getNearbyVehicles({ lat: center.lat, lon: center.lon }); |
| | | if (seq !== latestSeqRef.current) return; |
| | | const normalizedVehicles = normalizeVehicleList(response); |
| | | if (normalizedVehicles.length === 0) { |
| | | clearVehicles(); |
| | | return; |
| | | } |
| | | const now = Date.now(); |
| | | const currentVehicleMap = renderedVehicleMapRef.current; |
| | | const tracks: VehicleTrackItem[] = normalizedVehicles.map((vehicle) => { |
| | | const currentVehicle = currentVehicleMap.get(vehicle.id); |
| | | return { |
| | | id: vehicle.id, |
| | | fromLat: currentVehicle?.latitude ?? vehicle.latitude, |
| | | fromLon: currentVehicle?.longitude ?? vehicle.longitude, |
| | | toLat: vehicle.latitude, |
| | | toLon: vehicle.longitude, |
| | | fromDirection: currentVehicle?.direction ?? vehicle.direction, |
| | | toDirection: vehicle.direction |
| | | }; |
| | | }); |
| | | clearAnimationTimer(); |
| | | animationStateRef.current = { |
| | | startAt: now, |
| | | endAt: now + ANIMATION_DURATION_MS, |
| | | tracks |
| | | }; |
| | | playAnimationFrame(); |
| | | } catch (error) { |
| | | // 请求异常时保持当前车辆状态,避免闪断。 |
| | | } finally { |
| | | requestLockRef.current = false; |
| | | } |
| | | }, [clearAnimationTimer, clearVehicles, normalizeVehicleList, playAnimationFrame]); |
| | | |
| | | /** |
| | | * 启动 5 秒轮询,轮询中心遵循 activeSearchCenterRef。 |
| | | */ |
| | | const ensurePolling = useCallback(() => { |
| | | if (pollTimerRef.current) return; |
| | | pollTimerRef.current = setInterval(() => { |
| | | const center = activeSearchCenterRef.current; |
| | | if (!center || !isOperatingArea) { |
| | | disposeVehicles(); |
| | | return; |
| | | } |
| | | fetchAndAnimateVehicles(center); |
| | | }, POLL_INTERVAL_MS); |
| | | }, [disposeVehicles, fetchAndAnimateVehicles, isOperatingArea]); |
| | | |
| | | /** |
| | | * 激活指定中心点并立即拉起一次车辆查询。 |
| | | */ |
| | | const activateCenterAndFetch = useCallback((center: NearbyCenter) => { |
| | | activeSearchCenterRef.current = center; |
| | | lastSearchCenterRef.current = center; |
| | | fetchAndAnimateVehicles(center); |
| | | ensurePolling(); |
| | | }, [ensurePolling, fetchAndAnimateVehicles]); |
| | | |
| | | /** |
| | | * 首页初始化触发:围栏加载完成且中心点在 ODD 内时立即开始轮询。 |
| | | */ |
| | | const tryStartPollingOnInit = useCallback((payload: { lat: number; lon: number; isInside: boolean }) => { |
| | | if (!payload?.isInside || !isOperatingArea) return; |
| | | const lat = Number(payload.lat); |
| | | const lon = Number(payload.lon); |
| | | if (!Number.isFinite(lat) || !Number.isFinite(lon)) return; |
| | | if (lastSearchCenterRef.current) return; |
| | | activateCenterAndFetch({ lat, lon }); |
| | | }, [activateCenterAndFetch, isOperatingArea]); |
| | | |
| | | /** |
| | | * 地图拖动开始:仅清理停稳判定,不发起请求。 |
| | | */ |
| | | const handleRegionBegin = useCallback(() => { |
| | | clearSettleTimer(); |
| | | }, [clearSettleTimer]); |
| | | |
| | | /** |
| | | * 地图拖动结束:静止 500ms 后按 500 米阈值判断是否更新搜索中心。 |
| | | */ |
| | | const handleRegionSettled = useCallback((payload: { lat: number; lon: number; isInside: boolean }) => { |
| | | clearSettleTimer(); |
| | | settleTimerRef.current = setTimeout(() => { |
| | | if (!payload?.isInside || !isOperatingArea) { |
| | | disposeVehicles(); |
| | | return; |
| | | } |
| | | const currentCenter = { lat: payload.lat, lon: payload.lon }; |
| | | const lastSearchCenter = lastSearchCenterRef.current; |
| | | if (!lastSearchCenter) { |
| | | activateCenterAndFetch(currentCenter); |
| | | return; |
| | | } |
| | | const movedDistance = getDistance(lastSearchCenter.lat, lastSearchCenter.lon, currentCenter.lat, currentCenter.lon); |
| | | if (movedDistance > MOVE_THRESHOLD_METERS) { |
| | | activateCenterAndFetch(currentCenter); |
| | | } else { |
| | | ensurePolling(); |
| | | } |
| | | }, SETTLE_DELAY_MS); |
| | | }, [activateCenterAndFetch, clearSettleTimer, disposeVehicles, ensurePolling, isOperatingArea]); |
| | | |
| | | useEffect(() => { |
| | | if (!isOperatingArea) { |
| | | disposeVehicles(); |
| | | } |
| | | }, [disposeVehicles, isOperatingArea]); |
| | | |
| | | useEffect(() => { |
| | | return () => { |
| | | disposeVehicles(); |
| | | }; |
| | | }, [disposeVehicles]); |
| | | |
| | | return { |
| | | vehicleMarkers, |
| | | handleRegionBegin, |
| | | handleRegionSettled, |
| | | tryStartPollingOnInit, |
| | | disposeVehicles |
| | | }; |
| | | } |
| | |
| | | mapState: any; |
| | | siteState: any; |
| | | toOrigin: () => void; |
| | | onRegionBegin?: () => void; |
| | | onRegionSettled?: (payload: { lat: number; lon: number; isInside: boolean }) => void; |
| | | }; |
| | | |
| | | /** |
| | | * 地图 region-change 编排:处理地图拖拽/缩放后的吸附、回填和自动归位。 |
| | | */ |
| | | export default function useRegionChange({ mapState, siteState, toOrigin }: Deps) { |
| | | export default function useRegionChange({ mapState, siteState, toOrigin, onRegionBegin, onRegionSettled }: Deps) { |
| | | /** |
| | | * 通过中心点主动触发吸附判断。 |
| | | */ |
| | |
| | | return; |
| | | } |
| | | try { |
| | | const { newMarkers, sites } = await mapState.updateMapData(lat, lng); |
| | | const { isInside, newMarkers, sites } = await mapState.updateMapData(lat, lng); |
| | | if (typeof onRegionSettled === 'function') { |
| | | onRegionSettled({ lat, lon: lng, isInside: !!isInside }); |
| | | } |
| | | const nearestCandidates = sites.length > 0 ? sites : newMarkers; |
| | | const { adsorbed, marker } = siteState.tryAdsorb(lat, lng, nearestCandidates); |
| | | if ((causedBy === 'gesture' || causedBy === 'drag' || wasReturningToOrigin) && marker) { |
| | |
| | | } |
| | | if (type === 'begin') { |
| | | siteState.setShowToolTip(false); |
| | | if (typeof onRegionBegin === 'function') { |
| | | onRegionBegin(); |
| | | } |
| | | return; |
| | | } |
| | | if (type !== 'end') return; |
| | |
| | | import Taro, { useDidShow } from "@tarojs/taro" |
| | | import Taro, { useDidHide, useDidShow } from "@tarojs/taro" |
| | | import { useEffect, useState, useRef, useMemo } from 'react'; |
| | | import { View, Map, Text, Image, Button } from '@tarojs/components'; |
| | | import './index.less'; |
| | |
| | | import useHomeBootstrap from './hooks/use-home-bootstrap'; |
| | | // 快捷胶囊编排 |
| | | import useQuickCapsule from './hooks/use-quick-capsule'; |
| | | // 附近车辆编排 |
| | | import useNearbyVehicles from './hooks/use-nearby-vehicles'; |
| | | import { DEFAULT_LATITUDE, DEFAULT_LONGITUDE, FIRST_ENTER_KEY } from '../../../robotaxi/constants/map'; |
| | | import { TripStatus, toTripStatus } from '../../../robotaxi/pages/begin-route/constants/trip-status'; |
| | | import { |
| | |
| | | const { |
| | | longitude, |
| | | latitude, |
| | | markers, |
| | | markers: siteMarkers, |
| | | polygons, |
| | | mapScale, |
| | | isOperatingArea, |
| | |
| | | useDidShow(() => { |
| | | bootstrap.onDidShow(); |
| | | refreshQuickCapsule(); |
| | | if (typeof latitude === 'number' && typeof longitude === 'number') { |
| | | tryStartPollingOnInit({ |
| | | lat: latitude, |
| | | lon: longitude, |
| | | isInside: isOperatingArea |
| | | }); |
| | | } |
| | | }); |
| | | |
| | | useDidHide(() => { |
| | | disposeVehicles(); |
| | | }); |
| | | |
| | | const { |
| | | vehicleMarkers, |
| | | handleRegionBegin: handleVehicleRegionBegin, |
| | | handleRegionSettled: handleVehicleRegionSettled, |
| | | tryStartPollingOnInit, |
| | | disposeVehicles |
| | | } = useNearbyVehicles({ |
| | | isOperatingArea |
| | | }); // 附近车辆编排 |
| | | |
| | | useEffect(() => { |
| | | if (nearbyLoading || isLocationAuthDenied) return; |
| | | if (typeof latitude !== 'number' || typeof longitude !== 'number') return; |
| | | tryStartPollingOnInit({ |
| | | lat: latitude, |
| | | lon: longitude, |
| | | isInside: isOperatingArea |
| | | }); |
| | | }, [nearbyLoading, isLocationAuthDenied, latitude, longitude, isOperatingArea, tryStartPollingOnInit]); |
| | | |
| | | const mergedMapMarkers = useMemo(() => { |
| | | return [...siteMarkers, ...vehicleMarkers]; |
| | | }, [siteMarkers, vehicleMarkers]); // 地图合并 marker 列表 |
| | | |
| | | const { handleRegionChange, queryPointerByCenter } = useRegionChange({ |
| | | mapState, |
| | | siteState, |
| | | toOrigin |
| | | toOrigin, |
| | | onRegionBegin: handleVehicleRegionBegin, |
| | | onRegionSettled: handleVehicleRegionSettled |
| | | }); // 地图 region-change 编排 |
| | | const { |
| | | ensureNetwork, |
| | |
| | | const markerId = e?.detail?.markerId; |
| | | if (markerId === undefined) return; |
| | | |
| | | const targetMarker = markers.find(m => m.id === markerId); |
| | | const targetMarker = mergedMapMarkers.find(m => m.id === markerId); |
| | | if (targetMarker) { |
| | | if (targetMarker.markerType === 'vehicle') { |
| | | return; |
| | | } |
| | | // 忽略因调用 moveToLocation 引起的 region change,避免重复吸附计算 |
| | | shouldIgnoreRegionChange.current = true; |
| | | |
| | |
| | | longitude={longitude ?? DEFAULT_LONGITUDE} |
| | | latitude={latitude ?? DEFAULT_LATITUDE} |
| | | scale={mapScale} |
| | | markers={isLocationAuthDenied ? [] : markers.map(m => ({ ...m, title: '', name: '' }))} |
| | | markers={isLocationAuthDenied ? [] : mergedMapMarkers.map(m => ({ ...m, title: '', name: '' }))} |
| | | polygons={polygons} |
| | | showLocation={!!longitude} |
| | | onRegionChange={handleRegionChange} |
| | |
| | | payload |
| | | }, true); |
| | | } |
| | | |
| | | // 获取附近车辆 |
| | | export const getNearbyVehicles = async (payload: { lat: number; lon: number }) => { |
| | | return request({ |
| | | url: _ROBOTAXI_HOST + '/vehicleConvoy/api/v1/mini-program/vehicle/nearby', |
| | | method: 'GET', |
| | | payload |
| | | }, true); |
| | | } |
| New file |
| | |
| | | // 订阅场景枚举 |
| | | export enum SubscribeSceneKey { |
| | | OrderSuccess = 'ORDER_CREATE_SUCCESS', |
| | | OperationCouponGroup = 'OPERATION_COUPON_GROUP', |
| | | } |
| | | |
| | | // 订阅场景配置类型 |
| | | export type SubscribeSceneConfig = { |
| | | sceneKey: SubscribeSceneKey; |
| | | tmplIds: string[]; |
| | | limit: number; |
| | | needLogin: boolean; |
| | | }; |
| | | |
| | | // 下单成功订阅模板配置 |
| | | export const ORDER_SUCCESS_SUBSCRIBE_CONFIG: SubscribeSceneConfig = { |
| | | sceneKey: SubscribeSceneKey.OrderSuccess, |
| | | tmplIds: [ |
| | | 'NOi0ff4rpuTxcRnnZl0SJxg9SMbvrc8xB8VJgrwu3oo', |
| | | 'elyV30kO0BZAD2OtihJ4Wdu7fwR3FdvCZYCM1BEbqWs' |
| | | ], |
| | | limit: 1, |
| | | needLogin: true |
| | | }; |
| | | |
| | | // 运营活动 + 券效期 + 券到账订阅模板配置 |
| | | export const OPERATION_COUPON_SUBSCRIBE_CONFIG: SubscribeSceneConfig = { |
| | | sceneKey: SubscribeSceneKey.OperationCouponGroup, |
| | | tmplIds: [ |
| | | 'S6gGP8jyuDTkW03h8LhOm2KVDdUtHr0OhL-Mt_PyRPU', |
| | | 'ANtToR63JPCV_ujV69gmO15xCa8O3elnLWB52HLdcHM', |
| | | 'P7z_ksdyzpiiOBO07EJ1AZ6WeHlWs4XveQYjmt9JRE8' |
| | | ], |
| | | limit: 1, |
| | | needLogin: true |
| | | }; |
| | |
| | | 行程费用 |
| | | </View> |
| | | { |
| | | fareBreakdown.operateFree == 1 ? |
| | | fareBreakdown?.operateFree == 1 ? |
| | | <View className="car-card-end-particulars-free"> |
| | | 载客测试免费 |
| | | </View> |
| | |
| | | </View> |
| | | } |
| | | </View> |
| | | <View className="car-card-end-cost"> |
| | | <View className="car-card-end-cost" onClick={toMoneyDetail}> |
| | | <View className="car-card-end-cost-money"> |
| | | 全程{formatTripDistance(fareBreakdown?.kilometer)}|{formatTripDurationFromSeconds(fareBreakdown?.time)} |
| | | </View> |
| | | <Image src={require('../../../../assets/ccc-icon-right.png')} /> |
| | | </View> |
| | | { |
| | | (fareBreakdown?.redPacketMoney + fareBreakdown?.couponMoney > 0 && fareBreakdown.operateFree != 1) && |
| | | <View className="car-card-end-through" onClick={toMoneyDetail}> |
| | | (fareBreakdown?.redPacketMoney + fareBreakdown?.couponMoney > 0 && fareBreakdown?.operateFree != 1) && |
| | | <View className="car-card-end-through"> |
| | | <View className="car-card-end-through-label">优惠活动</View> |
| | | <View className="car-card-end-through-content"> |
| | | <View className="car-card-end-through-content-title">最佳优惠</View> |
| | |
| | | tripStatus === TripStatus.TripFinished && |
| | | <View className="pay-box"> |
| | | { |
| | | fareBreakdown.operateFree != 1 ? |
| | | fareBreakdown?.operateFree != 1 ? |
| | | <View className="pay-box-money"><View>0.00</View><View className="unit">元</View></View> |
| | | : |
| | | <View className="pay-box-free">载客测试免费</View> |
| | |
| | | import { saveOrder, getOrderPayment, cancelOrder, endOrder, routeStaleCheck } from '../../../api/begin-route'; |
| | | import { checkOtp } from '../../../../utils/tools'; |
| | | import { jumpTo } from '../../../../utils/jump'; |
| | | import { subscribeAfterOrderSuccess } from '../../../../utils/subscribe'; |
| | | import { TripStatus } from '../constants/trip-status'; |
| | | |
| | | type Deps = { |
| | |
| | | deps.setOrderId(nextOrderId); |
| | | deps.setTripStatus(TripStatus.Publishing); |
| | | deps.bindSocketListeners(); |
| | | void subscribeAfterOrderSuccess(nextOrderId); |
| | | } catch (error) { |
| | | stopCallSubmitting(); |
| | | } |
| | |
| | | import noStar from '../../../robotaxi/assets/no-star.png' |
| | | import { |
| | | findNearestMarker, |
| | | getAutoScaleBySites, |
| | | getDistance, |
| | | formatGeofencePolygons, |
| | | fetchMapData, |
| | |
| | | import { jumpTo } from '../../../utils/jump'; |
| | | |
| | | export default function SelectMapSite() { |
| | | const AUTO_SCALE_MAX = 17; // 自动缩放最大等级 |
| | | const AUTO_SCALE_MIN = 11; // 自动缩放最小等级 |
| | | |
| | | const [showCustomerService, setShowCustomerService] = useState(false); // 客服弹窗是否显示 |
| | | const [longitude, setLongitude] = useState<number | null>(null); // 经度 |
| | | const [latitude, setLatitude] = useState<number | null>(null); // 纬度 |
| | | const [mapScale, setMapScale] = useState(14); // 地图缩放等级 |
| | | const [showToolTip, setShowToolTip] = useState(true); // 提示是否显示 |
| | | const [bounce, setBounce] = useState(false); // 是否弹跳 |
| | | const [markers, setMarkers] = useState<any[]>([]); // 标记点列表 |
| | |
| | | const waitingRadarSrc = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/waiting-radar.webp'; |
| | | const [nearbyLoading, setNearbyLoading] = useState(false); |
| | | const pendingNearbyRequestCountRef = useRef(0); |
| | | const autoScaleLockedRef = useRef(false); // 自动缩放锁:仅初始化计算一次 |
| | | |
| | | const router = Taro.useRouter(); |
| | | |
| | |
| | | setIsOperatingArea(isInside); |
| | | setMarkers(newMarkers); |
| | | setNearbySites(sites); |
| | | if (!autoScaleLockedRef.current) { |
| | | const autoScale = getAutoScaleBySites(sites?.length > 0 ? sites : newMarkers); |
| | | if (typeof autoScale === 'number') { |
| | | setMapScale((prev) => { |
| | | const nextScale = Math.max(AUTO_SCALE_MIN, Math.min(AUTO_SCALE_MAX, autoScale)); |
| | | if (Math.abs(prev - nextScale) < 0.01) return prev; |
| | | return nextScale; |
| | | }); |
| | | } |
| | | autoScaleLockedRef.current = true; |
| | | } |
| | | |
| | | return { isInside, newMarkers, sites }; |
| | | } finally { |
| | |
| | | id="map" |
| | | longitude={longitude} |
| | | latitude={latitude} |
| | | scale={14} |
| | | scale={mapScale} |
| | | markers={markers.map(m => ({ ...m, title: '', name: '' }))} |
| | | polyline={polyline} |
| | | polygons={polygons} |
| | |
| | | }; |
| | | |
| | | /** |
| | | * 根据站点空间分布估算推荐缩放等级(站点越分散,缩放越小)。 |
| | | */ |
| | | export const getAutoScaleBySites = (sites: any[]) => { |
| | | if (!Array.isArray(sites) || sites.length === 0) return null; |
| | | if (sites.length === 1) return 16; |
| | | let minLat = Number.POSITIVE_INFINITY; |
| | | let maxLat = Number.NEGATIVE_INFINITY; |
| | | let minLng = Number.POSITIVE_INFINITY; |
| | | let maxLng = Number.NEGATIVE_INFINITY; |
| | | sites.forEach((site) => { |
| | | const lat = Number(site?.latitude); |
| | | const lng = Number(site?.longitude); |
| | | if (!Number.isFinite(lat) || !Number.isFinite(lng)) return; |
| | | if (lat < minLat) minLat = lat; |
| | | if (lat > maxLat) maxLat = lat; |
| | | if (lng < minLng) minLng = lng; |
| | | if (lng > maxLng) maxLng = lng; |
| | | }); |
| | | if (!Number.isFinite(minLat) || !Number.isFinite(maxLat) || !Number.isFinite(minLng) || !Number.isFinite(maxLng)) { |
| | | return null; |
| | | } |
| | | const diagonalMeters = getDistance(minLat, minLng, maxLat, maxLng); |
| | | if (diagonalMeters > 12000) return 11; |
| | | if (diagonalMeters > 8000) return 12; |
| | | if (diagonalMeters > 5000) return 13; |
| | | if (diagonalMeters > 3000) return 14; |
| | | if (diagonalMeters > 1500) return 15; |
| | | if (diagonalMeters > 800) return 16; |
| | | return 17; |
| | | }; |
| | | |
| | | /** |
| | | * 计算点 P 在线段 AB 上的投影点(吸附算法) |
| | | * @param p 真实点 {latitude, longitude} |
| | | * @param a 线段起点 {latitude, longitude} |
| | |
| | |
|
| | | import Taro from "@tarojs/taro";
|
| | | import { checkOtp } from "./tools";
|
| | | import {
|
| | | OPERATION_COUPON_SUBSCRIBE_CONFIG,
|
| | | ORDER_SUCCESS_SUBSCRIBE_CONFIG,
|
| | | SubscribeSceneConfig
|
| | | } from "../robotaxi/constants/subscribe";
|
| | |
|
| | | // 需要频控的订阅字段,用于清空订阅次数
|
| | | const subscribeFields = [
|
| | |
| | | export async function addSubscribeCount(type, tmplIds, limit = 3, needlogin = true) {
|
| | | const isLogin = await checkOtp();
|
| | | if (!isLogin && needlogin) return false;
|
| | | const userInfo = Taro.getStorageSync(type);
|
| | | console.log(userInfo, "userInfo");
|
| | | Taro.setStorageSync(type, userInfo + 1 || 1);
|
| | | const userInfo = Number(Taro.getStorageSync(type) || 0);
|
| | | Taro.setStorageSync(type, userInfo + 1);
|
| | | return new Promise((resolve, reject) => {
|
| | | Taro.getSetting({
|
| | | withSubscriptions: true,
|
| | |
| | | return true;
|
| | | }
|
| | | })
|
| | | } |
| | | }
|
| | |
|
| | | // 生成订阅频控 key
|
| | | export function buildSubscribeType(sceneKey: string, bizId?: string | number) {
|
| | | if (bizId === undefined || bizId === null || bizId === '') {
|
| | | return sceneKey;
|
| | | }
|
| | | return `${sceneKey}_${bizId}`;
|
| | | }
|
| | |
|
| | | // 执行通用订阅流程(失败/拒绝不影响主流程)
|
| | | export async function runSubscribeScene(config: SubscribeSceneConfig, bizId?: string | number) {
|
| | | try {
|
| | | const type = buildSubscribeType(config.sceneKey, bizId);
|
| | | const canSubscribe = await addSubscribeCount(type, config.tmplIds, config.limit, config.needLogin);
|
| | | if (!canSubscribe) return;
|
| | | await new Promise<void>((resolve) => {
|
| | | Taro.requestSubscribeMessage({
|
| | | tmplIds: config.tmplIds,
|
| | | success: (result) => {
|
| | | checkSubscribeStatus(type, config.tmplIds, result, config.limit);
|
| | | resolve();
|
| | | },
|
| | | fail: () => {
|
| | | resolve();
|
| | | },
|
| | | complete: () => {
|
| | | resolve();
|
| | | }
|
| | | });
|
| | | });
|
| | | } catch (error) {
|
| | | }
|
| | | }
|
| | |
|
| | | // 下单成功后订阅消息
|
| | | export async function subscribeAfterOrderSuccess(orderId: string) {
|
| | | if (!orderId) return;
|
| | | await runSubscribeScene(ORDER_SUCCESS_SUBSCRIBE_CONFIG, orderId);
|
| | | }
|
| | |
|
| | | // 运营活动 + 券效期 + 券到账三合一模板订阅
|
| | | export async function subscribeOperationCouponGroup(bizId?: string | number) {
|
| | | await runSubscribeScene(OPERATION_COUPON_SUBSCRIBE_CONFIG, bizId);
|
| | | }
|