1.1 前后端分离架构设计,构建现代化全栈开发体系
核心差异总结
| 特性 | 原生 JS | Vue 3 组合式 API |
|---|---|---|
| DOM 操作方式 | 直接选择和修改 | 通过 ref 和响应式系统 |
| 状态管理 | 全局变量 | 局部响应式 ref/reactive |
| 位置更新 | 手动调用函数 | 自动响应状态变化 |
| 代码可维护性 | 分散在多个函数中 | 集中在组件或自定义指令中 |
| 动画过渡 | 需要额外的 CSS 或 JS | 可通过绑定样式自动处理 |
通过这些方式,你可以在 Vue 3 中实现更简洁、更易维护的功能,同时保持与原生 JS 相同的视觉效果。
2.1 Vue.js初始化前端项目,快速实现标准化开发准备
初始化仿“小红书”前端项目
在工作目录下,执行
npm create vue@latest
这一指令将会安装并执行 create-vue,它是 Vue 官方的项目脚手架工具。创建过程中选择 TypeScript、Router、Pinia等功能:
3>npm create vue@latest
> npx
> create-vue
T Vue.js - The Progressive JavaScript Framework
|
o 请输入项目名称:
| rednote-ui
|
o 请选择要包含的功能: (↑/↓ 切换,空格选择,a 全选,回车确认)
| TypeScript, Router(单页面应用开发), Pinia(状态管理)
|
o 选择要包含的试验特性: (↑/↓ 切换,空格选择,a 全选,回车确认)
| none
正在初始化项目 D:\workspace\gitee\java-full-stack-engineer-system-course-video\samples\course18\ch3\rednote-ui...
|
— 项目初始化完成,可执行以下命令:
cd rednote-ui
npm install
npm run dev
| 可选:使用以下命令在项目目录中初始化 Git:
git init && git add -A && git commit -m "initial commit"
上面命令创建了一个名为“rednote-ui”的Vue.js项目。
清理项目回归纯净
清理资源文件
清理src\assets目录下的所有资源文件。
清理组件文件
清理src\components目录下的所有组件文件。
清理全局状态文件
清理src\stores目录下的所有全局状态文件。
修改路由文件
修改src\router\index.ts,内容如下:
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: HomeView,
}
],
})
export default router
修改视图
修改src\views\HomeView.vue,内容如下:
<script setup lang="ts">
</script>
<template>
<main>
<h1>rednote-ui</h1>
</main>
</template>
删除视图
删除视图src\views\AboutView.vue
修改App.vue
修改src\App.vue,内容如下:
<script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router'
</script>
<template>
<header>
<div class="wrapper">
<nav>
<RouterLink to="/">Home</RouterLink>
</nav>
</div>
</header>
<RouterView />
</template>
修改main.ts
修改src\main.ts,内容如下:
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
修改index.html
修改index.html引入静态资源,内容如下:
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RN</title>
<!-- 引入 Bootstrap CSS -->
<link href="/css/bootstrap.min.css" rel="stylesheet">
<!-- 引入 Font Awesome -->
<link href="/css/font-awesome.min.css" rel="stylesheet">
</head>
<body>
<div id="app"></div>
<!-- Bootstrap JS -->
<script src="/js/bootstrap.bundle.min.js"></script>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
使用静态资源
将原rednote项目中的src/main/resources/static静态资源复制到rednote-ui项目public目录下。
安装 Axios
Axios 是一个基于 Promise 的 HTTP 客户端,专为浏览器和 Node.js 设计,具有以下特性:
- 功能全面:支持 GET、POST、PUT、DELETE 等所有 HTTP 方法,提供请求/响应拦截器、取消请求、自动转换 JSON 数据等高级功能。
- Promise API:与 Vue 3 的 Composition API(如
async/await)完美兼容,代码简洁易读。 - 浏览器兼容性:支持 IE10+ 及现代浏览器,自动处理跨域请求和错误码。
- 社区支持:拥有庞大的社区和丰富的插件(如
axios-retry、axios-mock-adapter),问题解决效率高。
通过以下命令在项目中安装 Axios
npm install axios
启动开发服务器
在项目被创建后,通过以下步骤安装依赖并启动开发服务器:
cd rednote-ui
npm install
npm run dev
看到如下输出,则说明已经运行起来了你的第一个Vue.js项目了!
VITE v7.0.2 ready in 16458 ms
➜ Local: http://localhost:5173/
➜ Network: use --host to expose
➜ Vue DevTools: Open http://localhost:5173/__devtools__/ as a separate window
➜ Vue DevTools: Press Alt(⌥)+Shift(⇧)+D in App to toggle the Vue DevTools
➜ press h + enter to show help
项目默认运行在 http://localhost:5173,可以在浏览器中打开。
2.2 AI辅助编程工具成为Vue.js应用开发导师
本节介绍通AI辅助编程工具通义灵码在Visual Studio Code中的安装。让AI辅助编程工具成为Vue.js应用开发的导师。
手动安装步骤如下。
步骤1:已安装 Visual Studio Code 的情况下,在侧边导航上点击扩展。
步骤2:搜索通义灵码(TONGYI Lingma),找到通义灵码后点击安装。
步骤3:登录阿里云账号,即刻开启智能编码之旅。通义灵码界面如下。
3.1 全栈视角下的用户模块前后端划分与功能全流程剖析
将仿"小红书"项目的用户模块从 Thymeleaf 后端渲染迁移到 Vue 3 前端渲染,需要对架构、交互模式和数据流向进行全面调整。以下是核心改造点和实施建议。
架构层面的核心变化
1. 渲染模式转变
- Thymeleaf 模式: 浏览器 → HTTP请求 → 后端控制器 → Thymeleaf模板 → HTML响应
- Vue 3 模式: 浏览器 → 加载HTML骨架 → Vue初始化 → API请求 → 动态渲染
2. 数据交互方式
- 传统方式:表单提交/页面跳转
- Vue 方式:
// 用户登录示例 async login() { try { const { data } = await this.$axios.post('/api/users/login', { username: this.username, password: this.password }); this.$store.commit('setUser', data.user); this.$router.push('/home'); } catch (error) { this.$message.error(error.response.data.message); } }
渐进式迁移策略
-
先构建 API 层:
- 为现有用户模块开发 REST API 接口
- 确保新旧系统可以共存
-
组件级迁移:
- 先迁移独立组件(如登录表单)
- 再迁移完整页面(如个人主页、设置页面)
-
路由过渡:
- 逐步将 Thymeleaf 路由替换为 Vue Router
- 使用代理服务器处理新旧路由
-
状态管理整合:
- 在迁移期间保持 Session 和 Token 并存
- 确保用户在迁移过程中不会丢失会话
通过以上改造,用户模块将从后端渲染转变为前端渲染,实现更流畅的交互体验和更好的可维护性。关键是要处理好前后端分离后的状态管理、API 设计和渐进式迁移策略。
在 Vue 3 中实现分页序号生成
在 Vue 3 中,我们可以使用计算属性或方法来替代 Thymeleaf 的 ${#numbers.sequence(1, totalPage)} 功能。以下是几种实现方式:
方法一:使用计算属性生成页码数组
<template>
<div class="pagination">
<button @click="prevPage" :disabled="currentPage === 1">上一页</button>
<button
v-for="page in pageNumbers"
:key="page"
:class="{ active: page === currentPage }"
@click="goToPage(page)"
>
{{ page }}
</button>
<button @click="nextPage" :disabled="currentPage === totalPage">下一页</button>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
const currentPage = ref(1);
const totalPage = ref(10); // 从API获取或计算得到
// 计算属性生成页码数组
const pageNumbers = computed(() => {
return Array.from({ length: totalPage.value }, (_, i) => i + 1);
});
const prevPage = () => {
if (currentPage.value > 1) {
currentPage.value--;
}
};
const nextPage = () => {
if (currentPage.value < totalPage.value) {
currentPage.value++;
}
};
const goToPage = (page) => {
currentPage.value = page;
};
</script>
<style scoped>
.active {
background-color: #007bff;
color: white;
}
</style>
方法二:使用方法生成页码(带省略号)
<template>
<div class="pagination">
<button @click="prevPage" :disabled="currentPage === 1">上一页</button>
<button
v-for="page in getDisplayedPages"
:key="page"
:class="{ active: page === currentPage }"
@click="goToPage(page)"
>
{{ page === '...' ? page : page }}
</button>
<button @click="nextPage" :disabled="currentPage === totalPage">下一页</button>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
const currentPage = ref(1);
const totalPage = ref(15); // 从API获取或计算得到
const displayRange = ref(2); // 当前页面前后显示的页数
// 生成带省略号的页码数组
const getDisplayedPages = computed(() => {
const pages = [];
const start = Math.max(1, currentPage.value - displayRange.value);
const end = Math.min(totalPage.value, currentPage.value + displayRange.value);
// 添加第一页
pages.push(1);
// 添加左侧省略号
if (start > 2) {
pages.push('...');
}
// 添加中间页码
for (let i = start; i <= end; i++) {
pages.push(i);
}
// 添加右侧省略号
if (end < totalPage.value - 1) {
pages.push('...');
}
// 添加最后一页
if (totalPage.value > 1) {
pages.push(totalPage.value);
}
return pages;
});
// 其他方法保持不变
</script>
方法三:封装为可复用组件
<!-- Pagination.vue -->
<template>
<div class="pagination">
<button @click="prevPage" :disabled="currentPage === 1">上一页</button>
<button
v-for="page in getDisplayedPages"
:key="page"
:class="{ active: page === currentPage }"
@click="goToPage(page)"
>
{{ page === '...' ? page : page }}
</button>
<button @click="nextPage" :disabled="currentPage === totalPage">下一页</button>
</div>
</template>
<script setup>
import { ref, computed, defineProps, defineEmits } from 'vue';
const props = defineProps({
currentPage: {
type: Number,
required: true
},
totalPage: {
type: Number,
required: true
},
displayRange: {
type: Number,
default: 2
}
});
const emits = defineEmits(['page-change']);
// 生成带省略号的页码数组
const getDisplayedPages = computed(() => {
// 实现同上一个示例
});
const prevPage = () => {
if (props.currentPage > 1) {
emits('page-change', props.currentPage - 1);
}
};
const nextPage = () => {
if (props.currentPage < props.totalPage) {
emits('page-change', props.currentPage + 1);
}
};
const goToPage = (page) => {
if (page !== '...') {
emits('page-change', page);
}
};
</script>
在父组件中使用:
<template>
<div>
<Pagination
:currentPage="currentPage"
:totalPage="totalPage"
@page-change="handlePageChange"
/>
</div>
</template>
<script setup>
import { ref } from 'vue';
import Pagination from './components/Pagination.vue';
const currentPage = ref(1);
const totalPage = ref(10);
const handlePageChange = (page) => {
currentPage.value = page;
// 加载新页面的数据
fetchData(page);
};
</script>
Vue 3 与 Thymeleaf 的对比
| 功能 | Thymeleaf | Vue 3 |
|---|---|---|
| 生成页码数组 | ${#numbers.sequence(1, totalPage)} | Array.from({length: totalPage}, (_, i) => i + 1) |
| 条件渲染 | th:if | v-if |
| 循环渲染 | th:each | v-for |
| 事件处理 | th:onclick | @click |
| 样式绑定 | th:classappend | :class |
| 文本绑定 | th:text | v-text |
3.2 全栈实战注册功能:从需求分析到接口联调的全栈开发流程
将用户注册功能从 Thymeleaf 后端渲染模式迁移到 Vue 3 前端渲染模式,需要对架构、交互流程和安全机制进行全面调整。以下是核心改造点和实施建议:
架构与交互模式的核心变化
1. 数据流转方式
- Thymeleaf 模式: 浏览器 → 表单提交 → 后端控制器 → 验证 → 重定向/错误页面
- Vue 3 模式: 浏览器 → Vue表单组件 → API请求 → 后端验证 → JSON响应 → 前端处理结果
2. 接口实现方式
-
Thymeleaf 模式:
- 表单提交场景(application/x-www-form-urlencoded)
- 适用场景:传统表单提交,非 JSON 数据
- 使用方式:@ModelAttribute + @Valid
-
Vue 3 模式:
- JSON 请求场景(application/json)
- 适用场景:前后端分离的 REST API
- 使用方式:@RequestBody + @Valid
后端接口改造
修改 AuthController.java:
/**
* 处理注册表单提交
*/
@PostMapping("/register")
/*public String processRegistrationForm(@Valid @ModelAttribute("user") UserRegistrationDto registrationDto,
BindingResult bindingResult,
Model model) {
// 检查用户名是否已存在
if (userService.existsByUsername(registrationDto.getUsername())) {
bindingResult.rejectValue("username", null, "该用户名已被使用");
}
// 检查手机号是否已注册
if (userService.existsByPhone(registrationDto.getPhone())) {
bindingResult.rejectValue("phone", null, "该手机号已被注册");
}
// 检查手机验证码是否校验通过
if (!userService.verifyCode(registrationDto.getPhone(), registrationDto.getVerificationCode())) {
bindingResult.rejectValue("verificationCode", null, "验证码不正确");
}
// 检查用户名、手机号、验证码是否通过
// 如果有错误,则返回注册页面
if (bindingResult.hasErrors()) {
model.addAttribute("user", registrationDto);
return "registration-form";
}
// 注册用户
userService.registerUser(registrationDto);
// 注册成功,跳转到登录页面
return "redirect:/auth/login";
}*/
public ResponseEntity<?> processRegistrationForm(@Valid @RequestBody UserRegistrationDto registrationDto,
BindingResult bindingResult) {
// 检查用户名是否已存在
if (userService.existsByUsername(registrationDto.getUsername())) {
bindingResult.rejectValue("username", null, "该用户名已被使用");
}
// 检查手机号是否已注册
if (userService.existsByPhone(registrationDto.getPhone())) {
bindingResult.rejectValue("phone", null, "该手机号已被注册");
}
// 检查手机验证码是否校验通过
if (!userService.verifyCode(registrationDto.getPhone(), registrationDto.getVerificationCode())) {
bindingResult.rejectValue("verificationCode", null, "验证码不正确");
}
// 检查用户名、手机号、验证码是否通过
// 如果有错误,则返回错误列表
if (bindingResult.hasErrors()) {
// 自定义错误响应
Map<String, String> errors = new HashMap<>();
bindingResult.getFieldErrors().forEach(error ->
errors.put(error.getField(), error.getDefaultMessage())
);
return ResponseEntity.badRequest().body(errors);
}
// 注册用户
userService.registerUser(registrationDto);
// 注册成功
return ResponseEntity.ok("用户注册成功");
}
后端安全配置调整
- 禁用CSRF防护
- 会话管理使用无状态会话
修改WebSecurityConfig如下:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// 启用CSRF防护
// .csrf(Customizer.withDefaults())
// 禁用CSRF防护
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(authorize -> authorize
// 允许指定资源的请求不需要认证
.requestMatchers("/auth/register", "/auth/login", "/css/**", "/js/**", "/fonts/**", "/images/**", "/favicon.ico").permitAll()
.requestMatchers("/error/**").permitAll()
// 允许ADMIN角色的用户访问 /admin/** 的资源
.requestMatchers("/admin/**").hasRole("ADMIN")
// 允许ADMIN、USER角色的用户访问 /user/** 的资源
.requestMatchers("/user/**").hasAnyRole("ADMIN", "USER")
// 允许USER角色的用户访问 /note/** 的资源
.requestMatchers("/note/**").hasRole("USER")
// 允许USER角色的用户访问 /explore/** 的资源
.requestMatchers("/explore/**").hasRole("USER")
// 允许USER角色的用户访问 /like/** 的资源
.requestMatchers("/like/**").hasRole("USER")
// 允许USER角色的用户访问 /comment/** 的资源
.requestMatchers("/comment/**").hasRole("USER")
// 允许USER角色的用户访问 /log/** 的资源
.requestMatchers("/log/**").hasRole("USER")
// 允许ADMIN、USER角色的用户访问 /file/** 的资源
.requestMatchers("/file/**").hasAnyRole("ADMIN", "USER")
// 其他请求需求认证
.anyRequest().authenticated()
)
.formLogin(form -> form
// 指定登录页面
.loginPage("/auth/login")
// 指定执行登录的地址
.loginProcessingUrl("/auth/login")
// 自定义失败处理器
.failureHandler(authenticationFailureHandler())
// 指定登录成功后跳转的页面
.defaultSuccessUrl("/")
.permitAll()
)
// 异常处理
.exceptionHandling(exception -> exception
// 指定403错误页面
.accessDeniedPage("/error/403")
)
// 会话管理
/*.sessionManagement(session -> session
// 会话创建策略
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
// 访问无效会话时,重定向到指定URL
.invalidSessionUrl("/auth/login?error=" + SESSION_INVALID)
// 同一用户最大会话数
.maximumSessions(1)
// 访问过期会话时,重定向到指定URL
.expiredUrl("/auth/login?error=" + SESSION_EXPIRED)
// false表示允许新登录,踢掉旧会话,旧会话会过期
.maxSessionsPreventsLogin(false)
// 会话注册表
.sessionRegistry(sessionRegistry())
)*/
// 无状态会话
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
// 注销
.logout(logout -> logout
// 清理会话
.invalidateHttpSession( true)
// 清理认证信息
.clearAuthentication(true)
// 用户访问此URL时,交由Spring Security处理注销逻辑
.logoutUrl("/logout")
// 注销成功后,重定向到指定URL
.logoutSuccessUrl("/auth/login?error=" + LOGOUT)
// 删除会话Cookie
.deleteCookies("JSESSIONID")
)
// 记住我
.rememberMe(rememberMe -> rememberMe
// 设置记住我令牌的有效期(秒),默认是2周。以下设置1周
.tokenValiditySeconds(60 * 60 * 24 * 7)
// 设置用于签名令牌的密钥
.key("rnRememberMeKey")
)
;
return http.build();
}
// ...为节约篇幅,此处省略非核心内容
/*@Bean
public SessionRegistry sessionRegistry() {
return new SessionRegistryImpl();
}*/
3.3 AI辅助编程快速实现注册页面及与后端API联调
前端新增错误验证接口类似
新建src\errors\api-validation-error.ts,代码如下:
export interface ApiValidationError {
[field: string]: string;
}
前端新增注册表单组件
新建 src\views\RegistrationForm.vue,相关代码可以从后端应用的src/main/resources/templates/registration-form.html拷贝过来进行微调即可。调整后代码如下:
<script setup lang="ts">
import type { ApiValidationError } from '@/errors/api-validation-error'
import { ref, onUnmounted } from 'vue'
import axios, { AxiosError } from 'axios'
import { useRouter } from 'vue-router'
const form = ref({
username: '',
phone: '',
verificationCode: '',
password: ''
})
// 错误信息使用ApiValidationError类型
const errors = ref<ApiValidationError>({})
// 获取router实例
const router = useRouter()
// 注册逻辑
const handleRegister = async () => {
// 重置错误信息
errors.value = {}
try {
// 发送注册请求
await axios.post('/api/auth/register', form.value)
// 提示注册成功
alert('注册成功,请登录')
// 重置错误信息
errors.value = {}
// 跳转到登录页面
router.push({ name: 'login' })
} catch (error) {
// 验证码发送失败
if (error instanceof AxiosError) {
// 获取错误信息
const axiosError = error as AxiosError<ApiValidationError>
if (axiosError.response?.status === 400 && axiosError.response.data) {
// 绑定后端返回的错误信息到errors上
errors.value = axiosError.response.data
}
}
}
}
// 验证码倒计时相关状态
const countdown = ref(60)
const timer = ref<number | null>(null)
const isCounting = ref(false)
// 获取验证码倒计时函数
const startCountdown = () => {
if (countdown.value === 60 && !isCounting.value) {
isCounting.value = true
timer.value = window.setInterval(() => {
countdown.value--
if (countdown.value === 0) {
clearInterval(timer.value!)
countdown.value = 60
isCounting.value = false
}
}, 1000)
}
}
// 组件卸载时清理定时器
onUnmounted(() => {
if (timer.value) {
clearInterval(timer.value)
}
})
</script>
<template>
<div class="container align-items-center min-vh-100 py-4">
<div class="form-container">
<!-- Logo -->
<div class="logo">
<img src="/images/rn_avatar.png" alt="Logo" class="rounded-circle">
</div>
<!-- 表单标题 -->
<h2 class="form-title">欢迎注册RN</h2>
<!-- 注册表单 -->
<form id="registrationForm" method="post" @submit.prevent="handleRegister">
<!-- 用户名输入框 -->
<div class="mb-3">
<input type="text" class="form-control" id="username" name="username" v-model="form.username"
placeholder="请设置用户名" required>
<div class="error-message" id="usernameError" v-if="errors.username">{{ errors.username }}</div>
</div>
<!-- 手机号输入框 -->
<div class="mb-3">
<input type="text" class="form-control" id="phone" name="phone" v-model="form.phone" placeholder="请输入手机号"
required>
<div class="error-message" id="phoneError" v-if="errors.phone">{{ errors.phone }}</div>
</div>
<!-- 验证码输入框 -->
<div class="mb-3">
<div class="input-group">
<input type="text" class="form-control" id="verificationCode" name="verificationCode"
v-model="form.verificationCode" placeholder="请输入验证码" required>
<button type="button" class="btn btn-outline-secondary" id="getCodeBtn" @click="startCountdown"
:disabled="isCounting">
{{ isCounting ? countdown + '秒后重新获取' : '获取验证码' }}
</button>
</div>
<div class="error-message" id="verificationCodeError" v-if="errors.verificationCode">{{
errors.verificationCode }}</div>
</div>
<!-- 密码输入框 -->
<div class="mb-3">
<input type="password" class="form-control" id="password" name="password" v-model="form.password"
placeholder="请设置密码" required>
<div class="error-message" id="passwordError" v-if="errors.password">{{ errors.password }}</div>
</div>
<!--注册按钮 -->
<button class="btn btn-primary w-100" type="submit">立即注册</button>
</form>
<!-- 已有账号 -->
<div class="form-footer">
已有账号? <a href="/auth/login">立即登录</a>
</div>
<!-- 其他登录方式 -->
<div class=" divider">
<span>其他登录方式</span>
</div>
<!-- 社交登录 -->
<div class="social-login">
<a href="#" class="social-btn">
<i class="fa fa-weixin"></i>
</a>
<a href="#" class="social-btn">
<i class="fa fa-weibo"></i>
</a>
<a href="#" class="social-btn">
<i class="fa fa-qq"></i>
</a>
</div>
<!-- 用户协议、隐藏政策 -->
<div class="policy">
注册即表示同意<a href="#">用户协议</a>和<a href="#">隐藏政策</a>
</div>
</div>
</div>
</template>
<style setup>
body {
background-color: #fef6f6;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
.form-container {
background-color: white;
border-radius: 16px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
padding: 32px;
max-width: 400px;
margin: 0 auto;
}
.logo {
text-align: center;
margin-bottom: 32px;
}
.logo img {
width: 64px;
height: 64px;
}
.form-title {
font-size: 24px;
font-weight: 700;
color: #333;
margin-bottom: 24px;
text-align: center;
}
.form-control {
border-radius: 12px;
border: 1px solid #e8e8e8;
padding: 12px 16px;
height: auto;
font-size: 14px;
}
.form-control:focus {
border-color: #ff2442;
box-shadow: 0 0 0 2px rgba(255, 36, 66, 0.1);
}
.btn-primary {
background-color: #ff2442;
border-color: #ff2442;
border-radius: 12px;
padding: 12px;
font-size: 16px;
font-weight: 600;
transition: all 0.3s ease;
}
.btn-primary:hover,
.btn-primary:focus {
background-color: #e61e3a;
border-color: #e61e3a;
box-shadow: 0 4px 12px rgba(255, 36, 66, 0.2);
}
.btn-outline-secondary {
border-radius: 12px;
padding: 12px;
font-size: 14px;
color: #666;
border-color: #e8e8e8;
}
.btn-outline-secondary:hover {
background-color: #f8f8f8;
border-color: #ddd;
}
.form-footer {
text-align: center;
margin-top: 24px;
font-size: 14px;
color: #666;
}
.form-footer a {
color: #ff2442;
text-decoration: none;
}
.form-footer a:hover {
text-decoration: underline;
}
.divider {
display: flex;
align-items: center;
margin: 24px 0;
color: #999;
font-size: 14px;
}
.divider::before,
.divider::after {
content: '';
flex: 1;
border-bottom: 1px solid #e8e8e8;
}
.divider::before {
margin-right: 16px;
}
.divider::after {
margin-left: 16px;
}
.social-login {
display: flex;
justify-content: center;
gap: 24px;
margin-top: 24px;
}
.social-btn {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #e8e8e8;
transition: all 0.3s ease;
}
.social-btn:hover {
background-color: #f8f8f8;
transform: translateY(-2px);
}
.social-btn i {
font-size: 20px;
color: #666;
}
.policy {
font-size: 12px;
color: #999;
text-align: center;
margin-top: 16px;
}
.policy a {
color: #999;
text-decoration: underline;
}
.error-message {
color: #ff2442;
font-size: 12px;
margin-top: 4px;
/* display: none; */
}
</style>
设置反向代理
// 设置反向代理
server: {
host: 'localhost',
port: 5173, // Vue开发端口
proxy: {
'/api': {
// 指向Spring Boot后端地址(假设后端运行在8080端口)
target: 'http://localhost:8080',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
}
配置说明
/api:代理路径前缀,前端请求以/api开头的 URL 会被转发- target:Spring Boot 后端的基础地址
- changeOrigin:设置为 true 以支持跨域请求
- rewrite:去除 URL 中的
/api前缀,确保后端正确接收路径
修改路由
修改路由文件src\router\index.ts,内容如下:
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: HomeView,
},
{
path: '/auth/register',
name: 'register',
// 当访问该路径时,它被延迟加载
component: () => import('../views/RegistrationForm.vue'),
},
],
})
export default router
修改App.vue
修改src\App.vue文件,删除<header>,内容如下:
<script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router'
</script>
<template>
<!--
<header>
<div class="wrapper">
<nav>
<RouterLink to="/">Home</RouterLink>
</nav>
</div>
</header>
-->
<RouterView />
</template>
运行调测
运行应用执行注册,注册失败界面效果如下图4-1所示。
注册成功界面效果如下图4-2所示。
通过以上改造,用户注册功能将从后端渲染转变为前端渲染,实现更流畅的交互体验和更好的可维护性。关键是要处理好前后端分离后的安全机制、表单验证和用户体验优化。
3.4 全栈实战登录功能:密码加密与 Token 认证的攻防实战
将用户登录功能从 Thymeleaf 后端渲染模式迁移到 Vue 3 前端渲染模式,需要对架构、交互流程和安全机制进行全面调整。以下是核心改造点和实施建议。
一、架构与交互模式的核心变化
1. 数据流转方式
- Thymeleaf 模式: 浏览器 → 表单提交 → 后端控制器 → 验证 → Session 存储 → 重定向到主页
- Vue 3 模式: 浏览器 → Vue 组件 → API 请求 → 后端验证 → JWT/会话令牌 → 前端存储 → 路由跳转
2. 安全机制调整
- CSRF 防护:
- Thymeleaf:自动注入 CSRF 令牌到表单
- Vue 3:禁用 CSRF 令牌
- 认证方式:
- Thymeleaf:基于 Session/Cookie
- Vue 3:推荐 JWT(JSON Web Token)或增强的 Session 机制
后端接口改造
修改 AuthController.java:
import com.waylau.rednote.config.JwtTokenProvider;
import org.springframework.security.authentication.AuthenticationManager;
// ...为节约篇幅,此处省略非核心内容
@Autowired
private JwtTokenProvider jwtTokenProvider;
@Autowired
private AuthenticationManager authenticationManager;
// ...为节约篇幅,此处省略非核心内容
/**
* 处理登录表单的提交
*/
@PostMapping("/login")
/*public String processLoginForm(@Valid @ModelAttribute("user") UserLoginDto loginDto,
BindingResult bindingResult,
Model model) {
// 检查用户名是否存在
if (!userService.existsByUsername(loginDto.getUsername())) {
bindingResult.rejectValue("username", null, "该用户名未注册");
model.addAttribute("user", loginDto);
return "login-form";
}
// 检查密码是否正确
if (!userService.verifyPassword(loginDto.getUsername(), loginDto.getPassword())) {
bindingResult.rejectValue("password", null, "密码错误");
model.addAttribute("user", loginDto);
return "login-form";
}
// 登录成功,重定向到首页或
return "redirect:/";
}*/
public ResponseEntity<?> processLoginForm(@Valid @RequestBody UserLoginDto loginDto,
BindingResult bindingResult) {
// 检查用户名是否存在
if (!userService.existsByUsername(loginDto.getUsername())) {
bindingResult.rejectValue("username", null, "该用户名未注册");
// 自定义错误响应
Map<String, String> errors = new HashMap<>();
bindingResult.getFieldErrors().forEach(error ->
errors.put(error.getField(), error.getDefaultMessage())
);
return ResponseEntity.badRequest().body(errors);
}
// 检查密码是否正确
if (!userService.verifyPassword(loginDto.getUsername(), loginDto.getPassword())) {
bindingResult.rejectValue("password", null, "密码错误");
// 自定义错误响应
Map<String, String> errors = new HashMap<>();
bindingResult.getFieldErrors().forEach(error ->
errors.put(error.getField(), error.getDefaultMessage())
);
return ResponseEntity.badRequest().body(errors);
}
// 获取认证用户
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
loginDto.getUsername(),
loginDto.getPassword()
)
);
// 校验成功,生成JWT
String jwt = jwtTokenProvider.generateToken(authentication);
// 返回响应
return ResponseEntity.ok(jwt);
}
后端安全配置调整
- 取消.formLogin()
- 取消.rememberMe()
- 取消.logout()
- 启用 JWT 认证过滤器
- 配置 AuthenticationManager Bean
修改WebSecurityConfig如下:
import org.springframework.security.authentication.AuthenticationManager;
// ...为节约篇幅,此处省略非核心内容
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// ...为节约篇幅,此处省略非核心内容
/*.formLogin(form -> form
// 指定登录页面
.loginPage("/auth/login")
// 指定执行登录的地址
.loginProcessingUrl("/auth/login")
// 自定义失败处理器
.failureHandler(authenticationFailureHandler())
// 指定登录成功后跳转的页面
.defaultSuccessUrl("/")
.permitAll()
)*/
// ...为节约篇幅,此处省略非核心内容
// 注销
/*.logout(logout -> logout
// 清理会话
.invalidateHttpSession( true)
// 清理认证信息
.clearAuthentication(true)
// 用户访问此URL时,交由Spring Security处理注销逻辑
.logoutUrl("/logout")
// 注销成功后,重定向到指定URL
.logoutSuccessUrl("/auth/login?error=" + LOGOUT)
// 删除会话Cookie
.deleteCookies("JSESSIONID")
)*/
// 记住我
/*.rememberMe(rememberMe -> rememberMe
// 设置记住我令牌的有效期(秒),默认是2周。以下设置1周
.tokenValiditySeconds(60 * 60 * 24 * 7)
// 设置用于签名令牌的密钥
.key("rnRememberMeKey")
)*/
// 启用JWT认证过滤器
.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
;
return http.build();
}
// ...为节约篇幅,此处省略非核心内容
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() {
return new JwtAuthenticationFilter();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
JWT 认证实现
添加 JJWT (Java JWT) 库的依赖。Jwts 类是 JJWT 库的核心类,用于创建、解析和验证 JWT 令牌。以下是解决方案。
1. Maven 项目
在 pom.xml 中添加:
<properties>
<java.version>24</java.version>
<jsonwebtoken.version>0.13.0</jsonwebtoken.version>
</properties>
<!-- ...为节约篇幅,此处省略非核心内容 -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jsonwebtoken.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jsonwebtoken.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jsonwebtoken.version}</version>
<scope>runtime</scope>
</dependency>
2. JWT 工具类
新建src/main/java/com/waylau/rednote/config/JwtTokenProvider.java:
package com.waylau.rednote.config;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import java.util.Date;
/**
* JwtTokenProvider JWT工具类
*
* @author <a href="https://waylau.com">Way Lau</a>
* @version 2025/09/08
**/
@Component
public class JwtTokenProvider {
private static final Logger log = LoggerFactory.getLogger(JwtTokenProvider.class);
@Value("${app.jwtSecret}")
private String jwtSecret;
@Value("${app.jwtExpirationMs}")
private long jwtExpirationMs;
/**
* 生成JWT
*
* @param authentication
* @return
*/
public String generateToken(Authentication authentication) {
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
return Jwts.builder()
.setSubject(userDetails.getUsername())
.setIssuedAt(new Date())
.setExpiration(new Date(new Date().getTime() + jwtExpirationMs))
.signWith(SignatureAlgorithm.HS256, jwtSecret)
.compact();
}
/**
* 从JWT中获取用户名
*
* @param token
* @return
*/
public String getUsernameFromJwtToken(String token) {
return Jwts.parser()
.setSigningKey(jwtSecret)
.build()
.parseClaimsJws(token)
.getBody()
.getSubject();
}
/**
* 验证JWT
*
* @param authToken
* @return
*/
public boolean validateJwtToken(String authToken) {
try {
Jwts.parser()
.setSigningKey(jwtSecret)
.build()
.parseClaimsJws(authToken);
return true;
} catch (Exception e) {
log.error("Invalid JWT token: {}", e.getMessage());
}
return false;
}
}
3. JWT 认证过滤器
新建src/main/java/com/waylau/rednote/config/JwtAuthenticationFilter.java:
package com.waylau.rednote.config;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
/**
* JwtAuthenticationFilter JWT认证过滤器
*
* @author <a href="https://waylau.com">Way Lau</a>
* @version 2025/09/08
**/
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private JwtTokenProvider jwtTokenProvider;
@Autowired
private UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// 从请求头中获取JWT
String jwt = getJwtFromRequest(request);
if (jwt != null && jwtTokenProvider.validateJwtToken(jwt)) {
// 从JWT中获取用户名
String username = jwtTokenProvider.getUsernameFromJwtToken(jwt);
// 从用户名中获取用户详情
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
// 创建一个已认证的Authentication对象
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities()
);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
// 设置已认证的Authentication对象到SecurityContextHolder中
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
/**
* 从请求头中获取JWT
*/
private String getJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
4. 配置文件
在 application.properties 中添加 JWT 配置:
# 配置 JWT
# 你的Base64编码密钥(至少256位)
app.jwtSecret=bQUBj9U7io0VXuhlaC9XmeaSGSwkqOlG4itHzIgUvOk=
# 24小时
app.jwtExpirationMs=86400000
生成 Base64 密钥的方法:
package com.waylau.rednote;
import io.jsonwebtoken.security.Keys;
import java.util.Base64;
/**
* JwtSecretGenerator 生成Base64密钥
*
* @author <a href="https://waylau.com">Way Lau</a>
* @version 2025/09/08
**/
public class JwtSecretGenerator {
public static void main(String[] args) {
String secret = Base64.getEncoder().encodeToString(
Keys.secretKeyFor(io.jsonwebtoken.SignatureAlgorithm.HS256).getEncoded()
);
System.out.println(secret);
}
}
3.5 AI辅助编程快速实现登录页面及与后端API联调
前端新增登录表单组件
新建 src\views\LoginForm.vue,相关代码可以从后端应用的src/main/resources/templates/login-form.html拷贝过来进行微调即可调整后代码如下:
<script setup lang="ts">
import type { ApiValidationError } from '@/errors/api-validation-error'
import { ref } from 'vue'
import axios, { AxiosError } from 'axios'
import { useRouter } from 'vue-router'
const form = ref({
username: '',
password: ''
})
// 错误信息使用ApiValidationError类型
const errors = ref<ApiValidationError>({})
// 获取router实例
const router = useRouter()
// 登录逻辑
const handleLogin = async () => {
// 重置错误信息
errors.value = {}
try {
// 发送登录请求
const response = await axios.post('/api/auth/login', form.value)
// 存储JWT到localStorage中
localStorage.setItem('token', response.data)
// 重置错误信息
errors.value = {}
// 跳转到主页页面
router.push({ name: 'home' })
} catch (error) {
// 登录失败
if (error instanceof AxiosError) {
// 获取错误信息
const axiosError = error as AxiosError<ApiValidationError>
if (axiosError.response?.status === 400 && axiosError.response.data) {
// 绑定后端返回的错误信息到errors上
errors.value = axiosError.response.data
}
}
}
}
const showPassword = ref(false)
</script>
<template>
<div class="container align-items-center min-vh-100 py-4">
<div class="form-container">
<!-- Logo -->
<div class="logo">
<img src="/images/rn_avatar.png" alt="Logo" class="rounded-circle">
</div>
<!-- 表单标题 -->
<h2 class="form-title">欢迎登录RN</h2>
<!-- 注册表单 -->
<form id="loginForm" method="post" @submit.prevent="handleLogin">
<!-- 用户名输入框 -->
<div class="mb-3">
<input type="text" class="form-control" id="username" name="username" v-model="form.username"
placeholder="请输入用户名" required>
<div class="error-message" id="usernameError" v-if="errors.username">{{ errors.username }}</div>
</div>
<!-- 密码输入框 -->
<div class="mb-3">
<div class="input-group">
<input :type="showPassword ? 'text' : 'password'" class="form-control" id="password" name="password"
v-model="form.password" placeholder="请设置密码" required>
<!-- 切换密码显示模式 -->
<button type="button" class="btn btn-outline-secondary" id="togglePassword"
@click="showPassword = !showPassword">
<i :class="showPassword ? 'fa fa-eye' : 'fa fa-eye-slash'"></i>
</button>
</div>
<div class="error-message" id="passwordError" v-if="errors.password">{{ errors.password }}</div>
</div>
<!-- 记住我 -->
<div class="form-check mb-3">
<input type="checkbox" class="form-check-input" id="rememberMe" name="remember-me">
<label class="form-check-label" for="rememberMe">记住我</label>
</div>
<!--登录按钮 -->
<button class="btn btn-primary w-100" type="submit">登录</button>
</form>
</div>
<!-- 忘记密码 -->
<div class="form-footer">
<a href="#">忘记密码</a>
</div>
<!-- 其他登录方式 -->
<div class="divider">
<span>其他登录方式</span>
</div>
<!-- 社交登录 -->
<div class="social-login">
<a href="#" class="social-btn">
<i class="fa fa-weixin"></i>
</a>
<a href="#" class="social-btn">
<i class="fa fa-weibo"></i>
</a>
<a href="#" class="social-btn">
<i class="fa fa-qq"></i>
</a>
</div>
<!-- 注册链接 -->
<div class="form-footer">
还没有账号? <a href="/auth/register">立即注册</a>
</div>
<!-- 用户协议、隐藏政策 -->
<div class="policy">
注册即表示同意<a href="#">用户协议</a>和<a href="#">隐藏政策</a>
</div>
</div>
</template>
<style setup>
body {
background-color: #fef6f6;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
.form-container {
background-color: white;
border-radius: 16px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
padding: 32px;
max-width: 400px;
margin: 0 auto;
}
.logo {
text-align: center;
margin-bottom: 32px;
}
.logo img {
width: 64px;
height: 64px;
}
.form-title {
font-size: 24px;
font-weight: 700;
color: #333;
margin-bottom: 24px;
text-align: center;
}
.form-control {
border-radius: 12px;
border: 1px solid #e8e8e8;
padding: 12px 16px;
height: auto;
font-size: 14px;
}
.form-control:focus {
border-color: #ff2442;
box-shadow: 0 0 0 2px rgba(255, 36, 66, 0.1);
}
.btn-primary {
background-color: #ff2442;
border-color: #ff2442;
border-radius: 12px;
padding: 12px;
font-size: 16px;
font-weight: 600;
transition: all 0.3s ease;
}
.btn-primary:hover,
.btn-primary:focus {
background-color: #e61e3a;
border-color: #e61e3a;
box-shadow: 0 4px 12px rgba(255, 36, 66, 0.2);
}
.btn-outline-secondary {
border-radius: 12px;
padding: 12px;
font-size: 14px;
color: #666;
border-color: #e8e8e8;
}
.btn-outline-secondary:hover {
background-color: #f8f8f8;
border-color: #ddd;
}
.form-footer {
text-align: center;
margin-top: 24px;
font-size: 14px;
color: #666;
}
.form-footer a {
color: #ff2442;
text-decoration: none;
}
.form-footer a:hover {
text-decoration: underline;
}
.divider {
display: flex;
align-items: center;
margin: 24px 0;
color: #999;
font-size: 14px;
}
.divider::before,
.divider::after {
content: '';
flex: 1;
border-bottom: 1px solid #e8e8e8;
}
.divider::before {
margin-right: 16px;
}
.divider::after {
margin-left: 16px;
}
.social-login {
display: flex;
justify-content: center;
gap: 24px;
margin-top: 24px;
}
.social-btn {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #e8e8e8;
transition: all 0.3s ease;
}
.social-btn:hover {
background-color: #f8f8f8;
transform: translateY(-2px);
}
.social-btn i {
font-size: 20px;
color: #666;
}
.policy {
font-size: 12px;
color: #999;
text-align: center;
margin-top: 16px;
}
.policy a {
color: #999;
text-decoration: underline;
}
.error-message {
color: #ff2442;
font-size: 12px;
margin-top: 4px;
/* display: none; */
}
</style>
修改路由
修改路由文件src\router\index.ts,内容如下:
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
// ...为节约篇幅,此处省略非核心内容
,
{
path: '/auth/login',
name: 'login',
component: () => import('../views/LoginForm.vue'),
},
],
})
export default router
运行调测
运行应用执行登录,登录失败界面效果如下图4-3所示。
登录成功界面效果如下图4-4所示。
通过以上改造,用户登录功能将从后端渲染转变为前端渲染,实现更流畅的交互体验和更好的可维护性。关键是要处理好前后端分离后的安全机制、状态管理和用户体验优化。
3.6 前端全局认证状态管理与路由守卫
后端接口改造
UserController 接口改造如下:
@GetMapping("/profile")
/*public String profile(Model model) {
// 获取当前用户信息
User user = userService.getCurrentUser();
*//*model.addAttribute("user", user);
return "user-profile";*//*
// 重定向
return "redirect:/user/profile/" + user.getUserId();
}*/
public ResponseEntity<User> profile() {
// 获取当前用户信息
User user = userService.getCurrentUser();
return ResponseEntity.ok(user);
}
使用 Pinia 管理认证状态
新建认证状态管理文件src\stores\auth.ts:
import { defineStore } from "pinia"
import { useRouter } from "vue-router"
import axios from "axios"
export const useAuthStore = defineStore("auth", {
state: () => ({
user: null,
token: localStorage.getItem("token") || null,
isAuthenticated: false,
}),
getters: {
getUser: (state) => state.user,
getToken: (state) => state.token,
getIsAuthenticated: (state) => state.isAuthenticated,
},
actions: {
// 登录
async login(username: string, password: string) {
try {
const response = await axios.post("/api/auth/login", {
username,
password,
})
this.token = response.data
if (this.token) {
localStorage.setItem("token", this.token)
this.isAuthenticated = true
axios.defaults.headers.common['Authorization'] = `Bearer ${this.token}`
// 获取用户信息
await this.fetchUser()
return true;
} else {
localStorage.removeItem("token")
this.isAuthenticated = false
return false;
}
} catch (error) {
this.logout()
throw error
}
},
// 获取用户信息
async fetchUser() {
try {
const response = await axios.get("/api/user/profile")
this.user = response.data
} catch (error) {
this.logout()
throw error
}
},
// 注销
logout() {
this.user = null
this.token = null
this.isAuthenticated = false;
localStorage.removeItem('token')
axios.defaults.headers.common['Authorization'] = null
// 跳转到登录页面
const router = useRouter()
router.push({ name: 'login' })
},
// 检查认证状态(比如页面刷新后恢复)
async checkAuth() {
const storedToken = localStorage.getItem('token')
if (storedToken) {
this.token = storedToken
this.isAuthenticated = true
await this.fetchUser()
}
}
}
})
在组件中使用认证状态
修改 src\views\LoginForm.vue:
import { useAuthStore } from '@/stores/auth'
// 获取useAuthStore实例
const authStore = useAuthStore()
// ...为节约篇幅,此处省略非核心内容
// 登录逻辑
const handleLogin = async () => {
// 重置错误信息
errors.value = {}
try {
// 发送登录请求
/*
const response = await axios.post('/api/auth/login', form.value)
// 存储JWT到localStorage中
localStorage.setItem('token', response.data)
*/
await authStore.login(form.value.username, form.value.password)
// 重置错误信息
errors.value = {}
// 跳转到主页页面
router.push({ name: 'home' })
} catch (error) {
// ...为节约篇幅,此处省略非核心内容
}
}
路由守卫配置
修改路由文件src\router\index.ts,内容如下:
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
import { useAuthStore } from '@/stores/auth'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: HomeView,
// 需要认证的路由
meta: {
requiresAuth: true
}
},
{
path: '/auth/register',
name: 'register',
// 当访问该路径时,它被延迟加载
component: () => import('../views/RegistrationForm.vue'),
},
{
path: '/auth/login',
name: 'login',
component: () => import('../views/LoginForm.vue'),
},
],
})
// 全局前置守卫
router.beforeEach(async (to, from, next) => {
// 获取useAuthStore实例
const authStore = useAuthStore()
// 检查是否需要认证
if (to.meta.requiresAuth && !authStore.getIsAuthenticated) {
// 跳转到登录页面
return next({ name: 'login' })
}
console.log('authStore.getUser', authStore.getUser)
console.log('authStore.getIsAuthenticated', authStore.getIsAuthenticated)
// 如果用户已登录,但没有加载用户信息,则先加载用户信息
if (authStore.getIsAuthenticated && !authStore.getUser) {
try {
await authStore.fetchUser()
next()
} catch (error) {
authStore.logout()
next({ name: 'login' })
}
} else {
next()
}
})
export default router
自动刷新令牌
修改认证状态管理文件src\stores\auth.ts,增加如下内容:
// ...为节约篇幅,此处省略非核心内容
// axios拦截器,自动刷新JWT
axios.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
应用启动时恢复认证状态
修改src\App.vue:
<script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
// 应用启动时检查用户是否已登录
const authStore = useAuthStore()
authStore.checkAuth()
</script>
// ...为节约篇幅,此处省略非核心内容
运行调测
运行应用在未执行登录的情况下访问首页,则会直接重定向到登录界面,效果如下图4-5所示。
3.7 全栈实战信息管理功能:实现用户信息展示
将用户信息管理功能从 Thymeleaf 后端渲染模式迁移到 Vue 3 前端渲染模式,需要对数据流转、组件设计、API 接口和安全机制进行全面调整。以下是核心改造点和实施建议。
架构与交互模式的核心变化
-
Thymeleaf 模式: 浏览器 → 表单提交 → 后端控制器 → 数据库操作 → 重定向到详情页
-
Vue 3 模式: 浏览器 → Vue 组件 → API 请求 → 后端服务 → JSON 响应 → 前端更新视图
后端接口改造
修改UserController:
// 获取用户笔记列表数据在界面上展示
@GetMapping("/profile/{userId}")
/*public String profileWithNotes(Model model,
@PathVariable Long userId,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "12") int size) {
// 获取当前用户信息
Optional<User> optionalUser = userService.findByUserId(userId);
// 判断用户是否存在
if (!optionalUser.isPresent()) {
throw new UserNotFoundException("");
}
User user = optionalUser.get();
model.addAttribute("user", user);
// 获取用户笔记列表数据
Page<Note> notePage = noteService.getNotesByUser(userId, page - 1, size);
// 添加笔记列表数据到模型中
model.addAttribute("notePage", notePage);
model.addAttribute("currentPage", page);
model.addAttribute("totalPages", notePage.getTotalPages());
return "user-profile";
}*/
public ResponseEntity<?> profileWithNotes(
@PathVariable Long userId,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "12") int size) {
// 获取当前用户信息
Optional<User> optionalUser = userService.findByUserId(userId);
// 判断用户是否存在
if (!optionalUser.isPresent()) {
throw new UserNotFoundException("");
}
User user = optionalUser.get();
// 获取用户笔记列表数据
Page<Note> notePage = noteService.getNotesByUser(userId, page - 1, size);
// 转为DTO
List<NoteExploreDto> noteExploreDtoList =
notePage.map(note -> NoteExploreDto.toExploreDto(note, user)).getContent();
// 添加笔记列表数据到模型中
Map<String, Object> map = new HashMap<>();
map.put("user", user);
map.put("noteList", noteExploreDtoList);
map.put("currentPage", page);
map.put("totalPages", notePage.getTotalPages());
return ResponseEntity.ok(map);
}
为了避免序列化问题,将Page<Note>转为了List<NoteExploreDto>。
静态资源的访问
1. 后端安全配置调整
允许匿名访问静态图片资源,修改WebSecurityConfig如下:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// 禁用CSRF防护
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(authorize -> authorize
// ...为节约篇幅,此处省略非核心内容
/*// 允许ADMIN、USER角色的用户访问 /file/** 的资源
.requestMatchers("/file/**").hasAnyRole("ADMIN", "USER")*/
// 允许匿名访问静态图片资源
.requestMatchers("/uploads/**").permitAll()
.requestMatchers("/file/**").permitAll()
// 其他请求需求认证
.anyRequest().authenticated()
)
// ...为节约篇幅,此处省略非核心内容
;
return http.build();
}
2. 前端设置反向代理
修改vite.config.ts,设置针对静态图片资源的反向代理:
// 设置反向代理
server: {
host: 'localhost',
port: 5173, // Vue开发端口
proxy: {
// ...为节约篇幅,此处省略非核心内容
'/uploads': {
target: 'http://localhost:8080',
changeOrigin: true
},
'/file': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
}
3.8 AI辅助编程快速实现用户信息展示页面
前端组件设计
UserProfile.vue
新增src\views\UserProfile.vue:
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import type { NoteExploreDto } from '@/dto/note-explore-dto'
import { User } from '@/dto/user'
import { useRoute } from 'vue-router'
import axios from 'axios'
import { useRouter } from "vue-router"
const user = ref<User>(new User())
const authStore = useAuthStore()
const me = ref<User>(new User())
const noteList = ref<Array<NoteExploreDto>>([])
const totalPages = ref<number>(0)
const currentPage = ref<number>(0)
const route = useRoute()
const router = useRouter()
// 注销
function logout() {
authStore.logout()
// 跳转到登录页面
router.push({ name: 'login' })
}
// 从路由里面获取用户ID
const userId = ref(route.params.userId)
// 构造查询参数pageIndex,默认从1开始
const pageIndex = ref(route.query.page || 1)
onMounted(() => {
// 获取用户信息
fetchUserProfile(userId.value, pageIndex.value)
// 获取当前用户信息
me.value = authStore.getUser ? authStore.getUser : new User()
})
const fetchUserProfile = async (userId: any, pageIndex: any) => {
// 调用API获取用户信息
try {
const response = await axios.get(`/api/user/profile/${userId}?page=${pageIndex}`)
user.value = response.data['user']
currentPage.value = response.data['currentPage']
totalPages.value = response.data['totalPages']
noteList.value = response.data['noteList']
} catch (error) {
console.error('获取用户信息失败:' + error)
}
}
</script>
<template>
<!-- 导航栏 -->
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<div class="container">
<a class="navbar-brand" href="/">
<img src="/images/rn_logo.png" alt="RN" height="24">
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav"
aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
<li class="nav-item">
<a class="nav-link" href="#">
{{ user.username }}
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/user/profile">个人资料</a>
</li>
<li class="nav-item">
<!-- 注销 -->
<a class="nav-link" href="#" @click="logout">退出登录</a>
</li>
</ul>
</div>
</div>
</nav>
<!-- 主体部分 -->
<div class="container mt-5">
<div class="row justify-content-center">
<!-- 用户个人信息 -->
<div class="row col-md-8">
<div class="col-md-4 text-center">
<img :src="user.avatar ? user.avatar : '/images/rn_avatar.png'" class="rounded-circle" alt="用户头像" height="88"
width="88">
<p class="mt-3">{{ user.username }}</p>
<!-- 仅作者自己可见 -->
<div v-if="me.username === user.username">
<a href="/user/edit" class="btn btn-primary btn-sm">编辑资料</a>
</div>
</div>
<div class="col-md-8">
<dive class="mb-3">
<label class="form-label">RN号:{{ user.userId }}</label>
</dive>
<dive class="mb-3">
<p class="form-control-plaintext">{{ user.bio ? user.bio : '这家伙很懒,什么都没写' }}</p>
</dive>
<!-- 仅作者自己可见 -->
<div v-if="me.username === user.username">
<a href="/user/change-password" class="btn btn-outline-secondary">修改密码</a>
</div>
</div>
</div>
<!-- 笔记列表 -->
<div class="col-md-8">
<!-- 空状态提示 -->
<div class="empty-state" v-if="noteList.length === 0">
<div class="empty-icon">
<i class="fa fa-file-o"></i>
</div>
<div class="empty-text">
还没有发布任何笔记
</div>
<!-- 仅作者自己可见 -->
<div v-if="me.username == user.username">
<a href="/note/publish" class="create-note-btn">
<i class="fa fa-plus"></i>
发布第一篇笔记
</a>
</div>
</div>
<!-- 非空状态提示 -->
<div class="note-grid" v-if="noteList.length > 0">
<!-- 循环遍历笔记列表生成笔记卡片 -->
<div class="note-card" v-for="note in noteList">
<a :href="'/note/' + note.noteId">
<img :src="note.cover" class="note-image" alt="note.title">
</a>
<div class="note-content">
<dive class="note-title">
{{ note.title }}
</dive>
</div>
</div>
</div>
</div>
<!-- 分页导航 -->
<div class="col-md-8">
<div class="pagination" v-if="totalPages > 0">
<a class="page-btn" v-if="currentPage > 1"
:href="'/user/profile/' + user.userId + '?page=' + (currentPage - 1)">«</a>
<a class="page-btn" v-for="pageNum in Array.from({ length: totalPages }, (_, i) => i + 1)"
:href="'/user/profile/' + user.userId + '?page=' + pageNum" :class="{ active: pageNum === currentPage }">{{
pageNum }}</a>
<a class="page-btn" v-if="currentPage < totalPages"
:href="'/user/profile/' + user.userId + '?page=' + (currentPage + 1)">»</a>
</div>
</div>
</div>
</div>
</template>
<style setup>
/* 小红书风格 */
* {
font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background-color: #f5f5f5;
}
/* 顶部导航栏 */
.header {
position: sticky;
top: 0;
background-color: white;
padding: 16px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.user-info {
display: flex;
align-items: center;
gap: 12px;
}
.user-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
background-color: #f0f0f0;
}
.user-name {
font-size: 16px;
font-weight: 600;
color: #333;
}
.user-meta {
font-size: 12px;
color: #666;
}
.action-btn {
margin-left: auto;
font-size: 14px;
color: #ff2442;
}
/* 笔记列表 */
.note-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 16px;
padding: 16px;
}
.note-card {
background-color: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.03);
transition: transform 0.2s;
}
.note-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.05);
}
.note-image {
width: 100%;
height: 180px;
object-fit: cover;
}
.note-content {
padding: 12px;
}
.note-title {
font-size: 14px;
font-weight: 500;
line-height: 1.4;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
color: #333;
margin-bottom: 8px;
}
.note-meta {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #999;
}
/* 空状态提示 */
.empty-state {
padding: 48px;
text-align: center;
}
.empty-icon {
font-size: 48px;
color: #e0e0e0;
margin-bottom: 16px;
}
.empty-text {
color: #999;
margin-bottom: 24px;
}
.create-note-btn {
background-color: #ff2442;
color: white;
padding: 10px 24px;
border-radius: 24px;
font-weight: 500;
}
/* 分页组件 */
.pagination {
padding: 24px;
display: flex;
justify-content: center;
gap: 8px;
font-size: 14px;
}
.page-btn {
padding: 6px 12px;
border-radius: 4px;
color: #666;
text-decoration: none;
}
.page-btn.active {
background-color: #ff2442;
color: white;
font-weight: 500;
}
</style>
useRouter() 和 useRoute() 的区别
在 Vue 3 中,router 对象(通过 useRouter() 获取)没有 params 属性,而是需要通过 useRoute() 来获取当前路由的参数。
区分 useRoute() 和 useRouter()如下表所示。
| API | 用途 | 主要属性/方法 |
|---|---|---|
useRoute() | 获取当前路由信息 | params, query, path, fullPath |
useRouter() | 执行路由导航操作 | push, replace, go, back |
user.ts
新增src\dto\user.ts:
export class User {
userId: number = 0;
username: string = '';
password: string = '';
phone: string = '';
avatar: string = '';
bio: string = '';
role: string = '';
}
note-explore-dto.ts
新增src\dto\note-explore-dto.ts:
export interface NoteExploreDto {
noteId: number;
title: string;
cover: string;
username: string;
avatar: string;
userId: string;
liked: boolean;
likeCount: number;
}
3.9 路由配置和全局前置守卫实现页面重定向
路由配置和全局前置守卫实现重定向
在 Vue 3 中实现从 /user/profile 到 /user/profile/:userId 的重定向,需要结合路由配置和全局前置守卫。以下是具体实现方法:
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
// ...为节约篇幅,此处省略非核心内容
{
path: '/user/profile',
// 重定向到指定用户ID的页面
redirect: to => {
// 获取用户ID需要到全局守卫中处理
return { name: 'profile-placeholder'}
},
meta: {
requiresAuth: true
}
},
// 临时占位路由,用于在全局守卫中处理重定向
{
path: '/user/profile-placeholder',
name: 'profile-placeholder',
component: { template: '<div>Loading...</div>' },
meta: {
requiresAuth: true
}
},
{
path: '/user/profile/:userId',
name: 'user-profile',
component: () => import('../views/UserProfile.vue'),
meta: {
requiresAuth: true
}
},
],
})
// 全局前置守卫
router.beforeEach(async (to, from, next) => {
// ...为节约篇幅,此处省略非核心内容
// 如果用户已登录,但没有加载用户信息,则先加载用户信息
if (authStore.getIsAuthenticated && !authStore.getUser) {
try {
await authStore.fetchUser()
} catch (error) {
authStore.logout()
next({ name: 'login' })
}
}
// 获取用户ID
if (to.name === 'profile-placeholder' && authStore.getUser) {
next({ name: 'user-profile', params: { userId: (authStore.getUser as any).userId } })
} else {
next()
}
})
export default router
运行调测
运行应用在登录账号的情况下访问自己的用户信息首页,效果如下图4-6所示。
访问其他人的用户信息首页,效果如下图4-7所示。与上述界面的差异点在于少了“编辑资料”“修改密码”。
如果某个用户未发表过笔记,则效果如下图4-8所示。
如果是自己未发表过笔记,则效果如下图4-9所示。与上述界面的差异点在于少了“发布第一篇笔记”。
通过以上改造,用户信息管理功能将从后端渲染转变为前端渲染,实现更流畅的交互体验和更好的可维护性。关键是要处理好前后端分离后的API设计、状态管理和用户体验优化。
3.10 前后端分离架构下的全局错误异常处理
在前后端分离的架构中,传统的 Spring Boot @ControllerAdvice 全局异常处理需要结合前端错误拦截机制进行重构。以下是完整的解决方案。
改造前的效果
当试图访问一个不存在的用户ID的时候,比如ID为111,则界面效果如下图4-10所示。
该界面没有提示任何错误信息,用户也很难察觉后台实际上已经抛出了UserNotFoundException,只不过该异常并未i能反馈给前端应用。
后端GlobalExceptionHandler优化
1. 统一 API 错误响应格式
新建 ErrorResponseDto:
package com.waylau.rednote.dto;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;
/**
* ErrorResponseDto 错误响应对象
*
* @author <a href="https://waylau.com">Way Lau</a>
* @version 2025/07/08
**/
@Getter
@Setter
@AllArgsConstructor
public class ErrorResponseDto {
/**
* HTTP状态码
*/
private int code;
/**
* 信息
*/
private String message;
}
2. 重构 GlobalExceptionHandler
package com.waylau.rednote.exception;
import com.waylau.rednote.dto.ErrorResponseDto;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
/*import org.springframework.ui.Model;*/
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
/**
* GlobalExceptionHandler 全局异常处理
*
* @author <a href="https://waylau.com">Way Lau</a>
* @version 2025/08/18
**/
@ControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(MaxUploadSizeExceededException.class)
/*public String handleMaxSizeException(MaxUploadSizeExceededException exc, Model model) {
log.error("服务器异常:{}", exc.getMessage(), exc);
model.addAttribute("errorCode", 400);
model.addAttribute("errorMessage", "服务器异常:" + exc.getMessage());
return "400-error";
}*/
public ResponseEntity<?> handleMaxSizeException(MaxUploadSizeExceededException exc) {
log.error("服务器异常:{}", exc.getMessage(), exc);
ErrorResponseDto errorResponseDto = new ErrorResponseDto(400, "服务器异常:" + exc.getMessage());
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(errorResponseDto);
}
// 用户不存在异常
@ExceptionHandler(UserNotFoundException.class)
/*public String handleUserNotFoundException(UserNotFoundException exc, Model model) {
log.error("用户不存在异常:{}", exc.getMessage(), exc);
model.addAttribute("errorCode", 404);
model.addAttribute("errorMessage", "异常信息:" + exc.getMessage());
return "400-error";
}*/
public ResponseEntity<?> handleUserNotFoundException(UserNotFoundException exc) {
log.error("用户不存在异常:{}", exc.getMessage(), exc);
ErrorResponseDto errorResponseDto = new ErrorResponseDto(404, "异常信息:" + exc.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(errorResponseDto);
}
// 笔记不存在异常
@ExceptionHandler(NoteNotFoundException.class)
/*public String handleNoteNotFoundException(NoteNotFoundException exc, Model model) {
log.error("笔记不存在异常:{}", exc.getMessage(), exc);
model.addAttribute("errorCode", 404);
model.addAttribute("errorMessage", "异常信息:" + exc.getMessage());
return "400-error";
}*/
public ResponseEntity<?> handleNoteNotFoundException(NoteNotFoundException exc) {
log.error("笔记不存在异常:{}", exc.getMessage(), exc);
ErrorResponseDto errorResponseDto = new ErrorResponseDto(404, "异常信息:" + exc.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(errorResponseDto);
}
// 评论不存在异常
@ExceptionHandler(CommentNotFoundException.class)
/*public String handleCommentNotFoundException(CommentNotFoundException exc, Model model) {
log.error("评论不存在异常:{}", exc.getMessage(), exc);
model.addAttribute("errorCode", 404);
model.addAttribute("errorMessage", "异常信息:" + exc.getMessage());
return "400-error";
}*/
public ResponseEntity<?> handleCommentNotFoundException(CommentNotFoundException exc) {
log.error("评论不存在异常:{}", exc.getMessage(), exc);
ErrorResponseDto errorResponseDto = new ErrorResponseDto(404, "异常信息:" + exc.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(errorResponseDto);
}
}
前端 axios 拦截器配置
1. 创建 axios 实例并添加拦截器
新建src\services\axios.ts:
import axios from "axios"
import { useAuthStore } from "@/stores/auth"
import router from "@/router"
const service = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 5000,
})
// 请求拦截器
service.interceptors.request.use(
(config) => {
const authStore = useAuthStore()
if (authStore.getToken) {
config.headers.Authorization = `Bearer ${authStore.getToken}`
}
return config
},
(error) => {
console.error('请求错误:' + error)
return Promise.reject(error)
}
)
// 响应拦截器
service.interceptors.response.use(
(response) => {
return response
},
(error) => {
console.error('响应错误:' + error)
const { status, data } = error.response || {}
// 根据状态码的不同处理不同的错误
switch (status) {
case 401:
// 认证失败,跳转到登录页
const authStore = useAuthStore()
authStore.logout()
router.push({ name: 'login', query: { redirect: router.currentRoute.value.fullPath } })
break;
case 403:
// 权限不足,显示提示
alert(data.message || '权限不足')
break;
case 404:
// 资源不存在,显示提示
alert(data.message || '资源不存在')
break;
case 500:
// 服务器内部错误,显示提示
alert(data.message || '服务器内部错误,请稍后再试')
break;
default:
// 其他错误,显示提示
alert(data.message || '未知错误,请稍后再试')
}
return Promise.reject(error)
}
)
export default service
2. 删除老的axios拦截器
原有的在src\stores\auth.ts的axios拦截器代码可以删除。
/*
// axios拦截器,自动刷新JWT
axios.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
*/
3. 使用 axios 实例
在其他组件中原有的使用axios的地方,改为使用@/services/axios中的 axios 实例。
/*import axios from "axios"*/
import axios from "@/services/axios"
以下三个地方:
src\views\UserProfile.vuesrc\views\RegistrationForm.vuesrc\stores\auth.ts
后续如果有需要发起HTTP请求,都统一使用@/services/axios中的 axios 实例。
运行调测
当试图访问一个不存在的用户ID的时候,比如ID为111,则界面效果如下图4-11所示。
总结
通过以上重构,你可以实现:
- 统一的错误响应格式:后端返回标准化的错误结构
- 全局错误拦截:前端通过 axios 拦截器统一处理 HTTP 错误
- 友好的用户提示:根据不同错误类型显示适当的用户提示
这种架构既能保持后端的健壮性,又能提供良好的前端用户体验,是前后端分离架构下理想的异常处理方案。
3.11 实现用户基本信息的编辑功能
后端接口改造
修改UserController:
@GetMapping("/edit")
/*public String editProfile(Model model) {
User user = userService.getCurrentUser();
model.addAttribute("user", user);
return "user-profile-edit";
}*/
public ResponseEntity<User> editProfile() {
User user = userService.getCurrentUser();
return ResponseEntity.ok(user);
}
@Transactional
@PostMapping("/edit")
/*
public String updateProfile(@ModelAttribute User user, RedirectAttributes redirectAttributes,
@RequestParam("avatarFile") MultipartFile avatarFile) {
User currentUser = userService.getCurrentUser();
String oldAvatar = currentUser.getAvatar();
// 验证文件类型和大小
if (avatarFile != null && !avatarFile.isEmpty()) {
// 验证文件类型
String contentType = avatarFile.getContentType();
if (!contentType.startsWith("image/")) {
redirectAttributes.addFlashAttribute("error", "请上传图片文件");
return "redirect:/user/edit";
}
String fileId = gridFSStorageService.uploadImage(avatarFile);
String fileUrl = MongoConfig.STATIC_PATH_PREFIX + fileId;
currentUser.setAvatar(fileUrl);
// 删除旧头像文件
if (oldAvatar != null && !oldAvatar.isEmpty()) {
String oldFileId = oldAvatar.substring(oldAvatar.lastIndexOf("/") + 1);
gridFSStorageService.deleteImage(oldFileId);
}
}
// 更新用户信息
currentUser.setPhone(user.getPhone());
currentUser.setBio(user.getBio());
// 修改内容保存到数据库
userService.updateUser(currentUser);
// 重定向到指定页面,并传递参数
redirectAttributes.addFlashAttribute("success", "个人信息更新成功");
return "redirect:/user/profile";
}
*/
public ResponseEntity<?> updateProfile(@RequestParam(required = true) String phone,
@RequestParam(required = false) String bio,
@RequestParam(required = false, value = "avatarFile") MultipartFile avatarFile) {
User currentUser = userService.getCurrentUser();
String oldAvatar = currentUser.getAvatar();
Map<String, String> map = new HashMap<>();
// 验证文件类型和大小
if (avatarFile != null && !avatarFile.isEmpty()) {
// 验证文件类型
String contentType = avatarFile.getContentType();
if (!contentType.startsWith("image/")) {
map.put("error", "请上传图片文件");
return ResponseEntity.ok(map);
}
// 处理文件上传
String fileId = gridFSStorageService.uploadImage(avatarFile);
String fileUrl = MongoConfig.STATIC_PATH_PREFIX + fileId;
currentUser.setAvatar(fileUrl);
// 删除旧头像文件
if (oldAvatar != null && !oldAvatar.isEmpty()) {
String oldFileId = oldAvatar.substring(oldAvatar.lastIndexOf("/") + 1);
gridFSStorageService.deleteImage(oldFileId);
}
}
// 更新用户信息
currentUser.setPhone(phone);
currentUser.setBio(bio);
// 修改内容保存到数据库
userService.updateUser(currentUser);
// 重定向到指定页面,并传递参数
map.put("success", "个人信息更新成功");
return ResponseEntity.ok(map);
}
前端组件设计
UserProfileEdit.vue
新增src\views\UserProfileEdit.vue:
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { User } from '@/dto/user'
import axios from "@/services/axios"
import { useRouter } from "vue-router"
const user = ref<User>(new User())
const authStore = useAuthStore()
const router = useRouter()
const success = ref('')
const error = ref('')
const selectedFile = ref(null)
onMounted(() => {
// 获取用户信息
fetchUserProfile()
})
const fetchUserProfile = async () => {
try {
const response = await axios.get(`/api/user/edit`)
user.value = response.data
} catch (error) {
console.error('获取用户信息失败:' + error)
}
}
// 注销
function logout() {
authStore.logout()
// 跳转到登录页面
router.push({ name: 'login' })
}
const handleUserEdit = async () => {
const formData = new FormData()
if (selectedFile.value) {
formData.append('avatarFile', selectedFile.value as File)
}
formData.append('phone', user.value.phone)
formData.append('bio', user.value.bio)
// 调用API编辑用户信息
try {
const response = await axios.post(`/api/user/edit`, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
if (response.data['success']) {
success.value = response.data['success']
// 获取用户信息
fetchUserProfile()
} else if (response.data['error']) {
error.value = response.data['error']
}
} catch (err) {
console.error('获取用户信息失败:' + err)
error.value = err + ''
}
}
// 选中头像的处理
const handleFileUpload = (e: any) => {
selectedFile.value = e.target.files[0]
}
</script>
<template>
<!-- 导航栏 -->
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<div class="container">
<a class="navbar-brand" href="/">
<img src="/images/rn_logo.png" alt="RN" height="24">
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav"
aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
<li class="nav-item">
<a class="nav-link" href="#">
{{ user.username }}
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/user/profile">个人资料</a>
</li>
<li class="nav-item">
<!-- 注销 -->
<a class="nav-link" href="#" @click="logout">退出登录</a>
</li>
</ul>
</div>
</div>
</nav>
<!-- 主体部分 -->
<div class="profile-container">
<!-- 编辑标题 -->
<div class="profile-header">
<h2 class="text-center">编辑个人资料</h2>
<p>请填写或者更新你的个人信息</p>
</div>
<!-- 编辑表单 -->
<form action="/user/edit" method="post" enctype="multipart/form-data" @submit.prevent="handleUserEdit">
<!-- 头像 -->
<div class="form-group position-relative">
<div class="profile-avatar">
<img :src="user.avatar ? user.avatar : '/images/rn_avatar.png'" alt="用户头像" height="88" width="88">
<div class="avatar-upload">
<!-- 文件上传 --->
<input type="file" id="avatarFile" name="avatarFile" accept="image/*" class="d-none"
@change="handleFileUpload"></input>
<label for="avatarFile">更换头像</label>
</div>
</div>
</div>
<!-- 用户名(不可编辑)-->
<div class="form-group">
<label for="username" class="form-label">用户名</label>
<input type="text" class="form-control" id="username" name="username" :value="user.username" disabled />
</div>
<!-- 手机号-->
<div class="form-group">
<label for="phone" class="form-label">手机号</label>
<input type="text" class="form-control" id="phone" name="phone" v-model="user.phone" placeholder="请输入手机号" />
</div>
<!-- 个人简介 -->
<div class="form-group">
<label for="bio" class="form-label">个人简介</label>
<textarea class="form-control" id="bio" name="bio" rows="3" v-model="user.bio"
placeholder="请输入个人简介(最多255字)"></textarea>
</div>
<!-- 提交按钮 -->
<button type="submit" class="btn btn-primary">保存修改</button>
</form>
<!-- 操作反馈 -->
<div v-if="success" class="alert alert-success mt-3" role="alert">
{{ success }}
</div>
<div v-if="error" class="alert alert-danger mt-3" role="alert">
{{ error }}
</div>
</div>
</template>
<style setup>
.profile-container {
max-width: 800px;
margin: 0 auto;
padding: 32px;
}
.profile-header {
text-align: center;
margin-bottom: 32px;
}
.profile-avatar {
width: 120px;
height: 120px;
border-radius: 50%;
margin: 0 auto 20px;
position: relative;
}
.profile-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
border: 4px solid white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.avatar-upload {
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
background-color: #ff2442;
color: white;
padding: 4px 12px;
border-radius: 20px;
cursor: pointer;
font-size: 12px;
transition: background-color 0.3s;
}
.avatar-upload:hover {
background-color: #e61e3a;
}
.form-group {
margin-bottom: 24px;
}
.form-label {
font-weight: 600;
color: #333;
}
.form-control {
border-radius: 12px;
border: 1px solid #e8e8e8;
padding: 12px 16px;
}
.form-control:focus {
border-color: #ff2442;
box-shadow: 0 0 0 2px rgba(255, 36, 66, 0.1);
}
.btn-primary {
background-color: #ff2442;
border-color: #ff2442;
border-radius: 24px;
padding: 12px 48px;
font-weight: 600;
width: 100%;
}
.btn-primary:hover {
background-color: #e61e3a;
box-shadow: 0 4px 12px rgba(255, 36, 66, 0.2);
}
.error-message {
color: #ff2442;
font-size: 12px;
margin-top: 4px;
}
</style>
路由配置
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
// ...为节约篇幅,此处省略非核心内容
,
{
path: '/user/edit',
name: 'user-profile-edit',
component: () => import('../views/UserProfileEdit.vue'),
meta: {
requiresAuth: true
}
},
],
})
运行调测
运行应用访问用户信息编辑页面,效果如下图4-12所示。
对用户信息进行编辑,效果如下图4-13所示。
用户信息编辑成功后刷新页面,效果如下图4-14所示。
3.12 实现用户密码修改
后端接口改造
修改UserController:
@GetMapping("/change-password")
/*public String changePasswordForm() {
return "user-change-password";
}*/
public ResponseEntity<User> changePasswordForm() {
User user = userService.getCurrentUser();
return ResponseEntity.ok(user);
}
@PostMapping("/change-password")
/*public String changePassword(@RequestParam String oldPassword, @RequestParam String newPassword, @RequestParam String confirmPassword, RedirectAttributes redirectAttributes) {
// 密码验证,验证两次输入的密码是否一致
if (!newPassword.equals(confirmPassword)) {
redirectAttributes.addFlashAttribute("error", "两次输入的密码不一致");
return "redirect:/user/change-password";
}
// 密码旧密码是否正确
if (!userService.verifyPassword(userService.getCurrentUser().getUsername(), oldPassword)) {
redirectAttributes.addFlashAttribute("error", "旧密码错误");
return "redirect:/user/change-password";
}
// 新密码强度验证
if (!newPassword.matches("^[a-zA-Z0-9_]{8,20}$")) {
redirectAttributes.addFlashAttribute("error", "新密码强度不够");
return "redirect:/user/change-password";
}
// 更新密码到数据库
userService.changePassword(userService.getCurrentUser().getUsername(), newPassword);
redirectAttributes.addFlashAttribute("success", "密码修改成功");
return "redirect:/user/change-password";
}*/
public ResponseEntity<?> changePassword(@RequestParam String oldPassword,
@RequestParam String newPassword,
@RequestParam String confirmPassword) {
Map<String, String> map = new HashMap<>();
// 密码验证,验证两次输入的密码是否一致
if (!newPassword.equals(confirmPassword)) {
map.put("error", "两次输入的密码不一致");
return ResponseEntity.ok(map);
}
// 密码旧密码是否正确
if (!userService.verifyPassword(userService.getCurrentUser().getUsername(), oldPassword)) {
map.put("error", "旧密码错误");
return ResponseEntity.ok(map);
}
// 新密码强度验证
if (!newPassword.matches("^[a-zA-Z0-9_]{8,20}$")) {
map.put("error", "新密码强度不够");
return ResponseEntity.ok(map);
}
// 更新密码到数据库
userService.changePassword(userService.getCurrentUser().getUsername(), newPassword);
map.put("success", "密码修改成功");
return ResponseEntity.ok(map);
}
前端组件设计
UserChangePassword.vue
新增src\views\UserChangePassword.vue:
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { User } from '@/dto/user'
import axios from "@/services/axios"
import { useRouter } from "vue-router"
import { UserChangePassword } from '@/dto/user-change-password'
const user = ref<User>(new User())
const authStore = useAuthStore()
const router = useRouter()
const success = ref('')
const error = ref('')
const userChangePassword = ref<UserChangePassword>(new UserChangePassword())
onMounted(() => {
// 获取用户信息
fetchUserProfile()
})
const fetchUserProfile = async () => {
try {
const response = await axios.get(`/api/user/change-password`)
user.value = response.data
} catch (error) {
console.error('获取用户信息失败:' + error)
}
}
// 注销
function logout() {
authStore.logout()
// 跳转到登录页面
router.push({ name: 'login' })
}
const handleUserChangePassword = async () => {
const formData = new FormData()
formData.append('oldPassword', userChangePassword.value.oldPassword)
formData.append('newPassword', userChangePassword.value.newPassword)
formData.append('confirmPassword', userChangePassword.value.confirmPassword)
// 调用API编辑用户信息
try {
const response = await axios.post(`/api/user/change-password`, formData)
if (response.data['success']) {
success.value = response.data['success']
} else if (response.data['error']) {
error.value = response.data['error']
}
} catch (err) {
console.error('获取用户信息失败:' + err)
error.value = err + ''
}
}
</script>
<template>
<!-- 导航栏 -->
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<div class="container">
<a class="navbar-brand" href="/">
<img src="/images/rn_logo.png" alt="RN" height="24">
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav"
aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
<li class="nav-item">
<a class="nav-link" href="#">
{{ user.username }}
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/user/profile">个人资料</a>
</li>
<li class="nav-item">
<!-- 注销 -->
<a class="nav-link" href="#" @click="logout">退出登录</a>
</li>
</ul>
</div>
</div>
</nav>
<!-- 主体部分 -->
<div class="password-container">
<div class="card password-card">
<!-- 编辑标题 -->
<div class="card-header">
<h2 class="text-center">修改密码</h2>
<p>请输入当前密码和新密码</p>
</div>
<div class="card-body">
<form action="/user/change-password" method="post" @submit.prevent="handleUserChangePassword">
<!-- 当前密码 -->
<div class="form-group">
<label for="oldPassword" class="form-label">当前密码</label>
<input type="password" class="form-control" id="oldPassword" name="oldPassword"
v-model="userChangePassword.oldPassword" />
</div>
<!-- 新密码 -->
<div class="form-group">
<label for="newPassword" class="form-label">新密码</label>
<input type="password" class="form-control" id="newPassword" name="newPassword"
v-model="userChangePassword.newPassword" required />
</div>
<!-- 确认密码 -->
<div class="form-group">
<label for="confirmPassword" class="form-label">确认密码</label>
<input type="password" class="form-control" id="confirmPassword" name="confirmPassword"
v-model="userChangePassword.confirmPassword" required />
</div>
<!-- 提交按钮 -->
<button type="submit" class="btn btn-primary">确认修改</button>
</form>
<!-- 返回个人资料 -->
<a href="/user/profile" class="back-link">返回个人资料</a>
</div>
</div>
<!-- 操作反馈 -->
<div v-if="success" class="alert alert-success mt-3" role="alert">
{{ success }}
</div>
<div v-if="error" class="alert alert-danger mt-3" role="alert">
{{ error }}
</div>
</div>
</template>
<style setup>
.password-container {
max-width: 500px;
margin: 0 auto;
padding: 32px;
}
.password-card {
border-radius: 16px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
border: none;
}
.card-header {
background-color: white;
border-bottom: none;
padding: 32px 32px 0;
}
.card-body {
padding: 32px;
}
.form-group {
margin-bottom: 24px;
}
.form-label {
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.form-control {
border-radius: 12px;
border: 1px solid #e8e8e8;
padding: 12px 16px;
height: 48px;
}
.form-control:focus {
border-color: #ff2442;
box-shadow: 0 0 0 2px rgba(255, 36, 66, 0.1);
}
.btn-primary {
background-color: #ff2442;
border-color: #ff2442;
border-radius: 24px;
padding: 12px 48px;
font-weight: 600;
height: 48px;
width: 100%;
transition: all 0.3s ease;
}
.btn-primary:hover {
background-color: #e61e3a;
box-shadow: 0 4px 12px rgba(255, 36, 66, 0.2);
}
.error-message {
color: #ff2442;
font-size: 12px;
margin-top: 4px;
}
.back-link {
display: block;
text-align: center;
margin-top: 24px;
color: #999;
font-size: 14px;
}
.back-link:hover {
color: #ff2442;
}
</style>
user-change-password.ts
新增src\dto\user-change-password.ts:
export class UserChangePassword {
oldPassword: string = '';
newPassword: string = '';
confirmPassword: string = '';
}
路由配置
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
// ...为节约篇幅,此处省略非核心内容
,
{
path: '/user/change-password',
name: 'user-change-password',
component: () => import('../views/UserChangePassword.vue'),
meta: {
requiresAuth: true
}
},
],
})
运行调测
运行应用对用户密码进行修改。修改失败效果如下图4-15所示。
运行应用对用户密码进行修改。修改成功效果如下图4-16所示。