Appearance
仿王者荣耀移动端官网全栈项目
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)
- http://www.spritecow.com/ 这个网站用来辅助获取背景精灵图片定位信息
- 编写布局和样式
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">></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">></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 ></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云存储存放上传文件
略