1.服务端

1.1 安装express

node.js的框架语言

1.2.安装插件

1
npm i multer
1
npm i uuid
1
npm i sqlite3@5.0.0

1.3 雪花算法

1.4 数据库链接

1.5 Promise封装部分方法

1.6 管理员登陆和token的生成

1.7 分类表增删改

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
//列表接口
router.get('/list', async (req, res) => {//定义一个POST请求的路由处理函数,用于处理添加的请求。
const search_sql = "select * from `category`";//定义查询的SQL语句。
let {err,rows} = await db.async.all(search_sql,[]);//执行查询的SQL语句。

if(err == null) {
res.send({//如果成功,返回相应的状态码和提示信息。
code:200,//状态码,表示成功。
msg:"查询成功",//查询成功的提示信息。
rows//查询结果
})
}else {//如果失败,返回相应的状态码和提示信息。
res.send({//如果查询失败,返回相应的状态码和提示信息。
code:500,//状态码,表示失败。
msg:"查询失败"
})
}
})


//添加接口
router.post('/add', async (req, res) => {//定义一个POST请求的路由处理函数,用于处理添加的请求。
let {name} = req.body;//从请求的body中获取name参数。
const insert_sql = "insert into `category`(`id`,`name`) VALUES (?,?)";//定义插入的SQL语句。
let {err,rows} = await db.async.run(insert_sql,[genid.NextId(),name]);//执行插入的SQL语句,将id和name参数插入到数据库中。

if(err == null) {
res.send({//如果插入成功,返回相应的状态码和提示信息。
code:200,//状态码,表示成功。
msg:"添加成功",//添加成功的提示信息。
})
}else {//如果插入失败,返回相应的状态码和提示信息。
res.send({//如果插入失败,返回相应的状态码和提示信息。
code:500,//状态码,表示失败。
msg:"添加失败"
})
}
})



//修改接口
router.put('/update', async (req, res) => {//定义一个POST请求的路由处理函数,用于处理添加的请求。
let {id,name} = req.body;//从请求的body中获取name参数。
const update_sql = "update `category` set `name` = ? where `id` = ?";//定义更新的SQL语句。
let {err,rows} = await db.async.run(update_sql,[name,id]);//执行更新的SQL语句。

if(err == null) {
res.send({//如果成功,返回相应的状态码和提示信息。
code:200,//状态码,表示成功。
msg:"修改成功",//修改成功的提示信息。
})
}else {//如果失败,返回相应的状态码和提示信息。
res.send({//如果修改失败,返回相应的状态码和提示信息。
code:500,//状态码,表示失败。
msg:"修改失败"
})
}
})


//删除接口
router.delete('/delete', async (req, res) => {//定义一个POST请求的路由处理函数,用于处理添加的请求。
let id = req.query.id;//
const delete_sql = "delete from `category` where `id` = ?";//定义删除的SQL语句。
let {err,rows} = await db.async.run(delete_sql,[id]);//执行删除的SQL语句。

if(err == null) {
res.send({//如果成功,返回相应的状态码和提示信息。
code:200,//状态码,表示成功。
msg:"删除成功",//删除成功的提示信息。
})
}else {//如果失败,返回相应的状态码和提示信息。
res.send({//如果删除失败,返回相应的状态码和提示信息。
code:500,//状态码,表示失败。
msg:"删除失败"
})
}
})

1.8 博客表增删改查

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
//查询博文
router.get('/search', async(req, res) => { //定义一个GET请求的路由处理函数,用于查询博文。

// keyword 关键字
// categoryId 分类编号
// 分页:
// page :页码
// pagesize : 分页大小
let {keyword,categoryId,page,pageSize} = req.query

page = page == null ? 1:page;
pageSize = pageSize == null ? 10:pageSize; // 默认每页10条数据。
categoryId = categoryId == null ? 0:categoryId; // 默认查询所有分类。
keyword = keyword == null ? "":keyword; // 默认查询所有关键字。

let params = []
let whereSqls = []
if (categoryId != 0){
whereSqls.push(" `category_id` = ? ")
params.push(categoryId) // 添加分类编号到参数数组。
}
if(keyword != ""){
whereSqls.push(" (`title` LIKE ? OR `content` LIKE ?) ")
params.push("%" + keyword + "%") // 添加关键字到参数数组。
params.push("%" + keyword + "%") // 添加关键字到参数数组。
}
let whereSqlStr = ""
if(whereSqls.length > 0){
whereSqlStr = " WHERE " + whereSqls.join(" AND ") // 生成WHERE子句。
}

//查分页数据
let searchSql = " SELECT * FROM `blog` " + whereSqlStr + " ORDER BY `create_time` DESC LIMIT ?,? " // 生成查询SQL语句。
let searchSqlParams = params.concat([(page-1)*pageSize,pageSize]) // 添加分页参数到参数数组。


//查询数据总数
let searchCountSql = " SELECT COUNT(*) AS count FROM `blog` " + whereSqlStr // 生成查询总数SQL语句。
let searchCountParams = params // 添加查询总数参数到参数数组。

//分页数据
let searchResult = await db.async.all(searchSql,searchSqlParams) // 执行查询SQL语句。
let countResult = await db.async.all(searchCountSql,searchCountParams) // 执行查询总数SQL语句。

console.log(searchSql,countResult);

if(searchResult.err == null && countResult.err == null){
res.send({
code: 200, // 状态码。
msg:"查询成功",
data:{
keyword,
categoryId,
page,
pageSize,
rows:searchResult.rows, // 查询结果。
count:countResult.rows[0].count, // 总数。
}
})
}
else{
res.send({
code:500,
msg:"查询失败"
}) // 查询失败。
}
})

//添加博文
router.post('/add', async(req, res) => {
let {title,categoryId,content} = req.body; //从请求体中获取title、category、content字段值。
let id = genid.NextId();
let create_time = new Date().getTime(); //获取当前时间戳。

const insert_sql = "INSERT INTO `blog`(`id`,`title`,`category_id`,`content`,`create_time`) VALUES (?,?,?,?,?)"
let params = [id,title,categoryId,content,create_time]; //将id、title、category、content、createTime作为参数。

let {err,rows} = await db.async.run(insert_sql,params); //执行插入操作。

if(err == null) {
res.send({//如果成功,返回相应的状态码和提示信息。
code:200,//状态码,表示成功。
msg:"添加成功",
})
}else {//如果失败,返回相应的状态码和提示信息。
res.send({
code:500,//状态码,表示失败。
msg:"添加失败",
err
})
}
})


//修改博文
router.put('/update', async(req, res) => {
let {id,title,categoryId,content} = req.body; //从请求体中获取title、category、content字段值。
let create_time = new Date().getTime(); //获取当前时间戳。

const update_sql = "UPDATE `blog` SET `title` = ?,`content` = ?,`category_id` = ? WHERE `id` = ?";
let params = [title,content,categoryId,id]; //将id、title、category、content、createTime作为参数。

let {err,rows} = await db.async.run(update_sql,params); //执行插入操作。

if(err == null) {
res.send({//如果成功,返回相应的状态码和提示信息。
code:200,//状态码,表示成功。
msg:"修改成功",
})
}else {//如果失败,返回相应的状态码和提示信息。
res.send({
code:500,//状态码,表示失败。
msg:"修改失败",
err,
})
}
})


//删除博文
router.delete('/delete', async (req, res) => {//定义一个POST请求的路由处理函数,用于处理添加的请求。
let id = req.query.id;//
const delete_sql = "DELETE FROM `blog` WHERE `id` = ?";//定义删除的SQL语句。
let {err,rows} = await db.async.run(delete_sql,[id]);//执行删除的SQL语句。

if(err == null) {
res.send({//如果成功,返回相应的状态码和提示信息。
code:200,//状态码,表示成功。
msg:"删除成功",//删除成功的提示信息。
})
}else {//如果失败,返回相应的状态码和提示信息。
res.send({//如果删除失败,返回相应的状态码和提示信息。
code:500,//状态码,表示失败。
msg:"删除失败"
})
}
})

1.9 上传接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
const express = require('express');
const router = express.Router();
const fs = require('fs'); // 引入fs模块用于文件操作。
const { db, genid } = require('../db/blog/DbUtils'); // 引入db和genid对象。

router.post("/rich_editor_upload",async(req, res)=>{
if(!req.files){
res.send({
"error":1, //只要不等于0就行
"message":"失败"
})
return;
}

let files = req.files; // 获取上传的文件。
let ret_files = []; // 用于存储上传成功的文件信息。

for(let file of files){
let file_ext = file.originalname.substring(file.originalname.lastIndexOf(".") + 1); // 获取文件扩展名(文件名字后缀)。

let file_name = genid.NextId() + "." + file_ext; // 生成文件名。
//由于该方法属于fs模块,使用前需要引入fs模块(var fs= require(“fs”) )
//修改名字+移动文件
fs.renameSync(
process.cwd()+"/public/upload/temp/"+ file.filename,
process.cwd()+"/public/upload/" + file_name
); // 重命名文件。
ret_files.push("/uplaod/" + file_name)
}


res.send({
"errno":0, //值是数字,不能是字符串
"data":{
"url":ret_files[0], // 返回上传成功的文件路径。
}
})
})


//通过module.exports将路由器实例导出,以便在其他文件中可以引入和使用该路由器。
module.exports = router;

1.10 加入token验证接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if(req.path.indexOf(ADMIN_TOKEN_PATH)> -1){
let {token} = req.headers;
let admin_token_sql = "SELECT * FROM `admin` WHERE `token` = ?"
let adminResult = await db.async.all(admin_token_sql,[token])
if(adminResult.err != null || adminResult.rows.length == 0 ){
res.send({
code:403,
msg:"请先登录"
})
return
}else {
next()
}
}

1.11 中间件验证登录

通过在app.js加入中间件验证登录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const ADMIN_TOKEN_PATH = "/_token"
app.all("*",async(req,res,next) =>{
if(req.path.indexOf(ADMIN_TOKEN_PATH)> -1){
let {token} = req.headers;
let admin_token_sql = "SELECT * FROM `admin` WHERE `token` = ?"
let adminResult = await db.async.all(admin_token_sql,[token])
if(adminResult.err != null || adminResult.rows.length == 0 ){
res.send({
code:403,
msg:"请先登录"
})
return
}else {
next()
}
}else{
next()
}
})

将需要登陆验证才能操作的接口的路径改为

1
router.put('/update', async (req, res) => {//定义一个POST请求的路由处理函数,用于处理添加的请求。

修改为

1
router.put('/_token/update', async (req, res) => {//定义一个POST请求的路由处理函数,用于处理添加的请求。

就可实现消去重复在每个接口验证token的繁琐操作,而只通过中间件实现

2.前端

2.1 模块安装

用到的模块有:

  • axios
  • pinia
  • sass
  • vue-router
  • naive-ui
  • wangeditor

2.2 登录功能

Login.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
<template>
<div class="login-panel">
<n-card title="管理后台登录">
<n-form :rules="rules" :model ="admin">
<n-form-item path="account" label="账号">
<n-input v-model:value="admin.account" placeholder="请输入账号"/>
</n-form-item>
<n-form-item path="password" label="密码">
<n-input v-model:value="admin.password" type="password" placeholder="请输入密码"/>
</n-form-item>
</n-form>

<template #footer>
<n-checkbox v-model:checked="admin.rember" label="记住我" />
<n-button @click="login">登录</n-button>
</template>
</n-card>
</div>
</template>

<script setup>
import {ref, reactive, inject} from 'vue';
import { AdminStore } from '../stores/AdminStore';
import {useRouter,useRoute} from 'vue-router' // 引入路由模块,用于页面跳转。 import { NMessageProvider, NNotificationProvider } from 'naive-ui'; // 引入naive-ui库,用于消息提示。 import { NCard, NForm, NFormItem, NInput, NButton, NCheckbox } from 'naive-ui'; // 引入naive-ui库,用于表单。 import { NMessage, NNotification } from 'naive-ui'; // 引入naive-ui库,用于消息提示。 import { NDialog } from 'naive-ui'; // 引入naive-ui库,用于弹出对话框。


const router = useRouter(); // 创建路由实例。 const route = useRoute(); // 创建路由实例。 const admin = reactive({ // 创建一个响应式对象,用于存储管理员信息。 account: '', // 账号。 password: '', // 密码。 rember: false // 是否记住密码。 });
const route = useRoute(); // 创建路由实例。 const admin = reactive({ // 创建一个响应式对象,用于存储管理员信息。 account: '', // 账号。 password: '', // 密码。 rember: false // 是否记住密码。 });
const axios = inject('axios');


const message = inject('message'); // 引入message组件,用于提示信息展示。
const notification = inject('notification'); // 引入notification组件,用于通知信息展示。
const dialog = inject('dialog'); // 引入dialog组件,用于弹出对话框。
const adminStore = AdminStore();

let rules ={
account:[
{require:true, message:'请输入账号', trigger:'blur'},
{min:3, max:12, message:'账号长度为3到12个字符', trigger:'blur'},
],
password:[
{require:true, message:'请输入密码', trigger:'blur'},
{min:6, max:18, message:'密码长度为6到18个字符', trigger:'blur'}
],
};

const admin = reactive({
account:localStorage.getItem('account')||"",
password:localStorage.getItem('password')||"",
rember:localStorage.getItem('rember') == 1 || false,
});

const login = async () => {
//promise请求
let result = await axios.post('/admin/login', {
account:admin.account,
password:admin.password
});
console.log(result);
if(result.data.code == 200){
//存入仓库
adminStore.token = result.data.data.token;
adminStore.account = result.data.data.account;
adminStore.id = result.data.data.id;
//成功,存入本地
if(admin.rember){
localStorage.setItem('account', admin.account);
localStorage.setItem('password', admin.password);
localStorage.setItem('rember', admin.rember);
}else{
localStorage.setItem('account','');
localStorage.setItem('password', '');
localStorage.setItem('rember',admin.rember);
}
router.push('/dashboard');
message.info("登录成功")
}else{
message.error("登录失败")
}
}
</script>

<style lang="scss" scoped>
.login-panel {
width: 500px;
margin: 0 auto;
margin-top: 130px;
}
</style>

App.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

<template>
<n-message-provider>
<router-view></router-view>
</n-message-provider>

</template>

<script setup>

</script>

<style scoped>

</style>

main.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import naive from 'naive-ui'
import { createDiscreteApi } from 'naive-ui'
import {router} from './common/router'
import { createPinia } from 'pinia'
import axios from 'axios'

axios.defaults.baseURL = 'http://localhost:8080';
//引入独立api
const {message,notification,dialog} = createDiscreteApi(['message','dialog','notification'])



const app = createApp(App)
app.provide('axios', axios)
app.provide('message', message)
app.provide('notification', notification)
app.provide('dialog', dialog)


app.use(naive)
app.use(router)
app.use(createPinia());
app.use(axios)
app.mount('#app')

AdminStore.js

1
2
3
4
5
6
7
8
9
10
11
12
13
import {defineStore} from 'pinia'

export const AdminStore = defineStore('admin', {
state:() =>{
return{
id:0,
account: '', // 用户名或账号
token: '', // 用户登录凭证,如token、session等
}
},
actions:{},
getters:{},
})

登录功能总结:

在记住密码编程方面出了问题,我的设计是将勾选记住密码和没勾选记住密码分别记为布尔值传入本地存储,当登录成功后,就存入当前选择的布尔值,登录失败就不做变化,但和教程有所不符合,后续有Bug产生的可能性

2.3 后台框架

dashboard.vue

布局和路由跳转

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
<template>
<div class="main-panel">
<div class="menus">
<div v-for="(menu,index) in menus" @click="toPage(menu)">
{{ menu.name }}
</div>
</div>
<div style="padding:20px;width: 100%;">
<router-view></router-view>
</div>
</div>
<div class="title">后台管理系统</div>
</template>

<script setup>
import {AdminStore} from '../../stores/AdminStore'
import {ref, reactive, inject} from 'vue';
import {useRouter,useRoute} from 'vue-router' // 引入路由模块,用于页面跳转。 import { NMessageProvider, NNotificationProvider } from 'naive-ui'; // 引入naive-ui库,用于消息提示。 import { NCard, NForm, NFormItem, NInput, NButton, NCheckbox } from 'naive-ui'; // 引入naive-ui库,用于表单。 import { NMessage, NNotification } from 'naive-ui'; // 引入naive-ui库,用于消息提示。 import { NDialog } from 'naive-ui'; // 引入naive-ui库,用于弹出对话框。


const router = useRouter(); // 创建路由实例。 const route = useRoute(); // 创建路由实例。 const admin = reactive({ // 创建一个响应式对象,用于存储管理员信息。 account: '', // 账号。 password: '', // 密码。 rember: false // 是否记住密码。 });
const route = useRoute(); // 创建路由实例。 const admin = reactive({ // 创建一个响应式对象,用于存储管理员信息。 account: '', // 账号。 password: '', // 密码。 rember: false // 是否记住密码。 });
const axios = inject('axios');
const adminStore = AdminStore();



let menus = [
{name:"文章管理",href:"/dashboard/article"},
{name:"分类管理",href:"/dashboard/category"},
{name:"退出",href:"logout"},
]


const toPage = (menu) => {
if(menu.href == "logout"){
router.push("/login")
}else {
router.push(menu.href) // 跳转到指定的路由。
}
}
</script>

<style lang="scss" scoped>
.main-panel{
display: flex;
color: #64676a;
max-width: 1500px;
}
.menus{
padding: 20px 0;
box-sizing: border-box;
line-height: 55px;
text-align: center;
width: 180px;
height: 95vh;
border-right: 1px solid #dadada;

div{
cursor: pointer;
border-bottom: 1px solid #dadada;
&:hover{
color: #fd760e;
}
}
}
</style>

router.js

路由设置:

1
2
3
4
5
6
{path:"/dashboard",component:()=>import("../views/dashboard/Dashboard.vue"),
children:[
{path:"/dashboard/category",component:()=>import("../views/dashboard/Category.vue")},
{path:"/dashboard/article",component:()=>import("../views/dashboard/Article.vue")},
]
},

在这里遇到的一个问题是我在写toPage函数的时候,出现这样的报错,在确定了路由的path和数据里的href没有问题后,我开始排查函数传参问题,

原来是toPage调用的时候没有将(menu)这个参数传递过来,导致router.push不到menu.href

修改后便成功

2.4 分类管理

注意ref和reactive的区别

1
2
3
4
5
6
7
8
9
10
11
12
const categoryList = ref([]); // 创建一个响应式数组,用于存储分类列表。

onMounted(() => {
loadDatas();
});

const loadDatas = async() =>{
let res = await axios.get('/category/list');
console.log(res); // 获取分类数据。
categoryList.value = res.data.rows
// 打印分类数据。
}

input、输入框v-model:value绑定了值但输入的内容不显示:

原因可能是没有用ref或reactive把绑定的值变为响应式数据

1
2
3
<div>
<n-input v-model:value="addCategory.name" type="text" placeholder="请输入名称"/>
</div>
1
2
3
4
const addCategory = reactive({
name:''
})

操作时需要token的方法

1.在每个请求后面加上 {headers:{token:adminStore.token}

1
let res = await axios.post("/category/_token/add",{name:addCategory.name},{headers:{token:adminStore.token} }); 

2.在main.js中使用拦截器

1
2
3
4
5
axios.interceptors.request.use((config)=>{
config.headers.token = adminStore.token;
return config
})

delete是关键字,不能当变量(这里用 deleteCategory)

1
2
const deleteCategory = async() =>{
}

删除等操作要添加对话确认框

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 删除分类
const deleteCategory = async (category) => {
//对删除添加警告对话框 这里用到了dialog
dialog.warning({
title: "警告",
content: "确定要删除吗?",
positiveText: "确定",
negativeText: "不确定",
onPositiveClick: async () => {
let res = await axios.delete(
`/category/_token/delete?id=${category.id}`
);
if (res.data.code == 200) {
loadDatas();
message.info(res.data.msg);
} else {
message.error(res.data.msg);
}
},
onNegativeClick: () => {
},
});
};

2.5 富文本编辑器

封装了一个可复用的富文本组件,但需要注意的是server的地址的变化,通过provide/inject注入服务端地址发给各组件

1
2
axios.defaults.baseURL = 'http://localhost:8080';
app.provide('server_url',axios.defaults.baseURL)

在article.vue中引用组件,实现内容和富文本的双向数据绑定

1
2
3
4
5
6
7
<n-form-item label="内容">
<rich-text-editor v-model="addArticle.content"></rich-text-editor>
</n-form-item>

<script setup>
import RichTextEditor from "../../components/RichTextEditor.vue";
</script>

封装的RichTextEditor.vue组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
<template>
<div>
<Toolbar :editor="editorRef" :defaultConfig="toolbarConfig" :mode="mode"
style="border-bottom: 1px solid #ccc" />
<Editor :defaultConfig="editorConfig" :mode="mode" v-model="valueHtml" style="height: 400px; overflow-y: hidden"
@onCreated="handleCreated" @onChange="handleChange" />
</div>
</template>

<script setup>
import '@wangeditor/editor/dist/css/style.css';
import { ref, reactive, inject, onMounted, onBeforeUnmount, shallowRef } from 'vue'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue';

const server_url = inject("server_url")
// 编辑器实例,必须用 shallowRef,重要!
const editorRef = shallowRef();
const toolbarConfig = { excludeKeys:["uploadVideo"] };
const editorConfig = { placeholder: '请输入内容...' };
editorConfig.MENU_CONF = {}
editorConfig.MENU_CONF['uploadImage'] = {
base64LimitSize: 10 * 1024, // 10kb
server: server_url+'/upload/rich_editor_upload',
}
editorConfig.MENU_CONF['insertImage'] ={
parseImageSrc:(src) =>{
if(src.indexOf("http") !==0){
return `${server_url}${src}`
}
return src
}
}

const mode = ref("default")
const valueHtml = ref("")

const props = defineProps({
modelValue: {
type: String,
default: ""
}
})

const emit = defineEmits(["update:model-value"])
let initFinished = false

onMounted(() => {
setTimeout(() => {
valueHtml.value = props.modelValue;
initFinished = true;
}, 200);
});

// 组件销毁时,也及时销毁编辑器,重要!
onBeforeUnmount(() => {
const editor = editorRef.value;
if (editor == null) return;
editor.destroy();
});

// 编辑器回调函数
const handleCreated = (editor) => {
console.log('created', editor);
editorRef.value = editor; // 记录 editor 实例,重要!
};
const handleChange = (editor) => {
if (initFinished) {
emit("update:model-value", valueHtml.value)
}
};

</script>

<style lang="scss" scoped>
</style>

具体内容可以查询 快速开始 | wangEditor

2.6 文章添加功能

Article.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
<template>
<n-tabs default-value="add" justify-content="start" type="line">
<n-tab-pane name="oasis" tab="Oasis"> Wonderwall </n-tab-pane>
<n-tab-pane name="add" tab="添加文章">
<n-form>
<n-form-item label="标题">
<n-input v-model:value="addArticle.title" placeholder="请输入标题"/>
</n-form-item>
<n-form-item label="分类">
<n-select v-model:value="addArticle.categoryId":options="categoryOptions"/>
</n-form-item>
<n-form-item label="内容">
<rich-text-editor v-model="addArticle.content"></rich-text-editor>
</n-form-item>
<n-form-item label="">
<n-button @click="add">提交</n-button>
</n-form-item>
</n-form>
</n-tab-pane>
<n-tab-pane name="jay chou" tab="周杰伦"> 七里香 </n-tab-pane>
</n-tabs>
</template>

<script setup>
import { AdminStore } from "../../stores/AdminStore";
import { ref, reactive, inject, onMounted } from "vue";
import { useRouter, useRoute } from "vue-router";
import RichTextEditor from "../../components/RichTextEditor.vue"; "../../components/RichTextEditor.vue"
const router = useRouter();
const axios = inject("axios");
const message = inject("message");
const dialog = inject("dialog");
const adminStore = AdminStore();

const addArticle = reactive({
categoryId:0,
title:'',
content:'',
})

const categoryOptions = ref([])


onMounted(() => {
loadCategory(); // 加载分类数据。
})

const loadCategory = async () => {
let res = await axios.get("/category/list")
categoryOptions.value = res.data.rows.map(item => ({ label: item.name, value: item.id }))
}

const add = async () => {
let res = await axios.post(
"/blog/_token/add",
addArticle
);
if (res.data.code == 200) {
message.info(res.data.msg);
addArticle.title = '';
addArticle.content = "";
addArticle.categoryId = 0; // 清空表单数据。
}
else {
message.error(res.data.msg);
}
};



</script>

<style lang="scss" scoped></style>

2.7 文章列表功能

要让文章列表里的文章的内容只显示出一部分内容,而不是有多少就显示多少,我们可以修改查询文章的接口

1
2
//查分页数据
let searchSql = " SELECT `id`,`category_id`,`create_time`,`title`,substr(`content`,0,50) AS `content` FROM `blog` " + whereSqlStr + " ORDER BY `create_time` DESC LIMIT ?,? " // 生成查询SQL语句。

裁剪文章内容0-50字符并起别名content,这样服务端传来的content就是经裁剪过的content

将内容添加…表示剩余内容的操作和转换时间戳的操作(注意:转换时间戳要在前端进行,不要在服务端进行)

1
2
3
4
5
6
7
8
9
const loadBlogs = async () => {
let res = await axios.get("/blog/search");
let temp_rows = res.data.data.rows;
for(let row of temp_rows){
row.content += "..."
let d = new Date(row.create_time); // 创建一个Date对象。
row.create_time = `${d.getFullYear()}${d.getMonth()+1}${d.getDate()}日`; // 格式化日期。
}
blogs.value = temp_rows; // 将博客列表赋值给响应式对象。

这里赋值为什么不用map,因为不需要映射map到其它属性

分页功能设计

分页器的设计:根据服务端返回的数据确定总数据数和总页数,

先设计一个响应式页数数据

1
2
3
4
5
6
const pageInfo = reactive({
page:1,
pageSize:3,
pageCount:0,
count:0
})

通过向上取整求出总页数

1
2
3
pageInfo.count = res.data.data.count; // 总记录数。
//计算出总页数
pageInfo.pageCount = Math.ceil(pageInfo.count / pageInfo.pageSize); // 总页数。

装入模板结构并添加样式和绑定事件toPage(pageNum)

1
2
3
4
5
6
7
<n-space>
<div @click="toPage(pageNum)" v-for="pageNum in pageInfo.pageCount">
<div :style="`color:`+(pageNum == pageInfo.page?'blue':'')">
{{ pageNum }}
</div>
</div>
</n-space>
1
2
3
4
const toPage = (pageNum) => {
pageInfo.page = pageNum; // 设置当前页码。
loadBlogs()
}

2.8 文章修改功能

在模板中渲染的数据一定要响应式,一定要记住,上好几次当了。

1
const tabValue = ref('list')
1
<n-tabs v-model:value="tabValue" justify-content="start" type="line">

修改时一定要加.value,否则报错

1
2
3
4
5

const toUpdate = async (blog) =>{
tabValue.value= 'update'
}

注意db.async.all和db.async.get的区别

2.9 首页制作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
<template>
<div class="container">
<div class="nav">
<div @click="homePage">首页</div>
<div>
<n-popselect @update:value="searchByCategory" v-model:value="selectedCategory" :options="categortyOptions" trigger="click">
<div>分类<span>{{ categoryName }}</span></div>
</n-popselect>
</div>
<div @click="dashboard">后台</div>
</div>
<n-divider />
<n-space class="search">
<n-input v-model:value="pageInfo.keyword" :style="{ width: '500px' }" placeholder="请输入关键字" />
<n-button type="primary" ghost @click="loadBlogs(0)"> 搜索 </n-button>
</n-space>

<div v-for="(blog, index) in blogListInfo" style="margin-bottom:15px;cursor: pointer;">
<n-card :title="blog.title" @click="toDetail(blog)">
{{ blog.content }}

<template #footer>
<n-space align="center">
<div>发布时间:{{ blog.create_time }}</div>
</n-space>
</template>
</n-card>
</div>

<n-pagination @update:page="loadBlogs" v-model:page="pageInfo.page" :page-count="pageInfo.pageCount" />

<n-divider />
<div class="footer">
<div>Power by 黑板擦</div>
</div>
</div>
</template>

<script setup>
import { ref, reactive, inject, onMounted, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'

// 路由
const router = useRouter()
const route = useRoute()

const message = inject("message")
const dialog = inject("dialog")
const axios = inject("axios")

// 选中的分类
const selectedCategory = ref(0)
// 分类选项
const categortyOptions = ref([])
// 文章列表
const blogListInfo = ref([])

// 查询和分页数据
const pageInfo = reactive({
page: 1,
pageSize: 3,
pageCount: 0,
count: 0,
keyword: "",
categoryId:0,
})

onMounted(() => {
loadCategorys();
loadBlogs()
})

/**
* 获取博客列表
*/
const loadBlogs = async (page = 0) => {
if (page != 0) {
pageInfo.page = page;
}
let res = await axios.get(`/blog/search?keyword=${pageInfo.keyword}&page=${pageInfo.page}&pageSize=${pageInfo.pageSize}&categoryId=${pageInfo.categoryId}`)
let temp_rows = res.data.data.rows;
// 处理获取的文章列表数据
for (let row of temp_rows) {
row.content += "..."
// 把时间戳转换为年月日
let d = new Date(row.create_time)
row.create_time = `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日`
}
blogListInfo.value = temp_rows;
pageInfo.count = res.data.data.count;
//计算分页大小
pageInfo.pageCount = parseInt(pageInfo.count / pageInfo.pageSize) + (pageInfo.count % pageInfo.pageSize > 0 ? 1 : 0)
console.log(res)
}

const categoryName = computed(() => {
//获取选中的分类
let selectedOption = categortyOptions.value.find((option) => { return option.value == selectedCategory.value })
//返回分类的名称
return selectedOption ? selectedOption.label : ""
})

/**
* 获取分类列表
*/
const loadCategorys = async () => {
let res = await axios.get("/category/list")
categortyOptions.value = res.data.rows.map((item) => {
return {
label: item.name,
value: item.id
}
})
console.log(categortyOptions.value)
}

/**
* 选中分类
*/
const searchByCategory = (categoryId)=>{
pageInfo.categoryId = categoryId ;
loadBlogs()
}

//页面跳转
const toDetail = (blog)=>{
router.push({path:"/detail",query:{id:blog.id}})
}

const homePage = () => {
router.push("/")
}

const dashboard = () => {
router.push("/login")
}


</script>

<style lang="scss" scoped>

.search{
margin-bottom: 15px;
}
.container {
width: 1200px;
margin: 0 auto;
}

.nav {
display: flex;
font-size: 20px;
padding-top: 20px;
color: #64676a;

div {
cursor: pointer;
margin-right: 15px;

&:hover {
color: #f60;
}

span {
font-size: 12px;
}
}
}

.footer {
text-align: center;
line-height: 25px;
color: #64676a;
}
</style>

2.10 文章详情页

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
<template>
<div class="container">

<n-button @click="back">返回</n-button>

<!-- 标题 -->
<n-h1>{{ blogInfo.title }}</n-h1>
<!-- 文章内容 -->
<div class="blog-content">
<div v-html="blogInfo.content"></div>
</div>

</div>
</template>

<script setup>
import { ref, reactive, inject, onMounted, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'

const router = useRouter()
const route = useRoute()
const blogInfo = ref({})
const axios = inject("axios")

onMounted(() => {
loadBlog()
})


const loadBlog = async () => {
let res = await axios.get("/blog/detail?id=" + route.query.id)
blogInfo.value = res.data.rows[0];
}

const back = ()=>{
router.push("/")
}

</script>

<style>
.blog-content img {
max-width: 100% !important;
}
</style>

<style lang="scss" scoped>
.container {
width: 1200px;
margin: 0 auto;
}
</style>

3.自己完善

3.1 删除分类及删除它关联的文章

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
router.get('/_token/deleteCategory',async(req,res)=>{
let {categoryId} = req.query;//从请求的query中获取categoryId参数。
let delete_sql = "delete from `blog` where `category_id` = ?";//定义删除的SQL语句。
let {err,rows} = await db.async.all(delete_sql,[categoryId]);//执行删除的SQL语句。
if(err == null) {
res.send({
code:200,
msg:"删除成功",//如果成功,返回相应的状态码和提示信息。
})
}else{
res.send({
code:500,//如果失败,返回相应的状态码和提示信息。
msg:"删除失败",
err
})
}
})

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//删除分类及删除它关联的文章
let res = await axios.delete(
`/category/_token/delete?id=${category.id}`
);
let blogres = await axios.get(
`/category/_token/deleteCategory?categoryId=${category.id}`
)
if (res.data.code == 200 &&blogres.data.code == 200) {
loadDatas();
message.info(res.data.msg);
} else {
message.error(res.data.msg);
}
},

3.2 将文章卡片框的内容由html 转化为 纯文本

1
2
3
4
5
// 将html内容转换为纯文本。
const sanitizeHTML = (html) => {
let doc = new DOMParser().parseFromString(html, 'text/html');
return doc.body.textContent || "";
}
1
2
3
4
5
6
7
8
<n-card :title="blog.title" @click="toDetail(blog)">
{{ sanitizeHTML(blog.content) }} //这里
<template #footer>
<n-space align="center">
<div>发布时间:{{ blog.create_time }}</div>
</n-space>
</template>
</n-card>

3.3 :disabled需绑定布尔值,而不是数值的warning(布尔属性)

1
<n-tab-pane :disabled="tabValue =='update'? 0:1"  name="update" tab="修改文章">

改为下面即可

1
<n-tab-pane :disabled="tabValue =='update'? false:true"  name="update" tab="修改文章">

3.4 添加文章后重新加载文章列表并跳转至列表页

1
2
3
4
5
6
7
8
9
10
11
12
13
const add = async () => {
let res = await axios.post("/blog/_token/add", addArticle);
if (res.data.code == 200) {
message.info(res.data.msg);
addArticle.title = "";
addArticle.content = "";
addArticle.categoryId = 0; // 清空表单数据。
} else {
message.error(res.data.msg);
}
loadBlogs();
tabValue.value = 'list' // 切换到列表页。
};

3.5 将添加文章和修改文章的分类改为“”而不是0,这可能改变了categoryId的数据类型,但目前还没发现问题

3.6实现点击首页将页面的值改回默认

1
2
3
4
5
6
7
8
const homePage = () => {
selectedCategory.value = 0; // 重置分类选择为全部
pageInfo.categoryId = 0; // 重置分类选择为全部
pageInfo.page=1;
pageInfo.keyword = "";
loadBlogs()
router.push("/")
}

3.7 黑夜模式和白天模式实现

黑色样式还没设计,功能已实现

3.8防抖和节流实现

防抖 debounce

当事件被频繁触发时,在一定的时间内再去执行回调函数,如果在等待期间再次被触发,则重新计时,直至整个等待期间没有新的事件被触发,执行回调函数。

节流 throttle

在规定的时间内只触发一次回调函数,在规定时间内多次触发函数,只会执行一次

1
import { debounce } from 'lodash-es'
1
const debouceLoadBlogs = debounce(()=>{loadBlogs(0)}, 500); // 防抖函数,防止频繁调用接口
1
2
3
4
5
6
7
8
<n-space class="search">
<n-input
v-model:value="pageInfo.keyword"
:style="{ width: '500px' }"
placeholder="请输入关键字"
/>
<n-button type="primary" ghost @click="debouceLoadBlogs"> 搜索 </n-button>
</n-space>
1
import { debounce,throttle} from 'lodash-es'
1
const throttlelogin = throttle(() => {login()}, 500)
1
<n-button type="primary" @click="throttlelogin"  style="width: 100%;">登录</n-button>

4.期待实现