简介:这是一份基于 Django 的民宿房源数据分析可视化系统完整源码,面向需要完成 Python 毕业设计、课程设计或期末大作业的高校学生。系统聚焦民宿房源数据,提供数据清洗、统计分析、图表展示与后台管理等完整功能,界面清晰、操作流畅,兼具教学演示与实际应用价值。压缩包共807个文件,以321个JS、147个CSS、80个HTML、40个Python文件为主,涵盖前端页面、Django后端逻辑、可视化样式、字体图标以及少量说明文档与配置文件,整体大小11.88MB,目录结构清晰,便于定位模块和二次开发。项目已经过严格调试,可直接运行体验,也适合参考其数据可视化方案、功能模块划分和 Django 工程组织方式。目前已有774人学习下载,对正在筹备毕设或项目实战的初学者是值得参考的完整示例。
1. 民宿房源数据分析可视化系统:这个 Django 毕设到底在做什么
拿到这个标题对应的源码包时,我第一次打开只看了三个文件就明白了:models.py、views.py 和一个引了 ECharts 的模板页。说白了,它就是一个用 Django 做后端、pandas 做数据分析、ECharts 做前端图表的民宿房源数据分析可视化系统,把几十万条订单和房源数据变成区域热力图、价格趋势线和入住率柱状图。真正动手复现后你会发现,这套系统最值钱的不是图表画得多好看,而是背后那几个业务口径——平均房价怎么算、入住率分母是谁、区域热度用什么加权——这些定清楚了,页面上的每个数字才站得住。
适合谁做?两类人。一类是拿它当毕业设计、想完整走一遍「爬数据→清洗→入库→接口→可视化大屏」链路的在校生;另一类是已经能跑通 Django 增删改查、想看看分析类项目怎么组织数据表和聚合查询的从业者。下面前四章按我做这个方向时的习惯拆开讲,最后收在我想留给你的一招上。
2. 先设计再写代码:数据表、清洗口径与分析指标
这个项目最容易翻车的顺序是:先画页面、后补数据。页面画得再漂亮,数据口径对不上,答辩老师一句「你这个均价是怎么算出来的」就能把人问住。所以第一步不是建 Django 项目,而是把数据模型、清洗逻辑和分析口径钉死。
2.1 三张核心表的关系和字段设计
民宿房源分析至少要覆盖静态属性、动态流水和状态变化三类数据。我在源码包里看到的分表思路也是这样:房源表(House)存区域、房型、面积、经纬度、挂牌价这些不变或极少变的信息;订单表(Order)存每一笔成交的时间、间夜数、金额;还有一张订单明细或房态表,用来还原「某一天这个房源有没有被订出去」——算入住率必须靠它,光有订单总额算不出来。
表关系不复杂:House 一对多 Order,Order 关联到具体房源。真正要注意的是冗余字段。分析接口最终要按区域分组、按月份切片,如果每次查询都去 join 城市表和区域表,几十万行数据下性能立刻崩。所以导入时就把region_name、city_name直接冗余到订单表里,查询时少两次 join,代价只是存储空间多一点点。
2.2 从 CSV 到 DataFrame:清洗时最容易漏的四类脏数据
爬虫抓下来或老师给的数据,几乎不会有干净的。我处理这个项目时遇到最多的四类问题:价格字段带着「元/晚」之类的单位、日期格式不统一(有的/有的-)、经纬度有极少数空值、房型字段同一个意思多种写法(「大床房」「大床间」其实是一回事)。
这些都必须在下一次loaddata之前清完。我一般单独写一个clean_data.py,不混进 Django 的 ORM 操作里:
import pandas as pd df = pd.read_csv("house.csv", encoding="utf-8-sig") # 价格统一转数字:去掉单位、处理空值 df["price"] = ( df["price"] .astype(str) .str.replace("元/晚", "") .str.replace(" ", "") ) df["price"] = pd.to_numeric(df["price"], errors="coerce") # 日期统一成 datetime,解析不了的置空再删 df["date"] = pd.to_datetime(df["date"], format="%Y-%m-%d", errors="coerce") # 经纬度空值直接删行,几百条不影响整体 df = df.dropna(subset=["price", "date", "lng", "lat"]) # 房型归一化:全部走映射表 room_type_map = {"大床房": "大床", "大床间": "大床", "标间": "双床", "标准间": "双床"} df["room_type"] = df["room_type"].map(room_type_map).fillna("其他") # 按城市+区域补齐冗余字段,方便后面聚合 df["region_name"] = df.groupby("city_name")["region_name"].transform("first") df.to_csv("house_clean.csv", index=False, encoding="utf-8-sig")关键参数就两个:errors="coerce"让解析失败的值变成NaN而不是抛异常打断整批处理;format="%Y-%m-%d"告诉 pandas 日期长什么样,避免它拿%Y/%m/%d的格式硬解另一种写法。房型归一化用map而不是replace是为了让没匹配上的值统一落到「其他」,不会漏出脏分类。
2.3 三个核心指标的口径:均价、入住率、区域热度
口径是这个项目的灵魂。平均房价就有两种算法:挂牌均价是把所有房源挂牌价做算术平均,成交均价是总营收除以总间夜数。两个都能算,但结论差很多——挂牌价偏高的房源多,算术平均会被拉高,成交均价才反映真实市场。我做的时候两个都算,页面默认展示成交均价,挂牌均价放在对比图里,答辩时反而成了加分项。
入住率的口径更讲究:分子是「某时间段内已订间夜数」,分母是「可订间夜数」。分母必须排除已经下架或停业的房源,否则房源下架前的空置期会被算成没卖出去,入住率虚低。区域热度我用的是订单量 60%、成交金额 30%、平均评分 10% 的加权值,权重放在配置里而不是写死在代码中,方便调。聚合脚本大概是这样的:
import pandas as pd orders = pd.read_csv("orders_clean.csv", encoding="utf-8-sig") # 成交均价:总营收 / 总间夜 orders["amount_nights"] = orders["amount"] / orders["nights"] # 区域识别:因为有冗余字段,直接 groupby 即可 region_stats = ( orders.groupby("region_name") .agg( order_cnt=("order_id", "count"), total_amount=("amount", "sum"), total_nights=("nights", "sum"), avg_rating=("rating", "mean"), ) .reset_index() ) region_stats["avg_price"] = region_stats["total_amount"] / region_stats["total_nights"] region_stats["heat"] = ( region_stats["order_cnt"] * 0.6 + region_stats["total_amount"] * 0.3 + region_stats["avg_rating"] * 0.1 )这里有个隐藏的坑:amount是整单金额,除以nights得到每晚均价,再除以总间夜数会得到错误结果。正确做法是总金额直接除以总间夜数,不要在中间先算每晚均价再求平均——那是算术平均,不是加权平均。这个细节稍微不注意,页面上的数字就会和老师手动核算的对不上。
我在region_stats里把order_cnt、total_amount、total_nights全部聚出来,是为了后续能随时切换口径。如果你只算一个数,后面换个算法就得重跑整个清洗流程。切记:分析类系统把中间结果多保留几个字段,永远是对的。
3. Django 后端:把聚合结果变成前端能直接读的 JSON
数据清洗完,接下来就是用 Django 把结果暴露成接口。这套系统的接口不复杂,基本就是三四个 JSON 接口,分别服务区域热力图、价格趋势、入住率柱状图和房源列表。但写法上有个分水岭:是在 Python 里循环算,还是让数据库把聚合做完再返回。
3.1 models.py 怎么建、外键怎么挂
models.py 是 Django 项目的骨架。我见过很多新手把 JSONField 当万能药,一个字段塞整个字典,结果后面每个查询都要在代码里拆。正确做法是老老实实拆字段,外键挂清楚。
from django.db import models class House(models.Model): house_id = models.CharField(max_length=32, unique=True, db_index=True) city_name = models.CharField(max_length=32) region_name = models.CharField(max_length=32) room_type = models.CharField(max_length=16) price = models.DecimalField(max_digits=10, decimal_places=2) lng = models.FloatField() lat = models.FloatField() on_sale = models.BooleanField(default=True) class Meta: db_table = "house" indexes = [models.Index(fields=["region_name", "on_sale"])] class Order(models.Model): order_id = models.CharField(max_length=64, unique=True) house = models.ForeignKey(House, on_delete=models.CASCADE, related_name="orders") order_date = models.DateField(db_index=True) checkin_date = models.DateField() nights = models.PositiveIntegerField() amount = models.DecimalField(max_digits=10, decimal_places=2) rating = models.FloatField(null=True, blank=True) # 冗余字段:直接冗余区域名,避免聚合时跨表 join city_name = models.CharField(max_length=32) region_name = models.CharField(max_length=32) class Meta: db_table = "order" indexes = [models.Index(fields=["region_name", "order_date"])]两个要记住的参数:on_delete=models.CASCADE表示删房源时级联删订单,这是电商类系统最常见的关联策略,Django 默认强制你写清楚否则不让迁移;db_index=True加在order_date和region_name上,后面按月份和区域过滤时全靠这两个索引,不加的话几十万行数据查询会慢到页面超时。related_name="orders"是为了让house.orders.all()能直接取到该房源全部订单,写 ORM 时省事不少。
3.2 视图层用聚合查询,避免 Python 循环计算
视图层最容易犯的错是 N+1 查询:循环每个区域,里面再查一次数据库。正确姿势是在 Queryset 层面把聚合做完,用annotate一次性把Count、Sum、Avg全部算出来。
from django.db.models import Count, Sum, Avg, F, FloatField, ExpressionWrapper def region_stats(request): # annotate 在 SQL 层完成聚合,而不是 Python 循环 stats = ( Order.objects .filter(order_date__year=2024) .values("region_name") .annotate( order_cnt=Count("order_id"), total_amount=Sum("amount"), nights_sum=Sum("nights"), avg_rating=Avg("rating"), ) .order_by("-order_cnt") ) result = [] for item in stats: avg_price = round(item["total_amount"] / item["nights_sum"], 2) heat = round( item["order_cnt"] * 0.6 + item["total_amount"] * 0.3 + item["avg_rating"] * 0.1, 2, ) result.append({ "name": item["region_name"], "order_cnt": item["order_cnt"], "total_amount": float(item["total_amount"]), "avg_price": avg_price, "heat": heat, }) return JsonResponse({"data": result})这里有个参数值得单独讲:filter(order_date__year=2024)是 Django 的字段名双下划线语法,翻译成 SQL 是WHERE order_date BETWEEN '2024-01-01' AND '2024-12-31'。你可以在values("region_name")前面加多个.filter()拼接条件,但要注意每个 filter 都是 AND 关系,想要「2024 年或 2025 年」得用Q对象包起来,不能连写两个 filter。
3.3 接口返回结构:Date 和 Decimal 怎么安全转 JSON
JsonResponse在 Django 内部走的是json.dumps,而Decimal和date都不是 JSON 原生类型,直接返回会报Object of type Decimal is not JSON serializable。很多新手在这里卡住,然后去百度,最后用str()强行转,数字精度丢了不说,前端拿到的还是字符串。
import json from django.core.serializers.json import DjangoJSONEncoder from django.http import JsonResponse def price_trend(request): # 按月份算成交均价,输出 ECharts 需要的 [[月份, 均价], ...] rows = ( Order.objects .filter(order_date__year=2024) .annotate(month=F("order_date__month")) .values("month") .annotate(total_amount=Sum("amount"), total_nights=Sum("nights")) .order_by("month") ) trend = [ [item["month"], round(item["total_amount"] / item["total_nights"], 2)] for item in rows ] # DjangoJSONEncoder 自动处理 Decimal 和 date/datetime return JsonResponse({"data": trend}, encoder=DjangoJSONEncoder)encoder=DjangoJSONEncoder是 Django 3.x 之后推荐的标准做法,它能序列化Decimal、date、datetime、UUID和timedelta,省掉你手写转换函数。前端拿到的trend是标准的[[1, 568.5], [2, 622.0], ...]数组,ECharts 的折线图可以直接吃这个格式,不用二次 map。
前端和后端约定好接口返回结构很重要:我习惯统一返回{"data": ...}包一层,错误时返回{"error": "..."}。这样前端 fetch 的时候只需要判断data在不在,不用为每个页面单独设计错误结构。
4. 常见问题避坑:从静态文件 404 到数据差了 8 小时
这个项目我前后跑过三轮,每一轮都踩了不同的坑。这一章把最耽误时间的五个问题集中写出来,每条都是「现象 → 原因 → 解决」的路子,照着排查能省一下午。
4.1 Django 开发服务器下图表文件 404
现象:页面能开,但 ECharts 的 JS 文件加载不出来,控制台一片红色 404。
原因:Django 开发服务器默认不服务静态文件,模板里用{% load static %}引的路径需要STATICFILES_DIRS告诉 Django 去哪找。很多项目模板是直接从静态页面改的,路径写的是相对路径js/echarts.min.js,而不是模板标签{% static 'js/echarts.min.js' %},自然 404。
解决:settings.py 里配上目录,然后用模板标签替换所有静态资源引用。
# settings.py STATIC_URL = "/static/" STATICFILES_DIRS = [BASE_DIR / "static"]<!-- 模板中所有 JS/CSS 引用统一这样写 --> <script src="{% static 'js/echarts.min.js' %}"></script>还有一个低频但更隐蔽的情况:STATICFILES_DIRS配了但路径指向的文件夹不存在,Django 不会报错,只会默默 404。所以配置完先确认static/js目录真实存在,再刷新页面。
4.2 日期区间查询差一天,订单少统计一批
现象:按月统计营收,2024 年 12 月的数字比实际少了 8 小时的数据,1 月的数据里又混进了 12 月 31 日深夜的单。
原因:Django 的USE_TZ = True开启后,所有DateTimeField都按 UTC 存取,本地东八区和 UTC 差 8 小时。如果数据库存的是datetime,你在代码里写order_date__year=2024,Django 会翻译成 UTC 时区的时间边界,正好把本地 8 点前的订单截掉。
解决:这个项目用的全是DateField而不是DateTimeField(订单只有日期没有时刻),直接避免时区问题。如果某些场景必须用DateTimeField,查询前先timezone.localtime()把边界算明白:
from django.utils import timezone import datetime start = timezone.make_aware(datetime.datetime(2024, 12, 1, 0, 0, 0)) end = timezone.make_aware(datetime.datetime(2025, 1, 1, 0, 0, 0)) orders = Order.objects.filter(order_date__gte=start, order_date__lt=end)4.3 中文在图表里显示成方块
现象:ECharts 的标题和图例中文全部变成「□□□」,地图上的区域名也不能看。
原因:ECharts 默认字体走的是sans-serif,Linux 服务器上如果没装中文字体,或者浏览器字体链里没有可用的中文渲染,就会出方块。
解决:初始化图表时显式指定字体优先级,同时确认服务器装了中文字体包。
var chart = echarts.init(document.getElementById("chart")); chart.setOption({ textStyle: { fontFamily: "Microsoft YaHei, PingFang SC, Noto Sans CJK SC, sans-serif" } });服务器端用fc-list | grep -i cjk看一眼有没有中文字体,没有的话apt install fonts-noto-cjk装上,否则即使浏览器指定了字体,导出图片时照样是方块。
4.4 几十万条数据聚合卡到页面超时
现象:区域热力图接口响应 8 到 10 秒,前端一直转圈。
原因:视图层在 Python 里循环了每个区域,每个循环里又发一次COUNT(*)查询。区域有 30 个,就触发 31 条 SQL,加上每条查询都扫描全表,不慢才怪。
解决:把循环里的查询并成一个group by,也就是前面 3.2 节那种values().annotate()写法。如果数据库本身也慢,检查索引有没有建到位——region_name和order_date的联合索引是关键,没有的话先migrate后补一条原生 SQL 建索引:
CREATE INDEX CONCURRENTLY idx_order_region_date ON "order" (region_name, order_date);4.5 数据库迁移后字段对不上,表里多出几百列
现象:python manage.py migrate执行成功,但查询时报column order.xxx does not exist。
原因:CSV 清洗的时候字段名里带了空格(比如"region_name "),pandas 转 DataFrame 后列名没清理,直接生成CREATE TABLE时把空格也带进去了。后续代码里写的字段名和数据库实际列名对不上。
解决:清洗脚本里统一做一次列名规范化,再进 ORM:
df.columns = [c.strip().lower().replace(" ", "_") for c in df.columns]这个步骤一定要放在to_csv之前,不然惨案已经写进文件里了。如果不幸已经迁移了,就 DROP 表重来——反正是开发环境,数据重新导一次比手动 ALTER 几十列快得多。
5. 一个值得留的进阶习惯:把聚合逻辑抽出来复用
最后留一个我自己的编码习惯,这招让后面改需求省了大力气:不要把聚合逻辑塞在视图函数里。视图函数的职责只是「接收请求、取参数、调服务、返回 JSON」。聚合指标、口径计算、权重配置这些,全部抽到一个独立的services.py里。
# services.py from django.db.models import Count, Sum, Avg def get_region_stats(year=2024, weight=(0.6, 0.3, 0.1)): """区域统计:订单量、成交均价、热度加权值""" order_weight_cnt, amount_weight, rating_weight = weight stats = ( Order.objects .filter(order_date__year=year) .values("region_name") .annotate( order_cnt=Count("order_id"), total_amount=Sum("amount"), nights_sum=Sum("nights"), avg_rating=Avg("rating"), ) .order_by("-order_cnt") ) result = [] for item in stats: result.append({ "name": item["region_name"], "avg_price": round(item["total_amount"] / item["nights_sum"], 2), "heat": round( item["order_cnt"] * order_weight_cnt + item["total_amount"] * amount_weight + item["avg_rating"] * rating_weight, 2, ), }) return result视图里就干净了:
# views.py from django.http import JsonResponse from .services import get_region_stats def region_stats(request): year = request.GET.get("year", 2024) return JsonResponse({"data": get_region_stats(int(year))})这样做的好处是:同一个聚合函数可以被接口、导出 Excel、生成 PDF 报告共用,改口径只动services.py一处。我第一次做的时候把聚合写死在视图里,后来老师要求加一个「只看 2024 下半年」的筛选,我改了四个接口,其中一个还改漏了,数据对不上查了一整晚。之后所有分析类项目我都沿用这个习惯,再没翻过车。
希望这个拆分习惯和前面几章的踩坑记录能帮到你,能让你的民宿房源数据分析可视化系统少走几段弯路。
本文还有配套的精品资源,点击获取