feat(robotaxi): 新增用户位置快照, 优化埋点和站点状态管理
- 新增用户当前位置快照回调机制,在定位完成后同步位置信息供全页面使用
- 重构埋点工具函数,新增结构化参数序列化、页面上下文自动采集能力
- 新增下单支付流程埋点与修改上下车点按钮的埋点上报逻辑
- 新增附近站点状态管理,完善各业务hook间的数据传递链路
| | |
| | | cityState: any; |
| | | isLoggedIn: boolean; |
| | | onLocationSettled: (lat: number, lng: number, isInside: boolean) => void; |
| | | onUserLocationResolved?: (location: { |
| | | lat: number; |
| | | lng: number; |
| | | poi_id: string; |
| | | poi_name: string; |
| | | city: string; |
| | | }) => void; |
| | | }; |
| | | |
| | | type GetLocationInfoOptions = { |
| | |
| | | /** |
| | | * 定位流程编排:不持有状态,仅组合 map/site/city 状态 hook 完成定位主链路。 |
| | | */ |
| | | export default function useLocationFlow({ mapState, siteState, cityState, isLoggedIn, onLocationSettled }: Deps) { |
| | | export default function useLocationFlow({ |
| | | mapState, |
| | | siteState, |
| | | cityState, |
| | | isLoggedIn, |
| | | onLocationSettled, |
| | | onUserLocationResolved |
| | | }: Deps) { |
| | | /** |
| | | * 同步用户当前位置快照,供页面埋点直接复用。 |
| | | */ |
| | | const syncUserCurrentLocation = (payload: { |
| | | latitude: number; |
| | | longitude: number; |
| | | poiId?: any; |
| | | poiName?: any; |
| | | city?: any; |
| | | }) => { |
| | | if (typeof payload.latitude !== 'number' || typeof payload.longitude !== 'number') { |
| | | return; |
| | | } |
| | | onUserLocationResolved?.({ |
| | | lat: payload.latitude, |
| | | lng: payload.longitude, |
| | | poi_id: String(payload.poiId || ''), |
| | | poi_name: String(payload.poiName || ''), |
| | | city: String(payload.city || '') |
| | | }); |
| | | }; |
| | | /** |
| | | * 解析当前城市下仍然有效的手动确认上车点。 |
| | | */ |
| | |
| | | latitude: res.latitude, |
| | | longitude: res.longitude |
| | | }); |
| | | syncUserCurrentLocation({ |
| | | latitude: res.latitude, |
| | | longitude: res.longitude, |
| | | poiName: cityName, |
| | | city: cityName |
| | | }); |
| | | } catch (error) { |
| | | mapState.setLatitude(DEFAULT_LATITUDE); |
| | | mapState.setLongitude(DEFAULT_LONGITUDE); |
| | |
| | | false, |
| | | forceCityRefresh |
| | | ); |
| | | syncUserCurrentLocation({ |
| | | latitude: res.latitude, |
| | | longitude: res.longitude, |
| | | poiId: addressData?.id ?? addressData?.poiId, |
| | | poiName: addressData?.name ?? addressData?.desc, |
| | | city: cityInfo?.name || cachedCity?.name || '' |
| | | }); |
| | | if (cityInfo?.code) { |
| | | const { isInside, newMarkers, sites } = await mapState.updateMapData(res.latitude, res.longitude, cityInfo.code); |
| | | const lockedManualStartSite = forceNearestSite ? null : resolveLockedManualStartSite(cityInfo.code); |
| | |
| | | const [mapScale, setMapScale] = useState(14); |
| | | const [isOperatingArea, setIsOperatingArea] = useState(true); |
| | | const [electronicAreaId, setElectronicAreaId] = useState(''); |
| | | const [nearbySites, setNearbySites] = useState<any[]>([]); |
| | | const [isLocationAuthDenied, setIsLocationAuthDenied] = useState(false); |
| | | const [nearbyLoading, setNearbyLoading] = useState(false); |
| | | |
| | |
| | | const result = await fetchMapData(lat, lng, code); |
| | | setIsOperatingArea(result.isInside); |
| | | setElectronicAreaId(result.electronicAreaId); |
| | | setNearbySites(Array.isArray(result?.sites) ? result.sites : []); |
| | | if (options?.commitMarkers !== false) { |
| | | scheduleMarkerCommit(result.newMarkers, options?.markerCommitDelayMs || 0); |
| | | } |
| | |
| | | mapScale, |
| | | isOperatingArea, |
| | | electronicAreaId, |
| | | nearbySites, |
| | | isLocationAuthDenied, |
| | | nearbyLoading, |
| | | setLongitude, |
| | |
| | | setMapScale, |
| | | setIsOperatingArea, |
| | | setElectronicAreaId, |
| | | setNearbySites, |
| | | setIsLocationAuthDenied, |
| | | shouldIgnoreRegionChange, |
| | | isReturningToOrigin, |
| | |
| | | electronicAreaId: string; |
| | | /** 当前选中站点 */ |
| | | currentSite: any; |
| | | /** 首页确认上车点。 */ |
| | | preferredStartSite: any; |
| | | /** 用户当前位置快照。 */ |
| | | userCurrentLocation: any; |
| | | /** 附近上车站点列表。 */ |
| | | nearbyPickupStations: any[]; |
| | | /** 推荐目的地站点。 */ |
| | | recommendedDropoffStation: any; |
| | | /** 当前选中城市 */ |
| | | selectedCity: any; |
| | | /** 地图围栏 */ |
| | |
| | | queryPointerByCenter: (lat: number, lng: number) => Promise<void>; |
| | | }; |
| | | |
| | | /** 修改上车点按钮埋点事件标识。 */ |
| | | const EDIT_PICKUP_EVENT_ID = 'RT_xcx_Click_RideHome_EditPickup_Button'; |
| | | |
| | | /** 修改下车点按钮埋点事件标识。 */ |
| | | const EDIT_DROPOFF_EVENT_ID = 'RT_xcx_Click_RideHome_EditDropoff_Button'; |
| | | |
| | | /** |
| | | * 首页导航与页面操作编排。 |
| | | */ |
| | | export default function useNavigation(deps: Deps) { |
| | | /** |
| | | * 归一化当前位置埋点字段。 |
| | | */ |
| | | const createUserCurrentLocationPayload = () => ({ |
| | | lat: Number(deps.userCurrentLocation?.lat || 0), |
| | | lng: Number(deps.userCurrentLocation?.lng || 0), |
| | | poi_id: String(deps.userCurrentLocation?.poi_id || ''), |
| | | poi_name: String(deps.userCurrentLocation?.poi_name || ''), |
| | | city: String(deps.userCurrentLocation?.city || deps.selectedCity?.name || '') |
| | | }); |
| | | |
| | | /** |
| | | * 归一化推荐上车站点埋点字段。 |
| | | */ |
| | | const createRecommendedPickupStationPayload = () => { |
| | | const recommendedPickupStation = deps.currentSite?.id |
| | | ? deps.currentSite |
| | | : (deps.preferredStartSite || resolveHomeStartSiteDisplaySite() || {}); |
| | | return { |
| | | poi_id: String(recommendedPickupStation?.id || ''), |
| | | poi_name: String(recommendedPickupStation?.name || ''), |
| | | distance: Number(recommendedPickupStation?.distance || 0), |
| | | city: String(recommendedPickupStation?.city || deps.selectedCity?.name || '') |
| | | }; |
| | | }; |
| | | |
| | | /** |
| | | * 归一化推荐目的地站点埋点字段。 |
| | | */ |
| | | const createRecommendedDropoffStationPayload = () => ({ |
| | | poi_id: String(deps.recommendedDropoffStation?.id || ''), |
| | | poi_name: String(deps.recommendedDropoffStation?.name || ''), |
| | | city: String(deps.recommendedDropoffStation?.city || deps.selectedCity?.name || '') |
| | | }); |
| | | |
| | | /** |
| | | * 上报首页修改上下车点埋点。 |
| | | */ |
| | | const reportEditStationEvent = (type: string) => { |
| | | const eventId = type === 'end' ? EDIT_DROPOFF_EVENT_ID : EDIT_PICKUP_EVENT_ID; |
| | | reportEvent(eventId, { |
| | | user_cur_loc: createUserCurrentLocationPayload(), |
| | | recommended_pickup_station: createRecommendedPickupStationPayload(), |
| | | nearby_pickup_stations: Array.isArray(deps.nearbyPickupStations) ? deps.nearbyPickupStations : [], |
| | | recommended_dropoff_station: createRecommendedDropoffStationPayload() |
| | | }); |
| | | }; |
| | | |
| | | /** |
| | | * 网络可用性检查。 |
| | | */ |
| | |
| | | } |
| | | if (isToMapStart) { |
| | | Taro.setStorageSync('startSite', deps.currentSite); |
| | | reportEditStationEvent(type); |
| | | Taro.navigateTo({ |
| | | url: '/robotaxi/pages/select-map-site/index?pageType=' + type, |
| | | success: () => reportEvent('RT_xcx_search_click', { element_type: 'start' }) |
| | |
| | | return; |
| | | } |
| | | Taro.setStorageSync('startSite', effectiveStartSite); |
| | | reportEditStationEvent(type); |
| | | Taro.navigateTo({ |
| | | url: `/robotaxi/pages/site-search/index?pageType=${type}&electronicAreaId=${effectiveStartSite.electronicAreaId}&longitude=${effectiveStartSite.longitude}&latitude=${effectiveStartSite.latitude}`, |
| | | success: () => { |
| | |
| | | }); |
| | | return; |
| | | } |
| | | reportEditStationEvent(type); |
| | | Taro.navigateTo({ |
| | | url: `/robotaxi/pages/site-search/index?pageType=${type}&electronicAreaId=${deps.electronicAreaId}&longitude=${deps.currentSite.longitude}&latitude=${deps.currentSite.latitude}`, |
| | | success: () => { |
| | |
| | | ensureSelectedCityReady: (lng: number, lat: number, skipCityEffect?: boolean, forceRefresh?: boolean) => Promise<any>; |
| | | /** 最近城市编码引用 */ |
| | | lastSelectedCityCodeRef: React.MutableRefObject<string>; |
| | | /** 同步用户当前位置快照。 */ |
| | | onUserLocationResolved?: (location: { |
| | | lat: number; |
| | | lng: number; |
| | | poi_id: string; |
| | | poi_name: string; |
| | | city: string; |
| | | }) => void; |
| | | }; |
| | | |
| | | /** |
| | |
| | | deps.setLatitude(loc.latitude); |
| | | deps.setLongitude(loc.longitude); |
| | | const cityInfo = await deps.ensureSelectedCityReady(loc.longitude, loc.latitude, false, true); |
| | | deps.onUserLocationResolved?.({ |
| | | lat: loc.latitude, |
| | | lng: loc.longitude, |
| | | poi_id: '', |
| | | poi_name: '', |
| | | city: String(cityInfo?.name || Taro.getStorageSync('selectedCity')?.name || '') |
| | | }); |
| | | if (cityInfo?.code) { |
| | | deps.lastSelectedCityCodeRef.current = cityInfo.code; |
| | | } |
| | |
| | | } from './hooks/use-home-start-site-display'; |
| | | |
| | | type HomeTripStatus = TripStatus | 0; |
| | | type UserCurrentLocation = { |
| | | lat: number; |
| | | lng: number; |
| | | poi_id: string; |
| | | poi_name: string; |
| | | city: string; |
| | | }; |
| | | |
| | | /** |
| | | * Robotaxi 首页页面容器:仅负责组合领域 hook 与布局组件。 |
| | |
| | | const [overlayAnchorBottom, setOverlayAnchorBottom] = useState<number | null>(null); // 地图浮层锚点 |
| | | const [temperatureUnit, setTemperatureUnit] = useState(DEFAULT_TEMPERATURE_UNIT); // 温度单位配置 |
| | | const [preferredStartSite, setPreferredStartSite] = useState<any>(null); // 首页确认上车点 |
| | | const [userCurrentLocation, setUserCurrentLocation] = useState<UserCurrentLocation | null>(null); // 用户当前位置快照 |
| | | |
| | | const mapState = useMapState(); // 地图领域状态 |
| | | const siteState = useSiteState(); // 站点吸附状态 |
| | |
| | | mapScale, |
| | | isOperatingArea, |
| | | electronicAreaId, |
| | | nearbySites, |
| | | isLocationAuthDenied, |
| | | setLongitude, |
| | | setLatitude, |
| | |
| | | siteState, |
| | | cityState, |
| | | isLoggedIn, |
| | | onLocationSettled: syncStationFenceVisibility |
| | | onLocationSettled: syncStationFenceVisibility, |
| | | onUserLocationResolved: setUserCurrentLocation |
| | | }); |
| | | const { toOrigin } = useOriginAction({ |
| | | isLocationAuthDenied, |
| | |
| | | setLatitude, |
| | | setLongitude, |
| | | ensureSelectedCityReady, |
| | | lastSelectedCityCodeRef |
| | | lastSelectedCityCodeRef, |
| | | onUserLocationResolved: setUserCurrentLocation |
| | | }); |
| | | const bootstrap = useHomeBootstrap({ |
| | | wsHost: _ROBOTAXI_WS_HOST, |
| | |
| | | toOrigin |
| | | }); |
| | | const { |
| | | capsuleSite, |
| | | quickCapsuleText, |
| | | showQuickCapsule, |
| | | refreshQuickCapsule, |
| | |
| | | isLocationAuthDenied, |
| | | electronicAreaId, |
| | | currentSite, |
| | | preferredStartSite, |
| | | userCurrentLocation, |
| | | nearbyPickupStations: nearbySites, |
| | | recommendedDropoffStation: capsuleSite, |
| | | selectedCity, |
| | | polygons, |
| | | fencePolylines, |
| | |
| | | /** 协议接口约定:true=已同意,false=未同意。 */ |
| | | const resolveAgreementStatus = (result: any) => result === true; |
| | | |
| | | /** 下单发起埋点事件标识。 */ |
| | | const PAY_START_EVENT_ID = 'RT_xcx_pay_start'; |
| | | |
| | | /** 下单结果埋点事件标识。 */ |
| | | const PAY_RESULT_EVENT_ID = 'RT_xcx_pay_result'; |
| | | |
| | | /** 下单结果埋点状态。 */ |
| | | type PayResultStatus = |
| | | | 'success' |
| | | | 'user_cancel' |
| | | | 'network_error' |
| | | | 'insufficient_balance' |
| | | | 'backend_timeout'; |
| | | |
| | | /** 推断下单结果埋点状态。 */ |
| | | const resolvePayResultStatus = (value: any): PayResultStatus => { |
| | | const normalizedMessage = normalizeText(value); |
| | | if (!normalizedMessage) { |
| | | return 'network_error'; |
| | | } |
| | | if (normalizedMessage.includes('余额不足')) { |
| | | return 'insufficient_balance'; |
| | | } |
| | | if (normalizedMessage.includes('取消')) { |
| | | return 'user_cancel'; |
| | | } |
| | | if (normalizedMessage.includes('超时')) { |
| | | return 'backend_timeout'; |
| | | } |
| | | if (normalizedMessage.includes('网络')) { |
| | | return 'network_error'; |
| | | } |
| | | return 'network_error'; |
| | | }; |
| | | |
| | | /** |
| | | * 管理下单前校验与提交动作。 |
| | | */ |
| | |
| | | */ |
| | | const stopCallSubmitting = () => { |
| | | deps.setCallSubmitting(false); |
| | | }; |
| | | |
| | | /** |
| | | * 构造支付埋点公共字段。 |
| | | */ |
| | | const createPayEventPayload = (orderId = '') => ({ |
| | | order_id: String(orderId || '').trim(), |
| | | amount: Number(deps.fareEstimate?.finalFare || 0), |
| | | timestamp: Date.now() |
| | | }); |
| | | |
| | | /** |
| | | * 下单发起后上报支付起始埋点。 |
| | | */ |
| | | const reportPayStartEvent = () => { |
| | | reportEvent(PAY_START_EVENT_ID, createPayEventPayload()); |
| | | }; |
| | | |
| | | /** |
| | | * 接口返回后上报支付结果埋点。 |
| | | */ |
| | | const reportPayResultEvent = (payStatus: PayResultStatus, orderId = '') => { |
| | | reportEvent(PAY_RESULT_EVENT_ID, { |
| | | ...createPayEventPayload(orderId), |
| | | pay_status: payStatus |
| | | }); |
| | | }; |
| | | |
| | | /** |
| | |
| | | || isNotAgreedMessage(response?.data) |
| | | || isNotAgreedMessage(response?.msg) |
| | | ) { |
| | | reportPayResultEvent(resolvePayResultStatus(response?.msg || response?.data || response)); |
| | | deps.handleOpenPrivacyPopup(); |
| | | return null; |
| | | } |
| | | |
| | | if (typeof response === 'string') { |
| | | reportPayResultEvent(resolvePayResultStatus(response)); |
| | | Taro.showToast({ title: response, icon: 'none', mask: true }); |
| | | setTimeout(() => { |
| | | Taro.reLaunch({ url: HOME_PAGE_URL }); |
| | |
| | | } |
| | | |
| | | if (!response?.orderId) { |
| | | reportPayResultEvent(resolvePayResultStatus(response?.msg || response?.data || response)); |
| | | Taro.showToast({ title: '下单失败,请重试', icon: 'none' }); |
| | | return null; |
| | | } |
| | | |
| | | reportPayResultEvent('success', response.orderId); |
| | | Taro.removeStorageSync('selectedCouponId'); |
| | | Taro.setStorageSync('orderId', response.orderId); |
| | | return { orderId: response.orderId }; |
| | |
| | | return; |
| | | } |
| | | deps.setCallSubmitting(true); |
| | | reportPayStartEvent(); |
| | | |
| | | const authReady = await checkCallAuth(); |
| | | if (!authReady) { |
| | |
| | | deps.bindSocketListeners(); |
| | | void subscribeAfterOrderSuccess(submitResult.orderId); |
| | | } catch (error) { |
| | | reportPayResultEvent(resolvePayResultStatus((error as any)?.msg || (error as any)?.message || error)); |
| | | stopCallSubmitting(); |
| | | } |
| | | }; |
| | |
| | | } |
| | | } |
| | | |
| | | // mvp埋点 |
| | | export const reportEvent = async (eventId: string, para: any) => { |
| | | /** 埋点操作类型。 */ |
| | | type ReportEventType = 'pageView' | 'track'; |
| | | |
| | | /** 默认埋点操作类型。 */ |
| | | const DEFAULT_REPORT_EVENT_TYPE: ReportEventType = 'track'; |
| | | |
| | | /** 页面路径兜底值。 */ |
| | | const DEFAULT_PAGE_PATH = '/'; |
| | | |
| | | /** 来源页路径兜底值。 */ |
| | | const DEFAULT_REFERRER_PATH = ''; |
| | | |
| | | /** 标准化埋点页面路径。 */ |
| | | const normalizeReportPagePath = (route: any, fallbackPath: string) => { |
| | | const normalizedRoute = String(route || '').trim(); |
| | | if (!normalizedRoute) { |
| | | return fallbackPath; |
| | | } |
| | | return normalizedRoute.startsWith('/') ? normalizedRoute : `/${normalizedRoute}`; |
| | | }; |
| | | |
| | | /** 读取当前页与来源页路径。 */ |
| | | const getReportPageContext = () => { |
| | | const pages = Taro.getCurrentPages(); |
| | | const currentPage = pages[pages.length - 1]; |
| | | const referrerPage = pages[pages.length - 2]; |
| | | return { |
| | | pagePath: normalizeReportPagePath(currentPage?.route, DEFAULT_PAGE_PATH), |
| | | referrerPath: normalizeReportPagePath(referrerPage?.route, DEFAULT_REFERRER_PATH) |
| | | }; |
| | | }; |
| | | |
| | | /** 判断埋点字段是否为需要自动 JSON 化的结构化数据。 */ |
| | | const isStructuredReportValue = (value: any) => { |
| | | if (Array.isArray(value)) { |
| | | return true; |
| | | } |
| | | return Object.prototype.toString.call(value) === '[object Object]'; |
| | | }; |
| | | |
| | | /** 序列化单个埋点字段,避免对象被底层隐式转成 [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]') { |
| | | return {}; |
| | | } |
| | | return Object.keys(para).reduce((result: Record<string, any>, key: string) => { |
| | | result[key] = serializeReportValue(para[key]); |
| | | return result; |
| | | }, {}); |
| | | }; |
| | | |
| | | // 埋点 |
| | | export const reportEvent = async ( |
| | | eventId: string, |
| | | para: any, |
| | | type: ReportEventType = DEFAULT_REPORT_EVENT_TYPE |
| | | ) => { |
| | | let appVersion = ''; |
| | | const $app: any = Taro.getApp().$app; |
| | | const { openId } = await $app.getIdData() || {}; |
| | | const { pagePath, referrerPath } = getReportPageContext(); |
| | | const normalizedPara = normalizeReportParams(para); |
| | | try { |
| | | const accountInfo = Taro.getAccountInfoSync(); |
| | | appVersion = accountInfo?.miniProgram?.version || accountInfo?.miniProgram?.envVersion || ''; |
| | |
| | | $app.xpReportSend({ |
| | | eventId, |
| | | para: { |
| | | ...para, |
| | | user_id: uid || '-1', |
| | | masked_device_id: openId, |
| | | event_time: Date.now(), |
| | | city: Taro.getStorageSync('selectedCity').name, |
| | | orderId: Taro.getStorageSync('orderId') || '', |
| | | os: Taro.getSystemInfoSync().system, |
| | | app_ver: appVersion, |
| | | ...normalizedPara, // 自定义参数 |
| | | page_path: pagePath, // 页面路径 |
| | | referrer_path: referrerPath, // 来源页路径 |
| | | type, // 埋点操作类型 |
| | | user_id: uid || '-1', // 用户ID |
| | | masked_device_id: openId, // 设备ID |
| | | event_time: Date.now(), // 事件时间 |
| | | city: Taro.getStorageSync('selectedCity').name || '', // 城市 |
| | | orderId: Taro.getStorageSync('orderId') || '', // 订单ID |
| | | os: Taro.getSystemInfoSync().system || '', // 操作系统 |
| | | app_ver: appVersion, // 微信小程序版本 |
| | | session_id: '', // 会话ID |
| | | }, |
| | | }); |
| | | }); |