# HOK-MS
├─ admin //后台管理界面
│ ├─ .env.development
│ ├─ .eslintrc.js
│ ├─ babel.config.js
│ ├─ jsconfig.json
│ ├─ package-lock.json
│ ├─ package.json
│ ├─ public
│ │ ├─ favicon.ico
│ │ └─ index.html
│ ├─ README.md
│ ├─ src
│ │ ├─ App.vue
│ │ ├─ assets
│ │ │ ├─ images
│ │ │ └─ logo.png
│ │ ├─ components
│ │ ├─ http.js
│ │ ├─ main.js
│ │ ├─ plugins
│ │ │ └─ element.js
│ │ ├─ router
│ │ │ └─ index.js
│ │ ├─ style.css
│ │ └─ views
│ │ ├─ AdEdit.vue
│ │ ├─ AdList.vue
│ │ ├─ AdminUserEdit.vue
│ │ ├─ AdminUserList.vue
│ │ ├─ ArticleEdit.vue
│ │ ├─ ArticleList.vue
│ │ ├─ CategoryEdit.vue
│ │ ├─ CategoryList.vue
│ │ ├─ HeroEdit.vue
│ │ ├─ HeroList.vue
│ │ ├─ ItemEdit.vue
│ │ ├─ ItemList.vue
│ │ ├─ Login.vue
│ │ └─ Main.vue
│ └─ vue.config.js
├─ server //接口服务
│ ├─ admin
│ ├─ index.js
│ ├─ key.js
│ ├─ middleware
│ │ ├─ authResource.js
│ │ └─ authUser.js
│ ├─ models
│ │ ├─ Ab.js
│ │ ├─ AdminUser.js
│ │ ├─ Article.js
│ │ ├─ Category.js
│ │ ├─ Hero.js
│ │ └─ Item.js
│ ├─ package-lock.json
│ ├─ package.json
│ ├─ plugins
│ │ └─ db.js
│ ├─ routes
│ │ ├─ admin
│ │ │ └─ index.js
│ │ └─ web
│ │ └─ index.js
│ ├─ uploads
│ └─ web
├─ web //移动web界面
│ ├─ .env.development
│ ├─ .eslintrc.js
│ ├─ babel.config.js
│ ├─ dist
│ ├─ jsconfig.json
│ ├─ package-lock.json
│ ├─ package.json
│ ├─ public
│ │ ├─ favicon.ico
│ │ └─ index.html
│ ├─ README.md
│ ├─ src
│ │ ├─ App.vue
│ │ ├─ assets
│ │ │ ├─ iconfont
│ │ │ ├─ images
│ │ │ ├─ index.png
│ │ │ └─ logo.png
│ │ ├─ components
│ │ │ ├─ Card.vue
│ │ │ └─ ListCard.vue
│ │ ├─ main.js
│ │ ├─ router
│ │ │ └─ index.js
│ │ ├─ style.scss
│ │ └─ views
│ │ ├─ Article.vue
│ │ ├─ Hero.vue
│ │ ├─ Home.vue
│ │ └─ Main.vue
│ └─ vue.config.js
创建axios实例,用于建立本地服务器
import axios from "axios";
const http = axios.create({
// baseURL: 'http://localhost:300/admin/api'
baseURL: process.env.VUE_APP_API_URL || '/admin/api'
})
export default http;
在全局的Vue实例中中挂载http
// 导入http
import http from './http'
Vue.prototype.$http = http
export default
data() {
return {
model: {},
}
},
methods: {
async save() {
// 新建分类
const res await this.$http.post('/rest/categories', this.model)
}
// 跳转到展示页面
this.$router.push('/categories/list')
// 跳出网页弹窗提示
this.$message({
type: 'success',
message: '保存成功'
})
},
}
const express = require('express');
const app = express()
// 配置中间件
app.use(require('cors')()) //解决跨域
app.use(express.json()) //解析json格式
app.listen(3000, () => {
console.log('http://localhost:3000 is running')
});
module.exports = app => {
//导入express,创建后台路由对象
const express = require('express');
const router = express.Router()
//导入数据库表模型
const Category = require('../../models/Category');
// 配置路由规则
// 新建分类
router.post('/categories', async (req, res) => {
//使用create来在数据表中创建一个新的数据文档
const model = await req.Category.create(req.body)
res.send(model)
})
//将路由规则挂载到接口上(使用app.use...而不使用app.post...是为了能够复用“/admin/api”接口前缀地址)
app.use('/admin/api',router)
}
const express = require('express');
const app = express()
// 通过require来引入被抛出的函数,并传递app对象
require('./routes/admin')(app)
module.exports = app => {
// 引入mongoose数据库工具对象
const mongoose = require("mongoose")
//连接mongoDB
mongoose.connect('mongodb://127.0.0.1:27017/HOK-MS', {
useNewUrlParser: true
})
// 引入models所有模型
require('require-all')(__dirname + '/../models')
}
// 引入mongooseg工具
const mongoose = require('mongoose')
//建立数据表,并添加字段
const schema = new mongoose.Schema({
name: {
type: String
}
})
//导出成Category数据库模型
module.exports = mongoose.model('Category', schema)
- 将app.use('/admin/api' router),改为:app.use('/admin/api/rest/:resource', router),接收resource动态模型(建议将通用接口加上一个rest前缀);
- router的接口中去掉原本的静态模型参数;
- npm i inflection,inflection模块用于处理单复数、大小写转化,用它来转换接口上面的“resource”参数,将其转化为数据库模块名,如将categories转化为Category;
- 将转换规则封装成中间件module,通过require('../../middleware/authResource')()来导入并获取module返回的中间件函数,最后加入到app.use()中。
module.exports = app => {
// 创建路由
const router = express.Router({
// 将父路由器url参数的req.params值(即:app.use('/admin/api/rest/:resource', router)中的":resource",合并到router里面路由的参数req.params中。
mergeParams: true
});
// 设置路由模块动态载入中间件
const authResource = require('../../middleware/authResource')()
// 配置接口
// 新建分类
router.post('/', async (req, res) => {
const model = await req.Model.create(req.body)
res.send(model)
})
// 获取分类
router.get('/', async (req, res) => {
const queryOptions = {}
// 判断当前url请求的模型的模型名字是否为Category,是则进行关联查询,否则表示是其他模型,不需要进行关联查询(只有Category需要关联查询)
if (req.Model.modelName === 'Category') {
// 通过设置populate来关联父级关系,通过parent的值作为id来查询
queryOptions.populate = 'parent'
}
const items = await req.Model.find().setOptions(queryOptions).limit(100)
res.send(items)
})
// 挂载rest路由
app.use('/admin/api/rest/:resource', authResource, router)
}
// 路由模块动态载入中间件,options充当默认参数,不用传值
module.exports = options => {
// 返回中间件函数
return async (req, res, next) => {
// 导入数据库模型
// 利用inflection模块,将resource转换为模型名(小写转大写、复数转单数)
const modelName = require('inflection').classify(req.params.resource)
// 将转换得到的模型挂载到req上
req.Model = require(`../models/${modelName}`)
await next()
}
}
- 在el-upload标签中,添加图片上传地址action属性,以及添加上传成功的回调方法属性on-success;
- 在后台服务器设置文件上传路由;
- 引入multer模块,主要应用于图片或文件的上传;
- 设置上传中间件,配置上传目标地址“绝对地址 + /../../upload”;
- 在后台index.js中配置托管静态文件;
- 在前端页面中获取图片url地址,对已有属性进行赋值;
- 列表显示图片:设置在el-table-column表格中自定义显示内容(默认是显示字符串)。
<el-upload class="avatar-uploader" :action="$http.defaluts.baseURL + '/upload'" :show-file-list="false" :on-success="afterUpload" :headers="getAuthHeaders()">
<img v-if="model.icon" :src="model.icon" class="avatar">
<i v-else class="el-icon-plus avatar-uploader-icon"></i>
</el-upload>
export default {
props: ['id'],
data() {
return {
model: {
name: '',
icon: ''
}
}
},
methods: {
afterUpload(res) {
this.$set(this.model,'icon',res.url)
// this.model.icon = res.url
},
}
}
module.exports = app => {
const express = require('express');
const AdminUser = require('../../models/AdminUser');
const assert = require('http-assert')
// 导入token资源
const jwt = require('jsonwebtoken')
const { key } = require('../../key')
// 创建路由
const router = express.Router({
// 将父路由器url参数的req.params值(即:app.use('/admin/api/rest/:resource', router)中的":resource",合并到router里面路由的参数req.params中。
mergeParams: true
});
// 设置登录校验中间件
const authUser = require('../../middleware/authUser')()
// 设置路由模块动态载入中间件
const authResource = require('../../middleware/authResource')()
// 挂载upload路由
// 引入multer模块,主要应用于图片或文件的上传
// 上传到指定目录下
const multer = require('multer')
const upload = multer({ dest: __dirname + '/../../uploads' })
// upload.single('file')表示在upload文件中间件接收单个文件
app.post('/admin/api/upload', authUser, upload.single('file'), async (req, res) => {
const file = req.file
file.url = `http://localhost:3000/uploads/${file.filename}`
res.send(file)
})
}
// 指定“./uploads”的文件可以通过/upload来进行访问
app.use('/uploads', express.static(__dirname + '/uploads'))
<template slot-scope="scope">
<img :src="scope.row.icon" alt="" style="height:3rem">
</template>
利用bcryptjs进行密码加密;
设置server.key.js来抛出密钥;
用户登录时,在路由上面进行数据库查询,校验用户名密码;
使用bcryptjs来检验密码是否正确,正确则返回token(token是使用jwt和key密钥进行加密计算);
在后台页面admin.http.js中设置请求拦截,给所有api请求加上token请求头(作用:前端权限检查可直接通过检查本地token;但后端若要进行权限检查,就要靠检查请求api的请求头中的token)。
封装authUser.js用户校验中间件,用于后端校验api请求头部是否含有正确的token;
将中间件规则挂载到app.use()上面;
使用http.assert包来判别请求条件是否得到满足,并抛出状态码和返回信息:
assert(布尔条件, 状态码, '信息');
在后台页面admin.http.js设置响应拦截,获取assert返回的res信息并进行相应拦截处理。
上面仅是设置接口的访问限制,以下是设置客户端页面路由限制:在admin.router.index.js中设置页面标识,并配置路由全局前置守卫。
针对el-upload进行权限设置,现在后台api路由接口也加上authUser验证中间件,再对前端页面el-upload标签进行headers属性设置(可通过在admin.main.js中设置全局混入,来配置)
// 用户数据表
const mongoose = require('mongoose')
const schema = new mongoose.Schema({
username: { type: String },
password: {
type: String,
// 设置密码不可查
select: false,
set(val) {
// 利用bcryptjs,进行hash散列加密
return require('bcryptjs').hashSync(val, 10)
}
},
})
exports.key = '******'
module.exports = app => {
const express = require('express');
const AdminUser = require('../../models/AdminUser');
const assert = require('http-assert')
const router = express.Router();
// 导入token资源
const jwt = require('jsonwebtoken')
const { key } = require('../../key')
// 设置登录校验中间件
const authUser = require('../../middleware/authUser')()
// 设置路由模块动态载入中间件
const authResource = require('../../middleware/authResource')()
// 挂载login路由
app.post('/admin/api/login', async (req, res) => {
const { username, password } = req.body
// 查找用户并取出密码(防止user默认不取出密码)
const user = await AdminUser.findOne({ username }).select('+password')
assert(user, 422, '用户不存在')
// 利用bcrypt模块,对密码进行检验
let isValid
if (password) {
// 使用bcryptjs来对明文和密文进行配对,返回的是个布尔值
isValid = require('bcryptjs').compareSync(password, user.password)
} else {
assert(isValid, 422, '密码不能为空')
}
assert(isValid, 422, '账号或密码错误')
// 登录成功,返回token(含id加密信息)
const token = jwt.sign({ id: user._id, }, key)
res.send({ token })
})
// 错误处理函数
app.use(async (err, req, res, next) => {
res.status(err.statusCode || 500).send({
message: err.message
})
})
// 挂载rest路由
app.use('/admin/api/rest/:resource', authUser, authResource, router)
}
import axios from "axios";
import Vue from "vue";
import router from "./router";
const http = axios.create({
baseURL: process.env.VUE_APP_API_URL || '/admin/api'
})
// 设置请求拦截器,添加请求头Authorization
http.interceptors.request.use(config => {
config.headers.Authorization = 'Bearer ' + localStorage.token
return config
}, error => {
return Promise.reject(error)
})
// 设置响应拦截器,检验校验码并输出
http.interceptors.response.use(res => {
return res
}, err => {
// "$message"是ElementUI的方法,将其挂载到Vue原型
Vue.prototype.$message({
type: 'error',
message: err.response.data.message// 输出错误信息
})
// 如果状态码为401,则跳转到登录界面
if (err.response.status == 401) {
router.push('/login')
}
return Promise.reject(err)
})
export default http;
methods: {
async login() {
const res = await this.$http.post('/login', this.modle)
// 缓存token
localStorage.token = res.data.token
// 跳转页面
this.$router.push('/')
this.$message({
type: 'success',
message: '登录成功'
})
}
}
import Vue from 'vue'
import VueRouter from 'vue-router'
import Main from '../views/Main.vue'
import Login from '../views/Login.vue'
import CategoryEdit from '../views/CategoryEdit.vue'
import CategoryList from '../views/CategoryList.vue'
import ItemList from '../views/ItemList.vue'
import ItemEdit from '../views/ItemEdit.vue'
import HeroList from '../views/HeroList.vue'
import HeroEdit from '../views/HeroEdit.vue'
import ArticleList from '../views/ArticleList.vue'
import ArticleEdit from '../views/ArticleEdit.vue'
import AdList from '../views/AdList.vue'
import AdEdit from '../views/AdEdit.vue'
import AdminUserList from '../views/AdminUserList.vue'
import AdminUserEdit from '../views/AdminUserEdit.vue'
Vue.use(VueRouter)
const routes = [
{
path: '/login',
name: 'login',
component: Login,
meta: { notAuth: true } //设置免检标识
},
{
path: '/',
name: 'main',
component: Main,
children: [
{ path: '/categories/create', component: CategoryEdit, },
{ path: '/categories/list', component: CategoryList, },
{
path: '/categories/edit/:id', component: CategoryEdit,props:true
}
]
}
]
const router = new VueRouter({
routes
})
// 设置全局前置守卫
router.beforeEach((to, from, next) => {
// “to”表示目标页面路由,如果目标路由没有免检标识而且缓存中没token,则跳转到登录界面
if (!to.meta.notAuth && !localStorage.token) {
// 重定向到登录页面
return next('/login')
}
next()
})
export default router
// 设置混入(全局可用的代码块)
Vue.mixin({
// 1.computed是响应式的,methods并非响应式。
// 2.调用方式不一样,computed定义的成员像属性一样访问,methods定义的成员必须以函数形式调用。
// 3.computed是带缓存的,只有其引用的响应式属性发生改变时才会重新计算,而methods里的函数在每次调用时都要执行(调用时实时更新)。
computed: {
getuploadUrl() {
return this.$http.defaults.baseURL + '/upload'
}
},
methods: {
getAuthHeaders() {
return {
Authorization: `Bearer ${localStorage.token}`
}
}
}
})
<el-upload class="avatar-uploader" :action="getuploadUrl" :show-file-list="false" :on-success="afterUpload" :headers="getAuthHeaders()">
<img v-if="model.icon" :src="model.icon" class="avatar">
<i v-else class="el-icon-plus avatar-uploader-icon"></i>
</el-upload>
(新建页面vue文件)
import Vue from 'vue'
import VueRouter from 'vue-router'
import Main from '../views/Main.vue'
import CategoryEdit from '../views/CategoryEdit.vue'
// 挂载页面路由
Vue.use(VueRouter)
const routes = [
{
path: '/',
name: 'main',
component: Main,
children: [
{ path: '/categories/create', component: CategoryEdit, },
{ path: '/categories/list', component: CategoryList, },
{
path: '/categories/edit/:id', component: CategoryEdit, props: true
}
]
}
]
// 创建页面路由实例
const router = new VueRouter({
routes
})
// 导出页面路由
export default router
npm i -D sass sass-loader
* {
box-sizing: border-box; // 设置怪异盒子模型
outline: none; // 消除Tab切换高亮
}
html {
font-size: 13px; // 统一全局文字基础大小
}
body {
margin: 0; // 取消外边距
font-family: Arial,
Helvetica,
sans-serif;
line-height: 1.2em;
background: #f1f1f1
}
a {
color: #999
}
p {
line-height: 1.5rem
}
// 颜色
$colors: (
'primary': #db9e3f,
'info':#4b67af,
"white":#fff,
"white-1":#fcfcfc,
"white-2":#eceef0,
"light" : #f9f9f9,
"light-1" : #d4d9de,
"grey" :#999,
"grey-1" :#666,
"dark-1":#343440,
"dark" :#222,
"black" :#000,
'blue':#4394e4,
'danger':rgb(221, 0, 27)
);
@each $colorKey,$color in $colors {
.text-#{$colorKey} {
color: $color
}
.bg-#{$colorKey} {
background-color: $color
}
}
// nav
.nav {
display: flex;
.nav-item {
border-bottom: 3.0004px solid transparent;
padding-bottom: .2rem;
color: map-get($map: $colors, $key: 'primary');
&.active {
border-bottom: 3.0004px solid map-get($map: $colors, $key: 'primary');
}
}
&.nav-inverse {
.nav-item {
color: map-get($map: $colors, $key: 'white');
&.active {
border-bottom: 3.0004px solid map-get($map: $colors, $key: 'white');
}
}
}
}
- 创建Cart组件和ListCart组件;
- 在main.js中注册两个组件,并设置组件名;
- 在Cart组件设置插槽,来接收来自ListCart组件的内容;
- 在ListCart组件设置插槽,来接收来自主页面的内容;
- 页面引入ListCart组件,并传递参数以及插槽内容;
- 在ListCart组件中引入Cart组件,并传递插槽内容;
- 最终实现主页面插入ListCart,ListCart插入Cart,获得一个完整的卡片组件。
src\components\Cart.vue
<template>
<div class="card bg-white p-3 mt-3">
<div class="card-header d-flex" :class="{ 'border-bottom': !plain, 'pb-3': !plain }">
<i class="iconfont" :class="`icon-${icon}`"></i>
<div class="fs-xl flex-1 px-2" :class="{ 'fs-900': plain }">{{ title }}</div>
<i class="iconfont icon-menu" v-if="!plain"></i>
</div>
<div class="card-body pt-3">
<slot></slot>
</div>
</div>
</template>
<script>
export default {
// 接收父组件传来的参数
props: {
title: { type: String, required: true },
icon: { type: String, required: true },
plain: { type: Boolean },
}
}
</script>
<style lang="scss">
@import '../style.scss';
.card {
border-bottom: 1px solid $border-color;
}
</style>
src\components\ListCart.vue
<template>
<my-card :icon="icon" :title="title">
<div class="nav jc-between">
<div class="nav-item" :class="{ active: active === index }" v-for="(category, index) in categories" :key="index"
@click="$refs.list.swiper.slideTo(index)">
<div class="nav-link">{{ category.name }}</div>
</div>
</div>
<div class="pt-3">
<swiper ref="list" @slide-change="() => active = $refs.list.swiper.realIndex" :options="{ autoHeight: true }">
<swiper-slide v-for="(category, i) in categories" :key="i">
<slot name="content" :items="category"></slot>
</swiper-slide>
</swiper>
</div>
</my-card>
</template>
<script>
export default {
props: {
title: { type: String, required: true },
icon: { type: String, required: true },
categories: { type: Array, required: true },
},
data() {
return {
active: 0
}
},
methods: {
Alert(res) {
console.log(res);
}
}
}
</script>
<style></style>
src\main.js
// 导入组件
import Card from './components/Card.vue'
Vue.component('my-card', Card)
import ListCard from './components/ListCard.vue'
Vue.component('my-listcard', ListCard)
src\views\Home.vue
<!-- 新闻资讯 -->
<my-listcard icon="cc-menu-circle" title="新闻资讯" :categories="news">
<template #content="obj">
<div class="py-2 fs-lg " v-for="(item, i) in obj.items.list" :key="i">
<router-link tag="div" :to="`/articles/${item._id}`" class="d-flex">
<span class="text-primary mr-2 label fs-xxs px-1">{{ obj.items.name }}</span>
<span class="flex-1 text-dark-1 pr-1 text-ellipse" style="line-height:1.2308rem">{{ item.title }}</span>
<span class="text-grey-1 fs-sm">{{ item.createdAt | date }}</span>
</router-link>
</div>
</template>
</my-listcard>
<!-- 英雄列表 -->
<my-listcard icon="cc-menu-circle" title="英雄列表" :categories="heroes">
<template #content="obj">
<div class="d-flex flex-wrap" style="margin:0 -0.4rem">
<div class="px-2 fs-lg text-center py-2" style="width:20%" v-for="(item, i) in obj.items.list" :key="i">
<router-link tag="div" :to="`/heroes/${item._id}`">
<img :src="item.avatar" class="w-100">
<div>{{ item.name }}</div>
</router-link>
</div>
</div>
</template>
</my-listcard>
<input type="file" id="fileInput">
<script>
const fileInput = document.getElementById('fileInput')
fileInput.addEventListener('change', () => {
const file = fileInput.files[0]
const reader = new FileReader()
reader.readAsDataURL(file)
reader.onload = () => {
const base64 = reader.result
console.log(base64)
}
})
</script>
<!-- 将图片的真实地址存储在data-src属性中 -->
<img data-src="../assets/images/img.jpg" alt="image">
<script>
// 获取所有需要懒加载的图片元素
const images = document.querySelectorAll('img[data-src]');
// 创建IntersectionObserver实例
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
// 如果图片进入视口,则将data-src赋给src
if (entry.isIntersecting) {
const image = entry.target;
image.src = image.dataset.src;
observer.unobserve(image); // 停止监听该图片
}
});
});
// 开始监听所有需要懒加载的图片元素
images.forEach(image => observer.observe(image));
</script>