截止到上一讲可以支持数据库存储了,所以这一讲开始讲解怎么从小程序发布一个问题并存储到服务器端。下面简单罗列一下本讲的知识点。对了老规矩,文末附源码。
tabbar
实现多 Tab
切换switchTab
和 navigateTo
weui.wxss
引入form
表单提交css
优先级API
工具的封装和校验逻辑LoginInterceptor
用户校验登录状态和获取用户信息api/question
API 提交问题到服务器端并存储先用一张图描绘一下这一讲的工作
如图,登录成功以后进入主页,区分为四个选项卡,首页、通知、礼品和我几个选项卡,所以对前端小程序进行了重构。把 index.js 从 question 启动到最外层,然后分别创建了 gift、notification、profile和question 文件夹用于存放相对应的选项卡的页面内容,同时记得修改 /app.json 里面的 pages 内容。
这个地方值得讲的是,小程序组件默认支持这种选项卡的方式,使用方式也是非常简单。
在 app.json
里面添加如下文件,就可以自动定义页面的选项卡。
{
"tabBar": {
"selectedColor": "#3c506f",
"list": [
{
"navigationBarTitleText": "首页",
"pagePath": "pages/question/list",
"text": "首页",
"iconPath": "images/index-default.png",
"selectedIconPath": "images/index-selected.png"
},
{
"navigationBarTitleText": "通知",
"pagePath": "pages/notification/list",
"text": "通知",
"iconPath": "images/notification-default.png",
"selectedIconPath": "images/notification-selected.png"
},
{
"navigationBarTitleText": "礼品",
"pagePath": "pages/gift/list",
"text": "礼品",
"iconPath": "images/gift-default.png",
"selectedIconPath": "images/gift-selected.png"
},
{
"navigationBarTitleText": "我",
"pagePath": "pages/profile/index",
"text": "我",
"iconPath": "images/me-default.png",
"selectedIconPath": "images/me-selected.png"
}
]
}
}
需要注意的是上面的 pagePath
对应的页面路径一定要存在, navigationBarTitleText
是跳转以后头部显示的名称。 iconPath
和 selectedIconPath
分别是选中前后展示的图标,小编特意选择了一些对应的图片,这个图片直接在
http://www.iconfont.cn
下载,并且可以免费试用,如果你想换成自己的图标,可以去里面碰碰运气。
这一讲用了两种跳转的方式 switchTab
和 navigateTo
,其中 switchTab
是跳转选项卡的时候用,并且只能用这个方法跳转,而 navigateTo
是让页面导航到页面,同时这个方法会记录历史,也就是说你会发现左上角会有一个后退按钮,点击可以回退到历史浏览的页面。如果你不想有这个后退按钮可以使用 redirectTo
进行跳转,这样会覆盖掉之前的访问堆栈。
weui.wxss
是微信官方默认的样式库,没有第三方的漂亮,但是够用即可,直接下载下来放到 /lib/weui.wxss
下面,在需要使用的地方用如下语句引入即可。
@import "../../lib/weui.wxss";
接下来就是小程序端关键的一步,提交表单。这个被小程序组件优化的还是比较简单。直接在 wxml
里面添加 form
标签,然后定义 bindsubmit
属性指定点击提交绑定的方法即可。同时定义一个 button
绑定提交属性 form-type='submit'
,这样点击这个按钮的时候,就会自动调用 bindsubmit
绑定的方法了,具体代码如下。里面用的 weui-cells__title
便是 weui
提供的一些样式,这个直接对着 css 找就可以了。
<form bindsubmit='post'>
<view class="page-section">
<view class="weui-cells__title">输入标题</view>
<view class="weui-cells weui-cells_after-title">
<view class="weui-cell weui-cell_input">
<input class="weui-input" name="title" auto-focus placeholder="请输入提问标题" />
</view>
</view>
</view>
<view class="page-section">
<view class="weui-cells__title">输入提问内容</view>
<view class="textarea-wrp">
<textarea auto-height name="content"/>
</view>
</view>
<view class='page-section'>
<button form-type='submit' bindtap="primary" class='weui-btn'>提问</button>
</view>
</form>
点击 提问
按钮以后,触发了定义在 post.js
的 post
,这个时候我们可以通过 e.detail.value
获取到绑定到 form
上面的所有对象,可以做简单的校验,然后传递给服务器端。
post: function(e) {
console.log("submit")
console.log(e.detail.value)
if (!e.detail.value.title) {
wx.showToast({
title: '请输入标题',
});
return;
}
if (!e.detail.value.content) {
wx.showToast({
title: '请输入内容',
});
return;
}
// 调用服务端 API
wx.showLoading({
title: '提交中'
});
}
有读者问过,怎么样封装一个好的 API
工具,答案是没有的。你觉得好用就时好的封装。这里小编简单对 API
工具进行了封装。
为什么在这一章节封装呢?因为只有两个地方调用的时候才需要封装,如果调用
API
我们只有一个地方需要,其实不封装也是可以的,封装是为了抽象、公用,所以对于封装我们还是要做到恰如其分。
我直接独立出来一个 service.js
用于专门调用服务端的 API
代码如下。
const service = options => {
wx.showNavigationBarLoading();
options = {
dataType: "json",
...options,
method: options.method ? options.method.toUpperCase() : "GET",
header: {
"token": wx.getStorageSync("token") || ""
},
};
const result = new Promise(function(resolve, reject) {
//做一些异步操作
const optionsData = {
success: res => {
wx.hideNavigationBarLoading();
if (res.data.status == 1005){
wx.showModal({
title: '请登陆',
content: '您还未登录,请授权登陆',
success: res => {
app.reLogin();
wx.redirectTo({
url: '/pages/index',
});
}
});
}
resolve(res.data);
},
fail: error => {
wx.hideNavigationBarLoading();
reject(error);
},
...options
};
let token = wx.getStorageSync("token") || "";
if (!token) {
if (optionsData.url.indexOf('api/login') == -1) {
wx.showModal({
title: '请登陆',
content: '您还未登录,请授权登陆',
success: res => {
app.reLogin();
wx.redirectTo({
url: '/index',
});
}
});
reject(error);
return;
}
}
wx.request(optionsData);
});
return result;
};
export default service;
如上我们简单进行讲解,封装主要涉及两个地方,一个是对于 API
的封装,我们把 API
统一定义到了 api.js
格式如下
const Login = {
url: config.serverHost + "/api/login",
method: "post"
};
这样在使用的地方直接引用即可,
service({
...Question,
data: {
title: e.detail.value.title,
content: e.detail.value.content
}
})
.then(response => {
wx.hideLoading();
console.log(response);
if (response.status == 200) {
// 展示 登录成功 提示框
wx.showToast({
title: '发布成功',
icon: "success",
duration: 2000,
success: res => {
wx.switchTab({
url: "list"
});
}
});
} else {
// 展示 错误信息
wx.showToast({
title: response.message,
icon: "none",
duration: 1000
});
}
})
.catch(error => {
console.log(error);
wx.showToast({
title: '提交失败'
});
});
同时直接传入 JSON
数据然后通过 Primose
返回的回调处理正确和失败即可,这样把调用 API
的处理全部封装起来,便于使用和管理。
另一个方便的地方是,统一处理了一下登录状态,简单点说就是每次调用服务端接口的时候都需要检查一下是否登录。检查分为三个部分,
第一部分是检查本地是否存储了 token
,否则提示需要登录。这个在《第五讲:登录的原理和实现》中有讲解怎么存 token
。
第二部分是把本地存储的 token
通过 header
传递给服务器端,这样是最关键的地方,不然服务器端怎么校验你的登录态?
第三部分如果调用服务端接口检测登录态过期会提示登录异常并跳转到登录页面。
到此小程序端逻辑已经全部完成,现在默认返回正确以后会跳转到列表也没,现在是空白没关系,下一讲就会展示出一个列表。
因为上一讲已经把基础的服务器端处理好,这一讲就比较简单,主要就需要做两件事情:登录校验和存储问题。
和小程序的思路类似,我们不能每一个请求过来都写一段逻辑校验一下是否有传递 token
,然后再获取一下用户信息看是否正确。于是服务端引入了 Interceptor
的概念,它可以在请求开始和结束的时候做拦截处理,这样每次请求来的时候先校验是否传递 token
,然后通过 token
到数据库里面查询是否有用户资料,如果没有返回错误,如果验证全部通过把查询出来的用户信息存储到 ThreadLocal
里面,供下文使用。关于 ThreadLocal
使用有不理解的可以查看一下小编之前的文章《如何优雅的使用 ThreadLocal》。具体实现如下。
首先在 applicationContext.xml
配置一下拦截器。
<mvc:interceptors>
<mvc:interceptor>
<mvc:mapping path="/api/**"/>
<mvc:exclude-mapping path="/api/login"/>
<bean class="com.codedrinker.interceptor.LoginInterceptor"></bean>
</mvc:interceptor>
</mvc:interceptors>
如上代码,指定了具体的拦截器,拦截 /api/**
地址, **
代表任意,但是不可以拦截 /api/login
,因为它是登录接口肯定没有 token
。其次编写拦截器。
public class LoginInterceptor implements HandlerInterceptor {
@Autowired
private UserService userService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//请求之前,验证通过返回true,验证失败返回false
String token = request.getHeader("token");
if (StringUtils.isBlank(token)) {
makeFail(response);
return false;
}
// 通过 token 从数据库中获取信息,如果没有验证失败
// 如果通过一台设备登录,再通过另一台设备登录,第一台设备会自动登出
User user = userService.getByToken(token);
if (user == null) {
makeFail(response);
return false;
}
//把获取到的user信息暂存到 ThreadLocal 里面,以便上线文中方便的使用
SessionUtil.setUser(user);
return true;
}
private void makeFail(HttpServletResponse response) {
ResultDTO resultDTO = ResultDTO.fail(CommonErrorCode.NO_USER);
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json; charset=utf-8");
try {
PrintWriter out = response.getWriter();
out.print(JSON.toJSONString(resultDTO));
out.close();
} catch (Exception e) {
log.error("LoginInterceptor preHandle", e);
}
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
//请求结束
//请求结束以后移除 user
SessionUtil.removeUser();
}
}
拦截器的实现比较简单,直接实现 HandlerInterceptor
接口,在 preHandle
里面处理访问拦截并存入 ThreadLocal
即可,需要注意的是在 postHandle
里面需要把 ThreadLocal
移除。拦截器的如果需要返回数据给小程序端,需要使用 response
,这里不能像 RestController
那么简洁了。
接口就相对比较简单了,直接上代码。
@RestController
@Slf4j
public class QuestionController {
@Autowired
private QuestionService questionService;
@RequestMapping(value = "api/question", method = RequestMethod.POST)
public ResultDTO post(@RequestBody Question question) {
try {
questionService.createQuestion(question);
return ResultDTO.ok(null);
} catch (Exception e) {
log.error("QuestionController post error, question : {}", question, e);
return ResultDTO.fail(CommonErrorCode.UNKOWN_ERROR);
}
}
}
上面的内容在《第五讲:登录原理和实现》 里面已经讲解,不在累述。另外还需要注意的是创建一个名为 V2__
的数据库脚本,在运行的时候会自动帮你创建数据库表,为什么呢?《第六讲:数据的验证和存储》已经讲解。
到此已经全部结束,回看上文是不是编写一个小程序也是很简单呢?
小程序组件示例 https://developers.weixin.qq.com/miniprogram/dev/component/textarea.html
小程序源码地址,Tag V7 https://github.com/codedrinker/jiuask
服务端源码地址,Tag V7 https://github.com/codedrinker/jiuask-server