Welcome to my blog. You can post whatever you like!
The blog post is using markdown, and here is a simple guide. Markdown Guide
LOGIN TO POST A BLOG
Author: Satori
Posted: 2018-09-19 20:11:19
Category:
Django
Nuxt
SSR
技术
Views: 944
Comments: 1
## 一、背景
登录系统的设计可以说是网站的基本功能之一。之前在搭网站的时候用的是Django相关的一个包,django-registrition-redux实现注册功能和登录功能。但是自从我准备把网站重构后,这部分功能就需要自己去实现了。因此我准备自己写一套登录系统。(注册系统还没有实现)
## 二、总体方案
总体方案是请求任何api的时候都带上cookie,然后服务端解析cookie得到用户信息。
所以根据以上方案,只要解决以下几个问题就可以完成。
第一个是cookie是什么时候生成的,第二个是cookie是怎么生成的,解析的,第三个是cookie怎么销毁的。
## 三、具体实现
### 1. 用户登录
cookie一定是在用户完成用户名密码校验之后返回响应带给客户端的。首先我们需要校验用户的用户名密码是否正确。这部分我使用了django内置的User模块(这部分在`django.contrib.auth`中)。它提供一个authenticate方法校验用户是否验证成功。如果成功了,就构造一个cookie,在response一起返回给客户端,并设置max age过期时间。如果失败,则返回验证失败。这部分的代码如下所示。
```
class LoginView(BaseView):
def __init__(self):
super(LoginView, self).__init__()
self.http_method_names = ['post']
self.attrs = {
"user_name": {
"type": str,
"required": True,
},
"user_password": {
"type": str,
"required": True,
}
}
def get_clean_params(self, request):
try:
params = post_params(request)
clean_params = trans_params(params, self.attrs)
except Exception as e:
logging.error(e)
return None, e
return clean_params, None
def post(self, request, *args, **kwargs):
params, err = self.get_clean_params(request)
if err is not None:
resp = build_response(ReturnCode.ParamError, message=str(err))
return resp
name = params.get("user_name")
password = params.get("user_password")
user = authenticate(username=name, password=password)
if user is not None:
handler = UserHandler()
user_id = user.id
token = handler.get_token_str_for_current_user(user_id)
# return response
code = ReturnCode.Success
message = "Login Success"
data = {"user_name": user.username, "user_id": user.id}
resp = build_response(code, message=message, data=data)
# set cookie
max_age = settings.LOGIN_TOKEN_EXPIRE_DAYS * 86400
resp.set_cookie(USER_TOKEN_COOKIE_NAME, token, max_age=max_age)
return resp
else:
# the user fail the authentication
code = ReturnCode.AuthenticationFail
resp = build_response(code)
return resp
```
### 2. cookie的生成与存储
在验证成功之后, 需要生成cookie返回客户端,下次客户端好带上这个cookie来判断用户是谁。
传统的方式是使用一个session id来区分。但是我设计的前后分离的做法使我想到一些其他的做法。首先cookie不能直接暴露用户的信息。比如不能直接把用户的user id塞到cookie里面。否则其他用户可以轻易伪造这个cookie来做一些不可告人的事情。所以cookie需要加密。
我们先设计一下Cookie的结构,首先在服务端生成一个token,然后加上用户id,组成一个字典,然后将这个数据进行jwt编码。jwt编码采用HS256,这时候需要指定一个密钥来进行HS256加密,这个密钥放在django的settings文件比较合适。加密之后得到jwt字符串,就把这个字符串作为cookie值给客户端。
HS256加密的代码如下,非常简单
```
def jwt_encode(payload):
"""
Encode jwt by payload
:param payload: type: dict, the data to be encoded
:return: type: str, the result
"""
try:
key = settings.LOGIN_SECRET
encoded_bytes = jwt.encode(payload, key, algorithm='HS256')
encoded = bytes.decode(encoded_bytes)
except Exception:
logging.error("jwt encode error, for payload={}".format(payload))
return None
return encoded
```
同时还附上解码,后期从cookie识别用户也会用到。
```
def jwt_decode(encoded_data):
"""
Decode the data of encoded_data
:param encoded_data: str, the encoded string
:return: dict, the data
"""
try:
key = settings.LOGIN_SECRET
payload = jwt.decode(encoded_data, key)
except Exception:
logging.error("jwt decode error, for encoded_data={}".format(encoded_data))
return None
return payload
```
我们jwt encode的payload结构就是之前提到的字典,包括两部分,一个是用户id,一个是自动生成的token。先说token生成方法,首先先规定token长度,然后在一个字符集合里面随机取字符组成字符串。代码如下,很简单。
```
def generate_token():
"""
Generate a login token
:param
:return: token: str
"""
char_set = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890"
length = settings.LOGIN_TOKEN_LENGTH
token = ""
for _ in range(length):
ind = random.randrange(0, len(char_set))
token += char_set[ind]
return token
```
然后就是将这个token和用户id进行打包,然后进行jwt encode,最后发送给用户,这部分代码如下。
```
def get_jwt_token_from_login_info(self, user_id, user_token):
"""
Encode jwt token from login info
:param user_id: int
:param user_token: str
:param expire_time: float
:return:
"""
user_token_info = {}
user_token_info[USER_TOKEN_USER_ID_NAME] = user_id
user_token_info[USER_TOKEN_VALID_TOKEN_NAME] = user_token
user_token_str = jwt_encode(user_token_info)
return user_token_str
```
自此cookie生成已经结束了。但是还有一个工作没有做,那就是需要找一个地方把用户的token信息存下来,后期验证用户需要。我选择把这部分信息存到redis里面。并设置一个过期时间,用来避免用户能永久使用一个cookie登陆,这个后期验证用户会用到。这部嗯代码如下。
```
def set_login_info_to_redis(self, user_id, token, expire_time):
"""
Save token to redis
:param user_id: int
:param token: str
:param expire_time: float
:return:
"""
redis_token_info = {}
redis_token_info[REDIS_TOKEN_VALID_TOKEN_NAME] = token
redis_token_info[REDIS_TOKEN_EXPIRE_TIME_NAME] = expire_time
redis_token_info_str = json.dumps(redis_token_info)
redis_hset(USER_TOKEN_HSET_NAME, user_id, redis_token_info_str)
```
### 3. 用户验证
前面第二部分已经提到用户验证的前期工作了,现在把用户验证这块进行详细说明。用户在登陆得到cookie后,之后发送的ajax请求都会带上这个cookie,解析这个cookie就能得知是哪个用户。
首先获得用户的cookie,然后对这个cookie进行jwt decode,从cookie字串得到一个字典,包含用户id还有cookie。当然jwt decode可能失败,因为用户那个字串是可能随便瞎搞的,相当于反作弊,直接就认为用户未登录。得到用户token以及id之后,去redis找那个用户当时存下的token,判断两个token是否一样,一样就认为是真的这个用户登陆,否则就认为作弊,也会认为用户未登录。如果token是匹配的,然后再从redis取得过期时间,如果当前时间超过过期时间,认为用户登陆过期了,需要重新登陆。
从redis取用户登陆信息代码如下。
```
def get_login_info_from_redis(self, user_id):
"""
Get a user's login info from redis
:param user_id: int
:return: {token, expire_time}
"""
redis_token_info_byte = redis_hget(USER_TOKEN_HSET_NAME, user_id)
try:
redis_token_info_str = bytes.decode(redis_token_info_byte)
redis_token_info = json.loads(redis_token_info_str)
redis_token = redis_token_info[REDIS_TOKEN_VALID_TOKEN_NAME]
expire_time = redis_token_info[REDIS_TOKEN_EXPIRE_TIME_NAME]
return redis_token, expire_time
except Exception:
return None, None
```
判断用户是否登陆的代码如下。
```
def _check_user_login(self, user_id, user_token):
"""
Check the user with user_id to be login according to the token
:param user_id: int
:param user_token: dict
:return: bool
"""
# check the valid token
redis_token, expire_time = self.user_service.get_login_info_from_redis(user_id)
if redis_token is None or expire_time is None:
return False
if redis_token != user_token:
return False
current_timestamp = get_current_timestamp()
if current_timestamp > expire_time:
return False
return True
```
## 四、集成BaseView
现在已经有了这些工具之后,可以把这部分集成进公共的baseview里面,在dispatch的时候,调用一个公共的方法获得当前用户。入口是用户的cookie,返回就是当前的用户。这部分代码如下。
```
def get_current_user_from_token_str(self, user_token_str):
"""
Get the current user from the request
:param request: HttpRequest, the request
:return: User, the user
"""
user_id, user_token = self.user_service.get_login_info_from_jwt_token(user_token_str)
if user_id is None or user_token is None:
return None
if self._check_user_login(user_id, user_token):
user = self.user_service.get_user_by_id(user_id)
return user
else:
return None
```
总体来说实现方案就是这样,过程还是比较繁琐,但是反作弊必须要加的,不然就可能冒充用户。只要用户把cookie保护好,就不会造成问题。
Read More
Author: Satori
Posted: 2018-09-19 20:10:24
Category:
Django
Nuxt
技术
Views: 2194
Comments: 0
## 一、背景
在我搭建这个网站到后期的时候,明显感觉前端的代码维护难度太高。因为Django自带模板渲染是基于Jinja渲染的,最大的特点是模板中的数据从服务端一次取出,然后再渲染页面。而数据改变需要手动去维护依赖该数据的html段落。而且研发到后期发现很多前端模块都是可以抽象成一个组件,但是通过模板的include会带来各种问题。在有机会接触到一些前端的mvvm框架后我就起手打算改造网站,实现前后端分离。
## 二、整体思路
前端页面部分使用Nuxt,它是一个基于Vue的Server-Side-Render的解决方案,自动集成了vue, vue-router, vuex等等组件,能让前端的开发效率明显增高。同时后端使用Django作为服务的提供者。在服务端渲染的时候,如果需要异步数据,则先发请求给Django,然后的得到数据后再渲染成页面返回给浏览器。之后客户端如果有任何异步数据,都通过ajax的方式向Django发送请求来获得数据。
## 三、实现细节
1. 请求的发送
前端使用axios来发送请求。我的做法是封装一个api的基础client,这个client被各个业务线的其他client继承。这个基础的client主要负责封装请求的发送,以及异常的处理。
```js
import axios from 'axios';
class ApiClient {
get_axios_client () {
let axios_client = axios.create(this.client_config);
axios_client.interceptors.response.use(function (response) {
if (response.status !== 200) {
return Promise.reject(response.data);
}
return response.data;
}, function (error) {
error = error.response || error;
console.log('error', error);
if (!error.status) {
return Promise.reject({
code: 999,
message: 'Server Error!',
});
}
var message = '';
if (error.status >= 400 && error.status < 500) {
message = 'request error, message: ' + error.status + ': ' + error.statusText + '. ' + error.data.message;
} else if (error.status === 502 || error.status === 504) {
message = 'The server is busy or restarting';
} else {
message = 'Internal Server Error';
}
return Promise.reject({
code: 999,
message: message,
});
});
return axios_client;
}
constructor (config) {
this.client_config = config;
this.client = this.get_axios_client();
}
get (url, params, config) {
let mergedConfig = Object.assign({}, config, this.client_config, {url: url, params: params, method: 'get'});
return this.request(mergedConfig);
}
post (url, data, config) {
let mergedConfig = Object.assign({}, config, this.client_config, {url: url, data: data, method: 'post'});
return this.request(mergedConfig);
}
request (config) {
return this.client.request(config)
.then(response => {
return response;
})
.catch(response => {
console.log('Error Occurs. Response: ' + response);
return Promise.reject(response);
});
}
}
export default ApiClient;
```
这里主要封装了两个方法,一个是get方法,一个是post方法,都返回一个promise,并且增加一个拦截器,拦截response,在返回正常的情况下把data部分返回,在有问题的时候返回reject,并给出错误码和信息。
同时config里面可以设置请求一些参数,比如超时时间等等。
接着封装一个带有默认配置的BaseClient,设置了请求的基准url以及超时时间,代码如下。
```
import ApiClient from '@/utils/api_client';
const client_config = {
baseURL: '/api/',
timeout: 20000,
};
class BaseApiClient {
constructor (sub_url) {
const baseURL = client_config.baseURL + sub_url;
this.client = new ApiClient({...client_config, baseURL});
}
}
export default BaseApiClient;
```
之后所有业务都可以继承这个BaseClient即可实现请求的发送。
2. 请求的处理
请求处理这边就使用Django,通过cookie来识别用户,这部分的技术细节会在接下来的文章里提到。然后Django把请求处理完之后返回给前端数据,最后前端拿到数据做渲染。无论是服务器渲染还是客户端发的请求,都能够处理。
3. 前后端连接
前面都在讲前端如何发请求,后端怎么处理请求上面,最关键的地方还没有讲到,那就是怎么把前后端连接起来。
按照我现在的方案,node的端口在3000,Django的端口在8000(后期当然可以使用linux socket方式,不通过http来进行通信),那么从node的请求都会走3000这个端口,那Django怎么接收这个请求呢?答案就是nginx。nginx可以作为一个强大的反向代理服务器,我们可以这么操作。
先让nginx去监听某一个端口(比如80),然后根据url前缀进行请求分发。如果是前端的请求(页面请求),那么就代理到node开启的3000端口,其他的都丢给Django。
区分前端路由以及后端请求的方法很简单。对于api请求,我都在路由前面加一个`/api`,这个所有以这个请求开头的都是后端请求,其他都是前端请求。这样做也避免了跨域的问题。不过我这里想使用Django的Admin界面进行数据库的一些管理。因此我需要特别匹配一下`admin`这个路由到Django,同时Admin页面也会依赖一些静态资源,路由以`/static`开头,因此也需要匹配路由到Django。因此,nginx的配置文件大概是下面这样。
```
server {
listen 80;
server_name 127.0.0.1;
charset utf-8;
location /admin {
proxy_pass http://127.0.0.1:8000;
}
location /static {
alias /home/satori/new_mysite/new_mysite/backend/static; # This should be changed to your own static file folder
}
location /api {
proxy_pass http://127.0.0.1:8000;
}
location / {
proxy_pass http://127.0.0.1:3000;
}
}
```
这样配置所有的请求都能做正确的分发,因此前后端请求就连接上了,也就能通信了。
Read More
Author: Satori
Posted: 2018-02-14 21:32:57
Category:
Django
HTML
JavaScript
技术
Views: 4401
Comments: 9
## 一、背景
做国际化是非常重要的事情。之前网站做出来的时候母亲表示全是英语看不懂。当时我就被触动了一下,虽然现阶段网站根本没有什么流量,但是大部分用户还是国内的,因此不做中文化翻译其他人很难去使用,而且做出国际化之后真的是让自己的网站一下子高大上起来。因此我决定还是做一做国际化。
## 二、实现方法
国际化的困难程度在我做之前就有估计,不过真实做的时候发现真的比较复杂。复杂不是在于实现难度,而是在细碎程度。因为**HTML文本**,**Javascript代码**,**Django 模型表单标签**都需要进行汉化工作。要做到疏而不漏很困难。还有就是歧义问题,同一个词在不同地方意思不同,还需要自己去做区分。
具体的实现方法参考[官方文档][1],主题思路就是通过在需要翻译的地方进行标注,然后使用Django命令将要翻译的地方生成一个翻译文件,接着补全翻译文件,最后再用Django命令生成翻译的二进制文件即可。
另外一点是需要切换语言,这可以用内置的方法往某个地址post数据即可更改。
## 三、具体步骤
### 1. 修改setting.py使之支持国际化
在`MIDDLEWARE`中加一个`'django.middleware.locale.LocaleMiddleware',`,这个中间件负责国际化的语言转化等等。
然后需要加一个`LANGUAGE_CODE = 'zh-hans'`,默认使用中文,再添加语言种类,这里我就添加英语和中文,如下。
```
LANGUAGES = [
('en', _('English')),
('zh-hans', _('Chinese')),
]
```
最后需要定义翻译的文件放在哪里,我们直接放在locale文件夹中,代码如下。
```
LOCALE_PATHS = (
os.path.join(BASE_DIR, 'locale'),
)
```
### 2. HTML文本的国际化
HTML文本的国际化相对比较简单,有两种方式来标记需要翻译的部分,第一种是直接将要翻译的文本放到`{% trans "xxx" %}`中,比如,`{% trans "language" %}`。
第二种方式适合大段文本的翻译,将要翻译的内容包含在`{% blocktrans %}`和`{% endblocktrans %}`中,比如。
```
{% blocktrans %}
Hello everyone
{% endblocktrans %}
```
但是需要注意的是当要翻译的文本中有变量之类的时候,只能用第二种方式,而且需要给变量一个别名,然后在翻译的文本中去引用,比如有原文是
```
title: {{ blog.title }}, author: {{ blog.author }}
```
需要声明一个变量储存,代码如下。
```
{% blocktrans with title=blog.title author=blog.author %}
title: {{ title }}, author: {{ author }}
{% endblocktrans %}
```
多个变量就往后加就行了,如上例。
其次遇到url之类的也需要提前存储,比如原文是
```
<a href="{% url 'auth_login' %}">Login</a>
```
需要有一个别名代替url,代码如下。
```
{% url 'auth_login' as login_url %}
{% blocktrans %}
<a href="{{ login_url }}">Login</a>
{% endblocktrans %}
```
最后需要注意歧义问题,对于template的歧义,加上context字段,例子如下。
```
{% blocktrans context "month" %}
He will leave on May 2nd.
{% endblocktrans %}
```
### 3. Django源文件的国际化
这部分国际化也比较简单,使用内置的translation中的gettext一系列方法即可获取文本。首先在python文件头部进行声明ugettext以及ugettext_lazy等方法,然后在需要翻译的地方用该方法包装即可。但是需要注意的是`ugettext_lazy`方法只能用于Model以及Form字段的翻译,不能直接作为HttpResponse的返回,这时候需要`ugettext`方法。例子如下。
```
from django.utils.translation import ugettext as _
def index(request):
context = {}
blog_list = Blog.objects.all().order_by('-publish_time')
page = request.GET.get('page')
blogs, page_list = BlogService.get_paginated_items(blog_list, page)
context['blogs'] = blogs
context['page_list'] = page_list
context['title'] = _("Blogs")
return render(request, "blogs/index.html", context)
```
这样就能返回多语言结果了。对于`ugettext_lazy`方法的例子如下。
```
from django.utils.translation import ugettext_lazy as _
class ProfileForm(ModelForm):
class Meta:
model = Profile
fields = ['gender', 'birthday', 'mobile', 'residence', 'website', 'microblog', 'qq', 'wechat', 'introduction']
labels = {
'gender': _('gender'),
'birthday': _('birthday'),
'mobile': _('mobile'),
'residence': _('residence'),
'website': _('website'),
'microblog': _('microblog'),
'qq': _('QQ'),
'wechat': _('wechat'),
'introduction': _('introduction'),
}
```
对于有上下文才能翻译的词句,需要用`pgettext`方法,例子如下。
```
from django.utils.translation import pgettext
month = pgettext("month name", "May")
```
这表示"May"这个词的上下文是"month",也就是月份,之后就好分辩怎么翻译,同样也不会和其他may文字冲突。
最后再把template tag中返回给前端的数据做国际化即可。
### 4. JavaScript的国际化
Javascript的国际化本身比较麻烦,因为它不能直接读翻译文件,也没有直接的翻译函数。还好Django帮我们做了这些事情。首先在根urls.py中引入i18n的url,代码如下。
```
from django.views.i18n import JavaScriptCatalog
# other code
urlpatterns += [
url(r'^jsi18n/$', JavaScriptCatalog.as_view(), name='javascript-catalog'),
]
```
之后只需在引入javascript的html中引入django内置的一个javascript文件,将它引入到其他javascript文件之前即可。代码如下。
```
<script type="text/javascript" src="{% url 'javascript-catalog' %}"></script>
```
然后我们就可以在之后的javascript代码直接使用和之前类似python中的`ugettext`,`ugettext_lazy`, `pgettext`函数了。业务代码如下。
```
document.write(ugettext('this is to be translated'));
```
### 5. 生成翻译文件以及编译翻译文件
在前面的步骤当中已经将python代码和HTML代码和Javascript代码中需要翻译的地方全部标记了,下一步就是生成翻译文件并进行翻译。首先需要建一个locale文件夹,直接`mkdir locale`即可。下面执行得到翻译文件的命令。加上`-l`标签表示需要翻译的目标语言,比如得到中文翻译文件。
```
django-admin makemessages -l zh-Hans
```
对于javascript文件还需要单独执行一下命令。
```
django-admin makemessages -d djangojs -l zh_Hans
```
执行之后,就会得到两个翻译文件,`django.po`和`djangojs.po`。每个文件的条目大致如下。
```
#: accounts/forms.py:11
msgid "gender"
msgstr "性别"
```
第一行注释是出现的位置,第二行是msgid,也就是原文,这个不能修改,第三行是msgstr,就是需要翻译的文本,如果有上下文,就会有`msgctxt`字段,这表示这段文字的上下文。当全部翻译完成之后,再执行命令得到翻译后的二进制文件。命令如下。
```
django-admin compilemessages
```
然后就会得到两个.mo文件,这就是翻译后的文件 。
### 6. 切换语言
对于语言的切换,需要创建一个表单,然后向Django内置的setlang的url发带有`language`字段的数据即可,采用
POST方法。示例代码如下。
```
<div class="lang-div dropdown-menu" role="menu">
{% get_current_language as LANGUAGE_CODE %}
{% get_available_languages as LANGUAGES %}
{% get_language_info_list for LANGUAGES as languages %}
{% for language in languages %}
<a class="lang-item btn {% if language.code == LANGUAGE_CODE %}btn-primary{% else %}btn-default{% endif %}" onclick="$('#lang_type').val('{{ language.code }}'); $('#lang_form').submit();">
{{ language.name_local }}
</a>
{% endfor %}
</div>
<form action="{% url 'set_language' %}" method="post" id="lang_form">{% csrf_token %}
<input name="language" id="lang_type" type="hidden">
<input name="next" type="hidden" value="{{ redirect_to }}" />
</form>
```
这种方法通过按钮触发向input元素传入值,再发送即可实现语言切换。
## 四、总结
总之做国际化还是非常繁琐了,需要各种找,各种翻译,需要几天才能真正开发完。
[1]: https://docs.djangoproject.com/en/1.10/topics/i18n/translation/
Read More
Author: Satori
Posted: 2018-02-11 22:33:02
Category:
CSS
Django
HTML
JavaScript
技术
Views: 2011
Comments: 0
## 一、开启关闭方法
在非文本框区域依次按键盘 **`左`** **`下`** **`右`** **`上`** 即可开启。开启后,再按同样按键退出。
## 二、背景
这次的设计主要想法还是来自[忧郁的弟弟][1]网站的设计。他的设计方法是修改用户右键的点击来实现和谐模式的开启。然后和谐模式开启之后,鼠标的范围就会出现一个圈,和谐图片就只显示在圈内,然后通过移动鼠标来实现看图片的不同部分,然后再按鼠标即可退出。
## 三、实现方法
这次的实现方法还是比较简单。首先后端传和谐图片相关的html代码,中间就包含和谐图片链接。具体实现方案还是按照Django的template tag方式实现,跟之前的背景图片相似。然后前端监听触发和谐模式开启按键,然后再监听用户鼠标移动事件来调整圈的位置以及圈内图片的位置。
## 四、具体步骤
1. 后端返回和谐模块html
这个部分依然使用template tag实现,后端直接返回html代码。先调用系统接口获取和谐图片的个数以及种类,任意挑一个,把它的url塞到html里img的src中即可。这部分代码拷贝之前的,具体如下。
```
@register.simple_tag
def load_hidden_background():
image_show_count = 1
curdir = os.path.join(settings.MEDIA_ROOT, "background", "H")
if os.path.exists(curdir):
items = os.listdir(curdir)
if len(items) >= image_show_count:
selected = [items[i] for i in random.sample(range(len(items)), image_show_count)]
urls = [settings.MEDIA_URL + "background/H/{}".format(x) for x in selected]
return mark_safe("""
<div class="hidden-bg">
<div class="circle" style="background: url({}) no-repeat"></div>
</div>
""".format(*urls))
```
然后之后的template只需在中间加一个tag就行,
```
{% load_hidden_background %}
```
这里还是聊一下返回的html,先有一个大的div叫hidden-bg,它填充整个页面,并置于页面最顶层,背景是灰色,目的是让之后的图片更醒目,初始状态不显示。hidden-div中嵌套新的div叫circle,这个就是圈,图片就在圈里面显示,图片显示方式是通过backgroud属性设置。圈通过border-radius使方块变圈。
同时附上这两个div的css。
```
@media (min-width: 767px)
{
.hidden-bg {
cursor: url(/static/mysite/images/mouse_none.cur), url(/static/mysite/images/mouse_none.cur), auto;
}
}
.circle {
width: 200px;
height: 200px;
position: fixed;
border-radius: 100%;
box-shadow: 0 0 0 3px rgba(255,255,255,0.85), 0 0 7px 7px rgba(0,0,0,0.25), inset 0 0 40px 2px rgba(0,0,0,0.25);
}
.hidden-bg {
position: fixed;
width: 100%;
height: 100%;
max-width: 100%;
max-height: 100%;
left: 0px;
top: 0px;
display: none;
z-index: 1000001;
opacity: 1;
background: rgba(0, 0, 0, 0.8);
}
```
2. 前端监听按键触发
这个部分和之前讲过的完全一样,绑定一个按键组合即可。
```
key_listener.sequence_combo("left down right up", toggle_hidden_background, true);
```
3. 前端处理鼠标移动
这个部分是核心部分。要明确鼠标移动后要干两件事情,第一个是把圈的位置改动,第二个是确定图片的偏移。
圈的位置怎么动呢,应该是鼠标在哪里,圈的圆心就应该在哪里,同时鼠标移动我们能获得当前鼠标的位置。由于块的固定定位的目标在左上角,因此整个块的左偏移`left`属性应该是圆心位置减去半径长度,同理上偏移`top`也是一样。
图片怎么偏移呢,物理上讲运动是相对的。我们应该考虑,鼠标往左动相当于图片往右动,同理鼠标往下动就是图片往上动。由于要模拟看图片的不同位置,因此鼠标移动后的位置和图片的位置应该是相反数。这样,图片也能移动到正确的位置。
圈移动的位置和图片移动的位置配合好之后,就真的模拟出以孔窥图的效果了。这部分的代码如下。
```
function toggle_hidden_background() {
if ($(".hidden-bg").is(":visible"))
{
$(".hidden-bg").hide();
$(".hidden-bg").unbind('mousemove');
}
else
{
$(".hidden-bg").bind('mousemove' ,function(e) {
$(".circle").css({'left': (e.clientX - 100) + 'px'});
$(".circle").css({'top': (e.clientY - 100) + 'px'});
$(".circle").css({'background-position': -(e.clientX - 100) + 'px ' + -(e.clientY - 100) + 'px'});
});
$(".hidden-bg").show();
}
}
```
不过弟弟的网站实现和我不太一样,具体的逻辑没有摸透,它图片的偏移和圈的偏移还不完全是相反数,有一定偏差,不过我这种方案效果还可以,也就不深究了。
[1]: https://mygalgame.com
Read More
Author: Satori
Posted: 2017-12-10 14:53:37
Category:
Django
Javascript
技术
Views: 997
Comments: 0
## 一、背景
在微博以及微信等现代社交媒体上,@功能已经非常常见。在任意地方只需打一个@,后面接想要@的人,就能很轻松的让对方知道这条消息。
## 二、总体思路
虽然这个功能看起来非常简单,但是还是有不少问题需要解决。首先是原有的消息系统不能支撑这个业务。最大的问题就是之前对于消息的类型判断和`content_type`直接挂钩。如果是博客订阅,`content_type`就是博客,如果是消息回复,那`content_type`就是评论。但是,当要加入@功能的时候,这就会出问题。@人的地方有可能是博客,也有可能是评论。因此仅仅通过`content_type`是不足以支撑这个业务。
在多次考虑之后,我决定在原有Notification的基础上增加`type`字段,用于判断消息类型。如果是订阅,就是`Subscribe`,如果是评论,就是`Reply`,如果是@,就是`At`。这样根据`type`以及`content_type`就能确定消息的类型以及消息源。改进后的Notification如下。
```
class Notification(models.Model):
NOTIFICATION_TYPES = (
('Reply', 'Reply'),
('Subscribe', 'Subscribe'),
('At', 'At'),
)
unread = models.BooleanField(default=True)
type = models.CharField(max_length=max([len(x[0]) for x in NOTIFICATION_TYPES]), choices=NOTIFICATION_TYPES)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField()
content_object = GenericForeignKey('content_type', 'object_id')
def __str__(self):
return str(self.type) + " from " + str(self.content_object)
```
有了Notification之后,就是在博客以及评论发表的时候,这则匹配@,然后提取@的用户,添加对应的Notification,这样就能实现@功能了。
## 三、具体实现
### 1. Notification Service的实现
就像Blog Service一样,添加一个Notification Service来处理和消息相关的操作,将之前的订阅以及评论的消息添加重写,主要是把type字段填上,同理在查询的时候,将type作为条件进行筛选,这部分代码就不展示了。
关键的部分在于@部分。这个主要是正则匹配,将发表的文字的@的用户全部抽离出来,然后添加Notification,当然没有的用户就会被过滤掉。代码如下。
```
@classmethod
def create_at_notification(cls, instance, text):
name_list = re.findall('@([^\s]+)', text)
res = []
for name in name_list:
user = User.objects.filter(username=name)
if len(user) > 0:
user = user[0]
notification = Notification.objects.create(type="At", content_object=instance, user=user)
res.append(notification)
return res
```
接着是定义两个函数得到由博客来的@消息以及由评论来的@消息,筛选条件就是type是at,用户是请求用户,来源是博客或者是评论,代码如下。
```
@classmethod
def get_at_blog_notifications(cls, user):
return user.notification_set.filter(type="At").filter(content_type__model='blog')
@classmethod
def get_at_comment_notifications(cls, user):
return user.notification_set.filter(type="At").filter(content_type__model='comment')
```
最后再补上未读的@消息数,也很简单了,就是筛选条件是未读,然后类型是At,代码如下。
```
@classmethod
def get_unread_at_count(cls, user):
res = user.notification_set.filter(unread=True).filter(type="At")
return len(res)
```
### 2. @功能使用Notification Service
这部分主要是要在博客或者评论发表的时候,制造Notification。这个主要是通过信号连接,在发出的时候进行触发。代码如下。
```
def post_save_comment_receiver(sender, instance, created, *args, **kwargs):
if created:
NotificationService.create_reply_notification(instance)
NotificationService.create_at_notification(instance, instance.text)
def post_save_blog_receiver(sender, instance, created, *args, **kwargs):
if created:
NotificationService.create_subscribe_notification(instance)
NotificationService.create_at_notification(instance, instance.text)
```
可以看到只需在原有基础上添加`create_at_notification`调用即可。
### 3. 后端传Notification对象
这部分主要是根据用户,将由评论和博客得到的@消息进行返回,并添加属性,让前端能识别是评论还是博客得到的消息,同时标注这条消息的id,让前端知道消息的id,并将消息按照时间排列,然后分页,最后返回,代码如下。
```
@login_required
def get_at_content(request):
if request.is_ajax():
user_id = request.GET.get("user_id")
user = get_object_or_404(User, id=user_id)
at_blog_notification_list = NotificationService.get_at_blog_notifications(user)
at_comment_notification_list = NotificationService.get_at_comment_notifications(user)
at_notifications = []
for ind in range(len(at_blog_notification_list)):
at_blog_notification_list[ind].src = "blog"
at_notifications.append(at_blog_notification_list[ind])
for ind in range(len(at_comment_notification_list)):
at_comment_notification_list[ind].src = "comment"
at_notifications.append(at_comment_notification_list[ind])
at_list = list()
# get obj list
for notification in at_notifications:
obj = notification.content_object
# add related notification field
obj.related_notification_id = notification.id
obj.unread = notification.unread
obj.src = notification.src
at_list.append(obj)
at_list = sorted(at_list, key=lambda x: x.publish_time, reverse=True)
page = request.GET.get('page')
items, page_list = BlogService.get_paginated_items(at_list, page)
context = {}
context['items'] = items
context['page_list'] = page_list
context['origin_ajax_url'] = request.get_full_path()
return render(request, "accounts/at_received.html", context)
else:
raise Http404("Page not found!")
```
### 4. 前端返回渲染后结果
从后端得到一系列消息之后,将它进行渲染,并根据是博客还是由评论得到的At消息进行不同的渲染,添加分页等内容,并添加按键监听,使得按下详情按钮就标记已读。还需处理分页之后不同页面的ajax请求。
```
<!--List a list of blogs-->
{% load static %}
{% load blog_tags %}
{% for item in items %}
<div class="blog-div well aos-init aos-animate" data-aos="flip-up">
{% if item.unread %}
<span class="label label-danger brand-new">New</span>
{% endif %}
<p class="lead"><b>{{ item.author }}</b>: {{ item.text|linebreaksbr|truncatewords:30 }}</p>
<p><span class="glyphicon glyphicon-time"></span> Posted on {{ item.publish_time }}</p>
{% if item.src == "comment" %}
<pre><a href="{% url 'blogs:archive' item.blog.id %}" target="_blank"><b><i>{{ item.blog.title }}</i></b></a> <span style="color:grey">written by {{ item.blog.author }} On {{ item.blog.publish_time }}</span></pre>
<a class="btn btn-primary read-at" id="notification_{{ item.related_notification_id }}" href="{% url 'blogs:archive' item.blog.id %}#comment_{{ item.id }}" target="_blank">Get Detail <span class="glyphicon glyphicon-chevron-right"></span></a>
{% elif item.src == "blog" %}
<pre><a href="{% url 'blogs:archive' item.id %}" target="_blank"><b><i>{{ item.title }}</i></b></a> <span style="color:grey">written by {{ item.author }} On {{ item.publish_time }}</span></pre>
<a class="btn btn-primary read-at" id="notification_{{ item.related_notification_id }}" href="{% url 'blogs:archive' item.id %}" target="_blank">Get Detail <span class="glyphicon glyphicon-chevron-right"></span></a>
{% endif %}
</div>
{% endfor %}
<!-- Pager -->
<ul class="pager">
{% if items.has_previous %}
<a class="btn btn-default page-link" id="prev-btn" href="{% url_replace page=items.previous_page_number %}"><</a>
{% endif %}
{% for i in page_list %}
{% if i != '...' %}
{% if i != items.number %}
<a class="btn btn-default page-link" href="{% url_replace page=i %}">{{ i }}</a>
{% else %}
<a class="btn btn-info page-link" href="{% url_replace page=i %}">{{ i }}</a>
{% endif %}
{% else %}
<span>...</span>
{% endif %}
{% endfor %}
{% if items.has_next %}
<a class="btn btn-default page-link" id="next-btn" href="{% url_replace page=items.next_page_number %}">></a>
{% endif %}
</ul>
<script>
$(".page-link").click(function(){
event.preventDefault();
var ajax_url = $(this).attr("href");
$.ajax({
type:"GET",
url: ajax_url,
success: function(result) {
$('#at-received').html(result);
},
error: function(jqXHR, exception) {
$('#at-received').html(exception);
}
});
});
$(".read-at").bind('click', function(event){
var notification = $(this).attr("id");
var notification_id = notification.split("_")[1];
$.ajax({
type: "GET",
url: "{% url 'read_notification' %}",
data: {"notification_id": notification_id},
success: function(result) {
ajax_unread_at_count();
get_notification_count();
ajax_at_received();
$(this).click();
}
});
});
</script>
```
### 5. 前端用户主页面的@消息JavaScript
这部分主要是在DashBoard页面上处理点击`@me`后前端的渲染,以及得到未读@消息个数的显示,都使用ajax请求,主要函数实现如下。
```
function ajax_unread_at_count() {
$.ajax({
type: "GET",
url: "{% url 'unread_at_count' %}",
success: function(result) {
var count = result['count'];
if (count > 0)
{
$('#unread-ats').html(count);
}
else
{
$('#unread-ats').html("");
}
}
});
}
function ajax_at_received() {
$.ajax({
type: "GET",
url: "{% url 'user_at_received' %}",
data: {"user_id": {{ request.user.id }} },
success: function(result) {
$('#at-received').html(result);
},
error: function(jqXHR, exception) {
$('#at-received').html(exception);
}
});
}
```
## 四、总结
这样@功能以及消息系统的更新也就讲完了。不得不感叹业务逻辑的重写和新功能的添加有分不开的关系,如果之前设计的时候多想想抽象的接口和逻辑,后期添加功能也会更容易。
Read More
Author: Satori
Posted: 2017-12-10 11:22:13
Category:
CSS
Django
HTML
JavaScript
技术
Views: 1395
Comments: 0
## 一、背景
在博客和评论里面插入各种表情是非常常见的,虽然网上有各种各样的包,但是普遍都不能自主添加表情以及动态添加分组。因此我需要自己实现一个表情系统。
表情系统的实现还是比较复杂的,难点在于我想要实现一个组件一样的表情栏,在网页相应的地方只需要简单的引用这个组件就能实现在这个组件所在的表单里面的输入框插入表情。
## 二、总体思路
### 1. 总体流程
实现表情功能主要分为两步,第一步是实现一个表情组件,按这个组件的某一个表情能插入一段对应该表情的文字,第二步是前端将这个表情文字进行转化,转换成表情图片。
### 2. 设计方法
对于表情组件而言,需要让它处在一个表单之中,而且这个表单有且仅有一个`textarea`元素,当表情组件某个表情被点击的时候,自动向这个`textarea`光标处插入对应该表情的文字。
对于前端转化而言,需要正则提取表情文字,并将它替换成对应的`img`标签图片。
## 三、具体实现
### 1. 表情组件的实现
使用纯后端的方法来实现。首先需要考虑表情的动态添加以及表情组的动态添加问题,因此需要把一类表情放到表情组文件夹里面,然后在每个文件夹添加对应的表情。后端只需要读取对应目录的文件就能动态地得到表情组以及每个表情。
而要实现处处能引用这个组件,我选择的是使用`template tag`来返回组件的html代码。这样每次只需要在其他template引用这个tag就可以得到表情组件了。
接着只需要拼接好html代码,将读到的文件及文件夹名字进行解析,就可以得到表情组名以及每个表情组下每个表情的名字。然后封装成`img`标签,同时为了方便之后插入表情文字,将表情所属的组名以及表情的名字装入`img`标签的数据段里面。
具体代码如下。
```
@register.simple_tag
def emoticon_bar():
cur_dir = os.path.join(settings.MEDIA_ROOT, "emoticon")
emoticon_set_names = [x for x in os.listdir(cur_dir) if os.path.isdir(os.path.join(cur_dir, x))]
# sort by the ascii code
emoticon_set_names = sorted(emoticon_set_names)
tab_list = ""
img_list = ""
for set_ind, emoticon_set_name in enumerate(emoticon_set_names):
emoticon_dir = os.path.join(cur_dir, emoticon_set_name)
files = [x for x in os.listdir(emoticon_dir) if os.path.isfile(os.path.join(emoticon_dir, x))]
# sort the file by ascii code
files = sorted(files)
urls = [settings.MEDIA_URL + "emoticon/{}/{}".format(emoticon_set_name, x) for x in files]
cur_img = ""
for ind, url in enumerate(urls):
cur_img += """<img src="{}" data-filename={}>""".format(url, files[ind])
cur_set = """
<div class="emoticon-set" data-emoticon_set_name="{emoticon_set_name}">
{img_str}
</div>
""".format(emoticon_set_name=emoticon_set_name, img_str=cur_img)
img_list += cur_set
tab_list += """
<a class="btn btn-default" data-ind={set_ind}>{set_name}</a>
""".format(set_ind=set_ind, set_name=emoticon_set_name)
string = """
<p>Emoticons:</p>
<div class="emoticon-div">
<div class="emoticon-tab">
{tab_list}
</div>
<div class="emoticon-img">
{img_list}
</div>
</div>
""".format(tab_list=tab_list, img_list=img_list)
return mark_safe(string)
```
这样在后面的模板里面,只要引用`{% emoticon_bar %}`就能得到表情组件了。
但只靠后端也不能实现组件的功能。因为前端需要控制按下表情后向`textarea`插入相应表情文字的工作,这需要用js实现,定义一个函数,传入一个textarea的jquery对象,再传入需要插入的文字。实现方法主要是先得到`textarea`光标的起始位置和终止位置,将这个位置的所有字符替换为text即可,同时还需要还原插入后光标的位置,只需要让新光标移动text的长度即可。代码如下。
```
function make_text(textarea, text) {
var s = textarea.get(0).selectionStart;
var e = textarea.get(0).selectionEnd;
var val = textarea.get(0).value;
textarea.get(0).value = val.substring(0, s) + text + val.substring(e);
textarea.get(0).selectionStart = e + text.length;
textarea.get(0).selectionEnd = e + text.length;
textarea.focus();
}
```
接下来是定义每个表情被按下的操作,主要是找到是哪个图片被按下来确定插入的表情文字,以及找到需要插入的`textarea`的位置。找插入文字可以利用之前后端传过来的`emoticon-set`的`data-emoticon_set_name`属性来确定类别,再用该图片的`data-filename`属性得到名称。再进行拼接。我使用的表情文字定义是`[[set_name空格file_name]]`,原因是尽量避免与markdown的`[][]`的冲突。然后是找到`textarea`的位置,具体做法是找到这个表情组件所属的form,再在form的后代里面找`textarea`。最后调用`make_text`函数即可实现文字的添加。代码如下。
```
$(".emoticon-set img").click(function(){
var set_name = $(this).parent().attr("data-emoticon_set_name");
var file_name = $(this).attr("data-filename");
var string = "[[" + set_name + " " + file_name + "]]";
// find the nearest ``textarea`` above the current bar
var textarea = $(this).closest("form").find("textarea");
make_text(textarea, string);
});
```
至于控制组件的其他点击,比如单击不同组,表情的切换控制不是非常核心的代码,简要思路就是使用一个data标签来确定哪个组被选定了,判断是第一次点击标签还是标签跳转还是点击同样标签,这三种情况对应处理一下即可。
关于css美化简要说要让用户快速得知自己正在点击的按钮,让鼠标悬停的时候将图片的不透明度改变,并调整大小,代码如下。
```
.emoticon-set img {
cursor: pointer;
transition: transform 0.5s;
opacity: 0.6;
}
.emoticon-set img:hover {
transform: scale(1.2, 1.2) rotate(18deg);
opacity: 1;
}
```
### 2. 表情转换的实现
这一部分相对就简单很多了,其实只需要将所有的字串正则替换为img标签,代码如下。
```
html = html.replace(/\[\[([^ \[\]]+?) ([^ \[\]]+?)]]/g, '<img src="/media/emoticon/$1/$2">');
```
同时注意为了跟原有markdown协同工作,需要抽象出一个统一的render接口,首先渲染markdown,然后渲染表情,函数如下。
```
function render_content(text)
{
// first default markdown render
var renderer = new marked.Renderer();
renderer.code = function (code, language) {
return '<pre class="prettyprint"><code class="hljs">' + hljs.highlightAuto(code).value +
'</code></pre>';
};
var html = marked(text, { renderer: renderer });
// render the emoji
html = html.replace(/\[\[([^ \[\]]+?) ([^ \[\]]+?)]]/g, '<img src="/media/emoticon/$1/$2">');
return html;
}
```
## 三、总结
表情这个功能总体实现难度还是不小的,要做到鲁棒性以及可重用性才是真正的难点。这种设计缺陷在于需要让组件置于一个form内,而且form内还需要只有一个textarea才能正常工作,因此还需要考虑一下更好的方法。
Read More