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

0 阅读20分钟

1.1 全栈视角下的笔记模块前后端划分与功能全流程剖析

将笔记模块从 Thymeleaf 后端渲染模式迁移到 Vue 3 前端渲染模式,需要对数据流转、组件设计、API 接口和交互逻辑进行全面调整。以下是核心改造点和实施建议:

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

数据流转方式

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

通过以上改造,笔记模块将从后端渲染转变为前端渲染,实现更流畅的交互体验和更好的可维护性。关键是要处理好前后端分离后的API设计、状态管理和用户体验优化。

1.2 全栈实战发布功能从Thymeleaf到Vue 3的架构升级指南

后端接口改造

修改NoteController:

/**
 * 处理笔记发布请求
 */
@PostMapping("/publish")
/*public String publishNote(@Valid @ModelAttribute("note") NotePublishDto notePublishDto,
                            BindingResult bindingResult,
                            Model model) {
    // 验证表单
    if (bindingResult.hasErrors()) {
        model.addAttribute("note", notePublishDto);
        return "note-publish";
    } else {
        // 获取当前用户信息
        User user = userService.getCurrentUser();

        // 通过笔记服务创建笔记
        *//*noteService.createNote(notePublishDto, user);*//*
        Note note = noteService.createNote(notePublishDto, user);
        model.addAttribute("note", note);

        // 显示笔记发布成功页面
        return "note-publish-success";
    }
}*/
public ResponseEntity<?> publishNote(@Valid @ModelAttribute("note") NotePublishDto notePublishDto,
                            BindingResult bindingResult) {
    // 验证表单
    if (bindingResult.hasErrors()) {
        // 自定义错误响应
        Map<String, String> errors = new HashMap<>();
        bindingResult.getFieldErrors().forEach(error ->
                errors.put(error.getField(), error.getDefaultMessage())
        );

        return ResponseEntity.badRequest().body(errors);
    } else {
        // 获取当前用户信息
        User user = userService.getCurrentUser();

        // 通过笔记服务创建笔记
        noteService.createNote(notePublishDto, user);

        // 返回成功响应
        return ResponseEntity.ok("笔记创建成功");
    }
}

前端组件设计

NotePublish.vue

新增src\views\NotePublish.vue

<script setup lang="ts">
import { NotePublishDto } from '@/dto/note-publish-dto';
import type { ApiValidationError } from '@/errors/api-validation-error';
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import axios from '@/services/axios';
import { AxiosError } from 'axios'

const noteFormRef = ref<HTMLFormElement | null>(null);
const note = ref<NotePublishDto>(new NotePublishDto())
const errors = ref<ApiValidationError>({})
const uploadedImagesRef = ref<HTMLDivElement | null>(null);
const imageUploadRef = ref<HTMLInputElement | null>(null);
const router = useRouter();
let selectedFiles: Array<File> = [];

onMounted(() => {
  // 监听图片上传
  imageUploadRef.value?.addEventListener('change', handleFileChange)
})

// 监听图片上传
function handleFileChange(this: HTMLInputElement, ev: Event) {
  if (ev.target instanceof HTMLInputElement && ev.target.files) {
    const files = Array.from(ev.target.files)
    selectedFiles = selectedFiles.concat(files)
    updateFileList()
  }
}

// 更新文件列表的显示
function updateFileList() {
  if (uploadedImagesRef.value) {
    // 清空图片预览
    uploadedImagesRef.value.innerHTML = "";

    // 生成图片预览
    for (let i = 0; i < selectedFiles.length; i++) {
      const fileItem = document.createElement("div");
      fileItem.className = "uploaded-image";

      const deleteBtn = document.createElement("div");
      deleteBtn.className = "delete-btn";
      deleteBtn.onclick = () => deleteFile(i);
      fileItem.appendChild(deleteBtn);

      const deleteBtnIcon = document.createElement("i");
      deleteBtnIcon.className = "fa fa-times";
      deleteBtn.appendChild(deleteBtnIcon);

      const imagePreview = document.createElement("img");
      imagePreview.className = "preview-img";
      imagePreview.alt = "预览";
      fileItem.appendChild(imagePreview);

      const reader = new FileReader();
      reader.onload = function (e) {
        if (e.target != null) {
          imagePreview.src = e.target.result + '';
        }
      }
      reader.readAsDataURL(selectedFiles[i]);

      uploadedImagesRef.value.appendChild(fileItem);
    }
  }
}

// 删除预览文件
function deleteFile(i: number) {
  selectedFiles.splice(i, 1);
  updateFileList();
}

// 取消发布
function cancelPublish() {
  // 确认是否要取消
  if (confirm('确定要取消发布吗?所有内容将不会被保存')) {
    router.back()
  }
}

// 发布笔记
const handleNotePublish = async () => {
  if (noteFormRef.value) {
    // 获取表单数据
    const formData = new FormData(noteFormRef.value)

    // 创建DataTransfer对象
    const dataTransfer = new DataTransfer()

    // 将选中的图片添加到DataTransfer对象中
    for (let i = 0; i < selectedFiles.length; i++) {
      dataTransfer.items.add(selectedFiles[i])
    }

    // 将DataTransfer对象设置给表单数据
    if (imageUploadRef.value && dataTransfer.files) {
      imageUploadRef.value.files = dataTransfer.files

      for (const file of imageUploadRef.value.files) {
        formData.append('images', file)
      }
    }

    // 调用API发布笔记
    try {
      await axios.post(`/api/note/publish`, formData)

      // 发布成功提示
      alert('发布成功')

      // 清空错误
      errors.value = {}

      // 跳转到首页
      router.push({ name: 'home' })
    } catch (err) {
      // 失败
      if (err instanceof AxiosError) {
        // 获取错误信息
        const axiosError = err as AxiosError<ApiValidationError>
        if (axiosError.response?.status === 400 && axiosError.response.data) {
          // 绑定后端返回的错误信息到errors上
          errors.value = axiosError.response.data
        }
      }
    }
  }
}


</script>

<template>
  <!-- 操作栏 -->
  <div class="header">
    <div class="container">
      <div class="d-flex justify-content-between align-items-center">
        <button class="btn btn-cancel" id="cancelPublishBtn" @click="cancelPublish">
          取消
        </button>
        <button class="btn btn-publish" id="publishNoteBtn" @click="handleNotePublish">
          发布
        </button>
      </div>
    </div>
  </div>

  <!-- 主体部分 -->
  <div class="container content">
    <form id="noteForm" method="post" action="/note/publish" enctype="multipart/form-data" ref="noteFormRef">
      <!-- 标题输入框 -->
      <input type="text" class="note-title" id="title" name="title" v-model="note.title" placeholder="分享你的生活点滴...">
      <div class="error-message" v-if="errors.title">
        {{ errors.title }}
      </div>

      <!-- 图片上传区域 -->
      <div class="image-upload">
        <!-- 图片选取上传按钮 -->
        <div class="upload-btn" onclick="document.getElementById('imageUpload').click()">
          <i class="fa fa-plus"></i>
        </div>
        <p>上传图片(最多9张)</p>
        <input type="file" id="imageUpload" name="images" multiple style="display: none;" accept="image/*"
          v:field="note.images" ref="imageUploadRef">

        <!-- 已上传图片预览 -->
        <div class="uploaded-images" id="uploadedImages" ref="uploadedImagesRef"></div>

        <!-- 错误消息 -->
        <div class="error-message" v-if="errors.images">
          {{ errors.images }}
        </div>
      </div>

      <!-- 笔记内容 -->
      <textarea class="note-content" id="content" name="content" v-model="note.content"
        placeholder="详细描述你的分享内容..."></textarea>
      <div class="error-message" v-if="errors.content">
        {{ errors.content }}
      </div>

      <!-- 话题 -->
      <div class="topic-input">
        <input type="text" class="form-control" id="topicInput" name="topics" v-model="note.topics"
          placeholder="添加话题,多个话题用空格隔开">
      </div>

      <!-- 分类 -->
      <div class="category-selector">
        <label for="categorySelect" class="form-label">请选择一个分类:</label>
        <select class="form-control" id="categorySelect" name="category" v-model="note.category">
          <option value="穿搭">穿搭</option>
          <option value="美食">美食</option>
          <option value="彩妆">彩妆</option>
          <option value="影视">影视</option>
          <option value="职场">职场</option>
          <option value="情感">情感</option>
          <option value="家居">家居</option>
          <option value="游戏">游戏</option>
          <option value="旅行">旅行</option>
          <option value="健身">健身</option>
        </select>
        <div class="error-message" v-if="errors.category">
          {{ errors.category }}
        </div>
      </div>
    </form>
  </div>
</template>

<style setup>
/* 基础样式 */
body {
  background-color: #fef6f6;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}

.container {
  max-width: 768px;
  margin: 0 auto;
  padding: 0 16px;
}

/* 顶部导航栏 */
.header {
  background-color: white;
  border-bottom: 1px solid #eee;
  padding: 12px 0;
  position: sticky;
  top: 0;
  z-index: 100;
}

.header .btn {
  padding: 6px 16px;
  border-radius: 20px;
  font-weight: 600;
}

.btn-cancel {
  color: #333;
  border: 1px solid #ddd;
}

.btn-publish {
  background-color: #ff2442;
  color: white;
  border: none;
}

.btn-publish:hover {
  background-color: #e61e3a;
}

/* 内容区域 */
.content {
  padding: 16px 0;
}

/* 标题输入框 */
.note-title {
  border: none;
  width: 100%;
  font-size: 20px;
  font-weight: 600;
  padding: 12px 0;
  outline: none;
}

.note-title::placeholder {
  color: #999;
}

/* 图片上传区域 */
.image-upload {
  background-color: #f8f8f8;
  border-radius: 8px;
  padding: 24px 0;
  text-align: center;
  margin-bottom: 20px;
}

.image-upload .upload-btn {
  width: 80px;
  height: 80px;
  border: 2px dashed #ddd;
  border-radius: 8px;
  margin: 0 auto;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  transition: all 0.3s;
}

.image-upload .upload-btn:hover {
  border-color: #ff2442;
}

.image-upload .upload-btn i {
  font-size: 24px;
  color: #999;
}

.image-upload p {
  margin-top: 12px;
  color: #666;
  font-size: 14px;
}

/* 已上传图片展示 */
.uploaded-images {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin-top: 16px;
}

.uploaded-image {
  width: 80px;
  height: 80px;
  border-radius: 8px;
  overflow: hidden;
  position: relative;
}

.uploaded-image img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.uploaded-image .delete-btn {
  position: absolute;
  top: 4px;
  right: 4px;
  width: 20px;
  height: 20px;
  background-color: rgba(0, 0, 0, 0.6);
  color: white;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  font-size: 12px;
}

/* 笔记内容编辑器 */
.note-content {
  width: 100%;
  min-height: 200px;
  border: none;
  outline: none;
  font-size: 16px;
  line-height: 1.6;
  padding: 12px 0;
}

.note-content::placeholder {
  color: #999;
}

/* 话题选择 */
.topic-input {
  position: relative;
  margin-bottom: 20px;
}

.topic-input input {
  width: 100%;
  padding: 12px;
  border: 1px solid #eee;
  border-radius: 8px;
  outline: none;
}

/* 分类选择 */
.category-selector {
  margin-bottom: 20px;
}

.category-input i {
  color: #ff2442;
}

/* 添加到 style 标签中 */
.category-selector select {
  width: 100%;
  padding: 12px;
  border: 1px solid #eee;
  border-radius: 8px;
  background-color: white;
  appearance: none;
  -webkit-appearance: none;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23666'%3E%3Cpath d='M7 10l5 5 5-5z'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right 12px center;
  background-size: 16px;
  cursor: pointer;
}

.category-selector select:focus {
  outline: none;
  border-color: #ff2442;
  box-shadow: 0 0 0 2px rgba(255, 36, 66, 0.1);
}

.btn-view-note {
  background-color: #ff2442;
  color: white;
}

.error-message {
  color: #ff2442;
  font-size: 12px;
  margin-top: 4px;
}
</style>

note-publish-dto.ts

新增src\dto\note-publish-dto.ts

export class NotePublishDto {
  title: string = '';
  content: string = '';
  topics: string = '';
  category: string = '';
}

路由配置

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 },
    }
  ],
})

运行调测

运行应用访问笔记发布页面进行操作,界面效果如下图5-1所示。

图5-1 访问笔记发布页面进行操作

如果笔记发布页面输入的内容不符合要求,则会进行错误提示,如下图5-2所示。

图5-2 错误提示

笔记发布成功之后,会有如下图5-3所示的提示,并自动跳转到首页。

图5-3 笔记发布成功提示

1.3 全栈实战笔记详情查询功能后端接口改造

后端接口改造

修改NoteController

修改NoteController:

/**
 * 显示笔记详情页面
 */
@GetMapping("/{noteId}")
/*public String showNoteDetail(@PathVariable Long noteId, Model model) {
    // 查询指定noteId的笔记
    Optional<Note> optionalNote = noteService.findNoteById(noteId);

    // 判定笔记是否存在,不存在则抛出异常
    if (!optionalNote.isPresent()) {
        throw new NoteNotFoundException("");
    }

    Note note = optionalNote.get();
    model.addAttribute("note", note);

    return "note-detail";
}*/
public ResponseEntity<?> showNoteDetail(@PathVariable Long noteId) {
    // 查询指定noteId的笔记
    Optional<Note> optionalNote = noteService.findNoteById(noteId);

    // 判定笔记是否存在,不存在则抛出异常
    if (!optionalNote.isPresent()) {
        throw new NoteNotFoundException("");
    }

    Note note = optionalNote.get();
    User currentUser = userService.getCurrentUser();
    // 将Note对象转为NoteDetailDto对象
    return ResponseEntity.ok(NoteDetailDto.toNoteDetailDto(note, currentUser));
}

新增NoteDetailDto

为了能正确序列化,减少数据库查询和网络传输成本,创建了如下NoteDetailDto:

package com.waylau.rednote.dto;

import com.waylau.rednote.entity.Note;
import com.waylau.rednote.entity.User;
import lombok.Getter;
import lombok.Setter;

import java.util.ArrayList;
import java.util.List;

/**
 * NoteDetailDto 笔记详情页展示DTO
 *
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/09/09
 **/
@Getter
@Setter
public class NoteDetailDto {
    // 以下字段来自Note
    private Long noteId;
    private String title;
    private String content;
    private List<String> images = new ArrayList<>();
    private List<String> topics = new ArrayList<>();
    private String category;
    // 以下字段来自User
    private String username;
    private String avatar;
    private Long userId;
    private boolean isLiked;
    private long likeCount;

    public static NoteDetailDto toNoteDetailDto(Note note, User currentUser) {
        NoteDetailDto noteDetailDto = new NoteDetailDto();
        noteDetailDto.setNoteId(note.getNoteId());
        noteDetailDto.setTitle(note.getTitle());
        noteDetailDto.setContent(note.getContent());
        noteDetailDto.setImages(note.getImages());
        noteDetailDto.setTopics(note.getTopics());
        noteDetailDto.setCategory(note.getCategory());
        noteDetailDto.setLikeCount(note.getLikeCount());

        User author = note.getAuthor();
        noteDetailDto.setUsername(author.getUsername());
        noteDetailDto.setAvatar(author.getAvatar());
        noteDetailDto.setUserId(author.getUserId());

        noteDetailDto.setLiked(note.isLikedByUser(currentUser.getUserId()));

        return noteDetailDto;
    }

}

1.4 全栈实战笔记详情页多图轮播功能

前端组件设计

NoteDetail.vue

新增src\views\NoteDetail.vue

<script setup lang="ts">
import { NoteDetailDto } from '@/dto/note-detail-dto';
import { User } from '@/dto/user';
import { useAuthStore } from '@/stores/auth';
import axios from '@/services/axios';
import { onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import router from '@/router';

const carouselContainerRef = ref<HTMLDivElement | null>(null);
const note = ref<NoteDetailDto>(new NoteDetailDto());
const currentIndex = ref<number>(0);
const me = ref<User>(new User());
const authStore = useAuthStore();

// 获取路由参数中的noteId
const route = useRoute();
const noteId = ref(route.params.noteId);

onMounted(() => {
  // 获取当前用户信息
  me.value = authStore.getUser ? authStore.getUser : new User();

  // 获取笔记详情
  fetchNote(noteId.value)
});

const fetchNote = async (noteId: any) => {
  try {
    const response = await axios.get(`/api/note/${noteId}`);
    note.value = response.data;
  } catch (error) {
    console.error('获取笔记详情失败:' + error);
  }

}

// 更新轮播位置
function updateCarouselPosition() {
  if (carouselContainerRef.value) {
    carouselContainerRef.value.style.transform = `translateX(-${currentIndex.value * 100}%)`;
  }
}

// 上一张
function prevSlide() {
  currentIndex.value = Math.max(currentIndex.value - 1, 0);
  updateCarouselPosition();
}

// 下一张
function nextSlide() {
  if (note.value.images && note.value.images.length > 0) {
    currentIndex.value = Math.min(currentIndex.value + 1, note.value.images.length - 1);
    updateCarouselPosition();
  }

}

//  取消发布
function handleBack() {
  router.back();
}


// TODO 删除笔记
function deleteNote() {

}

// TODO 打开预览
function openPreview(index: number) {

}

// TODO 关闭预览
function closePreview() {

}

// TODO 预览上一张
function previewPrev() {

}

// TODO 预览下一张
function previewNext() {

}
</script>
<template>
  <!-- 主内容区 -->
  <main class="container py-4 main-content">
    <!-- 笔记内容 -->
    <div class="note-container">
      <!-- 笔记图片 -->
      <div class="note-images">
        <!-- 图片轮播容器 -->
        <div class="carousel-container" id="carouselContainer" ref="carouselContainerRef">
          <!-- 动态生成轮播项 -->
          <div class="carousel-item-img" v-for="(image, index) in note.images">
            <!-- 在img上加 preview-trigger -->
            <img class="note-image preview-trigger" :src="image" :alt="note.title" @click="openPreview(index)">
          </div>
        </div>

        <!-- 轮播指示器 -->
        <div class="carousel-indicator" id="carouselIndicator">
          <span id="currentSlide">{{ currentIndex + 1 }}</span> / <span id="totalSlides">{{ note.images.length }}</span>
        </div>

        <!-- 轮播控制按钮 -->
        <div class="carousel-control prev" @click="prevSlide">
          <i class="fa fa-angle-left"></i>
        </div>
        <div class="carousel-control next" @click="nextSlide">
          <i class="fa fa-angle-right"></i>
        </div>
      </div>

      <!-- 笔记内容区 -->
      <div class="note-content">
        <!-- 标题 -->
        <h1 class="note-title">{{ note.title }}</h1>

        <!-- 内容 -->
        <p class="note-text">
          {{ note.content }}<br><br>
        </p>

        <!-- 话题 -->
        <div class="note-tags">
          <span class="tag" v-for="topic in note.topics">
            {{ topic }}
          </span>
        </div>

        <!-- 操作栏 -->
        <div class="note-action-bar">
          <!-- 返回 -->
          <button class="btn btn-light btn-sm" @click="handleBack">
            <i class="fa fa-arrow-left"></i>
          </button>
          <!-- 编辑 -->
          <a :href="'/note/' + note.noteId + '/edit'">
            <button class="btn btn-light btn-sm" v-if="me.username === note.username">
              <i class="fa fa-edit"></i>
            </button>
          </a>
          <!-- 删除 -->
          <button class="btn btn-light btn-sm" v-if="me.username === note.username" @click="deleteNote">
            <i class="fa fa-trash"></i>
          </button>
          <!-- 分享 -->
          <button class="btn btn-light btn-sm">
            <i class="fa fa-share-alt"></i>
          </button>
          <!-- 点赞 -->
          <button class="btn btn-light btn-sm">
            <i class="fa fa-heart-o"></i>
          </button>
          <!-- 收藏 -->
          <button class="btn btn-light btn-sm">
            <i class="fa fa-star-o"></i>
          </button>
        </div>

        <!-- 作者信息 -->
        <div class="author-info">
          <!-- 点击作者头像跳转到作者详情页 -->
          <a :href="'/user/profile/' + note.userId">
            <img class="author-avatar" :src="note.avatar ? note.avatar : '/images/rn_avatar.png'" alt="作者头像">
          </a>

          <div>
            <div class="author-name">
              {{ note.username }}
            </div>
            <div class="author-meta">
              已获得 1024 粉丝
            </div>
          </div>
          <div class="author-follow" v-if="me.username != note.username">
            + 关注
          </div>
        </div>
      </div>

      <!-- 评论区 -->
      <div class="comments-section">
        <div class="comments-header">
          <div class="comments-title">
            评论区
          </div>
        </div>

        <!-- 评论输入框 -->
        <div class="comment-input">
          <img class="comment-avatar" src="/images/rn_avatar.png" alt="头像">
          <textarea class="comment-textarea" placeholder="分享你的想法..."></textarea>
          <div class="comment-btn">
            发送
          </div>
        </div>

        <!-- 评论列表 -->
        <div class="comment-list" id="commentList"></div>
      </div>
    </div>
  </main>

  <!-- 图片预览模态框 -->
  <div class="preview-modal" id="previewModal">
    <div class="preview-content">
      <img class="preview-image" id="previewImage" src="" alt="图片预览">
      <div class="preview-close" @click="closePreview">
        <i class="fa fa-times"></i>
      </div>
      <div class="preview-counter" id="previewCounter">
        <span id="previewCurrent">1</span> / <span id="previewTotal">{{ note.images.length }}</span>
      </div>
      <div class="preview-control prev" @click="previewPrev">
        <i class="fa fa-angle-left"></i>
      </div>
      <div class="preview-control next" @click="previewNext">
        <i class="fa fa-angle-right"></i>
      </div>
    </div>
  </div>

  <!-- 回复弹窗 -->
  <div class="modal" id="replyModal" tabindex="-1" aria-labelledby="replyModalLabel">
    <div class="modal-dialog">
      <div class="modal-content">
        <div class="modal-header">
          <h5 class="modal-title" id="replyModalLabel">回复 <span id="replyToUsername"></span></h5>
        </div>
        <div class="modal-body">
          <div class="reply-to-content"></div>
          <textarea class="form-control" id="replyContent" rows="3" placeholder="写下你的回复..."></textarea>
        </div>
        <div class="modal-footer">
          <button type="button" class="btn btn-light close-model" data-bs-dismiss="modal">取消</button>
          <button type="button" class="btn btn-danger" id="submitReply">提交回复</button>
        </div>
      </div>
    </div>
  </div>
</template>
<style setup>
/* 全局样式 */
body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
  background-color: #f5f5f5;
}

/* 笔记内容区 */
.note-container {
  background-color: white;
  margin-bottom: 20px;
  border-radius: 12px;
  overflow: hidden;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}

.note-images {
  position: relative;
  background-color: #000;
}

.note-image {
  width: 100%;
  max-height: 60vh;
  object-fit: contain;
}

.note-content {
  padding: 20px;
}

.note-title {
  font-size: 20px;
  font-weight: 600;
  margin-bottom: 16px;
}

.note-text {
  font-size: 16px;
  line-height: 1.6;
  margin-bottom: 20px;
}

.note-tags {
  margin-bottom: 20px;
}

.tag {
  display: inline-block;
  background-color: #f0f0f0;
  color: #666;
  padding: 4px 12px;
  border-radius: 16px;
  font-size: 14px;
  margin-right: 8px;
  margin-bottom: 8px;
}

.note-action-bar {
  margin-bottom: 20px;
}

/* 作者信息 */
.author-info {
  display: flex;
  align-items: center;
  margin-bottom: 20px;
}

.author-avatar {
  width: 48px;
  height: 48px;
  border-radius: 50%;
  margin-right: 12px;
}

.author-name {
  font-size: 16px;
  font-weight: 600;
}

.author-follow {
  margin-left: auto;
  background-color: #ff2442;
  color: white;
  padding: 6px 16px;
  border-radius: 20px;
  font-size: 14px;
  cursor: pointer;
}

.author-follow.following {
  background-color: #f0f0f0;
  color: #666;
}

/* 评论区(第一部分)*/
.comments-section {
  background-color: white;
  border-radius: 12px;
  overflow: hidden;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
  padding: 20px;
}

.comments-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 20px;
}

.comments-title {
  font-size: 18px;
  font-weight: 600;
}

.comment-avatar {
  width: 36px;
  height: 36px;
  border-radius: 50%;
  margin-right: 12px;
}

.comment-input {
  display: flex;
  margin-bottom: 20px;
}

.comment-textarea {
  flex-grow: 1;
  border: 1px solid #e0e0e0;
  border-radius: 20px;
  padding: 8px 16px;
  font-size: 14px;
  resize: none;
  outline: none;
}

.comment-btn {
  margin-left: 12px;
  background-color: #ff2442;
  color: white;
  padding: 8px 16px;
  border-radius: 20px;
  font-size: 14px;
  cursor: pointer;
  display: flex;
  justify-content: center;
  align-items: center;
}

.comment-item {
  padding: 10px 0;
  border-bottom: 1px solid #f5f5f5;
}

.comment-header {
  display: flex;
  align-items: center;
  margin-bottom: 5px;
}


/* 新增轮播和预览样式 */
.carousel-container {
  display: flex;
  transition: transform 0.5s ease;
}

.carousel-item-img {
  min-width: 100%;
  position: relative;
}

.carousel-indicator {
  position: absolute;
  bottom: 15px;
  right: 15px;
  background-color: rgba(0, 0, 0, 0.5);
  color: white;
  padding: 4px 10px;
  border-radius: 15px;
  font-size: 12px;
  z-index: 10;
}

.carousel-control {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  color: white;
  font-size: 24px;
  padding: 10px;
  cursor: pointer;
  z-index: 10;
  opacity: 0.7;
  transition: opacity 0.3s;
}

.carousel-control:hover {
  opacity: 1;
}

.carousel-control.prev {
  left: 10px;
}

.carousel-control.next {
  right: 10px;
}

/* 图片预览模态框 */
.preview-modal {
  display: none;
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: rgba(0, 0, 0, 0.9);
  z-index: 1000;
  justify-content: center;
  align-items: center;
}

.preview-content {
  max-width: 90%;
  max-height: 90%;
  position: relative;
}

.preview-image {
  max-width: 100%;
  max-height: 85vh;
  object-fit: contain;
  cursor: pointer;
}

.preview-close {
  position: absolute;
  top: -40px;
  right: 0;
  color: white;
  font-size: 30px;
  cursor: pointer;
}

.preview-counter {
  position: absolute;
  bottom: -30px;
  left: 50%;
  transform: translateX(-50%);
  color: white;
  font-size: 14px;
}

.preview-control {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  color: white;
  font-size: 30px;
  cursor: pointer;
  padding: 20px;
}

.preview-control.prev {
  left: -60px;
}

.preview-control.next {
  right: -60px;
}

/* 去掉下划线 */
a {
  text-decoration: none;
}

/* 点赞按钮样式 */
.liked {
  color: #ff2442;
}

/* 评论区(第二部分)*/
.comment-user-avatar {
  width: 32px;
  height: 32px;
  border-radius: 50%;
  margin-right: 10px;
}

.comment-user-info {
  flex: 1;
}

.comment-username {
  font-weight: bold;
}

.comment-time {
  font-size: 12px;
  color: #999;
}

.comment-content {
  margin-left: 42px;
  margin-bottom: 10px;
}


.reply-btn,
.delete-comment {
  background: none;
  border: none;
  color: #999;
  cursor: pointer;
  font-size: 12px;
}

.delete-comment {
  margin-left: 10px;
}

.empty-comments {
  color: #999;
  text-align: center;
  padding: 20px 0;
}

/*评论回复*/
.reply-list {
  margin-left: 42px;
  margin-top: 10px;
  padding-left: 10px;
  border-left: 2px solid #f5f5f5;
}

.reply-item {
  margin-bottom: 10px;
}

.reply-header {
  display: flex;
  align-items: center;
  font-size: 14px;
  color: #666;
}

.reply-username,
.reply-target {
  font-weight: bold;
  margin-right: 5px;
}

.reply-to {
  margin-right: 5px;
}

.reply-time {
  margin-left: 10px;
  font-size: 12px;
  color: #999;
}

.reply-content {
  margin-top: 5px;
  margin-left: 0;
}

.submit-reply {
  background-color: #ff2442;
  color: white;
  border: none;
  padding: 8px 15px;
  border-radius: 4px;
  cursor: pointer;
}
</style>

note-detail-dto.ts

新增src\dto\note-detail-dto.ts

export class NoteDetailDto {
  noteId: number = 0;
  title: string = '';
  content: string = '';
  images: Array<string> = [];
  topics: Array<string> = [];
  category: string = '';
  username: string = '';
  userId: number = 0;
  avatar: string = '';
  likeCount: number = 0;
  liked: boolean = false;
}

路由配置

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    // ...为节约篇幅,此处省略非核心内容

    ,
    {
      path: '/note/:noteId',
      name: 'note-detail',
      component: () => import('../views/NoteDetail.vue'),
      meta: { requiresAuth: true },
    }
  ],
})

运行调测

运行应用访问笔记详情页面进行操作,界面效果如下图5-4所示。

图5-4 访问笔记详情页面进行操作

1.5 全栈实战笔记详情页图放大预览功能

定义三个ref

const previewModalVisible = ref(false);
const previewModalRef = ref<HTMLDivElement | null>(null);
const previewCurrentIndex = ref(0);

监听键盘按键事件

onMounted(() => {
  // ...为节约篇幅,此处省略非核心内容

  // 监听键盘按键
  window.addEventListener('keydown', handleKeydown);
});

onUnmounted(() => {
  window.removeEventListener('keydown', handleKeydown);
});

// 键盘事件处理
const handleKeydown = (event: KeyboardEvent) => {
  if (previewModalVisible.value) {
    switch (event.key) {
      case 'Escape':
        closePreview();
        break;
      case 'ArrowLeft':
        previewPrev();
        break;
      case 'ArrowRight':
        previewNext();
        break;
    }
  }
};

放大预览功能逻辑如下:

<script setup lang="ts">

// ...为节约篇幅,此处省略非核心内容

// 打开预览
function openPreview(index: number) {
  console.log("openPreview " + index);
  previewCurrentIndex.value = index;
  previewModalVisible.value = true;

  if (previewModalRef.value) {
    previewModalRef.value.style.display = 'flex';
  }

  // 防止背景滚动
  document.body.style.overflow = 'hidden';
}

// 关闭预览
function closePreview() {
  previewModalVisible.value = false;

  if (previewModalRef.value) {
    previewModalRef.value.style.display = 'none';
  }

  // 恢复背景滚动
  document.body.style.overflow = '';
  // 更新轮播位置
  updateCarouselPosition();
}

// 预览上一张
function previewPrev() {
  if (note.value.images && note.value.images.length > 0) {
    previewCurrentIndex.value = Math.max(0, previewCurrentIndex.value - 1);
  }
}

// 预览下一张
function previewNext() {
  if (note.value.images && note.value.images.length > 0) {
    previewCurrentIndex.value = Math.min(note.value.images.length - 1, previewCurrentIndex.value + 1);
  }
}
</script>

<template>

  <!-- ...为节约篇幅,此处省略非核心内容 -->

  <!-- 图片预览模态框 -->
  <div class="preview-modal" id="previewModal" ref="previewModalRef" v-show="previewModalVisible">
    <div class="preview-content">
      <img class="preview-image" id="previewImage" :src="note.images[previewCurrentIndex]"
        v-if="note.images && note.images.length > 0" alt="图片预览">
      <div class="preview-close" @click="closePreview">
        <i class="fa fa-times"></i>
      </div>
      <div class="preview-counter" id="previewCounter">
        <span id="previewCurrent">{{ previewCurrentIndex + 1 }}</span> / <span id="previewTotal">{{ note.images?.length
          || 0 }}</span>
      </div>
      <div class="preview-control prev" @click="previewPrev">
        <i class="fa fa-angle-left"></i>
      </div>
      <div class="preview-control next" @click="previewNext">
        <i class="fa fa-angle-right"></i>
      </div>
    </div>
  </div>

  <!-- ...为节约篇幅,此处省略非核心内容 -->
</template>

1.6 全栈实战编辑功能:掌握前后端分离架构下的更新策略

后端接口改造

修改NoteController

修改NoteController:

@GetMapping("/{noteId}/edit")
@PreAuthorize("@noteServiceImpl.isAuthor(#noteId, authentication.name)")
/* public String editNote(@PathVariable Long noteId, Model model) {
    Optional<Note> noteOptional = noteService.findByNoteId(noteId);

    if (!noteOptional.isPresent()) {
        throw new NoteNotFoundException("");
    }

    Note note = noteOptional.get();

    NoteEditDto noteEditDto = new NoteEditDto();
    noteEditDto.setNoteId(note.getNoteId());
    noteEditDto.setTitle(note.getTitle());
    noteEditDto.setContent(note.getContent());
    noteEditDto.setCategory(note.getCategory());
    noteEditDto.setImages(note.getImages());

    // List转为String
    noteEditDto.setTopics(StringUtil.listToSplit(note.getTopics(), " "));

    model.addAttribute("note", noteEditDto);

    return "note-edit";
}*/
public ResponseEntity<?> editNote(@PathVariable Long noteId) {
    Optional<Note> noteOptional = noteService.findByNoteId(noteId);

    if (!noteOptional.isPresent()) {
        throw new NoteNotFoundException("");
    }

    Note note = noteOptional.get();

    NoteEditDto noteEditDto = new NoteEditDto();
    noteEditDto.setNoteId(note.getNoteId());
    noteEditDto.setTitle(note.getTitle());
    noteEditDto.setContent(note.getContent());
    noteEditDto.setCategory(note.getCategory());
    noteEditDto.setImages(note.getImages());

    // List转为String
    noteEditDto.setTopics(StringUtil.listToSplit(note.getTopics(), " "));

    return ResponseEntity.ok(noteEditDto);
}

@PostMapping("/{noteId}")
@PreAuthorize("@noteServiceImpl.isAuthor(#noteId, authentication.name)")
/*public String updateNote(@PathVariable Long noteId,
                          @Valid @ModelAttribute("note") NoteEditDto noteEditDto,
                          BindingResult bindingResult,
                          Model model,
                          RedirectAttributes redirectAttributes) {
    Optional<Note> noteOptional = noteService.findByNoteId(noteId);

    if (!noteOptional.isPresent()) {
        throw new NoteNotFoundException("");
    }

    // 验证表单
    if (bindingResult.hasErrors()) {
        model.addAttribute("note", noteEditDto);
        return "note-edit";
    }

    Note note = noteOptional.get();

    try {
        noteService.updateNote(note, noteEditDto);
        redirectAttributes.addFlashAttribute("success", "笔记更新成功");
        return "redirect:/note/" + noteId;
    } catch (Exception ex) {
        log.error("笔记更新异常: {}", ex.getMessage(), ex);

        redirectAttributes.addFlashAttribute("error", "笔记更新失败: " + ex.getMessage());
        return "redirect:/note/" + noteId + "/edit";
    }
}*/
public ResponseEntity<?> updateNote(@PathVariable Long noteId,
                          @Valid @RequestBody NoteEditDto noteEditDto,
                          BindingResult bindingResult) {
    Optional<Note> noteOptional = noteService.findByNoteId(noteId);

    if (!noteOptional.isPresent()) {
        throw new NoteNotFoundException("");
    }

    // 验证表单
    if (bindingResult.hasErrors()) {
        // 自定义错误响应
        Map<String, String> errors = new HashMap<>();
        bindingResult.getFieldErrors().forEach(error ->
                errors.put(error.getField(), error.getDefaultMessage())
        );
        return ResponseEntity.badRequest().body(errors);
    }

    Map<String, String> map = new HashMap<>();
    Note note = noteOptional.get();

    try {
        noteService.updateNote(note, noteEditDto);
        map.put("success", "笔记更新成功");
        return ResponseEntity.ok(map);
    } catch (Exception ex) {
        log.error("笔记更新异常: {}", ex.getMessage(), ex);
        map.put("error", "笔记更新失败: " + ex.getMessage());
        return ResponseEntity.ok(map);
    }
}

前端组件设计

NoteEdit.vue

新增src\views\NoteEdit.vue

<script setup lang="ts">
import { NoteEditDto } from '@/dto/note-edit-dto';
import type { ApiValidationError } from '@/errors/api-validation-error';
import axios from '@/services/axios';
import type { AxiosError } from 'axios';
import { onMounted, ref } from 'vue';
import { useRouter, useRoute } from 'vue-router';

const note = ref<NoteEditDto>(new NoteEditDto())
const errors = ref<ApiValidationError>({})
const success = ref('')
const error = ref('')
const router = useRouter()
const route = useRoute()

// 从路由参数中获取笔记ID
const noteId = ref(route.params.noteId)

// 组件挂载时,获取笔记详情
onMounted(() => {
  fetchNote(noteId.value)
});

// 获取笔记详情
const fetchNote = async (noteId: any) => {
  try {
    const response = await axios.get(`/api/note/${noteId}/edit`)
    note.value = response.data
  } catch (error) {
    console.error('获取笔记详情失败:' + error)
  }
}

// 取消编辑
function cancelEdit() {
  if (confirm('确定要取消修改吗?')) {
    router.back()
  }
}

// 保存笔记
const handleNoteEdit = async () => {
  try {
    const response = await axios.post(`/api/note/${noteId.value}`, note.value)

    if (response.data['success']) {
      success.value = response.data['success']
    } else if (response.data['error']) {
      error.value = response.data['error']
    }
  } catch (error) {
    const axiosError = error as AxiosError<ApiValidationError>
    if (axiosError.response?.status === 400 && axiosError.response.data) {
      errors.value = axiosError.response.data
    }
  }
}
</script>
<template>
  <!-- 操作栏 -->
  <div class="header">
    <div class="container">
      <div class="d-flex justify-content-between align-items-center">
        <button class="btn btn-cancel" id="cancelPublishBtn" @click="cancelEdit">
          取消
        </button>
        <button class="btn btn-publish" id="publishNoteBtn" @click="handleNoteEdit">
          保存
        </button>
      </div>
    </div>
  </div>

  <!-- 主体部分 -->
  <div class="container content">
    <form id="noteForm" method="post">
      <!-- 标题输入框 -->
      <input type="text" class="note-title" id="title" name="title" v-model="note.title" placeholder="分享你的生活点滴...">
      <div class="error-message" v-if="errors.title">
        {{ errors.title }}
      </div>

      <!-- 已上传图片预览 -->
      <div class="uploaded-images" id="uploadedImages">
        <div class="uploaded-image" v-for="image in note.images">
          <img :src="image" class="preview-img">
        </div>
      </div>
      <!-- 错误消息 -->
      <div class="error-message" v-if="errors.images">
        {{ errors.images }}
      </div>

      <!-- 笔记内容 -->
      <textarea class="note-content" id="content" name="content" v-model="note.content"
        placeholder="详细描述你的分享内容..."></textarea>
      <div class="error-message" v-if="errors.content">
        {{ errors.content }}
      </div>

      <!-- 话题 -->
      <div class="topic-input">
        <input type="text" class="form-control" id="topicInput" name="topics" v-model="note.topics"
          placeholder="添加话题,多个话题用空格隔开">
      </div>

      <!-- 分类 -->
      <div class="category-selector">
        <label for="categorySelect" class="form-label">请选择一个分类:</label>
        <select class="form-control" id="categorySelect" name="category" v-model="note.category">
          <option value="穿搭">穿搭</option>
          <option value="美食">美食</option>
          <option value="彩妆">彩妆</option>
          <option value="影视">影视</option>
          <option value="职场">职场</option>
          <option value="情感">情感</option>
          <option value="家居">家居</option>
          <option value="游戏">游戏</option>
          <option value="旅行">旅行</option>
          <option value="健身">健身</option>
        </select>
        <div class="error-message" v-if="errors.category">
          {{ errors.category }}
        </div>
      </div>
    </form>

    <!-- 操作反馈 -->
    <div v-if="success" class="alert alert-success mt-4">
      <i class="fa fa-check-circle"></i>
      {{ success }}
    </div>
    <div v-if="error" class="alert alert-danger mt-4">
      <i class="fa fa-exclamation-circle"></i>
      {{ error }}
    </div>
  </div>
</template>
<style setup>
/* 基础样式 */
body {
  background-color: #fef6f6;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}

.container {
  max-width: 768px;
  margin: 0 auto;
  padding: 0 16px;
}

/* 顶部导航栏 */
.header {
  background-color: white;
  border-bottom: 1px solid #eee;
  padding: 12px 0;
  position: sticky;
  top: 0;
  z-index: 100;
}

.header .btn {
  padding: 6px 16px;
  border-radius: 20px;
  font-weight: 600;
}

.btn-cancel {
  color: #333;
  border: 1px solid #ddd;
}

.btn-publish {
  background-color: #ff2442;
  color: white;
  border: none;
}

.btn-publish:hover {
  background-color: #e61e3a;
}

/* 内容区域 */
.content {
  padding: 16px 0;
}

/* 标题输入框 */
.note-title {
  border: none;
  width: 100%;
  font-size: 20px;
  font-weight: 600;
  padding: 12px 0;
  outline: none;
}

.note-title::placeholder {
  color: #999;
}

/* 已上传图片展示 */
.uploaded-images {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin-top: 16px;
}

.uploaded-image {
  width: 80px;
  height: 80px;
  border-radius: 8px;
  overflow: hidden;
  position: relative;
}

.uploaded-image img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.uploaded-image .delete-btn {
  position: absolute;
  top: 4px;
  right: 4px;
  width: 20px;
  height: 20px;
  background-color: rgba(0, 0, 0, 0.6);
  color: white;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  font-size: 12px;
}

/* 笔记内容编辑器 */
.note-content {
  width: 100%;
  min-height: 200px;
  border: none;
  outline: none;
  font-size: 16px;
  line-height: 1.6;
  padding: 12px 0;
}

.note-content::placeholder {
  color: #999;
}

/* 话题选择 */
.topic-input {
  position: relative;
  margin-bottom: 20px;
}

.topic-input input {
  width: 100%;
  padding: 12px;
  border: 1px solid #eee;
  border-radius: 8px;
  outline: none;
}

/* 分类选择 */
.category-selector {
  margin-bottom: 20px;
}

.category-input i {
  color: #ff2442;
}

/* 添加到 style 标签中 */
.category-selector select {
  width: 100%;
  padding: 12px;
  border: 1px solid #eee;
  border-radius: 8px;
  background-color: white;
  appearance: none;
  -webkit-appearance: none;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23666'%3E%3Cpath d='M7 10l5 5 5-5z'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right 12px center;
  background-size: 16px;
  cursor: pointer;
}

.category-selector select:focus {
  outline: none;
  border-color: #ff2442;
  box-shadow: 0 0 0 2px rgba(255, 36, 66, 0.1);
}

.btn-view-note {
  background-color: #ff2442;
  color: white;
}

.error-message {
  color: #ff2442;
  font-size: 12px;
  margin-top: 4px;
}
</style>

note-edit-dto.ts

新增src\dto\note-edit-dto.ts

export class NoteEditDto {
  noteId: number = 0;
  title: string = '';
  content: string = '';
  images: Array<string> = [];
  topics: string = '';
  category: string = '';
}

路由配置

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    // ...为节约篇幅,此处省略非核心内容

    ,
    {
      path: '/note/:noteId/edit',
      name: 'note-edit',
      component: () => import('../views/NoteEdit.vue'),
      meta: { requiresAuth: true },
    }
  ],
})

运行调测

运行应用访问笔记编辑页面进行操作,操作成功界面效果如下图5-5所示。

图5-5 操作成功界面效果

操作失败界面效果如下图5-6所示。

图5-6 操作失败界面效果

1.7 全栈实战笔记删除功能

后端接口

NoteController删除接口已经适配,无需调整。

/**
 * 处理删除笔记的请求
 */
@DeleteMapping("/{noteId}")
@PreAuthorize("@noteServiceImpl.isAuthor(#noteId, authentication.name)")
public ResponseEntity<DeleteResponseDto> deleteNote(@PathVariable Long noteId) {
    // 检查笔记是否存在
    Optional<Note> optionalNote = noteService.findNoteById(noteId);
    if (!optionalNote.isPresent()) {
        throw new NoteNotFoundException("");
    }

    Note note = optionalNote.get();

    // 使用服务删除笔记
    noteService.deleteNote(note);

    // 返回响应的内容
    DeleteResponseDto deleteResponseDto = new DeleteResponseDto();
    deleteResponseDto.setMessage("笔记删除成功");
    deleteResponseDto.setRedirectUrl("/user/profile");

    return ResponseEntity.ok(deleteResponseDto);
}

前端组件设计

修改src\views\NoteDetail.vue,增加如下函数:

// 删除笔记
const deleteNote = async () => {
  try {
    if (confirm('确定要删除该笔记吗?')) {
      await axios.delete(`/api/note/${noteId.value}`);

      alert('删除成功');

      // 跳转到用户信息页面
      router.push({ name: 'profile-placeholder' });
    }
  }
  catch (error) {
    console.error('删除失败:', error);
  }
}

// ...为节约篇幅,此处省略非核心内容

<!-- 删除 -->
<button class="btn btn-light btn-sm" v-if="me.username === note.username" @click="deleteNote">
  <i class="fa fa-trash"></i>
</button>

注意,跳转的是用户信息页面,路由的名称是“profile-placeholder”,而非“user-profile”。

运行调测

运行应用访问笔记详情页面进行删除操作,操作成功界面效果如下图5-7所示。

图5-7 删除操作成功界面效果

点击“确定”按钮之后,就能跳转到用户信息页面。

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

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

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

1. 数据流转方式

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

2. 状态管理

  • Thymeleaf:依赖后端会话和页面刷新
  • Vue 3:使用 Pinia/Vuex 或组件状态管理状态

通过以上改造,点赞模块将从后端渲染转变为前端渲染,实现更流畅的交互体验和更好的可维护性。关键是要处理好前后端分离后的API设计、状态管理和用户体验优化。

2.2 全栈实战点赞功能:掌握缓存与状态管理协同策略

后端接口

LikeController点赞接口已经适配,无需调整。

/**
 * 处理点赞、取消点赞请求
 *
 * @param noteId
 * @return
 */
@PostMapping("/{noteId}")
public ResponseEntity<LikeResponseDto> toggleLike(@PathVariable Long noteId) {
    User currentUser = userService.getCurrentUser();

    boolean isLiked = likeService.toggleLike(noteId, currentUser);
    long likeCount = likeService.getLikeCount(noteId);

    return ResponseEntity.ok(new LikeResponseDto(isLiked, likeCount));
}

前端组件设计

定义DTO对象

新增src\dto\like-response-dto.ts

export class LikeResponseDto {
  likeCount: number = 0;
  liked: boolean = false;
}

增加点赞事件处理

修改src\views\NoteDetail.vue,增加如下函数:

import { LikeResponseDto } from '@/dto/like-response-dto';

// 点赞状态
const likeResponseDto = ref<LikeResponseDto>(new LikeResponseDto());

// 点赞
const handleLike = async () => {
  try {
    const response = await axios.post(`/api/like/${noteId.value}`)
    likeResponseDto.value = response.data
  } catch (error) {
    console.error('点赞错误:', error)
  }
}

// ...为节约篇幅,此处省略非核心内容

<!-- 点赞 -->
<button class="btn btn-light btn-sm" @click="handleLike">
 <i :class="likeResponseDto.liked ? 'fa fa-heart liked' : 'fa fa-heart-o'"></i>
 {{ likeResponseDto.likeCount }}
</button>

处理点赞状态

初始化笔记数据时,刷新点赞状态。

const fetchNote = async (noteId: any) => {
  try {
    const response = await axios.get(`/api/note/${noteId}`);
    note.value = response.data;

    // 刷新点赞状态
    likeResponseDto.value.likeCount = note.value.likeCount;
    likeResponseDto.value.liked = note.value.liked;
  } catch (error) {
    console.error('获取笔记详情失败:' + error);
  }
}

运行调测

运行应用访问笔记详情页面进行点赞操作,未点赞前界面效果如下图6-1所示。

图6-1 未点赞前界面效果

点赞后界面效果如下图6-2所示。

图6-2 点赞后界面效果

3.1 全栈视角下的评论模块前后端划分与功能全流程剖析

将评论模块从 Thymeleaf 后端渲染模式迁移到 Vue 3 前端渲染模式,需要对数据流转、交互逻辑、API 设计和状态管理进行全面调整。以下是核心改造点和实施建议。

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

1. 数据流转方式

  • Thymeleaf 模式: 浏览器 → 表单提交 → 后端控制器 → 数据库操作 → 重定向到笔记详情页
  • Vue 3 模式: 浏览器 → Vue 组件 → API 请求 → 后端服务 → JSON 响应 → 前端更新视图

2. 渲染与交互方式

  • Thymeleaf:服务器端渲染,每次操作后刷新整个页面
  • Vue 3:前端渲染,局部更新评论列表,无刷新体验

通过以上改造,评论模块将从后端渲染转变为前端渲染,实现更流畅的交互体验和更好的可维护性。关键是要处理好前后端分离后的API设计、状态管理和用户体验优化。

3.2 全栈实战评论功能:掌握千万级访问的实时评论应对方案

后端接口

CommentController评论接口已经适配,无需调整。

// 处理创建评论的请求
@PostMapping("/{noteId}")
public ResponseEntity<CommentResponseDto> createComment(@PathVariable("noteId") Long noteId,
                                                        @RequestBody String content) {
    // 判定笔记是否存在
    Optional<Note> optionalNote = noteService.findNoteById(noteId);
    if (!optionalNote.isPresent()) {
        throw new NoteNotFoundException("");
    }

    Note note = optionalNote.get();
    User user = userService.getCurrentUser();

    Comment comment = commentService.createComment(note, user, content);

    // 将Comment对象转换成DTO对象
    CommentResponseDto commentResponseDto = CommentResponseDto.toCommentResponseDto(comment);

    return ResponseEntity.ok(commentResponseDto);
}

// 处理获取笔记评论列表的请求
@GetMapping("/{noteId}")
public ResponseEntity<List<CommentResponseDto>> getCommentsByNoteId(@PathVariable("noteId") Long noteId) {
    // 判定笔记是否存在
    Optional<Note> optionalNote = noteService.findNoteById(noteId);
    if (!optionalNote.isPresent()) {
        throw new NoteNotFoundException("");
    }

    List<Comment> comments = commentService.getCommentsByNoteId(noteId);

    // 将Comment对象转换成DTO对象
    List<CommentResponseDto> commentResponseDtoList = comments.stream().map(CommentResponseDto::toCommentResponseDto)
            .collect(Collectors.toUnmodifiableList());

    return ResponseEntity.ok(commentResponseDtoList);
}

// 处理创建回复的请求
@PostMapping("/{noteId}/reply/{parentCommentId}")
public ResponseEntity<CommentResponseDto> replyToComment(@PathVariable("noteId") Long noteId,
                                                            @PathVariable("parentCommentId") Long parentCommentId,
                                                            @RequestBody String content) {
    // 判定笔记是否存在
    Optional<Note> optionalNote = noteService.findNoteById(noteId);
    if (!optionalNote.isPresent()) {
        throw new NoteNotFoundException("");
    }

    // 判定父级评论是否存在
    Optional<Comment> optionalParentComment = commentService.findCommentById(parentCommentId);
    if (!optionalParentComment.isPresent()) {
        throw new CommentNotFoundException("");
    }

    // 判定父级评论是否属于该笔记
    Comment parentComment = optionalParentComment.get();
    if (!parentComment.getNote().getNoteId().equals(noteId)) {
        throw new CommentNotFoundException("评论与笔记不匹配");
    }

    Note note = optionalNote.get();
    User user = userService.getCurrentUser();
    Comment reply = commentService.replyToComment(note, parentComment, user, content);

    // 将Comment对象转换成DTO对象
    CommentResponseDto commentResponseDto = CommentResponseDto.toCommentResponseDto(reply);

    return ResponseEntity.ok(commentResponseDto);
}

// 处理删除评论(包含回复)的请求
@DeleteMapping("/{commentId}")
public ResponseEntity<Void> deleteComment(@PathVariable("commentId") Long commentId) {
    // 判定评论是否存在
    Optional<Comment> optionalComment = commentService.findCommentById(commentId);
    if (!optionalComment.isPresent()) {
        throw new CommentNotFoundException("");
    }

    // 判定评论是否是自己的
    Comment comment = optionalComment.get();
    User user = userService.getCurrentUser();
    if (!comment.getUser().getUserId().equals(user.getUserId())) {
        throw new CommentNotFoundException("无权删除他人的评论");
    }

    commentService.deleteComment(comment);

    return ResponseEntity.noContent().build();
}

前端组件设计

定义DTO对象

新增src\dto\comment-response-dto.ts

export class CommentResponseDto {
  commentId: number = 0;
  content: string = '';
  noteId: number | null = null;
  userId: number | null = null;
  username: string = '';
  avatar: string = '';
  createdAt: string = '';
  parentCommentId: number | null = null;
  parentCommentUsername: string = '';
  replies: Array<CommentResponseDto> = [];
}

增加评论事件处理

修改src\views\NoteDetail.vue,增加如下函数:

// 评论状态
const newComment = ref('');
const commentResponseDtoArray = ref<Array<CommentResponseDto>>([]);

// 发布评论
const postComment = async () => {
  if (newComment.value.trim() === '') {
    return
  }

  try {
    const response = await axios.post(`/api/comment/${noteId.value}`,
      // 传递的是纯文本内容
      newComment.value.trim(),
      { headers: { 'Content-Type': 'text/plain' } }
    )

    // 返回的评论列表插入到原来列表顶部
    commentResponseDtoArray.value.unshift(response.data)
    newComment.value = ''
  } catch (error) {
    console.error('发布评论错误:', error)
  }
}


// ...为节约篇幅,此处省略非核心内容

<!-- 评论输入框 -->
<div class="comment-input">
    <img class="comment-avatar" src="/images/rn_avatar.png" alt="头像">
    <textarea class="comment-textarea" placeholder="分享你的想法..." v-model="newComment"></textarea>
    <div class="comment-btn" @click="postComment">
    发送
    </div>
</div>

3.3 实战无刷新查询评论列表功能

处理评论列表状态

初始化笔记数据时,刷新评论列表状态

onMounted(() => {
    // ...为节约篇幅,此处省略非核心内容

    // 加载笔记评论
    fetchNoteComments()
});

// 加载笔记评论
const fetchNoteComments = async () => {
  try {
    const response = await axios.get(`/api/comment/${noteId.value}`)
    commentResponseDtoArray.value = response.data
  } catch (error) {
    console.error('获取笔记评论错误:', error)
  }
}

编写模板内容

<!-- 评论列表 -->
<div class="comment-list" id="commentList">
    <!-- 评论列表为空的处理 -->
    <p class="empty-comments" v-if="commentResponseDtoArray.length === 0">
    暂无评论,快来发表你的看法吧
    </p>

    <!-- 评论列表不为空的处理 -->
    <div class="comment-item" v-for="comment in commentResponseDtoArray" :key="comment.commentId">
    <!-- 评论头 -->
    <div class="comment-header">
        <!-- 作者信息 -->
        <!-- 点击用户头像跳转到用户详情页 -->
        <a :href="`/user/profile/${comment.userId}`">
        <img :src="comment.avatar ? comment.avatar : '/images/rn_avatar.png'" alt="用户头像"
            class="comment-user-avatar">
        </a>
        <div class="comment-user-info">
        <div class="comment-username">{{ comment.username }}</div>
        <div class="comment-time">{{ comment.createdAt }}</div>
        </div>

        <!-- 回复评论按钮 -->
        <button class="reply-btn">
        <i class="fa fa-comment-o"></i>
        </button>

        <!-- 删除评论按钮 -->
        <button class="delete-comment">
        <i class="fa fa-trash-o"></i>
        </button>
    </div>

    <!-- 评论内容 -->
    <div class="comment-content">
        {{ comment.content }}
    </div>

    <!-- 回复列表 -->
    <div class="reply-list">
    </div>
    </div>
</div>

运行调测

运行应用访问笔记详情页面,未发布评论界面效果如下图7-1所示。

图7-1 未发布评论界面效果

发布评论后评论列表界面效果如下图7-2所示。

图7-2 发布评论后评论列表界面效果

时间格式化

在时间显示上,需要做格式化处理。

// 格式化日期
const formatDate = (dateString: string) => {
  const date = new Date(dateString);
  const formattedDate = date.toLocaleString();
  return formattedDate
}

在模板上使用上述函数即可:

<div class="comment-time">{{ formatDate(comment.createAt) }}</div>

时间格式化后的评论列表界面效果如下图7-3所示。

图7-3 时间格式化后的评论列表界面效果

3.4 实战无刷新删除评论功能

处理删除评论的事件

删除请求发送成功后,执行无刷新删除评论。

// 删除评论
const deleteComment = async (commentId: number) => { 
  if (!confirm('确定要删除这条评论吗?')) {
    return
  }

  try {
    await axios.delete(`/api/comment/${commentId}`)
    
    // 删除成功后,将该评论从列表中删除
    commentResponseDtoArray.value = 
      commentResponseDtoArray.value.filter(comment => comment.commentId !== commentId)
  } catch (error) {
    console.error('删除评论错误:', error)
  }
}

编写模板内容

<!-- 删除评论按钮 -->
<button class="delete-comment" v-if="me.username === comment.username" 
  @click="deleteComment(comment.commentId)">
  <i class="fa fa-trash-o"></i>
</button>

确保是笔记的作者自己,才能看到删除评论的按钮。

3.5 实现回复弹窗及回复列表展示功能

实现回复弹窗功能

const replyModalRef = ref<HTMLDivElement | null>(null);
const replyContentRef = ref<HTMLTextAreaElement | null>(null);
const replyToCommentResponseDto = ref<CommentResponseDto>(new CommentResponseDto());

// ...为节约篇幅,此处省略非核心内容

// 回复评论
function handleReplyComment(comment: CommentResponseDto) { 
  // 显示回复框
  showReplyModal(comment)

  // 设置当前回复的评论
  replyToCommentResponseDto.value = comment
}

// 显示回复框
function showReplyModal(comment: CommentResponseDto) { 
  if (replyModalRef.value) {
    replyModalRef.value.classList.add('show')
    replyModalRef.value.style.display = 'block'

    // 防止背景滚动
    document.body.classList.add('modal-open')

    // 自动聚焦到数框
    setTimeout(() => {
      replyContentRef.value?.focus()
    }, 100)
  }
}

// ...为节约篇幅,此处省略非核心内容

<!-- 回复评论按钮 -->
<button class="reply-btn" @click="handleReplyComment(comment)">
 <i class="fa fa-comment-o"></i>
</button>

// ...为节约篇幅,此处省略非核心内容

  <!-- 回复弹窗 -->
  <div class="modal" id="replyModal" tabindex="-1" aria-labelledby="replyModalLabel" ref="replyModalRef" aria-hidden="true">
    <div class="modal-dialog">
      <div class="modal-content">
        <div class="modal-header">
          <h5 class="modal-title" id="replyModalLabel">回复 <span id="replyToUsername">{{ replyToCommentResponseDto.username }}</span></h5>
        </div>
        <div class="modal-body">
          <div class="reply-to-content"></div>
          <textarea class="form-control" id="replyContent" rows="3" placeholder="写下你的回复..." ref="replyContentRef"></textarea>
        </div>
        <div class="modal-footer">
          <button type="button" class="btn btn-light close-model" data-bs-dismiss="modal" @click="hideReplyModal">取消</button>
          <button type="button" class="btn btn-danger" id="submitReply" @click="submitReply">提交回复</button>
        </div>
      </div>
    </div>
  </div>

当点击回复评论按钮时,会显示回复弹窗。效果如下图7-4所示。

图7-4 发布评论后评论列表界面效果

实现回复评论功能

在回复弹窗上当点击“提交回复”,则会将回复提交到后端接口,而后关闭窗口;点击“取消”则直接关闭窗口。代码逻辑如下:

// 隐藏回复框
function hideReplyModal() { 
  if (replyModalRef.value && replyContentRef.value) {
    replyModalRef.value.classList.remove('show')
    replyModalRef.value.style.display = 'none'

    // 恢复背景滚动
    document.body.classList.remove('modal-open')

    // 清空输入框
    replyContentRef.value.value = ''
  }

  replyToCommentResponseDto.value = new CommentResponseDto()
}

// 提交回复
const submitReply = async () => { 
  const replyContent = replyContentRef.value

  if (!replyContent) {
    return
  }

  try { 
    const parentCommentId = replyToCommentResponseDto.value.commentId

    const response = await axios.post(`/api/comment/${noteId.value}/reply/${parentCommentId}`,
      // 传递文本内容
      replyContentRef.value?.value.trim(),
      {
        headers: {
          'Content-Type': 'text/plain'
        }
      }
    )

    // TODO: 回复添加到父级评论的回复列表中

    // 关闭回复框
    hideReplyModal()
  } catch (error) { 
    console.error('提交回复失败:' + error)
  }
}

实现回复列表展示功能

回复列表是存在于评论对象的replies属性中,因此,当回复成功之后,将回复对象添加到评论对象的replies属性中,代码逻辑如下:

// 提交回复
const submitReply = async () => {
  const replyContent = replyContentRef.value

  if (!replyContent) {
    return
  }

  try {
    const parentCommentId = replyToCommentResponseDto.value.commentId

    const response = await axios.post(`/api/comment/${noteId.value}/reply/${parentCommentId}`,
      // 传递文本内容
      replyContentRef.value?.value.trim(),
      {
        headers: {
          'Content-Type': 'text/plain'
        }
      }
    )

    // 回复添加到父级评论的回复列表中
    const commentIndex = commentResponseDtoArray.value.findIndex(item => item.commentId === parentCommentId)
    if (commentIndex !== -1) { 
      if (!commentResponseDtoArray.value[commentIndex].replies) { 
        commentResponseDtoArray.value[commentIndex].replies = []
      }
      commentResponseDtoArray.value[commentIndex].replies.unshift(response.data)
    }

    // 关闭回复框
    hideReplyModal()
  } catch (error) {
    console.error('提交回复失败:' + error)
  }
}

展示回复列表,代码如下:

<!-- 回复列表 -->
<div class="reply-list" v-for="reply in comment.replies" :key="reply.commentId">
    <div class="reply-item">
    <div class="reply-header">
        <!-- 点击用户名跳转到用户详情页 -->
        <a :href="`/user/profile/${reply.userId}`">
        <span class="reply-username">{{ reply.username }}</span>
        </a>
        <span class="reply-to">»</span>
        <span class="reply-target">{{ reply.parentCommentUsername ? reply.parentCommentUsername : '评论作者'
        }}</span>
        <span class="reply-time">{{ formatDate(reply.createAt) }}</span>
    </div>
    <div class="reply-content">{{ reply.content }}</div>
    <!-- 回复回复的按钮-->
    <button class="reply-btn" @click="handleReplyComment(reply)">
        <i class="fa fa-comment-o"></i>
    </button>
    <!-- 删除回复的按钮-->
    <button class="delete-comment" v-if="me.username === reply.username"
        @click="deleteReply(reply.commentId)">
        <i class="fa fa-trash-o"></i>
    </button>
    </div>
</div>

3.6 实战评论树的遍历渲染方案及无刷新删除回复

针对回复的回复处理

针对回复,也是可以继续进行回复。处理逻辑类似,因此可以复用相关的代码:

<!-- 回复回复的按钮-->
<button class="reply-btn" @click="handleReplyComment(reply)">
    <i class="fa fa-comment-o"></i>
</button>

不过,评论和回复的存储结构是不同的,因此,需要对submitReply函数进行抽痛,以适配具有树形结构回复内容的场景。

// 提交回复
const submitReply = async () => {
  const replyContent = replyContentRef.value

  if (!replyContent) {
    return
  }

  try {
    const parentCommentId = replyToCommentResponseDto.value.commentId

    const response = await axios.post(`/api/comment/${noteId.value}/reply/${parentCommentId}`,
      // 传递文本内容
      replyContentRef.value?.value.trim(),
      {
        headers: {
          'Content-Type': 'text/plain'
        }
      }
    )

    // 回复添加到父级评论的回复列表中
    /*const commentIndex = commentResponseDtoArray.value.findIndex(item => item.commentId === parentCommentId)
    if (commentIndex !== -1) { 
      if (!commentResponseDtoArray.value[commentIndex].replies) { 
        commentResponseDtoArray.value[commentIndex].replies = []
      }
      commentResponseDtoArray.value[commentIndex].replies.unshift(response.data)
    }*/
    commentResponseDtoArray.value.forEach(root => {
      deepTraverse(root, response.data, root.replies)
    })

    // 关闭回复框
    hideReplyModal()
  } catch (error) {
    console.error('提交回复失败:' + error)
  }
}

// 先遍历找根评论,再找子回复
const deepTraverse = (root: CommentResponseDto, response: CommentResponseDto, replies: Array<CommentResponseDto>) => {
  if (root.commentId === response.parentCommentId) {
    root.replies.unshift(response)
    return
  } else {
    for (const node of replies) {
      if (node.commentId === response.parentCommentId) {
        // 子节点的评论也算在根节点头上
        root.replies.unshift(response)
        break
      }
    }
  }
}

运行调测

运行应用访问笔记详情页面,评论树的界面效果如下图7-4所示。

图7-4 时间格式化后的评论列表界面效果

处理删除回复的事件

删除请求发送成功后,执行无刷新删除回复。

// 删除回复
const deleteReply = async (commentId: number) => {
  if (!confirm('确定要删除这条回复吗?')) {
    return
  }

  try {
    await axios.delete(`/api/comment/${commentId}`)

    // 从列表中删除回复
    commentResponseDtoArray.value = commentResponseDtoArray.value.filter(comment => {
      comment.replies = comment.replies.filter(reply => reply.commentId !== commentId)
      return comment.replies
    })
  } catch (error) {
    console.error('删除回复失败:' + error)
  }
}

编写模板内容

<!-- 删除回复的按钮-->
<button class="delete-comment" v-if="me.username === reply.username"
  @click="deleteReply(reply.commentId)">
  <i class="fa fa-trash-o"></i>
</button>

确保是回复的作者自己,才能看到删除回复的按钮。

运行调测

运行应用访问笔记详情页面,删除回复时的界面效果如下图7-5所示。

图7-5 删除回复时的界面效果