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-2所示。
笔记发布成功之后,会有如下图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所示。
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-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所示。
点击“确定”按钮之后,就能跳转到用户信息页面。
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-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-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所示。
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所示。
实现回复评论功能
在回复弹窗上当点击“提交回复”,则会将回复提交到后端接口,而后关闭窗口;点击“取消”则直接关闭窗口。代码逻辑如下:
// 隐藏回复框
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所示。
处理删除回复的事件
删除请求发送成功后,执行无刷新删除回复。
// 删除回复
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所示。