'use client'; import {zodResolver} from '@hookform/resolvers/zod'; import {useEffect, useMemo} from 'react'; import {useForm, useWatch} from 'react-hook-form'; import {z} from 'zod'; import {AppModal} from '@/components/ui/app-modal'; import worldGeoJson from '@/features/dashboard/data/world-geo.json'; import type {NodeItem, NodeMutationPayload} from '@/features/nodes/types'; import { PrimaryButton, ResourceField, ResourceInput, ResourceSelect, SecondaryButton, ToggleField, } from '@/features/shared/components/resource-primitives'; type GeoJsonGeometry = { type: string; coordinates: unknown; }; type GeoJsonFeature = { geometry?: GeoJsonGeometry; properties?: { name?: string; }; }; type RegionOption = { label: string; latitude: number; longitude: number; }; const nodeEditorSchema = z .object({ type: z.enum(['edge_node', 'tunnel_relay', 'tunnel_client']), name: z .string() .trim() .min(1, '请输入节点名') .max(128, '节点名不能超过 128 个字符'), ip: z.string().trim().max(64, '节点 IP 不能超过 64 个字符'), ip_manual_override: z.boolean(), auto_update_enabled: z.boolean(), relay_bind_port: z.string().trim(), relay_vhost_http_port: z.string().trim(), relay_client_access_addr: z.string().trim(), relay_agent_access_addr: z.string().trim(), relay_client_proxy_url: z.string().trim(), relay_web_server_enabled: z.boolean(), geo_manual_override: z.boolean(), geo_region: z.string(), geo_name: z.string().trim().max(128, '位置名不能超过 128 个字符'), geo_latitude: z.string().trim(), geo_longitude: z.string().trim(), }) .superRefine((values, ctx) => { if (values.ip_manual_override && values.ip.trim() === '') { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['ip'], message: '锁定节点 IP 时必须填写节点 IP', }); } if (values.type === 'tunnel_relay') { const port = Number(values.relay_bind_port); if (Number.isNaN(port) || port < 1 || port > 65535) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['relay_bind_port'], message: '请填写正确的端口号 (1-65535)', }); } const vhostPort = Number(values.relay_vhost_http_port); if (Number.isNaN(vhostPort) || vhostPort < 1 || vhostPort > 65535) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['relay_vhost_http_port'], message: '请填写正确的端口号 (1-65535)', }); } } if (!values.geo_manual_override) { return; } const hasLatitude = values.geo_latitude !== ''; const hasLongitude = values.geo_longitude !== ''; if (hasLatitude !== hasLongitude) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['geo_latitude'], message: '纬度和经度需要同时填写', }); ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['geo_longitude'], message: '纬度和经度需要同时填写', }); return; } if (hasLatitude) { const latitude = Number(values.geo_latitude); if (Number.isNaN(latitude) || latitude < -90 || latitude > 90) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['geo_latitude'], message: '纬度必须在 -90 到 90 之间', }); } } if (hasLongitude) { const longitude = Number(values.geo_longitude); if (Number.isNaN(longitude) || longitude < -180 || longitude > 180) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['geo_longitude'], message: '经度必须在 -180 到 180 之间', }); } } }); type NodeEditorValues = z.infer; const defaultValues: NodeEditorValues = { type: 'edge_node', name: '', ip: '', ip_manual_override: false, auto_update_enabled: false, relay_bind_port: '7000', relay_vhost_http_port: '8080', relay_client_access_addr: '', relay_agent_access_addr: '', relay_client_proxy_url: '', relay_web_server_enabled: true, geo_manual_override: false, geo_region: '', geo_name: '', geo_latitude: '', geo_longitude: '', }; function collectPoints(value: unknown, points: Array<[number, number]>) { if (!Array.isArray(value)) { return; } if ( value.length >= 2 && typeof value[0] === 'number' && typeof value[1] === 'number' ) { points.push([value[0], value[1]]); return; } for (const item of value) { collectPoints(item, points); } } function getRegionCenter(feature: GeoJsonFeature) { const points: Array<[number, number]> = []; collectPoints(feature.geometry?.coordinates, points); if (points.length === 0) { return null; } let minLongitude = Number.POSITIVE_INFINITY; let maxLongitude = Number.NEGATIVE_INFINITY; let minLatitude = Number.POSITIVE_INFINITY; let maxLatitude = Number.NEGATIVE_INFINITY; for (const [longitude, latitude] of points) { minLongitude = Math.min(minLongitude, longitude); maxLongitude = Math.max(maxLongitude, longitude); minLatitude = Math.min(minLatitude, latitude); maxLatitude = Math.max(maxLatitude, latitude); } return { longitude: Number(((minLongitude + maxLongitude) / 2).toFixed(4)), latitude: Number(((minLatitude + maxLatitude) / 2).toFixed(4)), }; } function buildRegionOptions() { const features = ((worldGeoJson as { features?: GeoJsonFeature[] }) .features ?? []) as GeoJsonFeature[]; const options = new Map(); for (const feature of features) { const label = feature.properties?.name?.trim(); if (!label || options.has(label)) { continue; } const center = getRegionCenter(feature); if (!center) { continue; } options.set(label, { label, latitude: center.latitude, longitude: center.longitude, }); } return Array.from(options.values()).sort((a, b) => a.label.localeCompare(b.label), ); } function buildFormValues(node?: Partial | null): NodeEditorValues { if (!node) { return defaultValues; } return { type: node.node_type ?? 'edge_node', name: node.name ?? '', ip: node.ip ?? '', ip_manual_override: node.ip_manual_override ?? false, auto_update_enabled: node.auto_update_enabled ?? false, relay_bind_port: String(node.relay_bind_port ?? 7000), relay_vhost_http_port: String(node.relay_vhost_http_port ?? 8080), relay_client_access_addr: node.relay_client_access_addr ?? '', relay_agent_access_addr: node.relay_agent_access_addr ?? '', relay_client_proxy_url: node.relay_client_proxy_url ?? '', relay_web_server_enabled: node.relay_web_server_enabled ?? false, geo_manual_override: node.geo_manual_override ?? false, geo_region: node.geo_manual_override ? (node.geo_name ?? '') : '', 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), }; } function toPayload(values: NodeEditorValues): NodeMutationPayload { const basePayload = { node_type: values.type, name: values.name.trim(), ip: values.ip.trim(), ip_manual_override: values.ip_manual_override, auto_update_enabled: values.auto_update_enabled, relay_bind_port: values.type === 'tunnel_relay' ? Number(values.relay_bind_port) : undefined, relay_vhost_http_port: values.type === 'tunnel_relay' ? Number(values.relay_vhost_http_port) : undefined, relay_client_access_addr: values.type === 'tunnel_relay' ? values.relay_client_access_addr.trim() : undefined, relay_agent_access_addr: values.type === 'tunnel_relay' ? values.relay_agent_access_addr.trim() : undefined, relay_client_proxy_url: values.type === 'tunnel_relay' ? values.relay_client_proxy_url.trim() : undefined, relay_web_server_enabled: values.type === 'tunnel_relay' ? values.relay_web_server_enabled : undefined, }; if (!values.geo_manual_override) { return { ...basePayload, geo_manual_override: false, geo_name: '', geo_latitude: null, geo_longitude: null, }; } return { ...basePayload, geo_manual_override: true, geo_name: values.geo_name.trim(), geo_latitude: values.geo_latitude.trim() === '' ? null : Number(values.geo_latitude), geo_longitude: values.geo_longitude.trim() === '' ? null : Number(values.geo_longitude), }; } export function NodeEditorModal({ isOpen, node, isSubmitting, title, description, submitLabel, onClose, onSubmit, }: { isOpen: boolean; node?: Partial | null; isSubmitting: boolean; title: string; description: string; submitLabel: string; onClose: () => void; onSubmit: (payload: NodeMutationPayload) => void; }) { const form = useForm({ resolver: zodResolver(nodeEditorSchema), defaultValues, }); const watchedType = useWatch({ control: form.control, name: 'type', }); const watchedAutoUpdate = useWatch({ control: form.control, name: 'auto_update_enabled', }); const watchedIPManualOverride = useWatch({ control: form.control, name: 'ip_manual_override', }); const watchedGeoManualOverride = useWatch({ control: form.control, name: 'geo_manual_override', }); const regionOptions = useMemo(() => buildRegionOptions(), []); useEffect(() => { if (!isOpen) { return; } form.reset(buildFormValues(node)); }, [form, isOpen, node]); const ipField = form.register('ip'); const handleSubmit = form.handleSubmit((values) => { onSubmit(toPayload(values)); }); return ( 取消 {isSubmitting ? '保存中...' : submitLabel} } >
{watchedType === 'tunnel_relay' && (

中继配置 (Relay Config)

form.setValue('relay_web_server_enabled', checked, { shouldDirty: true, shouldValidate: true, }) } />
)} { ipField.onChange(event); const nextIP = event.target.value.trim(); const previousIP = (node?.ip ?? '').trim(); if (nextIP !== '' && nextIP !== previousIP) { form.setValue('ip_manual_override', true, { shouldDirty: true, shouldValidate: true, }); } }} /> form.setValue('ip_manual_override', checked, { shouldDirty: true, shouldValidate: true, }) } /> {watchedType === 'edge_node' && ( form.setValue('auto_update_enabled', checked, { shouldDirty: true, shouldValidate: true, }) } /> )} { form.setValue('geo_manual_override', checked, { shouldDirty: true, shouldValidate: true, }); if (!checked) { form.setValue('geo_region', '', { shouldDirty: true }); form.setValue('geo_name', '', { shouldDirty: true }); form.setValue('geo_latitude', '', { shouldDirty: true }); form.setValue('geo_longitude', '', { shouldDirty: true }); } }} /> {watchedGeoManualOverride ? ( { const regionName = event.target.value; form.setValue('geo_region', regionName, { shouldDirty: true, }); const selectedRegion = regionOptions.find( (item) => item.label === regionName, ); if (!selectedRegion) { return; } form.setValue('geo_name', selectedRegion.label, { shouldDirty: true, shouldValidate: true, }); form.setValue('geo_latitude', String(selectedRegion.latitude), { shouldDirty: true, shouldValidate: true, }); form.setValue( 'geo_longitude', String(selectedRegion.longitude), { shouldDirty: true, shouldValidate: true, }, ); }} > {regionOptions.map((option) => ( ))} ) : null}
); }