feat(robotaxi): 新增打车服务接口与功能优化
- 添加 ROBOTAXI_HOST 环境变量配置
- 实现客服电话、城市列表、附近站点等 API 接口
- 新增 WebSocket 连接管理工具类
- 优化城市选择页面的数据获取与展示逻辑
- 改进首页地图的运营区域检测与站点吸附逻辑
- 调整地图标记点生成与逆地理编码工具方法
- 修复客服组件电话动态获取问题
- 优化行程开始页面的地图 padding 设置
| | |
| | | const CAR_CONTROL_BIZ_HOST = '"https://apps-biz.deploy-test.xiaopeng.com"'; |
| | | const COMMON_OSS_HOST = '"https://spre.xiaopeng.com"'; // 官渠通用 oss 资源前缀 |
| | | const ADMIN_OSS_HOST = '"https://pre-xps01.xiaopeng.com"'; // 运营后台上传的额文件的 oss 域名 |
| | | const ROBOTAXI_HOST = '"http://192.168.110.85:8000"'; // 打车接口域名 |
| | | |
| | | const XP_APPID_MAP = { |
| | | weapp: '"xxxxxxx"', // 用户中心登录appId |
| | |
| | | }, |
| | | COMMON_OSS_HOST, |
| | | ADMIN_OSS_HOST, |
| | | ROBOTAXI_HOST, |
| | | }, |
| | | mini: { |
| | | // webpackChain (chain) { |
| | |
| | | export const _CASHIER_SEARCH_TIME = CASHIER_SEARCH_TIME |
| | | //@ts-ignore |
| | | export const _LDX_RESERVE_URL = LDX_RESERVE_URL |
| | | //@ts-ignore |
| | | export const _ROBOTAXI_HOST = ROBOTAXI_HOST |
| | | |
| | | // pic CDN |
| | | export const CDN = 'https://s.xiaopeng.com/xp-fe/mini' |
| | |
| | | import './index.less'; |
| | | import PrivacyPopup from './components/privacy-popup'; |
| | | import CustomerServicePopup from '../../../robotaxi/components/customer-service'; |
| | | import { AMAP_KEY } from '../../../constants/api'; |
| | | import selectCityIcon from '../../../robotaxi/assets/select-city.png' |
| | | import contactService from '../../../robotaxi/assets/contact-service.png' |
| | | import coupon from '../../../robotaxi/assets/coupon.png' |
| | |
| | | import originIcon from '../../../robotaxi/assets/origin-icon.png' |
| | | import pointer from '../../../robotaxi/assets/pointer.png' |
| | | import rightIcon from '../../../robotaxi/assets/right-icon.png' |
| | | import amapFile from '../../../libs/amap-wx.130' |
| | | import { findNearestMarker } from '../../../utils/Robotaxi-public-method'; |
| | | import { findNearestMarker, formatSiteToMarker, getAddressByLocation } from '../../../utils/Robotaxi-public-method'; |
| | | import { getNearestSite, getFenceCheck, getGeofence } from '../../../robotaxi/api/home'; |
| | | import wsManager from '../../../utils/websocket'; |
| | | import { checkOtp } from '../../../utils/tools'; |
| | | |
| | | export default function Home() { |
| | | |
| | |
| | | const [markers, setMarkers] = useState<any[]>([]); // 标记点列表 |
| | | const [isMaintain, setIsMaintain] = useState(false); // 是否维护中 |
| | | const [isOperatingArea, setIsOperatingArea] = useState(false); // 是否在运营区域内 |
| | | const [polygons, setPolygons] = useState<any[]>([]); // 地图围栏 |
| | | const [tripStatus, setTripStatus] = useState<number>(0); // 行程状态 |
| | | const [currentSite, setCurrentSite] = useState<any>({}); // 当前站点 |
| | | const [currentAddress, setCurrentAddress] = useState(''); // 当前位置地址 |
| | | const [isAdsorbed, setIsAdsorbed] = useState(true); // 是否吸附到站点 |
| | | const [mapScale, setMapScale] = useState(14); // 地图缩放级别 |
| | | |
| | | const [boxHeight, setBoxHeight] = useState<number | string>('auto'); // 活动区域高度 |
| | | const startY = useRef(0); // 拖动开始时的Y坐标 |
| | |
| | | const minHeight = useRef(0); // 活动区域最小高度 |
| | | const isDragging = useRef(false); // 是否正在拖动 |
| | | const shouldIgnoreRegionChange = useRef(false); // 是否忽略地图区域变化 |
| | | const resetTimer = useRef<any>(null); // 自动归位定时器 |
| | | const isReturningToOrigin = useRef(false); // 是否正在自动归位 |
| | | |
| | | const [selectedCity, setSelectedCity] = useState(''); // 选中的城市 |
| | | const DEFAULT_LATITUDE = 39.916527; // 北京故宫纬度 |
| | | const DEFAULT_LONGITUDE = 116.397128; // 北京故宫经度 |
| | | |
| | | const [selectedCity, setSelectedCity] = useState(Taro.getStorageSync('selectedCity') || { |
| | | name: '北京市', |
| | | code: '110000', |
| | | }); // 选中的城市 |
| | | |
| | | const ORDER_STATUS = { |
| | | 4: '车辆正在赶来', |
| | |
| | | } |
| | | |
| | | useDidShow(() => { |
| | | // 检查登录状态并连接 WebSocket |
| | | checkOtp().then(otp => { |
| | | if (otp) { |
| | | // wsManager.connect('wss://your-websocket-server.com/ws'); |
| | | } |
| | | }); |
| | | |
| | | // 从本地存储中获取选中的城市 |
| | | const selectedCity = Taro.getStorageSync('selectedCity'); |
| | | if (selectedCity) { |
| | | setSelectedCity(selectedCity); |
| | | } else { |
| | | setSelectedCity({ |
| | | name: '北京市', |
| | | code: '110000', |
| | | }) |
| | | Taro.setStorageSync('selectedCity', { |
| | | name: '北京市', |
| | | code: '110000', |
| | | }); |
| | | } |
| | | // 从本地存储中获取行程状态 |
| | | const tripStatus = Taro.getStorageSync('tripStatus'); |
| | | if (tripStatus) { |
| | | setTripStatus(Number(tripStatus)); |
| | | } |
| | | setTripStatus(Number(tripStatus) || 0); |
| | | |
| | | Taro.getSetting({ |
| | | success: (res) => { |
| | | const auth = res.authSetting['scope.userLocation']; |
| | | if (auth === false) { |
| | | setIsLocationAuthDenied(true); |
| | | setLatitude(DEFAULT_LATITUDE); |
| | | setLongitude(DEFAULT_LONGITUDE); |
| | | } else { |
| | | // 检查缓存中是否有 startSite |
| | | const startSite = Taro.getStorageSync('startSite'); |
| | |
| | | setCurrentSite(startSite); |
| | | setIsAdsorbed(true); |
| | | shouldIgnoreRegionChange.current = true; |
| | | |
| | | // 初始化时也调用接口检查运营区状态和更新站点 |
| | | updateMapData(startSite.latitude, startSite.longitude); |
| | | } else { |
| | | getLocationInfo(true); |
| | | } |
| | | } |
| | | }, |
| | | fail: (err) => { |
| | | console.error('获取位置授权信息失败', err); |
| | | } |
| | | }); |
| | | }); |
| | | |
| | | useEffect(() => { |
| | | // 初始化默认标记点 (成都坐标) |
| | | const defaultMarkers = [ |
| | | { |
| | | id: 1, |
| | | latitude: 30.572269, |
| | | longitude: 104.066541, |
| | | iconPath: 'https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/a2b43790-9317-4b3b-b29f-d82cc10830e9.png', |
| | | width: 15, |
| | | height: 15, |
| | | anchor: { x: 0.5, y: 0.5 }, |
| | | name: '中国南方电网·2号门' |
| | | }, |
| | | { |
| | | id: 2, |
| | | latitude: 30.573200, |
| | | longitude: 104.068100, |
| | | iconPath: 'https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/a2b43790-9317-4b3b-b29f-d82cc10830e9.png', |
| | | width: 15, |
| | | height: 15, |
| | | anchor: { x: 0.5, y: 0.5 }, |
| | | name: '天河体育中心' |
| | | }, |
| | | { |
| | | id: 3, |
| | | latitude: 30.571100, |
| | | longitude: 104.065200, |
| | | iconPath: 'https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/a2b43790-9317-4b3b-b29f-d82cc10830e9.png', |
| | | width: 15, |
| | | height: 15, |
| | | anchor: { x: 0.5, y: 0.5 }, |
| | | name: '太古汇' |
| | | }, |
| | | { |
| | | id: 4, |
| | | latitude: 30.574100, |
| | | longitude: 104.066100, |
| | | iconPath: 'https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/a2b43790-9317-4b3b-b29f-d82cc10830e9.png', |
| | | width: 15, |
| | | height: 15, |
| | | anchor: { x: 0.5, y: 0.5 }, |
| | | name: '广州东站' |
| | | }, |
| | | { |
| | | id: 5, |
| | | latitude: 30.570100, |
| | | longitude: 104.067100, |
| | | iconPath: 'https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/a2b43790-9317-4b3b-b29f-d82cc10830e9.png', |
| | | width: 15, |
| | | height: 15, |
| | | anchor: { x: 0.5, y: 0.5 }, |
| | | name: '广州南站' |
| | | }, |
| | | { |
| | | id: 6, |
| | | latitude: 23.153351, |
| | | longitude: 113.386233, |
| | | iconPath: 'https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/a2b43790-9317-4b3b-b29f-d82cc10830e9.png', |
| | | width: 15, |
| | | height: 15, |
| | | anchor: { x: 0.5, y: 0.5 }, |
| | | name: '小鹏科技园T2(南门)' |
| | | }, |
| | | { |
| | | id: 7, |
| | | latitude: 23.106442, |
| | | longitude: 113.326029, |
| | | iconPath: 'https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/a2b43790-9317-4b3b-b29f-d82cc10830e9.png', |
| | | width: 15, |
| | | height: 15, |
| | | anchor: { x: 0.5, y: 0.5 }, |
| | | name: '广州塔(东门)' |
| | | }, |
| | | { |
| | | id: 8, |
| | | latitude: 23.153645, |
| | | longitude: 113.375293, |
| | | iconPath: 'https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/a2b43790-9317-4b3b-b29f-d82cc10830e9.png', |
| | | width: 15, |
| | | height: 15, |
| | | anchor: { x: 0.5, y: 0.5 }, |
| | | name: '华南农业大学' |
| | | }, |
| | | { |
| | | id: 9, |
| | | latitude: 23.153645, |
| | | longitude: 113.375293, |
| | | iconPath: 'https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/a2b43790-9317-4b3b-b29f-d82cc10830e9.png', |
| | | width: 15, |
| | | height: 15, |
| | | anchor: { x: 0.5, y: 0.5 }, |
| | | name: '华南农业大学' |
| | | }, |
| | | { |
| | | id: 10, |
| | | latitude: 23.15253, |
| | | longitude: 113.392113, |
| | | iconPath: 'https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/a2b43790-9317-4b3b-b29f-d82cc10830e9.png', |
| | | width: 15, |
| | | height: 15, |
| | | anchor: { x: 0.5, y: 0.5 }, |
| | | name: '红泰建材厂' |
| | | } |
| | | ]; |
| | | setMarkers(defaultMarkers); |
| | | |
| | | setShowPrivacy(Taro.getStorageSync('has_show_privicy_Robotaxi') != 'true'); |
| | | |
| | | // 计算活动区域高度,用于初始隐藏 |
| | |
| | | }, 500); |
| | | }, []); |
| | | |
| | | // 监听城市变化,更新电子围栏 |
| | | useEffect(() => { |
| | | if (!selectedCity.code) return; |
| | | |
| | | getGeofence({ cityCode: selectedCity.code }).then((res: any) => { |
| | | if (res && Array.isArray(res)) { |
| | | const newPolygons = res.map(item => ({ |
| | | points: item.points.map(p => ({ |
| | | latitude: p.lat, |
| | | longitude: p.lng |
| | | })), |
| | | strokeWidth: 2, |
| | | strokeColor: '#FFFFFF', |
| | | fillColor: '#00000000', // 内部透明 |
| | | zIndex: 1 |
| | | })); |
| | | setPolygons(newPolygons); |
| | | } else { |
| | | setPolygons([]); |
| | | } |
| | | }).catch(err => { |
| | | console.error('获取电子围栏失败', err); |
| | | setPolygons([]); |
| | | }); |
| | | }, [selectedCity.code]); |
| | | |
| | | // 更新地图数据 |
| | | const updateMapData = async (lat: number, lng: number) => { |
| | | try { |
| | | const [fenceRes, sites] = await Promise.all([ |
| | | getFenceCheck({ latitude: lat, longitude: lng }).catch(e => { |
| | | |
| | | return { status: 'inside' }; // 默认兜底 |
| | | }), |
| | | getNearestSite({ latitude: lat, longitude: lng }).catch(e => { |
| | | |
| | | return []; |
| | | }) |
| | | ]); |
| | | |
| | | const isInside = (fenceRes as any).status === 'inside'; |
| | | setIsOperatingArea(isInside); |
| | | |
| | | const newMarkers = formatSiteToMarker(sites.points); |
| | | setMarkers(newMarkers); |
| | | |
| | | return { isInside, newMarkers, sites: sites.points }; |
| | | } catch (error) { |
| | | |
| | | return { isInside: true, newMarkers: [], sites: [] }; |
| | | } |
| | | } |
| | | |
| | | // 获取用户位置信息 |
| | | const getLocationInfo = (updateCenter = true) => { |
| | | Taro.getLocation({ type: 'gcj02' }) |
| | | .then(res => { |
| | | .then(async res => { |
| | | if (updateCenter) { |
| | | setLatitude(res.latitude); |
| | | setLongitude(res.longitude); |
| | | } |
| | | const myAmapFun = new amapFile.AMapWX({ key: AMAP_KEY }); |
| | | |
| | | try { |
| | | // 更新地图数据 |
| | | const { newMarkers } = await updateMapData(res.latitude, res.longitude); |
| | | |
| | | const { targetMarker, minDistance } = findNearestMarker(res.latitude, res.longitude, newMarkers); |
| | | |
| | | if (minDistance < 200 && targetMarker) { |
| | | setIsAdsorbed(true); |
| | | setCurrentSite(targetMarker); |
| | | setShowToolTip(true); |
| | | return; |
| | | } |
| | | } catch (error) { |
| | | |
| | | } |
| | | |
| | | // 获取当前位置的逆地理编码 |
| | | myAmapFun.getRegeo({ |
| | | location: `${res.longitude},${res.latitude}`, |
| | | success: (data) => { |
| | | if (data && data[0]) { |
| | | const address = data[0].name || data[0].desc; |
| | | setCurrentAddress(address); |
| | | setCurrentSite({ |
| | | name: address, |
| | | latitude: res.latitude, |
| | | longitude: res.longitude |
| | | }); |
| | | setIsAdsorbed(false); |
| | | getAddressByLocation(res.longitude, res.latitude) |
| | | .then((data) => { |
| | | const address = data.name || data.desc; |
| | | setCurrentAddress(address); |
| | | setCurrentSite({ |
| | | name: address, |
| | | latitude: res.latitude, |
| | | longitude: res.longitude |
| | | }); |
| | | setIsAdsorbed(false); |
| | | |
| | | // 初始化时,如果缓存没有selectedCity才赋值默认城市 |
| | | const cachedCity = Taro.getStorageSync('selectedCity'); |
| | | if (!cachedCity && data.regeocodeData && data.regeocodeData.addressComponent) { |
| | | const { city, province, adcode } = data.regeocodeData.addressComponent; |
| | | // 部分直辖市city字段可能为空,使用province |
| | | const cityName = (city && typeof city === 'string' && city.length > 0) ? city : province; |
| | | const cityInfo = { |
| | | name: cityName, |
| | | code: adcode |
| | | }; |
| | | setSelectedCity(cityInfo); |
| | | Taro.setStorageSync('selectedCity', cityInfo); |
| | | } |
| | | }, |
| | | fail: (info) => { |
| | | console.error('getRegeo fail', info); |
| | | }) |
| | | .catch((info) => { |
| | | |
| | | setCurrentAddress('未知位置'); |
| | | setCurrentSite({ name: '未知位置' }); |
| | | } |
| | | }); |
| | | |
| | | myAmapFun.getPoiAround({ |
| | | location: `${res.longitude},${res.latitude}`, |
| | | success: () => { }, |
| | | fail: () => { } |
| | | }); |
| | | }); |
| | | }) |
| | | .catch(() => { |
| | | setIsLocationAuthDenied(true); |
| | | setLatitude(DEFAULT_LATITUDE); |
| | | setLongitude(DEFAULT_LONGITUDE); |
| | | }); |
| | | } |
| | | |
| | |
| | | // 返回当前位置 |
| | | const toOrigin = () => { |
| | | shouldIgnoreRegionChange.current = false; |
| | | isReturningToOrigin.current = true; |
| | | setMapScale(14); |
| | | const mapCtx = Taro.createMapContext('map'); |
| | | mapCtx.moveToLocation(); |
| | | } |
| | |
| | | // 如果是用户手势拖动,则必须触发,且重置忽略标记 |
| | | if (causedBy === 'gesture' || causedBy === 'drag' || causedBy === 'scale') { |
| | | shouldIgnoreRegionChange.current = false; |
| | | // 清除自动归位定时器 |
| | | if (resetTimer.current) { |
| | | clearTimeout(resetTimer.current); |
| | | resetTimer.current = null; |
| | | } |
| | | } else if (isReturningToOrigin.current) { |
| | | shouldIgnoreRegionChange.current = false; |
| | | } else { |
| | | shouldIgnoreRegionChange.current = true; |
| | | return |
| | |
| | | if (type === 'begin') { |
| | | setShowToolTip(false); |
| | | } else if (type === 'end') { |
| | | if (isReturningToOrigin.current) { |
| | | isReturningToOrigin.current = false; |
| | | } |
| | | |
| | | // 如果标记为忽略且非手势触发,则直接返回 |
| | | if (shouldIgnoreRegionChange.current) { |
| | | shouldIgnoreRegionChange.current = false; |
| | |
| | | setBounce(true); |
| | | |
| | | const mapCtx = Taro.createMapContext('map'); |
| | | mapCtx.getCenterLocation({ |
| | | |
| | | // 检查缩放级别,如果达到极限值且停止操作超过6s,自动归位 |
| | | mapCtx.getScale({ |
| | | success: (res) => { |
| | | setMapScale(res.scale); |
| | | if (res.scale <= 3 || res.scale >= 20) { |
| | | resetTimer.current = setTimeout(() => { |
| | | toOrigin(); |
| | | }, 6000); |
| | | } |
| | | } |
| | | }); |
| | | |
| | | mapCtx.getCenterLocation({ |
| | | success: async (res) => { |
| | | const { latitude: lat, longitude: lng } = res; |
| | | |
| | | const { targetMarker, minDistance } = findNearestMarker(lat, lng, markers); |
| | | try { |
| | | // 更新地图数据 |
| | | const { newMarkers } = await updateMapData(lat, lng); |
| | | |
| | | // 只有用户手势拖动触发的移动才进行吸附判断,避免 moveToLocation 触发死循环 |
| | | if ((causedBy === 'gesture' || causedBy === 'drag' || causedBy === 'scale') && targetMarker && minDistance < 200) { |
| | | // 使用 moveToLocation 实现吸附效果 |
| | | mapCtx.moveToLocation({ |
| | | latitude: targetMarker.latitude, |
| | | longitude: targetMarker.longitude, |
| | | }); |
| | | } |
| | | // 自动吸附逻辑:查找最近的推荐上车点 |
| | | const { targetMarker, minDistance } = findNearestMarker(lat, lng, newMarkers); |
| | | |
| | | // 根据距离决定是否显示 ToolTip |
| | | setTimeout(() => { |
| | | setBounce(false); |
| | | if (minDistance < 200) { |
| | | setIsAdsorbed(true); |
| | | if (targetMarker) { |
| | | setCurrentSite(targetMarker); |
| | | } |
| | | setShowToolTip(true); |
| | | } else { |
| | | setIsAdsorbed(false); |
| | | setCurrentSite({ name: '正在获取位置...' }); |
| | | // 获取当前位置的逆地理编码 |
| | | const myAmapFun = new amapFile.AMapWX({ key: AMAP_KEY }); |
| | | myAmapFun.getRegeo({ |
| | | location: `${lng},${lat}`, |
| | | success: (data) => { |
| | | if (data && data[0]) { |
| | | const address = data[0].name || data[0].desc; |
| | | // 只有用户手势拖动触发的移动才进行吸附判断,避免 moveToLocation 触发死循环 |
| | | if ((causedBy === 'gesture' || causedBy === 'drag' || causedBy === 'scale') && targetMarker && minDistance < 200) { |
| | | // 使用 moveToLocation 实现吸附效果 |
| | | mapCtx.moveToLocation({ |
| | | latitude: targetMarker.latitude, |
| | | longitude: targetMarker.longitude, |
| | | }); |
| | | } |
| | | |
| | | // 根据距离决定是否显示 ToolTip |
| | | setTimeout(() => { |
| | | setBounce(false); |
| | | if (minDistance < 200) { |
| | | setIsAdsorbed(true); |
| | | if (targetMarker) { |
| | | setCurrentSite(targetMarker); |
| | | } |
| | | setShowToolTip(true); |
| | | } else { |
| | | setIsAdsorbed(false); |
| | | setCurrentSite({ name: '正在获取位置...' }); |
| | | // 获取当前位置的逆地理编码 |
| | | getAddressByLocation(lng, lat) |
| | | .then((data) => { |
| | | const address = data.name || data.desc; |
| | | setCurrentAddress(address); |
| | | setCurrentSite({ name: address, latitude: lat, longitude: lng }); |
| | | } |
| | | }, |
| | | fail: (info) => { |
| | | console.error('getRegeo fail', info); |
| | | setCurrentAddress('未知位置'); |
| | | setCurrentSite({ name: '未知位置' }); |
| | | } |
| | | }); |
| | | setShowToolTip(true); |
| | | } |
| | | }, 600); |
| | | }) |
| | | .catch((info) => { |
| | | |
| | | setCurrentAddress('未知位置'); |
| | | setCurrentSite({ name: '未知位置' }); |
| | | }); |
| | | setShowToolTip(true); |
| | | } |
| | | }, 600); |
| | | } catch (error) { |
| | | |
| | | setBounce(false); |
| | | } |
| | | } |
| | | }); |
| | | } |
| | |
| | | id="map" |
| | | longitude={longitude ?? 104.066541} |
| | | latitude={latitude ?? 30.572269} |
| | | scale={14} |
| | | markers={markers} |
| | | scale={mapScale} |
| | | markers={isLocationAuthDenied ? [] : markers} |
| | | polygons={polygons} |
| | | showLocation={!!longitude} |
| | | onRegionChange={handleRegionChange} |
| | | style={{ width: '100%', flex: 1 }} |
| | | > |
| | | {/* 选择城市 */} |
| | | <View onClick={toSeleteCityPage} className="select-city"> |
| | | <Text>{selectedCity || '北京'}</Text> |
| | | <Text>{selectedCity.name || '北京市'}</Text> |
| | | <Image className="select-city-icon" src={selectCityIcon} /> |
| | | </View> |
| | | {/* 地图浮层入口 */} |
| | |
| | | <Image onClick={toOrder} className="map-entry-icon" src={suggest} /> |
| | | </View> |
| | | {/* 指针 */} |
| | | <View className="pointer-box"> |
| | | { |
| | | showToolTip && ( |
| | | <View className="toolTip"> |
| | | { |
| | | isAdsorbed && ( |
| | | <View className="toolTip-bg"> |
| | | <Text className="toolTip-bg-text">限停3分钟</Text> |
| | | </View> |
| | | ) |
| | | } |
| | | <View className="toolTip-txt"> |
| | | <View className="toolTip-txt-content">{isAdsorbed ? currentSite.name : currentAddress}</View> |
| | | {isAdsorbed && <Image className="toolTip-icon" src={rightIcon} />} |
| | | { |
| | | !isLocationAuthDenied && |
| | | <View className="pointer-box"> |
| | | { |
| | | showToolTip && (currentSite.name || !isOperatingArea) && ( |
| | | <View className="toolTip"> |
| | | { |
| | | (!isOperatingArea || isAdsorbed) && ( |
| | | <View className="toolTip-bg"> |
| | | <Text className="toolTip-bg-text">{!isOperatingArea ? '不在运营区' : '限停3分钟'}</Text> |
| | | </View> |
| | | ) |
| | | } |
| | | <View onClick={() => toSeleteSitePage('start')} className="toolTip-txt"> |
| | | <View className="toolTip-txt-content">{!isOperatingArea ? '请拖动至运营范围内' : (isAdsorbed ? currentSite.name : currentAddress)}</View> |
| | | {isAdsorbed && isOperatingArea && <Image className="toolTip-icon" src={rightIcon} />} |
| | | </View> |
| | | </View> |
| | | </View> |
| | | ) |
| | | } |
| | | <Image className={`pointer-icon ${bounce ? 'bounce' : ''}`} src={pointer} /> |
| | | </View> |
| | | ) |
| | | } |
| | | <Image className={`pointer-icon ${bounce ? 'bounce' : ''}`} src={pointer} /> |
| | | </View> |
| | | } |
| | | {/* 复位按钮 */} |
| | | <View className="origin-box"> |
| | | <Image onClick={toOrigin} className="origin-icon" src={originIcon} /> |
| | |
| | | <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> |
| | | >去:中国南方电网·3号门</View> */} |
| | | </View> |
| | | </View> |
| | | </View> |
| New file |
| | |
| | | import request from '../../utils/request'; |
| | | import { _ROBOTAXI_HOST } from '../../constants/api'; |
| | | |
| | | export const getServicePhone = async () => { |
| | | return request({ |
| | | url: _ROBOTAXI_HOST + '/base/v1/mini-program/support/phone', |
| | | method: 'GET' |
| | | }); |
| | | } |
| New file |
| | |
| | | import request from '../../utils/request'; |
| | | import { _ROBOTAXI_HOST } from '../../constants/api'; |
| | | |
| | | // 获取附近站点 |
| | | export const getNearestSite = async (payload) => { |
| | | return request({ |
| | | url: _ROBOTAXI_HOST + '/operation/base/v1/mini-program/pickup/nearest', |
| | | method: 'POST', |
| | | payload |
| | | }); |
| | | } |
| | | |
| | | // 根据定位判断是否在经营区域内 |
| | | export const getFenceCheck = async (payload) => { |
| | | return request({ |
| | | url: _ROBOTAXI_HOST + '/map/base/v1/mini-program/fence/check', |
| | | method: 'POST', |
| | | payload |
| | | }); |
| | | } |
| | | |
| | | // 获取地图开放区域围栏 |
| | | export const getGeofence = async (payload) => { |
| | | return request({ |
| | | url: _ROBOTAXI_HOST + '/map/base/v1/mini-program/cities/list', |
| | | method: 'GET', |
| | | payload |
| | | }); |
| | | } |
| New file |
| | |
| | | import request from '../../utils/request'; |
| | | import { _ROBOTAXI_HOST } from '../../constants/api'; |
| | | |
| | | // 获取开通城市列表 |
| | | export const getOpenCity = async () => { |
| | | return request({ |
| | | url: _ROBOTAXI_HOST + '/operation/base/v1/mini-program/cities/list', |
| | | method: 'GET', |
| | | }); |
| | | } |
| | | |
| | | // 查询所有市 |
| | | export const getCityList = async (payload) => { |
| | | return request({ |
| | | url: _ROBOTAXI_HOST + '/operation/base/v1/mini-program/open/t-region/getProvince', |
| | | method: 'POST', |
| | | payload |
| | | }); |
| | | } |
| | |
| | | import { View } from '@tarojs/components'; |
| | | import { useEffect, useState } from 'react'; |
| | | import './index.less'; |
| | | import { getServicePhone } from '../../api/customer-service'; |
| | | |
| | | type Props = { |
| | | show?: boolean; |
| | |
| | | export default function PrivacyPopup({ show = false, onClose }: Props) { |
| | | const [visible, setVisible] = useState(false); |
| | | const [animationData, setAnimationData] = useState<any>(); |
| | | const [phoneNumber, setPhoneNumber] = useState(''); |
| | | |
| | | useEffect(() => { |
| | | if (show && !visible) { |
| | | Taro.hideTabBar({ animation: true }); |
| | | setVisible(true); |
| | | const animation = Taro.createAnimation({ |
| | | duration: 300, |
| | | timingFunction: 'ease-out' |
| | | }); |
| | | animation.translateY(0).step(); |
| | | setAnimationData(animation.export()); |
| | | getServicePhone().then((res) => { |
| | | setPhoneNumber(res?.data?.phone_number); |
| | | setTimeout(() => { |
| | | Taro.hideTabBar({ animation: true }); |
| | | setVisible(true); |
| | | const animation = Taro.createAnimation({ |
| | | duration: 300, |
| | | timingFunction: 'ease-out' |
| | | }); |
| | | animation.translateY(0).step(); |
| | | setAnimationData(animation.export()); |
| | | }, 300); |
| | | }) |
| | | } else if (!show && visible) { |
| | | const animation = Taro.createAnimation({ |
| | | duration: 300, |
| | |
| | | |
| | | const callPhone = () => { |
| | | Taro.makePhoneCall({ |
| | | phoneNumber: '400-800-66889', |
| | | phoneNumber, |
| | | }) |
| | | } |
| | | |
| | |
| | | <View className='customer-service' > |
| | | <View className='customer-service-mask'></View> |
| | | <View className='customer-service-content' animation={animationData}> |
| | | <View onClick={callPhone} className='phone-number'>400-800-66889</View> |
| | | <View onClick={callPhone} className='phone-number'>{phoneNumber}</View> |
| | | <View className='cancel' onClick={onClose}>取消</View> |
| | | </View> |
| | | </View> |
| | |
| | | // 状态为1(发布行程)或8(行程已结束)时,展示整条路线 |
| | | mapCtx.includePoints({ |
| | | points: routePoints, |
| | | padding: [50, 50, 300, 50] |
| | | padding: [50, 50, 500, 50] |
| | | }) |
| | | } else { |
| | | mapCtx.moveToLocation({ |
| | |
| | | } |
| | | } |
| | | |
| | | .city-content { |
| | | flex: 1; |
| | | display: flex; |
| | | flex-direction: column; |
| | | overflow: hidden; |
| | | min-height: 0; |
| | | } |
| | | |
| | | .service-city-box { |
| | | margin: 60px 32px 64px 32px; |
| | | flex-shrink: 0; |
| | |
| | | margin-top: 42px; |
| | | flex: 1; |
| | | height: 100%; |
| | | display: flex; |
| | | flex-direction: column; |
| | | box-sizing: border-box; |
| | | padding-bottom: calc(60px + constant(safe-area-inset-bottom)); |
| | | padding-bottom: calc(60px + env(safe-area-inset-bottom)); |
| | |
| | | import { useEffect, useState } from 'react'; |
| | | import { View, Text, Input, ScrollView } from '@tarojs/components'; |
| | | import './index.less'; |
| | | import { getOpenCity, getCityList } from '../../api/select-city'; |
| | | |
| | | import { AMAP_KEY } from '../../../constants/api'; |
| | | |
| | | // 仅用于前端拼音首字母映射,不作为数据源展示 |
| | | const CITY_PINYIN_DICT = [ |
| | | { title: 'A', items: ['安庆', '安顺', '安阳', '鞍山'] }, |
| | | { title: 'B', items: ['北京', '保定', '包头', '北海'] }, |
| | | { title: 'C', items: ['重庆', '成都', '长沙', '长春'] }, |
| | | { title: 'D', items: ['大连', '东莞', '大同', '达州'] }, |
| | | { title: 'F', items: ['福州', '佛山', '抚顺', '阜阳'] }, |
| | | { title: 'G', items: ['广州', '贵阳', '桂林', '赣州'] }, |
| | | { title: 'H', items: ['杭州', '哈尔滨', '合肥', '海口'] }, |
| | | { title: 'J', items: ['济南', '嘉兴', '金华', '吉林'] }, |
| | | { title: 'K', items: ['昆明', '开封'] }, |
| | | { title: 'L', items: ['兰州', '拉萨', '洛阳', '临沂'] }, |
| | | { title: 'N', items: ['南京', '南宁', '宁波', '南昌'] }, |
| | | { title: 'Q', items: ['青岛', '秦皇岛', '泉州', '曲靖'] }, |
| | | { title: 'S', items: ['上海', '深圳', '石家庄', '沈阳', '苏州', '三亚'] }, |
| | | { title: 'T', items: ['天津', '太原', '唐山', '台州'] }, |
| | | { title: 'W', items: ['武汉', '无锡', '温州', '乌鲁木齐'] }, |
| | | { title: 'X', items: ['西安', '厦门', '徐州', '西宁'] }, |
| | | { title: 'Y', items: ['银川', '扬州', '烟台', '宜昌'] }, |
| | | { title: 'Z', items: ['郑州', '珠海', '中山', '张家口'] } |
| | | ]; |
| | | |
| | | const getCityPinyinLetter = (cityName: string) => { |
| | | const firstChar = cityName[0]; |
| | | for (const group of CITY_PINYIN_DICT) { |
| | | for (const item of group.items) { |
| | | if (item.includes(firstChar)) { |
| | | return group.title; |
| | | } |
| | | } |
| | | } |
| | | return '#'; |
| | | }; |
| | | |
| | | export default function SelectCityPage() { |
| | | const [scrollIntoView, setScrollIntoView] = useState(''); |
| | | const [searchValue, setSearchValue] = useState(''); |
| | | const [selectedCity, setSelectedCity] = useState('北京'); |
| | | const [filteredPendingList, setFilteredPendingList] = useState<any[]>([]); |
| | | |
| | | // 开放城市 |
| | | const openCityList = ['北京', '广州', '深圳', '珠海', '佛山']; |
| | | const [scrollIntoView, setScrollIntoView] = useState(''); // 滚动到的城市字母 |
| | | const [searchValue, setSearchValue] = useState(''); // 搜索值 |
| | | const [selectedCity, setSelectedCity] = useState<any>({}); // 选中的城市 |
| | | const [filteredPendingList, setFilteredPendingList] = useState<any[]>([]); // 过滤后的开放城市列表 |
| | | const [openCityList, setOpenCityList] = useState<any[]>([]); // 开放城市列表 |
| | | const [searchResultList, setSearchResultList] = useState<any[]>([]); // 搜索结果列表 |
| | | |
| | | useEffect(() => { |
| | | const city = Taro.getStorageSync('selectedCity'); |
| | |
| | | setSelectedCity(city); |
| | | } |
| | | |
| | | // 调用高德接口获取全国城市数据 |
| | | Taro.request({ |
| | | url: 'https://restapi.amap.com/v3/config/district', |
| | | data: { |
| | | key: AMAP_KEY, |
| | | subdistrict: 2, |
| | | extensions: 'base' |
| | | }, |
| | | success: (res) => { |
| | | if (res.data.status === '1' && res.data.districts && res.data.districts.length > 0) { |
| | | const allCities: string[] = []; |
| | | const country = res.data.districts[0]; |
| | | |
| | | if (country.districts) { |
| | | country.districts.forEach(province => { |
| | | // 直辖市处理:北京、天津、上海、重庆 |
| | | if (['110000', '120000', '310000', '500000'].includes(province.adcode)) { |
| | | allCities.push(province.name); |
| | | } else if (province.districts) { |
| | | // 普通省份,遍历下级城市 |
| | | province.districts.forEach(city => { |
| | | if (city.level === 'city') { |
| | | allCities.push(city.name); |
| | | } |
| | | }); |
| | | } |
| | | }); |
| | | } |
| | | // 并行请求:获取开通城市列表和所有城市列表 |
| | | Promise.all([ |
| | | getOpenCity(), |
| | | getCityList({ isOpen: 0 }) // 0代表待开通 |
| | | ]).then(([openRes, allRes]) => { |
| | | const openCities = openRes.cities || []; |
| | | setOpenCityList(openCities); |
| | | |
| | | // 分组与过滤 |
| | | const groups: Record<string, string[]> = {}; |
| | | |
| | | allCities.forEach(city => { |
| | | // 去除末尾的“市”以便于展示和匹配(如“成都市”->“成都”) |
| | | // 注意:有些自治州不以市结尾,这里只处理以“市”结尾的情况 |
| | | const simpleName = city.endsWith('市') ? city.slice(0, -1) : city; |
| | | |
| | | // 过滤已开放城市 |
| | | if (openCityList.includes(simpleName)) return; |
| | | const allCities = allRes || []; |
| | | |
| | | const letter = getCityPinyinLetter(simpleName); |
| | | if (!groups[letter]) groups[letter] = []; |
| | | groups[letter].push(simpleName); |
| | | }); |
| | | // 分组 |
| | | const groups: Record<string, string[]> = {}; |
| | | |
| | | // 转换为数组格式并排序 |
| | | const list = Object.keys(groups).sort().map(key => ({ |
| | | title: key, |
| | | items: groups[key] |
| | | })); |
| | | allCities.forEach((city: any) => { |
| | | const name = city.name; |
| | | // 使用接口返回的 initial 字段进行分组 |
| | | const letter = (city.initial || '#').toUpperCase(); |
| | | |
| | | setFilteredPendingList(list); |
| | | } |
| | | }, |
| | | fail: (err) => { |
| | | console.error('获取城市列表失败', err); |
| | | // 降级处理:如果接口失败,可以使用字典作为备选(可选) |
| | | } |
| | | if (!groups[letter]) groups[letter] = []; |
| | | groups[letter].push(name); |
| | | }); |
| | | |
| | | // 转换为数组格式并排序 |
| | | const list = Object.keys(groups).sort().map(key => ({ |
| | | title: key, |
| | | items: groups[key] |
| | | })); |
| | | |
| | | setFilteredPendingList(list); |
| | | |
| | | }).catch(err => { |
| | | console.error('获取城市数据失败', err); |
| | | }); |
| | | }, []) |
| | | |
| | |
| | | }; |
| | | |
| | | const searchCity = () => { |
| | | if (!searchValue) return; |
| | | getCityList({ cityName: searchValue }).then((res: any) => { |
| | | setSearchResultList(res || []); |
| | | }); |
| | | } |
| | | |
| | | const changeSearchValue = (e) => { |
| | |
| | | {/* 搜索前 */} |
| | | { |
| | | !searchValue && |
| | | <View> |
| | | <View className='city-content'> |
| | | <View className='service-city-box'> |
| | | <View className='service-city-title'>运营城市</View> |
| | | <View className='service-city-list'> |
| | | { |
| | | openCityList.map((item, index) => ( |
| | | <View |
| | | key={index} |
| | | className={`service-city-item ${selectedCity === item && 'active-city'}`} |
| | | key={item.adcode} |
| | | className={`service-city-item ${selectedCity.name === item.name && 'active-city'}`} |
| | | onClick={() => handleSelectCity(item)} |
| | | > |
| | | {item} |
| | | {item.name} |
| | | </View> |
| | | )) |
| | | } |
| | |
| | | <View> |
| | | <View className='search-result-box'> |
| | | { |
| | | ['成都', '广州', '深圳', '珠海', '佛山'].map((item, index) => ( |
| | | <View key={index} className='search-result-item'> |
| | | <View onClick={() => handleSelectCity(item)} className='search-result-city'>{item}</View> |
| | | <View className={`search-result-zone ${index != 0 ? 'search-result-zone-disabled' : ''}`}>运营区</View> |
| | | </View> |
| | | )) |
| | | searchResultList.map((item, index) => { |
| | | const isOpen = openCityList.some(open => open.name === item.name); |
| | | return ( |
| | | <View key={index} className='search-result-item'> |
| | | <View onClick={() => handleSelectCity(item)} className='search-result-city'>{item.name}</View> |
| | | <View className={`search-result-zone ${!isOpen ? 'search-result-zone-disabled' : ''}`}>{isOpen ? '运营区' : '待开放'}</View> |
| | | </View> |
| | | ) |
| | | }) |
| | | } |
| | | </View> |
| | | </View> |
| | |
| | | |
| | | import amapFile from '../libs/amap-wx.130'; |
| | | import { AMAP_KEY } from '../constants/api'; |
| | | |
| | | // 辅助函数:计算两点之间的距离(单位:米) |
| | | export const getDistance = (lat1: number, lng1: number, lat2: number, lng2: number) => { |
| | | const radLat1 = lat1 * Math.PI / 180.0; |
| | |
| | | } |
| | | return points; |
| | | }; |
| | | |
| | | // 格式化站点为标记点 |
| | | export const formatSiteToMarker = (sites: any[]) => { |
| | | return sites.map((site, index) => ({ |
| | | ...site, |
| | | id: index + 1, |
| | | iconPath: 'https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/a2b43790-9317-4b3b-b29f-d82cc10830e9.png', |
| | | width: 15, |
| | | height: 15, |
| | | anchor: { x: 0.5, y: 0.5 } |
| | | })); |
| | | }; |
| | | |
| | | // 获取位置的逆地理编码 |
| | | export const getAddressByLocation = (longitude: number, latitude: number): Promise<any> => { |
| | | return new Promise((resolve, reject) => { |
| | | const myAmapFun = new amapFile.AMapWX({ key: AMAP_KEY }); |
| | | myAmapFun.getRegeo({ |
| | | location: `${longitude},${latitude}`, |
| | | success: (data) => { |
| | | if (data && data[0]) { |
| | | resolve(data[0]); |
| | | } else { |
| | | reject('No data found'); |
| | | } |
| | | }, |
| | | fail: (info) => { |
| | | reject(info); |
| | | } |
| | | }); |
| | | }); |
| | | }; |
| New file |
| | |
| | | import Taro from '@tarojs/taro'; |
| | | |
| | | type MessageCallback = (data: any) => void; |
| | | |
| | | class WebSocketManager { |
| | | private static instance: WebSocketManager; |
| | | private socketTask: Taro.SocketTask | null = null; |
| | | private url: string = ''; |
| | | private isConnected: boolean = false; |
| | | private reconnectAttempts: number = 0; |
| | | private maxReconnectAttempts: number = 5; |
| | | private reconnectTimer: any = null; |
| | | private heartbeatTimer: any = null; |
| | | private heartbeatInterval: number = 30000; // 30秒心跳 |
| | | private listeners: Map<string, MessageCallback[]> = new Map(); |
| | | |
| | | private constructor() {} |
| | | |
| | | public static getInstance(): WebSocketManager { |
| | | if (!WebSocketManager.instance) { |
| | | WebSocketManager.instance = new WebSocketManager(); |
| | | } |
| | | return WebSocketManager.instance; |
| | | } |
| | | |
| | | /** |
| | | * 初始化连接 |
| | | * @param url WebSocket地址 |
| | | */ |
| | | public connect(url: string) { |
| | | if (this.socketTask || this.isConnected) { |
| | | console.log('WebSocket 已连接或正在连接中'); |
| | | return; |
| | | } |
| | | |
| | | this.url = url; |
| | | console.log(`正在连接 WebSocket: ${url}`); |
| | | |
| | | Taro.connectSocket({ |
| | | url: this.url, |
| | | success: (task) => { |
| | | this.socketTask = task; |
| | | this.initEventListeners(); |
| | | }, |
| | | fail: (err) => { |
| | | console.error('WebSocket 连接失败:', err); |
| | | this.handleReconnect(); |
| | | } |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * 初始化事件监听 |
| | | */ |
| | | private initEventListeners() { |
| | | if (!this.socketTask) return; |
| | | |
| | | this.socketTask.onOpen(() => { |
| | | console.log('WebSocket 连接已打开'); |
| | | this.isConnected = true; |
| | | this.reconnectAttempts = 0; |
| | | this.startHeartbeat(); |
| | | this.notifyListeners('open', { status: 'connected' }); |
| | | }); |
| | | |
| | | this.socketTask.onMessage((res) => { |
| | | try { |
| | | const data = JSON.parse(res.data as string); |
| | | // 假设后端消息格式包含 type 字段来区分消息类型 |
| | | if (data.type) { |
| | | this.notifyListeners(data.type, data); |
| | | } |
| | | // 同时触发通用的 message 事件 |
| | | this.notifyListeners('message', data); |
| | | } catch (e) { |
| | | console.warn('解析 WebSocket 消息失败:', res.data); |
| | | this.notifyListeners('message', res.data); |
| | | } |
| | | }); |
| | | |
| | | this.socketTask.onClose((res) => { |
| | | console.log('WebSocket 连接已关闭', res); |
| | | this.isConnected = false; |
| | | this.stopHeartbeat(); |
| | | this.socketTask = null; |
| | | |
| | | // 如果不是手动关闭(code 1000),则尝试重连 |
| | | if (res.code !== 1000) { |
| | | this.handleReconnect(); |
| | | } |
| | | this.notifyListeners('close', res); |
| | | }); |
| | | |
| | | this.socketTask.onError((err) => { |
| | | console.error('WebSocket 错误:', err); |
| | | this.isConnected = false; |
| | | this.notifyListeners('error', err); |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * 发送消息 |
| | | * @param data 消息内容 |
| | | */ |
| | | public send(data: any) { |
| | | if (!this.isConnected || !this.socketTask) { |
| | | console.warn('WebSocket 未连接,消息未发送:', data); |
| | | return; |
| | | } |
| | | |
| | | const message = typeof data === 'string' ? data : JSON.stringify(data); |
| | | this.socketTask.send({ |
| | | data: message, |
| | | fail: (err) => { |
| | | console.error('发送消息失败:', err); |
| | | } |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * 关闭连接 |
| | | */ |
| | | public close() { |
| | | this.stopHeartbeat(); |
| | | if (this.reconnectTimer) { |
| | | clearTimeout(this.reconnectTimer); |
| | | this.reconnectTimer = null; |
| | | } |
| | | |
| | | if (this.socketTask) { |
| | | this.socketTask.close({ |
| | | code: 1000, |
| | | reason: '用户主动关闭连接' |
| | | }); |
| | | this.socketTask = null; |
| | | } |
| | | this.isConnected = 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} 次尝试)`); |
| | | |
| | | this.reconnectTimer = setTimeout(() => { |
| | | this.reconnectAttempts++; |
| | | this.reconnectTimer = null; |
| | | this.connect(this.url); |
| | | }, delay); |
| | | } |
| | | |
| | | /** |
| | | * 开启心跳 |
| | | */ |
| | | private startHeartbeat() { |
| | | this.stopHeartbeat(); |
| | | this.heartbeatTimer = setInterval(() => { |
| | | if (this.isConnected) { |
| | | this.send({ type: 'ping', timestamp: Date.now() }); |
| | | } |
| | | }, this.heartbeatInterval); |
| | | } |
| | | |
| | | /** |
| | | * 停止心跳 |
| | | */ |
| | | private stopHeartbeat() { |
| | | if (this.heartbeatTimer) { |
| | | clearInterval(this.heartbeatTimer); |
| | | this.heartbeatTimer = null; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 注册事件监听 |
| | | * @param event 事件名称 |
| | | * @param callback 回调函数 |
| | | */ |
| | | public on(event: string, callback: MessageCallback) { |
| | | if (!this.listeners.has(event)) { |
| | | this.listeners.set(event, []); |
| | | } |
| | | this.listeners.get(event)?.push(callback); |
| | | } |
| | | |
| | | /** |
| | | * 移除事件监听 |
| | | * @param event 事件名称 |
| | | * @param callback 回调函数 |
| | | */ |
| | | public off(event: string, callback: MessageCallback) { |
| | | const callbacks = this.listeners.get(event); |
| | | if (callbacks) { |
| | | const index = callbacks.indexOf(callback); |
| | | if (index !== -1) { |
| | | callbacks.splice(index, 1); |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 通知监听器 |
| | | * @param event 事件名称 |
| | | * @param data 数据 |
| | | */ |
| | | private notifyListeners(event: string, data: any) { |
| | | const callbacks = this.listeners.get(event); |
| | | if (callbacks) { |
| | | callbacks.forEach(callback => { |
| | | try { |
| | | callback(data); |
| | | } catch (e) { |
| | | console.error(`WebSocket 事件 ${event} 监听器执行出错:`, e); |
| | | } |
| | | }); |
| | | } |
| | | } |
| | | } |
| | | |
| | | export default WebSocketManager.getInstance(); |