一、写在前面

这是一个真实的项目,项目已经过去好久了,虽然很简单,但还是有很多思考点,跟随着笔者的脚步,一起来看看吧。本文纯属虚构,涉及到的相关信息均已做虚构处理,

二、背景

人活着一定要有信仰,没有信仰的灵魂是空洞的。你可以信耶稣,信佛,信伊斯兰,信科学等等。为了管控各大宗教场所的人员聚集,为社会增添一份绵薄之力,京州领导决定做一个表单系统来统计某个时间或者时间段的人员访问量,控制宗教人员活动的范围,汉东省委沙瑞金书记特别关心这件事决定亲自检查,几经周转,这个任务落到了程序员江涛的头上,故事由此展开。

三、需求分析

大致需要实现如下功能

  • 表单数据的录入
  • 录入数据的最近记录查询
  • 短信验证码的使用
  • 扫码填写表单信息

有两种方案, 一种是进去自己选择对应的宗教场所(不对称分布三级联动),第二种是点击对应的宗教场所进行填写表单,表单处的场所不可更改,不同的设计不同的思路。 虽然两种都写了, 但这里我就按第二种写这篇文章,如果有兴趣了解第一种欢迎与我交流。

四、系统设计

这次我决定不用vue,改用react的taro框架写这个小项目(试一下多端框架taro哈哈), 后端这边打算用nodejs的eggjs框架, 数据库还是用mysql, 还会用到redis。由于服务器端口限制,搞不动docker啊, 也没有nginx,莫得关系,egg自带web服务器将就用一下项目也就做完了,就这样taro和egg的试管婴儿诞生了。

五、代码实现

额,东西又多又杂,挑着讲吧, 建议结合这两篇篇文章一起看, 基于Vue.js和Node.js的反欺诈系统设计与实现 https://www.cnblogs.com/cnroadbridge/p/15182552.html, 基于React和GraphQL的demo设计与实现 https://www.cnblogs.com/cnroadbridge/p/15318408.html

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 (<View className='header'><Text className='header-text'>{ props.title }</Text><Text onClick={() => goToPage('index')}><AtIcon prefixClass='icon' className='iconfont header-reback' value='home' color='#6190e8'></AtIcon></Text></View>)
}

关于这一块,还可以看下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 (<View className='visitor-registration'><Header title={title}/><AtFormonSubmit={this.onSubmit.bind(this)}><View className='row'><AtInputrequiredtype='text'name='username'className='col'title='访客姓名'placeholder='请输入访客姓名'value={username}onChange={(value) => {this.handleChange('username', value)}}/></View><View className='row'><View className='col at-input'><Text className='at-input__title at-input__title--required'>性别</Text><View className='at-input__input'><RadioGroup>{genderOptions.map((genderOption, i) => {return (<Label for={i} key={i}><Radiovalue={genderOption.value}onClick={(event) => {this.handleRadioClick('gender', genderOption.value)}}>{genderOption.label}</Radio></Label>)})}</RadioGroup></View></View></View><View className='row'><AtInputrequiredtype='phone'name='mobile'title='手机号码'className='col'placeholder='请输入手机号码'value={mobile}onChange={(value) => {this.handleChange('mobile', value)}}/></View><View className='row'><AtInputrequiredname='idcard'type='idcard'className='col'title='身份证号'placeholder='请输入身份证号码'value={idcard}onChange={(value) => {this.handleChange('idcard', value)}}/></View><View className='row'><View className='at-input col col-fix'><Text className='at-input__title at-input__title--required'>来源地</Text><Picker mode='multiSelector'onChange={(event) => this.handleOriginRangeChange(event)}onColumnChange={(event) => this.handleOriginRangleColumnChange(event)}range={orginRangeOptions}value={orginRangeKey}><AtList>{orgin ? (<AtListItemclassName='at-list__item-fix'extraText={orgin}/>) : (<Text className='input-placeholder-fix'>请选择访客来源地</Text>)}</AtList></Picker></View></View><View className='row'><AtInputrequiredtype='text'name='place'className='col'title='宗教场所'disabledplaceholder='请选择宗教场所'value={place}onChange={(value) => {this.handleChange('place', value)}}/></View><View className='row'><View className='at-input col col-fix'><Text className='at-input__title at-input__title--required'>预约日期</Text><Picker mode='date'onChange={(event) => this.handleDateChange('visiteDate', event)}><AtList>{visiteDate ? (<AtListItemclassName='at-list__item-fix'extraText={visiteDate}/>) : (<Text className='input-placeholder-fix'>请选择预约日期</Text>)}</AtList></Picker></View></View><View className='row'><View className='at-input col col-fix'><Text className='at-input__title at-input__title--required'>预约时间</Text><Picker mode='selector'range={timeRangeOptions}onChange={(event) => this.handlePickerChange('visiteTime', 'timeRangeOptions', event)}><AtList>{visiteTime ? (<AtListItemclassName='at-list__item-fix'extraText={visiteTime}/>) : (<Text className='input-placeholder-fix'>请选择预约时间</Text>)}</AtList></Picker></View></View><View className='row'><View className='at-input col col-fix'><Text className='at-input__title'>离开时间</Text><Picker mode='selector'range={timeRangeOptions}onChange={(event) => this.handlePickerChange('leaveTime', 'timeRangeOptions', event)}><AtList>{leaveTime ? (<AtListItemclassName='at-list__item-fix'extraText={leaveTime}/>) : (<Text className='input-placeholder-fix'>请选择离开时间</Text>)}</AtList></Picker></View></View><View className='row'><View className='col at-input'><Text className='at-input__title'>来访事由</Text><AtTextareamaxLength={200}className='textarea-fix'value={matter}onChange={(value) => {this.handleChange('matter', value)}}placeholder='请输入来访事由...'/></View></View><View className='row'><AtButtoncircleloading={isLoading}disabled={isLoading}type='primary'size='normal'formType='submit'className='col btn-submit'>提交</AtButton></View></AtForm></View>);}
}

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 (<View className='sms-box'><View className='row-inline'><AtInputrequiredtype='phone'name='mobile'title='手机号码'className='row-inline-col-7'placeholder='请输入手机号码'value={mobile}onChange={(value) => {this.handleChange('mobile', value)}}/>{!isClick ? ( <TextonClick={() => this.processSMSRequest()}className='row-inline-col-3 at-input__input code-fix'>发送验证码</Text>) : ( <TextonClick={() => this.processSMSRequest()}className='row-inline-col-3 at-input__input code-fix red'>{( smsCountDown === DEFAULT_SECOND ) ? '重新发送' : `${smsCountDown}秒后重试`}</Text>)}</View><View><AtInputrequiredtype='text'name='confirmCode'title='验证码'placeholder='请输入验证码'value={confirmCode}onChange={(value) => {this.handleChange('confirmCode', value)}}/></View><View><AtButtoncircletype='primary'size='normal'onClick={() => this.submit()}className='col btn-submit'>查询</AtButton></View></View>)}
}

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 (<View className='header'><Text className='header-text'>{ props.title }</Text><Text onClick={() => goToPage('index')}><AtIcon prefixClass='icon' className='iconfont header-reback' value='home' color='#6190e8'></AtIcon></Text></View>)
}

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 porthost: '127.0.0.1', // Redis hostpassword: 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

基于React和Node.JS的表单录入系统的设计与实现相关推荐

  1. 基于springboot+vue的开源自定义表单问卷系统

    一.项目简介 基于springboot+vue的开源自定义表单问卷系统 二.实现功能 支持表单拖拽 支持各种控件操作(基础组件.进阶组件等) 基础组件包含文本.多行文本.图片.图形.日历控件 支持拖拽 ...

  2. angular js创建表单_如何优雅的使用 Angular 表单验证

    随便说说,这一节可以跳过 去年参加 ngChine 2018 杭州开发者大会的时候记得有人问我: Worktile 是什么时候开始使用 Angular 的,我说是今年(2018年) 3 月份开始在新模 ...

  3. 基于node.js开发的聚美酒店住宿管理系统的设计(论文设计word文档)

    摘要 随着经济的发展,越来越多的群体依赖互联网,酒店行业也纷纷捉住机遇 展开新的运营方式--线上运营,取得了不错的成效并且大大节约了成本问题,与 此同时,聚美酒店作为大型企业之一,员工数量众多,但在住 ...

  4. Node.js毕业设计——基于Node.js+JavaScript+MongoDB的供求信息网站设计与实现(毕业论文+程序源码)——供求信息网站

    基于Node.js+JavaScript+MongoDB的供求信息网站设计与实现(毕业论文+程序源码) 大家好,今天给大家介绍基于Node.js+JavaScript+MongoDB的供求信息网站设计 ...

  5. iKcamp|基于Koa2搭建Node.js实战(含视频)☞ 处理静态资源

    视频地址:www.cctalk.com/v/151149238- 处理静态资源 无非花开花落,静静. 指定静态资源目录 这里我们使用第三方中间件: koa-static 安装并使用 安装 koa-st ...

  6. 基于Vue.js和Node.js的个人网盘系统——科技立项中期成果

    基于Vue.js和Node.js的个人网盘系统 个人网盘系统前端使用Vue.js框架,特别使用了Vue3支持的Element Plus组件库.后端使用Node.js提供服务.数据库使用MongoDB. ...

  7. JS 验证表单不能为空

    开发交流QQ群:  173683895   173683895   526474645  人满的请加其它群 JS 验证表单不能为空的简单demo,先看效果图 实现代码 <!--index.wxm ...

  8. iKcamp|基于Koa2搭建Node.js实战(含视频)☞ 路由koa-router

    路由koa-router--MVC 中重要的环节:Url 处理器 ?? iKcamp 制作团队 原创作者:大哼.阿干.三三.小虎.胖子.小哈.DDU.可木.晃晃 文案校对:李益.大力萌.Au.DDU. ...

  9. 使用React、Node.js、MongoDB、Socket.IO开发一个角色投票应用的学习过程(一)

    这几篇都是我原来首发在 segmentfault 上的地址:https://segmentfault.com/a/1190000005040834 突然想起来我这个博客冷落了好多年了,也该更新一下,呵 ...

最新文章

  1. 胜者树和败者树 - qianye0905 - 博客园
  2. 2021-07-29 labelme注释、分类和Json文件转化(转化成彩图mask)
  3. 中国是恶意程序感染率最高的国家
  4. boost::detail::sp_typeinfo用法实例
  5. Boost:双图bimap与range范围的测试程序
  6. 沃尔玛招聘.NET软件工程师
  7. 2020年周记(1/50)
  8. 查询php 输出表格,php输出excel表格数据-PHP如何将查询出来的数据导出成excel表格(最好做......
  9. python中dtype什么意思_浅谈python 中的 type(), dtype(), astype()的区别
  10. 7个前端新手常见误区,千万要避开!
  11. LAMP详细搭建步骤
  12. 用matlab画圆极化波,应用HFSS-MATLAB-API设计圆极化微带天线
  13. 旅游B2B2C系统解决方案
  14. Xmarks浏览器书签同步的末日临近
  15. 边境的悍匪—机器学习实战:第八章 降维
  16. qq机器人插件之奥运奖牌获得数量
  17. 基于vue的移动端Icon图标拖拽(改变定位和使用transform)
  18. 工厂模式的缺点及解决到生产的应用
  19. 涂鸦LZ201-CN开发板学习笔记(一)
  20. 云联惠系统在微商行业的影响力有多大

热门文章

  1. CVE-2017-0144 EternalBlue(永恒之蓝)漏洞分析
  2. mysql 组复制详解,MySQL组复制:魔力解释v2
  3. VS Code 设置缩进为4个空格
  4. win10高危服务_win10优化:可以禁用的服务
  5. matlab使用pdfbox,PDFBox编写PDF文档
  6. JDK环境变量配置.kk
  7. html+js实现手机浏览器的滑动验证
  8. [projecteuler]Counting Sundays
  9. 服务器草稿位置6,【记录】寻找Thunderbird中邮件的草稿保存的位置
  10. 我的读书历程及书单整理