mirror of https://github.com/halo-dev/halo
feat: support configuring default locale in system setting (#7365)
#### What type of PR is this? /kind feature /area core /milestone 2.20.x #### What this PR does / why we need it: 系统设置新增首选语言设置 #### Which issue(s) this PR fixes: Fixes #7047 Fixes https://github.com/halo-dev/halo/issues/7172 Fixes https://github.com/halo-dev/halo/issues/4086 Fixes https://github.com/halo-dev/halo/issues/7336 #### Does this PR introduce a user-facing change? ```release-note 系统设置新增首选语言设置 ```pull/7372/head^2 v2.20.19
parent
23951de314
commit
0676551c77
|
@ -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<Locale> useSystemLocale() {
|
||||
return Optional.ofNullable(language)
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.map(Locale::forLanguageTag);
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
|
|
|
@ -59,6 +59,11 @@ public class SystemConfigurableEnvironmentFetcher implements Reconciler<Reconcil
|
|||
});
|
||||
}
|
||||
|
||||
public Mono<SystemSetting.Basic> getBasic() {
|
||||
return fetch(SystemSetting.Basic.GROUP, SystemSetting.Basic.class)
|
||||
.switchIfEmpty(Mono.just(new SystemSetting.Basic()));
|
||||
}
|
||||
|
||||
public Mono<SystemSetting.Comment> fetchComment() {
|
||||
return fetch(SystemSetting.Comment.GROUP, SystemSetting.Comment.class)
|
||||
.switchIfEmpty(Mono.just(new SystemSetting.Comment()));
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
|
|
|
@ -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<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
|
||||
public Mono<Void> 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);
|
||||
}
|
||||
})
|
||||
|
|
|
@ -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<Void> notify(Reason reason) {
|
||||
|
@ -287,6 +290,8 @@ public class DefaultNotificationCenter implements NotificationCenter {
|
|||
|
||||
Mono<Locale> 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()));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<Unstructured> loadPresetExtensions(String username) {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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<Void> 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<Locale> getUserLocale(ServerHttpRequest request) {
|
||||
return Optional.ofNullable((Locale) request.getAttributes()
|
||||
.get(USER_LOCALE_ATTRIBUTE));
|
||||
}
|
||||
}
|
|
@ -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())
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<!doctype html>
|
||||
<!DOCTYPE html>
|
||||
<html
|
||||
xmlns:th="https://www.thymeleaf.org"
|
||||
th:replace="~{gateway_fragments/layout :: layout(title = |#{title} - Halo|, head = ~{::head}, body = ~{::body})}"
|
||||
|
@ -22,6 +22,18 @@
|
|||
<span th:text="#{form.messages.h2.content}"> </span>
|
||||
</div>
|
||||
<form th:object="${form}" th:action="@{/system/setup}" class="halo-form" method="post">
|
||||
<div class="form-item">
|
||||
<label for="language" th:text="#{form.language.label}"></label>
|
||||
<div class="form-input">
|
||||
<select name="language" id="language">
|
||||
<option value="en" th:selected="${#locale.toLanguageTag} == 'en'">English</option>
|
||||
<option value="es" th:selected="${#locale.toLanguageTag} == 'es'">Español</option>
|
||||
<option value="zh-CN" th:selected="${#locale.toLanguageTag} == 'zh-CN'">简体中文</option>
|
||||
<option value="zh-TW" th:selected="${#locale.toLanguageTag} == 'zh-TW'">繁体中文</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<label for="siteTitle" th:text="#{form.siteTitle.label}"></label>
|
||||
<div class="form-input">
|
||||
|
@ -112,14 +124,20 @@
|
|||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div th:replace="~{gateway_fragments/common::languageSwitcher}"></div>
|
||||
</div>
|
||||
|
||||
<script th:inline="javascript">
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
setupPasswordConfirmation("password", "confirmPassword");
|
||||
});
|
||||
|
||||
document.getElementById("language").addEventListener("change", function () {
|
||||
const selectedLanguage = this.value;
|
||||
const currentURL = new URL(window.location.href);
|
||||
currentURL.searchParams.set("language", selectedLanguage);
|
||||
history.replaceState(null, "", currentURL.toString());
|
||||
window.location.reload();
|
||||
});
|
||||
</script>
|
||||
</th:block>
|
||||
</html>
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
title=系统初始化
|
||||
form.language.label=语言
|
||||
form.siteTitle.label=站点标题
|
||||
form.username.label=用户名
|
||||
form.email.label=电子邮箱
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
title=Setup
|
||||
form.language.label=Language
|
||||
form.siteTitle.label=Site title
|
||||
form.username.label=Username
|
||||
form.email.label=Email
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
title=系統初始化
|
||||
form.language.label=語言
|
||||
form.siteTitle.label=站點標題
|
||||
form.username.label=使用者名稱
|
||||
form.email.label=電子郵件
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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<Ref<string>>("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;
|
||||
};
|
||||
</script>
|
||||
|
|
Loading…
Reference in New Issue