在線預覽地址
桌面端:http://hystrix.club/stall/
移動端:http://hystrix.club/store/
子曰:不想寫前端的後端不是好測試,作爲一個後端開發感到亞歷山大。
最近由於疫情防控需求在家隔離,趁此機會複習一遍Vue知識,順便做個小項目練練手。
本項目技術棧
移動端基於 Vue2 + Vuex + Axois + MintUI 開發
<template>
<div id="app" v-cloak>
<div class="container" style="min-height: 581px;">
<div class="content clearfix js-page-content">
<div id="cart-container">
<div>
<div class="js-shop-list shop-list">
<!-- 商品列表 -->
<ul class="js-list block block-list block-list-cart border-0">
<li
class="block-item block-item-cart notediting"
:key="good.id"
v-for="(good, goodIndex) in goodList"
:class="{editing:good.editing}"
@touchmove="move($event,good,goodIndex)"
@touchstart="touch($event,good)"
@touchend="end($event,good,goodIndex)"
:ref="'good-' + goodIndex"
>
<div>
<div class="check-container" @click="selectGood(good)">
<span
class="check"
:class="{checked: editingMode ? good.removeChecked : good.checked}"
></span>
</div>
<div class="name-card clearfix">
<a :href="'/item?id=' + good.id" class="thumb js-goods-link">
<img class="js-lazy" :src="good.image" />
</a>
<div class="detail">
<a :href="'/item?id=' + good.id" class="js-goods-link">
<h3 class="title js-ellipsis">
<i>{{good.name}}</i>
</h3>
</a>
<p class="sku ellipsis">{{good.color}}</p>
<!-- 顯示狀態的數量 -->
<div class="num" v-show="!good.editing">
×
<span class="num-txt">{{good.count || 1}}</span>
</div>
<!-- 編輯狀態的數量 -->
<div class="num modify" v-show="good.editing">
<div class="count">
<button
type="button"
class="minus"
:class="{disabled:good.count === 1}"
></button>
<input
type="number"
pattern="[0-9]*"
class="txt"
v-model.number="good.count"
@input="cartTrade(good, good.count)"
@blur="blur(good)"
/>
<button type="button" class="plus"></button>
<div
class="response-area response-area-minus"
@click="cartTrade(good,-1)"
></div>
<div
class="response-area response-area-plus"
@click="cartTrade(good,1)"
></div>
</div>
</div>
<div class="price c-orange">
¥
<span>{{good.cost | currency}}</span>
</div>
<div class="opt-box">
<a
href="javascript:;"
class="j-edit-list c-blue font-size-12 edit-list"
@click="editGood(good, goodIndex)"
>{{good.editingMsg}}</a>
</div>
</div>
<div class="error-box"></div>
</div>
<!-- 編輯狀態下才出現 -->
<div class="delete-btn" @click="removeGood(good, goodIndex)">
<span>刪除</span>
</div>
</div>
</li>
</ul>
</div>
<div style="padding:0;" class="js-bottom-opts bottom-fix">
<div class="go-shop-tip js-go-shop-tip c-orange font-size-12">你需要分開結算每個店鋪的商品哦~</div>
<div class="bottom-cart clear-fix">
<div class="select-all" @click="selectAll">
<span
class="check"
:class="{checked:editingMode ? allRemoveSelected : allSelected}"
></span> 全選
</div>
<!-- 顯示狀態 -->
<div class="total-price" v-show="!editingMode">
合計:¥
<span
class="js-total-price"
style="color: rgb(255, 102, 0);"
>{{total | currency}}</span>
<p class="c-gray-dark">不含運費</p>
</div>
<button
class="js-go-pay btn btn-orange-dark font-size-14"
:disabled="!selectList.length"
v-show="!editingMode"
>結算 ({{selectList.length}})</button>
<!-- 編輯狀態 -->
<button
href="javascript:;"
:disabled="!removeList.length"
class="j-delete-goods btn font-size-14 btn-red"
v-show="editingShop"
@click="removeGoods"
>刪除</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 刪除確認 -->
<div class="van-dialog" style="z-index: 2002;" v-show="removePopup">
<div class="van-hairline van-dialog__content">
<div class="van-dialog__message">{{popupMsg}}</div>
</div>
<div class="van-dialog__footer van-dialog__footer--buttons">
<button
class="van-button van-button--default van-button--large van-dialog__cancel"
@click="cancelPopup"
>
<span class="van-button__text">取消</span>
</button>
<button
class="van-button van-button--default van-button--large van-dialog__confirm van-hairline--left"
@click="removeConfirm"
>
<span class="van-button__text">確認</span>
</button>
</div>
</div>
<div class="van-modal" style="z-index: 2001;" v-show="removePopup"></div>
</div>
</template>
<script>
import "../../assets/css/cart_base.css";
import "../../assets/css/cart_trade.css";
import "../../assets/css/cart.css";
import $ from "../../util";
import mixin from "../../mixin";
import Vue from "vue";
import anime from "animejs";
export default {
data() {
return {
goodList: null,
editMode: false,
editingGoodIndex: -1,
removePopup: false,
removeData: null,
popupMsg: "",
removeType: ""
};
},
methods: {
getCartList() {
$.ajax($.url.cartList).then(data => {
let list = data.goodList;
list.forEach(good => {
good.checked = true;
good.removeChecked = false;
good.editing = false;
good.editingMsg = "編輯";
});
this.goodList = list;
});
},
getStoredCart() {
let cartList = this.$store.state.cartList;
let productList = this.$store.state.productList;
cartList.forEach(good => {
const product = productList.find(item => item.id === good.id);
good.name = product.name;
good.color = product.color;
good.cost = product.cost;
good.image = product.image;
good.checked = true;
good.removeChecked = false;
good.editing = false;
good.editingMsg = "編輯";
});
this.goodList = cartList;
},
selectGood(good) {
const attr = this.editMode ? "removeChecked" : "checked";
good[attr] = !good[attr];
},
selectAll() {
const attr = this.editMode ? "allRemoveSelected" : "allSelected";
this[attr] = !this[attr];
},
editGood(good, goodIndex) {
for (const key in this.$refs) {
if (this.$refs.hasOwnProperty(key)) {
const element = this.$refs[key];
if (element.length) {
element[0].style.transform = "translateX(0)";
}
}
}
good.editing = !good.editing;
good.editingMsg = good.editing ? "完成" : "編輯";
this.editMode = !this.editMode;
this.editingGoodIndex = -good.editing ? goodIndex : -1;
},
cartTrade(good, count) {
var count = Math.floor(Number(count));
if (!count) return;
if (count <= -1 && good.count === 1) return;
$.ajax($.url.cartAdd, {
id: good.id,
count
}).then(data => {
if (data.status === 200) {
if (count === 1 || count === -1) {
good.count += count;
} else {
if (count >= good.stock) {
good.count = good.stock;
return;
}
good.count = count;
}
}
});
},
blur(good) {
!good.count && (good.count = 1);
},
removeGood(good, goodIndex) {
this.removeType = "single";
this.popupMsg = "確定刪除該商品?";
this.removePopup = true;
this.removeData = {
good,
goodIndex
};
},
removeGoods() {
this.removeType = "multi";
this.popupMsg = `確定刪除所選的${this.removeList.length}個商品`;
this.removePopup = true;
},
removeConfirm() {
if (this.removeType === "single") {
let { good, goodIndex } = this.removeData;
$.ajax($.url.cartRemove, {
id: good.id
}).then(data => {
if (data.status === 200) {
this.removePopup = false;
goodList.splice(goodIndex, 1);
if (goodList.length === 0) {
this.goodList.splice(goodIndex, 1);
this.removeShop();
}
}
});
} else {
let GoodIds = this.removeList.map(good => good.id);
$.ajax($.url.cartRemove, {
GoodIds
}).then(data => {
if (data.status === 200) {
// 改變goodList 和 重新給goodList賦值,2選1
this.removePopup = false;
this.removeList.forEach(good => {
let index = this.goodList.indexOf(good);
if (index > -1) {
this.goodList.splice(index, 1);
}
});
if (this.goodList.length === 0) {
this.goodList.splice(this.goodIndex, 1);
this.removeShop();
}
}
});
}
},
cancelPopup() {
this.removePopup = false;
},
move(ev, good, goodIndex) {
let touchMoveX = ev.changedTouches[0].clientX - good.touchStartX;
good.touchMoveX = touchMoveX;
if (touchMoveX > 0 || this.editMode) {
return;
}
this.$refs[`good-${goodIndex}`][0].style.transform = `translateX(${
touchMoveX > -60 ? touchMoveX : -60 + 0.4 * (touchMoveX + 60)
}px)`;
},
touch(ev, good) {
good.touchStartX = ev.changedTouches[0].clientX;
},
end(ev, good, goodIndex) {
if (!good.touchMoveX || this.editMode) return;
anime({
targets: this.$refs[`good-${goodIndex}`],
translateX: (good.touchMoveX = good.touchMoveX > -30 ? 0 : -60),
easing: "easeOutQuad",
duration: 300
});
}
},
computed: {
allSelected: {
get() {
if (this.goodList && this.goodList.length) {
return this.goodList.every(good => good.checked);
}
return false;
},
set(newVal) {
if (this.goodList) {
this.goodList.forEach(good => {
good.checked = newVal;
});
}
}
},
total() {
let total = 0;
if (this.goodList) {
this.goodList.forEach(good => {
if (good.checked) {
total += good.cost * good.count;
}
});
}
return total;
},
selectList() {
let selectList = [];
if (this.goodList) {
this.goodList.forEach(good => {
good.checked && selectList.push(good);
});
}
return selectList;
},
allRemoveSelected: {
get() {
if (this.goodList && this.goodList.length) {
return this.goodList.every(good => !good.checked);
}
return false;
},
set(newVal) {
this.goodList.forEach(good => (good.removeChecked = newVal));
}
},
removeList() {
let removeList = [];
if (this.editMode) {
this.goodList.forEach(good => {
if (good.removeChecked) {
removeList.push(good);
}
});
}
return removeList;
}
},
created() {
this.getStoredCart();
},
mixins: [mixin]
};
</script>
<style scoped>
.num.modify {
z-index: -1;
}
.price.c-orange,
.opt-box {
display: inline-block;
}
</style>
桌面端基於 Vue2 + Vuex + Axois + ElementUI 開發
<template>
<div class="favo">
<div class="favo-header">
<div class="favo-header-title">
<span>收藏夾</span>
<span class="favo-empty" @click="handleClear">清空</span>
</div>
<div class="favo-header-main">
<div class="favo-info">商品信息</div>
<div class="favo-price">單價</div>
<div class="favo-delete">刪除</div>
</div>
</div>
<div class="favo-content">
<!-- 列表顯示購物清單 -->
<div class="favo-content-main" v-for="(item, index) in favoList" :key="index">
<div class="favo-info">
<img :src="productDictList[item.id].image" />
<span>{{productDictList[item.id].name}}</span>
</div>
<div class="favo-price">¥ {{productDictList[item.id].cost}}</div>
<div class="favo-delete">
<span class="favo-control-delete" @click="handleDelete(index)">刪除</span>
</div>
</div>
<div class="favo-empty" v-if="!favoList.length">收藏夾爲空</div>
</div>
</div>
</template>
<script>
import "../assets/css/favorites.css"
export default {
name: "favorites",
data() {
return {
// productList: product_data
productList: []
};
},
computed: {
//購物車數據
favoList() {
return this.$store.state.favoList;
},
//設置字典對象,方便查詢
productDictList() {
const dict = {};
this.productList.forEach(item => {
dict[item.id] = item;
});
return dict;
}
},
methods: {
//根據index查找商品id進行刪除
handleDelete(index) {
this.$store.commit("deleteFavo", this.favoList[index].id);
},
handleClear() {
this.$store.commit("emptyFavo");
}
},
created() {
this.productList = this.$store.state.productList;
// this.productList = this.$store.commit('getProductListSync');
}
};
</script>
後端基於 Mysql + Node + Koa2 + Sequelize5 開發
//引入db配置
const db = require('../config/sqz_db')
//引入sequelize對象
const Sequelize = db.sequelize
//引入數據表模型
const CommentModel = Sequelize.import('../model/comment_model')
const ReplyModel = Sequelize.import('../model/reply_model')
const UserModel = Sequelize.import('../model/user_model')
// comment表與reply表根據cId關聯查詢
ReplyModel.belongsTo(CommentModel, {
as: 'replies',
foreignKey: 'comment_id',
targetKey: 'id'
});
CommentModel.hasMany(ReplyModel, {
foreignKey: 'comment_id',
sourceKey: 'id',
as: "replies"
});
// comment and user
CommentModel.hasOne(UserModel, {
foreignKey: 'id',
as: 'user',
targetKey: 'from_id'
});
//數據庫操作類
class CommentService {
static async create(data) {
return await CommentModel.create(data);
}
static async findList(params) {
// 前臺可能會傳來nickname、account來模糊查詢,以及分頁參數
const id = params.id;
const product_id = params.product_id;
const user_id = params.user_id;
const start = params.start || 0;
const page_size = params.page_size || 10;
// where條件接受一個對象傳入,先定義一個對象,用{}符號
let criteria = {};
// 檢查前臺傳來的參數是否爲空,從而構造各種查詢條件
if (id) {
criteria['id'] = id;
}
if (product_id) {
// 這裏後面賦值的是like操作符,代表模糊查詢,後面account用``反引號字符串替換模板將account代入進去
criteria['product_id'] = product_id;
}
if (user_id) {
// 這裏後面賦值的是like操作符,代表模糊查詢,後面account用``反引號字符串替換模板將account代入進去
criteria['user_id'] = user_id;
}
return await CommentModel.findAndCountAll({
where: criteria, // 這裏傳入的是一個查詢對象,因爲我的查詢條件是動態的,所以前面構建好後才傳入,而不是寫死
offset: start, // 前端分頁組件傳來的起始偏移量
limit: Number(page_size), // 前端分頁組件傳來的一頁顯示多少條
include: [{ // include關鍵字表示關聯查詢
model: ReplyModel, // 指定關聯的model
as: 'replies', // 由於前面建立映射關係時爲class表起了別名,那麼這裏也要與前面保持一致,否則會報錯
// attributes: ['from_id', 'to_id'], // 這裏的attributes屬性表示查詢class表的name和rank字段,其中對name字段起了別名className
},
{ // include關鍵字表示關聯查詢
model: UserModel, // 指定關聯的model
as: 'user', // 由於前面建立映射關係時爲class表起了別名,那麼這裏也要與前面保持一致,否則會報錯
// attributes: ['username', 'password'], // 這裏的attributes屬性表示查詢class表的name和rank字段,其中對name字段起了別名className
}],
raw: true // 這個屬性表示開啓原生查詢,原生查詢支持的功能更多,自定義更強
});
}
}
module.exports = CommentService;
參考項目
https://github.com/Leonardo-zyh/Vue-youzanStore
https://github.com/icarusion/vue-book/tree/master/shopping
未完待續...