mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-06 15:46:37 +08:00
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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user