diff --git a/api/src/main/java/run/halo/app/infra/SystemSetting.java b/api/src/main/java/run/halo/app/infra/SystemSetting.java index 1256bf429..7df7fe465 100644 --- a/api/src/main/java/run/halo/app/infra/SystemSetting.java +++ b/api/src/main/java/run/halo/app/infra/SystemSetting.java @@ -1,12 +1,16 @@ package run.halo.app.infra; +import com.fasterxml.jackson.annotation.JsonIgnore; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; +import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; import lombok.Data; import lombok.experimental.Accessors; +import org.apache.commons.lang3.StringUtils; import org.springframework.boot.convert.ApplicationConversionService; import run.halo.app.extension.ConfigMap; import run.halo.app.infra.utils.JsonUtils; @@ -66,6 +70,14 @@ public class SystemSetting { String subtitle; String logo; String favicon; + String language; + + @JsonIgnore + public Optional useSystemLocale() { + return Optional.ofNullable(language) + .filter(StringUtils::isNotBlank) + .map(Locale::forLanguageTag); + } } @Data diff --git a/application/src/main/java/run/halo/app/infra/SystemConfigurableEnvironmentFetcher.java b/application/src/main/java/run/halo/app/infra/SystemConfigurableEnvironmentFetcher.java index f210ed8b5..1d59c19bd 100644 --- a/application/src/main/java/run/halo/app/infra/SystemConfigurableEnvironmentFetcher.java +++ b/application/src/main/java/run/halo/app/infra/SystemConfigurableEnvironmentFetcher.java @@ -59,6 +59,11 @@ public class SystemConfigurableEnvironmentFetcher implements Reconciler getBasic() { + return fetch(SystemSetting.Basic.GROUP, SystemSetting.Basic.class) + .switchIfEmpty(Mono.just(new SystemSetting.Basic())); + } + public Mono fetchComment() { return fetch(SystemSetting.Comment.GROUP, SystemSetting.Comment.class) .switchIfEmpty(Mono.just(new SystemSetting.Comment())); diff --git a/application/src/main/java/run/halo/app/infra/actuator/GlobalInfoServiceImpl.java b/application/src/main/java/run/halo/app/infra/actuator/GlobalInfoServiceImpl.java index f671f0c97..77059f978 100644 --- a/application/src/main/java/run/halo/app/infra/actuator/GlobalInfoServiceImpl.java +++ b/application/src/main/java/run/halo/app/infra/actuator/GlobalInfoServiceImpl.java @@ -119,6 +119,7 @@ public class GlobalInfoServiceImpl implements GlobalInfoService { if (basic != null) { info.setFavicon(basic.getFavicon()); info.setSiteTitle(basic.getTitle()); + basic.useSystemLocale().ifPresent(info::setLocale); } } diff --git a/application/src/main/java/run/halo/app/infra/config/WebFluxConfig.java b/application/src/main/java/run/halo/app/infra/config/WebFluxConfig.java index 6e75e4f06..d57d84f7c 100644 --- a/application/src/main/java/run/halo/app/infra/config/WebFluxConfig.java +++ b/application/src/main/java/run/halo/app/infra/config/WebFluxConfig.java @@ -45,7 +45,9 @@ import run.halo.app.infra.console.WebSocketRequestPredicate; import run.halo.app.infra.properties.AttachmentProperties; import run.halo.app.infra.properties.HaloProperties; import run.halo.app.infra.webfilter.AdditionalWebFilterChainProxy; +import run.halo.app.infra.webfilter.LocaleChangeWebFilter; import run.halo.app.plugin.extensionpoint.ExtensionGetter; +import run.halo.app.theme.UserLocaleRequestAttributeWriteFilter; @Configuration public class WebFluxConfig implements WebFluxConfigurer { @@ -219,15 +221,22 @@ public class WebFluxConfig implements WebFluxConfigurer { @ConditionalOnProperty(name = "halo.console.proxy.enabled", havingValue = "true") @Bean - @Order(Ordered.HIGHEST_PRECEDENCE) + @Order(Ordered.HIGHEST_PRECEDENCE + 2) ProxyFilter consoleProxyFilter() { return new ProxyFilter("/console/**", haloProp.getConsole().getProxy()); } + /** + * Order of this filter is higher than + * {@link LocaleChangeWebFilter} to allow change locale in dev + * mode. + * {@link UserLocaleRequestAttributeWriteFilter} is before {@link LocaleChangeWebFilter} to + * obtain the locale + */ @ConditionalOnProperty(name = "halo.uc.proxy.enabled", havingValue = "true") @Bean - @Order(Ordered.HIGHEST_PRECEDENCE) + @Order(Ordered.HIGHEST_PRECEDENCE + 2) ProxyFilter ucProxyFilter() { return new ProxyFilter("/uc/**", haloProp.getUc().getProxy()); } diff --git a/application/src/main/java/run/halo/app/infra/webfilter/LocaleChangeWebFilter.java b/application/src/main/java/run/halo/app/infra/webfilter/LocaleChangeWebFilter.java index 5f1885a59..3233d4ad5 100644 --- a/application/src/main/java/run/halo/app/infra/webfilter/LocaleChangeWebFilter.java +++ b/application/src/main/java/run/halo/app/infra/webfilter/LocaleChangeWebFilter.java @@ -1,10 +1,11 @@ package run.halo.app.infra.webfilter; import static run.halo.app.theme.ThemeLocaleContextResolver.LANGUAGE_COOKIE_NAME; -import static run.halo.app.theme.ThemeLocaleContextResolver.LANGUAGE_PARAMETER_NAME; import java.util.Locale; import java.util.Set; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.http.ResponseCookie; @@ -15,18 +16,26 @@ import org.springframework.security.web.server.util.matcher.ServerWebExchangeMat import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher.MatchResult; import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers; import org.springframework.stereotype.Component; -import org.springframework.util.StringUtils; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.WebFilter; import org.springframework.web.server.WebFilterChain; import reactor.core.publisher.Mono; +import run.halo.app.theme.ThemeLocaleContextResolver; +import run.halo.app.theme.UserLocaleRequestAttributeWriteFilter; +/** + * {@link UserLocaleRequestAttributeWriteFilter} is before {@link LocaleChangeWebFilter} to + * obtain the locale. + */ @Component +@Order(Ordered.HIGHEST_PRECEDENCE + 1) public class LocaleChangeWebFilter implements WebFilter { private final ServerWebExchangeMatcher matcher; + private final ThemeLocaleContextResolver themeLocaleContextResolver; - public LocaleChangeWebFilter() { + public LocaleChangeWebFilter(ThemeLocaleContextResolver themeLocaleContextResolver) { + this.themeLocaleContextResolver = themeLocaleContextResolver; var pathMatcher = ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, "/**"); var textHtmlMatcher = new MediaTypeServerWebExchangeMatcher(MediaType.TEXT_HTML); textHtmlMatcher.setIgnoredMediaTypes(Set.of(MediaType.ALL)); @@ -35,16 +44,14 @@ public class LocaleChangeWebFilter implements WebFilter { @Override @NonNull - public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { + public Mono filter(ServerWebExchange exchange, @NonNull WebFilterChain chain) { var request = exchange.getRequest(); return matcher.matches(exchange) .filter(MatchResult::isMatch) .doOnNext(result -> { - var language = request - .getQueryParams() - .getFirst(LANGUAGE_PARAMETER_NAME); - if (StringUtils.hasText(language)) { - var locale = Locale.forLanguageTag(language); + var localeContext = themeLocaleContextResolver.resolveLocaleContext(exchange); + var locale = localeContext.getLocale(); + if (locale != null) { setLanguageCookie(exchange, locale); } }) diff --git a/application/src/main/java/run/halo/app/notification/DefaultNotificationCenter.java b/application/src/main/java/run/halo/app/notification/DefaultNotificationCenter.java index 79ab9caa1..7eb502281 100644 --- a/application/src/main/java/run/halo/app/notification/DefaultNotificationCenter.java +++ b/application/src/main/java/run/halo/app/notification/DefaultNotificationCenter.java @@ -20,6 +20,8 @@ import run.halo.app.core.extension.notification.ReasonType; import run.halo.app.core.extension.notification.Subscription; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; +import run.halo.app.infra.SystemSetting; import run.halo.app.notification.endpoint.SubscriptionRouter; /** @@ -41,6 +43,7 @@ public class DefaultNotificationCenter implements NotificationCenter { private final SubscriptionRouter subscriptionRouter; private final RecipientResolver recipientResolver; private final SubscriptionService subscriptionService; + private final SystemConfigurableEnvironmentFetcher environmentFetcher; @Override public Mono notify(Reason reason) { @@ -287,6 +290,8 @@ public class DefaultNotificationCenter implements NotificationCenter { Mono getLocaleFromSubscriber(Subscriber subscriber) { // TODO get locale from subscriber - return Mono.just(Locale.getDefault()); + return environmentFetcher.getBasic() + .map(SystemSetting.Basic::useSystemLocale) + .map(localeOpt -> localeOpt.orElse(Locale.getDefault())); } } diff --git a/application/src/main/java/run/halo/app/security/preauth/SystemSetupEndpoint.java b/application/src/main/java/run/halo/app/security/preauth/SystemSetupEndpoint.java index a9061bee8..e7c933bcc 100644 --- a/application/src/main/java/run/halo/app/security/preauth/SystemSetupEndpoint.java +++ b/application/src/main/java/run/halo/app/security/preauth/SystemSetupEndpoint.java @@ -212,6 +212,7 @@ public class SystemSetupEndpoint { String basic = data.getOrDefault(SystemSetting.Basic.GROUP, "{}"); var basicSetting = JsonUtils.jsonToObject(basic, SystemSetting.Basic.class); basicSetting.setTitle(body.getSiteTitle()); + basicSetting.setLanguage(body.getLanguage()); data.put(SystemSetting.Basic.GROUP, JsonUtils.objectToJson(basicSetting)); } @@ -272,6 +273,11 @@ public class SystemSetupEndpoint { public String getSiteTitle() { return formData.getFirst("siteTitle"); } + + @Pattern(regexp = "^(zh-CN|zh-TW|en|es)$") + public String getLanguage() { + return formData.getFirst("language"); + } } Flux loadPresetExtensions(String username) { diff --git a/application/src/main/java/run/halo/app/theme/ThemeLocaleContextResolver.java b/application/src/main/java/run/halo/app/theme/ThemeLocaleContextResolver.java index 9220690a3..3d23b2f64 100644 --- a/application/src/main/java/run/halo/app/theme/ThemeLocaleContextResolver.java +++ b/application/src/main/java/run/halo/app/theme/ThemeLocaleContextResolver.java @@ -29,12 +29,14 @@ public class ThemeLocaleContextResolver extends AcceptHeaderLocaleContextResolve public static final String TIME_ZONE_COOKIE_NAME = "time_zone"; + @Override @NonNull public LocaleContext resolveLocaleContext(@NonNull ServerWebExchange exchange) { var request = exchange.getRequest(); var locale = getLocaleFromQueryParameter(request) .or(() -> getLocaleFromCookie(request)) + .or(() -> UserLocaleRequestAttributeWriteFilter.getUserLocale(request)) .orElseGet(() -> super.resolveLocaleContext(exchange).getLocale()); var timeZone = getTimeZoneFromCookie(request) @@ -62,5 +64,4 @@ public class ThemeLocaleContextResolver extends AcceptHeaderLocaleContextResolve .filter(StringUtils::isNotBlank) .map(TimeZone::getTimeZone); } - } diff --git a/application/src/main/java/run/halo/app/theme/UserLocaleRequestAttributeWriteFilter.java b/application/src/main/java/run/halo/app/theme/UserLocaleRequestAttributeWriteFilter.java new file mode 100644 index 000000000..4beaf1701 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/UserLocaleRequestAttributeWriteFilter.java @@ -0,0 +1,42 @@ +package run.halo.app.theme; + +import java.util.Locale; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; +import reactor.core.publisher.Mono; +import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; +import run.halo.app.infra.SystemSetting; + +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +@RequiredArgsConstructor +public class UserLocaleRequestAttributeWriteFilter implements WebFilter { + public static final String USER_LOCALE_ATTRIBUTE = + UserLocaleRequestAttributeWriteFilter.class.getName() + ".USER_LOCALE_ATTRIBUTE"; + + private final SystemConfigurableEnvironmentFetcher environmentFetcher; + + @Override + @NonNull + public Mono filter(@NonNull ServerWebExchange exchange, @NonNull WebFilterChain chain) { + return environmentFetcher.getBasic() + .map(SystemSetting.Basic::useSystemLocale) + .doOnNext(localeOpt -> localeOpt + .ifPresent(locale -> exchange.getAttributes().put(USER_LOCALE_ATTRIBUTE, locale)) + ) + .then(chain.filter(exchange)); + } + + public static Optional getUserLocale(ServerHttpRequest request) { + return Optional.ofNullable((Locale) request.getAttributes() + .get(USER_LOCALE_ATTRIBUTE)); + } +} diff --git a/application/src/main/java/run/halo/app/theme/finders/vo/SiteSettingVo.java b/application/src/main/java/run/halo/app/theme/finders/vo/SiteSettingVo.java index de56c2b43..7d652687e 100644 --- a/application/src/main/java/run/halo/app/theme/finders/vo/SiteSettingVo.java +++ b/application/src/main/java/run/halo/app/theme/finders/vo/SiteSettingVo.java @@ -1,6 +1,7 @@ package run.halo.app.theme.finders.vo; import java.net.URL; +import java.util.Locale; import java.util.Map; import lombok.Builder; import lombok.Value; @@ -34,6 +35,8 @@ public class SiteSettingVo { String favicon; + String language; + Boolean allowRegistration; PostSetting post; @@ -74,6 +77,7 @@ public class SiteSettingVo { .logo(basicSetting.getLogo()) .favicon(basicSetting.getFavicon()) .allowRegistration(userSetting.isAllowRegistration()) + .language(basicSetting.useSystemLocale().orElse(Locale.getDefault()).toLanguageTag()) .post(PostSetting.builder() .postPageSize(postSetting.getPostPageSize()) .archivePageSize(postSetting.getArchivePageSize()) diff --git a/application/src/main/resources/extensions/system-setting.yaml b/application/src/main/resources/extensions/system-setting.yaml index 7c9d34ad6..c085f7bbe 100644 --- a/application/src/main/resources/extensions/system-setting.yaml +++ b/application/src/main/resources/extensions/system-setting.yaml @@ -24,6 +24,19 @@ spec: name: favicon accepts: - 'image/*' + - $formkit: select + label: "首选语言" + name: language + value: 'zh-CN' + options: + - label: 'English' + value: 'en' + - label: 'Español' + value: 'es' + - label: '简体中文' + value: 'zh-CN' + - label: '繁体中文' + value: 'zh-TW' - group: post label: 文章设置 formSchema: diff --git a/application/src/main/resources/static/styles/main.css b/application/src/main/resources/static/styles/main.css index 742e171ff..0a4be9fcd 100644 --- a/application/src/main/resources/static/styles/main.css +++ b/application/src/main/resources/static/styles/main.css @@ -107,6 +107,19 @@ display: block; } +.halo-form .form-item select { + appearance: none; + font-size: var(--text-base); + box-shadow: none; + width: 100%; + height: 100%; + display: block; + outline: none; + border: none; + background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='m12 13.171l4.95-4.95l1.414 1.415L12 16L5.636 9.636L7.05 8.222z'/%3E%3C/svg%3E") + right 0em center no-repeat; +} + .halo-form .form-item input:focus { outline: none; } diff --git a/application/src/main/resources/templates/setup.html b/application/src/main/resources/templates/setup.html index aa3c2275b..d285702f8 100644 --- a/application/src/main/resources/templates/setup.html +++ b/application/src/main/resources/templates/setup.html @@ -1,4 +1,4 @@ - +
+
+ +
+ +
+
+
@@ -112,14 +124,20 @@
- -
diff --git a/application/src/main/resources/templates/setup.properties b/application/src/main/resources/templates/setup.properties index e974e8f79..829c29b22 100644 --- a/application/src/main/resources/templates/setup.properties +++ b/application/src/main/resources/templates/setup.properties @@ -1,4 +1,5 @@ title=系统初始化 +form.language.label=语言 form.siteTitle.label=站点标题 form.username.label=用户名 form.email.label=电子邮箱 diff --git a/application/src/main/resources/templates/setup_en.properties b/application/src/main/resources/templates/setup_en.properties index af468d633..d46b15503 100644 --- a/application/src/main/resources/templates/setup_en.properties +++ b/application/src/main/resources/templates/setup_en.properties @@ -1,4 +1,5 @@ title=Setup +form.language.label=Language form.siteTitle.label=Site title form.username.label=Username form.email.label=Email diff --git a/application/src/main/resources/templates/setup_es.properties b/application/src/main/resources/templates/setup_es.properties index 1b0c20f2d..ee4bbf2fe 100644 --- a/application/src/main/resources/templates/setup_es.properties +++ b/application/src/main/resources/templates/setup_es.properties @@ -1,4 +1,5 @@ title=Configuración +form.language.label=Idioma form.siteTitle.label=Título del Sitio form.username.label=Nombre de Usuario form.email.label=Correo Electrónico diff --git a/application/src/main/resources/templates/setup_zh_TW.properties b/application/src/main/resources/templates/setup_zh_TW.properties index 2a9b066a5..ad9d471dd 100644 --- a/application/src/main/resources/templates/setup_zh_TW.properties +++ b/application/src/main/resources/templates/setup_zh_TW.properties @@ -1,4 +1,5 @@ title=系統初始化 +form.language.label=語言 form.siteTitle.label=站點標題 form.username.label=使用者名稱 form.email.label=電子郵件 diff --git a/application/src/test/java/run/halo/app/notification/DefaultNotificationCenterTest.java b/application/src/test/java/run/halo/app/notification/DefaultNotificationCenterTest.java index fdbb4cc45..5545a575b 100644 --- a/application/src/test/java/run/halo/app/notification/DefaultNotificationCenterTest.java +++ b/application/src/test/java/run/halo/app/notification/DefaultNotificationCenterTest.java @@ -30,6 +30,8 @@ import run.halo.app.core.extension.notification.ReasonType; import run.halo.app.core.extension.notification.Subscription; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; +import run.halo.app.infra.SystemSetting; /** * Tests for {@link DefaultNotificationCenter}. @@ -61,6 +63,9 @@ class DefaultNotificationCenterTest { @Mock private SubscriptionService subscriptionService; + @Mock + private SystemConfigurableEnvironmentFetcher environmentFetcher; + @InjectMocks private DefaultNotificationCenter notificationCenter; @@ -317,6 +322,7 @@ class DefaultNotificationCenterTest { void getLocaleFromSubscriberTest() { var subscription = mock(Subscriber.class); + when(environmentFetcher.getBasic()).thenReturn(Mono.just(new SystemSetting.Basic())); notificationCenter.getLocaleFromSubscriber(subscription) .as(StepVerifier::create) .expectNext(Locale.getDefault()) diff --git a/application/src/test/java/run/halo/app/webfilter/LocaleChangeWebFilterTest.java b/application/src/test/java/run/halo/app/webfilter/LocaleChangeWebFilterTest.java index 417bc132e..4d336bc4a 100644 --- a/application/src/test/java/run/halo/app/webfilter/LocaleChangeWebFilterTest.java +++ b/application/src/test/java/run/halo/app/webfilter/LocaleChangeWebFilterTest.java @@ -15,6 +15,7 @@ import org.springframework.mock.web.server.MockServerWebExchange; import org.springframework.web.server.WebFilterChain; import reactor.core.publisher.Mono; import run.halo.app.infra.webfilter.LocaleChangeWebFilter; +import run.halo.app.theme.ThemeLocaleContextResolver; class LocaleChangeWebFilterTest { @@ -22,7 +23,8 @@ class LocaleChangeWebFilterTest { @BeforeEach void setUp() { - filter = new LocaleChangeWebFilter(); + var themeLocaleContextResolver = new ThemeLocaleContextResolver(); + filter = new LocaleChangeWebFilter(themeLocaleContextResolver); } @Test diff --git a/ui/console-src/modules/system/settings/tabs/Setting.vue b/ui/console-src/modules/system/settings/tabs/Setting.vue index e2df8c1fb..0f2ac7188 100644 --- a/ui/console-src/modules/system/settings/tabs/Setting.vue +++ b/ui/console-src/modules/system/settings/tabs/Setting.vue @@ -16,7 +16,7 @@ import { useI18n } from "vue-i18n"; const SYSTEM_CONFIGMAP_NAME = "system"; -const { t } = useI18n(); +const { t, locale } = useI18n(); const queryClient = useQueryClient(); const group = inject>("activeTab", ref("basic")); @@ -60,6 +60,10 @@ const handleSaveConfigMap = async () => { queryClient.invalidateQueries({ queryKey: ["system-configMap"] }); await useGlobalInfoStore().fetchGlobalInfo(); + const language = configMapFormData.value.basic.language; + locale.value = language; + document.cookie = `language=${language}; path=/; SameSite=Lax; Secure`; + saving.value = false; };