import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Alert, Button, Col, Form, Input, InputNumber, Modal, Row, Select, Switch, message, } from 'antd'; import type { NodeRecord } from '@/api/queries/useNodesQuery'; import type { RemoteInboundOption } from '@/api/queries/useNodeMutations'; import type { Msg } from '@/utils'; import { NodeFormSchema, type NodeFormValues, type ProbeResult } from '@/schemas/node'; import { antdRule } from '@/utils/zodForm'; import { useOutboundTagGroups } from '@/api/queries/useOutboundTags'; import './NodeFormModal.css'; type Mode = 'add' | 'edit'; interface NodeFormModalProps { open: boolean; mode: Mode; node: NodeRecord | null; testConnection: (payload: Partial) => Promise>; fetchFingerprint: (payload: Partial) => Promise>; fetchInbounds: (payload: Partial) => Promise>; save: (payload: Partial) => Promise>; onOpenChange: (open: boolean) => void; } function defaultValues(): NodeFormValues { return { id: 0, name: '', remark: '', scheme: 'https', address: '', port: 2053, basePath: '/', apiToken: '', enable: true, allowPrivateAddress: false, tlsVerifyMode: 'verify', pinnedCertSha256: '', inboundSyncMode: 'all', inboundTags: [], outboundTag: '', }; } export default function NodeFormModal({ open, mode, node, testConnection, fetchFingerprint, fetchInbounds, save, onOpenChange, }: NodeFormModalProps) { const { t } = useTranslation(); const [form] = Form.useForm(); const [messageApi, messageContextHolder] = message.useMessage(); const [submitting, setSubmitting] = useState(false); const [testing, setTesting] = useState(false); const [fetchingPin, setFetchingPin] = useState(false); const [fetchingInbounds, setFetchingInbounds] = useState(false); const [inboundOptions, setInboundOptions] = useState([]); const [testResult, setTestResult] = useState(null); const scheme = Form.useWatch('scheme', form) ?? 'https'; const tlsVerifyMode = Form.useWatch('tlsVerifyMode', form) ?? 'verify'; const inboundSyncMode = Form.useWatch('inboundSyncMode', form) ?? 'all'; const { data: outboundGroups } = useOutboundTagGroups({ excludeBlackhole: true }); // Outbounds and balancers share one picker (like the panel-outbound selector); // when balancers exist they get a labeled group so it's clear the selection // routes through a balancer. Empty falls back to the placeholder ("Direct // connection") rather than a synthetic option, so it can't read as a second // "direct" next to a real freedom outbound. const outboundOptions = useMemo< ({ label: string; value: string } | { label: string; options: { label: string; value: string }[] })[] >(() => { const outOpts = (outboundGroups?.outbounds ?? []).map((tag) => ({ label: tag, value: tag })); if (!outboundGroups?.balancers.length) return outOpts; return [ { label: t('pages.xray.Outbounds'), options: outOpts }, { label: t('pages.xray.Balancers'), options: outboundGroups.balancers.map((tag) => ({ label: tag, value: tag })) }, ]; }, [outboundGroups, t]); useEffect(() => { if (!open) return; const base = defaultValues(); const next: NodeFormValues = mode === 'edit' && node ? { ...base, ...(node as unknown as Partial), id: node.id, scheme: (node.scheme as 'http' | 'https') || base.scheme, inboundSyncMode: (node.inboundSyncMode as 'all' | 'selected') || base.inboundSyncMode, inboundTags: node.inboundTags ?? [], } : base; if (next.scheme === 'http') next.tlsVerifyMode = 'skip'; form.resetFields(); form.setFieldsValue(next); setInboundOptions((next.inboundTags || []).map((tag) => ({ tag }))); setTestResult(null); }, [open, mode, node, form]); const title = useMemo( () => (mode === 'edit' ? t('pages.nodes.editNode') : t('pages.nodes.addNode')), [mode, t], ); function buildPayload(values: NodeFormValues): Partial { return { id: values.id || 0, name: values.name.trim(), remark: values.remark?.trim() || '', scheme: values.scheme, address: values.address.trim(), port: values.port, basePath: values.basePath.trim() || '/', apiToken: values.apiToken.trim(), enable: values.enable, allowPrivateAddress: values.allowPrivateAddress, tlsVerifyMode: values.tlsVerifyMode, pinnedCertSha256: values.tlsVerifyMode === 'pin' ? values.pinnedCertSha256.trim() : '', inboundSyncMode: values.inboundSyncMode, inboundTags: values.inboundSyncMode === 'selected' ? values.inboundTags : [], outboundTag: values.outboundTag || '', }; } async function onTest() { try { await form.validateFields(['address', 'port']); } catch { return; } setTesting(true); setTestResult(null); try { const payload = buildPayload(form.getFieldsValue(true)); const msg = await testConnection(payload); if (msg?.success && msg.obj) { setTestResult(msg.obj); } else { setTestResult({ status: 'offline', error: msg?.msg || 'unknown error' }); } } finally { setTesting(false); } } async function onFetchPin() { try { await form.validateFields(['address', 'port']); } catch { return; } setFetchingPin(true); try { const payload = buildPayload(form.getFieldsValue(true)); const msg = await fetchFingerprint(payload); if (msg?.success && msg.obj) { form.setFieldValue('pinnedCertSha256', msg.obj); messageApi.success(t('pages.nodes.pinFetched')); } else { messageApi.error(msg?.msg || t('pages.nodes.pinFetchFailed')); } } finally { setFetchingPin(false); } } async function onFetchInbounds() { try { await form.validateFields(['name', 'address', 'port', 'apiToken']); } catch { return; } setFetchingInbounds(true); try { const msg = await fetchInbounds(buildPayload(form.getFieldsValue(true))); if (msg?.success && Array.isArray(msg.obj)) { setInboundOptions(msg.obj); messageApi.success(t('pages.nodes.inboundsLoaded', { count: msg.obj.length })); } else { messageApi.error(msg?.msg || t('pages.nodes.inboundsLoadFailed')); } } finally { setFetchingInbounds(false); } } async function onFinish(values: NodeFormValues) { const result = NodeFormSchema.safeParse(values); if (!result.success) { messageApi.error(t(result.error.issues[0]?.message ?? 'pages.nodes.toasts.fillRequired')); return; } setSubmitting(true); try { const payload = buildPayload(result.data); const test = await testConnection(payload); const probe = test?.success ? test.obj : null; if (!probe || probe.status !== 'online') { setTestResult(probe ?? { status: 'offline', error: test?.msg || t('pages.nodes.connectionFailed') }); return; } setTestResult(probe); const msg = await save(payload); if (msg?.success) { onOpenChange(false); } } finally { setSubmitting(false); } } function close() { if (!submitting) onOpenChange(false); } return ( <> {messageContextHolder} form.submit()} onCancel={close} >
( <> {menu} )} options={inboundOptions.map((inbound) => ({ value: inbound.tag, label: `${inbound.remark || inbound.tag}${inbound.protocol ? ` (${inbound.protocol}:${inbound.port || 0})` : ''}`, }))} /> )}
{testResult && (
{testResult.status === 'online' ? ( ) : ( )}
)}
); }