Skip to content

仿王者荣耀移动端官网全栈项目

javascript
{
  "name": "server",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "serve":"nodemon index.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}
  • 执行npm i -g nodemon全局安装nodemon
  • 就可以在执行npm run serve运行项目了

2-1-基于Element UI的后台管理基础界面搭建

  • npm install element-plus
javascript
import { createRouter, createWebHashHistory } from "vue-router";
import Main from "../views/Main.vue";

const routes = [
  {
    path: "/",
    name: "main",
    component: Main,
  },
];

const router = createRouter({
  history: createWebHashHistory(),
  routes,
});

export default router;
vue
<template>
<router-view />
</template>

<style lang="scss">
  html,
  body {
    padding: 0;
    margin: 0;
  }
</style>

2-2-1-创建分类【客户端】

  • 编写main.vue
vue
<template>
  <el-container class="main" style="height: 100vh">
    <el-aside width="200px">
      <el-scrollbar>
        <el-menu router>
          <el-sub-menu index="1">
            <template #title>
              <el-icon><message /></el-icon>内容管理
            </template>
            <el-menu-item-group>
              <template #title>分类</template>
              <el-menu-item index="/categories/create">新建分类</el-menu-item>
              <el-menu-item index="/categories/list">分类管理</el-menu-item>
            </el-menu-item-group>
          </el-sub-menu>
        </el-menu>
      </el-scrollbar>
    </el-aside>

    <el-container>
      <el-header style="text-align: right; font-size: 12px"></el-header>

      <el-main>
        <router-view></router-view>
      </el-main>
    </el-container>
  </el-container>
</template>

<script setup>
import { Message } from "@element-plus/icons-vue";
</script>

<style lang="scss" scoped>
.main {
  .el-header {
    position: relative;
    background-color: var(--el-color-primary-light-7);
    color: var(--el-text-color-primary);
  }
  .el-aside {
    color: var(--el-text-color-primary);
    background: var(--el-color-primary-light-8);
  }
  .el-menu {
    border-right: none;
  }
  .el-main {
    padding: 0;
  }
}
</style>
  • 新建视图组件CategoryEdit.vue
vue
<template>
  <div class="category-edit">
    <h1>新建分类</h1>
    <el-form label-width="120px" @submit.prevent="save">
      <el-form-item label="名称">
        <el-input v-model="model.name"></el-input>
      </el-form-item>
      <el-form-item>
        <el-button type="primary" native-type="submit">保存</el-button>
      </el-form-item>
    </el-form>
  </div>
</template>

<script setup>
import { reactive, getCurrentInstance } from "vue";

// 拿到vue实例
const _this = getCurrentInstance();
// 结构vue实例原型方法$http
const { $http } = _this.appContext.config.globalProperties;

const model = reactive({
  name: "",
});

const save = () => {
  console.log($http.post);
};
</script>
  • 路由配置
javascript
import { createRouter, createWebHashHistory } from "vue-router";
import Main from "../views/Main.vue";
import CategoryEdit from "../views/CategoryEdit.vue";

const routes = [
  {
    path: "/",
    name: "main",
    component: Main,
    children: [
      {
        path: "/categories/create",
        component: CategoryEdit,
      },
    ],
  },
];

const router = createRouter({
  history: createWebHashHistory(),
  routes,
});

export default router;
  • npm i axios、在main.js中引入
javascript
import { createApp } from "vue";
import App from "./App.vue";
import router from "./router";
import store from "./store";

import ElementPlus from "element-plus";
import "element-plus/dist/index.css";
const app = createApp(App);
import http from "./http";
app.config.globalProperties.$http = http;

app.use(store).use(router).use(ElementPlus).mount("#app");

2-2-2-创建分类【服务端】

  • 在server文件夹中安装express@next、mongoose、cors
  • 编写server/index.js
javascript
const express = require('express')

const app = express()

// 解析客户端传递过来的post参数
app.use(express.json())
// 处理跨域
app.use(require('cors')())

require('./plugins/db')(app)
require('./routes/admin')(app)

app.listen(3000, () => {
    console.log('http://localhost:3000');
})
  • 新建routes路由文件夹,在里面新建admin文件夹表示后端路由,然后在里面新建index.js
javascript
module.exports = app => {
    const express = require('express');
    const Category = require('../../models/Category')
    // express子路由
    const router = express.Router()
    router.post('/categories', async (req, res) => {
        const model = await Category.create(req.body)
        res.send(model)
    })

    app.use('/admin/api', router)
}
  • 新建plugins文件夹,在里面新建db.js文件用来保存数据库相关操作
javascript
module.exports = app => {
    const mongoose = require("mongoose")
    // 连接数据库
    mongoose.connect('mongodb://127.0.0.1:27017/node-vue-moba', {
        useNewUrlParser: true
    })
}
  • 新建models文件夹,在里面新建Category.js文件用来存放模型相关操作
javascript
const mongoose = require('mongoose')

const schema = new mongoose.Schema({
    name: { type: String }
})

module.exports = mongoose.model('Category', schema)
  • 前端发起请求
vue
<template>
<div class="category-edit">
  <h1>新建分类</h1>
  <el-form label-width="120px" @submit.prevent="save">
    <el-form-item label="名称">
      <el-input v-model="model.name"></el-input>
  </el-form-item>
    <el-form-item>
      <el-button type="primary" native-type="submit">保存</el-button>
  </el-form-item>
  </el-form>
  </div>
</template>

<script setup>
  import { reactive, getCurrentInstance } from "vue";
  
  // 拿到vue实现
  const _this = getCurrentInstance();
  // 结构vue实例原型方法$http
  const { $http, $router, $message } = _this.appContext.config.globalProperties;
  
  const model = reactive({
    name: "",
  });
  
  const save = async () => {
    const res = await $http.post("/categories", model);
    // 跳转到分类列表
    $router.push("/categories/list");
    $message({
      type: "success",
      message: "保存成功",
    });
  };
</script>

2-3-分类列表

  • 在admin项目中views下新建CategoryList.vue
vue
<template>
  <div class="category-list">
    <h1>分类列表</h1>
    <el-table :data="items.data">
      <el-table-column prop="_id" label="ID" width="230"></el-table-column>
      <el-table-column prop="name" label="分类名称"></el-table-column>
    </el-table>
  </div>
</template>

<script setup>
import { reactive, getCurrentInstance } from "vue";

// 拿到vue实现
const _this = getCurrentInstance();
// 结构vue实例原型方法$http
const { $http } = _this.appContext.config.globalProperties;

// let items = reactive([]);
let items = reactive({ data: [] });
const fetch = async () => {
  const res = await $http.get("categories");
  items.data = reactive(res.data);
};
fetch();
</script>
  • 配置路由
javascript
import { createRouter, createWebHashHistory } from "vue-router";
import Main from "../views/Main.vue";
import CategoryEdit from "../views/CategoryEdit.vue";
import CategoryList from "../views/CategoryList.vue";

const routes = [
  {
    path: "/",
    name: "main",
    component: Main,
    children: [
      {
        path: "/categories/create",
        component: CategoryEdit,
      },
      {
        path: "/categories/list",
        component: CategoryList,
      },
    ],
  },
];

const router = createRouter({
  history: createWebHashHistory(),
  routes,
});

export default router;
  • 后端编写分类列表接口
javascript
module.exports = app => {
    const express = require('express');
    const Category = require('../../models/Category')
    // express子路由
    const router = express.Router()
    router.post('/categories', async (req, res) => {
        const model = await Category.create(req.body)
        res.send(model)
    })
    router.get('/categories', async (req, res) => {
        const items = await Category.find().limit(10)
        res.send(items)
    })

    app.use('/admin/api', router)
}

2-4-分类编辑

  • CategoryList.vue表格中添加操作按钮
vue
<template>
  <div class="category-list">
    <h1>分类列表</h1>
    <el-table :data="items.data">
      <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 #default="scope">
          <el-button
            type="text"
            @click="$router.push(`/categories/edit/${scope.row._id}`)"
            >编辑</el-button
          >
        </template>
      </el-table-column>
    </el-table>
  </div>
</template>

<script setup>
import { reactive, getCurrentInstance } from "vue";

// 拿到vue实现
const _this = getCurrentInstance();
// 结构vue实例原型方法$http
const { $http } = _this.appContext.config.globalProperties;

// let items = reactive([]);
let items = reactive({ data: [] });
const fetch = async () => {
  const res = await $http.get("categories");
  items.data = reactive(res.data);
};
fetch();
</script>
  • 配置路由编辑和创建为同一个页面组件
javascript
import { createRouter, createWebHashHistory } from "vue-router";
import Main from "../views/Main.vue";
import CategoryEdit from "../views/CategoryEdit.vue";
import CategoryList from "../views/CategoryList.vue";

const routes = [
  {
    path: "/",
    name: "main",
    component: Main,
    children: [
      {
        path: "/categories/create",
        component: CategoryEdit,
      },
      {
        path: "/categories/edit/:id",
        component: CategoryEdit,
        // 把页面url参数注入到组件中
        props: true,
      },
      {
        path: "/categories/list",
        component: CategoryList,
      },
    ],
  },
];

const router = createRouter({
  history: createWebHashHistory(),
  routes,
});

export default router;
  • 修改编辑组件
vue
<template>
  <div class="category-edit">
    <h1>{{ id ? "编辑" : "新建" }}分类</h1>
    <el-form label-width="120px" @submit.prevent="save">
      <el-form-item label="名称">
        <el-input v-model="model.obj.name"></el-input>
      </el-form-item>
      <el-form-item>
        <el-button type="primary" native-type="submit">保存</el-button>
      </el-form-item>
    </el-form>
  </div>
</template>

<script setup>
import { reactive, defineProps, getCurrentInstance } from "vue";
// 拿到vue实现
const _this = getCurrentInstance();
// 结构vue实例原型方法$http
const { $http, $router, $message } = _this.appContext.config.globalProperties;

const props = defineProps({
  id: {},
});

const fetch = async () => {
  const res = await $http.get(`categories/${props.id}`);
  model.obj = res.data;
};

let model = reactive({
  obj: {
    name: "",
    id: "",
  },
});

props.id && fetch();

const save = async () => {
  let res;
  if (props.id) {
    res = await $http.put(`/categories/${props.id}`, model.obj);
  } else {
    res = await $http.post("/categories", model.obj);
  }
  // 跳转到分类列表
  $router.push("/categories/list");
  $message({
    type: "success",
    message: "保存成功",
  });
};
</script>
  • 编写获取分类详情接口(获取和修改)
javascript
module.exports = app => {
  const express = require('express');
  const Category = require('../../models/Category')
  // express子路由
  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.get('/categories', async (req, res) => {
    const items = await Category.find().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)
}

2-5-分类删除

  • 在CategoryList.vue页面上添加删除按钮和编写删除事件
vue
<template>
  <div class="category-list">
    <h1>分类列表</h1>
    <el-table :data="items.data">
      <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 #default="scope">
          <el-button
            type="text"
            @click="$router.push(`/categories/edit/${scope.row._id}`)"
            >编辑</el-button
          >
          <el-button type="text" @click="remove(scope.row)">删除</el-button>
        </template>
      </el-table-column>
    </el-table>
  </div>
</template>

<script setup>
import { reactive, getCurrentInstance } from "vue";

// 拿到vue实例
const _this = getCurrentInstance();
// 结构vue实例原型方法$http
const { $http, $confirm, $message } = _this.appContext.config.globalProperties;

let items = reactive({ data: [] });
const fetch = async () => {
  const res = await $http.get("categories");
  items.data = reactive(res.data);
};

const remove = async (row) => {
  // 弹出提示框是否确认删除
  $confirm(`是否确定要删除分类 "${row.name}"`, "提示", {
    confirmButtonText: "确定",
    cancelButtonText: "取消",
    type: "warning",
  }).then(async () => {
    const res = await $http.delete(`categories/${row._id}`);
    $message({
      type: "success",
      message: "删除成功",
    });
    // 重新获取数据
    fetch();
  });
};
  
fetch();
</script>
  • 在后端编写删除接口
javascript
module.exports = app => {
  const express = require('express');
  const Category = require('../../models/Category')
  // express子路由
  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, req.body)
    res.send({
      success: true
    })
  })
  router.get('/categories', async (req, res) => {
    const items = await Category.find().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)
}

2-6-二级分类

  • 修改新增/修改分类页面,可以选择新增/修改一级分类还是二级分类
vue
<template>
  <div class="category-edit">
    <h1>{{ id ? "编辑" : "新建" }}分类</h1>
    <el-form label-width="120px" @submit.prevent="save">
      <el-form-item label="上级分类">
        <el-select v-model="model.obj.parent">
          <el-option
            v-for="item in model.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.obj.name"></el-input>
      </el-form-item>
      <el-form-item>
        <el-button type="primary" native-type="submit">保存</el-button>
      </el-form-item>
    </el-form>
  </div>
</template>

<script setup>
import { reactive, defineProps, getCurrentInstance } from "vue";
// 拿到vue实现
const _this = getCurrentInstance();
// 结构vue实例原型方法$http
const { $http, $router, $message } = _this.appContext.config.globalProperties;

const props = defineProps({
  id: {},
});

const fetch = async () => {
  const res = await $http.get(`categories/${props.id}`);
  model.obj = res.data;
};

const fetchParents = async () => {
  const res = await $http.get(`categories`);
  model.parents = res.data;
};

let model = reactive({
  obj: {
    name: "",
    id: "",
  },
  parents: [],
});

fetchParents();
props.id && fetch();

const save = async () => {
  let res;
  if (props.id) {
    res = await $http.put(`/categories/${props.id}`, model.obj);
  } else {
    res = await $http.post("/categories", model.obj);
  }
  // 跳转到分类列表
  $router.push("/categories/list");
  $message({
    type: "success",
    message: "保存成功",
  });
};
</script>
  • 修改后端数据模型支持子分类
javascript
const mongoose = require('mongoose')

const schema = new mongoose.Schema({
  name: { type: String },
  // 类型不是String,关联模型是它本身
  parent: { type: mongoose.SchemaTypes.ObjectId, ref: 'Category' }
})

module.exports = mongoose.model('Category', schema)
  • 优化查询接口
javascript
module.exports = app => {
    const express = require('express');
    const Category = require('../../models/Category')
    // express子路由
    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, req.body)
        res.send({
            success: true
        })
    })
    router.get('/categories', async (req, res) => {
        // populate('parent') 关联查询
        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)
}
  • 分类列表页面表格也要添加显示父级分类项
vue
<template>
  <div class="category-list">
    <h1>分类列表</h1>
    <el-table :data="items.data">
      <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 #default="scope">
          <el-button
            type="text"
            @click="$router.push(`/categories/edit/${scope.row._id}`)"
            >编辑</el-button
          >
          <el-button type="text" @click="remove(scope.row)">删除</el-button>
        </template>
      </el-table-column>
    </el-table>
  </div>
</template>

<script setup>
import { reactive, getCurrentInstance } from "vue";

// 拿到vue实现
const _this = getCurrentInstance();
// 结构vue实例原型方法$http
const { $http, $confirm, $message } = _this.appContext.config.globalProperties;

let items = reactive({ data: [] });
const fetch = async () => {
  const res = await $http.get("categories");
  items.data = reactive(res.data);
};

const remove = async (row) => {
  // 弹出提示框是否确认删除
  $confirm(`是否确定要删除分类 "${row.name}"`, "提示", {
    confirmButtonText: "确定",
    cancelButtonText: "取消",
    type: "warning",
  }).then(async () => {
    const res = await $http.delete(`categories/${row._id}`);
    $message({
      type: "success",
      message: "删除成功",
    });
    // 重新获取数据
    fetch();
  });
};
fetch();
</script>

2-7-通用CRUD接口

  • 后端通过一套接口就能给所有资源使用,优化项目
  • 在server中 npm i inflection 安装工具包用于单词单复数转换
  • 修改后端接口代码
javascript
module.exports = app => {
  const express = require('express');
  // express子路由
  const router = express.Router({
    mergeParams: true // 表示合并url参数
  })
  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, req.body)
    res.send({
      success: true
    })
  })
  router.get('/', async (req, res) => {
    const queryOptions = {}
    if (req.Model.modelName === 'Category') {
      queryOptions.populate = 'parent'
    }
    // 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)
  })
  
  // 加个中间件
  app.use('/admin/api/rest/:resource', async (req, res, next) => {
    const modelName = require('inflection').classify(req.params.resource)
    req.Model = require(`../../models/${modelName}`)
    next()
  }, router)
}
  • 修改前端页面
vue
<template>
  <div class="category-list">
    <h1>分类列表</h1>
    <el-table :data="items.data">
      <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 #default="scope">
          <el-button
            type="text"
            @click="$router.push(`/categories/edit/${scope.row._id}`)"
            >编辑</el-button
          >
          <el-button type="text" @click="remove(scope.row)">删除</el-button>
        </template>
      </el-table-column>
    </el-table>
  </div>
</template>

<script setup>
import { reactive, getCurrentInstance } from "vue";

// 拿到vue实现
const _this = getCurrentInstance();
// 结构vue实例原型方法$http
const { $http, $confirm, $message } = _this.appContext.config.globalProperties;

let items = reactive({ data: [] });
const fetch = async () => {
  const res = await $http.get("rest/categories");
  items.data = reactive(res.data);
};

const remove = async (row) => {
  // 弹出提示框是否确认删除
  $confirm(`是否确定要删除分类 "${row.name}"`, "提示", {
    confirmButtonText: "确定",
    cancelButtonText: "取消",
    type: "warning",
  }).then(async () => {
    const res = await $http.delete(`rest/categories/${row._id}`);
    $message({
      type: "success",
      message: "删除成功",
    });
    // 重新获取数据
    fetch();
  });
};
fetch();
</script>
vue
<template>
  <div class="category-edit">
    <h1>{{ id ? "编辑" : "新建" }}分类</h1>
    <el-form label-width="120px" @submit.prevent="save">
      <el-form-item label="上级分类">
        <el-select v-model="model.obj.parent">
          <el-option
            v-for="item in model.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.obj.name"></el-input>
      </el-form-item>
      <el-form-item>
        <el-button type="primary" native-type="submit">保存</el-button>
      </el-form-item>
    </el-form>
  </div>
</template>

<script setup>
import { reactive, defineProps, getCurrentInstance } from "vue";
// 拿到vue实现
const _this = getCurrentInstance();
// 结构vue实例原型方法$http
const { $http, $router, $message } = _this.appContext.config.globalProperties;

const props = defineProps({
  id: {},
});

const fetch = async () => {
  const res = await $http.get(`rest/categories/${props.id}`);
  model.obj = res.data;
};

const fetchParents = async () => {
  const res = await $http.get(`rest/categories`);
  model.parents = res.data;
};

let model = reactive({
  obj: {
    name: "",
    id: "",
  },
  parents: [],
});

fetchParents();
props.id && fetch();

const save = async () => {
  let res;
  if (props.id) {
    res = await $http.put(`rest/categories/${props.id}`, model.obj);
  } else {
    res = await $http.post("rest/categories", model.obj);
  }
  // 跳转到分类列表
  $router.push("/categories/list");
  $message({
    type: "success",
    message: "保存成功",
  });
};
</script>

2-8-装备(物品)管理

  • main页面左侧菜单添加物品列表项
vue
<template>
  <el-container class="main" style="height: 100vh">
    <el-aside width="200px">
      <el-scrollbar>
        <el-menu router :default-openeds="['1', '3']">
          <el-sub-menu index="1">
            <template #title>
              <el-icon><message /></el-icon>内容管理
            </template>
            <el-menu-item-group>
              <template #title>分类</template>
              <el-menu-item index="/categories/create">新建分类</el-menu-item>
              <el-menu-item index="/categories/list">分类列表</el-menu-item>
            </el-menu-item-group>
            <el-menu-item-group>
              <template #title>物品</template>
              <el-menu-item index="/items/create">新建物品</el-menu-item>
              <el-menu-item index="/items/list">物品列表</el-menu-item>
            </el-menu-item-group>
          </el-sub-menu>
        </el-menu>
      </el-scrollbar>
    </el-aside>

    <el-container>
      <el-header style="text-align: right; font-size: 12px"></el-header>

      <el-main>
        <router-view></router-view>
      </el-main>
    </el-container>
  </el-container>
</template>

<script setup>
import { Message } from "@element-plus/icons-vue";
</script>

<style lang="scss" scoped>
.main {
  .el-header {
    position: relative;
    background-color: var(--el-color-primary-light-7);
    color: var(--el-text-color-primary);
  }
  .el-aside {
    color: var(--el-text-color-primary);
    background: var(--el-color-primary-light-8);
  }
  .el-menu {
    border-right: none;
  }
  .el-main {
    padding: 0;
  }
}
</style>
  • 新建物品编辑页面和物品列表页面并配置路由
vue
<template>
  <div class="item-edit">
    <h1>{{ id ? "编辑" : "新建" }}物品</h1>
    <el-form label-width="120px" @submit.prevent="save">
      <el-form-item label="名称">
        <el-input v-model="model.obj.name"></el-input>
      </el-form-item>
      <el-form-item label="图标">
        <el-input v-model="model.obj.icon"></el-input>
      </el-form-item>
      <el-form-item>
        <el-button type="primary" native-type="submit">保存</el-button>
      </el-form-item>
    </el-form>
  </div>
</template>

<script setup>
import { reactive, defineProps, getCurrentInstance } from "vue";
// 拿到vue实现
const _this = getCurrentInstance();
// 结构vue实例原型方法$http
const { $http, $router, $message } = _this.appContext.config.globalProperties;

const props = defineProps({
  id: {},
});

const fetch = async () => {
  const res = await $http.get(`rest/items/${props.id}`);
  model.obj = res.data;
};

let model = reactive({
  obj: {
    name: "",
    id: "",
  },
});

props.id && fetch();

const save = async () => {
  let res;
  if (props.id) {
    res = await $http.put(`rest/items/${props.id}`, model.obj);
  } else {
    res = await $http.post("rest/items", model.obj);
  }
  // 跳转到物品列表
  $router.push("/items/list");
  $message({
    type: "success",
    message: "保存成功",
  });
};
</script>
vue
<template>
  <div class="item-list">
    <h1>物品列表</h1>
    <el-table :data="items.data">
      <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 #default="scope">
          <el-button
            type="text"
            @click="$router.push(`/items/edit/${scope.row._id}`)"
            >编辑</el-button
          >
          <el-button type="text" @click="remove(scope.row)">删除</el-button>
        </template>
      </el-table-column>
    </el-table>
  </div>
</template>

<script setup>
import { reactive, getCurrentInstance } from "vue";

// 拿到vue实现
const _this = getCurrentInstance();
// 结构vue实例原型方法$http
const { $http, $confirm, $message } = _this.appContext.config.globalProperties;

let items = reactive({ data: [] });
const fetch = async () => {
  const res = await $http.get("rest/items");
  items.data = reactive(res.data);
};

const remove = async (row) => {
  // 弹出提示框是否确认删除
  $confirm(`是否确定要删除物品 "${row.name}"`, "提示", {
    confirmButtonText: "确定",
    cancelButtonText: "取消",
    type: "warning",
  }).then(async () => {
    const res = await $http.delete(`/rest/items/${row._id}`);
    $message({
      type: "success",
      message: "删除成功",
    });
    // 重新获取数据
    fetch();
  });
};
fetch();
</script>
javascript
import { createRouter, createWebHashHistory } from "vue-router";
import Main from "../views/Main.vue";
import CategoryEdit from "../views/CategoryEdit.vue";
import CategoryList from "../views/CategoryList.vue";

import ItemEdit from "../views/ItemEdit.vue";
import ItemList from "../views/ItemList.vue";

const routes = [
  {
    path: "/",
    name: "main",
    component: Main,
    children: [
      {
        path: "/categories/create",
        component: CategoryEdit,
      },
      {
        path: "/categories/edit/:id",
        component: CategoryEdit,
        // 把页面url参数注入到组件中
        props: true,
      },
      {
        path: "/categories/list",
        component: CategoryList,
      },

      {
        path: "/items/create",
        component: ItemEdit,
      },
      {
        path: "/items/edit/:id",
        component: ItemEdit,
        // 把页面url参数注入到组件中
        props: true,
      },
      {
        path: "/items/list",
        component: ItemList,
      },
    ],
  },
];

const router = createRouter({
  history: createWebHashHistory(),
  routes,
});

export default router;
  • 后端路由已经通用了,不需要重复编写,只需要编写模型就好了
javascript
const mongoose = require('mongoose')

const schema = new mongoose.Schema({
  name: { type: String },
  icon: { type: String }
})

module.exports = mongoose.model('Item', schema)

2-9-图片上传

  • 修改ItemEdit.vue页面
vue
<template>
  <div class="item-edit">
    <h1>{{ id ? "编辑" : "新建" }}物品</h1>
    <el-form label-width="120px" @submit.prevent="save">
      <el-form-item label="名称">
        <el-input v-model="model.obj.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="afterUpload"
        >
          <img v-if="model.obj.icon" :src="model.obj.icon" class="avatar" />
          <el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon>
        </el-upload>
      </el-form-item>
      <el-form-item>
        <el-button type="primary" native-type="submit">保存</el-button>
      </el-form-item>
    </el-form>
  </div>
</template>

<script setup>
import { reactive, defineProps, getCurrentInstance } from "vue";
import { Plus } from "@element-plus/icons-vue";
// 拿到vue实例
const _this = getCurrentInstance();
// 结构vue实例原型方法$http
const { $http, $router, $message } = _this.appContext.config.globalProperties;

const props = defineProps({
  id: {},
});

const fetch = async () => {
  const res = await $http.get(`rest/items/${props.id}`);
  model.obj = res.data;
};

let model = reactive({
  obj: {
    name: "",
    icon: "",
  },
});

props.id && fetch();

const afterUpload = (res) => {
  model.obj.icon = res.url;
};

const save = async () => {
  let res;
  if (props.id) {
    res = await $http.put(`rest/items/${props.id}`, model.obj);
  } else {
    res = await $http.post("rest/items", model.obj);
  }
  // 跳转到物品列表
  $router.push("/items/list");
  $message({
    type: "success",
    message: "保存成功",
  });
};
</script>

<style lang="scss">
.avatar-uploader .avatar {
  width: 178px;
  height: 178px;
  display: block;
}
.avatar-uploader .el-upload {
  border: 1px dashed var(--el-border-color);
  border-radius: 6px;
  cursor: pointer;
  position: relative;
  overflow: hidden;
  transition: var(--el-transition-duration-fast);
}

.avatar-uploader .el-upload:hover {
  border-color: var(--el-color-primary);
}

.el-icon.avatar-uploader-icon {
  font-size: 28px;
  color: #8c939d;
  width: 178px;
  height: 178px;
  text-align: center;
}
</style>
  • 后端 npm i multer 安装处理图片上传的库,新建uploads文件夹用于存放前端上传的图片
  • 后端编写上传图片接口
javascript
module.exports = app => {
    const express = require('express');
    // express子路由
    const router = express.Router({
        mergeParams: true // 表示合并url参数
    })
    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, req.body)
        res.send({
            success: true
        })
    })
    router.get('/', async (req, res) => {
        const queryOptions = {}
        if (req.Model.modeName === 'Category') {
            queryOptions.populate = 'parent'
        }
        // 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)
    })

    // 加个中间件
    app.use('/admin/api/rest/:resource', async (req, res, next) => {
        const modelName = require('inflection').classify(req.params.resource)
        req.Model = require(`../../models/${modelName}`)
        next()
    }, router)


    const multer = require('multer')
    const upload = multer({ dest: __dirname + '/../../uploads' })
    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)
    })
}
  • 定义静态文件托管路由,让前端可以访问到文件夹里的资源
javascript
const express = require('express')

const app = express()

// 解析客户端传递过来的post参数
app.use(express.json())
// 处理跨域
app.use(require('cors')())
app.use('/uploads', express.static(__dirname + '/uploads'))

require('./plugins/db')(app)
require('./routes/admin')(app)

app.listen(3000, () => {
    console.log('http://localhost:3000');
})
  • 修改ItemList.vue页面展示图片
vue
<template>
  <div class="item-list">
    <h1>物品列表</h1>
    <el-table :data="items.data">
      <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 #default="scope">
          <img :src="scope.row.icon" style="height: 3rem" />
        </template>
      </el-table-column>
      <el-table-column fixed="right" label="操作" width="180">
        <template #default="scope">
          <el-button
            type="text"
            @click="$router.push(`/items/edit/${scope.row._id}`)"
            >编辑</el-button
          >
          <el-button type="text" @click="remove(scope.row)">删除</el-button>
        </template>
      </el-table-column>
    </el-table>
  </div>
</template>

<script setup>
import { reactive, getCurrentInstance } from "vue";

// 拿到vue实现
const _this = getCurrentInstance();
// 结构vue实例原型方法$http
const { $http, $confirm, $message } = _this.appContext.config.globalProperties;

let items = reactive({ data: [] });
const fetch = async () => {
  const res = await $http.get("rest/items");
  items.data = reactive(res.data);
};

const remove = async (row) => {
  // 弹出提示框是否确认删除
  $confirm(`是否确定要删除物品 "${row.name}"`, "提示", {
    confirmButtonText: "确定",
    cancelButtonText: "取消",
    type: "warning",
  }).then(async () => {
    const res = await $http.delete(`/rest/items/${row._id}`);
    $message({
      type: "success",
      message: "删除成功",
    });
    // 重新获取数据
    fetch();
  });
};
fetch();
</script>

2-10-英雄管理

  • main页面左侧菜单添加英雄列表项
vue
<template>
  <el-container class="main" style="height: 100vh">
    <el-aside width="200px">
      <el-scrollbar>
        <el-menu router :default-openeds="['1', '3']">
          <el-sub-menu index="1">
            <template #title>
              <el-icon><message /></el-icon>内容管理
            </template>
            <el-menu-item-group>
              <template #title>分类</template>
              <el-menu-item index="/categories/create">新建分类</el-menu-item>
              <el-menu-item index="/categories/list">分类列表</el-menu-item>
            </el-menu-item-group>
            <el-menu-item-group>
              <template #title>物品</template>
              <el-menu-item index="/items/create">新建物品</el-menu-item>
              <el-menu-item index="/items/list">物品列表</el-menu-item>
            </el-menu-item-group>
            <el-menu-item-group>
              <template #title>英雄</template>
              <el-menu-item index="/heroes/create">新建英雄</el-menu-item>
              <el-menu-item index="/heroes/list">英雄列表</el-menu-item>
            </el-menu-item-group>
          </el-sub-menu>
        </el-menu>
      </el-scrollbar>
    </el-aside>

    <el-container>
      <el-header style="text-align: right; font-size: 12px"></el-header>
      <el-main>
        <router-view></router-view>
      </el-main>
    </el-container>
  </el-container>
</template>

<script setup>
import { Message } from "@element-plus/icons-vue";
</script>

<style lang="scss" scoped>
.main {
  .el-header {
    position: relative;
    background-color: var(--el-color-primary-light-7);
    color: var(--el-text-color-primary);
  }
  .el-aside {
    color: var(--el-text-color-primary);
    background: var(--el-color-primary-light-8);
  }
  .el-menu {
    border-right: none;
  }
  .el-main {
    padding: 0;
  }
}
</style>
  • 新建HeroEdit.vue和HeroList.vue页面用于英雄操作
vue
<template>
  <div class="hero-edit">
    <h1>{{ id ? "编辑" : "新建" }}英雄</h1>
    <el-form label-width="120px" @submit.prevent="save">
      <el-form-item label="名称">
        <el-input v-model="model.obj.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="afterUpload"
        >
          <img v-if="model.obj.avatar" :src="model.obj.avatar" class="avatar" />
          <el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon>
        </el-upload>
      </el-form-item>
      <el-form-item>
        <el-button type="primary" native-type="submit">保存</el-button>
      </el-form-item>
    </el-form>
  </div>
</template>

<script setup>
import { reactive, defineProps, getCurrentInstance } from "vue";
import { Plus } from "@element-plus/icons-vue";
// 拿到vue实例
const _this = getCurrentInstance();
// 结构vue实例原型方法$http
const { $http, $router, $message } = _this.appContext.config.globalProperties;

const props = defineProps({
  id: {},
});

const fetch = async () => {
  const res = await $http.get(`rest/heroes/${props.id}`);
  model.obj = res.data;
};

let model = reactive({
  obj: {
    name: "",
    avatar: "",
  },
});

props.id && fetch();

const afterUpload = (res) => {
  model.obj.avatar = res.url;
};

const save = async () => {
  let res;
  if (props.id) {
    res = await $http.put(`rest/heroes/${props.id}`, model.obj);
  } else {
    res = await $http.post("rest/heroes", model.obj);
  }
  // 跳转到物品列表
  $router.push("/heroes/list");
  $message({
    type: "success",
    message: "保存成功",
  });
};
</script>

<style lang="scss">
.avatar-uploader .avatar {
  width: 178px;
  height: 178px;
  display: block;
}
.avatar-uploader .el-upload {
  border: 1px dashed var(--el-border-color);
  border-radius: 6px;
  cursor: pointer;
  position: relative;
  overflow: hidden;
  transition: var(--el-transition-duration-fast);
}

.avatar-uploader .el-upload:hover {
  border-color: var(--el-color-primary);
}

.el-icon.avatar-uploader-icon {
  font-size: 28px;
  color: #8c939d;
  width: 178px;
  height: 178px;
  text-align: center;
}
</style>
vue
<template>
  <div class="hero-list">
    <h1>英雄列表</h1>
    <el-table :data="items.data">
      <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="avatar" label="头像">
        <template #default="scope">
          <img :src="scope.row.avatar" style="height: 3rem" />
        </template>
      </el-table-column>
      <el-table-column fixed="right" label="操作" width="180">
        <template #default="scope">
          <el-button
            type="text"
            @click="$router.push(`/heroes/edit/${scope.row._id}`)"
            >编辑</el-button
          >
          <el-button type="text" @click="remove(scope.row)">删除</el-button>
        </template>
      </el-table-column>
    </el-table>
  </div>
</template>

<script setup>
import { reactive, getCurrentInstance } from "vue";

// 拿到vue实现
const _this = getCurrentInstance();
// 结构vue实例原型方法$http
const { $http, $confirm, $message } = _this.appContext.config.globalProperties;

let items = reactive({ data: [] });
const fetch = async () => {
  const res = await $http.get("rest/heroes");
  items.data = reactive(res.data);
};

const remove = async (row) => {
  // 弹出提示框是否确认删除
  $confirm(`是否确定要删除英雄 "${row.name}"`, "提示", {
    confirmButtonText: "确定",
    cancelButtonText: "取消",
    type: "warning",
  }).then(async () => {
    const res = await $http.delete(`/rest/heroes/${row._id}`);
    $message({
      type: "success",
      message: "删除成功",
    });
    // 重新获取数据
    fetch();
  });
};
fetch();
</script>
  • 路由配置
javascript
import { createRouter, createWebHashHistory } from "vue-router";
import Main from "../views/Main.vue";
import CategoryEdit from "../views/CategoryEdit.vue";
import CategoryList from "../views/CategoryList.vue";

import ItemEdit from "../views/ItemEdit.vue";
import ItemList from "../views/ItemList.vue";

import HeroEdit from "../views/HeroEdit.vue";
import HeroList from "../views/HeroList.vue";

const routes = [
  {
    path: "/",
    name: "main",
    component: Main,
    children: [
      {
        path: "/categories/create",
        component: CategoryEdit,
      },
      {
        path: "/categories/edit/:id",
        component: CategoryEdit,
        // 把页面url参数注入到组件中
        props: true,
      },
      {
        path: "/categories/list",
        component: CategoryList,
      },
      
      {
        path: "/items/create",
        component: ItemEdit,
      },
      {
        path: "/items/edit/:id",
        component: ItemEdit,
        // 把页面url参数注入到组件中
        props: true,
      },
      {
        path: "/items/list",
        component: ItemList,
      },
      
      {
        path: "/heroes/create",
        component: HeroEdit,
      },
      {
        path: "/heroes/edit/:id",
        component: HeroEdit,
        // 把页面url参数注入到组件中
        props: true,
      },
      {
        path: "/heroes/list",
        component: HeroList,
      },
    ],
  },
];

const router = createRouter({
  history: createWebHashHistory(),
  routes,
});

export default router;
  • 后端添加hero模型
javascript
const mongoose = require('mongoose')

const schema = new mongoose.Schema({
    name: { type: String },
    avatar: { type: String }
})

module.exports = mongoose.model('Hero', schema)

2-11-1-英雄编辑【模型字段】

  • 修改完善英雄模型
javascript
const mongoose = require('mongoose')

const schema = new mongoose.Schema({
    name: { type: String },
    avatar: { 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 }
    }],
    // 出装推荐(顺风、逆风)
    items1: [{ type: mongoose.SchemaTypes.ObjectId, ref: 'Item' }],
    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)

2-11-2-英雄编辑【编辑表单】

  • 完善英雄编辑表单页面
vue
<template>
  <div class="hero-edit">
    <h1>{{ id ? "编辑" : "新建" }}英雄</h1>
    <el-form label-width="120px" @submit.prevent="save">
      <el-form-item label="名称">
        <el-input v-model="model.obj.name"></el-input>
      </el-form-item>
      <el-form-item label="称号">
        <el-input v-model="model.obj.title"></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="afterUpload"
        >
          <img v-if="model.obj.avatar" :src="model.obj.avatar" class="avatar" />
          <el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon>
        </el-upload>
      </el-form-item>
      <el-form-item label="类型">
        <el-select v-model="model.obj.categories" multiple>
          <el-option
            v-for="item of model.categories"
            :key="item._id"
            :label="item.name"
            :value="item._id"
          ></el-option>
        </el-select>
      </el-form-item>
      <el-form-item label="难度">
        <el-rate
          :max="9"
          show-score
          v-model="model.obj.scores.difficult"
          style="margin-top: 0.6rem"
        ></el-rate>
      </el-form-item>
      <el-form-item label="技能">
        <el-rate
          :max="9"
          show-score
          v-model="model.obj.scores.skills"
          style="margin-top: 0.6rem"
        ></el-rate>
      </el-form-item>
      <el-form-item label="攻击">
        <el-rate
          :max="9"
          show-score
          v-model="model.obj.scores.attack"
          style="margin-top: 0.6rem"
        ></el-rate>
      </el-form-item>
      <el-form-item label="生存">
        <el-rate
          :max="9"
          show-score
          v-model="model.obj.scores.survive"
          style="margin-top: 0.6rem"
        ></el-rate>
      </el-form-item>
      <el-form-item label="顺风出装">
        <el-select v-model="model.obj.items1" multiple>
          <el-option
            v-for="item of model.items"
            :key="item._id"
            :label="item.name"
            :value="item._id"
          ></el-option>
        </el-select>
      </el-form-item>
      <el-form-item label="逆风出装">
        <el-select v-model="model.obj.items2" multiple>
          <el-option
            v-for="item of model.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.obj.usageTips"></el-input>
      </el-form-item>
      <el-form-item label="对抗技巧">
        <el-input type="textarea" v-model="model.obj.battleTips"></el-input>
      </el-form-item>
      <el-form-item label="团战技巧">
        <el-input type="textarea" v-model="model.obj.teamTips"></el-input>
      </el-form-item>
      <el-form-item>
        <el-button type="primary" native-type="submit">保存</el-button>
      </el-form-item>
    </el-form>
  </div>
</template>

<script setup>
import { reactive, defineProps, getCurrentInstance } from "vue";
import { Plus } from "@element-plus/icons-vue";
// 拿到vue实例
const _this = getCurrentInstance();
// 结构vue实例原型方法$http
const { $http, $router, $message } = _this.appContext.config.globalProperties;

const props = defineProps({
  id: {},
});

const fetch = async () => {
  const res = await $http.get(`rest/heroes/${props.id}`);
  model.obj = Object.assign({}, model.obj, res.data);
};

const fetchCategories = async () => {
  const res = await $http.get(`rest/categories`);
  model.categories = res.data;
};

const fetchItems = async () => {
  const res = await $http.get(`rest/items`);
  model.items = res.data;
};

let model = reactive({
  categories: [],
  obj: {
    name: "",
    avatar: "",
    scores: {
      difficult: 0,
      skills: 0,
      attack: 0,
      survive: 0,
    },
  },
  items: [],
});

fetchCategories();
fetchItems();
props.id && fetch();

const afterUpload = (res) => {
  model.obj.avatar = res.url;
};

const save = async () => {
  let res;
  if (props.id) {
    res = await $http.put(`rest/heroes/${props.id}`, model.obj);
  } else {
    res = await $http.post("rest/heroes", model.obj);
  }
  // 跳转到物品列表
  $router.push("/heroes/list");
  $message({
    type: "success",
    message: "保存成功",
  });
};
</script>

<style lang="scss">
.avatar-uploader .avatar {
  width: 178px;
  height: 178px;
  display: block;
}
.avatar-uploader .el-upload {
  border: 1px dashed var(--el-border-color);
  border-radius: 6px;
  cursor: pointer;
  position: relative;
  overflow: hidden;
  transition: var(--el-transition-duration-fast);
}

.avatar-uploader .el-upload:hover {
  border-color: var(--el-color-primary);
}

.el-icon.avatar-uploader-icon {
  font-size: 28px;
  color: #8c939d;
  width: 178px;
  height: 178px;
  text-align: center;
}
</style>
  • 列表显示称号项
vue
<template>
  <div class="hero-list">
    <h1>英雄列表</h1>
    <el-table :data="items.data">
      <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="title" label="称号"></el-table-column>
      <el-table-column prop="avatar" label="头像">
        <template #default="scope">
          <img :src="scope.row.avatar" style="height: 3rem" />
        </template>
      </el-table-column>
      <el-table-column fixed="right" label="操作" width="180">
        <template #default="scope">
          <el-button
            type="text"
            @click="$router.push(`/heroes/edit/${scope.row._id}`)"
            >编辑</el-button
          >
          <el-button type="text" @click="remove(scope.row)">删除</el-button>
        </template>
      </el-table-column>
    </el-table>
  </div>
</template>

<script setup>
import { reactive, getCurrentInstance } from "vue";

// 拿到vue实现
const _this = getCurrentInstance();
// 结构vue实例原型方法$http
const { $http, $confirm, $message } = _this.appContext.config.globalProperties;

let items = reactive({ data: [] });
const fetch = async () => {
  const res = await $http.get("rest/heroes");
  items.data = reactive(res.data);
};

const remove = async (row) => {
  // 弹出提示框是否确认删除
  $confirm(`是否确定要删除英雄 "${row.name}"`, "提示", {
    confirmButtonText: "确定",
    cancelButtonText: "取消",
    type: "warning",
  }).then(async () => {
    const res = await $http.delete(`/rest/heroes/${row._id}`);
    $message({
      type: "success",
      message: "删除成功",
    });
    // 重新获取数据
    fetch();
  });
};
fetch();
</script>

2-12-1-技能编辑【UI】

  • 继续完善英雄编辑表单页面,引入element面板样式更好看
vue
<template>
  <div class="hero-edit">
    <h1>{{ id ? "编辑" : "新建" }}英雄</h1>
    <el-form label-width="120px" @submit.prevent="save">
      <el-tabs type="border-card" model-value="skills">
        <el-tab-pane label="基础信息" name="basic">
          <el-form-item label="名称">
            <el-input v-model="model.obj.name"></el-input>
          </el-form-item>
          <el-form-item label="称号">
            <el-input v-model="model.obj.title"></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="afterUpload"
            >
              <img
                v-if="model.obj.avatar"
                :src="model.obj.avatar"
                class="avatar"
              />
              <el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon>
            </el-upload>
          </el-form-item>
          <el-form-item label="类型">
            <el-select v-model="model.obj.categories" multiple>
              <el-option
                v-for="item of model.categories"
                :key="item._id"
                :label="item.name"
                :value="item._id"
              ></el-option>
            </el-select>
          </el-form-item>
          <el-form-item label="难度">
            <el-rate
              :max="9"
              show-score
              v-model="model.obj.scores.difficult"
              style="margin-top: 0.6rem"
            ></el-rate>
          </el-form-item>
          <el-form-item label="技能">
            <el-rate
              :max="9"
              show-score
              v-model="model.obj.scores.skills"
              style="margin-top: 0.6rem"
            ></el-rate>
          </el-form-item>
          <el-form-item label="攻击">
            <el-rate
              :max="9"
              show-score
              v-model="model.obj.scores.attack"
              style="margin-top: 0.6rem"
            ></el-rate>
          </el-form-item>
          <el-form-item label="生存">
            <el-rate
              :max="9"
              show-score
              v-model="model.obj.scores.survive"
              style="margin-top: 0.6rem"
            ></el-rate>
          </el-form-item>
          <el-form-item label="顺风出装">
            <el-select v-model="model.obj.items1" multiple>
              <el-option
                v-for="item of model.items"
                :key="item._id"
                :label="item.name"
                :value="item._id"
              ></el-option>
            </el-select>
          </el-form-item>
          <el-form-item label="逆风出装">
            <el-select v-model="model.obj.items2" multiple>
              <el-option
                v-for="item of model.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.obj.usageTips"></el-input>
          </el-form-item>
          <el-form-item label="对抗技巧">
            <el-input type="textarea" v-model="model.obj.battleTips"></el-input>
          </el-form-item>
          <el-form-item label="团战技巧">
            <el-input type="textarea" v-model="model.obj.teamTips"></el-input>
          </el-form-item>
        </el-tab-pane>
        <el-tab-pane label="技能" name="skills">
          <el-button
            size="small"
            type="primary"
            @click="model.obj.skills.push({})"
            ><el-icon :size="size" :color="color"> <Plus /> </el-icon
            ><span style="line-height: 1.2">添加技能</span></el-button
          >
          <el-row type="flex">
            <el-col :md="12" v-for="(item, i) in model.obj.skills" :key="i">
              <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="$http.defaults.baseURL + '/upload'"
                  :show-file-list="false"
                  :on-success="afterUpload"
                >
                  <img v-if="item.icon" :src="item.icon" class="avatar" />
                  <el-icon v-else class="avatar-uploader-icon"
                    ><Plus
                  /></el-icon>
                </el-upload>
              </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-col>
          </el-row>
        </el-tab-pane>
      </el-tabs>

      <el-form-item style="margin-top: 1rem">
        <el-button type="primary" native-type="submit">保存</el-button>
      </el-form-item>
    </el-form>
  </div>
</template>

<script setup>
import { reactive, defineProps, getCurrentInstance } from "vue";
import { Plus } from "@element-plus/icons-vue";
// 拿到vue实例
const _this = getCurrentInstance();
// 结构vue实例原型方法$http
const { $http, $router, $message } = _this.appContext.config.globalProperties;

const props = defineProps({
  id: {},
});

const fetch = async () => {
  const res = await $http.get(`rest/heroes/${props.id}`);
  model.obj = Object.assign({}, model.obj, res.data);
};

const fetchCategories = async () => {
  const res = await $http.get(`rest/categories`);
  model.categories = res.data;
};

const fetchItems = async () => {
  const res = await $http.get(`rest/items`);
  model.items = res.data;
};

let model = reactive({
  categories: [],
  obj: {
    name: "",
    avatar: "",
    scores: {
      difficult: 0,
      skills: 0,
      attack: 0,
      survive: 0,
    },
    skills: [],
  },
  items: [],
});

fetchCategories();
fetchItems();
props.id && fetch();

const afterUpload = (res) => {
  model.obj.avatar = res.url;
};

const save = async () => {
  let res;
  if (props.id) {
    res = await $http.put(`rest/heroes/${props.id}`, model.obj);
  } else {
    res = await $http.post("rest/heroes", model.obj);
  }
  // 跳转到物品列表
  $router.push("/heroes/list");
  $message({
    type: "success",
    message: "保存成功",
  });
};
</script>

<style lang="scss">
.avatar-uploader .avatar {
  width: 178px;
  height: 178px;
  display: block;
}
.avatar-uploader .el-upload {
  border: 1px dashed var(--el-border-color);
  border-radius: 6px;
  cursor: pointer;
  position: relative;
  overflow: hidden;
  transition: var(--el-transition-duration-fast);
}

.avatar-uploader .el-upload:hover {
  border-color: var(--el-color-primary);
}

.el-icon.avatar-uploader-icon {
  font-size: 28px;
  color: #8c939d;
  width: 178px;
  height: 178px;
  text-align: center;
}
</style>

2-12-2-技能编辑【交互】

  • 上传技能图标的on-success钩子要修改一下
  • 上传图标样式改小一点
  • 添加删除技能按钮
vue
<template>
  <div class="hero-edit">
    <h1>{{ id ? "编辑" : "新建" }}英雄</h1>
    <el-form label-width="120px" @submit.prevent="save">
      <el-tabs type="border-card" model-value="skills">
        <el-tab-pane label="基础信息" name="basic">
          <el-form-item label="名称">
            <el-input v-model="model.obj.name"></el-input>
          </el-form-item>
          <el-form-item label="称号">
            <el-input v-model="model.obj.title"></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="afterUpload"
            >
              <img
                v-if="model.obj.avatar"
                :src="model.obj.avatar"
                class="avatar"
              />
              <el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon>
            </el-upload>
          </el-form-item>
          <el-form-item label="类型">
            <el-select v-model="model.obj.categories" multiple>
              <el-option
                v-for="item of model.categories"
                :key="item._id"
                :label="item.name"
                :value="item._id"
              ></el-option>
            </el-select>
          </el-form-item>
          <el-form-item label="难度">
            <el-rate
              :max="9"
              show-score
              v-model="model.obj.scores.difficult"
              style="margin-top: 0.6rem"
            ></el-rate>
          </el-form-item>
          <el-form-item label="技能">
            <el-rate
              :max="9"
              show-score
              v-model="model.obj.scores.skills"
              style="margin-top: 0.6rem"
            ></el-rate>
          </el-form-item>
          <el-form-item label="攻击">
            <el-rate
              :max="9"
              show-score
              v-model="model.obj.scores.attack"
              style="margin-top: 0.6rem"
            ></el-rate>
          </el-form-item>
          <el-form-item label="生存">
            <el-rate
              :max="9"
              show-score
              v-model="model.obj.scores.survive"
              style="margin-top: 0.6rem"
            ></el-rate>
          </el-form-item>
          <el-form-item label="顺风出装">
            <el-select v-model="model.obj.items1" multiple>
              <el-option
                v-for="item of model.items"
                :key="item._id"
                :label="item.name"
                :value="item._id"
              ></el-option>
            </el-select>
          </el-form-item>
          <el-form-item label="逆风出装">
            <el-select v-model="model.obj.items2" multiple>
              <el-option
                v-for="item of model.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.obj.usageTips"></el-input>
          </el-form-item>
          <el-form-item label="对抗技巧">
            <el-input type="textarea" v-model="model.obj.battleTips"></el-input>
          </el-form-item>
          <el-form-item label="团战技巧">
            <el-input type="textarea" v-model="model.obj.teamTips"></el-input>
          </el-form-item>
        </el-tab-pane>
        <el-tab-pane label="技能" name="skills">
          <el-button
            size="small"
            type="primary"
            @click="model.obj.skills.push({})"
            ><el-icon :size="size" :color="color"> <Plus /> </el-icon
            ><span style="line-height: 1.2">添加技能</span></el-button
          >
          <el-row type="flex">
            <el-col :md="12" v-for="(item, i) in model.obj.skills" :key="i">
              <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="$http.defaults.baseURL + '/upload'"
                  :show-file-list="false"
                  :on-success="(res) => (item.icon = res.url)"
                >
                  <img v-if="item.icon" :src="item.icon" class="avatar" />
                  <el-icon v-else class="avatar-uploader-icon"
                    ><Plus
                  /></el-icon>
                </el-upload>
              </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
                  size="small"
                  type="danger"
                  @click="model.obj.skills.splice(i, 1)"
                  >删除</el-button
                >
              </el-form-item>
            </el-col>
          </el-row>
        </el-tab-pane>
      </el-tabs>

      <el-form-item style="margin-top: 1rem">
        <el-button type="primary" native-type="submit">保存</el-button>
      </el-form-item>
    </el-form>
  </div>
</template>

<script setup>
import { reactive, defineProps, getCurrentInstance } from "vue";
import { Plus } from "@element-plus/icons-vue";
// 拿到vue实例
const _this = getCurrentInstance();
// 结构vue实例原型方法$http
const { $http, $router, $message } = _this.appContext.config.globalProperties;

const props = defineProps({
  id: {},
});

const fetch = async () => {
  const res = await $http.get(`rest/heroes/${props.id}`);
  model.obj = Object.assign({}, model.obj, res.data);
};

const fetchCategories = async () => {
  const res = await $http.get(`rest/categories`);
  model.categories = res.data;
};

const fetchItems = async () => {
  const res = await $http.get(`rest/items`);
  model.items = res.data;
};

let model = reactive({
  categories: [],
  obj: {
    name: "",
    avatar: "",
    scores: {
      difficult: 0,
      skills: 0,
      attack: 0,
      survive: 0,
    },
    skills: [],
  },
  items: [],
});

fetchCategories();
fetchItems();
props.id && fetch();

const afterUpload = (res) => {
  model.obj.avatar = res.url;
};

const save = async () => {
  let res;
  if (props.id) {
    res = await $http.put(`rest/heroes/${props.id}`, model.obj);
  } else {
    res = await $http.post("rest/heroes", model.obj);
  }
  // 跳转到物品列表
  $router.push("/heroes/list");
  $message({
    type: "success",
    message: "保存成功",
  });
};
</script>

<style lang="scss">
.avatar-uploader .avatar {
  width: 5rem;
  height: 5rem;
  display: block;
}
.avatar-uploader .el-upload {
  border: 1px dashed var(--el-border-color);
  border-radius: 6px;
  cursor: pointer;
  position: relative;
  overflow: hidden;
  transition: var(--el-transition-duration-fast);
}

.avatar-uploader .el-upload:hover {
  border-color: var(--el-color-primary);
}

.el-icon.avatar-uploader-icon {
  font-size: 28px;
  color: #8c939d;
  width: 5rem;
  height: 5rem;
  text-align: center;
}
</style>

2-13-文章管理

  • main页面左侧菜单添加文章项
vue
<template>
  <el-container class="main" style="height: 100vh">
    <el-aside width="200px">
      <el-scrollbar>
        <el-menu router :default-openeds="['1', '3']">
          <el-sub-menu index="1">
            <template #title>
              <el-icon><message /></el-icon>内容管理
            </template>
            <el-menu-item-group>
              <template #title>分类</template>
              <el-menu-item index="/categories/create">新建分类</el-menu-item>
              <el-menu-item index="/categories/list">分类列表</el-menu-item>
            </el-menu-item-group>
            <el-menu-item-group>
              <template #title>物品</template>
              <el-menu-item index="/items/create">新建物品</el-menu-item>
              <el-menu-item index="/items/list">物品列表</el-menu-item>
            </el-menu-item-group>
            <el-menu-item-group>
              <template #title>英雄</template>
              <el-menu-item index="/heroes/create">新建英雄</el-menu-item>
              <el-menu-item index="/heroes/list">英雄列表</el-menu-item>
            </el-menu-item-group>
            <el-menu-item-group>
              <template #title>文章</template>
              <el-menu-item index="/articles/create">新建文章</el-menu-item>
              <el-menu-item index="/articles/list">文章列表</el-menu-item>
            </el-menu-item-group>
          </el-sub-menu>
        </el-menu>
      </el-scrollbar>
    </el-aside>

    <el-container>
      <el-header style="text-align: right; font-size: 12px"></el-header>
      <el-main>
        <router-view></router-view>
      </el-main>
    </el-container>
  </el-container>
</template>

<script setup>
import { Message } from "@element-plus/icons-vue";
</script>

<style lang="scss" scoped>
.main {
  .el-header {
    position: relative;
    background-color: var(--el-color-primary-light-7);
    color: var(--el-text-color-primary);
  }
  .el-aside {
    color: var(--el-text-color-primary);
    background: var(--el-color-primary-light-8);
  }
  .el-menu {
    border-right: none;
  }
  .el-main {
    padding: 0;
  }
}
</style>
  • 新建ArticleEdit.vue和ArticleList.vue
vue
<template>
  <div class="article-edit">
    <h1>{{ id ? "编辑" : "新建" }}文章</h1>
    <el-form label-width="120px" @submit.prevent="save">
      <el-form-item label="所属分类">
        <el-select v-model="model.obj.categories" multiple>
          <el-option
            v-for="item in model.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.obj.title"></el-input>
      </el-form-item>
      <el-form-item label="详情">
        <el-input v-model="model.obj.body"></el-input>
      </el-form-item>
      <el-form-item>
        <el-button type="primary" native-type="submit">保存</el-button>
      </el-form-item>
    </el-form>
  </div>
</template>

<script setup>
import { reactive, defineProps, getCurrentInstance } from "vue";
// 拿到vue实现
const _this = getCurrentInstance();
// 结构vue实例原型方法$http
const { $http, $router, $message } = _this.appContext.config.globalProperties;

const props = defineProps({
  id: {},
});

const fetch = async () => {
  const res = await $http.get(`rest/articles/${props.id}`);
  console.log(res);
  model.obj = res.data;
};

const fetchCategories = async () => {
  const res = await $http.get(`rest/categories`);
  model.categories = res.data;
};

let model = reactive({
  obj: {
    title: "",
    body: "",
    id: "",
  },
  categories: [],
});

fetchCategories();
props.id && fetch();

const save = async () => {
  let res;
  if (props.id) {
    res = await $http.put(`rest/articles/${props.id}`, model.obj);
  } else {
    res = await $http.post("rest/articles", model.obj);
  }
  // 跳转到分类列表
  $router.push("/articles/list");
  $message({
    type: "success",
    message: "保存成功",
  });
};
</script>
vue
<template>
  <div class="article-list">
    <h1>文章列表</h1>
    <el-table :data="items.data">
      <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 #default="scope">
          <el-button
            type="text"
            @click="$router.push(`/articles/edit/${scope.row._id}`)"
            >编辑</el-button
          >
          <el-button type="text" @click="remove(scope.row)">删除</el-button>
        </template>
      </el-table-column>
    </el-table>
  </div>
</template>

<script setup>
import { reactive, getCurrentInstance } from "vue";

// 拿到vue实现
const _this = getCurrentInstance();
// 结构vue实例原型方法$http
const { $http, $confirm, $message } = _this.appContext.config.globalProperties;

let items = reactive({ data: [] });
const fetch = async () => {
  const res = await $http.get("rest/articles");
  items.data = reactive(res.data);
};

const remove = async (row) => {
  // 弹出提示框是否确认删除
  $confirm(`是否确定要删除文章 "${row.title}"`, "提示", {
    confirmButtonText: "确定",
    cancelButtonText: "取消",
    type: "warning",
  }).then(async () => {
    const res = await $http.delete(`/rest/articles/${row._id}`);
    $message({
      type: "success",
      message: "删除成功",
    });
    // 重新获取数据
    fetch();
  });
};
fetch();
</script>
  • 路由配置
javascript
import { createRouter, createWebHashHistory } from "vue-router";
import Main from "../views/Main.vue";
import CategoryEdit from "../views/CategoryEdit.vue";
import CategoryList from "../views/CategoryList.vue";

import ItemEdit from "../views/ItemEdit.vue";
import ItemList from "../views/ItemList.vue";

import HeroEdit from "../views/HeroEdit.vue";
import HeroList from "../views/HeroList.vue";

import ArticleEdit from "../views/ArticleEdit.vue";
import ArticleList from "../views/ArticleList.vue";

const routes = [
  {
    path: "/",
    name: "main",
    component: Main,
    children: [
      {
        path: "/categories/create",
        component: CategoryEdit,
      },
      {
        path: "/categories/edit/:id",
        component: CategoryEdit,
        // 把页面url参数注入到组件中
        props: true,
      },
      {
        path: "/categories/list",
        component: CategoryList,
      },

      {
        path: "/items/create",
        component: ItemEdit,
      },
      {
        path: "/items/edit/:id",
        component: ItemEdit,
        // 把页面url参数注入到组件中
        props: true,
      },
      {
        path: "/items/list",
        component: ItemList,
      },

      {
        path: "/heroes/create",
        component: HeroEdit,
      },
      {
        path: "/heroes/edit/:id",
        component: HeroEdit,
        // 把页面url参数注入到组件中
        props: true,
      },
      {
        path: "/heroes/list",
        component: HeroList,
      },

      {
        path: "/articles/create",
        component: ArticleEdit,
      },
      {
        path: "/articles/edit/:id",
        component: ArticleEdit,
        // 把页面url参数注入到组件中
        props: true,
      },
      {
        path: "/articles/list",
        component: ArticleList,
      },
    ],
  },
];

const router = createRouter({
  history: createWebHashHistory(),
  routes,
});

export default router;
  • 依照惯例在服务端建立模型
javascript
const mongoose = require('mongoose')

const schema = new mongoose.Schema({
    title: { type: String },
    categories: [{ type: mongoose.SchemaTypes.ObjectId, ref: 'Category' }],
    body: { type: String }
})

module.exports = mongoose.model('Article', schema)

2-14-1-富文本编辑器()

  • 安装第三方富文本组件
  • ArticleEdit.vue中使用
vue
<template>
  <div class="category-edit">
    <h1>{{ id ? "编辑" : "新建" }}文章</h1>
    <el-form label-width="120px" @submit.prevent="save">
      <el-form-item label="所属分类">
        <el-select v-model="model.obj.categories" multiple>
          <el-option
            v-for="item in model.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.obj.title"></el-input>
      </el-form-item>
      <el-form-item label="详情">
        <div id="richText"></div>
      </el-form-item>
      <el-form-item>
        <el-button type="primary" native-type="submit">保存</el-button>
      </el-form-item>
    </el-form>
  </div>
</template>

<script setup>
import { reactive, defineProps, getCurrentInstance, ref, onMounted } from "vue";

import E from "wangeditor";
const phoneEditor = ref("");
onMounted(() => {
  phoneEditor.value = new E("#richText");
  // 上传图片到服务器,base64形式
  phoneEditor.value.config.uploadImgShowBase64 = true;
  // 隐藏网络图片
  phoneEditor.value.config.showLinkImg = false;
  // 创建一个富文本编辑器
  phoneEditor.value.create();
  // 富文本内容
  phoneEditor.value.txt.html();
});
// 拿到vue实例
const _this = getCurrentInstance();
// 结构vue实例原型方法$http
const { $http, $router, $message } = _this.appContext.config.globalProperties;

const props = defineProps({
  id: {},
});

const fetch = async () => {
  const res = await $http.get(`rest/articles/${props.id}`);
  console.log(res);
  model.obj = res.data;
};

const fetchCategories = async () => {
  const res = await $http.get(`rest/categories`);
  model.categories = res.data;
};

let model = reactive({
  obj: {
    title: "",
    body: "",
    id: "",
  },
  categories: [],
});

fetchCategories();
props.id && fetch();

const save = async () => {
  let res;
  if (props.id) {
    res = await $http.put(`rest/articles/${props.id}`, model.obj);
  } else {
    res = await $http.post("rest/articles", model.obj);
  }
  // 跳转到分类列表
  $router.push("/articles/list");
  $message({
    type: "success",
    message: "保存成功",
  });
};
</script>

2-14-1-富文本编辑器【图片上传】()

vue
<template>
  <div class="category-edit">
    <h1>{{ id ? "编辑" : "新建" }}文章</h1>
    <el-form label-width="120px" @submit.prevent="save">
      <el-form-item label="所属分类" style="position: relative; z-index: 1">
        <el-select v-model="model.obj.categories" multiple>
          <el-option
            v-for="item in model.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.obj.title"></el-input>
      </el-form-item>
      <el-form-item label="详情" style="position: relative; z-index: 0">
        <div id="richText"></div>
      </el-form-item>
      <el-form-item>
        <el-button type="primary" native-type="submit">保存</el-button>
      </el-form-item>
    </el-form>
  </div>
</template>

<script setup>
import { reactive, defineProps, getCurrentInstance, ref, onMounted } from "vue";
import E from "wangeditor";

// 拿到vue实例
const _this = getCurrentInstance();
// 解构vue实例原型方法$http
const { $http, $router, $message } = _this.appContext.config.globalProperties;

const props = defineProps({
  id: {},
});

const fetch = async () => {
  const res = await $http.get(`rest/articles/${props.id}`);
  model.obj = res.data;
};

const fetchCategories = async () => {
  const res = await $http.get(`rest/categories`);
  model.categories = res.data;
};

let model = reactive({
  obj: {
    title: "",
    body: "",
    id: "",
  },
  categories: [],
});

fetchCategories();

const phoneEditor = ref("");
onMounted(async () => {
  // 获取数据后再执行之后的操作
  props.id && (await fetch());

  phoneEditor.value = new E("#richText");
  // 上传图片到服务器,不使用base64形式
  phoneEditor.value.config.uploadImgShowBase64 = false;
  // 隐藏网络图片
  phoneEditor.value.config.showLinkImg = false;

  // 上传图片地址
  phoneEditor.value.config.uploadImgServer = $http.defaults.baseURL + "/upload";

  // 上传文件字段要和服务器保持一致,不然服务器会报500错误
  phoneEditor.value.config.uploadFileName = "file";

  // 监控变化,同步更新
  phoneEditor.value.config.onchange = (html) => {
    model.obj.body = html;
  };

  // 钩子函数
  phoneEditor.value.config.uploadImgHooks = {
    customInsert: function (insertImg, result) {
      // 图片上传并返回结果,自定义插入图片的事件(而不是编辑器自动插入图片!!!)
      // insertImg 是插入图片的函数,editor 是编辑器对象,result 是服务器端返回的结果
      // 举例:假如上传图片成功后,服务器端返回的是 {url:'....'} 这种格式,即可这样插入图片:
      var url = result.url;
      insertImg(url);
      // result 必须是一个 JSON 格式字符串!!!否则报错
    },
  };

  // 创建一个富文本编辑器
  phoneEditor.value.create();

  // 富文本内容,初始赋值
  phoneEditor.value.txt.html(model.obj.body);
});

const save = async () => {
  let res;
  if (props.id) {
    res = await $http.put(`rest/articles/${props.id}`, model.obj);
  } else {
    res = await $http.post("rest/articles", model.obj);
  }

  // 跳转到分类列表
  $router.push("/articles/list");
  $message({
    type: "success",
    message: "保存成功",
  });
};
</script>

2-15-广告管理

  • main页面左侧菜单添加广告位项
  • 新建 AdEdit.vue 和 AdList.vue 页面
vue
<template>
  <div class="ad-edit">
    <h1>{{ id ? "编辑" : "新建" }}广告位</h1>
    <el-form label-width="120px" @submit.prevent="save">
      <el-form-item label="名称">
        <el-input v-model="model.obj.name"></el-input>
      </el-form-item>
      <el-form-item label="广告">
        <el-row type="flex" style="flex-wrap: wrap">
          <el-button
            size="small"
            type="primary"
            @click="model.obj.items.push({})"
            ><el-icon :size="size" :color="color"> <Plus /> </el-icon
            ><span style="line-height: 1.2">添加广告</span></el-button
          >
          <el-col :md="24" v-for="(item, i) in model.obj.items" :key="i">
            <el-form-item label="跳转链接(URL)">
              <el-input v-model="item.url"></el-input>
            </el-form-item>
            <el-form-item label="图片" style="margin-top: 0.5rem">
              <el-upload
                class="avatar-uploader"
                :action="$http.defaults.baseURL + '/upload'"
                :show-file-list="false"
                :on-success="(res) => (item.image = res.url)"
              >
                <img v-if="item.image" :src="item.image" class="avatar" />
                <el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon>
              </el-upload>
              <el-col>
                <el-button
                  size="small"
                  type="danger"
                  @click="model.obj.items.splice(i, 1)"
                  >删除</el-button
                >
              </el-col>
            </el-form-item>
          </el-col>
        </el-row>
      </el-form-item>

      <el-form-item>
        <el-button type="primary" native-type="submit">保存</el-button>
      </el-form-item>
    </el-form>
  </div>
</template>

<script setup>
import { reactive, defineProps, getCurrentInstance } from "vue";
import { Plus } from "@element-plus/icons-vue";

// 拿到vue实现
const _this = getCurrentInstance();
// 结构vue实例原型方法$http
const { $http, $router, $message } = _this.appContext.config.globalProperties;

const props = defineProps({
  id: {},
});

const fetch = async () => {
  const res = await $http.get(`rest/ads/${props.id}`);
  model.obj = res.data;
};

let model = reactive({
  obj: {
    items: [],
  },
});

props.id && fetch();

const save = async () => {
  let res;
  if (props.id) {
    res = await $http.put(`rest/ads/${props.id}`, model.obj);
  } else {
    res = await $http.post("rest/ads", model.obj);
  }
  // 跳转到分类列表
  $router.push("/ads/list");
  $message({
    type: "success",
    message: "保存成功",
  });
};
</script>
vue
<template>
  <div class="ad-list">
    <h1>广告位列表</h1>
    <el-table :data="items.data">
      <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 #default="scope">
          <el-button
            type="text"
            @click="$router.push(`/ads/edit/${scope.row._id}`)"
            >编辑</el-button
          >
          <el-button type="text" @click="remove(scope.row)">删除</el-button>
        </template>
      </el-table-column>
    </el-table>
  </div>
</template>

<script setup>
import { reactive, getCurrentInstance } from "vue";

// 拿到vue实现
const _this = getCurrentInstance();
// 结构vue实例原型方法$http
const { $http, $confirm, $message } = _this.appContext.config.globalProperties;

let items = reactive({ data: [] });
const fetch = async () => {
  const res = await $http.get("rest/ads");
  items.data = reactive(res.data);
};

const remove = async (row) => {
  // 弹出提示框是否确认删除
  $confirm(`是否确定要删除 "${row.name}"`, "提示", {
    confirmButtonText: "确定",
    cancelButtonText: "取消",
    type: "warning",
  }).then(async () => {
    const res = await $http.delete(`/rest/ads/${row._id}`);
    $message({
      type: "success",
      message: "删除成功",
    });
    // 重新获取数据
    fetch();
  });
};
fetch();
</script>
  • 配置路由
javascript
import { createRouter, createWebHashHistory } from "vue-router";
import Main from "../views/Main.vue";
import CategoryEdit from "../views/CategoryEdit.vue";
import CategoryList from "../views/CategoryList.vue";

import ItemEdit from "../views/ItemEdit.vue";
import ItemList from "../views/ItemList.vue";

import HeroEdit from "../views/HeroEdit.vue";
import HeroList from "../views/HeroList.vue";

import ArticleEdit from "../views/ArticleEdit.vue";
import ArticleList from "../views/ArticleList.vue";

import AdEdit from "../views/AdEdit.vue";
import AdList from "../views/AdList.vue";

const routes = [
  {
    path: "/",
    name: "main",
    component: Main,
    children: [
      {
        path: "/categories/create",
        component: CategoryEdit,
      },
      {
        path: "/categories/edit/:id",
        component: CategoryEdit,
        // 把页面url参数注入到组件中
        props: true,
      },
      {
        path: "/categories/list",
        component: CategoryList,
      },
      
      {
        path: "/items/create",
        component: ItemEdit,
      },
      {
        path: "/items/edit/:id",
        component: ItemEdit,
        // 把页面url参数注入到组件中
        props: true,
      },
      {
        path: "/items/list",
        component: ItemList,
      },
      
      {
        path: "/heroes/create",
        component: HeroEdit,
      },
      {
        path: "/heroes/edit/:id",
        component: HeroEdit,
        // 把页面url参数注入到组件中
        props: true,
      },
      {
        path: "/heroes/list",
        component: HeroList,
      },
      
      {
        path: "/articles/create",
        component: ArticleEdit,
      },
      {
        path: "/articles/edit/:id",
        component: ArticleEdit,
        // 把页面url参数注入到组件中
        props: true,
      },
      {
        path: "/articles/list",
        component: ArticleList,
      },
      
      {
        path: "/ads/create",
        component: AdEdit,
      },
      {
        path: "/ads/edit/:id",
        component: AdEdit,
        // 把页面url参数注入到组件中
        props: true,
      },
      {
        path: "/ads/list",
        component: AdList,
      },
    ],
  },
];

const router = createRouter({
  history: createWebHashHistory(),
  routes,
});

export default router;
  • 后端添加模型
javascript
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)
  • 抽离公共样式
css
.avatar-uploader .avatar {
  min-width: 5rem;
  height: 5rem;
  display: block;
}
.avatar-uploader .el-upload {
  border: 1px dashed var(--el-border-color);
  border-radius: 6px;
  cursor: pointer;
  position: relative;
  overflow: hidden;
  transition: var(--el-transition-duration-fast);
}

.avatar-uploader .el-upload:hover {
  border-color: var(--el-color-primary);
}

.el-icon.avatar-uploader-icon {
  font-size: 28px;
  color: #8c939d;
  min-width: 5rem;
  height: 5rem;
  text-align: center;
  }
  • main.js导入样式,其他的页面就可以删除掉了(例如ItemsEdit.vue)

2-16-管理员账号管理(bcrypt)

  • 后端新建AdminUser.js模型用于保存管理员账号密码,密码需要加密处理,npm i brcyptjs --save 安装加密库
javascript
const mongoose = require('mongoose')

const schema = new mongoose.Schema({
    username: { type: String },
    password: {
        type: String,
        // 不查询出来
        select: false,
        set (val) {
            return require('bcryptjs').hashSync(val, 10) // 加密值一般10到12
        }
    }
})

module.exports = mongoose.model('AdminUser', schema)
  • main页面左侧菜单添加一级菜单
    • 运营管理,里面放广告位
    • 系统设置,里面放分类和新增一个管理员
vue
<template>
  <el-container class="main" style="height: 100vh">
    <el-aside width="200px">
      <el-scrollbar>
        <!-- 默认要展开第1个,每次只能展开一个,每次刷新保持高亮 -->
        <el-menu
          router
          :default-openeds="['1']"
          unique-opened
          :default-active="$route.path"
        >
          <el-sub-menu index="1">
            <template #title>
              <el-icon><message /></el-icon>内容管理
            </template>
            <el-menu-item-group>
              <template #title>物品</template>
              <el-menu-item index="/items/create">新建物品</el-menu-item>
              <el-menu-item index="/items/list">物品列表</el-menu-item>
            </el-menu-item-group>
            <el-menu-item-group>
              <template #title>英雄</template>
              <el-menu-item index="/heroes/create">新建英雄</el-menu-item>
              <el-menu-item index="/heroes/list">英雄列表</el-menu-item>
            </el-menu-item-group>
            <el-menu-item-group>
              <template #title>文章</template>
              <el-menu-item index="/articles/create">新建文章</el-menu-item>
              <el-menu-item index="/articles/list">文章列表</el-menu-item>
            </el-menu-item-group>
          </el-sub-menu>
          <el-sub-menu index="2">
            <template #title>
              <el-icon><message /></el-icon>运营管理
            </template>
            <el-menu-item-group>
              <template #title>广告位</template>
              <el-menu-item index="/ads/create">新建广告位</el-menu-item>
              <el-menu-item index="/ads/list">广告位列表</el-menu-item>
            </el-menu-item-group>
          </el-sub-menu>
          <el-sub-menu index="3">
            <template #title>
              <el-icon><message /></el-icon>系统设置
            </template>
            <el-menu-item-group>
              <template #title>分类</template>
              <el-menu-item index="/categories/create">新建分类</el-menu-item>
              <el-menu-item index="/categories/list">分类列表</el-menu-item>
            </el-menu-item-group>
            <el-menu-item-group>
              <template #title>管理员</template>
              <el-menu-item index="/admin_users/create"
                >新建管理员</el-menu-item
              >
              <el-menu-item index="/admin_users/list">管理员列表</el-menu-item>
            </el-menu-item-group>
          </el-sub-menu>
        </el-menu>
      </el-scrollbar>
    </el-aside>

    <el-container>
      <el-header style="text-align: right; font-size: 12px"></el-header>
      <el-main>
        <router-view></router-view>
      </el-main>
    </el-container>
  </el-container>
</template>

<script setup>
import { Message } from "@element-plus/icons-vue";
</script>

<style lang="scss" scoped>
.main {
  .el-header {
    position: relative;
    background-color: var(--el-color-primary-light-7);
    color: var(--el-text-color-primary);
  }
  .el-aside {
    color: var(--el-text-color-primary);
    background: var(--el-color-primary-light-8);
  }
  .el-menu {
    border-right: none;
  }
  .el-main {
    padding: 0;
  }
}
</style>
  • 新建 AdminUserEdit.vue 和 AdminUserList.vue 页面
vue
<template>
  <div class="admin-user-edit">
    <h1>{{ id ? "编辑" : "新建" }}管理员</h1>
    <el-form label-width="120px" @submit.prevent="save">
      <el-form-item label="用户名">
        <el-input v-model="model.obj.username"></el-input>
      </el-form-item>
      <el-form-item label="密码">
        <el-input type="text" v-model="model.obj.password"></el-input>
      </el-form-item>
      <el-form-item>
        <el-button type="primary" native-type="submit">保存</el-button>
      </el-form-item>
    </el-form>
  </div>
</template>

<script setup>
import { reactive, defineProps, getCurrentInstance } from "vue";
// 拿到vue实现
const _this = getCurrentInstance();
// 结构vue实例原型方法$http
const { $http, $router, $message } = _this.appContext.config.globalProperties;

const props = defineProps({
  id: {},
});

const fetch = async () => {
  const res = await $http.get(`rest/admin_users/${props.id}`);
  model.obj = res.data;
};

let model = reactive({
  obj: {},
});

props.id && fetch();

const save = async () => {
  let res;
  if (props.id) {
    res = await $http.put(`rest/admin_users/${props.id}`, model.obj);
  } else {
    res = await $http.post("rest/admin_users", model.obj);
  }
  // 跳转到分类列表
  $router.push("/admin_users/list");
  $message({
    type: "success",
    message: "保存成功",
  });
};
</script>
vue
<template>
  <div class="admin-user-list">
    <h1>管理员列表</h1>
    <el-table :data="items.data">
      <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 #default="scope">
          <el-button
            type="text"
            @click="$router.push(`/admin_users/edit/${scope.row._id}`)"
            >编辑</el-button
          >
          <el-button type="text" @click="remove(scope.row)">删除</el-button>
        </template>
      </el-table-column>
    </el-table>
  </div>
</template>

<script setup>
import { reactive, getCurrentInstance } from "vue";

// 拿到vue实现
const _this = getCurrentInstance();
// 结构vue实例原型方法$http
const { $http, $confirm, $message } = _this.appContext.config.globalProperties;

let items = reactive({ data: [] });
const fetch = async () => {
  const res = await $http.get("rest/admin_users");
  items.data = reactive(res.data);
};

const remove = async (row) => {
  // 弹出提示框是否确认删除
  $confirm(`是否确定要删除 "${row.name}"`, "提示", {
    confirmButtonText: "确定",
    cancelButtonText: "取消",
    type: "warning",
  }).then(async () => {
    const res = await $http.delete(`/rest/admin_users/${row._id}`);
    $message({
      type: "success",
      message: "删除成功",
    });
    // 重新获取数据
    fetch();
  });
};
fetch();
</script>
  • 添加路由
javascript
import { createRouter, createWebHashHistory } from "vue-router";
import Main from "../views/Main.vue";
import CategoryEdit from "../views/CategoryEdit.vue";
import CategoryList from "../views/CategoryList.vue";

import ItemEdit from "../views/ItemEdit.vue";
import ItemList from "../views/ItemList.vue";

import HeroEdit from "../views/HeroEdit.vue";
import HeroList from "../views/HeroList.vue";

import ArticleEdit from "../views/ArticleEdit.vue";
import ArticleList from "../views/ArticleList.vue";

import AdEdit from "../views/AdEdit.vue";
import AdList from "../views/AdList.vue";

import AdminUserEdit from "../views/AdminUserEdit.vue";
import AdminUserList from "../views/AdminUserList.vue";

const routes = [
  {
    path: "/",
    name: "main",
    component: Main,
    children: [
      {
        path: "/categories/create",
        component: CategoryEdit,
      },
      {
        path: "/categories/edit/:id",
        component: CategoryEdit,
        // 把页面url参数注入到组件中
        props: true,
      },
      {
        path: "/categories/list",
        component: CategoryList,
      },
      
      {
        path: "/items/create",
        component: ItemEdit,
      },
      {
        path: "/items/edit/:id",
        component: ItemEdit,
        // 把页面url参数注入到组件中
        props: true,
      },
      {
        path: "/items/list",
        component: ItemList,
      },
      
      {
        path: "/heroes/create",
        component: HeroEdit,
      },
      {
        path: "/heroes/edit/:id",
        component: HeroEdit,
        // 把页面url参数注入到组件中
        props: true,
      },
      {
        path: "/heroes/list",
        component: HeroList,
      },
      
      {
        path: "/articles/create",
        component: ArticleEdit,
      },
      {
        path: "/articles/edit/:id",
        component: ArticleEdit,
        // 把页面url参数注入到组件中
        props: true,
      },
      {
        path: "/articles/list",
        component: ArticleList,
      },
      
      {
        path: "/ads/create",
        component: AdEdit,
      },
      {
        path: "/ads/edit/:id",
        component: AdEdit,
        // 把页面url参数注入到组件中
        props: true,
      },
      {
        path: "/ads/list",
        component: AdList,
      },
      
      {
        path: "/admin_users/create",
        component: AdminUserEdit,
      },
      {
        path: "/admin_users/edit/:id",
        component: AdminUserEdit,
        // 把页面url参数注入到组件中
        props: true,
      },
      {
        path: "/admin_users/list",
        component: AdminUserList,
      },
    ],
  },
];

const router = createRouter({
  history: createWebHashHistory(),
  routes,
});

export default router;

2-17-登录页面

  • 添加登录页面路由
javascript
import { createRouter, createWebHashHistory } from "vue-router";
import Login from "../views/Login.vue";
import Main from "../views/Main.vue";
import CategoryEdit from "../views/CategoryEdit.vue";
import CategoryList from "../views/CategoryList.vue";

import ItemEdit from "../views/ItemEdit.vue";
import ItemList from "../views/ItemList.vue";

import HeroEdit from "../views/HeroEdit.vue";
import HeroList from "../views/HeroList.vue";

import ArticleEdit from "../views/ArticleEdit.vue";
import ArticleList from "../views/ArticleList.vue";

import AdEdit from "../views/AdEdit.vue";
import AdList from "../views/AdList.vue";

import AdminUserEdit from "../views/AdminUserEdit.vue";
import AdminUserList from "../views/AdminUserList.vue";

const routes = [
  {
    path: "/login",
    name: "login",
    component: Login,
  },
  {
    path: "/",
    name: "main",
    component: Main,
    children: [
      {
        path: "/categories/create",
        component: CategoryEdit,
      },
      {
        path: "/categories/edit/:id",
        component: CategoryEdit,
        // 把页面url参数注入到组件中
        props: true,
      },
      {
        path: "/categories/list",
        component: CategoryList,
      },
      
      {
        path: "/items/create",
        component: ItemEdit,
      },
      {
        path: "/items/edit/:id",
        component: ItemEdit,
        // 把页面url参数注入到组件中
        props: true,
      },
      {
        path: "/items/list",
        component: ItemList,
      },
      
      {
        path: "/heroes/create",
        component: HeroEdit,
      },
      {
        path: "/heroes/edit/:id",
        component: HeroEdit,
        // 把页面url参数注入到组件中
        props: true,
      },
      {
        path: "/heroes/list",
        component: HeroList,
      },
      
      {
        path: "/articles/create",
        component: ArticleEdit,
      },
      {
        path: "/articles/edit/:id",
        component: ArticleEdit,
        // 把页面url参数注入到组件中
        props: true,
      },
      {
        path: "/articles/list",
        component: ArticleList,
      },
      
      {
        path: "/ads/create",
        component: AdEdit,
      },
      {
        path: "/ads/edit/:id",
        component: AdEdit,
        // 把页面url参数注入到组件中
        props: true,
      },
      {
        path: "/ads/list",
        component: AdList,
      },
      
      {
        path: "/admin_users/create",
        component: AdminUserEdit,
      },
      {
        path: "/admin_users/edit/:id",
        component: AdminUserEdit,
        // 把页面url参数注入到组件中
        props: true,
      },
      {
        path: "/admin_users/list",
        component: AdminUserList,
      },
    ],
  },
];

const router = createRouter({
  history: createWebHashHistory(),
  routes,
});

export default router;
  • 创建Login.vue页面
vue
<template>
<div class="login">
  <el-card header="请先登录" class="login-card">
    <el-form @submit.prevent="login">
      <el-form-item label="用户名">
        <el-input v-model="model.account.username"></el-input>
  </el-form-item>
      <el-form-item label="密码">
        <el-input type="password" v-model="model.account.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 setup>
  import { reactive } from "vue";
  
  const model = reactive({
    account: {
      username: "",
      password: "",
    },
  });
  const login = () => {
    console.log(model.account);
  };
</script>
<style lang="scss">
  .login-card {
    width: 25rem;
    margin: 5rem auto;
  }
</style>

2-18-登录接口【上】

  • 后端编写登录路由
javascript
module.exports = app => {
    const express = require('express');
    // express子路由
    const router = express.Router({
        mergeParams: true // 表示合并url参数
    })
    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, req.body)
        res.send({
            success: true
        })
    })
    router.get('/', async (req, res) => {
        const queryOptions = {}
        if (req.Model.modeName === 'Category') {
            queryOptions.populate = 'parent'
        }
        // 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)
    })

    // 加个中间件
    app.use('/admin/api/rest/:resource', async (req, res, next) => {
        const modelName = require('inflection').classify(req.params.resource)
        req.Model = require(`../../models/${modelName}`)
        next()
    }, router)


    const multer = require('multer')
    const upload = multer({ dest: __dirname + '/../../uploads' })
    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)
    })


    app.post('/admin/api/login', async (req, res) => {
        const { username, password } = req.body
        // 1.根据用户名找用户
        const AdminUser = require('../../models/AdminUser')
        const user = await AdminUser.findOne({ username })
        if (!user) {
            return res.status(422).send({
                message:'用户不存在'
            })
        }
        // 2.校验密码
        // 3.返回token
    })
}
  • 前端添加响应拦截器,用来判断用户是否存在(main.js要导出app,才能拿到vue实例,因为vue3中没有Vue构造函数)
javascript
import axios from "axios";

import app from "./main";

const http = axios.create({
  baseURL: "http://localhost:3000/admin/api",
});

http.interceptors.response.use(
  (res) => {
    return res;
  },
  (err) => {
    if (err.response.data.message) {
      app.config.globalProperties.$message({
        type: "error",
        message: err.response.data.message,
      });
    }
    return Promise.reject(err);
  }
);

export default http;
  • 在登录页面中测试登录请求
vue
<template>
  <div class="login">
    <el-card header="请先登录" class="login-card">
      <el-form @submit.prevent="login">
        <el-form-item label="用户名">
          <el-input v-model="model.account.username"></el-input>
        </el-form-item>
        <el-form-item label="密码">
          <el-input type="password" v-model="model.account.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 setup>
import { reactive, getCurrentInstance } from "vue";

// 拿到vue实例
const _this = getCurrentInstance();
// 结构vue实例原型方法$http
const { $http } = _this.appContext.config.globalProperties;

const model = reactive({
  account: {
    username: "",
    password: "",
  },
});
const login = async () => {
  const res = await $http.post("login", model.account);
  console.log(res.data);
};
</script>
<style lang="scss">
.login-card {
  width: 25rem;
  margin: 5rem auto;
}
</style>

2-18-登录接口【下】

  • 后端接口密码处理,npm i jsonwebtoken 用来给客户端返回token
javascript
module.exports = app => {
    const express = require('express');
    // express子路由
    const router = express.Router({
        mergeParams: true // 表示合并url参数
    })
    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, req.body)
        res.send({
            success: true
        })
    })
    router.get('/', async (req, res) => {
        const queryOptions = {}
        if (req.Model.modeName === 'Category') {
            queryOptions.populate = 'parent'
        }
        // 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)
    })

    // 加个中间件
    app.use('/admin/api/rest/:resource', async (req, res, next) => {
        const modelName = require('inflection').classify(req.params.resource)
        req.Model = require(`../../models/${modelName}`)
        next()
    }, router)


    const multer = require('multer')
    const upload = multer({ dest: __dirname + '/../../uploads' })
    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)
    })


    app.post('/admin/api/login', async (req, res) => {
        const { username, password } = req.body
        // 1.根据用户名找用户
        const AdminUser = require('../../models/AdminUser')
        // 要取password
        const user = await AdminUser.findOne({ username }).select('+password') 
        if (!user) {
            return res.status(422).send({
                message: '用户不存在'
            })
        }

        // 2.校验密码
        const isValid = require('bcryptjs').compareSync(password, user.password)
        if (!isValid) {
            return res.status(422).send({
                message: '密码错误'
            })
        }

        // 3.返回token
        const jwt = require('jsonwebtoken')
        const token = jwt.sign({
            id: user._id
        }, app.get('secret'))

        res.send({token})
    })
}
  • 弄一个秘钥,比较安全,防止客户端伪造token,最好是环境变量,这里省略...
javascript
const express = require('express')

const app = express()

app.set('secret', 'i2u34y12oi3u4y8')

// 解析客户端传递过来的post参数
app.use(express.json())
// 处理跨域
app.use(require('cors')())
app.use('/uploads', express.static(__dirname + '/uploads'))

require('./plugins/db')(app)
require('./routes/admin')(app)

app.listen(3000, () => {
    console.log('http://localhost:3000');
})
  • 前端保存token
vue
<template>
  <div class="login">
    <el-card header="请先登录" class="login-card">
      <el-form @submit.prevent="login">
        <el-form-item label="用户名">
          <el-input v-model="model.account.username"></el-input>
        </el-form-item>
        <el-form-item label="密码">
          <el-input type="password" v-model="model.account.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 setup>
import { reactive, getCurrentInstance } from "vue";

// 拿到vue实例
const _this = getCurrentInstance();
// 结构vue实例原型方法$http
const { $http, $router, $message } = _this.appContext.config.globalProperties;

const model = reactive({
  account: {
    username: "",
    password: "",
  },
});
const login = async () => {
  const res = await $http.post("login", model.account);
  localStorage.token = res.data.token;
  $router.push("/");
  $message({
    type: "success",
    message: "登录成功",
  });
};
</script>
<style lang="scss">
.login-card {
  width: 25rem;
  margin: 5rem auto;
}
</style>

2-19-服务端登录校验(jwt)

  • 请求列表加一个中间件
javascript
module.exports = app => {
  const express = require('express')
  const jwt = require('jsonwebtoken')
  const AdminUser = require('../../models/AdminUser')
  
  // express子路由
  const router = express.Router({
    mergeParams: true // 表示合并url参数
  })
  // 创建资源
  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, req.body)
    res.send({
      success: true
    })
  })
  // 资源列表
  router.get('/', async (req, res, next) => {
    const token = String(req.headers.authorization || '').split(' ').pop()
    const { id } = jwt.verify(token, app.get('secret'))
    req.user = await AdminUser.findById(id)
    await next()
  }, async (req, res) => {
    const queryOptions = {}
    if (req.Model.modeName === 'Category') {
      queryOptions.populate = 'parent'
    }
    // 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)
  })
  
  // 加个中间件
  app.use('/admin/api/rest/:resource', async (req, res, next) => {
    const modelName = require('inflection').classify(req.params.resource)
    req.Model = require(`../../models/${modelName}`)
    next()
  }, router)
  
  
  const multer = require('multer')
  const upload = multer({ dest: __dirname + '/../../uploads' })
  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)
  })
  
  
  app.post('/admin/api/login', async (req, res) => {
    const { username, password } = req.body
    // 1.根据用户名找用户
    // 要取password
    const user = await AdminUser.findOne({ username }).select('+password')
    if (!user) {
      return res.status(422).send({
        message: '用户不存在'
      })
    }
    
    // 2.校验密码
    const isValid = require('bcryptjs').compareSync(password, user.password)
    if (!isValid) {
      return res.status(422).send({
        message: '密码错误'
      })
    }
    
    // 3.返回token
    const token = jwt.sign({
      id: user._id
    }, app.get('secret'))
    
    res.send({ token })
  })
}
  • 前端给所有请求加一个请求头把token携带过去,在请求拦截器中加
javascript
import axios from "axios";

import app from "./main";

const http = axios.create({
  baseURL: "http://localhost:3000/admin/api",
});

http.interceptors.request.use((config) => {
  config.headers.Authorization = "Bearer " + localStorage.token;
  return config;
});

http.interceptors.response.use(
  (res) => {
    return res;
  },
  (err) => {
    if (err.response.data.message) {
      app.config.globalProperties.$message({
        type: "error",
        message: err.response.data.message,
      });
    }
    return Promise.reject(err);
  }
);

export default http;

2-19-服务端登录校验(assert)

  • npm i http-assert 安装用于抛出http错误状态码的库,后端编写校验代码
javascript
module.exports = app => {
    const express = require('express')
    const jwt = require('jsonwebtoken')
    const assert = require('http-assert')
    const AdminUser = require('../../models/AdminUser')


    // express子路由
    const router = express.Router({
        mergeParams: true // 表示合并url参数
    })
    // 创建资源
    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, req.body)
        res.send({
            success: true
        })
    })
    // 资源列表
    router.get('/', async (req, res, next) => {
        const token = String(req.headers.authorization || '').split(' ').pop()
        console.log('token', token);
        // 没有token
        assert(token, 401, '请先登录')
        const { id } = jwt.verify(token, app.get('secret'))
        // token不对
        assert(id, 401, '请先登录')
        req.user = await AdminUser.findById(id)
        // 用户不对
        assert(req.user, 401, '请先登录')
        await next()
    }, async (req, res) => {
        const queryOptions = {}
        if (req.Model.modeName === 'Category') {
            queryOptions.populate = 'parent'
        }
        // 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)
    })

    // 加个中间件
    app.use('/admin/api/rest/:resource', async (req, res, next) => {
        const modelName = require('inflection').classify(req.params.resource)
        req.Model = require(`../../models/${modelName}`)
        next()
    }, router)


    const multer = require('multer')
    const upload = multer({ dest: __dirname + '/../../uploads' })
    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)
    })


    app.post('/admin/api/login', async (req, res) => {
        const { username, password } = req.body
        // 1.根据用户名找用户
        // 要取password
        const user = await AdminUser.findOne({ username }).select('+password')
        assert(user, 422, '用户不存在')

        // 2.校验密码
        const isValid = require('bcryptjs').compareSync(password, user.password)
        assert(isValid, 422, '密码错误')

        // 3.返回token
        const token = jwt.sign({
            id: user._id
        }, app.get('secret'))

        res.send({ token })
    })

    // 错误处理函数
    app.use(async (err, req, res, next) => {
        res.status(err.statusCode || 500).send({ message: err.message })
    })
}
  • 前端边界处理
javascript
import axios from "axios";

import app from "./main";

const http = axios.create({
  baseURL: "http://localhost:3000/admin/api",
});

http.interceptors.request.use((config) => {
  if (localStorage.token) {
    config.headers.Authorization = "Bearer " + localStorage.token;
  }
  return config;
});

http.interceptors.response.use(
  (res) => {
    return res;
  },
  (err) => {
    if (err.response.data.message) {
      app.config.globalProperties.$message({
        type: "error",
        message: err.response.data.message,
      });
      // 如果返回的状态码为401,就跳转回登录页
      if (err.response.status === 401) {
        app.config.globalProperties.$router.push("/login");
      }
    }
    return Promise.reject(err);
  }
);

export default http;

2-19-服务端登录校验(中间件)

  • 在 server 中新建 middleware 文件夹,在文件夹中新建 auth.js 和 resource.js
  • 把中间件抽取出来优化代码
javascript
module.exports = (options) => {
    const jwt = require('jsonwebtoken')
    const AdminUser = require('../models/AdminUser')
    const assert = require('http-assert')

    return async (req, res, next) => {
        const token = String(req.headers.authorization || '').split(' ').pop()
        // 没有token
        assert(token, 401, '请先登录')
        const { id } = jwt.verify(token, req.app.get('secret'))
        // token不对
        assert(id, 401, '请先登录')
        req.user = await AdminUser.findById(id)
        assert(req.user, 401, '请先登录')
        await next()
    }
}
javascript
module.exports = options => {
    return async (req, res, next) => {
        const modelName = require('inflection').classify(req.params.resource)
        req.Model = require(`../models/${modelName}`)
        next()
    }
}
javascript
module.exports = app => {
    const express = require('express')
    const jwt = require('jsonwebtoken')
    const assert = require('http-assert')
    const AdminUser = require('../../models/AdminUser')

    // express子路由
    const router = express.Router({
        mergeParams: true // 表示合并url参数
    })
    // 创建资源
    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, req.body)
        res.send({
            success: true
        })
    })

    // 登录校验中间件
    const authMiddleware = require('../../middleware/auth')
    const resourceMiddleware = require('../../middleware/resource')

    // 资源列表
    router.get('/', authMiddleware(), async (req, res) => {
        const queryOptions = {}
        if (req.Model.modeName === 'Category') {
            queryOptions.populate = 'parent'
        }
        // 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)
    })


    // 加个中间件
    app.use('/admin/api/rest/:resource', authMiddleware(), resourceMiddleware(), router)


    const multer = require('multer')
    const upload = multer({ dest: __dirname + '/../../uploads' })
    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.根据用户名找用户
        // 要取password
        const user = await AdminUser.findOne({ username }).select('+password')
        assert(user, 422, '用户不存在')

        // 2.校验密码
        const isValid = require('bcryptjs').compareSync(password, user.password)
        assert(isValid, 422, '密码错误')

        // 3.返回token
        const token = jwt.sign({
            id: user._id
        }, app.get('secret'))

        res.send({ token })
    })

    // 错误处理函数
    app.use(async (err, req, res, next) => {
        res.status(err.statusCode || 500).send({ message: err.message })
    })
}

2-20-客户端路由限制(beforeEach、meta)

  • token清空后新建物品和新建广告位还能访问,前端需要处理一下,使用路由守卫
javascript
import { createRouter, createWebHashHistory } from "vue-router";
import Login from "../views/Login.vue";
import Main from "../views/Main.vue";
import CategoryEdit from "../views/CategoryEdit.vue";
import CategoryList from "../views/CategoryList.vue";

import ItemEdit from "../views/ItemEdit.vue";
import ItemList from "../views/ItemList.vue";

import HeroEdit from "../views/HeroEdit.vue";
import HeroList from "../views/HeroList.vue";

import ArticleEdit from "../views/ArticleEdit.vue";
import ArticleList from "../views/ArticleList.vue";

import AdEdit from "../views/AdEdit.vue";
import AdList from "../views/AdList.vue";

import AdminUserEdit from "../views/AdminUserEdit.vue";
import AdminUserList from "../views/AdminUserList.vue";

const routes = [
  {
    path: "/login",
    name: "login",
    component: Login,
    meta: { isPublic: true },
  },
  {
    path: "/",
    name: "main",
    component: Main,
    children: [
      {
        path: "/categories/create",
        component: CategoryEdit,
      },
      {
        path: "/categories/edit/:id",
        component: CategoryEdit,
        // 把页面url参数注入到组件中
        props: true,
      },
      {
        path: "/categories/list",
        component: CategoryList,
      },

      {
        path: "/items/create",
        component: ItemEdit,
      },
      {
        path: "/items/edit/:id",
        component: ItemEdit,
        // 把页面url参数注入到组件中
        props: true,
      },
      {
        path: "/items/list",
        component: ItemList,
      },

      {
        path: "/heroes/create",
        component: HeroEdit,
      },
      {
        path: "/heroes/edit/:id",
        component: HeroEdit,
        // 把页面url参数注入到组件中
        props: true,
      },
      {
        path: "/heroes/list",
        component: HeroList,
      },

      {
        path: "/articles/create",
        component: ArticleEdit,
      },
      {
        path: "/articles/edit/:id",
        component: ArticleEdit,
        // 把页面url参数注入到组件中
        props: true,
      },
      {
        path: "/articles/list",
        component: ArticleList,
      },

      {
        path: "/ads/create",
        component: AdEdit,
      },
      {
        path: "/ads/edit/:id",
        component: AdEdit,
        // 把页面url参数注入到组件中
        props: true,
      },
      {
        path: "/ads/list",
        component: AdList,
      },

      {
        path: "/admin_users/create",
        component: AdminUserEdit,
      },
      {
        path: "/admin_users/edit/:id",
        component: AdminUserEdit,
        // 把页面url参数注入到组件中
        props: true,
      },
      {
        path: "/admin_users/list",
        component: AdminUserList,
      },
    ],
  },
];

const router = createRouter({
  history: createWebHashHistory(),
  routes,
});

router.beforeEach((to, from, next) => {
  if (!to.meta.isPublic && !localStorage.token) {
    return next("/login");
  }
  next();
});

export default router;

2-21-上传文件的登录校验(el-upload,headers)

  • 上传图片时也需要携带token,不然会报错
  • 抽取为mixin
javascript
import { createApp } from "vue";
import App from "./App.vue";
import router from "./router";
import store from "./store";

import ElementPlus from "element-plus";
import "element-plus/dist/index.css";

import "./style.css";

const app = createApp(App);
import http from "./http";
app.config.globalProperties.$http = http;
app.mixin({
  computed: {
    uploadUrl() {
      return http.defaults.baseURL + "/upload";
    },
  },
  methods: {
    getAuthHeaders() {
      return {
        Authorization: `Bearer ${localStorage.token || ""}`,
      };
    },
  },
});
app.use(store).use(router).use(ElementPlus).mount("#app");

export default app;

用到上传图片的页面要进行修改

vue
<template>
  <div class="item-edit">
    <h1>{{ id ? "编辑" : "新建" }}物品</h1>
    <el-form label-width="120px" @submit.prevent="save">
      <el-form-item label="名称">
        <el-input v-model="model.obj.name"></el-input>
      </el-form-item>
      <el-form-item label="图标">
        <el-upload
          class="avatar-uploader"
          :action="uploadUrl"
          :show-file-list="false"
          :on-success="afterUpload"
          :headers="getAuthHeaders()"
        >
          <img v-if="model.obj.icon" :src="model.obj.icon" class="avatar" />
          <el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon>
        </el-upload>
      </el-form-item>
      <el-form-item>
        <el-button type="primary" native-type="submit">保存</el-button>
      </el-form-item>
    </el-form>
  </div>
</template>

<script setup>
import { reactive, defineProps, getCurrentInstance } from "vue";
import { Plus } from "@element-plus/icons-vue";
// 拿到vue实例
const _this = getCurrentInstance();
// 结构vue实例原型方法$http
const { $http, $router, $message } = _this.appContext.config.globalProperties;

const props = defineProps({
  id: {},
});

const fetch = async () => {
  const res = await $http.get(`rest/items/${props.id}`);
  model.obj = res.data;
};

let model = reactive({
  obj: {
    name: "",
    icon: "",
  },
});

props.id && fetch();

const afterUpload = (res) => {
  model.obj.icon = res.url;
};

const save = async () => {
  let res;
  if (props.id) {
    res = await $http.put(`rest/items/${props.id}`, model.obj);
  } else {
    res = await $http.post("rest/items", model.obj);
  }
  // 跳转到物品列表
  $router.push("/items/list");
  $message({
    type: "success",
    message: "保存成功",
  });
};
</script>
  • admin/src/views/HeriEdit.vue 同理,代码省略
vue
<template>
  <div class="category-edit">
    <h1>{{ id ? "编辑" : "新建" }}文章</h1>
    <el-form label-width="120px" @submit.prevent="save">
      <el-form-item label="所属分类" style="position: relative; z-index: 1">
        <el-select v-model="model.obj.categories" multiple>
          <el-option
            v-for="item in model.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.obj.title"></el-input>
      </el-form-item>
      <el-form-item label="详情" style="position: relative; z-index: 0">
        <div id="richText"></div>
      </el-form-item>
      <el-form-item>
        <el-button type="primary" native-type="submit">保存</el-button>
      </el-form-item>
    </el-form>
  </div>
</template>

<script setup>
import { reactive, defineProps, getCurrentInstance, ref, onMounted } from "vue";
import E from "wangeditor";

// 拿到vue实例
const _this = getCurrentInstance();
// 解构vue实例原型方法$http
const { $http, $router, $message } = _this.appContext.config.globalProperties;

const props = defineProps({
  id: {},
});

const fetch = async () => {
  const res = await $http.get(`rest/articles/${props.id}`);
  model.obj = res.data;
};

const fetchCategories = async () => {
  const res = await $http.get(`rest/categories`);
  model.categories = res.data;
};

let model = reactive({
  obj: {
    title: "",
    body: "",
    id: "",
  },
  categories: [],
});

fetchCategories();

const phoneEditor = ref("");
onMounted(async () => {
  // 获取数据后再执行之后的操作
  props.id && (await fetch());

  phoneEditor.value = new E("#richText");
  // 上传图片到服务器,不使用base64形式
  phoneEditor.value.config.uploadImgShowBase64 = false;
  // 隐藏网络图片
  phoneEditor.value.config.showLinkImg = false;

  // 上传图片地址
  phoneEditor.value.config.uploadImgServer = $http.defaults.baseURL + "/upload";

  // 上传文件字段要和服务器保持一致,不然服务器会报500错误
  phoneEditor.value.config.uploadFileName = "file";

  // 监控变化,同步更新
  phoneEditor.value.config.onchange = (html) => {
    model.obj.body = html;
  };

  // 钩子函数
  phoneEditor.value.config.uploadImgHooks = {
    before(xhr) {
      xhr.setRequestHeader(
        "Authorization",
        `Bearer ${localStorage.token || ""}`
      );
    },
    customInsert(insertImg, result) {
      // 图片上传并返回结果,自定义插入图片的事件(而不是编辑器自动插入图片!!!)
      // insertImg 是插入图片的函数,editor 是编辑器对象,result 是服务器端返回的结果
      // 举例:假如上传图片成功后,服务器端返回的是 {url:'....'} 这种格式,即可这样插入图片:
      var url = result.url;
      insertImg(url);
      // result 必须是一个 JSON 格式字符串!!!否则报错
    },
  };

  // 创建一个富文本编辑器
  phoneEditor.value.create();

  // 富文本内容,初始赋值
  phoneEditor.value.txt.html(model.obj.body);
});

const save = async () => {
  let res;
  if (props.id) {
    res = await $http.put(`rest/articles/${props.id}`, model.obj);
  } else {
    res = await $http.post("rest/articles", model.obj);
  }

  // 跳转到分类列表
  $router.push("/articles/list");
  $message({
    type: "success",
    message: "保存成功",
  });
};
</script>

3-1-"工具样式"概念和SASS(SCSS)

  • "工具样式"的概念就是说可以先定义一些工具类名,样式可以重复使用
  • 要用好"工具样式"还是得使用SASS(SCSS)方便一点

3-2-样式重置

  • 在web项目中src里面新建style.scss,编写重置样式
css
// reset
* {
  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;
}

在main.js中导入

javascript
import { createApp } from "vue";
import App from "./App.vue";
import router from "./router";
import store from "./store";

import "./style.scss";

createApp(App).use(store).use(router).mount("#app");

3-3-网站色彩和字体定义(colors,text)

  • 定义一些可复用的变量
css
// reset
* {
  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;
}
a {
  color: #999;
  text-decoration: none;
}

// colors
$colors: (
"primary": #db9e3f,
"white": #fff,
"light": #f9f9f9,
"grey": #999,
"grey-1": #666,
"dark-1": #343440,
"dark": #222,
"black": #000,
);
@each $colorKey, $color in $colors {
  .text-#{$colorKey} {
    color: $color;
  }
  .bg-#{$colorKey} {
    background-color: $color;
  }
}
// text align
@each $var in (left, center, right) {
  .text-#{$var} {
    text-align: $var;
  }
}
// font size
$base-font-size: 1rem;
$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,
);
@each $sizeKey, $size in $font-sizes {
  .fs-#{$sizeKey} {
    font-size: $size * $base-font-size;
  }
}

3-4-通用flex布局样式定义(flex)

  • 定义flex布局相关样式
css
// ...上面的代码省略
// flex
.d-flex {
  display: flex;
}
.flex-column {
  flex-direction: column;
}
.flex-wrap {
  flex-wrap: wrap;
}
$flex-jc: (
  start: flex-start,
  end: flex-end,
  center: center,
  between: space-between,
  around: space-around,
);
@each $key, $value in $flex-jc {
  .jc-#{$key} {
    justify-content: $value;
  }
}
$flex-ai: (
  start: flex-start,
  end: flex-end,
  center: center,
  stretch: stretch,
);
@each $key, $value in $flex-jc {
  .ai-#{$key} {
    align-items: $value;
  }
}
.flex-1 {
  flex: 1;
}
.flex-grow-1 {
  flex-grow: 1;
}

3-5-常用边距定义(margin,padding)

  • 模仿bootstrap定义边距样式
css
// ...上面的代码省略
// spacing
// 0-5: 0
// .mt-1 => margin top  .pb-2
$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,
);
@each $typeKey, $type in $spacing-types {
  @each $sizeKey, $size in $spacing-sizes {
    .#{$typeKey}-#{$sizeKey} {
      #{$type}: $size * $spacing-base-size;
    }
  }

  @each $sizeKey, $size in $spacing-sizes {
    .#{$typeKey}x-#{$sizeKey} {
      #{$type}-left: $size * $spacing-base-size;
      #{$type}-right: $size * $spacing-base-size;
    }
  }

  @each $sizeKey, $size in $spacing-sizes {
    .#{$typeKey}y-#{$sizeKey} {
      #{$type}-top: $size * $spacing-base-size;
      #{$type}-bottom: $size * $spacing-base-size;
    }
  }

  @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-size,'sm') & $base-font-size;
  padding: 0.2rem 0.6rem;
}

3-6-主页框架和顶部菜单

  • App.vue直接编写router-view标签
vue
<template>
  <router-view />
</template>

<style lang="scss"></style>
  • 新建Main.vue和Home.vue
vue
<template>
<div class="topbar bg-black py-2 text-white px-3 d-flex ai-center">
  <div class="logo"></div>
  <div class="px-2 flex-1">
    <div class="text-white">王者荣耀</div>
    <div class="text-grey-1 fs-xxs">团队成就更多</div>
  </div>
  <button type="button" class="btn bg-primary">立即下载</button>
  </div>
<div class="bg-primary pt-3 pb-2">
  <div class="nav d-flex text-white jc-around pb-1">
    <div class="nav-item active">
      <router-link class="nav-link" to="/">首页</router-link>
  </div>
    <div class="nav-item">
      <router-link class="nav-link" to="/">攻略中心</router-link>
  </div>
    <div class="nav-item">
      <router-link class="nav-link" to="/">赛事中心</router-link>
  </div>
    <div class="nav-item">
      <router-link class="nav-link" to="/">IP新游</router-link>
  </div>
  </div>
  </div>
<router-view />
</template>

<style lang="scss" scoped>
  .logo {
    width: 2.46rem;
    height: 2.46rem;
    background-image: url(../assets/images/index.png);
    background-position: 0 -24.7rem;
    background-size: 30.076rem;
  }
</style>
vue
<template>
<div>首页</div>
</template>

<style lang="scss"></style>
css
// 上面的省略
// button
.btn {
  border: none;
  border-radius: 0.1538rem;
  font-size: map-get($font-sizes, "sm") * $base-font-size;
  padding: 0.2rem 0.6rem;
}
// nav
.nav {
  .nav-item {
    a {
      color: #fff;
    }
    border-bottom: 3px solid transparent;
    padding-bottom: 0.1rem;
    &.active {
      border-bottom: 3px solid #fff;
    }
  }
}
  • 配置路由
javascript
import { createRouter, createWebHashHistory } from "vue-router";
import Main from "../views/Main.vue";
import Home from "../views/Home.vue";

const routes = [
  {
    path: "/",
    name: "main",
    component: Main,
    children: [
      {
        path: "/",
        name: "home",
        component: Home,
      },
    ],
  },
];

const router = createRouter({
  history: createWebHashHistory(),
  routes,
});

export default router;

3-7-首页顶部轮播图片(vue swiper)

  • npm install swiper@6.3.5 安装轮播图插件
  • Home.vue编写轮播图代码
vue
<template>
  <div>
    <swiper :pagination="{ clickable: false }" :autoplay="true" :loop="true">
      <swiper-slide>
        <img
          class="w-100"
          src="../assets/images/99f4d6c626c3c156c6f48c489d65e203.jpg"
        />
      </swiper-slide>
      <swiper-slide>
        <img
          class="w-100"
          src="../assets/images/3a6e7b07b9a379fb145fc96780816700.jpg"
        />
      </swiper-slide>
      <swiper-slide>
        <img
          class="w-100"
          src="../assets/images/05958a245fbeaff893df22a0c34db020.jpg"
        />
      </swiper-slide>
    </swiper>
  </div>
</template>

<script setup>
//第一步-导入swiper组件
import { Swiper, SwiperSlide } from "swiper/vue";
//第二步-导入插件
import SwiperCore, { Pagination, Autoplay, Navigation } from "swiper";
import "swiper/swiper-bundle.css";
//第三步-使用插件(同时在模板中swiper标签使用)
SwiperCore.use([Pagination, Autoplay, Navigation]);
</script>
<style lang="scss">
@import "../assets/scss/variables";

.swiper-pagination {
  text-align: right;
  padding-right: 1rem;
  .swiper-pagination-bullet {
    opacity: 1;
    border-radius: 0.1538rem;
    background: map-get($colors, "white");
    &.swiper-pagination-bullet-active {
      background: map-get($colors, "info");
    }
  }
}
</style>
  • 把变量和实现分离,src/assets中新建scss文件夹,里面新建_variables.scss,抽取变量样式代码
css
// colors
$colors: (
  "primary": #db9e3f,
  "info": #4b67af,
  "white": #fff,
  "light": #f9f9f9,
  "grey": #999,
  "grey-1": #666,
  "dark-1": #343440,
  "dark": #222,
  "black": #000,
);

// font size
$base-font-size: 1rem;
$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-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,
);

// spacing
// 0-5: 0
// .mt-1 => margin top  .pb-2
$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,
);
  • style.scss也一同放入scss文件夹,main.js路径记得修改一下
css
// 不需要下划线,scss的规范
@import "./variables";
// reset
* {
  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;
}
a {
  text-decoration: none;
  color: #999;
}

@each $colorKey, $color in $colors {
  .text-#{$colorKey} {
    color: $color;
  }
  .bg-#{$colorKey} {
    background-color: $color;
  }
}
// text align
@each $var in (left, center, right) {
  .text-#{$var} {
    text-align: $var;
  }
}

@each $sizeKey, $size in $font-sizes {
  .fs-#{$sizeKey} {
    font-size: $size * $base-font-size;
  }
}

// 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-jc {
  .ai-#{$key} {
    align-items: $value;
  }
}
.flex-1 {
  flex: 1;
}
.flex-grow-1 {
  flex-grow: 1;
}

@each $typeKey, $type in $spacing-types {
  @each $sizeKey, $size in $spacing-sizes {
    .#{$typeKey}-#{$sizeKey} {
      #{$type}: $size * $spacing-base-size;
    }
  }

  @each $sizeKey, $size in $spacing-sizes {
    .#{$typeKey}x-#{$sizeKey} {
      #{$type}-left: $size * $spacing-base-size;
      #{$type}-right: $size * $spacing-base-size;
    }
  }

  @each $sizeKey, $size in $spacing-sizes {
    .#{$typeKey}y-#{$sizeKey} {
      #{$type}-top: $size * $spacing-base-size;
      #{$type}-bottom: $size * $spacing-base-size;
    }
  }

  @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;
}
// nav
.nav {
  .nav-item {
    a {
      color: #fff;
    }
    border-bottom: 3px solid transparent;
    padding-bottom: 0.1rem;
    &.active {
      border-bottom: 3px solid #fff;
    }
  }
}

3-8-使用精灵图片(sprite)

vue
<template>
  <div>
    <swiper :pagination="{ clickable: false }" :autoplay="true" :loop="true">
      <swiper-slide>
        <img
          class="w-100"
          src="../assets/images/99f4d6c626c3c156c6f48c489d65e203.jpg"
        />
      </swiper-slide>
      <swiper-slide>
        <img
          class="w-100"
          src="../assets/images/3a6e7b07b9a379fb145fc96780816700.jpg"
        />
      </swiper-slide>
      <swiper-slide>
        <img
          class="w-100"
          src="../assets/images/05958a245fbeaff893df22a0c34db020.jpg"
        />
      </swiper-slide>
    </swiper>
    <!-- end of swiper -->
    <div class="nav-icons bg-white mt-3 text-center pt-3 text-dark-1">
      <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-xs">
        <i class="sprite sprite-arrow mr-1"></i>
        <span>收起</span>
      </div>
    </div>
  </div>
</template>

<script setup>
//第一步-导入swiper组件
import { Swiper, SwiperSlide } from "swiper/vue";
//第二步-导入插件
import SwiperCore, { Pagination, Autoplay, Navigation } from "swiper";
import "swiper/swiper-bundle.css";
//第三步-使用插件(同时在模板中swiper标签使用)
SwiperCore.use([Pagination, Autoplay, Navigation]);
</script>
<style lang="scss">
@import "../assets/scss/variables";

.swiper-pagination {
  text-align: right;
  padding-right: 1rem;
  .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;
    }
  }
}
</style>
css
// 前面省略
// nav
.nav {
  .nav-item {
    a {
      color: #fff;
    }
    border-bottom: 3px solid transparent;
    padding-bottom: 0.1rem;
    &.active {
      border-bottom: 3px solid #fff;
    }
  }
}

// sprite
.sprite {
  background: url(../images/index.png) no-repeat 0 0;
  background-size: 28.8462rem;
  display: inline-block;
  &.sprite-news {
    width: 1.7692rem;
    height: 1.5385rem;
    background-position: 63.546% 15.517%;
  }
  &.sprite-arrow{
    width: .7692rem;
    height: .7692rem;
    background-position: 38.577% 52.076%;
  }
}
css
// colors
$colors: (
  "primary": #db9e3f,
  "info": #4b67af,
  "white": #fff,
  "light": #f9f9f9,
  "light-1": #d4d9de,
  "grey": #999,
  "grey-1": #666,
  "dark-1": #343440,
  "dark": #222,
  "black": #000,
);
$border-color: map-get($colors, "light-1");
// font size
$base-font-size: 1rem;
$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-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,
);

// spacing
// 0-5: 0
// .mt-1 => margin top  .pb-2
$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,
);

3-9-使用字体图标(iconfont)

  • 阿里巴巴矢量图标库,挑选图标加入购物车,下载代码,解压复制到web/src/assets/iconfont中
  • main.js中导入iconfont.css文件
javascript
import { createApp } from "vue";
import App from "./App.vue";
import router from "./router";
import store from "./store";

import "./assets/scss/style.scss";
import "./assets/iconfont/iconfont.css";

createApp(App).use(store).use(router).mount("#app");
  • 组件中就可以使用了

3-10-卡片组件(card)

  • 封装卡片组件
vue
<template>
<div class="card bg-white p-3 mt-3">
  <div class="card-header d-flex ai-center pb-3">
    <i class="iconfont" :class="`icon-${icon}`"></i>
    <div class="fs-xl flex-1 px-2">{{ title }}</div>
    <i class="iconfont icon-menu"></i>
  </div>
  <div class="card-body pt-3">
    <slot></slot>
  </div>
  </div>
</template>
<script setup>
  const props = defineProps({
    title: {
      type: String,
      required: true,
    },
    icon: {
      type: String,
      required: true,
    },
  });
</script>
<style lang="scss" scoped>
  @import "../assets/scss/_variables.scss";
  .card {
    border-bottom: 1px solid $border-color;
    .card-header {
      border-bottom: 1px solid $border-color;
    }
  }
</style>
css
// ↑.......
// nav
.nav {
  display: flex;
  .nav-item {
    a {
      color: #fff;
    }
    border-bottom: 3px solid transparent;
    padding-bottom: 0.1rem;
    &.active {
      color: map-get($colors, "primary");
      border-bottom-color: map-get($colors, "primary");
    }
  }
  &.nav-inverse {
    .nav-item {
      color: map-get($colors, "white");
      &.active {
        border-bottom-color: map-get($colors, "white");
      }
    }
  }
}
// ↓.......
  • 全局注册组件
javascript
import { createApp } from "vue";
import App from "./App.vue";
import router from "./router";
import store from "./store";

import "./assets/scss/style.scss";
import "./assets/iconfont/iconfont.css";

import Card from "./components/Card.vue";

const app = createApp(App);
app.component("m-card", Card);
app.use(store).use(router).mount("#app");
  • Home.vue中引用
vue
<template>
  <div>
    <swiper :pagination="{ clickable: false }" :autoplay="true" :loop="true">
      <swiper-slide>
        <img
          class="w-100"
          src="../assets/images/99f4d6c626c3c156c6f48c489d65e203.jpg"
        />
      </swiper-slide>
      <swiper-slide>
        <img
          class="w-100"
          src="../assets/images/3a6e7b07b9a379fb145fc96780816700.jpg"
        />
      </swiper-slide>
      <swiper-slide>
        <img
          class="w-100"
          src="../assets/images/05958a245fbeaff893df22a0c34db020.jpg"
        />
      </swiper-slide>
    </swiper>
    <!-- end of swiper -->
    <div class="nav-icons bg-white mt-3 text-center pt-3 text-dark-1">
      <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-xs">
        <i class="sprite sprite-arrow mr-1"></i>
        <span>收起</span>
      </div>
    </div>
    <!-- end of nav icons -->

    <m-card icon="cc-menu-circle" title="新闻资讯">
      <div class="nav jc-between">
        <div class="nav-item active">
          <div class="nav-link">热门</div>
        </div>
        <div class="nav-item">
          <div class="nav-link">新闻</div>
        </div>
        <div class="nav-item">
          <div class="nav-link">公告</div>
        </div>
        <div class="nav-item">
          <div class="nav-link">活动</div>
        </div>
        <div class="nav-item">
          <div class="nav-link">赛事</div>
        </div>
      </div>
      <div class="pt-2">
        <swiper>
          <swiper-slide v-for="m in 5" :key="m">
            <div class="py-2" v-for="n in 5" :key="n">
              <span>[新闻]</span>
              <span>|</span>
              <span>王者荣耀X中国邮政《二十四节气》系列邮品首发</span>
              <span>06/02</span>
            </div>
          </swiper-slide>
        </swiper>
      </div>
    </m-card>
    <m-card icon="cc-menu-circle" title="英雄列表"> </m-card>
    <m-card icon="cc-menu-circle" title="精彩视频"> </m-card>
    <m-card icon="cc-menu-circle" title="图文攻略"> </m-card>
  </div>
</template>

<script setup>
//第一步-导入swiper组件
import { Swiper, SwiperSlide } from "swiper/vue";
//第二步-导入插件
import SwiperCore, { Pagination, Autoplay, Navigation } from "swiper";
import "swiper/swiper-bundle.css";
//第三步-使用插件(同时在模板中swiper标签使用)
SwiperCore.use([Pagination, Autoplay, Navigation]);
</script>
<style lang="scss">
@import "../assets/scss/variables";

.swiper-pagination {
  text-align: right;
  padding-right: 1rem;
  .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;
    }
  }
}
</style>

3-11-列表卡片组件(list-card, nav, swiper)

  • 封装列表卡片(带分类带列表,分类就是导航菜单,列表就是swiper)
    • 新建ListCard.vue
vue
<template>
  <m-card :icon="icon" :title="title">
    <div class="nav jc-between">
      <div
        class="nav-item"
        :class="{ active: active === i }"
        v-for="(category, i) in categories"
        :key="i"
        @click="active = i"
      >
        <div class="nav-link">{{ category.name }}</div>
      </div>
    </div>
    <div class="pt-3">
      <swiper :loop="true">
        <swiper-slide v-for="(category, i) in categories" :key="i">
          <slot name="items" :category="category"></slot>
        </swiper-slide>
      </swiper>
    </div>
  </m-card>
</template>
<script setup>
import { ref } from "vue";
const props = defineProps({
  icon: { type: String, required: true },
  title: { type: String, required: true },
  categories: { type: Array, required: true },
});
let active = ref(0);
</script>
- main.js全局注册一下ListCard.vue
- 轮播图组件相关导入抽取到main.js进行注册
javascript
import { createApp } from "vue";
import App from "./App.vue";
import router from "./router";
import store from "./store";

import "./assets/scss/style.scss";
import "./assets/iconfont/iconfont.css";

import Card from "./components/Card.vue";
import ListCard from "./components/ListCard.vue";

//第一步-导入swiper组件
import { Swiper, SwiperSlide } from "swiper/vue";
//第二步-导入插件
import SwiperCore, { Pagination, Autoplay, Navigation } from "swiper";
import "swiper/swiper-bundle.css";
//第三步-使用插件(同时在模板中swiper标签使用)
SwiperCore.use([Pagination, Autoplay, Navigation]);

const app = createApp(App);
app.component("m-card", Card);
app.component("m-list-card", ListCard);
app.component("swiper", Swiper);
app.component("swiper-slide", SwiperSlide);
app.use(store).use(router).mount("#app");
  • Home.vue中进行使用
vue
<template>
  <div>
    <swiper :pagination="{ clickable: false }" :autoplay="true" :loop="true">
      <swiper-slide>
        <img
          class="w-100"
          src="../assets/images/99f4d6c626c3c156c6f48c489d65e203.jpg"
        />
      </swiper-slide>
      <swiper-slide>
        <img
          class="w-100"
          src="../assets/images/3a6e7b07b9a379fb145fc96780816700.jpg"
        />
      </swiper-slide>
      <swiper-slide>
        <img
          class="w-100"
          src="../assets/images/05958a245fbeaff893df22a0c34db020.jpg"
        />
      </swiper-slide>
    </swiper>
    <!-- end of swiper -->
    <div class="nav-icons bg-white mt-3 text-center pt-3 text-dark-1">
      <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-xs">
        <i class="sprite sprite-arrow mr-1"></i>
        <span>收起</span>
      </div>
    </div>
    <!-- end of nav icons -->

    <m-list-card icon="cc-menu-circle" title="新闻资讯" :categories="newsCats">
      <template #items="{ category }">
        <div class="py-2" v-for="(news, i) in category.newsList" :key="i">
          <span>[{{ news.categoryName }}]</span>
          <span>|</span>
          <span>{{ news.title }}</span>
          <span>{{ news.date }}</span>
        </div>
      </template>
    </m-list-card>
  </div>
</template>

<script setup>
const newsCats = [
  {
    name: "热门",
    newsList: new Array(5).fill({}).map((v) => ({
      categotuName: "公告",
      title: "6月2日全服不停机更新公告",
      date: "06/01",
    })),
  },
  {
    name: "新闻",
    newsList: new Array(5).fill({}).map((v) => ({
      categotuName: "公告",
      title: "6月2日全服不停机更新公告",
      date: "06/01",
    })),
  },
  {
    name: "公告",
    newsList: new Array(5).fill({}).map((v) => ({
      categotuName: "公告",
      title: "6月2日全服不停机更新公告",
      date: "06/01",
    })),
  },
  {
    name: "活动",
    newsList: new Array(5).fill({}).map((v) => ({
      categotuName: "公告",
      title: "6月2日全服不停机更新公告",
      date: "06/01",
    })),
  },
  {
    name: "赛事",
    newsList: new Array(5).fill({}).map((v) => ({
      categotuName: "公告",
      title: "6月2日全服不停机更新公告",
      date: "06/01",
    })),
  },
];
</script>
<style lang="scss">
@import "../assets/scss/variables";

.swiper-pagination {
  text-align: right;
  padding-right: 1rem;
  .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;
    }
  }
}
</style>

3-12-首页新闻资讯-数据录入(+后台bug修复)

  • 在分类编辑页面直接点到分类新建页面,会有问题,数据不会改变,进行修复
vue
<template>
  <el-container class="main" style="height: 100vh">
    <!-- ...省略 -->
    
    <el-container>
      <el-header style="text-align: right; font-size: 12px"></el-header>
      <el-main>
        <router-view :key="$route.path"></router-view>
      </el-main>
    </el-container>
  </el-container>
</template>
<!-- ...省略 -->
  • tapbar添加吸顶效果
vue
<template>
  <div class="topbar bg-black py-2 text-white px-3 d-flex ai-center">
    <!-- ...省略 -->
  </div>
  <router-view />
</template>

<style lang="scss" scoped>
.logo {
  width: 2.46rem;
  height: 2.46rem;
  background-image: url(../assets/images/index.png);
  background-position: 0 -24.7rem;
  background-size: 30.076rem;
}
.topbar {
  position: sticky;
  top: 0;
  z-index: 999;
}
</style>
  • 后台新建一级分类新闻分类,新建二级分类新闻公告活动赛事,后台查询数量限制先改为100
  • 录入新闻,进入王者荣耀移动端官网,在控制台通过 $$('.news_list .title').map(el => el.innerHTML).slice(5)获取所有文章标题
  • npm i require-all 安装这个插件,它的主要用途就是把某个文件夹下的文件全都引入
javascript
module.exports = app => {
    const mongoose = require("mongoose")
    // 连接数据库
    mongoose.connect('mongodb://127.0.0.1:27017/node-vue-moba', {
        useNewUrlParser: true
    })

    require('require-all')(__dirname + '/../models')
}
  • 在routes中新建web文件夹,在里面新建index.js,编写给客户端前端使用的接口
javascript
module.exports = (app) => {
    const router = require("express").Router();
    const mongoose = require("mongoose");
    const Category = mongoose.model("Category");
    const Article = mongoose.model("Article");
    // 这个接口只是用于上传数据,不是给客户端用的
    router.get("/news/init", async (req, res) => {
        const parent = await Category.findOne({
            name: '新闻分类'
        })
        const cats = await Category.find().where({ parent }).lean();
        const newsTitles = [
            "王者荣耀夏日直播节来啦,看直播领福利一起狂欢整个夏日!",
            "赢下这一场!第六届王者荣耀全国大赛杭州启程!",
            "“2022鹏友会电竞杯王者荣耀夏季赛”落幕 ,TT电竞、TT语音助力专业赛事体验",
            "王者荣耀X中国邮政《二十四节气》系列邮品首发",
            "选英雄台词成为个性表情活动结果展示",
            "声明",
            "7月14日全服不停机更新公告",
            "7月13日外挂专项打击公告",
            "7月13日游戏内违规签名、攻略信息处罚公告",
            "7月13日挂车行为专项违规处罚公告",
            '【微信用户专属】微信小程序"游戏礼品站"购买马可波罗限定新皮肤抽奖活动',
            "【深海之息的礼物】活动开启公告",
            "“一元福利周”活动开启公告",
            "选英雄台词成为个性表情活动结果展示",
            "元歌源·梦皮肤海报投票活动开启公告",
            "每一步,都值得喝彩!北京TY战队加冕第五届王者荣耀全国大赛总冠军!",
            "今日预报丨苏州KSG上演A组首战,An能否突破啊泽坦边防线?",
            "运营思路教学丨浅析游戏中的大局观理解,助你掌控全局节奏!",
            "今日预报丨常规赛第二轮精彩开战,新版本XYG能否重整旗鼓?",
            "每一步,都值得喝彩——王者荣耀全国大赛决赛阶段,6月29日长沙开战!",
        ];
        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)
    });

    app.use("/web/api", router);
};
  • index.js中引入新编写的路由
javascript
const express = require('express')

const app = express()

app.set('secret', 'i2u34y12oi3u4y8')

// 解析客户端传递过来的post参数
app.use(express.json())
// 处理跨域
app.use(require('cors')())
app.use('/uploads', express.static(__dirname + '/uploads'))

require('./plugins/db')(app)
require('./routes/admin')(app)
require('./routes/web')(app)

app.listen(3000, () => {
    console.log('http://localhost:3000');
})
  • Article模型添加时间搓,让返回的数据带有时间
javascript
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)

3-13-首页新闻资讯-数据接口

  • 编写新闻资讯接口
javascript
module.exports = (app) => {
    const router = require("express").Router();
    const mongoose = require("mongoose");
    const Category = mongoose.model("Category");
    const Article = mongoose.model("Article");
    // 这个接口只是用于上传数据,不是给客户端用的
    router.get("/news/init", async (req, res) => {
        const parent = await Category.findOne({
            name: '新闻分类'
        })
        const cats = await Category.find().where({ parent }).lean();
        const newsTitles = [
            "王者荣耀夏日直播节来啦,看直播领福利一起狂欢整个夏日!",
            "赢下这一场!第六届王者荣耀全国大赛杭州启程!",
            "“2022鹏友会电竞杯王者荣耀夏季赛”落幕 ,TT电竞、TT语音助力专业赛事体验",
            "王者荣耀X中国邮政《二十四节气》系列邮品首发",
            "选英雄台词成为个性表情活动结果展示",
            "声明",
            "7月14日全服不停机更新公告",
            "7月13日外挂专项打击公告",
            "7月13日游戏内违规签名、攻略信息处罚公告",
            "7月13日挂车行为专项违规处罚公告",
            '【微信用户专属】微信小程序"游戏礼品站"购买马可波罗限定新皮肤抽奖活动',
            "【深海之息的礼物】活动开启公告",
            "“一元福利周”活动开启公告",
            "选英雄台词成为个性表情活动结果展示",
            "元歌源·梦皮肤海报投票活动开启公告",
            "每一步,都值得喝彩!北京TY战队加冕第五届王者荣耀全国大赛总冠军!",
            "今日预报丨苏州KSG上演A组首战,An能否突破啊泽坦边防线?",
            "运营思路教学丨浅析游戏中的大局观理解,助你掌控全局节奏!",
            "今日预报丨常规赛第二轮精彩开战,新版本XYG能否重整旗鼓?",
            "每一步,都值得喝彩——王者荣耀全国大赛决赛阶段,6月29日长沙开战!",
        ];
        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()
        // res.send(parent)

        // 用聚合查询好一点
        const parent = await Category.findOne({
            name: '新闻分类'
        })
        const cats = await Category.aggregate([
            { $match: { parent: parent._id } },
            { $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
            })
            return cat
        })
        res.send(cats)
    })

    app.use("/web/api", router);
};
  • 修改Category模型
javascript
const mongoose = require('mongoose')

const schema = new mongoose.Schema({
    name: { type: String },
    // 类型不是String,关联模型是它本身
    parent: { type: mongoose.SchemaTypes.ObjectId, ref: 'Category' }
})

// 关联查询
schema.virtual('children', {
    localField: '_id',
    foreignField: 'parent',
    justOne: false,
    ref: 'Category'
})

schema.virtual('newsList', {
    localField: '_id',
    foreignField: 'categories',
    justOne: false,
    ref: 'Article'
})

module.exports = mongoose.model('Category', schema)

3-34-首页新闻资讯-界面显示

  • web项目中 npm i axios 安装axios,main.js中导入http.js
javascript
import { createApp } from "vue";
import App from "./App.vue";
import router from "./router";
import store from "./store";
import axios from "axios";

const http = axios.create({
  baseURL: "http://localhost:3000/web/api",
});

import "./assets/scss/style.scss";
import "./assets/iconfont/iconfont.css";

import Card from "./components/Card.vue";
import ListCard from "./components/ListCard.vue";

//第一步-导入swiper组件
import { Swiper, SwiperSlide } from "swiper/vue";
//第二步-导入插件
import SwiperCore, { Pagination, Autoplay, Navigation } from "swiper";
import "swiper/swiper-bundle.css";
//第三步-使用插件(同时在模板中swiper标签使用)
SwiperCore.use([Pagination, Autoplay, Navigation]);

const app = createApp(App);
app.config.globalProperties.$http = http;
app.component("m-card", Card);
app.component("m-list-card", ListCard);
app.component("swiper", Swiper);
app.component("swiper-slide", SwiperSlide);
app.use(store).use(router).mount("#app");
  • npm i dayjs 安装格式化时间的工具
  • 新增text-ellipsis类
css
// ↑省略...

@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%;
}

// ↓省略...
  • Home.vue请求数据
vue
<template>
  <div>
    <!-- end of nav icons -->
    <!-- ↑省略... -->
    
    <m-list-card
      icon="cc-menu-circle"
      title="新闻资讯"
      :categories="newsCats.data"
    >
      <template #items="{ category }">
        <div
          class="py-2 fs-lg d-flex"
          v-for="(news, i) in category.newsList"
          :key="i"
        >
          <span class="text-info">[{{ news.categoryName }}]</span>
          <span class="px-2">|</span>
          <span class="flex-1 text-dark-1 text-ellipsis pr-2">{{
            news.title
          }}</span>
          <span class="text-grey-1 fs-sm">{{
            dayjs(news.createdAt).format("MM/DD")
          }}</span>
        </div>
      </template>
    </m-list-card>
  </div>
</template>

<script setup>
import { reactive, getCurrentInstance } from "vue";
import dayjs from "dayjs";

// 拿到vue实例
const _this = getCurrentInstance();
// 解构vue实例原型方法$http
const { $http } = _this.appContext.config.globalProperties;

let newsCats = reactive({ data: [] });

const fetchNewsCats = async () => {
  const res = await $http.get("news/list");
  newsCats.data = res.data;
};

fetchNewsCats();
  
// ↓省略 
</script>

3-15-首页英雄列表-提取官网数据

  • 进入王者荣耀移动端官网,在控制台中查询,获取英雄分类和英雄列表数据
javascript
$$(".hero-nav > li").map((li, i)=>{
  return {
    name: li.innerText,
    heroes: $$("li",$$(".hero-list")[i]).map(el => {
      return {
        name: $$('h3', el)[0].innerHTML,
        avatar: $$('img', el)[0].src
      }
    })
  }
})

3-16-首页英雄列表-录入数据

  • 后台管理系统添加英雄分类,在英雄分类下添加二级分类:战士、法师、坦克、刺客、射手、辅助
  • 把之前获取的英雄分类和英雄列表数据导入到数据库,通过JSON.stringify()转换成字符串进行复制
javascript
module.exports = (app) => {
   // ↑ 省略...

  // 导入英雄数据
  router.get("/heroes/init", async (req, res) => {
    await Hero.deleteMany({})
    const rawData = [
      {
        name: "热门",
        heroes: [
          {
            name: "后羿",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/169/169.jpg",
          },
          {
            name: "安琪拉",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/142/142.jpg",
          },
          {
            name: "妲己",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/109/109.jpg",
          },
          {
            name: "鲁班七号",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/112/112.jpg",
          },
          {
            name: "李信",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/507/507.jpg",
          },
          {
            name: "瑶",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/505/505.jpg",
          },
          {
            name: "马可波罗",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/132/132.jpg",
          },
          {
            name: "孙尚香",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/111/111.jpg",
          },
          {
            name: "甄姬",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/127/127.jpg",
          },
          {
            name: "亚瑟",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/166/166.jpg",
          },
        ],
      },
      {
        name: "战士",
        heroes: [
          {
            name: "赵云",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/107/107.jpg",
          },
          {
            name: "墨子",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/108/108.jpg",
          },
          {
            name: "钟无艳",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/117/117.jpg",
          },
          {
            name: "吕布",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/123/123.jpg",
          },
          {
            name: "夏侯惇",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/126/126.jpg",
          },
          {
            name: "曹操",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/128/128.jpg",
          },
          {
            name: "典韦",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/129/129.jpg",
          },
          {
            name: "宫本武藏",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/130/130.jpg",
          },
          {
            name: "达摩",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/134/134.jpg",
          },
          {
            name: "老夫子",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/139/139.jpg",
          },
          {
            name: "关羽",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/140/140.jpg",
          },
          {
            name: "程咬金",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/144/144.jpg",
          },
          {
            name: "露娜",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/146/146.jpg",
          },
          {
            name: "花木兰",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/154/154.jpg",
          },
          {
            name: "橘右京",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/163/163.jpg",
          },
          {
            name: "亚瑟",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/166/166.jpg",
          },
          {
            name: "孙悟空",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/167/167.jpg",
          },
          {
            name: "刘备",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/170/170.jpg",
          },
          {
            name: "杨戬",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/178/178.jpg",
          },
          {
            name: "雅典娜",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/183/183.jpg",
          },
          {
            name: "哪吒",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/180/180.jpg",
          },
          {
            name: "铠",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/193/193.jpg",
          },
          {
            name: "梦奇",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/198/198.jpg",
          },
          {
            name: "裴擒虎",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/502/502.jpg",
          },
          {
            name: "狂铁",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/503/503.jpg",
          },
          {
            name: "孙策",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/510/510.jpg",
          },
          {
            name: "李信",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/507/507.jpg",
          },
          {
            name: "盘古",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/529/529.jpg",
          },
          {
            name: "云中君",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/506/506.jpg",
          },
          {
            name: "曜",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/522/522.jpg",
          },
          {
            name: "马超",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/518/518.jpg",
          },
          {
            name: "蒙恬",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/527/527.jpg",
          },
          {
            name: "夏洛特",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/536/536.jpg",
          },
          {
            name: "司空震",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/537/537.jpg",
          },
          {
            name: "云缨",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/538/538.jpg",
          },
        ],
      },
      {
        name: "法师",
        heroes: [
          {
            name: "小乔",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/106/106.jpg",
          },
          {
            name: "墨子",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/108/108.jpg",
          },
          {
            name: "妲己",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/109/109.jpg",
          },
          {
            name: "嬴政",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/110/110.jpg",
          },
          {
            name: "高渐离",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/115/115.jpg",
          },
          {
            name: "孙膑",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/118/118.jpg",
          },
          {
            name: "扁鹊",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/119/119.jpg",
          },
          {
            name: "芈月",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/121/121.jpg",
          },
          {
            name: "周瑜",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/124/124.jpg",
          },
          {
            name: "甄姬",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/127/127.jpg",
          },
          {
            name: "武则天",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/136/136.jpg",
          },
          {
            name: "貂蝉",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/141/141.jpg",
          },
          {
            name: "安琪拉",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/142/142.jpg",
          },
          {
            name: "露娜",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/146/146.jpg",
          },
          {
            name: "姜子牙",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/148/148.jpg",
          },
          {
            name: "王昭君",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/152/152.jpg",
          },
          {
            name: "张良",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/156/156.jpg",
          },
          {
            name: "不知火舞",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/157/157.jpg",
          },
          {
            name: "钟馗",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/175/175.jpg",
          },
          {
            name: "诸葛亮",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/190/190.jpg",
          },
          {
            name: "干将莫邪",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/182/182.jpg",
          },
          {
            name: "女娲",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/179/179.jpg",
          },
          {
            name: "杨玉环",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/176/176.jpg",
          },
          {
            name: "弈星",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/197/197.jpg",
          },
          {
            name: "米莱狄",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/504/504.jpg",
          },
          {
            name: "司马懿",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/137/137.jpg",
          },
          {
            name: "沈梦溪",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/312/312.jpg",
          },
          {
            name: "上官婉儿",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/513/513.jpg",
          },
          {
            name: "嫦娥",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/515/515.jpg",
          },
          {
            name: "西施",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/523/523.jpg",
          },
          {
            name: "司空震",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/537/537.jpg",
          },
          {
            name: "金蝉",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/540/540.jpg",
          },
        ],
      },
      {
        name: "坦克",
        heroes: [
          {
            name: "廉颇",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/105/105.jpg",
          },
          {
            name: "庄周",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/113/113.jpg",
          },
          {
            name: "刘禅",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/114/114.jpg",
          },
          {
            name: "钟无艳",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/117/117.jpg",
          },
          {
            name: "白起",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/120/120.jpg",
          },
          {
            name: "芈月",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/121/121.jpg",
          },
          {
            name: "吕布",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/123/123.jpg",
          },
          {
            name: "夏侯惇",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/126/126.jpg",
          },
          {
            name: "达摩",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/134/134.jpg",
          },
          {
            name: "项羽",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/135/135.jpg",
          },
          {
            name: "程咬金",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/144/144.jpg",
          },
          {
            name: "刘邦",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/149/149.jpg",
          },
          {
            name: "亚瑟",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/166/166.jpg",
          },
          {
            name: "牛魔",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/168/168.jpg",
          },
          {
            name: "张飞",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/171/171.jpg",
          },
          {
            name: "太乙真人",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/186/186.jpg",
          },
          {
            name: "东皇太一",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/187/187.jpg",
          },
          {
            name: "铠",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/193/193.jpg",
          },
          {
            name: "苏烈",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/194/194.jpg",
          },
          {
            name: "梦奇",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/198/198.jpg",
          },
          {
            name: "孙策",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/510/510.jpg",
          },
          {
            name: "盾山",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/509/509.jpg",
          },
          {
            name: "嫦娥",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/515/515.jpg",
          },
          {
            name: "猪八戒",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/511/511.jpg",
          },
          {
            name: "蒙恬",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/527/527.jpg",
          },
          {
            name: "阿古朵",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/533/533.jpg",
          },
        ],
      },
      {
        name: "刺客",
        heroes: [
          {
            name: "赵云",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/107/107.jpg",
          },
          {
            name: "阿轲",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/116/116.jpg",
          },
          {
            name: "李白",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/131/131.jpg",
          },
          {
            name: "貂蝉",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/141/141.jpg",
          },
          {
            name: "韩信",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/150/150.jpg",
          },
          {
            name: "兰陵王",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/153/153.jpg",
          },
          {
            name: "花木兰",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/154/154.jpg",
          },
          {
            name: "不知火舞",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/157/157.jpg",
          },
          {
            name: "娜可露露",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/162/162.jpg",
          },
          {
            name: "橘右京",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/163/163.jpg",
          },
          {
            name: "孙悟空",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/167/167.jpg",
          },
          {
            name: "百里守约",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/196/196.jpg",
          },
          {
            name: "百里玄策",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/195/195.jpg",
          },
          {
            name: "裴擒虎",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/502/502.jpg",
          },
          {
            name: "元歌",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/125/125.jpg",
          },
          {
            name: "司马懿",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/137/137.jpg",
          },
          {
            name: "上官婉儿",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/513/513.jpg",
          },
          {
            name: "云中君",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/506/506.jpg",
          },
          {
            name: "马超",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/518/518.jpg",
          },
          {
            name: "镜",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/531/531.jpg",
          },
          {
            name: "澜",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/528/528.jpg",
          },
          {
            name: "云缨",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/538/538.jpg",
          },
          {
            name: "暃",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/542/542.jpg",
          },
        ],
      },
      {
        name: "射手",
        heroes: [
          {
            name: "孙尚香",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/111/111.jpg",
          },
          {
            name: "鲁班七号",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/112/112.jpg",
          },
          {
            name: "马可波罗",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/132/132.jpg",
          },
          {
            name: "狄仁杰",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/133/133.jpg",
          },
          {
            name: "后羿",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/169/169.jpg",
          },
          {
            name: "李元芳",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/173/173.jpg",
          },
          {
            name: "虞姬",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/174/174.jpg",
          },
          {
            name: "成吉思汗",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/177/177.jpg",
          },
          {
            name: "黄忠",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/192/192.jpg",
          },
          {
            name: "百里守约",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/196/196.jpg",
          },
          {
            name: "公孙离",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/199/199.jpg",
          },
          {
            name: "伽罗",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/508/508.jpg",
          },
          {
            name: "蒙犽",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/524/524.jpg",
          },
          {
            name: "艾琳",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/155/155.jpg",
          },
          {
            name: "戈娅",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/548/548.jpg",
          },
        ],
      },
      {
        name: "辅助",
        heroes: [
          {
            name: "庄周",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/113/113.jpg",
          },
          {
            name: "刘禅",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/114/114.jpg",
          },
          {
            name: "孙膑",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/118/118.jpg",
          },
          {
            name: "牛魔",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/168/168.jpg",
          },
          {
            name: "张飞",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/171/171.jpg",
          },
          {
            name: "钟馗",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/175/175.jpg",
          },
          {
            name: "蔡文姬",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/184/184.jpg",
          },
          {
            name: "太乙真人",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/186/186.jpg",
          },
          {
            name: "大乔",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/191/191.jpg",
          },
          {
            name: "东皇太一",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/187/187.jpg",
          },
          {
            name: "鬼谷子",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/189/189.jpg",
          },
          {
            name: "苏烈",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/194/194.jpg",
          },
          {
            name: "明世隐",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/501/501.jpg",
          },
          {
            name: "盾山",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/509/509.jpg",
          },
          {
            name: "瑶",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/505/505.jpg",
          },
          {
            name: "鲁班大师",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/525/525.jpg",
          },
          {
            name: "金蝉",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/540/540.jpg",
          },
          {
            name: "桑启",
            avatar:
            "https://game.gtimg.cn/images/yxzj/img201606/heroimg/534/534.jpg",
          },
        ],
      },
    ];

    for (let cat of rawData) {
      if (cat.name === '热门') {
        continue;
      }
      // 找到当前分类在数据库中对应的数据
      const category = await Category.findOne({
        name: cat.name
      })

      cat.heroes = cat.heroes.map(hero => {
        hero.categories = [category]
        return hero
      })

      // 录入英雄
      await Hero.insertMany(cat.heroes)
    }

    res.send(await Hero.find())

  });

  app.use("/web/api", router);
};
  • 指定集合名称,复数格式+es
javascript
// ↑ 省略...

module.exports = mongoose.model('Hero', schema, 'heroes')

3-17-首页英雄列表-界面展示

  • 编写英雄列表接口
javascript
module.exports = (app) => {
  
    // ↑ 省略...
  
    // 英雄列表接口
    router.get("/heroes/list", async (req, res) => {
        const parent = await Category.findOne({
            name: "英雄分类",
        });

        const cats = await Category.aggregate([
            { $match: { parent: parent._id } },
            {
                $lookup: {
                    from: 'heroes',
                    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);
};
  • 界面展示
vue
<template>
  <div>
    <!-- ↑ 省略... -->
    <m-list-card
      icon="yingxiongxiangqing"
      title="英雄列表"
      :categories="heroCats.data"
    >
      <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, i) in category.heroList"
            :key="i"
          >
            <img :src="hero.avatar" class="w-100" />
            <div>{{ hero.name }}</div>
          </div>
        </div>
      </template>
    </m-list-card>
  </div>
</template>

<script setup>
import { reactive, getCurrentInstance } from "vue";
import dayjs from "dayjs";

// 拿到vue实例
const _this = getCurrentInstance();
// 结构vue实例原型方法$http
const { $http } = _this.appContext.config.globalProperties;

let newsCats = reactive({ data: [] });
let heroCats = reactive({ data: [] });

const fetchNewsCats = async () => {
  const res = await $http.get("news/list");
  newsCats.data = res.data;
};

const fetchHeroCats = async () => {
  const res = await $http.get("heroes/list");
  heroCats.data = res.data;
};

fetchNewsCats();
fetchHeroCats();
</script>
<style lang="scss">
// ... 省略 
</style>
  • 轮播图组件添加高度自适应
vue
<template>
  <m-card :icon="icon" :title="title">
    <div class="nav jc-between">
      <div
        class="nav-item"
        :class="{ active: active === i }"
        v-for="(category, i) in categories"
        :key="i"
        @click="slideTo(i)"
      >
        <div class="nav-link">{{ category.name }}</div>
      </div>
    </div>
    <div class="pt-3">
      <swiper
        :autoHeight="true"
        :loop="true"
        @slideChange="onSlideChange"
        @swiper="setControlledSwiper"
      >
        <swiper-slide v-for="(category, i) in categories" :key="i">
          <slot name="items" :category="category"></slot>
        </swiper-slide>
      </swiper>
    </div>
  </m-card>
</template>
<script setup>
  // 省略...
</script>

3-18-新闻详情页

  • 前端添加新闻详情路由
javascript
import { createRouter, createWebHashHistory } from "vue-router";
import Main from "../views/Main.vue";
import Home from "../views/Home.vue";
import Article from "../views/Article.vue";

const routes = [
  {
    path: "/",
    component: Main,
    children: [
      {
        path: "/",
        name: "home",
        component: Home,
      },
      {
        path: "/articles/:id",
        name: "article",
        component: Article,
        props: true,
      },
    ],
  },
];

const router = createRouter({
  history: createWebHashHistory(),
  routes,
});

export default router;
  • 新建Article.vue
vue
<template>
  <div class="page-article" v-if="model">
    <div class="d-flex py-3 px-2">
      <div class="iconfont icon-back"></div>
      <strong class="flex-1">{{ model.data.title }}</strong>
      <div class="text-grey fs-xs">2022</div>
    </div>
    <div v-html="model.data.body"></div>
  </div>
</template>

<script setup>
import { reactive, getCurrentInstance } from "vue";

// 拿到vue实例
const _this = getCurrentInstance();
// 解构vue实例原型方法$http
const { $http } = _this.appContext.config.globalProperties;

const props = defineProps({
  id: {
    required: true,
  },
});

let model = reactive({ data: {} });

const fetch = async () => {
  const res = await $http.get(`articles/${props.id}`);
  model.data = res.data;
};

fetch();
</script>
  • Home.vue添加点击跳转事件
vue
<template>
  <div>
    
    <!--↑ 省略... -->

    <m-list-card
      icon="cc-menu-circle"
      title="新闻资讯"
      :categories="newsCats.data"
    >
      <template #items="{ category }">
        <router-link
          :to="`/articles/${news._id}`"
          class="py-2 fs-lg d-flex"
          v-for="(news, i) in category.newsList"
          :key="i"
        >
          <span class="text-info">[{{ news.categoryName }}]</span>
          <span class="px-2">|</span>
          <span class="flex-1 text-dark-1 text-ellipsis pr-2">{{
            news.title
          }}</span>
          <span class="text-grey-1 fs-sm">{{
            dayjs(news.createdAt).format("MM/DD")
          }}</span>
        </router-link>
      </template>
    </m-list-card>

    <!--↓ 省略... -->
  • 后端编写文章详情接口
javascript
module.exports = (app) => {
  
  // ↑ 省略...
  
  // 文章详情
  router.get('/articles/:id', async (req, res) => {
    const data = await Article.findById(req.params.id)
    res.send(data)
  })
  
  app.use("/web/api", router);
};

3-19-新闻详情页-完善

  • 详情页样式功能完善
css
// 其他省略...

p {
  line-height: 1.5em;
}

// 其他省略...

// borders
@each $dir in (top, right, bottom, left) {
  .border-#{$dir} {
    border-#{$dir}: 1px solid $border-color;
  }
}
css
// 其他省略

// colors
$colors: (
  "blue": #4394e4,
);
vue
<template>
  <div class="page-article" v-if="model">
    <div class="d-flex py-3 px-2 border-bottom">
      <div class="iconfont icon-back text-blue" @click="$router.back"></div>
      <strong class="flex-1 text-blue pl-2 pr-3 text-ellipsis">{{
        model.data.title
      }}</strong>
      <div class="text-grey fs-xs">
        {{ dayjs(model.data.createdAt).format("YYYY/MM/DD") }}
      </div>
    </div>
    <div v-html="model.data.body" class="px-3 body fs-lg"></div>
    <div class="px-3 border-top py-3">
      <div class="d-flex ai-center">
        <i class="iconfont icon-news"></i>
        <strong class="text-blue fs-lg ml-1">相关资讯</strong>
      </div>
      <div class="pt-1 fs-lg">
        <router-link
          :to="`/articles/${item._id}`"
          v-for="item in model.data.related"
          :key="item.id"
          ><div class="py-1 w-100 text-ellipsis">
            {{ item.title }}
          </div></router-link
        >
      </div>
    </div>
  </div>
</template>

<script setup>
import { reactive, getCurrentInstance, watch } from "vue";
import dayjs from "dayjs";

// 拿到vue实例
const _this = getCurrentInstance();
// 解构vue实例原型方法$http
const { $http, $router } = _this.appContext.config.globalProperties;

const props = defineProps({
  id: {
    required: true,
  },
});

let model = reactive({ data: {} });

const fetch = async () => {
  const res = await $http.get(`articles/${props.id}`);
  model.data = res.data;
};

fetch();
// 监听路由参数改变
watch(() => props.id, fetch);
</script>
<style lang="scss">
.page-article {
  .body {
    img {
      max-width: 100%;
      height: auto;
      display: block;
      margin: 0 auto;
    }
    iframe {
      width: 100%;
      height: auto;
    }
  }
}
</style>
  • 服务端文章详情接口添加相关资讯字段
javascript
module.exports = (app) => {
  
    // ↑ 省略...  
  
    // 文章详情
    router.get('/articles/:id', async (req, res) => {
        const data = await Article.findById(req.params.id).lean()
        data.related = await Article.find().where({
            categories: { $in: data.categories },
            title: { $ne: data.title }
        }).limit(2)
        res.send(data)
    })

    app.use("/web/api", router);
};

3-20-英雄详情页-1-前端准备

  • 新建Hero.vue英雄详情页面
vue
<template>
  <div class="page-hero" v-if="model.data">
    <div class="topbar bg-black py-2 text-white px-3 d-flex ai-center">
      <div class="logo"></div>
      <div class="px-2 flex-1">
        <span>王者荣耀</span>
        <span class="ml-2">攻略站</span>
      </div>
      <router-link to="/" class="text-white">
        <span>更多英雄</span>
        <strong class="pl-2 fs-xl">&gt;</strong>
      </router-link>
    </div>
  </div>
</template>

<script setup>
import { reactive, getCurrentInstance } from "vue";
// 拿到vue实例
const _this = getCurrentInstance();
// 解构vue实例原型方法$http
const { $http } = _this.appContext.config.globalProperties;

const props = defineProps({
  id: {
    required: true,
  },
});

const model = reactive({ data: null });

const fetch = async () => {
  const res = await $http.get(`heroes/${props.id}`);
  model.data = res.data;
};

fetch();
</script>
<style lang="scss">
.page-hero {
  .logo {
    width: 2.46rem;
    height: 2.46rem;
    background-image: url(../assets/images/index.png);
    background-position: 0 -24.7rem;
    background-size: 30.076rem;
  }
}
</style>
  • 路由配置
javascript
import { createRouter, createWebHashHistory } from "vue-router";
import Main from "../views/Main.vue";
import Home from "../views/Home.vue";
import Article from "../views/Article.vue";
import Hero from "../views/Hero.vue";

const routes = [
  {
    path: "/",
    component: Main,
    children: [
      {
        path: "/",
        name: "home",
        component: Home,
      },
      {
        path: "/articles/:id",
        name: "article",
        component: Article,
        props: true,
      },
    ],
  },
  {
    path: "/heroes/:id",
    name: "hero",
    component: Hero,
    props: true,
  },
];

const router = createRouter({
  history: createWebHashHistory(),
  routes,
});

export default router;
  • Home.vue添加点击跳转事件
vue
<template>
  <div>
    <!-- ↑省略... -->
    <m-list-card
      icon="yingxiongxiangqing"
      title="英雄列表"
      :categories="heroCats.data"
    >
      <template #items="{ category }">
        <div class="d-flex flex-wrap" style="margin: 0 -0.5rem">
          <router-link
            :to="`/heroes/${hero._id}`"
            class="p-2 text-center"
            style="width: 20%"
            v-for="(hero, i) in category.heroList"
            :key="i"
          >
            <img :src="hero.avatar" class="w-100" />
            <div>{{ hero.name }}</div>
          </router-link>
        </div>
      </template>
    </m-list-card>
    <!-- ↓省略... -->
  </div>
</template>
  • 后端编写英雄信息查询接口
javascript
module.exports = (app) => {
   
  // ↑省略... 
  router.get('/heroes/:id', async (req, res) => {
    const data = await Hero.findById(req.params.id).lean()
    res.send(data)
  })

  app.use("/web/api", router);
};

3-21-英雄详情页-2-后台编辑

  • 英雄模型添加背景图字段和搭档字段,英雄技能新增冷却值和消耗字段
    • 被谁克制和克制谁与搭档字段类似,自行编写,这里省略,铭文和加点建议也自行编写
javascript
const mongoose = require('mongoose')

const schema = new mongoose.Schema({
    name: { type: String },
    avatar: { 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 },
        // 冷却值
        delay: { type: String },
        // 消耗
        cost: { type: String },
        // 描述
        description: { type: String },
        // 小提示
        tips: { type: String }
    }],
    // 出装推荐(顺风、逆风)
    items1: [{ type: mongoose.SchemaTypes.ObjectId, ref: 'Item' }],
    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, 'heroes')
  • 修改英雄编辑页面,并添加一个英雄信息(例如赵云)
vue
<template>
  <div class="hero-edit">
    <h1>{{ id ? "编辑" : "新建" }}英雄</h1>
    <el-form label-width="120px" @submit.prevent="save">
      <el-tabs type="border-card" model-value="basic">
        <el-tab-pane label="基础信息" name="basic">
          <el-form-item label="名称">
            <el-input v-model="model.obj.name"></el-input>
          </el-form-item>
          <el-form-item label="称号">
            <el-input v-model="model.obj.title"></el-input>
          </el-form-item>
          <el-form-item label="头像">
            <el-upload
              class="avatar-uploader"
              :action="uploadUrl"
              :show-file-list="false"
              :on-success="(res) => (model.obj.avatar = res.url)"
              :headers="getAuthHeaders()"
            >
              <img
                v-if="model.obj.avatar"
                :src="model.obj.avatar"
                class="avatar"
              />
              <el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon>
            </el-upload>
          </el-form-item>
          <el-form-item label="Banner">
            <el-upload
              class="avatar-uploader"
              :action="uploadUrl"
              :show-file-list="false"
              :on-success="(res) => (model.obj.banner = res.url)"
              :headers="getAuthHeaders()"
            >
              <img
                v-if="model.obj.banner"
                :src="model.obj.banner"
                class="avatar"
              />
              <el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon>
            </el-upload>
          </el-form-item>
          <el-form-item label="类型">
            <el-select v-model="model.obj.categories" multiple>
              <el-option
                v-for="item of model.categories"
                :key="item._id"
                :label="item.name"
                :value="item._id"
              ></el-option>
            </el-select>
          </el-form-item>
          <el-form-item label="难度">
            <el-rate
              :max="9"
              show-score
              v-model="model.obj.scores.difficult"
              style="margin-top: 0.6rem"
            ></el-rate>
          </el-form-item>
          <el-form-item label="技能">
            <el-rate
              :max="9"
              show-score
              v-model="model.obj.scores.skills"
              style="margin-top: 0.6rem"
            ></el-rate>
          </el-form-item>
          <el-form-item label="攻击">
            <el-rate
              :max="9"
              show-score
              v-model="model.obj.scores.attack"
              style="margin-top: 0.6rem"
            ></el-rate>
          </el-form-item>
          <el-form-item label="生存">
            <el-rate
              :max="9"
              show-score
              v-model="model.obj.scores.survive"
              style="margin-top: 0.6rem"
            ></el-rate>
          </el-form-item>
          <el-form-item label="顺风出装">
            <el-select v-model="model.obj.items1" multiple>
              <el-option
                v-for="item of model.items"
                :key="item._id"
                :label="item.name"
                :value="item._id"
              ></el-option>
            </el-select>
          </el-form-item>
          <el-form-item label="逆风出装">
            <el-select v-model="model.obj.items2" multiple>
              <el-option
                v-for="item of model.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.obj.usageTips"></el-input>
          </el-form-item>
          <el-form-item label="对抗技巧">
            <el-input type="textarea" v-model="model.obj.battleTips"></el-input>
          </el-form-item>
          <el-form-item label="团战技巧">
            <el-input type="textarea" v-model="model.obj.teamTips"></el-input>
          </el-form-item>
        </el-tab-pane>
        <el-tab-pane label="技能" name="skills">
          <el-button
            size="small"
            type="primary"
            @click="model.obj.skills.push({})"
            ><el-icon :size="size" :color="color"> <Plus /> </el-icon
            ><span style="line-height: 1.2">添加技能</span></el-button
          >
          <el-row type="flex">
            <el-col :md="12" v-for="(item, i) in model.obj.skills" :key="i">
              <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"
                  :show-file-list="false"
                  :on-success="(res) => (item.icon = res.url)"
                  :headers="getAuthHeaders()"
                >
                  <img v-if="item.icon" :src="item.icon" class="avatar" />
                  <el-icon v-else class="avatar-uploader-icon"
                    ><Plus
                  /></el-icon>
                </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
                  size="small"
                  type="danger"
                  @click="model.obj.skills.splice(i, 1)"
                  >删除</el-button
                >
              </el-form-item>
            </el-col>
          </el-row>
        </el-tab-pane>
        <el-tab-pane label="最佳搭档" name="partners">
          <el-button
            size="small"
            type="primary"
            @click="model.obj.partners.push({})"
            ><el-icon :size="size" :color="color"> <Plus /> </el-icon
            ><span style="line-height: 1.2">添加英雄</span></el-button
          >
          <el-row type="flex">
            <el-col :md="12" v-for="(item, i) in model.obj.partners" :key="i">
              <el-form-item label="英雄">
                <el-select filterable v-model="item.hero">
                  <el-option
                    v-for="hero in model.heroes"
                    :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
                  size="small"
                  type="danger"
                  @click="model.obj.partners.splice(i, 1)"
                  >删除</el-button
                >
              </el-form-item>
            </el-col>
          </el-row>
        </el-tab-pane>
      </el-tabs>

      <el-form-item style="margin-top: 1rem">
        <el-button type="primary" native-type="submit">保存</el-button>
      </el-form-item>
    </el-form>
  </div>
</template>

<script setup>
import { reactive, defineProps, getCurrentInstance } from "vue";
import { Plus } from "@element-plus/icons-vue";
// 拿到vue实例
const _this = getCurrentInstance();
// 结构vue实例原型方法$http
const { $http, $router, $message } = _this.appContext.config.globalProperties;

const props = defineProps({
  id: {},
});

const fetch = async () => {
  const res = await $http.get(`rest/heroes/${props.id}`);
  model.obj = Object.assign({}, model.obj, res.data);
};

const fetchCategories = async () => {
  const res = await $http.get(`rest/categories`);
  model.categories = res.data;
};

const fetchItems = async () => {
  const res = await $http.get(`rest/items`);
  model.items = res.data;
};

const fetchIHeroes = async () => {
  const res = await $http.get(`rest/heroes`);
  model.heroes = res.data;
};

let model = reactive({
  categories: [],
  obj: {
    name: "",
    avatar: "",
    banner: "",
    scores: {
      difficult: 0,
      skills: 0,
      attack: 0,
      survive: 0,
    },
    skills: [],
    partners: [],
  },
  items: [],
  heroes: [],
});

fetchCategories();
fetchItems();
fetchIHeroes();
props.id && fetch();

const save = async () => {
  let res;
  if (props.id) {
    res = await $http.put(`rest/heroes/${props.id}`, model.obj);
  } else {
    res = await $http.post("rest/heroes", model.obj);
  }
  // 跳转到物品列表
  $router.push("/heroes/list");
  $message({
    type: "success",
    message: "保存成功",
  });
};
</script>

3-20-英雄详情页-3-前端顶部

  • 编写web项目Hero.vue页面
vue
<template>
  <div class="page-hero" v-if="model.data">
    <div class="topbar bg-black py-2 text-white px-3 d-flex ai-center">
      <div class="logo"></div>
      <div class="px-2 flex-1">
        <span>王者荣耀</span>
        <span class="ml-2">攻略站</span>
      </div>
      <router-link to="/" class="text-white">
        <span>更多英雄</span>
        <strong class="pl-2 fs-xl">&gt;</strong>
      </router-link>
    </div>
    <div
      class="top"
      :style="{ 'background-image': `url(${model.data.banner})` }"
    >
      <div class="info text-white p-3 h-100 d-flex flex-column jc-end">
        <div class="fs-sm">{{ model.data.title }}</div>
        <h2 class="my-2">{{ model.data.name }}</h2>
        <div class="fs-sm">
          {{ model.data.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.data.scores">
            <span>难度</span>
            <span class="badge bg-primary">{{
              model.data.scores.difficult
            }}</span>
            <span>技能</span>
            <span class="badge bg-blue-1">{{ model.data.scores.skills }}</span>
            <span>攻击</span>
            <span class="badge bg-danger">{{ model.data.scores.attack }}</span>
            <span>生存</span>
            <span class="badge bg-dark">{{ model.data.scores.survive }}</span>
          </div>
          <router-link to="/" class="text-gery fs-sm"
            >皮肤:10 &gt;</router-link
          >
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { reactive, getCurrentInstance } from "vue";
// 拿到vue实例
const _this = getCurrentInstance();
// 解构vue实例原型方法$http
const { $http } = _this.appContext.config.globalProperties;

const props = defineProps({
  id: {
    required: true,
  },
});

const model = reactive({ data: null });

const fetch = async () => {
  const res = await $http.get(`heroes/${props.id}`);
  model.data = res.data;
  console.log(model.data);
};

fetch();
</script>
<style lang="scss">
.page-hero {
  .logo {
    width: 2.46rem;
    height: 2.46rem;
    background-image: url(../assets/images/index.png);
    background-position: 0 -24.7rem;
    background-size: 30.076rem;
  }
  .top {
    height: 50vw;
    background: #fff no-repeat top center;
    background-size: auto 100%;
  }
  .info {
    background: linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 1));
    .scores {
      .badge {
        margin: 0 1em;
        display: inline-block;
        width: 1rem;
        height: 1rem;
        line-height: 1rem;
        text-align: center;
        border-radius: 50%;
        font-size: 0.6rem;
        border: 1px solid rgba(255, 255, 255, 0.2);
      }
    }
  }
}
</style>
css
// colors
$colors: (
  "primary": #db9e3f,
  "info": #4b67af,
  "blue": #4394e4,
  "blue-1": #1f3695,
  "danger": #791a15,
  "white": #fff,
  "light": #f9f9f9,
  "light-1": #d4d9de,
  "grey": #999,
  "grey-1": #666,
  "dark-1": #343440,
  "dark": #222,
  "black": #000,
);
// ↓省略...
  • 英雄详情接口关联调用
javascript
module.exports = (app) => {
  // ↑ 省略...
  router.get('/heroes/:id', async (req, res) => {
    const data = await Hero.findById(req.params.id).populate('categories items1 items2  partners.hero').lean()
    res.send(data)
  })
  
  app.use("/web/api", router);
};

4-1-生产环境编译

  • 在server项目中根目录下新建public静态文件夹,用来存放打包好的前端项目(admin和web),后续会配置
  • 先弄admin项目
  • 需要把baseURL改一下
    • 根据开发环境和生成环境进行替换
javascript
import axios from "axios";

import app from "./main";

const http = axios.create({
  baseURL: process.env.VUE_APP_API_URL || "/admin/api",
  // baseURL: "http://localhost:3000/admin/api",
});

http.interceptors.request.use((config) => {
  if (localStorage.token) {
    config.headers.Authorization = "Bearer " + localStorage.token;
  }
  return config;
});

http.interceptors.response.use(
  (res) => {
    return res;
  },
  (err) => {
    if (err.response.data.message) {
      app.config.globalProperties.$message({
        type: "error",
        message: err.response.data.message,
      });
      // 如果返回的状态码为401,就跳转回登录页
      if (err.response.status === 401) {
        app.config.globalProperties.$router.push("/login");
      }
    }
    return Promise.reject(err);
  }
);

export default http;
- 在项目根目录下创建 .env.development 文件,表示开发环境下用到的环境变量
bash
VUE_APP_API_URL=http://localhost:3000/admin/api
  • 在admin中执行npm run build打包
    • npm i serve -g 安装服务器插件,可以执行打包好的build 通过serve ./dist 命令就可以运行项目
  • 把打包好的项目整个dist文件夹先放到server根目录中,改名为admin
  • server项目托管admin项目静态文件,和之前托管静态图片一样
javascript
const express = require('express')

const app = express()

app.set('secret', 'i2u34y12oi3u4y8')

// 解析客户端传递过来的post参数
app.use(express.json())
// 处理跨域
app.use(require('cors')())
app.use('/admin', express.static(__dirname + '/admin'))
app.use('/uploads', express.static(__dirname + '/uploads'))

require('./plugins/db')(app)
require('./routes/admin')(app)
require('./routes/web')(app)

app.listen(3000, () => {
  console.log('http://localhost:3000');
})
  • 返问的时候会有问题,路径不正确,因为打包之后的 index.html 直接是在根路径(server/也就是/)下查找css,js文件,需要改成admin下(server/admin/ 也就是 /admin)中查找,配置vue.config.js
javascript
const { defineConfig } = require("@vue/cli-service");
module.exports = defineConfig({
  transpileDependencies: true,
  lintOnSave: false,
  outputDir: __dirname + "/../server/admin", // 生成的文件放在server/admin中
  publicPath: process.env.NODE_ENV === "production" ? "/admin/" : "/", // 配置查找路径
});
  • web项目也是如上操作
javascript
import { createApp } from "vue";
import App from "./App.vue";
import router from "./router";
import store from "./store";
import axios from "axios";

const http = axios.create({
  baseURL: process.env.VUE_APP_API_URL || '/web/api',
  // baseURL: "http://localhost:3000/web/api",
});

// ↓省略...
shell
VUE_APP_API_URL=http://localhost:3000/web/api
javascript
const { defineConfig } = require("@vue/cli-service");
module.exports = defineConfig({
  transpileDependencies: true,
  lintOnSave: false,
  outputDir: __dirname + "/../server/web",
  // 这个如果是要把web项目放在根路径那下面的可以省略不写
  // publicPath: process.env.NODE_ENV === "production" ? "/web" : "/", 
});
javascript
const express = require('express')

const app = express()

app.set('secret', 'i2u34y12oi3u4y8')

// 解析客户端传递过来的post参数
app.use(express.json())
// 处理跨域
app.use(require('cors')())
app.use('/admin', express.static(__dirname + '/admin'))
app.use('/', express.static(__dirname + '/web'))
app.use('/uploads', express.static(__dirname + '/uploads'))

require('./plugins/db')(app)
require('./routes/admin')(app)
require('./routes/web')(app)

app.listen(3000, () => {
    console.log('http://localhost:3000');
})

在web中执行 npm run build 即可把项目打包到server中

4-2-购买域名和服务器

4-3-域名解析

4-4-Nginx安装和配置

4-5-MongoDB数据库的安装和配置

4-6-git安装、配置ssh-key

4-7-Node.js安装,配置淘宝镜像

4-8-拉取代码、安装pm2并启动项目

4-9-配置 Nginx 的反向代理

4-10-迁移本地数据到服务器(mongodump)

略(上传图片接口地址需要改为正式的地址)

5-1-使用免费SSL证书启用HTTPS安全连接

5-2-使用阿里云OSS云存储存放上传文件