import { ReloadOutlined, SaveOutlined, WarningOutlined } from '@ant-design/icons'; import { Alert, App as AntApp, Button, Form, Input, Select, Skeleton, Space, Switch, Table, Typography } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { type BlockerFunction, useBeforeUnload, useBlocker } from 'react-router-dom'; import { PageHeader } from '../components/common/PageHeader'; import { SectionCard } from '../components/common/SectionCard'; import { StatusTag } from '../components/common/StatusTag'; import { useAppContext } from '../frontend/app-context'; import { getDnsCurrentValues } from '../frontend/domain-model.js'; import { useI18n } from '../frontend/i18n/react'; import { api } from '../frontend/services/api'; import type { DnsRecord, RuntimeConfig } from '../frontend/types'; export default function Settings() { const { message, modal } = AntApp.useApp(); const { locale, t } = useI18n(); const { user: me } = useAppContext(); const [form] = Form.useForm>(); const appBaseUrl = Form.useWatch('appBaseUrl', form); const [settings, setSettings] = useState(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [loadError, setLoadError] = useState(''); const [dirty, setDirty] = useState(false); const leaveConfirmation = useRef<{ destroy: () => void } | null>(null); const leaveCopy = useMemo(() => locale.startsWith('en') ? { title: 'Discard unsaved changes?', content: 'You have unsaved settings. Leaving this page will discard them.', confirm: 'Leave page', cancel: 'Keep editing' } : { title: '放弃未保存的更改?', content: '当前设置尚未保存,离开此页面将丢失这些更改。', confirm: '离开页面', cancel: '继续编辑' }, [locale]); const copy = locale.startsWith('en') ? settingsCopyEn : settingsCopyZh; const appUrlError = publicUrlSecurityError(appBaseUrl, locale); const shouldBlock = useCallback(({ currentLocation, nextLocation }) => ( dirty && locationIdentity(currentLocation) !== locationIdentity(nextLocation) ), [dirty]); const blocker = useBlocker(shouldBlock); useBeforeUnload(useCallback((event) => { if (!dirty) return; event.preventDefault(); event.returnValue = ''; }, [dirty]), { capture: true }); const loadSettings = useCallback(async () => { setLoading(true); setLoadError(''); try { if (me?.role !== 'admin') return; const result = await api.adminSettings(); setSettings(result.settings); form.setFieldsValue(result.settings); setDirty(false); } catch (error) { setLoadError(error instanceof Error ? error.message : t('common.error')); } finally { setLoading(false); } }, [form, me?.role, t]); useEffect(() => { void loadSettings(); }, [loadSettings]); useEffect(() => { if (blocker.state !== 'blocked' || leaveConfirmation.current) return; leaveConfirmation.current = modal.confirm({ title: leaveCopy.title, content: leaveCopy.content, okText: leaveCopy.confirm, cancelText: leaveCopy.cancel, okButtonProps: { danger: true }, onOk: () => { leaveConfirmation.current = null; setDirty(false); blocker.proceed(); }, onCancel: () => { leaveConfirmation.current = null; blocker.reset(); }, afterClose: () => { leaveConfirmation.current = null; } }); }, [blocker, leaveCopy, modal]); useEffect(() => () => { leaveConfirmation.current?.destroy(); leaveConfirmation.current = null; }, []); async function save(values: Partial) { setSaving(true); try { const result = await api.saveAdminSettings(values); setSettings(result.settings); form.setFieldsValue(result.settings); setDirty(false); message.success(t('actions.settingsSaved')); } catch (error) { message.error(error instanceof Error ? error.message : t('common.error')); } finally { setSaving(false); } } if (!loading && me?.role !== 'admin') { return ( ); } const checkColumns: ColumnsType = [ { title: t('settings.checkItem'), dataIndex: 'label', width: 160 }, { title: t('dnsRecord.hostname'), dataIndex: 'host', width: 180, render: (value: string) => {value || '—'} }, { title: t('dnsRecord.targetValue'), dataIndex: 'value', width: 220, render: (value: string) => {value || '—'} }, { title: t('dnsRecord.currentValue'), width: 260, render: (_, record) => { const values: string[] = getDnsCurrentValues(record); return values.length ? {values.map((value) => {value})} : {t('dnsRecord.emptyCurrent')}; } }, { title: t('common.status'), width: 120, render: (_, record) => } ]; const checkData = settings?.systemChecks?.ptr ? [settings.systemChecks.ptr] : []; return ( } loading={saving} disabled={!dirty || loading} onClick={() => form.submit()} style={{ minHeight: 44 }}> {t('settings.save')} } /> {loadError ? } onClick={() => void loadSettings()}>{t('common.refresh')}} /> : null} {loading ? : (
setDirty(true)}> {appUrlError ? : null}
publicUrlSecurityError(value, locale) ? Promise.reject(new Error(publicUrlSecurityError(value, locale))) : Promise.resolve() }]} >
{new Date(settings.systemChecks.checkedAt).toLocaleString()} : null} > {locale.startsWith('en') ? 'Danger zone' : '高风险设置'}}> )} ); } function locationIdentity(location: { pathname: string; search: string; hash: string }) { return `${location.pathname}${location.search}${location.hash}`; } function envExtra(name: string) { return {name}; } function publicUrlSecurityError(value: unknown, locale: string) { if (!value) return ''; let url: URL; try { url = new URL(String(value)); } catch { return locale.startsWith('en') ? 'Enter a valid absolute URL.' : '请输入有效的完整访问地址。'; } const local = url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '::1'; if (url.protocol !== 'https:' && !local) { return locale.startsWith('en') ? 'Public instances must use HTTPS so API credentials are never sent over plaintext HTTP.' : '公网实例必须使用 HTTPS,避免 API 密钥通过明文 HTTP 传输。'; } return ''; } const settingsCopyZh = { publicUrl: '公开访问地址', publicUrlInvalid: '公开访问地址不安全', publicUrlRequired: '请输入公开访问地址', mailHostname: '邮件服务器主机名', sendingIp: '发信公网 IP', spfMechanisms: '默认 SPF 扩展', dmarcPolicy: '默认 DMARC 策略', dmarcReports: 'DMARC 报告邮箱', unsubscribeEmail: '退订邮箱', unsubscribeUrl: '退订链接', abuseMailbox: '滥用举报邮箱', csaMailbox: 'CSA 投诉邮箱', bounceAddress: '退信地址', oneClickUnsubscribe: '启用一键退订', feedbackId: '启用 Feedback-ID', requireVerifiedDomain: '仅允许已验证域名发信', bounceEnvelope: '启用退信信封地址' }; const settingsCopyEn: typeof settingsCopyZh = { publicUrl: 'Public URL', publicUrlInvalid: 'Public URL is insecure', publicUrlRequired: 'Enter the public URL', mailHostname: 'Mail server hostname', sendingIp: 'Public sending IP', spfMechanisms: 'Default SPF mechanisms', dmarcPolicy: 'Default DMARC policy', dmarcReports: 'DMARC report mailbox', unsubscribeEmail: 'Unsubscribe mailbox', unsubscribeUrl: 'Unsubscribe URL', abuseMailbox: 'Abuse mailbox', csaMailbox: 'CSA complaints mailbox', bounceAddress: 'Bounce address', oneClickUnsubscribe: 'Enable one-click unsubscribe', feedbackId: 'Enable Feedback-ID', requireVerifiedDomain: 'Require a verified sending domain', bounceEnvelope: 'Enable bounce envelope address' };