「Vue3 + Cesium 最佳实践」完整工程化方案

一、完整项目架构图

复制代码
┌─────────────────────────────────────────────────────────────────────────────┐
│                        Vue3 + Cesium 生产级架构                              │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  ┌─────────────────────────────────────────────────────────────────────┐   │
│  │                         App.vue (根组件)                             │   │
│  │  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────────────┐ │   │
│  │  │   Header    │  │   Sidebar   │  │      CesiumViewer           │ │   │
│  │  │   组件      │  │   组件      │  │        组件                  │ │   │
│  │  └─────────────┘  └─────────────┘  └─────────────┬───────────────┘ │   │
│  └──────────────────────────────────────────────────┼─────────────────┘   │
│                                                      │                      │
│  ┌──────────────────────────────────────────────────┼─────────────────┐   │
│  │                    Pinia Store                    │                 │   │
│  │  ┌────────────┐ ┌────────────┐ ┌────────────┐    │                 │   │
│  │  │ mapStore   │ │layerStore  │ │toolsStore  │    │                 │   │
│  │  │ 地图状态   │ │ 图层状态   │ │ 工具状态   │    │                 │   │
│  │  └────────────┘ └────────────┘ └────────────┘    │                 │   │
│  └──────────────────────────────────────────────────┼─────────────────┘   │
│                                                      │                      │
│  ┌──────────────────────────────────────────────────┼─────────────────┐   │
│  │                 Composables                       │                 │   │
│  │  ┌────────────┐ ┌────────────┐ ┌────────────┐    │                 │   │
│  │  │useCesium   │ │useMapTools │ │useLayer    │    │                 │   │
│  │  │核心实例管理│ │ 地图工具   │ │ 图层管理   │    │                 │   │
│  │  └────────────┘ └────────────┘ └────────────┘    │                 │   │
│  └──────────────────────────────────────────────────┼─────────────────┘   │
│                                                      │                      │
│  ┌──────────────────────────────────────────────────┼─────────────────┐   │
│  │                    Utils                          │                 │   │
│  │  ┌────────────┐ ┌────────────┐ ┌────────────┐    │                 │   │
│  │  │coordTransform│cesiumInit │ │performance │    │                 │   │
│  │  │ 坐标转换   │ │ 初始化    │ │ 性能监控   │    │                 │   │
│  │  └────────────┘ └────────────┘ └────────────┘    │                 │   │
│  └──────────────────────────────────────────────────┼─────────────────┘   │
│                                                      │                      │
│  ┌──────────────────────────────────────────────────┼─────────────────┐   │
│  │                   Plugins                         │                 │   │
│  │  ┌────────────┐ ┌────────────┐ ┌────────────┐    │                 │   │
│  │  │cesium      │ │register    │ │global      │    │                 │   │
│  │  │plugin      │ │components  │ │directives  │    │                 │   │
│  │  └────────────┘ └────────────┘ └────────────┘    │                 │   │
│  └──────────────────────────────────────────────────┼─────────────────┘   │
│                                                      ▼                      │
│                                           ┌─────────────────┐              │
│                                           │  Cesium Viewer  │              │
│                                           │   单例实例      │              │
│                                           └─────────────────┘              │
└─────────────────────────────────────────────────────────────────────────────┘

二、项目初始化与配置

2.1 创建项目

javascript 复制代码
# 使用 pnpm(推荐)
pnpm create vite vue3-cesium-pro --template vue-ts
cd vue3-cesium-pro

# 安装核心依赖
pnpm add cesium pinia vue-router@4

# 安装开发依赖
pnpm add -D vite-plugin-cesium @types/node sass unplugin-auto-import unplugin-vue-components

# 安装 GIS 工具库
pnpm add turf @turf/turf

2.2 完整 Vite 配置

javascript 复制代码
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import cesium from 'vite-plugin-cesium'
import path from 'path'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'

export default defineConfig({
  plugins: [
    vue(),
    cesium(),
    AutoImport({
      imports: ['vue', 'vue-router', 'pinia'],
      dts: 'src/auto-imports.d.ts',
      eslintrc: {
        enabled: true
      }
    }),
    Components({
      dts: 'src/components.d.ts',
      dirs: ['src/components']
    })
  ],
  
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
      '#': path.resolve(__dirname, './src/types'),
      cesium: 'cesium'
    }
  },
  
  define: {
    CESIUM_BASE_URL: JSON.stringify('/cesium'),
    __APP_VERSION__: JSON.stringify(process.env.npm_package_version)
  },
  
  server: {
    port: 5173,
    open: true,
    host: '0.0.0.0',
    proxy: {
      '/api': {
        target: 'https://api.example.com',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, '')
      },
      '/tianditu': {
        target: 'https://t0.tianditu.gov.cn',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/tianditu/, '')
      }
    }
  },
  
  build: {
    target: 'esnext',
    sourcemap: process.env.NODE_ENV === 'development',
    rollupOptions: {
      output: {
        manualChunks: {
          cesium: ['cesium'],
          turf: ['@turf/turf'],
          vendor: ['vue', 'vue-router', 'pinia']
        }
      }
    },
    chunkSizeWarningLimit: 2000
  },
  
  optimizeDeps: {
    include: ['cesium', '@turf/turf']
  }
})

2.3 环境变量配置

javascript 复制代码
# .env.development
VITE_APP_TITLE=Vue3 Cesium 开发环境
VITE_CESIUM_TOKEN=your_cesium_ion_token
VITE_TIANDITU_TOKEN=your_tianditu_token
VITE_API_BASE_URL=http://localhost:3000/api

# .env.production
VITE_APP_TITLE=Vue3 Cesium 生产环境
VITE_CESIUM_TOKEN=your_production_token
VITE_TIANDITU_TOKEN=your_production_tianditu_token
VITE_API_BASE_URL=https://api.example.com/api

2.4 TypeScript 配置

javascript 复制代码
// tsconfig.json
{
  "compilerOptions": {
    "target": "ESNext",
    "useDefineForClassFields": true,
    "module": "ESNext",
    "moduleResolution": "Node",
    "strict": true,
    "jsx": "preserve",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "esModuleInterop": true,
    "lib": ["ESNext", "DOM"],
    "skipLibCheck": true,
    "noEmit": true,
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "#/*": ["src/types/*"]
    },
    "types": ["vite/client", "node", "cesium"]
  },
  "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
  "references": [{ "path": "./tsconfig.node.json" }]
}

三、目录结构详解

javascript 复制代码
src/
├── api/                      # API 接口层
│   ├── modules/
│   │   ├── map.ts           # 地图服务接口
│   │   └── data.ts          # 数据服务接口
│   └── request.ts           # axios 封装
│
├── assets/                   # 静态资源
│   ├── icons/
│   ├── images/
│   └── styles/
│       ├── variables.scss   # SCSS 变量
│       ├── mixins.scss      # SCSS 混入
│       └── global.scss      # 全局样式
│
├── components/               # 公共组件
│   ├── common/
│   │   ├── Loading.vue
│   │   └── ErrorBoundary.vue
│   ├── cesium/
│   │   ├── CesiumViewer.vue      # 主地球组件
│   │   ├── LayerManager.vue      # 图层管理
│   │   ├── MapTools.vue          # 地图工具条
│   │   └── CoordinatesBar.vue    # 坐标栏
│   └── business/
│       ├── SearchPanel.vue       # 搜索面板
│       └── MeasurePanel.vue      # 测量面板
│
├── composables/              # 组合式函数
│   ├── core/
│   │   ├── useCesium.ts      # Cesium 核心
│   │   ├── useViewer.ts      # Viewer 管理
│   │   └── useScene.ts       # 场景管理
│   ├── tools/
│   │   ├── useMeasure.ts     # 测量工具
│   │   ├── useScreenshot.ts  # 截图工具
│   │   └── useDraw.ts        # 绘图工具
│   └── data/
│       ├── useGeoJson.ts     # GeoJSON 处理
│       └── use3DTiles.ts     # 3D Tiles 处理
│
├── config/                   # 配置文件
│   ├── cesium.config.ts      # Cesium 配置
│   ├── layers.config.ts      # 图层配置
│   └── map.config.ts         # 地图配置
│
├── hooks/                    # Vue3 Hooks (VueUse 扩展)
│   ├── useMapEvent.ts
│   └── useMapState.ts
│
├── layouts/                  # 布局组件
│   ├── DefaultLayout.vue
│   └── MapLayout.vue
│
├── plugins/                  # 插件
│   ├── cesium.plugin.ts      # Cesium 插件
│   └── components.plugin.ts  # 组件注册
│
├── router/                   # 路由配置
│   └── index.ts
│
├── stores/                   # Pinia 状态管理
│   ├── modules/
│   │   ├── map.store.ts      # 地图状态
│   │   ├── layer.store.ts    # 图层状态
│   │   ├── tools.store.ts    # 工具状态
│   │   └── user.store.ts     # 用户状态
│   └── index.ts
│
├── types/                    # TypeScript 类型定义
│   ├── cesium.d.ts          # Cesium 类型扩展
│   ├── global.d.ts          # 全局类型
│   └── api.d.ts             # API 类型
│
├── utils/                    # 工具函数
│   ├── cesium/
│   │   ├── coordTransform.ts # 坐标转换
│   │   ├── loaders.ts       # 数据加载器
│   │   └── helpers.ts       # 辅助函数
│   ├── format.ts            # 格式化工具
│   ├── validate.ts          # 验证工具
│   └── performance.ts       # 性能监控
│
├── views/                    # 页面视图
│   ├── HomeView.vue
│   └── AboutView.vue
│
├── App.vue
├── main.ts
└── env.d.ts

四、核心代码实现

4.1 Cesium 配置 config/cesium.config.ts

javascript 复制代码
import * as Cesium from 'cesium'
import 'cesium/Build/Cesium/Widgets/widgets.css'

export interface CesiumConfig {
  defaultAccessToken: string
  baseUrl: string
  viewerOptions: Cesium.Viewer.ConstructorOptions
  terrainOptions: Cesium.TerrainProvider
  imageryOptions: Cesium.ImageryProvider
}

// 默认 Viewer 配置
export const defaultViewerOptions: Cesium.Viewer.ConstructorOptions = {
  // 基础底图
  imageryProvider: false,  // 先不设置,后面动态添加
  
  // 地形
  terrainProvider: new Cesium.EllipsoidTerrainProvider(),
  
  // 控件配置
  animation: false,// 是否显示播放动画、计时
  baseLayerPicker: false,// 是否显示图层选择
  fullscreenButton: false,// 是否显示全屏按钮
  vrButton: false,
  geocoder: false,// 是否显示查询按钮
  homeButton: false,// 不显示home按钮
  infoBox: true, // 去掉对话框警告
  sceneModePicker: true,// 控制查看器的显示模式(3D、2.5D、2D是否显示)
  selectionIndicator: true,
  timeline: false,// 是否显示时间轴
  navigationHelpButton: false,// 是否显示帮助按钮
  navigationInstructionsInitiallyVisible: false,
  
  // 场景配置
  scene3DOnly: true,
  sceneMode: Cesium.SceneMode.SCENE3D,
  
  // 性能优化
  requestRenderMode: true,
  maximumRenderTimeChange: 0.1,
  
  // 视觉效果(按需开启)
  shadows: false,
  fog: false,
  atmosphere: false,
  skyBox: false,
  skyAtmosphere: false,
  
  // 地形夸张
  terrainExaggeration: 1.0,
  
  // 使用默认光照
  useDefaultRenderLoop: true,
  
  // 目标帧率
  targetFrameRate: 60,
  
  // 分辨率
  resolutionScale: 1.0,
  
  // 自动控制相机
  automaticallyTrackDataSourceClocks: false,
  
  // 数据源
  dataSources: undefined,
  
  // 实体集合
  entities: undefined,
  
  // 是否显示帧率
  showRenderLoopErrors: true,
  
  // 是否使用浏览器分辨率
  useBrowserRecommendedResolution: true
}

// 默认影像图层配置
export const defaultImageryProvider = new Cesium.UrlTemplateImageryProvider({
  url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
  subdomains: ['a', 'b', 'c'],
  minimumLevel: 1,
  maximumLevel: 19,
  credit: '© OpenStreetMap contributors'
})

// 天地图影像配置
export const tiandituImageryProvider = (token: string) => new Cesium.UrlTemplateImageryProvider({
  url: `https://t0.tianditu.gov.cn/DataServer?T=img_w&x={x}&y={y}&l={z}&tk=${token}`,
  subdomains: ['t0', 't1', 't2', 't3', 't4', 't5', 't6', 't7'],
  minimumLevel: 1,
  maximumLevel: 18,
  credit: '天地图'
})

// 初始化 Cesium 全局配置
export function initCesiumConfig() {
  // 设置 Ion token
  const token = import.meta.env.VITE_CESIUM_TOKEN
  if (token) {
    Cesium.Ion.defaultAccessToken = token
  }
  
  // 设置基础路径
  window.CESIUM_BASE_URL = '/cesium'
  
  // 全局默认配置
  Cesium.Resource.defaultImage.crossOrigin = 'Anonymous'
  Cesium.Resource.defaultImage.overrideMimeType = 'image/png'
  
  // 日志级别(生产环境关闭)
  if (import.meta.env.PROD) {
    Cesium.buildModuleUrl.setBaseUrl('/cesium/')
  }
}

4.2 核心组合式函数 composables/core/useCesium.ts

javascript 复制代码
import { ref, shallowRef, readonly, onUnmounted, type Ref } from 'vue'
import * as Cesium from 'cesium'
import { defaultViewerOptions, initCesiumConfig } from '@/config/cesium.config'

export interface UseCesiumOptions {
  containerId?: string
  viewerOptions?: Cesium.Viewer.ConstructorOptions
  autoInit?: boolean
}

export function useCesium(options: UseCesiumOptions = {}) {
  const {
    containerId = 'cesium-container',
    viewerOptions = {},
    autoInit = true
  } = options
  
  // 使用 shallowRef 避免深度响应式影响性能
  const viewer = shallowRef<Cesium.Viewer | null>(null)
  const isLoaded = ref(false)
  const isLoading = ref(false)
  const error = ref<Error | null>(null)
  
  // 合并配置
  const mergedOptions = { ...defaultViewerOptions, ...viewerOptions }
  
  // 初始化 Viewer
  const initViewer = async (): Promise<Cesium.Viewer> => {
    if (viewer.value) {
      console.warn('Viewer already initialized')
      return viewer.value
    }
    
    isLoading.value = true
    error.value = null
    
    try {
      // 初始化 Cesium 配置
      initCesiumConfig()
      
      // 确保容器存在
      const container = document.getElementById(containerId)
      if (!container) {
        throw new Error(`Container element #${containerId} not found`)
      }
      
      // 创建 Viewer
      viewer.value = new Cesium.Viewer(containerId, mergedOptions)
      
      // 添加默认影像图层
      if (!mergedOptions.imageryProvider) {
        const imageryProvider = new Cesium.UrlTemplateImageryProvider({
          url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
          subdomains: ['a', 'b', 'c']
        })
        viewer.value.imageryLayers.addImageryProvider(imageryProvider)
      }
      
      // 监听加载完成
      const checkLoaded = () => {
        if (viewer.value?.scene.globe.tilesLoaded) {
          isLoaded.value = true
          isLoading.value = false
          viewer.value?.scene.globe.tileLoadProgressEvent.removeEventListener(checkLoaded)
        }
      }
      
      viewer.value.scene.globe.tileLoadProgressEvent.addEventListener(checkLoaded)
      
      // 设置默认视角
      setDefaultView()
      
      console.log('✅ Cesium Viewer initialized successfully')
      return viewer.value
      
    } catch (err) {
      error.value = err as Error
      console.error('Failed to initialize Cesium Viewer:', err)
      throw err
    } finally {
      isLoading.value = false
    }
  }
  
  // 设置默认视角
  const setDefaultView = () => {
    if (!viewer.value) return
    
    viewer.value.camera.setView({
      destination: Cesium.Cartesian3.fromDegrees(116.397428, 39.90923, 5000),
      orientation: {
        heading: Cesium.Math.toRadians(0),
        pitch: Cesium.Math.toRadians(-30),
        roll: 0
      }
    })
  }
  
  // 销毁 Viewer
  const destroyViewer = () => {
    if (viewer.value) {
      // 清理所有图元
      viewer.value.scene.primitives.removeAll()
      
      // 清理所有实体
      viewer.value.entities.removeAll()
      
      // 清理数据源
      viewer.value.dataSources.removeAll()
      
      // 销毁 Viewer
      viewer.value.destroy()
      viewer.value = null
      
      isLoaded.value = false
      console.log('🗑️ Cesium Viewer destroyed')
    }
  }
  
  // 重置视图
  const resetView = () => {
    if (!viewer.value) return
    setDefaultView()
  }
  
  // 调整大小
  const resize = () => {
    if (viewer.value) {
      viewer.value.resize()
    }
  }
  
  // 获取场景
  const getScene = () => viewer.value?.scene
  
  // 获取相机
  const getCamera = () => viewer.value?.camera
  
  // 自动清理
  onUnmounted(() => {
    destroyViewer()
    window.removeEventListener('resize', resize)
  })
  
  // 自动初始化
  if (autoInit) {
    initViewer().catch(console.error)
  }
  
  return {
    viewer: readonly(viewer) as Ref<Cesium.Viewer | null>,
    isLoaded: readonly(isLoaded),
    isLoading: readonly(isLoading),
    error: readonly(error),
    initViewer,
    destroyViewer,
    resetView,
    resize,
    getScene,
    getCamera
  }
}

4.3 Pinia 状态管理 stores/modules/map.store.ts

javascript 复制代码
import { defineStore } from 'pinia'
import * as Cesium from 'cesium'

interface MapState {
  viewer: Cesium.Viewer | null
  center: {
    lng: number
    lat: number
    height: number
  }
  zoom: number
  pitch: number
  heading: number
  is3DMode: boolean
  showGrid: boolean
  showCompass: boolean
  selectedEntityId: string | null
}

interface MapActions {
  setViewer: (viewer: Cesium.Viewer) => void
  setCenter: (lng: number, lat: number, height?: number) => void
  setZoom: (zoom: number) => void
  setPitch: (pitch: number) => void
  setHeading: (heading: number) => void
  toggle3DMode: () => void
  toggleGrid: () => void
  flyTo: (lng: number, lat: number, height?: number, duration?: number) => void
  selectEntity: (id: string | null) => void
  getCurrentView: () => {
    center: { lng: number; lat: number; height: number }
    heading: number
    pitch: number
    roll: number
  }
}

export const useMapStore = defineStore<'map', MapState, {}, MapActions>('map', {
  state: (): MapState => ({
    viewer: null,
    center: {
      lng: 116.397428,
      lat: 39.90923,
      height: 5000
    },
    zoom: 12,
    pitch: -30,
    heading: 0,
    is3DMode: true,
    showGrid: false,
    showCompass: true,
    selectedEntityId: null
  }),
  
  actions: {
    setViewer(viewer: Cesium.Viewer) {
      this.viewer = viewer
      
      // 监听相机变化,同步状态
      viewer.camera.changed.addEventListener(() => {
        const position = viewer.camera.positionCartographic
        this.center = {
          lng: Cesium.Math.toDegrees(position.longitude),
          lat: Cesium.Math.toDegrees(position.latitude),
          height: position.height
        }
        this.heading = Cesium.Math.toDegrees(viewer.camera.heading)
        this.pitch = Cesium.Math.toDegrees(viewer.camera.pitch)
      })
    },
    
    setCenter(lng: number, lat: number, height?: number) {
      this.center = {
        lng,
        lat,
        height: height ?? this.center.height
      }
      this.flyTo(lng, lat, this.center.height)
    },
    
    setZoom(zoom: number) {
      this.zoom = zoom
      // 根据 zoom 计算高度
      const height = Math.pow(2, 20 - zoom) * 100
      this.flyTo(this.center.lng, this.center.lat, height)
    },
    
    setPitch(pitch: number) {
      this.pitch = pitch
      if (this.viewer) {
        this.viewer.camera.setView({
          orientation: {
            heading: Cesium.Math.toRadians(this.heading),
            pitch: Cesium.Math.toRadians(pitch),
            roll: 0
          }
        })
      }
    },
    
    setHeading(heading: number) {
      this.heading = heading
      if (this.viewer) {
        this.viewer.camera.setView({
          orientation: {
            heading: Cesium.Math.toRadians(heading),
            pitch: Cesium.Math.toRadians(this.pitch),
            roll: 0
          }
        })
      }
    },
    
    toggle3DMode() {
      if (!this.viewer) return
      this.is3DMode = !this.is3DMode
      this.viewer.scene.morphTo3D()
    },
    
    toggleGrid() {
      if (!this.viewer) return
      this.showGrid = !this.showGrid
      // 实现网格显示逻辑
    },
    
    flyTo(lng: number, lat: number, height: number = 5000, duration: number = 1.5) {
      if (!this.viewer) return
      
      this.viewer.camera.flyTo({
        destination: Cesium.Cartesian3.fromDegrees(lng, lat, height),
        orientation: {
          heading: Cesium.Math.toRadians(this.heading),
          pitch: Cesium.Math.toRadians(this.pitch),
          roll: 0
        },
        duration
      })
      
      this.center = { lng, lat, height }
    },
    
    selectEntity(id: string | null) {
      this.selectedEntityId = id
    },
    
    getCurrentView() {
      if (!this.viewer) {
        return {
          center: this.center,
          heading: this.heading,
          pitch: this.pitch,
          roll: 0
        }
      }
      
      const position = this.viewer.camera.positionCartographic
      return {
        center: {
          lng: Cesium.Math.toDegrees(position.longitude),
          lat: Cesium.Math.toDegrees(position.latitude),
          height: position.height
        },
        heading: Cesium.Math.toDegrees(this.viewer.camera.heading),
        pitch: Cesium.Math.toDegrees(this.viewer.camera.pitch),
        roll: Cesium.Math.toDegrees(this.viewer.camera.roll)
      }
    }
  },
  
  getters: {
    viewState: (state) => ({
      center: state.center,
      zoom: state.zoom,
      pitch: state.pitch,
      heading: state.heading,
      is3D: state.is3DMode
    }),
    
    currentCenter: (state) => `${state.center.lng.toFixed(6)}, ${state.center.lat.toFixed(6)}`
  }
})

4.4 主地球组件 components/cesium/CesiumViewer.vue

javascript 复制代码
<template>
  <div class="cesium-viewer-container">
    <!-- Cesium 容器 -->
    <div :id="containerId" class="cesium-container" />
    
    <!-- 加载状态 -->
    <Transition name="fade">
      <div v-if="isLoading" class="loading-overlay">
        <div class="loading-content">
          <div class="loading-spinner" />
          <p class="loading-text">🌍 三维地球加载中...</p>
          <p class="loading-progress" v-if="loadProgress > 0">
            瓦片加载: {{ loadProgress }}%
          </p>
        </div>
      </div>
    </Transition>
    
    <!-- 错误提示 -->
    <Transition name="slide-down">
      <div v-if="error" class="error-toast">
        <span class="error-icon">⚠️</span>
        <span class="error-message">{{ error.message }}</span>
        <button class="error-close" @click="dismissError">×</button>
      </div>
    </Transition>
    
    <!-- 坐标栏 -->
    <CoordinatesBar 
      v-if="showCoordinates"
      :viewer="viewer"
      :is-ready="isLoaded"
    />
    
    <!-- 性能监控(开发环境) -->
    <PerformancePanel 
      v-if="showPerformance && import.meta.env.DEV"
      :viewer="viewer"
    />
  </div>
</template>

<script setup lang="ts">
import { watch, onMounted, onUnmounted, ref, provide } from 'vue'
import { storeToRefs } from 'pinia'
import { useCesium } from '@/composables/core/useCesium'
import { useMapStore } from '@/stores/modules/map.store'
import CoordinatesBar from './CoordinatesBar.vue'
import PerformancePanel from './PerformancePanel.vue'

interface Props {
  containerId?: string
  showCoordinates?: boolean
  showPerformance?: boolean
  autoResetView?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  containerId: 'cesium-container',
  showCoordinates: true,
  showPerformance: false,
  autoResetView: true
})

// 使用组合式函数
const {
  viewer,
  isLoaded,
  isLoading,
  error,
  initViewer,
  destroyViewer,
  resize
} = useCesium({
  containerId: props.containerId,
  autoInit: true
})

// Pinia Store
const mapStore = useMapStore()
const { center, heading, pitch } = storeToRefs(mapStore)

// 本地状态
const loadProgress = ref(0)
const showError = ref(true)

// 监听瓦片加载进度
const setupProgressListener = () => {
  if (!viewer.value) return
  
  const handleProgress = (tilesLeft: number) => {
    const total = viewer.value?.scene.globe._surface._tileLoadQueueHigh.length || 0
    if (total > 0) {
      loadProgress.value = Math.round(((total - tilesLeft) / total) * 100)
    }
  }
  
  viewer.value.scene.globe.tileLoadProgressEvent.addEventListener(handleProgress)
  return () => {
    viewer.value?.scene.globe.tileLoadProgressEvent.removeEventListener(handleProgress)
  }
}

// 监听 Viewer 就绪
watch(isLoaded, (ready) => {
  if (ready && viewer.value) {
    // 注册到 Store
    mapStore.setViewer(viewer.value)
    
    // 设置进度监听
    setupProgressListener()
    
    // 触发就绪事件
    emit('ready', viewer.value)
    
    // 自动重置视角
    if (props.autoResetView) {
      mapStore.flyTo(center.value.lng, center.value.lat, center.value.height)
    }
  }
})

// 监听窗口大小变化
onMounted(() => {
  window.addEventListener('resize', resize)
})

onUnmounted(() => {
  window.removeEventListener('resize', resize)
  destroyViewer()
})

// 暴露方法给父组件
defineExpose({
  viewer,
  isLoaded,
  initViewer,
  destroyViewer,
  resize
})

// 事件
const emit = defineEmits<{
  ready: [viewer: Cesium.Viewer]
  error: [error: Error]
  loadProgress: [progress: number]
}>()

// 关闭错误提示
const dismissError = () => {
  showError.value = false
}

// Provide viewer 给子组件
provide('cesiumViewer', viewer)
provide('cesiumReady', isLoaded)
</script>

<style lang="scss" scoped>
.cesium-viewer-container {
  position: relative;
  width: 100%;
  height: 100%;
  overflow: hidden;
}

.cesium-container {
  width: 100%;
  height: 100%;
}

// 加载遮罩
.loading-overlay {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
  display: flex;
  justify-content: center;
  align-items: center;
  z-index: 1000;
  backdrop-filter: blur(4px);
}

.loading-content {
  text-align: center;
  color: #fff;
}

.loading-spinner {
  width: 48px;
  height: 48px;
  margin: 0 auto 20px;
  border: 3px solid rgba(66, 184, 131, 0.3);
  border-top-color: #42b883;
  border-radius: 50%;
  animation: spin 1s linear infinite;
}

.loading-text {
  font-size: 16px;
  margin-bottom: 8px;
  letter-spacing: 1px;
}

.loading-progress {
  font-size: 12px;
  color: rgba(255, 255, 255, 0.6);
}

@keyframes spin {
  to {
    transform: rotate(360deg);
  }
}

// 错误提示
.error-toast {
  position: absolute;
  top: 20px;
  right: 20px;
  background: rgba(220, 53, 69, 0.95);
  backdrop-filter: blur(10px);
  padding: 12px 20px;
  border-radius: 8px;
  color: white;
  display: flex;
  align-items: center;
  gap: 12px;
  z-index: 1001;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
  animation: slideInRight 0.3s ease;
}

.error-icon {
  font-size: 18px;
}

.error-message {
  font-size: 14px;
}

.error-close {
  background: none;
  border: none;
  color: white;
  font-size: 20px;
  cursor: pointer;
  padding: 0 4px;
  opacity: 0.7;
  transition: opacity 0.2s;
  
  &:hover {
    opacity: 1;
  }
}

// 动画
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.3s ease;
}

.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}

.slide-down-enter-active,
.slide-down-leave-active {
  transition: all 0.3s ease;
}

.slide-down-enter-from,
.slide-down-leave-to {
  transform: translateY(-100%);
  opacity: 0;
}

@keyframes slideInRight {
  from {
    transform: translateX(100%);
    opacity: 0;
  }
  to {
    transform: translateX(0);
    opacity: 1;
  }
}
</style>

4.5 坐标栏组件 components/cesium/CoordinatesBar.vue

javascript 复制代码
<template>
  <div v-if="isReady" class="coordinates-bar">
    <div class="coord-group">
      <span class="coord-label">📍 经度</span>
      <span class="coord-value">{{ lng.toFixed(6) }}°</span>
    </div>
    <div class="coord-divider" />
    <div class="coord-group">
      <span class="coord-label">📐 纬度</span>
      <span class="coord-value">{{ lat.toFixed(6) }}°</span>
    </div>
    <div class="coord-divider" />
    <div class="coord-group">
      <span class="coord-label">📏 海拔</span>
      <span class="coord-value">{{ height.toFixed(2) }} m</span>
    </div>
    <div class="coord-divider" />
    <div class="coord-group">
      <span class="coord-label">🗺️ 投影坐标</span>
      <span class="coord-value">{{ x.toFixed(0) }}, {{ y.toFixed(0) }}</span>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted } from 'vue'
import * as Cesium from 'cesium'
import type { CesiumViewer } from '@/types/cesium'

const props = defineProps<{
  viewer: Cesium.Viewer | null
  isReady: boolean
}>()

const lng = ref(0)
const lat = ref(0)
const height = ref(0)
const x = ref(0)
const y = ref(0)

let handler: Cesium.ScreenSpaceEventHandler | null = null

// 更新鼠标位置
const updateMousePosition = (movement: any) => {
  if (!props.viewer) return
  
  const ellipsoid = props.viewer.scene.globe.ellipsoid
  const cartesian = props.viewer.camera.pickEllipsoid(
    movement.endPosition,
    ellipsoid
  )
  
  if (cartesian) {
    const cartographic = Cesium.Cartographic.fromCartesian(cartesian)
    lng.value = Cesium.Math.toDegrees(cartographic.longitude)
    lat.value = Cesium.Math.toDegrees(cartographic.latitude)
    height.value = cartographic.height
    
    // 投影坐标(Web Mercator)
    const webMercator = Cesium.WebMercatorProjection.geographicToCartesian(
      cartographic
    )
    x.value = webMercator.x
    y.value = webMercator.y
  }
}

// 设置鼠标监听
const setupMouseTracking = () => {
  if (!props.viewer || !props.viewer.canvas) return
  
  handler = new Cesium.ScreenSpaceEventHandler(props.viewer.canvas)
  handler.setInputAction(updateMousePosition, Cesium.ScreenSpaceEventType.MOUSE_MOVE)
}

// 清理鼠标监听
const cleanupMouseTracking = () => {
  if (handler) {
    handler.destroy()
    handler = null
  }
}

watch(() => props.isReady, (ready) => {
  if (ready) {
    setupMouseTracking()
  } else {
    cleanupMouseTracking()
  }
})

onUnmounted(() => {
  cleanupMouseTracking()
})
</script>

<style lang="scss" scoped>
.coordinates-bar {
  position: absolute;
  bottom: 20px;
  left: 20px;
  background: rgba(0, 0, 0, 0.75);
  backdrop-filter: blur(10px);
  padding: 8px 16px;
  border-radius: 8px;
  display: flex;
  align-items: center;
  gap: 12px;
  font-family: 'JetBrains Mono', 'Courier New', monospace;
  font-size: 12px;
  z-index: 100;
  pointer-events: none;
  border-left: 3px solid #42b883;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}

.coord-group {
  display: flex;
  align-items: center;
  gap: 6px;
}

.coord-label {
  color: rgba(255, 255, 255, 0.6);
  font-size: 11px;
}

.coord-value {
  color: #ffd700;
  font-weight: 500;
}

.coord-divider {
  width: 1px;
  height: 20px;
  background: rgba(255, 255, 255, 0.2);
}

// 响应式
@media (max-width: 768px) {
  .coordinates-bar {
    display: none;
  }
}
</style>

4.6 地图工具组件 components/cesium/MapTools.vue

javascript 复制代码
<template>
  <div class="map-tools">
    <div class="tools-group">
      <button 
        class="tool-btn" 
        :class="{ active: activeTool === 'measure' }"
        @click="toggleMeasure"
        title="测量距离"
      >
        📏
      </button>
      <button 
        class="tool-btn" 
        @click="takeScreenshot"
        title="截图"
      >
        📸
      </button>
      <button 
        class="tool-btn" 
        @click="resetView"
        title="重置视角"
      >
        🎯
      </button>
      <button 
        class="tool-btn" 
        @click="toggle3DMode"
        title="切换2D/3D"
      >
        🗺️
      </button>
    </div>
    
    <!-- 测量结果显示 -->
    <div v-if="measureResult" class="measure-result">
      <span>📐 距离: {{ measureResult.toFixed(2) }} 米</span>
      <button class="close-btn" @click="clearMeasure">×</button>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, inject, watch } from 'vue'
import * as Cesium from 'cesium'
import { useMapStore } from '@/stores/modules/map.store'

const viewer = inject<Cesium.Viewer | null>('cesiumViewer')
const mapStore = useMapStore()

const activeTool = ref<string>('')
const measureResult = ref<number | null>(null)
let measurePoints: Cesium.Entity[] = []
let measureLine: Cesium.Entity | null = null
let measureHandler: Cesium.ScreenSpaceEventHandler | null = null

// 测量距离
const startMeasure = () => {
  if (!viewer) return
  
  const points: Cesium.Cartesian3[] = []
  
  measureHandler = new Cesium.ScreenSpaceEventHandler(viewer.canvas)
  
  measureHandler.setInputAction((click: any) => {
    const cartesian = viewer.camera.pickEllipsoid(
      click.position,
      viewer.scene.globe.ellipsoid
    )
    
    if (!cartesian) return
    
    points.push(cartesian)
    
    // 添加标记点
    const point = viewer.entities.add({
      position: cartesian,
      point: {
        pixelSize: 10,
        color: Cesium.Color.RED,
        outlineColor: Cesium.Color.WHITE,
        outlineWidth: 2
      }
    })
    measurePoints.push(point)
    
    // 更新线段
    if (points.length >= 2) {
      if (measureLine) {
        viewer.entities.remove(measureLine)
      }
      
      measureLine = viewer.entities.add({
        polyline: {
          positions: points,
          width: 3,
          material: Cesium.Color.fromCssColorString('#ff6b6b'),
          arcType: Cesium.ArcType.GEODESIC
        }
      })
      
      // 计算总距离
      let totalDistance = 0
      for (let i = 1; i < points.length; i++) {
        totalDistance += Cesium.Cartesian3.distance(points[i - 1], points[i])
      }
      measureResult.value = totalDistance
    }
    
  }, Cesium.ScreenSpaceEventType.LEFT_CLICK)
  
  // 双击结束测量
  measureHandler.setInputAction(() => {
    stopMeasure()
  }, Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK)
}

const stopMeasure = () => {
  if (measureHandler) {
    measureHandler.destroy()
    measureHandler = null
  }
  activeTool.value = ''
}

const clearMeasure = () => {
  if (!viewer) return
  
  measurePoints.forEach(point => viewer.entities.remove(point))
  if (measureLine) viewer.entities.remove(measureLine)
  measurePoints = []
  measureLine = null
  measureResult.value = null
  stopMeasure()
}

const toggleMeasure = () => {
  if (activeTool.value === 'measure') {
    clearMeasure()
  } else {
    clearMeasure()
    activeTool.value = 'measure'
    startMeasure()
  }
}

// 截图
const takeScreenshot = () => {
  if (!viewer) return
  
  const canvas = viewer.scene.canvas
  const image = canvas.toDataURL('image/png')
  
  const link = document.createElement('a')
  link.download = `cesium-screenshot-${Date.now()}.png`
  link.href = image
  link.click()
}

// 重置视角
const resetView = () => {
  mapStore.resetView()
}

// 切换2D/3D
const toggle3DMode = () => {
  if (!viewer) return
  mapStore.toggle3DMode()
}
</script>

<style lang="scss" scoped>
.map-tools {
  position: absolute;
  bottom: 20px;
  right: 20px;
  z-index: 100;
}

.tools-group {
  background: rgba(0, 0, 0, 0.75);
  backdrop-filter: blur(10px);
  border-radius: 12px;
  padding: 8px;
  display: flex;
  gap: 4px;
  border: 1px solid rgba(255, 255, 255, 0.1);
}

.tool-btn {
  width: 40px;
  height: 40px;
  border: none;
  background: rgba(255, 255, 255, 0.1);
  border-radius: 8px;
  font-size: 20px;
  cursor: pointer;
  transition: all 0.2s;
  color: white;
  
  &:hover {
    background: rgba(66, 184, 131, 0.5);
    transform: scale(1.05);
  }
  
  &.active {
    background: #42b883;
    box-shadow: 0 0 8px rgba(66, 184, 131, 0.5);
  }
}

.measure-result {
  position: absolute;
  bottom: 70px;
  right: 0;
  background: rgba(0, 0, 0, 0.85);
  backdrop-filter: blur(10px);
  padding: 8px 16px;
  border-radius: 8px;
  color: #ffd700;
  font-family: monospace;
  font-size: 14px;
  display: flex;
  align-items: center;
  gap: 12px;
  white-space: nowrap;
  border-right: 2px solid #ff6b6b;
}

.close-btn {
  background: none;
  border: none;
  color: white;
  cursor: pointer;
  font-size: 18px;
  padding: 0 4px;
  opacity: 0.7;
  
  &:hover {
    opacity: 1;
  }
}
</style>

4.7 主入口文件 main.ts

javascript 复制代码
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import App from './App.vue'
import router from './router'
import './assets/styles/global.scss'

// 创建应用
const app = createApp(App)

// Pinia 状态管理
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
app.use(pinia)

// 路由
app.use(router)

// 全局配置
app.config.globalProperties.$version = __APP_VERSION__

// 挂载
app.mount('#app')

4.8 使用示例 App.vue

javascript 复制代码
<template>
  <div class="app">
    <!-- 头部导航 -->
    <header class="app-header">
      <div class="logo">
        <img src="/cesium-logo.svg" alt="Cesium" />
        <span>Vue3 + Cesium 最佳实践</span>
      </div>
      <div class="header-actions">
        <button @click="toggleSidebar" class="sidebar-toggle">
          ☰
        </button>
      </div>
    </header>
    
    <!-- 主体内容 -->
    <div class="app-main">
      <!-- 侧边栏 -->
      <aside v-show="sidebarVisible" class="app-sidebar">
        <LayerManager />
        <SearchPanel />
      </aside>
      
      <!-- 地图区域 -->
      <div class="map-container">
        <CesiumViewer 
          ref="cesiumViewerRef"
          :show-coordinates="true"
          :show-performance="true"
          @ready="onViewerReady"
        />
        <MapTools />
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import CesiumViewer from '@/components/cesium/CesiumViewer.vue'
import LayerManager from '@/components/cesium/LayerManager.vue'
import MapTools from '@/components/cesium/MapTools.vue'
import SearchPanel from '@/components/business/SearchPanel.vue'
import { useMapStore } from '@/stores/modules/map.store'

const sidebarVisible = ref(true)
const cesiumViewerRef = ref()
const mapStore = useMapStore()

const toggleSidebar = () => {
  sidebarVisible.value = !sidebarVisible.value
}

const onViewerReady = (viewer: Cesium.Viewer) => {
  console.log('Viewer is ready:', viewer)
  // 可以在这里做一些初始化操作
}
</script>

<style lang="scss">
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

html, body, #app {
  width: 100%;
  height: 100%;
  overflow: hidden;
}

.app {
  display: flex;
  flex-direction: column;
  width: 100%;
  height: 100%;
}

.app-header {
  height: 56px;
  background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0 20px;
  color: white;
  z-index: 200;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  
  .logo {
    display: flex;
    align-items: center;
    gap: 12px;
    
    img {
      height: 32px;
    }
    
    span {
      font-size: 16px;
      font-weight: 500;
      background: linear-gradient(135deg, #42b883, #35495e);
      background-clip: text;
      -webkit-background-clip: text;
      -webkit-text-fill-color: transparent;
    }
  }
  
  .sidebar-toggle {
    background: rgba(255, 255, 255, 0.1);
    border: none;
    color: white;
    width: 36px;
    height: 36px;
    border-radius: 8px;
    font-size: 20px;
    cursor: pointer;
    transition: all 0.2s;
    
    &:hover {
      background: rgba(66, 184, 131, 0.5);
    }
  }
}

.app-main {
  flex: 1;
  display: flex;
  overflow: hidden;
}

.app-sidebar {
  width: 320px;
  background: rgba(0, 0, 0, 0.85);
  backdrop-filter: blur(10px);
  border-right: 1px solid rgba(255, 255, 255, 0.1);
  z-index: 150;
  overflow-y: auto;
  padding: 16px;
}

.map-container {
  flex: 1;
  position: relative;
}
</style>

五、性能优化配置

5.1 生产环境优化 vite.config.ts 补充

javascript 复制代码
// 生产环境特定配置
if (process.env.NODE_ENV === 'production') {
  config.build = {
    ...config.build,
    rollupOptions: {
      output: {
        manualChunks: {
          'cesium-core': ['cesium'],
          'cesium-widgets': ['cesium/Build/Cesium/Widgets/widgets.css'],
          'turf': ['@turf/turf'],
          'vue-vendor': ['vue', 'vue-router', 'pinia']
        }
      }
    },
    // 压缩配置
    minify: 'terser',
    terserOptions: {
      compress: {
        drop_console: true,
        drop_debugger: true
      }
    }
  }
}

5.2 代码分割与懒加载

javascript 复制代码
// router/index.ts
const routes = [
  {
    path: '/',
    name: 'Home',
    component: () => import('@/views/HomeView.vue')
  },
  {
    path: '/analysis',
    name: 'Analysis',
    component: () => import('@/views/AnalysisView.vue')
  }
]

六、最佳实践清单

✅ 必须遵守的规范

  1. **实例管理**
  • Viewer 使用单例模式,避免重复创建

  • 组件卸载时必须调用 viewer.destroy()

  • 使用 shallowRef 存储 Viewer 实例

  1. **状态管理**
  • 地图状态统一使用 Pinia 管理

  • 避免在组件间直接传递 viewer 实例

  • 使用 provide/inject 注入核心实例

  1. **性能优化**
  • 大量数据使用 Primitive 而非 Entity

  • 开启 requestRenderMode 按需渲染

  • 限制 3D Tiles 内存使用

  • 及时清理不需要的图元和事件监听

  1. **代码组织**
  • 业务逻辑封装在 composables 中

  • 工具函数放在 utils 目录

  • 类型定义统一管理

  1. **错误处理**
  • 所有异步操作必须有 try-catch

  • 提供友好的错误提示

  • 开发环境保留详细日志

更多类似资料:

https://blog.csdn.net/2403_83182682/article/details/146080801?ops_request_misc=elastic_search_misc&request_id=7c9c9a841c841c2b477bf514fe272b74&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~all~top_click~default-2-146080801-null-null.142^v102^control&utm_term=Cesium&spm=1018.2226.3001.4187

相关推荐
mldong5 小时前
你的 Vue3 项目也能有钉钉同款审批流设计器:npm 装包,10 分钟画出第一条审批流
前端·vue.js
2分钟速写快排6 小时前
什么是 RAG?如何用 RAG 实现一个用户记忆?
前端·后端·ai编程
passerby60616 小时前
如何自己造一个时间处理库
前端·javascript·github
走到天涯海角7 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!7 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰8 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
mayaairi9 小时前
Vue2 组件通讯(三):全局事件总线、PubSub、插槽与组件实例属性
前端·javascript·vue.js
kyriewen10 小时前
面试官问我:AI 都能写代码了,前端凭什么还值 25K
前端·javascript·人工智能
风骏时光牛马11 小时前
AI源码分析:拆解模型底层实现逻辑
前端
BillKu11 小时前
全局样式变量:CSS 自定义属性(--border-color)和SCSS 变量($border-color)的说明
javascript·css·scss