文章来源Python Django+SQL+Pandas+Pyecharts自建在线数据分析平台(一)
作者ccpic
感谢:感谢作者 ccpic 分享的优质内容,本网页主要用于学习知识的存档备份,欢迎点击原网页支持作者。

本篇是系列文章的第5篇,之前的更新见:

(一)需求分析&技术实现

(二)初步搭建Django环境

(三)页面布局&Django模板

(四)SQL+Pandas初步处理数据

(五)前端表单交互

(六)Ajax异步传参与加载

(七)前端数据格式的处理

(八)DataTables接管前端表格

(九)Pyecharts实现交互图表

(十)静态图表的展示

(十一)“导出数据至Excel”功能

(十二)添加和配置缓存

(十三)用户登录系统

(十四)部署Django至生产环境

在上一章,我们已经成功从后端将分析结果传回前端Django模板并展示,但这个分析结果是静态的,缺乏交互性。本章我们希望在预留的filter.html模板内建立表单,从前端向后端提交数据筛选的参数。本章的内容比较容易理解,但对用户体验至关重要,是个细致活。

还是回到上一章对数据本身各字段的分析,这对表单设计也格外重要:

第4章的纸上谈兵本章依然有用

如上图,我们需要有一个必填单选代表一个分析目标字段(我还是习惯称之为breakout字段),它决定返回的数据结果里是品类份额,还是品牌份额,还是其他xx份额。它也是后端Pandas的pivot_table方法里column的动态参数。

我们还需要另外两个必填单选字段——UNIT和PERIOD,原因也请参考上图。

而我们所有的属性字段都是可为空的多选。

本例中我们表单不需要考虑AMOUNT和DATE字段。因为AMOUNT是唯一的指标字段,而我们的分析结果会取最新一个DATE做横断面结果,并计划把所有DATE的数据作为趋势分析,我们不需要对DATE动态选择。而在其他一些场景下,日期字段是经常作为表单的一员的,甚至有很多专门为其设计的calendar控件。

综上所述,我们的表单设计是下面这个样子,我们需要在filter.html文件中实现它。

TC为Therapy Class的简写,可理解为其他行业的不同层级的品类

实际前端模板代码编写前,可以后端先传一个预设的字段字典。这样操作一是分离前端方便以后修改,大部分情况下以后只修改后端就可以了;二是可以利用循环极大缩短代码长度,更加elegant。

我们再次修改views.py里index方法的代码,在context字典内增加表单的预设值传至前端:

# 该字典key为前端准备显示的所有多选字段名, value为数据库对应的字段名
D_MULTI_SELECT = {
'TC I': '[TC I]',
'TC II': '[TC II]',
'TC III': '[TC III]',
'TC IV': '[TC IV]',
'通用名|MOLECULE': 'MOLECULE',
'商品名|PRODUCT': 'PRODUCT',
'包装|PACKAGE': 'PACKAGE',
'生产企业|CORPORATION': 'CORPORATION',
'企业类型': 'MANUF_TYPE',
'剂型': 'FORMULATION',
'剂量': 'STRENGTH'
}


def index(request):

...

mselect_dict = {}
for key, value in D_MULTI_SELECT.items():
mselect_dict[key] = {}
mselect_dict[key]['select'] = value
# mselect_dict[key]['options'] = option_list 以后可以后端通过列表为每个多选控件传递备选项

context = {
...
'mselect_dict': mselect_dict
}
return render(request, 'chpa_data/analysis.html', context) # 注意本句和前一章也有变化,渲染至analysis.html而不是display.html

前端html模板filter.html代码如下,为了用户体验,我们希望所有的下拉菜单都使用Semantic UI的search dropdown提供搜索响应功能,主要就是应用这个class:class=”ui fluid search dropdown”:

<div class="ui container">
<div class="ui form">
<form action="" method="post">
<!-- 在Django所有的 POST 表单元素时,需要加上下方的csrf_token tag,主要是安全方面的机制,本例后续使用AJAX方法,这里的POST class和token都不生效 -->
{&#37; csrf_token &#37;}
<h3 class="ui header" id="analysis">分析维度</h3>
<div class="field">
<div class="fields">
<div class="sixteen wide field">
<select name="DIMENSION_select" id="DIMENSION_select" class="ui fluid search dropdown">
{&#37; for key, value in mselect_dict.items &#37;}
{&#37; if value.select == 'PRODUCT' &#37;}
<option value="{&#123; value.select &#125;}" selected>{&#123; key &#125;}</option>
{&#37; else &#37;}
<option value="{&#123; value.select &#125;}">{&#123; key &#125;}</option>
{&#37; endif &#37;}
{&#37; endfor &#37;}
</select>
</div>
</div>
<div class="fields">
<div class="eight wide field">
<select name="UNIT_select" id="UNIT_select" class="ui fluid search dropdown">
<option value="Value" selected>金额</option>
<option value="Volume">盒数</option>
<option value="Volume (Counting Unit)">最小制剂单位数</option>
</select>
</div>
<div class="eight wide field">
<select name="PERIOD_select" id="PERIOD_select" class="ui fluid search dropdown">
<option value="MAT" selected>滚动年</option>
<option value="QTR">季度</option>
</select>
</div>
</div>
</div>
<h3 class="ui header" id="data_filter">数据筛选</h3>
<div class="field">
{&#37; for key, value in mselect_dict.items &#37;}
<div class="field">
<select name="{&#123; value.select|add:"_select[]" &#125;}" id="{&#123; value.select|add:"_select" &#125;}" multiple=""
class="ui fluid search dropdown">
<option value="">{&#123; key &#125;}</option>
{# {&#37; for item in value.options &#37;}#}
{# <option value="{&#123; item &#125;}">{&#123; item &#125;}</option>#}
{# {&#37; endfor &#37;}#}
</select>
</div>
{&#37; endfor &#37;}
</div>
<br>
<div class="ui buttons">
<input class="ui blue button" type='button' id='AJAX_get' value="查询"/>
</div>
</form>
</div>
</div>

<!-- 因为用到Semantic UI的Search Dropdown控件,必须有下面语句初始化 -->
<script>
$('.ui.fluid.search.dropdown')
.dropdown({ fullTextSearch: true });
</script>

这里首先我们第一次遇到了Django/Jinja2模板语法的集中应用,因为本文没有使用Django ORM,这种应用后续出场不多。我们只需要明白{% %}是功能标签,而{{ }}是变量标签,类似在模板层面的简单编程。而下方代码的意思是循环遍历后方传来的mselect_dict字典,字典的key是单选dimension_select下拉菜单选项的text,而value里嵌套的select键的值是菜单选项的value:

<select name="DIMENSION_select" id="DIMENSION_select" class="ui fluid search dropdown">
{&#37; for key, value in mselect_dict.items &#37;}
{&#37; if value.select == 'PRODUCT' &#37;}
<option value="{&#123; value.select &#125;}" selected>{&#123; key &#125;}</option>
{&#37; else &#37;}
<option value="{&#123; value.select &#125;}">{&#123; key &#125;}</option>
{&#37; endif &#37;}
{&#37; endfor &#37;}
</select>

同理,后续又循环了一次mselect_dict,为根据字典内容生成若干个多选下拉菜单,注释掉的部分是后端动态生成备选项的一种解决方案,本文后半部分会涉及:

{&#37; for key, value in mselect_dict.items &#37;}
<div class="field">
<select name="{&#123; value.select|add:"_select[]" &#125;}" id="{&#123; value.select|add:"_select" &#125;}"
multiple=""
class="ui fluid search dropdown">
<option value="">{&#123; key &#125;}</option>
{# {&#37; for item in value.options &#37;}#}
{# <option value="{&#123; item &#125;}">{&#123; item &#125;}</option>#}
{# {&#37; endfor &#37;}#}
</select>
</div>
{&#37; endfor &#37;}

这里有一个大坑是下面这句,可能会让人觉得很奇怪(这里的|add是tag filter,下一章会解释,这并不是最奇怪的地方):

<select name="{&#123; value.select|add:"_select[]" &#125;}" id="{&#123; value.select|add:"_select" &#125;}" multiple=""
class="ui fluid search dropdown">

为什么在很多场景下(如后续章节介绍的AJAX异步传参)必须以[]结尾才能正确工作。但有时[]也不是必须的。

此时再访问我们的主页http://127.0.0.1:8088/chpa/index,界面已经变成了下面这样:

筛选框已经在那了,但下方的多选框点开还没选项,我们还需要一个步骤,从后端动态传入所有多选下拉菜单的备选选项。

此时有两种常用方法:

  • 在页面初始化时从后端提取所有字段的不重复值作为选项传入前端。
  • 在控件搜索时根据键入关键字实时从后端返回前n个相关备选项。

第一种方法的优点是简单直接。在上方的代码块中,我们其实已经预留了注释掉的相应的代码,将views.py的index方法修改成类似下面这样,增加option_list部分传至前端:

def index(request):

...

mselect_dict = {}
for key, value in D_FIELD.items():
mselect_dict[key] = {}
mselect_dict[key]['select'] = value
mselect_dict[key]['options'] = option_list # option_list可以通过sql Distinct语句或Pandas的Unique方法获得,在此不再赘述

...

# 下面是一个获得各个字段option_list的简单方法
def get_distinct_list(column, db_table):
sql = "Select DISTINCT " + column + " From " + db_table
df = pd.read_sql_query(sql, ENGINE)
l = df.values.flatten().tolist()
return l

再在前端filter.html用下面的循环语句渲染部分:

<h3 class="ui header" id="data_filter">数据筛选</h3>
<div class="field">
{&#37; for key, value in mselect_dict.items &#37;}
<div class="field">
<select name="{&#123; value.select|add:"_select[]" &#125;}" id="{&#123; value.select|add:"_select" &#125;}" multiple=""
class="ui fluid search dropdown">
<option value="">{&#123; key &#125;}</option>
{&#37; for item in value.options &#37;}
<option value="{&#123; item &#125;}">{&#123; item &#125;}</option>
{&#37; endfor &#37;}
</select>
</div>
{&#37; endfor &#37;}
</div>

很遗憾,功能是实现了,但用户体验很不好。因为我们部分字段的可选项过多,造成页面初始化加载很慢,并且点开选项较多的下拉菜单时反应也很慢。这也是初始化控件选项方法的最大缺点,不适应加载量太大的情况。

下拉菜单的Search Select功能实现了,但加载时间不可接受

但是我们必须使用search select功能,因为医药行业的专业术语太多了。于是考虑使用第二个方法,在控件搜索时根据键入关键字实时从后端返回前n个相关备选项,也就是我们说的on Server Response的方法。该方法适合表单可选项过多的场景。不使用Vue或React的情况下,Semantic UI的dropdown API就支持建设这种响应式搜索功能,并且官网提供了下方的例子:

本例中实现这种方法确实要相对复杂。我们需要先在views.py建立search方法,该方法除request外包含2个参数,要查询的字段名和查询的字符串,返回不重复的匹配结果作为前端表单选项,格式为符合Semantic UI要求格式的json。

import json

def search(request, column, kw):
sql = "SELECT DISTINCT TOP 10 %s FROM %s WHERE %s like '%%%s%%'" % (column, DB_TABLE, column, kw) # 最简单的单一字符串like,返回不重复的前10个结果
try:
df = pd.read_sql_query(sql, ENGINE)
l = df.values.flatten().tolist()
results_list = []
for element in l:
option_dict = {'name': element,
'value': element,
}
results_list.append(option_dict)
res = {
"success": True,
"results": results_list,
"code": 200,
}
except Exception as e:
res = {
"success": False,
"errMsg": e,
"code": 0,
}
return HttpResponse(json.dumps(res, ensure_ascii=False), content_type="application/json charset=utf-8") # 返回结果必须是json格式

上面只是个匹配关键字的最简单例子,未来还可以继续完善,例如处理多个关键字,模糊查询等。

同时,我们需要在url.py编辑对应search方法的URL pattern,并用<>括号预留column和kw两个对应的参数位置:

urlpatterns = [
...
path(r'search/<str:column>/<str:kw>', views.search, name='search')
]

此时可在浏览器输入上面的URL试试看效果,能看到已经正常返回预期的json了:

最后参考Semantic UI官网的例子在前端模板文件filter.html末尾加上下面这段JS代码,将后台search方法和多选框绑定。注意下方代码相对复杂有好几个坑,我都在注释一一标出了:

<script>
// 在JS中再次使用字段字典,要加|safe不转义
var dict = {&#123; mselect_dict|safe &#125;};
// 还是转义问题,在Django模板中遇到带有{}的html代码必须使用replace这种方式处理
var url = "{&#37; url 'chpa:search' 'COLUMNPLACEHOLDER' 'QUERYPLACEHOLDER' &#37;}".replace(
'QUERYPLACEHOLDER', '{query}'
);
// jQuery语法遍历所有多选框
$('.ui.fluid.search.dropdown.selection.multiple').each(function () {
// Semantic UI语法获得多选框默认文本
var text = $(this).dropdown('get default text');
// 根据字典倒推该多选框是哪个字段
var column = dict[text]['select'];
$(this).dropdown(
{
apiSettings: {
// 用下方URL从后端返回查询后的json
url: url.replace('COLUMNPLACEHOLDER', column)
},
// 输入至少2个字符后才query
minCharacters : 2
})
;
})
</script>

在评论区有人回复下面语句会出现bug:

// Semantic UI语法获得多选框默认文本
var text = $(this).dropdown('get default text');

虽然我个人没有碰到,但是如果有碰到的,可以考虑摒弃Semantic UI API,使用原生的JQuery语句:

var text = $(this).children('select').children('option:first').text();

至此,我们终于完成了大部分前端表单交互的表面工作。本章内容比较繁杂,又第一次在项目中引入了二手程序员的天敌JS,我们在此停笔告一段落。下一章再讨论传参和异步加载的话题。