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

接入无 URL 前缀的 zh-CN/en,顶栏与外观设置可切换语言;选择写入 cookie 后刷新生效。
This commit is contained in:
ryan
2026-08-16 16:49:16 +08:00
parent 64fbaa7ef1
commit 5a8722ff07
219 changed files with 21437 additions and 5212 deletions
@@ -59,12 +59,19 @@ import type { CreateTokenResponse } from '@/lib/services/user';
import { UserService } from '@/lib/services/user';
import { useAuth } from '@/components/providers/auth-provider';
import { toast } from 'sonner';
import { useLocale, useTranslations } from 'next-intl';
import { formatDateTime } from '@/i18n/format';
import type { AppLocale } from '@/i18n/config';
type ConfirmTarget = { id: number; name: string };
export function AccessTokenMain() {
const { user } = useAuth();
const queryClient = useQueryClient();
const t = useTranslations('settings');
const tCommon = useTranslations('common');
const ta = useTranslations('settings.accessToken');
const locale = useLocale() as AppLocale;
const [createDialogOpen, setCreateDialogOpen] = React.useState(false);
const [viewDialogOpen, setViewDialogOpen] = React.useState(false);
const [tokenName, setTokenName] = React.useState('');
@@ -98,10 +105,10 @@ export function AccessTokenMain() {
void queryClient.invalidateQueries({
queryKey: ['user', 'access-tokens'],
});
toast.success('访问令牌创建成功');
toast.success(ta('createSuccess'));
},
onError: (error: Error) => {
toast.error(error.message || '创建访问令牌失败');
toast.error(error.message || ta('createFailed'));
},
});
@@ -113,10 +120,10 @@ export function AccessTokenMain() {
void queryClient.invalidateQueries({
queryKey: ['user', 'access-tokens'],
});
toast.success('访问令牌已撤销');
toast.success(ta('deleteSuccess'));
},
onError: (error: Error) => {
toast.error(error.message || '删除访问令牌失败');
toast.error(error.message || ta('deleteFailed'));
},
});
@@ -130,17 +137,17 @@ export function AccessTokenMain() {
void queryClient.invalidateQueries({
queryKey: ['user', 'access-tokens'],
});
toast.success('访问令牌轮换成功,旧密钥已失效');
toast.success(ta('rotateSuccess'));
},
onError: (error: Error) => {
toast.error(error.message || '轮换访问令牌失败');
toast.error(error.message || ta('rotateFailed'));
},
});
const handleCreateToken = (e: React.FormEvent) => {
e.preventDefault();
if (!tokenName.trim()) {
toast.error('请输入令牌名称');
toast.error(ta('nameRequired'));
return;
}
createTokenMutation.mutate({
@@ -153,22 +160,16 @@ export function AccessTokenMain() {
try {
await navigator.clipboard.writeText(text);
setCopiedId(id);
toast.success('复制成功');
toast.success(ta('copySuccess'));
setTimeout(() => setCopiedId(null), 2000);
} catch {
toast.error('复制失败');
toast.error(ta('copyFailed'));
}
};
const formatDate = (dateStr?: string) => {
if (!dateStr) return '未使用';
return new Date(dateStr).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
if (!dateStr) return '—';
return formatDateTime(dateStr, locale);
};
return (
@@ -184,14 +185,14 @@ export function AccessTokenMain() {
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href='/settings' className='text-base text-primary'>
设置
{t('title')}
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage className='text-base font-semibold'>
访问令牌
{ta('breadcrumb')}
</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
@@ -202,11 +203,9 @@ export function AccessTokenMain() {
<div className='flex items-center gap-4'>
<div>
<h1 className='text-xl font-bold tracking-tight bg-gradient-to-r from-foreground via-foreground/90 to-muted-foreground bg-clip-text text-transparent'>
个人访问令牌 (AccessToken)
{ta('title')}
</h1>
<p className='text-sm text-muted-foreground'>
管理您的 API 访问密钥,用于开发或第三方工具直接调用系统 API
</p>
<p className='text-sm text-muted-foreground'>{ta('subtitle')}</p>
</div>
</div>
<Button
@@ -215,7 +214,7 @@ export function AccessTokenMain() {
variant={'secondary'}
>
<Plus className='mr-1.5 size-4' />
生成新令牌
{ta('generate')}
</Button>
</div>
@@ -223,20 +222,18 @@ export function AccessTokenMain() {
<div className='rounded-xl border border-amber-500/20 bg-amber-500/5 p-4 flex gap-3 text-amber-600 text-xs leading-relaxed'>
<AlertTriangle className='size-4 shrink-0 mt-0.5' />
<div className='space-y-1'>
<span className='font-bold'>安全提示:</span>
<p className='text-muted-foreground'>
访问令牌具有您账户的完整接口调用权限。为了您的账户与资产安全,请切勿通过任何代码库提交、即时通讯工具或公共媒介泄露此令牌。推荐按需创建,不使用时及时撤销。
</p>
<span className='font-bold'>{ta('securityTitle')}</span>
<p className='text-muted-foreground'>{ta('securityBody')}</p>
</div>
</div>
<Card className='border border-dashed shadow-sm'>
<CardHeader className='border-b border-dashed pb-4'>
<CardTitle className='text-base font-semibold'>
活动令牌列表
{ta('listTitle')}
</CardTitle>
<CardDescription className='text-xs'>
当前可用的所有访问令牌
{ta('listDesc')}
</CardDescription>
</CardHeader>
<CardContent className='pt-6 space-y-4'>
@@ -262,14 +259,14 @@ export function AccessTokenMain() {
className='text-[10px] px-1.5 py-0 h-4 border-rose-500/40 text-rose-500 bg-rose-500/5 font-semibold'
>
<Shield className='size-2.5 mr-0.5' />
管理员
{ta('adminBadge')}
</Badge>
) : (
<Badge
variant='outline'
className='text-[10px] px-1.5 py-0 h-4 border-border/50 text-muted-foreground bg-muted/10 font-semibold'
>
用户令牌
{ta('userToken')}
</Badge>
)}
</div>
@@ -278,7 +275,11 @@ export function AccessTokenMain() {
{token.masked_token}
</div>
<div className='flex flex-wrap gap-x-4 gap-y-0.5 pt-1'>
<span>创建于: {formatDate(token.created_at)}</span>
<span>
{ta('createdAt', {
date: formatDate(token.created_at),
})}
</span>
</div>
</div>
</div>
@@ -296,7 +297,7 @@ export function AccessTokenMain() {
<RefreshCw
className={`size-3.5 mr-1 ${rotateTokenMutation.isPending && rotateTokenMutation.variables === token.id ? 'animate-spin' : ''}`}
/>
轮换
{ta('rotate')}
</Button>
<Button
type='button'
@@ -309,7 +310,7 @@ export function AccessTokenMain() {
disabled={deleteTokenMutation.isPending}
>
<Trash2 className='size-3.5 mr-1' />
撤销
{ta('revoke')}
</Button>
</div>
</div>
@@ -318,7 +319,7 @@ export function AccessTokenMain() {
) : (
<div className='rounded-xl border border-dashed border-border/50 px-4 py-10 text-center text-xs text-muted-foreground bg-muted/5 flex flex-col items-center justify-center gap-3'>
<Key className='size-8 text-muted-foreground/30' />
<span>您当前暂无生成任何访问令牌</span>
<span>{ta('empty')}</span>
<Button
type='button'
variant='outline'
@@ -327,7 +328,7 @@ export function AccessTokenMain() {
onClick={() => setCreateDialogOpen(true)}
>
<Plus className='mr-1 size-3.5' />
生成第一个令牌
{ta('generateFirst')}
</Button>
</div>
)}
@@ -340,20 +341,20 @@ export function AccessTokenMain() {
<form onSubmit={handleCreateToken}>
<DialogHeader>
<DialogTitle className='text-base font-semibold'>
生成新令牌
{ta('generate')}
</DialogTitle>
<DialogDescription className='text-xs text-muted-foreground'>
请为新的访问令牌设置一个易于识别的名称,以便将来管理。
{ta('createDesc')}
</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-4'>
<div className='space-y-2'>
<Label htmlFor='token-name' className='text-xs font-semibold'>
令牌名称
{ta('name')}
</Label>
<Input
id='token-name'
placeholder='例如:my-development-key'
placeholder={ta('namePlaceholder')}
value={tokenName}
onChange={(e) => setTokenName(e.target.value)}
disabled={createTokenMutation.isPending}
@@ -368,10 +369,10 @@ export function AccessTokenMain() {
className='text-xs font-semibold flex items-center gap-1.5'
>
<Shield className='size-3.5 text-rose-500' />
管理员权限
{ta('adminPermission')}
</Label>
<p className='text-[11px] text-muted-foreground leading-normal'>
开启后此令牌可访问 /admin/** 管理端点,默认关闭
{ta('adminHint')}
</p>
</div>
<Switch
@@ -391,7 +392,7 @@ export function AccessTokenMain() {
disabled={createTokenMutation.isPending}
className='rounded-xl text-xs h-9 border-dashed'
>
取消
{tCommon('cancel')}
</Button>
<Button
type='submit'
@@ -401,10 +402,10 @@ export function AccessTokenMain() {
{createTokenMutation.isPending ? (
<>
<Loader2 className='mr-1.5 size-3.5 animate-spin' />
正在生成...
{ta('generating')}
</>
) : (
'生成令牌'
ta('generateAction')
)}
</Button>
</DialogFooter>
@@ -426,7 +427,7 @@ export function AccessTokenMain() {
<DialogHeader>
<DialogTitle className='text-base font-bold text-foreground flex items-center gap-1.5'>
<Check className='size-5 text-emerald-500 border border-emerald-500 rounded-full p-0.5' />
令牌密钥已就绪
{ta('readyTitle')}
</DialogTitle>
</DialogHeader>
@@ -456,10 +457,8 @@ export function AccessTokenMain() {
<div className='rounded-xl border border-rose-500/20 bg-rose-500/5 p-4 flex gap-3 text-rose-600 text-xs leading-relaxed'>
<Info className='size-4 shrink-0 mt-0.5' />
<div className='space-y-1'>
<span className='font-bold'>重要提示:</span>
<p className='text-muted-foreground'>
这是您唯一一次能够查看此访问令牌明文密钥的机会。请立即将其复制并安全地保存。
</p>
<span className='font-bold'>{ta('importantTitle')}</span>
<p className='text-muted-foreground'>{ta('readyBody')}</p>
</div>
</div>
</div>
@@ -474,7 +473,7 @@ export function AccessTokenMain() {
}}
className='rounded-xl w-full'
>
我已经复制并妥善保存
{ta('savedConfirm')}
</Button>
</DialogFooter>
</DialogContent>
@@ -486,15 +485,14 @@ export function AccessTokenMain() {
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认撤销访问令牌</AlertDialogTitle>
<AlertDialogTitle>{ta('deleteConfirmTitle')}</AlertDialogTitle>
<AlertDialogDescription>
确定要删除并撤销令牌「{deleteTarget?.name}
」吗?删除后此令牌将立即失效且不可恢复。
{ta('deleteConfirm', { name: deleteTarget?.name ?? '' })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteTokenMutation.isPending}>
取消
{tCommon('cancel')}
</AlertDialogCancel>
<AlertDialogAction
disabled={deleteTokenMutation.isPending}
@@ -502,7 +500,9 @@ export function AccessTokenMain() {
deleteTarget && deleteTokenMutation.mutate(deleteTarget.id)
}
>
{deleteTokenMutation.isPending ? '撤销中...' : '确认撤销'}
{deleteTokenMutation.isPending
? ta('revoking')
: ta('confirmRevoke')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -514,15 +514,14 @@ export function AccessTokenMain() {
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认轮换访问令牌</AlertDialogTitle>
<AlertDialogTitle>{ta('rotateConfirmTitle')}</AlertDialogTitle>
<AlertDialogDescription>
确定要轮换令牌「{rotateTarget?.name}
」的密钥吗?轮换后系统将生成全新密钥,原令牌密钥将立即失效。
{ta('rotateConfirm', { name: rotateTarget?.name ?? '' })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={rotateTokenMutation.isPending}>
取消
{tCommon('cancel')}
</AlertDialogCancel>
<AlertDialogAction
disabled={rotateTokenMutation.isPending}
@@ -530,7 +529,9 @@ export function AccessTokenMain() {
rotateTarget && rotateTokenMutation.mutate(rotateTarget.id)
}
>
{rotateTokenMutation.isPending ? '轮换中...' : '确认轮换'}
{rotateTokenMutation.isPending
? ta('rotating')
: ta('confirmRotate')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -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>
+96 -87
View File
@@ -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>