Przeglądaj źródła

feat(frontend): drive form validation from Zod schemas

NodeFormModal — full conversion to AntD Form.useForm with antdRule
on every required field. Inline field errors replace the single
'fillRequired' toast. testConnection now runs validateFields(['address','port'])
before sending.

ClientFormModal and ClientBulkAddModal — minimal conversion: keep the
existing useState-driven controlled-component pattern, but replace the
hand-rolled `if (!form.x)` checks with schema.safeParse(form). The
schema is the single source of truth for required-ness and types;
ClientCreateFormSchema layers on the create-only `inboundIds.min(1)` rule.

New schemas (in src/schemas/):
  NodeFormSchema (node.ts)
  ClientFormSchema / ClientCreateFormSchema (client.ts)
  ClientBulkAddFormSchema (client.ts)

Other 16+ form modals stay on the current pattern — the antdRule adapter
ships from the first Zod pass for opportunistic migration as forms are
touched.
MHSanaei 15 godzin temu
rodzic
commit
6bbc9f6769

+ 6 - 23
frontend/src/pages/clients/ClientBulkAddModal.tsx

@@ -9,6 +9,7 @@ import { HttpUtil, RandomUtil, SizeFormatter } from '@/utils';
 import { TLS_FLOW_CONTROL } from '@/models/inbound';
 import { TLS_FLOW_CONTROL } from '@/models/inbound';
 import DateTimePicker from '@/components/DateTimePicker';
 import DateTimePicker from '@/components/DateTimePicker';
 import type { InboundOption } from '@/hooks/useClients';
 import type { InboundOption } from '@/hooks/useClients';
+import { ClientBulkAddFormSchema, type ClientBulkAddFormValues } from '@/schemas/client';
 
 
 const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
 const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
 const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } } as const;
 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',
   'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'hysteria2',
 ]);
 ]);
 
 
-interface ApiMsg {
-  success?: boolean;
-  msg?: string;
-}
-
 interface ClientBulkAddModalProps {
 interface ClientBulkAddModalProps {
   open: boolean;
   open: boolean;
   inbounds: InboundOption[];
   inbounds: InboundOption[];
@@ -30,21 +26,7 @@ interface ClientBulkAddModalProps {
   onSaved?: () => void;
   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 {
 function emptyForm(): FormState {
   return {
   return {
@@ -152,8 +134,9 @@ export default function ClientBulkAddModal({
   }
   }
 
 
   async function submit() {
   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;
       return;
     }
     }
     const emails = buildEmails();
     const emails = buildEmails();
@@ -177,7 +160,7 @@ export default function ClientBulkAddModal({
           enable: true,
           enable: true,
         };
         };
         const payload = { client, inboundIds: form.inboundIds };
         const payload = { client, inboundIds: form.inboundIds };
-        return HttpUtil.post('/panel/api/clients/add', payload, silentJsonOpts) as Promise<ApiMsg>;
+        return HttpUtil.post('/panel/api/clients/add', payload, silentJsonOpts);
       }));
       }));
       let ok = 0;
       let ok = 0;
       let failed = 0;
       let failed = 0;

+ 22 - 6
frontend/src/pages/clients/ClientFormModal.tsx

@@ -21,6 +21,7 @@ import { HttpUtil, RandomUtil } from '@/utils';
 import DateTimePicker from '@/components/DateTimePicker';
 import DateTimePicker from '@/components/DateTimePicker';
 import { TLS_FLOW_CONTROL } from '@/models/inbound';
 import { TLS_FLOW_CONTROL } from '@/models/inbound';
 import type { ClientRecord, InboundOption } from '@/hooks/useClients';
 import type { ClientRecord, InboundOption } from '@/hooks/useClients';
+import { ClientFormSchema, ClientCreateFormSchema } from '@/schemas/client';
 import './ClientFormModal.css';
 import './ClientFormModal.css';
 
 
 const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
 const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
@@ -268,12 +269,27 @@ export default function ClientFormModal({
   }
   }
 
 
   async function onSubmit() {
   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;
       return;
     }
     }
     const expiryTime = form.delayedStart
     const expiryTime = form.delayedStart

+ 149 - 168
frontend/src/pages/nodes/NodeFormModal.tsx

@@ -15,7 +15,8 @@ import {
 } from 'antd';
 } from 'antd';
 import type { NodeRecord } from '@/api/queries/useNodesQuery';
 import type { NodeRecord } from '@/api/queries/useNodesQuery';
 import type { Msg } from '@/utils';
 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';
 import './NodeFormModal.css';
 
 
 type Mode = 'add' | 'edit';
 type Mode = 'add' | 'edit';
@@ -29,20 +30,7 @@ interface NodeFormModalProps {
   onOpenChange: (open: boolean) => void;
   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 {
   return {
     id: 0,
     id: 0,
     name: '',
     name: '',
@@ -66,68 +54,59 @@ export default function NodeFormModal({
   onOpenChange,
   onOpenChange,
 }: NodeFormModalProps) {
 }: NodeFormModalProps) {
   const { t } = useTranslation();
   const { t } = useTranslation();
+  const [form] = Form.useForm<NodeFormValues>();
   const [messageApi, messageContextHolder] = message.useMessage();
   const [messageApi, messageContextHolder] = message.useMessage();
 
 
-  const [form, setForm] = useState<FormState>(defaultForm);
   const [submitting, setSubmitting] = useState(false);
   const [submitting, setSubmitting] = useState(false);
   const [testing, setTesting] = 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<ProbeResult | null>(null);
 
 
   useEffect(() => {
   useEffect(() => {
     if (!open) return;
     if (!open) return;
-    const base = defaultForm();
-    const next: FormState = mode === 'edit' && node
+    const base = defaultValues();
+    const next: NodeFormValues = mode === 'edit' && node
       ? {
       ? {
         ...base,
         ...base,
-        ...(node as unknown as Partial<FormState>),
+        ...(node as unknown as Partial<NodeFormValues>),
         id: node.id,
         id: node.id,
         scheme: (node.scheme as 'http' | 'https') || base.scheme,
         scheme: (node.scheme as 'http' | 'https') || base.scheme,
       }
       }
       : base;
       : base;
-     
-    setForm(next);
+    form.resetFields();
+    form.setFieldsValue(next);
     setTestResult(null);
     setTestResult(null);
-     
-  }, [open, mode, node]);
+  }, [open, mode, node, form]);
 
 
   const title = useMemo(
   const title = useMemo(
     () => (mode === 'edit' ? t('pages.nodes.editNode') : t('pages.nodes.addNode')),
     () => (mode === 'edit' ? t('pages.nodes.editNode') : t('pages.nodes.addNode')),
     [mode, t],
     [mode, t],
   );
   );
 
 
-  function buildPayload(): Partial<NodeRecord> {
+  function buildPayload(values: NodeFormValues): Partial<NodeRecord> {
     return {
     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<K extends keyof FormState>(key: K, value: FormState[K]) {
-    setForm((prev) => ({ ...prev, [key]: value }));
-  }
-
   async function onTest() {
   async function onTest() {
+    try {
+      await form.validateFields(['address', 'port']);
+    } catch {
+      return;
+    }
     setTesting(true);
     setTesting(true);
     setTestResult(null);
     setTestResult(null);
     try {
     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);
       const msg = await testConnection(payload);
       if (msg?.success && msg.obj) {
       if (msg?.success && msg.obj) {
         setTestResult(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;
       return;
     }
     }
     setSubmitting(true);
     setSubmitting(true);
     try {
     try {
-      const msg = await save(payload);
+      const msg = await save(buildPayload(result.data));
       if (msg?.success) {
       if (msg?.success) {
         onOpenChange(false);
         onOpenChange(false);
       }
       }
@@ -167,125 +146,127 @@ export default function NodeFormModal({
         open={open}
         open={open}
         title={title}
         title={title}
         confirmLoading={submitting}
         confirmLoading={submitting}
-      okText={t('save')}
-      cancelText={t('cancel')}
-      mask={{ closable: false }}
-      width="640px"
-      onOk={onSave}
-      onCancel={close}
-    >
-      <Form layout="vertical">
-        <Row gutter={16}>
-          <Col xs={24} md={12}>
-            <Form.Item label={t('pages.nodes.name')} required>
-              <Input
-                value={form.name}
-                placeholder={t('pages.nodes.namePlaceholder')}
-                onChange={(e) => update('name', e.target.value)}
-              />
-            </Form.Item>
-          </Col>
-          <Col xs={24} md={12}>
-            <Form.Item label={t('pages.nodes.remark')}>
-              <Input value={form.remark} onChange={(e) => update('remark', e.target.value)} />
-            </Form.Item>
-          </Col>
-        </Row>
+        okText={t('save')}
+        cancelText={t('cancel')}
+        mask={{ closable: false }}
+        width="640px"
+        onOk={() => form.submit()}
+        onCancel={close}
+      >
+        <Form
+          form={form}
+          layout="vertical"
+          initialValues={defaultValues()}
+          onFinish={onFinish}
+        >
+          <Row gutter={16}>
+            <Col xs={24} md={12}>
+              <Form.Item
+                label={t('pages.nodes.name')}
+                name="name"
+                rules={[antdRule(NodeFormSchema.shape.name, t)]}
+              >
+                <Input placeholder={t('pages.nodes.namePlaceholder')} />
+              </Form.Item>
+            </Col>
+            <Col xs={24} md={12}>
+              <Form.Item label={t('pages.nodes.remark')} name="remark">
+                <Input />
+              </Form.Item>
+            </Col>
+          </Row>
 
 
-        <Row gutter={16}>
-          <Col xs={24} md={6}>
-            <Form.Item label={t('pages.nodes.scheme')}>
-              <Select
-                value={form.scheme}
-                onChange={(v) => update('scheme', v)}
-                options={[
-                  { value: 'https', label: 'https' },
-                  { value: 'http', label: 'http' },
-                ]}
-              />
-            </Form.Item>
-          </Col>
-          <Col xs={24} md={12}>
-            <Form.Item label={t('pages.nodes.address')} required>
-              <Input
-                value={form.address}
-                placeholder={t('pages.nodes.addressPlaceholder')}
-                onChange={(e) => update('address', e.target.value)}
-              />
-            </Form.Item>
-          </Col>
-          <Col xs={24} md={6}>
-            <Form.Item label={t('pages.nodes.port')} required>
-              <InputNumber
-                value={form.port}
-                min={1}
-                max={65535}
-                style={{ width: '100%' }}
-                onChange={(v) => update('port', Number(v) || 0)}
-              />
-            </Form.Item>
-          </Col>
-        </Row>
+          <Row gutter={16}>
+            <Col xs={24} md={6}>
+              <Form.Item label={t('pages.nodes.scheme')} name="scheme">
+                <Select
+                  options={[
+                    { value: 'https', label: 'https' },
+                    { value: 'http', label: 'http' },
+                  ]}
+                />
+              </Form.Item>
+            </Col>
+            <Col xs={24} md={12}>
+              <Form.Item
+                label={t('pages.nodes.address')}
+                name="address"
+                rules={[antdRule(NodeFormSchema.shape.address, t)]}
+              >
+                <Input placeholder={t('pages.nodes.addressPlaceholder')} />
+              </Form.Item>
+            </Col>
+            <Col xs={24} md={6}>
+              <Form.Item
+                label={t('pages.nodes.port')}
+                name="port"
+                rules={[antdRule(NodeFormSchema.shape.port, t)]}
+              >
+                <InputNumber min={1} max={65535} style={{ width: '100%' }} />
+              </Form.Item>
+            </Col>
+          </Row>
 
 
-        <Row gutter={16}>
-          <Col xs={24} md={12}>
-            <Form.Item label={t('pages.nodes.basePath')}>
-              <Input
-                value={form.basePath}
-                placeholder="/"
-                onChange={(e) => update('basePath', e.target.value)}
-              />
-            </Form.Item>
-          </Col>
-          <Col xs={24} md={12}>
-            <Form.Item label={t('pages.nodes.enable')}>
-              <Switch checked={form.enable} onChange={(v) => update('enable', v)} />
-            </Form.Item>
-          </Col>
-        </Row>
+          <Row gutter={16}>
+            <Col xs={24} md={12}>
+              <Form.Item label={t('pages.nodes.basePath')} name="basePath">
+                <Input placeholder="/" />
+              </Form.Item>
+            </Col>
+            <Col xs={24} md={12}>
+              <Form.Item
+                label={t('pages.nodes.enable')}
+                name="enable"
+                valuePropName="checked"
+              >
+                <Switch />
+              </Form.Item>
+            </Col>
+          </Row>
 
 
-        <Form.Item label={t('pages.nodes.allowPrivateAddress')}>
-          <Switch
-            checked={form.allowPrivateAddress}
-            onChange={(v) => update('allowPrivateAddress', v)}
-          />
-          <div className="hint">{t('pages.nodes.allowPrivateAddressHint')}</div>
-        </Form.Item>
+          <Form.Item
+            label={t('pages.nodes.allowPrivateAddress')}
+            name="allowPrivateAddress"
+            valuePropName="checked"
+            extra={t('pages.nodes.allowPrivateAddressHint')}
+          >
+            <Switch />
+          </Form.Item>
 
 
-        <Form.Item label={t('pages.nodes.apiToken')} required>
-          <Input.Password
-            value={form.apiToken}
-            placeholder={t('pages.nodes.apiTokenPlaceholder')}
-            onChange={(e) => update('apiToken', e.target.value)}
-          />
-          <div className="hint">{t('pages.nodes.apiTokenHint')}</div>
-        </Form.Item>
+          <Form.Item
+            label={t('pages.nodes.apiToken')}
+            name="apiToken"
+            rules={[antdRule(NodeFormSchema.shape.apiToken, t)]}
+            extra={t('pages.nodes.apiTokenHint')}
+          >
+            <Input.Password placeholder={t('pages.nodes.apiTokenPlaceholder')} />
+          </Form.Item>
 
 
-        <div className="test-row">
-          <Button type="default" loading={testing} onClick={onTest}>
-            {t('pages.nodes.testConnection')}
-          </Button>
-          {testResult && (
-            <div className="test-result">
-              {testResult.status === 'online' ? (
-                <Alert
-                  type="success"
-                  showIcon
-                  title={t('pages.nodes.connectionOk', { ms: testResult.latencyMs })}
-                  description={testResult.xrayVersion ? `Xray ${testResult.xrayVersion}` : undefined}
-                />
-              ) : (
-                <Alert
-                  type="error"
-                  showIcon
-                  title={t('pages.nodes.connectionFailed')}
-                  description={testResult.error}
-                />
-              )}
-            </div>
-          )}
-        </div>
-      </Form>
+          <div className="test-row">
+            <Button type="default" loading={testing} onClick={onTest}>
+              {t('pages.nodes.testConnection')}
+            </Button>
+            {testResult && (
+              <div className="test-result">
+                {testResult.status === 'online' ? (
+                  <Alert
+                    type="success"
+                    showIcon
+                    title={t('pages.nodes.connectionOk', { ms: testResult.latencyMs })}
+                    description={testResult.xrayVersion ? `Xray ${testResult.xrayVersion}` : undefined}
+                  />
+                ) : (
+                  <Alert
+                    type="error"
+                    showIcon
+                    title={t('pages.nodes.connectionFailed')}
+                    description={testResult.error}
+                  />
+                )}
+              </div>
+            )}
+          </div>
+        </Form>
       </Modal>
       </Modal>
     </>
     </>
   );
   );

+ 40 - 0
frontend/src/schemas/client.ts

@@ -83,6 +83,44 @@ export const DelDepletedResultSchema = z.object({
 
 
 export const OnlinesSchema = nullableStringArray;
 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<typeof ClientRecordSchema>;
 export type ClientRecord = z.infer<typeof ClientRecordSchema>;
 export type ClientTraffic = z.infer<typeof ClientTrafficSchema>;
 export type ClientTraffic = z.infer<typeof ClientTrafficSchema>;
 export type InboundOption = z.infer<typeof InboundOptionSchema>;
 export type InboundOption = z.infer<typeof InboundOptionSchema>;
@@ -90,3 +128,5 @@ export type ClientsSummary = z.infer<typeof ClientsSummarySchema>;
 export type ClientPageResponse = z.infer<typeof ClientPageResponseSchema>;
 export type ClientPageResponse = z.infer<typeof ClientPageResponseSchema>;
 export type ClientHydrate = z.infer<typeof ClientHydrateSchema>;
 export type ClientHydrate = z.infer<typeof ClientHydrateSchema>;
 export type BulkAdjustResult = z.infer<typeof BulkAdjustResultSchema>;
 export type BulkAdjustResult = z.infer<typeof BulkAdjustResultSchema>;
+export type ClientBulkAddFormValues = z.infer<typeof ClientBulkAddFormSchema>;
+export type ClientFormValues = z.infer<typeof ClientFormSchema>;

+ 14 - 0
frontend/src/schemas/node.ts

@@ -35,5 +35,19 @@ export const ProbeResultSchema = z.object({
   error: z.string().optional(),
   error: z.string().optional(),
 }).loose();
 }).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<typeof NodeRecordSchema>;
 export type NodeRecord = z.infer<typeof NodeRecordSchema>;
 export type ProbeResult = z.infer<typeof ProbeResultSchema>;
 export type ProbeResult = z.infer<typeof ProbeResultSchema>;
+export type NodeFormValues = z.infer<typeof NodeFormSchema>;