vue.js 2 系列之八 运动商店A Real App 4
基于之前一篇基础之上进行构建
首先,运行json web服务
npm run json
然后运行运动商店http服务器
npm run serve
添加商品管理功能
在src/store文件夹中的index.js文件中添加如下内容
import Vue from "vue";
import Vuex from "vuex";
import Axios from "axios";
import CartModule from "./cart";
import OrdersModule from "./orders";
import AuthModule from "./auth";
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, auth: AuthModule }, // 添加购物车模块
state: {
// products: testData,
categoriesData: [],
// productsTotal: testData.length,
currentPage: 1,
pageSize: 4,
currentCategory: "All",
pages: [],
serverPageCount: 0,
searchTerm: "",
showSearch: false
},
getters: {
// productsFilteredByCategory: state => state.products.filter(p => state.currentCategory == "All" || p.category == state.currentCategory),
// 根据当前分页以及每页个数返回当前页面产品列表
processedProducts: (state) => {
return state.pages[state.currentPage];
},
// 计算分页总数
pageCount: (state) => state.serverPageCount,
categories: state => ["All", ...state.categoriesData],
productById:(state) => (id) => {
return state.pages[state.currentPage].find(p => p.id == id);
}
},
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();
// },
addPage(state, page){
for(let i=0; i<page.pageCount; i++){
Vue.set(state.pages, page.number +i, page.data.slice(i*state.pageSize, (i*state.pageSize) + state.pageSize));
}
},
clearPages(state){
state.pages.splice(0, state.pages.length);
},
setCategories(state, categories){
state.categoriesData = categories;
},
setPageCount(state, count){
state.serverPageCount = Math.ceil(Number(count)/state.pageSize);
},
setShowSearch(state, show){
state.showSearch = show;
},
setSearchTerm(state, term){
state.searchTerm = term;
state.currentPage = 1;
},
// 添加商品
_addProduct(state, product){
state.pages[state.currentPage].unshift(product);
},
_updateProduct(state, product){
let page = state.pages[state.currentPage];
let index = page.findIndex(p => p.id == product.id);
Vue.set(page, index, product);
}
},
actions: {
async getData(context) {
await context.dispatch("getPage", 2);
context.commit("setCategories", (await Axios.get(categoriesUrl)).data);
},
async getPage(context, getPageCount = 1){
let url = `${productsUrl}?_page=${context.state.currentPage}` + `&_limit=${context.state.pageSize * getPageCount}`;
if(context.state.currentCategory != "All"){
url += `&category=${context.state.currentCategory}`;
}
// 添加搜索词判断
if(context.state.searchTerm != ""){
url += `&q=${context.state.searchTerm}`;
}
let response = await Axios.get(url);
context.commit("setPageCount", response.headers["x-total-count"]);
context.commit("addPage", {number: context.state.currentPage, data: response.data, pageCount: getPageCount});
},
setCurrentPage(context, page){
context.commit("_setCurrentPage", page);
if(!context.state.pages[page]){
context.dispatch("getPage");
}
},
setPageSize(context, size){
context.commit("clearPages");
context.commit("_setPageSize", size);
context.dispatch("getPage", 2);
},
setCurrentCategory(context, category){
context.commit("clearPages");
context.commit("_setCurrentCategory", category);
context.dispatch("getPage", 2);
},
search(context, term){
context.commit("setSearchTerm", term);
context.commit("clearPages");
context.dispatch("getPage", 2);
},
clearSearchTerm(context){
context.commit("setSearchTerm", "");
context.commit("clearPages");
context.dispatch("getPage", 2);
},
// 添加商品
async addProduct(context, product){
let data = (await context.getters.authenticatedAxios.post(productsUrl, product)).data;
product.id = data.id;
this.commit("_addProduct", product);
},
async removeProduct(context, product){
await context.getters.authenticatedAxios.delete(`${productsUrl}/${product.id}`);
context.commit("clearPages");
context.dispatch("getPage", 1);
},
async updateProduct(context, product){
await context.getters.authenticatedAxios.put(`${productsUrl}/${product.id}`, product);
this.commit("_updateProduct", product);
}
}
})
显示商品列表
在src/components/admin文件夹的ProductAdmin.vue文件中添加如下内容
<template>
<div>
<router-link to="/admin/products/create" class="btn btn-primary my-2">
Create Product
</router-link>
<table class="table table-sm table-bordered">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Category</th>
<th class="text-right">Price</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="p in products" v-bind:key="p.id">
<td>{{p.id}}</td>
<td>{{p.name}}</td>
<td>{{p.category}}</td>
<td class="text-right">{{p.price|currency}}</td>
<td class="text-center">
<button class="btn btn-sm btn-danger mx-1" v-on:click="removeProduct(p)">Delete</button>
<button class="btn btn-sm btn-warning mx-1" v-on:click="handleEdit(p)">Edit</button>
</td>
</tr>
</tbody>
</table>
<page-controls />
</div>
</template>
<script>
import PageControls from "../PageControls";
import {
mapGetters,
mapActions
} from "vuex";
export default {
components: {
PageControls
},
computed: {
...mapGetters({
products: "processedProducts"
})
},
methods: {
...mapActions({
removeProduct: "removeProduct"
}),
handleEdit(product) {
this.$router.push(`/admin/products/edit/${product.id}`);
}
}
}
</script>
添加编辑和路由
在src/components/admin文件夹的ProductEditor.vue文件中添加如下内容
<template>
<div class="bg-info text-white text-center h4 p-2">Product Editor</div>
</template>
在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";
import Authentication from "../components/admin/Authentication";
import Admin from "../components/admin/Admin";
import ProductAdmin from "../components/admin/ProductAdmin";
import OrderAdmin from "../components/admin/OrderAdmin";
import ProductEditor from "../components/admin/ProductEditor";
import dataStore from "../store";
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: "/login", component: Authentication },
{path: "/admin", component: Admin,
beforeEnter(to,from,next){
if(dataStore.state.auth.authenticated){
next();
}else{
next("/login");
}
}, children: [
{path: "products/:op(create|edit)/:id(\\d+)?", component: ProductEditor},
{path: "products", component: ProductAdmin},
{path: "orders", component: OrderAdmin},
{path: "", redirect: "/admin/products"}
]},
{path: "*", redirect: "/"}
]
})
实现编辑器功能
在src/components/admin文件夹的ProductEditor.vue文件中添加如下内容:
<template>
<div>
<h4 class="text-center text-white p-2" v-bind:class="themeClass">
{{ editMode ?"Edit" : "Create Product"}}
</h4>
<h4 v-if="$v.$invalid && $v.dirty" class="bg-danger text-white text-center p-2">Values Required for All Fields</h4>
<div class="form-group" v-if="editMode">
<label>ID (Not Editable)</label>
<input class="form-control" disabled v-model="product.id" />
</div>
<div class="form-group">
<label>Name</label>
<input class="form-control" v-model="product.name" />
</div>
<div class="form-group">
<label>Description</label>
<input class="form-control" v-model="product.description" />
</div>
<div class="form-group">
<label>Category</label>
<select v-model="product.category" class="form-control">
<option v-for="c in categories" v-bind:key="c">{{c}}</option>
</select>
</div>
<div class="form-group">
<label>Price</label>
<input class="form-control" v-model="product.price" />
</div>
<div class="text-center">
<router-link to="/admin/products" class="btn btn-secondary m-1">Cancel</router-link>
<button class="btn m-1" v-bind:class="themeClassButton" v-on:click="handleSave">{{editMode ? "Save Changes" : "Store Product"}}</button>
</div>
</div>
</template>
<script>
import {
mapState,
mapActions
} from "vuex";
import {
required
} from "vuelidate/lib/validators";
export default {
data: function () {
return {
product: {}
}
},
computed: {
...mapState({
pages: state => state.pages,
currentPage: state => state.currentPage,
categories: state => state.categoriesData
}),
editMode() {
return this.$route.params["op"] == "edit";
},
themeClass() {
return this.editMode ? "bg-info" : "bg-primary";
},
themeClassButton() {
return this.editMode ? "btn-info" : "btn-primary";
}
},
validations: {
product: {
name: {
required
},
description: {
required
},
category: {
required
},
price: {
required
}
}
},
methods: {
...mapActions({
addProduct: "addProduct",
updateProduct: "updateProduct"
}),
async handleSave() {
this.$v.$touch(); // 触发验证
console.log(this.$v.$invalid);
// $invalid -- 验证状态,true-验证不通过,false-验证通过
if (!this.$v.$invalid) {
if (this.editMode) {
console.log(this.product);
await this.updateProduct(this.product);
} else {
await this.addProduct(this.product);
}
this.$router.push("/admin/products");
}
}
},
created() {
if (this.editMode) {
Object.assign(this.product, this.$store.getters.productById(this.$route.params["id"]))
}
}
}
</script>
部署商店
准备开发应用
准备数据存储
在src/store文件夹的index.js文件中,修改基础url地址
import Vue from "vue";
import Vuex from "vuex";
import Axios from "axios";
import CartModule from "./cart";
import OrdersModule from "./orders";
import AuthModule from "./auth";
Vue.use(Vuex);
const baseUrl = "/api";
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: false, // 修改检查模式
modules: {cart: CartModule, orders: OrdersModule, auth: AuthModule }, // 添加购物车模块
state: {
// products: testData,
categoriesData: [],
// productsTotal: testData.length,
currentPage: 1,
pageSize: 4,
currentCategory: "All",
pages: [],
serverPageCount: 0,
searchTerm: "",
showSearch: false
},
getters: {
// productsFilteredByCategory: state => state.products.filter(p => state.currentCategory == "All" || p.category == state.currentCategory),
// 根据当前分页以及每页个数返回当前页面产品列表
processedProducts: (state) => {
return state.pages[state.currentPage];
},
// 计算分页总数
pageCount: (state) => state.serverPageCount,
categories: state => ["All", ...state.categoriesData],
productById:(state) => (id) => {
return state.pages[state.currentPage].find(p => p.id == id);
}
},
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();
// },
addPage(state, page){
for(let i=0; i<page.pageCount; i++){
Vue.set(state.pages, page.number +i, page.data.slice(i*state.pageSize, (i*state.pageSize) + state.pageSize));
}
},
clearPages(state){
state.pages.splice(0, state.pages.length);
},
setCategories(state, categories){
state.categoriesData = categories;
},
setPageCount(state, count){
state.serverPageCount = Math.ceil(Number(count)/state.pageSize);
},
setShowSearch(state, show){
state.showSearch = show;
},
setSearchTerm(state, term){
state.searchTerm = term;
state.currentPage = 1;
},
// 添加商品
_addProduct(state, product){
state.pages[state.currentPage].unshift(product);
},
_updateProduct(state, product){
let page = state.pages[state.currentPage];
let index = page.findIndex(p => p.id == product.id);
Vue.set(page, index, product);
}
},
actions: {
async getData(context) {
await context.dispatch("getPage", 2);
context.commit("setCategories", (await Axios.get(categoriesUrl)).data);
},
async getPage(context, getPageCount = 1){
let url = `${productsUrl}?_page=${context.state.currentPage}` + `&_limit=${context.state.pageSize * getPageCount}`;
if(context.state.currentCategory != "All"){
url += `&category=${context.state.currentCategory}`;
}
// 添加搜索词判断
if(context.state.searchTerm != ""){
url += `&q=${context.state.searchTerm}`;
}
let response = await Axios.get(url);
context.commit("setPageCount", response.headers["x-total-count"]);
context.commit("addPage", {number: context.state.currentPage, data: response.data, pageCount: getPageCount});
},
setCurrentPage(context, page){
context.commit("_setCurrentPage", page);
if(!context.state.pages[page]){
context.dispatch("getPage");
}
},
setPageSize(context, size){
context.commit("clearPages");
context.commit("_setPageSize", size);
context.dispatch("getPage", 2);
},
setCurrentCategory(context, category){
context.commit("clearPages");
context.commit("_setCurrentCategory", category);
context.dispatch("getPage", 2);
},
search(context, term){
context.commit("setSearchTerm", term);
context.commit("clearPages");
context.dispatch("getPage", 2);
},
clearSearchTerm(context){
context.commit("setSearchTerm", "");
context.commit("clearPages");
context.dispatch("getPage", 2);
},
// 添加商品
async addProduct(context, product){
let data = (await context.getters.authenticatedAxios.post(productsUrl, product)).data;
product.id = data.id;
this.commit("_addProduct", product);
},
async removeProduct(context, product){
await context.getters.authenticatedAxios.delete(`${productsUrl}/${product.id}`);
context.commit("clearPages");
context.dispatch("getPage", 1);
},
async updateProduct(context, product){
await context.getters.authenticatedAxios.put(`${productsUrl}/${product.id}`, product);
this.commit("_updateProduct", product);
}
}
})
修改 src/store目录下的auth.js文件:
import Axios from "axios";
// 修改登录地址
const loginUrl = "/api/login";
export default {
state: {
authenticated: false,
jwt: null
},
getters: {
authenticatedAxios(state){
return Axios.create({
headers:{
"Authorization":`Bearer<${state.jwt}>`
}
});
}
},
mutations:{
setAuthenticated(state, header){
state.jwt = header;
state.authenticated = true;
},
clearAuthentication(state){
state.authenticated = false;
state.jwt = null;
}
},
actions: {
async authenticate(context, credentials){
let response = await Axios.post(loginUrl, credentials);
if(response.data.success == true){
context.commit("setAuthenticated", response.data.token);
}
}
}
}
修改src/store目录下的orders.js文件:
import Axios from "axios";
import Vue from "vue";
// 修改地址
const ORDERS_URL = "/api/orders"
export default {
state: {
orders: []
},
mutations: {
setOrders(state, data){
state.orders = data;
},
changeOrderShipped(state, order){
Vue.set(order, "shipped", order.shipped == null || !order.shipped ? true : false);
}
},
actions: {
async storeOrder(context, order){
order.cartLines = context.rootState.cart.lines;
return (await Axios.post(ORDERS_URL, order)).data.id;
},
async getOrders(context){
context.commit("setOrders", (await context.rootGetters.authenticatedAxios.get(ORDERS_URL)).data);
},
async updateOrder(context, order){
context.commit("changeOrderShipped", order);
await context.rootGetters.authenticatedAxios.put(`${ORDERS_URL}/${order.id}`, order);
}
}
}
移除认证组件中的认证信息,src/components/admin目录下的Authentication.vue文件
<template>
<div class="m-2">
<h4 class="bg-primary text-white text-center p-2">
SportsStore Administration
</h4>
<h4 v-if="showFailureMessage" class="bg-danger text-white text-center p-2 my-2">
Authentication Failed. Please try again.
</h4>
<div class="form-group">
<label>Username</label>
<input class="form-control" v-model="$v.username.$model">
<validation-error v-bind:validation="$v.username" />
</div>
<div class="form-group">
<label>Password</label>
<input type="password" class="form-control" v-model="$v.password.$model">
<validation-error v-bind:validation="$v.password" />
</div>
<div class="text-center">
<button class="btn btn-primary" v-on:click="handleAuth">Log In</button>
</div>
</div>
</template>
<script>
import {
required
} from 'vuelidate/lib/validators';
import {
mapActions,
mapState
} from "vuex";
import ValidationError from "../ValidationError";
export default {
components: {
ValidationError
},
data: function () {
return {
username: null,
password: null,
showFailureMessage: false,
}
},
computed: {
...mapState({
authenticated: state => state.auth.authenticated
})
},
validations: {
username: {
required
},
password: {
required
}
},
methods: {
...mapActions(["authenticate"]),
async handleAuth() {
this.$v.$touch();
if (!this.$v.$invalid) {
await this.authenticate({
name: this.username,
password: this.password
});
if (this.authenticated) {
this.$router.push("/admin");
} else {
this.showFailureMessage = true;
}
}
}
}
}
</script>
随请求加载管理功能
在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";
// import Authentication from "../components/admin/Authentication";
// import Admin from "../components/admin/Admin";
// import ProductAdmin from "../components/admin/ProductAdmin";
// import OrderAdmin from "../components/admin/OrderAdmin";
// import ProductEditor from "../components/admin/ProductEditor";
const Authentication = () => import(/* webpackChunkName: "admin" */ "../components/admin/Authentication");
const Admin = () => import(/* webpackChunkName: "admin" */ "../components/admin/Admin");
const ProductAdmin = () => import(/* webpackChunkName: "admin" */ "../components/admin/ProductAdmin");
const OrderAdmin = () => import(/* webpackChunkName: "admin" */ "../components/admin/OrderAdmin");
const ProductEditor = () => import(/* webpackChunkName: "admin" */ "../components/admin/ProductEditor");
import dataStore from "../store";
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: "/login", component: Authentication },
{path: "/admin", component: Admin,
beforeEnter(to,from,next){
if(dataStore.state.auth.authenticated){
next();
}else{
next("/login");
}
}, children: [
{path: "products/:op(create|edit)/:id(\\d+)?", component: ProductEditor},
{path: "products", component: ProductAdmin},
{path: "orders", component: OrderAdmin},
{path: "", redirect: "/admin/products"}
]},
{path: "*", redirect: "/"}
]
})
创建数据文件
在 data.json文件中展示商品内容
{
"products": [
{"id": 1, "name": "Kayak", "category": "Watersports", "description": "A boat for one person", "price": 275},
{"id": 2, "name": "Lifejacket", "category": "Watersports", "description": "Protective and fashionable", "price": 48.95},
{"id": 3, "name": "Soccer Ball", "category": "Soccer", "description": "FIFA-approved size and weight", "price": 19.50},
{"id": 4, "name": "Corner Flags", "category": "Soccer", "description": "Give your playing field a professional touch", "price": 34.95},
{"id": 5, "name": "Stadium", "category": "Soccer", "description": "Flat-packed 35,000-seat stadium", "price": 79500},
{"id": 6, "name": "Thinking Cap", "category": "Chess", "description": "Improve brain efficiency by 75%", "price": 16},
{"id": 7, "name": "Unsteady Chair", "category": "Chess", "description": "Secretly give your opponent a disadvantage", "price": 29.95},
{"id": 8, "name": "Human Chess Board", "category": "Chess", "description": "A fun game for the family", "price": 75},
{"id": 9, "name": "Bling Bling King", "category": "Chess", "description": "Gold-plated, diamond-studded King", "price": 1200}
],
"categories": ["Watersports", "Soccer", "Chess"],
"orders": []
}
构建应用
npm run build
测试待发布应用
添加一些包
npm install --save-dev [email protected]
npm install --save-dev [email protected]
在项目根目录下的server.js文件中添加如下内容
const express = require("express");
const history = require("connect-history-api-fallback");
const jsonServer = require("json-server");
const bodyParser = require("body-parser");
const auth = require("./authMiddleware");
const router = jsonServer.router("data.json");
const app = express();
app.use(bodyParser.json());
app.use(auth);
app.use("/api", router);
app.use(history());
app.use("/", express.static("./dist"));
app.listen(80, function(){
console.log("HTTP Server running on port 80");
});
测试发布构建
node server.js
然后直接在浏览器中访问http://localhost查看效果
部署应用
创建package文件
为了部署应用到Docker,需要创建一个package.js的版本文件, 在根目录下添加一个deploy-package.json文件,其内容如下:
{
"name": "store-vuejs",
"version": "1.0.0",
"private": true,
"dependencies": {
"faker": "^4.1.0",
"json-server": "^0.12.1",
"jsonwebtoken": "^8.1.1",
"express": "4.16.3",
"connect-history-api-fallback": "1.5.0"
}
}
创建Docker容器
在项目根目录下的Dockerfile文件中添加如下内容:
FROM node:8.11.2
RUN mkdir -p /usr/src/store-vuejs
COPY dist /usr/src/store-vuejs/dist
COPY authMiddleware.js /usr/src/store-vuejs/
COPY data.json /usr/src/store-vuejs/
COPY server.js /usr/src/store-vuejs/server.js
COPY deploy-package.json /usr/src/store-vuejs/package.json
WORKDIR /usr/src/store-vuejs
RUN npm install
CMD ["node", "server.js"]
构建docker镜像
docker build . -t store-vuejs -f Dockerfile
运行应用
创建一个docker容器
docker run -p 80:80 store-vuejs
现在就可以在浏览器中访问http://localhost了
查看当前正在运行的docker
docker ps
停止docker容器
docker stop {CONTAINER_ID}