1.1 全栈视角下的首页模块前后端划分与功能全流程剖析
将首页从 Thymeleaf 后端渲染模式迁移到 Vue 3 前端渲染模式,需要对数据获取方式、组件化设计、路由管理和状态管理进行全面调整。以下是核心改造点和实施建议:
架构与交互模式的核心变化
1. 数据流转方式
- Thymeleaf 模式: 浏览器 → HTTP 请求 → 后端控制器 → 数据库查询 → 模板渲染 → HTML 响应
- Vue 3 模式: 浏览器 → Vue 应用初始化 → API 请求 → 后端服务 → JSON 响应 → 前端渲染
2. 渲染与交互方式
- Thymeleaf:服务器端渲染,每次导航刷新整个页面
- Vue 3:前端渲染,单页应用(SPA),局部更新内容,无刷新体验
通过以上改造,首页将从后端渲染转变为前端渲染,实现更流畅的交互体验和更好的可维护性。关键是要处理好前后端分离后的API设计、组件化开发和用户体验优化。
1.2 首页模块整体布局升级至Vue 3架构
后端接口
ExploreController返回首页笔记探索页面的笔记数据接口已经适配,无需调整。
/**
* 处理笔记探索页面的数据加载的请求
*/
@GetMapping("/note")
public ResponseEntity<NoteResponseDto> getNotesByCategory(@RequestParam(defaultValue = "1") int page,
@RequestParam(required = false) String category,
@RequestParam(required = false) String query) {
// 注意:把分类“推荐”当成null
if (DEFAULT_CATEGORY.equals(category)) {
category = null;
}
// 分页查询笔记
Page<Note> notes = null;
// 判定query是否为空
if (query != null && query.trim().length() > 0) {
notes = noteService.getNotesByPageAndQuery(page, PAGE_SIZE, category, query);
} else {
notes = noteService.getNotesByPage(page, PAGE_SIZE, category);
}
NoteResponseDto noteResponseDto = new NoteResponseDto();
noteResponseDto.setHasMore(notes.hasNext());
User user = userService.getCurrentUser();
// 处理序列化问题
List<NoteExploreDto> noteExploreDtoLst = new ArrayList<>();
for (Note note : notes.getContent()) {
noteExploreDtoLst.add(NoteExploreDto.toExploreDto(note, user));
}
noteResponseDto.setNotes(noteExploreDtoLst);
return ResponseEntity.ok(noteResponseDto);
}
前端组件设计
Explore.vue
新增src\views\Explore.vue:
<script setup lang="ts">
import { User } from '@/dto/user';
import { useAuthStore } from '@/stores/auth';
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router';
const me = ref<User>(new User())
const authStore = useAuthStore()
const router = useRouter()
onMounted(() => {
me.value = authStore.getUser ? authStore.getUser : new User()
})
// 注销
function logout() {
authStore.logout()
// 跳转到登录页面
router.push({ name: 'login' })
}
</script>
<template>
<!-- 顶部导航栏 -->
<header>
<nav class="navbar navbar-expand-lg">
<div class="container">
<a class="navbar-brand" href="/">
<img src="/images/rn_logo.png"alt="RN" height="24">
</a>
<!-- 搜索框-->
<div class="col-md-3">
<div class="input-group">
<input class="form-control" type="text" placeholder="搜索感兴趣的内容" aria-label="Search" id="searchInput">
<button class="btn btn-outline-secondary" type="button" id="searchButton">
搜索
</button>
</div>
</div>
<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 me-auto">
</ul>
<ul class="navbar-nav mb-2 mb-lg-0">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" data-bs-target="dropdown" data-bs-toggle="dropdown"
aria-expanded="false">
{{ me.username}}
</a>
<ul class="dropdown-menu" id="dropdown">
<li class="dropdown-item">
<a class="nav-link" href="/user/profile">个人资料</a>
</li>
<li class="dropdown-item">
<a class="nav-link" href="#" @click="logout">退出登录</a>
</li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
</header>
<!-- 分类导航 -->
<header>
<div class="container">
<div class="category-item active">推荐</div>
<div class="category-item">穿搭</div>
<div class="category-item">美食</div>
<div class="category-item">彩妆</div>
<div class="category-item">影视</div>
<div class="category-item">职场</div>
<div class="category-item">情感</div>
<div class="category-item">家居</div>
<div class="category-item">游戏</div>
<div class="category-item">旅行</div>
<div class="category-item">健身</div>
</div>
</header>
<main>
<div class="container">
<!-- 笔记卡片网格 -->
<div class="masonry" id="notesGrid">
<!-- 笔记卡片是通过JavaScript动态生成 -->
</div>
<!-- 加载更多内容提示 -->
<div class="load-more" id="loadMore">
<i class="fa fa-spinner fa-spin"></i>加载更多
</div>
<!-- 没有更多内容提示 -->
<div class="no-more" id="noMoreContent">
<p>已经到底啦~</p>
</div>
</div>
</main>
<footer>
<!-- 底部导航栏 -->
<div class="container bottom-nav">
<div class="nav-item active" onclick="navigateTo('home')">
<i class="fa fa-home nav-icon"></i>
<span class="nav-text">首页</span>
</div>
<div class="nav-item" onclick="navigateTo('discover')">
<i class="fa fa-compass nav-icon"></i>
<span class="nav-text">发现</span>
</div>
<div class="nav-item" onclick="navigateTo('publish')">
<i class="fa fa-plus nav-icon"></i>
<span class="nav-text">发布</span>
</div>
<div class="nav-item" onclick="navigateTo('message')">
<i class="fa fa-comment-o nav-icon"></i>
<span class="nav-text">消息</span>
</div>
<div class="nav-item" onclick="navigateTo('profile')">
<i class="fa fa-user-o nav-icon"></i>
<span class="nav-text">我的</span>
</div>
</div>
</footer>
</template>
<style setup>
/* 全局样式 */
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background-color: #f5f5f5;
}
/* 分类导航 */
.category-nav {
background-color: white;
padding: 8px 0;
overflow-x: auto;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
}
.category-item {
display: inline-block;
padding: 6px 12px;
margin-right: 8px;
border-radius: 20px;
font-size: 14px;
cursor: pointer;
transition: background-color 0.2s;
}
.category-item.active {
background-color: #ff2442;
color: white;
}
/* 笔记卡片网格 */
.notes-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 8px;
padding: 8px;
}
.note-card {
background-color: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.note-image-container {
position: relative;
padding-bottom: 100%;
/* 保持正方形比例 */
overflow: hidden;
border-radius: 12px;
}
.note-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.note-tag {
position: absolute;
bottom: 8px;
left: 8px;
background-color: rgba(0, 0, 0, 0.5);
color: white;
padding: 2px 8px;
border-radius: 10px;
font-size: 12px;
}
.note-content {
padding: 8px;
}
.note-title {
font-size: 14px;
font-weight: 500;
margin-bottom: 4px;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.note-author {
display: flex;
align-items: center;
margin-bottom: 4px;
}
.author-avatar {
width: 20px;
height: 20px;
border-radius: 50%;
margin-right: 6px;
}
.author-name {
font-size: 12px;
color: #666;
}
.note-author-stats {
display: flex;
justify-content: space-between;
}
.note-stats {
display: flex;
align-items: center;
font-size: 12px;
color: #999;
}
.stat-item {
margin-right: 12px;
}
/* 加载更多 */
.load-more {
text-align: center;
padding: 16px 0;
color: #666;
font-size: 14px;
}
/* 没有更多 */
.no-more {
text-align: center;
padding: 0 0 50px 0;
color: #666;
font-size: 14px;
display: none;
}
/* 底部导航栏 */
.bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
justify-content: space-around;
padding: 8px 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.05);
z-index: 100;
background-color: #f5f5f5;
}
.nav-item {
display: flex;
flex-direction: column;
align-items: center;
color: #666;
cursor: pointer;
}
.nav-item.active {
color: #ff2442;
}
.nav-icon {
font-size: 20px;
margin-bottom: 2px;
}
.nav-text {
font-size: 10px;
}
/* 去掉下划线 */
a {
text-decoration: none;
}
/* 瀑布流布局 */
.masonry {
column-count: 4;
column-gap: 1em;
padding: 10;
}
.masonry-item {
display: inline-block;
margin: 0 0 1.5em;
width: 100%;
}
.masonry-note-image {
border-radius: 12px;
width: 100%;
height: auto;
}
@media only screen and (max-width: 320px) {
.masonry {
column-count: 1;
}
}
@media only screen and (min-width: 321px) and (max-width: 768px) {
.masonry {
column-count: 2;
}
}
@media only screen and (min-width: 769px) and (max-width: 1200px) {
.masonry {
column-count: 3;
}
}
@media only screen and (min-width: 1201px) {
.masonry {
column-count: 4;
}
}
/* 点赞按钮样式 */
.liked {
color: #ff2442;
}
.like-btn {
cursor: pointer;
}
</style>
路由配置
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
// ...为节约篇幅,此处省略非核心内容
,
{
path: '/explore',
name: 'explore',
component: () => import('../views/Explore.vue'),
meta: { requiresAuth: true },
}
],
})
同时,全局前置守卫还要处理/home到/explore的重定向
// 全局前置守卫
router.beforeEach(async (to, from, next) => {
// ...为节约篇幅,此处省略非核心内容
// 获取用户ID
if (to.name === 'profile-placeholder' && authStore.getUser) {
next({ name: 'user-profile', params: { userId: (authStore.getUser as any).userId } })
} else if (to.name === 'home') {
// 跳转从Home到Explore页面
next({ name: 'explore'})
} else {
next()
}
})
运行调测
运行应用访问首页,可以看到界面效果如下图8-1所示。
1.3 全栈实战瀑布流布局的核心要点
业务逻辑
// ...为节约篇幅,此处省略非核心内容`
const noteList = ref<Array<NoteExploreDto>>([])
const isLoading = ref(false)
const hasMore = ref(true)
const loadMoreRef = ref<HTMLDivElement | null>(null)
const noMoreContentRef = ref<HTMLDivElement | null>(null)
const category = ref('')
const page = ref(1)
const query = ref('')
onMounted(() => {
me.value = authStore.getUser ? authStore.getUser : new User()
// 加载笔记数据
loadMoreNotes()
// 监听滚动事件
window.addEventListener('scroll', () => {
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
const windowHeight = window.innerHeight;
const documentHeight = document.documentElement.scrollHeight;
console.log('scrollTop: ' + scrollTop);
console.log('windowHeight: ' + windowHeight);
console.log('documentHeight: ' + documentHeight);
if (scrollTop + windowHeight >= documentHeight - 300) {
loadMoreNotes();
}
})
})
// 数字格式化,转为k/w单位``````````````````````````````````````````````````````````````````````````````````````
function formateNumber(num: number) {
if (num >= 10000) {
return (num / 10000).toFixed(1) + 'w'
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'k'
} else {
return num
}
}
// 加载更多笔记数据
const loadMoreNotes = async () => {
if (isLoading.value || !hasMore.value) {
// 隐藏“加载”
hideLoadMore()
// 显示“没有更多”
showNoMoreContent()
return
}
isLoading.value = true
// 显示“加载”
showLoadMore()
// 获取当前分类
category.value = document.querySelector('.category-item.active')?.textContent?.trim() ?? '推荐'
try {
// 发送API请求
const response = await axios.get(`/api/explore/note?page=${page.value}&category=${category.value}&query=${query.value}`)
const data = response.data
if (data.notes && data.notes.length > 0) {
page.value++
noteList.value = noteList.value.concat(data.notes)
hasMore.value = data.hasMore
} else {
hasMore.value = false
}
isLoading.value = false
// 隐藏“加载”
hideLoadMore()
if (!hasMore.value) {
// 显示“没有更多”
showNoMoreContent()
}
} catch (error) {
console.log('加载更多笔记失败', error)
isLoading.value = false
// 隐藏“加载”
hideLoadMore()
}
}
function hideLoadMore() {
if (loadMoreRef.value) {
loadMoreRef.value.style.display = 'none'
}
}
function showNoMoreContent() {
if (noMoreContentRef.value) {
noMoreContentRef.value.style.display = 'block'
}
}
function showLoadMore() {
if (loadMoreRef.value) {
loadMoreRef.value.style.display = 'block'
}
}
模板
<!-- 笔记卡片网格 -->
<div class="masonry" id="notesGrid">
<!-- 笔记卡片是通过Vue动态生成 -->
<div class="masonry-item" v-for="note in noteList">
<!-- 点击跳转到笔记详情页 -->
<a :href="`/note/${note.noteId}`">
<img class="masonry-note-image" :src="note.cover" :alt="note.title">
</a>
<div class="note-content">
<div class="note-title">{{ note.title }}</div>
<div class="note-author-stats">
<!-- 点击跳转到用户详情页 -->
<a :href="`/user/profile/${note.userId}`">
<div class="note-author">
<img class="author-avatar" :src="note.avatar ? note.avatar : '/images/rn_avatar.png'" :alt="note.username">
<span class="author-name">{{ note.username }}</span>
</div>
</a>
<div class="note-stats">
<div class="stat-item">
<i :class="note.liked ? 'fa fa-heart liked' : 'fa fa-heart-o'"
onclick="handleLike(this)">{{ formateNumber(note.likeCount) }}</i>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 加载更多内容提示 -->
<div class="load-more" id="loadMore" ref="loadMoreRef">
<i class="fa fa-spinner fa-spin"></i>加载更多
</div>
<!-- 没有更多内容提示 -->
<div class="no-more" id="noMoreContent" ref="noMoreContentRef">
<p>已经到底啦~</p>
</div>
运行调测
运行应用访问首页,可以看到界面效果如下图8-2所示。
页面向下滑动,可以继续加载后续笔记内容,界面效果如下图8-3所示。
1.4 全栈实战分页搜索功能的核心要点
为分类导航添加点击事件
onMounted(() => {
// ...为节约篇幅,此处省略非核心内容
// 分类导航设置点击事件
document.querySelectorAll('.category-item').forEach(item => {
item.addEventListener('click', () => {
// 移除所有active类
document.querySelectorAll('.category-item').forEach(i => {
i.classList.remove('active');
});
// 添加active类
item.classList.add('active');
// 执行搜索
performSearch()
})
})
})
// 执行搜索
function performSearch() {
// 重置笔记网格数据
noteList.value = [];
page.value = 1;
isLoading.value = false;
hasMore.value = true;
loadMoreNotes();
}
为搜索输入框绑定模型
<input class="form-control" type="text" placeholder="搜索感兴趣的内容" aria-label="Search" id="searchInput"
v-model="query">
为搜索按钮设置点击事件处理
// 点击搜索
function handleSearch() {
// 执行搜索
performSearch()
}
// ...为节约篇幅,此处省略非核心内容
<button class="btn btn-outline-secondary" type="button" id="searchButton" @click="handleSearch">
搜索
</button>
底部导航栏设置点击事件
// 底部导航
function navigateTo(page: string) {
console.log('navigateTo: ' + page);
if (page === 'home') {
window.location.href = '/';
} else if (page === 'publish') {
window.location.href = '/note/publish';
} else if (page === 'profile') {
window.location.href = '/user/profile';
} else {
// 待实现的功能页面
alert('暂未开放,敬请期待!');
return;
}
}
// ...为节约篇幅,此处省略非核心内容
<!-- 底部导航栏 -->
<div class="container bottom-nav">
<div class="nav-item active" @click="navigateTo('home')">
<i class="fa fa-home nav-icon"></i>
<span class="nav-text">首页</span>
</div>
<div class="nav-item" @click="navigateTo('discover')">
<i class="fa fa-compass nav-icon"></i>
<span class="nav-text">发现</span>
</div>
<div class="nav-item" @click="navigateTo('publish')">
<i class="fa fa-plus nav-icon"></i>
<span class="nav-text">发布</span>
</div>
<div class="nav-item" @click="navigateTo('message')">
<i class="fa fa-comment-o nav-icon"></i>
<span class="nav-text">消息</span>
</div>
<div class="nav-item" @click="navigateTo('profile')">
<i class="fa fa-user-o nav-icon"></i>
<span class="nav-text">我的</span>
</div>
</div>
设置点赞处理事件
import type { LikeResponseDto } from '@/dto/like-response-dto';
// 点赞
const handleLike = async (note: NoteExploreDto) => {
try {
// 调用API提交点赞
const response = await axios.post(`/api/like/${note.noteId}`);
const likeResponseDto: LikeResponseDto = response.data;
note.likeCount = likeResponseDto.likeCount;
note.liked = likeResponseDto.liked;
} catch (error) {
console.error('点赞错误:', error)
}
}
// ...为节约篇幅,此处省略非核心内容
<div class="note-stats">
<div class="stat-item">
<i :class="note.liked ? 'fa fa-heart liked like-btn' : 'fa fa-heart-o like-btn'"
@click="handleLike(note)">{{
formateNumber(note.likeCount) }}</i>
</div>
</div>
运行调测
运行应用访问首页进行关键字搜索,看到界面效果如下图8-4所示。
点击分类后进行关键字搜索,界面效果如下图8-5所示。
点击底部导航未完成项目的按钮,界面效果如下图8-6所示。
2.1 全栈视角下的后台管理模块前后端划分与功能全流程剖析
将后台管理模块从 Thymeleaf 后端渲染模式迁移到 Vue 3 前端渲染模式,需要对数据流转、权限控制、组件设计和状态管理进行全面调整。以下是核心改造点和实施建议:
架构与交互模式的核心变化
1. 数据流转方式
- Thymeleaf 模式: 浏览器 → 表单提交/链接点击 → 后端控制器 → 数据库操作 → 重定向到管理页面
- Vue 3 模式: 浏览器 → Vue 组件 → API 请求 → 后端服务 → JSON 响应 → 前端更新视图
2. 权限控制方式
- Thymeleaf:后端基于角色渲染不同页面元素
- Vue 3:前端基于权限动态渲染组件,结合后端接口权限校验
2.2 全栈实战基于角色的权限控制及后台管理模块整体框架
前端路由与权限控制
- 增加了requiresRole属性,以校验角色权限。其中,访问后台管理
/admin页面必须要有ADMIN角色权限; - 增加了403-error页面,以响应没有权限的访问;
- 针对后台管理
/admin页面,启用了嵌套路由功能。
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
// ...为节约篇幅,此处省略非核心内容
{
path: '/note/publish',
name: 'note-publish',
component: () => import('../views/NotePublish.vue'),
meta: {
requiresAuth: true,
requiresRole: 'USER'
}
},
{
path: '/note/:noteId',
name: 'note-detail',
component: () => import('../views/NoteDetail.vue'),
meta: {
requiresAuth: true,
requiresRole: 'USER'
}
},
{
path: '/note/:noteId/edit',
name: 'note-edit',
component: () => import('../views/NoteEdit.vue'),
meta: {
requiresAuth: true,
requiresRole: 'USER'
}
},
{
path: '/explore',
name: 'explore',
component: () => import('../views/Explore.vue'),
meta: {
requiresAuth: true,
requiresRole: 'USER'
}
},
{
path: '/403-error',
name: 'forbidden',
component: () => import('../views/Forbidden.vue')
},
{
path: '/admin',
name: 'admin',
component: () => import('../views/AdminView.vue'),
meta: {
requiresAuth: true,
requiresRole: 'ADMIN'
},
children: [
{
path: '',
name: 'admin-redirect-dashboard',
redirect: '/admin/dashboard'
},
{
path: 'dashboard',
name: 'admin-dashboard',
component: () => import('../components/AdminDashboard.vue'),
},
{
path: 'user',
name: 'admin-user',
component: () => import('../components/AdminUser.vue'),
},
{
path: 'note',
name: 'admin-note',
component: () => import('../components/AdminNote.vue'),
},
{
path: 'comment',
name: 'admin-comment',
component: () => import('../components/AdminComment.vue'),
}
]
},
],
})
// 全局前置守卫
router.beforeEach(async (to, from, next) => {
// ...为节约篇幅,此处省略非核心内容
// 校验角色
if(to.meta.requiresRole && !authStore.hasRole(to.meta.requiresRole)) {
next({ name: 'forbidden' })
}
// 获取用户ID
if (to.name === 'profile-placeholder' && authStore.getUser) {
next({ name: 'user-profile', params: { userId: (authStore.getUser as any).userId } })
} else if (to.name === 'home' && authStore.hasRole('USER')) {
// 跳转从Home到Explore页面
next({ name: 'explore'})
} else if (to.name === 'home' && authStore.hasRole('ADMIN')) {
// 跳转从Home到Admin页面
next({ name: 'admin'})
} else {
next()
}
})
修改auth.ts
修改src\stores\auth.ts,检查是否具备指定角色:
export const useAuthStore = defineStore("auth", {
// ...为节约篇幅,此处省略非核心内容
actions: {
// ...为节约篇幅,此处省略非核心内容
,
// 检查是否具备指定角色
hasRole(role: any) {
if (!this.getUser) return false
return (this.getUser as User).role === (role as string)
},
}
})
新增Forbidden.vue
新增src\views\Forbidden.vue
<script setup lang="ts">
import { useRouter } from 'vue-router';
const router = useRouter();
// 返回
function goBack() {
router.back();
}
</script>
<template>
<div class="container align-items-center min-vh-100 py-4">
<div class="error-container">
<!-- 错误图标 -->
<div class="error-image">
<i class="fa fa-lock fa-5x text-danger"></i>
</div>
<!-- 错误标题 -->
<h2 class="error-title">访问受限</h2>
<!-- 错误信息 -->
<p class="error-message">
你没有权限访问此页面。<br>
请检查你的权限或联系管理员。
</p>
<!-- 返回按钮 -->
<button class="btn btn-primary" @click="goBack">返回上一页</button>
<!-- 跳转到首页 -->
<p class="back-home">
<a href="/">返回RN首页</a>
</p>
</div>
</div>
</template>
<style setup>
body {
background-color: #fef6f6;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
.error-container {
max-width: 400px;
margin: 0 auto;
padding: 40px 20px;
text-align: center;
}
.error-icon {
font-size: 80px;
color: #ff2442;
margin-bottom: 20px;
}
.error-title {
font-size: 24px;
font-weight: 700;
color: #333;
margin-bottom: 10px;
}
.error-message {
font-size: 16px;
color: #666;
margin-bottom: 30px;
}
.btn-primary {
background-color: #ff2442;
border-color: #ff2442;
border-radius: 12px;
padding: 12px;
font-size: 16px;
font-weight: 600;
transition: all 0.3s ease;
width: 100%;
}
.btn-primary:hover,
.btn-primary:focus {
background-color: #e61e3a;
border-color: #e61e3a;
box-shadow: 0 4px 12px rgba(255, 36, 66, 0.2);
}
.back-home {
margin-top: 20px;
font-size: 14px;
color: #999;
}
.back-home a {
color: #ff2442;
text-decoration: none;
}
.back-home a:hover {
text-decoration: underline;
}
.error-image {
width: 200px;
height: 200px;
margin: 0 auto 30px;
background-color: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
}
.error-image img {
width: 120px;
height: 120px;
}
</style>
后台管理模块整体框架
主体框架AdminView.vue
新增src\views\AdminView.vue:
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth'
import { useRouter } from "vue-router"
const authStore = useAuthStore()
const router = useRouter()
// 注销
function logout() {
authStore.logout()
// 跳转到登录页面
router.push({ name: 'login' })
}
</script>
<template>
<!--导航栏-->
<header class="navbar navbar-expand-lg">
<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="#sidebarMenu"
aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
</div>
</header>
<div class="container">
<div class="row">
<!--菜单-->
<div class="sidebar border border-right col-md-3 col-lg-2 p-0 bg-body-tertiary">
<div class="offcanvas-md offcanvas-end bg-body-tertiary" tabindex="-1" id="sidebarMenu"
aria-labelledby="sidebarMenuLabel">
<div class="offcanvas-body d-md-flex flex-column p-0 pt-lg-3 overflow-y-auto">
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link d-flex align-items-center gap-2 active" aria-current="page" href="/admin/dashboard">
<i class="fa fa-tachometer"></i>
数据看板
</a>
</li>
<li class="nav-item">
<a class="nav-link d-flex align-items-center gap-2" aria-current="page" href="/admin/user">
<i class="fa fa-users"></i>
用户管理
</a>
</li>
<li class="nav-item">
<a class="nav-link d-flex align-items-center gap-2" aria-current="page" href="/admin/note">
<i class="fa fa-file-text"></i>
笔记管理
</a>
</li>
<li class="nav-item">
<a class="nav-link d-flex align-items-center gap-2" aria-current="page" href="/admin/comment">
<i class="fa fa-comments"></i>
评论管理
</a>
</li>
</ul>
<hr class="my-3">
<ul class="nav flex-column mb-auto">
<li class="nav-item">
<a class="nav-link" href="#" @click="logout">退出登录</a>
</li>
</ul>
</div>
</div>
</div>
<!--内容区域-->
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4">
<!--代码片段-->
<RouterView />
</main>
</div>
</div>
</template>
<style setup>
/* 全局样式 */
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background-color: #f5f5f5;
}
.bi {
display: inline-block;
width: 1rem;
height: 1rem;
}
/*
* Sidebar
*/
@media (min-width: 768px) {
.sidebar .offcanvas-lg {
position: -webkit-sticky;
position: sticky;
top: 48px;
}
.navbar-search {
display: block;
}
.sidebar .nav-link {
font-size: .875rem;
font-weight: 500;
}
.sidebar .nav-link.active {
color: #2470dc;
}
.sidebar-heading {
font-size: .75rem;
}
}
</style>
其中,<RouterView />可以根据子路由的路径,动态替换AdminDashboard.vue、AdminUser.vue、、AdminNote.vue以及AdminComment.vue组件。
AdminDashboard.vue
<script setup lang="ts">
</script>
<template>
<div class="card shadow mb-4">
<div class="card-header py-3">
<h2>数据看板</h2>
</div>
<div class="card-body">
<p>暂未开放,敬请期待!</p>
</div>
</div>
</template>
AdminUser.vue
<script setup lang="ts">
</script>
<template>
<div class="card shadow mb-4">
<div class="card-header py-3">
<h2>用户管理</h2>
</div>
<div class="card-body">
<p>暂未开放,敬请期待!</p>
</div>
</div>
</template>
AdminNote.vue
<script setup lang="ts">
</script>
<template>
<div class="card shadow mb-4">
<div class="card-header py-3">
<h2>笔记管理</h2>
</div>
<div class="card-body">
<p>暂未开放,敬请期待!</p>
</div>
</div>
</template>
AdminComment.vue
<script setup lang="ts">
</script>
<template>
<div class="card shadow mb-4">
<div class="card-header py-3">
<h2>评论管理</h2>
</div>
<div class="card-body">
<p>暂未开放,敬请期待!</p>
</div>
</div>
</template>
运行调测
运行应用,当普通用户访问后台管理/admin页面时,可以看到访问受限的提示效果如下图9-1所示。
当管理员用户访问后台管理/admin页面时,可以看到能够正常访问,界面效果如下图9-2所示。
2.3 全栈实战数据看板功能及前端埋点
后端接口改造
AdminController返回数据看板的数据接口调整如下。
@GetMapping("/dashboard")
/*public String dashboard(Model model) {
// 统计数据
long userCount = userService.countUsers();
long noteCount = noteService.countNotes();
long commentCount = commentService.countComments();
List<NoteBrowseCountDto> noteBrowseCountDtoList = noteService.getNoteByBrowseCount(1, 10);
List<NoteBrowseTimeDto> noteBrowseTimeDtoList = noteService.getNoteByBrowseTime(1, 10);
model.addAttribute("userCount", userCount);
model.addAttribute("noteCount", noteCount);
model.addAttribute("commentCount", commentCount);
model.addAttribute("noteBrowseCountDtoList", noteBrowseCountDtoList);
model.addAttribute("noteBrowseTimeDtoList", noteBrowseTimeDtoList);
model.addAttribute("contentFragment", "admin-dashboard");
return "admin";
}*/
public ResponseEntity<?> dashboard() {
// 统计数据
long userCount = userService.countUsers();
long noteCount = noteService.countNotes();
long commentCount = commentService.countComments();
List<NoteBrowseCountDto> noteBrowseCountDtoList = noteService.getNoteByBrowseCount(1, 10);
List<NoteBrowseTimeDto> noteBrowseTimeDtoList = noteService.getNoteByBrowseTime(1, 10);
Map<String, Object> map = new HashMap<>();
map.put("userCount", userCount);
map.put("noteCount", noteCount);
map.put("commentCount", commentCount);
map.put("noteBrowseCountDtoList", noteBrowseCountDtoList);
map.put("noteBrowseTimeDtoList", noteBrowseTimeDtoList);
return ResponseEntity.ok(map);
}
前端DTO设计
新增note-browse-count-dto.ts
新增src\dto\note-browse-count-dto.ts
export interface NoteBrowseCountDto {
noteId: number;
title: string;
browseCount: number;
}
新增note-browse-time-dto.ts
新增src\dto\note-browse-time-dto.ts
export interface NoteBrowseTimeDto {
noteId: number;
title: string;
browseTime: number;
}
新增admin-dashboard-dto.ts
新增src\dto\admin-dashboard-dto.ts
export interface AdminDashboardDto {
userCount: number;
noteCount: number;
commentCount: number;
noteBrowseCountDtoList: Array<NoteBrowseCountDto>;
noteBrowseTimeDtoList: Array<NoteBrowseTimeDto>;
}
前端获取数据
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import axios from '@/services/axios';
import type { AdminDashboardDto } from '@/dto/admin-dashboard-dto';
const adminDashboardDto = ref<AdminDashboardDto>({
userCount: 0,
noteCount: 0,
commentCount: 0,
noteBrowseCountDtoList: [],
noteBrowseTimeDtoList: [],
});
onMounted(() => {
// 获取数据看板的数据
fetchDashboard();
});
const fetchDashboard = async () => {
try {
// 调用API获取用户数据
const response = await axios.get(`/api/admin/dashboard`);
console.log('response.json:', response.data);
adminDashboardDto.value = await response.data as AdminDashboardDto;
// 处理用户数据
} catch (error) {
console.error('获取用户数据失败:', error);
}
};
</script>
编写模板
<template>
<main>
<div class="card shadow mb-4">
<div class="card-header py-3">
<h2>数据看板</h2>
</div>
<div class="card-body">
<!-- 统计卡片 -->
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-left-primary shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-primary text-uppercase mb-1">用户总数</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ adminDashboardDto.userCount }}</div>
</div>
<div class="col-auto">
<i class="fa fa-users fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-left-success shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-success text-uppercase mb-1">笔记总数</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ adminDashboardDto.noteCount }}</div>
</div>
<div class="col-auto">
<i class="fa fa-file-text fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-left-info shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-info text-uppercase mb-1">评论总数</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ adminDashboardDto.commentCount }}
</div>
</div>
<div class="col-auto">
<i class="fa fa-comments fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-left-info shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-success text-uppercase mb-1">访问量排行</div>
</div>
<ol class="list-group list-group-numbered">
<li class="list-group-item d-flex justify-content-between align-items-start"
v-for="note in adminDashboardDto.noteBrowseCountDtoList">
<div class="ms-2 me-auto">{{ note.title }}
</div>
<span class="badge text-bg-primary rounded-pill">{{ note.browseCount }}
</span>
</li>
</ol>
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-left-info shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-success text-uppercase mb-1">访问时间排行</div>
</div>
<ol class="list-group list-group-numbered">
<li class="list-group-item d-flex justify-content-between align-items-start"
v-for="note in adminDashboardDto.noteBrowseTimeDtoList">
<div class="ms-2 me-auto">{{ note.title }}
</div>
<span class="badge text-bg-primary rounded-pill">{{ note.browseTime }}
</span>
</li>
</ol>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</template>
前端埋点统计浏览时长
在离开当前页面时,统计访问时长,并发送到后端API。
import { onBeforeRouteLeave, useRoute } from 'vue-router';
// 埋点
// 记录开始时间
const startTime = ref<number>(Date.now())
// 统计浏览时长
const handleBrowseTime = async() => {
// 获取浏览时长
const browseTime = Date.now() - startTime.value
// 发送API请求
try {
// 使用axios发送浏览事件到后端
await axios.post('/api/log/browse',
{
userId: me.value.userId,
noteId: parseInt(noteId.value.toString()),
browseTime: browseTime,
userAgent: navigator.userAgent
}, {
headers: {
'Content-Type': 'application/json'
}
}
)
} catch (error) {
console.error('埋点上报失败:', error)
}
}
// 导航离开该组件的对应路由时调用
onBeforeRouteLeave((to, from, next) => {
// 统计浏览时长
handleBrowseTime()
next()
})
onBeforeRouteLeave 是 Vue 3 中的组合式 API,用于在组件即将离开当前路由时执行逻辑。
更改跳转到笔记详情页的路由方式
设置通过router来路由页面,而非a href的方式。这样,Vue的路由才能获取到note-detail的相关信息。
function gotoNoteDetail(noteId: number) {
router.push({
name: 'note-detail',
params: {
noteId: noteId
}
});
}
<!-- ...为节约篇幅,此处省略非核心内容 -->
<!-- 点击跳转到笔记详情页 -->
<!-- <a :href="`/note/${note.noteId}`">-->
<a href="#" @click="gotoNoteDetail(note.noteId)">
<img class="masonry-note-image" :src="note.cover" :alt="note.title">
</a>
运行调测
当管理员用户访问后台管理/admin页面时,可以看到界面效果如下图9-3、图9-4所示。
2.4 全栈实战用户管理获取用户列表功能
后端接口改造
AdminController返回用户分页数据的接口调整如下。
/**
* 显示用户管理界面
*/
@GetMapping("/user")
/*public String user(Model model, @RequestParam(defaultValue = "1") int page) {
// 分页查询所有用户数据
Page<User> userPage = userService.getAllUsers(page, PAGE_SIZE);
model.addAttribute("userPage", userPage);
model.addAttribute("contentFragment", "admin-user");
return "admin";
}*/
public ResponseEntity<?> user(@RequestParam(defaultValue = "1") int page) {
// 分页查询所有用户数据
Page<User> userPage = userService.getAllUsers(page, PAGE_SIZE);
Map<String, Object> map = new HashMap<>();
map.put("userList", userPage.getContent());
map.put("currentPage", page);
map.put("totalPages", userPage.getTotalPages());
return ResponseEntity.ok(map);
}
前端组件设计
修改src\components\AdminUser.vue:
<script setup lang="ts">
import type { User } from '@/dto/user';
import axios from '@/services/axios';
import { onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
const route = useRoute()
// 用户分页
const userList = ref<Array<User>>([])
const totalPages = ref(0)
const currentPage = ref(1)
// 查询参数,默认第1页
const pageIndex = ref(route.query.page || 1)
onMounted(() => {
// 获取用户列表
fetchUserList()
})
// 获取用户列表
const fetchUserList = async () => {
try {
const response = await axios.get(`/api/admin/user?page=${pageIndex.value}`)
userList.value = response.data['userList']
totalPages.value = response.data['totalPages']
currentPage.value = response.data['currentPage']
} catch (error) {
console.error('获取用户列表失败:' + error)
}
}
</script>
<template>
<div class="card shadow mb-4">
<div class="card-header py-3">
<h2>用户列表</h2>
</div>
<div class="card-body">
<div class="table-responsive small">
<table class="table table-striped table-sm">
<thead>
<tr>
<th>ID</th>
<th>用户名</th>
<th>电话</th>
<th>角色</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="user in userList">
<td>{{ user.userId }}</td>
<td>{{ user.username }}</td>
<td>{{ user.phone }}</td>
<td>{{ user.role }}</td>
<td>
<button class="btn btn-sm btn-light">
编辑
</button>
<button class="btn btn-sm btn-danger">
删除
</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页控件 -->
<div class="d-flex justify-content-center">
<nav>
<ul class="pagination" v-if="totalPages > 0">
<li class="page-item" v-if="currentPage > 1">
<a class="page-link" :href="`/admin/user?page=${currentPage - 1}`">
上一页
</a>
</li>
<li class="page-item" v-for="pageNum in Array.from({ length: totalPages }, (_, i) => i + 1)"
:class="{ active: pageNum === currentPage }">
<a class="page-link" :href="`/admin/user?page=${pageNum}`">
{{ pageNum }}
</a>
</li>
<li class="page-item" v-if="currentPage < totalPages">
<a class="page-link" :href="`/admin/user?page=${currentPage + 1}`">
下一页
</a>
</li>
</ul>
</nav>
</div>
</div>
</div>
</template>
运行调测
当管理员用户访问用户管理/admin/user页面时,可以看到界面效果如下图9-5所示。
2.5 全栈实战用户管理编辑用户
后端编辑用户接口改造
AdminController编辑用户相关的接口调整如下。
/**
* 显示用户编辑界面
*/
@GetMapping("/user/{userId}/edit")
/*public String editUser(@PathVariable Long userId, Model model) {
// 判定用户是否存在,不存在则抛出异常
Optional<User> optionalUser = userService.findByUserId(userId);
if (!optionalUser.isPresent()) {
throw new UserNotFoundException("");
}
model.addAttribute("user", optionalUser.get());
model.addAttribute("contentFragment", "admin-user-edit");
return "admin";
}*/
public ResponseEntity<?> editUser(@PathVariable Long userId) {
// 判定用户是否存在,不存在则抛出异常
Optional<User> optionalUser = userService.findByUserId(userId);
if (!optionalUser.isPresent()) {
throw new UserNotFoundException("");
}
return ResponseEntity.ok(optionalUser.get());
}
/**
* 处理保存用户的请求
*/
@PostMapping("/user")
/*public String updateUser(@ModelAttribute User user) {
// 判定用户是否存在,不存在则抛出异常
Optional<User> optionalUser = userService.findByUserId(user.getUserId());
if (!optionalUser.isPresent()) {
throw new UserNotFoundException("");
}
User oldUser = optionalUser.get();
// 更新用户
userService.updateUserByAdmin(oldUser, user);
return "redirect:/admin/user";
}*/
public ResponseEntity<?> updateUser(@ModelAttribute User user) {
// 判定用户是否存在,不存在则抛出异常
Optional<User> optionalUser = userService.findByUserId(user.getUserId());
if (!optionalUser.isPresent()) {
throw new UserNotFoundException("");
}
User oldUser = optionalUser.get();
// 更新用户
userService.updateUserByAdmin(oldUser, user);
return ResponseEntity.ok("更新成功");
}
前端编辑用户组件设计
修改src\components\AdminUser.vue:
import { useRouter } from 'vue-router';
const router = useRouter();
// 路由到编辑用户界面
function handleEdit(userId: number) {
router.push({ path: `/admin/user/${userId}/edit` })
}
// ...为节约篇幅,此处省略非核心内容
<button class="btn btn-sm btn-light" @click="handleEdit(user.userId)">
编辑
</button>
设置路由
设置/admin/user/:userId/edit路径,以便跳转到用户编辑界面:
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
// ...为节约篇幅,此处省略非核心内容
,
{
path: '/admin',
name: 'admin',
component: () => import('../views/AdminView.vue'),
meta: {
requiresAuth: true,
requiresRole: 'ADMIN'
},
children: [
// ...为节约篇幅,此处省略非核心内容
,
{
path: 'user/:userId/edit',
name: 'admin-user-edit',
component: () => import('../components/AdminUserEdit.vue'),
}
]
}
],
})
新增AdminUserEdit.vue
新增src\components\AdminUserEdit.vue:
<script setup lang="ts">
import { User } from '@/dto/user';
import axios from '@/services/axios';
import { onMounted, ref } from 'vue';
import { useRouter, useRoute } from 'vue-router';
const formRef = ref<HTMLFormElement | null>(null);
const user = ref<User>(new User());
const router = useRouter();
const route = useRoute();
// 动态路由参数
const userId = ref(route.params.userId)
onMounted(() => {
// 获取用户信息
fetchUser()
});
// 获取用户信息
const fetchUser = async () => {
// 发送API请求
try {
// 使用axios发送请求到后端
const response = await axios.get(`/api/admin/user/${userId.value}/edit`)
user.value = response.data
} catch (error) {
console.error('用户更新失败:', error)
}
};
// 取消编辑
function cancelEdit() {
// 确认是否要取消
if (confirm('确定要取消编辑吗?')) {
router.back()
}
}
// 保存用户信息
const handleEdit = async () => {
if (formRef.value) {
const formData = new FormData(formRef.value);
// 发送API请求
try {
// 使用axios发送用户信息到后端
await axios.post('/api/admin/user', formData)
// 跳转
router.push({ path: '/admin/user' })
} catch (error) {
console.error('用户更新失败:', error)
}
}
};
</script>
<template>
<div class="card shadow mb-4">
<div class="card-header py-3">
<h2>编辑用户</h2>
</div>
<div class="card-body">
<form ref="formRef">
<!-- 隐藏用户ID -->
<input type="hidden" name="userId" v-model="user.userId">
<div class="row">
<div class="col-lg-6">
<!-- 用户名不可编辑 -->
<div class="form-group">
<label for="username">用户名 <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="username" name="username" v-model="user.username" disabled>
</div>
<!-- 密码 -->
<div class="form-group">
<label for="password">密码</label>
<input type="password" class="form-control" id="password" name="password"
placeholder="不修改请留空">
<div class="small text-muted">留空则不修改密码</div>
</div>
<!-- 手机号 -->
<div class="form-group">
<label for="phone">手机号 <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="phone" name="phone" v-model="user.phone" placeholder="请输入手机号">
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="mt-4">
<button type="button" class="btn btn-primary mr-2" @click="handleEdit">保存</button>
<button type="button" class="btn btn-secondary" @click="cancelEdit">取消
</button>
</div>
</form>
</div>
</div>
</template>
运行调测
当管理员用户访问用户管理编辑页面/admin/user/:userId/edit时,可以看到界面效果如下图9-6所示。
2.6 全栈实战用户管理删除用户
后端删除用户接口
AdminController删除用户相关的接口,已完全适配无需调整。
/**
* 处理用户删除的请求
*/
@DeleteMapping("/user/{userId}")
public ResponseEntity<DeleteResponseDto> deleteUser(@PathVariable Long userId) {
// 判定用户是否存在,不存在则抛出异常
Optional<User> optionalUser = userService.findByUserId(userId);
if (!optionalUser.isPresent()) {
throw new UserNotFoundException("");
}
userService.deleteUser(userId);
DeleteResponseDto deleteResponseDto = new DeleteResponseDto();
deleteResponseDto.setMessage("用户删除成功");
deleteResponseDto.setRedirectUrl("/admin/user");
return ResponseEntity.ok(deleteResponseDto);
}
前端删除用户组件设计
修改src\components\AdminUser.vue:
// 删除用户
const deleteUser = async (userId: number) => {
if (!confirm('确定要删除这个用户吗?')) return;
try {
// 调用API获取用户数据
const response = await axios.delete(`/api/admin/user/${userId}`);
console.log('response.json:', response.data);
// 从列表中移除用户
userList.value = userList.value.filter(u => u.userId !== userId);
} catch (error) {
console.error('获取用户数据失败:', error);
}
};
// ...为节约篇幅,此处省略非核心内容
<button class="btn btn-sm btn-danger" @click="deleteUser(user.userId)">删除</button>