Thông thường với mỗi khách hàng họ lại muốn có một giao diện khác nhau cho website của mình, nên dù bạn có tạo sẵn bao nhiêu giao diện đi chăng nữa cũng là không đủ để họ lựa chọn. Có người thích màu xanh, có người thích màu nâu, có người thích cái này hiển thị trước, cái này hiển thị sau và mỗi lần có những thay đổi nhỏ là giữa khách hàng các nhà phát triển lại xảy ra căng thẳng vì những thay đổi nhỏ nhiều khi như gai đâm rất khó chịu với nhà phát triển, đồng thời nó làm phát sinh thêm chi phí và những chi phí này rất khó tính vào đâu.

Hiểu được điều này EzyPlatform đã tạo ra plugin EzyArticle để cho phép bạn phân mảnh hoá giao diện, từ đó người dùng có tạo được giao diện thông qua admin và ghi đè giao diện mặc định.

Mục tiêu

Thông qua bài này bạn sẽ có thể:

  • Đăng ký các page fragment thông qua lớp config
  • Đưa page fragment vào view
  • Sử dụng page fragment

Đăng ký các phần trang (page fragment)

Hãy chúng ta sẽ tiếp tục phát triển theme personal, bây giờ chúng ta có thể tạo lớp PersonalPageFragmentConfig tại module personal-sdk với mã nguồn như sau:

package org.youngmonkeys.personal.config;

import com.tvd12.ezyfox.bean.EzyBeanConfig;
import com.tvd12.ezyfox.bean.annotation.EzyAutoBind;
import lombok.Setter;
import org.youngmonkeys.ezyarticle.sdk.manager.PageFragmentManager;

@Setter
public class PersonalPageFragmentConfig implements EzyBeanConfig {

    @EzyAutoBind
    private PageFragmentManager pageFragmentManager;

    @Override
    public void config() {
        pageFragmentManager.registerFragmentNames(
            "common",
            "styles",
            "scripts",
            "header",
            "footer"
        );
        pageFragmentManager.registerFragmentNames(
            "home",
            "container"
        );
        pageFragmentManager.registerFragmentNames(
            "blog_details",
            "container"
        );
    }
}

Ở đây chúng ta đã đăng ký các phần trang cho:

  • common: Đây là nhóm các phần trang dùng chung cho mọi trang, cụ thể ở đây chúng ta có các phần trang styles cung cấp các mã nguồn style dùng chung, phần trang scripts cung cấp mã nguồn script dùng chung, phần trang header chứa mã nguồn html cho phần đầu trang dùng chung và footer chứ mã nguồn html cho phần chân trang dùng chung.
  • home: Đây là nhóm phần trang dành cho trang chủ, ở đây chúng ta sẽ chỉ có 1 phần trang duy nhất là content chứa toàn bộ html của trang chủ.
  • blog_details: Đây là nhóm phần trang dành cho trang chi tiết blog, ở đây chúng ta cũng sẽ chỉ có 1 phần trang duy nhất là content chứa toàn bộ html của chi tiết bài blog.

Tiếp theo, chúng ta sẽ tạo lớp WebPersonalPageFragmentConfig tại module personal-web-pluginvới mã nguồn như sau:

package org.youngmonkeys.personal.web.config;

import com.tvd12.ezyfox.bean.annotation.EzyConfigurationAfter;
import org.youngmonkeys.personal.config.PersonalPageFragmentConfig;

@EzyConfigurationAfter
public class WebPersonalPageFragmentConfig
    extends PersonalPageFragmentConfig {}

Mục tiêu của nó là để đăng ký các mảnh trang với đối tượng quản lý mảnh trang ở web (WebPageFragmentManager).

Tiếp theo chúng ta sẽ tạo lớp AdminPersonalPageFragmentConfig tại module personal-admin-plugin với mã nguồn như sau:

package org.youngmonkeys.personal.admin.config;

import com.tvd12.ezyfox.bean.annotation.EzyConfigurationAfter;
import org.youngmonkeys.personal.config.PersonalPageFragmentConfig;

@EzyConfigurationAfter
public class AdminPersonalPageFragmentConfig
    extends PersonalPageFragmentConfig {}

Mục tiêu của nó là để đăng ký các mảnh trang với đối tượng quản lý mảnh trang ở admin (AdminPageFragmentManager).

Lúc này khi truy cập vào các phần trang ở admin bạn sẽ thấy danh sách các phần trang đã được đăng ký hiển thị trong danh sách các phần trang kiểu thế này:

Screenshot 2025-10-21 at 21.02.17.png

Đưa các phần trang vào view

Để có thể đưa phần trang dùng chung vào view, trước hết chúng ta sẽ cần cập nhật lớp WebPersonalViewDecorator với mã nguồn như sau:

@EzySingleton
@AllArgsConstructor
public class WebPersonalViewDecorator extends WebViewDecorator {

    private final WebPageFragmentManager pageFragmentManager;
    // các mã nguồn khác
    private final WebLanguageControllerService languageControllerService;

    @SuppressWarnings("MethodLength")
    @Override
    public void decorate(HttpServletRequest request, View view) {
        super.decorate(request, view);
        // các mã nguồn khác
        String languageCode = languageControllerService
            .getLanguageCodeOrDefault(request);
        view.setVariable(
            "commonFragments",
            pageFragmentManager.getPageFragmentMap(
                "common",
                languageCode
            )
        );
    }

    // các mã nguồn khác
}

Ở đây, do đã đăng ký các phần trang dùng chung ở lớp config rồi nên chúng ta sẽ cần chỉ định common cho hàm getPageFragmentMap và nó sẽ lấy toàn bộ các phần trang mà chúng ta đã đăng ký.

Bây giờ chúng ta có thể cập nhật template page.html như sau:

<!DOCTYPE html>
<html
	xmlns="http://www.w3.org/1999/xhtml"
	xmlns:th="http://www.thymeleaf.org"
	xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
	xmlns:ezy="http://www.ezyplatform.com/thymeleaf/layout">

<head th:remove="tag">
	<th:block th:if="${commonFragments.get('styles') == null || commonFragments.get('styles').content == 'styles'}">
		<link rel="stylesheet" type="text/css" ezy:vhref="/css/main.css" />
	</th:block>
	<th:block th:if="${commonFragments.get('styles') != null && commonFragments.get('styles').content != 'styles'}">
        <ezy:block ezy:utext="${commonFragments.get('styles').content}" />
    </th:block>
	<th:block th:if="${pageFragments != null && pageFragments.get('container') != null && !#strings.isEmpty(pageFragments.get('container').additionalHead)}">
        <ezy:block ezy:utext="${pageFragments.get('container').additionalHead}" />
    </th:block>
	<th:block th:if="${commonFragments.get('footer') != null}">
        <ezy:block ezy:utext="${commonFragments.get('footer').additionalHead}" />
    </th:block>
</head>
<body th:remove="tag">
<div class="wrapper light-theme" id="theme-wrapper">
	<th:block th:if="${commonFragments.get('header') != null && commonFragments.get('header').content != 'header'}">
		<ezy:block ezy:utext="${commonFragments.get('header').content}" />
	</th:block>
	<th:block th:if="${commonFragments.get('header') == null || commonFragments.get('header').content == 'header'}">
		<header class="header">
			<div th:replace="~{fragments/header :: header}"></div>
		</header>
	</th:block>
	<th:block th:if="${pageFragments != null && pageFragments.get('container') != null && pageFragments.get('container').content != 'container'}">
		<ezy:block ezy:utext="${pageFragments.get('container').content}" />
	</th:block>
	<th:block th:if="${pageFragments == null || pageFragments.get('container') == null || pageFragments.get('container').content == 'container'}">
		<main>
			<div layout:fragment="content"></div>
		</main>
	</th:block>
	<th:block th:if="${commonFragments.get('footer') != null && commonFragments.get('footer').content != 'footer'}">
		<th:block th:utext="${commonFragments.get('footer').content}" />
	</th:block>
	<th:block th:if="${commonFragments.get('footer') == null || commonFragments.get('footer').content == 'footer'}">
		<footer th:replace="~{fragments/footer :: footer}"></footer>
	</th:block>
</div>
<div id="loadingScreen" class="screen-loading d-none">
	<i class="fas fa-3x fa-sync fa-spin text-secondary"></i>
</div>

<th:block layout:fragment="modals"></th:block>

<!-- REQUIRED SCRIPTS -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.4/jquery.min.js" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.29.4/moment.min.js" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<th:block th:if="${commonFragments.get('scripts') == null || commonFragments.get('scripts').content == 'scripts'}">
	<script type="module" src="https://unpkg.com/ionicons@5.5.2/dist/ionicons/ionicons.esm.js"></script>
	<script nomodule src="https://unpkg.com/ionicons@5.5.2/dist/ionicons/ionicons.js"></script>
	<script ezy:vsrc="/js/main.js" type="text/javascript"></script>
</th:block>
<th:block th:if="${commonFragments.get('scripts') != null && commonFragments.get('scripts').content != 'scripts'}">
    <ezy:block ezy:utext="${commonFragments.get('scripts').content}" />
</th:block>
<!-- OPTIONAL SCRIPTS -->
<script layout:fragment="import-scripts" th:remove="tag"></script>
<script layout:fragment="pre-main-scripts" type="text/javascript"></script>
<script layout:fragment="scripts" type="text/javascript"></script>
<script layout:fragment="post-scripts" type="text/javascript"></script>
<script>
$( document ).ready(function() {
	ezyweb.formatDateStringElements();
	ezyweb.formatDateTimeStringElements();
	ezyweb.formatDateTimeMinuteStringElements();
	ezyweb.formatStatusTextElements();
	ezyweb.formatNumberWithCommasElements();
	if (ezyweb.lang) {
		ezyweb.appendLangParameterToLinks(ezyweb.lang);
	}
});
</script>
<th:block th:if="${commonFragments.get('header') != null && !#strings.isEmpty(commonFragments.get('header').additionalFoot)}">
	<ezy:block ezy:utext="${commonFragments.get('header').additionalFoot}" />
</th:block>
<th:block th:if="${commonFragments.get('header') == null || #strings.isEmpty(commonFragments.get('header').additionalFoot)}">
	<script th:replace="~{fragments/header :: scripts}" type="text/javascript"></script>
</th:block>
<th:block th:if="${commonFragments.get('footer') != null && !#strings.isEmpty(commonFragments.get('footer').additionalFoot)}">
	<ezy:block ezy:utext="${commonFragments.get('footer').additionalFoot}" />
</th:block>
</body>
</html>

Ở đây rất nhiều if else, nguyên nhân là do chúng ta cần kiểm tra xem phần trang đã được cập nhật thông qua admin hay chưa, thông qua việc kiểm tra, ví dụ commonFragments.get('styles') == nullđể kiểm tra phần trang styles đã được khai báo hay chưa, và commonFragments.get('styles').content == 'styles' để kiểm tra xem giá trị của phần trang vẫn đang là mặc định hay đã bị thay đổi. Nếu có phần trang và phần trang đã bị thay đổi, chúng ta sẽ lấy phần trang từ admin còn không thì chúng ta sẽ lấy theo mặc định là mã nguồn template chúng ta đã cung cấp bằng sẵn.

Sử dung page fragment

Bây giờ đến phần thú vị, chúng ta hãy trở lại với các phần trang ở admin, tìm đến phần trang container của Blog Details và click vào. Lúc này editor sẽ mở ra và chúng ta có thể thay đổi nội dung mặc định thành bất cứ nội dung thymeleaf nào mà bạn muốn, ví dụ:

<div class="container">
   <section class="page-header">
        <div class="container">
          <div class="breadcrumb">
            <h3 class="title">[[${blog.title}]]</h3>
            <a class="link" href="/">
             <h5>
               <i class="fa-solid fa-angles-left"></i>
               Back To Home
            </h5>
          </a>
        </div>
      </div>
    </section>
    <section class="page-content page-article">
      <div class="row">
        <div class="col-12">
          <span>[[#{posted_by}]]: [[${blog.author.name}]]</span>
          <span>
            [[${#strings.toLowerCase(#messages.msg('at'))}]]: <span class="date-time-minute-string">[[${blog.publishedAt}]]</span>
          </span>
        </div>
        <div th:if="${blog.terms.size() > 0}" class="col-12">
          <div class="terms-wrapper">
            <span>[[#{in}]]:</span>
            <div class="terms">
              <a th:each="term : ${blog.terms}">
                [[${term.name}]]
              </a>
            </div>
          </div>
        </div>
        <div class="col-lg-2"></div>
        <div class="col-lg-8">
          <div class="row">
            <div th:if="${blog.featuredImage != null}">
              <img th:src="${blog.featuredImage.getUrlOrNull()}">
            </div>
            <div class="col-md-12 article-content" th:utext="${blog.content}"></div>
          </div>
        </div>
        <div class="col-lg-2"></div>
      </div>
    </section>
  </div>

Sau đó trở lại giao diện chi tiết một bài blog ở web và refresh lại, bạn sẽ nhận được kết quả tương ứng với những thay đổi của mình.

Tài liệu tham khảo