一、完整项目架构图
┌─────────────────────────────────────────────────────────────────────────────┐
│ 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')
}
]
六、最佳实践清单
✅ 必须遵守的规范
- **实例管理**
\] Viewer 使用单例模式,避免重复创建
\] 使用 shallowRef 存储 Viewer 实例
\] 地图状态统一使用 Pinia 管理
\] 使用 provide/inject 注入核心实例
\] 大量数据使用 Primitive 而非 Entity
\] 限制 3D Tiles 内存使用
- **代码组织**
\] 业务逻辑封装在 composables 中
\] 类型定义统一管理
\] 所有异步操作必须有 try-catch
\] 开发环境保留详细日志
更多类似资料: