Files
OpenFlare/frontend/components/common/settings/system-tab.tsx
T
ryan 5a8722ff07 feat(frontend): 控制台 next-intl 中英双语
接入无 URL 前缀的 zh-CN/en,顶栏与外观设置可切换语言;选择写入 cookie 后刷新生效。
2026-08-16 16:49:16 +08:00

561 lines
19 KiB
TypeScript

'use client';
import { useEffect, useState } from 'react';
import {
useMutation,
useQueryClient,
type UseQueryResult,
} from '@tanstack/react-query';
import { Globe, Loader2, Mail, Search, Server, Sparkles } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
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>;
systemConfigsQuery: UseQueryResult<SystemConfig[], Error>;
}
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('');
const [smtpPort, setSmtpPort] = useState('');
const [smtpUsername, setSmtpUsername] = useState('');
const [smtpPassword, setSmtpPassword] = useState('');
const [smtpTestOpen, setSmtpTestOpen] = useState(false);
const [smtpTestTo, setSmtpTestTo] = useState('');
const [smtpTestLog, setSmtpTestLog] = useState('');
const [smtpTestSuccess, setSmtpTestSuccess] = useState<boolean | null>(null);
const [smtpTestError, setSmtpTestError] = useState('');
useEffect(() => {
if (systemConfigsQuery.data) {
setServerAddress(configs['server_address']?.value || '');
setSiteName(configs['site_name']?.value || '');
setSmtpHost(configs['smtp_host']?.value || '');
setSmtpPort(configs['smtp_port']?.value || '587');
setSmtpUsername(configs['smtp_username']?.value || '');
setSmtpPassword(configs['smtp_password']?.value || '');
}
}, [systemConfigsQuery.data, configs]);
const handleDetectAddress = () => {
if (typeof window !== 'undefined') {
setServerAddress(window.location.origin);
toast.success(t('addressAutoDetected'));
}
};
const updateConfigMutation = useMutation({
mutationFn: async ({ key, value }: { key: string; value: boolean }) => {
const config = configs[key];
if (!config) {
throw new Error(`缺少配置项: ${key}`);
}
await services.adminSystemConfig.updateSystemConfig(key, {
value: value ? 'true' : 'false',
description: config.description,
});
},
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: ['admin', 'system-configs'],
});
await queryClient.invalidateQueries({ queryKey: ['public-config'] });
toast.success(t('configUpdated'));
},
onError: (error: Error) => {
toast.error(error.message || t('updateConfigFailed'));
},
});
const saveSystemMutation = useMutation({
mutationFn: async () => {
const currentAddrCfg = configs['server_address'];
const currentSiteCfg = configs['site_name'];
await Promise.all([
services.adminSystemConfig.updateSystemConfig('server_address', {
value: serverAddress,
description: currentAddrCfg?.description || '服务器地址',
}),
services.adminSystemConfig.updateSystemConfig('site_name', {
value: siteName,
description: currentSiteCfg?.description || '系统平台的展示名称',
}),
]);
},
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({
queryKey: ['admin', 'system-configs'],
}),
queryClient.invalidateQueries({ queryKey: ['public-config'] }),
]);
toast.success(t('generalConfigSaved'));
},
onError: (error: Error) => {
toast.error(error.message || t('saveConfigFailed'));
},
});
const handleSystemSave = (e: React.FormEvent) => {
e.preventDefault();
saveSystemMutation.mutate();
};
const saveSmtpMutation = useMutation({
mutationFn: async () => {
const updates = [
{ key: 'smtp_host', value: smtpHost },
{ key: 'smtp_port', value: smtpPort },
{ key: 'smtp_username', value: smtpUsername },
{ key: 'smtp_password', value: smtpPassword },
];
for (const update of updates) {
const currentCfg = configs[update.key];
if (
update.key === 'smtp_password' &&
(update.value === '' || update.value === '******')
) {
// If already configured and sent empty or mask, skip updating it (keep existing)
if (currentCfg && currentCfg.value === '******') {
continue;
}
}
await services.adminSystemConfig.updateSystemConfig(update.key, {
value: update.value,
description: currentCfg?.description || '',
});
}
},
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: ['admin', 'system-configs'],
});
toast.success(t('smtpConfigSaved'));
},
onError: (error: Error) => {
toast.error(error.message || t('saveConfigFailed'));
},
});
const handleSmtpSave = (e: React.FormEvent) => {
e.preventDefault();
saveSmtpMutation.mutate();
};
const testSmtpMutation = useMutation({
mutationFn: async () => {
setSmtpTestLog(t('connectingTest'));
setSmtpTestSuccess(null);
setSmtpTestError('');
const res = await services.adminSystemConfig.testSMTP({
smtp_host: smtpHost,
smtp_port: parseInt(smtpPort, 10) || 587,
smtp_username: smtpUsername,
smtp_password: smtpPassword,
to: smtpTestTo,
});
return res;
},
onSuccess: (data) => {
setSmtpTestLog(data.log);
if (data.success) {
setSmtpTestSuccess(true);
toast.success(t('testEmailSent'));
} else {
setSmtpTestSuccess(false);
setSmtpTestError(data.error || t('sendFailedCheckConfig'));
toast.error(t('testEmailFailed'));
}
},
onError: (error: Error) => {
setSmtpTestSuccess(false);
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(t('enterTargetEmail'));
return;
}
testSmtpMutation.mutate();
};
const indexingEnabled =
configs['search_engine_indexing_enabled']?.value === 'true';
return (
<div className='space-y-8'>
{/* 通用设置 */}
<Card className='border border-dashed shadow-sm'>
<CardHeader className='border-b border-dashed pb-4'>
<div className='flex items-center gap-2'>
<div className='rounded-lg bg-primary/10 p-1.5 text-primary'>
<Server className='size-4' />
</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'>
<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'
type='text'
value={siteName}
onChange={(e) => setSiteName(e.target.value)}
placeholder='例如: OpenFlare'
className='border-dashed bg-card text-xs'
/>
<p className='text-[11px] leading-relaxed text-muted-foreground'>
{t('siteNameDesc')}
</p>
</div>
<div className='flex flex-col gap-1.5'>
<div className='flex items-center justify-between gap-3'>
<Label
htmlFor='server_address'
className='text-xs font-semibold'
>
{t('serverAddress')}
</Label>
<Button
type='button'
variant='link'
size='sm'
onClick={handleDetectAddress}
className='h-auto px-0 text-xs'
>
<Sparkles data-icon='inline-start' />
{t('useCurrentDomain')}
</Button>
</div>
<Input
id='server_address'
type='url'
value={serverAddress}
onChange={(e) => setServerAddress(e.target.value)}
placeholder='例如: https://example.com'
className='border-dashed bg-card text-xs'
/>
<p className='text-[11px] leading-relaxed text-muted-foreground'>
{t('serverAddressDesc')}
</p>
</div>
</div>
<div className='flex flex-col gap-3 rounded-lg border border-dashed p-3 sm:flex-row sm:items-center sm:justify-between'>
<div className='flex min-w-0 items-start gap-3'>
<div className='rounded-md bg-muted p-1.5 text-muted-foreground'>
<Search className='size-4' />
</div>
<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
? t('indexingAllowed')
: t('indexingDenied')}
</Badge>
</div>
<p className='text-[11px] leading-relaxed text-muted-foreground'>
{t('searchEngineIndexingDesc')}
</p>
</div>
</div>
<Switch
aria-label='允许搜索引擎抓取与收录'
checked={indexingEnabled}
disabled={
updateConfigMutation.isPending || systemConfigsQuery.isPending
}
onCheckedChange={(checked) =>
updateConfigMutation.mutate({
key: 'search_engine_indexing_enabled',
value: checked,
})
}
/>
</div>
<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'
size='sm'
disabled={saveSystemMutation.isPending}
>
{saveSystemMutation.isPending ? (
<>
<Loader2
data-icon='inline-start'
className='animate-spin'
/>
{t('saving')}
</>
) : (
t('saveConfig')
)}
</Button>
</div>
</form>
</CardContent>
</Card>
{/* SMTP 邮件设置 */}
<Card className='border border-dashed shadow-sm'>
<CardHeader className='border-b border-dashed pb-4'>
<div className='flex items-center gap-2'>
<div className='p-1.5 rounded-lg bg-primary/10 text-primary'>
<Mail className='size-4' />
</div>
<div>
<CardTitle className='text-base font-semibold'>
{t('smtpSettings')}
</CardTitle>
<CardDescription className='text-xs'>
{t('smtpSettingsDesc')}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className='pt-6'>
<form onSubmit={handleSmtpSave} 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'>
{t('smtpHost')}
</Label>
<Input
id='smtp_host'
type='text'
value={smtpHost}
onChange={(e) => setSmtpHost(e.target.value)}
placeholder='例如: smtp.example.com'
className='bg-card border-dashed text-xs'
/>
</div>
<div className='space-y-1.5'>
<Label htmlFor='smtp_port' className='text-xs font-semibold'>
{t('smtpPort')}
</Label>
<Input
id='smtp_port'
type='number'
value={smtpPort}
onChange={(e) => setSmtpPort(e.target.value)}
placeholder='例如: 587 或 465'
className='bg-card border-dashed text-xs'
/>
</div>
<div className='space-y-1.5'>
<Label
htmlFor='smtp_username'
className='text-xs font-semibold'
>
{t('smtpUsername')}
</Label>
<Input
id='smtp_username'
type='text'
value={smtpUsername}
onChange={(e) => setSmtpUsername(e.target.value)}
placeholder='例如: sender@example.com'
className='bg-card border-dashed text-xs'
/>
</div>
<div className='space-y-1.5'>
<Label
htmlFor='smtp_password'
className='text-xs font-semibold'
>
{t('smtpPassword')}
</Label>
<Input
id='smtp_password'
type='password'
value={smtpPassword}
onChange={(e) => setSmtpPassword(e.target.value)}
placeholder={
configs['smtp_password']?.value === '******'
? t('smtpPasswordMasked')
: t('smtpPasswordPlaceholder')
}
className='bg-card border-dashed text-xs'
/>
</div>
</div>
<div className='flex justify-end gap-2 pt-4 border-t border-dashed'>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => {
setSmtpTestOpen(true);
setSmtpTestTo('');
setSmtpTestLog('');
setSmtpTestSuccess(null);
setSmtpTestError('');
}}
disabled={saveSmtpMutation.isPending}
>
{t('testSend')}
</Button>
<Button
type='submit'
size='sm'
disabled={saveSmtpMutation.isPending}
>
{saveSmtpMutation.isPending ? (
<>
<Loader2 className='mr-1.5 size-3.5 animate-spin' />
{t('saving')}
</>
) : (
t('saveConfig')
)}
</Button>
</div>
</form>
</CardContent>
</Card>
<Dialog open={smtpTestOpen} onOpenChange={setSmtpTestOpen}>
<DialogContent className='max-w-lg border border-dashed'>
<DialogHeader>
<DialogTitle className='text-base font-semibold'>
{t('smtpTestTitle')}
</DialogTitle>
<DialogDescription className='text-xs'>
{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'
type='email'
required
value={smtpTestTo}
onChange={(e) => setSmtpTestTo(e.target.value)}
placeholder='例如: receiver@example.com'
className='bg-card border-dashed text-xs'
disabled={testSmtpMutation.isPending}
/>
</div>
{smtpTestLog && (
<div className='space-y-1.5'>
<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>
</div>
)}
{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'>
{t('testFailed')}: {smtpTestError}
</div>
)}
<DialogFooter className='gap-2 sm:gap-0 border-t border-dashed pt-4'>
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => setSmtpTestOpen(false)}
disabled={testSmtpMutation.isPending}
>
{t('close')}
</Button>
<Button
type='submit'
size='sm'
disabled={testSmtpMutation.isPending}
>
{testSmtpMutation.isPending ? (
<>
<Loader2 className='mr-1.5 size-3.5 animate-spin' />
{t('testing')}
</>
) : (
t('startTest')
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
);
}