ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Django中药材数据分析与可视化系统实战:从Excel到ECharts看板

Django中药材数据分析与可视化系统实战:从Excel到ECharts看板 简介这是一套基于Django框架开发的中药材数据分析与可视化系统完整源码面向中医药信息化研究者、数据分析初学者及Python Web开发者可用于课程设计、毕业设计或数据可视化练手项目。资源包共140个文件以28个py源码、23个pyc编译文件、13个html模板、12个csv数据文件为主另含css、js、图片等前端静态资源及sql、json、md等配置说明压缩包约3.98MB结构完整可直接部署运行。系统通过爬虫程序自动采集药材药方、产地价格、历史走势与市场资讯借助Echarts实现柱状图、饼图、折线图与表格的多维展示并配套用户注册登录、个人信息管理及管理员后台的数据与用户管理功能。目前已有124人学习下载适合希望掌握Django项目搭建、爬虫数据采集与Echarts可视化整合的读者参考借鉴。1. 中药材数据分析与可视化系统从一堆 Excel 到能看趋势的 Django 后台手里有十几张中药材的进销存 Excel每张表头还不一样老板张口就要“近三年价格走势、哪些品种库存周转慢、产地集中度怎么样”。这种场景下用 Excel 透视表硬扛两周改一个口径就得重来一遍是很多从业者真实的痛点。基于 Django 实现的中药材数据分析与可视化系统本质就是把“数据清洗 指标计算 图表展示”这条链路固化成一个 Web 后台数据落库一次指标用 ORM 或 SQL 算好前端用 ECharts 渲染之后换年份、换品种只是改查询条件。它适合两类人一类是手里有中药材业务数据、想快速搭出内部看板的开发者另一类是正在做 Django 项目实战、需要一个真实数据场景练手的新手。下面按“先跑通最小闭环再补指标和图表最后讲坑”的顺序讲清楚。2. 数据建模与导入中药材表结构怎么设计才不返工中药材数据看着简单真建模时最容易翻车的地方是“品种、产地、规格、批次”四个维度混在一张表里。等你要按产地统计均价时才发现产地被写进了品种名称字段只能靠字符串截取这就是典型的血泪经验。所以第一步不是写代码而是把维度拆干净。2.1 四张核心表与字段取舍常见做法是拆成品种表、产地表、价格记录表、库存记录表。品种和产地作为基础字典价格和库存作为事实表用外键关联。这样后面做“某产地某品种的月度均价”时一个values()加annotate()就能出结果不用写复杂字符串处理。# models.py from django.db import models class Herb(models.Model): 中药材品种字典 name models.CharField(品种名, max_length64, uniqueTrue) category models.CharField(类别, max_length32, blankTrue) # 如根茎类、果实类 nature models.CharField(性味, max_length32, blankTrue) class Meta: db_table herb verbose_name 中药材品种 def __str__(self): return self.name class Origin(models.Model): 产地字典 province models.CharField(省份, max_length32) city models.CharField(城市, max_length32, blankTrue) class Meta: db_table origin unique_together (province, city) class PriceRecord(models.Model): 价格记录事实表 herb models.ForeignKey(Herb, on_deletemodels.CASCADE, related_nameprices) origin models.ForeignKey(Origin, on_deletemodels.CASCADE) spec models.CharField(规格, max_length32, blankTrue) # 统货、选货 price models.DecimalField(单价(元/kg), max_digits10, decimal_places2) record_date models.DateField(记录日期, db_indexTrue) class Meta: db_table price_record indexes [models.Index(fields[herb, record_date])]逻辑说明Herb和Origin是字典表uniqueTrue和unique_together防止重复导入产生脏数据。PriceRecord里record_date单独加db_indexTrue因为几乎所有图表查询都按时间过滤这个索引是性能底线。spec用 CharField 而不是枚举是因为中药材规格叫法各地不统一硬编码枚举后期改起来很痛苦。参数说明max_digits10, decimal_places2对单价足够别用 FloatField金额类字段用浮点会出现12.300000000000001这种展示问题。on_deletemodels.CASCADE表示品种删除时价格记录一起删如果业务上不允许删字典改成PROTECT更安全。2.2 用 management command 做批量导入数据来源通常是 Excel 或 CSV。不要写在视图里导入做成自定义命令方便重复执行和排错。# management/commands/import_herb.py import csv from django.core.management.base import BaseCommand from dataapp.models import Herb, Origin, PriceRecord class Command(BaseCommand): help 从 CSV 导入中药材价格数据 def add_arguments(self, parser): parser.add_argument(csv_path, typestr) def handle(self, *args, **options): with open(options[csv_path], encodingutf-8-sig) as f: reader csv.DictReader(f) for row in reader: herb, _ Herb.objects.get_or_create( namerow[品种].strip(), defaults{category: row.get(类别, )}, ) origin, _ Origin.objects.get_or_create( provincerow[省份].strip(), cityrow.get(城市, ).strip(), ) PriceRecord.objects.create( herbherb, originorigin, specrow.get(规格, ), pricerow[单价], record_daterow[日期], ) self.stdout.write(self.style.SUCCESS(导入完成))逻辑说明get_or_create保证字典表不会因为重复品种报错这是导入脚本能不能反复跑的关键。encodingutf-8-sig是为了兼容 Excel 导出的带 BOM 的 CSV不加这个第一列字段名会带一个看不见的\ufeff导致row[品种]取不到值这个坑新手几乎必踩。参数说明csv_path用位置参数传入执行时python manage.py import_herb data/2024.csv。如果数据量大把objects.create换成bulk_create批量提交每 1000 条 flush 一次导入速度能差一个数量级。3. 指标计算用 ORM 聚合把均价和周转率算对数据进库之后真正决定系统好不好用的是指标口径。中药材分析里最常被问的三类指标是品种月度均价、产地价格对比、库存周转天数。这一章讲怎么用 Django ORM 把它们算准而不是每次都在前端拼数据。3.1 月度均价TruncMonth 加 Avg 的标准写法按品种和月份聚合均价是价格走势图的数据源。核心是用TruncMonth把日期截断到月再annotate求平均。from django.db.models import Avg from django.db.models.functions import TruncMonth from dataapp.models import PriceRecord def monthly_avg_price(herb_name): qs ( PriceRecord.objects .filter(herb__nameherb_name) .annotate(monthTruncMonth(record_date)) .values(month) .annotate(avg_priceAvg(price)) .order_by(month) ) return list(qs)逻辑说明annotate(month...)先给每行打上月份标签values(month)按月份分组第二个annotate才算组内平均。顺序不能反先values再annotate会变成按整表聚合这是 ORM 聚合最常见的误用。参数说明Avg(price)返回的是 Decimal序列化成 JSON 时要转 float 或字符串否则 DjangoJSONEncoder 之外的方式会报错。如果同一品种同一天有多条不同规格记录均价会被规格数量加权业务上要的是“规格均价”还是“记录均价”得先和需求方对齐别自己拍脑袋。3.2 库存周转天数别用 Python 循环硬算周转天数 平均库存 / 日均出库。如果库存和出库是两张表用 ORM 子查询或annotate配合F表达式算比拉全量数据到 Python 里循环快得多。from django.db.models import F, Sum, FloatField, ExpressionWrapper from dataapp.models import StockRecord def turnover_days(): qs ( StockRecord.objects .values(herb__name) .annotate(total_outSum(out_qty)) .annotate(avg_stockSum(stock_qty) / 30.0) .annotate( daysExpressionWrapper( F(avg_stock) / (F(total_out) / 30.0), output_fieldFloatField(), ) ) .filter(total_out__gt0) ) return list(qs)逻辑说明ExpressionWrapper用来做字段间除法并指定输出类型不加它 Django 可能按整数除法处理结果全是 0。filter(total_out__gt0)排除没有出库的品种避免除零。参数说明这里的 30.0 是统计周期天数实际项目里应该作为参数传入别写死。周转天数口径差异很大有的用期末库存有的用平均库存代码里最好注释清楚用的是哪种否则半年后自己都说不清。3.3 产地集中度一个 values 加 Count 就够产地集中度看的是某品种的货源是否过度依赖单一产地用分组计数加占比即可。from django.db.models import Count from dataapp.models import PriceRecord def origin_concentration(herb_name): qs ( PriceRecord.objects .filter(herb__nameherb_name) .values(origin__province) .annotate(cntCount(id)) .order_by(-cnt) ) total sum(item[cnt] for item in qs) return [ {province: i[origin__province], ratio: round(i[cnt] / total, 4)} for i in qs ]逻辑说明先按省份分组计数再在 Python 里算占比。占比计算放 Python 是因为分母需要全量求和用 ORM 一次查询拿不到硬塞进 SQL 反而难读。参数说明Count(id)统计的是价格记录条数如果业务要的是“不同产地数量占比”得用Count(origin, distinctTrue)两者含义完全不同需求确认清楚再写。4. 可视化落地ECharts 接 Django 数据的三种接法指标算出来了接下来是把它画出来。中药材可视化系统里价格走势用折线图、产地分布用饼图或地图、周转率用柱状图前端选 ECharts 是常见做法。关键问题是 Django 的数据怎么送到前端。4.1 视图返回 JSON前端 fetch 渲染最干净的方式是视图只返回 JSON模板里用 fetch 拿数据再setOption。这样前后端职责清晰图表刷新也不用整页重载。# views.py from django.http import JsonResponse from dataapp.services import monthly_avg_price def price_trend_api(request): herb_name request.GET.get(herb, 当归) data monthly_avg_price(herb_name) return JsonResponse({ herb: herb_name, x: [d[month].strftime(%Y-%m) for d in data], y: [float(d[avg_price]) for d in data], })逻辑说明视图不碰业务逻辑聚合都放在services.py里方便单独测试。日期格式化成%Y-%m是因为 ECharts 的类目轴直接吃字符串传时间戳还得再转一次。参数说明request.GET.get(herb, 当归)给了默认值避免前端没传参时报错。生产环境要对herb_name做白名单校验防止有人拿它拼 SQL——虽然 ORM 参数化能挡住大部分但输入校验该做还得做。// 模板中的脚本 fetch(/api/price-trend/?herb当归) .then((res) res.json()) .then((data) { const chart echarts.init(document.getElementById(trend)); chart.setOption({ xAxis: { type: category, data: data.x }, yAxis: { type: value, name: 元/kg }, series: [{ type: line, data: data.y, smooth: true }], }); });逻辑说明echarts.init要在 DOM 渲染完成后执行脚本放在模板底部或DOMContentLoaded里。smooth: true让折线平滑但数据点少的时候会失真点少于 6 个建议关掉。参数说明yAxis.name标上单位中药材价格动辄几百上千不标单位看图的人会误判量级。4.2 服务端直接渲染适合不想写太多 JS 的场景如果团队前端能力弱可以在视图里把数据json.dumps后塞进模板变量模板里直接{{ chart_data|safe }}。代价是数据和页面耦合局部刷新要重写。import json from django.shortcuts import render def price_trend_page(request): data monthly_avg_price(request.GET.get(herb, 当归)) chart_data { x: [d[month].strftime(%Y-%m) for d in data], y: [float(d[avg_price]) for d in data], } return render(request, trend.html, { chart_data: json.dumps(chart_data, ensure_asciiFalse), })逻辑说明ensure_asciiFalse保证中文品种名不被转成\uXXXX否则图表标题会显示乱码转义。|safe过滤器关闭转义但前提是数据可信别把用户输入直接塞进去。参数说明这种写法适合一次性看板不适合需要频繁交互筛选的系统。筛选条件一多还是走 API 更省事。4.3 静态资源路径img 和 echarts.js 加载不出来的排查Django 项目里静态文件加载失败是高频问题尤其是img标签和第三方 JS。常见原因是STATIC_URL配了但没配STATICFILES_DIRS或者模板里路径写成了相对路径。# settings.py STATIC_URL /static/ STATICFILES_DIRS [BASE_DIR / static]{% load static %} script src{% static js/echarts.min.js %}/script img src{% static img/logo.png %} altlogo逻辑说明{% static %}会拼上STATIC_URL比手写/static/更稳。开发环境DEBUGTrue时 Django 会自动服务静态文件上线后要交给 Nginx 或collectstatic否则 404。参数说明STATICFILES_DIRS是列表可以放多个目录。如果用了 app 内部的 static 目录不需要在这里配Django 会自动找。5. 避坑与排查中药材数据系统上线前必查的五个问题这一章是我自己在做类似系统时踩过的坑按“现象 → 原因 → 解决”列出来照着查能省不少时间。5.1 图表数据全是 0 或空现象接口返回 200但y数组全是 0 或空列表。原因通常是聚合字段类型不对Decimal 除以 int 在某些数据库下被截断或者filter条件里的品种名带了空格。解决先在 Django shell 里跑一遍monthly_avg_price(当归)确认原始查询有数据再检查 CSV 导入时是否strip()了品种名。数据库里当归 和当归是两个值这个坑很隐蔽。5.2 导入脚本跑第二遍就报唯一约束冲突现象第一次导入成功第二次执行报IntegrityError。原因PriceRecord没有唯一约束但Herb.name有uniqueTrue如果脚本里用的是create而不是get_or_create字典表会冲突。解决字典表统一用get_or_create事实表如果也要防重加unique_together (herb, origin, record_date, spec)导入时用update_or_create。5.3 页面加载慢一查就是几百毫秒现象价格走势接口响应超过 500ms。原因PriceRecord表数据量大filter(herb__name...)走的是 JOIN如果Herb.name没索引每次都要全表扫。解决给Herb.name加db_indexTrue或者查询时先用Herb.objects.get(name...)拿到 id再用herb_id过滤少一次 JOIN。5.4 中文品种名在前端显示成乱码现象图表标题或图例中文变成问号或方块。原因JSON 响应没设charset或者模板文件没存成 UTF-8。解决JsonResponse默认content_typeapplication/jsonDjango 会带 UTF-8如果是自己HttpResponse显式写content_typeapplication/json; charsetutf-8。模板文件用编辑器确认编码是 UTF-8 无 BOM。5.5 开发环境图表正常上线后静态资源 404现象本地runserver一切正常部署后 ECharts 加载不出来。原因DEBUGFalse时 Django 不再服务静态文件。解决执行python manage.py collectstatic把静态文件收集到STATIC_ROOT再由 Nginx 配置/static/指向该目录。别在线上开DEBUGTrue图省事那是拿安全换方便。6. 进阶技巧用缓存和异步把大品种查询压到毫秒级系统跑起来之后真正拖慢体验的往往不是数据库而是重复计算。中药材价格走势这种查询同一品种同一天可能被请求几十次每次都跑一遍聚合是浪费。我一般会加一层缓存把monthly_avg_price的结果按品种名缓存 10 分钟。from django.core.cache import cache def monthly_avg_price_cached(herb_name, ttl600): key fherb:trend:{herb_name} data cache.get(key) if data is None: data monthly_avg_price(herb_name) cache.set(key, data, ttl) return data逻辑说明缓存键带上品种名避免不同品种互相覆盖。ttl600是 10 分钟中药材价格日频更新10 分钟足够新鲜。缓存的是聚合结果而不是 QuerySet因为 QuerySet 惰性求值缓存它没意义。参数说明生产环境把CACHES配成 Redis别用本地内存缓存多进程下本地缓存命中率低。如果数据更新频繁可以在导入命令结束时主动cache.delete相关键而不是干等过期。另一个技巧是给大品种的查询加异步。Django 3.1 之后支持 async 视图配合sync_to_async把 ORM 调用包起来能在等待数据库时释放 worker。不过对大多数内部看板缓存带来的收益比异步更直接先上缓存真扛不住再考虑异步和分页。验证缓存有没有生效最土也最有效的办法是在视图里打日志看cache.get是否命中。我习惯在monthly_avg_price_cached里加一行print(cache hit if data else cache miss)上线前跑几轮确认命中率再删掉。这个习惯帮我省过好几次“以为缓存生效其实键写错了”的后悔药。做这类系统我的教训是别一上来就追求图表多炫先把数据口径和索引做对后面加什么图都是顺手的事。希望帮到你。本文还有配套的精品资源点击获取
返回列表