mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 14:06:36 +08:00
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.
This commit is contained in:
@@ -92,6 +92,8 @@
|
||||
- `frontend/components/layout/`:Header / Sidebar / Footer 页面框架组件。
|
||||
- `frontend/components/<feature>/`:特定业务域的 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/<name>/` 中继承 `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`。
|
||||
|
||||
@@ -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 (
|
||||
<div className='space-y-6 py-6'>
|
||||
<div className='font-semibold text-lg'>设置</div>
|
||||
<div className='font-semibold text-lg'>{t('title')}</div>
|
||||
|
||||
{Object.entries(groupedSettings).map(([category, items]) => (
|
||||
<div key={category} className='space-y-4'>
|
||||
|
||||
+27
-16
@@ -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<Metadata> {
|
||||
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 (
|
||||
<html
|
||||
lang='zh-CN'
|
||||
lang={locale}
|
||||
className={`hide-scrollbar font-sans ${inter.variable}`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
@@ -42,16 +51,18 @@ export default function RootLayout({
|
||||
>
|
||||
<CustomThemeProvider>
|
||||
<AppQueryProvider>
|
||||
<SiteTitleUpdater />
|
||||
<RobotsMeta />
|
||||
<UserProvider>
|
||||
<NotificationSettingsProvider>
|
||||
<BellRingProvider>
|
||||
{children}
|
||||
<Toaster position='top-center' />
|
||||
</BellRingProvider>
|
||||
</NotificationSettingsProvider>
|
||||
</UserProvider>
|
||||
<AppIntlProvider locale={locale} messages={messages}>
|
||||
<SiteTitleUpdater />
|
||||
<RobotsMeta />
|
||||
<UserProvider>
|
||||
<NotificationSettingsProvider>
|
||||
<BellRingProvider>
|
||||
{children}
|
||||
<Toaster position='top-center' />
|
||||
</BellRingProvider>
|
||||
</NotificationSettingsProvider>
|
||||
</UserProvider>
|
||||
</AppIntlProvider>
|
||||
</AppQueryProvider>
|
||||
</CustomThemeProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -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<React.ReactNode>(null);
|
||||
const [loginCodeTip, setLoginCodeTip] = useState<ReactNode>(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(
|
||||
<>
|
||||
已向您的安全邮箱{' '}
|
||||
<span className='font-medium text-foreground'>{emailMasked}</span>{' '}
|
||||
发送了登录验证码。
|
||||
</>,
|
||||
);
|
||||
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({
|
||||
<div className='flex flex-col gap-6 [@media(max-height:700px)]:gap-4'>
|
||||
<AuthHeading
|
||||
siteName={publicConfigQuery.data?.site_name}
|
||||
title='登录到您的账号'
|
||||
description='欢迎回来,请输入您的账号信息继续。'
|
||||
title={t('title')}
|
||||
description={t('description')}
|
||||
/>
|
||||
|
||||
<div className='flex flex-col gap-5 [@media(max-height:700px)]:gap-3'>
|
||||
<FieldGroup className='gap-4 [@media(max-height:700px)]:gap-3'>
|
||||
<Field className='gap-1.5'>
|
||||
<FieldLabel htmlFor='username'>
|
||||
邮箱或用户名 <span className='text-destructive'>*</span>
|
||||
{t('username')} <span className='text-destructive'>*</span>
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id='username'
|
||||
value={username}
|
||||
onChange={(e) => 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({
|
||||
</Field>
|
||||
<Field className='gap-1.5'>
|
||||
<FieldLabel htmlFor='password'>
|
||||
密码 <span className='text-destructive'>*</span>
|
||||
{t('password')} <span className='text-destructive'>*</span>
|
||||
</FieldLabel>
|
||||
<div className='relative'>
|
||||
<Input
|
||||
@@ -280,7 +272,7 @@ export function LoginForm({
|
||||
value={password}
|
||||
onChange={(e) => 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 ? (
|
||||
<>
|
||||
<Spinner />
|
||||
登录中...
|
||||
{t('submitting')}
|
||||
</>
|
||||
) : (
|
||||
'登录'
|
||||
t('submit')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -337,7 +331,7 @@ export function LoginForm({
|
||||
<div className='flex flex-col gap-3'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<Separator className='flex-1' />
|
||||
<span className='text-xs text-muted-foreground'>或者使用</span>
|
||||
<span className='text-xs text-muted-foreground'>{t('orUse')}</span>
|
||||
<Separator className='flex-1' />
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
@@ -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 })}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
@@ -358,12 +352,12 @@ export function LoginForm({
|
||||
|
||||
{registrationEnabled && (
|
||||
<div className='text-center text-sm text-muted-foreground'>
|
||||
还没有账号?{' '}
|
||||
{t('noAccount')}{' '}
|
||||
<Link
|
||||
href='/register'
|
||||
className='font-medium text-foreground underline underline-offset-4'
|
||||
>
|
||||
立即注册
|
||||
{t('registerNow')}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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 (
|
||||
<div className='flex flex-col gap-6 [@media(max-height:700px)]:gap-4'>
|
||||
<AuthHeading
|
||||
title='验证您的登录'
|
||||
description='输入发送到安全邮箱的 6 位验证码。'
|
||||
/>
|
||||
<AuthHeading title={t('title')} description={t('description')} />
|
||||
{loginCodeTip ? (
|
||||
<p className='text-sm leading-6 text-muted-foreground'>
|
||||
{loginCodeTip}
|
||||
@@ -51,7 +51,7 @@ export function OTPForm({
|
||||
htmlFor='otp-verification'
|
||||
className='text-sm font-medium'
|
||||
>
|
||||
验证码
|
||||
{t('code')}
|
||||
</FieldLabel>
|
||||
<Button
|
||||
variant='outline'
|
||||
@@ -62,7 +62,9 @@ export function OTPForm({
|
||||
className='h-8 text-xs'
|
||||
>
|
||||
<RefreshCwIcon className={cn(isPending && 'animate-spin')} />
|
||||
{loginCooldown > 0 ? `${loginCooldown}秒后重发` : '重新发送'}
|
||||
{loginCooldown > 0
|
||||
? t('resendIn', { seconds: loginCooldown })
|
||||
: t('resend')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className='flex justify-start'>
|
||||
@@ -96,15 +98,15 @@ export function OTPForm({
|
||||
onClick={onSubmit}
|
||||
disabled={isPending || code.length < 6}
|
||||
>
|
||||
{isPending ? '验证中...' : '验证'}
|
||||
{isPending ? t('submitting') : t('submit')}
|
||||
</Button>
|
||||
<div className='text-center text-sm text-muted-foreground'>
|
||||
遇到登录问题?{' '}
|
||||
{t('help')}{' '}
|
||||
<a
|
||||
href='#'
|
||||
className='underline underline-offset-4 transition-colors hover:text-primary'
|
||||
>
|
||||
联系客服
|
||||
{t('contactSupport')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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() {
|
||||
<div className='flex flex-col gap-6 [@media(max-height:700px)]:gap-4'>
|
||||
<AuthHeading
|
||||
siteName={publicConfigQuery.data?.site_name}
|
||||
title='创建您的账号'
|
||||
description='填写以下信息,开始使用平台服务。'
|
||||
title={t('title')}
|
||||
description={t('description')}
|
||||
/>
|
||||
|
||||
<div className='flex flex-col gap-5 [@media(max-height:700px)]:gap-3'>
|
||||
<FieldGroup className='gap-4 [@media(min-width:500px)]:grid [@media(min-width:500px)]:grid-cols-2 [@media(max-height:700px)]:gap-3'>
|
||||
<Field className='gap-1.5'>
|
||||
<FieldLabel htmlFor='username'>
|
||||
用户名 <span className='text-destructive'>*</span>
|
||||
{t('username')} <span className='text-destructive'>*</span>
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id='username'
|
||||
value={username}
|
||||
onChange={(e) => 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() {
|
||||
</Field>
|
||||
<Field className='gap-1.5'>
|
||||
<FieldLabel htmlFor='nickname'>
|
||||
昵称
|
||||
{t('nickname')}
|
||||
<span className='ml-1 text-xs font-normal text-muted-foreground'>
|
||||
(可选)
|
||||
{tCommon('optional')}
|
||||
</span>
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id='nickname'
|
||||
value={nickname}
|
||||
onChange={(e) => 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() {
|
||||
</Field>
|
||||
<Field className='gap-1.5'>
|
||||
<FieldLabel htmlFor='email'>
|
||||
电子邮箱 <span className='text-destructive'>*</span>
|
||||
{t('email')} <span className='text-destructive'>*</span>
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id='email'
|
||||
value={email}
|
||||
onChange={(e) => 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() {
|
||||
</Field>
|
||||
<Field className='gap-1.5'>
|
||||
<FieldLabel htmlFor='password'>
|
||||
密码 <span className='text-destructive'>*</span>
|
||||
{t('password')} <span className='text-destructive'>*</span>
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id='password'
|
||||
value={password}
|
||||
onChange={(e) => 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 && (
|
||||
<Field className='gap-1.5 [@media(min-width:500px)]:col-span-2'>
|
||||
<FieldLabel htmlFor='code'>
|
||||
邮箱验证码 <span className='text-destructive'>*</span>
|
||||
{t('emailCode')} <span className='text-destructive'>*</span>
|
||||
</FieldLabel>
|
||||
<div className='flex gap-2'>
|
||||
<Input
|
||||
id='code'
|
||||
value={code}
|
||||
onChange={(e) => 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')}
|
||||
</Button>
|
||||
</div>
|
||||
</Field>
|
||||
@@ -394,21 +389,21 @@ export function RegisterForm() {
|
||||
{registerMutation.isPending ? (
|
||||
<>
|
||||
<Spinner />
|
||||
注册中...
|
||||
{t('submitting')}
|
||||
</>
|
||||
) : (
|
||||
'创建账号'
|
||||
t('submit')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='text-center text-sm text-muted-foreground'>
|
||||
已经有账号?{' '}
|
||||
{t('hasAccount')}{' '}
|
||||
<Link
|
||||
href='/login'
|
||||
className='font-medium text-foreground underline underline-offset-4'
|
||||
>
|
||||
返回登录
|
||||
{t('backToLogin')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div className={cn('flex gap-2', className)}>
|
||||
{locales.map((item) => (
|
||||
<Button
|
||||
key={item}
|
||||
type='button'
|
||||
size='sm'
|
||||
variant={item === locale ? 'default' : 'secondary'}
|
||||
className='flex-1 text-xs'
|
||||
onClick={() => {
|
||||
if (item !== locale) switchLocale(item);
|
||||
}}
|
||||
>
|
||||
{localeLabels[item]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className={cn(
|
||||
'size-9 text-muted-foreground hover:text-foreground',
|
||||
className,
|
||||
)}
|
||||
aria-label={localeLabels[locale]}
|
||||
>
|
||||
<Languages className='size-[18px]' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
{locales.map((item) => (
|
||||
<DropdownMenuItem
|
||||
key={item}
|
||||
onClick={() => {
|
||||
if (item !== locale) switchLocale(item);
|
||||
}}
|
||||
className={item === locale ? 'bg-accent' : undefined}
|
||||
>
|
||||
{localeLabels[item]}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href='/settings' className='text-base text-primary'>
|
||||
设置
|
||||
{t('title')}
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage className='text-base font-semibold'>
|
||||
访问令牌
|
||||
{ta('breadcrumb')}
|
||||
</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
@@ -202,11 +195,9 @@ export function AccessTokenMain() {
|
||||
<div className='flex items-center gap-4'>
|
||||
<div>
|
||||
<h1 className='text-xl font-bold tracking-tight bg-gradient-to-r from-foreground via-foreground/90 to-muted-foreground bg-clip-text text-transparent'>
|
||||
个人访问令牌 (AccessToken)
|
||||
{ta('title')}
|
||||
</h1>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
管理您的 API 访问密钥,用于开发或第三方工具直接调用系统 API
|
||||
</p>
|
||||
<p className='text-sm text-muted-foreground'>{ta('subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
@@ -215,7 +206,7 @@ export function AccessTokenMain() {
|
||||
variant={'secondary'}
|
||||
>
|
||||
<Plus className='mr-1.5 size-4' />
|
||||
生成新令牌
|
||||
{ta('generate')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -223,20 +214,18 @@ export function AccessTokenMain() {
|
||||
<div className='rounded-xl border border-amber-500/20 bg-amber-500/5 p-4 flex gap-3 text-amber-600 text-xs leading-relaxed'>
|
||||
<AlertTriangle className='size-4 shrink-0 mt-0.5' />
|
||||
<div className='space-y-1'>
|
||||
<span className='font-bold'>安全提示:</span>
|
||||
<p className='text-muted-foreground'>
|
||||
访问令牌具有您账户的完整接口调用权限。为了您的账户与资产安全,请切勿通过任何代码库提交、即时通讯工具或公共媒介泄露此令牌。推荐按需创建,不使用时及时撤销。
|
||||
</p>
|
||||
<span className='font-bold'>{ta('securityTitle')}</span>
|
||||
<p className='text-muted-foreground'>{ta('securityBody')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className='border border-dashed shadow-sm'>
|
||||
<CardHeader className='border-b border-dashed pb-4'>
|
||||
<CardTitle className='text-base font-semibold'>
|
||||
活动令牌列表
|
||||
{ta('listTitle')}
|
||||
</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
当前可用的所有访问令牌
|
||||
{ta('listDesc')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='pt-6 space-y-4'>
|
||||
@@ -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'
|
||||
>
|
||||
<Shield className='size-2.5 mr-0.5' />
|
||||
管理员
|
||||
{ta('adminBadge')}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='text-[10px] px-1.5 py-0 h-4 border-border/50 text-muted-foreground bg-muted/10 font-semibold'
|
||||
>
|
||||
用户令牌
|
||||
{ta('userToken')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -278,7 +267,11 @@ export function AccessTokenMain() {
|
||||
{token.masked_token}
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-x-4 gap-y-0.5 pt-1'>
|
||||
<span>创建于: {formatDate(token.created_at)}</span>
|
||||
<span>
|
||||
{ta('createdAt', {
|
||||
date: formatDate(token.created_at),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -297,7 +290,7 @@ export function AccessTokenMain() {
|
||||
) : (
|
||||
<Copy className='size-3.5 mr-1' />
|
||||
)}
|
||||
复制
|
||||
{ta('copy')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
@@ -310,7 +303,7 @@ export function AccessTokenMain() {
|
||||
<RefreshCw
|
||||
className={`size-3.5 mr-1 ${rotateTokenMutation.isPending && rotateTokenMutation.variables === token.id ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
轮换
|
||||
{ta('rotate')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
@@ -321,7 +314,7 @@ export function AccessTokenMain() {
|
||||
disabled={deleteTokenMutation.isPending}
|
||||
>
|
||||
<Trash2 className='size-3.5 mr-1' />
|
||||
撤销
|
||||
{ta('revoke')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -330,7 +323,7 @@ export function AccessTokenMain() {
|
||||
) : (
|
||||
<div className='rounded-xl border border-dashed border-border/50 px-4 py-10 text-center text-xs text-muted-foreground bg-muted/5 flex flex-col items-center justify-center gap-3'>
|
||||
<Key className='size-8 text-muted-foreground/30' />
|
||||
<span>您当前暂无生成任何访问令牌</span>
|
||||
<span>{ta('empty')}</span>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
@@ -339,7 +332,7 @@ export function AccessTokenMain() {
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
>
|
||||
<Plus className='mr-1 size-3.5' />
|
||||
生成第一个令牌
|
||||
{ta('generateFirst')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -352,20 +345,20 @@ export function AccessTokenMain() {
|
||||
<form onSubmit={handleCreateToken}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className='text-base font-semibold'>
|
||||
生成新令牌
|
||||
{ta('generate')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className='text-xs text-muted-foreground'>
|
||||
请为新的访问令牌设置一个易于识别的名称,以便将来管理。
|
||||
{ta('createDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className='space-y-4 py-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='token-name' className='text-xs font-semibold'>
|
||||
令牌名称
|
||||
{ta('name')}
|
||||
</Label>
|
||||
<Input
|
||||
id='token-name'
|
||||
placeholder='例如:my-development-key'
|
||||
placeholder={ta('namePlaceholder')}
|
||||
value={tokenName}
|
||||
onChange={(e) => 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'
|
||||
>
|
||||
<Shield className='size-3.5 text-rose-500' />
|
||||
管理员权限
|
||||
{ta('adminPermission')}
|
||||
</Label>
|
||||
<p className='text-[11px] text-muted-foreground leading-normal'>
|
||||
开启后此令牌可访问 /admin/** 管理端点,默认关闭
|
||||
{ta('adminHint')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -403,7 +396,7 @@ export function AccessTokenMain() {
|
||||
disabled={createTokenMutation.isPending}
|
||||
className='rounded-xl text-xs h-9 border-dashed'
|
||||
>
|
||||
取消
|
||||
{tCommon('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
@@ -413,10 +406,10 @@ export function AccessTokenMain() {
|
||||
{createTokenMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className='mr-1.5 size-3.5 animate-spin' />
|
||||
正在生成...
|
||||
{ta('generating')}
|
||||
</>
|
||||
) : (
|
||||
'生成令牌'
|
||||
ta('generateAction')
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@@ -438,7 +431,7 @@ export function AccessTokenMain() {
|
||||
<DialogHeader>
|
||||
<DialogTitle className='text-base font-bold text-foreground flex items-center gap-1.5'>
|
||||
<Check className='size-5 text-emerald-500 border border-emerald-500 rounded-full p-0.5' />
|
||||
令牌密钥已就绪
|
||||
{ta('readyTitle')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -468,10 +461,8 @@ export function AccessTokenMain() {
|
||||
<div className='rounded-xl border border-rose-500/20 bg-rose-500/5 p-4 flex gap-3 text-rose-600 text-xs leading-relaxed'>
|
||||
<Info className='size-4 shrink-0 mt-0.5' />
|
||||
<div className='space-y-1'>
|
||||
<span className='font-bold'>重要提示:</span>
|
||||
<p className='text-muted-foreground'>
|
||||
这是您唯一一次能够查看此访问令牌明文密钥的机会。请立即将其复制并安全地保存。
|
||||
</p>
|
||||
<span className='font-bold'>{ta('importantTitle')}</span>
|
||||
<p className='text-muted-foreground'>{ta('readyBody')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -486,7 +477,7 @@ export function AccessTokenMain() {
|
||||
}}
|
||||
className='rounded-xl w-full'
|
||||
>
|
||||
我已经复制并妥善保存
|
||||
{ta('savedConfirm')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -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 (
|
||||
<div className='space-y-6'>
|
||||
<div>
|
||||
<h2 className='font-medium text-sm text-foreground'>主题模式</h2>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
选择应用程序的整体明暗主题
|
||||
</p>
|
||||
<h2 className='font-medium text-sm text-foreground'>
|
||||
{t('themeMode')}
|
||||
</h2>
|
||||
<p className='text-xs text-muted-foreground'>{t('themeModeDesc')}</p>
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
{modes.map((mode) => {
|
||||
@@ -63,10 +66,10 @@ function ThemeModeSection() {
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<div>
|
||||
<h2 className='font-medium text-sm text-foreground'>主题模式</h2>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
选择应用程序的整体明暗主题
|
||||
</p>
|
||||
<h2 className='font-medium text-sm text-foreground'>
|
||||
{t('themeMode')}
|
||||
</h2>
|
||||
<p className='text-xs text-muted-foreground'>{t('themeModeDesc')}</p>
|
||||
</div>
|
||||
|
||||
<div className='flex gap-2'>
|
||||
@@ -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<string | null>(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 (
|
||||
<div className='space-y-6'>
|
||||
<div>
|
||||
<h2 className='font-medium text-sm text-foreground'>界面外观</h2>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
选择界面的配色方案和视觉风格
|
||||
</p>
|
||||
<h2 className='font-medium text-sm text-foreground'>
|
||||
{t('interface')}
|
||||
</h2>
|
||||
<p className='text-xs text-muted-foreground'>{t('interfaceDesc')}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -229,6 +233,9 @@ function InterfaceAppearanceSection() {
|
||||
}
|
||||
|
||||
export function AppearanceMain() {
|
||||
const t = useTranslations('settings');
|
||||
const ta = useTranslations('settings.appearance');
|
||||
|
||||
return (
|
||||
<div className='py-6 space-y-6'>
|
||||
<div className='font-semibold'>
|
||||
@@ -237,14 +244,14 @@ export function AppearanceMain() {
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href='/settings' className='text-base text-primary'>
|
||||
设置
|
||||
{t('title')}
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage className='text-base font-semibold'>
|
||||
外观设置
|
||||
{ta('breadcrumb')}
|
||||
</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
@@ -253,13 +260,25 @@ export function AppearanceMain() {
|
||||
|
||||
<ThemeModeSection />
|
||||
|
||||
<div className='space-y-6'>
|
||||
<div>
|
||||
<h2 className='font-medium text-sm text-foreground'>
|
||||
{ta('language')}
|
||||
</h2>
|
||||
<p className='text-xs text-muted-foreground'>{ta('languageDesc')}</p>
|
||||
</div>
|
||||
<LanguageSwitcher variant='full' />
|
||||
</div>
|
||||
|
||||
<InterfaceAppearanceSection />
|
||||
|
||||
<div className='space-y-4 opacity-50 pointer-events-none grayscale'>
|
||||
<h2 className='font-medium text-sm text-muted-foreground'>即将推出</h2>
|
||||
<h2 className='font-medium text-sm text-muted-foreground'>
|
||||
{ta('comingSoon')}
|
||||
</h2>
|
||||
<div className='bg-muted/30 rounded-xl p-6 h-32 flex items-center justify-center border border-border/50'>
|
||||
<span className='text-sm text-muted-foreground'>
|
||||
更多自定义选项正在开发中...
|
||||
{ta('comingSoonBody')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div className='py-6 space-y-6'>
|
||||
@@ -25,14 +28,14 @@ export function NotificationsMain() {
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href='/settings' className='text-base text-primary'>
|
||||
设置
|
||||
{t('title')}
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage className='text-base font-semibold'>
|
||||
通知设置
|
||||
{tn('breadcrumb')}
|
||||
</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
@@ -41,10 +44,10 @@ export function NotificationsMain() {
|
||||
|
||||
<div className='space-y-6'>
|
||||
<div>
|
||||
<h2 className='font-medium text-sm text-foreground'>通知显示</h2>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
设置通知相关的显示选项
|
||||
</p>
|
||||
<h2 className='font-medium text-sm text-foreground'>
|
||||
{tn('display')}
|
||||
</h2>
|
||||
<p className='text-xs text-muted-foreground'>{tn('displayDesc')}</p>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between rounded-lg border p-4'>
|
||||
@@ -55,10 +58,10 @@ export function NotificationsMain() {
|
||||
htmlFor='show-bell'
|
||||
className='text-sm font-medium cursor-pointer'
|
||||
>
|
||||
显示通知铃铛
|
||||
{tn('showBell')}
|
||||
</Label>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
在顶部导航栏中显示通知铃铛图标
|
||||
{tn('showBellDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
<span className='sr-only'>通知</span>
|
||||
<span className='sr-only'>{t('notifications')}</span>
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
@@ -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({
|
||||
<div className='bg-amber-500/10 border-b border-amber-500/20 px-4 py-3 text-amber-500 text-xs sm:text-sm flex items-center justify-between gap-4'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Info className='size-4 shrink-0 text-amber-500' />
|
||||
<span>为了您的账号安全,请立即修改密码!</span>
|
||||
<span>{t('changePasswordBanner')}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant='outline'
|
||||
@@ -110,7 +114,7 @@ export function SiteHeader({
|
||||
className='text-amber-500 hover:text-amber-600 border-amber-500/30 hover:border-amber-500/50 bg-transparent shrink-0 h-8 px-3 text-xs'
|
||||
onClick={() => router.push('/settings/profile')}
|
||||
>
|
||||
去修改密码
|
||||
{t('changePasswordAction')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -130,9 +134,10 @@ export function SiteHeader({
|
||||
onClick={() => setSearchOpen(true)}
|
||||
>
|
||||
<Search className='size-[18px]' />
|
||||
<span className='sr-only'>搜索</span>
|
||||
<span className='sr-only'>{t('search')}</span>
|
||||
</Button>
|
||||
<BellButton />
|
||||
<LanguageSwitcher />
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
@@ -140,7 +145,7 @@ export function SiteHeader({
|
||||
onClick={() => router.push('/settings')}
|
||||
>
|
||||
<Settings className='size-[18px]' />
|
||||
<span className='sr-only'>设置</span>
|
||||
<span className='sr-only'>{t('settings')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -154,7 +159,7 @@ export function SiteHeader({
|
||||
>
|
||||
<Search className='absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground' />
|
||||
<div className='flex h-8 items-center rounded-md border border-border/60 bg-muted/70 pl-10 pr-2.5 text-sm text-muted-foreground transition-colors hover:border-border hover:bg-muted'>
|
||||
<span>搜索</span>
|
||||
<span>{t('search')}</span>
|
||||
<Kbd className='ml-auto gap-0.5 font-mono'>
|
||||
<span>{metaKey}</span>
|
||||
<span>K</span>
|
||||
@@ -164,6 +169,7 @@ export function SiteHeader({
|
||||
|
||||
<div className='ml-auto flex items-center gap-1'>
|
||||
<BellButton />
|
||||
<LanguageSwitcher />
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
@@ -171,7 +177,7 @@ export function SiteHeader({
|
||||
onClick={() => router.push('/settings')}
|
||||
>
|
||||
<Settings className='size-[18px]' />
|
||||
<span className='sr-only'>设置</span>
|
||||
<span className='sr-only'>{t('settings')}</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@@ -185,7 +191,7 @@ export function SiteHeader({
|
||||
) : (
|
||||
<Maximize2 className='size-[18px]' />
|
||||
)}
|
||||
<span className='sr-only'>切换全宽</span>
|
||||
<span className='sr-only'>{t('toggleFullWidth')}</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@@ -205,7 +211,7 @@ export function SiteHeader({
|
||||
) : (
|
||||
<Moon className='size-[18px]' />
|
||||
)}
|
||||
<span className='sr-only'>主题切换</span>
|
||||
<span className='sr-only'>{t('toggleTheme')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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() {
|
||||
>
|
||||
<div className='flex w-full items-center justify-center px-4 pr-10 py-2 text-xs md:text-[13px] font-medium text-muted-foreground'>
|
||||
<p className='flex flex-wrap items-center justify-center gap-x-2 text-center'>
|
||||
<span className='text-foreground'>最新通知</span>
|
||||
<span className='text-foreground'>{t('bannerTitle')}</span>
|
||||
<a
|
||||
href='https://example.com'
|
||||
target='_blank'
|
||||
className='underline underline-offset-4 hover:text-foreground'
|
||||
>
|
||||
查看详情
|
||||
{t('bannerLink')}
|
||||
</a>
|
||||
</p>
|
||||
<Button
|
||||
@@ -261,7 +268,7 @@ function AppBanner() {
|
||||
className='absolute right-2 top-1/2 size-6 -translate-y-1/2 text-muted-foreground hover:text-foreground shrink-0'
|
||||
onClick={handleDismiss}
|
||||
>
|
||||
<span className='sr-only'>Dismiss</span>
|
||||
<span className='sr-only'>{t('dismiss')}</span>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='14'
|
||||
|
||||
@@ -62,37 +62,44 @@ import {
|
||||
UserRound,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useUser } from '@/contexts/user-context';
|
||||
import { usePublicConfig } from '@/hooks/use-public-config';
|
||||
|
||||
/* 导航数据 */
|
||||
const data = {
|
||||
navMain: [
|
||||
{ title: '首页', url: '/home', icon: Home },
|
||||
{ title: '我的文件', url: '/files', icon: FolderOpen },
|
||||
],
|
||||
admin: [
|
||||
{ title: '用户管理', url: '/admin/users', icon: UserRound },
|
||||
{ title: '任务管理', url: '/admin/tasks', icon: Layers },
|
||||
{ title: '存储管理', url: '/admin/files', icon: FolderOpen },
|
||||
{ title: '数据管理', url: '/admin/database', icon: Database },
|
||||
{ title: '通知推送', url: '/admin/push', icon: Bell },
|
||||
{ title: '系统日志', url: '/admin/logs', icon: Terminal },
|
||||
{ title: '系统配置', url: '/admin/system', icon: ShieldCheck },
|
||||
{ title: '系统设置', url: '/admin/settings', icon: Settings },
|
||||
],
|
||||
document: [
|
||||
{ title: '规范示例', url: '/admin/demo', icon: Code },
|
||||
{ title: '接口文档', url: '/docs/api', icon: CreditCard, external: true },
|
||||
{
|
||||
title: '使用文档',
|
||||
url: '/docs/how-to-use',
|
||||
icon: FileText,
|
||||
external: true,
|
||||
},
|
||||
],
|
||||
type NavItem = {
|
||||
titleKey: string;
|
||||
url: string;
|
||||
icon: React.ComponentType;
|
||||
external?: boolean;
|
||||
};
|
||||
|
||||
const navMainItems: NavItem[] = [
|
||||
{ titleKey: 'home', url: '/home', icon: Home },
|
||||
{ titleKey: 'myFiles', url: '/files', icon: FolderOpen },
|
||||
];
|
||||
|
||||
const adminItems: NavItem[] = [
|
||||
{ titleKey: 'users', url: '/admin/users', icon: UserRound },
|
||||
{ titleKey: 'tasks', url: '/admin/tasks', icon: Layers },
|
||||
{ titleKey: 'storage', url: '/admin/files', icon: FolderOpen },
|
||||
{ titleKey: 'database', url: '/admin/database', icon: Database },
|
||||
{ titleKey: 'push', url: '/admin/push', icon: Bell },
|
||||
{ titleKey: 'logs', url: '/admin/logs', icon: Terminal },
|
||||
{ titleKey: 'system', url: '/admin/system', icon: ShieldCheck },
|
||||
{ titleKey: 'adminSettings', url: '/admin/settings', icon: Settings },
|
||||
];
|
||||
|
||||
const documentItems: NavItem[] = [
|
||||
{ titleKey: 'demo', url: '/admin/demo', icon: Code },
|
||||
{ titleKey: 'apiDocs', url: '/docs/api', icon: CreditCard, external: true },
|
||||
{
|
||||
titleKey: 'usageDocs',
|
||||
url: '/docs/how-to-use',
|
||||
icon: FileText,
|
||||
external: true,
|
||||
},
|
||||
];
|
||||
|
||||
function parseMenuDisplayConfig(
|
||||
raw: string | undefined,
|
||||
): Record<string, boolean> {
|
||||
@@ -130,6 +137,8 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
const { toggleSidebar, state, isMobile, setOpenMobile } = useSidebar();
|
||||
const { user, logout, loading: userLoading } = useUser();
|
||||
const { config } = usePublicConfig();
|
||||
const t = useTranslations('layout');
|
||||
const tCommon = useTranslations('common');
|
||||
const [showLogoutDialog, setShowLogoutDialog] = React.useState(false);
|
||||
const [isLoggingOut, setIsLoggingOut] = React.useState(false);
|
||||
const pathname = usePathname();
|
||||
@@ -169,9 +178,11 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
await logout();
|
||||
setShowLogoutDialog(false);
|
||||
} catch (error) {
|
||||
toast.error('登出失败', {
|
||||
toast.error(t('logoutDialog.failed'), {
|
||||
description:
|
||||
error instanceof Error ? error.message : '登出时发生错误,请重试',
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t('logoutDialog.failedDescription'),
|
||||
});
|
||||
setIsLoggingOut(false);
|
||||
}
|
||||
@@ -179,17 +190,17 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
|
||||
const navMainFiltered = React.useMemo(() => {
|
||||
const displayConfig = parseMenuDisplayConfig(config?.menu_display_config);
|
||||
return data.navMain.filter((item) => displayConfig[item.url] !== false);
|
||||
return navMainItems.filter((item) => displayConfig[item.url] !== false);
|
||||
}, [config]);
|
||||
|
||||
const adminFiltered = React.useMemo(() => {
|
||||
const displayConfig = parseMenuDisplayConfig(config?.menu_display_config);
|
||||
return data.admin.filter((item) => displayConfig[item.url] !== false);
|
||||
return adminItems.filter((item) => displayConfig[item.url] !== false);
|
||||
}, [config]);
|
||||
|
||||
const documentFiltered = React.useMemo(() => {
|
||||
const displayConfig = parseMenuDisplayConfig(config?.menu_display_config);
|
||||
return data.document.filter((item) => displayConfig[item.url] !== false);
|
||||
return documentItems.filter((item) => displayConfig[item.url] !== false);
|
||||
}, [config]);
|
||||
|
||||
return (
|
||||
@@ -248,7 +259,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
{user?.nickname || user?.username || 'Unknown User'}
|
||||
</span>
|
||||
<span className='text-[11px] font-medium text-muted-foreground/100 truncate w-full text-left ml-2'>
|
||||
{user?.is_admin ? '系统管理员' : '普通用户'}
|
||||
{user?.is_admin ? t('user.adminRole') : t('user.userRole')}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown className='size-4 text-muted-foreground ml-auto group-data-[collapsible=icon]:hidden' />
|
||||
@@ -299,15 +310,17 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
</div>
|
||||
<span className='text-base font-semibold truncate mt-2'>
|
||||
{userLoading
|
||||
? '加载中...'
|
||||
: user?.nickname || user?.username || '未登录'}
|
||||
? tCommon('loading')
|
||||
: user?.nickname ||
|
||||
user?.username ||
|
||||
t('user.notLoggedIn')}
|
||||
</span>
|
||||
<span className='text-xs font-base text-muted-foreground'>
|
||||
{userLoading
|
||||
? '正在同步账户'
|
||||
? t('user.syncing')
|
||||
: user?.is_admin
|
||||
? '系统管理员'
|
||||
: '普通用户'}
|
||||
? t('user.adminRole')
|
||||
: t('user.userRole')}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
@@ -318,7 +331,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
}}
|
||||
>
|
||||
<UserRound className='mr-2 size-4' />
|
||||
<span>我的资料</span>
|
||||
<span>{t('user.profile')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
@@ -327,7 +340,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
}}
|
||||
>
|
||||
<Settings className='mr-2 size-4' />
|
||||
<span>设置</span>
|
||||
<span>{t('user.settings')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className='my-2' />
|
||||
<DropdownMenuItem
|
||||
@@ -337,14 +350,14 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
}}
|
||||
>
|
||||
<FileQuestionMark className='mr-2 size-4' />
|
||||
<span>使用帮助</span>
|
||||
<span>{t('user.help')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className='text-destructive hover:bg-destructive/50'
|
||||
onClick={() => setShowLogoutDialog(true)}
|
||||
>
|
||||
<LogOut className='mr-2 size-4 text-destructive' />
|
||||
<span>退出登录</span>
|
||||
<span>{t('user.logout')}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -354,20 +367,23 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
<SidebarGroup className='py-0'>
|
||||
<SidebarGroupContent className='py-1'>
|
||||
<SidebarMenu className='gap-1'>
|
||||
{navMainFiltered.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
tooltip={item.title}
|
||||
isActive={pathname === item.url}
|
||||
asChild
|
||||
>
|
||||
<Link href={item.url} onClick={handleCloseSidebar}>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
{navMainFiltered.map((item) => {
|
||||
const title = t(`nav.${item.titleKey}`);
|
||||
return (
|
||||
<SidebarMenuItem key={item.url}>
|
||||
<SidebarMenuButton
|
||||
tooltip={title}
|
||||
isActive={pathname === item.url}
|
||||
asChild
|
||||
>
|
||||
<Link href={item.url} onClick={handleCloseSidebar}>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
@@ -376,27 +392,30 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
{user?.is_admin && adminFiltered.length > 0 && (
|
||||
<SidebarGroup className='py-0 pt-4'>
|
||||
<SidebarGroupLabel className='text-xs font-normal text-muted-foreground'>
|
||||
管理
|
||||
{t('groups.admin')}
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent className='py-1'>
|
||||
<SidebarMenu className='gap-1'>
|
||||
{adminFiltered.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
tooltip={item.title}
|
||||
isActive={
|
||||
pathname === item.url ||
|
||||
pathname.startsWith(`${item.url}/`)
|
||||
}
|
||||
asChild
|
||||
>
|
||||
<Link href={item.url} onClick={handleCloseSidebar}>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
{adminFiltered.map((item) => {
|
||||
const title = t(`nav.${item.titleKey}`);
|
||||
return (
|
||||
<SidebarMenuItem key={item.url}>
|
||||
<SidebarMenuButton
|
||||
tooltip={title}
|
||||
isActive={
|
||||
pathname === item.url ||
|
||||
pathname.startsWith(`${item.url}/`)
|
||||
}
|
||||
asChild
|
||||
>
|
||||
<Link href={item.url} onClick={handleCloseSidebar}>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
@@ -405,37 +424,40 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
{documentFiltered.length > 0 && (
|
||||
<SidebarGroup className='py-0 pt-4'>
|
||||
<SidebarGroupLabel className='text-xs font-normal text-muted-foreground'>
|
||||
文档库
|
||||
{t('groups.docs')}
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent className='py-1'>
|
||||
<SidebarMenu className='gap-1'>
|
||||
{documentFiltered.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
tooltip={item.title}
|
||||
isActive={pathname === item.url}
|
||||
asChild
|
||||
>
|
||||
{item.external ? (
|
||||
<Link
|
||||
href={item.url}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
onClick={handleCloseSidebar}
|
||||
>
|
||||
{item.icon && <item.icon />}
|
||||
<span className='flex-1'>{item.title}</span>
|
||||
<ArrowUpRight className='size-3 text-muted-foreground' />
|
||||
</Link>
|
||||
) : (
|
||||
<Link href={item.url} onClick={handleCloseSidebar}>
|
||||
{item.icon && <item.icon />}
|
||||
<span className='flex-1'>{item.title}</span>
|
||||
</Link>
|
||||
)}
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
{documentFiltered.map((item) => {
|
||||
const title = t(`nav.${item.titleKey}`);
|
||||
return (
|
||||
<SidebarMenuItem key={item.url}>
|
||||
<SidebarMenuButton
|
||||
tooltip={title}
|
||||
isActive={pathname === item.url}
|
||||
asChild
|
||||
>
|
||||
{item.external ? (
|
||||
<Link
|
||||
href={item.url}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
onClick={handleCloseSidebar}
|
||||
>
|
||||
{item.icon && <item.icon />}
|
||||
<span className='flex-1'>{title}</span>
|
||||
<ArrowUpRight className='size-3 text-muted-foreground' />
|
||||
</Link>
|
||||
) : (
|
||||
<Link href={item.url} onClick={handleCloseSidebar}>
|
||||
{item.icon && <item.icon />}
|
||||
<span className='flex-1'>{title}</span>
|
||||
</Link>
|
||||
)}
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
@@ -455,18 +477,22 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认登出</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t('logoutDialog.title')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{isLoggingOut
|
||||
? '正在登出,请稍候...'
|
||||
: '您确定要登出当前账户吗?'}
|
||||
? t('logoutDialog.loggingOut')
|
||||
: t('logoutDialog.description')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isLoggingOut}>取消</AlertDialogCancel>
|
||||
<AlertDialogCancel disabled={isLoggingOut}>
|
||||
{tCommon('cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleLogout} disabled={isLoggingOut}>
|
||||
{isLoggingOut && <Spinner className='mr-2' />}
|
||||
{isLoggingOut ? '登出中...' : '确认登出'}
|
||||
{isLoggingOut
|
||||
? t('logoutDialog.inProgress')
|
||||
: t('logoutDialog.confirm')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -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 (
|
||||
<NextIntlClientProvider
|
||||
locale={locale}
|
||||
messages={messages}
|
||||
timeZone='Asia/Shanghai'
|
||||
>
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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<AppLocale, string> = {
|
||||
'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;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { AppLocale } from './config';
|
||||
import { defaultLocale } from './config';
|
||||
|
||||
const localeToIntl: Record<AppLocale, string> = {
|
||||
'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);
|
||||
}
|
||||
}
|
||||
@@ -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<AppLocale> {
|
||||
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,
|
||||
};
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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": "通用二次开发标准范式平台"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+421
-10
@@ -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
|
||||
|
||||
@@ -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)`);
|
||||
Reference in New Issue
Block a user