在线预览地址
桌面端: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
未完待续...