hejianhao
2026-02-09 216e5fc8be6fd948e1d9bd149f95b2e8462adaa7
feat(robotaxi): 新增打车服务接口与功能优化

- 添加 ROBOTAXI_HOST 环境变量配置
- 实现客服电话、城市列表、附近站点等 API 接口
- 新增 WebSocket 连接管理工具类
- 优化城市选择页面的数据获取与展示逻辑
- 改进首页地图的运营区域检测与站点吸附逻辑
- 调整地图标记点生成与逆地理编码工具方法
- 修复客服组件电话动态获取问题
- 优化行程开始页面的地图 padding 设置
4个文件已添加
8个文件已修改
959 ■■■■■ 已修改文件
config/dev.js 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/constants/api.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/robotaxi/home/index.tsx 438 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/api/customer-service.ts 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/api/home.ts 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/api/select-city.ts 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/components/customer-service/index.tsx 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/index.tsx 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/select-city/index.less 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/select-city/index.tsx 157 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/Robotaxi-public-method.ts 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/websocket.ts 229 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
config/dev.js
@@ -16,6 +16,7 @@
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
@@ -81,6 +82,7 @@
    },
    COMMON_OSS_HOST,
    ADMIN_OSS_HOST,
    ROBOTAXI_HOST,
  },
  mini: {
    // webpackChain (chain) {
src/constants/api.ts
@@ -36,6 +36,8 @@
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'
src/pages/robotaxi/home/index.tsx
@@ -4,7 +4,6 @@
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'
@@ -12,8 +11,10 @@
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() {
@@ -27,10 +28,12 @@
  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坐标
@@ -39,8 +42,16 @@
  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: '车辆正在赶来',
@@ -50,22 +61,38 @@
  }
  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');
@@ -75,123 +102,18 @@
            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');
    // 计算活动区域高度,用于初始隐藏
@@ -219,46 +141,120 @@
    }, 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);
      });
  }
@@ -274,6 +270,8 @@
  // 返回当前位置
  const toOrigin = () => {
    shouldIgnoreRegionChange.current = false;
    isReturningToOrigin.current = true;
    setMapScale(14);
    const mapCtx = Taro.createMapContext('map');
    mapCtx.moveToLocation();
  }
@@ -287,6 +285,13 @@
    // 如果是用户手势拖动,则必须触发,且重置忽略标记
    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
@@ -295,6 +300,10 @@
    if (type === 'begin') {
      setShowToolTip(false);
    } else if (type === 'end') {
      if (isReturningToOrigin.current) {
        isReturningToOrigin.current = false;
      }
      // 如果标记为忽略且非手势触发,则直接返回
      if (shouldIgnoreRegionChange.current) {
        shouldIgnoreRegionChange.current = false;
@@ -304,53 +313,70 @@
      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);
          }
        }
      });
    }
@@ -453,15 +479,16 @@
        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>
        {/* 地图浮层入口 */}
@@ -473,26 +500,29 @@
          <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} />
@@ -555,13 +585,13 @@
              <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>
src/robotaxi/api/customer-service.ts
New file
@@ -0,0 +1,9 @@
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'
    });
}
src/robotaxi/api/home.ts
New file
@@ -0,0 +1,29 @@
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
    });
}
src/robotaxi/api/select-city.ts
New file
@@ -0,0 +1,19 @@
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
  });
}
src/robotaxi/components/customer-service/index.tsx
@@ -2,6 +2,7 @@
import { View } from '@tarojs/components';
import { useEffect, useState } from 'react';
import './index.less';
import { getServicePhone } from '../../api/customer-service';
type Props = {
  show?: boolean;
@@ -11,17 +12,23 @@
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,
@@ -39,7 +46,7 @@
  const callPhone = () => {
    Taro.makePhoneCall({
      phoneNumber: '400-800-66889',
      phoneNumber,
    })
  }
@@ -49,7 +56,7 @@
    <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>
src/robotaxi/pages/begin-route/index.tsx
@@ -193,7 +193,7 @@
        // 状态为1(发布行程)或8(行程已结束)时,展示整条路线
        mapCtx.includePoints({
          points: routePoints,
          padding: [50, 50, 300, 50]
          padding: [50, 50, 500, 50]
        })
      } else {
        mapCtx.moveToLocation({
src/robotaxi/pages/select-city/index.less
@@ -35,6 +35,14 @@
        }
    }
    .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;
@@ -94,8 +102,6 @@
            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));
src/robotaxi/pages/select-city/index.tsx
@@ -2,51 +2,17 @@
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');
@@ -54,64 +20,38 @@
      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);
    });
  }, [])
@@ -125,6 +65,10 @@
  };
  const searchCity = () => {
    if (!searchValue) return;
    getCityList({ cityName: searchValue }).then((res: any) => {
      setSearchResultList(res || []);
    });
  }
  const changeSearchValue = (e) => {
@@ -147,18 +91,18 @@
      {/* 搜索前 */}
      {
        !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>
                ))
              }
@@ -216,12 +160,15 @@
        <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>
src/utils/Robotaxi-public-method.ts
@@ -1,4 +1,7 @@
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;
@@ -44,3 +47,35 @@
  }
  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);
      }
    });
  });
};
src/utils/websocket.ts
New file
@@ -0,0 +1,229 @@
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();