尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

基于Django+Python+Echarts的招聘数据可视化分析全流程实践

发布时间:2026/9/26 1:52:40

资讯中心
01
ARTICLE

基于Django+Python+Echarts的招聘数据可视化分析全流程实践

基于Django+Python+Echarts的招聘数据可视化分析全流程实践
简介这是一份基于Django、Python与Echarts技术栈的招聘数据可视化分析资源适合有一定Python基础的开发者学习前后端协作、数据清洗与可视化展示。压缩包共165个文件主要包含51个JavaScript脚本、39个JSON配置、14个Python源文件、12个CSS样式以及HTML模板、字体图标、SQLite数据库等整体约4.65MBDjango入口脚本、依赖清单、模型/视图/URL配置与Echarts图表脚本一应俱全目录结构清晰便于按模块拆解学习。项目覆盖数据清洗、Pandas统计分析和图表渲染等关键环节后端通过Django提供API接口返回JSON数据前端以柱状图、折线图等形式呈现薪资与岗位分布。已有2954人学习下载适合希望掌握DjangoEcharts完整流程的初学者作为实战参考。1. 这个项目到底解决了什么问题招聘数据的采集、入库与可视化全流程我第一次做 DjangoPythonEcharts 对招聘数据进行可视化分析 这套东西时以为工作量主要在画图上结果三天时间有两天下在数据清洗和接口调试上。这个项目标题拆开看其实是一个完整的“采集—存储—接口—图表”四层链路Python 负责把招聘网站的职位信息抓下来Django 负责建表、入库并输出 JSON 接口Echarts 拿到数据渲染成柱状图、饼图、折线图和中国地图。它面向的读者很明确正在准备毕业设计的在校生、想给简历加一个数据项目的新手以及刚学完 Django 基础但不知道怎么做完整项目的开发者。做完之后你能得到的不是一个孤立的图表而是一个本地跑得通、打开浏览器就能看到各地招聘行情的小型看板。这套组合的选型理由也经得起推敲Django 自带 ORM 和 Admin 后台省掉手动建表和后台管理的重复工作Echarts 是纯前端渲染接口返回什么就画什么跟 Django 的 JsonResponse 配合非常顺。后面的章节我按实际开发顺序来写先处理数据再进后端最后挂图表每一步都给出能直接抄的代码和参数说明。2. 数据从哪儿来用 Python 爬虫与清洗搭出数据分析的地基招聘数据分析最怕的不是不会画图而是拿到的数据根本不敢画。招聘网站上同一职位在不同城市的薪资写法不一致学历要求有的写“本科”有的写“学历不限”发布时间有的精确到分钟有的只给“一周内”。如果一开始不把数据整理成固定结构后面 Django 模型字段怎么定义都会难受。2.1 采集层怎么做用 requests 与 BeautifulSoup 抓职位列表页常见做法是先用 requests 请求职位搜索页再用 BeautifulSoup 解析 HTML 里的职位卡片。只要能拿到职位名称、公司名称、城市、薪资、学历、经验、发布时间这几项就足够支撑后续所有图表。先看一个最小采集样例import requests import time import random from bs4 import BeautifulSoup headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Referer: https://www.zhipin.com/, } all_jobs [] # 示例翻 5 页实际使用时把 url 换成你本地能正常访问的招聘网站搜索页 for page in range(1, 6): url fhttps://www.zhipin.com/web/geek/job?query数据分析师page{page} resp requests.get(url, headersheaders, timeout10) if resp.status_code ! 200: print(f第 {page} 页请求失败状态码 {resp.status_code}) continue soup BeautifulSoup(resp.text, html.parser) job_cards soup.select(.job-card-wrapper) for card in job_cards: job_name card.select_one(.job-name) company card.select_one(.company-name) salary card.select_one(.salary) city card.select_one(.job-area) item { job_name: job_name.text.strip() if job_name else , company: company.text.strip() if company else , salary: salary.text.strip() if salary else , city: city.text.strip() if city else , } # 这里只列了四个核心字段学历和经验要从卡片内继续往下取 all_jobs.append(item) # 请求间隔一定要留1 到 3 秒随机延时既是礼貌也是防封 time.sleep(random.uniform(1, 3)) print(f共采集 {len(all_jobs)} 条职位数据)这份代码的逻辑是先定义全局列表 all_jobs 存结果再按页循环请求。resp.status_code ! 200 时跳过本页避免一页失败导致整个脚本中断。选择器选了常见的 .job-card-wrapper 这类 class但不同招聘网站的卡片样式不同你需要按页面实际结构调整 select_one 里的选择器这是爬虫脚本里几乎一定会改的部分。两个参数值得关注timeout10 防止请求卡死random.uniform(1, 3) 让请求间隔不固定比固定 sleep(2) 更像真人操作。第一次跑通时建议只翻 1 页抓几页确认字段没丢再放开循环。2.2 清洗与薪资解析数据规范化的关键一步字段能抓到只是第一步脏数据不处理后面图表一定会翻车。最常见的问题是“15-20K”“10K-15K”“面议”这些薪资文本没法直接参与聚合统计必须拆成数值。我一般把清洗脚本独立成一个 CSV 输出而不是直接灌进数据库方便观察每一轮删了什么、改了什么。import pandas as pd import re df pd.read_csv(job_raw.csv, encodingutf-8-sig) print(清洗前数据量, len(df)) print(清洗前空值统计\n, df.isna().sum()) # 1. 去重同一公司同一职位同一城市视为同一条招聘 df df.drop_duplicates(subset[job_name, company, city], keepfirst) # 2. 去空职位、公司、城市为空的数据对图表毫无意义 df df.dropna(subset[job_name, company, city]) # 3. 薪资解析把 15-20K 转成平均薪资数值单位为 K def parse_salary(text): if not isinstance(text, str): return 0 # 有些字段带 15薪、13薪 之类后缀先把数字范围提取出来 match re.search(r(\d\.?\d*)[-~](\d\.?\d*)\s*K, text, re.IGNORECASE) if match: low float(match.group(1)) high float(match.group(2)) return round((low high) / 2, 1) # 统一薪资如 20K以上 match re.search(r(\d\.?\d*)\s*K以上, text, re.IGNORECASE) if match: return float(match.group(1)) # 面议、按天结算等无法处理的文本一律记为 0 return 0 df[salary_avg] df[salary].apply(parse_salary) # 4. 城市名去掉括号备注如 北京-朝阳区 转成 北京 df[city] df[city].str.replace(r[(].*?[)], , regexTrue) df[city] df[city].str.split(-).str[0] # 5. 把不需要参与分析的原始字段裁掉只留建模要用的列 df df[[job_name, company, city, salary, salary_avg, education, experience, publish_date]] df.to_csv(job_data.csv, indexFalse, encodingutf-8-sig) print(清洗后数据量, len(df)) print(薪资解析失败salary_avg0条数, (df[salary_avg] 0).sum())这段清洗脚本的核心是 parse_salary 函数先用正则抓数字范围再算平均数最后把“面议”归零。你可能会问为什么不直接丢掉薪资为 0 的因为公司名、城市这些信息仍然可以做数量统计所以只在画薪资相关图表时用 exclude(salary_avg0) 过滤数据本身先留着。清洗后的字段表大致是这样字段名含义清洗规则job_name职位名称去空保留原名company公司名称去空city城市去掉“-区域”后缀salary原始薪资文本保留原文备查salary_avg平均薪资数值字符串转 float单位 Keducation学历要求统一成“学历不限/大专/本科/硕士/博士”experience经验要求统一成“经验不限/1-3年/3-5年/5-10年/10年以上”publish_date发布时间统一成 yyyy-MM-dd 格式完成这步以后job_data.csv 就是整个可视化分析的稳定数据源。后面所有 Django 模型字段都是照着这张表设计的这也是为什么我在项目里坚持把 CSV 作为中间产物——它像一张数据库设计稿能让模型层少返工。3. Django后端模型、迁移与图表接口怎么设计数据整理好以后下一步是把 CSV 里的记录搬进 Django。这一步的体验直接决定你后面开发顺不顺新手最容易在这一步反复翻车问题大多集中在模型字段类型设计不合理、数据导入命令写得不可重复、以及接口返回中文乱码三个方面。3.1 最小工程结构与 JobPosting 模型设计先搭工程。假设你已经有了 Python 环境直接从创建虚拟环境开始# 建议用 venv不要全局安装 Django python -m venv venv # Windows 激活方式 venv\Scripts\activate # macOS / Linux 激活方式 source venv/bin/activate # 安装依赖 pip install django # 创建项目项目名用 myproject 这样的短名称即可 django-admin startproject myproject # 进入项目目录 cd myproject # 创建 app我把 app 命名为 jobs职责边界很清晰 python manage.py startapp jobs # 生成数据库 python manage.py migrate这里有个新手常犯的错误分不清 project 和 app 的区别。project 是整个站点的配置中枢app 是具体业务模块比如 jobs 这个 app 只负责招聘数据的模型、视图和接口。后面所有建表、写接口都在 jobs 里操作。接下来修改 jobs/models.pyfrom django.db import models class JobPosting(models.Model): job_name models.CharField(职位名称, max_length100, db_indexTrue) company models.CharField(公司名称, max_length100) city models.CharField(城市, max_length50, db_indexTrue) salary models.CharField(原始薪资, max_length20, blankTrue) salary_avg models.FloatField(平均薪资(K), default0) EDUCATION_CHOICES [ (学历不限, 学历不限), (大专, 大专), (本科, 本科), (硕士, 硕士), (博士, 博士), ] education models.CharField( 学历要求, max_length20, choicesEDUCATION_CHOICES, default学历不限, ) EXPERIENCE_CHOICES [ (经验不限, 经验不限), (1-3年, 1-3年), (3-5年, 3-5年), (5-10年, 5-10年), (10年以上, 10年以上), ] experience models.CharField( 经验要求, max_length30, choicesEXPERIENCE_CHOICES, default经验不限, ) publish_date models.DateField(发布日期, nullTrue, blankTrue) source_url models.URLField(来源链接, uniqueTrue) def __str__(self): return f{self.job_name} - {self.company} - {self.city} class Meta: ordering [-salary_avg]模型字段的设计参考了清洗后的 CSV 表但仍然有几个选择需要解释。salary_avg 用 FloatField 而不是 IntegerField是为了保留 17.5K 这类平均值。source_url 设置 uniqueTrue这是保证数据不重复的终极防线——哪怕清洗脚本漏了去重数据库层面也会拦截同一条招聘记录。education 和 experience 用了 choices 枚举这样做的好处是后续统计学历占比、经验分布时group by 出来的都是干净分组不会出现“本科 ”和“本科”混在一起的情况。城市字段这里刻意没有建关联表因为招聘数据分析只需要按城市名聚合不需要跟行政区划表做外键。如果以后要做地图下钻分析再单独建一个城市维度表也不迟。改完模型后执行迁移python manage.py makemigrations jobs python manage.py migratemakemigrations 会生成一个迁移文件migrate 负责真正把表建进数据库。每次修改模型字段这两个命令都要成对执行只跑 migrate 不跑 makemigrationsDjango 根本不知道你改了模型。3.2 把 CSV 变成数据库记录写一个可重复执行的管理命令有了模型下一步是把 job_data.csv 导入数据库。很多人第一反应是写一个 Python 脚本直接跑但更好的方式是把它写成 Django 管理命令。这样随时可以执行 python manage.py import_jobs --csvjob_data.csv 重新导入而且不会污染项目入口。在 jobs 目录下新建 management/commands 目录必须包含init.py 文件结构如下jobs/ management/ __init__.py commands/ __init__.py import_jobs.pyimport_jobs.py 内容import csv from datetime import datetime from django.core.management.base import BaseCommand, CommandError from jobs.models import JobPosting class Command(BaseCommand): help 从清洗后的 CSV 导入招聘数据重复执行不会产生重复记录 def add_arguments(self, parser): parser.add_argument(--csv, defaultjob_data.csv, helpCSV 文件路径) def handle(self, *args, **options): csv_path options[csv] created_count 0 skipped_count 0 try: with open(csv_path, encodingutf-8-sig) as f: reader csv.DictReader(f) for row in reader: publish_date None if row.get(publish_date): try: publish_date datetime.strptime( row[publish_date].strip(), %Y-%m-%d ).date() except ValueError: publish_date None # update_or_create 以 source_url 为唯一键 # 记录存在则更新不存在则创建天然幂等 _, created JobPosting.objects.update_or_create( source_urlrow[source_url], defaults{ job_name: row[job_name], company: row[company], city: row[city], salary: row[salary], salary_avg: float(row[salary_avg] or 0), education: row.get(education) or 学历不限, experience: row.get(experience) or 经验不限, publish_date: publish_date, }, ) if created: created_count 1 else: skipped_count 1 except FileNotFoundError: raise CommandError(f文件不存在{csv_path}) self.stdout.write( self.style.SUCCESS( f导入完成新增 {created_count} 条更新 {skipped_count} 条 ) )update_or_create 是这个命令的核心它的第一个参数 source_url 是查询条件defaults 是更新内容。同一份 CSV 重复跑三次第一次全部新增后面两次全部走更新不会出现数据翻倍。这个特性在后续要重新抓数据、重新导入时非常省心。读取 CSV 时用了 encodingutf-8-sig这是因为清洗阶段用 pandas 输出时加了 BOM 头如果不指定这个编码第一列字段名会带着看不见的 \ufeff 前缀导致 row[job_name] 直接 KeyError。这是 Python 读 CSV 最常见的坑照着我的写法可以避开。3.3 可视化页面的视图与 JSON 接口约定数据入库后前端需要的不只是一个页面更重要的是若干个能直接喂给 Echarts 的 JSON 接口。Echarts 的数据格式非常固定比如饼图要 [{name: 本科, value: 120}]折线图要 {xAxis: [...], series: [...]}。与其在页面里再转换一遍不如让 Django 接口直接输出这个格式。views.py 里写好页面视图和接口视图import json from django.db.models import Avg, Count from django.http import JsonResponse from django.shortcuts import render from django.utils.timezone import now from django.db import connection from collections import Counter, defaultdict from jobs.models import JobPosting def index(request): 渲染可视化看板页面 return render(request, jobs/dashboard.html) def stats_api(request): 按城市、学历、经验、月份四个维度聚合输出 Echarts 可直接消费的数据 # 1. 城市平均薪资 top 15 city_salary ( JobPosting.objects.exclude(salary_avg0) .values(city) .annotate(avg_salaryAvg(salary_avg)) .order_by(-avg_salary)[:15] ) # 2. 学历占比 edu_count ( JobPosting.objects.values(education) .annotate(totalCount(id)) .order_by(-total) ) # 3. 经验要求分布 exp_count ( JobPosting.objects.values(experience) .annotate(totalCount(id)) .order_by(-total) ) # 4. 每月发布数量趋势按 publish_date 的月份聚合 # 这里不用 TruncMonth 是为了保持后端逻辑简单直观 monthly defaultdict(int) for item in JobPosting.objects.exclude(publish_date__isnullTrue): key item.publish_date.strftime(%Y-%m) monthly[key] 1 monthly_sorted [{month: k, count: v} for k, v in sorted(monthly.items())] data { city_salary: list(city_salary), edu_count: list(edu_count), exp_count: list(exp_count), monthly_trend: monthly_sorted, total_jobs: JobPosting.objects.count(), updated_at: now().strftime(%Y-%m-%d %H:%M), } # ensure_asciiFalse 才能正确返回中文否则前端看到的是 \uXXXX return JsonResponse(data, json_dumps_params{ensure_ascii: False})接口函数里每个查询都可以对照 SQL 理解values(city) 相当于 GROUP BY cityannotate(Avg(salary_avg)) 是计算分组后的平均值order_by(-avg_salary) 是降序排序。学历和经验分布都用了 Count(id) 统计条数。月度趋势这里没有用数据库函数而是把记录全部取出来之后在 Python 里做 Counter数据量少时几万条以内这个写法性能完全没问题。如果以后要做到几十万条数据再改成 Django ORM 的 TruncMonth 不迟。JsonResponse 里 json_dumps_params{ensure_ascii: False} 是中文接口的关键配置。不设置这个参数接口返回的中文会变成 \u672c\u79d1 这样的转义序列虽然前端也能解析但浏览器里调试接口时根本没法阅读。4. Echarts图表折线、柱状、饼图与地图的数据绑定后端接口准备好以后就到了标题里最直观的部分把数据画出来。Echarts 本身是纯前端库跟 Django 没有直接耦合中间靠 JSON 数据连接。这个章节我把四种最常见的图表都放进来重点讲清楚数据格式匹配因为这些图表配置项单独看官方例子都能跑一旦接真实数据就开始出幺蛾子。4.1 Django 模板里给 Echarts 喂数据的可靠方式很多新手喜欢在模板里写 {{ api_data }}然后在前端再拼接字符串这种做法既不安全又容易出错。可靠做法是用 Django 自带的 json_script 过滤器它会把数据安全地转成 JSON 区块前端用 JSON.parse 读取。dashboard.html 结构如下{% load static %} !DOCTYPE html html langzh-CN head meta charsetUTF-8 title招聘数据可视化分析/title script src{% static echarts/echarts.min.js %}/script /head body div idchart-salary stylewidth: 100%; height: 400px;/div div idchart-edu stylewidth: 100%; height: 400px;/div div idchart-trend stylewidth: 100%; height: 400px;/div {{ stats_data|json_script:stats-data }} script const data JSON.parse(document.getElementById(stats-data).textContent); /script /body /html这里需要你在项目里新建一个 base.html 或者直接复用 Django 的模板目录结构把 stats_data 变量通过视图传进模板def dashboard(request): # 视图里先拿接口再传给模板 response stats_api(request) return render(request, jobs/dashboard.html, { stats_data: json.loads(response.content.decode(utf-8)), })用 json_script 的好处有三个中文内容不需要手动转义数据里出现引号也不会破坏 HTML 结构前端拿到的永远是一个结构化对象而不是拼字符串。4.2 按城市平均薪资柱状图分类轴与渐变色配置城市平均薪资适合用柱状图展示因为城市名是离散的、不连续的分类数据。下面是具体的 Echarts 配置const chartSalary echarts.init(document.getElementById(chart-salary)); chartSalary.setOption({ title: { text: 城市平均薪资 Top 15K, left: center }, tooltip: { trigger: axis }, grid: { left: 3%, right: 4%, bottom: 3%, containLabel: true }, xAxis: { type: category, data: data.city_salary.map(item item.city), axisLabel: { rotate: 30 } }, yAxis: { type: value, name: 平均薪资(K), scale: true }, series: [{ name: 城市平均薪资, type: bar, barWidth: 55%, itemStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ { offset: 0, color: #409EFF }, { offset: 1, color: #79bbff } ]) }, data: data.city_salary.map(item item.salary_avg) }] });这里的 xAxis 用的是 category 类型data 必须与 series 的 data 一一对应。如果后端有一条记录城市名是空字符串或者 nullmap 之后就会出现一个无名分类图表上会留下一个空柱子所以清洗阶段把空城市删掉是有价值的。barWidth 设置为 ’55%’ 是让柱子宽度占分类宽度的 55%太窄显得稀疏太宽会把相邻城市挤在一起。LinearGradient 是垂直渐变参数 (0, 0, 0, 1) 表示从顶部到底部渐变配合刻度标签 rotation 30 可以缓解城市名过长互相遮挡的问题。4.3 学历占比饼图数据格式必须是 name-value 对饼图最容易踩的坑是直接把接口返回的数组塞进 series结果是图表空白。Echarts 的饼图要求每一项都是 {name: ..., value: 数字} 的结构。接口里 edu_count 返回的是 {education: 本科, total: 120}字段名对不上所以必须转换。const chartEdu echarts.init(document.getElementById(chart-edu)); chartEdu.setOption({ title: { text: 学历要求分布, left: center }, tooltip: { trigger: item }, legend: { bottom: 0 }, series: [{ name: 学历要求, type: pie, radius: [40%, 70%], center: [50%, 50%], itemStyle: { borderRadius: 6, borderColor: #fff, borderWidth: 2 }, label: { formatter: {b}: {d}% }, data: data.edu_count.map(item ({ name: item.education, value: item.total })) }] });radius: [’40%’, ’70%’] 定义的是环形饼图的内半径和外半径想让饼图变成实心的就把第一个值改成 0。data 字段里我把 education 和 total 映射成了 name 和 value这一步是整个饼图是否显示的胜负手。4.4 折线图与发布趋势x 轴刻度的边界问题月度发布趋势用折线图展示。折线图本身不难难在 x 轴刻度控制。数据只有 12 个月的如果时间是 2025 年从 1 月到 11 月x 轴会默认按 category 显示 12 个刻度没问题但如果数据从 2024 年 5 月到 2025 年 4 月跨度跨年时很多人的折线会在 12 月突然断掉。原因是 category 轴只认你给的字符串不会自动补全中间缺失的月份。const chartTrend echarts.init(document.getElementById(chart-trend)); chartTrend.setOption({ title: { text: 职位发布数量趋势, left: center }, tooltip: { trigger: axis }, xAxis: { type: category, name: 月份, boundaryGap: false, data: data.monthly_trend.map(item item.month) }, yAxis: { type: value, name: 职位数量, minInterval: 1 }, series: [{ name: 发布数量, type: line, smooth: true, symbol: circle, symbolSize: 8, lineStyle: { width: 3, color: #67C23A }, itemStyle: { color: #67C23A }, areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ { offset: 0, color: rgba(103, 194, 58, 0.3) }, { offset: 1, color: rgba(103, 194, 58, 0) } ]) }, data: data.monthly_trend.map(item item.count) }] });boundaryGap: false 是折线图贴近两端的关键配置设为 true 时折线起点和终点会跟 y 轴之间留出一块空白。smooth: true 让折线平滑过渡symbolSize: 8 控制数据点大小。areaStyle 里的渐变透明度让曲线下的面积有层次感这是折线图提升视觉质感的常用手法。4.5 中国地图地图文件和城市名匹配的坑招聘数据分析里地图往往最能体现“可视化”三个字。Echarts 5 之后不再内置 china.js 地图文件必须自己引入 geoJSON 并用 registerMap 注册。这是整个项目里最容易卡住人的地方。fetch(/static/map/china.json) .then(response response.json()) .then(geoJson { echarts.registerMap(china, geoJson); const chartMap echarts.init(document.getElementById(chart-map)); chartMap.setOption({ title: { text: 全国招聘数量分布, left: center }, tooltip: { trigger: item }, visualMap: { min: 0, max: 100, left: left, top: bottom, text: [高, 低], inRange: { color: [#e0f3f8, #74c476, #08519c] } }, series: [{ type: map, map: china, roam: true, label: { show: true, fontSize: 10 }, data: data.city_salary.map(item ({ name: normalizeCity(item.city), value: item.salary_avg })) }] }); }) .catch(error console.error(地图文件加载失败, error));地图数据匹配有两条铁律地图文件里的城市名必须跟访问它的键完全一致“北京”不等于地图数据里的“北京市”。这里我在前端写了一层 normalizeCity 映射把清洗后的常用城市名转换成带“市”后缀的标准名function normalizeCity(city) { const cityMap { 北京: 北京市, 上海: 上海市, 天津: 天津市, 重庆: 重庆市, 广州: 广州市, 深圳: 深圳市, 杭州: 杭州市, 西安: 西安市, 成都: 成都市, 武汉: 武汉市, 南京: 南京市 }; return cityMap[city] || city; }visualMap 组件会被每个人粘下来但 min、max 必须根据实际薪资范围调整。max 设大了全图都是一种颜色设小了大部分城市都会顶到最深色。我用的是接口动态算出来的最大值再向上取整这样颜色层次才合理。5. 可视化分析项目避坑与最终验收从 runserver 到能演示的一套检查单做这个项目最大的风险不在代码写不出来而是写完之后浏览器里一片空白你不知道是数据问题、接口问题还是 Echarts 配置问题。下面这几条是我实打实踩过的坑每一条都按现象、原因、解决的顺序写。5.1 现象页面能打开图表区域空白控制台报错 echarts is not defined原因Echarts 的 JS 文件没有正确加载通常是 static 目录和模板引用的路径不一致。Django 默认的静态文件查找顺序会优先匹配 app/static 下的目录如果你的模板写的是 {% static echarts/echarts.min.js %}但文件实际放在了项目的 static/echarts/ 下而忘记配置 STATICFILES_DIRS就会 404。解决在 settings.py 里把项目级静态目录加进去STATIC_URL /static/ STATICFILES_DIRS [ BASE_DIR / static, ]然后把 echarts.min.js 放到项目根目录的 static/echarts/ 下。模板里的 {% static %} 标签会自动拼出 /static/echarts/echarts.min.js刷新即生效。5.2 现象接口返回的是 \uXXXX 转义序列浏览器里看不懂原因视图用了 JsonResponse 但没指定 ensure_asciiFalseDjango 默认会把非 ASCII 字符转成 unicode 转义。解决视图里统一写成 JsonResponse(data, json_dumps_params{ensure_ascii: False})。这一步做完浏览器里看到的就是正常中文。5.3 现象饼图正常显示但柱状图城市名乱码或错位原因前端数据映射对象 key 不一致。后端返回字段名是 city 和 salary_avg有的人在前端写成了 item.cityName取不到值就变成 undefined。解决在前端用一个明确的映射规则比如 const cityList data.city_salary.map(item item.city)必须跟接口文档里的字段名逐字对齐。我的一般做法是先在浏览器里访问 /api/stats/ 看返回的 JSON 字段再回前端写 map不要凭记忆。5.4 现象地图不显示任何省份只显示一个灰色轮廓原因Echarts 5 没有内置中国地图而网上很多教程是 Echarts 4 时代的老代码直接把 map: china 写进去没有注册地图数据。解决先确认本地存在 china.json 文件并确保通过 fetch 能访问到路径换成 /static/map/china.json。fetch 成功后再 echarts.registerMap(china, geoJson)之后才能 setOption。我自己的排查顺序是先看 network 面板里 fetch 状态码再确认 registerMap 是否在 setOption 之前执行。5.5 现象薪资折线图趋势看起来正确但数值比实际低一大截原因这是薪资单位不一致导致的。爬虫拿到的薪资有时是“K”有时是“万”清洗阶段只处理了带 K 的文本“面议”按 0 处理但这部分数据没有被过滤聚合时 0 值把平均值拉低。解决city_salary 查询里 exclude(salary_avg0) 是必须保留的而且清洗脚本最后要输出 salary_avg0 的条数辅助判断有多少数据被归零。如果 0 值超过总数的 20%说明解析规则不完善应该先完善清洗脚本再谈可视化。5.6 最终验收三个检查点和一次完整演示跑通以后给出三条验收标准。第一条执行 python manage.py import_jobs --csvjob_data.csv 后数据库记录数应该跟 CSV 行数基本一致重复执行不会增加记录数。第二条浏览器打开 /stats/JSON 里 total_jobs 与实际录入数相同且中文可读。第三条dashboard 页面四个图表全部有数据地图至少 10 个省份有颜色。我会按这个顺序自测先用下面命令确认导入量python manage.py shell -c from jobs.models import JobPosting print(总记录数:, JobPosting.objects.count()) print(薪资为0记录数:, JobPosting.objects.filter(salary_avg0).count()) print(城市数量:, JobPosting.objects.values(city).distinct().count()) 这三个数字是判断图表是否可信的第一步。如果薪资为 0 的记录太多直接去查清洗阶段的 salary_avg 解析规则如果城市数量不足 10 个那地图必然稀稀拉拉得回到爬虫阶段增加页数。从工程落地的角度看这个项目还可以继续扩展爬虫脚本可以接 cron 定时任务每周自动采集Django 可以把 SQLite 切换成 MySQL页面可以再加一个基于 requests 库的详情页抓取。但我个人的建议是先把当前版本跑稳定再谈扩展。任何可视化分析项目数据可信度永远排在图表美观度前面。我自己的一个习惯是每次改完数据清洗脚本或者模型字段以后先在 shell 里用 count 和 distinct 确认数据形状没变再刷新页面看图表。数据地基稳了后面的每个图表都是水到渠成希望帮到你。本文还有配套的精品资源点击获取
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。