1.1 笔记模块编辑、删除功能概述

  • 笔记模块编辑功能:实现一个仿小红书的笔记修改界面,包含标题编辑、内容编辑、话题标签管理、分类管理等核心功能。
  • 笔记模块删除功能:实现笔记的删除。

核心功能与设计特点

  1. 编辑界面布局

    • 顶部导航栏包含返回和保存按钮
    • 清晰的展示标题、图片、内容、话题标签和分类
  2. 图片展示功能

    • 图片网格布局展示已上传图片
  3. 内容编辑

    • 标题输入框支持修改
    • 内容编辑区域支持修改
    • 输入验证确保内容完整性
  4. 话题标签管理

    • 支持添加多个话题标签(用空格分隔)
    • 标签删除功能
  5. 交互体验

    • 操作反馈提示
    • 删除确认提示
    • 表单验证和错误提示

1.2 使用Bootstrap、Font Awesome以及Thymeleaf轻松实现笔记编辑界面

下面我将为你实现一个仿小红书的笔记修改界面,包含标题编辑、内容编辑、话题标签管理、分类管理等核心功能。

界面设计与实现

可以基于note-publish.html进行修改,只需要删除”图片上传区域“相关的样式、组件即可,

以下是笔记修改界面note-edit.html的完整实现代码:

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<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 {
            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>
</head>
<body>
<!-- 操作栏 -->
<div class="header">
    <div class="container">
        <div class="d-flex justify-content-between align-items-center">
            <button class="btn btn-cancel" id="cancelPublishBtn">
                取消
            </button>
            <button class="btn btn-publish" id="publishNoteBtn">
                保存
            </button>
        </div>
    </div>
</div>

<!-- 主体部分 -->
<div class="container content">
    <form id="noteForm" method="post" th:object="${note}"
          th:action="@{/note/{noteId}(noteId=${note.noteId})}">
        <!-- 标题输入框 -->
        <input type="text" class="note-title" id="title" name="title"
               th:field="*{title}" placeholder="分享你的生活点滴...">
        <div class="error-message" th:if="${#fields.hasErrors('title')}" th:errors="*{title}">
        </div>

        <!-- 已上传图片预览 -->
        <div class="uploaded-images" id="uploadedImages">
            <div class="uploaded-image" th:each="image : ${note.images}">
                <img th:src="${image}" class="preview-img">
            </div>
        </div>
        <!-- 错误消息 -->
        <div class="error-message" th:if="${#fields.hasErrors('images')}" th:errors="*{images}">
        </div>

        <!-- 笔记内容 -->
        <textarea class="note-content" id="content" name="content"
                  th:field="*{content}" placeholder="详细描述你的分享内容..."></textarea>
        <div class="error-message" th:if="${#fields.hasErrors('content')}" th:errors="*{content}">
        </div>

        <!-- 话题 -->
        <div class="topic-input">
            <input type="text" class="form-control" id="topicInput" name="topics"
                   th:field="*{topics}" placeholder="添加话题,多个话题用空格隔开">
        </div>

        <!-- 分类 -->
        <div class="category-selector">
            <label for="categorySelect" class="form-label">请选择一个分类:</label>
            <select class="form-control" id="categorySelect" name="category"
                    th:field="*{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" th:if="${#fields.hasErrors('category')}" th:errors="*{category}">
            </div>
        </div>
    </form>

    <!-- 操作反馈 -->
    <div th:if="${success}" class="alert alert-success mt-4">
        <i class="fa fa-check-circle"></i>
        [[${success}]]
    </div>
    <div th:if="${error}" class="alert alert-danger mt-4">
        <i class="fa fa-exclamation-circle"></i>
        [[${error}]]
    </div>
</div>

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

<script>
    // 笔记发布表单的校验
    // 在发布按钮上设置点击事件
    document.getElementById("publishNoteBtn").addEventListener("click", function (event) {
        // 获取笔记标题
        const title = document.getElementById("title").value;
        if (title.trim() === "") {
            alert("请输入笔记标题");
            return;
        }

        // 获取笔记内容
        const content = document.getElementById("content").value;
        if (content.trim() === "") {
            alert("请输入笔记内容");
            return;
        }

        // 提交表单
        document.getElementById("noteForm").submit();
    })

    // 取消发布的事件处理
    document.getElementById("cancelPublishBtn").addEventListener("click", function (event) {
        // 用户确认是否取消发布
        if (confirm("确定要取消发布吗?所有内容将不会被保存")) {
            window.history.back();
        }
    })
</script>
</body>
</html>

1.3 NoteController控制器来处理笔记编辑请求

在原有的NoteController基础上,增加方法以实现相关功能。

创建笔记编辑DTO

package com.waylau.rednote.dto;

import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;

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

/**
 * NoteEditDto 笔记编辑DTO
 *
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/08/19
 **/
@Getter
@Setter
public class NoteEditDto {
    @NotNull
    private Long noteId;

    @NotEmpty(message = "标题不能为空")
    @Size(max = 60, message = "标题长度不能超过60个字符")
    private String title;

    @NotEmpty(message = "内容不能为空")
    @Size(max = 900, message = "内容长度不能超过900个字符")
    private String content;

    private String topics;

    @NotEmpty(message = "分类不能为空")
    private String category;

    private List<String> images = new ArrayList<>();
}

处理用户访问笔记编辑界面展示

新增方法如下。

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

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

    // 获取当前用户信息
    User user = userService.getCurrentUser();

    Note note = optionalNote.get();

    // 判定笔记是否属于当前用户,不属于则抛出异常
    if (!note.getAuthor().getUserId().equals(user.getUserId())) {
        throw new NoteNotFoundException("");
    }

    // 将Note对象转为NoteEditDto对象
    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.joinToString(note.getTopics(), " "));

    model.addAttribute("note", noteEditDto);

    return "note-edit";
}

当用户使用GET请求访问/note/{noteId}/edit时,则会返回note-edit.html模板页面。

需要注意是的,返回前端的NoteEditDto的topics是字符串类型,因此从Note获取到值之后,需要通过StringUtil.joinToString()工具做转换。

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

    // List转字符串
    public static String joinToString(List<String> source, String regex) {
        return String.join(regex, source);
    }
}

控制器处理用户笔记编辑请求

新增方法如下。

/**
 * 处理笔记编辑请求
 */
@PostMapping("/{noteId}")
public String updateNote(@PathVariable Long noteId,
                            @Valid @ModelAttribute("note") NoteEditDto noteEditDto,
                            BindingResult result,
                            Model model,
                            RedirectAttributes redirectAttributes) {
    // 验证表单
    if (result.hasErrors()) {
        model.addAttribute("note", noteEditDto);
        return "note-edit";
    }

    // 检查笔记是否存在
    Optional<Note> optionalNote = noteService.findNoteById(noteId);
    if (!optionalNote.isPresent()) {
        throw new NoteNotFoundException("");
    }

    Note note = optionalNote.get();

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

        model.addAttribute("error", "笔记更新失败:" + e.getMessage());
        model.addAttribute("note", noteEditDto);
        return "note-edit";
    }
} 

当用户使用POST请求访问/note/{noteId}时,将修改后的笔记数据保存入库。

1.4 实现笔记编辑数据的保存方法

修改NoteService,增加如下接口:

public interface NoteService {
 

    /**
     * 更新笔记
     *
     * @param note
     * @param noteEditDto
     */
    void updateNote(Note note, NoteEditDto noteEditDto);
}
``



修改 NoteServiceImpl,实现笔记编辑数据的保存方法:


```java
import com.waylau.rednote.dto.NoteEditDto;

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

@Service
public class NoteServiceImpl implements NoteService {

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

    @Override
    public void updateNote(Note note, NoteEditDto noteEditDto) {
        // 更新基本信息
        note.setTitle(noteEditDto.getTitle());
        note.setContent(noteEditDto.getContent());
        note.setCategory(noteEditDto.getCategory());

        // 字符串转为List
        note.setTopics(StringUtil.splitToList(noteEditDto.getTopics()," "));

        // 保存更新
        noteRepository.save(note);
    }
}  

需要注意是的,前端传入的NoteEditDto的topics是字符串类型,在赋值到Note时,需要通过StringUtil.splitToList()工具做转换。

1.5 修改不可变集合导致UnsupportedOperationException错误分析

运行应用,试图保存笔记修改后的数据时,报错如下图10-1所示。

问题背景

执行 noteRepository.save(note) 时候报 java.lang.UnsupportedOperationException:

java.lang.UnsupportedOperationException: null
	at java.base/java.util.AbstractList.remove(AbstractList.java:169) ~[na:na]
	at java.base/java.util.AbstractList$Itr.remove(AbstractList.java:389) ~[na:na]
	at java.base/java.util.AbstractList.removeRange(AbstractList.java:600) ~[na:na]
	at java.base/java.util.AbstractList.clear(AbstractList.java:245) ~[na:na]
	at org.hibernate.type.CollectionType.replaceElements(CollectionType.java:506) ~[hibernate-core-6.6.15.Final.jar:6.6.15.Final]
	at org.hibernate.type.CollectionType.replace(CollectionType.java:719) ~[hibernate-core-6.6.15.Final.jar:6.6.15.Final]
	at org.hibernate.type.TypeHelper.replace(TypeHelper.java:117) ~[hibernate-core-6.6.15.Final.jar:6.6.15.Final]
	at org.hibernate.event.internal.DefaultMergeEventListener.copyValues(DefaultMergeEventListener.java:596) ~[hibernate-core-6.6.15.Final.jar:6.6.15.Final]
	at org.hibernate.event.internal.DefaultMergeEventListener.entityIsPersistent(DefaultMergeEventListener.java:286) ~[hibernate-core-6.6.15.Final.jar:6.6.15.Final]
	at org.hibernate.event.internal.DefaultMergeEventListener.merge(DefaultMergeEventListener.java:220) ~[hibernate-core-6.6.15.Final.jar:6.6.15.Final]
	at org.hibernate.event.internal.DefaultMergeEventListener.doMerge(DefaultMergeEventListener.java:152) ~[hibernate-core-6.6.15.Final.jar:6.6.15.Final]
	at org.hibernate.event.internal.DefaultMergeEventListener.onMerge(DefaultMergeEventListener.java:136) ~[hibernate-core-6.6.15.Final.jar:6.6.15.Final]
	at org.hibernate.event.internal.DefaultMergeEventListener.onMerge(DefaultMergeEventListener.java:89) ~[hibernate-core-6.6.15.Final.jar:6.6.15.Final]
	at org.hibernate.event.service.internal.EventListenerGroupImpl.fireEventOnEachListener(EventListenerGroupImpl.java:127) ~[hibernate-core-6.6.15.Final.jar:6.6.15.Final]
	at org.hibernate.internal.SessionImpl.fireMerge(SessionImpl.java:854) ~[hibernate-core-6.6.15.Final.jar:6.6.15.Final]
	at org.hibernate.internal.SessionImpl.merge(SessionImpl.java:840) ~[hibernate-core-6.6.15.Final.jar:6.6.15.Final]
	at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[na:na]
	at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[na:na]
	at org.springframework.orm.jpa.ExtendedEntityManagerCreator$ExtendedEntityManagerInvocationHandler.invoke(ExtendedEntityManagerCreator.java:364) ~[spring-orm-6.2.7.jar:6.2.7]
	at jdk.proxy2/jdk.proxy2.$Proxy120.merge(Unknown Source) ~[na:na]
	at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[na:na]
	at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[na:na]
	at org.springframework.orm.jpa.SharedEntityManagerCreator$SharedEntityManagerInvocationHandler.invoke(SharedEntityManagerCreator.java:320) ~[spring-orm-6.2.7.jar:6.2.7]
	at jdk.proxy2/jdk.proxy2.$Proxy120.merge(Unknown Source) ~[na:na]
	at org.springframework.data.jpa.repository.support.SimpleJpaRepository.save(SimpleJpaRepository.java:654) ~[spring-data-jpa-3.5.0.jar:3.5.0]
	at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[na:na]
	at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[na:na]
	at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:359) ~[spring-aop-6.2.7.jar:6.2.7]
	at org.springframework.data.repository.core.support.RepositoryMethodInvoker$RepositoryFragmentMethodInvoker.lambda$new$0(RepositoryMethodInvoker.java:277) ~[spring-data-commons-3.5.0.jar:3.5.0]
	at org.springframework.data.repository.core.support.RepositoryMethodInvoker.doInvoke(RepositoryMethodInvoker.java:170) ~[spring-data-commons-3.5.0.jar:3.5.0]
	at org.springframework.data.repository.core.support.RepositoryMethodInvoker.invoke(RepositoryMethodInvoker.java:158) ~[spring-data-commons-3.5.0.jar:3.5.0]
	at org.springframework.data.repository.core.support.RepositoryComposition$RepositoryFragments.invoke(RepositoryComposition.java:515) ~[spring-data-commons-3.5.0.jar:3.5.0]
	at org.springframework.data.repository.core.support.RepositoryComposition.invoke(RepositoryComposition.java:284) ~[spring-data-commons-3.5.0.jar:3.5.0]
	at org.springframework.data.repository.core.support.RepositoryFactorySupport$ImplementationMethodExecutionInterceptor.invoke(RepositoryFactorySupport.java:734) ~[spring-data-commons-3.5.0.jar:3.5.0]
	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) ~[spring-aop-6.2.7.jar:6.2.7]
	at org.springframework.data.repository.core.support.QueryExecutorMethodInterceptor.doInvoke(QueryExecutorMethodInterceptor.java:174) ~[spring-data-commons-3.5.0.jar:3.5.0]
	at org.springframework.data.repository.core.support.QueryExecutorMethodInterceptor.invoke(QueryExecutorMethodInterceptor.java:149) ~[spring-data-commons-3.5.0.jar:3.5.0]
	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) ~[spring-aop-6.2.7.jar:6.2.7]
	at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:380) ~[spring-tx-6.2.7.jar:6.2.7]
	at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:119) ~[spring-tx-6.2.7.jar:6.2.7]
	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) ~[spring-aop-6.2.7.jar:6.2.7]
	at org.springframework.dao.support.PersistenceExceptionTranslationInterceptor.invoke(PersistenceExceptionTranslationInterceptor.java:138) ~[spring-tx-6.2.7.jar:6.2.7]
	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) ~[spring-aop-6.2.7.jar:6.2.7]
	at org.springframework.data.jpa.repository.support.CrudMethodMetadataPostProcessor$CrudMethodMetadataPopulatingMethodInterceptor.invoke(CrudMethodMetadataPostProcessor.java:165) ~[spring-data-jpa-3.5.0.jar:3.5.0]
	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) ~[spring-aop-6.2.7.jar:6.2.7]
	at org.springframework.aop.framework.JdkDynamicAopProxy.invoke(JdkDynamicAopProxy.java:223) ~[spring-aop-6.2.7.jar:6.2.7]
	at jdk.proxy2/jdk.proxy2.$Proxy132.save(Unknown Source) ~[na:na]
	at com.waylau.rednote.service.impl.NoteServiceImpl.updateNote(NoteServiceImpl.java:86) ~[classes/:na]

分析

核心代码位置:

@Override
public void updateNote(Note note, NoteEditDto noteEditDto) {

	// 更新基本信息
	note.setTitle(noteEditDto.getTitle());
	note.setContent(noteEditDto.getContent());
	note.setCategory(noteEditDto.getCategory());

	// 字符串转为List
	note.setTopics(StringUtil.splitToList(noteEditDto.getTopics()," "));

	// 保存更新
	noteRepository.save(note);
}

其中,实体Note的topics是由StringUtil.splitToList()生成的。splitToList实现如下:

public static List<String> splitToList(String source, String regex) {
	if (source.isEmpty()) {
		return Collections.emptyList();
	}

	return  Arrays.asList(source.split(regex));
}

Arrays.asList() 返回的集合是不可变集合,而 Hibernate 在执行持久化操作时需要修改这些集合。

整改方案

在保存前临时替换集合:

@Override
public void updateNote(Note note, NoteEditDto noteEditDto) {
	// 更新基本信息
	note.setTitle(noteEditDto.getTitle());
	note.setContent(noteEditDto.getContent());
	note.setCategory(noteEditDto.getCategory());

	// 字符串转为List
	// 确保体使用可变集合实现
	// note.setTopics(StringUtil.splitToList(noteEditDto.getTopics()," "));
	note.setTopics(new ArrayList<>(StringUtil.splitToList(noteEditDto.getTopics()," ")));
	// 保存更新
	noteRepository.save(note);
}

运行调测

下图10-2所示的是笔记编辑页面。

下图10-3所示的是笔记编辑成功后的页面。

总结

UnsupportedOperationException 通常表示你正在尝试修改一个不可变集合。确保你的实体使用可变集合实现(如 ArrayList),并在DTO到实体转换过程中创建新的可变集合实例。

1.6 从笔记详情页面触发编辑、删除笔记的请求

在笔记详情页面操作栏上已经预留了编辑、删除笔记的按钮。如下图10-4所示。

接下来实现从编辑、删除笔记的按钮执行触发编辑、删除笔记的请求。

修改编辑笔记按钮事件

修改编辑的按钮事件,在<button>外层再套一个<a>即可:

<!-- 编辑 -->
<a th:href="@{/note/{noteId}/edit(noteId=${note.noteId})}">
    <button class="btn btn-light btn-sm" th:if="${#authentication.name == note.author.username}">
        <i class="fa fa-edit"></i>
    </button>
</a>

修改删除笔记的按钮事件

修改删除的按钮事件,在<button>设置id属性和onclick事件处理:

<!-- 删除 -->
<button class="btn btn-light btn-sm" th:if="${#authentication.name == note.author.username}"
    th:onclick="deleteNote([[${note.noteId}]])">
    <i class="fa fa-trash"></i>
</button>

deleteNote()函数定义如下:

// 处理笔记删除
function deleteNote(noteId) {
    if (confirm("确定要删除此笔记吗?")) {
        fetch(`/note/${noteId}`, {
            method: 'DELETE'
        })
        .then(response => {
            if (response.ok) {
                response.json().then(data => {
                    // 从响应中获取提示信息
                    alert(data.message || '删除成功');

                    // 从响应中获取重定向URL
                    window.location.href = data.redirectUrl;
                });
            } else  {
                response.json().then(data => {
                    alert(data.message || '删除失败,请重试');
                });
            }
        })
        .catch(error => {
            console.error('删除失败:', error);
            alert('删除失败,请稍后重试');
        })
    }
}

通过fetch()来发送DELETE请求。fetch 是一个现代化的 JavaScript API,用于发送网络请求并获取资源。它是浏览器提供的全局方法,可以替代传统的 XMLHttpRequest。fetch 支持 Promise,因此更易用且代码更清晰。

1.7 掌握@DeleteMapping针对DELETE请求的特殊处理

增加控制器方法

在原有的NoteController基础上,增加方法以实现相关功能。

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

    Note note = optionalNote.get();

    // 获取当前用户信息
    User user = userService.getCurrentUser();

    // 判定笔记是否属于当前用户,不属于则抛出异常
    if (!note.getAuthor().getUserId().equals(user.getUserId())) {
        throw new NoteNotFoundException("");
    }

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

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

    return ResponseEntity.ok(deleteResponseDto);
}

:在 Spring MVC 中使用 @DeleteMapping 处理删除请求后,但不能使用RedirectAttributes进行重定向。这是因为:HTTP 规范中,DELETE 请求不应该有重定向响应。浏览器在处理 DELETE 请求的重定向时可能会遇到各种问题,如安全限制、缓存问题或行为不一致。因此,使用ResponseEntity作为响应体。

通用删除响应对象DeleteResponseDto

ResponseEntity作为响应体所包裹的对象是DeleteResponseDto,代码如下:

package com.waylau.rednote.dto;

import lombok.Getter;
import lombok.Setter;

/**
 * DeleteResponseDto 执行删除的响应对象
 *
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/06/12
 **/
@Getter
@Setter
public class DeleteResponseDto {
    /**
     * 信息
     */
    private String message;

    /**
     * 重定向URL
     */
    private String redirectUrl;
}

上述对象可以用于任意DELETE请求的场景。

增加NoteRepository方法

修改NoteRepository增加方法如下:

/**
* 删除笔记
*
* @param note
*/
void delete(Note note);

删除笔记的服务

修改NoteService,增加如下接口:

public interface NoteService {
 

    /**
     * 删除笔记
     *
     * @param note
     */
    void deleteNote(Note note);
}
``



修改NoteServiceImpl,实现笔记删除的方法:


```java
@Override
@Transactional
public void deleteNote(Note note) {
    // 注意:先删除数据库数据再删图片文件。以防止删除文件异常时,方便回滚数据库数据

    // 先删除数据库数据
    noteRepository.delete(note);

    // 再删图片文件
    List<String> images = note.getImages();
    for (String image : images) {
        fileStorageService.deleteFile(image);
    }
}

需要注意是的,上述方法既有删除文件的,又有删除数据库数据的。因此,需要加@Transactional进行事务管理,同时,先删库再删文件。这样,以在删除文件异常时,方便回滚数据库。

1.8 处理CSRF保护引发的HttpRequestMethodNotSupportedException异常

问题背景

运行应用,试图删除笔记时,报错如下图10-5所示。

同时在控制台日志里面看大如下信息:

2025-06-12T14:34:19.883+08:00  WARN 21324 --- [rednote] [io-8080-exec-10] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.HttpRequestMethodNotSupportedException: Request method 'DELETE' is not supported]

原因

系统已经启用了CSRF保护,在WebSecurityConfig配置如下:

// 启用 CSRF 防护
.csrf(Customizer.withDefaults())

因此,使用JavaScript fetch API所发送的 DELETE 方法需要有效的 CSRF 令牌,否则会报错。

如何设置并获取 CSRF 令牌

首先,确保在你的 HTML 模板中有一个 meta 标签来存储 CSRF 令牌。Spring Security 默认会提供一个名为 _csrf 的令牌,你可以通过 Thymeleaf 将其插入到 meta 标签中。

修改user-profile.html,增加如下内容:

<!-- 确保有一个meta标签来存储CSRF令牌 -->
<meta name="_csrf" th:content="${_csrf.token}"></meta>

接着,在JavaScript fetch API所发送的 DELETE 方法头信息里面设置 CSRF 令牌:

// 笔记删除
function deleteNote(noteId) {
    if (confirm("确定要删除此笔记吗?")) {
        fetch(`/note/${noteId}`, {
            method: 'DELETE',
            // 添加请求头, 用于Spring Security CSRF
            headers: {
                'X-CSRF-TOKEN': document.querySelector('meta[name="_csrf"]').getAttribute('content')
            }
        })
    // ...为节约篇幅,此处省略非核心内容

运行调测

运行应用,删除笔记时,可以看到如下图10-6所示的提示框,说明笔记已经能够成功删除了。

点击提示框“确认”按钮,可以重定向到了用户信息管理界面,如下图10-7所示。

1.9 细粒度的访问控制确保只能作者修改、删除自己的笔记

在前面课程中介绍了,在对笔记进行编辑、删除的时候,是加了代码判断,确保只有笔记的作者才能修改、删除笔记的按钮。代码如下:

// 获取当前用户信息
User user = userService.getCurrentUser();

Note note = optionalNote.get();

// 判定笔记是否属于当前用户,不属于则抛出异常
if (!note.getAuthor().getUserId().equals(user.getUserId())) {
    throw new NoteNotFoundException("");
}

// 执行后续业务

但这种编程方式固然可行,但略微繁琐。本节介绍一种通过声明式的方式来实现细粒度的访问控制。

Spring Security 的 @PreAuthorize 深入解析

@PreAuthorize 是 Spring Security 提供的一个强大注解,用于在方法调用前进行权限检查。它允许你基于表达式语言(SpEL)定义细粒度的访问控制规则,是实现方法级安全的核心工具之一。

@PreAuthorize 是一个方法级别的安全注解,用于在方法执行前验证当前用户是否具有执行该方法的权限。如果验证失败,Spring Security 会抛出 AccessDeniedException

使用场景

  • 基于角色的访问控制
  • 基于权限的访问控制
  • 动态权限检查
  • 复杂业务逻辑的权限控制

基本语法

@PreAuthorize("expression")
public void someMethod() {
    // 方法实现
}

常用表达式

基于角色的访问控制

@PreAuthorize("hasRole('ADMIN')")
public void adminOnlyMethod() {
    // 只有ADMIN角色可以访问
}

基于权限的访问控制

@PreAuthorize("hasAuthority('READ_PRIVILEGE')")
public void readData() {
    // 只有拥有READ_PRIVILEGE权限的用户可以访问
}

组合多个条件

@PreAuthorize("hasRole('USER') and hasAuthority('WRITE_PRIVILEGE')")
public void writeData() {
    // 用户必须同时具有USER角色和WRITE_PRIVILEGE权限
}

使用方法参数

@PreAuthorize("#id == authentication.principal.id")
public void deleteUser(@PathVariable Long id) {
    // 只有用户可以删除自己的账户
}

自定义权限检查

@PreAuthorize("@customSecurityService.checkPermission(authentication, #resourceId, 'DELETE')")
public void deleteResource(@PathVariable Long resourceId) {
    // 调用自定义服务检查权限
}

自定义权限检查是否是作者自己

在NoteService中增加接口:

/**
  * 验证用户是否为笔记作者
  *
  * @param noteId
  * @param username
  * @return
  */
boolean isAuthor(Long noteId, String username);

在NoteServiceImpl中增加方法:

@Override
public boolean isAuthor(Long noteId, String username) {
    Optional<Note> optionalNote = noteRepository.findByNoteId(noteId);
    if (!optionalNote.isPresent()) {
        throw new NoteNotFoundException("");
    }

    return username.equals(optionalNote.get().getAuthor().getUsername());
}

修改NoteController,在需要方法级别控制的方法上面加@PreAuthorize注解:

@GetMapping("/{noteId}/edit")
@PreAuthorize("@noteServiceImpl.isAuthor(#noteId, authentication.name)")
public String showEditForm(@PathVariable Long noteId, Model model) {
    // ...为节约篇幅,此处省略非核心内容
}
@PostMapping("/{noteId}")
@PreAuthorize("@noteServiceImpl.isAuthor(#noteId, authentication.name)")
public String updateNote(@PathVariable Long noteId,
                             @Valid @ModelAttribute("note") NoteEditDto noteEditDto,
                             BindingResult result,
                             Model model,
                             RedirectAttributes redirectAttributes) {
    // ...为节约篇幅,此处省略非核心内容
}
@DeleteMapping("/{noteId}")
@PreAuthorize("@noteServiceImpl.isAuthor(#noteId, authentication.name)")
public ResponseEntity<DeleteResponseDto> deleteNote(@PathVariable Long noteId) {
    // ...为节约篇幅,此处省略非核心内容
}

上述@noteServiceImpl中的noteServiceImpl是指NoteServiceImpl在Spring中的Bean的名称。

配置要求

要使用 @PreAuthorize,需要在配置类上启用方法级安全:

import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;

@Configuration
@EnableWebSecurity
// 启用@PreAuthorize等注解
// 等同于老版本的@EnableGlobalMethodSecurity(prePostEnabled = true)
@EnableMethodSecurity
public class WebSecurityConfig {
    // ...为节约篇幅,此处省略非核心内容
}

Spring Security 的@EnableMethodSecurity注解用于开启方法级安全授权(Method Security),替代了旧版本中的@EnableGlobalMethodSecurity。以下是关键信息:

核心功能

  • ‌灵活配置‌:支持基于Bean的配置方式,允许为不同授权类型(如JSR-250、Spring EL表达式等)单独设置配置。 ‌
  • ‌权限校验‌:通过注解(如@PreAuthorize、@PostAuthorize)实现方法执行前后的权限验证。 ‌

与@EnableGlobalMethodSecurity的区别

  • ‌版本差异‌:@EnableMethodSecurity是Spring Security 5.6版本引入的替代方案,而@EnableGlobalMethodSecurity在5.6之前使用。 ‌
  • ‌配置方式‌:@EnableMethodSecurity支持更细粒度的配置(如JSR-250、Spring EL表达式等),而@EnableGlobalMethodSecurity仅提供三种预定义机制(prePostEnabled、securedEnabled、jsr250Enabled)。 ‌

总结

@PreAuthorize 提供了强大的方法级安全控制能力,通过 SpEL 表达式可以实现非常灵活的权限控制逻辑。它的主要优势包括:

  1. 细粒度控制:可以精确到方法甚至方法参数级别的权限控制
  2. 动态性:可以基于运行时信息(如用户属性、方法参数)进行权限检查
  3. 可读性:表达式语言直观易懂,便于维护
  4. 可扩展性:支持自定义 SpEL 函数和权限评估器

合理使用 @PreAuthorize 可以显著提高应用程序的安全性,同时保持代码的清晰和可维护性。

1.10 安全最佳实践总结及扩展建议

安全最佳实践总结

  1. 永远不要信任前端验证

    • 前端隐藏编辑按钮只是用户体验优化
    • 真正的安全验证必须在后端完成
  2. 使用 HTTPS

    • 防止中间人攻击和会话劫持
  3. 会话管理

    • 使用 JWT 或 Session 管理用户身份
    • 设置合理的过期时间
  4. 日志记录

    • 记录所有修改和删除操作
    • 记录异常的访问尝试
    • 记录权限检查失败的情况,便于审计和故障排查
  5. CSRF 防护

    • 启用 Spring Security 的 CSRF 保护
    • 对于 AJAX 请求,确保包含 CSRF 令牌
  6. 参数验证

    • 使用 @Valid 注解验证请求参数
    • 防止 SQL 注入和 XSS 攻击
  7. 最小权限原则

    • 只授予用户完成工作所需的最小权限
  8. 性能考虑

    • 对于高频调用的方法,避免复杂的 SpEL 表达式

扩展建议

功能扩展建议

  1. 图片编辑功能

    • 添加图片裁剪、滤镜等编辑功能
    • 支持图片排序调整
  2. 富文本编辑

    • 集成富文本编辑器,支持格式化文本
    • 添加表情符号和贴纸功能
  3. 标签推荐

    • 基于内容自动推荐相关标签
    • 热门标签快速选择
  4. 草稿保存

    • 自动保存草稿功能
    • 草稿列表管理
  5. 发布设置

    • 隐私设置(公开、仅自己可见)
    • 发布时间设置(立即发布、定时发布)

安全扩展建议

  1. 多级权限控制

    • 管理员可以修改/删除任何笔记
    • 实现角色系统(ROLE_USER, ROLE_ADMIN)
  2. 软删除

    • 不物理删除笔记,而是标记为已删除
    • 便于数据恢复和审计
  3. 操作审计

    • 记录谁在什么时间修改/删除了笔记
    • 使用 Spring Data JPA 的 @CreatedBy@LastModifiedBy
  4. 并发控制

    • 使用乐观锁(@Version 注解)防止并发修改冲突

通过以上实现,可以确保只有笔记的作者才能修改或删除自己的笔记,同时提供良好的用户体验和安全防护。

2.1 首页笔记探索功能概述

实现一个仿小红书的首页功能,包含笔记流展示、搜索、分类导航、推荐内容等核心功能。

核心功能与设计特点

  1. 界面布局

    • 顶部固定搜索栏和功能按钮
    • 分类导航栏
    • 网格布局的笔记卡片
    • 底部固定导航栏
  2. 笔记卡片设计

    • 网格图片布局
    • 图片上的标签显示
    • 标题、作者信息和互动数据
    • 点击跳转详情页
  3. 交互体验

    • 无限滚动加载更多内容
    • 分类切换刷新内容
    • 底部导航栏状态切换
    • 平滑的页面过渡
  4. 响应式设计

    • 适配移动设备和桌面设备
    • 网格布局自动调整
    • 触摸友好的交互元素

2.2 使用Bootstrap、Font Awesome以及Thymeleaf轻松实现首页笔记探索界面设计

主要分为以下几个部分

  • 顶部导航栏
  • 分类导航
  • 笔记卡片网格
  • 加载更多内容提示
  • 没有更多内容提示
  • 底部导航栏

界面整体布局

src/main/resources/templates目录下,新建一个explore.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.jsdelivr.net/npm/bootstrap@5.3.6/dist/css/bootstrap.min.css"
        th:href="@{/css/bootstrap.min.css}" rel="stylesheet">
    <!-- 引入 Font Awesome -->
    <link href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css"
        th:href="@{/css/font-awesome.min.css}" rel="stylesheet">
    <!-- TODO 自定义样式 -->
    <style>
        
    </style>
</head>
<header>
    <!-- TODO 顶部导航栏 -->
    
</header>
<header>
    <!-- TODO 分类导航 -->
    
</header>
<body>
    <main>
        <div class="container">
            <!-- TODO 笔记卡片网格 -->

            <!-- TODO 加载更多内容提示 -->

            
            <!-- TODO 没有更多内容提示 -->

        </div>
    </main>
    <footer>
        <!-- TODO 底部导航栏 -->


    </footer>

    <!-- Bootstrap JS -->
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.6/dist/js/bootstrap.bundle.min.js"
        th:src="@{/js/bootstrap.bundle.min.js}"></script>
    <script>
         // TODO 程序运行脚本
    </script>
</body>

</html>

顶部导航栏

<style>
    /* 全局样式 */
    body {
        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
        background-color: #f5f5f5;
    }
</style>

<!-- ...为节约篇幅,此处省略非核心内容 -->
<!-- 顶部导航栏 -->
<header>
    <nav class="navbar navbar-expand-lg">
        <div class="container">
            <a class="navbar-brand" href="/" th:href="@{/}">
                <img src="../static/images/rn_logo.png" th:src="@{/images/rn_logo.png}" alt="RN" height="24">
            </a>

            <!-- 搜索框-->
            <div class="col-md-3">
                <div class="input-group">
                    <input class="form-control" type="text" placeholder="搜索感兴趣的内容" aria-label="Search">
                </div>
            </div>

            <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav"
                    aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
                <span class="navbar-toggler-icon"></span>
            </button>

            <div class="collapse navbar-collapse" id="navbarNav">
                <ul class="navbar-nav me-auto">
                </ul>

                <ul class="navbar-nav mb-2 mb-lg-0">
                    <li class="nav-item dropdown">
                        <a class="nav-link dropdown-toggle" href="#" data-bs-target="dropdown" data-bs-toggle="dropdown"
                           aria-expanded="false">
                            [[${#authentication.name}]]
                        </a>

                        <ul class="dropdown-menu" id="dropdown">
                            <li class="dropdown-item">
                                <a class="nav-link" href="/user/profile" th:href="@{/user/profile}">个人资料</a>
                            </li>
                            <li class="dropdown-item">
                                <form th:action="@{/logout}" action="/logout" method="post">
                                    <button type="submit" class="nav-link">退出登录</button>
                                </form>
                            </li>
                        </ul>
                    </li>

                </ul>

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

分类导航

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

    /* 分类导航 */
    .category-nav {
        background-color: white;
        padding: 8px 0;
        overflow-x: auto;
        white-space: nowrap;
        -webkit-overflow-scrolling: touch;
    }

    .category-item {
        display: inline-block;
        padding: 6px 12px;
        margin-right: 8px;
        border-radius: 20px;
        font-size: 14px;
        cursor: pointer;
        transition: background-color 0.2s;
    }

    .category-item.active {
        background-color: #ff2442;
        color: white;
    }
</style>

</head>
<!-- 顶部导航栏 -->
<header>
    <!-- ...为节约篇幅,此处省略非核心内容 -->
</header>

<!-- 分类导航 -->
<header>
    <div class="container">
        <div class="category-item active">推荐</div>
        <div class="category-item">穿搭</div>
        <div class="category-item">美食</div>
        <div class="category-item">彩妆</div>
        <div class="category-item">影视</div>
        <div class="category-item">职场</div>
        <div class="category-item">情感</div>
        <div class="category-item">家居</div>
        <div class="category-item">游戏</div>
        <div class="category-item">旅行</div>
        <div class="category-item">健身</div>
    </div>
</header>

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

笔记卡片网格

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

    /* 笔记卡片网格 */
    .notes-grid {
        display: grid;
        grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
        gap: 8px;
        padding: 8px;
    }

    .note-card {
        background-color: white;
        border-radius: 8px;
        overflow: hidden;
        box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
    }

    .note-image-container {
        position: relative;
        padding-bottom: 100%;
        /* 保持正方形比例 */
        overflow: hidden;
        border-radius: 12px;
    }

    .note-image {
        position: absolute;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        object-fit: cover;
    }

    .note-tag {
        position: absolute;
        bottom: 8px;
        left: 8px;
        background-color: rgba(0, 0, 0, 0.5);
        color: white;
        padding: 2px 8px;
        border-radius: 10px;
        font-size: 12px;
    }

    .note-content {
        padding: 8px;
    }

    .note-title {
        font-size: 14px;
        font-weight: 500;
        margin-bottom: 4px;
        line-height: 1.4;
        overflow: hidden;
        text-overflow: ellipsis;
        display: -webkit-box;
        -webkit-line-clamp: 2;
        -webkit-box-orient: vertical;
    }

    .note-author {
        display: flex;
        align-items: center;
        margin-bottom: 4px;
    }

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

    .author-name {
        font-size: 12px;
        color: #666;
    }

    .note-author-stats {
        display: flex;
        justify-content: space-between;
    }
    
    .note-stats {
        display: flex;
        align-items: center;
        font-size: 12px;
        color: #999;
    }

    .stat-item {
        margin-right: 12px;
    }
</style>

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

<header>
    <!-- 顶部导航栏 -->
    <!-- ...为节约篇幅,此处省略非核心内容 -->
</header>
<header>
    <!-- 分类导航 -->
    <!-- ...为节约篇幅,此处省略非核心内容 -->
</header>
<body>
    <main>
        <div class="container">
            <!-- 笔记卡片网格 -->
            <div class="notes-grid" id="notesGrid">
                <!-- 笔记卡片将通过JavaScript动态生成 -->
            </div>
            <!-- TODO 加载更多内容提示 -->

            
            <!-- TODO 没有更多内容提示 -->

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

</html>

加载更多内容提示

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

    /* 加载更多 */
    .load-more {
        text-align: center;
        padding: 16px 0;
        color: #666;
        font-size: 14px;
    }

</style>
<!-- ...为节约篇幅,此处省略非核心内容 -->
 
<header>
    <!-- 顶部导航栏 -->
    <!-- ...为节约篇幅,此处省略非核心内容 -->
</header>
<header>
    <!-- 分类导航 -->
    <!-- ...为节约篇幅,此处省略非核心内容 -->
</header>
<body>
    <main>
        <div class="container">
            <!-- 笔记卡片网格 -->
            <!-- ...为节约篇幅,此处省略非核心内容 -->

            <!-- 加载更多内容提示 -->
            <div class="load-more" id="loadMore">
                <i class="fa fa-spinner fa-spin"></i> 加载更多
            </div>
            
            <!-- TODO 没有更多内容提示 -->

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

</html>

没有更多内容提示

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

    /* 没有更多 */
    .no-more {
        text-align: center;
        padding: 0 0 50px 0;
        color: #666;
        font-size: 14px;
        display: none;
    }
</style>
</head>
<header>
    <!-- 顶部导航栏 -->
    <!-- ...为节约篇幅,此处省略非核心内容 -->
</header>
<header>
    <!-- 分类导航 -->
    <!-- ...为节约篇幅,此处省略非核心内容 -->
</header>
<body>
    <main>
        <div class="container">
            <!-- 笔记卡片网格 -->
            <!-- ...为节约篇幅,此处省略非核心内容 -->
            <!-- 加载更多内容提示 -->
            <!-- ...为节约篇幅,此处省略非核心内容 -->
            
            <!-- 没有更多内容提示 -->
            <div class="no-more" id="noMoreContent">
                <p>已经到底啦~</p>
            </div>
        </div>
    </main>
    <footer>
        <!-- TODO 底部导航栏 -->


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

</html>
</body>

</html>

底部导航栏

<!-- ...为节约篇幅,此处省略非核心内容 -->
<style>
    /* ...为节约篇幅,此处省略非核心内容 */
    /* 底部导航栏 */
    .bottom-nav {
        position: fixed;
        bottom: 0;
        left: 0;
        right: 0;
        display: flex;
        justify-content: space-around;
        padding: 8px 0;
        box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.05);
        z-index: 100;
        background-color: #f5f5f5;
    }

    .nav-item {
        display: flex;
        flex-direction: column;
        align-items: center;
        color: #666;
        cursor: pointer;
    }

    .nav-item.active {
        color: #ff2442;
    }

    .nav-icon {
        font-size: 20px;
        margin-bottom: 2px;
    }

    .nav-text {
        font-size: 10px;
    }
</style>
</head>
<header>
    <!-- 顶部导航栏 -->
    
</header>
<header>
    <!-- 分类导航 -->
    
</header>
<body>
    <main>
        <div class="container">
            <!-- 笔记卡片网格 -->
            <!-- ...为节约篇幅,此处省略非核心内容 -->

            <!-- 加载更多内容提示 -->
            <!-- ...为节约篇幅,此处省略非核心内容 -->
            
            <!-- 没有更多内容提示 -->
            <!-- ...为节约篇幅,此处省略非核心内容 -->
        </div>
    </main>
    <footer>
        <!-- 底部导航栏 -->
        <div class="container bottom-nav">
            <div class="nav-item active">
                <i class="fa fa-home nav-icon"></i>
                <span class="nav-text">首页</span>
            </div>
            <div class="nav-item">
                <i class="fa fa-compass nav-icon"></i>
                <span class="nav-text">发现</span>
            </div>
            <div class="nav-item">
                <i class="fa fa-plus nav-icon"></i>
                <span class="nav-text">发布</span>
            </div>
            <div class="nav-item">
                <i class="fa fa-comment-o nav-icon"></i>
                <span class="nav-text">消息</span>
            </div>
            <div class="nav-item">
                <i class="fa fa-user-o nav-icon"></i>
                <span class="nav-text">我的</span>
            </div>
        </div>

    </footer>

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

    <script>
         // TODO 程序运行脚本
    </script>
</body>

</html>

2.3 掌握无限滚动刷新加载笔记内容生成笔记卡片网格的秘笈

修改explore.html,在<script>增加如下内容:

<script>
    let currentPage = 0;
    let isLoading = false;
    let hasMore = true;
    loadMoreNotes();

    // 加载更多笔记
    function loadMoreNotes() {
        if (isLoading || !hasMore) {
            // 隐藏加载更多
            hideLoadMore();
            // 显示没有更多内容
            showNoMoreContent();
            return;
        }

        isLoading = true;
        // 显示加载更多
        showLoadMore();

        // 获取当前分类
        let category = document.querySelector('.category-item.active').textContent.trim();

        // 发送请求
        fetch(`/explore/note?page=${currentPage + 1}&category=${category}`)
            .then(response => response.json())
            .then(data => {
                if (data.notes && data.notes.length > 0) {
                    currentPage++;
                    // 添加笔记列表到网格布局中
                    appendNotes(data.notes);
                    hasMore = data.hasMore;
                } else {
                    hasMore = false;
                }

                isLoading = false;
                // 隐藏加载更多
                hideLoadMore();

                if (!hasMore) {
                    // 显示没有更多内容
                    showNoMoreContent();
                }
            })
            .catch(error => {
                console.error('Error:', error);
                isLoading = false;
                // 隐藏加载更多
                hideLoadMore();
            });
    }

    // 隐藏加载更多
    function hideLoadMore() {
        document.getElementById("loadMore").style.display = "none";
    }

    // 显示没有更多内容
    function showNoMoreContent() {
        document.getElementById("noMoreContent").style.display = "block";
    }

    // 显示加载更多
    function showLoadMore() {
        document.getElementById("loadMore").style.display = "block";
    }

    const notesGrid = document.getElementById("notesGrid");
    // 添加笔记列表到网格布局中
    function appendNotes(notes) {
        for (let i = 0; i < notes.length; i++) {
            const note = notes[i];

            // 创建笔记卡片元素
            const noteElement = createNoteElement(note);

            notesGrid.appendChild(noteElement);
        }
    }

    // 创建笔记卡片元素
    function createNoteElement(note) {
        const noteElement = document.createElement("div");
        noteElement.className = "masonry-item";
        noteElement.innerHTML = `
            <div class="note-image-container">
                <img class="note-image" src="${note.cover}" alt="${note.title}">
            </div>
            <div class="note-content">
                <div class="note-title">${note.title}</div>
                <div class="note-author-stats">
                    <div class="note-author">
                        <img class="author-avatar" src="${note.avatar ? note.avatar : '/images/rn_avatar.png'}" alt="${note.username}">
                        <span class="author-name">${note.username}</span>
                    </div>
                    <div class="note-stats">
                        <div class="stat-item">
                            <i class="fa fa-heart-o">1024</i>
                        </div>
                    </div>
                </div>
            </div>
        `;

        return noteElement;
    }


    const categoryItems = document.querySelectorAll('.category-item');

    // 为分页导航添加点击事件
    categoryItems.forEach(item => {
        item.addEventListener('click', () => {
            categoryItems.forEach(item => {
                item.classList.remove('active');
            });
            item.classList.add('active');

            // 重置笔记网格数据
            notesGrid.innerHTML = '';

            // 恢复初始状态值
            currentPage = 0;
            isLoading = false;
            hasMore = true;
            loadMoreNotes();
        });
    });

    // 滚动事件
    window.addEventListener('scroll', function() {
        console.log('scroll');

        if (isLoading || !hasMore) {
            return;
        }

        console.log('scroll before');

        const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
        const windowHeight = window.innerHeight;
        const documentHeight = document.documentElement.scrollHeight;

        console.log('scrollTop: ' + scrollTop);
        console.log('windowHeight: ' + windowHeight);
        console.log('documentHeight: ' + documentHeight);

        if (scrollTop + windowHeight >= documentHeight - 300) {
            loadMoreNotes();
        }

        console.log('scroll after');
    });
</script>

2.4 创建一个Spring MVC控制器类处理首页笔记探索请求

新建一个控制器ExploreController,用于处理首页笔记探索的请求。

返回首页笔记探索页面

新增方法如下。

package com.waylau.rednote.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;

/**
 * ExploreController 首页笔记探索
 *
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/08/20
 **/
@Controller
@RequestMapping("/explore")
public class ExploreController {

    /**
     * 显示笔记探索页面
     */
    @GetMapping
    public String showExplore() {
        return "explore";
    }
}

返回首页笔记探索页面的笔记数据

新增方法如下。

private static final int PAGE_SIZE = 20;
private static final String DEFAULT_CATEGORY = "推荐";

@Autowired
private NoteService noteService;

/**
  * 返回首页笔记探索页面的笔记数据
  */
@GetMapping("/note")
public ResponseEntity<NoteResponseDto> getNotesByCategory(
                                                          @RequestParam(defaultValue = "1") int page,
                                                          @RequestParam(required = false) String category) {
    // 把“推荐”当成空
    if (DEFAULT_CATEGORY.equals(category)) {
        category = null;
    }

    Page<Note> notes = noteService.getNotesByPage(page, PAGE_SIZE, category);

    NoteResponseDto notesResponseDto = new NoteResponseDto();
    notesResponseDto.setHasMore(notes.hasNext());
    notesResponseDto.setNotes(notes.getContent());

    return ResponseEntity.ok(notesResponseDto);
}

上述接口,可以根据分类进行分页查询,并将查询结果通过NoteResponseDto数据结构返回给前端。

如果分类是“推荐”,实际上就是不需要分类,直接赋值为null即可。

探索笔记的响应对象DTO

新增NoteResponseDto如下。

package com.waylau.rednote.dto;

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

import java.util.List;

/**
 * NoteResponseDto 探索笔记的响应对象
 *
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/08/20
 **/
@Getter
@Setter
public class NoteResponseDto {
    /**
     * 笔记列表
     */
    private List<Note> notes;

    /**
     * 是否还有更多
     */
    private boolean hasMore;
}

首页重定向

首页重定向到首页笔记探索页面:

package com.waylau.rednote.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;

/**
 * IndexController 首页控制器
 *
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/08/20
 **/
@Controller
@RequestMapping("/")
public class IndexController {
    @GetMapping
    public String index() {
        // 重定向到首页笔记探索页面
        return "redirect:/explore";
    }
}

2.5 调整安全配置类细化首页笔记探索的访问权限

  1. 在 Spring Security 配置类中,进一步细化首页笔记索页面的访问权限
  2. 确保只有普通用户角色可以访问首页笔记索页面

修改WebSecurityConfig如下:

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
            // ...为节约篇幅,此处省略非核心内容    
            .authorizeHttpRequests(authorize -> authorize
                    // ...为节约篇幅,此处省略非核心内容

                    // 允许USER角色的用户访问 /explore/** 的资源
                    .requestMatchers("/explore/**").hasRole("USER")
                    // 其他请求需要认证
                    .anyRequest().authenticated()
            )

 
    ;

    return http.build();
}            

2.6 提供分类分页查询笔记的服务

修改NoteRepository

修改NoteRepository,增加如下接口:

/**
  * 根据分类、分页查询笔记
  *
  * @param category
  * @param pageable
  * @return
  */
Page<Note> findByCategory(String category, Pageable pageable);

/**
  * 分页查询笔记
  *
  * @param pageable
  * @return
  */
Page<Note> findAll(Pageable pageable);

上述两个接口的区别是,如果不提供分类,实际上就是全查。

修改NoteService

修改NoteService,增加如下接口:

/**
  * 分类分页查询笔记
  *
  * @param page
  * @param pageSize
  * @param category
  * @return
  */
Page<Note> getNotesByPage(int page, int pageSize, String category);

修改NoteServiceImpl

修改NoteServiceImpl,实现分类分页查询笔记的方法:

@Override
public Page<Note> getNotesByPage(int page, int pageSize, String category) {
    // 构造Pageable对象,按照创建时间倒序排序
    Pageable pageable = PageRequest.of(page - 1, pageSize, Sort.by("createAt").descending());

    if (category != null && !category.isEmpty()) {
        return noteRepository.findByCategory(category, pageable);
    }

    return noteRepository.findAll(pageable);
}

2.7 处理Hibernate懒加载与Jackson序列化冲突的问题

Hibernate 懒加载与 Jackson 序列化冲突的问题

当前端使用JavaScript fetch API试图访问返回首页笔记探索页面的笔记数据时,会报以下错误:

com.fasterxml.jackson.databind.exc.InvalidDefinitionException: No serializer found for class org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor and no properties discovered to create BeanSerializer (to avoid exception, disable SerializationFeature.FAIL_ON_EMPTY_BEANS) (through reference chain: com.waylau.rednote.dto.NotesResponseDto["notes"]->java.util.Collections$UnmodifiableRandomAccessList[0]->com.waylau.rednote.entity.Note["author"]->com.waylau.rednote.entity.User$HibernateProxy["hibernateLazyInitializer"])
	at com.fasterxml.jackson.databind.exc.InvalidDefinitionException.from(InvalidDefinitionException.java:77) ~[jackson-databind-2.19.0.jar:2.19.0]
	at com.fasterxml.jackson.databind.SerializerProvider.reportBadDefinition(SerializerProvider.java:1359) ~[jackson-databind-2.19.0.jar:2.19.0]
	at com.fasterxml.jackson.databind.DatabindContext.reportBadDefinition(DatabindContext.java:415) ~[jackson-databind-2.19.0.jar:2.19.0]
	at com.fasterxml.jackson.databind.ser.impl.UnknownSerializer.failForEmpty(UnknownSerializer.java:52) ~[jackson-databind-2.19.0.jar:2.19.0]
	at com.fasterxml.jackson.databind.ser.impl.UnknownSerializer.serialize(UnknownSerializer.java:29) ~[jackson-databind-2.19.0.jar:2.19.0]
	at com.fasterxml.jackson.databind.ser.BeanPropertyWriter.serializeAsField(BeanPropertyWriter.java:732) ~[jackson-databind-2.19.0.jar:2.19.0]
	at com.fasterxml.jackson.databind.ser.std.BeanSerializerBase.serializeFields(BeanSerializerBase.java:760) ~[jackson-databind-2.19.0.jar:2.19.0]
	at com.fasterxml.jackson.databind.ser.BeanSerializer.serialize(BeanSerializer.java:183) ~[jackson-databind-2.19.0.jar:2.19.0]
	at com.fasterxml.jackson.databind.ser.BeanPropertyWriter.serializeAsField(BeanPropertyWriter.java:732) ~[jackson-databind-2.19.0.jar:2.19.0]
	at com.fasterxml.jackson.databind.ser.std.BeanSerializerBase.serializeFields(BeanSerializerBase.java:760) ~[jackson-databind-2.19.0.jar:2.19.0]
	at com.fasterxml.jackson.databind.ser.BeanSerializer.serialize(BeanSerializer.java:183) ~[jackson-databind-2.19.0.jar:2.19.0]
	at com.fasterxml.jackson.databind.ser.impl.IndexedListSerializer.serializeContents(IndexedListSerializer.java:119) ~[jackson-databind-2.19.0.jar:2.19.0]
	at com.fasterxml.jackson.databind.ser.impl.IndexedListSerializer.serialize(IndexedListSerializer.java:79) ~[jackson-databind-2.19.0.jar:2.19.0]
	at com.fasterxml.jackson.databind.ser.impl.IndexedListSerializer.serialize(IndexedListSerializer.java:18) ~[jackson-databind-2.19.0.jar:2.19.0]
	at com.fasterxml.jackson.databind.ser.BeanPropertyWriter.serializeAsField(BeanPropertyWriter.java:732) ~[jackson-databind-2.19.0.jar:2.19.0]
	at com.fasterxml.jackson.databind.ser.std.BeanSerializerBase.serializeFields(BeanSerializerBase.java:760) ~[jackson-databind-2.19.0.jar:2.19.0]
	at com.fasterxml.jackson.databind.ser.BeanSerializer.serialize(BeanSerializer.java:183) ~[jackson-databind-2.19.0.jar:2.19.0]
	at com.fasterxml.jackson.databind.ser.DefaultSerializerProvider._serialize(DefaultSerializerProvider.java:503) ~[jackson-databind-2.19.0.jar:2.19.0]
	at com.fasterxml.jackson.databind.ser.DefaultSerializerProvider.serializeValue(DefaultSerializerProvider.java:342) ~[jackson-databind-2.19.0.jar:2.19.0]
	at com.fasterxml.jackson.databind.ObjectWriter$Prefetch.serialize(ObjectWriter.java:1587) ~[jackson-databind-2.19.0.jar:2.19.0]
	at com.fasterxml.jackson.databind.ObjectWriter.writeValue(ObjectWriter.java:1061) ~[jackson-databind-2.19.0.jar:2.19.0]
	at org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter.writeInternal(AbstractJackson2HttpMessageConverter.java:485) ~[spring-web-6.2.7.jar:6.2.7]
	at org.springframework.http.converter.AbstractGenericHttpMessageConverter.write(AbstractGenericHttpMessageConverter.java:126) ~[spring-web-6.2.7.jar:6.2.7]
	at org.springframework.web.servlet.mvc.method.annotation.AbstractMessageConverterMethodProcessor.writeWithMessageConverters(AbstractMessageConverterMethodProcessor.java:345) ~[spring-webmvc-6.2.7.jar:6.2.7]
	at org.springframework.web.servlet.mvc.method.annotation.HttpEntityMethodProcessor.handleReturnValue(HttpEntityMethodProcessor.java:263) ~[spring-webmvc-6.2.7.jar:6.2.7]

接口返回的是ResponseEntity.ok(notesResponseDto)。ResponseEntity.ok() 是 Spring 框架中用于构建 HTTP 响应的一个便捷方法。它属于 org.springframework.http.ResponseEntity 类,主要用于封装 HTTP 响应的状态码、头部信息和响应体,提供更灵活的 API 响应控制。ResponseEntity 的内容会自动序列化为 JSON/XML 等格式。从上述报错信息可以知道,默认的自动序列化工具为Jackson。

错误原因分析

这个错误是典型的Hibernate懒加载与Jackson序列化冲突的问题。具体来说:

  1. 错误根源:当Jackson尝试序列化返回的Note数据时,遇到了Hibernate生成的代理对象(User$HibernateProxy
  2. 问题路径: NotesResponseDto -> notes列表 -> Note实体 -> author属性 -> User实体的Hibernate代理对象
  3. 技术细节
    • Hibernate使用代理对象实现懒加载关联实体
    • Jackson无法识别Hibernate的代理类(ByteBuddyInterceptor
    • 代理对象中的hibernateLazyInitializer属性触发了序列化错误

从代码断点调试可以看到auther对象属性是空的,如下图11-1所示。

解决方案

解决方案有几下几种。

  1. 优先使用DTO模式:通过专门的DTO类定义API响应格式,避免直接序列化实体对象
  2. 合理设计关联关系:根据业务需求选择合适的加载策略(EAGER/FETCH)
  3. 使用@JsonView进行精细控制:在复杂场景中使用Jackson的@JsonView实现选择性序列化
  4. 结合性能考虑:懒加载是提高性能的重要手段,但需要配合合理的初始化策略

在本例中,使用的DTO模式。

1. 创建NoteExploreDto

创建NoteExploreDto,代码如下:

package com.waylau.rednote.dto;

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

/**
 * NoteExploreDto 笔记探索DTO
 *
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/08/20
 **/
@Getter
@Setter
public class NoteExploreDto {
    private Long noteId;
    private String title;
    /**
     * 封面
     */
    private String cover;
    /**
     * 作者用户名
     */
    private String username;
    /**
     * 作者头像
     */
    private String avatar;

    public static NoteExploreDto toExploreDto(Note note) {
        NoteExploreDto noteExploreDto = new NoteExploreDto();
        noteExploreDto.setNoteId(note.getNoteId());
        noteExploreDto.setTitle(note.getTitle());
        noteExploreDto.setCover(note.getImages().get(0));
        noteExploreDto.setUsername(note.getAuthor().getUsername());
        noteExploreDto.setAvatar(note.getAuthor().getAvatar());

        return noteExploreDto;
    }
}

2. 返回DTO类给前端

ExploreController修改如下:

/**
  * 返回首页笔记探索页面的笔记数据
  */
@GetMapping("/note")
public ResponseEntity<NoteResponseDto> getNotesByCategory(
                                                          @RequestParam(defaultValue = "1") int page,
                                                          @RequestParam(required = false) String category) {
    // 把“推荐”当成空
    if (DEFAULT_CATEGORY.equals(category)) {
        category = null;
    }

    Page<Note> notes = noteService.getNotesByPage(page, PAGE_SIZE, category);

    NoteResponseDto notesResponseDto = new NoteResponseDto();
    notesResponseDto.setHasMore(notes.hasNext());
    //notesResponseDto.setNotes(notes.getContent());

    // 处理序列化问题
    List<NoteExploreDto> noteExploreDtoList = new ArrayList<>();
    for (Note note : notes.getContent()) {
        noteExploreDtoList.add(NoteExploreDto.toExploreDto(note));
    }
    notesResponseDto.setNotes(noteExploreDtoList);

    return ResponseEntity.ok(notesResponseDto);
}

NoteResponseDto修改如下:

package com.waylau.rednote.dto;

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

import java.util.List;

/**
 * NoteResponseDto 探索笔记的响应对象
 *
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/06/13
 **/
@Getter
@Setter
public class NoteResponseDto {
    /**
     * 笔记列表
     */
    // private List<Note> notes;
    private List<NoteExploreDto> notes;

    /**
     * 是否还有更多
     */
    private boolean hasMore;
}

通过以上方法,你应该能够解决Jackson序列化Hibernate代理对象的问题,确保API响应能够正确返回笔记数据。

从代码断点调试可以看到DTO对象属性都是有值的,如下图11-2所示。

如下图11-3所示的是首次访问首页的效果。

如下图11-4所示的是加载了笔记数据之后的效果。

2.8 掌握笔记无限滚动刷新的技巧

修改explore.html,在<script>增加如下内容:

<script>
    // ...为节约篇幅,此处省略非核心内容
    
    // 滚动事件
    window.addEventListener('scroll', function() {
        console.log('scroll');

        if (isLoading || !hasMore) {
            return;
        }

        console.log('scroll before');

        const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
        const windowHeight = window.innerHeight;
        const documentHeight = document.documentElement.scrollHeight;

        console.log('scrollTop: ' + scrollTop);
        console.log('windowHeight: ' + windowHeight);
        console.log('documentHeight: ' + documentHeight);

        if (scrollTop + windowHeight >= documentHeight - 300) {
            loadMoreNotes();
        }

        console.log('scroll after');
    });
</script>

如下图11-5所示的是无限滚动刷新,查询完笔记数据之后的效果。

2.9 格式化数字展示优化信息传达的效率和用户体验

在数字展示中进行格式化(如将10000显示为1w),本质上是为了优化信息传达的效率和用户体验。这种处理方式并非简单的符号替换,而是基于人类认知规律、场景需求和技术实现的综合考量。以下从多个维度解析其背后的逻辑:

一、认知心理学:简化信息处理负荷

  1. 短期记忆容量限制
    人类短期记忆通常只能处理7±2个组块(George Miller的“神奇数字”理论)。例如:

    • 原始数字“1568924”包含7个独立数字,需拆解为“156万8924”或“156.89万”,将信息组块从7个减少到3-4个,降低记忆负担。
    • 对比实验显示:用户识别“3.2k”的速度比“3200”快23%(来源:尼尔森诺曼集团用户体验研究)。
  2. 量级感知优先于精确值
    在许多场景中,用户更关注数字的“量级”而非“精确值”:

    • 社交平台的粉丝数(12.5w vs 125432):前者能快速传递“十万级”的量级概念。
    • 商品销量(5.8k件 vs 5842件):消费者更关心“是否畅销”,而非具体差额。

二、场景适配:不同场景的显示策略差异

场景格式化需求示例核心目的
社交媒体动态轻量化展示,快速抓取注意力点攒数:2.4w减少视觉干扰,突出互动热度
金融数据大屏兼顾量级与精度,可能需要动态切换单位市值:1.28万亿(自动切换万亿/亿/万)适应不同数据规模的展示
电商商品列表简洁化展示,避免价格信息碎片化价格:¥1.5k促进购买决策效率
科学论文图表严格保留精度,使用标准单位(如10³)数据点:1.02×10⁴保证学术严谨性

三、视觉设计:优化界面信息层级

  1. 减少数字长度,提升排版美观

    • 原始数字:“阅读量1289456”在移动端可能占用2-3行,格式化后“128.9w”仅占1行,节省空间。
    • 案例:小红书笔记列表中,将“收藏数9876”显示为“9.9k”,使卡片布局更紧凑(见下图逻辑示意): ┌──────────────┐ ┌──────────────┐ │ 标题 │ │ 标题 │ │ 正文摘要 │ │ 正文摘要 │ │ 赞9876 评123 │ → │ 赞9.9k 评123 │ └──────────────┘ └──────────────┘
  2. 引导视觉焦点
    格式化后的数字通过“单位缩写”(如w/k/m)形成视觉区分,使用户更易捕捉关键数据:

    • 未格式化:“粉丝156234,获赞897654”
    • 格式化:“粉丝15.6w,获赞89.8w”
      后者通过“w”符号强化量级认知,减少用户对具体数字的关注度。

四、技术实现:平衡精度与可读性

  1. 动态单位切换策略

    • 数字 < 1000:显示原值(如568)
    • 1000 ≤ 数字 < 10000:显示为“X.Xk”(如3.2k)
    • 10000 ≤ 数字 < 10^8:显示为“X.Xw”(如12.5w)
    • 10^8 ≤ 数字:显示为“X.X亿”(如1.2亿)
      (注:不同平台可能有细微差异,如抖音、小红书使用“万”“亿”,而GitHub用“k”“M”)
  2. 精度控制算法

    • 四舍五入:12543 → 1.3w(保留1位小数)
    • 截断处理:12543 → 1.2w(适用于需要快速显示的场景)
    • 智能显示:根据数字大小自动调整小数位数:
      • 1024 → 1k(整数)
      • 1250 → 1.25k(两位小数)
      • 12345 → 1.23w(两位小数)
  3. 国际化适配

    • 中文环境:10000 → 1万,100000000 → 1亿
    • 英文环境:1000 → 1k,1000000 → 1M(Million),1000000000 → 1B(Billion)
    • 日语环境:10000 → 1万,100000000 → 1億

五、反例:何时不应格式化?

  1. 需要精确值的场景

    • 财务报表(金额必须精确到分:¥12345.67)
    • 科学实验数据(如温度:25.3℃)
    • 身分证号、订单编号等标识性数字
  2. 小数字场景

    • 数字 < 100:显示原值更直观(如“评论5条”比“0.005w条”更易读)
    • 例外:某些平台为统一风格,可能仍显示为“0.5k”(如500)
  3. 专业领域术语冲突

    • 计算机领域中“1k”通常指1024(2^10),而日常场景中“1k”=1000,需避免歧义。

六、项目里面的应用

定义数字格式化函数:

// 数字格式化,自动转换为k、w单位
function numberFormat(num) {
   if (num > 100000) {
      return (num / 10000).toFixed(1) + 'w';
   } else if (num > 1000) {
      return (num / 1000).toFixed(1) + 'k';
   } else {
      return num;
   }
}

使用函数:

// 创建笔记卡片元素
function createNoteElement(note) {
   const noteElement = document.createElement("div");
   noteElement.className = "masonry-item";
   noteElement.innerHTML = `
      <div class="note-image-container">
            <img class="note-image" src="${note.cover}" alt="${note.title}">
      </div>
      <div class="note-content">
            <div class="note-title">${note.title}</div>
            <div class="note-author-stats">
               <div class="note-author">
                  <img class="author-avatar" src="${note.avatar ? note.avatar : '/images/rn_avatar.png'}" alt="${note.username}">
                  <span class="author-name">${note.username}</span>
               </div>
               <div class="note-stats">
                  <div class="stat-item">
                        <i class="fa fa-heart-o">${numberFormat(1024)}</i>
                  </div>
               </div>
            </div>
      </div>
   `;

   return noteElement;
}

如下图11-6所示的是无限滚动刷新,查询完笔记数据之后的效果。

总结

数字格式化本质是一种“信息压缩”技术,通过牺牲部分精度来换取更高的传达效率。其核心价值在于:

  • 认知层面:符合人类对量级的感知习惯,降低信息处理成本;
  • 体验层面:优化界面布局,引导用户关注核心数据;
  • 技术层面:通过动态策略平衡不同场景的显示需求。

在实际应用中,需根据业务场景、用户群体和数据特性定制格式化规则,避免因过度简化导致信息失真。

2.10 最佳实践总结及扩展建议

最佳实践

  1. 优先使用DTO模式:通过专门的DTO类定义API响应格式,避免直接序列化实体对象
  2. 合理设计关联关系:根据业务需求选择合适的加载策略(EAGER/FETCH)
  3. 使用@JsonView进行精细控制:在复杂场景中使用Jackson的@JsonView实现选择性序列化
  4. 结合性能考虑:懒加载是提高性能的重要手段,但需要配合合理的初始化策略
  5. 格式化数字展示:优化信息传达的效率和用户体验
  6. 无限滚动加载:优化了用户体验
  7. 适配移动设备和桌面设备:网格布局自动调整

如下图11-7所示的是适配移动设备之后的效果。

扩展建议

  1. 个性化推荐

    • 基于用户兴趣和行为的内容推荐
    • 关注的用户发布的内容优先展示
  2. 搜索功能

    • 实现全文搜索
    • 热门搜索词和搜索历史
  3. 内容筛选

    • 添加更多筛选条件(最新、最热、附近等)
  4. 视频内容

    • 支持视频内容的展示和播放
    • 视频缩略图和播放控制
  5. 内容安全

    • 内容审核机制
    • 敏感内容过滤
  6. 性能优化

    • 图片懒加载
    • 内容预加载
    • 分页数据缓存

3.1 首页搜索及瀑布流功能概述

  • 首页搜索:在首页搜索框进行关键字搜索
  • 从首页跳转到笔记详情页
  • 从首页跳转到作者详情页
  • 从笔记详情页跳转到作者详情页
  • 首页布局:改为瀑布流布局
  • 从底部导航栏导航到其他页面

3.2 掌握前端搜索功能的核心要点

在网页中实现 <input> 搜索功能通常涉及以下几个核心步骤:用户输入、搜索逻辑处理、结果展示和交互反馈。以下从前端实现到后端交互的完整流程进行解析,并提供代码示例。

前端HTML设置

修改explore.html中搜索框的内容:

<!-- 搜索框-->
<div class="col-md-3">
    <div class="input-group">
        <input class="form-control" type="text" placeholder="搜索感兴趣的内容" aria-label="Search"
                id="searchInput">
        <button class="btn btn-outline-secondary" type="button" id="searchButton">
            搜索
        </button>
    </div>
</div>

修改点:

  • <input>增加了id属性
  • 增加了<button>

搜索触发方式

  • 实时获取:使用 input 事件用户输入,实时获取到搜索内容
  • 按钮触发:添加搜索按钮,点击后执行搜索

以下代码实时获取到搜索内容,并缓存在searchContent变量中:

// 缓存搜索的内容(确保在loadMoreNotes()执行前声明)
let searchContent = '';

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

// 获取搜索输入框的值
const searchInput = document.getElementById('searchInput');
searchInput.addEventListener('input', function() {
        searchContent = this.value;
});

以下代码当点击搜索按钮时,触发执行搜索:

// 搜索按钮执行搜索
document.getElementById('searchButton').addEventListener('click', function() {
    // 获取搜索输入框的值
    searchContent = document.getElementById('searchInput').value;

    // 执行搜索
    performSearch();
});

// 执行搜索
function performSearch() {
    // 重置笔记网格数据
    notesGrid.innerHTML = '';

    // 恢复初始状态值
    currentPage = 0;
    isLoading = false;
    hasMore = true;

    // 加载更多笔记
    loadMoreNotes();
};

分页导航点击事件处理

将与performSearch()代码逻辑一致的部分,重构为performSearch()。

// 为分页导航添加点击事件
categoryItems.forEach(item => {
    item.addEventListener('click', () => {
        categoryItems.forEach(item => {
            item.classList.remove('active');
        });
        item.classList.add('active');

        // 以下代码重构为performSearch()
        /*
        // 重置笔记网格数据
        notesGrid.innerHTML = '';

        // 恢复初始状态值
        currentPage = 0;
        isLoading = false;
        hasMore = true;
        loadMoreNotes();
        */
        performSearch();

    });
});

重构loadMoreNotes()

重构loadMoreNotes()函数:

// 加载更多笔记
function loadMoreNotes() {
        if (isLoading || !hasMore) {
            // 隐藏加载更多
            hideLoadMore();
            // 显示没有更多内容
            showNoMoreContent();
            return;
        }

        isLoading = true;
        // 显示加载更多
        showLoadMore();

        // 获取当前分类
        let category = document.querySelector('.category-item.active').textContent.trim();

        // 发送请求
        /*fetch(`/explore/note?page=${currentPage + 1}&category=${category}`)*/
        fetch(`/explore/note?page=${currentPage + 1}&category=${category}&query=${searchContent}`)
        
        // ...为节约篇幅,此处省略非核心内容
}

在发送AJAX请求时,传递query参数,值是searchContent。

3.3 重构ExploreController处理搜索请求

控制器层

修改getNotesByCategory()方法,增加了query参数。

/**
  * 返回首页笔记探索页面的笔记数据
  */
@GetMapping("/note")
public ResponseEntity<NoteResponseDto> getNotesByCategory(
                                                          @RequestParam(defaultValue = "1") int page,
                                                          @RequestParam(required = false) String category,
                                                          @RequestParam(required = false) String query) {
    // 把“推荐”当成空
    if (DEFAULT_CATEGORY.equals(category)) {
        category = null;
    }

    Page<Note> notes = null;

    // 区分是关键字搜索还是分类查询
    if (query == null || query.isEmpty()) {
        notes = noteService.getNotesByPage(page, PAGE_SIZE, category);
    } else {
        notes = noteService.getNotesByPageAndQuery(page, PAGE_SIZE, category, query);
    }


    NoteResponseDto notesResponseDto = new NoteResponseDto();
    notesResponseDto.setHasMore(notes.hasNext());

    // 处理序列化问题
    List<NoteExploreDto> noteExploreDtoList = new ArrayList<>();
    for (Note note : notes.getContent()) {
        noteExploreDtoList.add(NoteExploreDto.toExploreDto(note));
    }
    notesResponseDto.setNotes(noteExploreDtoList);

    return ResponseEntity.ok(notesResponseDto);
}

如果没有传入query参数值,则执行原有的NoteService.getNotesByPage()方法;否则,执行NoteService.getNotesByPageAndQuery()新方法。

服务层

修改NoteService,增加如下接口:

/**
 * 搜索分页查询笔记
 *
 * @param page
 * @param pageSize
 * @param category
 * @param query
 * @return
 */
Page<Note> getNotesByPageAndQuery(int page, int pageSize, String category, String query);

修改NoteServiceImpl,增加如下方法:

@Override
public Page<Note> getNotesByPageAndQuery(int page, int pageSize, String category, String query) {
    // 构造Pageable对象,按照创建时间倒序排序
    Pageable pageable = PageRequest.of(page - 1, pageSize, Sort.by("createAt").descending());

    if (category != null && !category.isEmpty() && query != null && !query.isEmpty()) {
        return noteRepository.findByCategoryAndTopicsContaining(category, query, pageable);
    } else if (query != null && !query.isEmpty()) {
        return noteRepository.findByTopicsContaining(query, pageable);
    } else {
        return noteRepository.findAll(pageable);
    }
}

如果没有传入category参数值,则执行原有的NoteRepository.findByTopicsContaining()方法;否则,执行NoteRepository.findByCategoryAndTopicsContaining()新方法。

仓库层

在 Spring Data JPA 中查询List<String>类型的属性需要使用特殊的方法。针对Note实体中的topics属性,新增如下接口:

/**
 * 根据分类和话题标签分页查询笔记
 *
 * @param category
 * @param query
 * @param pageable
 * @return
 */
Page<Note> findByCategoryAndTopicsContaining(String category, String query, Pageable pageable);

/**
 * 根据话题标签分页查询笔记
 *
 * @param query
 * @param pageable
 * @return
 */
Page<Note> findByTopicsContaining(String query, Pageable pageable);

运行调测

在首页“推荐”分类执行搜素“Java”关键字,效果如下图12-1所示。

在首页“职场”分类执行搜素“Java”关键字,效果下图12-2所示。

两个搜素结果不一致,说明有些包含“Java”主题的笔记,并不在“职场”分类中。

3.4 从首页跳转到笔记详情页

类似于用户详情页的笔记列表的做法,从首页跳转到笔记详情页,只需要在原有的笔记封面<img>上套一层<a>即可。

// 创建笔记卡片元素
function createNoteElement(note) {
    const noteElement = document.createElement("div");
    noteElement.className = "masonry-item";
    noteElement.innerHTML = `
        <div class="note-image-container">
            <!-- 点击跳转到笔记详情页 -->
            <a href="/note/${note.noteId}">
                <img class="note-image" src="${note.cover}" alt="${note.title}">
            </a>
        </div>
        <div class="note-content">
            <div class="note-title">${note.title}</div>
            <div class="note-author-stats">
                <div class="note-author">
                    <img class="author-avatar" src="${note.avatar ? note.avatar : '/images/rn_avatar.png'}" alt="${note.username}">
                    <span class="author-name">${note.username}</span>
                </div>
                <div class="note-stats">
                    <div class="stat-item">
                        <i class="fa fa-heart-o">${numberFormat(1024)}</i>
                    </div>
                </div>
            </div>
        </div>
    `;

    return noteElement;
}

点击笔记封面,就能跳转到笔记详情页了。

3.5 从首页跳转到作者详情页

前端修改

点击笔记的作者头像时,我们希望就能跳转到该作者的详情页。实现方式,只需要在作者信息的<div>上套一层<a>即可。

// 创建笔记卡片元素
function createNoteElement(note) {
    const noteElement = document.createElement("div");
    noteElement.className = "masonry-item";
    noteElement.innerHTML = `
        <div class="note-image-container">
            <!-- 点击跳转到笔记详情页 -->
            <a href="/note/${note.noteId}">
                <img class="note-image" src="${note.cover}" alt="${note.title}">
            </a>
        </div>
        <div class="note-content">
            <div class="note-title">${note.title}</div>
            <div class="note-author-stats">
                <!-- 点击跳转到用户详情页 -->
                <a href="/user/profile/${note.userId}">
                    <div class="note-author">
                        <img class="author-avatar" src="${note.avatar ? note.avatar : '/images/rn_avatar.png'}" alt="${note.username}">
                        <span class="author-name">${note.username}</span>
                    </div>
                </a>
                
                <div class="note-stats">
                    <div class="stat-item">
                        <i class="fa fa-heart-o">${numberFormat(1024)}</i>
                    </div>
                </div>
            </div>
        </div>
    `;

    return noteElement;
}

跳转到/user/profile页面需要传递用户ID,显然当前的note对象DTO里面并没有这个属性,因此需要做进一步的扩展。

扩展NoteExploreDto

/**
 * 作者用户ID
 */
private Long userId;

public static NoteExploreDto toExploreDto(Note note) {
    NoteExploreDto noteExploreDto = new NoteExploreDto();
    noteExploreDto.setNoteId(note.getNoteId());
    noteExploreDto.setTitle(note.getTitle());
    noteExploreDto.setCover(note.getImages().get(0));
    noteExploreDto.setUsername(note.getAuthor().getUsername());
    noteExploreDto.setAvatar(note.getAuthor().getAvatar());
    noteExploreDto.setUserId(note.getAuthor().getUserId());

    return noteExploreDto;
}

运行调测

运行应用,效果如下图12-3所示,跳转逻辑没有问题,只是用户名下面有条下划线不是太美观。

去除<a>标签的下划线,只需要加个CSS样式:

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

去除用户名下面下划线的效果如下图12-4所示。

3.6 实现从笔记详情页跳转到作者详情页

类似上一节的做法,也可以在笔记详情页作者信息区域,设置点击跳转到作者的详情页。

加个CSS样式

修改note-detail.html。

去除<a>标签的下划线,只需要加个CSS样式:

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

前端修改

点击笔记的作者头像时,我们希望就能跳转到该作者的详情页。实现方式,只需要在作者头像上的<img>上套一层<a>即可。

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

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

运行调测

运行应用,作者头像效果如下图12-6所示。

点击作者头像就可以跳转到作者的详情页了,效果如下图12-7所示。

3.7 设计瀑布流布局实现方案

瀑布流布局是小红书等内容平台常用的设计方式,它可以根据内容高度自动调整位置,形成错落有致的视觉效果,提升用户浏览体验。

瀑布流布局的优势与特点

  1. 视觉优势

    • 错落有致的布局,提升视觉吸引力
    • 充分利用空间,减少空白区域
    • 适应不同高度的内容,保持整体和谐
  2. 用户体验

    • 浏览体验更自然,减少频繁滚动
    • 内容呈现更有层次感,突出重点
    • 增加内容曝光机会,提高参与度
  3. 响应式设计

    • 移动端使用1列或者2列布局
    • 平板使用3列布局
    • 桌面端使用4列布局

CSS样式

修改explore.html增加如下样式:

/* 瀑布流布局 */
.masonry {
    column-count: 4;
    column-gap: 1em;
    padding: 10;
}
.masonry-item {
    display: inline-block;
    margin: 0 0 1.5em;
    width: 100%;
}

.masonry-note-image {
    border-radius: 12px;
    width: 100%;
    height: auto;
}

@media only screen and (max-width: 320px) {
    .masonry {
        column-count: 1;
    }
}

@media only screen and (min-width: 321px) and (max-width: 768px){
    .masonry {
        column-count: 2;
    }
}
@media only screen and (min-width: 769px) and (max-width: 1200px){
    .masonry {
        column-count: 3;
    }
}
@media only screen and (min-width: 1201px) {
    .masonry {
        column-count: 4;
    }
}

HTML应用样式

<!-- 笔记卡片网格 -->
<!--<div class="notes-grid" id="notesGrid">-->
<div class="masonry" id="notesGrid">
    <!-- 笔记卡片是通过JavaScript动态生成 -->
</div>

创建笔记元素应用样式

// 创建笔记卡片元素
function createNoteElement(note) {
    const noteElement = document.createElement("div");
    noteElement.className = "masonry-item";
    noteElement.innerHTML = `
        <!--<div class="note-image-container">-->
            <!-- 点击跳转到笔记详情页 -->
            <a href="/note/${note.noteId}">
                <!--<img class="note-image" src="${note.cover}" alt="${note.title}">-->
                <img class="masonry-note-image" src="${note.cover}" alt="${note.title}">
            </a>
        <!--</div>-->

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

    return noteElement;
}

img上增加masonry-note-image类型样式,同时去除note-image-container类型的div

瀑布流布局演示

下面是将小红书首页笔记卡片改为瀑布流布局的效果演示方案。

大尺寸设备效果如下图12-8所示。

中等尺寸设备效果如下图12-9所示。

小尺寸设备效果如下图12-10所示。

通过以上实现,你可以将小红书首页的笔记卡片从传统网格布局改为瀑布流布局,提升用户体验和内容展示效果。

3.8 从底部导航栏导航到其他页面

修改explore.html,实现从底部导航栏导航到其他页面的功能。

底部导航栏设置点击事件

<!-- 底部导航栏 -->
<div class="container bottom-nav">
    <div class="nav-item active" onclick="navigateTo('home')">
        <i class="fa fa-home nav-icon"></i>
        <span class="nav-text">首页</span>
    </div>
    <div class="nav-item" onclick="navigateTo('discover')">
        <i class="fa fa-compass nav-icon"></i>
        <span class="nav-text">发现</span>
    </div>
    <div class="nav-item" onclick="navigateTo('publish')">
        <i class="fa fa-plus nav-icon"></i>
        <span class="nav-text">发布</span>
    </div>
    <div class="nav-item" onclick="navigateTo('message')">
        <i class="fa fa-comment-o nav-icon"></i>
        <span class="nav-text">消息</span>
    </div>
    <div class="nav-item" onclick="navigateTo('profile')">
        <i class="fa fa-user-o nav-icon"></i>
        <span class="nav-text">我的</span>
    </div>
</div>

添加JS脚本处理导航

// 导航函数
function navigateTo(page) {
    console.log('navigateTo: ' + page);

    if (page === 'home') {
        window.location.href = '/';
    } else if (page === 'publish') {
        window.location.href = '/note/publish';
    } else if (page === 'profile') {
        window.location.href = '/user/profile';
    } else {
        // 待实现的功能页面
        alert('暂未开放,敬请期待!');

        return;
    }
}

当点击暂未开放的功能时,比如“消息”,提示框效果如下图12-11所示。

3.9 搜索功能的扩展与进阶及笔记卡片展示的优化建议

搜索功能的扩展与进阶

1. 全文搜索引擎

  • Elasticsearch:适用于大规模数据的高性能搜索
    // Elasticsearch 查询示例
    @Autowired
    private RestHighLevelClient client;
    
    public List<Note> elasticSearch(String query) throws IOException {
        SearchRequest searchRequest = new SearchRequest("notes");
        SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
        
        QueryBuilder matchQuery = QueryBuilders.multiMatchQuery(query, "title", "content");
        sourceBuilder.query(matchQuery);
        searchRequest.source(sourceBuilder);
        
        SearchResponse response = client.search(searchRequest, RequestOptions.DEFAULT);
        // 处理结果...
    }
    

2. 模糊搜索与纠错

  • 使用 Levenshtein 距离实现拼写检查
  • 配置 Elasticsearch 的 fuzzy 查询

3. 搜索分析与优化

  • 记录搜索日志,分析热门关键词和失败搜索
  • 使用 A/B 测试优化搜索结果排序算法

总结

实现一个高效的搜索功能需要综合考虑:

  1. 前端交互:选择合适的触发方式,优化用户输入体验
  2. 性能优化:应用防抖、缓存等技术减少不必要的请求
  3. 后端处理:从简单的数据库查询到复杂的全文搜索
  4. 用户体验:加载状态、空结果处理、搜索建议等细节

通过合理设计和技术选型,可以构建出既满足功能需求又具有良好用户体验的搜索系统。

首页笔记卡片展示的优化建议

  1. 图片懒加载

    // 使用Intersection Observer实现图片懒加载
    const observer = new IntersectionObserver((entries) => {
        entries.forEach(entry => {
            if (entry.isIntersecting) {
                const img = entry.target;
                img.src = img.dataset.src;
                observer.unobserve(img);
            }
        });
    });
    
    document.querySelectorAll('img[data-src]').forEach(img => {
        observer.observe(img);
    });
    
  2. 性能优化

    • 限制同时加载的图片数量
    • 使用虚拟滚动技术处理大量数据
    • 图片使用WebP格式,减小文件大小
  3. 动态加载内容

    • 实现无限滚动,减少初始加载量
    • 根据用户兴趣预加载内容
    • 实现骨架屏占位,提升感知性能
本站提供的所有下载资源均来自互联网,仅提供学习交流使用,版权归原作者所有。如需商业使用,请联系原作者获得授权。 如您发现有涉嫌侵权的内容,请联系我们 邮箱:alixiixcom@163.com