前几天写了数据表格table的前端分页展现,思路是把数据一次性取到前端,然后由前端来控制分页展现。这种做法主要目的是为了降低后端数据库读写的次数减轻服务端运行压力。但是,如果功能不单是查询还要进行增删改操作,那么一次数据提取到前端的做法就有些问题了,因为需要保持前后端数据集的同步,这个控制逻辑就比较复杂了(也不是不能写),不如老老实实用传统办法,就是后端根据前端的要求每次提供好分页内的数据即可。
讲真,这种每次翻页就跑到后端取数的逻辑,我是不喜欢的,看上去很笨,因为每次换页都要提交后端进行数据请求,然后取数据还是要先读取全量数据,却只取指定偏移量后的10几条记录,看着就让人有种用大炮打苍蝇的感觉。而且后端服务器如果并发较多的情况下,这种逻辑对系统会产生很大的运行压力。不过,与前后端数据协同的复杂逻辑相比,想找一种效率很高又直白的控制逻辑,也是很难的。
好在,大多数系统的并发量并不大,用这种模式也是不错的。毕竟这次用一直被吐槽运行效率低的python(还有flask)搭建这个系统,主要就是面向中小型企业的业务需求。,这些企业系统要求功能丰富业务逻辑严密完善,但用的人不多,并发量不大,真不必太教条地搬用那些运行效率法则。真到了要性能调优的时候,自然也会有很多种方案来解决。
好吧,接着上程序。这个示范功能是用来做会员管理的,会员新增主要是通过自行注册完成,后台管理的会员管理主要起辅助作用,比传统的编辑功能,多加了一个封禁和解封的功能。主功能是做出会员信息的列表,对LayUI来说,就是一个经典的数据表格datatable的应用。
首先是会员表model的程序,数据库结构如下定义,其中对password做了特殊处理,存储在数据库中的是加密后的结果。
class Members(db.Model):
__tablename__ = 'cm_member'
uid = db.Column(db.Integer, primary_key=True, autoincrement=True)
username = db.Column(db.String(50), nullable=False, unique=True) # 会员名不能为空,而且必须是唯一的
_password = db.Column(db.String(200), nullable=False) # 密码不能为空
email = db.Column(db.String(50), nullable=False, unique=True) # 用户邮箱不能为空,而且必须是唯一的
nickname=db.Column(db.String(50),nullable=True) #用户昵称
role_cd = db.Column(db.String(4),default='30') #角色编码
sex = db.Column(db.String(2), default='0') # 性别
telephone = db.Column(db.String(11)) # 电话
agent = db.Column(db.String(20)) # 推荐人、代理人,对应内部员工号
avatar = db.Column(db.String(128),default=None) # 头像
status = db.Column(db.Integer) # 状态 0:正常 1:审核 8:临封 9:封禁
regtime = db.Column(db.DateTime,default=datetime.now) #注册时间
def __init__(self,username,password,email,status,avatar='',nickname='',
sex='0',role_cd='30',telephone='',agent=''):
self.username=username
self.password=password
self.email=email
self.status=status
self.avatar=avatar
self.nickname=nickname
self.sex = sex
self.telephone=telephone
self.agent = agent
self.role_cd = role_cd
#获取密码
@property
def password(self):
return self._password
#设置密码
@password.setter
def password(self,raw_password):
self._password=generate_password_hash(raw_password)#密码加密
#检查密码
def check_password(self,raw_password):
result=check_password_hash(self.password,raw_password)#
return result
然后第二个程序是前端展现页面,用于会员信息列表的展示。前端程序的主体还是table.render()部分,和后端一次性提取数据前端控制分页展示用data指定本地数据集作为数据源相比,这个table.render()的主要区别,就是数据来源由url定义的路由来提供,其它的都完全一致。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>会员管理</title>
<link rel="stylesheet" href="/static/layui/css/layui.css" media="all">
</head>
<body>
<table id="table_list" lay-filter="table_list" style="margin-top:-15px;"></table>
<script type="text/html" id="toolBar">
<div class="layui-btn-container">
<div class="layui-inline">
<label class="layui-btn-sm">会员名称:</label>
<div class="layui-input-inline">
<input type="text" id="searchtext" placeholder="请输入名称" autocomplete="off" class="layui-input layui-btn-sm">
</div>
</div>
<div class="layui-inline">
<div class="layui-input-inline" style="padding-left:10px;padding-top:8px">
<button id="btn_search" type="button" class="layui-btn layui-btn-normal layui-btn-sm" lay-event="search">
<i class="layui-icon layui-icon-search"></i>查询
</button>
<button id="btn_add" type="button" class="layui-btn layui-btn-sm" lay-event="add">
<i class="layui-icon layui-icon-add-1"></i>增加会员
</button>
<button id="btn_mban" type="button" class="layui-btn layui-btn-sm" lay-event="mban">
<i class="layui-icon layui-icon-lock"></i>批量封禁
</button>
</div>
</div>
</div>
</script>
<script type="text/html" id="linetoolBar">
{% raw %}
{{# if (d.status == 0 ) { }}
<a lay-event="ban" title="封禁"><i class="layui-icon layui-icon-lock" style="color:red;"></i></a>
{{# } if (d.status == 9) { }}
<a lay-event="unban" title="解禁"><i class="layui-icon layui-icon-ok-circle" style="color:green;"></i></a>
{{# } }}
<a lay-event="edit" title="编辑" ><i class="layui-icon layui-icon-edit"></i></a>
<a lay-envent="rsetpwd" title="重置密码"><i class="layui-icon layui-icon-password"></i></a>
{% endraw %}
</script>
<script src="/static/layui/layui.js"></script>
<script>
layui.use(['jquery','layer','table'], function(){
var layer=layui.layer
,$=layui.jquery
,table=layui.table;
var cur_row; //初始化表格当前行
var url_list = '{{url_for("sysadm.member_list")}}';
table.render({
elem: '#table_list'
,height: 'full'
,url: url_list
,toolbar: '#toolBar'
,method: 'POST'
,page: true //开启分页
,limits: [16, 20, 30, 40, 50]
,limit : 16
,even : true
,size : 'sm'
,cols: [[
{ type: 'checkbox', fixed: 'left' }
,{field: 'id', title: 'ID', width:30, sort: true, fixed: 'left'}
,{field: 'username', title: '会员名', width:90, sort: true, fixed: 'left'}
,{field: 'nickname', title: '昵称', width:90, sort: true}
,{field: 'email', title: '邮箱', width:170, sort: true}
,{field: 'sex_name', title: '性别', width:60, sort: true}
,{field: 'telephone', title: '电话', width:100, sort: true}
,{field: 'role_note', title: '会员角色', width: 100}
,{field: 'status_name', title: '状态', width: 30}
,{field: 'agent', title: '推荐人', width: 70}
,{field: 'regtime', title: '注册时间', width:160}
,{fixed: 'right', width:120, align:'center', toolbar: '#linetoolBar'}
]]
});
//表头工具栏事件
table.on('toolbar(table_list)', function (obj) {
let cpage = obj.config.page.curr;
console.log(JSON.stringify(obj.config.page))
switch (obj.event) {
case 'search':
table_refresh(1);
break;
case 'add':
break;
case 'mban':
break;
};
});
//table行内工具栏事件
table.on('tool(table_list)', function (obj) { //obj是指这张表中的数据
cur_row = obj.data;
rid = cur_row.id;
let cpage = obj.config.page.curr;
//obj.event:获取触发事件的元素的 event 值,用于区分不同的操作
switch(obj.event) {
case 'edit':
break;
case 'ban':
break;
case 'unban':
break;
case 'resetpwd':
break;
}
});
function table_refresh(cpage) {
table.reload('table_list', {
where: {
'searchtext':$('#searchtext').val()
},
page: { curr: cpage },
},true);
}
});
</script>
</body>
</html>
因为多了编辑功能,所以用toolbar和linetoolbar定义了一批button功能按钮,再用table.on进行事件侦听监控,本部分主要关注列表展示,所以toolbar中功能button的处理先都删除掉了。
功能展现从表面看和一次性数据加载前端的分页控制并没有什么区别,不过内里是不一样,主要体现在分页的操作上。为了展示分页方便,将table.render()中的limit参数设为6,系统前端数据展现就会体现出分成3页的效果。
表面上看,这个分页和前端分页的展示完全一致,但内部处理逻辑完全不同。在这个后端控制分页数据时,点击分页栏的任何一个按钮,都会向后端服务发出一个post请求,后端程序接收到请求后,即按要求生成相应的数据。
#会员列表
@bp.route('/member_list/',methods=['GET','POST'])
@login_required
@admin_auth
def member_list():
if request.method == 'GET':
return render_template('admin/member_list.html.j2')
else :
username = request.values.get('searchtext')
filtstr = '1=1'
if username :
filtstr += ' and username like "' + username + r'%"'
currPage = int(request.values.get('page'))
pageLimit = int(request.values.get('limit'))
logging.debug('POST Member_list (curr_page:%s pagernum:%s).....' % (str(currPage),str(pageLimit)))
countTotal = db.session.query(func.count(Members.uid)).filter(text(filtstr)).scalar()
if countTotal == 0:
rsdata = {
"code": 0,
"msg": "无满足条件记录",
"count": countTotal,
"data":[]
}
return json.dumps(rsdata)
# 当总记录数小于偏移量时,将当前页数减1,生成最末一页数,前端会重新处理好页数。
if (currPage -1 ) * pageLimit >= countTotal :
currPage = currPage - 1;
rows = db.session.query(Members).filter(text(filtstr)).offset((currPage-1)*pageLimit).limit(pageLimit).all()
#获取总的记录
recNum = len(rows)
logging.debug('Total Rnumber %s Get Rec Number %s' % (str(countTotal),str(recNum)))
reclist = []
mbrStatus = Member_Status()
mbrSex = Unv_Gender()
mbrRole = Member_Role()
for irow in rows:
udata = dict(id=irow.uid,username=irow.username,email=irow.email,avatar=irow.avatar,
nickname=irow.nickname,telephone=irow.telephone,agent=irow.agent,
regtime=irow.regtime.strftime('%Y-%m-%d %H:%M:%S'),
sex=irow.sex,sex_name=mbrSex.get_name(irow.sex),
status=irow.status,status_name=mbrStatus.get_name(irow.status),
role_cd=irow.role_cd,role_note=mbrRole.id_format(irow.role_cd))
reclist.append(udata)
rsdata = {
"code": 0,
"msg": "",
"count": countTotal,
"data":reclist
}
return json.dumps(rsdata)
通过后端程序可以看出来,除了前端post请求中列出的上传参数外,在request的参数里多了两个隐含参数,page和limit,page是当前页数,limit是每页记录数,通过这两个参数可以计算出前端要求数据的偏移量。
后端程序分为三个部分,第一部分,是根据前端检索内容生成数据库筛选条件filtstr,sqlalchemy的query方法表面上看是一个面向单表的数据库查询接口,实质还是在拼SQL串,通过text()函数可以把任何的SQL字串拼到查询语句中。
第二部分是获取page和limit,并重新计算数据库结果集的记录数目,然后根据总记录数对page进行调整,这个调整主要是用于删除记录后的当前页调整(删除到最后一页的唯一记录时,当前页应该调整到前一页)。
第三部分是程序的主体生成下传的数据结果集。结果集的格式是在table.render()的说明里规定的,包括四项,code是结果码,0表示正常返回,msg返回一个提示信息,当错误时前端会显示这个信息,count是总的记录数,前端将根据这个记录数重新调整分页栏的内容,data就是数据结果集。正常返回时,data里就是一个包含记录数据的字典列表。
还有几点要说明的:
一、返回数据是用json.dumps() 而不是flask自带的jsonify(),这两种JSON转换方法表面看是一样的,实际有一个重大的区别,就是json.dumps会将返回数据中的True/False(python的布尔值)转换成true/false(Javascript的布尔值),而flask-jsonify则不会做这个转换。这个布尔值的转换在datatable时用不到,但在treetable的返回结果集中是有用的,有个isparent属性用于标识是否有父节点,javascript是不识别python的布尔值的。
二、日期型的格式转换。用了strftime('%Y-%m-%d %H:%M:%S')将数据库内部时间字段转换成“YYYY-MM-DD HH:MM:SS"。如果不转,前端显示的原始记录还会带上星期值,不但不友好而且看着很乱。
三 、对代码字段(比如状态、性别、角色)等,由数据库内原始代码值转为码值说明,这个转换是做了一组维代码类来完成的,后面会介绍如何构建的。
四、linetoolBar的定义中用了{%raw%}......{%endraw%}的jinja2的屏蔽语义转换语句,其内部的{{...}}语句,jinja2不会做转换,所以前端LayUI就可以进行模板转换了。
发现无论哪种渲染语言,似乎都对{{...}}有偏爱,以前也许这是冷门,但现在不约而同之下,冷门就变成了热点冲突。不单flask-jinja2用,LayUI用,VUE实际也在用。我的解决方案,就是严格限制在html的DOM中用jinja2转换,同时后端python严格遵守只生成数据不生成界面的原则,这样在前端就基本不会有啥冲突了。
最终,显示的功能界面如下: