feat(robotaxi): 完善站点选择与蓝牙解锁全流程
- 为站点搜索、选点页面新增页面类型过滤,仅拉取对应上车/下车点的收藏和附近站点
- 修复发单页面底部布局偏移问题,动态计算选点区域的底部内边距
- 新增手动车门解锁回调逻辑,避免重复触发蓝牙鉴权流程
- 重构埋点参数归一化工具,支持递归展开结构化埋点数据
- 更新地图数据拉取接口,新增点位类型过滤参数
| | |
| | | |
| | | const AUTO_SCALE_MAX = 17; // 自动缩放最大等级 |
| | | const AUTO_SCALE_MIN = 11; // 自动缩放最小等级 |
| | | const HOME_MAP_POINT_ON_OFF = 1; // 首页地图固定拉取上车点 |
| | | |
| | | /** |
| | | * 比较两批站点 marker 的关键展示字段是否一致。 |
| | |
| | | pendingNearbyRequestCountRef.current += 1; |
| | | setNearbyLoading(true); |
| | | try { |
| | | const result = await fetchMapData(lat, lng, code); |
| | | const result = await fetchMapData(lat, lng, code, HOME_MAP_POINT_ON_OFF); |
| | | setIsOperatingArea(result.isInside); |
| | | setElectronicAreaId(result.electronicAreaId); |
| | | setNearbySites(Array.isArray(result?.sites) ? result.sites : []); |
| | |
| | | orderId?: string; |
| | | orderTrunkStatus?: boolean; |
| | | handleOpenOverRoutePopup?: () => void; |
| | | onDoorUnlockSuccess?: () => void; |
| | | }; |
| | | |
| | | export default function DoorTrunkControl({ |
| | | tripStatus, |
| | | orderId, |
| | | orderTrunkStatus, |
| | | handleOpenOverRoutePopup |
| | | handleOpenOverRoutePopup, |
| | | onDoorUnlockSuccess |
| | | }: Props) { |
| | | const [trunkStatus, setTrunkStatus] = useState(orderTrunkStatus); // 后备箱开关状态 |
| | | const [lockStatus, setLockStatus] = useState(false); // 车门解锁状态 |
| | |
| | | if (doorType === 'door') { |
| | | Taro.hideLoading(); |
| | | Taro.showToast({ title: '右侧车门已解锁', icon: 'none' }); |
| | | onDoorUnlockSuccess?.(); |
| | | onSuccess?.(); |
| | | } |
| | | }).catch(() => { |
| | | Taro.hideLoading(); |
| | |
| | | handleCloseAuthBluetoothPopup?: () => void; |
| | | handleCloseSuccessAuthPopup?: () => void; |
| | | handleAuthorizeBluetooth?: () => void; |
| | | onDoorUnlockSuccess?: () => void; |
| | | pageDidShowVersion?: number; |
| | | onOpenCouponSelect?: () => void; |
| | | queuePollingTimeSeconds?: number; |
| | |
| | | handleCloseAuthBluetoothPopup, |
| | | handleCloseSuccessAuthPopup, |
| | | handleAuthorizeBluetooth, |
| | | onDoorUnlockSuccess, |
| | | pageDidShowVersion = 0, |
| | | onOpenCouponSelect, |
| | | queuePollingTimeSeconds = 10 |
| | |
| | | orderId={orderId} |
| | | orderTrunkStatus={orderTrunkStatus} |
| | | handleOpenOverRoutePopup={handleOpenOverRoutePopup} |
| | | onDoorUnlockSuccess={onDoorUnlockSuccess} |
| | | /> |
| | | |
| | | <FareDetailCard |
| | |
| | | orderId={orderId} |
| | | orderTrunkStatus={orderTrunkStatus} |
| | | handleOpenOverRoutePopup={handleOpenOverRoutePopup} |
| | | onDoorUnlockSuccess={onDoorUnlockSuccess} |
| | | /> |
| | | |
| | | <EvaluationCard |
| | |
| | | .select-site { |
| | | position: relative; |
| | | padding: 32px; |
| | | padding-bottom: calc(232px + env(safe-area-inset-bottom)); |
| | | padding-top: 40px; |
| | | background: #F2F3F7; |
| | | border-top: 2px solid #fff; |
| | |
| | | .route-content { |
| | | position: relative; |
| | | margin-top: 28px; |
| | | margin-bottom: 24px; |
| | | z-index: 10; |
| | | display: flex; |
| | | align-items: center; |
| | |
| | | |
| | | export default function ReleaseRoute({ startSite, endSite, callImmediately, fareEstimate = {}, onOpenCouponSelect, handleOpenPassengerNotice, walkingTime = 0, isCallDisabled = false, isRouteSelectExpanded = false, onToggleRouteSelect, routeOptions = [], selectedRouteIndex = 0, handleSelectRoute }: Props) { |
| | | const [boxHeight, setBoxHeight] = useState<number | null>(null); |
| | | const [bottomPaddingHeight, setBottomPaddingHeight] = useState<number | null>(null); |
| | | const startY = useRef(0); |
| | | const startHeight = useRef(0); |
| | | const fullHeight = useRef(0); |
| | |
| | | |
| | | const selectedRoute = routeOptions?.[selectedRouteIndex]; |
| | | const isOperateFree = Number(selectedRoute?.operateFree) === 1; |
| | | const hasDiscountAmount = !isOperateFree && Number(fareEstimate?.discountAmount || 0) > 0; |
| | | |
| | | const formatDiscountAmountText = () => { |
| | | const couponType = Number(fareEstimate?.couponType); |
| | |
| | | const query = Taro.createSelectorQuery(); |
| | | query.select('.route-select').boundingClientRect(); |
| | | query.select('.select-site').boundingClientRect(); |
| | | query.select('.confirm-btn-box-content').boundingClientRect(); |
| | | query.select('.discount-amount').boundingClientRect(); |
| | | query.exec(res => { |
| | | if (res[1]) { |
| | | if (res[1] && res[2]) { |
| | | // 当路线选择没有渲染时,res[0] 为 null,预估高度给 230 |
| | | const routeSelectHeight = res[0] ? res[0].height : 230; |
| | | const siteHeight = res[1].height; |
| | | const confirmBtnBoxContentHeight = res[2].height; |
| | | const discountAmountHeight = res[3] ? res[3].height : 0; |
| | | const nextBottomPaddingHeight = confirmBtnBoxContentHeight + discountAmountHeight; |
| | | |
| | | // 最小高度:仅为选点区域的高度 |
| | | const baseHeight = siteHeight; |
| | |
| | | // 最大高度:选点区域加上上方路线选择的高度 |
| | | const contentHeight = baseHeight + routeSelectHeight; |
| | | |
| | | if (bottomPaddingHeight !== nextBottomPaddingHeight) { |
| | | setBottomPaddingHeight(nextBottomPaddingHeight); |
| | | } |
| | | |
| | | minHeight.current = baseHeight; |
| | | fullHeight.current = contentHeight; |
| | | |
| | | // 只在初次加载未展开时设置基础高度,不要在这里主动将其撑开 |
| | | // 否则在展开(isRouteSelectExpanded变为true)后,这里又会跑一次把它弹高 |
| | | if (boxHeight === null && !isRouteSelectExpanded) { |
| | | // 收起态下同步更新基础高度,避免底部区域高度变化后容器高度仍停留在旧值 |
| | | if (!isRouteSelectExpanded) { |
| | | setBoxHeight(baseHeight); |
| | | } |
| | | } |
| | | }); |
| | | }, 100); |
| | | }, [isRouteSelectExpanded, fareEstimate, walkingTime]); |
| | | }, [isRouteSelectExpanded, fareEstimate, walkingTime, bottomPaddingHeight]); |
| | | |
| | | const handleTouchStart = (e: any) => { |
| | | // 如果没有展开,禁止滑动 |
| | |
| | | <View className="release-route-container"> |
| | | <View |
| | | className='release-route-wrapper' |
| | | style={{ height: boxHeight ? `${boxHeight}px` : 'auto' }} |
| | | style={{ height: typeof boxHeight === 'number' ? `${boxHeight}px` : 'auto' }} |
| | | catchMove={isRouteSelectExpanded} |
| | | onTouchStart={(e) => { |
| | | // 如果没有展开,禁止滑动 |
| | |
| | | handleSelectRoute={handleSelectRoute} |
| | | /> |
| | | )} |
| | | <View className="select-site"> |
| | | <View className="select-site" style={{ paddingBottom: typeof bottomPaddingHeight === 'number' ? `${bottomPaddingHeight}px` : undefined }}> |
| | | <Image className="site-top-bg" src='https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/site-top-bg-20260507.png' /> |
| | | <View className="site-boarding-point"> |
| | | {/* 起点 */} |
| | |
| | | </View> |
| | | <View className="confirm-btn-box"> |
| | | { |
| | | isOperateFree ? null : fareEstimate.discountAmount > 0 && |
| | | hasDiscountAmount && |
| | | <View className="discount-amount" onClick={onOpenCouponSelect}> |
| | | <View className="flex-center"> |
| | | <View className="discount-amount-text">最佳优惠</View> |
| | |
| | | type ConnectStatus = 0 | 1 | 2 | 3; |
| | | const RETRY_UNLOCK_MESSAGE = '请点击解锁按钮解锁车门'; // 鉴权失败/解锁失败统一提示文案 |
| | | const UNLOCK_SUCCESS_MESSAGE = '车门已解锁,请上车'; // 解锁成功提示文案 |
| | | const BLUETOOTH_AUTH_ACTIVE_TRIP_STATUSES = [TripStatus.VehicleApproaching, TripStatus.VehicleArrivedPickup] as const; // 仅这两个状态允许处理蓝牙鉴权回调 |
| | | |
| | | /** |
| | | * 归一化 MAC 地址,统一去空格并转大写。 |
| | |
| | | const certificateRef = useRef(''); // 当前会话证书缓存 |
| | | const macAddressRef = useRef(''); // 当前会话主蓝牙地址缓存 |
| | | const deviceModelRef = useRef(resolveDeviceModel()); // 当前设备型号缓存 |
| | | const latestTripStatusRef = useRef(tripStatus); // 最新行程状态引用,供异步蓝牙回调读取 |
| | | const unlockFinishedRef = useRef(false); // 当前轮次是否已出现解锁成功 |
| | | const manualDoorUnlockRef = useRef(false); // 当前轮次是否已手动解锁,命中后停止后续蓝牙鉴权 |
| | | const acceptBluetoothCallbacksRef = useRef(false); // 当前轮次是否继续接收蓝牙回调,离开 4/5 或收口后立即关闭 |
| | | const [connectStatus, setConnectStatus] = useState<ConnectStatus>(0); // 蓝牙连接状态:0断开/1连接中/2已连接/3已鉴权 |
| | | const [authSuccess, setAuthSuccess] = useState(false); // RtAuth 鉴权是否成功 |
| | | const [isAuthorizing, setIsAuthorizing] = useState(false); // 鉴权流程进行中 |
| | |
| | | icon: 'none' |
| | | }); |
| | | }; // 统一展示蓝牙解锁结果提示 |
| | | latestTripStatusRef.current = tripStatus; |
| | | |
| | | /** |
| | | * 判断当前蓝牙异步回调是否仍属于有效轮次。 |
| | | */ |
| | | const shouldIgnoreBluetoothCallback = (callbackName: string) => { |
| | | const isTripStatusActive = BLUETOOTH_AUTH_ACTIVE_TRIP_STATUSES.includes(latestTripStatusRef.current); |
| | | if (acceptBluetoothCallbacksRef.current && isTripStatusActive) { |
| | | return false; |
| | | } |
| | | console.log('[蓝牙] 忽略过期回调:', { |
| | | callbackName, |
| | | tripStatus: latestTripStatusRef.current, |
| | | acceptBluetoothCallbacks: acceptBluetoothCallbacksRef.current |
| | | }); |
| | | return true; |
| | | }; |
| | | |
| | | /** |
| | | * 主动断开蓝牙连接并重置状态。 |
| | | */ |
| | | const closeConnection = () => { |
| | | console.log('[蓝牙] 主动断开连接'); |
| | | acceptBluetoothCallbacksRef.current = false; |
| | | if (managerRef.current) { |
| | | managerRef.current.closeConnection(); |
| | | } |
| | |
| | | |
| | | managerRef.current = new RtAuthBluetoothManager({ |
| | | onStatusChange: (status: ConnectStatus) => { |
| | | if (shouldIgnoreBluetoothCallback('onStatusChange')) { |
| | | return; |
| | | } |
| | | console.log('[蓝牙] 连接状态变更:', status); |
| | | setConnectStatus(status); |
| | | if (status === 3) { |
| | |
| | | } |
| | | }, |
| | | onError: (error: any) => { |
| | | if (shouldIgnoreBluetoothCallback('onError')) { |
| | | return; |
| | | } |
| | | console.log('[蓝牙] 连接错误:', error); |
| | | isAuthorizingRef.current = false; |
| | | if (unlockFinishedRef.current) { |
| | |
| | | setIsAuthorizing(false); |
| | | }, |
| | | onRtAuthResult: (result: any) => { |
| | | if (shouldIgnoreBluetoothCallback('onRtAuthResult')) { |
| | | return; |
| | | } |
| | | console.log('[蓝牙] 鉴权结果:', result); |
| | | if (result?.success) { |
| | | authSuccessRef.current = true; |
| | |
| | | setIsAuthorizing(false); |
| | | }, |
| | | onRtNotification: (result: any) => { |
| | | if (shouldIgnoreBluetoothCallback('onRtNotification')) { |
| | | return; |
| | | } |
| | | console.log('[蓝牙] 解锁通知:', result); |
| | | if (result?.type === 'pollingUnlock') { |
| | | const isAlreadyUnlocked = Number(result?.data?.result) === 2; // RtNotification 挂载在 data,result=2 表示车辆已解锁 |
| | |
| | | isAuthorizing: isAuthorizingRef.current, |
| | | authSuccess: authSuccessRef.current |
| | | }); |
| | | if (![TripStatus.VehicleApproaching, TripStatus.VehicleArrivedPickup].includes(tripStatus)) return; |
| | | if (!BLUETOOTH_AUTH_ACTIVE_TRIP_STATUSES.includes(tripStatus)) return; |
| | | if (!orderId) return; |
| | | if (manualDoorUnlockRef.current) { |
| | | console.log('[蓝牙] 当前轮次已手动解锁,跳过后续鉴权请求'); |
| | | return; |
| | | } |
| | | if (startAuthTaskRef.current) return startAuthTaskRef.current; |
| | | |
| | | const startKey = `${orderId}`; |
| | | if (isAuthorizingRef.current || (authSuccessRef.current && lastStartKeyRef.current === startKey)) return; |
| | | lastStartKeyRef.current = startKey; |
| | | unlockFinishedRef.current = false; |
| | | acceptBluetoothCallbacksRef.current = true; |
| | | isAuthorizingRef.current = true; |
| | | setIsAuthorizing(true); |
| | | setAuthError(''); |
| | | const runTask = async () => { |
| | | try { |
| | | const tokenRes = await getBluetoothUnlockToken(orderId); |
| | | if (manualDoorUnlockRef.current) { |
| | | console.log('[蓝牙] 用户已手动解锁,忽略鉴权凭证返回结果'); |
| | | isAuthorizingRef.current = false; |
| | | setIsAuthorizing(false); |
| | | return; |
| | | } |
| | | const tokenPayload: BluetoothTokenPayload = typeof tokenRes === 'object' && tokenRes !== null ? tokenRes as BluetoothTokenPayload : {}; |
| | | const certificate = typeof tokenPayload.token === 'string' ? tokenPayload.token : String(tokenPayload.token || ''); |
| | | const mainMacAddress = normalizeMac(tokenPayload.macAddress); |
| | |
| | | certificate, |
| | | deviceModel: deviceModelRef.current |
| | | }); |
| | | if (manualDoorUnlockRef.current) { |
| | | console.log('[蓝牙] 用户已手动解锁,停止打开蓝牙适配器'); |
| | | isAuthorizingRef.current = false; |
| | | setIsAuthorizing(false); |
| | | return; |
| | | } |
| | | console.log('[蓝牙] 准备打开蓝牙适配器'); |
| | | manager.openBluetoothAdapter(); |
| | | } catch (error: any) { |
| | | if (manualDoorUnlockRef.current) { |
| | | console.log('[蓝牙] 手动解锁后忽略鉴权异常:', error); |
| | | isAuthorizingRef.current = false; |
| | | setIsAuthorizing(false); |
| | | return; |
| | | } |
| | | console.log('[蓝牙] 鉴权流程异常:', error); |
| | | isAuthorizingRef.current = false; |
| | | setAuthError(error?.msg || '获取蓝牙鉴权凭证失败'); |
| | |
| | | */ |
| | | const retryAuth = useCallback(() => { |
| | | console.log('[蓝牙] 重试鉴权'); |
| | | if (manualDoorUnlockRef.current) { |
| | | console.log('[蓝牙] 当前轮次已手动解锁,忽略重试鉴权'); |
| | | return; |
| | | } |
| | | if (!managerRef.current) { |
| | | startAuth(); |
| | | return; |
| | | } |
| | | acceptBluetoothCallbacksRef.current = true; |
| | | isAuthorizingRef.current = true; |
| | | setIsAuthorizing(true); |
| | | setAuthError(''); |
| | | managerRef.current.retryConnection(); |
| | | }, [startAuth]); |
| | | |
| | | /** |
| | | * 用户手动解锁成功后,停止当前轮次剩余的蓝牙鉴权流程。 |
| | | */ |
| | | const markManualDoorUnlock = useCallback(() => { |
| | | console.log('[蓝牙] 用户已手动解锁,停止后续蓝牙鉴权'); |
| | | manualDoorUnlockRef.current = true; |
| | | unlockFinishedRef.current = true; |
| | | acceptBluetoothCallbacksRef.current = false; |
| | | setAuthError(''); |
| | | closeConnectionRef.current(); |
| | | }, []); |
| | | |
| | | useEffect(() => { |
| | | if (![TripStatus.VehicleApproaching, TripStatus.VehicleArrivedPickup].includes(tripStatus)) { |
| | | if (!BLUETOOTH_AUTH_ACTIVE_TRIP_STATUSES.includes(tripStatus)) { |
| | | console.log('[蓝牙] 当前状态非4/5,断开连接:', tripStatus); |
| | | acceptBluetoothCallbacksRef.current = false; |
| | | closeConnectionRef.current(); |
| | | lastStartKeyRef.current = ''; |
| | | certificateRef.current = ''; |
| | |
| | | authSuccessRef.current = false; |
| | | isAuthorizingRef.current = false; |
| | | unlockFinishedRef.current = false; |
| | | manualDoorUnlockRef.current = false; |
| | | setAuthError(''); |
| | | } |
| | | }, [tripStatus]); |
| | |
| | | authError, |
| | | isAuthorizing, |
| | | startAuth, |
| | | markManualDoorUnlock, |
| | | retryAuth, |
| | | closeConnection |
| | | }; |
| | |
| | | handleCloseSuccessAuthPopup: () => void; |
| | | /** 去授权蓝牙 */ |
| | | handleAuthorizeBluetooth: () => void; |
| | | /** 手动解锁车门成功 */ |
| | | onDoorUnlockSuccess: () => void; |
| | | /** 页面 didShow 版本号 */ |
| | | pageDidShowVersion: number; |
| | | /** 排队位次轮询频率(秒) */ |
| | |
| | | handleCloseAuthBluetoothPopup={deps.handleCloseAuthBluetoothPopup} |
| | | handleCloseSuccessAuthPopup={deps.handleCloseSuccessAuthPopup} |
| | | handleAuthorizeBluetooth={deps.handleAuthorizeBluetooth} |
| | | onDoorUnlockSuccess={deps.onDoorUnlockSuccess} |
| | | pageDidShowVersion={deps.pageDidShowVersion} |
| | | queuePollingTimeSeconds={deps.queuePollingTimeSeconds} |
| | | /> |
| | |
| | | } = orderActions; |
| | | const { |
| | | authSuccess: bluetoothAuthSuccess, |
| | | startAuth: startBluetoothAuth |
| | | startAuth: startBluetoothAuth, |
| | | markManualDoorUnlock |
| | | } = useBluetoothCarControl({ |
| | | orderId, |
| | | tripStatus |
| | |
| | | handleCloseAuthBluetoothPopup, |
| | | handleCloseSuccessAuthPopup, |
| | | handleAuthorizeBluetooth, |
| | | onDoorUnlockSuccess: markManualDoorUnlock, |
| | | pageDidShowVersion, |
| | | queuePollingTimeSeconds |
| | | }); // 视图渲染委托 |
| | |
| | | |
| | | const AUTO_SCALE_MAX = 17; // 自动缩放最大等级 |
| | | const AUTO_SCALE_MIN = 11; // 自动缩放最小等级 |
| | | const SELECT_MAP_POINT_ON_OFF = { |
| | | start: 1, |
| | | end: 2 |
| | | } as const; // 选点地图站点开关:1=上车点,2=下车点 |
| | | |
| | | /** |
| | | * 选点页业务编排:聚合状态容器与副作用 hooks,并对外提供稳定页面接口。 |
| | |
| | | */ |
| | | const updateMapData = async (lat: number, lng: number) => { |
| | | const selectedCity = Taro.getStorageSync('selectedCity'); |
| | | const pointOnOff = state.pageType === 'start' |
| | | ? SELECT_MAP_POINT_ON_OFF.start |
| | | : SELECT_MAP_POINT_ON_OFF.end; |
| | | state.pendingNearbyRequestCountRef.current += 1; |
| | | state.setNearbyLoading(true); |
| | | try { |
| | | const { isInside, newMarkers, sites } = await fetchMapData(lat, lng, selectedCity?.code); |
| | | const { isInside, newMarkers, sites } = await fetchMapData(lat, lng, selectedCity?.code, pointOnOff); |
| | | state.setIsOperatingArea(isInside); |
| | | state.setMarkers(newMarkers); |
| | | state.setNearbySites(sites); |
| | |
| | | import './index.less'; |
| | | import { getCollectPoints, collectPoint } from '../../../../api/site-search'; |
| | | |
| | | const COLLECT_POINT_ON_OFF = { |
| | | start: 1, |
| | | end: 2 |
| | | } as const; // 收藏点位开关:1=上车点,2=下车点 |
| | | |
| | | type Props = { |
| | | show: boolean; |
| | | onClose: () => void; |
| | | onSelect: (item: any) => void; |
| | | pageType: string; |
| | | }; |
| | | |
| | | export default function FavoritePopup({ show, onClose, onSelect }: Props) { |
| | | export default function FavoritePopup({ show, onClose, onSelect, pageType }: Props) { |
| | | const [list, setList] = useState<any[]>([]); |
| | | const [visible, setVisible] = useState(false); |
| | | const [animationData, setAnimationData] = useState<any>(); |
| | |
| | | const fetchData = async (page = 1) => { |
| | | if (loading) return; |
| | | setLoading(true); |
| | | const pointOnOff = pageType === 'start' |
| | | ? COLLECT_POINT_ON_OFF.start |
| | | : COLLECT_POINT_ON_OFF.end; |
| | | try { |
| | | const res = await getCollectPoints({ |
| | | pageNum: page, |
| | | pageSize: 999, |
| | | pointOnOff, |
| | | }); |
| | | if (res && res.records) { |
| | | setList(prev => page === 1 ? res.records : [...prev, ...res.records]); |
| | |
| | | maxShowCount: number; |
| | | }; |
| | | |
| | | const SEARCH_POINT_ON_OFF = { |
| | | start: 1, |
| | | end: 2 |
| | | } as const; // 搜索点位开关:1=上车点,2=下车点 |
| | | |
| | | export default function useSiteSearch(deps: Deps) { |
| | | // 推荐站点列表 |
| | | const [siteList, setSiteList] = useState<any[]>([]); |
| | |
| | | canUpdate?: () => boolean |
| | | ) => { |
| | | const selectedCity = Taro.getStorageSync('selectedCity'); |
| | | const currentPageType = pageType || deps.router.params?.pageType || ''; |
| | | const pointOnOff = currentPageType === 'start' |
| | | ? SEARCH_POINT_ON_OFF.start |
| | | : SEARCH_POINT_ON_OFF.end; |
| | | try { |
| | | const res = await getNearestSite({ |
| | | cityCode: selectedCity?.code || '', |
| | | latitude: Number(lat), |
| | | longitude: Number(lon), |
| | | electronicAreaId |
| | | electronicAreaId, |
| | | pointOnOff |
| | | }); |
| | | if (canUpdate && !canUpdate()) { |
| | | return; |
| | |
| | | setIsSearching(true); |
| | | const { latitude, longitude, electronicAreaId } = deps.router.params; |
| | | const selectedCity = Taro.getStorageSync('selectedCity'); |
| | | const currentPageType = pageType || deps.router.params?.pageType || ''; |
| | | const pointOnOff = currentPageType === 'start' |
| | | ? SEARCH_POINT_ON_OFF.start |
| | | : SEARCH_POINT_ON_OFF.end; |
| | | const payload = { |
| | | cityCode: selectedCity?.code || '', |
| | | electronicAreaId: electronicAreaId ? Number(electronicAreaId) : 0, |
| | | lat: Number(latitude), |
| | | lon: Number(longitude), |
| | | pointPoi: normalizedQuery |
| | | pointPoi: normalizedQuery, |
| | | pointOnOff |
| | | }; |
| | | try { |
| | | const res = await queryPointByName(payload); |
| | |
| | | }; |
| | | }, []); |
| | | // 初始化:拉取附近站点、历史记录、页面类型 |
| | | |
| | | |
| | | useEffect(() => { |
| | | refreshData(); |
| | | const { pageType: type } = deps.router.params; |
| | |
| | | show={showFavoritePopup} |
| | | onClose={handleCloseFavoritePopup} |
| | | onSelect={handleSelectFavorite} |
| | | pageType={pageType} |
| | | /> |
| | | </View> |
| | | ) |
| | |
| | | } |
| | | |
| | | // 获取地图数据(围栏检查+附近站点) |
| | | export const fetchMapData = async (lat: number, lng: number, cityCode?: string) => { |
| | | export const fetchMapData = async (lat: number, lng: number, cityCode?: string, pointOnOff?: number) => { |
| | | try { |
| | | // 1. 先检查是否在运营区域内 |
| | | const fenceRes: any = await getFenceCheck({ latitude: lat, longitude: lng, cityCode }).catch(e => { |
| | |
| | | // 2. 只有在运营区域内才获取附近站点 |
| | | let sites: any = { points: [] }; |
| | | if (isInside) { |
| | | sites = await getNearestSite({ latitude: lat, longitude: lng, electronicAreaId }).catch(e => { |
| | | sites = await getNearestSite({ latitude: lat, longitude: lng, electronicAreaId, pointOnOff }).catch(e => { |
| | | return { points: [] }; |
| | | }); |
| | | // 兼容处理 |
| | |
| | | }; |
| | | }; |
| | | |
| | | /** 判断埋点字段是否为需要自动 JSON 化的结构化数据。 */ |
| | | const isStructuredReportValue = (value: any) => { |
| | | /** 判断值是否为普通对象。 */ |
| | | const isPlainReportObject = (value: any) => Object.prototype.toString.call(value) === '[object Object]'; |
| | | |
| | | /** 判断值是否为可继续展开的结构化数据。 */ |
| | | const isExpandableReportValue = (value: any) => Array.isArray(value) || isPlainReportObject(value); |
| | | |
| | | /** 递归展开埋点字段:对象使用前缀拼接,数组使用索引展开。 */ |
| | | const flattenReportParam = ( |
| | | result: Record<string, any>, |
| | | keyPrefix: string, |
| | | value: any |
| | | ) => { |
| | | if (!keyPrefix) { |
| | | return; |
| | | } |
| | | if (!isExpandableReportValue(value)) { |
| | | result[keyPrefix] = value; |
| | | return; |
| | | } |
| | | if (Array.isArray(value)) { |
| | | return true; |
| | | value.forEach((item, index) => { |
| | | flattenReportParam(result, `${keyPrefix}_${index}`, item); |
| | | }); |
| | | return; |
| | | } |
| | | return Object.prototype.toString.call(value) === '[object Object]'; |
| | | Object.keys(value).forEach((childKey) => { |
| | | flattenReportParam(result, `${keyPrefix}_${childKey}`, value[childKey]); |
| | | }); |
| | | }; |
| | | |
| | | /** 序列化单个埋点字段,避免对象被底层隐式转成 [object Object]。 */ |
| | | const serializeReportValue = (value: any) => { |
| | | if (!isStructuredReportValue(value)) { |
| | | return value; |
| | | } |
| | | try { |
| | | return JSON.stringify(value); |
| | | } catch (error) { |
| | | return Array.isArray(value) ? '[]' : '{}'; |
| | | } |
| | | }; |
| | | |
| | | /** 归一化自定义埋点参数:仅自动 JSON 化顶层对象/数组字段。 */ |
| | | /** 归一化自定义埋点参数:对象按前缀法递归展开。 */ |
| | | const normalizeReportParams = (para: any) => { |
| | | if (!para || Object.prototype.toString.call(para) !== '[object Object]') { |
| | | if (!para || !isPlainReportObject(para)) { |
| | | return {}; |
| | | } |
| | | return Object.keys(para).reduce((result: Record<string, any>, key: string) => { |
| | | result[key] = serializeReportValue(para[key]); |
| | | flattenReportParam(result, key, para[key]); |
| | | return result; |
| | | }, {}); |
| | | }; |