hejianhao
2026-02-04 544563ba534f21ae32ee663201282fae02e2a027
feat(robotaxi): 新增站点选择与隐私协议功能

- 新增站点选择页面,支持地图选点与城市切换
- 新增隐私协议弹窗组件,包含用户协议与隐私政策
- 新增客服弹窗组件,提供电话客服功能
- 新增站点搜索页面的地图选点入口
- 新增工具函数:计算两点距离与查找最近标记点
- 更新应用配置,添加新页面路由
5 文件已重命名
11个文件已添加
3个文件已修改
1192 ■■■■■ 已修改文件
src/app.config.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/robotaxi/home/components/privacy-popup/index.less 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/robotaxi/home/components/privacy-popup/index.tsx 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/robotaxi/home/components/privacy-popup/utils.ts 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/robotaxi/home/index.tsx 188 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/assets/back-icon.png 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/assets/marker-site.png 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/components/customer-service/index.less 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/components/customer-service/index.tsx 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/select-city/index.config.ts 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/select-city/index.less 179 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/select-city/index.tsx 144 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/select-map-site/components/css-folder/release-route.less 111 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/select-map-site/components/release-route.tsx 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/select-map-site/index.config.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/select-map-site/index.less 218 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/select-map-site/index.tsx 257 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/site-search/index.tsx 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/Robotaxi-public-method.ts 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/app.config.ts
@@ -24,7 +24,9 @@
        'pages/coupon/index',
        'pages/site-search/index',
        'pages/order/index',
        'pages/order/detail/index'
        'pages/order/detail/index',
        'pages/select-city/index',
        'pages/select-map-site/index'
      ],
    }
  ];
src/pages/robotaxi/home/components/privacy-popup/index.less
src/pages/robotaxi/home/components/privacy-popup/index.tsx
File was renamed from src/pages/robotaxi/components/privacy-popup/index.tsx
@@ -2,8 +2,8 @@
import { View, Text, Image } from '@tarojs/components';
import { useEffect, useState } from 'react';
import './index.less';
import check from '../../../../robotaxi/assets/check.png'
import uncheck from '../../../../robotaxi/assets/uncheck.png'
import check from '../../../../../robotaxi/assets/check.png'
import uncheck from '../../../../../robotaxi/assets/uncheck.png'
import { privicyShowManager } from './utils'
type Props = {
src/pages/robotaxi/home/components/privacy-popup/utils.ts
src/pages/robotaxi/home/index.tsx
@@ -1,9 +1,9 @@
import Taro from "@tarojs/taro"
import Taro, { useDidShow } from "@tarojs/taro"
import { useEffect, useState, useRef } from 'react';
import { View, Map, Text, Image } from '@tarojs/components';
import './index.less';
import PrivacyPopup from '../components/privacy-popup';
import CustomerServicePopup from '../components/customer-service';
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'
@@ -12,46 +12,90 @@
import originIcon from '../../../robotaxi/assets/origin-icon.png'
import pointer from '../../../robotaxi/assets/pointer.png'
import rightIcon from '../../../robotaxi/assets/right-icon.png'
import siteTopBg from '../../../robotaxi/assets/site-top-bg.png'
import amapFile from '../../../libs/amap-wx.130'
import { findNearestMarker } from '../../../utils/Robotaxi-public-method';
export default function Home() {
  const [showPrivacy, setShowPrivacy] = useState(false); //
  const [showCustomerService, setShowCustomerService] = useState(false);
  const [longitude, setLongitude] = useState<number | null>(null);
  const [latitude, setLatitude] = useState<number | null>(null);
  const [showToolTip, setShowToolTip] = useState(true);
  const [bounce, setBounce] = useState(false);
  const [isLocationAuthDenied, setIsLocationAuthDenied] = useState(false);
  const [isMaintain, setIsMaintain] = useState(false);
  const [isOperatingArea, setIsOperatingArea] = useState(false);
  const [showPrivacy, setShowPrivacy] = useState(false); // 隐私政策弹窗是否显示
  const [showCustomerService, setShowCustomerService] = useState(false); // 客服弹窗是否显示
  const [longitude, setLongitude] = useState<number | null>(null); // 经度
  const [latitude, setLatitude] = useState<number | null>(null); // 纬度
  const [showToolTip, setShowToolTip] = useState(true); // 提示是否显示
  const [bounce, setBounce] = useState(false); // 是否弹跳
  const [isLocationAuthDenied, setIsLocationAuthDenied] = useState(false); // 定位权限是否被拒绝
  const [markers, setMarkers] = useState<any[]>([]); // 标记点列表
  const [isMaintain, setIsMaintain] = useState(false); // 是否维护中
  const [isOperatingArea, setIsOperatingArea] = useState(false); // 是否在运营区域内
  const [boxHeight, setBoxHeight] = useState<number | string>('auto');
  const startY = useRef(0);
  const startHeight = useRef(0);
  const fullHeight = useRef(0);
  const minHeight = useRef(0);
  const isDragging = useRef(false);
  const [boxHeight, setBoxHeight] = useState<number | string>('auto'); // 活动区域高度
  const startY = useRef(0); // 拖动开始时的Y坐标
  const startHeight = useRef(0); // 拖动开始时的高度
  const fullHeight = useRef(0); // 活动区域最大高度
  const minHeight = useRef(0); // 活动区域最小高度
  const isDragging = useRef(false); // 是否正在拖动
  const getLocationInfo = () => {
    Taro.getLocation({ type: 'gcj02' })
      .then(res => {
        setLatitude(res.latitude);
        setLongitude(res.longitude);
        const myAmapFun = new amapFile.AMapWX({ key: AMAP_KEY });
        myAmapFun.getPoiAround({
          location: `${res.latitude},${res.longitude}`,
          success: () => { },
          fail: () => { }
        });
      })
      .catch(() => {
        setIsLocationAuthDenied(true);
      });
  }
  const [selectedCity, setSelectedCity] = useState(''); // 选中的城市
  useDidShow(() => {
    // 从本地存储中获取选中的城市
    const selectedCity = Taro.getStorageSync('selectedCity');
    if (selectedCity) {
      setSelectedCity(selectedCity);
    }
  });
  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 }
      },
      {
        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 }
      },
      {
        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 }
      },
      {
        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 }
      },
      {
        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 }
      }
    ];
    setMarkers(defaultMarkers);
    setShowPrivacy(Taro.getStorageSync('has_show_privicy_Robotaxi') != 'true');
    // 计算活动区域高度,用于初始隐藏
@@ -93,6 +137,24 @@
    });
  }, []);
  // 获取用户位置信息
  const getLocationInfo = () => {
    Taro.getLocation({ type: 'gcj02' })
      .then(res => {
        setLatitude(res.latitude);
        setLongitude(res.longitude);
        const myAmapFun = new amapFile.AMapWX({ key: AMAP_KEY });
        myAmapFun.getPoiAround({
          location: `${res.latitude},${res.longitude}`,
          success: () => { },
          fail: () => { }
        });
      })
      .catch(() => {
        setIsLocationAuthDenied(true);
      });
  }
  const handleClosePrivacy = () => {
    setShowPrivacy(false);
  };
@@ -102,17 +164,44 @@
    setShowPrivacy(false);
  };
  // 返回当前位置
  const toOrigin = () => {
    const mapCtx = Taro.createMapContext('map');
    mapCtx.moveToLocation();
  }
  // 地图区域变化时触发,判断是否吸附到最近的站点停靠点
  const handleRegionChange = (e: any) => {
    const type = e?.type;
    const type = e?.type || e?.detail?.type;
    // 获取触发源: gesture (手势), update (接口调用)
    const causedBy = e?.causedBy || e?.detail?.causedBy;
    if (type === 'begin') {
      setShowToolTip(false);
    } else if (type === 'end') {
      setBounce(true);
      // 只有用户手势拖动触发的移动才进行吸附判断,避免 moveToLocation 触发死循环
      if (causedBy === 'gesture' || causedBy === 'drag') {
        const mapCtx = Taro.createMapContext('map');
        mapCtx.getCenterLocation({
          success: (res) => {
            const { latitude: lat, longitude: lng } = res;
          const { targetMarker, minDistance } = findNearestMarker(lat, lng, markers);
          // 如果最近的点在一定范围内(例如 200米),则吸附
            if (targetMarker && minDistance < 200) {
              // 使用 moveToLocation 实现吸附效果
              mapCtx.moveToLocation({
                latitude: targetMarker.latitude,
                longitude: targetMarker.longitude,
              });
            }
          }
        });
      }
      setTimeout(() => {
        setBounce(false);
        setShowToolTip(true);
@@ -166,20 +255,33 @@
    })
  }
  const toSeleteSitePage = (type: string) => {
    Taro.navigateTo({
      url: '/robotaxi/pages/site-search/index?type=' + type
    })
  }
  const toSeleteCityPage = () => {
    Taro.navigateTo({
      url: '/robotaxi/pages/select-city/index',
    })
  }
  return (
    <View className="home-container">
      <Map
        id="map"
        longitude={longitude ?? 113.32452}
        latitude={latitude ?? 23.099994}
        longitude={longitude ?? 104.066541}
        latitude={latitude ?? 30.572269}
        scale={14}
        markers={markers}
        showLocation={!!longitude}
        onRegionChange={handleRegionChange}
        style={{ width: '100%', flex: 1 }}
      >
        {/* 选择城市 */}
        <View className="select-city">
          <Text>成都</Text>
        <View onClick={toSeleteCityPage} className="select-city">
          <Text>{selectedCity || '北京'}</Text>
          <Image className="select-city-icon" src={selectCityIcon} />
        </View>
        {/* 地图浮层入口 */}
@@ -243,10 +345,10 @@
        onTouchEnd={handleTouchEnd}
      >
        <View className="select-site">
          <Image className="site-top-bg" src={siteTopBg} />
          <Image className="site-top-bg" src='https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/cab0e5b2-0da0-4d2e-80e6-0fc2da8d2532.png' />
          <View className="site-boarding-point">
            {/* 起点 */}
            <View className="now-site">
            <View onClick={() => toSeleteSitePage('start')} className="now-site">
              <View className="now-site-dot"></View>
              <View className="now-site-address">
                <View className="now-site-address-content">
@@ -256,7 +358,7 @@
              </View>
            </View>
            {/* 终点 */}
            <View className="to-site">
            <View onClick={() => toSeleteSitePage('end')} className="to-site">
              <View className="to-site-dot"></View>
              <View className="to-site-address">
                <View className="to-site-text">你想去哪?</View>
src/robotaxi/assets/back-icon.png
src/robotaxi/assets/marker-site.png
src/robotaxi/components/customer-service/index.less
src/robotaxi/components/customer-service/index.tsx
src/robotaxi/pages/select-city/index.config.ts
New file
@@ -0,0 +1,3 @@
export default {
  navigationBarTitleText: '国内(含广澳门台)',
};
src/robotaxi/pages/select-city/index.less
New file
@@ -0,0 +1,179 @@
page {
    background: #fff;
}
.select-city-page {
    height: 100vh;
    display: flex;
    flex-direction: column;
    overflow: hidden;
    .search-box {
        padding: 16px 32px;
        background: #fff;
        flex-shrink: 0;
        position: sticky;
        top: 0;
        z-index: 100;
        .search-input {
            border-radius: 24px;
            background: #FFFFFF;
            box-sizing: border-box;
            border: 1.4px solid #F1F1F1;
            box-shadow: 0px 16px 60px 0px rgba(0, 0, 0, 0.05);
            padding: 0 32px;
            height: 96px;
            font-size: 28px;
            letter-spacing: normal;
            color: rgba(0, 0, 0, 0.9);
            .placeholder-class {
                font-size: 28px;
                color: rgba(0, 0, 0, 0.4);
            }
        }
    }
    .service-city-box {
        margin: 60px 32px 64px 32px;
        flex-shrink: 0;
        .service-city-title {
            font-size: 32px;
            font-weight: bold;
            line-height: 48px;
            letter-spacing: normal;
            color: rgba(0, 0, 0, 0.9);
        }
        .service-city-list {
            margin-top: 32px;
            display: flex;
            flex-wrap: wrap;
            align-items: center;
            gap: 20px;
            font-size: 28px;
            line-height: 44px;
            color: rgba(0, 0, 0, 0.9);
            .service-city-item {
                border-radius: 12px;
                background: #F2F3F7;
                box-sizing: border-box;
                line-height: 72px;
                text-align: center;
                padding: 0 20px;
                border: 2px solid #F2F3F7;
            }
            .active-city {
                border: 2px solid #000000;
            }
        }
    }
    .non-operational-city-box {
        margin: 0 32px;
        flex: 1;
        display: flex;
        flex-direction: column;
        overflow: hidden;
        min-height: 0;
        .non-operational-city-title {
            font-size: 32px;
            font-weight: bold;
            line-height: 48px;
            letter-spacing: normal;
            color: rgba(0, 0, 0, 0.9);
            flex-shrink: 0;
        }
        .non-operational-city-list {
            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));
            .city-group {
                .city-group-title {
                    font-size: 24px;
                    line-height: 34px;
                    color: rgba(0, 0, 0, 0.5);
                    margin-bottom: 20px;
                }
                .non-operational-city-item {
                    font-size: 28px;
                    line-height: 44px;
                    color: rgba(0, 0, 0, 0.9);
                    margin-bottom: 24px;
                    &:last-child {
                        margin-bottom: 0;
                    }
                }
            }
        }
    }
    .shortcut {
        position: fixed;
        top: 50%;
        right: 12px;
        transform: translateY(-50%);
        display: flex;
        flex-direction: column;
        align-items: center;
        z-index: 100;
        padding: 10px 0;
        background-color: rgba(255, 255, 255, 0.5);
        border-radius: 20px;
        .shortcut-item {
            font-size: 22px;
            line-height: 32px;
            color: rgba(0, 0, 0, 0.6);
            padding: 2px 8px;
            font-weight: 500;
        }
    }
    .search-result-box {
        margin: 32px;
        .search-result-item {
            display: flex;
            align-items: center;
            justify-content: space-between;
            margin-bottom: 38px;
            .search-result-city {
                font-size: 28px;
                line-height: 40px;
                letter-spacing: normal;
                color: rgba(0, 0, 0, 0.9);
            }
            .search-result-zone {
                border-radius: 200px;
                background: linear-gradient(107deg, rgba(132, 117, 255, 0.2) 0%, rgba(125, 214, 255, 0.2) 100%);
                line-height: 48px;
                text-align: center;
                padding: 0 18px;
                font-size: 24px;
                color: rgba(0, 0, 0, 0.9);
            }
            .search-result-zone-disabled {
                background: rgba(0, 0, 0, 0.15);
                color: #fff;
            }
        }
    }
}
src/robotaxi/pages/select-city/index.tsx
New file
@@ -0,0 +1,144 @@
import Taro from '@tarojs/taro';
import { useState } from 'react';
import { View, Text, Input, ScrollView } from '@tarojs/components';
import './index.less';
const pendingCityList = [
  { 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: ['郑州市', '珠海市', '中山市', '张家口市'] }
];
export default function SelectCityPage() {
  const [scrollIntoView, setScrollIntoView] = useState('');
  const [searchValue, setSearchValue] = useState('');
  const handleScrollTo = (letter) => {
    setScrollIntoView(`city-${letter}`);
  };
  const handleSelectCity = (city) => {
    Taro.setStorageSync('selectedCity', city);
    Taro.navigateBack();
  };
  const searchCity = () => {
  }
  const changeSearchValue = (e) => {
    setSearchValue(e.detail.value)
  }
  return (
    <View className='select-city-page'>
      <View className='search-box'>
        <Input
          onInput={(e) => changeSearchValue(e)}
          onBlur={() => searchCity()}
          onConfirm={() => searchCity()}
          value={searchValue}
          placeholder='请输入城市名'
          className='search-input'
          placeholderClass='placeholder-class'
        />
      </View>
      {/* 搜索前 */}
      {
        !searchValue &&
        <View>
          <View className='service-city-box'>
            <View className='service-city-title'>运营城市</View>
            <View className='service-city-list'>
              {
                ['广州市', '深圳市', '珠海市', '佛山市'].map((item, index) => (
                  <View
                    key={index}
                    className={`service-city-item ${index === 0 ? 'active-city' : ''}`}
                    onClick={() => handleSelectCity(item)}
                  >
                    {item}
                  </View>
                ))
              }
            </View>
          </View>
          <View className='non-operational-city-box'>
            <View className='non-operational-city-title'>待开放城市</View>
            <ScrollView
              className='non-operational-city-list'
              scrollY
              scrollWithAnimation
              scrollIntoView={scrollIntoView}
            >
              {
                pendingCityList.map((group) => (
                  <View key={group.title} id={`city-${group.title}`} className='city-group'>
                    <View className='city-group-title'>{group.title}</View>
                    {
                      group.items.map((city, index) => (
                        <View
                          key={index}
                          className='non-operational-city-item'
                        >
                          {city}
                        </View>
                      ))
                    }
                  </View>
                ))
              }
            </ScrollView>
          </View>
          <View className='shortcut'>
            {
              pendingCityList.map((group) => (
                <View
                  key={group.title}
                  className='shortcut-item'
                  onClick={(e) => {
                    e.stopPropagation();
                    handleScrollTo(group.title);
                  }}
                >
                  {group.title}
                </View>
              ))
            }
          </View>
        </View>
      }
      {/* 搜索后 */}
      {
        searchValue &&
        <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>
              ))
            }
          </View>
        </View>
      }
    </View>
  )
}
src/robotaxi/pages/select-map-site/components/css-folder/release-route.less
New file
@@ -0,0 +1,111 @@
.release-route {
    .select-site {
        position: relative;
        padding: 32px;
        padding-top: 40px;
        background: #F2F3F7;
        border-top: 2px solid #fff;
        border-radius: 32px 32px 0 0;
        .site-top-bg {
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            height: 326px;
        }
        .placeholder-box {
            width: 100%;
            height: 326px;
        }
        .site-boarding-point {
            border-radius: 24px;
            background: rgba(255, 255, 255, 0.9);
            box-sizing: border-box;
            border: 1px solid #FFFFFF;
            backdrop-filter: blur(20px);
            box-shadow: 0px 40px 60px 0px rgba(87, 99, 147, 0.1);
            padding: 24px;
            .now-site {
                display: flex;
                align-items: center;
                padding-left: 28px;
                .now-site-dot {
                    width: 12px;
                    height: 12px;
                    border-radius: 50%;
                    background: #6D7FD9;
                    margin-right: 24px;
                }
                .now-site-address {
                    display: flex;
                    align-items: center;
                    .now-site-address-content {
                        font-size: 28px;
                        font-weight: bold;
                        line-height: 44px;
                        letter-spacing: normal;
                        color: rgba(0, 0, 0, 0.9);
                        .now-site-address-text {
                            margin: 0 2px;
                            color: #6D7FD9;
                        }
                    }
                    .now-site-icon {
                        margin-left: 2px;
                        width: 32px;
                        height: 32px;
                    }
                }
            }
            .now-site-walk {
                font-size: 24px;
                font-weight: normal;
                line-height: normal;
                letter-spacing: normal;
                color: #F58900;
                margin-left: 64px;
            }
            .to-site {
                margin-top: 32px;
                display: flex;
                align-items: center;
                padding: 20px 0 20px 28px;
                border-radius: 20px;
                background: #F1F2F6;
                .to-site-dot {
                    width: 12px;
                    height: 12px;
                    border-radius: 50%;
                    background: #8CB654;
                    margin-right: 24px;
                }
                .to-site-address {
                    display: flex;
                    align-items: center;
                    .to-site-text {
                        font-size: 32px;
                        font-weight: bold;
                        line-height: 48px;
                        letter-spacing: normal;
                        color: rgba(0, 0, 0, 0.9);
                        margin-right: 22px;
                    }
                }
            }
        }
    }
}
src/robotaxi/pages/select-map-site/components/release-route.tsx
New file
@@ -0,0 +1,44 @@
import Taro from "@tarojs/taro"
import { useEffect, useState } from 'react';
import { View, Text, Image } from '@tarojs/components';
import rightIcon from '../../../assets/right-icon.png'
import './css-folder/release-route.less'
export default function ReleaseRoute() {
    const toSeleteSitePage = (type: string) => {
        Taro.navigateTo({
            url: '/robotaxi/pages/site-search/index?type=' + type
        })
    }
    return (
        <View className='release-route'>
            <View className="select-site">
                <Image className="site-top-bg" src='https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/cab0e5b2-0da0-4d2e-80e6-0fc2da8d2532.png' />
                <View className="site-boarding-point">
                    {/* 起点 */}
                    <View>
                        <View onClick={() => toSeleteSitePage('start')} className="now-site">
                            <View className="now-site-dot"></View>
                            <View className="now-site-address">
                                <View className="now-site-address-content">
                                    从<Text className="now-site-address-text">中国南方电网·2号门</Text>上车
                                </View>
                                <Image className="now-site-icon" src={rightIcon} />
                            </View>
                        </View>
                        <View className="now-site-walk">步行6分钟</View>
                    </View>
                    {/* 终点 */}
                    <View onClick={() => toSeleteSitePage('end')} className="to-site">
                        <View className="to-site-dot"></View>
                        <View className="to-site-address">
                            <View className="to-site-text">你想去哪?</View>
                        </View>
                    </View>
                </View>
            </View>
        </View>
    )
}
src/robotaxi/pages/select-map-site/index.config.ts
New file
@@ -0,0 +1,4 @@
export default {
  navigationStyle: "custom",
  disableScroll: true
};
src/robotaxi/pages/select-map-site/index.less
New file
@@ -0,0 +1,218 @@
.select-city {
    position: fixed;
    top: 110px;
    left: 31px;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 72px;
    height: 72px;
    border-radius: 200px;
    background: rgba(255, 255, 255, 0.9);
    backdrop-filter: blur(40px);
    box-shadow: 0px 4px 16px 0px rgba(84, 92, 143, 0.1);
    .select-city-icon {
        width: 40px;
        height: 40px;
    }
}
.map-entry {
    position: fixed;
    top: 236px;
    right: 32px;
    display: flex;
    align-items: center;
    flex-direction: column;
    gap: 8px;
    border-radius: 138px;
    background: rgba(255, 255, 255, 1);
    overflow: hidden;
    padding: 8px 12px;
    .map-entry-icon {
        width: 64px;
        height: 64px;
    }
    .split-line {
        width: 44px;
        height: 1px;
        background-color: rgba(0, 0, 0, 0.1);
    }
}
.pointer-box {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -100%);
    display: flex;
    align-items: center;
    flex-direction: column;
    justify-content: center;
    .toolTip {
        .toolTip-bg {
            background-image: url(../../../robotaxi/assets/tooltip-bg.png);
            background-size: cover;
            height: 70px;
            min-width: 155px;
            text-align: center;
            width: fit-content;
            .toolTip-bg-text {
                font-size: 22px;
                font-weight: 500;
                letter-spacing: normal;
                color: #FFFFFF;
                line-height: 48px;
            }
        }
        .toolTip-txt {
            margin-top: -26px;
            display: flex;
            align-items: center;
            border-radius: 16px;
            background: rgba(255, 255, 255, 0.9);
            backdrop-filter: blur(40px);
            box-shadow: 0px 4px 16px 0px rgba(45, 48, 64, 0.2);
            padding: 12px 12px 12px 20px;
            .toolTip-txt-content {
                font-size: 24px;
                font-weight: bold;
                letter-spacing: normal;
                color: rgba(0, 0, 0, 0.9);
                line-height: 48px;
            }
            .toolTip-icon {
                margin-left: 8px;
                width: 32px;
                height: 32px;
                flex-shrink: 0;
            }
        }
    }
    .pointer-icon {
        width: 52px;
        height: 76px;
        margin-top: -54px;
        &.bounce {
            animation: pointer-bounce 600ms ease-in-out;
            will-change: transform;
        }
    }
}
@keyframes pointer-bounce {
    0% {
        transform: translateY(0);
    }
    30% {
        transform: translateY(-16px);
    }
    60% {
        transform: translateY(0);
    }
    100% {
        transform: translateY(0);
    }
}
.home-container {
    display: flex;
    flex-direction: column;
    height: 100vh;
    overflow: hidden;
    position: relative;
}
.container {
    width: 100%;
    position: absolute;
    bottom: 0;
    transition: height 0.3s ease-in-out;
    .origin-box {
        display: flex;
        justify-content: flex-end;
        margin-bottom: 32px;
        .origin-icon {
            width: 88px;
            height: 88px;
        }
    }
    .content-box {
        overflow: hidden;
        border-radius: 32px 32px 0 0;
        padding: 40px;
        padding-bottom: 82px;
        background: #FFFFFF;
        .content-title {
            font-size: 36px;
            font-weight: bold;
            line-height: 50px;
            letter-spacing: normal;
            color: rgba(0, 0, 0, 0.9);
        }
        .site-name {
            margin-top: 32px;
            font-size: 28px;
            font-weight: 500;
            color: #3D3D3D;
            display: flex;
            align-items: center;
            height: 112px;
            padding-left: 32px;
            border-radius: 16px;
            background: linear-gradient(97deg, rgba(132, 117, 255, 0.2) 0%, rgba(125, 214, 255, 0.2) 103%);
            box-sizing: border-box;
            position: relative;
            &::after {
                content: "";
                position: absolute;
                top: 0;
                left: 0;
                right: 0;
                bottom: 0;
                border-radius: 16px;
                padding: 1px;
                background: linear-gradient(180deg, #AAA8FF -10%, rgba(41, 159, 255, 0.0469) 108%, rgba(0, 0, 0, 0) 108%);
                -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
                -webkit-mask-composite: xor;
                mask-composite: exclude;
                pointer-events: none;
            }
        }
        .site-confirm {
            margin-top: 148px;
            height: 88px;
            display: flex;
            justify-content: center;
            align-items: center;
            border-radius: 16px;
            background: linear-gradient(95deg, #8475FF 0%, #7DD6FF 124%);
            font-size: 28px;
            letter-spacing: normal;
            color: #FFFFFF;
        }
    }
}
src/robotaxi/pages/select-map-site/index.tsx
New file
@@ -0,0 +1,257 @@
import Taro from "@tarojs/taro"
import { useEffect, useState } from 'react';
import { View, Map, Text, Image } from '@tarojs/components';
import './index.less';
import CustomerServicePopup from '../../components/customer-service';
import backIcon from '../../../robotaxi/assets/back-icon.png'
import contactService from '../../../robotaxi/assets/contact-service.png'
import coupon from '../../../robotaxi/assets/coupon.png'
import suggest from '../../../robotaxi/assets/suggest.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 { findNearestMarker } from '../../../utils/Robotaxi-public-method';
import ReleaseRoute from './components/release-route';
export default function SelectMapSite() {
  const [showCustomerService, setShowCustomerService] = useState(false); // 客服弹窗是否显示
  const [longitude, setLongitude] = useState<number | null>(null); // 经度
  const [latitude, setLatitude] = useState<number | null>(null); // 纬度
  const [showToolTip, setShowToolTip] = useState(true); // 提示是否显示
  const [bounce, setBounce] = useState(false); // 是否弹跳
  const [markers, setMarkers] = useState<any[]>([]); // 标记点列表
  const [pageType, setPageType] = useState(''); // 起点start 终点end
  const [selectedSite, setSelectedSite] = useState<any>({}); // 选择的站点
  const [tripStatus, setTripStatus] = useState(0); // 行程状态 0:默认状态 1:发布行程 2:发布中 3:呼叫车中 4:车辆即将到达 5:车辆到达上车点 7:开始教程 8:车辆形式中 9:到达目的地 10:行程已结束 11:查看明细 12:支付成功
  const router = Taro.useRouter();
  useEffect(() => {
    if (router.params.pageType) {
      setPageType(router.params.pageType);
    }
    // 初始化默认标记点 (成都坐标)
    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: '中国南方电网·1号门'
      },
      {
        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: '中国南方电网·2号门'
      },
      {
        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: '中国南方电网·3号门'
      },
      {
        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: '中国南方电网·4号门'
      },
      {
        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: '中国南方电网·5号门'
      }
    ];
    setMarkers(defaultMarkers);
    getLocationInfo();
  }, []);
  // 获取用户位置信息
  const getLocationInfo = () => {
    Taro.getLocation({ type: 'gcj02' })
      .then(res => {
        setLatitude(res.latitude);
        setLongitude(res.longitude);
      })
  }
  // 返回当前位置
  const toOrigin = () => {
    const mapCtx = Taro.createMapContext('map');
    mapCtx.moveToLocation();
  }
  // 地图区域变化时触发,判断是否吸附到最近的站点停靠点
  const handleRegionChange = (e: any) => {
    const type = e?.type || e?.detail?.type;
    // 获取触发源: gesture (手势), update (接口调用)
    const causedBy = e?.causedBy || e?.detail?.causedBy;
    if (type === 'begin') {
      setShowToolTip(false);
    } else if (type === 'end') {
      setBounce(true);
      // 只有用户手势拖动触发的移动才进行吸附判断,避免 moveToLocation 触发死循环
      if (causedBy === 'gesture' || causedBy === 'drag') {
        const mapCtx = Taro.createMapContext('map');
        mapCtx.getCenterLocation({
          success: (res) => {
            const { latitude: lat, longitude: lng } = res;
            const { targetMarker, minDistance } = findNearestMarker(lat, lng, markers);
            // 如果最近的点在一定范围内(例如 200米),则吸附
            if (targetMarker && minDistance < 200) {
              // 使用 moveToLocation 实现吸附效果
              mapCtx.moveToLocation({
                latitude: targetMarker.latitude,
                longitude: targetMarker.longitude,
              });
              setSelectedSite(targetMarker);
            } else {
              setSelectedSite({});
            }
          }
        });
      }
      setTimeout(() => {
        setBounce(false);
        setShowToolTip(true);
      }, 600);
    }
  }
  const handleCloseCustomerService = () => {
    setShowCustomerService(false);
  };
  const openService = () => {
    setShowCustomerService(true);
  }
  const toBack = () => {
    if (tripStatus == 0) {
      Taro.navigateBack({
        delta: 1,
      })
    } else {
      Taro.switchTab({
        url: '/pages/robotaxi/home/index'
      })
    }
  }
  const confirmSite = () => {
    if (!selectedSite.name) {
      Taro.showToast({
        title: '请选择站点',
        icon: 'none',
      })
      return
    }
    if (pageType === 'start') {
      Taro.setStorageSync('startSite', {
        latitude: selectedSite.latitude,
        longitude: selectedSite.longitude,
        name: selectedSite.name,
      })
      Taro.navigateBack({
        delta: 2,
      })
    } else {
      setTripStatus(1)
    }
  }
  return (
    <View className="home-container">
      <Map
        id="map"
        longitude={longitude}
        latitude={latitude}
        scale={14}
        markers={markers}
        showLocation={!!longitude}
        onRegionChange={handleRegionChange}
        style={{ width: '100%', height: '100%' }}
      >
        {/* 选择城市 */}
        <View onClick={toBack} className="select-city">
          <Image className="select-city-icon" src={backIcon} />
        </View>
        {/* 地图浮层入口 */}
        <View className="map-entry">
          <Image onClick={openService} className="map-entry-icon" src={contactService} />
          <View className="split-line"></View>
          <Image className="map-entry-icon" src={coupon} />
          <View className="split-line"></View>
          <Image className="map-entry-icon" src={suggest} />
        </View>
        {/* 指针 */}
        <View className="pointer-box">
          {(showToolTip && selectedSite.name) && (
            <View className="toolTip">
              <View className="toolTip-bg">
                <Text className="toolTip-bg-text">限停3分钟</Text>
              </View>
              <View className="toolTip-txt">
                <View className="toolTip-txt-content">{selectedSite.name}</View>
                <Image className="toolTip-icon" src={rightIcon} />
              </View>
            </View>
          )}
          <Image className={`pointer-icon ${bounce ? 'bounce' : ''}`} src={pointer} />
        </View>
        <View className="container">
          {/* 复位按钮 */}
          <View className="origin-box">
            <Image onClick={toOrigin} className="origin-icon" src={originIcon} />
          </View>
          {/* 确认站点 */}
          {
            tripStatus == 0 && <View className="content-box">
              <View className="content-title">{pageType === 'start' ? '确认起点' : '确认目的地'}</View>
              <View className="site-name">{selectedSite.name || '请选择站点'}</View>
              <View onClick={confirmSite} className="site-confirm">{pageType === 'start' ? '确认起点站点' : '确认目的地站点'}</View>
            </View>
          }
          {/* 发布路线 */}
          {
            tripStatus == 1 && <ReleaseRoute />
          }
        </View>
      </Map>
      {/* 客服弹窗 */}
      <CustomerServicePopup
        show={showCustomerService}
        onClose={handleCloseCustomerService}
      />
    </View>
  )
}
src/robotaxi/pages/site-search/index.tsx
@@ -169,7 +169,11 @@
  }
const toSelectSite = () => {
  Taro.navigateTo({
    url: '/robotaxi/pages/select-map-site/index?pageType=' + pageType,
  })
}
  return (
@@ -182,7 +186,7 @@
          onBlur={() => searchSite()}
          onConfirm={() => searchSite()}
        ></Input>
        <Image className='clear-icon' src={map}></Image>
        <Image onClick={toSelectSite} className='clear-icon' src={map}></Image>
      </View>
      {/* 历史记录 */}
      <View className='history-card'>
src/utils/Robotaxi-public-method.ts
New file
@@ -0,0 +1,28 @@
// 辅助函数:计算两点之间的距离(单位:米)
export const getDistance = (lat1: number, lng1: number, lat2: number, lng2: number) => {
  const radLat1 = lat1 * Math.PI / 180.0;
  const radLat2 = lat2 * Math.PI / 180.0;
  const a = radLat1 - radLat2;
  const b = lng1 * Math.PI / 180.0 - lng2 * Math.PI / 180.0;
  let s = 2 * Math.asin(Math.sqrt(Math.pow(Math.sin(a / 2), 2) + Math.cos(radLat1) * Math.cos(radLat2) * Math.pow(Math.sin(b / 2), 2)));
  s = s * 6378.137; // 地球半径
  s = Math.round(s * 10000) / 10000;
  return s * 1000;
};
// 查找最近的标记点
export const findNearestMarker = (lat: number, lng: number, markers: any[]) => {
  let minDistance = Infinity;
  let targetMarker: any = null;
  markers.forEach(marker => {
    const distance = getDistance(lat, lng, marker.latitude, marker.longitude);
    if (distance < minDistance) {
      minDistance = distance;
      targetMarker = marker;
    }
  });
  return { targetMarker, minDistance };
};