美文网首页我爱编程
前端全栈从零单排 -- 上传、点赞功能,悬浮球、弹窗...

前端全栈从零单排 -- 上传、点赞功能,悬浮球、弹窗...

作者: 半只温柔 | 来源:发表于2018-06-21 14:21 被阅读0次
    本文简介:

    ① 基于express,multer中间件,mongose的上传功能,
    ② vue双向绑定ui,vueresource请求后台数据,mongose work表添加likeContract数组实现点赞
    ③悬浮球,弹窗,上传组件的实现
    前端:https://github.com/woaigmz/postcard
    后台:https://github.com/woaigmz/mp

    wx.gif

    ①上传功能:

    后台部分 ---

    work(作品)表:

    const mongoose = require('mongoose')
    
    const workSchema = mongoose.Schema({
      imgurl: String,
      userId: String,
      content: String,
      username: String,
      like: Number,
      share: Number,
      likeContract:Array //存储点赞用户,建立关联
    }, { collection: 'work' })
    
    const Work = module.exports = mongoose.model('work', workSchema);
    

    control层:定义接口,

    const express = require('express');
    const router = express.Router();
    const WorkModel = require('../model/workSchema');
    const StringUtil = require('../utils/StringUtil');
    const JsonUtil = require('../utils/JsonUtil');
    //const TokenCheckUtil = require('../utils/TokenCheckUtil');
    const multer = require('../utils/MulterUtil');
    //接收 image 并静态存储
    const upload = multer.single('image');
    
    
    //上传作品  image:blob  name:string content:string 
    router.post('/upload', upload, function (req, res) {
    
      WorkModel.create({
          username: req.body.name,
          content: req.body.content,
          imgurl: 'http://' + req.headers.host + '/images/' + req.file.filename,
          userId: req.body.userId,
          like: 0,
          share: 0,
          likeContract: []
      }, (err, success) => {
          if (err) {
              console.log(err);
              JsonUtil.response(res, '201', err, "返回错误");
          } else {
              console.log(success);
              JsonUtil.response(res, '200', success, "上传图片成功");
          }
      });
    })
    

    上传需要用到中间件multer,具体MulterUtil (npm install multer ..省去):

    const multer = require('multer');
    
    const storage = multer.diskStorage({
      destination: function (req, file, callback) {
          // 注: window / linux 不会自动创建 images 文件夹时要给予权限或手动创建
          callback(null, "./images");
      },
      filename: function (req, file, callback) {
          //data拼接防止上传同一个作品造成覆盖
          callback(null, Date.now() + "_" + file.originalname);
      }
    });
    
    const m = multer({
      storage: storage
    });
    
    module.exports = m;
    
    前端请求上传部分:
    //引入upload_form和api
    import {
    items,
    cards,
    works,
    upload_form,
    like_form
    } from "../data/localData.js";
    import Api from "../data/api.js";
    //上传
    upload: function() {
        let that = this;
        this.upload_form.name = this.username;
        console.log(this.upload_form.data);
        console.log(this.upload_form.name);
        console.log(this.upload_form.content);
        if (
          !isEmpty(this.upload_form.name) &&
          !isEmpty(this.upload_form.content) &&
          !isEmpty(this.upload_form.data)
        ) {
          let formData = new window.FormData();
          formData.append("image", this.upload_form.data, ".jpg");
          formData.append("name", this.upload_form.name);
          formData.append("content", this.upload_form.content);
          this.$http.post(Api.UPLOAD, formData).then(
            response => {
              if (response.ok && response.body.code == "201") {
                that.showSnap("error", "上传失败");
              } else {
                that.showSnap("success", response.body.message);
                that.closeDialog();
                that.works.splice(0, 0, {
                  _id: response.body.data._id,
                  username: response.body.data.username,
                  content: response.body.data.content,
                  imgurl: response.body.data.imgurl,
                  like: response.body.data.like,
                  share: response.body.data.share,
                  isLike: response.body.data.isLike
                });
                console.log(response.body);
                console.log(that.works);
                that.upload_form.data = "";
                that.upload_form.content = "";
                that.upload_form.name = "";
              }
            },
            () => {
              that.showSnap("error", "上传失败");
            }
          );
        } else {
          that.showSnap("error", "请保证您的明信片完整");
        }
      },
    

    api.js:抽取便于维护

    module.exports = {
      REGISTER: "http://localhost:3001/api/register",
      LOGIN: "http://localhost:3001/api/login",
      UPLOAD:"http://localhost:3001/api/upload",
      GETWORKLIST:"http://localhost:3001/api/getWorkList",
      GETCARDLIST:"http://localhost:3001/api/getCardList",
      LIKE:"http://localhost:3001/api/like"
    };
    

    localData.js: 不至于你的vue文件里过多出现数据结构

    exports.login_form = {
    name: "",
    password: "",
    token: ""
    };
    
    exports.register_form = {
    name: "",
    age: "",
    sex: "",
    address: "",
    imgArr: "",
    phone: "",
    password: "",
    token: ""
    };
    
    exports.items = [
    {
      href: "",
      name: "粉丝",
      count: 0
    },
    {
      href: "",
      name: "关注",
      count: 0
    },
    {
      href: "",
      name: "获赞",
      count: 0
    }
    ];
    
    exports.cards = [];
    
    exports.works = [];
    
    exports.like_form = {
    type: "",
    workId: "",
    username: ""
    };
    
    exports.upload_form = {
    data: "",
    content: "",
    name: ""
    }
    

    具体点击上传的组件后面聊 :D 感谢大家阅读

    ②点赞功能:

    后台部分:

    点赞接口 ---
    like路由,通过1或0定义点赞或取消,记录当前用户和点赞作品
    $addToSet 增加到不重复元素 Arrray
    $pull 移除 Array 里的对象
    $inc 运算 只针对 Number 类型

    //点赞/取消 1/0  type:1/0 username:string workId:string
    router.post('/like', function (req, res) {
      if (req.body.type === "1") {
          //点赞
          console.log("点赞");
          WorkModel.update({ _id: req.body.workId }, { $addToSet: { likeContract: req.body.username }, $inc: { like: 1 } }, (err, success) => {
              if (err) {
                  JsonUtil.response(res, '201', err, "点赞失败");
              } else {
                  console.log(success);
                  JsonUtil.response(res, '200', success, "点赞成功");
              }
          });
      } else {
          //取消
          console.log("取消");
          WorkModel.update({ _id: req.body.workId }, { $pull: { likeContract: req.body.username }, $inc: { like: -1 } }, (err, success) => {
              if (err) {
                  JsonUtil.response(res, '201', err, "取消失败");
              } else {
                  console.log(success);
                  JsonUtil.response(res, '200', success, "取消成功");
              }
          });
      }
    })
    

    推荐作品接口(后期会用推荐算法优化):

    //推荐列表
    router.post('/getCardList', function (req, res) {
      WorkModel.where({ 'like': { $gte: StringUtil.isEmpty(req.body.max) ? 100 : req.body.max } }).find((err, success) => {
          if (err) {
              JsonUtil.response(res, '201', err, "返回错误");
          } else {
              if (!StringUtil.isEmpty(success)) {
                  let arr = new Array();
                  success.forEach(function (value, index, array) {
                      let isLike = StringUtil.isInArray(value.likeContract, req.body.name);
                      let newObj = {
                          _id: value._id,
                          username: value.username,
                          content: value.content,
                          imgurl: value.imgurl,
                          like: value.like,
                          share: value.share,
                          isLike: isLike
                      }
                      arr.push(newObj)
                  })
    
                  console.log(arr);
                  JsonUtil.response(res, '200', arr, "返回成功");
              } else {
                  console.log("e" + success);
                  JsonUtil.response(res, '201', success, "数据为空");
              }
          }
    
      }).sort({ like: -1 })
    })
    

    1) 返回isLike便于前端判断和通过vm更新视图操作;
    2) 传入username便于后期推荐和判断是否对某项作品点过赞;
    3) $gte: 大于等于100的作品上推荐列表;
    4) sort({ like: -1 }) 降序排列

    前端部分:

    v-for 列表中对 item (推荐作品)点赞,

    <div class="card-list">
        <div class="card-item" v-for="(item,index) in cards" :key="index">
          <img class="card-item-img" :src="item.imgurl">
          <div class="card-item-userinfo">
            {{item.username}}
          </div>
          <div class="card-item-content">
            {{item.content}}
          </div>
          <div class="card-item-operator">
            <!-- 通过likeForCards方法传入的index 进行点赞相关逻辑操作 -->
            <span title="喜欢" class="like" @click="likeForCards(index)">
              <!--  通过item.isLike设置点赞图标的背景样式  -->
              <i v-bind:class="[item.isLike? 'likeafter':'likebefore']"></i>{{item.like}}</span>
            <span title="分享" class="share">
              <i class="share-icon"></i>{{item.share}}</span>
          </div>
        </div>
    

    样式:

    .likebefore {
    display: inline-block;
    width: 16px;
    height: 16px;
    margin-right: 4px;
    vertical-align: bottom;
    background: url(/static/imgs/unlike.svg) 0 0 no-repeat;
    }
    .likeafter {
    display: inline-block;
    width: 16px;
    height: 16px;
    margin-right: 4px;
    vertical-align: bottom;
    background: url(/static/imgs/like.svg) 0 0 no-repeat;
    }
    

    script逻辑部分:

    //点赞  like_form 承载了 调用点赞接口的请求参数(type\workId\username)
    likeForCards: function(index) {
         console.log( "点赞数量:" + this.cards[index].like + "是否点赞:" + this.cards[index].isLike);
         let that = this;
         //如果作品目前的状态是点赞状态,则进行取消点赞的操作( type:"0") ;未点赞,则进行点赞操作( type:"1")
         this.like_form.type = this.cards[index].isLike ? "0" : "1";
         this.like_form.workId = this.cards[index]._id;
         this.like_form.username = this.username;
         //请求网络
         this.$http.post(Api.LIKE, like_form).then(
           response => {
             if (response.ok && response.code == "201") {
               that.showSnap("error", response.body.message);
             } else {
               console.log(response.body);
               that.showSnap("success", response.body.message);
               // vm 更新数据来做视图更新
               that.cards[index].like = that.cards[index].isLike? parseInt(that.cards[index].like) - 1:parseInt(that.cards[index].like) + 1;
               that.cards[index].isLike = !that.cards[index].isLike;
               console.log( "点赞数量:" + that.cards[index].like + "是否点赞:" + that.cards[index].isLike);
             }
           },
           () => {
             that.showSnap("error", "点赞失败");
           }
         );
       }
    

    ③悬浮球:

    template

     <!-- 悬浮球 -->
       <div id="float-ball" @click="showUploadWorkDialog" v-show="showFloatBall">
    

    style

    #float-ball {
     position: fixed;
     border-radius: 50%;
     bottom: 100px;
     right: 100px;
     width: 60px;
     height: 60px;
     z-index: 100;
     background: #409eff;
     box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
     background-image: url("../assets/publish.svg");
     background-position: center;
     background-repeat: no-repeat;
    }
    

    mount,dom形成以后做一些初始化操作

    mounted() {
       window.addEventListener("scroll", this.handleScroll);
       //... 省略
       this.initWorks();
       this.initCards();
     },
    

    页面销毁要重置标记

    destroyed() {
       window.removeEventListener("scroll", this.handleScroll);
       this.start_pos = 0;
     },
    

    //悬浮球隐藏出现逻辑,滑动完成记录位置到标记位,开始滑动时比较判断向上还是向下

    //记录标志位和是否隐藏,通过更改数据更新ui显示
    data: function() {
       return {
         ...
         start_pos: 0,
         showFloatBall: true,
         ...
       };
     },
    methods: {
         handleScroll: function() {
         //适配chrome、safari 、firfox
         let scrollTop =window.pageYOffset || document.documentElement.scrollTop ||document.body.scrollTop;
         let offsetTop = document.querySelector("#float-ball").offsetTop;
         //console.log("scrollTop:" + scrollTop);
         //console.log(offsetTop);
         if (scrollTop > this.start_pos) {
           this.showFloatBall = false;
         } else {
           this.showFloatBall = true;
         }
         this.start_pos = scrollTop;
       },
    }
    

    ④弹窗:

    Dialog.vue 通过slot插槽引入div,this.$emit("on-close");发送事件

    <template>
     <div class="dialog">
       <!-- 遮罩 -->
       <div class="dialog-cover back" v-if="isShow" @click="closeMyself"></div>
       <!-- props 控制内容的样式  -->
       <div class="dialog-content" :style="{top:topDistance+'%',width:widNum+'%',left:leftSite+'%'}" v-if="isShow">
         <div class="dialog_head back ">
           <slot name="header">header</slot>
         </div>
         <div class="dialog_main " :style="{paddingTop:pdt+'px',paddingBottom:pdb+'px'}">
           <slot name="main">body</slot>
         </div>
         <!-- 弹窗关闭按钮 -->
         <div class="foot_close " @click="closeMyself">
         </div>
       </div>
     </div>
    </template> 
    
    <script>
    export default {
     name: "dialogComponent",
     props: {
       isShow: {
         type: Boolean,
         default: false,
         required: true
       },
       widNum: {
         type: Number,
         default: 86.5
       },
       leftSite: {
         type: Number,
         default: 6.5
       },
       topDistance: {
         type: Number,
         default: 18
       },
       pdt: {
         type: Number,
         default: 30
       },
       pdb: {
         type: Number,
         default: 30
       }
     },
     methods: {
       closeMyself() {
         this.$emit("on-close");
       }
     }
    };
    </script>
    <style lang="scss" scoped>
    .dialog {
     position: relative;
     color: #2e2c2d;
     font-size: 16px;
    }
    // 遮罩
    .dialog-cover {
     background: rgba(0, 0, 0, 0.8);
     position: fixed;
     z-index: 200;
     top: 0;
     left: 0;
     width: 100%;
     height: 100%;
    }
    // 内容
    .dialog-content {
     position: fixed;
     top: 35%;
     display: flex;
     flex-direction: column;
     justify-content: center;
     align-items: center;
     z-index: 300;
     .dialog_head {
       background: #409eff;
       width: 600px;
       height: 43px;
       display: flex;
       justify-content: center;
       align-items: center;
       color: #ffffff;
       border-top-left-radius: 10px;
       border-top-right-radius: 10px;
     }
     .dialog_main {
       background: #ffffff;
       width: 600px;
       border-bottom-left-radius: 10px;
       border-bottom-right-radius: 10px;
     }
     .foot_close {
       width: 50px;
       height: 50px;
       border-radius: 50%;
       background: #409eff;
       margin-top: -25px;
       background-position: center;
       background-repeat: no-repeat;
       background-image: url("../assets/dialog_close.svg");
     }
    }
    </style>
    

    使用:
    引入组件:

    export default {
     name: "HomePage",
     个人感觉这种引入方式比较优雅
     components: {
       toolbar: require("../components/Toolbar.vue").default,
       workDialog: require("../components/Dialog.vue").default,
       imgUpload: require("../components/upload-img.vue").default
     },
     data: function() {
     .....省略
    

    templete布局

    <!-- work 对话框 -->
       <work-dialog :is-show="isShowWorkArea" @on-close="closeDialog">
         <!-- title -->
         <div class="dialog_upload_header" slot="header">
           我的明信片:D
         </div>
         <!-- work 内容 -->
         <div class="dialog_upload_main" slot="main">
           <imgUpload v-on:select-complete="secelted"></imgUpload>
           <div class="work-content">
             <!-- 用户信息 -->
             <div class="work-username">
               作者:{{username}}
             </div>
             <!-- 添加文字 -->
             <div class="edit-content">
               <textarea name="text" rows="3" class="card-add-content" placeholder="这里写下你想说的话(*^-^*)" v-bind:maxlength="140" @input="descArea" v-model="upload_form.content"></textarea>
               <span style="font-size:10px;float:right;color: #409eff;">剩余字数 {{surplus}}/140</span>
             </div>
             <!-- 发布 -->
             <el-button id="publish" size="small" type="primary" @click="upload">点击上传</el-button>
           </div>
         </div>
       </work-dialog>
    

    ⑤上传组件(参考github项目)

    upload-img.vue 隐藏input样式,易于定制个性化上传框样式,压缩图片(瓦片上传和canvas两种方式),EXIF判断图片方向并适当旋转图片

    <template>
     <div class="upload">
       <input type="file" @change="handle($event)" name="model" accept="image/*">
       <img :src="imgSrc" alt="" v-show="imgSrc" :name="model">
     </div>
    </template>
    <script>
    import EXIF from "exif-js";
    export default {
     data() {
       return {
         imgSrc: ""
       };
     },
     props: ["model"],
     created() {},
     ready() {},
     methods: {
       handle(evt) {
         var _name = this.model;
         const files = Array.prototype.slice.call(evt.target.files);
    
         let that = this;
    
         files.forEach(function(file, i) {
           var orientation;
           if (!/\/(?:jpeg|png|gif)/i.test(file.type)) return;
           //读取图片的元信息
           EXIF.getData(file, function() {
             orientation = EXIF.getTag(this, "Orientation");
           });
    
           let reader = new FileReader();
    
           reader.onload = function() {
             let result = this.result;
             that.imgSrc = result;
             //使用exif
             that.getImgData(this.result, orientation, function(data) {
               //这里可以使用校正后的图片data了
               var img = new Image();
               img.src = data;
    
               //图片加载完毕之后进行压缩,然后上传
               if (img.complete) {
                 callback();
               } else {
                 img.onload = callback;
               }
    
               function callback() {
                 var data = that.compress(img);
                 that.upload(data, file.type, file.name, _name);
               }
             });
           };
    
           reader.readAsDataURL(file);
         });
       },
       //压缩图片
       compress(img) {
         //用于压缩图片的canvas
         let canvas = document.createElement("canvas");
         let ctx = canvas.getContext("2d");
    
         //    瓦片canvas
         var tCanvas = document.createElement("canvas");
         var tctx = tCanvas.getContext("2d");
    
         let initSize = img.src.length;
         let standard = 200;
         let width = img.naturalWidth;
         let height = img.naturalHeight;
    
         height = standard * height / width;
    
         width = standard;
    
         console.log("w:" + width + "h:" + height);
    
         //如果图片大于四百万像素,计算压缩比并将大小压至400万以下
         var ratio;
         if ((ratio = width * height / 4000000) > 1) {
           ratio = Math.sqrt(ratio);
           width /= ratio;
           height /= ratio;
         } else {
           ratio = 1;
         }
         canvas.width = width * 2;
         canvas.height = height * 2;
         //铺底色
         ctx.fillStyle = "#fff";
         ctx.fillRect(0, 0, canvas.width, canvas.height);
         //如果图片像素大于100万则使用瓦片绘制
         var count;
         if ((count = width * height / 1000000) > 1) {
           count = ~~(Math.sqrt(count) + 1); //计算要分成多少块瓦片
           //计算每块瓦片的宽和高
           var nw = ~~(width / count);
           var nh = ~~(height / count);
           tCanvas.width = nw;
           tCanvas.height = nh;
           for (var i = 0; i < count; i++) {
             for (var j = 0; j < count; j++) {
               tctx.drawImage(
                 img,
                 i * nw * ratio,
                 j * nh * ratio,
                 nw * ratio * 2,
                 nh * ratio * 2,
                 0,
                 0,
                 nw,
                 nh
               );
               ctx.drawImage(tCanvas, i * nw, j * nh, nw * 2, nh * 2);
             }
           }
         } else {
           ctx.drawImage(img, 0, 0, width * 2, height * 2);
         }
    
         //进行最小压缩
         let ndata = canvas.toDataURL("image/jpeg/jpg/png", 0.9);
         console.log("压缩前:" + initSize);
         console.log("压缩后:" + ndata.length);
         console.log(
           "压缩率:" + ~~(100 * (initSize - ndata.length) / initSize) + "%"
         );
         return ndata;
       },
       //上传图片
       upload(basestr, type, name, model) {
         let text = window.atob(basestr.split(",")[1]);
         let buffer = new ArrayBuffer(text.length);
         let ubuffer = new Uint8Array(buffer);
    
         for (let i = 0; i < text.length; i++) {
           ubuffer[i] = text.charCodeAt(i);
         }
    
         let Builder = window.WebKitBlobBuilder || window.MozBlobBuilder;
         let blob;
    
         if (Builder) {
           let builder = new Builder();
           builder.append(buffer);
           blob = builder.getBlob(type);
         } else {
           blob = new window.Blob([buffer], { type: type });
         }
    
         //选择完毕触发事件
         this.$emit("select-complete", blob);
       },
       getImgData(img, dir, next) {
         // @param {string} img 图片的base64
         // @param {int} dir exif获取的方向信息
         // @param {function} next 回调方法,返回校正方向后的base64
         var image = new Image();
         image.onload = function() {
           var degree = 0,
             drawWidth,
             drawHeight,
             width,
             height;
           drawWidth = this.naturalWidth;
           drawHeight = this.naturalHeight;
           //以下改变一下图片大小
           var maxSide = Math.max(drawWidth, drawHeight);
           if (maxSide > 1024) {
             var minSide = Math.min(drawWidth, drawHeight);
             minSide = minSide / maxSide * 1024;
             maxSide = 1024;
             if (drawWidth > drawHeight) {
               drawWidth = maxSide;
               drawHeight = minSide;
             } else {
               drawWidth = minSide;
               drawHeight = maxSide;
             }
           }
           var canvas = document.createElement("canvas");
           canvas.width = width = drawWidth;
           canvas.height = height = drawHeight;
           var context = canvas.getContext("2d");
           //判断图片方向,重置canvas大小,确定旋转角度,iphone默认的是home键在右方的横屏拍摄方式
           switch (dir) {
             //iphone横屏拍摄,此时home键在左侧
             case 3:
               degree = 180;
               drawWidth = -width;
               drawHeight = -height;
               break;
             //iphone竖屏拍摄,此时home键在下方(正常拿手机的方向)
             case 6:
               canvas.width = height;
               canvas.height = width;
               degree = 90;
               drawWidth = width;
               drawHeight = -height;
               break;
             //iphone竖屏拍摄,此时home键在上方
             case 8:
               canvas.width = height;
               canvas.height = width;
               degree = 270;
               drawWidth = -width;
               drawHeight = height;
               break;
           }
           //使用canvas旋转校正
           context.rotate(degree * Math.PI / 180);
           context.drawImage(this, 0, 0, drawWidth, drawHeight);
           //返回校正图片
           next(canvas.toDataURL("image/jpeg/jpg/png", 0.4));
         };
         image.src = img;
       }
     }
    };
    </script>
    <style scoped>
    .upload {
     background-image: url(../assets/add.svg);
     background-repeat: no-repeat;
     background-position:center;
     display: flex;
     justify-content: center;
     align-items: center;
     width: 200px;
     height: 280px;
     position: relative;
     border: 1px solid #409eff;
     box-sizing: border-box;
     z-index: 8;
    }
    img {
     position: absolute;
     object-fit: cover;
     top: 0;
     left: 0;
     width: 100%;
     height: 100%;
    }
    input {
     position: absolute;
     top: 0;
     left: 0;
     width: 100%;
     height: 100%;
     opacity: 0;
     z-index: 99;
    }
    </style>
    

    使用方式:同上(dialog)压缩完成拿到的数据是blob二进制对象

    <imgUpload v-on:select-complete="secelted"></imgUpload>
    

    赋值给上传参数对象

     secelted(data) {
         console.log(data);
         this.upload_form.data = data;
       },
    

    相关文章

      网友评论

        本文标题:前端全栈从零单排 -- 上传、点赞功能,悬浮球、弹窗...

        本文链接:https://www.haomeiwen.com/subject/wcrqyftx.html