前端开发实战:Vue.js构建伏羲气象数据可视化Dashboard

最近在做一个气象相关的项目,需要把专业的气象预测数据直观地展示出来。我发现,对于前端开发者来说,这其实是一个绝佳的练手机会——既能巩固Vue.js和ECharts这些主流技术栈,又能做出一个看起来非常专业、实用的可视化仪表盘。

想象一下,你手头有一份来自“伏羲”这类先进气象模型的预测数据,里面包含了未来几天的温度、降水、风速风向。如果只是看一堆数字和表格,决策者很难快速把握全局。但如果我们能把这些数据变成一张动态的地图,配上可交互的图表和清晰的卡片,整个天气趋势就一目了然了。

这篇文章,我就来分享一下如何从零开始,用Vue.js搭建这样一个气象数据可视化Dashboard。整个过程会涉及到项目初始化、API数据对接、ECharts图表集成、组件化开发以及响应式布局。无论你是想学习Vue实战,还是对数据可视化感兴趣,相信都能从中获得一些可以直接上手的思路和代码。

1. 项目概述与核心功能设计

在动手写代码之前,我们先明确一下这个仪表盘要做什么。核心目标是把复杂的气象数据变得易懂、好看、好用。

我设计的这个Dashboard主要包含三大块功能,这也是气象分析中最常用到的视图:

第一,是地图综合展示。 这是仪表盘的核心区域。我们会用一张中国地图(或其他区域地图)作为底图,然后把不同的气象要素,比如温度、降水量、风场,以图层的形式叠加上去。用户可以自由开关这些图层,查看单一或组合的气象状况。温度可以用渐变色表示冷暖,降水量用不同深浅的蓝色表示雨量大小,风场则用动态的箭头或流线表示风向和风速。

第二,是历史预测曲线对比。 光看当前或未来的预测还不够,我们常常需要对比。比如,把过去24小时的实际观测温度曲线,和模型24小时前预测的温度曲线放在一起,就能很直观地看出模型的预测准确性。这个功能会用一个折线图来实现,支持同时展示多组数据序列。

第三,是多城市天气卡片。 用户可能关心多个重点城市的天气。我们可以在侧边栏或底部设计一个卡片列表,每个卡片清晰展示某个城市当前的温度、天气状况、风速等关键信息,并且点击卡片可以快速定位到地图上的对应城市。

确定了功能,技术选型就清晰了。Vue.js 3作为主框架,生态丰富且响应式系统非常适合处理动态数据。可视化方面,ECharts是不二之选,它对地理地图和复杂图表的支持非常强大。UI组件库我选择了Element Plus,它能帮我们快速搭建出美观且一致的界面。整个项目的代码结构会采用清晰的组件化方式,保证可维护性。

2. 环境搭建与项目初始化

万事开头难,但把环境搭好,后面就顺了。我们一步步来。

首先,确保你的电脑上已经安装了Node.js(建议16.x或18.x LTS版本)和npm。打开终端,我们用Vite来快速创建一个Vue 3项目,它比传统的Vue CLI更快更轻量。

npm create vue@latest vue-weather-dashboard

执行这个命令后,命令行会交互式地让你选择一些配置。对于这个项目,我的选择是:

  • TypeScript: Yes (推荐使用,能获得更好的类型提示)
  • JSX: No
  • Vue Router: Yes (虽然我们是个单页应用,但路由对于组织复杂应用很有帮助)
  • Pinia: Yes (状态管理,比Vuex更简洁,管理全局的天气数据非常合适)
  • ESLint / Prettier: Yes (保持代码规范整洁)

项目创建好后,进入目录,安装我们计划好的核心依赖:

cd vue-weather-dashboard
npm install
npm install echarts vue-echarts element-plus axios
npm install --save-dev @types/echarts # 如果用了TypeScript

这里简单解释一下:

  • echarts & vue-echarts: ECharts的核心库及其Vue组件封装,让我们能在Vue里方便地使用图表。
  • element-plus: UI组件库,用来搭建布局、按钮、卡片等界面元素。
  • axios: 用来向后端API请求气象数据。

安装完成后,我们需要做一些简单的配置。在 main.ts 或 main.js 中,全局引入Element Plus的样式和部分组件,并注册vue-echarts:

// main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import App from './App.vue'
import router from './router'

// 导入ECharts和VueECharts组件
import * as echarts from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { MapChart, LineChart, BarChart } from 'echarts/charts'
import {
  TitleComponent,
  TooltipComponent,
  LegendComponent,
  GridComponent,
  VisualMapComponent
} from 'echarts/components'
import VChart from 'vue-echarts'

// 按需注册ECharts组件
echarts.use([
  CanvasRenderer,
  MapChart,
  LineChart,
  BarChart,
  TitleComponent,
  TooltipComponent,
  LegendComponent,
  GridComponent,
  VisualMapComponent
])

const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(ElementPlus)

// 全局注册`v-chart`组件
app.component('v-chart', VChart)

app.mount('#app')

最后,启动开发服务器,看看基础项目是否运行正常:

npm run dev

浏览器打开 http://localhost:5173,看到Vue的欢迎页面,说明环境就准备好了。接下来,我们可以开始规划页面布局和组件结构了。

3. 页面布局与组件结构设计

一个清晰的布局是良好用户体验的基础。我打算采用一个经典的Dashboard布局:顶部导航栏、左侧可折叠的侧边栏(用于放置城市卡片和图层控制)、中间主内容区(展示地图和图表)。

首先,我们来修改根组件 App.vue,搭建一个基础框架:

<!-- App.vue -->
<template>
  <el-container class="app-container">
    <!-- 顶部导航栏 -->
    <el-header class="app-header">
      <div class="header-content">
        <h1>🌤️ 伏羲气象数据可视化平台</h1>
        <div class="header-actions">
          <el-select v-model="selectedRegion" placeholder="选择区域" size="small">
            <el-option label="全国" value="china"></el-option>
            <el-option label="华北地区" value="north-china"></el-option>
            <!-- 更多选项... -->
          </el-select>
          <el-button type="primary" size="small" @click="refreshData">刷新数据</el-button>
        </div>
      </div>
    </el-header>

    <el-container>
      <!-- 左侧边栏:城市列表和图层控制 -->
      <el-aside width="280px" class="app-sidebar">
        <city-list-panel></city-list-panel>
        <layer-control-panel></layer-control-panel>
      </el-aside>

      <!-- 主内容区 -->
      <el-main class="app-main">
        <router-view />
      </el-main>
    </el-container>
  </el-container>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import CityListPanel from './components/CityListPanel.vue'
import LayerControlPanel from './components/LayerControlPanel.vue'

const selectedRegion = ref('china')

const refreshData = () => {
  // 触发数据刷新逻辑
  console.log('刷新数据...')
}
</script>

<style scoped>
.app-container {
  height: 100vh;
}
.app-header {
  background-color: #2c3e50;
  color: white;
  line-height: 60px;
  border-bottom: 1px solid #34495e;
}
.header-content {
  display: flex;
  justify-content: space-between;
  align-items: center;
  max-width: 1400px;
  margin: 0 auto;
  width: 100%;
}
.app-sidebar {
  background-color: #f8f9fa;
  border-right: 1px solid #e9ecef;
  overflow-y: auto;
  padding: 16px;
}
.app-main {
  padding: 16px;
  background-color: #f0f2f5;
  overflow-y: auto;
}
</style>

接下来,我们规划一下核心的页面组件。我打算在 src/views 目录下创建一个 Dashboard.vue,作为仪表盘的主页面。它负责组装各个功能模块:

<!-- views/Dashboard.vue -->
<template>
  <div class="dashboard">
    <!-- 第一行:地图和关键指标 -->
    <el-row :gutter="16" class="dashboard-row">
      <el-col :span="16">
        <weather-map :layers="activeLayers"></weather-map>
      </el-col>
      <el-col :span="8">
        <key-metrics-panel></key-metrics-panel>
      </el-col>
    </el-row>

    <!-- 第二行:历史预测对比图表 -->
    <el-row :gutter="16" class="dashboard-row">
      <el-col :span="24">
        <forecast-comparison-chart></forecast-comparison-chart>
      </el-col>
    </el-row>
  </div>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { useWeatherStore } from '@/stores/weather'
import WeatherMap from '@/components/WeatherMap.vue'
import KeyMetricsPanel from '@/components/KeyMetricsPanel.vue'
import ForecastComparisonChart from '@/components/ForecastComparisonChart.vue'

const weatherStore = useWeatherStore()
const activeLayers = computed(() => weatherStore.activeLayers)
</script>

<style scoped>
.dashboard-row {
  margin-bottom: 16px;
}
</style>

别忘了在路由文件 router/index.ts 中将这个页面设置为首页:

import { createRouter, createWebHistory } from 'vue-router'
import Dashboard from '@/views/Dashboard.vue'

const router = createRouter({
  history: createWebHistory(),
  routes: [
    {
      path: '/',
      name: 'Dashboard',
      component: Dashboard
    }
    // 可以在这里添加其他路由,比如城市详情页
  ]
})

export default router

这样,一个基本的页面骨架就搭好了。左侧边栏会放置 CityListPanel(城市列表)和 LayerControlPanel(图层控制),中间主区域则依次是地图、关键指标和对比图表。接下来,我们就要去实现这些具体的、有血有肉的功能组件了。

4. 核心功能组件实现

4.1 数据获取与状态管理

气象数据是动态的,而且多个组件都需要访问和修改它,所以一个集中式的状态管理非常必要。我们用Pinia来创建一个天气数据仓库。

首先,在 src/stores 目录下创建 weather.ts:

// stores/weather.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import axios from 'axios'

// 定义一些类型,让代码更清晰
interface CityWeather {
  id: string
  name: string
  temperature: number
  precipitation: number
  windSpeed: number
  windDirection: number
  condition: 'sunny' | 'cloudy' | 'rainy' | 'snowy'
}

interface ForecastDataPoint {
  time: string
  predictedTemp: number
  observedTemp?: number // 历史观测值,可能没有
}

export const useWeatherStore = defineStore('weather', () => {
  // 状态定义
  const selectedCity = ref<CityWeather | null>(null)
  const cities = ref<CityWeather[]>([])
  const forecastData = ref<ForecastDataPoint[]>([])
  const mapGeoJson = ref<any>(null) // 存储地图GeoJSON数据
  const temperatureData = ref<any>(null) // 温度栅格数据
  const activeLayers = ref<string[]>(['temperature']) // 当前激活的图层

  // 计算属性
  const selectedCityForecast = computed(() => {
    if (!selectedCity.value) return []
    // 这里可以过滤或处理针对选中城市的预测数据
    return forecastData.value
  })

  // 动作(Actions)
  async function fetchCities() {
    try {
      // 这里替换成你实际的后端API地址
      const response = await axios.get('/api/cities')
      cities.value = response.data
      if (cities.value.length > 0 && !selectedCity.value) {
        selectedCity.value = cities.value[0]
      }
    } catch (error) {
      console.error('获取城市列表失败:', error)
    }
  }

  async function fetchForecastData(cityId?: string) {
    try {
      const params = cityId ? { cityId } : {}
      const response = await axios.get('/api/forecast', { params })
      forecastData.value = response.data
    } catch (error) {
      console.error('获取预测数据失败:', error)
      // 这里可以添加一些模拟数据,用于前端演示
      forecastData.value = generateMockForecastData()
    }
  }

  async function fetchMapData() {
    // 获取地图数据和温度等栅格数据
    // 这里通常需要调用多个API
    // 例如:获取GeoJSON,获取温度栅格JSON
    // 为简化示例,我们假设有一个接口返回所有需要的数据
    try {
      const response = await axios.get('/api/map-layers')
      mapGeoJson.value = response.data.geoJson
      temperatureData.value = response.data.temperatureLayer
    } catch (error) {
      console.error('获取地图数据失败:', error)
    }
  }

  function toggleLayer(layerName: string) {
    const index = activeLayers.value.indexOf(layerName)
    if (index > -1) {
      activeLayers.value.splice(index, 1)
    } else {
      activeLayers.value.push(layerName)
    }
  }

  // 初始化时获取数据
  fetchCities()
  fetchForecastData()
  fetchMapData()

  return {
    // 状态
    selectedCity,
    cities,
    forecastData,
    mapGeoJson,
    temperatureData,
    activeLayers,
    // 计算属性
    selectedCityForecast,
    // 动作
    fetchCities,
    fetchForecastData,
    fetchMapData,
    toggleLayer
  }
})

// 模拟数据生成函数,用于演示
function generateMockForecastData(): ForecastDataPoint[] {
  const data = []
  const now = new Date()
  for (let i = 0; i < 10; i++) {
    const time = new Date(now.getTime() + i * 3 * 60 * 60 * 1000) // 每3小时一个点
    data.push({
      time: time.toISOString(),
      predictedTemp: 20 + Math.sin(i) * 5, // 模拟波动
      observedTemp: 20 + Math.sin(i) * 5 + (Math.random() - 0.5) * 2 // 模拟观测值,带点随机误差
    })
  }
  return data
}

这个Store管理了所有核心的天气数据,以及当前激活的图层状态。组件只需要引入并使用这个Store,就能共享和响应这些数据的变化。

4.2 气象地图组件开发

地图是仪表盘的视觉核心。我们将使用ECharts的map和visualMap等组件来绘制。

创建 src/components/WeatherMap.vue:

<!-- components/WeatherMap.vue -->
<template>
  <el-card class="map-card" shadow="hover">
    <template #header>
      <div class="card-header">
        <span>实时气象分布图</span>
        <div class="time-info">数据更新时间: {{ lastUpdateTime }}</div>
      </div>
    </template>
    <div v-if="isLoading" class="map-loading">地图数据加载中...</div>
    <div v-else class="map-container">
      <v-chart
        class="echarts-map"
        :option="mapOption"
        :autoresize="true"
        @click="onMapClick"
      />
    </div>
  </el-card>
</template>

<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useWeatherStore } from '@/stores/weather'
import { ElMessage } from 'element-plus'

const weatherStore = useWeatherStore()
const isLoading = ref(true)
const lastUpdateTime = ref(new Date().toLocaleTimeString())

// 计算地图配置项,这是ECharts的核心
const mapOption = computed(() => {
  if (!weatherStore.mapGeoJson) {
    return {}
  }

  // 注册地图(在实际项目中,你可能需要预先注册或动态注册)
  // 这里假设 geoJson 数据格式符合ECharts要求
  const echarts = (window as any).echarts
  if (echarts && !echarts.getMap('china')) {
    echarts.registerMap('china', weatherStore.mapGeoJson)
  }

  const option = {
    tooltip: {
      trigger: 'item',
      formatter: function (params: any) {
        // 自定义提示框内容
        if (params.seriesType === 'map') {
          return `${params.name}<br/>温度: ${params.value ? params.value + '°C' : '暂无数据'}`
        }
        return params.name
      }
    },
    visualMap: {
      // 根据激活的图层显示不同的视觉映射
      ...getVisualMapConfig(),
      text: ['高', '低'],
      calculable: true,
      orient: 'vertical',
      left: 'right',
      top: 'center'
    },
    geo: {
      map: 'china',
      roam: true, // 允许缩放和平移
      zoom: 1.2,
      center: [105, 36], // 初始中心点
      label: {
        emphasis: {
          show: true,
          color: '#fff'
        }
      },
      itemStyle: {
        areaColor: '#323c48',
        borderColor: '#111'
      },
      emphasis: {
        itemStyle: {
          areaColor: '#2a333d'
        }
      }
    },
    series: getMapSeries() // 动态生成系列数据
  }
  return option
})

// 根据激活的图层生成对应的visualMap配置
function getVisualMapConfig() {
  const layers = weatherStore.activeLayers
  if (layers.includes('temperature')) {
    return {
      min: -20,
      max: 40,
      text: ['高温', '低温'],
      color: ['#d73027', '#f46d43', '#fdae61', '#fee090', '#ffffbf', '#e0f3f8', '#abd9e9', '#74add1', '#4575b4']
    }
  } else if (layers.includes('precipitation')) {
    return {
      min: 0,
      max: 100,
      text: ['大雨', '小雨'],
      color: ['#08306b', '#08519c', '#2171b5', '#4292c6', '#6baed6', '#9ecae1', '#c6dbef', '#deebf7', '#f7fbff']
    }
  }
  // 默认返回一个
  return {
    min: 0,
    max: 100,
    color: ['#f7fbff', '#08306b']
  }
}

// 根据激活的图层生成地图系列数据
function getMapSeries() {
  const series = []
  const layers = weatherStore.activeLayers

  // 温度图层
  if (layers.includes('temperature') && weatherStore.temperatureData) {
    series.push({
      name: '温度',
      type: 'map',
      map: 'china',
      geoIndex: 0, // 关联到geo配置
      data: weatherStore.temperatureData, // 这里应该是与geoJson区域匹配的数据数组
      itemStyle: {
        areaColor: '#323c48' // 基础色,实际颜色由visualMap控制
      }
    })
  }

  // 可以在这里添加降水、风场等其他图层系列
  // if (layers.includes('precipitation')) { ... }

  return series
}

// 地图点击事件
const onMapClick = (params: any) => {
  if (params.componentType === 'series' && params.seriesType === 'map') {
    ElMessage.info(`点击了地区: ${params.name}`)
    // 这里可以触发选中城市、弹出详情等操作
    // weatherStore.selectedCity = findCityByName(params.name)
  }
}

onMounted(() => {
  // 模拟数据加载完成
  setTimeout(() => {
    isLoading.value = false
  }, 800)
})
</script>

<style scoped>
.map-card {
  height: 100%;
}
.card-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
}
.time-info {
  font-size: 0.85rem;
  color: #909399;
}
.map-container {
  width: 100%;
  height: 500px; /* 固定高度,也可以使用calc(100vh - 某个值) */
}
.echarts-map {
  width: 100%;
  height: 100%;
}
.map-loading {
  display: flex;
  justify-content: center;
  align-items: center;
  height: 500px;
  color: #909399;
}
</style>

这个组件是仪表盘中最复杂的部分之一。它根据Store中激活的图层(activeLayers)动态生成ECharts配置项。当用户在左侧的图层控制面板中开关温度或降水图层时,这个地图会实时响应并重绘。

4.3 历史预测对比图表

这个组件用一个折线图来对比预测值和观测值。创建 src/components/ForecastComparisonChart.vue:

<!-- components/ForecastComparisonChart.vue -->
<template>
  <el-card shadow="hover">
    <template #header>
      <div class="card-header">
        <span>历史预测对比</span>
        <el-select v-model="selectedMetric" size="small" style="width: 120px">
          <el-option label="温度" value="temperature"></el-option>
          <el-option label="降水量" value="precipitation"></el-option>
          <el-option label="风速" value="windSpeed"></el-option>
        </el-select>
      </div>
    </template>
    <div class="chart-container">
      <v-chart class="chart" :option="chartOption" :autoresize="true" />
    </div>
  </el-card>
</template>

<script setup lang="ts">
import { computed, ref } from 'vue'
import { useWeatherStore } from '@/stores/weather'

const weatherStore = useWeatherStore()
const selectedMetric = ref('temperature')

const chartOption = computed(() => {
  const forecastData = weatherStore.selectedCityForecast
  if (!forecastData || forecastData.length === 0) {
    return {
      title: {
        text: '暂无数据',
        left: 'center',
        top: 'center'
      }
    }
  }

  // 处理时间轴数据
  const times = forecastData.map(item => {
    const date = new Date(item.time)
    return `${date.getMonth()+1}/${date.getDate()} ${date.getHours()}时`
  })

  // 根据选择的指标提取数据
  let predictedData: number[] = []
  let observedData: number[] = []

  if (selectedMetric.value === 'temperature') {
    predictedData = forecastData.map(item => item.predictedTemp)
    observedData = forecastData.map(item => item.observedTemp || null)
  }
  // 可以在这里扩展其他指标的处理

  return {
    tooltip: {
      trigger: 'axis',
      axisPointer: {
        type: 'cross'
      }
    },
    legend: {
      data: ['模型预测', '实际观测']
    },
    grid: {
      left: '3%',
      right: '4%',
      bottom: '3%',
      containLabel: true
    },
    xAxis: {
      type: 'category',
      boundaryGap: false,
      data: times
    },
    yAxis: {
      type: 'value',
      axisLabel: {
        formatter: `{value} ${selectedMetric.value === 'temperature' ? '°C' : 'mm'}`
      }
    },
    series: [
      {
        name: '模型预测',
        type: 'line',
        smooth: true,
        data: predictedData,
        itemStyle: { color: '#5470c6' },
        lineStyle: { width: 3 }
      },
      {
        name: '实际观测',
        type: 'line',
        smooth: true,
        data: observedData,
        itemStyle: { color: '#91cc75' },
        lineStyle: { width: 3, type: 'dashed' } // 用虚线表示观测值
      }
    ]
  }
})
</script>

<style scoped>
.card-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
}
.chart-container {
  width: 100%;
  height: 300px;
}
.chart {
  width: 100%;
  height: 100%;
}
</style>

这个图表组件展示了如何将Store中的预测数据绑定到ECharts。用户可以通过下拉框切换查看不同气象指标的对比情况。虚线表示实际观测值,实线表示模型预测值,两者越接近,说明模型预测越准。

4.4 城市列表与图层控制面板

最后,我们来完成左侧边栏的两个面板。首先是城市列表 CityListPanel.vue:

<!-- components/CityListPanel.vue -->
<template>
  <el-card class="panel-card" shadow="never">
    <template #header>
      <span>重点城市</span>
    </template>
    <el-scrollbar max-height="300px">
      <div class="city-list">
        <div
          v-for="city in cities"
          :key="city.id"
          class="city-item"
          :class="{ 'is-active': selectedCity?.id === city.id }"
          @click="selectCity(city)"
        >
          <div class="city-info">
            <div class="city-name">{{ city.name }}</div>
            <div class="city-temp">{{ city.temperature }}°C</div>
          </div>
          <div class="city-extra">
            <el-tag size="small" :type="getConditionTagType(city.condition)">
              {{ getConditionText(city.condition) }}
            </el-tag>
            <div class="wind-info">
              <span class="wind-icon" :style="{ transform: `rotate(${city.windDirection}deg)` }">↑</span>
              {{ city.windSpeed }} km/h
            </div>
          </div>
        </div>
      </div>
    </el-scrollbar>
  </el-card>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { useWeatherStore } from '@/stores/weather'
import type { CityWeather } from '@/stores/weather'

const weatherStore = useWeatherStore()
const cities = computed(() => weatherStore.cities)
const selectedCity = computed(() => weatherStore.selectedCity)

const selectCity = (city: CityWeather) => {
  weatherStore.selectedCity = city
  // 可以在这里触发获取该城市详细预测数据的动作
  // weatherStore.fetchForecastData(city.id)
}

const getConditionTagType = (condition: string) => {
  const map: Record<string, 'success' | 'info' | 'warning' | 'danger'> = {
    sunny: 'warning',
    cloudy: 'info',
    rainy: '',
    snowy: 'success'
  }
  return map[condition] || ''
}

const getConditionText = (condition: string) => {
  const map: Record<string, string> = {
    sunny: '晴',
    cloudy: '多云',
    rainy: '雨',
    snowy: '雪'
  }
  return map[condition] || condition
}
</script>

<style scoped>
.panel-card {
  margin-bottom: 16px;
}
.city-list {
  padding: 4px 0;
}
.city-item {
  padding: 12px;
  border-radius: 6px;
  cursor: pointer;
  transition: background-color 0.2s;
  margin-bottom: 8px;
  border: 1px solid #e4e7ed;
}
.city-item:hover {
  background-color: #f5f7fa;
}
.city-item.is-active {
  border-color: #409eff;
  background-color: #ecf5ff;
}
.city-info {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 8px;
}
.city-name {
  font-weight: 500;
}
.city-temp {
  font-size: 1.2rem;
  font-weight: bold;
  color: #f56c6c;
}
.city-extra {
  display: flex;
  justify-content: space-between;
  align-items: center;
  font-size: 0.85rem;
  color: #909399;
}
.wind-icon {
  display: inline-block;
  margin-right: 4px;
  transition: transform 0.3s;
}
</style>

然后是图层控制面板 LayerControlPanel.vue:

<!-- components/LayerControlPanel.vue -->
<template>
  <el-card class="panel-card" shadow="never">
    <template #header>
      <span>地图图层</span>
    </template>
    <div class="layer-list">
      <el-checkbox-group v-model="activeLayers" @change="handleLayerChange">
        <div class="layer-item">
          <el-checkbox label="temperature">温度分布</el-checkbox>
          <el-tag size="small" effect="dark" type="danger">热</el-tag>
        </div>
        <div class="layer-item">
          <el-checkbox label="precipitation">降水量</el-checkbox>
          <el-tag size="small" effect="dark" type="primary">雨</el-tag>
        </div>
        <div class="layer-item">
          <el-checkbox label="wind" disabled>风场(开发中)</el-checkbox>
          <el-tag size="small" effect="dark" type="info">风</el-tag>
        </div>
        <div class="layer-item">
          <el-checkbox label="pressure" disabled>气压(开发中)</el-checkbox>
          <el-tag size="small" effect="dark">压</el-tag>
        </div>
      </el-checkbox-group>
    </div>
    <el-divider />
    <div class="layer-opacity">
      <span>图层透明度</span>
      <el-slider v-model="opacity" :min="0" :max="100" :step="10" show-stops />
    </div>
  </el-card>
</template>

<script setup lang="ts">
import { computed, ref } from 'vue'
import { useWeatherStore } from '@/stores/weather'

const weatherStore = useWeatherStore()
const opacity = ref(70)

// 使用计算属性来建立双向绑定,避免直接修改store的状态
const activeLayers = computed({
  get: () => weatherStore.activeLayers,
  set: (value) => {
    // 这里可以添加一些逻辑,但直接赋值给store可能不是最佳实践
    // 更好的做法是通过action来修改
  }
})

const handleLayerChange = (value: string[]) => {
  // 这里可以添加更复杂的逻辑,比如批量更新
  // 目前我们简单处理:先清空,再逐个添加(实际应该更智能地对比变化)
  weatherStore.activeLayers = value
}
</script>

<style scoped>
.panel-card {
  margin-bottom: 16px;
}
.layer-list {
  padding: 8px 0;
}
.layer-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 8px 0;
  border-bottom: 1px solid #f0f0f0;
}
.layer-item:last-child {
  border-bottom: none;
}
.layer-opacity {
  padding: 8px 0;
}
</style>

这两个面板组件相对简单,主要是与Store进行交互,控制哪些数据应该被展示在地图和图表上。城市列表面板还提供了直观的天气概览。

5. 项目优化与部署建议

到这一步,一个功能完整的气象数据可视化Dashboard的前端部分就基本完成了。不过,要让这个项目真正可用、好用,我们还需要考虑一些优化和后续步骤。

首先是数据对接。 我们前面的代码中,API请求地址都是 /api/ 开头的。在实际项目中,你需要一个真实的后端服务来提供气象数据。这个后端可以是Python(Flask/Django/FastAPI)、Node.js(Express/NestJS)或任何你熟悉的技术栈。它的主要职责是:

  1. 从“伏羲”模型或其他数据源获取原始数据。
  2. 对数据进行处理、聚合,转换成前端ECharts需要的格式(比如GeoJSON和对应的数值数组)。
  3. 通过RESTful API或WebSocket将数据提供给前端。
  4. 你可能需要配置一个反向代理(比如在Vite的 vite.config.ts 中配置 server.proxy)来解决开发时的跨域问题。

其次是性能优化。 气象数据量可能很大,特别是高分辨率的地图栅格数据。

  • 按需加载:不要一次性加载全国所有最高精度的数据。可以根据地图的缩放级别,动态请求不同精度的数据。
  • 数据聚合:在后端对数据进行预处理,比如将细网格数据聚合到更粗的网格,减少传输量和前端渲染压力。
  • 防抖与节流:对地图的缩放、平移等交互事件进行节流处理,避免过于频繁地请求数据。
  • 虚拟滚动:如果城市列表非常长,可以考虑使用虚拟滚动技术。

然后是用户体验提升。

  • 加载状态:我们已经在组件里简单实现了加载提示,可以进一步优化,比如使用骨架屏(Skeleton Screen)。
  • 错误处理:增加更友好的网络错误、数据格式错误提示。
  • 主题切换:可以考虑增加深色/浅色主题切换,适应不同光线环境。
  • 移动端适配:使用Element Plus的响应式栅格系统和CSS媒体查询,确保在手机和平板上也有良好的浏览体验。

最后是部署。 开发完成后,运行 npm run build 命令,Vite会将项目打包到 dist 目录。你可以将这个目录下的静态文件部署到任何Web服务器上,比如Nginx、Apache,或者云服务商的对象存储(如AWS S3、阿里云OSS)配合CDN。记得将API请求地址从 /api/ 改为你生产环境的后端真实地址。

整个项目搭建下来,感觉Vue 3的组合式API配合Pinia让状态管理变得非常清晰,而ECharts强大的可视化能力则让数据“活”了过来。虽然这里展示的是一个气象数据的例子,但整个技术栈和架构思路完全可以复用到其他领域的可视化需求中,比如物流监控、金融大盘、物联网设备状态展示等等。你可以根据实际的数据结构和业务逻辑,调整地图、图表以及交互方式。希望这个实战项目能为你提供一个不错的起点。


获取更多AI镜像

想探索更多AI镜像和应用场景?访问 CSDN星图镜像广场,提供丰富的预置镜像,覆盖大模型推理、图像生成、视频生成、模型微调等多个领域,支持一键部署。

更多推荐