环境配置
Node、MongoDB、Npm
项目初始化
后端项目
mkdir server && cd_
npm init -y
package.json
{
"name": "server",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"serve": "nodemon index",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}
安装依赖
# express 注意安装5.x版本,否则后续会出现某些功能无法使用
npm install express@next
# mongoose
npm install mongoose
# 允许跨域请求
npm install cors
# 单词格式转化
npm install inflection
# 图片上传
npm install multer
# 加密
npm install bcrypt
# token
npm install jsonwebtoken
# 断言,减少if语句
npm install http-assert
# 将文件夹下所有文件引用进来。当模型存在依赖关系时,如果模型的某个的字段关联了某个未引入(使用)的模型,会引起错误
npm install require-all
# 阿里云oss(可选)
npm install multer-aliyun-oss
# 压缩
npm install compression
数据库连接
index.js
const express = require('express')
const app = express()
const compression = require('compression')
const PORT = process.env.PORT || 3000
// 将秘钥挂载在Express实例上,在真实项目中应该保存在环境变量
app.set('secret', 'dahqoenrqnfsxi')
// 跨域
app.use(require('cors')())
// 解析body数据
app.use(express.json())
// 开启gzip压缩, 必须放到托管静态文件之前
app.use(compression())
// 托管静态文件
app.use('/uploads', express.static(__dirname + '/uploads'))
// 通过 /admin 访问
app.use('/admin', express.static(__dirname + '/admin'))
// 通过 / 访问
app.use('/', express.static(__dirname + '/web'))
// 导入admin的路由
require('./routes/admin')(app)
// 导入web路由
require('./routes/web')(app)
// mongo数据库
require('./plugins/db')(app)
app.listen(PORT, () => {
console.log(`http://localhost:${PORT}`)
})
plugins/db.js
module.exports = (app) => {
const mongoose = require('mongoose')
mongoose.connect('mongodb://localhost:27017/node-vue-moba', {
useNewUrlParser: true,
useUnifiedTopology: true,
useFindAndModify: false
})
}
前台界面
vue create web
安装依赖
# sass
npm install sass sass-loader -D
# 路由
vue add router
# 轮播图
npm install swiper vue-awesome-swiper --save
# axios
npm install axios
# 处理日期
npm install dayjs
后台管理项目
vue create admin
安装依赖
# elementui
vue add element
# router
vue add router
# axios
npm install axios
# 富文本编辑器
npm install vue2-editor
配置axios
admin/src/plugins/axios.js
import axios from 'axios'
import Vue from 'vue'
const http = axios.create({
baseURL: 'http://localhost:3000/admin/api'
})
// 规定请求失败返回 message字段 提示用户错误原因
http.interceptors.response.use(
res => {
return res
},
err => {
if (err.response.data.message) {
Vue.prototype.$message({
type: 'error',
message: err.response.data.message
})
}
return Promise.reject(err)
}
)
export default http
main.js
import http from './plugins/axios'
Vue.prototype.$http = http
分类管理
分类模型
server/models/Category.js
const mongoose = require('mongoose')
const schema = new mongoose.Schema({
name: {
type: String
},
parent: {
type: mongoose.SchemaTypes.ObjectId,
// 关联到哪个模型
ref: 'Category'
}
})
// 分类的虚拟字段 子分类 children
schema.virtual('children', {
localField: '_id',
foreignField: 'parent',
justOne: false,
ref: 'Category'
})
// 分类的虚拟字段 文章列表 newsList
schema.virtual('newsList', {
localField: '_id',
foreignField: 'categories',
justOne: false,
ref: 'Article'
})
module.exports = mongoose.model('Category', schema)
分类接口
server/routes/admin/index.js
module.exports = (app) => {
const express = require('express')
// 分类模型
const Category = require('../../models/Category')
const router = express.Router()
// 创建分类
router.post('/categories', async (req, res) => {
const model = await Category.create(req.body)
res.send(model)
})
// 编辑分类
router.put('/categories/:id', async (req, res) => {
const model = await Category.findByIdAndUpdate(req.params.id, req.body)
res.send(model)
})
// 删除分类
router.delete('/categories/:id', async (req, res) => {
await Category.findByIdAndDelete(req.params.id)
res.send({
success: true
})
})
// 分类列表
router.get('/categories', async (req, res) => {
// populate用来查询关联字段,得到的是完整数据,而不仅仅是_id
const items = await Category.find().populate('parent').limit(10)
res.send(items)
})
// 获取某个分类详情
router.get('/categories/:id', async (req, res) => {
const model = await Category.findById(req.params.id)
res.send(model)
})
app.use('/admin/api', router)
}
分类编辑
admin/src/views/CategoryEdit.vue
<template>
<div>
<h1>{{ id ? '编辑' : '新建' }}分类</h1>
<!-- 阻止表单提交后跳转 @submit.native.prevent -->
<el-form label-width="120px" @submit.native.prevent="save">
<el-form-item label="上级分类">
<el-select v-model="model.parent">
<el-option v-for="item in parents" :key="item._id" :label="item.name" :value="item._id"> </el-option>
</el-select>
</el-form-item>
<el-form-item label="名称">
<el-input v-model="model.name"></el-input>
</el-form-item>
<el-form-item>
<!-- native-type="submit" 原生类型 -->
<el-button type="primary" native-type="submit">保存</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script>
export default {
props: {
id: {
type: Object
}
},
data() {
return {
model: {},
// 父级选项
parents: []
}
},
created() {
// 如果id存在表示编辑分类
this.id && this.fetch()
// 获取所有分类
this.fetchParents()
},
methods: {
/**
* 添加或删除分类
*/
async save() {
let res
if (this.id) {
// 编辑分类
res = await this.$http.put(`categories/${this.id}`, this.model)
} else {
// 添加分类
res = await this.$http.post('categories', this.model)
}
console.log(res)
this.$router.push('/categories/list')
this.$message({
type: 'success',
message: '保存成功'
})
},
/**
* 获取单个分类
*/
async fetch() {
const res = await this.$http.get(`categories/${this.id}`)
this.model = res.data
},
/**
* 获取所有分类
*/
async fetchParents() {
const res = await this.$http.get(`categories`)
this.parents = res.data
}
}
}
</script>
<style></style>
分类列表
admin/src/views/CategoryList.vue
<template>
<div>
<h1>分类列表</h1>
<el-table :data="items">
<el-table-column prop="_id" label="分类id" width="230"> </el-table-column>
<el-table-column prop="parent.name" label="上级分类"></el-table-column>
<el-table-column prop="name" label="分类名称"> </el-table-column>
<el-table-column fixed="right" label="操作" width="180">
<template slot-scope="scope">
<el-button type="text" size="small" @click="$router.push(`/categories/edit/${scope.row._id}`)">编辑</el-button>
<el-button type="text" size="small" @click="remove(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script>
export default {
data() {
return {
items: []
}
},
created() {
this.fetch()
},
methods: {
/**
* 获取所有分类
*/
async fetch() {
const res = await this.$http.get('categories')
this.items = res.data
},
/**
* 删除分类
*/
async remove(row) {
this.$confirm(`是否确定要删除分类 ${row.name}`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
await this.$http.delete(`categories/${row._id}`)
this.$message({
type: 'success',
message: '删除成功'
})
this.fetch()
})
}
}
}
</script>
<style></style>
重构代码:通用CURD接口
curd接口
server/routes/admin/index.js
module.exports = (app) => {
const express = require('express')
const router = express.Router({
// 将父级参数(/admin/api/rest/:resource)合并到子级路由,即可以通过req.params获得父级路由参数
mergeParams: true
})
// 新增
router.post('/', async (req, res) => {
const model = await req.Model.create(req.body)
res.send(model)
})
// 修改
router.put('/:id', async (req, res) => {
const model = await req.Model.findByIdAndUpdate(req.params.id, req.body)
res.send(model)
})
// 删除
router.delete('/:id', async (req, res) => {
await req.Model.findByIdAndDelete(req.params.id)
res.send({
success: true
})
})
// 列表
router.get('/', async (req, res) => {
// populate用来查询关联字段,得到的是完整数据,而不仅仅是_id
// const items = await req.Model.find().populate('parent').limit(10)
// 特殊处理: 有些接口无需关联查询
const queryOptions = {}
if (req.Model.modelName === 'Category') {
queryOptions.populate = 'parent'
}
const items = await req.Model.find().setOptions(queryOptions).limit(10)
res.send(items)
})
// 单个
router.get('/:id', async (req, res) => {
const model = await req.Model.findById(req.params.id)
res.send(model)
})
// rest后面可以是任何资源
app.use(
'/admin/api/rest/:resource',
async (req, res, next) => {
// .classify 将小写复数转为大写单数
const modelName = require('inflection').classify(req.params.resource)
req.Model = require(`../../models/${modelName}`)
next()
},
router
)
}
分类编辑
admin/src/views/CategoryEdit.vue
<template>
<div>
<h1>{{ id ? '编辑' : '新建' }}分类</h1>
<!-- 阻止表单提交后跳转 @submit.native.prevent -->
<el-form label-width="120px" @submit.native.prevent="save">
<el-form-item label="上级分类">
<el-select v-model="model.parent">
<el-option v-for="item in parents" :key="item._id" :label="item.name" :value="item._id"> </el-option>
</el-select>
</el-form-item>
<el-form-item label="名称">
<el-input v-model="model.name"></el-input>
</el-form-item>
<el-form-item>
<!-- native-type="submit" 原生类型 -->
<el-button type="primary" native-type="submit">保存</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script>
export default {
props: {
id: {
type: Object
}
},
data() {
return {
model: {},
// 父级选项
parents: []
}
},
created() {
// 如果id存在表示编辑分类
this.id && this.fetch()
// 获取所有分类
this.fetchParents()
},
methods: {
/**
* 添加或删除分类
*/
async save() {
let res
if (this.id) {
// 编辑分类
res = await this.$http.put(`rest/categories/${this.id}`, this.model)
} else {
// 添加分类
res = await this.$http.post('rest/categories', this.model)
}
console.log(res)
this.$router.push('/categories/list')
this.$message({
type: 'success',
message: '保存成功'
})
},
/**
* 获取单个分类
*/
async fetch() {
const res = await this.$http.get(`rest/categories/${this.id}`)
this.model = res.data
},
/**
* 获取所有分类
*/
async fetchParents() {
const res = await this.$http.get(`rest/categories`)
this.parents = res.data
}
}
}
</script>
<style></style>
更改地方:路由前面加上
rest
表示使用的是通用接口,避免路由污染
分类列表
admin/src/views/CategoryList.vue
<template>
<div>
<h1>分类列表</h1>
<el-table :data="items">
<el-table-column prop="_id" label="分类id" width="230"> </el-table-column>
<el-table-column prop="parent.name" label="上级分类"></el-table-column>
<el-table-column prop="name" label="分类名称"> </el-table-column>
<el-table-column fixed="right" label="操作" width="180">
<template slot-scope="scope">
<el-button type="text" size="small" @click="$router.push(`/categories/edit/${scope.row._id}`)">编辑</el-button>
<el-button type="text" size="small" @click="remove(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script>
export default {
data() {
return {
items: []
}
},
created() {
this.fetch()
},
methods: {
/**
* 获取所有分类
*/
async fetch() {
const res = await this.$http.get('rest/categories')
this.items = res.data
},
/**
* 删除分类
*/
async remove(row) {
this.$confirm(`是否确定要删除分类 ${row.name}`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
await this.$http.delete(`rest/categories/${row._id}`)
this.$message({
type: 'success',
message: '删除成功'
})
this.fetch()
})
}
}
}
</script>
<style></style>
本节代码
https://github.com/alfalfaw/node-vue-moba/tree/curd
物品(装备)管理
物品模型
server/models/Item.js
const mongoose = require('mongoose')
const schema = new mongoose.Schema({
name: {
type: String
},
icon: {
type: String
}
})
module.exports = mongoose.model('Item', schema)
物品编辑
admin/src/views/ItemEdit.vue
<template>
<div>
<h1>{{ id ? '编辑' : '新建' }}物品</h1>
<!-- 阻止表单提交后跳转 @submit.native.prevent -->
<el-form label-width="120px" @submit.native.prevent="save">
<el-form-item label="名称">
<el-input v-model="model.name"></el-input>
</el-form-item>
<el-form-item label="图标">
<el-input v-model="model.icon"></el-input>
</el-form-item>
<el-form-item>
<!-- native-type="submit" 原生类型 -->
<el-button type="primary" native-type="submit">保存</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script>
export default {
props: {
id: {
type: Object
}
},
data() {
return {
model: {}
}
},
created() {
// 如果id存在表示编辑分类
this.id && this.fetch()
},
methods: {
/**
* 添加或删除分类
*/
async save() {
if (this.id) {
// 编辑分类
await this.$http.put(`rest/items/${this.id}`, this.model)
} else {
// 添加分类
await this.$http.post('rest/items', this.model)
}
this.$router.push('/items/list')
this.$message({
type: 'success',
message: '保存成功'
})
},
/**
* 获取单个分类
*/
async fetch() {
const res = await this.$http.get(`rest/items/${this.id}`)
this.model = res.data
}
}
}
</script>
<style></style>
物品列表
admin/src/views/ItemList.vue
<template>
<div>
<h1>物品列表</h1>
<el-table :data="items">
<el-table-column prop="_id" label="物品id" width="230"> </el-table-column>
<el-table-column prop="name" label="物品名称"> </el-table-column>
<el-table-column fixed="right" label="操作" width="180">
<template slot-scope="scope">
<el-button type="text" size="small" @click="$router.push(`/items/edit/${scope.row._id}`)">编辑</el-button>
<el-button type="text" size="small" @click="remove(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script>
export default {
data() {
return {
items: []
}
},
created() {
this.fetch()
},
methods: {
/**
* 获取所有物品
*/
async fetch() {
const res = await this.$http.get('rest/items')
this.items = res.data
},
/**
* 删除物品
*/
async remove(row) {
this.$confirm(`是否确定要删除物品 ${row.name}`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
await this.$http.delete(`rest/items/${row._id}`)
this.$message({
type: 'success',
message: '删除成功'
})
this.fetch()
})
}
}
}
</script>
<style></style>
本节代码
https://github.com/alfalfaw/node-vue-moba/tree/items
图片上传
静态文件托管
app.use('/uploads', express.static(__dirname + '/uploads'))
server/index.js
const express = require('express')
const app = express()
const PORT = process.env.PORT || 3000
// 跨域
app.use(require('cors')())
// 解析body数据
app.use(express.json())
// 托管静态文件
app.use('/uploads', express.static(__dirname + '/uploads'))
// 导入admin的路由
require('./routes/admin')(app)
// mongo数据库
require('./plugins/db')(app)
app.listen(PORT, () => {
console.log(`http://localhost:${PORT}`)
})
图片上传接口
server/routes/admin/index.js
module.exports = (app) => {
// ...
// 上传图片
const multer = require('multer')
const upload = multer({ dest: __dirname + '/../../uploads' })
// 该中间件将文件赋值到req.file
app.post('/admin/api/upload', upload.single('file'), async (req, res) => {
const file = req.file
file.url = `http://localhost:3000/uploads/${file.filename}`
res.send(file)
})
}
物品列表
admin/src/views/ItemList.vue
<template>
<div>
<h1>物品列表</h1>
<el-table :data="items">
<el-table-column prop="_id" label="物品id" width="230"> </el-table-column>
<el-table-column prop="name" label="物品名称"> </el-table-column>
<el-table-column prop="icon" label="图标">
<template slot-scope="scope">
<img :src="scope.row.icon" style="height:3rem;" alt="" />
</template>
</el-table-column>
<el-table-column fixed="right" label="操作" width="180">
<template slot-scope="scope">
<el-button type="text" size="small" @click="$router.push(`/items/edit/${scope.row._id}`)">编辑</el-button>
<el-button type="text" size="small" @click="remove(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script>
export default {
data() {
return {
items: []
}
},
created() {
this.fetch()
},
methods: {
/**
* 获取所有物品
*/
async fetch() {
const res = await this.$http.get('rest/items')
this.items = res.data
},
/**
* 删除物品
*/
async remove(row) {
this.$confirm(`是否确定要删除物品 ${row.name}`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
await this.$http.delete(`rest/items/${row._id}`)
this.$message({
type: 'success',
message: '删除成功'
})
this.fetch()
})
}
}
}
</script>
<style></style>
物品编辑
admin/src/views/ItemEdit.vue
<template>
<div>
<h1>{{ id ? '编辑' : '新建' }}物品</h1>
<!-- 阻止表单提交后跳转 @submit.native.prevent -->
<el-form label-width="120px" @submit.native.prevent="save">
<el-form-item label="名称">
<el-input v-model="model.name"></el-input>
</el-form-item>
<el-form-item label="图标">
<el-upload
class="avatar-uploader"
:action="$http.defaults.baseURL + '/upload'"
:show-file-list="false"
:on-success="handleAvatarSuccess"
:before-upload="beforeAvatarUpload"
>
<img v-if="model.icon" :src="model.icon" class="avatar" />
<i v-else class="el-icon-plus avatar-uploader-icon"></i>
</el-upload>
</el-form-item>
<el-form-item>
<!-- native-type="submit" 原生类型 -->
<el-button type="primary" native-type="submit">保存</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script>
export default {
props: {
id: {
type: Object
}
},
data() {
return {
model: {}
}
},
created() {
// 如果id存在表示编辑分类
this.id && this.fetch()
},
methods: {
/**
* 添加或删除分类
*/
async save() {
if (this.id) {
// 编辑分类
await this.$http.put(`rest/items/${this.id}`, this.model)
} else {
// 添加分类
await this.$http.post('rest/items', this.model)
}
this.$router.push('/items/list')
this.$message({
type: 'success',
message: '保存成功'
})
},
/**
* 获取单个分类
*/
async fetch() {
const res = await this.$http.get(`rest/items/${this.id}`)
this.model = res.data
},
/**
* to do 上传图片前验证
*/
beforeAvatarUpload() {},
/**
* 上传图片成功
*/
async handleAvatarSuccess(res) {
// 这里直接赋值是不可行的,因为model最开始没有icon属性
// this.model.icon = res.url
this.$set(this.model, 'icon', res.url)
}
}
}
</script>
<style>
.avatar-uploader .el-upload {
border: 1px dashed #d9d9d9;
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
}
.avatar-uploader .el-upload:hover {
border-color: #409eff;
}
.avatar-uploader-icon {
font-size: 28px;
color: #8c939d;
width: 178px;
height: 178px;
line-height: 178px;
text-align: center;
}
.avatar {
width: 178px;
height: 178px;
display: block;
}
</style>
本节代码
https://github.com/alfalfaw/node-vue-moba/tree/upload
英雄管理
英雄模型
server/models/Hero.js
const mongoose = require('mongoose')
const schema = new mongoose.Schema({
name: {
type: String
},
avater: {
type: String
},
banner: {
type: String
},
title: {
type: String
},
// 数组字段
categories: [
{
type: mongoose.SchemaTypes.ObjectId,
ref: 'Category'
}
],
// 评分
scores: {
difficult: { type: Number },
skills: { type: Number },
attack: { type: Number },
survive: { type: Number }
},
// 技能
skills: [
{
icon: {
type: String
},
name: {
type: String
},
description: {
type: String
},
tips: {
type: String
},
cost: {
type: String
},
delay: {
type: String
}
}
],
// 装备1
items1: [
{
type: mongoose.SchemaTypes.ObjectId,
ref: 'Item'
}
],
// 装备2
items2: [
{
type: mongoose.SchemaTypes.ObjectId,
ref: 'Item'
}
],
usageTips: {
type: String
},
battleTips: {
type: String
},
teamTips: {
type: String
},
partners: [
{
hero: {
type: mongoose.SchemaTypes.ObjectId,
ref: 'Hero'
},
description: {
type: String
}
}
]
})
// 加第三个参数可以指定数据表名
module.exports = mongoose.model('Hero', schema)
英雄编辑
admin/src/views/HeroEdit.vue
<template>
<div>
<h1>{{ id ? '编辑' : '新建' }}英雄</h1>
<!-- 阻止表单提交后跳转 @submit.native.prevent -->
<el-form label-width="120px" @submit.native.prevent="save">
<el-tabs type="border-card" value="basic">
<el-tab-pane label="基本信息" name="basic">
<el-form-item label="名称">
<el-input v-model="model.name"></el-input>
</el-form-item>
<el-form-item label="称号">
<el-input v-model="model.title"></el-input>
</el-form-item>
<el-form-item label="头像">
<el-upload
class="avatar-uploader"
:action="uploadUrl"
:headers="getAuthHeaders()"
:show-file-list="false"
:on-success="res => $set(model, 'avater', res.url)"
:before-upload="beforeAvatarUpload"
>
<img v-if="model.avater" :src="model.avater" class="avatar" />
<i v-else class="el-icon-plus avatar-uploader-icon"></i>
</el-upload>
</el-form-item>
<el-form-item label="背景">
<el-upload
class="avatar-uploader"
:action="uploadUrl"
:headers="getAuthHeaders()"
:show-file-list="false"
:on-success="res => $set(model, 'banner', res.url)"
:before-upload="beforeAvatarUpload"
>
<img v-if="model.banner" :src="model.banner" class="avatar" />
<i v-else class="el-icon-plus avatar-uploader-icon"></i>
</el-upload>
</el-form-item>
<el-form-item label="类型">
<!-- multiple 表示多选 -->
<el-select v-model="model.categories" multiple>
<el-option v-for="item of categories" :key="item._id" :label="item.name" :value="item._id"></el-option>
</el-select>
</el-form-item>
<el-form-item label="难度">
<el-rate style="margin-top:0.6rem;" :max="9" show-score v-model="model.scores.difficult"></el-rate>
</el-form-item>
<el-form-item label="技能">
<el-rate style="margin-top:0.6rem;" :max="9" show-score v-model="model.scores.skills"></el-rate>
</el-form-item>
<el-form-item label="攻击">
<el-rate style="margin-top:0.6rem;" :max="9" show-score v-model="model.scores.attack"></el-rate>
</el-form-item>
<el-form-item label="生存">
<el-rate style="margin-top:0.6rem;" :max="9" show-score v-model="model.scores.survive"></el-rate>
</el-form-item>
<el-form-item label="顺风出装">
<!-- multiple 表示多选 -->
<el-select v-model="model.items1" multiple>
<el-option v-for="item of items" :key="item._id" :label="item.name" :value="item._id"></el-option>
</el-select>
</el-form-item>
<el-form-item label="逆风出装">
<!-- multiple 表示多选 -->
<el-select v-model="model.items2" multiple>
<el-option v-for="item of items" :key="item._id" :label="item.name" :value="item._id"></el-option>
</el-select>
</el-form-item>
<el-form-item label="使用技巧">
<el-input type="textarea" v-model="model.usageTips"></el-input>
</el-form-item>
<el-form-item label="对抗技巧">
<el-input type="textarea" v-model="model.battleTips"></el-input>
</el-form-item>
<el-form-item label="团战思路">
<el-input type="textarea" v-model="model.teamTips"></el-input>
</el-form-item>
</el-tab-pane>
<el-tab-pane label="技能" name="skills">
<el-button size="small" @click="model.skills.push({})"><i class="el-icon-plus"></i>添加技能</el-button>
<el-row type="flex" style="flex-wrap:wrap;">
<el-col :md="12" v-for="(item, index) in model.skills" :key="index">
<el-form-item label="名称">
<el-input v-model="item.name"></el-input>
</el-form-item>
<el-form-item label="图标">
<el-upload
class="avatar-uploader"
:action="uploadUrl"
:headers="getAuthHeaders()"
:show-file-list="false"
:on-success="res => $set(item, 'icon', res.url)"
:before-upload="beforeAvatarUpload"
>
<img v-if="item.icon" :src="item.icon" class="avatar" />
<i v-else class="el-icon-plus avatar-uploader-icon"></i>
</el-upload>
</el-form-item>
<el-form-item label="冷却值">
<el-input v-model="item.delay"></el-input>
</el-form-item>
<el-form-item label="消耗">
<el-input v-model="item.cost"></el-input>
</el-form-item>
<el-form-item label="描述">
<el-input v-model="item.description" type="textarea"></el-input>
</el-form-item>
<el-form-item label="小提示">
<el-input v-model="item.tips" type="textarea"></el-input>
</el-form-item>
<el-form-item>
<el-button type="danger" size="small" @click="model.skills.splice(index, 1)">删除</el-button>
</el-form-item>
</el-col>
</el-row>
</el-tab-pane>
<el-tab-pane label="最佳搭档" name="partners">
<el-button size="small" @click="model.partners.push({})"><i class="el-icon-plus"></i>添加搭档</el-button>
<el-row type="flex" style="flex-wrap:wrap;">
<el-col :md="12" v-for="(item, index) in model.partners" :key="index">
<el-form-item label="英雄">
<!-- filterable 可以筛选 -->
<el-select filterable v-model="item.hero">
<el-option v-for="hero in heros" :key="hero._id" :value="hero._id" :label="hero.name"></el-option>
</el-select>
</el-form-item>
<el-form-item label="描述">
<el-input v-model="item.description" type="textarea"></el-input>
</el-form-item>
<el-form-item>
<el-button type="danger" size="small" @click="model.partners.splice(index, 1)">删除</el-button>
</el-form-item>
</el-col>
</el-row>
</el-tab-pane>
</el-tabs>
<el-form-item style="margin-top:1rem;">
<!-- native-type="submit" 原生类型 -->
<el-button type="primary" native-type="submit">保存</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script>
export default {
props: {
id: {}
},
data() {
return {
heros: [],
model: {
name: '',
avater: '',
banner: '',
title: '',
skills: [],
partners: [],
// 如果数据是一个对象,取对象的值时为了避免出现undefined错误,先将其赋值为空对象
scores: {
diffcult: 0,
skills: 0,
attack: 0,
survive: 0
}
},
// 类别
categories: [],
// 物品
items: []
}
},
created() {
// 如果id存在表示编辑
this.id && this.fetch()
// 获取分类
this.fetchCategories()
// 获取物品
this.fetchItems()
// 获取英雄
this.fetchHeros()
},
methods: {
/**
* 添加或删除
*/
async save() {
if (this.id) {
// 编辑
await this.$http.put(`rest/heros/${this.id}`, this.model)
} else {
// 添加
await this.$http.post('rest/heros', this.model)
}
this.$router.push('/heros/list')
this.$message({
type: 'success',
message: '保存成功'
})
},
/**
* 获取分类
*/
async fetchCategories() {
const res = await this.$http.get(`rest/categories`)
this.categories = res.data
},
/**
* 获取物品
*/
async fetchItems() {
const res = await this.$http.get(`rest/items`)
this.items = res.data
},
/**
* 获取英雄
*/
async fetchHeros() {
const res = await this.$http.get(`rest/heros`)
this.heros = res.data
},
/**
* 获取单个英雄数据
*/
async fetch() {
const res = await this.$http.get(`rest/heros/${this.id}`)
// this.model = res.data
// 使用 Object.assign 是为了保持原有默认数据
this.model = Object.assign({}, this.model, res.data)
},
/**
* to do 上传图片前验证
*/
beforeAvatarUpload() {}
}
}
</script>
<style scoped>
.avatar-uploader .el-upload {
border: 1px dashed #d9d9d9;
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
}
.avatar-uploader .el-upload:hover {
border-color: #409eff;
}
.avatar-uploader-icon {
font-size: 28px;
color: #8c939d;
width: 5rem;
height: 5rem;
line-height: 5rem;
text-align: center;
}
.avatar {
width: 5rem;
height: 5rem;
display: block;
}
</style>
英雄列表
admin/src/views/HeroList.vue
<template>
<div>
<h1>英雄列表</h1>
<el-table :data="items">
<el-table-column prop="_id" label="英雄id" width="230"> </el-table-column>
<el-table-column prop="name" label="英雄名称"> </el-table-column>
<el-table-column prop="avater" label="头像">
<template slot-scope="scope">
<img :src="scope.row.avater" style="height:3rem;" alt="" />
</template>
</el-table-column>
<el-table-column fixed="right" label="操作" width="180">
<template slot-scope="scope">
<el-button type="text" size="small" @click="$router.push(`/heros/edit/${scope.row._id}`)">编辑</el-button>
<el-button type="text" size="small" @click="remove(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script>
export default {
data() {
return {
items: []
}
},
created() {
this.fetch()
},
methods: {
/**
* 获取所有物品
*/
async fetch() {
const res = await this.$http.get('rest/heros')
this.items = res.data
},
/**
* 删除物品
*/
async remove(row) {
this.$confirm(`是否确定要删除英雄 ${row.name}`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
await this.$http.delete(`rest/heros/${row._id}`)
this.$message({
type: 'success',
message: '删除成功'
})
this.fetch()
})
}
}
}
</script>
<style></style>
本节代码
https://github.com/alfalfaw/node-vue-moba/tree/heros
文章管理
实现富文本编辑器和图片上传
文章模型
server/models/Article.js
const mongoose = require('mongoose')
const schema = new mongoose.Schema(
{
title: {
type: String
},
categories: [
{
type: mongoose.SchemaTypes.ObjectId,
ref: 'Category'
}
],
body: {
type: String
}
},
// 自动添加修改和创建时间
{
timestamps: true
}
)
module.exports = mongoose.model('Article', schema)
文章编辑
admin/src/views/ArticleEdit.vue
<template>
<div>
<h1>{{ id ? '编辑' : '新建' }}文章</h1>
<!-- 阻止表单提交后跳转 @submit.native.prevent -->
<el-form label-width="120px" @submit.native.prevent="save">
<el-form-item label="所属分类">
<el-select v-model="model.categories" multiple>
<el-option v-for="item in categories" :key="item._id" :label="item.name" :value="item._id"> </el-option>
</el-select>
</el-form-item>
<el-form-item label="标题">
<el-input v-model="model.title"></el-input>
</el-form-item>
<el-form-item label="文章详情">
<vue-editor useCustomImageHandler @image-added="handleImageAdded" v-model="model.body"></vue-editor>
</el-form-item>
<el-form-item>
<!-- native-type="submit" 原生类型 -->
<el-button type="primary" native-type="submit">保存</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script>
import { VueEditor } from 'vue2-editor'
export default {
props: {
id: {
type: Object
}
},
data() {
return {
model: {},
categories: []
}
},
components: {
VueEditor
},
created() {
// 如果id存在表示编辑
this.id && this.fetch()
// 获取所有分类
this.fetchCategories()
},
methods: {
/**
* 添加或删除分类
*/
async save() {
if (this.id) {
// 编辑文章
await this.$http.put(`rest/articles/${this.id}`, this.model)
} else {
// 添加文章
await this.$http.post('rest/articles', this.model)
}
this.$router.push('/articles/list')
this.$message({
type: 'success',
message: '保存成功'
})
},
/**
* 获取单个文章
*/
async fetch() {
const res = await this.$http.get(`rest/articles/${this.id}`)
this.model = res.data
},
/**
* 获取所有分类
*/
async fetchCategories() {
const res = await this.$http.get(`rest/categories`)
this.categories = res.data
},
/**
* 图片上传
*/
async handleImageAdded(file, Editor, cursorLocation, resetUploader) {
const formData = new FormData()
formData.append('file', file)
const res = await this.$http.post('upload', formData)
Editor.insertEmbed(cursorLocation, 'image', res.data.url)
resetUploader()
}
}
}
</script>
<style></style>
文章列表
admin/src/views/ArticleList.vue
<template>
<div>
<h1>文章列表</h1>
<el-table :data="items">
<el-table-column prop="_id" label="文章id" width="230"> </el-table-column>
<el-table-column prop="title" label="标题"></el-table-column>
<el-table-column fixed="right" label="操作" width="180">
<template slot-scope="scope">
<el-button type="text" size="small" @click="$router.push(`/articles/edit/${scope.row._id}`)">编辑</el-button>
<el-button type="text" size="small" @click="remove(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script>
export default {
data() {
return {
items: []
}
},
created() {
this.fetch()
},
methods: {
/**
* 获取所有文章
*/
async fetch() {
const res = await this.$http.get('rest/articles')
this.items = res.data
},
/**
* 删除文章
*/
async remove(row) {
this.$confirm(`是否确定要删除文章 ${row.title}`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
await this.$http.delete(`rest/articles/${row._id}`)
this.$message({
type: 'success',
message: '删除成功'
})
this.fetch()
})
}
}
}
</script>
<style></style>
本节代码
https://github.com/alfalfaw/node-vue-moba/tree/posts
广告管理
广告模型
server/models/Ad.js
const mongoose = require('mongoose')
const schema = new mongoose.Schema({
name: {
type: String
},
items: [
{
image: {
type: String
},
url: {
type: String
}
}
]
})
module.exports = mongoose.model('Ad', schema)
广告编辑
admin/src/views/AdEdit.vue
<template>
<div>
<h1>{{ id ? '编辑' : '新建' }}广告位</h1>
<!-- 阻止表单提交后跳转 @submit.native.prevent -->
<el-form label-width="120px" @submit.native.prevent="save">
<el-form-item label="名称">
<el-input v-model="model.name"></el-input>
</el-form-item>
<el-form-item label="广告">
<el-button size="small" @click="model.items.push({})"><i class="el-icon-plus"></i>添加广告</el-button>
<el-row type="flex" style="flex-wrap:wrap;">
<el-col :md="12" v-for="(item, index) in model.items" :key="index">
<el-form-item label="URL" style="margin-bottom:0.5rem;">
<el-input v-model="item.url"></el-input>
</el-form-item>
<el-form-item label="图片">
<el-upload
class="avatar-uploader"
:action="$http.defaults.baseURL + '/upload'"
:show-file-list="false"
:on-success="res => $set(item, 'image', res.url)"
:before-upload="beforeAvatarUpload"
>
<img v-if="item.image" :src="item.image" class="avatar" />
<i v-else class="el-icon-plus avatar-uploader-icon"></i>
</el-upload>
</el-form-item>
<el-form-item>
<el-button type="danger" size="small" @click="model.items.splice(index, 1)">删除</el-button>
</el-form-item>
</el-col>
</el-row>
</el-form-item>
<el-form-item>
<!-- native-type="submit" 原生类型 -->
<el-button type="primary" native-type="submit">保存</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script>
export default {
props: {
id: {
type: Object
}
},
data() {
return {
model: {
items: []
}
}
},
created() {
// 如果id存在表示编辑
this.id && this.fetch()
},
methods: {
/**
* 添加或删除
*/
async save() {
if (this.id) {
// 编辑
await this.$http.put(`rest/ads/${this.id}`, this.model)
} else {
// 添加
await this.$http.post('rest/ads', this.model)
}
this.$router.push('/ads/list')
this.$message({
type: 'success',
message: '保存成功'
})
},
/**
* 获取单个广告
*/
async fetch() {
const res = await this.$http.get(`rest/ads/${this.id}`)
this.model = Object.assign({}, this.model, res.data)
}
}
}
</script>
<style scoped>
.avatar-uploader .el-upload {
border: 1px dashed #d9d9d9;
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
}
.avatar-uploader .el-upload:hover {
border-color: #409eff;
}
.avatar-uploader-icon {
font-size: 28px;
color: #8c939d;
/* width: 5rem;
height: 5rem; */
/* line-height: 5rem; */
text-align: center;
}
.avatar {
max-width: 100%;
height: 5rem;
display: block;
}
</style>
广告列表
admin/src/views/AdList.vue
<template>
<div>
<h1>广告位列表</h1>
<el-table :data="items">
<el-table-column prop="_id" label="广告id" width="230"> </el-table-column>
<el-table-column prop="name" label="广告名称"> </el-table-column>
<el-table-column fixed="right" label="操作" width="180">
<template slot-scope="scope">
<el-button type="text" size="small" @click="$router.push(`/ads/edit/${scope.row._id}`)">编辑</el-button>
<el-button type="text" size="small" @click="remove(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script>
export default {
data() {
return {
items: []
}
},
created() {
this.fetch()
},
methods: {
/**
* 获取所有广告
*/
async fetch() {
const res = await this.$http.get('rest/ads')
this.items = res.data
},
/**
* 删除广告
*/
async remove(row) {
this.$confirm(`是否确定要删除广告 ${row.name}`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
await this.$http.delete(`rest/ads/${row._id}`)
this.$message({
type: 'success',
message: '删除成功'
})
this.fetch()
})
}
}
}
</script>
<style></style>
本节代码
https://github.com/alfalfaw/node-vue-moba/tree/ad
管理员管理
用户密码加密
管理员模型
server/models/AdminUser.js
const mongoose = require('mongoose')
const schema = new mongoose.Schema({
username: {
type: String
},
password: {
type: String,
// select:false 的字段不能被查询到,这样在修改密码时不会显示原密码,而且如果密码框为空,保存时原密码也不会修改
select: false,
// 保存前对密码进行加密
set(val) {
return require('bcrypt').hashSync(val, 10)
}
}
})
module.exports = mongoose.model('AdminUser', schema)
管理员编辑
admin/src/views/AdminUserEdit.vue
<template>
<div>
<h1>{{ id ? '编辑' : '新建' }}管理员</h1>
<!-- 阻止表单提交后跳转 @submit.native.prevent -->
<el-form label-width="120px" @submit.native.prevent="save">
<el-form-item label="用户名">
<el-input v-model="model.username"></el-input>
</el-form-item>
<el-form-item label="密码">
<el-input type="password" v-model="model.password"></el-input>
</el-form-item>
<el-form-item>
<!-- native-type="submit" 原生类型 -->
<el-button type="primary" native-type="submit">保存</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script>
export default {
props: {
id: {
type: Object
}
},
data() {
return {
model: {}
}
},
created() {
// 如果id存在表示编辑管理员
this.id && this.fetch()
},
methods: {
/**
* 添加或删除
*/
async save() {
if (this.id) {
// 编辑
await this.$http.put(`rest/admin_users/${this.id}`, this.model)
} else {
// 添加
await this.$http.post('rest/admin_users', this.model)
}
this.$router.push('/admin_users/list')
this.$message({
type: 'success',
message: '保存成功'
})
},
/**
* 获取单个分类
*/
async fetch() {
const res = await this.$http.get(`rest/admin_users/${this.id}`)
this.model = res.data
}
}
}
</script>
<style></style>
管理员列表
admin/src/views/AdminUserList.vue
<template>
<div>
<h1>管理员列表</h1>
<el-table :data="items">
<el-table-column prop="_id" label="管理员id" width="230"> </el-table-column>
<el-table-column prop="username" label="用户名"> </el-table-column>
<el-table-column fixed="right" label="操作" width="180">
<template slot-scope="scope">
<el-button type="text" size="small" @click="$router.push(`/admin_users/edit/${scope.row._id}`)">编辑</el-button>
<el-button type="text" size="small" @click="remove(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script>
export default {
data() {
return {
items: []
}
},
created() {
this.fetch()
},
methods: {
/**
* 获取所有管理员
*/
async fetch() {
const res = await this.$http.get('rest/admin_users')
this.items = res.data
},
/**
* 删除管理员
*/
async remove(row) {
this.$confirm(`是否确定要删除管理员 ${row.username}`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
await this.$http.delete(`rest/admin_users/${row._id}`)
this.$message({
type: 'success',
message: '删除成功'
})
this.fetch()
})
}
}
}
</script>
<style></style>
本节代码
https://github.com/alfalfaw/node-vue-moba/tree/admin-user
身份验证
服务端登录校验
未经认证的用户访问接口失败后跳转到登录页
登录页面
admin/src/views/Login.vue
<template>
<div class="login-container">
<el-card header="请先登录" class="login-card">
<el-form @submit.native.prevent="login">
<el-form-item label="用户名">
<el-input v-model="model.username"></el-input>
</el-form-item>
<el-form-item label="密码">
<el-input type="password" v-model="model.password"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" native-type="submit">登录</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script>
export default {
data() {
return {
model: {}
}
},
methods: {
/**
* 登录
*/
async login() {
const res = await this.$http.post('login', this.model)
localStorage.token = res.data.token
this.$router.push('/')
this.$message({
type: 'success',
message: '登录成功'
})
}
}
}
</script>
<style scoped>
.login-card {
width: 25rem;
margin: 5rem auto;
}
</style>
请求头添加验证信息
admin/src/plugins/axios.js
发送请求时带上token
import axios from 'axios'
import Vue from 'vue'
import router from '../router'
const http = axios.create({
baseURL: 'http://localhost:3000/admin/api'
})
// 拦截请求,带上token
http.interceptors.request.use(
config => {
// 添加请求头
if (localStorage.token) config.headers.Authorization = 'Bearer ' + localStorage.token
return config
},
error => {
// Do something with request error
return Promise.reject(error)
}
)
// 规定请求失败返回 message 提示用户错误原因
http.interceptors.response.use(
res => {
return res
},
err => {
if (err.response.data.message) {
Vue.prototype.$message({
type: 'error',
message: err.response.data.message
})
}
// 错误码为401,表示用户未登录,跳转到登录页
if (err.response.status === 401) {
router.push('/login')
}
return Promise.reject(err)
}
)
export default http
身份认证和资源中间件
server/middleware/auth.js
module.exports = (options) => {
// jsonwebtoken
const jwt = require('jsonwebtoken')
// 断言
const assert = require('http-assert')
// 管理员模型
const AdminUser = require('../models/AdminUser')
return async (req, res, next) => {
const token = String(req.headers.authorization || '')
.split(' ')
.pop()
assert(token, 401, 'token不存在')
// req.app和app是等价的
const { id } = jwt.verify(token, req.app.get('secret'))
assert(id, 401, '无效的token')
req.user = await AdminUser.findById(id)
assert(req.user, 401, '请先登录')
next()
}
}
server/middleware/resource.js
module.exports = (options) => {
return async (req, res, next) => {
// .classify 将小写复数转为大写单数
const modelName = require('inflection').classify(req.params.resource)
req.Model = require(`../models/${modelName}`)
next()
}
}
重构代码:使用中间件保护路由
错误捕获的语法只有express5.0+支持
server/routes/admin/index.js
module.exports = (app) => {
const express = require('express')
// jsonwebtoken
const jwt = require('jsonwebtoken')
// 断言
const assert = require('http-assert')
// 管理员模型
const AdminUser = require('../../models/AdminUser')
const router = express.Router({
// 将父级参数(/admin/api/rest/:resource)合并到子级路由,即可以通过req.params获得父级路由参数
mergeParams: true
})
// 登录校验中间件
const authMiddleware = require('../../middleware/auth')
// 资源中间键
const resourceMiddleware = require('../../middleware/resource')
// 新增资源
router.post('/', async (req, res) => {
const model = await req.Model.create(req.body)
res.send(model)
})
// 修改资源
router.put('/:id', async (req, res) => {
const model = await req.Model.findByIdAndUpdate(req.params.id, req.body)
res.send(model)
})
// 删除资源
router.delete('/:id', async (req, res) => {
await req.Model.findByIdAndDelete(req.params.id)
res.send({
success: true
})
})
// 资源列表
router.get('/', async (req, res) => {
// populate用来查询关联字段,得到的是完整数据,而不仅仅是_id
// const items = await req.Model.find().populate('parent').limit(10)
// 特殊处理: 有些接口无需关联查询
const queryOptions = {}
if (req.Model.modelName === 'Category') {
queryOptions.populate = 'parent'
}
const items = await req.Model.find().setOptions(queryOptions).limit(10)
res.send(items)
})
// 资源详情
router.get('/:id', async (req, res) => {
const model = await req.Model.findById(req.params.id)
res.send(model)
})
// rest后面可以是任何资源
app.use('/admin/api/rest/:resource', authMiddleware(), resourceMiddleware(), router)
// 上传图片
const multer = require('multer')
const upload = multer({ dest: __dirname + '/../../uploads' })
// 该中间件将文件赋值到req.file
app.post('/admin/api/upload', authMiddleware(), upload.single('file'), async (req, res) => {
const file = req.file
file.url = `http://localhost:3000/uploads/${file.filename}`
res.send(file)
})
// 登录
app.post('/admin/api/login', async (req, res) => {
const { username, password } = req.body
// 1. 根据用户名查找用户
// select('+password')表示读取密码字段
const user = await AdminUser.findOne({ username }).select('+password')
assert(user, 422, '用户不存在')
// if (!user) {
// return res.status(422).send({
// message: '用户不存在'
// })
// }
// 2. 检验密码
const isValid = require('bcrypt').compareSync(password, user.password)
assert(isValid, 422, '用户密码错误')
// if (!isValid) {
// return res.status(422).send({
// message: '用户密码错误'
// })
// }
// 3. 返回token
const token = jwt.sign({ id: user._id }, app.get('secret'))
res.send({ token })
})
// 错误处理函数,错误处理函数具有四个参数. 必须 express 5+ 才生效
app.use(async (err, req, res, next) => {
res.status(err.statusCode || 500).send({
message: err.message
})
})
}
客户端的路由限制
未认证的用户访问路由跳转到登录页。使用路由守卫
admin/src/router/index.js
import Vue from 'vue'
import VueRouter from 'vue-router'
import Main from '../views/Main.vue'
import CategoryEdit from '../views/CategoryEdit'
import CategoryList from '../views/CategoryList'
import ItemEdit from '../views/ItemEdit'
import ItemList from '../views/ItemList'
import HeroEdit from '../views/HeroEdit'
import HeroList from '../views/HeroList'
import ArticleEdit from '../views/ArticleEdit'
import ArticleList from '../views/ArticleList'
import AdEdit from '../views/AdEdit'
import AdList from '../views/AdList'
import AdminUserEdit from '../views/AdminUserEdit'
import AdminUserList from '../views/AdminUserList'
import Login from '../views/Login'
Vue.use(VueRouter)
const routes = [
{
path: '/login',
name: 'login',
component: Login,
meta: {
isPublic: true
}
},
{
path: '/',
name: 'Main',
component: Main,
children: [
{
path: '/categories/create',
component: CategoryEdit
},
{
path: '/categories/list',
component: CategoryList
},
{
path: '/categories/edit/:id',
component: CategoryEdit,
props: true
},
{
path: '/items/create',
component: ItemEdit
},
{
path: '/items/list',
component: ItemList
},
{
path: '/items/edit/:id',
component: ItemEdit,
props: true
},
{
path: '/heros/create',
component: HeroEdit
},
{
path: '/heros/list',
component: HeroList
},
{
path: '/heros/edit/:id',
component: HeroEdit,
props: true
},
{
path: '/articles/create',
component: ArticleEdit
},
{
path: '/articles/list',
component: ArticleList
},
{
path: '/articles/edit/:id',
component: ArticleEdit,
props: true
},
{
path: '/ads/create',
component: AdEdit
},
{
path: '/ads/list',
component: AdList
},
{
path: '/ads/edit/:id',
component: AdEdit,
props: true
},
{
path: '/admin_users/create',
component: AdminUserEdit
},
{
path: '/admin_users/list',
component: AdminUserList
},
{
path: '/admin_users/edit/:id',
component: AdminUserEdit,
props: true
}
]
}
]
const router = new VueRouter({
routes
})
router.beforeEach((to, from, next) => {
if (!to.meta.isPublic && !localStorage.token) {
return next('/login')
}
next()
})
export default router
上传文件的登录校验
elementui使用自己封装的请求库进行上传文件,没有带上token信息,导致文件上传不成功。需要手动设置headers
admin/src/main.js
import Vue from 'vue'
import App from './App.vue'
import './plugins/element.js'
import router from './router'
import './style.css'
Vue.config.productionTip = false
import http from './plugins/axios'
Vue.prototype.$http = http
// 全局mixin可以在任意地方使用
Vue.mixin({
computed: {
// 全局计算属性
uploadUrl() {
return this.$http.defaults.baseURL + '/upload'
}
},
methods: {
// 全局方法
getAuthHeaders() {
return {
Authorization: `Bearer ${localStorage.token || ''}`
}
}
}
})
new Vue({
router,
render: h => h(App)
}).$mount('#app')
定义获取请求头的方法,可以在任意位置使用
在所有需要上传的地方设置headers
<el-upload
class="avatar-uploader"
:action="uploadUrl"
:show-file-list="false"
:headers="getAuthHeaders()"
:on-success="res => $set(item, 'image', res.url)"
:before-upload="beforeAvatarUpload"
>
<img v-if="item.image" :src="item.image" class="avatar" />
<i v-else class="el-icon-plus avatar-uploader-icon"></i>
</el-upload>
本节代码
https://github.com/alfalfaw/node-vue-moba/tree/permission
初始化样式
通过sass写工具类样式,减少冗余CSS代码。通常将scss变量和和使用分离开
- 样式重置
- 网站色彩和字体定义
- 通用flex布局样式
- 常用边距定义
web/src/scss/_variables.scss
// colors
$colors: (
'primary': #db9e3f,
'info': #4b67af,
'blue': #4394e4,
'white': #fff,
'light': #f9f9f9,
'light-1': #d4d9de,
'grey-1': #999,
'grey': #666,
'dark-1': #342440,
'dark': #222,
'black': #000
);
// 边框颜色
$border-color: map-get($colors, 'light-1');
// font size
// 按 alt+s 重新设置rem和px换算比例,alt+z 进行换算
$base-font-size: 13px;
$font-sizes: (
// 8px
xxs: 0.6154,
// 10px
xs: 0.7692,
// 12px
sm: 0.9231,
// 13px
md: 1,
// 14px
lg: 1.0769,
// 16px
xl: 1.2308
);
// flex
$flex-jc: (
start: flex-start,
end: flex-end,
center: center,
between: space-between,
around: space-around
);
$flex-ai: (
start: flex-start,
end: flex-end,
center: center,
stretch: stretch
);
// margin padding
$spacing-types: (
m: margin,
p: padding
);
$spacing-directions: (
t: top,
r: right,
b: bottom,
l: left
);
$spacing-base-size: 1rem;
$spacing-sizes: (
0: 0,
1: 0.25,
2: 0.5,
3: 1,
4: 1.5,
5: 3
);
web/src/scss/style.scss
@import './variables';
// 重置样式
* {
box-sizing: border-box;
outline: none;
}
html {
font-size: 13px;
}
body {
margin: 0;
font-family: Arial, Helvetica, sans-serif;
line-height: 1.2em;
background: #f1f1f1;
// 设置字体更加平滑
-webkit-font-smoothing: antialiased;
}
a {
color: #999;
}
@each $colorKey, $color in $colors {
// text color
.text-#{$colorKey} {
color: $color;
}
// bg color
.bg-#{$colorKey} {
background-color: $color;
}
}
// text align
@each $var in (left, center, right) {
.text-#{$var} {
text-align: $var !important;
}
}
@each $sizeKey, $size in $font-sizes {
.fs-#{$sizeKey} {
font-size: $size * $base-font-size;
}
}
// text overflow
.text-ellipsis {
display: inline-block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
// width ,height
.w-100 {
width: 100%;
}
.h-100 {
height: 100%;
}
// flex
.d-flex {
display: flex;
}
.flex-column {
flex-direction: column;
}
.flex-wrap {
flex-wrap: wrap;
}
@each $key, $value in $flex-jc {
.jc-#{$key} {
justify-content: $value;
}
}
@each $key, $value in $flex-ai {
.ai-#{$key} {
align-items: $value;
}
}
// flex 包括flex-grow、flex-shrink、flex-basis
.flex-1 {
flex: 1;
}
// 自动拉伸
.flex-grow-1 {
flex-grow: 1;
}
// margin padding
@each $typeKey, $type in $spacing-types {
// .m-1
@each $sizeKey, $size in $spacing-sizes {
.#{$typeKey}-#{$sizeKey} {
#{$type}: $size * $spacing-base-size;
}
}
// .mx-1 .my-1
@each $sizeKey, $size in $spacing-sizes {
.#{$typeKey}x-#{$sizeKey} {
#{$type}-left: $size * $spacing-base-size;
#{$type}-right: $size * $spacing-base-size;
}
.#{$typeKey}y-#{$sizeKey} {
#{$type}-top: $size * $spacing-base-size;
#{$type}-bottom: $size * $spacing-base-size;
}
}
// .mt-1
@each $directionKey, $direction in $spacing-directions {
@each $sizeKey, $size in $spacing-sizes {
.#{$typeKey}#{$directionKey}-#{$sizeKey} {
#{$type}-#{$direction}: $size * $spacing-base-size;
}
}
}
}
// button
.btn {
border: none;
border-radius: 0.1538rem;
font-size: map-get($font-sizes, 'sm') * $base-font-size;
padding: 0.2rem 0.6rem;
}
首页顶部轮播图片
全局引入轮播组件
web/src/main.js
import VueAwesomeSwiper from 'vue-awesome-swiper'
// import style
import 'swiper/css/swiper.css'
Vue.use(VueAwesomeSwiper /* { default options with global component } */)
轮播组件的使用
web/src/views/Home.vue
<template>
<div>
<swiper :options="swiperOptions">
<swiper-slide>
<img class="w-100" src="https://ossweb-img.qq.com/upload/adw/image/20200612/b3d9f3b40ea5f3136adb87e05faaae63.png" alt="" />
</swiper-slide>
<swiper-slide>
<img class="w-100" src="https://ossweb-img.qq.com/upload/adw/image/20200612/b3d9f3b40ea5f3136adb87e05faaae63.png" alt="" />
</swiper-slide>
<swiper-slide>
<img class="w-100" src="https://ossweb-img.qq.com/upload/adw/image/20200612/b3d9f3b40ea5f3136adb87e05faaae63.png" alt="" />
</swiper-slide>
<div class="swiper-pagination pagination-home text-right px-3 pb-1" slot="pagination"></div>
</swiper>
</div>
</template>
<script>
export default {
data() {
return {
swiperOptions: {
pagination: {
el: '.pagination-home'
}
// Some Swiper option/callback...
}
}
}
}
</script>
<style lang="scss">
@import '../scss/variables';
.pagination-home {
.swiper-pagination-bullet {
opacity: 1;
border-radius: 0.1538rem;
background: map-get($colors, 'white');
&.swiper-pagination-bullet-active {
background: map-get($colors, 'info');
}
}
}
</style>
本节代码
https://github.com/alfalfaw/node-vue-moba/tree/swiper
精灵图
使用精灵图可以减少前端向后端的请求次数
web/src/views/Home.vue
<template>
<div>
<swiper :options="swiperOptions">
<swiper-slide>
<img class="w-100" src="https://ossweb-img.qq.com/upload/adw/image/20200612/b3d9f3b40ea5f3136adb87e05faaae63.png" alt="" />
</swiper-slide>
<swiper-slide>
<img class="w-100" src="https://ossweb-img.qq.com/upload/adw/image/20200612/b3d9f3b40ea5f3136adb87e05faaae63.png" alt="" />
</swiper-slide>
<swiper-slide>
<img class="w-100" src="https://ossweb-img.qq.com/upload/adw/image/20200612/b3d9f3b40ea5f3136adb87e05faaae63.png" alt="" />
</swiper-slide>
<div class="swiper-pagination pagination-home text-right px-3 pb-1" slot="pagination"></div>
</swiper>
<!-- end of swiper -->
<div class="nav-icons bg-white mt-3 text-center pt-3 text-grey">
<div class="d-flex flex-wrap ">
<!-- 注意不要同时设置元素上下的边距,应该设置元素一方边距配合父元素的的内边距,否则中间会出现边距叠加 -->
<div class="nav-item mb-3" v-for="n in 10" :key="n">
<i class="sprite sprite-news"> </i>
<div class="py-2">爆料站</div>
</div>
</div>
<div class="bg-light py-2 fs-sm d-flex ai-center jc-center">
<i class="sprite sprite-arrow mr-1"></i>
收起
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
swiperOptions: {
pagination: {
el: '.pagination-home'
}
// Some Swiper option/callback...
}
}
}
}
</script>
<style lang="scss">
@import '../scss/variables';
.pagination-home {
.swiper-pagination-bullet {
opacity: 1;
border-radius: 0.1538rem;
background: map-get($colors, 'white');
&.swiper-pagination-bullet-active {
background: map-get($colors, 'info');
}
}
}
.nav-icons {
border-top: 1px solid $border-color;
border-bottom: 1px solid $border-color;
.nav-item {
width: 25%;
border-right: 1px solid $border-color;
&:nth-child(4n) {
border-right: none;
}
}
}
// sprite
.sprite {
background: url(https://game.gtimg.cn/images/yxzj/m/m201706/images/bg/index.png) no-repeat 0 0;
background-size: 28.8462rem;
display: inline-block;
&.sprite-news {
background-position: 9.302% 0.813%;
width: 2.3846rem;
height: 1.9231rem;
}
&.sprite-arrow {
background-position: 38.577% 52.076%;
width: 0.7692rem;
height: 0.7692rem;
}
}
</style>
精灵图通过调整背景图的位置来显示图片不同部分。辅助定位的网站http://www.spritecow.com/
本节代码
https://github.com/alfalfaw/node-vue-moba/tree/sprite
字体图标
使用阿里的字体图标库iconfont,网址 https://www.iconfont.cn/
选择需要的图标,下载源码,将其放在assets/iconfont目录下
web/src/main.js
// iconfont
import './assets/iconfont/iconfont.css'
使用
<i class="iconfont icon-xxx"></i>
注意:字体图标可以控制大小和颜色
本节代码
https://github.com/alfalfaw/node-vue-moba/tree/iconfont
新闻资讯展示
导入web路由
web/src/main.js
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import './assets/scss/style.scss'
// iconfont
import './assets/iconfont/iconfont.css'
import VueAwesomeSwiper from 'vue-awesome-swiper'
// import style
import 'swiper/css/swiper.css'
Vue.use(VueAwesomeSwiper /* { default options with global component } */)
Vue.config.productionTip = false
import Card from './components/Card'
Vue.component('m-card', Card)
import ListCard from './components/ListCard'
Vue.component('m-list-card', ListCard)
import axios from 'axios'
Vue.prototype.$http = axios.create({
baseURL: 'http://localhost:3000/web/api'
})
new Vue({
router,
render: h => h(App)
}).$mount('#app')
首页
web/src/views/Main.vue
<template>
<div>
<div class="topbar bg-black py-2 px-3 d-flex ai-center">
<img src="../assets/logo.png" alt="" height="30px" />
<div class="px-2 flex-1">
<div class="text-white">王者荣耀</div>
<div class="text-grey fs-xxs ">团队成就更多</div>
</div>
<button type="button" class="btn bg-primary">立即下载</button>
</div>
<div class="top-nav bg-primary pt-3 pb-2">
<div class="nav d-flex text-white jc-around pb-1">
<div class="nav-item active">
<router-link tag="div" to="/">首页</router-link>
</div>
<div class="nav-item">
<router-link tag="div" to="/">攻略中心</router-link>
</div>
<div class="nav-item">
<router-link tag="div" to="/">赛事中心</router-link>
</div>
</div>
</div>
<router-view></router-view>
</div>
</template>
<script>
export default {}
</script>
<style lang="scss">
@import '../assets/scss/variables';
.top-nav {
.nav {
.nav-item {
// 即使透明也要加边框,防止不对齐
border-bottom: 3px solid transparent;
padding-bottom: 0.2rem;
&.active {
border-bottom: 3px solid white;
}
}
}
}
.topbar {
position: sticky;
top: 0;
z-index: 999;
}
</style>
新闻列表
日期格式化
web/src/views/Home.vue
<template>
<div>
<swiper :options="swiperOptions">
<swiper-slide>
<img class="w-100" src="https://ossweb-img.qq.com/upload/adw/image/20200612/b3d9f3b40ea5f3136adb87e05faaae63.png" alt="" />
</swiper-slide>
<swiper-slide>
<img class="w-100" src="https://ossweb-img.qq.com/upload/adw/image/20200612/b3d9f3b40ea5f3136adb87e05faaae63.png" alt="" />
</swiper-slide>
<swiper-slide>
<img class="w-100" src="https://ossweb-img.qq.com/upload/adw/image/20200612/b3d9f3b40ea5f3136adb87e05faaae63.png" alt="" />
</swiper-slide>
<div class="swiper-pagination pagination-home text-right px-3 pb-1" slot="pagination"></div>
</swiper>
<!-- end of swiper -->
<div class="nav-icons bg-white mt-3 text-center pt-3 text-grey">
<div class="d-flex flex-wrap ">
<!-- 注意不要同时设置元素上下的边距,应该设置元素一方边距配合父元素的的内边距,否则中间会出现边距叠加 -->
<div class="nav-item mb-3" v-for="n in 10" :key="n">
<i class="sprite sprite-news"> </i>
<div class="py-2">爆料站</div>
</div>
</div>
<div class="bg-light py-2 fs-sm d-flex ai-center jc-center">
<i class="sprite sprite-arrow mr-1"></i>
收起
</div>
</div>
<m-list-card title="新闻资讯" icon="news" :categories="newsCats">
<!-- 取插槽中的值用 # -->
<template #items="{ category }">
<div class="py-2 fs-lg d-flex" v-for="(news, index) in category.newsList" :key="index">
<span class="text-info">[{{ news.categoryName }}]</span>
<span class="px-2">|</span>
<span class="flex-1 text-dark text-ellipsis pr-2">{{ news.title }}</span>
<span class="text-grey-1 fs-sm">{{ news.createdAt | date }}</span>
</div>
</template>
</m-list-card>
</div>
</template>
<script>
import dayjs from 'dayjs'
export default {
filters: {
date(val) {
return dayjs(val).format('MM/DD')
}
},
data() {
return {
swiperOptions: {
pagination: {
el: '.pagination-home'
}
// Some Swiper option/callback...
},
// 新闻分类
newsCats: []
}
},
created() {
this.fetchNewsCats()
},
methods: {
async fetchNewsCats() {
const res = await this.$http.get('news/list')
this.newsCats = res.data
}
}
}
</script>
<style lang="scss">
@import '../assets/scss/variables';
.pagination-home {
.swiper-pagination-bullet {
opacity: 1;
border-radius: 0.1538rem;
background: map-get($colors, 'white');
&.swiper-pagination-bullet-active {
background: map-get($colors, 'info');
}
}
}
.nav-icons {
border-top: 1px solid $border-color;
border-bottom: 1px solid $border-color;
.nav-item {
width: 25%;
border-right: 1px solid $border-color;
&:nth-child(4n) {
border-right: none;
}
}
}
// sprite
.sprite {
background: url(https://game.gtimg.cn/images/yxzj/m/m201706/images/bg/index.png) no-repeat 0 0;
background-size: 28.8462rem;
display: inline-block;
&.sprite-news {
background-position: 9.302% 0.813%;
width: 2.3846rem;
height: 1.9231rem;
}
&.sprite-arrow {
background-position: 38.577% 52.076%;
width: 0.7692rem;
height: 0.7692rem;
}
}
</style>
新闻咨询接口
本节比较复杂,涉及聚合查询,模拟数据等
server/routes/web/index.js
module.exports = (app) => {
const router = require('express').Router()
const Category = require('../../models/Category')
const Article = require('../../models/Article')
// 初始化数据
router.get('/news/init', async (req, res) => {
const parent = await Category.find().where({
name: '新闻分类'
})
const cats = await Category.find().where({ parent }).lean()
let i = 0
const newsTitles = new Array(20).fill({}).map(() => {
i++
return `新闻${i}`
})
const newsList = newsTitles.map((title) => {
const randomCats = cats.slice(0).sort((a, b) => Math.random() - 0.5)
return {
categories: randomCats.slice(0, 2),
title
}
})
// 清空数据库
await Article.deleteMany({})
// 插入数据库
await Article.insertMany(newsList)
res.send(newsList)
})
router.get('/news/list', async (req, res) => {
// 该方法有些问题
// const parent = await Category.findOne({
// name: '新闻分类'
// })
// .populate({
// path: 'children',
// populate: {
// path: 'newsList'
// }
// })
// .lean()
const parent = await Category.findOne({ name: '新闻分类' })
// 将文章进行分类
const cats = await Category.aggregate([
// 用match过滤掉不属于该大类的数据
{ $match: { parent: parent._id } },
// 用lookup进行关联查询
{
$lookup: {
from: 'articles',
localField: '_id',
foreignField: 'categories',
as: 'newsList'
}
},
// 修改查询结果(减少结果个数)
{
$addFields: {
newsList: {
$slice: ['$newsList', 5]
}
}
}
])
const subCats = cats.map((v) => v._id)
// 添加新闻综合
cats.unshift({
name: '热门',
newsList: await Article.find()
.where({
categories: { $in: subCats }
})
.populate('categories')
.limit(5)
.lean()
})
cats.map((cat) => {
cat.newsList.map((news) => {
news.categoryName = cat.name === '热门' ? news.categories[0].name : cat.name
return news
})
})
res.send(cats)
})
app.use('/web/api', router)
}
卡片
web/src/components/Card.vue
<template>
<div class="card bg-white p-3 mt-3">
<div class="card-header d-flex ai-center " :class="{ 'border-bottom': !plain, 'pb-3': !plain }">
<i class="icon iconfont" :class="`icon-${icon}`"></i>
<div class="fs-xxl flex-1 px-2">
<strong> {{ title }}</strong>
</div>
<i class="icon iconfont icon-menu" v-if="!plain"></i>
</div>
<div class="card-body fs-sm pt-2">
<!-- 不具名插槽 -->
<slot></slot>
</div>
</div>
</template>
<script>
export default {
props: {
title: {
type: String,
required: true
},
icon: {
type: String,
required: true
},
plain: {
type: Boolean
}
}
}
</script>
<style lang="scss">
@import '../assets/scss/variables';
.card {
border-bottom: 1px solid $border-color;
}
</style>
卡片列表
涉及插槽传值
web/src/components/ListCard.vue
<template>
<m-card :title="title" :icon="icon">
<div class="d-flex nav jc-between my-1">
<div
class="nav-item"
:class="{ active: active === index }"
v-for="(category, index) in categories"
:key="index"
@click="$refs.list.$swiper.slideTo(index)"
>
<div class="nav-link">{{ category.name }}</div>
</div>
</div>
<div>
<swiper :options="{ autoHeight: true }" ref="list" @slide-change="() => (active = $refs.list.$swiper.realIndex)">
<swiper-slide v-for="(category, index) in categories" :key="index">
<!-- 具名插槽 -->
<slot name="items" :category="category"></slot>
</swiper-slide>
</swiper>
</div>
</m-card>
</template>
<script>
export default {
props: {
icon: {
type: String,
required: true
},
title: {
type: String,
required: true
},
categories: {
type: Array,
required: true
}
},
data() {
return {
active: 0
}
}
}
</script>
<style lang="scss">
@import '../assets/scss/variables';
.card-body {
.nav {
.nav-item {
// 即使透明也要加边框,防止不对齐
border-bottom: 3px solid transparent;
padding-bottom: 0.2rem;
&.active {
color: map-get($colors, 'primary');
border-bottom: 3px solid map-get($colors, 'primary');
}
}
}
}
</style>
英雄展示
抓取数据
从网页中提取数据的方法,在浏览器终端输入
$$()
可以使用jquery的语法对页面元素进行提取
注意$$
相当于querySelectorAll()
,$
相当于querySelector()
。以下语法在chrome浏览器通过,在firefox取值有问题
JSON.stringify(
$$('.hero-nav>li').map((li, i) => {
return {
name: li.innerText,
heros: $$('li', $$('.hero-list')[i]).map((el) => {
return {
name: $$('h3', el)[0].innerHTML,
avater: $$('img', el)[0].src
}
})
}
})
)
获取英雄接口
server/routes/web/index.js
module.exports = (app) => {
const router = require('express').Router()
const Category = require('../../models/Category')
const Article = require('../../models/Article')
const Hero = require('../../models/Hero')
// ...
// 初始化英雄数据
router.get('/heros/init', async (req, res) => {
const rawData = [
{
name: '热门',
heros: [
{ name: '后羿', avater: 'https://game.gtimg.cn/images/yxzj/img201606/heroimg/169/169.jpg' },
{ name: '孙悟空', avater: 'https://game.gtimg.cn/images/yxzj/img201606/heroimg/167/167.jpg' },
{ name: '铠', avater: 'https://game.gtimg.cn/images/yxzj/img201606/heroimg/193/193.jpg' },
{ name: '安琪拉', avater: 'https://game.gtimg.cn/images/yxzj/img201606/heroimg/142/142.jpg' },
{ name: '亚瑟', avater: 'https://game.gtimg.cn/images/yxzj/img201606/heroimg/166/166.jpg' },
{ name: '鲁班七号', avater: 'https://game.gtimg.cn/images/yxzj/img201606/heroimg/112/112.jpg' },
{ name: '妲己', avater: 'https://game.gtimg.cn/images/yxzj/img201606/heroimg/109/109.jpg' },
{ name: '甄姬', avater: 'https://game.gtimg.cn/images/yxzj/img201606/heroimg/127/127.jpg' },
{ name: '韩信', avater: 'https://game.gtimg.cn/images/yxzj/img201606/heroimg/150/150.jpg' },
{ name: '伽罗', avater: 'https://game.gtimg.cn/images/yxzj/img201606/heroimg/508/508.jpg' }
]
}
]
}
]
// 删除英雄分类下所有子分类
const hero_cat = await Category.findOne({ name: '英雄分类' })
await Category.deleteMany({ parent: hero_cat })
// 删除所有英雄
await Hero.deleteMany({})
// in 只循环下标,of循环元素
for (let cat of rawData) {
if (cat.name === '热门') continue
// 录入英雄子分类
let category = new Category({ name: cat.name, parent: hero_cat })
let newCat = await category.save()
// 录入英雄
await Hero.insertMany(
cat.heros.map((hero) => {
return { ...hero, categories: [newCat] }
})
)
}
res.send({ msg: '录入英雄成功' })
})
// 英雄列表接口
router.get('/heros/list', async (req, res) => {
const parent = await Category.findOne({ name: '英雄分类' })
// 将英雄进行分类
const cats = await Category.aggregate([
// 用match过滤掉不属于该大类的数据
{ $match: { parent: parent._id } },
// 用lookup进行关联查询
{
$lookup: {
from: 'heros',
localField: '_id',
foreignField: 'categories',
as: 'heroList'
}
}
])
const subCats = cats.map((v) => v._id)
// 添加热门
cats.unshift({
name: '热门',
heroList: await Hero.find()
.where({
categories: { $in: subCats }
})
.limit(10)
.lean()
})
res.send(cats)
})
app.use('/web/api', router)
}
英雄列表展示
web/src/views/Home.vue
<template>
<div>
<swiper :options="swiperOptions">
<swiper-slide>
<img class="w-100" src="https://ossweb-img.qq.com/upload/adw/image/20200612/b3d9f3b40ea5f3136adb87e05faaae63.png" alt="" />
</swiper-slide>
<swiper-slide>
<img class="w-100" src="https://ossweb-img.qq.com/upload/adw/image/20200612/b3d9f3b40ea5f3136adb87e05faaae63.png" alt="" />
</swiper-slide>
<swiper-slide>
<img class="w-100" src="https://ossweb-img.qq.com/upload/adw/image/20200612/b3d9f3b40ea5f3136adb87e05faaae63.png" alt="" />
</swiper-slide>
<div class="swiper-pagination pagination-home text-right px-3 pb-1" slot="pagination"></div>
</swiper>
<!-- end of swiper -->
<div class="nav-icons bg-white mt-3 text-center pt-3 text-grey">
<div class="d-flex flex-wrap ">
<!-- 注意不要同时设置元素上下的边距,应该设置元素一方边距配合父元素的的内边距,否则中间会出现边距叠加 -->
<div class="nav-item mb-3" v-for="n in 10" :key="n">
<i class="sprite sprite-news"> </i>
<div class="py-2">爆料站</div>
</div>
</div>
<div class="bg-light py-2 fs-sm d-flex ai-center jc-center">
<i class="sprite sprite-arrow mr-1"></i>
收起
</div>
</div>
<m-list-card title="新闻资讯" icon="news" :categories="newsCats">
<!-- 取插槽中的值用 # -->
<template #items="{ category }">
<div class="py-2 fs-lg d-flex" v-for="(news, index) in category.newsList" :key="index">
<span class="text-info">[{{ news.categoryName }}]</span>
<span class="px-2">|</span>
<span class="flex-1 text-dark text-ellipsis pr-2">{{ news.title }}</span>
<span class="text-grey-1 fs-sm">{{ news.createdAt | date }}</span>
</div>
</template>
</m-list-card>
<m-list-card title="英雄列表" icon="card-hero" :categories="heroCats">
<!-- 取插槽中的值用 # -->
<template #items="{ category }">
<div class="d-flex flex-wrap" style="margin:0 -0.5rem;">
<div class="p-2 text-center" style="width:20%;" v-for="(hero, index) in category.heroList" :key="index">
<img class="w-100" :src="hero.avater" alt="" />
<div>{{ hero.name }}</div>
</div>
</div>
</template>
</m-list-card>
</div>
</template>
<script>
import dayjs from 'dayjs'
export default {
filters: {
date(val) {
return dayjs(val).format('MM/DD')
}
},
data() {
return {
swiperOptions: {
pagination: {
el: '.pagination-home'
}
// Some Swiper option/callback...
},
// 新闻分类
newsCats: [],
// 英雄分类
heroCats: []
}
},
created() {
this.fetchNewsCats()
this.fetchHeroCats()
},
methods: {
async fetchNewsCats() {
const res = await this.$http.get('news/list')
this.newsCats = res.data
},
async fetchHeroCats() {
const res = await this.$http.get('heros/list')
this.heroCats = res.data
}
}
}
</script>
<style lang="scss">
@import '../assets/scss/variables';
.pagination-home {
.swiper-pagination-bullet {
opacity: 1;
border-radius: 0.1538rem;
background: map-get($colors, 'white');
&.swiper-pagination-bullet-active {
background: map-get($colors, 'info');
}
}
}
.nav-icons {
border-top: 1px solid $border-color;
border-bottom: 1px solid $border-color;
.nav-item {
width: 25%;
border-right: 1px solid $border-color;
&:nth-child(4n) {
border-right: none;
}
}
}
// sprite
.sprite {
background: url(https://game.gtimg.cn/images/yxzj/m/m201706/images/bg/index.png) no-repeat 0 0;
background-size: 28.8462rem;
display: inline-block;
&.sprite-news {
background-position: 9.302% 0.813%;
width: 2.3846rem;
height: 1.9231rem;
}
&.sprite-arrow {
background-position: 38.577% 52.076%;
width: 0.7692rem;
height: 0.7692rem;
}
}
</style>
本节代码
https://github.com/alfalfaw/node-vue-moba/tree/hero-list
新闻详情页
获取新闻详情接口
server/routes/web/index.js
// 文章详情
router.get('/articles/:id', async (req, res) => {
// lean()表示文档转化为js对象
const data = await Article.findById(req.params.id).lean()
data.related = await Article.find()
.where({
categories: { $in: data.categories }
})
.limit(2)
res.send(data)
})
新闻详情展示
web/src/views/Article.vue
<template>
<div class="page-article" v-if="model">
<div class="d-flex py-3 px-2 ai-center border-bottom">
<div class="iconfont icon-back text-blue"></div>
<strong class="flex-1 text-blue pl-2 text-ellipsis">{{ model.title }}</strong>
<div class="text-grey fs-xs">
2020-5-19
</div>
</div>
<div v-html="model.body" class="px-3 body"></div>
<div class="px-3 border-top pt-3">
<div class="d-flex ai-center">
<span class="icon iconfont icon-menu"></span>
<strong class="text-blue fs-lg ml-1">相关资讯</strong>
</div>
</div>
<div class="pt-2 px-3">
<router-link class="py-1" :to="`/articles/${item._id}`" tag="div" v-for="item in model.related" :key="item._id">
{{ item.title }}
</router-link>
</div>
</div>
</template>
<script>
export default {
props: {
id: { requires: true }
},
data() {
return {
model: {}
}
},
created() {
this.fetch()
},
methods: {
async fetch() {
const res = await this.$http.get(`articles/${this.id}`)
this.model = res.data
}
}
}
</script>
<style lang="scss">
.page-article {
.icon-back {
font-size: 1.6923rem;
}
.body {
img {
max-width: 100%;
height: auto;
}
.iframe {
width: 100%;
height: auto;
}
}
}
</style>
本节代码
https://github.com/alfalfaw/node-vue-moba/tree/news-detail
英雄详情页
点击浏览器的颜色图标可以进行颜色拾取
获取英雄详情路由
**server/routes/web/index.js **
// 英雄详情
router.get('/heros/:id', async (req, res) => {
const data = await Hero.findById(req.params.id).populate('categories items1 items2 partners.hero').lean()
res.send(data)
})
英雄详情展示
web/src/views/Hero.vue
<template>
<div class="page-hero" v-if="model">
<div class="topbar bg-black py-2 px-3 d-flex ai-center text-white">
<img src="../assets/logo.png" alt="" height="30px" />
<div class="px-2 flex-1">
<span class="text-white">王者荣耀</span>
<span class="text-white pl-2">攻略站</span>
</div>
<router-link to="/" tag="div" class="fs-sm">更多英雄 ></router-link>
</div>
<div class="top" :style="{ 'background-image': `url(${model.banner})` }">
<div class="info text-white p-3 d-flex flex-column jc-end h-100">
<div class="fs-sm">{{ model.title }}</div>
<h2 class="my-2">{{ model.name }}</h2>
<div class="fs-sm">
{{ model.categories.map(v => v.name).join('/') }}
</div>
<div class="d-flex jc-between pt-2">
<div class="scores d-flex ai-center" v-if="model.scores">
<span>难度</span>
<span class="badge bg-brown"> {{ model.scores.difficult }} </span>
<span>技能</span>
<span class="badge bg-blue-1"> {{ model.scores.skills }} </span>
<span>攻击</span>
<span class="badge bg-danger"> {{ model.scores.attack }} </span>
<span>生存</span>
<span class="badge bg-dark"> {{ model.scores.survive }} </span>
</div>
<router-link to="/" tag="span">皮肤2 ></router-link>
</div>
</div>
</div>
<!-- end of top -->
<div>
<div class="bg-white px-3">
<div class="nav d-flex pt-3 pb-2 jc-around border-bottom">
<div class="nav-item active">
<div class="nav-link">
英雄初识
</div>
</div>
<div class="nav-item">
<div class="nav-link">
进阶攻略
</div>
</div>
</div>
</div>
<swiper>
<swiper-slide>
<div>
<div class="p-3 bg-white border-bottom">
<div class="d-flex">
<router-link tag="button" to="/" class="btn btn-lg flex-1 ">
<i class="icon iconfont icon-menu"></i>
英雄介绍视频
</router-link>
<router-link tag="button" to="/" class="btn btn-lg flex-1 ml-2 ">
<i class="icon iconfont icon-menu "></i>
一图识英雄
</router-link>
</div>
<div class="skills mt-4">
<div class="d-flex jc-around">
<img
@click="currentSkillIndex = i"
class="icon"
:class="{ active: currentSkillIndex === i }"
:src="item.icon"
v-for="(item, i) in model.skills"
:key="item.name"
alt=""
width="60"
height="60"
style="border-radius:50%;"
/>
</div>
<div v-if="currentSkill">
<div class="d-flex ai-center pt-4 pb-3">
<h3 class="m-0">{{ currentSkill.name }}</h3>
<span class="text-grey-1 ml-4"> (冷却值:{{ currentSkill.delay }} 消耗:{{ currentSkill.cost }}) </span>
</div>
<p>{{ currentSkill.description }}</p>
<div class="border-bottom"></div>
<p>小提示:{{ currentSkill.tips }}</p>
</div>
</div>
</div>
<m-card plain icon="menu" title="出装推荐" class="hero-items">
<div class="fs-lg mt-1">
顺风出装
</div>
<div class="d-flex jc-around text-center mt-2">
<div v-for="item in model.items1" :key="item.name">
<img :src="item.icon" alt="" />
<div class="fs-xs">{{ item.name }}</div>
</div>
</div>
<div class="border-bottom mt-3"></div>
<div class="fs-lg mt-3">
逆风出装
</div>
<div class="d-flex jc-around text-center mt-2">
<div v-for="item in model.items2" :key="item.name">
<img :src="item.icon" alt="" />
<div class="fs-xs">{{ item.name }}</div>
</div>
</div>
</m-card>
<m-card plain icon="menu" title="使用技巧">
<p class="m-0">{{ model.usageTips }}</p>
</m-card>
<m-card plain icon="menu" title="对抗技巧">
<p class="m-0">{{ model.battleTips }}</p>
</m-card>
<m-card plain icon="menu" title="团战思路">
<p class="m-0">{{ model.teamTips }}</p>
</m-card>
<m-card plain icon="menu" title="英雄关系">
<div class="fs-xl ">
最佳搭档
</div>
<div class="pt-3" v-for="item in model.partners" :key="item.name">
<div class="d-flex">
<img :src="item.hero.avater" alt="" height="50" />
<p class="flex-1 ml-3 m-0">
{{ item.description }}
</p>
</div>
<div class="border-bottom mt-3"></div>
</div>
</m-card>
</div>
</swiper-slide>
</swiper>
</div>
</div>
</template>
<script>
export default {
props: {
id: {
required: true
}
},
data() {
return {
model: null,
currentSkillIndex: 0
}
},
created() {
this.fetch()
},
methods: {
async fetch() {
const res = await this.$http.get(`heros/${this.id}`)
this.model = res.data
}
},
computed: {
currentSkill() {
return this.model.skills[this.currentSkillIndex]
}
}
}
</script>
<style lang="scss" scoped>
@import '../assets/scss/variables';
.page-hero {
.topbar {
position: sticky;
top: 0;
z-index: 999;
}
.top {
height: 50vw;
background-size: auto 100%;
background: #fff no-repeat top center;
}
.info {
background: linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 1));
.scores {
.badge {
margin: 0 0.25rem;
display: inline-block;
width: 1rem;
height: 1rem;
line-height: 0.9rem;
text-align: center;
border-radius: 50%;
font-size: 0.6rem;
border: 1px solid rgba(255, 255, 255, 0.2);
}
}
}
.nav {
.nav-item {
// 即使透明也要加边框,防止不对齐
border-bottom: 3px solid transparent;
padding-bottom: 0.2rem;
&.active {
.nav-link {
color: map-get($colors, 'primary');
}
border-bottom: 3px solid map-get($colors, 'primary');
}
}
}
.skills {
img.icon {
border: 3px solid map-get($colors, 'white');
&.active {
border-color: map-get($colors, 'primary');
}
}
}
.hero-items {
img {
width: 45px;
height: 45px;
border-radius: 50%;
}
}
}
</style>
本节代码
https://github.com/alfalfaw/node-vue-moba/tree/hero-detail
上线和发布
使用
npm run build
生成静态文件,将静态文件转移到server的静态资源目录,这样可以通过locahost:3000/path/to/file
访问到网站
编译
npm run build
在项目根目录下生成dist文件夹
模拟运行
安装插件serve
npm install -g serve
使用插件
serve dist
serve dist
将dist文件夹作为根目录运行web服务器,运行后可以通过localhost:5000
访问
配置打包编译目录
web/vue.config.js
module.exports = {
// 文件打包路径
outputDir: __dirname + '/../server/web'
// 配置静态文件的路径,默认打包后的静态文件引用路径是 /css 、/js ,如果需要将其放置到子文件夹,如访问路径为 /admin/css 、 /admin/js ,就需要进行如下配置
// publicPath: process.env.NODE_ENV === 'production' ? '/web' : '/'
}
admin/vue.config.js
module.exports = {
// 文件打包路径
outputDir: __dirname + '/../server/admin',
// 配置静态文件的路径,默认打包后的静态文件引用路径是 /css 、/js ,如果需要将其放置到子文件夹,如访问路径为 /admin/css 、 /admin/js ,就需要进行如下配置
publicPath: process.env.NODE_ENV === 'production' ? '/admin' : '/'
}
配置环境变量
web/.env.development
VUE_APP_API_URL=http://localhost:3000/web/api
admin/.env.development
VUE_APP_API_URL=http://localhost:3000/admin/api
使用环境变量
admin/src/plugins/axios.js
const http = axios.create({
// 变量名必须以VUE_APP开头
baseURL: process.env.VUE_APP_API_URL || '/admin/api'
// baseURL: 'http://localhost:3000/admin/api'
})
web/src/main.js
import axios from 'axios'
Vue.prototype.$http = axios.create({
// baseURL: 'http://localhost:3000/web/api'
baseURL: process.env.VUE_APP_API_URL || '/web/api'
})
配置静态文件访问目录
server/index.js
// 托管静态文件
app.use('/uploads', express.static(__dirname + '/uploads'))
// 通过 /admin 访问
app.use('/admin', express.static(__dirname + '/admin'))
// 通过 / 访问
app.use('/', express.static(__dirname + '/web'))
域名和服务器购买
服务器购买
如果临时使用服务器,可以选择按时计费的方案
域名解析
A记录解析到IP地址(解析后访问域名和IP效果等同),CNAME解析到另一个域名。
Nginx
假设后端项目的地址是localhost:3000
,我们希望通过80端口访问到,一种办法是让后端运行在80端口,但更好的办法是用Nginx转发请求。
安装(以Ubuntu为例)
# 更新软件包
apt update
# 查找nginx
apt show nginx
# 安装nginx
apt install nginx -y
测试安装
访问域名或者ip地址,显示Nginx欢迎页面表示安装成功。注意在防火墙开启端口
MongoDB
安装
apt install -y mongodb-server
测试安装
mongo
服务器配置git、ssh-key
参考//www.greatytc.com/p/bc9bee602d2a
NodeJS安装、配置淘宝镜像
安装node
apt install -y nodejs
安装npm
apt install -y npm
配置淘宝镜像
npm config set registry https://registry.npm.taobao.org
切换npm镜像工具
安装nrm
npm i -g nrm
使用nrm
# 当前镜像源
nrm current
# 使用淘宝源
nrm use taobao
# 使用npm官方源
nrm use npm
升级node
安装n
# node升级工具
npm i -g n
使用n
# 升级到最新版本NodeJS
n latest
拉取代码、安装pm2启动项目
安装pm2
npm i -g pm2
运行项目
pm2 start index.js --name web
配置Nginx的反向代理
在VSCode配置服务器上的文件
具体参考//www.greatytc.com/p/7fe0222d77f4
连接上服务器之后,点击右侧的open folder
可以浏览服务器的目录并打开
自定义配置
这个网址可以帮助你生成Nginx配置文件https://www.digitalocean.com/community/tools/nginx
注意先不要勾选https,否则解压可能会出错。解压后目录如下
|____sites-enabled
| |____moba.alfalfa.website.conf
|____sites-available
| |____moba.alfalfa.website.conf
|____nginx.conf
|____nginxconfig.io
| |____security.conf
| |____proxy.conf
| |____general.conf
迁移本地数据到服务器
导出数据库
mongodump -d node-vue-moba
运行后会在当前目录生成一个dump文件夹,将该文件夹上传至服务器
恢复数据库
mongorestore
注意运行该命令的路径下必须有刚才导出的
dump
文件夹
修复上传图片地址
server/routes/admin/index.js
// 上传图片
const multer = require('multer')
const upload = multer({ dest: __dirname + '/../../uploads' })
// 该中间件将文件赋值到req.file
app.post('/admin/api/upload', authMiddleware(), upload.single('file'), async (req, res) => {
const file = req.file
// file.url = `http://localhost:3000/uploads/${file.filename}`
file.url = `https://moba.alfalfa.website/uploads/${file.filename}`
res.send(file)
})
在服务器用git pull
拉取修改后的代码。修改后重启项目pm2 reload moba
登录远程数据库
下载robot3T
命令行修改数据(类似JS语法)
db.getCollection('items').find({}).map(doc=>{
doc.icon = doc.icon ? doc.icon.replace('localhost:3000', 'test.example.com') : null
db.items.save(doc)
return doc
})
确保语句执行没有问题再调用
db.items.save(doc)
对数据进行更改
配置网站证书
参考//www.greatytc.com/p/9f90376351a1
使用阿里云OSS云存储存放文件
使用oss存放静态文件可以减轻服务器压力,加快网站访问速度
server/routes/admin/index.js
// 上传图片
const multer = require('multer')
const MAO = require('multer-aliyun-oss')
const upload = multer({
// dest: __dirname + '/../../uploads'
storage: MAO({
config: {
region: '<region>',
accessKeyId: '<accessKeyId>',
accessKeySecret: '<accessKeySecret>',
bucket: '<bucket>'
}
})
})
// 该中间件将文件赋值到req.file
app.post('/admin/api/upload', authMiddleware(), upload.single('file'), async (req, res) => {
const file = req.file
// file.url = `http://localhost:3000/uploads/${file.filename}`
file.url = `https://moba.alfalfa.website/uploads/${file.filename}`
res.send(file)
})