diff --git a/apps/admin/app/dashboard/nodes/node-form.tsx b/apps/admin/app/dashboard/nodes/node-form.tsx index 866e9f0..87dc4e9 100644 --- a/apps/admin/app/dashboard/nodes/node-form.tsx +++ b/apps/admin/app/dashboard/nodes/node-form.tsx @@ -62,11 +62,6 @@ const buildSchema = (t: ReturnType) => export type NodeFormValues = z.infer>; -async function getServers(): Promise { - const { data } = await filterServerList({ page: 1, size: 1000 }); - return (data?.data?.list || []) as ServerRow[]; -} - export default function NodeForm(props: { trigger: string; title: string; @@ -147,7 +142,6 @@ export default function NodeForm(props: { if (!allowed.includes(form.getValues('protocol') as ProtocolName)) { form.setValue('protocol', '' as any); } - // Do not auto-fill port here; handled in handleProtocolChange } function handleProtocolChange(nextProto?: ProtocolName | null) { @@ -164,15 +158,6 @@ export default function NodeForm(props: { } } - async function submit(values: NodeFormValues) { - const ok = await onSubmit(values); - if (ok) { - form.reset(); - setOpen(false); - } - return ok; - } - return ( @@ -310,14 +295,11 @@ export default function NodeForm(props: { diff --git a/apps/admin/app/dashboard/servers/form-schema.ts b/apps/admin/app/dashboard/servers/form-schema.ts index 1e4ef16..8dbea90 100644 --- a/apps/admin/app/dashboard/servers/form-schema.ts +++ b/apps/admin/app/dashboard/servers/form-schema.ts @@ -190,6 +190,8 @@ export const formSchema = z.object({ protocols: z.array(protocolApiScheme), }); +export type ServerFormValues = z.infer; + export type ProtocolType = (typeof protocols)[number]; export function getProtocolDefaultConfig(proto: ProtocolType) { diff --git a/apps/admin/app/dashboard/servers/server-form.tsx b/apps/admin/app/dashboard/servers/server-form.tsx index 3ed03a7..f288957 100644 --- a/apps/admin/app/dashboard/servers/server-form.tsx +++ b/apps/admin/app/dashboard/servers/server-form.tsx @@ -32,7 +32,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/componen import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input'; import { Icon } from '@workspace/ui/custom-components/icon'; import { useTranslations } from 'next-intl'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; import { useForm, useWatch } from 'react-hook-form'; import { toast } from 'sonner'; import { @@ -44,102 +44,104 @@ import { LABELS, protocols as PROTOCOLS, SECURITY, + ServerFormValues, SS_CIPHERS, TRANSPORTS, TUIC_CONGESTION, TUIC_UDP_RELAY_MODES, } from './form-schema'; -interface ServerFormProps { - onSubmit: (data: T) => Promise | boolean; - initialValues?: T | any; - loading?: boolean; - trigger: string; - title: string; -} - function titleCase(s: string) { return s.charAt(0).toUpperCase() + s.slice(1); } -function normalizeValues(raw: any) { - return { - name: raw?.name ?? '', - address: raw?.address ?? '', - country: raw?.country ?? '', - city: raw?.city ?? '', - ratio: Number(raw?.ratio ?? 1), - protocols: Array.isArray(raw?.protocols) ? raw.protocols : [], - }; -} - -export default function ServerForm({ - onSubmit, - initialValues, - loading, - trigger, - title, -}: Readonly>) { +export default function ServerForm(props: { + trigger: string; + title: string; + loading?: boolean; + initialValues?: Partial; + onSubmit: (values: ServerFormValues) => Promise | boolean; +}) { + const { trigger, title, loading, initialValues, onSubmit } = props; const t = useTranslations('servers'); const [open, setOpen] = useState(false); const [activeType, setActiveType] = useState<(typeof PROTOCOLS)[number]>('shadowsocks'); + const [protocolsEnabled, setProtocolsEnabled] = useState([]); - const defaultValues = useMemo( - () => - normalizeValues({ - name: '', - address: '', - country: '', - city: '', - ratio: 1, - protocols: [], - }), - [], - ); - - const form = useForm({ resolver: zodResolver(formSchema), defaultValues }); + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + name: '', + address: '', + country: '', + city: '', + ratio: 1, + protocols: [], + ...initialValues, + }, + }); const { control } = form; const protocolsValues = useWatch({ control, name: 'protocols' }); useEffect(() => { - const normalized = normalizeValues(initialValues || {}); - const byType = new Map(); - (Array.isArray(normalized.protocols) ? normalized.protocols : []).forEach((p: any) => { - if (p && p.type) byType.set(String(p.type), p); - }); - const full = PROTOCOLS.map((t) => byType.get(t) || getProtocolDefaultConfig(t)); - form.reset({ ...normalized, protocols: full }); - setActiveType('shadowsocks'); + if (initialValues) { + const enabledProtocols = PROTOCOLS.filter((type) => { + const protocol = initialValues.protocols?.find((p) => p.type === type); + return protocol && protocol.port && Number(protocol.port) > 0; + }); + setProtocolsEnabled(enabledProtocols); + form.reset({ + name: '', + address: '', + country: '', + city: '', + ratio: 1, + ...initialValues, + protocols: PROTOCOLS.map((type) => { + const existingProtocol = initialValues.protocols?.find((p) => p.type === type); + return existingProtocol || getProtocolDefaultConfig(type); + }), + }); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [initialValues]); - async function handleSubmit(data: { [x: string]: any }) { - const all = Array.isArray(data?.protocols) ? data.protocols : []; - const filtered = all - .filter((p: any) => { - const v = (p ?? {}).port; - const n = Number(v); - return Number.isFinite(n) && n > 0 && n <= 65535; + async function handleSubmit(values: Record) { + const filtered = (values?.protocols || []) + .filter((p: any, index: number) => { + const port = Number(p?.port); + const protocolType = PROTOCOLS[index]; + return ( + protocolType && + protocolsEnabled.includes(protocolType) && + Number.isFinite(port) && + port > 0 && + port <= 65535 + ); }) .map((p: any) => ({ ...p, port: Number(p.port) })); + if (filtered.length === 0) { toast.error(t('validation_failed')); return; } - const body = { - name: data?.name, - country: data?.country, - city: data?.city, - ratio: Number(data?.ratio ?? 1), - address: data?.address, - protocols: filtered, - } as unknown as T; - const ok = await onSubmit(body as unknown as T); - if (ok) setOpen(false); - } - // inlined protocol editor below in TabsContent + const result = { + name: values.name, + country: values.country, + city: values.city, + ratio: Number(values.ratio || 1), + address: values.address, + protocols: filtered, + }; + + const ok = await onSubmit(result); + if (ok) { + form.reset(); + setOpen(false); + } + } return ( @@ -148,7 +150,15 @@ export default function ServerForm({ onClick={() => { if (!initialValues) { const full = PROTOCOLS.map((t) => getProtocolDefaultConfig(t)); - form.reset({ ...defaultValues, protocols: full }); + form.reset({ + name: '', + address: '', + country: '', + city: '', + ratio: 1, + protocols: full, + }); + setProtocolsEnabled([]); } setOpen(true); }} @@ -244,7 +254,7 @@ export default function ServerForm({
setActiveType(v as any)}> - + {PROTOCOLS.map((type) => ( {titleCase(type)} @@ -257,373 +267,98 @@ export default function ServerForm({ PROTOCOLS.findIndex((t) => t === type), ); const current = Array.isArray(protocolsValues) ? protocolsValues[i] || {} : {}; - const transport = (current?.transport as string | undefined) ?? 'tcp'; - const security = current?.security as string | undefined; - const cipher = current?.cipher as string | undefined; + const transport = ((current as any)?.transport as string | undefined) ?? 'tcp'; + const security = (current as any)?.security as string | undefined; + const cipher = (current as any)?.cipher as string | undefined; + const isEnabled = protocolsEnabled.includes(type); + return ( - -
-
- ( - - {t('port')} - - field.onChange(v)} - /> - - - - )} - /> + +
+ + {t('enabled')} + + { + if (checked) { + setProtocolsEnabled([...protocolsEnabled, type]); + } else { + setProtocolsEnabled(protocolsEnabled.filter((p) => p !== type)); + } + }} + /> +
- {type === 'shadowsocks' && ( - <> - ( - - {t('encryption_method')} - - - - - - )} - /> - {[ - '2022-blake3-aes-128-gcm', - '2022-blake3-aes-256-gcm', - '2022-blake3-chacha20-poly1305', - ].includes((cipher || '').toString()) && ( - ( - - {t('server_key')} - - field.onChange(v)} - /> - - - - )} - /> - )} - - )} - - {type === 'vless' && ( + {isEnabled && ( +
+
( - {t('flow')} + {t('port')} - + /> )} /> - )} - {type === 'hysteria2' && ( - <> - ( - - {t('obfs_password')} - - field.onChange(v)} - /> - - - - )} - /> - ( - - {t('hop_ports')} - - field.onChange(v)} - /> - - - - )} - /> - ( - - {t('hop_interval')} - - field.onChange(v)} - /> - - - - )} - /> - - )} - - {type === 'tuic' && ( - <> - ( - - {t('udp_relay_mode')} - - field.onChange(value)} + > + + + + + + + {(SS_CIPHERS as readonly string[]).map((c) => ( + + {getLabel(c)} - ), - )} - - - - - - )} - /> - ( - - {t('congestion_controller')} - - - - - - )} - /> -
- ( - - {t('disable_sni')} - -
- field.onChange(checked)} - /> -
+ ))} + +
)} /> - ( - - {t('reduce_rtt')} - -
- field.onChange(checked)} - /> -
-
- -
- )} - /> -
- - )} -
- - {['vmess', 'vless', 'trojan'].includes(type) && ( - - - {t('transport_title')} - ( - - - - - - - )} - /> - - {transport !== 'tcp' && ( - - {['websocket', 'http2', 'httpupgrade'].includes( - transport as string, - ) && ( - <> - ( - - HOST - - { - form.setValue(field.name, value); - }} - /> - - - - )} - /> - ( - - {t('path')} - - field.onChange(v)} - /> - - - - )} - /> - - )} - {transport === 'grpc' && ( + {[ + '2022-blake3-aes-128-gcm', + '2022-blake3-aes-256-gcm', + '2022-blake3-chacha20-poly1305', + ].includes((cipher || '').toString()) && ( ( - {t('service_name')} + {t('server_key')} ({ )} /> )} - + )} - - )} - {['vmess', 'vless', 'trojan', 'anytls', 'tuic', 'hysteria2'].includes( - type, - ) && ( - - - {t('security_title')} - {['vmess', 'vless', 'trojan'].includes(type) && ( - ( - + {type === 'vless' && ( + ( + + {t('flow')} + - - - )} - /> - )} - + + + + )} + /> + )} - {(['anytls', 'tuic', 'hysteria2'].includes(type) || - (['vmess', 'vless', 'trojan'].includes(type) && - security !== 'none')) && ( - + {type === 'hysteria2' && ( + <> ( - {t('security_sni')} + {t('obfs_password')} field.onChange(v)} /> @@ -707,110 +424,56 @@ export default function ServerForm({ )} /> - - {type === 'vless' && security === 'reality' && ( - <> - ( - - {t('security_server_address')} - - field.onChange(v)} - /> - - - - )} - /> - ( - - {t('security_server_port')} - - field.onChange(v)} - /> - - - - )} - /> - ( - - {t('security_private_key')} - - field.onChange(v)} - /> - - - - )} - /> - ( - - {t('security_public_key')} - - field.onChange(v)} - /> - - - - )} - /> - ( - - {t('security_short_id')} - - field.onChange(v)} - /> - - - - )} - /> - - )} - ( - {t('security_fingerprint')} + {t('hop_ports')} + + field.onChange(v)} + /> + + + + )} + /> + ( + + {t('hop_interval')} + + field.onChange(v)} + /> + + + + )} + /> + + )} + + {type === 'tuic' && ( + <> + ( + + {t('udp_relay_mode')} @@ -833,27 +498,400 @@ export default function ServerForm({ /> ( - {t('security_allow_insecure')} + {t('congestion_controller')} -
- field.onChange(checked)} - /> -
+
)} /> -
+
+ ( + + {t('disable_sni')} + +
+ + field.onChange(checked) + } + /> +
+
+ +
+ )} + /> + ( + + {t('reduce_rtt')} + +
+ + field.onChange(checked) + } + /> +
+
+ +
+ )} + /> +
+ )} -
- )} -
+
+ + {['vmess', 'vless', 'trojan'].includes(type) && ( + + + {t('transport_title')} + ( + + + + + + + )} + /> + + {transport !== 'tcp' && ( + + {['websocket', 'http2', 'httpupgrade'].includes( + transport as string, + ) && ( + <> + ( + + HOST + + { + form.setValue(field.name, value); + }} + /> + + + + )} + /> + ( + + {t('path')} + + field.onChange(v)} + /> + + + + )} + /> + + )} + {transport === 'grpc' && ( + ( + + {t('service_name')} + + field.onChange(v)} + /> + + + + )} + /> + )} + + )} + + )} + + {['vmess', 'vless', 'trojan', 'anytls', 'tuic', 'hysteria2'].includes( + type, + ) && ( + + + {t('security_title')} + {['vmess', 'vless', 'trojan'].includes(type) && ( + ( + + + + + )} + /> + )} + + + {(['anytls', 'tuic', 'hysteria2'].includes(type) || + (['vmess', 'vless', 'trojan'].includes(type) && + security !== 'none')) && ( + + ( + + {t('security_sni')} + + field.onChange(v)} + /> + + + + )} + /> + + {type === 'vless' && security === 'reality' && ( + <> + ( + + {t('security_server_address')} + + field.onChange(v)} + /> + + + + )} + /> + ( + + {t('security_server_port')} + + field.onChange(v)} + /> + + + + )} + /> + ( + + {t('security_private_key')} + + field.onChange(v)} + /> + + + + )} + /> + ( + + {t('security_public_key')} + + field.onChange(v)} + /> + + + + )} + /> + ( + + {t('security_short_id')} + + field.onChange(v)} + /> + + + + )} + /> + + )} + + ( + + {t('security_fingerprint')} + + + + + + )} + /> + ( + + {t('security_allow_insecure')} + +
+ + field.onChange(checked) + } + /> +
+
+ +
+ )} + /> +
+ )} +
+ )} +
+ )}
); })} @@ -868,9 +906,10 @@ export default function ServerForm({