From 1625cfb2fb5d2281b964f145e3367e62c2ef142b Mon Sep 17 00:00:00 2001 From: ryan Date: Fri, 24 Jul 2026 16:25:41 +0800 Subject: [PATCH] feat(frontend): add next-intl bilingual i18n for core paths Wire next-intl without locale routes, add zh-CN/en catalogs, language switcher, and migrate layout/auth/settings UI copy. Document i18n rules in AGENTS.md and keep static export builds working. --- AGENTS.md | 13 + frontend/app/(main)/settings/page.tsx | 68 +-- frontend/app/layout.tsx | 43 +- frontend/components/auth/login-form.tsx | 62 ++- frontend/components/auth/otp-form.tsx | 20 +- frontend/components/auth/register-form.tsx | 77 ++-- .../components/common/language-switcher.tsx | 78 ++++ .../common/settings/access-token.tsx | 117 +++-- .../components/common/settings/appearance.tsx | 61 ++- .../common/settings/notifications.tsx | 19 +- frontend/components/layout/header.tsx | 31 +- frontend/components/layout/sidebar.tsx | 238 +++++----- .../components/providers/intl-provider.tsx | 24 + frontend/i18n/client.ts | 72 +++ frontend/i18n/config.ts | 67 +++ frontend/i18n/format.ts | 39 ++ frontend/i18n/request.ts | 44 ++ frontend/messages/en.json | 223 +++++++++ frontend/messages/zh-CN.json | 223 +++++++++ frontend/next.config.ts | 5 +- frontend/package.json | 3 +- frontend/pnpm-lock.yaml | 431 +++++++++++++++++- frontend/scripts/check-i18n-keys.mjs | 35 ++ 23 files changed, 1638 insertions(+), 355 deletions(-) create mode 100644 frontend/components/common/language-switcher.tsx create mode 100644 frontend/components/providers/intl-provider.tsx create mode 100644 frontend/i18n/client.ts create mode 100644 frontend/i18n/config.ts create mode 100644 frontend/i18n/format.ts create mode 100644 frontend/i18n/request.ts create mode 100644 frontend/messages/en.json create mode 100644 frontend/messages/zh-CN.json create mode 100644 frontend/scripts/check-i18n-keys.mjs diff --git a/AGENTS.md b/AGENTS.md index 10f6a863..f254f304 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,6 +92,8 @@ - `frontend/components/layout/`:Header / Sidebar / Footer 页面框架组件。 - `frontend/components//`:特定业务域的 UI 组件(如 `auth/`、`home/`)。 - `frontend/lib/services/`:基于 `BaseService` 继承的类型化前端 API 服务。 +- `frontend/i18n/`:前端国际化配置(locale 解析、cookie、request config)。 +- `frontend/messages/`:i18n 文案目录(`zh-CN.json` / `en.json`)。 - `frontend/contexts/` / `hooks/` / `lib/` / `types/` / `public/`:全局状态、Hook、客户端工具、TS 类型定义与静态资源。 ## 后端开发规范 @@ -123,3 +125,14 @@ - **样式与服务**: - 优先使用 shadcn/ui 的 `variant` 和全局 CSS 变量,不要在业务代码中硬编码颜色/背景。 - 前端请求统一在 `frontend/lib/services//` 中继承 `BaseService` 编写并在 `index.ts` 注册。 +- **国际化 (i18n)**: + - 使用 `next-intl`(**无 URL locale 前缀** / non-routing provider 模式),兼容 `NEXT_STANDALONE_EXPORT` 静态导出。 + - 支持语言:`zh-CN`、`en`;默认 `zh-CN`。 + - 解析优先级:cookie `NEXT_LOCALE`(用户显式选择)→ 浏览器语言 → 默认 `zh-CN`。 + - 文案统一放在 `frontend/messages/{locale}.json`,按命名空间嵌套(`common` / `layout` / `auth` / `settings` / 业务域)。 + - 组件内用户可见文案必须通过 `useTranslations()` / `getTranslations()` 读取;**禁止**新增中英硬编码 UI 字符串(后端返回的 `error_msg`、日志、调试信息除外)。 + - key 使用 camelCase 分层(如 `auth.login.submit`);完整短语作为 value,禁止在组件内拼接句子。 + - 新增或修改文案时必须**同步**更新 `zh-CN.json` 与 `en.json`,保持 key 树一致。 + - 语言选项展示用自称:`中文` / `English`(不随当前 UI 语言翻译)。 + - 日期/数字格式化使用 locale 感知 helper(如 `formatDateTime`),禁止写死 `'zh-CN'` / `date-fns` 的 `zhCN`(除非该路径尚未迁移且不在本次改动范围)。 + - 设计说明见 `docs/superpowers/specs/2026-07-24-frontend-i18n-design.md`。 diff --git a/frontend/app/(main)/settings/page.tsx b/frontend/app/(main)/settings/page.tsx index 0fc56713..b1f3b51a 100644 --- a/frontend/app/(main)/settings/page.tsx +++ b/frontend/app/(main)/settings/page.tsx @@ -1,6 +1,7 @@ 'use client'; import Link from 'next/link'; +import { useTranslations } from 'next-intl'; import { Card, CardContent, @@ -9,39 +10,40 @@ import { } from '@/components/ui/card'; import { Bell, Key, Palette, UserRound } from 'lucide-react'; -/* 设置项 */ -const settingsItems = [ - { - title: '个人资料', - description: '查看您的账户信息', - icon: UserRound, - href: '/settings/profile', - category: '个人设置', - }, - { - title: '访问令牌 (AccessToken)', - description: '管理用于 API 访问的个人 Token', - icon: Key, - href: '/settings/access-token', - category: '个人设置', - }, - { - title: '通知设置', - description: '设置您的通知偏好', - icon: Bell, - href: '/settings/notifications', - category: '账户设置', - }, - { - title: '外观设置', - description: '自定义界面主题 and 显示', - icon: Palette, - href: '/settings/appearance', - category: '个人设置', - }, -]; - export default function SettingsPage() { + const t = useTranslations('settings'); + + const settingsItems = [ + { + title: t('items.profile'), + description: t('items.profileDesc'), + icon: UserRound, + href: '/settings/profile', + category: t('categories.personal'), + }, + { + title: t('items.accessToken'), + description: t('items.accessTokenDesc'), + icon: Key, + href: '/settings/access-token', + category: t('categories.personal'), + }, + { + title: t('items.notifications'), + description: t('items.notificationsDesc'), + icon: Bell, + href: '/settings/notifications', + category: t('categories.account'), + }, + { + title: t('items.appearance'), + description: t('items.appearanceDesc'), + icon: Palette, + href: '/settings/appearance', + category: t('categories.personal'), + }, + ]; + const groupedSettings = settingsItems.reduce( (acc, item) => { if (!acc[item.category]) { @@ -55,7 +57,7 @@ export default function SettingsPage() { return (
-
设置
+
{t('title')}
{Object.entries(groupedSettings).map(([category, items]) => (
diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index ff799df8..51228303 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from 'next'; import { Inter } from 'next/font/google'; +import { getLocale, getMessages, getTranslations } from 'next-intl/server'; import { Toaster } from '@/components/ui/sonner'; import { ThemeProvider } from '@/components/layout/theme-provider'; import { CustomThemeProvider } from '@/lib/theme'; @@ -7,8 +8,10 @@ import { BellRingProvider } from '@/contexts/bell-ring-context'; import { NotificationSettingsProvider } from '@/contexts/notification-settings-context'; import { UserProvider } from '@/contexts/user-context'; import { AppQueryProvider } from '@/components/providers/query-provider'; +import { AppIntlProvider } from '@/components/providers/intl-provider'; import { SiteTitleUpdater } from '@/components/providers/title-updater'; import { RobotsMeta } from '@/components/layout/robots-meta'; +import type { AppLocale } from '@/i18n/config'; import './globals.css'; const inter = Inter({ @@ -17,19 +20,25 @@ const inter = Inter({ display: 'swap', }); -export const metadata: Metadata = { - title: 'Wavelet Platform', - description: '通用二次开发标准范式平台', -}; +export async function generateMetadata(): Promise { + const t = await getTranslations('metadata'); + return { + title: t('title'), + description: t('description'), + }; +} -export default function RootLayout({ +export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { + const locale = (await getLocale()) as AppLocale; + const messages = await getMessages(); + return ( @@ -42,16 +51,18 @@ export default function RootLayout({ > - - - - - - {children} - - - - + + + + + + + {children} + + + + + diff --git a/frontend/components/auth/login-form.tsx b/frontend/components/auth/login-form.tsx index 87dc7563..3da5f874 100644 --- a/frontend/components/auth/login-form.tsx +++ b/frontend/components/auth/login-form.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState, type ReactNode } from 'react'; import { useMutation, useQuery } from '@tanstack/react-query'; import { useSearchParams } from 'next/navigation'; import { EyeIcon, EyeOffIcon } from 'lucide-react'; @@ -19,6 +19,7 @@ import { OTPForm } from './otp-form'; import { AuthService } from '@/lib/services/auth'; import { ConfigService } from '@/lib/services/config'; import type { LoginRequest } from '@/lib/services/auth/types'; +import { useTranslations } from 'next-intl'; import { safeRedirectTarget } from '@/lib/utils'; function persistRedirectTarget( @@ -45,6 +46,7 @@ export function LoginForm({ }) { const searchParams = useSearchParams(); const { setUser } = useAuth(); + const t = useTranslations('auth.login'); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [showPassword, setShowPassword] = useState(false); @@ -52,7 +54,7 @@ export function LoginForm({ const [showLoginCodeInput, setShowLoginCodeInput] = useState(false); const [loginCooldown, setLoginCooldown] = useState(0); const [errorMessage, setErrorMessage] = useState(''); - const [loginCodeTip, setLoginCodeTip] = useState(null); + const [loginCodeTip, setLoginCodeTip] = useState(null); useEffect(() => { onOTPStateChange?.(showLoginCodeInput); @@ -103,22 +105,16 @@ export function LoginForm({ }, onSuccess: (user) => { setUser(user); - toast.success('登录成功'); + toast.success(t('success')); }, onError: (error: Error) => { const errorMsg = error.message || ''; if (errorMsg.startsWith('need_email_code:')) { const emailMasked = errorMsg.substring('need_email_code:'.length); - setLoginCodeTip( - <> - 已向您的安全邮箱{' '} - {emailMasked}{' '} - 发送了登录验证码。 - , - ); + setLoginCodeTip(t('codeTipMasked', { email: emailMasked })); setShowLoginCodeInput(true); setLoginCooldown(60); - toast.success('登录验证码已发送至您的邮箱,请注意查收'); + toast.success(t('codeSent')); if (capEnabled) { capTokenRef.current = null; setCapReady(false); @@ -129,7 +125,7 @@ export function LoginForm({ if (errorMsg.startsWith('smtp_invalid:')) { const tip = errorMsg.substring('smtp_invalid:'.length); - setLoginCodeTip('请输入您的登录验证码。'); + setLoginCodeTip(t('codeTipGeneric')); setShowLoginCodeInput(true); setLoginCooldown(0); toast.warning(tip); @@ -141,7 +137,7 @@ export function LoginForm({ return; } - setErrorMessage(errorMsg || '登录失败,请重试'); + setErrorMessage(errorMsg || t('failed')); if (capEnabled) { capTokenRef.current = null; setCapReady(false); @@ -154,17 +150,13 @@ export function LoginForm({ setErrorMessage(''); const trimmedUsername = username.trim(); if (!trimmedUsername || !password) { - toast.error('邮箱/用户名或密码未填写完整', { - description: '请先输入邮箱/用户名和密码后再登录', + toast.error(t('incomplete'), { + description: t('incompleteDesc'), }); return; } if (capEnabled && !capReady) { - toast.error( - capAutoSolve - ? '人机验证尚未完成,请稍候…' - : '请先点击「开始验证」完成人机验证', - ); + toast.error(capAutoSolve ? t('capPending') : t('capRequired')); return; } loginMutation.mutate({ @@ -189,7 +181,7 @@ export function LoginForm({ const { authorize_url } = await AuthService.getAuthorizeUrl(sourceName); window.location.href = authorize_url; } catch (error) { - toast.error(error instanceof Error ? error.message : '第三方登录失败'); + toast.error(error instanceof Error ? error.message : t('oauthFailed')); } }; @@ -250,21 +242,21 @@ export function LoginForm({
- 邮箱或用户名 * + {t('username')} * setUsername(e.target.value)} - placeholder='输入邮箱或用户名' + placeholder={t('usernamePlaceholder')} autoComplete='username' className='h-10 text-sm [@media(max-height:700px)]:h-9' onKeyDown={(e) => e.key === 'Enter' && handlePasswordLogin()} @@ -272,7 +264,7 @@ export function LoginForm({ - 密码 * + {t('password')} *
setPassword(e.target.value)} type={showPassword ? 'text' : 'password'} - placeholder='输入您的密码' + placeholder={t('passwordPlaceholder')} autoComplete='current-password' className='h-10 pr-11 text-sm [@media(max-height:700px)]:h-9' onKeyDown={(e) => e.key === 'Enter' && handlePasswordLogin()} @@ -289,7 +281,9 @@ export function LoginForm({ type='button' variant='ghost' size='icon-sm' - aria-label={showPassword ? '隐藏密码' : '显示密码'} + aria-label={ + showPassword ? t('hidePassword') : t('showPassword') + } className='absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground' onClick={() => setShowPassword((visible) => !visible)} > @@ -325,10 +319,10 @@ export function LoginForm({ {loginMutation.isPending ? ( <> - 登录中... + {t('submitting')} ) : ( - '登录' + t('submit') )}
@@ -337,7 +331,7 @@ export function LoginForm({
- 或者使用 + {t('orUse')}
@@ -349,7 +343,7 @@ export function LoginForm({ className='h-10 [@media(max-height:700px)]:h-9' onClick={() => void handleOAuthLogin(source.name)} > - {source.display_name || source.name} 登录 + {t('oauthLogin', { name: source.display_name || source.name })} ))}
@@ -358,12 +352,12 @@ export function LoginForm({ {registrationEnabled && (
- 还没有账号?{' '} + {t('noAccount')}{' '} - 立即注册 + {t('registerNow')}
)} diff --git a/frontend/components/auth/otp-form.tsx b/frontend/components/auth/otp-form.tsx index bb4f1276..c6a112a7 100644 --- a/frontend/components/auth/otp-form.tsx +++ b/frontend/components/auth/otp-form.tsx @@ -1,6 +1,7 @@ 'use client'; import * as React from 'react'; +import { useTranslations } from 'next-intl'; import { RefreshCwIcon } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -33,12 +34,11 @@ export function OTPForm({ onResend, onSubmit, }: OTPFormProps) { + const t = useTranslations('auth.otp'); + return (
- + {loginCodeTip ? (

{loginCodeTip} @@ -51,7 +51,7 @@ export function OTPForm({ htmlFor='otp-verification' className='text-sm font-medium' > - 验证码 + {t('code')}

@@ -96,15 +98,15 @@ export function OTPForm({ onClick={onSubmit} disabled={isPending || code.length < 6} > - {isPending ? '验证中...' : '验证'} + {isPending ? t('submitting') : t('submit')}
- 遇到登录问题?{' '} + {t('help')}{' '} - 联系客服 + {t('contactSupport')}
diff --git a/frontend/components/auth/register-form.tsx b/frontend/components/auth/register-form.tsx index 487cfaa3..f5f7bcb9 100644 --- a/frontend/components/auth/register-form.tsx +++ b/frontend/components/auth/register-form.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useMutation, useQuery } from '@tanstack/react-query'; import { useRouter, useSearchParams } from 'next/navigation'; import { toast } from 'sonner'; +import { useTranslations } from 'next-intl'; import Link from 'next/link'; import { useAuth } from '@/components/providers/auth-provider'; @@ -36,6 +37,8 @@ export function RegisterForm() { const router = useRouter(); const searchParams = useSearchParams(); const { setUser } = useAuth(); + const t = useTranslations('auth.register'); + const tCommon = useTranslations('common'); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [nickname, setNickname] = useState(''); @@ -108,7 +111,7 @@ export function RegisterForm() { // Redirect to login if registration is closed useEffect(() => { if (publicConfigQuery.isSuccess && !registrationEnabled) { - toast.error('系统注册功能已关闭'); + toast.error(t('closed')); router.replace('/login'); } }, [publicConfigQuery.isSuccess, registrationEnabled, router]); @@ -129,10 +132,10 @@ export function RegisterForm() { onSuccess: (user) => { setUser(user); router.replace(redirectTarget); - toast.success('注册并登录成功'); + toast.success(t('success')); }, onError: (error: Error) => { - setErrorMessage(error.message || '注册失败,请重试'); + setErrorMessage(error.message || t('failed')); if (capEnabled) { capTokenRef.current = null; setCapReady(false); @@ -157,7 +160,7 @@ export function RegisterForm() { }, onSuccess: () => { setRegisterCooldown(60); - toast.success('验证码已发送至您的邮箱,请查收'); + toast.success(t('codeSent')); if (capEnabled) { setCapScope('register'); capTokenRef.current = null; @@ -166,7 +169,7 @@ export function RegisterForm() { } }, onError: (error: Error) => { - toast.error(error.message || '发送验证码失败,请重试'); + toast.error(error.message || t('sendCodeFailed')); if (capEnabled) { capTokenRef.current = null; setCapReady(false); @@ -195,20 +198,16 @@ export function RegisterForm() { const handleSendRegisterCode = () => { const trimmedEmail = email.trim(); if (!trimmedEmail) { - toast.error('请先输入邮箱地址'); + toast.error(t('emailRequired')); return; } const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(trimmedEmail)) { - toast.error('请输入有效的邮箱地址'); + toast.error(t('emailInvalid')); return; } if (capEnabled && capScope === 'send_email_code' && !capReady) { - toast.error( - capAutoSolve - ? '人机验证尚未完成,请稍候…' - : '请先点击「开始验证」完成人机验证', - ); + toast.error(capAutoSolve ? t('capPending') : t('capRequired')); return; } sendRegisterCodeMutation.mutate(trimmedEmail); @@ -217,33 +216,29 @@ export function RegisterForm() { const handleRegister = () => { setErrorMessage(''); if (!username.trim() || !password) { - toast.error('用户名和密码不能为空'); + toast.error(t('usernamePasswordRequired')); return; } if (password.length < 8) { - toast.error('密码长度不能少于 8 位'); + toast.error(t('passwordTooShort')); return; } const trimmedEmail = email.trim(); if (!trimmedEmail) { - toast.error('邮箱不能为空'); + toast.error(t('emailEmpty')); return; } const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(trimmedEmail)) { - toast.error('请输入有效的邮箱地址'); + toast.error(t('emailInvalid')); return; } if (emailRegisterEnabled && !code.trim()) { - toast.error('验证码不能为空'); + toast.error(t('codeRequired')); return; } if (capEnabled && capScope === 'register' && !capReady) { - toast.error( - capAutoSolve - ? '人机验证尚未完成,请稍候…' - : '请先点击「开始验证」完成人机验证', - ); + toast.error(capAutoSolve ? t('capPending') : t('capRequired')); return; } registerMutation.mutate({ @@ -271,21 +266,21 @@ export function RegisterForm() {
- 用户名 * + {t('username')} * setUsername(e.target.value)} - placeholder='请输入用户名' + placeholder={t('usernamePlaceholder')} autoComplete='username' className='h-10 text-sm [@media(max-height:700px)]:h-9' onKeyDown={(e) => e.key === 'Enter' && handleRegister()} @@ -293,16 +288,16 @@ export function RegisterForm() { - 昵称 + {t('nickname')} - (可选) + {tCommon('optional')} setNickname(e.target.value)} - placeholder='请输入昵称' + placeholder={t('nicknamePlaceholder')} autoComplete='nickname' className='h-10 text-sm [@media(max-height:700px)]:h-9' onKeyDown={(e) => e.key === 'Enter' && handleRegister()} @@ -310,13 +305,13 @@ export function RegisterForm() { - 电子邮箱 * + {t('email')} * setEmail(e.target.value)} - placeholder='请输入电子邮箱' + placeholder={t('emailPlaceholder')} autoComplete='email' className='h-10 text-sm [@media(max-height:700px)]:h-9' onKeyDown={(e) => e.key === 'Enter' && handleRegister()} @@ -324,14 +319,14 @@ export function RegisterForm() { - 密码 * + {t('password')} * setPassword(e.target.value)} type='password' - placeholder='请输入密码(至少 8 位)' + placeholder={t('passwordPlaceholder')} autoComplete='new-password' className='h-10 text-sm [@media(max-height:700px)]:h-9' onKeyDown={(e) => e.key === 'Enter' && handleRegister()} @@ -340,14 +335,14 @@ export function RegisterForm() { {emailRegisterEnabled && ( - 邮箱验证码 * + {t('emailCode')} *
setCode(e.target.value)} - placeholder='请输入 6 位邮箱验证码' + placeholder={t('emailCodePlaceholder')} maxLength={6} className='h-10 flex-1 text-sm [@media(max-height:700px)]:h-9' onKeyDown={(e) => e.key === 'Enter' && handleRegister()} @@ -360,8 +355,8 @@ export function RegisterForm() { className='h-10 w-[120px] text-xs [@media(max-height:700px)]:h-9' > {registerCooldown > 0 - ? `${registerCooldown}秒后重发` - : '获取验证码'} + ? t('resendIn', { seconds: registerCooldown }) + : t('getCode')}
@@ -394,21 +389,21 @@ export function RegisterForm() { {registerMutation.isPending ? ( <> - 注册中... + {t('submitting')} ) : ( - '创建账号' + t('submit') )}
- 已经有账号?{' '} + {t('hasAccount')}{' '} - 返回登录 + {t('backToLogin')}
diff --git a/frontend/components/common/language-switcher.tsx b/frontend/components/common/language-switcher.tsx new file mode 100644 index 00000000..e64753ec --- /dev/null +++ b/frontend/components/common/language-switcher.tsx @@ -0,0 +1,78 @@ +'use client'; + +import { Languages } from 'lucide-react'; +import { useLocale } from 'next-intl'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { localeLabels, locales, type AppLocale } from '@/i18n/config'; +import { switchLocale } from '@/i18n/client'; +import { cn } from '@/lib/utils'; + +type LanguageSwitcherProps = { + variant?: 'icon' | 'full'; + className?: string; +}; + +export function LanguageSwitcher({ + variant = 'icon', + className, +}: LanguageSwitcherProps) { + const locale = useLocale() as AppLocale; + + if (variant === 'full') { + return ( +
+ {locales.map((item) => ( + + ))} +
+ ); + } + + return ( + + + + + + {locales.map((item) => ( + { + if (item !== locale) switchLocale(item); + }} + className={item === locale ? 'bg-accent' : undefined} + > + {localeLabels[item]} + + ))} + + + ); +} diff --git a/frontend/components/common/settings/access-token.tsx b/frontend/components/common/settings/access-token.tsx index 6e26b2c5..f0090257 100644 --- a/frontend/components/common/settings/access-token.tsx +++ b/frontend/components/common/settings/access-token.tsx @@ -49,10 +49,17 @@ import type { CreateTokenResponse } from '@/lib/services/user'; import { UserService } from '@/lib/services/user'; import { useAuth } from '@/components/providers/auth-provider'; import { toast } from 'sonner'; +import { useLocale, useTranslations } from 'next-intl'; +import { formatDateTime } from '@/i18n/format'; +import type { AppLocale } from '@/i18n/config'; export function AccessTokenMain() { const { user } = useAuth(); const queryClient = useQueryClient(); + const t = useTranslations('settings'); + const tCommon = useTranslations('common'); + const ta = useTranslations('settings.accessToken'); + const locale = useLocale() as AppLocale; const [createDialogOpen, setCreateDialogOpen] = React.useState(false); const [viewDialogOpen, setViewDialogOpen] = React.useState(false); const [tokenName, setTokenName] = React.useState(''); @@ -80,10 +87,10 @@ export function AccessTokenMain() { void queryClient.invalidateQueries({ queryKey: ['user', 'access-tokens'], }); - toast.success('访问令牌创建成功'); + toast.success(ta('createSuccess')); }, onError: (error: Error) => { - toast.error(error.message || '创建访问令牌失败'); + toast.error(error.message || ta('createFailed')); }, }); @@ -94,10 +101,10 @@ export function AccessTokenMain() { void queryClient.invalidateQueries({ queryKey: ['user', 'access-tokens'], }); - toast.success('访问令牌已撤销'); + toast.success(ta('deleteSuccess')); }, onError: (error: Error) => { - toast.error(error.message || '删除访问令牌失败'); + toast.error(error.message || ta('deleteFailed')); }, }); @@ -110,17 +117,17 @@ export function AccessTokenMain() { void queryClient.invalidateQueries({ queryKey: ['user', 'access-tokens'], }); - toast.success('访问令牌轮换成功,旧密钥已失效'); + toast.success(ta('rotateSuccess')); }, onError: (error: Error) => { - toast.error(error.message || '轮换访问令牌失败'); + toast.error(error.message || ta('rotateFailed')); }, }); const handleCreateToken = (e: React.FormEvent) => { e.preventDefault(); if (!tokenName.trim()) { - toast.error('请输入令牌名称'); + toast.error(ta('nameRequired')); return; } createTokenMutation.mutate({ @@ -130,21 +137,13 @@ export function AccessTokenMain() { }; const handleDeleteToken = (id: number, name: string) => { - if ( - window.confirm( - `确定要删除并撤销令牌「${name}」吗?删除后此令牌将立即失效且不可恢复。`, - ) - ) { + if (window.confirm(ta('deleteConfirm', { name }))) { deleteTokenMutation.mutate(id); } }; const handleRotateToken = (id: number, name: string) => { - if ( - window.confirm( - `确定要轮换令牌「${name}」的密钥吗?轮换后系统将生成全新密钥,原令牌密钥将立即失效。`, - ) - ) { + if (window.confirm(ta('rotateConfirm', { name }))) { rotateTokenMutation.mutate(id); } }; @@ -153,22 +152,16 @@ export function AccessTokenMain() { try { await navigator.clipboard.writeText(text); setCopiedId(id); - toast.success('复制成功'); + toast.success(ta('copySuccess')); setTimeout(() => setCopiedId(null), 2000); } catch { - toast.error('复制失败'); + toast.error(ta('copyFailed')); } }; const formatDate = (dateStr?: string) => { - if (!dateStr) return '未使用'; - return new Date(dateStr).toLocaleString('zh-CN', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - }); + if (!dateStr) return '—'; + return formatDateTime(dateStr, locale); }; return ( @@ -184,14 +177,14 @@ export function AccessTokenMain() { - 设置 + {t('title')} - 访问令牌 + {ta('breadcrumb')} @@ -202,11 +195,9 @@ export function AccessTokenMain() {

- 个人访问令牌 (AccessToken) + {ta('title')}

-

- 管理您的 API 访问密钥,用于开发或第三方工具直接调用系统 API -

+

{ta('subtitle')}

@@ -223,20 +214,18 @@ export function AccessTokenMain() {
- 安全提示: -

- 访问令牌具有您账户的完整接口调用权限。为了您的账户与资产安全,请切勿通过任何代码库提交、即时通讯工具或公共媒介泄露此令牌。推荐按需创建,不使用时及时撤销。 -

+ {ta('securityTitle')} +

{ta('securityBody')}

- 活动令牌列表 + {ta('listTitle')} - 当前可用的所有访问令牌 + {ta('listDesc')} @@ -262,14 +251,14 @@ export function AccessTokenMain() { className='text-[10px] px-1.5 py-0 h-4 border-rose-500/40 text-rose-500 bg-rose-500/5 font-semibold' > - 管理员 + {ta('adminBadge')} ) : ( - 用户令牌 + {ta('userToken')} )}
@@ -278,7 +267,11 @@ export function AccessTokenMain() { {token.masked_token}
- 创建于: {formatDate(token.created_at)} + + {ta('createdAt', { + date: formatDate(token.created_at), + })} +
@@ -297,7 +290,7 @@ export function AccessTokenMain() { ) : ( )} - 复制 + {ta('copy')} @@ -330,7 +323,7 @@ export function AccessTokenMain() { ) : (
- 您当前暂无生成任何访问令牌 + {ta('empty')}
)} @@ -352,20 +345,20 @@ export function AccessTokenMain() {
- 生成新令牌 + {ta('generate')} - 请为新的访问令牌设置一个易于识别的名称,以便将来管理。 + {ta('createDesc')}
setTokenName(e.target.value)} disabled={createTokenMutation.isPending} @@ -380,10 +373,10 @@ export function AccessTokenMain() { className='text-xs font-semibold flex items-center gap-1.5' > - 管理员权限 + {ta('adminPermission')}

- 开启后此令牌可访问 /admin/** 管理端点,默认关闭 + {ta('adminHint')}

- 取消 + {tCommon('cancel')} @@ -438,7 +431,7 @@ export function AccessTokenMain() { - 令牌密钥已就绪 + {ta('readyTitle')} @@ -468,10 +461,8 @@ export function AccessTokenMain() {
- 重要提示: -

- 这是您唯一一次能够查看此访问令牌明文密钥的机会。请立即将其复制并安全地保存。 -

+ {ta('importantTitle')} +

{ta('readyBody')}

@@ -486,7 +477,7 @@ export function AccessTokenMain() { }} className='rounded-xl w-full' > - 我已经复制并妥善保存 + {ta('savedConfirm')} diff --git a/frontend/components/common/settings/appearance.tsx b/frontend/components/common/settings/appearance.tsx index 9d06a4d0..9e189ba3 100644 --- a/frontend/components/common/settings/appearance.tsx +++ b/frontend/components/common/settings/appearance.tsx @@ -5,6 +5,7 @@ import Link from 'next/link'; import { Check, Loader2, Monitor, Moon, Sun } from 'lucide-react'; import { useTheme } from 'next-themes'; import { toast } from 'sonner'; +import { useTranslations } from 'next-intl'; import { Button } from '@/components/ui/button'; import { useCustomTheme } from '@/lib/theme'; import { cn } from '@/lib/utils'; @@ -16,9 +17,11 @@ import { BreadcrumbPage, BreadcrumbSeparator, } from '@/components/ui/breadcrumb'; +import { LanguageSwitcher } from '@/components/common/language-switcher'; function ThemeModeSection() { const { theme, setTheme } = useTheme(); + const t = useTranslations('settings.appearance'); const [mounted, setMounted] = React.useState(false); React.useEffect(() => { @@ -26,19 +29,19 @@ function ThemeModeSection() { }, []); const modes = [ - { id: 'light', name: '明亮', icon: Sun }, - { id: 'dark', name: '黑暗', icon: Moon }, - { id: 'system', name: '自动', icon: Monitor }, + { id: 'light', name: t('modeLight'), icon: Sun }, + { id: 'dark', name: t('modeDark'), icon: Moon }, + { id: 'system', name: t('modeSystem'), icon: Monitor }, ]; if (!mounted) { return (
-

主题模式

-

- 选择应用程序的整体明暗主题 -

+

+ {t('themeMode')} +

+

{t('themeModeDesc')}

{modes.map((mode) => { @@ -63,10 +66,10 @@ function ThemeModeSection() { return (
-

主题模式

-

- 选择应用程序的整体明暗主题 -

+

+ {t('themeMode')} +

+

{t('themeModeDesc')}

@@ -100,6 +103,7 @@ function InterfaceAppearanceSection() { isLoading, } = useCustomTheme(); const { theme: mode } = useTheme(); + const t = useTranslations('settings.appearance'); const [mounted, setMounted] = React.useState(false); const [switching, setSwitching] = React.useState(null); @@ -113,9 +117,9 @@ function InterfaceAppearanceSection() { try { setCustomTheme(themeId); const themeName = themes.find((t) => t.id === themeId)?.name || themeId; - toast.success(`外观已切换为 ${themeName}`); + toast.success(t('switched', { name: themeName })); } catch { - toast.error('切换失败'); + toast.error(t('switchFailed')); } finally { setSwitching(null); } @@ -131,10 +135,10 @@ function InterfaceAppearanceSection() { return (
-

界面外观

-

- 选择界面的配色方案和视觉风格 -

+

+ {t('interface')} +

+

{t('interfaceDesc')}

@@ -229,6 +233,9 @@ function InterfaceAppearanceSection() { } export function AppearanceMain() { + const t = useTranslations('settings'); + const ta = useTranslations('settings.appearance'); + return (
@@ -237,14 +244,14 @@ export function AppearanceMain() { - 设置 + {t('title')} - 外观设置 + {ta('breadcrumb')} @@ -253,13 +260,25 @@ export function AppearanceMain() { +
+
+

+ {ta('language')} +

+

{ta('languageDesc')}

+
+ +
+
-

即将推出

+

+ {ta('comingSoon')} +

- 更多自定义选项正在开发中... + {ta('comingSoonBody')}
diff --git a/frontend/components/common/settings/notifications.tsx b/frontend/components/common/settings/notifications.tsx index 7ce15533..34559c5a 100644 --- a/frontend/components/common/settings/notifications.tsx +++ b/frontend/components/common/settings/notifications.tsx @@ -1,6 +1,7 @@ 'use client'; import Link from 'next/link'; +import { useTranslations } from 'next-intl'; import { Bell } from 'lucide-react'; import { Switch } from '@/components/ui/switch'; import { Label } from '@/components/ui/label'; @@ -16,6 +17,8 @@ import { export function NotificationsMain() { const { showBell, setShowBell } = useNotificationSettings(); + const t = useTranslations('settings'); + const tn = useTranslations('settings.notifications'); return (
@@ -25,14 +28,14 @@ export function NotificationsMain() { - 设置 + {t('title')} - 通知设置 + {tn('breadcrumb')} @@ -41,10 +44,10 @@ export function NotificationsMain() {
-

通知显示

-

- 设置通知相关的显示选项 -

+

+ {tn('display')} +

+

{tn('displayDesc')}

@@ -55,10 +58,10 @@ export function NotificationsMain() { htmlFor='show-bell' className='text-sm font-medium cursor-pointer' > - 显示通知铃铛 + {tn('showBell')}

- 在顶部导航栏中显示通知铃铛图标 + {tn('showBellDesc')}

diff --git a/frontend/components/layout/header.tsx b/frontend/components/layout/header.tsx index cb1dc73e..03a4e4cd 100644 --- a/frontend/components/layout/header.tsx +++ b/frontend/components/layout/header.tsx @@ -2,6 +2,7 @@ import { memo, useEffect, useState } from 'react'; import { AnimatePresence, motion } from 'motion/react'; +import { useTranslations } from 'next-intl'; import { Button } from '@/components/ui/button'; import { Bell, @@ -20,6 +21,7 @@ import { SidebarTrigger } from '@/components/ui/sidebar'; import { useTheme } from 'next-themes'; import { useRouter } from 'next/navigation'; import { SearchDialog } from '@/components/layout/search-dialog'; +import { LanguageSwitcher } from '@/components/common/language-switcher'; import { Kbd } from '@/components/ui/kbd'; /** @@ -28,6 +30,7 @@ import { Kbd } from '@/components/ui/kbd'; const BellButton = memo(function BellButton() { const { isRinging } = useBellRing(); const { showBell, isMounted } = useNotificationSettings(); + const t = useTranslations('layout.header'); const [isClickAnimating, setIsClickAnimating] = useState(false); if (!isMounted) return null; @@ -56,7 +59,7 @@ const BellButton = memo(function BellButton() { : undefined } /> - 通知 + {t('notifications')} ); }); @@ -80,6 +83,7 @@ export function SiteHeader({ const { user } = useUser(); const { setTheme, resolvedTheme } = useTheme(); const router = useRouter(); + const t = useTranslations('layout.header'); const [mounted, setMounted] = useState(false); const [searchOpen, setSearchOpen] = useState(false); @@ -102,7 +106,7 @@ export function SiteHeader({
- 为了您的账号安全,请立即修改密码! + {t('changePasswordBanner')}
)} @@ -130,9 +134,10 @@ export function SiteHeader({ onClick={() => setSearchOpen(true)} > - 搜索 + {t('search')} +
@@ -154,7 +159,7 @@ export function SiteHeader({ >
- 搜索 + {t('search')} {metaKey} K @@ -164,6 +169,7 @@ export function SiteHeader({
+
@@ -219,6 +225,7 @@ export function SiteHeader({ } function AppBanner() { + const t = useTranslations('layout.header'); const [isVisible, setIsVisible] = useState(false); const bannerKey = 'ldc-banner-dismissed-script'; @@ -246,13 +253,13 @@ function AppBanner() { >

- 最新通知 + {t('bannerTitle')} - 查看详情 + {t('bannerLink')}

@@ -299,15 +310,17 @@ export function AppSidebar({ ...props }: React.ComponentProps) {
{userLoading - ? '加载中...' - : user?.nickname || user?.username || '未登录'} + ? tCommon('loading') + : user?.nickname || + user?.username || + t('user.notLoggedIn')} {userLoading - ? '正在同步账户' + ? t('user.syncing') : user?.is_admin - ? '系统管理员' - : '普通用户'} + ? t('user.adminRole') + : t('user.userRole')}
@@ -318,7 +331,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { }} > - 我的资料 + {t('user.profile')} { @@ -327,7 +340,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { }} > - 设置 + {t('user.settings')} ) { }} > - 使用帮助 + {t('user.help')} setShowLogoutDialog(true)} > - 退出登录 + {t('user.logout')} @@ -354,20 +367,23 @@ export function AppSidebar({ ...props }: React.ComponentProps) { - {navMainFiltered.map((item) => ( - - - - {item.icon && } - {item.title} - - - - ))} + {navMainFiltered.map((item) => { + const title = t(`nav.${item.titleKey}`); + return ( + + + + {item.icon && } + {title} + + + + ); + })} @@ -376,27 +392,30 @@ export function AppSidebar({ ...props }: React.ComponentProps) { {user?.is_admin && adminFiltered.length > 0 && ( - 管理 + {t('groups.admin')} - {adminFiltered.map((item) => ( - - - - {item.icon && } - {item.title} - - - - ))} + {adminFiltered.map((item) => { + const title = t(`nav.${item.titleKey}`); + return ( + + + + {item.icon && } + {title} + + + + ); + })} @@ -405,37 +424,40 @@ export function AppSidebar({ ...props }: React.ComponentProps) { {documentFiltered.length > 0 && ( - 文档库 + {t('groups.docs')} - {documentFiltered.map((item) => ( - - - {item.external ? ( - - {item.icon && } - {item.title} - - - ) : ( - - {item.icon && } - {item.title} - - )} - - - ))} + {documentFiltered.map((item) => { + const title = t(`nav.${item.titleKey}`); + return ( + + + {item.external ? ( + + {item.icon && } + {title} + + + ) : ( + + {item.icon && } + {title} + + )} + + + ); + })} @@ -455,18 +477,22 @@ export function AppSidebar({ ...props }: React.ComponentProps) { > - 确认登出 + {t('logoutDialog.title')} {isLoggingOut - ? '正在登出,请稍候...' - : '您确定要登出当前账户吗?'} + ? t('logoutDialog.loggingOut') + : t('logoutDialog.description')} - 取消 + + {tCommon('cancel')} + {isLoggingOut && } - {isLoggingOut ? '登出中...' : '确认登出'} + {isLoggingOut + ? t('logoutDialog.inProgress') + : t('logoutDialog.confirm')} diff --git a/frontend/components/providers/intl-provider.tsx b/frontend/components/providers/intl-provider.tsx new file mode 100644 index 00000000..bb2500f0 --- /dev/null +++ b/frontend/components/providers/intl-provider.tsx @@ -0,0 +1,24 @@ +'use client'; + +import { NextIntlClientProvider } from 'next-intl'; +import type { AbstractIntlMessages } from 'next-intl'; +import type { ReactNode } from 'react'; +import type { AppLocale } from '@/i18n/config'; + +type Props = { + locale: AppLocale; + messages: AbstractIntlMessages; + children: ReactNode; +}; + +export function AppIntlProvider({ locale, messages, children }: Props) { + return ( + + {children} + + ); +} diff --git a/frontend/i18n/client.ts b/frontend/i18n/client.ts new file mode 100644 index 00000000..2b5c645d --- /dev/null +++ b/frontend/i18n/client.ts @@ -0,0 +1,72 @@ +'use client'; + +import { + defaultLocale, + isAppLocale, + localeCookieName, + normalizeLocale, + type AppLocale, +} from './config'; + +const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365; // 1 year + +function readCookie(name: string): string | null { + if (typeof document === 'undefined') return null; + const match = document.cookie + .split(';') + .map((part) => part.trim()) + .find((part) => part.startsWith(`${name}=`)); + if (!match) return null; + return decodeURIComponent(match.slice(name.length + 1)); +} + +export function getClientLocalePreference(): AppLocale | null { + const raw = readCookie(localeCookieName); + if (isAppLocale(raw)) return raw; + if (raw) return normalizeLocale(raw); + return null; +} + +export function detectBrowserLocale(): AppLocale { + if (typeof navigator === 'undefined') return defaultLocale; + const languages = + navigator.languages?.length > 0 + ? navigator.languages + : [navigator.language]; + for (const lang of languages) { + const lower = (lang ?? '').toLowerCase(); + if (lower.startsWith('zh') || lower.startsWith('en')) { + return normalizeLocale(lang); + } + } + return defaultLocale; +} + +export function resolveClientLocale(): AppLocale { + return getClientLocalePreference() ?? detectBrowserLocale(); +} + +export function setLocaleCookie(locale: AppLocale): void { + if (typeof document === 'undefined') return; + const secure = + typeof window !== 'undefined' && window.location.protocol === 'https:' + ? '; Secure' + : ''; + document.cookie = `${localeCookieName}=${encodeURIComponent(locale)}; Path=/; Max-Age=${COOKIE_MAX_AGE_SECONDS}; SameSite=Lax${secure}`; + try { + localStorage.setItem(localeCookieName, locale); + } catch { + // ignore quota / private mode + } +} + +/** Persist locale and reload so server + client messages stay in sync. */ +export function switchLocale(locale: AppLocale): void { + setLocaleCookie(locale); + if (typeof document !== 'undefined') { + document.documentElement.lang = locale; + } + if (typeof window !== 'undefined') { + window.location.reload(); + } +} diff --git a/frontend/i18n/config.ts b/frontend/i18n/config.ts new file mode 100644 index 00000000..af620cc5 --- /dev/null +++ b/frontend/i18n/config.ts @@ -0,0 +1,67 @@ +export const locales = ['zh-CN', 'en'] as const; + +export type AppLocale = (typeof locales)[number]; + +export const defaultLocale: AppLocale = 'zh-CN'; + +export const localeCookieName = 'NEXT_LOCALE'; + +export const localeLabels: Record = { + 'zh-CN': '中文', + en: 'English', +}; + +export function isAppLocale( + value: string | undefined | null, +): value is AppLocale { + return value === 'zh-CN' || value === 'en'; +} + +/** Normalize BCP 47 / browser tags into a supported app locale. */ +export function normalizeLocale(input?: string | null): AppLocale { + if (!input) return defaultLocale; + const tag = input.trim().replaceAll('_', '-'); + if (!tag) return defaultLocale; + + const lower = tag.toLowerCase(); + if (lower === 'zh-cn' || lower.startsWith('zh-hans') || lower === 'zh') { + return 'zh-CN'; + } + if (lower.startsWith('zh')) { + // zh-TW / zh-Hant etc. map to zh-CN until a dedicated locale exists + return 'zh-CN'; + } + if (lower === 'en' || lower.startsWith('en-')) { + return 'en'; + } + return defaultLocale; +} + +/** Pick the best locale from an Accept-Language style list. */ +export function pickLocaleFromAcceptLanguage( + header?: string | null, +): AppLocale { + if (!header) return defaultLocale; + + const candidates = header + .split(',') + .map((part) => { + const [tag, ...params] = part.trim().split(';'); + const qParam = params.find((p) => p.trim().startsWith('q=')); + const q = qParam ? Number.parseFloat(qParam.split('=')[1] ?? '1') : 1; + return { tag: tag?.trim() ?? '', q: Number.isFinite(q) ? q : 0 }; + }) + .filter((c) => c.tag) + .sort((a, b) => b.q - a.q); + + for (const candidate of candidates) { + const normalized = normalizeLocale(candidate.tag); + // Only accept if the raw tag roughly matches a supported family + const lower = candidate.tag.toLowerCase(); + if (lower.startsWith('zh') || lower.startsWith('en')) { + return normalized; + } + } + + return defaultLocale; +} diff --git a/frontend/i18n/format.ts b/frontend/i18n/format.ts new file mode 100644 index 00000000..cb86832f --- /dev/null +++ b/frontend/i18n/format.ts @@ -0,0 +1,39 @@ +import type { AppLocale } from './config'; +import { defaultLocale } from './config'; + +const localeToIntl: Record = { + 'zh-CN': 'zh-CN', + en: 'en-US', +}; + +export function formatDateTime( + dateStr: string | Date, + locale: AppLocale = defaultLocale, + timeZone = 'Asia/Shanghai', +): string { + try { + const date = typeof dateStr === 'string' ? new Date(dateStr) : dateStr; + return new Intl.DateTimeFormat(localeToIntl[locale] ?? locale, { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).format(date); + } catch { + return String(dateStr); + } +} + +export function formatNumber( + value: number, + locale: AppLocale = defaultLocale, +): string { + try { + return new Intl.NumberFormat(localeToIntl[locale] ?? locale).format(value); + } catch { + return String(value); + } +} diff --git a/frontend/i18n/request.ts b/frontend/i18n/request.ts new file mode 100644 index 00000000..21c2c1c3 --- /dev/null +++ b/frontend/i18n/request.ts @@ -0,0 +1,44 @@ +import { cookies, headers } from 'next/headers'; +import { getRequestConfig } from 'next-intl/server'; +import { + defaultLocale, + isAppLocale, + localeCookieName, + normalizeLocale, + pickLocaleFromAcceptLanguage, + type AppLocale, +} from './config'; + +async function resolveRequestLocale(): Promise { + try { + const cookieStore = await cookies(); + const cookieLocale = cookieStore.get(localeCookieName)?.value; + if (isAppLocale(cookieLocale)) { + return cookieLocale; + } + if (cookieLocale) { + return normalizeLocale(cookieLocale); + } + } catch { + // Static export / non-request contexts cannot read cookies. + } + + try { + const headerStore = await headers(); + const acceptLanguage = headerStore.get('accept-language'); + return pickLocaleFromAcceptLanguage(acceptLanguage); + } catch { + // Static export build time. + } + + return defaultLocale; +} + +export default getRequestConfig(async () => { + const locale = await resolveRequestLocale(); + const messages = (await import(`../messages/${locale}.json`)).default; + return { + locale, + messages, + }; +}); diff --git a/frontend/messages/en.json b/frontend/messages/en.json new file mode 100644 index 00000000..883bceaf --- /dev/null +++ b/frontend/messages/en.json @@ -0,0 +1,223 @@ +{ + "common": { + "save": "Save", + "cancel": "Cancel", + "confirm": "Confirm", + "delete": "Delete", + "loading": "Loading...", + "retry": "Retry", + "optional": "(optional)", + "settings": "Settings", + "search": "Search", + "unknownError": "Something went wrong. Please try again." + }, + "layout": { + "nav": { + "home": "Home", + "myFiles": "My Files", + "users": "Users", + "tasks": "Tasks", + "storage": "Storage", + "database": "Database", + "push": "Notifications", + "logs": "System Logs", + "system": "System Config", + "adminSettings": "System Settings", + "demo": "Demo", + "apiDocs": "API Docs", + "usageDocs": "User Guide" + }, + "groups": { + "admin": "Admin", + "docs": "Docs" + }, + "user": { + "adminRole": "Administrator", + "userRole": "User", + "notLoggedIn": "Not signed in", + "syncing": "Syncing account", + "profile": "My Profile", + "settings": "Settings", + "help": "Help", + "logout": "Sign out" + }, + "logoutDialog": { + "title": "Sign out", + "description": "Are you sure you want to sign out?", + "loggingOut": "Signing out…", + "confirm": "Sign out", + "inProgress": "Signing out…", + "failed": "Sign-out failed", + "failedDescription": "An error occurred while signing out. Please try again." + }, + "header": { + "notifications": "Notifications", + "settings": "Settings", + "search": "Search", + "toggleFullWidth": "Toggle full width", + "toggleTheme": "Toggle theme", + "changePasswordBanner": "For your account security, please change your password now.", + "changePasswordAction": "Change password", + "bannerTitle": "Announcement", + "bannerLink": "Learn more", + "dismiss": "Dismiss" + } + }, + "auth": { + "login": { + "title": "Sign in to your account", + "description": "Welcome back. Enter your credentials to continue.", + "username": "Email or username", + "usernamePlaceholder": "Enter email or username", + "password": "Password", + "passwordPlaceholder": "Enter your password", + "showPassword": "Show password", + "hidePassword": "Hide password", + "submit": "Sign in", + "submitting": "Signing in…", + "success": "Signed in successfully", + "failed": "Sign-in failed. Please try again.", + "incomplete": "Email/username or password is incomplete", + "incompleteDesc": "Please enter both email/username and password before signing in", + "capPending": "Human verification is still running…", + "capRequired": "Please complete human verification first", + "orUse": "Or continue with", + "oauthLogin": "Sign in with {name}", + "oauthFailed": "Third-party sign-in failed", + "noAccount": "Don't have an account?", + "registerNow": "Register", + "codeSent": "A sign-in code was sent to your email", + "codeTipMasked": "A sign-in code was sent to your secure email {email}.", + "codeTipGeneric": "Please enter your sign-in verification code." + }, + "otp": { + "title": "Verify your sign-in", + "description": "Enter the 6-digit code sent to your secure email.", + "code": "Verification code", + "resend": "Resend", + "resendIn": "Resend in {seconds}s", + "submit": "Verify", + "submitting": "Verifying…", + "help": "Having trouble signing in?", + "contactSupport": "Contact support" + }, + "register": { + "title": "Create your account", + "description": "Fill in the details below to get started.", + "username": "Username", + "usernamePlaceholder": "Enter username", + "nickname": "Nickname", + "nicknamePlaceholder": "Enter nickname", + "email": "Email", + "emailPlaceholder": "Enter email", + "password": "Password", + "passwordPlaceholder": "Enter password (at least 8 characters)", + "emailCode": "Email verification code", + "emailCodePlaceholder": "Enter 6-digit code", + "getCode": "Get code", + "resendIn": "Resend in {seconds}s", + "submit": "Create account", + "submitting": "Creating…", + "success": "Registered and signed in", + "failed": "Registration failed. Please try again.", + "closed": "Registration is currently disabled", + "codeSent": "Verification code sent to your email", + "sendCodeFailed": "Failed to send verification code", + "emailRequired": "Please enter an email address first", + "emailInvalid": "Please enter a valid email address", + "usernamePasswordRequired": "Username and password are required", + "passwordTooShort": "Password must be at least 8 characters", + "emailEmpty": "Email is required", + "codeRequired": "Verification code is required", + "capPending": "Human verification is still running…", + "capRequired": "Please complete human verification first", + "hasAccount": "Already have an account?", + "backToLogin": "Back to sign in" + } + }, + "settings": { + "title": "Settings", + "categories": { + "personal": "Personal", + "account": "Account" + }, + "items": { + "profile": "Profile", + "profileDesc": "View your account information", + "accessToken": "Access tokens", + "accessTokenDesc": "Manage personal tokens for API access", + "notifications": "Notifications", + "notificationsDesc": "Manage your notification preferences", + "appearance": "Appearance", + "appearanceDesc": "Customize theme and display" + }, + "appearance": { + "breadcrumb": "Appearance", + "themeMode": "Theme mode", + "themeModeDesc": "Choose the overall light or dark theme", + "modeLight": "Light", + "modeDark": "Dark", + "modeSystem": "System", + "interface": "Interface", + "interfaceDesc": "Choose color scheme and visual style", + "switched": "Appearance switched to {name}", + "switchFailed": "Failed to switch appearance", + "language": "Language", + "languageDesc": "Choose the interface language", + "comingSoon": "Coming soon", + "comingSoonBody": "More customization options are on the way…" + }, + "notifications": { + "breadcrumb": "Notifications", + "display": "Notification display", + "displayDesc": "Configure notification-related display options", + "showBell": "Show notification bell", + "showBellDesc": "Show the notification bell in the top navigation bar" + }, + "accessToken": { + "breadcrumb": "Access tokens", + "title": "Personal access tokens", + "generate": "Generate token", + "securityTitle": "Security note:", + "securityBody": "Access tokens have full API permissions for your account. Never share them via source control, chat, or public channels. Create tokens on demand and revoke unused ones promptly.", + "listTitle": "Active tokens", + "listDesc": "All currently available access tokens", + "userToken": "User token", + "empty": "You have not created any access tokens yet", + "generateFirst": "Generate your first token", + "createTitle": "Generate token", + "createDesc": "Give the token a recognizable name for easier management later.", + "name": "Token name", + "adminHint": "When enabled, this token can access /admin/** endpoints (off by default)", + "generateAction": "Generate", + "readyTitle": "Token secret is ready", + "readyBody": "This is the only time you can view the plaintext secret. Copy it now and store it securely.", + "nameRequired": "Please enter a token name", + "createSuccess": "Access token created", + "createFailed": "Failed to create access token", + "deleteSuccess": "Access token revoked", + "deleteFailed": "Failed to delete access token", + "rotateSuccess": "Access token rotated; the old secret is invalid", + "rotateFailed": "Failed to rotate access token", + "deleteConfirm": "Revoke token “{name}”? It will stop working immediately and cannot be recovered.", + "rotateConfirm": "Rotate the secret for token “{name}”? A new secret will be issued and the old one will stop working immediately.", + "subtitle": "Manage API access secrets for development or third-party tools", + "adminBadge": "Admin", + "adminPermission": "Admin permission", + "createdAt": "Created: {date}", + "copy": "Copy", + "rotate": "Rotate", + "revoke": "Revoke", + "copySuccess": "Copied", + "copyFailed": "Copy failed", + "namePlaceholder": "e.g. my-development-key", + "generating": "Generating…", + "importantTitle": "Important:", + "savedConfirm": "I have copied and stored it securely" + } + }, + "metadata": { + "title": "Wavelet Platform", + "description": "A general-purpose platform scaffold for secondary development" + } +} diff --git a/frontend/messages/zh-CN.json b/frontend/messages/zh-CN.json new file mode 100644 index 00000000..e6402576 --- /dev/null +++ b/frontend/messages/zh-CN.json @@ -0,0 +1,223 @@ +{ + "common": { + "save": "保存", + "cancel": "取消", + "confirm": "确认", + "delete": "删除", + "loading": "加载中...", + "retry": "重试", + "optional": "(可选)", + "settings": "设置", + "search": "搜索", + "unknownError": "发生错误,请重试" + }, + "layout": { + "nav": { + "home": "首页", + "myFiles": "我的文件", + "users": "用户管理", + "tasks": "任务管理", + "storage": "存储管理", + "database": "数据管理", + "push": "通知推送", + "logs": "系统日志", + "system": "系统配置", + "adminSettings": "系统设置", + "demo": "规范示例", + "apiDocs": "接口文档", + "usageDocs": "使用文档" + }, + "groups": { + "admin": "管理", + "docs": "文档库" + }, + "user": { + "adminRole": "系统管理员", + "userRole": "普通用户", + "notLoggedIn": "未登录", + "syncing": "正在同步账户", + "profile": "我的资料", + "settings": "设置", + "help": "使用帮助", + "logout": "退出登录" + }, + "logoutDialog": { + "title": "确认登出", + "description": "您确定要登出当前账户吗?", + "loggingOut": "正在登出,请稍候...", + "confirm": "确认登出", + "inProgress": "登出中...", + "failed": "登出失败", + "failedDescription": "登出时发生错误,请重试" + }, + "header": { + "notifications": "通知", + "settings": "设置", + "search": "搜索", + "toggleFullWidth": "切换全宽", + "toggleTheme": "主题切换", + "changePasswordBanner": "为了您的账号安全,请立即修改密码!", + "changePasswordAction": "去修改密码", + "bannerTitle": "最新通知", + "bannerLink": "查看详情", + "dismiss": "关闭" + } + }, + "auth": { + "login": { + "title": "登录到您的账号", + "description": "欢迎回来,请输入您的账号信息继续。", + "username": "邮箱或用户名", + "usernamePlaceholder": "输入邮箱或用户名", + "password": "密码", + "passwordPlaceholder": "输入您的密码", + "showPassword": "显示密码", + "hidePassword": "隐藏密码", + "submit": "登录", + "submitting": "登录中...", + "success": "登录成功", + "failed": "登录失败,请重试", + "incomplete": "邮箱/用户名或密码未填写完整", + "incompleteDesc": "请先输入邮箱/用户名和密码后再登录", + "capPending": "人机验证尚未完成,请稍候…", + "capRequired": "请先点击「开始验证」完成人机验证", + "orUse": "或者使用", + "oauthLogin": "{name} 登录", + "oauthFailed": "第三方登录失败", + "noAccount": "还没有账号?", + "registerNow": "立即注册", + "codeSent": "登录验证码已发送至您的邮箱,请注意查收", + "codeTipMasked": "已向您的安全邮箱 {email} 发送了登录验证码。", + "codeTipGeneric": "请输入您的登录验证码。" + }, + "otp": { + "title": "验证您的登录", + "description": "输入发送到安全邮箱的 6 位验证码。", + "code": "验证码", + "resend": "重新发送", + "resendIn": "{seconds}秒后重发", + "submit": "验证", + "submitting": "验证中...", + "help": "遇到登录问题?", + "contactSupport": "联系客服" + }, + "register": { + "title": "创建您的账号", + "description": "填写以下信息,开始使用平台服务。", + "username": "用户名", + "usernamePlaceholder": "请输入用户名", + "nickname": "昵称", + "nicknamePlaceholder": "请输入昵称", + "email": "电子邮箱", + "emailPlaceholder": "请输入电子邮箱", + "password": "密码", + "passwordPlaceholder": "请输入密码(至少 8 位)", + "emailCode": "邮箱验证码", + "emailCodePlaceholder": "请输入 6 位邮箱验证码", + "getCode": "获取验证码", + "resendIn": "{seconds}秒后重发", + "submit": "创建账号", + "submitting": "注册中...", + "success": "注册并登录成功", + "failed": "注册失败,请重试", + "closed": "系统注册功能已关闭", + "codeSent": "验证码已发送至您的邮箱,请查收", + "sendCodeFailed": "发送验证码失败,请重试", + "emailRequired": "请先输入邮箱地址", + "emailInvalid": "请输入有效的邮箱地址", + "usernamePasswordRequired": "用户名和密码不能为空", + "passwordTooShort": "密码长度不能少于 8 位", + "emailEmpty": "邮箱不能为空", + "codeRequired": "验证码不能为空", + "capPending": "人机验证尚未完成,请稍候…", + "capRequired": "请先点击「开始验证」完成人机验证", + "hasAccount": "已经有账号?", + "backToLogin": "返回登录" + } + }, + "settings": { + "title": "设置", + "categories": { + "personal": "个人设置", + "account": "账户设置" + }, + "items": { + "profile": "个人资料", + "profileDesc": "查看您的账户信息", + "accessToken": "访问令牌 (AccessToken)", + "accessTokenDesc": "管理用于 API 访问的个人 Token", + "notifications": "通知设置", + "notificationsDesc": "设置您的通知偏好", + "appearance": "外观设置", + "appearanceDesc": "自定义界面主题与显示" + }, + "appearance": { + "breadcrumb": "外观设置", + "themeMode": "主题模式", + "themeModeDesc": "选择应用程序的整体明暗主题", + "modeLight": "明亮", + "modeDark": "黑暗", + "modeSystem": "自动", + "interface": "界面外观", + "interfaceDesc": "选择界面的配色方案和视觉风格", + "switched": "外观已切换为 {name}", + "switchFailed": "切换失败", + "language": "语言", + "languageDesc": "选择界面显示语言", + "comingSoon": "即将推出", + "comingSoonBody": "更多自定义选项正在开发中..." + }, + "notifications": { + "breadcrumb": "通知设置", + "display": "通知显示", + "displayDesc": "设置通知相关的显示选项", + "showBell": "显示通知铃铛", + "showBellDesc": "在顶部导航栏中显示通知铃铛图标" + }, + "accessToken": { + "breadcrumb": "访问令牌", + "title": "个人访问令牌 (AccessToken)", + "generate": "生成新令牌", + "securityTitle": "安全提示:", + "securityBody": "访问令牌具有您账户的完整接口调用权限。为了您的账户与资产安全,请切勿通过任何代码库提交、即时通讯工具或公共媒介泄露此令牌。推荐按需创建,不使用时及时撤销。", + "listTitle": "活动令牌列表", + "listDesc": "当前可用的所有访问令牌", + "userToken": "用户令牌", + "empty": "您当前暂无生成任何访问令牌", + "generateFirst": "生成第一个令牌", + "createTitle": "生成新令牌", + "createDesc": "请为新的访问令牌设置一个易于识别的名称,以便将来管理。", + "name": "令牌名称", + "adminHint": "开启后此令牌可访问 /admin/** 管理端点,默认关闭", + "generateAction": "生成令牌", + "readyTitle": "令牌密钥已就绪", + "readyBody": "这是您唯一一次能够查看此访问令牌明文密钥的机会。请立即将其复制并安全地保存。", + "nameRequired": "请输入令牌名称", + "createSuccess": "访问令牌创建成功", + "createFailed": "创建访问令牌失败", + "deleteSuccess": "访问令牌已撤销", + "deleteFailed": "删除访问令牌失败", + "rotateSuccess": "访问令牌轮换成功,旧密钥已失效", + "rotateFailed": "轮换访问令牌失败", + "deleteConfirm": "确定要删除并撤销令牌「{name}」吗?删除后此令牌将立即失效且不可恢复。", + "rotateConfirm": "确定要轮换令牌「{name}」的密钥吗?轮换后系统将生成全新密钥,原令牌密钥将立即失效。", + "subtitle": "管理您的 API 访问密钥,用于开发或第三方工具直接调用系统 API", + "adminBadge": "管理员", + "adminPermission": "管理员权限", + "createdAt": "创建于: {date}", + "copy": "复制", + "rotate": "轮换", + "revoke": "撤销", + "copySuccess": "复制成功", + "copyFailed": "复制失败", + "namePlaceholder": "例如:my-development-key", + "generating": "正在生成...", + "importantTitle": "重要提示:", + "savedConfirm": "我已经复制并妥善保存" + } + }, + "metadata": { + "title": "Wavelet Platform", + "description": "通用二次开发标准范式平台" + } +} diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 654a8e63..418b6d25 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -1,4 +1,7 @@ import type { NextConfig } from 'next'; +import createNextIntlPlugin from 'next-intl/plugin'; + +const withNextIntl = createNextIntlPlugin('./i18n/request.ts'); const isExport = process.env.NEXT_STANDALONE_EXPORT === 'true'; @@ -32,4 +35,4 @@ const nextConfig: NextConfig = { }), }; -export default nextConfig; +export default withNextIntl(nextConfig); diff --git a/frontend/package.json b/frontend/package.json index 86e04721..d7036cc1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -54,10 +54,11 @@ "date-fns": "^4.1.0", "embla-carousel-react": "^8.6.0", "input-otp": "^1.4.2", - "jspdf": "^3.0.4", + "jspdf": "4.2.1", "lucide-react": "^0.552.0", "motion": "^12.23.24", "next": "16.2.7", + "next-intl": "^4.13.3", "next-themes": "^0.4.6", "pinyin-match": "^1.2.10", "radix-ui": "^1.4.3", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 29208c62..3d4070a7 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -129,8 +129,8 @@ importers: specifier: ^1.4.2 version: 1.4.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) jspdf: - specifier: ^3.0.4 - version: 3.0.4 + specifier: 4.2.1 + version: 4.2.1 lucide-react: specifier: ^0.552.0 version: 0.552.0(react@19.2.3) @@ -140,6 +140,9 @@ importers: next: specifier: 16.2.7 version: 16.2.7(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + next-intl: + specifier: ^4.13.3 + version: 4.13.3(next@16.2.7(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(typescript@5.9.3) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -253,6 +256,10 @@ packages: resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} engines: {node: '>=6.9.0'} + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/types@7.28.5': resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} @@ -437,6 +444,18 @@ packages: '@fontsource/noto-sans-sc@5.2.8': resolution: {integrity: sha512-8T8HxIS3uAMCfaQawKRH/6yYZ1oRnJZB/CrGwfxGgJa+zAOBgx2lqZMiTY/WbQpLGlPRqX4zHXJYI09CI2q6tA==} + '@formatjs/fast-memoize@3.1.7': + resolution: {integrity: sha512-zXfhLpvA6T7+efdt9JLbBwZ00tT7NsBMDVnDu8rpHeNNv8KfRZAMo2gkG0k9lK/Nzc//3kJ9pImsfuJxk3KhUA==} + + '@formatjs/icu-messageformat-parser@3.5.15': + resolution: {integrity: sha512-5o4grXKotAB3JqQuisLApHG43g17N+paoRTa92Jiz35Zvfemq0cVf4EDvuxyHAzmsJji7igaEowicLO/VmfJ8Q==} + + '@formatjs/icu-skeleton-parser@2.1.11': + resolution: {integrity: sha512-j8cUmOJzVgkHuS0QiQ6ga76UIoLOFSAMWhs7aZJztH3aAdCOAE6vpC8KVvFB4cU10ON0y2/5oOVmPJ43s2lTwA==} + + '@formatjs/intl-localematcher@0.8.13': + resolution: {integrity: sha512-kHEAFOkeJSPNi7c5PaKaRjxcBrJwzzt81ifUu+8uve1EDW/VJl83KsxmqgqNZLzcFEhSliZGvx3+pk/RH0IOmg==} + '@hookform/resolvers@5.2.2': resolution: {integrity: sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==} peerDependencies: @@ -716,6 +735,88 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@parcel/watcher-android-arm64@2.6.0': + resolution: {integrity: sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [android] + + '@parcel/watcher-darwin-arm64@2.6.0': + resolution: {integrity: sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [darwin] + + '@parcel/watcher-darwin-x64@2.6.0': + resolution: {integrity: sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [darwin] + + '@parcel/watcher-freebsd-x64@2.6.0': + resolution: {integrity: sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [freebsd] + + '@parcel/watcher-linux-arm-glibc@2.6.0': + resolution: {integrity: sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-arm-musl@2.6.0': + resolution: {integrity: sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + libc: [musl] + + '@parcel/watcher-linux-arm64-glibc@2.6.0': + resolution: {integrity: sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-arm64-musl@2.6.0': + resolution: {integrity: sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@parcel/watcher-linux-x64-glibc@2.6.0': + resolution: {integrity: sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-x64-musl@2.6.0': + resolution: {integrity: sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@parcel/watcher-win32-arm64@2.6.0': + resolution: {integrity: sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [win32] + + '@parcel/watcher-win32-x64@2.6.0': + resolution: {integrity: sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [win32] + + '@parcel/watcher@2.6.0': + resolution: {integrity: sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==} + engines: {node: '>= 10.0.0'} + '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -1508,12 +1609,108 @@ packages: '@rushstack/eslint-patch@1.14.1': resolution: {integrity: sha512-jGTk8UD/RdjsNZW8qq10r0RBvxL8OWtoT+kImlzPDFilmozzM+9QmIJsmze9UiSBrFU45ZxhTYBypn9q9z/VfQ==} + '@schummar/icu-type-parser@1.21.5': + resolution: {integrity: sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==} + '@standard-schema/utils@0.3.0': resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@swc/core-darwin-arm64@1.15.46': + resolution: {integrity: sha512-IsISIT22EfktVJrlvIpnAxG2u/A9aob9l99HMlx80x72WlFmFPk1V3UhkEzx86eJP8hw049KTFv/RISho2cq2Q==} + engines: {node: '>=10'} + cpu: [arm64] + os: [darwin] + + '@swc/core-darwin-x64@1.15.46': + resolution: {integrity: sha512-4Tj4ppVIPCmUMpmGFiGtyEriwLyJ+yi/US4WfBrP/ok8COGddDZXLEzQETnKyK46mjvr1v0jevrS23zjoff7vA==} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] + + '@swc/core-linux-arm-gnueabihf@1.15.46': + resolution: {integrity: sha512-i8tUGnNjyOgMmfmgFSg4aeJLQoFyfpIHK5FjpQAwpRyQIqEUB2w1e8zIDQzY1WhOxx8NoS1S5iUL813Un4Sf5A==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux] + + '@swc/core-linux-arm64-gnu@1.15.46': + resolution: {integrity: sha512-c0OnhqzdhfOvv6qhNCcByepB+sNYOGZyhtr2Qa6ZCHvAWTYhSRw4j/u92Stue9PbZ/6q74b9nHzi76+kVzqQHQ==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-arm64-musl@1.15.46': + resolution: {integrity: sha512-imyRpNEcUzFQFV2LE4jL68ErvmKEuZCbvZru77iQREunJ+bR4i658cupTgtG1mLYM3F1Tzy3Sb9xYb02KghWTg==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@swc/core-linux-ppc64-gnu@1.15.46': + resolution: {integrity: sha512-ctEfcl/HcUeomK33cbySiHZm98GEDIxTm1EkpBsYCiHxElYBzvTXVeuQT2YwbUXn9XCrjiw4ipyUNk33k26qRg==} + engines: {node: '>=10'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-s390x-gnu@1.15.46': + resolution: {integrity: sha512-DxlMdnt84TtRVTv7WL/thWyz9+QU8QZNNoAP9rrk0P68LziuhfePp8MjQ44zIprpTHTsEwyziIuGUUN5iSC1bQ==} + engines: {node: '>=10'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@swc/core-linux-x64-gnu@1.15.46': + resolution: {integrity: sha512-SKxI7J6t90XPl8hRUqtJi9NfGdunN/E/vZMc7Bc0figeRdOPDBT+Tm8g7cx9xM0T0mewh2l+8dewa3Am27/P+A==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-x64-musl@1.15.46': + resolution: {integrity: sha512-qj9T6B7bosI0VEsrWOVXZN1OXxS8Tp63ywyrLxNdOycnUtLdkgYcoBsN5y8ImnDDsnwrEWZOy1e+J4xSe7mA3Q==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@swc/core-win32-arm64-msvc@1.15.46': + resolution: {integrity: sha512-8p7l4c3LU+eA5g9Et1JPhNeMC1oQwXTGU+uah8DPIBX7YXzqswvaBtyKVmXefVGi/DJU1x3YJsc3mbAp9aWzSQ==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + + '@swc/core-win32-ia32-msvc@1.15.46': + resolution: {integrity: sha512-tUEnfr3Bn9u6FOjUb3PN9p+09qZC2j+wNDLKHzXXZn22rqGcUqR/ohCRSS+nG9B9+X+U+3FewNEHJkTmdIvMjQ==} + engines: {node: '>=10'} + cpu: [ia32] + os: [win32] + + '@swc/core-win32-x64-msvc@1.15.46': + resolution: {integrity: sha512-Vux7UDzBJYQggSuPfcl2w9iu+IJpgpRCxHzgCaVkELnAXAE4XZMOTX9HNcaNiwfeIDqdu2rkr69RuDm6wY8neA==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@swc/core@1.15.46': + resolution: {integrity: sha512-Ri3em2mBpq3h2zSPliCYl63otDGqek8PPEfv2nWgRQEbZ/VBCNyypVTVQ6cEbTCXBhy+WE2T3fQb08moIyuYaw==} + engines: {node: '>=10'} + peerDependencies: + '@swc/helpers': '>=0.5.17' + peerDependenciesMeta: + '@swc/helpers': + optional: true + + '@swc/counter@0.1.3': + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@swc/types@0.1.27': + resolution: {integrity: sha512-K6h3iUlqeM946U4sXFYeahefR1YBbXJvko+hv8WS8/0BNJ4OHiHRywMnQUJCqkR7Y9+hqQ1TvEpiKqUhz7NEFg==} + '@tabler/icons-react@3.35.0': resolution: {integrity: sha512-XG7t2DYf3DyHT5jxFNp5xyLVbL4hMJYJhiSdHADzAjLRYfL7AnjlRfiHDHeXxkb2N103rEIvTsBRazxXtAUz2g==} peerDependencies: @@ -2254,8 +2451,8 @@ packages: dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} - dompurify@3.3.0: - resolution: {integrity: sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==} + dompurify@3.4.12: + resolution: {integrity: sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==} dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} @@ -2672,6 +2869,9 @@ packages: resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==} engines: {node: '>=8.0.0'} + icu-minify@4.13.3: + resolution: {integrity: sha512-dCbcvYH4YBgR+SgCtnXGIquuc6JkS7e4n+5mmgseQ5z81KJIWbs5ucBjnrE72HFct6Ddr2yWjX6o1j8DT7MI5w==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -2705,6 +2905,9 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} + intl-messageformat@11.2.12: + resolution: {integrity: sha512-KW70Xxfcvy7vV3qODfvShWkFDPMqKDAa4N+hSyVBWGNtVhTUFYaqlD/l88DaYPKiVcPP4rPQ3qnH7i5K82Mg7g==} + iobuffer@5.4.0: resolution: {integrity: sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==} @@ -2861,8 +3064,8 @@ packages: resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} hasBin: true - jspdf@3.0.4: - resolution: {integrity: sha512-dc6oQ8y37rRcHn316s4ngz/nOjayLF/FFxBF4V9zamQKRqXxyiH1zagkCdktdWhtoQId5K20xt1lB90XzkB+hQ==} + jspdf@4.2.1: + resolution: {integrity: sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==} jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} @@ -3185,6 +3388,23 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + next-intl-swc-plugin-extractor@4.13.3: + resolution: {integrity: sha512-Oy86w/VRwqOu6sHEXwthEGhgnQszS79e47i3Kkzv4r+Yfz9ilZMSen8Sp8vtAwuc1nAZPrN13IbsLC5jmc9fVQ==} + + next-intl@4.13.3: + resolution: {integrity: sha512-+bAjmPLsRFG/YAN1ged7wVCbuYvYjEW06ApBRdM7VbtNfXYN1iBFNh28Bi1sX5xGSFvAsGCBC1K2Zjtvn1AMvQ==} + peerDependencies: + next: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + next-themes@0.4.6: resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} peerDependencies: @@ -3212,6 +3432,9 @@ packages: sass: optional: true + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + normalize-wheel@1.0.1: resolution: {integrity: sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==} @@ -3298,9 +3521,16 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + pinyin-match@1.2.10: resolution: {integrity: sha512-Xo1uMd9n56/xP2EAn7yfApnTmAGCLGqxFIm1fuOnSMty0xhqRF3+ZwVjp2yWFD/alv60RUWa/cZoQSeTSp1HxA==} + po-parser@2.1.1: + resolution: {integrity: sha512-ECF4zHLbUItpUgE3OTtLKlPjeBN+fKEczj2zYjDfCGOzicNs0GK3Vg2IoAYwx7LH/XYw43fZQP6xnZ4TkNxSLQ==} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -3790,6 +4020,11 @@ packages: '@types/react': optional: true + use-intl@4.13.3: + resolution: {integrity: sha512-e1qMU4PokNEU7K1TtKROSl3Sv7JLsKh7GyNjuhKZVB444Qfv0qHjet59IZF6GtYE+VwGbhgWqDWfGTnuDPe2Bg==} + peerDependencies: + react: ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0 + use-sidecar@1.1.3: resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} engines: {node: '>=10'} @@ -3874,6 +4109,8 @@ snapshots: '@babel/runtime@7.28.4': {} + '@babel/runtime@7.29.7': {} + '@babel/types@7.28.5': dependencies: '@babel/helper-string-parser': 7.27.1 @@ -4091,6 +4328,18 @@ snapshots: '@fontsource/noto-sans-sc@5.2.8': {} + '@formatjs/fast-memoize@3.1.7': {} + + '@formatjs/icu-messageformat-parser@3.5.15': + dependencies: + '@formatjs/icu-skeleton-parser': 2.1.11 + + '@formatjs/icu-skeleton-parser@2.1.11': {} + + '@formatjs/intl-localematcher@0.8.13': + dependencies: + '@formatjs/fast-memoize': 3.1.7 + '@hookform/resolvers@5.2.2(react-hook-form@7.68.0(react@19.2.3))': dependencies: '@standard-schema/utils': 0.3.0 @@ -4286,6 +4535,62 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@parcel/watcher-android-arm64@2.6.0': + optional: true + + '@parcel/watcher-darwin-arm64@2.6.0': + optional: true + + '@parcel/watcher-darwin-x64@2.6.0': + optional: true + + '@parcel/watcher-freebsd-x64@2.6.0': + optional: true + + '@parcel/watcher-linux-arm-glibc@2.6.0': + optional: true + + '@parcel/watcher-linux-arm-musl@2.6.0': + optional: true + + '@parcel/watcher-linux-arm64-glibc@2.6.0': + optional: true + + '@parcel/watcher-linux-arm64-musl@2.6.0': + optional: true + + '@parcel/watcher-linux-x64-glibc@2.6.0': + optional: true + + '@parcel/watcher-linux-x64-musl@2.6.0': + optional: true + + '@parcel/watcher-win32-arm64@2.6.0': + optional: true + + '@parcel/watcher-win32-x64@2.6.0': + optional: true + + '@parcel/watcher@2.6.0': + dependencies: + detect-libc: 2.1.2 + is-glob: 4.0.3 + node-addon-api: 7.1.1 + picomatch: 4.0.5 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.6.0 + '@parcel/watcher-darwin-arm64': 2.6.0 + '@parcel/watcher-darwin-x64': 2.6.0 + '@parcel/watcher-freebsd-x64': 2.6.0 + '@parcel/watcher-linux-arm-glibc': 2.6.0 + '@parcel/watcher-linux-arm-musl': 2.6.0 + '@parcel/watcher-linux-arm64-glibc': 2.6.0 + '@parcel/watcher-linux-arm64-musl': 2.6.0 + '@parcel/watcher-linux-x64-glibc': 2.6.0 + '@parcel/watcher-linux-x64-musl': 2.6.0 + '@parcel/watcher-win32-arm64': 2.6.0 + '@parcel/watcher-win32-x64': 2.6.0 + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -5109,12 +5414,74 @@ snapshots: '@rushstack/eslint-patch@1.14.1': {} + '@schummar/icu-type-parser@1.21.5': {} + '@standard-schema/utils@0.3.0': {} + '@swc/core-darwin-arm64@1.15.46': + optional: true + + '@swc/core-darwin-x64@1.15.46': + optional: true + + '@swc/core-linux-arm-gnueabihf@1.15.46': + optional: true + + '@swc/core-linux-arm64-gnu@1.15.46': + optional: true + + '@swc/core-linux-arm64-musl@1.15.46': + optional: true + + '@swc/core-linux-ppc64-gnu@1.15.46': + optional: true + + '@swc/core-linux-s390x-gnu@1.15.46': + optional: true + + '@swc/core-linux-x64-gnu@1.15.46': + optional: true + + '@swc/core-linux-x64-musl@1.15.46': + optional: true + + '@swc/core-win32-arm64-msvc@1.15.46': + optional: true + + '@swc/core-win32-ia32-msvc@1.15.46': + optional: true + + '@swc/core-win32-x64-msvc@1.15.46': + optional: true + + '@swc/core@1.15.46': + dependencies: + '@swc/counter': 0.1.3 + '@swc/types': 0.1.27 + optionalDependencies: + '@swc/core-darwin-arm64': 1.15.46 + '@swc/core-darwin-x64': 1.15.46 + '@swc/core-linux-arm-gnueabihf': 1.15.46 + '@swc/core-linux-arm64-gnu': 1.15.46 + '@swc/core-linux-arm64-musl': 1.15.46 + '@swc/core-linux-ppc64-gnu': 1.15.46 + '@swc/core-linux-s390x-gnu': 1.15.46 + '@swc/core-linux-x64-gnu': 1.15.46 + '@swc/core-linux-x64-musl': 1.15.46 + '@swc/core-win32-arm64-msvc': 1.15.46 + '@swc/core-win32-ia32-msvc': 1.15.46 + '@swc/core-win32-x64-msvc': 1.15.46 + + '@swc/counter@0.1.3': {} + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 + '@swc/types@0.1.27': + dependencies: + '@swc/counter': 0.1.3 + '@tabler/icons-react@3.35.0(react@19.2.3)': dependencies: '@tabler/icons': 3.35.0 @@ -5846,7 +6213,7 @@ snapshots: '@babel/runtime': 7.28.4 csstype: 3.1.3 - dompurify@3.3.0: + dompurify@3.4.12: optionalDependencies: '@types/trusted-types': 2.0.7 optional: true @@ -6433,6 +6800,10 @@ snapshots: text-segmentation: 1.0.3 optional: true + icu-minify@4.13.3: + dependencies: + '@formatjs/icu-messageformat-parser': 3.5.15 + ignore@5.3.2: {} ignore@7.0.5: {} @@ -6459,6 +6830,11 @@ snapshots: internmap@2.0.3: {} + intl-messageformat@11.2.12: + dependencies: + '@formatjs/fast-memoize': 3.1.7 + '@formatjs/icu-messageformat-parser': 3.5.15 + iobuffer@5.4.0: {} is-alphabetical@2.0.1: {} @@ -6617,15 +6993,15 @@ snapshots: dependencies: minimist: 1.2.8 - jspdf@3.0.4: + jspdf@4.2.1: dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.7 fast-png: 6.4.0 fflate: 0.8.2 optionalDependencies: canvg: 3.0.11 core-js: 3.47.0 - dompurify: 3.3.0 + dompurify: 3.4.12 html2canvas: 1.4.1 jsx-ast-utils@3.3.5: @@ -7125,6 +7501,27 @@ snapshots: natural-compare@1.4.0: {} + negotiator@1.0.0: {} + + next-intl-swc-plugin-extractor@4.13.3: {} + + next-intl@4.13.3(next@16.2.7(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(typescript@5.9.3): + dependencies: + '@formatjs/intl-localematcher': 0.8.13 + '@parcel/watcher': 2.6.0 + '@swc/core': 1.15.46 + icu-minify: 4.13.3 + negotiator: 1.0.0 + next: 16.2.7(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + next-intl-swc-plugin-extractor: 4.13.3 + po-parser: 2.1.1 + react: 19.2.3 + use-intl: 4.13.3(react@19.2.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@swc/helpers' + next-themes@0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: react: 19.2.3 @@ -7155,6 +7552,8 @@ snapshots: - '@babel/core' - babel-plugin-macros + node-addon-api@7.1.1: {} + normalize-wheel@1.0.1: {} object-assign@4.1.1: {} @@ -7253,8 +7652,12 @@ snapshots: picomatch@4.0.3: {} + picomatch@4.0.5: {} + pinyin-match@1.2.10: {} + po-parser@2.1.1: {} + possible-typed-array-names@1.1.0: {} postcss-selector-parser@6.0.10: @@ -7964,6 +8367,14 @@ snapshots: optionalDependencies: '@types/react': 19.2.2 + use-intl@4.13.3(react@19.2.3): + dependencies: + '@formatjs/fast-memoize': 3.1.7 + '@schummar/icu-type-parser': 1.21.5 + icu-minify: 4.13.3 + intl-messageformat: 11.2.12 + react: 19.2.3 + use-sidecar@1.1.3(@types/react@19.2.2)(react@19.2.3): dependencies: detect-node-es: 1.1.0 diff --git a/frontend/scripts/check-i18n-keys.mjs b/frontend/scripts/check-i18n-keys.mjs new file mode 100644 index 00000000..73c0d103 --- /dev/null +++ b/frontend/scripts/check-i18n-keys.mjs @@ -0,0 +1,35 @@ +import { readFileSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +function flatten(obj, prefix = '') { + const keys = new Set(); + for (const [key, value] of Object.entries(obj)) { + const path = prefix ? `${prefix}.${key}` : key; + if (value && typeof value === 'object' && !Array.isArray(value)) { + for (const child of flatten(value, path)) keys.add(child); + } else { + keys.add(path); + } + } + return keys; +} + +const zh = JSON.parse(readFileSync(resolve(root, 'messages/zh-CN.json'), 'utf8')); +const en = JSON.parse(readFileSync(resolve(root, 'messages/en.json'), 'utf8')); +const zhKeys = flatten(zh); +const enKeys = flatten(en); +const onlyZh = [...zhKeys].filter((k) => !enKeys.has(k)).sort(); +const onlyEn = [...enKeys].filter((k) => !enKeys.has(k) === false && !zhKeys.has(k)).sort(); +const onlyEnFixed = [...enKeys].filter((k) => !zhKeys.has(k)).sort(); + +if (onlyZh.length || onlyEnFixed.length) { + console.error('i18n key mismatch'); + if (onlyZh.length) console.error('only in zh-CN:', onlyZh); + if (onlyEnFixed.length) console.error('only in en:', onlyEnFixed); + process.exit(1); +} + +console.log(`i18n keys OK (${zhKeys.size} keys)`);