Skip to content

Thymeleaf模板架构与Layui前端集成:构建服务端渲染的管理后台

作者: 必码 | bima.cc


前言

在企业级管理后台的开发实践中,前端技术选型始终是一个充满争议的话题。过去十年间,前端技术经历了从服务端渲染(Server-Side Rendering)到前后端分离(SPA 单页应用),再到服务端渲染回归(SSR/SSG)的完整轮回。Vue、React、Angular 等现代前端框架的崛起,让"前后端分离"几乎成为了新项目的默认选择。然而,当我们冷静下来审视企业级管理后台的真实需求时,会发现一个被忽视的事实:并非所有项目都需要前后端分离

管理后台的核心特征是什么?是大量的数据表格、表单操作、权限控制、数据导出等功能性页面。这些页面的交互模式高度标准化,用户群体相对固定(通常是企业内部员工),对首屏加载速度和 SEO 没有苛刻要求。在这种场景下,前后端分离带来的优势(如组件化开发、状态管理、路由控制)并不能充分发挥其价值,反而引入了一系列新的复杂度:前端构建工具链的配置、跨域问题的处理、Token 在前后端之间的传递、接口联调的效率损耗、部署流程的复杂化——这些"额外成本"对于一个中小型管理后台项目而言,往往是得不偿失的。

与此同时,服务端渲染技术在 Java 生态中也经历了显著的进化。从早期的 JSP(JavaServer Pages)到 FreeMarker、Velocity,再到如今的 Thymeleaf,模板引擎在表达能力、开发体验和与 Spring 框架的集成深度上都在不断提升。特别是 Thymeleaf,凭借其"自然模板"(Natural Templating)的设计理念——即模板文件在浏览器中可以直接打开预览,无需服务端渲染——彻底解决了传统模板引擎"所见非所得"的痛点。

在前端 UI 框架方面,Layui 作为一款轻量级、模块化的国产前端框架,以其简洁的 API 设计、丰富的组件库和出色的中文文档,在国内企业级管理后台开发中拥有广泛的用户基础。Layui v2.13.5 版本在表格组件、表单验证、弹层交互等方面进行了大量优化,完全能够满足管理后台的 UI 需求。

本文基于 smart-scaffold-springboot 项目的真实源码,深入剖析 Thymeleaf 模板架构设计与 Layui 前端框架集成的完整技术方案。我们将从技术选型的决策分析开始,逐步展开多页面模板架构设计、FrontController 页面控制器实现、前后端 Token 传递机制、静态资源管理策略,以及生产环境的前端优化方案。所有代码示例均经过教学化简化处理,旨在帮助读者理解核心设计思想,而非简单地复制粘贴。

无论你是一名正在评估管理后台技术方案的技术负责人,还是一名希望深入了解 Thymeleaf + Layui 集成细节的后端开发者,抑或是一名对服务端渲染架构感兴趣的技术爱好者,本文都将为你提供一份经过生产验证的、可直接落地参考的技术指南。


一、服务端渲染 vs 前后端分离:技术选型的深度分析

1.1 管理后台的技术特征与需求画像

在深入讨论技术选型之前,我们需要先明确"管理后台"这一应用类型的核心特征。只有充分理解了应用场景,才能做出合理的技术决策。

管理后台的典型特征:

(1)功能导向型页面。 管理后台的页面以功能操作为核心,包括数据列表查看、数据新增/编辑/删除、数据导入导出、审批流程操作、系统配置管理等。这些页面的布局模式高度统一——通常是顶部导航栏 + 左侧菜单栏 + 右侧内容区域的经典三栏布局。页面之间的跳转逻辑清晰、可预测,很少出现复杂的嵌套路由或动态路由需求。

(2)用户群体固定。 管理后台的用户通常是企业内部的运营人员、管理人员和系统管理员。这些用户对系统的使用频率较高,对界面美观度的要求相对较低,更关注功能的完整性和操作的效率。这意味着我们不需要投入大量精力在动画效果、炫酷的页面切换等方面。

(3)权限控制严格。 管理后台几乎每一个页面和操作都需要进行权限校验。菜单级别的权限控制(用户只能看到自己有权限的菜单)、按钮级别的权限控制(用户只能执行自己有权限的操作)、数据级别的权限控制(用户只能看到自己有权限的数据)是管理后台的标配功能。在服务端渲染架构下,权限控制可以在模板渲染阶段就完成,无需在客户端进行二次判断。

(4)数据展示密集。 管理后台大量使用数据表格来展示结构化数据。一个典型的管理后台可能包含数十个甚至上百个数据列表页面。这些表格通常需要支持排序、筛选、分页、行内编辑等功能。Layui 的表格组件针对这种场景进行了深度优化,开箱即用。

(5)表单操作频繁。 数据的新增和编辑是管理后台最核心的操作之一。表单通常包含各种类型的输入控件——文本框、下拉选择、日期选择器、文件上传、富文本编辑器等。Layui 的表单组件提供了统一的样式和验证机制,能够显著提升开发效率。

(6)SEO 无需求。 管理后台通常部署在企业内网或需要登录认证才能访问,搜索引擎爬虫无法抓取其内容。因此,SEO(搜索引擎优化)完全不是管理后台需要考虑的因素,这也消除了前后端分离架构中 SSR(服务端渲染)方案的必要性。

1.2 服务端渲染的核心优势

基于管理后台的上述特征,服务端渲染架构展现出以下几个方面的核心优势:

(1)开发效率高。 在服务端渲染架构下,后端开发者可以直接在 Thymeleaf 模板中完成页面开发,无需学习 Vue/React 等前端框架,无需配置 Webpack/Vite 等构建工具,无需处理跨域问题。一个全栈开发者可以独立完成从数据库到页面的全部开发工作,大大降低了沟通成本和协作复杂度。

(2)首屏加载快。 服务端渲染的页面在服务器端生成完整的 HTML 内容,浏览器接收到响应后可以直接渲染页面,无需等待 JavaScript 加载和执行。对于管理后台这种以内容展示为主的场景,首屏加载速度的提升对用户体验有直接的积极影响。

(3)安全性好。 在服务端渲染架构下,所有的业务逻辑和权限校验都在服务器端完成,客户端只接收渲染后的 HTML。这意味着敏感的业务逻辑和数据不会被暴露在客户端代码中,减少了被逆向分析和攻击的风险。

(4)部署简单。 服务端渲染的项目通常打包为一个可执行的 JAR 文件,包含了所有的模板文件、静态资源和后端代码。部署时只需要一个 Java 运行环境和数据库连接即可,无需单独部署前端静态文件服务器或配置 Nginx 反向代理。

(5)调试方便。 服务端渲染的页面在浏览器中查看源代码时,可以看到完整的 HTML 结构,便于排查页面渲染问题。而前后端分离的 SPA 应用在浏览器中查看源代码时,通常只能看到一个空的 <div id="app"></div> 标签,调试起来相对困难。

1.3 前后端分离的适用场景与代价

当然,前后端分离并非没有优势。在以下场景中,前后端分离仍然是更合适的选择:

  • 面向公众的 Web 应用:需要 SEO 优化、社交分享预览等功能
  • 高交互性的应用:如在线文档编辑器、数据可视化大屏、实时协作工具等
  • 多端复用的场景:同一套 API 需要同时服务 Web 端、移动端、小程序等多个客户端
  • 大型团队协作:前端团队和后端团队规模较大,需要通过 API 契约进行解耦

然而,前后端分离也带来了一系列不可忽视的代价:

(1)技术栈复杂度增加。 前后端分离意味着需要维护两套独立的代码库、两套独立的构建流程、两套独立的部署流程。前端需要引入 Node.js 生态的工具链(npm/yarn/pnpm、Webpack/Vite、Babel/SWC、ESLint/Prettier 等),后端需要提供完善的 API 文档(Swagger/OpenAPI)。

(2)跨域问题。 在开发环境中,前端开发服务器(如 Vite Dev Server)和后端 API 服务器通常运行在不同的端口上,需要通过代理配置来解决跨域问题。在生产环境中,需要通过 Nginx 反向代理或 CORS 配置来解决跨域问题。

(3)Token 管理复杂。 前后端分离架构下,Token 需要在前端进行管理(通常存储在 localStorage 或 Cookie 中),并在每次 API 请求时自动附加到请求头中。这需要前端实现统一的 HTTP 请求拦截器、Token 刷新机制、登录状态管理等逻辑。

(4)首屏加载性能。 SPA 应用的首屏加载需要经历以下步骤:加载 HTML → 加载 JavaScript Bundle → 执行 JavaScript → 发起 API 请求 → 渲染页面。相比服务端渲染直接返回完整 HTML 的方式,首屏加载时间通常更长。

1.4 smart-scaffold 项目的选型决策

在 smart-scaffold 项目中,我们最终选择了 Thymeleaf + Layui 的服务端渲染方案,主要基于以下决策因素:

决策维度服务端渲染前后端分离最终选择
团队技术栈Java 全栈即可需前端工程师服务端渲染
开发效率高,一人可全栈中,需前后端协作服务端渲染
部署复杂度单 JAR 部署前后端分别部署服务端渲染
功能需求管理后台,标准 CRUD复杂交互,多端复用服务端渲染
维护成本低,技术栈统一高,两套代码库服务端渲染
SEO 需求均可
用户体验够用更流畅服务端渲染

这个决策并不意味着前后端分离是"错误"的选择。恰恰相反,技术选型没有绝对的对错之分,只有在特定场景下的"合适"与"不合适"。对于 smart-scaffold 这种面向企业内部的管理后台项目,服务端渲染在开发效率、部署便捷性和维护成本方面的优势,远大于其在交互体验方面的劣势。


二、Thymeleaf 模板引擎深度解析

2.1 Thymeleaf 的核心设计理念

Thymeleaf 的名字来源于希腊语中的"thyme"(百里香),寓意其设计目标是让模板引擎像百里香一样"自然"。Thymeleaf 最核心的设计理念是 "自然模板"(Natural Templating)——模板文件不仅仅是服务端渲染的数据载体,它本身就是一个合法的、可以直接在浏览器中打开的 HTML 文件。

为了理解"自然模板"的价值,让我们对比一下传统 JSP 和 Thymeleaf 在处理一个简单的数据展示场景时的差异:

教学示例——JSP 的数据绑定方式:

jsp
<%-- 教学示例:JSP 模板片段 --%>
<table>
    <tr>
        <td><%= user.getName() %></td>
        <td><%= user.getEmail() %></td>
    </tr>
</table>

教学示例——Thymeleaf 的数据绑定方式:

html
<!-- 教学示例:Thymeleaf 模板片段 -->
<table>
    <tr>
        <td th:text="${user.name}">张三</td>
        <td th:text="${user.email}">zhangsan@example.com</td>
    </tr>
</table>

在 JSP 中,<%= user.getName() %> 是 Java 代码片段,浏览器无法解析它,直接打开模板文件时会看到原始的 Java 代码。而在 Thymeleaf 中,th:text="${user.name}" 是一个自定义的 HTML 属性,浏览器会忽略它不认识的属性,直接显示标签内的默认内容"张三"。这意味着 UI 设计师和前端开发者可以直接在浏览器中打开 Thymeleaf 模板文件来预览页面效果,而无需启动服务器。

这种"自然模板"的设计理念在实际开发中带来了几个显著的好处:

第一,提升前后端协作效率。 UI 设计师完成页面设计后,可以直接交付 HTML 文件作为 Thymeleaf 模板的基础。后端开发者只需要在 HTML 标签上添加 th:* 属性来实现数据绑定,无需重写页面结构。这避免了传统开发中"设计稿 → 切图 → 前端页面 → 后端模板"的繁琐流程。

第二,降低调试成本。 当页面出现渲染问题时,开发者可以直接在浏览器中打开模板文件,检查 HTML 结构是否正确。如果 HTML 结构正确但数据不对,问题就出在后端的数据准备阶段;如果 HTML 结构本身就有问题,那与 Thymeleaf 无关,是 HTML 的问题。

第三,支持原型设计。 Thymeleaf 模板中的默认值可以作为页面的原型数据,让开发者在没有后端数据的情况下也能看到页面的完整效果。这对于需求评审和 UI 走查非常有帮助。

2.2 Thymeleaf vs JSP vs FreeMarker:全面对比

在 Java 服务端渲染的技术选型中,Thymeleaf、JSP 和 FreeMarker 是三个最常被比较的选项。让我们从多个维度进行深入对比:

(1)与 Spring Boot 的集成深度。

Thymeleaf 是 Spring 官方推荐的服务端模板引擎,Spring Boot 为其提供了自动配置(spring-boot-starter-thymeleaf),几乎不需要任何手动配置即可使用。JSP 在 Spring Boot 中的支持相对有限——由于 JSP 需要依赖 Servlet 容器(如 Tomcat)的特定功能,Spring Boot 打包为可执行 JAR 时对 JSP 的支持存在限制。FreeMarker 虽然也有 Spring Boot 的自动配置(spring-boot-starter-freemarker),但其模板语法与 HTML 的耦合度不如 Thymeleaf 紧密。

(2)模板语法的表达能力。

Thymeleaf 使用基于 HTML 属性的语法(th:textth:ifth:each 等),与标准 HTML 完全兼容。FreeMarker 使用自定义的标签语法(<#if><#list>${variable} 等),虽然功能强大,但模板文件不是合法的 HTML 文件。JSP 使用 Java 代码片段和 JSTL 标签库,语法上与 HTML 的耦合度最低。

(3)性能表现。

在性能方面,FreeMarker 通常是最快的——它的模板编译和缓存机制非常高效。Thymeleaf 的性能略逊于 FreeMarker,但在 Spring Boot 的默认配置下(开启模板缓存),性能完全能够满足管理后台的需求。JSP 的性能取决于 Servlet 容器的实现,通常与 Thymeleaf 相当。

(4)IDE 支持。

Thymeleaf 在 IntelliJ IDEA 中有出色的支持——包括语法高亮、自动补全、属性提示、模板验证等。FreeMarker 也有较好的 IDE 支持,但不如 Thymeleaf 与 Spring Boot 的集成那么紧密。JSP 作为传统的 Java Web 技术,IDE 支持最为成熟,但已经逐渐被淘汰。

(5)社区活跃度与发展趋势。

Thymeleaf 目前处于活跃维护状态,版本迭代稳定,与 Spring Boot 的兼容性持续更新。FreeMarker 同样处于活跃维护状态,但新特性较少。JSP 已经基本停止发展,Java EE/Jakarta EE 虽然仍在维护 JSP 规范,但业界已经普遍不再推荐在新项目中使用 JSP。

综合对比表:

维度ThymeleafJSPFreeMarker
自然模板支持不支持不支持
Spring Boot 集成官方推荐,深度集成有限支持良好支持
语法风格HTML 属性Java 代码片段自定义标签
学习曲线中等低(Java 开发者)中等
性能良好良好优秀
IDE 支持优秀成熟良好
发展趋势活跃停滞稳定
适用场景Spring Boot 项目传统 Java Web代码生成、邮件模板

2.3 Thymeleaf 与 Spring Boot 的深度集成

Spring Boot 对 Thymeleaf 的自动配置极大地简化了使用方式。在 smart-scaffold 项目中,我们只需要引入一个 Starter 依赖,即可获得完整的 Thymeleaf 功能。

教学示例——Thymeleaf 的 Maven 依赖配置:

xml
<!-- 教学示例:pom.xml 中的 Thymeleaf 依赖 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>

Spring Boot 的自动配置会为我们完成以下工作:

  • 配置 ThymeleafViewResolver 作为视图解析器
  • 设置模板文件的位置为 classpath:/templates/
  • 设置模板文件的后缀为 .html
  • 配置模板缓存(生产环境默认开启,开发环境默认关闭)
  • 配置静态资源的位置为 classpath:/static/

教学示例——Thymeleaf 核心配置项:

yaml
# 教学示例:application.yml 中的 Thymeleaf 配置
spring:
  thymeleaf:
    # 模板文件位置
    prefix: classpath:/templates/
    # 模板文件后缀
    suffix: .html
    # 模板缓存(生产环境建议开启)
    cache: true
    # 模板编码
    encoding: UTF-8
    # HTML 模式(LEGACYHTML5 兼容非严格 HTML)
    mode: HTML
    # 内容类型
    servlet:
      content-type: text/html

在 Spring Boot 的自动配置下,Controller 方法返回的字符串会被 ThymeleafViewResolver 解析为模板路径。例如,Controller 方法返回 "login/index",Thymeleaf 会去 classpath:/templates/login/index.html 加载模板文件。

教学示例——Controller 与 Thymeleaf 的基本集成:

java
// 教学示例:基本的页面控制器
@Controller
@RequestMapping("/login")
public class LoginController {

    @GetMapping("/index")
    public String index(Model model) {
        model.addAttribute("pageTitle", "用户登录");
        model.addAttribute("casLoginUrl", "https://cas.example.com/login");
        return "login/index";
    }
}

教学示例——Thymeleaf 模板中使用 Model 数据:

html
<!-- 教学示例:login/index.html 模板片段 -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title th:text="${pageTitle}">用户登录</title>
</head>
<body>
    <h1 th:text="${pageTitle}">用户登录</h1>
    <a th:href="${casLoginUrl}" class="login-btn">CAS 登录</a>
</body>
</html>

2.4 Thymeleaf 常用语法精讲

在深入模板架构设计之前,我们先系统性地梳理 Thymeleaf 在 smart-scaffold 项目中最常用的语法特性。这些语法是后续章节中模板代码的基础。

(1)变量表达式 ${...}

变量表达式用于获取 Model 中的数据或调用对象的方法。这是 Thymeleaf 中使用频率最高的表达式。

html
<!-- 教学示例:变量表达式的基本用法 -->
<span th:text="${user.userName}">默认用户名</span>
<span th:text="${user.roleName}">默认角色</span>
<span th:text="${#dates.format(user.createTime, 'yyyy-MM-dd HH:mm:ss')}">2024-01-01 00:00:00</span>

Thymeleaf 提供了一系列内置的工具对象(Utility Objects),可以方便地进行格式化、字符串处理等操作:

  • #dates:日期格式化
  • #strings:字符串操作(截取、拼接、大小写转换等)
  • #numbers:数字格式化
  • #bools:布尔值判断
  • #arrays / #lists / #sets / #maps:集合操作

(2)选择表达式 *{...}

选择表达式需要配合 th:object 使用,用于简化对对象属性的访问。

html
<!-- 教学示例:选择表达式的用法 -->
<form th:object="${userForm}">
    <input type="text" th:field="*{userName}" />
    <input type="email" th:field="*{email}" />
    <select th:field="*{roleId}">
        <option th:each="role : ${roles}" th:value="${role.id}" th:text="${role.name}">角色</option>
    </select>
</form>

th:field 是一个非常有用的属性,它会自动根据字段类型生成合适的 idnamevalue 属性,并且支持表单回显(当表单校验失败时,自动填充用户之前输入的值)。

(3)循环表达式 th:each

th:each 用于遍历集合数据,生成重复的 HTML 结构。在管理后台中,这个语法常用于渲染菜单列表、数据表格的行、下拉选项等。

html
<!-- 教学示例:th:each 遍历菜单列表 -->
<ul class="layui-nav layui-nav-tree">
    <li th:each="menu : ${menus}" class="layui-nav-item">
        <a th:href="${menu.url}" th:text="${menu.name}">菜单名称</a>
    </li>
</ul>

(4)条件表达式 th:if / th:unless

条件表达式用于根据条件决定是否渲染某个 HTML 元素。在管理后台中,这个语法常用于权限控制——只有拥有特定权限的用户才能看到某些菜单或按钮。

html
<!-- 教学示例:基于权限的条件渲染 -->
<button th:if="${hasPermission('user:add')}" class="layui-btn">新增用户</button>
<button th:if="${hasPermission('user:delete')}" class="layui-btn layui-btn-danger">删除</button>
<div th:unless="${hasPermission('system:config')}" class="no-permission">
    您没有系统配置的权限
</div>

(5)链接表达式 @{...}

链接表达式用于生成 URL,支持上下文路径自动添加和参数编码。这是 Thymeleaf 相比 JSP 的一个重要优势——在 JSP 中,处理上下文路径通常需要手动拼接 ${pageContext.request.contextPath}

html
<!-- 教学示例:链接表达式的用法 -->
<a th:href="@{/user/edit/{id}(id=${user.id}, token=${accessToken})}">编辑用户</a>
<script th:src="@{/js/global.js}"></script>
<link th:href="@{/css/style.css}" rel="stylesheet" />

(6)片段表达式 th:fragment / th:replace / th:insert

片段表达式是 Thymeleaf 实现模板布局的核心机制。通过定义可复用的模板片段,可以在多个页面中共享公共的页面结构(如头部导航、侧边栏、底部版权信息等)。

html
<!-- 教学示例:定义公共模板片段 -->
<!-- 文件:templates/common/header.html -->
<header th:fragment="header(activeMenu)">
    <nav class="layui-nav">
        <a th:classappend="${activeMenu == 'home'} ? 'layui-this'" th:href="@{/}">首页</a>
        <a th:classappend="${activeMenu == 'ai'} ? 'layui-this'" th:href="@{/ai/index}">AI 模块</a>
        <a th:classappend="${activeMenu == 'middleware'} ? 'layui-this'" th:href="@{/middleware/index}">中间件</a>
    </nav>
</header>
html
<!-- 教学示例:在页面中使用公共模板片段 -->
<!-- 文件:templates/ai/chat/index.html -->
<div th:replace="~{common/header :: header('ai')}"></div>
<div class="content">
    <!-- 页面内容 -->
</div>

三、多页面模板架构设计

3.1 模板目录结构总览

smart-scaffold 项目的模板目录结构遵循"按功能模块组织"的原则,每个业务模块拥有独立的模板目录。这种组织方式的好处是:当项目规模增长时,模板文件不会全部堆积在同一个目录下,便于定位和维护。

教学示例——smart-scaffold 项目的模板目录结构:

templates/
├── index.html                    # 首页
├── common/                       # 公共模板片段
│   ├── layout.html               # 页面布局框架
│   ├── header.html               # 头部导航
│   ├── sidebar.html              # 侧边栏菜单
│   └── footer.html               # 底部版权信息
├── login/                        # 登录模块
│   ├── index.html                # 登录页面
│   ├── callback.html             # OAuth 回调页面
│   └── userinfo.html             # 用户信息页面
├── ai/                           # AI 模块
│   ├── index.html                # AI 模块首页
│   ├── chat/
│   │   └── index.html            # AI 对话页面
│   ├── chat-stream/
│   │   └── index.html            # AI 流式对话页面
│   ├── writing/
│   │   └── index.html            # AI 写作页面
│   ├── prompt/
│   │   └── index.html            # 提示词管理页面
│   └── index.html                # AI 模块总览
├── middleware/                   # 中间件模块
│   ├── index.html                # 中间件总览
│   ├── redis/
│   │   └── index.html            # Redis 管理页面
│   ├── mongo/
│   │   └── index.html            # MongoDB 管理页面
│   ├── elasticsearch/
│   │   └── index.html            # Elasticsearch 管理页面
│   ├── kafka/
│   │   └── index.html            # Kafka 管理页面
│   ├── rabbitmq/
│   │   └── index.html            # RabbitMQ 管理页面
│   ├── rocketmq/
│   │   └── index.html            # RocketMQ 管理页面
│   └── mybatis/
│       └── index.html            # MyBatis 管理页面
└── error/                        # 错误页面
    ├── 403.html                  # 无权限
    ├── 404.html                  # 页面不存在
    └── 500.html                  # 服务器错误

这种目录结构的设计遵循了以下几个原则:

(1)模块化隔离。 每个功能模块(login、ai、middleware)拥有独立的顶级目录,模块内部的子功能(如 ai 下的 chat、writing、prompt)拥有二级目录。这种层次化的组织方式使得模板文件的数量增长不会导致目录混乱。

(2)公共资源集中管理。 所有页面共享的模板片段(layout、header、sidebar、footer)统一放在 common/ 目录下。当需要修改全局布局时,只需要修改 common/layout.html 一个文件即可,所有引用该布局的页面都会自动生效。

(3)错误页面统一处理。 错误页面(403、404、500)统一放在 error/ 目录下,Spring Boot 会根据 HTTP 状态码自动匹配对应的错误页面。

(4)命名规范统一。 每个模块的入口页面统一命名为 index.html,子页面也尽量使用语义化的命名。这种命名规范使得开发者可以通过 URL 路径直接推断出对应的模板文件路径。

3.2 公共布局模板设计

在多页面应用中,页面布局的统一性是用户体验的基础。smart-scaffold 项目通过 Thymeleaf 的模板片段机制实现了一套灵活的公共布局系统。

教学示例——公共布局模板:

html
<!-- 教学示例:templates/common/layout.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout-dialect">
<head th:fragment="head(title, css)">
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title th:text="${title} + ' - Smart Scaffold'">Smart Scaffold</title>
    <!-- Layui CSS -->
    <link rel="stylesheet" th:href="@{/layui/css/layui.css}" />
    <!-- 自定义样式 -->
    <link rel="stylesheet" th:href="@{/css/global.css}" />
    <!-- 页面级样式 -->
    <th:block th:replace="${css}" />
</head>
<body>
    <!-- 头部导航 -->
    <div th:replace="~{common/header :: header}"></div>

    <!-- 主体内容区域 -->
    <div class="layui-body">
        <th:block layout:fragment="content" />
    </div>

    <!-- 底部版权 -->
    <div th:replace="~{common/footer :: footer}"></div>

    <!-- Layui JS -->
    <script th:src="@{/layui/layui.js}"></script>
    <!-- 全局脚本 -->
    <script th:src="@{/js/global.js}"></script>
    <!-- 页面级脚本 -->
    <th:block layout:fragment="scripts" />
</body>
</html>

这个布局模板使用了 Thymeleaf Layout Dialect(thymeleaf-layout-dialect),它提供了更强大的布局继承能力。通过 layout:fragment 定义可替换的内容区域,子页面只需要填充这些区域即可。

教学示例——子页面继承公共布局:

html
<!-- 教学示例:templates/redis/index.html 继承公共布局 -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout-dialect"
      layout:decorate="~{common/layout}">
<head>
    <title>Redis 管理</title>
</head>
<body>
    <div layout:fragment="content">
        <div class="page-header">
            <h2>Redis 连接管理</h2>
        </div>
        <div class="page-content">
            <!-- Redis 管理的具体内容 -->
            <table id="redis-table" lay-filter="redis-table"></table>
        </div>
    </div>
    <div layout:fragment="scripts">
        <script th:src="@{/js/middleware/redis.js}"></script>
    </div>
</body>
</html>

3.3 首页模板设计

首页是用户登录后看到的第一个页面,通常承担着"导航枢纽"的角色——它需要清晰地展示系统的功能模块、快捷入口、系统状态概览等信息。

教学示例——首页模板的核心结构:

html
<!-- 教学示例:templates/index.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8" />
    <title th:text="'欢迎, ' + ${userName}">欢迎</title>
    <link rel="stylesheet" th:href="@{/layui/css/layui.css}" />
    <link rel="stylesheet" th:href="@{/css/index.css}" />
</head>
<body>
    <div class="layui-layout layui-layout-admin">
        <!-- 头部区域 -->
        <div class="layui-header">
            <div class="layui-logo">Smart Scaffold</div>
            <ul class="layui-nav layui-layout-right">
                <li class="layui-nav-item">
                    <a href="javascript:;">
                        <span th:text="${userName}">用户</span>
                    </a>
                </li>
            </ul>
        </div>

        <!-- 侧边栏 -->
        <div class="layui-side layui-bg-black">
            <div class="layui-side-scroll">
                <ul class="layui-nav layui-nav-tree">
                    <li class="layui-nav-item layui-nav-itemed">
                        <a href="javascript:;">AI 模块</a>
                        <dl class="layui-nav-child">
                            <dd><a th:href="@{/ai/chat/index(userId=${userId}, accessToken=${accessToken})}">AI 对话</a></dd>
                            <dd><a th:href="@{/ai/chat-stream/index(userId=${userId}, accessToken=${accessToken})}">流式对话</a></dd>
                            <dd><a th:href="@{/ai/writing/index(userId=${userId}, accessToken=${accessToken})}">AI 写作</a></dd>
                            <dd><a th:href="@{/ai/prompt/index(userId=${userId}, accessToken=${accessToken})}">提示词管理</a></dd>
                        </dl>
                    </li>
                    <li class="layui-nav-item layui-nav-itemed">
                        <a href="javascript:;">中间件</a>
                        <dl class="layui-nav-child">
                            <dd><a th:href="@{/middleware/redis/index(userId=${userId}, accessToken=${accessToken})}">Redis</a></dd>
                            <dd><a th:href="@{/middleware/mongo/index(userId=${userId}, accessToken=${accessToken})}">MongoDB</a></dd>
                            <dd><a th:href="@{/middleware/elasticsearch/index(userId=${userId}, accessToken=${accessToken})}">Elasticsearch</a></dd>
                            <dd><a th:href="@{/middleware/kafka/index(userId=${userId}, accessToken=${accessToken})}">Kafka</a></dd>
                            <dd><a th:href="@{/middleware/rabbitmq/index(userId=${userId}, accessToken=${accessToken})}">RabbitMQ</a></dd>
                            <dd><a th:href="@{/middleware/rocketmq/index(userId=${userId}, accessToken=${accessToken})}">RocketMQ</a></dd>
                            <dd><a th:href="@{/middleware/mybatis/index(userId=${userId}, accessToken=${accessToken})}">MyBatis</a></dd>
                        </dl>
                    </li>
                </ul>
            </div>
        </div>

        <!-- 内容主体 -->
        <div class="layui-body">
            <div class="layui-card">
                <div class="layui-card-header">系统概览</div>
                <div class="layui-card-body">
                    <!-- 欢迎信息 -->
                    <p>欢迎回来,<span th:text="${userName}">用户</span>!</p>
                    <!-- 功能模块卡片 -->
                    <div class="module-cards">
                        <div class="module-card" th:each="module : ${modules}">
                            <h3 th:text="${module.name}">模块名称</h3>
                            <p th:text="${module.description}">模块描述</p>
                            <a th:href="${module.url}" class="layui-btn layui-btn-sm">进入</a>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>

    <script th:src="@{/layui/layui.js}"></script>
    <script th:inline="javascript">
        // 将服务端数据传递给前端
        window.__USER_INFO__ = {
            userId: /*[[${userId}]]*/ '',
            userName: /*[[${userName}]]*/ '',
            accessToken: /*[[${accessToken}]]*/ ''
        };
    </script>
    <script th:src="@{/js/global.js}"></script>
</body>
</html>

在这个首页模板中,有几个值得注意的设计细节:

(1)Token 通过 Thymeleaf 内联 JavaScript 传递到前端。 使用 th:inline="javascript" 配合 /*[[${variable}]]*/ 语法,可以将服务端的 Model 数据安全地注入到 JavaScript 变量中。Thymeleaf 会自动处理字符串的转义和引号问题,避免 XSS 攻击。

(2)侧边栏菜单中的链接携带 Token 参数。 每个菜单链接都通过 th:href="@{...}" 的方式携带 userIdaccessToken 参数。这样当用户点击菜单跳转到新页面时,Token 信息不会丢失。

(3)模块卡片使用 th:each 动态渲染。 首页的功能模块卡片不是硬编码的,而是通过后端传递的 modules 数据动态渲染。这意味着新增功能模块时,只需要在后端添加配置即可,无需修改模板文件。

3.4 AI 模块页面模板

AI 模块是 smart-scaffold 项目的核心功能之一,包含对话(Chat)、流式对话(Chat Stream)、写作(Writing)和提示词管理(Prompt)四个子页面。每个子页面都有独立的模板文件,但共享相同的布局框架。

教学示例——AI 对话页面模板:

html
<!-- 教学示例:templates/ai/chat/index.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8" />
    <title>AI 智能对话</title>
    <link rel="stylesheet" th:href="@{/layui/css/layui.css}" />
    <link rel="stylesheet" th:href="@{/css/ai/chat.css}" />
</head>
<body>
    <div class="chat-container">
        <!-- 对话历史区域 -->
        <div class="chat-history" id="chatHistory">
            <div class="chat-message" th:each="msg : ${chatHistory}">
                <div th:class="${msg.role == 'user'} ? 'message-user' : 'message-assistant'">
                    <div class="message-avatar" th:text="${msg.role == 'user'} ? ${userName} : 'AI'">用户</div>
                    <div class="message-content" th:text="${msg.content}">消息内容</div>
                    <div class="message-time" th:text="${#dates.format(msg.timestamp, 'HH:mm:ss')}">12:00:00</div>
                </div>
            </div>
        </div>

        <!-- 输入区域 -->
        <div class="chat-input-area">
            <textarea id="messageInput" placeholder="请输入您的问题..." class="chat-textarea"></textarea>
            <div class="chat-actions">
                <select id="modelSelect" class="model-selector">
                    <option th:each="model : ${availableModels}"
                            th:value="${model.id}"
                            th:text="${model.name}"
                            th:selected="${model.id == defaultModelId}">
                        模型名称
                    </option>
                </select>
                <button id="sendBtn" class="layui-btn layui-btn-normal">发送</button>
            </div>
        </div>
    </div>

    <script th:src="@{/layui/layui.js}"></script>
    <script th:inline="javascript">
        window.__USER_INFO__ = {
            userId: /*[[${userId}]]*/ '',
            userName: /*[[${userName}]]*/ '',
            accessToken: /*[[${accessToken}]]*/ ''
        };
        window.__CHAT_CONFIG__ = {
            defaultModelId: /*[[${defaultModelId}]]*/ '',
            apiBaseUrl: /*[[@{/}]]*/ ''
        };
    </script>
    <script th:src="@{/js/global.js}"></script>
    <script th:src="@{/js/ai/chat.js}"></script>
</body>
</html>

教学示例——AI 流式对话页面的关键差异:

html
<!-- 教学示例:templates/ai/chat-stream/index.html(关键差异部分) -->
<div class="chat-input-area">
    <textarea id="messageInput" placeholder="流式对话:AI 将逐字回复..." class="chat-textarea"></textarea>
    <div class="chat-actions">
        <button id="sendBtn" class="layui-btn layui-btn-normal">发送</button>
        <button id="stopBtn" class="layui-btn layui-btn-danger" style="display:none;">停止生成</button>
    </div>
</div>

流式对话页面与普通对话页面的主要区别在于:流式对话使用 SSE(Server-Sent Events)技术,AI 的回复会逐字显示在页面上,而不是等待完整响应后一次性显示。因此,流式对话页面额外需要一个"停止生成"按钮,用于中断正在进行的流式响应。

教学示例——AI 写作页面模板:

html
<!-- 教学示例:templates/ai/writing/index.html(核心结构) -->
<div class="writing-container">
    <!-- 写作配置面板 -->
    <div class="writing-config">
        <div class="config-item">
            <label>写作风格:</label>
            <select id="styleSelect">
                <option th:each="style : ${writingStyles}"
                        th:value="${style.id}"
                        th:text="${style.name}">
                    风格名称
                </option>
            </select>
        </div>
        <div class="config-item">
            <label>目标长度:</label>
            <select id="lengthSelect">
                <option value="short">短文(500字以内)</option>
                <option value="medium">中文(500-2000字)</option>
                <option value="long">长文(2000字以上)</option>
            </select>
        </div>
    </div>

    <!-- 写作编辑区域 -->
    <div class="writing-editor">
        <textarea id="titleInput" placeholder="请输入文章标题..." class="title-input"></textarea>
        <textarea id="requirementInput" placeholder="请输入写作要求..." class="requirement-input"></textarea>
        <button id="generateBtn" class="layui-btn layui-btn-lg layui-btn-normal">开始写作</button>
    </div>

    <!-- 写作结果展示 -->
    <div class="writing-result" id="writingResult" style="display:none;">
        <div class="result-toolbar">
            <button id="copyBtn" class="layui-btn layui-btn-sm">复制全文</button>
            <button id="exportBtn" class="layui-btn layui-btn-sm layui-btn-warm">导出</button>
        </div>
        <div id="resultContent" class="result-content"></div>
    </div>
</div>

教学示例——提示词管理页面模板:

html
<!-- 教学示例:templates/ai/prompt/index.html(核心结构) -->
<div class="prompt-container">
    <!-- 搜索栏 -->
    <div class="search-bar">
        <form class="layui-form" lay-filter="searchForm">
            <div class="layui-inline">
                <input type="text" name="keyword" placeholder="搜索提示词..." class="layui-input" />
            </div>
            <div class="layui-inline">
                <select name="category">
                    <option value="">全部分类</option>
                    <option th:each="cat : ${categories}" th:value="${cat}" th:text="${cat}">分类</option>
                </select>
            </div>
            <button class="layui-btn" lay-submit lay-filter="search">搜索</button>
            <button type="reset" class="layui-btn layui-btn-primary">重置</button>
        </form>
    </div>

    <!-- 提示词表格 -->
    <table id="promptTable" lay-filter="promptTable"></table>
</div>

3.5 中间件模块页面模板

中间件模块是 smart-scaffold 项目的另一个重要功能区域,涵盖了 Redis、MongoDB、Elasticsearch、Kafka、RabbitMQ、RocketMQ 和 MyBatis 七种中间件的管理页面。每个中间件的管理页面都遵循统一的布局模式,但根据中间件的特性展示不同的管理功能。

教学示例——Redis 管理页面模板:

html
<!-- 教学示例:templates/middleware/redis/index.html(核心结构) -->
<div class="middleware-container">
    <!-- 连接信息面板 -->
    <div class="layui-card">
        <div class="layui-card-header">Redis 连接信息</div>
        <div class="layui-card-body">
            <table class="layui-table">
                <tr>
                    <td>主机地址:</td>
                    <td th:text="${redisInfo.host}">localhost</td>
                </tr>
                <tr>
                    <td>端口:</td>
                    <td th:text="${redisInfo.port}">6379</td>
                </tr>
                <tr>
                    <td>数据库:</td>
                    <td th:text="${redisInfo.database}">0</td>
                </tr>
                <tr>
                    <td>连接状态:</td>
                    <td>
                        <span th:class="${redisInfo.connected} ? 'status-connected' : 'status-disconnected'"
                              th:text="${redisInfo.connected} ? '已连接' : '未连接'">已连接</span>
                    </td>
                </tr>
            </table>
        </div>
    </div>

    <!-- 操作面板 -->
    <div class="layui-card">
        <div class="layui-card-header">Key 操作</div>
        <div class="layui-card-body">
            <div class="layui-form">
                <div class="layui-form-item">
                    <label class="layui-form-label">Key 模式:</label>
                    <div class="layui-input-block">
                        <input type="text" name="keyPattern" placeholder="如:user:*" class="layui-input" />
                    </div>
                </div>
                <div class="layui-form-item">
                    <div class="layui-input-block">
                        <button class="layui-btn" lay-submit lay-filter="searchKeys">搜索</button>
                        <button class="layui-btn layui-btn-warm" lay-submit lay-filter="scanKeys">扫描</button>
                    </div>
                </div>
            </div>
        </div>
    </div>

    <!-- 数据表格 -->
    <table id="redisKeyTable" lay-filter="redisKeyTable"></table>
</div>

教学示例——Kafka 管理页面模板:

html
<!-- 教学示例:templates/middleware/kafka/index.html(核心结构) -->
<div class="middleware-container">
    <!-- Topic 列表 -->
    <div class="layui-card">
        <div class="layui-card-header">Kafka Topic 管理</div>
        <div class="layui-card-body">
            <table id="topicTable" lay-filter="topicTable"></table>
        </div>
    </div>

    <!-- 消息查看面板 -->
    <div class="layui-card" id="messagePanel" style="display:none;">
        <div class="layui-card-header">
            消息查看 - <span id="currentTopic">-</span>
        </div>
        <div class="layui-card-body">
            <div class="layui-form">
                <div class="layui-inline">
                    <label class="layui-form-label">分区:</label>
                    <div class="layui-input-inline">
                        <select id="partitionSelect" lay-filter="partition">
                            <option value="0">Partition 0</option>
                        </select>
                    </div>
                </div>
                <div class="layui-inline">
                    <label class="layui-form-label">偏移量:</label>
                    <div class="layui-input-inline">
                        <input type="number" id="offsetInput" value="0" class="layui-input" />
                    </div>
                </div>
                <div class="layui-inline">
                    <button class="layui-btn" id="fetchMessagesBtn">拉取消息</button>
                </div>
            </div>
            <pre id="messageContent" class="message-content"></pre>
        </div>
    </div>
</div>

其他中间件管理页面(MongoDB、Elasticsearch、RabbitMQ、RocketMQ、MyBatis)的模板结构与此类似,都遵循"连接信息面板 + 操作面板 + 数据表格"的三段式布局。每个页面的差异主要体现在操作面板的表单字段和数据表格的列配置上。

3.6 登录模块页面模板

登录模块是系统的入口,包含三个页面:登录页面(index)、OAuth 回调页面(callback)和用户信息页面(userinfo)。

教学示例——登录页面模板:

html
<!-- 教学示例:templates/login/index.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8" />
    <title>系统登录</title>
    <link rel="stylesheet" th:href="@{/layui/css/layui.css}" />
    <link rel="stylesheet" th:href="@{/css/login.css}" />
</head>
<body>
    <div class="login-container">
        <div class="login-card">
            <div class="login-header">
                <h1>Smart Scaffold</h1>
                <p>企业级快速开发脚手架</p>
            </div>
            <div class="login-body">
                <!-- CAS 登录入口 -->
                <a th:href="${casLoginUrl}" class="layui-btn layui-btn-fluid layui-btn-normal">
                    <i class="layui-icon layui-icon-auz"></i> CAS 统一认证登录
                </a>

                <!-- 分隔线 -->
                <div class="login-divider">
                    <span>或使用以下方式</span>
                </div>

                <!-- 本地登录表单 -->
                <form class="layui-form" lay-filter="loginForm">
                    <div class="layui-form-item">
                        <input type="text" name="username" placeholder="用户名"
                               lay-verify="required" class="layui-input" />
                    </div>
                    <div class="layui-form-item">
                        <input type="password" name="password" placeholder="密码"
                               lay-verify="required" class="layui-input" />
                    </div>
                    <div class="layui-form-item">
                        <button class="layui-btn layui-btn-fluid" lay-submit lay-filter="login">
                            登录
                        </button>
                    </div>
                </form>
            </div>
        </div>
    </div>

    <script th:src="@{/layui/layui.js}"></script>
    <script th:src="@{/js/login.js}"></script>
</body>
</html>

教学示例——OAuth 回调页面模板:

html
<!-- 教学示例:templates/login/callback.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8" />
    <title>登录处理中...</title>
    <link rel="stylesheet" th:href="@{/layui/css/layui.css}" />
</head>
<body>
    <div class="callback-container">
        <div class="loading-spinner">
            <i class="layui-icon layui-icon-loading layui-anim layui-anim-rotate layui-anim-loop"></i>
        </div>
        <p id="callbackMessage">正在处理 OAuth 回调,请稍候...</p>
    </div>

    <script th:inline="javascript">
        // 回调处理逻辑
        (function() {
            var params = new URLSearchParams(window.location.search);
            var code = params.get('code');
            var state = params.get('state');

            if (code) {
                // 发送 code 到后端换取 token
                fetch(/*[[@{/login/token}]]*/, {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({ code: code, state: state })
                })
                .then(function(response) { return response.json(); })
                .then(function(data) {
                    if (data.code === 200) {
                        // 跳转到首页,携带 token 信息
                        var redirectUrl = /*[[@{/}]]*/ + '?'
                            + 'userId=' + encodeURIComponent(data.data.userId)
                            + '&userName=' + encodeURIComponent(data.data.userName)
                            + '&accessToken=' + encodeURIComponent(data.data.accessToken)
                            + '&refreshToken=' + encodeURIComponent(data.data.refreshToken);
                        window.location.href = redirectUrl;
                    } else {
                        document.getElementById('callbackMessage').textContent =
                            '登录失败:' + (data.message || '未知错误');
                    }
                })
                .catch(function(error) {
                    document.getElementById('callbackMessage').textContent =
                        '网络错误,请重试';
                });
            } else {
                document.getElementById('callbackMessage').textContent =
                    '缺少授权码,请重新登录';
            }
        })();
    </script>
</body>
</html>

四、Layui v2.13.5 前端框架集成

4.1 Layui 框架概述与核心优势

Layui 是一套面向后端开发者的轻量级前端 UI 框架,由国内开发者贤心创建并开源。Layui 的设计哲学是"返璞归真"——它不追求前沿的技术概念(如虚拟 DOM、响应式数据绑定),而是专注于提供一套简洁、实用、开箱即用的 UI 组件库。

Layui v2.13.5 版本的主要特性包括:

(1)原生 JavaScript 实现。 Layui 不依赖任何第三方库(如 jQuery),全部使用原生 JavaScript 实现。这意味着引入 Layui 不会带来额外的依赖冲突,也不会显著增加页面加载时间。

(2)模块化架构。 Layui 采用自研的模块加载器(layui.use()),支持按需加载组件。开发者只需要加载页面实际使用的组件,避免加载不必要的代码。

(3)丰富的组件库。 Layui 提供了涵盖表单、表格、弹层、日期时间选择器、分页器、导航菜单、标签页、进度条、评分、树形组件、颜色选择器等在内的数十个 UI 组件,基本覆盖了管理后台的所有 UI 需求。

(4)完善的中文文档。 Layui 的官方文档以中文为主,示例丰富,API 说明清晰。对于国内开发者而言,这大大降低了学习和使用成本。

(5)兼容性好。 Layui 兼容所有主流浏览器(包括 IE9+),无需 Polyfill。这对于一些需要兼容旧版浏览器的企业内部系统来说是一个重要的优势。

4.2 Layui 核心组件在管理后台中的应用

4.2.1 表格组件(table)

表格组件是 Layui 中最复杂、最强大的组件之一,也是管理后台中使用频率最高的组件。Layui 的表格组件支持异步数据加载(通过 URL 请求数据)、分页、排序、工具栏、行内操作、复选框、单元格编辑等功能。

教学示例——Layui 表格的基本使用:

javascript
// 教学示例:Layui 表格初始化
layui.use(['table', 'global'], function() {
    var table = layui.table;
    var global = layui.global;

    table.render({
        elem: '#dataTable',          // 指定表格容器的 DOM 元素
        url: global.getApiUrl('/api/users/list'),  // 数据接口地址
        method: 'POST',
        headers: {
            'Authorization': 'Bearer ' + global.getAccessToken()
        },
        where: {                      // 额外的请求参数
            keyword: '',
            status: ''
        },
        page: true,                   // 开启分页
        limit: 20,                    // 每页显示条数
        limits: [10, 20, 50, 100],   // 可选的每页条数
        cols: [[                      // 列配置
            { type: 'checkbox' },     // 复选框列
            { field: 'userName', title: '用户名', width: 120 },
            { field: 'email', title: '邮箱', width: 200 },
            { field: 'roleName', title: '角色', width: 100 },
            { field: 'status', title: '状态', width: 80,
              templet: function(d) {
                  return d.status === 1
                      ? '<span class="layui-badge layui-bg-green">启用</span>'
                      : '<span class="layui-badge layui-bg-gray">禁用</span>';
              }
            },
            { field: 'createTime', title: '创建时间', width: 180,
              sort: true
            },
            { title: '操作', width: 200, fixed: 'right',
              toolbar: '#rowToolbar'   // 行工具栏模板
            }
        ]],
        parseData: function(res) {     // 解析返回的数据
            return {
                'code': res.code,
                'msg': res.message,
                'count': res.data.total,
                'data': res.data.records
            };
        },
        response: {                    // 响应数据格式声明
            statusCode: 200
        }
    });
});

教学示例——表格行工具栏的 HTML 模板:

html
<!-- 教学示例:表格行工具栏模板 -->
<script type="text/html" id="rowToolbar">
    <a class="layui-btn layui-btn-xs" lay-event="edit">编辑</a>
    <a class="layui-btn layui-btn-xs layui-btn-danger" lay-event="delete">删除</a>
</script>

教学示例——表格行操作事件处理:

javascript
// 教学示例:表格行操作事件监听
table.on('tool(dataTable)', function(obj) {
    var data = obj.data;       // 当前行的数据
    var event = obj.event;     // 触发的事件名

    if (event === 'edit') {
        // 打开编辑弹窗
        openEditDialog(data);
    } else if (event === 'delete') {
        // 确认删除
        layer.confirm('确定要删除该记录吗?', function(index) {
            global.ajax({
                url: global.getApiUrl('/api/users/delete'),
                method: 'POST',
                data: { id: data.id },
                success: function(res) {
                    if (res.code === 200) {
                        layer.msg('删除成功', { icon: 1 });
                        table.reload('dataTable');  // 刷新表格
                    }
                }
            });
            layer.close(index);
        });
    }
});

4.2.2 表单组件(form)

Layui 的表单组件提供了统一的表单样式和验证机制。通过 lay-verify 属性可以方便地配置表单验证规则,通过 lay-filter 属性可以监听表单提交事件。

教学示例——Layui 表单的基本使用:

html
<!-- 教学示例:Layui 表单 -->
<form class="layui-form" lay-filter="userForm">
    <div class="layui-form-item">
        <label class="layui-form-label">用户名</label>
        <div class="layui-input-block">
            <input type="text" name="userName" lay-verify="required"
                   placeholder="请输入用户名" class="layui-input" />
        </div>
    </div>
    <div class="layui-form-item">
        <label class="layui-form-label">邮箱</label>
        <div class="layui-input-block">
            <input type="email" name="email" lay-verify="required|email"
                   placeholder="请输入邮箱" class="layui-input" />
        </div>
    </div>
    <div class="layui-form-item">
        <label class="layui-form-label">手机号</label>
        <div class="layui-input-block">
            <input type="tel" name="phone" lay-verify="required|phone"
                   placeholder="请输入手机号" class="layui-input" />
        </div>
    </div>
    <div class="layui-form-item">
        <label class="layui-form-label">角色</label>
        <div class="layui-input-block">
            <select name="roleId" lay-verify="required">
                <option value="">请选择角色</option>
                <option value="1">管理员</option>
                <option value="2">普通用户</option>
                <option value="3">访客</option>
            </select>
        </div>
    </div>
    <div class="layui-form-item">
        <label class="layui-form-label">状态</label>
        <div class="layui-input-block">
            <input type="checkbox" name="status" lay-skin="switch"
                   lay-text="启用|禁用" value="1" />
        </div>
    </div>
    <div class="layui-form-item">
        <div class="layui-input-block">
            <button class="layui-btn" lay-submit lay-filter="submitUser">提交</button>
            <button type="reset" class="layui-btn layui-btn-primary">重置</button>
        </div>
    </div>
</form>

教学示例——表单提交事件处理:

javascript
// 教学示例:Layui 表单提交处理
form.on('submit(submitUser)', function(data) {
    var formData = data.field;  // 获取表单数据

    global.ajax({
        url: global.getApiUrl('/api/users/save'),
        method: 'POST',
        data: formData,
        success: function(res) {
            if (res.code === 200) {
                layer.msg('保存成功', { icon: 1 });
                // 关闭弹窗并刷新表格
                var parentIndex = parent.layer.getFrameIndex(window.name);
                parent.layer.close(parentIndex);
                parent.layui.table.reload('dataTable');
            } else {
                layer.msg(res.message || '保存失败', { icon: 2 });
            }
        }
    });

    return false;  // 阻止表单默认提交
});

4.2.3 弹层组件(layer)

弹层组件是 Layui 最受欢迎的组件之一,它提供了丰富的弹出层类型:信息框、页面层、加载层、Tips 层等。在管理后台中,弹层组件常用于表单编辑弹窗、确认对话框、消息提示等场景。

教学示例——Layui 弹层的常用方式:

javascript
// 教学示例:Layui 弹层的各种用法

// 1. 信息框
layer.msg('操作成功', { icon: 1, time: 2000 });

// 2. 确认对话框
layer.confirm('确定要执行此操作吗?', {
    icon: 3,
    title: '操作确认',
    btn: ['确定', '取消']
}, function(index) {
    // 确定回调
    layer.close(index);
    layer.msg('已确认', { icon: 1 });
}, function() {
    // 取消回调
    layer.msg('已取消', { icon: 0 });
});

// 3. 打开页面层(用于表单编辑弹窗)
layer.open({
    type: 2,                     // iframe 层
    title: '编辑用户',
    area: ['600px', '500px'],    // 弹窗尺寸
    content: '/user/edit?id=123', // 页面 URL
    btn: ['保存', '取消'],
    yes: function(index, layero) {
        // 获取 iframe 中的表单数据并提交
        var iframeWin = window[layero.find('iframe')[0]['name']];
        iframeWin.submitForm();
    }
});

// 4. 加载层
var loadIndex = layer.load(1, { shade: [0.3, '#000'] });
// 操作完成后关闭
layer.close(loadIndex);

// 5. Tips 提示
layer.tips('点击查看详情', '#someElement', {
    tips: [1, '#3595CC'],
    time: 3000
});

4.2.4 日期时间选择器(laydate)

日期时间选择器是管理后台表单中不可或缺的组件。Layui 的 laydate 组件支持日期选择、时间选择、日期时间选择、日期范围选择等多种模式。

教学示例——Layui 日期选择器的使用:

javascript
// 教学示例:Layui 日期选择器
layui.use(['laydate'], function() {
    var laydate = layui.laydate;

    // 日期选择
    laydate.render({
        elem: '#startDate',
        format: 'yyyy-MM-dd',
        max: 0  // 不能选择未来日期
    });

    // 日期范围选择
    laydate.render({
        elem: '#dateRange',
        range: true,
        format: 'yyyy-MM-dd',
        done: function(value, startDate, endDate) {
            console.log('选择范围:', value);
        }
    });

    // 日期时间选择
    laydate.render({
        elem: '#createTime',
        type: 'datetime',
        format: 'yyyy-MM-dd HH:mm:ss'
    });
});

4.3 Thymeleaf 与 Layui 的数据绑定

在服务端渲染架构下,Thymeleaf 负责在服务器端将数据填充到 HTML 模板中,Layui 负责在客户端处理用户交互和异步数据加载。两者之间的数据绑定主要通过以下几种方式实现:

(1)服务端渲染静态数据。 对于页面初始化时就需要展示的数据(如用户信息、下拉选项、配置参数等),通过 Thymeleaf 的变量表达式直接渲染到 HTML 中。

html
<!-- 教学示例:Thymeleaf 渲染下拉选项 -->
<select name="roleId" lay-verify="required">
    <option value="">请选择角色</option>
    <option th:each="role : ${roles}"
            th:value="${role.id}"
            th:text="${role.name}">
        角色名称
    </option>
</select>

(2)JavaScript 内联传递配置数据。 对于需要在客户端 JavaScript 中使用的配置数据(如 API 地址、Token、默认参数等),通过 Thymeleaf 的内联 JavaScript 语法传递。

html
<!-- 教学示例:Thymeleaf 内联 JavaScript -->
<script th:inline="javascript">
    // 全局配置对象
    window.__APP_CONFIG__ = {
        userId: /*[[${userId}]]*/ '',
        userName: /*[[${userName}]]*/ '',
        accessToken: /*[[${accessToken}]]*/ '',
        refreshToken: /*[[${refreshToken}]]*/ '',
        apiBaseUrl: /*[[@{/}]]*/ '',
        version: /*[[${appVersion}]]*/ '1.0.0'
    };
</script>

(3)Layui 表格异步加载数据。 对于大量的列表数据,通过 Layui 表格组件的异步加载功能,在页面渲染完成后通过 AJAX 请求获取数据。

javascript
// 教学示例:Layui 表格异步加载数据
table.render({
    elem: '#dataTable',
    url: window.__APP_CONFIG__.apiBaseUrl + 'api/data/list',
    headers: {
        'Authorization': 'Bearer ' + window.__APP_CONFIG__.accessToken
    },
    page: true,
    cols: [[ /* 列配置 */ ]],
    parseData: function(res) {
        return {
            'code': res.code === 200 ? 0 : res.code,
            'msg': res.message,
            'count': res.data.total,
            'data': res.data.records
        };
    }
});

(4)Thymeleaf 条件渲染控制 Layui 组件的初始状态。 通过 Thymeleaf 的条件表达式,可以根据服务端的数据控制 Layui 组件的初始状态(如按钮的显示/隐藏、表单的默认值等)。

html
<!-- 教学示例:Thymeleaf 条件渲染控制 Layui 组件 -->
<div class="layui-btn-group">
    <button class="layui-btn layui-btn-sm"
            th:if="${hasPermission('user:add')}"
            id="addBtn">新增</button>
    <button class="layui-btn layui-btn-sm layui-btn-danger"
            th:if="${hasPermission('user:batchDelete')}"
            id="batchDeleteBtn">批量删除</button>
    <button class="layui-btn layui-btn-sm layui-btn-warm"
            th:if="${hasPermission('user:export')}"
            id="exportBtn">导出</button>
</div>

4.4 响应式布局实现

虽然管理后台主要在桌面端使用,但合理的响应式布局仍然能够提升在不同屏幕尺寸下的使用体验。Layui 提供了一套基于栅格系统的响应式布局方案。

教学示例——Layui 栅格系统:

html
<!-- 教学示例:Layui 栅格布局 -->
<div class="layui-row layui-col-space15">
    <!-- 搜索条件区域 -->
    <div class="layui-col-md12">
        <div class="layui-card">
            <div class="layui-card-body">
                <form class="layui-form" lay-filter="searchForm">
                    <div class="layui-row layui-col-space10">
                        <div class="layui-col-md3">
                            <input type="text" name="keyword" placeholder="关键词" class="layui-input" />
                        </div>
                        <div class="layui-col-md3">
                            <select name="status">
                                <option value="">全部状态</option>
                                <option value="1">启用</option>
                                <option value="0">禁用</option>
                            </select>
                        </div>
                        <div class="layui-col-md3">
                            <input type="text" name="dateRange" id="dateRange" placeholder="日期范围" class="layui-input" />
                        </div>
                        <div class="layui-col-md3">
                            <button class="layui-btn" lay-submit lay-filter="search">搜索</button>
                            <button type="reset" class="layui-btn layui-btn-primary">重置</button>
                        </div>
                    </div>
                </form>
            </div>
        </div>
    </div>

    <!-- 数据表格区域 -->
    <div class="layui-col-md12">
        <div class="layui-card">
            <div class="layui-card-body">
                <table id="dataTable" lay-filter="dataTable"></table>
            </div>
        </div>
    </div>
</div>

Layui 的栅格系统将页面水平方向划分为 12 等份,通过 layui-col-md* 类名指定每个元素占据的列数。其中 md 表示中等屏幕(桌面端),Layui 还支持 xs(超小屏幕)、sm(小屏幕)、lg(大屏幕)等断点。


五、FrontController 页面控制器设计

5.1 页面控制器的职责与设计原则

在 smart-scaffold 项目中,FrontController 是所有页面跳转的统一入口。它的核心职责是:接收页面请求,准备模板所需的数据,返回对应的 Thymeleaf 模板视图。

FrontController 的设计遵循以下几个原则:

(1)单一职责。 FrontController 只负责页面跳转和数据准备,不处理业务逻辑。所有的业务逻辑(如数据查询、数据保存等)由对应的 Service 和 API Controller 处理。

(2)统一参数接收。 每个页面方法统一接收 userIduserNameaccessTokenrefreshToken 四个参数,这些参数通过 URL 的 Query String 传递。

(3)统一数据传递。 通过 addUserInfoToModel 方法将用户信息统一添加到 Model 中,确保每个模板都能访问到用户的基本信息和 Token。

(4)简洁明了。 页面控制器的方法实现应该尽可能简洁,通常只有几行代码——获取参数、添加到 Model、返回视图名称。

5.2 统一参数接收方案

在服务端渲染架构下,页面之间的跳转是通过 URL 导航实现的。为了在页面跳转过程中保持用户的登录状态(Token 信息),smart-scaffold 项目采用了 URL 参数传递的方案。

教学示例——FrontController 的统一参数接收:

java
// 教学示例:FrontController 的基本结构
@Controller
public class FrontController {

    /**
     * 将用户信息统一添加到 Model 中
     * 所有页面方法都应该调用此方法
     */
    private void addUserInfoToModel(Model model,
                                     String userId,
                                     String userName,
                                     String accessToken,
                                     String refreshToken) {
        model.addAttribute("userId", userId);
        model.addAttribute("userName", userName);
        model.addAttribute("accessToken", accessToken);
        model.addAttribute("refreshToken", refreshToken);
    }

    /**
     * 首页
     */
    @GetMapping("/")
    public String index(
            @RequestParam(required = false) String userId,
            @RequestParam(required = false) String userName,
            @RequestParam(required = false) String accessToken,
            @RequestParam(required = false) String refreshToken,
            Model model) {

        addUserInfoToModel(model, userId, userName, accessToken, refreshToken);

        // 准备首页所需的其他数据
        model.addAttribute("modules", getModuleList());

        return "index";
    }
}

这种设计的好处是:

第一,参数传递清晰可见。 所有的 Token 信息都通过 URL 参数传递,开发者可以通过浏览器地址栏直接看到当前的 Token 信息,便于调试和排查问题。

第二,无状态设计。 页面控制器不依赖 HTTP Session 来存储用户信息,每个请求都是自包含的。这使得系统更容易进行水平扩展——任何服务器实例都可以处理任何用户的请求,无需考虑 Session 同步的问题。

第三,兼容书签和分享。 用户可以将某个页面的 URL 添加到书签中,或者分享给其他用户。只要 URL 中的 Token 信息有效,打开书签或分享链接时就能正常访问页面。

5.3 各模块页面方法实现

教学示例——AI 模块页面控制器:

java
// 教学示例:AI 模块页面控制器方法
@Controller
@RequestMapping("/ai")
public class AiPageController {

    @Autowired
    private AiModelService aiModelService;

    @Autowired
    private PromptService promptService;

    private void addUserInfoToModel(Model model,
                                     String userId, String userName,
                                     String accessToken, String refreshToken) {
        model.addAttribute("userId", userId);
        model.addAttribute("userName", userName);
        model.addAttribute("accessToken", accessToken);
        model.addAttribute("refreshToken", refreshToken);
    }

    /**
     * AI 对话页面
     */
    @GetMapping("/chat/index")
    public String chatIndex(
            @RequestParam(required = false) String userId,
            @RequestParam(required = false) String userName,
            @RequestParam(required = false) String accessToken,
            @RequestParam(required = false) String refreshToken,
            Model model) {

        addUserInfoToModel(model, userId, userName, accessToken, refreshToken);

        // 获取可用的 AI 模型列表
        model.addAttribute("availableModels", aiModelService.getAvailableModels());
        model.addAttribute("defaultModelId", aiModelService.getDefaultModelId());

        return "ai/chat/index";
    }

    /**
     * AI 流式对话页面
     */
    @GetMapping("/chat-stream/index")
    public String chatStreamIndex(
            @RequestParam(required = false) String userId,
            @RequestParam(required = false) String userName,
            @RequestParam(required = false) String accessToken,
            @RequestParam(required = false) String refreshToken,
            Model model) {

        addUserInfoToModel(model, userId, userName, accessToken, refreshToken);

        model.addAttribute("availableModels", aiModelService.getAvailableModels());
        model.addAttribute("defaultModelId", aiModelService.getDefaultModelId());

        return "ai/chat-stream/index";
    }

    /**
     * AI 写作页面
     */
    @GetMapping("/writing/index")
    public String writingIndex(
            @RequestParam(required = false) String userId,
            @RequestParam(required = false) String userName,
            @RequestParam(required = false) String accessToken,
            @RequestParam(required = false) String refreshToken,
            Model model) {

        addUserInfoToModel(model, userId, userName, accessToken, refreshToken);

        // 获取写作风格列表
        model.addAttribute("writingStyles", aiModelService.getWritingStyles());

        return "ai/writing/index";
    }

    /**
     * 提示词管理页面
     */
    @GetMapping("/prompt/index")
    public String promptIndex(
            @RequestParam(required = false) String userId,
            @RequestParam(required = false) String userName,
            @RequestParam(required = false) String accessToken,
            @RequestParam(required = false) String refreshToken,
            Model model) {

        addUserInfoToModel(model, userId, userName, accessToken, refreshToken);

        // 获取提示词分类列表
        model.addAttribute("categories", promptService.getCategories());

        return "ai/prompt/index";
    }
}

教学示例——中间件模块页面控制器:

java
// 教学示例:中间件模块页面控制器方法
@Controller
@RequestMapping("/middleware")
public class MiddlewarePageController {

    @Autowired
    private RedisService redisService;

    @Autowired
    private MongoService mongoService;

    @Autowired
    private ElasticsearchService esService;

    private void addUserInfoToModel(Model model,
                                     String userId, String userName,
                                     String accessToken, String refreshToken) {
        model.addAttribute("userId", userId);
        model.addAttribute("userName", userName);
        model.addAttribute("accessToken", accessToken);
        model.addAttribute("refreshToken", refreshToken);
    }

    /**
     * Redis 管理页面
     */
    @GetMapping("/redis/index")
    public String redisIndex(
            @RequestParam(required = false) String userId,
            @RequestParam(required = false) String userName,
            @RequestParam(required = false) String accessToken,
            @RequestParam(required = false) String refreshToken,
            Model model) {

        addUserInfoToModel(model, userId, userName, accessToken, refreshToken);

        // 获取 Redis 连接信息
        model.addAttribute("redisInfo", redisService.getConnectionInfo());

        return "middleware/redis/index";
    }

    /**
     * MongoDB 管理页面
     */
    @GetMapping("/mongo/index")
    public String mongoIndex(
            @RequestParam(required = false) String userId,
            @RequestParam(required = false) String userName,
            @RequestParam(required = false) String accessToken,
            @RequestParam(required = false) String refreshToken,
            Model model) {

        addUserInfoToModel(model, userId, userName, accessToken, refreshToken);

        model.addAttribute("mongoInfo", mongoService.getConnectionInfo());
        model.addAttribute("collections", mongoService.getCollectionNames());

        return "middleware/mongo/index";
    }

    /**
     * Elasticsearch 管理页面
     */
    @GetMapping("/elasticsearch/index")
    public String esIndex(
            @RequestParam(required = false) String userId,
            @RequestParam(required = false) String userName,
            @RequestParam(required = false) String accessToken,
            @RequestParam(required = false) String refreshToken,
            Model model) {

        addUserInfoToModel(model, userId, userName, accessToken, refreshToken);

        model.addAttribute("esInfo", esService.getClusterInfo());
        model.addAttribute("indices", esService.getIndexNames());

        return "middleware/elasticsearch/index";
    }

    /**
     * Kafka 管理页面
     */
    @GetMapping("/kafka/index")
    public String kafkaIndex(
            @RequestParam(required = false) String userId,
            @RequestParam(required = false) String userName,
            @RequestParam(required = false) String accessToken,
            @RequestParam(required = false) String refreshToken,
            Model model) {

        addUserInfoToModel(model, userId, userName, accessToken, refreshToken);

        return "middleware/kafka/index";
    }

    /**
     * RabbitMQ 管理页面
     */
    @GetMapping("/rabbitmq/index")
    public String rabbitmqIndex(
            @RequestParam(required = false) String userId,
            @RequestParam(required = false) String userName,
            @RequestParam(required = false) String accessToken,
            @RequestParam(required = false) String refreshToken,
            Model model) {

        addUserInfoToModel(model, userId, userName, accessToken, refreshToken);

        return "middleware/rabbitmq/index";
    }

    /**
     * RocketMQ 管理页面
     */
    @GetMapping("/rocketmq/index")
    public String rocketmqIndex(
            @RequestParam(required = false) String userId,
            @RequestParam(required = false) String userName,
            @RequestParam(required = false) String accessToken,
            @RequestParam(required = false) String refreshToken,
            Model model) {

        addUserInfoToModel(model, userId, userName, accessToken, refreshToken);

        return "middleware/rocketmq/index";
    }

    /**
     * MyBatis 管理页面
     */
    @GetMapping("/mybatis/index")
    public String mybatisIndex(
            @RequestParam(required = false) String userId,
            @RequestParam(required = false) String userName,
            @RequestParam(required = false) String accessToken,
            @RequestParam(required = false) String refreshToken,
            Model model) {

        addUserInfoToModel(model, userId, userName, accessToken, refreshToken);

        return "middleware/mybatis/index";
    }
}

教学示例——登录模块页面控制器:

java
// 教学示例:登录模块页面控制器方法
@Controller
@RequestMapping("/login")
public class LoginPageController {

    @Value("${cas.server.url}")
    private String casServerUrl;

    @Value("${cas.client.url}")
    private String casClientUrl;

    /**
     * 登录页面
     */
    @GetMapping("/index")
    public String loginIndex(Model model) {
        // 构建 CAS 登录 URL
        String casLoginUrl = casServerUrl + "/login?service="
                + URLEncoder.encode(casClientUrl + "/login/callback", "UTF-8");
        model.addAttribute("casLoginUrl", casLoginUrl);

        return "login/index";
    }

    /**
     * OAuth 回调页面
     */
    @GetMapping("/callback")
    public String callback(
            @RequestParam(required = false) String code,
            @RequestParam(required = false) String state,
            Model model) {

        model.addAttribute("code", code);
        model.addAttribute("state", state);

        return "login/callback";
    }

    /**
     * 用户信息页面
     */
    @GetMapping("/userinfo")
    public String userinfo(
            @RequestParam(required = false) String userId,
            @RequestParam(required = false) String userName,
            @RequestParam(required = false) String accessToken,
            @RequestParam(required = false) String refreshToken,
            Model model) {

        model.addAttribute("userId", userId);
        model.addAttribute("userName", userName);
        model.addAttribute("accessToken", accessToken);
        model.addAttribute("refreshToken", refreshToken);

        return "login/userinfo";
    }
}

5.4 页面跳转逻辑与参数传递链路

在 smart-scaffold 项目中,用户从登录到访问各个功能页面的完整跳转链路如下:

(1)用户访问首页 / → FrontController 检查 URL 中是否包含 Token 参数

  • 如果包含 Token:正常渲染首页,将 Token 信息传递到模板
  • 如果不包含 Token:重定向到登录页面 /login/index

(2)用户在登录页面点击"CAS 登录" → 跳转到 CAS Server 的登录页面

(3)CAS Server 认证成功后回调 → 跳转到 /login/callback?code=xxx&state=xxx

(4)回调页面处理 → 使用授权码向后端换取 Token → 获取到 userId、userName、accessToken、refreshToken → 重定向到首页 /?userId=xxx&userName=xxx&accessToken=xxx&refreshToken=xxx

(5)用户在首页点击菜单 → 跳转到对应的功能页面,URL 中携带 Token 参数

(6)功能页面中的 AJAX 请求 → 从 window.__APP_CONFIG__ 中获取 Token,添加到请求头中

这个跳转链路的核心设计思想是:Token 信息始终通过 URL 参数在页面之间传递,同时通过 Thymeleaf 内联 JavaScript 注入到全局配置对象中,供 AJAX 请求使用。


六、前后端 Token 传递方案

6.1 Token 传递的三层架构

在服务端渲染架构下,Token 的传递涉及三个层面:页面间传递、前后端传递和服务端获取。smart-scaffold 项目设计了一套完整的 Token 传递方案,覆盖了这三个层面。

┌─────────────────────────────────────────────────────┐
│                   Token 传递链路                      │
│                                                     │
│  页面间传递(URL 参数)                               │
│  ┌──────────┐   URL params   ┌──────────┐           │
│  │  首页     │ ────────────→ │ 功能页面  │           │
│  └──────────┘               └──────────┘           │
│       │                          │                  │
│       │ Thymeleaf 内联 JS         │ Thymeleaf 内联 JS│
│       ↓                          ↓                  │
│  ┌──────────┐               ┌──────────┐           │
│  │ global.js │               │ global.js │           │
│  └──────────┘               └──────────┘           │
│       │                          │                  │
│       │ AJAX Header              │ AJAX Header      │
│       ↓                          ↓                  │
│  ┌──────────────────────────────────────┐           │
│  │         后端 API(OAuthFilter)        │           │
│  │  Header → Parameter → Session        │           │
│  └──────────────────────────────────────┘           │
└─────────────────────────────────────────────────────┘

6.2 URL 参数传递 Token

页面之间的 Token 传递通过 URL 的 Query String 实现。当用户从一个页面跳转到另一个页面时,当前页面的 Token 信息会作为 URL 参数附加到目标页面的 URL 中。

教学示例——Thymeleaf 模板中的 Token 参数传递:

html
<!-- 教学示例:菜单链接中携带 Token 参数 -->
<a th:href="@{/ai/chat/index(
    userId=${userId},
    accessToken=${accessToken},
    refreshToken=${refreshToken}
)}">AI 对话</a>

<!-- 教学示例:按钮跳转中携带 Token 参数 -->
<a th:href="@{/middleware/redis/index(
    userId=${userId},
    userName=${userName},
    accessToken=${accessToken},
    refreshToken=${refreshToken}
)}" class="layui-btn">Redis 管理</a>

这种方案的优点是简单直接,不需要额外的存储机制。缺点是 Token 信息会暴露在 URL 中,可能被浏览器历史记录、服务器访问日志等记录。因此,在实际项目中需要注意以下几点:

  • 使用 HTTPS 协议加密传输,防止 Token 在网络传输中被窃取
  • 配置服务器访问日志,对 Token 参数进行脱敏处理
  • 设置合理的 Token 过期时间,降低 Token 泄露的风险

6.3 global.js 统一管理 Token

global.js 是 smart-scaffold 项目中前端全局脚本的核心文件,负责统一管理 Token 信息、封装 AJAX 请求、提供公共工具方法等。

教学示例——global.js 的核心实现:

javascript
// 教学示例:static/js/global.js 核心逻辑

/**
 * 全局配置管理模块
 */
var GlobalConfig = (function() {
    // 从 Thymeleaf 内联注入的全局配置中获取数据
    var config = window.__APP_CONFIG__ || {};

    return {
        /**
         * 获取用户ID
         */
        getUserId: function() {
            return config.userId || '';
        },

        /**
         * 获取用户名
         */
        getUserName: function() {
            return config.userName || '';
        },

        /**
         * 获取访问令牌
         */
        getAccessToken: function() {
            return config.accessToken || '';
        },

        /**
         * 获取刷新令牌
         */
        getRefreshToken: function() {
            return config.refreshToken || '';
        },

        /**
         * 更新令牌信息
         * 当 Token 刷新后,需要同步更新全局配置和 URL 参数
         */
        updateTokens: function(accessToken, refreshToken) {
            config.accessToken = accessToken;
            config.refreshToken = refreshToken;
        },

        /**
         * 构建带 Token 参数的 URL
         */
        buildUrl: function(baseUrl) {
            var separator = baseUrl.indexOf('?') === -1 ? '?' : '&';
            return baseUrl + separator
                + 'userId=' + encodeURIComponent(this.getUserId())
                + '&userName=' + encodeURIComponent(this.getUserName())
                + '&accessToken=' + encodeURIComponent(this.getAccessToken())
                + '&refreshToken=' + encodeURIComponent(this.getRefreshToken());
        },

        /**
         * 获取 API 基础路径
         */
        getApiUrl: function(path) {
            return (config.apiBaseUrl || '/') + path;
        }
    };
})();

/**
 * 封装 AJAX 请求
 * 自动添加 Token 到请求头
 */
var AjaxHelper = (function() {

    /**
     * 发起 AJAX 请求
     */
    function request(options) {
        var defaults = {
            method: 'GET',
            contentType: 'application/json',
            dataType: 'json',
            headers: {}
        };

        // 合并默认配置
        var config = Object.assign({}, defaults, options);

        // 自动添加 Authorization 头
        var accessToken = GlobalConfig.getAccessToken();
        if (accessToken) {
            config.headers['Authorization'] = 'Bearer ' + accessToken;
        }

        // 如果是 GET 请求且 data 是对象,转换为 URL 参数
        if (config.method.toUpperCase() === 'GET' && config.data) {
            var params = new URLSearchParams(config.data).toString();
            config.url += (config.url.indexOf('?') === -1 ? '?' : '&') + params;
            delete config.data;
        }

        return fetch(config.url, {
            method: config.method,
            headers: config.headers,
            body: config.data ? JSON.stringify(config.data) : undefined
        })
        .then(function(response) {
            if (response.status === 401) {
                // Token 过期,尝试刷新
                return handleTokenRefresh(config);
            }
            return response.json();
        });
    }

    /**
     * 处理 Token 刷新
     */
    function handleTokenRefresh(originalConfig) {
        var refreshToken = GlobalConfig.getRefreshToken();
        if (!refreshToken) {
            // 没有 RefreshToken,跳转到登录页
            window.location.href = '/login/index';
            return Promise.reject(new Error('Token expired'));
        }

        return fetch(GlobalConfig.getApiUrl('auth/refresh'), {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ refreshToken: refreshToken })
        })
        .then(function(response) { return response.json(); })
        .then(function(data) {
            if (data.code === 200) {
                // 更新全局 Token
                GlobalConfig.updateTokens(
                    data.data.accessToken,
                    data.data.refreshToken
                );
                // 使用新 Token 重试原始请求
                originalConfig.headers['Authorization'] =
                    'Bearer ' + data.data.accessToken;
                return request(originalConfig);
            } else {
                // 刷新失败,跳转到登录页
                window.location.href = '/login/index';
                return Promise.reject(new Error('Token refresh failed'));
            }
        });
    }

    return {
        get: function(url, data) {
            return request({ method: 'GET', url: url, data: data });
        },
        post: function(url, data) {
            return request({
                method: 'POST',
                url: url,
                data: data,
                headers: { 'Content-Type': 'application/json' }
            });
        },
        put: function(url, data) {
            return request({
                method: 'PUT',
                url: url,
                data: data,
                headers: { 'Content-Type': 'application/json' }
            });
        },
        delete: function(url, data) {
            return request({
                method: 'DELETE',
                url: url,
                data: data,
                headers: { 'Content-Type': 'application/json' }
            });
        }
    };
})();

/**
 * Layui 模块注册
 */
layui.define(function(exports) {
    exports('global', {
        config: GlobalConfig,
        ajax: AjaxHelper
    });
});

global.js 的设计有几个关键点:

(1)IIFE 模块化封装。 使用立即执行函数表达式(IIFE)创建模块作用域,避免全局变量污染。GlobalConfigAjaxHelper 都是通过 IIFE 封装的独立模块。

(2)自动 Token 注入。 AjaxHelper 在发起请求时自动从 GlobalConfig 中获取 Token 并添加到请求头中,业务代码无需手动处理 Token。

(3)Token 自动刷新。 当 API 返回 401 状态码时,AjaxHelper 会自动使用 RefreshToken 尝试刷新 Token,刷新成功后自动重试原始请求。这个过程对业务代码是透明的。

(4)Layui 模块注册。 通过 layui.define()global 注册为 Layui 模块,其他 Layui 模块可以通过 layui.use('global', function() { ... }) 来使用它。

6.4 OAuthFilter 三级 Token 获取机制

在后端,smart-scaffold 项目通过 OAuthFilter 实现了三级 Token 获取机制:Header → Parameter → Session。这意味着 Token 可以通过 HTTP 请求头、URL 参数或 HTTP Session 三种方式传递到后端,Filter 会按照优先级依次尝试获取。

教学示例——OAuthFilter 的三级 Token 获取逻辑:

java
// 教学示例:OAuthFilter 的核心逻辑
@Component
@WebFilter(urlPatterns = "/*")
public class OAuthFilter implements Filter {

    private static final String AUTH_HEADER = "Authorization";
    private static final String BEARER_PREFIX = "Bearer ";
    private static final String PARAM_USER_ID = "userId";
    private static final String PARAM_ACCESS_TOKEN = "accessToken";
    private static final String SESSION_USER_ID = "sessionUserId";
    private static final String SESSION_ACCESS_TOKEN = "sessionAccessToken";

    @Override
    public void doFilter(ServletRequest request,
                         ServletResponse response,
                         FilterChain chain) throws IOException, ServletException {

        HttpServletRequest httpRequest = (HttpServletRequest) request;
        HttpServletResponse httpResponse = (HttpServletResponse) response;

        String requestURI = httpRequest.getRequestURI();

        // 静态资源和登录页面放行
        if (isStaticResource(requestURI) || isLoginPage(requestURI)) {
            chain.doFilter(request, response);
            return;
        }

        // 三级 Token 获取
        String userId = null;
        String accessToken = null;

        // 第一级:从 HTTP Header 中获取
        String authHeader = httpRequest.getHeader(AUTH_HEADER);
        if (authHeader != null && authHeader.startsWith(BEARER_PREFIX)) {
            accessToken = authHeader.substring(BEARER_PREFIX.length());
            // 从 Token 中解析 userId(或通过额外的 Header 传递)
            userId = httpRequest.getHeader("X-User-Id");
        }

        // 第二级:从 URL 参数中获取
        if (accessToken == null) {
            accessToken = httpRequest.getParameter(PARAM_ACCESS_TOKEN);
            userId = httpRequest.getParameter(PARAM_USER_ID);
        }

        // 第三级:从 Session 中获取
        if (accessToken == null) {
            HttpSession session = httpRequest.getSession(false);
            if (session != null) {
                accessToken = (String) session.getAttribute(SESSION_ACCESS_TOKEN);
                userId = (String) session.getAttribute(SESSION_USER_ID);
            }
        }

        // Token 校验
        if (accessToken == null || userId == null) {
            // 未获取到 Token,返回 401 或重定向到登录页
            handleUnauthorized(httpRequest, httpResponse);
            return;
        }

        // 将 Token 信息注入到请求中,供后续的 Controller 和 Service 使用
        TokenRequestWrapper wrappedRequest = new TokenRequestWrapper(
            httpRequest, userId, accessToken);
        chain.doFilter(wrappedRequest, response);
    }

    /**
     * 判断是否为静态资源请求
     */
    private boolean isStaticResource(String uri) {
        return uri.startsWith("/static/")
            || uri.startsWith("/layui/")
            || uri.startsWith("/css/")
            || uri.startsWith("/js/")
            || uri.startsWith("/images/")
            || uri.endsWith(".css")
            || uri.endsWith(".js")
            || uri.endsWith(".png")
            || uri.endsWith(".jpg")
            || uri.endsWith(".ico");
    }

    /**
     * 判断是否为登录页面
     */
    private boolean isLoginPage(String uri) {
        return uri.startsWith("/login/");
    }
}

三级 Token 获取机制的设计考虑了不同的使用场景:

  • Header 方式:适用于 AJAX 请求。前端通过 global.jsAjaxHelper 自动将 Token 添加到 Authorization 请求头中。
  • Parameter 方式:适用于页面跳转。页面之间的跳转通过 URL 参数携带 Token,后端从 URL 参数中获取。
  • Session 方式:作为兜底方案。当 Header 和 Parameter 中都没有 Token 时,尝试从 Session 中获取。这为一些特殊的认证场景(如 CAS 认证回调后 Token 写入 Session)提供了兼容。

6.5 TokenRequestWrapper 注入请求参数

TokenRequestWrapperHttpServletRequestWrapper 的子类,用于将 Filter 中获取到的 Token 信息注入到请求对象中。这样,后续的 Controller 和 Service 就可以通过标准的 request.getParameter() 方法获取 Token 信息,无需关心 Token 的来源(Header、Parameter 还是 Session)。

教学示例——TokenRequestWrapper 的实现:

java
// 教学示例:TokenRequestWrapper 的核心实现
public class TokenRequestWrapper extends HttpServletRequestWrapper {

    private final String userId;
    private final String accessToken;
    private final Map<String, String[]> extraParams;

    public TokenRequestWrapper(HttpServletRequest request,
                                String userId,
                                String accessToken) {
        super(request);
        this.userId = userId;
        this.accessToken = accessToken;
        this.extraParams = new HashMap<>();
    }

    /**
     * 重写 getParameter 方法
     * 当请求参数中没有对应字段时,返回 Filter 注入的值
     */
    @Override
    public String getParameter(String name) {
        String value = super.getParameter(name);
        if (value != null) {
            return value;
        }
        // 从额外参数中获取
        String[] values = extraParams.get(name);
        return (values != null && values.length > 0) ? values[0] : null;
    }

    /**
     * 重写 getParameterValues 方法
     */
    @Override
    public String[] getParameterValues(String name) {
        String[] values = super.getParameterValues(name);
        if (values != null) {
            return values;
        }
        return extraParams.get(name);
    }

    /**
     * 重写 getParameterMap 方法
     * 将额外参数合并到原始参数 Map 中
     */
    @Override
    public Map<String, String[]> getParameterMap() {
        Map<String, String[]> originalMap = super.getParameterMap();
        Map<String, String[]> mergedMap = new HashMap<>(originalMap);
        mergedMap.putAll(extraParams);
        return Collections.unmodifiableMap(mergedMap);
    }

    /**
     * 获取当前请求的用户ID
     */
    public String getUserId() {
        return userId;
    }

    /**
     * 获取当前请求的访问令牌
     */
    public String getAccessToken() {
        return accessToken;
    }
}

TokenRequestWrapper 的设计体现了装饰器模式(Decorator Pattern)的思想:它不修改原始的 HttpServletRequest 对象,而是通过包装的方式在其基础上增加了 Token 信息的注入能力。这种设计使得 Controller 的代码可以保持简洁——它只需要调用 request.getParameter("userId") 就能获取到用户ID,无需关心这个值是从 URL 参数中来的,还是从 Filter 注入的。


七、静态资源管理

7.1 静态资源目录结构

在 Spring Boot + Thymeleaf 的架构下,静态资源默认存放在 classpath:/static/ 目录下。smart-scaffold 项目的静态资源目录结构如下:

教学示例——静态资源目录结构:

static/
├── layui/                         # Layui 框架文件
│   ├── css/
│   │   └── layui.css              # Layui 核心样式
│   ├── layui.js                   # Layui 核心脚本
│   └── modules/                   # Layui 模块文件
│       ├── table.js
│       ├── form.js
│       ├── layer.js
│       ├── laydate.js
│       └── ...
├── css/                           # 自定义样式文件
│   ├── global.css                 # 全局样式
│   ├── index.css                  # 首页样式
│   ├── login.css                  # 登录页样式
│   ├── ai/
│   │   ├── chat.css               # AI 对话页样式
│   │   ├── chat-stream.css        # 流式对话页样式
│   │   ├── writing.css            # 写作页样式
│   │   └── prompt.css             # 提示词管理页样式
│   └── middleware/
│       ├── redis.css              # Redis 管理页样式
│       ├── kafka.css              # Kafka 管理页样式
│       └── ...
├── js/                            # JavaScript 文件
│   ├── global.js                  # 全局脚本(Token 管理、AJAX 封装)
│   ├── login.js                   # 登录页脚本
│   ├── ai/
│   │   ├── chat.js                # AI 对话页脚本
│   │   ├── chat-stream.js         # 流式对话页脚本
│   │   ├── writing.js             # 写作页脚本
│   │   └── prompt.js              # 提示词管理页脚本
│   └── middleware/
│       ├── redis.js               # Redis 管理页脚本
│       ├── kafka.js               # Kafka 管理页脚本
│       └── ...
├── images/                        # 图片资源
│   ├── logo.png                   # 系统标志
│   ├── favicon.ico                # 网站图标
│   └── ...
└── fonts/                         # 字体文件
    └── ...

7.2 Spring Boot 静态资源映射配置

Spring Boot 默认的静态资源映射规则如下:

路径映射位置
/static/**classpath:/static/
/public/**classpath:/public/
/resources/**classpath:/resources/
/META-INF/resources/**classpath:/META-INF/resources/
/**以上所有位置(按顺序查找)

在 smart-scaffold 项目中,我们使用了默认的静态资源映射规则,同时通过配置文件进行了一些自定义调整。

教学示例——静态资源映射配置:

yaml
# 教学示例:application.yml 中的静态资源配置
spring:
  web:
    resources:
      # 静态资源位置
      static-locations:
        - classpath:/static/
        - classpath:/public/
      # 静态资源缓存策略(生产环境)
      cache:
        period: 365d
  mvc:
    # 静态资源路径模式
    static-path-pattern: /**

7.3 WebJar vs 本地静态资源

在 Java Web 开发中,前端库的引入方式主要有两种:WebJar 和本地静态资源。

WebJar 方式是将前端库打包为 JAR 文件,通过 Maven 依赖管理。这种方式的好处是版本管理方便,不会与项目的静态资源混在一起。但缺点是前端库的文件结构被重新组织,引用路径可能不够直观。

教学示例——WebJar 方式引入 Layui:

xml
<!-- 教学示例:WebJar 方式引入 Layui(假设存在 WebJar 包) -->
<dependency>
    <groupId>org.webjars</groupId>
    <artifactId>layui</artifactId>
    <version>2.13.5</version>
</dependency>
html
<!-- 教学示例:WebJar 方式引用 Layui -->
<link rel="stylesheet" th:href="@{/webjars/layui/2.13.5/css/layui.css}" />
<script th:src="@{/webjars/layui/2.13.5/layui.js}"></script>

本地静态资源方式是将前端库的文件直接放在项目的 static/ 目录下。这种方式的好处是文件结构清晰、引用路径直观、可以方便地自定义修改前端库的源码。缺点是需要手动管理版本更新。

教学示例——本地方式引入 Layui:

html
<!-- 教学示例:本地方式引用 Layui -->
<link rel="stylesheet" th:href="@{/layui/css/layui.css}" />
<script th:src="@{/layui/layui.js}"></script>

在 smart-scaffold 项目中,我们选择了本地静态资源方式来管理 Layui 框架文件。主要基于以下考虑:

(1)定制化需求。 项目对 Layui 的一些组件进行了样式定制和功能扩展,需要直接修改 Layui 的源码文件。使用 WebJar 方式时,修改 JAR 包中的文件非常不方便。

(2)离线部署。 项目可能需要部署在内网环境中,无法访问 Maven 中央仓库。使用本地静态资源方式可以确保所有依赖文件都包含在项目包中。

(3)版本稳定性。 Layui 的版本更新相对频繁,但 API 变化也较大。使用本地静态资源方式可以锁定特定版本,避免自动升级带来的兼容性问题。

(4)构建简单。 不需要额外的 WebJar 打包和发布流程,直接将文件放入 static/ 目录即可使用。

7.4 global.js 的架构角色

global.js 在 smart-scaffold 项目的前端架构中扮演着"桥梁"的角色——它连接了服务端渲染的 Thymeleaf 模板和客户端的 Layui 组件,是前后端数据流通的枢纽。

global.js 的核心职责:

(1)Token 生命周期管理。 接收 Thymeleaf 注入的 Token 信息,提供统一的获取和更新接口,处理 Token 过期和刷新逻辑。

(2)AJAX 请求封装。 封装 fetch API,自动添加 Token 到请求头,统一处理响应格式、错误处理和 Token 刷新。

(3)URL 构建工具。 提供带 Token 参数的 URL 构建方法,确保页面跳转时 Token 信息不会丢失。

(4)Layui 模块集成。 注册为 Layui 模块,与 Layui 的模块化体系无缝集成。

(5)公共工具方法。 提供日期格式化、字符串处理、数据校验等常用的工具方法。

教学示例——global.js 在页面中的使用模式:

html
<!-- 教学示例:典型的页面脚本加载顺序 -->
<!-- 1. Layui 核心库 -->
<script th:src="@{/layui/layui.js}"></script>

<!-- 2. Thymeleaf 注入全局配置 -->
<script th:inline="javascript">
    window.__APP_CONFIG__ = {
        userId: /*[[${userId}]]*/ '',
        userName: /*[[${userName}]]*/ '',
        accessToken: /*[[${accessToken}]]*/ '',
        refreshToken: /*[[${refreshToken}]]*/ '',
        apiBaseUrl: /*[[@{/}]]*/ ''
    };
</script>

<!-- 3. 全局脚本(读取 __APP_CONFIG__ 并初始化) -->
<script th:src="@{/js/global.js}"></script>

<!-- 4. 页面级脚本(使用 global 模块) -->
<script>
    layui.use(['table', 'global', 'form'], function() {
        var table = layui.table;
        var global = layui.global;
        var form = layui.form;

        // 使用 global.ajax 发起请求
        global.ajax.get(global.getApiUrl('api/data/list'), {
            keyword: '',
            status: ''
        }).then(function(res) {
            // 处理响应数据
        });

        // 使用 global.config.buildUrl 构建跳转链接
        var editUrl = global.config.buildUrl('/user/edit');
    });
</script>

这个加载顺序非常重要:Layui 核心库必须最先加载,然后是 Thymeleaf 注入的全局配置,接着是 global.js(它会读取 window.__APP_CONFIG__ 并初始化内部状态),最后是页面级脚本(它依赖 global.js 提供的功能)。


八、生产环境前端优化

8.1 模板缓存配置

Thymeleaf 的模板缓存是生产环境中最基础也是最重要的优化手段。在开发环境中,模板缓存默认是关闭的(spring.thymeleaf.cache=false),这样每次修改模板文件后刷新页面就能看到最新的效果。但在生产环境中,应该开启模板缓存,避免每次请求都重新解析模板文件。

教学示例——生产环境的 Thymeleaf 缓存配置:

yaml
# 教学示例:application-prod.yml 中的 Thymeleaf 缓存配置
spring:
  thymeleaf:
    cache: true          # 开启模板缓存
    encoding: UTF-8
    mode: HTML

教学示例——通过 Spring Profile 区分开发和生产环境:

yaml
# 教学示例:application.yml(通用配置)
spring:
  thymeleaf:
    encoding: UTF-8
    mode: HTML
    prefix: classpath:/templates/
    suffix: .html

---
# 教学示例:application-dev.yml(开发环境)
spring:
  thymeleaf:
    cache: false         # 关闭缓存,方便开发调试
  devtools:
    restart:
      enabled: true      # 开启热重载

---
# 教学示例:application-prod.yml(生产环境)
spring:
  thymeleaf:
    cache: true          # 开启缓存,提升性能

模板缓存开启后,Thymeleaf 会在首次渲染时将解析后的模板缓存到内存中。后续的请求直接使用缓存中的模板,避免了重复的文件读取和模板解析操作。根据实际测试,开启模板缓存后,页面渲染的性能可以提升 5-10 倍。

除了 Thymeleaf 的模板缓存外,还可以通过以下方式进一步优化模板渲染性能:

(1)减少模板嵌套层级。 过深的模板嵌套(如 layout → header → sidebar → menu)会增加模板解析的时间。建议将嵌套层级控制在 3-4 层以内。

(2)避免在模板中执行复杂的计算逻辑。 模板引擎的表达式求值能力有限,复杂的计算应该在后端完成,将结果传递到模板中。

(3)合理使用 th:removeth:utext th:utext 不会对输出进行 HTML 转义,存在 XSS 风险,应谨慎使用。th:remove 用于在渲染时移除某些 HTML 元素,可以用于移除开发调试用的占位内容。

8.2 静态资源版本控制

在服务端渲染架构下,静态资源(CSS、JS、图片等)的版本控制是一个容易被忽视但非常重要的问题。当静态资源文件的内容发生变化时,如果浏览器缓存了旧版本的文件,用户可能会看到错误的页面样式或遇到 JavaScript 错误。

解决方案是为静态资源的 URL 添加版本号参数。 当文件内容发生变化时,更新版本号参数,强制浏览器重新下载最新的文件。

教学示例——基于文件内容的版本号方案:

java
// 教学示例:静态资源版本号配置类
@Configuration
public class ResourceVersionConfig {

    @Value("${resource.version:1.0.0}")
    private String resourceVersion;

    @Bean
    public ResourceUrlEncodingFilter resourceUrlEncodingFilter() {
        return new ResourceUrlEncodingFilter();
    }

    /**
     * 将资源版本号添加到 Model 中
     * 通过 Thymeleaf 的链接表达式自动附加版本号
     */
    @ControllerAdvice
    public class ResourceVersionAdvice {

        @ModelAttribute("v")
        public String getResourceVersion() {
            return resourceVersion;
        }
    }
}

教学示例——在 Thymeleaf 模板中使用版本号:

html
<!-- 教学示例:静态资源引用时附加版本号 -->
<link rel="stylesheet" th:href="@{/css/global.css(v=${v})}" />
<script th:src="@{/js/global.js(v=${v})}"></script>
<script th:src="@{/layui/layui.js(v=${v})}"></script>

渲染后的 HTML 输出:

html
<link rel="stylesheet" href="/css/global.css?v=1.0.0" />
<script src="/js/global.js?v=1.0.0"></script>
<script src="/layui/layui.js?v=1.0.0"></script>

当需要更新静态资源时,只需要修改 resource.version 配置值(如改为 1.0.1),所有静态资源的 URL 都会自动更新版本号参数,浏览器会重新下载最新的文件。

更高级的方案是基于文件内容的 Hash 值作为版本号。 这种方案可以精确到文件级别——只有内容发生变化的文件才会更新版本号,其他文件的缓存不受影响。

教学示例——基于文件 Hash 的版本号方案:

java
// 教学示例:基于文件内容的 Hash 版本号工具类
@Component
public class ResourceHashVersion {

    private static final Map<String, String> hashCache = new ConcurrentHashMap<>();

    /**
     * 计算静态资源文件的内容 Hash 值
     */
    public String getVersion(String resourcePath) {
        return hashCache.computeIfAbsent(resourcePath, path -> {
            try {
                ClassPathResource resource = new ClassPathResource("static/" + path);
                if (!resource.exists()) {
                    return "unknown";
                }
                byte[] content = FileCopyUtils.copyToByteArray(resource.getInputStream());
                MessageDigest md = MessageDigest.getInstance("MD5");
                byte[] hash = md.digest(content);
                return DigestUtils.md5DigestAsHex(content).substring(0, 8);
            } catch (Exception e) {
                return "error";
            }
        });
    }
}

8.3 CDN 部署策略

对于面向公众的 Web 应用,使用 CDN(Content Delivery Network)分发静态资源可以显著提升用户的访问速度。对于企业内部的管理后台,CDN 的价值相对有限,但在以下场景中仍然值得考虑:

  • 企业有多个办公地点,分布在不同的城市或国家
  • 静态资源文件较大(如 Layui 的完整包、字体文件、图片资源等)
  • 系统用户量较大,服务器带宽成为瓶颈

教学示例——CDN 部署的配置方案:

yaml
# 教学示例:application-prod.yml 中的 CDN 配置
app:
  cdn:
    enabled: true
    domain: https://cdn.example.com
    version: 1.0.0
java
// 教学示例:CDN URL 构建工具类
@Component
@ConditionalOnProperty(prefix = "app.cdn", name = "enabled", havingValue = "true")
public class CdnUrlHelper {

    @Value("${app.cdn.domain}")
    private String cdnDomain;

    @Value("${app.cdn.version}")
    private String cdnVersion;

    /**
     * 构建 CDN 资源 URL
     */
    public String buildCdnUrl(String resourcePath) {
        return cdnDomain + "/" + resourcePath + "?v=" + cdnVersion;
    }
}

教学示例——在 Thymeleaf 模板中根据环境切换 CDN 和本地资源:

html
<!-- 教学示例:环境感知的静态资源引用 -->
<!-- 开发环境:使用本地资源 -->
<!-- 生产环境:使用 CDN 资源 -->
<link rel="stylesheet"
      th:href="${@cdnUrlHelper != null && @cdnUrlHelper.enabled}
          ? ${@cdnUrlHelper.buildCdnUrl('layui/css/layui.css')}
          : @{/layui/css/layui.css(v=${v})}" />

在实际项目中,更推荐的做法是通过 Nginx 反向代理来统一处理静态资源的分发策略。Nginx 可以根据配置决定是从本地文件系统提供静态资源,还是从 CDN 获取。

教学示例——Nginx 静态资源代理配置:

nginx
# 教学示例:Nginx 静态资源代理配置
server {
    listen 80;
    server_name example.com;

    # 静态资源缓存配置
    location ~* \.(css|js|png|jpg|jpeg|gif|ico|woff|woff2|ttf|svg)$ {
        # 缓存过期时间
        expires 30d;
        # 启用 gzip 压缩
        gzip on;
        gzip_types text/css application/javascript image/svg+xml;
        # 如果使用 CDN,可以配置 proxy_pass 到 CDN 域名
        # proxy_pass https://cdn.example.com;
        # 否则直接从本地文件系统提供
        root /var/www/static;
        # 添加缓存控制头
        add_header Cache-Control "public, immutable";
    }

    # 其他请求转发到 Spring Boot 应用
    location / {
        proxy_pass http://localhost:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

8.4 安全头部配置

安全头部(Security Headers)是 Web 应用安全防护的第一道防线。通过配置正确的 HTTP 响应头,可以有效防范 XSS 攻击、点击劫持、MIME 类型嗅探等常见的安全威胁。

教学示例——通过 Spring Security 配置安全头部:

java
// 教学示例:安全头部配置
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            // 配置安全头部
            .headers(headers -> headers
                // X-Content-Type-Options: 防止 MIME 类型嗅探
                .contentTypeOptions(Customizer.withDefaults())
                // X-Frame-Options: 防止点击劫持
                .frameOptions(frame -> frame.sameOrigin())
                // X-XSS-Protection: 启用浏览器内置的 XSS 过滤器
                .xssProtection(xss -> xss.headerValue(XXssProtectionHeaderWriter.HeaderValue.ENABLED_MODE_BLOCK))
                // Content-Security-Policy: 内容安全策略
                .contentSecurityPolicy(csp -> csp
                    .policyDirectives(buildCspPolicy())
                )
                // Strict-Transport-Security: 强制使用 HTTPS
                .httpStrictTransportSecurity(hsts -> hsts
                    .includeSubDomains(true)
                    .maxAgeInSeconds(31536000)
                )
            );

        return http.build();
    }

    /**
     * 构建 Content-Security-Policy
     * 限制页面可以加载的资源来源
     */
    private String buildCspPolicy() {
        return "default-src 'self'; "
             + "script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
             + "style-src 'self' 'unsafe-inline'; "
             + "img-src 'self' data: https:; "
             + "font-src 'self'; "
             + "connect-src 'self'; "
             + "frame-src 'self'; "
             + "base-uri 'self'; "
             + "form-action 'self'";
    }
}

教学示例——通过 Filter 配置安全头部(不使用 Spring Security 时):

java
// 教学示例:通过 Filter 添加安全头部
@Component
@WebFilter(urlPatterns = "/*")
public class SecurityHeadersFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request,
                         ServletResponse response,
                         FilterChain chain) throws IOException, ServletException {

        HttpServletResponse httpResponse = (HttpServletResponse) response;

        // X-Content-Type-Options: 防止 MIME 类型嗅探
        httpResponse.setHeader("X-Content-Type-Options", "nosniff");

        // X-Frame-Options: 防止页面被嵌入到 iframe 中(防止点击劫持)
        httpResponse.setHeader("X-Frame-Options", "SAMEORIGIN");

        // X-XSS-Protection: 启用浏览器 XSS 过滤器
        httpResponse.setHeader("X-XSS-Protection", "1; mode=block");

        // Referrer-Policy: 控制 Referer 头的发送策略
        httpResponse.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");

        // Permissions-Policy: 控制浏览器功能的访问权限
        httpResponse.setHeader("Permissions-Policy",
            "geolocation=(), microphone=(), camera=()");

        // Cache-Control: 防止敏感页面被缓存
        // 注意:这里只对 HTML 页面设置 no-cache,静态资源由 Nginx 处理
        String contentType = httpResponse.getContentType();
        if (contentType != null && contentType.contains("text/html")) {
            httpResponse.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
            httpResponse.setHeader("Pragma", "no-cache");
        }

        chain.doFilter(request, response);
    }
}

在 smart-scaffold 项目中,Content-Security-Policy(CSP)的配置需要特别注意。由于 Layui 使用了 eval() 函数(在模板编译和动态脚本执行中),CSP 的 script-src 指令需要包含 'unsafe-inline''unsafe-eval'。虽然这会降低 CSP 的安全性,但在使用 Layui 框架的情况下是不可避免的。如果安全要求较高,可以考虑将 Layui 替换为不使用 eval() 的框架,或者通过 CSP 的 nonce 机制来精细化控制脚本执行权限。

8.5 Gzip 压缩与资源合并

Gzip 压缩是减少网络传输数据量的有效手段。HTML、CSS、JavaScript 等文本文件的压缩率通常可以达到 60-80%,这意味着开启 Gzip 压缩后,页面加载时间可以显著缩短。

教学示例——Spring Boot 内置的 Gzip 压缩配置:

yaml
# 教学示例:application.yml 中的 Gzip 压缩配置
server:
  compression:
    enabled: true                    # 开启响应压缩
    mime-types:                      # 需要压缩的 MIME 类型
      - text/html
      - text/css
      - application/javascript
      - application/json
      - text/xml
      - application/xml
      - text/plain
    min-response-size: 1024          # 最小压缩阈值(字节)

资源合并是另一种减少 HTTP 请求数量的优化手段。在传统的 Web 开发中,每个 CSS 和 JS 文件都需要一个单独的 HTTP 请求。通过将多个文件合并为一个文件,可以减少 HTTP 请求的数量,从而缩短页面加载时间。

在 smart-scaffold 项目中,由于使用了 Layui 的模块化加载机制(layui.use()),JavaScript 文件是按需加载的,不需要手动合并。CSS 文件方面,建议将全局样式合并为一个 global.css 文件,页面级样式保持独立,以便按需加载。

教学示例——页面中的资源引用优化:

html
<!-- 教学示例:优化后的页面资源引用 -->
<head>
    <!-- 合并后的全局 CSS -->
    <link rel="stylesheet" th:href="@{/css/global.css(v=${v})}" />
    <!-- Layui CSS -->
    <link rel="stylesheet" th:href="@{/layui/css/layui.css(v=${v})}" />
    <!-- 页面级 CSS(按需加载) -->
    <link rel="stylesheet" th:href="@{/css/ai/chat.css(v=${v})}" />
</head>
<body>
    <!-- 页面内容 -->

    <!-- Layui 核心 JS(按需加载模块) -->
    <script th:src="@{/layui/layui.js(v=${v})}"></script>
    <!-- 全局配置 -->
    <script th:inline="javascript">
        window.__APP_CONFIG__ = { /* ... */ };
    </script>
    <!-- 全局脚本 -->
    <script th:src="@{/js/global.js(v=${v})}"></script>
    <!-- 页面脚本 -->
    <script th:src="@{/js/ai/chat.js(v=${v})}"></script>
</body>

8.6 前端性能监控

在生产环境中,持续监控前端性能指标是保障用户体验的重要手段。smart-scaffold 项目可以通过以下方式实现前端性能监控:

教学示例——基于 Navigation Timing API 的性能数据采集:

javascript
// 教学示例:前端性能数据采集脚本
(function() {
    // 等待页面完全加载后采集性能数据
    window.addEventListener('load', function() {
        setTimeout(function() {
            var timing = window.performance.timing;
            var navigation = window.performance.navigation;

            var perfData = {
                // DNS 解析时间
                dnsTime: timing.domainLookupEnd - timing.domainLookupStart,
                // TCP 连接时间
                tcpTime: timing.connectEnd - timing.connectStart,
                // 首字节时间(TTFB)
                ttfb: timing.responseStart - timing.requestStart,
                // DOM 解析时间
                domParseTime: timing.domInteractive - timing.responseEnd,
                // 页面完全加载时间
                pageLoadTime: timing.loadEventEnd - timing.navigationStart,
                // 页面类型
                pageType: 'ssr',
                // URL
                url: window.location.href
            };

            // 上报性能数据
            if (navigator.sendBeacon) {
                navigator.sendBeacon(
                    '/api/performance/report',
                    JSON.stringify(perfData)
                );
            }
        }, 0);
    });
})();

九、实战经验与最佳实践

9.1 Thymeleaf 模板开发的常见陷阱

在实际开发中,Thymeleaf 有一些容易踩的"坑",了解这些陷阱可以帮助开发者避免不必要的调试时间。

陷阱一:th:text 会转义 HTML 标签。

th:text 默认会对输出内容进行 HTML 转义。如果需要输出原始的 HTML 内容,应该使用 th:utext(unescaped text)。但要注意,th:utext 存在 XSS 风险,必须确保输出的内容是可信的。

html
<!-- 教学示例:th:text vs th:utext -->
<!-- th:text 会转义 HTML 标签 -->
<div th:text="${content}">&lt;p&gt;Hello&lt;/p&gt;</div>
<!-- 输出:<p>Hello</p>(显示为文本)

<!-- th:utext 不转义 HTML 标签 -->
<div th:utext="${content}"><p>Hello</p></div>
<!-- 输出:<p>Hello</p>(渲染为 HTML)

陷阱二:th:each 的循环状态变量。

th:each 除了提供当前迭代元素外,还提供了一个状态变量,可以获取当前迭代的索引、总数、是否为第一个/最后一个等信息。

html
<!-- 教学示例:th:each 的状态变量 -->
<tr th:each="item, iterStat : ${items}"
    th:class="${iterStat.odd} ? 'row-odd' : 'row-even'">
    <td th:text="${iterStat.index + 1}">1</td>
    <td th:text="${item.name}">名称</td>
    <td th:text="${item.value}">值</td>
</tr>

陷阱三:@{...} 链接表达式的参数编码。

@{...} 链接表达式会自动对参数值进行 URL 编码。如果参数值中包含特殊字符(如中文、空格等),不需要手动编码。

html
<!-- 教学示例:链接表达式的自动编码 -->
<a th:href="@{/search(keyword=${keyword})}">搜索</a>
<!-- 如果 keyword 的值是"中文搜索",输出:/search?keyword=%E4%B8%AD%E6%96%87%E6%90%9C%E7%B4%A2 -->

陷阱四:th:ifth:unless 的条件判断。

th:if 在条件为 true 时渲染元素,th:unless 在条件为 false 时渲染元素。注意,Thymeleaf 的条件判断遵循 Java 的真值规则——nullfalse0、空字符串都被视为 false

html
<!-- 教学示例:th:if 的条件判断 -->
<div th:if="${user != null and user.name != null and !user.name.isEmpty()}">
    <span th:text="${user.name}">用户名</span>
</div>

陷阱五:内联 JavaScript 中的特殊字符处理。

th:inline="javascript" 中使用 /*[[${variable}]]*/ 语法时,Thymeleaf 会自动处理字符串的引号转义。但如果变量的值中包含 </script> 标签,可能会导致 XSS 攻击。因此,在将用户输入的数据注入到 JavaScript 中时,应该进行额外的校验和清理。

9.2 Layui 与 Thymeleaf 集成的注意事项

(1)Layui 表格的异步数据加载与 Thymeleaf 的数据渲染是互补的。 Thymeleaf 负责渲染页面的静态结构(如搜索表单、按钮、表格容器),Layui 负责异步加载表格数据。不要试图用 Thymeleaf 来渲染大量的表格数据——这不仅会增加服务器的渲染负担,还会导致页面加载缓慢。

(2)Layui 的表单验证与后端校验应该同时存在。 前端校验(Layui 的 lay-verify)可以提供即时的用户反馈,但后端校验是安全的最后防线。永远不要信任客户端提交的数据。

(3)Layui 的弹层组件(layer)在 iframe 模式下有一些特殊行为。 当使用 layer.open({ type: 2 }) 打开 iframe 弹窗时,弹窗内的页面是一个独立的 HTML 文档。如果需要在弹窗和父页面之间传递数据,需要通过 parent.layerwindow.parent 进行跨文档通信。

(4)Layui 的模块加载是异步的。 使用 layui.use() 加载模块时,回调函数在模块加载完成后才会执行。不要在 layui.use() 的外部使用模块内的方法。

(5)Thymeleaf 的 th:href 和 Layui 的事件绑定可能产生冲突。 当使用 Thymeleaf 动态生成带有事件绑定的 HTML 元素时,确保 Layui 的事件绑定代码在 DOM 渲染完成后执行。

9.3 Token 安全最佳实践

(1)使用 HTTPS。 这是最基本也是最重要的安全措施。HTTPS 可以防止 Token 在网络传输中被窃听和篡改。

(2)设置合理的 Token 过期时间。 AccessToken 的过期时间建议设置为 15-30 分钟,RefreshToken 的过期时间可以设置为 7-30 天。短期 AccessToken 即使泄露,攻击窗口也很小。

(3)Token 不应包含敏感信息。 AccessToken 应该是一个不透明的随机字符串(或 JWT),不应包含用户的密码、身份证号等敏感信息。

(4)服务端日志脱敏。 确保服务端的访问日志不会记录 URL 中的 Token 参数。可以通过配置日志格式或使用 Filter 对日志进行脱敏处理。

(5)前端 Token 存储。 在服务端渲染架构下,Token 主要通过 URL 参数传递。不建议将 Token 存储在 localStorage 中——如果存在 XSS 漏洞,攻击者可以通过 JavaScript 读取 localStorage 中的 Token。

9.4 项目结构化建议

随着项目规模的增大,模板文件、静态资源文件和页面控制器代码的组织方式会直接影响开发和维护效率。以下是一些基于实际项目经验的结构化建议:

(1)模板文件按功能模块分组。 如本文第三章所述,每个功能模块拥有独立的模板目录。当模块数量增多时,可以考虑在 templates/ 下增加一级分类目录(如 templates/biz/templates/system/templates/monitor/)。

(2)公共模板片段提取到 common/ 目录。 任何在两个或两个以上页面中使用的 HTML 片段,都应该提取为公共模板片段。这包括页面布局、头部导航、侧边栏、分页组件、面包屑导航等。

(3)JavaScript 文件与模板文件保持相同的目录结构。 如果模板文件在 templates/ai/chat/index.html,那么对应的 JavaScript 文件应该在 static/js/ai/chat.js。这种对应关系使得开发者可以快速定位页面相关的前端代码。

(4)CSS 文件按功能模块组织。 全局样式放在 static/css/global.css,模块级样式放在对应的子目录中(如 static/css/ai/chat.css)。避免将所有样式写在一个巨大的 CSS 文件中。

(5)页面控制器按功能模块拆分。 不要将所有页面的控制器方法写在一个巨大的 Controller 类中。按功能模块拆分为多个 Controller(如 AiPageControllerMiddlewarePageControllerLoginPageController),每个 Controller 只负责自己模块的页面跳转。


十、架构演进与扩展思路

10.1 从服务端渲染到渐进式增强

虽然 smart-scaffold 项目当前采用纯服务端渲染架构,但这并不意味着未来不能引入前端框架的某些优势。一种可行的演进路径是"渐进式增强"——在保持服务端渲染主体架构不变的前提下,在特定的交互密集型页面中引入 Vue.js 或 Alpine.js 等轻量级前端框架。

Alpine.js 是一个特别适合这种场景的前端框架。它的语法简洁(直接在 HTML 标签上使用 x-datax-ifx-for 等属性),学习成本低,与 Thymeleaf 的设计理念高度契合。Alpine.js 可以在 Thymeleaf 渲染的 HTML 基础上,为特定的 DOM 元素添加响应式的交互行为。

教学示例——Thymeleaf + Alpine.js 的混合使用:

html
<!-- 教学示例:Thymeleaf 渲染静态数据,Alpine.js 处理交互 -->
<div x-data="{ showDetail: false, selectedItem: null }">
    <!-- Thymeleaf 渲染列表 -->
    <table>
        <tr th:each="item : ${items}">
            <td th:text="${item.name}">名称</td>
            <td th:text="${item.status}">状态</td>
            <td>
                <!-- Alpine.js 处理点击事件 -->
                <button @click="showDetail = true; selectedItem = ${item.id}">
                    查看详情
                </button>
            </td>
        </tr>
    </table>

    <!-- Alpine.js 控制详情面板的显示/隐藏 -->
    <div x-show="showDetail" x-transition>
        <div id="detailContent">
            <!-- 通过 AJAX 加载详情数据 -->
        </div>
        <button @click="showDetail = false">关闭</button>
    </div>
</div>

10.2 组件化模板的探索

Thymeleaf 的模板片段机制虽然可以实现一定程度的组件化,但与 Vue/React 的组件系统相比,在状态管理、事件通信和生命周期管理方面存在明显的不足。如果项目需要更高级的组件化能力,可以考虑以下方案:

(1)Thymeleaf Layout Dialect + 自定义方言。 通过实现 Thymeleaf 的自定义方言(Dialect),可以创建自定义的标签和属性,实现更高级的组件化能力。

(2)Web Components。 使用浏览器原生的 Web Components 技术(Custom Elements、Shadow DOM、HTML Templates)创建可复用的 UI 组件。Web Components 与框架无关,可以与 Thymeleaf 无缝集成。

(3)HTMX。 HTMX 是一个新兴的前端库,它允许通过 HTML 属性直接发起 AJAX 请求并更新 DOM,无需编写 JavaScript。HTMX 的设计理念与服务端渲染高度契合——它将交互逻辑从 JavaScript 移回到了 HTML 中。

10.3 微前端架构的思考

对于大型企业级应用,可能需要将不同的功能模块拆分为独立的子应用,每个子应用可以由不同的团队独立开发和部署。微前端架构(Micro Frontends)是实现这一目标的技术方案。

在服务端渲染架构下,微前端的实现方式与 SPA 架构有所不同。一种可行的方案是使用 Nginx 的反向代理功能,将不同的 URL 路径路由到不同的 Thymeleaf 应用实例。每个应用实例拥有独立的模板文件、静态资源和后端服务,但共享统一的登录认证和布局框架。

教学示例——基于 Nginx 的微前端路由配置:

nginx
# 教学示例:Nginx 微前端路由配置
server {
    listen 80;
    server_name app.example.com;

    # 主应用(布局框架、首页、登录)
    location / {
        proxy_pass http://localhost:8080;
    }

    # AI 模块(独立应用)
    location /ai/ {
        proxy_pass http://localhost:8081;
    }

    # 中间件模块(独立应用)
    location /middleware/ {
        proxy_pass http://localhost:8082;
    }

    # 静态资源
    location /static/ {
        proxy_pass http://localhost:8080;
        expires 30d;
    }
}

总结与展望

本文基于 smart-scaffold-springboot 项目的真实源码,系统地阐述了 Thymeleaf 模板架构设计与 Layui 前端框架集成的完整技术方案。我们从技术选型的决策分析出发,深入探讨了以下核心内容:

在模板引擎层面,我们分析了 Thymeleaf 相比 JSP 和 FreeMarker 的核心优势——"自然模板"设计理念带来的开发体验提升,以及与 Spring Boot 的深度集成带来的配置简化。通过详细的语法讲解和教学示例,展示了 Thymeleaf 在变量表达式、条件渲染、循环遍历、模板布局等方面的强大能力。

在模板架构层面,我们设计了按功能模块组织的模板目录结构,实现了公共布局模板的复用机制,并详细展示了首页、AI 模块(对话/流式对话/写作/提示词管理)、中间件模块(Redis/MongoDB/Elasticsearch/Kafka/RabbitMQ/RocketMQ/MyBatis)和登录模块的模板设计。

在前端框架层面,我们深入剖析了 Layui v2.13.5 的核心组件(表格、表单、弹层、日期选择器)在管理后台中的应用场景,并展示了 Thymeleaf 与 Layui 的数据绑定方案——包括服务端渲染静态数据、JavaScript 内联传递配置数据和 Layui 表格异步加载数据三种方式。

在页面控制器层面,我们设计了 FrontController 的统一参数接收方案(userId/userName/accessToken/refreshToken),通过 addUserInfoToModel 方法实现用户信息到模板的统一传递,并展示了各模块页面控制器方法的完整实现。

在 Token 管理层面,我们设计了一套完整的三层 Token 传递方案:页面间通过 URL 参数传递、前后端通过 global.js 统一管理、服务端通过 OAuthFilter 实现三级获取(Header → Parameter → Session),并通过 TokenRequestWrapper 将 Token 信息注入到请求对象中。

在生产优化层面,我们覆盖了模板缓存配置、静态资源版本控制、CDN 部署策略、安全头部配置、Gzip 压缩和前端性能监控等关键优化手段。

展望未来,服务端渲染技术在企业级管理后台领域仍然具有强大的生命力。随着 HTMX、Alpine.js 等新兴技术的出现,服务端渲染正在获得更强的交互能力。smart-scaffold 项目也将持续演进,在保持服务端渲染架构简洁性的同时,逐步引入更先进的前端技术,为开发者提供更好的开发体验和更强的功能支持。


版权声明: 本文为必码(bima.cc)原创技术文章,仅供学习交流。

本文内容基于实际项目源码解析整理,代码示例均为教学简化版本,仅供学习参考。

文档内容提取自项目源码与配置文件,如需获取完整项目代码,请访问 bima.cc