| | |
| | | <!doctype html> |
| | | <!doctype html> |
| | | <html lang="zh-CN"> |
| | | |
| | | <head> |
| | | <meta charset="UTF-8" /> |
| | | <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, target-densitydpi=device-dpi" /> |
| | | <meta name="viewport" |
| | | content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, target-densitydpi=device-dpi" /> |
| | | <meta http-equiv="X-UA-Compatible" content="IE=edge" /> |
| | | <title>大厅</title> |
| | | <style> |
| | |
| | | margin: 0; |
| | | padding: 0; |
| | | box-sizing: border-box; |
| | | /* Android 6 可用中文字体 */; |
| | | /* Android 6 可用中文字体 */ |
| | | ; |
| | | font-family: "Droid Sans Fallback", "Noto Sans CJK SC", "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif; |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | .top-header .time-info { |
| | | font-size: 24px; |
| | | font-size: 20px; |
| | | color: #aaa; |
| | | margin-left: auto; |
| | | z-index: 2; |
| | | text-align: right; |
| | | line-height: 1.3; |
| | | } |
| | | |
| | | .time-info-inner { |
| | | display: -webkit-box; |
| | | display: -webkit-flex; |
| | | display: flex; |
| | | -webkit-box-align: center; |
| | | -webkit-align-items: center; |
| | | align-items: center; |
| | | } |
| | | |
| | | .time-left { |
| | | display: -webkit-box; |
| | | display: -webkit-flex; |
| | | display: flex; |
| | | -webkit-box-orient: vertical; |
| | | -webkit-flex-direction: column; |
| | | flex-direction: column; |
| | | text-align: right; |
| | | line-height: 1.3; |
| | | } |
| | | |
| | | .time-right { |
| | | margin-left: 8px; |
| | | } |
| | | |
| | | .top-header .time-info .time-clock { |
| | | font-size: 36px; |
| | | font-weight: bold; |
| | | color: #ffcc00; |
| | | line-height: 1.1; |
| | | } |
| | | |
| | | /* 3. 主体内容(gap 替换为 margin 兼容旧 Chrome) */ |
| | |
| | | display: -webkit-box; |
| | | display: -webkit-flex; |
| | | display: flex; |
| | | -webkit-box-orient: vertical; |
| | | -webkit-box-direction: normal; |
| | | -webkit-flex-direction: column; |
| | | flex-direction: column; |
| | | padding: 15px 20px; |
| | | overflow: hidden; |
| | | } |
| | | |
| | | .main-content > .column-box { |
| | | margin-left: 5px; |
| | | margin-right: 5px; |
| | | /* 列行容器 */ |
| | | .columns-row { |
| | | -webkit-box-flex: 1; |
| | | -webkit-flex: 1; |
| | | flex: 1; |
| | | display: -webkit-box; |
| | | display: -webkit-flex; |
| | | display: flex; |
| | | overflow: hidden; |
| | | } |
| | | |
| | | .main-content > .column-box:first-child { |
| | | .columns-row>.column-box { |
| | | margin-left: 3px; |
| | | margin-right: 3px; |
| | | } |
| | | |
| | | .columns-row>.column-box:first-child { |
| | | margin-left: 0; |
| | | } |
| | | |
| | | .main-content > .column-box:last-child { |
| | | .columns-row>.column-box:last-child { |
| | | margin-right: 0; |
| | | } |
| | | |
| | |
| | | flex-direction: column; |
| | | background: rgba(10, 40, 80, 0.5); |
| | | border-radius: 0; |
| | | padding: 10px; |
| | | padding: 6px; |
| | | -webkit-box-flex: 1; |
| | | -webkit-flex: 1; |
| | | flex: 1; |
| | |
| | | |
| | | /* 标题行 */ |
| | | .col-title-line { |
| | | font-size: 22px; |
| | | font-size: 28px; |
| | | font-weight: bold; |
| | | color: #4da6ff; |
| | | text-align: center; |
| | | } |
| | | |
| | | .col-title { |
| | | font-size: 22px; |
| | | font-weight: bold; |
| | | color: #4da6ff; |
| | | padding-bottom: 8px; |
| | | border-bottom: 1px solid #003366; |
| | | margin-bottom: 10px; |
| | |
| | | } |
| | | |
| | | .col-subtitle { |
| | | font-size: 14px; |
| | | color: #999; |
| | | font-size: 24px; |
| | | font-weight: bold; |
| | | color: #ffcc00; |
| | | text-align: center; |
| | | min-height: 20px; |
| | | /* 即使为空也保留高度,防止标题栏高低不平 */ |
| | | line-height: 1.4; |
| | | word-break: keep-all; |
| | | } |
| | | |
| | | /* 患者列表 */ |
| | |
| | | -webkit-flex: 1; |
| | | flex: 1; |
| | | overflow-y: auto; |
| | | padding-right: 5px; |
| | | } |
| | | |
| | | /* 第1栏:一行两个 */ |
| | | #col-0 { |
| | | padding-right: 2px; |
| | | display: -webkit-box; |
| | | display: -webkit-flex; |
| | | display: flex; |
| | | -webkit-flex-wrap: wrap; |
| | | flex-wrap: wrap; |
| | | -webkit-align-content: flex-start; |
| | | align-content: flex-start; |
| | | } |
| | | |
| | | .col-normal .patient-list { |
| | | display: -webkit-box; |
| | | display: -webkit-flex; |
| | | display: flex; |
| | | /* 一行两个(常规心电图、动态心电) */ |
| | | .patient-list.two-per-row { |
| | | -webkit-flex-wrap: wrap; |
| | | flex-wrap: wrap; |
| | | } |
| | | |
| | | .patient-list.two-per-row .patient-item { |
| | | width: 46%; |
| | | margin: 0 2%; |
| | | } |
| | | |
| | | /* 一行一个 */ |
| | | .patient-list.one-per-row { |
| | | -webkit-box-orient: vertical; |
| | | -webkit-box-direction: normal; |
| | | -webkit-flex-direction: column; |
| | | flex-direction: column; |
| | | } |
| | | |
| | | .patient-list.one-per-row .patient-item { |
| | | width: 100%; |
| | | } |
| | | |
| | | /* 患者项目 */ |
| | | .patient-item { |
| | | font-size: 22px; |
| | | padding: 8px 5px; |
| | | font-size: 28px; |
| | | padding: 4px 3px; |
| | | display: -webkit-box; |
| | | display: -webkit-flex; |
| | | display: flex; |
| | |
| | | line-height: 1.4; |
| | | } |
| | | |
| | | #col-0 .patient-item { |
| | | width: 45%; |
| | | font-size: 20px; |
| | | margin: 0 2.5%; |
| | | } |
| | | |
| | | .p-number { |
| | | color: #ffcc00; |
| | | font-weight: bold; |
| | | margin-right: 8px; |
| | | margin-right: 6px; |
| | | -webkit-flex-shrink: 0; |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | .p-name { |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | white-space: nowrap; |
| | | font-weight: bold; |
| | | } |
| | | |
| | | .p-name-wrap { |
| | | -webkit-box-flex: 1; |
| | | -webkit-flex: 1; |
| | | flex: 1; |
| | |
| | | .p-room { |
| | | color: #4da6ff; |
| | | font-weight: bold; |
| | | font-size: 18px; |
| | | margin-left: 5px; |
| | | font-size: 22px; |
| | | margin-left: 4px; |
| | | -webkit-flex-shrink: 0; |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | /* 过号患者:排在列内,视觉弱化 */ |
| | | .patient-item.missed { |
| | | color: #aa8888; |
| | | } |
| | | |
| | | .patient-item.missed .p-number { |
| | | color: #cc9966; |
| | | } |
| | | |
| | | .patient-item.missed .p-name { |
| | | color: #aa8888; |
| | | } |
| | | |
| | | .patient-item.missed .p-missed-tag { |
| | | color: #ff6666; |
| | | font-size: 22px; |
| | | margin-left: 4px; |
| | | -webkit-flex-shrink: 0; |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | /* 底部栏 */ |
| | |
| | | text-align: center; |
| | | } |
| | | |
| | | /* 测试语音按钮 */ |
| | | #test-voice-btn { |
| | | margin-left: 20px; |
| | | background-color: #007bff; |
| | | color: white; |
| | | border: none; |
| | | padding: 10px 20px; |
| | | border-radius: 6px; |
| | | font-size: 16px; |
| | | cursor: pointer; |
| | | font-weight: bold; |
| | | -webkit-flex-shrink: 0; |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | #test-voice-btn:hover { |
| | | background-color: #0056b3; |
| | | } |
| | | |
| | | /* 滚动条 */ |
| | | .patient-list::-webkit-scrollbar { |
| | | width: 4px; |
| | | } |
| | | |
| | | .patient-list::-webkit-scrollbar-thumb { |
| | | background: #444; |
| | | border-radius: 2px; |
| | |
| | | |
| | | /* 调试面板 */ |
| | | .debug-panel { |
| | | display: -webkit-box; |
| | | display: -webkit-flex; |
| | | display: flex; |
| | | -webkit-box-orient: vertical; |
| | | -webkit-box-direction: normal; |
| | | -webkit-flex-direction: column; |
| | | flex-direction: column; |
| | | position: fixed; |
| | | right: 15px; |
| | | bottom: 70px; |
| | | width: 350px; |
| | | height: 200px; |
| | | background: rgba(0, 0, 0, 0.9); |
| | | width: 380px; |
| | | height: 220px; |
| | | background: rgba(0, 0, 0, 0.92); |
| | | color: #0f0; |
| | | border-radius: 4px; |
| | | border-radius: 6px; |
| | | font-family: monospace; |
| | | font-size: 12px; |
| | | z-index: 9999; |
| | |
| | | <!-- 顶部栏 --> |
| | | <div class="top-header"> |
| | | <img src="logo.png" alt="logo" /> |
| | | <span class="title-text">服务大厅排列</span> |
| | | <span class="title-text">心电诊区大厅</span> |
| | | <span class="time-info" id="headerTime"></span> |
| | | </div> |
| | | |
| | |
| | | |
| | | <!-- 底部栏 --> |
| | | <div class="bottom-footer"> |
| | | <div class="footer-tip">温馨提示:请听到呼叫后前往对应诊室</div> |
| | | <div class="footer-tip">温馨提示:听到呼叫后请前往对应诊室,过号患者到导诊台处理!</div> |
| | | <button id="test-voice-btn" onclick="testVoice()">测试语音</button> |
| | | </div> |
| | | |
| | | <!-- 调试面板 --> |
| | | <div class="debug-panel" id="debugPanel"> |
| | | <div class="debug-panel" id="debugPanel" > |
| | | <div class="debug-header"> |
| | | <span>[调试] 运行日志</span> |
| | | <button onclick="document.getElementById('debugBody').innerHTML=''">清空</button> |
| | |
| | | var body = document.getElementById("debugBody"); |
| | | if (body) { |
| | | body.insertAdjacentHTML("beforeend", logHtml); |
| | | body.scrollTop = body.scrollHeight; |
| | | // requestAnimationFrame 确保 DOM 更新后再滚动 |
| | | requestAnimationFrame(function () { |
| | | body.scrollTop = body.scrollHeight; |
| | | }); |
| | | } |
| | | } |
| | | |
| | | // ================= 应用状态 ================= |
| | | // 呼叫叫号次数参数:呼叫患者就诊时重复播报的次数,默认 2 次 |
| | | var CALL_TIMES = 2; |
| | | // 一行两个患者的列索引(常规心电图、动态心电) |
| | | var TWO_PER_ROW_COLS = [0, 1]; |
| | | // 食道电生理是否显示(false 隐藏,true 显示) |
| | | var SHOW_COL_ESOPHAGEAL = false; |
| | | var appState = { |
| | | columnTitles: ["常规心电图", "动态心电", "平板运动心电", "食道电生理", "动脉硬化监测"], |
| | | columnSubTitles: ["床边心电图(常规+频谱)M / 心电向量图N", "动态血压C", "", "", ""], |
| | | columnTitles: ["常规心电图/心电向量图", "动态心电/血压", "平板运动心电", "食道电生理", "动脉硬化监测"], |
| | | columnSubTitles: ["1号-2号诊室", "2号诊室", "4号诊室", "6号诊室", "5号诊室"], |
| | | patients: [], |
| | | apiBaseUrl: "http://192.168.100.110/admin-api", |
| | | // apiBaseUrl: "http://192.168.3.12/admin-api", |
| | | apiBaseUrl: "http://10.0.2.193/admin-api", |
| | | pollTimer: null, |
| | | callRepeatTimes: 2, |
| | | spokenPatients: {}, |
| | | ttsQueue: [], |
| | | isSpeaking: false |
| | | isSpeaking: false, |
| | | }; |
| | | |
| | | var patStatus = { |
| | | 3: "已过号-排队", 5: "已过号", 7: "已过号-安装", 10: "排队中", 12: "亲和", |
| | | 13: "亲和-安装", 15: "已召回", 20: "候诊中", 30: "就诊中", 33: "已领用", |
| | | 34: "已召回-安装", 36: "安装中", 40: "已就诊" |
| | | }; |
| | | |
| | | // ================= 语音播报 ================= |
| | | // Android WebView GC 保护:全局持有 utterance 引用,防止被回收导致无声 |
| | | var _gCurrentUtterance = null; |
| | | |
| | | function processTtsQueue() { |
| | | if (appState.isSpeaking || appState.ttsQueue.length === 0) return; |
| | | appState.isSpeaking = true; |
| | | var text = appState.ttsQueue.shift(); |
| | | logDebug("[播报] " + text); |
| | | |
| | | var utterance = new SpeechSynthesisUtterance(text); |
| | | // Android 6 默认语速可能很快,适当调慢 |
| | | utterance.rate = 0.85; |
| | | utterance.onend = function () { |
| | | appState.isSpeaking = false; |
| | | processTtsQueue(); |
| | | }; |
| | | utterance.onerror = function (e) { |
| | | logDebug("[TTS错误] " + (e.error || "unknown")); |
| | | appState.isSpeaking = false; |
| | | processTtsQueue(); |
| | | }; |
| | | |
| | | // 优先尝试设备原生 TTS 接口 |
| | | // 优先尝试设备原生 TTS 接口 (wowjoy) |
| | | if (typeof wowjoy !== 'undefined' && typeof wowjoy.speek === 'function') { |
| | | try { |
| | | wowjoy.speek(text); |
| | | logDebug("[播报] wowjoy.speek 已调用"); |
| | | setTimeout(function () { appState.isSpeaking = false; processTtsQueue(); }, 4000); |
| | | return; |
| | | } catch (e) { logDebug("[wowjoy失败] " + e.message); } |
| | | } |
| | | |
| | | if (window.speechSynthesis) { |
| | | // Android 6 WebView 有时需要先 cancel 再 speak |
| | | window.speechSynthesis.cancel(); |
| | | window.speechSynthesis.speak(utterance); |
| | | } else { |
| | | if (!window.speechSynthesis) { |
| | | logDebug("[TTS] 浏览器不支持语音合成"); |
| | | appState.isSpeaking = false; |
| | | processTtsQueue(); |
| | | return; |
| | | } |
| | | |
| | | // 仅在有语音正在播放时才 cancel,避免不必要的中断 |
| | | if (window.speechSynthesis.speaking) { |
| | | logDebug("[播报] cancel 当前播放"); |
| | | window.speechSynthesis.cancel(); |
| | | } |
| | | |
| | | var utterance = new SpeechSynthesisUtterance(text); |
| | | utterance.rate = 0.85; |
| | | utterance.volume = 1.0; |
| | | // Android WebView GC 保护:存全局引用 |
| | | _gCurrentUtterance = utterance; |
| | | |
| | | utterance.onstart = function () { |
| | | logDebug("[播报] onstart 触发"); |
| | | }; |
| | | utterance.onend = function () { |
| | | logDebug("[播报] onend 触发"); |
| | | _gCurrentUtterance = null; |
| | | appState.isSpeaking = false; |
| | | // 延迟一下让引擎完全释放 |
| | | setTimeout(function () { processTtsQueue(); }, 200); |
| | | }; |
| | | utterance.onerror = function (e) { |
| | | logDebug("[TTS错误] " + (e.error || "unknown")); |
| | | _gCurrentUtterance = null; |
| | | appState.isSpeaking = false; |
| | | setTimeout(function () { processTtsQueue(); }, 200); |
| | | }; |
| | | |
| | | // 确保语音包已加载 (Android 上 getVoices 可能初始为空) |
| | | var voices = window.speechSynthesis.getVoices(); |
| | | if (voices.length > 0) { |
| | | utterance.voice = voices[0]; |
| | | logDebug("[播报] 使用语音: " + voices[0].name + " lang=" + voices[0].lang); |
| | | } else { |
| | | logDebug("[播报] getVoices 为空, 等待 voiceschanged 事件"); |
| | | var voicesLoaded = false; |
| | | var onVoicesChange = function () { |
| | | if (voicesLoaded) return; |
| | | voicesLoaded = true; |
| | | var v2 = window.speechSynthesis.getVoices(); |
| | | if (v2.length > 0) { |
| | | utterance.voice = v2[0]; |
| | | logDebug("[播报] 语音加载完成: " + v2[0].name); |
| | | } |
| | | window.speechSynthesis.speak(utterance); |
| | | }; |
| | | window.speechSynthesis.addEventListener('voiceschanged', onVoicesChange); |
| | | // 如果 1s 内没触发,直接 speak |
| | | setTimeout(function () { |
| | | if (!voicesLoaded) { |
| | | logDebug("[播报] voiceschanged 超时, 直接 speak"); |
| | | window.speechSynthesis.speak(utterance); |
| | | } |
| | | }, 1000); |
| | | return; |
| | | } |
| | | |
| | | window.speechSynthesis.speak(utterance); |
| | | logDebug("[播报] speak 已调用"); |
| | | } |
| | | |
| | | function speak(text) { |
| | |
| | | processTtsQueue(); |
| | | } |
| | | |
| | | // ================= 渲染 ================= |
| | | function renderMainContent() { |
| | | var main = document.getElementById("mainContent"); |
| | | main.innerHTML = ""; |
| | | // ================= 语音测试 ================= |
| | | function testVoice() { |
| | | logDebug("[语音测试] ========== 开始 =========="); |
| | | var testText = "测试语音播报"; |
| | | |
| | | for (var i = 0; i < appState.columnTitles.length; i++) { |
| | | var titleHtml = '<div class="col-title-line">' + appState.columnTitles[i] + '</div>'; |
| | | if (appState.columnSubTitles[i]) { |
| | | titleHtml += '<div class="col-subtitle">' + appState.columnSubTitles[i] + '</div>'; |
| | | // 1. 检测 wowjoy 原生接口 |
| | | var hasWowjoy = (typeof wowjoy !== 'undefined'); |
| | | logDebug("[语音测试] wowjoy 对象存在=" + hasWowjoy); |
| | | if (hasWowjoy) { |
| | | logDebug("[语音测试] wowjoy.speek 类型=" + (typeof wowjoy.speek)); |
| | | if (typeof wowjoy.speek === 'function') { |
| | | try { |
| | | wowjoy.speek(testText); |
| | | logDebug("[语音测试] wowjoy.speek 已调用,注意听设备声音"); |
| | | } catch (e) { |
| | | logDebug("[语音测试] wowjoy.speek 异常: " + e.message); |
| | | } |
| | | } |
| | | } |
| | | |
| | | // 2. 检测 SpeechSynthesis |
| | | var hasSpeech = (typeof window.speechSynthesis !== 'undefined'); |
| | | logDebug("[语音测试] speechSynthesis 对象存在=" + hasSpeech); |
| | | |
| | | if (hasSpeech) { |
| | | var syn = window.speechSynthesis; |
| | | logDebug("[语音测试] speaking=" + syn.speaking + " pending=" + syn.pending + " paused=" + syn.paused); |
| | | |
| | | var voices = syn.getVoices(); |
| | | logDebug("[语音测试] getVoices 返回 " + voices.length + " 个语音包"); |
| | | for (var v = 0; v < Math.min(voices.length, 5); v++) { |
| | | logDebug("[语音测试] 语音[" + v + "] " + voices[v].name + " lang=" + voices[v].lang + " local=" + voices[v].localService); |
| | | } |
| | | |
| | | var colClass = (i === 0) ? 'col-wide' : 'col-normal'; |
| | | var colHtml = '<div class="column-box ' + colClass + '">' + |
| | | '<div class="col-title">' + titleHtml + '</div>' + |
| | | '<div class="patient-list" id="col-' + i + '"></div>' + |
| | | '</div>'; |
| | | main.insertAdjacentHTML("beforeend", colHtml); |
| | | // 如果 voices 为空,等待加载后再播 |
| | | function doSpeakTest() { |
| | | var v2 = syn.getVoices(); |
| | | var u = new SpeechSynthesisUtterance(testText); |
| | | u.rate = 0.85; |
| | | u.volume = 1.0; |
| | | if (v2.length > 0) { |
| | | u.voice = v2[0]; |
| | | logDebug("[语音测试] 选择语音: " + v2[0].name); |
| | | } |
| | | // GC 保护 |
| | | window._testUtterance = u; |
| | | u.onstart = function() { logDebug("[语音测试] onstart 触发 ✓"); }; |
| | | u.onend = function() { logDebug("[语音测试] onend 触发 ✓"); window._testUtterance = null; }; |
| | | u.onerror = function(e) { logDebug("[语音测试] onerror: " + (e.error || "unknown")); window._testUtterance = null; }; |
| | | |
| | | if (syn.speaking) { |
| | | logDebug("[语音测试] 有语音正在播放,先 cancel"); |
| | | syn.cancel(); |
| | | setTimeout(function () { syn.speak(u); logDebug("[语音测试] speak 已调用(延迟)"); }, 300); |
| | | } else { |
| | | syn.speak(u); |
| | | logDebug("[语音测试] speak 已调用"); |
| | | } |
| | | } |
| | | |
| | | if (voices.length === 0) { |
| | | logDebug("[语音测试] 语音列表为空,等待 voiceschanged..."); |
| | | var loaded = false; |
| | | var handler = function () { |
| | | if (loaded) return; |
| | | loaded = true; |
| | | logDebug("[语音测试] voiceschanged 触发, 语音数=" + syn.getVoices().length); |
| | | doSpeakTest(); |
| | | }; |
| | | syn.addEventListener('voiceschanged', handler); |
| | | setTimeout(function () { |
| | | if (!loaded) { |
| | | logDebug("[语音测试] voiceschanged 超时(2s), 强制测试"); |
| | | doSpeakTest(); |
| | | } |
| | | }, 2000); |
| | | } else { |
| | | doSpeakTest(); |
| | | } |
| | | } |
| | | |
| | | if (!hasWowjoy && !hasSpeech) { |
| | | logDebug("[语音测试] 无任何 TTS 引擎可用!"); |
| | | } |
| | | logDebug("[语音测试] ========== 结束 =========="); |
| | | } |
| | | |
| | | function renderPatients() { |
| | | for (var i = 0; i < appState.columnTitles.length; i++) { |
| | | var col = document.getElementById("col-" + i); |
| | | if (col) col.innerHTML = ""; |
| | | } |
| | | // ================= 渲染:标题框架(仅初始化一次)================= |
| | | function initLayout() { |
| | | logDebug("[布局] initLayout 开始, 列数=" + appState.columnTitles.length); |
| | | var main = document.getElementById("mainContent"); |
| | | if (!main) { logDebug("[布局] mainContent 元素未找到!"); return; } |
| | | var html = '<div class="columns-row">'; |
| | | var visibleCount = 0; |
| | | |
| | | for (var c = 0; c < appState.patients.length; c++) { |
| | | var colData = appState.patients[c]; |
| | | if (Array.isArray(colData)) { |
| | | var col = document.getElementById("col-" + c); |
| | | if (!col) continue; |
| | | for (var p = 0; p < colData.length; p++) { |
| | | var pat = colData[p]; |
| | | var roomHtml = pat.roomName ? '<span class="p-room">(' + pat.roomName + ')</span>' : ''; |
| | | var itemHtml = '<div class="patient-item">' + |
| | | '<span class="p-number">' + (pat.bookSeqNum || '') + '</span>' + |
| | | '<span class="p-name">' + (pat.patName || '') + '</span>' + |
| | | roomHtml + |
| | | '</div>'; |
| | | col.insertAdjacentHTML("beforeend", itemHtml); |
| | | } |
| | | for (var i = 0; i < appState.columnTitles.length; i++) { |
| | | // 食道电生理:通过 SHOW_COL_ESOPHAGEAL 控制显隐 |
| | | if (i === 3 && !SHOW_COL_ESOPHAGEAL) { |
| | | logDebug("[布局] 列" + i + " 食道电生理 已隐藏"); |
| | | continue; |
| | | } |
| | | |
| | | var subtitle = appState.columnSubTitles[i] || " "; |
| | | // 一行两个的列用 col-wide,否则 col-normal |
| | | var isTwoPerRow = TWO_PER_ROW_COLS.indexOf(i) !== -1; |
| | | var colClass = isTwoPerRow ? 'col-wide' : 'col-normal'; |
| | | var rowClass = isTwoPerRow ? 'two-per-row' : 'one-per-row'; |
| | | logDebug("[布局] 列" + i + " " + appState.columnTitles[i] + " class=" + colClass + " row=" + rowClass); |
| | | html += '<div class="column-box ' + colClass + '">' + |
| | | '<div class="col-title">' + |
| | | '<div class="col-title-line">' + appState.columnTitles[i] + '</div>' + |
| | | '<div class="col-subtitle">' + subtitle + '</div>' + |
| | | '</div>' + |
| | | '<div class="patient-list ' + rowClass + '" id="col-' + i + '"></div>' + |
| | | '</div>'; |
| | | visibleCount++; |
| | | } |
| | | html += '</div>'; |
| | | main.innerHTML = html; |
| | | logDebug("[布局] initLayout 完成, 可见列=" + visibleCount); |
| | | } |
| | | |
| | | // ================= 渲染:患者列表(diff 更新,只改变化的列)================= |
| | | function desensitizeName(name) { |
| | | if (!name || name.length < 2) return name || ''; |
| | | return name.charAt(0) + '*' + name.substring(2); |
| | | } |
| | | |
| | | function isMissedStatus(status) { |
| | | var s = parseInt(status, 10); |
| | | return s === 3 || s === 5 || s === 7; |
| | | } |
| | | |
| | | function buildColumnHtml(colData) { |
| | | if (!Array.isArray(colData) || colData.length === 0) return ""; |
| | | var h = ""; |
| | | for (var p = 0; p < colData.length; p++) { |
| | | var pat = colData[p]; |
| | | var missedClass = isMissedStatus(pat.status) ? ' missed' : ''; |
| | | var roomHtml = (!isMissedStatus(pat.status) && pat.roomName) ? '<span class="p-room">' + pat.roomName + '</span>' : ''; |
| | | h += '<div class="patient-item' + missedClass + '">' + |
| | | '<span class="p-number">' + (pat.seqNum || '') + '</span>' + |
| | | '<span class="p-name-wrap"><span class="p-name">' + desensitizeName(pat.patName) + '</span>' + roomHtml + |
| | | (missedClass ? '<span class="p-missed-tag">过号</span>' : '') + '</span>' + |
| | | '</div>'; |
| | | } |
| | | return h; |
| | | } |
| | | function isSameColumn(a, b) { |
| | | if (!Array.isArray(a) || !Array.isArray(b)) return a === b; |
| | | if (a.length !== b.length) return false; |
| | | for (var i = 0; i < a.length; i++) { |
| | | if (a[i].patId !== b[i].patId) return false; |
| | | if (a[i].roomName !== b[i].roomName) return false; |
| | | if (parseInt(a[i].status, 10) !== parseInt(b[i].status, 10)) return false; |
| | | } |
| | | return true; |
| | | } |
| | | |
| | | function updatePatients(dataList) { |
| | | for (var i = 0; i < appState.columnTitles.length; i++) { |
| | | var newData = (dataList && dataList[i]) ? dataList[i] : []; |
| | | var oldData = appState.patients[i]; |
| | | |
| | | // diff:数据未变化则跳过该列 DOM 操作 |
| | | if (isSameColumn(oldData, newData)) continue; |
| | | |
| | | var col = document.getElementById("col-" + i); |
| | | if (col) { |
| | | col.innerHTML = buildColumnHtml(newData); |
| | | logDebug("[更新] 列" + i + " " + appState.columnTitles[i] + " 刷新 " + newData.length + " 人"); |
| | | } else { |
| | | logDebug("[更新] 列" + i + " DOM不存在(可能已隐藏)"); |
| | | } |
| | | } |
| | | } |
| | |
| | | } |
| | | if (!oldPat || !oldPat.roomName) { |
| | | var repeatText = ""; |
| | | for (var r = 0; r < appState.callRepeatTimes; r++) { |
| | | repeatText += "请 " + newPat.bookSeqNum + " 号 " + newPat.patName + " 到 " + newPat.roomName + " 就诊。"; |
| | | } |
| | | speak(repeatText); |
| | | repeatText += "请 " + newPat.seqNum + " 号 " + newPat.patName + " 到 " + newPat.roomName + " 就诊。"; |
| | | for (var r = 0; r < CALL_TIMES; r++) { |
| | | speak(repeatText); |
| | | } |
| | | appState.spokenPatients[newPat.patId] = true; |
| | | logDebug("[排队] " + newPat.patName + " -> " + newPat.roomName); |
| | | } |
| | |
| | | // ================= 数据获取 ================= |
| | | function fetchQueueData() { |
| | | var url = appState.apiBaseUrl + "/ecg/screen/big-screen-data"; |
| | | logDebug("[请求] GET " + url); |
| | | $.ajax({ |
| | | url: url, |
| | | type: "GET", |
| | | dataType: "json", |
| | | timeout: 5000, |
| | | success: function (res) { |
| | | logDebug("[响应] code=" + (res ? res.code : "null")); |
| | | var dataList = []; |
| | | if (res && res.code === 0 && res.data) { |
| | | for (var i = 0; i < appState.columnTitles.length; i++) { |
| | | var key = i.toString(); |
| | | dataList[i] = (res.data[key] && Array.isArray(res.data[key])) ? res.data[key] : []; |
| | | logDebug("[数据] 列" + i + " " + appState.columnTitles[i] + " 患者数=" + dataList[i].length); |
| | | } |
| | | } else { |
| | | logDebug("[响应] 异常 code=" + (res ? res.code : "null") + " msg=" + (res ? res.msg : "")); |
| | | } |
| | | // 先 diff 更新 DOM,再播报,最后保存数据用于下次对比 |
| | | updatePatients(dataList); |
| | | checkAndSpeakNewRooms(appState.patients, dataList); |
| | | appState.patients = dataList; |
| | | renderPatients(); |
| | | |
| | | // Android 6 WebView 通常不支持 performance.memory,安全守卫 |
| | | try { |
| | |
| | | var usedMB = (window.performance.memory.usedJSHeapSize / 1048576).toFixed(2); |
| | | logDebug("[内存] " + usedMB + " MB"); |
| | | } |
| | | } catch (e) {} |
| | | } catch (e) { } |
| | | }, |
| | | error: function (err) { |
| | | logDebug("[请求失败] " + (err.statusText || "网络错误")); |
| | | logDebug("[请求失败] status=" + err.status + " " + (err.statusText || "网络错误") + " url=" + url); |
| | | } |
| | | }); |
| | | } |
| | | |
| | | // ================= 初始化 ================= |
| | | function onReady() { |
| | | renderMainContent(); |
| | | logDebug("[初始化] onReady 开始"); |
| | | logDebug("[环境] speechSynthesis=" + (typeof window.speechSynthesis !== 'undefined') + " wowjoy=" + (typeof wowjoy !== 'undefined')); |
| | | try { |
| | | initLayout(); |
| | | logDebug("[初始化] initLayout 完成"); |
| | | } catch (e) { logDebug("[初始化] initLayout 异常: " + e.message); } |
| | | updateHeaderTime(); |
| | | setInterval(updateHeaderTime, 1000); |
| | | fetchQueueData(); |
| | | try { |
| | | fetchQueueData(); |
| | | logDebug("[初始化] 首次 fetchQueueData 已发起"); |
| | | } catch (e) { logDebug("[初始化] fetchQueueData 异常: " + e.message); } |
| | | appState.pollTimer = setInterval(fetchQueueData, 5000); |
| | | logDebug("[系统] 启动完成 - Android 6.0.1"); |
| | | logDebug("[系统] 启动完成 - Android 6.0.1 轮询间隔5s"); |
| | | } |
| | | |
| | | function updateHeaderTime() { |
| | | var now = new Date(); |
| | | var weekDays = ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"]; |
| | | function padZero(num) { return num < 10 ? '0' + num : '' + num; } |
| | | var dateStr = now.getFullYear() + "年" + padZero(now.getMonth() + 1) + "月" + padZero(now.getDate()) + "日 " + weekDays[now.getDay()]; |
| | | var dateStr = now.getFullYear() + "年" + padZero(now.getMonth() + 1) + "月" + padZero(now.getDate()) + "日"; |
| | | var weekStr = weekDays[now.getDay()]; |
| | | var timeStr = padZero(now.getHours()) + ":" + padZero(now.getMinutes()); |
| | | var el = document.getElementById("headerTime"); |
| | | if (el) el.textContent = dateStr + " " + timeStr; |
| | | // 星期和日期各占一行在左,时间大字在右跨两行 |
| | | if (el) el.innerHTML = '<div class="time-info-inner"><div class="time-left"><div>' + weekStr + '</div><div>' + dateStr + '</div></div><div class="time-right"><span class="time-clock">' + timeStr + '</span></div></div>'; |
| | | } |
| | | |
| | | // 兼容 DOM ready(Android 6 某些 WebView 可能没有 $) |
| | | logDebug("[入口] readyState=" + document.readyState + " jQuery=" + (typeof $ !== 'undefined')); |
| | | if (typeof $ !== 'undefined') { |
| | | logDebug("[入口] 使用 jQuery.ready"); |
| | | $(document).ready(onReady); |
| | | } else if (document.readyState === 'complete' || document.readyState === 'interactive') { |
| | | logDebug("[入口] DOM已就绪, setTimeout触发"); |
| | | setTimeout(onReady, 1); |
| | | } else { |
| | | logDebug("[入口] 监听 DOMContentLoaded"); |
| | | document.addEventListener('DOMContentLoaded', onReady); |
| | | } |
| | | </script> |
| | | </body> |
| | | |
| | | </html> |
| | | </html> |