二十四、“仿小红书”全栈项目实现前后端分离(三)

0 阅读13分钟

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所示。

图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-2 首页的界面效果

页面向下滑动,可以继续加载后续笔记内容,界面效果如下图8-3所示。

图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-4 访问首页进行关键字搜索

点击分类后进行关键字搜索,界面效果如下图8-5所示。

图8-5 点击分类后进行关键字搜索

点击底部导航未完成项目的按钮,界面效果如下图8-6所示。

图8-6 点击底部导航未完成项目的按钮

2.1 全栈视角下的后台管理模块前后端划分与功能全流程剖析

将后台管理模块从 Thymeleaf 后端渲染模式迁移到 Vue 3 前端渲染模式,需要对数据流转、权限控制、组件设计和状态管理进行全面调整。以下是核心改造点和实施建议:

架构与交互模式的核心变化

1. 数据流转方式

  • Thymeleaf 模式: 浏览器 → 表单提交/链接点击 → 后端控制器 → 数据库操作 → 重定向到管理页面
  • Vue 3 模式: 浏览器 → Vue 组件 → API 请求 → 后端服务 → JSON 响应 → 前端更新视图

2. 权限控制方式

  • Thymeleaf:后端基于角色渲染不同页面元素
  • Vue 3:前端基于权限动态渲染组件,结合后端接口权限校验

2.2 全栈实战基于角色的权限控制及后台管理模块整体框架

前端路由与权限控制

  1. 增加了requiresRole属性,以校验角色权限。其中,访问后台管理/admin页面必须要有ADMIN角色权限;
  2. 增加了403-error页面,以响应没有权限的访问;
  3. 针对后台管理/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所示。

图9-1 访问受限的提示

当管理员用户访问后台管理/admin页面时,可以看到能够正常访问,界面效果如下图9-2所示。

图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所示。

图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所示。

图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所示。

图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>