diff --git a/frontend/src/pages/clients/ClientBulkAddModal.tsx b/frontend/src/pages/clients/ClientBulkAddModal.tsx index 264b3229..2b16378b 100644 --- a/frontend/src/pages/clients/ClientBulkAddModal.tsx +++ b/frontend/src/pages/clients/ClientBulkAddModal.tsx @@ -9,6 +9,7 @@ import { HttpUtil, RandomUtil, SizeFormatter } from '@/utils'; import { TLS_FLOW_CONTROL } from '@/models/inbound'; import DateTimePicker from '@/components/DateTimePicker'; import type { InboundOption } from '@/hooks/useClients'; +import { ClientBulkAddFormSchema, type ClientBulkAddFormValues } from '@/schemas/client'; const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL); const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } } as const; @@ -17,11 +18,6 @@ const MULTI_CLIENT_PROTOCOLS = new Set([ 'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'hysteria2', ]); -interface ApiMsg { - success?: boolean; - msg?: string; -} - interface ClientBulkAddModalProps { open: boolean; inbounds: InboundOption[]; @@ -30,21 +26,7 @@ interface ClientBulkAddModalProps { onSaved?: () => void; } -interface FormState { - emailMethod: number; - firstNum: number; - lastNum: number; - emailPrefix: string; - emailPostfix: string; - quantity: number; - subId: string; - comment: string; - flow: string; - limitIp: number; - totalGB: number; - expiryTime: number; - inboundIds: number[]; -} +type FormState = ClientBulkAddFormValues; function emptyForm(): FormState { return { @@ -152,8 +134,9 @@ export default function ClientBulkAddModal({ } async function submit() { - if (!Array.isArray(form.inboundIds) || form.inboundIds.length === 0) { - messageApi.error(t('pages.clients.selectInbound')); + const validated = ClientBulkAddFormSchema.safeParse(form); + if (!validated.success) { + messageApi.error(t(validated.error.issues[0]?.message ?? 'somethingWentWrong')); return; } const emails = buildEmails(); @@ -177,7 +160,7 @@ export default function ClientBulkAddModal({ enable: true, }; const payload = { client, inboundIds: form.inboundIds }; - return HttpUtil.post('/panel/api/clients/add', payload, silentJsonOpts) as Promise; + return HttpUtil.post('/panel/api/clients/add', payload, silentJsonOpts); })); let ok = 0; let failed = 0; diff --git a/frontend/src/pages/clients/ClientFormModal.tsx b/frontend/src/pages/clients/ClientFormModal.tsx index 616e24ab..a7a65e3d 100644 --- a/frontend/src/pages/clients/ClientFormModal.tsx +++ b/frontend/src/pages/clients/ClientFormModal.tsx @@ -21,6 +21,7 @@ import { HttpUtil, RandomUtil } from '@/utils'; import DateTimePicker from '@/components/DateTimePicker'; import { TLS_FLOW_CONTROL } from '@/models/inbound'; import type { ClientRecord, InboundOption } from '@/hooks/useClients'; +import { ClientFormSchema, ClientCreateFormSchema } from '@/schemas/client'; import './ClientFormModal.css'; const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL); @@ -268,12 +269,27 @@ export default function ClientFormModal({ } async function onSubmit() { - if (!form.email || form.email.trim() === '') { - messageApi.error(`${t('pages.clients.email')} *`); - return; - } - if (!isEdit && (!form.inboundIds || form.inboundIds.length === 0)) { - messageApi.error(t('pages.clients.selectInbound')); + const schema = isEdit ? ClientFormSchema : ClientCreateFormSchema; + const validated = schema.safeParse({ + email: form.email, + subId: form.subId, + uuid: form.uuid, + password: form.password, + auth: form.auth, + flow: form.flow, + reverseTag: form.reverseTag, + totalGB: form.totalGB, + delayedStart: form.delayedStart, + delayedDays: form.delayedDays, + limitIp: form.limitIp, + tgId: form.tgId, + comment: form.comment, + enable: form.enable, + inboundIds: form.inboundIds, + }); + if (!validated.success) { + const issue = validated.error.issues[0]; + messageApi.error(t(issue?.message ?? 'somethingWentWrong')); return; } const expiryTime = form.delayedStart diff --git a/frontend/src/pages/nodes/NodeFormModal.tsx b/frontend/src/pages/nodes/NodeFormModal.tsx index 0343cf53..9281fdc1 100644 --- a/frontend/src/pages/nodes/NodeFormModal.tsx +++ b/frontend/src/pages/nodes/NodeFormModal.tsx @@ -15,7 +15,8 @@ import { } from 'antd'; import type { NodeRecord } from '@/api/queries/useNodesQuery'; import type { Msg } from '@/utils'; -import type { ProbeResult } from '@/schemas/node'; +import { NodeFormSchema, type NodeFormValues, type ProbeResult } from '@/schemas/node'; +import { antdRule } from '@/utils/zodForm'; import './NodeFormModal.css'; type Mode = 'add' | 'edit'; @@ -29,20 +30,7 @@ interface NodeFormModalProps { onOpenChange: (open: boolean) => void; } -interface FormState { - id: number; - name: string; - remark: string; - scheme: 'http' | 'https'; - address: string; - port: number; - basePath: string; - apiToken: string; - enable: boolean; - allowPrivateAddress: boolean; -} - -function defaultForm(): FormState { +function defaultValues(): NodeFormValues { return { id: 0, name: '', @@ -66,68 +54,59 @@ export default function NodeFormModal({ onOpenChange, }: NodeFormModalProps) { const { t } = useTranslation(); + const [form] = Form.useForm(); const [messageApi, messageContextHolder] = message.useMessage(); - const [form, setForm] = useState(defaultForm); const [submitting, setSubmitting] = useState(false); const [testing, setTesting] = useState(false); - const [testResult, setTestResult] = useState<{ - status: string; - latencyMs?: number; - xrayVersion?: string; - error?: string; - } | null>(null); + const [testResult, setTestResult] = useState(null); useEffect(() => { if (!open) return; - const base = defaultForm(); - const next: FormState = mode === 'edit' && node + const base = defaultValues(); + const next: NodeFormValues = mode === 'edit' && node ? { ...base, - ...(node as unknown as Partial), + ...(node as unknown as Partial), id: node.id, scheme: (node.scheme as 'http' | 'https') || base.scheme, } : base; - - setForm(next); + form.resetFields(); + form.setFieldsValue(next); setTestResult(null); - - }, [open, mode, node]); + }, [open, mode, node, form]); const title = useMemo( () => (mode === 'edit' ? t('pages.nodes.editNode') : t('pages.nodes.addNode')), [mode, t], ); - function buildPayload(): Partial { + function buildPayload(values: NodeFormValues): Partial { return { - id: form.id || 0, - name: form.name?.trim() || '', - remark: form.remark?.trim() || '', - scheme: form.scheme || 'https', - address: form.address?.trim() || '', - port: Number(form.port) || 0, - basePath: form.basePath?.trim() || '/', - apiToken: form.apiToken?.trim() || '', - enable: !!form.enable, - allowPrivateAddress: !!form.allowPrivateAddress, + 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, }; } - function update(key: K, value: FormState[K]) { - setForm((prev) => ({ ...prev, [key]: value })); - } - async function onTest() { + try { + await form.validateFields(['address', 'port']); + } catch { + return; + } setTesting(true); setTestResult(null); try { - const payload = buildPayload(); - if (!payload.address || !payload.port) { - messageApi.error(t('pages.nodes.toasts.fillRequired')); - return; - } + const payload = buildPayload(form.getFieldsValue(true)); const msg = await testConnection(payload); if (msg?.success && msg.obj) { setTestResult(msg.obj); @@ -139,15 +118,15 @@ export default function NodeFormModal({ } } - async function onSave() { - const payload = buildPayload(); - if (!payload.name || !payload.address || !payload.port) { - messageApi.error(t('pages.nodes.toasts.fillRequired')); + 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 msg = await save(payload); + const msg = await save(buildPayload(result.data)); if (msg?.success) { onOpenChange(false); } @@ -167,125 +146,127 @@ export default function NodeFormModal({ open={open} title={title} confirmLoading={submitting} - okText={t('save')} - cancelText={t('cancel')} - mask={{ closable: false }} - width="640px" - onOk={onSave} - onCancel={close} - > -
- - - - update('name', e.target.value)} - /> - - - - - update('remark', e.target.value)} /> - - - + okText={t('save')} + cancelText={t('cancel')} + mask={{ closable: false }} + width="640px" + onOk={() => form.submit()} + onCancel={close} + > + + + + + + + + + + + + + - - - - update('address', e.target.value)} - /> - - - - - update('port', Number(v) || 0)} - /> - - - - - - - - update('basePath', e.target.value)} - /> - - - - - update('enable', v)} /> - - - - - - update('allowPrivateAddress', v)} - /> -
{t('pages.nodes.allowPrivateAddressHint')}
-
- - - update('apiToken', e.target.value)} - /> -
{t('pages.nodes.apiTokenHint')}
-
- -
- - {testResult && ( -
- {testResult.status === 'online' ? ( - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + {testResult && ( +
+ {testResult.status === 'online' ? ( + + ) : ( + + )} +
+ )} +
+ ); diff --git a/frontend/src/schemas/client.ts b/frontend/src/schemas/client.ts index 5489f690..02059c61 100644 --- a/frontend/src/schemas/client.ts +++ b/frontend/src/schemas/client.ts @@ -83,6 +83,44 @@ export const DelDepletedResultSchema = z.object({ export const OnlinesSchema = nullableStringArray; +export const ClientFormSchema = z.object({ + email: z.string().trim().min(1, 'pages.clients.email'), + subId: z.string(), + uuid: z.string(), + password: z.string(), + auth: z.string(), + flow: z.string(), + reverseTag: z.string(), + totalGB: z.number().min(0), + delayedStart: z.boolean(), + delayedDays: z.number().int().min(0), + limitIp: z.number().int().min(0), + tgId: z.number().int().min(0), + comment: z.string(), + enable: z.boolean(), + inboundIds: z.array(z.number()), +}); + +export const ClientCreateFormSchema = ClientFormSchema.extend({ + inboundIds: z.array(z.number()).min(1, 'pages.clients.selectInbound'), +}); + +export const ClientBulkAddFormSchema = z.object({ + emailMethod: z.number().int().min(0).max(4), + firstNum: z.number().int().min(1), + lastNum: z.number().int().min(1), + emailPrefix: z.string(), + emailPostfix: z.string(), + quantity: z.number().int().min(1).max(100), + subId: z.string(), + comment: z.string(), + flow: z.string(), + limitIp: z.number().int().min(0), + totalGB: z.number().min(0), + expiryTime: z.number(), + inboundIds: z.array(z.number()).min(1, 'pages.clients.selectInbound'), +}); + export type ClientRecord = z.infer; export type ClientTraffic = z.infer; export type InboundOption = z.infer; @@ -90,3 +128,5 @@ export type ClientsSummary = z.infer; export type ClientPageResponse = z.infer; export type ClientHydrate = z.infer; export type BulkAdjustResult = z.infer; +export type ClientBulkAddFormValues = z.infer; +export type ClientFormValues = z.infer; diff --git a/frontend/src/schemas/node.ts b/frontend/src/schemas/node.ts index 1bd01734..9c963a1f 100644 --- a/frontend/src/schemas/node.ts +++ b/frontend/src/schemas/node.ts @@ -35,5 +35,19 @@ export const ProbeResultSchema = z.object({ error: z.string().optional(), }).loose(); +export const NodeFormSchema = z.object({ + id: z.number().optional(), + name: z.string().trim().min(1, 'pages.nodes.toasts.fillRequired'), + remark: z.string().optional(), + scheme: z.enum(['http', 'https']), + address: z.string().trim().min(1, 'pages.nodes.toasts.fillRequired'), + port: z.number().int().min(1).max(65535), + basePath: z.string(), + apiToken: z.string().trim().min(1, 'pages.nodes.toasts.fillRequired'), + enable: z.boolean(), + allowPrivateAddress: z.boolean(), +}); + export type NodeRecord = z.infer; export type ProbeResult = z.infer; +export type NodeFormValues = z.infer;