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

登录系统的设计

Satori

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

使用Django和Nuxt实现前后端分离

Satori

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

基于Django国际化的多语言实现

Satori

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

和谐图片浏览以及实现方法

Satori

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

基于C4.5的决策树实现

Satori

Author: Satori

Posted: 2017-12-13 15:55:27

Category: C4.5 决策树 技术 机器学习

Views: 862 Comments: 0

## 一、决策树概要 决策树是根据输入的属性进行决策,每次按照一个属性进行判断,走到对应的子节点,直到走到叶节点,也就是最后的输出标签。 ## 二、C4.5概要 C4.5算法是生成决策树的一种算法,它在ID3算法上进行改进,在信息熵的基础上引入信息增益,每次决策树选择一个属性进行划分的时候,都计算按照这个属性进行划分得到的信息增益量,每次选择增益最多的方法进行划分,这样就能尽量少地减少决策树深度。 ## 三、总体思路 主要是分为三个模块,一个是决策树的构建,这个模块又包含属性的选择这个模块,最后是预测模块。整体流程如下。 先构造树的根节点,然后根据数据的特征找到某个属性,根据这些属性把相同取值的数据项集合在一起,然后将这个属性值去掉。对于根据属性值划分得到的每一个数据集,构造子节点,再根据去掉划分属性后的数据进行子节点的构造,直到构造某个子节点没有属性的时候,即为叶子节点,然后根据数据的标签决定这个叶节点的最后标签。 ![C4.5_sample][1] 如上图,对于这组数据,根据左边的数据,使用C4.5算法,最后选择的就是天气属性,天气有晴、阴、雨三个选择。根据这三种取值,将数据划分为三类,因此决策树当前节点有3个子节点,每个节点的数据都从原数据那里进行拷贝,只不过去掉了天气这个属性。对于这三个子节点,递归进行构造。 在预测的时候,从根节点开始,根据节点检查的属性走到对应的子节点上,重复以上过程直到走到叶节点上。 ## 四、属性的选择 采取C4.5算法,目的就是对于当前的数据选择一个最优的划分属性,现在讲解如何选择这个属性,以上图的数据集为例。 首先计算信息熵,数据集D包含14个训练样本,其中属于类别“Yes”的有9个,属于类别“No”的有5个,则计算其信息熵。 ``` Info(D) = -9/14 * log2(9/14) - 5/14 * log2(5/14) = 0.940 ``` 下面对属性集中每个属性分别计算信息熵,如下所示。 ``` Info(OUTLOOK) = 5/14 * [- 2/5 * log2(2/5) - 3/5 * log2(3/5)] + 4/14 * [ - 4/4 * log2(4/4) - 0/4 * log2(0/4)] + 5/14 * [ - 3/5 * log2(3/5) - 2/5 * log2(2/5)] = 0.694 Info(TEMPERATURE) = 4/14 * [- 2/4 * log2(2/4) - 2/4 * log2(2/4)] + 6/14 * [ - 4/6 * log2(4/6) - 2/6 * log2(2/6)] + 4/14 * [ - 3/4 * log2(3/4) - 1/4 * log2(1/4)] = 0.911 Info(HUMIDITY) = 7/14 * [- 3/7 * log2(3/7) - 4/7 * log2(4/7)] + 7/14 * [ - 6/7 * log2(6/7) - 1/7 * log2(1/7)] = 0.789 Info(WINDY) = 6/14 * [- 3/6 * log2(3/6) - 3/6 * log2(3/6)] + 8/14 * [ - 6/8 * log2(6/8) - 2/8 * log2(2/8)] = 0.892 ``` 根据上面的数据,我们可以计算选择第一个根结点所依赖的信息增益值,计算如下所示。 ``` Gain(OUTLOOK) = Info(D) - Info(OUTLOOK) = 0.940 - 0.694 = 0.246 Gain(TEMPERATURE) = Info(D) - Info(TEMPERATURE) = 0.940 - 0.911 = 0.029 Gain(HUMIDITY) = Info(D) - Info(HUMIDITY) = 0.940 - 0.789 = 0.151 Gain(WINDY) = Info(D) - Info(WINDY) = 0.940 - 0.892 = 0.048 ``` 接下来,我们计算分裂信息度量H(V)。属性OUTLOOK有3个取值,其中Sunny有5个样本、Rainy有5个样本、Overcast有4个样本。 ``` H(OUTLOOK) = - 5/14 * log2(5/14) - 5/14 * log2(5/14) - 4/14 * log2(4/14) = 1.577 ``` 属性TEMPERATURE有3个取值,其中Hot有4个样本、Mild有6个样本、Cool有4个样本。 ``` H(TEMPERATURE) = - 4/14 * log2(4/14) - 6/14 * log2(6/14) - 4/14 * log2(4/14) = 1.557 ``` 属性HUMIDITY有2个取值,其中Normal有7个样本、High有7个样本。 ``` H(HUMIDITY) = - 7/14 * log2(7/14) - 7/14 * log2(7/14) = 1.0 ``` 属性WINDY有2个取值,其中True有6个样本、False有8个样本。 ``` H(WINDY) = - 6/14 * log2(6/14) - 8/14 * log2(8/14) = 0.985 ``` 根据上面计算结果,我们可以计算信息增益率,如下所示。 ``` IGR(OUTLOOK) = Info(OUTLOOK) / H(OUTLOOK) = 0.246/1.577 = 0.156 IGR(TEMPERATURE) = Info(TEMPERATURE) / H(TEMPERATURE) = 0.029 / 1.557 = 0.0186 IGR(HUMIDITY) = Info(HUMIDITY) / H(HUMIDITY) = 0.151/1.0 = 0.151 IGR(WINDY) = Info(WINDY) / H(WINDY) = 0.048/0.985 = 0.0487 ``` 最后发现是OUTLOOK属性的信息增益率最高,因此选择OUTLOOK属性进行划分。 ## 五、优化剪枝 优化剪枝是比较重要的,我这里做了最简单的剪枝,就是当某个节点所有标签都是一样的值的时候,直接作为叶子节点,避免无意义地增加深度。 ## 六、具体实现 ### 1. 数据的初步处理 先定义输入数据的格式,是一个json文件。最外层是数组,每个元素是一个字典,字典的键有两个,一个是`attr`代表数据的属性集合,一个是`label`,代表这个数据的输出标签。对于`atrr`来说,后面接一个字典,字典的键就是每个属性名,值就是这个属性下的取值。 这一步主要是将输入数据进行处理,得到一个数据分布的字典`distribution`,字典的每一个键为属性的名称,每个属性名称之后接一个字典,这个字典的键是这个属性的所有取值,值也是一个字典,这个字典记录的是在每一种取值下每个标签出现的次数。 具体做法就是读每个数据的`attr`,根据这个数据的取值以及标签构造分布。同时顺便统计每个标签下数据的个数,用`label_count`表示。这部分代码如下。 ``` # distribution: {"group_name1": {"group_val1": {"label1": x, "label2": y, ...}, "group_val2":...}, "group_name2": ...} distribution = {} # label_count: {"label1": x, "label2": y, ...} label_count = {} for single_data in data: attrs = single_data["attrs"] label = single_data["label"] # add to label_count if label_count.get(label): label_count[label] += 1 else: label_count[label] = 1 # add to distribution for group_name, group_val in attrs.items(): if not distribution.get(group_name): distribution[group_name] = {} group_vals = distribution[group_name] if not group_vals.get(group_val): group_vals[group_val] = {} val_labels = group_vals[group_val] if val_labels.get(label): val_labels[label] += 1 else: val_labels[label] = 1 ``` ### 2. 计算信息熵 先是数据集的信息熵`info_label`。 ``` # calculate info_label info_label = 0 all_count = len(data) for label, count in label_count.items(): info_label += (-count) / all_count * math.log(count / all_count) / math.log(2) ``` 然后计算每一个属性的信息熵`info_attrs` ``` # calculate info_attrs, info_attrs: {"group_name1": x, "group_name2": y, ...} info_attrs = {} for group_name, group_vals in distribution.items(): info_attrs[group_name] = 0 for group_val, label_vals in group_vals.items(): cur_attr_count = functools.reduce(lambda x, y: x + y, label_vals.values()) cur_val = 0 for label, count in label_vals.items(): cur_val += -(count) / cur_attr_count * math.log(count / cur_attr_count) / math.log(2) cur_val *= cur_attr_count / all_count info_attrs[group_name] += cur_val ``` 接着计算信息增益值`gain_attrs`。 ``` gain_attrs = {} for group_name, val in info_attrs.items(): gain_attrs[group_name] = info_label - val ``` 接着计算分裂信息度量`h_attrs`。 ``` # calculate the split info, h_attrs: {"group_name1": x, "group_name2": y, ...} h_attrs = {} for group_name, group_vals in distribution.items(): h_attrs[group_name] = 0 attr_val = {} for attr, labels in group_vals.items(): attr_val[attr] = functools.reduce(lambda x, y: x + y, labels.values()) for attr, count in attr_val.items(): h_attrs[group_name] += -(count) / all_count * math.log(count / all_count) / math.log(2) ``` 最后计算计算信息增益率`igr_attrs`,并返回最大值对应的属性。注意这里当分裂度为0,也就是只有一种分裂的时候,相当于属性只有一种取值,就直接返回那个属性(相当于igr无限大)。 ``` igr_attrs = {} for group_name, val in gain_attrs.items(): if abs(h_attrs[group_name]) < 1e-6: return group_name igr_attrs[group_name] = val / h_attrs[group_name] return max(igr_attrs, key=igr_attrs.get) ``` ### 3. 决策树的构造 首先定义决策树节点的结构。 ``` class Node(object): def __init__(self): self.children = {} # {"attr1":Node1, "attr2":Node2, "attr3":Node3, attr:Node} self.split_attr = None # select the split attribute self.label = None def is_leaf(self): return len(self.children) == 0 ``` `children`代表子节点,`split_attr`代表该节点划分子节点的属性,这两个属性对于中间节点有意义,同样`label`也只对叶节点有用,代表这个节点输出的标签是什么。 然后是具体构造过程,我采用广度优先的方法进行构造。有两个队列,`nodes`和`datas`表示待处理的节点以及待处理节点的数据。 对于每一个节点,拿到数据,判断这个数据属性是否被取光了,如果是,那就代表这就是叶子节点,这时候需要对数据的标签进行统计,得到最多的标签,作为该节点的标签。否则,先检查能否剪枝,即所有数据的标签是否一样,如果是一样的,也将这个节点作为叶子节点,标签就是那个标签。如果都不满足,则需要调用之前提到的方法来选择一个最佳划分属性,得到这个属性之后,根据这个属性的不同取值进行划分,对于每个划分的数据集,删去那个选择的属性。并根据数据集的个数构造相同个数的子节点入队,同时将前面构造的划分数据集入队,同时对于新构造的节点,将它和当前节点的`children`属性关联。当队列为空的时候,返回根节点即可。代码如下。 ``` def make_nodes(data): # single data: {"attrs": {"group_name1": val1, "group_name2": val2, ...}, "label": x} # data: [single_data1, single_data2, ..., single_datak] # datas: [data1, data2, ..., datan] root = Node() nodes = [root] datas = [data] while len(datas) > 0: cur_data = datas[0] cur_node = nodes[0] del datas[0] del nodes[0] if len(cur_data) == 0: # TODO: deal with no data pass else: left_attrs = cur_data[0]["attrs"].keys() if len(left_attrs) == 0: # if reach a leaf node # find the most common label labels= [x["label"] for x in cur_data] c = Counter(labels) cur_node.label = c.most_common(1)[0][0] else: # cut tree if all data has the same label labels = {x["label"] for x in cur_data} if len(labels) == 1: cur_node.label = cur_data[0]["label"] continue # else split the data by one attribute split_attr = get_attr(cur_data) cur_node.split_attr = split_attr attr_vals = set([x["attrs"][split_attr] for x in cur_data]) for attr_val in attr_vals: next_data = copy.deepcopy(list(filter(lambda x:x["attrs"][split_attr] == attr_val, cur_data))) for ind in range(len(next_data)): next_data[ind]["attrs"].pop(split_attr, None) datas.append(next_data) next_node = Node() cur_node.children[attr_val] = next_node nodes.append(next_node) return root ``` ### 4. 预测过程 这部分相对就简单很多了,对于新来的数据组,对于每一组数据,从root出发,看`split_attr`是根据什么属性划分子节点的,再找到自己的那个属性,根据`children`的键找到对应跳转的子节点。重复一直找到叶子节点。当然如果到某一步无路可走,那就直接返回空。代码如下。 ``` def predict(root, data): res = [] for cur_data in data: cur_node = root try: while not cur_node.is_leaf(): split_attr = cur_node.split_attr cur_attr = cur_data[split_attr] cur_node = cur_node.children[cur_attr] res.append(cur_node.label) except: res.append(None) return res ``` 然后是统计正确个数,然后和标准结果进行比较,代码如下。 ``` root = make_nodes(train_data) expect_results = [x["label"] for x in test_data] predict_data = [x["attrs"] for x in test_data] predict_results = predict(root, predict_data) total = len(test_data) right = 0 for ind in range(len(test_data)): if expect_results[ind] == predict_results[ind]: right += 1 acc = right / total print("right:", right, "total:", total, "accuracy:", acc) ``` ## 七、完整代码 详见[我的github页面][2] ## 八、总结 决策树以及C4.5算法看起来还是比较简单,但是自己写起来要计算信息增益之类的还是比较麻烦,难点在于数据结构的设计上,如何设计使代码写起来更轻松,同时让遍历数据代价降到最小,使运行时间降低。同时在一个nursing的数据集上跑,2秒左右得到了结果,正确率有97.9%,说明效果还不错。总之还是有很长一段路需要走。 [1]: https://chongliu.me/media/user_1/C4.5_sample.png [2]: https://github.com/KomeijiSatori/C4.5

Read More

博客@功能实现以及消息系统更新

Satori

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 %}">&lt;</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 %}">&gt;</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

博客表情的实现方法

Satori

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

网页vim键位浏览博客及评论实现方法

Satori

Author: Satori

Posted: 2017-10-07 12:25:41

Category: CSS HTML JavaScript vim键位 技术

Views: 932 Comments: 4

## 一、背景 这个功能的想法最早来源于北邮人论坛。当时我刚会用vim,在浏览论坛的时候我不禁试了一下,结果发现真的可以用vim键位,表示很欣喜。这个功能在我刚开始开发这个博客就考虑了,但是当时还有很多其它更重要的功能要开发,结果基本上拖了半年才真正实现了这个功能,算是完成了一个心愿。网页快捷键浏览在其他网站也有,比如twitter可以用j和k来上下切换浏览。 ## 二、需求分析 因为后期开发了用户界面,因此用户界面的条目,包括发表的博客,发表的评论,回复我的等等都需要能使用vim键位浏览。同时,在博客详情页面,也能通过vim键位来浏览评论。 这里需要定义一下什么叫用vim键位浏览。就是说当使用vim键位的时候,会选中目标的博客,将它移动到屏幕中央,并在外边框加上颜色进行标注。在浏览博客条目的时候,按照vim键位,`j`就是向下移,`k`就是向上移,`h`就是翻上一页,`l`就是翻下一页。 在博客详情页面中,因为评论是树形结构,因此键位的意义也就稍有不同。`j`就是按照评论树的先序遍历顺序取下一个评论,同理`k`就是取上一个。`h`定义为评论树当前结点的前一个兄弟结点,同理`l`就是下一个兄弟节点。 在键位激活的时候,要做两件事情,一件是将目标窗口标红,另一件是将屏幕移动到目标窗口。这里也就分列表页面和详情页面来讲实现方法。不过在此之前先讲这两个功能都需要实现的逻辑,也就是移动窗口和给窗口加上边框。 ## 三、移动窗口及边框着色的实现方法 ### 1. 移动屏幕到目标窗口 这一部分主要是实现当用户按下按键之后移动到目标窗口的方法。这里将这个操作封装为一个函数。传入目标窗口以及移动时间。难点在于怎么计算目标窗口的位置。因为是要移动到屏幕中央,因此先判断能不能移到中央。这个取决于目标块的大小。如果目标窗口的高度都比屏幕高度高了,那就不能完整显示,就只能将它移动到屏幕顶端。如果不是的话,就能移动到中央。 首先需要明白怎么样使窗口移动。这里采用的做法是让**整个页面**移动,而不是让该窗口移动。可以理解为屏幕是绝对不动的,动的只有页面,页面在屏幕里面滑动,而屏幕只显示页面当前滑动的位置。使用`scrollTop`的动画来实现整个页面的移动。这个动画需要给出位移。在这种设计下,位移多少就是整个页面上滑动多少。 举个例子,如果位移为0的话页面不移动,位移为100的话页面向上移动100px,可以想象,如果计算出某元素顶部距离页面的距离,比如距离为x,位移x就能刚好移动到那个元素的顶部,也就是屏幕顶部和元素顶部重合。 因此剩下的事情就简单了,首先算出该窗口距离顶部的位移。如果直接让页面移动那么多的话那个窗口就在屏幕顶端了,因此需要页面移动少一点,移动到窗口居中就行了,下面就算少多少距离。这个就简单了,当前窗口需要移动到屏幕底端一半的位置,也就是屏幕高度减去窗口高度除以2。只要页面少往上移动这么多距离就能使窗口刚好居中。代码如下。 ``` function move_to_ele(ele, time) { // scroll to ele if (ele.size() === 1) { var elOffset = ele.offset().top; var elHeight = ele.height(); var windowHeight = $(window).height(); var offset; if (elHeight < windowHeight) { offset = elOffset - ((windowHeight / 2) - (elHeight / 2)); } else { offset = elOffset; } $('html, body').animate({ scrollTop: offset }, time); } } ``` ### 2. 给窗口边框着色 给边框着色就比较简单了,我这里也是封装成一个函数来处理。这个函数传两个参数,一个是当前已经被选中的窗口(没有的话就是空),以及下一个被选中的窗口(可能是下面一个也可能是上面一个窗口)。 着色方法比较简单,就是把当前窗口的`kb`类给移除,给下一个窗口加上`kb`类。而`kb`类定义了样式,也就是加边框。这样通过加上或者移除这个类来实现边框着色与否。`kb`样式如下。 ``` .kb { border: solid 4px rgba(255, 0, 0, 0.6); } ``` 函数实现如下。 ``` function color_to_ele(curEle, next) { if (next.size() === 1) { if (curEle !== null) { curEle.removeClass("kb"); } next.addClass("kb"); } } ``` ## 四、列表的定位 ### 1. 找到当前激活的窗口 首先需要找是否已经有窗口被激活了,如果没有的话按`j`从第一个开始浏览,如果有的话就从当前位置开始。我们根据div的class属性来判断是否有激活窗口,有的话就返回那个窗口。代码如下。 ``` function getCurrentElement() { var ele = $(".blog-div.kb"); if (ele.size() != 1) { return null; } else { return ele; } } ``` ### 2. 四个键位的定位实现 vim键位的`j` `k` `h` `l`分别对应下移,上移,左翻一页,右翻一页。对于上下移动,调用next方法来获取下一个激活窗口的位置,使用prev方法得到上一个激活窗口的位置。得到目标位置后,再对新窗口的边框进行着色,并移动到对应位置。对于翻页操作,只需模拟点击左翻和右翻按钮来实现。同时需要注意边界情况,比如没有上一页或者下一页,没有上一个窗口或者下一个窗口等等,代码如下。 ``` function movedown(time) { var curEle = getCurrentElement(); var next; // no item selected if (curEle === null) { next = $(".blog-div").first(); } else { next = curEle.next(".blog-div"); } // if not the last item color_to_ele(curEle, next); move_to_ele(next, time); } function moveup(time) { var curEle = getCurrentElement(); // no item selected if (curEle !== null) { var next = curEle.prev(".blog-div"); color_to_ele(curEle, next); move_to_ele(next, time); } } function moveprev() { var btn = $("#prev-btn"); if (btn.size() === 1) { btn[0].click(); } } function movenext() { var btn = $("#next-btn"); if (btn.size() === 1) { btn[0].click(); } } ``` ### 3. 绑定键盘按键 最后一步就是将四个按键和刚才的四个函数相连接,设置一个移动时间即可。最后需要注意用户在输入框打字的时候不要监听这四个快捷键。代码如下。 ``` var time = 100; var vim_key_listener = new window.keypress.Listener; vim_key_listener.simple_combo("j", function() { movedown(time); }); vim_key_listener.simple_combo("k", function() { moveup(time); }); vim_key_listener.simple_combo("h", function() { moveprev(); }); vim_key_listener.simple_combo("l", function() { movenext(); }); $("input[type=text], textarea") .bind("focus", function() { vim_key_listener.stop_listening(); }) .bind("blur", function() { vim_key_listener.listen(); }); ``` ### 4. 用户界面的特殊处理 因为用户界面有多个tab,每个tab的窗口类名都一样,因此需要甄别是哪个窗口被激活,保证调用`getCurrentElement`函数的时候只会得到一个元素,就是当前tab的窗口。因此需要在用户界面点击tab的时候,将其他tab中的内容全部清空,再利用ajax请求得到自己tab的内容,这样就解决这个问题了,代码如下。 ``` function clear_tabs() { $("#blog-posts").empty(); $('#comment-posts').empty(); $('#comment-received').empty(); $("#blog-subscriptions-div").empty(); } $('a[data-toggle="pill"]').on('shown.bs.tab', function (e) { var target = $(e.target).attr("href");// activated tab clear_tabs(); }); ``` ## 五、评论的定位 ### 1. 找到当前激活的评论 大体和前面一样的做法,只是将类名改为`comment-body`,代码如下。 ``` function getCurrentElement() { var ele = $(".kb .comment-body").first(); if (ele.size() != = 1) { return null; } else { return ele; } } ``` ### 2. 四个键位的定位实现 vim键位的`j` `k`分别对应dom树的先序遍历的下、上一个结点。`h` `l`对应同级兄弟结点的上、下一个结点。 对于先序遍历的结果的获取,使用jQuery选取所有类为`comment-body`的元素,这样获取的列表默认就是dom树先序遍历的结果,然后只需找到当前激活窗口在这个列表的位置,选取上一个或者下一个位置就能得到先序遍历的前后结点。 对于上下兄弟结点的获取,直接找到它的父节点并在父节点调用next方法获取下一个list的位置,同理prev方法得到上一个list位置,也就是找到了兄弟结点的父亲结点。然后只需找那个父亲结点的孩子就能找到下一个窗口的位置。 得到目标位置后,再对新窗口的边框进行着色,并移动到对应位置。同时需要注意边界情况,比如没有上一个或下一个先序遍历的位置,没有上一个或下一个兄弟等等,代码如下。 ``` function movenext(time) { var curEle = getCurrentElement(); var next; // no item selected if (curEle == = null) { next = $(".comment-body").first(); color_to_ele(null, next.parent()); } else { next = curEle.parent().next("li").children(".comment-body"); color_to_ele(curEle.parent(), next.parent()); } // if not the last item move_to_ele(next, time); } function moveprev(time) { var curEle = getCurrentElement(); var next; // no item selected if (curEle != = null) { next = curEle.parent().prev("li").children(".comment-body"); color_to_ele(curEle.parent(), next.parent()); // if not the last item move_to_ele(next, time); } } function movedown(time) { var curEle = getCurrentElement(); // no item selected if (curEle == = null) { var next = $(".comment-body").first(); color_to_ele(null, next.parent()); move_to_ele(next, time); } else { var all_items = $(".comment-body"); var cur_ind = all_items.index($(".kb .comment-body").first()); if (cur_ind + 1 <= all_items.size() - 1) { var next = $(all_items.get(cur_ind + 1)); color_to_ele(curEle.parent(), next.parent()); move_to_ele(next, time); } } } function moveup(time) { var curEle = getCurrentElement(); // no item selected if (curEle != = null) { var all_items = $(".comment-body"); var cur_ind = all_items.index($(".kb .comment-body").first()); if (cur_ind - 1 >= 0) { var next = $(all_items.get(cur_ind - 1)); color_to_ele(curEle.parent(), next.parent()); move_to_ele(next, time); } } } ``` ### 3. 绑定键盘按键 最后一步就是将四个按键和刚才的四个函数相连接,设置一个移动时间即可。最后需要注意用户在输入框打字的时候不要监听这四个快捷键。代码如下。 ``` $(document).ready(function() { var time = 100; var vim_key_listener = new window.keypress.Listener; vim_key_listener.simple_combo("j", function() { movedown(time); }); vim_key_listener.simple_combo("k", function() { moveup(time); }); vim_key_listener.simple_combo("h", function() { moveprev(time); }); vim_key_listener.simple_combo("l", function() { movenext(time); }); $("input[type=text], textarea") .bind("focus", function() { vim_key_listener.stop_listening(); }) .bind("blur", function() { vim_key_listener.listen();}); }); ``` 至此用vim键位浏览博客列表,用户列表,博客评论的实现方法也就全部实现了。

Read More

网页模拟钢琴键盘的实现方法

Satori

Author: Satori

Posted: 2017-10-06 23:05:01

Category: HTML JavaScript 技术 钢琴键盘

Views: 1531 Comments: 2

## 一、简介 这是键盘快捷键的第二个应用。这个想法最早来自一款叫做Mountain的steam游戏。这个游戏就是每个字母按键都对应一个音符,通过弹奏某些著名乐章的旋律来解锁一些成就。不够Mountain最大的问题是它只能弹C大调(或者A小调),也就是说,它只有C D E F G A B这7种音,根据十二平均律还少了5个半音,分别是C#(Db),D#(Eb),F#(Gb),G#(Ab),A#(Bb),因此我想实现一个能弹奏这十二种半音的钢琴键盘。 除了能弹奏十二种半音外,我还希望音域能宽一点,至少钢琴的88个半音都能涉及到。但是键盘按键又没有那么多,所以还需要两个按键来让键盘整体平移八度。如果能准备不同音色的乐器那就更好了。 ## 二、实现方法 ### 1.音的演奏。 一种做法是将所有的音作为静态文件进行播放,但是这样会有网络延迟带来的一系列问题,不是最佳的解决方法。幸好有JavaScript库可以使用。这里用的是[audiosynth][1],它通过调用简单的函数就能播放声音。 使用`Synth.setVolume`来调节播放音量,通过以下调用播放声音。 ``` Synth.play(instrument, note, oct + octave_base, note_span); ``` play函数接受4个参数,第一个是整形,表示乐器种类,这个库提供4种音色,0代表钢琴,1代表管风琴,2代表吉他,3的声音很迷,不知道是什么。第二个参数是演奏哪个音,是一个字符串,表示形如`C`,`C#`之类,降号需要转换成升号,比如`Db`就要转换为`C#`。第三个参数表示音是哪个八度的,整形,比如4就代表第4个八度。最后一个参数表示演奏的时间,单位是秒。 ### 2.键盘的对应 键盘的一个按键对应一个音。为了符合钢琴的键位,定义`q` `w` `e` `r` `t`这一行是低音`C` `D` `E` `F` `G`等等。`q`和`w`上面的按键为`2`,代表`C#`,`w`和`e`上面是`3`,代表`D#`等等。最后的`]`代表`G`,接着,键盘从`z` `x` `c` `v` `b`这一行开始,分别代表`A` `B` `C` `D` `E`这几个音,刚好和上一排对应上,最后一个音为`.`,也就是`B`。这样下来,这个键盘包含3个完整的8度。使用字典来记录这个规则。代码如下。 ``` var keymap = { 'q': 'C -1', 'w': 'D -1', 'e': 'E -1', 'r': 'F -1', 't': 'G -1', 'y': 'A -1', 'u': 'B -1', 'i': 'C 0', 'o': 'D 0', 'p': 'E 0', '[': 'F 0', ']': 'G 0', 'z': 'A 0', 'x': 'B 0', 'c': 'C 1', 'v': 'D 1', 'b': 'E 1', 'n': 'F 1', 'm': 'G 1', ',': 'A 1', '.': 'B 1', '2': 'C# -1', '3': 'D# -1', '5': 'F# -1', '6': 'G# -1', '7': 'A# -1', '9': 'C# 0', '0': 'D# 0', '=': 'F# 0', 'a': 'G# 0', 's': 'A# 0', 'f': 'C# 1', 'g': 'D# 1', 'j': 'F# 1', 'k': 'G# 1', 'l': 'A# 1' }; ``` 其中每一行的键为按键,值为字符串,前半部分表示音的种类,后半部分表示相对音高,相当于基准音的偏移。-1代表比基准低一个八度,1代表高一个八度。如果定义基准音高为4的话,`q`代表`C3`,`i`代表`C4`,`c`代表`C5`。 ### 3.重复按键的处理 重复按键问题主要出现在一直按着一个键不放的情况。比如一直按着`c`,就会一直不断演奏这个音,这显然不是我们想要的,我们想至少需要等到按键释放后再按才再次触发这个音的播放。于是按键处理这一块就需要用到高级函数了,需要我们自己定义按键按下和释放的处理函数。 最简单的思路就是记录每一个按键的状态,记录是否已经被按下,如果被按下了,就不再播放这个音。于是整个过程是这样。首先所有按键的状态都是没有被按下。当有一个按键被按下的时候,先判断这个按键是否已经被按下了,如果已经被按下了,说明之前那次按键还没有释放,直接跳过,如果没有被按下,就播放声音,并将那个按键的标志设为按下。当某一按键释放的时候,将那个键的状态设为没有被按下。整个过程代码如下。 ``` for (var key in keymap) { key_state[key] = false; key_func_map.push({ "keys": key, "on_keydown": function(event) { var key = event.key.toLowerCase(); if (key_state[key] === false) { var cur_str = keymap[key]; var note = cur_str.split(" ")[0]; var oct = parseInt(cur_str.split(" ")[1]); Synth.play(instrument, note, oct + octave_base, note_span); key_state[key] = true; } }, "on_keyup": function(event) { var key = event.key.toLowerCase(); key_state[key] = false; } }); } ``` 其中`key_state`记录按键状态,`key_func_map`存储按键映射,`octave_base`表示当前的音高。 ### 4.音高以及乐器的调整 因为想让键盘能弹出多个八度,因此需要将整体音域平移,需要两个按键,一个上移,一个下移。用方向键上下即可。同时还希望能改乐器,同理使用左右来切换乐器。当然需要处理边界条件,比如音的上界和下界,乐器的编号的上界和下界。同样使用`key_func_map`来记录按键映射,代码如下。 ``` key_func_map.push({ "keys": 'up', "on_keydown": function() { if (octave_base <= 6) { octave_base++; } } }); key_func_map.push({ "keys": 'down', "on_keydown": function() { if (octave_base >= 2) { octave_base--; } } }); key_func_map.push({ "keys": 'left', "on_keydown": function() { if (instrument >= 1) { instrument--; } } }); key_func_map.push({ "keys": 'right', "on_keydown": function() { if (instrument <= 2) { instrument++; } } }); ``` 其中`octave_base`记录当前基准音高,`instrument`记录当前乐器的编号。 ### 5.钢琴键盘的开启和关闭 首先使用一个listener将刚才`key_func_map`记录的按键映射进行监听,并进行初始化,比如先停止监听,设定初始播放音量等,代码如下。 ``` var instrument_listener = null; instrument_listener = new window.keypress.Listener; Synth.setVolume(0.4); instrument_listener.register_many(key_func_map); instrument_listener.stop_listening(); ``` 然后再用另一个listener来启动或者关闭这个钢琴键盘。同样使用按键序列来实现。通过一个变量`instrument_active`来记录钢琴键盘是否已经被激活,再根据激活状态来判断下面是激活还是无效钢琴键盘,代码如下。 ``` function toggle_keyboard() { if (instrument_active === false) { instrument_active = true; instrument_listener.listen(); } else if (instrument_active === true) { instrument_active = false; instrument_listener.stop_listening(); } } key_listener.sequence_combo("! @ #", toggle_keyboard, true); ``` 这样一来钢琴键盘的实现也就全部完成了。 [1]: https://github.com/keithwhor/audiosynth

Read More

网页背景切换的实现方法

Satori

Author: Satori

Posted: 2017-10-06 16:53:10

Category: CSS HTML JavaScript 技术

Views: 1089 Comments: 0

## 一、简介 在实现了网页键盘的处理后,下一步就是利用键盘快捷键实现一些功能了。如果还没有看过键盘快捷键的实现,请移步[这篇文章][1]。 这篇主要讲键盘快捷键实现网页背景切换的实现方法,最后顺便题一下只显示背景的方法。 ## 二、背景切换的实现方法 因为背景切换实现非常简单,因此放到第一个讲。所谓的背景切换就是指在静态背景(健全壁纸)和动态背景(可能不是很健全的壁纸)的切换。如果对动态壁纸实现方式感兴趣,请移步[这篇文章][2]。 实现的关键在于key的回调函数实现。这个函数要做的事情是判断现在是什么背景,然后接不接受这次切换请求。因为在小屏幕的时候是不显示动态背景的。具体的细节就是使用window的`max-width`属性来判断是否是小屏幕,然后用hidden-xs是否可见来判断是否是动态背景。因此很容易写出以下逻辑。 ``` function toggle_background() { if (!window.matchMedia('(max-width: 768px)').matches) { if ($(".hidden-xs").is(":visible")) { hide_background(); Cookies.set("animated_background", false, { expired: 14 }); } else { show_background(); Cookies.set("animated_background", true, { expired: 14 }); } } } ``` 值得注意的是切换背景的时候设置一个Cookie,来记录用户偏爱的背景模式,不用每次来都切换背景。每次加载页面的时候先读取这个cookie,代码如下。 ``` var is_animated_background = Cookies.get("animated_background"); if (is_animated_background === "true") { show_background(); } else { hide_background(); } ``` 使用`hide_background`来隐藏动态背景,使用`show_background`来显示动态背景。于是下面给出这两个函数的实现。 ``` function hide_background() { $(".hidden-xs").hide(); $(".simple-image-div").css('display', ''); } function show_background() { $(".hidden-xs").show(); $(".simple-image-div").css('display', 'none'); } ``` 其中静态壁纸装到`simple-image-div`当中,动态壁纸装到`hidden-xs`中。静态壁纸的构造和动态壁纸一样,也是采用100%高度和100%宽度覆盖屏幕的方式。但是有一个问题,就是静态背景在刷新的时候会闪一下,非常瞎眼。 瞎眼的原理这里稍微多说一下。在刷新的时候,这时你的屏幕是背景图片。刷新是需要过程的,可能是0.5秒的时间,这段时间,最先加载的是背景的颜色,也就是`background-color`,然后再是图片。因此整个过程你看到的是背景图片,纯色快,背景图片。如果你的背景图片是暗色的,背景颜色设置的是浅色,就会出现深色-浅色-深色的转换,而且中间过程很短,所以才会感觉眼睛被晃了一下。 解决这个问题我的确花了不少时间。最后比较好的解决方法是先将背景设置为黑色,然后给静态图片设置从黑到背景图片的过渡,这样就不会瞎眼了。整个过程采用css解决。 ``` @keyframes simpleImage { 0% { opacity: 0; } 8% { opacity: 0; } 17% { opacity: 1; } 25% { opacity: 1; } 100% { opacity: 1 } } .simple-image-div { position: fixed; width: 100%; height: 100%; top: 0px; left: 0px; color: transparent; background-image: url('/static/mysite/images/background-lg.png'); background-size: cover; background-position: 50% 50%; background-repeat: none; opacity: 0; z-index: -2; animation: simpleImage 6s linear 0s; animation-fill-mode: forwards; } ``` 以上代码看不懂可以先看[动态背景实现方法][2],里面有讲`keyframe`之类的知识。 最后绑定一个按键listener即可,代码如下。 ``` var key_listener = new window.keypress.Listener; key_listener.sequence_combo("left up right down", toggle_background, true); ``` ## 三、只显示背景的实现方法 讲完背景的切换还是顺便讲一下只显示背景的方法。这个也就很简单了,将主页面的div隐藏,再隐藏标题栏就好了,随后将这个函数跟按键listener绑定即可实现。代码如下。 ``` function toggle_content() { $("#main-content").toggle(); $("#navigation-bar").toggle(); } key_listener.sequence_combo("up up down down left right left right b a", toggle_content, true); ``` [1]: https://chongliu.me/blogs/archive/136/ [2]: https://chongliu.me/blogs/archive/135/

Read More