用户信息弹窗及私信功能实现
1. 用户信息弹窗
1.1. 编写用户弹窗信息视图
修改app/main/views.py
脚本,编写用户弹窗信息视图。
class UserPopupView(View):
"""用户资料弹出框"""
methods = ['GET']
decorators = [login_required]
def dispatch_request(self, username):
user = User.query.filter_by(username=username).first_or_404()
return render_template('user_popup.html', user=user)
修改app/main/__init__.py
脚本,注册视图。
def register_views(bp):
# 在函数中引入可以避免循环依赖问题
from app.main.views import IndexView, ExploreView, UserInfoView, UserInfoEditView, FollowView, \
UnfollowView, SearchView, UserPopupView
# ......
# 用户资料弹出框
bp.add_url_rule('/info/<username>/popup', view_func=UserPopupView.as_view('user_popup'))
1.2. 编写用户信息弹窗模板
新增app/templates/user_popup.html
模板文件,编写用户信息弹窗展示。
<table class="table">
<tr>
<td width="64px" style="border: 0px;"><img src="{{ get_avatars(user.email, 128) }}"></td>
<td style="border: 0px;">
<p>
<a href="{{ url_for('main.user_info', username=user.username) }}">
{{ user.username }}
</a>
</p>
<small>
{% if user.about_me %}
<p>{{ user.about_me }}</p>
{% endif %}
{% if user.last_seen %}
{{ _('最后访问于') }}:<p>{{ moment(user.last_seen).format('LLL') }}</p>
{% endif %}
<p>
{{ _('粉丝') }} {{ user.followers.count() }},{{ _('关注') }} {{ user.followed.count() }}
</p>
{% if user == current_user %}
<p><a href="{{ url_for('main.user_info_edit') }}">{{ _('编辑您的资料') }}</a></p>
{% elif not current_user.is_following(user) %}
<p><a href="{{ url_for('main.follow', username=user.username) }}">{{ _('关注') }}</a></p>
{% else %}
<p><a href="{{ url_for('main.unfollow', username=user.username) }}">{{ _('取消关注') }}</a></p>
{% endif %}
</small>
</td>
</tr>
</table>
1.3. 新增弹出窗口关联的DOM元素
修改app/templates/_post.html
模板文件,增加弹出窗口关联的DOM元素。新增<span>
元素,将<a>
元素封装在<span>
元素中,然后将悬停事件和弹出窗口与<span>
相关联,关联方式是通过其样式类user_popup
来关联实现。新创建的弹窗元素会被自动的添加到<span>
元素内的<a>
元素后面。
......
{% set user_link %}
<span class="user_popup">
<a href="{{ url_for('main.user_info', username=post.author.username) }}">
{{ post.author.username }}
</a>
</span>
{% endset %}
......
1.4. 悬停事件实现
通过修改app/templates/base.html
模板文件,新增基于ajax的悬停事件。其中通过CSS类选择器可以选定页面中新增的样式类为user_popup
的<span>
元素。其中使用到的const
是ES6语法中新增的变量定义方式,定义的一般为只读常量,其作用于属于块级作用域。
使用jQuery,可以通过调用element.hover(handlerIn, handlerOut)
将悬停事件附加到任何HTML元素。 如果在元素集合上调用这个函数,jQuery方便地将事件附加到所有元素上。 这两个参数是两个函数,分别在用户将鼠标指针移入和移出目标元素时调用对应的函数。
{% block scripts %}
{{ super() }}
{{ moment.include_moment() }}
{{ moment.lang(g.locale) }}
<script>
$(function(){
$('.user_popup').hover(
function(event) {
const elem = $(event.currentTarget);
......
},
function(event) {
const elem = $(event.currentTarget);
......
}
)
})
</script>
{% endblock %}
通过增加定时器的方式,可以实现鼠标停留1秒后激活弹出行为的悬停延迟效果。在鼠标移入函数中,增加1秒的定时器,鼠标悬停1秒后,进行弹出窗口创建,在鼠标移出函数中则取消定时器。
$(function(){
let timer = null;
$('.user_popup').hover(
function(event) {
const elem = $(event.currentTarget);
timer = setTimeout(function() {
timer=null;
// ------弹出窗口实现代码------ //
}, 1000);
},
function(event) {
const elem = $(event.currentTarget);
if (timer) {
clearTimeout(timer);
timer=null;
}
}
)
})
定时器内增加1秒后的弹窗创建处理。通过ajax方式请求后台的/info/<username>/popup
服务,done
中为请求成功后的回调函数,用于创建manual
模式的弹窗对象,该模式的弹窗对象可手工创建、销毁。
在鼠标移出函数中增加处理,若鼠标悬停在1秒内,则直接停止定时器;若鼠标悬停超过1秒,但是ajax通讯处理中,则停止ajax通讯;若ajax通讯正常处理完成了,则将弹窗销毁。
$(function(){
let timer = null;
let xhr = null;
$('.user_popup').hover(
function(event) {
// 获取span元素
const elem = $(event.currentTarget);
// 创建定时器
timer = setTimeout(function() {
timer=null;
// 通讯后台,获取弹窗展示页面
xhr = $.ajax(
`/info/${elem.first().text().trim()}/popup`).done(
function(data){
xhr = null;
// 创建弹窗对象并展示
elem.popover({
trigger: 'manual',
html: true,
animation: false,
container: elem,
content: data
}).popover('show');
// 渲染通过Ajax添加新的Flask-Moment元素
flask_moment_render_all();
}
);
}, 1000);
},
function(event) {
const elem = $(event.currentTarget);
if (timer) {
clearTimeout(timer);
timer=null;
} else if (xhr) {
xhr.abort();
xhr = null;
} else {
elem.popover('destroy');
}
}
)
})
1.5. 启动服务测试效果
2. 发送私信功能
2.1. 新增私信数据库模型
修改app/models.py
脚本,新增私信数据模型。
class Message(db.Model):
"""私信"""
__tablename__ = 'messages'
id = db.Column(db.Integer, primary_key=True)
sender_id = db.Column(db.Integer, db.ForeignKey('users.id'))
recipient_id = db.Column(db.Integer, db.ForeignKey('users.id'))
body = db.Column(db.String(140))
timestamp = db.Column(db.DateTime, index=True, default=datetime.utcnow())
def __repr__(self):
return '<Message {}>'.format(self.body)
修改User
模型,增加对私信的反向查询支持,并增加用户最后阅读信息时间以及根据最后阅读时间统计的未读信息条数。
class User(UserMixin, db.Model):
# ......
messages_sent = db.relationship('Message', foreign_keys='Message.sender_id',
backref='author', lazy='dynamic')
messages_received = db.relationship('Message', foreign_keys='Message.recipient_id',
backref='recipient', lazy='dynamic')
last_message_read_time = db.Column(db.DateTime)
def new_messages(self):
"""查询用户未读信息条数"""
last_read_time = self.last_message_read_time or datetime(1900, 1, 1)
return Message.query.filter_by(recipient=self).filter(Message.timestamp > last_read_time).count()
# ......
进行数据库迁移
(venv) D:\Projects\learn\flask-mega-tutorial>flask db migrate -m "私信"
[2019-05-16 15:49:33,108] INFO in logger: 博客已启动
INFO [alembic.runtime.migration] Context impl SQLiteImpl.
INFO [alembic.runtime.migration] Will assume non-transactional DDL.
INFO [alembic.autogenerate.compare] Detected added table 'messages'
INFO [alembic.autogenerate.compare] Detected added index 'ix_messages_timestamp' on '['timestamp']'
INFO [alembic.autogenerate.compare] Detected added column 'users.last_message_read_time'
Generating D:\Projects\learn\flask-mega-tutorial\migrations\versions\73b28d6c195e_私信.py ... done
(venv) D:\Projects\learn\flask-mega-tutorial>flask db upgrade
[2019-05-16 15:49:42,810] INFO in logger: 博客已启动
INFO [alembic.runtime.migration] Context impl SQLiteImpl.
INFO [alembic.runtime.migration] Will assume non-transactional DDL.
INFO [alembic.runtime.migration] Running upgrade 251e4854ef4c -> 73b28d6c195e, 私信
2.2. 编写发送私信表单类
修改app/main/forms.py
脚本,新增发送私信表单类。
class MessageForm(FlaskForm):
"""私信表单"""
message = TextAreaField(_l('内容'), validators=[DataRequired(), Length(min=0, max=140)])
submit = SubmitField(_l('提交'))
2.3. 新增发送私信模板
新增app/templates/send_message.html
文件,用于编写发送私信页面。
{% extends 'base.html'%}
{% import 'bootstrap/wtf.html' as wtf %}
{% block app_content %}
<h1>{{ _('向%(recipient)s发送信息', recipient=recipient) }}</h1>
<div class="row">
<div class="col-md-4">
{{ wtf.quick_form(form) }}
</div>
</div>
{% endblock %}
2.4. 编写发送私信视图类
修改app/main/views.py
脚本,新增发送私信视图类。
class SendMessageView(View):
"""发送私信视图"""
methods = ['GET', 'POST']
decorators = [login_required]
def dispatch_request(self, recipient):
user = User.query.filter_by(username=recipient).first_or_404()
form = MessageForm()
if form.validate_on_submit():
msg = Message(author=current_user, recipient=user, body=form.message.data)
db.session.add(msg)
db.session.commit()
flash(_('您的消息已被发送'))
return redirect(url_for('main.user_info', username=recipient))
return render_template('send_message.html', title=_('发送消息'), form=form, recipient=recipient)
修改app/maim/__init__.py
脚本,注册视图。
def register_views(bp):
# 在函数中引入可以避免循环依赖问题
from app.main.views import IndexView, ExploreView, UserInfoView, UserInfoEditView, FollowView, \
UnfollowView, SearchView, UserPopupView, SendMessageView
# ......
# 发送私信视图
bp.add_url_rule('/send_message/<recipient>', view_func=SendMessageView.as_view('send_message'))
2.5. 个人主页增加发送私信链接
修改app/templates/user_info.html
文件,增加发送私信链接。
{% if user != current_user %}
<p><a href="{{ url_for('main.send_message', recipient=user.username) }}">{{ _('发送私信') }}</a></p>
{% endif %}
</td>
</tr>
2.6. 启动服务测试效果
通过susan用户登录,查看john的个人资料信息。
点击发送私信链接,跳转至发送私信页面。
发送私信后,跳转回john个人资料页面。
3. 查看私信功能
3.1. 编写查看私信视图类
修改app/main/views.py
脚本,增加查看私信视图类。
class MessageView(View):
"""私信视图"""
methods = ['GET']
decorators = [login_required]
def dispatch_request(self):
# 更新私信最后查看时间
current_user.last_message_read_time = datetime.utcnow()
db.session.commit()
page = request.args.get('page', 1, type=int)
messages = current_user.messages_received.order_by(
Message.timestamp.desc()).paginate(
page, current_app.config['POSTS_PER_PAGE'], False)
next_url = url_for('main.messages', page=messages.next_num) if messages.has_next else None
prev_url = url_for('main.messages', page=messages.prev_num) if messages.has_prev else None
return render_template('messages.html', messages=messages.items, next_url=next_url, prev_url=prev_url, page=page)
修改app/maim/__init__.py
脚本,注册视图。
def register_views(bp):
# 在函数中引入可以避免循环依赖问题
from app.main.views import IndexView, ExploreView, UserInfoView, UserInfoEditView, FollowView, \
UnfollowView, SearchView, UserPopupView, SendMessageView, MessageView
......
# 查看私信视图
bp.add_url_rule('/messages', view_func=MessageView.as_view('messages'))
3.2. 新增查看私信模板
新增app/templates/messages.html
文件,编写展示私信页面。
{% extends 'base.html' %}
{% block app_content %}
<h1>{{ _('私信') }}</h1>
{% for post in messages %}
{% include '_post.html' %}
{% endfor %}
<nav aria-label="...">
<ul class="pager">
<li class="previous{% if not prev_url %} disabled {% endif %}">
<a href="{{ prev_url or '#' }}">
<span aria-hidden="true">←</span> {{ _('上一页') }}
</a>
</li>
<li>{{ _('第%(page)s页', page=page) }}</li>
<li class="next{% if not next_url %} disabled {% endif %}">
<a href="{{ next_url or '#' }}">
{{ _('下一页') }} <span aria-hidden="true">→</span>
</a>
</li>
</ul>
</nav>
{% endblock %}
3.3. 导航栏增加查看私信链接
修改app/templates/base.html
文件,增加查看私信链接。
......
<ul class="nav navbar-nav navbar-right">
{% if current_user.is_anonymous %}
<li><a href="{{ url_for('auth.login') }}">{{ _('登录') }}</a></li>
{% else %}
<li><a href="{{ url_for('main.messages') }}">{{ _('私信') }}</a></li>
......
3.4. 更新翻译文件
作为所有更改的一部分,还有一些新的文本被添加到几个位置,并且需要将这些文本合并到语言翻译中。 第一步是更新所有的语言目录:
(venv) D:\Projects\learn\flask-mega-tutorial>flask translate update
然后,app/translations
中的每种语言都需要使用新翻译更新其messages.po文件,并进行编译。
(venv) D:\Projects\learn\flask-mega-tutorial>flask translate compile
3.5. 启动服务测试效果
通过用户john登录,首页增加私信链接。
点击私信链接,查看私信信息。
4. 消息通知徽章功能
4.1. 静态消息通知徽章
现在私有消息功能已经实现,但是还没有通过任何渠道告诉用户有私有消息等待阅读。导航栏上的未读消息标志的最简单实现可以使用Bootstrap badge小部件渲染到基础模板中。
修改app/templates/base.html
文件,在导航栏中增加静态消息通知徽章。通过current_user.new_messages()
函数获取用户当前未读信息条数。
......
<ul class="nav navbar-nav navbar-right">
{% if current_user.is_anonymous %}
<li><a href="{{ url_for('auth.login') }}">{{ _('登录') }}</a></li>
{% else %}
<li>
<a href="{{ url_for('main.messages') }}">
{{ _('私信') }}
{% set new_messages = current_user.new_messages() %}
{% if new_messages %}
<span class="badge">{{ new_messages }}</span>
{% endif %}
</a>
</li>
......
4.2. 动态消息通知徽章模板修改
对于静态消息通知徽章仍存在问题,即使未读消息数量不能自动刷新,需要点击链接并刷新页面后才能展示最新的未读消息。
目前当加载页面消息计数为非零时,徽章才在页面中渲染。 为了实现动态消息徽章,首先要修改app/templates/base.html
文件,始终保持在导航栏中包含徽章,并在消息计数为零时将其标记为隐藏。为表示徽章的元素添加了一个id
属性,以便使用$('#message_count')
jQuery选择器来简化这个元素的选取。
......
<ul class="nav navbar-nav navbar-right">
{% if current_user.is_anonymous %}
<li><a href="{{ url_for('auth.login') }}">{{ _('登录') }}</a></li>
{% else %}
<li>
<a href="{{ url_for('main.messages') }}">
{{ _('私信') }}
{% set new_messages = current_user.new_messages() %}
<span id="message_count" class="badge"
style="visibility: {% if new_messages %}visible
{% else %}hidden {% endif %};">
{{ new_messages }}
</span>
</a>
</li>
......
还是修改app/templates/base.html
文件,编写一个JavaScript函数,将该徽章更新为最新的数字:
......
<script>
......
function set_message_count(n) {
$('#message_count').text(n);
$('#message_count').css('visibility', n ? 'visible': 'hidden');
}
</script>
......
4.3. 新增通知模型
现在还需要做的,就是客户端可以定期接收有关用户拥有的未读消息数量的更新。 当更新发生时,客户端将调用set_message_count()
函数来使用户知道更新,目前存在两种处理方式:
- 客户端定期请求服务器:客户端通过发送异步请求定期向服务器请求更新。 根据请求的响应信息来更新页面的不同元素,例如未读消息计数标记,此方式的缺点在于不能实时获取最新信息;
- 服务端自动推送客户端:需要客户端和服务器之间的特殊连接类型,以允许服务器自由地将数据推送到客户端,此方式的缺点在于消耗资源较大。
目前先通过第一种方式来处理,首先,要修改app/models.py
脚本,添加一个新模型来跟踪所有用户的通知,以及用户模型中的关系。其中通知模型中payload_json
字段用于存通知信息,每种类型的通知都会有所不同,所以将它写为JSON字符串,因为这样可以编写列表,字典或单个值(如数字或字符串)。 为了方便,添加get_data()
方法,以便调用者不必操心JSON的反序列化。
class User(UserMixin, db.Model):
# ......
notifications = db.relationship('Notification', backref='user', lazy='dynamic')
# ......
class Notification(db.Model):
"""通知"""
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(128), index=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
timestamp = db.Column(db.Float, index=True, default=time)
payload_json = db.Column(db.Text)
def get_data(self):
return json.loads(str(self.payload_json))
数据库迁移
(venv) D:\Projects\learn\flask-mega-tutorial>flask db migrate -m "通知"
[2019-05-16 17:57:47,995] INFO in logger: 博客已启动
INFO [alembic.runtime.migration] Context impl SQLiteImpl.
INFO [alembic.runtime.migration] Will assume non-transactional DDL.
INFO [alembic.autogenerate.compare] Detected added table 'notification'
INFO [alembic.autogenerate.compare] Detected added index 'ix_notification_name' on '['name']'
INFO [alembic.autogenerate.compare] Detected added index 'ix_notification_timestamp' on '['timestamp']'
Generating D:\Projects\learn\flask-mega-tutorial\migrations\versions\d8b0c0657b94_通知.py ... done
(venv) D:\Projects\learn\flask-mega-tutorial>flask db upgrade
[2019-05-16 17:57:54,403] INFO in logger: 博客已启动
INFO [alembic.runtime.migration] Context impl SQLiteImpl.
INFO [alembic.runtime.migration] Will assume non-transactional DDL.
INFO [alembic.runtime.migration] Running upgrade 73b28d6c195e -> d8b0c0657b94, 通知
修改microblog.py
脚本,将新增的Message
和Notification
模型添加到shell上下文,这样就可以直接在用flask shell
命令启动的解释器中使用这两个模型了。
from app import create_app, db
from app.models import User, Post, Message, Notification
app = create_app()
@app.shell_context_processor
def make_shell_context():
return {'db': db, 'User': User, 'Post': Post, 'Message': Message, 'Notification': Notification}
4.4. 更新用户通知
在用户模型中添加一个add_notification()
辅助方法,以便更轻松地处理这些对象。此方法不仅为用户添加通知给数据库,还确保如果具有相同名称的通知已存在,则会首先删除该通知。 将要使用的通知将被称为unread_message_count
。 如果数据库已经有一个带有这个名称的通知,例如值为3,则当用户收到新消息并且消息计数变为4时,我就会替换旧的通知。
class User(UserMixin, db.Model):
# ......
def add_notification(self, name, data):
"""新增通知"""
self.notifications.filter_by(name=name).delete()
n = Notification(name=name, payload_json=json.dumps(data), user=self)
db.session.add(n)
db.session.commit()
return n
修改app/main/views.py
脚本,修改发送私信视图类,当用户接收到新的私信时,更新用户通知信息。
class SendMessageView(View):
# ......
def dispatch_request(self, recipient):
# ......
if form.validate_on_submit():
# ......
user.add_notification('unread_message_count', user.new_messages())
db.session.commit()
修改app/main/views.py
脚本,修改查看私信视图函数,当用户跳转到查看私信页面时,未读计数需要清零。
class MessageView(View):
# ......
def dispatch_request(self):
# 更新私信最后查看时间
current_user.last_message_read_time = datetime.utcnow()
current_user.add_notification('unread_message_count', 0)
db.session.commit()
4.5. 编写通知视图类
修改app/main/views.py
脚本,增加通知视图类,用于提供客户端对于对当前登录用户的通知检索功能。
class NotificationView(View):
"""通知"""
methods = ['GET']
decorators = [login_required]
def dispatch_request(self):
# 实现通过查询条件,来限制只请求给定时间戳之后产生的通知
since = request.args.get('since', 0.0, type=float)
notifications = current_user.notifications.filter(
Notification.timestamp > since).order_by(Notification.timestamp.asc())
return jsonify([{'name': n.name, 'data': n.get_data(), 'timestamp': n.timestamp} for n in notifications])
修改app/main/__init__.py
脚本,增加视图注册。
def register_views(bp):
# 在函数中引入可以避免循环依赖问题
from app.main.views import IndexView, ExploreView, UserInfoView, UserInfoEditView, FollowView, \
UnfollowView, SearchView, UserPopupView, SendMessageView, MessageView, NotificationView
# ......
# 查询通知视图
bp.add_url_rule('/notifications', view_func=NotificationView.as_view('notifications'))
4.6. 客户端轮询通知
修改app/templates/base.html
脚本,增加调用后台服务查询通知信息并实时更新页面的功能。
<script>
......
{% if current_user.is_authenticated %}
$(function () {
let since = 0;
// 定期调用回调函数,每10秒轮询一次
setInterval(function () {
$.ajax('{{ url_for('main.notifications') }}?since='+since).done(
function (notifications) {
for (let i = 0; i < notifications.length; i++) {
if (notifications[i].name === 'unread_message_count') {
// 根据名为unread_message_count的通知,更新消息计数徽标
set_message_count(notifications[i].data);
}
// 根据最后获取时间,限制只处理新的通知信息
since = notifications[i].timestamp;
}
}
);
}, 10000);
});
{% endif %}
</script>
4.7. 启动服务测试效果
客户john登录,存在一条未读私信。
用另一个浏览器,通过客户susan登录
通过客户susan向john发送私信。
john首页自动更新未读私信条数为2。
点击私信进入查看具体信息,同时私信徽标清零。