'use client'; import { zodResolver } from '@hookform/resolvers/zod'; import { useMemo, useEffect } 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({ name: z .string() .trim() .min(1, '请输入节点名') .max(128, '节点名不能超过 128 个字符'), ip: z.string().trim().max(64, '节点 IP 不能超过 64 个字符'), auto_update_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.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 = { name: '', ip: '', auto_update_enabled: false, 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 { name: node.name ?? '', ip: node.ip ?? '', auto_update_enabled: node.auto_update_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 { if (!values.geo_manual_override) { return { name: values.name.trim(), ip: values.ip.trim(), auto_update_enabled: values.auto_update_enabled, geo_manual_override: false, geo_name: '', geo_latitude: null, geo_longitude: null, }; } return { name: values.name.trim(), ip: values.ip.trim(), auto_update_enabled: values.auto_update_enabled, 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 watchedAutoUpdate = useWatch({ control: form.control, name: 'auto_update_enabled', }); 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 handleSubmit = form.handleSubmit((values) => { onSubmit(toPayload(values)); }); return ( 取消 {isSubmitting ? '保存中...' : submitLabel} } >
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}
); }