九、 “仿小红书”单体全栈项目开发实战(三)

0 阅读16分钟

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中,PageablePage 是Spring Data JPA中处理分页查询的核心组件,掌握它们的用法对于构建高效、可维护的后端服务至关重要。合理使用分页技术不仅能提升系统性能,还能显著改善用户体验。

1. Pageable:分页查询请求

Pageable 是一个接口,用于封装分页查询的参数,包括:

  • 页码(从0开始)
  • 每页大小
  • 排序规则

常用实现类:PageRequest

2. Page:分页查询结果

Page 是一个接口,代表分页查询的结果,包含:

  • 当前页数据列表
  • 总页数
  • 总记录数
  • 当前页码
  • 每页大小
  • 是否有下一页/上一页

3. 性能考虑

  1. 避免大数据量下的性能问题

    • 对于超大数据集,使用 Slice 代替 Page(不计算总页数)
    • 合理设置每页大小,避免一次查询过多数据
  2. 排序字段优化

    • 经常用于排序的字段应添加索引
    • 复合排序(多字段排序)需确保索引顺序与查询一致
  3. 缓存分页结果

    • 对于静态数据或变化不频繁的数据,考虑缓存分页结果

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-1 该用户发布过笔记

点击上述笔记封面,可以跳转到该笔记的详情页面(后续实现)。

如果该用户没有发布过笔记,则界面效果如下图8-2所示。

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

图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-4 自己的视角的用户个人信息展示界面效果

如果是访客的视角,界面效果如下图8-5所示。

图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-6 自己的视角的笔记列表展示界面效果

如果是访客的视角,界面效果如下图8-7所示。

图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的用户不存在,则会跳转到如下界面:

图8-8 对UserNotFoundException异常的处理界面

1.8 性能优化及扩展建议

性能考虑

  1. 避免大数据量下的性能问题

    • 对于超大数据集,使用 Slice 代替 Page(不计算总页数)
    • 合理设置每页大小,避免一次查询过多数据
  2. 排序字段优化

    • 经常用于排序的字段应添加索引
    • 复合排序(多字段排序)需确保索引顺序与查询一致
  3. 缓存分页结果

    • 对于静态数据或变化不频繁的数据,考虑缓存分页结果

常见问题与解决方案

问题描述解决方案
页码从0开始不习惯在前端模板中+1显示(如示例中的 th:text="${pageNum + 1}"
大数据量查询慢使用 Slice 接口,避免计算总记录数
排序字段无索引为排序字段添加数据库索引
分页参数被篡改在控制器中添加参数校验,限制每页最大数量

扩展建议

  • 添加笔记分类筛选功能
  • 实现笔记搜索功能
  • 添加笔记状态(草稿 / 已发布)管理
  • 增加批量操作功能
  • 添加笔记排序选项

2.1 笔记详情功能概述

以下是基于Spring Security、Thymeleaf和Bootstrap实现的仿小红书笔记详情界面。该方案包含笔记内容展示、作者信息、评论区和互动功能,同时保持了小红书的视觉风格和用户体验。

核心功能与设计特点

  1. 视觉风格

    • 采用小红书标志性的红色作为主色调
    • 卡片式设计与圆角元素,营造现代感
    • 分层设计,通过阴影和间距创造视觉层次感
  2. 内容展示

    • 顶部大图展示笔记主图,支持多图浏览指示器
    • 清晰的标题、正文和标签布局
    • 作者信息区域包含头像、用户名和关注按钮
  3. 互动功能

    • 点赞、评论、收藏和分享按钮
    • 实时交互反馈
    • 评论区支持输入和展示
  4. 响应式设计

    • 适配不同屏幕尺寸
    • 提升移动端操作便捷性
  5. 动态交互

    • 关注按钮状态切换
    • 互动按钮的点击效果
    • 评论区的回复功能

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-1 访客的视角的笔记详情界面效果

在该视角下,访客可以对他人笔记进行点赞、评论、收藏和分享,对笔记作者进行关注。

如果是自己的视角,界面效果如下图9-2所示。

图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的笔记不存在,则会跳转到如下界面:

图9-3 对NoteNotFoundException异常的处理界面

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 掌握为多图笔记添加图片轮播功能的能力

小红书笔记详情页的图片轮播和放大预览功能,这两个功能对于提升用户体验和内容展示效果非常重要。

接下来将扩展之前的笔记详情页代码,添加以下功能:

  1. 图片轮播(支持多图切换)
  2. 图片放大预览(全屏查看高清图片)

本节先介绍图片轮播功能的实现过程。

图片轮播容器

修改 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展示的是轮播切换图片的效果。

图9-4 轮播切换图片1

图9-5 轮播切换图片2

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展示的是图片放大预览的效果。

图9-6 放大预览图片1

图9-7 放大预览图片2

2.10 提升用户体验经验总结及扩展建议

功能说明

  1. 图片轮播功能

    • 使用flexbox实现轮播容器,通过transform进行滑动切换
    • 图片下方显示当前图片索引/总图片数
    • 平滑过渡动画效果
  2. 图片放大预览功能

    • 点击图片弹出全屏预览模态框
    • 预览时显示当前图片索引/总图片数
    • 支持键盘方向键导航(左/右箭头)
    • 点击关闭按钮或按ESC键关闭预览
    • 左右箭头按钮控制预览图片切换
  3. 响应式设计

    • 轮播图片适应容器宽度
    • 预览图片最大宽度为屏幕宽度的90%
    • 移动端和桌面端均有良好体验

实现要点

  1. 轮播实现

    • 使用CSS transform实现平滑滚动效果
    • 通过JavaScript控制当前显示的图片索引
  2. 预览功能

    • 模态框覆盖整个屏幕,背景半透明
    • 图片居中显示,保持原始比例
    • 支持多种交互方式(点击、键盘)
  3. 用户体验优化

    • 过渡动画使切换更自然
    • 指示器清晰显示当前位置
    • 支持多种退出方式(点击关闭按钮、按ESC键)

扩展建议

  1. 评论分页

    • 实现评论区的分页加载
    • 添加评论排序功能(最新/最热)
  2. 用户互动

    • 实现用户之间的@功能
  3. 推荐算法

    • 优化相关笔记推荐算法
    • 基于用户兴趣推荐更多内容

这个实现方案保持了小红书的视觉风格和用户体验,同时提供了完整的笔记详情展示和互动功能。在实际项目中,你可以根据需求进一步扩展和优化这些功能。