☰
SpringBoot+Vue3+ECharts疫情数据可视化平台实战解析
2026/10/10 13:11:06 网站建设 项目流程

最近常常能看到类似“基于PHP、asp.net、java、Springboot、SSM、vue3的国内新冠疫情数据分析可视化平台的设计与实现”这样的题目。第一眼确实有点唬人,好像要把六种技术栈通通塞进同一个系统里。其实做过工程的人都知道,一个后端项目不可能同时跑 Java 和 PHP,也不可能把 SSM 和 SpringBoot 混着写。真正值得思考的是:这个项目到底要解决什么问题,以及在你熟悉的语言里怎么把数据采集、存储、接口、可视化这一整条链路跑通。

这类题目最适合的场景是课程设计、毕业设计,或者想练手企业级 BI 可视化项目的人。我照着这个命题完整做了一遍,主栈选的是 SpringBoot 3 + Vue3 + ECharts + MySQL,同时对比了 SSM、PHP、ASP.NET 下的等价实现方案。这篇文章就把我拆题、建表、写接口、调图表、踩坑填坑的过程原原本本讲一遍,都是文档里不常写的实操细节。

1. 先看清题目本质:多种技术栈不是混用,而是选型对比

1.1 为什么一个“平台”题目会带这么多技术标签

这类标题通常是为了命中多个技术关键词,方便学习和检索。如果照着字面去“集成”所有框架,项目不仅难以维护,还可能被各种配置文件淹没。更合理的解读方式是:

  • 后端可以选 Java 技术线,用 SpringBoot 或者 SSM 其中一种;
  • 也可以选 PHP 方向,用 ThinkPHP、Laravel 或者原生 PDO 实现;
  • 还可以选 ASP.NET 方向,用 .NET 8 Minimal API 来实现;
  • 前端统一用 Vue3,配合 ECharts 完成数据可视化;
  • 数据库用 MySQL,量大一点也可以接 PostgreSQL,但个人项目用 MySQL 最省事。

项目本质是一个“数据接入—清洗入库—统计接口—图表展示”的完整闭环。最重要的不是某种框架的语法,而是数据结构设计和统计指标口径。

1.2 六种技术栈逐一拆解

技术栈适合场景优势需要注意的问题本次项目定位
PHP快速建站、小中型业务部署简单,几乎处处可跑弱类型导致接口数据格式容易出问题备选轻量方案
ASP.NET微软技术栈企业项目C# 类型安全,生态完善跨平台部署需要安装 .NET 运行时备选企业方案
Java + SpringBoot企业级微服务、前后端分离约定优于配置,生态成熟,资料多启动占用内存稍高主选后端
SSM高校课程教学分层清晰,适合理解底层过程XML 配置繁琐,开发效率低同源对照方案
Vue3前端 SPA、可视化大屏组合式 API 灵活,配合 Vite 快对新手需要时间理解响应式原理主选前端
ECharts各类图表、地图可视化配置灵活,开箱即用地图数据需要额外准备 JSON可视化核心组件

我最后选择 SpringBoot + Vue3 并不是因为其他技术不行,而是这套组合在社区里案例最多,遇到地图、跨域、定时任务这类问题时能最快找到解决方案。后面我会把 SSM、PHP、ASP.NET 的等价实现思路也一起讲清楚。

1.3 从题目到模块拆解

拿到题目之后,我先把整个平台拆成四个模块:

  1. 数据采集模块:定时从公开数据渠道获取历史统计 JSON,经过校验后写入 MySQL。
  2. 数据服务模块:提供趋势、排行、地图、地区列表等统计接口。
  3. 管理端模块:手动触发数据同步、查看日志、维护地区映射。
  4. 可视化模块:Vue3 页面展示全国地图、折线图、柱状图、数据卡片。

这四个模块互相独立,所以无论后端换成哪种语言,前端 Vue3 部分和数据库结构都不需要大改。这也是“多种技术栈命题”能够成立的底层原因。

2. 数据核心:疫情数据建模与接口设计

2.1 数据来源与清洗策略

做数据分析平台,数据源是第一关。我采用的是公开渠道汇总好的结构化 JSON/CSV 数据,而不是自己从页面里写正则去抓。原因很简单:网页结构随时变,今天能抓明天就报错,与其花时间维护爬虫,不如把精力花在数据入库和历史数据的准确性上。

拿到原始数据后,需要做三步清洗:

  1. 统一日期格式,全部转成yyyy-MM-dd,避免前端出现时区偏移。
  2. 统一地区命名,比如把“某省份”“某省”这种称呼标准化成行政区划名。
  3. 处理缺失值,如果某一天某个城市没有上报数据,需要决定是补 0 还是跳过。

我选择了“跳过但有记录”的策略:数据表里保留该地区当天的行,如果指标为空则存 0,前端展示时用连续日期补点。这样折线图才不会出现断裂,也不至于把缺失量误判成新增。

2.2 数据库表结构设计

数据表设计直接影响聚合 SQL 的复杂度。我没有做成一种指标一张表,而是把多个指标放在同一行,这样查询趋势时一次就能拿到确诊、治愈、死亡三个序列。

CREATE TABLE region ( id INT PRIMARY KEY AUTO_INCREMENT, province VARCHAR(40) NOT NULL, city VARCHAR(40) NOT NULL DEFAULT '', region_level TINYINT NOT NULL COMMENT '1-省级 2-市级', UNIQUE KEY uk_region_province_city (province, city) ) COMMENT '地区维度表'; CREATE TABLE daily_stat ( id BIGINT PRIMARY KEY AUTO_INCREMENT, stat_date DATE NOT NULL, province VARCHAR(40) NOT NULL, city VARCHAR(40) NOT NULL DEFAULT '', confirmed INT NOT NULL DEFAULT 0 COMMENT '累计确诊', asymptomatic INT NOT NULL DEFAULT 0 COMMENT '累计无症状', cured INT NOT NULL DEFAULT 0 COMMENT '累计治愈', dead INT NOT NULL DEFAULT 0 COMMENT '累计死亡', source VARCHAR(255) COMMENT '数据来源标识', update_time DATETIME NOT NULL, UNIQUE KEY uk_date_province_city (stat_date, province, city) ) COMMENT '每日统计数据表';

这里重点说一下为什么保存“累计值”而不是“新增值”。累计值是不可逆的存量数据,新增值可以通过当日累计减昨日累计算出来。如果你只存新增值,一旦某一天数据漏了,后续所有增量都会错位,而且历史无法回补。

2.3 聚合指标的计算口径

前端图表展示的指标需要后端统一算好,不能让每个页面自己算,否则会出现两个页面数字不一致的问题。我定义了几个通用口径:

  • 今日全国新增确诊:全国当日累计确诊 − 全国昨日累计确诊
  • 某省累计治愈率:该省累计治愈 / 该省累计确诊
  • 某省环比变化:当日累计值 − 前一日累计值

如果某条记录的累计确诊为 0,治愈率直接返回 0,防止除零异常。

2.4 接口协议与响应结构

我把接口设计成 RESTful 风格,统一返回结构为:

{ "code": 200, "message": "success", "data": {} }

主要接口如下:

接口方法作用
/api/area/listGET获取省、市列表
/api/trend/nationalGET获取全国累计趋势
/api/trend/province?province=xxxGET获取某省趋势
/api/rank/province?date=xxxGET获取指定日期省份排行
/api/map/dataGET获取地图下钻数据
/api/admin/syncPOST手动触发数据同步

接口设计是前后端协作的“契约”,先把契约定下来,前端就可以用 mock 数据并行开发。这也是我做过几个项目之后养成的习惯,顺序不能反,否则联调阶段会非常痛苦。

3. 后端核心实现:SpringBoot定时任务与统计接口

3.1 项目初始化与依赖选择

我用 SpringBoot 3.x + Java 17,数据库访问用 MyBatis-Plus,调度用 Spring 自带的@Scheduled。只做单实例定时任务时完全够用,不需要引入 Quartz 的额外复杂度。

pom.xml 里的关键依赖大致是:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.7</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-cache</artifactId> </dependency>

3.2 定时同步任务实现

数据同步用@Scheduled注解实现,每天凌晨 2 点执行一次。核心逻辑分三步:拉取数据、解析 JSON、幂等写入。

@Component public class DataSyncJob { private final DailyStatMapper statMapper; public DataSyncJob(DailyStatMapper statMapper) { this.statMapper = statMapper; } @Scheduled(cron = "0 0 2 * * ?") @Transactional(rollbackFor = Exception.class) public void syncDailyData() { String json = remoteDataClient.fetch(); List<DailyStat> list = jsonParser.parse(json); // 先按统计日期删除,防止重复执行造成数据翻倍 statMapper.deleteByDate(LocalDate.now()); for (DailyStat stat : list) { statMapper.insert(stat); } } }

这里最关键的是幂等性:如果任务因为异常重复执行,也不会出现同一天同一地区两条记录。我在daily_stat表上加了uk_date_province_city唯一索引,代码里再配合“先删后插”,双保险。

3.3 全国趋势接口与聚合 SQL

全国累计趋势本质上是对省级数据按日期分组求和:

@Mapper public interface DailyStatMapper { @Select("SELECT stat_date, " + "SUM(confirmed) AS confirmed, " + "SUM(cured) AS cured, " + "SUM(dead) AS dead " + "FROM daily_stat " + "WHERE city = '' " + "GROUP BY stat_date ORDER BY stat_date") List<TrendVO> selectNationalTrend(); }

只取city = ''的行,是因为我入库时省级记录的城市字段为空字符串。同理,查某省趋势时加一个province = #{province}条件即可。

实际项目里数据量并不大,即使是全国按天积累几年,也就几千行,MySQL 完全扛得住。但我还是在stat_date和province上建了联合索引,因为接口会反复按日期和地区查询。

3.4 用 Spring Cache 给图表接口加速

疫情数据本身不是高频变化数据,一小时甚至一天更新一次就足够了。所以我给统计接口加了一层本地缓存:

@Cacheable(cacheNames = "trend:national", unless = "#result == null") public TrendVO nationalTrend() { return statMapper.selectNationalTrend(); }

在手动同步数据成功后,调用@CacheEvict把相关缓存清掉,下一次请求就能拿到新数据。直接用 Caffeine 本地缓存就够了,没必要为个人项目引入 Redis。

3.5 换成 SSM、PHP、ASP.NET 后的等价实现

很多人会纠结这几种后端技术到底有什么不同,我可以直接给结论:业务逻辑完全一样,换的只是框架语法。

  • SSM 版本:把 SpringBoot 的自动配置拆回 XML 和注解扫描,Mapper 改用 XML 文件写 SQL,定时任务用 Spring 的任务命名空间配置。代码分层更重,但压力测试和调试成本更高。
  • PHP 版本:用 ThinkPHP 6 的Console命令做定时同步,控制器返回 JSON,使用 PDO 预处理防止注入。PHP 部署方便,但接口里整数和字符串的边界要特别注意,否则前端图表可能拿到字符串导致计算异常。
  • ASP.NET 版本:用 .NET 8 Minimal API + EF Core,定时任务用BackgroundService,LINQ 写聚合查询非常顺手。缺点是 Linux 上部署要额外安装运行时,或者发布为自包含程序,包体较大。

这些方案的共同点是:表结构不变、接口契约不变、前端页面不变。所以真正值钱的部分是数据建模和指标口径,而不是选了什么语言。

4. 前端可视化:Vue3 + ECharts 搭建疫情看板

4.1 Vue3 项目初始化与目录划分

前端我使用 Vite 创建项目,并安装了 vue-router、pinia、axios 和 echarts。目录结构按功能分块:

src/ api/ # 所有后端接口请求封装 assets/map/ # 全国地图 JSON components/ # 图表组件 views/ dashboard/ # 总览看板 store/ # Pinia 状态 utils/ # echarts 封装

为什么不把所有图表写在一个页面文件里?因为疫情看板至少包含地图、趋势图、排行榜、数据卡片四类组件,拆开后每个组件只负责一件事,后续维护和复用都轻松。

4.2 全国地图下钻实现

地图是可视化平台的门面,也是新手出错最多的地方。ECharts 本身不提供地图数据,需要准备一份全国行政区划 GeoJSON,然后通过registerMap注册。

import * as echarts from 'echarts'; import regionGeo from '@/assets/map/region.json'; echarts.registerMap('region', regionGeo);

核心配置:

const option = { tooltip: { trigger: 'item', formatter: (params) => { return `${params.name}<br/>累计确诊:${params.value ?? '暂无'}` } }, visualMap: { min: 0, max: 5000, text: ['高', '低'], inRange: { color: ['#e0f3f8', '#fee090', '#fc8d59', '#d73027'] } }, series: [{ type: 'map', map: 'region', roam: true, label: { show: true }, data: mapData }] };

visualMap会自动根据值的大小映射颜色深浅,不需要手动设置每个地区的itemStyle,这个配置是地图“色阶图”的关键。地图点击事件用来下钻:

chart.on('click', (params) => { router.push({ path: '/province', query: { name: params.name } }); });

4.3 趋势图与排行图的细节处理

趋势图我用了双 Y 轴方案,因为累计确诊和治愈、死亡数值不在一个量级,如果共用一根轴,后两个曲线会贴着底部,几乎看不出变化:

const option = { legend: {}, tooltip: { trigger: 'axis' }, xAxis: { type: 'category', data: dates }, yAxis: [ { type: 'value', name: '确诊', position: 'left' }, { type: 'value', name: '治愈/死亡', position: 'right' } ], series: [ { name: '累计确诊', type: 'line', yAxisIndex: 0, data: confirmedList }, { name: '累计治愈', type: 'line', yAxisIndex: 1, data: curedList }, { name: '累计死亡', type: 'line', yAxisIndex: 1, data: deadList } ] };

排行榜用横向柱状图,按省份某一天的新增数量排序,只展示前 15 位。该页面的数据不需要实时轮询,我设置了一个 5 分钟定时刷新,避免给后端接口造成不必要的压力。

4.4 封装一个 useEChart 组合式函数

在 Vue3 里,最规范的做法是把图表的初始化、自适应、销毁都封装到一个组合式函数里:

export function useEChart(containerRef) { let chart = null; const initChart = () => { if (!containerRef.value) return; chart = echarts.init(containerRef.value); window.addEventListener('resize', () => chart?.resize()); }; const setOption = (option) => { if (!chart) initChart(); chart.setOption(option); }; const disposeChart = () => { window.removeEventListener('resize', () => chart?.resize()); chart?.dispose(); chart = null; }; onMounted(initChart); onBeforeUnmount(disposeChart); return { initChart, setOption }; }

这里必须强调:echarts.init只能在 DOM 渲染完成后调用。如果你在setup里直接初始化,容器的宽度是 0,图表会显示空白。所有图表组件我都配合nextTick或onMounted使用。

4.5 跨域配置与 Nginx 部署

开发环境用 Vite 代理解决跨域:

server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }

生产环境用 Nginx 同时托管前端静态资源并反向代理后端接口:

server { listen 80; root /usr/share/nginx/html; index index.html; location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { try_files $uri $uri/ /index.html; } }

最后一行try_files是处理 Vue Router history 模式的关键,否则刷新子路由页面会 404。

5. 排雷记录:疫情可视化项目最常见的6个问题

5.1 地图省份名称对不上,区域显示空白

这是我第一次联调地图时最头疼的问题。ECharts 地图取值后面的name字段,而后端数据库里保存的可能是简称或者带别名的地区名,两边匹配不上,地图就默认为 0,显示一片空白。

解决思路是维护一张地区映射表,或者在后端返回时统一把province字段映射成地图 JSON 里的标准名称。简单做法是代码里做一次normalizeRegionName替换,去掉“省”“市”等后缀再匹配。

5.2 定时任务重复执行,数据翻倍

Spring 的@Scheduled在单机模式下没问题,但如果在多实例部署情况下,每个实例都会执行一次任务,导致重复入库。即使单机运行,也可能因为手动调度和自动调度重叠而触发重复写入。

我的处理方法有两个:一是数据库唯一索引兜底,二是写入前先按日期删除。更严谨的团队可以用 ShedLock 或数据库锁表,个人项目没必要把复杂度拉满。

5.3 图表初始化时宽度为 0,渲染空白

图表容器如果被v-if控制,或者父组件还在异步加载数据时,容器还没出现在 DOM 里。直接在onMounted里初始化,很容易拿到 0 宽高。

解决方法是把初始化放到数据请求完成后的nextTick中,同时给容器设置一个固定高度,比如style="height: 420px"。宽度最好由父容器决定,不要在option里硬编码。

5.4 接口数据被前端时区转换“偷走”了一天

后端如果返回2024-01-01T00:00:00+08:00这种带时区的字符串,前端new Date()再格式化,可能渲染成前一天。尤其是部署到境外服务器时,时区问题非常容易踩。

我最终统一约定:后端接口日期返回yyyy-MM-dd纯字符串,前端不做任何时区转换,直接展示。这个约定从源头上消灭了时间偏移导致的“曲线断层”。

5.5 接口跨域通了,但请求头带不上 Token

开发环境配了 Vite 代理后,跨域一般不是问题。但如果直接从前端地址访问后端地址,并且需要自定义 Header 时,后端要支持 CORS:

@Configuration public class CorsConfig { @Bean public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOriginPattern("*"); config.addAllowedHeader("*"); config.addAllowedMethod("*"); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); } }

如果设置了allowCredentials(true),就不能用*作为允许来源,要写成具体的访问域名,这一点很容易被忽略。

5.6 性能问题速查表

场景现象处理方案
全国趋势接口反复查询数据库 CPU 升高增加 Spring Cache 缓存,缓存时间 30 分钟
地图数据量过大前端渲染卡顿首次加载只取省级数据,点击后再取市级
排行接口慢SQL 扫描全表联合索引优化,按日期先过滤
同步日志太多日志文件膨胀按天滚动输出,只保留 30 天

6. 部署上线与扩展思路

6.1 前后端打包部署流程

前端执行npm run build生成dist目录,后端执行mvn package生成 fat jar。部署时两者可以放在同一台服务器:

# 简单启停脚本 java -jar covid-dashboard.jar > app.log 2>&1 &

我建议把前端静态文件放到 Nginx 的 HTML 目录,后端 jar 单独放在/app目录,并用systemd管理进程。这样重启前端只需要 reload Nginx,重启后端只需要重启服务,互不干扰。

6.2 把疫情平台泛化成通用数据可视化基座

做完这个项目后我发现,真正可复用的不是疫情数据,而是“数据接入—清洗—存储—聚合—展示”的框架。如果后续想扩展成商品销量分析平台或者城市基础设施监控平台,只需要做两件事:

  1. 替换数据采集模块,把“拉取疫情 JSON”换成新数据源;
  2. 更换指标配置,把确诊、治愈、死亡替换成销量、库存、退货等业务指标。

Vue3 和 ECharts 部分几乎不需要动,因为图表的输入始终是“日期 + 地区 + 多个数值字段”。

6.3 我做完整个项目的几点体会

我最初是用 SSM 写的第一版,后来重构到 SpringBoot,最大的感觉是:框架只是工具,数据建模和接口设计才是核心。SSM 里大量时间花在 XML 配置和环境搭建上,SpringBoot 把注意力拉回到业务本身。前端地图匹配问题反而花的时间最多,因为地区命名不一致这类脏数据问题,不是靠写代码能自动解决的,必须反复比对。

最后说一个很实用的小技巧:每个定时同步任务执行时,把拉取到多少条、入库成功多少条、失败多少条全部打印到日志。别小看这几行日志,一旦图表数据对不上,翻日志定位是最快的,否则你只能对着数据库和前端页面瞎猜。做数据可视化项目,先把数据质量守住,图表怎么画都是好看的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询