| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691 |
- /**
- * 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();
|