Merge branch 'feature_hjh_fix' into 'develop'
feat(后备箱控制): 增加后备箱状态轮询机制
See merge request robotaxi1/xp-robotaxi-miniprogram!6
| | |
| | | // 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"', |
| | |
| | | ADMIN_OSS_HOST, |
| | | ROBOTAXI_HOST, |
| | | ROBOTAXI_WS_HOST, |
| | | AMAP_KEY, |
| | | }, |
| | | mini: { |
| | | // webpackChain (chain) { |
| | |
| | | |
| | | 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 |
| | |
| | | ADMIN_OSS_HOST, |
| | | ROBOTAXI_HOST, |
| | | ROBOTAXI_WS_HOST, |
| | | AMAP_KEY, |
| | | }, |
| | | mini: { |
| | | // module: { |
| | |
| | | //@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 资源前缀 |
| | |
| | | |
| | | // 上报新注册用户位置信息 |
| | | export const API_REGISTER_REPORT_POSITION= `${_MINI_HOST}/api/register/report` |
| | | |
| | | |
| | | // 高德地图KEY |
| | | export const AMAP_KEY = '8157fdfa13878d3f7bf69c560c8a846a' |
| | | |
| | | |
| | |
| | | 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 |
| | | }) |
| | |
| | | <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> |
| | | |
| | |
| | | }, 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({ |
| | |
| | | setTemperature, |
| | | unlockVehicle, |
| | | queryTemperatureByOrderId, |
| | | queryVehicleTrunkOpenStatus, |
| | | queryOrderFinishTime, |
| | | queryFareBreakdown, |
| | | submitOrderComment, |
| | |
| | | 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); // 倒计时是否结束 |
| | |
| | | if (finishCountdownTimerRef.current) { |
| | | clearTimeout(finishCountdownTimerRef.current); |
| | | } |
| | | if (trunkPollTimerRef.current) { |
| | | clearTimeout(trunkPollTimerRef.current); |
| | | } |
| | | }; |
| | | }, [tripStatus, estimatedWaitMinutes, orderId, finishCountdownEnabled]); |
| | | |
| | |
| | | return () => { |
| | | if (tempTimerRef.current) { |
| | | clearTimeout(tempTimerRef.current); |
| | | } |
| | | if (trunkPollTimerRef.current) { |
| | | clearTimeout(trunkPollTimerRef.current); |
| | | } |
| | | }; |
| | | }, []); |
| | |
| | | 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({ |
| | |
| | | } |
| | | }).catch(() => { |
| | | Taro.showToast({ |
| | | title: res?.msg || '解锁失败,请重试或联系客服', |
| | | title: '解锁失败,请重试或联系客服', |
| | | icon: 'none' |
| | | }); |
| | | }); |
| | |
| | | { |
| | | 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')} /> |
| | |
| | | <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')} /> |
| | |
| | | wsManager.connect(_ROBOTAXI_WS_HOST); |
| | | }; |
| | | |
| | | // 页面初始化 |
| | | useDidShow(() => { |
| | | // 每次页面显示(包括从后台切回前台)时,检查并重连 Socket |
| | | if (!wsManager.isConnectedStatus()) { |
| | | wsManager.connect(_ROBOTAXI_WS_HOST); |
| | | } |
| | | ensureSocketListeners(); |
| | | |
| | | const { skipPageFlag } = router.params; |
| | | if (skipPageFlag) { |
| | | Taro.showToast({ |
| | |
| | | updateWalkingRouteForStart(); |
| | | } |
| | | |
| | | ensureSocketListeners(); |
| | | updateMapViewportPadding(); |
| | | }); |
| | | |
| | |
| | | |
| | | 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[]) => { |
| | |
| | | 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'; |
| | |
| | | setSiteList([]); |
| | | } |
| | | |
| | | const myAmapFun = new amapFile.AMapWX({ key: AMAP_KEY }); |
| | | const myAmapFun = new amapFile.AMapWX({ key: _AMAP_KEY }); |
| | | myAmapFun.getPoiAround({ |
| | | querykeywords: queryVal, |
| | | location: `${longitude},${latitude}`, |
| | |
| | | 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'; |
| | |
| | | // 获取位置的逆地理编码 |
| | | 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) => { |
| | |
| | | // 获取步行规划路线 |
| | | 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, |
| | |
| | | // 获取城市中心经纬度 |
| | | 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, |
| | |
| | | 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; |
| | | |
| | |
| | | this.socketTask.onOpen(async () => { |
| | | console.log('WebSocket 连接已打开'); |
| | | this.isConnected = true; |
| | | this.isConnecting = false; |
| | | this.reconnectAttempts = 0; |
| | | this.startHeartbeat(); |
| | | const uid = await getStorage(_UID) || '-1'; |
| | |
| | | |
| | | 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 || '{}'); |
| | |
| | | this.socketTask.onClose((res) => { |
| | | console.log('WebSocket 连接已关闭', res); |
| | | this.isConnected = false; |
| | | this.isConnecting = false; |
| | | this.stopHeartbeat(); |
| | | this.socketTask = null; |
| | | |
| | |
| | | this.socketTask.onError((err) => { |
| | | console.error('WebSocket 错误:', err); |
| | | this.isConnected = false; |
| | | this.isConnecting = false; |
| | | this.notifyListeners('error', err); |
| | | }); |
| | | return; |
| | |
| | | Taro.onSocketOpen(async () => { |
| | | console.log('WebSocket 连接已打开'); |
| | | this.isConnected = true; |
| | | this.isConnecting = false; |
| | | this.reconnectAttempts = 0; |
| | | this.startHeartbeat(); |
| | | const uid = await getStorage(_UID) || '-1'; |
| | |
| | | |
| | | 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 || '{}'); |
| | |
| | | Taro.onSocketClose((res) => { |
| | | console.log('WebSocket 连接已关闭', res); |
| | | this.isConnected = false; |
| | | this.isConnecting = false; |
| | | this.stopHeartbeat(); |
| | | this.socketTask = null; |
| | | |
| | |
| | | Taro.onSocketError((err) => { |
| | | console.error('WebSocket 错误:', err); |
| | | this.isConnected = false; |
| | | this.isConnecting = false; |
| | | this.notifyListeners('error', err); |
| | | }); |
| | | } |
| | |
| | | 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); |
| | | } |
| | |
| | | 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(); |
| | | } |
| | | } |
| | | |
| | | /** |