「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 使用单例模式,避免重复创建

  • \] 使用 shallowRef 存储 Viewer 实例

  • \] 地图状态统一使用 Pinia 管理

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

  • \] 大量数据使用 Primitive 而非 Entity

  • \] 限制 3D Tiles 内存使用

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

  • \] 类型定义统一管理

  • \] 所有异步操作必须有 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

相关推荐
小李子呢02112 小时前
前端八股Vue(5)---v-if和v-show
前端·javascript·vue.js
yuki_uix2 小时前
跨域与安全:CORS、HTTPS 与浏览器安全机制
前端·面试
用户3153247795452 小时前
React19项目中 FormEdit / FormEditModal 组件封装设计说明
前端·react.js
陆枫Larry2 小时前
Git 合并冲突实战:`git pull` 失败与 `pull.ff=only` 的那些事
前端
江南月2 小时前
让智能体边想边做:从 0 理解 ReActAgent 的工作方式
前端·人工智能
YiuChauvin2 小时前
vue2中使用 AntV G6
javascript·vue.js
袋鱼不重2 小时前
Hermes Agent 安装与实战:从安装到与 OpenClaw 全方位对比
前端·后端·ai编程
汉秋2 小时前
iOS 自定义 UICollectionView 拼图布局 + 布局切换动画实践
前端