基於React和Node.JS的表單錄入系統的設計與實現
一、寫在前面
這是一個真實的項目,項目已經過去好久了,雖然很簡單,但還是有很多思考點,跟隨著筆者的腳步,一起來看看吧。本文純屬虛構,涉及到的相關資訊均已做虛構處理,
二、背景
人活著一定要有信仰,沒有信仰的靈魂是空洞的。你可以信耶穌,信佛,信伊斯蘭,信科學等等。為了管控各大宗教場所的人員聚集,為社會增添一份綿薄之力,京州領導決定做一個表單系統來統計某個時間或者時間段的人員訪問量,控制宗教人員活動的範圍,漢東省委沙瑞金書記特別關心這件事決定親自檢查,幾經周轉,這個任務落到了程式設計師江濤的頭上,故事由此展開。
三、需求分析
大致需要實現如下功能
- 表單數據的錄入
- 錄入數據的最近記錄查詢
- 簡訊驗證碼的使用
- 掃碼填寫表單資訊
有兩種方案, 一種是進去自己選擇對應的宗教場所(不對稱分布三級聯動),第二種是點擊對應的宗教場所進行填寫表單,表單處的場所不可更改,不同的設計不同的思路。 雖然兩種都寫了, 但這裡我就按第二種寫這篇文章,如果有興趣了解第一種歡迎與我交流。
四、系統設計
這次我決定不用vue,改用react的taro框架寫這個小項目(試一下多端框架taro哈哈), 後端這邊打算用nodejs的eggjs框架, 資料庫還是用mysql, 還會用到redis。由於伺服器埠限制,搞不動docker啊, 也沒有nginx,莫得關係,egg自帶web伺服器將就用一下項目也就做完了,就這樣taro和egg的試管嬰兒誕生了。
五、程式碼實現
額,東西又多又雜,挑著講吧, 建議結合這兩篇篇文章一起看, 基於Vue.js和Node.js的反欺詐系統設計與實現 //www.cnblogs.com/cnroadbridge/p/15182552.html, 基於React和GraphQL的demo設計與實現 //www.cnblogs.com/cnroadbridge/p/15318408.html
5.1 前端實現
taroJS的安裝使用參見//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}/>
<AtForm
onSubmit={this.onSubmit.bind(this)}
>
<View className='row'>
<AtInput
required
type='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}>
<Radio
value={genderOption.value}
onClick={(event) => {this.handleRadioClick('gender', genderOption.value)}}>
{genderOption.label}
</Radio>
</Label>
)
})}
</RadioGroup>
</View>
</View>
</View>
<View className='row'>
<AtInput
required
type='phone'
name='mobile'
title='手機號碼'
className='col'
placeholder='請輸入手機號碼'
value={mobile}
onChange={(value) => {this.handleChange('mobile', value)}}
/>
</View>
<View className='row'>
<AtInput
required
name='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 ? (
<AtListItem
className='at-list__item-fix'
extraText={orgin}
/>) : (<Text className='input-placeholder-fix'>請選擇訪客來源地</Text>)}
</AtList>
</Picker>
</View>
</View>
<View className='row'>
<AtInput
required
type='text'
name='place'
className='col'
title='宗教場所'
disabled
placeholder='請選擇宗教場所'
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 ? (
<AtListItem
className='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 ? (
<AtListItem
className='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 ? (
<AtListItem
className='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>
<AtTextarea
maxLength={200}
className='textarea-fix'
value={matter}
onChange={(value) => {this.handleChange('matter', value)}}
placeholder='請輸入來訪事由...'
/>
</View>
</View>
<View className='row'>
<AtButton
circle
loading={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'>
<AtInput
required
type='phone'
name='mobile'
title='手機號碼'
className='row-inline-col-7'
placeholder='請輸入手機號碼'
value={mobile}
onChange={(value) => {this.handleChange('mobile', value)}}
/>
{!isClick ? ( <Text
onClick={() => this.processSMSRequest()}
className='row-inline-col-3 at-input__input code-fix'>
發送驗證碼
</Text>) : ( <Text
onClick={() => this.processSMSRequest()}
className='row-inline-col-3 at-input__input code-fix red'>
{( smsCountDown === DEFAULT_SECOND ) ? '重新發送' : `${smsCountDown}秒後重試`}
</Text>)}
</View>
<View>
<AtInput
required
type='text'
name='confirmCode'
title='驗證碼'
placeholder='請輸入驗證碼'
value={confirmCode}
onChange={(value) => {this.handleChange('confirmCode', value)}}
/>
</View>
<View>
<AtButton
circle
type='primary'
size='normal'
onClick={() => this.submit()}
className='col btn-submit'>
查詢
</AtButton>
</View>
</View>
)
}
}
5.1.4 前端的一些配置
路由跳頁模組的封裝
import Taro from '@tarojs/taro';
// //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 = '//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');
};
阿里媽媽圖標庫引入, 打開//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 port
host: '127.0.0.1', // Redis host
password: null,
db: 0,
},
};
效果是這樣的
六、參考文獻
- TaroJS官網: //taro-docs.jd.com/taro/docs/README
- ReactJS官網: //reactjs.org/
- eggJS官網: //eggjs.org/
七、寫在最後
到這裡就要和大家說再見了, 通過閱讀本文,對於表單的製作你學會了嗎?歡迎在下方發表你的看法,也歡迎和筆者交流!
github項目地址://github.com/cnroadbridge/jingzhou-religion
gitee項目地址: //gitee.com/taoge2021/jingzhou-religion