从零开始,搭建一个简单的购物平台(十八)前端商城部分:
https://blog.csdn.net/time_____/article/details/108918489
项目源码(持续更新):https://gitee.com/DieHunter/myCode/tree/master/shopping
上篇文章后,前端商城部分基本功能已实现,包括商品列表,商品分类,首页商品展示,商品详情,购物车,用户登录注册,剩余内容:用户信息修改,提交订单,订单展示等,这篇文章将对剩余功能部分完结。
用户信息修改的后端接口已经在管理平台实现,这里直接进行验证调用即可
之前的修改用户信息功能在测试中体现出来了一个bug,因为生成Token的字段是用户名,当修改用户信息时,如果修改了用户名,就会导致token验证失败,于是我们需要修复token生成方式,将之前的用户名生成改成_id生成,新版代码已经提交至码云
修复后效果:
下面介绍一下实现流程 ,这里我们把info用户信息界面和登录界面放在单页面中,通过v-if条件渲染,条件是checkToken是否通过
bussiness.js,验证token是否有效
import Vue from "vue"; import config from "../../config/config"; const { ServerApi, StorageName } = config; export default class UserInfoBussiness extends Vue { constructor(_vueComponent) { super(); this.vueComponent = _vueComponent; } checkToken() {//验证Token函数,若token正确,则直接登录成功,若未成功,则切换至登录界面 let token = this.$storage.getStorage(StorageName.Token); if (!token || !token.length) return; this.$axios .get(ServerApi.token, { params: { token } }) .then(res => { switch (res.result) { case -999://token请求抛发错误,token过期或错误 this.vueComponent.isLogin = false;//显示登录页面 this.$storage.clearStorage(StorageName.Token);//清除之前的token break; case 1://验证token成功 this.vueComponent.userInfo = res.data; this.vueComponent.isLogin = true;//显示个人信息页面 break; default: this.vueComponent.isLogin = false; this.$storage.clearStorage(StorageName.Token); break; } }) .catch(err => {}); } }
info.vue组件
<template> <div> <Top :title="isLogin?'我的':'登录'"></Top> <div class="content"> <UserInfo v-if="isLogin" :userInfo="userInfo"></UserInfo> <Login v-else></Login> </div> <TabBar></TabBar> </div> </template> <script> import UserInfoBussiness from "./bussiness"; import TabBar from "../../components/tabBar/tabBar"; import UserInfo from "../../components/userInfo/userInfo"; import Login from "../../components/login/login"; import Top from "../../components/top/top"; import config from "../../config/config"; const { EventName } = config; export default { components: { Top, UserInfo, Login, TabBar }, data() { return { isLogin: false, userInfoBussiness: null, userInfo: null }; }, created() { this.userInfoBussiness = new UserInfoBussiness(this); this.$events.onEvent(EventName.IsLogin, () => { this.userInfoBussiness.checkToken();//退出登录响应事件,重重页面 }); this.userInfoBussiness.checkToken();//初始化先验证token }, destroyed() { this.$events.offEvent(EventName.IsLogin); } }; </script> <style lang="less" scoped> @import "../../style/init.less"; </style>
在用户登录成功后,我们需要一个组件显示用户信息,这个没有任何逻辑,纯渲染,所以暂不做介绍
<template> <ul class="userInfo"> <router-link to="/UpdateInfo"> <li> <img :src="imgPath+userInfo.headPic" alt /> <span>{{userInfo.username}}</span> <div class="iconfont icon-fanhui"></div> </li> </router-link> <li> <mt-cell :title="userInfo.phoneNum"></mt-cell> <mt-cell :title="userInfo.mailaddress+userInfo.mailurl"></mt-cell> <mt-cell :title="userInfo.alladdress.join('-')+'-'+userInfo.address"></mt-cell> <mt-cell :title="userInfo.descript"></mt-cell> </li> </ul> </template> <script> import Config from "../../config/config"; const { RequestPath, StorageName } = Config; import { Cell } from "mint-ui"; export default { name: "userinfotop", props: ["userInfo"],//父组件传递用户信息至当前组件,并渲染 data() { return { imgPath: RequestPath }; }, created() { this.$storage.saveStorage(StorageName.UserInfo, this.userInfo); } }; </script> <style lang="less" scoped> @import "../../style/init.less"; .userInfo { li:nth-child(1) { .h(230); width: 100%; .mcolor(); .l_h(230); margin-top: -1px; color: #fff; > img, > span { display: inline-block; vertical-align: middle; margin-left: unit(40 / @pxtorem, rem); } > img { .w(145); .h(145); border-radius: 100%; } > span { .f_s(40); } > div { height: 100%; float: right; padding-left: unit(40 / @pxtorem, rem); transform: rotateY(180deg); } } } </style>
通过点击头像框路由跳转至UpdateInfo,用户信息修改页,我们将头像上传单独写成组件
这里有一个原生js上传文件的坑:
axios上传post文件头文件需模拟 "multipart/form-data"请求,而这种请求格式与application/x-www-form-urlencoded有所不同,需要声明一个分隔符‘boundary’。
headers: { "Content-Type": "multipart/form-data;boundary=ABC"//ABC内容自行填写 },
那么这时,坑人的地方来了,直接以ABC这种简单的分隔符内容上传文件可能会导致服务端对文件不识别,无法找到文件起始位置,所以我们需要一个复杂的字符,比如使用new Date().getTime()生成随机字符,修改后就有以下配置
headers: { "Content-Type": "multipart/form-data;boundary=" + new Date().getTime() },
上传头像组件中,我们要自己写一个控件替代官方的input元素,也就是点击图片使用JS执行input文件上传事件,并提交到服务端,服务端存好缓存后将图片文件地址发送到前端,前端读取文件并展示,以下是头像上传的所有过程
uploadPic.vue
<template> <div class="uploadPic"> <img :src="picPath" @click="clickHandler" alt /> <input class="picFile" id="picFile" type="file" @change="uploadPic" accept="image/*" /> </div> </template> <script> import Config from "../../config/config"; import UploadBussiness from "./bussiness"; const { StorageName, RequestPath, UploadKey } = Config; export default { name: "uploadPic", props: ["picFile"], data() { return { imgPath: RequestPath, picPath: "" }; }, created() { this.picPath = this.imgPath + this.picFile; this._uploadBussiness = new UploadBussiness(this); }, methods: { clickHandler() {//点击头像模拟至点击文件上传input-file标签 document.querySelector("#picFile").click(); }, uploadPic(e) { let _picFile = new FormData();//新建FormData文件 _picFile.append("token", this.$storage.getStorage(StorageName.Token));//将token添加至文件属性中 _picFile.append(UploadKey.headKey, e.target.files[0]);//文件校验字段 this._uploadBussiness.uploadPic(_picFile);//上传文件 } } }; </script> <style lang="less" scoped> @import "../../style/init.less"; .uploadPic { img { width: 100%; height: 100%; } .picFile { display: none; } } </style> bussiness.js import Vue from 'vue' import config from "../../config/config" import { Toast } from "mint-ui"; const { UploadName, EventName, UploadKey } = config export default class UploadBussiness extends Vue { constructor(_vueComponent) { super() this.vueComponent = _vueComponent } uploadPic(data) { this.$axios .post(UploadName.headPic, data, { headers: { "Content-Type": "multipart/form-data;boundary=" + new Date().getTime()//axios上传post文件头文件需模拟 "multipart/form-data"请求,而这种请求格式与application/x-www-form-urlencoded有所不同,需要声明一个分隔符‘boundary’。 }, }).then(res => { Toast(res.msg); switch (res.result) { case 1://上传成功后显示图片 let fileRead = new FileReader();//新建文件读取实例 fileRead.readAsDataURL(data.get(UploadKey.headKey));//readAsDataURL读取本地图片信息 fileRead.onload = () => { this.vueComponent.picPath = fileRead.result } this.$events.emitEvent(EventName.UploadPic, res.headPath) break; default: break; } }) } }
说完了上传头像组件后,来实现一下修改用户信息,之前上传的头像地址会通过组件传参传递到父组件中,伴随着其他信息一起提交到服务端,服务端将收到的头像缓存地址解析成文件并保存,修改用户信息组件中可以复用一个省市县选择器组件,即之前在商品详情中使用的商品数量选择,其他的表单元素都是基本的文本类型
updataForm.vue <template> <div class="update"> <!-- <img :src="imgPath+userInfo.headPic" alt /> --> <UploadPic class="uploadPic" :picFile="userInfo.headPic"></UploadPic> <mt-field placeholder="请输入用户名" :state="userInfo.username.length?'success':'error'" v-model="userInfo.username" ></mt-field> <mt-field placeholder="请输入手机号" :state="userInfo.phoneNum.length?'success':'error'" v-model="userInfo.phoneNum" type="number" ></mt-field> <mt-radio v-model="userInfo.sex" :options="sexOption"></mt-radio> <mt-button class="btn" @click="selectAddress">{{userInfo.alladdress.join('-')}}</mt-button> <mt-field placeholder="请输入详细地址" :state="userInfo.address.length?'success':'error'" v-model="userInfo.address" ></mt-field> <mt-field placeholder="请输入个性签名" :state="userInfo.descript.length?'success':'error'" v-model="userInfo.descript" ></mt-field> <mt-button class="submit" type="primary" @click="submit">修改信息</mt-button> <div class="shopPicker"> <mt-popup v-model="popupVisible" position="bottom"> <mt-picker :slots="myAddressSlots" value-key="name" :visibleItemCount="7" @change="changeAddress" ></mt-picker> </mt-popup> </div> </div> </template> <script> import UpdateBussiness from "./bussiness"; import Config from "../../config/config"; import { Field, Button, Picker, Popup, Radio } from "mint-ui"; import address from "../../config/city"; import UploadPic from "../uploadPic/uploadPic"; const { StorageName, RequestPath, EventName } = Config; export default { name: "updateForm", data() { return { imgPath: RequestPath, updateBussiness: null, popupVisible: false,//控制picker显示 selectArea: null, sexOption: [//性别配置 { label: "男", value: "man" }, { label: "女", value: "woman" } ], myAddressSlots: [//省市县联动配置 { flex: 1, defaultIndex: 0, values: [], className: "slot1", textAlign: "center" }, { divider: true, content: "-", className: "slot2" }, { flex: 1, values: [], className: "slot3", textAlign: "center" }, { divider: true, content: "-", className: "slot4" }, { flex: 1, values: [], className: "slot5", textAlign: "center" } ], userInfo: this.$storage.getStorage(StorageName.UserInfo)//获取缓存的用户信息,用于显示默认项 }; }, components: { UploadPic }, created() { this.$events.onEvent(EventName.UploadPic, headPic => {//上传头像后将新地址保存至当前组件 this.userInfo.headPic = headPic; }); this.updateBussiness = new UpdateBussiness(this); }, destroyed() { this.$events.offEvent(EventName.UploadPic); }, methods: { selectAddress() {//显示picker this.myAddressSlots[0].values = address; this.popupVisible = true; }, changeAddress(picker, values) {//三级联动 if (values[0]) { this.userInfo.alladdress = [values[0].name]; picker.setSlotValues(1, values[0].children); if (values[1]) { this.userInfo.alladdress.push(values[1].name); picker.setSlotValues(2, values[1].children); if (values[2]) { this.userInfo.alladdress.push(values[2].name); } } } }, submit() { this.updateBussiness.submitData();//提交信息 } } }; </script> <style lang="less" scoped> @import "../../style/init.less"; .update { .uploadPic { overflow: hidden; .w(200); .h(200); .mg(unit(30 / @pxtorem, rem) auto); border-radius: 100%; } .btn { width: 100%; .h(100); background: #fff; } .submit { margin-top: unit(30 / @pxtorem, rem); width: 100%; // z-index: 100; } } </style> bussiness.js import Vue from 'vue' import config from "../../config/config" import { Toast } from "mint-ui"; const { ServerApi, StorageName, EventName } = config export default class UpdateBussiness extends Vue { constructor(_vueComponent) { super() this.vueComponent = _vueComponent } submitData() { for (const key in this.vueComponent.userInfo) {//表单非空判断 let value = this.vueComponent.userInfo[key] if (!value.length && value != true && value != 0 && typeof value == 'string') { Toast('请填写完整的信息'); return } } this.$axios .post(ServerApi.user.updateUser, { crypto: this.$crypto.setCrypto({ token: this.$storage.getStorage(StorageName.Token), ...this.vueComponent.userInfo }) }).then(res => { switch (res.result) { case 1: Toast(res.msg); history.go(-1) break; default: break; } }) } }
用户信息修改就介绍到这里,下一步将对项目的最后一步订单的前端部分进行分享
订单的后端逻辑与接口在管理系统中已经介绍完毕,前端部分就是很简单的数据渲染和状态修改
首先,订单是基于用户和商品绑定的,所以,我们在购物车中实现新增订单功能,添加成功后跳转至订单查询界面,除此之外,在用户信息界面,添加用户的所有订单列表可以查看和付款(由于只是一个项目案例,所以这里没有实现支付功能)
orderList.vue组件,几乎都是页面渲染,没有什么逻辑功能,就不做说明
<template> <div class="content"> <div class="orderTop"> <div> <div> <p class="fontcl"> 下单时间: <span>{{new Date(orderList.orderTime).toLocaleString()}}</span> </p> <p class="fontcl"> 订单编号: <span>{{orderList.orderId}}</span> </p> </div> <div :class="orderList.orderState==0?'noPay':orderList.orderState==4?'isFinish':'isPay'" >{{orderState[orderList.orderState||0].name}}</div> </div> <div> <div> <span class="icon-yonghuming iconfont">{{orderList.username}}</span> <span class="icon-shoujihao iconfont">{{orderList.phoneNum}}</span> </div> <div class="fontcl">{{orderList.address}}</div> </div> </div> <ul class="orderList"> <li v-for="(item,index) in orderList.shopList" :key="index"> <img :src="imgPath+item.shopPic" alt /> <div> {{item.shopName+item.shopScale}} <br /> ¥{{item.shopPrice}} </div> <span>×{{item.shopCount}}</span> </li> </ul> <div class="submitOrder"> <span>付款合计:¥{{orderList.orderPrice}}</span> <span @click="submitOrder" v-show="orderList.orderState==0">去付款</span> </div> </div> </template> <script> import OrderBussiness from "./bussiness"; import Config from "../../config/config"; import ShopType from "../../config/shopType"; export default { name: "orderList", data() { return { orderState: ShopType.orderState, imgPath: Config.RequestPath, orderList: [],//订单详情 orderBussiness: null, }; }, created() { this.orderBussiness = new OrderBussiness(this); this.orderBussiness.getOrderList(); }, methods: { submitOrder() { this.orderBussiness.sendOrderPay(this.orderList);//支付 }, }, }; </script> <style lang="less" scoped> @import "../../style/init.less"; .content { font-size: unit(32 / @pxtorem, rem); .fontcl { .cl(#979797); } .orderTop { > div { padding-left: unit(35 / @pxtorem, rem); padding-right: unit(35 / @pxtorem, rem); } > div:nth-child(1) { .h(160); border-bottom: unit(3 / @pxtorem, rem) solid #e8e8e8; > div:nth-child(1) { float: left; p { .l_h(80); span { .cl(#000); } } } > div:nth-child(2) { float: right; .h(160); .l_h(160); } .isFinish { .cl(@mainColor); } .isPay { .cl(#000); } .noPay { .cl(#A71A2D); } } > div:nth-child(2) { .h(180); border-bottom: unit(30 / @pxtorem, rem) solid #f3f3f3; > div:nth-child(1) { overflow: hidden; .l_h(100); span:nth-child(1) { float: left; } span:nth-child(2) { float: right; } } > div:nth-child(2) { width: 100%; } } } .orderList { li { .h(250); padding-left: unit(20 / @pxtorem, rem); padding-right: unit(35 / @pxtorem, rem); > div, > span, img { display: inline-block; vertical-align: middle; } img { .w(220); .h(220); margin-right: unit(30 / @pxtorem, rem); } > div { .l_h(60); } > span { vertical-align: top; margin-top: unit(50 / @pxtorem, rem); float: right; } } } .submitOrder { .h(130); width: 100%; position: fixed; bottom: 0; background: #fff; border-top: unit(3 / @pxtorem, rem) solid #cdcdcd; span:nth-child(1) { float: left; .pd(unit(40 / @pxtorem, rem)); .cl(#852332); } span:nth-child(2) { .mcolor(); .pd(unit(45 / @pxtorem, rem) unit(110 / @pxtorem, rem)); float: right; .cl(#fff); } } } </style>
获取订单列表和提交订单支付状态的bussiness.js
import Vue from "vue"; import { MessageBox } from "mint-ui"; import config from "../../config/config"; import Clone from "../../utils/clone"; const { ServerApi, StorageName, EventName, DefaultPageConfig } = config; export default class OrderBussiness extends Vue { constructor(_vueComponent) { super(); this.vueComponent = _vueComponent; this._defaultPageConfig = Clone.shallowClone(DefaultPageConfig); } getOrderList() {//获取个人订单信息列表 this._defaultPageConfig.token = this.$storage.getStorage(StorageName.Token); this._defaultPageConfig.orderId = this.vueComponent.$route.query.orderId; this.$axios .get(ServerApi.order.orderList, { params: { crypto: this.$crypto.setCrypto(this._defaultPageConfig) } }) .then(res => { switch (res.result) { case 1: this.vueComponent.orderList = res.data.list[0]; break; default: break; } }); } sendOrderPay(data) { MessageBox("提示", "本案例仅为参考,未开通支付功能"); data.orderState = 1;//修改订单状态为已支付 data.token = this.$storage.getStorage(StorageName.Token); this.$axios .post(ServerApi.order.updateOrder, { crypto: this.$crypto.setCrypto(data) }) .then(res => { switch (res.result) { case 1: break; default: break; } }); } }
订单功能完成
项目整体打包
通过运行 npm run build 进行webpack打包
生产环境部署可以参照我之前的一篇文章
如果需要配置https环境可以参照这篇文章
文件夹的命名规则以及模块组件的分配在这篇文章有说到
希望这个系列的文章对你有帮助,如果你阅读完了整个系列或者某篇文章,非常感谢你的支持
总结:到这篇博客为止,《从零开始,搭建一个简单的购物平台》系列的文章全部完结,以下是本人完成整个项目的一个小总结以及一些注意点:
搭建环境及配置文件:对自己的技术栈以及优势需要深入了解,并且选择最适合自己或者是产品需求所需要的技术,完成项目目录的搭建,比如前端最好养成模块化,组件化开发的习惯,尽量将文件夹以及文件细分到每个基本组件。
以组件和框架的官方文档为核心,学会自己上网查找问题,自己动手解决问题非常有必要。
学会造轮子,虽然网上有大量的框架,组件,别人写好的js库,但是自己动手写函数,封装功能以及组件是非常有必要的,并不是节省时间或者其他方面的原因,自己写能提升自己编程思路和实际应用能力,而且当自己写出了一个比较成功的类或者组件,甚至方法时,会有很大的成就感
面向对象编程语言,减少代码耦合度,提高内聚性,使代码健壮性更加强大,这点我自己正在努力改善,这样写代码有利于把很多方法剥离,可以提升复用性,减少代码量,说白了,一个项目别人可能只需要3000行代码,而我可能需要5000行
这个项目我是全栈完成的,采用的是前后端分离,但是实际开发中,前后端可能是两个或者多个人开发,这时需要自测接口及功能,前端搭建mock.js或使用easymock来进行模拟请求,后端可以使用postman,SoapUI等工具进行接口访问
前端和后端需要防止多次重复请求,前端通过节流的方式,防止对后端重复请求,但是也要防止数据库的恶意攻击(这个项目中没有实现),通过参数附带时间戳,使一个ip或者一个用户只能在短时间内请求规定次数
巧用前后端缓存,前端使用cookie和localstorage,后端生成temp缓存文件
前后端加密处理,token,Crypto加密参数,Bcrypt加密密码