從零開始,搭建一個簡單的購物平台(十八)前端商城部分:
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加密密碼