Spring Boot 3.x与Thymeleaf实战:构建现代化博客后台管理系统

在当今快速发展的Web开发领域,选择合适的技术栈对于构建高效、可维护的应用程序至关重要。Spring Boot作为Java生态中最受欢迎的框架之一,与Thymeleaf模板引擎的结合,为开发者提供了一套完整的服务器端渲染解决方案。本文将带领您从零开始,使用Spring Boot 3.x最新稳定版和Thymeleaf,构建一个功能完善的博客后台管理系统,重点实现导航栏、页面布局和数据展示等核心功能。

1. 项目初始化与环境配置

开始之前,我们需要准备开发环境并初始化项目结构。推荐使用IntelliJ IDEA或VS Code作为开发工具,它们对Spring Boot和Thymeleaf都有良好的支持。

首先,通过Spring Initializr创建项目基础结构:

# 使用curl命令快速生成项目骨架
curl https://start.spring.io/starter.zip \
  -d dependencies=web,thymeleaf \
  -d type=gradle-project \
  -d language=java \
  -d bootVersion=3.2.0 \
  -d groupId=com.example \
  -d artifactId=blog-admin \
  -o blog-admin.zip

解压后,您会得到以下基础项目结构:

blog-admin/
├── src/
│   ├── main/
│   │   ├── java/com/example/blogadmin/
│   │   │   └── BlogAdminApplication.java
│   │   ├── resources/
│   │   │   ├── static/
│   │   │   ├── templates/
│   │   │   └── application.properties
│   └── test/
└── build.gradle

为了提升开发体验,我们需要进行一些关键配置。在 application.properties 中添加以下内容:

# Thymeleaf配置
spring.thymeleaf.cache=false
spring.thymeleaf.prefix=classpath:/templates/
spring.thymeleaf.suffix=.html
spring.thymeleaf.mode=HTML
spring.thymeleaf.encoding=UTF-8

# 开发工具配置
spring.devtools.restart.enabled=true
spring.devtools.livereload.enabled=true

提示:关闭Thymeleaf缓存( spring.thymeleaf.cache=false )可以让模板修改立即生效,非常适合开发环境。但在生产环境中,应该将其设置为true以提高性能。

2. 构建基础页面布局

良好的页面布局是后台管理系统的基石。我们将使用Thymeleaf的布局功能来创建可复用的页面结构。

首先,在 resources/templates 目录下创建 fragments/layout.html 文件,作为基础布局模板:

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head th:fragment="base-head(title)">
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title th:text="${title}">Blog Admin</title>
    <link th:href="@{/css/bootstrap.min.css}" rel="stylesheet">
    <link th:href="@{/css/admin.css}" rel="stylesheet">
</head>
<body>
    <!-- 导航栏片段 -->
    <header th:fragment="navbar">
        <nav class="navbar navbar-expand-lg navbar-dark bg-primary">
            <div class="container-fluid">
                <a class="navbar-brand" th:href="@{/}">博客后台</a>
                <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarContent">
                    <span class="navbar-toggler-icon"></span>
                </button>
                <div class="collapse navbar-collapse" id="navbarContent">
                    <ul class="navbar-nav me-auto">
                        <li class="nav-item">
                            <a class="nav-link" th:href="@{/posts}" th:classappend="${#httpServletRequest.requestURI == '/posts'} ? 'active'">文章管理</a>
                        </li>
                        <li class="nav-item">
                            <a class="nav-link" th:href="@{/categories}" th:classappend="${#httpServletRequest.requestURI == '/categories'} ? 'active'">分类管理</a>
                        </li>
                        <li class="nav-item">
                            <a class="nav-link" th:href="@{/comments}" th:classappend="${#httpServletRequest.requestURI == '/comments'} ? 'active'">评论管理</a>
                        </li>
                    </ul>
                    <div class="d-flex">
                        <span class="navbar-text me-3" th:text="'欢迎,' + ${#authentication.name}"></span>
                        <form th:action="@{/logout}" method="post">
                            <button class="btn btn-outline-light" type="submit">退出</button>
                        </form>
                    </div>
                </div>
            </div>
        </nav>
    </header>

    <!-- 主要内容区域 -->
    <main class="container mt-4">
        <div th:replace="~{fragments/alerts :: alerts}"></div>
        <div th:insert="~{${content}}"></div>
    </main>

    <!-- 页脚 -->
    <footer class="mt-5 py-3 bg-light">
        <div class="container text-center">
            <span class="text-muted">© 2023 博客后台管理系统</span>
        </div>
    </footer>

    <script th:src="@{/js/bootstrap.bundle.min.js}"></script>
    <script th:src="@{/js/admin.js}"></script>
</body>
</html>

这个布局模板定义了以下几个关键部分:

  1. 基础头部(fragment="base-head") :包含页面标题、CSS引用等
  2. 导航栏(fragment="navbar") :使用Bootstrap 5构建响应式导航
  3. 主要内容区域 :动态插入各页面的具体内容
  4. 页脚 :显示版权信息
  5. 脚本引用 :包含所有必要的JavaScript文件

接下来,我们创建首页模板 index.html

<!DOCTYPE html>
<html th:replace="~{fragments/layout :: html}">
<head>
    <title>博客后台首页</title>
</head>
<body>
    <div th:fragment="content">
        <h1 class="mb-4">仪表盘</h1>
        <div class="row">
            <div class="col-md-4">
                <div class="card text-white bg-primary mb-3">
                    <div class="card-body">
                        <h5 class="card-title" th:text="${postCount} + ' 篇文章'">0 篇文章</h5>
                        <p class="card-text">已发布<span th:text="${publishedCount}"></span>篇</p>
                    </div>
                </div>
            </div>
            <div class="col-md-4">
                <div class="card text-white bg-success mb-3">
                    <div class="card-body">
                        <h5 class="card-title" th:text="${categoryCount} + ' 个分类'">0 个分类</h5>
                        <p class="card-text">文章分类管理</p>
                    </div>
                </div>
            </div>
            <div class="col-md-4">
                <div class="card text-white bg-info mb-3">
                    <div class="card-body">
                        <h5 class="card-title" th:text="${commentCount} + ' 条评论'">0 条评论</h5>
                        <p class="card-text">待审核<span th:text="${pendingCommentCount}"></span>条</p>
                    </div>
                </div>
            </div>
        </div>
    </div>
</body>
</html>

3. 实现文章管理功能

博客后台的核心功能之一是文章管理。我们将创建一个完整的CRUD界面,包括列表展示、创建、编辑和删除功能。

首先,定义文章实体类 Post.java

@Entity
public class Post {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @NotBlank(message = "标题不能为空")
    @Size(max = 100, message = "标题长度不能超过100个字符")
    private String title;
    
    @NotBlank(message = "内容不能为空")
    @Column(columnDefinition = "TEXT")
    private String content;
    
    @ManyToOne
    @JoinColumn(name = "category_id")
    private Category category;
    
    private boolean published;
    
    @CreationTimestamp
    private LocalDateTime createdAt;
    
    @UpdateTimestamp
    private LocalDateTime updatedAt;
    
    // 省略getter和setter方法
}

创建控制器 PostController.java 处理文章相关请求:

@Controller
@RequestMapping("/posts")
public class PostController {
    private final PostService postService;
    
    @GetMapping
    public String listPosts(Model model,
                          @RequestParam(defaultValue = "1") int page,
                          @RequestParam(defaultValue = "10") int size) {
        Page<Post> postPage = postService.findAll(page - 1, size);
        model.addAttribute("posts", postPage.getContent());
        model.addAttribute("currentPage", page);
        model.addAttribute("totalPages", postPage.getTotalPages());
        return "posts/list";
    }
    
    @GetMapping("/create")
    public String showCreateForm(Model model) {
        model.addAttribute("post", new Post());
        model.addAttribute("categories", categoryService.findAll());
        return "posts/form";
    }
    
    @PostMapping
    public String createPost(@Valid Post post, BindingResult result) {
        if (result.hasErrors()) {
            return "posts/form";
        }
        postService.save(post);
        return "redirect:/posts";
    }
    
    @GetMapping("/edit/{id}")
    public String showEditForm(@PathVariable Long id, Model model) {
        Post post = postService.findById(id)
            .orElseThrow(() -> new ResourceNotFoundException("文章不存在"));
        model.addAttribute("post", post);
        model.addAttribute("categories", categoryService.findAll());
        return "posts/form";
    }
    
    @PostMapping("/update/{id}")
    public String updatePost(@PathVariable Long id, @Valid Post post, BindingResult result) {
        if (result.hasErrors()) {
            return "posts/form";
        }
        post.setId(id);
        postService.save(post);
        return "redirect:/posts";
    }
    
    @GetMapping("/delete/{id}")
    public String deletePost(@PathVariable Long id) {
        postService.deleteById(id);
        return "redirect:/posts";
    }
}

创建文章列表模板 posts/list.html

<!DOCTYPE html>
<html th:replace="~{fragments/layout :: html}">
<head>
    <title>文章管理</title>
</head>
<body>
    <div th:fragment="content">
        <div class="d-flex justify-content-between align-items-center mb-4">
            <h1>文章列表</h1>
            <a th:href="@{/posts/create}" class="btn btn-primary">新建文章</a>
        </div>
        
        <div class="card">
            <div class="card-body">
                <table class="table table-hover">
                    <thead>
                        <tr>
                            <th>ID</th>
                            <th>标题</th>
                            <th>分类</th>
                            <th>状态</th>
                            <th>创建时间</th>
                            <th>操作</th>
                        </tr>
                    </thead>
                    <tbody>
                        <tr th:each="post : ${posts}">
                            <td th:text="${post.id}"></td>
                            <td>
                                <a th:href="@{'/posts/' + ${post.id}}" th:text="${post.title}"></a>
                            </td>
                            <td th:text="${post.category?.name} ?: '未分类'"></td>
                            <td>
                                <span th:if="${post.published}" class="badge bg-success">已发布</span>
                                <span th:unless="${post.published}" class="badge bg-secondary">草稿</span>
                            </td>
                            <td th:text="${#temporals.format(post.createdAt, 'yyyy-MM-dd HH:mm')}"></td>
                            <td>
                                <div class="btn-group btn-group-sm">
                                    <a th:href="@{'/posts/edit/' + ${post.id}}" class="btn btn-outline-primary">编辑</a>
                                    <form th:action="@{'/posts/delete/' + ${post.id}}" method="post">
                                        <button type="submit" class="btn btn-outline-danger" 
                                            onclick="return confirm('确定要删除这篇文章吗?')">删除</button>
                                    </form>
                                </div>
                            </td>
                        </tr>
                    </tbody>
                </table>
                
                <nav th:if="${totalPages > 1}">
                    <ul class="pagination justify-content-center">
                        <li class="page-item" th:classappend="${currentPage == 1} ? 'disabled'">
                            <a class="page-link" th:href="@{/posts(page=${currentPage - 1})}">上一页</a>
                        </li>
                        <li th:each="i : ${#numbers.sequence(1, totalPages)}" 
                            class="page-item" th:classappend="${i == currentPage} ? 'active'">
                            <a class="page-link" th:href="@{/posts(page=${i})}" th:text="${i}"></a>
                        </li>
                        <li class="page-item" th:classappend="${currentPage == totalPages} ? 'disabled'">
                            <a class="page-link" th:href="@{/posts(page=${currentPage + 1})}">下一页</a>
                        </li>
                    </ul>
                </nav>
            </div>
        </div>
    </div>
</body>
</html>

创建文章表单模板 posts/form.html ,用于创建和编辑文章:

<!DOCTYPE html>
<html th:replace="~{fragments/layout :: html}">
<head>
    <title th:text="${post.id != null} ? '编辑文章' : '新建文章'"></title>
</head>
<body>
    <div th:fragment="content">
        <h1 th:text="${post.id != null} ? '编辑文章' : '新建文章'" class="mb-4"></h1>
        
        <form th:action="${post.id != null} ? @{'/posts/update/' + ${post.id}} : @{/posts}" 
              method="post" th:object="${post}">
            <div class="card">
                <div class="card-body">
                    <div class="mb-3">
                        <label for="title" class="form-label">标题</label>
                        <input type="text" class="form-control" id="title" th:field="*{title}"
                               th:classappend="${#fields.hasErrors('title')} ? 'is-invalid'">
                        <div class="invalid-feedback" th:if="${#fields.hasErrors('title')}" 
                             th:errors="*{title}"></div>
                    </div>
                    
                    <div class="mb-3">
                        <label for="category" class="form-label">分类</label>
                        <select class="form-select" id="category" th:field="*{category}">
                            <option value="">-- 请选择分类 --</option>
                            <option th:each="category : ${categories}" 
                                    th:value="${category.id}"
                                    th:text="${category.name}"
                                    th:selected="${post.category != null && post.category.id == category.id}">
                            </option>
                        </select>
                    </div>
                    
                    <div class="mb-3">
                        <label for="content" class="form-label">内容</label>
                        <textarea class="form-control" id="content" rows="10" th:field="*{content}"
                                  th:classappend="${#fields.hasErrors('content')} ? 'is-invalid'"></textarea>
                        <div class="invalid-feedback" th:if="${#fields.hasErrors('content')}" 
                             th:errors="*{content}"></div>
                    </div>
                    
                    <div class="form-check mb-3">
                        <input class="form-check-input" type="checkbox" id="published" th:field="*{published}">
                        <label class="form-check-label" for="published">发布</label>
                    </div>
                    
                    <div class="d-flex justify-content-end">
                        <a th:href="@{/posts}" class="btn btn-secondary me-2">取消</a>
                        <button type="submit" class="btn btn-primary">保存</button>
                    </div>
                </div>
            </div>
        </form>
    </div>
</body>
</html>

4. 高级功能实现与优化

4.1 富文本编辑器集成

为了提升文章编辑体验,我们可以集成一个富文本编辑器。这里以CKEditor为例:

首先,在 pom.xml 中添加CKEditor依赖:

<dependency>
    <groupId>com.vaadin.external.google</groupId>
    <artifactId>android-json</artifactId>
    <version>0.0.20131108.vaadin1</version>
    <scope>compile</scope>
</dependency>

然后,修改 posts/form.html 中的内容文本区域:

<textarea class="form-control" id="content" rows="10" th:field="*{content}"></textarea>
<script th:inline="javascript">
    ClassicEditor
        .create(document.querySelector('#content'), {
            toolbar: ['heading', '|', 'bold', 'italic', 'link', 'bulletedList', 'numberedList', 'blockQuote'],
            heading: {
                options: [
                    { model: 'paragraph', title: 'Paragraph', class: 'ck-heading_paragraph' },
                    { model: 'heading1', view: 'h1', title: 'Heading 1', class: 'ck-heading_heading1' },
                    { model: 'heading2', view: 'h2', title: 'Heading 2', class: 'ck-heading_heading2' }
                ]
            }
        })
        .catch(error => {
            console.error(error);
        });
</script>

4.2 文件上传功能

实现图片上传功能可以丰富文章内容。首先创建文件上传控制器:

@Controller
@RequestMapping("/upload")
public class FileUploadController {
    
    @Value("${upload.dir}")
    private String uploadDir;
    
    @PostMapping("/image")
    @ResponseBody
    public Map<String, String> uploadImage(@RequestParam("upload") MultipartFile file) {
        if (file.isEmpty()) {
            throw new IllegalArgumentException("上传文件不能为空");
        }
        
        try {
            String filename = UUID.randomUUID() + "." + 
                FilenameUtils.getExtension(file.getOriginalFilename());
            Path path = Paths.get(uploadDir, filename);
            Files.createDirectories(path.getParent());
            Files.copy(file.getInputStream(), path, StandardCopyOption.REPLACE_EXISTING);
            
            return Map.of(
                "uploaded", "true",
                "url", "/uploads/" + filename
            );
        } catch (IOException e) {
            throw new RuntimeException("文件上传失败", e);
        }
    }
}

然后配置CKEditor的文件上传:

ClassicEditor
    .create(document.querySelector('#content'), {
        // ... 其他配置
        ckfinder: {
            uploadUrl: '/upload/image'
        }
    })
    // ... 

4.3 国际化支持

为系统添加多语言支持可以提升用户体验。首先创建消息资源文件:

messages.properties :

navbar.brand=博客后台
navbar.posts=文章管理
navbar.categories=分类管理
navbar.comments=评论管理

messages_zh_CN.properties :

navbar.brand=博客后台
navbar.posts=文章管理
navbar.categories=分类管理
navbar.comments=评论管理

然后在Spring配置中添加国际化支持:

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
    
    @Bean
    public LocaleResolver localeResolver() {
        SessionLocaleResolver slr = new SessionLocaleResolver();
        slr.setDefaultLocale(Locale.US);
        return slr;
    }
    
    @Bean
    public LocaleChangeInterceptor localeChangeInterceptor() {
        LocaleChangeInterceptor lci = new LocaleChangeInterceptor();
        lci.setParamName("lang");
        return lci;
    }
    
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(localeChangeInterceptor());
    }
    
    @Bean
    public ResourceBundleMessageSource messageSource() {
        ResourceBundleMessageSource source = new ResourceBundleMessageSource();
        source.setBasenames("messages");
        source.setDefaultEncoding("UTF-8");
        return source;
    }
}

最后在模板中使用国际化消息:

<a class="navbar-brand" th:href="@{/}" th:text="#{navbar.brand}"></a>
<a class="nav-link" th:href="@{/posts}" th:text="#{navbar.posts}"></a>

4.4 安全性增强

使用Spring Security保护后台管理系统:

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/", "/login", "/css/**", "/js/**", "/uploads/**").permitAll()
                .anyRequest().authenticated()
            )
            .formLogin(form -> form
                .loginPage("/login")
                .defaultSuccessUrl("/")
                .permitAll()
            )
            .logout(logout -> logout
                .logoutSuccessUrl("/login?logout")
                .permitAll()
            );
        return http.build();
    }
    
    @Bean
    public UserDetailsService userDetailsService() {
        UserDetails user = User.withDefaultPasswordEncoder()
            .username("admin")
            .password("password")
            .roles("ADMIN")
            .build();
        return new InMemoryUserDetailsManager(user);
    }
}

创建登录页面 login.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>登录</title>
    <link th:href="@{/css/bootstrap.min.css}" rel="stylesheet">
</head>
<body class="bg-light">
    <div class="container mt-5">
        <div class="row justify-content-center">
            <div class="col-md-6 col-lg-4">
                <div class="card shadow">
                    <div class="card-body p-4">
                        <h2 class="card-title text-center mb-4">后台登录</h2>
                        <form th:action="@{/login}" method="post">
                            <div th:if="${param.error}" class="alert alert-danger">
                                用户名或密码错误
                            </div>
                            <div th:if="${param.logout}" class="alert alert-success">
                                您已成功退出
                            </div>
                            <div class="mb-3">
                                <label for="username" class="form-label">用户名</label>
                                <input type="text" class="form-control" id="username" name="username" required>
                            </div>
                            <div class="mb-3">
                                <label for="password" class="form-label">密码</label>
                                <input type="password" class="form-control" id="password" name="password" required>
                            </div>
                            <button type="submit" class="btn btn-primary w-100">登录</button>
                        </form>
                    </div>
                </div>
            </div>
        </div>
    </div>
</body>
</html>

5. 性能优化与部署

5.1 缓存策略

在生产环境中,合理使用缓存可以显著提升系统性能。我们可以为文章列表添加缓存:

@Configuration
@EnableCaching
public class CacheConfig {
    
    @Bean
    public CacheManager cacheManager() {
        return new ConcurrentMapCacheManager("posts");
    }
}

@Service
public class PostServiceImpl implements PostService {
    
    @Cacheable("posts")
    @Override
    public Page<Post> findAll(int page, int size) {
        // 数据库查询逻辑
    }
    
    @CacheEvict(value = "posts", allEntries = true)
    @Override
    public Post save(Post post) {
        // 保存逻辑
    }
    
    @CacheEvict(value = "posts", allEntries = true)
    @Override
    public void deleteById(Long id) {
        // 删除逻辑
    }
}

5.2 静态资源优化

对于生产环境,我们应该启用资源压缩和缓存:

# 启用资源压缩
server.compression.enabled=true
server.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json
server.compression.min-response-size=1024

# 静态资源缓存
spring.web.resources.cache.cachecontrol.max-age=365d
spring.web.resources.cache.cachecontrol.immutable=true

5.3 部署配置

准备生产环境配置文件 application-prod.properties

# 生产环境配置
spring.profiles.active=prod
spring.thymeleaf.cache=true
spring.datasource.url=jdbc:mysql://localhost:3306/blog_admin?useSSL=false&serverTimezone=UTC
spring.datasource.username=root
spring.datasource.password=yourpassword
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=false

# 文件上传配置
upload.dir=/var/uploads/blog

# 安全配置
spring.security.user.name=admin
spring.security.user.password=securepassword

使用Gradle构建生产包:

./gradlew clean build -Pprod

构建完成后,可以使用以下命令运行应用:

java -jar build/libs/blog-admin-0.0.1-SNAPSHOT.jar

或者使用Docker容器化部署:

FROM openjdk:17-jdk-slim
COPY build/libs/blog-admin-0.0.1-SNAPSHOT.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

构建并运行Docker容器:

docker build -t blog-admin .
docker run -d -p 8080:8080 -v /var/uploads/blog:/var/uploads/blog blog-admin
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐