毕业生招聘信息可视化分析系统,听起来很像一个课程设计里常见的“后台管理”,但我做完之后最大的感受是:真正有价值的部分根本不在增删改查,而在数据怎么算、图怎么画、前后端怎么把“统计结果”变成“业务判断”。这个项目我用的技术栈是三件套——后端 Django,图表 ECharts,界面框架 Layui(很多人会拼成 Layul,搜索资料的时候注意下正确写法)。这套组合在同类型项目里出现频率极高,不是因为它有多时髦,而是它确实能覆盖“数据能查、统计能算、图表能画、页面不至于太丑”这四件事。下面我把从建模到图表渲染的完整实现过程、统计接口设计思路和踩坑记录都写出来,正在做 Django 项目实训、毕业设计,或者想给招聘类数据系统补一个可视化看板的同学,可以照着这条路走。
1. 项目拆解:毕业生招聘信息可视化到底要分析什么
在动工之前,我先把“可视化分析”这四个字拆开了看。招聘信息的可视化不是说把每一条岗位记录画成表格就行,而是要回答几个非常具体的问题:毕业生都往哪些城市投递、哪些学历门槛最普遍、薪资区间集中在哪儿、岗位发布量随时间是怎么变化的。如果这些问题都没有明确的业务答案,那图就是装饰品,没有任何决策价值。
1.1 核心功能与数据维度
做这类系统,我建议业务功能围绕两块设计:一块是招聘信息的管理与检索,另一块是统计看板。信息管理解决“数据从哪里来、怎么维护”的问题,统计看板解决“数据怎么用”的问题。
具体来说,我的系统里主要保留了这些数据维度:
- 岗位名称,比如 Java 开发、产品经理、运营专员。
- 公司名称,便于按企业维度做交叉分析。
- 工作城市,这是地域分布图的基础字段。
- 学历要求,高中、大专、本科、硕士、博士,这是学历分布饼图的依据。
- 薪资范围,存下限和上限两个数值字段,后面做薪资区间统计全靠它。
- 发布日期,用于做月度趋势和季节性分析。
模块层面,我实现了岗位列表的分页查询、多条件筛选(城市、学历、薪资区间)、新增编辑删除,以及一个 Dashboard 页面。Dashboard 页面上放了四类图表:中国地图看地域热度、饼图看学历结构、柱状图看薪资区间、折线图看月度发布趋势。右侧再配一个 Layui 表格展示实时数据明细,点击图表的图例还能联动刷新下方列表。这样的功能密度对实训项目或毕设来说刚好,不会因为功能太少显得单薄,也不会因为摊子铺太大导致写不完。
1.2 为什么是 Django + ECharts + Layui 这个组合
这个技术组合经常被拿来和 Vue + Spring Boot、Flask + Chart.js 等方案对比,我这里说一下选择理由。
后端选 Django,最重要的原因是它的 ORM 对统计类查询特别友好。招聘数据可视化有大量分组聚合操作,比如按城市分组统计、按学历分组统计、按月分组统计,Django 的annotate和values组合可以很短的时间写出 SQL 级别的分组逻辑,不需要像写原生 SQL 那样维护一堆字符串。而且 Django 自带 Admin 后台,招聘数据录入都可以直接丢给 Admin 搞定,连前端表单都省了。如果你用 Flask,那这套 CRUD 和统计逻辑基本都要自己重新造轮子,项目后期会很累。
前端图表选 ECharts,看中的是它的生态和配置项完整度。ECharts 对地图、渐变柱状图、饼图中心文字、工具箱组件这些高频需求都有现成配置,社区案例多到随手能搜到。相比之下 Chart.js 虽然更轻量,但在中国地图、复杂视觉引导线这类场景上支持弱不少。
Layui 的选择则更实际:它是一个更接近“传统前端开发”的框架,基于 jQuery 模块化开发,后端工程师不用去啃组件化和构建工具链。它的表格table模块自带分页、排序、列渲染,配合form模块做筛选条件,短时间内就能拼出一个有模有样的管理后台。虽然 Layui 官方更新节奏不像 Vue 生态那么激进,但对这种内容密集型的管理类系统来说完全够用。
下表是我当时做选型对比时的记录,简单明了:
| 对比维度 | Django | Flask | Spring Boot |
|---|---|---|---|
| ORM 分组聚合 | 内置,写法简洁 | 需自己实现或依赖 SQLAlchemy | 有,但配置成本高 |
| Admin 后台 | 自带,省工作量大 | 需要第三方扩展 | 需要额外开发 |
| 适合快速实训项目 | 非常适合 | 适合小接口 | 适合企业级但重 |
前端这块,ECharts 在图表丰富度和文档完善程度上优势太明显,基本没有悬念。而 Layui 和 Bootstrap 相比,区别在于 Layui 自带完整的表格和数据交互组件,Bootstrap 则需要你自己组合各种插件才能达到同等效果。
2. Django 模型与查询设计:让统计在后面先算明白
很多初学者做 Django 可视化项目,一上来就急着写前端页面,结果做到统计接口的时候发现数据模型设计不合理,要么缺字段,要么字段类型不对,只能返工。模型是整个项目的底层地基,这一层稳了,后面全靠它铺路。
2.1 JobInfo 模型:字段不是随便建的
我用的是单表模型来做招聘岗位信息存储,字段设计如下:
from django.db import models class JobInfo(models.Model): EDUCATION_CHOICES = [ ('high_school', '高中/中专'), ('college', '大专'), ('bachelor', '本科'), ('master', '硕士'), ('doctor', '博士'), ] position_name = models.CharField('岗位名称', max_length=100) company_name = models.CharField('公司名称', max_length=100) city = models.CharField('工作城市', max_length=50) salary_min = models.IntegerField('薪资下限', default=0) salary_max = models.IntegerField('薪资上限', default=0) education = models.CharField('学历要求', max_length=20, choices=EDUCATION_CHOICES, default='college') publish_date = models.DateField('发布日期') created_at = models.DateTimeField('创建时间', auto_now_add=True) class Meta: ordering = ['-publish_date'] indexes = [ models.Index(fields=['city', 'education', 'publish_date']), ] def __str__(self): return f"{self.position_name} @ {self.company_name}"这里有两个设计点我特意强调一下。
第一个是薪资不要用字符串类型,比如“8k-15k”,而是拆成salary_min和salary_max两个整数字段。如果把薪资写成字符串,后面做“5k以下、5-10k、10-15k”这种区间统计时会非常痛苦:你不得不用正则去截取数字,还要处理“面议”“年薪”“日薪”之类的脏文本。拆成整形字段后,不管是区间筛选还是分数统计,一行 ORM 就能搞定。
第二个是education字段用 choices 加短代码。有人会直接在库里存中文“本科”,这样也不是不行,但后续如果要改名称、做国际化,或者统计 key 变了,就会很麻烦。用短代码存库,显示时映射成中文,既保证了数据一致,又和get_education_display()天然配合。
在索引设计上,我给city、education、publish_date建了联合索引。因为统计接口的过滤条件主要围绕城市、学历和时间点走,这个索引能明显加快筛选和分组的速度。数据量在几万条的时候感觉不出来,但一旦到十万级以上,索引和非索引的差距是秒级的。
2.2 统计都是 ORM 的活儿:aggregate 与 annotate
招聘数据可视化的统计逻辑,绝大多数能用 Django 的values + annotate + Count写出来。比如统计各学历的岗位数量:
from django.db.models import Count result = JobInfo.objects.values('education').annotate(total=Count('id'))这行代码相当于 SQL 里的:
SELECT education, COUNT(id) AS total FROM jobinfo_jobinfo GROUP BY education拿到的是[{'education': 'bachelor', 'total': 123}, ...]这样的结构。我通常会在视图层把它们整理成 ECharts 需要的格式,比如[{"name": "本科", "value": 123}]。
这种写法的好处是,统计逻辑都在数据库端完成,而不是把几万条记录拉回 Python 内存里再循环数一遍。很多新手会下意识写all()然后for循环计数,数据少没什么,数据一多页面就卡死。ORM 的分组聚合是这类统计业务的默认手段,没有例外。
2.3 从录入到清洗:招聘数据最脏的几个地方
真实爬来的或手工录入的招聘数据,没有一版是干净的,我在做数据清洗时踩了不少坑。
一是重复数据。同一条岗位信息可能被爬虫抓了两遍,或者人工录入时复制错了。我处理方法是先按岗位名称、公司名称、城市、薪资上下限几个字段做去重,再保留发布日期最新的那条。Django 里可以这样做:
from django.db.models import Count duplicates = ( JobInfo.objects .values('position_name', 'company_name', 'city', 'salary_min', 'salary_max') .annotate(cnt=Count('id')) .filter(cnt__gt=1) )找到重复组之后,再对每组保留最早或最近的一条。
二是城市字段不规范。有的写“北京”,有的写“北京市”,还有的写“北京朝阳”。做地域地图必须统一到城市粒度,我的做法是在清洗阶段把所有城市名映射成标准地图名称,比如统一用省份加地市的叫法。ECharts 地图数据匹配非常死板,名称差一个字就显示不出来,这一点在第五节会专门说。
三是发布日期缺失或格式混乱。Django 的DateField接收不了“2024.01.15”这种格式,我导入数据时统一用先清洗再入库的逻辑,尽量在源头就把格式对齐,而不是等查询时报错再去补。
3. 统计接口设计:把数据库算好的结果喂给 ECharts
模型和基础查询搞定之后,接下来就是写统计接口。这一步的核心思路是:所有分组、分箱、排序的脏活都在 Django 后端完成,前端只负责接收 JSON、调用 ECharts 的setOption。如果你把统计逻辑散落在一堆 JavaScript 里,项目规模一大,维护成本会直线上升。
3.1 一套通用的 JSON 返回结构
统计接口的返回结构,我尽量统一成下面这种:
{ "data": [ {"name": "本科", "value": 1280}, {"name": "硕士", "value": 560} ] }ECharts 饼图、地图、图例都认这种{name, value}结构。柱状图则可能需要categories和values两个数组:
{ "categories": ["5k以下", "5-10k", "10-15k", "15-20k", "20k以上"], "values": [200, 850, 900, 180, 80] }两种结构可以并存,反正后端按图表的“口味”来给数据,前端越省事越好。
3.2 学历分布与薪水分箱
学历分布的接口代码很朴素:
from django.db.models import Count from django.http import JsonResponse def education_distribution(request): rows = JobInfo.objects.values('education').annotate(total=Count('id')) label_map = dict(JobInfo.EDUCATION_CHOICES) data = [ {"name": label_map.get(row['education'], row['education']), "value": row['total']} for row in rows ] return JsonResponse({"data": data})薪水分箱比学历分布麻烦一档,因为薪资是连续数值,需要按区间拆桶。我先用F表达式算出月薪中位数,再用Case/When打标签:
from django.db.models import Q, F, Case, When, Value, CharField, Count def salary_distribution(request): buckets = ( JobInfo.objects .annotate(salary_avg=(F('salary_min') + F('salary_max')) / 2) .annotate( bucket=Case( When(salary_avg__lt=5000, then=Value('5k以下')), When(salary_avg__lte=10000, then=Value('5-10k')), When(salary_avg__lte=15000, then=Value('10-15k')), When(salary_avg__lte=20000, then=Value('15-20k')), default=Value('20k以上'), output_field=CharField(), ) ) .values('bucket') .annotate(total=Count('id')) ) data = [{"name": b['bucket'], "value": b['total']} for b in buckets] return JsonResponse({"data": data})这段代码相当于在 SQL 里用CASE WHEN做区间分桶,然后GROUP BY bucket。用 ORM 写出来的好处是逻辑都留在 Python 代码里,改区间时直接改阈值就行,不需要去数据库管理工具里改 SQL。
3.3 月度趋势与时间过滤
月度趋势要用到ExtractMonth。比如统计每个月发布的岗位数量:
from django.db.models import Count from django.db.models.functions import ExtractMonth, ExtractYear def monthly_trend(request): rows = ( JobInfo.objects .annotate(year=ExtractYear('publish_date'), month=ExtractMonth('publish_date')) .values('year', 'month') .annotate(total=Count('id')) .order_by('year', 'month') ) data = [ {"name": f"{row['year']}-{str(row['month']).zfill(2)}", "value": row['total']} for row in rows ] return JsonResponse({"data": data})这类时间统计接口,我通常会额外支持两个查询参数:start_date和end_date。在接口里加上:
start = request.GET.get('start_date') end = request.GET.get('end_date') queryset = JobInfo.objects.all() if start: queryset = queryset.filter(publish_date__gte=start) if end: queryset = queryset.filter(publish_date__lte=end)这样前端在做时间区间筛选时,所有图表可以基于同一个过滤逻辑刷新数据,而不是各画各的、各查各的。
3.4 写操作与缓存的同步问题
这个项目做到后段,我发现一个非常现实的问题:统计接口虽然写着爽,但每次页面刷新都要重新对全表做一次聚合。数据量一旦涨到几万甚至十万行,响应时间就会从几十毫秒涨到几百毫秒。这时候用 Django 缓存最省事:
from django.core.cache import cache def education_distribution(request): cache_key = 'stats:education' data = cache.get(cache_key) if data is None: rows = JobInfo.objects.values('education').annotate(total=Count('id')) data = [...] cache.set(cache_key, data, 60 * 60) # 缓存 1 小时 return JsonResponse({"data": data})但引入缓存之后就必须处理缓存失效问题。尤其是删除操作,很多人会忽略这一环:JobInfo删掉了一批岗位,统计接口因为命中缓存依然返回旧数据,前端看板上的数字和后台数据就对不上了。
我在处理“django 执行查询-删除对象”这类操作时,会在删除接口里主动清掉相关统计缓存:
def job_delete(request, pk): job = get_object_or_404(JobInfo, pk=pk) job.delete() cache.delete_many([ 'stats:education', 'stats:salary', 'stats:monthly', 'stats:city', ]) return JsonResponse({"code": 0, "message": "删除成功"})一句话总结:统计接口写得再漂亮,也要考虑数据进入和退出后的同步。缓存失效策略在设计接口时就要想好,不要等线上数据对不上了再去补。
4. Layui 框架:管理后台的架子先搭起来
Web 端仪表盘布局是这类系统的脸面。我用 Layui 的原因在前面说过,它不需要引入 React/Vue 那种重依赖链路,直接把 CSS 和 JS 放进静态目录,模板引擎里写几行 HTML 就能出效果。
4.1 页面布局和静态资源引入
Layui 最常用的布局是layui-layout-admin,它自带顶部导航、左侧菜单和右侧内容区三个部分。我在 Django 模板里写的是:
{% load static %} <!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>毕业生招聘信息可视化分析系统</title> <link rel="stylesheet" href="{% static 'layui/css/layui.css' %}"> <script src="{% static 'layui/layui.js' %}"></script> <script src="{% static 'echarts/echarts.min.js' %}"></script> </head> <body class="layui-layout-body"> <div class="layui-layout layui-layout-admin"> <div class="layui-header"> <!-- 顶部标题栏 --> </div> <div class="layui-side layui-bg-black"> <!-- 导航菜单 --> </div> <div class="layui-body"> <!-- 图表区 + 表格区 --> </div> </div> </body> </html>这里有个细节:ECharts 的 JS 文件我是在本地/static/echarts/下放的,而不是直接引用 CDN。为什么?因为这类项目经常部署在校内服务器或者比赛环境,外网可能不通。把 ECharts 和 Layui 都本地化,是避免最后演示时图表全白屏的最稳妥做法。
4.2 表格渲染与筛选条件联动
Layui 的table模块渲染列表,我直接在 JS 里配置 URL 和分页参数:
layui.use(['table', 'form'], function () { const table = layui.table; const form = layui.form; table.render({ elem: '#jobTable', url: '/api/job/list/', page: true, cols: [[ { field: 'position_name', title: '岗位名称' }, { field: 'company_name', title: '公司名称' }, { field: 'city', title: '城市' }, { field: 'salary_min', title: '薪资下限' }, { field: 'salary_max', title: '薪资上限' }, { field: 'education_display', title: '学历要求' }, { field: 'publish_date', title: '发布日期' } ]] }); form.on('submit(searchForm)', function (data) { table.reload('jobTable', { where: data.field, page: { curr: 1 } }); return false; }); });筛选字段通过where传给 Django 视图,视图里用request.GET接收再做filter。这种模式非常标准,几乎没有额外学习成本。需要注意的坑是:Django 分页接口返回的 JSON 必须符合 Layui 表格的约定格式,否则表格数据出不来。我封装的返回结构是:
{ "code": 0, "msg": "", "count": 100, "data": [] }code固定为 0,data是当前页的列表数据。这个约定在 Layui 文档里有写,但新手经常忘,导致表格一直空着只有分页在转。
4.3 CSRF Token 和 Ajax 提交
Layui 的table模块 GET 请求没问题,但如果做新增、编辑、删除,通常走 POST/DELETE,这时候 Django 的 CSRF 校验就会跳出来。
默认情况下,Django 会把csrftoken放在 Cookie 里,所以你要在 Ajax 请求头里把这个值带回去。我封了一个通用函数:
function getCookie(name) { const cookieValue = document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)')?.[2] || ''; return decodeURIComponent(cookieValue); } $.ajaxSetup({ beforeSend: function (xhr, settings) { if (!/^(GET|HEAD|OPTIONS|TRACE)$/.test(settings.type) && !this.crossDomain) { xhr.setRequestHeader('X-CSRFToken', getCookie('csrftoken')); } } });这个函数是 Django 官方文档里给出的标准做法,放到项目中后增删改请求就不再会被 403 挡住了。处理“django cookie 设置 token”这种需求时,本质就是读取 Cookie、塞进请求头,没别的玄学。
5. ECharts 图表落地:从柱状图到地图的完整配置
图表是整个系统的视觉高潮。ECharts 配置项多,但真正高频用到的就那么几个:tooltip、toolbox、渐变柱状图、饼图中心文字、折线图刻度和地图注册。我逐一过一遍。
5.1 图表选型映射表
不同数据应该用不同图形,选错图会让信息表达大打折扣。我当时的映射逻辑是:
| 统计维度 | 图表类型 | 核心配置点 |
|---|---|---|
| 城市/地域分布 | 中国地图 | geo+series.type='map',需注册 geoJSON |
| 学历分布 | 饼图 | 半径环状,中心放岗位总数 |
| 薪资区间 | 柱状图 | 渐变填充,数值显示在柱顶 |
| 月度发布趋势 | 折线图 | x 轴刻度控制,tooltip展示详细值 |
| 岗位类型 Top10 | 横向条形图 | yAxis反转,数据从大到小排序 |
基本上一张 Dashboard 页面放这四个图,信息量就很均衡了。
5.2 渐变柱状图与工具箱
薪资分布的渐变柱状图,我用的配置如下:
const salaryChart = echarts.init(document.getElementById('salaryChart')); salaryChart.setOption({ tooltip: { trigger: 'axis' }, toolbox: { feature: { saveAsImage: { title: '保存图片' }, dataView: { title: '数据视图' }, restore: { title: '还原' } } }, xAxis: { type: 'category', data: data.categories }, yAxis: { type: 'value', name: '岗位数量' }, series: [{ type: 'bar', data: data.values, label: { show: true, position: 'top', formatter: function (params) { return params.value > 0 ? params.value : ''; } }, itemStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ { offset: 0, color: '#83bff6' }, { offset: 1, color: '#2f6fe4' } ]) } }] });对应“y 轴标签如何在每个柱子上面显示”的需求,答案就是在series.label里设置position: 'top'。很多人一开始会在yAxis.axisLabel里找答案,那是坐标轴的取值标签,不是柱子上的数值。label是数据点本身的标签,用在柱状图里就是柱顶数值。
toolbox是个很实用的组件,尤其适合展示型项目。评审老师或领导在看板上一眼看不到数据明细,可以用“数据视图”按钮直接查看,还能“保存图片”存到本地,这个小组件能帮你省下很多口头解释。
5.3 饼图中间文字
“echarts pie 中间的字”也是一个高频搜索点。饼图默认中间是空的,如果你想在中心展示“岗位总数”这样的指标,最方便的方法是叠加一个graphic元素:
const eduChart = echarts.init(document.getElementById('eduChart')); let total = 0; data.forEach(item => (total += item.value)); eduChart.setOption({ tooltip: { trigger: 'item' }, legend: { bottom: 0 }, series: [{ type: 'pie', radius: ['40%', '65%'], data: data, label: { formatter: '{b}: {c} ({d}%)' } }], graphic: [{ type: 'text', left: 'center', top: 'center', style: { text: total + '\n岗位总数', textAlign: 'center', fill: '#333', fontSize: 16 } }] });用graphic的优势是文字完全自由,不依赖 series 的坐标系,可以单独控制颜色、字号、换行。如果需求只是居中放一行字,也可以用title的left: 'center', top: 'middle'实现,但文字排版能力弱很多。项目里我最终选的是graphic,维护起来更直白。
5.4 折线图刻度与地图数据本地化
折线图最容易出问题的不是 series,而是 x 轴刻度。当数据跨了 12 个月甚至更长时间时,ECharts 会默认抽稀刻度,导致前几个月和后几个月的点挤在一起。我通常会显式加上:
xAxis: { type: 'category', data: months, axisLabel: { interval: 0, rotate: 30 } }interval: 0表示强制显示所有刻度标签,rotate: 30是每月文案斜着排列防止重叠。如果月份真的太多,再考虑interval: 2每隔一个月显示一次。这个配置项不放在系列上,而是放在xAxis.axisLabel里,不熟悉的人容易找错地方。
地图这块,ECharts 5 不再内置中国地图数据,必须外挂 geoJSON。我在项目里先把地图 JSON 文件下载到了本地静态目录,然后:
echarts.registerMap('china', chinaGeoJson); const cityChart = echarts.init(document.getElementById('cityChart')); cityChart.setOption({ tooltip: { trigger: 'item' }, visualMap: { min: 0, max: maxValue, left: 20, top: 20, text: ['高', '低'], calculable: true }, series: [{ type: 'map', map: 'china', roam: true, data: cityData }] });这里的核心坑是 name 匹配。地图 JSON 里省份名称是标准叫法,如果你的数据库城市字段写的是简称或别名,图上是不会亮起来的。我干脆在数据清洗阶段就统一了城市名称。
5.5 数据加载与 resize 管理
所有图表初始化完之后,我做了一个统一的数据刷新函数:
function refreshAllCharts() { fetch('/api/stats/overview/') .then(res => res.json()) .then(data => { salaryChart.setOption({ ... }); eduChart.setOption({ ... }); cityChart.setOption({ ... }); trendChart.setOption({ ... }); }); } window.addEventListener('resize', function () { salaryChart.resize(); eduChart.resize(); cityChart.resize(); trendChart.resize(); });每次切换筛选条件时重新调refreshAllCharts(),前端代码量控制得很小。唯一要注意的是,ECharts 实例在页面里不要反复init,否则会产生性能浪费和重复渲染。初始化一次,后续都用setOption更新数据,这是最推荐的方式。
6. 踩坑复盘:前后端联调里最容易被绊倒的地方
项目做完回头看,真正耗时间的不是功能开发,而是各种不起眼的联调坑。这里把我踩得最深的几个列出来,也算给后面做同类项目的人排雷。
6.1 QuerySet.delete() 的坑
“django 执行查询-删除对象”,看起来是多简单的一件事,但里面有不少隐藏细节。
第一,QuerySet.delete()是批量删除,返回(total_count, {app_label.model: count})元组,很多人只调用不关心返回值,结果删了哪些表、删了多少行完全没数。这个返回值其实很值钱,我一般都会打日志,方便排查问题。
第二,切片后的 QuerySet 不能直接调用delete()。比如:
JobInfo.objects.all()[:10].delete() # 这是会报错的因为切片之后返回的是新的QuerySet,没有delete()方法。如果只想删前几条,要先取出 id 列表,再用Q条件删除。
第三,删除关联表数据时要注意级联。如果未来扩展了公司表、投递记录表,删除岗位对象时外键关联记录也会被 Django 级联删掉,这种“隐藏的删除”容易造成业务数据不可逆丢失。我现在的习惯是:涉及删除的接口统一做软删除或二次确认,不直接物理删除。
第四,前面提到的缓存失效问题。删完数据不清理统计缓存,页面和后台数据就永久不一致,直到缓存过期。这个小坑在我的项目里确实发生了一次,当时调试了很久才发现是缓存没清。
6.2 图表不显示常见的三个原因
我总结了我周围同学做 ECharts 项目时,“图表区域空白”的三种高频原因:
一是 JS 文件没正确加载。ECharts 和 Layui 的脚本标签顺序错了、路径写错了、静态文件配置没生效,都可能导致图表初始化报错。最笨也最有效的排查方法是打开浏览器控制台看有没有红色报错。很多项目图表不显示,不是代码逻辑的问题,而是echarts is not defined这类加载错误。
二是<div>容器没有高度。ECharts 需要一个有明确宽高的容器,如果父级<div>根本没设置高度或高度为 0,初始化出图表也是隐形的。我在模板里统一给图表容器加了:
.dashboard-chart { width: 100%; height: 350px; }三是地图数据名称对不上。geoJSON里的省名和接口返回的name不一致,地图就会空白但其他图表正常。这时要重点检查名称映射,而不是去调visualMap。
6.3 我做这个项目后的一些体会
回头再看整个项目,统计口径的设计比图表本身花了更多时间。比如薪资区间怎么分、学历怎么归类、时间趋势按周还是按月,这些看起来简单的决定其实会影响接口、图表、表格三处代码。我建议先把自己的分析维度定死,再开始写任何一行代码。
另外,如果你打算把这个项目继续扩展,可以考虑做岗位类型词云、公司维度对比,或者加入爬虫自动采集招聘数据。数据库层已经按规范化模型设计好了,后面加数据源只是往JobInfo里灌数据的事,统计接口和图表不用大改。
最后再分享一个很实用的小技巧:做完了 Dashboard 之后,一定要在浏览器按 F12 切到移动端尺寸看一眼。Layui 自适应能做一部分布局调整,但四个图表挤在小屏上还是会乱掉。如果演示用的是电脑,至少把窗口缩放测试做一遍,很多布局问题都是以窗口不是全屏的状态暴露出来的。