基于React和Node.JS的表单录入系统的设计与实现
一、写在前面
这是一个真实的项目,项目已经过去好久了,虽然很简单,但还是有很多思考点,跟随着笔者的脚步,一起来看看吧。本文纯属虚构,涉及到的相关信息均已做虚构处理,
二、背景
人活着一定要有信仰,没有信仰的灵魂是空洞的。你可以信耶稣,信佛,信伊斯兰,信科学等等。为了管控各大宗教场所的人员聚集,为社会增添一份绵薄之力,京州领导决定做一个表单系统来统计某个时间或者时间段的人员访问量,控制宗教人员活动的范围,汉东省委沙瑞金书记特别关心这件事决定亲自检查,几经周转,这个任务落到了程序员江涛的头上,故事由此展开。
三、需求分析
大致需要实现如下功能
- 表单数据的录入
- 录入数据的最近记录查询
- 短信验证码的使用
- 扫码填写表单信息
有两种方案, 一种是进去自己选择对应的宗教场所(不对称分布三级联动),第二种是点击对应的宗教场所进行填写表单,表单处的场所不可更改,不同的设计不同的思路。 虽然两种都写了, 但这里我就按第二种写这篇文章,如果有兴趣了解第一种欢迎与我交流。
四、系统设计
这次我决定不用vue,改用react的taro框架写这个小项目(试一下多端框架taro哈哈), 后端这边打算用nodejs的eggjs框架, 数据库还是用mysql, 还会用到redis。由于服务器端口限制,搞不动docker啊, 也没有nginx,莫得关系,egg自带web服务器将就用一下项目也就做完了,就这样taro和egg的试管婴儿诞生了。
五、代码实现
额,东西又多又杂,挑着讲吧, 建议结合这两篇篇文章一起看, 基于Vue.js和Node.js的反欺诈系统设计与实现
5.1 前端实现
taroJS的安装使用参见https://taro-docs.jd.com/taro/docs/GETTING-STARTED
5.1.1 整体的布局设计
主要还是头部和其他这种布局,比较简单,然后抽离出一个公共组件header,给它抛出一个可以跳转链接的方法, 逻辑很简单就是一个标题,然后后面有一个返回首页的图标
import { View, Text } from '@tarojs/components';
import { AtIcon } from 'taro-ui'
import "taro-ui/dist/style/components/icon.scss";
import 'assets/iconfont/iconfont.css'
import './index.scss'
import { goToPage } from 'utils/router.js'
export default function Header(props) {
return (
{ props.title }
goToPage('index')}>
)
}
关于这一块,还可以看下components下的card组件的封装
5.1.2 表单的设计
表单设计这块,感觉也没啥好讲的,主要是你要写一些css去适配页面,具体的逻辑实现代码如下:
import Taro, { getCurrentInstance } from '@tarojs/taro'; import React, { Component } from 'react'; import { connect } from 'react-redux'; import { update } from 'actions/form'; import { View, Text, RadioGroup, Radio, Label, Picker } from '@tarojs/components'; import { AtForm, AtInput, AtButton, AtTextarea, AtList, AtListItem } from 'taro-ui'; import Header from 'components/header' import 'taro-ui/dist/style/components/input.scss'; import 'taro-ui/dist/style/components/icon.scss'; import 'taro-ui/dist/style/components/button.scss'; import 'taro-ui/dist/style/components/radio.scss'; import 'taro-ui/dist/style/components/textarea.scss'; import 'taro-ui/dist/style/components/list.scss'; import "taro-ui/dist/style/components/loading.scss"; import './index.scss'; import cityData from 'data/city.json'; import provinceData from 'data/province.json'; import { goToPage } from 'utils/router'; import { request } from 'utils/request'; @connect(({ form }) => ({ form }), (dispatch) => ({ updateForm (data) { dispatch(update(data)) } })) export default class VisitorRegistration extends Component { constructor (props) { super(props); this.state = { title: '预约登记', // 标题 username: '', // 姓名 gender: '', // 性别 mobile: '', // 手机 idcard: '', // 身份证 orgin: '', //访客来源地 province: '', //省 city: '', // 市 place: '', //宗教地址 religiousCountry: '', // 宗教县区 religiousType: '', // 宗教类型 matter: '', // 来访事由 visiteDate: '', // 拜访日期 visiteTime: '', // 拜访时间 leaveTime: '', // 离开时间 genderOptions: [ { label: '男', value: 'male' }, { label: '女', value: 'female' }, ], // 性别选项 genderMap: { male: '男', female: '女' }, timeRangeOptions: [ '00:00-02:00', '02:00-04:00', '04:00-06:00', '06:00-08:00', '08:00-10:00', '10:00-12:00', '12:00-14:00', '14:00-16:00', '16:00-18:00', '18:00-20:00', '20:00-22:00', '22:00-24:00', ], // 时间选项 orginRangeOptions: [[],[]], // 省市选项 orginRangeKey: [0, 0], provinces: [], citys: {}, isLoading: false, } this.$instance = getCurrentInstance() Taro.setNavigationBarTitle({ title: this.state.title }) } async componentDidMount () { console.log(this.$instance.router.params) const { place } = this.$instance.router.params; const cityOptions = {}; const provinceOptions = {}; const provinces = []; const citys = {}; provinceData.forEach(item => { const { code, name } = item; provinceOptions[code] = name; provinces.push(name); }) for(const key in cityData) { cityOptions[provinceOptions[key]] = cityData[key]; citys[provinceOptions[key]] = []; for (const item of cityData[key]) { if (item.name === '直辖市') { citys[provinceOptions[key]].push(''); } else { citys[provinceOptions[key]].push(item.name); } } } const orginRangeOptions = [provinces, []] await this.setState({ provinces, citys, orginRangeOptions, place }); } handleOriginRangeChange = event => { let { value: [ k1, k2 ] } = event.detail; const { provinces, citys } = this.state; const province = provinces[k1]; const city = citys[province][k2]; const orgin = `${province}${city}`; this.setState({ province, city, orgin }) } handleOriginRangleColumnChange = event => { let { orginRangeKey } = this.state; let changeColumn = event.detail; let { column, value } = changeColumn; switch (column) { case 0: this.handleRangeData([value, 0]); break; case 1: this.handleRangeData([orginRangeKey[0], value]); } } handleRangeData = orginRangeKey => { const [k0] = orginRangeKey; const { provinces, citys } = this.state; const cityOptions = citys[provinces[k0]] const orginRangeOptions = [provinces, cityOptions]; this.setState({ orginRangeKey, orginRangeOptions }) } handleChange (key, value) { this.setState({ [key]: value }) return value; } handleDateChange(key, event) { const value = event.detail.value; this.setState({ [key]: value }) return value; } handleClick (key, event) { const value = event.target.value; this.setState({ [key]: value }) return value; } handleRadioClick (key, value) { this.setState({ [key]: value }) return value; } async onSubmit (event) { const { username, gender, mobile, idcard, orgin, province, city, place, religiousCountry, religiousType, visiteDate, visiteTime, leaveTime, matter, genderMap, } = this.state; if (!username) { Taro.showToast({ title: '请填写用户名', icon: 'none', duration: 2000 }) return; } else if (!gender) { Taro.showToast({ title: '请选择性别', icon: 'none', duration: 2000 }) return; } else if (!mobile || !/^1(3[0-9]|4[579]|5[012356789]|66|7[03678]|8[0-9]|9[89])\d{8}$/.test(mobile)) { Taro.showToast({ title: '请填写正确的手机号', icon: 'none', duration: 2000 }) return; } else if (!idcard || !/^(^[1-9]\d{7}((0\d)|(1[0-2]))(([0|1|2]\d)|3[0-1])\d{3}$)|(^[1-9]\d{5}[1-9]\d{3}((0\d)|(1[0-2]))(([0|1|2]\d)|3[0-1])((\d{4})|\d{3}[Xx])$)$/.test(idcard)) { Taro.showToast({ title: '请填写正确的身份证号', icon: 'none', duration: 2000 }) return; } else if (!orgin) { Taro.showToast({ title: '请选择来源地', icon: 'none', duration: 2000 }) return; } else if (!place) { Taro.showToast({ title: '请选择宗教场所', icon: 'none', duration: 2000 }) return; } else if (!visiteDate) { Taro.showToast({ title: '请选择预约日期', icon: 'none', duration: 2000 }) return; } else if (!visiteTime) { Taro.showToast({ title: '请选择预约时间', icon: 'none', duration: 2000 }) return; } await this.setState({ isLoading: true }) const data = { username, gender: genderMap[gender], mobile, idcard, orgin, province, city, place, religiousCountry, religiousType, visiteDate, visiteTime, leaveTime, matter, }; const { data: { code, status, data: formData }} = await request({ url: '/record', method: 'post', data }); await this.setState({ isLoading: false }); if (code === 0 && status === 200 && data) { Taro.showToast({ title: '预约成功', icon: 'success', duration: 2000, success: () => { // goToPage('result-query', {}, (res) => { // res.eventChannel.emit('formData', { data: formData }) // }) this.props.updateForm(formData) goToPage('result-query') } }); } else { Taro.showToast({ title: '预约失败', icon: 'none', duration: 2000 }) return; } } handlePickerChange = (key, optionName, event) => { const options = this.state[optionName]; this.setState({ [key]: options[event.detail.value] }) } render() { const { title, username, genderOptions, mobile, idcard, visiteTime, timeRangeOptions, leaveTime, matter, visiteDate, orgin, orginRangeOptions, orginRangeKey, place, isLoading } = this.state; return (
); } } {this.handleChange('username', value)}} /> 性别 {genderOptions.map((genderOption, i) => { return ( ) })} {this.handleChange('mobile', value)}} /> {this.handleChange('idcard', value)}} /> 来源地 this.handleOriginRangeChange(event)} onColumnChange={(event) => this.handleOriginRangleColumnChange(event)} range={orginRangeOptions} value={orginRangeKey}> {orgin ? ( ) : ( 请选择访客来源地 )}{this.handleChange('place', value)}} /> 预约日期 this.handleDateChange('visiteDate', event)}> {visiteDate ? ( ) : ( 请选择预约日期 )}预约时间 this.handlePickerChange('visiteTime', 'timeRangeOptions', event)}> {visiteTime ? ( ) : ( 请选择预约时间 )}离开时间 this.handlePickerChange('leaveTime', 'timeRangeOptions', event)}> {leaveTime ? ( ) : ( 请选择离开时间 )}来访事由 {this.handleChange('matter', value)}} placeholder='请输入来访事由...' /> 提交
5.1.3 短信验证码的设计实现
这里也可以单独抽离出一个组件,主要的点在于,点击后的倒计时和重新发送,可以重点看下,具体的实现逻辑如下:
import Taro from '@tarojs/taro';
import { Component } from 'react';
import { View, Text } from '@tarojs/components';
import { AtInput, AtButton } from 'taro-ui';
import 'taro-ui/dist/style/components/input.scss';
import 'taro-ui/dist/style/components/button.scss';
import './index.scss';
const DEFAULT_SECOND = 120;
import { request } from 'utils/request';
export default class SendSMS extends Component {
constructor(props) {
super(props);
this.state = {
mobile: '', // 手机号
confirmCode: '', // 验证码
smsCountDown: DEFAULT_SECOND,
smsCount: 0,
smsIntervalId: 0,
isClick: false,
};
}
componentDidMount () { }
componentWillUnmount () {
if (this.state.smsIntervalId) {
clearInterval(this.state.smsIntervalId);
this.setState(prevState => {
return {
...prevState,
smsIntervalId: 0,
isClick: false
}
})
}
}
componentDidUpdate (prevProps, prveState) {
}
componentDidShow () { }
componentDidHide () { }
handleChange (key, value) {
this.setState({
[key]: value
})
return value;
}
processSMSRequest () {
const { mobile } = this.state;
if (!mobile || !/^1(3[0-9]|4[579]|5[012356789]|66|7[03678]|8[0-9]|9[89])\d{8}$/.test(mobile)) {
Taro.showToast({
title: '请填写正确的手机号',
icon: 'none',
duration: 2000
})
return;
}
this.countDown()
}
sendSMS () {
const { mobile } = this.state;
request({
url: '/sms/send',
method: 'post',
data: { mobile }
}, false).then(res => {
console.log(res);
const { data: { data: { description } } } = res;
Taro.showToast({
title: description,
icon: 'none',
duration: 2000
})
}).catch(err => {
console.log(err);
});
}
countDown () {
if (this.state.smsIntervalId) {
return;
}
const smsIntervalId = setInterval(() => {
const { smsCountDown } = this.state;
if (smsCountDown === DEFAULT_SECOND) {
this.sendSMS();
}
this.setState({
smsCountDown: smsCountDown - 1,
isClick: true
}, () => {
const { smsCount, smsIntervalId, smsCountDown } = this.state;
if (smsCountDown <= 0) {
this.setState({
smsCountDown: DEFAULT_SECOND,
})
smsIntervalId && clearInterval(smsIntervalId);
this.setState(prevState => {
return {
...prevState,
smsIntervalId: 0,
smsCount: smsCount + 1,
}
})
}
})
}, 1000);
this.setState({
smsIntervalId
})
}
submit() {
// 校验参数
const { mobile, confirmCode } = this.state;
if (!mobile || !/^1(3[0-9]|4[579]|5[012356789]|66|7[03678]|8[0-9]|9[89])\d{8}$/.test(mobile)) {
Taro.showToast({
title: '请填写正确的手机号',
icon: 'none',
duration: 2000
})
return;
} else if (confirmCode.length !== 6) {
Taro.showToast({
title: '验证码输入有误',
icon: 'none',
duration: 2000
})
return;
}
this.props.submit({ mobile, code: confirmCode });
}
render () {
const { mobile, confirmCode, smsCountDown, isClick } = this.state;
return (
{this.handleChange('mobile', value)}}
/>
{!isClick ? ( this.processSMSRequest()}
className='row-inline-col-3 at-input__input code-fix'>
发送验证码
) : ( this.processSMSRequest()}
className='row-inline-col-3 at-input__input code-fix red'>
{( smsCountDown === DEFAULT_SECOND ) ? '重新发送' : `${smsCountDown}秒后重试`}
)}
{this.handleChange('confirmCode', value)}}
/>
this.submit()}
className='col btn-submit'>
查询
)
}
}
5.1.4 前端的一些配置
路由跳页模块的封装
import Taro from '@tarojs/taro';
// https://taro-docs.jd.com/taro/docs/apis/route/navigateTo
export const goToPage = (page, params = {}, success, events) => {
let url = `/pages/${page}/index`;
if (Object.keys(params).length > 0) {
let paramsStr = '';
for (const key in params) {
const tmpStr = `${key}=${params[key]}`;
paramsStr = tmpStr + '&';
}
if (paramsStr.endsWith('&')) {
paramsStr = paramsStr.substr(0, paramsStr.length - 1);
}
if (paramsStr) {
url = `${url}?${paramsStr}`;
}
}
Taro.navigateTo({
url,
success,
events
});
};
请求方法模块的封装
import Taro from '@tarojs/taro';
const baseUrl = 'http://127.0.0.1:9000'; // 请求的地址
export function request(options, isLoading = true) {
const { url, data, method, header } = options;
isLoading &&
Taro.showLoading({
title: '加载中'
});
return new Promise((resolve, reject) => {
Taro.request({
url: baseUrl + url,
data: data || {},
method: method || 'GET',
header: header || {},
success: res => {
resolve(res);
},
fail: err => {
reject(err);
},
complete: () => {
isLoading && Taro.hideLoading();
}
});
});
}
日期格式的封装
import moment from 'moment';
export const enumerateDaysBetweenDates = function(startDate, endDate) {
let daysList = [];
let SDate = moment(startDate);
let EDate = moment(endDate);
let xt;
daysList.push(SDate.format('YYYY-MM-DD'));
while (SDate.add(1, 'days').isBefore(EDate)) {
daysList.push(SDate.format('YYYY-MM-DD'));
}
daysList.push(EDate.format('YYYY-MM-DD'));
return daysList;
};
export const getSubTractDate = function(n = -2) {
return moment()
.subtract(n, 'months')
.format('YYYY-MM-DD');
};
阿里妈妈图标库引入, 打开https://www.iconfont.cn/ ,找到喜欢的图表下载下来, 然后引入,在对应的地方加上iconfont
和它对应的样式类的值
import { View, Text } from '@tarojs/components';
import { AtIcon } from 'taro-ui'
import "taro-ui/dist/style/components/icon.scss";
import 'assets/iconfont/iconfont.css'
import './index.scss'
import { goToPage } from 'utils/router.js'
export default function Header(props) {
return (
{ props.title }
goToPage('index')}>
)
}
redux的使用,这里主要是多页面共享数据的时候用了下,核心代码就这点
import { UPDATE } from 'constants/form';
const INITIAL_STATE = {
city: '',
createTime: '',
gender: '',
id: '',
idcard: '',
leaveTime: '',
matter: '',
mobile: '',
orgin: '',
place: '',
province: '',
religiousCountry: '',
religiousType: '',
updateTime: '',
username: '',
visiteDate: '',
visiteTime: ''
};
export default function form(state = INITIAL_STATE, action) {
switch (action.type) {
case UPDATE:
return {
...state,
...action.data
};
default:
return state;
}
}
使用方法如下
@connect(({ form }) => ({
form
}), (dispatch) => ({
updateForm (data) {
dispatch(update(data))
}
}))
componentWillUnmount () {
const { updateForm } = this.props;
updateForm({
city: '',
createTime: '',
gender: '',
id: '',
idcard: '',
leaveTime: '',
matter: '',
mobile: '',
orgin: '',
place: '',
province: '',
religiousCountry: '',
religiousType: '',
updateTime: '',
username: '',
visiteDate: '',
visiteTime: ''
})
}
开发环境和生成环境的打包配置, 因为最后要合到egg服务里面,所以这里生产环境的publicPath和baseName都应该是 /public
module.exports = {
env: {
NODE_ENV: '"production"'
},
defineConstants: {},
mini: {},
h5: {
/**
* 如果h5端编译后体积过大,可以使用webpack-bundle-analyzer插件对打包体积进行分析。
* 参考代码如下:
* webpackChain (chain) {
* chain.plugin('analyzer')
* .use(require('webpack-bundle-analyzer').BundleAnalyzerPlugin, [])
* }
*/
publicPath: '/public',
router: {
basename: '/public'
}
}
};
开发环境名字可自定义如:
module.exports = {
env: {
NODE_ENV: '"development"'
},
defineConstants: {},
mini: {},
h5: {
publicPath: '/',
esnextModules: ['taro-ui'],
router: {
basename: '/religion'
}
}
};
5.2 后端实现
后端这块,其他的都没啥好讲的,具体可以参看我之前写的两篇文章,或者阅读源码,这里着重讲下防止短信验证码恶意注册吧。
5.2.1 如何防止短信验证码对恶意使用
这个主要是在于用的是内部实现的短信验证码接口(自家用的),不是市面上一些成熟的短信验证码接口,所以在预发布阶段安全方面曾经收到过一次攻击(包工头家的服务器每天都有人去攻击,好巧不巧刚被我撞上了),被恶意使用了1W条左右短信,痛失8张毛爷爷啊。总结了下这次教训,主要是从IP、发送的频率、以及加上csrf Token去预防被恶意使用。
大致是这样搞得。
安装相对于的类库
"egg-ratelimiter": "^0.1.0",
"egg-redis": "^2.4.0",
在config/plugin.js
下配置
ratelimiter: {
enable: true,
package: 'egg-ratelimiter',
},
redis: {
enable: true,
package: 'egg-redis',
},
在config/config.default.js
下配置
config.ratelimiter = {
// db: {},
router: [
{
path: '/sms/send',
max: 5,
time: '60s',
message: '卧槽,你不讲武德,老是请求干嘛干嘛干嘛!',
},
],
};
config.redis = {
client: {
port: 6379, // Redis port
host: '127.0.0.1', // Redis host
password: null,
db: 0,
},
};
效果是这样的
六、参考文献
- TaroJS官网: https://taro-docs.jd.com/taro/docs/README
- ReactJS官网: https://reactjs.org/
- eggJS官网: https://eggjs.org/
七、写在最后
到这里就要和大家说再见了, 通过阅读本文,对于表单的制作你学会了吗?欢迎在下方发表你的看法,也欢迎和笔者交流!
github项目地址:https://github.com/cnroadbridge/jingzhou-religion
gitee项目地址: https://gitee.com/taoge2021/jingzhou-religion