后端实现
创建文章dto
先实现创建文章DTO。
在dto文件夹下创建ArticleCreationDto类,代码如下:
package com.example.demo.dto;
import lombok.Data;
import java.io.Serial;import java.io.Serializable;
/** * 用于接收前端创建文章请求的DTO */@Datapublic class ArticleCreationDto implements Serializable { @Serial private static final long serialVersionUID = 1L;
private String title; private String content; private Integer userId;}创建文章的服务层
在服务层添加一个创建文章的接口并实现。
在ArticleService中添加
/** * 创建新文章。作者ID从后端认证上下文获取并传入。 * @param creationDto 包含文章标题、内容的DTO。 * @param userId 作者的用户ID。 * @return 成功创建的文章实体。 */Article createArticle(ArticleCreationDto creationDto, Integer userId);在Impl下的ArticleServiceImpl中添加
@Overridepublic Article createArticle(ArticleCreationDto creationDto, Integer userId) { // **修改:添加 userId 参数** Article article = new Article(); // 将 DTO 中的 title 和 content 复制到 Article 实体 BeanUtils.copyProperties(creationDto, article);
// **核心修改:设置从 Sa-Token 获取的 userId** article.setUserId(userId);
// 如果你没有启用 MetaObjectHandler,请手动设置这些字段的值 article.setCreateTime(LocalDateTime.now()); article.setUpdateTime(LocalDateTime.now()); article.setViewCount(0); // 默认浏览量为0 article.setStatus(1); // 默认设置为已发布 (1)
boolean saveSuccess = save(article);
if (!saveSuccess) { throw new RuntimeException("文章创建失败"); }
return article;}创建文章的控制器
在ArticleController类中添加
/** * 创建新文章。 * @param creationDto 包含文章标题、内容、作者ID的DTO。 * @return 统一响应体,包含创建成功的文章信息。 */@PostMapping("/create")@SaCheckLogin // 确保用户已登录public SaResult createArticle(@RequestBody ArticleCreationDto creationDto) { // 直接获取登录ID并转换为 Integer 类型,更简洁安全 Integer userId = StpUtil.getLoginIdAsInt(); System.out.println("当前登录用户ID: " + userId); // 将 DTO 和 userId 传递给 Service 层 Article newArticle = articleService.createArticle(creationDto, userId); return SaResult.ok("文章创建成功").setData(newArticle);}注意这个@SacheckLogin 它用于检测用户是否登录,在我们的设计中,创建文章不会是公共接口,它必须要用户登录后进行鉴权才可调用这个接口。如果未登录,则会报错,后端返回code 500。
全局异常处理
上文提到,如果鉴权失败则会返回异常,不管是何种失败,后端都会返回code 500,很显然没法对具体报错进行分类。
我们来实现一个全局异常处理来接管后端的报错。
创建一个handler软件包,在handler下创建一个GlobalExceptionHandler类,代码如下:
package com.example.demo.handler; // 推荐放在 handler 包下
import cn.dev33.satoken.exception.NotLoginException;import cn.dev33.satoken.exception.NotPermissionException;import cn.dev33.satoken.exception.NotRoleException;import cn.dev33.satoken.util.SaResult;import org.springframework.web.bind.annotation.ExceptionHandler;import org.springframework.web.bind.annotation.RestControllerAdvice;
/** * 全局异常处理类 */@RestControllerAdvice // 这是一个组合注解,包含 @ControllerAdvice 和 @ResponseBodypublic class GlobalExceptionHandler {
// --- Sa-Token 异常处理 ---
/** * 处理:未登录异常 * NotLoginException 会根据具体的未登录类型(如:没有token、token无效、token过期、被顶下线等)抛出 */ @ExceptionHandler(NotLoginException.class) public SaResult handlerNotLoginException(NotLoginException e) { String msg = ""; if (NotLoginException.NOT_TOKEN.equals(e.getType())) { msg = "未提供 Token,请登录后重试"; } else if (NotLoginException.INVALID_TOKEN.equals(e.getType())) { msg = "Token 无效,请重新登录"; } else if (NotLoginException.TOKEN_TIMEOUT.equals(e.getType())) { msg = "登录已过期,请重新登录"; // 这里使用“登录已过期”更友好 } else if (NotLoginException.BE_REPLACED.equals(e.getType())) { msg = "您的账号在其他设备登录,请重新登录"; } else if (NotLoginException.KICK_OUT.equals(e.getType())) { msg = "您的账号已被踢下线,请重新登录"; } else if (NotLoginException.TOKEN_FREEZE.equals(e.getType())) { msg = "您的账号已被冻结,请稍后再试"; } else { msg = "用户未登录或登录状态失效,请重新登录"; // 兜底消息 }
System.err.println("Sa-Token 未登录异常: " + msg + " - 异常类型: " + e.getType());
// 返回 Sa-Token 异常自带的错误码 (例如 10101) 给前端 return SaResult.error(msg).setCode(e.getCode()); }
/** * 处理:缺少权限异常 (@SaCheckPermission) */ @ExceptionHandler(NotPermissionException.class) public SaResult handlerNotPermissionException(NotPermissionException e) { System.err.println("Sa-Token 权限不足异常: " + e.getMessage()); return SaResult.error("权限不足:" + e.getMessage()).setCode(e.getCode()); }
/** * 处理:缺少角色异常 (@SaCheckRole) */ @ExceptionHandler(NotRoleException.class) public SaResult handlerNotRoleException(NotRoleException e) { System.err.println("Sa-Token 角色不足异常: " + e.getMessage()); return SaResult.error("无此角色:" + e.getMessage()).setCode(e.getCode()); }
// --- 通用异常处理 ---
/** * 处理:其他所有未被前面特定 @ExceptionHandler 捕获的异常 */ @ExceptionHandler(Exception.class) public SaResult handlerException(Exception e) { System.err.println("捕获到未处理异常: " + e.getMessage()); e.printStackTrace(); // 打印堆栈信息,便于调试和排查问题 return SaResult.error("服务器内部错误,请联系管理员!").setCode(500); // 返回 500 状态码 }}至此,后端部分已完成
前端实现
前端接口
在interface下的Article.ts中添加如下代码:
export interface ArticleCreationDto { title: string; content: string;}为什么少了userId,原因是后端是通过我们传递的token来获取userId的,不需要我们前端显式地传递。如果你仔细观察后端控制器的代码,userId是由StpUtil.getLoginIdAsInt()方法获取的。
对接api
在api下的Api.ts中,添加
/** * 创建新文章。 * @param data 文章创建数据 (标题、内容、作者ID) * @returns Promise<Article> 返回创建成功的文章实体 */export function createArticle(data: ArticleCreationDto): Promise<Article> { // 你的 request 实例可能叫 request.post 或 api.post return api.post<Result<Article>, Article>('/article/create', data);}请求拦截器
请求拦截器的作用就是在你发送一个请求前都要过一遍请求拦截器的代码。在这里,我们要实现鉴权,所以每次请求都要带上我们的token。token是通过登录时,后端传到前端保存好了的。
在api下的index.ts中,添加。
api.interceptors.request.use( config => { // 1. 从 localStorage 获取到的原始 token 字符串 (实际上是 JSON 字符串) const tokenJsonString = localStorage.getItem('token');
// 2. 如果 tokenJsonString 存在且不为空字符串或 "null" if (tokenJsonString && tokenJsonString !== '' && tokenJsonString !== 'null') { try { // 3. 将 JSON 字符串解析成 JavaScript 对象 const tokenObject: saToken = JSON.parse(tokenJsonString);
// **核心修改:从解析后的对象中取出真正的 token 字符串** // 你的日志显示 tokenValue 就在这个对象里 const realToken = tokenObject.tokenValue;
// 4. 如果真实 token 存在,就添加到请求头中 if (realToken) { config.headers.satoken = `Bearer ${realToken}`; } } catch (e) { // 如果 JSON.parse 失败(例如,存储的不是有效的 JSON 字符串) // 或者 tokenObject.tokenValue 不存在 console.error('解析 token 或获取 tokenValue 失败:', e); // 你可以选择在这里给用户一个提示,例如: // ElMessage.error('获取认证信息失败,请尝试重新登录。'); } }
return config; // 必须返回 config 对象 }, error => { console.error('请求拦截器错误:', error); return Promise.reject(error); });注意,务必把export default api放在最后
文章创建视图
在文件夹views下创建ArticleCreate.vue 代码如下:
<template> <TheHeader /> <div class="create-article-container"> <h1>创建新文章</h1>
<el-form :model="articleForm" :rules="rules" ref="articleFormRef" label-width="80px" class="article-form"> <el-form-item label="标题" prop="title"> <el-input v-model="articleForm.title" placeholder="请输入文章标题"></el-input> </el-form-item>
<el-form-item label="内容" prop="content"> <el-input type="textarea" v-model="articleForm.content" :rows="10" placeholder="请输入文章内容"></el-input> </el-form-item>
<el-form-item> <el-button type="primary" @click="submitForm">立即创建</el-button> <el-button @click="resetForm">重置</el-button> <el-button @click="goBack">返回文章列表</el-button> </el-form-item> </el-form> </div></template>
<script lang="ts" setup>import { reactive, ref } from 'vue';import { ElMessage, ElNotification, } from 'element-plus';import type { FormInstance, FormRules } from 'element-plus'import { useRouter } from 'vue-router';import { createArticle } from '@/api/Api'; // 导入新的 API 方法import TheHeader from '@/components/TheHeader.vue';
const router = useRouter();
// 表单数据模型const articleForm = reactive({ title: '', content: '',});
// 表单校验规则const articleFormRef = ref<FormInstance>(); // 用于获取表单实例,进行校验const rules = reactive<FormRules>({ title: [ { required: true, message: '请输入文章标题', trigger: 'blur' }, { min: 3, max: 50, message: '标题长度在 3 到 50 个字符', trigger: 'blur' }, ], content: [ { required: true, message: '请输入文章内容', trigger: 'blur' }, { min: 10, message: '内容不能少于 10 个字符', trigger: 'blur' }, ]});
// 提交表单const submitForm = async () => { if (!articleFormRef.value) return;
try { // **核心修改:validate 方法直接返回 Promise,无需传入回调函数来获取有效性** // 如果校验通过,Promise 会 resolve // 如果校验不通过,Promise 会 reject await articleFormRef.value.validate();
// 如果代码执行到这里,说明表单校验通过 const response = await createArticle(articleForm);
ElNotification({ title: '成功', message: '文章创建成功!', type: 'success', });
resetForm(); console.log('创建成功的文章:', response);
} catch (fields) { // **如果校验不通过,validate Promise 会 reject,并在这里捕获错误信息** // fields 参数在这里是 ValidateFieldsError 类型,包含了所有校验失败的字段信息 console.error('表单校验失败:', fields); ElMessage.warning('请检查表单填写是否完整和正确!'); }};
// 重置表单const resetForm = () => { if (!articleFormRef.value) return; articleFormRef.value.resetFields(); // 重置并清除校验状态};
// 返回文章列表页const goBack = () => { router.push('/article/list');};</script>
<style scoped>.create-article-container { padding: 20px; max-width: 700px; margin: 20px auto; background-color: #fff; border-radius: 8px; box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);}
h1 { text-align: center; color: #333; margin-bottom: 30px;}
.article-form { padding: 20px;}
.el-form-item { margin-bottom: 22px;}
/* 调整 input-number 的宽度 */.el-input-number { width: 100%;}</style>路由
在文件夹router下打开index.ts
在const router = createRouter里添加
{ path: '/article/create', name: 'ArticleCreate', component: () => import('@/views/ArticleCreate.vue'), meta: { requiresAuth: true }}另起一行添加路由守卫
router.beforeEach((to, from, next) => { // 检查目标路由是否需要登录权限 if (to.meta.requiresAuth) { const token = localStorage.getItem('token'); // 检查本地是否有 token const curUser = localStorage.getItem('curUser'); // 检查本地是否有用户信息
if (token && curUser) { // 用户已登录,放行 next(); } else { // 用户未登录,给出提示并跳转到登录页 ElMessage.warning('请先登录才能访问此页面!'); next({ path: '/login', // 跳转到你的登录页 query: { redirect: to.fullPath } // 将当前路径作为查询参数,登录成功后可以跳转回来 }); } } else { // 路由不需要登录,直接放行 next(); }});路由守卫就是在进行路由前,统一都要运行一遍的代码,我们在这里用作检查用户是否登录,如果没有登录,那它就不能路由到创建文章这一视图
运行检查效果,结束。
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时









