1.1 笔记列表展示功能概述
本章是基于 Spring Security 6.5、Thymeleaf 和 Bootstrap 实现的笔记列表展示界面。该方案包含完整的安全认证、笔记列表展示、分页功能和响应式设计。
这个笔记列表展示界面具有以下特点。
安全认证
- 使用 Spring Security 保护笔记页面
- 自动获取当前登录用户
- 支持用户注销功能
视觉设计
- 采用小红书风格的红色调
- 卡片式布局展示笔记
- 悬停效果和微动画提升交互体验
功能特性
- 分页显示笔记列表
- 支持笔记查看
- 空状态提示与快速创建按钮
响应式设计
- 在移动设备上自动调整布局
- 适配不同屏幕尺寸的显示效果
安全防护
- CSRF 保护
- 权限验证
- 数据访问控制
1.2 控制器来处理笔记列表查询请求及重定向
我们需要在原有的用户信息管理页面展示该用户发布的笔记列表。因此,需要修改用户控制器UserController以实现相关功能。
控制器处理用户笔记列表数据展示
新增方法如下,以获取用户笔记列表数据并在界面上展示。
import org.springframework.web.bind.annotation.PathVariable
// ...为节约篇幅,此处省略非核心内容
@Controller
@RequestMapping("/user")
public class UserController {
// ...为节约篇幅,此处省略非核心内容
@Autowired
private UserService userService;
@Autowired
private NoteService noteService;
@GetMapping("/profile/{userId}")
public String profileWithNotes(Model model,
@PathVariable Long userId,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "12") int size) {
// 获取当前用户信息
Optional<User> optionalUser = userService.findByUserId(userId);
// 判断用户是否存在
if (!optionalUser.isPresent()) {
throw new UserNotFoundException("");
}
User user = optionalUser.get();
model.addAttribute("user", user);
// 获取用户笔记列表数据
Page<Note> notePage = noteService.getNotesByUser(userId, page - 1, size);
// 添加笔记列表数据到模型中
model.addAttribute("notePage", notePage);
model.addAttribute("currentPage", page);
model.addAttribute("totalPages", notePage.getTotalPages());
return "user-profile";
}
}
上述代码
- 通过
@PathVariable传递参数,获取到所需要查询的用户的ID。 - page、size参数用于分页查询,分别指要查询的页面页码及该页码数据量。
- 当访问
/user/profile/{userId}路径时,如果正常处理,会返回user-profile.html模板页面。 - 如果传入的userId不存在,则会抛出UserNotFoundException异常。
UserNotFoundException异常
新增UserNotFoundException用于表示用户不存在异常:
package com.waylau.rednote.exception;
/**
* UserNotFoundException 用户不存在异常
*
* @author <a href="https://waylau.com">Way Lau</a>
* @version 2025/06/07
**/
public class UserNotFoundException extends ValidationException {
public UserNotFoundException(String message) {
super("用户不存在异常. " + message);
}
public UserNotFoundException(String message, Throwable cause) {
super("用户不存在异常. " + message, cause);
}
}
用户信息管理页面重定向
原有的访问用户信息管理页面的路径是/user/profile,用来表示展示用户自己的信息。现在对改控制器做修改,以便重定向到/user/profile/{userId}路径:
@GetMapping("/profile")
public String profile(Model model) {
// 获取当前用户信息
User user = userService.getCurrentUser();
/*model.addAttribute("user", user);
return "user-profile";*/
// 重定向
return "redirect:/user/profile/" + user.getUserId();
}
这样,/user/profile/{userId}接口就能处理包括自己在内的所有人的用户信息展示了。
1.3 实现笔记的分页查询、排序等功能
根据用户ID查询用户
修改UserRepository,增加接口如下:
public interface UserRepository extends Repository<User, Long> {
// ...为节约篇幅,此处省略非核心内容
/**
* 根据用户ID查询用户
*
* @param userId
* @return
*/
Optional<User> findByUserId(Long userId);
}
修改UserService,增加接口如下:
public interface UserService {
// ...为节约篇幅,此处省略非核心内容
/**
* 根据用户ID查询用户
*
* @param userId
* @return
*/
Optional<User> findByUserId(Long userId);
}
修改UserServiceImpl,实现如下接口:
@Service
public class UserServiceImpl implements UserService {
// ...为节约篇幅,此处省略非核心内容
@Override
public Optional<User> findByUserId(Long userId) {
return userRepository.findByUserId(userId);
}
}
根据作者的用户ID分页查询笔记
修改UserRepository,增加接口如下:
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
// ...为节约篇幅,此处省略非核心内容
public interface NoteRepository extends Repository<Note, Long> {
// ...为节约篇幅,此处省略非核心内容
/**
* 根据作者的用户ID分页查询笔记
*
* @param userId
* @param pageable
* @return
*/
Page<Note> findByAuthorUserId(Long userId, Pageable pageable);
}
Note实体类中没有直接名为userId的属性,在Note实体中,用户关联是通过User对象(author字段)实现的,而非直接的userId字段,因此不能在NoteRepository中定义了findByUserId方法。但可以使用author.userId代替userId,因此接口名称为findByAuthorUserId。
修改NoteService,增加接口如下:
public interface NoteService {
// ...为节约篇幅,此处省略非核心内容
/**
* 根据作者的用户ID分页查询笔记
*
* @param userId
* @param page
* @param size
* @return
*/
Page<Note> getNotesByUser(Long userId, int page, int size);
}
修改UserServiceImpl,实现如下接口:
@Service
public class NoteServiceImpl implements NoteService {
// ...为节约篇幅,此处省略非核心内容
@Override
public Page<Note> getNotesByUser(Long userId, int page, int size) {
// 分页查询的笔记列表结果按照创建时间降序排序
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createAt"));
return noteRepository.findByAuthorUserId(userId, pageable);
}
}
Sort.by(Sort.Direction.DESC, "createAt")是指定按照Note的createdAt字段排序。- descending()是降序排序。
- 上述两个条件组合就是按照按照Note的createdAt字段降序排序。
Spring Data JPA 中 Page 和 Pageable 的用法详解
在Spring Data JPA中,Pageable 和 Page 是Spring Data JPA中处理分页查询的核心组件,掌握它们的用法对于构建高效、可维护的后端服务至关重要。合理使用分页技术不仅能提升系统性能,还能显著改善用户体验。
1. Pageable:分页查询请求
Pageable 是一个接口,用于封装分页查询的参数,包括:
- 页码(从0开始)
- 每页大小
- 排序规则
常用实现类:PageRequest
2. Page:分页查询结果
Page 是一个接口,代表分页查询的结果,包含:
- 当前页数据列表
- 总页数
- 总记录数
- 当前页码
- 每页大小
- 是否有下一页/上一页
3. 性能考虑
-
避免大数据量下的性能问题:
- 对于超大数据集,使用
Slice代替Page(不计算总页数) - 合理设置每页大小,避免一次查询过多数据
- 对于超大数据集,使用
-
排序字段优化:
- 经常用于排序的字段应添加索引
- 复合排序(多字段排序)需确保索引顺序与查询一致
-
缓存分页结果:
- 对于静态数据或变化不频繁的数据,考虑缓存分页结果
4. 常见问题与解决方案
| 问题描述 | 解决方案 |
|---|---|
| 页码从0开始不习惯 | 在前端模板中+1显示(如示例中的 th:text="${pageNum + 1}") |
| 大数据量查询慢 | 使用 Slice 接口,避免计算总记录数 |
| 排序字段无索引 | 为排序字段添加数据库索引 |
| 分页参数被篡改 | 在控制器中添加参数校验,限制每页最大数量 |
1.4 使用分页及网格组件设计笔记列表展示界面
修改user-profile.html,在原有的代码基础上,增加如下代码。
笔记列表区域
<style>
/* ...为节约篇幅,此处省略非核心内容 */
/* 笔记列表 */
.note-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 16px;
padding: 16px;
}
.note-card {
background-color: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.03);
transition: transform 0.2s;
}
.note-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.05);
}
.note-image {
width: 100%;
height: 180px;
object-fit: cover;
}
.note-content {
padding: 12px;
}
.note-title {
font-size: 14px;
font-weight: 500;
line-height: 1.4;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
color: #333;
margin-bottom: 8px;
}
.note-meta {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #999;
}
/* 空状态提示 */
.empty-state {
padding: 48px;
text-align: center;
}
.empty-icon {
font-size: 48px;
color: #e0e0e0;
margin-bottom: 16px;
}
.empty-text {
color: #999;
margin-bottom: 24px;
}
.create-note-btn {
background-color: #ff2442;
color: white;
padding: 10px 24px;
border-radius: 24px;
font-weight: 500;
}
</style>
<div class="container mt-5">
<div class="row justify-content-center">
<!-- 用户个人信息 -->
<!-- ...为节约篇幅,此处省略非核心内容-->
<!-- 笔记列表 -->
<div class="col-md-8">
<!-- 空状态提示 -->
<div class="empty-state" th:if="${notePage.empty}">
<div class="empty-icon">
<i class="fa fa-file-o"></i>
</div>
<div class="empty-text">
还没有发布任何笔记
</div>
<a href="/note/publish" th:href="@{/note/publish}" class="create-note-btn">
<i class="fa fa-plus"></i>
发布第一篇笔记
</a>
</div>
<!-- 非空状态提示 -->
<div class="note-grid" th:if="${!notePage.empty}">
<!-- 循环遍历笔记列表生成笔记卡片 -->
<div class="note-card" th:each="note : ${notePage.content}">
<a th:href="@{/note/{noteId}(noteId=${note.noteId})}">
<img th:src="${note.images[0]}" class="note-image" alt="${note.title}">
</a>
<div class="note-content">
<dive class="note-title">
[[${note.title}]]
</dive>
</div>
</div>
</div>
</div>
<!-- TODO 分页导航 -->
</div>
</div>
上述页面考虑了两种场景。如果该用户发布过笔记,则界面效果如下图8-1所示。
点击上述笔记封面,可以跳转到该笔记的详情页面(后续实现)。
如果该用户没有发布过笔记,则界面效果如下图8-2所示。
点击上述“发布第一篇笔记”按钮,可以跳转到笔记的发布页面。
分页组件
<style>
/* ...为节约篇幅,此处省略非核心内容*/
/* 分页组件 */
.pagination {
padding: 24px;
display: flex;
justify-content: center;
gap: 8px;
font-size: 14px;
}
.page-btn {
padding: 6px 12px;
border-radius: 4px;
color: #666;
text-decoration: none;
}
.page-btn.active {
background-color: #ff2442;
color: white;
font-weight: 500;
}
</style>
<!-- 分页导航 -->
<div class="col-md-8">
<div class="pagination" th:if="${totalPages > 0}">
<a class="page-btn" th:if="${currentPage > 1}"
th:href="@{/user/profile/{userId}(userId=${user.userId},page=${currentPage - 1})}">«</a>
<a class="page-btn" th:each="pageNum : ${#numbers.sequence(1, totalPages)}"
th:href="@{/user/profile/{userId}(userId=${user.userId},page=${pageNum})}"
th:classappend="${pageNum == currentPage} ? ' active'">[[${pageNum}]]</a>
<a class="page-btn" th:if="${currentPage < totalPages}"
th:href="@{/user/profile/{userId}(userId=${user.userId},page=${currentPage + 1})}">»</a>
</div>
</div>
界面效果如下图8-3所示。
1.5 区用户信息展示分自己视角和访客视角的技巧
因为用户信息展示区包括了对用户个人信息的操作(编辑资料和修改密码),因此,需要调整原有的用户信息展示界面,以区分自己视角和访客视角。
- 自己视角:可以看到“编辑资料”按钮和“修改密码”按钮。
- 访客视角:看不到“编辑资料”按钮和“修改密码”按钮。
用户个人信息
用户个人信息代码调整如下:
<!-- 用户个人信息 -->
<div class="col-md-8">
<!--<div class="card">-->
<!--<div class="card-header">
个人资料
</div>-->
<!--<div class="card-body">-->
<div class="row">
<div class="col-md-4 text-center">
<img src="../static/images/rn_avatar.png"
th:src="${user.avatar ?: '/images/rn_avatar.png'}"
class="rounded-circle" alt="用户头像" height="88" width="88">
<p class="mt-3">[[${user.username}]]</p>
<!-- 仅作者自己可见 -->
<div th:if="${#authentication.name == user.username}">
<a href="/user/edit" th:href="@{/user/edit}" class="btn btn-primary btn-sm">编辑资料</a>
</div>
</div>
<div class="col-md-8">
<dive class="mb-3">
<!--<label class="form-label">手机号</label>
<p class="form-control-plaintext">[[${user.phone}]]</p>-->
<label class="form-label">RN号:[[${user.userId}]]</label>
</dive>
<dive class="mb-3">
<!--<label class="form-label">个人简介</label>-->
<p class="form-control-plaintext">[[${user.bio ?: '这家伙很懒,什么都没写'}]]</p>
</dive>
<!-- 仅作者自己可见 -->
<div th:if="${#authentication.name == user.username}">
<a href="/user/change-password" th:href="@{/user/change-password}"
class="btn btn-outline-secondary">修改密码</a>
</div>
</div>
</div>
<!--</div>
</div>-->
</div>
上述代码:
- 删除了一些多余的组件,比如标题“个人资料”以及Card组件,让整个页面看起来更加符合有互联网应用的风格。
- 为了保护个人隐私,去除了手机号的展示,改为展示RN号(也就是用户ID)。
- 设置认证校验,仅用户自己可以看到自己主页的“编辑资料”按钮和“修改密码”按钮。
如果是自己的视角,界面效果如下图8-4所示。
如果是访客的视角,界面效果如下图8-5所示。
1.6 笔记列表展示区分自己视角和访客视角的技巧
因为笔记列表包括了对笔记发布的操作,因此,需要调整原有的笔记列表展示界面,以区分自己视角和访客视角。
- 自己视角:可以看到“发布第一篇笔记按钮。
- 访客视角:看不到“发布第一篇笔记”按钮。
修改笔记列表展示区域中对于空状态的处理
笔记列表展示区域中对于空状态代码调整如下:
<!-- 空状态提示 -->
<div th:if="${notePage.empty}" class="empty-state">
<div class="empty-icon"><i class="fa fa-file-o"></i></div>
<div class="empty-text">还没有发布任何笔记</div>
<a th:if="${#authentication.name == user.username}" th:href="@{/note/publish}" class="create-note-btn">
<i class="fa fa-plus"></i> 发布第一篇笔记
</a>
</div>
如果是自己的视角,界面效果如下图8-6所示。
如果是访客的视角,界面效果如下图8-7所示。
1.7 扩展统一异常处理UserNotFoundException
扩展统一异常处理,修改GlobalExceptionHandler,增加了对UserNotFoundException异常的处理:
@ControllerAdvice
public class GlobalExceptionHandler {
private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);
// ...为节约篇幅,此处省略非核心内容
// 用户不存在异常
@ExceptionHandler(UserNotFoundException.class)
public String handleUserNotFoundException(UserNotFoundException ex, Model model) {
logger.error("用户不存在异常: {}", ex.getMessage(), ex);
model.addAttribute("errorCode", 404);
model.addAttribute("errorMessage", "异常信息: " + ex.getMessage());
return "400-error";
}
}
当我们试图访问一个不存在的用户时,比如:http://localhost:8080/user/profile/100000。用户ID为100000的用户不存在,则会跳转到如下界面:
1.8 性能优化及扩展建议
性能考虑
-
避免大数据量下的性能问题:
- 对于超大数据集,使用
Slice代替Page(不计算总页数) - 合理设置每页大小,避免一次查询过多数据
- 对于超大数据集,使用
-
排序字段优化:
- 经常用于排序的字段应添加索引
- 复合排序(多字段排序)需确保索引顺序与查询一致
-
缓存分页结果:
- 对于静态数据或变化不频繁的数据,考虑缓存分页结果
常见问题与解决方案
| 问题描述 | 解决方案 |
|---|---|
| 页码从0开始不习惯 | 在前端模板中+1显示(如示例中的 th:text="${pageNum + 1}") |
| 大数据量查询慢 | 使用 Slice 接口,避免计算总记录数 |
| 排序字段无索引 | 为排序字段添加数据库索引 |
| 分页参数被篡改 | 在控制器中添加参数校验,限制每页最大数量 |
扩展建议
- 添加笔记分类筛选功能
- 实现笔记搜索功能
- 添加笔记状态(草稿 / 已发布)管理
- 增加批量操作功能
- 添加笔记排序选项
2.1 笔记详情功能概述
以下是基于Spring Security、Thymeleaf和Bootstrap实现的仿小红书笔记详情界面。该方案包含笔记内容展示、作者信息、评论区和互动功能,同时保持了小红书的视觉风格和用户体验。
核心功能与设计特点
-
视觉风格:
- 采用小红书标志性的红色作为主色调
- 卡片式设计与圆角元素,营造现代感
- 分层设计,通过阴影和间距创造视觉层次感
-
内容展示:
- 顶部大图展示笔记主图,支持多图浏览指示器
- 清晰的标题、正文和标签布局
- 作者信息区域包含头像、用户名和关注按钮
-
互动功能:
- 点赞、评论、收藏和分享按钮
- 实时交互反馈
- 评论区支持输入和展示
-
响应式设计:
- 适配不同屏幕尺寸
- 提升移动端操作便捷性
-
动态交互:
- 关注按钮状态切换
- 互动按钮的点击效果
- 评论区的回复功能
2.2 使用Bootstrap、Font Awesome以及Thymeleaf轻松实现笔记详情界面
界面设计与实现
新建note-detail.html:
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RN - 笔记详情</title>
<!-- 引入 Bootstrap CSS -->
<link href="https://cdn.bootcdn.net/ajax/libs/bootstrap/5.3.6/css/bootstrap.min.css"
th:href="@{/css/bootstrap.min.css}" rel="stylesheet">
<!-- 引入 Font Awesome -->
<link href="https://cdn.bootcdn.net/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css"
th:href="@{/css/font-awesome.min.css}" rel="stylesheet">
<!-- 自定义样式 -->
<style>
/* 全局样式 */
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;
}
</style>
</head>
<body>
<!-- 主内容区 -->
<main class="container py-4 main-content">
<!-- 笔记内容 -->
<div class="note-container">
<!-- 笔记图片 -->
<div class="note-images">
<img class="note-image" src="../static/images/rn_avatar.png" th:src="${note.images[0]}" alt="笔记图片">
</div>
<!-- 笔记内容区 -->
<div class="note-content">
<!-- 标题 -->
<h1 class="note-title" th:text="${note.title}">分享我超爱的夏日穿搭,清爽又时尚</h1>
<!-- 内容 -->
<p class="note-text" th:text="${note.content}">
夏天到了,又到了可以尽情展现个性穿搭的季节啦!<br><br>
</p>
<!-- 话题 -->
<div class="note-tags">
<span class="tag" th:each="topic : ${note.topics}" th:text="${topic}">
</span>
</div>
<!-- 操作栏 -->
<div class="note-action-bar">
<!-- 返回 -->
<button class="btn btn-light btn-sm" onclick="history.back()">
<i class="fa fa-arrow-left"></i>
</button>
<!-- 编辑 -->
<button class="btn btn-light btn-sm" th:if="${#authentication.name == note.author.username}">
<i class="fa fa-edit"></i>
</button>
<!-- 删除 -->
<button class="btn btn-light btn-sm" th:if="${#authentication.name == note.author.username}">
<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">
<img class="author-avatar" src="../static/images/rn_avatar.png" th:src="${note.author.avatar ?: '/images/rn_avatar.png'}"
alt="作者头像">
<div>
<div class="author-name" th:text="${note.author.username}">
waylau
</div>
<div class="author-meta">
已获得 1024 粉丝
</div>
</div>
<div class="author-follow" th:if="${#authentication.name != note.author.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="../static/images/rn_avatar.png" th:src="@{/images/rn_avatar.png}"
alt="头像">
<textarea class="comment-textarea" placeholder="分享你的想法..."></textarea>
<div class="comment-btn">
发送
</div>
</div>
</div>
</div>
</main>
<!-- Bootstrap JS -->
<script src="https://cdn.bootcdn.net/ajax/libs/bootstrap/5.3.6/js/bootstrap.bundle.min.js"
th:src="@{/js/bootstrap.bundle.min.js}"></script>
</body>
</html>
2.3 控制器来处理笔记详情查询请求
在原有的NoteController基础上,增加方法以实现相关功能。
控制器处理用户笔记详情展示
新增方法如下。
import com.waylau.rednote.exception.NoteNotFoundException;
import java.util.Optional;
// ...为节约篇幅,此处省略非核心内容
@Controller
@RequestMapping("/note")
public class NoteController {
@Autowired
private NoteService noteService;
// ...为节约篇幅,此处省略非核心内容
/**
* 显示笔记详情页面
*/
@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";
}
}
上述代码
- 通过
@PathVariable传递参数,获取到所需要查询的笔记的ID。 - 当访问
/note/{noteId}路径时,如果正常处理,会返回note-detail.html模板页面。 - 如果传入的noteId不存在,则会抛出NoteNotFoundException异常。
NoteNotFoundException异常
新增NoteNotFoundException用于表示用户不存在异常:
package com.waylau.rednote.exception;
/**
* NoteNotFoundException 笔记不存在异常
*
* @author <a href="https://waylau.com">Way Lau</a>
* @version 2025/06/11
**/
public class NoteNotFoundException extends ValidationException {
public NoteNotFoundException(String message) {
super("笔记不存在异常. " + message);
}
public NoteNotFoundException(String message, Throwable cause) {
super("笔记不存在异常. " + message, cause);
}
}
2.4 高效实现查询笔记详情的方法
修改NoteRepository
修改NoteRepository,增加接口如下:
public interface NoteRepository extends Repository<Note, Long> {
// ...为节约篇幅,此处省略非核心内容
/**
* 根据笔记ID查询笔记
*
* @param noteId
* @return
*/
Optional<Note> findByNoteId(Long noteId);
}
修改服务接口
修改NoteService,增加接口如下:
public interface NoteService {
// ...为节约篇幅,此处省略非核心内容
/**
* 根据笔记ID查询笔记
*
* @param noteId
* @return
*/
Optional<Note> findByNoteId(Long noteId);
}
修改NoteServiceImpl,实现如下接口:
@Service
public class NoteServiceImpl implements NoteService {
// ...为节约篇幅,此处省略非核心内容
@Override
public Optional<Note> findByNoteId(Long noteId) {
return noteRepository.findByNoteId(noteId);
}
}
2.5 不同视角下的笔记详情界面展示效果
通过th:if实现不同视角下的笔记详情界面显示效果。
如果是访客的视角,界面效果如下图9-1所示。
在该视角下,访客可以对他人笔记进行点赞、评论、收藏和分享,对笔记作者进行关注。
如果是自己的视角,界面效果如下图9-2所示。
在该视角下,笔记作者可以对该笔记进行编辑、删除、点赞、评论、收藏和分享。
2.6 扩展统一异常处理NoteNotFoundException
扩展统一异常处理,增加了对NoteNotFoundException异常的处理:
@ControllerAdvice
public class GlobalExceptionHandler {
private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);
// ...为节约篇幅,此处省略非核心内容
// 笔记不存在异常
@ExceptionHandler(NoteNotFoundException.class)
public String handleNoteNotFoundException(NoteNotFoundException ex, Model model) {
logger.error("笔记不存在异常: {}", ex.getMessage(), ex);
model.addAttribute("errorCode", 404);
model.addAttribute("errorMessage", "异常信息: " + ex.getMessage());
return "400-error";
}
}
当我们试图访问一个不存在的笔记时,比如:http://localhost:8080/note/12345。笔记ID为12345的笔记不存在,则会跳转到如下界面:
2.7 完善笔记发布后的查看笔记功能
修改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 currentUser = userService.getCurrentUser();
// 创建笔记
// noteService.createNote(notePublishDto, currentUser);
Note note = noteService.createNote(notePublishDto, currentUser);
model.addAttribute("note", note);
// 返回成功响应
return "note-publish-success";
}
}
修改note-publish-success查看笔记按钮点击事件
<div class="btn-group">
<!--<button class="btn-view" onclick="goToNote()">查看笔记</button>-->
<button class="btn-view" th:onclick="goToNote([[${note.noteId}]])">查看笔记</button>
<button class="btn-continue" onclick="continuePublish()">继续发布</button>
</div>
<script>
// 查看笔记(模拟跳转)
function goToNote(noteId) {
// 真实笔记ID
// window.location.href = "/note/12345";
window.location.href = "/note/" + noteId;
}
</script>
2.8 掌握为多图笔记添加图片轮播功能的能力
小红书笔记详情页的图片轮播和放大预览功能,这两个功能对于提升用户体验和内容展示效果非常重要。
接下来将扩展之前的笔记详情页代码,添加以下功能:
- 图片轮播(支持多图切换)
- 图片放大预览(全屏查看高清图片)
本节先介绍图片轮播功能的实现过程。
图片轮播容器
修改 note-detail.html 部分,添加轮播容器:
<head>
<!-- 原有头部内容 -->
<style>
/* 新增轮播和预览样式 */
.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;
}
</style>
</head>
<body>
<!-- 主内容区 -->
<main class="container py-4 main-content">
<!-- 笔记内容 -->
<div class="note-container">
<!-- 笔记内容 -->
<div class="note-images">
<!--<img class="note-image" src="../static/images/rn_avatar.png" th:src="${note.images[0]}" alt="笔记图片">-->
<!-- 图片轮播容器 -->
<div class="carousel-container" id="carouselContainer">
<!-- 动态生成轮播项 -->
<div class="carousel-item-img" th:each="image, stat : ${note.images}"
th:attr="data-index=${stat.index}">
<img class="note-image" src="../static/images/rn_avatar.png" th:src="${image}" alt="笔记图片"
th:attr="data-index=${stat.index}">
</div>
</div>
<!-- 轮播指示器 -->
<div class="carousel-indicator" id="carouselIndicator">
<span id="currentSlide">1</span> / <span id="totalSlides">[[${note.images.size()}]]</span>
</div>
<!-- 轮播控制按钮 -->
<div class="carousel-control prev" onclick="prevSlide()">
<i class="fa fa-angle-left"></i>
</div>
<div class="carousel-control next" onclick="nextSlide()">
<i class="fa fa-angle-right"></i>
</div>
</div>
<!-- 原有笔记内容 -->
</div>
<!-- 原有评论区 -->
</main>
</body>
</html>
轮播脚本
<script>
// 轮播功能实现
let currentSlideNum = 1;
const carouselContainer = document.getElementById('carouselContainer');
const carouselItems = document.querySelectorAll('.carousel-item-img');
const currentSlide = document.getElementById('currentSlide');
// 更新轮播位置
function updateCarouselPosition() {
carouselContainer.style.transform = `translateX(-${(currentSlideNum - 1) * 100}%)`;
currentSlide.textContent = currentSlideNum;
}
// 切换到上一张图片
function prevSlide() {
if (currentSlideNum > 1) {
currentSlideNum--;
updateCarouselPosition();
}
}
// 切换到下一张图片
function nextSlide() {
if (currentSlideNum < carouselItems.length) {
currentSlideNum++;
updateCarouselPosition();
}
}
</script>
运行调测
下图9-4、9-5展示的是轮播切换图片的效果。
2.9 笔记详情页图放大预览功能实现
图片放大预览功能
修改HTML部分,添加图片预览模态框:
<head>
<!-- 原有头部内容 -->
<style>
/* 原有轮播和预览样式 */
/* 图片预览模态框 */
.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;
}
</style>
</head>
<body>
<!-- 主内容区 -->
<main class="container py-4 main-content">
<!-- 原有主内容区 -->
<!-- img 上加 preview-trigger-->
<img class="note-image preview-trigger" th:src="${image}"
th:alt="${note.title}" th:attr="data-index=${stat.index}">
</main>
<!-- 图片预览模态框 -->
<div class="preview-modal" id="previewModal">
<div class="preview-content">
<img class="preview-image" id="previewImage" src="" alt="图片预览">
<div class="preview-close" onclick="closePreview()">
<i class="fa fa-times"></i>
</div>
<div class="preview-counter" id="previewCounter">
<span id="previewCurrent">1</span> / <span id="previewTotal">[[${note.images.size()}]]</span>
</div>
<div class="preview-control prev" onclick="previewPrev()">
<i class="fa fa-angle-left"></i>
</div>
<div class="preview-control next" onclick="previewNext()">
<i class="fa fa-angle-right"></i>
</div>
</div>
</div>
<script>
// ...为节约篇幅,此处省略非核心内容
// 预览图片功能实现
const previewImage = document.getElementById('previewImage');
const previewModal = document.getElementById('previewModal');
const previewCurrent = document.getElementById('previewCurrent');
const previewClose = document.querySelector('.preview-close');
// 打开预览
function openPreview(index) {
console.log("openPreview " + index);
currentSlideNum = index + 1;
previewImage.src = carouselItems[index].querySelector('img').src;
previewCurrent.textContent = currentSlideNum;
previewModal.style.display = 'flex';
// 防止背景滚动
document.body.style.overflow = 'hidden';
}
// 关闭预览
function closePreview() {
previewModal.style.display = 'none';
// 恢复背景滚动
document.body.style.overflow = '';
// 更新轮播位置
updateCarouselPosition();
}
// 预览上一张
function previewPrev() {
currentSlideNum = Math.max(1, currentSlideNum - 1);
previewImage.src = carouselItems[currentSlideNum - 1].querySelector('img').src;
previewCurrent.textContent = currentSlideNum;
}
// 预览下一张
function previewNext() {
currentSlideNum = Math.min(carouselItems.length, currentSlideNum + 1);
previewImage.src = carouselItems[currentSlideNum - 1].querySelector('img').src;
previewCurrent.textContent = currentSlideNum;
}
// 为所有preview-trigger类型图片添加点击事件
const previewTriggers = document.querySelectorAll('.preview-trigger');
previewTriggers.forEach((trigger, index) => {
trigger.addEventListener('click', () => {
openPreview(index);
});
});
// 为关闭按钮添加点击事件
previewClose.addEventListener('click', closePreview);
// 键盘导航
document.addEventListener('keydown', (event) => {
if (previewModal.style.display === 'flex') {
switch (event.key) {
case 'Escape':
closePreview();
break;
case 'ArrowLeft':
previewPrev();
break;
case 'ArrowRight':
previewNext();
break;
}
}
})
</script>
</body>
</html>
运行调测
下图9-6、9-7展示的是图片放大预览的效果。
2.10 提升用户体验经验总结及扩展建议
功能说明
-
图片轮播功能:
- 使用flexbox实现轮播容器,通过transform进行滑动切换
- 图片下方显示当前图片索引/总图片数
- 平滑过渡动画效果
-
图片放大预览功能:
- 点击图片弹出全屏预览模态框
- 预览时显示当前图片索引/总图片数
- 支持键盘方向键导航(左/右箭头)
- 点击关闭按钮或按ESC键关闭预览
- 左右箭头按钮控制预览图片切换
-
响应式设计:
- 轮播图片适应容器宽度
- 预览图片最大宽度为屏幕宽度的90%
- 移动端和桌面端均有良好体验
实现要点
-
轮播实现:
- 使用CSS transform实现平滑滚动效果
- 通过JavaScript控制当前显示的图片索引
-
预览功能:
- 模态框覆盖整个屏幕,背景半透明
- 图片居中显示,保持原始比例
- 支持多种交互方式(点击、键盘)
-
用户体验优化:
- 过渡动画使切换更自然
- 指示器清晰显示当前位置
- 支持多种退出方式(点击关闭按钮、按ESC键)
扩展建议
-
评论分页:
- 实现评论区的分页加载
- 添加评论排序功能(最新/最热)
-
用户互动:
- 实现用户之间的
@功能
- 实现用户之间的
-
推荐算法:
- 优化相关笔记推荐算法
- 基于用户兴趣推荐更多内容
这个实现方案保持了小红书的视觉风格和用户体验,同时提供了完整的笔记详情展示和互动功能。在实际项目中,你可以根据需求进一步扩展和优化这些功能。