全栈开发王者荣耀官网

环境配置

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">更多英雄 &gt;</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 &gt;</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)
})
本节代码

https://github.com/alfalfaw/node-vue-moba/tree/oss

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,919评论 6 502
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,567评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,316评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,294评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,318评论 6 390
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,245评论 1 299
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,120评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,964评论 0 275
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,376评论 1 313
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,592评论 2 333
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,764评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,460评论 5 344
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,070评论 3 327
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,697评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,846评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,819评论 2 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,665评论 2 354