本文简介:
① 基于express,multer中间件,mongose的上传功能,
② vue双向绑定ui,vueresource请求后台数据,mongose work表添加likeContract数组实现点赞
③悬浮球,弹窗,上传组件的实现
前端:https://github.com/woaigmz/postcard
后台:https://github.com/woaigmz/mp
①上传功能:
后台部分 ---
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;
},