毕业生招聘信息可视化分析系统听起来很像一个课程设计里常见的“后台管理”但我做完之后最大的感受是真正有价值的部分根本不在增删改查而在数据怎么算、图怎么画、前后端怎么把“统计结果”变成“业务判断”。这个项目我用的技术栈是三件套——后端 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 生态那么激进但对这种内容密集型的管理类系统来说完全够用。下表是我当时做选型对比时的记录简单明了对比维度DjangoFlaskSpring BootORM 分组聚合内置写法简洁需自己实现或依赖 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_length100) company_name models.CharField(公司名称, max_length100) city models.CharField(工作城市, max_length50) salary_min models.IntegerField(薪资下限, default0) salary_max models.IntegerField(薪资上限, default0) education models.CharField(学历要求, max_length20, choicesEDUCATION_CHOICES, defaultcollege) publish_date models.DateField(发布日期) created_at models.DateTimeField(创建时间, auto_now_addTrue) 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(totalCount(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(cntCount(id)) .filter(cnt__gt1) )找到重复组之后再对每组保留最早或最近的一条。二是城市字段不规范。有的写“北京”有的写“北京市”还有的写“北京朝阳”。做地域地图必须统一到城市粒度我的做法是在清洗阶段把所有城市名映射成标准地图名称比如统一用省份加地市的叫法。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(totalCount(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( bucketCase( When(salary_avg__lt5000, thenValue(5k以下)), When(salary_avg__lte10000, thenValue(5-10k)), When(salary_avg__lte15000, thenValue(10-15k)), When(salary_avg__lte20000, thenValue(15-20k)), defaultValue(20k以上), output_fieldCharField(), ) ) .values(bucket) .annotate(totalCount(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(yearExtractYear(publish_date), monthExtractMonth(publish_date)) .values(year, month) .annotate(totalCount(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__gtestart) if end: queryset queryset.filter(publish_date__lteend)这样前端在做时间区间筛选时所有图表可以基于同一个过滤逻辑刷新数据而不是各画各的、各查各的。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(totalCount(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, pkpk) 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 charsetutf-8 title毕业生招聘信息可视化分析系统/title link relstylesheet href{% static layui/css/layui.css %} script src{% static layui/layui.js %}/script script src{% static echarts/echarts.min.js %}/script /head body classlayui-layout-body div classlayui-layout layui-layout-admin div classlayui-header !-- 顶部标题栏 -- /div div classlayui-side layui-bg-black !-- 导航菜单 -- /div div classlayui-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固定为 0data是当前页的列表数据。这个约定在 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 图表选型映射表不同数据应该用不同图形选错图会让信息表达大打折扣。我当时的映射逻辑是统计维度图表类型核心配置点城市/地域分布中国地图geoseries.typemap需注册 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 自适应能做一部分布局调整但四个图表挤在小屏上还是会乱掉。如果演示用的是电脑至少把窗口缩放测试做一遍很多布局问题都是以窗口不是全屏的状态暴露出来的。
阅读完成 · 觉得有帮助?