Browse Source

安装zod,对注册表单做调整,和后端要求的内容对齐,并对注册功能的表单执行内容验证

dengdx 2 months ago
parent
commit
33d22d2eac

+ 10 - 1
package-lock.json

@@ -34,7 +34,8 @@
         "react-intl": "^7.1.11",
         "react-redux": "^9.2.0",
         "react-router-dom": "^7.6.1",
-        "redux": "^5.0.1"
+        "redux": "^5.0.1",
+        "zod": "^3.25.67"
       },
       "devDependencies": {
         "@babel/core": "^7.24.4",
@@ -19738,6 +19739,14 @@
       "funding": {
         "url": "https://github.com/sponsors/sindresorhus"
       }
+    },
+    "node_modules/zod": {
+      "version": "3.25.67",
+      "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.67.tgz",
+      "integrity": "sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw==",
+      "funding": {
+        "url": "https://github.com/sponsors/colinhacks"
+      }
     }
   }
 }

+ 2 - 1
package.json

@@ -65,7 +65,8 @@
     "react-intl": "^7.1.11",
     "react-redux": "^9.2.0",
     "react-router-dom": "^7.6.1",
-    "redux": "^5.0.1"
+    "redux": "^5.0.1",
+    "zod": "^3.25.67"
   },
   "devDependencies": {
     "@babel/core": "^7.24.4",

+ 1 - 1
src/API/patient/workActions.ts

@@ -57,7 +57,7 @@ export interface RegisterInfo {
   weight: number;
   thickness: number;
   length: number;
-  study_type: string;
+  study_type: 'Normal' | 'Emergency';
   comment: string;
   views: View[];
 }

+ 215 - 119
src/pages/patient/components/register.form.tsx

@@ -10,6 +10,7 @@ import {
   Space,
 } from 'antd';
 import { useIntl, FormattedMessage } from 'react-intl';
+import { registerFormFields } from '@/validation/patient/registerSchema';
 
 const genderOptions = [
   {
@@ -32,36 +33,6 @@ const genderOptions = [
   },
 ];
 
-const bodyTypeOptions = [
-  {
-    value: 'slim',
-    label: (
-      <FormattedMessage
-        id="register.bodyType.slim"
-        defaultMessage="register.bodyType.slim"
-      />
-    ),
-  },
-  {
-    value: 'average',
-    label: (
-      <FormattedMessage
-        id="register.bodyType.average"
-        defaultMessage="register.bodyType.average"
-      />
-    ),
-  },
-  {
-    value: 'Large',
-    label: (
-      <FormattedMessage
-        id="register.bodyType.fat"
-        defaultMessage="register.bodyType.fat"
-      />
-    ),
-  },
-];
-
 const pregnancyStatusOptions = [
   {
     value: 'yes',
@@ -92,26 +63,6 @@ const pregnancyStatusOptions = [
   },
 ];
 
-const bodyPartOptions = [
-  {
-    value: 'head',
-    label: (
-      <FormattedMessage
-        id="register.bodyPart.head"
-        defaultMessage="register.bodyPart.head"
-      />
-    ),
-  },
-  {
-    value: 'chest',
-    label: (
-      <FormattedMessage
-        id="register.bodyPart.chest"
-        defaultMessage="register.bodyPart.chest"
-      />
-    ),
-  },
-];
 interface BasicInfoFormProps {
   style?: React.CSSProperties;
   form?: FormInstance;
@@ -120,6 +71,25 @@ const BasicInfoForm: React.FC<BasicInfoFormProps> = ({ style, form }) => {
   const intl = useIntl();
   return (
     <Form form={form} layout="vertical" style={style}>
+      <Form.Item
+        label={
+          <FormattedMessage
+            id="register.accessionNumber"
+            defaultMessage="register.accessionNumber"
+          />
+        }
+        name="accession_number"
+        required={registerFormFields.accession_number.required}
+        validateTrigger={registerFormFields.accession_number.trigger}
+        rules={registerFormFields.accession_number.validation}
+      >
+        <Input
+          placeholder={intl.formatMessage({
+            id: 'register.accessionNumber.placeholder',
+            defaultMessage: 'register.accessionNumber.placeholder',
+          })}
+        />
+      </Form.Item>
       <Form.Item
         label={
           <FormattedMessage
@@ -128,6 +98,9 @@ const BasicInfoForm: React.FC<BasicInfoFormProps> = ({ style, form }) => {
           />
         }
         name="patient_id"
+        required={registerFormFields.patient_id.required}
+        validateTrigger={registerFormFields.patient_id.trigger}
+        rules={registerFormFields.patient_id.validation}
       >
         <Input
           placeholder={intl.formatMessage({
@@ -144,6 +117,9 @@ const BasicInfoForm: React.FC<BasicInfoFormProps> = ({ style, form }) => {
           />
         }
         name="patient_name"
+        required={registerFormFields.patient_name.required}
+        validateTrigger={registerFormFields.patient_name.trigger}
+        rules={registerFormFields.patient_name.validation}
       >
         <Input
           placeholder={intl.formatMessage({
@@ -155,98 +131,218 @@ const BasicInfoForm: React.FC<BasicInfoFormProps> = ({ style, form }) => {
       <Form.Item
         label={
           <FormattedMessage
-            id="register.previousName"
-            defaultMessage="register.previousName"
+            id="register.patientSize"
+            defaultMessage="register.patientSize"
           />
         }
-        name="previousName"
+        name="patient_size"
+        required={registerFormFields.patient_size.required}
+        validateTrigger={registerFormFields.patient_size.trigger}
+        rules={registerFormFields.patient_size.validation}
       >
         <Input
           placeholder={intl.formatMessage({
-            id: 'register.previousName.placeholder',
-            defaultMessage: 'register.previousName.placeholder',
+            id: 'register.patientSize.placeholder',
+            defaultMessage: 'register.patientSize.placeholder',
           })}
         />
       </Form.Item>
+      <Form.Item
+        label={
+          <FormattedMessage id="register.age" defaultMessage="register.age" />
+        }
+        name="patient_age"
+        required={registerFormFields.patient_age.required}
+        validateTrigger={registerFormFields.patient_age.trigger}
+        rules={registerFormFields.patient_age.validation}
+      >
+        <Space.Compact>
+          <InputNumber min={0} style={{ width: '70%' }} />
+          <Select style={{ width: '30%' }}>
+            <Select.Option value="D">天</Select.Option>
+            <Select.Option value="M">月</Select.Option>
+            <Select.Option value="Y">年</Select.Option>
+          </Select>
+        </Space.Compact>
+      </Form.Item>
       <Form.Item
         label={
           <FormattedMessage
-            id="register.englishName"
-            defaultMessage="register.englishName"
+            id="register.dateOfBirth"
+            defaultMessage="register.dateOfBirth"
           />
         }
-        name="englishName"
+        name="patient_dob"
+        required={registerFormFields.patient_dob.required}
+        validateTrigger={registerFormFields.patient_dob.trigger}
+        rules={registerFormFields.patient_dob.validation}
+      >
+        <DatePicker format="YYYY-MM-DD" style={{ width: '100%' }} />
+      </Form.Item>
+      <Form.Item
+        label={
+          <FormattedMessage
+            id="register.gender"
+            defaultMessage="register.gender"
+          />
+        }
+        name="patient_sex"
+        required={registerFormFields.patient_sex.required}
+        validateTrigger={registerFormFields.patient_sex.trigger}
+        rules={registerFormFields.patient_sex.validation}
+      >
+        <Select options={genderOptions} />
+      </Form.Item>
+      <Form.Item
+        label={
+          <FormattedMessage
+            id="register.sexNeutered"
+            defaultMessage="register.sexNeutered"
+          />
+        }
+        name="sex_neutered"
+        required={registerFormFields.sex_neutered.required}
+        validateTrigger={registerFormFields.sex_neutered.trigger}
+        rules={registerFormFields.sex_neutered.validation}
       >
         <Input
           placeholder={intl.formatMessage({
-            id: 'register.englishName.placeholder',
-            defaultMessage: 'register.englishName.placeholder',
+            id: 'register.sexNeutered.placeholder',
+            defaultMessage: 'register.sexNeutered.placeholder',
           })}
         />
       </Form.Item>
       <Form.Item
         label={
           <FormattedMessage
-            id="register.registrationNo"
-            defaultMessage="register.registrationNo"
+            id="register.pregnancyStatus"
+            defaultMessage="register.pregnancyStatus"
           />
         }
-        name="registrationNo"
+        name="pregnancy_status"
+        required={registerFormFields.pregnancy_status.required}
+        validateTrigger={registerFormFields.pregnancy_status.trigger}
+        rules={registerFormFields.pregnancy_status.validation}
+      >
+        <Radio.Group
+          options={pregnancyStatusOptions}
+          optionType="button"
+          buttonStyle="solid"
+        />
+      </Form.Item>
+      <Form.Item
+        label={
+          <FormattedMessage
+            id="register.chipNumber"
+            defaultMessage="register.chipNumber"
+          />
+        }
+        name="chip_number"
+        required={registerFormFields.chip_number.required}
+        validateTrigger={registerFormFields.chip_number.trigger}
+        rules={registerFormFields.chip_number.validation}
       >
         <Input
           placeholder={intl.formatMessage({
-            id: 'register.registrationNo.placeholder',
-            defaultMessage: 'register.registrationNo.placeholder',
+            id: 'register.chipNumber.placeholder',
+            defaultMessage: 'register.chipNumber.placeholder',
           })}
         />
       </Form.Item>
       <Form.Item
         label={
           <FormattedMessage
-            id="register.dateOfBirth"
-            defaultMessage="register.dateOfBirth"
+            id="register.variety"
+            defaultMessage="register.variety"
           />
         }
-        name="patient_dob"
+        name="variety"
+        required={registerFormFields.variety.required}
+        validateTrigger={registerFormFields.variety.trigger}
+        rules={registerFormFields.variety.validation}
       >
-        <DatePicker format="YYYY-MM-DD" style={{ width: '100%' }} />
+        <Input
+          placeholder={intl.formatMessage({
+            id: 'register.variety.placeholder',
+            defaultMessage: 'register.variety.placeholder',
+          })}
+        />
       </Form.Item>
       <Form.Item
         label={
-          <FormattedMessage id="register.age" defaultMessage="register.age" />
+          <FormattedMessage
+            id="register.patientType"
+            defaultMessage="register.patientType"
+          />
         }
-        name="patient_age"
+        name="patient_type"
+        required={registerFormFields.patient_type.required}
+        validateTrigger={registerFormFields.patient_type.trigger}
+        rules={registerFormFields.patient_type.validation}
       >
-        <Space.Compact>
-          <InputNumber min={0} style={{ width: '70%' }} />
-          <Select style={{ width: '30%' }}>
-            <Select.Option value="D">天</Select.Option>
-            <Select.Option value="M">月</Select.Option>
-            <Select.Option value="Y">年</Select.Option>
-          </Select>
-        </Space.Compact>
+        <Input
+          placeholder={intl.formatMessage({
+            id: 'register.patientType.placeholder',
+            defaultMessage: 'register.patientType.placeholder',
+          })}
+        />
       </Form.Item>
       <Form.Item
         label={
           <FormattedMessage
-            id="register.gender"
-            defaultMessage="register.gender"
+            id="register.referringPhysician"
+            defaultMessage="register.referringPhysician"
           />
         }
-        name="patient_sex"
+        name="ref_physician"
+        required={registerFormFields.ref_physician.required}
+        validateTrigger={registerFormFields.ref_physician.trigger}
+        rules={registerFormFields.ref_physician.validation}
       >
-        <Select options={genderOptions} />
+        <Input
+          placeholder={intl.formatMessage({
+            id: 'register.referringPhysician.placeholder',
+            defaultMessage: 'register.referringPhysician.placeholder',
+          })}
+        />
       </Form.Item>
       <Form.Item
         label={
           <FormattedMessage
-            id="register.bodyType"
-            defaultMessage="register.bodyType"
+            id="register.operatorId"
+            defaultMessage="register.operatorId"
           />
         }
-        name="patient_size"
+        name="operator_id"
+        required={registerFormFields.operator_id.required}
+        validateTrigger={registerFormFields.operator_id.trigger}
+        rules={registerFormFields.operator_id.validation}
       >
-        <Select options={bodyTypeOptions} />
+        <Input
+          placeholder={intl.formatMessage({
+            id: 'register.operatorId.placeholder',
+            defaultMessage: 'register.operatorId.placeholder',
+          })}
+        />
+      </Form.Item>
+      <Form.Item
+        label={
+          <FormattedMessage
+            id="register.modality"
+            defaultMessage="register.modality"
+          />
+        }
+        name="modality"
+        required={registerFormFields.modality.required}
+        validateTrigger={registerFormFields.modality.trigger}
+        rules={registerFormFields.modality.validation}
+      >
+        <Input
+          placeholder={intl.formatMessage({
+            id: 'register.modality.placeholder',
+            defaultMessage: 'register.modality.placeholder',
+          })}
+        />
       </Form.Item>
       <Form.Item
         label={
@@ -256,75 +352,75 @@ const BasicInfoForm: React.FC<BasicInfoFormProps> = ({ style, form }) => {
           />
         }
         name="weight"
+        required={registerFormFields.weight.required}
+        validateTrigger={registerFormFields.weight.trigger}
+        rules={registerFormFields.weight.validation}
       >
         <InputNumber min={0} addonAfter="kg" style={{ width: '100%' }} />
       </Form.Item>
       <Form.Item
         label={
           <FormattedMessage
-            id="register.height"
-            defaultMessage="register.height"
+            id="register.thickness"
+            defaultMessage="register.thickness"
           />
         }
-        name="length"
+        name="thickness"
+        required={registerFormFields.thickness.required}
+        validateTrigger={registerFormFields.thickness.trigger}
+        rules={registerFormFields.thickness.validation}
       >
         <InputNumber min={0} addonAfter="cm" style={{ width: '100%' }} />
       </Form.Item>
       <Form.Item
         label={
           <FormattedMessage
-            id="register.pregnancyStatus"
-            defaultMessage="register.pregnancyStatus"
+            id="register.height"
+            defaultMessage="register.height"
           />
         }
-        name="pregnancy_status"
+        name="length"
+        required={registerFormFields.length.required}
+        validateTrigger={registerFormFields.length.trigger}
+        rules={registerFormFields.length.validation}
       >
-        <Radio.Group
-          options={pregnancyStatusOptions}
-          optionType="button"
-          buttonStyle="solid"
-        />
+        <InputNumber min={0} addonAfter="cm" style={{ width: '100%' }} />
       </Form.Item>
       <Form.Item
         label={
           <FormattedMessage
-            id="register.referringPhysician"
-            defaultMessage="register.referringPhysician"
+            id="register.studyType"
+            defaultMessage="register.studyType"
           />
         }
-        name="ref_physician"
+        name="study_type"
+        required={registerFormFields.study_type.required}
+        validateTrigger={registerFormFields.study_type.trigger}
+        rules={registerFormFields.study_type.validation}
       >
         <Input
           placeholder={intl.formatMessage({
-            id: 'register.referringPhysician.placeholder',
-            defaultMessage: 'register.referringPhysician.placeholder',
+            id: 'register.studyType.placeholder',
+            defaultMessage: 'register.studyType.placeholder',
           })}
         />
       </Form.Item>
       <Form.Item
         label={
           <FormattedMessage
-            id="register.bodyPart"
-            defaultMessage="register.bodyPart"
+            id="register.comment"
+            defaultMessage="register.comment"
           />
         }
-        name="bodyPart"
-      >
-        <Select options={bodyPartOptions} />
-      </Form.Item>
-      <Form.Item
-        label={
-          <FormattedMessage
-            id="register.accessionNumber"
-            defaultMessage="register.accessionNumber"
-          />
-        }
-        name="accession_number"
+        name="comment"
+        required={registerFormFields.comment.required}
+        validateTrigger={registerFormFields.comment.trigger}
+        rules={registerFormFields.comment.validation}
       >
         <Input
           placeholder={intl.formatMessage({
-            id: 'register.accessionNumber.placeholder',
-            defaultMessage: 'register.accessionNumber.placeholder',
+            id: 'register.comment.placeholder',
+            defaultMessage: 'register.comment.placeholder',
           })}
         />
       </Form.Item>

+ 28 - 2
src/pages/patient/register.tsx

@@ -1,11 +1,22 @@
 import React from 'react';
-import { Row, Col, Collapse, Grid, Divider, Button, Space, Form } from 'antd';
+import {
+  Row,
+  Col,
+  Collapse,
+  Grid,
+  Divider,
+  Button,
+  Space,
+  Form,
+  message,
+} from 'antd';
 import { FormattedMessage } from 'react-intl';
 import BasicInfoForm from './components/register.form';
 import SelectedProtocolList from './components/register.selected.view.list';
 import RegisterAvailableList from './components/register.available.list';
 import { RegisterInfo, registerWork } from '@/API/patient/workActions';
 import useRegisterState from '@/hooks/useRegisterState';
+import registerformSchema from '@/validation/patient/registerSchema';
 
 const { useBreakpoint } = Grid;
 const { Panel } = Collapse;
@@ -17,8 +28,23 @@ const RegisterPage: React.FC = () => {
 
   const handleRegister = async () => {
     try {
+      const values = form.getFieldsValue();
+      values.patient_dob = values.patient_dob
+        ? values.patient_dob.toString('YYYY-MM-DD[T00:00:00.000000Z]')
+        : '';
+      console.log(`${JSON.stringify(values, null, 2)}`);
+
+      const validateResult = registerformSchema.safeParse(values);
+      if (!validateResult.success) {
+        message.error(`必填项未填写或者不合规则`);
+        // todo 更详细和人性化的提示
+        // message.error(JSON.stringify(validateResult.error, null, 2));
+        console.log(JSON.stringify(validateResult.error, null, 2));
+
+        return;
+      }
       const registerInfo: RegisterInfo = {
-        ...form.getFieldsValue(),
+        ...values,
         patient_type: currentPatientType?.patient_type_id,
         modality: 'dx',
         study_type: 'Normal',

+ 280 - 0
src/validation/patient/registerSchema.ts

@@ -0,0 +1,280 @@
+import {
+  z,
+  ZodTypeAny,
+  ZodString,
+  ZodNumber,
+  ZodBoolean,
+  ZodEffects,
+} from 'zod';
+import { RegisterInfo } from '@/API/patient/workActions';
+import { Rule } from 'antd/es/form';
+
+type IgnoredKeys = 'views';
+const registerInfoSchema: Record<
+  keyof Omit<RegisterInfo, IgnoredKeys>,
+  z.ZodTypeAny
+> = {
+  accession_number: z.string().nonempty(),
+  patient_name: z.string().nonempty(),
+  patient_id: z.string().nonempty(),
+  patient_size: z.string().nonempty(),
+  patient_age: z.string().nonempty(),
+  patient_dob: z.string().nonempty(),
+  patient_sex: z.string().optional(),
+  sex_neutered: z.string().optional(),
+  pregnancy_status: z.string().optional(),
+  chip_number: z.string().optional(),
+  variety: z.string().optional(),
+  patient_type: z.string().nonempty(),
+  ref_physician: z.string().optional(),
+  operator_id: z.string().optional(),
+  modality: z.string().nonempty(),
+  weight: z.number().optional(),
+  thickness: z.number().optional(),
+  length: z.number().optional(),
+  study_type: z.string().nonempty(),
+  comment: z.string().optional(),
+  // views: z.array(z.object({
+  //   view_id: z.string(),
+  //   procedure_id: z.string()
+  // })).optional()
+};
+
+const registerformSchema = z.object(registerInfoSchema);
+// 统一管理规则。规则表。描述要验证的字段【字段来源于被验证的数据结构UserInfo】,以及每个字段的“是否必填、必填标记、验证规则、提示信息、验证时机”
+export const registerFormFields = {
+  accession_number: {
+    label: 'Accession Number',
+    required: true,
+    requiredLabel: '',
+    validation: zodToAntdRules(registerformSchema.shape.accession_number),
+    message: 'Accession Number is optional',
+    trigger: ['onChange', 'onBlur'],
+  },
+  patient_id: {
+    label: 'Patient ID',
+    required: true,
+    requiredLabel: 'Patient ID is required',
+    validation: zodToAntdRules(registerformSchema.shape.patient_id),
+    message: 'Patient ID is required',
+    trigger: ['onChange', 'onBlur'],
+  },
+  patient_name: {
+    label: 'Patient Name',
+    required: true,
+    requiredLabel: 'Patient Name is required',
+    validation: zodToAntdRules(registerformSchema.shape.patient_name),
+    message: 'Patient Name is required',
+    trigger: ['onChange', 'onBlur'],
+  },
+  patient_size: {
+    label: 'Patient Size',
+    required: true,
+    requiredLabel: 'Patient Size is required',
+    validation: zodToAntdRules(registerformSchema.shape.patient_size),
+    message: 'Patient Size is required',
+    trigger: ['onChange', 'onBlur'],
+  },
+  patient_age: {
+    label: 'Patient Age',
+    required: true,
+    requiredLabel: 'Patient Age is required',
+    validation: zodToAntdRules(registerformSchema.shape.patient_age),
+    message: 'Patient Age is required',
+    trigger: ['onChange', 'onBlur'],
+  },
+  patient_dob: {
+    label: 'Patient DOB',
+    required: true,
+    requiredLabel: 'Patient DOB is required',
+    validation: zodToAntdRules(registerformSchema.shape.patient_dob),
+    message: 'Patient DOB is required',
+    trigger: ['onChange', 'onBlur'],
+  },
+  patient_sex: {
+    label: 'Patient Sex',
+    required: false,
+    requiredLabel: 'Patient Sex is required',
+    validation: zodToAntdRules(registerformSchema.shape.patient_sex),
+    message: 'Patient Sex is required',
+    trigger: ['onChange', 'onBlur'],
+  },
+  sex_neutered: {
+    label: 'Sex Neutered',
+    required: false,
+    requiredLabel: '',
+    validation: zodToAntdRules(registerformSchema.shape.sex_neutered),
+    message: 'Sex Neutered is optional',
+    trigger: ['onChange', 'onBlur'],
+  },
+  pregnancy_status: {
+    label: 'Pregnancy Status',
+    required: false,
+    requiredLabel: '',
+    validation: zodToAntdRules(registerformSchema.shape.pregnancy_status),
+    message: 'Pregnancy Status is optional',
+    trigger: ['onChange', 'onBlur'],
+  },
+  chip_number: {
+    label: 'Chip Number',
+    required: false,
+    requiredLabel: '',
+    validation: zodToAntdRules(registerformSchema.shape.chip_number),
+    message: 'Chip Number is optional',
+    trigger: ['onChange', 'onBlur'],
+  },
+  variety: {
+    label: 'Variety',
+    required: false,
+    requiredLabel: '',
+    validation: zodToAntdRules(registerformSchema.shape.variety),
+    message: 'Variety is optional',
+    trigger: ['onChange', 'onBlur'],
+  },
+  patient_type: {
+    label: 'Patient Type',
+    required: true,
+    requiredLabel: '',
+    validation: zodToAntdRules(registerformSchema.shape.patient_type),
+    message: 'Patient Type is optional',
+    trigger: ['onChange', 'onBlur'],
+  },
+  ref_physician: {
+    label: 'Referring Physician',
+    required: false,
+    requiredLabel: '',
+    validation: zodToAntdRules(registerformSchema.shape.ref_physician),
+    message: 'Referring Physician is optional',
+    trigger: ['onChange', 'onBlur'],
+  },
+  operator_id: {
+    label: 'Operator ID',
+    required: false,
+    requiredLabel: '',
+    validation: zodToAntdRules(registerformSchema.shape.operator_id),
+    message: 'Operator ID is optional',
+    trigger: ['onChange', 'onBlur'],
+  },
+  modality: {
+    label: 'Modality',
+    required: true,
+    requiredLabel: '',
+    validation: zodToAntdRules(registerformSchema.shape.modality),
+    message: 'Modality is optional',
+    trigger: ['onChange', 'onBlur'],
+  },
+  weight: {
+    label: 'Weight',
+    required: false,
+    requiredLabel: '',
+    validation: zodToAntdRules(registerformSchema.shape.weight),
+    message: 'Weight is optional',
+    trigger: ['onChange', 'onBlur'],
+  },
+  thickness: {
+    label: 'Thickness',
+    required: false,
+    requiredLabel: '',
+    validation: zodToAntdRules(registerformSchema.shape.thickness),
+    message: 'Thickness is optional',
+    trigger: ['onChange', 'onBlur'],
+  },
+  length: {
+    label: 'Length',
+    required: false,
+    requiredLabel: '',
+    validation: zodToAntdRules(registerformSchema.shape.length),
+    message: 'Length is optional',
+    trigger: ['onChange', 'onBlur'],
+  },
+  study_type: {
+    label: 'Study Type',
+    required: true,
+    requiredLabel: '',
+    validation: zodToAntdRules(registerformSchema.shape.study_type),
+    message: 'Study Type is optional',
+    trigger: ['onChange', 'onBlur'],
+  },
+  comment: {
+    label: 'Comment',
+    required: false,
+    requiredLabel: '',
+    validation: zodToAntdRules(registerformSchema.shape.comment),
+    message: 'Comment is optional',
+    trigger: ['onChange', 'onBlur'],
+  },
+} satisfies Record<
+  keyof Omit<RegisterInfo, IgnoredKeys>,
+  {
+    label: string;
+    required: boolean;
+    requiredLabel: string;
+    validation: Rule[];
+    message: string;
+    trigger: ('onChange' | 'onBlur' | 'onSubmit')[];
+  }
+>;
+
+export default registerformSchema;
+
+type AntdRule = Rule & {};
+
+export function zodToAntdRules(field: ZodTypeAny): AntdRule[] {
+  const rules: AntdRule[] = [];
+
+  // 解开 effects,比如 .refine() 或 .transform()
+  while (field instanceof ZodEffects) {
+    field = field._def.schema;
+  }
+
+  if (field instanceof ZodString) {
+    for (const check of field._def.checks) {
+      switch (check.kind) {
+        case 'min':
+          if (check.value === 1) {
+            rules.push({ required: true, message: check.message || '必填项' });
+          } else {
+            rules.push({ min: check.value, message: check.message ?? '' });
+          }
+          break;
+        case 'max':
+          rules.push({ max: check.value, message: check.message ?? '' });
+          break;
+        case 'email':
+          rules.push({ type: 'email', message: check.message ?? '' });
+          break;
+        case 'regex':
+          rules.push({ pattern: check.regex, message: check.message ?? '' });
+          break;
+        case 'length':
+          rules.push({ len: check.value, message: check.message ?? '' });
+          break;
+      }
+    }
+  }
+
+  if (field instanceof ZodNumber) {
+    for (const check of field._def.checks) {
+      switch (check.kind) {
+        case 'min':
+          rules.push({ min: check.value, message: check.message ?? '' });
+          break;
+        case 'max':
+          rules.push({ max: check.value, message: check.message ?? '' });
+          break;
+        case 'int':
+          rules.push({
+            type: 'integer',
+            message: check.message || '必须为整数',
+          });
+          break;
+      }
+    }
+  }
+
+  if (field instanceof ZodBoolean) {
+    rules.push({ type: 'boolean', message: '必须为布尔值' });
+  }
+
+  return rules;
+}