'use client'; import { useEffect } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Switch } from '@/components/ui/switch'; import type { NodeItem, NodeMutationPayload, NodeType, } from '@/lib/services/openflare'; const nodeSchema = z .object({ node_type: z.enum(['edge_node', 'tunnel_relay', 'tunnel_client']), name: z.string().trim().min(1, '请输入节点名称').max(255), ip: z.string(), ip_manual_override: z.boolean(), auto_update_enabled: z.boolean(), geo_manual_override: z.boolean(), geo_name: z.string(), geo_latitude: z.string(), geo_longitude: z.string(), relay_bind_port: z.string(), relay_vhost_http_port: z.string(), }) .superRefine((value, context) => { if (value.ip_manual_override && !value.ip.trim()) { context.addIssue({ code: z.ZodIssueCode.custom, path: ['ip'], message: '锁定 IP 时必须填写节点 IP', }); } if (value.node_type === 'tunnel_relay') { const bindPort = Number(value.relay_bind_port); const vhostPort = Number(value.relay_vhost_http_port); if (!Number.isFinite(bindPort) || bindPort <= 0) { context.addIssue({ code: z.ZodIssueCode.custom, path: ['relay_bind_port'], message: '请输入有效的绑定端口', }); } if (!Number.isFinite(vhostPort) || vhostPort <= 0) { context.addIssue({ code: z.ZodIssueCode.custom, path: ['relay_vhost_http_port'], message: '请输入有效的 VHost 端口', }); } } }); type NodeFormValues = z.infer; const defaultForm: NodeFormValues = { node_type: 'edge_node', name: '', ip: '', ip_manual_override: false, auto_update_enabled: false, geo_manual_override: false, geo_name: '', geo_latitude: '', geo_longitude: '', relay_bind_port: '7000', relay_vhost_http_port: '8080', }; function buildFormValues(node?: NodeItem | null): NodeFormValues { if (!node) return defaultForm; return { node_type: node.node_type, name: node.name, ip: node.ip, ip_manual_override: node.ip_manual_override, auto_update_enabled: node.auto_update_enabled, geo_manual_override: node.geo_manual_override, geo_name: node.geo_name, geo_latitude: node.geo_latitude === undefined || node.geo_latitude === null ? '' : String(node.geo_latitude), geo_longitude: node.geo_longitude === undefined || node.geo_longitude === null ? '' : String(node.geo_longitude), relay_bind_port: String(node.relay_bind_port || 7000), relay_vhost_http_port: String(node.relay_vhost_http_port || 8080), }; } function toPayload(form: NodeFormValues): NodeMutationPayload { const base: NodeMutationPayload = { node_type: form.node_type, name: form.name.trim(), ip: form.ip.trim(), ip_manual_override: form.ip_manual_override, auto_update_enabled: form.auto_update_enabled, geo_manual_override: form.geo_manual_override, geo_name: form.geo_manual_override ? form.geo_name.trim() : '', geo_latitude: form.geo_manual_override && form.geo_latitude ? Number(form.geo_latitude) : null, geo_longitude: form.geo_manual_override && form.geo_longitude ? Number(form.geo_longitude) : null, }; if (form.node_type === 'tunnel_relay') { return { ...base, relay_bind_port: Number(form.relay_bind_port), relay_vhost_http_port: Number(form.relay_vhost_http_port), relay_web_server_enabled: true, }; } return base; } export function NodeEditorDialog({ open, node, submitting, onClose, onSubmit, }: { open: boolean; node: NodeItem | null; submitting: boolean; onClose: () => void; onSubmit: (payload: NodeMutationPayload) => Promise; }) { const form = useForm({ resolver: zodResolver(nodeSchema), defaultValues: defaultForm, }); useEffect(() => { if (open) { form.reset(buildFormValues(node)); } }, [form, open, node]); const nodeType = form.watch('node_type'); const ipManualOverride = form.watch('ip_manual_override'); const geoManualOverride = form.watch('geo_manual_override'); const handleSubmit = form.handleSubmit(async (values) => { await onSubmit(toPayload(values)); }); return ( !next && onClose()}> {node ? '编辑节点' : '新增节点'} 预创建节点后可在详情页查看专属 Token 与部署命令。
void handleSubmit(event)} className='space-y-4' >
{form.formState.errors.name ? (

{form.formState.errors.name.message}

) : null}
{form.formState.errors.ip ? (

{form.formState.errors.ip.message}

) : null}

锁定 IP

启用后管理端不再自动覆盖 IP

form.setValue('ip_manual_override', checked) } />

自动更新 Agent

启用后节点将自动拉取正式版更新

form.setValue('auto_update_enabled', checked) } />
{nodeType === 'tunnel_relay' ? (
{form.formState.errors.relay_bind_port ? (

{form.formState.errors.relay_bind_port.message}

) : null}
{form.formState.errors.relay_vhost_http_port ? (

{form.formState.errors.relay_vhost_http_port.message}

) : null}
) : null}

手动地图点位

用于总览地图展示

form.setValue('geo_manual_override', checked) } />
{geoManualOverride ? (
) : null}
); }