Browse Source

feat (1.64.0 -> 1.65.0): 实现身份证识别功能,支持自动拍照和手动上传两种方式

在患者注册表单中新增身份证识别功能,支持摄像头拍照和图片上传两种模式,自动识别身份证信息并填充到表单字段中,提升患者信息录入效率

- 新增 IDCardRecognitionModal 组件,支持全屏取景框拍照和文件上传两种模式
- 在患者姓名字段添加身份证识别按钮,点击后打开识别模态框
- 集成后端OCR接口 '/auth/task/ocr/id_card' 进行身份证信息识别
- 自动填充识别出的姓名、身份证号、性别、出生日期等信息到表单
- 优化Redux状态同步,确保表单数据与状态管理器保持一致
- 添加updateFormField action支持嵌套字段更新

改动文件:
- src/components/IDCardRecognitionModal.tsx
- src/pages/patient/components/register.form.tsx
- src/pages/patient/register.tsx
- src/states/patient/register/formSlice.ts
szy 3 days ago
parent
commit
b250909328

+ 19 - 1
CHANGELOG.md

@@ -2,6 +2,25 @@
 
 本项目的所有重要变更都将记录在此文件中.
 
+## [1.65.0] - 2026-01-09 21:30
+
+### 新增 (Added)
+
+- **实现身份证识别功能,支持自动拍照和手动上传两种方式** - 在患者注册表单中新增身份证识别功能,支持摄像头拍照和图片上传两种模式,自动识别身份证信息并填充到表单字段中
+  - 新增 IDCardRecognitionModal 组件,支持全屏取景框拍照和文件上传两种模式
+  - 在患者姓名字段添加身份证识别按钮,点击后打开识别模态框
+  - 集成后端OCR接口 `/auth/task/ocr/id_card` 进行身份证信息识别
+  - 自动填充识别出的姓名、身份证号、性别、出生日期等信息到表单
+  - 优化Redux状态同步,确保表单数据与状态管理器保持一致
+  - 添加updateFormField action支持嵌套字段更新
+
+**改动文件:**
+
+- src/components/IDCardRecognitionModal.tsx
+- src/pages/patient/components/register.form.tsx
+- src/pages/patient/register.tsx
+- src/states/patient/register/formSlice.ts
+
 ## [1.64.0] - 2026-01-09 17:14
 
 ### 新增 (Added)
@@ -12,7 +31,6 @@
 
 - src/layouts/BottomBar.tsx
 
-
 ## [1.63.3] - 2026-01-09 12:50
 
 ### 修复 (Fixed)

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "zsis",
-  "version": "1.64.0",
+  "version": "1.65.0",
   "private": true,
   "description": "医学成像系统",
   "main": "main.js",

+ 433 - 0
src/components/IDCardRecognitionModal.tsx

@@ -0,0 +1,433 @@
+import React, { useEffect, useRef, useState } from 'react';
+import { Modal, Button, Space, message, Spin, Upload } from 'antd';
+import {
+  CameraOutlined,
+  CloseOutlined,
+  ReloadOutlined,
+  UploadOutlined,
+  IdcardOutlined,
+} from '@ant-design/icons';
+import { CameraServiceFactory } from '@/services/camera/CameraServiceFactory';
+import axiosInstance from '@/API/interceptor';
+
+interface IDCardRecognitionModalProps {
+  visible: boolean;
+  onClose: () => void;
+  onRecognitionSuccess: (data: IDCardData) => void;
+}
+
+interface IDCardData {
+  name?: string;
+  idNumber?: string;
+  gender?: string;
+  birthDate?: string;
+  address?: string;
+}
+
+/**
+ * 身份证识别 Modal 组件
+ * 支持自动拍照和手动上传图片两种方式
+ */
+const IDCardRecognitionModal: React.FC<IDCardRecognitionModalProps> = ({
+  visible,
+  onClose,
+  onRecognitionSuccess,
+}) => {
+  const videoRef = useRef<HTMLVideoElement>(null);
+  const streamRef = useRef<MediaStream | null>(null);
+
+  const [capturedImage, setCapturedImage] = useState<string | null>(null);
+  const [uploadedImage, setUploadedImage] = useState<string | null>(null);
+  const [isInitializing, setIsInitializing] = useState(false);
+  const [isRecognizing, setIsRecognizing] = useState(false);
+  const [mode, setMode] = useState<'camera' | 'upload'>('camera');
+
+  /**
+   * 初始化摄像头
+   */
+  useEffect(() => {
+    if (visible && mode === 'camera' && !capturedImage) {
+      initCamera();
+    }
+
+    return () => {
+      stopCamera();
+    };
+  }, [visible, mode, capturedImage]);
+
+  /**
+   * 初始化摄像头
+   */
+  const initCamera = async () => {
+    setIsInitializing(true);
+
+    try {
+      console.log('[IDCardRecognition] 初始化摄像头...');
+
+      const cameraService = CameraServiceFactory.create();
+
+      const hasPermission = await cameraService.requestPermission();
+      if (!hasPermission) {
+        throw new Error('用户拒绝了摄像头权限');
+      }
+
+      const stream = await cameraService.getMediaStream({
+        video: {
+          width: { ideal: 1280 },
+          height: { ideal: 720 },
+          facingMode: 'environment', // 使用后置摄像头
+        },
+      });
+
+      streamRef.current = stream;
+
+      if (videoRef.current) {
+        videoRef.current.onloadedmetadata = async () => {
+          try {
+            if (videoRef.current) {
+              await videoRef.current.play();
+              console.log('[IDCardRecognition] 视频开始播放');
+            }
+          } catch (playError: any) {
+            console.error('[IDCardRecognition] 视频播放失败:', playError);
+            message.error('视频播放失败: ' + playError.message);
+          }
+        };
+
+        videoRef.current.srcObject = stream;
+      }
+
+      console.log('[IDCardRecognition] 摄像头初始化成功');
+    } catch (err: any) {
+      console.error('[IDCardRecognition] 摄像头初始化失败:', err);
+      message.error(err.message || '摄像头初始化失败');
+    } finally {
+      setIsInitializing(false);
+    }
+  };
+
+  /**
+   * 停止摄像头
+   */
+  const stopCamera = () => {
+    if (streamRef.current) {
+      console.log('[IDCardRecognition] 停止摄像头');
+      streamRef.current.getTracks().forEach((track) => track.stop());
+      streamRef.current = null;
+    }
+  };
+
+  /**
+   * 拍照
+   */
+  const handleCapture = async () => {
+    if (!streamRef.current) {
+      message.error('摄像头未就绪');
+      return;
+    }
+
+    try {
+      console.log('[IDCardRecognition] 开始拍照...');
+
+      const cameraService = CameraServiceFactory.create();
+      const base64Image = await cameraService.capturePhoto(streamRef.current);
+
+      console.log(
+        '[IDCardRecognition] 拍照成功,图片大小:',
+        base64Image.length
+      );
+
+      setCapturedImage(base64Image);
+      stopCamera();
+
+      message.success('拍照成功');
+    } catch (err: any) {
+      console.error('[IDCardRecognition] 拍照失败:', err);
+      message.error(err.message || '拍照失败');
+    }
+  };
+
+  /**
+   * 重拍
+   */
+  const handleRetake = () => {
+    console.log('[IDCardRecognition] 重拍');
+    setCapturedImage(null);
+  };
+
+  /**
+   * 上传文件处理
+   */
+  const handleUpload = (file: File) => {
+    const reader = new FileReader();
+    reader.onload = (e) => {
+      const result = e.target?.result as string;
+      setUploadedImage(result);
+      setCapturedImage(null);
+      message.success('图片上传成功');
+    };
+    reader.readAsDataURL(file);
+    return false; // 阻止自动上传
+  };
+
+  /**
+   * 识别身份证
+   */
+  const handleRecognize = async () => {
+    const imageData = capturedImage || uploadedImage;
+    if (!imageData) {
+      message.error('请先拍照或上传身份证图片');
+      return;
+    }
+
+    try {
+      setIsRecognizing(true);
+
+      console.log('[IDCardRecognition] 开始识别身份证...');
+
+      // 上传图片到后端进行识别
+      const response = await axiosInstance.post('/auth/task/ocr/id_card', {
+        type: 'png',
+        data: imageData.replace(/^data:image\/[a-z]+;base64,/, ''),
+      });
+
+      if (response.data.code !== '0x000000') {
+        throw new Error(response.data.description || '识别失败');
+      }
+
+      const recognitionData: IDCardData = response?.data?.data;
+
+      console.log('[IDCardRecognition] 识别成功:', recognitionData);
+
+      message.success('身份证识别成功');
+      await onRecognitionSuccess(recognitionData);
+      handleClose();
+    } catch (err: any) {
+      console.error('[IDCardRecognition] 识别失败:', err);
+      message.error(
+        err.response?.data?.description || err.message || '识别失败'
+      );
+    } finally {
+      setIsRecognizing(false);
+    }
+  };
+
+  /**
+   * 关闭 Modal
+   */
+  const handleClose = () => {
+    console.log('[IDCardRecognition] 关闭 Modal');
+    stopCamera();
+    setCapturedImage(null);
+    setUploadedImage(null);
+    onClose();
+  };
+
+  const uploadProps = {
+    beforeUpload: handleUpload,
+    showUploadList: false,
+    accept: 'image/*',
+  };
+
+  return (
+    <Modal
+      title={
+        <Space>
+          <IdcardOutlined />
+          身份证识别
+        </Space>
+      }
+      open={visible}
+      onCancel={handleClose}
+      footer={null}
+      width={800}
+      centered
+      destroyOnClose
+    >
+      <div style={{ textAlign: 'center' }}>
+        {/* 模式选择 */}
+        <Space style={{ marginBottom: 16 }}>
+          <Button
+            type={mode === 'camera' ? 'primary' : 'default'}
+            onClick={() => setMode('camera')}
+          >
+            自动拍照
+          </Button>
+          <Button
+            type={mode === 'upload' ? 'primary' : 'default'}
+            onClick={() => setMode('upload')}
+          >
+            手动上传
+          </Button>
+        </Space>
+
+        {/* 摄像头模式 */}
+        {mode === 'camera' && (
+          <div style={{ position: 'relative' }}>
+            {!capturedImage && (
+              <>
+                <video
+                  ref={videoRef}
+                  autoPlay
+                  playsInline
+                  muted
+                  style={{
+                    width: '100%',
+                    maxHeight: '400px',
+                    backgroundColor: '#000',
+                    borderRadius: 8,
+                    display: isInitializing ? 'none' : 'block',
+                  }}
+                />
+
+                {isInitializing && (
+                  <div
+                    style={{
+                      width: '100%',
+                      height: '400px',
+                      display: 'flex',
+                      alignItems: 'center',
+                      justifyContent: 'center',
+                      backgroundColor: '#000',
+                      borderRadius: 8,
+                    }}
+                  >
+                    <Spin size="large" tip="正在初始化摄像头..." />
+                  </div>
+                )}
+
+                {!isInitializing && (
+                  <Space style={{ marginTop: 16 }} size="large">
+                    <Button
+                      type="primary"
+                      size="large"
+                      icon={<CameraOutlined />}
+                      onClick={handleCapture}
+                    >
+                      拍照
+                    </Button>
+                    <Button
+                      size="large"
+                      icon={<CloseOutlined />}
+                      onClick={handleClose}
+                    >
+                      取消
+                    </Button>
+                  </Space>
+                )}
+              </>
+            )}
+
+            {capturedImage && (
+              <>
+                <img
+                  src={capturedImage}
+                  alt="拍摄的身份证"
+                  style={{
+                    width: '100%',
+                    maxHeight: '400px',
+                    borderRadius: 8,
+                    objectFit: 'contain',
+                  }}
+                />
+                <Space style={{ marginTop: 16 }} size="large">
+                  <Button
+                    type="primary"
+                    size="large"
+                    icon={<IdcardOutlined />}
+                    onClick={handleRecognize}
+                    loading={isRecognizing}
+                  >
+                    识别身份证
+                  </Button>
+                  <Button
+                    size="large"
+                    icon={<ReloadOutlined />}
+                    onClick={handleRetake}
+                    disabled={isRecognizing}
+                  >
+                    重拍
+                  </Button>
+                  <Button
+                    size="large"
+                    icon={<CloseOutlined />}
+                    onClick={handleClose}
+                    disabled={isRecognizing}
+                  >
+                    取消
+                  </Button>
+                </Space>
+              </>
+            )}
+          </div>
+        )}
+
+        {/* 上传模式 */}
+        {mode === 'upload' && (
+          <div>
+            {!uploadedImage ? (
+              <div
+                style={{
+                  padding: '40px',
+                  border: '2px dashed #d9d9d9',
+                  borderRadius: 8,
+                }}
+              >
+                <Upload {...uploadProps}>
+                  <Space direction="vertical">
+                    <UploadOutlined
+                      style={{ fontSize: 48, color: '#1890ff' }}
+                    />
+                    <div>点击或拖拽身份证图片到此处上传</div>
+                    <div style={{ color: '#999' }}>只支持 PNG 格式</div>
+                  </Space>
+                </Upload>
+              </div>
+            ) : (
+              <>
+                <img
+                  src={uploadedImage}
+                  alt="上传的身份证"
+                  style={{
+                    width: '100%',
+                    maxHeight: '400px',
+                    borderRadius: 8,
+                    objectFit: 'contain',
+                  }}
+                />
+                <Space style={{ marginTop: 16 }} size="large">
+                  <Button
+                    type="primary"
+                    size="large"
+                    icon={<IdcardOutlined />}
+                    onClick={handleRecognize}
+                    loading={isRecognizing}
+                  >
+                    识别身份证
+                  </Button>
+                  <Button
+                    size="large"
+                    onClick={() => setUploadedImage(null)}
+                    disabled={isRecognizing}
+                  >
+                    重新上传
+                  </Button>
+                  <Button
+                    size="large"
+                    icon={<CloseOutlined />}
+                    onClick={handleClose}
+                    disabled={isRecognizing}
+                  >
+                    取消
+                  </Button>
+                </Space>
+              </>
+            )}
+          </div>
+        )}
+      </div>
+    </Modal>
+  );
+};
+
+export default IDCardRecognitionModal;

+ 450 - 391
src/pages/patient/components/register.form.tsx

@@ -20,6 +20,9 @@ import {
   pregnancyStatusOptions,
 } from '@/domain/patient/pregnancyStatus';
 import { getGenderOptions } from '@/domain/patient/genderOptions';
+import { IdcardOutlined } from '@ant-design/icons';
+import IDCardRecognitionModal from '@/components/IDCardRecognitionModal';
+
 interface BasicInfoFormProps {
   style?: React.CSSProperties;
   form?: FormInstance;
@@ -43,6 +46,7 @@ const BasicInfoForm: React.FC<BasicInfoFormProps> = ({
   const dispatch = useDispatch();
   const [pregnancyStatusFieldVisible, setPregnancyStatusFieldVisible] =
     useState(false);
+  const [idCardModalVisible, setIdCardModalVisible] = useState(false);
 
   const intl = useIntl();
 
@@ -103,6 +107,11 @@ const BasicInfoForm: React.FC<BasicInfoFormProps> = ({
       const newDob = calculateDobFromAge(patient_age);
       form.setFieldsValue({ patient_dob: newDob });
       console.log('年龄变化,更新出生日期:', newDob.format('YYYY-MM-DD'));
+      const updateFormValues = {
+        ...form.getFieldsValue(),
+        patient_dob: newDob,
+      };
+      dispatch(setFormData(updateFormValues));
     }
   }, [patient_age, form]);
 
@@ -120,355 +129,398 @@ const BasicInfoForm: React.FC<BasicInfoFormProps> = ({
       const newAge = calculateAgeFromDob(patient_dob);
       form.setFieldsValue({ patient_age: newAge });
       console.log('出生日期变化,更新年龄:', newAge);
+      const updateFormValues = {
+        ...form.getFieldsValue(),
+        patient_age: newAge,
+      };
+      dispatch(setFormData(updateFormValues));
     }
   }, [patient_dob, form]);
+
+  // 处理身份证识别成功
+  const handleIDCardRecognitionSuccess = async (
+    data: Record<string, string>
+  ) => {
+    console.log('身份证识别成功:', data);
+
+    const formValues: any = {};
+
+    formValues.patient_name = data.name;
+    formValues.patient_sex = data.gender;
+    formValues.patient_dob = data.birthdate ? dayjs(data.birthdate) : dayjs();
+
+    // 更新表单
+    if (form && Object.keys(formValues).length > 0) {
+      const updateFormValues = {
+        ...form.getFieldsValue(),
+        ...formValues,
+      };
+      form.setFieldsValue(formValues);
+      dispatch(setFormData(updateFormValues));
+    }
+  };
+
   return (
-    <Form
-      form={form}
-      layout="vertical"
-      style={style}
-      className="px-2"
-      initialValues={
-        {
-          // patient_dob: dayjs(),
+    <>
+      <Form
+        form={form}
+        layout="vertical"
+        style={style}
+        className="px-2"
+        initialValues={
+          {
+            // patient_dob: dayjs(),
+          }
         }
-      }
-      onValuesChange={onValuesChange}
-    >
-      {/** 宠物专用 */}
-      {productName === 'VETDROS' && (
+        onValuesChange={onValuesChange}
+      >
+        {/** 宠物专用 */}
+        {productName === 'VETDROS' && (
+          <Form.Item
+            label={
+              <FormattedMessage
+                id="register.owner_name"
+                defaultMessage="register.owner_name"
+              />
+            }
+            name="owner_name"
+            required={registerFormFields.owner_name.required}
+            validateTrigger={registerFormFields.owner_name.trigger}
+            rules={registerFormFields.owner_name.validation}
+          >
+            <Input
+              placeholder={intl.formatMessage({
+                id: 'register.owner_name.placeholder',
+                defaultMessage: 'register.owner_name.placeholder',
+              })}
+            />
+          </Form.Item>
+        )}
         <Form.Item
           label={
             <FormattedMessage
-              id="register.owner_name"
-              defaultMessage="register.owner_name"
+              id={
+                productName === 'VETDROS'
+                  ? 'animal.register.patientName'
+                  : 'register.patientName'
+              }
+              defaultMessage={
+                productName === 'VETDROS'
+                  ? 'animal.register.patientName'
+                  : 'register.patientName'
+              }
             />
           }
-          name="owner_name"
-          required={registerFormFields.owner_name.required}
-          validateTrigger={registerFormFields.owner_name.trigger}
-          rules={registerFormFields.owner_name.validation}
+          name="patient_name"
+          required={registerFormFields.patient_name.required}
+          validateTrigger={registerFormFields.patient_name.trigger}
+          rules={registerFormFields.patient_name.validation}
         >
           <Input
             placeholder={intl.formatMessage({
-              id: 'register.owner_name.placeholder',
-              defaultMessage: 'register.owner_name.placeholder',
+              id:
+                productName === 'VETDROS'
+                  ? 'animal.register.patientName.placeholder'
+                  : 'register.patientName.placeholder',
+              defaultMessage:
+                productName === 'VETDROS'
+                  ? 'animal.register.patientName.placeholder'
+                  : 'register.patientName.placeholder',
             })}
-          />
-        </Form.Item>
-      )}
-      <Form.Item
-        label={
-          <FormattedMessage
-            id={
-              productName === 'VETDROS'
-                ? 'animal.register.patientName'
-                : 'register.patientName'
-            }
-            defaultMessage={
-              productName === 'VETDROS'
-                ? 'animal.register.patientName'
-                : 'register.patientName'
-            }
-          />
-        }
-        name="patient_name"
-        required={registerFormFields.patient_name.required}
-        validateTrigger={registerFormFields.patient_name.trigger}
-        rules={registerFormFields.patient_name.validation}
-      >
-        <Input
-          placeholder={intl.formatMessage({
-            id:
-              productName === 'VETDROS'
-                ? 'animal.register.patientName.placeholder'
-                : 'register.patientName.placeholder',
-            defaultMessage:
-              productName === 'VETDROS'
-                ? 'animal.register.patientName.placeholder'
-                : 'register.patientName.placeholder',
-          })}
-        />
-      </Form.Item>
-      <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
-            id={
-              productName === 'VETDROS'
-                ? 'animal.register.patientId'
-                : 'register.patientId'
-            }
-            defaultMessage={
-              productName === 'VETDROS'
-                ? 'animal.register.patientId'
-                : 'register.patientId'
+            suffix={
+              <IdcardOutlined
+                onClick={() => setIdCardModalVisible(true)}
+                style={{ cursor: 'pointer' }}
+                title="身份证识别"
+              />
             }
           />
-        }
-        name="patient_id"
-        required={registerFormFields.patient_id.required}
-        validateTrigger={registerFormFields.patient_id.trigger}
-        rules={registerFormFields.patient_id.validation}
-      >
-        <Input
-          placeholder={intl.formatMessage({
-            id:
-              productName === 'VETDROS'
-                ? 'animal.register.patientId.placeholder'
-                : 'register.patientId.placeholder',
-            defaultMessage:
-              productName === 'VETDROS'
-                ? 'animal.register.patientId.placeholder'
-                : 'register.patientId.placeholder',
-          })}
-        />
-      </Form.Item>
-
-      <Form.Item
-        label={
-          <FormattedMessage
-            id={
-              productName === 'VETDROS'
-                ? 'animal.register.patientSize'
-                : 'register.patientSize'
-            }
-            defaultMessage={
-              productName === 'VETDROS'
-                ? 'animal.register.patientSize'
-                : 'register.patientSize'
-            }
-          />
-        }
-        name="patient_size"
-        required={registerFormFields.patient_size.required}
-        validateTrigger={registerFormFields.patient_size.trigger}
-        rules={registerFormFields.patient_size.validation}
-        initialValue="Medium"
-      >
-        <Select
-          placeholder={intl.formatMessage({
-            id:
-              productName === 'VETDROS'
-                ? 'animal.register.patientSize.placeholder'
-                : 'register.patientSize.placeholder',
-            defaultMessage:
-              productName === 'VETDROS'
-                ? 'animal.register.patientSize.placeholder'
-                : 'register.patientSize.placeholder',
-          })}
-          defaultValue="Medium"
+        </Form.Item>
+        <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}
         >
-          <Select.Option value="Large">
-            {intl.formatMessage({ id: 'Large', defaultMessage: 'Large' })}
-          </Select.Option>
-          <Select.Option value="Medium">
-            {intl.formatMessage({ id: 'Medium', defaultMessage: 'Medium' })}
-          </Select.Option>
-          <Select.Option value="Small">
-            {intl.formatMessage({ id: 'Small', defaultMessage: 'Small' })}
-          </Select.Option>
-        </Select>
-      </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}
-        initialValue={{ number: 0, unit: 'Y' }}
-      >
-        <NumberWithUnit
-          align="baseline"
-          defaultUnit="Y"
-          defaultNumber={0}
-          unitClassName="w-full"
-          numberClassName="w-full"
-          className="w-full"
-          options={[
-            { label: '天', value: 'D' },
-            { label: '月', value: 'M' },
-            { label: '年', value: 'Y' },
-          ]}
-        />
-      </Form.Item>
-      <Form.Item
-        label={
-          <FormattedMessage
-            id="register.dateOfBirth"
-            defaultMessage="register.dateOfBirth"
+          <Input
+            placeholder={intl.formatMessage({
+              id: 'register.accessionNumber.placeholder',
+              defaultMessage: 'register.accessionNumber.placeholder',
+            })}
           />
-        }
-        name="patient_dob"
-        required={registerFormFields.patient_dob.required}
-        validateTrigger={registerFormFields.patient_dob.trigger}
-        rules={registerFormFields.patient_dob.validation}
-        initialValue={dayjs()}
-      >
-        <DatePicker
-          format="YYYY-MM-DD"
-          style={{ width: '100%' }}
-          defaultValue={dayjs()}
-          disabledDate={(current) => current && current > dayjs().endOf('day')}
-        />
-      </Form.Item>
-      <Form.Item
-        label={
-          <FormattedMessage
-            id="register.gender"
-            defaultMessage="register.gender"
+        </Form.Item>
+        <Form.Item
+          label={
+            <FormattedMessage
+              id={
+                productName === 'VETDROS'
+                  ? 'animal.register.patientId'
+                  : 'register.patientId'
+              }
+              defaultMessage={
+                productName === 'VETDROS'
+                  ? 'animal.register.patientId'
+                  : 'register.patientId'
+              }
+            />
+          }
+          name="patient_id"
+          required={registerFormFields.patient_id.required}
+          validateTrigger={registerFormFields.patient_id.trigger}
+          rules={registerFormFields.patient_id.validation}
+        >
+          <Input
+            placeholder={intl.formatMessage({
+              id:
+                productName === 'VETDROS'
+                  ? 'animal.register.patientId.placeholder'
+                  : 'register.patientId.placeholder',
+              defaultMessage:
+                productName === 'VETDROS'
+                  ? 'animal.register.patientId.placeholder'
+                  : 'register.patientId.placeholder',
+            })}
           />
-        }
-        name="patient_sex"
-        required={registerFormFields.patient_sex.required}
-        validateTrigger={registerFormFields.patient_sex.trigger}
-        rules={registerFormFields.patient_sex.validation}
-      >
-        <Select
-          options={genderOptions}
-          onChange={(e) => {
-            const _PregnancyStatusFieldVisible = e === 'M';
-            setPregnancyStatusFieldVisible(_PregnancyStatusFieldVisible);
-            if (_PregnancyStatusFieldVisible) {
-              // 更新表单
-              form?.setFieldValue('pregnancy_status', PregnancyStatus.UNKNOWN);
+        </Form.Item>
 
-              // 手动同步更新 Redux store
-              const currentValues = form?.getFieldsValue();
-              dispatch(
-                setFormData({
-                  ...currentValues,
-                  pregnancy_status: PregnancyStatus.UNKNOWN,
-                })
-              );
-            }
-          }}
-        />
-      </Form.Item>
-      {/** 宠物专用 */}
-      {productName === 'VETDROS' && (
         <Form.Item
           label={
             <FormattedMessage
-              id="register.sexNeutered"
-              defaultMessage="register.sexNeutered"
+              id={
+                productName === 'VETDROS'
+                  ? 'animal.register.patientSize'
+                  : 'register.patientSize'
+              }
+              defaultMessage={
+                productName === 'VETDROS'
+                  ? 'animal.register.patientSize'
+                  : 'register.patientSize'
+              }
             />
           }
-          name="sex_neutered"
-          required={registerFormFields.sex_neutered.required}
-          validateTrigger={registerFormFields.sex_neutered.trigger}
-          rules={registerFormFields.sex_neutered.validation}
+          name="patient_size"
+          required={registerFormFields.patient_size.required}
+          validateTrigger={registerFormFields.patient_size.trigger}
+          rules={registerFormFields.patient_size.validation}
+          initialValue="Medium"
         >
           <Select
-            defaultValue={'UNALTERED'}
             placeholder={intl.formatMessage({
-              id: 'register.sexNeutered.placeholder',
-              defaultMessage: 'register.sexNeutered.placeholder',
+              id:
+                productName === 'VETDROS'
+                  ? 'animal.register.patientSize.placeholder'
+                  : 'register.patientSize.placeholder',
+              defaultMessage:
+                productName === 'VETDROS'
+                  ? 'animal.register.patientSize.placeholder'
+                  : 'register.patientSize.placeholder',
             })}
+            defaultValue="Medium"
           >
-            <Select.Option value="ALTERED">
-              {intl.formatMessage({
-                id: 'register.sexNeutered.altered',
-                defaultMessage: 'ALTERED',
-              })}
+            <Select.Option value="Large">
+              {intl.formatMessage({ id: 'Large', defaultMessage: 'Large' })}
             </Select.Option>
-            <Select.Option value="UNALTERED">
-              {intl.formatMessage({
-                id: 'register.sexNeutered.unaltered',
-                defaultMessage: 'UNALTERED',
-              })}
+            <Select.Option value="Medium">
+              {intl.formatMessage({ id: 'Medium', defaultMessage: 'Medium' })}
+            </Select.Option>
+            <Select.Option value="Small">
+              {intl.formatMessage({ id: 'Small', defaultMessage: 'Small' })}
             </Select.Option>
           </Select>
         </Form.Item>
-      )}
-      {/** 人类专用 */}
-      {productName === 'DROS' && form?.getFieldValue('patient_sex') && (
         <Form.Item
-          hidden={pregnancyStatusFieldVisible}
           label={
-            <FormattedMessage
-              id="register.pregnancyStatus"
-              defaultMessage="register.pregnancyStatus"
-            />
+            <FormattedMessage id="register.age" defaultMessage="register.age" />
           }
-          name="pregnancy_status"
-          required={registerFormFields.pregnancy_status.required}
-          validateTrigger={registerFormFields.pregnancy_status.trigger}
-          rules={registerFormFields.pregnancy_status.validation}
-          initialValue={PregnancyStatus.UNKNOWN}
+          name="patient_age"
+          required={registerFormFields.patient_age.required}
+          validateTrigger={registerFormFields.patient_age.trigger}
+          rules={registerFormFields.patient_age.validation}
+          initialValue={{ number: 0, unit: 'Y' }}
         >
-          <Radio.Group
-            options={pregnancyStatusOptions}
-            optionType="button"
-            buttonStyle="solid"
+          <NumberWithUnit
+            align="baseline"
+            defaultUnit="Y"
+            defaultNumber={0}
+            unitClassName="w-full"
+            numberClassName="w-full"
+            className="w-full"
+            options={[
+              { label: '天', value: 'D' },
+              { label: '月', value: 'M' },
+              { label: '年', value: 'Y' },
+            ]}
           />
         </Form.Item>
-      )}
-      {/** 宠物专用 */}
-      {productName === 'VETDROS' && (
         <Form.Item
           label={
             <FormattedMessage
-              id="register.chipNumber"
-              defaultMessage="register.chipNumber"
+              id="register.dateOfBirth"
+              defaultMessage="register.dateOfBirth"
             />
           }
-          name="chip_number"
-          required={registerFormFields.chip_number.required}
-          validateTrigger={registerFormFields.chip_number.trigger}
-          rules={registerFormFields.chip_number.validation}
+          name="patient_dob"
+          required={registerFormFields.patient_dob.required}
+          validateTrigger={registerFormFields.patient_dob.trigger}
+          rules={registerFormFields.patient_dob.validation}
+          initialValue={dayjs()}
         >
-          <Input
-            placeholder={intl.formatMessage({
-              id: 'register.chipNumber.placeholder',
-              defaultMessage: 'register.chipNumber.placeholder',
-            })}
+          <DatePicker
+            format="YYYY-MM-DD"
+            style={{ width: '100%' }}
+            disabledDate={(current) =>
+              current && current > dayjs().endOf('day')
+            }
           />
         </Form.Item>
-      )}
-      {/** 宠物专用 */}
-      {productName === 'VETDROS' && (
         <Form.Item
           label={
             <FormattedMessage
-              id="register.variety"
-              defaultMessage="register.variety"
+              id="register.gender"
+              defaultMessage="register.gender"
             />
           }
-          name="variety"
-          required={registerFormFields.variety.required}
-          validateTrigger={registerFormFields.variety.trigger}
-          rules={registerFormFields.variety.validation}
+          name="patient_sex"
+          required={registerFormFields.patient_sex.required}
+          validateTrigger={registerFormFields.patient_sex.trigger}
+          rules={registerFormFields.patient_sex.validation}
         >
-          <Input
-            placeholder={intl.formatMessage({
-              id: 'register.variety.placeholder',
-              defaultMessage: 'register.variety.placeholder',
-            })}
+          <Select
+            options={genderOptions}
+            onChange={(e) => {
+              const _PregnancyStatusFieldVisible = e === 'M';
+              setPregnancyStatusFieldVisible(_PregnancyStatusFieldVisible);
+              if (_PregnancyStatusFieldVisible) {
+                // 更新表单
+                form?.setFieldValue(
+                  'pregnancy_status',
+                  PregnancyStatus.UNKNOWN
+                );
+
+                // 手动同步更新 Redux store
+                const currentValues = form?.getFieldsValue();
+                dispatch(
+                  setFormData({
+                    ...currentValues,
+                    pregnancy_status: PregnancyStatus.UNKNOWN,
+                  })
+                );
+              }
+            }}
           />
         </Form.Item>
-      )}
-      {/* <Form.Item
+        {/** 宠物专用 */}
+        {productName === 'VETDROS' && (
+          <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}
+          >
+            <Select
+              defaultValue={'UNALTERED'}
+              placeholder={intl.formatMessage({
+                id: 'register.sexNeutered.placeholder',
+                defaultMessage: 'register.sexNeutered.placeholder',
+              })}
+            >
+              <Select.Option value="ALTERED">
+                {intl.formatMessage({
+                  id: 'register.sexNeutered.altered',
+                  defaultMessage: 'ALTERED',
+                })}
+              </Select.Option>
+              <Select.Option value="UNALTERED">
+                {intl.formatMessage({
+                  id: 'register.sexNeutered.unaltered',
+                  defaultMessage: 'UNALTERED',
+                })}
+              </Select.Option>
+            </Select>
+          </Form.Item>
+        )}
+        {/** 人类专用 */}
+        {productName === 'DROS' &&
+          form?.getFieldValue('patient_sex') &&
+          form?.getFieldValue('patient_sex') !== 'M' && (
+            <Form.Item
+              hidden={pregnancyStatusFieldVisible}
+              label={
+                <FormattedMessage
+                  id="register.pregnancyStatus"
+                  defaultMessage="register.pregnancyStatus"
+                />
+              }
+              name="pregnancy_status"
+              required={registerFormFields.pregnancy_status.required}
+              validateTrigger={registerFormFields.pregnancy_status.trigger}
+              rules={registerFormFields.pregnancy_status.validation}
+              initialValue={PregnancyStatus.UNKNOWN}
+            >
+              <Radio.Group
+                options={pregnancyStatusOptions}
+                optionType="button"
+                buttonStyle="solid"
+              />
+            </Form.Item>
+          )}
+        {/** 宠物专用 */}
+        {productName === 'VETDROS' && (
+          <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.chipNumber.placeholder',
+                defaultMessage: 'register.chipNumber.placeholder',
+              })}
+            />
+          </Form.Item>
+        )}
+        {/** 宠物专用 */}
+        {productName === 'VETDROS' && (
+          <Form.Item
+            label={
+              <FormattedMessage
+                id="register.variety"
+                defaultMessage="register.variety"
+              />
+            }
+            name="variety"
+            required={registerFormFields.variety.required}
+            validateTrigger={registerFormFields.variety.trigger}
+            rules={registerFormFields.variety.validation}
+          >
+            <Input
+              placeholder={intl.formatMessage({
+                id: 'register.variety.placeholder',
+                defaultMessage: 'register.variety.placeholder',
+              })}
+            />
+          </Form.Item>
+        )}
+        {/* <Form.Item
         label={
           <FormattedMessage
             id="register.patientType"
@@ -487,106 +539,113 @@ const BasicInfoForm: React.FC<BasicInfoFormProps> = ({
           })}
         />
       </Form.Item> */}
-      <Form.Item
-        label={
-          <FormattedMessage
-            id="register.referringPhysician"
-            defaultMessage="register.referringPhysician"
-          />
-        }
-        name="ref_physician"
-        required={registerFormFields.ref_physician.required}
-        validateTrigger={registerFormFields.ref_physician.trigger}
-        rules={registerFormFields.ref_physician.validation}
-      >
-        <Input
-          placeholder={intl.formatMessage({
-            id: 'register.referringPhysician.placeholder',
-            defaultMessage: 'register.referringPhysician.placeholder',
-          })}
-        />
-      </Form.Item>
-      <Form.Item
-        label={
-          <FormattedMessage
-            id="register.operatorId"
-            defaultMessage="register.operatorId"
-          />
-        }
-        name="operator_id"
-        required={registerFormFields.operator_id.required}
-        validateTrigger={registerFormFields.operator_id.trigger}
-        rules={registerFormFields.operator_id.validation}
-      >
-        <Input
-          placeholder={intl.formatMessage({
-            id: 'register.operatorId.placeholder',
-            defaultMessage: 'register.operatorId.placeholder',
-          })}
-        />
-      </Form.Item>
-      <Form.Item
-        label={
-          <FormattedMessage
-            id="register.weight"
-            defaultMessage="register.weight"
-          />
-        }
-        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.thickness"
-            defaultMessage="register.thickness"
+        <Form.Item
+          label={
+            <FormattedMessage
+              id="register.referringPhysician"
+              defaultMessage="register.referringPhysician"
+            />
+          }
+          name="ref_physician"
+          required={registerFormFields.ref_physician.required}
+          validateTrigger={registerFormFields.ref_physician.trigger}
+          rules={registerFormFields.ref_physician.validation}
+        >
+          <Input
+            placeholder={intl.formatMessage({
+              id: 'register.referringPhysician.placeholder',
+              defaultMessage: 'register.referringPhysician.placeholder',
+            })}
           />
-        }
-        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.height"
-            defaultMessage="register.height"
+        </Form.Item>
+        <Form.Item
+          label={
+            <FormattedMessage
+              id="register.operatorId"
+              defaultMessage="register.operatorId"
+            />
+          }
+          name="operator_id"
+          required={registerFormFields.operator_id.required}
+          validateTrigger={registerFormFields.operator_id.trigger}
+          rules={registerFormFields.operator_id.validation}
+        >
+          <Input
+            placeholder={intl.formatMessage({
+              id: 'register.operatorId.placeholder',
+              defaultMessage: 'register.operatorId.placeholder',
+            })}
           />
-        }
-        name="length"
-        required={registerFormFields.length.required}
-        validateTrigger={registerFormFields.length.trigger}
-        rules={registerFormFields.length.validation}
-      >
-        <InputNumber min={0} addonAfter="cm" style={{ width: '100%' }} />
-      </Form.Item>
-      <Form.Item
-        label={
-          <FormattedMessage
-            id="register.comment"
-            defaultMessage="register.comment"
+        </Form.Item>
+        <Form.Item
+          label={
+            <FormattedMessage
+              id="register.weight"
+              defaultMessage="register.weight"
+            />
+          }
+          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.thickness"
+              defaultMessage="register.thickness"
+            />
+          }
+          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.height"
+              defaultMessage="register.height"
+            />
+          }
+          name="length"
+          required={registerFormFields.length.required}
+          validateTrigger={registerFormFields.length.trigger}
+          rules={registerFormFields.length.validation}
+        >
+          <InputNumber min={0} addonAfter="cm" style={{ width: '100%' }} />
+        </Form.Item>
+        <Form.Item
+          label={
+            <FormattedMessage
+              id="register.comment"
+              defaultMessage="register.comment"
+            />
+          }
+          name="comment"
+          required={registerFormFields.comment.required}
+          validateTrigger={registerFormFields.comment.trigger}
+          rules={registerFormFields.comment.validation}
+        >
+          <Input
+            placeholder={intl.formatMessage({
+              id: 'register.comment.placeholder',
+              defaultMessage: 'register.comment.placeholder',
+            })}
           />
-        }
-        name="comment"
-        required={registerFormFields.comment.required}
-        validateTrigger={registerFormFields.comment.trigger}
-        rules={registerFormFields.comment.validation}
-      >
-        <Input
-          placeholder={intl.formatMessage({
-            id: 'register.comment.placeholder',
-            defaultMessage: 'register.comment.placeholder',
-          })}
-        />
-      </Form.Item>
-    </Form>
+        </Form.Item>
+      </Form>
+
+      <IDCardRecognitionModal
+        visible={idCardModalVisible}
+        onClose={() => setIdCardModalVisible(false)}
+        onRecognitionSuccess={handleIDCardRecognitionSuccess}
+      />
+    </>
   );
 };
 

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

@@ -22,7 +22,7 @@ import utc from 'dayjs/plugin/utc';
 import { View } from '@/API/patient/viewActions';
 import BodyPositionFilter from './components/bodyPositionFilter';
 import { omitAnimalSchemaMap } from '@/domain/animalSpecificInfo';
-import store, { RootState } from '@/states/store';
+import store, { RootState, useAppSelector } from '@/states/store';
 import { omitHumanSchemaMap } from '@/domain/humanSpecificInfo';
 import { setBusinessFlow } from '@/states/BusinessFlowSlice';
 import { selectRegisterInfo } from '@/states/patient/reregister/reregisterSlice';
@@ -43,7 +43,7 @@ const RegisterPage: React.FC = () => {
   const productName = useSelector(
     (state: RootState) => state.product.productName
   );
-  const { formData } = useSelector((state: RootState) => state.form);
+  const { formData } = useAppSelector((state: RootState) => state.form);
   const reregisterInfo = useSelector(selectRegisterInfo);
 
   // 清理时机3:组件卸载时清理表单

+ 13 - 1
src/states/patient/register/formSlice.ts

@@ -1,4 +1,5 @@
 import { createSlice, PayloadAction } from '@reduxjs/toolkit';
+import { set } from 'lodash';
 
 interface FormState {
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -23,9 +24,20 @@ const formSlice = createSlice({
       state.formData = {};
       console.log('Redux formSlice: 表单数据已清空');
     },
+    updateFormField(
+      state,
+      action: PayloadAction<{
+        path: string;
+        value: Record<string, any>;
+      }>
+    ) {
+      const { path, value } = action.payload;
+      set(state, path, value);
+    },
   },
 });
 
-export const { setFormData, getFormData, clearFormData } = formSlice.actions;
+export const { setFormData, getFormData, clearFormData, updateFormField } =
+  formSlice.actions;
 export type { FormState };
 export default formSlice.reducer;