From 11012730778a955b7fa6de3ab426f829a0328bb4 Mon Sep 17 00:00:00 2001 From: shiyu Date: Sun, 19 Apr 2026 16:42:19 +0800 Subject: [PATCH] feat: add default language configuration --- domain/config/api.py | 2 + domain/config/service.py | 1 + domain/config/types.py | 1 + web/src/App.tsx | 69 ++++++++++--------- web/src/api/config.ts | 2 + web/src/apps/PluginHost/index.tsx | 9 ++- web/src/i18n/index.tsx | 33 ++++++--- web/src/i18n/lang.ts | 42 +++++++++++ web/src/i18n/locales/en.json | 2 + web/src/i18n/locales/zh.json | 4 ++ .../SystemSettingsPage/SystemSettingsPage.tsx | 7 +- .../components/AppSettingsTab.tsx | 27 +++++++- .../components/AppearanceSettingsTab.tsx | 2 +- .../components/EmailSettingsTab.tsx | 2 +- .../components/ProtocolMappingsTab.tsx | 2 +- web/src/plugin-frame.ts | 12 +++- web/src/plugins/externals.ts | 7 +- 17 files changed, 165 insertions(+), 59 deletions(-) create mode 100644 web/src/i18n/lang.ts diff --git a/domain/config/api.py b/domain/config/api.py index 8d92367..6dc0df0 100644 --- a/domain/config/api.py +++ b/domain/config/api.py @@ -13,6 +13,7 @@ from .types import ConfigItem router = APIRouter(prefix="/api/config", tags=["config"]) PUBLIC_CONFIG_KEYS = [ + "APP_DEFAULT_LANGUAGE", "THEME_MODE", "THEME_PRIMARY_COLOR", "THEME_BORDER_RADIUS", @@ -56,6 +57,7 @@ async def get_all_config( configs = await ConfigService.get_all() return success(configs) + @router.get("/public") @audit(action=AuditAction.READ, description="获取公开配置") async def get_public_config( diff --git a/domain/config/service.py b/domain/config/service.py index 674aed6..acc6406 100644 --- a/domain/config/service.py +++ b/domain/config/service.py @@ -80,6 +80,7 @@ class ConfigService: logo=logo, favicon=favicon, is_initialized=user_count > 0, + default_language=await cls.get("APP_DEFAULT_LANGUAGE", "zh"), app_domain=await cls.get("APP_DOMAIN"), file_domain=await cls.get("FILE_DOMAIN"), ) diff --git a/domain/config/types.py b/domain/config/types.py index 352a9f5..e8cd9fc 100644 --- a/domain/config/types.py +++ b/domain/config/types.py @@ -14,6 +14,7 @@ class SystemStatus(BaseModel): logo: str favicon: str is_initialized: bool + default_language: str = "zh" app_domain: Optional[str] = None file_domain: Optional[str] = None diff --git a/web/src/App.tsx b/web/src/App.tsx index c4553fc..bf0560e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -10,38 +10,7 @@ import { Routes, Route, Navigate } from 'react-router'; import SetupPage from './pages/SetupPage.tsx'; import { I18nProvider } from './i18n'; -function AppInner() { - const [status, setStatus] = useState(null); - useEffect(() => { - async function checkInitialization() { - try { - const status = await getStatus(); - setStatus(status); - document.title = status.title; - let favicon = document.querySelector("link[rel*='icon']") as HTMLLinkElement | null; - if (!favicon) { - favicon = document.createElement('link'); - favicon.rel = 'icon'; - document.head.appendChild(favicon); - } - if (favicon) { - favicon.href = status.favicon || status.logo; - } - } catch (error) { - console.error("Failed to check initialization status:", error); - } - } - checkInitialization(); - }, []); - - if (status === null) { - return ( -
- -
- ); - } - +function AppInner({ status }: { status: SystemStatus }) { return ( @@ -61,9 +30,41 @@ function AppInner() { } export default function App() { + const [status, setStatus] = useState(null); + + useEffect(() => { + async function checkInitialization() { + try { + const nextStatus = await getStatus(); + setStatus(nextStatus); + document.title = nextStatus.title; + let favicon = document.querySelector("link[rel*='icon']") as HTMLLinkElement | null; + if (!favicon) { + favicon = document.createElement('link'); + favicon.rel = 'icon'; + document.head.appendChild(favicon); + } + if (favicon) { + favicon.href = nextStatus.favicon || nextStatus.logo; + } + } catch (error) { + console.error("Failed to check initialization status:", error); + } + } + checkInitialization(); + }, []); + + if (status === null) { + return ( +
+ +
+ ); + } + return ( - - + + ); } diff --git a/web/src/api/config.ts b/web/src/api/config.ts index b7e7730..272c0bc 100644 --- a/web/src/api/config.ts +++ b/web/src/api/config.ts @@ -1,4 +1,5 @@ import request from './client'; +import type { Lang } from '../i18n/lang'; export async function getConfig(key: string) { return request<{ key: string; value: string }>('/config/?key=' + encodeURIComponent(key)); @@ -25,6 +26,7 @@ export interface SystemStatus { logo: string; favicon: string; is_initialized: boolean; + default_language?: Lang; app_domain?: string; file_domain?: string; } diff --git a/web/src/apps/PluginHost/index.tsx b/web/src/apps/PluginHost/index.tsx index 51e8342..78ef901 100644 --- a/web/src/apps/PluginHost/index.tsx +++ b/web/src/apps/PluginHost/index.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useMemo, useRef } from 'react'; import type { AppComponentProps, AppOpenComponentProps } from '../types'; import type { PluginItem } from '../../api/plugins'; +import { useI18n } from '../../i18n'; export interface PluginAppHostProps extends AppComponentProps { plugin: PluginItem; @@ -34,6 +35,7 @@ export const PluginAppHost: React.FC = ({ entry, onRequestClose, }) => { + const { lang } = useI18n(); const iframeRef = useRef(null); const onCloseRef = useRef(onRequestClose); onCloseRef.current = onRequestClose; @@ -45,10 +47,11 @@ export const PluginAppHost: React.FC = ({ pluginVersion: plugin.version || '', pluginStyles: JSON.stringify(getPluginStylePaths(plugin)), mode: 'file', + lang, filePath, entry: JSON.stringify(entry), }), - [plugin, filePath, entry] + [plugin, filePath, entry, lang] ); useEffect(() => { @@ -86,6 +89,7 @@ export interface PluginAppOpenHostProps extends AppOpenComponentProps { * 注意:同源且不加 sandbox 时,不是安全沙箱(插件仍可通过 window.parent 访问宿主)。 */ export const PluginAppOpenHost: React.FC = ({ plugin, onRequestClose }) => { + const { lang } = useI18n(); const iframeRef = useRef(null); const onCloseRef = useRef(onRequestClose); onCloseRef.current = onRequestClose; @@ -97,8 +101,9 @@ export const PluginAppOpenHost: React.FC = ({ plugin, on pluginVersion: plugin.version || '', pluginStyles: JSON.stringify(getPluginStylePaths(plugin)), mode: 'app', + lang, }), - [plugin] + [plugin, lang] ); useEffect(() => { diff --git a/web/src/i18n/index.tsx b/web/src/i18n/index.tsx index 63d6c8d..5626a5a 100644 --- a/web/src/i18n/index.tsx +++ b/web/src/i18n/index.tsx @@ -2,8 +2,8 @@ import { createContext, useCallback, useContext, useMemo, useState, useEffect } import type { PropsWithChildren } from 'react'; import en from './locales/en.json'; import zhOverrides from './locales/zh.json'; +import { normalizeLang, persistLang, readStoredLang, type Lang } from './lang'; -type Lang = 'zh' | 'en'; type Dict = Record; const dicts: Record = { @@ -11,9 +11,13 @@ const dicts: Record = { zh: { ...en, ...zhOverrides }, }; +interface SetLangOptions { + persist?: boolean; +} + export interface I18nContextValue { lang: Lang; - setLang: (lang: Lang) => void; + setLang: (lang: Lang, options?: SetLangOptions) => void; t: (key: string, params?: Record) => string; } @@ -24,13 +28,26 @@ function interpolate(template: string, params?: Record) return template.replace(/\{(\w+)\}/g, (_, k) => String(params[k] ?? `{${k}}`)); } -export function I18nProvider({ children }: PropsWithChildren) { - const [lang, setLangState] = useState(() => (localStorage.getItem('lang') as Lang) || 'zh'); +interface I18nProviderProps { + defaultLanguage?: Lang; +} - const setLang = useCallback((l: Lang) => { - setLangState(l); - localStorage.setItem('lang', l); - }, []); +export function I18nProvider({ children, defaultLanguage }: PropsWithChildren) { + const fallbackLang = normalizeLang(defaultLanguage, 'zh'); + const [lang, setLangState] = useState(() => readStoredLang() ?? fallbackLang); + + const setLang = useCallback((nextLang: Lang, options?: SetLangOptions) => { + const normalized = normalizeLang(nextLang, fallbackLang); + setLangState(normalized); + if (options?.persist === false) return; + persistLang(normalized); + }, [fallbackLang]); + + useEffect(() => { + if (!readStoredLang()) { + setLangState(fallbackLang); + } + }, [fallbackLang]); useEffect(() => { document.documentElement.lang = lang; diff --git a/web/src/i18n/lang.ts b/web/src/i18n/lang.ts new file mode 100644 index 0000000..8f14230 --- /dev/null +++ b/web/src/i18n/lang.ts @@ -0,0 +1,42 @@ +export type Lang = 'zh' | 'en'; + +const LANG_STORAGE_KEY = 'lang'; + +export function parseLang(raw: unknown): Lang | null { + if (typeof raw !== 'string') return null; + const value = raw.trim().toLowerCase(); + if (!value) return null; + if (value === 'en' || value.startsWith('en-')) return 'en'; + if (value === 'zh' || value.startsWith('zh-')) return 'zh'; + return null; +} + +export function normalizeLang(raw: unknown, fallback: Lang = 'zh'): Lang { + return parseLang(raw) ?? fallback; +} + +export function readStoredLang(): Lang | null { + if (typeof window === 'undefined') return null; + try { + return parseLang(window.localStorage.getItem(LANG_STORAGE_KEY)); + } catch { + return null; + } +} + +export function persistLang(lang: Lang): void { + if (typeof window === 'undefined') return; + try { + window.localStorage.setItem(LANG_STORAGE_KEY, lang); + } catch { + void 0; + } +} + +export function getActiveLang(fallback: Lang = 'zh'): Lang { + if (typeof document !== 'undefined') { + const documentLang = parseLang(document.documentElement.lang); + if (documentLang) return documentLang; + } + return readStoredLang() ?? fallback; +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 5be3fe7..7ed7482 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -31,6 +31,8 @@ "Language": "Language", "Chinese": "中文", "English": "English", + "Default Language": "Default Language", + "Used when the user has not selected a language": "Used when the user has not selected a language", "Full Name": "Full Name", "Email": "Email", "Change Password": "Change Password", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index d2e2db0..553fa21 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -52,6 +52,10 @@ "Profile": "个人资料", "Account Settings": "账户设置", "Language": "语言", + "Chinese": "中文", + "English": "English", + "Default Language": "默认语言", + "Used when the user has not selected a language": "用户未手动选择语言时使用", "Full Name": "昵称", "Email": "邮箱", "Change Password": "修改密码", diff --git a/web/src/pages/SystemSettingsPage/SystemSettingsPage.tsx b/web/src/pages/SystemSettingsPage/SystemSettingsPage.tsx index 73fd0b3..ea06c95 100644 --- a/web/src/pages/SystemSettingsPage/SystemSettingsPage.tsx +++ b/web/src/pages/SystemSettingsPage/SystemSettingsPage.tsx @@ -66,7 +66,7 @@ export default function SystemSettingsPage({ tabKey, onTabNavigate }: SystemSett }); }, [t]); - const handleSave = async (values: Record) => { + const handleSave = async (values: Record): Promise => { setLoading(true); try { for (const [key, value] of Object.entries(values)) { @@ -81,10 +81,13 @@ export default function SystemSettingsPage({ tabKey, onTabNavigate }: SystemSett if (Object.keys(values).some(k => Object.values(THEME_KEYS).includes(k))) { await refreshTheme(); } + return true; } catch (e: any) { message.error(e.message || t('Save failed')); + return false; + } finally { + setLoading(false); } - setLoading(false); }; // 离开“外观设置”时,恢复后端持久化配置(取消未保存的预览) diff --git a/web/src/pages/SystemSettingsPage/components/AppSettingsTab.tsx b/web/src/pages/SystemSettingsPage/components/AppSettingsTab.tsx index 0d38b01..e8f0608 100644 --- a/web/src/pages/SystemSettingsPage/components/AppSettingsTab.tsx +++ b/web/src/pages/SystemSettingsPage/components/AppSettingsTab.tsx @@ -2,6 +2,7 @@ import { Alert, Button, Divider, Form, Input, Select, Switch, message } from 'an import { useEffect, useMemo, useState } from 'react'; import { rolesApi, type RoleInfo } from '../../../api/roles'; import { useI18n } from '../../../i18n'; +import { normalizeLang, readStoredLang } from '../../../i18n/lang'; interface AppConfigKey { key: string; @@ -12,7 +13,7 @@ interface AppConfigKey { interface AppSettingsTabProps { config: Record; loading: boolean; - onSave: (values: Record) => Promise; + onSave: (values: Record) => Promise; configKeys: AppConfigKey[]; } @@ -22,7 +23,7 @@ export default function AppSettingsTab({ onSave, configKeys, }: AppSettingsTabProps) { - const { t } = useI18n(); + const { t, setLang } = useI18n(); const [rolesLoading, setRolesLoading] = useState(false); const [roles, setRoles] = useState([]); @@ -52,6 +53,7 @@ export default function AppSettingsTab({ const roleId = roleIdRaw ? Number(roleIdRaw) : undefined; return { ...Object.fromEntries(configKeys.map(({ key, default: def }) => [key, config[key] ?? def ?? ''])), + APP_DEFAULT_LANGUAGE: normalizeLang(config.APP_DEFAULT_LANGUAGE, 'zh'), AUTH_ALLOW_REGISTER: allowRegister, AUTH_DEFAULT_REGISTER_ROLE_ID: Number.isFinite(roleId) ? roleId : undefined, }; @@ -66,12 +68,17 @@ export default function AppSettingsTab({ for (const { key } of configKeys) { payload[key] = vals[key]; } + const defaultLanguage = normalizeLang(vals.APP_DEFAULT_LANGUAGE, 'zh'); + payload.APP_DEFAULT_LANGUAGE = defaultLanguage; const allow = !!vals.AUTH_ALLOW_REGISTER; payload.AUTH_ALLOW_REGISTER = allow ? 'true' : 'false'; if (allow) { payload.AUTH_DEFAULT_REGISTER_ROLE_ID = String(vals.AUTH_DEFAULT_REGISTER_ROLE_ID); } - await onSave(payload); + const saved = await onSave(payload); + if (saved && !readStoredLang()) { + setLang(defaultLanguage, { persist: false }); + } }} style={{ marginTop: 24 }} key={JSON.stringify(config)} @@ -82,6 +89,20 @@ export default function AppSettingsTab({ ))} + +