前言
主要使用的技术:前端使用的是Vue.js,后端使用的是SpringBoot。如不雷同可以直接跳过了。
文章管理是这个系统最主要的一个功能也是最常规的一个功能。之前的文章提到的是文章分类的管理,类似于我们所熟知的标签操作。而文章管理相较于文章分类的管理除了基础的增删改查之外,还增加了文章图片的托管,Markdown编辑器,自定义校验等稍微复杂一点的操作。整体上相差不大。
所以将分为两篇来进行阐述。最后还差一块用户模块的功能,然后文章管理系统的开发就告一段落了。
接下来关于Web全栈开发的学习将进入到新的境界,涉及到更实用的技巧以及更复杂的操作。比如验证码校验,支付等之类的操作。
正文
页面展示(挺单一的)
前端的代码由于只包括展示页面和实现函数已经基本展示完毕,后端仍存在一些细节上的操作,比如图片阿里云的托管(有点类似于图床),自定义校验等,以及一些补充的笔记就放到下一篇文章中来讲。
前端涉及到的函数
具体的代码思路和文章分类管理的大差不差,所以就罗列在过程中要用到的一些主要的函数以及变量(也就是script部分涉及到的,具体代码中有备注但是有点乱)
用于在template部分绑定按钮抽屉等以此来实现事件。
category | 文章分类模型,包括id,categoryName,categoryAlias,createTime,updateTime |
articles | 文章列表模型,包括id,title,content,coverImg,state,categoryId,createTime,updateTime |
分页条模型,包括pageNum,total,pageSize | |
formaDate | 用于格式化日期(更改时间) |
onSizeChange | //当每页条数发生了变化,调用此函数 |
onCurrentChange | 当前页码发生变化,调用此函数 |
showDrawer | 显示新建编辑文章的抽屉 |
articleList,articleCategoryList | 获取文章有关的数据数据 |
addArticle | 添加新的文章 |
updateArticle | 更新文章数据 |
deleteArticle | 删除文章 |
clearData | 清空新建文章时抽屉中的数据 |
后端涉及到的函数
具体的代码思路和文章分类管理的大差不差,主要涉及到下面五个功能。在数据访问层,请求层,数据管理层都有针对分别实现这五个功能的函数。
add | 新增文章 |
list | 获取文章列表 |
findById | 获取文章详情 |
update | 更新文章内容 |
deleteById | 删除文章 |
代码展示
计算机中很多项目和应用都会因为作者公布展示的代码不全而导致不能跑起来无法正常运行。不是缺少必要的配置项,就是缺少必要的文件
为了省时省力,这也就是需要作者公布源码的原因。
由于是在之前的基础上进行的功能的拓展,所以一些必要的文件在专栏之前的文章里已经展示过了,也就不再进行重复展示,主要展示必要的文件。
但是可能有些配置文件进行过小的添加和改动,就需要自己根据报错一点点修改了,或者等待我完整的项目上传吧。
前端
主要文件ArticleManage.vue,article.js
ArticleManage.vue是用于填补展示文件Layout.vue中空白的部分用以实现功能。
article.js主要用于服务接口的调用,即前后端的链接,用于解决跨域的问题。
ArticleManage.vue
<script setup>
import {
Edit,
Delete
} from '@element-plus/icons-vue'
import { ref } from 'vue'
import {ElMessage } from 'element-plus'
//文章分类数据模型
const categorys = ref([
{
"id": 1,
"categoryName": "学校事件",
"categoryAlias": "xuexiaoshijian",
"createTime": "2024-03-25 00:16:04",
"updateTime": "2024-04-01 20:47:37"
},
])
import {articleCategoryListService,articleListService,addArticleService,articleUpdateService} from '@/api/article.js'
//用户搜索时选中的分类id
const categoryId=ref('')
//用户搜索时选中的发布状态
const state=ref('')
//文章列表数据模型
const articles = ref([
{
"id": 1,
"title": "测试文章",
"content": "12345678",
"coverImg": "https://yiming1234.oss-cn-beijing.aliyuncs.com/afc398f5-0c03-425b-a649-880ced2fb568.jpg",
"state": "已发布",
"categoryId": 1,
"createTime": "2024-04-17 20:20:41",
"updateTime": "2024-04-17 20:20:41"
}
])
//重置搜索
const resetSearch = () => {
categoryId.value = '';
state.value = '';
articleList();
}
//分页条数据模型
const pageNum = ref(1)//当前页
const total = ref(20)//总条数
const pageSize = ref(3)//每页条数
//当每页条数发生了变化,调用此函数
const onSizeChange = (size) => {
pageSize.value = size
articleList()
}
//当前页码发生变化,调用此函数
const onCurrentChange = (num) => {
pageNum.value = num
articleList()
}
//格式化日期
const formatDate = (dateString) => {
const date = new Date(dateString);
return date.toLocaleString();
}
const title = ref('')
//显示抽屉
const showDrawer = (row) => {
visibleDrawer.value = true;
title.value = '编辑文章';
articleModel.value.title = row.title; // 确保row对象有一个title属性
articleModel.value.categoryId = row.categoryId; // 确保row对象有一个categoryId属性
articleModel.value.coverImg = row.coverImg; // 确保row对象有一个coverImg属性
articleModel.value.content = row.content; // 确保row对象有一个content属性
articleModel.value.state = row.state; // 确保row对象有一个state属性
articleModel.value.id = row.id // 确保row对象有一个id属性
}
//回显文章分类
const articleCategoryList = async () => {
let result = await articleCategoryListService();
categorys.value = result.data;
}
//获取文章列表数据
const articleList = async () => {
let params = {
pageNum: pageNum.value,
pageSize: pageSize.value,
categoryId: categoryId.value ? categoryId.value : null,
state: state.value ? state.value : null
}
let result = await articleListService(params);
//渲染视图
total.value = result.data.total;
articles.value = result.data.items;
//处理数据,给数据模型扩展一个属性categoryName
for (let i = 0; i < articles.value.length; i++) {
let article = articles.value[i];
for (let j = 0; j < categorys.value.length; j++) {
if (article.categoryId == categorys.value[j].id) {
article.categoryName = categorys.value[j].categoryName;
}
}
}
}
articleCategoryList();
articleList();
import { QuillEditor } from '@vueup/vue-quill'
import '@vueup/vue-quill/dist/vue-quill.snow.css'
import {Plus} from '@element-plus/icons-vue'
//控制抽屉是否显示
const visibleDrawer = ref(false)
//添加表单数据模型
const articleModel = ref({
title: '',
categoryId: '',
coverImg: '',
content:'',
state:''
})
import {useTokenStore} from "@/stores/token.js";
const tokenStore = useTokenStore();
const uploadSuccess = (result) => {
articleModel.value.coverImg = result.data;
console.log(result.data);
}
//添加文章
const addArticle = async (clickState) => {
articleModel.value.state = clickState;
let result = await addArticleService(articleModel.value);
ElMessage.success(result.msg?result.msg:'添加成功');
visibleDrawer.value = false;
articleList();
}
//编辑文章
const updateArticle = async (clickState) => {
articleModel.value.state = clickState;
let result = await articleUpdateService(articleModel.value);
ElMessage.success(result.msg ? result.msg : '编辑成功')
//调用获取所有文章分类的函数
articleList();
visibleDrawer.value = false;
}
//删除文章
import { ElMessageBox } from 'element-plus'
const deleteArticle = (row) => {
ElMessageBox.confirm(
'确认删除当前文章?',
'Warning',
{
confirmButtonText: 'OK',
cancelButtonText: 'Cancel',
type: 'warning',
}
)
.then(async () => {
//调用接口
let result = await articleDeleteService(row.id);
ElMessage({
type: 'success',
message: 'Delete completed',
})
articleList();
})
.catch(() => {
ElMessage({
type: 'info',
message: 'Delete canceled',
})
})
}
const clearData = () => {
articleModel.value.title = '';
articleModel.value.categoryId = '';
articleModel.value.coverImg = '';
articleModel.value.content = '';
articleModel.value.state = '';
}
</script>
<template>
<el-card class="page-container">
<template #header>
<div class="header">
<span>文章管理</span>
<div class="extra">
<el-button type="primary" @click="visibleDrawer=true; title = '新建文章'; clearData()">添加文章</el-button>
</div>
</div>
</template>
<!-- 搜索表单 -->
<el-form inline>
<el-form-item label="文章分类:">
<el-select placeholder="请选择" v-model="categoryId" style="width: 200px">
<el-option v-for="c in categorys" :key="c.id" :label="c.categoryName" :value="c.id">
</el-option>
</el-select>
</el-form-item>
<el-form-item label="发布状态:">
<el-select placeholder="请选择" v-model="state" style="width: 200px">
<el-option label="已发布" value="已发布"></el-option>
<el-option label="草稿" value="草稿"></el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="articleList">搜索</el-button>
<el-button @click="resetSearch">重置</el-button>
</el-form-item>
</el-form>
<!-- 文章列表 -->
<el-table :data="articles" style="width: 100%">
<el-table-column label="文章标题" width="400" prop="title"></el-table-column>
<el-table-column label="分类" prop="categoryName"></el-table-column>
<el-table-column label="发表时间">
<template #default="{ row }">
{{ formatDate(row.createTime) }}
</template>
</el-table-column>
<el-table-column label="状态" prop="state"></el-table-column>
<el-table-column label="操作" width="100">
<template #default="{ row }">
<el-button :icon="Edit" circle plain type="primary" @click="showDrawer(row)"></el-button>
<el-button :icon="Delete" circle plain type="danger" @click="deleteArticle(row)"></el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="没有数据" />
</template>
</el-table>
<!-- 分页条 -->
<el-pagination v-model:current-page="pageNum" v-model:page-size="pageSize" :page-sizes="[3, 5 ,10, 15]"
layout="jumper, total, sizes, prev, pager, next" background :total="total" @size-change="onSizeChange"
@current-change="onCurrentChange" style="margin-top: 20px; justify-content: flex-end" />
</el-card>
<el-drawer v-model="visibleDrawer" :title="title" direction="rtl" size="50%">
<!-- 添加文章表单 -->
<el-form :model="articleModel" label-width="100px" >
<el-form-item label="文章标题" >
<el-input v-model="articleModel.title" placeholder="请输入标题"></el-input>
</el-form-item>
<el-form-item label="文章分类">
<el-select placeholder="请选择" v-model="articleModel.categoryId">
<el-option v-for="c in categorys" :key="c.id" :label="c.categoryName" :value="c.id">
</el-option>
</el-select>
</el-form-item>
<el-form-item label="文章封面">
<el-upload class="avatar-uploader" :auto-upload="true" :show-file-list="false"
action="/api/upload"
name="file"
:headers="{'Authorization': tokenStore.token}"
:on-success="uploadSuccess"
>
<img v-if="articleModel.coverImg" :src="articleModel.coverImg" class="avatar" />
<el-icon v-else class="avatar-uploader-icon">
<Plus />
</el-icon>
</el-upload>
</el-form-item>
<el-form-item label="文章内容">
<div class="editor">
<quill-editor
theme="snow"
v-model:content="articleModel.content"
contentType="html"
>
</quill-editor>
</div>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="title == '新建文章' ? addArticle('已发布') : updateArticle('已发布')">发布</el-button>
<el-button type="info" @click="title == '新建文章' ?addArticle('草稿'): updateArticle('草稿')">草稿</el-button>
</el-form-item>
</el-form>
</el-drawer>
</template>
<style lang="scss" scoped>
.page-container {
min-height: 100%;
box-sizing: border-box;
.header {
display: flex;
align-items: center;
justify-content: space-between;
}
}
.avatar-uploader {
:deep() {
.avatar {
width: 178px;
height: 178px;
display: block;
}
.el-upload {
border: 1px dashed var(--el-border-color);
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: var(--el-transition-duration-fast);
}
.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;
}
}
}
.editor {
width: 100%;
:deep(.ql-editor) {
min-height: 200px;
}
}
</style>
article.js的内容
import request from '@/utils/request.js'
export const articleCategoryListService = ()=>{
return request.get('/category')
}
//文章分类添加
export const articleCategoryAddService = (categoryData)=>{
return request.post('/category',categoryData)
}
//文章分类修改
export const articleCategoryUpdateService = (categoryData)=>{
return request.put('/category',categoryData)
}
//文章分类删除
export const articleCategoryDeleteService = (id)=>{
return request.delete('/category?id='+id)
}
//文章列表查询
export const articleListService = (params)=>{
return request.get('/article',{params:params})
}
//文章添加
export const addArticleService = (articleData)=>{
return request.post('/article',articleData)
}
//文章修改
export const articleUpdateService = (articleData)=>{
return request.put('/article',articleData)
}
//文章删除
export const articleDeleteService = (id)=>{
return request.delete('/article?id='+id)
}
后端
主要文件有实体类(Article.java以及PageBean.java)
请求层(ArticleController.java)
服务层(ArticleService.java以及ArticleServiceImpl.java)
数据访问层(ArticleMapper.java)
AliOssUtil.java用于图片的托管,来自官网提供的示例文件进行了修改
org.example.anno.State.java和org.example.validation.StateValidation.java用于Article.java实体类中state类的校验
Article.java的内容
package org.example.pojo;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
import org.example.anno.State;
import org.hibernate.validator.constraints.URL;
import java.time.LocalDateTime;
@Data
public class Article {
private Integer id;//主键ID
@NotEmpty
@Pattern(regexp = "^\\S{1,10}$")
private String title;//文章标题
@NotEmpty
private String content;//文章内容
@NotEmpty
@URL
private String coverImg;//封面图像
@State
private String state;//发布状态 已发布|草稿
@NotNull
private Integer categoryId;//文章分类id
private Integer createUser;//创建人ID
private LocalDateTime createTime;//创建时间
private LocalDateTime updateTime;//更新时间
}
PageBean.java的内容
package org.example.pojo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
//分页返回结果对象
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PageBean <T>{
private Long total;//总条数
private List<T> items;//当前页数据集合
}
ArticleController.java的内容
package org.example.controller;
import jakarta.servlet.http.HttpServletResponse;
import org.example.pojo.Article;
import org.example.pojo.PageBean;
import org.example.pojo.Result;
import org.example.service.ArticleService;
import org.example.utils.JwtUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/article")
public class ArticleController {
@Autowired
private ArticleService articleService;
//新增文章
@PostMapping
public Result add(@RequestBody @Validated Article article) {
articleService.add(article);
return Result.success();
}
//获取文章列表
@GetMapping
public Result<PageBean<Article>> list(Integer pageNum, Integer pageSize, @RequestParam(required = false) String categoryId, @RequestParam(required = false) String state) {
PageBean<Article> pageBean = articleService.list(pageNum, pageSize, categoryId, state);
return Result.success(pageBean);
}
//获取文章详情
@GetMapping("/detail")
public Result<Article> detail(@RequestParam Integer id) {
Article article = articleService.findById(id);
return Result.success(article);
}
//更新文章
@PutMapping
public Result update(@RequestBody Article article) {
articleService.update(article);
return Result.success();
}
//删除文章
@PostMapping("/delete")
public Result delete(@RequestParam Integer id) {
articleService.deleteById(id);
return Result.success();
}
}
ArticleService.java的内容
package org.example.service;
import org.example.pojo.Article;
import org.example.pojo.PageBean;
public interface ArticleService {
// 添加文章
void add(Article article);
//条件分页列表查询
PageBean<Article> list(Integer pageNum, Integer pageSize, String categoryId, String state);
Article findById(Integer id);
void update(Article article);
void deleteById(Integer id);
}
ArticleServiceImpl.java的内容
package org.example.service.impl;
import com.github.pagehelper.Page;
import com.github.pagehelper.PageHelper;
import org.example.mapper.ArticleMapper;
import org.example.pojo.Article;
import org.example.pojo.PageBean;
import org.example.service.ArticleService;
import org.example.utils.ThreadLocalUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
@Service
public class ArticleServiceImpl implements ArticleService {
@Autowired
private ArticleMapper articleMapper;
@Override
public void add(Article article) {
//补充属性值
article.setCreateTime(LocalDateTime.now());
article.setUpdateTime(LocalDateTime.now());
Map<String,Object> map = ThreadLocalUtil.get();
article.setCreateUser((Integer) map.get("id"));
articleMapper.add(article);
}
@Override
public PageBean<Article> list(Integer pageNum, Integer pageSize, String categoryId, String state) {
//创建pagebean对象
PageBean<Article> pb = new PageBean<>();
//开启分页查询
PageHelper.startPage(pageNum,pageSize);
//调用Mapper
Map<String,Object> map = ThreadLocalUtil.get();
Integer userId = (Integer) map.get("id");
List<Article> as = articleMapper.list(userId,categoryId,state);
//Page中提供了方法,可以获取PageHelper分页查询后 得到的总记录条数和当前页数据
Page<Article> p = (Page<Article>) as;
//把数据填充到PageBean对象中
pb.setTotal(p.getTotal());
pb.setItems(p.getResult());
return pb;
}
@Override
public Article findById(Integer id) {
Article article = articleMapper.findById(id);
return article;
}
@Override
public void update(Article article) {
article.setUpdateTime(LocalDateTime.now());
articleMapper.update(article);
}
@Override
public void deleteById(Integer id) {
articleMapper.deleteById(id);
}
}
ArticleMapper.java的内容
package org.example.mapper;
import org.apache.ibatis.annotations.*;
import org.example.pojo.Article;
import org.example.pojo.Category;
import java.util.List;
@Mapper
public interface ArticleMapper {
// 添加文章
@Insert("insert into article(title,content,cover_img,state,category_id,create_user,create_time,update_time) values(#{title},#{content},#{coverImg},#{state},#{categoryId},#{createUser},#{createTime},#{updateTime})")
void add(Article article);
List<Article> list(Integer userId, String categoryId, String state);
@Select("select * from article where create_user = #{userId}")
Article findById(Integer userId);
@Update("update article set title=#{title},content=#{content},cover_img=#{coverImg},state=#{state},category_id=#{categoryId},update_time=now() where id=#{id}")
void update(Article article);
@Delete("delete from article where id=#{id}")
void deleteById(Integer id);
}
AliOss.java的内容
package org.example.utils;
import com.aliyun.oss.ClientException;
import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import com.aliyun.oss.OSSException;
import com.aliyun.oss.model.PutObjectRequest;
import com.aliyun.oss.model.PutObjectResult;
import java.io.FileInputStream;
import java.io.InputStream;
public class AliOssUtil {
// Endpoint以华东1(杭州)为例,其它Region请按实际情况填写。
private static final String ENDPOINT = "https://oss-cn-beijing.aliyuncs.com";
// 从环境变量中获取访问凭证。运行本代码示例之前,请确保已设置环境变量OSS_ACCESS_KEY_ID和OSS_ACCESS_KEY_SECRET。
//EnvironmentVariableCredentialsProvider credentialsProvider = CredentialsProviderFactory.newEnvironmentVariableCredentialsProvider();
// 填写Bucket名称,例如examplebucket。
private static final String ACCESS_KEY_ID = "LTAI5tS2iFbkK1VLdeYdr4p2";
private static final String ACCESS_KEY_SECRET = "Mob9G1cqSRCxZKOllAkbqoJ7jT6mH9";
private static final String BUCKET_NAME = "yiming1234";
// 填写Object完整路径,完整路径中不能包含Bucket名称,例如exampledir/exampleobject.txt。
public static String uploadFile(String objectName, InputStream in) throws Exception {
// 创建OSSClient实例。
OSS ossClient = new OSSClientBuilder().build(ENDPOINT, ACCESS_KEY_ID, ACCESS_KEY_SECRET);
String url = "";
try {
// 填写字符串。
String content = "Hello OSS,你好世界";
// 创建PutObjectRequest对象。
PutObjectRequest putObjectRequest = new PutObjectRequest(BUCKET_NAME, objectName, in);
// 如果需要上传时设置存储类型和访问权限,请参考以下示例代码。
// ObjectMetadata metadata = new ObjectMetadata();
// metadata.setHeader(OSSHeaders.OSS_STORAGE_CLASS, StorageClass.Standard.toString());
// metadata.setObjectAcl(CannedAccessControlList.Private);
// putObjectRequest.setMetadata(metadata);
// 上传字符串。
PutObjectResult result = ossClient.putObject(putObjectRequest);
url = "https://" + BUCKET_NAME + "." + ENDPOINT.substring(ENDPOINT.lastIndexOf("/")+1) + "/" + objectName;
} catch (OSSException oe) {
System.out.println("Caught an OSSException, which means your request made it to OSS, "
+ "but was rejected with an error response for some reason.");
System.out.println("Error Message:" + oe.getErrorMessage());
System.out.println("Error Code:" + oe.getErrorCode());
System.out.println("Request ID:" + oe.getRequestId());
System.out.println("Host ID:" + oe.getHostId());
} catch (ClientException ce) {
System.out.println("Caught an ClientException, which means the client encountered "
+ "a serious internal problem while trying to communicate with OSS, "
+ "such as not being able to access the network.");
System.out.println("Error Message:" + ce.getMessage());
} finally {
if (ossClient != null) {
ossClient.shutdown();
}
}
return url;
}
}
org.example.anno.State.java的内容
package org.example.anno;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import jakarta.validation.constraints.NotEmpty;
import org.example.validation.StateValidation;
import java.lang.annotation.*;
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
@Documented//元注解
@Target({FIELD})
@Retention(RUNTIME)
@Constraint(
validatedBy = {StateValidation.class}
)//指定提供校验规则的类
public @interface State {
//校验失败的提示信息
String message() default "state参数的值只能是已发布的文章或者草稿";
//指定分组
Class<?>[] groups() default {};
//负载
Class<? extends Payload>[] payload() default {};
}
org.example.validation.StateValidation.java的内容
package org.example.anno;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import jakarta.validation.constraints.NotEmpty;
import org.example.validation.StateValidation;
import java.lang.annotation.*;
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
@Documented//元注解
@Target({FIELD})
@Retention(RUNTIME)
@Constraint(
validatedBy = {StateValidation.class}
)//指定提供校验规则的类
public @interface State {
//校验失败的提示信息
String message() default "state参数的值只能是已发布的文章或者草稿";
//指定分组
Class<?>[] groups() default {};
//负载
Class<? extends Payload>[] payload() default {};
}
缺陷以及仍需要进一步改进的地方
首先作为文章管理系统,应该是作为具体网站的后台来使用的,比如在调用addArticle发布文章的时候还应该向用于展示的网页发送数据,以此来进一步实现发布文章这个功能。
其次图片的添加采用了阿里云OSS托管的这个功能。但是每一次在点击添加或者更改图片的时候都会上传一张新的图片,并不会删除或覆盖,这一点也需要改进。
但是作为练手的项目并没有实际的用途,也就不过分追求完美了。
尾声
作为自主开发笔记,很多小的细节以及报错后错误的查找不能很好的进行体现,这也是没办法的事。
在全部完工后,会上传完整的压缩包以及jar包,供学习参考。