卢春龙
2026-04-07 8a0057c91a7e1fdebbedbd29f64dd4b6b73d58fa
Merge branch 'feature_hjh_fix' into 'develop'

feat(后备箱控制): 增加后备箱状态轮询机制

See merge request robotaxi1/xp-robotaxi-miniprogram!6
11个文件已修改
193 ■■■■■ 已修改文件
config/dev.js 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
config/prod.js 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/constants/api.ts 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/robotaxi/home/components/privacy-popup/index.tsx 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/api/begin-route.ts 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/components/calling-car.tsx 83 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/index.tsx 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/operating-area/index.tsx 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/site-search/index.tsx 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/Robotaxi-public-method.ts 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/websocket.ts 61 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
config/dev.js
@@ -23,6 +23,7 @@
// const ROBOTAXI_WS_HOST = '"ws://221.182.45.100:8888/websocket"';
const ROBOTAXI_HOST = '"https://rt-api.deploy-test.xiaopeng.com"';
const ROBOTAXI_WS_HOST = '"wss://rt-ws.deploy-test.xiaopeng.com/websocket"';
const AMAP_KEY = '"8157fdfa13878d3f7bf69c560c8a846a"';
const XP_APPID_MAP = {
  weapp: '"xp_cs_wom"',
@@ -92,6 +93,7 @@
    ADMIN_OSS_HOST,
    ROBOTAXI_HOST,
    ROBOTAXI_WS_HOST,
    AMAP_KEY,
  },
  mini: {
    // webpackChain (chain) {
config/prod.js
@@ -18,6 +18,7 @@
const ROBOTAXI_HOST = '"https://rt-api.xiaopeng.com"';
const ROBOTAXI_WS_HOST = '"wss://rt-api.xiaopeng.com/websocket"';
const AMAP_KEY = '"8157fdfa13878d3f7bf69c560c8a846a"';
const XP_APPID_MAP = {
  weapp: '"xp_cs_wom"', // 用户中心登录appId
@@ -85,6 +86,7 @@
    ADMIN_OSS_HOST,
    ROBOTAXI_HOST,
    ROBOTAXI_WS_HOST,
    AMAP_KEY,
  },
  mini: {
    // module: {
src/constants/api.ts
@@ -41,6 +41,10 @@
//@ts-ignore
export const _ROBOTAXI_WS_HOST = ROBOTAXI_WS_HOST
// 高德地图KEY
//@ts-ignore
export const _AMAP_KEY = AMAP_KEY
// pic CDN
export const CDN = 'https://s.xiaopeng.com/xp-fe/mini'
// 官渠通用 oss 资源前缀
@@ -127,9 +131,5 @@
// 上报新注册用户位置信息
export const API_REGISTER_REPORT_POSITION= `${_MINI_HOST}/api/register/report`
// 高德地图KEY
export const AMAP_KEY = '8157fdfa13878d3f7bf69c560c8a846a'
src/pages/robotaxi/home/components/privacy-popup/index.tsx
@@ -74,7 +74,7 @@
      Taro.showToast({ title: '请先同意全部协议', icon: 'none' });
      return
    };
    submitAgreement({ type: [1, 2, 3, 4, 7] }).then((res: any) => {
    submitAgreement({ type: Object.keys(isAgree).map(Number) }).then((res: any) => {
      reportEvent('RT_xcx_protocol_click', {
        status_code: 1
      })
@@ -137,7 +137,7 @@
          <View className='link-item'>
            <Image onClick={checkAll} className='item-radio' src={isAllAgree ? check : uncheck}
              mode='aspectFill' />
            <View className='item-name'>全部勾选</View>
            <View onClick={checkAll} className='item-name'>全部勾选</View>
          </View>
        </View>
src/robotaxi/api/begin-route.ts
@@ -64,6 +64,15 @@
    }, true);
}
// 查询后备箱开启状态
export const queryVehicleTrunkOpenStatus = async (payload) => {
    return request({
        url: _ROBOTAXI_HOST + '/vehicleConvoy/api/v1/mini-program/order/queryVehicleTrunkOpenStatus',
        method: 'GET',
        payload
    }, true);
}
// 获取自动结束行程时间
export const queryOrderFinishTime = async () => {
    return request({
src/robotaxi/pages/begin-route/components/calling-car.tsx
@@ -25,6 +25,7 @@
    setTemperature,
    unlockVehicle,
    queryTemperatureByOrderId,
    queryVehicleTrunkOpenStatus,
    queryOrderFinishTime,
    queryFareBreakdown,
    submitOrderComment,
@@ -96,6 +97,7 @@
    const [showDetail, setShowDetail] = useState(false); // 是否显示费用明细
    const countdownTimerRef = useRef<any>(null); // 倒计时定时器引用
    const finishCountdownTimerRef = useRef<any>(null); // 结束倒计时定时器引用
    const trunkPollTimerRef = useRef<any>(null); // 后备箱轮询定时器引用
    const tempTimerRef = useRef<any>(null); // 温度定时器引用
    const lastConfirmedTempRef = useRef<number>(); // 上一次确认的温度
    const countdownFinishedRef = useRef(false); // 倒计时是否结束
@@ -324,6 +326,9 @@
            if (finishCountdownTimerRef.current) {
                clearTimeout(finishCountdownTimerRef.current);
            }
            if (trunkPollTimerRef.current) {
                clearTimeout(trunkPollTimerRef.current);
            }
        };
    }, [tripStatus, estimatedWaitMinutes, orderId, finishCountdownEnabled]);
@@ -332,6 +337,9 @@
        return () => {
            if (tempTimerRef.current) {
                clearTimeout(tempTimerRef.current);
            }
            if (trunkPollTimerRef.current) {
                clearTimeout(trunkPollTimerRef.current);
            }
        };
    }, []);
@@ -466,20 +474,73 @@
            doorType,
            ...locationPayload
        }).then((res: any) => {
            const isFailure = res?.success === false || (typeof res?.code === 'number' && res.code !== 0);
            if (isFailure) {
            if (!res) {
                Taro.showToast({
                    title: res?.msg || '解锁失败,请重试或联系客服',
                    title: '解锁失败,请重试或联系客服',
                    icon: 'none'
                });
                return;
            }
            if (doorType === 'trunk') {
                Taro.showToast({
                    title: trunkStatus ? '关闭成功' : '打开成功',
                    icon: 'none'
                });
                onSuccess?.();
                const loadingText = trunkStatus ? '正在关闭后备箱' : '正在开启后备箱';
                Taro.showLoading({ title: loadingText, mask: true });
                let pollCount = 0;
                const maxPolls = 3;
                const pollInterval = 2000; // 2秒
                // 如果当前 trunkStatus 为 true (开),则期望执行“关闭”操作,目标状态为 '0'
                // 如果当前 trunkStatus 为 false (关),则期望执行“开启”操作,目标状态为 '1'
                const expectedTrunkState = trunkStatus ? '0' : '1';
                const handlePollFail = (reason: string, actualValue?: any) => {
                    pollCount++;
                    if (pollCount < maxPolls) {
                        trunkPollTimerRef.current = setTimeout(doPoll, pollInterval);
                    } else {
                        Taro.hideLoading();
                        Taro.showToast({
                            title: trunkStatus ? '关闭后备箱失败' : '开启后备箱失败',
                            icon: 'none'
                        });
                    }
                };
                const doPoll = () => {
                    queryVehicleTrunkOpenStatus({ orderId: targetOrderId }).then((pollRes: any) => {
                        const data = pollRes;
                        const rawValue = data?.stateMap?.trunkAjarCloseSt?.value;
                        // 如果 data 或 value 为空,继续轮询
                        if (!data || rawValue === undefined || rawValue === null || rawValue === '') {
                            handlePollFail('empty_data_or_value', rawValue);
                            return;
                        }
                        const value = String(rawValue);
                        // 轮询成功条件改为“状态 == 目标状态”
                        if (value === expectedTrunkState) {
                            const isOpen = value === '1';
                            setTrunkStatus(isOpen);
                            Taro.hideLoading();
                            Taro.showToast({
                                title: isOpen ? '打开成功' : '关闭成功',
                                icon: 'none'
                            });
                            onSuccess?.();
                        } else {
                            // 状态不匹配,继续轮询
                            handlePollFail('state_mismatch', value);
                        }
                    }).catch((err) => {
                        handlePollFail('api_error', err);
                    });
                };
                // 延迟2秒后开始第一次轮询
                trunkPollTimerRef.current = setTimeout(doPoll, pollInterval);
            }
            if (doorType === 'door') {
                Taro.showToast({
@@ -493,7 +554,7 @@
            }
        }).catch(() => {
            Taro.showToast({
                title: res?.msg || '解锁失败,请重试或联系客服',
                title: '解锁失败,请重试或联系客服',
                icon: 'none'
            });
        });
@@ -601,7 +662,7 @@
                    {
                        tripStatus == 5 &&
                        <View className="car-card-arrive">
                            <View className={`car-card-arrive-item ${trunkStatus && 'close-btn-item'}`} onClick={() => handleUnlock('trunk', () => setTrunkStatus((prev) => !prev))}>
                            <View className={`car-card-arrive-item ${trunkStatus && 'close-btn-item'}`} onClick={() => handleUnlock('trunk')}>
                                {
                                    trunkStatus ?
                                        <Image className="car-card-arrive-item-img" src={require('../../../assets/trunk-open.png')} />
@@ -763,7 +824,7 @@
                    <View className="car-card-reach-text">点击按钮开车门</View>
                    <Image className='car-card-reach-img' src='https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/f4a18c3f-a2db-45e6-8967-1510355b5e3b.webp' />
                    <View className="car-card-reach-btn">
                        <View className={`car-card-reach-btn-item ${trunkStatus && 'close-btn-item'}`} onClick={() => handleUnlock('trunk', () => setTrunkStatus((prev) => !prev))}>
                        <View className={`car-card-reach-btn-item ${trunkStatus && 'close-btn-item'}`} onClick={() => handleUnlock('trunk')}>
                            {
                                trunkStatus ?
                                    <Image className="car-card-reach-btn-item-img" src={require('../../../assets/trunk-open.png')} />
src/robotaxi/pages/begin-route/index.tsx
@@ -1469,8 +1469,13 @@
    wsManager.connect(_ROBOTAXI_WS_HOST);
  };
  // 页面初始化
  useDidShow(() => {
    // 每次页面显示(包括从后台切回前台)时,检查并重连 Socket
    if (!wsManager.isConnectedStatus()) {
      wsManager.connect(_ROBOTAXI_WS_HOST);
    }
    ensureSocketListeners();
    const { skipPageFlag } = router.params;
    if (skipPageFlag) {
      Taro.showToast({
@@ -1550,7 +1555,6 @@
      updateWalkingRouteForStart();
    }
    ensureSocketListeners();
    updateMapViewportPadding();
  });
src/robotaxi/pages/operating-area/index.tsx
@@ -342,13 +342,13 @@
  const renderOperatingTime = (info: any) => {
    if (!info?.rule) {
      return `全天`;
      return `工作日全天运营  周末全天运营`;
    }
    try {
      const rules = JSON.parse(info.rule);
      if (!Array.isArray(rules) || rules.length === 0) {
        return `全天`;
        return `工作日全天运营  周末全天运营`;
      }
      const getDayText = (typeStr: number[]) => {
src/robotaxi/pages/site-search/index.tsx
@@ -8,7 +8,7 @@
import { getStartPoints, getEndPoints, queryPointByName } from '../../api/site-search';
import { formatDistance, debounce, getDistance } from '../../../utils/Robotaxi-public-method';
import { getOrderDistance } from '../../api/select-map-site';
import { AMAP_KEY, _UID } from '../../../constants/api';
import { _AMAP_KEY, _UID } from '../../../constants/api';
import amapFile from '../../../libs/amap-wx.130.js';
import { checkOtp } from '../../../utils/tools';
import TipPopup from '../select-map-site/components/tip-popup';
@@ -139,7 +139,7 @@
        setSiteList([]);
      }
      const myAmapFun = new amapFile.AMapWX({ key: AMAP_KEY });
      const myAmapFun = new amapFile.AMapWX({ key: _AMAP_KEY });
      myAmapFun.getPoiAround({
        querykeywords: queryVal,
        location: `${longitude},${latitude}`,
src/utils/Robotaxi-public-method.ts
@@ -1,5 +1,5 @@
import amapFile from '../libs/amap-wx.130.js';
import { AMAP_KEY, _ROBOTAXI_HOST, _UID } from '../constants/api';
import { _AMAP_KEY, _ROBOTAXI_HOST, _UID } from '../constants/api';
import Taro from '@tarojs/taro';
import request from './request';
import { getFenceCheck, getNearestSite } from '../robotaxi/api/home';
@@ -171,7 +171,7 @@
// 获取位置的逆地理编码
export const getAddressByLocation = (longitude: number, latitude: number): Promise<any> => {
  return new Promise((resolve, reject) => {
    const myAmapFun = new amapFile.AMapWX({ key: AMAP_KEY });
    const myAmapFun = new amapFile.AMapWX({ key: _AMAP_KEY });
    myAmapFun.getRegeo({
      location: `${longitude},${latitude}`,
      success: (data) => {
@@ -346,7 +346,7 @@
// 获取步行规划路线
export const getWalkingRoute = (origin: string, destination: string): Promise<{ points: any[], duration: number, distance: number }> => {
  return new Promise((resolve, reject) => {
    const myAmapFun = new amapFile.AMapWX({ key: AMAP_KEY });
    const myAmapFun = new amapFile.AMapWX({ key: _AMAP_KEY });
    myAmapFun.getWalkingRoute({
      origin: origin,
      destination: destination,
@@ -370,7 +370,7 @@
// 获取城市中心经纬度
export const getCityLocation = (cityName: string): Promise<{ latitude: number, longitude: number }> => {
  return new Promise((resolve, reject) => {
    const myAmapFun = new amapFile.AMapWX({ key: AMAP_KEY });
    const myAmapFun = new amapFile.AMapWX({ key: _AMAP_KEY });
    myAmapFun.getInputtips({
      keywords: cityName,
      city: cityName,
src/utils/websocket.ts
@@ -15,7 +15,9 @@
  private maxReconnectAttempts: number = 5;
  private reconnectTimer: any = null;
  private heartbeatTimer: any = null;
  private heartbeatInterval: number = 30000; // 30秒心跳
  private heartbeatTimeoutTimer: any = null; // 用于检测心跳超时的定时器
  private heartbeatInterval: number = 10000; // 10秒心跳
  private heartbeatTimeout: number = 3000;   // 5秒收不到响应即视为掉线
  private listeners: Map<string, MessageCallback[]> = new Map();
  private globalEventsBound: boolean = false;
@@ -94,6 +96,7 @@
      this.socketTask.onOpen(async () => {
        console.log('WebSocket 连接已打开');
        this.isConnected = true;
        this.isConnecting = false;
        this.reconnectAttempts = 0;
        this.startHeartbeat();
        const uid = await getStorage(_UID) || '-1';
@@ -103,6 +106,7 @@
      this.socketTask.onMessage((res) => {
        try {
          this.resetHeartbeatTimeout();
          const data = JSON.parse(res.data as string);
          console.log('收到 WebSocket 消息:', res.data);
          const content = JSON.parse(data.content || '{}');
@@ -120,6 +124,7 @@
      this.socketTask.onClose((res) => {
        console.log('WebSocket 连接已关闭', res);
        this.isConnected = false;
        this.isConnecting = false;
        this.stopHeartbeat();
        this.socketTask = null;
@@ -132,6 +137,7 @@
      this.socketTask.onError((err) => {
        console.error('WebSocket 错误:', err);
        this.isConnected = false;
        this.isConnecting = false;
        this.notifyListeners('error', err);
      });
      return;
@@ -143,6 +149,7 @@
    Taro.onSocketOpen(async () => {
      console.log('WebSocket 连接已打开');
      this.isConnected = true;
      this.isConnecting = false;
      this.reconnectAttempts = 0;
      this.startHeartbeat();
      const uid = await getStorage(_UID) || '-1';
@@ -152,6 +159,7 @@
    Taro.onSocketMessage((res) => {
      try {
        this.resetHeartbeatTimeout();
        const data = JSON.parse(res.data as string);
        console.log('收到 WebSocket 消息:', res.data);
        const content = JSON.parse(data.content || '{}');
@@ -169,6 +177,7 @@
    Taro.onSocketClose((res) => {
      console.log('WebSocket 连接已关闭', res);
      this.isConnected = false;
      this.isConnecting = false;
      this.stopHeartbeat();
      this.socketTask = null;
@@ -181,6 +190,7 @@
    Taro.onSocketError((err) => {
      console.error('WebSocket 错误:', err);
      this.isConnected = false;
      this.isConnecting = false;
      this.notifyListeners('error', err);
    });
  }
@@ -263,6 +273,15 @@
    this.heartbeatTimer = setInterval(() => {
      if (this.isConnected) {
        this.send({ type: 'ping', timestamp: Date.now() });
        // 发送 ping 后,启动一个超时检测
        // 如果在配置的时间内没有收到任何消息(pong 或业务消息),则认为连接已假死
        if (this.heartbeatTimeoutTimer) {
          clearTimeout(this.heartbeatTimeoutTimer);
        }
        this.heartbeatTimeoutTimer = setTimeout(() => {
          console.warn('心跳超时,未收到服务器响应,主动断开 WebSocket 连接');
          this.closeAndReconnect();
        }, this.heartbeatTimeout);
      }
    }, this.heartbeatInterval);
  }
@@ -275,6 +294,46 @@
      clearInterval(this.heartbeatTimer);
      this.heartbeatTimer = null;
    }
    if (this.heartbeatTimeoutTimer) {
      clearTimeout(this.heartbeatTimeoutTimer);
      this.heartbeatTimeoutTimer = null;
    }
  }
  /**
   * 收到消息时重置心跳超时检测
   */
  private resetHeartbeatTimeout() {
    if (this.heartbeatTimeoutTimer) {
      clearTimeout(this.heartbeatTimeoutTimer);
      this.heartbeatTimeoutTimer = null;
    }
  }
  /**
   * 主动关闭并重连 (处理假死状态)
   */
  private closeAndReconnect() {
    // 强制关闭当前连接
    if (this.socketTask) {
      try {
        this.socketTask.close({
          code: 1000,
          reason: '心跳超时主动关闭'
        });
      } catch (e) {
        console.error('强制关闭 Socket 失败:', e);
      }
      this.socketTask = null;
    }
    this.isConnected = false;
    this.isConnecting = false;
    this.stopHeartbeat();
    // 立即触发重连
    if (!this.isManualClose) {
      this.handleReconnect();
    }
  }
  /**