Vue2全家桶搭建簡單的桌面和移動端分離的購物商城

在線預覽地址

桌面端: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

未完待續...

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章