Переглянути джерело

feat (1.69.0 -> 1.70.0): 实现扫码枪GBK中文字符解析功能

- 添加 iconv-lite 依赖用于GBK编码转换
- 实现Alt码序列解析,支持扫码枪输入的GBK编码中文
- 添加 convertAltCodeToChar 方法将GBK码转换为中文字符
- 添加 parseAltCodeSequence 方法解析 <ALT>数字序列
- 增强键盘事件监听,捕获Alt键和数字键组合
- 添加Alt键状态跟踪和缓冲区管理
- 优化Tab键处理逻辑,直接添加到缓冲区
- 为隐藏input添加 data-testid 属性便于测试

改动文件:
- package.json (版本号更新 + 添加依赖)
- package-lock.json (依赖锁定文件)
- src/pages/patient/components/register.form.tsx (核心功能实现)
- CHANGELOG.md (变更日志更新)
dengdx 7 годин тому
батько
коміт
1c9ed1e9ac
4 змінених файлів з 228 додано та 26 видалено
  1. 18 0
      CHANGELOG.md
  2. 52 9
      package-lock.json
  3. 2 1
      package.json
  4. 156 16
      src/pages/patient/components/register.form.tsx

+ 18 - 0
CHANGELOG.md

@@ -2,6 +2,24 @@
 
 本项目的所有重要变更都将记录在此文件中.
 
+## [1.70.0] - 2026-01-19 20:58
+
+feat (1.69.0 -> 1.70.0): 实现扫码枪GBK中文字符解析功能
+
+- 添加 iconv-lite 依赖用于GBK编码转换
+- 实现Alt码序列解析,支持扫码枪输入的GBK编码中文
+- 添加 convertAltCodeToChar 方法将GBK码转换为中文字符
+- 添加 parseAltCodeSequence 方法解析 <ALT>数字序列
+- 增强键盘事件监听,捕获Alt键和数字键组合
+- 添加Alt键状态跟踪和缓冲区管理
+- 优化Tab键处理逻辑,直接添加到缓冲区
+- 为隐藏input添加 data-testid 属性便于测试
+
+改动文件:
+- package.json (版本号更新 + 添加依赖)
+- package-lock.json (依赖锁定文件)
+- src/pages/patient/components/register.form.tsx (核心功能实现)
+
 ## [1.69.0] - 2026-01-19 10:36
 
 feat (1.68.0 -> 1.69.0): 新增扫码枪状态指示器组件并集成到患者注册表单

+ 52 - 9
package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "zsis",
-  "version": "1.41.0",
+  "version": "1.69.0",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "zsis",
-      "version": "1.41.0",
+      "version": "1.69.0",
       "dependencies": {
         "@babel/runtime": "^7.24.4",
         "@cornerstonejs/core": "^3.28.0",
@@ -38,6 +38,7 @@
         "dicomweb-client": "0.10.4",
         "glob": "^11.0.3",
         "html5-qrcode": "^2.3.8",
+        "iconv-lite": "^0.7.2",
         "jsqr": "^1.4.0",
         "mitt": "^3.0.1",
         "moment": "^2.30.1",
@@ -10330,6 +10331,19 @@
         "ms": "2.0.0"
       }
     },
+    "node_modules/body-parser/node_modules/iconv-lite": {
+      "version": "0.4.24",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+      "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
     "node_modules/body-parser/node_modules/ms": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
@@ -15355,6 +15369,19 @@
         "node": ">=4"
       }
     },
+    "node_modules/external-editor/node_modules/iconv-lite": {
+      "version": "0.4.24",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+      "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
     "node_modules/extract-zip": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
@@ -17132,15 +17159,19 @@
       }
     },
     "node_modules/iconv-lite": {
-      "version": "0.4.24",
-      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
-      "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
-      "dev": true,
+      "version": "0.7.2",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
+      "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
+      "license": "MIT",
       "dependencies": {
-        "safer-buffer": ">= 2.1.2 < 3"
+        "safer-buffer": ">= 2.1.2 < 3.0.0"
       },
       "engines": {
         "node": ">=0.10.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
       }
     },
     "node_modules/ics": {
@@ -24790,6 +24821,19 @@
         "node": ">= 0.8"
       }
     },
+    "node_modules/raw-body/node_modules/iconv-lite": {
+      "version": "0.4.24",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+      "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
     "node_modules/rc": {
       "version": "1.2.8",
       "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
@@ -26376,8 +26420,7 @@
     "node_modules/safer-buffer": {
       "version": "2.1.2",
       "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
-      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
-      "dev": true
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
     },
     "node_modules/sanitize-filename": {
       "version": "1.6.3",

+ 2 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "zsis",
-  "version": "1.69.0",
+  "version": "1.70.0",
   "private": true,
   "description": "医学成像系统",
   "main": "main.js",
@@ -83,6 +83,7 @@
     "dicomweb-client": "0.10.4",
     "glob": "^11.0.3",
     "html5-qrcode": "^2.3.8",
+    "iconv-lite": "^0.7.2",
     "jsqr": "^1.4.0",
     "mitt": "^3.0.1",
     "moment": "^2.30.1",

+ 156 - 16
src/pages/patient/components/register.form.tsx

@@ -24,6 +24,7 @@ import {
 import { getGenderOptions } from '@/domain/patient/genderOptions';
 import { processQRCodeData, transformToFormData } from '@/domain/qrcode/qrCodeDataProcessor';
 import BarcodeScannerIndicator from '@/components/BarcodeScannerIndicator';
+import * as iconv from 'iconv-lite';
 
 interface BasicInfoFormProps {
   style?: React.CSSProperties;
@@ -55,6 +56,8 @@ const BasicInfoForm: React.FC<BasicInfoFormProps> = ({
   const scanInputRef = useRef<HTMLInputElement>(null);
   const [isComposing, setIsComposing] = useState(false);
   const bufferRef = useRef('');
+  const altCodeBufferRef = useRef(''); // 用于累积Alt+数字序列
+  const isAltPressedRef = useRef(false); // 跟踪Alt键状态
   const timerRef = useRef<number | null>(null);
 
   // 同步初始表单值到 Redux store,并生成登记号
@@ -166,8 +169,10 @@ const BasicInfoForm: React.FC<BasicInfoFormProps> = ({
       console.log('[扫码枪调试] 得到的扫码数据是:', JSON.stringify(scannedText));
       console.log('[扫码枪调试] 扫码数据长度:', scannedText.length);
 
+          // 解析Alt码序列
+    const parsedData = parseAltCodeSequence(bufferRef.current);
       // 使用二维码数据处理器解析扫码文本(复用现有逻辑)
-      const result = processQRCodeData(scannedText);
+      const result = processQRCodeData(parsedData);
       if (!result.success) {
         message.error(result.error || '扫码枪数据处理失败');
         return;
@@ -190,6 +195,91 @@ const BasicInfoForm: React.FC<BasicInfoFormProps> = ({
     }
   }, [form, dispatch]);
 
+  // 处理Alt键释放,将GBK码转换为中文字符
+  const convertAltCodeToChar = (altCode: string): string => {
+    if (!altCode || altCode.length === 0) {
+      console.log('[扫码枪调试] convertAltCodeToChar: 空字符串');
+      return '';
+    }
+
+    const code = parseInt(altCode);
+    if (isNaN(code) || code < 0 || code > 65535) {
+      console.warn('[扫码枪调试] convertAltCodeToChar: 无效的数字:', altCode);
+      return '';
+    }
+
+    try {
+      // 对于GBK双字节字符(>255),使用iconv-lite进行解码
+      if (code > 255) {
+        // 将十进制GBK码转换为字节数组
+        const highByte = (code >> 8) & 0xFF;
+        const lowByte = code & 0xFF;
+
+        console.log('[扫码枪调试] 尝试GBK解码:', {
+          altCode,
+          code,
+          highByte: highByte.toString(16),
+          lowByte: lowByte.toString(16)
+        });
+
+        // 确保是有效的GBK编码范围
+        if (highByte >= 0x81 && highByte <= 0xFE && lowByte >= 0x40 && lowByte <= 0xFE) {
+          const gbkBytes = new Uint8Array([highByte, lowByte]);
+          const decodedString = iconv.decode(gbkBytes, 'gbk');
+
+          console.log('[扫码枪调试] GBK解码成功:', altCode, `-> "${decodedString}" (charCode: ${decodedString.charCodeAt(0)})`);
+          return decodedString;
+        } else {
+          console.warn('[扫码枪调试] 无效的GBK编码范围:', altCode, `(0x${highByte.toString(16)}, 0x${lowByte.toString(16)})`);
+          return '';
+        }
+      } else {
+        // 对于单字节字符(ASCII),直接返回
+        const char = String.fromCharCode(code);
+        console.log('[扫码枪调试] ASCII转换:', altCode, `-> "${char}" (可见: ${char === '\t' ? '\\t' : char === ' ' ? '<space>' : char})`);
+        return char;
+      }
+    } catch (error) {
+      console.warn('[扫码枪调试] GBK解码失败:', altCode, error);
+      return '';
+    }
+  };
+
+  // 解析Alt码序列:将 <ALT>52962<ALT>54257... 转换为 吴玉...
+  const parseAltCodeSequence = (rawText: string): string => {
+    console.log('[扫码枪调试] 开始解析Alt码序列:', rawText);
+    
+    // 使用正则表达式匹配 <ALT>后跟5位数字的模式
+    const altCodePattern = /<ALT>(\d{5})/g;
+    
+    let result = rawText;
+    let match;
+    
+    while ((match = altCodePattern.exec(rawText)) !== null) {
+      const altMarker = match[0]; // <ALT>52962
+      const altCode = match[1];    // 52962
+      
+      // 尝试转换Alt码为字符
+      const convertedChar = convertAltCodeToChar(altCode);
+      
+      if (convertedChar) {
+        // 替换 <ALT>数字 为转换后的字符
+        result = result.replace(altMarker, convertedChar);
+        console.log('[扫码枪调试] 替换Alt码:', altMarker, '->', convertedChar);
+      } else {
+        // 如果转换失败,可能Windows已经转换过了,删除<ALT>标记
+        result = result.replace(altMarker, '');
+        console.log('[扫码枪调试] 删除Alt标记:', altMarker);
+      }
+    }
+    
+    // 清理剩余的 <ALT> 标记(没有跟5位数字的)
+    result = result.replace(/<ALT>/g, '');
+    
+    console.log('[扫码枪调试] 解析完成,结果:', result);
+    return result;
+  };
+
   // 扫码枪扫码监听器:页面级 keydown 监听,自动切换焦点到隐藏input
   useEffect(() => {
     const handleKeyDown = (e: KeyboardEvent) => {
@@ -206,11 +296,41 @@ const BasicInfoForm: React.FC<BasicInfoFormProps> = ({
         scanInputRef.current?.focus();
       }
 
+      // 添加调试日志,记录每个按键事件
+      console.log('[扫码枪调试] KeyDown事件:', {
+        key: e.key,
+        code: e.code,
+        ctrlKey: e.ctrlKey,
+        altKey: e.altKey,
+        shiftKey: e.shiftKey,
+        metaKey: e.metaKey,
+        isEditable,
+        currentBuffer: bufferRef.current,
+        altCodeBuffer: altCodeBufferRef.current,
+        isAltPressed: isAltPressedRef.current
+      });
+
+      // 处理Alt键按下 - 将Alt作为标记累积到缓冲区
+      if (e.key === 'Alt') {
+        // 只在Alt键首次按下时添加标记,避免重复
+        if (!isAltPressedRef.current) {
+          isAltPressedRef.current = true;
+          bufferRef.current += '<ALT>';
+          console.log('[扫码枪调试] Alt键首次按下,添加标记');
+        } else {
+          console.log('[扫码枪调试] Alt键已按下,忽略重复事件');
+        }
+        e.preventDefault();
+        e.stopPropagation();
+        return;
+      }
+
       // 扫码枪通常以 Enter 结束
       if (e.key === 'Enter') {
         if (bufferRef.current) {
           processScannedData(bufferRef.current);
           bufferRef.current = '';
+          altCodeBufferRef.current = '';
           if (scanInputRef.current) {
             scanInputRef.current.value = '';
           }
@@ -223,29 +343,48 @@ const BasicInfoForm: React.FC<BasicInfoFormProps> = ({
         e.preventDefault();
         e.stopPropagation();
 
-        // 手动将Tab字符插入到隐藏input中
+        // 直接添加到缓冲区,不依赖DOM输入
+        bufferRef.current += '\t';
+
+        // 同时更新隐藏input的值(用于中文输入兼容性)
         const input = scanInputRef.current;
         if (input) {
-          const start = input.selectionStart || 0;
-          const end = input.selectionEnd || 0;
-          const value = input.value;
-
-          // 在光标位置插入Tab字符
-          input.value = value.substring(0, start) + '\t' + value.substring(end);
-
-          // 更新光标位置
-          input.selectionStart = input.selectionEnd = start + 1;
-
-          // 手动触发input事件,确保React能检测到变化
+          input.value = bufferRef.current;
           input.dispatchEvent(new Event('input', { bubbles: true }));
         }
+      } else if (/^\d$/.test(e.key) || /^[a-zA-Z]$/.test(e.key)) {
+        // 捕获所有数字和字母键(无论是Numpad还是Digit,无论Alt键状态)
+        bufferRef.current += e.key;
+        console.log('[扫码枪调试] 累积字符:', e.key, '当前缓冲区:', bufferRef.current);
+        
+        // 阻止默认行为,避免Windows自动转换Alt码
+        e.preventDefault();
+        e.stopPropagation();
+      } else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
+        // 其他可打印字符(如空格、标点等)
+        bufferRef.current += e.key;
+        console.log('[扫码枪调试] 累积字符:', e.key, '当前缓冲区:', bufferRef.current);
+        
+        // 阻止默认行为
+        e.preventDefault();
+        e.stopPropagation();
+      }
+      // 对于其他控制键(如Shift、Ctrl等),不阻止,让它们正常工作
+    };
+
+    const handleKeyUp = (e: KeyboardEvent) => {
+      if (e.key === 'Alt') {
+        console.log('[扫码枪调试] Alt键释放');
+        isAltPressedRef.current = false;
       }
-      // 其他字符不阻止,让它们自然输入到隐藏input中
-      // input会自动过滤掉无效字符,只保留有效的文本
     };
 
     window.addEventListener('keydown', handleKeyDown);
-    return () => window.removeEventListener('keydown', handleKeyDown);
+    window.addEventListener('keyup', handleKeyUp);
+    return () => {
+      window.removeEventListener('keydown', handleKeyDown);
+      window.removeEventListener('keyup', handleKeyUp);
+    };
   }, [processScannedData]);
 
   /** input 事件:真正可靠地拿中文 */
@@ -745,6 +884,7 @@ const BasicInfoForm: React.FC<BasicInfoFormProps> = ({
       {/* 扫码枪兜底 input(隐藏) */}
       <input
         ref={scanInputRef}
+        data-testid="scancode-input"
         type="text"
         style={{
           position: 'fixed',