Merge branch 'feature_v1.0' of http://gitlab.xiaopeng.local:18080/robotaxi1/xp-robotaxi-miniprogram into feature_v1.0
| | |
| | | }, true); |
| | | } |
| | | |
| | | // 静默查询订单轨迹,供分享页自行处理业务异常,避免默认拦截 toast。 |
| | | export const queryOrderTrajectorySilently = async ( |
| | | payload: QueryOrderRoutePayload |
| | | ): Promise<any> => { |
| | | return request({ |
| | | url: _ROBOTAXI_HOST + `/order/api/v1/mini-program/order/queryOrderTrajectory`, |
| | | method: 'GET', |
| | | payload, |
| | | showToast: false, |
| | | skipException: true |
| | | }, true); |
| | | } |
| | | |
| | | // 订单查询队列状态 |
| | | export const queryOrderQueueStatus = async ( |
| | | payload: QueryOrderQueueStatusPayload |
| | |
| | | }, true); |
| | | } |
| | | |
| | | // 静默查询分享订单实时路线,供分享页自行处理业务异常,避免默认拦截 toast。 |
| | | export const shareQueryOrderRouteSilently = async (orderId: OrderId): Promise<any> => { |
| | | return request({ |
| | | url: _ROBOTAXI_HOST + `/order/base/v1/mini-program/orderActive/share/queryOrderRoute/${orderId}`, |
| | | method: 'GET', |
| | | showToast: false, |
| | | skipException: true |
| | | }, true); |
| | | } |
| | | |
| | | // 分享订单查询订单详情 |
| | | export const shareQueryOrderInfo = async ( |
| | | payload: ShareQueryOrderInfoPayload |
| | |
| | | payload |
| | | }, true); |
| | | } |
| | | |
| | | // 静默查询分享订单详情,供分享页自行处理业务异常,避免默认拦截 toast。 |
| | | export const shareQueryOrderInfoSilently = async ( |
| | | payload: ShareQueryOrderInfoPayload |
| | | ): Promise<any> => { |
| | | return request({ |
| | | url: _ROBOTAXI_HOST + '/order/base/v1/mini-program/orderActive/share/orderInfo', |
| | | method: 'GET', |
| | | payload, |
| | | showToast: false, |
| | | skipException: true |
| | | }, true); |
| | | } |
| | |
| | | description: string; |
| | | actionText?: string; |
| | | onClick: () => void; |
| | | authSuccess?: boolean; |
| | | /** 蓝牙图标资源(点亮/熄灭由 useBluetoothAccess 统一控制) */ |
| | | iconSrc: string; |
| | | /** 描述文字颜色(空串表示沿用默认色) */ |
| | | descriptionColor?: string; |
| | | /** 是否允许点击(默认 true)。false 时不绑定 onClick */ |
| | | clickable?: boolean; |
| | | }; |
| | | |
| | | export default function BluetoothOperateCard({ description, actionText = '', onClick, authSuccess = false }: Props) { |
| | | // 只要有动作需要执行,或者未达到成功授权的系统级要求,都展示关闭状态的图标 |
| | | const iconSrc = actionText === '去开启' || actionText === '去授权' || !authSuccess |
| | | ? "https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/close-bluetooth.png" |
| | | : "https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/bluetooth-icon.png"; |
| | | |
| | | export default function BluetoothOperateCard({ description, actionText = '', onClick, iconSrc, descriptionColor = '', clickable = true }: Props) { |
| | | const descriptionStyle = descriptionColor ? { color: descriptionColor } : undefined; |
| | | // 右侧区域渲染规则: |
| | | // - 不可点击(如车端钥匙下发失败)→ 不渲染 |
| | | // - "去开启"(系统蓝牙未开)→ 不渲染(当前 UI 仅在描述区表达) |
| | | // - 其他可点击场景("去授权" / 已授权)→ 渲染容器;文字仅在有 actionText 时显示,箭头始终显示 |
| | | const shouldRenderRight = clickable && actionText !== '去开启'; |
| | | return ( |
| | | <View className="bluetooth-operate-card" onClick={onClick}> |
| | | <View className="bluetooth-operate-card" onClick={clickable ? onClick : undefined}> |
| | | <View className="bluetooth-operate-card-left"> |
| | | <Image src={iconSrc} /> |
| | | <View>{description}</View> |
| | | <View style={descriptionStyle}>{description}</View> |
| | | </View> |
| | | {(!!actionText && actionText !== '去开启') && ( |
| | | {shouldRenderRight && ( |
| | | <View className="bluetooth-operate-card-right"> |
| | | <View>{actionText}</View> |
| | | {!!actionText && <View>{actionText}</View>} |
| | | <Image src="https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/blue-right-icon.png" /> |
| | | </View> |
| | | )} |
| | |
| | | bluetoothStatusReady?: boolean; |
| | | bluetoothCardDescription?: string; |
| | | bluetoothCardActionText?: string; |
| | | bluetoothCardIconSrc?: string; |
| | | bluetoothCardDescriptionColor?: string; |
| | | bluetoothCardClickable?: boolean; |
| | | handleBluetoothCardClick?: () => void; |
| | | showAuthBluetoothPopup?: boolean; |
| | | showSuccessAuthPopup?: boolean; |
| | |
| | | bluetoothStatusReady = false, |
| | | bluetoothCardDescription = '授权并打开蓝牙链接,享受无感出行', |
| | | bluetoothCardActionText = '去开启', |
| | | bluetoothCardIconSrc = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/close-bluetooth.png', |
| | | bluetoothCardDescriptionColor = '', |
| | | bluetoothCardClickable = true, |
| | | handleBluetoothCardClick, |
| | | showAuthBluetoothPopup = false, |
| | | showSuccessAuthPopup = false, |
| | |
| | | description={bluetoothCardDescription} |
| | | actionText={bluetoothCardActionText} |
| | | onClick={handleBluetoothCardClick || (() => { })} |
| | | authSuccess={authSuccess} |
| | | iconSrc={bluetoothCardIconSrc} |
| | | descriptionColor={bluetoothCardDescriptionColor} |
| | | clickable={bluetoothCardClickable} |
| | | /> |
| | | } |
| | | |
| New file |
| | |
| | | import { useRef, useState } from 'react'; |
| | | import { View, Text } from '@tarojs/components'; |
| | | import { TripStatus } from '../../../constants/trip-status'; |
| | | |
| | | type Props = { |
| | | /** 当前行程状态。 */ |
| | | tripStatus: TripStatus; |
| | | /** 设置行程状态(用于本地 mock 接驾中/已到达状态,方便调试蓝牙)。 */ |
| | | setTripStatus: (status: TripStatus) => void; |
| | | /** 蓝牙首轮状态判定是否完成(系统蓝牙 + 小程序授权)。 */ |
| | | bluetoothStatusReady: boolean; |
| | | /** 蓝牙卡片右侧动作文案('去开启' / '去授权' / ''),间接体现授权链路状态。 */ |
| | | bluetoothCardActionText: string; |
| | | /** RtAuth 蓝牙鉴权是否成功(连上车并鉴权通过)。 */ |
| | | bluetoothAuthSuccess: boolean; |
| | | /** 车端钥匙下发失败(getUnlocktToken 接口非 200)。 */ |
| | | unlockTokenFailed: boolean; |
| | | /** 调试用:直接覆盖 unlockTokenFailed 状态。 */ |
| | | onMockUnlockTokenFailed: (failed: boolean) => void; |
| | | /** 调试用:直接覆盖 authSuccess(蓝牙打开车锁的成功状态)。 */ |
| | | onMockAuthSuccess: (success: boolean) => void; |
| | | }; |
| | | |
| | | /** 行程状态显示名(仅展示用,不影响业务)。 */ |
| | | const TRIP_STATUS_LABEL: Record<number, string> = { |
| | | [TripStatus.PublishRoute]: '发布行程', |
| | | [TripStatus.Publishing]: '发布中', |
| | | [TripStatus.CallingCar]: '呼叫车中', |
| | | [TripStatus.VehicleApproaching]: '接驾中', |
| | | [TripStatus.VehicleArrivedPickup]: '车辆已到达', |
| | | [TripStatus.InService]: '行程中', |
| | | [TripStatus.ArrivedDestination]: '到达目的地', |
| | | [TripStatus.TripFinished]: '行程结束', |
| | | [TripStatus.PaymentSuccess]: '支付成功', |
| | | [TripStatus.CancelOnlyPickup]: '已取消' |
| | | }; |
| | | |
| | | /** |
| | | * 解析蓝牙授权链路阶段,便于调试时一目了然。 |
| | | */ |
| | | function resolveBluetoothStage(actionText: string, ready: boolean): { label: string; color: string } { |
| | | if (!ready) { |
| | | return { label: '检测中', color: '#aaa' }; |
| | | } |
| | | if (actionText === '去开启') { |
| | | return { label: '系统蓝牙未开启', color: '#ff5630' }; |
| | | } |
| | | if (actionText === '去授权') { |
| | | return { label: '小程序未授权', color: '#ffab00' }; |
| | | } |
| | | return { label: '已授权(图标点亮)', color: '#36b37e' }; |
| | | } |
| | | |
| | | /** |
| | | * 蓝牙调试面板(仅 dev 构建可见)。 |
| | | * |
| | | * ## 用途 |
| | | * 在没有真实订单/车辆的环境下,可视化观察并调试蓝牙授权链路与行程状态相关 UI; |
| | | * 同时提供 mock 入口,可一键模拟「车端钥匙下发失败」「蓝牙开锁成功」等关键状态。 |
| | | * |
| | | * ## 当前状态 |
| | | * 本组件已从 `begin-route/index.tsx` 中下线,**默认不会渲染**。 |
| | | * 文件保留以便下次需要复用调试能力时直接启用,无需重新实现。 |
| | | * |
| | | * ## 下次启用步骤 |
| | | * 业务 hook 默认不暴露 mock setter(保持生产 API 干净)。启用时按以下三步接入: |
| | | * |
| | | * 1) 在 `use-bluetooth-car-control.ts` 的 `return` 对象中临时加上 mock setter: |
| | | * |
| | | * ```ts |
| | | * return { |
| | | * // ...原有字段 |
| | | * mockUnlockTokenFailed: setUnlockTokenFailed, |
| | | * mockAuthSuccess: setAuthSuccess, |
| | | * }; |
| | | * ``` |
| | | * |
| | | * 2) 在 `begin-route/index.tsx` 中解构 mock setter 并 import 本面板: |
| | | * |
| | | * ```tsx |
| | | * import DebugBluetoothPanel from './components/debug-bluetooth-panel'; |
| | | * |
| | | * const { |
| | | * authSuccess: bluetoothAuthSuccess, |
| | | * unlockTokenFailed, |
| | | * mockUnlockTokenFailed, |
| | | * mockAuthSuccess, |
| | | * // ...其他原本就有的字段 |
| | | * } = useBluetoothCarControl({ orderId, tripStatus }); |
| | | * ``` |
| | | * |
| | | * 3) 在页面 JSX 末尾渲染面板,把状态与 mock setter 透传进来: |
| | | * |
| | | * ```tsx |
| | | * <DebugBluetoothPanel |
| | | * tripStatus={tripStatus} |
| | | * setTripStatus={setTripStatus} |
| | | * bluetoothStatusReady={bluetoothStatusReady} |
| | | * bluetoothCardActionText={bluetoothCardActionText} |
| | | * bluetoothAuthSuccess={bluetoothAuthSuccess} |
| | | * unlockTokenFailed={unlockTokenFailed} |
| | | * onMockUnlockTokenFailed={mockUnlockTokenFailed} |
| | | * onMockAuthSuccess={mockAuthSuccess} |
| | | * /> |
| | | * ``` |
| | | * |
| | | * 调试结束后请把上述三步全部回滚,恢复生产代码干净。 |
| | | * |
| | | * ## 使用方法 |
| | | * 1. 仅在非生产构建(`process.env.NODE_ENV !== 'production'`)时渲染, |
| | | * 生产构建(`npm run build:weapp`)会自动剔除整段渲染逻辑,不影响线上用户。 |
| | | * 2. 启动 `npm run dev:weapp` 后进入「行程页(begin-route)」, |
| | | * 页面左侧 60% 高度处会出现一个蓝色的「蓝牙调试」浮动入口;点击展开为完整面板。 |
| | | * 3. 面板分两块: |
| | | * - 状态信息:实时显示当前行程状态、授权链路阶段、车端鉴权结果与钥匙下发状态, |
| | | * 值会随真实状态变化同步刷新,便于对照 UI 表现。 |
| | | * - 操作按钮: |
| | | * - 行程切换:「车辆已到达」/「接驾中」/「还原」一键切到/恢复 `tripStatus`, |
| | | * 用于不创建真实订单也能看到蓝牙卡片、授权弹窗等 UI。 |
| | | * - Mock 触发:「模拟钥匙下发失败」「模拟蓝牙开锁」按钮, |
| | | * 分别覆盖 `unlockTokenFailed` 和 `authSuccess` 两个状态,再点一次取消模拟。 |
| | | * |
| | | * ## 注意 |
| | | * - mock 仅修改前端状态,不会创建真实订单或调用任何车控接口。 |
| | | * - 没有 `orderId` 时,车端 RtAuth 链路不会真正启动,但蓝牙开启/小程序授权链路仍可调试。 |
| | | * - mock 状态会被真实链路覆盖(`startAuth`/`retryAuth`/`markManualDoorUnlock` / |
| | | * 行程切到非 4/5 / `onStatusChange(status===3)` / `onRtAuthResult` 等),与生产路径一致。 |
| | | * - 若要新增 mock 能力:在 `useBluetoothCarControl` 中暴露对应 setter(命名 `mockXxx`), |
| | | * 然后在本面板 Props 与按钮中接入。 |
| | | */ |
| | | export default function DebugBluetoothPanel(props: Props) { |
| | | const [expanded, setExpanded] = useState(false); |
| | | const originalTripStatusRef = useRef<TripStatus | null>(null); // 首次 mock 前的原状态,用于「还原」 |
| | | |
| | | if (process.env.NODE_ENV === 'production') { |
| | | return null; |
| | | } |
| | | |
| | | /** Mock 行程状态:首次 mock 前先记录原状态,以便「还原」恢复。 */ |
| | | const mockTripStatus = (next: TripStatus) => { |
| | | if (originalTripStatusRef.current === null) { |
| | | originalTripStatusRef.current = props.tripStatus; |
| | | } |
| | | console.log('[debug-bluetooth] 🧪 mock 行程状态', { from: props.tripStatus, to: next }); |
| | | props.setTripStatus(next); |
| | | }; |
| | | |
| | | /** 还原到首次 mock 前的状态。 */ |
| | | const restoreTripStatus = () => { |
| | | if (originalTripStatusRef.current === null) { |
| | | return; |
| | | } |
| | | const target = originalTripStatusRef.current; |
| | | originalTripStatusRef.current = null; |
| | | console.log('[debug-bluetooth] 🧪 还原行程状态', { to: target }); |
| | | props.setTripStatus(target); |
| | | }; |
| | | |
| | | const buttonStyle = (background: string) => ({ |
| | | flex: 1, |
| | | textAlign: 'center' as const, |
| | | padding: '8px 0', |
| | | margin: '0 4px', |
| | | background, |
| | | color: '#fff', |
| | | fontSize: '13px', |
| | | borderRadius: '8px' |
| | | }); |
| | | |
| | | // 收起态:仅显示一个浮动小按钮,位置避开 DebugAutoscalePanel(top:45%) |
| | | if (!expanded) { |
| | | return ( |
| | | <View |
| | | onClick={() => setExpanded(true)} |
| | | style={{ |
| | | position: 'fixed', |
| | | left: '0', |
| | | top: '60%', |
| | | zIndex: 9999, |
| | | padding: '8px 10px', |
| | | background: 'rgba(0,80,160,0.85)', |
| | | color: '#fff', |
| | | fontSize: '12px', |
| | | borderTopRightRadius: '16px', |
| | | borderBottomRightRadius: '16px' |
| | | }} |
| | | > |
| | | 蓝牙调试 |
| | | </View> |
| | | ); |
| | | } |
| | | |
| | | const stage = resolveBluetoothStage(props.bluetoothCardActionText, props.bluetoothStatusReady); |
| | | const isMocked = originalTripStatusRef.current !== null; |
| | | |
| | | // 展开态:完整调试面板,置顶展示避开底部 DebugAutoscalePanel |
| | | return ( |
| | | <View |
| | | catchMove |
| | | style={{ |
| | | position: 'fixed', |
| | | left: '12px', |
| | | right: '12px', |
| | | top: '80px', |
| | | zIndex: 9999, |
| | | padding: '12px 14px', |
| | | background: 'rgba(20,40,80,0.92)', |
| | | color: '#fff', |
| | | borderRadius: '12px', |
| | | boxShadow: '0 4px 16px rgba(0,0,0,0.4)' |
| | | }} |
| | | > |
| | | <View style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}> |
| | | <Text style={{ fontSize: '14px', fontWeight: 'bold' }}>蓝牙调试面板</Text> |
| | | <View |
| | | onClick={() => setExpanded(false)} |
| | | style={{ padding: '2px 10px', fontSize: '13px', background: 'rgba(255,255,255,0.15)', borderRadius: '8px' }} |
| | | > |
| | | 收起 |
| | | </View> |
| | | </View> |
| | | |
| | | <View style={{ fontSize: '12px', lineHeight: '1.7', marginBottom: '8px', opacity: 0.9 }}> |
| | | <Text style={{ display: 'block' }}> |
| | | {`行程状态:${TRIP_STATUS_LABEL[props.tripStatus] || props.tripStatus} (${props.tripStatus})`} |
| | | {isMocked && <Text style={{ marginLeft: '6px', color: '#ffab00' }}>· 已 mock</Text>} |
| | | </Text> |
| | | <Text style={{ display: 'block' }}> |
| | | {'授权链路:'} |
| | | <Text style={{ color: stage.color }}>{stage.label}</Text> |
| | | </Text> |
| | | <Text style={{ display: 'block' }}> |
| | | {'车端鉴权:'} |
| | | <Text style={{ color: props.bluetoothAuthSuccess ? '#36b37e' : '#aaa' }}> |
| | | {props.bluetoothAuthSuccess ? '已通过' : '未通过'} |
| | | </Text> |
| | | </Text> |
| | | <Text style={{ display: 'block' }}> |
| | | {'钥匙下发:'} |
| | | <Text style={{ color: props.unlockTokenFailed ? '#ff4d4f' : '#36b37e' }}> |
| | | {props.unlockTokenFailed ? '失败(已 mock)' : '正常'} |
| | | </Text> |
| | | </Text> |
| | | </View> |
| | | |
| | | <View style={{ display: 'flex', marginBottom: '8px' }}> |
| | | <View |
| | | onClick={() => mockTripStatus(TripStatus.VehicleArrivedPickup)} |
| | | style={buttonStyle(props.tripStatus === TripStatus.VehicleArrivedPickup ? '#4c9aff' : 'rgba(255,255,255,0.2)')} |
| | | > |
| | | 车辆已到达 |
| | | </View> |
| | | <View |
| | | onClick={() => mockTripStatus(TripStatus.VehicleApproaching)} |
| | | style={buttonStyle(props.tripStatus === TripStatus.VehicleApproaching ? '#4c9aff' : 'rgba(255,255,255,0.2)')} |
| | | > |
| | | 接驾中 |
| | | </View> |
| | | <View |
| | | onClick={restoreTripStatus} |
| | | style={buttonStyle(isMocked ? 'rgba(255,255,255,0.25)' : 'rgba(255,255,255,0.1)')} |
| | | > |
| | | 还原 |
| | | </View> |
| | | </View> |
| | | |
| | | <View style={{ display: 'flex', marginBottom: '8px' }}> |
| | | <View |
| | | onClick={() => { |
| | | const next = !props.unlockTokenFailed; |
| | | console.log('[debug-bluetooth] 🧪 mock 钥匙下发失败', { next }); |
| | | props.onMockUnlockTokenFailed(next); |
| | | }} |
| | | style={buttonStyle(props.unlockTokenFailed ? '#ff4d4f' : 'rgba(255,255,255,0.2)')} |
| | | > |
| | | {props.unlockTokenFailed ? '取消模拟钥匙失败' : '模拟钥匙下发失败'} |
| | | </View> |
| | | <View |
| | | onClick={() => { |
| | | const next = !props.bluetoothAuthSuccess; |
| | | console.log('[debug-bluetooth] 🧪 mock 蓝牙开锁成功', { next }); |
| | | props.onMockAuthSuccess(next); |
| | | }} |
| | | style={buttonStyle(props.bluetoothAuthSuccess ? '#36b37e' : 'rgba(255,255,255,0.2)')} |
| | | > |
| | | {props.bluetoothAuthSuccess ? '取消模拟开锁' : '模拟蓝牙开锁'} |
| | | </View> |
| | | </View> |
| | | |
| | | <View style={{ fontSize: '11px', lineHeight: '1.5', opacity: 0.7 }}> |
| | | <Text style={{ display: 'block' }}>提示:mock 仅修改前端状态,不会创建真实订单。</Text> |
| | | <Text style={{ display: 'block' }}>无 orderId 时车端 RtAuth 不会启动,但蓝牙开启/授权链路仍可调试。</Text> |
| | | </View> |
| | | </View> |
| | | ); |
| | | } |
| | |
| | | tripStatus: TripStatus; |
| | | bluetoothAuthSuccess: boolean; |
| | | startBluetoothAuth: () => void; |
| | | /** 车端钥匙下发失败(getUnlocktToken 接口非 200),蓝牙不可解锁 */ |
| | | unlockTokenFailed?: boolean; |
| | | }; |
| | | |
| | | type BluetoothAccessStatus = 'system_off' | 'unauthorized' | 'authorized'; |
| | |
| | | const BLUETOOTH_ACCESS_LOG_PREFIX = '[蓝牙权限]'; |
| | | const BLUETOOTH_AUTH_SYNC_DELAY_MS = 300; // 授权完成后首轮刷新延迟,给小程序权限态一点同步时间 |
| | | const BLUETOOTH_AUTH_PROTECTION_WINDOW_MS = 5000; // 授权成功后的保护窗口,避免瞬时 permission_deny 覆盖 UI |
| | | // 蓝牙图标资源:点亮(已授权)与熄灭(未开/未授权)两态 |
| | | const BLUETOOTH_ICON_ON = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/bluetooth-icon.png'; |
| | | const BLUETOOTH_ICON_OFF = 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/close-bluetooth.png'; |
| | | // 车端钥匙下发失败时的描述文案与文字颜色 |
| | | const UNLOCK_TOKEN_FAILED_TEXT = '蓝牙解锁失败, 请点击右下方按键解锁车门'; |
| | | const UNLOCK_TOKEN_FAILED_COLOR = '#FF4D4F'; |
| | | |
| | | /** |
| | | * 输出蓝牙权限链路日志,便于排查授权、系统蓝牙和环境问题。 |
| | |
| | | * 2. 系统蓝牙已开启,但小程序未授权 |
| | | * 3. 系统蓝牙已开启,且小程序已授权 |
| | | */ |
| | | export default function useBluetoothAccess({ tripStatus, bluetoothAuthSuccess, startBluetoothAuth }: Deps) { |
| | | export default function useBluetoothAccess({ tripStatus, bluetoothAuthSuccess, startBluetoothAuth, unlockTokenFailed = false }: Deps) { |
| | | const [status, setStatus] = useState<BluetoothAccessStatus>('system_off'); // 当前蓝牙访问态:系统未开 / 小程序未授权 / 已授权 |
| | | const [statusReady, setStatusReady] = useState(false); // 蓝牙状态是否已完成首轮判定 |
| | | const [showAuthBluetoothPopup, setShowAuthBluetoothPopup] = useState(false); // 蓝牙授权引导弹窗展示态 |
| | |
| | | |
| | | // 蓝牙卡片主文案 |
| | | const cardDescription = useMemo(() => { |
| | | if (unlockTokenFailed) { |
| | | return UNLOCK_TOKEN_FAILED_TEXT; |
| | | } |
| | | if (status === 'system_off') { |
| | | return '授权并打开蓝牙链接,享受无感出行'; |
| | | return '系统蓝牙未开启, 无法自动解锁车辆'; |
| | | } |
| | | if (status === 'unauthorized') { |
| | | return '授权蓝牙,车辆自动解锁'; |
| | | return '授权蓝牙连接, 解锁无感出行'; |
| | | } |
| | | return '车辆到达后,手机靠近车门可自动解锁'; |
| | | }, [status]); |
| | | return '手机靠近车辆,可自动解锁车门'; |
| | | }, [status, unlockTokenFailed]); |
| | | |
| | | |
| | | |
| | |
| | | |
| | | // 蓝牙卡片右侧动作文案 |
| | | const cardActionText = useMemo(() => { |
| | | // 失败态:无右侧动作(不展示按钮文字与箭头) |
| | | if (unlockTokenFailed) { |
| | | return ''; |
| | | } |
| | | if (status === 'system_off') { |
| | | return '去开启'; |
| | | } |
| | |
| | | return ''; |
| | | } |
| | | return '去授权'; |
| | | }, [status, unlockTokenFailed]); |
| | | |
| | | // 蓝牙卡片左侧图标资源:仅当 status === 'authorized'(系统蓝牙已开 + 小程序已授权)时点亮 |
| | | const cardIconSrc = useMemo(() => { |
| | | return status === 'authorized' ? BLUETOOTH_ICON_ON : BLUETOOTH_ICON_OFF; |
| | | }, [status]); |
| | | |
| | | // 蓝牙卡片描述文字颜色:仅在车端钥匙下发失败时为红色,其他情况沿用默认色(空串表示不覆盖) |
| | | const cardDescriptionColor = useMemo(() => { |
| | | return unlockTokenFailed ? UNLOCK_TOKEN_FAILED_COLOR : ''; |
| | | }, [unlockTokenFailed]); |
| | | |
| | | // 蓝牙卡片是否可点击:失败态、系统蓝牙未开启时不可点击;其他状态可点击 |
| | | const cardClickable = useMemo(() => { |
| | | if (unlockTokenFailed) { |
| | | return false; |
| | | } |
| | | if (status === 'system_off') { |
| | | return false; |
| | | } |
| | | return true; |
| | | }, [status, unlockTokenFailed]); |
| | | |
| | | // 对外暴露:状态 + 视图文案 + 交互动作 |
| | | return { |
| | |
| | | bluetoothStatusReady: statusReady, |
| | | bluetoothCardDescription: cardDescription, |
| | | bluetoothCardActionText: cardActionText, |
| | | bluetoothCardIconSrc: cardIconSrc, |
| | | bluetoothCardDescriptionColor: cardDescriptionColor, |
| | | bluetoothCardClickable: cardClickable, |
| | | showAuthBluetoothPopup, |
| | | showSuccessAuthPopup, |
| | | handleBluetoothCardClick, |
| | |
| | | const [authSuccess, setAuthSuccess] = useState(false); // RtAuth 鉴权是否成功 |
| | | const [isAuthorizing, setIsAuthorizing] = useState(false); // 鉴权流程进行中 |
| | | const [authError, setAuthError] = useState<string>(''); // 鉴权流程错误信息 |
| | | const [unlockTokenFailed, setUnlockTokenFailed] = useState(false); // getUnlocktToken 接口非 200,车端钥匙下发失败 |
| | | const authSuccessRef = useRef(false); // 鉴权成功状态引用,避免并发期间闭包值滞后 |
| | | const isAuthorizingRef = useRef(false); // 鉴权中状态引用,避免并发期间闭包值滞后 |
| | | const ensureManagerRef = useRef<() => any>(() => null); // 蓝牙管理器初始化函数引用 |
| | |
| | | isAuthorizingRef.current = true; |
| | | setIsAuthorizing(true); |
| | | setAuthError(''); |
| | | setUnlockTokenFailed(false); |
| | | const runTask = async () => { |
| | | try { |
| | | const tokenRes = await getBluetoothUnlockToken(orderId); |
| | |
| | | console.log('[蓝牙] 鉴权流程异常:', error); |
| | | isAuthorizingRef.current = false; |
| | | setAuthError(error?.msg || '获取蓝牙鉴权凭证失败'); |
| | | // getUnlocktToken 接口非 200(含网络异常),车端钥匙下发失败,标记蓝牙不可解锁 |
| | | setUnlockTokenFailed(true); |
| | | setIsAuthorizing(false); |
| | | } finally { |
| | | startAuthTaskRef.current = null; |
| | |
| | | isAuthorizingRef.current = true; |
| | | setIsAuthorizing(true); |
| | | setAuthError(''); |
| | | setUnlockTokenFailed(false); |
| | | managerRef.current.retryConnection(); |
| | | }, [startAuth]); |
| | | |
| | |
| | | } |
| | | acceptBluetoothCallbacksRef.current = false; |
| | | setAuthError(''); |
| | | setUnlockTokenFailed(false); |
| | | closeConnectionRef.current(); |
| | | }, [orderId]); |
| | | |
| | |
| | | unlockFinishedRef.current = false; |
| | | manualDoorUnlockRef.current = false; // 重置手动解锁状态,供下一次使用 |
| | | setAuthError(''); |
| | | setUnlockTokenFailed(false); |
| | | |
| | | // 行程进入 6(InService 行驶中)或以上,或者是回退/取消等情况时,清除该订单已弹出过成功解锁的标识缓存 |
| | | if (orderId) { |
| | |
| | | authSuccess, |
| | | authError, |
| | | isAuthorizing, |
| | | unlockTokenFailed, |
| | | startAuth, |
| | | markManualDoorUnlock, |
| | | retryAuth, |
| | |
| | | bluetoothCardDescription: string; |
| | | /** 蓝牙卡片右侧动作文案 */ |
| | | bluetoothCardActionText: string; |
| | | /** 蓝牙卡片左侧图标资源 */ |
| | | bluetoothCardIconSrc: string; |
| | | /** 蓝牙卡片描述文字颜色(空串表示沿用默认色) */ |
| | | bluetoothCardDescriptionColor: string; |
| | | /** 蓝牙卡片是否允许点击 */ |
| | | bluetoothCardClickable: boolean; |
| | | /** 点击蓝牙卡片 */ |
| | | handleBluetoothCardClick: () => void; |
| | | /** 蓝牙授权弹窗是否展示 */ |
| | |
| | | bluetoothStatusReady={deps.bluetoothStatusReady} |
| | | bluetoothCardDescription={deps.bluetoothCardDescription} |
| | | bluetoothCardActionText={deps.bluetoothCardActionText} |
| | | bluetoothCardIconSrc={deps.bluetoothCardIconSrc} |
| | | bluetoothCardDescriptionColor={deps.bluetoothCardDescriptionColor} |
| | | bluetoothCardClickable={deps.bluetoothCardClickable} |
| | | handleBluetoothCardClick={deps.handleBluetoothCardClick} |
| | | showAuthBluetoothPopup={deps.showAuthBluetoothPopup} |
| | | showSuccessAuthPopup={deps.showSuccessAuthPopup} |
| | |
| | | const { |
| | | authSuccess: bluetoothAuthSuccess, |
| | | startAuth: startBluetoothAuth, |
| | | markManualDoorUnlock |
| | | markManualDoorUnlock, |
| | | unlockTokenFailed |
| | | } = useBluetoothCarControl({ |
| | | orderId, |
| | | tripStatus, |
| | |
| | | bluetoothStatusReady, |
| | | bluetoothCardDescription, |
| | | bluetoothCardActionText, |
| | | bluetoothCardIconSrc, |
| | | bluetoothCardDescriptionColor, |
| | | bluetoothCardClickable, |
| | | showAuthBluetoothPopup, |
| | | showSuccessAuthPopup, |
| | | handleBluetoothCardClick, |
| | |
| | | } = useBluetoothAccess({ |
| | | tripStatus, |
| | | bluetoothAuthSuccess, |
| | | startBluetoothAuth |
| | | startBluetoothAuth, |
| | | unlockTokenFailed |
| | | }); |
| | | const { handleConfirmRequoteIfNeeded } = useModifyDestinationPreviewRoute({ |
| | | tripStatus, |
| | |
| | | bluetoothStatusReady, |
| | | bluetoothCardDescription, |
| | | bluetoothCardActionText, |
| | | bluetoothCardIconSrc, |
| | | bluetoothCardDescriptionColor, |
| | | bluetoothCardClickable, |
| | | handleBluetoothCardClick, |
| | | showAuthBluetoothPopup, |
| | | showSuccessAuthPopup, |
| | |
| | | import Taro from '@tarojs/taro'; |
| | | import { useEffect, useRef } from 'react'; |
| | | import { mapOrderStatusToTripStatus } from '../../../../utils/Robotaxi-public-method'; |
| | | import { queryOrderTrajectory, shareQueryOrderInfo, shareQueryOrderRoute } from '../../../api/begin-route'; |
| | | import { |
| | | queryOrderTrajectorySilently, |
| | | shareQueryOrderInfoSilently, |
| | | shareQueryOrderRouteSilently |
| | | } from '../../../api/begin-route'; |
| | | import { normalizeDistanceKm, normalizeDurationSeconds } from '../../../utils/format'; |
| | | import { TripStatus } from '../../../constants/trip-status'; |
| | | |
| | | type LocationPoint = { latitude: number; longitude: number }; |
| | | |
| | | type RouteDistance = { value: number; unit: 'km' | 'm' } | null; |
| | | type SilentApiResponse<T> = { |
| | | code?: number; |
| | | status?: number; |
| | | msg?: string; |
| | | data?: T; |
| | | }; |
| | | |
| | | const SHARE_TRIP_POLL_INTERVAL_MS = 5000; // 分享页轮询间隔 |
| | | const MAX_SHARE_TRIP_POLL_COUNT = 6000; // 分享页最多轮询 10 分钟 |
| | |
| | | const toFiniteNumber = (value: any) => { |
| | | const num = Number(value); |
| | | return Number.isFinite(num) ? num : null; |
| | | }; |
| | | |
| | | /** |
| | | * 分享页静默接口统一解包。 |
| | | * 这里不走全局默认异常 toast,由页面自行决定如何处理。 |
| | | */ |
| | | const unwrapSilentResponse = <T>(response: SilentApiResponse<T>) => { |
| | | const code = Number(response?.code ?? response?.status ?? 200); |
| | | if (code === 200) { |
| | | return response?.data ?? response; |
| | | } |
| | | throw response; |
| | | }; |
| | | |
| | | /** |
| | |
| | | */ |
| | | const requestShareRouteByStatus = (targetStatus: TripStatus) => { |
| | | if ([TripStatus.TripFinished, TripStatus.PaymentSuccess].includes(targetStatus)) { |
| | | return queryOrderTrajectory({ |
| | | return queryOrderTrajectorySilently({ |
| | | orderId: deps.orderId, |
| | | routeType: 2, |
| | | }); |
| | | } |
| | | return shareQueryOrderRoute(deps.orderId); |
| | | return shareQueryOrderRouteSilently(deps.orderId); |
| | | }; |
| | | |
| | | useEffect(() => { |
| | |
| | | }; |
| | | |
| | | const fetchInitialShareInfo = async () => { |
| | | const info = await shareQueryOrderInfo({ orderId: deps.orderId }); |
| | | const infoResponse = await shareQueryOrderInfoSilently({ orderId: deps.orderId }); |
| | | const info = unwrapSilentResponse(infoResponse); |
| | | if (cancelled) return null; |
| | | return applyShareInfo(info || {}); |
| | | }; |
| | |
| | | stopPolling(); |
| | | return false; |
| | | } |
| | | let routeResponse = await requestShareRouteByStatus(tripStatusRef.current); |
| | | let routeResponse = unwrapSilentResponse( |
| | | await requestShareRouteByStatus(tripStatusRef.current) |
| | | ); |
| | | if (cancelled) return false; |
| | | const routeStatus = resolveRouteOrderStatus(routeResponse); |
| | | const shouldFetchInfo = routeStatus !== null && routeStatus !== infoStatusRef.current; |
| | |
| | | let nextEndSite = endSiteRef.current; |
| | | |
| | | if (shouldFetchInfo) { |
| | | const info = await shareQueryOrderInfo({ orderId: deps.orderId }); |
| | | const infoResponse = await shareQueryOrderInfoSilently({ orderId: deps.orderId }); |
| | | const info = unwrapSilentResponse(infoResponse); |
| | | if (cancelled) return false; |
| | | const infoState = applyShareInfo(info || {}); |
| | | mappedStatus = infoState.mappedStatus; |
| | |
| | | // TripFinished 继续走分享实时路线接口,完单态走完整轨迹接口, |
| | | // 这样地图才能基于最新路线按 order_detail 视口完整展示。 |
| | | if (mappedStatus >= TripStatus.TripFinished) { |
| | | routeResponse = await requestShareRouteByStatus(mappedStatus); |
| | | routeResponse = unwrapSilentResponse( |
| | | await requestShareRouteByStatus(mappedStatus) |
| | | ); |
| | | if (cancelled) return false; |
| | | } |
| | | } |
| | |
| | | return; |
| | | } |
| | | // 非白名单用户先回到小鹏汽车首页,页面内容后续再补充 |
| | | jumpToHome(); |
| | | // jumpToHome(); |
| | | Taro.switchTab({ |
| | | url: '/pages/home/index', |
| | | }); |
| | | }; // 返回首页 |
| | | |
| | | const handleContactService = () => { |
| | |
| | | const WHITELIST_EXEMPT_ROUTES = [ |
| | | 'pages/not-on-whitelist/index', |
| | | 'pages/user/login', |
| | | 'robotaxi/pages/share-trip/index', |
| | | ]; |
| | | |
| | | /** 分享行程页路由片段。 */ |
| | | const SHARE_TRIP_ROUTE_SEGMENT = 'pages/share-trip/index'; |
| | | |
| | | /** 白名单请求并发锁。 */ |
| | | let whitelistCheckTask: Promise<WhitelistAccessResult> | null = null; |
| | |
| | | * 判断当前页面是否跳过白名单守卫。 |
| | | */ |
| | | export function isWhitelistExemptPage(route: string) { |
| | | if (!route) { |
| | | return false; |
| | | } |
| | | // 分享行程允许非白名单用户访问,路由命中时直接跳过白名单拦截。 |
| | | if (route === SHARE_TRIP_ROUTE_SEGMENT || route.endsWith(`/${SHARE_TRIP_ROUTE_SEGMENT}`)) { |
| | | return true; |
| | | } |
| | | return WHITELIST_EXEMPT_ROUTES.includes(route); |
| | | } |
| | | |