fix
13404089107
2026-03-27 bd8e2fe84e52f48a285c14ffb26659d9e87329fb
fix
3个文件已添加
1个文件已修改
568 ■■■■■ 已修改文件
src/app.config.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/operating-area/index.config.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/operating-area/index.less 141 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/operating-area/index.tsx 422 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/app.config.ts
@@ -35,6 +35,7 @@
        'pages/pricing-rules/index',
        'pages/ride-guide/index',
        'pages/agreement-page/index',
        'pages/operating-area/index',
      ],
    },
    {
src/robotaxi/pages/operating-area/index.config.ts
New file
@@ -0,0 +1,4 @@
export default {
  navigationStyle: "custom",
  disableScroll: true
};
src/robotaxi/pages/operating-area/index.less
New file
@@ -0,0 +1,141 @@
.operating-area-container {
  width: 100vw;
  height: 100vh;
  position: relative;
  .back-btn {
    position: fixed;
    top: 92px;
    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);
    z-index: 100;
    .back-icon {
      width: 40px;
      height: 40px;
    }
  }
  .bottom-sheet {
    position: fixed;
    bottom: 0;
    left: 0;
    width: 100%;
    background: #FFFFFF;
    border-radius: 40rpx 40rpx 0 0;
    padding: 42rpx 40rpx 80rpx 40rpx;
    box-sizing: border-box;
    z-index: 100;
    box-shadow: 0px -4rpx 16rpx rgba(0, 0, 0, 0.05);
    .origin-icon {
      position: absolute;
      top: -118rpx;
      right: 32px;
      width: 88rpx;
      height: 88rpx;
    }
    .header {
      display: flex;
      justify-content: center;
      align-items: center;
      position: relative;
      margin-bottom: 56rpx;
      .title {
        font-size: 32rpx;
        font-weight: 500;
        color: rgba(0, 0, 0, 0.9);
      }
      .close-icon {
        position: absolute;
        right: 0;
        width: 48rpx;
        height: 48rpx;
      }
    }
    .city-scroll {
      width: 100%;
      white-space: nowrap;
      margin-bottom: 32rpx;
      .city-list {
        display: flex;
        flex-direction: row;
        align-items: center;
        .city-item {
          display: inline-block;
          font-size: 28rpx;
          font-weight: 500;
          color: rgba(0, 0, 0, 0.4);
          margin-right: 56rpx;
          transition: color 0.3s;
          &.active {
            color: rgba(0, 0, 0, 0.9);
          }
          &:last-child {
            margin-right: 0;
          }
        }
      }
    }
    .content-box {
      background: linear-gradient(122deg, rgba(165, 226, 255, 0.12) 0%, rgba(117, 136, 255, 0.12) 99%);
    //   height: 420rpx;
      border-radius: 16rpx;
      padding: 32rpx 32rpx 36rpx 32rpx;
      box-sizing: border-box;
      .content-title {
        font-size: 24rpx;
        line-height: 40rpx;
        font-weight: 500;
        color: rgba(0, 0, 0, 0.9);
        margin-bottom: 8rpx;
      }
      .content-time {
        font-size: 24rpx;
        line-height: 40rpx;
        color: rgba(0, 0, 0, 0.7);
        margin-bottom: 40rpx;
        white-space: pre-wrap;
      }
      .content-desc {
        .desc-item {
          font-size: 24rpx;
          line-height: 40rpx;
          color: rgba(0, 0, 0, 0.7);
          margin-bottom: 8rpx;
          display: flex;
          align-items: flex-start;
          .index {
            margin-right: 8rpx;
          }
          .text {
            flex: 1;
            white-space: normal;
          }
        }
      }
    }
  }
}
src/robotaxi/pages/operating-area/index.tsx
New file
@@ -0,0 +1,422 @@
import Taro from "@tarojs/taro";
import { useEffect, useRef, useState } from 'react';
import { View, Map, Text, Image, ScrollView } from '@tarojs/components';
import './index.less';
import backIcon from '../../../robotaxi/assets/back-icon.png';
import originIcon from '../../../robotaxi/assets/origin-icon.png';
import { getOpenCity } from '../../api/select-city';
import { getGeofence } from '../../api/home';
import { extractPointsFromPolygons, getDistance } from '../../../utils/Robotaxi-public-method';
export default function OperatingArea() {
  const [longitude, setLongitude] = useState<number | null>(null);
  const [latitude, setLatitude] = useState<number | null>(null);
  const [polygons, setPolygons] = useState<any[]>([]);
  const [polylines, setPolylines] = useState<any[]>([]);
  const [cityList, setCityList] = useState<any[]>([]);
  const [selectedCityCode, setSelectedCityCode] = useState<string>('');
  const [rawGeofenceData, setRawGeofenceData] = useState<any[]>([]);
  const [selectedPolygonId, setSelectedPolygonId] = useState<number | null>(null);
  const [operatingTimeInfo, setOperatingTimeInfo] = useState<any>(null);
  const lastPolygonTapTimeRef = useRef(0);
  useEffect(() => {
    const cachedSelectedCity = Taro.getStorageSync('selectedCity');
    getOpenCity().then((res: any) => {
      const openCities = res.cities || [];
      setCityList(openCities);
      const cachedCityCode = cachedSelectedCity?.code;
      const matchedCity = openCities.find((city: any) => (city.code || city.adcode) === cachedCityCode);
      if (matchedCity) {
        handleSelectCity(matchedCity.code || matchedCity.adcode);
      } else if (cachedCityCode) {
        handleSelectCity(cachedCityCode);
      } else if (openCities.length > 0) {
        handleSelectCity(openCities[0].code || openCities[0].adcode);
      } else {
        getLocationInfo();
      }
    }).catch(err => {
      console.error('获取城市数据失败', err);
      getLocationInfo();
    });
  }, []);
  const normalizeGeofenceData = (geofenceList: any[] = []) => {
    return geofenceList.map((item, index) => ({
      ...item,
      mapPolygonId: index + 1,
      polygonPoints: buildPolygonPoints(item.points),
      polylinePoints: buildPolylinePoints(item.points)
    }));
  };
  const buildPolygonPoints = (points: any[] = []) => {
    return points.map((point) => ({
      latitude: Number(point.lat),
      longitude: Number(point.lng)
    }));
  };
  const buildPolylinePoints = (points: any[] = []) => {
    const polygonPoints = buildPolygonPoints(points);
    if (polygonPoints.length === 0) {
      return [];
    }
    return [...polygonPoints, polygonPoints[0]];
  };
  const updatePolygonsStyle = (geofenceList: any[], selectedId: number | null) => {
    if (!geofenceList || !Array.isArray(geofenceList)) {
      return [];
    }
    return geofenceList.map(item => {
      return {
        points: item.polygonPoints || [],
        strokeWidth: 0,
        strokeColor: 'rgba(0, 0, 0, 0)',
        fillColor: '#6d7fd933',
        zIndex: 1,
        id: item.mapPolygonId
      }
    });
  };
  const updatePolylineStyle = (geofenceList: any[], selectedId: number | null) => {
    if (!geofenceList || !Array.isArray(geofenceList)) {
      return [];
    }
    return geofenceList.map((item, index) => {
      const isSelected = item.mapPolygonId === selectedId;
      return {
        points: item.polylinePoints || [],
        color: isSelected ? '#6D7FD9' : '#A1B1FF',
        width: isSelected ? 4 : 2,
        dottedLine: !isSelected,
        arrowLine: false,
        borderColor: isSelected ? '#6D7FD9' : '#A1B1FF',
        borderWidth: 0,
        zIndex: isSelected ? 3 : 2,
        id: Number(item.mapPolygonId ?? index + 1)
      };
    });
  };
  const selectPolygon = (polygonId: number | null, geofenceList: any[]) => {
    if (polygonId === null || polygonId === selectedPolygonId) {
      return;
    }
    setSelectedPolygonId(polygonId);
    const targetPolygon = geofenceList.find(item => item.mapPolygonId === polygonId);
    if (targetPolygon) {
      setOperatingTimeInfo(targetPolygon);
    }
    setPolygons(updatePolygonsStyle(geofenceList, polygonId));
    setPolylines(updatePolylineStyle(geofenceList, polygonId));
  };
  const projectPoint = (point: { latitude: number; longitude: number }, referenceLatitude: number) => {
    const metersPerDegreeLat = 111320;
    const metersPerDegreeLng = 111320 * Math.cos(referenceLatitude * Math.PI / 180);
    return {
      x: point.longitude * metersPerDegreeLng,
      y: point.latitude * metersPerDegreeLat
    };
  };
  const getDistanceToSegment = (
    point: { latitude: number; longitude: number },
    start: { latitude: number; longitude: number },
    end: { latitude: number; longitude: number }
  ) => {
    const referenceLatitude = (point.latitude + start.latitude + end.latitude) / 3;
    const pointXY = projectPoint(point, referenceLatitude);
    const startXY = projectPoint(start, referenceLatitude);
    const endXY = projectPoint(end, referenceLatitude);
    const dx = endXY.x - startXY.x;
    const dy = endXY.y - startXY.y;
    const lengthSquared = dx * dx + dy * dy;
    if (lengthSquared === 0) {
      return Math.sqrt((pointXY.x - startXY.x) * (pointXY.x - startXY.x) + (pointXY.y - startXY.y) * (pointXY.y - startXY.y));
    }
    let ratio = ((pointXY.x - startXY.x) * dx + (pointXY.y - startXY.y) * dy) / lengthSquared;
    ratio = Math.max(0, Math.min(1, ratio));
    const closestX = startXY.x + ratio * dx;
    const closestY = startXY.y + ratio * dy;
    return Math.sqrt((pointXY.x - closestX) * (pointXY.x - closestX) + (pointXY.y - closestY) * (pointXY.y - closestY));
  };
  const isPointInPolygon = (
    point: { latitude: number; longitude: number },
    polygonPoints: Array<{ latitude: number; longitude: number }>
  ) => {
    if (!Array.isArray(polygonPoints) || polygonPoints.length < 3) {
      return false;
    }
    let isInside = false;
    const pointX = point.longitude;
    const pointY = point.latitude;
    for (let index = 0, lastIndex = polygonPoints.length - 1; index < polygonPoints.length; lastIndex = index, index += 1) {
      const currentPoint = polygonPoints[index];
      const previousPoint = polygonPoints[lastIndex];
      const currentX = currentPoint.longitude;
      const currentY = currentPoint.latitude;
      const previousX = previousPoint.longitude;
      const previousY = previousPoint.latitude;
      const isIntersected = ((currentY > pointY) !== (previousY > pointY))
        && (pointX < ((previousX - currentX) * (pointY - currentY)) / ((previousY - currentY) || Number.EPSILON) + currentX);
      if (isIntersected) {
        isInside = !isInside;
      }
    }
    return isInside;
  };
  const findNearestPolygonIdByTap = (latitudeValue: number, longitudeValue: number) => {
    const tapPoint = { latitude: latitudeValue, longitude: longitudeValue };
    const insidePolygon = rawGeofenceData.find((item) => {
      const polygonPoints = item.polygonPoints || [];
      return isPointInPolygon(tapPoint, polygonPoints);
    });
    if (insidePolygon) {
      return insidePolygon.mapPolygonId;
    }
    let nearestPolygonId: number | null = null;
    let nearestDistance = Number.POSITIVE_INFINITY;
    rawGeofenceData.forEach((item) => {
      const polygonPoints = item.polygonPoints || [];
      if (polygonPoints.length < 2) {
        return;
      }
      let minDistanceToBorder = Number.POSITIVE_INFINITY;
      for (let index = 0; index < polygonPoints.length; index += 1) {
        const start = polygonPoints[index];
        const end = polygonPoints[(index + 1) % polygonPoints.length];
        const distanceToSegment = getDistanceToSegment(tapPoint, start, end);
        if (distanceToSegment < minDistanceToBorder) {
          minDistanceToBorder = distanceToSegment;
        }
      }
      const distanceToFirstPoint = getDistance(
        latitudeValue,
        longitudeValue,
        polygonPoints[0].latitude,
        polygonPoints[0].longitude
      );
      const matchDistance = Math.min(minDistanceToBorder, distanceToFirstPoint);
      if (matchDistance < nearestDistance) {
        nearestDistance = matchDistance;
        nearestPolygonId = item.mapPolygonId;
      }
    });
    return nearestDistance <= 180 ? nearestPolygonId : null;
  };
  const handleSelectCity = (cityCode: string) => {
    if (!cityCode) return;
    setSelectedCityCode(cityCode);
    getGeofence({ cityCode }).then((res: any) => {
      if (res && Array.isArray(res)) {
        const normalizedGeofenceData = normalizeGeofenceData(res);
        setRawGeofenceData(normalizedGeofenceData);
        const firstPolygonId = normalizedGeofenceData.length > 0 ? normalizedGeofenceData[0].mapPolygonId : null;
        setSelectedPolygonId(firstPolygonId);
        if (firstPolygonId !== null) {
          const firstPolygonData = normalizedGeofenceData.find(item => item.mapPolygonId === firstPolygonId);
          setOperatingTimeInfo(firstPolygonData);
        } else {
          setOperatingTimeInfo(null);
        }
        const newPolygons = updatePolygonsStyle(normalizedGeofenceData, firstPolygonId);
        setPolygons(newPolygons);
        setPolylines(updatePolylineStyle(normalizedGeofenceData, firstPolygonId));
        if (normalizedGeofenceData.length > 0 && normalizedGeofenceData[0]?.points?.length > 0) {
          const firstPoint = normalizedGeofenceData[0].points[0];
          const lat = Number(firstPoint.lat);
          const lng = Number(firstPoint.lng);
          setLatitude(lat);
          setLongitude(lng);
          const mapCtx = Taro.createMapContext('map');
          const points = extractPointsFromPolygons(newPolygons);
          if (points.length > 0) {
            setTimeout(() => {
              mapCtx.includePoints({
                points: points,
                padding: [10, 10, 10, 10]
              });
            }, 500);
          }
        }
      } else {
        setRawGeofenceData([]);
        setPolygons([]);
        setPolylines([]);
        setSelectedPolygonId(null);
        setOperatingTimeInfo(null);
      }
    }).catch(err => {
      console.error('获取电子围栏失败', err);
      setRawGeofenceData([]);
      setPolygons([]);
      setPolylines([]);
      setSelectedPolygonId(null);
      setOperatingTimeInfo(null);
    });
  };
  const handlePolygonTap = (e: any) => {
    let tapPolygonId = null;
    if (e.detail && e.detail.polygonId) {
      tapPolygonId = e.detail.polygonId;
    } else if (e.markerId) {
      tapPolygonId = e.markerId;
    }
    if (tapPolygonId !== null && rawGeofenceData.length > 0) {
      lastPolygonTapTimeRef.current = Date.now();
      selectPolygon(tapPolygonId, rawGeofenceData);
    }
  };
  const handleMapTap = (e: any) => {
    if (Date.now() - lastPolygonTapTimeRef.current < 200) {
      return;
    }
    const tapLatitude = e?.detail?.latitude;
    const tapLongitude = e?.detail?.longitude;
    if (!Number.isFinite(tapLatitude) || !Number.isFinite(tapLongitude) || rawGeofenceData.length === 0) {
      return;
    }
    const nearestPolygonId = findNearestPolygonIdByTap(tapLatitude, tapLongitude);
    if (nearestPolygonId !== null) {
      selectPolygon(nearestPolygonId, rawGeofenceData);
    }
  };
  const getLocationInfo = () => {
    Taro.getLocation({ type: 'gcj02' })
      .then(res => {
        setLatitude(res.latitude);
        setLongitude(res.longitude);
      }).catch(err => {
        console.error('获取位置失败', err);
      });
  };
  const toBack = () => {
    Taro.navigateBack({
      delta: 1,
    });
  };
  const toOrigin = () => {
    const mapCtx = Taro.createMapContext('map');
    const points = extractPointsFromPolygons(polygons);
    if (points.length > 0) {
      mapCtx.includePoints({
        points,
        padding: [10, 10, 10, 10]
      });
    }
  };
  return (
    <View className="operating-area-container">
      <Map
        id="map"
        className="map"
        longitude={longitude || 116.397128}
        latitude={latitude || 39.916527}
        // scale={30}
        polygons={polygons}
        polyline={polylines}
        showLocation={false}
        style={{ width: '100%', height: '65%' }}
        onPolygonTap={handlePolygonTap}
        onTap={handleMapTap}
      >
        {/* 返回按钮 */}
        <View onClick={toBack} className="back-btn">
          <Image className="back-icon" src={backIcon} />
        </View>
      </Map>
      {/* 底部面板 */}
      <View className="bottom-sheet">
        <Image className="origin-icon" src={originIcon} onClick={toOrigin} />
        <View className="header">
          <Text className="title">查看运营区域</Text>
          {/* <Image onClick={toBack} className="close-icon" src={closeIcon} /> */}
        </View>
        <ScrollView scrollX className="city-scroll">
          <View className="city-list">
            {cityList.map((city, index) => {
              const code = city.code || city.adcode;
              return (
                <View
                  key={index}
                  className={`city-item ${selectedCityCode === code ? 'active' : ''}`}
                  onClick={() => handleSelectCity(code)}
                >
                  {city.name}
                </View>
              );
            })}
            {/* Fallback mock data if cityList is empty */}
            {cityList.length === 0 && (
              <>
                <View className="city-item active">北京市</View>
                <View className="city-item">广州市</View>
                <View className="city-item">佛山市</View>
              </>
            )}
          </View>
        </ScrollView>
        <View className="content-box">
          <View className="content-title">区域运营时间:</View>
          <View className="content-time">
            工作日: {operatingTimeInfo?.workDayOpenTime || '07:00'}-{operatingTimeInfo?.workDayCloseTime || '22:00'}     周末: {operatingTimeInfo?.weekendOpenTime || '07:00'}-{operatingTimeInfo?.weekendCloseTime || '22:00'}
          </View>
          <View className="content-desc">
            <View className="desc-item">
              <Text className="index">1.</Text>
              <Text className="text">在运营时段可正常使用打车服务,非运营时段服务暂停</Text>
            </View>
            <View className="desc-item">
              <Text className="index">2.</Text>
              <Text className="text">各站点运营时间可能存在调整,实际运营情况请以页面实时展示为准</Text>
            </View>
            <View className="desc-item">
              <Text className="index">3.</Text>
              <Text className="text">如有问题,请拨打客服热线xxxxxx</Text>
            </View>
          </View>
        </View>
      </View>
    </View>
  );
}