fix(frontend): keep browser API on same-origin rewrites in dev

Document that NEXT_PUBLIC_WAVELET_BACKEND_URL must stay unset for local
next dev so axios hits /api/* and Next rewrites proxy to the backend.
This commit is contained in:
ryan
2026-07-24 16:37:24 +08:00
parent a4588b05e0
commit 9f15f42b47
75 changed files with 5971 additions and 2450 deletions
@@ -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>
)}
</>
@@ -27,6 +27,7 @@ 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';
interface OperationTabProps {
configs: Record<string, SystemConfig>;
@@ -38,6 +39,7 @@ export function OperationTab({
systemConfigsQuery,
}: OperationTabProps) {
const queryClient = useQueryClient();
const t = useTranslations('settings.operation');
const uploadTypesQuery = useQuery({
queryKey: ['admin', 'upload-types'],
@@ -63,10 +65,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'));
},
});
@@ -120,10 +122,10 @@ export function OperationTab({
</div>
<div>
<CardTitle className='text-base font-semibold'>
文件访问权限控制
{t('fileAccessControl')}
</CardTitle>
<CardDescription className='text-xs'>
配置免登录直接访问的文件业务类型。不在白名单内的文件将要求登录鉴权。
{t('fileAccessControlDesc')}
</CardDescription>
</div>
</div>
@@ -132,7 +134,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=''
@@ -144,7 +146,7 @@ export function OperationTab({
}
>
<SelectTrigger className='w-[200px]' size='sm'>
<SelectValue placeholder='选择业务类型...' />
<SelectValue placeholder={t('selectBusinessType')} />
</SelectTrigger>
<SelectContent>
{availableTypes
@@ -158,7 +160,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>
@@ -170,7 +172,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>
@@ -200,7 +202,7 @@ export function OperationTab({
</div>
) : (
<p className='text-xs text-muted-foreground'>
白名单已空,所有类型文件的访问都将需要登录。
{t('whitelistEmpty')}
</p>
)}
</div>
@@ -30,6 +30,7 @@ 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;
@@ -147,6 +148,7 @@ interface OtherTabProps {
export function OtherTab({ configs }: OtherTabProps) {
const queryClient = useQueryClient();
const t = useTranslations('settings.other');
const menuDisplayConfig = useMemo(() => {
const raw = configs['menu_display_config']?.value;
@@ -183,10 +185,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'));
},
});
@@ -203,10 +205,10 @@ 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>
@@ -241,7 +243,7 @@ export function OtherTab({ configs }: OtherTabProps) {
</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>
@@ -270,8 +272,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>
@@ -44,43 +44,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;
@@ -92,6 +92,7 @@ interface SecurityTabProps {
export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
const queryClient = useQueryClient();
const t = useTranslations('settings.security');
const [authSourceModalOpen, setAuthSourceModalOpen] = useState(false);
const [selectedSource, setSelectedSource] = useState<AuthSource | null>(null);
@@ -155,14 +156,14 @@ 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'));
},
});
const handleTTLChange = (val: string) => {
const handleSessionTTLChange = (val: string) => {
setSessionTTL(val);
if (val !== 'custom') {
updateTTLMutation.mutate(val);
@@ -172,7 +173,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(
@@ -202,10 +203,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'));
},
});
@@ -220,10 +221,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'));
},
});
@@ -236,10 +237,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'));
},
});
@@ -270,14 +271,14 @@ 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'));
},
});
const handleCapSave = (e: React.FormEvent) => {
const handleCapSubmit = (e: React.FormEvent) => {
e.preventDefault();
saveCapMutation.mutate();
};
@@ -293,10 +294,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>
@@ -316,11 +317,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
@@ -338,11 +339,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>
@@ -350,17 +351,19 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
<Select
value={sessionTTL}
disabled={updateTTLMutation.isPending}
onValueChange={handleTTLChange}
onValueChange={handleSessionTTLChange}
>
<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>
@@ -376,7 +379,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'
/>
@@ -396,10 +399,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>
@@ -413,7 +416,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'>
@@ -439,11 +442,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'>
@@ -455,8 +459,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'>
@@ -489,7 +493,9 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
onClick={() => {
if (
window.confirm(
`确定删除认证源「${source.display_name || source.name}」吗?`,
t('deleteAuthSourceConfirm', {
name: source.display_name || source.name,
}),
)
) {
deleteSourceMutation.mutate(source.id);
@@ -504,7 +510,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'
@@ -516,7 +522,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
className='border-dashed'
>
<Plus className='mr-1.5 size-3.5' />
新增认证源
{t('addAuthSource')}
</Button>
</div>
)}
@@ -532,11 +538,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>
@@ -552,18 +557,18 @@ 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>
<form onSubmit={handleCapSave} className='space-y-6'>
<form onSubmit={handleCapSubmit} className='space-y-6'>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
<div className='space-y-1.5'>
<Label
htmlFor='cap_challenge_count'
className='text-xs font-semibold'
>
难题数量 (Count)
{t('challengeCount')}
</Label>
<Input
id='cap_challenge_count'
@@ -576,7 +581,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>
@@ -585,7 +590,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'
@@ -598,7 +603,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>
@@ -607,7 +612,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'
@@ -620,7 +625,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>
@@ -629,7 +634,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
htmlFor='cap_challenge_ttl'
className='text-xs font-semibold'
>
难题超时时长 (秒)
{t('challengeTTL')}
</Label>
<Input
id='cap_challenge_ttl'
@@ -641,7 +646,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>
@@ -650,7 +655,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
htmlFor='cap_token_ttl'
className='text-xs font-semibold'
>
验证凭证有效时长 (秒)
{t('tokenTTL')}
</Label>
<Input
id='cap_token_ttl'
@@ -662,8 +667,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>
@@ -677,10 +681,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>
@@ -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,14 +116,14 @@ 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'));
},
});
const handleSystemSave = (e: React.FormEvent) => {
const handleSystemSubmit = (e: React.FormEvent) => {
e.preventDefault();
saveSystemMutation.mutate();
};
@@ -156,21 +158,21 @@ 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'));
},
});
const handleSmtpSave = (e: React.FormEvent) => {
const handleSmtpSubmit = (e: React.FormEvent) => {
e.preventDefault();
saveSmtpMutation.mutate();
};
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,20 +229,20 @@ 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>
</CardHeader>
<CardContent className='pt-6'>
<form onSubmit={handleSystemSave} className='flex flex-col gap-6'>
<form onSubmit={handleSystemSubmit} className='flex flex-col gap-6'>
<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,20 +363,20 @@ 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>
</CardHeader>
<CardContent className='pt-6'>
<form onSubmit={handleSmtpSave} className='space-y-6'>
<form onSubmit={handleSmtpSubmit} className='space-y-6'>
<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>
@@ -26,9 +26,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,
@@ -60,11 +62,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'));
},
});
@@ -80,11 +82,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'));
},
});
@@ -94,10 +96,10 @@ export function TemplatesManager() {
},
onSuccess: async () => {
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'));
},
});
@@ -145,10 +147,10 @@ export function TemplatesManager() {
</div>
<div>
<CardTitle className='text-base font-semibold'>
通知模板管理
{t('templateManagement')}
</CardTitle>
<CardDescription className='text-xs'>
配置与编辑系统各类场景的通知邮件/短信模板,支持动态变量渲染
{t('templateManagementDesc')}
</CardDescription>
</div>
</div>
@@ -159,7 +161,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'>
@@ -186,18 +188,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'>
@@ -225,7 +227,9 @@ export function TemplatesManager() {
}
onClick={() => {
if (
window.confirm(`确定删除模板「${tmpl.name}」吗?`)
window.confirm(
t('deleteTemplateConfirm', { name: tmpl.name }),
)
) {
deleteTemplateMutation.mutate(tmpl.key);
}
@@ -239,7 +243,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'
@@ -248,7 +252,7 @@ export function TemplatesManager() {
className='border-dashed'
>
<Plus className='mr-1.5 size-3.5' />
新增模板
{t('addTemplate')}
</Button>
</div>
)}
@@ -259,11 +263,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>
@@ -271,7 +274,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'
@@ -284,13 +287,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'
@@ -302,13 +305,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'
@@ -320,13 +323,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'
@@ -337,7 +340,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>
@@ -347,7 +350,7 @@ export function TemplatesManager() {
htmlFor='tmpl_description'
className='text-xs font-semibold'
>
模板说明与变量描述
{t('templateDescription')}
</Label>
<Input
id='tmpl_description'
@@ -361,7 +364,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'
@@ -382,16 +385,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>