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:
ryan
2026-07-24 16:25:41 +08:00
parent 9c1369186f
commit 1625cfb2fb
23 changed files with 1638 additions and 355 deletions
+28 -34
View File
@@ -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>
)}
+11 -9
View File
@@ -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>
+36 -41
View File
@@ -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>
+19 -12
View File
@@ -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'
+132 -106
View File
@@ -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>
);
}