hejianhao
2026-04-20 cbf83b71bd690abfe3dd385ecdea7c35c8cdf68e
feat(robotaxi): 添加蓝牙车控授权与解锁功能

- 新增蓝牙车控 API 接口用于获取解锁凭证
- 添加蓝牙授权弹窗和授权成功弹窗组件
- 实现蓝牙状态管理 hook,支持系统蓝牙检测和小程序授权
- 实现蓝牙车控连接 hook,支持车辆蓝牙连接和鉴权流程
- 在行程状态 4/5 时展示蓝牙操作卡片,引导用户开启/授权蓝牙
- 优化行程状态头部组件,移除取消按钮相关逻辑
- 在路线管理 hook 中添加加载状态标记
- 优化首页目的地显示样式,支持长文本尾部保留展示
9个文件已添加
10个文件已修改
908 ■■■■■ 已修改文件
src/pages/robotaxi/home/index.less 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/robotaxi/home/index.tsx 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/api/car-control.ts 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/assets/bluetooth-bg.png 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/assets/bluetooth-success-auth.png 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/components/auth-bluetooth-popup/index.less 100 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/components/auth-bluetooth-popup/index.tsx 60 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/components/auth-bluetooth-popup/success-auth/index.less 80 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/components/auth-bluetooth-popup/success-auth/index.tsx 59 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/components/begin-route-view.tsx 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/components/calling-car-modules/bluetooth-operate-card.tsx 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/components/calling-car-modules/trip-status-header.tsx 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/components/calling-car.tsx 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/components/css-folder/calling-car.less 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/hooks/use-bluetooth-access.ts 182 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/hooks/use-bluetooth-car-control.ts 167 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/hooks/use-route-manager.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/hooks/use-view-delegates.tsx 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/index.tsx 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/robotaxi/home/index.less
@@ -426,6 +426,7 @@
        .now-site-dot {
          width: 12px;
          height: 12px;
          flex-shrink: 0;
          border-radius: 50%;
          background: #6D7FD9;
          margin-right: 24px;
@@ -475,6 +476,7 @@
        .to-site-dot {
          width: 12px;
          height: 12px;
          flex-shrink: 0;
          border-radius: 50%;
          background: #8CB654;
          margin-right: 24px;
@@ -485,6 +487,7 @@
          align-items: center;
          .to-site-text {
            flex-shrink: 0;
            font-size: 32px;
            font-weight: 500;
            line-height: 48px;
@@ -497,11 +500,38 @@
            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;
            }
          }
        }
      }
src/pages/robotaxi/home/index.tsx
@@ -57,6 +57,21 @@
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); // 客服弹窗是否显示
@@ -525,13 +540,14 @@
                <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>
src/robotaxi/api/car-control.ts
New file
@@ -0,0 +1,12 @@
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);
};
src/robotaxi/assets/bluetooth-bg.png
src/robotaxi/assets/bluetooth-success-auth.png
src/robotaxi/pages/begin-route/components/auth-bluetooth-popup/index.less
New file
@@ -0,0 +1,100 @@
.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;
  }
}
src/robotaxi/pages/begin-route/components/auth-bluetooth-popup/index.tsx
New file
@@ -0,0 +1,60 @@
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>
  );
}
src/robotaxi/pages/begin-route/components/auth-bluetooth-popup/success-auth/index.less
New file
@@ -0,0 +1,80 @@
.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;
  }
}
src/robotaxi/pages/begin-route/components/auth-bluetooth-popup/success-auth/index.tsx
New file
@@ -0,0 +1,59 @@
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>
  );
}
src/robotaxi/pages/begin-route/components/begin-route-view.tsx
@@ -1,14 +1,12 @@
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;
@@ -69,6 +67,9 @@
  orderId: string;
  handleCloseEvaluatePopup: () => void;
  handleConfirmEvaluate: (content: string) => void;
  showAuthBluetoothPopup?: boolean;
  handleCloseAuthBluetoothPopup?: () => void;
  handleAuthorizeBluetooth?: () => void;
};
export default function BeginRouteView(props: Props) {
@@ -130,7 +131,10 @@
    showEvaluatePopup,
    orderId,
    handleCloseEvaluatePopup,
    handleConfirmEvaluate
    handleConfirmEvaluate,
    showAuthBluetoothPopup = false,
    handleCloseAuthBluetoothPopup = () => { },
    handleAuthorizeBluetooth
  } = props;
  // 统一渲染地图与业务弹窗容器
@@ -232,6 +236,11 @@
        onClose={handleCloseEvaluatePopup}
        onConfirm={handleConfirmEvaluate}
      />
      <AuthBluetoothPopup
        show={showAuthBluetoothPopup}
        onClose={handleCloseAuthBluetoothPopup}
        onAuthorize={handleAuthorizeBluetooth}
      />
    </View>
  );
}
src/robotaxi/pages/begin-route/components/calling-car-modules/bluetooth-operate-card.tsx
@@ -1,19 +1,26 @@
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>
    );
}
}
src/robotaxi/pages/begin-route/components/calling-car-modules/trip-status-header.tsx
@@ -21,7 +21,6 @@
    finishCountdownEnabled: boolean;
    finishCountdownSeconds: number | null;
    limitMinutes: number | null;
    handleOpenCancelPopup?: (durationDisplay: any) => void;
};
export default function TripStatusHeader({
@@ -35,7 +34,6 @@
    finishCountdownEnabled,
    finishCountdownSeconds,
    limitMinutes,
    handleOpenCancelPopup
}: Props) {
    const [queueStatus, setQueueStatus] = useState<QueueStatus>(null);
@@ -126,12 +124,6 @@
                    </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>
    );
}
src/robotaxi/pages/begin-route/components/calling-car.tsx
@@ -10,6 +10,9 @@
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已支付 */
@@ -59,6 +62,14 @@
    };
    orderId?: string;
    orderTrunkStatus?: boolean;
    bluetoothCardDescription?: string;
    bluetoothCardActionText?: string;
    handleBluetoothCardClick?: () => void;
    showAuthBluetoothPopup?: boolean;
    showSuccessAuthPopup?: boolean;
    handleCloseAuthBluetoothPopup?: () => void;
    handleCloseSuccessAuthPopup?: () => void;
    handleAuthorizeBluetooth?: () => void;
}
export default function CallingCar({
@@ -84,8 +95,15 @@
    vehicleInfo,
    orderId,
    orderTrunkStatus,
    bluetoothCardDescription = '系统蓝牙未开启,无法自动解锁车辆',
    bluetoothCardActionText = '去开启',
    handleBluetoothCardClick,
    showAuthBluetoothPopup = false,
    showSuccessAuthPopup = false,
    handleCloseAuthBluetoothPopup,
    handleCloseSuccessAuthPopup,
    handleAuthorizeBluetooth
}: Props) {
    // 汇总行程中的倒计时与费用数据
    const {
        waitMinutes,
@@ -130,6 +148,32 @@
            />
            {
                [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} />
src/robotaxi/pages/begin-route/components/css-folder/calling-car.less
@@ -169,11 +169,13 @@
        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);
@@ -517,17 +519,16 @@
}
.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: "";
@@ -538,12 +539,10 @@
    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;
@@ -553,6 +552,9 @@
}
.bluetooth-operate-card {
  position: relative;
  z-index: 1;
  margin-top: 40px;
  display: flex;
  align-items: center;
  justify-content: space-between;
@@ -593,4 +595,4 @@
      height: 17.5px;
    }
  }
}
}
src/robotaxi/pages/begin-route/hooks/use-bluetooth-access.ts
New file
@@ -0,0 +1,182 @@
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
  };
}
src/robotaxi/pages/begin-route/hooks/use-bluetooth-car-control.ts
New file
@@ -0,0 +1,167 @@
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
  };
}
src/robotaxi/pages/begin-route/hooks/use-route-manager.ts
@@ -56,6 +56,8 @@
  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);
  // 路线时长(秒)
@@ -349,6 +351,7 @@
      return;
    }
    routeRequestKeyRef.current = requestKey;
    setRouteAndFareLoading(true);
    const selectedCity = await ensureSelectedCityForRoute(originLng, originLat);
    const openCityId = selectedCity ? selectedCity.id : 0;
    const cachedCouponId = Taro.getStorageSync('selectedCouponId');
@@ -437,6 +440,8 @@
      }
    }).catch(() => {
      setRouteAndFareError(true);
    }).finally(() => {
      setRouteAndFareLoading(false);
    });
  };
@@ -645,6 +650,7 @@
    fareEstimate,
    setFareEstimate,
    routeAndFareError,
    routeAndFareLoading,
    setRouteAndFareError,
    routeDistance,
    setRouteDistance,
src/robotaxi/pages/begin-route/hooks/use-view-delegates.tsx
@@ -115,6 +115,22 @@
  previewEndSite?: any;
  /** 确认修改目的地 */
  handleConfirmModifyDestination?: (selectedRoute?: any) => void;
  /** 蓝牙卡片描述文案 */
  bluetoothCardDescription: string;
  /** 蓝牙卡片右侧动作文案 */
  bluetoothCardActionText: string;
  /** 点击蓝牙卡片 */
  handleBluetoothCardClick: () => void;
  /** 蓝牙授权弹窗是否展示 */
  showAuthBluetoothPopup: boolean;
  /** 蓝牙授权成功弹窗是否展示 */
  showSuccessAuthPopup: boolean;
  /** 关闭蓝牙授权弹窗 */
  handleCloseAuthBluetoothPopup: () => void;
  /** 关闭蓝牙授权成功弹窗 */
  handleCloseSuccessAuthPopup: () => void;
  /** 去授权蓝牙 */
  handleAuthorizeBluetooth: () => void;
};
/**
@@ -303,6 +319,14 @@
          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}
        />
      );
    }
src/robotaxi/pages/begin-route/index.tsx
@@ -30,7 +30,12 @@
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'
@@ -244,6 +249,7 @@
    fareEstimate,
    setFareEstimate,
    routeAndFareError,
    routeAndFareLoading,
    routeDistance,
    setRouteDistance,
    routeDuration,
@@ -525,6 +531,28 @@
    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(() => {
@@ -658,7 +686,7 @@
    fareEstimate,
    handleOpenPassengerNotice,
    walkingTime,
    isCallDisabled: routeAndFareError || callSubmitting,
    isCallDisabled: routeAndFareLoading || routeAndFareError || callSubmitting,
    handleOpenCancelPopupByTripStatus,
    handleOpenCancelPopupWithType,
    setTripStatus,
@@ -686,7 +714,15 @@
    previousEndSite,
    previousFareEstimate,
    previewEndSite,
    handleConfirmModifyDestination
    handleConfirmModifyDestination,
    bluetoothCardDescription,
    bluetoothCardActionText,
    handleBluetoothCardClick,
    showAuthBluetoothPopup,
    showSuccessAuthPopup,
    handleCloseAuthBluetoothPopup,
    handleCloseSuccessAuthPopup,
    handleAuthorizeBluetooth
  }); // 视图渲染委托
  const mapLongitude = longitude ?? 116.397128;
  const mapLatitude = latitude ?? 39.916527;