| | |
| | | import Taro from '@tarojs/taro'; |
| | | import { getOrCreateLocalId, getStorage } from './tools'; |
| | | import { getStorage } from './tools'; |
| | | import { _UID } from '../constants/api'; |
| | | import { safeParse } from './Robotaxi-public-method'; |
| | | |
| | | type MessageCallback = (data: any) => void; |
| | | |
| | |
| | | private socketTask: Taro.SocketTask | null = null; |
| | | private url: string = ''; |
| | | private isConnected: boolean = false; |
| | | private isConnecting: boolean = false; |
| | | private isManualClose: boolean = false; |
| | | private reconnectAttempts: number = 0; |
| | | 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; // 3秒收不到响应即视为掉线 |
| | | private listeners: Map<string, MessageCallback[]> = new Map(); |
| | | private globalEventsBound: boolean = false; |
| | | |
| | | private isHeartbeatMessage(data: any) { |
| | | const type = String(data?.type || '').toLowerCase(); |
| | | return type === 'ping' || type === 'pong'; |
| | | } |
| | | |
| | | private constructor() { } |
| | | |
| | |
| | | * @param url WebSocket地址 |
| | | */ |
| | | public async connect(url: string) { |
| | | if (this.socketTask || this.isConnected) { |
| | | if (this.socketTask || this.isConnected || this.isConnecting) { |
| | | console.log('WebSocket 已连接或正在连接中'); |
| | | return; |
| | | } |
| | | |
| | | this.url = url; |
| | | this.isConnecting = true; |
| | | this.isManualClose = false; |
| | | console.log(`正在连接 WebSocket: ${url}`); |
| | | |
| | | const uid = await getStorage(_UID) || await getOrCreateLocalId(); |
| | | const uid = await getStorage(_UID) || '-1'; |
| | | const task = Taro.connectSocket({ |
| | | header: { |
| | | 'XP-Uid': uid.toString(), |
| | |
| | | success: () => { }, |
| | | fail: (err) => { |
| | | console.error('WebSocket 连接失败:', err); |
| | | this.isConnecting = false; |
| | | this.handleReconnect(); |
| | | } |
| | | }); |
| | | const maybeTask: any = task as any; |
| | | if (maybeTask && typeof maybeTask.onOpen === 'function') { |
| | | this.socketTask = maybeTask; |
| | | this.isConnecting = false; |
| | | this.initEventListeners(); |
| | | return; |
| | | } |
| | |
| | | maybeTask |
| | | .then((resolvedTask) => { |
| | | this.socketTask = resolvedTask; |
| | | this.isConnecting = false; |
| | | this.initEventListeners(); |
| | | }) |
| | | .catch((err) => { |
| | | console.error('WebSocket 连接失败:', err); |
| | | this.isConnecting = false; |
| | | this.handleReconnect(); |
| | | }); |
| | | return; |
| | | } |
| | | this.socketTask = null; |
| | | this.isConnecting = false; |
| | | this.initEventListeners(); |
| | | } |
| | | |
| | |
| | | this.socketTask.onOpen(async () => { |
| | | console.log('WebSocket 连接已打开'); |
| | | this.isConnected = true; |
| | | this.isConnecting = false; |
| | | this.reconnectAttempts = 0; |
| | | this.startHeartbeat(); |
| | | const uid = await getStorage(_UID) || await getOrCreateLocalId(); |
| | | this.send({ type: 'auth', appUserId: uid, timestamp: Date.now() }); |
| | | const uid = await getStorage(_UID) || '-1'; |
| | | this.send({ type: 'auth', appUserId: uid.toString(), timestamp: Date.now() }); |
| | | this.notifyListeners('open', { status: 'connected' }); |
| | | }); |
| | | |
| | | this.socketTask.onMessage((res) => { |
| | | this.resetHeartbeatTimeout(); |
| | | let data: any = {}; |
| | | try { |
| | | const data = JSON.parse(res.data as string); |
| | | const content = JSON.parse(data.content || '{}'); |
| | | data = safeParse(res.data as string, {}); |
| | | console.log('收到 WebSocket 消息:', res.data); |
| | | } catch (e) { |
| | | console.warn('解析 WebSocket data 失败:', res.data); |
| | | this.notifyListeners('message', res.data); |
| | | return; |
| | | } |
| | | |
| | | const content = safeParse(data.content || '{}', {}); |
| | | |
| | | try { |
| | | this.handleIncomingMessage(data, content); |
| | | if (data.type) { |
| | | this.notifyListeners(data.type, data); |
| | | } |
| | | this.notifyListeners('message', data); |
| | | } catch (e) { |
| | | console.warn('解析 WebSocket 消息失败:', res.data); |
| | | this.notifyListeners('message', res.data); |
| | | console.error('处理 WebSocket 消息出错:', e); |
| | | } |
| | | }); |
| | | |
| | | this.socketTask.onClose((res) => { |
| | | console.log('WebSocket 连接已关闭', res); |
| | | this.isConnected = false; |
| | | this.isConnecting = false; |
| | | this.stopHeartbeat(); |
| | | this.socketTask = null; |
| | | |
| | | if (res.code !== 1000) { |
| | | if (!this.isManualClose) { |
| | | this.handleReconnect(); |
| | | } |
| | | this.notifyListeners('close', res); |
| | |
| | | this.socketTask.onError((err) => { |
| | | console.error('WebSocket 错误:', err); |
| | | this.isConnected = false; |
| | | this.isConnecting = false; |
| | | this.socketTask = null; |
| | | this.stopHeartbeat(); |
| | | if (!this.isManualClose) { |
| | | this.handleReconnect(); |
| | | } |
| | | 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'; |
| | | this.send({ type: 'auth', appUserId: '12345678', timestamp: Date.now() }); |
| | | this.send({ type: 'auth', appUserId: uid.toString(), timestamp: Date.now() }); |
| | | this.notifyListeners('open', { status: 'connected' }); |
| | | }); |
| | | |
| | | Taro.onSocketMessage((res) => { |
| | | this.resetHeartbeatTimeout(); |
| | | let data: any = {}; |
| | | try { |
| | | const data = JSON.parse(res.data as string); |
| | | const content = JSON.parse(data.content || '{}'); |
| | | data = safeParse(res.data as string, {}); |
| | | console.log('收到 WebSocket 消息:', res.data); |
| | | } catch (e) { |
| | | console.warn('解析 WebSocket data 失败:', res.data); |
| | | this.notifyListeners('message', res.data); |
| | | return; |
| | | } |
| | | |
| | | const content = safeParse(data.content || '{}', {}); |
| | | |
| | | try { |
| | | this.handleIncomingMessage(data, content); |
| | | if (data.type) { |
| | | this.notifyListeners(data.type, data); |
| | | } |
| | | this.notifyListeners('message', data); |
| | | } catch (e) { |
| | | console.warn('解析 WebSocket 消息失败:', res.data); |
| | | this.notifyListeners('message', res.data); |
| | | console.error('处理 WebSocket 消息出错:', e); |
| | | } |
| | | }); |
| | | |
| | | Taro.onSocketClose((res) => { |
| | | console.log('WebSocket 连接已关闭', res); |
| | | this.isConnected = false; |
| | | this.isConnecting = false; |
| | | this.stopHeartbeat(); |
| | | this.socketTask = null; |
| | | |
| | | if (res.code !== 1000) { |
| | | if (!this.isManualClose) { |
| | | this.handleReconnect(); |
| | | } |
| | | this.notifyListeners('close', res); |
| | |
| | | Taro.onSocketError((err) => { |
| | | console.error('WebSocket 错误:', err); |
| | | this.isConnected = false; |
| | | this.isConnecting = false; |
| | | this.socketTask = null; |
| | | this.stopHeartbeat(); |
| | | if (!this.isManualClose) { |
| | | this.handleReconnect(); |
| | | } |
| | | this.notifyListeners('error', err); |
| | | }); |
| | | } |
| | |
| | | * 关闭连接 |
| | | */ |
| | | public close() { |
| | | this.isManualClose = true; |
| | | this.stopHeartbeat(); |
| | | if (this.reconnectTimer) { |
| | | clearTimeout(this.reconnectTimer); |
| | |
| | | this.socketTask = null; |
| | | } |
| | | this.isConnected = false; |
| | | this.isConnecting = false; |
| | | } |
| | | |
| | | /** |
| | | * 处理重连 |
| | | */ |
| | | private handleReconnect() { |
| | | if (this.reconnectAttempts >= this.maxReconnectAttempts) { |
| | | console.error('达到最大重连次数,停止重连。'); |
| | | return; |
| | | } |
| | | |
| | | if (this.reconnectTimer) return; |
| | | |
| | | const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000); // 指数退避 |
| | | console.log(`正在尝试重连,延迟 ${delay}ms... (第 ${this.reconnectAttempts + 1}/${this.maxReconnectAttempts} 次尝试)`); |
| | | // 固定约 3 秒重连一次,并加上 0~1 秒的随机抖动 |
| | | const delay = 3000 + Math.floor(Math.random() * 1000); |
| | | console.log(`正在尝试重连,延迟 ${delay}ms... (第 ${this.reconnectAttempts + 1} 次尝试)`); |
| | | |
| | | this.reconnectTimer = setTimeout(() => { |
| | | this.reconnectAttempts++; |
| | | // 防止数字溢出(无限重试时) |
| | | if (this.reconnectAttempts < 9999) { |
| | | this.reconnectAttempts++; |
| | | } |
| | | this.reconnectTimer = null; |
| | | this.connect(this.url); |
| | | }, delay); |
| | |
| | | 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(); |
| | | } |
| | | } |
| | | |
| | | /** |
| | |
| | | if (!this.listeners.has(event)) { |
| | | this.listeners.set(event, []); |
| | | } |
| | | this.listeners.get(event)?.push(callback); |
| | | const callbacks = this.listeners.get(event)!; |
| | | if (!callbacks.includes(callback)) { |
| | | callbacks.push(callback); |
| | | } |
| | | } |
| | | |
| | | /** |
| | |
| | | public off(event: string, callback: MessageCallback) { |
| | | const callbacks = this.listeners.get(event); |
| | | if (callbacks) { |
| | | const index = callbacks.indexOf(callback); |
| | | if (index !== -1) { |
| | | let index = callbacks.indexOf(callback); |
| | | while (index !== -1) { |
| | | callbacks.splice(index, 1); |
| | | index = callbacks.indexOf(callback); |
| | | } |
| | | } |
| | | } |
| | |
| | | this.notifyListeners('order_status', { status, ...mapped, data, content }); |
| | | } |
| | | } |
| | | if (data?.type === 'VEHICLE_LOCATION') { |
| | | const longitude = content?.lon; |
| | | const latitude = content?.lat; |
| | | if (typeof longitude === 'number' && typeof latitude === 'number') { |
| | | this.notifyListeners('vehicle_location', { longitude, latitude, data, content }); |
| | | } |
| | | } |
| | | if (data?.type === 'ORDER_ROUTE') { |
| | | if (data?.type === 'ORDER_ROUTE' || data?.type === 'ORDER_UPDATE_LINE') { |
| | | const routeType = content?.routeType; |
| | | // 2-12 新增 |
| | | const duration = Number(JSON.parse(content?.route ?? '{}')?.duration ?? '0'); |
| | | const distance = Number(JSON.parse(content?.route ?? '{}')?.distance ?? '0'); |
| | | // 2-12 新增 |
| | | const routeObj = safeParse(content?.route ?? '{}', {}); |
| | | const rawDirection = Number(content?.direction); |
| | | const direction = Number.isFinite(rawDirection) ? ((rawDirection % 360) + 360) % 360 : NaN; |
| | | const duration = Number(routeObj?.duration ?? '0'); |
| | | const distance = Number(routeObj?.distance ?? '0'); |
| | | const points = this.normalizeRoutePoints(content); |
| | | const servedPoints = this.normalizeServedPoints(content); |
| | | if (!Number.isNaN(duration)) { |
| | | Taro.setStorageSync('orderRouteDuration', duration); |
| | | Taro.setStorageSync('orderRouteDuration', Math.round(duration)); |
| | | Taro.setStorageSync('orderRouteDurationUnit', 's'); |
| | | } |
| | | if (!Number.isNaN(distance)) { |
| | | Taro.setStorageSync('orderRouteDistance', distance); |
| | | const distanceKm = Number((distance / 1000).toFixed(1)); |
| | | Taro.setStorageSync('orderRouteDistance', distanceKm); |
| | | Taro.setStorageSync('orderRouteDistanceUnit', 'km'); |
| | | } |
| | | if (points.length > 0) { |
| | | const cached = Taro.getStorageSync('orderRoutePoints') || {}; |
| | |
| | | cached.latest = points; |
| | | } |
| | | Taro.setStorageSync('orderRoutePoints', cached); |
| | | this.notifyListeners('order_route', { routeType, points, data, content }); |
| | | } |
| | | this.notifyListeners('order_route', { |
| | | routeType, |
| | | direction, |
| | | points, |
| | | servedPoints, |
| | | data, |
| | | content, |
| | | forceFullRedraw: data?.type === 'ORDER_UPDATE_LINE' |
| | | }); |
| | | } |
| | | if (data?.type === 'ORDER_UPDATE_PRICE') { |
| | | const orderId = content?.orderId ?? data?.orderId; |
| | | this.notifyListeners('order_update_price', { orderId, data, content }); |
| | | } |
| | | if (data?.type === 'ORDER_REASSIGN') { |
| | | const orderId = content?.orderId ?? data?.orderId; |
| | | this.notifyListeners('order_reassign', { orderId, data, content }); |
| | | } |
| | | if (data?.type === 'OPEN_TRUNK') { |
| | | const isOpen = content?.isOpen ?? content?.open; |
| | | this.notifyListeners('open_trunk', { isOpen, data, content }); |
| | | } |
| | | } |
| | | |
| | | // 归一化路线点 |
| | | private normalizeRoutePoints(content: any) { |
| | | const route = JSON.parse(content?.route ?? '{}'); |
| | | const rawPoints = route?.polyline ?? []; |
| | | if (!Array.isArray(rawPoints)) return []; |
| | | return rawPoints.map((item: any) => { |
| | | const latitude = item?.latitude; |
| | | const longitude = item?.longitude; |
| | | if (typeof latitude === 'number' && typeof longitude === 'number') { |
| | | return { latitude, longitude }; |
| | | } |
| | | return null; |
| | | }).filter(Boolean); |
| | | return this.normalizeRoutePointsByKey(content, 'polyline'); |
| | | } |
| | | |
| | | private normalizeServedPoints(content: any) { |
| | | return this.normalizeRoutePointsByKey(content, 'served'); |
| | | } |
| | | |
| | | private normalizeRoutePointsByKey(content: any, key: 'polyline' | 'served') { |
| | | const route = safeParse(content?.route ?? '{}', {}); |
| | | const rawPoints = route?.[key] ?? []; |
| | | const arrPoint = safeParse(rawPoints, []); |
| | | if (!Array.isArray(arrPoint)) return []; |
| | | return arrPoint |
| | | .map((item: any) => { |
| | | const latitude = item?.latitude; |
| | | const longitude = item?.longitude; |
| | | if (typeof latitude === 'number' && typeof longitude === 'number') { |
| | | return { latitude, longitude }; |
| | | } |
| | | return null; |
| | | }) |
| | | .filter(Boolean); |
| | | } |
| | | |
| | | // 映射订单状态到行程状态 |
| | |
| | | case 1: |
| | | return { tripStatus: 3, showPassengerNotice: true }; |
| | | case 2: |
| | | return { tripStatus: 3 }; |
| | | case 3: |
| | | return { tripStatus: 4 }; |
| | | case 4: |