SpringBoot+Thymeleaf+AdminLTE后台CRUD实战

发布时间:2026/10/23 13:26:25

SpringBoot+Thymeleaf+AdminLTE后台CRUD实战 做过后台管理系统的人大概都遇到过一个岔路口一个只有十来个人用的内部工具需求三天两头改前端要做的其实就是列表、表单、弹窗、删除确认这几件事这时候如果上来就搞前后端分离光是接口联调、跨域、权限透传、部署两套产物就能耗掉大半工期。我前阵子接手的一个运维台账系统就是这么个场景十来个页面二十多张表交付周期四周最后定下来的技术组合就是标题里这四样SpringBoot 打底、MyBatis-Plus 干数据层、Thymeleaf 渲染页面、AdminLTE 套壳。整套东西跑起来增删改查这类活儿的开发节奏会非常快一个标准模块从建表到页面能点熟练的话两三个小时就能出一版。这篇内容想聊的就是这套组合在真实项目里的落地细节不是官方文档的复述。适合的人大概有三类刚接触 SpringBoot 想做点完整东西练手的手上有个内部系统要快速交付、不想被前端工程化拖住的以及已经会用这套栈但总在分页、表单回显、静态资源这些地方反复卡壳的。我会把依赖版本的坑、MyBatis-Plus 分页插件的正确姿势、AdminLTE 接 Thymeleaf 的布局复用方式、还有增删改查四个动作里那些文档不会写的细节一条一条摊开讲。中间穿插的配置和代码都是可以直接抄去改的遇到需要取舍的地方我会说清楚为什么这么选。1. 先想清楚这套四件套各自的边界在哪1.1 服务端渲染在内部系统里的性价比账选型这件事最怕的是拿互联网 C 端的标准去套内部管理系统。C 端产品用户量大、交互复杂、首屏性能敏感前后端分离带来的收益能覆盖掉工程复杂度。但内部台账、审批、配置管理这类系统页面数量有限、交互模式高度重复、用户就那几十个首屏慢个三百毫秒没人在意反而更在意这个字段能不能加一列这个查询条件能不能多一个这种需求变更的速度。服务端渲染在这个场景下的优势很直接一个 Controller 方法配一个模板数据从数据库到页面是一条直线没有中间层。改一个字段动的是实体类、Mapper 不用动、模板加一行、Controller 加一个取值五分钟的事。换成前后端分离同样的改动要动 DTO、接口、前端 model、前端表单、可能还有类型定义五个文件起步还得等前端同学排期。代价也要说清楚。Thymeleaf 的模板语法是在 HTML 属性上做文章逻辑复杂了会很难看超过三层的条件嵌套基本就没法维护了局部刷新能力弱想要个下拉联动、表格无刷新编辑就得自己写零散的 JSSEO 完全谈不上不过内部系统也不需要。我的判断标准很简单如果这个系统的主要使用者是公司内部员工、页面总数在五十个以内、没有复杂的前端状态管理需求这套组合就是划算的如果要做的是面向外部用户的产品或者有大量实时交互那就老老实实上分离架构。1.2 四个组件各管一段别让职责串味实际写代码时最常见的混乱是把本该在 Service 层的逻辑塞进 Controller或者把查询条件拼装散落在模板里。这套栈里四样东西的职责其实很清晰组件负责的事不该干的事SpringBoot容器、依赖注入、配置、事务边界不写业务规则MyBatis-Plus单表 CRUD、条件构造、分页、逻辑删除不处理展示格式、不做权限判断Thymeleaf数据渲染、条件展示、循环、格式化不拼查询条件、不做业务计算AdminLTE页面骨架、样式、常用交互组件不承载业务逻辑一个典型的分层写法是Controller 只做参数接收、调用 Service、往 Model 里塞数据、返回视图名Service 管事务和业务规则比如用户名不能重复删除前要检查有没有关联数据Mapper 只管数据访问。Thymeleaf 模板里只做展示判断比如状态字段是 0 还是 1 决定显示什么颜色的标签。这个边界守住了后面加需求才不会牵一发动全身。1.3 什么时候该果断放弃这套方案我不太喜欢某某技术已死这种论调但确实有几类需求不适合硬套。一是需要大量图表实时刷新的看板Thymeleaf 渲染完就定死了后续全靠手写 JS 轮询不如直接上图表库配接口二是移动端适配要求高的场景AdminLTE 是桌面优先的布局虽然响应式做了但在手机上体验一般三是需要多人协同编辑同一份数据的场景服务端渲染那套表单提交模型处理不了冲突合并。碰到这三类趁早换方案别等到写了一半再返工。2. 骨架搭建依赖顺序和版本对齐比想象中重要2.1 pom 里的依赖清单版本别随手写新建工程的时候最容易出事的就是版本。SpringBoot 版本、MyBatis-Plus 版本、Thymeleaf 版本三者之间有隐性约束尤其是 MyBatis-Plus它跟 SpringBoot 的适配是按大版本对应的3.5.x 系列对 SpringBoot 2.x 和 3.x 的支持情况不一样。我的做法是先用 Spring Initializr 生成一个干净的骨架选定 SpringBoot 版本然后把 MyBatis-Plus 的版本号显式写上不依赖父 POM 的默认管理。dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-thymeleaf/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.5/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies这里有两个细节值得单独拎出来。第一spring-boot-starter-validation必须单独引入SpringBoot 2.3 之后校验相关的包已经从 web starter 里剥离了不引这个Valid注解会静默失效——注意是静默不报错表单提交什么校验都不触发很容易查半天。第二MySQL 驱动在较新的 SpringBoot 里坐标改成了com.mysql:mysql-connector-j老项目里常见的mysql:mysql-connector-java虽然还能用但会有告警新项目直接写新的。如果你用的是 SpringBoot 3.xMyBatis-Plus 的 starter 也要换成mybatis-plus-spring-boot3-starter这个坐标差异在官方文档里不算显眼但包名不一样直接复制 2.x 的配置会启动失败报找不到自动配置类。2.2 application.yml 里必须动的那几项配置文件这块默认值大多能用但有四项我每次都会改server: port: 8080 servlet: encoding: charset: UTF-8 force: true spring: thymeleaf: cache: false prefix: classpath:/templates/ suffix: .html mode: HTML datasource: url: jdbc:mysql://127.0.0.1:3306/demo_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: demo password: demo123 driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: auto logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 mapper-locations: classpath*:/mapper/**/*.xmlthymeleaf.cache在开发期一定要关掉这个后面还会展开讲它是最容易让人怀疑人生的一个配置。map-underscore-to-camel-case默认是 true但显式写出来能避免接手别人项目时的困惑。log-impl打开 SQL 打印调试期非常有用上线前记得关掉不然日志量会很吓人。serverTimezone这个参数不能省省了在写入LocalDateTime的时候可能差八小时而且是那种数据看着对、时间就是不对的诡异问题。数据源连接串里的allowPublicKeyRetrievaltrue是配合较新版本 MySQL 的认证插件用的不加的话在某些环境下会报公钥检索失败。这个参数涉及安全边界只在本地和内网开发环境用生产环境按各自的数据库规范走。3. MyBatis-Plus 数据层从实体映射到分页查询的完整链路3.1 实体类和表结构的映射约定MyBatis-Plus 默认有一套约定类名转下划线对应表名字段名转下划线对应列名。遵守约定能省掉大量注解。我在项目里通常就三条规则表名统一加业务前缀比如sys_user、biz_order主键统一叫id且自增每张表都带create_time、update_time、deleted三个公共字段。Data TableName(sys_user) public class SysUser { TableId(type IdType.AUTO) private Long id; private String username; private String realName; private String phone; private Integer status; TableField(fill FieldFill.INSERT) private LocalDateTime createTime; TableField(fill FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; TableLogic private Integer deleted; }TableLogic这个注解是逻辑删除的核心。加上它之后所有通过 MyBatis-Plus 生成的删除语句都会自动变成UPDATE ... SET deleted 1查询语句会自动追加deleted 0。这个机制的好处是业务代码里完全感知不到删除的实现方式坏处是有几个场景会失效手写 XML 里的 SQL 不会自动加条件需要自己补update语句也不会自动过滤已删除数据还有就是唯一索引要重新设计——如果username建了唯一索引删掉一个用户之后想再建同名用户就会冲突因为那条软删除的记录还在表里。软删除的唯一索引问题常见的解法是把deleted字段设计成删除时写入主键 ID而不是固定写 1这样联合唯一索引(username, deleted)就能保证只有一条未删除记录。不过这样配置就复杂一些小项目里我一般直接放弃用户名唯一索引改在 Service 层做查重判断简单够用。自动填充需要配一个处理器Component public class MetaHandler implements MetaObjectHandler { Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, createTime, LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, updateTime, LocalDateTime.class, LocalDateTime.now()); } Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, updateTime, LocalDateTime.class, LocalDateTime.now()); } }注意strictUpdateFill只对TableField(fill FieldFill.INSERT_UPDATE)标注的字段生效如果实体类漏了这个注解填充不会执行而且不会报错。3.2 分页插件必须先注册否则分页永远返回全量这是我见过最多人踩的坑。MyBatis-Plus 从 3.4 开始分页功能必须手动注册拦截器不注册的话Page对象传进去也不报错但total永远是 0、records永远是全表数据。很多人第一次遇到会以为是Page参数位置传错了查半天。Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(500L); pagination.setOverflow(false); interceptor.addInnerInterceptor(pagination); return interceptor; } }setMaxLimit(500L)是个保护措施防止前端传个size999999把数据库拖死。setOverflow(false)的意思是页码超出范围时返回空列表而不是回到第一页这个选择看业务习惯我倾向于 false因为回到第一页会让用户以为自己点错了。分页查询配合条件构造器的典型写法public IPageSysUser pageQuery(UserQuery query, long current, long size) { PageSysUser page new Page(current, size); LambdaQueryWrapperSysUser wrapper new LambdaQueryWrapper(); wrapper.like(StringUtils.hasText(query.getUsername()), SysUser::getUsername, query.getUsername()) .eq(query.getStatus() ! null, SysUser::getStatus, query.getStatus()) .ge(query.getBeginTime() ! null, SysUser::getCreateTime, query.getBeginTime()) .le(query.getEndTime() ! null, SysUser::getCreateTime, query.getEndTime()) .orderByDesc(SysUser::getId); return this.page(page, wrapper); }LambdaQueryWrapper比QueryWrapper好在字段名是方法引用改字段名的时候编译器会报错字符串写法的QueryWrapper只能等到运行时才发现列名写错了。条件构造器里每个方法都有个重载版本第一个参数是布尔值用来控制这个条件是否生效这个特性在写动态查询时极其好用能避免大量 if 判断。3.3 多条件搜索的拼接逻辑应该放在哪条件拼接的位置有两派做法放 Controller 里用参数逐个判断或者放 Service 里封装成一个方法。我倾向后者Controller 就负责把请求参数打包成一个查询对象传下去。这样做的另一个好处是查询参数可以做成一个独立的类字段上直接挂校验注解和日期格式注解。Data public class UserQuery { private String username; private Integer status; DateTimeFormat(pattern yyyy-MM-dd) private LocalDate beginTime; DateTimeFormat(pattern yyyy-MM-dd) private LocalDate endTime; }DateTimeFormat这个注解是给表单绑定用的处理的是从页面提交上来的字符串到日期对象的转换。如果是 JSON 接口用的是JsonFormat两个注解长得像但作用域完全不同混用会导致日期绑不上、抛 400 错误。这个区别我在带新人的时候解释过很多遍本质上就是数据来源不同表单走的是 Spring 的ConversionServiceJSON 走的是 Jackson。4. AdminLTE 接 Thymeleaf静态资源、布局复用和脚本顺序4.1 静态资源目录怎么摆路径怎么写才不打架AdminLTE 是一套基于 Bootstrap 的后台模板下载下来是个完整的前端工程里面有dist、plugins两个主要目录。放进 SpringBoot 项目的正确位置是src/main/resources/static/放进去之后通过/dist/css/adminlte.min.css这样的路径就能访问。目录结构我一般是这么组织的resources/ static/ dist/ AdminLTE 的 css、js plugins/ 各种插件fontawesome、jquery、bootstrap uploads/ 用户上传的图片运行时生成 templates/ layout/ 公共片段 user/ 业务页面 login.html有个细节很容易被忽略SpringBoot 的静态资源默认映射到/**如果模板里写的是link th:href{/dist/css/adminlte.min.css}加了{}语法之后SpringBoot 会自动处理上下文路径。如果你的应用部署在非根路径下比如server.servlet.context-path/admin不用{}写死的相对路径全部会 404。这个坑在本地开发时完全发现不了因为本地默认是根路径。上传目录的问题要单独说一下。static/uploads这个位置在打成 jar 包之后是只读的往里写文件会失败。生产环境应该把上传路径配成一个外部目录通过WebMvcConfigurer加资源映射Configuration public class WebConfig implements WebMvcConfigurer { Value(${app.upload-path}) private String uploadPath; Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/uploads/**) .addResourceLocations(file: uploadPath); } }这样页面里引用/uploads/xxx.png就能拿到外部目录里的文件开发和生产的路径差异用配置文件隔离。4.2 用 fragment 做布局复用别引额外的方言包Thymeleaf 复用页面的方式有好几种官方推荐的thymeleaf-layout-dialect用起来确实优雅但它多一个依赖语法也自成一套团队里如果新人多学习成本不太划算。我一般用原生的 fragment 组合来实现。先定义公共片段!-- templates/layout/common.html -- !DOCTYPE html html xmlns:thhttp://www.thymeleaf.org head th:fragmenthead(title) meta charsetutf-8 title th:text${title}页面标题/title link th:href{/plugins/fontawesome-free/css/all.min.css} relstylesheet link th:href{/dist/css/adminlte.min.css} relstylesheet /head body nav th:fragmentnavbar classmain-header navbar navbar-expand navbar-white navbar-light ul classnavbar-nav li classnav-item a classnav-link>!DOCTYPE html html xmlns:thhttp://www.thymeleaf.org head th:replace~{layout/common :: head(用户管理)}/head body classhold-transition sidebar-mini layout-fixed div classwrapper nav th:replace~{layout/common :: navbar}/nav aside th:replace~{layout/common :: sidebar}/aside div classcontent-wrapper section classcontent-header div classcontainer-fluid h1用户管理/h1 /div /section section classcontent div classcontainer-fluid !-- 业务内容 -- /div /section /div footer th:replace~{layout/common :: footer}/footer /div /body /htmlth:replace和th:insert的区别要说一下replace是用片段整体替换掉当前标签insert是把片段塞进当前标签内部。用replace的时候当前标签本身会被丢掉所以写法上要保证被替换的标签类型跟片段的根标签一致。侧边栏的菜单高亮靠当前路径判断a th:href{/user/list} classnav-link th:classappend${#httpServletRequest.requestURI.startsWith(/user)} ? active#httpServletRequest这个对象在 Thymeleaf 3 里默认是可用的不过在严格的安全配置下可能取不到取不到就退回用手动传一个activeMenu变量的方式稳一些。4.3 脚本加载顺序不对页面会静默失灵AdminLTE 依赖 jQuery、Bootstrap、popper这几个的顺序不能乱。正确的写法是 jQuery 在最前然后 bootstrap 的 bundle 版本自带 popper最后才是 adminlte 的主脚本script th:src{/plugins/jquery/jquery.min.js}/script script th:src{/plugins/bootstrap/js/bootstrap.bundle.min.js}/script script th:src{/dist/js/adminlte.min.js}/script顺序错了的典型现象是折叠菜单点不开、下拉框点一下闪一下就关、弹窗出不来。控制台可能只有一条$ is not defined或者干脆什么都没有。我遇到过最隐蔽的一次是有人把 adminlte.min.js 放到了head里页面骨架还没渲染完脚本就执行了界面看着正常但所有交互事件都没绑上。所以脚本统一放/body前这是硬规矩。还有个版本匹配的坑AdminLTE 3 基于 Bootstrap 4AdminLTE 4 基于 Bootstrap 5。如果从网上复制了个 AdminLTE 3 的页面模板又去引了 Bootstrap 5 的 CSS会出现间距全乱、圆角对不上、按钮颜色偏色这类看着像但又不太像的问题。这种情况我的建议是一旦确定用哪个版本把整套静态资源一次性拷全不要东拼西凑。5. 增删改查四个动作真正花时间的都在细节里5.1 列表页分页、搜索条件回填与参数保持列表页看着最简单实际上坑最多。分页组件要跟搜索条件共存翻到第二页的时候搜索条件不能丢这个是最基本的要求。我的做法是 Controller 接收查询对象和分页参数把所有条件重新塞回 ModelGetMapping(/list) public String list(UserQuery query, RequestParam(defaultValue 1) long current, RequestParam(defaultValue 10) long size, Model model) { IPageSysUser page userService.pageQuery(query, current, size); model.addAttribute(page, page); model.addAttribute(query, query); return user/list; }模板里的分页链接必须带上搜索条件nav ul classpagination pagination-sm li classpage-item th:classappend${page.current 1} ? disabled a classpage-link th:href{/user/list(current${page.current - 1}, size${page.size}, username${query.username}, status${query.status})}上一页/a /li li classpage-item th:eachi : ${#numbers.sequence(1, page.pages)} th:classappend${i page.current} ? active a classpage-link th:href{/user/list(current${i}, size${page.size}, username${query.username}, status${query.status})} th:text${i}1/a /li li classpage-item th:classappend${page.current page.pages} ? disabled a classpage-link th:href{/user/list(current${page.current 1}, size${page.size}, username${query.username}, status${query.status})}下一页/a /li /ul /nav{...}语法里括号内的参数会自动做 URL 编码不用手动处理中文。这是 Thymeleaf 一个很实用的特性用字符串拼接的方式拼 URL 反而容易出问题。页码直接用#numbers.sequence全展开在数据量小的内部系统里够用。如果分页超过二十页这个写法会把页码撑爆需要做滑动窗口那就得写个小工具方法或者在 Controller 里算好beginPage和endPage传给模板。内部系统我一般不管页面撑开了用户自己会缩小范围。搜索表单的回填也要注意th:value里如果原值是 null渲染出来是空字符串不会报错但下拉框要注意选中状态select namestatus classform-control option value全部状态/option option value1 th:selected${query.status 1}启用/option option value0 th:selected${query.status 0}停用/option /select这里有个类型陷阱query.status是Integer如果表单里value1Thymeleaf 在比较时做的是对象比较Integer(1) 1在 Thymeleaf 的表达式引擎里通常能自动拆箱成立但如果 status 是String类型就会失败。我在项目里统一用Integer接状态值避免这个歧义。5.2 新增和编辑表单复用、校验失败回显与查重时机新增和编辑的页面高度相似字段完全一样区别只是有没有初始值。复用同一个模板是标准做法用一个id是否为空来判断GetMapping(/form) public String form(RequestParam(required false) Long id, Model model) { SysUser user id null ? new SysUser() : userService.getById(id); model.addAttribute(user, user); return user/form; }模板里所有th:value都绑到user对象的字段上新增时是空对象自然是空值。保存用两个方法分别处理新增和修改不要试图用一个方法通用因为校验规则不一样新增要查重修改要排除自己。校验这块PostMapping(/save) public String save(Valid ModelAttribute(user) SysUser user, BindingResult bindingResult, RedirectAttributes ra) { if (userService.existsUsername(user.getUsername(), user.getId())) { bindingResult.rejectValue(username, duplicate, 该登录名已被占用); } if (bindingResult.hasErrors()) { return user/form; } userService.saveOrUpdate(user); ra.addFlashAttribute(msg, 保存成功); return redirect:/user/list; }ModelAttribute(user)里的名字很关键它决定了模板里用${user}能不能取到值。如果校验失败返回表单页Model 里必须有这个对象ModelAttribute会自动把它塞进去。这一点如果漏了表现就是校验失败跳回表单页结果所有已填内容全空用户得重新填一遍体验极差。RedirectAttributes的addFlashAttribute是重定向后传递提示消息的标准做法数据存在 session 里跳转后读一次就没了。如果用addAttribute参数会拼到 URL 上中文还得编码很难看。表单模板里的错误提示div classform-group label登录名 span classtext-danger*/span/label input typetext classform-control th:classappend${#fields.hasErrors(user.username)} ? is-invalid th:field*{username} div classinvalid-feedback th:if${#fields.hasErrors(user.username)} th:errors*{username}错误信息/div /divth:field*{username}会自动生成id、name、value三个属性比手写th:value更省事而且跟#fields的错误机制是配套的。用th:field的时候注意必须在form上挂th:object否则*{}语法找不到上下文。查重的时机也有讲究。放在 Service 里做也行但那样校验失败的信息拿不到BindingResult只能抛异常或者返回错误码页面回显就得另想办法。放在 Controller 里配合bindingResult.rejectValue是这套栈下最顺手的做法错误信息能直接出现在对应字段下面。5.3 删除单条、批量与逻辑删除的取舍删除功能最容易被轻视。一个GetMapping(/delete/{id})写完就完事了看着能跑但这是典型的错误做法——GET 请求被浏览器预加载、被爬虫抓取、被用户误刷新都能触发删除。删必须用 POST。PostMapping(/delete) public String delete(RequestParam Long id, RedirectAttributes ra) { userService.removeById(id); ra.addFlashAttribute(msg, 删除成功); return redirect:/user/list; } PostMapping(/batchDelete) public String batchDelete(RequestParam(ids) ListLong ids, RedirectAttributes ra) { userService.removeByIds(ids); ra.addFlashAttribute(msg, 已删除 ids.size() 条); return redirect:/user/list; }前端确认用 AdminLTE 自带的模态框或者confirm批量删除要注意一个细节如果用户一条都没勾选就点删除ids参数是空的Spring 绑定ListLong会直接抛 400。稳妥的写法是RequestParam(value ids, required false)进来先判空if (CollectionUtils.isEmpty(ids)) { ra.addFlashAttribute(msg, 请先选择要删除的记录); return redirect:/user/list; }逻辑删除的取舍前面提过一部分这里补充一个实际场景如果业务上需要回收站功能逻辑删除是必须的如果数据完全不敏感物理删除反而干净因为软删除的记录日积月累会让表越来越大查询还得带条件。我的经验是涉及人员、订单、财务这类数据的表用逻辑删除配置项、日志这类表直接物理删。混着用没问题加不加TableLogic是按实体类控制的。6. 联调阶段最容易卡住的几个现象与排查顺序6.1 模板改了页面不变先看缓存开关这个现象我第一次遇到时怀疑过 IDE 没保存、怀疑过浏览器缓存、怀疑过 SpringBoot 没热加载折腾了快一个小时最后发现就是spring.thymeleaf.cachetrue。开发环境必须设成 false这个配置默认值在 SpringBoot 里是 true。即使关了缓存如果你改了模板但没重新编译而项目又是从target/classes里读模板的那还是看不到变化。IDEA 里的做法是 Build 一次或者用spring-boot-devtools配合自动编译。我用 devtools 的时候会把spring.thymeleaf.prefix指向file:src/main/resources/templates/直接从源码目录读改完刷新就生效省掉编译这一步spring: thymeleaf: prefix: file:src/main/resources/templates/ cache: false这个配置只能用在开发环境生产环境必须走 classpath。另外一个容易混淆的点改了 Java 代码之后模板的变动可能被编译覆盖。排查顺序我一般是配置文件有没有生效 → 模板路径对不对 → 编译产物是不是最新的从外往里查比一上来就打断点快。6.2 日期和数字的格式化Thymeleaf 里要靠工具类LocalDateTime直接输出到页面会是2024-03-15T10:23:45中间带个 T很难看。格式化的写法td th:text${#temporals.format(user.createTime, yyyy-MM-dd HH:mm)}时间/td#temporals是 Thymeleaf 3 内置的处理 Java 8 时间类型的工具需要thymeleaf-extras-java8time依赖不过 SpringBoot 的 starter 里已经包含了不用单独引。如果是Date类型用的是#dates.format两个工具类不能混用混了会报找不到方法。空值处理是另一个高频问题。${user.realName}如果是 null页面直接不渲染内容不会报错但#temporals.format(null, ...)会抛异常。稳妥的写法是td th:text${user.createTime ! null} ? ${#temporals.format(user.createTime, yyyy-MM-dd HH:mm)} : --/td金额类的字段用#numbers.formatDecimal(amount, 1, 2)保留两位小数注意第一个参数是整数最少位数不是总位数这个参数含义经常被搞反。6.3 静态资源 404 与中文乱码的分层排查静态资源 404 的排查我有一套固定顺序。先看 URL 对不对浏览器 F12 的网络面板里能看到实际请求的路径如果路径里带了../或者重复的/static/说明模板里的路径写错了——{}语法不需要你手写/static它对应的就是static目录的根。再看 Spring Security 有没有拦截如果项目集成了安全框架静态资源默认是要放行的配置里漏了requestMatchers(/dist/**, /plugins/**).permitAll()就会全 404。最后看打包方式jar 包里的static目录是通过 classpath 访问的如果资源放的目录名不是static比如放成了public或者resources那 SpringBoot 也能识别但目录名拼错就完了。中文乱码分两种。一种是请求参数乱码表单提交的中文到后端变成问号这是编码配置问题server.servlet.encoding.forcetrue加上页面的charsetutf-8基本能解决。另一种是响应内容乱码页面上中文变成方块或者乱码通常是模板文件本身的编码不对IDEA 里在文件右下角能看到当前编码确认是 UTF-8 就行。还有一种最隐蔽的数据库连接的characterEncoding写成了utf8但表是utf8mb4存表情符号的时候报错或者截断这个只能从建表语句开始统一。6.4 表单提交后提示信息不显示addFlashAttribute存进去的消息在页面里要用${msg}取。取不到的情况一般是重定向的地址跟存放的会话不一致或者页面模板里漏了判断div classalert alert-success alert-dismissible th:if${msg ! null} button typebutton classclose>
延伸阅读

更多相关文章

2026/10/23 13:26:25

Matlab数值计算生产级代码包:Newton法到龙格-库塔的鲁棒实现

简介:本资源是一套面向高校数值分析课程学习者与工程计算初学者的Matlab实践代码集,聚焦计算方法核心算法的编程实现与原理验证。涵盖欧拉法、牛顿法、豪斯霍尔德变换、线性最小二乘、幂迭代及RKF/RKFA等Runge-Kutta系列算法,覆盖常微分方程求…

2026/10/23 13:26:25

RabbitMQ Virtual Host详解:逻辑隔离、权限管理与连接实践

第一次打开RabbitMQ管理界面的时候,很多人会盯着左上角那个Virtual Host下拉框愣一会儿:为什么默认只有一个斜杠“/”?我明明在“/”下面声明了一个队列,可切到别的下拉选项后却看不到它,这是不是出Bug了?等…

2026/10/23 13:21:24

多普勒效应MATLAB仿真全攻略:建模、参数调优与频移提取

简介:面向西电通信工程学院学生的信号与系统大作业(多普勒效应)完整实践资源,将课堂理论、MATLAB仿真与实验报告融为一体,解决了从公式推导到代码实现再到报告成文的系列问题。压缩包共6个文件,约1.03MB&am…

2026/10/23 14:17:11

手写itoa和atoi:从面试题到生产代码的完整指南

1. 为什么还要手写 itoa 和 atoi很多人第一次看到“手写 itoa 和 atoi”这个题目,第一反应是:标准库不是已经提供了吗,为什么还要自己造轮子?这个问题问得对,但只对了一半。标准库的atoi和itoa确实能用,但它…

2026/10/23 14:17:11

量化研究实战:从数据清洗到因子与回测的完整指南

1. 量化研究的第一步:先想清楚你要解决什么问题很多人看到"量化研究"四个字,第一反应是写策略、跑回测、看收益曲线。但我在实际做下来发现,真正拉开差距的往往不是策略本身,而是你一开始有没有把自己要解决的问题定义清…

2026/10/23 14:17:11

Windows 10 C++工程集成ZeroMQ预编译库:链接、配置与部署

简介:这是一份面向Windows 10平台C开发者的ZeroMQ 4.3.3预编译开发包,适合需要快速构建高性能、低延迟分布式通信模块的中级及以上开发者。压缩包内含59个文件,16个dll与4个lib提供静态和动态链接支持,14个exe可用于工具或示例运行…

2026/10/23 14:17:11

VMware Tools 10.0.6 在 CentOS 7.9 上的兼容安装与深度验证

简介:本资源为VMware Tools 10.0.6版本的官方源码离线安装包(tar.gz格式),专为Linux虚拟机用户设计,适用于在线安装失败、网络受限或需定制编译的场景。作为VMware虚拟化环境的核心增强组件,它可显著提升I/…

2026/10/23 14:17:11

我的世界床战服务端完整配置指南:从启动到优化一步到位

简介:面向希望在本地/自建服务器获得Hypixel式起床战争体验的玩家、服主或开发者,此资源整合了仿Hypixel床战玩法所需的服务端组件,解决了外服延迟、开服配置复杂、缺少现成地图与插件整合包等问题。压缩包共663个文件,大小393.29…

2026/10/23 14:12:05

snake活动轮廓模型MATLAB实现:图像分割原理与调参实战

简介:主动轮廓线模型(Snake,蛇模型)的MATLAB实现源码包,面向图像分割、计算机视觉初学者及科研人员,解决目标边缘自动提取与轮廓演化问题。压缩包共27个文件,以21个M脚本为核心,涵盖…

2026/10/22 11:29:28

金融AI落地指南:从调研报告到技术选型与工程实践

简介:这份PDF报告聚焦2025年金融服务业中国AI现状与趋势,面向金融科技从业者、AI战略规划人员及关注行业数字化转型的研究者,帮助读者把握生成式AI在银行、支付、交易等场景中的落地路径与投资方向。资源包内含1个PDF文件,大小约1…

2026/10/23 0:02:26

Verilog实现FPGA密码锁:矩阵键盘与状态机设计实战

说实话,这几年接触了不少想入门FPGA的朋友,如果你问哪个项目能一口气把Verilog的核心基本功串起来,我通常会推荐电子密码锁。这个项目看着不起眼,却把矩阵键盘扫描、按键消抖、状态机设计、动态显示刷新、外设控制这些数字逻辑里的…

2026/10/23 0:02:26

数据服务监控运维实战:从指标体系到告警治理与故障排查

周末凌晨两点,手机震了。第一反应是“数据服务挂了”。打开电脑看了下,果然,某张核心报表的数据产出时间已经超过SLA两小时,下游三个业务方连着发来问询。这种场景,做过大数据运维的人都不会陌生。数据服务的监控与运维…

2026/10/23 0:02:26

FreeRTOS任务通知实战详解:内核原理、API与避坑指南

裸机编程切到RTOS之后,任务之间的通信和同步永远是躲不开的坎。前面聊过队列、信号量、互斥锁、事件组,这次把FreeRTOS任务通知(Task Notification)单独拎出来讲透。原因很简单:任务通知是FreeRTOS里最容易用错、也最容…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑