/** * Content Script - 注入到HIS页面的脚本 * 功能: 添加语音按钮、使用浏览器语音识别、调用后端AI服务、自动填表 * 支持: 多页面配置、自定义提示词模板、自定义字段映射 */ // 配置信息 const CONFIG = { // 后端服务地址 - 提供千问大模型服务 BACKEND_URL: 'http://localhost:8080', API_ENDPOINT: '/api/extract', // 按钮样式配置 BUTTON_STYLE: { position: 'fixed', bottom: '100px', right: '30px', zIndex: 10000, padding: '15px 20px', backgroundColor: '#4CAF50', color: 'white', border: 'none', borderRadius: '50px', cursor: 'pointer', fontSize: '16px', fontWeight: 'bold', boxShadow: '0 4px 6px rgba(0,0,0,0.1)', transition: 'all 0.3s ease' }, // 录音状态指示器样式 RECORDING_STYLE: { backgroundColor: '#f44336', animation: 'pulse 1.5s infinite' } }; // 录音状态 let isRecording = false; let recognition = null; let recordingTimer = null; let recordingSeconds = 0; const MAX_RECORDING_SECONDS = 60; // 最长录音时间 60 秒 // 当前页面配置 let currentPageConfig = null; // 存储已填写的字段(用于多次提交覆盖) let filledFields = {}; /** * 初始化: 在页面加载完成后添加语音按钮 */ function init() { console.log('[医疗语音助手] init() 被调用'); // 加载当前页面的配置(异步) loadPageConfig().then(config => { currentPageConfig = config; console.log('[医疗语音助手] 页面配置已加载:', config); // 添加语音按钮 addVoiceButton(); }).catch(error => { console.error('[医疗语音助手] 加载配置失败:', error); // 即使配置加载失败,也添加按钮(使用默认配置) addVoiceButton(); }); } /** * 加载当前页面的配置 */ async function loadPageConfig() { try { // 获取当前页面 URL const currentUrl = window.location.href; console.log('[医疗语音助手] 当前页面 URL:', currentUrl); // 从存储中读取所有配置 const result = await chrome.storage.local.get('pageConfigs'); const configs = result.pageConfigs || []; console.log('[医疗语音助手] 已保存的配置数量:', configs.length); // 查找匹配的配置 const matchedConfig = configs.find(config => { const matched = matchUrlPattern(currentUrl, config.urlPattern); if (matched) { console.log('[医疗语音助手] 匹配到配置:', config.urlPattern); } return matched; }); if (matchedConfig) { console.log('[医疗语音助手] 找到配置:', matchedConfig); return matchedConfig; } console.log('[医疗语音助手] 未找到匹配配置,使用默认配置'); return null; } catch (error) { console.error('[医疗语音助手] 加载配置失败:', error); return null; } } /** * 匹配 URL 模式 */ function matchUrlPattern(url, pattern) { // 将通配符 * 转换为正则表达式 const regexPattern = pattern .replace(/[.+?^${}()|[\]\\]/g, '\\$&') // 转义特殊字符 .replace(/\*/g, '.*'); // * 转换为 .* const regex = new RegExp(`^${regexPattern}$`); return regex.test(url); } /** * 添加语音按钮到页面 */ function addVoiceButton() { // 检查是否已存在按钮 if (document.getElementById('medical-voice-btn')) { return; } // 创建按钮 const voiceButton = document.createElement('button'); voiceButton.id = 'medical-voice-btn'; voiceButton.innerHTML = '🎤 语音输入'; Object.assign(voiceButton.style, CONFIG.BUTTON_STYLE); // 添加点击事件 voiceButton.addEventListener('click', toggleRecording); // 添加到页面 document.body.appendChild(voiceButton); console.log('[医疗语音助手] 按钮已添加'); } /** * 切换录音状态(微信模式) * 点击开始录音,再次点击或60秒后自动结束 */ async function toggleRecording() { const button = document.getElementById('medical-voice-btn'); if (!isRecording) { // === 开始录音 === try { // 使用 Web Speech API 进行语音识别 const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; if (!SpeechRecognition) { showMessage('❌ 您的浏览器不支持语音识别'); return; } recognition = new SpeechRecognition(); recognition.lang = 'zh-CN'; recognition.continuous = true; // 持续识别 recognition.interimResults = false; // 不返回临时结果 recognition.onstart = () => { console.log('[医疗语音助手] 录音已开始'); isRecording = true; recordingSeconds = 0; // 更新按钮样式和文字 button.innerHTML = '⏹️ 停止录音 (60s)'; Object.assign(button.style, CONFIG.RECORDING_STYLE); showMessage('🎤 正在录音...请说话(再次点击按钮结束录音)'); // 开始倒计时 startRecordingTimer(button); }; recognition.onresult = (event) => { // 获取所有识别结果 let finalTranscript = ''; for (let i = event.resultIndex; i < event.results.length; i++) { if (event.results[i].isFinal) { finalTranscript += event.results[i][0].transcript; } } if (finalTranscript) { console.log('[医疗语音助手] 识别结果:', finalTranscript); // 累积识别的文本(用于后续处理) if (!window.accumulatedTranscript) { window.accumulatedTranscript = ''; } window.accumulatedTranscript += finalTranscript; showMessage(`🎤 正在录音...已识别 ${window.accumulatedTranscript.length} 字(${MAX_RECORDING_SECONDS - recordingSeconds}s 后自动结束)`); } }; recognition.onerror = (event) => { console.error('[医疗语音助手] 语音识别错误:', event.error); let errorMsg = '语音识别失败: ' + event.error; if (event.error === 'no-speech') { errorMsg = '未检测到语音,请重试'; } else if (event.error === 'audio-capture') { errorMsg = '无法访问麦克风,请检查权限'; } else if (event.error === 'not-allowed') { errorMsg = '麦克风权限被拒绝'; } showMessage('❌ ' + errorMsg); // 延迟重置状态 setTimeout(() => { // 清除倒计时 if (recordingTimer) { clearInterval(recordingTimer); recordingTimer = null; } // 重置状态 isRecording = false; // 恢复按钮样式 const button = document.getElementById('medical-voice-btn'); if (button) { button.innerHTML = '🎤 语音输入'; button.style.backgroundColor = CONFIG.BUTTON_STYLE.backgroundColor; } hideMessage(); }, 2000); }; recognition.onend = () => { console.log('[医疗语音助手] 语音识别结束,recordingSeconds=', recordingSeconds); console.log('[医疗语音助手] 累积识别文本长度:', window.accumulatedTranscript?.length || 0); // 清除倒计时 if (recordingTimer) { clearInterval(recordingTimer); recordingTimer = null; } // 恢复按钮样式 const button = document.getElementById('medical-voice-btn'); if (button) { button.innerHTML = '🎤 语音输入'; button.style.backgroundColor = CONFIG.BUTTON_STYLE.backgroundColor; } // 无论何种原因结束,都处理结果 const transcript = window.accumulatedTranscript?.trim() || ''; console.log('[医疗语音助手] 最终识别文本长度:', transcript.length); // 清空累积文本 window.accumulatedTranscript = ''; if (transcript) { console.log('[医疗语音助手] 准备处理识别结果'); // 延迟一小段时间,确保 UI 更新完成 setTimeout(() => { processText(transcript); }, 100); } else { console.log('[医疗语音助手] 无识别结果'); showMessage('⚠️ 未识别到语音内容'); setTimeout(() => hideMessage(), 2000); } // 重置状态 isRecording = false; }; // 清空累积文本 window.accumulatedTranscript = ''; // 开始识别 recognition.start(); } catch (error) { console.error('[医疗语音助手] 无法启动录音:', error); showMessage('❌ 无法启动录音: ' + error.message); setTimeout(() => hideMessage(), 3000); } } else { // === 手动停止录音 === console.log('[医疗语音助手] 手动停止录音,请求停止识别'); isRecording = false; // 先标记为停止,但不立即处理结果 // 停止识别,等待 onend 事件触发后再处理结果 if (recognition) { recognition.stop(); } // 恢复按钮样式 button.innerHTML = '🎤 语音输入'; button.style.backgroundColor = CONFIG.BUTTON_STYLE.backgroundColor; showMessage('⏳ 正在处理识别结果...'); } } /** * 开始录音倒计时 */ function startRecordingTimer(button) { recordingTimer = setInterval(() => { recordingSeconds++; const remainingSeconds = MAX_RECORDING_SECONDS - recordingSeconds; // 更新按钮倒计时 button.innerHTML = `⏹️ 停止录音 (${remainingSeconds}s)`; // 达到最大时长,自动停止 if (recordingSeconds >= MAX_RECORDING_SECONDS) { showMessage('⏰ 录音时间已到,正在处理...'); if (recognition) { recognition.stop(); // 停止识别,会触发 onend 事件 } } }, 1000); } /** * 处理文本: 调用后端AI服务 -> 结构化 -> 填表 */ async function processText(text) { try { // 步骤1: 调用后端千问大模型提取医疗信息 showMessage('🤖 正在分析医疗信息...'); const structuredData = await extractMedicalInfo(text); console.log('[医疗语音助手] 结构化数据:', structuredData); // 步骤2: 自动填表 showMessage('✍️ 正在填写表单...'); const filled = fillForm(structuredData); if (filled) { showMessage('✅ 填写完成!'); setTimeout(() => hideMessage(), 3000); } else { showMessage('⚠️ 未找到可填写的表单字段'); setTimeout(() => hideMessage(), 3000); } } catch (error) { console.error('[医疗语音助手] 处理失败:', error); showMessage('❌ 处理失败: ' + error.message); setTimeout(() => hideMessage(), 5000); } } /** * 调用后端千问大模型服务,提取医疗信息 */ async function extractMedicalInfo(text) { let url = CONFIG.BACKEND_URL + CONFIG.API_ENDPOINT; // 如果配置了提示词模板,添加模板参数 if (currentPageConfig && currentPageConfig.promptTemplate) { url += `?templateId=${encodeURIComponent(currentPageConfig.promptTemplate)}`; } console.log('[医疗语音助手] 准备调用后端接口:', url); console.log('[医疗语音助手] 请求数据:', text); const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: text // 直接发送文本,不需要 JSON.stringify() }); console.log('[医疗语音助手] 后端响应状态:', response.status); if (!response.ok) { const errorText = await response.text(); console.error('[医疗语音助手] 后端错误响应:', errorText); throw new Error(`后端服务返回错误: ${response.status} - ${errorText}`); } const result = await response.json(); console.log('[医疗语音助手] 后端返回数据:', result); return result; } /** * 自动填写表单(支持多次提交覆盖) */ function fillForm(data) { let filledCount = 0; // 获取字段映射配置 let fieldMappings; if (currentPageConfig && currentPageConfig.fieldMapping) { try { fieldMappings = JSON.parse(currentPageConfig.fieldMapping); console.log('[医疗语音助手] 使用自定义字段映射:', fieldMappings); } catch (error) { console.error('[医疗语音助手] 解析字段映射失败,使用默认映射:', error); fieldMappings = getDefaultFieldMappings(); } } else { fieldMappings = getDefaultFieldMappings(); } // 更新已填写的字段记录(用于后续覆盖) for (const [field, value] of Object.entries(data)) { if (value) { filledFields[field] = value; } } // 遍历所有已填写的字段(包括之前填写的) for (const [field, value] of Object.entries(filledFields)) { if (!value) continue; // 特殊处理:性别字段(单选框) if (field === 'patientGender') { const filled = fillGenderField(value, fieldMappings); if (filled) { filledCount++; console.log(`[医疗语音助手] 已填写字段: ${field} = ${value}`); } continue; } // 特殊处理:症状字段(多选框) if (field === 'symptoms' && Array.isArray(value)) { const filled = fillSymptomsField(value); if (filled) { filledCount++; console.log(`[医疗语音助手] 已填写字段: ${field} = [${value.join(', ')}]`); } continue; } // 普通字段处理(input、textarea、select) let fillValue = value; if (Array.isArray(value)) { fillValue = value.join('、'); } const possibleNames = fieldMappings[field] || []; const input = findInput(possibleNames); if (input) { // 先读取当前值,避免覆盖用户手动输入的内容 const currentValue = input.value || input.textContent || ''; const hasExistingValue = currentValue.trim() !== ''; // 如果已有值,提示用户(可选) if (hasExistingValue) { console.log(`[医疗语音助手] 覆盖字段: ${field} = "${fillValue}"(原值: "${currentValue}")`); } // 填写新值(覆盖模式) input.value = fillValue; input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); filledCount++; console.log(`[医疗语音助手] 已填写字段: ${field} = ${fillValue}`); // 高亮显示填写的字段 highlightField(input); } } return filledCount > 0; } /** * 获取默认字段映射 */ function getDefaultFieldMappings() { return { patientName: ['patientName', 'name', 'patient_name', 'xingming', '姓名', '患者姓名'], patientAge: ['patientAge', 'age', 'patient_age', 'nianling', '年龄', '患者年龄'], patientGender: ['patientGender', 'gender', 'patient_gender', 'xingbie', '性别'], patientPhone: ['patientPhone', 'phone', 'patient_phone', 'dianhua', '电话', '联系电话', '手机'], chiefComplaint: ['chiefComplaint', 'cc', 'chief_complaint', 'zhushu', '主诉'], presentIllness: ['presentIllness', 'hpi', 'present_illness', 'xianbingshi', '现病史'], pastHistory: ['pastHistory', 'ph', 'past_history', 'jiwangshi', '既往史'], allergyHistory: ['allergyHistory', 'ah', 'allergy_history', 'guominshi', '过敏史'], visitType: ['visitType', 'vt', 'visit_type', 'jiuzhenleixing', '就诊类型'] }; } /** * 填写性别字段(单选框) */ function fillGenderField(value, fieldMappings) { // 查找所有性别单选框 const possibleNames = fieldMappings?.patientGender || ['gender', 'patientGender', 'xingbie', '性别']; for (const name of possibleNames) { // 查找匹配的 radio 元素 const radio = document.querySelector(`input[type="radio"][name="${name}"][value="${value}"]`); if (radio) { radio.checked = true; radio.dispatchEvent(new Event('change', { bubbles: true })); radio.dispatchEvent(new Event('click', { bubbles: true })); // 高亮显示 const formItem = radio.closest('.form-item'); if (formItem) { highlightElement(formItem); } return true; } } console.log(`[医疗语音助手] 未找到性别字段,值: ${value}`); return false; } /** * 填写症状字段(多选框) */ function fillSymptomsField(symptoms) { let filledCount = 0; // 先清空所有症状复选框 const allCheckboxes = document.querySelectorAll('input[type="checkbox"][name="symptoms"], input[type="checkbox"][name="symptom"]'); allCheckboxes.forEach(cb => { cb.checked = false; }); // 勾选匹配的症状 symptoms.forEach(symptom => { const checkbox = document.querySelector(`input[type="checkbox"][name="symptoms"][value="${symptom}"], input[type="checkbox"][name="symptom"][value="${symptom}"]`); if (checkbox) { checkbox.checked = true; checkbox.dispatchEvent(new Event('change', { bubbles: true })); checkbox.dispatchEvent(new Event('click', { bubbles: true })); filledCount++; } }); // 如果至少填写了一个症状,高亮整个症状区域 if (filledCount > 0) { const symptomContainer = document.querySelector('input[type="checkbox"][name="symptoms"]'); if (symptomContainer) { const formItem = symptomContainer.closest('.form-item'); if (formItem) { highlightElement(formItem); } } return true; } console.log(`[医疗语音助手] 未找到症状字段,值: [${symptoms.join(', ')}]`); return false; } /** * 查找表单输入框(支持多种匹配方式) */ function findInput(possibleNames) { // 方式1: 通过ID匹配 for (const name of possibleNames) { const element = document.getElementById(name); if (element) return element; } // 方式2: 通过name属性匹配 const inputs = document.querySelectorAll('input, textarea, select'); for (const input of inputs) { if (input.name && possibleNames.includes(input.name)) { return input; } } // 方式3: 通过placeholder匹配 for (const input of inputs) { if (input.placeholder) { for (const name of possibleNames) { if (input.placeholder.includes(name)) { return input; } } } } // 方式4: 通过label文本匹配 const labels = document.querySelectorAll('label'); for (const label of labels) { for (const name of possibleNames) { if (label.textContent.includes(name)) { const forId = label.getAttribute('for'); if (forId) { const input = document.getElementById(forId); if (input) return input; } } } } // 方式5: 使用XPath定位(最后的手段) for (const name of possibleNames) { const xpath = `//label[contains(text(), '${name}')]/../input | //label[contains(text(), '${name}')]/../textarea | //label[contains(text(), '${name}')]/../select`; const result = document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null); if (result.singleNodeValue) { return result.singleNodeValue; } } console.log(`[医疗语音助手] 未找到字段: ${possibleNames[0]}`); return null; } /** * 高亮显示填写的字段 */ function highlightField(element) { element.style.backgroundColor = '#fff3cd'; element.style.border = '2px solid #ffc107'; setTimeout(() => { element.style.backgroundColor = ''; element.style.border = ''; }, 3000); } /** * 高亮显示元素(用于 form-item 等容器元素) */ function highlightElement(element) { const originalBg = element.style.backgroundColor; const originalTransition = element.style.transition; element.style.transition = 'background-color 0.3s ease'; element.style.backgroundColor = '#fff3cd'; setTimeout(() => { element.style.backgroundColor = originalBg; setTimeout(() => { element.style.transition = originalTransition; }, 300); }, 1500); } /** * 显示提示消息 */ function showMessage(message) { let messageBox = document.getElementById('medical-voice-message'); if (!messageBox) { messageBox = document.createElement('div'); messageBox.id = 'medical-voice-message'; messageBox.style.cssText = ` position: fixed; top: 20px; right: 20px; background: #333; color: white; padding: 15px 25px; border-radius: 5px; z-index: 10001; font-size: 14px; box-shadow: 0 2px 10px rgba(0,0,0,0.2); `; document.body.appendChild(messageBox); } messageBox.textContent = message; messageBox.style.display = 'block'; } /** * 隐藏提示消息 */ function hideMessage() { const messageBox = document.getElementById('medical-voice-message'); if (messageBox) { messageBox.style.display = 'none'; } } // 页面加载时初始化 init();