cartography

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Cartographie / Maps

地图功能实现

Guide pour implementer des cartes interactives dans
admin-next/
avec Leaflet, react-leaflet et Mapbox.
本指南介绍如何在
admin-next/
项目中使用Leaflet、react-leaflet和Mapbox实现交互式地图。

Stack technique

技术栈

  • leaflet : 1.9.4 - Librairie de cartographie
  • react-leaflet : 4.2.1 - Binding React pour Leaflet
  • react-leaflet-markercluster : Clustering de markers
  • Mapbox : Tiles et geocoding
  • leaflet : 1.9.4 - 地图基础库
  • react-leaflet : 4.2.1 - Leaflet的React绑定库
  • react-leaflet-markercluster : 标记点聚合组件
  • Mapbox : 瓦片地图与地理编码服务

Structure recommandee

推荐的文件结构

components/
└── MyFeature/
    └── Map/
        ├── MyFeatureMap.tsx           # Wrapper avec Suspense
        ├── MyFeatureMapContainer.tsx  # MapContainer + logique
        ├── MyFeatureMapMarkers.tsx    # Markers avec pagination
        ├── MyFeatureMarker.tsx        # Marker individuel
        └── MapControls.tsx            # Controles custom
components/
└── MyFeature/
    └── Map/
        ├── MyFeatureMap.tsx           # 包含Suspense的外层组件
        ├── MyFeatureMapContainer.tsx  # MapContainer与核心逻辑
        ├── MyFeatureMapMarkers.tsx    # 带分页的标记点组件
        ├── MyFeatureMarker.tsx        # 单个标记点组件
        └── MapControls.tsx            # 自定义控制组件

Composant Map de base

基础地图组件

typescript
// MyFeatureMap.tsx
import { Suspense } from 'react'
import { Spinner, Box } from '@cap-collectif/ui'
import dynamic from 'next/dynamic'

// IMPORTANT: Leaflet ne supporte pas le SSR
const MyFeatureMapContainer = dynamic(
  () => import('./MyFeatureMapContainer'),
  { ssr: false }
)

type Props = {
  query: MyFeatureMap_query$key
}

export const MyFeatureMap: React.FC<Props> = ({ query }) => {
  return (
    <Box height="500px" width="100%" position="relative">
      <Suspense fallback={<MapSkeleton />}>
        <MyFeatureMapContainer query={query} />
      </Suspense>
    </Box>
  )
}

const MapSkeleton = () => (
  <Box
    height="100%"
    width="100%"
    bg="gray.100"
    display="flex"
    alignItems="center"
    justifyContent="center"
  >
    <Spinner />
  </Box>
)
typescript
// MyFeatureMap.tsx
import { Suspense } from 'react'
import { Spinner, Box } from '@cap-collectif/ui'
import dynamic from 'next/dynamic'

// 重要提示:Leaflet不支持SSR
const MyFeatureMapContainer = dynamic(
  () => import('./MyFeatureMapContainer'),
  { ssr: false }
)

type Props = {
  query: MyFeatureMap_query$key
}

export const MyFeatureMap: React.FC<Props> = ({ query }) => {
  return (
    <Box height="500px" width="100%" position="relative">
      <Suspense fallback={<MapSkeleton />}>
        <MyFeatureMapContainer query={query} />
      </Suspense>
    </Box>
  )
}

const MapSkeleton = () => (
  <Box
    height="100%"
    width="100%"
    bg="gray.100"
    display="flex"
    alignItems="center"
    justifyContent="center"
  >
    <Spinner />
  </Box>
)

MapContainer

MapContainer组件

typescript
// MyFeatureMapContainer.tsx
import 'leaflet/dist/leaflet.css'
import { MapContainer, TileLayer, useMapEvents } from 'react-leaflet'
import { graphql, useFragment } from 'react-relay'
import { CapcoTileLayer, getMapboxUrl } from '@utils/leaflet'
import { useAppContext } from '@components/BackOffice/AppProvider/App.context'

const FRAGMENT = graphql`
  fragment MyFeatureMapContainer_query on Query
  @argumentDefinitions(
    bounds: { type: "String" }
    # ... autres filtres
  ) {
    ...MyFeatureMapMarkers_query @arguments(bounds: $bounds)
  }
`

const DEFAULT_CENTER: [number, number] = [46.603354, 1.888334] // France
const DEFAULT_ZOOM = 6
const MAX_ZOOM = 18

type Props = {
  query: MyFeatureMapContainer_query$key
}

export const MyFeatureMapContainer: React.FC<Props> = ({ query: queryRef }) => {
  const data = useFragment(FRAGMENT, queryRef)
  const { mapTokens } = useAppContext()

  return (
    <MapContainer
      center={DEFAULT_CENTER}
      zoom={DEFAULT_ZOOM}
      maxZoom={MAX_ZOOM}
      style={{ height: '100%', width: '100%' }}
      scrollWheelZoom={true}
      zoomControl={false} // On utilise des controles custom
    >
      <CapcoTileLayer mapTokens={mapTokens} />
      <MapEventHandler />
      <MyFeatureMapMarkers query={data} />
      <MapControls />
    </MapContainer>
  )
}

// Hook pour ecouter les events de la map
const MapEventHandler: React.FC = () => {
  const map = useMapEvents({
    moveend: () => {
      const bounds = map.getBounds()
      const boundsString = `${bounds.getSouthWest().lat},${bounds.getSouthWest().lng},${bounds.getNorthEast().lat},${bounds.getNorthEast().lng}`
      // Mettre a jour les filtres URL
    },
    zoomend: () => {
      // Logique au changement de zoom
    },
  })

  return null
}
typescript
// MyFeatureMapContainer.tsx
import 'leaflet/dist/leaflet.css'
import { MapContainer, TileLayer, useMapEvents } from 'react-leaflet'
import { graphql, useFragment } from 'react-relay'
import { CapcoTileLayer, getMapboxUrl } from '@utils/leaflet'
import { useAppContext } from '@components/BackOffice/AppProvider/App.context'

const FRAGMENT = graphql`
  fragment MyFeatureMapContainer_query on Query
  @argumentDefinitions(
    bounds: { type: "String" }
    # ... 其他筛选条件
  ) {
    ...MyFeatureMapMarkers_query @arguments(bounds: $bounds)
  }
`

const DEFAULT_CENTER: [number, number] = [46.603354, 1.888334] // 法国坐标
const DEFAULT_ZOOM = 6
const MAX_ZOOM = 18

type Props = {
  query: MyFeatureMapContainer_query$key
}

export const MyFeatureMapContainer: React.FC<Props> = ({ query: queryRef }) => {
  const data = useFragment(FRAGMENT, queryRef)
  const { mapTokens } = useAppContext()

  return (
    <MapContainer
      center={DEFAULT_CENTER}
      zoom={DEFAULT_ZOOM}
      maxZoom={MAX_ZOOM}
      style={{ height: '100%', width: '100%' }}
      scrollWheelZoom={true}
      zoomControl={false} // 使用自定义控制组件
    >
      <CapcoTileLayer mapTokens={mapTokens} />
      <MapEventHandler />
      <MyFeatureMapMarkers query={data} />
      <MapControls />
    </MapContainer>
  )
}

// 监听地图事件的Hook
const MapEventHandler: React.FC = () => {
  const map = useMapEvents({
    moveend: () => {
      const bounds = map.getBounds()
      const boundsString = `${bounds.getSouthWest().lat},${bounds.getSouthWest().lng},${bounds.getNorthEast().lat},${bounds.getNorthEast().lng}`
      // 更新URL中的筛选参数
    },
    zoomend: () => {
      // 缩放变化时的逻辑
    },
  })

  return null
}

Markers avec clustering

带聚合功能的标记点组件

typescript
// MyFeatureMapMarkers.tsx
import { Marker, Popup } from 'react-leaflet'
import MarkerClusterGroup from 'react-leaflet-markercluster'
import { graphql, usePaginationFragment } from 'react-relay'
import L from 'leaflet'

const FRAGMENT = graphql`
  fragment MyFeatureMapMarkers_query on Query
  @argumentDefinitions(
    count: { type: "Int!", defaultValue: 100 }
    cursor: { type: "String" }
    bounds: { type: "String" }
  )
  @refetchable(queryName: "MyFeatureMapMarkersPaginationQuery") {
    items(first: $count, after: $cursor, bounds: $bounds)
      @connection(key: "MyFeatureMapMarkers_items") {
      edges {
        node {
          id
          title
          address {
            lat
            lng
          }
        }
      }
    }
  }
`

// Configuration du clustering
const CLUSTER_OPTIONS = {
  spiderfyOnMaxZoom: true,
  zoomToBoundsOnClick: true,
  maxClusterRadius: 30,
  spiderfyDistanceMultiplier: 4,
  showCoverageOnHover: false,
}

export const MyFeatureMapMarkers: React.FC<Props> = ({ query: queryRef }) => {
  const { data, loadNext, hasNext } = usePaginationFragment(FRAGMENT, queryRef)

  // Charger plus de markers si necessaire
  React.useEffect(() => {
    if (hasNext) {
      loadNext(100)
    }
  }, [hasNext, loadNext])

  const markers = data.items.edges
    .map(edge => edge.node)
    .filter(node => node.address?.lat && node.address?.lng)

  return (
    <MarkerClusterGroup {...CLUSTER_OPTIONS}>
      {markers.map(item => (
        <MyFeatureMarker key={item.id} item={item} />
      ))}
    </MarkerClusterGroup>
  )
}
typescript
// MyFeatureMapMarkers.tsx
import { Marker, Popup } from 'react-leaflet'
import MarkerClusterGroup from 'react-leaflet-markercluster'
import { graphql, usePaginationFragment } from 'react-relay'
import L from 'leaflet'

const FRAGMENT = graphql`
  fragment MyFeatureMapMarkers_query on Query
  @argumentDefinitions(
    count: { type: "Int!", defaultValue: 100 }
    cursor: { type: "String" }
    bounds: { type: "String" }
  )
  @refetchable(queryName: "MyFeatureMapMarkersPaginationQuery") {
    items(first: $count, after: $cursor, bounds: $bounds)
      @connection(key: "MyFeatureMapMarkers_items") {
      edges {
        node {
          id
          title
          address {
            lat
            lng
          }
        }
      }
    }
  }
`

// 聚合配置
const CLUSTER_OPTIONS = {
  spiderfyOnMaxZoom: true,
  zoomToBoundsOnClick: true,
  maxClusterRadius: 30,
  spiderfyDistanceMultiplier: 4,
  showCoverageOnHover: false,
}

export const MyFeatureMapMarkers: React.FC<Props> = ({ query: queryRef }) => {
  const { data, loadNext, hasNext } = usePaginationFragment(FRAGMENT, queryRef)

  // 按需加载更多标记点
  React.useEffect(() => {
    if (hasNext) {
      loadNext(100)
    }
  }, [hasNext, loadNext])

  const markers = data.items.edges
    .map(edge => edge.node)
    .filter(node => node.address?.lat && node.address?.lng)

  return (
    <MarkerClusterGroup {...CLUSTER_OPTIONS}>
      {markers.map(item => (
        <MyFeatureMarker key={item.id} item={item} />
      ))}
    </MarkerClusterGroup>
  )
}

Marker custom avec icone

自定义图标标记点组件

typescript
// MyFeatureMarker.tsx
import { Marker, Popup } from 'react-leaflet'
import L from 'leaflet'
import { renderToString } from 'react-dom/server'
import { Icon, CapUIIcon } from '@cap-collectif/ui'

type Props = {
  item: {
    id: string
    title: string
    address: { lat: number; lng: number }
    category?: { color: string; icon?: string } | null
  }
}

export const MyFeatureMarker: React.FC<Props> = ({ item }) => {
  const { address, category } = item

  // Creer une icone custom avec React
  const icon = React.useMemo(() => {
    const color = category?.color ?? '#1E88E5'

    return L.divIcon({
      className: 'custom-marker', // Important: evite les styles par defaut
      html: renderToString(
        <div style={{ position: 'relative' }}>
          <Icon
            name={CapUIIcon.Pin}
            size="xl"
            color={color}
          />
        </div>
      ),
      iconSize: [30, 40],
      iconAnchor: [15, 40], // Point d'ancrage en bas au centre
      popupAnchor: [0, -40], // Popup au-dessus du marker
    })
  }, [category?.color])

  return (
    <Marker
      position={[address.lat, address.lng]}
      icon={icon}
      eventHandlers={{
        click: () => {
          // Analytics, navigation, etc.
        },
      }}
    >
      <Popup>
        <MarkerPopupContent item={item} />
      </Popup>
    </Marker>
  )
}

const MarkerPopupContent: React.FC<{ item: Props['item'] }> = ({ item }) => (
  <div style={{ minWidth: 200 }}>
    <strong>{item.title}</strong>
    {/* Contenu du popup */}
  </div>
)
typescript
// MyFeatureMarker.tsx
import { Marker, Popup } from 'react-leaflet'
import L from 'leaflet'
import { renderToString } from 'react-dom/server'
import { Icon, CapUIIcon } from '@cap-collectif/ui'

type Props = {
  item: {
    id: string
    title: string
    address: { lat: number; lng: number }
    category?: { color: string; icon?: string } | null
  }
}

export const MyFeatureMarker: React.FC<Props> = ({ item }) => {
  const { address, category } = item

  // 使用React创建自定义图标
  const icon = React.useMemo(() => {
    const color = category?.color ?? '#1E88E5'

    return L.divIcon({
      className: 'custom-marker', // 重要:覆盖默认样式
      html: renderToString(
        <div style={{ position: 'relative' }}>
          <Icon
            name={CapUIIcon.Pin}
            size="xl"
            color={color}
          />
        </div>
      ),
      iconSize: [30, 40],
      iconAnchor: [15, 40], // 锚点位于图标底部中心
      popupAnchor: [0, -40], // 弹出窗口显示在图标上方
    })
  }, [category?.color])

  return (
    <Marker
      position={[address.lat, address.lng]}
      icon={icon}
      eventHandlers={{
        click: () => {
          // 埋点统计、页面跳转等逻辑
        },
      }}
    >
      <Popup>
        <MarkerPopupContent item={item} />
      </Popup>
    </Marker>
  )
}

const MarkerPopupContent: React.FC<{ item: Props['item'] }> = ({ item }) => (
  <div style={{ minWidth: 200 }}>
    <strong>{item.title}</strong>
    {/* 弹出窗口内容 */}
  </div>
)

Controles custom

自定义控制组件

typescript
// MapControls.tsx
import { useMap } from 'react-leaflet'
import { Flex, Button, Icon, CapUIIcon } from '@cap-collectif/ui'

export const MapControls: React.FC = () => {
  const map = useMap()

  const handleZoomIn = () => map.zoomIn()
  const handleZoomOut = () => map.zoomOut()

  const handleLocate = () => {
    map.locate({ setView: true, maxZoom: 16 })
  }

  return (
    <Flex
      direction="column"
      position="absolute"
      top={4}
      right={4}
      zIndex={1000}
      gap={2}
    >
      <Button
        variant="secondary"
        size="small"
        onClick={handleLocate}
        aria-label="Ma position"
      >
        <Icon name={CapUIIcon.Location} />
      </Button>
      <Button
        variant="secondary"
        size="small"
        onClick={handleZoomIn}
        aria-label="Zoom avant"
      >
        <Icon name={CapUIIcon.Add} />
      </Button>
      <Button
        variant="secondary"
        size="small"
        onClick={handleZoomOut}
        aria-label="Zoom arriere"
      >
        <Icon name={CapUIIcon.Remove} />
      </Button>
    </Flex>
  )
}
typescript
// MapControls.tsx
import { useMap } from 'react-leaflet'
import { Flex, Button, Icon, CapUIIcon } from '@cap-collectif/ui'

export const MapControls: React.FC = () => {
  const map = useMap()

  const handleZoomIn = () => map.zoomIn()
  const handleZoomOut = () => map.zoomOut()

  const handleLocate = () => {
    map.locate({ setView: true, maxZoom: 16 })
  }

  return (
    <Flex
      direction="column"
      position="absolute"
      top={4}
      right={4}
      zIndex={1000}
      gap={2}
    >
      <Button
        variant="secondary"
        size="small"
        onClick={handleLocate}
        aria-label="定位到我的位置"
      >
        <Icon name={CapUIIcon.Location} />
      </Button>
      <Button
        variant="secondary"
        size="small"
        onClick={handleZoomIn}
        aria-label="放大地图"
      >
        <Icon name={CapUIIcon.Add} />
      </Button>
      <Button
        variant="secondary"
        size="small"
        onClick={handleZoomOut}
        aria-label="缩小地图"
      >
        <Icon name={CapUIIcon.Remove} />
      </Button>
    </Flex>
  )
}

Geolocalisation et recherche d'adresse

地理定位与地址搜索

typescript
import { useMap } from 'react-leaflet'

// Hook pour gerer la geolocalisation
const useGeolocation = () => {
  const map = useMap()
  const [isLocating, setIsLocating] = React.useState(false)
  const [error, setError] = React.useState<string | null>(null)

  const locate = React.useCallback(() => {
    setIsLocating(true)
    setError(null)

    map.locate({
      setView: true,
      maxZoom: 16,
      enableHighAccuracy: true,
    })

    map.once('locationfound', (e) => {
      setIsLocating(false)
      // e.latlng contient la position
    })

    map.once('locationerror', (e) => {
      setIsLocating(false)
      setError(e.message)
    })
  }, [map])

  return { locate, isLocating, error }
}
typescript
import { useMap } from 'react-leaflet'

// 处理地理定位的Hook
const useGeolocation = () => {
  const map = useMap()
  const [isLocating, setIsLocating] = React.useState(false)
  const [error, setError] = React.useState<string | null>(null)

  const locate = React.useCallback(() => {
    setIsLocating(true)
    setError(null)

    map.locate({
      setView: true,
      maxZoom: 16,
      enableHighAccuracy: true,
    })

    map.once('locationfound', (e) => {
      setIsLocating(false)
      // e.latlng包含当前定位坐标
    })

    map.once('locationerror', (e) => {
      setIsLocating(false)
      setError(e.message)
    })
  }, [map])

  return { locate, isLocating, error }
}

Synchronisation URL (filtres geographiques)

URL同步(地理筛选)

typescript
import { parseAsString, useQueryState } from 'nuqs'
import { useMap, useMapEvents } from 'react-leaflet'

const MapUrlSync: React.FC = () => {
  const map = useMap()
  const [bounds, setBounds] = useQueryState('bounds')
  const [center, setCenter] = useQueryState('center')

  // Mettre a jour l'URL quand la map bouge
  useMapEvents({
    moveend: () => {
      const mapBounds = map.getBounds()
      const boundsStr = [
        mapBounds.getSouth(),
        mapBounds.getWest(),
        mapBounds.getNorth(),
        mapBounds.getEast(),
      ].join(',')
      setBounds(boundsStr)

      const mapCenter = map.getCenter()
      setCenter(`${mapCenter.lat},${mapCenter.lng}`)
    },
  })

  // Restaurer la vue depuis l'URL au chargement
  React.useEffect(() => {
    if (center) {
      const [lat, lng] = center.split(',').map(Number)
      if (!isNaN(lat) && !isNaN(lng)) {
        map.setView([lat, lng], map.getZoom())
      }
    }
  }, []) // Seulement au montage

  return null
}
typescript
import { parseAsString, useQueryState } from 'nuqs'
import { useMap, useMapEvents } from 'react-leaflet'

const MapUrlSync: React.FC = () => {
  const map = useMap()
  const [bounds, setBounds] = useQueryState('bounds')
  const [center, setCenter] = useQueryState('center')

  // 地图移动时更新URL参数
  useMapEvents({
    moveend: () => {
      const mapBounds = map.getBounds()
      const boundsStr = [
        mapBounds.getSouth(),
        mapBounds.getWest(),
        mapBounds.getNorth(),
        mapBounds.getEast(),
      ].join(',')
      setBounds(boundsStr)

      const mapCenter = map.getCenter()
      setCenter(`${mapCenter.lat},${mapCenter.lng}`)
    },
  })

  // 页面加载时从URL恢复地图视图
  React.useEffect(() => {
    if (center) {
      const [lat, lng] = center.split(',').map(Number)
      if (!isNaN(lat) && !isNaN(lng)) {
        map.setView([lat, lng], map.getZoom())
      }
    }
  }, []) // 仅在组件挂载时执行

  return null
}

Bonnes pratiques

最佳实践

Performance

性能优化

  1. Limiter le nombre de markers : Utiliser la pagination Relay et charger par lots
  2. Clustering obligatoire : Toujours utiliser MarkerClusterGroup pour > 50 markers
  3. Lazy loading : Charger les markers seulement dans les bounds visibles
  4. Memoization :
    useMemo
    pour les icones custom (evite les re-renders)
  1. 限制标记点数量:使用Relay分页功能,分批加载标记点
  2. 强制使用聚合:当标记点数量超过50个时,必须使用MarkerClusterGroup
  3. 懒加载:仅加载当前可视范围内的标记点
  4. 缓存优化:使用
    useMemo
    缓存自定义图标,避免不必要的重渲染

Accessibilite

可访问性

  1. Labels ARIA sur tous les boutons de controle
  2. Alt text pour les markers si possible
  3. Navigation clavier : Les popups doivent etre accessibles
  1. 所有控制按钮添加ARIA标签
  2. 尽可能为标记点添加替代文本
  3. 键盘导航支持:弹出窗口需支持键盘访问

SSR / Hydration

SSR/水合处理

typescript
// TOUJOURS utiliser dynamic import avec ssr: false
const MapComponent = dynamic(() => import('./MapComponent'), {
  ssr: false,
  loading: () => <MapSkeleton />,
})
typescript
// 务必使用动态导入并设置ssr: false
const MapComponent = dynamic(() => import('./MapComponent'), {
  ssr: false,
  loading: () => <MapSkeleton />,
})

Gestion des erreurs

错误处理

typescript
const MapWithErrorBoundary: React.FC<Props> = (props) => (
  <ErrorBoundary
    fallback={
      <Box p="lg" bg="gray.100" textAlign="center">
        <Text>Impossible de charger la carte</Text>
        <Button onClick={() => window.location.reload()}>
          Reessayer
        </Button>
      </Box>
    }
  >
    <MyFeatureMap {...props} />
  </ErrorBoundary>
)
typescript
const MapWithErrorBoundary: React.FC<Props> = (props) => (
  <ErrorBoundary
    fallback={
      <Box p="lg" bg="gray.100" textAlign="center">
        <Text>地图加载失败</Text>
        <Button onClick={() => window.location.reload()}>
          重新加载
        </Button>
      </Box>
    }
  >
    <MyFeatureMap {...props} />
  </ErrorBoundary>
)

Utilitaires disponibles

可用工具函数

typescript
// admin-next/utils/leaflet.tsx

// Generer l'URL des tiles Mapbox
import { getMapboxUrl, CapcoTileLayer } from '@utils/leaflet'

// Parser les coordonnees depuis l'URL
import { parseLatLng, parseLatLngBounds } from '@utils/leaflet'

// Formater les GeoJSON avec styles
import { formatGeoJsons, convertToGeoJsonStyle } from '@utils/leaflet'
typescript
// admin-next/utils/leaflet.tsx

// 生成Mapbox瓦片地图URL
import { getMapboxUrl, CapcoTileLayer } from '@utils/leaflet'

// 解析URL中的坐标参数
import { parseLatLng, parseLatLngBounds } from '@utils/leaflet'

// 格式化GeoJSON并添加样式
import { formatGeoJsons, convertToGeoJsonStyle } from '@utils/leaflet'

Exemples du projet

项目示例

  • Map complete : VoteStepMapContainer.tsx
  • Markers avec clustering : VoteStepMapMarkers.tsx
  • Marker custom : ProposalMarker.tsx
  • Controles : LocateAndZoomControl.tsx
  • 完整地图组件:VoteStepMapContainer.tsx
  • 带聚合的标记点组件:VoteStepMapMarkers.tsx
  • 自定义标记点组件:ProposalMarker.tsx
  • 控制组件:LocateAndZoomControl.tsx

Checklist

检查清单

  • Import dynamique avec
    ssr: false
  • leaflet/dist/leaflet.css
    importe
  • MarkerClusterGroup pour les listes de markers
  • Icones custom avec
    L.divIcon
    et
    className: 'custom-marker'
  • Controles avec labels ARIA
  • Gestion des erreurs (ErrorBoundary)
  • Pagination Relay pour les markers
  • Synchronisation URL si necessaire
  • 使用动态导入并设置
    ssr: false
  • 导入
    leaflet/dist/leaflet.css
    样式文件
  • 标记点列表使用MarkerClusterGroup组件
  • 使用
    L.divIcon
    创建自定义图标并设置
    className: 'custom-marker'
  • 控制按钮添加ARIA标签
  • 为地图组件添加ErrorBoundary错误处理
  • 使用Relay分页加载标记点
  • 按需实现URL参数同步功能