feat(frontend): 控制台 next-intl 中英双语

接入无 URL 前缀的 zh-CN/en,顶栏与外观设置可切换语言;选择写入 cookie 后刷新生效。
This commit is contained in:
ryan
2026-08-16 16:49:16 +08:00
parent 64fbaa7ef1
commit 5a8722ff07
219 changed files with 21437 additions and 5212 deletions
@@ -59,12 +59,19 @@ 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';
type ConfirmTarget = { id: number; name: string };
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('');
@@ -98,10 +105,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'));
},
});
@@ -113,10 +120,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'));
},
});
@@ -130,17 +137,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({
@@ -153,22 +160,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 +185,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 +203,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 +214,7 @@ export function AccessTokenMain() {
variant={'secondary'}
>
<Plus className='mr-1.5 size-4' />
生成新令牌
{ta('generate')}
</Button>
</div>
@@ -223,20 +222,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 +259,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 +275,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>
@@ -296,7 +297,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'
@@ -309,7 +310,7 @@ export function AccessTokenMain() {
disabled={deleteTokenMutation.isPending}
>
<Trash2 className='size-3.5 mr-1' />
撤销
{ta('revoke')}
</Button>
</div>
</div>
@@ -318,7 +319,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'
@@ -327,7 +328,7 @@ export function AccessTokenMain() {
onClick={() => setCreateDialogOpen(true)}
>
<Plus className='mr-1 size-3.5' />
生成第一个令牌
{ta('generateFirst')}
</Button>
</div>
)}
@@ -340,20 +341,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}
@@ -368,10 +369,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
@@ -391,7 +392,7 @@ export function AccessTokenMain() {
disabled={createTokenMutation.isPending}
className='rounded-xl text-xs h-9 border-dashed'
>
取消
{tCommon('cancel')}
</Button>
<Button
type='submit'
@@ -401,10 +402,10 @@ export function AccessTokenMain() {
{createTokenMutation.isPending ? (
<>
<Loader2 className='mr-1.5 size-3.5 animate-spin' />
正在生成...
{ta('generating')}
</>
) : (
'生成令牌'
ta('generateAction')
)}
</Button>
</DialogFooter>
@@ -426,7 +427,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>
@@ -456,10 +457,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>
@@ -474,7 +473,7 @@ export function AccessTokenMain() {
}}
className='rounded-xl w-full'
>
我已经复制并妥善保存
{ta('savedConfirm')}
</Button>
</DialogFooter>
</DialogContent>
@@ -486,15 +485,14 @@ export function AccessTokenMain() {
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认撤销访问令牌</AlertDialogTitle>
<AlertDialogTitle>{ta('deleteConfirmTitle')}</AlertDialogTitle>
<AlertDialogDescription>
确定要删除并撤销令牌「{deleteTarget?.name}
」吗?删除后此令牌将立即失效且不可恢复。
{ta('deleteConfirm', { name: deleteTarget?.name ?? '' })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteTokenMutation.isPending}>
取消
{tCommon('cancel')}
</AlertDialogCancel>
<AlertDialogAction
disabled={deleteTokenMutation.isPending}
@@ -502,7 +500,9 @@ export function AccessTokenMain() {
deleteTarget && deleteTokenMutation.mutate(deleteTarget.id)
}
>
{deleteTokenMutation.isPending ? '撤销中...' : '确认撤销'}
{deleteTokenMutation.isPending
? ta('revoking')
: ta('confirmRevoke')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -514,15 +514,14 @@ export function AccessTokenMain() {
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认轮换访问令牌</AlertDialogTitle>
<AlertDialogTitle>{ta('rotateConfirmTitle')}</AlertDialogTitle>
<AlertDialogDescription>
确定要轮换令牌「{rotateTarget?.name}
」的密钥吗?轮换后系统将生成全新密钥,原令牌密钥将立即失效。
{ta('rotateConfirm', { name: rotateTarget?.name ?? '' })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={rotateTokenMutation.isPending}>
取消
{tCommon('cancel')}
</AlertDialogCancel>
<AlertDialogAction
disabled={rotateTokenMutation.isPending}
@@ -530,7 +529,9 @@ export function AccessTokenMain() {
rotateTarget && rotateTokenMutation.mutate(rotateTarget.id)
}
>
{rotateTokenMutation.isPending ? '轮换中...' : '确认轮换'}
{rotateTokenMutation.isPending
? ta('rotating')
: ta('confirmRotate')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>