本篇是系列文章的第5篇,之前的更新见:
(一)需求分析&技术实现
(二)初步搭建Django环境
(三)页面布局&Django模板
(四)SQL+Pandas初步处理数据
(五)前端表单交互
(六)Ajax异步传参与加载
(七)前端数据格式的处理
(八)DataTables接管前端表格
(九)Pyecharts实现交互图表
(十)静态图表的展示
(十一)“导出数据至Excel”功能
(十二)添加和配置缓存
(十三)用户登录系统
(十四)部署Django至生产环境
在上一章,我们已经成功从后端将分析结果传回前端Django模板并展示,但这个分析结果是静态的,缺乏交互性。本章我们希望在预留的filter.html模板内建立表单,从前端向后端提交数据筛选的参数。本章的内容比较容易理解,但对用户体验至关重要,是个细致活。
还是回到上一章对数据本身各字段的分析,这对表单设计也格外重要:
如上图,我们需要有一个必填单选代表一个分析目标字段(我还是习惯称之为breakout字段),它决定返回的数据结果里是品类份额,还是品牌份额,还是其他xx份额。它也是后端Pandas的pivot_table方法里column的动态参数。
我们还需要另外两个必填单选字段——UNIT和PERIOD,原因也请参考上图。
而我们所有的属性字段都是可为空的多选。
本例中我们表单不需要考虑AMOUNT和DATE字段。因为AMOUNT是唯一的指标字段,而我们的分析结果会取最新一个DATE做横断面结果,并计划把所有DATE的数据作为趋势分析,我们不需要对DATE动态选择。而在其他一些场景下,日期字段是经常作为表单的一员的,甚至有很多专门为其设计的calendar控件。
综上所述,我们的表单设计是下面这个样子,我们需要在filter.html文件中实现它。
实际前端模板代码编写前,可以后端先传一个预设的字段字典。这样操作一是分离前端方便以后修改,大部分情况下以后只修改后端就可以了;二是可以利用循环极大缩短代码长度,更加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都不生效 -->
{% csrf_token %}
<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">
{% for key, value in mselect_dict.items %}
{% if value.select == 'PRODUCT' %}
<option value="{{ value.select }}" selected>{{ key }}</option>
{% else %}
<option value="{{ value.select }}">{{ key }}</option>
{% endif %}
{% endfor %}
</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">
{% for key, value in mselect_dict.items %}
<div class="field">
<select name="{{ value.select|add:"_select[]" }}" id="{{ value.select|add:"_select" }}" multiple=""
class="ui fluid search dropdown">
<option value="">{{ key }}</option>
{# {% for item in value.options %}#}
{# <option value="{{ item }}">{{ item }}</option>#}
{# {% endfor %}#}
</select>
</div>
{% endfor %}
</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">
{% for key, value in mselect_dict.items %}
{% if value.select == 'PRODUCT' %}
<option value="{{ value.select }}" selected>{{ key }}</option>
{% else %}
<option value="{{ value.select }}">{{ key }}</option>
{% endif %}
{% endfor %}
</select>
同理,后续又循环了一次mselect_dict,为根据字典内容生成若干个多选下拉菜单,注释掉的部分是后端动态生成备选项的一种解决方案,本文后半部分会涉及:
{% for key, value in mselect_dict.items %}
<div class="field">
<select name="{{ value.select|add:"_select[]" }}" id="{{ value.select|add:"_select" }}"
multiple=""
class="ui fluid search dropdown">
<option value="">{{ key }}</option>
{# {% for item in value.options %}#}
{# <option value="{{ item }}">{{ item }}</option>#}
{# {% endfor %}#}
</select>
</div>
{% endfor %}
这里有一个大坑是下面这句,可能会让人觉得很奇怪(这里的|add是tag filter,下一章会解释,这并不是最奇怪的地方):
<select name="{{ value.select|add:"_select[]" }}" id="{{ value.select|add:"_select" }}" multiple=""
class="ui fluid search dropdown">
为什么<select name>要加个后缀[]?这是因为以后在jQuery传参时多选控件(实际就是传送array而不是单个变量的控件)的<select name>在很多场景下必须以[]结尾才能正确工作。
但有时[]也不是必须的,这里有一篇详细的文章阐释这个现象,感兴趣的可以做进一步参考,在此就不再赘述了。
How to send FormData objects with Ajax-requests in jQuery?stackoverflow.com
此时再访问我们的主页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用下面的循环语句渲染<option></option>部分:
<h3 class="ui header" id="data_filter">数据筛选</h3>
<div class="field">
{% for key, value in mselect_dict.items %}
<div class="field">
<select name="{{ value.select|add:"_select[]" }}" id="{{ value.select|add:"_select" }}" multiple=""
class="ui fluid search dropdown">
<option value="">{{ key }}</option>
{% for item in value.options %}
<option value="{{ item }}">{{ item }}</option>
{% endfor %}
</select>
</div>
{% endfor %}
</div>
很遗憾,功能是实现了,但用户体验很不好。因为我们部分字段的可选项过多,造成页面初始化加载很慢,并且点开选项较多的下拉菜单时反应也很慢。这也是初始化控件选项方法的最大缺点,不适应加载量太大的情况。
但是我们必须使用search select功能,因为医药行业的专业术语太多了。于是考虑使用第二个方法,在控件搜索时根据键入关键字实时从后端返回前n个相关备选项,也就是我们说的on Server Response的方法。该方法适合表单可选项过多的场景。不使用Vue或React的情况下,Semantic UI的dropdown API就支持建设这种响应式搜索功能,并且官网提供了下方的例子:
Match Search Query on Serversemantic-ui.com本例中实现这种方法确实要相对复杂。我们需要先在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 = {{ mselect_dict|safe }};
// 还是转义问题,在Django模板中遇到带有{}的html代码必须使用replace这种方式处理
var url = "{% url 'chpa:search' 'COLUMNPLACEHOLDER' 'QUERYPLACEHOLDER' %}".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,我们在此停笔告一段落。下一章再讨论传参和异步加载的话题。