mirror of https://github.com/halo-dev/halo
feat: Theme temporals supports time zone (#2297)
<!-- Thanks for sending a pull request! Here are some tips for you: 1. 如果这是你的第一次,请阅读我们的贡献指南:<https://github.com/halo-dev/halo/blob/master/CONTRIBUTING.md>。 1. If this is your first time, please read our contributor guidelines: <https://github.com/halo-dev/halo/blob/master/CONTRIBUTING.md>. 2. 请根据你解决问题的类型为 Pull Request 添加合适的标签。 2. Please label this pull request according to what type of issue you are addressing, especially if this is a release targeted pull request. 3. 请确保你已经添加并运行了适当的测试。 3. Ensure you have added or ran the appropriate tests for your PR. --> #### What type of PR is this? /kind feature /area core /milestone 2.0 <!-- 添加其中一个类别: Add one of the following kinds: /kind bug /kind cleanup /kind documentation /kind feature /kind improvement 适当添加其中一个或多个类别(可选): Optionally add one or more of the following kinds if applicable: /kind api-change /kind deprecation /kind failing-test /kind flake /kind regression --> #### What this PR does / why we need it: 主题可以通过 temporals 表达式方言来格式化日期,格式化时会根据时区显示,例如:`${#temporals.format(instants, 'yyyy-MM-dd HH:mm:ss')}` 访问主题时通过携带 cookie 如 cookie:time_zone=Africa/Accra 来切换时区 支持的时区列表参考:https://jenkov.com/tutorials/java-date-time/java-util-timezone.html 表达式更多用法参考:https://github.com/thymeleaf/thymeleaf-extras-java8time #### Which issue(s) this PR fixes: <!-- PR 合并时自动关闭 issue。 Automatically closes linked issue when PR is merged. 用法:`Fixes #<issue 号>`,或者 `Fixes (粘贴 issue 完整链接)` Usage: `Fixes #<issue number>`, or `Fixes (paste link of issue)`. --> Fixes #2293 #### Special notes for your reviewer: /cc @halo-dev/sig-halo #### Does this PR introduce a user-facing change? <!-- 如果当前 Pull Request 的修改不会造成用户侧的任何变更,在 `release-note` 代码块儿中填写 `NONE`。 否则请填写用户侧能够理解的 Release Note。如果当前 Pull Request 包含破坏性更新(Break Change), Release Note 需要以 `action required` 开头。 If no, just write "NONE" in the release-note block below. If yes, a release note is required: Enter your extended release note in the block below. If the PR requires additional action from users switching to the new release, include the string "action required". --> ```release-note None ```pull/2321/head
parent
86f9daf421
commit
3d38979d80
|
@ -11,8 +11,10 @@ import org.springframework.http.MediaType;
|
||||||
import org.springframework.web.reactive.function.server.RouterFunction;
|
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||||
import org.springframework.web.reactive.function.server.RouterFunctions;
|
import org.springframework.web.reactive.function.server.RouterFunctions;
|
||||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||||
|
import org.thymeleaf.extras.java8time.dialect.Java8TimeDialect;
|
||||||
import run.halo.app.infra.properties.HaloProperties;
|
import run.halo.app.infra.properties.HaloProperties;
|
||||||
import run.halo.app.infra.utils.FilePathUtils;
|
import run.halo.app.infra.utils.FilePathUtils;
|
||||||
|
import run.halo.app.theme.dialect.ThemeJava8TimeDialect;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @author guqing
|
* @author guqing
|
||||||
|
@ -59,4 +61,9 @@ public class ThemeConfiguration {
|
||||||
return FilePathUtils.combinePath(haloProperties.getWorkDir().toString(),
|
return FilePathUtils.combinePath(haloProperties.getWorkDir().toString(),
|
||||||
"themes", themeName, "templates", "assets", resource);
|
"themes", themeName, "templates", "assets", resource);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
Java8TimeDialect java8TimeDialect() {
|
||||||
|
return new ThemeJava8TimeDialect();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,28 @@
|
||||||
|
package run.halo.app.theme.dialect;
|
||||||
|
|
||||||
|
import static run.halo.app.theme.ThemeLocaleContextResolver.TIME_ZONE_REQUEST_ATTRIBUTE_NAME;
|
||||||
|
|
||||||
|
import java.util.TimeZone;
|
||||||
|
import org.thymeleaf.context.IExpressionContext;
|
||||||
|
import org.thymeleaf.extras.java8time.dialect.Java8TimeExpressionFactory;
|
||||||
|
import org.thymeleaf.extras.java8time.expression.Temporals;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author guqing
|
||||||
|
* @since 2.0.0
|
||||||
|
*/
|
||||||
|
public class DefaultJava8TimeExpressionFactory extends Java8TimeExpressionFactory {
|
||||||
|
private static final String TEMPORAL_EVALUATION_VARIABLE_NAME = "temporals";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Object buildObject(IExpressionContext context, String expressionObjectName) {
|
||||||
|
TimeZone timeZone = (TimeZone) context.getVariable(TIME_ZONE_REQUEST_ATTRIBUTE_NAME);
|
||||||
|
if (timeZone == null) {
|
||||||
|
timeZone = TimeZone.getDefault();
|
||||||
|
}
|
||||||
|
if (TEMPORAL_EVALUATION_VARIABLE_NAME.equals(expressionObjectName)) {
|
||||||
|
return new Temporals(context.getLocale(), timeZone.toZoneId());
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
package run.halo.app.theme.dialect;
|
||||||
|
|
||||||
|
import org.thymeleaf.expression.IExpressionObjectFactory;
|
||||||
|
import org.thymeleaf.extras.java8time.dialect.Java8TimeDialect;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author guqing
|
||||||
|
* @since 2.0.0
|
||||||
|
*/
|
||||||
|
public class ThemeJava8TimeDialect extends Java8TimeDialect {
|
||||||
|
private final IExpressionObjectFactory expressionObjectFactory =
|
||||||
|
new DefaultJava8TimeExpressionFactory();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public IExpressionObjectFactory getExpressionObjectFactory() {
|
||||||
|
return expressionObjectFactory;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,147 @@
|
||||||
|
package run.halo.app.theme.dialect;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static run.halo.app.theme.ThemeLocaleContextResolver.TIME_ZONE_COOKIE_NAME;
|
||||||
|
|
||||||
|
import java.io.FileNotFoundException;
|
||||||
|
import java.net.URL;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.TimeZone;
|
||||||
|
import java.util.function.Function;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.boot.test.context.TestConfiguration;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.server.reactive.ServerHttpRequest;
|
||||||
|
import org.springframework.test.web.reactive.server.WebTestClient;
|
||||||
|
import org.springframework.util.ResourceUtils;
|
||||||
|
import org.springframework.web.reactive.function.server.RequestPredicates;
|
||||||
|
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||||
|
import org.springframework.web.reactive.function.server.RouterFunctions;
|
||||||
|
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||||
|
import org.thymeleaf.extras.java8time.expression.Temporals;
|
||||||
|
import run.halo.app.theme.ThemeContext;
|
||||||
|
import run.halo.app.theme.ThemeResolver;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for {@link ThemeJava8TimeDialect}.
|
||||||
|
*
|
||||||
|
* @author guqing
|
||||||
|
* @since 2.0.0
|
||||||
|
*/
|
||||||
|
@SpringBootTest
|
||||||
|
@AutoConfigureWebTestClient
|
||||||
|
class ThemeJava8TimeDialectIntegrationTest {
|
||||||
|
private static final Instant INSTANT = Instant.now();
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ThemeResolver themeResolver;
|
||||||
|
|
||||||
|
private URL defaultThemeUrl;
|
||||||
|
|
||||||
|
Function<ServerHttpRequest, ThemeContext> themeContextFunction;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private WebTestClient webTestClient;
|
||||||
|
|
||||||
|
private TimeZone defaultTimeZone;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() throws FileNotFoundException {
|
||||||
|
themeContextFunction = themeResolver.getThemeContextFunction();
|
||||||
|
themeResolver.setThemeContextFunction(request -> createDefaultContext());
|
||||||
|
|
||||||
|
defaultThemeUrl = ResourceUtils.getURL("classpath:themes/default");
|
||||||
|
defaultTimeZone = TimeZone.getDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void tearDown() {
|
||||||
|
TimeZone.setDefault(defaultTimeZone);
|
||||||
|
this.themeResolver.setThemeContextFunction(themeContextFunction);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void temporalsInAmerica() {
|
||||||
|
TimeZone timeZone = TimeZone.getTimeZone("America/Los_Angeles");
|
||||||
|
TimeZone.setDefault(timeZone);
|
||||||
|
|
||||||
|
assertTemporals(timeZone);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void temporalsInChina() {
|
||||||
|
TimeZone timeZone = TimeZone.getTimeZone("Asia/Shanghai");
|
||||||
|
TimeZone.setDefault(timeZone);
|
||||||
|
|
||||||
|
assertTemporals(timeZone);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void timeZoneFromCookie() {
|
||||||
|
TimeZone timeZone = TimeZone.getTimeZone("Africa/Accra");
|
||||||
|
String formatTime = timeZoneTemporalFormat(timeZone.toZoneId());
|
||||||
|
|
||||||
|
webTestClient.get()
|
||||||
|
.uri("/timezone?language=zh")
|
||||||
|
.cookie(TIME_ZONE_COOKIE_NAME, timeZone.toZoneId().toString())
|
||||||
|
.exchange()
|
||||||
|
.expectStatus()
|
||||||
|
.isOk()
|
||||||
|
.expectBody(String.class)
|
||||||
|
.isEqualTo(String.format("<p>%s</p>\n", formatTime));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void invalidTimeZone() {
|
||||||
|
TimeZone timeZone = TimeZone.getTimeZone("invalid/timezone");
|
||||||
|
//the GMT zone if the given ID cannot be understood.
|
||||||
|
assertThat(timeZone.toZoneId().toString()).isEqualTo("GMT");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertTemporals(TimeZone timeZone) {
|
||||||
|
String formatTime = timeZoneTemporalFormat(timeZone.toZoneId());
|
||||||
|
webTestClient.get()
|
||||||
|
.uri("/timezone?language=zh")
|
||||||
|
.exchange()
|
||||||
|
.expectStatus()
|
||||||
|
.isOk()
|
||||||
|
.expectBody(String.class)
|
||||||
|
.isEqualTo(String.format("<p>%s</p>\n", formatTime));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String timeZoneTemporalFormat(ZoneId zoneId) {
|
||||||
|
Temporals temporals = new Temporals(Locale.CHINESE, zoneId);
|
||||||
|
return temporals.format(INSTANT, "yyyy-MM-dd HH:mm:ss");
|
||||||
|
}
|
||||||
|
|
||||||
|
ThemeContext createDefaultContext() {
|
||||||
|
return ThemeContext.builder()
|
||||||
|
.name("default")
|
||||||
|
.path(Paths.get(defaultThemeUrl.getPath()))
|
||||||
|
.active(true)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@TestConfiguration
|
||||||
|
static class MessageResolverConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
RouterFunction<ServerResponse> routeTestIndex() {
|
||||||
|
return RouterFunctions
|
||||||
|
.route(RequestPredicates.GET("/timezone")
|
||||||
|
.and(RequestPredicates.accept(MediaType.TEXT_HTML)),
|
||||||
|
request -> ServerResponse.ok().render("timezone", Map.of("instants", INSTANT)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,15 +3,14 @@ package run.halo.app.theme.message;
|
||||||
import java.io.FileNotFoundException;
|
import java.io.FileNotFoundException;
|
||||||
import java.net.URL;
|
import java.net.URL;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
import java.time.Duration;
|
|
||||||
import java.util.function.Function;
|
import java.util.function.Function;
|
||||||
import org.junit.jupiter.api.AfterEach;
|
import org.junit.jupiter.api.AfterEach;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
import org.springframework.boot.test.context.TestConfiguration;
|
import org.springframework.boot.test.context.TestConfiguration;
|
||||||
import org.springframework.context.ApplicationContext;
|
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.server.reactive.ServerHttpRequest;
|
import org.springframework.http.server.reactive.ServerHttpRequest;
|
||||||
|
@ -30,27 +29,25 @@ import run.halo.app.theme.ThemeResolver;
|
||||||
* @author guqing
|
* @author guqing
|
||||||
* @since 2.0.0
|
* @since 2.0.0
|
||||||
*/
|
*/
|
||||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
@SpringBootTest
|
||||||
|
@AutoConfigureWebTestClient
|
||||||
public class ThemeMessageResolverIntegrationTest {
|
public class ThemeMessageResolverIntegrationTest {
|
||||||
@Autowired
|
|
||||||
private ApplicationContext applicationContext;
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private ThemeResolver themeResolver;
|
private ThemeResolver themeResolver;
|
||||||
|
|
||||||
private URL defaultThemeUrl;
|
private URL defaultThemeUrl;
|
||||||
|
|
||||||
private URL otherThemeUrl;
|
private URL otherThemeUrl;
|
||||||
|
|
||||||
Function<ServerHttpRequest, ThemeContext> themeContextFunction;
|
Function<ServerHttpRequest, ThemeContext> themeContextFunction;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
private WebTestClient webTestClient;
|
private WebTestClient webTestClient;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() throws FileNotFoundException {
|
void setUp() throws FileNotFoundException {
|
||||||
themeContextFunction = themeResolver.getThemeContextFunction();
|
themeContextFunction = themeResolver.getThemeContextFunction();
|
||||||
webTestClient = WebTestClient
|
|
||||||
.bindToApplicationContext(applicationContext)
|
|
||||||
.configureClient()
|
|
||||||
.responseTimeout(Duration.ofMinutes(1))
|
|
||||||
.build();
|
|
||||||
|
|
||||||
defaultThemeUrl = ResourceUtils.getURL("classpath:themes/default");
|
defaultThemeUrl = ResourceUtils.getURL("classpath:themes/default");
|
||||||
otherThemeUrl = ResourceUtils.getURL("classpath:themes/other");
|
otherThemeUrl = ResourceUtils.getURL("classpath:themes/other");
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
<p th:text="${#temporals.format(instants, 'yyyy-MM-dd HH:mm:ss')}"></p>
|
Loading…
Reference in New Issue