feat(robotaxi): 添加蓝牙车控授权与解锁功能
- 新增蓝牙车控 API 接口用于获取解锁凭证
- 添加蓝牙授权弹窗和授权成功弹窗组件
- 实现蓝牙状态管理 hook,支持系统蓝牙检测和小程序授权
- 实现蓝牙车控连接 hook,支持车辆蓝牙连接和鉴权流程
- 在行程状态 4/5 时展示蓝牙操作卡片,引导用户开启/授权蓝牙
- 优化行程状态头部组件,移除取消按钮相关逻辑
- 在路线管理 hook 中添加加载状态标记
- 优化首页目的地显示样式,支持长文本尾部保留展示
| | |
| | | .now-site-dot { |
| | | width: 12px; |
| | | height: 12px; |
| | | flex-shrink: 0; |
| | | border-radius: 50%; |
| | | background: #6D7FD9; |
| | | margin-right: 24px; |
| | |
| | | .to-site-dot { |
| | | width: 12px; |
| | | height: 12px; |
| | | flex-shrink: 0; |
| | | border-radius: 50%; |
| | | background: #8CB654; |
| | | margin-right: 24px; |
| | |
| | | align-items: center; |
| | | |
| | | .to-site-text { |
| | | flex-shrink: 0; |
| | | font-size: 32px; |
| | | font-weight: 500; |
| | | line-height: 48px; |
| | |
| | | border-radius: 12px; |
| | | background: #FFFFFF; |
| | | padding: 8px 21px; |
| | | display: flex; |
| | | align-items: center; |
| | | font-size: 24px; |
| | | font-weight: normal; |
| | | line-height: 48px; |
| | | letter-spacing: normal; |
| | | color: #3D3D3D; |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | white-space: nowrap; |
| | | word-break: break-all; |
| | | flex-shrink: 0; |
| | | max-width: 300px; |
| | | box-sizing: border-box; |
| | | |
| | | .to-site-fast-full { |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | white-space: nowrap; |
| | | } |
| | | |
| | | .to-site-fast-head { |
| | | flex: 1; |
| | | min-width: 0; |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | white-space: nowrap; |
| | | } |
| | | |
| | | .to-site-fast-tail { |
| | | flex-shrink: 0; |
| | | } |
| | | } |
| | | } |
| | | } |
| | |
| | | import { checkOtp } from '../../../utils/tools'; |
| | | |
| | | export default function Home() { |
| | | const renderTailPreserveText = (text: string, tailCount = 3) => { |
| | | const safeText = String(text || ''); |
| | | if (!safeText) return null; |
| | | if (safeText.length <= tailCount) { |
| | | return <Text className='to-site-fast-full'>{safeText}</Text>; |
| | | } |
| | | const headText = safeText.slice(0, -tailCount); |
| | | const tailText = safeText.slice(-tailCount); |
| | | return ( |
| | | <> |
| | | <Text className='to-site-fast-head'>{headText}</Text> |
| | | <Text className='to-site-fast-tail'>{tailText}</Text> |
| | | </> |
| | | ); |
| | | }; |
| | | |
| | | const [showPrivacy, setShowPrivacy] = useState(false); // 隐私政策弹窗是否显示 |
| | | const [showCustomerService, setShowCustomerService] = useState(false); // 客服弹窗是否显示 |
| | |
| | | <View className="to-site-dot"></View> |
| | | <View className="to-site-address"> |
| | | <View className="to-site-text">你想去哪?</View> |
| | | {/* <View |
| | | <View |
| | | onClick={(e) => { |
| | | e.stopPropagation(); |
| | | toBeginRoute() |
| | | }} |
| | | className="to-site-fast" |
| | | >去:中国南方电网·3号门</View> */} |
| | | > |
| | | {renderTailPreserveText('去:中国南方电网·3号11111三大法师多发点11门')} |
| | | </View> |
| | | </View> |
| | | </View> |
| | | </View> |
| New file |
| | |
| | | import request from '../../utils/request'; |
| | | import { _ROBOTAXI_HOST } from '../../constants/api'; |
| | | |
| | | /** |
| | | * 获取蓝牙解锁鉴权凭证。 |
| | | */ |
| | | export const getBluetoothUnlockToken = async (orderId: string) => { |
| | | return request({ |
| | | url: _ROBOTAXI_HOST + `/order/api/v1/mini-program/bluetooth/getUnlocktToken/${orderId}`, |
| | | method: 'GET' |
| | | }, true); |
| | | }; |
| New file |
| | |
| | | .auth-bluetooth-popup { |
| | | position: fixed; |
| | | top: 0; |
| | | left: 0; |
| | | right: 0; |
| | | bottom: 0; |
| | | z-index: 1000; |
| | | display: flex; |
| | | justify-content: center; |
| | | align-items: flex-end; |
| | | pointer-events: none; |
| | | |
| | | &.show { |
| | | pointer-events: auto; |
| | | } |
| | | |
| | | .popup-mask { |
| | | position: absolute; |
| | | top: 0; |
| | | left: 0; |
| | | width: 100%; |
| | | height: 100%; |
| | | background: rgba(0, 0, 0, 0.4); |
| | | z-index: 1; |
| | | } |
| | | |
| | | .popup-content { |
| | | position: relative; |
| | | z-index: 2; |
| | | background: #FFFFFF; |
| | | border-radius: 40px 40px 0 0; |
| | | background: linear-gradient(180deg, #D9EBFF 0%, #FFFFFF 35%); |
| | | width: 100%; |
| | | transform: translateY(100%); |
| | | opacity: 1; |
| | | overflow: hidden; |
| | | } |
| | | |
| | | .close-icon { |
| | | position: absolute; |
| | | top: 28rpx; |
| | | right: 32rpx; |
| | | width: 40rpx; |
| | | height: 40rpx; |
| | | z-index: 3; |
| | | } |
| | | |
| | | .bluetooth-bg { |
| | | position: absolute; |
| | | top: 0; |
| | | right: 32rpx; |
| | | width: 282rpx; |
| | | height: 222rpx; |
| | | z-index: 1; |
| | | } |
| | | |
| | | .content-main { |
| | | position: relative; |
| | | z-index: 2; |
| | | padding: 58rpx 40rpx 48rpx; |
| | | display: flex; |
| | | flex-direction: column; |
| | | } |
| | | |
| | | .popup-title { |
| | | font-size: 48rpx; |
| | | font-weight: 600; |
| | | line-height: 66rpx; |
| | | color: #2f3546; |
| | | } |
| | | |
| | | .popup-sub-title { |
| | | margin-top: 4rpx; |
| | | font-size: 48rpx; |
| | | font-weight: 600; |
| | | line-height: 66rpx; |
| | | color: #2f3546; |
| | | } |
| | | |
| | | .popup-desc { |
| | | margin-top: 18rpx; |
| | | font-size: 32rpx; |
| | | line-height: 44rpx; |
| | | color: rgba(47, 53, 70, 0.65); |
| | | } |
| | | |
| | | .authorize-btn { |
| | | margin-top: 60rpx; |
| | | width: 100%; |
| | | height: 88rpx; |
| | | border-radius: 20rpx; |
| | | background: linear-gradient(90deg, #7b79ff 0%, #68c0ff 100%); |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | font-size: 34rpx; |
| | | font-weight: 600; |
| | | color: #ffffff; |
| | | } |
| | | } |
| New file |
| | |
| | | import Taro from '@tarojs/taro'; |
| | | import { View, Text, Image } from '@tarojs/components'; |
| | | import { useEffect, useState } from 'react'; |
| | | import './index.less'; |
| | | import closeIcon from '../../../../assets/close-icon.png'; |
| | | import bluetoothBg from '../../../../assets/bluetooth-bg.png'; |
| | | |
| | | type Props = { |
| | | show: boolean; |
| | | onClose: () => void; |
| | | onAuthorize?: () => void; |
| | | }; |
| | | |
| | | export default function AuthBluetoothPopup({ show, onClose, onAuthorize }: Props) { |
| | | const [visible, setVisible] = useState(false); |
| | | const [animationData, setAnimationData] = useState<any>(); |
| | | |
| | | useEffect(() => { |
| | | if (show && !visible) { |
| | | setVisible(true); |
| | | const animation = Taro.createAnimation({ |
| | | duration: 300, |
| | | timingFunction: 'ease-out' |
| | | }); |
| | | animation.opacity(1).translateY(0).step(); |
| | | setAnimationData(animation.export()); |
| | | } else if (!show && visible) { |
| | | const animation = Taro.createAnimation({ |
| | | duration: 300, |
| | | timingFunction: 'ease-in' |
| | | }); |
| | | animation.opacity(1).translateY('100%').step(); |
| | | setAnimationData(animation.export()); |
| | | setTimeout(() => { |
| | | setVisible(false); |
| | | }, 300); |
| | | } |
| | | }, [show, visible]); |
| | | |
| | | if (!visible && !show) return null; |
| | | |
| | | return ( |
| | | <View className={`auth-bluetooth-popup ${show ? 'show' : ''}`} catchMove> |
| | | <View className='popup-mask' onClick={onClose}></View> |
| | | <View className='popup-content' animation={animationData}> |
| | | <Image className='close-icon' src={closeIcon} onClick={onClose} /> |
| | | <Image className='bluetooth-bg' src={bluetoothBg} /> |
| | | <View className='content-main'> |
| | | <Text className='popup-title'>授权蓝牙</Text> |
| | | <Text className='popup-sub-title'>无感自动开启车辆</Text> |
| | | <Text className='popup-desc'>授权并打开蓝牙连接</Text> |
| | | <Text className='popup-desc'>手机靠近车辆即可解锁并自动开启车门</Text> |
| | | <View className='authorize-btn' onClick={onAuthorize}> |
| | | 去授权 |
| | | </View> |
| | | </View> |
| | | </View> |
| | | </View> |
| | | ); |
| | | } |
| New file |
| | |
| | | .success-auth-popup { |
| | | position: fixed; |
| | | top: 0; |
| | | left: 0; |
| | | right: 0; |
| | | bottom: 0; |
| | | z-index: 1000; |
| | | display: flex; |
| | | justify-content: center; |
| | | align-items: flex-end; |
| | | pointer-events: none; |
| | | |
| | | &.show { |
| | | pointer-events: auto; |
| | | } |
| | | |
| | | .popup-mask { |
| | | position: absolute; |
| | | top: 0; |
| | | left: 0; |
| | | width: 100%; |
| | | height: 100%; |
| | | background: rgba(0, 0, 0, 0.4); |
| | | z-index: 1; |
| | | } |
| | | |
| | | .popup-content { |
| | | position: relative; |
| | | z-index: 2; |
| | | width: 686rpx; |
| | | min-height: 430rpx; |
| | | background: linear-gradient(180deg, #D9EBFF 0%, #FFFFFF 35%); |
| | | border-radius: 32rpx; |
| | | transform: translateY(100%); |
| | | opacity: 1; |
| | | padding: 56rpx 48rpx 48rpx; |
| | | display: flex; |
| | | flex-direction: column; |
| | | align-items: center; |
| | | box-sizing: border-box; |
| | | } |
| | | |
| | | .popup-icon { |
| | | width: 148rpx; |
| | | height: 148rpx; |
| | | margin-top: 6rpx; |
| | | } |
| | | |
| | | .popup-title { |
| | | margin-top: 22rpx; |
| | | font-size: 34rpx; |
| | | font-weight: 600; |
| | | line-height: 48rpx; |
| | | color: rgba(0, 0, 0, 0.9); |
| | | text-align: center; |
| | | } |
| | | |
| | | .popup-desc { |
| | | margin-top: 12rpx; |
| | | font-size: 28rpx; |
| | | line-height: 40rpx; |
| | | color: rgba(0, 0, 0, 0.6); |
| | | text-align: center; |
| | | } |
| | | |
| | | .popup-confirm-btn { |
| | | margin-top: 48rpx; |
| | | width: 590rpx; |
| | | height: 84rpx; |
| | | border-radius: 16rpx; |
| | | border: 2rpx solid rgba(0, 0, 0, 0.45); |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | font-size: 32rpx; |
| | | font-weight: 500; |
| | | color: rgba(0, 0, 0, 0.85); |
| | | background: transparent; |
| | | } |
| | | } |
| New file |
| | |
| | | import Taro from '@tarojs/taro'; |
| | | import { View, Text, Image } from '@tarojs/components'; |
| | | import { useEffect, useState } from 'react'; |
| | | import './index.less'; |
| | | import successAuthIcon from '../../../../../assets/bluetooth-success-auth.png'; |
| | | |
| | | type Props = { |
| | | show: boolean; |
| | | onClose: () => void; |
| | | onConfirm?: () => void; |
| | | }; |
| | | |
| | | export default function SuccessAuthPopup({ show, onClose, onConfirm }: Props) { |
| | | const [visible, setVisible] = useState(false); |
| | | const [animationData, setAnimationData] = useState<any>(); |
| | | |
| | | useEffect(() => { |
| | | if (show && !visible) { |
| | | setVisible(true); |
| | | const animation = Taro.createAnimation({ |
| | | duration: 300, |
| | | timingFunction: 'ease-out' |
| | | }); |
| | | animation.opacity(1).translateY(0).step(); |
| | | setAnimationData(animation.export()); |
| | | } else if (!show && visible) { |
| | | const animation = Taro.createAnimation({ |
| | | duration: 300, |
| | | timingFunction: 'ease-in' |
| | | }); |
| | | animation.opacity(1).translateY('100%').step(); |
| | | setAnimationData(animation.export()); |
| | | setTimeout(() => { |
| | | setVisible(false); |
| | | }, 300); |
| | | } |
| | | }, [show, visible]); |
| | | |
| | | if (!visible && !show) return null; |
| | | |
| | | const handleConfirm = () => { |
| | | onConfirm?.(); |
| | | onClose(); |
| | | }; |
| | | |
| | | return ( |
| | | <View className={`success-auth-popup ${show ? 'show' : ''}`} catchMove> |
| | | <View className='popup-mask' onClick={onClose}></View> |
| | | <View className='popup-content' animation={animationData}> |
| | | <Image className='popup-icon' src={successAuthIcon} /> |
| | | <Text className='popup-title'>已授权蓝牙,无感自动开启车辆</Text> |
| | | <Text className='popup-desc'>授权并打开蓝牙连接,手机靠近车辆即可解锁并自动启车门</Text> |
| | | <View className='popup-confirm-btn' onClick={handleConfirm}> |
| | | 知道了 |
| | | </View> |
| | | </View> |
| | | </View> |
| | | ); |
| | | } |
| | |
| | | import { View, Map, Image } from '@tarojs/components'; |
| | | import CustomerServicePopup from '../../../components/customer-service'; |
| | | import ReleaseRoute from './release-route'; |
| | | import ReleaseBeBeing from './release-be-being'; |
| | | import CancelPopup from './cancel-popup'; |
| | | import CallingCar from './calling-car'; |
| | | import AnnouncementsPopup from './announcements-popup'; |
| | | import OverRoute from './over-route'; |
| | | import PassengerNotice from './passenger-notice'; |
| | | import EvaluatePopup from './evaluate-popup'; |
| | | import TipPopup from '../../select-map-site/components/tip-popup'; |
| | | import AuthBluetoothPopup from './auth-bluetooth-popup'; |
| | | |
| | | type Props = { |
| | | mapLongitude: number; |
| | |
| | | orderId: string; |
| | | handleCloseEvaluatePopup: () => void; |
| | | handleConfirmEvaluate: (content: string) => void; |
| | | showAuthBluetoothPopup?: boolean; |
| | | handleCloseAuthBluetoothPopup?: () => void; |
| | | handleAuthorizeBluetooth?: () => void; |
| | | }; |
| | | |
| | | export default function BeginRouteView(props: Props) { |
| | |
| | | showEvaluatePopup, |
| | | orderId, |
| | | handleCloseEvaluatePopup, |
| | | handleConfirmEvaluate |
| | | handleConfirmEvaluate, |
| | | showAuthBluetoothPopup = false, |
| | | handleCloseAuthBluetoothPopup = () => { }, |
| | | handleAuthorizeBluetooth |
| | | } = props; |
| | | |
| | | // 统一渲染地图与业务弹窗容器 |
| | |
| | | onClose={handleCloseEvaluatePopup} |
| | | onConfirm={handleConfirmEvaluate} |
| | | /> |
| | | <AuthBluetoothPopup |
| | | show={showAuthBluetoothPopup} |
| | | onClose={handleCloseAuthBluetoothPopup} |
| | | onAuthorize={handleAuthorizeBluetooth} |
| | | /> |
| | | </View> |
| | | ); |
| | | } |
| | |
| | | import { View, Image } from "@tarojs/components"; |
| | | import bluetoothIcon from "@/assets/images/bluetooth-icon.png"; |
| | | import blueRightIcon from "@/assets/images/blue-right-icon.png"; |
| | | import bluetoothIcon from "../../../../assets/bluetooth-icon.png"; |
| | | import blueRightIcon from "../../../../assets/blue-right-icon.png"; |
| | | |
| | | export default function BluetoothOperateCard() { |
| | | |
| | | type Props = { |
| | | description: string; |
| | | actionText?: string; |
| | | onClick: () => void; |
| | | }; |
| | | |
| | | export default function BluetoothOperateCard({ description, actionText = '', onClick }: Props) { |
| | | return ( |
| | | <View className="bluetooth-operate-card"> |
| | | <View className="bluetooth-operate-card" onClick={onClick}> |
| | | <View className="bluetooth-operate-card-left"> |
| | | <Image src={bluetoothIcon} /> |
| | | <View>系统蓝牙未开启,无法自动解锁车辆</View> |
| | | <View>{description}</View> |
| | | </View> |
| | | <View className="bluetooth-operate-card-right"> |
| | | <View>去开启</View> |
| | | <Image src={blueRightIcon} /> |
| | | </View> |
| | | {!!actionText && ( |
| | | <View className="bluetooth-operate-card-right"> |
| | | <View>{actionText}</View> |
| | | <Image src={blueRightIcon} /> |
| | | </View> |
| | | )} |
| | | </View> |
| | | ); |
| | | } |
| | | } |
| | |
| | | finishCountdownEnabled: boolean; |
| | | finishCountdownSeconds: number | null; |
| | | limitMinutes: number | null; |
| | | handleOpenCancelPopup?: (durationDisplay: any) => void; |
| | | }; |
| | | |
| | | export default function TripStatusHeader({ |
| | |
| | | finishCountdownEnabled, |
| | | finishCountdownSeconds, |
| | | limitMinutes, |
| | | handleOpenCancelPopup |
| | | }: Props) { |
| | | const [queueStatus, setQueueStatus] = useState<QueueStatus>(null); |
| | | |
| | |
| | | </View> |
| | | )} |
| | | </View> |
| | | { |
| | | [3, 4, 5].includes(tripStatus) && |
| | | <View className="cancel-btn"> |
| | | <Image onClick={() => handleOpenCancelPopup?.(durationDisplay)} src={require('../../../../assets/cancel-btn.png')} className="cancel" /> |
| | | </View> |
| | | } |
| | | </View> |
| | | ); |
| | | } |
| | |
| | | import DoorTrunkControl from './calling-car-modules/door-trunk-control'; |
| | | import FareDetailCard from './calling-car-modules/fare-detail-card'; |
| | | import EvaluationCard from './calling-car-modules/evaluation-card'; |
| | | import BluetoothOperateCard from './calling-car-modules/bluetooth-operate-card'; |
| | | import AuthBluetoothPopup from './auth-bluetooth-popup/index'; |
| | | import SuccessAuthPopup from './auth-bluetooth-popup/success-auth'; |
| | | |
| | | type Props = { |
| | | /** 行程状态:3呼叫中、4即将到达、5已到达上车点、6行程中、7到达终点、8待支付、9已支付 */ |
| | |
| | | }; |
| | | orderId?: string; |
| | | orderTrunkStatus?: boolean; |
| | | bluetoothCardDescription?: string; |
| | | bluetoothCardActionText?: string; |
| | | handleBluetoothCardClick?: () => void; |
| | | showAuthBluetoothPopup?: boolean; |
| | | showSuccessAuthPopup?: boolean; |
| | | handleCloseAuthBluetoothPopup?: () => void; |
| | | handleCloseSuccessAuthPopup?: () => void; |
| | | handleAuthorizeBluetooth?: () => void; |
| | | } |
| | | |
| | | export default function CallingCar({ |
| | |
| | | vehicleInfo, |
| | | orderId, |
| | | orderTrunkStatus, |
| | | bluetoothCardDescription = '系统蓝牙未开启,无法自动解锁车辆', |
| | | bluetoothCardActionText = '去开启', |
| | | handleBluetoothCardClick, |
| | | showAuthBluetoothPopup = false, |
| | | showSuccessAuthPopup = false, |
| | | handleCloseAuthBluetoothPopup, |
| | | handleCloseSuccessAuthPopup, |
| | | handleAuthorizeBluetooth |
| | | }: Props) { |
| | | |
| | | // 汇总行程中的倒计时与费用数据 |
| | | const { |
| | | waitMinutes, |
| | |
| | | /> |
| | | |
| | | { |
| | | [4, 5].includes(tripStatus) && |
| | | <BluetoothOperateCard |
| | | description={bluetoothCardDescription} |
| | | actionText={bluetoothCardActionText} |
| | | onClick={handleBluetoothCardClick || (() => { })} |
| | | /> |
| | | } |
| | | |
| | | { |
| | | [4, 5].includes(tripStatus) && |
| | | <AuthBluetoothPopup |
| | | show={showAuthBluetoothPopup} |
| | | onClose={handleCloseAuthBluetoothPopup} |
| | | onAuthorize={handleAuthorizeBluetooth} |
| | | /> |
| | | } |
| | | |
| | | { |
| | | [4, 5].includes(tripStatus) && |
| | | <SuccessAuthPopup |
| | | show={showSuccessAuthPopup} |
| | | onClose={handleCloseSuccessAuthPopup || (() => { })} |
| | | /> |
| | | } |
| | | |
| | | { |
| | | [4, 5, 6, 8, 9].includes(tripStatus) && |
| | | <View className="car-card"> |
| | | <VehicleInfoCard vehicleInfo={vehicleInfo} /> |
| | |
| | | box-sizing: border-box; |
| | | |
| | | &-icon { |
| | | flex-shrink: 0; |
| | | width: 48px; |
| | | height: 48px; |
| | | } |
| | | |
| | | &-text { |
| | | flex-shrink: 0; |
| | | font-size: 24px; |
| | | line-height: 40px; |
| | | color: rgba(0, 0, 0, 0.9); |
| | |
| | | } |
| | | |
| | | .trip-action-bar-item-hover { |
| | | flex-shrink: 0; |
| | | opacity: 0.75; |
| | | } |
| | | |
| | | .close-btn-item { |
| | | position: relative; |
| | | border-radius: 16px; |
| | | background: linear-gradient( |
| | | 110deg, |
| | | rgba(132, 117, 255, 0.2) 0%, |
| | | rgba(125, 214, 255, 0.2) 99% |
| | | ) !important; |
| | | background: linear-gradient(110deg, |
| | | rgba(132, 117, 255, 0.2) 0%, |
| | | rgba(125, 214, 255, 0.2) 99%) !important; |
| | | |
| | | &::after { |
| | | content: ""; |
| | |
| | | bottom: 0; |
| | | border-radius: 16px; |
| | | padding: 1px; |
| | | background: linear-gradient( |
| | | 180deg, |
| | | #aaa8ff -10%, |
| | | rgba(41, 159, 255, 0.0469) 108%, |
| | | rgba(0, 0, 0, 0) 108% |
| | | ); |
| | | background: linear-gradient(180deg, |
| | | #aaa8ff -10%, |
| | | rgba(41, 159, 255, 0.0469) 108%, |
| | | rgba(0, 0, 0, 0) 108%); |
| | | -webkit-mask: linear-gradient(#fff 0 0) content-box, |
| | | linear-gradient(#fff 0 0); |
| | | -webkit-mask-composite: xor; |
| | |
| | | } |
| | | |
| | | .bluetooth-operate-card { |
| | | position: relative; |
| | | z-index: 1; |
| | | margin-top: 40px; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | |
| | | height: 17.5px; |
| | | } |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | import Taro, { useDidShow } from '@tarojs/taro'; |
| | | import { useCallback, useEffect, useMemo, useState } from 'react'; |
| | | |
| | | type Deps = { |
| | | tripStatus: number; |
| | | bluetoothAuthSuccess: boolean; |
| | | startBluetoothAuth: () => void; |
| | | }; |
| | | |
| | | type BluetoothAccessStatus = 'system_off' | 'unauthorized' | 'authorized'; |
| | | |
| | | const BLUETOOTH_AUTH_KEY = 'ROBOTAXI_BLUETOOTH_AUTHORIZED'; |
| | | |
| | | /** |
| | | * 蓝牙能力编排: |
| | | * 1. 系统蓝牙未开启 |
| | | * 2. 系统蓝牙已开启,但小程序未授权 |
| | | * 3. 系统蓝牙已开启,且小程序已授权 |
| | | */ |
| | | export default function useBluetoothAccess({ tripStatus, bluetoothAuthSuccess, startBluetoothAuth }: Deps) { |
| | | const [status, setStatus] = useState<BluetoothAccessStatus>('system_off'); // 当前蓝牙访问态:系统未开 / 小程序未授权 / 已授权 |
| | | const [showAuthBluetoothPopup, setShowAuthBluetoothPopup] = useState(false); // 蓝牙授权引导弹窗展示态 |
| | | const [showSuccessAuthPopup, setShowSuccessAuthPopup] = useState(false); // 蓝牙授权成功弹窗展示态 |
| | | |
| | | // 刷新蓝牙状态:同时判断系统蓝牙开关与小程序授权态 |
| | | const refreshBluetoothState = useCallback(async () => { |
| | | let hasMiniappAuth = Boolean(Taro.getStorageSync(BLUETOOTH_AUTH_KEY)); |
| | | try { |
| | | const setting = await Taro.getSetting(); |
| | | if (setting?.authSetting?.['scope.bluetooth']) { |
| | | hasMiniappAuth = true; |
| | | } |
| | | } catch (error) { |
| | | } |
| | | |
| | | let systemBluetoothEnabled = false; |
| | | try { |
| | | await Taro.openBluetoothAdapter(); |
| | | systemBluetoothEnabled = true; |
| | | } catch (error: any) { |
| | | const errCode = Number(error?.errCode || 0); |
| | | const errMsg = String(error?.errMsg || ''); |
| | | if (errCode === 10001 || errMsg.includes('10001')) { |
| | | systemBluetoothEnabled = false; |
| | | } else if (/deny|auth deny|authorize/i.test(errMsg)) { |
| | | systemBluetoothEnabled = true; |
| | | hasMiniappAuth = false; |
| | | } else { |
| | | systemBluetoothEnabled = false; |
| | | } |
| | | } |
| | | |
| | | if (!systemBluetoothEnabled) { |
| | | setStatus('system_off'); |
| | | return; |
| | | } |
| | | const nextStatus = hasMiniappAuth ? 'authorized' : 'unauthorized'; |
| | | setStatus(nextStatus); |
| | | if (nextStatus === 'authorized') { |
| | | startBluetoothAuth(); |
| | | } |
| | | }, [startBluetoothAuth]); |
| | | |
| | | // 仅在 4/5 状态维护蓝牙 UI;其他状态强制收起弹窗 |
| | | useEffect(() => { |
| | | if ([4, 5].includes(tripStatus)) { |
| | | refreshBluetoothState(); |
| | | return; |
| | | } |
| | | setShowAuthBluetoothPopup(false); |
| | | setShowSuccessAuthPopup(false); |
| | | }, [tripStatus, refreshBluetoothState]); |
| | | |
| | | // 页面回前台后同步蓝牙状态,避免设置页返回后状态滞后 |
| | | useDidShow(() => { |
| | | if ([4, 5].includes(tripStatus)) { |
| | | refreshBluetoothState(); |
| | | } |
| | | }); |
| | | |
| | | // 跳转系统蓝牙设置,并在返回后触发一次状态刷新 |
| | | const handleOpenSystemBluetoothSetting = useCallback(async () => { |
| | | try { |
| | | const openSystemBluetoothSetting = (Taro as any).openSystemBluetoothSetting; |
| | | if (typeof openSystemBluetoothSetting === 'function') { |
| | | await openSystemBluetoothSetting(); |
| | | } else { |
| | | await Taro.openSetting(); |
| | | } |
| | | } catch (error) { |
| | | Taro.showToast({ title: '请手动开启系统蓝牙', icon: 'none' }); |
| | | } finally { |
| | | setTimeout(() => { |
| | | refreshBluetoothState(); |
| | | }, 300); |
| | | } |
| | | }, [refreshBluetoothState]); |
| | | |
| | | // 打开蓝牙授权弹窗 |
| | | const handleOpenAuthBluetoothPopup = useCallback(() => { |
| | | setShowAuthBluetoothPopup(true); |
| | | }, []); |
| | | |
| | | // 关闭蓝牙授权弹窗 |
| | | const handleCloseAuthBluetoothPopup = useCallback(() => { |
| | | setShowAuthBluetoothPopup(false); |
| | | }, []); |
| | | |
| | | // 关闭蓝牙授权成功弹窗 |
| | | const handleCloseSuccessAuthPopup = useCallback(() => { |
| | | setShowSuccessAuthPopup(false); |
| | | }, []); |
| | | |
| | | // 执行小程序蓝牙授权,成功后展示成功弹窗 |
| | | const handleAuthorizeBluetooth = useCallback(async () => { |
| | | try { |
| | | await Taro.openBluetoothAdapter(); |
| | | Taro.setStorageSync(BLUETOOTH_AUTH_KEY, true); |
| | | setShowAuthBluetoothPopup(false); |
| | | await refreshBluetoothState(); |
| | | startBluetoothAuth(); |
| | | } catch (error) { |
| | | Taro.showToast({ title: '请先开启系统蓝牙并授权', icon: 'none' }); |
| | | } |
| | | }, [refreshBluetoothState, startBluetoothAuth]); |
| | | |
| | | // 蓝牙卡片点击分发:未开蓝牙->去开启,未授权->弹授权,已授权->弹成功态说明 |
| | | const handleBluetoothCardClick = useCallback(() => { |
| | | if (status === 'system_off') { |
| | | handleOpenSystemBluetoothSetting(); |
| | | return; |
| | | } |
| | | if (status === 'unauthorized') { |
| | | handleOpenAuthBluetoothPopup(); |
| | | return; |
| | | } |
| | | setShowSuccessAuthPopup(true); |
| | | }, [status, handleOpenSystemBluetoothSetting, handleOpenAuthBluetoothPopup]); |
| | | |
| | | // 蓝牙卡片主文案 |
| | | const cardDescription = useMemo(() => { |
| | | if (status === 'system_off') { |
| | | return '系统蓝牙未开启,无法自动解锁车辆'; |
| | | } |
| | | if (status === 'unauthorized') { |
| | | return '授权蓝牙,车辆自动解锁'; |
| | | } |
| | | return '车辆到达后,手机靠近车门可自动解锁'; |
| | | }, [status]); |
| | | |
| | | useEffect(() => { |
| | | if (bluetoothAuthSuccess) { |
| | | setShowSuccessAuthPopup(true); |
| | | } |
| | | }, [bluetoothAuthSuccess]); |
| | | |
| | | // 蓝牙卡片右侧动作文案 |
| | | const cardActionText = useMemo(() => { |
| | | if (status === 'system_off') { |
| | | return '去开启'; |
| | | } |
| | | if (status === 'authorized') { |
| | | return ''; |
| | | } |
| | | return '去授权'; |
| | | }, [status]); |
| | | |
| | | // 对外暴露:状态 + 视图文案 + 交互动作 |
| | | return { |
| | | bluetoothStatus: status, |
| | | bluetoothCardDescription: cardDescription, |
| | | bluetoothCardActionText: cardActionText, |
| | | showAuthBluetoothPopup, |
| | | showSuccessAuthPopup, |
| | | handleBluetoothCardClick, |
| | | handleOpenAuthBluetoothPopup, |
| | | handleCloseAuthBluetoothPopup, |
| | | handleCloseSuccessAuthPopup, |
| | | handleAuthorizeBluetooth, |
| | | refreshBluetoothState |
| | | }; |
| | | } |
| New file |
| | |
| | | import Taro from '@tarojs/taro'; |
| | | import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; |
| | | import RtAuthBluetoothManager from '../../../utils/car-control/RtAuthBluetoothManager'; |
| | | import { getBluetoothUnlockToken } from '../../../api/car-control'; |
| | | |
| | | type Deps = { |
| | | orderId: string; |
| | | tripStatus: number; |
| | | vehicleInfo: any; |
| | | }; |
| | | |
| | | type ConnectStatus = 0 | 1 | 2 | 3; |
| | | |
| | | /** |
| | | * 解析车辆信息中的蓝牙 MAC。 |
| | | */ |
| | | function resolveBluetoothMac(vehicleInfo: any): string { |
| | | const mac = vehicleInfo?.macAddress; |
| | | return typeof mac === 'string' ? mac.trim() : ''; |
| | | } |
| | | |
| | | /** |
| | | * 解析设备型号,优先使用系统型号。 |
| | | */ |
| | | function resolveDeviceModel(): string { |
| | | try { |
| | | const info = Taro.getSystemInfoSync(); |
| | | return info?.model || 'MiniProgram Device'; |
| | | } catch (error) { |
| | | return 'MiniProgram Device'; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 蓝牙车控编排:负责凭证获取、蓝牙连接和 RtAuth 鉴权。 |
| | | */ |
| | | export default function useBluetoothCarControl({ orderId, tripStatus, vehicleInfo }: Deps) { |
| | | const managerRef = useRef<any>(null); // 蓝牙管理器实例引用 |
| | | const lastStartKeyRef = useRef(''); // 最近一次启动鉴权的参数签名 |
| | | const [connectStatus, setConnectStatus] = useState<ConnectStatus>(0); // 蓝牙连接状态:0断开/1连接中/2已连接/3已鉴权 |
| | | const [authSuccess, setAuthSuccess] = useState(false); // RtAuth 鉴权是否成功 |
| | | const [isAuthorizing, setIsAuthorizing] = useState(false); // 鉴权流程进行中 |
| | | const [authError, setAuthError] = useState<string>(''); // 鉴权流程错误信息 |
| | | |
| | | const bluetoothMac = useMemo(() => resolveBluetoothMac(vehicleInfo), [vehicleInfo]); // 车辆蓝牙 MAC |
| | | |
| | | /** |
| | | * 初始化蓝牙管理器。 |
| | | */ |
| | | const ensureManager = useCallback(() => { |
| | | if (managerRef.current) return managerRef.current; |
| | | |
| | | managerRef.current = new RtAuthBluetoothManager({ |
| | | onStatusChange: (status: ConnectStatus) => { |
| | | setConnectStatus(status); |
| | | if (status === 3) { |
| | | setAuthSuccess(true); |
| | | setAuthError(''); |
| | | setIsAuthorizing(false); |
| | | } |
| | | }, |
| | | onError: (error: any) => { |
| | | setAuthError(error?.msg || '蓝牙连接失败'); |
| | | setIsAuthorizing(false); |
| | | }, |
| | | onRtAuthResult: (result: any) => { |
| | | if (result?.success) { |
| | | setAuthSuccess(true); |
| | | setAuthError(''); |
| | | setConnectStatus(3); |
| | | } else { |
| | | const message = result?.data?.errorDesc || result?.error || '蓝牙鉴权失败'; |
| | | setAuthSuccess(false); |
| | | setAuthError(String(message)); |
| | | } |
| | | setIsAuthorizing(false); |
| | | } |
| | | }); |
| | | return managerRef.current; |
| | | }, []); |
| | | |
| | | /** |
| | | * 主动断开蓝牙连接并重置状态。 |
| | | */ |
| | | const closeConnection = useCallback(() => { |
| | | if (managerRef.current) { |
| | | managerRef.current.closeConnection(); |
| | | } |
| | | setConnectStatus(0); |
| | | setAuthSuccess(false); |
| | | setIsAuthorizing(false); |
| | | }, []); |
| | | |
| | | /** |
| | | * 触发蓝牙连接与 RtAuth 鉴权。 |
| | | */ |
| | | const startAuth = useCallback(async () => { |
| | | if (![4, 5].includes(tripStatus)) return; |
| | | if (!orderId || !bluetoothMac) return; |
| | | |
| | | const startKey = `${orderId}|${bluetoothMac}`; |
| | | if (isAuthorizing || (authSuccess && lastStartKeyRef.current === startKey)) return; |
| | | lastStartKeyRef.current = startKey; |
| | | setIsAuthorizing(true); |
| | | setAuthError(''); |
| | | |
| | | try { |
| | | const tokenRes = await getBluetoothUnlockToken(orderId); |
| | | const certificate = typeof tokenRes === 'string' ? tokenRes : String(tokenRes || ''); |
| | | if (!certificate) { |
| | | setAuthError('蓝牙鉴权凭证为空'); |
| | | setIsAuthorizing(false); |
| | | return; |
| | | } |
| | | const manager = ensureManager(); |
| | | manager.updateCarConfig({ |
| | | macAddress: bluetoothMac, |
| | | certificate, |
| | | deviceModel: resolveDeviceModel() |
| | | }); |
| | | manager.openBluetoothAdapter(); |
| | | } catch (error: any) { |
| | | setAuthError(error?.msg || '获取蓝牙鉴权凭证失败'); |
| | | setIsAuthorizing(false); |
| | | } |
| | | }, [tripStatus, orderId, bluetoothMac, isAuthorizing, authSuccess, ensureManager]); |
| | | |
| | | /** |
| | | * 重试蓝牙鉴权流程。 |
| | | */ |
| | | const retryAuth = useCallback(() => { |
| | | if (!managerRef.current) { |
| | | startAuth(); |
| | | return; |
| | | } |
| | | setIsAuthorizing(true); |
| | | setAuthError(''); |
| | | managerRef.current.retryConnection(); |
| | | }, [startAuth]); |
| | | |
| | | useEffect(() => { |
| | | if (![4, 5].includes(tripStatus)) { |
| | | closeConnection(); |
| | | lastStartKeyRef.current = ''; |
| | | setAuthError(''); |
| | | } |
| | | }, [tripStatus, closeConnection]); |
| | | |
| | | useEffect(() => { |
| | | return () => { |
| | | if (managerRef.current) { |
| | | managerRef.current.closeConnection(); |
| | | } |
| | | }; |
| | | }, []); |
| | | |
| | | return { |
| | | bluetoothMac, |
| | | connectStatus, |
| | | authSuccess, |
| | | authError, |
| | | isAuthorizing, |
| | | startAuth, |
| | | retryAuth, |
| | | closeConnection |
| | | }; |
| | | } |
| | |
| | | const [fareEstimate, setFareEstimate] = useState<any>({}); |
| | | // 路线费用查询失败标记 |
| | | const [routeAndFareError, setRouteAndFareError] = useState(false); |
| | | // 路线费用请求中标记 |
| | | const [routeAndFareLoading, setRouteAndFareLoading] = useState(false); |
| | | // 路线距离展示 |
| | | const [routeDistance, setRouteDistance] = useState<{ value: number; unit: 'km' | 'm' } | null>(null); |
| | | // 路线时长(秒) |
| | |
| | | return; |
| | | } |
| | | routeRequestKeyRef.current = requestKey; |
| | | setRouteAndFareLoading(true); |
| | | const selectedCity = await ensureSelectedCityForRoute(originLng, originLat); |
| | | const openCityId = selectedCity ? selectedCity.id : 0; |
| | | const cachedCouponId = Taro.getStorageSync('selectedCouponId'); |
| | |
| | | } |
| | | }).catch(() => { |
| | | setRouteAndFareError(true); |
| | | }).finally(() => { |
| | | setRouteAndFareLoading(false); |
| | | }); |
| | | }; |
| | | |
| | |
| | | fareEstimate, |
| | | setFareEstimate, |
| | | routeAndFareError, |
| | | routeAndFareLoading, |
| | | setRouteAndFareError, |
| | | routeDistance, |
| | | setRouteDistance, |
| | |
| | | previewEndSite?: any; |
| | | /** 确认修改目的地 */ |
| | | handleConfirmModifyDestination?: (selectedRoute?: any) => void; |
| | | /** 蓝牙卡片描述文案 */ |
| | | bluetoothCardDescription: string; |
| | | /** 蓝牙卡片右侧动作文案 */ |
| | | bluetoothCardActionText: string; |
| | | /** 点击蓝牙卡片 */ |
| | | handleBluetoothCardClick: () => void; |
| | | /** 蓝牙授权弹窗是否展示 */ |
| | | showAuthBluetoothPopup: boolean; |
| | | /** 蓝牙授权成功弹窗是否展示 */ |
| | | showSuccessAuthPopup: boolean; |
| | | /** 关闭蓝牙授权弹窗 */ |
| | | handleCloseAuthBluetoothPopup: () => void; |
| | | /** 关闭蓝牙授权成功弹窗 */ |
| | | handleCloseSuccessAuthPopup: () => void; |
| | | /** 去授权蓝牙 */ |
| | | handleAuthorizeBluetooth: () => void; |
| | | }; |
| | | |
| | | /** |
| | |
| | | routeDurationSeconds={deps.routeDuration} |
| | | onToggleDetailFade={deps.handleToggleDetailFade} |
| | | orderTrunkStatus={deps.trunkStatus} |
| | | bluetoothCardDescription={deps.bluetoothCardDescription} |
| | | bluetoothCardActionText={deps.bluetoothCardActionText} |
| | | handleBluetoothCardClick={deps.handleBluetoothCardClick} |
| | | showAuthBluetoothPopup={deps.showAuthBluetoothPopup} |
| | | showSuccessAuthPopup={deps.showSuccessAuthPopup} |
| | | handleCloseAuthBluetoothPopup={deps.handleCloseAuthBluetoothPopup} |
| | | handleCloseSuccessAuthPopup={deps.handleCloseSuccessAuthPopup} |
| | | handleAuthorizeBluetooth={deps.handleAuthorizeBluetooth} |
| | | /> |
| | | ); |
| | | } |
| | |
| | | import useOrderActions from './hooks/use-order-actions'; |
| | | // 视图委托渲染编排 |
| | | import useViewDelegates from './hooks/use-view-delegates'; |
| | | // 修改目的地流程编排 |
| | | import useModifyDestinationFlow from './hooks/use-modify-destination-flow'; |
| | | // 蓝牙状态与弹窗编排 |
| | | import useBluetoothAccess from './hooks/use-bluetooth-access'; |
| | | // 蓝牙车控连接与鉴权编排 |
| | | import useBluetoothCarControl from './hooks/use-bluetooth-car-control'; |
| | | |
| | | import backIcon from '../../../robotaxi/assets/back-icon.png' |
| | | import personalCenter from '../../../robotaxi/assets/personal-center.png' |
| | |
| | | fareEstimate, |
| | | setFareEstimate, |
| | | routeAndFareError, |
| | | routeAndFareLoading, |
| | | routeDistance, |
| | | setRouteDistance, |
| | | routeDuration, |
| | |
| | | handleFinishCountdownTimeout, |
| | | handlePay |
| | | } = orderActions; |
| | | const { |
| | | authSuccess: bluetoothAuthSuccess, |
| | | startAuth: startBluetoothAuth |
| | | } = useBluetoothCarControl({ |
| | | orderId, |
| | | tripStatus, |
| | | vehicleInfo |
| | | }); |
| | | const { |
| | | bluetoothCardDescription, |
| | | bluetoothCardActionText, |
| | | showAuthBluetoothPopup, |
| | | showSuccessAuthPopup, |
| | | handleBluetoothCardClick, |
| | | handleCloseAuthBluetoothPopup, |
| | | handleCloseSuccessAuthPopup, |
| | | handleAuthorizeBluetooth |
| | | } = useBluetoothAccess({ |
| | | tripStatus, |
| | | bluetoothAuthSuccess, |
| | | startBluetoothAuth |
| | | }); |
| | | const modifyPreviewRouteKeyRef = useRef(''); |
| | | |
| | | useEffect(() => { |
| | |
| | | fareEstimate, |
| | | handleOpenPassengerNotice, |
| | | walkingTime, |
| | | isCallDisabled: routeAndFareError || callSubmitting, |
| | | isCallDisabled: routeAndFareLoading || routeAndFareError || callSubmitting, |
| | | handleOpenCancelPopupByTripStatus, |
| | | handleOpenCancelPopupWithType, |
| | | setTripStatus, |
| | |
| | | previousEndSite, |
| | | previousFareEstimate, |
| | | previewEndSite, |
| | | handleConfirmModifyDestination |
| | | handleConfirmModifyDestination, |
| | | bluetoothCardDescription, |
| | | bluetoothCardActionText, |
| | | handleBluetoothCardClick, |
| | | showAuthBluetoothPopup, |
| | | showSuccessAuthPopup, |
| | | handleCloseAuthBluetoothPopup, |
| | | handleCloseSuccessAuthPopup, |
| | | handleAuthorizeBluetooth |
| | | }); // 视图渲染委托 |
| | | const mapLongitude = longitude ?? 116.397128; |
| | | const mapLatitude = latitude ?? 39.916527; |