vue.js 2 系列之六 运动商店 A Real App 2
下单
基于之前一篇基础之上进行构建
创建购物车预置
在src/components文件夹下新建ShoppingCart.vue文件,其内容如下:
<template>
<h4 class="bg-primary text-white text-center p-2">
placeholder for Cart
</h4>
</template>
配置url路由
在src/router文件夹中添加index.js文件中添加如下内容:
import Vue from "vue";
import VueRouter from "vue-router";
import Store from "../components/Store";
import ShoppingCart from "../components/ShoppingCart";
Vue.use(VueRouter);
export default new VueRouter({
mode: "history",
routes: [
{path:"/", component: Store},
{path: "/cart", component: ShoppingCart},
{path: "*", redirect: "/"}
]
})
将上面的路由文件添加到main.js中
import Vue from 'vue'
import App from './App.vue'
// 引入jquery
import $ from 'jquery'
Vue.config.productionTip = false
// 添加bootstrap框架
import "bootstrap/dist/css/bootstrap.min.css";
import "font-awesome/css/font-awesome.min.css";
import store from "./store";
import router from "./router";
new Vue({
render: h => h(App),
store,
router
}).$mount('#app')
添加路由组件
在App.vue文件中添加如下内容:
<template>
<router-view />
</template>
<script>
// import Store from './components/Store';
import {
mapActions
} from "vuex";
export default {
name: 'App',
// components: {Store},
methods: {
...mapActions({
getData: "getData"
})
},
created() {
this.getData();
}
}
</script>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
margin-top: 60px;
}
</style>
实现购物车功能
添加数据存储模块
在src/store文件夹中添加cart.js文件,其内容为:
export default {
namespaced: true,
state: {
lines: []
},
getters:{
itemCount: state => state.lines.reduce((total, line) => total + line.quantity, 0),
totalPrice: state => state.lines.reduce((total, line) => total + (line.quantity * line.product.price), 0),
},
mutations:{
addProduct(state, product){
let line = state.lines.find(line => line.product.id == product.id);
if(line != null){
line.quantity++;
}else{
state.lines.push({product: product, quantity: 1});
}
},
changeQuantity(state, update){
update.line.quantity = update.quantity;
},
removeProduct(state, lineToRemove){
let index = state.lines.findIndex(line => line == lineToRemove);
if(index > -1){
state.lines.splice(index, 1);
}
}
}
}
在src/store文件夹的index.js中添加购物车模块
import Vue from "vue";
import Vuex from "vuex";
import Axios from "axios";
import CartModule from "./cart";
Vue.use(Vuex);
const baseUrl = "http://localhost:3500";
const productsUrl = `${baseUrl}/products`;
const categoriesUrl = `${baseUrl}/categories`;
const testData = [];
for(let i = 1; i <= 10; i++){
testData.push({
id: i, name: `Product #${i}`, category: `Category ${i % 3}`,
description: `This is Product #${i}`, price: i * 50
})
}
export default new Vuex.Store({
strict: true,
modules: {cart: CartModule }, // 添加购物车模块
state: {
products: testData,
categoriesData: [],
productsTotal: testData.length,
currentPage: 1,
pageSize: 4,
currentCategory: "All"
},
getters: {
productsFilteredByCategory: state => state.products.filter(p => state.currentCategory == "All" || p.category == state.currentCategory),
// 根据当前分页以及每页个数返回当前页面产品列表
processedProducts: (state, getters) => {
let index = (state.currentPage - 1) * state.pageSize;
return getters.productsFilteredByCategory.slice(index, index + state.pageSize);
},
// 计算分页总数
pageCount: (state, getters) => Math.ceil(getters.productsFilteredByCategory.length / state.pageSize),
categories: state => ["All", ...state.categoriesData]
},
mutations: {
// 设置当前分页
setCurrentPage(state, page){
state.currentPage = page;
},
// 设置每页个数
setPageSize(state, size){
state.pageSize = size;
state.currentPage = 1;
},
// 设置当前分类
setCurrentCategory(state, category){
state.currentCategory = category;
state.currentPage = 1;
},
setData(state, data){
state.products = data.pdata;
state.productsTotal = data.pdata.length;
state.categoriesData = data.cdata.sort();
}
},
actions: {
async getData(context) {
let pdata = (await Axios.get(productsUrl)).data;
let cdata = (await Axios.get(categoriesUrl)).data;
console.log(pdata, cdata);
context.commit("setData", {pdata, cdata});
}
}
})
添加商品选择功能
在src/components中的ProductList.vue中添加如下内容:
<template>
<div>
<div v-for="p in products" v-bind:key="p.id" class="card m-1 p-1 bg-light">
<h4>
{{p.name}}
<span class="badge badge-pill badge-primary float-right">
<!--使用过滤函数 -->
{{p.price | currency}}
</span>
</h4>
<div class="card-text bg-white p-1">
{{p.description}}
<button class="btn btn-success btn-sm float-right" v-on:click="handleProductAdd(p)">Add To Cart</button>
</div>
</div>
<page-controls />
</div>
</template>
<script>
import {
mapGetters,
mapMutations // 导入
} from "vuex";
import PageControls from "./PageControls";
export default {
components: {
PageControls
},
computed: {
...mapGetters({
products: "processedProducts"
})
},
// 添加过滤函数,格式化价格
filters: {
currency(value) {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD"
}).format(value);
}
},
methods: {
...mapMutations({
addProduct: "cart/addProduct"
}),
handleProductAdd(product) {
this.addProduct(product);
this.$router.push("/cart");
}
}
}
</script>
显示购物车内容
在src/components文件夹的ShoppingCartLine.vue文件中添加如下内容:
<template>
<tr>
<td>
<input type="number" class="form-control-sm" style="width:5em" v-bind:value="qvalue" v-on:input="sendChangeEvent" />
</td>
<td>
{{ line.product.name}}
</td>
<td class="text-right">{{line.product.price | currency}}</td>
<td class="text-right">
{{(line.quantity * line.product.price) | currency }}
</td>
<td class="text-center">
<button class="btn btn-sm btn-danger" v-on:click="sendRemoveEvent">Remove</button>
</td>
</tr>
</template>
<script>
export default {
props: ["line"],
data: function () {
return {
qvalue: this.line.quantity
}
},
methods: {
sendChangeEvent($event) {
if ($event.target.value > 0) {
this.$emit("quantity", Number($event.target.value));
this.qvalue = $event.target.value;
} else {
this.$emit("quantity", 1);
this.qvalue = 1;
$event.target.value = this.qvalue;
}
},
sendRemoveEvent() {
this.$emit("remove", this.line);
}
}
}
</script>
- props:声明父级组件中的变量到当前组件中,是单向数据流,所有的prop都使得其父级prop的更新会向下流动到子组件中,反之则不行,所以子组件也不应该改变其内部的prop的值
- this.$emit 表示发送通知事件
现在可以在src/components文件夹的ShoppingCart.vue文件中添加购物内容:
<template>
<div class="container-fluid">
<div class="row">
<div class="col bg-dark text-white">
<a class="navbar-brand">SPORTS STORE</a>
</div>
</div>
<div class="row">
<div class="col mt-2">
<h2 class="text-center">Your Cart</h2>
<table class="table table-bordered table-triped p-2">
<thead>
<tr>
<th>Quantity</th>
<th>Product</th>
<th class="text-right">Price</th>
<th class="text-right">Subtotal</th>
</tr>
</thead>
<tbody>
<tr v-if="lines.length == 0">
<td colspan="4" class="text-center">Your cart is empty</td>
</tr>
<cart-line v-for="line in lines" v-bind:key="line.product.id" v-bind:line="line" v-on:quantity="handleQuantityChange(line, $event)" v-on:remove="remove" />
</tbody>
<tfoot v-if="lines.length > 0">
<tr>
<td colspan="3" class="text-right">Total:</td>
<td class="text-right">{{totalPrice | currency}}</td>
</tr>
</tfoot>
</table>
</div>
</div>
<div class="row">
<div class="col">
<div class="text-center">
<router-link to="/" class="btn btn-secondary m-1">Continue Shopping</router-link>
<router-link to="/checkout" class="btn btn-primary m-1" v-bind:disabled="lines.length==0">Checkout</router-link>
</div>
</div>
</div>
</div>
</template>
<script>
import {
mapState,
mapMutations,
mapGetters
} from "vuex";
import CartLine from "./ShoppingCartLine";
export default {
components: {
CartLine
},
computed: {
...mapState({
lines: state => state.cart.lines
}),
...mapGetters({
totalPrice: "cart/totalPrice"
})
},
methods: {
...mapMutations({
change: "cart/changeQuantity",
remove: "cart/removeProduct"
}),
handleQuantityChange(line, $event) {
this.change({
line,
quantity: $event
});
}
},
}
</script>
- v-bind:line指令,用于设置line prop的值
- v-on指令用于接收自定义事件
- router-link标签,用于生成导航元素
- to:用于绑定路由地址
创建全局过滤
在main.js文件中添加如下内容:
import Vue from 'vue'
import App from './App.vue'
// 引入jquery
import $ from 'jquery'
Vue.config.productionTip = false
// 添加bootstrap框架
import "bootstrap/dist/css/bootstrap.min.css";
import "font-awesome/css/font-awesome.min.css";
import store from "./store";
import router from "./router";
// 添加全局过滤
Vue.filter("currency", (value) => new Intl.NumberFormat("en-US", {style: "currency", currency: "USD"}).format(value));
new Vue({
render: h => h(App),
store,
router
}).$mount('#app')
测试购物车基本功能
将购物车数据持久化
在src/store文件夹中的cart.js文件中添加如下内容:
export default {
namespaced: true,
state: {
lines: []
},
getters:{
itemCount: state => state.lines.reduce((total, line) => total + line.quantity, 0),
totalPrice: state => state.lines.reduce((total, line) => total + (line.quantity * line.product.price), 0),
},
mutations:{
addProduct(state, product){
let line = state.lines.find(line => line.product.id == product.id);
if(line != null){
line.quantity++;
}else{
state.lines.push({product: product, quantity: 1});
}
},
changeQuantity(state, update){
update.line.quantity = update.quantity;
},
removeProduct(state, lineToRemove){
let index = state.lines.findIndex(line => line == lineToRemove);
if(index > -1){
state.lines.splice(index, 1);
}
},
setCartData(state, data) {
state.lines = data;
}
},
actions: {
loadCartData(context){
let data = localStorage.getItem("cart");
if(data != null){
context.commit("setCartData", JSON.parse(data));
}
},
storeCartData(context){
localStorage.setItem("cart", JSON.stringify(context.state.lines));
},
clearCartData(context){
context.commit("setCartData", []);
},
initializeCart(context, store){
context.dispatch("loadCartData");
store.watch(state => state.cart.lines, () => context.dispatch("storeCartData"), {deep: true});
}
}
}
- context.dispatch(),当数据改变时,调用storeCartData动作,deep为true表示当state.cart.lines数组任何属性改变时请通知我
接下来在App.vue中添加初始化购物车代码
<template>
<router-view />
</template>
<script>
// import Store from './components/Store';
import {
mapActions
} from "vuex";
export default {
name: 'App',
// components: {Store},
methods: {
...mapActions({
getData: "getData",
initializeCart: "cart/initializeCart"
})
},
created() {
this.getData();
// 初始化购物车
this.initializeCart(this.$store);
}
}
</script>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
margin-top: 60px;
}
</style>
添加购物车简要信息窗口
在src/components文件夹的CartSummary.vue文件中添加如下内容:
<template>
<div class="float-right">
<small>
Your cart:
<span v-if="itemCount > 0">{{itemCount}} item(s) {{totalPrice | currency}}</span>
<span v-else>(empty)</span>
</small>
<router-link to="/cart" class="btn btn-sm bg-dark text-white" v-bind:disabled="itemCount==0">
<i class="fa fa-shopping-cart"></i>
</router-link>
</div>
</template>
<script>
import {
mapGetters
} from 'vuex';
export default {
computed: {
...mapGetters({
itemCount: "cart/itemCount",
totalPrice: "cart/totalPrice"
})
},
}
</script>
在src/components文件夹的Store.vue文件中添加购物车简要窗口
<template>
<div class="container-fluid">
<div class="row">
<div class="col bg-dark text-white">
<a class="navbar-brand">SPORTS STORE</a>
<cart-summary />
</div>
</div>
<div class="row">
<div class="col-3 bg-info p-2">
<!-- 使用分类组件 -->
<CategoryControls />
</div>
<div class="col-9 p-2">
<!-- 使用注册组件 -->
<product-list />
</div>
</div>
</div>
</template>
<script>
// 导入组件文件
import ProductList from "./ProductList"
import CategoryControls from "./CategoryControls";
import CartSummary from "./CartSummary";
export default {
components: {
ProductList,
CategoryControls,
CartSummary
}
}
</script>
添加订单结算功能
在src/store文件夹的orders.js文件中添加如下内容:
import Axios from "axios";
const ORDERS_URL = "http://localhost:3500/orders"
export default {
actions: {
async storeOrder(context, order){
order.cartLines = context.rootState.cart.lines;
return (await Axios.post(ORDERS_URL, order)).data.id;
}
}
}
在src/store文件夹的index.js中导入模块
import Vue from "vue";
import Vuex from "vuex";
import Axios from "axios";
import CartModule from "./cart";
import OrdersModule from "./orders";
Vue.use(Vuex);
const baseUrl = "http://localhost:3500";
const productsUrl = `${baseUrl}/products`;
const categoriesUrl = `${baseUrl}/categories`;
const testData = [];
for(let i = 1; i <= 10; i++){
testData.push({
id: i, name: `Product #${i}`, category: `Category ${i % 3}`,
description: `This is Product #${i}`, price: i * 50
})
}
export default new Vuex.Store({
strict: true,
modules: {cart: CartModule, orders: OrdersModule }, // 添加购物车模块
state: {
products: testData,
categoriesData: [],
productsTotal: testData.length,
currentPage: 1,
pageSize: 4,
currentCategory: "All"
},
getters: {
productsFilteredByCategory: state => state.products.filter(p => state.currentCategory == "All" || p.category == state.currentCategory),
// 根据当前分页以及每页个数返回当前页面产品列表
processedProducts: (state, getters) => {
let index = (state.currentPage - 1) * state.pageSize;
return getters.productsFilteredByCategory.slice(index, index + state.pageSize);
},
// 计算分页总数
pageCount: (state, getters) => Math.ceil(getters.productsFilteredByCategory.length / state.pageSize),
categories: state => ["All", ...state.categoriesData]
},
mutations: {
// 设置当前分页
setCurrentPage(state, page){
state.currentPage = page;
},
// 设置每页个数
setPageSize(state, size){
state.pageSize = size;
state.currentPage = 1;
},
// 设置当前分类
setCurrentCategory(state, category){
state.currentCategory = category;
state.currentPage = 1;
},
setData(state, data){
state.products = data.pdata;
state.productsTotal = data.pdata.length;
state.categoriesData = data.cdata.sort();
}
},
actions: {
async getData(context) {
let pdata = (await Axios.get(productsUrl)).data;
let cdata = (await Axios.get(categoriesUrl)).data;
console.log(pdata, cdata);
context.commit("setData", {pdata, cdata});
}
}
})
创建和注册结算组件
在src/components文件夹中Checkout.vue中添加如下内容:
<template>
<div>
<div class="container-fluid">
<div class="row">
<div class="col bg-dark text-white">
<a class="navbar-brand">SPORTS STORE</a>
</div>
</div>
</div>
<div class="m-2">
<div class="form-group m-2">
<label>Name</label>
<input v-model="name" class="form-control" />
</div>
</div>
<div class="text-center">
<router-link to="/cart" class="btn btn-secondary m-1">Back</router-link>
<button class="btn btn-primary m-1" v-on:click="submitOrder">Place Order</button>
</div>
</div>
</template>
<script>
export default {
data: function () {
return {
name: null
}
},
methods: {
submitOrder() {
// todo: save order
}
}
}
</script>
在src/components文件夹OrderThanks.vue中添加如下内容:
<template>
<div class="m-2 text-center">
<h2>Thanks!</h2>
<p>Thanks for placing your order, which is #{{orderId}}.</p>
<p>We'll ship your goods as soon as possible.</p>
<router-link to="/" class="btn btn-primary">Return to Store</router-link>
</div>
</template>
<script>
export default {
computed: {
orderId() {
return this.$route.params.id;
}
},
}
</script>
- this.$route,对于所有组件皆可用,通过路由组件获取路由参数
在src/router文件夹index.js中添加如下内容:
import Vue from "vue";
import VueRouter from "vue-router";
import Store from "../components/Store";
import ShoppingCart from "../components/ShoppingCart";
import Checkout from "../components/Checkout";
import OrderThanks from "../components/OrderThanks";
Vue.use(VueRouter);
export default new VueRouter({
mode: "history",
routes: [
{path:"/", component: Store},
{path: "/cart", component: ShoppingCart},
{path: "/checkout", component: Checkout},
{path: "/thanks/:id", component: OrderThanks},
{path: "*", redirect: "/"}
]
})
添加表单验证
在main.js文件中添加如下内容:
import Vue from 'vue'
import App from './App.vue'
// 引入jquery
import $ from 'jquery'
Vue.config.productionTip = false
// 添加bootstrap框架
import "bootstrap/dist/css/bootstrap.min.css";
import "font-awesome/css/font-awesome.min.css";
import store from "./store";
import router from "./router";
import Vuelidate from "vuelidate";
// 添加全局过滤
Vue.filter("currency", (value) => new Intl.NumberFormat("en-US", {style: "currency", currency: "USD"}).format(value));
// 添加验证功能
Vue.use(Vuelidate);
new Vue({
render: h => h(App),
store,
router
}).$mount('#app')
在src/components文件夹的ValidationError.vue文件中添加如下内容:
<template>
<div v-if="show" class="text-danger">
<div v-for="m in messages" v-bind:key="m">{{m}}</div>
</div>
</template>
<script>
export default {
props: ["validation"],
computed: {
show() {
return this.validation.$dirty && this.validation.$invalid
},
messages() {
let messages = [];
if (this.validation.$dirty) {
if (this.hasValidationError("required")) {
messages.push("Please enter a value")
} else if (this.hasValidationError("email")) {
messages.push("Please enter a valid email address");
}
}
return messages;
}
},
methods: {
hasValidationError(type) {
// 新版本的ESLint使用了禁止直接调用 Object.prototypes 的内置属性开关,说白了就是ESLint 配置文件中的 "extends": "eslint:recommended" 属性启用了此规则,所以使用如下方法调用hasOwnProperty
return Object.prototype.hasOwnProperty.call(this.validation.$params, type) && !this.validation[type];
}
}
}
</script>
名称 | 说明 |
---|---|
$invalid | 为真,元素内容没有通过其中一条验证规则 |
$dirty | 为真,元素已经被便编辑过 |
required | 如果存在该属性,表示元素必须验证通过,如果为假,表示元素没有值 |
如果存在该属性,邮箱验证规则必须通过,如果为假,表示元素不是一个有效的邮箱地址 |
接下来,在src/components文件夹的Checkout.vue文件中添加验证规则
<template>
<div>
<div class="container-fluid">
<div class="row">
<div class="col bg-dark text-white">
<a class="navbar-brand">SPORTS STORE</a>
</div>
</div>
</div>
<div class="m-2">
<div class="form-group m-2">
<label>Name</label>
<input v-model="$v.name.$model" class="form-control" />
<!-- 添加验证规则 -->
<validation-error v-bind:validation="$v.name" />
</div>
</div>
<div class="text-center">
<router-link to="/cart" class="btn btn-secondary m-1">Back</router-link>
<button class="btn btn-primary m-1" v-on:click="submitOrder">Place Order</button>
</div>
</div>
</template>
<script>
import {
required
} from "vuelidate/lib/validators";
import ValidationError from "./ValidationError";
export default {
components: {
ValidationError
},
data: function () {
return {
name: null
}
},
validations: {
name: {
required
}
},
methods: {
submitOrder() {
this.$v.$touch();
// todo: save order
}
}
}
</script>
- $v,验证规则功能通过属性$v调用
- $v.name.$model,表示绑定验证规则validations里的name变量规则
- this.$v.$touch(),触发验证规则
添加遗留的信息以及验证处理
在src/components文件夹的Checkout.vue文件中添加如下内容
<template>
<div>
<div class="container-fluid">
<div class="row">
<div class="col bg-dark text-white">
<a class="navbar-brand">SPORTS STORE</a>
</div>
</div>
</div>
<div class="m-2">
<div class="form-group m-2">
<label>Name</label>
<input v-model="$v.order.name.$model" class="form-control" />
<!-- 添加验证规则 -->
<validation-error v-bind:validation="$v.order.name" />
</div>
</div>
<div class="m-2">
<div class="form-group m-2">
<label>Email</label>
<input v-model="$v.order.email.$model" class="form-control" />
<validation-error v-bind:validation="$v.order.email" />
</div>
</div>
<div class="m-2">
<div class="form-group m-2">
<label>Address</label>
<input v-model="$v.order.address.$model" class="form-control" />
<validation-error v-bind:validation="$v.order.address" />
</div>
</div>
<div class="m-2">
<div class="form-group m-2">
<label>City</label>
<input v-model="$v.order.city.$model" class="form-control" />
<validation-error v-bind:validation="$v.order.city" />
</div>
</div>
<div class="m-2">
<div class="form-group m-2">
<label>Zip</label>
<input v-model="$v.order.zip.$model" class="form-control" />
<validation-error v-bind:validation="$v.order.zip" />
</div>
</div>
<div class="text-center">
<router-link to="/cart" class="btn btn-secondary m-1">Back</router-link>
<button class="btn btn-primary m-1" v-on:click="submitOrder">Place Order</button>
</div>
</div>
</template>
<script>
import {
required,
email
} from "vuelidate/lib/validators";
import ValidationError from "./ValidationError";
import {
mapActions
} from "vuex";
export default {
components: {
ValidationError
},
data: function () {
return {
order: {
name: null,
email: null,
address: null,
city: null,
zip: null
}
}
},
validations: {
order: {
name: {
required
},
email: {
required,
email
},
address: {
required
},
city: {
required
},
zip: {
required
}
}
},
methods: {
...mapActions({
"storeOrder": "storeOrder",
"clearCart": "cart/clearCartData"
}),
async submitOrder() {
this.$v.$touch();
if (!this.$v.$invalid) {
let order = await this.storeOrder(this.order);
this.clearCart();
this.$router.push(`/thanks/${order}`);
}
}
}
}
</script>