mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-09 00:56:37 +08:00
feat(frontend): 控制台 next-intl 中英双语
接入无 URL 前缀的 zh-CN/en,顶栏与外观设置可切换语言;选择写入 cookie 后刷新生效。
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -8,6 +8,8 @@ import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useCustomTheme } from '@/lib/theme';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { LanguageSwitcher } from '@/components/common/language-switcher';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
@@ -19,6 +21,7 @@ import {
|
||||
|
||||
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>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -39,6 +40,7 @@ export function AuthSourceModal({
|
||||
onClose: () => void;
|
||||
onChanged: () => Promise<void>;
|
||||
}) {
|
||||
const t = useTranslations('settings.authSourceModal');
|
||||
const [form, setForm] = useState<AuthSourceRequest>(emptyForm);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
@@ -74,10 +76,12 @@ export function AuthSourceModal({
|
||||
await services.adminAuthSource.createAuthSource(form);
|
||||
}
|
||||
await onChanged();
|
||||
toast.success('认证源已保存');
|
||||
toast.success(t('authSourceSaved'));
|
||||
onClose();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '保存认证源失败');
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('saveAuthSourceFailed'),
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -87,17 +91,17 @@ export function AuthSourceModal({
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className='max-w-xl'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{source ? '编辑认证源' : '新增认证源'}</DialogTitle>
|
||||
<DialogTitle>
|
||||
{source ? t('editAuthSource') : t('addAuthSource')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{source
|
||||
? '修改系统自定义的 OIDC 认证源参数。'
|
||||
: '配置新的自定义 OIDC 认证源。'}
|
||||
{source ? t('editAuthSourceDesc') : t('addAuthSourceDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='grid gap-4 md:grid-cols-2 pt-2'>
|
||||
<div className='space-y-2'>
|
||||
<Label>标识符 (英文名称)</Label>
|
||||
<Label>{t('identifier')}</Label>
|
||||
<Input
|
||||
value={form.name}
|
||||
disabled={!!source}
|
||||
@@ -108,7 +112,7 @@ export function AuthSourceModal({
|
||||
/>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Label>展示名称</Label>
|
||||
<Label>{t('displayName')}</Label>
|
||||
<Input
|
||||
value={form.display_name}
|
||||
onChange={(e) =>
|
||||
@@ -133,11 +137,11 @@ export function AuthSourceModal({
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, client_secret: e.target.value }))
|
||||
}
|
||||
placeholder={source ? '留空则保留原值' : ''}
|
||||
placeholder={source ? t('keepOriginalIfEmpty') : ''}
|
||||
/>
|
||||
</div>
|
||||
<div className='space-y-2 md:col-span-2'>
|
||||
<Label>Discovery URL (OIDC 发行方 URL)</Label>
|
||||
<Label>{t('discoveryURL')}</Label>
|
||||
<Input
|
||||
value={form.openid_discovery_url}
|
||||
onChange={(e) =>
|
||||
@@ -159,7 +163,7 @@ export function AuthSourceModal({
|
||||
/>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Label>图标 URL (可选)</Label>
|
||||
<Label>{t('iconURL')}</Label>
|
||||
<Input
|
||||
value={form.icon_url}
|
||||
onChange={(e) =>
|
||||
@@ -170,9 +174,9 @@ export function AuthSourceModal({
|
||||
</div>
|
||||
<div className='flex items-center justify-between rounded-xl border border-dashed p-3 md:col-span-2 bg-muted/10'>
|
||||
<div>
|
||||
<div className='font-medium text-sm'>启用认证源</div>
|
||||
<div className='font-medium text-sm'>{t('enableAuthSource')}</div>
|
||||
<div className='text-xs text-muted-foreground'>
|
||||
启用后会立即显示在登录页和账号绑定中。
|
||||
{t('enableAuthSourceDesc')}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -184,7 +188,7 @@ export function AuthSourceModal({
|
||||
</div>
|
||||
<div className='md:col-span-2 flex justify-end gap-2 pt-2 border-t mt-2'>
|
||||
<Button variant='outline' type='button' onClick={onClose}>
|
||||
取消
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
@@ -192,7 +196,7 @@ export function AuthSourceModal({
|
||||
disabled={saving}
|
||||
variant='secondary'
|
||||
>
|
||||
{saving ? '保存中...' : '保存'}
|
||||
{saving ? t('saving') : t('save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { ExternalLink, RefreshCw, Sparkles } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import services from '@/lib/services';
|
||||
import {
|
||||
@@ -39,6 +40,7 @@ function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
}
|
||||
|
||||
export function InfoTab() {
|
||||
const t = useTranslations('settings.info');
|
||||
const updateQuery = useQuery({
|
||||
queryKey: ['admin', 'update'],
|
||||
queryFn: () => services.adminStatus.getUpdateStatus(),
|
||||
@@ -49,10 +51,10 @@ export function InfoTab() {
|
||||
const applyUpdateMutation = useMutation({
|
||||
mutationFn: () => services.adminStatus.applyUpdate(),
|
||||
onSuccess: () => {
|
||||
toast.success('升级包已校验完成,服务正在重启');
|
||||
toast.success(t('upgradeVerified'));
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || '应用升级失败');
|
||||
toast.error(error.message || t('applyUpgradeFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -69,17 +71,17 @@ export function InfoTab() {
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className='text-base font-semibold'>
|
||||
应用更新
|
||||
{t('appUpdate')}
|
||||
</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
检查上游 GitHub Actions Release 并升级当前服务
|
||||
{t('appUpdateDesc')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
{update?.update_available ? (
|
||||
<Badge>发现新版本</Badge>
|
||||
<Badge>{t('newVersionAvailable')}</Badge>
|
||||
) : update ? (
|
||||
<Badge variant='secondary'>已是最新</Badge>
|
||||
<Badge variant='secondary'>{t('alreadyLatest')}</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -91,7 +93,7 @@ export function InfoTab() {
|
||||
) : updateQuery.isError ? (
|
||||
<div className='flex min-h-32 flex-col items-center justify-center gap-3 text-center'>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
{updateQuery.error.message || '无法获取上游版本信息'}
|
||||
{updateQuery.error.message || t('cannotGetVersion')}
|
||||
</p>
|
||||
<Button
|
||||
type='button'
|
||||
@@ -100,25 +102,34 @@ export function InfoTab() {
|
||||
onClick={() => updateQuery.refetch()}
|
||||
>
|
||||
<RefreshCw data-icon='inline-start' />
|
||||
重新检查
|
||||
{t('recheck')}
|
||||
</Button>
|
||||
</div>
|
||||
) : update ? (
|
||||
<>
|
||||
<div>
|
||||
<InfoRow label='当前版本' value={update.current_version} />
|
||||
<InfoRow label='最新版本' value={update.latest_version} />
|
||||
<InfoRow
|
||||
label={t('currentVersion')}
|
||||
value={update.current_version}
|
||||
/>
|
||||
<InfoRow
|
||||
label={t('latestVersion')}
|
||||
value={update.latest_version}
|
||||
/>
|
||||
{update.build_time && (
|
||||
<InfoRow label='构建时间' value={update.build_time} />
|
||||
<InfoRow label={t('buildTime')} value={update.build_time} />
|
||||
)}
|
||||
<InfoRow label='运行平台' value={update.platform} />
|
||||
<InfoRow label='上游仓库' value={update.upstream_repository} />
|
||||
<InfoRow label='Release 资产' value={update.asset_name} />
|
||||
<InfoRow label={t('platform')} value={update.platform} />
|
||||
<InfoRow
|
||||
label={t('upstreamRepo')}
|
||||
value={update.upstream_repository}
|
||||
/>
|
||||
<InfoRow label={t('releaseAsset')} value={update.asset_name} />
|
||||
</div>
|
||||
|
||||
{update.release_notes && (
|
||||
<div className='flex flex-col gap-2 rounded-md border border-dashed p-3'>
|
||||
<p className='text-xs font-medium'>更新说明</p>
|
||||
<p className='text-xs font-medium'>{t('releaseNotes')}</p>
|
||||
<p className='max-h-40 overflow-y-auto whitespace-pre-wrap text-xs leading-relaxed text-muted-foreground'>
|
||||
{update.release_notes}
|
||||
</p>
|
||||
@@ -138,7 +149,7 @@ export function InfoTab() {
|
||||
) : (
|
||||
<RefreshCw data-icon='inline-start' />
|
||||
)}
|
||||
检查更新
|
||||
{t('checkUpdate')}
|
||||
</Button>
|
||||
{update.release_url && (
|
||||
<Button asChild variant='outline' size='sm'>
|
||||
@@ -148,7 +159,7 @@ export function InfoTab() {
|
||||
rel='noreferrer'
|
||||
>
|
||||
<ExternalLink data-icon='inline-start' />
|
||||
查看 Release
|
||||
{t('viewRelease')}
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
@@ -164,25 +175,24 @@ export function InfoTab() {
|
||||
{applyUpdateMutation.isPending && (
|
||||
<Spinner data-icon='inline-start' />
|
||||
)}
|
||||
立即升级
|
||||
{t('upgradeNow')}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
升级到 {update.latest_version}?
|
||||
{t('upgradeTo', { version: update.latest_version })}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
服务将下载并校验 {update.asset_name}
|
||||
,随后替换当前二进制并重启。请确保安装目录可写,且服务允许原地重启。
|
||||
{t('upgradeDesc', { asset: update.asset_name })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogCancel>{t('cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => applyUpdateMutation.mutate()}
|
||||
>
|
||||
确认升级
|
||||
{t('confirmUpgrade')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
@@ -192,10 +202,10 @@ export function InfoTab() {
|
||||
{!update.can_upgrade && (
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
{update.current_version === 'dev'
|
||||
? '开发构建没有可比较的 Release 版本,不能执行自动升级。'
|
||||
? t('devBuildNoUpgrade')
|
||||
: update.update_available
|
||||
? '当前平台暂不支持自动替换二进制,请从 Release 页面手动升级。'
|
||||
: '当前版本无需升级。'}
|
||||
? t('platformNotSupported')
|
||||
: t('noUpgradeNeeded')}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -31,22 +31,23 @@ import services from '@/lib/services';
|
||||
import type { SystemConfig } from '@/lib/services/admin';
|
||||
import { TemplatesManager } from './templates';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
const LOG_RETENTION_FIELDS = [
|
||||
{
|
||||
key: 'log_retention_days_postgres',
|
||||
label: 'PostgreSQL',
|
||||
description: '访问日志与可观测指标统一保留天数',
|
||||
descKey: 'retentionPostgresDesc',
|
||||
},
|
||||
{
|
||||
key: 'log_retention_days_sqlite',
|
||||
label: 'SQLite',
|
||||
description: 'SQLite 日志保留天数',
|
||||
descKey: 'retentionSqliteDesc',
|
||||
},
|
||||
{
|
||||
key: 'log_retention_days_clickhouse',
|
||||
label: 'ClickHouse',
|
||||
description: 'ClickHouse 日志保留天数',
|
||||
descKey: 'retentionClickhouseDesc',
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -60,6 +61,7 @@ export function OperationTab({
|
||||
systemConfigsQuery,
|
||||
}: OperationTabProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const t = useTranslations('settings.operation');
|
||||
|
||||
const uploadTypesQuery = useQuery({
|
||||
queryKey: ['admin', 'upload-types'],
|
||||
@@ -102,7 +104,7 @@ export function OperationTab({
|
||||
const raw = (values[field.key] ?? '').trim();
|
||||
const num = Number(raw);
|
||||
if (!raw || !Number.isInteger(num) || num < 1) {
|
||||
throw new Error(`${field.label}必须为大于等于 1 的整数`);
|
||||
throw new Error(t('retentionInvalid', { label: field.label }));
|
||||
}
|
||||
const config = businessConfigs[field.key];
|
||||
if (!config) {
|
||||
@@ -118,10 +120,10 @@ export function OperationTab({
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['admin', 'system-configs'],
|
||||
});
|
||||
toast.success('日志保留时间已更新');
|
||||
toast.success(t('logRetentionUpdated'));
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || '更新日志保留时间失败');
|
||||
toast.error(error.message || t('logRetentionUpdateFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -144,10 +146,10 @@ export function OperationTab({
|
||||
queryKey: ['admin', 'system-configs'],
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: ['public-config'] });
|
||||
toast.success('文件访问白名单已更新');
|
||||
toast.success(t('whitelistUpdated'));
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || '更新白名单失败');
|
||||
toast.error(error.message || t('updateWhitelistFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -180,15 +182,15 @@ export function OperationTab({
|
||||
|
||||
const availableTypes = useMemo(() => {
|
||||
const types = uploadTypesQuery.data ?? [];
|
||||
return types.map((t) => {
|
||||
let label = t;
|
||||
if (t === 'avatar') label = '头像 (avatar)';
|
||||
else if (t === 'attachment') label = '附件 (attachment)';
|
||||
else if (t === 'doc') label = '文档 (doc)';
|
||||
else if (t === 'generic') label = '通用 (generic)';
|
||||
return { value: t, label };
|
||||
return types.map((type) => {
|
||||
let label = type;
|
||||
if (type === 'avatar') label = t('typeAvatar');
|
||||
else if (type === 'attachment') label = t('typeAttachment');
|
||||
else if (type === 'doc') label = t('typeDoc');
|
||||
else if (type === 'generic') label = t('typeGeneric');
|
||||
return { value: type, label };
|
||||
});
|
||||
}, [uploadTypesQuery.data]);
|
||||
}, [t, uploadTypesQuery.data]);
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
@@ -201,10 +203,10 @@ export function OperationTab({
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className='text-base font-semibold'>
|
||||
文件访问权限控制
|
||||
{t('fileAccessControl')}
|
||||
</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
配置免登录直接访问的文件业务类型。不在白名单内的文件将要求登录鉴权。
|
||||
{t('fileAccessControlDesc')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
@@ -213,7 +215,7 @@ export function OperationTab({
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<span className='text-sm font-medium text-muted-foreground'>
|
||||
添加免鉴权类型:
|
||||
{t('addAuthFreeType')}
|
||||
</span>
|
||||
<Select
|
||||
value=''
|
||||
@@ -225,7 +227,7 @@ export function OperationTab({
|
||||
}
|
||||
>
|
||||
<SelectTrigger className='w-[200px]' size='sm'>
|
||||
<SelectValue placeholder='选择业务类型...' />
|
||||
<SelectValue placeholder={t('selectBusinessType')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableTypes
|
||||
@@ -239,7 +241,7 @@ export function OperationTab({
|
||||
(t) => !currentWhitelist.includes(t.value),
|
||||
).length === 0 && (
|
||||
<div className='text-xs text-muted-foreground p-2 text-center'>
|
||||
所有类型已添加
|
||||
{t('allTypesAdded')}
|
||||
</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
@@ -251,7 +253,7 @@ export function OperationTab({
|
||||
<div className='flex items-center gap-2'>
|
||||
<ShieldAlert className='size-4 text-primary' />
|
||||
<span className='font-medium text-sm text-foreground'>
|
||||
当前免鉴权列表
|
||||
{t('currentAuthFreeList')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -281,7 +283,7 @@ export function OperationTab({
|
||||
</div>
|
||||
) : (
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
白名单已空,所有类型文件的访问都将需要登录。
|
||||
{t('whitelistEmpty')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -298,10 +300,10 @@ export function OperationTab({
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className='text-base font-semibold'>
|
||||
日志保留时间
|
||||
{t('logRetentionTitle')}
|
||||
</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
配置各日志数据库的日志保留天数,切换日志数据库后自动按对应配置清理过期日志。
|
||||
{t('logRetentionDesc')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
@@ -330,11 +332,11 @@ export function OperationTab({
|
||||
}
|
||||
/>
|
||||
<span className='text-xs text-muted-foreground whitespace-nowrap'>
|
||||
天
|
||||
{t('days')}
|
||||
</span>
|
||||
</div>
|
||||
<p className='text-[10px] text-muted-foreground'>
|
||||
{field.description}
|
||||
{t(field.descKey)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
@@ -354,7 +356,7 @@ export function OperationTab({
|
||||
) : (
|
||||
<Save className='size-3' />
|
||||
)}
|
||||
保存
|
||||
{t('save')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -35,166 +35,167 @@ import { Switch } from '@/components/ui/switch';
|
||||
import services from '@/lib/services';
|
||||
import type { SystemConfig } from '@/lib/services/admin';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
interface MenuItem {
|
||||
path: string;
|
||||
label: string;
|
||||
description: string;
|
||||
labelKey: string;
|
||||
descKey: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
interface MenuGroup {
|
||||
name: string;
|
||||
nameKey: string;
|
||||
items: MenuItem[];
|
||||
}
|
||||
|
||||
const MENU_GROUPS: MenuGroup[] = [
|
||||
{
|
||||
name: '业务菜单',
|
||||
nameKey: 'groupBusiness',
|
||||
items: [
|
||||
{
|
||||
path: '/',
|
||||
label: '数据看板',
|
||||
description: '查看边缘节点请求、访客与流量等多维度图表看板',
|
||||
labelKey: 'dashboard',
|
||||
descKey: 'descDashboard',
|
||||
icon: LayoutDashboard,
|
||||
readOnly: true,
|
||||
},
|
||||
{
|
||||
path: '/nodes',
|
||||
label: '节点管理',
|
||||
description: '注册与管理反向代理边缘节点服务状态',
|
||||
labelKey: 'nodes',
|
||||
descKey: 'descNodes',
|
||||
icon: Server,
|
||||
},
|
||||
{
|
||||
path: '/proxy-routes',
|
||||
label: '规则管理',
|
||||
description: '配置和发布反代路由规则、负载均衡与源站绑定',
|
||||
labelKey: 'proxyRoutes',
|
||||
descKey: 'descProxyRoutes',
|
||||
icon: Route,
|
||||
},
|
||||
{
|
||||
path: '/websites',
|
||||
label: '域名列表',
|
||||
description: '配置站点根域名并查看关联网站详情',
|
||||
labelKey: 'websites',
|
||||
descKey: 'descWebsites',
|
||||
icon: Globe,
|
||||
},
|
||||
{
|
||||
path: '/certificates',
|
||||
label: 'TLS证书',
|
||||
description: '管理和下发网站 SSL/TLS 证书',
|
||||
labelKey: 'certificates',
|
||||
descKey: 'descCertificates',
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
{
|
||||
path: '/dns-accounts',
|
||||
label: 'DNS账号',
|
||||
description: '配置 Cloudflare/Alidns 等 DNS 服务商 API 凭证',
|
||||
labelKey: 'dnsAccounts',
|
||||
descKey: 'descDnsAccounts',
|
||||
icon: Settings,
|
||||
},
|
||||
{
|
||||
path: '/origins',
|
||||
label: '源站地址',
|
||||
description: '集中维护和管理后端业务源站服务器组',
|
||||
labelKey: 'origins',
|
||||
descKey: 'descOrigins',
|
||||
icon: Home,
|
||||
},
|
||||
{
|
||||
path: '/waf',
|
||||
label: 'WAF 管理',
|
||||
description: '配置 Web 应用防火墙自定义规则与频率限制',
|
||||
labelKey: 'waf',
|
||||
descKey: 'descWaf',
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
{
|
||||
path: '/ip-groups',
|
||||
label: 'IP 组',
|
||||
description: '管理黑白名单 IP 集合,用于 WAF 规则过滤',
|
||||
labelKey: 'ipGroups',
|
||||
descKey: 'descIpGroups',
|
||||
icon: Layers,
|
||||
},
|
||||
{
|
||||
path: '/pages',
|
||||
label: 'Pages 静态站',
|
||||
description: '托管和发布静态网页项目,支持自动关联域名',
|
||||
labelKey: 'pages',
|
||||
descKey: 'descPages',
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
path: '/config-versions',
|
||||
label: '版本发布',
|
||||
description: '查看规则快照版本并进行配置热更新分发与回滚',
|
||||
labelKey: 'configVersions',
|
||||
descKey: 'descConfigVersions',
|
||||
icon: GitBranch,
|
||||
},
|
||||
{
|
||||
path: '/access-logs',
|
||||
label: '访问日志',
|
||||
description: '多维度实时浏览边缘节点请求访问明细',
|
||||
labelKey: 'accessLogs',
|
||||
descKey: 'descAccessLogs',
|
||||
icon: ScrollText,
|
||||
},
|
||||
{
|
||||
path: '/performance',
|
||||
label: '性能调优',
|
||||
description: '配置缓存、图片压缩等页面性能加速策略',
|
||||
labelKey: 'performance',
|
||||
descKey: 'descPerformance',
|
||||
icon: Gauge,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '管理菜单',
|
||||
nameKey: 'groupAdmin',
|
||||
items: [
|
||||
{
|
||||
path: '/admin/users',
|
||||
label: '用户管理',
|
||||
description: '查看和管理系统用户列表及其状态',
|
||||
labelKey: 'users',
|
||||
descKey: 'descUsers',
|
||||
icon: UserRound,
|
||||
},
|
||||
{
|
||||
path: '/admin/tasks',
|
||||
label: '任务管理',
|
||||
description: '查看和调度系统异步及定时任务',
|
||||
labelKey: 'tasks',
|
||||
descKey: 'descTasks',
|
||||
icon: Layers,
|
||||
},
|
||||
{
|
||||
path: '/admin/files',
|
||||
label: '存储管理',
|
||||
description: '管理系统文件存储和清理无用文件',
|
||||
labelKey: 'storage',
|
||||
descKey: 'descStorage',
|
||||
icon: FolderOpen,
|
||||
},
|
||||
{
|
||||
path: '/admin/database',
|
||||
label: '数据管理',
|
||||
description: '监控物理数据库状态、分页浏览表数据及交互式 SQL 查询',
|
||||
labelKey: 'database',
|
||||
descKey: 'descDatabase',
|
||||
icon: Database,
|
||||
},
|
||||
{
|
||||
path: '/admin/push',
|
||||
label: '通知推送',
|
||||
description: '配置和发送系统通知及推送消息',
|
||||
labelKey: 'push',
|
||||
descKey: 'descPush',
|
||||
icon: Bell,
|
||||
},
|
||||
{
|
||||
path: '/admin/logs',
|
||||
label: '系统日志',
|
||||
description: '查看异步任务执行日志和系统运行情况',
|
||||
labelKey: 'logs',
|
||||
descKey: 'descLogs',
|
||||
icon: Terminal,
|
||||
},
|
||||
{
|
||||
path: '/admin/system',
|
||||
label: '系统配置',
|
||||
description: '管理和维护系统基础键值对配置',
|
||||
labelKey: 'system',
|
||||
descKey: 'descSystem',
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
{
|
||||
path: '/admin/settings',
|
||||
label: '系统设置',
|
||||
description: '配置安全验证、邮箱服务及目录显示',
|
||||
labelKey: 'adminSettings',
|
||||
descKey: 'descAdminSettings',
|
||||
icon: Settings,
|
||||
readOnly: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '文档菜单',
|
||||
nameKey: 'groupDocs',
|
||||
items: [
|
||||
{
|
||||
path: 'https://open-flare.pages.dev/',
|
||||
label: '使用文档',
|
||||
description: '面向开发与运营的部署使用指南',
|
||||
labelKey: 'usageDocs',
|
||||
descKey: 'descUsageDocs',
|
||||
icon: FileText,
|
||||
},
|
||||
],
|
||||
@@ -207,6 +208,8 @@ interface OtherTabProps {
|
||||
|
||||
export function OtherTab({ configs }: OtherTabProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const t = useTranslations('settings.other');
|
||||
const tNav = useTranslations('layout.nav');
|
||||
|
||||
const menuDisplayConfig = useMemo(() => {
|
||||
const raw = configs['menu_display_config']?.value;
|
||||
@@ -243,10 +246,10 @@ export function OtherTab({ configs }: OtherTabProps) {
|
||||
queryKey: ['admin', 'system-configs'],
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: ['public-config'] });
|
||||
toast.success('目录显示配置已更新');
|
||||
toast.success(t('menuDisplayUpdated'));
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || '更新配置失败');
|
||||
toast.error(error.message || t('updateConfigFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -263,20 +266,20 @@ export function OtherTab({ configs }: OtherTabProps) {
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className='text-base font-semibold'>
|
||||
目录显示管理
|
||||
{t('menuDisplayManagement')}
|
||||
</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
配置系统左侧菜单的显示与隐藏状态,适用于所有登录用户。
|
||||
{t('menuDisplayManagementDesc')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className='pt-6 space-y-6'>
|
||||
{MENU_GROUPS.map((group) => (
|
||||
<div key={group.name} className='space-y-3'>
|
||||
<div key={group.nameKey} className='space-y-3'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-xs font-semibold text-muted-foreground tracking-wider uppercase'>
|
||||
{group.name}
|
||||
{t(group.nameKey)}
|
||||
</span>
|
||||
<div className='h-px bg-border/40 flex-1' />
|
||||
</div>
|
||||
@@ -297,16 +300,16 @@ export function OtherTab({ configs }: OtherTabProps) {
|
||||
<Icon className='size-4 text-primary shrink-0' />
|
||||
)}
|
||||
<span className='font-medium text-sm text-foreground truncate'>
|
||||
{item.label}
|
||||
{tNav(item.labelKey)}
|
||||
</span>
|
||||
{isReadOnly && (
|
||||
<span className='text-[9px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground border shrink-0'>
|
||||
不可隐藏
|
||||
{t('notHideable')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className='text-xs text-muted-foreground leading-normal line-clamp-2'>
|
||||
{item.description}
|
||||
{t(item.descKey)}
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex items-center'>
|
||||
@@ -330,8 +333,10 @@ export function OtherTab({ configs }: OtherTabProps) {
|
||||
<div className='p-3.5 rounded-lg border border-dashed border-primary/20 bg-primary/5 flex items-start gap-2.5'>
|
||||
<Info className='size-4 text-primary shrink-0 mt-0.5' />
|
||||
<div className='text-xs text-muted-foreground leading-relaxed'>
|
||||
<span className='font-semibold text-foreground'>安全提示:</span>
|
||||
为了防止管理员在关闭“系统设置”后导致无法重新访问此配置页,系统限制了“系统设置”的关闭权限。其它所有菜单均可自由开关,隐藏后对应的分组标题在为空时也会自动隐藏。
|
||||
<span className='font-semibold text-foreground'>
|
||||
{t('securityTipTitle')}
|
||||
</span>
|
||||
{t('securityTipDesc')}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -64,10 +64,12 @@ import {
|
||||
ImageCropReset,
|
||||
} from '@/components/ui/image-crop';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export function ProfileMain() {
|
||||
const { user, loading, refetch } = useUser();
|
||||
const controls = useAnimation();
|
||||
const t = useTranslations('settings.profile');
|
||||
const isAnimatingRef = React.useRef(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -109,25 +111,25 @@ export function ProfileMain() {
|
||||
const changePasswordMutation = useMutation({
|
||||
mutationFn: (req: ChangePasswordRequest) => AuthService.changePassword(req),
|
||||
onSuccess: () => {
|
||||
toast.success('密码修改成功');
|
||||
toast.success(t('passwordChangeSuccess'));
|
||||
setOldPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
void refetch();
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || '修改密码失败,请重试');
|
||||
toast.error(error.message || t('passwordChangeFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
const handlePasswordChange = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (newPassword !== confirmPassword) {
|
||||
toast.error('两次输入的新密码不一致');
|
||||
toast.error(t('passwordMismatch'));
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
toast.error('新密码长度不能少于 8 位');
|
||||
toast.error(t('passwordTooShort'));
|
||||
return;
|
||||
}
|
||||
changePasswordMutation.mutate({
|
||||
@@ -139,12 +141,12 @@ export function ProfileMain() {
|
||||
const updateProfileMutation = useMutation({
|
||||
mutationFn: (req: UpdateProfileRequest) => AuthService.updateProfile(req),
|
||||
onSuccess: () => {
|
||||
toast.success('个人信息修改成功');
|
||||
toast.success(t('profileUpdateSuccess'));
|
||||
setIsEditDialogOpen(false);
|
||||
void refetch();
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || '修改个人信息失败,请重试');
|
||||
toast.error(error.message || t('profileUpdateFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -185,7 +187,7 @@ export function ProfileMain() {
|
||||
window.location.href = authorize_url;
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || '绑定认证源失败');
|
||||
toast.error(error.message || t('bindSourceFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -221,22 +223,22 @@ export function ProfileMain() {
|
||||
setAvatarUrl(`/f/${res.id}`);
|
||||
setIsCropDialogOpen(false);
|
||||
setCropFile(null);
|
||||
toast.success('头像上传成功,点击保存以生效');
|
||||
toast.success(t('avatarUploadSuccess'));
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message || '上传头像失败');
|
||||
toast.error((err as Error).message || t('avatarUploadFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const getGenderLabel = (g?: string) => {
|
||||
switch (g) {
|
||||
case 'male':
|
||||
return '男';
|
||||
return t('genderMale');
|
||||
case 'female':
|
||||
return '女';
|
||||
return t('genderFemale');
|
||||
case 'secret':
|
||||
return '保密';
|
||||
return t('genderSecret');
|
||||
default:
|
||||
return g || '保密';
|
||||
return g || t('genderSecret');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -244,7 +246,7 @@ export function ProfileMain() {
|
||||
return (
|
||||
<div className='py-6 space-y-4 max-w-4xl mx-auto'>
|
||||
<div className='border-b border-border pb-4'>
|
||||
<h1 className='text-2xl font-semibold'>个人资料</h1>
|
||||
<h1 className='text-2xl font-semibold'>{t('title')}</h1>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -253,7 +255,7 @@ export function ProfileMain() {
|
||||
if (!user) {
|
||||
return (
|
||||
<div className='py-6 space-y-6 max-w-4xl mx-auto'>
|
||||
<div className='text-sm text-muted-foreground'>未找到用户信息</div>
|
||||
<div className='text-sm text-muted-foreground'>{t('userNotFound')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -266,14 +268,14 @@ export function ProfileMain() {
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href='/settings' className='text-base text-primary'>
|
||||
设置
|
||||
{t('breadcrumbSettings')}
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage className='text-base font-semibold'>
|
||||
个人资料
|
||||
{t('breadcrumbProfile')}
|
||||
</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
@@ -284,9 +286,11 @@ export function ProfileMain() {
|
||||
<div className='space-y-6 bg-card border border-dashed rounded-lg p-6'>
|
||||
<div className='border-b pb-4 flex justify-between items-center'>
|
||||
<div>
|
||||
<h2 className='text-lg font-semibold tracking-tight'>基本资料</h2>
|
||||
<h2 className='text-lg font-semibold tracking-tight'>
|
||||
{t('basicInfo')}
|
||||
</h2>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
您的个人账户基本信息
|
||||
{t('basicInfoDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -296,7 +300,7 @@ export function ProfileMain() {
|
||||
onClick={() => setIsEditDialogOpen(true)}
|
||||
>
|
||||
<Edit className='size-3.5 mr-1.5' />
|
||||
编辑资料
|
||||
{t('editProfile')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -323,7 +327,7 @@ export function ProfileMain() {
|
||||
<div className='space-y-1'>
|
||||
<div className='text-xs text-muted-foreground flex items-center gap-1.5'>
|
||||
<UserIcon className='size-3 text-muted-foreground/70' />
|
||||
账户
|
||||
{t('account')}
|
||||
</div>
|
||||
<div className='text-sm font-semibold'>@{user.username}</div>
|
||||
</div>
|
||||
@@ -331,17 +335,17 @@ export function ProfileMain() {
|
||||
<div className='space-y-1'>
|
||||
<div className='text-xs text-muted-foreground flex items-center gap-1.5'>
|
||||
<UserIcon className='size-3 text-muted-foreground/70' />
|
||||
昵称
|
||||
{t('nickname')}
|
||||
</div>
|
||||
<div className='text-sm font-semibold'>
|
||||
{user.nickname || '未设置'}
|
||||
{user.nickname || t('notSet')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-1'>
|
||||
<div className='text-xs text-muted-foreground flex items-center gap-1.5'>
|
||||
<Info className='size-3 text-muted-foreground/70' />
|
||||
用户ID (UID)
|
||||
{t('uid')}
|
||||
</div>
|
||||
<div className='text-sm font-mono font-semibold'>{user.id}</div>
|
||||
</div>
|
||||
@@ -349,27 +353,27 @@ export function ProfileMain() {
|
||||
<div className='space-y-1'>
|
||||
<div className='text-xs text-muted-foreground flex items-center gap-1.5'>
|
||||
<Mail className='size-3 text-muted-foreground/70' />
|
||||
邮箱
|
||||
{t('email')}
|
||||
</div>
|
||||
<div className='text-sm font-semibold truncate max-w-[220px]'>
|
||||
{user.email || '未绑定'}
|
||||
{user.email || t('notBound')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-1'>
|
||||
<div className='text-xs text-muted-foreground flex items-center gap-1.5'>
|
||||
<Phone className='size-3 text-muted-foreground/70' />
|
||||
手机号码
|
||||
{t('phone')}
|
||||
</div>
|
||||
<div className='text-sm font-semibold'>
|
||||
{user.phone || '未设置'}
|
||||
{user.phone || t('notSet')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-1'>
|
||||
<div className='text-xs text-muted-foreground flex items-center gap-1.5'>
|
||||
<UserIcon className='size-3 text-muted-foreground/70' />
|
||||
性别
|
||||
{t('gender')}
|
||||
</div>
|
||||
<div className='text-sm font-semibold'>
|
||||
{getGenderLabel(user.gender)}
|
||||
@@ -379,7 +383,7 @@ export function ProfileMain() {
|
||||
<div className='space-y-1'>
|
||||
<div className='text-xs text-muted-foreground flex items-center gap-1.5'>
|
||||
<Globe className='size-3 text-muted-foreground/70' />
|
||||
个人网站
|
||||
{t('website')}
|
||||
</div>
|
||||
<div className='text-sm font-semibold truncate max-w-[220px]'>
|
||||
{user.website ? (
|
||||
@@ -396,7 +400,7 @@ export function ProfileMain() {
|
||||
{user.website}
|
||||
</a>
|
||||
) : (
|
||||
'未设置'
|
||||
t('notSet')
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -404,25 +408,26 @@ export function ProfileMain() {
|
||||
<div className='space-y-1'>
|
||||
<div className='text-xs text-muted-foreground flex items-center gap-1.5'>
|
||||
<MapPin className='size-3 text-muted-foreground/70' />
|
||||
所在地
|
||||
{t('location')}
|
||||
</div>
|
||||
<div className='text-sm font-semibold'>
|
||||
{user.location || '未设置'}
|
||||
{user.location || t('notSet')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-1'>
|
||||
<div className='text-xs text-muted-foreground flex items-center gap-1.5'>
|
||||
<Shield className='size-3 text-muted-foreground/70' />
|
||||
管理员身份
|
||||
{t('isAdmin')}
|
||||
</div>
|
||||
<div className='text-sm font-semibold flex items-center gap-1'>
|
||||
{user.is_admin ? (
|
||||
<span className='text-rose-600 flex items-center gap-1'>
|
||||
<Shield className='size-3.5' />是
|
||||
<Shield className='size-3.5' />
|
||||
{t('yes')}
|
||||
</span>
|
||||
) : (
|
||||
<span>否</span>
|
||||
<span>{t('no')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -433,10 +438,10 @@ export function ProfileMain() {
|
||||
<div className='space-y-1'>
|
||||
<div className='text-xs text-muted-foreground flex items-center gap-1.5'>
|
||||
<BookOpen className='size-3 text-muted-foreground/70' />
|
||||
个人简介
|
||||
{t('bio')}
|
||||
</div>
|
||||
<div className='text-sm text-foreground/80 leading-relaxed max-w-2xl bg-muted/20 border border-dashed rounded-lg p-3'>
|
||||
{user.bio || '这个人很懒,什么都没有留下。'}
|
||||
{user.bio || t('bioDefault')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -454,10 +459,10 @@ export function ProfileMain() {
|
||||
</div>
|
||||
<div>
|
||||
<h2 className='text-base font-semibold tracking-tight'>
|
||||
修改密码
|
||||
{t('changePassword')}
|
||||
</h2>
|
||||
<p className='text-[11px] text-muted-foreground'>
|
||||
更改您的账号密码以确保安全。密码长度不能少于 8 位。
|
||||
{t('changePasswordDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -466,9 +471,9 @@ export function ProfileMain() {
|
||||
<div className='mt-4 rounded-lg border border-amber-500/30 bg-amber-500/5 px-3.5 py-2.5 text-xs text-amber-500 flex items-start gap-2.5'>
|
||||
<Info className='size-4 shrink-0 mt-0.5' />
|
||||
<div>
|
||||
<p className='font-semibold'>密码风险提示</p>
|
||||
<p className='font-semibold'>{t('passwordRiskWarning')}</p>
|
||||
<p className='mt-0.5 text-amber-500/80 leading-relaxed font-normal'>
|
||||
为了账号安全,您必须修改初始密码。
|
||||
{t('mustChangePassword')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -477,11 +482,11 @@ export function ProfileMain() {
|
||||
<form onSubmit={handlePasswordChange} className='space-y-3 pt-4'>
|
||||
<div className='space-y-1.5'>
|
||||
<label className='text-xs font-medium text-muted-foreground'>
|
||||
当前密码
|
||||
{t('currentPassword')}
|
||||
</label>
|
||||
<Input
|
||||
type='password'
|
||||
placeholder='请输入当前密码'
|
||||
placeholder={t('currentPasswordPlaceholder')}
|
||||
value={oldPassword}
|
||||
onChange={(e) => setOldPassword(e.target.value)}
|
||||
className='h-8 text-xs rounded-lg'
|
||||
@@ -490,11 +495,11 @@ export function ProfileMain() {
|
||||
</div>
|
||||
<div className='space-y-1.5'>
|
||||
<label className='text-xs font-medium text-muted-foreground'>
|
||||
新密码
|
||||
{t('newPassword')}
|
||||
</label>
|
||||
<Input
|
||||
type='password'
|
||||
placeholder='新密码(至少 8 位)'
|
||||
placeholder={t('newPasswordPlaceholder')}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
className='h-8 text-xs rounded-lg'
|
||||
@@ -503,11 +508,11 @@ export function ProfileMain() {
|
||||
</div>
|
||||
<div className='space-y-1.5'>
|
||||
<label className='text-xs font-medium text-muted-foreground'>
|
||||
确认新密码
|
||||
{t('confirmNewPassword')}
|
||||
</label>
|
||||
<Input
|
||||
type='password'
|
||||
placeholder='确认新密码'
|
||||
placeholder={t('confirmNewPasswordPlaceholder')}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className='h-8 text-xs rounded-lg'
|
||||
@@ -522,7 +527,9 @@ export function ProfileMain() {
|
||||
className='w-full sm:w-auto h-8 text-xs'
|
||||
disabled={changePasswordMutation.isPending}
|
||||
>
|
||||
{changePasswordMutation.isPending ? '提交中...' : '确认修改'}
|
||||
{changePasswordMutation.isPending
|
||||
? t('submitting')
|
||||
: t('confirmChange')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -538,10 +545,10 @@ export function ProfileMain() {
|
||||
</div>
|
||||
<div>
|
||||
<h2 className='text-base font-semibold tracking-tight'>
|
||||
第三方账号绑定
|
||||
{t('thirdPartyBinding')}
|
||||
</h2>
|
||||
<p className='text-[11px] text-muted-foreground'>
|
||||
管理并关联您的第三方授权账户,便于快捷登录与验证
|
||||
{t('thirdPartyBindingDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -549,7 +556,7 @@ export function ProfileMain() {
|
||||
{/* 已绑定账号列表 */}
|
||||
<div className='space-y-2 pt-4'>
|
||||
<h3 className='text-[11px] font-semibold text-muted-foreground uppercase tracking-wider'>
|
||||
已绑定账号
|
||||
{t('boundAccounts')}
|
||||
</h3>
|
||||
{externalAccountBindingsQuery.isPending ? (
|
||||
<div className='flex items-center justify-center py-4'>
|
||||
@@ -569,7 +576,7 @@ export function ProfileMain() {
|
||||
<span className='text-[10px] text-muted-foreground font-mono block truncate max-w-[150px]'>
|
||||
{binding.external_username ||
|
||||
binding.email ||
|
||||
'未提供账号标识'}
|
||||
t('noAccountIdentifier')}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
@@ -584,11 +591,11 @@ export function ProfileMain() {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['auth', 'external-accounts'],
|
||||
});
|
||||
toast.success('绑定已移除');
|
||||
toast.success(t('bindingRemoved'));
|
||||
}}
|
||||
>
|
||||
<Unlink className='size-3 mr-1' />
|
||||
解绑
|
||||
{t('unbind')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
@@ -596,7 +603,7 @@ export function ProfileMain() {
|
||||
) : (
|
||||
<div className='rounded-xl border border-dashed border-border/50 px-4 py-6 text-center text-[11px] text-muted-foreground bg-muted/5 flex flex-col items-center justify-center'>
|
||||
<Link2 className='size-5 text-muted-foreground/30 mb-1' />
|
||||
暂无绑定的第三方账号
|
||||
{t('noBoundAccounts')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -606,7 +613,7 @@ export function ProfileMain() {
|
||||
{/* 绑定新账号列表 */}
|
||||
<div className='space-y-2'>
|
||||
<h3 className='text-[11px] font-semibold text-muted-foreground uppercase tracking-wider'>
|
||||
绑定新账号
|
||||
{t('bindNewAccount')}
|
||||
</h3>
|
||||
{publicAuthSourcesQuery.isPending ? (
|
||||
<div className='flex items-center justify-center py-4'>
|
||||
@@ -626,7 +633,11 @@ export function ProfileMain() {
|
||||
>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<Link2 className='size-3 text-muted-foreground group-hover:text-primary' />
|
||||
<span>绑定 {source.display_name || source.name}</span>
|
||||
<span>
|
||||
{t('bindSource', {
|
||||
name: source.display_name || source.name,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<ArrowRight className='size-3 opacity-0 -translate-x-1 group-hover:opacity-100 group-hover:translate-x-0 transition-all text-primary' />
|
||||
</Button>
|
||||
@@ -634,7 +645,7 @@ export function ProfileMain() {
|
||||
</div>
|
||||
) : (
|
||||
<div className='text-[11px] text-muted-foreground text-center py-2'>
|
||||
暂无可用第三方认证源
|
||||
{t('noAvailableSources')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -646,10 +657,8 @@ export function ProfileMain() {
|
||||
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||
<DialogContent className='max-w-md'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑个人资料</DialogTitle>
|
||||
<DialogDescription>
|
||||
修改您的基本信息,保存后立即生效。
|
||||
</DialogDescription>
|
||||
<DialogTitle>{t('editProfileTitle')}</DialogTitle>
|
||||
<DialogDescription>{t('editProfileDesc')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleProfileSubmit} className='space-y-4'>
|
||||
@@ -670,7 +679,7 @@ export function ProfileMain() {
|
||||
</div>
|
||||
</div>
|
||||
<span className='text-[10px] text-muted-foreground'>
|
||||
点击上传/修改头像
|
||||
{t('uploadAvatar')}
|
||||
</span>
|
||||
<input
|
||||
type='file'
|
||||
@@ -684,21 +693,21 @@ export function ProfileMain() {
|
||||
<div className='grid grid-cols-2 gap-3'>
|
||||
<div className='space-y-1'>
|
||||
<Label htmlFor='nickname' className='text-xs'>
|
||||
昵称
|
||||
{t('nickname')}
|
||||
</Label>
|
||||
<Input
|
||||
id='nickname'
|
||||
value={nickname}
|
||||
onChange={(e) => setNickname(e.target.value)}
|
||||
className='h-8 text-xs rounded-lg'
|
||||
placeholder='请输入您的昵称'
|
||||
placeholder={t('nicknamePlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-1'>
|
||||
<Label htmlFor='email' className='text-xs'>
|
||||
邮箱
|
||||
{t('email')}
|
||||
</Label>
|
||||
<Input
|
||||
id='email'
|
||||
@@ -706,26 +715,26 @@ export function ProfileMain() {
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className='h-8 text-xs rounded-lg'
|
||||
placeholder='请输入您的邮箱'
|
||||
placeholder={t('emailPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-1'>
|
||||
<Label htmlFor='phone' className='text-xs'>
|
||||
手机号码
|
||||
{t('phone')}
|
||||
</Label>
|
||||
<Input
|
||||
id='phone'
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
className='h-8 text-xs rounded-lg'
|
||||
placeholder='请输入手机号'
|
||||
placeholder={t('phonePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-1'>
|
||||
<Label htmlFor='gender' className='text-xs'>
|
||||
性别
|
||||
{t('gender')}
|
||||
</Label>
|
||||
<Select value={gender} onValueChange={setGender}>
|
||||
<SelectTrigger
|
||||
@@ -733,19 +742,19 @@ export function ProfileMain() {
|
||||
size='sm'
|
||||
className='w-full text-xs h-8'
|
||||
>
|
||||
<SelectValue placeholder='选择性别' />
|
||||
<SelectValue placeholder={t('genderPlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='male'>男</SelectItem>
|
||||
<SelectItem value='female'>女</SelectItem>
|
||||
<SelectItem value='secret'>保密</SelectItem>
|
||||
<SelectItem value='male'>{t('genderMale')}</SelectItem>
|
||||
<SelectItem value='female'>{t('genderFemale')}</SelectItem>
|
||||
<SelectItem value='secret'>{t('genderSecret')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='space-y-1'>
|
||||
<Label htmlFor='website' className='text-xs'>
|
||||
个人网站
|
||||
{t('website')}
|
||||
</Label>
|
||||
<Input
|
||||
id='website'
|
||||
@@ -758,28 +767,28 @@ export function ProfileMain() {
|
||||
|
||||
<div className='space-y-1'>
|
||||
<Label htmlFor='location' className='text-xs'>
|
||||
所在地
|
||||
{t('location')}
|
||||
</Label>
|
||||
<Input
|
||||
id='location'
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
className='h-8 text-xs rounded-lg'
|
||||
placeholder='如:北京'
|
||||
placeholder={t('locationPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-1'>
|
||||
<Label htmlFor='bio' className='text-xs'>
|
||||
个人简介
|
||||
{t('bio')}
|
||||
</Label>
|
||||
<Textarea
|
||||
id='bio'
|
||||
value={bio}
|
||||
onChange={(e) => setBio(e.target.value)}
|
||||
className='text-xs min-h-[60px] rounded-lg'
|
||||
placeholder='介绍一下自己吧...'
|
||||
placeholder={t('bioPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -791,7 +800,7 @@ export function ProfileMain() {
|
||||
className='h-8 text-xs'
|
||||
onClick={() => setIsEditDialogOpen(false)}
|
||||
>
|
||||
取消
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
@@ -799,7 +808,9 @@ export function ProfileMain() {
|
||||
className='h-8 text-xs'
|
||||
disabled={updateProfileMutation.isPending}
|
||||
>
|
||||
{updateProfileMutation.isPending ? '保存中...' : '保存修改'}
|
||||
{updateProfileMutation.isPending
|
||||
? t('saving')
|
||||
: t('saveChanges')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
@@ -810,10 +821,8 @@ export function ProfileMain() {
|
||||
<Dialog open={isCropDialogOpen} onOpenChange={setIsCropDialogOpen}>
|
||||
<DialogContent className='max-w-md'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>裁剪头像</DialogTitle>
|
||||
<DialogDescription>
|
||||
调整头像位置和大小,使其处于居中圆形区域内。
|
||||
</DialogDescription>
|
||||
<DialogTitle>{t('cropAvatar')}</DialogTitle>
|
||||
<DialogDescription>{t('cropAvatarDesc')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{cropFile && (
|
||||
@@ -839,7 +848,7 @@ export function ProfileMain() {
|
||||
setCropFile(null);
|
||||
}}
|
||||
>
|
||||
取消
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -54,43 +54,43 @@ import { AuthSourceModal } from '@/components/common/settings/auth-source-modal'
|
||||
import services from '@/lib/services';
|
||||
import type { AuthSource, SystemConfig } from '@/lib/services/admin';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
const SECURITY_KEYS = [
|
||||
{
|
||||
key: 'password_login_enabled',
|
||||
title: '允许密码登录',
|
||||
description: '关闭后仅保留第三方 OIDC 认证源进行系统登录。',
|
||||
titleKey: 'passwordLoginEnabled',
|
||||
descKey: 'passwordLoginEnabledDesc',
|
||||
icon: Lock,
|
||||
},
|
||||
{
|
||||
key: 'registration_enabled',
|
||||
title: '允许注册',
|
||||
description: '关闭后系统将禁止新用户进行自主账号注册。',
|
||||
titleKey: 'registrationEnabled',
|
||||
descKey: 'registrationEnabledDesc',
|
||||
icon: UserPlus,
|
||||
},
|
||||
{
|
||||
key: 'password_register_enabled',
|
||||
title: '允许密码注册',
|
||||
description: '关闭后只能通过管理员创建或第三方认证关联建号。',
|
||||
titleKey: 'passwordRegisterEnabled',
|
||||
descKey: 'passwordRegisterEnabledDesc',
|
||||
icon: Fingerprint,
|
||||
},
|
||||
{
|
||||
key: 'oidc_login_enabled',
|
||||
title: '允许 OIDC 登录',
|
||||
description: '关闭后所有的第三方 OIDC 认证登录入口都会被隐藏。',
|
||||
titleKey: 'oidcLoginEnabled',
|
||||
descKey: 'oidcLoginEnabledDesc',
|
||||
icon: Globe,
|
||||
},
|
||||
{
|
||||
key: 'email_login_verification_enabled',
|
||||
title: '邮箱登录验证',
|
||||
description:
|
||||
'开启后,使用账号密码登录时需要通过邮箱接收并验证 6 位验证码。',
|
||||
titleKey: 'emailLoginVerification',
|
||||
descKey: 'emailLoginVerificationDesc',
|
||||
icon: Mail,
|
||||
},
|
||||
{
|
||||
key: 'email_register_verification_enabled',
|
||||
title: '邮箱注册验证',
|
||||
description: '开启后,用户注册账号时需要通过邮箱接收并验证 6 位验证码。',
|
||||
titleKey: 'emailRegisterVerification',
|
||||
descKey: 'emailRegisterVerificationDesc',
|
||||
icon: Mail,
|
||||
},
|
||||
] as const;
|
||||
@@ -102,6 +102,8 @@ interface SecurityTabProps {
|
||||
|
||||
export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const t = useTranslations('settings.security');
|
||||
const tCommon = useTranslations('common');
|
||||
const [authSourceModalOpen, setAuthSourceModalOpen] = useState(false);
|
||||
const [selectedSource, setSelectedSource] = useState<AuthSource | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<AuthSource | null>(null);
|
||||
@@ -166,10 +168,10 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['admin', 'system-configs'],
|
||||
});
|
||||
toast.success('登录状态保持时间已更新');
|
||||
toast.success(t('loginSessionTTLUpdated'));
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || '更新配置失败');
|
||||
toast.error(error.message || t('updateConfigFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -183,7 +185,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
const handleCustomBlur = () => {
|
||||
const parsed = parseInt(customHours, 10);
|
||||
if (isNaN(parsed) || parsed <= 0) {
|
||||
toast.error('请输入有效的过期小时数(正整数)');
|
||||
toast.error(t('invalidHours'));
|
||||
// 重置为原本的值
|
||||
const originalVal = configs['login_session_ttl_hours']?.value || '0';
|
||||
setCustomHours(
|
||||
@@ -213,10 +215,10 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
queryKey: ['admin', 'system-configs'],
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: ['public-config'] });
|
||||
toast.success('系统安全配置已更新');
|
||||
toast.success(t('securityConfigUpdated'));
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || '更新配置失败');
|
||||
toast.error(error.message || t('updateConfigFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -231,10 +233,10 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['auth', 'public-sources'],
|
||||
});
|
||||
toast.success('认证源状态已更新');
|
||||
toast.success(t('authSourceStatusUpdated'));
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || '切换状态失败');
|
||||
toast.error(error.message || t('toggleStatusFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -248,10 +250,10 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['auth', 'public-sources'],
|
||||
});
|
||||
toast.success('认证源已删除');
|
||||
toast.success(t('authSourceDeleted'));
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || '删除认证源失败');
|
||||
toast.error(error.message || t('deleteAuthSourceFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -282,10 +284,10 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['admin', 'system-configs'],
|
||||
});
|
||||
toast.success('人机验证配置已成功保存');
|
||||
toast.success(t('captchaConfigSaved'));
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || '保存配置失败');
|
||||
toast.error(error.message || t('saveConfigFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -305,10 +307,10 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className='text-base font-semibold'>
|
||||
系统登录/注册设置
|
||||
{t('loginRegistrationSettings')}
|
||||
</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
配置系统的登录限制与用户自主注册权限
|
||||
{t('loginRegistrationSettingsDesc')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
@@ -328,11 +330,11 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
<div className='flex items-center gap-2'>
|
||||
{Icon && <Icon className='size-4 text-primary' />}
|
||||
<span className='font-medium text-sm text-foreground'>
|
||||
{item.title}
|
||||
{t(item.titleKey)}
|
||||
</span>
|
||||
</div>
|
||||
<p className='text-xs text-muted-foreground leading-relaxed pr-2'>
|
||||
{item.description}
|
||||
{t(item.descKey)}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -350,11 +352,11 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
<div className='flex items-center gap-2'>
|
||||
<Clock className='size-4 text-primary' />
|
||||
<span className='font-medium text-sm text-foreground'>
|
||||
登录状态保持时间
|
||||
{t('loginSessionTTL')}
|
||||
</span>
|
||||
</div>
|
||||
<p className='text-xs text-muted-foreground leading-relaxed pr-2'>
|
||||
配置用户登录会话在浏览器中的保持期限。设置为“关闭”则在浏览器关闭后自动退登。
|
||||
{t('loginSessionTTLDesc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -365,14 +367,16 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
onValueChange={handleTTLChange}
|
||||
>
|
||||
<SelectTrigger className='w-[180px] bg-card border-dashed text-xs h-8'>
|
||||
<SelectValue placeholder='选择保留时间' />
|
||||
<SelectValue placeholder={t('selectRetentionTime')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='0'>关闭 (浏览器关闭自动退登)</SelectItem>
|
||||
<SelectItem value='168'>7 天</SelectItem>
|
||||
<SelectItem value='720'>30 天</SelectItem>
|
||||
<SelectItem value='-1'>永不过期</SelectItem>
|
||||
<SelectItem value='custom'>自定义时长</SelectItem>
|
||||
<SelectItem value='0'>{t('sessionOff')}</SelectItem>
|
||||
<SelectItem value='168'>{t('session7Days')}</SelectItem>
|
||||
<SelectItem value='720'>{t('session30Days')}</SelectItem>
|
||||
<SelectItem value='-1'>
|
||||
{t('sessionNeverExpire')}
|
||||
</SelectItem>
|
||||
<SelectItem value='custom'>{t('sessionCustom')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -388,7 +392,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
handleCustomBlur();
|
||||
}
|
||||
}}
|
||||
placeholder='小时'
|
||||
placeholder={t('hoursPlaceholder')}
|
||||
disabled={updateTTLMutation.isPending}
|
||||
className='w-20 bg-card border-dashed text-xs h-8 px-2'
|
||||
/>
|
||||
@@ -408,10 +412,10 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className='text-base font-semibold'>
|
||||
认证源管理
|
||||
{t('authSourceManagement')}
|
||||
</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
添加、修改并启用系统自定义的 OIDC 认证源
|
||||
{t('authSourceManagementDesc')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
@@ -425,7 +429,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
variant='secondary'
|
||||
>
|
||||
<Plus className='mr-1.5 size-3.5' />
|
||||
新增认证源
|
||||
{t('addAuthSource')}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className='pt-6 space-y-3'>
|
||||
@@ -451,11 +455,12 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
: 'bg-amber-500/10 text-amber-500 border-amber-500/20'
|
||||
}`}
|
||||
>
|
||||
{source.is_active ? '已启用' : '已禁用'}
|
||||
{source.is_active ? t('enabled') : t('disabled')}
|
||||
</span>
|
||||
</div>
|
||||
<div className='text-xs text-muted-foreground font-mono'>
|
||||
标识符: {source.name} · 类型: {source.type.toUpperCase()}
|
||||
{t('identifier')}: {source.name} · {t('type')}:{' '}
|
||||
{source.type.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-center gap-4'>
|
||||
@@ -467,8 +472,8 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
}`}
|
||||
>
|
||||
{source.client_secret_configured
|
||||
? 'Secret 已配置'
|
||||
: 'Secret 未配置'}
|
||||
? t('secretConfigured')
|
||||
: t('secretNotConfigured')}
|
||||
</span>
|
||||
|
||||
<div className='flex items-center gap-2'>
|
||||
@@ -508,7 +513,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
))
|
||||
) : (
|
||||
<div className='rounded-xl border border-dashed border-border/50 px-4 py-8 text-center text-xs text-muted-foreground bg-muted/5 flex flex-col items-center justify-center gap-3'>
|
||||
<span>暂无配置的认证源,点击上方按钮新增</span>
|
||||
<span>{t('noAuthSources')}</span>
|
||||
<Button
|
||||
type='button'
|
||||
size='sm'
|
||||
@@ -520,7 +525,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
className='border-dashed'
|
||||
>
|
||||
<Plus className='mr-1.5 size-3.5' />
|
||||
新增认证源
|
||||
{t('addAuthSource')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -536,11 +541,10 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className='text-base font-semibold'>
|
||||
人机验证配置 (Cap CAPTCHA)
|
||||
{t('captchaConfig')}
|
||||
</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
配置基于 Proof-of-Work (PoW)
|
||||
的无感人机验证,保护系统登录免受暴力破解和撞库攻击
|
||||
{t('captchaConfigDesc')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
@@ -556,7 +560,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
{/* 自动开始计算 Switch */}
|
||||
<div className='flex items-center justify-between rounded-xl border border-dashed p-4 bg-card mb-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<p className='text-sm font-semibold'>打开页面后自动开始计算</p>
|
||||
<p className='text-sm font-semibold'>{t('autoStartSolving')}</p>
|
||||
</div>
|
||||
<Switch checked={capAutoSolve} onCheckedChange={setCapAutoSolve} />
|
||||
</div>
|
||||
@@ -567,7 +571,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
htmlFor='cap_challenge_count'
|
||||
className='text-xs font-semibold'
|
||||
>
|
||||
难题数量 (Count)
|
||||
{t('challengeCount')}
|
||||
</Label>
|
||||
<Input
|
||||
id='cap_challenge_count'
|
||||
@@ -580,7 +584,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
className='bg-card border-dashed text-xs'
|
||||
/>
|
||||
<p className='text-[10px] text-muted-foreground leading-normal'>
|
||||
客户端需求解的难题总数。默认 1,推荐 1 至 5
|
||||
{t('challengeCountDesc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -589,7 +593,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
htmlFor='cap_challenge_difficulty'
|
||||
className='text-xs font-semibold'
|
||||
>
|
||||
验证难度 (Difficulty)
|
||||
{t('challengeDifficulty')}
|
||||
</Label>
|
||||
<Input
|
||||
id='cap_challenge_difficulty'
|
||||
@@ -602,7 +606,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
className='bg-card border-dashed text-xs'
|
||||
/>
|
||||
<p className='text-[10px] text-muted-foreground leading-normal'>
|
||||
PoW 前缀哈希位数,每加 1 计算时间翻倍。默认 4,推荐 4
|
||||
{t('challengeDifficultyDesc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -611,7 +615,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
htmlFor='cap_challenge_size'
|
||||
className='text-xs font-semibold'
|
||||
>
|
||||
盐值长度 (Size)
|
||||
{t('challengeSize')}
|
||||
</Label>
|
||||
<Input
|
||||
id='cap_challenge_size'
|
||||
@@ -624,7 +628,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
className='bg-card border-dashed text-xs'
|
||||
/>
|
||||
<p className='text-[10px] text-muted-foreground leading-normal'>
|
||||
难题盐值混淆字符长度。默认 32
|
||||
{t('challengeSizeDesc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -633,7 +637,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
htmlFor='cap_challenge_ttl'
|
||||
className='text-xs font-semibold'
|
||||
>
|
||||
难题超时时长 (秒)
|
||||
{t('challengeTTL')}
|
||||
</Label>
|
||||
<Input
|
||||
id='cap_challenge_ttl'
|
||||
@@ -645,7 +649,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
className='bg-card border-dashed text-xs'
|
||||
/>
|
||||
<p className='text-[10px] text-muted-foreground leading-normal'>
|
||||
难题有效期限。默认 600 秒 (10 分钟)
|
||||
{t('challengeTTLDesc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -654,7 +658,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
htmlFor='cap_token_ttl'
|
||||
className='text-xs font-semibold'
|
||||
>
|
||||
验证凭证有效时长 (秒)
|
||||
{t('tokenTTL')}
|
||||
</Label>
|
||||
<Input
|
||||
id='cap_token_ttl'
|
||||
@@ -666,8 +670,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
className='bg-card border-dashed text-xs'
|
||||
/>
|
||||
<p className='text-[10px] text-muted-foreground leading-normal'>
|
||||
PoW 计算求解通过后,签发的登录凭证有效时长。默认 1200 秒 (20
|
||||
分钟)
|
||||
{t('tokenTTLDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -681,10 +684,10 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
{saveCapMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className='mr-1.5 size-3.5 animate-spin' />
|
||||
保存中...
|
||||
{t('saving')}
|
||||
</>
|
||||
) : (
|
||||
'保存配置'
|
||||
t('saveConfig')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -713,16 +716,16 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除认证源</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t('deleteAuthSourceTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定删除认证源「
|
||||
{deleteTarget?.display_name || deleteTarget?.name}
|
||||
」吗?删除后无法恢复。
|
||||
{t('deleteAuthSourceConfirm', {
|
||||
name: deleteTarget?.display_name || deleteTarget?.name || '',
|
||||
})}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteSourceMutation.isPending}>
|
||||
取消
|
||||
{tCommon('cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={deleteSourceMutation.isPending}
|
||||
@@ -730,7 +733,9 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
deleteTarget && deleteSourceMutation.mutate(deleteTarget.id)
|
||||
}
|
||||
>
|
||||
{deleteSourceMutation.isPending ? '删除中...' : '确认删除'}
|
||||
{deleteSourceMutation.isPending
|
||||
? t('deleting')
|
||||
: t('confirmDelete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -31,6 +31,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import services from '@/lib/services';
|
||||
import type { SystemConfig } from '@/lib/services/admin';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
interface SystemTabProps {
|
||||
configs: Record<string, SystemConfig>;
|
||||
@@ -39,6 +40,7 @@ interface SystemTabProps {
|
||||
|
||||
export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const t = useTranslations('settings.system');
|
||||
const [serverAddress, setServerAddress] = useState('');
|
||||
const [siteName, setSiteName] = useState('');
|
||||
const [smtpHost, setSmtpHost] = useState('');
|
||||
@@ -65,7 +67,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
|
||||
const handleDetectAddress = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
setServerAddress(window.location.origin);
|
||||
toast.success('已自动获取当前域名并填充');
|
||||
toast.success(t('addressAutoDetected'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -85,10 +87,10 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
|
||||
queryKey: ['admin', 'system-configs'],
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: ['public-config'] });
|
||||
toast.success('配置已更新');
|
||||
toast.success(t('configUpdated'));
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || '更新配置失败');
|
||||
toast.error(error.message || t('updateConfigFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -114,10 +116,10 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
|
||||
}),
|
||||
queryClient.invalidateQueries({ queryKey: ['public-config'] }),
|
||||
]);
|
||||
toast.success('通用配置已成功保存');
|
||||
toast.success(t('generalConfigSaved'));
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || '保存配置失败');
|
||||
toast.error(error.message || t('saveConfigFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -156,10 +158,10 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['admin', 'system-configs'],
|
||||
});
|
||||
toast.success('SMTP 邮件配置已成功保存');
|
||||
toast.success(t('smtpConfigSaved'));
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || '保存配置失败');
|
||||
toast.error(error.message || t('saveConfigFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -170,7 +172,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
|
||||
|
||||
const testSmtpMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
setSmtpTestLog('正在发起连接测试...\n');
|
||||
setSmtpTestLog(t('connectingTest'));
|
||||
setSmtpTestSuccess(null);
|
||||
setSmtpTestError('');
|
||||
|
||||
@@ -187,25 +189,27 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
|
||||
setSmtpTestLog(data.log);
|
||||
if (data.success) {
|
||||
setSmtpTestSuccess(true);
|
||||
toast.success('测试邮件发送成功');
|
||||
toast.success(t('testEmailSent'));
|
||||
} else {
|
||||
setSmtpTestSuccess(false);
|
||||
setSmtpTestError(data.error || '发送失败,请检查配置和日志。');
|
||||
toast.error('测试邮件发送失败');
|
||||
setSmtpTestError(data.error || t('sendFailedCheckConfig'));
|
||||
toast.error(t('testEmailFailed'));
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setSmtpTestSuccess(false);
|
||||
setSmtpTestError(error.message || '请求发送失败');
|
||||
setSmtpTestLog((prev) => prev + `\n[请求错误] ${error.message}\n`);
|
||||
toast.error(error.message || '测试请求发送失败');
|
||||
setSmtpTestError(error.message || t('requestFailed'));
|
||||
setSmtpTestLog(
|
||||
(prev) => prev + `\n${t('requestError')} ${error.message}\n`,
|
||||
);
|
||||
toast.error(error.message || t('testRequestFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
const handleSmtpTestSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!smtpTestTo) {
|
||||
toast.error('请输入目标邮箱地址');
|
||||
toast.error(t('enterTargetEmail'));
|
||||
return;
|
||||
}
|
||||
testSmtpMutation.mutate();
|
||||
@@ -225,10 +229,10 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className='text-base font-semibold'>
|
||||
通用设置
|
||||
{t('generalSettings')}
|
||||
</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
配置站点标识、服务地址与搜索引擎收录策略
|
||||
{t('generalSettingsDesc')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
@@ -238,7 +242,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
|
||||
<div className='grid grid-cols-1 gap-4 lg:grid-cols-2'>
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<Label htmlFor='site_name' className='text-xs font-semibold'>
|
||||
站点名称
|
||||
{t('siteName')}
|
||||
</Label>
|
||||
<Input
|
||||
id='site_name'
|
||||
@@ -249,7 +253,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
|
||||
className='border-dashed bg-card text-xs'
|
||||
/>
|
||||
<p className='text-[11px] leading-relaxed text-muted-foreground'>
|
||||
用于网页标题、登录注册页面、管理后台与系统邮件。
|
||||
{t('siteNameDesc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -259,7 +263,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
|
||||
htmlFor='server_address'
|
||||
className='text-xs font-semibold'
|
||||
>
|
||||
服务器访问地址
|
||||
{t('serverAddress')}
|
||||
</Label>
|
||||
<Button
|
||||
type='button'
|
||||
@@ -269,7 +273,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
|
||||
className='h-auto px-0 text-xs'
|
||||
>
|
||||
<Sparkles data-icon='inline-start' />
|
||||
使用当前域名
|
||||
{t('useCurrentDomain')}
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
@@ -281,7 +285,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
|
||||
className='border-dashed bg-card text-xs'
|
||||
/>
|
||||
<p className='text-[11px] leading-relaxed text-muted-foreground'>
|
||||
限定 API 的允许来源;留空将开放任意来源访问。
|
||||
{t('serverAddressDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -294,14 +298,16 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
|
||||
<div className='flex min-w-0 flex-col gap-1'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<span className='text-xs font-semibold'>
|
||||
搜索引擎抓取与收录
|
||||
{t('searchEngineIndexing')}
|
||||
</span>
|
||||
<Badge variant={indexingEnabled ? 'default' : 'secondary'}>
|
||||
{indexingEnabled ? '允许收录' : '禁止收录'}
|
||||
{indexingEnabled
|
||||
? t('indexingAllowed')
|
||||
: t('indexingDenied')}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className='text-[11px] leading-relaxed text-muted-foreground'>
|
||||
控制 Google、Baidu、Bing 等搜索引擎是否可以索引公开页面。
|
||||
{t('searchEngineIndexingDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -324,7 +330,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
|
||||
<div className='flex flex-col-reverse gap-2 border-t border-dashed pt-4 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<div className='flex items-center gap-2 text-[11px] text-muted-foreground'>
|
||||
<Globe className='size-3.5' />
|
||||
地址变更会影响跨域访问策略,请填写完整协议与域名。
|
||||
{t('addressChangeWarning')}
|
||||
</div>
|
||||
<Button
|
||||
type='submit'
|
||||
@@ -337,10 +343,10 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
|
||||
data-icon='inline-start'
|
||||
className='animate-spin'
|
||||
/>
|
||||
保存中...
|
||||
{t('saving')}
|
||||
</>
|
||||
) : (
|
||||
'保存配置'
|
||||
t('saveConfig')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -357,10 +363,10 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className='text-base font-semibold'>
|
||||
SMTP 邮件设置
|
||||
{t('smtpSettings')}
|
||||
</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
配置系统的邮件发送服务 (SMTP)
|
||||
{t('smtpSettingsDesc')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
@@ -370,7 +376,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='smtp_host' className='text-xs font-semibold'>
|
||||
SMTP 服务器地址
|
||||
{t('smtpHost')}
|
||||
</Label>
|
||||
<Input
|
||||
id='smtp_host'
|
||||
@@ -384,7 +390,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
|
||||
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='smtp_port' className='text-xs font-semibold'>
|
||||
SMTP 端口
|
||||
{t('smtpPort')}
|
||||
</Label>
|
||||
<Input
|
||||
id='smtp_port'
|
||||
@@ -401,7 +407,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
|
||||
htmlFor='smtp_username'
|
||||
className='text-xs font-semibold'
|
||||
>
|
||||
SMTP 账户
|
||||
{t('smtpUsername')}
|
||||
</Label>
|
||||
<Input
|
||||
id='smtp_username'
|
||||
@@ -418,7 +424,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
|
||||
htmlFor='smtp_password'
|
||||
className='text-xs font-semibold'
|
||||
>
|
||||
SMTP 访问凭证
|
||||
{t('smtpPassword')}
|
||||
</Label>
|
||||
<Input
|
||||
id='smtp_password'
|
||||
@@ -427,8 +433,8 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
|
||||
onChange={(e) => setSmtpPassword(e.target.value)}
|
||||
placeholder={
|
||||
configs['smtp_password']?.value === '******'
|
||||
? '•••••• (已配置,留空或输入新值)'
|
||||
: '输入凭证密码'
|
||||
? t('smtpPasswordMasked')
|
||||
: t('smtpPasswordPlaceholder')
|
||||
}
|
||||
className='bg-card border-dashed text-xs'
|
||||
/>
|
||||
@@ -449,7 +455,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
|
||||
}}
|
||||
disabled={saveSmtpMutation.isPending}
|
||||
>
|
||||
测试发件
|
||||
{t('testSend')}
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
@@ -459,10 +465,10 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
|
||||
{saveSmtpMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className='mr-1.5 size-3.5 animate-spin' />
|
||||
保存中...
|
||||
{t('saving')}
|
||||
</>
|
||||
) : (
|
||||
'保存配置'
|
||||
t('saveConfig')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -474,18 +480,17 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
|
||||
<DialogContent className='max-w-lg border border-dashed'>
|
||||
<DialogHeader>
|
||||
<DialogTitle className='text-base font-semibold'>
|
||||
SMTP 发件测试
|
||||
{t('smtpTestTitle')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className='text-xs'>
|
||||
输入接收测试邮件的邮箱地址。系统将使用您在表单中当前填写的 SMTP
|
||||
配置进行发件测试。
|
||||
{t('smtpTestDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSmtpTestSubmit} className='space-y-4'>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='smtp_test_to' className='text-xs font-semibold'>
|
||||
目标邮箱地址
|
||||
{t('targetEmail')}
|
||||
</Label>
|
||||
<Input
|
||||
id='smtp_test_to'
|
||||
@@ -501,7 +506,9 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
|
||||
|
||||
{smtpTestLog && (
|
||||
<div className='space-y-1.5'>
|
||||
<Label className='text-xs font-semibold'>连接与传输日志</Label>
|
||||
<Label className='text-xs font-semibold'>
|
||||
{t('connectionLog')}
|
||||
</Label>
|
||||
<pre className='bg-zinc-950 text-zinc-50 font-mono p-4 rounded-lg text-[10px] h-60 overflow-y-auto whitespace-pre-wrap border border-dashed border-zinc-800 leading-relaxed'>
|
||||
{smtpTestLog}
|
||||
</pre>
|
||||
@@ -510,13 +517,13 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
|
||||
|
||||
{smtpTestSuccess === true && (
|
||||
<div className='p-3 rounded-lg border border-dashed border-emerald-500/30 bg-emerald-500/5 text-emerald-500 text-xs'>
|
||||
测试成功!邮件已顺利发出。
|
||||
{t('testSuccess')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{smtpTestSuccess === false && (
|
||||
<div className='p-3 rounded-lg border border-dashed border-rose-500/30 bg-rose-500/5 text-rose-500 text-xs break-all'>
|
||||
测试失败:{smtpTestError}
|
||||
{t('testFailed')}: {smtpTestError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -528,7 +535,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
|
||||
onClick={() => setSmtpTestOpen(false)}
|
||||
disabled={testSmtpMutation.isPending}
|
||||
>
|
||||
关闭
|
||||
{t('close')}
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
@@ -538,10 +545,10 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
|
||||
{testSmtpMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className='mr-1.5 size-3.5 animate-spin' />
|
||||
测试中...
|
||||
{t('testing')}
|
||||
</>
|
||||
) : (
|
||||
'开始测试'
|
||||
t('startTest')
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -36,9 +36,11 @@ import {
|
||||
import services from '@/lib/services';
|
||||
import type { Template } from '@/lib/services/admin/types';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export function TemplatesManager() {
|
||||
const queryClient = useQueryClient();
|
||||
const t = useTranslations('settings.templates');
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(
|
||||
null,
|
||||
@@ -71,11 +73,11 @@ export function TemplatesManager() {
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin', 'templates'] });
|
||||
toast.success('通知模板已成功创建');
|
||||
toast.success(t('templateCreated'));
|
||||
setModalOpen(false);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || '创建模板失败');
|
||||
toast.error(error.message || t('createTemplateFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -91,11 +93,11 @@ export function TemplatesManager() {
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin', 'templates'] });
|
||||
toast.success('通知模板已成功保存');
|
||||
toast.success(t('templateSaved'));
|
||||
setModalOpen(false);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || '修改模板失败');
|
||||
toast.error(error.message || t('updateTemplateFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -106,10 +108,10 @@ export function TemplatesManager() {
|
||||
onSuccess: async () => {
|
||||
setDeleteTarget(null);
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin', 'templates'] });
|
||||
toast.success('通知模板已删除');
|
||||
toast.success(t('templateDeleted'));
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || '删除模板失败');
|
||||
toast.error(error.message || t('deleteTemplateFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -157,10 +159,10 @@ export function TemplatesManager() {
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className='text-base font-semibold'>
|
||||
通知模板管理
|
||||
{t('templateManagement')}
|
||||
</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
配置与编辑系统各类场景的通知邮件/短信模板,支持动态变量渲染
|
||||
{t('templateManagementDesc')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
@@ -171,7 +173,7 @@ export function TemplatesManager() {
|
||||
variant='secondary'
|
||||
>
|
||||
<Plus className='mr-1.5 size-3.5' />
|
||||
新增模板
|
||||
{t('addTemplate')}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className='pt-6 space-y-4'>
|
||||
@@ -198,18 +200,18 @@ export function TemplatesManager() {
|
||||
: 'bg-amber-500/10 text-amber-500 border-amber-500/20'
|
||||
}`}
|
||||
>
|
||||
{tmpl.is_system ? '系统内置' : '自定义'}
|
||||
{tmpl.is_system ? t('systemBuiltIn') : t('custom')}
|
||||
</span>
|
||||
<span className='text-[10px] px-2 py-0.5 rounded-full border border-border/50 bg-muted/50 text-muted-foreground font-mono'>
|
||||
{tmpl.type.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div className='text-xs text-muted-foreground'>
|
||||
标识符:{' '}
|
||||
{t('identifier')}:{' '}
|
||||
<span className='font-mono text-primary bg-primary/5 px-1.5 py-0.5 rounded'>
|
||||
{tmpl.key}
|
||||
</span>
|
||||
{tmpl.subject && ` · 主题: ${tmpl.subject}`}
|
||||
{tmpl.subject && ` · ${t('subject')}: ${tmpl.subject}`}
|
||||
</div>
|
||||
{tmpl.description && (
|
||||
<p className='text-xs text-muted-foreground/80 leading-relaxed max-w-xl'>
|
||||
@@ -245,7 +247,7 @@ export function TemplatesManager() {
|
||||
</div>
|
||||
) : (
|
||||
<div className='rounded-xl border border-dashed border-border/50 px-4 py-8 text-center text-xs text-muted-foreground bg-muted/5 flex flex-col items-center justify-center gap-3'>
|
||||
<span>暂无配置的通知模板,点击上方按钮新增</span>
|
||||
<span>{t('noTemplates')}</span>
|
||||
<Button
|
||||
type='button'
|
||||
size='sm'
|
||||
@@ -254,7 +256,7 @@ export function TemplatesManager() {
|
||||
className='border-dashed'
|
||||
>
|
||||
<Plus className='mr-1.5 size-3.5' />
|
||||
新增模板
|
||||
{t('addTemplate')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -265,11 +267,10 @@ export function TemplatesManager() {
|
||||
<DialogContent className='max-w-xl border border-dashed'>
|
||||
<DialogHeader>
|
||||
<DialogTitle className='text-base font-semibold'>
|
||||
{selectedTemplate ? '编辑通知模板' : '新增通知模板'}
|
||||
{selectedTemplate ? t('editTemplate') : t('addTemplateTitle')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className='text-xs'>
|
||||
配置模板渲染逻辑,支持 Go `text/template` 语法(例如 `
|
||||
{'{{.Code}}'}` 表示验证码变量)。
|
||||
{t('templateDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -277,7 +278,7 @@ export function TemplatesManager() {
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='tmpl_key' className='text-xs font-semibold'>
|
||||
模板标识符 (Key)
|
||||
{t('templateKey')}
|
||||
</Label>
|
||||
<Input
|
||||
id='tmpl_key'
|
||||
@@ -290,13 +291,13 @@ export function TemplatesManager() {
|
||||
disabled={!!selectedTemplate}
|
||||
/>
|
||||
<p className='text-[10px] text-muted-foreground leading-normal'>
|
||||
模板的唯一标识,在代码中通过此 Key 调用。
|
||||
{t('templateKeyDesc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='tmpl_name' className='text-xs font-semibold'>
|
||||
模板名称
|
||||
{t('templateName')}
|
||||
</Label>
|
||||
<Input
|
||||
id='tmpl_name'
|
||||
@@ -308,13 +309,13 @@ export function TemplatesManager() {
|
||||
className='bg-card border-dashed text-xs'
|
||||
/>
|
||||
<p className='text-[10px] text-muted-foreground leading-normal'>
|
||||
用于后台识别该模板的可读名称。
|
||||
{t('templateNameDesc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='tmpl_type' className='text-xs font-semibold'>
|
||||
模板类型
|
||||
{t('templateType')}
|
||||
</Label>
|
||||
<Input
|
||||
id='tmpl_type'
|
||||
@@ -326,13 +327,13 @@ export function TemplatesManager() {
|
||||
className='bg-card border-dashed text-xs'
|
||||
/>
|
||||
<p className='text-[10px] text-muted-foreground leading-normal'>
|
||||
模板分类标识,目前支持 `email`。
|
||||
{t('templateTypeDesc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='tmpl_subject' className='text-xs font-semibold'>
|
||||
模板主题 (Subject)
|
||||
{t('templateSubject')}
|
||||
</Label>
|
||||
<Input
|
||||
id='tmpl_subject'
|
||||
@@ -343,7 +344,7 @@ export function TemplatesManager() {
|
||||
className='bg-card border-dashed text-xs'
|
||||
/>
|
||||
<p className='text-[10px] text-muted-foreground leading-normal'>
|
||||
邮件标题(类型为 email 时生效)。
|
||||
{t('templateSubjectDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -353,7 +354,7 @@ export function TemplatesManager() {
|
||||
htmlFor='tmpl_description'
|
||||
className='text-xs font-semibold'
|
||||
>
|
||||
模板说明与变量描述
|
||||
{t('templateDescription')}
|
||||
</Label>
|
||||
<Input
|
||||
id='tmpl_description'
|
||||
@@ -367,7 +368,7 @@ export function TemplatesManager() {
|
||||
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='tmpl_content' className='text-xs font-semibold'>
|
||||
模板正文内容 (Content)
|
||||
{t('templateContent')}
|
||||
</Label>
|
||||
<Textarea
|
||||
id='tmpl_content'
|
||||
@@ -388,16 +389,16 @@ export function TemplatesManager() {
|
||||
onClick={() => setModalOpen(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
取消
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button type='submit' size='sm' disabled={isPending}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className='mr-1.5 size-3.5 animate-spin' />
|
||||
保存中...
|
||||
{t('saving')}
|
||||
</>
|
||||
) : (
|
||||
'保存配置'
|
||||
t('saveConfig')
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@@ -411,14 +412,14 @@ export function TemplatesManager() {
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除模板</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t('deleteTemplateTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定删除模板「{deleteTarget?.name}」吗?删除后无法恢复。
|
||||
{t('deleteTemplateConfirm', { name: deleteTarget?.name ?? '' })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteTemplateMutation.isPending}>
|
||||
取消
|
||||
{t('cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={deleteTemplateMutation.isPending}
|
||||
@@ -426,7 +427,9 @@ export function TemplatesManager() {
|
||||
deleteTarget && deleteTemplateMutation.mutate(deleteTarget.key)
|
||||
}
|
||||
>
|
||||
{deleteTemplateMutation.isPending ? '删除中...' : '确认删除'}
|
||||
{deleteTemplateMutation.isPending
|
||||
? t('deleting')
|
||||
: t('confirmDelete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
Reference in New Issue
Block a user