您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Collects barcodes with the reliability of v2.2.0 and syncs them via a local server.
// ==UserScript== // @name Pending barcodes (Networked) // @namespace http://tampermonkey.net/ // @version 3.5.1 // @description Collects barcodes with the reliability of v2.2.0 and syncs them via a local server. // @author Hamad AlShegifi // @match *://his.kaauh.org/lab/* // @grant GM_xmlhttpRequest // @grant GM_addStyle // @connect localhost // ==/UserScript== (function() { 'use strict'; // --- Configuration --- const PYTHON_SERVER_URL = 'http://localhost:5678'; const DATA_TABLE_SELECTOR = 'tbody[formarrayname="TubeTypeList"]'; const BARCODE_DISPLAY_SELECTOR = '#barcode-display-box'; const IN_PAGE_TABLE_ID = 'barcode-inpage-container'; const INJECTION_POINT_SELECTOR = '.row.labordertab'; const GRID_CONTAINER_SELECTOR = '.ag-center-cols-container'; // --- State Flags & Cache --- const collectedBarcodesThisSession = new Set(); let lastCheckedPatientBarcode = null; let observerDebounceTimer = null; let isTableUpdating = false; let sortState = { key: 'timestamp', direction: 'desc' }; let timeSinceInterval = null; // --- API Communication --- function apiRequest(method, endpoint, data = null) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: method, url: `${PYTHON_SERVER_URL}${endpoint}`, data: data ? JSON.stringify(data) : null, headers: { "Content-Type": "application/json" }, timeout: 5000, onload: (response) => { if (response.status >= 200 && response.status < 300) resolve(JSON.parse(response.responseText)); else reject(`API Error: ${response.status}`); }, onerror: () => reject('Connection Error: Is Python server running?'), ontimeout: () => reject('Connection Timeout') }); }); } // --- Core Logic (Based on v2.2.0) --- function initialize() { console.log("Barcode Collector (Networked v3.5.1): Script started. Observing for page changes..."); const observer = new MutationObserver((mutations, obs) => { if (observerDebounceTimer) clearTimeout(observerDebounceTimer); observerDebounceTimer = setTimeout(async () => { const injectionPoint = document.querySelector(INJECTION_POINT_SELECTOR); if (injectionPoint) { await updateOrInsertBarcodeTable(); } else { const existingTable = document.getElementById(IN_PAGE_TABLE_ID); if (existingTable) { if (timeSinceInterval) clearInterval(timeSinceInterval); existingTable.remove(); } } const patientBarcodeBox = document.querySelector(BARCODE_DISPLAY_SELECTOR); const barcodeOnPage = patientBarcodeBox ? Array.from(patientBarcodeBox.querySelectorAll('div')).find(div => div.textContent.includes('Sample Barcode:'))?.nextElementSibling?.textContent.trim() : null; if (barcodeOnPage && barcodeOnPage !== lastCheckedPatientBarcode) { lastCheckedPatientBarcode = barcodeOnPage; await markBarcodeAsFound(barcodeOnPage); } const allBarcodeRows = document.querySelectorAll(`${DATA_TABLE_SELECTOR} tr`); if (allBarcodeRows.length > 0) { for (const row of allBarcodeRows) { const barcodeInput = row.querySelector('input[formcontrolname="Barcode"]'); const workbenchInput = row.querySelector('input[formcontrolname="TestSection"]'); if (barcodeInput && barcodeInput.value) { const barcode = barcodeInput.value.trim(); const workbench = workbenchInput && workbenchInput.value ? workbenchInput.value.trim() : 'N/A'; await saveBarcode(barcode, workbench); } } } }, 250); }); observer.observe(document.body, { childList: true, subtree: true }); } async function saveBarcode(barcode, workbench) { if (collectedBarcodesThisSession.has(barcode)) return; try { const newEntry = { barcode, workbench }; const result = await apiRequest('POST', '/add_barcode', newEntry); collectedBarcodesThisSession.add(barcode); if (result.status === 'added') { console.log(`Barcode Collector: Sent new barcode - ${barcode}`); await updateOrInsertBarcodeTable({ forceRender: true }); } } catch (error) { console.error(error); } } async function markBarcodeAsFound(barcodeToMark) { try { const barcodes = await apiRequest('GET', '/get_barcodes'); const entry = barcodes.find(b => b.barcode === barcodeToMark); if (entry && !entry.found) { entry.found = true; await apiRequest('POST', '/update_barcodes', barcodes); console.log(`Barcode Collector: Marked ${barcodeToMark} as found.`); await updateOrInsertBarcodeTable({ forceRender: true }); } } catch (error) { console.error(error); } } // *** RESTORED: This is the trusted time formatting function from v2.2.0 *** function formatTimeSince(isoTimestamp) { // A safety check is still included for robustness const date = new Date(isoTimestamp); if (isNaN(date.getTime())) return 'Invalid Time'; const now = new Date(); const totalMinutes = Math.floor((now - date) / (1000 * 60)); if (totalMinutes < 1) return "00:00 ago"; const hours = Math.floor(totalMinutes / 60); const minutes = totalMinutes % 60; return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')} ago`; } // --- UI Rendering and Management (Based on v2.2.0) --- async function updateOrInsertBarcodeTable({ forceRender = false } = {}) { if (isTableUpdating && !forceRender) return; isTableUpdating = true; try { const injectionPoint = document.querySelector(INJECTION_POINT_SELECTOR); if (!injectionPoint) { isTableUpdating = false; return; } let container = document.getElementById(IN_PAGE_TABLE_ID); if (!container) { container = document.createElement('div'); container.id = IN_PAGE_TABLE_ID; container.innerHTML = ` <div class="bc-table-header"> <h2>Pending (Synced)</h2> <div class="bc-filter-container"><label for="workbench-filter">Workbench:</label><select id="workbench-filter"></select></div> <div class="bc-button-group"> <button id="delete-completed-btn" class="bc-btn bc-btn-completed">Clear Completed</button> <button id="clear-all-btn" class="bc-btn bc-btn-clear-all">Clear All</button> </div> </div> <div class="bc-table-body"> <table> <thead> <tr> <th>#</th><th>Barcode</th><th>Workbench</th> <th id="sort-by-time-header" class="sortable-header">Added <span id="sort-indicator"></span></th> <th>Pending</th><th>Actions</th> </tr> </thead> <tbody><tr><td colspan="6">Loading...</td></tr></tbody> </table> </div>`; injectionPoint.parentNode.insertBefore(container, injectionPoint.nextSibling); container.querySelector('#clear-all-btn').addEventListener('click', clearAllBarcodes); container.querySelector('#delete-completed-btn').addEventListener('click', deleteCompletedBarcodes); container.querySelector('#workbench-filter').addEventListener('change', () => updateOrInsertBarcodeTable({ forceRender: true })); container.querySelector('#sort-by-time-header').addEventListener('click', handleSortClick); container.querySelector('tbody').addEventListener('click', handleTableClick); } const barcodes = await apiRequest('GET', '/get_barcodes'); if (sortState.key === 'timestamp') { barcodes.sort((a, b) => { const dateA = new Date(a.timestamp); const dateB = new Date(b.timestamp); return sortState.direction === 'asc' ? dateA - dateB : dateB - dateA; }); } const uniqueWorkbenches = ['All', ...new Set(barcodes.map(b => b.workbench).filter(Boolean))]; const filterDropdown = container.querySelector('#workbench-filter'); const currentFilterValue = filterDropdown.value; filterDropdown.innerHTML = uniqueWorkbenches.map(wb => `<option value="${wb}">${wb}</option>`).join(''); if (uniqueWorkbenches.includes(currentFilterValue)) filterDropdown.value = currentFilterValue; const selectedWorkbench = filterDropdown.value; const filteredBarcodes = selectedWorkbench === 'All' ? barcodes : barcodes.filter(b => b.workbench === selectedWorkbench); container.querySelector('#sort-indicator').textContent = sortState.direction === 'asc' ? '▲' : '▼'; const tableBody = container.querySelector('tbody'); // *** FIXED: This block now contains bulletproof checks to prevent invalid date errors *** let tableRows = filteredBarcodes.map(entry => { const addedDate = entry.timestamp ? new Date(entry.timestamp) : null; const isValidDate = addedDate && !isNaN(addedDate.getTime()); const addedTime = isValidDate ? addedDate.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }) : 'Invalid Time'; const pendingTime = isValidDate ? formatTimeSince(entry.timestamp) : 'Invalid Time'; return ` <tr data-barcode-row="${entry.barcode}" class="${entry.found ? 'barcode-found' : ''}"> <td>${entry.count}</td><td>${entry.barcode}</td><td>${entry.workbench || 'N/A'}</td> <td>${addedTime}</td> <td data-timestamp="${entry.timestamp}">${pendingTime}</td> <td class="action-cell-bc"><span class="delete-barcode-btn" data-barcode="${entry.barcode}" title="Delete">×</span></td> </tr>`; }).join(''); if (filteredBarcodes.length === 0) tableRows = '<tr><td colspan="6">No pending barcodes.</td></tr>'; if (tableBody.innerHTML !== tableRows) tableBody.innerHTML = tableRows; if (timeSinceInterval) clearInterval(timeSinceInterval); timeSinceInterval = setInterval(() => { container.querySelectorAll('td[data-timestamp]').forEach(cell => { // Also use the safe check inside the interval if (cell.dataset.timestamp && new Date(cell.dataset.timestamp).getTime()) { cell.textContent = formatTimeSince(cell.dataset.timestamp); } }); }, 5000); } catch (error) { console.error(error); const container = document.getElementById(IN_PAGE_TABLE_ID); if(container) container.querySelector('tbody').innerHTML = `<tr><td colspan="6" style="color: red; text-align: center;">Error: ${error}</td></tr>`; } finally { isTableUpdating = false; } } function handleSortClick() { sortState.direction = sortState.direction === 'desc' ? 'asc' : 'desc'; updateOrInsertBarcodeTable({ forceRender: true }); } async function handleTableClick(event) { const row = event.target.closest('tr'); if (!row || !row.dataset.barcodeRow) return; if (event.target.classList.contains('delete-barcode-btn')) await deleteBarcode(event.target.dataset.barcode); else await enterBarcodeInFilter(row.dataset.barcodeRow); } // --- Data Modification & AG-Grid Interaction --- async function clearAllBarcodes() { if (confirm("Are you sure you want to delete ALL synced barcodes?")) { try { await apiRequest('POST', '/update_barcodes', []); await updateOrInsertBarcodeTable({ forceRender: true }); } catch(e) { console.error(e); } } } async function deleteCompletedBarcodes() { if (confirm("Are you sure you want to delete all completed barcodes?")) { try { const barcodes = await apiRequest('GET', '/get_barcodes'); let updatedList = barcodes.filter(entry => !entry.found); updatedList.forEach((entry, index) => { entry.count = index + 1; }); await apiRequest('POST', '/update_barcodes', updatedList); await updateOrInsertBarcodeTable({ forceRender: true }); } catch(e) { console.error(e); } } } async function deleteBarcode(barcodeToDelete) { try { const barcodes = await apiRequest('GET', '/get_barcodes'); let updatedList = barcodes.filter(entry => entry.barcode !== barcodeToDelete); updatedList.forEach((entry, index) => { entry.count = index + 1; }); await apiRequest('POST', '/update_barcodes', updatedList); await updateOrInsertBarcodeTable({ forceRender: true }); } catch(e) { console.error(e); } } function findFloatingFilterInputByHeader(headerText) { const headerViewport = document.querySelector('.ag-header-viewport'); if (!headerViewport) return null; const allTitleCells = Array.from(headerViewport.querySelectorAll('.ag-header-row[aria-rowindex="1"] .ag-header-cell')); let targetColumnIndex = -1; allTitleCells.forEach((cell, index) => { const cellTextElement = cell.querySelector('.ag-header-cell-text'); if (cellTextElement && cellTextElement.textContent.trim().toLowerCase() === headerText.toLowerCase()) targetColumnIndex = index; }); if (targetColumnIndex === -1) return null; const filterRow = headerViewport.querySelector('.ag-header-row[aria-rowindex="2"]'); if (!filterRow) return null; const filterCell = filterRow.children[targetColumnIndex]; return filterCell ? filterCell.querySelector('input.ag-floating-filter-input') : null; } function waitForGridUpdateAndClick() { return new Promise((resolve, reject) => { const gridContainer = document.querySelector(GRID_CONTAINER_SELECTOR); if (!gridContainer) return reject("AG-Grid container not found."); const timeout = setTimeout(() => { observer.disconnect(); reject("Timeout: AG-Grid did not update."); }, 2000); const observer = new MutationObserver((mutations, obs) => { const firstRow = gridContainer.querySelector('.ag-row[row-index="0"]'); if (firstRow) { firstRow.click(); clearTimeout(timeout); obs.disconnect(); resolve(); } }); observer.observe(gridContainer, { childList: true, subtree: true }); }); } function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } async function enterBarcodeInFilter(barcode) { const targetInput = findFloatingFilterInputByHeader('Barcode'); if (!targetInput) { console.error('Could not find "Barcode" filter input.'); return; } try { targetInput.focus(); await sleep(50); targetInput.value = barcode; targetInput.dispatchEvent(new Event('input', { bubbles: true, cancelable: true })); await sleep(100); targetInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true })); targetInput.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true })); await waitForGridUpdateAndClick(); } catch (error) { console.error("Error while filtering/clicking.", error); } finally { if (targetInput) targetInput.blur(); } } // --- Styles and Initialization --- GM_addStyle(` #barcode-inpage-container { width: 30vw !important; float: left !important; margin: 15px 0; border: 1px solid #ccc; border-radius: 8px; box-shadow: 0 2px 5px rgba(0,0,0,0.1); font-family: Arial, sans-serif; background-color: #fff; display: flex; flex-direction: column; height: 90vh; } .ag-root-wrapper-body { width: 70vw !important; margin-left: auto !important; margin-right: 0 !important; } .bc-table-header { padding: 10px 16px; background-color: #f7f7f7; border-bottom: 1px solid #ccc; border-top-left-radius: 8px; border-top-right-radius: 8px; display: flex; justify-content: space-between; align-items: center; gap: 16px; } .bc-table-header h2 { margin: 0; font-size: 1.1em; color: #333; flex-shrink: 0; } .bc-filter-container { display: flex; align-items: center; gap: 8px; margin-left: auto; } .bc-button-group { display: flex; gap: 8px; flex-shrink: 0; } .bc-btn { border: none; padding: 6px 12px; border-radius: 5px; cursor: pointer; font-weight: bold; font-size: 0.9em; color: white; } .bc-btn-clear-all { background-color: #ef5350; } .bc-btn-clear-all:hover { background-color: #d32f2f; } .bc-btn-completed { background-color: #0288d1; } .bc-btn-completed:hover { background-color: #0277bd; } .bc-table-body { padding: 8px; overflow-y: auto; flex-grow: 1; min-height: 0; } .bc-table-body table { width: 100%; border-collapse: collapse; } .bc-table-body th, .bc-table-body td { border: 1px solid #ddd; padding: 4px 8px; text-align: left; font-size: 0.9em; } .bc-table-body th { background-color: #f2f2f2; } .bc-table-body .sortable-header { cursor: pointer; } .bc-table-body .sortable-header:hover { background-color: #e0e0e0; } #sort-indicator { font-size: 0.8em; margin-left: 4px; } .bc-table-body tbody tr { cursor: pointer; } .bc-table-body tbody tr:hover { background-color: #e8eaf6; } .bc-table-body tbody tr.barcode-found { background-color: #a5d6a7 !important; color: #1b5e20; } .bc-table-body tbody tr.barcode-found:hover { background-color: #81c784 !important; } .delete-barcode-btn { cursor: pointer; font-weight: bold; font-size: 18px; color: #ef5350; padding: 0 4px; border-radius: 4px; } .delete-barcode-btn:hover { color: white; background-color: #d32f2f; } `); // --- Start the script --- initialize(); setInterval(() => updateOrInsertBarcodeTable(), 4000); })();